From 75ef0db6de9e6c79c211df12573ea71c0e60e8c5 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:53:52 -0700 Subject: [PATCH 01/83] unpin xcode version --- .github/workflows/build-ipa.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/build-ipa.yml b/.github/workflows/build-ipa.yml index e8a6fffe..6e27bbb1 100644 --- a/.github/workflows/build-ipa.yml +++ b/.github/workflows/build-ipa.yml @@ -27,11 +27,6 @@ jobs: submodules: recursive fetch-depth: 2 - - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: '26.4.1' - - name: Check version change id: version_check run: | @@ -67,6 +62,7 @@ jobs: if: steps.version_check.outputs.should_build == 'true' || github.event_name == 'workflow_dispatch' run: | brew tap FelixHerrmann/tap + brew trust FelixHerrmann/tap brew install xcodegen swiftgen swift-package-list - name: Generate Xcode project From 4fb0504c3c068a35b8e82eff91141906d97361f1 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:51:21 -0700 Subject: [PATCH 02/83] feat(l10n): update "Pair your device" onboarding instructions --- MC1/Resources/Localization/de.lproj/Onboarding.strings | 2 +- MC1/Resources/Localization/en.lproj/Onboarding.strings | 2 +- MC1/Resources/Localization/es.lproj/Onboarding.strings | 2 +- MC1/Resources/Localization/fr.lproj/Onboarding.strings | 2 +- MC1/Resources/Localization/nl.lproj/Onboarding.strings | 2 +- MC1/Resources/Localization/pl.lproj/Onboarding.strings | 2 +- MC1/Resources/Localization/ru.lproj/Onboarding.strings | 2 +- MC1/Resources/Localization/uk.lproj/Onboarding.strings | 2 +- MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/MC1/Resources/Localization/de.lproj/Onboarding.strings b/MC1/Resources/Localization/de.lproj/Onboarding.strings index 8df7004b..70006257 100644 --- a/MC1/Resources/Localization/de.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/de.lproj/Onboarding.strings @@ -67,7 +67,7 @@ "deviceScan.title" = "Gerät koppeln"; /* Location: DeviceScanView.swift - Subtitle with pairing instructions */ -"deviceScan.subtitle" = "Schalte es ein, dann tippe auf \"Gerät hinzufügen\"."; +"deviceScan.subtitle" = "Schalte dein Funkgerät ein, trenne es von allen anderen Apps und Geräten und tippe dann auf \"Gerät hinzufügen\"."; /* Location: DeviceScanView.swift - Message shown when device is already paired */ "deviceScan.alreadyPaired" = "Dein Gerät ist bereits gekoppelt"; diff --git a/MC1/Resources/Localization/en.lproj/Onboarding.strings b/MC1/Resources/Localization/en.lproj/Onboarding.strings index 1b5ae67c..f57857eb 100644 --- a/MC1/Resources/Localization/en.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/en.lproj/Onboarding.strings @@ -67,7 +67,7 @@ "deviceScan.title" = "Pair your device"; /* Location: DeviceScanView.swift - Subtitle with pairing instructions */ -"deviceScan.subtitle" = "Power it on, then tap Add Device."; +"deviceScan.subtitle" = "Power your radio on, disconnect it from all other apps and devices, then tap Add Device."; /* Location: DeviceScanView.swift - Message shown when device is already paired */ "deviceScan.alreadyPaired" = "Your device is already paired"; diff --git a/MC1/Resources/Localization/es.lproj/Onboarding.strings b/MC1/Resources/Localization/es.lproj/Onboarding.strings index f4c6084d..d72b7cc4 100644 --- a/MC1/Resources/Localization/es.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/es.lproj/Onboarding.strings @@ -67,7 +67,7 @@ "deviceScan.title" = "Empareja tu dispositivo"; /* Location: DeviceScanView.swift - Subtitle with pairing instructions */ -"deviceScan.subtitle" = "Enciéndelo y toca Añadir dispositivo."; +"deviceScan.subtitle" = "Enciende tu radio, desconéctala de todas las demás apps y dispositivos y, a continuación, toca Añadir dispositivo."; /* Location: DeviceScanView.swift - Message shown when device is already paired */ "deviceScan.alreadyPaired" = "Tu dispositivo ya está emparejado"; diff --git a/MC1/Resources/Localization/fr.lproj/Onboarding.strings b/MC1/Resources/Localization/fr.lproj/Onboarding.strings index c9591deb..9ff9509c 100644 --- a/MC1/Resources/Localization/fr.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/fr.lproj/Onboarding.strings @@ -67,7 +67,7 @@ "deviceScan.title" = "Associer votre appareil"; /* Location: DeviceScanView.swift - Subtitle with pairing instructions */ -"deviceScan.subtitle" = "Allumez-le, puis appuyez sur Ajouter un appareil."; +"deviceScan.subtitle" = "Allumez votre radio, déconnectez-la de toutes les autres applications et de tous les autres appareils, puis appuyez sur Ajouter un appareil."; /* Location: DeviceScanView.swift - Message shown when device is already paired */ "deviceScan.alreadyPaired" = "Votre appareil est déjà associé"; diff --git a/MC1/Resources/Localization/nl.lproj/Onboarding.strings b/MC1/Resources/Localization/nl.lproj/Onboarding.strings index 948136dd..60f27371 100644 --- a/MC1/Resources/Localization/nl.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/nl.lproj/Onboarding.strings @@ -67,7 +67,7 @@ "deviceScan.title" = "Koppel je apparaat"; /* Location: DeviceScanView.swift - Subtitle with pairing instructions */ -"deviceScan.subtitle" = "Schakel het in en tik op Apparaat toevoegen."; +"deviceScan.subtitle" = "Schakel je radio in, koppel deze los van alle andere apps en apparaten en tik vervolgens op Apparaat toevoegen."; /* Location: DeviceScanView.swift - Message shown when device is already paired */ "deviceScan.alreadyPaired" = "Je apparaat is al gekoppeld"; diff --git a/MC1/Resources/Localization/pl.lproj/Onboarding.strings b/MC1/Resources/Localization/pl.lproj/Onboarding.strings index aa509818..4104fa16 100644 --- a/MC1/Resources/Localization/pl.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/pl.lproj/Onboarding.strings @@ -67,7 +67,7 @@ "deviceScan.title" = "Sparuj urządzenie"; /* Location: DeviceScanView.swift - Subtitle with pairing instructions */ -"deviceScan.subtitle" = "Włącz je, a następnie dotknij Dodaj urządzenie."; +"deviceScan.subtitle" = "Włącz radio, odłącz je od wszystkich innych aplikacji i urządzeń, a następnie dotknij Dodaj urządzenie."; /* Location: DeviceScanView.swift - Message shown when device is already paired */ "deviceScan.alreadyPaired" = "Twoje urządzenie jest już sparowane"; diff --git a/MC1/Resources/Localization/ru.lproj/Onboarding.strings b/MC1/Resources/Localization/ru.lproj/Onboarding.strings index 6bdea8c0..cb903ad2 100644 --- a/MC1/Resources/Localization/ru.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/ru.lproj/Onboarding.strings @@ -67,7 +67,7 @@ "deviceScan.title" = "Сопряжение устройства"; /* Location: DeviceScanView.swift - Subtitle with pairing instructions */ -"deviceScan.subtitle" = "Включите его, затем нажмите «Добавить устройство»."; +"deviceScan.subtitle" = "Включите рацию, отключите её от всех других приложений и устройств, а затем нажмите «Добавить устройство»."; /* Location: DeviceScanView.swift - Message shown when device is already paired */ "deviceScan.alreadyPaired" = "Ваше устройство уже сопряжено"; diff --git a/MC1/Resources/Localization/uk.lproj/Onboarding.strings b/MC1/Resources/Localization/uk.lproj/Onboarding.strings index c42e0ac4..460a30cb 100644 --- a/MC1/Resources/Localization/uk.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/uk.lproj/Onboarding.strings @@ -67,7 +67,7 @@ "deviceScan.title" = "Сполучення пристрою"; /* Location: DeviceScanView.swift - Subtitle with pairing instructions */ -"deviceScan.subtitle" = "Увімкніть його, а потім торкніться «Додати пристрій»."; +"deviceScan.subtitle" = "Увімкніть рацію, від'єднайте її від усіх інших додатків і пристроїв, а потім торкніться «Додати пристрій»."; /* Location: DeviceScanView.swift - Message shown when device is already paired */ "deviceScan.alreadyPaired" = "Ваш пристрій вже сполучено"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings b/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings index 72236877..ceb3a4cd 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings @@ -65,7 +65,7 @@ "deviceScan.title" = "配对您的设备"; /* Location: DeviceScanView.swift - Subtitle with pairing instructions */ -"deviceScan.subtitle" = "开启设备,然后点击「添加设备」。"; +"deviceScan.subtitle" = "开启设备,断开它与所有其他应用和设备的连接,然后点击「添加设备」。"; /* Location: DeviceScanView.swift - Message shown when device is already paired */ "deviceScan.alreadyPaired" = "您的设备已配对"; From 699aa93f346801fc14df2b83a8cc81cef6314f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Baham=C3=B3ndez-Honores?= Date: Mon, 29 Jun 2026 10:39:58 -0400 Subject: [PATCH 03/83] feat(radio): add Chile region radio preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Chile (CL) community radio preset so devices in Chile get a recommended configuration: 927.875 MHz, 62.5 kHz bandwidth, SF 8, CR 5. - Add a `southAmerica` case to `RadioRegion` (short code "SA") so Chile is grouped under its own "South America" section in the preset picker instead of being shoehorned into another continent. - Register the `cl` preset in `RadioPresets.all`, available to country CL. - Map CL → .southAmerica in `RegionalAreas.continents` and add Chile to the selectable countries catalog so it resolves via the country tier of `recommended(for:)`. - Prefer South America first in `regionsForLocale` for a CL locale. --- .../MC1Services/Services/RadioPresets.swift | 9 +++++++++ .../MC1Services/Services/RegionalAreas.swift | 9 ++++++--- .../RadioPresetRecommendationTests.swift | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/MC1Services/Sources/MC1Services/Services/RadioPresets.swift b/MC1Services/Sources/MC1Services/Services/RadioPresets.swift index 786a8113..ea384138 100644 --- a/MC1Services/Sources/MC1Services/Services/RadioPresets.swift +++ b/MC1Services/Sources/MC1Services/Services/RadioPresets.swift @@ -3,6 +3,7 @@ import Foundation /// Geographic regions for radio preset filtering public enum RadioRegion: String, CaseIterable, Sendable { case northAmerica = "North America" + case southAmerica = "South America" case europe = "Europe" case oceania = "Oceania" case asia = "Asia" @@ -22,6 +23,8 @@ public enum RadioRegion: String, CaseIterable, Sendable { return [.europe, .northAmerica, .oceania, .asia] case "VN", "TH", "MY", "SG", "PH", "ID": return [.asia, .oceania, .europe, .northAmerica] + case "CL": + return [.southAmerica, .northAmerica, .europe, .oceania, .asia] default: return RadioRegion.allCases } @@ -31,6 +34,7 @@ public enum RadioRegion: String, CaseIterable, Sendable { public var shortCode: String { switch self { case .northAmerica: return "NA" + case .southAmerica: return "SA" case .europe: return "EU" case .oceania: return "AU" case .asia: return "AS" @@ -161,6 +165,11 @@ public enum RadioPresets { "ventura", "imperial", "kern", "santa barbara", "san luis obispo", ])), + // South America + RadioPreset(id: "cl", name: "Chile", region: .southAmerica, + frequencyMHz: 927.875, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, + availability: .countries(["CL"])), + // Asia RadioPreset(id: "vn-narrow", name: "Vietnam (Narrow)", region: .asia, frequencyMHz: 920.250, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, diff --git a/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift b/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift index b947eca0..bcadfda9 100644 --- a/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift +++ b/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift @@ -47,12 +47,14 @@ public enum RegionalAreas { nameKey: "region.subdivision.AU-WA"), ] - /// ISO α-2 → `RadioRegion` mapping. Mexico (MX), Africa, and - /// South America are intentionally absent — these countries fall through - /// `recommended(for:)` to the empty-region fallback. + /// ISO α-2 → `RadioRegion` mapping. Mexico (MX) and Africa are intentionally + /// absent — those countries fall through `recommended(for:)` to the + /// empty-region fallback. South America is currently limited to Chile (CL). public static let continents: [String: RadioRegion] = [ // North America "US": .northAmerica, "CA": .northAmerica, + // South America + "CL": .southAmerica, // Europe "GB": .europe, "IE": .europe, "DE": .europe, "FR": .europe, "IT": .europe, "ES": .europe, "PT": .europe, "NL": .europe, @@ -76,6 +78,7 @@ public enum RegionalAreas { public static let countries: [Country] = [ Country(id: "US", subdivisions: usSubdivisions), Country(id: "CA", subdivisions: nil), + Country(id: "CL", subdivisions: nil), Country(id: "AU", subdivisions: auSubdivisions), Country(id: "NZ", subdivisions: nil), Country(id: "GB", subdivisions: nil), diff --git a/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift b/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift index 6477cf07..0a472d85 100644 --- a/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift @@ -72,6 +72,22 @@ struct RadioPresetRecommendationTests { #expect(RadioPresets.recommended(for: region)?.id == "nl") } + @Test("Chile → cl") + func chileGetsCL() { + let region = RegionSelection(countryCode: "CL", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "cl") + } + + @Test("Chile preset carries the expected radio parameters") + func chilePresetParameters() throws { + let preset = try #require(RadioPresets.all.first(where: { $0.id == "cl" })) + #expect(preset.frequencyMHz == 927.875) + #expect(preset.bandwidthKHz == 62.5) + #expect(preset.spreadingFactor == 8) + #expect(preset.codingRate == 5) + #expect(preset.region == .southAmerica) + } + // MARK: - Tier 3 (continent) @Test("Berlin (DE) → eu-narrow (priority 110 beats eu-lr)") From a45068b8145e04b50c4e5997594c1b14ffac94fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Baham=C3=B3ndez-Honores?= Date: Mon, 29 Jun 2026 10:58:23 -0400 Subject: [PATCH 04/83] docs(radio): cite MeshChile as the source for the Chile preset --- MC1Services/Sources/MC1Services/Services/RadioPresets.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/MC1Services/Sources/MC1Services/Services/RadioPresets.swift b/MC1Services/Sources/MC1Services/Services/RadioPresets.swift index ea384138..3f6e753f 100644 --- a/MC1Services/Sources/MC1Services/Services/RadioPresets.swift +++ b/MC1Services/Sources/MC1Services/Services/RadioPresets.swift @@ -166,6 +166,7 @@ public enum RadioPresets { ])), // South America + // Chile: community-standard settings from the MeshChile network (https://meshchile.cl). RadioPreset(id: "cl", name: "Chile", region: .southAmerica, frequencyMHz: 927.875, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, availability: .countries(["CL"])), From b66def007aa04fffb0fec12df42889704c92e035 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:02:07 -0700 Subject: [PATCH 05/83] fix(remote-nodes): auto-heal login when repeater contact is missing from radio When a contact is in the app's database but missing from the radio's firmware contact table (backup restore, radio reset), CMD_SEND_LOGIN comes back ERR_CODE_NOT_FOUND (0x02), which was showing as "Item not found on device." A blank password is not the cause; firmware accepts empty passwords without complaint. The fix catches notFound during login, pushes the local contact to the radio with the flood-route sentinel via addContact (CMD 0x09), syncs the local row so keep-alive routing stays consistent, then retries. A full contact table now returns a specific error with an actionable message rather than the generic device error. - Widen RemoteNodeService.session to include ContactSessionOps so login can call session.addContact (zero call-site changes; MeshCoreSession already conforms via MeshCoreSessionProtocol) - Add sendLoginHealingIfNeeded and addLocalContactToRadio to RemoteNodeService+Login.swift; preserve RemoteNodeError through the catch - Add ContactDTO.floodedContactFrame(asOf:); refactor ContactService.resetPath to use it - Add RemoteNodeError.radioContactsFull, errorDescription, userFacingMessage, and the L10n key across all 9 locales - Extend MockMeshCoreSession with RemoteAccessSessionOps and a scriptable sendLogin stub - Add RemoteNodeLoginHealTests: heal+retry, table-full, no-local-contact --- .../RemoteNodeError+UserFacingMessage.swift | 2 + MC1/Resources/Generated/L10n.swift | 4 +- .../Localization/de.lproj/Localizable.strings | 3 + .../Localization/en.lproj/Localizable.strings | 3 + .../Localization/es.lproj/Localizable.strings | 3 + .../Localization/fr.lproj/Localizable.strings | 3 + .../Localization/nl.lproj/Localizable.strings | 3 + .../Localization/pl.lproj/Localizable.strings | 3 + .../Localization/ru.lproj/Localizable.strings | 3 + .../Localization/uk.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + .../MC1Services/Errors/RemoteNodeError.swift | 3 + .../Sources/MC1Services/Models/Contact.swift | 19 ++ .../MC1Services/Services/ContactService.swift | 13 +- .../Services/RemoteNodeService+Login.swift | 64 ++++- .../Services/RemoteNodeService.swift | 4 +- .../Mocks/MockMeshCoreSession.swift | 81 ++++++ .../Mocks/MockPersistenceStore.swift | 2 +- .../Services/ContactServiceTests.swift | 34 +++ .../Services/RemoteNodeLoginHealTests.swift | 255 ++++++++++++++++++ 20 files changed, 489 insertions(+), 19 deletions(-) create mode 100644 MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift diff --git a/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift b/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift index 47322716..e43064af 100644 --- a/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift @@ -27,6 +27,8 @@ extension RemoteNodeError { L10n.Localizable.Error.RemoteNode.pathDiscoveryFailed case .contactNotFound: L10n.Localizable.Error.RemoteNode.contactNotFound + case .radioContactsFull: + L10n.Localizable.Error.RemoteNode.radioContactsFull case .cancelled: L10n.Localizable.Error.RemoteNode.cancelled case .sessionError(let error): diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 8173acdb..8b590df5 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2627,6 +2627,8 @@ public enum L10n { public static let pathDiscoveryFailed = L10n.tr("Localizable", "error.remoteNode.pathDiscoveryFailed", fallback: "Failed to establish direct path") /// Location: RemoteNodeError+UserFacingMessage.swift - Remote node rejected the operation public static let permissionDenied = L10n.tr("Localizable", "error.remoteNode.permissionDenied", fallback: "Permission denied") + /// Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login + public static let radioContactsFull = L10n.tr("Localizable", "error.remoteNode.radioContactsFull", fallback: "Your radio is out of contact slots. Remove a contact you no longer need, then retry sign-in.") /// Location: RemoteNodeError+UserFacingMessage.swift - Sending to the remote node failed public static let sendFailed = L10n.tr("Localizable", "error.remoteNode.sendFailed", fallback: "Failed to send.") /// Location: RemoteNodeError+UserFacingMessage.swift - No stored session for the remote node @@ -2941,7 +2943,7 @@ public enum L10n { /// Location: DeviceScanView.swift - Button to retry connection after other-app conflict public static let retryConnection = L10n.tr("Onboarding", "deviceScan.retryConnection", fallback: "Retry Connection") /// Location: DeviceScanView.swift - Subtitle with pairing instructions - public static let subtitle = L10n.tr("Onboarding", "deviceScan.subtitle", fallback: "Power it on, then tap Add Device.") + public static let subtitle = L10n.tr("Onboarding", "deviceScan.subtitle", fallback: "Power your radio on, disconnect it from all other apps and devices, then tap Add Device.") /// Location: DeviceScanView.swift - Screen title for device pairing public static let title = L10n.tr("Onboarding", "deviceScan.title", fallback: "Pair your device") public enum DemoModeAlert { diff --git a/MC1/Resources/Localization/de.lproj/Localizable.strings b/MC1/Resources/Localization/de.lproj/Localizable.strings index c26f421e..79cc0f42 100644 --- a/MC1/Resources/Localization/de.lproj/Localizable.strings +++ b/MC1/Resources/Localization/de.lproj/Localizable.strings @@ -538,6 +538,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Kontakt nicht in der Datenbank gefunden"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "Auf deinem Funkgerät sind keine Kontaktplätze mehr frei. Entferne einen Kontakt, den du nicht mehr brauchst, und versuche dann erneut, dich anzumelden."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Anmeldung abgebrochen"; diff --git a/MC1/Resources/Localization/en.lproj/Localizable.strings b/MC1/Resources/Localization/en.lproj/Localizable.strings index 9ac4ed35..cb1a2b00 100644 --- a/MC1/Resources/Localization/en.lproj/Localizable.strings +++ b/MC1/Resources/Localization/en.lproj/Localizable.strings @@ -546,6 +546,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Contact not found in database"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "Your radio is out of contact slots. Remove a contact you no longer need, then retry sign-in."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Login cancelled"; diff --git a/MC1/Resources/Localization/es.lproj/Localizable.strings b/MC1/Resources/Localization/es.lproj/Localizable.strings index 27b3b679..e79720e5 100644 --- a/MC1/Resources/Localization/es.lproj/Localizable.strings +++ b/MC1/Resources/Localization/es.lproj/Localizable.strings @@ -542,6 +542,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Contacto no encontrado en la base de datos"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "Tu radio se ha quedado sin espacios para contactos. Elimina un contacto que ya no necesites y vuelve a intentar iniciar sesión."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Inicio de sesión cancelado"; diff --git a/MC1/Resources/Localization/fr.lproj/Localizable.strings b/MC1/Resources/Localization/fr.lproj/Localizable.strings index e34dffe0..95bde14e 100644 --- a/MC1/Resources/Localization/fr.lproj/Localizable.strings +++ b/MC1/Resources/Localization/fr.lproj/Localizable.strings @@ -551,6 +551,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Contact introuvable dans la base de données"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "Votre radio n'a plus d'emplacements de contact disponibles. Supprimez un contact dont vous n'avez plus besoin, puis réessayez de vous connecter."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Connexion annulée"; diff --git a/MC1/Resources/Localization/nl.lproj/Localizable.strings b/MC1/Resources/Localization/nl.lproj/Localizable.strings index ed699375..62b11b2d 100644 --- a/MC1/Resources/Localization/nl.lproj/Localizable.strings +++ b/MC1/Resources/Localization/nl.lproj/Localizable.strings @@ -553,6 +553,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Contact niet gevonden in de database"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "Je radio heeft geen contactplekken meer vrij. Verwijder een contact dat je niet meer nodig hebt en probeer daarna opnieuw in te loggen."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Inloggen geannuleerd"; diff --git a/MC1/Resources/Localization/pl.lproj/Localizable.strings b/MC1/Resources/Localization/pl.lproj/Localizable.strings index 1d26b832..034ae5dc 100644 --- a/MC1/Resources/Localization/pl.lproj/Localizable.strings +++ b/MC1/Resources/Localization/pl.lproj/Localizable.strings @@ -550,6 +550,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Nie znaleziono kontaktu w bazie danych"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "W Twoim radiu zabrakło miejsc na kontakty. Usuń kontakt, którego już nie potrzebujesz, a następnie spróbuj zalogować się ponownie."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Logowanie anulowane"; diff --git a/MC1/Resources/Localization/ru.lproj/Localizable.strings b/MC1/Resources/Localization/ru.lproj/Localizable.strings index 7fc3a6b2..27b25ae2 100644 --- a/MC1/Resources/Localization/ru.lproj/Localizable.strings +++ b/MC1/Resources/Localization/ru.lproj/Localizable.strings @@ -549,6 +549,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Контакт не найден в базе данных"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "На вашей рации не осталось слотов для контактов. Удалите ненужный контакт и повторите вход."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Вход отменён"; diff --git a/MC1/Resources/Localization/uk.lproj/Localizable.strings b/MC1/Resources/Localization/uk.lproj/Localizable.strings index e4acd959..8bda863a 100644 --- a/MC1/Resources/Localization/uk.lproj/Localizable.strings +++ b/MC1/Resources/Localization/uk.lproj/Localizable.strings @@ -546,6 +546,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Контакт не знайдено в базі даних"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "На вашій рації не залишилося слотів для контактів. Видаліть непотрібний контакт і повторіть вхід."; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "Вхід скасовано"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings b/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings index baeb0d5e..342f73d4 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings @@ -546,6 +546,9 @@ /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "数据库中未找到联系人"; +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "你的设备联系人槽位已满。请删除一个不再需要的联系人,然后重新登录。"; + /* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ "error.remoteNode.cancelled" = "登录已取消"; diff --git a/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift b/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift index 4b6f8cd2..d54766a2 100644 --- a/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift +++ b/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift @@ -13,6 +13,7 @@ public enum RemoteNodeError: Error, LocalizedError, Sendable { case floodRouted // Keep-alive requires direct path case pathDiscoveryFailed case contactNotFound + case radioContactsFull // Radio's contact table is full; cannot auto-add a missing node case cancelled // Login cancelled due to duplicate attempt or shutdown case sessionError(MeshCoreError) @@ -40,6 +41,8 @@ public enum RemoteNodeError: Error, LocalizedError, Sendable { return "Failed to establish direct path" case .contactNotFound: return "Contact not found in database" + case .radioContactsFull: + return "Radio contact list is full" case .cancelled: return "Login cancelled" case .sessionError(let error): diff --git a/MC1Services/Sources/MC1Services/Models/Contact.swift b/MC1Services/Sources/MC1Services/Models/Contact.swift index 5611ffae..f2ce7724 100644 --- a/MC1Services/Sources/MC1Services/Models/Contact.swift +++ b/MC1Services/Sources/MC1Services/Models/Contact.swift @@ -505,4 +505,23 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, lastModified: lastModified ) } + + /// A frame for this contact with the path reset to flood routing. A contact the radio doesn't + /// know has no valid stored path, so flooding is the only route that can reach it; the firmware + /// rediscovers the direct path from the first response. + public func floodedContactFrame(asOf now: UInt32) -> ContactFrame { + ContactFrame( + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: flags, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: name, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: now + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/ContactService.swift b/MC1Services/Sources/MC1Services/Services/ContactService.swift index 6475a44e..5ec928dc 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactService.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactService.swift @@ -281,18 +281,7 @@ public actor ContactService { // Update local contact to show flood routing if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { - let frame = ContactFrame( - publicKey: contact.publicKey, - type: contact.type, - flags: contact.flags, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: contact.name, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: UInt32(Date().timeIntervalSince1970) - ) + let frame = contact.floodedContactFrame(asOf: UInt32(Date().timeIntervalSince1970)) _ = try await dataStore.saveContact(radioID: radioID, from: frame) } } catch let error as MeshCoreError { diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift index e42d5f06..5fe064a5 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift @@ -56,12 +56,21 @@ extension RemoteNodeService { let timeoutTask = Task { [self] in let sentInfo: MessageSentInfo do { - sentInfo = try await session.sendLogin(to: remoteSession.publicKey, password: pwd) + sentInfo = try await sendLoginHealingIfNeeded( + publicKey: remoteSession.publicKey, + radioID: remoteSession.radioID, + password: pwd + ) } catch { + // Cancellation means this task no longer owns the continuation: whoever + // cancelled it (an overlapping login for this prefix, or cancelPendingLogin + // on outer-task cancellation) has already resumed and removed it. + guard !Task.isCancelled else { return } pendingLoginTimeoutTasks.removeValue(forKey: prefix) if let pending = pendingLogins.removeValue(forKey: prefix) { - let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) - pending.resume(throwing: RemoteNodeError.sessionError(meshError)) + let rnError = (error as? RemoteNodeError) + ?? .sessionError(error as? MeshCoreError ?? .connectionLost(underlying: error)) + pending.resume(throwing: rnError) } return } @@ -92,6 +101,55 @@ extension RemoteNodeService { } } + /// Sends the login; if the radio reports the contact is missing from its table, pushes the local + /// copy (flood-routed) and retries once. A contact can be in the app database but absent on the + /// radio after a backup restore or a radio swap, which the firmware answers with notFound (0x02). + /// The parked login continuation is covered here by the session commands' own timeouts, not the + /// login timeout, which only starts after this returns. + func sendLoginHealingIfNeeded( + publicKey: Data, + radioID: UUID, + password: String + ) async throws -> MessageSentInfo { + do { + return try await session.sendLogin(to: publicKey, password: password) + } catch let error as MeshCoreError { + guard case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue else { + throw error + } + try Task.checkCancellation() + try await addLocalContactToRadio(publicKey: publicKey, radioID: radioID) + // Bounded to a single retry: a second notFound after a successful add is a firmware + // inconsistency, left to propagate rather than loop. + return try await session.sendLogin(to: publicKey, password: password) + } + } + + /// Pushes the local contact to the radio's contact table with flood routing, then reconciles the + /// local row so keep-alive routing agrees with the radio. + private func addLocalContactToRadio(publicKey: Data, radioID: UUID) async throws { + guard let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) else { + throw RemoteNodeError.contactNotFound + } + let frame = contact.floodedContactFrame(asOf: UInt32(Date().timeIntervalSince1970)) + do { + try await session.addContact(frame.toMeshContact()) + } catch let error as MeshCoreError { + guard case .deviceError(let code) = error, code == ProtocolError.tableFull.rawValue else { + throw error + } + throw RemoteNodeError.radioContactsFull + } + // The radio is healed; failing to sync the local row is a bookkeeping issue that must not + // abort the login retry. Keep-alive routing self-corrects on the next contact refresh. + do { + _ = try await dataStore.saveContact(radioID: radioID, from: frame) + } catch { + logger.warning("Re-added contact to radio but failed to sync local row: \(error)") + } + logger.info("Re-added missing contact to radio during login") + } + /// Handle login result push from device. func handleLoginResult(_ result: LoginResult, fromPublicKeyPrefix: Data) async { guard fromPublicKeyPrefix.count >= 6 else { diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift index 405a04d5..92add9cf 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift @@ -17,7 +17,7 @@ public actor RemoteNodeService { // MARK: - Properties - let session: any RemoteAccessSessionOps & SessionEventStreaming + let session: any RemoteAccessSessionOps & SessionEventStreaming & ContactSessionOps let dataStore: any PersistenceStoreProtocol let keychainService: KeychainService let logger = PersistentLogger(subsystem: "com.mc1", category: "RemoteNode") @@ -90,7 +90,7 @@ public actor RemoteNodeService { // MARK: - Initialization init( - session: any RemoteAccessSessionOps & SessionEventStreaming, + session: any RemoteAccessSessionOps & SessionEventStreaming & ContactSessionOps, dataStore: any PersistenceStoreProtocol, keychainService: KeychainService ) { diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift index b01570a7..046981f4 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift @@ -121,6 +121,9 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol { /// Result to return from getMessage public var stubbedGetMessageResult: Result = .success(.noMoreMessages) + /// Results to return from successive `sendLogin` calls, consumed FIFO. + public var stubbedSendLoginResults: [Result] = [] + // MARK: - Recorded Invocations public struct SendMessageInvocation: Sendable, Equatable { @@ -140,6 +143,11 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol { public let contact: MeshContact } + public struct SendLoginInvocation: Sendable, Equatable { + public let destination: Data + public let password: String + } + public struct SetChannelInvocation: Sendable, Equatable { public let index: UInt8 public let name: String @@ -156,6 +164,7 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol { public private(set) var getContactsInvocations: [Date?] = [] public private(set) var getContactPublicKeys: [Data] = [] public private(set) var addContactInvocations: [AddContactInvocation] = [] + public private(set) var sendLoginInvocations: [SendLoginInvocation] = [] public private(set) var removeContactPublicKeys: [Data] = [] public private(set) var resetPathPublicKeys: [Data] = [] public private(set) var sendPathDiscoveryDestinations: [Data] = [] @@ -184,6 +193,16 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol { currentSelfInfo = selfInfo } + /// Sets the results returned by successive `sendLogin` calls (isolated setter). + public func setSendLoginResults(_ results: [Result]) { + stubbedSendLoginResults = results + } + + /// Sets the error thrown by `addContact` (isolated setter). + public func setAddContactError(_ error: Error?) { + stubbedAddContactError = error + } + // MARK: - Protocol Methods public func sendMessage(to destination: Data, text: String, timestamp: Date, attempt: UInt8) async throws -> MessageSentInfo { @@ -323,6 +342,9 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol { // MARK: - Test Helpers + /// Error thrown by remote-access methods that a given test has not configured. + enum NotStubbed: Error { case method(String) } + /// Resets all recorded invocations public func reset() { sendMessageInvocations = [] @@ -330,6 +352,7 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol { getContactsInvocations = [] getContactPublicKeys = [] addContactInvocations = [] + sendLoginInvocations = [] removeContactPublicKeys = [] resetPathPublicKeys = [] sendPathDiscoveryDestinations = [] @@ -341,3 +364,61 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol { stopAutoMessageFetchingCallCount = 0 } } + +// MARK: - RemoteAccessSessionOps + +extension MockMeshCoreSession: RemoteAccessSessionOps { + + public func sendLogin(to destination: Data, password: String) async throws -> MessageSentInfo { + sendLoginInvocations.append(SendLoginInvocation(destination: destination, password: password)) + guard !stubbedSendLoginResults.isEmpty else { throw NotStubbed.method("sendLogin") } + switch stubbedSendLoginResults.removeFirst() { + case .success(let info): return info + case .failure(let error): throw error + } + } + + public func sendLogout(to destination: Data) async throws {} + + public func sendCommand(to destination: Data, command: String, timestamp: Date) async throws -> MessageSentInfo { + throw NotStubbed.method("sendCommand") + } + + public func sendKeepAlive(to publicKey: Data, syncSince: UInt32) async throws -> MessageSentInfo { + throw NotStubbed.method("sendKeepAlive") + } + + public func requestOwnerInfo(from publicKey: Data) async throws -> OwnerInfoResponse { + throw NotStubbed.method("requestOwnerInfo") + } + + public func requestStatus(from publicKey: Data, type: ContactType) async throws -> StatusResponse { + throw NotStubbed.method("requestStatus") + } + + public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { + throw NotStubbed.method("requestTelemetry") + } + + public func requestNeighbours( + from publicKey: Data, + count: UInt8, + offset: UInt16, + orderBy: UInt8, + pubkeyPrefixLength: UInt8 + ) async throws -> NeighboursResponse { + throw NotStubbed.method("requestNeighbours") + } + + public func sendMessageWithRetry( + to destination: Data, + text: String, + timestamp: Date, + maxAttempts: Int, + floodAfter: Int, + maxFloodAttempts: Int, + timeout: TimeInterval? + ) async throws -> MessageSentInfo? { + throw NotStubbed.method("sendMessageWithRetry") + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index 4f1c93e7..f4ea2114 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -527,7 +527,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { radioID: radioID, publicKey: frame.publicKey, name: frame.name, - typeRawValue: frame.type.rawValue, + typeRawValue: frame.typeRawValue, flags: frame.flags, outPathLength: frame.outPathLength, outPath: frame.outPath, diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift index c0dc1114..99feb68e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift @@ -789,4 +789,38 @@ struct ContactServiceTests { #expect(invocations[0].reason == .unblocked) } + // MARK: - Reset Path + + @Test("resetPath flood-routes the contact while preserving an unmodeled type byte") + func resetPathPreservesUnmodeledTypeByte() async throws { + let radioID = UUID() + // The real store round-trips the raw type byte; the mock store normalizes it, so this uses + // a real PersistenceStore to observe what resetPath actually persists. + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let unmodeledType: UInt8 = 0x7F + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: testPublicKey, + typeRawValue: unmodeledType, + outPathLength: 2, + outPath: testOutPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + let service = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + try await service.resetPath(radioID: radioID, publicKey: testPublicKey) + + #expect(await session.resetPathPublicKeys == [testPublicKey]) + let reset = try await dataStore.fetchContact(radioID: radioID, publicKey: testPublicKey) + #expect(reset?.isFloodRouted == true) + #expect(reset?.typeRawValue == unmodeledType) + } + } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift new file mode 100644 index 00000000..7b6c885d --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift @@ -0,0 +1,255 @@ +import Foundation +import Testing +import MeshCore +@testable import MC1Services + +/// Covers the login auto-heal path: when the radio reports a contact missing from its table +/// (firmware notFound, 0x02) during `sendLogin`, the service pushes the local contact to the radio +/// and retries once. +@Suite("RemoteNodeService login heal") +struct RemoteNodeLoginHealTests { + + private static let publicKey = Data(repeating: 0xCC, count: 32) + private static let directPath = Data([0x01, 0x02]) + + @Test("notFound on login pushes the local contact to the radio and retries") + func healsMissingContactThenRetries() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // A direct-routed contact present locally but absent from the radio's table. + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), + .success(MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + + // The radio was sent the missing contact exactly once, and login was retried. + #expect(await session.addContactInvocations.count == 1) + #expect(await session.sendLoginInvocations.count == 2) + // The pushed contact is the right node, flood-routed, and the local row is reconciled to match. + let pushed = await session.addContactInvocations.first?.contact + #expect(pushed?.publicKey == Self.publicKey) + #expect(pushed?.outPathLength == PacketBuilder.floodPathSentinel) + let healed = try await dataStore.fetchContact(radioID: radioID, publicKey: Self.publicKey) + #expect(healed?.isFloodRouted == true) + } + + @Test("healing preserves a contact type byte not modeled by ContactType") + func healingPreservesUnmodeledTypeByte() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // A type byte newer firmware might use that ContactType does not model; it must reach the + // radio verbatim rather than being coerced to .chat by the typed accessor. + let unmodeledType: UInt8 = 0x7F + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + typeRawValue: unmodeledType, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), + .success(MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + + let pushed = await session.addContactInvocations.first?.contact + #expect(pushed?.typeRawValue == unmodeledType) + } + + @Test("a non-notFound login error skips healing and is not retried") + func nonNotFoundErrorSkipsHealing() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + // tableFull shares its code with the add-contact path; only notFound from sendLogin may heal. + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: MeshCoreError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + } + guard case .deviceError(let code) = thrown, code == ProtocolError.tableFull.rawValue else { + Issue.record("expected the original tableFull device error, got \(String(describing: thrown))") + return + } + #expect(await session.addContactInvocations.isEmpty) + #expect(await session.sendLoginInvocations.count == 1) + } + + @Test("a second notFound after re-adding the contact surfaces the device error unhealed") + func secondNotFoundAfterHealPropagates() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: MeshCoreError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + } + guard case .deviceError(let code) = thrown, code == ProtocolError.notFound.rawValue else { + Issue.record("expected notFound device error, got \(String(describing: thrown))") + return + } + // The contact was re-added once, then the retry's second notFound surfaced without re-adding. + #expect(await session.addContactInvocations.count == 1) + #expect(await session.sendLoginInvocations.count == 2) + } + + @Test("a full radio contact table surfaces radioContactsFull") + func tableFullSurfacesActionableError() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + await session.setAddContactError(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: RemoteNodeError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + } + guard case .radioContactsFull = thrown else { + Issue.record("expected radioContactsFull, got \(String(describing: thrown))") + return + } + // The add was attempted once and the retry never fired, so login stops at the full table. + #expect(await session.addContactInvocations.count == 1) + #expect(await session.sendLoginInvocations.count == 1) + } + + @Test("a contact absent from the local store cannot be healed") + func missingLocalContactCannotBeHealed() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: RemoteNodeError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + } + guard case .contactNotFound = thrown else { + Issue.record("expected contactNotFound, got \(String(describing: thrown))") + return + } + #expect(await session.addContactInvocations.isEmpty) + } + + @Test("login() surfaces radioContactsFull through the continuation instead of a generic session error") + func loginPreservesTypedHealError() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID, publicKey: Self.publicKey) + try await dataStore.saveRemoteNodeSessionDTO(remoteSession) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + // The radio is missing the contact (notFound), and re-adding it hits a full table. The error the + // public login() resolves the continuation with must stay the typed RemoteNodeError.radioContactsFull, + // not collapse into the generic .sessionError that the catch falls back to. + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + await session.setAddContactError(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: RemoteNodeError.self) { + _ = try await service.login(sessionID: remoteSession.id, password: "") + } + guard case .radioContactsFull = thrown else { + Issue.record("expected radioContactsFull to propagate unwrapped, got \(String(describing: thrown))") + return + } + #expect(await session.sendLoginInvocations.count == 1) + #expect(await session.addContactInvocations.count == 1) + } +} From 3bc3bb4027f684a8137412f8a4fbd511e958be0f Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:07:26 -0700 Subject: [PATCH 06/83] feat(neighbors): add repeater neighbors map - "View on Map" opens a full-screen map with the repeater centered and each located neighbor pinned by an SNR-colored link line - Map controls toolbar (north-lock, location, layers, labels) plus center-all that fits the repeater and its neighbors - Auto-zoom on appear, framed inside the chrome, re-issued once the map style finishes loading - Neighbors without a usable location surface in a top pill that pushes their list - NeighborSNRMapBuilder with tests computes the points, links, and fit region - Add RemoteNodeSessionDTO location helpers and shared SNR line/badge styling - L10n strings across all 9 locales --- MC1/Resources/Generated/L10n.swift | 12 + .../Localization/de.lproj/RemoteNodes.strings | 17 + .../de.lproj/RemoteNodes.stringsdict | 22 ++ .../Localization/en.lproj/RemoteNodes.strings | 17 + .../en.lproj/RemoteNodes.stringsdict | 22 ++ .../Localization/es.lproj/RemoteNodes.strings | 17 + .../es.lproj/RemoteNodes.stringsdict | 22 ++ .../Localization/fr.lproj/RemoteNodes.strings | 17 + .../fr.lproj/RemoteNodes.stringsdict | 22 ++ .../Localization/nl.lproj/RemoteNodes.strings | 17 + .../nl.lproj/RemoteNodes.stringsdict | 22 ++ .../Localization/pl.lproj/RemoteNodes.strings | 17 + .../pl.lproj/RemoteNodes.stringsdict | 32 ++ .../Localization/ru.lproj/RemoteNodes.strings | 17 + .../ru.lproj/RemoteNodes.stringsdict | 32 ++ .../Localization/uk.lproj/RemoteNodes.strings | 17 + .../uk.lproj/RemoteNodes.stringsdict | 32 ++ .../zh-Hans.lproj/RemoteNodes.strings | 17 + .../zh-Hans.lproj/RemoteNodes.stringsdict | 20 ++ MC1/Utilities/RepeaterResolver.swift | 72 +++- MC1/Views/Map/MapLine+SNR.swift | 70 ++++ .../Repeaters/NeighborSNRMapBuilder.swift | 124 +++++++ .../Repeaters/NeighborSNRMapView.swift | 194 +++++++++++ .../Repeaters/RepeaterStatusContent.swift | 21 ++ .../TracePath/Map/TracePathMapViewModel.swift | 34 +- .../Models/RemoteNodeSession.swift | 16 + .../NeighborSNRMapBuilderTests.swift | 329 ++++++++++++++++++ 27 files changed, 1214 insertions(+), 37 deletions(-) create mode 100644 MC1/Resources/Localization/de.lproj/RemoteNodes.stringsdict create mode 100644 MC1/Resources/Localization/en.lproj/RemoteNodes.stringsdict create mode 100644 MC1/Resources/Localization/es.lproj/RemoteNodes.stringsdict create mode 100644 MC1/Resources/Localization/fr.lproj/RemoteNodes.stringsdict create mode 100644 MC1/Resources/Localization/nl.lproj/RemoteNodes.stringsdict create mode 100644 MC1/Resources/Localization/pl.lproj/RemoteNodes.stringsdict create mode 100644 MC1/Resources/Localization/ru.lproj/RemoteNodes.stringsdict create mode 100644 MC1/Resources/Localization/uk.lproj/RemoteNodes.stringsdict create mode 100644 MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.stringsdict create mode 100644 MC1/Views/Map/MapLine+SNR.swift create mode 100644 MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift create mode 100644 MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift create mode 100644 MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 8b590df5..550c76db 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -3733,6 +3733,12 @@ public enum L10n { public static let neighbors = L10n.tr("RemoteNodes", "remoteNodes.status.neighbors", fallback: "Neighbors") /// Location: RepeaterStatusView.swift - Neighbors section footer public static let neighborsFooter = L10n.tr("RemoteNodes", "remoteNodes.status.neighborsFooter", fallback: "Other nodes discovered by this repeater and their signal quality.") + /// Location: NeighborSNRMapView.swift - Navigation title for the neighbors map + public static let neighborsMapTitle = L10n.tr("RemoteNodes", "remoteNodes.status.neighborsMapTitle", fallback: "Neighbors Map") + /// Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count + public static func neighborsNotShown(_ p1: Int) -> String { + return L10n.tr("RemoteNodes", "remoteNodes.status.neighborsNotShown", p1, fallback: "%d neighbors not shown") + } /// Location: RepeaterStatusView.swift - Noise floor label public static let noiseFloor = L10n.tr("RemoteNodes", "remoteNodes.status.noiseFloor", fallback: "Noise Floor") /// Location: RepeaterStatusView.swift - No neighbors empty state @@ -3773,6 +3779,8 @@ public enum L10n { public static func secondsAgo(_ p1: Int) -> String { return L10n.tr("RemoteNodes", "remoteNodes.status.secondsAgo", p1, fallback: "%ds ago") } + /// Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form + public static let snrBadgeUnit = L10n.tr("RemoteNodes", "remoteNodes.status.snrBadgeUnit", fallback: "dB") /// Location: RepeaterStatusView.swift - SNR display format public static func snrFormat(_ p1: Any) -> String { return L10n.tr("RemoteNodes", "remoteNodes.status.snrFormat", String(describing: p1), fallback: "SNR %@dB") @@ -3807,6 +3815,8 @@ public enum L10n { public static func uptimeMinutes(_ p1: Int) -> String { return L10n.tr("RemoteNodes", "remoteNodes.status.uptimeMinutes", p1, fallback: "%dm") } + /// Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section + public static let viewOnMap = L10n.tr("RemoteNodes", "remoteNodes.status.viewOnMap", fallback: "View on Map") public enum Accessibility { /// Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors public static let reloadNeighbors = L10n.tr("RemoteNodes", "remoteNodes.status.accessibility.reloadNeighbors", fallback: "Reload neighbors") @@ -3816,6 +3826,8 @@ public enum L10n { public static let reloadStatus = L10n.tr("RemoteNodes", "remoteNodes.status.accessibility.reloadStatus", fallback: "Reload status") /// Location: SharedNodeStatusViews.swift - Per-section reload button accessibility label for telemetry public static let reloadTelemetry = L10n.tr("RemoteNodes", "remoteNodes.status.accessibility.reloadTelemetry", fallback: "Reload telemetry") + /// Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button + public static let viewNeighborsOnMap = L10n.tr("RemoteNodes", "remoteNodes.status.accessibility.viewNeighborsOnMap", fallback: "View neighbors on map") } public enum Sensor { /// Accelerometer diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 50dd1daf..956ee2e5 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -551,6 +551,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Nachbarn neu laden"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Auf Karte anzeigen"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Nachbarn auf Karte anzeigen"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Nachbarkarte"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d Nachbarn nicht angezeigt"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/de.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..155bd8ff --- /dev/null +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.stringsdict @@ -0,0 +1,22 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d Nachbar nicht angezeigt + other + %d Nachbarn nicht angezeigt + + + + diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index 18f127e6..d68fab6b 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -580,6 +580,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Reload neighbors"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "View on Map"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "View neighbors on map"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Neighbors Map"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d neighbors not shown"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/en.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..09223185 --- /dev/null +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.stringsdict @@ -0,0 +1,22 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d neighbor not shown + other + %d neighbors not shown + + + + diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 13d3e520..775195f5 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -551,6 +551,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Recargar vecinos"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Ver en el mapa"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Ver vecinos en el mapa"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Mapa de vecinos"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d vecinos no mostrados"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/es.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..08b04465 --- /dev/null +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.stringsdict @@ -0,0 +1,22 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d vecino no mostrado + other + %d vecinos no mostrados + + + + diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index c72c3318..82062c2d 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -551,6 +551,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Recharger les voisins"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Voir sur la carte"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Voir les voisins sur la carte"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Carte des voisins"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d voisins non affichés"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/fr.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..530fc6bd --- /dev/null +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.stringsdict @@ -0,0 +1,22 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d voisin non affiché + other + %d voisins non affichés + + + + diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 62f60a03..b3655c26 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -551,6 +551,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Buren opnieuw laden"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Toon op kaart"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Buren op kaart tonen"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Burenkaart"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d buren niet weergegeven"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/nl.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..29e18254 --- /dev/null +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.stringsdict @@ -0,0 +1,22 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d buur niet weergegeven + other + %d buren niet weergegeven + + + + diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 51343456..3f2af10a 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -548,6 +548,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Odśwież sąsiadów"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Pokaż na mapie"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Pokaż sąsiadów na mapie"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Mapa sąsiadów"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "Nie pokazano %d sąsiadów"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/pl.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..a9fda4b4 --- /dev/null +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.stringsdict @@ -0,0 +1,32 @@ + + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Nie pokazano %d sąsiada + few + Nie pokazano %d sąsiadów + many + Nie pokazano %d sąsiadów + other + Nie pokazano %d sąsiadów + + + + diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index 97424195..c458673d 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -548,6 +548,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Обновить соседей"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Показать на карте"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Показать соседей на карте"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Карта соседей"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "Не показано %d соседей"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/ru.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..04c29ca9 --- /dev/null +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.stringsdict @@ -0,0 +1,32 @@ + + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Не показано %d соседа + few + Не показано %d соседей + many + Не показано %d соседей + other + Не показано %d соседей + + + + diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index 107ab85a..3e3f71ec 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -548,6 +548,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "Оновити сусідів"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Показати на карті"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Показати сусідів на карті"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Карта сусідів"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "Не показано %d сусідів"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/uk.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..118eb60e --- /dev/null +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.stringsdict @@ -0,0 +1,32 @@ + + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Не показано %d сусіда + few + Не показано %d сусідів + many + Не показано %d сусідів + other + Не показано %d сусідів + + + + diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 86d22bc1..67d3b93c 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -551,6 +551,23 @@ /* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ "remoteNodes.status.accessibility.reloadNeighbors" = "重新加载邻居"; +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "在地图上查看"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "在地图上查看邻居"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "邻居地图"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d 个邻居未显示"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.stringsdict new file mode 100644 index 00000000..d513718f --- /dev/null +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.stringsdict @@ -0,0 +1,20 @@ + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + other + %d 个邻居未显示 + + + + diff --git a/MC1/Utilities/RepeaterResolver.swift b/MC1/Utilities/RepeaterResolver.swift index 0bb207cc..aed3e4d4 100644 --- a/MC1/Utilities/RepeaterResolver.swift +++ b/MC1/Utilities/RepeaterResolver.swift @@ -7,6 +7,22 @@ struct ResolvedNode: Sendable { let matchKind: NodeNameMatchKind } +/// A neighbor resolution that also carries the resolved node's location so callers +/// (the SNR map) can place it. The coordinate is stored as the `Double` pair (not +/// `CLLocationCoordinate2D`, which is neither `Sendable` nor `Hashable`) so the struct +/// stays `Sendable`, matching its file siblings. +struct ResolvedNeighbor: Sendable { + let displayName: String + let matchKind: NodeNameMatchKind + let latitude: Double? + let longitude: Double? + + var coordinate: CLLocationCoordinate2D? { + guard let latitude, let longitude else { return nil } + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} + /// A single routing hop (hash bytes shown as hex) resolved to a repeater name, ready to display. struct ResolvedPathHop: Identifiable, Sendable { let id: Int @@ -105,29 +121,69 @@ enum RepeaterResolver { } enum NeighborNameResolver { - static func resolve( + /// Single resolution core: resolves a neighbor prefix to a name, match confidence, and the + /// resolved node's location. Contacts are preferred over discovered nodes, and the cross-source + /// `matchKind` refinement is applied here so every caller reads identical name/matchKind/coordinate. + static func resolveLocated( for prefix: Data, contacts: [ContactDTO], discoveredNodes: [DiscoveredNodeDTO], userLocation: CLLocation? - ) -> NodeNameResolution? { + ) -> ResolvedNeighbor? { if let contact = RepeaterResolver.resolve(for: prefix, in: contacts, userLocation: userLocation) { - return NodeNameResolution( - displayName: contact.node.resolvableName, - matchKind: matchKind(for: prefix, resolvedMatchKind: contact.matchKind, contacts: contacts, discoveredNodes: discoveredNodes) + return located( + node: contact.node, + resolvedMatchKind: contact.matchKind, + prefix: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes ) } if let node = RepeaterResolver.resolve(for: prefix, in: discoveredNodes, userLocation: userLocation) { - return NodeNameResolution( - displayName: node.node.resolvableName, - matchKind: matchKind(for: prefix, resolvedMatchKind: node.matchKind, contacts: contacts, discoveredNodes: discoveredNodes) + return located( + node: node.node, + resolvedMatchKind: node.matchKind, + prefix: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes ) } return nil } + private static func located( + node: T, + resolvedMatchKind: NodeNameMatchKind, + prefix: Data, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO] + ) -> ResolvedNeighbor { + ResolvedNeighbor( + displayName: node.resolvableName, + matchKind: matchKind(for: prefix, resolvedMatchKind: resolvedMatchKind, contacts: contacts, discoveredNodes: discoveredNodes), + latitude: node.hasLocation ? node.latitude : nil, + longitude: node.hasLocation ? node.longitude : nil + ) + } + + static func resolve( + for prefix: Data, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> NodeNameResolution? { + guard let resolved = resolveLocated( + for: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) else { return nil } + + return NodeNameResolution(displayName: resolved.displayName, matchKind: resolved.matchKind) + } + private static func matchKind( for prefix: Data, resolvedMatchKind: NodeNameMatchKind, diff --git a/MC1/Views/Map/MapLine+SNR.swift b/MC1/Views/Map/MapLine+SNR.swift new file mode 100644 index 00000000..d0c7552d --- /dev/null +++ b/MC1/Views/Map/MapLine+SNR.swift @@ -0,0 +1,70 @@ +import CoreLocation +import Foundation +import MC1Services + +extension MapLine.LineStyle { + /// Maps an SNR value to a trace line style. `SNRQuality` owns the thresholds, so no bare + /// SNR literals are re-encoded here. + static func forSNR(_ snr: Double?) -> MapLine.LineStyle { + switch SNRQuality(snr: snr) { + case .excellent, .good: .traceGood + case .fair: .traceMedium + case .poor: .traceWeak + case .unknown: .traceUntraced + } + } +} + +extension MapLine { + /// Builds the midpoint badge label shared by the trace and neighbor SNR maps, of the form + /// " · dB", with the `dB` unit routed through `L10n`. The space before the unit + /// is composed here so whitespace-trimming tooling can't strip it out of the localized value. + static func snrBadgeText(distance: Double, snr: Double) -> String { + let distFormatted = Measurement(value: distance, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .road)) + let snrFormatted = snr.formatted(.number.precision(.fractionLength(1))) + return "\(distFormatted) · \(snrFormatted) \(L10n.RemoteNodes.RemoteNodes.Status.snrBadgeUnit)" + } + + /// The midpoint distance/SNR badge pin for the link between two coordinates, shared by the trace + /// and neighbor SNR maps. Callers pass `id` so the trace map keeps its deterministic + /// `UUID(hopIndex:)` for diffing while the neighbor map mints a fresh one. + static func snrBadge( + id: UUID, + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D, + snr: Double + ) -> MapPoint { + let distance = CLLocation(latitude: from.latitude, longitude: from.longitude) + .distance(from: CLLocation(latitude: to.latitude, longitude: to.longitude)) + return MapPoint( + id: id, + coordinate: midpoint(from: from, to: to), + pinStyle: .badge, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: snrBadgeText(distance: distance, snr: snr) + ) + } + + /// Geographic midpoint of two coordinates, shifting one longitude by 360° before averaging when + /// the pair straddles the antimeridian so the badge lands between them rather than on the + /// opposite hemisphere. + private static func midpoint( + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D + ) -> CLLocationCoordinate2D { + var lon1 = from.longitude + var lon2 = to.longitude + if abs(lon1 - lon2) > 180 { + if lon1 < lon2 { lon1 += 360 } else { lon2 += 360 } + } + var midLongitude = (lon1 + lon2) / 2 + if midLongitude > 180 { midLongitude -= 360 } + return CLLocationCoordinate2D( + latitude: (from.latitude + to.latitude) / 2, + longitude: midLongitude + ) + } +} diff --git a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift new file mode 100644 index 00000000..b3ed5c10 --- /dev/null +++ b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift @@ -0,0 +1,124 @@ +import CoreLocation +import MapKit +import MC1Services + +/// Builds the pins, lines, and camera region for the neighbor SNR map from data already in hand. +/// It is a pure builder rather than a view model: the content is a function of the session, its +/// neighbors, and the resolver inputs, with no per-connection dependency and nothing live to read. +/// `PlottedNeighbors` holds non-`Sendable` MapKit types, so it is built and consumed on `@MainActor`. +enum NeighborSNRMapBuilder { + struct PlottedNeighbors { + let points: [MapPoint] + let lines: [MapLine] + let region: MKCoordinateRegion? + let unplottable: [UnplottableNeighbor] + } + + /// A neighbor that could not be placed reliably (ambiguous match, no location, an invalid + /// coordinate, or unresolved), carried with its resolved name and confidence for the list card. + struct UnplottableNeighbor { + let neighbor: NeighbourInfo + let displayName: String + let matchKind: NodeNameMatchKind + } + + private static let lineOpacity = 1.0 + + static func build( + session: RemoteNodeSessionDTO, + neighbors: [NeighbourInfo], + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> PlottedNeighbors { + var points: [MapPoint] = [] + var lines: [MapLine] = [] + var unplottable: [UnplottableNeighbor] = [] + var plottedCoordinates: [CLLocationCoordinate2D] = [] + + let centerCoordinate = session.coordinate + if let centerCoordinate { + points.append(MapPoint( + id: UUID(), + coordinate: centerCoordinate, + pinStyle: .repeaterRingWhite, + label: session.name, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + plottedCoordinates.append(centerCoordinate) + } + + for (index, neighbor) in neighbors.enumerated() { + guard let resolved = NeighborNameResolver.resolveLocated( + for: neighbor.publicKeyPrefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) else { + unplottable.append(UnplottableNeighbor( + neighbor: neighbor, + displayName: NeighborNameResolver.fallbackName(for: neighbor.publicKeyPrefix), + matchKind: .unresolved + )) + continue + } + + // Only an exact identity match with a trustworthy coordinate is plotted; the validity + // guard lives here because `DiscoveredNodeDTO.hasLocation` checks only non-(0,0) and + // would otherwise admit an out-of-range point. + guard resolved.matchKind == .exact, + let coordinate = resolved.coordinate, + isPlottable(coordinate) else { + unplottable.append(UnplottableNeighbor( + neighbor: neighbor, + displayName: resolved.displayName, + matchKind: resolved.matchKind + )) + continue + } + + points.append(MapPoint( + id: UUID(), + coordinate: coordinate, + pinStyle: .repeater, + label: resolved.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + plottedCoordinates.append(coordinate) + + // A line and distance/SNR badge are defensible only when the center is also located; + // without an anchor the neighbor pin still stands alone. + guard let centerCoordinate else { continue } + + lines.append(MapLine( + id: "neighbor-\(index)", + coordinates: [centerCoordinate, coordinate], + style: .forSNR(neighbor.snr), + opacity: lineOpacity, + pathIndex: nil + )) + + points.append(MapLine.snrBadge( + id: UUID(), + from: centerCoordinate, + to: coordinate, + snr: neighbor.snr + )) + } + + return PlottedNeighbors( + points: points, + lines: lines, + region: plottedCoordinates.boundingRegion(), + unplottable: unplottable + ) + } + + private static func isPlottable(_ coordinate: CLLocationCoordinate2D) -> Bool { + CLLocationCoordinate2DIsValid(coordinate) + } +} diff --git a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift new file mode 100644 index 00000000..3e682486 --- /dev/null +++ b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift @@ -0,0 +1,194 @@ +import CoreLocation +import MapKit +import MapLibre +import MC1Services +import SwiftUI + +/// Map of a repeater's neighbors: the repeater at center, each exact-match located neighbor pinned +/// with an SNR-colored link line. Neighbors that can't be placed are counted in a top pill that +/// pushes their list. The content is a pure function of data already in hand, so it carries plain +/// screen-lifetime parameters and holds only camera `@State` rather than a view model. +/// +/// Pushed onto the host's navigation stack rather than presented modally: stacking a cover or +/// sheet on the telemetry sheet that hosts it collapses both presentations. The no-location list is +/// likewise a push, not a sheet, for the same reason. +struct NeighborSNRMapView: View { + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + + let session: RemoteNodeSessionDTO + let neighbors: [NeighbourInfo] + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? + + @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels + + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 + @State private var plotted: NeighborSNRMapBuilder.PlottedNeighbors? + @State private var isNorthLocked = false + @State private var showingLayersMenu = false + @State private var showingNoLocationList = false + @State private var isStyleLoaded = false + + private static let myLocationSpan = 0.02 + + /// Float the layers menu just above the toolbar's bottom-right corner. + private static let layersMenuTrailingPadding: CGFloat = 16 + private static let layersMenuBottomPadding: CGFloat = 240 + + var body: some View { + ZStack(alignment: .bottom) { + MC1MapView( + points: plotted?.points ?? [], + lines: plotted?.lines ?? [], + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: isNorthLocked, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + cameraBottomSheetFraction: 0, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { cameraRegion = $0 }, + isStyleLoaded: $isStyleLoaded + ) + .ignoresSafeArea() + + toolbarOverlay + } + .overlay(alignment: .top) { + if let unplottable = plotted?.unplottable, !unplottable.isEmpty { + noLocationPill(count: unplottable.count) + } + } + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.neighborsMapTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $showingNoLocationList) { + if let unplottable = plotted?.unplottable { + NeighborsNoLocationList(unplottable: unplottable) + } + } + .onAppear { + guard plotted == nil else { return } + let built = NeighborSNRMapBuilder.build( + session: session, + neighbors: neighbors, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + withAnimation { plotted = built } + setCameraRegion(built.region) + } + // The map gates camera moves until its style finishes loading, which usually lands after + // the on-appear fit. Re-issue the fit on that signal so the nodes frame deterministically + // rather than depending on an incidental re-render. + .onChange(of: isStyleLoaded) { _, loaded in + if loaded { setCameraRegion(plotted?.region) } + } + } + + /// Glanceable count of neighbors that couldn't be placed; tapping pushes their list. Styled like + /// the trace path map's top banner so the two maps read the same. + private func noLocationPill(count: Int) -> some View { + Button { + showingNoLocationList = true + } label: { + HStack(spacing: 6) { + Image(systemName: "mappin.slash") + Text(L10n.RemoteNodes.RemoteNodes.Status.neighborsNotShown(count)) + } + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .liquidGlass(in: .capsule) + } + .buttonStyle(.plain) + .safeAreaPadding(.top) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + /// Fixed bottom-right map controls. + private var toolbarOverlay: some View { + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: centerOnMyLocation, + isNorthLocked: $isNorthLocked, + showLabels: $showLabels, + showingLayersMenu: $showingLayersMenu + ) { + centerAllButton + } + .overlay(alignment: .bottomTrailing) { + if showingLayersMenu { + LayersMenu( + selection: $mapStyleSelection, + isPresented: $showingLayersMenu, + viewportBounds: cameraRegion?.toMLNCoordinateBounds() + ) + .padding(.trailing, Self.layersMenuTrailingPadding) + .padding(.bottom, Self.layersMenuBottomPadding) + .transition(.scale.combined(with: .opacity)) + } + } + } + .animation(.spring(response: 0.3), value: showingLayersMenu) + } + + /// Centers and fits the camera in one step: `MC1MapView` ignores a region whose version + /// still matches the last applied one, so the region and the version bump must move together. + private func setCameraRegion(_ region: MKCoordinateRegion?) { + guard let region else { return } + cameraRegion = region + cameraRegionVersion += 1 + } + + private func centerOnMyLocation() { + if let location = appState.bestAvailableLocation { + setCameraRegion(MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: Self.myLocationSpan, longitudeDelta: Self.myLocationSpan) + )) + } else { + appState.locationService.requestLocation() + } + } + + /// Re-fits the camera to the repeater and its plotted neighbors. Disabled when nothing is plottable. + private var centerAllButton: some View { + Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { + setCameraRegion(plotted?.region) + } + .mapControlButton(tint: plotted?.region == nil ? .secondary : .primary) + .disabled(plotted?.region == nil) + } +} + +// MARK: - No Location List + +/// Pushed list of neighbors that could not be placed reliably (ambiguous, unlocated, or +/// unresolved). Reuses `NeighborRow`, including its "?" fallback-match affordance. +private struct NeighborsNoLocationList: View { + let unplottable: [NeighborSNRMapBuilder.UnplottableNeighbor] + + var body: some View { + List(unplottable, id: \.neighbor.publicKeyPrefix) { item in + NeighborRow( + neighbor: item.neighbor, + displayName: item.displayName, + matchKind: item.matchKind + ) + } + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.neighborsNotShown(unplottable.count)) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift index d6e20db5..5455f115 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift @@ -53,12 +53,26 @@ struct RepeaterStatusContent: View { } } .nodeStatusDestinations(helper: viewModel.helper) + .navigationDestination(for: NeighborMapRoute.self) { _ in + NeighborSNRMapView( + session: session, + neighbors: viewModel.neighbors, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + } .themedCanvas(theme) .nodeManagementHeaderTopMargin() .scrollDismissesKeyboard(.interactively) } } +/// Value-based push identity for the neighbors map. Carries no payload: the destination +/// reads the live neighbor, contact, and location data in scope where it is registered, +/// keeping heavy arrays out of the navigation path. +private struct NeighborMapRoute: Hashable {} + // MARK: - Owner Info Section private struct OwnerInfoSection: View { @@ -168,6 +182,13 @@ private struct NeighborsSection: View { Text(L10n.RemoteNodes.RemoteNodes.Status.noNeighbors) .foregroundStyle(.secondary) } else { + if !viewModel.neighbors.isEmpty { + NavigationLink(value: NeighborMapRoute()) { + Label(L10n.RemoteNodes.RemoteNodes.Status.viewOnMap, systemImage: "map") + } + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.Accessibility.viewNeighborsOnMap) + } + ForEach(viewModel.neighbors, id: \.publicKeyPrefix) { neighbor in let resolution = NeighborNameResolver.resolve( for: neighbor.publicKeyPrefix, diff --git a/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift b/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift index 402429b7..8eb9355c 100644 --- a/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift +++ b/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift @@ -261,7 +261,7 @@ final class TracePathMapViewModel { let hopIndex = pathIndex + 1 if hopIndex < result.hops.count { let hop = result.hops[hopIndex] - let style = lineStyle(for: hop.snr) + let style = MapLine.LineStyle.forSNR(hop.snr) updatedLines.append(MapLine( id: line.id, @@ -273,24 +273,11 @@ final class TracePathMapViewModel { // Badge at midpoint if line.coordinates.count >= 2 { - let mid = CLLocationCoordinate2D( - latitude: (line.coordinates[0].latitude + line.coordinates[1].latitude) / 2, - longitude: (line.coordinates[0].longitude + line.coordinates[1].longitude) / 2 - ) - let distance = CLLocation(latitude: line.coordinates[0].latitude, longitude: line.coordinates[0].longitude) - .distance(from: CLLocation(latitude: line.coordinates[1].latitude, longitude: line.coordinates[1].longitude)) - let distFormatted = Measurement(value: distance, unit: UnitLength.meters) - .formatted(.measurement(width: .abbreviated, usage: .road)) - let snrFormatted = hop.snr.formatted(.number.precision(.fractionLength(1))) - - badgePoints.append(MapPoint( + badgePoints.append(MapLine.snrBadge( id: UUID(hopIndex: hopIndex), - coordinate: mid, - pinStyle: .badge, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: "\(distFormatted) · \(snrFormatted) dB" + from: line.coordinates[0], + to: line.coordinates[1], + snr: hop.snr )) } } else { @@ -302,17 +289,6 @@ final class TracePathMapViewModel { rebuildMapPoints() } - // MARK: - Signal Quality - - private func lineStyle(for snr: Double?) -> MapLine.LineStyle { - switch SNRQuality(snr: snr) { - case .excellent, .good: .traceGood - case .fair: .traceMedium - case .poor: .traceWeak - case .unknown: .traceUntraced - } - } - /// Clear all overlays func clearOverlays() { mapLines.removeAll() diff --git a/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift b/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift index d845160b..1d2f67b4 100644 --- a/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift +++ b/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift @@ -1,3 +1,4 @@ +import CoreLocation import Foundation import SwiftData @@ -374,4 +375,19 @@ public struct RemoteNodeSessionDTO: Sendable, Equatable, Identifiable, Hashable, public var canPost: Bool { isRoom && permissionLevel.canPost } public var isAdmin: Bool { permissionLevel.isAdmin } + + /// Whether the node reported a usable location. Latitude/longitude default to (0,0) when GPS was + /// never shared, so the sentinel and validity check are guarded here, mirroring `ContactDTO`. + public var hasLocation: Bool { + let hasNonZero = latitude != 0 || longitude != 0 + guard hasNonZero else { return false } + return CLLocationCoordinate2DIsValid( + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + } + + public var coordinate: CLLocationCoordinate2D? { + guard hasLocation else { return nil } + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } diff --git a/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift b/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift new file mode 100644 index 00000000..46e37787 --- /dev/null +++ b/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift @@ -0,0 +1,329 @@ +import CoreLocation +import Foundation +import MapKit +import Testing +@testable import MC1 +@testable import MC1Services + +@Suite("NeighborSNRMapBuilder") +struct NeighborSNRMapBuilderTests { + + // MARK: - Fixtures + + private func makeSession(latitude: Double, longitude: Double, name: String = "Center") -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data([0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5] + Array(repeating: UInt8(0), count: 26)), + name: name, + role: .repeater, + latitude: latitude, + longitude: longitude + ) + } + + private func makeContact(prefix: [UInt8], name: String, latitude: Double, longitude: Double) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(prefix + Array(repeating: UInt8(0), count: 32 - prefix.count)), + name: name, + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 100, + latitude: latitude, + longitude: longitude, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } + + private func makeDiscoveredNode(prefix: [UInt8], name: String, latitude: Double, longitude: Double) -> DiscoveredNodeDTO { + DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(prefix + Array(repeating: UInt8(0), count: 32 - prefix.count)), + name: name, + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: 100, + latitude: latitude, + longitude: longitude, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + } + + private func makeNeighbor(prefix: [UInt8], snr: Double = -3.0) -> NeighbourInfo { + NeighbourInfo(publicKeyPrefix: Data(prefix), secondsAgo: 0, snr: snr) + } + + private let exactPrefix: [UInt8] = [0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6] + private let secondExactPrefix: [UInt8] = [0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6] + + /// `CLLocationCoordinate2D` is not `Equatable`; compare components within a tight epsilon. + private func coordinatesMatch(_ lhs: CLLocationCoordinate2D, _ rhs: CLLocationCoordinate2D) -> Bool { + abs(lhs.latitude - rhs.latitude) < 1e-9 && abs(lhs.longitude - rhs.longitude) < 1e-9 + } + + private func region(_ region: MKCoordinateRegion, brackets coordinate: CLLocationCoordinate2D) -> Bool { + abs(coordinate.latitude - region.center.latitude) <= region.span.latitudeDelta / 2 + && abs(coordinate.longitude - region.center.longitude) <= region.span.longitudeDelta / 2 + } + + // MARK: - Plotting + + @Test("center located plus exact located neighbor draws pins, an SNR line, and a badge") + func centerAndExactNeighbor() throws { + let centerCoordinate = CLLocationCoordinate2D(latitude: 37.0, longitude: -122.0) + let neighborCoordinate = CLLocationCoordinate2D(latitude: 37.1, longitude: -122.1) + let session = makeSession(latitude: centerCoordinate.latitude, longitude: centerCoordinate.longitude) + let contact = makeContact( + prefix: exactPrefix, + name: "Ridge", + latitude: neighborCoordinate.latitude, + longitude: neighborCoordinate.longitude + ) + let neighbor = makeNeighbor(prefix: exactPrefix) + + let result = NeighborSNRMapBuilder.build( + session: session, + neighbors: [neighbor], + contacts: [contact], + discoveredNodes: [], + userLocation: nil + ) + + #expect(result.points.filter { $0.pinStyle == .repeaterRingWhite }.count == 1) + #expect(result.points.filter { $0.pinStyle == .repeater }.count == 1) + #expect(result.points.filter { $0.pinStyle == .badge }.count == 1) + #expect(result.unplottable.isEmpty) + + let line = try #require(result.lines.first) + #expect(result.lines.count == 1) + // The fixture SNR of -3.0 must drive a non-default style, and the link must run from the + // center to the neighbor in that order. + #expect(line.style == .forSNR(neighbor.snr)) + #expect(line.style == .traceMedium) + #expect(line.coordinates.count == 2) + #expect(coordinatesMatch(line.coordinates[0], centerCoordinate)) + #expect(coordinatesMatch(line.coordinates[1], neighborCoordinate)) + + let region = try #require(result.region) + #expect(self.region(region, brackets: centerCoordinate)) + #expect(self.region(region, brackets: neighborCoordinate)) + } + + @Test("center plus two located neighbors draws a ring, two repeater pins, two badges, and two lines") + func centerAndTwoLocatedNeighbors() throws { + let centerCoordinate = CLLocationCoordinate2D(latitude: 37.0, longitude: -122.0) + let firstCoordinate = CLLocationCoordinate2D(latitude: 37.1, longitude: -122.1) + let secondCoordinate = CLLocationCoordinate2D(latitude: 37.2, longitude: -121.9) + let session = makeSession(latitude: centerCoordinate.latitude, longitude: centerCoordinate.longitude) + let contacts = [ + makeContact(prefix: exactPrefix, name: "Ridge", latitude: firstCoordinate.latitude, longitude: firstCoordinate.longitude), + makeContact(prefix: secondExactPrefix, name: "Valley", latitude: secondCoordinate.latitude, longitude: secondCoordinate.longitude) + ] + let neighbors = [makeNeighbor(prefix: exactPrefix), makeNeighbor(prefix: secondExactPrefix)] + + let result = NeighborSNRMapBuilder.build( + session: session, + neighbors: neighbors, + contacts: contacts, + discoveredNodes: [], + userLocation: nil + ) + + #expect(result.points.filter { $0.pinStyle == .repeaterRingWhite }.count == 1) + #expect(result.points.filter { $0.pinStyle == .repeater }.count == 2) + #expect(result.points.filter { $0.pinStyle == .badge }.count == 2) + #expect(result.lines.count == 2) + #expect(result.unplottable.isEmpty) + + let region = try #require(result.region) + #expect(self.region(region, brackets: centerCoordinate)) + #expect(self.region(region, brackets: firstCoordinate)) + #expect(self.region(region, brackets: secondCoordinate)) + } + + @Test("center not located draws neighbor pin without line or badge") + func centerNotLocated() { + let session = makeSession(latitude: 0, longitude: 0) + let contact = makeContact(prefix: exactPrefix, name: "Ridge", latitude: 37.1, longitude: -122.1) + let neighbor = makeNeighbor(prefix: exactPrefix) + + let result = NeighborSNRMapBuilder.build( + session: session, + neighbors: [neighbor], + contacts: [contact], + discoveredNodes: [], + userLocation: nil + ) + + #expect(result.points.filter { $0.pinStyle == .repeaterRingWhite }.isEmpty) + #expect(result.points.filter { $0.pinStyle == .repeater }.count == 1) + #expect(result.points.filter { $0.pinStyle == .badge }.isEmpty) + #expect(result.lines.isEmpty) + #expect(result.unplottable.isEmpty) + } + + @Test("ambiguous fallback neighbor is not plotted and is listed as fallback") + func fallbackNeighborUnplottable() { + // A sub-6-byte prefix that collides across a contact and a discovered node forces the + // resolver's refinement gate to return `.fallback`; production 6-byte prefixes never do. + let session = makeSession(latitude: 0, longitude: 0) + let contact = makeContact(prefix: [0xAB, 0xCD], name: "Saved", latitude: 37.1, longitude: -122.1) + let node = makeDiscoveredNode(prefix: [0xAB, 0xEF], name: "Advert", latitude: 38.0, longitude: -123.0) + let neighbor = makeNeighbor(prefix: [0xAB]) + + let result = NeighborSNRMapBuilder.build( + session: session, + neighbors: [neighbor], + contacts: [contact], + discoveredNodes: [node], + userLocation: nil + ) + + #expect(result.points.filter { $0.pinStyle == .repeater }.isEmpty) + #expect(result.lines.isEmpty) + #expect(result.unplottable.count == 1) + #expect(result.unplottable.first?.matchKind == .fallback) + } + + @Test("exact neighbor without a location is not plotted") + func exactNeighborWithoutLocationUnplottable() { + let session = makeSession(latitude: 37.0, longitude: -122.0) + let contact = makeContact(prefix: exactPrefix, name: "No GPS", latitude: 0, longitude: 0) + let neighbor = makeNeighbor(prefix: exactPrefix) + + let result = NeighborSNRMapBuilder.build( + session: session, + neighbors: [neighbor], + contacts: [contact], + discoveredNodes: [], + userLocation: nil + ) + + #expect(result.points.filter { $0.pinStyle == .repeater }.isEmpty) + #expect(result.unplottable.count == 1) + #expect(result.unplottable.first?.matchKind == .exact) + #expect(result.unplottable.first?.displayName == "No GPS") + } + + @Test("exact neighbor with an out-of-range coordinate is rejected by the builder guard") + func exactNeighborWithInvalidCoordinateUnplottable() { + // `DiscoveredNodeDTO.hasLocation` only checks non-(0,0), so an out-of-range latitude reaches + // the builder; its own validity guard must reject it. + let session = makeSession(latitude: 37.0, longitude: -122.0) + let node = makeDiscoveredNode(prefix: exactPrefix, name: "Bad GPS", latitude: 200.0, longitude: -122.1) + let neighbor = makeNeighbor(prefix: exactPrefix) + + let result = NeighborSNRMapBuilder.build( + session: session, + neighbors: [neighbor], + contacts: [], + discoveredNodes: [node], + userLocation: nil + ) + + #expect(result.points.filter { $0.pinStyle == .repeater }.isEmpty) + #expect(result.unplottable.count == 1) + #expect(result.unplottable.first?.matchKind == .exact) + } + + @Test("unresolved neighbor falls back to a hex name and is listed") + func unresolvedNeighborFallsBackToHexName() { + let session = makeSession(latitude: 37.0, longitude: -122.0) + let neighbor = makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + + let result = NeighborSNRMapBuilder.build( + session: session, + neighbors: [neighbor], + contacts: [], + discoveredNodes: [], + userLocation: nil + ) + + #expect(result.points.filter { $0.pinStyle == .repeater }.isEmpty) + #expect(result.unplottable.count == 1) + #expect(result.unplottable.first?.matchKind == .unresolved) + #expect(result.unplottable.first?.displayName == NeighborNameResolver.fallbackName(for: neighbor.publicKeyPrefix)) + } + + @Test("all neighbors unplottable keeps only the center pin and lists them all") + func allUnplottableKeepsOnlyCenter() { + let session = makeSession(latitude: 37.0, longitude: -122.0) + let neighbors = [ + makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]), + makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x02]) + ] + + let result = NeighborSNRMapBuilder.build( + session: session, + neighbors: neighbors, + contacts: [], + discoveredNodes: [], + userLocation: nil + ) + + #expect(result.points.count == 1) + #expect(result.points.first?.pinStyle == .repeaterRingWhite) + #expect(result.lines.isEmpty) + #expect(result.region != nil) + #expect(result.unplottable.count == 2) + } + + @Test("nothing plotted yields a nil region") + func nothingPlottedYieldsNilRegion() { + let session = makeSession(latitude: 0, longitude: 0) + let neighbor = makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + + let result = NeighborSNRMapBuilder.build( + session: session, + neighbors: [neighbor], + contacts: [], + discoveredNodes: [], + userLocation: nil + ) + + #expect(result.points.isEmpty) + #expect(result.region == nil) + #expect(result.unplottable.count == 1) + } + + // MARK: - SNR bucketing + + // The 0.0 and -6.0 cases sit on `SNRQuality`'s strict-greater thresholds, so they catch a + // `>` to `>=` regression that the interior values would not. + @Test("SNR buckets map to the expected trace line style", arguments: [ + (snr: 7.0 as Double?, style: MapLine.LineStyle.traceGood), + (snr: 3.0, style: .traceGood), + (snr: 0.0, style: .traceMedium), + (snr: -3.0, style: .traceMedium), + (snr: -6.0, style: .traceWeak), + (snr: -10.0, style: .traceWeak), + (snr: nil, style: .traceUntraced) + ]) + func snrBucketing(snr: Double?, style: MapLine.LineStyle) { + #expect(MapLine.LineStyle.forSNR(snr) == style) + } + + @Test("SNR badge midpoint stays between coordinates that straddle the antimeridian") + func snrBadgeMidpointCrossesAntimeridian() { + let west = CLLocationCoordinate2D(latitude: 0, longitude: 179) + let east = CLLocationCoordinate2D(latitude: 0, longitude: -179) + + let badge = MapLine.snrBadge(id: UUID(), from: west, to: east, snr: -3.0) + + // The midpoint must land on the date line near ±180, not on the opposite hemisphere near 0. + #expect(abs(abs(badge.coordinate.longitude) - 180) < 0.0001) + } +} From 0ef0eb260c18fc5d7ff431f2c6745c9ac9676c3e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:41:33 -0700 Subject: [PATCH 07/83] feat(map): add distance pill to message path map - pulled the pill into a shared PathDistanceBanner so trace map and message path map use the same component instead of duplicating TracePathResultsBanner - added totalDistance() to sum great-circle distance over a coordinate chain - message path map pill shows total hop count plus the drawn path's distance --- .../CLLocationCoordinate2D+Distance.swift | 21 +++++++++++ .../Chats/Components/MessagePathMapView.swift | 17 +++++++++ MC1/Views/Map/PathDistanceBanner.swift | 34 ++++++++++++++++++ .../TracePath/Map/TracePathMapView.swift | 35 ++----------------- 4 files changed, 74 insertions(+), 33 deletions(-) create mode 100644 MC1/Extensions/CLLocationCoordinate2D+Distance.swift create mode 100644 MC1/Views/Map/PathDistanceBanner.swift diff --git a/MC1/Extensions/CLLocationCoordinate2D+Distance.swift b/MC1/Extensions/CLLocationCoordinate2D+Distance.swift new file mode 100644 index 00000000..968b6bf0 --- /dev/null +++ b/MC1/Extensions/CLLocationCoordinate2D+Distance.swift @@ -0,0 +1,21 @@ +import CoreLocation + +extension Array where Element == CLLocationCoordinate2D { + /// Total length in meters along the ordered coordinate chain, summing + /// great-circle (WGS-84) distance between consecutive points. Nil for + /// fewer than 2 points. + /// + /// Separate from `TracePathViewModel.calculateDistance(for:)` on purpose: + /// that helper is all-or-nothing (nil if any hop lacks a location), whereas + /// callers here pass already-located coordinates, so this always succeeds. + func totalDistance() -> CLLocationDistance? { + guard count >= 2 else { return nil } + var meters: CLLocationDistance = 0 + for index in 0..<(count - 1) { + let origin = CLLocation(latitude: self[index].latitude, longitude: self[index].longitude) + let destination = CLLocation(latitude: self[index + 1].latitude, longitude: self[index + 1].longitude) + meters += origin.distance(from: destination) + } + return meters + } +} diff --git a/MC1/Views/Chats/Components/MessagePathMapView.swift b/MC1/Views/Chats/Components/MessagePathMapView.swift index c2f3d2d3..ed7c33ff 100644 --- a/MC1/Views/Chats/Components/MessagePathMapView.swift +++ b/MC1/Views/Chats/Components/MessagePathMapView.swift @@ -36,6 +36,18 @@ struct MessagePathMapView: View { return [MapLine(id: "message-path", coordinates: coords, style: .messagePath, opacity: 1.0)] } + /// Length of the drawn path, over only the nodes we could place. Nil until at + /// least two nodes resolve to coordinates, so the pill's distance always + /// matches the polyline in `mapLines`. + private var totalPathDistance: CLLocationDistance? { + locatedNodes.map(\.coordinate).totalDistance() + } + + /// Total intermediate-hop count from the message's path, including hops too + /// ambiguous to plot. A `nil` `pathNodes` yields 0, which is indistinguishable + /// between a genuine direct message and firmware that omits path data. + private var hopCount: Int { message.pathHops.count } + var body: some View { NavigationStack { Group { @@ -69,6 +81,11 @@ struct MessagePathMapView: View { ) .ignoresSafeArea() + PathDistanceBanner( + hopCount: hopCount, + totalPathDistance: totalPathDistance + ) + VStack { Spacer() HStack { diff --git a/MC1/Views/Map/PathDistanceBanner.swift b/MC1/Views/Map/PathDistanceBanner.swift new file mode 100644 index 00000000..6f9f34f0 --- /dev/null +++ b/MC1/Views/Map/PathDistanceBanner.swift @@ -0,0 +1,34 @@ +import MapKit +import SwiftUI + +/// Floating top-of-map capsule summarizing a path: hop count and, when at least +/// two nodes have coordinates, the drawn-path distance. +struct PathDistanceBanner: View { + private static let horizontalPadding: CGFloat = 16 + private static let verticalPadding: CGFloat = 10 + + let hopCount: Int + let totalPathDistance: CLLocationDistance? + + var body: some View { + VStack { + HStack { + Text(L10n.Contacts.Contacts.Trace.Map.hops(hopCount)) + if let distance = totalPathDistance { + Text("•") + Text(Measurement(value: distance, unit: UnitLength.meters), + format: .measurement(width: .abbreviated, usage: .road)) + } + } + .font(.subheadline.weight(.medium)) + .padding(.horizontal, Self.horizontalPadding) + .padding(.vertical, Self.verticalPadding) + .liquidGlass(in: .capsule) + + Spacer() + } + .frame(maxWidth: .infinity) + .safeAreaPadding(.top) + .transition(.move(edge: .top).combined(with: .opacity)) + } +} diff --git a/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift b/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift index 1072075c..f023d8ce 100644 --- a/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift +++ b/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift @@ -31,8 +31,8 @@ struct TracePathMapView: View { // Results banner at top if let result = mapViewModel.result, result.success { - TracePathResultsBanner( - result: result, + PathDistanceBanner( + hopCount: result.hops.count - 2, totalPathDistance: traceViewModel.totalPathDistance ) } @@ -143,34 +143,3 @@ struct TracePathMapView: View { } } - -// MARK: - Results Banner - -private struct TracePathResultsBanner: View { - let result: TraceResult - let totalPathDistance: Double? - - var body: some View { - VStack { - HStack { - let hopCount = result.hops.count - 2 - Text(L10n.Contacts.Contacts.Trace.Map.hops(hopCount)) - - if let distance = totalPathDistance { - Text("•") - Text(Measurement(value: distance, unit: UnitLength.meters), - format: .measurement(width: .abbreviated, usage: .road)) - } - } - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .liquidGlass(in: .capsule) - - Spacer() - } - .safeAreaPadding(.top) - .transition(.move(edge: .top).combined(with: .opacity)) - .animation(.spring(response: 0.3), value: result.id) - } -} From f2ad13f66c7275eb844f451dc6ea5ecb60c63fda Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 30 Jun 2026 20:46:14 -0500 Subject: [PATCH 08/83] ci: lint and formatting jobs --- .github/workflows/lint.yml | 56 ++++++++++++++++++++++++++++++ MC1/Resources/Generated/L10n.swift | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..c981349d --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,56 @@ +name: Lint + +on: + pull_request: + branches: + - main + - dev + + push: + branches: + - main + - dev + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + linting: + name: Formatting and Linting + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install dependencies + run: command -v swiftlint >/dev/null 2>&1 || brew install swiftlint + + - name: Lint + run: swiftlint lint --strict + + codegen: + name: Code Generation Checks + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install dependencies + run: command -v swiftgen >/dev/null 2>&1 || brew install swiftgen + + - name: Regenerate + run: swiftgen config run --config swiftgen.yml + + - name: Verify no changes + run: | + if ! git diff --exit-code; then + echo "::error::Generated files are out of date. Run 'swiftgen config run --config swiftgen.yml' and commit the result." + exit 1 + fi diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 8173acdb..7f14414e 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2941,7 +2941,7 @@ public enum L10n { /// Location: DeviceScanView.swift - Button to retry connection after other-app conflict public static let retryConnection = L10n.tr("Onboarding", "deviceScan.retryConnection", fallback: "Retry Connection") /// Location: DeviceScanView.swift - Subtitle with pairing instructions - public static let subtitle = L10n.tr("Onboarding", "deviceScan.subtitle", fallback: "Power it on, then tap Add Device.") + public static let subtitle = L10n.tr("Onboarding", "deviceScan.subtitle", fallback: "Power your radio on, disconnect it from all other apps and devices, then tap Add Device.") /// Location: DeviceScanView.swift - Screen title for device pairing public static let title = L10n.tr("Onboarding", "deviceScan.title", fallback: "Pair your device") public enum DemoModeAlert { From 5d431bb772d09b5b270123dd8ae6ef5fa4174f51 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 30 Jun 2026 21:18:56 -0500 Subject: [PATCH 09/83] ci: formatter --- .github/workflows/lint.yml | 3 + .swiftformat | 51 + MC1/ContentView.swift | 332 +- MC1/Extensions/BatteryInfo+Display.swift | 140 +- ...LLocationCoordinate2D+BoundingRegion.swift | 78 +- .../CLLocationCoordinate2D+Distance.swift | 34 +- .../CLLocationCoordinate2D+Formatting.swift | 6 +- MC1/Extensions/ChannelDTO+DisplayName.swift | 8 +- MC1/Extensions/Collection+Chunked.swift | 14 +- MC1/Extensions/Color+Hex.swift | 14 +- MC1/Extensions/ContactDTO+Coordinate.swift | 6 +- MC1/Extensions/ContactType+Display.swift | 50 +- MC1/Extensions/Date+RelativeFormatting.swift | 10 +- MC1/Extensions/DecryptStatus+Display.swift | 22 +- ...ssorySetupKitError+UserFacingMessage.swift | 46 +- ...AdvertisementError+UserFacingMessage.swift | 26 +- .../Errors/BLEError+UserFacingMessage.swift | 66 +- ...inaryProtocolError+UserFacingMessage.swift | 30 +- ...hannelServiceError+UserFacingMessage.swift | 46 +- ...dQueueServiceError+UserFacingMessage.swift | 20 +- .../ConnectionError+UserFacingMessage.swift | 26 +- ...ontactServiceError+UserFacingMessage.swift | 42 +- ...DeviceServiceError+UserFacingMessage.swift | 18 +- .../Errors/Error+UserFacingMessage.swift | 68 +- ...KeyGenerationError+UserFacingMessage.swift | 26 +- .../KeychainError+UserFacingMessage.swift | 26 +- .../MeshCoreError+UserFacingMessage.swift | 104 +- ...essagePollingError+UserFacingMessage.swift | 22 +- ...essageServiceError+UserFacingMessage.swift | 38 +- ...ConfigServiceError+UserFacingMessage.swift | 82 +- ...sistenceStoreError+UserFacingMessage.swift | 42 +- .../ProtocolError+UserFacingMessage.swift | 24 +- .../RemoteNodeError+UserFacingMessage.swift | 66 +- .../RoomServerError+UserFacingMessage.swift | 34 +- ...ttingsServiceError+UserFacingMessage.swift | 42 +- .../StoreServiceError+UserFacingMessage.swift | 42 +- ...ncCoordinatorError+UserFacingMessage.swift | 22 +- .../TimeoutError+UserFacingMessage.swift | 12 +- ...WiFiTransportError+UserFacingMessage.swift | 42 +- MC1/Extensions/LPPSensorType+Chart.swift | 46 +- .../LPPSensorType+LocalizedName.swift | 68 +- .../NotificationLevel+Display.swift | 28 +- .../ProcessInfo+ScreenshotMode.swift | 6 +- .../RoomPermissionLevel+Display.swift | 14 +- MC1/Extensions/SNRQuality+Color.swift | 50 +- MC1/Extensions/UInt8+Hex.swift | 10 +- MC1/Extensions/View+LiquidGlass.swift | 171 +- MC1/Extensions/View+MapControlButton.swift | 22 +- MC1/Extensions/View+RadioDisabled.swift | 47 +- MC1/Intents/AdvertReach.swift | 26 +- MC1/Intents/AppState+IntentRefresh.swift | 12 +- MC1/Intents/CompositeIDHelpers.swift | 32 +- MC1/Intents/IntentBridge.swift | 8 +- MC1/Intents/IntentError.swift | 44 +- MC1/Intents/MC1AppShortcutsProvider.swift | 60 +- MC1/Intents/MessageRecipient.swift | 20 +- MC1/Intents/MessageTargetEntity.swift | 69 +- MC1/Intents/MessageTargetKind.swift | 10 +- MC1/Intents/MessageTargetQuery.swift | 54 +- MC1/Intents/MessageTargetResolution.swift | 76 +- MC1/Intents/SendAdvertIntent.swift | 100 +- MC1/Intents/SendMessageIntent+Routing.swift | 157 +- MC1/Intents/SendMessageIntent.swift | 380 +- MC1/Intents/SendOutcome.swift | 4 +- MC1/Intents/SendRoute.swift | 24 +- MC1/Intents/StatusQueryIntent.swift | 96 +- MC1/MC1App.swift | 363 +- MC1/Models/ChatFilter.swift | 30 +- MC1/Models/Conversation+Filtering.swift | 68 +- MC1/Models/Conversation.swift | 121 +- MC1/Models/DevicePreferenceStore.swift | 68 +- MC1/Models/LinkPreviewPreferences.swift | 68 +- MC1/Models/WhatsNew/WhatsNewCatalog.swift | 42 +- MC1/Models/WhatsNew/WhatsNewItem.swift | 8 +- MC1/Models/WhatsNew/WhatsNewRelease.swift | 8 +- MC1/Models/WhatsNew/WhatsNewVersion.swift | 30 +- MC1/Services/AppStateProviderImpl.swift | 17 +- MC1/Services/DecodedPreviewCache.swift | 148 +- MC1/Services/DemoInlineImageSeeder.swift | 14 +- MC1/Services/ElevationService.swift | 447 +- MC1/Services/Geocoder.swift | 40 +- MC1/Services/ImageHeaderDecoder.swift | 33 +- MC1/Services/ImageURLDetector.swift | 121 +- MC1/Services/InlineImageCache.swift | 516 +- MC1/Services/InlineImagePrefetcher.swift | 99 +- MC1/Services/LinkPreviewCache.swift | 308 +- MC1/Services/LinkPreviewCaching.swift | 61 +- MC1/Services/LinkPreviewService.swift | 308 +- MC1/Services/LocationService.swift | 383 +- MC1/Services/LogExportService.swift | 325 +- MC1/Services/MapSnapshotStore.swift | 470 +- .../NotificationStringProviderImpl.swift | 73 +- MC1/Services/OfflineMapService.swift | 739 +- MC1/Services/RegionResolver.swift | 155 +- MC1/State/AppState+DeviceActions.swift | 295 +- MC1/State/AppState+Lifecycle.swift | 171 +- MC1/State/AppState+NotificationHandlers.swift | 83 +- MC1/State/AppState+Wiring.swift | 309 +- MC1/State/AppState.swift | 1082 ++- MC1/State/AppTab.swift | 10 +- MC1/State/BatteryMonitor.swift | 321 +- MC1/State/ConnectionUIState.swift | 519 +- MC1/State/LiveActivityManager.swift | 987 +-- MC1/State/MapFocusRequest.swift | 10 +- MC1/State/MessageEvent.swift | 52 +- MC1/State/MessageEventDispatcher.swift | 190 +- MC1/State/MessageEventStream.swift | 45 +- MC1/State/NavigationCoordinator.swift | 507 +- MC1/State/OnboardingState.swift | 117 +- MC1/State/PendingPurchase.swift | 6 +- MC1/State/RestoreState.swift | 12 +- MC1/State/StatusPillState.swift | 64 +- MC1/State/StoreState.swift | 232 +- MC1/State/WhatsNewState.swift | 109 +- MC1/Theme/AppColorSchemePreference.swift | 30 +- MC1/Theme/AppColors.swift | 35 +- MC1/Theme/AppearanceToken.swift | 60 +- MC1/Theme/AvatarCategory.swift | 32 +- MC1/Theme/CategoryAvatarColors.swift | 20 +- MC1/Theme/CategoryHues.swift | 20 +- MC1/Theme/EnvironmentValues+AppTheme.swift | 10 +- MC1/Theme/IdentityGamut.swift | 422 +- MC1/Theme/Theme+AvatarColors.swift | 151 +- MC1/Theme/Theme+LocalizedName.swift | 70 +- MC1/Theme/Theme.swift | 480 +- MC1/Theme/ThemeCardMetrics.swift | 26 +- MC1/Theme/ThemePaletteSwatch.swift | 130 +- MC1/Theme/ThemeRegistry.swift | 14 +- MC1/Theme/ThemeService.swift | 248 +- MC1/Theme/ThemeServiceError.swift | 10 +- MC1/Theme/ThemedSurfaceModifiers.swift | 145 +- MC1/Theme/WCAGContrast.swift | 95 +- MC1/Tips/DeviceMenuTip.swift | 42 +- MC1/Tips/LiveActivityTip.swift | 26 +- MC1/Utilities/AsyncSemaphore.swift | 40 +- MC1/Utilities/DemoModeManager.swift | 38 +- MC1/Utilities/FirmwareSuggestedTimeout.swift | 34 +- MC1/Utilities/ImageByteCost.swift | 20 +- MC1/Utilities/MalwareDomainFilter.swift | 222 +- MC1/Utilities/MeshCoreURLParser.swift | 177 +- MC1/Utilities/PingHelper.swift | 116 +- MC1/Utilities/RepeaterResolver.swift | 434 +- MC1/Utilities/URLSafetyChecker.swift | 282 +- MC1/Views/AppSidebar.swift | 156 +- MC1/Views/Appearance/AppearanceView.swift | 162 +- .../Components/ThemeSelectionCard.swift | 68 +- MC1/Views/Chats/BlockSenderContext.swift | 6 +- MC1/Views/Chats/ChannelAvatar.swift | 52 +- MC1/Views/Chats/ChannelConversationRow.swift | 96 +- MC1/Views/Chats/ChatConversationActions.swift | 77 +- .../Chats/ChatConversationInputBar.swift | 98 +- .../ChatConversationMessagesContent.swift | 396 +- MC1/Views/Chats/ChatConversationType.swift | 274 +- MC1/Views/Chats/ChatConversationView.swift | 1674 ++--- MC1/Views/Chats/ChatCoordinateDetector.swift | 112 +- MC1/Views/Chats/ChatFilterPicker.swift | 20 +- MC1/Views/Chats/ChatListActions.swift | 208 +- MC1/Views/Chats/ChatsListModifiers.swift | 182 +- MC1/Views/Chats/ChatsView.swift | 232 +- .../Chats/Components/AnimatedGIFView.swift | 32 +- .../Chats/Components/BubbleActions.swift | 28 +- .../Components/BubbleContentPadding.swift | 8 +- .../Chats/Components/BubbleFooterRow.swift | 164 +- .../Components/BubbleFragmentStack.swift | 112 +- .../Chats/Components/BubbleResolver.swift | 48 +- .../Chats/Components/BubbleStatusRow.swift | 162 +- .../Components/ChatCellContentFactory.swift | 64 +- .../Chats/Components/ChatComposerProxy.swift | 8 +- .../Components/ChatComposerTextView.swift | 396 +- MC1/Views/Chats/Components/ChatInputBar.swift | 531 +- .../Components/ChatMessagesTableView.swift | 300 +- .../Components/ChatScrollConstants.swift | 48 +- .../ChatScrollDisplayLinkProxy.swift | 8 +- .../ChatScrollToMentionPolicy.swift | 20 +- .../Chats/Components/ChatShareMenu.swift | 256 +- .../Chats/Components/ChatTableView.swift | 2316 +++--- .../FallbackMatchIndicatorView.swift | 82 +- .../Chats/Components/FragmentLayout.swift | 50 +- .../Fragments/InlineImageFragmentView.swift | 147 +- .../Fragments/LinkPreviewFragmentView.swift | 104 +- .../Fragments/MapPreviewFragmentView.swift | 176 +- .../Fragments/MessageBodyTextView.swift | 572 +- .../Fragments/MessageTextView.swift | 46 +- .../Fragments/ReactionsFragmentView.swift | 34 +- .../Fragments/TapYieldingToLongPress.swift | 102 +- .../Components/FullScreenImageViewer.swift | 386 +- .../Chats/Components/InlineImageView.swift | 238 +- .../Chats/Components/LinkPreviewCard.swift | 220 +- .../Components/LinkPreviewLoadingCard.swift | 220 +- .../Chats/Components/MalwareWarningCard.swift | 90 +- .../Components/MentionSuggestionRow.swift | 34 +- .../Components/MentionSuggestionView.swift | 76 +- .../Components/MessageBubbleCallbacks.swift | 22 +- .../MessageBubbleConfiguration.swift | 76 +- .../Components/MessageBubbleLongPress.swift | 106 +- .../Chats/Components/MessageBubbleView.swift | 80 +- .../Components/MessageDayDividerView.swift | 78 +- .../Chats/Components/MessagePathContent.swift | 134 +- .../Components/MessagePathFormatter.swift | 50 +- .../Chats/Components/MessagePathMapView.swift | 453 +- .../Components/MessagePathViewModel.swift | 130 +- MC1/Views/Chats/Components/MessageText.swift | 258 +- .../Components/MessageTimestampView.swift | 24 +- .../Components/NewMessagesDividerView.swift | 32 +- .../Chats/Components/PathHopRowView.swift | 170 +- .../Chats/Components/PreviewSkeleton.swift | 16 +- .../Components/RepeatDetailsContent.swift | 64 +- .../Chats/Components/RepeatRowView.swift | 196 +- .../Chats/Components/RichPreviewCard.swift | 54 +- .../Chats/Components/RichPreviewMetrics.swift | 34 +- .../ScrollState/ChatScrollState.swift | 112 +- .../Components/ScrollToBottomButton.swift | 54 +- .../Components/ScrollToDividerButton.swift | 28 +- .../Components/ScrollToMentionButton.swift | 42 +- MC1/Views/Chats/Components/Shimmer.swift | 62 +- .../Chats/Components/TapToLoadPreview.swift | 60 +- .../Components/UnifiedMessageBubble.swift | 556 +- MC1/Views/Chats/Components/UnreadBadge.swift | 28 +- MC1/Views/Chats/ContactMatchRow.swift | 208 +- MC1/Views/Chats/ConversationActionError.swift | 10 +- .../ConversationContextMenuModifier.swift | 104 +- MC1/Views/Chats/ConversationListContent.swift | 412 +- MC1/Views/Chats/ConversationRow.swift | 100 +- MC1/Views/Chats/ConversationSnapshot.swift | 8 +- .../Chats/CreatePrivateChannelView.swift | 456 +- MC1/Views/Chats/DecodedPreviewAssets.swift | 4 +- MC1/Views/Chats/HashtagDeeplinkSupport.swift | 46 +- .../Linkify/MessageLinkAccessibility.swift | 143 +- .../Chats/Linkify/MessageLinkStyler.swift | 49 +- .../Chats/Linkify/MessageLinkToken.swift | 64 +- .../Chats/Linkify/MessageLinkTokenizer.swift | 391 +- .../Chats/Linkify/MessageTextNormalizer.swift | 365 +- .../ChatConversationMentionOverlay.swift | 44 +- .../Mentions/MentionDeeplinkSupport.swift | 62 +- .../Chats/Mentions/MentionPickerContext.swift | 10 +- .../Chats/Mentions/MentionPickerSheet.swift | 106 +- .../Chats/Mentions/MentionTapEvaluator.swift | 100 +- .../Chats/Mentions/MentionTapHandler.swift | 140 +- .../Chats/Navigation/ChatLinkRouter.swift | 169 +- MC1/Views/Chats/Navigation/ChatRoute.swift | 128 +- .../Chats/Navigation/ChatsContentColumn.swift | 270 +- .../Navigation/ChatsSplitDetailContent.swift | 34 +- .../Navigation/ChatsSplitSidebarContent.swift | 121 +- .../Chats/Navigation/ChatsStackLayout.swift | 72 +- .../Navigation/ChatsStackRootContent.swift | 80 +- MC1/Views/Chats/NewChatView.swift | 152 +- .../Chats/NotificationLevelIndicator.swift | 34 +- .../Chats/Reactions/EmojiCategoryHeader.swift | 32 +- .../Chats/Reactions/EmojiPickerRow.swift | 96 +- .../Chats/Reactions/EmojiPickerSheet.swift | 114 +- .../Reactions/EmojiPickerViewModel.swift | 44 +- MC1/Views/Chats/Reactions/EmojiProvider.swift | 254 +- MC1/Views/Chats/Reactions/MessageAction.swift | 14 +- .../Reactions/MessageActionAvailability.swift | 42 +- .../Chats/Reactions/MessageActionsSheet.swift | 277 +- .../Chats/Reactions/ReactionBadgesView.swift | 190 +- .../Reactions/ReactionDetailsSheet.swift | 222 +- .../Chats/Reactions/RecentEmojisStore.swift | 42 +- .../Reactions/Sections/ActionButton.swift | 28 +- .../Reactions/Sections/ActionInfoRow.swift | 34 +- .../Sections/ActionsButtonsSection.swift | 64 +- .../Sections/ActionsDestructiveSection.swift | 46 +- .../Sections/ActionsDetailsSection.swift | 386 +- .../Sections/ActionsEmojiSection.swift | 44 +- .../Sections/ActionsPreviewHeader.swift | 126 +- .../String+EmojiAccessibilityName.swift | 20 +- .../Chats/Room/RoomAuthenticationSheet.swift | 66 +- .../Chats/Room/RoomConversationRow.swift | 114 +- MC1/Views/Chats/Room/RoomInfoSheet.swift | 206 +- MC1/Views/Chats/ScanChannelQRView.swift | 512 +- MC1/Views/Chats/SendDMContext.swift | 6 +- MC1/Views/Chats/SenderContactMatcher.swift | 30 +- .../Sheets/AddContactConfirmationSheet.swift | 333 +- MC1/Views/Chats/Sheets/BlockSenderSheet.swift | 168 +- MC1/Views/Chats/Sheets/ChannelInfoSheet.swift | 1184 ++-- .../Chats/Sheets/ChannelOptionsSheet.swift | 424 +- .../Sheets/ChatConversationInfoSheet.swift | 54 +- .../Sheets/ChatsConversationSheets.swift | 222 +- .../Sheets/JoinChannelConfirmationSheet.swift | 400 +- .../Chats/Sheets/JoinHashtagChannelView.swift | 324 +- .../Sheets/JoinHashtagFromMessageView.swift | 414 +- .../Chats/Sheets/JoinPrivateChannelView.swift | 224 +- .../Chats/Sheets/JoinPublicChannelView.swift | 168 +- MC1/Views/Chats/Sheets/SendDMSheet.swift | 150 +- .../Sheets/ShareContactPickerSheet.swift | 220 +- MC1/Views/Chats/UnreadBadges.swift | 56 +- .../ViewModel/ChatViewModel+Channels.swift | 561 +- .../ChatViewModel+ConversationCache.swift | 107 +- .../ChatViewModel+DisplayItems.swift | 307 +- .../ViewModel/ChatViewModel+EventStream.swift | 125 +- .../ViewModel/ChatViewModel+ItemBuild.swift | 336 +- .../ChatViewModel+MessageActions.swift | 307 +- .../ViewModel/ChatViewModel+Messages.swift | 1058 ++- .../ViewModel/ChatViewModel+Pagination.swift | 217 +- .../ViewModel/ChatViewModel+Prefetch.swift | 839 ++- .../ChatViewModel+ReactionIndexing.swift | 204 +- .../ViewModel/ChatViewModel+Reactions.swift | 363 +- .../ViewModel/ChatViewModel+SendQueues.swift | 59 +- MC1/Views/Chats/ViewModel/ChatViewModel.swift | 1025 +-- MC1/Views/Components/AsyncActionLabel.swift | 36 +- .../Components/BLEStatusIndicatorView.swift | 366 +- .../Components/BLEStatusToolbarItem.swift | 6 +- MC1/Views/Components/ContactAvatar.swift | 82 +- .../ConversationQuickActionsSection.swift | 46 +- .../Components/ConversationTimestamp.swift | 50 +- MC1/Views/Components/DeletingRowOverlay.swift | 34 +- MC1/Views/Components/ErrorAlertModifier.swift | 70 +- .../Components/ErrorBannerModifier.swift | 82 +- .../ExpandableSettingsSection.swift | 214 +- MC1/Views/Components/FavoriteToggleRow.swift | 26 +- MC1/Views/Components/GlassFilterBar.swift | 299 +- MC1/Views/Components/MapControlsToolbar.swift | 156 +- .../Components/NavigationHeaderModifier.swift | 116 +- MC1/Views/Components/NodeAvatar.swift | 94 +- .../Components/NotificationLevelPicker.swift | 92 +- MC1/Views/Components/RSSIScanTracker.swift | 94 +- MC1/Views/Components/RSSITuning.swift | 144 +- .../Components/RadioCommandTimeout.swift | 8 +- MC1/Views/Components/RadioParameterText.swift | 22 +- .../RegionDiscoveryResultsView.swift | 102 +- .../Components/RegionManagementView.swift | 300 +- .../Components/RegionNameValidator.swift | 48 +- .../Components/RelativeTimestampText.swift | 74 +- .../Components/SectionReloadButton.swift | 52 +- .../Components/SelectedRowHighlight.swift | 36 +- MC1/Views/Components/SignalBars.swift | 38 +- MC1/Views/Components/SyncingPillOverlay.swift | 102 +- MC1/Views/Components/SyncingPillView.swift | 143 +- MC1/Views/Components/TintedLabel.swift | 26 +- MC1/Views/Components/WiFiAddressFields.swift | 154 +- .../Components/WiFiSheetToolbarModifier.swift | 84 +- MC1/Views/Contacts/AddContactSheet.swift | 494 +- MC1/Views/Contacts/BlockedContactsView.swift | 130 +- .../Contacts/ContactContextMenuModifier.swift | 88 +- MC1/Views/Contacts/ContactDetailView.swift | 2176 +++--- MC1/Views/Contacts/ContactListActions.swift | 98 +- MC1/Views/Contacts/ContactQRShareSheet.swift | 316 +- MC1/Views/Contacts/ContactRoute.swift | 66 +- MC1/Views/Contacts/ContactRowView.swift | 270 +- .../Contacts/ContactsContentColumn.swift | 94 +- MC1/Views/Contacts/ContactsDetailColumn.swift | 30 +- MC1/Views/Contacts/ContactsEmptyView.swift | 56 +- MC1/Views/Contacts/ContactsListContent.swift | 307 +- MC1/Views/Contacts/ContactsListView.swift | 96 +- .../Contacts/ContactsSearchEmptyView.swift | 16 +- .../Contacts/ContactsSidebarContent.swift | 428 +- MC1/Views/Contacts/ContactsViewModel.swift | 634 +- .../Contacts/DiscoverSegmentPicker.swift | 20 +- MC1/Views/Contacts/DiscoveryView.swift | 731 +- MC1/Views/Contacts/DiscoveryViewModel.swift | 317 +- MC1/Views/Contacts/NodeSegmentPicker.swift | 20 +- MC1/Views/Contacts/ScanContactQRView.swift | 378 +- MC1/Views/MainSidebarView.swift | 478 +- MC1/Views/Map/ContactCalloutContent.swift | 144 +- MC1/Views/Map/ContactDetailSheet.swift | 633 +- MC1/Views/Map/DroppedPinCallout.swift | 54 +- MC1/Views/Map/LayersMenu.swift | 114 +- MC1/Views/Map/LocationPickerView.swift | 522 +- MC1/Views/Map/MC1MapView+Layers.swift | 829 ++- MC1/Views/Map/MC1MapView.swift | 947 +-- MC1/Views/Map/MapCameraStore.swift | 35 +- MC1/Views/Map/MapCanvasView.swift | 207 +- MC1/Views/Map/MapContentView.swift | 197 +- MC1/Views/Map/MapLine+SNR.swift | 114 +- MC1/Views/Map/MapLine.swift | 44 +- MC1/Views/Map/MapPoint.swift | 66 +- MC1/Views/Map/MapSnapshotLayout.swift | 12 +- MC1/Views/Map/MapSnapshotRenderer.swift | 126 +- MC1/Views/Map/MapSnapshotRendering.swift | 6 +- MC1/Views/Map/MapSnapshotRequest.swift | 54 +- MC1/Views/Map/MapStyleSelection.swift | 62 +- MC1/Views/Map/MapTileURLs.swift | 12 +- MC1/Views/Map/MapView.swift | 190 +- MC1/Views/Map/MapViewModel.swift | 244 +- MC1/Views/Map/OfflineBadge.swift | 30 +- MC1/Views/Map/PathDistanceBanner.swift | 46 +- MC1/Views/Map/PinSpriteRenderer.swift | 862 +-- MC1/Views/Onboarding/DeviceScanView.swift | 437 +- MC1/Views/Onboarding/DeviceScannerSheet.swift | 198 +- MC1/Views/Onboarding/MeshAnimationView.swift | 320 +- MC1/Views/Onboarding/NoDeviceSheet.swift | 88 +- MC1/Views/Onboarding/OnboardingMetrics.swift | 24 +- MC1/Views/Onboarding/PermissionCard.swift | 130 +- .../Onboarding/PermissionsCoordinator.swift | 88 +- MC1/Views/Onboarding/PermissionsView.swift | 166 +- MC1/Views/Onboarding/PresetStepView.swift | 457 +- MC1/Views/Onboarding/PulsingAntenna.swift | 18 +- MC1/Views/Onboarding/RegionPickerView.swift | 255 +- MC1/Views/Onboarding/RegionStepView.swift | 339 +- .../Onboarding/TroubleshootingSheet.swift | 184 +- MC1/Views/Onboarding/WelcomeView.swift | 78 +- .../Onboarding/WiFiConnectionSheet.swift | 261 +- .../AddHopPickerPresentation.swift | 54 +- MC1/Views/PathEditing/AddHopPickerView.swift | 854 +-- .../PathEditing/AddHopSegmentPicker.swift | 20 +- MC1/Views/PathEditing/CodeInputResult.swift | 42 +- .../PathEditing/HopCodeClassification.swift | 34 +- MC1/Views/PathEditing/HopCodeParser.swift | 108 +- MC1/Views/PathEditing/HopNodeMatching.swift | 44 +- MC1/Views/PathEditing/HopPickerSource.swift | 54 +- MC1/Views/PathEditing/NodeKindBadge.swift | 20 +- MC1/Views/PathEditing/PathEditCTALabel.swift | 26 +- MC1/Views/PathEditing/PathEditMetrics.swift | 30 +- MC1/Views/PathEditing/PathEditingSheet.swift | 464 +- .../PathEditing/PathManagementViewModel.swift | 1204 ++-- MC1/Views/PathEditing/PickerNode.swift | 84 +- MC1/Views/PathEditing/RecentHopsStore.swift | 56 +- MC1/Views/RemoteNodes/ChannelGroup.swift | 92 +- .../RemoteNodes/DisappearedNeighborRow.swift | 48 +- .../RemoteNodes/HistoryTimeRangePicker.swift | 52 +- MC1/Views/RemoteNodes/MetricChartView.swift | 436 +- MC1/Views/RemoteNodes/NeighborRow.swift | 162 +- .../RemoteNodes/NeighborSNRChartView.swift | 70 +- .../RemoteNodes/NodeAuthPathViewModel.swift | 50 +- .../RemoteNodes/NodeAuthenticationSheet.swift | 800 +-- MC1/Views/RemoteNodes/NodeCLIView.swift | 144 +- MC1/Views/RemoteNodes/NodeCLIViewModel.swift | 504 +- MC1/Views/RemoteNodes/NodeManagementTab.swift | 20 +- .../RemoteNodes/NodeManagementTabPicker.swift | 20 +- .../RemoteNodes/NodeRoutePathSection.swift | 168 +- MC1/Views/RemoteNodes/NodeSettingsError.swift | 10 +- .../RemoteNodes/NodeSettingsViewModel.swift | 1162 +-- .../RemoteNodes/NodeStatusHistoryView.swift | 61 +- MC1/Views/RemoteNodes/NodeStatusRoute.swift | 44 +- .../RemoteNodes/NodeStatusViewModel.swift | 945 +-- MC1/Views/RemoteNodes/NodeTelemetryView.swift | 98 +- .../RemoteNodes/NodeTelemetryViewModel.swift | 96 +- MC1/Views/RemoteNodes/RadioMetricCharts.swift | 172 +- .../Repeaters/NeighborSNRMapBuilder.swift | 204 +- .../Repeaters/NeighborSNRMapView.swift | 324 +- .../Repeaters/RepeaterSettingsView.swift | 868 +-- .../Repeaters/RepeaterSettingsViewModel.swift | 929 +-- .../Repeaters/RepeaterStatusContent.swift | 456 +- .../Repeaters/RepeaterStatusView.swift | 164 +- .../Repeaters/RepeaterStatusViewModel.swift | 411 +- .../Rooms/RoomConversationView.swift | 761 +- .../Rooms/RoomConversationViewModel.swift | 402 +- .../RemoteNodes/Rooms/RoomMessageAction.swift | 8 +- .../Rooms/RoomMessageActionAvailability.swift | 16 +- .../Rooms/RoomMessageActionsSheet.swift | 323 +- .../RemoteNodes/Rooms/RoomMessageBubble.swift | 374 +- .../Rooms/RoomMessageDTO+Status.swift | 44 +- .../RemoteNodes/Rooms/RoomSettingsView.swift | 578 +- .../Rooms/RoomSettingsViewModel.swift | 616 +- .../RemoteNodes/Rooms/RoomStatusContent.swift | 76 +- .../RemoteNodes/Rooms/RoomStatusView.swift | 112 +- .../Rooms/RoomStatusViewModel.swift | 217 +- .../RemoteNodes/SettingsLoadPlaceholder.swift | 22 +- .../RemoteNodes/SharedNodeSettingsViews.swift | 764 +- .../RemoteNodes/SharedNodeStatusViews.swift | 510 +- MC1/Views/RemoteNodes/StatusDeltaView.swift | 66 +- .../TelemetryHistoryOverviewView.swift | 366 +- .../TelemetryHistoryOverviewViewModel.swift | 135 +- .../RemoteNodes/TelemetryHistoryView.swift | 91 +- MC1/Views/Settings/AdvancedSettingsView.swift | 314 +- MC1/Views/Settings/AppBackupDocument.swift | 30 +- .../Settings/AppBackupError+Localized.swift | 46 +- MC1/Views/Settings/AppBackupViewModel.swift | 590 +- .../Settings/BackupModelKind+Label.swift | 34 +- MC1/Views/Settings/BackupRestoreView.swift | 260 +- .../Settings/BlockedChannelSendersView.swift | 148 +- MC1/Views/Settings/ChatSettingsView.swift | 42 +- .../Components/BatteryCurveChart.swift | 107 +- .../Settings/ConnectionSettingsView.swift | 40 +- MC1/Views/Settings/DeviceInfoView.swift | 614 +- MC1/Views/Settings/DeviceSelectionSheet.swift | 702 +- MC1/Views/Settings/ExportSuccessContent.swift | 126 +- MC1/Views/Settings/ImportPreviewSheet.swift | 365 +- MC1/Views/Settings/ImportSuccessContent.swift | 280 +- MC1/Views/Settings/LocationSettingsView.swift | 26 +- .../NodeConfig/NodeConfigExportView.swift | 218 +- .../NodeConfigExportViewModel.swift | 108 +- .../NodeConfig/NodeConfigImportView.swift | 506 +- .../NodeConfigImportViewModel.swift | 590 +- .../Settings/NotificationSettingsView.swift | 16 +- .../Settings/OfflineMapSettingsView.swift | 776 +- MC1/Views/Settings/PublicKeyView.swift | 72 +- MC1/Views/Settings/RadioSettingsView.swift | 16 +- .../Settings/Sections/AboutSection.swift | 128 +- .../Settings/Sections/ActivityView.swift | 10 +- .../Sections/AdvancedRadioSection.swift | 464 +- .../Settings/Sections/AppearanceSection.swift | 24 +- .../Sections/BatteryCurveSection.swift | 354 +- .../Settings/Sections/BlockingSection.swift | 26 +- .../Settings/Sections/BluetoothSection.swift | 476 +- .../Sections/ConfigExportImportSection.swift | 44 +- .../Settings/Sections/DangerZoneSection.swift | 218 +- .../Sections/DangerZoneViewModel.swift | 218 +- .../Sections/DefaultFloodScopeSection.swift | 340 +- .../Sections/DeviceActionsSection.swift | 92 +- .../Sections/DeviceIdentitySection.swift | 44 +- .../Sections/DiagnosticsSection.swift | 128 +- .../DirectMessagesSettingsSection.swift | 108 +- .../Settings/Sections/ExportedLogFile.swift | 4 +- .../Settings/Sections/ImportKeySheet.swift | 162 +- .../Sections/ImportKeyViewModel.swift | 128 +- .../Sections/LinkPreviewSettingsSection.swift | 72 +- .../Sections/LocationSettingsSection.swift | 590 +- .../Sections/MapPreviewSettingsSection.swift | 34 +- .../Sections/MessagesSettingsSection.swift | 40 +- .../Settings/Sections/NoDeviceSection.swift | 32 +- .../Sections/NodesSettingsSection.swift | 436 +- .../NotificationSettingsSection.swift | 170 +- .../Sections/PathHashModeSection.swift | 122 +- .../Sections/RadioPresetSection.swift | 566 +- .../Sections/RegenerateIdentitySheet.swift | 268 +- .../RegenerateIdentityViewModel.swift | 200 +- .../Sections/RetryAlertModifier.swift | 114 +- .../Sections/StaleNodeCleanupSection.swift | 111 +- .../Sections/TelemetrySettingsSection.swift | 286 +- .../Settings/Sections/WiFiEditSheet.swift | 235 +- MC1/Views/Settings/Sections/WiFiSection.swift | 54 +- MC1/Views/Settings/SettingsDetail.swift | 40 +- MC1/Views/Settings/SettingsDetailRow.swift | 12 +- MC1/Views/Settings/SettingsDetailView.swift | 64 +- MC1/Views/Settings/SettingsListContent.swift | 435 +- MC1/Views/Settings/SettingsSubpage.swift | 56 +- MC1/Views/Settings/SettingsView.swift | 32 +- .../Settings/TrustedContactsPickerView.swift | 244 +- .../Sidebar/SidebarBackgroundExtension.swift | 28 +- .../SidebarContentColumnBackground.swift | 29 +- .../Support/Components/ContributionRow.swift | 118 +- .../Support/Components/ThemeBundleCard.swift | 114 +- .../Support/Components/ThemePreviewCard.swift | 68 +- .../Sections/ContributionsSection.swift | 70 +- .../Sections/PendingPurchaseBanner.swift | 16 +- .../Support/Sections/RefundLinkSection.swift | 66 +- .../Sections/SupportContactSection.swift | 40 +- .../Sections/SupportHeaderSection.swift | 16 +- .../Sections/ThemesPurchaseSection.swift | 122 +- .../Support/SupportDevelopmentView.swift | 117 +- MC1/Views/Tools/CLI/CLICompletionEngine.swift | 417 +- .../Tools/CLI/CLIInputAccessoryView.swift | 188 +- MC1/Views/Tools/CLI/CLIOutputLine.swift | 34 +- MC1/Views/Tools/CLI/CLISession.swift | 22 +- MC1/Views/Tools/CLI/CLITerminalView.swift | 454 +- MC1/Views/Tools/CLI/CLIToolView.swift | 258 +- .../CLI/CLIToolViewModel+Completion.swift | 231 +- .../Tools/CLI/CLIToolViewModel+Sessions.swift | 585 +- MC1/Views/Tools/CLI/CLIToolViewModel.swift | 689 +- MC1/Views/Tools/CLI/HiddenTextView.swift | 457 +- .../LineOfSight/AddRepeaterRowView.swift | 50 +- .../Tools/LineOfSight/AnalysisErrorView.swift | 42 +- .../LineOfSight/ChartCoordinateSpace.swift | 58 +- .../LineOfSight/ClearanceStatus+UI.swift | 46 +- .../Components/ClearanceStatusView.swift | 58 +- .../Components/LOSFormatters.swift | 117 +- .../Components/ResultsCardView.swift | 684 +- .../LineOfSight/FresnelZoneRenderer.swift | 222 +- .../Tools/LineOfSight/HeightEditorGrid.swift | 116 +- .../LineOfSight/LineOfSightLayoutMode.swift | 6 +- .../Tools/LineOfSight/LineOfSightView.swift | 1070 +-- .../LineOfSight/LineOfSightViewModel.swift | 2304 +++--- .../LineOfSight/PointHeightEditorView.swift | 26 +- .../LineOfSight/PointRowButtonsView.swift | 170 +- .../Tools/LineOfSight/PointRowView.swift | 146 +- .../PointsSummarySectionView.swift | 194 +- .../LineOfSight/RFSettingsSectionView.swift | 58 +- .../RepeaterHeightEditorView.swift | 26 +- .../Tools/LineOfSight/RepeaterRowView.swift | 106 +- .../LineOfSight/TerrainProfileCanvas.swift | 1367 ++-- .../TerrainProfileSectionView.swift | 96 +- .../NodeDiscovery/NodeDiscoveryRowView.swift | 148 +- .../NodeDiscovery/NodeDiscoveryView.swift | 324 +- .../NodeDiscoveryViewModel.swift | 488 +- MC1/Views/Tools/NoiseFloorView.swift | 492 +- MC1/Views/Tools/NoiseFloorViewModel.swift | 258 +- MC1/Views/Tools/RxLogView.swift | 1012 +-- MC1/Views/Tools/RxLogViewModel.swift | 376 +- MC1/Views/Tools/ToolDestinationView.swift | 22 +- MC1/Views/Tools/ToolSelection.swift | 70 +- MC1/Views/Tools/ToolsContentColumn.swift | 118 +- MC1/Views/Tools/ToolsDetailColumn.swift | 54 +- MC1/Views/Tools/ToolsView.swift | 42 +- MC1/Views/Tools/TracePath/BatchRTTRow.swift | 40 +- MC1/Views/Tools/TracePath/BatchSizeChip.swift | 32 +- .../Tools/TracePath/ComparisonRowView.swift | 96 +- .../TracePath/DistanceInfoSheetView.swift | 106 +- .../Map/PathActionsSectionView.swift | 150 +- .../TracePath/Map/RunTraceSectionView.swift | 130 +- .../Map/TracePathFloatingButtonsView.swift | 128 +- .../Map/TracePathMapToolbarView.swift | 90 +- .../TracePath/Map/TracePathMapView.swift | 251 +- .../TracePath/Map/TracePathMapViewModel.swift | 620 +- MC1/Views/Tools/TracePath/MiniSparkline.swift | 48 +- .../Tools/TracePath/SavePathRowView.swift | 90 +- .../Tools/TracePath/SavedPathDetailView.swift | 314 +- .../TracePath/SavedPathDetailViewModel.swift | 95 +- MC1/Views/Tools/TracePath/SavedPathRow.swift | 134 +- .../Tools/TracePath/SavedPathsSheet.swift | 226 +- .../Tools/TracePath/SavedPathsViewModel.swift | 111 +- .../Tools/TracePath/TotalDistanceRow.swift | 90 +- MC1/Views/Tools/TracePath/TraceHop.swift | 42 +- .../Tools/TracePath/TracePathHopRow.swift | 36 +- .../Tools/TracePath/TracePathListView.swift | 192 +- .../Tools/TracePath/TracePathRoute.swift | 12 +- MC1/Views/Tools/TracePath/TracePathView.swift | 340 +- .../Tools/TracePath/TracePathViewModel.swift | 2222 +++--- MC1/Views/Tools/TracePath/TraceResult.swift | 46 +- .../Tools/TracePath/TraceResultHopRow.swift | 162 +- .../TracePath/TraceResultsSectionView.swift | 136 +- .../Tools/TracePath/TraceResultsSheet.swift | 112 +- MC1/Views/WhatsNew/WhatsNewSheet.swift | 160 +- MC1Services/Package.swift | 42 +- .../BLEReconnectionCoordinator.swift | 443 +- .../Connection/ConnectionIntent.swift | 75 +- .../Connection/ConnectionManager+BLE.swift | 1225 ++-- ...ctionManager+BLEReconnectionDelegate.swift | 455 +- .../ConnectionManager+Lifecycle.swift | 1404 ++-- .../ConnectionManager+Pairing.swift | 989 ++- .../Connection/ConnectionManager+WiFi.swift | 663 +- .../Connection/ConnectionManager.swift | 2050 +++--- .../Connection/DeviceConnectionState.swift | 46 +- .../Connection/DevicePlatform.swift | 170 +- .../Connection/DisconnectReason.swift | 20 +- .../Connection/LastConnectionStore.swift | 103 +- .../Connection/RemoveUnfavoritedResult.swift | 4 +- .../Connection/TransportType.swift | 4 +- .../MC1Services/DTOs/LinkPreviewDataDTO.swift | 74 +- .../MC1Services/Errors/AppBackupError.swift | 48 +- .../Sources/MC1Services/Errors/BLEError.swift | 90 +- .../MC1Services/Errors/ConnectionError.swift | 30 +- .../Errors/MeshCoreError+LocalizedError.swift | 102 +- .../MC1Services/Errors/PairingError.swift | 56 +- .../MC1Services/Errors/ProtocolError.swift | 30 +- .../MC1Services/Errors/RemoteNodeError.swift | 104 +- .../Errors/SettingsServiceError.swift | 60 +- .../Errors/StoreServiceError.swift | 72 +- .../WiFiTransportError+LocalizedError.swift | 38 +- .../MC1Services/Extensions/Bundle+App.swift | 16 +- .../Extensions/Character+EmojiDetection.swift | 8 +- .../Extensions/Data+Extensions.swift | 138 +- .../Extensions/LPPDataPoint+Display.swift | 104 +- .../LPPSensorType+LocaleUnits.swift | 107 +- .../MC1Services/Extensions/Locale+POSIX.swift | 8 +- .../StatusResponse+Compatibility.swift | 32 +- .../Extensions/String+StableUUID.swift | 66 +- .../TelemetryResponse+DataPoints.swift | 12 +- .../Sources/MC1Services/MC1Services.swift | 2 +- .../MC1Services/Models/AutoAddMode.swift | 60 +- .../Models/BlockedChannelSender.swift | 92 +- .../Sources/MC1Services/Models/Channel.swift | 928 +-- .../Models/ChannelFloodScope.swift | 52 +- .../Models/ChannelMessageEnvelope.swift | 42 +- .../Models/ChatConversationID.swift | 62 +- .../MC1Services/Models/ConnectionMethod.swift | 87 +- .../Sources/MC1Services/Models/Contact.swift | 1012 +-- .../MC1Services/Models/ContactFrame.swift | 78 +- .../MC1Services/Models/DebugLogEntry.swift | 108 +- .../MC1Services/Models/DebugLogLevel.swift | 30 +- .../MC1Services/Models/DecryptStatus.swift | 58 +- .../MC1Services/Models/DeliveryContext.swift | 14 +- .../Sources/MC1Services/Models/Device.swift | 1478 ++-- .../Models/DirectMessageEnvelope.swift | 16 +- .../MC1Services/Models/DiscoveredNode.swift | 355 +- .../MC1Services/Models/LinkPreviewData.swift | 82 +- .../Sources/MC1Services/Models/Message.swift | 1323 ++-- .../MC1Services/Models/MessageEnvelope.swift | 90 +- .../MC1Services/Models/MessageRepeat.swift | 322 +- .../MC1Services/Models/NodeConfig.swift | 484 +- .../Models/NodeStatusSnapshot.swift | 497 +- .../Models/NotificationLevel.swift | 56 +- .../Models/NotificationPreferences.swift | 262 +- .../MC1Services/Models/OCVPreset.swift | 250 +- .../MC1Services/Models/PendingSend.swift | 330 +- .../Models/PendingSendEnvelope.swift | 150 +- .../MC1Services/Models/ProtocolLimits.swift | 64 +- .../MC1Services/Models/ProtocolTypes.swift | 89 +- .../Sources/MC1Services/Models/Reaction.swift | 249 +- .../MC1Services/Models/RegionSelection.swift | 64 +- .../Models/RemoteNodeSession.swift | 733 +- .../Models/Rendering/BaseColorSlot.swift | 4 +- .../Models/Rendering/ChatRenderState.swift | 185 +- .../Models/Rendering/EnvInputs.swift | 178 +- .../Models/Rendering/GroupingFlags.swift | 38 +- .../Models/Rendering/ImageReference.swift | 18 +- .../Models/Rendering/InlineImage.swift | 35 +- .../Rendering/LinkPreviewFragmentState.swift | 53 +- .../Rendering/MapPreviewFragmentState.swift | 30 +- .../Models/Rendering/MessageBuildInputs.swift | 152 +- .../Models/Rendering/MessageFooter.swift | 148 +- .../Models/Rendering/MessageFragment.swift | 22 +- .../Models/Rendering/MessageItem.swift | 90 +- .../Models/Rendering/MessageTextPayload.swift | 36 +- .../Models/Rendering/NodeNameResolution.swift | 24 +- .../Models/Rendering/PreviewLoadState.swift | 12 +- .../Models/Rendering/SNRQuality.swift | 58 +- .../MC1Services/Models/RoomMessage.swift | 396 +- .../MC1Services/Models/RxLogEntry.swift | 568 +- .../MC1Services/Models/SavedTracePath.swift | 406 +- .../Protocols/AppStateProvider.swift | 6 +- .../Protocols/ChannelServiceProtocol.swift | 43 +- .../Protocols/ContactServiceProtocol.swift | 15 +- .../MessagePollingServiceProtocol.swift | 47 +- .../NotificationStringProvider.swift | 72 +- .../Persistence/ChannelPersisting.swift | 147 +- .../Persistence/ContactPersisting.swift | 145 +- .../Persistence/DebugLogPersisting.swift | 21 +- .../Persistence/DevicePersisting.swift | 19 +- .../DiscoveredNodePersisting.swift | 37 +- .../Persistence/HeardRepeatPersisting.swift | 29 +- .../Persistence/LinkPreviewPersisting.swift | 9 +- .../Persistence/MessagePersisting.swift | 307 +- .../Persistence/NodeSnapshotPersisting.swift | 137 +- .../Persistence/ReactionPersisting.swift | 29 +- .../Persistence/RoomPersisting.swift | 119 +- .../Persistence/RxLogPersisting.swift | 145 +- .../Persistence/TracePathPersisting.swift | 25 +- .../Protocols/PersistenceStoreProtocol.swift | 26 +- .../Protocols/RepeaterResolvable.swift | 18 +- .../MC1Services/RF/ClearanceStatus.swift | 8 +- .../MC1Services/RF/ElevationSample.swift | 18 +- .../MC1Services/RF/ObstructionPoint.swift | 36 +- .../MC1Services/RF/PathAnalysisResult.swift | 128 +- .../Sources/MC1Services/RF/RFCalculator.swift | 790 ++- .../RF/RelayPathAnalysisResult.swift | 36 +- .../RF/SegmentAnalysisResult.swift | 40 +- .../MC1Services/ServiceContainer.swift | 788 ++- .../AccessorySetupKitDiscoveryCriteria.swift | 64 +- .../AccessorySetupKitLogFormatter.swift | 60 +- .../Services/AccessorySetupKitService.swift | 845 +-- .../Services/AccessorySetupKitServicing.swift | 20 +- .../AccessorySetupPairingService.swift | 129 +- .../MC1Services/Services/AckCodeBuilder.swift | 36 +- .../Services/AdvertLocationPolicy.swift | 12 +- .../Services/AdvertisementEvent.swift | 42 +- .../Services/AdvertisementService.swift | 847 ++- .../Services/AppBackupEnvelope.swift | 506 +- .../Services/AppBackupService.swift | 209 +- .../MC1Services/Services/AppStorageKey.swift | 128 +- .../Services/BLETransportOpenedSignal.swift | 111 +- .../Services/BackupUserDefaults.swift | 431 +- .../Services/BinaryProtocolService.swift | 483 +- .../BluetoothScanPairingService.swift | 177 +- .../MC1Services/Services/CLIResponse.swift | 246 +- .../Services/ChannelFloodScopeResolver.swift | 32 +- .../MC1Services/Services/ChannelService.swift | 1514 ++-- .../Services/ChatCoordinator+Mutations.swift | 335 +- .../Services/ChatCoordinator+Rebuild.swift | 129 +- .../Services/ChatCoordinator+Reload.swift | 264 +- .../Services/ChatCoordinator.swift | 257 +- .../Services/ChatCoordinatorRegistry.swift | 81 +- .../Services/ChatSendQueueService.swift | 1339 ++-- .../Services/CommandAuditLogger.swift | 261 +- .../Services/ContactCleanupCoordinator.swift | 85 +- .../Services/ContactCleanupHandling.swift | 15 +- .../MC1Services/Services/ContactService.swift | 1395 ++-- .../Services/ContactServiceEvent.swift | 8 +- .../MC1Services/Services/DebugLogBuffer.swift | 134 +- .../MC1Services/Services/DeviceGPSState.swift | 12 +- .../Services/DevicePairingDelegate.swift | 10 +- .../Services/DevicePairingError.swift | 24 +- .../Services/DevicePairingFactory.swift | 14 +- .../Services/DevicePairingService.swift | 104 +- .../MC1Services/Services/DeviceService.swift | 118 +- .../MC1Services/Services/DraftStore.swift | 119 +- .../Services/FirmwareDeviceErrorCode.swift | 36 +- .../Services/HeardRepeatEvent.swift | 8 +- .../Services/HeardRepeatsService.swift | 264 +- .../Services/InboundHopAdoption.swift | 32 +- .../Services/InlineImageDimensionsStore.swift | 207 +- .../Services/KeepAliveRetryPolicy.swift | 122 +- .../Services/KeyGenerationService.swift | 219 +- .../Services/KeychainService.swift | 240 +- .../MC1Services/Services/LoginResult.swift | 26 +- .../Services/LoginTimeoutConfig.swift | 28 +- .../Services/MeshCoreOpenReactionParser.swift | 387 +- .../Services/MessageFragmentBuilder.swift | 466 +- .../Services/MessageLRUCache.swift | 200 +- .../Services/MessagePollingService.swift | 577 +- .../Services/MessageService+ACK.swift | 235 +- .../Services/MessageService+SendChannel.swift | 529 +- .../Services/MessageService+SendDM.swift | 1401 ++-- .../Services/MessageService+SendHelpers.swift | 77 +- .../MC1Services/Services/MessageService.swift | 369 +- .../Services/MessageServiceConfig.swift | 166 +- .../Services/MessageServiceError.swift | 48 +- .../Services/MessageStatusEvent.swift | 34 +- .../Services/NodeConfigImportPlanner.swift | 624 +- .../Services/NodeConfigService.swift | 1074 +-- .../Services/NodeSettingsResponseParser.swift | 377 +- .../Services/NodeSnapshotService.swift | 124 +- .../Services/NotificationActionHandler.swift | 435 +- .../Services/NotificationService.swift | 1841 +++-- .../MC1Services/Services/PendingAck.swift | 20 +- .../PersistenceStore+BackupBatchInsert.swift | 986 ++- .../PersistenceStore+BackupExport.swift | 255 +- .../PersistenceStore+BackupHelpers.swift | 481 +- .../PersistenceStore+BackupImport.swift | 1322 ++-- .../Services/PersistenceStore+Channels.swift | 825 ++- .../Services/PersistenceStore+Contacts.swift | 667 +- .../Services/PersistenceStore+Devices.swift | 839 +-- .../PersistenceStore+Diagnostics.swift | 1324 ++-- .../Services/PersistenceStore+Messages.swift | 1533 ++-- .../Services/PersistenceStore+Metadata.swift | 223 +- .../Services/PersistenceStore+Migration.swift | 467 +- .../PersistenceStore+PendingSends.swift | 629 +- .../Services/PersistenceStore+Rooms.swift | 729 +- .../Services/PersistenceStore.swift | 550 +- .../Services/PersistentLogger.swift | 106 +- .../MC1Services/Services/RadioOptions.swift | 120 +- .../MC1Services/Services/RadioPresets.swift | 572 +- .../MC1Services/Services/ReactionParser.swift | 375 +- .../Services/ReactionService.swift | 558 +- .../Services/RegionDiscoveryService.swift | 327 +- .../MC1Services/Services/RegionalAreas.swift | 335 +- .../Services/RemoteNodeEvent.swift | 6 +- .../Services/RemoteNodeService+CLI.swift | 363 +- .../Services/RemoteNodeService+Login.swift | 705 +- .../RemoteNodeService+Reconnection.swift | 159 +- .../RemoteNodeService+Telemetry.swift | 113 +- .../Services/RemoteNodeService.swift | 656 +- .../RemoteOperationTimeoutPolicy.swift | 30 +- .../Services/RepeaterAdminService.swift | 607 +- .../Services/ResolvedFloodScope.swift | 10 +- .../MC1Services/Services/RestoreOutcome.swift | 4 +- .../Services/RoomAdminService.swift | 295 +- .../Services/RoomServerEvent.swift | 8 +- .../Services/RoomServerService.swift | 1065 ++- .../RxLogService+RegionResolution.swift | 316 +- .../MC1Services/Services/RxLogService.swift | 1013 +-- .../Services/SceneStorageKey.swift | 8 +- .../MC1Services/Services/SendQueue.swift | 187 +- .../MC1Services/Services/SettingsEvent.swift | 12 +- .../Services/SettingsService+Verified.swift | 396 +- .../Services/SettingsService.swift | 996 +-- .../MC1Services/Services/StoreCatalog.swift | 62 +- .../MC1Services/Services/StoreLoadState.swift | 8 +- .../Services/StorePurchaseOutcome.swift | 8 +- .../MC1Services/Services/StoreService.swift | 456 +- .../MC1Services/Services/TelemetryModes.swift | 34 +- .../MockDataProvider+ChannelMessages.swift | 253 +- .../Simulator/MockDataProvider+Channels.swift | 99 +- .../Simulator/MockDataProvider+Contacts.swift | 350 +- .../MockDataProvider+DMMessages.swift | 587 +- .../MockDataProvider+DemoImage.swift | 28 +- .../MockDataProvider+Reactions.swift | 101 +- .../Simulator/MockDataProvider+Repeats.swift | 83 +- .../Simulator/MockDataProvider.swift | 158 +- .../Simulator/MockMessageFactory.swift | 118 +- .../Simulator/SimulatorConnectionMode.swift | 157 +- .../Simulator/SimulatorMockTransport.swift | 50 +- .../Sync/ConnectionManager+SyncRetry.swift | 546 +- .../Sync/DevicePlatform+SyncThrottling.swift | 45 +- .../Sync/SyncCoordinator+HandlerHelpers.swift | 328 +- .../SyncCoordinator+MessageHandlers.swift | 1056 ++- .../SyncCoordinator+ReactionHandlers.swift | 912 ++- .../Sync/SyncCoordinator+Sync.swift | 1378 ++-- .../MC1Services/Sync/SyncCoordinator.swift | 699 +- .../MC1Services/Sync/SyncDataEvent.swift | 24 +- .../MC1Services/Sync/SyncDependencies.swift | 208 +- .../Sync/SyncThrottlingConfig.swift | 46 +- .../MC1Services/Transport/BLEPhase.swift | 256 +- .../Transport/BLEServiceUUID.swift | 12 +- .../BLEStateMachine+CBDelegate.swift | 279 +- .../BLEStateMachine+CallbackHandlers.swift | 1158 ++- .../Transport/BLEStateMachine.swift | 2212 +++--- .../Transport/BLEStateMachineProtocol.swift | 129 +- .../Transport/BluetoothAvailability.swift | 12 +- .../Transport/DiscoveredDevice.swift | 22 +- .../Transport/iOSBLETransport.swift | 285 +- .../Transport/iOSMeshTransport.swift | 8 +- .../Utilities/ChannelMessageFormat.swift | 32 +- .../MC1Services/Utilities/ContactResult.swift | 20 +- .../Utilities/ContactShareUtilities.swift | 170 +- .../Utilities/DeduplicationKey.swift | 36 +- .../Utilities/DeviceIdentity.swift | 29 +- .../Utilities/EventBroadcaster.swift | 115 +- .../Utilities/HashtagUtilities.swift | 203 +- .../Utilities/ImageURLClassifier.swift | 96 +- .../MC1Services/Utilities/LogRedaction.swift | 65 +- .../Utilities/MentionUtilities.swift | 315 +- .../Utilities/PersistenceKeys.swift | 20 +- .../Utilities/Sequence+IndexByID.swift | 18 +- .../Utilities/TimeoutUtility.swift | 68 +- .../AppBackupEnvelopeTests.swift | 667 +- .../AppBackupServiceTests.swift | 1225 ++-- .../BLEReconnectionCoordinatorTests.swift | 928 +-- .../BLEStateMachineErrorMappingTests.swift | 119 +- .../BackupIntegrationTests.swift | 6211 ++++++++--------- .../BackupUserDefaultsCoverageTests.swift | 179 +- ...BlockedChannelSenderPersistenceTests.swift | 275 +- .../BlockedSenderMessageDeletionTests.swift | 447 +- .../BluetoothScanPairingServiceTests.swift | 171 +- .../ChannelFloodScopeMigrationTests.swift | 217 +- .../ChannelFloodScopeResolverTests.swift | 147 +- .../ChannelFloodScopeTests.swift | 331 +- .../Connection/LastConnectionStoreTests.swift | 123 +- .../ConnectionIntentTests.swift | 242 +- .../ConnectionManagerBLEHealthTests.swift | 1105 ++- .../ConnectionManagerBLEScanningTests.swift | 169 +- ...ionManagerDisconnectDiagnosticsTests.swift | 316 +- .../ConnectionManagerPairingTests.swift | 489 +- .../ConnectionManagerPromotionTests.swift | 477 +- .../ConnectionManagerResyncLoopTests.swift | 428 +- .../ConnectionManagerRetryBudgetTests.swift | 245 +- .../ConnectionManagerSessionTests.swift | 309 +- .../ContactDTOPathTests.swift | 175 +- .../ContactShareUtilitiesTests.swift | 273 +- .../DeduplicationKeyTests.swift | 166 +- .../DeviceConnectionStateTests.swift | 34 +- .../DevicePlatformSyncThrottlingTests.swift | 207 +- .../DevicePublicKeyDeduplicationTests.swift | 385 +- .../MC1ServicesTests/DraftStoreTests.swift | 379 +- .../EnvInputsThemeTokenTests.swift | 71 +- .../EventBroadcasterTests.swift | 193 +- .../Helpers/AppBackupEnvelope+Testing.swift | 81 +- .../BlockedChannelSenderDTO+Testing.swift | 39 +- .../Helpers/ChannelDTO+Testing.swift | 73 +- .../Helpers/ConnectionManager+Testing.swift | 129 +- .../Helpers/ContactDTO+Testing.swift | 103 +- .../Helpers/DeviceDTO+Testing.swift | 125 +- .../Helpers/ImportResult+Testing.swift | 134 +- .../Helpers/MessageDTO+Testing.swift | 217 +- .../Helpers/MessageRepeatDTO+Testing.swift | 55 +- .../Helpers/MessageService+Testing.swift | 242 +- .../NodeStatusSnapshotDTO+Testing.swift | 63 +- .../Helpers/PersistenceStore+TestFetch.swift | 121 +- .../Helpers/PersistenceStore+Testing.swift | 41 +- .../Helpers/ReactionDTO+Testing.swift | 63 +- .../RemoteNodeSessionDTO+Testing.swift | 89 +- .../Helpers/RoomMessageDTO+Testing.swift | 59 +- .../Helpers/SavedTracePathDTO+Testing.swift | 94 +- .../Helpers/TestPolling.swift | 26 +- ...entityReconciliationIntegrationTests.swift | 373 +- .../InlineImageDimensionsStoreTests.swift | 243 +- .../MC1ServicesTests/KnownRegionTests.swift | 293 +- .../MC1ServicesTests/MC1ServicesTests.swift | 25 +- .../MessageDTOPathTests.swift | 93 +- .../Mocks/MockAccessorySetupKitService.swift | 108 +- .../Mocks/MockAppStateProvider.swift | 33 +- .../Mocks/MockBLEStateMachine.swift | 372 +- .../Mocks/MockChannelService.swift | 99 +- .../Mocks/MockContactService.swift | 65 +- .../Mocks/MockMeshCoreSession.swift | 748 +- .../Mocks/MockMeshTransport.swift | 165 +- .../Mocks/MockMessagePollingService.swift | 147 +- .../Mocks/MockPersistenceStore.swift | 3865 +++++----- .../Models/ChannelRegionScopeTests.swift | 243 +- .../Models/ConnectionMethodTests.swift | 181 +- .../Models/DeviceDTOClientRepeatTests.swift | 499 +- .../Models/DevicePlatformTests.swift | 546 +- .../NodeSettingsResponseParserTests.swift | 359 +- .../NodeSnapshotServiceTests.swift | 1105 ++- .../PairingCancellationTests.swift | 221 +- .../PairingRaceIntegrationTests.swift | 113 +- .../PairingWhileConnectedTests.swift | 407 +- .../PendingSendPersistenceTests.swift | 807 ++- .../PersistenceKeysThemeTests.swift | 23 +- .../PersistenceStoreBatchSyncTests.swift | 433 +- .../PersistenceStoreTests.swift | 5879 ++++++++-------- .../MC1ServicesTests/RFCalculatorTests.swift | 1576 +++-- .../RFPathAnalysisCharacterizationTests.swift | 515 +- .../RadioIDMigrationTests.swift | 555 +- .../RadioPresetRecommendationTests.swift | 439 +- .../MC1ServicesTests/RegionalAreasTests.swift | 177 +- .../RepeaterUnreadMigrationTests.swift | 249 +- .../SegmentAnalysisTests.swift | 134 +- .../ServiceContainerWiringTests.swift | 227 +- ...essorySetupKitDiscoveryCriteriaTests.swift | 69 +- .../Services/AckCodeBuilderTests.swift | 101 +- .../BLETransportOpenedSignalTests.swift | 135 +- .../ChannelServicePipelineTests.swift | 573 +- .../Services/ChannelServiceTests.swift | 375 +- .../ChatCoordinatorRegistryOfflineTests.swift | 97 +- .../ChatCoordinatorRegistryTests.swift | 169 +- .../Services/ChatCoordinatorTests.swift | 793 +-- ...hatSendQueueServiceAttemptCountTests.swift | 359 +- .../Services/ChatSendQueueServiceTests.swift | 2238 +++--- .../Services/ContactServiceSyncTests.swift | 143 +- .../Services/ContactServiceTests.swift | 1622 +++-- .../Services/DebugLogBufferTests.swift | 79 +- .../Services/ErrorLocalizationTests.swift | 297 +- .../Services/HeardRepeatsServiceTests.swift | 91 +- .../Services/KeyGenerationServiceTests.swift | 447 +- .../Services/LoginTimeoutConfigTests.swift | 177 +- .../MeshCoreOpenReactionParserTests.swift | 975 ++- .../Services/MessageDeduplicationTests.swift | 359 +- .../Services/MessageLRUCacheTests.swift | 357 +- .../Services/MessageServiceACKTests.swift | 2069 +++--- .../Services/MessageServiceConfigTests.swift | 23 +- ...ssageServiceListenerIntegrationTests.swift | 177 +- ...MessageServiceSendDMBookkeepingTests.swift | 143 +- .../Services/MessageServiceSendTests.swift | 1869 +++-- .../Services/MessageServiceTests.swift | 61 +- .../NodeConfigImportPlannerTests.swift | 2049 +++--- .../Services/NodeConfigServiceTests.swift | 2069 +++--- .../Services/NodeConfigTests.swift | 987 ++- .../NotificationActionHandlerTests.swift | 302 +- .../Services/NotificationServiceTests.swift | 505 +- .../NotificationStringProviderTests.swift | 151 +- .../Services/ReactionParserTests.swift | 723 +- .../Services/ReactionServiceTests.swift | 1149 ++- .../RegionDiscoveryServiceTests.swift | 135 +- .../Services/RemoteNodeKeepAliveTests.swift | 339 +- .../Services/RemoteNodeLoginHealTests.swift | 477 +- .../Services/RemoteNodeTeardownTests.swift | 183 +- .../Services/RxLogServiceAdvertHopTests.swift | 355 +- .../Services/RxLogServiceReprocessTests.swift | 344 +- ...ettingsServiceDefaultFloodScopeTests.swift | 239 +- .../SettingsServiceEventStreamTests.swift | 134 +- .../SettingsServiceLocationTests.swift | 425 +- .../MC1ServicesTests/SimulatorSeedTests.swift | 137 +- .../SortDateBackfillMigrationTests.swift | 117 +- .../SortDateResetMigrationTests.swift | 113 +- .../MC1ServicesTests/StoreCatalogTests.swift | 82 +- .../StoreServiceErrorTests.swift | 99 +- .../SyncCoordinatorChannelSkipTests.swift | 768 +- .../SyncCoordinatorDataEventTests.swift | 103 +- .../SyncCoordinatorMessageHandlerTests.swift | 462 +- .../SyncCoordinatorTests.swift | 1694 ++--- .../SyncCoordinatorTimestampTests.swift | 1065 ++- .../Transport/BLEPhaseTests.swift | 69 +- .../Transport/BLEStateMachineTests.swift | 473 +- ...tateMachineWriteWithoutResponseTests.swift | 217 +- .../Utilities/DeviceIdentityTests.swift | 65 +- .../Utilities/HashtagUtilitiesTests.swift | 219 +- MC1Tests/AppColorSchemePreferenceTests.swift | 39 +- MC1Tests/AppState/AppStateRegionTests.swift | 61 +- .../AppState/BatteryMonitoringTests.swift | 91 +- .../AppState/ConnectionUIStateTests.swift | 643 +- MC1Tests/AppState/DisconnectedPillTests.swift | 233 +- .../AppState/LifecycleTransitionTests.swift | 131 +- .../AppState/NavigationCoordinatorTests.swift | 929 ++- MC1Tests/AppState/NavigationStateTests.swift | 575 +- MC1Tests/AppState/OnboardingStateTests.swift | 395 +- MC1Tests/AppState/StatusPillStateTests.swift | 123 +- MC1Tests/AppStateThemeWiringTests.swift | 55 +- MC1Tests/AppThemeEnvironmentTests.swift | 15 +- MC1Tests/AppearanceSelectionTests.swift | 110 +- .../Extensions/BatteryInfoDisplayTests.swift | 201 +- .../BatteryPercentageCalculationTests.swift | 87 +- MC1Tests/Extensions/DataExtensionsTests.swift | 201 +- .../ErrorDispatchCoverageTests.swift | 239 +- .../ErrorUserFacingMessageTests.swift | 537 +- .../MessagePathFormatterTests.swift | 306 +- MC1Tests/Helpers/StoreKitTestPurchase.swift | 22 +- MC1Tests/Helpers/TestHelpers.swift | 8 +- MC1Tests/Helpers/TestPolling.swift | 28 +- MC1Tests/Intents/EntityIdentityTests.swift | 696 +- MC1Tests/Intents/IntentBridgeTests.swift | 35 +- .../IntentErrorLocalizationTests.swift | 139 +- .../IntentMetadataLocalizationTests.swift | 163 +- MC1Tests/Intents/SendAdvertIntentTests.swift | 291 +- MC1Tests/Intents/SendMessageIntentTests.swift | 843 ++- MC1Tests/Intents/StatusQueryIntentTests.swift | 339 +- .../AirtimePercentLabelTests.swift | 15 +- MC1Tests/MessageTextThemeTests.swift | 89 +- MC1Tests/Models/ContactOCVTests.swift | 215 +- .../Models/ConversationFilteringTests.swift | 395 +- .../Models/DevicePreferenceStoreTests.swift | 222 +- .../Models/LinkPreviewPreferencesTests.swift | 113 +- MC1Tests/Models/OCVPresetTests.swift | 285 +- MC1Tests/Models/RemoteNodeModelTests.swift | 469 +- MC1Tests/Models/WhatsNewCatalogTests.swift | 33 +- MC1Tests/Models/WhatsNewVersionTests.swift | 69 +- MC1Tests/Protocol/CLIResponseTests.swift | 683 +- .../Protocol/LPPDataPointDisplayTests.swift | 181 +- MC1Tests/RefundLinkSectionTests.swift | 112 +- .../Services/DecodedPreviewCacheTests.swift | 259 +- MC1Tests/Services/ElevationServiceTests.swift | 372 +- .../Services/ImageHeaderDecoderTests.swift | 89 +- MC1Tests/Services/ImageURLDetectorTests.swift | 319 +- MC1Tests/Services/InlineImageCacheTests.swift | 189 +- .../Services/InlineImagePrefetcherTests.swift | 973 ++- MC1Tests/Services/LinkPreviewCacheTests.swift | 1087 +-- .../Services/LinkPreviewServiceTests.swift | 327 +- MC1Tests/Services/MapSnapshotStoreTests.swift | 417 +- MC1Tests/Services/RegionResolverTests.swift | 94 +- MC1Tests/SidebarNavigationLayoutTests.swift | 125 +- MC1Tests/State/LiveActivityManagerTests.swift | 63 +- MC1Tests/State/MessageEventStreamTests.swift | 176 +- MC1Tests/State/SendQueueTests.swift | 781 ++- MC1Tests/State/WhatsNewStateTests.swift | 343 +- MC1Tests/StoreEntitlementFoldTests.swift | 74 +- MC1Tests/StoreKitTestAvailability.swift | 10 +- MC1Tests/StoreServiceTests.swift | 474 +- MC1Tests/StoreStateTests.swift | 364 +- MC1Tests/Theme/IdentityGamutTests.swift | 119 +- MC1Tests/Theme/ThemeContrastTests.swift | 297 +- MC1Tests/Theme/ThemeStructureTests.swift | 121 +- MC1Tests/ThemeLocalizedNameTests.swift | 49 +- MC1Tests/ThemeRegistryTests.swift | 81 +- MC1Tests/ThemeServiceTests.swift | 576 +- MC1Tests/ThemedSurfaceRowFillTests.swift | 47 +- .../ChatCoordinateDetectorTests.swift | 113 +- .../ChatScrollToMentionPolicyTests.swift | 105 +- MC1Tests/Utilities/DemoModeManagerTests.swift | 123 +- .../FirmwareSuggestedTimeoutTests.swift | 42 +- .../Utilities/MalwareDomainFilterTests.swift | 113 +- .../Utilities/MentionInsertionTests.swift | 89 +- .../Utilities/MentionUtilitiesTests.swift | 769 +- .../Utilities/MeshCoreURLParserTests.swift | 217 +- .../Utilities/RepeaterResolverTests.swift | 995 ++- .../Utilities/URLSafetyCheckerTests.swift | 411 +- .../ViewModels/AppBackupViewModelTests.swift | 366 +- MC1Tests/ViewModels/BatchTraceTests.swift | 596 +- .../ChatViewModelPaginationTests.swift | 2089 +++--- MC1Tests/ViewModels/ChatViewModelTests.swift | 1756 +++-- .../ViewModels/ContactsViewModelTests.swift | 747 +- .../LineOfSightViewModelTests.swift | 3352 ++++----- .../MessagePathViewModelTests.swift | 369 +- .../NodeConfigImportViewModelTests.swift | 178 +- .../ViewModels/NoiseFloorViewModelTests.swift | 358 +- .../PathManagementViewModelEditingTests.swift | 1221 ++-- ...RemoteNodeStatusHandlerSurvivalTests.swift | 404 +- .../RepeaterStatusViewModelTests.swift | 426 +- MC1Tests/ViewModels/RxLogViewModelTests.swift | 543 +- ...lemetryHistoryOverviewViewModelTests.swift | 628 +- .../ViewModels/TracePathListenerTests.swift | 192 +- .../ViewModels/TracePathViewModelTests.swift | 3696 +++++----- .../Views/ChartCoordinateSpaceTests.swift | 143 +- .../ChannelInfoRegionQueryTargetsTests.swift | 457 +- .../Chats/ChatConversationTypeTests.swift | 786 ++- .../Chats/ChatConversationViewTests.swift | 313 +- .../Views/Chats/ChatLinkRouterTests.swift | 93 +- .../Chats/ChatViewModelAdmissionTests.swift | 1120 +-- .../Chats/ChatViewModelAppendRaceTests.swift | 71 +- .../ChatViewModelConversationTests.swift | 413 +- ...ChatViewModelCoordinatorSharingTests.swift | 123 +- .../ChatViewModelDeleteSequencingTests.swift | 297 +- .../ChatViewModelDependencies+Testing.swift | 96 +- .../ChatViewModelDraftRestoreTests.swift | 113 +- .../Chats/ChatViewModelEventGuardTests.swift | 261 +- .../ChatViewModelReactionIndexingTests.swift | 455 +- ...hatViewModelReloadSerializationTests.swift | 377 +- .../Chats/Components/BubbleGestureTests.swift | 119 +- .../Components/BubbleStatusRowTests.swift | 137 +- .../Components/ChatScrollStateTests.swift | 55 +- .../Chats/Components/ChatShareMenuTests.swift | 107 +- ...ChatTableViewSnapshotRegressionTests.swift | 851 ++- .../Components/FragmentLayoutTests.swift | 387 +- .../MessageBubblePredicateTests.swift | 381 +- .../Components/MessageBubbleTestData.swift | 330 +- .../Components/MessageBubbleViewTests.swift | 193 +- .../MessageTextContactShareTests.swift | 453 +- .../Chats/Components/MessageTextTests.swift | 421 +- .../UnifiedMessageBubbleA11yLabelTests.swift | 183 +- .../MessageBodyAttributeBridgingTests.swift | 859 ++- .../MessageLinkAccessibilityTests.swift | 235 +- .../Linkify/MessageLinkTokenizerTests.swift | 413 +- .../Chats/MentionDeeplinkSupportTests.swift | 77 +- .../MessageActionAvailabilityTests.swift | 379 +- .../MessageBubbleConfigurationTests.swift | 138 +- .../Chats/Models/ChatRenderStateTests.swift | 441 +- .../Models/MessageFootprintHashTests.swift | 95 +- ...MessageFragmentBuilderBenchmarkTests.swift | 59 +- .../MessageFragmentBuilderFixtures.swift | 262 +- .../MessageFragmentBuilderOffMainTests.swift | 93 +- .../Models/MessageFragmentBuilderTests.swift | 1265 ++-- .../Chats/PendingSendEnvelopeTests.swift | 63 +- .../Chats/RegionNameValidatorTests.swift | 161 +- .../Chats/SenderContactMatcherTests.swift | 159 +- .../Components/RSSIScanTrackerTests.swift | 184 +- .../Views/Components/RSSITuningTests.swift | 217 +- .../ContactsViewModelDeleteTests.swift | 241 +- MC1Tests/Views/FresnelZoneRendererTests.swift | 375 +- .../Views/HashtagChannelNavigationTests.swift | 187 +- MC1Tests/Views/Map/MapCameraStoreTests.swift | 79 +- MC1Tests/Views/Map/MapViewModelTests.swift | 297 +- .../Views/RelativeTimestampTextTests.swift | 235 +- .../NeighborSNRMapBuilderTests.swift | 639 +- .../RemoteNodes/NodeCLIViewModelTests.swift | 379 +- .../Settings/DeviceSelectionSheetTests.swift | 244 +- MC1Tests/Views/SyncingPillViewTests.swift | 145 +- .../Tools/CLI/CLICompletionEngineTests.swift | 1155 ++- .../Tools/CLI/CLIToolViewModelTests.swift | 1273 ++-- MC1Widgets/MC1RadioControl.swift | 18 +- MC1Widgets/MC1WidgetsBundle.swift | 8 +- MC1Widgets/MeshStatusLiveActivity.swift | 163 +- MC1Widgets/Views/LockScreenView.swift | 147 +- MeshCore/Package.swift | 62 +- .../MeshCore/Events/ChannelPayloads.swift | 50 +- .../MeshCore/Events/ConnectionState.swift | 20 +- .../MeshCore/Events/ContactPayloads.swift | 98 +- .../MeshCore/Events/DevicePayloads.swift | 294 +- .../MeshCore/Events/DiagnosticsPayloads.swift | 316 +- .../MeshCore/Events/EventDispatcher.swift | 280 +- .../Sources/MeshCore/Events/EventFilter.swift | 464 +- .../Sources/MeshCore/Events/MeshEvent.swift | 814 +-- .../MeshCore/Events/MeshTransportError.swift | 24 +- .../MeshCore/Events/MessagingPayloads.swift | 254 +- .../NeighboursResponse+Pagination.swift | 86 +- .../MeshCore/Events/RemoteAdminPayloads.swift | 252 +- .../MeshCore/Events/StatsPayloads.swift | 174 +- .../Sources/MeshCore/LPP/LPPDecoder.swift | 689 +- .../Sources/MeshCore/LPP/LPPEncoder.swift | 390 +- .../Sources/MeshCore/Models/Contact.swift | 260 +- .../MeshCore/Models/ContactTypes.swift | 34 +- .../Sources/MeshCore/Models/Destination.swift | 196 +- .../Sources/MeshCore/Models/DeviceInfo.swift | 268 +- .../Sources/MeshCore/Protocol/ACLParser.swift | 44 +- .../MeshCore/Protocol/ChannelCrypto.swift | 213 +- .../MeshCore/Protocol/DataExtensions.swift | 351 +- .../Protocol/DirectMessageCrypto.swift | 328 +- .../MeshCore/Protocol/Ed25519ToX25519.swift | 395 +- .../Sources/MeshCore/Protocol/ErrorCode.swift | 24 +- .../Sources/MeshCore/Protocol/MMAParser.swift | 216 +- .../MeshCore/Protocol/NeighboursParser.swift | 106 +- .../MeshCore/Protocol/PacketBuilder.swift | 2267 +++--- .../MeshCore/Protocol/PacketCodes.swift | 586 +- .../MeshCore/Protocol/PacketParser.swift | 741 +- .../MeshCore/Protocol/PacketSize.swift | 156 +- .../MeshCore/Protocol/Parsers+Auth.swift | 160 +- .../MeshCore/Protocol/Parsers+Binary.swift | 350 +- .../MeshCore/Protocol/Parsers+Channels.swift | 139 +- .../MeshCore/Protocol/Parsers+Contacts.swift | 307 +- .../MeshCore/Protocol/Parsers+Device.swift | 385 +- .../Protocol/Parsers+Diagnostics.swift | 253 +- .../MeshCore/Protocol/Parsers+Messaging.swift | 375 +- .../MeshCore/Protocol/Parsers+Network.swift | 172 +- .../MeshCore/Protocol/Parsers+Stats.swift | 139 +- .../MeshCore/Protocol/Parsers+Status.swift | 517 +- .../MeshCore/Protocol/PathEncoding.swift | 44 +- .../MeshCore/Protocol/RegionsParser.swift | 48 +- .../MeshCore/Protocol/RxLogParser.swift | 139 +- .../MeshCore/Protocol/RxLogTypes.swift | 268 +- .../TransportCodeRegionResolver.swift | 164 +- .../Protocols/AdvertisingSessionOps.swift | 47 +- .../Protocols/ChannelSessionOps.swift | 43 +- .../Protocols/ConfigurationSessionOps.swift | 407 +- .../Protocols/ContactSessionOps.swift | 121 +- .../Protocols/DiagnosticsSessionOps.swift | 183 +- .../Protocols/MeshCoreSessionProtocol.swift | 10 +- .../Protocols/MessageFetchSessionOps.swift | 31 +- .../Protocols/MessagingSessionOps.swift | 79 +- .../Protocols/RemoteAccessSessionOps.swift | 253 +- .../Protocols/SessionEventStreaming.swift | 69 +- .../MeshCore/Session/ContactManager.swift | 412 +- .../MeshCoreSession+BinaryProtocol.swift | 837 ++- .../Session/MeshCoreSession+Channels.swift | 363 +- .../Session/MeshCoreSession+Contacts.swift | 786 +-- .../MeshCoreSession+DeviceConfig.swift | 863 ++- .../MeshCoreSession+EventWaiting.swift | 431 +- .../Session/MeshCoreSession+Messaging.swift | 1069 ++- .../Session/MeshCoreSession+Regions.swift | 159 +- .../Session/MeshCoreSession+Signing.swift | 227 +- .../MeshCore/Session/MeshCoreSession.swift | 772 +- .../MeshCore/Session/OtherParamsConfig.swift | 62 +- .../MeshCore/Session/RequestContext.swift | 146 +- .../Session/SessionConfiguration.swift | 238 +- .../Session/StreamProgressTracker.swift | 30 +- .../MeshCore/Transport/MeshTransport.swift | 142 +- .../MeshCore/Transport/MockTransport.swift | 186 +- .../MeshCore/Transport/WiFiFrameCodec.swift | 121 +- .../MeshCore/Transport/WiFiTransport.swift | 623 +- .../MeshCoreTestSupport/CallTracker.swift | 42 +- .../MeshCoreTests/ChannelCryptoTests.swift | 631 +- .../DirectMessageCryptoTests.swift | 696 +- .../MeshCoreTests/Ed25519ToX25519Tests.swift | 231 +- .../Events/EventDispatcherDropTests.swift | 31 +- ...tDispatcherFilteredSubscriptionTests.swift | 75 +- .../EventFilterAnyAcknowledgementTests.swift | 33 +- .../Events/EventFilterFactoryTests.swift | 217 +- .../Events/MeshEventErrorCodeTests.swift | 90 +- .../Fixtures/PythonReferenceBytes.swift | 186 +- .../MeshCoreTests/Helpers/TestPolling.swift | 26 +- .../Protocol/AckParsingTests.swift | 107 +- .../Protocol/ContactNameDecodingTests.swift | 105 +- .../Protocol/DataExtensionsTests.swift | 287 +- .../Protocol/DecodePathLenTests.swift | 359 +- .../Protocol/FactoryResetTests.swift | 33 +- .../Protocol/FloodScopeMappingTests.swift | 101 +- .../Protocol/LoginSuccessParserTests.swift | 276 +- .../Protocol/NewCommandsTests.swift | 249 +- .../MeshCoreTests/Protocol/RegionTests.swift | 796 ++- .../Protocol/UpdateContactTests.swift | 310 +- .../Protocol/V112ProtocolTests.swift | 433 +- .../Protocol/V115CommandsTests.swift | 395 +- .../MeshCoreTests/RxLogParserTests.swift | 449 +- .../Tests/MeshCoreTests/RxLogTypesTests.swift | 203 +- .../Session/AutoContactRefreshTests.swift | 194 +- .../Session/AutoMessageFetchTests.swift | 254 +- .../Session/ConnectionStateTests.swift | 276 +- .../Session/GetMessageCoalescingTests.swift | 249 +- .../GetMessageSerializationTests.swift | 147 +- .../Session/GetMessageTimeoutTests.swift | 74 +- ...shCoreSessionCommandCorrelationTests.swift | 2152 +++--- .../MeshCoreSessionGetChannelsTests.swift | 419 +- .../OtherParamsSerializationTests.swift | 203 +- .../Session/V115GetMessageTests.swift | 123 +- .../Session/V115SessionMethodsTests.swift | 679 +- .../MeshTransportDefaultsTests.swift | 51 +- .../Transport/WiFiFrameCodecTests.swift | 179 +- .../Transport/WiFiTransportTests.swift | 521 +- .../TransportCodeRegionResolverTests.swift | 403 +- .../Validation/DeviceInfoParsingTests.swift | 229 +- .../DiscoverResponseParsingTests.swift | 167 +- .../Validation/LPPPythonReferenceTests.swift | 613 +- .../Validation/NewResponseParsingTests.swift | 173 +- .../PathDiscoveryParsingTests.swift | 265 +- .../Validation/ProtocolBugFixTests.swift | 1703 +++-- .../Validation/PythonReferenceTests.swift | 449 +- .../Validation/RawDataParsingTests.swift | 79 +- .../Validation/RoundTripTests.swift | 2031 +++--- .../Validation/TelemetryParsingTests.swift | 81 +- .../Validation/TraceDataParsingTests.swift | 403 +- .../Validation/V115ParsingTests.swift | 237 +- Shared/MeshStatusAttributes.swift | 34 +- Shared/OpenRadioStatusIntent.swift | 14 +- 1300 files changed, 183242 insertions(+), 181529 deletions(-) create mode 100644 .swiftformat diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c981349d..cc11f949 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,6 +32,9 @@ jobs: - name: Lint run: swiftlint lint --strict + - name: Format + run: swiftformat --lint --strict . + codegen: name: Code Generation Checks runs-on: macos-26 diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 00000000..e08cf6dd --- /dev/null +++ b/.swiftformat @@ -0,0 +1,51 @@ +# SwiftFormat configuration — mirrors the conventions already in the codebase. +# Run: swiftformat . (lint-only: swiftformat --lint .) + +--swiftversion 6.2 + +# Match .swiftlint.yml exclusions +--exclude .build,.worktrees,build,tmp,MC1.xcodeproj,MC1/Resources/Generated,MeshCore/.build,MeshCore-references,MC1Services/.build,fastlane,scripts + +# Indentation — 2 spaces, no tabs +--indent 2 +--tabwidth 2 +--smarttabs enabled +--indentcase false # switch cases align with switch (switch_case_alignment disabled in SwiftLint) + +# Line width — aligned with SwiftLint line_length warning +--maxwidth 250 + +# Imports — alphabetized within a single group +--importgrouping alpha + +# Self — codebase omits redundant self; keep only where required +--self remove + +# Braces / control flow — K&R, else & catch on the same line as the brace +--allman false +--elseposition same-line +--guardelse auto + +# Wrapping — preserve author's line breaks; only balance the closing paren +--wraparguments preserve +--wrapparameters preserve +--wrapcollections preserve +--wrapconditions preserve +--closingparen balanced + +# Spacing / misc conventions observed in the code +--voidtype void +--stripunusedargs closure-only +--commas inline # no trailing commas (trailing_comma disabled in SwiftLint) +--emptybraces no-space +--ranges no-space +--operatorfunc spaced +--nospaceoperators ..<,... + +# Rules that would introduce churn against the existing style +--disable markTypes # code does not MARK every type declaration +--disable trailingCommas # matches SwiftLint disabling trailing_comma +--disable wrapMultilineStatementBraces + +# Leave file headers untouched +--header ignore diff --git a/MC1/ContentView.swift b/MC1/ContentView.swift index e13786cb..8b5454dc 100644 --- a/MC1/ContentView.swift +++ b/MC1/ContentView.swift @@ -1,197 +1,197 @@ -import SwiftUI import MC1Services +import SwiftUI struct ContentView: View { - @Environment(\.appState) private var appState - @Environment(\.scenePhase) private var scenePhase - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - var body: some View { - @Bindable var connectionUI = appState.connectionUI - - Group { - if appState.onboarding.hasCompletedOnboarding { - if horizontalSizeClass == .regular { - MainSidebarView() - } else { - MainTabView() - } - } else { - OnboardingView() - } - } - .animation(.default, value: appState.onboarding.hasCompletedOnboarding) - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { - appState.handleBecameActive() - } - } - .alert( - connectionUI.connectionFailedTitle ?? L10n.Localizable.Alert.ConnectionFailed.title, - isPresented: $connectionUI.showingConnectionFailedAlert - ) { - if appState.connectionUI.failedPairingDeviceID != nil { - switch appState.connectionUI.pairingFailureKind { - case .authentication: - // Auth-failure variant — bond is bad, destructive remove is the recovery - Button(L10n.Localizable.Alert.ConnectionFailed.removeAndRetry, role: .destructive) { - appState.removeFailedPairingAndRetry() - } - .accessibilityLabel(L10n.Localizable.Accessibility.Alert.ConnectionFailed.removeAndRetry) - Button(L10n.Localizable.Common.cancel, role: .cancel) { - appState.connectionUI.failedPairingDeviceID = nil - } - case .transient, .none: - // Transient variant — bond is still good, prefer non-destructive retry. - // `.none` is unreachable in practice (every pairing-failure path routes - // through `presentPairingFailure`, which always sets the kind). Folding - // it into the safer branch ensures a missing kind can't promote a working - // bond into the destructive recovery. - Button(L10n.Localizable.Common.tryAgain) { - Task { await appState.retryFailedPairingConnect() } - } - Button(L10n.Localizable.Alert.ConnectionFailed.removeAndRetry, role: .destructive) { - appState.removeFailedPairingAndRetry() - } - .accessibilityLabel(L10n.Localizable.Accessibility.Alert.ConnectionFailed.removeAndRetry) - Button(L10n.Localizable.Common.cancel, role: .cancel) { - appState.connectionUI.failedPairingDeviceID = nil - } - } - } else { - Button(L10n.Localizable.Common.ok, role: .cancel) { } - } - } message: { - Text(appState.connectionUI.connectionFailedMessage ?? L10n.Localizable.Alert.ConnectionFailed.defaultMessage) + @Environment(\.appState) private var appState + @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + @Bindable var connectionUI = appState.connectionUI + + Group { + if appState.onboarding.hasCompletedOnboarding { + if horizontalSizeClass == .regular { + MainSidebarView() + } else { + MainTabView() } - .alert( - L10n.Localizable.Alert.CouldNotConnect.title, - isPresented: Binding( - get: { appState.connectionUI.otherAppWarningDeviceID != nil }, - set: { if !$0 { appState.connectionUI.otherAppWarningDeviceID = nil } } - ) - ) { - Button(L10n.Localizable.Common.ok) { - appState.connectionUI.otherAppWarningDeviceID = nil - } - } message: { - Text(L10n.Localizable.Alert.CouldNotConnect.otherAppMessage) - } - // macOS "Designed for iPad" device picker. `bluetoothScanPicker` is nil on iOS, where - // AccessorySetupKit presents its own system picker, so this sheet never appears there. - .sheet(isPresented: Binding( - get: { appState.connectionManager.bluetoothScanPicker?.isPresenting ?? false }, - set: { if !$0 { appState.connectionManager.bluetoothScanPicker?.cancel() } } - )) { - if let scanPicker = appState.connectionManager.bluetoothScanPicker { - DeviceScannerSheet(picker: scanPicker) - } - } - // SwiftUI does not reliably co-present a sheet and an alert from the same host, - // so the binding yields a release only while the connection UI above is quiescent; - // `pendingRelease` stays set and re-presents on the next render once any alert clears. - .sheet(item: Binding( - get: { connectionUIQuiescent ? appState.whatsNew.pendingRelease : nil }, - set: { if $0 == nil { appState.whatsNew.markShown() } } - )) { release in - WhatsNewSheet(release: release) + } else { + OnboardingView() + } + } + .animation(.default, value: appState.onboarding.hasCompletedOnboarding) + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + appState.handleBecameActive() + } + } + .alert( + connectionUI.connectionFailedTitle ?? L10n.Localizable.Alert.ConnectionFailed.title, + isPresented: $connectionUI.showingConnectionFailedAlert + ) { + if appState.connectionUI.failedPairingDeviceID != nil { + switch appState.connectionUI.pairingFailureKind { + case .authentication: + // Auth-failure variant — bond is bad, destructive remove is the recovery + Button(L10n.Localizable.Alert.ConnectionFailed.removeAndRetry, role: .destructive) { + appState.removeFailedPairingAndRetry() + } + .accessibilityLabel(L10n.Localizable.Accessibility.Alert.ConnectionFailed.removeAndRetry) + Button(L10n.Localizable.Common.cancel, role: .cancel) { + appState.connectionUI.failedPairingDeviceID = nil + } + case .transient, .none: + // Transient variant — bond is still good, prefer non-destructive retry. + // `.none` is unreachable in practice (every pairing-failure path routes + // through `presentPairingFailure`, which always sets the kind). Folding + // it into the safer branch ensures a missing kind can't promote a working + // bond into the destructive recovery. + Button(L10n.Localizable.Common.tryAgain) { + Task { await appState.retryFailedPairingConnect() } + } + Button(L10n.Localizable.Alert.ConnectionFailed.removeAndRetry, role: .destructive) { + appState.removeFailedPairingAndRetry() + } + .accessibilityLabel(L10n.Localizable.Accessibility.Alert.ConnectionFailed.removeAndRetry) + Button(L10n.Localizable.Common.cancel, role: .cancel) { + appState.connectionUI.failedPairingDeviceID = nil + } } + } else { + Button(L10n.Localizable.Common.ok, role: .cancel) {} + } + } message: { + Text(appState.connectionUI.connectionFailedMessage ?? L10n.Localizable.Alert.ConnectionFailed.defaultMessage) } - - /// True when no connection alert or scan picker from this host is presenting. - private var connectionUIQuiescent: Bool { - !appState.connectionUI.showingConnectionFailedAlert - && appState.connectionUI.otherAppWarningDeviceID == nil - && !(appState.connectionManager.bluetoothScanPicker?.isPresenting ?? false) + .alert( + L10n.Localizable.Alert.CouldNotConnect.title, + isPresented: Binding( + get: { appState.connectionUI.otherAppWarningDeviceID != nil }, + set: { if !$0 { appState.connectionUI.otherAppWarningDeviceID = nil } } + ) + ) { + Button(L10n.Localizable.Common.ok) { + appState.connectionUI.otherAppWarningDeviceID = nil + } + } message: { + Text(L10n.Localizable.Alert.CouldNotConnect.otherAppMessage) + } + // macOS "Designed for iPad" device picker. `bluetoothScanPicker` is nil on iOS, where + // AccessorySetupKit presents its own system picker, so this sheet never appears there. + .sheet(isPresented: Binding( + get: { appState.connectionManager.bluetoothScanPicker?.isPresenting ?? false }, + set: { if !$0 { appState.connectionManager.bluetoothScanPicker?.cancel() } } + )) { + if let scanPicker = appState.connectionManager.bluetoothScanPicker { + DeviceScannerSheet(picker: scanPicker) + } } + // SwiftUI does not reliably co-present a sheet and an alert from the same host, + // so the binding yields a release only while the connection UI above is quiescent; + // `pendingRelease` stays set and re-presents on the next render once any alert clears. + .sheet(item: Binding( + get: { connectionUIQuiescent ? appState.whatsNew.pendingRelease : nil }, + set: { if $0 == nil { appState.whatsNew.markShown() } } + )) { release in + WhatsNewSheet(release: release) + } + } + + /// True when no connection alert or scan picker from this host is presenting. + private var connectionUIQuiescent: Bool { + !appState.connectionUI.showingConnectionFailedAlert + && appState.connectionUI.otherAppWarningDeviceID == nil + && !(appState.connectionManager.bluetoothScanPicker?.isPresenting ?? false) + } } // MARK: - Onboarding View struct OnboardingView: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - var body: some View { - @Bindable var onboarding = appState.onboarding + var body: some View { + @Bindable var onboarding = appState.onboarding - NavigationStack(path: $onboarding.onboardingPath) { + NavigationStack(path: $onboarding.onboardingPath) { + WelcomeView() + .navigationDestination(for: OnboardingStep.self) { step in + switch step { + case .welcome: WelcomeView() - .navigationDestination(for: OnboardingStep.self) { step in - switch step { - case .welcome: - WelcomeView() - case .permissions: - PermissionsView() - case .pair: - DeviceScanView() - case .region: - RegionStepView() - case .preset: - PresetStepView() - } - } + case .permissions: + PermissionsView() + case .pair: + DeviceScanView() + case .region: + RegionStepView() + case .preset: + PresetStepView() + } } } + } } // MARK: - Main Tab View struct MainTabView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var showingDeviceSelection = false - - var body: some View { - @Bindable var navigation = appState.navigation - - TabView(selection: $navigation.selectedTab) { - Tab(L10n.Localizable.Tabs.chats, systemImage: "message.fill", value: AppTab.chats.rawValue) { - ChatsView() - } - .badge(appState.services?.notificationService.badgeCount ?? 0) - - Tab(L10n.Localizable.Tabs.nodes, systemImage: "flipphone", value: AppTab.nodes.rawValue) { - ContactsListView() - } - - Tab(L10n.Localizable.Tabs.map, systemImage: "map.fill", value: AppTab.map.rawValue) { - MapView() - } - - Tab(L10n.Localizable.Tabs.tools, systemImage: "wrench.and.screwdriver", value: AppTab.tools.rawValue) { - ToolsView() - } - - Tab(L10n.Localizable.Tabs.settings, systemImage: "gear", value: AppTab.settings.rawValue) { - SettingsView() - } - } - .themedChrome(theme) - .syncingPillOverlay(onDisconnectedTap: { showingDeviceSelection = true }) - .onChange(of: appState.navigation.selectedTab) { _, _ in - // Donate pending device menu tip when returning to a valid tab - if appState.navigation.pendingDeviceMenuTipDonation && appState.navigation.isOnValidTabForDeviceMenuTip { - Task { - await appState.donateDeviceMenuTipIfOnValidTab() - } - } - } - .sheet(isPresented: $showingDeviceSelection) { - DeviceSelectionSheet() - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var showingDeviceSelection = false + + var body: some View { + @Bindable var navigation = appState.navigation + + TabView(selection: $navigation.selectedTab) { + Tab(L10n.Localizable.Tabs.chats, systemImage: "message.fill", value: AppTab.chats.rawValue) { + ChatsView() + } + .badge(appState.services?.notificationService.badgeCount ?? 0) + + Tab(L10n.Localizable.Tabs.nodes, systemImage: "flipphone", value: AppTab.nodes.rawValue) { + ContactsListView() + } + + Tab(L10n.Localizable.Tabs.map, systemImage: "map.fill", value: AppTab.map.rawValue) { + MapView() + } + + Tab(L10n.Localizable.Tabs.tools, systemImage: "wrench.and.screwdriver", value: AppTab.tools.rawValue) { + ToolsView() + } + + Tab(L10n.Localizable.Tabs.settings, systemImage: "gear", value: AppTab.settings.rawValue) { + SettingsView() + } + } + .themedChrome(theme) + .syncingPillOverlay(onDisconnectedTap: { showingDeviceSelection = true }) + .onChange(of: appState.navigation.selectedTab) { _, _ in + // Donate pending device menu tip when returning to a valid tab + if appState.navigation.pendingDeviceMenuTipDonation, appState.navigation.isOnValidTabForDeviceMenuTip { + Task { + await appState.donateDeviceMenuTipIfOnValidTab() } + } + } + .sheet(isPresented: $showingDeviceSelection) { + DeviceSelectionSheet() + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) } + } } #Preview("Content View - Onboarding") { - ContentView() - .environment(\.appState, AppState()) + ContentView() + .environment(\.appState, AppState()) } #Preview("Content View - Main App") { - let appState = AppState() - appState.onboarding.hasCompletedOnboarding = true - return ContentView() - .environment(\.appState, appState) + let appState = AppState() + appState.onboarding.hasCompletedOnboarding = true + return ContentView() + .environment(\.appState, appState) } diff --git a/MC1/Extensions/BatteryInfo+Display.swift b/MC1/Extensions/BatteryInfo+Display.swift index e366bd16..0526c366 100644 --- a/MC1/Extensions/BatteryInfo+Display.swift +++ b/MC1/Extensions/BatteryInfo+Display.swift @@ -5,89 +5,91 @@ 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 } + /// 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 - } - - /// Estimated percentage based on LiPo curve (4.2V = 100%, 3.0V = 0%) - var percentage: Int { - let percent = ((voltage - 3.0) / 1.2) * 100 - return Int(min(100, max(0, percent))) - } + /// Battery voltage in volts (converted from millivolts) + var voltage: Double { + Double(level) / 1000.0 + } - /// Calculate percentage using OCV array lookup with linear interpolation. - /// The OCV array should have 11 values mapping to 100%, 90%, 80%... 0%. - func percentage(using ocvArray: [Int]) -> Int { - guard ocvArray.count == 11 else { return percentage } // Fallback to linear + /// Estimated percentage based on LiPo curve (4.2V = 100%, 3.0V = 0%) + var percentage: Int { + let percent = ((voltage - 3.0) / 1.2) * 100 + return Int(min(100, max(0, percent))) + } - let millivolts = level + /// Calculate percentage using OCV array lookup with linear interpolation. + /// The OCV array should have 11 values mapping to 100%, 90%, 80%... 0%. + func percentage(using ocvArray: [Int]) -> Int { + guard ocvArray.count == 11 else { return percentage } // Fallback to linear - // Above max voltage = 100% - if millivolts >= ocvArray[0] { - return 100 - } + let millivolts = level - // Below min voltage = 0% - if millivolts <= ocvArray[10] { - return 0 - } + // Above max voltage = 100% + if millivolts >= ocvArray[0] { + return 100 + } - // Find segment and interpolate - for index in 0..<10 { - let upperV = ocvArray[index] - let lowerV = ocvArray[index + 1] - if millivolts >= lowerV { - let segmentPercent = Double(millivolts - lowerV) / Double(upperV - lowerV) - let basePercent = (10 - index - 1) * 10 // 90, 80, 70, ... - return basePercent + Int((segmentPercent * 10).rounded()) - } - } + // Below min voltage = 0% + if millivolts <= ocvArray[10] { + return 0 + } - return 0 + // Find segment and interpolate + for index in 0..<10 { + let upperV = ocvArray[index] + let lowerV = ocvArray[index + 1] + if millivolts >= lowerV { + let segmentPercent = Double(millivolts - lowerV) / Double(upperV - lowerV) + let basePercent = (10 - index - 1) * 10 // 90, 80, 70, ... + return basePercent + Int((segmentPercent * 10).rounded()) + } } - /// SF Symbol name for battery level - var iconName: String { - switch percentage { - case 88...100: "battery.100" - case 63..<88: "battery.75" - case 38..<63: "battery.50" - case 13..<38: "battery.25" - default: "battery.0" - } + return 0 + } + + /// SF Symbol name for battery level + var iconName: String { + switch percentage { + case 88...100: "battery.100" + case 63..<88: "battery.75" + case 38..<63: "battery.50" + case 13..<38: "battery.25" + default: "battery.0" } + } - /// SF Symbol name for battery level using OCV array - func iconName(using ocvArray: [Int]) -> String { - switch percentage(using: ocvArray) { - case 88...100: "battery.100" - case 63..<88: "battery.75" - case 38..<63: "battery.50" - case 13..<38: "battery.25" - default: "battery.0" - } + /// SF Symbol name for battery level using OCV array + func iconName(using ocvArray: [Int]) -> String { + switch percentage(using: ocvArray) { + case 88...100: "battery.100" + case 63..<88: "battery.75" + case 38..<63: "battery.50" + case 13..<38: "battery.25" + default: "battery.0" } + } - /// Color for battery display based on level - var levelColor: Color { - switch percentage { - case 20...100: .primary - case 10..<20: .orange - default: .red - } + /// Color for battery display based on level + var levelColor: Color { + switch percentage { + case 20...100: .primary + case 10..<20: .orange + default: .red } + } - /// Color for battery display based on OCV level - func levelColor(using ocvArray: [Int]) -> Color { - switch percentage(using: ocvArray) { - case 20...100: .primary - case 10..<20: .orange - default: .red - } + /// Color for battery display based on OCV level + func levelColor(using ocvArray: [Int]) -> Color { + switch percentage(using: ocvArray) { + case 20...100: .primary + case 10..<20: .orange + default: .red } + } } diff --git a/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift index a5fc25aa..ee3aa43e 100644 --- a/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift +++ b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift @@ -2,51 +2,51 @@ 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 } +extension [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 + 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) - } + 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) + } - let center = CLLocationCoordinate2D( - latitude: (minLat + maxLat) / 2, - longitude: (minLon + maxLon) / 2 - ) - let rawLatDelta = Swift.max(0.01, (maxLat - minLat) * paddingMultiplier) - let rawLonDelta = Swift.max(0.01, (maxLon - minLon) * paddingMultiplier) + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + let rawLatDelta = Swift.max(0.01, (maxLat - minLat) * paddingMultiplier) + let rawLonDelta = Swift.max(0.01, (maxLon - minLon) * paddingMultiplier) - // Clamp spans so center ± span/2 stays within valid coordinate ranges - let maxLatDelta = (90 - abs(center.latitude)) * 2 - let latDelta = Swift.min(rawLatDelta, maxLatDelta) - let lonDelta = Swift.min(rawLonDelta, 360) + // Clamp spans so center ± span/2 stays within valid coordinate ranges + let maxLatDelta = (90 - abs(center.latitude)) * 2 + let latDelta = Swift.min(rawLatDelta, maxLatDelta) + let lonDelta = Swift.min(rawLonDelta, 360) - return MKCoordinateRegion( - center: center, - span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta) - ) - } + return MKCoordinateRegion( + center: center, + span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta) + ) + } } 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 - ) - ) - } + 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 + ) + ) + } } diff --git a/MC1/Extensions/CLLocationCoordinate2D+Distance.swift b/MC1/Extensions/CLLocationCoordinate2D+Distance.swift index 968b6bf0..2da1876a 100644 --- a/MC1/Extensions/CLLocationCoordinate2D+Distance.swift +++ b/MC1/Extensions/CLLocationCoordinate2D+Distance.swift @@ -1,21 +1,21 @@ import CoreLocation -extension Array where Element == CLLocationCoordinate2D { - /// Total length in meters along the ordered coordinate chain, summing - /// great-circle (WGS-84) distance between consecutive points. Nil for - /// fewer than 2 points. - /// - /// Separate from `TracePathViewModel.calculateDistance(for:)` on purpose: - /// that helper is all-or-nothing (nil if any hop lacks a location), whereas - /// callers here pass already-located coordinates, so this always succeeds. - func totalDistance() -> CLLocationDistance? { - guard count >= 2 else { return nil } - var meters: CLLocationDistance = 0 - for index in 0..<(count - 1) { - let origin = CLLocation(latitude: self[index].latitude, longitude: self[index].longitude) - let destination = CLLocation(latitude: self[index + 1].latitude, longitude: self[index + 1].longitude) - meters += origin.distance(from: destination) - } - return meters +extension [CLLocationCoordinate2D] { + /// Total length in meters along the ordered coordinate chain, summing + /// great-circle (WGS-84) distance between consecutive points. Nil for + /// fewer than 2 points. + /// + /// Separate from `TracePathViewModel.calculateDistance(for:)` on purpose: + /// that helper is all-or-nothing (nil if any hop lacks a location), whereas + /// callers here pass already-located coordinates, so this always succeeds. + func totalDistance() -> CLLocationDistance? { + guard count >= 2 else { return nil } + var meters: CLLocationDistance = 0 + for index in 0..<(count - 1) { + let origin = CLLocation(latitude: self[index].latitude, longitude: self[index].longitude) + let destination = CLLocation(latitude: self[index + 1].latitude, longitude: self[index + 1].longitude) + meters += origin.distance(from: destination) } + return meters + } } diff --git a/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift b/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift index 08bd9730..897ed6ea 100644 --- a/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift +++ b/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift @@ -1,7 +1,7 @@ import CoreLocation extension CLLocationCoordinate2D { - var formattedString: String { - "\(latitude.formatted(.number.precision(.fractionLength(6)))), \(longitude.formatted(.number.precision(.fractionLength(6))))" - } + var formattedString: String { + "\(latitude.formatted(.number.precision(.fractionLength(6)))), \(longitude.formatted(.number.precision(.fractionLength(6))))" + } } diff --git a/MC1/Extensions/ChannelDTO+DisplayName.swift b/MC1/Extensions/ChannelDTO+DisplayName.swift index 4ac9fc0c..34bebc3a 100644 --- a/MC1/Extensions/ChannelDTO+DisplayName.swift +++ b/MC1/Extensions/ChannelDTO+DisplayName.swift @@ -1,8 +1,8 @@ import MC1Services extension ChannelDTO { - /// Localized display name, falling back to a default name based on channel index. - var displayName: String { - name.isEmpty ? L10n.Chats.Chats.Channel.defaultName(Int(index)) : name - } + /// Localized display name, falling back to a default name based on channel index. + var displayName: String { + name.isEmpty ? L10n.Chats.Chats.Channel.defaultName(Int(index)) : name + } } diff --git a/MC1/Extensions/Collection+Chunked.swift b/MC1/Extensions/Collection+Chunked.swift index 2ad35a7a..25090b8b 100644 --- a/MC1/Extensions/Collection+Chunked.swift +++ b/MC1/Extensions/Collection+Chunked.swift @@ -1,12 +1,12 @@ import Foundation extension Collection { - /// Split collection into chunks of specified size. - /// Returns empty array if size <= 0 (prevents infinite loop). - func chunked(into size: Int) -> [[Element]] { - guard size > 0 else { return [] } - return stride(from: 0, to: count, by: size).map { - Array(self[index(startIndex, offsetBy: $0).. [[Element]] { + guard size > 0 else { return [] } + return stride(from: 0, to: count, by: size).map { + Array(self[index(startIndex, offsetBy: $0)..> 16) & 0xFF) / 255, - green: Double((hex >> 8) & 0xFF) / 255, - blue: Double(hex & 0xFF) / 255 - ) - } + init(hex: Int) { + self.init( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255 + ) + } } diff --git a/MC1/Extensions/ContactDTO+Coordinate.swift b/MC1/Extensions/ContactDTO+Coordinate.swift index 8f268014..4568317f 100644 --- a/MC1/Extensions/ContactDTO+Coordinate.swift +++ b/MC1/Extensions/ContactDTO+Coordinate.swift @@ -2,7 +2,7 @@ import CoreLocation import MC1Services extension ContactDTO { - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - } + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } diff --git a/MC1/Extensions/ContactType+Display.swift b/MC1/Extensions/ContactType+Display.swift index d69a7a88..e976c2ec 100644 --- a/MC1/Extensions/ContactType+Display.swift +++ b/MC1/Extensions/ContactType+Display.swift @@ -2,36 +2,36 @@ import MeshCore import SwiftUI extension ContactType { - /// Localized display label for contact-type rows and sheets. - var localizedName: String { - switch self { - case .chat: L10n.Contacts.Contacts.NodeKind.contact - case .repeater: L10n.Contacts.Contacts.NodeKind.repeater - case .room: L10n.Contacts.Contacts.NodeKind.room - } + /// Localized display label for contact-type rows and sheets. + var localizedName: String { + switch self { + case .chat: L10n.Contacts.Contacts.NodeKind.contact + case .repeater: L10n.Contacts.Contacts.NodeKind.repeater + case .room: L10n.Contacts.Contacts.NodeKind.room } + } - var iconSystemName: String { - switch self { - case .chat: "person.fill" - case .repeater: "antenna.radiowaves.left.and.right" - case .room: "person.3.fill" - } + 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 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 - } + var pinStyle: MapPoint.PinStyle { + switch self { + case .chat: .contactChat + case .repeater: .contactRepeater + case .room: .contactRoom } + } } diff --git a/MC1/Extensions/Date+RelativeFormatting.swift b/MC1/Extensions/Date+RelativeFormatting.swift index c769777b..8464da5b 100644 --- a/MC1/Extensions/Date+RelativeFormatting.swift +++ b/MC1/Extensions/Date+RelativeFormatting.swift @@ -1,9 +1,9 @@ import Foundation extension Date { - var relativeFormatted: String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: self, relativeTo: Date()) - } + var relativeFormatted: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: self, relativeTo: Date()) + } } diff --git a/MC1/Extensions/DecryptStatus+Display.swift b/MC1/Extensions/DecryptStatus+Display.swift index 6435f265..d004738c 100644 --- a/MC1/Extensions/DecryptStatus+Display.swift +++ b/MC1/Extensions/DecryptStatus+Display.swift @@ -1,16 +1,16 @@ import MC1Services extension DecryptStatus { - /// Localized display label for decrypt-status indicators. - var localizedName: String { - switch self { - case .notApplicable: L10n.Tools.Tools.RxLog.DecryptStatus.notApplicable - case .noMatchingKey: L10n.Tools.Tools.RxLog.DecryptStatus.noKey - case .hmacFailed: L10n.Tools.Tools.RxLog.DecryptStatus.hmacFailed - case .decryptFailed: L10n.Tools.Tools.RxLog.DecryptStatus.decryptFailed - case .success: L10n.Tools.Tools.RxLog.DecryptStatus.decrypted - case .pending: L10n.Tools.Tools.RxLog.DecryptStatus.hasKey - case .dmNoMatchingKey: L10n.Tools.Tools.RxLog.DecryptStatus.noDmKey - } + /// Localized display label for decrypt-status indicators. + var localizedName: String { + switch self { + case .notApplicable: L10n.Tools.Tools.RxLog.DecryptStatus.notApplicable + case .noMatchingKey: L10n.Tools.Tools.RxLog.DecryptStatus.noKey + case .hmacFailed: L10n.Tools.Tools.RxLog.DecryptStatus.hmacFailed + case .decryptFailed: L10n.Tools.Tools.RxLog.DecryptStatus.decryptFailed + case .success: L10n.Tools.Tools.RxLog.DecryptStatus.decrypted + case .pending: L10n.Tools.Tools.RxLog.DecryptStatus.hasKey + case .dmNoMatchingKey: L10n.Tools.Tools.RxLog.DecryptStatus.noDmKey } + } } diff --git a/MC1/Extensions/Errors/AccessorySetupKitError+UserFacingMessage.swift b/MC1/Extensions/Errors/AccessorySetupKitError+UserFacingMessage.swift index 96e652ef..d47909d3 100644 --- a/MC1/Extensions/Errors/AccessorySetupKitError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/AccessorySetupKitError+UserFacingMessage.swift @@ -1,28 +1,28 @@ import MC1Services extension AccessorySetupKitError { - /// L10n-routed user-facing message for accessory pairing errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .sessionNotActive: - L10n.Localizable.Error.AccessorySetup.sessionNotActive - case .sessionInvalidated: - L10n.Localizable.Error.AccessorySetup.sessionInvalidated - case .pickerDismissed: - L10n.Localizable.Error.AccessorySetup.pickerDismissed - case .pickerRestricted: - L10n.Localizable.Error.AccessorySetup.pickerRestricted - case .pickerAlreadyActive: - L10n.Localizable.Error.AccessorySetup.pickerAlreadyActive - case .pairingFailed(let reason): - L10n.Localizable.Error.AccessorySetup.pairingFailed(reason) - case .noBluetoothIdentifier: - L10n.Localizable.Error.AccessorySetup.noBluetoothIdentifier - case .discoveryTimeout: - L10n.Localizable.Error.AccessorySetup.discoveryTimeout - case .connectionFailed: - L10n.Localizable.Error.AccessorySetup.connectionFailed - } + /// L10n-routed user-facing message for accessory pairing errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .sessionNotActive: + L10n.Localizable.Error.AccessorySetup.sessionNotActive + case .sessionInvalidated: + L10n.Localizable.Error.AccessorySetup.sessionInvalidated + case .pickerDismissed: + L10n.Localizable.Error.AccessorySetup.pickerDismissed + case .pickerRestricted: + L10n.Localizable.Error.AccessorySetup.pickerRestricted + case .pickerAlreadyActive: + L10n.Localizable.Error.AccessorySetup.pickerAlreadyActive + case let .pairingFailed(reason): + L10n.Localizable.Error.AccessorySetup.pairingFailed(reason) + case .noBluetoothIdentifier: + L10n.Localizable.Error.AccessorySetup.noBluetoothIdentifier + case .discoveryTimeout: + L10n.Localizable.Error.AccessorySetup.discoveryTimeout + case .connectionFailed: + L10n.Localizable.Error.AccessorySetup.connectionFailed } + } } diff --git a/MC1/Extensions/Errors/AdvertisementError+UserFacingMessage.swift b/MC1/Extensions/Errors/AdvertisementError+UserFacingMessage.swift index 60174c3e..3618a183 100644 --- a/MC1/Extensions/Errors/AdvertisementError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/AdvertisementError+UserFacingMessage.swift @@ -1,18 +1,18 @@ import MC1Services extension AdvertisementError { - /// L10n-routed user-facing message for advertisement errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.Advertisement.notConnected - case .sendFailed: - L10n.Localizable.Error.Advertisement.sendFailed - case .invalidResponse: - L10n.Localizable.Error.Advertisement.invalidResponse - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for advertisement errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.Advertisement.notConnected + case .sendFailed: + L10n.Localizable.Error.Advertisement.sendFailed + case .invalidResponse: + L10n.Localizable.Error.Advertisement.invalidResponse + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/BLEError+UserFacingMessage.swift b/MC1/Extensions/Errors/BLEError+UserFacingMessage.swift index 80921eba..1e7930a6 100644 --- a/MC1/Extensions/Errors/BLEError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/BLEError+UserFacingMessage.swift @@ -1,38 +1,38 @@ import MC1Services extension BLEError { - /// L10n-routed user-facing message for BLE transport errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .bluetoothUnavailable: - L10n.Localizable.Error.Ble.bluetoothUnavailable - case .bluetoothUnauthorized: - L10n.Localizable.Error.Ble.bluetoothUnauthorized - case .bluetoothPoweredOff: - L10n.Localizable.Error.Ble.bluetoothPoweredOff - case .deviceNotFound: - L10n.Localizable.Error.Ble.deviceNotFound - case .connectionFailed(let message): - L10n.Localizable.Error.Ble.connectionFailed(message) - case .connectionTimeout: - L10n.Localizable.Error.Ble.connectionTimeout - case .notConnected: - L10n.Localizable.Error.Ble.notConnected - case .characteristicNotFound: - L10n.Localizable.Error.Ble.characteristicNotFound - case .writeError(let message): - L10n.Localizable.Error.Ble.writeError(message) - case .invalidResponse: - L10n.Localizable.Error.Ble.invalidResponse - case .operationTimeout: - L10n.Localizable.Error.Ble.operationTimeout - case .authenticationFailed: - L10n.Localizable.Error.Ble.authenticationFailed - case .pairingFailed(let reason): - L10n.Localizable.Error.Ble.pairingFailed(reason) - case .deviceConnectedToOtherApp: - L10n.Localizable.Error.Ble.deviceConnectedToOtherApp - } + /// L10n-routed user-facing message for BLE transport errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .bluetoothUnavailable: + L10n.Localizable.Error.Ble.bluetoothUnavailable + case .bluetoothUnauthorized: + L10n.Localizable.Error.Ble.bluetoothUnauthorized + case .bluetoothPoweredOff: + L10n.Localizable.Error.Ble.bluetoothPoweredOff + case .deviceNotFound: + L10n.Localizable.Error.Ble.deviceNotFound + case let .connectionFailed(message): + L10n.Localizable.Error.Ble.connectionFailed(message) + case .connectionTimeout: + L10n.Localizable.Error.Ble.connectionTimeout + case .notConnected: + L10n.Localizable.Error.Ble.notConnected + case .characteristicNotFound: + L10n.Localizable.Error.Ble.characteristicNotFound + case let .writeError(message): + L10n.Localizable.Error.Ble.writeError(message) + case .invalidResponse: + L10n.Localizable.Error.Ble.invalidResponse + case .operationTimeout: + L10n.Localizable.Error.Ble.operationTimeout + case .authenticationFailed: + L10n.Localizable.Error.Ble.authenticationFailed + case let .pairingFailed(reason): + L10n.Localizable.Error.Ble.pairingFailed(reason) + case .deviceConnectedToOtherApp: + L10n.Localizable.Error.Ble.deviceConnectedToOtherApp } + } } diff --git a/MC1/Extensions/Errors/BinaryProtocolError+UserFacingMessage.swift b/MC1/Extensions/Errors/BinaryProtocolError+UserFacingMessage.swift index b76f05a1..1aaea2a8 100644 --- a/MC1/Extensions/Errors/BinaryProtocolError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/BinaryProtocolError+UserFacingMessage.swift @@ -1,20 +1,20 @@ import MC1Services extension BinaryProtocolError { - /// L10n-routed user-facing message for binary protocol errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.BinaryProtocol.notConnected - case .sendFailed: - L10n.Localizable.Error.BinaryProtocol.sendFailed - case .timeout: - L10n.Localizable.Error.BinaryProtocol.timeout - case .invalidResponse: - L10n.Localizable.Error.BinaryProtocol.invalidResponse - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for binary protocol errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.BinaryProtocol.notConnected + case .sendFailed: + L10n.Localizable.Error.BinaryProtocol.sendFailed + case .timeout: + L10n.Localizable.Error.BinaryProtocol.timeout + case .invalidResponse: + L10n.Localizable.Error.BinaryProtocol.invalidResponse + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/ChannelServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/ChannelServiceError+UserFacingMessage.swift index 2be3533a..894644bc 100644 --- a/MC1/Extensions/Errors/ChannelServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/ChannelServiceError+UserFacingMessage.swift @@ -1,28 +1,28 @@ import MC1Services extension ChannelServiceError { - /// L10n-routed user-facing message for channel service errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.ChannelService.notConnected - case .channelNotFound: - L10n.Localizable.Error.ChannelService.channelNotFound - case .invalidChannelIndex: - L10n.Localizable.Error.ChannelService.invalidChannelIndex - case .secretHashingFailed: - L10n.Localizable.Error.ChannelService.secretHashingFailed - case .saveFailed(let reason): - L10n.Localizable.Error.ChannelService.saveFailed(reason) - case .sendFailed(let reason): - L10n.Localizable.Error.ChannelService.sendFailed(reason) - case .sessionError(let error): - error.userFacingMessage - case .syncAlreadyInProgress: - L10n.Localizable.Error.ChannelService.syncAlreadyInProgress - case .circuitBreakerOpen(let consecutiveFailures): - L10n.Localizable.Error.ChannelService.circuitBreakerOpen(consecutiveFailures) - } + /// L10n-routed user-facing message for channel service errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.ChannelService.notConnected + case .channelNotFound: + L10n.Localizable.Error.ChannelService.channelNotFound + case .invalidChannelIndex: + L10n.Localizable.Error.ChannelService.invalidChannelIndex + case .secretHashingFailed: + L10n.Localizable.Error.ChannelService.secretHashingFailed + case let .saveFailed(reason): + L10n.Localizable.Error.ChannelService.saveFailed(reason) + case let .sendFailed(reason): + L10n.Localizable.Error.ChannelService.sendFailed(reason) + case let .sessionError(error): + error.userFacingMessage + case .syncAlreadyInProgress: + L10n.Localizable.Error.ChannelService.syncAlreadyInProgress + case let .circuitBreakerOpen(consecutiveFailures): + L10n.Localizable.Error.ChannelService.circuitBreakerOpen(consecutiveFailures) } + } } diff --git a/MC1/Extensions/Errors/ChatSendQueueServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/ChatSendQueueServiceError+UserFacingMessage.swift index 4fa40f54..d73ba9cd 100644 --- a/MC1/Extensions/Errors/ChatSendQueueServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/ChatSendQueueServiceError+UserFacingMessage.swift @@ -1,15 +1,15 @@ import MC1Services extension ChatSendQueueServiceError { - /// L10n-routed user-facing message for send queue errors. The wrapped - /// error in `persistFailed` recurses through `userFacingMessage` so - /// mapped service errors localize instead of falling back to English. - var userFacingMessage: String { - switch self { - case .persistFailed(let underlying): - L10n.Localizable.Error.ChatSendQueue.persistFailed(underlying.userFacingMessage) - case .notConnected: - L10n.Localizable.Error.ChatSendQueue.notConnected - } + /// L10n-routed user-facing message for send queue errors. The wrapped + /// error in `persistFailed` recurses through `userFacingMessage` so + /// mapped service errors localize instead of falling back to English. + var userFacingMessage: String { + switch self { + case let .persistFailed(underlying): + L10n.Localizable.Error.ChatSendQueue.persistFailed(underlying.userFacingMessage) + case .notConnected: + L10n.Localizable.Error.ChatSendQueue.notConnected } + } } diff --git a/MC1/Extensions/Errors/ConnectionError+UserFacingMessage.swift b/MC1/Extensions/Errors/ConnectionError+UserFacingMessage.swift index ab1835bb..10749c0d 100644 --- a/MC1/Extensions/Errors/ConnectionError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/ConnectionError+UserFacingMessage.swift @@ -1,18 +1,18 @@ import MC1Services extension ConnectionError { - /// L10n-routed user-facing message for connection lifecycle errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .connectionFailed(let reason): - L10n.Localizable.Error.Connection.connectionFailed(reason) - case .deviceNotFound: - L10n.Localizable.Error.Connection.deviceNotFound - case .notConnected: - L10n.Localizable.Error.Connection.notConnected - case .initializationFailed(let reason): - L10n.Localizable.Error.Connection.initializationFailed(reason) - } + /// L10n-routed user-facing message for connection lifecycle errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case let .connectionFailed(reason): + L10n.Localizable.Error.Connection.connectionFailed(reason) + case .deviceNotFound: + L10n.Localizable.Error.Connection.deviceNotFound + case .notConnected: + L10n.Localizable.Error.Connection.notConnected + case let .initializationFailed(reason): + L10n.Localizable.Error.Connection.initializationFailed(reason) } + } } diff --git a/MC1/Extensions/Errors/ContactServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/ContactServiceError+UserFacingMessage.swift index e05d1956..9e82c0f8 100644 --- a/MC1/Extensions/Errors/ContactServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/ContactServiceError+UserFacingMessage.swift @@ -1,26 +1,26 @@ import MC1Services extension ContactServiceError { - /// L10n-routed user-facing message for contact service errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.ContactService.notConnected - case .sendFailed: - L10n.Localizable.Error.ContactService.sendFailed - case .invalidResponse: - L10n.Localizable.Error.ContactService.invalidResponse - case .syncInterrupted: - L10n.Localizable.Error.ContactService.syncInterrupted - case .contactNotFound: - L10n.Localizable.Error.ContactService.contactNotFound - case .contactTableFull: - L10n.Localizable.Error.ContactService.contactTableFull - case .shareContactUnavailable: - L10n.Localizable.Error.ContactService.shareContactUnavailable - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for contact service errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.ContactService.notConnected + case .sendFailed: + L10n.Localizable.Error.ContactService.sendFailed + case .invalidResponse: + L10n.Localizable.Error.ContactService.invalidResponse + case .syncInterrupted: + L10n.Localizable.Error.ContactService.syncInterrupted + case .contactNotFound: + L10n.Localizable.Error.ContactService.contactNotFound + case .contactTableFull: + L10n.Localizable.Error.ContactService.contactTableFull + case .shareContactUnavailable: + L10n.Localizable.Error.ContactService.shareContactUnavailable + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/DeviceServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/DeviceServiceError+UserFacingMessage.swift index 9a5cf771..97ee3cc8 100644 --- a/MC1/Extensions/Errors/DeviceServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/DeviceServiceError+UserFacingMessage.swift @@ -1,14 +1,14 @@ import MC1Services extension DeviceServiceError { - /// L10n-routed user-facing message for device service errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .deviceNotFound: - L10n.Localizable.Error.DeviceService.deviceNotFound - case .persistenceFailed(let reason): - L10n.Localizable.Error.DeviceService.persistenceFailed(reason) - } + /// L10n-routed user-facing message for device service errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .deviceNotFound: + L10n.Localizable.Error.DeviceService.deviceNotFound + case let .persistenceFailed(reason): + L10n.Localizable.Error.DeviceService.persistenceFailed(reason) } + } } diff --git a/MC1/Extensions/Errors/Error+UserFacingMessage.swift b/MC1/Extensions/Errors/Error+UserFacingMessage.swift index bd56bd53..7ea6f687 100644 --- a/MC1/Extensions/Errors/Error+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/Error+UserFacingMessage.swift @@ -2,39 +2,39 @@ import Foundation import MC1Services extension Error { - /// Localized user-facing message for `errorMessage` at the view boundary. - /// Inside each `case let` the value is concretely typed, so the concrete - /// type's `userFacingMessage` extension member wins over this protocol - /// extension and there is no recursion. Errors without a mapping fall back - /// to `localizedDescription`; each newly mapped error type adds a case here. - var userFacingMessage: String { - switch self { - case let error as MeshCoreError: error.userFacingMessage - case let error as ProtocolError: error.userFacingMessage - case let error as TimeoutError: error.userFacingMessage - case let error as AppBackupError: error.userFacingMessage - case let error as BLEError: error.userFacingMessage - case let error as ConnectionError: error.userFacingMessage - case let error as WiFiTransportError: error.userFacingMessage - case let error as AccessorySetupKitError: error.userFacingMessage - case let error as ContactServiceError: error.userFacingMessage - case let error as MessageServiceError: error.userFacingMessage - case let error as ChannelServiceError: error.userFacingMessage - case let error as ChatSendQueueServiceError: error.userFacingMessage - case let error as MessagePollingError: error.userFacingMessage - case let error as AdvertisementError: error.userFacingMessage - case let error as RemoteNodeError: error.userFacingMessage - case let error as RoomServerError: error.userFacingMessage - case let error as BinaryProtocolError: error.userFacingMessage - case let error as PersistenceStoreError: error.userFacingMessage - case let error as SyncCoordinatorError: error.userFacingMessage - case let error as DeviceServiceError: error.userFacingMessage - case let error as SettingsServiceError: error.userFacingMessage - case let error as KeychainError: error.userFacingMessage - case let error as KeyGenerationError: error.userFacingMessage - case let error as NodeConfigServiceError: error.userFacingMessage - case let error as StoreServiceError: error.userFacingMessage - default: localizedDescription - } + /// Localized user-facing message for `errorMessage` at the view boundary. + /// Inside each `case let` the value is concretely typed, so the concrete + /// type's `userFacingMessage` extension member wins over this protocol + /// extension and there is no recursion. Errors without a mapping fall back + /// to `localizedDescription`; each newly mapped error type adds a case here. + var userFacingMessage: String { + switch self { + case let error as MeshCoreError: error.userFacingMessage + case let error as ProtocolError: error.userFacingMessage + case let error as TimeoutError: error.userFacingMessage + case let error as AppBackupError: error.userFacingMessage + case let error as BLEError: error.userFacingMessage + case let error as ConnectionError: error.userFacingMessage + case let error as WiFiTransportError: error.userFacingMessage + case let error as AccessorySetupKitError: error.userFacingMessage + case let error as ContactServiceError: error.userFacingMessage + case let error as MessageServiceError: error.userFacingMessage + case let error as ChannelServiceError: error.userFacingMessage + case let error as ChatSendQueueServiceError: error.userFacingMessage + case let error as MessagePollingError: error.userFacingMessage + case let error as AdvertisementError: error.userFacingMessage + case let error as RemoteNodeError: error.userFacingMessage + case let error as RoomServerError: error.userFacingMessage + case let error as BinaryProtocolError: error.userFacingMessage + case let error as PersistenceStoreError: error.userFacingMessage + case let error as SyncCoordinatorError: error.userFacingMessage + case let error as DeviceServiceError: error.userFacingMessage + case let error as SettingsServiceError: error.userFacingMessage + case let error as KeychainError: error.userFacingMessage + case let error as KeyGenerationError: error.userFacingMessage + case let error as NodeConfigServiceError: error.userFacingMessage + case let error as StoreServiceError: error.userFacingMessage + default: localizedDescription } + } } diff --git a/MC1/Extensions/Errors/KeyGenerationError+UserFacingMessage.swift b/MC1/Extensions/Errors/KeyGenerationError+UserFacingMessage.swift index be78f944..c002bf40 100644 --- a/MC1/Extensions/Errors/KeyGenerationError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/KeyGenerationError+UserFacingMessage.swift @@ -1,18 +1,18 @@ import MC1Services extension KeyGenerationError { - /// L10n-routed user-facing message for key generation errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .maxAttemptsExceeded: - L10n.Localizable.Error.KeyGeneration.maxAttemptsExceeded - case .reservedPrefix: - L10n.Localizable.Error.KeyGeneration.reservedPrefix - case .randomGenerationFailed: - L10n.Localizable.Error.KeyGeneration.randomGenerationFailed - case .invalidKey: - L10n.Localizable.Error.KeyGeneration.invalidKey - } + /// L10n-routed user-facing message for key generation errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .maxAttemptsExceeded: + L10n.Localizable.Error.KeyGeneration.maxAttemptsExceeded + case .reservedPrefix: + L10n.Localizable.Error.KeyGeneration.reservedPrefix + case .randomGenerationFailed: + L10n.Localizable.Error.KeyGeneration.randomGenerationFailed + case .invalidKey: + L10n.Localizable.Error.KeyGeneration.invalidKey } + } } diff --git a/MC1/Extensions/Errors/KeychainError+UserFacingMessage.swift b/MC1/Extensions/Errors/KeychainError+UserFacingMessage.swift index 997ec952..f71c2da6 100644 --- a/MC1/Extensions/Errors/KeychainError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/KeychainError+UserFacingMessage.swift @@ -1,18 +1,18 @@ import MC1Services extension KeychainError { - /// L10n-routed user-facing message for keychain errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .encodingFailed: - L10n.Localizable.Error.Keychain.encodingFailed - case .storageFailed(let status): - L10n.Localizable.Error.Keychain.storageFailed(Int(status)) - case .retrievalFailed(let status): - L10n.Localizable.Error.Keychain.retrievalFailed(Int(status)) - case .deletionFailed(let status): - L10n.Localizable.Error.Keychain.deletionFailed(Int(status)) - } + /// L10n-routed user-facing message for keychain errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .encodingFailed: + L10n.Localizable.Error.Keychain.encodingFailed + case let .storageFailed(status): + L10n.Localizable.Error.Keychain.storageFailed(Int(status)) + case let .retrievalFailed(status): + L10n.Localizable.Error.Keychain.retrievalFailed(Int(status)) + case let .deletionFailed(status): + L10n.Localizable.Error.Keychain.deletionFailed(Int(status)) } + } } diff --git a/MC1/Extensions/Errors/MeshCoreError+UserFacingMessage.swift b/MC1/Extensions/Errors/MeshCoreError+UserFacingMessage.swift index 187d5eea..03f96ff5 100644 --- a/MC1/Extensions/Errors/MeshCoreError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/MeshCoreError+UserFacingMessage.swift @@ -2,59 +2,59 @@ import Foundation import MC1Services extension MeshCoreError { - /// L10n-routed user-facing message for session errors. The package-level - /// `errorDescription` provides an English fallback so MC1Services stays - /// independent of the app target's L10n; views should prefer this property. - var userFacingMessage: String { - switch self { - case .timeout: - L10n.Localizable.Error.MeshCore.timeout - case .deviceError(let code): - Self.deviceErrorMessage(code: code) - case .parseError(let detail): - L10n.Localizable.Error.MeshCore.parseError(detail) - case .notConnected: - L10n.Localizable.Error.MeshCore.notConnected - case .commandFailed(_, let reason): - L10n.Localizable.Error.MeshCore.commandFailed(reason) - case .invalidResponse(let expected, let got): - L10n.Localizable.Error.MeshCore.invalidResponse(expected, got) - case .contactNotFound: - L10n.Localizable.Error.MeshCore.contactNotFound - case .dataTooLarge(let maxSize, let actualSize): - L10n.Localizable.Error.MeshCore.dataTooLarge(actualSize, maxSize) - case .signingFailed(let reason): - L10n.Localizable.Error.MeshCore.signingFailed(reason) - case .invalidInput(let detail): - L10n.Localizable.Error.MeshCore.invalidInput(detail) - case .unknown(let detail): - L10n.Localizable.Error.MeshCore.unknown(detail) - case .bluetoothUnavailable: - L10n.Localizable.Error.MeshCore.bluetoothUnavailable - case .bluetoothUnauthorized: - L10n.Localizable.Error.MeshCore.bluetoothUnauthorized - case .bluetoothPoweredOff: - L10n.Localizable.Error.MeshCore.bluetoothPoweredOff - case .connectionLost(let underlying): - if let underlying { - L10n.Localizable.Error.MeshCore.connectionLost(underlying.userFacingMessage) - } else { - L10n.Localizable.Error.MeshCore.connectionLostNoDetail - } - case .sessionNotStarted: - L10n.Localizable.Error.MeshCore.sessionNotStarted - case .featureDisabled: - L10n.Localizable.Error.MeshCore.featureDisabled - } + /// L10n-routed user-facing message for session errors. The package-level + /// `errorDescription` provides an English fallback so MC1Services stays + /// independent of the app target's L10n; views should prefer this property. + var userFacingMessage: String { + switch self { + case .timeout: + L10n.Localizable.Error.MeshCore.timeout + case let .deviceError(code): + Self.deviceErrorMessage(code: code) + case let .parseError(detail): + L10n.Localizable.Error.MeshCore.parseError(detail) + case .notConnected: + L10n.Localizable.Error.MeshCore.notConnected + case let .commandFailed(_, reason): + L10n.Localizable.Error.MeshCore.commandFailed(reason) + case let .invalidResponse(expected, got): + L10n.Localizable.Error.MeshCore.invalidResponse(expected, got) + case .contactNotFound: + L10n.Localizable.Error.MeshCore.contactNotFound + case let .dataTooLarge(maxSize, actualSize): + L10n.Localizable.Error.MeshCore.dataTooLarge(actualSize, maxSize) + case let .signingFailed(reason): + L10n.Localizable.Error.MeshCore.signingFailed(reason) + case let .invalidInput(detail): + L10n.Localizable.Error.MeshCore.invalidInput(detail) + case let .unknown(detail): + L10n.Localizable.Error.MeshCore.unknown(detail) + case .bluetoothUnavailable: + L10n.Localizable.Error.MeshCore.bluetoothUnavailable + case .bluetoothUnauthorized: + L10n.Localizable.Error.MeshCore.bluetoothUnauthorized + case .bluetoothPoweredOff: + L10n.Localizable.Error.MeshCore.bluetoothPoweredOff + case let .connectionLost(underlying): + if let underlying { + L10n.Localizable.Error.MeshCore.connectionLost(underlying.userFacingMessage) + } else { + L10n.Localizable.Error.MeshCore.connectionLostNoDetail + } + case .sessionNotStarted: + L10n.Localizable.Error.MeshCore.sessionNotStarted + case .featureDisabled: + L10n.Localizable.Error.MeshCore.featureDisabled } + } - /// Maps the firmware error sub-codes carried by `deviceError(code:)` to the - /// shared `error.device.*` keys that `ProtocolError` also uses, so the two - /// renderings of the same firmware codes cannot drift apart. - private static func deviceErrorMessage(code: UInt8) -> String { - guard let protocolError = ProtocolError(rawValue: code) else { - return L10n.Localizable.Error.Device.unknown(Int(code)) - } - return protocolError.userFacingMessage + /// Maps the firmware error sub-codes carried by `deviceError(code:)` to the + /// shared `error.device.*` keys that `ProtocolError` also uses, so the two + /// renderings of the same firmware codes cannot drift apart. + private static func deviceErrorMessage(code: UInt8) -> String { + guard let protocolError = ProtocolError(rawValue: code) else { + return L10n.Localizable.Error.Device.unknown(Int(code)) } + return protocolError.userFacingMessage + } } diff --git a/MC1/Extensions/Errors/MessagePollingError+UserFacingMessage.swift b/MC1/Extensions/Errors/MessagePollingError+UserFacingMessage.swift index ae834ad9..b84867f2 100644 --- a/MC1/Extensions/Errors/MessagePollingError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/MessagePollingError+UserFacingMessage.swift @@ -1,16 +1,16 @@ import MC1Services extension MessagePollingError { - /// L10n-routed user-facing message for message polling errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.MessagePolling.notConnected - case .pollingFailed: - L10n.Localizable.Error.MessagePolling.pollingFailed - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for message polling errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.MessagePolling.notConnected + case .pollingFailed: + L10n.Localizable.Error.MessagePolling.pollingFailed + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/MessageServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/MessageServiceError+UserFacingMessage.swift index 09e46e82..2cafb930 100644 --- a/MC1/Extensions/Errors/MessageServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/MessageServiceError+UserFacingMessage.swift @@ -1,24 +1,24 @@ import MC1Services extension MessageServiceError { - /// L10n-routed user-facing message for message service errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.MessageService.notConnected - case .contactNotFound: - L10n.Localizable.Error.MessageService.contactNotFound - case .channelNotFound: - L10n.Localizable.Error.MessageService.channelNotFound - case .sendFailed: - L10n.Localizable.Error.MessageService.sendFailed - case .invalidRecipient: - L10n.Localizable.Error.MessageService.invalidRecipient - case .messageTooLong: - L10n.Localizable.Error.MessageService.messageTooLong - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for message service errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.MessageService.notConnected + case .contactNotFound: + L10n.Localizable.Error.MessageService.contactNotFound + case .channelNotFound: + L10n.Localizable.Error.MessageService.channelNotFound + case .sendFailed: + L10n.Localizable.Error.MessageService.sendFailed + case .invalidRecipient: + L10n.Localizable.Error.MessageService.invalidRecipient + case .messageTooLong: + L10n.Localizable.Error.MessageService.messageTooLong + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/NodeConfigServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/NodeConfigServiceError+UserFacingMessage.swift index b53b4f6c..66dc5124 100644 --- a/MC1/Extensions/Errors/NodeConfigServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/NodeConfigServiceError+UserFacingMessage.swift @@ -1,50 +1,50 @@ import MC1Services extension NodeConfigServiceError { - /// L10n-routed user-facing message for config import validation errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .invalidChannelSecret(let index, let hexLength): - L10n.Settings.ConfigImport.Error.invalidChannelSecret(index, hexLength) - case .invalidContactPublicKey(let name): - L10n.Settings.ConfigImport.Error.invalidContactPublicKey(name) - case .invalidPathHashMode(let name, let mode): - L10n.Settings.ConfigImport.Error.invalidPathHashMode(name, Int(mode)) - case .invalidPrivateKey(let hexLength): - L10n.Settings.ConfigImport.Error.invalidPrivateKey(hexLength, ProtocolLimits.privateKeySize * 2) - case .invalidRadioSettings(let field): - L10n.Settings.ConfigImport.Error.radioOutOfRange(Self.radioFieldLabel(field)) - case .noAvailableChannelSlot(let name): - L10n.Settings.ConfigImport.Error.noAvailableChannelSlot(name) - case .invalidCoordinate(let field): - switch field { - case .positionLatitude, .positionLongitude: - L10n.Settings.ConfigImport.Error.positionInvalid(Self.coordinateLabel(field)) - case .contactLatitude(let name), .contactLongitude(let name): - L10n.Settings.ConfigImport.Error.contactCoordinateInvalid(name, Self.coordinateLabel(field)) - } - case .invalidOutPath(let name): - L10n.Settings.ConfigImport.Error.invalidOutPath(name) - case .contactCapacityExceeded(let needed, let available): - L10n.Settings.ConfigImport.Error.contactCapacityExceeded(needed, available) - } + /// L10n-routed user-facing message for config import validation errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case let .invalidChannelSecret(index, hexLength): + L10n.Settings.ConfigImport.Error.invalidChannelSecret(index, hexLength) + case let .invalidContactPublicKey(name): + L10n.Settings.ConfigImport.Error.invalidContactPublicKey(name) + case let .invalidPathHashMode(name, mode): + L10n.Settings.ConfigImport.Error.invalidPathHashMode(name, Int(mode)) + case let .invalidPrivateKey(hexLength): + L10n.Settings.ConfigImport.Error.invalidPrivateKey(hexLength, ProtocolLimits.privateKeySize * 2) + case let .invalidRadioSettings(field): + L10n.Settings.ConfigImport.Error.radioOutOfRange(Self.radioFieldLabel(field)) + case let .noAvailableChannelSlot(name): + L10n.Settings.ConfigImport.Error.noAvailableChannelSlot(name) + case let .invalidCoordinate(field): + switch field { + case .positionLatitude, .positionLongitude: + L10n.Settings.ConfigImport.Error.positionInvalid(Self.coordinateLabel(field)) + case let .contactLatitude(name), let .contactLongitude(name): + L10n.Settings.ConfigImport.Error.contactCoordinateInvalid(name, Self.coordinateLabel(field)) + } + case let .invalidOutPath(name): + L10n.Settings.ConfigImport.Error.invalidOutPath(name) + case let .contactCapacityExceeded(needed, available): + L10n.Settings.ConfigImport.Error.contactCapacityExceeded(needed, available) } + } - private static func radioFieldLabel(_ field: RadioField) -> String { - switch field { - case .frequency: L10n.Settings.ConfigImport.Field.frequency - case .bandwidth: L10n.Settings.ConfigImport.Field.bandwidth - case .spreadingFactor: L10n.Settings.ConfigImport.Field.spreadingFactor - case .codingRate: L10n.Settings.ConfigImport.Field.codingRate - case .txPower: L10n.Settings.ConfigImport.Field.txPower - } + private static func radioFieldLabel(_ field: RadioField) -> String { + switch field { + case .frequency: L10n.Settings.ConfigImport.Field.frequency + case .bandwidth: L10n.Settings.ConfigImport.Field.bandwidth + case .spreadingFactor: L10n.Settings.ConfigImport.Field.spreadingFactor + case .codingRate: L10n.Settings.ConfigImport.Field.codingRate + case .txPower: L10n.Settings.ConfigImport.Field.txPower } + } - private static func coordinateLabel(_ field: CoordinateField) -> String { - switch field { - case .positionLatitude, .contactLatitude: L10n.Settings.ConfigImport.Field.latitude - case .positionLongitude, .contactLongitude: L10n.Settings.ConfigImport.Field.longitude - } + private static func coordinateLabel(_ field: CoordinateField) -> String { + switch field { + case .positionLatitude, .contactLatitude: L10n.Settings.ConfigImport.Field.latitude + case .positionLongitude, .contactLongitude: L10n.Settings.ConfigImport.Field.longitude } + } } diff --git a/MC1/Extensions/Errors/PersistenceStoreError+UserFacingMessage.swift b/MC1/Extensions/Errors/PersistenceStoreError+UserFacingMessage.swift index d7d0e474..41e4a880 100644 --- a/MC1/Extensions/Errors/PersistenceStoreError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/PersistenceStoreError+UserFacingMessage.swift @@ -1,26 +1,26 @@ import MC1Services extension PersistenceStoreError { - /// L10n-routed user-facing message for persistence errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .deviceNotFound: - L10n.Localizable.Error.Persistence.deviceNotFound - case .contactNotFound: - L10n.Localizable.Error.Persistence.contactNotFound - case .messageNotFound: - L10n.Localizable.Error.Persistence.messageNotFound - case .channelNotFound: - L10n.Localizable.Error.Persistence.channelNotFound - case .remoteNodeSessionNotFound: - L10n.Localizable.Error.Persistence.remoteNodeSessionNotFound - case .saveFailed(let reason): - L10n.Localizable.Error.Persistence.saveFailed(reason) - case .fetchFailed(let reason): - L10n.Localizable.Error.Persistence.fetchFailed(reason) - case .invalidData: - L10n.Localizable.Error.Persistence.invalidData - } + /// L10n-routed user-facing message for persistence errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .deviceNotFound: + L10n.Localizable.Error.Persistence.deviceNotFound + case .contactNotFound: + L10n.Localizable.Error.Persistence.contactNotFound + case .messageNotFound: + L10n.Localizable.Error.Persistence.messageNotFound + case .channelNotFound: + L10n.Localizable.Error.Persistence.channelNotFound + case .remoteNodeSessionNotFound: + L10n.Localizable.Error.Persistence.remoteNodeSessionNotFound + case let .saveFailed(reason): + L10n.Localizable.Error.Persistence.saveFailed(reason) + case let .fetchFailed(reason): + L10n.Localizable.Error.Persistence.fetchFailed(reason) + case .invalidData: + L10n.Localizable.Error.Persistence.invalidData } + } } diff --git a/MC1/Extensions/Errors/ProtocolError+UserFacingMessage.swift b/MC1/Extensions/Errors/ProtocolError+UserFacingMessage.swift index a0380a7e..4574424c 100644 --- a/MC1/Extensions/Errors/ProtocolError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/ProtocolError+UserFacingMessage.swift @@ -1,17 +1,17 @@ import MC1Services extension ProtocolError { - /// L10n-routed user-facing message for raw firmware error codes. Shares the - /// `error.device.*` keys with `MeshCoreError.deviceError(code:)` because both - /// describe the identical firmware codes. - var userFacingMessage: String { - switch self { - case .unsupportedCommand: L10n.Localizable.Error.Device.unsupportedCommand - case .notFound: L10n.Localizable.Error.Device.notFound - case .tableFull: L10n.Localizable.Error.Device.storageFull - case .badState: L10n.Localizable.Error.Device.invalidState - case .fileIOError: L10n.Localizable.Error.Device.fileSystem - case .illegalArgument: L10n.Localizable.Error.Device.invalidParameter - } + /// L10n-routed user-facing message for raw firmware error codes. Shares the + /// `error.device.*` keys with `MeshCoreError.deviceError(code:)` because both + /// describe the identical firmware codes. + var userFacingMessage: String { + switch self { + case .unsupportedCommand: L10n.Localizable.Error.Device.unsupportedCommand + case .notFound: L10n.Localizable.Error.Device.notFound + case .tableFull: L10n.Localizable.Error.Device.storageFull + case .badState: L10n.Localizable.Error.Device.invalidState + case .fileIOError: L10n.Localizable.Error.Device.fileSystem + case .illegalArgument: L10n.Localizable.Error.Device.invalidParameter } + } } diff --git a/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift b/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift index e43064af..83aa272c 100644 --- a/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/RemoteNodeError+UserFacingMessage.swift @@ -1,38 +1,38 @@ import MC1Services extension RemoteNodeError { - /// L10n-routed user-facing message for remote node errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.RemoteNode.notConnected - case .loginFailed: - L10n.Localizable.Error.RemoteNode.loginFailed - case .sendFailed: - L10n.Localizable.Error.RemoteNode.sendFailed - case .invalidResponse: - L10n.Localizable.Error.RemoteNode.invalidResponse - case .permissionDenied: - L10n.Localizable.Error.RemoteNode.permissionDenied - case .timeout: - L10n.Localizable.Error.RemoteNode.timeout - case .sessionNotFound: - L10n.Localizable.Error.RemoteNode.sessionNotFound - case .passwordNotFound: - L10n.Localizable.Error.RemoteNode.passwordNotFound - case .floodRouted: - L10n.Localizable.Error.RemoteNode.floodRouted - case .pathDiscoveryFailed: - L10n.Localizable.Error.RemoteNode.pathDiscoveryFailed - case .contactNotFound: - L10n.Localizable.Error.RemoteNode.contactNotFound - case .radioContactsFull: - L10n.Localizable.Error.RemoteNode.radioContactsFull - case .cancelled: - L10n.Localizable.Error.RemoteNode.cancelled - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for remote node errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.RemoteNode.notConnected + case .loginFailed: + L10n.Localizable.Error.RemoteNode.loginFailed + case .sendFailed: + L10n.Localizable.Error.RemoteNode.sendFailed + case .invalidResponse: + L10n.Localizable.Error.RemoteNode.invalidResponse + case .permissionDenied: + L10n.Localizable.Error.RemoteNode.permissionDenied + case .timeout: + L10n.Localizable.Error.RemoteNode.timeout + case .sessionNotFound: + L10n.Localizable.Error.RemoteNode.sessionNotFound + case .passwordNotFound: + L10n.Localizable.Error.RemoteNode.passwordNotFound + case .floodRouted: + L10n.Localizable.Error.RemoteNode.floodRouted + case .pathDiscoveryFailed: + L10n.Localizable.Error.RemoteNode.pathDiscoveryFailed + case .contactNotFound: + L10n.Localizable.Error.RemoteNode.contactNotFound + case .radioContactsFull: + L10n.Localizable.Error.RemoteNode.radioContactsFull + case .cancelled: + L10n.Localizable.Error.RemoteNode.cancelled + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/RoomServerError+UserFacingMessage.swift b/MC1/Extensions/Errors/RoomServerError+UserFacingMessage.swift index 8f9488cb..f6905467 100644 --- a/MC1/Extensions/Errors/RoomServerError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/RoomServerError+UserFacingMessage.swift @@ -1,22 +1,22 @@ import MC1Services extension RoomServerError { - /// L10n-routed user-facing message for room server errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.RoomServer.notConnected - case .sessionNotFound: - L10n.Localizable.Error.RoomServer.sessionNotFound - case .sendFailed: - L10n.Localizable.Error.RoomServer.sendFailed - case .permissionDenied: - L10n.Localizable.Error.RoomServer.permissionDenied - case .invalidResponse: - L10n.Localizable.Error.RoomServer.invalidResponse - case .sessionError(let error): - error.userFacingMessage - } + /// L10n-routed user-facing message for room server errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.RoomServer.notConnected + case .sessionNotFound: + L10n.Localizable.Error.RoomServer.sessionNotFound + case .sendFailed: + L10n.Localizable.Error.RoomServer.sendFailed + case .permissionDenied: + L10n.Localizable.Error.RoomServer.permissionDenied + case .invalidResponse: + L10n.Localizable.Error.RoomServer.invalidResponse + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Extensions/Errors/SettingsServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/SettingsServiceError+UserFacingMessage.swift index 82e215b2..aa112e2e 100644 --- a/MC1/Extensions/Errors/SettingsServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/SettingsServiceError+UserFacingMessage.swift @@ -1,26 +1,26 @@ import MC1Services extension SettingsServiceError { - /// L10n-routed user-facing message for settings service errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.Settings.notConnected - case .sendFailed: - L10n.Localizable.Error.Settings.sendFailed - case .invalidResponse: - L10n.Localizable.Error.Settings.invalidResponse - case .sessionError(let error): - error.userFacingMessage - case .verificationFailed(let expected, let actual): - L10n.Localizable.Error.Settings.verificationFailed(expected, actual) - case .deviceGPSVerificationFailed(let expectedEnabled, _): - // Two whole-sentence variants instead of interpolating an On/Off word, - // so every locale can phrase the toggle state naturally. - expectedEnabled - ? L10n.Localizable.Error.Settings.gpsNotSavedExpectedOn - : L10n.Localizable.Error.Settings.gpsNotSavedExpectedOff - } + /// L10n-routed user-facing message for settings service errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.Settings.notConnected + case .sendFailed: + L10n.Localizable.Error.Settings.sendFailed + case .invalidResponse: + L10n.Localizable.Error.Settings.invalidResponse + case let .sessionError(error): + error.userFacingMessage + case let .verificationFailed(expected, actual): + L10n.Localizable.Error.Settings.verificationFailed(expected, actual) + case let .deviceGPSVerificationFailed(expectedEnabled, _): + // Two whole-sentence variants instead of interpolating an On/Off word, + // so every locale can phrase the toggle state naturally. + expectedEnabled + ? L10n.Localizable.Error.Settings.gpsNotSavedExpectedOn + : L10n.Localizable.Error.Settings.gpsNotSavedExpectedOff } + } } diff --git a/MC1/Extensions/Errors/StoreServiceError+UserFacingMessage.swift b/MC1/Extensions/Errors/StoreServiceError+UserFacingMessage.swift index 2280dbec..b9b805b6 100644 --- a/MC1/Extensions/Errors/StoreServiceError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/StoreServiceError+UserFacingMessage.swift @@ -1,26 +1,26 @@ import MC1Services extension StoreServiceError { - /// L10n-routed user-facing message for store errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .productsNotLoaded: - L10n.Settings.Support.Error.productsNotLoaded - case .productNotFound: - L10n.Settings.Support.Error.productNotFound - case .purchaseFailed(let reason): - L10n.Settings.Support.Error.purchaseFailed(reason) - case .verificationFailed: - L10n.Settings.Support.Error.verificationFailed - case .notEntitled: - L10n.Settings.Support.Error.notEntitled - case .networkUnavailable: - L10n.Settings.Support.Error.networkUnavailable - case .storefrontUnavailable: - L10n.Settings.Support.Error.storefrontUnavailable - case .unsupported: - L10n.Settings.Support.Error.unsupported - } + /// L10n-routed user-facing message for store errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .productsNotLoaded: + L10n.Settings.Support.Error.productsNotLoaded + case .productNotFound: + L10n.Settings.Support.Error.productNotFound + case let .purchaseFailed(reason): + L10n.Settings.Support.Error.purchaseFailed(reason) + case .verificationFailed: + L10n.Settings.Support.Error.verificationFailed + case .notEntitled: + L10n.Settings.Support.Error.notEntitled + case .networkUnavailable: + L10n.Settings.Support.Error.networkUnavailable + case .storefrontUnavailable: + L10n.Settings.Support.Error.storefrontUnavailable + case .unsupported: + L10n.Settings.Support.Error.unsupported } + } } diff --git a/MC1/Extensions/Errors/SyncCoordinatorError+UserFacingMessage.swift b/MC1/Extensions/Errors/SyncCoordinatorError+UserFacingMessage.swift index 3236dccd..156aee80 100644 --- a/MC1/Extensions/Errors/SyncCoordinatorError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/SyncCoordinatorError+UserFacingMessage.swift @@ -1,16 +1,16 @@ import MC1Services extension SyncCoordinatorError { - /// L10n-routed user-facing message for sync coordinator errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .notConnected: - L10n.Localizable.Error.SyncCoordinator.notConnected - case .syncFailed(let reason): - L10n.Localizable.Error.SyncCoordinator.syncFailed(reason) - case .alreadySyncing: - L10n.Localizable.Error.SyncCoordinator.alreadySyncing - } + /// L10n-routed user-facing message for sync coordinator errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case .notConnected: + L10n.Localizable.Error.SyncCoordinator.notConnected + case let .syncFailed(reason): + L10n.Localizable.Error.SyncCoordinator.syncFailed(reason) + case .alreadySyncing: + L10n.Localizable.Error.SyncCoordinator.alreadySyncing } + } } diff --git a/MC1/Extensions/Errors/TimeoutError+UserFacingMessage.swift b/MC1/Extensions/Errors/TimeoutError+UserFacingMessage.swift index e60cbee4..5edb3868 100644 --- a/MC1/Extensions/Errors/TimeoutError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/TimeoutError+UserFacingMessage.swift @@ -1,10 +1,10 @@ import MC1Services extension TimeoutError { - /// Generic localized timeout message. `operationName` is a `#function` - /// string, developer-facing by construction, so it stays out of the - /// user-facing rendering. - var userFacingMessage: String { - L10n.Localizable.Error.Timeout.operationTimedOut - } + /// Generic localized timeout message. `operationName` is a `#function` + /// string, developer-facing by construction, so it stays out of the + /// user-facing rendering. + var userFacingMessage: String { + L10n.Localizable.Error.Timeout.operationTimedOut + } } diff --git a/MC1/Extensions/Errors/WiFiTransportError+UserFacingMessage.swift b/MC1/Extensions/Errors/WiFiTransportError+UserFacingMessage.swift index e12977df..74a72c19 100644 --- a/MC1/Extensions/Errors/WiFiTransportError+UserFacingMessage.swift +++ b/MC1/Extensions/Errors/WiFiTransportError+UserFacingMessage.swift @@ -1,26 +1,26 @@ import MC1Services extension WiFiTransportError { - /// L10n-routed user-facing message for WiFi transport errors. The - /// package-level `errorDescription` stays the developer-facing fallback. - var userFacingMessage: String { - switch self { - case .connectionFailed(let reason): - L10n.Localizable.Error.Wifi.connectionFailed(reason) - case .connectionTimeout: - L10n.Localizable.Error.Wifi.connectionTimeout - case .notConnected: - L10n.Localizable.Error.Wifi.notConnected - case .sendFailed(let reason): - L10n.Localizable.Error.Wifi.sendFailed(reason) - case .sendTimeout: - L10n.Localizable.Error.Wifi.sendTimeout - case .invalidHost: - L10n.Localizable.Error.Wifi.invalidHost - case .invalidPort: - L10n.Localizable.Error.Wifi.invalidPort - case .notConfigured: - L10n.Localizable.Error.Wifi.notConfigured - } + /// L10n-routed user-facing message for WiFi transport errors. The + /// package-level `errorDescription` stays the developer-facing fallback. + var userFacingMessage: String { + switch self { + case let .connectionFailed(reason): + L10n.Localizable.Error.Wifi.connectionFailed(reason) + case .connectionTimeout: + L10n.Localizable.Error.Wifi.connectionTimeout + case .notConnected: + L10n.Localizable.Error.Wifi.notConnected + case let .sendFailed(reason): + L10n.Localizable.Error.Wifi.sendFailed(reason) + case .sendTimeout: + L10n.Localizable.Error.Wifi.sendTimeout + case .invalidHost: + L10n.Localizable.Error.Wifi.invalidHost + case .invalidPort: + L10n.Localizable.Error.Wifi.invalidPort + case .notConfigured: + L10n.Localizable.Error.Wifi.notConfigured } + } } diff --git a/MC1/Extensions/LPPSensorType+Chart.swift b/MC1/Extensions/LPPSensorType+Chart.swift index a4139a46..5c47a155 100644 --- a/MC1/Extensions/LPPSensorType+Chart.swift +++ b/MC1/Extensions/LPPSensorType+Chart.swift @@ -2,30 +2,30 @@ import MeshCore import SwiftUI extension LPPSensorType { - /// Chart accent color for telemetry history views. - var chartColor: Color { - switch self { - case .voltage: .orange - case .temperature: .red - case .humidity: .teal - case .barometer: .purple - case .illuminance: .yellow - case .current: .mint - case .power: .pink - case .frequency: .blue - case .altitude, .distance: .green - case .energy: .orange - case .direction: .indigo - case .percentage: .cyan - default: .cyan - } + /// Chart accent color for telemetry history views. + var chartColor: Color { + switch self { + case .voltage: .orange + case .temperature: .red + case .humidity: .teal + case .barometer: .purple + case .illuminance: .yellow + case .current: .mint + case .power: .pink + case .frequency: .blue + case .altitude, .distance: .green + case .energy: .orange + case .direction: .indigo + case .percentage: .cyan + default: .cyan } + } - /// Sort priority for telemetry charts (lower = earlier). - var chartSortPriority: Int { - switch self { - case .voltage: 0 - default: 1 - } + /// Sort priority for telemetry charts (lower = earlier). + var chartSortPriority: Int { + switch self { + case .voltage: 0 + default: 1 } + } } diff --git a/MC1/Extensions/LPPSensorType+LocalizedName.swift b/MC1/Extensions/LPPSensorType+LocalizedName.swift index 09bb3b32..bfca54d7 100644 --- a/MC1/Extensions/LPPSensorType+LocalizedName.swift +++ b/MC1/Extensions/LPPSensorType+LocalizedName.swift @@ -1,39 +1,39 @@ import MeshCore extension LPPSensorType { - /// Localized display name for telemetry labels. - /// Distinct from `name`, which is the wire/serialization identifier - /// (stored in snapshot entries and reverse-looked-up via `init?(name:)`) - /// and must stay English. - var localizedName: String { - switch self { - case .digitalInput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.digitalInput - case .digitalOutput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.digitalOutput - case .analogInput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.analogInput - case .analogOutput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.analogOutput - case .genericSensor: L10n.RemoteNodes.RemoteNodes.Status.Sensor.genericSensor - case .illuminance: L10n.RemoteNodes.RemoteNodes.Status.Sensor.illuminance - case .presence: L10n.RemoteNodes.RemoteNodes.Status.Sensor.presence - case .temperature: L10n.RemoteNodes.RemoteNodes.Status.Sensor.temperature - case .humidity: L10n.RemoteNodes.RemoteNodes.Status.Sensor.humidity - case .accelerometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.accelerometer - case .barometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.barometer - case .voltage: L10n.RemoteNodes.RemoteNodes.Status.Sensor.voltage - case .current: L10n.RemoteNodes.RemoteNodes.Status.Sensor.current - case .frequency: L10n.RemoteNodes.RemoteNodes.Status.Sensor.frequency - case .percentage: L10n.RemoteNodes.RemoteNodes.Status.Sensor.percentage - case .altitude: L10n.RemoteNodes.RemoteNodes.Status.Sensor.altitude - case .load: L10n.RemoteNodes.RemoteNodes.Status.Sensor.load - case .concentration: L10n.RemoteNodes.RemoteNodes.Status.Sensor.concentration - case .power: L10n.RemoteNodes.RemoteNodes.Status.Sensor.power - case .distance: L10n.RemoteNodes.RemoteNodes.Status.Sensor.distance - case .energy: L10n.RemoteNodes.RemoteNodes.Status.Sensor.energy - case .direction: L10n.RemoteNodes.RemoteNodes.Status.Sensor.direction - case .unixTime: L10n.RemoteNodes.RemoteNodes.Status.Sensor.unixTime - case .gyrometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.gyrometer - case .colour: L10n.RemoteNodes.RemoteNodes.Status.Sensor.colour - case .gps: L10n.RemoteNodes.RemoteNodes.Status.Sensor.gps - case .switchValue: L10n.RemoteNodes.RemoteNodes.Status.Sensor.switchValue - } + /// Localized display name for telemetry labels. + /// Distinct from `name`, which is the wire/serialization identifier + /// (stored in snapshot entries and reverse-looked-up via `init?(name:)`) + /// and must stay English. + var localizedName: String { + switch self { + case .digitalInput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.digitalInput + case .digitalOutput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.digitalOutput + case .analogInput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.analogInput + case .analogOutput: L10n.RemoteNodes.RemoteNodes.Status.Sensor.analogOutput + case .genericSensor: L10n.RemoteNodes.RemoteNodes.Status.Sensor.genericSensor + case .illuminance: L10n.RemoteNodes.RemoteNodes.Status.Sensor.illuminance + case .presence: L10n.RemoteNodes.RemoteNodes.Status.Sensor.presence + case .temperature: L10n.RemoteNodes.RemoteNodes.Status.Sensor.temperature + case .humidity: L10n.RemoteNodes.RemoteNodes.Status.Sensor.humidity + case .accelerometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.accelerometer + case .barometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.barometer + case .voltage: L10n.RemoteNodes.RemoteNodes.Status.Sensor.voltage + case .current: L10n.RemoteNodes.RemoteNodes.Status.Sensor.current + case .frequency: L10n.RemoteNodes.RemoteNodes.Status.Sensor.frequency + case .percentage: L10n.RemoteNodes.RemoteNodes.Status.Sensor.percentage + case .altitude: L10n.RemoteNodes.RemoteNodes.Status.Sensor.altitude + case .load: L10n.RemoteNodes.RemoteNodes.Status.Sensor.load + case .concentration: L10n.RemoteNodes.RemoteNodes.Status.Sensor.concentration + case .power: L10n.RemoteNodes.RemoteNodes.Status.Sensor.power + case .distance: L10n.RemoteNodes.RemoteNodes.Status.Sensor.distance + case .energy: L10n.RemoteNodes.RemoteNodes.Status.Sensor.energy + case .direction: L10n.RemoteNodes.RemoteNodes.Status.Sensor.direction + case .unixTime: L10n.RemoteNodes.RemoteNodes.Status.Sensor.unixTime + case .gyrometer: L10n.RemoteNodes.RemoteNodes.Status.Sensor.gyrometer + case .colour: L10n.RemoteNodes.RemoteNodes.Status.Sensor.colour + case .gps: L10n.RemoteNodes.RemoteNodes.Status.Sensor.gps + case .switchValue: L10n.RemoteNodes.RemoteNodes.Status.Sensor.switchValue } + } } diff --git a/MC1/Extensions/NotificationLevel+Display.swift b/MC1/Extensions/NotificationLevel+Display.swift index 735e4334..3cb1b6db 100644 --- a/MC1/Extensions/NotificationLevel+Display.swift +++ b/MC1/Extensions/NotificationLevel+Display.swift @@ -1,21 +1,21 @@ import MC1Services extension NotificationLevel { - /// Localized display label for pickers and rows. - var localizedName: String { - switch self { - case .muted: L10n.Chats.Chats.NotificationLevel.muted - case .mentionsOnly: L10n.Chats.Chats.NotificationLevel.mentions - case .all: L10n.Chats.Chats.NotificationLevel.all - } + /// Localized display label for pickers and rows. + var localizedName: String { + switch self { + case .muted: L10n.Chats.Chats.NotificationLevel.muted + case .mentionsOnly: L10n.Chats.Chats.NotificationLevel.mentions + case .all: L10n.Chats.Chats.NotificationLevel.all } + } - /// Localized VoiceOver description of the level's effect. - var localizedAccessibilityDescription: String { - switch self { - case .muted: L10n.Chats.Chats.NotificationLevel.Accessibility.muted - case .mentionsOnly: L10n.Chats.Chats.NotificationLevel.Accessibility.mentionsOnly - case .all: L10n.Chats.Chats.NotificationLevel.Accessibility.all - } + /// Localized VoiceOver description of the level's effect. + var localizedAccessibilityDescription: String { + switch self { + case .muted: L10n.Chats.Chats.NotificationLevel.Accessibility.muted + case .mentionsOnly: L10n.Chats.Chats.NotificationLevel.Accessibility.mentionsOnly + case .all: L10n.Chats.Chats.NotificationLevel.Accessibility.all } + } } diff --git a/MC1/Extensions/ProcessInfo+ScreenshotMode.swift b/MC1/Extensions/ProcessInfo+ScreenshotMode.swift index d4d689c2..733e5599 100644 --- a/MC1/Extensions/ProcessInfo+ScreenshotMode.swift +++ b/MC1/Extensions/ProcessInfo+ScreenshotMode.swift @@ -1,10 +1,10 @@ import Foundation #if DEBUG -extension ProcessInfo { + extension ProcessInfo { /// True when launched with `-screenshotMode` for App Store screenshot capture. var isScreenshotMode: Bool { - arguments.contains("-screenshotMode") + arguments.contains("-screenshotMode") } -} + } #endif diff --git a/MC1/Extensions/RoomPermissionLevel+Display.swift b/MC1/Extensions/RoomPermissionLevel+Display.swift index 247055a8..bb2d3e1e 100644 --- a/MC1/Extensions/RoomPermissionLevel+Display.swift +++ b/MC1/Extensions/RoomPermissionLevel+Display.swift @@ -1,12 +1,12 @@ import MC1Services extension RoomPermissionLevel { - /// Localized display label for room info and status rows. - var localizedName: String { - switch self { - case .guest: L10n.RemoteNodes.RemoteNodes.Permission.guest - case .readWrite: L10n.RemoteNodes.RemoteNodes.Permission.member - case .admin: L10n.RemoteNodes.RemoteNodes.Permission.admin - } + /// Localized display label for room info and status rows. + var localizedName: String { + switch self { + case .guest: L10n.RemoteNodes.RemoteNodes.Permission.guest + case .readWrite: L10n.RemoteNodes.RemoteNodes.Permission.member + case .admin: L10n.RemoteNodes.RemoteNodes.Permission.admin } + } } diff --git a/MC1/Extensions/SNRQuality+Color.swift b/MC1/Extensions/SNRQuality+Color.swift index 4a26f8bd..84f94ce9 100644 --- a/MC1/Extensions/SNRQuality+Color.swift +++ b/MC1/Extensions/SNRQuality+Color.swift @@ -3,34 +3,34 @@ import SwiftUI import UIKit extension SNRQuality { - /// SwiftUI color for signal quality indicators. - var color: Color { - switch self { - case .excellent, .good: .green - case .fair: .yellow - case .poor: .red - case .unknown: .secondary - } + /// SwiftUI color for signal quality indicators. + var color: Color { + switch self { + 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 - } + /// 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 - } + /// 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 } + } } diff --git a/MC1/Extensions/UInt8+Hex.swift b/MC1/Extensions/UInt8+Hex.swift index fcde1584..843e00ec 100644 --- a/MC1/Extensions/UInt8+Hex.swift +++ b/MC1/Extensions/UInt8+Hex.swift @@ -1,9 +1,9 @@ import Foundation extension UInt8 { - /// Two-character uppercase hex string (e.g., "0A", "FF") - var hexString: String { - let hex = String(self, radix: 16, uppercase: true) - return hex.count < 2 ? "0" + hex : hex - } + /// Two-character uppercase hex string (e.g., "0A", "FF") + var hexString: String { + let hex = String(self, radix: 16, uppercase: true) + return hex.count < 2 ? "0" + hex : hex + } } diff --git a/MC1/Extensions/View+LiquidGlass.swift b/MC1/Extensions/View+LiquidGlass.swift index a7ad6ba5..17871481 100644 --- a/MC1/Extensions/View+LiquidGlass.swift +++ b/MC1/Extensions/View+LiquidGlass.swift @@ -1,84 +1,82 @@ import SwiftUI extension View { - /// Applies liquid glass effect on iOS 26+, falls back to regularMaterial on earlier versions - @ViewBuilder - func liquidGlass(in shape: some Shape = .rect(cornerRadius: 12)) -> some View { - if #available(iOS 26.0, *) { - self.glassEffect(in: shape) - } else { - self.background(.regularMaterial, in: shape) - } + /// Applies liquid glass effect on iOS 26+, falls back to regularMaterial on earlier versions + @ViewBuilder + func liquidGlass(in shape: some Shape = .rect(cornerRadius: 12)) -> some View { + if #available(iOS 26.0, *) { + glassEffect(in: shape) + } else { + background(.regularMaterial, in: shape) } + } - /// Applies glass button style on iOS 26+, falls back to borderedProminent on earlier versions - @ViewBuilder - func liquidGlassButtonStyle() -> some View { - if #available(iOS 26.0, *) { - self.buttonStyle(.glass) - } else { - self.buttonStyle(.borderedProminent) - } + /// Applies glass button style on iOS 26+, falls back to borderedProminent on earlier versions + @ViewBuilder + func liquidGlassButtonStyle() -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.glass) + } else { + buttonStyle(.borderedProminent) } + } - /// 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 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, *) { + buttonStyle(.glass) + } else { + buttonStyle(.bordered) } + } - /// Applies prominent glass button style with tint on iOS 26+, falls back to borderedProminent on earlier versions - @ViewBuilder - func liquidGlassProminentButtonStyle() -> some View { - if #available(iOS 26.0, *) { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.borderedProminent) - } + /// Applies prominent glass button style with tint on iOS 26+, falls back to borderedProminent on earlier versions + @ViewBuilder + func liquidGlassProminentButtonStyle() -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.glassProminent) + } else { + buttonStyle(.borderedProminent) } + } - /// Applies interactive liquid glass effect on iOS 26+, falls back to thinMaterial on earlier versions - @ViewBuilder - func liquidGlassInteractive(in shape: some Shape = .circle) -> some View { - if #available(iOS 26.0, *) { - self.glassEffect(.regular.interactive(), in: shape) - } else { - self.background(.thinMaterial, in: shape) - } + /// Applies interactive liquid glass effect on iOS 26+, falls back to thinMaterial on earlier versions + @ViewBuilder + func liquidGlassInteractive(in shape: some Shape = .circle) -> some View { + if #available(iOS 26.0, *) { + glassEffect(.regular.interactive(), in: shape) + } else { + background(.thinMaterial, in: shape) } + } - #if os(iOS) + #if os(iOS) /// Applies visible toolbar backgrounds for full-screen content views. /// On iOS 26+, explicitly sets visibility so system applies liquid glass. /// On iOS 18, uses regularMaterial background. @ViewBuilder func liquidGlassToolbarBackground() -> some View { - if #available(iOS 26.0, *) { - self - .toolbarBackgroundVisibility(.visible, for: .navigationBar) - } else { - self - .toolbarBackground(.regularMaterial, for: .navigationBar, .tabBar) - .toolbarBackgroundVisibility(.visible, for: .navigationBar, .tabBar) - } + if #available(iOS 26.0, *) { + toolbarBackgroundVisibility(.visible, for: .navigationBar) + } else { + toolbarBackground(.regularMaterial, for: .navigationBar, .tabBar) + .toolbarBackgroundVisibility(.visible, for: .navigationBar, .tabBar) + } } - #endif + #endif } extension View { - /// Applies glassEffectID on iOS 26+ for smooth morphing transitions, no-op on earlier versions - @ViewBuilder - func liquidGlassID(_ id: ID, in namespace: Namespace.ID) -> some View { - if #available(iOS 26.0, *) { - self.glassEffectID(id, in: namespace) - } else { - self - } + /// Applies glassEffectID on iOS 26+ for smooth morphing transitions, no-op on earlier versions + @ViewBuilder + func liquidGlassID(_ id: ID, in namespace: Namespace.ID) -> some View { + if #available(iOS 26.0, *) { + glassEffectID(id, in: namespace) + } else { + self } + } } /// Drop-in replacement for `Menu` that works around an iPadOS 26 Liquid Glass bug @@ -88,45 +86,40 @@ extension View { /// overlay, so the glass morph animation has nothing visible to ghost. /// On earlier iOS versions, uses a standard `Menu`. struct ToolbarMenu: View { - let content: Content - let label: LabelView + @ViewBuilder let content: Content + @ViewBuilder let label: LabelView - init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> LabelView) { - self.content = content() - self.label = label() - } - - var body: some View { - if #available(iOS 26, *) { - label - .accessibilityHidden(true) - .overlay { - Menu { content } label: { label } - .colorMultiply(.clear) - } - } else { - Menu { content } label: { label } + var body: some View { + if #available(iOS 26, *) { + label + .accessibilityHidden(true) + .overlay { + Menu { content } label: { label } + .colorMultiply(.clear) } + } else { + Menu { content } label: { label } } + } } /// A container that uses GlassEffectContainer on iOS 26+, passes through content on earlier versions struct LiquidGlassContainer: View { - let spacing: CGFloat - @ViewBuilder let content: Content + let spacing: CGFloat + @ViewBuilder let content: Content - init(spacing: CGFloat = 20, @ViewBuilder content: () -> Content) { - self.spacing = spacing - self.content = content() - } + init(spacing: CGFloat = 20, @ViewBuilder content: () -> Content) { + self.spacing = spacing + self.content = content() + } - var body: some View { - if #available(iOS 26.0, *) { - GlassEffectContainer(spacing: spacing) { - content - } - } else { - content - } + var body: some View { + if #available(iOS 26.0, *) { + GlassEffectContainer(spacing: spacing) { + content + } + } else { + content } + } } diff --git a/MC1/Extensions/View+MapControlButton.swift b/MC1/Extensions/View+MapControlButton.swift index 5ad7efc6..c87485a6 100644 --- a/MC1/Extensions/View+MapControlButton.swift +++ b/MC1/Extensions/View+MapControlButton.swift @@ -1,18 +1,18 @@ import SwiftUI extension View { - /// Applies the shared styling for a map control toolbar button: a fixed - /// 44pt icon-only tappable square with a medium-weight glyph and the given tint. - func mapControlButton(tint: Color) -> some View { - font(.body.weight(.medium)) - .foregroundStyle(tint) - .frame(width: MapControlButtonMetrics.size, height: MapControlButtonMetrics.size) - .contentShape(.rect) - .buttonStyle(.plain) - .labelStyle(.iconOnly) - } + /// Applies the shared styling for a map control toolbar button: a fixed + /// 44pt icon-only tappable square with a medium-weight glyph and the given tint. + func mapControlButton(tint: Color) -> some View { + font(.body.weight(.medium)) + .foregroundStyle(tint) + .frame(width: MapControlButtonMetrics.size, height: MapControlButtonMetrics.size) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + } } private enum MapControlButtonMetrics { - static let size: CGFloat = 44 + static let size: CGFloat = 44 } diff --git a/MC1/Extensions/View+RadioDisabled.swift b/MC1/Extensions/View+RadioDisabled.swift index 190dc6f6..af1f741e 100644 --- a/MC1/Extensions/View+RadioDisabled.swift +++ b/MC1/Extensions/View+RadioDisabled.swift @@ -1,29 +1,28 @@ -import SwiftUI import MC1Services +import SwiftUI extension View { - /// Disables the view when radio connection is not ready, applying visual - /// feedback to indicate unavailability. - /// - /// - Parameters: - /// - connectionState: The current connection state from appState - /// - otherCondition: Additional condition that should disable the view - /// - /// Example: - /// ```swift - /// Button("Save") { } - /// .radioDisabled(for: appState.connectionState, or: isSaving) - /// ``` - @ViewBuilder - func radioDisabled(for connectionState: DeviceConnectionState, or otherCondition: Bool = false) -> some View { - let isNotReady = connectionState != .ready - if isNotReady { - self - .disabled(true) - .foregroundStyle(.secondary) - .accessibilityHint(L10n.Localizable.Accessibility.requiresRadioConnection) - } else { - self.disabled(otherCondition) - } + /// Disables the view when radio connection is not ready, applying visual + /// feedback to indicate unavailability. + /// + /// - Parameters: + /// - connectionState: The current connection state from appState + /// - otherCondition: Additional condition that should disable the view + /// + /// Example: + /// ```swift + /// Button("Save") { } + /// .radioDisabled(for: appState.connectionState, or: isSaving) + /// ``` + @ViewBuilder + func radioDisabled(for connectionState: DeviceConnectionState, or otherCondition: Bool = false) -> some View { + let isNotReady = connectionState != .ready + if isNotReady { + disabled(true) + .foregroundStyle(.secondary) + .accessibilityHint(L10n.Localizable.Accessibility.requiresRadioConnection) + } else { + disabled(otherCondition) } + } } diff --git a/MC1/Intents/AdvertReach.swift b/MC1/Intents/AdvertReach.swift index db2e22da..505aa21d 100644 --- a/MC1/Intents/AdvertReach.swift +++ b/MC1/Intents/AdvertReach.swift @@ -6,19 +6,21 @@ import AppIntents /// saved shortcut persists, so a case must never be renamed without preserving /// its raw value. enum AdvertReach: String, AppEnum { - // swiftlint:disable redundant_string_enum_value - case zeroHop = "zeroHop" - case flood = "flood" - // swiftlint:enable redundant_string_enum_value + // swiftlint:disable redundant_string_enum_value + case zeroHop + case flood + // swiftlint:enable redundant_string_enum_value - static let typeDisplayRepresentation = TypeDisplayRepresentation( - name: LocalizedStringResource("intent.advert.reach.type", table: "Tools") - ) + static let typeDisplayRepresentation = TypeDisplayRepresentation( + name: LocalizedStringResource("intent.advert.reach.type", table: "Tools") + ) - static let caseDisplayRepresentations: [AdvertReach: DisplayRepresentation] = [ - .zeroHop: DisplayRepresentation(title: LocalizedStringResource("intent.advert.reach.zeroHop", table: "Tools")), - .flood: DisplayRepresentation(title: LocalizedStringResource("intent.advert.reach.flood", table: "Tools")) - ] + static let caseDisplayRepresentations: [AdvertReach: DisplayRepresentation] = [ + .zeroHop: DisplayRepresentation(title: LocalizedStringResource("intent.advert.reach.zeroHop", table: "Tools")), + .flood: DisplayRepresentation(title: LocalizedStringResource("intent.advert.reach.flood", table: "Tools")) + ] - var sendsFlood: Bool { self == .flood } + var sendsFlood: Bool { + self == .flood + } } diff --git a/MC1/Intents/AppState+IntentRefresh.swift b/MC1/Intents/AppState+IntentRefresh.swift index 64f039b9..9266fe58 100644 --- a/MC1/Intents/AppState+IntentRefresh.swift +++ b/MC1/Intents/AppState+IntentRefresh.swift @@ -1,10 +1,10 @@ import AppIntents extension AppState { - /// Re-resolves the App Intents parameter queries when the addressable - /// contact/channel set changes, so saved shortcuts and Siri offer the - /// currently connected radio's contacts and channels rather than a stale set. - func refreshAppShortcutParameters() { - MC1AppShortcutsProvider.updateAppShortcutParameters() - } + /// Re-resolves the App Intents parameter queries when the addressable + /// contact/channel set changes, so saved shortcuts and Siri offer the + /// currently connected radio's contacts and channels rather than a stale set. + func refreshAppShortcutParameters() { + MC1AppShortcutsProvider.updateAppShortcutParameters() + } } diff --git a/MC1/Intents/CompositeIDHelpers.swift b/MC1/Intents/CompositeIDHelpers.swift index e18248a2..e45cd9fb 100644 --- a/MC1/Intents/CompositeIDHelpers.swift +++ b/MC1/Intents/CompositeIDHelpers.swift @@ -21,7 +21,7 @@ private let compositeIDSeparator: Character = "/" /// and the kind segment lets resolution route a contact id (public key) apart /// from a channel id (secret digest) without trusting an in-memory field. func formatCompositeID(radioID: UUID, kind: MessageTargetKind, keyHex: String) -> String { - "\(radioID.uuidString)\(compositeIDSeparator)\(kind.rawValue)\(compositeIDSeparator)\(keyHex)" + "\(radioID.uuidString)\(compositeIDSeparator)\(kind.rawValue)\(compositeIDSeparator)\(keyHex)" } /// Parses a composite id back into its radio scope, kind, and key hex, returning @@ -29,24 +29,24 @@ func formatCompositeID(radioID: UUID, kind: MessageTargetKind, keyHex: String) - /// entity" rather than resolving against the wrong radio or kind. Splits on the /// first two separators only; the key hex never contains a separator. func parseCompositeID(_ id: String) -> (radioID: UUID, kind: MessageTargetKind, keyHex: String)? { - guard let firstSeparator = id.firstIndex(of: compositeIDSeparator) else { return nil } - let radioPart = String(id[.. String { - var input = Data(channelDigestDomain.utf8) - withUnsafeBytes(of: radioID.uuid) { input.append(contentsOf: $0) } - input.append(secret) - let digest = SHA256.hash(data: input) - return Data(digest.prefix(channelDigestByteCount)).hexString + var input = Data(channelDigestDomain.utf8) + withUnsafeBytes(of: radioID.uuid) { input.append(contentsOf: $0) } + input.append(secret) + let digest = SHA256.hash(data: input) + return Data(digest.prefix(channelDigestByteCount)).hexString } diff --git a/MC1/Intents/IntentBridge.swift b/MC1/Intents/IntentBridge.swift index e4448089..4b1aab31 100644 --- a/MC1/Intents/IntentBridge.swift +++ b/MC1/Intents/IntentBridge.swift @@ -9,9 +9,9 @@ import MC1Services @Observable @MainActor final class IntentBridge { - private(set) var appState: AppState? + private(set) var appState: AppState? - func adopt(_ appState: AppState) { - self.appState = appState - } + func adopt(_ appState: AppState) { + self.appState = appState + } } diff --git a/MC1/Intents/IntentError.swift b/MC1/Intents/IntentError.swift index c268bb2b..9a7cb12f 100644 --- a/MC1/Intents/IntentError.swift +++ b/MC1/Intents/IntentError.swift @@ -4,28 +4,28 @@ import MC1Services /// Errors thrown by MeshCore One's App Intents, mirroring the /// `.sessionError(MeshCoreError)` wrapping convention of `MessageServiceError`. enum IntentError: LocalizedError { - case notConnected - case invalidRecipient - case messageTooLong - case sendFailed - case advertFailed - case sessionError(MeshCoreError) + case notConnected + case invalidRecipient + case messageTooLong + case sendFailed + case advertFailed + case sessionError(MeshCoreError) - /// Siri and Shortcuts read `errorDescription`, so this is the L10n seam. - var errorDescription: String? { - switch self { - case .notConnected: - L10n.Localizable.Error.Intent.notConnected - case .invalidRecipient: - L10n.Localizable.Error.Intent.invalidRecipient - case .messageTooLong: - L10n.Localizable.Error.Intent.messageTooLong - case .sendFailed: - L10n.Localizable.Error.Intent.sendFailed - case .advertFailed: - L10n.Localizable.Error.Advertisement.sendFailed - case .sessionError(let error): - error.userFacingMessage - } + /// Siri and Shortcuts read `errorDescription`, so this is the L10n seam. + var errorDescription: String? { + switch self { + case .notConnected: + L10n.Localizable.Error.Intent.notConnected + case .invalidRecipient: + L10n.Localizable.Error.Intent.invalidRecipient + case .messageTooLong: + L10n.Localizable.Error.Intent.messageTooLong + case .sendFailed: + L10n.Localizable.Error.Intent.sendFailed + case .advertFailed: + L10n.Localizable.Error.Advertisement.sendFailed + case let .sessionError(error): + error.userFacingMessage } + } } diff --git a/MC1/Intents/MC1AppShortcutsProvider.swift b/MC1/Intents/MC1AppShortcutsProvider.swift index db16cf20..133b60d3 100644 --- a/MC1/Intents/MC1AppShortcutsProvider.swift +++ b/MC1/Intents/MC1AppShortcutsProvider.swift @@ -8,34 +8,34 @@ import AppIntents /// MeshCore One" by voice) and the advert phrase binds the reach, but neither /// binds free text. struct MC1AppShortcutsProvider: AppShortcutsProvider { - static var appShortcuts: [AppShortcut] { - AppShortcut( - intent: StatusQueryIntent(), - phrases: [ - "Check radio status in \(.applicationName)", - "What's my radio battery in \(.applicationName)", - "Is my radio connected in \(.applicationName)" - ], - shortTitle: LocalizedStringResource("intent.status.shortTitle", table: "Tools"), - systemImageName: "antenna.radiowaves.left.and.right" - ) - AppShortcut( - intent: SendMessageIntent(), - phrases: [ - "Send a message in \(.applicationName)", - "Message \(\.$target) in \(.applicationName)" - ], - shortTitle: LocalizedStringResource("intent.send.shortTitle", table: "Tools"), - systemImageName: "paperplane" - ) - AppShortcut( - intent: SendAdvertIntent(), - phrases: [ - "Send an advert in \(.applicationName)", - "Send a \(\.$reach) advert in \(.applicationName)" - ], - shortTitle: LocalizedStringResource("intent.advert.shortTitle", table: "Tools"), - systemImageName: "dot.radiowaves.left.and.right" - ) - } + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StatusQueryIntent(), + phrases: [ + "Check radio status in \(.applicationName)", + "What's my radio battery in \(.applicationName)", + "Is my radio connected in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("intent.status.shortTitle", table: "Tools"), + systemImageName: "antenna.radiowaves.left.and.right" + ) + AppShortcut( + intent: SendMessageIntent(), + phrases: [ + "Send a message in \(.applicationName)", + "Message \(\.$target) in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("intent.send.shortTitle", table: "Tools"), + systemImageName: "paperplane" + ) + AppShortcut( + intent: SendAdvertIntent(), + phrases: [ + "Send an advert in \(.applicationName)", + "Send a \(\.$reach) advert in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("intent.advert.shortTitle", table: "Tools"), + systemImageName: "dot.radiowaves.left.and.right" + ) + } } diff --git a/MC1/Intents/MessageRecipient.swift b/MC1/Intents/MessageRecipient.swift index 6cfa7605..88fae656 100644 --- a/MC1/Intents/MessageRecipient.swift +++ b/MC1/Intents/MessageRecipient.swift @@ -4,16 +4,16 @@ import MC1Services /// The resolved, validated send target carried across the actor boundary as a /// Sendable DTO. The raw `@Model` never leaves `PersistenceStore`, so the intent /// works with these DTOs. -enum MessageRecipient: Sendable { - case contact(ContactDTO) - case channel(ChannelDTO) +enum MessageRecipient { + case contact(ContactDTO) + case channel(ChannelDTO) - /// The radio the recipient was resolved against, used to confirm the live - /// radio still owns it before enqueuing. - var radioID: UUID { - switch self { - case .contact(let dto): dto.radioID - case .channel(let dto): dto.radioID - } + /// The radio the recipient was resolved against, used to confirm the live + /// radio still owns it before enqueuing. + var radioID: UUID { + switch self { + case let .contact(dto): dto.radioID + case let .channel(dto): dto.radioID } + } } diff --git a/MC1/Intents/MessageTargetEntity.swift b/MC1/Intents/MessageTargetEntity.swift index 143b9906..73df7873 100644 --- a/MC1/Intents/MessageTargetEntity.swift +++ b/MC1/Intents/MessageTargetEntity.swift @@ -9,44 +9,43 @@ import MC1Services /// when a channel is relocated). The kind is re-derived from the id at resolution /// time, so the in-memory `kind` read here drives display only. struct MessageTargetEntity: AppEntity { - static let typeDisplayRepresentation = TypeDisplayRepresentation( - name: LocalizedStringResource("intent.entity.target", table: "Tools") - ) - static let defaultQuery = MessageTargetQuery() + static let typeDisplayRepresentation = TypeDisplayRepresentation( + name: LocalizedStringResource("intent.entity.target", table: "Tools") + ) + static let defaultQuery = MessageTargetQuery() - let id: String - let kind: MessageTargetKind - let displayName: String - let subtitle: String + let id: String + let kind: MessageTargetKind + let displayName: String + let subtitle: String - var displayRepresentation: DisplayRepresentation { - let symbol: String - switch kind { - case .contact: symbol = "person.fill" - case .channel: symbol = "person.3.fill" - } - return DisplayRepresentation( - title: "\(displayName)", - subtitle: "\(subtitle)", - image: .init(systemName: symbol) - ) + var displayRepresentation: DisplayRepresentation { + let symbol = switch kind { + case .contact: "person.fill" + case .channel: "person.3.fill" } + return DisplayRepresentation( + title: "\(displayName)", + subtitle: "\(subtitle)", + image: .init(systemName: symbol) + ) + } - init(dto: ContactDTO) { - self.id = formatCompositeID(radioID: dto.radioID, kind: .contact, keyHex: dto.publicKey.hexString) - self.kind = .contact - self.displayName = dto.displayName - self.subtitle = dto.publicKeyPrefix.hexString - } + init(dto: ContactDTO) { + id = formatCompositeID(radioID: dto.radioID, kind: .contact, keyHex: dto.publicKey.hexString) + kind = .contact + displayName = dto.displayName + subtitle = dto.publicKeyPrefix.hexString + } - init(dto: ChannelDTO) { - self.id = formatCompositeID( - radioID: dto.radioID, - kind: .channel, - keyHex: channelSecretDigestHex(radioID: dto.radioID, secret: dto.secret) - ) - self.kind = .channel - self.displayName = dto.displayName - self.subtitle = L10n.Tools.Intent.Entity.channel - } + init(dto: ChannelDTO) { + id = formatCompositeID( + radioID: dto.radioID, + kind: .channel, + keyHex: channelSecretDigestHex(radioID: dto.radioID, secret: dto.secret) + ) + kind = .channel + displayName = dto.displayName + subtitle = L10n.Tools.Intent.Entity.channel + } } diff --git a/MC1/Intents/MessageTargetKind.swift b/MC1/Intents/MessageTargetKind.swift index 6b7fc4d8..72e6fc84 100644 --- a/MC1/Intents/MessageTargetKind.swift +++ b/MC1/Intents/MessageTargetKind.swift @@ -1,9 +1,9 @@ /// Discriminates the two send targets a `MessageTargetEntity` can represent. Raw /// values are pinned strings because they are embedded in the framework-persisted /// composite id; a case reorder must never change a saved id. -enum MessageTargetKind: String, Sendable { - // swiftlint:disable redundant_string_enum_value - case contact = "contact" - case channel = "channel" - // swiftlint:enable redundant_string_enum_value +enum MessageTargetKind: String { + // swiftlint:disable redundant_string_enum_value + case contact + case channel + // swiftlint:enable redundant_string_enum_value } diff --git a/MC1/Intents/MessageTargetQuery.swift b/MC1/Intents/MessageTargetQuery.swift index 760d7dd8..d8490727 100644 --- a/MC1/Intents/MessageTargetQuery.swift +++ b/MC1/Intents/MessageTargetQuery.swift @@ -7,37 +7,37 @@ import MC1Services /// stay nonisolated and hop to a single `@MainActor` fetch that reads the live /// connection through `IntentBridge`. struct MessageTargetQuery: EntityQuery { - @Dependency private var bridge: IntentBridge + @Dependency private var bridge: IntentBridge - /// Re-fetches by id against the current radio and resolves through - /// `resolveMessageTargets`. Deliberately does not chat-filter: - /// `SendMessageIntent.validate` is the type gate, so a contact that changed - /// type after being saved still resolves here and is rejected at send time. - func entities(for identifiers: [MessageTargetEntity.ID]) async throws -> [MessageTargetEntity] { - guard !identifiers.isEmpty else { return [] } - let fetched = try await fetchTargets() - return resolveMessageTargets(matching: identifiers, contacts: fetched.contacts, channels: fetched.channels) - } + /// Re-fetches by id against the current radio and resolves through + /// `resolveMessageTargets`. Deliberately does not chat-filter: + /// `SendMessageIntent.validate` is the type gate, so a contact that changed + /// type after being saved still resolves here and is rejected at send time. + func entities(for identifiers: [MessageTargetEntity.ID]) async throws -> [MessageTargetEntity] { + guard !identifiers.isEmpty else { return [] } + let fetched = try await fetchTargets() + return resolveMessageTargets(matching: identifiers, contacts: fetched.contacts, channels: fetched.channels) + } - func suggestedEntities() async throws -> [MessageTargetEntity] { - let fetched = try await fetchTargets() - return buildMessageTargets(contacts: chatContacts(fetched.contacts), channels: fetched.channels) - } + func suggestedEntities() async throws -> [MessageTargetEntity] { + let fetched = try await fetchTargets() + return buildMessageTargets(contacts: chatContacts(fetched.contacts), channels: fetched.channels) + } - @MainActor - private func fetchTargets() async throws -> (contacts: [ContactDTO], channels: [ChannelDTO]) { - guard let scope = currentRadioScope(bridge) else { return ([], []) } - let contacts = try await scope.store.fetchContacts(radioID: scope.radioID) - let channels = try await scope.store.fetchChannels(radioID: scope.radioID) - return (contacts, channels) - } + @MainActor + private func fetchTargets() async throws -> (contacts: [ContactDTO], channels: [ChannelDTO]) { + guard let scope = currentRadioScope(bridge) else { return ([], []) } + let contacts = try await scope.store.fetchContacts(radioID: scope.radioID) + let channels = try await scope.store.fetchChannels(radioID: scope.radioID) + return (contacts, channels) + } } extension MessageTargetQuery: EntityStringQuery { - func entities(matching string: String) async throws -> [MessageTargetEntity] { - let needle = string.lowercased() - let fetched = try await fetchTargets() - return buildMessageTargets(contacts: chatContacts(fetched.contacts), channels: fetched.channels) - .filter { $0.displayName.lowercased().contains(needle) } - } + func entities(matching string: String) async throws -> [MessageTargetEntity] { + let needle = string.lowercased() + let fetched = try await fetchTargets() + return buildMessageTargets(contacts: chatContacts(fetched.contacts), channels: fetched.channels) + .filter { $0.displayName.lowercased().contains(needle) } + } } diff --git a/MC1/Intents/MessageTargetResolution.swift b/MC1/Intents/MessageTargetResolution.swift index e5c779ae..9c3cd230 100644 --- a/MC1/Intents/MessageTargetResolution.swift +++ b/MC1/Intents/MessageTargetResolution.swift @@ -12,11 +12,11 @@ import MC1Services /// an empty result rather than an error. @MainActor func currentRadioScope(_ bridge: IntentBridge) -> (radioID: UUID, store: PersistenceStore)? { - guard let appState = bridge.appState, - let radioID = appState.currentRadioID else { return nil } - let store = appState.services?.dataStore - ?? appState.connectionManager.createStandalonePersistenceStore() - return (radioID, store) + guard let appState = bridge.appState, + let radioID = appState.currentRadioID else { return nil } + let store = appState.services?.dataStore + ?? appState.connectionManager.createStandalonePersistenceStore() + return (radioID, store) } // MARK: - Channel resolution @@ -26,28 +26,28 @@ func currentRadioScope(_ bridge: IntentBridge) -> (radioID: UUID, store: Persist /// or more than one channel fails safe to no match rather than guessing, so a /// relocated or duplicated channel can never mis-route a send. func resolveUniqueChannels(matching ids: [String], in channels: [ChannelDTO]) -> [ChannelDTO] { - let wanted = Set(ids) - var matchesByID: [String: [ChannelDTO]] = [:] - for channel in channels { - let id = formatCompositeID( - radioID: channel.radioID, - kind: .channel, - keyHex: channelSecretDigestHex(radioID: channel.radioID, secret: channel.secret) - ) - guard wanted.contains(id) else { continue } - matchesByID[id, default: []].append(channel) - } - return wanted.compactMap { id in - guard let matches = matchesByID[id], matches.count == 1 else { return nil } - return matches.first - } + let wanted = Set(ids) + var matchesByID: [String: [ChannelDTO]] = [:] + for channel in channels { + let id = formatCompositeID( + radioID: channel.radioID, + kind: .channel, + keyHex: channelSecretDigestHex(radioID: channel.radioID, secret: channel.secret) + ) + guard wanted.contains(id) else { continue } + matchesByID[id, default: []].append(channel) + } + return wanted.compactMap { id in + guard let matches = matchesByID[id], matches.count == 1 else { return nil } + return matches.first + } } // MARK: - Picker list /// Chat contacts only: a DM target must be a person, never a repeater or room. func chatContacts(_ contacts: [ContactDTO]) -> [ContactDTO] { - contacts.filter { $0.type == .chat } + contacts.filter { $0.type == .chat } } /// Resolves saved-shortcut ids against fetched DTOs the way `entities(for:)` @@ -56,27 +56,27 @@ func chatContacts(_ contacts: [ContactDTO]) -> [ContactDTO] { /// zero/duplicate-digest fail-safe. The two strategies are distinct and must not /// collapse: a channel digest can collide across rows, a public-key id cannot. func resolveMessageTargets(matching ids: [String], contacts: [ContactDTO], channels: [ChannelDTO]) -> [MessageTargetEntity] { - let wanted = Set(ids) - let contactIDs = wanted.filter { parseCompositeID($0)?.kind == .contact } - let channelIDs = wanted.filter { parseCompositeID($0)?.kind == .channel } - let contactMatches = contacts - .map(MessageTargetEntity.init(dto:)) - .filter { contactIDs.contains($0.id) } - let channelMatches = resolveUniqueChannels(matching: Array(channelIDs), in: channels) - .map(MessageTargetEntity.init(dto:)) - return contactMatches + channelMatches + let wanted = Set(ids) + let contactIDs = wanted.filter { parseCompositeID($0)?.kind == .contact } + let channelIDs = wanted.filter { parseCompositeID($0)?.kind == .channel } + let contactMatches = contacts + .map(MessageTargetEntity.init(dto:)) + .filter { contactIDs.contains($0.id) } + let channelMatches = resolveUniqueChannels(matching: Array(channelIDs), in: channels) + .map(MessageTargetEntity.init(dto:)) + return contactMatches + channelMatches } /// The combined recipient-picker list: contacts then channels, each sorted by /// display name so the order is stable across cold launches. Channels whose /// secret digest collides are omitted because the send path cannot resolve them. func buildMessageTargets(contacts: [ContactDTO], channels: [ChannelDTO]) -> [MessageTargetEntity] { - func byDisplayName(_ lhs: MessageTargetEntity, _ rhs: MessageTargetEntity) -> Bool { - lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending - } - let contactEntities = contacts.map(MessageTargetEntity.init(dto:)).sorted(by: byDisplayName) - let channelEntities = resolveUniqueChannels(matching: channels.map { MessageTargetEntity(dto: $0).id }, in: channels) - .map(MessageTargetEntity.init(dto:)) - .sorted(by: byDisplayName) - return contactEntities + channelEntities + func byDisplayName(_ lhs: MessageTargetEntity, _ rhs: MessageTargetEntity) -> Bool { + lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + let contactEntities = contacts.map(MessageTargetEntity.init(dto:)).sorted(by: byDisplayName) + let channelEntities = resolveUniqueChannels(matching: channels.map { MessageTargetEntity(dto: $0).id }, in: channels) + .map(MessageTargetEntity.init(dto:)) + .sorted(by: byDisplayName) + return contactEntities + channelEntities } diff --git a/MC1/Intents/SendAdvertIntent.swift b/MC1/Intents/SendAdvertIntent.swift index 92a30736..47431f14 100644 --- a/MC1/Intents/SendAdvertIntent.swift +++ b/MC1/Intents/SendAdvertIntent.swift @@ -10,66 +10,66 @@ import MC1Services /// an advert has no delivery ACK, so the radio queuing the command is the /// terminal success state, the same as a channel broadcast. struct SendAdvertIntent: AppIntent { - static let title = LocalizedStringResource("intent.advert.title", table: "Tools") - static let description = IntentDescription( - LocalizedStringResource("intent.advert.description", table: "Tools") - ) - static let openAppWhenRun = false + static let title = LocalizedStringResource("intent.advert.title", table: "Tools") + static let description = IntentDescription( + LocalizedStringResource("intent.advert.description", table: "Tools") + ) + static let openAppWhenRun = false - /// The advert transmits the radio's live GPS, so it must run only when this - /// iPhone is unlocked, not merely an authenticated companion device. - static let authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication + /// The advert transmits the radio's live GPS, so it must run only when this + /// iPhone is unlocked, not merely an authenticated companion device. + static let authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication - @Parameter(title: LocalizedStringResource("intent.advert.param.reach", table: "Tools"), default: .zeroHop) - var reach: AdvertReach + @Parameter(title: LocalizedStringResource("intent.advert.param.reach", table: "Tools"), default: .zeroHop) + var reach: AdvertReach - @Dependency var bridge: IntentBridge + @Dependency var bridge: IntentBridge - static var parameterSummary: some ParameterSummary { - Summary("Send a \(\.$reach) advert", table: "Tools") - } + static var parameterSummary: some ParameterSummary { + Summary("Send a \(\.$reach) advert", table: "Tools") + } - @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { - guard let appState = bridge.appState else { throw IntentError.notConnected } - do { - try await appState.sendSelfAdvert(flood: reach.sendsFlood, allowLocationPrompt: false) - } catch { - throw Self.mapToIntentError(error) - } - return .result(dialog: IntentDialog(stringLiteral: Self.successDialog(for: reach))) + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let appState = bridge.appState else { throw IntentError.notConnected } + do { + try await appState.sendSelfAdvert(flood: reach.sendsFlood, allowLocationPrompt: false) + } catch { + throw Self.mapToIntentError(error) } + return .result(dialog: IntentDialog(stringLiteral: Self.successDialog(for: reach))) + } - /// The spoken confirmation, branched on reach. Pure so it is unit-assertable - /// without the framework's perform machinery. - static func successDialog(for reach: AdvertReach) -> String { - switch reach { - case .zeroHop: L10n.Tools.Intent.Advert.Dialog.sentZeroHop - case .flood: L10n.Tools.Intent.Advert.Dialog.sentFlood - } + /// The spoken confirmation, branched on reach. Pure so it is unit-assertable + /// without the framework's perform machinery. + static func successDialog(for reach: AdvertReach) -> String { + switch reach { + case .zeroHop: L10n.Tools.Intent.Advert.Dialog.sentZeroHop + case .flood: L10n.Tools.Intent.Advert.Dialog.sentFlood } + } - /// Maps any error from the send onto the localized `IntentError` surface so - /// Siri never speaks a raw error. The service layer rewraps only - /// `MeshCoreError`, so a mid-advert radio drop can surface a raw transport - /// error here; anything unrecognized maps to the generic advert failure. - static func mapToIntentError(_ error: Error) -> IntentError { - switch error { - case let intentError as IntentError: intentError - case let advertError as AdvertisementError: mapToIntentError(advertError) - case let meshError as MeshCoreError: .sessionError(meshError) - default: .advertFailed - } + /// Maps any error from the send onto the localized `IntentError` surface so + /// Siri never speaks a raw error. The service layer rewraps only + /// `MeshCoreError`, so a mid-advert radio drop can surface a raw transport + /// error here; anything unrecognized maps to the generic advert failure. + static func mapToIntentError(_ error: Error) -> IntentError { + switch error { + case let intentError as IntentError: intentError + case let advertError as AdvertisementError: mapToIntentError(advertError) + case let meshError as MeshCoreError: .sessionError(meshError) + default: .advertFailed } + } - /// Maps the advert service's errors onto the localized `IntentError` surface. - /// Typed and exhaustive so an unexpected case can't silently collapse to the - /// wrong message; `.sendFailed` and `.invalidResponse` share one user line. - static func mapToIntentError(_ error: AdvertisementError) -> IntentError { - switch error { - case .notConnected: .notConnected - case .sessionError(let meshError): .sessionError(meshError) - case .sendFailed, .invalidResponse: .advertFailed - } + /// Maps the advert service's errors onto the localized `IntentError` surface. + /// Typed and exhaustive so an unexpected case can't silently collapse to the + /// wrong message; `.sendFailed` and `.invalidResponse` share one user line. + static func mapToIntentError(_ error: AdvertisementError) -> IntentError { + switch error { + case .notConnected: .notConnected + case let .sessionError(meshError): .sessionError(meshError) + case .sendFailed, .invalidResponse: .advertFailed } + } } diff --git a/MC1/Intents/SendMessageIntent+Routing.swift b/MC1/Intents/SendMessageIntent+Routing.swift index b6afd416..90fdb4e2 100644 --- a/MC1/Intents/SendMessageIntent+Routing.swift +++ b/MC1/Intents/SendMessageIntent+Routing.swift @@ -2,92 +2,91 @@ import Foundation import MC1Services extension SendMessageIntent { - - /// Classifies a send purely from the connection rung. `.connected` is - /// deliberately treated as not-yet-ready (services are built after the rung - /// flips to `.connected`), so it never takes the queue path. - static func route(for state: DeviceConnectionState) -> SendRoute { - switch state { - case .ready: .headlessQueue - case .syncing: .queueAfterSync - case .connected, .connecting: .foregroundEscalate - case .disconnected: .notConnected - } + /// Classifies a send purely from the connection rung. `.connected` is + /// deliberately treated as not-yet-ready (services are built after the rung + /// flips to `.connected`), so it never takes the queue path. + static func route(for state: DeviceConnectionState) -> SendRoute { + switch state { + case .ready: .headlessQueue + case .syncing: .queueAfterSync + case .connected, .connecting: .foregroundEscalate + case .disconnected: .notConnected } + } - /// Splits the disconnected rung on whether a prior radio is restorable: with - /// a last-connected radio the send foreground-escalates so the app reconnects; - /// with nothing to restore, retrying cannot help, so the send throws. - static func disconnectedRoute(hasRestorableRadio: Bool) -> SendRoute { - hasRestorableRadio ? .foregroundEscalate : .notConnected - } + /// Splits the disconnected rung on whether a prior radio is restorable: with + /// a last-connected radio the send foreground-escalates so the app reconnects; + /// with nothing to restore, retrying cannot help, so the send throws. + static func disconnectedRoute(hasRestorableRadio: Bool) -> SendRoute { + hasRestorableRadio ? .foregroundEscalate : .notConnected + } - /// Rejects a message that cannot be sent to the chosen recipient: only a chat - /// contact can receive a DM (a repeater or room cannot), and channel text is - /// capped at the node-name-adjusted budget. The firmware prepends - /// `": "` to every channel broadcast, so the usable text is the - /// total length minus the node name and its separator; validating against the - /// full total would let a near-limit message pass and then be silently - /// truncated on the air. DM length is enforced downstream by - /// `createPendingMessage`. - static func validate(message: String, for recipient: MessageRecipient, nodeNameByteCount: Int) throws { - switch recipient { - case .contact(let dto): - guard dto.type == .chat else { throw IntentError.invalidRecipient } - case .channel: - let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: nodeNameByteCount) - guard message.utf8.count <= maxBytes else { - throw IntentError.messageTooLong - } - } + /// Rejects a message that cannot be sent to the chosen recipient: only a chat + /// contact can receive a DM (a repeater or room cannot), and channel text is + /// capped at the node-name-adjusted budget. The firmware prepends + /// `": "` to every channel broadcast, so the usable text is the + /// total length minus the node name and its separator; validating against the + /// full total would let a near-limit message pass and then be silently + /// truncated on the air. DM length is enforced downstream by + /// `createPendingMessage`. + static func validate(message: String, for recipient: MessageRecipient, nodeNameByteCount: Int) throws { + switch recipient { + case let .contact(dto): + guard dto.type == .chat else { throw IntentError.invalidRecipient } + case .channel: + let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: nodeNameByteCount) + guard message.utf8.count <= maxBytes else { + throw IntentError.messageTooLong + } } + } - /// The confirmation prompt naming the recipient, spoken before the send so a - /// broadcast under the user's identity is always deliberate. - static func confirmText(for recipient: MessageRecipient) -> String { - L10n.Tools.Intent.Send.confirm(recipientName(for: recipient)) - } + /// The confirmation prompt naming the recipient, spoken before the send so a + /// broadcast under the user's identity is always deliberate. + static func confirmText(for recipient: MessageRecipient) -> String { + L10n.Tools.Intent.Send.confirm(recipientName(for: recipient)) + } - /// Rewraps a reused-service error into a localized `IntentError` so Siri - /// never speaks a raw `MessageServiceError`/`ChannelServiceError`/ - /// `ChatSendQueueServiceError`. A failure to persist or enqueue a connected - /// send maps to `.sendFailed`, never `.notConnected`, so the spoken line - /// doesn't blame the connection for a local write that failed. - static func mapToIntentError(_ error: Error) -> IntentError { - switch error { - case let intentError as IntentError: - return intentError - case let serviceError as MessageServiceError: - switch serviceError { - case .notConnected: return .notConnected - case .invalidRecipient, .contactNotFound, .channelNotFound: return .invalidRecipient - case .messageTooLong: return .messageTooLong - case .sessionError(let underlying): return .sessionError(underlying) - case .sendFailed: return .sendFailed - } - case let serviceError as ChannelServiceError: - switch serviceError { - case .notConnected: return .notConnected - case .channelNotFound, .invalidChannelIndex: return .invalidRecipient - case .sessionError(let underlying): return .sessionError(underlying) - case .secretHashingFailed, .saveFailed, .sendFailed, - .syncAlreadyInProgress, .circuitBreakerOpen: - return .sendFailed - } - case let queueError as ChatSendQueueServiceError: - switch queueError { - case .notConnected: return .notConnected - case .persistFailed: return .sendFailed - } - default: - return .sendFailed - } + /// Rewraps a reused-service error into a localized `IntentError` so Siri + /// never speaks a raw `MessageServiceError`/`ChannelServiceError`/ + /// `ChatSendQueueServiceError`. A failure to persist or enqueue a connected + /// send maps to `.sendFailed`, never `.notConnected`, so the spoken line + /// doesn't blame the connection for a local write that failed. + static func mapToIntentError(_ error: Error) -> IntentError { + switch error { + case let intentError as IntentError: + intentError + case let serviceError as MessageServiceError: + switch serviceError { + case .notConnected: .notConnected + case .invalidRecipient, .contactNotFound, .channelNotFound: .invalidRecipient + case .messageTooLong: .messageTooLong + case let .sessionError(underlying): .sessionError(underlying) + case .sendFailed: .sendFailed + } + case let serviceError as ChannelServiceError: + switch serviceError { + case .notConnected: .notConnected + case .channelNotFound, .invalidChannelIndex: .invalidRecipient + case let .sessionError(underlying): .sessionError(underlying) + case .secretHashingFailed, .saveFailed, .sendFailed, + .syncAlreadyInProgress, .circuitBreakerOpen: + .sendFailed + } + case let queueError as ChatSendQueueServiceError: + switch queueError { + case .notConnected: .notConnected + case .persistFailed: .sendFailed + } + default: + .sendFailed } + } - private static func recipientName(for recipient: MessageRecipient) -> String { - switch recipient { - case .contact(let dto): dto.displayName - case .channel(let dto): dto.displayName - } + private static func recipientName(for recipient: MessageRecipient) -> String { + switch recipient { + case let .contact(dto): dto.displayName + case let .channel(dto): dto.displayName } + } } diff --git a/MC1/Intents/SendMessageIntent.swift b/MC1/Intents/SendMessageIntent.swift index 93138635..682511ef 100644 --- a/MC1/Intents/SendMessageIntent.swift +++ b/MC1/Intents/SendMessageIntent.swift @@ -10,204 +10,204 @@ import MC1Services /// foreground or throws a localized error rather than silently dropping a /// message the user believes was sent. struct SendMessageIntent: AppIntent { - static let title = LocalizedStringResource("intent.send.title", table: "Tools") - static let description = IntentDescription( - LocalizedStringResource("intent.send.description", table: "Tools") - ) - static let openAppWhenRun = false - - /// A send broadcasts under the user's identity, so it must not run from a - /// locked device; this gates running the send, not entity-picker resolution. - static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication - - @Parameter(title: LocalizedStringResource("intent.send.param.target", table: "Tools")) - var target: MessageTargetEntity - - @Parameter(title: LocalizedStringResource("intent.send.param.message", table: "Tools")) - var message: String - - @Dependency var bridge: IntentBridge - - static var parameterSummary: some ParameterSummary { - Summary("Send \(\.$message) to \(\.$target)", table: "Tools") - } - - @MainActor - func perform() async throws -> some IntentResult { - try await executeSend() - return .result() + static let title = LocalizedStringResource("intent.send.title", table: "Tools") + static let description = IntentDescription( + LocalizedStringResource("intent.send.description", table: "Tools") + ) + static let openAppWhenRun = false + + /// A send broadcasts under the user's identity, so it must not run from a + /// locked device; this gates running the send, not entity-picker resolution. + static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication + + @Parameter(title: LocalizedStringResource("intent.send.param.target", table: "Tools")) + var target: MessageTargetEntity + + @Parameter(title: LocalizedStringResource("intent.send.param.message", table: "Tools")) + var message: String + + @Dependency var bridge: IntentBridge + + static var parameterSummary: some ParameterSummary { + Summary("Send \(\.$message) to \(\.$target)", table: "Tools") + } + + @MainActor + func perform() async throws -> some IntentResult { + try await executeSend() + return .result() + } + + /// Drives the readiness matrix to a confirmation-gated send, a foreground + /// handoff, or a thrown localized `IntentError`. A successful queue returns + /// no spoken result: the confirmation prompt is the only interaction, and the + /// message's true status surfaces in the chat rather than as a second popup. + @MainActor + private func executeSend() async throws { + guard let appState = bridge.appState else { + try await continueInForeground() + return } - /// Drives the readiness matrix to a confirmation-gated send, a foreground - /// handoff, or a thrown localized `IntentError`. A successful queue returns - /// no spoken result: the confirmation prompt is the only interaction, and the - /// message's true status surfaces in the chat rather than as a second popup. - @MainActor - private func executeSend() async throws { - guard let appState = bridge.appState else { - try await continueInForeground() - return - } - - // Classify the route before resolving a recipient: only the queue paths - // need one, and resolution depends on a live radio scope, so resolving it - // up front would surface `invalidRecipient` on a disconnected radio where - // the route is the true diagnosis. A drop during the later confirmation - // await is re-checked inside `performSend`. - let route = Self.route(for: appState.connectionManager.connectionState) - switch route { - case .headlessQueue, .queueAfterSync: - let recipient = try await resolveRecipient() - let nodeNameByteCount = appState.connectedDevice?.nodeName.utf8.count ?? 0 - try Self.validate(message: message, for: recipient, nodeNameByteCount: nodeNameByteCount) - try await requestConfirmation(dialog: IntentDialog(stringLiteral: Self.confirmText(for: recipient))) - switch try await Self.performSend(message: message, recipient: recipient, in: appState) { - case .queued: - return - case .mustForeground: - try await continueInForeground() - } - case .foregroundEscalate: - try await continueInForeground() - case .notConnected: - let hasRestorableRadio = appState.connectionManager.lastConnectedRadioID != nil - switch Self.disconnectedRoute(hasRestorableRadio: hasRestorableRadio) { - case .foregroundEscalate: - try await continueInForeground() - default: - throw IntentError.notConnected - } - } + // Classify the route before resolving a recipient: only the queue paths + // need one, and resolution depends on a live radio scope, so resolving it + // up front would surface `invalidRecipient` on a disconnected radio where + // the route is the true diagnosis. A drop during the later confirmation + // await is re-checked inside `performSend`. + let route = Self.route(for: appState.connectionManager.connectionState) + switch route { + case .headlessQueue, .queueAfterSync: + let recipient = try await resolveRecipient() + let nodeNameByteCount = appState.connectedDevice?.nodeName.utf8.count ?? 0 + try Self.validate(message: message, for: recipient, nodeNameByteCount: nodeNameByteCount) + try await requestConfirmation(dialog: IntentDialog(stringLiteral: Self.confirmText(for: recipient))) + switch try await Self.performSend(message: message, recipient: recipient, in: appState) { + case .queued: + return + case .mustForeground: + try await continueInForeground() + } + case .foregroundEscalate: + try await continueInForeground() + case .notConnected: + let hasRestorableRadio = appState.connectionManager.lastConnectedRadioID != nil + switch Self.disconnectedRoute(hasRestorableRadio: hasRestorableRadio) { + case .foregroundEscalate: + try await continueInForeground() + default: + throw IntentError.notConnected + } } - - // MARK: - Recipient resolution - - /// Re-resolves the chosen target id against the currently connected radio, - /// carrying only a Sendable DTO across the actor boundary. Routing keys on the - /// kind parsed from the id, never the entity's in-memory `kind`, and re-fetches - /// (rather than trusting the saved-shortcut entity) so a stale or other-radio - /// id can't orphan the message on a radio it doesn't belong to. An - /// unparseable id fails safe to `invalidRecipient`. - @MainActor - private func resolveRecipient() async throws -> MessageRecipient { - switch parseCompositeID(target.id)?.kind { - case .contact: - guard let dto = try await liveContact(for: target) else { - throw IntentError.invalidRecipient - } - return .contact(dto) - case .channel: - guard let dto = try await liveChannel(for: target) else { - throw IntentError.invalidRecipient - } - return .channel(dto) - case nil: - throw IntentError.invalidRecipient - } + } + + // MARK: - Recipient resolution + + /// Re-resolves the chosen target id against the currently connected radio, + /// carrying only a Sendable DTO across the actor boundary. Routing keys on the + /// kind parsed from the id, never the entity's in-memory `kind`, and re-fetches + /// (rather than trusting the saved-shortcut entity) so a stale or other-radio + /// id can't orphan the message on a radio it doesn't belong to. An + /// unparseable id fails safe to `invalidRecipient`. + @MainActor + private func resolveRecipient() async throws -> MessageRecipient { + switch parseCompositeID(target.id)?.kind { + case .contact: + guard let dto = try await liveContact(for: target) else { + throw IntentError.invalidRecipient + } + return .contact(dto) + case .channel: + guard let dto = try await liveChannel(for: target) else { + throw IntentError.invalidRecipient + } + return .channel(dto) + case nil: + throw IntentError.invalidRecipient } - - @MainActor - private func liveContact(for entity: MessageTargetEntity) async throws -> ContactDTO? { - guard let scope = currentRadioScope(bridge) else { return nil } - let contacts = try await scope.store.fetchContacts(radioID: scope.radioID) - return contacts.first { MessageTargetEntity(dto: $0).id == entity.id } - } - - @MainActor - private func liveChannel(for entity: MessageTargetEntity) async throws -> ChannelDTO? { - guard let scope = currentRadioScope(bridge) else { return nil } - let channels = try await scope.store.fetchChannels(radioID: scope.radioID) - return resolveUniqueChannels(matching: [entity.id], in: channels).first - } - - // MARK: - Enqueue - - /// Performs the durable-queue send after confirmation, re-reading services - /// first. `requestConfirmation` is an unbounded user-driven await, and a drop - /// during it nils `services` synchronously, so a nil read here means the - /// radio is no longer ready: the send returns `.mustForeground` so the caller - /// escalates rather than reporting a queued message that never enqueued. - /// Reached only with a confirmed-live decision; carries Sendable DTOs. - @MainActor - static func performSend( - message: String, - recipient: MessageRecipient, - in appState: AppState - ) async throws -> SendOutcome { - guard let services = appState.services else { return .mustForeground } - // The recipient was resolved against one radio before the confirmation - // await; a switch to another radio during it leaves services non-nil for - // the new radio. Enqueuing here would scope the message row to the old - // radio and the PendingSend to the new one, mis-routing the send, so a - // radio change foreground-escalates exactly like a dropped connection. - guard appState.currentRadioID == recipient.radioID else { return .mustForeground } - // Re-validate against the node name the envelope is about to capture. The - // budget checked before the unbounded confirmation await can be stale: a - // rename or reconnect during it changes the firmware-prepended - // ": " length, and a now-too-long channel message would - // otherwise be silently truncated on the air. - let nodeNameByteCount = appState.connectedDevice?.nodeName.utf8.count ?? 0 - try validate(message: message, for: recipient, nodeNameByteCount: nodeNameByteCount) - - let pending: MessageDTO - do { - switch recipient { - case .contact(let dto): - pending = try await services.messageService.createPendingMessage(text: message, to: dto) - case .channel(let dto): - pending = try await services.messageService.createPendingChannelMessage( - text: message, - channelIndex: dto.index, - radioID: dto.radioID - ) - } - } catch { - throw mapToIntentError(error) - } - - do { - switch recipient { - case .contact(let dto): - try await services.chatSendQueueService.enqueueDM( - DirectMessageEnvelope(messageID: pending.id, contactID: dto.id) - ) - case .channel(let dto): - try await services.chatSendQueueService.enqueueChannel( - ChannelMessageEnvelope( - messageID: pending.id, - channelIndex: dto.index, - isResend: false, - messageText: pending.text, - messageTimestamp: pending.timestamp, - localNodeName: appState.connectedDevice?.nodeName - ) - ) - } - } catch { - // The row is already persisted as `.pending`, but the enqueue write - // failed so no `PendingSend` backs it and the drain never sends it. - // Mark it `.failed` so it surfaces a retry instead of hanging pending, - // the same recovery the chat send path performs. - _ = try? await services.dataStore.updateMessageStatusUnlessDelivered(id: pending.id, status: .failed) - throw mapToIntentError(error) - } - // A headless send never crosses the chat view model that an in-app send - // refreshes through, so announce the change here or the Chats list keeps - // its cached preview until an unrelated reload fires. - appState.refreshConversations() - return .queued + } + + @MainActor + private func liveContact(for entity: MessageTargetEntity) async throws -> ContactDTO? { + guard let scope = currentRadioScope(bridge) else { return nil } + let contacts = try await scope.store.fetchContacts(radioID: scope.radioID) + return contacts.first { MessageTargetEntity(dto: $0).id == entity.id } + } + + @MainActor + private func liveChannel(for entity: MessageTargetEntity) async throws -> ChannelDTO? { + guard let scope = currentRadioScope(bridge) else { return nil } + let channels = try await scope.store.fetchChannels(radioID: scope.radioID) + return resolveUniqueChannels(matching: [entity.id], in: channels).first + } + + // MARK: - Enqueue + + /// Performs the durable-queue send after confirmation, re-reading services + /// first. `requestConfirmation` is an unbounded user-driven await, and a drop + /// during it nils `services` synchronously, so a nil read here means the + /// radio is no longer ready: the send returns `.mustForeground` so the caller + /// escalates rather than reporting a queued message that never enqueued. + /// Reached only with a confirmed-live decision; carries Sendable DTOs. + @MainActor + static func performSend( + message: String, + recipient: MessageRecipient, + in appState: AppState + ) async throws -> SendOutcome { + guard let services = appState.services else { return .mustForeground } + // The recipient was resolved against one radio before the confirmation + // await; a switch to another radio during it leaves services non-nil for + // the new radio. Enqueuing here would scope the message row to the old + // radio and the PendingSend to the new one, mis-routing the send, so a + // radio change foreground-escalates exactly like a dropped connection. + guard appState.currentRadioID == recipient.radioID else { return .mustForeground } + // Re-validate against the node name the envelope is about to capture. The + // budget checked before the unbounded confirmation await can be stale: a + // rename or reconnect during it changes the firmware-prepended + // ": " length, and a now-too-long channel message would + // otherwise be silently truncated on the air. + let nodeNameByteCount = appState.connectedDevice?.nodeName.utf8.count ?? 0 + try validate(message: message, for: recipient, nodeNameByteCount: nodeNameByteCount) + + let pending: MessageDTO + do { + switch recipient { + case let .contact(dto): + pending = try await services.messageService.createPendingMessage(text: message, to: dto) + case let .channel(dto): + pending = try await services.messageService.createPendingChannelMessage( + text: message, + channelIndex: dto.index, + radioID: dto.radioID + ) + } + } catch { + throw mapToIntentError(error) } - /// Foregrounds the app (still launching, connecting, or a restorable - /// disconnect) so the user can finish the send there, speaking the handoff - /// prompt as it hands off. The dictated text is not carried across: this - /// hands control to the app rather than enqueuing the message itself. - @MainActor - private func continueInForeground() async throws { - try await requestToContinueInForeground( - IntentDialog(stringLiteral: L10n.Tools.Intent.Send.foreground) + do { + switch recipient { + case let .contact(dto): + try await services.chatSendQueueService.enqueueDM( + DirectMessageEnvelope(messageID: pending.id, contactID: dto.id) ) + case let .channel(dto): + try await services.chatSendQueueService.enqueueChannel( + ChannelMessageEnvelope( + messageID: pending.id, + channelIndex: dto.index, + isResend: false, + messageText: pending.text, + messageTimestamp: pending.timestamp, + localNodeName: appState.connectedDevice?.nodeName + ) + ) + } + } catch { + // The row is already persisted as `.pending`, but the enqueue write + // failed so no `PendingSend` backs it and the drain never sends it. + // Mark it `.failed` so it surfaces a retry instead of hanging pending, + // the same recovery the chat send path performs. + _ = try? await services.dataStore.updateMessageStatusUnlessDelivered(id: pending.id, status: .failed) + throw mapToIntentError(error) } + // A headless send never crosses the chat view model that an in-app send + // refreshes through, so announce the change here or the Chats list keeps + // its cached preview until an unrelated reload fires. + appState.refreshConversations() + return .queued + } + + /// Foregrounds the app (still launching, connecting, or a restorable + /// disconnect) so the user can finish the send there, speaking the handoff + /// prompt as it hands off. The dictated text is not carried across: this + /// hands control to the app rather than enqueuing the message itself. + @MainActor + private func continueInForeground() async throws { + try await requestToContinueInForeground( + IntentDialog(stringLiteral: L10n.Tools.Intent.Send.foreground) + ) + } } @available(iOSApplicationExtension, unavailable) diff --git a/MC1/Intents/SendOutcome.swift b/MC1/Intents/SendOutcome.swift index 3aeaa4b3..5c37e579 100644 --- a/MC1/Intents/SendOutcome.swift +++ b/MC1/Intents/SendOutcome.swift @@ -2,6 +2,6 @@ /// services re-read maps to `.mustForeground` so the caller escalates instead of /// reporting a queued message that was never enqueued. enum SendOutcome: Equatable { - case queued - case mustForeground + case queued + case mustForeground } diff --git a/MC1/Intents/SendRoute.swift b/MC1/Intents/SendRoute.swift index a5be9fe2..78c870a6 100644 --- a/MC1/Intents/SendRoute.swift +++ b/MC1/Intents/SendRoute.swift @@ -5,16 +5,16 @@ import MC1Services /// This shadows nothing: it maps the existing connection rungs onto the one /// branch the send cares about (durable queue vs foreground vs throw). enum SendRoute: Equatable { - /// `.ready`: the queue drains now, so enqueue headlessly. - case headlessQueue - /// `.syncing`: services exist and the row persists, but the queue drains - /// only once sync clears, so the dialog says "after sync". - case queueAfterSync - /// `.connected` (services may be nil) or `.connecting`: do not enqueue - /// through a possibly-nil service; foreground-escalate instead. - case foregroundEscalate - /// `.disconnected` with nothing to restore: retrying cannot help, so throw. - /// A restorable disconnect instead resolves to `foregroundEscalate` via - /// `disconnectedRoute(hasRestorableRadio:)`. - case notConnected + /// `.ready`: the queue drains now, so enqueue headlessly. + case headlessQueue + /// `.syncing`: services exist and the row persists, but the queue drains + /// only once sync clears, so the dialog says "after sync". + case queueAfterSync + /// `.connected` (services may be nil) or `.connecting`: do not enqueue + /// through a possibly-nil service; foreground-escalate instead. + case foregroundEscalate + /// `.disconnected` with nothing to restore: retrying cannot help, so throw. + /// A restorable disconnect instead resolves to `foregroundEscalate` via + /// `disconnectedRoute(hasRestorableRadio:)`. + case notConnected } diff --git a/MC1/Intents/StatusQueryIntent.swift b/MC1/Intents/StatusQueryIntent.swift index 13d55bbf..fd39bf2f 100644 --- a/MC1/Intents/StatusQueryIntent.swift +++ b/MC1/Intents/StatusQueryIntent.swift @@ -8,62 +8,62 @@ import MC1Services /// pocketed responder wants it. The battery value is honestly staleness-labeled /// because it is whatever the last background poll read, not a fresh reading. struct StatusQueryIntent: AppIntent { - static let title = LocalizedStringResource("intent.status.title", table: "Tools") - static let description = IntentDescription( - LocalizedStringResource("intent.status.description", table: "Tools") - ) - static let openAppWhenRun = false + static let title = LocalizedStringResource("intent.status.title", table: "Tools") + static let description = IntentDescription( + LocalizedStringResource("intent.status.description", table: "Tools") + ) + static let openAppWhenRun = false - /// Reads only cached `@Observable` state and never touches the radio, so it is - /// safe to answer from the lock screen. Without this the default - /// `.requiresAuthentication` would force an unlock first, defeating the - /// pocketed-responder glance the intent exists for. - static let authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed + /// Reads only cached `@Observable` state and never touches the radio, so it is + /// safe to answer from the lock screen. Without this the default + /// `.requiresAuthentication` would force an unlock first, defeating the + /// pocketed-responder glance the intent exists for. + static let authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed - @Dependency var bridge: IntentBridge + @Dependency var bridge: IntentBridge - @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { - guard let appState = bridge.appState else { - return .result(dialog: IntentDialog(stringLiteral: L10n.Tools.Intent.Status.Dialog.notReady)) - } - return .result(dialog: IntentDialog(stringLiteral: Self.dialogText(for: appState))) + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let appState = bridge.appState else { + return .result(dialog: IntentDialog(stringLiteral: L10n.Tools.Intent.Status.Dialog.notReady)) } + return .result(dialog: IntentDialog(stringLiteral: Self.dialogText(for: appState))) + } - /// Builds the spoken line from synchronous `@Observable` state only. Connected - /// rungs report cached battery (or "no reading" when the radio is mains-powered - /// or the value is absent); other rungs report the connection state with the - /// best offline-readable radio name. - @MainActor - static func dialogText(for appState: AppState) -> String { - let connectionState = appState.connectionManager.connectionState + /// Builds the spoken line from synchronous `@Observable` state only. Connected + /// rungs report cached battery (or "no reading" when the radio is mains-powered + /// or the value is absent); other rungs report the connection state with the + /// best offline-readable radio name. + @MainActor + static func dialogText(for appState: AppState) -> String { + let connectionState = appState.connectionManager.connectionState - if connectionState.isConnected { - let name = appState.connectedDevice?.nodeName ?? offlineName(for: appState) ?? L10n.Tools.Intent.Status.radioFallbackName - guard let battery = appState.batteryMonitor.deviceBattery, - battery.isBatteryPresent else { - return L10n.Tools.Intent.Status.Dialog.connectedNoBattery(name) - } - let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) - let percent = battery.percentage(using: ocvArray) - return L10n.Tools.Intent.Status.Dialog.connectedWithBattery(name, percent) - } - - if connectionState == .connecting { - let name = offlineName(for: appState) ?? L10n.Tools.Intent.Status.radioFallbackName - return L10n.Tools.Intent.Status.Dialog.connecting(name) - } + if connectionState.isConnected { + let name = appState.connectedDevice?.nodeName ?? offlineName(for: appState) ?? L10n.Tools.Intent.Status.radioFallbackName + guard let battery = appState.batteryMonitor.deviceBattery, + battery.isBatteryPresent else { + return L10n.Tools.Intent.Status.Dialog.connectedNoBattery(name) + } + let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) + let percent = battery.percentage(using: ocvArray) + return L10n.Tools.Intent.Status.Dialog.connectedWithBattery(name, percent) + } - if let name = offlineName(for: appState) { - return L10n.Tools.Intent.Status.Dialog.disconnectedNamed(name) - } - return L10n.Tools.Intent.Status.Dialog.disconnectedUnknown + if connectionState == .connecting { + let name = offlineName(for: appState) ?? L10n.Tools.Intent.Status.radioFallbackName + return L10n.Tools.Intent.Status.Dialog.connecting(name) } - /// Last-connected radio name for offline display, or nil when this install has - /// never connected so a caller can speak a generic fallback instead. - @MainActor - private static func offlineName(for appState: AppState) -> String? { - appState.connectionManager.lastConnectedDeviceName + if let name = offlineName(for: appState) { + return L10n.Tools.Intent.Status.Dialog.disconnectedNamed(name) } + return L10n.Tools.Intent.Status.Dialog.disconnectedUnknown + } + + /// Last-connected radio name for offline display, or nil when this install has + /// never connected so a caller can speak a generic fallback instead. + @MainActor + private static func offlineName(for appState: AppState) -> String? { + appState.connectionManager.lastConnectedDeviceName + } } diff --git a/MC1/MC1App.swift b/MC1/MC1App.swift index 204ceae1..a5e08166 100644 --- a/MC1/MC1App.swift +++ b/MC1/MC1App.swift @@ -1,216 +1,217 @@ -import os import AppIntents -import SwiftUI +import MC1Services +import os import SwiftData +import SwiftUI import TipKit -import MC1Services 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 - - /// Stable holder that App Intents read through `AppDependencyManager`. It - /// must outlive the before-first-unlock `AppState` swap, so it is a plain - /// stored property registered once in `init()`, not `@State`. - private let intentBridge = IntentBridge() - - init() { - // Register the bridge synchronously here: a background-launched intent - // runs only `App.init` (no scene, no `.task`), so a deferred - // registration could let its `@Dependency` read throw before it lands. - // Capture into a local so the escaping autoclosure does not capture the - // still-initializing `self`. - let intentBridge = self.intentBridge - AppDependencyManager.shared.add(dependency: intentBridge) - - // True only on the before-first-unlock path, where the normal init site - // holds an in-memory throwaway the bridge must not adopt. - var usingThrowawayStore = false - - let container: ModelContainer + @State private var appState: AppState + @State private var awaitingDataProtection = false + @Environment(\.scenePhase) private var scenePhase + + /// Stable holder that App Intents read through `AppDependencyManager`. It + /// must outlive the before-first-unlock `AppState` swap, so it is a plain + /// stored property registered once in `init()`, not `@State`. + private let intentBridge = IntentBridge() + + init() { + // Register the bridge synchronously here: a background-launched intent + // runs only `App.init` (no scene, no `.task`), so a deferred + // registration could let its `@Dependency` read throw before it lands. + // Capture into a local so the escaping autoclosure does not capture the + // still-initializing `self`. + let intentBridge = intentBridge + AppDependencyManager.shared.add(dependency: intentBridge) + + // True only on the before-first-unlock path, where the normal init site + // holds an in-memory throwaway the bridge must not adopt. + var usingThrowawayStore = false + + let container: ModelContainer + do { + container = try PersistenceStore.createContainer() + } catch { + 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() + container = try PersistenceStore.createContainer() } catch { - 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 { - let nsError = error as NSError - logger.fault(""" - Container creation failed after retry: \ - domain=\(nsError.domain, privacy: .public) \ - code=\(nsError.code, privacy: .public) \ - desc=\(nsError.localizedDescription, privacy: .public) \ - userInfo=\(String(describing: nsError.userInfo), privacy: .public) - """) - fatalError("ModelContainer creation failed after retry while data is available: \(nsError.domain) \(nsError.code)") - } - let appState = AppState(modelContainer: container) - _appState = State(initialValue: appState) - intentBridge.adopt(appState) - 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(inMemory: true) - } catch { - fatalError("In-memory ModelContainer creation failed: \(error)") - } - _awaitingDataProtection = State(initialValue: true) - usingThrowawayStore = true + let nsError = error as NSError + logger.fault(""" + Container creation failed after retry: \ + domain=\(nsError.domain, privacy: .public) \ + code=\(nsError.code, privacy: .public) \ + desc=\(nsError.localizedDescription, privacy: .public) \ + userInfo=\(String(describing: nsError.userInfo), privacy: .public) + """) + fatalError("ModelContainer creation failed after retry while data is available: \(nsError.domain) \(nsError.code)") } let appState = AppState(modelContainer: container) _appState = State(initialValue: appState) - // BFU throwaway: defer adoption to the post-unlock swap so a pre-unlock - // intent reads nil, not an empty store. - if !usingThrowawayStore { - intentBridge.adopt(appState) - } + intentBridge.adopt(appState) + 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(inMemory: true) + } catch { + fatalError("In-memory ModelContainer creation failed: \(error)") + } + _awaitingDataProtection = State(initialValue: true) + usingThrowawayStore = true } + let appState = AppState(modelContainer: container) + _appState = State(initialValue: appState) + // BFU throwaway: defer adoption to the post-unlock swap so a pre-unlock + // intent reads nil, not an empty store. + if !usingThrowawayStore { + intentBridge.adopt(appState) + } + } + + var body: some Scene { + WindowGroup { + ContentView() + .environment(\.appState, appState) + .environment(\.appTheme, appState.themeService.current) + .tint(appState.themeService.current.chromeTint) + .preferredColorScheme(appState.themeService.effectiveColorScheme) + #if !SIDELOAD + .task(id: ObjectIdentifier(appState)) { await appState.storeState.service.load() } + #endif + .task { + if awaitingDataProtection { + await waitForProtectedData() + do { + let container = try PersistenceStore.createContainer() + // Tear down the BFU-bootstrap AppState's StoreService listener Task + // before swapping in the real AppState — otherwise the bootstrap + // instance's Transaction.updates listener leaks for the process + // lifetime and every later transaction event fires `walkCurrentEntitlements` + // twice (once per orphaned StoreService). + appState.shutdown() + let realAppState = AppState(modelContainer: container) + appState = realAppState + // First real store on the BFU path; the bridge was + // left nil in `init()` until now. + intentBridge.adopt(realAppState) + awaitingDataProtection = false + } catch { + let nsError = error as NSError + logger.fault(""" + Container creation failed after unlock: \ + domain=\(nsError.domain, privacy: .public) \ + code=\(nsError.code, privacy: .public) \ + desc=\(nsError.localizedDescription, privacy: .public) \ + userInfo=\(String(describing: nsError.userInfo), privacy: .public) + """) + fatalError("ModelContainer creation failed after protected data became available: \(nsError.domain) \(nsError.code)") + } + } + + try? Tips.configure([ + .displayFrequency(.immediate) + ]) + + #if DEBUG + if ProcessInfo.processInfo.isScreenshotMode { + await setupScreenshotMode() + } else { + await appState.initialize() + } + #else + await appState.initialize() + #endif - var body: some Scene { - WindowGroup { - ContentView() - .environment(\.appState, appState) - .environment(\.appTheme, appState.themeService.current) - .tint(appState.themeService.current.chromeTint) - .preferredColorScheme(appState.themeService.effectiveColorScheme) - #if !SIDELOAD - .task(id: ObjectIdentifier(appState)) { await appState.storeState.service.load() } - #endif - .task { - if awaitingDataProtection { - await waitForProtectedData() - do { - let container = try PersistenceStore.createContainer() - // Tear down the BFU-bootstrap AppState's StoreService listener Task - // before swapping in the real AppState — otherwise the bootstrap - // instance's Transaction.updates listener leaks for the process - // lifetime and every later transaction event fires `walkCurrentEntitlements` - // twice (once per orphaned StoreService). - appState.shutdown() - let realAppState = AppState(modelContainer: container) - appState = realAppState - // First real store on the BFU path; the bridge was - // left nil in `init()` until now. - intentBridge.adopt(realAppState) - awaitingDataProtection = false - } catch { - let nsError = error as NSError - logger.fault(""" - Container creation failed after unlock: \ - domain=\(nsError.domain, privacy: .public) \ - code=\(nsError.code, privacy: .public) \ - desc=\(nsError.localizedDescription, privacy: .public) \ - userInfo=\(String(describing: nsError.userInfo), privacy: .public) - """) - fatalError("ModelContainer creation failed after protected data became available: \(nsError.domain) \(nsError.code)") - } - } - - try? Tips.configure([ - .displayFrequency(.immediate) - ]) - - #if DEBUG - if ProcessInfo.processInfo.isScreenshotMode { - await setupScreenshotMode() - } else { - await appState.initialize() - } - #else - await appState.initialize() - #endif - - await runInitialForegroundReconciliationIfNeeded() - } - .onOpenURL { _ in - // meshcoreone://status — tapped from Live Activity - // Opening the app is sufficient; future: navigate based on url.host - } - .onChange(of: scenePhase) { oldPhase, newPhase in - handleScenePhaseChange(from: oldPhase, to: newPhase) - } + await runInitialForegroundReconciliationIfNeeded() + } + .onOpenURL { _ in + // meshcoreone://status — tapped from Live Activity + // Opening the app is sufficient; future: navigate based on url.host + } + .onChange(of: scenePhase) { oldPhase, newPhase in + handleScenePhaseChange(from: oldPhase, to: newPhase) } } + } - #if DEBUG && targetEnvironment(simulator) + #if DEBUG && targetEnvironment(simulator) /// Sets up the app for App Store screenshot capture. /// Bypasses onboarding and auto-connects to simulator with mock data. @MainActor private func setupScreenshotMode() async { - // Bypass onboarding - appState.onboarding.hasCompletedOnboarding = true + // Bypass onboarding + appState.onboarding.hasCompletedOnboarding = true - // Persist simulator device ID for auto-reconnect - UserDefaults.standard.set( - MockDataProvider.simulatorDeviceID.uuidString, - forKey: PersistenceKeys.lastConnectedDeviceID - ) + // Persist simulator device ID for auto-reconnect + UserDefaults.standard.set( + MockDataProvider.simulatorDeviceID.uuidString, + forKey: PersistenceKeys.lastConnectedDeviceID + ) - // Initialize app (will auto-connect to simulator device) - await appState.initialize() + // Initialize app (will auto-connect to simulator device) + await appState.initialize() } - #elseif DEBUG + + #elseif DEBUG @MainActor private func setupScreenshotMode() async { - // Screenshot mode only works in simulator - await appState.initialize() + // Screenshot mode only works in simulator + await appState.initialize() } - #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() + #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 } - } - - private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) { - switch newPhase { - case .active: - Task { - await appState.handleReturnToForeground() - } - case .background: - appState.handleEnterBackground() - Task { - await appState.services?.debugLogBuffer.flush() - } - case .inactive: - break - @unknown default: - break + } + group.addTask { + while await !UIApplication.shared.isProtectedDataAvailable { + try? await Task.sleep(for: .seconds(1)) } + } + await group.next() + group.cancelAll() } + } - private func runInitialForegroundReconciliationIfNeeded() async { - guard scenePhase == .active else { return } + private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) { + switch newPhase { + case .active: + Task { await appState.handleReturnToForeground() + } + case .background: + appState.handleEnterBackground() + Task { + await appState.services?.debugLogBuffer.flush() + } + case .inactive: + break + @unknown default: + break } + } + + private func runInitialForegroundReconciliationIfNeeded() async { + guard scenePhase == .active else { return } + await appState.handleReturnToForeground() + } } diff --git a/MC1/Models/ChatFilter.swift b/MC1/Models/ChatFilter.swift index f96c9582..980bdc25 100644 --- a/MC1/Models/ChatFilter.swift +++ b/MC1/Models/ChatFilter.swift @@ -2,21 +2,23 @@ import Foundation /// Filter options for the Chats list view enum ChatFilter: String, CaseIterable, Identifiable { - case all - case unread - case directMessages - case channels - case rooms + case all + case unread + case directMessages + case channels + case rooms - var id: String { rawValue } + var id: String { + rawValue + } - var localizedName: String { - switch self { - case .all: L10n.Chats.Chats.Filter.all - case .unread: L10n.Chats.Chats.Filter.unread - case .directMessages: L10n.Chats.Chats.Filter.directMessages - case .channels: L10n.Chats.Chats.Filter.channels - case .rooms: L10n.Chats.Chats.Filter.rooms - } + var localizedName: String { + switch self { + case .all: L10n.Chats.Chats.Filter.all + case .unread: L10n.Chats.Chats.Filter.unread + case .directMessages: L10n.Chats.Chats.Filter.directMessages + case .channels: L10n.Chats.Chats.Filter.channels + case .rooms: L10n.Chats.Chats.Filter.rooms } + } } diff --git a/MC1/Models/Conversation+Filtering.swift b/MC1/Models/Conversation+Filtering.swift index 085a8b61..a95a8d9c 100644 --- a/MC1/Models/Conversation+Filtering.swift +++ b/MC1/Models/Conversation+Filtering.swift @@ -1,39 +1,39 @@ import Foundation -extension Array where Element == Conversation { - /// Filters conversations by category and search text - /// - Parameters: - /// - filter: Filter category - /// - searchText: Search string to match against display names - /// - Returns: Filtered array of conversations - func filtered(by filter: ChatFilter, searchText: String) -> [Conversation] { - // When searching, ignore the selected filter and search all conversations - if !searchText.isEmpty { - return self.filter { conversation in - conversation.displayName.localizedStandardContains(searchText) - } - } +extension [Conversation] { + /// Filters conversations by category and search text + /// - Parameters: + /// - filter: Filter category + /// - searchText: Search string to match against display names + /// - Returns: Filtered array of conversations + func filtered(by filter: ChatFilter, searchText: String) -> [Conversation] { + // When searching, ignore the selected filter and search all conversations + if !searchText.isEmpty { + return self.filter { conversation in + conversation.displayName.localizedStandardContains(searchText) + } + } - switch filter { - case .all: - return self - case .unread: - return self.filter { $0.unreadCount > 0 && !$0.isMuted } - case .directMessages: - return self.filter { - if case .direct = $0 { return true } - return false - } - case .channels: - return self.filter { - if case .channel = $0 { return true } - return false - } - case .rooms: - return self.filter { - if case .room = $0 { return true } - return false - } - } + switch filter { + case .all: + return self + case .unread: + return self.filter { $0.unreadCount > 0 && !$0.isMuted } + case .directMessages: + return self.filter { + if case .direct = $0 { return true } + return false + } + case .channels: + return self.filter { + if case .channel = $0 { return true } + return false + } + case .rooms: + return self.filter { + if case .room = $0 { return true } + return false + } } + } } diff --git a/MC1/Models/Conversation.swift b/MC1/Models/Conversation.swift index 2b5fa755..920f10b7 100644 --- a/MC1/Models/Conversation.swift +++ b/MC1/Models/Conversation.swift @@ -3,78 +3,77 @@ import MC1Services /// Represents a conversation in the chat list - direct chat, channel, or room enum Conversation: Identifiable, Hashable { - case direct(ContactDTO) - case channel(ChannelDTO) - case room(RemoteNodeSessionDTO) + case direct(ContactDTO) + case channel(ChannelDTO) + case room(RemoteNodeSessionDTO) - var id: UUID { - switch self { - case .direct(let contact): - return contact.id - case .channel(let channel): - return channel.id - case .room(let session): - return session.id - } + var id: UUID { + switch self { + case let .direct(contact): + contact.id + case let .channel(channel): + channel.id + case let .room(session): + session.id } + } - var displayName: String { - switch self { - case .direct(let contact): - return contact.displayName - case .channel(let channel): - return channel.displayName - case .room(let session): - return session.name - } + var displayName: String { + switch self { + case let .direct(contact): + contact.displayName + case let .channel(channel): + channel.displayName + case let .room(session): + session.name } + } - var lastMessageDate: Date? { - switch self { - case .direct(let contact): - return contact.lastMessageDate - case .channel(let channel): - return channel.lastMessageDate - case .room(let session): - return session.lastMessageDate - } + var lastMessageDate: Date? { + switch self { + case let .direct(contact): + contact.lastMessageDate + case let .channel(channel): + channel.lastMessageDate + case let .room(session): + session.lastMessageDate } + } - var unreadCount: Int { - switch self { - case .direct(let contact): - return contact.unreadCount - case .channel(let channel): - return channel.unreadCount - case .room(let session): - return session.unreadCount - } + var unreadCount: Int { + switch self { + case let .direct(contact): + contact.unreadCount + case let .channel(channel): + channel.unreadCount + case let .room(session): + session.unreadCount } + } - var notificationLevel: NotificationLevel { - switch self { - case .direct(let contact): - return contact.isMuted ? .muted : .all - case .channel(let channel): - return channel.notificationLevel - case .room(let session): - return session.notificationLevel - } + var notificationLevel: NotificationLevel { + switch self { + case let .direct(contact): + contact.isMuted ? .muted : .all + case let .channel(channel): + channel.notificationLevel + case let .room(session): + session.notificationLevel } + } - var isMuted: Bool { - notificationLevel == .muted - } + var isMuted: Bool { + notificationLevel == .muted + } - var isFavorite: Bool { - switch self { - case .direct(let contact): - return contact.isFavorite - case .channel(let channel): - return channel.isFavorite - case .room(let session): - return session.isFavorite - } + var isFavorite: Bool { + switch self { + case let .direct(contact): + contact.isFavorite + case let .channel(channel): + channel.isFavorite + case let .room(session): + session.isFavorite } - + } } diff --git a/MC1/Models/DevicePreferenceStore.swift b/MC1/Models/DevicePreferenceStore.swift index bed60235..34444a13 100644 --- a/MC1/Models/DevicePreferenceStore.swift +++ b/MC1/Models/DevicePreferenceStore.swift @@ -1,52 +1,52 @@ import Foundation -enum GPSSource: String, Sendable, CaseIterable { - case phone - case device +enum GPSSource: String, CaseIterable { + case phone + case device } struct DevicePreferenceStore { - private let userDefaults: UserDefaults + private let userDefaults: UserDefaults - init(userDefaults: UserDefaults = .standard) { - self.userDefaults = userDefaults - } + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } - // MARK: - Auto-Update Location + // MARK: - Auto-Update Location - func isAutoUpdateLocationEnabled(deviceID: UUID) -> Bool { - userDefaults.bool(forKey: Self.autoUpdateLocationKey(deviceID: deviceID)) - } + func isAutoUpdateLocationEnabled(deviceID: UUID) -> Bool { + userDefaults.bool(forKey: Self.autoUpdateLocationKey(deviceID: deviceID)) + } - func setAutoUpdateLocationEnabled(_ enabled: Bool, deviceID: UUID) { - userDefaults.set(enabled, forKey: Self.autoUpdateLocationKey(deviceID: deviceID)) - } + func setAutoUpdateLocationEnabled(_ enabled: Bool, deviceID: UUID) { + userDefaults.set(enabled, forKey: Self.autoUpdateLocationKey(deviceID: deviceID)) + } - // MARK: - GPS Source + // MARK: - GPS Source - func gpsSource(deviceID: UUID) -> GPSSource { - guard let raw = userDefaults.string(forKey: Self.gpsSourceKey(deviceID: deviceID)), - let source = GPSSource(rawValue: raw) else { - return .phone - } - return source + func gpsSource(deviceID: UUID) -> GPSSource { + guard let raw = userDefaults.string(forKey: Self.gpsSourceKey(deviceID: deviceID)), + let source = GPSSource(rawValue: raw) else { + return .phone } + return source + } - func hasSetGPSSource(deviceID: UUID) -> Bool { - userDefaults.string(forKey: Self.gpsSourceKey(deviceID: deviceID)) != nil - } + func hasSetGPSSource(deviceID: UUID) -> Bool { + userDefaults.string(forKey: Self.gpsSourceKey(deviceID: deviceID)) != nil + } - func setGPSSource(_ source: GPSSource, deviceID: UUID) { - userDefaults.set(source.rawValue, forKey: Self.gpsSourceKey(deviceID: deviceID)) - } + func setGPSSource(_ source: GPSSource, deviceID: UUID) { + userDefaults.set(source.rawValue, forKey: Self.gpsSourceKey(deviceID: deviceID)) + } - // MARK: - Keys + // MARK: - Keys - private static func autoUpdateLocationKey(deviceID: UUID) -> String { - "device.\(deviceID.uuidString).autoUpdateLocation" - } + private static func autoUpdateLocationKey(deviceID: UUID) -> String { + "device.\(deviceID.uuidString).autoUpdateLocation" + } - private static func gpsSourceKey(deviceID: UUID) -> String { - "device.\(deviceID.uuidString).gpsSource" - } + private static func gpsSourceKey(deviceID: UUID) -> String { + "device.\(deviceID.uuidString).gpsSource" + } } diff --git a/MC1/Models/LinkPreviewPreferences.swift b/MC1/Models/LinkPreviewPreferences.swift index 658b6c25..b34cc4c0 100644 --- a/MC1/Models/LinkPreviewPreferences.swift +++ b/MC1/Models/LinkPreviewPreferences.swift @@ -3,37 +3,39 @@ import MC1Services /// User preferences for link preview behavior struct LinkPreviewPreferences: @unchecked Sendable { - private static let enabledKey = AppStorageKey.linkPreviewsEnabled.rawValue - private static let autoResolveDMKey = AppStorageKey.linkPreviewsAutoResolveDM.rawValue - private static let autoResolveChannelsKey = AppStorageKey.linkPreviewsAutoResolveChannels.rawValue - - private let defaults: UserDefaults - - var previewsEnabled: Bool { - get { defaults.bool(forKey: Self.enabledKey) } - nonmutating set { defaults.set(newValue, forKey: Self.enabledKey) } - } - var autoResolveDM: Bool { - get { defaults.object(forKey: Self.autoResolveDMKey) as? Bool ?? AppStorageKey.defaultLinkPreviewsAutoResolveDM } - nonmutating set { defaults.set(newValue, forKey: Self.autoResolveDMKey) } - } - var autoResolveChannels: Bool { - get { defaults.object(forKey: Self.autoResolveChannelsKey) as? Bool ?? AppStorageKey.defaultLinkPreviewsAutoResolveChannels } - nonmutating set { defaults.set(newValue, forKey: Self.autoResolveChannelsKey) } - } - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - /// Whether previews should be shown at all - var shouldShowPreview: Bool { - previewsEnabled - } - - /// Whether to auto-resolve based on message type - func shouldAutoResolve(isChannelMessage: Bool) -> Bool { - guard previewsEnabled else { return false } - return isChannelMessage ? autoResolveChannels : autoResolveDM - } + private static let enabledKey = AppStorageKey.linkPreviewsEnabled.rawValue + private static let autoResolveDMKey = AppStorageKey.linkPreviewsAutoResolveDM.rawValue + private static let autoResolveChannelsKey = AppStorageKey.linkPreviewsAutoResolveChannels.rawValue + + private let defaults: UserDefaults + + var previewsEnabled: Bool { + get { defaults.bool(forKey: Self.enabledKey) } + nonmutating set { defaults.set(newValue, forKey: Self.enabledKey) } + } + + var autoResolveDM: Bool { + get { defaults.object(forKey: Self.autoResolveDMKey) as? Bool ?? AppStorageKey.defaultLinkPreviewsAutoResolveDM } + nonmutating set { defaults.set(newValue, forKey: Self.autoResolveDMKey) } + } + + var autoResolveChannels: Bool { + get { defaults.object(forKey: Self.autoResolveChannelsKey) as? Bool ?? AppStorageKey.defaultLinkPreviewsAutoResolveChannels } + nonmutating set { defaults.set(newValue, forKey: Self.autoResolveChannelsKey) } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// Whether previews should be shown at all + var shouldShowPreview: Bool { + previewsEnabled + } + + /// Whether to auto-resolve based on message type + func shouldAutoResolve(isChannelMessage: Bool) -> Bool { + guard previewsEnabled else { return false } + return isChannelMessage ? autoResolveChannels : autoResolveDM + } } diff --git a/MC1/Models/WhatsNew/WhatsNewCatalog.swift b/MC1/Models/WhatsNew/WhatsNewCatalog.swift index cb6f56ad..e03781af 100644 --- a/MC1/Models/WhatsNew/WhatsNewCatalog.swift +++ b/MC1/Models/WhatsNew/WhatsNewCatalog.swift @@ -3,26 +3,26 @@ import Foundation /// Per-release What's New notes, keyed by the `major.minor` they belong to. Add a /// release as one `WhatsNewRelease` plus its `L10n` strings; it presents once on upgrade. enum WhatsNewCatalog { - static let releases: [WhatsNewRelease] = [ - WhatsNewRelease( - version: WhatsNewVersion(major: 1, minor: 1), - items: [ - WhatsNewItem( - symbol: "wand.and.rays", - title: L10n.WhatsNew.WhatsNew.SiriShortcuts.title, - description: L10n.WhatsNew.WhatsNew.SiriShortcuts.description - ), - WhatsNewItem( - symbol: "trash", - title: L10n.WhatsNew.WhatsNew.ClearMessages.title, - description: L10n.WhatsNew.WhatsNew.ClearMessages.description - ), - WhatsNewItem( - symbol: "antenna.radiowaves.left.and.right", - title: L10n.WhatsNew.WhatsNew.InboundHops.title, - description: L10n.WhatsNew.WhatsNew.InboundHops.description - ) - ] + static let releases: [WhatsNewRelease] = [ + WhatsNewRelease( + version: WhatsNewVersion(major: 1, minor: 1), + items: [ + WhatsNewItem( + symbol: "wand.and.rays", + title: L10n.WhatsNew.WhatsNew.SiriShortcuts.title, + description: L10n.WhatsNew.WhatsNew.SiriShortcuts.description + ), + WhatsNewItem( + symbol: "trash", + title: L10n.WhatsNew.WhatsNew.ClearMessages.title, + description: L10n.WhatsNew.WhatsNew.ClearMessages.description + ), + WhatsNewItem( + symbol: "antenna.radiowaves.left.and.right", + title: L10n.WhatsNew.WhatsNew.InboundHops.title, + description: L10n.WhatsNew.WhatsNew.InboundHops.description ) - ] + ] + ) + ] } diff --git a/MC1/Models/WhatsNew/WhatsNewItem.swift b/MC1/Models/WhatsNew/WhatsNewItem.swift index 60178605..4b0e4e0f 100644 --- a/MC1/Models/WhatsNew/WhatsNewItem.swift +++ b/MC1/Models/WhatsNew/WhatsNewItem.swift @@ -3,8 +3,8 @@ import Foundation /// One feature row in a What's New release: an SF Symbol with a localized title /// and description. struct WhatsNewItem: Identifiable { - let id = UUID() - let symbol: String - let title: String - let description: String + let id = UUID() + let symbol: String + let title: String + let description: String } diff --git a/MC1/Models/WhatsNew/WhatsNewRelease.swift b/MC1/Models/WhatsNew/WhatsNewRelease.swift index c79ccdda..1b04ef3b 100644 --- a/MC1/Models/WhatsNew/WhatsNewRelease.swift +++ b/MC1/Models/WhatsNew/WhatsNewRelease.swift @@ -2,8 +2,10 @@ import Foundation /// `Identifiable` by its `major.minor` version so a release drives `.sheet(item:)`. struct WhatsNewRelease: Identifiable { - let version: WhatsNewVersion - let items: [WhatsNewItem] + let version: WhatsNewVersion + let items: [WhatsNewItem] - var id: WhatsNewVersion { version } + var id: WhatsNewVersion { + version + } } diff --git a/MC1/Models/WhatsNew/WhatsNewVersion.swift b/MC1/Models/WhatsNew/WhatsNewVersion.swift index bc11476f..a6af161a 100644 --- a/MC1/Models/WhatsNew/WhatsNewVersion.swift +++ b/MC1/Models/WhatsNew/WhatsNewVersion.swift @@ -3,24 +3,24 @@ import Foundation /// The leading `major.minor` of a marketing version, ignoring patch. `Comparable` /// is lexicographic on `(major, minor)`, so "a 0.1 bump or more" is just `>`. struct WhatsNewVersion: Comparable, Hashable { - let major: Int - let minor: Int + let major: Int + let minor: Int - static func < (lhs: WhatsNewVersion, rhs: WhatsNewVersion) -> Bool { - (lhs.major, lhs.minor) < (rhs.major, rhs.minor) - } + static func < (lhs: WhatsNewVersion, rhs: WhatsNewVersion) -> Bool { + (lhs.major, lhs.minor) < (rhs.major, rhs.minor) + } } extension WhatsNewVersion { - /// Parses the leading `major.minor`; returns `nil` for anything unparseable - /// (`"unknown"`, `"2"`, a TestFlight `"1.0 (123)"`) so the sheet fails closed. - init?(marketingVersion: String) { - let components = marketingVersion.split(separator: ".") - guard components.count >= 2, - let major = Int(components[0]), - let minor = Int(components[1]) else { - return nil - } - self.init(major: major, minor: minor) + /// Parses the leading `major.minor`; returns `nil` for anything unparseable + /// (`"unknown"`, `"2"`, a TestFlight `"1.0 (123)"`) so the sheet fails closed. + init?(marketingVersion: String) { + let components = marketingVersion.split(separator: ".") + guard components.count >= 2, + let major = Int(components[0]), + let minor = Int(components[1]) else { + return nil } + self.init(major: major, minor: minor) + } } diff --git a/MC1/Services/AppStateProviderImpl.swift b/MC1/Services/AppStateProviderImpl.swift index a85a7522..d3c6bd18 100644 --- a/MC1/Services/AppStateProviderImpl.swift +++ b/MC1/Services/AppStateProviderImpl.swift @@ -1,5 +1,5 @@ -import UIKit import MC1Services +import UIKit /// UIKit-based implementation of AppStateProvider. /// @@ -7,14 +7,13 @@ import MC1Services /// MainActor-isolated with async getter to allow cross-actor access. @MainActor final class AppStateProviderImpl: AppStateProvider { + init() {} - init() {} - - nonisolated var isInForeground: Bool { - get async { - await MainActor.run { - UIApplication.shared.applicationState != .background - } - } + nonisolated var isInForeground: Bool { + get async { + await MainActor.run { + UIApplication.shared.applicationState != .background + } } + } } diff --git a/MC1/Services/DecodedPreviewCache.swift b/MC1/Services/DecodedPreviewCache.swift index cbf6e177..547e8b4f 100644 --- a/MC1/Services/DecodedPreviewCache.swift +++ b/MC1/Services/DecodedPreviewCache.swift @@ -9,27 +9,27 @@ import UIKit /// re-decode. `@unchecked Sendable` is sound because every stored property is /// `let` and `UIImage` / `LinkPreviewDataDTO` are immutable post-construction. final class CachedDecodedPreview: @unchecked Sendable { - let dto: LinkPreviewDataDTO - let hero: UIImage? - let icon: UIImage? - let cost: Int + let dto: LinkPreviewDataDTO + let hero: UIImage? + let icon: UIImage? + let cost: Int - init(dto: LinkPreviewDataDTO, hero: UIImage?, icon: UIImage?) { - // Strip the raw image/icon bytes: the decoded hero and icon carry the - // pixels, and the rehydration render path reads only the DTO's url, - // title, and dimensions. Retaining the compressed source bytes would - // be dead weight that the pixel-based cost budget never accounts for. - self.dto = LinkPreviewDataDTO( - url: dto.url, - title: dto.title, - imageWidth: dto.imageWidth, - imageHeight: dto.imageHeight, - fetchedAt: dto.fetchedAt - ) - self.hero = hero - self.icon = icon - self.cost = ImageByteCost.bytes(for: hero) + ImageByteCost.bytes(for: icon) - } + init(dto: LinkPreviewDataDTO, hero: UIImage?, icon: UIImage?) { + // Strip the raw image/icon bytes: the decoded hero and icon carry the + // pixels, and the rehydration render path reads only the DTO's url, + // title, and dimensions. Retaining the compressed source bytes would + // be dead weight that the pixel-based cost budget never accounts for. + self.dto = LinkPreviewDataDTO( + url: dto.url, + title: dto.title, + imageWidth: dto.imageWidth, + imageHeight: dto.imageHeight, + fetchedAt: dto.fetchedAt + ) + self.hero = hero + self.icon = icon + cost = ImageByteCost.bytes(for: hero) + ImageByteCost.bytes(for: icon) + } } /// Process-lifetime cache of decoded link-preview assets keyed by URL. @@ -43,75 +43,75 @@ final class CachedDecodedPreview: @unchecked Sendable { /// in the same call frame. FIFO eviction bounded by `maxEntryCount` and /// `maxTotalCostBytes`; auto-clears on memory pressure. final class DecodedPreviewCache: Sendable { - static let shared = DecodedPreviewCache() + static let shared = DecodedPreviewCache() - private static let maxEntryCount = 50 - private static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB + private static let maxEntryCount = 50 + private static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB - private let mirror = OSAllocatedUnfairLock(initialState: DecodedPreviewCacheState()) + private let mirror = OSAllocatedUnfairLock(initialState: DecodedPreviewCacheState()) - init() { - NotificationCenter.default.addObserver( - forName: UIApplication.didReceiveMemoryWarningNotification, - object: nil, - queue: nil - ) { [weak self] _ in - self?.clear() - } + init() { + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.clear() } + } - /// Persists a decoded preview keyed on the message's detected URL. Called - /// from `ChatViewModel.decodeAndStorePreviewImages` once the hero and icon - /// decode completes, before the per-VM apply, so a chat-exit mid-decode - /// still hands the assets to the next visit. FIFO eviction inside the lock - /// keeps the mirror within budget. - func store(_ entry: CachedDecodedPreview, for url: URL) { - let key = url.absoluteString - mirror.withLock { state in - if let existing = state.entries[key] { - state.totalCostBytes -= existing.cost - if let idx = state.insertionOrder.firstIndex(of: key) { - state.insertionOrder.remove(at: idx) - } - } - state.entries[key] = entry - state.insertionOrder.append(key) - state.totalCostBytes += entry.cost + /// Persists a decoded preview keyed on the message's detected URL. Called + /// from `ChatViewModel.decodeAndStorePreviewImages` once the hero and icon + /// decode completes, before the per-VM apply, so a chat-exit mid-decode + /// still hands the assets to the next visit. FIFO eviction inside the lock + /// keeps the mirror within budget. + func store(_ entry: CachedDecodedPreview, for url: URL) { + let key = url.absoluteString + mirror.withLock { state in + if let existing = state.entries[key] { + state.totalCostBytes -= existing.cost + if let idx = state.insertionOrder.firstIndex(of: key) { + state.insertionOrder.remove(at: idx) + } + } + state.entries[key] = entry + state.insertionOrder.append(key) + state.totalCostBytes += entry.cost - // Keep at least the just-inserted entry even when it singly - // exceeds the cost budget, so a large hero is still served once. - while state.insertionOrder.count > 1, - state.insertionOrder.count > Self.maxEntryCount - || state.totalCostBytes > Self.maxTotalCostBytes { - let oldest = state.insertionOrder.removeFirst() - if let evicted = state.entries.removeValue(forKey: oldest) { - state.totalCostBytes -= evicted.cost - } - } + // Keep at least the just-inserted entry even when it singly + // exceeds the cost budget, so a large hero is still served once. + while state.insertionOrder.count > 1, + state.insertionOrder.count > Self.maxEntryCount + || state.totalCostBytes > Self.maxTotalCostBytes { + let oldest = state.insertionOrder.removeFirst() + if let evicted = state.entries.removeValue(forKey: oldest) { + state.totalCostBytes -= evicted.cost } + } } + } - /// Wait-free decoded-preview lookup. Safe to call from a main-actor view - /// body or the URL-detection write path without an await. - func decoded(for url: URL) -> CachedDecodedPreview? { - mirror.withLock { $0.entries[url.absoluteString] } - } + /// Wait-free decoded-preview lookup. Safe to call from a main-actor view + /// body or the URL-detection write path without an await. + func decoded(for url: URL) -> CachedDecodedPreview? { + mirror.withLock { $0.entries[url.absoluteString] } + } - /// Empties the cache in response to system memory pressure. - func clear() { - mirror.withLock { state in - state.entries.removeAll() - state.insertionOrder.removeAll() - state.totalCostBytes = 0 - } + /// Empties the cache in response to system memory pressure. + func clear() { + mirror.withLock { state in + state.entries.removeAll() + state.insertionOrder.removeAll() + state.totalCostBytes = 0 } + } } /// State held under the mirror lock. Combining the dict with the /// insertion-order list and running cost lets a single `withLock` perform /// both the write and the eviction sweep atomically. private struct DecodedPreviewCacheState { - var entries: [String: CachedDecodedPreview] = [:] - var insertionOrder: [String] = [] - var totalCostBytes: Int = 0 + var entries: [String: CachedDecodedPreview] = [:] + var insertionOrder: [String] = [] + var totalCostBytes: Int = 0 } diff --git a/MC1/Services/DemoInlineImageSeeder.swift b/MC1/Services/DemoInlineImageSeeder.swift index e6e08bb1..8e6978e2 100644 --- a/MC1/Services/DemoInlineImageSeeder.swift +++ b/MC1/Services/DemoInlineImageSeeder.swift @@ -7,11 +7,11 @@ import UIKit /// so seeding it here (the cache and `UIImage` are app-layer) lets the package-level /// seed reference the URL while the pixels stay embedded and render offline. enum DemoInlineImageSeeder { - /// Idempotent: re-seeding overwrites the same cache key. - static func seed() { - guard let url = URL(string: MockDataProvider.inlineImageURL), - let image = UIImage(data: MockDataProvider.demoImageData) else { return } - let entry = CachedDecodedImage(image: image, isGIF: false, data: MockDataProvider.demoImageData) - InlineImageCache.shared.storeDecoded(entry, for: ImageURLClassifier.directImageURL(for: url)) - } + /// Idempotent: re-seeding overwrites the same cache key. + static func seed() { + guard let url = URL(string: MockDataProvider.inlineImageURL), + let image = UIImage(data: MockDataProvider.demoImageData) else { return } + let entry = CachedDecodedImage(image: image, isGIF: false, data: MockDataProvider.demoImageData) + InlineImageCache.shared.storeDecoded(entry, for: ImageURLClassifier.directImageURL(for: url)) + } } diff --git a/MC1/Services/ElevationService.swift b/MC1/Services/ElevationService.swift index 95191a22..359d85ca 100644 --- a/MC1/Services/ElevationService.swift +++ b/MC1/Services/ElevationService.swift @@ -9,270 +9,269 @@ private let logger = Logger(subsystem: "com.mc1", category: "ElevationService") /// Errors from elevation service enum ElevationServiceError: LocalizedError { - case networkError(String) - case invalidResponse - case apiError(String) - case noData - case rateLimited - - var errorDescription: String? { - switch self { - case .networkError(let description): - return L10n.Localizable.Common.Error.networkError(description) - case .invalidResponse: - return L10n.Localizable.Common.Error.invalidResponse - case .apiError(let message): - return L10n.Localizable.Common.Error.apiError(message) - case .noData: - return L10n.Localizable.Common.Error.noElevationData - case .rateLimited: - return L10n.Localizable.Common.Error.rateLimited - } + case networkError(String) + case invalidResponse + case apiError(String) + case noData + case rateLimited + + var errorDescription: String? { + switch self { + case let .networkError(description): + L10n.Localizable.Common.Error.networkError(description) + case .invalidResponse: + L10n.Localizable.Common.Error.invalidResponse + case let .apiError(message): + L10n.Localizable.Common.Error.apiError(message) + case .noData: + L10n.Localizable.Common.Error.noElevationData + case .rateLimited: + L10n.Localizable.Common.Error.rateLimited } + } } // MARK: - Protocol /// Protocol for elevation data fetching (for testability) protocol ElevationServiceProtocol: Sendable { - func fetchElevation(at coordinate: CLLocationCoordinate2D) async throws -> Double - func fetchElevations(along path: [CLLocationCoordinate2D]) async throws -> [ElevationSample] + func fetchElevation(at coordinate: CLLocationCoordinate2D) async throws -> Double + func fetchElevations(along path: [CLLocationCoordinate2D]) async throws -> [ElevationSample] } // MARK: - Service /// Service for fetching elevation data from Open-Meteo API actor ElevationService: ElevationServiceProtocol { + // MARK: - Constants - // MARK: - Constants + private static let apiEndpoint = "https://api.open-meteo.com/v1/elevation" + private static let maxPointsPerRequest = 100 + private static let maxRetries = 3 + private static let baseRetryDelay: Duration = .milliseconds(500) - private static let apiEndpoint = "https://api.open-meteo.com/v1/elevation" - private static let maxPointsPerRequest = 100 - private static let maxRetries = 3 - private static let baseRetryDelay: Duration = .milliseconds(500) + // MARK: - Sample Count Thresholds - // MARK: - Sample Count Thresholds + private static let thresholdUnder1km = 1000.0 + private static let threshold1to5km = 5000.0 + private static let threshold5to20km = 20000.0 - private static let thresholdUnder1km = 1_000.0 - private static let threshold1to5km = 5_000.0 - private static let threshold5to20km = 20_000.0 + private static let sampleCountUnder1km = 20 + private static let sampleCount1to5km = 50 + private static let sampleCount5to20km = 80 + private static let sampleCountOver20km = 100 - private static let sampleCountUnder1km = 20 - private static let sampleCount1to5km = 50 - private static let sampleCount5to20km = 80 - private static let sampleCountOver20km = 100 + // MARK: - Dependencies - // MARK: - Dependencies + private let session: URLSession - private let session: URLSession + // MARK: - Initialization - // MARK: - Initialization + init(session: URLSession = .shared) { + self.session = session + } - init(session: URLSession = .shared) { - self.session = session + // MARK: - Public Methods + + /// Fetch elevation for a single coordinate + /// - Parameter coordinate: The coordinate to get elevation for + /// - Returns: Elevation in meters above sea level + func fetchElevation(at coordinate: CLLocationCoordinate2D) async throws -> Double { + let samples = try await fetchElevations(along: [coordinate]) + guard let first = samples.first else { + throw ElevationServiceError.noData + } + return first.elevation + } + + /// Fetch elevations for multiple coordinates along a path + /// - Parameter path: Array of coordinates to get elevations for + /// - Returns: Array of elevation samples with distances calculated from first point + func fetchElevations(along path: [CLLocationCoordinate2D]) async throws -> [ElevationSample] { + guard !path.isEmpty else { + throw ElevationServiceError.noData } - // MARK: - Public Methods - - /// Fetch elevation for a single coordinate - /// - Parameter coordinate: The coordinate to get elevation for - /// - Returns: Elevation in meters above sea level - func fetchElevation(at coordinate: CLLocationCoordinate2D) async throws -> Double { - let samples = try await fetchElevations(along: [coordinate]) - guard let first = samples.first else { - throw ElevationServiceError.noData - } - return first.elevation + var coordinatesToFetch = path + + // Subsample if exceeding max points, ensuring endpoints are preserved + if coordinatesToFetch.count > Self.maxPointsPerRequest { + logger.warning( + "Requested \(path.count) elevation points, subsampling to \(Self.maxPointsPerRequest)" + ) + coordinatesToFetch = subsample(path, targetCount: Self.maxPointsPerRequest) } - /// Fetch elevations for multiple coordinates along a path - /// - Parameter path: Array of coordinates to get elevations for - /// - Returns: Array of elevation samples with distances calculated from first point - func fetchElevations(along path: [CLLocationCoordinate2D]) async throws -> [ElevationSample] { - guard !path.isEmpty else { - throw ElevationServiceError.noData - } - - var coordinatesToFetch = path - - // Subsample if exceeding max points, ensuring endpoints are preserved - if coordinatesToFetch.count > Self.maxPointsPerRequest { - logger.warning( - "Requested \(path.count) elevation points, subsampling to \(Self.maxPointsPerRequest)" - ) - coordinatesToFetch = subsample(path, targetCount: Self.maxPointsPerRequest) - } - - // Build URL with query parameters (using POSIX locale to ensure dot decimal separator) - let coordinateFormat = FloatingPointFormatStyle - .number - .locale(Locale(identifier: "en_US_POSIX")) - .precision(.fractionLength(6)) - let latitudes = coordinatesToFetch.map { $0.latitude.formatted(coordinateFormat) }.joined(separator: ",") - let longitudes = coordinatesToFetch.map { $0.longitude.formatted(coordinateFormat) }.joined(separator: ",") - - guard var components = URLComponents(string: Self.apiEndpoint) else { - throw ElevationServiceError.invalidResponse - } - - components.queryItems = [ - URLQueryItem(name: "latitude", value: latitudes), - URLQueryItem(name: "longitude", value: longitudes) - ] - - guard let url = components.url else { - throw ElevationServiceError.invalidResponse - } - - // Make request with retry on rate limit - let data = try await performRequest(url: url) - - // Parse response - let elevations = try parseElevationResponse(data) - - guard elevations.count == coordinatesToFetch.count else { - throw ElevationServiceError.invalidResponse - } - - // Build elevation samples with distance from first point - var samples: [ElevationSample] = [] - let startCoordinate = coordinatesToFetch[0] - - for (index, coordinate) in coordinatesToFetch.enumerated() { - let distanceFromA = RFCalculator.distance(from: startCoordinate, to: coordinate) - let sample = ElevationSample( - coordinate: coordinate, - elevation: elevations[index], - distanceFromAMeters: distanceFromA - ) - samples.append(sample) - } - - return samples + // Build URL with query parameters (using POSIX locale to ensure dot decimal separator) + let coordinateFormat = FloatingPointFormatStyle + .number + .locale(Locale(identifier: "en_US_POSIX")) + .precision(.fractionLength(6)) + let latitudes = coordinatesToFetch.map { $0.latitude.formatted(coordinateFormat) }.joined(separator: ",") + let longitudes = coordinatesToFetch.map { $0.longitude.formatted(coordinateFormat) }.joined(separator: ",") + + guard var components = URLComponents(string: Self.apiEndpoint) else { + throw ElevationServiceError.invalidResponse } - // MARK: - Static Methods - - /// Generate evenly spaced coordinates between two points - /// - Parameters: - /// - from: Starting coordinate (will be first in result) - /// - to: Ending coordinate (will be last in result) - /// - sampleCount: Number of samples to generate (minimum 2) - /// - Returns: Array of evenly spaced coordinates including endpoints - static func sampleCoordinates( - from: CLLocationCoordinate2D, - to: CLLocationCoordinate2D, - sampleCount: Int - ) -> [CLLocationCoordinate2D] { - let count = max(2, min(sampleCount, maxPointsPerRequest)) - - guard count >= 2 else { - return [from, to] - } - - var coordinates: [CLLocationCoordinate2D] = [] - coordinates.reserveCapacity(count) - - for i in 0.. Int { - switch distanceMeters { - case .. Data { - for attempt in 0.. [Double] { - struct ElevationResponse: Decodable { - let elevation: [Double] - } - - do { - let response = try JSONDecoder().decode(ElevationResponse.self, from: data) - return response.elevation - } catch { - throw ElevationServiceError.invalidResponse - } + return samples + } + + // MARK: - Static Methods + + /// Generate evenly spaced coordinates between two points + /// - Parameters: + /// - from: Starting coordinate (will be first in result) + /// - to: Ending coordinate (will be last in result) + /// - sampleCount: Number of samples to generate (minimum 2) + /// - Returns: Array of evenly spaced coordinates including endpoints + static func sampleCoordinates( + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D, + sampleCount: Int + ) -> [CLLocationCoordinate2D] { + let count = max(2, min(sampleCount, maxPointsPerRequest)) + + guard count >= 2 else { + return [from, to] } - /// Subsample an array to a target count, preserving first and last elements - private func subsample(_ array: [T], targetCount: Int) -> [T] { - guard array.count > targetCount, targetCount >= 2 else { - return array - } + var coordinates: [CLLocationCoordinate2D] = [] + coordinates.reserveCapacity(count) - var result: [T] = [] - result.reserveCapacity(targetCount) + for i in 0.. Int { + switch distanceMeters { + case .. Data { + for attempt in 0.. 0 { - let step = Double(array.count - 2) / Double(middleCount + 1) - for i in 1...middleCount { - let index = Int(Double(i) * step) - result.append(array[index]) - } - } + logger.error("Rate limited after \(Self.maxRetries) retries") + throw ElevationServiceError.rateLimited + } - // Always include last element - result.append(array[array.count - 1]) + private func parseElevationResponse(_ data: Data) throws -> [Double] { + struct ElevationResponse: Decodable { + let elevation: [Double] + } + + do { + let response = try JSONDecoder().decode(ElevationResponse.self, from: data) + return response.elevation + } catch { + throw ElevationServiceError.invalidResponse + } + } - return result + /// Subsample an array to a target count, preserving first and last elements + private func subsample(_ array: [T], targetCount: Int) -> [T] { + guard array.count > targetCount, targetCount >= 2 else { + return array } + + var result: [T] = [] + result.reserveCapacity(targetCount) + + // Always include first element + result.append(array[0]) + + // Calculate step for middle elements (targetCount - 2 middle samples) + let middleCount = targetCount - 2 + if middleCount > 0 { + let step = Double(array.count - 2) / Double(middleCount + 1) + for i in 1...middleCount { + let index = Int(Double(i) * step) + result.append(array[index]) + } + } + + // Always include last element + result.append(array[array.count - 1]) + + return result + } } diff --git a/MC1/Services/Geocoder.swift b/MC1/Services/Geocoder.swift index d272d409..e927847b 100644 --- a/MC1/Services/Geocoder.swift +++ b/MC1/Services/Geocoder.swift @@ -5,35 +5,35 @@ import Foundation /// `GeocodeResult` so test doubles don't need to construct `CLPlacemark` (which /// has no usable initializer outside CoreLocation). protocol Geocoder: Sendable { - func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? - func cancelGeocode() + func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? + func cancelGeocode() } -struct GeocodeResult: Sendable, Equatable { - let countryCode: String? - let administrativeArea: String? - let subAdministrativeArea: String? +struct GeocodeResult: Equatable { + let countryCode: String? + let administrativeArea: String? + let subAdministrativeArea: String? } /// `CLGeocoder`-backed implementation. Retains a single `CLGeocoder` instance /// so cancellation actually cancels the in-flight request — using a fresh /// instance per call (the previous shape) made `cancelGeocode()` a no-op. final class AppleGeocoder: Geocoder, @unchecked Sendable { - private let geocoder = CLGeocoder() + private let geocoder = CLGeocoder() - init() {} + init() {} - func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? { - let placemarks = try await geocoder.reverseGeocodeLocation(location, preferredLocale: preferredLocale) - guard let placemark = placemarks.first else { return nil } - return GeocodeResult( - countryCode: placemark.isoCountryCode, - administrativeArea: placemark.administrativeArea, - subAdministrativeArea: placemark.subAdministrativeArea - ) - } + func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? { + let placemarks = try await geocoder.reverseGeocodeLocation(location, preferredLocale: preferredLocale) + guard let placemark = placemarks.first else { return nil } + return GeocodeResult( + countryCode: placemark.isoCountryCode, + administrativeArea: placemark.administrativeArea, + subAdministrativeArea: placemark.subAdministrativeArea + ) + } - func cancelGeocode() { - geocoder.cancelGeocode() - } + func cancelGeocode() { + geocoder.cancelGeocode() + } } diff --git a/MC1/Services/ImageHeaderDecoder.swift b/MC1/Services/ImageHeaderDecoder.swift index 85af2591..8191e049 100644 --- a/MC1/Services/ImageHeaderDecoder.swift +++ b/MC1/Services/ImageHeaderDecoder.swift @@ -5,22 +5,21 @@ import ImageIO /// payload without decoding the full bitmap. Used by inline-image probes and /// link-preview hero extraction. enum ImageHeaderDecoder { - - /// Returns pixel width and height parsed from the image header in `data`. - /// Returns `nil` if the bytes are not a recognized image or either - /// dimension is missing or non-positive. - static func decodeDimensions(from data: Data) -> (width: Int, height: Int)? { - guard let source = CGImageSourceCreateWithData(data as CFData, nil), - let props = CGImageSourceCopyPropertiesAtIndex( - source, - 0, - [kCGImageSourceShouldCache: false] as CFDictionary - ) as? [CFString: Any], - let width = props[kCGImagePropertyPixelWidth] as? Int, - let height = props[kCGImagePropertyPixelHeight] as? Int, - width > 0, height > 0 else { - return nil - } - return (width, height) + /// Returns pixel width and height parsed from the image header in `data`. + /// Returns `nil` if the bytes are not a recognized image or either + /// dimension is missing or non-positive. + static func decodeDimensions(from data: Data) -> (width: Int, height: Int)? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let props = CGImageSourceCopyPropertiesAtIndex( + source, + 0, + [kCGImageSourceShouldCache: false] as CFDictionary + ) as? [CFString: Any], + let width = props[kCGImagePropertyPixelWidth] as? Int, + let height = props[kCGImagePropertyPixelHeight] as? Int, + width > 0, height > 0 else { + return nil } + return (width, height) + } } diff --git a/MC1/Services/ImageURLDetector.swift b/MC1/Services/ImageURLDetector.swift index 16859df4..5d8f6917 100644 --- a/MC1/Services/ImageURLDetector.swift +++ b/MC1/Services/ImageURLDetector.swift @@ -5,76 +5,75 @@ import UIKit /// Inline image decoding (UIKit/ImageIO). For URL classification, use /// `MC1Services.ImageURLClassifier`. enum ImageURLDetector { + /// Max pixel dimension for inline image display (280pt × 3x scale) + private static let inlineMaxPixelSize: CGFloat = 900 - /// Max pixel dimension for inline image display (280pt × 3x scale) - private static let inlineMaxPixelSize: CGFloat = 900 - - /// Decodes an image at a reduced size using ImageIO, avoiding full-resolution decode. - /// Falls back to `UIImage(data:)` if thumbnail generation fails. - static func downsampledImage(from data: Data) -> UIImage? { - let options: [CFString: Any] = [ - kCGImageSourceShouldCache: false - ] - guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else { - return UIImage(data: data) - } - let downsampleOptions: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: inlineMaxPixelSize, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCacheImmediately: true - ] - guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { - return UIImage(data: data) - } - return UIImage(cgImage: cgImage) + /// Decodes an image at a reduced size using ImageIO, avoiding full-resolution decode. + /// Falls back to `UIImage(data:)` if thumbnail generation fails. + static func downsampledImage(from data: Data) -> UIImage? { + let options: [CFString: Any] = [ + kCGImageSourceShouldCache: false + ] + guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else { + return UIImage(data: data) } - - /// Returns `true` if the data begins with the GIF magic bytes (`GIF8`) - static func isGIFData(_ data: Data) -> Bool { - guard data.count >= 4 else { return false } - return data[data.startIndex] == 0x47 // G - && data[data.startIndex + 1] == 0x49 // I - && data[data.startIndex + 2] == 0x46 // F - && data[data.startIndex + 3] == 0x38 // 8 + let downsampleOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: inlineMaxPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { + return UIImage(data: data) } + return UIImage(cgImage: cgImage) + } - /// Decodes GIF data into an animated UIImage using CGImageSource. - /// Returns `nil` if the data is not valid GIF data. - static func decodeGIFImage(from data: Data) -> UIImage? { - guard isGIFData(data), - let source = CGImageSourceCreateWithData(data as CFData, nil) else { - return nil - } + /// Returns `true` if the data begins with the GIF magic bytes (`GIF8`) + static func isGIFData(_ data: Data) -> Bool { + guard data.count >= 4 else { return false } + return data[data.startIndex] == 0x47 // G + && data[data.startIndex + 1] == 0x49 // I + && data[data.startIndex + 2] == 0x46 // F + && data[data.startIndex + 3] == 0x38 // 8 + } - let count = CGImageSourceGetCount(source) - guard count > 1 else { - return downsampledImage(from: data) - } + /// Decodes GIF data into an animated UIImage using CGImageSource. + /// Returns `nil` if the data is not valid GIF data. + static func decodeGIFImage(from data: Data) -> UIImage? { + guard isGIFData(data), + let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } - let thumbnailOptions: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: inlineMaxPixelSize, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCacheImmediately: true - ] + let count = CGImageSourceGetCount(source) + guard count > 1 else { + return downsampledImage(from: data) + } - var frames: [UIImage] = [] - var totalDuration: TimeInterval = 0 + let thumbnailOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: inlineMaxPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] - for i in 0..() - private let fetchSemaphore = AsyncSemaphore(value: 3) - private var failedURLs: Set = [] - private var inFlightURLs: Set = [] - private var dimensionsStore: InlineImageDimensionsStore? - - /// Per-URL decoded-image cache. Survives `ChatViewModel` teardown so - /// exit-then-reenter of the same chat does not reshimmer. Read via a - /// wait-free `OSAllocatedUnfairLock` mirror so main-actor view bodies - /// can resolve cache hits without awaiting an actor hop. FIFO eviction - /// bounded by `maxDecodedEntryCount` and `maxDecodedTotalCostBytes`. - private let decodedMirror = OSAllocatedUnfairLock(initialState: DecodedCacheState()) - - private static let maxEntryCount = 50 - private static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB - private static let maxDownloadBytes = 10 * 1024 * 1024 // 10MB per image - private static let probeMaxBufferBytes = 1 * 1024 * 1024 // 1MB cap for probe bodies - private static let probeByteRange = "bytes=0-65535" - private static let rangeHeaderField = "Range" - private static let httpStatusOK = 200 - private static let httpStatusPartialContent = 206 - - /// Decoded-cache caps cover the total working-set per entry: decoded - /// pixel bytes (cgImage bytesPerRow times height across frames) plus - /// optional retained encoded bytes for the viewer/share path. A 4MB - /// JPEG decoded to a 4096x3072 RGBA bitmap is ~48MB of pixels alone; - /// the encoded `data` term keeps the cost honest when both are held. - private static let maxDecodedEntryCount = 50 - private static let maxDecodedTotalCostBytes = 100 * 1024 * 1024 - - init() { - memoryCache.countLimit = Self.maxEntryCount - memoryCache.totalCostLimit = Self.maxTotalCostBytes - - // Mirror NSCache's auto-eviction-on-memory-warning behavior for the - // hand-rolled decoded mirror. The closure is retained by - // NotificationCenter for the singleton's lifetime; weak self keeps - // the contract clean even though the cache outlives the process. - NotificationCenter.default.addObserver( - forName: UIApplication.didReceiveMemoryWarningNotification, - object: nil, - queue: nil - ) { [weak self] _ in - self?.clearDecodedMirror() - } + static let shared = InlineImageCache() + + private let logger = Logger(subsystem: "com.mc1", category: "InlineImageCache") + + private let memoryCache = NSCache() + private let fetchSemaphore = AsyncSemaphore(value: 3) + private var failedURLs: Set = [] + private var inFlightURLs: Set = [] + private var dimensionsStore: InlineImageDimensionsStore? + + /// Per-URL decoded-image cache. Survives `ChatViewModel` teardown so + /// exit-then-reenter of the same chat does not reshimmer. Read via a + /// wait-free `OSAllocatedUnfairLock` mirror so main-actor view bodies + /// can resolve cache hits without awaiting an actor hop. FIFO eviction + /// bounded by `maxDecodedEntryCount` and `maxDecodedTotalCostBytes`. + private let decodedMirror = OSAllocatedUnfairLock(initialState: DecodedCacheState()) + + private static let maxEntryCount = 50 + private static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB + private static let maxDownloadBytes = 10 * 1024 * 1024 // 10MB per image + private static let probeMaxBufferBytes = 1 * 1024 * 1024 // 1MB cap for probe bodies + private static let probeByteRange = "bytes=0-65535" + private static let rangeHeaderField = "Range" + private static let httpStatusOK = 200 + private static let httpStatusPartialContent = 206 + + /// Decoded-cache caps cover the total working-set per entry: decoded + /// pixel bytes (cgImage bytesPerRow times height across frames) plus + /// optional retained encoded bytes for the viewer/share path. A 4MB + /// JPEG decoded to a 4096x3072 RGBA bitmap is ~48MB of pixels alone; + /// the encoded `data` term keeps the cost honest when both are held. + private static let maxDecodedEntryCount = 50 + private static let maxDecodedTotalCostBytes = 100 * 1024 * 1024 + + init() { + memoryCache.countLimit = Self.maxEntryCount + memoryCache.totalCostLimit = Self.maxTotalCostBytes + + // Mirror NSCache's auto-eviction-on-memory-warning behavior for the + // hand-rolled decoded mirror. The closure is retained by + // NotificationCenter for the singleton's lifetime; weak self keeps + // the contract clean even though the cache outlives the process. + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.clearDecodedMirror() } - - /// Empties the decoded-image mirror in response to system memory - /// pressure. `nonisolated` so the notification block can call it - /// directly without an actor hop on whichever queue the system - /// delivers the warning. - nonisolated func clearDecodedMirror() { - decodedMirror.withLock { state in - state.entries.removeAll() - state.insertionOrder.removeAll() - state.totalCostBytes = 0 - } - } - - /// Registers the persistence sink for successful probes. Held strongly; - /// later calls overwrite the reference so each connection's store wins. - func attachDimensionsStore(_ store: InlineImageDimensionsStore) { - self.dimensionsStore = store + } + + /// Empties the decoded-image mirror in response to system memory + /// pressure. `nonisolated` so the notification block can call it + /// directly without an actor hop on whichever queue the system + /// delivers the warning. + nonisolated func clearDecodedMirror() { + decodedMirror.withLock { state in + state.entries.removeAll() + state.insertionOrder.removeAll() + state.totalCostBytes = 0 } + } - /// Fetches image data for the given URL, returning cached data when available. - func fetchImageData(for url: URL) async -> InlineImageResult { - let key = url.absoluteString - - // Check negative cache - if failedURLs.contains(key) { - return .failed - } - - // Check memory cache - if let cached = memoryCache.object(forKey: key as NSString) { - return .loaded(cached.data) - } - - // Prevent duplicate in-flight fetches - guard !inFlightURLs.contains(key) else { - return .loading - } + /// Registers the persistence sink for successful probes. Held strongly; + /// later calls overwrite the reference so each connection's store wins. + func attachDimensionsStore(_ store: InlineImageDimensionsStore) { + dimensionsStore = store + } - inFlightURLs.insert(key) - await fetchSemaphore.wait() - - // Single exit path after semaphore acquisition - let result: InlineImageResult - if let cached = memoryCache.object(forKey: key as NSString) { - result = .loaded(cached.data) - } else if Task.isCancelled { - result = .failed - } else { - result = await performFetch(for: url, key: key) - } + /// Fetches image data for the given URL, returning cached data when available. + func fetchImageData(for url: URL) async -> InlineImageResult { + let key = url.absoluteString - await fetchSemaphore.signal() - inFlightURLs.remove(key) - return result + // Check negative cache + if failedURLs.contains(key) { + return .failed } - /// Removes a URL from the negative cache, allowing it to be retried. - func clearFailure(for url: URL) { - failedURLs.remove(url.absoluteString) + // Check memory cache + if let cached = memoryCache.object(forKey: key as NSString) { + return .loaded(cached.data) } - /// Persists a decoded `UIImage` keyed on the direct image URL so a - /// later chat re-entry can skip the decode step. Called from - /// `ChatViewModel.fetchInlineImage` *before* its per-VM cancellation - /// guards so a scroll-away or chat-exit mid-decode still hands the - /// pixels to the next visit. FIFO eviction inside the lock keeps the - /// mirror within budget. `nonisolated` because the body only touches - /// the lock-guarded mirror, never actor-isolated state. - nonisolated func storeDecoded(_ entry: CachedDecodedImage, for url: URL) { - let key = url.absoluteString - decodedMirror.withLock { state in - if let existing = state.entries[key] { - state.totalCostBytes -= existing.cost - if let idx = state.insertionOrder.firstIndex(of: key) { - state.insertionOrder.remove(at: idx) - } - } - state.entries[key] = entry - state.insertionOrder.append(key) - state.totalCostBytes += entry.cost - - // Keep at least the just-inserted entry, even when it singly - // exceeds the cost budget — a 4K image decodes to ~50MB on its - // own and we'd otherwise evict immediately and never serve it. - while state.insertionOrder.count > 1, - state.insertionOrder.count > Self.maxDecodedEntryCount - || state.totalCostBytes > Self.maxDecodedTotalCostBytes { - let oldest = state.insertionOrder.removeFirst() - if let evicted = state.entries.removeValue(forKey: oldest) { - state.totalCostBytes -= evicted.cost - } - } - } + // Prevent duplicate in-flight fetches + guard !inFlightURLs.contains(key) else { + return .loading } - /// Nonisolated wait-free decoded-image lookup. Safe to call from a - /// SwiftUI view body or the main-actor URL-detection write path - /// without an actor hop, mirroring the shape of - /// `InlineImageDimensionsStore.aspect(for:)`. - nonisolated func decoded(for url: URL) -> CachedDecodedImage? { - decodedMirror.withLock { $0.entries[url.absoluteString] } + inFlightURLs.insert(key) + await fetchSemaphore.wait() + + // Single exit path after semaphore acquisition + let result: InlineImageResult = if let cached = memoryCache.object(forKey: key as NSString) { + .loaded(cached.data) + } else if Task.isCancelled { + .failed + } else { + await performFetch(for: url, key: key) } - /// Probes the image header for pixel dimensions without persisting the bytes. - /// Uses a small Range request and `ImageHeaderDecoder`. Persists the resolved - /// size to the attached dimensions store on success. Failures do not touch - /// the negative cache or the in-flight set. - func probeImageDimensions(url: URL) async -> CGSize? { - guard await URLSafetyChecker.isSafe(url) else { - logger.debug("Blocked probe to unsafe URL: \(url.host() ?? "unknown")") - return nil + await fetchSemaphore.signal() + inFlightURLs.remove(key) + return result + } + + /// Removes a URL from the negative cache, allowing it to be retried. + func clearFailure(for url: URL) { + failedURLs.remove(url.absoluteString) + } + + /// Persists a decoded `UIImage` keyed on the direct image URL so a + /// later chat re-entry can skip the decode step. Called from + /// `ChatViewModel.fetchInlineImage` *before* its per-VM cancellation + /// guards so a scroll-away or chat-exit mid-decode still hands the + /// pixels to the next visit. FIFO eviction inside the lock keeps the + /// mirror within budget. `nonisolated` because the body only touches + /// the lock-guarded mirror, never actor-isolated state. + nonisolated func storeDecoded(_ entry: CachedDecodedImage, for url: URL) { + let key = url.absoluteString + decodedMirror.withLock { state in + if let existing = state.entries[key] { + state.totalCostBytes -= existing.cost + if let idx = state.insertionOrder.firstIndex(of: key) { + state.insertionOrder.remove(at: idx) } + } + state.entries[key] = entry + state.insertionOrder.append(key) + state.totalCostBytes += entry.cost + + // Keep at least the just-inserted entry, even when it singly + // exceeds the cost budget — a 4K image decodes to ~50MB on its + // own and we'd otherwise evict immediately and never serve it. + while state.insertionOrder.count > 1, + state.insertionOrder.count > Self.maxDecodedEntryCount + || state.totalCostBytes > Self.maxDecodedTotalCostBytes { + let oldest = state.insertionOrder.removeFirst() + if let evicted = state.entries.removeValue(forKey: oldest) { + state.totalCostBytes -= evicted.cost + } + } + } + } + + /// Nonisolated wait-free decoded-image lookup. Safe to call from a + /// SwiftUI view body or the main-actor URL-detection write path + /// without an actor hop, mirroring the shape of + /// `InlineImageDimensionsStore.aspect(for:)`. + nonisolated func decoded(for url: URL) -> CachedDecodedImage? { + decodedMirror.withLock { $0.entries[url.absoluteString] } + } + + /// Probes the image header for pixel dimensions without persisting the bytes. + /// Uses a small Range request and `ImageHeaderDecoder`. Persists the resolved + /// size to the attached dimensions store on success. Failures do not touch + /// the negative cache or the in-flight set. + func probeImageDimensions(url: URL) async -> CGSize? { + guard await URLSafetyChecker.isSafe(url) else { + logger.debug("Blocked probe to unsafe URL: \(url.host() ?? "unknown")") + return nil + } - logger.debug("Probing image dimensions: \(url.absoluteString)") + logger.debug("Probing image dimensions: \(url.absoluteString)") - await fetchSemaphore.wait() + await fetchSemaphore.wait() - var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) - request.setValue(Self.probeByteRange, forHTTPHeaderField: Self.rangeHeaderField) + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) + request.setValue(Self.probeByteRange, forHTTPHeaderField: Self.rangeHeaderField) - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - logger.info("Image probe network failure: \(error.localizedDescription)") - await fetchSemaphore.signal() - return nil - } + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + logger.info("Image probe network failure: \(error.localizedDescription)") + await fetchSemaphore.signal() + return nil + } - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == Self.httpStatusOK - || httpResponse.statusCode == Self.httpStatusPartialContent else { - let code = (response as? HTTPURLResponse)?.statusCode ?? -1 - logger.info("Image probe non-success status \(code): \(url.absoluteString)") - await fetchSemaphore.signal() - return nil - } + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == Self.httpStatusOK + || httpResponse.statusCode == Self.httpStatusPartialContent else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger.info("Image probe non-success status \(code): \(url.absoluteString)") + await fetchSemaphore.signal() + return nil + } - guard data.count <= Self.probeMaxBufferBytes else { - logger.info("Image probe body exceeds cap: \(url.absoluteString)") - await fetchSemaphore.signal() - return nil - } + guard data.count <= Self.probeMaxBufferBytes else { + logger.info("Image probe body exceeds cap: \(url.absoluteString)") + await fetchSemaphore.signal() + return nil + } - guard let dims = ImageHeaderDecoder.decodeDimensions(from: data) else { - logger.info("Image probe could not decode dimensions: \(url.absoluteString)") - await fetchSemaphore.signal() - return nil - } + guard let dims = ImageHeaderDecoder.decodeDimensions(from: data) else { + logger.info("Image probe could not decode dimensions: \(url.absoluteString)") + await fetchSemaphore.signal() + return nil + } - let size = CGSize(width: dims.width, height: dims.height) - logger.debug("Probed dimensions \(dims.width)x\(dims.height): \(url.absoluteString)") + let size = CGSize(width: dims.width, height: dims.height) + logger.debug("Probed dimensions \(dims.width)x\(dims.height): \(url.absoluteString)") - if let dimensionsStore { - await dimensionsStore.save(url: url, size: size) - } - - await fetchSemaphore.signal() - return size + if let dimensionsStore { + await dimensionsStore.save(url: url, size: size) } - /// Performs the HTTP fetch, validates the response, and caches the result. - private func performFetch(for url: URL, key: String) async -> InlineImageResult { - guard await URLSafetyChecker.isSafe(url) else { - logger.debug("Blocked fetch to unsafe URL: \(url.host() ?? "unknown")") - failedURLs.insert(key) - return .failed - } + await fetchSemaphore.signal() + return size + } - do { - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { - logger.debug("Non-success HTTP status for image: \(url.absoluteString)") - failedURLs.insert(key) - return .failed - } - - guard data.count <= Self.maxDownloadBytes else { - logger.debug("Image too large (\(data.count) bytes): \(url.absoluteString)") - failedURLs.insert(key) - return .failed - } - - // Lightweight validation: check that ImageIO recognizes the data as an image - guard CGImageSourceCreateWithData(data as CFData, nil) != nil else { - logger.debug("Data is not a valid image: \(url.absoluteString)") - failedURLs.insert(key) - return .failed - } - - memoryCache.setObject(CachedImageData(data), forKey: key as NSString, cost: data.count) - return .loaded(data) - - } catch { - if !Task.isCancelled { - logger.debug("Failed to fetch image: \(error.localizedDescription)") - failedURLs.insert(key) - } - return .failed - } + /// Performs the HTTP fetch, validates the response, and caches the result. + private func performFetch(for url: URL, key: String) async -> InlineImageResult { + guard await URLSafetyChecker.isSafe(url) else { + logger.debug("Blocked fetch to unsafe URL: \(url.host() ?? "unknown")") + failedURLs.insert(key) + return .failed } + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + logger.debug("Non-success HTTP status for image: \(url.absoluteString)") + failedURLs.insert(key) + return .failed + } + + guard data.count <= Self.maxDownloadBytes else { + logger.debug("Image too large (\(data.count) bytes): \(url.absoluteString)") + failedURLs.insert(key) + return .failed + } + + // Lightweight validation: check that ImageIO recognizes the data as an image + guard CGImageSourceCreateWithData(data as CFData, nil) != nil else { + logger.debug("Data is not a valid image: \(url.absoluteString)") + failedURLs.insert(key) + return .failed + } + + memoryCache.setObject(CachedImageData(data), forKey: key as NSString, cost: data.count) + return .loaded(data) + + } catch { + if !Task.isCancelled { + logger.debug("Failed to fetch image: \(error.localizedDescription)") + failedURLs.insert(key) + } + return .failed + } + } } /// Wrapper class for NSCache (requires reference type) private final class CachedImageData: @unchecked Sendable { - let data: Data - init(_ data: Data) { self.data = data } + let data: Data + init(_ data: Data) { + self.data = data + } } /// Reference-typed payload for the decoded-image cache. Carries optional @@ -286,36 +286,36 @@ private final class CachedImageData: @unchecked Sendable { /// because the stored properties are `let` and `UIImage` / `Data` are /// immutable post-construction. final class CachedDecodedImage: @unchecked Sendable { - let image: UIImage - let isGIF: Bool - /// Original encoded bytes for static images, so the full-screen viewer - /// can present at full resolution and the share sheet can hand off - /// raw `Data`. Nil for GIFs, matching the existing per-VM - /// `loadedImageData` policy (GIFs inline-animate via the UIImage but - /// do not open the full-screen viewer). - let data: Data? - let cost: Int - - init(image: UIImage, isGIF: Bool, data: Data?) { - self.image = image - self.isGIF = isGIF - self.data = data - self.cost = Self.computeCost(for: image, isGIF: isGIF) + (data?.count ?? 0) - } - - private static func computeCost(for image: UIImage, isGIF: Bool) -> Int { - if isGIF, let frames = image.images, !frames.isEmpty { - return frames.reduce(0) { $0 + ImageByteCost.bytes(for: $1) } - } - return ImageByteCost.bytes(for: image) + let image: UIImage + let isGIF: Bool + /// Original encoded bytes for static images, so the full-screen viewer + /// can present at full resolution and the share sheet can hand off + /// raw `Data`. Nil for GIFs, matching the existing per-VM + /// `loadedImageData` policy (GIFs inline-animate via the UIImage but + /// do not open the full-screen viewer). + let data: Data? + let cost: Int + + init(image: UIImage, isGIF: Bool, data: Data?) { + self.image = image + self.isGIF = isGIF + self.data = data + cost = Self.computeCost(for: image, isGIF: isGIF) + (data?.count ?? 0) + } + + private static func computeCost(for image: UIImage, isGIF: Bool) -> Int { + if isGIF, let frames = image.images, !frames.isEmpty { + return frames.reduce(0) { $0 + ImageByteCost.bytes(for: $1) } } + return ImageByteCost.bytes(for: image) + } } /// State held under the decoded-mirror lock. Combining the dict with the /// insertion-order list and running cost lets a single `withLock` perform /// both the write and the eviction sweep atomically. private struct DecodedCacheState { - var entries: [String: CachedDecodedImage] = [:] - var insertionOrder: [String] = [] - var totalCostBytes: Int = 0 + var entries: [String: CachedDecodedImage] = [:] + var insertionOrder: [String] = [] + var totalCostBytes: Int = 0 } diff --git a/MC1/Services/InlineImagePrefetcher.swift b/MC1/Services/InlineImagePrefetcher.swift index 504efad5..7ed8ca7b 100644 --- a/MC1/Services/InlineImagePrefetcher.swift +++ b/MC1/Services/InlineImagePrefetcher.swift @@ -1,11 +1,11 @@ -import Foundation import CoreGraphics +import Foundation import MC1Services /// Probing seam for inline image dimension lookup. Lets tests inject a /// stand-in for `InlineImageCache.probeImageDimensions(url:)`. protocol InlineImageDimensionProbing: AnyObject, Sendable { - func probeImageDimensions(url: URL) async -> CGSize? + func probeImageDimensions(url: URL) async -> CGSize? } extension InlineImageCache: InlineImageDimensionProbing {} @@ -16,58 +16,57 @@ extension InlineImageCache: InlineImageDimensionProbing {} /// a slow probe never blocks message admission. @MainActor final class InlineImagePrefetcher { + private let imageCache: any InlineImageDimensionProbing + private let linkPreviewCache: any LinkPreviewCaching + private let dimensionsStore: InlineImageDimensionsStore + private let dataStore: any PersistenceStoreProtocol - private let imageCache: any InlineImageDimensionProbing - private let linkPreviewCache: any LinkPreviewCaching - private let dimensionsStore: InlineImageDimensionsStore - private let dataStore: any PersistenceStoreProtocol - - init( - imageCache: any InlineImageDimensionProbing, - linkPreviewCache: any LinkPreviewCaching, - dimensionsStore: InlineImageDimensionsStore, - dataStore: any PersistenceStoreProtocol - ) { - self.imageCache = imageCache - self.linkPreviewCache = linkPreviewCache - self.dimensionsStore = dimensionsStore - self.dataStore = dataStore - } + init( + imageCache: any InlineImageDimensionProbing, + linkPreviewCache: any LinkPreviewCaching, + dimensionsStore: InlineImageDimensionsStore, + dataStore: any PersistenceStoreProtocol + ) { + self.imageCache = imageCache + self.linkPreviewCache = linkPreviewCache + self.dimensionsStore = dimensionsStore + self.dataStore = dataStore + } - /// Prefetch dimensions and link-preview metadata for every URL in `text`. - /// Returns once all probes have resolved (success or failure). Never throws. - /// - /// Delegates URL extraction to `LinkPreviewService.extractAllURLs(in:)` so - /// the receive-time prefetcher and the per-message URL-detection writer - /// see the same set of URLs (Giphy short-codes expanded, `@[mention]` - /// ranges skipped, HTTP/HTTPS only). - func prefetch(urlsIn text: String, isChannelMessage: Bool) async { - let urls = LinkPreviewService.extractAllURLs(in: text) - guard !urls.isEmpty else { return } + /// Prefetch dimensions and link-preview metadata for every URL in `text`. + /// Returns once all probes have resolved (success or failure). Never throws. + /// + /// Delegates URL extraction to `LinkPreviewService.extractAllURLs(in:)` so + /// the receive-time prefetcher and the per-message URL-detection writer + /// see the same set of URLs (Giphy short-codes expanded, `@[mention]` + /// ranges skipped, HTTP/HTTPS only). + func prefetch(urlsIn text: String, isChannelMessage: Bool) async { + let urls = LinkPreviewService.extractAllURLs(in: text) + guard !urls.isEmpty else { return } - let imageCache = self.imageCache - let linkPreviewCache = self.linkPreviewCache - let dimensionsStore = self.dimensionsStore - let dataStore = self.dataStore + let imageCache = imageCache + let linkPreviewCache = linkPreviewCache + let dimensionsStore = dimensionsStore + let dataStore = dataStore - await withTaskGroup(of: Void.self) { group in - for url in urls { - if ImageURLClassifier.isImageURL(url) { - let probeURL = ImageURLClassifier.directImageURL(for: url) - guard dimensionsStore.aspect(for: probeURL) == nil else { continue } - group.addTask { - _ = await imageCache.probeImageDimensions(url: probeURL) - } - } else { - group.addTask { - _ = await linkPreviewCache.preview( - for: url, - using: dataStore, - isChannelMessage: isChannelMessage - ) - } - } - } + await withTaskGroup(of: Void.self) { group in + for url in urls { + if ImageURLClassifier.isImageURL(url) { + let probeURL = ImageURLClassifier.directImageURL(for: url) + guard dimensionsStore.aspect(for: probeURL) == nil else { continue } + group.addTask { + _ = await imageCache.probeImageDimensions(url: probeURL) + } + } else { + group.addTask { + _ = await linkPreviewCache.preview( + for: url, + using: dataStore, + isChannelMessage: isChannelMessage + ) + } } + } } + } } diff --git a/MC1/Services/LinkPreviewCache.swift b/MC1/Services/LinkPreviewCache.swift index d322a85a..ffa43617 100644 --- a/MC1/Services/LinkPreviewCache.swift +++ b/MC1/Services/LinkPreviewCache.swift @@ -1,184 +1,184 @@ import Foundation -import OSLog import MC1Services +import OSLog // MARK: - Constants private enum CacheConfig { - static let maxEntryCount = 100 - static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB - static let maxConcurrentFetches = 3 + static let maxEntryCount = 100 + static let maxTotalCostBytes = 50 * 1024 * 1024 // 50MB + static let maxConcurrentFetches = 3 } /// Two-tier cache for link previews with URL-based deduplication. /// Uses actor isolation for thread safety without blocking the main thread. /// Limits concurrent LPMetadataProvider instances to prevent WKWebView spawn bursts. actor LinkPreviewCache: LinkPreviewCaching { - private let logger = Logger(subsystem: "com.mc1", category: "LinkPreviewCache") - private let memoryCache = NSCache() - private let service: any LinkMetadataFetching - private nonisolated let preferences = LinkPreviewPreferences() - - /// Shared fetch task per in-flight URL. Concurrent requests for the same - /// URL await the same task and receive the resolved result, instead of a - /// `.loading` placeholder that would strand a follower's preview state with - /// no path back to `.loaded`. - private var inFlightTasks: [String: Task] = [:] - - /// URLs that have been fetched but have no preview available - private var noPreviewAvailable: Set = [] - - /// Semaphore to limit concurrent LPMetadataProvider instances. - /// Each LPMetadataProvider spawns WKWebView on main thread. - private let fetchSemaphore = AsyncSemaphore(value: CacheConfig.maxConcurrentFetches) - - init(service: any LinkMetadataFetching = LinkPreviewService()) { - self.service = service - memoryCache.countLimit = CacheConfig.maxEntryCount - memoryCache.totalCostLimit = CacheConfig.maxTotalCostBytes + private let logger = Logger(subsystem: "com.mc1", category: "LinkPreviewCache") + private let memoryCache = NSCache() + private let service: any LinkMetadataFetching + private nonisolated let preferences = LinkPreviewPreferences() + + /// Shared fetch task per in-flight URL. Concurrent requests for the same + /// URL await the same task and receive the resolved result, instead of a + /// `.loading` placeholder that would strand a follower's preview state with + /// no path back to `.loaded`. + private var inFlightTasks: [String: Task] = [:] + + /// URLs that have been fetched but have no preview available + private var noPreviewAvailable: Set = [] + + /// Semaphore to limit concurrent LPMetadataProvider instances. + /// Each LPMetadataProvider spawns WKWebView on main thread. + private let fetchSemaphore = AsyncSemaphore(value: CacheConfig.maxConcurrentFetches) + + init(service: any LinkMetadataFetching = LinkPreviewService()) { + self.service = service + memoryCache.countLimit = CacheConfig.maxEntryCount + memoryCache.totalCostLimit = CacheConfig.maxTotalCostBytes + } + + func preview( + for url: URL, + using dataStore: any PersistenceStoreProtocol, + isChannelMessage: Bool + ) async -> LinkPreviewResult { + let urlString = url.absoluteString + + // Check negative cache first + if noPreviewAvailable.contains(urlString) { + return .noPreviewAvailable } - func preview( - for url: URL, - using dataStore: any PersistenceStoreProtocol, - isChannelMessage: Bool - ) async -> LinkPreviewResult { - let urlString = url.absoluteString - - // Check negative cache first - if noPreviewAvailable.contains(urlString) { - return .noPreviewAvailable - } - - // Check memory and database caches - if let cached = await checkCaches(urlString: urlString, dataStore: dataStore) { - return .loaded(cached) - } - - // Check preferences before network fetch - guard preferences.shouldAutoResolve(isChannelMessage: isChannelMessage) else { - return .disabled - } - - // Network fetch, coalescing concurrent requests for the same URL - return await fetchFromNetwork(url: url, urlString: urlString, dataStore: dataStore) + // Check memory and database caches + if let cached = await checkCaches(urlString: urlString, dataStore: dataStore) { + return .loaded(cached) } - func manualFetch( - for url: URL, - using dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult { - let urlString = url.absoluteString + // Check preferences before network fetch + guard preferences.shouldAutoResolve(isChannelMessage: isChannelMessage) else { + return .disabled + } - // Check memory and database caches (skip negative cache for manual retry) - if let cached = await checkCaches(urlString: urlString, dataStore: dataStore) { - return .loaded(cached) - } + // Network fetch, coalescing concurrent requests for the same URL + return await fetchFromNetwork(url: url, urlString: urlString, dataStore: dataStore) + } - // Clear from negative cache on manual retry - noPreviewAvailable.remove(urlString) + func manualFetch( + for url: URL, + using dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult { + let urlString = url.absoluteString - return await fetchFromNetwork(url: url, urlString: urlString, dataStore: dataStore) + // Check memory and database caches (skip negative cache for manual retry) + if let cached = await checkCaches(urlString: urlString, dataStore: dataStore) { + return .loaded(cached) } - // MARK: - Private Helpers - - /// Checks memory cache and database for existing preview data. - /// Returns the DTO if found and caches in memory if loaded from database. - private func checkCaches( - urlString: String, - dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewDataDTO? { - // Tier 1: Memory cache (instant) - if let cached = memoryCache.object(forKey: urlString as NSString) { - return cached.dto - } - - // Tier 2: Database lookup - do { - if let persisted = try await dataStore.fetchLinkPreview(url: urlString) { - let cost = (persisted.imageData?.count ?? 0) + (persisted.iconData?.count ?? 0) - memoryCache.setObject(CachedPreview(persisted), forKey: urlString as NSString, cost: cost) - return persisted - } - } catch { - logger.error("Failed to fetch link preview from database: \(error.localizedDescription)") - } - - return nil + // Clear from negative cache on manual retry + noPreviewAvailable.remove(urlString) + + return await fetchFromNetwork(url: url, urlString: urlString, dataStore: dataStore) + } + + // MARK: - Private Helpers + + /// Checks memory cache and database for existing preview data. + /// Returns the DTO if found and caches in memory if loaded from database. + private func checkCaches( + urlString: String, + dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewDataDTO? { + // Tier 1: Memory cache (instant) + if let cached = memoryCache.object(forKey: urlString as NSString) { + return cached.dto } - /// Coalesces concurrent fetches for the same URL onto a single shared task. - /// Followers await that task and receive its resolved result rather than a - /// `.loading` placeholder. The shared task is independent of any caller's - /// cancellation, so it completes and warms the cache even if the requesting - /// cell scrolls away or the conversation switches. - private func fetchFromNetwork( - url: URL, - urlString: String, - dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult { - if let existing = inFlightTasks[urlString] { - return await existing.value - } - - let task = Task { [self] in - await performNetworkFetch(url: url, urlString: urlString, dataStore: dataStore) - } - inFlightTasks[urlString] = task - let result = await task.value - inFlightTasks[urlString] = nil - return result + // Tier 2: Database lookup + do { + if let persisted = try await dataStore.fetchLinkPreview(url: urlString) { + let cost = (persisted.imageData?.count ?? 0) + (persisted.iconData?.count ?? 0) + memoryCache.setObject(CachedPreview(persisted), forKey: urlString as NSString, cost: cost) + return persisted + } + } catch { + logger.error("Failed to fetch link preview from database: \(error.localizedDescription)") } - private func performNetworkFetch( - url: URL, - urlString: String, - dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult { - // Wait for semaphore to limit concurrent fetches - await fetchSemaphore.wait() - defer { Task { await self.fetchSemaphore.signal() } } - - let metadata = await service.fetchMetadata(for: url) - - guard let metadata else { - // Cache negative result to avoid repeated fetch attempts - noPreviewAvailable.insert(urlString) - return .noPreviewAvailable - } - - let heroDimensions = metadata.imageData.flatMap(ImageHeaderDecoder.decodeDimensions(from:)) - let dto = LinkPreviewDataDTO( - url: urlString, - title: metadata.title, - imageData: metadata.imageData, - iconData: metadata.iconData, - imageWidth: heroDimensions?.width, - imageHeight: heroDimensions?.height - ) - - // Cache in memory with cost based on image sizes - let cost = (dto.imageData?.count ?? 0) + (dto.iconData?.count ?? 0) - memoryCache.setObject(CachedPreview(dto), forKey: urlString as NSString, cost: cost) - - // Persist to database - do { - try await dataStore.saveLinkPreview(dto) - } catch { - logger.error("Failed to save link preview to database: \(error.localizedDescription)") - } - - return .loaded(dto) + return nil + } + + /// Coalesces concurrent fetches for the same URL onto a single shared task. + /// Followers await that task and receive its resolved result rather than a + /// `.loading` placeholder. The shared task is independent of any caller's + /// cancellation, so it completes and warms the cache even if the requesting + /// cell scrolls away or the conversation switches. + private func fetchFromNetwork( + url: URL, + urlString: String, + dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult { + if let existing = inFlightTasks[urlString] { + return await existing.value } - func isFetching(_ url: URL) async -> Bool { - inFlightTasks[url.absoluteString] != nil + let task = Task { [self] in + await performNetworkFetch(url: url, urlString: urlString, dataStore: dataStore) + } + inFlightTasks[urlString] = task + let result = await task.value + inFlightTasks[urlString] = nil + return result + } + + private func performNetworkFetch( + url: URL, + urlString: String, + dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult { + // Wait for semaphore to limit concurrent fetches + await fetchSemaphore.wait() + defer { Task { await self.fetchSemaphore.signal() } } + + let metadata = await service.fetchMetadata(for: url) + + guard let metadata else { + // Cache negative result to avoid repeated fetch attempts + noPreviewAvailable.insert(urlString) + return .noPreviewAvailable } - func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { - memoryCache.object(forKey: url.absoluteString as NSString)?.dto + let heroDimensions = metadata.imageData.flatMap(ImageHeaderDecoder.decodeDimensions(from:)) + let dto = LinkPreviewDataDTO( + url: urlString, + title: metadata.title, + imageData: metadata.imageData, + iconData: metadata.iconData, + imageWidth: heroDimensions?.width, + imageHeight: heroDimensions?.height + ) + + // Cache in memory with cost based on image sizes + let cost = (dto.imageData?.count ?? 0) + (dto.iconData?.count ?? 0) + memoryCache.setObject(CachedPreview(dto), forKey: urlString as NSString, cost: cost) + + // Persist to database + do { + try await dataStore.saveLinkPreview(dto) + } catch { + logger.error("Failed to save link preview to database: \(error.localizedDescription)") } + + return .loaded(dto) + } + + func isFetching(_ url: URL) async -> Bool { + inFlightTasks[url.absoluteString] != nil + } + + func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { + memoryCache.object(forKey: url.absoluteString as NSString)?.dto + } } // MARK: - Supporting Types @@ -186,6 +186,8 @@ actor LinkPreviewCache: LinkPreviewCaching { /// Wrapper class for NSCache (requires reference type). /// Immutable after initialization, making @unchecked Sendable safe. private final class CachedPreview: @unchecked Sendable { - let dto: LinkPreviewDataDTO - init(_ dto: LinkPreviewDataDTO) { self.dto = dto } + let dto: LinkPreviewDataDTO + init(_ dto: LinkPreviewDataDTO) { + self.dto = dto + } } diff --git a/MC1/Services/LinkPreviewCaching.swift b/MC1/Services/LinkPreviewCaching.swift index 9e6879f9..6a5c170c 100644 --- a/MC1/Services/LinkPreviewCaching.swift +++ b/MC1/Services/LinkPreviewCaching.swift @@ -1,47 +1,38 @@ import Foundation -import SwiftUI import MC1Services +import SwiftUI /// Protocol for link preview caching, enabling dependency injection and testing protocol LinkPreviewCaching: Sendable { - /// Gets preview for a URL, fetching if needed - func preview( - for url: URL, - using dataStore: any PersistenceStoreProtocol, - isChannelMessage: Bool - ) async -> LinkPreviewResult - - /// Manual fetch bypassing preference check (for tap-to-load) - func manualFetch( - for url: URL, - using dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult - - /// Checks if a fetch is currently in progress for a URL - func isFetching(_ url: URL) async -> Bool - - /// Gets cached preview without triggering fetch - func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? + /// Gets preview for a URL, fetching if needed + func preview( + for url: URL, + using dataStore: any PersistenceStoreProtocol, + isChannelMessage: Bool + ) async -> LinkPreviewResult + + /// Manual fetch bypassing preference check (for tap-to-load) + func manualFetch( + for url: URL, + using dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult + + /// Checks if a fetch is currently in progress for a URL + func isFetching(_ url: URL) async -> Bool + + /// Gets cached preview without triggering fetch + func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? } /// Result of a link preview fetch operation -enum LinkPreviewResult: Sendable { - case loaded(LinkPreviewDataDTO) - case loading - case noPreviewAvailable - case disabled - case failed -} - -// MARK: - Environment Key - -private struct LinkPreviewCacheKey: EnvironmentKey { - static let defaultValue: any LinkPreviewCaching = LinkPreviewCache() +enum LinkPreviewResult { + case loaded(LinkPreviewDataDTO) + case loading + case noPreviewAvailable + case disabled + case failed } extension EnvironmentValues { - var linkPreviewCache: any LinkPreviewCaching { - get { self[LinkPreviewCacheKey.self] } - set { self[LinkPreviewCacheKey.self] = newValue } - } + @Entry var linkPreviewCache: any LinkPreviewCaching = LinkPreviewCache() } diff --git a/MC1/Services/LinkPreviewService.swift b/MC1/Services/LinkPreviewService.swift index a973b2d5..6ecd0924 100644 --- a/MC1/Services/LinkPreviewService.swift +++ b/MC1/Services/LinkPreviewService.swift @@ -1,196 +1,192 @@ import Foundation import LinkPresentation -import os import MC1Services +import os import UIKit import UniformTypeIdentifiers /// Metadata extracted from a URL for link previews -struct LinkPreviewMetadata: Sendable { - let url: URL - let title: String? - let imageData: Data? - let iconData: Data? +struct LinkPreviewMetadata { + let url: URL + let title: String? + let imageData: Data? + let iconData: Data? } /// Abstracts the network metadata fetch so the cache layer's fetch /// coalescing can be tested without the LinkPresentation network path. protocol LinkMetadataFetching: Sendable { - func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? + func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? } /// Service for extracting URLs from text and fetching link metadata final class LinkPreviewService: LinkMetadataFetching, Sendable { - private let logger = Logger(subsystem: "com.mc1", category: "LinkPreviewService") - - /// Shared URL detector instance to avoid creating NSDataDetector on every call - private static let urlDetector: NSDataDetector? = { - try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - }() - - /// Extracts a Giphy GIF URL from meshcore-open `g:{id}` message format. - /// - Parameter text: Message text to check - /// - Returns: Giphy direct GIF URL if text matches `g:{id}` format, nil otherwise - static func extractGiphyGIFURL(from text: String) -> URL? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let match = trimmed.wholeMatch(of: /g:([A-Za-z0-9_-]+)/) else { return nil } - return URL(string: "https://media.giphy.com/media/\(match.1)/giphy.gif") + private let logger = Logger(subsystem: "com.mc1", category: "LinkPreviewService") + + /// Shared URL detector instance to avoid creating NSDataDetector on every call + private static let urlDetector: NSDataDetector? = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + + /// Extracts a Giphy GIF URL from meshcore-open `g:{id}` message format. + /// - Parameter text: Message text to check + /// - Returns: Giphy direct GIF URL if text matches `g:{id}` format, nil otherwise + static func extractGiphyGIFURL(from text: String) -> URL? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let match = trimmed.wholeMatch(of: /g:([A-Za-z0-9_-]+)/) else { return nil } + return URL(string: "https://media.giphy.com/media/\(match.1)/giphy.gif") + } + + /// Extracts the first HTTP/HTTPS URL from text, excluding URLs within mentions. + /// - Parameter text: Message text to scan + /// - Returns: First HTTP(S) URL found outside mentions, or nil + static func extractFirstURL(from text: String) -> URL? { + extractAllURLs(in: text).first + } + + /// Extracts every HTTP/HTTPS URL from text, in document order. Used by the + /// receive-time prefetcher and the per-message URL-detection writer so + /// both paths see the same set of URLs: + /// - meshcore-open `g:{id}` short-codes are expanded to direct Giphy URLs + /// - URLs inside `@[mention]` ranges are skipped + /// - schemes are restricted to HTTP / HTTPS + static func extractAllURLs(in text: String) -> [URL] { + guard !text.isEmpty else { return [] } + + // Check for meshcore-open g:{giphy_id} format first; when present it + // is the entire message (trimmed wholeMatch), so no detector pass. + if let gifURL = extractGiphyGIFURL(from: text) { + return [gifURL] } - /// Extracts the first HTTP/HTTPS URL from text, excluding URLs within mentions. - /// - Parameter text: Message text to scan - /// - Returns: First HTTP(S) URL found outside mentions, or nil - static func extractFirstURL(from text: String) -> URL? { - extractAllURLs(in: text).first - } - - /// Extracts every HTTP/HTTPS URL from text, in document order. Used by the - /// receive-time prefetcher and the per-message URL-detection writer so - /// both paths see the same set of URLs: - /// - meshcore-open `g:{id}` short-codes are expanded to direct Giphy URLs - /// - URLs inside `@[mention]` ranges are skipped - /// - schemes are restricted to HTTP / HTTPS - static func extractAllURLs(in text: String) -> [URL] { - guard !text.isEmpty else { return [] } - - // Check for meshcore-open g:{giphy_id} format first; when present it - // is the entire message (trimmed wholeMatch), so no detector pass. - if let gifURL = extractGiphyGIFURL(from: text) { - return [gifURL] - } + guard let detector = urlDetector else { return [] } - guard let detector = urlDetector else { return [] } + let mentionRanges = extractMentionRanges(from: text) + let range = NSRange(text.startIndex..., in: text) + let matches = detector.matches(in: text, options: [], range: range) - let mentionRanges = extractMentionRanges(from: text) - let range = NSRange(text.startIndex..., in: text) - let matches = detector.matches(in: text, options: [], range: range) + var urls: [URL] = [] + urls.reserveCapacity(matches.count) + for match in matches { + guard let url = match.url, + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + continue + } - var urls: [URL] = [] - urls.reserveCapacity(matches.count) - for match in matches { - guard let url = match.url, - let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { - continue - } + let urlRange = match.range + let overlapsWithMention = mentionRanges.contains { mentionRange in + NSIntersectionRange(urlRange, mentionRange).length > 0 + } + if overlapsWithMention { continue } - let urlRange = match.range - let overlapsWithMention = mentionRanges.contains { mentionRange in - NSIntersectionRange(urlRange, mentionRange).length > 0 - } - if overlapsWithMention { continue } - - urls.append(url) - } - - return urls + urls.append(url) } - /// Cached mention regex to avoid re-creating on every call - private static let mentionRegex: NSRegularExpression? = { - try? NSRegularExpression(pattern: MentionUtilities.mentionPattern) - }() + return urls + } + + /// Cached mention regex to avoid re-creating on every call + private static let mentionRegex: NSRegularExpression? = try? NSRegularExpression(pattern: MentionUtilities.mentionPattern) + + /// Extracts ranges of all mentions in the text (format: @[name]) + private static func extractMentionRanges(from text: String) -> [NSRange] { + guard let regex = mentionRegex else { return [] } + let range = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: range).map(\.range) + } + + /// Fetches metadata for a URL using LinkPresentation framework + /// - Parameter url: The URL to fetch metadata for + /// - Returns: Metadata if successful, nil on failure + func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? { + guard await URLSafetyChecker.isSafe(url) else { + logger.warning("Blocked metadata fetch to unsafe URL: \(url.host() ?? "unknown")") + return nil + } - /// Extracts ranges of all mentions in the text (format: @[name]) - private static func extractMentionRanges(from text: String) -> [NSRange] { - guard let regex = mentionRegex else { return [] } - let range = NSRange(text.startIndex..., in: text) - return regex.matches(in: text, range: range).map(\.range) + let provider = LPMetadataProvider() + provider.timeout = 10 + + do { + let metadata = try await provider.startFetchingMetadata(for: url) + + // Extract image data + var imageData: Data? + if let imageProvider = metadata.imageProvider { + imageData = await loadData(from: imageProvider) + } + + // Extract icon data + var iconData: Data? + if let iconProvider = metadata.iconProvider { + iconData = await loadData(from: iconProvider) + } + + return LinkPreviewMetadata( + url: url, + title: metadata.title, + imageData: imageData, + iconData: iconData + ) + } catch { + logger.warning("Failed to fetch metadata for \(url): \(error.localizedDescription)") + return nil } + } - /// Fetches metadata for a URL using LinkPresentation framework - /// - Parameter url: The URL to fetch metadata for - /// - Returns: Metadata if successful, nil on failure - func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? { - guard await URLSafetyChecker.isSafe(url) else { - logger.warning("Blocked metadata fetch to unsafe URL: \(url.host() ?? "unknown")") - return nil - } + /// Maximum image size in bytes (500KB) + private static let maxImageSize = 500 * 1024 - let provider = LPMetadataProvider() - provider.timeout = 10 - - do { - let metadata = try await provider.startFetchingMetadata(for: url) - - // Extract image data - var imageData: Data? - if let imageProvider = metadata.imageProvider { - imageData = await loadData(from: imageProvider) - } - - // Extract icon data - var iconData: Data? - if let iconProvider = metadata.iconProvider { - iconData = await loadData(from: iconProvider) - } - - return LinkPreviewMetadata( - url: url, - title: metadata.title, - imageData: imageData, - iconData: iconData - ) - } catch { - logger.warning("Failed to fetch metadata for \(url): \(error.localizedDescription)") - return nil + /// Loads image data from an NSItemProvider, compressing if necessary + private func loadData(from provider: NSItemProvider) async -> Data? { + let rawData = await withCheckedContinuation { continuation in + _ = provider.loadDataRepresentation(for: .image) { data, error in + if let error { + self.logger.debug("Failed to load image data: \(error.localizedDescription)") } + continuation.resume(returning: data) + } } - /// Maximum image size in bytes (500KB) - private static let maxImageSize = 500 * 1024 - - /// Loads image data from an NSItemProvider, compressing if necessary - private func loadData(from provider: NSItemProvider) async -> Data? { - let rawData = await withCheckedContinuation { continuation in - _ = provider.loadDataRepresentation(for: .image) { data, error in - if let error { - self.logger.debug("Failed to load image data: \(error.localizedDescription)") - } - continuation.resume(returning: data) - } - } - - guard let data = rawData else { return nil } - - // If within size limit, return as-is - if data.count <= Self.maxImageSize { - return data - } + guard let data = rawData else { return nil } - // Compress the image - return compressImage(data: data, maxSize: Self.maxImageSize) + // If within size limit, return as-is + if data.count <= Self.maxImageSize { + return data } - /// Compresses image data to fit within a maximum size - private func compressImage(data: Data, maxSize: Int) -> Data? { - guard let image = UIImage(data: data) else { return data } + // Compress the image + return compressImage(data: data, maxSize: Self.maxImageSize) + } - // Start with high quality and reduce until within size - var quality: CGFloat = 0.8 - var compressed = image.jpegData(compressionQuality: quality) + /// Compresses image data to fit within a maximum size + private func compressImage(data: Data, maxSize: Int) -> Data? { + guard let image = UIImage(data: data) else { return data } - while let compressedData = compressed, compressedData.count > maxSize, quality > 0.1 { - quality -= 0.1 - compressed = image.jpegData(compressionQuality: quality) - } + // Start with high quality and reduce until within size + var quality: CGFloat = 0.8 + var compressed = image.jpegData(compressionQuality: quality) - // If still too large, scale down the image - if let compressedData = compressed, compressedData.count > maxSize { - let scale = sqrt(Double(maxSize) / Double(compressedData.count)) - let newSize = CGSize( - width: image.size.width * scale, - height: image.size.height * scale - ) - - let renderer = UIGraphicsImageRenderer(size: newSize) - let resized = renderer.image { _ in - image.draw(in: CGRect(origin: .zero, size: newSize)) - } - compressed = resized.jpegData(compressionQuality: 0.7) - } + while let compressedData = compressed, compressedData.count > maxSize, quality > 0.1 { + quality -= 0.1 + compressed = image.jpegData(compressionQuality: quality) + } - logger.debug("Compressed image from \(data.count) to \(compressed?.count ?? 0) bytes") - return compressed + // If still too large, scale down the image + if let compressedData = compressed, compressedData.count > maxSize { + let scale = sqrt(Double(maxSize) / Double(compressedData.count)) + let newSize = CGSize( + width: image.size.width * scale, + height: image.size.height * scale + ) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let resized = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + compressed = resized.jpegData(compressionQuality: 0.7) } + + logger.debug("Compressed image from \(data.count) to \(compressed?.count ?? 0) bytes") + return compressed + } } diff --git a/MC1/Services/LocationService.swift b/MC1/Services/LocationService.swift index a359ba21..74f588cf 100644 --- a/MC1/Services/LocationService.swift +++ b/MC1/Services/LocationService.swift @@ -1,34 +1,34 @@ import CoreLocation import OSLog -enum LocationServiceError: Error, LocalizedError, Sendable { - case notAuthorized(CLAuthorizationStatus) - case requestInProgress - case permissionTimeout - case locationTimeout - case requestFailed(String) - - var errorDescription: String? { - switch self { - case .notAuthorized(let status): - switch status { - case .notDetermined: - "Location permission is required to update your node's location." - case .denied, .restricted: - "Location permission is denied. Enable it in Settings to update your node's location." - default: - "Location permission is not available." - } - case .requestInProgress: - "A location request is already in progress." - case .permissionTimeout: - "Timed out while waiting for location permission." - case .locationTimeout: - "Timed out while requesting location." - case .requestFailed(let message): - "Location request failed: \(message)" - } +enum LocationServiceError: Error, LocalizedError { + case notAuthorized(CLAuthorizationStatus) + case requestInProgress + case permissionTimeout + case locationTimeout + case requestFailed(String) + + var errorDescription: String? { + switch self { + case let .notAuthorized(status): + switch status { + case .notDetermined: + "Location permission is required to update your node's location." + case .denied, .restricted: + "Location permission is denied. Enable it in Settings to update your node's location." + default: + "Location permission is not available." + } + case .requestInProgress: + "A location request is already in progress." + case .permissionTimeout: + "Timed out while waiting for location permission." + case .locationTimeout: + "Timed out while requesting location." + case let .requestFailed(message): + "Location request failed: \(message)" } + } } /// App-wide location service for managing location permissions and access. @@ -36,208 +36,207 @@ enum LocationServiceError: Error, LocalizedError, Sendable { @Observable @MainActor final class LocationService: NSObject, CLLocationManagerDelegate { + // MARK: - Properties + + private let logger = Logger(subsystem: "com.mc1", category: "LocationService") + private let locationManager: CLLocationManager + + private var requestContinuation: CheckedContinuation? + private var locationTimeoutTask: Task? + + private var authorizationContinuation: CheckedContinuation? + private var permissionTimeoutTask: Task? + + /// Current authorization status + private(set) var authorizationStatus: CLAuthorizationStatus + + /// Current device location (nil if unavailable or not yet determined) + private(set) var currentLocation: CLLocation? - // MARK: - Properties + /// Whether a location request is in progress + private(set) var isRequestingLocation = false - private let logger = Logger(subsystem: "com.mc1", category: "LocationService") - private let locationManager: CLLocationManager + /// Whether location services are authorized for use + var isAuthorized: Bool { + authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways + } - private var requestContinuation: CheckedContinuation? - private var locationTimeoutTask: Task? + /// Whether permission has been determined (not .notDetermined) + var hasRequestedPermission: Bool { + authorizationStatus != .notDetermined + } - private var authorizationContinuation: CheckedContinuation? - private var permissionTimeoutTask: Task? + /// Whether location is denied or restricted + var isLocationDenied: Bool { + authorizationStatus == .denied || authorizationStatus == .restricted + } - /// Current authorization status - private(set) var authorizationStatus: CLAuthorizationStatus + // MARK: - Initialization - /// Current device location (nil if unavailable or not yet determined) - private(set) var currentLocation: CLLocation? + override init() { + locationManager = CLLocationManager() + authorizationStatus = locationManager.authorizationStatus + super.init() + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } - /// Whether a location request is in progress - private(set) var isRequestingLocation = false + // MARK: - Public Methods - /// Whether location services are authorized for use - var isAuthorized: Bool { - authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways + /// Request location permission if not already determined. + /// Call this when a location-dependent feature is accessed. + func requestPermissionIfNeeded() { + guard authorizationStatus == .notDetermined else { + let raw = authorizationStatus.rawValue + logger.debug("Location permission already determined: \(String(describing: raw))") + return } - /// Whether permission has been determined (not .notDetermined) - var hasRequestedPermission: Bool { - authorizationStatus != .notDetermined + logger.info("Requesting location permission") + locationManager.requestWhenInUseAuthorization() + } + + /// Request a one-shot location update. + /// Call this when you need the current location (e.g., for distance sorting). + func requestLocation() { + guard isAuthorized else { + logger.debug("Cannot request location: not authorized") + requestPermissionIfNeeded() + return } - /// Whether location is denied or restricted - var isLocationDenied: Bool { - authorizationStatus == .denied || authorizationStatus == .restricted + guard !isRequestingLocation else { + logger.debug("Location request already in progress") + return } - // MARK: - Initialization + logger.info("Requesting one-shot location update") + isRequestingLocation = true + locationManager.requestLocation() + } - override init() { - locationManager = CLLocationManager() - authorizationStatus = locationManager.authorizationStatus - super.init() - locationManager.delegate = self - locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters + /// Request current location asynchronously with timeout. + /// Handles permission prompting if needed. + func requestCurrentLocation(timeout: Duration = .seconds(10)) async throws -> CLLocation { + guard requestContinuation == nil, authorizationContinuation == nil else { + throw LocationServiceError.requestInProgress } - // MARK: - Public Methods + if !isAuthorized { + if authorizationStatus == .notDetermined { + requestPermissionIfNeeded() + _ = try await waitForAuthorizationDecision(timeout: .seconds(30)) + } - /// Request location permission if not already determined. - /// Call this when a location-dependent feature is accessed. - func requestPermissionIfNeeded() { - guard authorizationStatus == .notDetermined else { - let raw = self.authorizationStatus.rawValue - logger.debug("Location permission already determined: \(String(describing: raw))") - return - } - - logger.info("Requesting location permission") - locationManager.requestWhenInUseAuthorization() + guard isAuthorized else { + throw LocationServiceError.notAuthorized(authorizationStatus) + } } - /// Request a one-shot location update. - /// Call this when you need the current location (e.g., for distance sorting). - func requestLocation() { - guard isAuthorized else { - logger.debug("Cannot request location: not authorized") - requestPermissionIfNeeded() - return - } - - guard !isRequestingLocation else { - logger.debug("Location request already in progress") - return - } + isRequestingLocation = true - logger.info("Requesting one-shot location update") - isRequestingLocation = true - locationManager.requestLocation() - } + return try await withCheckedThrowingContinuation { continuation in + requestContinuation = continuation + locationManager.requestLocation() - /// Request current location asynchronously with timeout. - /// Handles permission prompting if needed. - func requestCurrentLocation(timeout: Duration = .seconds(10)) async throws -> CLLocation { - guard requestContinuation == nil, authorizationContinuation == nil else { - throw LocationServiceError.requestInProgress + locationTimeoutTask?.cancel() + locationTimeoutTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: timeout) + } catch { + return } + guard let self, let continuation = requestContinuation else { return } - if !isAuthorized { - if authorizationStatus == .notDetermined { - requestPermissionIfNeeded() - _ = try await waitForAuthorizationDecision(timeout: .seconds(30)) - } + requestContinuation = nil + isRequestingLocation = false + continuation.resume(throwing: LocationServiceError.locationTimeout) + } + } + } - guard isAuthorized else { - throw LocationServiceError.notAuthorized(authorizationStatus) - } - } + // MARK: - Private Methods - isRequestingLocation = true - - return try await withCheckedThrowingContinuation { continuation in - requestContinuation = continuation - locationManager.requestLocation() - - locationTimeoutTask?.cancel() - locationTimeoutTask = Task { @MainActor [weak self] in - do { - try await Task.sleep(for: timeout) - } catch { - return - } - guard let self, let continuation = self.requestContinuation else { return } - - self.requestContinuation = nil - self.isRequestingLocation = false - continuation.resume(throwing: LocationServiceError.locationTimeout) - } - } + private func waitForAuthorizationDecision(timeout: Duration) async throws -> CLAuthorizationStatus { + guard authorizationStatus == .notDetermined else { return authorizationStatus } + guard authorizationContinuation == nil else { + throw LocationServiceError.requestInProgress } - // MARK: - Private Methods + return try await withCheckedThrowingContinuation { continuation in + authorizationContinuation = continuation - private func waitForAuthorizationDecision(timeout: Duration) async throws -> CLAuthorizationStatus { - guard authorizationStatus == .notDetermined else { return authorizationStatus } - guard authorizationContinuation == nil else { - throw LocationServiceError.requestInProgress + permissionTimeoutTask?.cancel() + permissionTimeoutTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: timeout) + } catch { + return } + guard let self, let continuation = authorizationContinuation else { return } - return try await withCheckedThrowingContinuation { continuation in - authorizationContinuation = continuation - - permissionTimeoutTask?.cancel() - permissionTimeoutTask = Task { @MainActor [weak self] in - do { - try await Task.sleep(for: timeout) - } catch { - return - } - guard let self, let continuation = self.authorizationContinuation else { return } - - self.authorizationContinuation = nil - continuation.resume(throwing: LocationServiceError.permissionTimeout) - } - } + authorizationContinuation = nil + continuation.resume(throwing: LocationServiceError.permissionTimeout) + } } - - // MARK: - CLLocationManagerDelegate - - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - Task { @MainActor in - self.authorizationStatus = status - self.logger.info("Location authorization changed: \(String(describing: status.rawValue))") - - if status != .notDetermined, let authorizationContinuation = self.authorizationContinuation { - self.authorizationContinuation = nil - self.permissionTimeoutTask?.cancel() - self.permissionTimeoutTask = nil - authorizationContinuation.resume(returning: status) - } - - if status == .denied || status == .restricted { - if let continuation = self.requestContinuation { - self.requestContinuation = nil - self.locationTimeoutTask?.cancel() - self.locationTimeoutTask = nil - self.isRequestingLocation = false - continuation.resume(throwing: LocationServiceError.notAuthorized(status)) - } - } + } + + // MARK: - CLLocationManagerDelegate + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + self.authorizationStatus = status + self.logger.info("Location authorization changed: \(String(describing: status.rawValue))") + + if status != .notDetermined, let authorizationContinuation = self.authorizationContinuation { + self.authorizationContinuation = nil + self.permissionTimeoutTask?.cancel() + self.permissionTimeoutTask = nil + authorizationContinuation.resume(returning: status) + } + + if status == .denied || status == .restricted { + if let continuation = self.requestContinuation { + self.requestContinuation = nil + self.locationTimeoutTask?.cancel() + self.locationTimeoutTask = nil + self.isRequestingLocation = false + continuation.resume(throwing: LocationServiceError.notAuthorized(status)) } + } } - - nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let location = locations.last else { return } - Task { @MainActor in - self.currentLocation = location - self.isRequestingLocation = false - self.logger.info("Location updated: \(location.coordinate.latitude), \(location.coordinate.longitude)") - - self.locationTimeoutTask?.cancel() - self.locationTimeoutTask = nil - - if let continuation = self.requestContinuation { - self.requestContinuation = nil - continuation.resume(returning: location) - } - } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + Task { @MainActor in + self.currentLocation = location + self.isRequestingLocation = false + self.logger.info("Location updated: \(location.coordinate.latitude), \(location.coordinate.longitude)") + + self.locationTimeoutTask?.cancel() + self.locationTimeoutTask = nil + + if let continuation = self.requestContinuation { + self.requestContinuation = nil + continuation.resume(returning: location) + } } + } - nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - Task { @MainActor in - self.isRequestingLocation = false - self.logger.error("Location request failed: \(error.localizedDescription)") + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Task { @MainActor in + self.isRequestingLocation = false + self.logger.error("Location request failed: \(error.localizedDescription)") - self.locationTimeoutTask?.cancel() - self.locationTimeoutTask = nil + self.locationTimeoutTask?.cancel() + self.locationTimeoutTask = nil - if let continuation = self.requestContinuation { - self.requestContinuation = nil - continuation.resume(throwing: LocationServiceError.requestFailed(error.localizedDescription)) - } - } + if let continuation = self.requestContinuation { + self.requestContinuation = nil + continuation.resume(throwing: LocationServiceError.requestFailed(error.localizedDescription)) + } } + } } diff --git a/MC1/Services/LogExportService.swift b/MC1/Services/LogExportService.swift index 2e37ac41..80662e15 100644 --- a/MC1/Services/LogExportService.swift +++ b/MC1/Services/LogExportService.swift @@ -1,194 +1,193 @@ import Foundation +import MC1Services import MeshCore import OSLog -import MC1Services import UIKit /// Service for exporting debug logs and app state for troubleshooting enum LogExportService { - private static let logger = Logger(subsystem: "com.mc1", category: "LogExportService") - - /// Generates a debug export containing app logs and current state - @MainActor - static func generateExport(appState: AppState, persistenceStore: PersistenceStore) async -> String { - // Flush buffered logs first so the export includes the latest lifecycle events. - if let debugLogBuffer = DebugLogBuffer.shared { - await debugLogBuffer.flush() - } - - let isConnected = appState.connectedDevice != nil - let device: DeviceDTO? - if let connectedDevice = appState.connectedDevice { - device = connectedDevice - } else { - do { - device = try await persistenceStore.fetchActiveDevice() - } catch { - logger.error("Failed to fetch active device for export: \(error.localizedDescription)") - device = nil - } - } - - var sections: [String] = [] + private static let logger = Logger(subsystem: "com.mc1", category: "LogExportService") + + /// Generates a debug export containing app logs and current state + @MainActor + static func generateExport(appState: AppState, persistenceStore: PersistenceStore) async -> String { + // Flush buffered logs first so the export includes the latest lifecycle events. + if let debugLogBuffer = DebugLogBuffer.shared { + await debugLogBuffer.flush() + } - // Header - sections.append(generateHeader()) + let isConnected = appState.connectedDevice != nil + let device: DeviceDTO? + if let connectedDevice = appState.connectedDevice { + device = connectedDevice + } else { + do { + device = try await persistenceStore.fetchActiveDevice() + } catch { + logger.error("Failed to fetch active device for export: \(error.localizedDescription)") + device = nil + } + } - // Connection info - sections.append(await generateConnectionSection(appState: appState, device: device)) + var sections: [String] = [] - // Device info (connected or last-connected from persistence) - if let device { - sections.append(generateDeviceSection(device: device, isConnected: isConnected)) - } + // Header + sections.append(generateHeader()) - // Battery info - if let battery = appState.batteryMonitor.deviceBattery { - sections.append(generateBatterySection(battery: battery)) - } + // Connection info + await sections.append(generateConnectionSection(appState: appState, device: device)) - // Logs - sections.append(await generateLogsSection(persistenceStore: persistenceStore)) + // Device info (connected or last-connected from persistence) + if let device { + sections.append(generateDeviceSection(device: device, isConnected: isConnected)) + } - return sections.joined(separator: "\n\n") + // Battery info + if let battery = appState.batteryMonitor.deviceBattery { + sections.append(generateBatterySection(battery: battery)) } - /// Creates a temporary file with the export content and returns its URL - @MainActor - static func createExportFile(appState: AppState, persistenceStore: PersistenceStore) async -> URL? { - let content = await generateExport(appState: appState, persistenceStore: persistenceStore) + // Logs + await sections.append(generateLogsSection(persistenceStore: persistenceStore)) - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd-HHmmss" - let timestamp = formatter.string(from: Date()) - let filename = "MeshCore-One-Debug-\(timestamp).txt" - - let tempURL = FileManager.default.temporaryDirectory.appending(path: filename) - - do { - try content.write(to: tempURL, atomically: true, encoding: .utf8) - return tempURL - } catch { - logger.error("Failed to write export file: \(error.localizedDescription)") - return nil - } - } + return sections.joined(separator: "\n\n") + } - // MARK: - Section Generators - - @MainActor - private static func generateHeader() -> String { - let appVersion = Bundle.main.appVersion - let buildNumber = Bundle.main.appBuild - let deviceModel = UIDevice.current.model - let systemVersion = UIDevice.current.systemVersion - - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - let exportedAt = formatter.string(from: Date()) - - return """ - === MeshCore One Debug Export === - Exported: \(exportedAt) - App Version: \(appVersion) (\(buildNumber)) - Device: \(deviceModel), iOS \(systemVersion) - """ - } + /// Creates a temporary file with the export content and returns its URL + @MainActor + static func createExportFile(appState: AppState, persistenceStore: PersistenceStore) async -> URL? { + let content = await generateExport(appState: appState, persistenceStore: persistenceStore) - @MainActor - private static func generateConnectionSection(appState: AppState, device: DeviceDTO?) async -> String { - let state = appState.connectionState - let stateString: String - switch state { - case .disconnected: stateString = "disconnected" - case .connecting: stateString = "connecting" - case .connected: stateString = "connected" - case .syncing: stateString = "syncing" - case .ready: stateString = "ready" - } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + let timestamp = formatter.string(from: Date()) + let filename = "MeshCore-One-Debug-\(timestamp).txt" - var lines = [ - "=== Connection ===", - "State: \(stateString)", - "Intent: \(appState.connectionManager.connectionIntentSummary)" - ] + let tempURL = FileManager.default.temporaryDirectory.appending(path: filename) - let disconnectDiagnostic = - appState.connectionManager.lastDisconnectDiagnostic ?? - "Unavailable (no disconnect callback captured; app may have been suspended)" - lines.append("Last Disconnect Diagnostic: \(disconnectDiagnostic)") - lines.append(await appState.connectionManager.currentBLEDiagnosticsSummary()) + do { + try content.write(to: tempURL, atomically: true, encoding: .utf8) + return tempURL + } catch { + logger.error("Failed to write export file: \(error.localizedDescription)") + return nil + } + } + + // MARK: - Section Generators + + @MainActor + private static func generateHeader() -> String { + let appVersion = Bundle.main.appVersion + let buildNumber = Bundle.main.appBuild + let deviceModel = UIDevice.current.model + let systemVersion = UIDevice.current.systemVersion + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let exportedAt = formatter.string(from: Date()) + + return """ + === MeshCore One Debug Export === + Exported: \(exportedAt) + App Version: \(appVersion) (\(buildNumber)) + Device: \(deviceModel), iOS \(systemVersion) + """ + } + + @MainActor + private static func generateConnectionSection(appState: AppState, device: DeviceDTO?) async -> String { + let state = appState.connectionState + let stateString = switch state { + case .disconnected: "disconnected" + case .connecting: "connecting" + case .connected: "connected" + case .syncing: "syncing" + case .ready: "ready" + } - if let device { - lines.append("Device: \(device.nodeName) (\(device.id.uuidString.prefix(8))...)") + var lines = [ + "=== Connection ===", + "State: \(stateString)", + "Intent: \(appState.connectionManager.connectionIntentSummary)" + ] - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - lines.append("Last Connected: \(formatter.string(from: device.lastConnected))") - } + let disconnectDiagnostic = + appState.connectionManager.lastDisconnectDiagnostic ?? + "Unavailable (no disconnect callback captured; app may have been suspended)" + lines.append("Last Disconnect Diagnostic: \(disconnectDiagnostic)") + await lines.append(appState.connectionManager.currentBLEDiagnosticsSummary()) - return lines.joined(separator: "\n") - } + if let device { + lines.append("Device: \(device.nodeName) (\(device.id.uuidString.prefix(8))...)") - private static func generateDeviceSection(device: DeviceDTO, isConnected: Bool) -> String { - let frequencyMHz = Double(device.frequency) / 1000.0 - let bandwidthKHz = device.bandwidth - let header = isConnected ? "=== Device Info ===" : "=== Device Info (Last Connected) ===" - - return """ - \(header) - Name: \(device.nodeName) - Firmware: \(device.firmwareVersionString) (v\(device.firmwareVersion)) - Manufacturer: \(device.manufacturerName) - Build Date: \(device.buildDate) - Radio: \(frequencyMHz.formatted(.number.precision(.fractionLength(3)))) MHz, BW \(bandwidthKHz) kHz, SF\(device.spreadingFactor), CR\(device.codingRate) - TX Power: \(device.txPower) dBm (max \(device.maxTxPower)) - Max Nodes: \(device.maxContacts) - Max Channels: \(device.maxChannels) - Manual Add Nodes: \(device.manualAddContacts) - Multi-ACKs: \(device.multiAcks) - """ + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + lines.append("Last Connected: \(formatter.string(from: device.lastConnected))") } - private static func generateBatterySection(battery: BatteryInfo) -> String { - return """ - === Battery === - Level: \(battery.percentage)% - Voltage: \(battery.voltage.formatted(.number.precision(.fractionLength(2)))) V - Raw: \(battery.level) mV - """ - } + return lines.joined(separator: "\n") + } + + private static func generateDeviceSection(device: DeviceDTO, isConnected: Bool) -> String { + let frequencyMHz = Double(device.frequency) / 1000.0 + let bandwidthKHz = device.bandwidth + let header = isConnected ? "=== Device Info ===" : "=== Device Info (Last Connected) ===" + + return """ + \(header) + Name: \(device.nodeName) + Firmware: \(device.firmwareVersionString) (v\(device.firmwareVersion)) + Manufacturer: \(device.manufacturerName) + Build Date: \(device.buildDate) + Radio: \(frequencyMHz.formatted(.number.precision(.fractionLength(3)))) MHz, BW \(bandwidthKHz) kHz, SF\(device.spreadingFactor), CR\(device.codingRate) + TX Power: \(device.txPower) dBm (max \(device.maxTxPower)) + Max Nodes: \(device.maxContacts) + Max Channels: \(device.maxChannels) + Manual Add Nodes: \(device.manualAddContacts) + Multi-ACKs: \(device.multiAcks) + """ + } + + private static func generateBatterySection(battery: BatteryInfo) -> String { + """ + === Battery === + Level: \(battery.percentage)% + Voltage: \(battery.voltage.formatted(.number.precision(.fractionLength(2)))) V + Raw: \(battery.level) mV + """ + } + + private static func generateLogsSection(persistenceStore: PersistenceStore) async -> String { + var lines = ["=== Logs (Last 24 Hours) ==="] + + do { + let secondsPerDay: TimeInterval = 86400 + let twentyFourHoursAgo = Date().addingTimeInterval(-secondsPerDay) + let entries = try await persistenceStore.fetchDebugLogEntries( + since: twentyFourHoursAgo, + limit: 1000 + ) + + if entries.isEmpty { + lines.append("(No logs found)") + } else { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - private static func generateLogsSection(persistenceStore: PersistenceStore) async -> String { - var lines = ["=== Logs (Last 24 Hours) ==="] - - do { - let secondsPerDay: TimeInterval = 86_400 - let twentyFourHoursAgo = Date().addingTimeInterval(-secondsPerDay) - let entries = try await persistenceStore.fetchDebugLogEntries( - since: twentyFourHoursAgo, - limit: 1000 - ) - - if entries.isEmpty { - lines.append("(No logs found)") - } else { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - - for entry in entries { - let timestamp = formatter.string(from: entry.timestamp) - lines.append("\(timestamp) [\(entry.level.label)] \(entry.category): \(entry.message)") - } - - lines.append("") - lines.append("Total entries: \(entries.count)") - } - } catch { - lines.append("(Failed to fetch logs: \(error.localizedDescription))") - logger.error("Debug log fetch failed: \(error.localizedDescription)") + for entry in entries { + let timestamp = formatter.string(from: entry.timestamp) + lines.append("\(timestamp) [\(entry.level.label)] \(entry.category): \(entry.message)") } - return lines.joined(separator: "\n") + lines.append("") + lines.append("Total entries: \(entries.count)") + } + } catch { + lines.append("(Failed to fetch logs: \(error.localizedDescription))") + logger.error("Debug log fetch failed: \(error.localizedDescription)") } + + return lines.joined(separator: "\n") + } } diff --git a/MC1/Services/MapSnapshotStore.swift b/MC1/Services/MapSnapshotStore.swift index 104c4951..8dbe7b08 100644 --- a/MC1/Services/MapSnapshotStore.swift +++ b/MC1/Services/MapSnapshotStore.swift @@ -6,270 +6,270 @@ import UIKit /// owns only the `resolutionStream` subscription. @MainActor final class MapSnapshotStore { - static let shared = MapSnapshotStore() + static let shared = MapSnapshotStore() - private static let cacheCountLimit = 50 - private static let cacheCostLimitBytes = 50 * 1024 * 1024 - private static let resolutionStreamBufferDepth = 64 - /// Serial by default: 2-3 concurrent `MLNMapSnapshotter`s are unprecedented - /// GL load in this app. Raise only if Instruments shows serial is too slow. - private static let maxConcurrent = 1 - /// Hard cap on the failed-render set. The set is sticky (failures stay - /// known until cleared) and grows with chat history during flaky networks; - /// this cap keeps memory bounded in pathological cases by FIFO-evicting the - /// oldest entry, which causes that one request to retry on next on-appear. - internal static let failedSetSizeLimit = 200 + private static let cacheCountLimit = 50 + private static let cacheCostLimitBytes = 50 * 1024 * 1024 + private static let resolutionStreamBufferDepth = 64 + /// Serial by default: 2-3 concurrent `MLNMapSnapshotter`s are unprecedented + /// GL load in this app. Raise only if Instruments shows serial is too slow. + private static let maxConcurrent = 1 + /// Hard cap on the failed-render set. The set is sticky (failures stay + /// known until cleared) and grows with chat history during flaky networks; + /// this cap keeps memory bounded in pathological cases by FIFO-evicting the + /// oldest entry, which causes that one request to retry on next on-appear. + static let failedSetSizeLimit = 200 - private let renderer: MapSnapshotRendering - /// Hand-rolled FIFO image cache. Mirrors the `DecodedPreviewCache` and - /// `InlineImageCache.decodedMirror` patterns — and unlike `NSCache`, FIFO - /// eviction here knows which key is being dropped, so it can notify - /// `resolvedKeys` and yield a resolution event so on-screen rows reload to - /// the skeleton state and re-request the snapshot. Also gives the - /// `resolvedKeys` mirror a natural bound (it shadows live cache entries). - private var imageEntries: [MapSnapshotRequest: ImageEntry] = [:] - private var imageInsertionOrder: [MapSnapshotRequest] = [] - private var totalImageCostBytes: Int = 0 - private var inFlight: Set = [] - private var failed: Set = [] - /// Insertion-order shadow of `failed` for FIFO eviction once the set hits - /// `failedSetSizeLimit`. Kept in sync with `failed`. - private var failedOrder: [MapSnapshotRequest] = [] - /// Every request that has ever reached a resolved state (cached image or - /// known failure). Used to broadcast invalidations on `clear()` and - /// `clearFailures()` so on-screen rows reload to the skeleton state and - /// re-request the snapshot. Bounded because cache eviction and failed-set - /// FIFO eviction both prune the matching key here. - private var resolvedKeys: Set = [] - private let semaphore = AsyncSemaphore(value: maxConcurrent) - /// Memory-warning observer token, retained for removal in `deinit`. - private var memoryWarningObserver: NSObjectProtocol? - /// Bumped on every `clear()` so a `performRender` that started before the - /// clear can detect it (the render's `await` releases the main actor; the - /// memory-warning observer can run during that suspension and empty - /// everything). The post-await branch reads this and bails to avoid - /// re-populating the state the clear was trying to evict. - private var clearGeneration: UInt64 = 0 + private let renderer: MapSnapshotRendering + /// Hand-rolled FIFO image cache. Mirrors the `DecodedPreviewCache` and + /// `InlineImageCache.decodedMirror` patterns — and unlike `NSCache`, FIFO + /// eviction here knows which key is being dropped, so it can notify + /// `resolvedKeys` and yield a resolution event so on-screen rows reload to + /// the skeleton state and re-request the snapshot. Also gives the + /// `resolvedKeys` mirror a natural bound (it shadows live cache entries). + private var imageEntries: [MapSnapshotRequest: ImageEntry] = [:] + private var imageInsertionOrder: [MapSnapshotRequest] = [] + private var totalImageCostBytes: Int = 0 + private var inFlight: Set = [] + private var failed: Set = [] + /// Insertion-order shadow of `failed` for FIFO eviction once the set hits + /// `failedSetSizeLimit`. Kept in sync with `failed`. + private var failedOrder: [MapSnapshotRequest] = [] + /// Every request that has ever reached a resolved state (cached image or + /// known failure). Used to broadcast invalidations on `clear()` and + /// `clearFailures()` so on-screen rows reload to the skeleton state and + /// re-request the snapshot. Bounded because cache eviction and failed-set + /// FIFO eviction both prune the matching key here. + private var resolvedKeys: Set = [] + private let semaphore = AsyncSemaphore(value: maxConcurrent) + /// Memory-warning observer token, retained for removal in `deinit`. + private var memoryWarningObserver: NSObjectProtocol? + /// Bumped on every `clear()` so a `performRender` that started before the + /// clear can detect it (the render's `await` releases the main actor; the + /// memory-warning observer can run during that suspension and empty + /// everything). The post-await branch reads this and bails to avoid + /// re-populating the state the clear was trying to evict. + private var clearGeneration: UInt64 = 0 - /// One continuation per live subscriber. `AsyncStream` is single-consumer: a - /// single shared stream hands each yield to only one iterator, so with two - /// `ChatViewModel`s alive (iPad split view, or the overlap during a - /// conversation switch) some thumbnails would never get their resolution and - /// stay stuck on the skeleton. Each subscriber gets its own stream via - /// `resolutionStream()`; every resolution is fanned out to all of them. - private var resolutionContinuations: [UUID: AsyncStream.Continuation] = [:] + /// One continuation per live subscriber. `AsyncStream` is single-consumer: a + /// single shared stream hands each yield to only one iterator, so with two + /// `ChatViewModel`s alive (iPad split view, or the overlap during a + /// conversation switch) some thumbnails would never get their resolution and + /// stay stuck on the skeleton. Each subscriber gets its own stream via + /// `resolutionStream()`; every resolution is fanned out to all of them. + private var resolutionContinuations: [UUID: AsyncStream.Continuation] = [:] - init(renderer: MapSnapshotRendering = MapSnapshotRenderer()) { - self.renderer = renderer + init(renderer: MapSnapshotRendering = MapSnapshotRenderer()) { + self.renderer = renderer - memoryWarningObserver = NotificationCenter.default.addObserver( - forName: UIApplication.didReceiveMemoryWarningNotification, - object: nil, - queue: nil - ) { [weak self] _ in - Task { @MainActor in self?.clear() } - } + memoryWarningObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil + ) { [weak self] _ in + Task { @MainActor in self?.clear() } } + } - isolated deinit { - if let memoryWarningObserver { - NotificationCenter.default.removeObserver(memoryWarningObserver) - } + isolated deinit { + if let memoryWarningObserver { + NotificationCenter.default.removeObserver(memoryWarningObserver) } + } - /// A resolution stream scoped to a single subscriber. The subscriber's task - /// ending (cancel or deinit) terminates the stream and drops its continuation. - /// Every render resolution is delivered to all live subscribers. - func resolutionStream() -> AsyncStream { - let id = UUID() - // `bufferingNewest` is the right policy for a latest-state notification - // stream: when the buffer is full, `bufferingOldest` would drop new - // events instead of replacing stale ones. `clear()` and `clearFailures()` - // can synchronously yield hundreds of events at once (one per resolved - // request), so the buffer can saturate before the main-actor consumer - // runs; the newest events are the ones the UI needs. - let (stream, continuation) = AsyncStream.makeStream( - of: MapSnapshotRequest.self, - bufferingPolicy: .bufferingNewest(Self.resolutionStreamBufferDepth) - ) - continuation.onTermination = { [weak self] _ in - Task { @MainActor in self?.resolutionContinuations.removeValue(forKey: id) } - } - resolutionContinuations[id] = continuation - return stream + /// A resolution stream scoped to a single subscriber. The subscriber's task + /// ending (cancel or deinit) terminates the stream and drops its continuation. + /// Every render resolution is delivered to all live subscribers. + func resolutionStream() -> AsyncStream { + let id = UUID() + // `bufferingNewest` is the right policy for a latest-state notification + // stream: when the buffer is full, `bufferingOldest` would drop new + // events instead of replacing stale ones. `clear()` and `clearFailures()` + // can synchronously yield hundreds of events at once (one per resolved + // request), so the buffer can saturate before the main-actor consumer + // runs; the newest events are the ones the UI needs. + let (stream, continuation) = AsyncStream.makeStream( + of: MapSnapshotRequest.self, + bufferingPolicy: .bufferingNewest(Self.resolutionStreamBufferDepth) + ) + continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.resolutionContinuations.removeValue(forKey: id) } } + resolutionContinuations[id] = continuation + return stream + } - /// Synchronous cache lookup for the build path and the view resolver. - func image(for request: MapSnapshotRequest) -> UIImage? { - imageEntries[request]?.image - } - - /// True once the render attempt has resolved (cached image or known failure). - /// Build-time `isReady` reads this. - func isResolved(_ request: MapSnapshotRequest) -> Bool { - imageEntries[request] != nil || failed.contains(request) - } + /// Synchronous cache lookup for the build path and the view resolver. + func image(for request: MapSnapshotRequest) -> UIImage? { + imageEntries[request]?.image + } - /// Lazily enqueue a render. Dedupes against the cache, the failed set, and the - /// in-flight set, so repeated on-appear calls during scroll are cheap. - func request(_ request: MapSnapshotRequest) { - guard imageEntries[request] == nil else { return } - guard !failed.contains(request) else { return } - guard !inFlight.contains(request) else { return } - inFlight.insert(request) - Task { await performRender(request) } - } + /// True once the render attempt has resolved (cached image or known failure). + /// Build-time `isReady` reads this. + func isResolved(_ request: MapSnapshotRequest) -> Bool { + imageEntries[request] != nil || failed.contains(request) + } - private func performRender(_ request: MapSnapshotRequest) async { - // `await`s here release the main actor; the memory-warning observer - // can run `clear()` during the suspension. Capture the generation up - // front so we can detect that case post-await and skip the writes — - // otherwise we'd re-populate the exact state the clear just evicted. - let generationAtStart = clearGeneration - // Drain `inFlight` on every exit path. The renderer's snapshotter is - // wrapped in `withTaskCancellationHandler`, but if a future caller - // ever cancels this task before the cache write runs, an explicit - // `inFlight` remove keeps subsequent on-appear retries from being - // dedupe-swallowed forever. - defer { inFlight.remove(request) } - await semaphore.wait() - defer { Task { await semaphore.signal() } } - let image = await renderer.render(request) + /// Lazily enqueue a render. Dedupes against the cache, the failed set, and the + /// in-flight set, so repeated on-appear calls during scroll are cheap. + func request(_ request: MapSnapshotRequest) { + guard imageEntries[request] == nil else { return } + guard !failed.contains(request) else { return } + guard !inFlight.contains(request) else { return } + inFlight.insert(request) + Task { await performRender(request) } + } - guard clearGeneration == generationAtStart else { - // `clear()` already evicted everything resolved at the time and - // yielded an invalidation. Skip the cache write so the eviction - // sticks; the deferred `inFlight.remove` keeps the dedupe set - // consistent regardless. - return - } + private func performRender(_ request: MapSnapshotRequest) async { + // `await`s here release the main actor; the memory-warning observer + // can run `clear()` during the suspension. Capture the generation up + // front so we can detect that case post-await and skip the writes — + // otherwise we'd re-populate the exact state the clear just evicted. + let generationAtStart = clearGeneration + // Drain `inFlight` on every exit path. The renderer's snapshotter is + // wrapped in `withTaskCancellationHandler`, but if a future caller + // ever cancels this task before the cache write runs, an explicit + // `inFlight` remove keeps subsequent on-appear retries from being + // dedupe-swallowed forever. + defer { inFlight.remove(request) } + await semaphore.wait() + defer { Task { await semaphore.signal() } } + let image = await renderer.render(request) - if let image { - cacheImage(image, for: request) - } else { - insertFailed(request) - } - resolvedKeys.insert(request) - yieldResolution(request) + guard clearGeneration == generationAtStart else { + // `clear()` already evicted everything resolved at the time and + // yielded an invalidation. Skip the cache write so the eviction + // sticks; the deferred `inFlight.remove` keeps the dedupe set + // consistent regardless. + return } - /// Inserts an image into the FIFO mirror and evicts oldest entries while - /// either the count or cost cap is exceeded. Each eviction prunes the - /// matching `resolvedKeys` entry and yields a resolution so on-screen rows - /// for that key reload to the skeleton state and re-fire `request(_:)`. - private func cacheImage(_ image: UIImage, for request: MapSnapshotRequest) { - let cost = ImageByteCost.bytes(for: image) - if let existing = imageEntries[request] { - totalImageCostBytes -= existing.cost - if let idx = imageInsertionOrder.firstIndex(of: request) { - imageInsertionOrder.remove(at: idx) - } - } - imageEntries[request] = ImageEntry(image: image, cost: cost) - imageInsertionOrder.append(request) - totalImageCostBytes += cost + if let image { + cacheImage(image, for: request) + } else { + insertFailed(request) + } + resolvedKeys.insert(request) + yieldResolution(request) + } - // Keep at least the just-inserted entry even when it singly exceeds - // the cost budget — matches `InlineImageCache.storeDecoded` so a large - // thumbnail is still served once before being evicted. - while imageInsertionOrder.count > 1, - imageInsertionOrder.count > Self.cacheCountLimit - || totalImageCostBytes > Self.cacheCostLimitBytes { - let evicted = imageInsertionOrder.removeFirst() - if let removed = imageEntries.removeValue(forKey: evicted) { - totalImageCostBytes -= removed.cost - } - resolvedKeys.remove(evicted) - yieldResolution(evicted) - } + /// Inserts an image into the FIFO mirror and evicts oldest entries while + /// either the count or cost cap is exceeded. Each eviction prunes the + /// matching `resolvedKeys` entry and yields a resolution so on-screen rows + /// for that key reload to the skeleton state and re-fire `request(_:)`. + private func cacheImage(_ image: UIImage, for request: MapSnapshotRequest) { + let cost = ImageByteCost.bytes(for: image) + if let existing = imageEntries[request] { + totalImageCostBytes -= existing.cost + if let idx = imageInsertionOrder.firstIndex(of: request) { + imageInsertionOrder.remove(at: idx) + } } + imageEntries[request] = ImageEntry(image: image, cost: cost) + imageInsertionOrder.append(request) + totalImageCostBytes += cost - private func insertFailed(_ request: MapSnapshotRequest) { - guard failed.insert(request).inserted else { return } - failedOrder.append(request) - while failedOrder.count > Self.failedSetSizeLimit { - let evicted = failedOrder.removeFirst() - failed.remove(evicted) - // Mirror the FIFO cache eviction path: pruning a failed entry must - // also drop the matching `resolvedKeys` and yield a resolution, so - // any on-screen row showing the stale failure fallback flips back - // to the skeleton state and re-fires `request(_:)`. - resolvedKeys.remove(evicted) - yieldResolution(evicted) - } + // Keep at least the just-inserted entry even when it singly exceeds + // the cost budget — matches `InlineImageCache.storeDecoded` so a large + // thumbnail is still served once before being evicted. + while imageInsertionOrder.count > 1, + imageInsertionOrder.count > Self.cacheCountLimit + || totalImageCostBytes > Self.cacheCostLimitBytes { + let evicted = imageInsertionOrder.removeFirst() + if let removed = imageEntries.removeValue(forKey: evicted) { + totalImageCostBytes -= removed.cost + } + resolvedKeys.remove(evicted) + yieldResolution(evicted) } + } - /// Yields a resolution event to every live subscriber. Each `ChatViewModel` - /// translates the request into the affected message rows via its - /// `mapPreviewRequestIndex` and rebuilds them. - private func yieldResolution(_ request: MapSnapshotRequest) { - for continuation in resolutionContinuations.values { - continuation.yield(request) - } + private func insertFailed(_ request: MapSnapshotRequest) { + guard failed.insert(request).inserted else { return } + failedOrder.append(request) + while failedOrder.count > Self.failedSetSizeLimit { + let evicted = failedOrder.removeFirst() + failed.remove(evicted) + // Mirror the FIFO cache eviction path: pruning a failed entry must + // also drop the matching `resolvedKeys` and yield a resolution, so + // any on-screen row showing the stale failure fallback flips back + // to the skeleton state and re-fires `request(_:)`. + resolvedKeys.remove(evicted) + yieldResolution(evicted) } + } - /// Drops a single failed key so the next `request(_:)` re-attempts the - /// render. Wired to the retry control on the chat thumbnail fallback so a - /// user who hit a transient online failure can recover without waiting on - /// the offline-to-online edge that calls `clearFailures()`. - func retry(_ request: MapSnapshotRequest) { - guard failed.remove(request) != nil else { return } - if let idx = failedOrder.firstIndex(of: request) { - failedOrder.remove(at: idx) - } - resolvedKeys.remove(request) - // Yielding flips the row back to the skeleton state via the rebuild, - // which re-fires `request(_:)` through the existing `.onAppear`. - yieldResolution(request) + /// Yields a resolution event to every live subscriber. Each `ChatViewModel` + /// translates the request into the affected message rows via its + /// `mapPreviewRequestIndex` and rebuilds them. + private func yieldResolution(_ request: MapSnapshotRequest) { + for continuation in resolutionContinuations.values { + continuation.yield(request) } + } - /// Drops every known failure so the next `request(_:)` for those keys - /// re-attempts the render. Called when the network transitions from - /// unavailable to available (in `ChatViewModel.applyEnvInputs`) so that - /// renders failed during the outage retry on the next chat rebuild. The - /// yield reloads on-screen rows showing the failure fallback to the - /// skeleton state so they re-fire `request(_:)` via `onAppear`. - func clearFailures() { - let cleared = failed - failed.removeAll() - failedOrder.removeAll() - for request in cleared { - resolvedKeys.remove(request) - yieldResolution(request) - } + /// Drops a single failed key so the next `request(_:)` re-attempts the + /// render. Wired to the retry control on the chat thumbnail fallback so a + /// user who hit a transient online failure can recover without waiting on + /// the offline-to-online edge that calls `clearFailures()`. + func retry(_ request: MapSnapshotRequest) { + guard failed.remove(request) != nil else { return } + if let idx = failedOrder.firstIndex(of: request) { + failedOrder.remove(at: idx) } + resolvedKeys.remove(request) + // Yielding flips the row back to the skeleton state via the rebuild, + // which re-fires `request(_:)` through the existing `.onAppear`. + yieldResolution(request) + } - /// Drops cached and failed state on memory pressure and yields a - /// resolution event for each previously-resolved request so on-screen - /// rows reload to the skeleton state and re-request the snapshot. Without - /// the broadcast, rows whose cached image was evicted would strand on the - /// fallback because the build-time `isReady` doesn't flip back without a - /// rebuild trigger. Internal for tests; in production it is invoked from - /// the memory-warning observer in `init`. - internal func clear() { - clearGeneration &+= 1 - let toInvalidate = resolvedKeys - imageEntries.removeAll() - imageInsertionOrder.removeAll() - totalImageCostBytes = 0 - failed.removeAll() - failedOrder.removeAll() - resolvedKeys.removeAll() - // In-flight renders restart with `inFlight` empty so post-clear - // on-appear `request(_:)` calls re-enqueue without dedupe; the - // generation guard in `performRender` keeps the pre-clear render's - // result from leaking back into the cleared state. - inFlight.removeAll() - for request in toInvalidate { - yieldResolution(request) - } + /// Drops every known failure so the next `request(_:)` for those keys + /// re-attempts the render. Called when the network transitions from + /// unavailable to available (in `ChatViewModel.applyEnvInputs`) so that + /// renders failed during the outage retry on the next chat rebuild. The + /// yield reloads on-screen rows showing the failure fallback to the + /// skeleton state so they re-fire `request(_:)` via `onAppear`. + func clearFailures() { + let cleared = failed + failed.removeAll() + failedOrder.removeAll() + for request in cleared { + resolvedKeys.remove(request) + yieldResolution(request) } + } - /// Pairs the cached image with its precomputed byte cost so the eviction - /// path can decrement the running total without re-running `ImageByteCost` - /// on the way out. - private struct ImageEntry { - let image: UIImage - let cost: Int + /// Drops cached and failed state on memory pressure and yields a + /// resolution event for each previously-resolved request so on-screen + /// rows reload to the skeleton state and re-request the snapshot. Without + /// the broadcast, rows whose cached image was evicted would strand on the + /// fallback because the build-time `isReady` doesn't flip back without a + /// rebuild trigger. Internal for tests; in production it is invoked from + /// the memory-warning observer in `init`. + func clear() { + clearGeneration &+= 1 + let toInvalidate = resolvedKeys + imageEntries.removeAll() + imageInsertionOrder.removeAll() + totalImageCostBytes = 0 + failed.removeAll() + failedOrder.removeAll() + resolvedKeys.removeAll() + // In-flight renders restart with `inFlight` empty so post-clear + // on-appear `request(_:)` calls re-enqueue without dedupe; the + // generation guard in `performRender` keeps the pre-clear render's + // result from leaking back into the cleared state. + inFlight.removeAll() + for request in toInvalidate { + yieldResolution(request) } + } + + /// Pairs the cached image with its precomputed byte cost so the eviction + /// path can decrement the running total without re-running `ImageByteCost` + /// on the way out. + private struct ImageEntry { + let image: UIImage + let cost: Int + } } diff --git a/MC1/Services/NotificationStringProviderImpl.swift b/MC1/Services/NotificationStringProviderImpl.swift index d0c496f5..6cbd8f44 100644 --- a/MC1/Services/NotificationStringProviderImpl.swift +++ b/MC1/Services/NotificationStringProviderImpl.swift @@ -3,41 +3,58 @@ import MC1Services /// App-layer implementation of NotificationStringProvider using L10n. struct NotificationStringProviderImpl: NotificationStringProvider { - func discoveryNotificationTitle(for type: ContactType) -> String { - switch type { - case .chat: - L10n.Localizable.Notifications.Discovery.contact - case .repeater: - L10n.Localizable.Notifications.Discovery.repeater - case .room: - L10n.Localizable.Notifications.Discovery.room - } + func discoveryNotificationTitle(for type: ContactType) -> String { + switch type { + case .chat: + L10n.Localizable.Notifications.Discovery.contact + case .repeater: + L10n.Localizable.Notifications.Discovery.repeater + case .room: + L10n.Localizable.Notifications.Discovery.room } + } - var replyActionTitle: String { L10n.Localizable.Notifications.Action.reply } - var sendButtonTitle: String { L10n.Localizable.Notifications.Action.send } - var messagePlaceholder: String { L10n.Localizable.Notifications.Action.messagePlaceholder } - var markAsReadActionTitle: String { L10n.Localizable.Notifications.Action.markAsRead } + var replyActionTitle: String { + L10n.Localizable.Notifications.Action.reply + } - var lowBatteryTitle: String { L10n.Localizable.Notifications.LowBattery.title } + var sendButtonTitle: String { + L10n.Localizable.Notifications.Action.send + } - func lowBatteryBody(deviceName: String, percentage: Int) -> String { - L10n.Localizable.Notifications.LowBattery.body(deviceName, percentage) - } + var messagePlaceholder: String { + L10n.Localizable.Notifications.Action.messagePlaceholder + } - var quickReplyFailedTitle: String { L10n.Localizable.Notifications.QuickReplyFailed.title } + var markAsReadActionTitle: String { + L10n.Localizable.Notifications.Action.markAsRead + } - func quickReplyFailedBody(conversationName: String) -> String { - L10n.Localizable.Notifications.QuickReplyFailed.body(conversationName) - } + var lowBatteryTitle: String { + L10n.Localizable.Notifications.LowBattery.title + } - var unknownContactName: String { L10n.Localizable.Notifications.Discovery.unknownContact } + func lowBatteryBody(deviceName: String, percentage: Int) -> String { + L10n.Localizable.Notifications.LowBattery.body(deviceName, percentage) + } - func defaultChannelName(index: Int) -> String { - L10n.Chats.Chats.Channel.defaultName(index) - } + var quickReplyFailedTitle: String { + L10n.Localizable.Notifications.QuickReplyFailed.title + } - func reactionNotificationBody(emoji: String, messagePreview: String) -> String { - L10n.Localizable.Notifications.Reaction.body(emoji, messagePreview) - } + func quickReplyFailedBody(conversationName: String) -> String { + L10n.Localizable.Notifications.QuickReplyFailed.body(conversationName) + } + + var unknownContactName: String { + L10n.Localizable.Notifications.Discovery.unknownContact + } + + func defaultChannelName(index: Int) -> String { + L10n.Chats.Chats.Channel.defaultName(index) + } + + func reactionNotificationBody(emoji: String, messagePreview: String) -> String { + L10n.Localizable.Notifications.Reaction.body(emoji, messagePreview) + } } diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index 7b4e0a2c..acf3df6e 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -4,64 +4,64 @@ import Network import os enum OfflineMapLayer: String, Codable { - case base - case topo + case base + case topo - var label: String { - switch self { - case .base: L10n.Settings.OfflineMaps.Layer.base - case .topo: L10n.Settings.OfflineMaps.Layer.topo - } + var label: String { + switch self { + case .base: L10n.Settings.OfflineMaps.Layer.base + case .topo: L10n.Settings.OfflineMaps.Layer.topo } + } - var maxDownloadZoom: Double { - switch self { - case .base: 14 - case .topo: 17 - } + var maxDownloadZoom: Double { + switch self { + case .base: 14 + case .topo: 17 } - - var styleURL: URL? { - switch self { - case .base: - URL(string: MapTileURLs.openFreeMapLiberty) - case .topo: - Bundle.main.url(forResource: "topo-offline", withExtension: "json") - } + } + + var styleURL: URL? { + switch self { + case .base: + URL(string: MapTileURLs.openFreeMapLiberty) + case .topo: + Bundle.main.url(forResource: "topo-offline", withExtension: "json") } + } } struct OfflinePackMetadata: Codable { - let name: String - let createdAt: Date - var layer: OfflineMapLayer - - init(name: String, createdAt: Date, layer: OfflineMapLayer = .base) { - self.name = name - self.createdAt = createdAt - self.layer = layer - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - createdAt = try container.decode(Date.self, forKey: .createdAt) - layer = try container.decodeIfPresent(OfflineMapLayer.self, forKey: .layer) ?? .base - } + let name: String + let createdAt: Date + var layer: OfflineMapLayer + + init(name: String, createdAt: Date, layer: OfflineMapLayer = .base) { + self.name = name + self.createdAt = createdAt + self.layer = layer + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + createdAt = try container.decode(Date.self, forKey: .createdAt) + layer = try container.decodeIfPresent(OfflineMapLayer.self, forKey: .layer) ?? .base + } } enum OfflineMapError: LocalizedError { - case insufficientDiskSpace - case missingStyleResource(OfflineMapLayer) - - var errorDescription: String? { - switch self { - case .insufficientDiskSpace: - L10n.Settings.OfflineMaps.Error.insufficientDiskSpace - case .missingStyleResource(let layer): - "Missing style resource for layer: \(layer.rawValue)" - } + case insufficientDiskSpace + case missingStyleResource(OfflineMapLayer) + + var errorDescription: String? { + switch self { + case .insufficientDiskSpace: + L10n.Settings.OfflineMaps.Error.insufficientDiskSpace + case let .missingStyleResource(layer): + "Missing style resource for layer: \(layer.rawValue)" } + } } private let logger = Logger(subsystem: "com.mc1", category: "OfflineMapService") @@ -69,359 +69,364 @@ private let logger = Logger(subsystem: "com.mc1", category: "OfflineMapService") @Observable @MainActor final class OfflineMapService { - - private static let minimumDiskSpaceBytes: Int64 = 100_000_000 - - private(set) var packs: [OfflinePack] = [] - private(set) var databaseSize: Int64 = 0 - private(set) var isNetworkAvailable = true - private(set) var lastPackError: String? - - private let monitor = NWPathMonitor() - private var observationTasks: [Task] = [] - private var pendingLoadTask: Task? - private var highWaterMarks: [ObjectIdentifier: Double] = [:] - private var byteSnapshots: [ObjectIdentifier: (bytes: UInt64, time: ContinuousClock.Instant)] = [:] - private var downloadSpeeds: [ObjectIdentifier: Int64] = [:] - private var metadataCache: [ObjectIdentifier: OfflinePackMetadata?] = [:] - private var deletingPackIDs: Set = [] - private var userPausedPackIDs: Set = [] - - init() { - let monitor = self.monitor - let networkStream = AsyncStream { continuation in - continuation.onTermination = { _ in monitor.cancel() } - monitor.pathUpdateHandler = { continuation.yield($0) } - // NWPathMonitor requires a DispatchQueue; no Swift concurrency alternative exists. - monitor.start(queue: .global(qos: .utility)) + private static let minimumDiskSpaceBytes: Int64 = 100_000_000 + + private(set) var packs: [OfflinePack] = [] + private(set) var databaseSize: Int64 = 0 + private(set) var isNetworkAvailable = true + private(set) var lastPackError: String? + + private let monitor = NWPathMonitor() + private var observationTasks: [Task] = [] + private var pendingLoadTask: Task? + private var highWaterMarks: [ObjectIdentifier: Double] = [:] + private var byteSnapshots: [ObjectIdentifier: (bytes: UInt64, time: ContinuousClock.Instant)] = [:] + private var downloadSpeeds: [ObjectIdentifier: Int64] = [:] + private var metadataCache: [ObjectIdentifier: OfflinePackMetadata?] = [:] + private var deletingPackIDs: Set = [] + private var userPausedPackIDs: Set = [] + + init() { + let monitor = monitor + let networkStream = AsyncStream { continuation in + continuation.onTermination = { _ in monitor.cancel() } + monitor.pathUpdateHandler = { continuation.yield($0) } + // NWPathMonitor requires a DispatchQueue; no Swift concurrency alternative exists. + monitor.start(queue: .global(qos: .utility)) + } + observationTasks.append(Task { [weak self] in + for await path in networkStream { + self?.isNetworkAvailable = path.status == .satisfied + } + }) + + observationTasks.append(Task { [weak self] in + for await _ in NotificationCenter.default.notifications(named: .MLNOfflinePackProgressChanged) { + self?.scheduleLoadPacks() + } + }) + observationTasks.append(Task { [weak self] in + for await notification in NotificationCenter.default.notifications(named: .MLNOfflinePackError) { + if let error = notification.userInfo?[MLNOfflinePackUserInfoKey.error] as? NSError { + logger.warning("Offline pack error: \(error.localizedDescription)") + self?.lastPackError = error.localizedDescription } - observationTasks.append(Task { [weak self] in - for await path in networkStream { - self?.isNetworkAvailable = path.status == .satisfied - } - }) - - observationTasks.append(Task { [weak self] in - for await _ in NotificationCenter.default.notifications(named: .MLNOfflinePackProgressChanged) { - self?.scheduleLoadPacks() - } - }) - observationTasks.append(Task { [weak self] in - for await notification in NotificationCenter.default.notifications(named: .MLNOfflinePackError) { - if let error = notification.userInfo?[MLNOfflinePackUserInfoKey.error] as? NSError { - logger.warning("Offline pack error: \(error.localizedDescription)") - self?.lastPackError = error.localizedDescription - } - } - }) - observationTasks.append(Task { [weak self] in - for await _ in NotificationCenter.default.notifications( - named: .MLNOfflinePackMaximumMapboxTilesReached - ) { - logger.warning("Offline pack tile limit reached") - self?.lastPackError = L10n.Settings.OfflineMaps.Error.tileLimitReached - } - }) - - excludeDatabaseFromBackup() - loadPacks() - updateDatabaseSize() - - // MLNOfflineStorage.shared.packs may be nil until async DB load completes. - // Retry once after a delay to catch late initialization. - if MLNOfflineStorage.shared.packs == nil { - observationTasks.append(Task { [weak self] in - do { - try await Task.sleep(for: .seconds(2)) - } catch { - return - } - self?.loadPacks() - }) + } + }) + observationTasks.append(Task { [weak self] in + for await _ in NotificationCenter.default.notifications( + named: .MLNOfflinePackMaximumMapboxTilesReached + ) { + logger.warning("Offline pack tile limit reached") + self?.lastPackError = L10n.Settings.OfflineMaps.Error.tileLimitReached + } + }) + + excludeDatabaseFromBackup() + loadPacks() + updateDatabaseSize() + + // MLNOfflineStorage.shared.packs may be nil until async DB load completes. + // Retry once after a delay to catch late initialization. + if MLNOfflineStorage.shared.packs == nil { + observationTasks.append(Task { [weak self] in + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return } + self?.loadPacks() + }) } + } - isolated deinit { - monitor.cancel() - pendingLoadTask?.cancel() - for task in observationTasks { - task.cancel() - } + isolated deinit { + monitor.cancel() + pendingLoadTask?.cancel() + for task in observationTasks { + task.cancel() } + } - func hasCompletedPack(for layer: OfflineMapLayer) -> Bool { - packs.contains { $0.layer == layer && $0.isComplete } - } + func hasCompletedPack(for layer: OfflineMapLayer) -> Bool { + packs.contains { $0.layer == layer && $0.isComplete } + } - func hasCompletedPack(for layer: OfflineMapLayer, overlapping viewport: MLNCoordinateBounds) -> Bool { - packs.contains { pack in - pack.layer == layer && pack.isComplete && pack.bounds.map { $0.overlaps(viewport) } ?? false - } + func hasCompletedPack(for layer: OfflineMapLayer, overlapping viewport: MLNCoordinateBounds) -> Bool { + packs.contains { pack in + pack.layer == layer && pack.isComplete && pack.bounds.map { $0.overlaps(viewport) } ?? false } - - func loadPacks() { - let mlnPacks = MLNOfflineStorage.shared.packs ?? [] - let currentIDs = Set(mlnPacks.map { ObjectIdentifier($0) }) - let now = ContinuousClock.now - - // Remove tracking data for deleted packs - highWaterMarks = highWaterMarks.filter { currentIDs.contains($0.key) } - byteSnapshots = byteSnapshots.filter { currentIDs.contains($0.key) } - downloadSpeeds = downloadSpeeds.filter { currentIDs.contains($0.key) } - metadataCache = metadataCache.filter { currentIDs.contains($0.key) } - - packs = mlnPacks.map { mlnPack in - let packID = ObjectIdentifier(mlnPack) - let previousFraction = highWaterMarks[packID] ?? 0 - let currentBytes = mlnPack.progress.countOfBytesCompleted - - if let previous = byteSnapshots[packID] { - let elapsed = now - previous.time - let seconds = elapsed / .seconds(1) - if seconds > 0.5, currentBytes > previous.bytes { - downloadSpeeds[packID] = Int64(Double(currentBytes - previous.bytes) / seconds) - } else if currentBytes == previous.bytes { - downloadSpeeds[packID] = 0 - } - } - byteSnapshots[packID] = (bytes: currentBytes, time: now) - - let speed = downloadSpeeds[packID] - if metadataCache[packID] == nil { - metadataCache[packID] = try? JSONDecoder().decode(OfflinePackMetadata.self, from: mlnPack.context) - } - let pack = OfflinePack(pack: mlnPack, metadata: metadataCache[packID] ?? nil, previousFraction: previousFraction, downloadSpeed: speed) - highWaterMarks[packID] = pack.completedFraction - return pack - } - - for mlnPack in mlnPacks where mlnPack.state == .unknown { - mlnPack.requestProgress() + } + + func loadPacks() { + let mlnPacks = MLNOfflineStorage.shared.packs ?? [] + let currentIDs = Set(mlnPacks.map { ObjectIdentifier($0) }) + let now = ContinuousClock.now + + // Remove tracking data for deleted packs + highWaterMarks = highWaterMarks.filter { currentIDs.contains($0.key) } + byteSnapshots = byteSnapshots.filter { currentIDs.contains($0.key) } + downloadSpeeds = downloadSpeeds.filter { currentIDs.contains($0.key) } + metadataCache = metadataCache.filter { currentIDs.contains($0.key) } + + packs = mlnPacks.map { mlnPack in + let packID = ObjectIdentifier(mlnPack) + let previousFraction = highWaterMarks[packID] ?? 0 + let currentBytes = mlnPack.progress.countOfBytesCompleted + + if let previous = byteSnapshots[packID] { + let elapsed = now - previous.time + let seconds = elapsed / .seconds(1) + if seconds > 0.5, currentBytes > previous.bytes { + downloadSpeeds[packID] = Int64(Double(currentBytes - previous.bytes) / seconds) + } else if currentBytes == previous.bytes { + downloadSpeeds[packID] = 0 } + } + byteSnapshots[packID] = (bytes: currentBytes, time: now) + + let speed = downloadSpeeds[packID] + if metadataCache[packID] == nil { + metadataCache[packID] = try? JSONDecoder().decode(OfflinePackMetadata.self, from: mlnPack.context) + } + let pack = OfflinePack(pack: mlnPack, metadata: metadataCache[packID] ?? nil, previousFraction: previousFraction, downloadSpeed: speed) + highWaterMarks[packID] = pack.completedFraction + return pack } - private func updateDatabaseSize() { - let url = MLNOfflineStorage.shared.databaseURL - let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 - databaseSize = Int64(size) + for mlnPack in mlnPacks where mlnPack.state == .unknown { + mlnPack.requestProgress() } - - /// Coalesces rapid progress notifications into a single `loadPacks()` call. - private func scheduleLoadPacks() { - pendingLoadTask?.cancel() - pendingLoadTask = Task { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { return } - loadPacks() - } - } - - // Note: active offline downloads share MapLibre's internal FIFO request queue - // with the interactive map renderer. Large downloads may degrade live map tile - // loading. Consider suspending packs during active map interaction if needed. - func downloadRegion( - name: String, - bounds: MLNCoordinateBounds, - layers: Set, - minZoom: Double = 10 - ) async throws { - let values = try URL.documentsDirectory.resourceValues( - forKeys: [.volumeAvailableCapacityForImportantUsageKey] - ) - if let available = values.volumeAvailableCapacityForImportantUsage, - available < Self.minimumDiskSpaceBytes { - throw OfflineMapError.insufficientDiskSpace - } - - let encoder = JSONEncoder() - let now = Date.now - - var pendingPacks: [(region: MLNTilePyramidOfflineRegion, context: Data)] = [] - for layer in layers { - guard let styleURL = layer.styleURL else { - throw OfflineMapError.missingStyleResource(layer) - } - let region = MLNTilePyramidOfflineRegion( - styleURL: styleURL, - bounds: bounds, - fromZoomLevel: minZoom, - toZoomLevel: layer.maxDownloadZoom - ) - let metadata = OfflinePackMetadata(name: name, createdAt: now, layer: layer) - let context = try encoder.encode(metadata) - pendingPacks.append((region, context)) - } - - for (region, context) in pendingPacks { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - MLNOfflineStorage.shared.addPack(for: region, withContext: context) { pack, error in - if let error { - continuation.resume(throwing: error) - } else { - pack?.resume() - continuation.resume() - } - } - } - } - loadPacks() - updateDatabaseSize() + } + + private func updateDatabaseSize() { + let url = MLNOfflineStorage.shared.databaseURL + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + databaseSize = Int64(size) + } + + /// Coalesces rapid progress notifications into a single `loadPacks()` call. + private func scheduleLoadPacks() { + pendingLoadTask?.cancel() + pendingLoadTask = Task { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + loadPacks() } - - func deletePack(_ pack: OfflinePack) async { - guard deletingPackIDs.insert(pack.id).inserted else { return } - defer { deletingPackIDs.remove(pack.id) } - - await withCheckedContinuation { continuation in - MLNOfflineStorage.shared.removePack(pack.mlnPack) { error in - if let error { - logger.error("Failed to delete offline pack: \(error.localizedDescription)") - } - continuation.resume() - } - } - highWaterMarks.removeValue(forKey: pack.id) - byteSnapshots.removeValue(forKey: pack.id) - downloadSpeeds.removeValue(forKey: pack.id) - loadPacks() - updateDatabaseSize() + } + + /// Note: active offline downloads share MapLibre's internal FIFO request queue + /// with the interactive map renderer. Large downloads may degrade live map tile + /// loading. Consider suspending packs during active map interaction if needed. + func downloadRegion( + name: String, + bounds: MLNCoordinateBounds, + layers: Set, + minZoom: Double = 10 + ) async throws { + let values = try URL.documentsDirectory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ) + if let available = values.volumeAvailableCapacityForImportantUsage, + available < Self.minimumDiskSpaceBytes { + throw OfflineMapError.insufficientDiskSpace } - func pausePack(_ pack: OfflinePack) { - userPausedPackIDs.insert(pack.id) - pack.mlnPack.suspend() - loadPacks() + let encoder = JSONEncoder() + let now = Date.now + + var pendingPacks: [(region: MLNTilePyramidOfflineRegion, context: Data)] = [] + for layer in layers { + guard let styleURL = layer.styleURL else { + throw OfflineMapError.missingStyleResource(layer) + } + let region = MLNTilePyramidOfflineRegion( + styleURL: styleURL, + bounds: bounds, + fromZoomLevel: minZoom, + toZoomLevel: layer.maxDownloadZoom + ) + let metadata = OfflinePackMetadata(name: name, createdAt: now, layer: layer) + let context = try encoder.encode(metadata) + pendingPacks.append((region, context)) } - func resumePack(_ pack: OfflinePack) { - userPausedPackIDs.remove(pack.id) - pack.mlnPack.resume() - loadPacks() + for (region, context) in pendingPacks { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + MLNOfflineStorage.shared.addPack(for: region, withContext: context) { pack, error in + if let error { + continuation.resume(throwing: error) + } else { + pack?.resume() + continuation.resume() + } + } + } } - - func resumeAllPacks() { - for pack in MLNOfflineStorage.shared.packs ?? [] { - let packID = ObjectIdentifier(pack) - if pack.state == .inactive, !userPausedPackIDs.contains(packID) { - pack.resume() - } + loadPacks() + updateDatabaseSize() + } + + func deletePack(_ pack: OfflinePack) async { + guard deletingPackIDs.insert(pack.id).inserted else { return } + defer { deletingPackIDs.remove(pack.id) } + + await withCheckedContinuation { continuation in + MLNOfflineStorage.shared.removePack(pack.mlnPack) { error in + if let error { + logger.error("Failed to delete offline pack: \(error.localizedDescription)") } - loadPacks() + continuation.resume() + } } - - func clearLastPackError() { - lastPackError = nil + highWaterMarks.removeValue(forKey: pack.id) + byteSnapshots.removeValue(forKey: pack.id) + downloadSpeeds.removeValue(forKey: pack.id) + loadPacks() + updateDatabaseSize() + } + + func pausePack(_ pack: OfflinePack) { + userPausedPackIDs.insert(pack.id) + pack.mlnPack.suspend() + loadPacks() + } + + func resumePack(_ pack: OfflinePack) { + userPausedPackIDs.remove(pack.id) + pack.mlnPack.resume() + loadPacks() + } + + func resumeAllPacks() { + for pack in MLNOfflineStorage.shared.packs ?? [] { + let packID = ObjectIdentifier(pack) + if pack.state == .inactive, !userPausedPackIDs.contains(packID) { + pack.resume() + } + } + loadPacks() + } + + func clearLastPackError() { + lastPackError = nil + } + + /// Estimated download size using per-zoom average byte sizes. + nonisolated static func estimatedDownloadSize( + bounds: MLNCoordinateBounds, + minZoom: Int, + maxZoom: Int, + layer: OfflineMapLayer = .base + ) -> Int64 { + let bytesPerTile: [Int: Int64] = switch layer { + case .base: + // OpenFreeMap vector tiles (OpenMapTiles schema, max z14). + // Populated land regions average 30-150 KB per tile at these zooms. + [ + 10: 15000, 11: 25000, 12: 45000, + 13: 70000, 14: 100_000, + ] + case .topo: + // OpenTopoMap PNG raster tiles (256px, max z17). + [ + 10: 15000, 11: 18000, 12: 22000, + 13: 25000, 14: 30000, 15: 35000, + 16: 40000, 17: 45000, + ] } - /// Estimated download size using per-zoom average byte sizes. - nonisolated static func estimatedDownloadSize( - bounds: MLNCoordinateBounds, - minZoom: Int, - maxZoom: Int, - layer: OfflineMapLayer = .base - ) -> Int64 { - let bytesPerTile: [Int: Int64] - switch layer { - case .base: - // OpenFreeMap vector tiles (OpenMapTiles schema, max z14). - // Populated land regions average 30-150 KB per tile at these zooms. - bytesPerTile = [ - 10: 15_000, 11: 25_000, 12: 45_000, - 13: 70_000, 14: 100_000, - ] - case .topo: - // OpenTopoMap PNG raster tiles (256px, max z17). - bytesPerTile = [ - 10: 15_000, 11: 18_000, 12: 22_000, - 13: 25_000, 14: 30_000, 15: 35_000, - 16: 40_000, 17: 45_000, - ] - } - - // Non-tile resources: style JSON, TileJSON manifests, sprites, glyph PBFs - let overhead: Int64 = 500_000 + // Non-tile resources: style JSON, TileJSON manifests, sprites, glyph PBFs + let overhead: Int64 = 500_000 - var total: Int64 = 0 - for z in minZoom...maxZoom { - let n = Double(1 << z) - let xMin = Int(floor((bounds.sw.longitude + 180) / 360 * n)) - let xMax = Int(floor((bounds.ne.longitude + 180) / 360 * n)) + var total: Int64 = 0 + for z in minZoom...maxZoom { + let n = Double(1 << z) + let xMin = Int(floor((bounds.sw.longitude + 180) / 360 * n)) + let xMax = Int(floor((bounds.ne.longitude + 180) / 360 * n)) - let latRadNE = bounds.ne.latitude * .pi / 180 - let latRadSW = bounds.sw.latitude * .pi / 180 - let yMin = Int(floor((1 - log(tan(latRadNE) + 1 / cos(latRadNE)) / .pi) / 2 * n)) - let yMax = Int(floor((1 - log(tan(latRadSW) + 1 / cos(latRadSW)) / .pi) / 2 * n)) + let latRadNE = bounds.ne.latitude * .pi / 180 + let latRadSW = bounds.sw.latitude * .pi / 180 + let yMin = Int(floor((1 - log(tan(latRadNE) + 1 / cos(latRadNE)) / .pi) / 2 * n)) + let yMax = Int(floor((1 - log(tan(latRadSW) + 1 / cos(latRadSW)) / .pi) / 2 * n)) - let tileCount = (abs(xMax - xMin) + 1) * (abs(yMax - yMin) + 1) - total += Int64(tileCount) * (bytesPerTile[z] ?? 10_000) - } - return total + overhead + let tileCount = (abs(xMax - xMin) + 1) * (abs(yMax - yMin) + 1) + total += Int64(tileCount) * (bytesPerTile[z] ?? 10000) } - - private func excludeDatabaseFromBackup() { - var url = MLNOfflineStorage.shared.databaseURL - var values = URLResourceValues() - values.isExcludedFromBackup = true - do { - try url.setResourceValues(values) - } catch { - logger.error("Failed to exclude offline database from backup: \(error.localizedDescription)") - } + return total + overhead + } + + private func excludeDatabaseFromBackup() { + var url = MLNOfflineStorage.shared.databaseURL + var values = URLResourceValues() + values.isExcludedFromBackup = true + do { + try url.setResourceValues(values) + } catch { + logger.error("Failed to exclude offline database from backup: \(error.localizedDescription)") } + } } struct OfflinePack: Identifiable { - let id: ObjectIdentifier - fileprivate let mlnPack: MLNOfflinePack - let name: String - let createdAt: Date? - let layer: OfflineMapLayer - let completedFraction: Double - let downloadSpeed: Int64? - let bounds: MLNCoordinateBounds? - - private let progress: MLNOfflinePackProgress - private let state: MLNOfflinePackState - - var completedBytes: UInt64 { progress.countOfBytesCompleted } - var isComplete: Bool { state == .complete } - var isPaused: Bool { state == .inactive } - - init(pack: MLNOfflinePack, metadata: OfflinePackMetadata?, previousFraction: Double = 0, downloadSpeed: Int64? = nil) { - self.id = ObjectIdentifier(pack) - self.mlnPack = pack - self.progress = pack.progress - self.state = pack.state - self.bounds = (pack.region as? MLNTilePyramidOfflineRegion)?.bounds - - let rawFraction: Double - if state == .complete { - rawFraction = 1 - } else if progress.countOfResourcesExpected > 0 { - rawFraction = Double(progress.countOfResourcesCompleted) / Double(progress.countOfResourcesExpected) - } else { - rawFraction = 0 - } - self.completedFraction = max(rawFraction, previousFraction) - self.downloadSpeed = state == .active ? downloadSpeed : nil - - if let metadata { - self.name = metadata.name - self.createdAt = metadata.createdAt - self.layer = metadata.layer - } else { - self.name = L10n.Settings.OfflineMaps.unknownRegion - self.createdAt = nil - self.layer = .base - } + let id: ObjectIdentifier + fileprivate let mlnPack: MLNOfflinePack + let name: String + let createdAt: Date? + let layer: OfflineMapLayer + let completedFraction: Double + let downloadSpeed: Int64? + let bounds: MLNCoordinateBounds? + + private let progress: MLNOfflinePackProgress + private let state: MLNOfflinePackState + + var completedBytes: UInt64 { + progress.countOfBytesCompleted + } + + var isComplete: Bool { + state == .complete + } + + var isPaused: Bool { + state == .inactive + } + + init(pack: MLNOfflinePack, metadata: OfflinePackMetadata?, previousFraction: Double = 0, downloadSpeed: Int64? = nil) { + id = ObjectIdentifier(pack) + mlnPack = pack + progress = pack.progress + state = pack.state + bounds = (pack.region as? MLNTilePyramidOfflineRegion)?.bounds + + let rawFraction: Double = if state == .complete { + 1 + } else if progress.countOfResourcesExpected > 0 { + Double(progress.countOfResourcesCompleted) / Double(progress.countOfResourcesExpected) + } else { + 0 } + completedFraction = max(rawFraction, previousFraction) + self.downloadSpeed = state == .active ? downloadSpeed : nil + + if let metadata { + name = metadata.name + createdAt = metadata.createdAt + layer = metadata.layer + } else { + name = L10n.Settings.OfflineMaps.unknownRegion + createdAt = nil + layer = .base + } + } } extension MLNCoordinateBounds { - func overlaps(_ other: MLNCoordinateBounds) -> Bool { - sw.latitude <= other.ne.latitude - && ne.latitude >= other.sw.latitude - && sw.longitude <= other.ne.longitude - && ne.longitude >= other.sw.longitude - } + func overlaps(_ other: MLNCoordinateBounds) -> Bool { + sw.latitude <= other.ne.latitude + && ne.latitude >= other.sw.latitude + && sw.longitude <= other.ne.longitude + && ne.longitude >= other.sw.longitude + } } diff --git a/MC1/Services/RegionResolver.swift b/MC1/Services/RegionResolver.swift index 31d52cf1..abff570c 100644 --- a/MC1/Services/RegionResolver.swift +++ b/MC1/Services/RegionResolver.swift @@ -10,97 +10,98 @@ import OSLog /// `AppState.regionSelection`, not this object — so it is not `@Observable`. @MainActor final class RegionResolver { + static let locationTimeout: Duration = .seconds(5) + static let geocodeTimeout: Duration = .seconds(5) + static let cacheTTL: TimeInterval = 24 * 60 * 60 - static let locationTimeout: Duration = .seconds(5) - static let geocodeTimeout: Duration = .seconds(5) - static let cacheTTL: TimeInterval = 24 * 60 * 60 + private static let geocodingLocale = Locale(identifier: "en_US") + private static let countySuffix = " county" - private static let geocodingLocale = Locale(identifier: "en_US") - private static let countySuffix = " county" + private let logger = Logger(subsystem: "com.mc1", category: "RegionResolver") + private let location: LocationService + private let geocoder: any Geocoder + private var cache: [CacheKey: CachedResult] = [:] - private let logger = Logger(subsystem: "com.mc1", category: "RegionResolver") - private let location: LocationService - private let geocoder: any Geocoder - private var cache: [CacheKey: CachedResult] = [:] + init(location: LocationService, geocoder: any Geocoder = AppleGeocoder()) { + self.location = location + self.geocoder = geocoder + } - init(location: LocationService, geocoder: any Geocoder = AppleGeocoder()) { - self.location = location - self.geocoder = geocoder - } - - /// Returns a `RegionSelection` derived from the device's current location, or - /// nil for any failure (denied, timeout, no network, nil isoCountryCode). - /// Failure modes are silent — callers fall through to manual picker. - func resolve() async -> RegionSelection? { - guard location.isAuthorized else { return nil } - do { - let loc = try await location.requestCurrentLocation(timeout: Self.locationTimeout) - let key = CacheKey(loc) - if let cached = cache[key], cached.isFresh { return cached.value } + /// Returns a `RegionSelection` derived from the device's current location, or + /// nil for any failure (denied, timeout, no network, nil isoCountryCode). + /// Failure modes are silent — callers fall through to manual picker. + func resolve() async -> RegionSelection? { + guard location.isAuthorized else { return nil } + do { + let loc = try await location.requestCurrentLocation(timeout: Self.locationTimeout) + let key = CacheKey(loc) + if let cached = cache[key], cached.isFresh { return cached.value } - let result = try await reverseGeocode(loc) + let result = try await reverseGeocode(loc) - guard let countryCode = result?.countryCode else { return nil } + guard let countryCode = result?.countryCode else { return nil } - let normalize: (String) -> String = { - $0.trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .folding(options: .diacriticInsensitive, locale: Self.geocodingLocale) - } - let normalizedAdmin = result?.administrativeArea.map(normalize) - let normalizedCounty = result?.subAdministrativeArea - .map(normalize) - .map { $0.replacingOccurrences(of: Self.countySuffix, with: "") } + let normalize: (String) -> String = { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .folding(options: .diacriticInsensitive, locale: Self.geocodingLocale) + } + let normalizedAdmin = result?.administrativeArea.map(normalize) + let normalizedCounty = result?.subAdministrativeArea + .map(normalize) + .map { $0.replacingOccurrences(of: Self.countySuffix, with: "") } - let adminCode = RegionalAreas.matchSubdivision( - country: countryCode, normalized: normalizedAdmin - ) - let countyKey = RegionalAreas.matchCounty( - country: countryCode, state: adminCode, normalized: normalizedCounty - ) + let adminCode = RegionalAreas.matchSubdivision( + country: countryCode, normalized: normalizedAdmin + ) + let countyKey = RegionalAreas.matchCounty( + country: countryCode, state: adminCode, normalized: normalizedCounty + ) - let selection = RegionSelection( - countryCode: countryCode, - administrativeAreaCode: adminCode, - countyKey: countyKey, - source: .location - ) - cache[key] = CachedResult(value: selection, expiresAt: Date().addingTimeInterval(Self.cacheTTL)) - return selection - } catch { - logger.debug("Region resolution failed: \(error)") - return nil - } + let selection = RegionSelection( + countryCode: countryCode, + administrativeAreaCode: adminCode, + countyKey: countyKey, + source: .location + ) + cache[key] = CachedResult(value: selection, expiresAt: Date().addingTimeInterval(Self.cacheTTL)) + return selection + } catch { + logger.debug("Region resolution failed: \(error)") + return nil } + } - /// Bounds the geocoder call. The location request already has its own timeout via - /// `LocationService`; without a separate bound here, a slow geocode (offline, throttled - /// CLGeocoder) would leave the user staring at the resolving screen indefinitely. On - /// timeout the geocoder is cancelled and `TimeoutError` surfaces to `resolve()`'s catch, - /// which returns nil and falls through to the manual picker — matching every other - /// failure mode in this resolver. - private func reverseGeocode(_ loc: CLLocation) async throws -> GeocodeResult? { - try await withTimeout(Self.geocodeTimeout, operationName: "reverseGeocode") { [geocoder] in - try await withTaskCancellationHandler { - try await geocoder.reverseGeocode(loc, preferredLocale: Self.geocodingLocale) - } onCancel: { - geocoder.cancelGeocode() - } - } + /// Bounds the geocoder call. The location request already has its own timeout via + /// `LocationService`; without a separate bound here, a slow geocode (offline, throttled + /// CLGeocoder) would leave the user staring at the resolving screen indefinitely. On + /// timeout the geocoder is cancelled and `TimeoutError` surfaces to `resolve()`'s catch, + /// which returns nil and falls through to the manual picker — matching every other + /// failure mode in this resolver. + private func reverseGeocode(_ loc: CLLocation) async throws -> GeocodeResult? { + try await withTimeout(Self.geocodeTimeout, operationName: "reverseGeocode") { [geocoder] in + try await withTaskCancellationHandler { + try await geocoder.reverseGeocode(loc, preferredLocale: Self.geocodingLocale) + } onCancel: { + geocoder.cancelGeocode() + } } + } - private struct CacheKey: Hashable { - let lat: Int - let lng: Int - init(_ location: CLLocation) { - self.lat = Int(location.coordinate.latitude.rounded()) - self.lng = Int(location.coordinate.longitude.rounded()) - } + private struct CacheKey: Hashable { + let lat: Int + let lng: Int + init(_ location: CLLocation) { + lat = Int(location.coordinate.latitude.rounded()) + lng = Int(location.coordinate.longitude.rounded()) } + } - private struct CachedResult { - let value: RegionSelection - let expiresAt: Date - var isFresh: Bool { Date() < expiresAt } + private struct CachedResult { + let value: RegionSelection + let expiresAt: Date + var isFresh: Bool { + Date() < expiresAt } + } } diff --git a/MC1/State/AppState+DeviceActions.swift b/MC1/State/AppState+DeviceActions.swift index b1628287..ca588dec 100644 --- a/MC1/State/AppState+DeviceActions.swift +++ b/MC1/State/AppState+DeviceActions.swift @@ -5,169 +5,168 @@ import MC1Services // MARK: - Device Actions extension AppState { + /// Start device scan/pairing + func startDeviceScan() { + // Hide disconnected pill when starting new connection + connectionUI.hideDisconnectedPill() + // Clear any previous pairing failure state + connectionUI.failedPairingDeviceID = nil + connectionUI.isBusy = true + + Task { + defer { connectionUI.isBusy = false } + + do { + // pairNewDevice() triggers onConnectionReady callback on success + try await connectionManager.pairNewDevice() + await wireServicesIfConnected() - /// Start device scan/pairing - func startDeviceScan() { - // Hide disconnected pill when starting new connection - connectionUI.hideDisconnectedPill() - // Clear any previous pairing failure state - connectionUI.failedPairingDeviceID = nil - connectionUI.isBusy = true - - Task { - defer { connectionUI.isBusy = false } - - do { - // pairNewDevice() triggers onConnectionReady callback on success - try await connectionManager.pairNewDevice() - await wireServicesIfConnected() - - // If still in onboarding, navigate to region step; otherwise mark complete - if !onboarding.hasCompletedOnboarding { - onboarding.onboardingPath.append(.region) - } - } catch DevicePairingError.cancelled { - // User cancelled - no error - } catch DevicePairingError.alreadyInProgress { - // Picker is already showing - ignore - } catch let pairingError as PairingError { - connectionUI.presentPairingFailure(pairingError) - } catch { - connectionUI.presentConnectionFailure(message: error.userFacingMessage) - } + // If still in onboarding, navigate to region step; otherwise mark complete + if !onboarding.hasCompletedOnboarding { + onboarding.onboardingPath.append(.region) } + } catch DevicePairingError.cancelled { + // User cancelled - no error + } catch DevicePairingError.alreadyInProgress { + // Picker is already showing - ignore + } catch let pairingError as PairingError { + connectionUI.presentPairingFailure(pairingError) + } catch { + connectionUI.presentConnectionFailure(message: error.userFacingMessage) + } } + } - /// Remove a device that failed pairing (wrong PIN) and automatically retry - func removeFailedPairingAndRetry() { - guard let deviceID = connectionUI.failedPairingDeviceID else { return } + /// Remove a device that failed pairing (wrong PIN) and automatically retry + func removeFailedPairingAndRetry() { + guard let deviceID = connectionUI.failedPairingDeviceID else { return } - Task { - await connectionManager.removeFailedPairing(deviceID: deviceID) - connectionUI.failedPairingDeviceID = nil - // Set flag - View observing scenePhase will trigger startDeviceScan when active - connectionUI.shouldShowPickerOnForeground = true - } + Task { + await connectionManager.removeFailedPairing(deviceID: deviceID) + connectionUI.failedPairingDeviceID = nil + // Set flag - View observing scenePhase will trigger startDeviceScan when active + connectionUI.shouldShowPickerOnForeground = true } - - /// Retry connecting to the device that just failed without removing the bond. - /// Used for transient pairing failures where the bond is still good — radio out of range, - /// brief BLE flap, etc. Auth-failure paths route through `removeFailedPairingAndRetry` - /// because the bond itself needs to be torn down before retrying. - func retryFailedPairingConnect() async { - guard let deviceID = connectionUI.failedPairingDeviceID else { return } - connectionUI.isBusy = true - defer { connectionUI.isBusy = false } - - do { - try await connectionManager.connect(to: deviceID, forceReconnect: true) - connectionUI.failedPairingDeviceID = nil - await wireServicesIfConnected() - } catch BLEError.deviceConnectedToOtherApp { - connectionUI.failedPairingDeviceID = nil - connectionUI.presentPairingFailure(.deviceConnectedToOtherApp(deviceID: deviceID)) - } catch { - connectionUI.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: error)) - } + } + + /// Retry connecting to the device that just failed without removing the bond. + /// Used for transient pairing failures where the bond is still good — radio out of range, + /// brief BLE flap, etc. Auth-failure paths route through `removeFailedPairingAndRetry` + /// because the bond itself needs to be torn down before retrying. + func retryFailedPairingConnect() async { + guard let deviceID = connectionUI.failedPairingDeviceID else { return } + connectionUI.isBusy = true + defer { connectionUI.isBusy = false } + + do { + try await connectionManager.connect(to: deviceID, forceReconnect: true) + connectionUI.failedPairingDeviceID = nil + await wireServicesIfConnected() + } catch BLEError.deviceConnectedToOtherApp { + connectionUI.failedPairingDeviceID = nil + connectionUI.presentPairingFailure(.deviceConnectedToOtherApp(deviceID: deviceID)) + } catch { + connectionUI.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: error)) } + } - /// Called by View when scenePhase becomes active and shouldShowPickerOnForeground is true - func handleBecameActive() { - if connectionUI.shouldShowPickerOnForeground { - connectionUI.shouldShowPickerOnForeground = false - startDeviceScan() - } + /// Called by View when scenePhase becomes active and shouldShowPickerOnForeground is true + func handleBecameActive() { + if connectionUI.shouldShowPickerOnForeground { + connectionUI.shouldShowPickerOnForeground = false + startDeviceScan() + } - activeRecoveryFallbackTask?.cancel() - activeRecoveryFallbackTask = Task { @MainActor [weak self] in - guard let self else { return } - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { return } - guard self.connectionState == .disconnected, - self.connectionManager.lastConnectedDeviceID != nil else { return } + activeRecoveryFallbackTask?.cancel() + activeRecoveryFallbackTask = Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + guard connectionState == .disconnected, + connectionManager.lastConnectedDeviceID != nil else { return } - self.logger.info("[BLE] Active fallback: disconnected after activation, running foreground reconciliation") - await self.handleReturnToForeground() - } + logger.info("[BLE] Active fallback: disconnected after activation, running foreground reconciliation") + await handleReturnToForeground() } - - /// Disconnect from device - /// - Parameter reason: The reason for disconnecting (for debugging) - func disconnect(reason: DisconnectReason = .userInitiated) async { - await connectionManager.disconnect(reason: reason) - await liveActivityManager.endActivity() - // Explicit disconnect does not fire onConnectionLost, so run the same - // per-session teardown the loss path performs in wireServicesIfConnected. - tearDownAppStateSessionState() + } + + /// Disconnect from device + /// - Parameter reason: The reason for disconnecting (for debugging) + func disconnect(reason: DisconnectReason = .userInitiated) async { + await connectionManager.disconnect(reason: reason) + await liveActivityManager.endActivity() + // Explicit disconnect does not fire onConnectionLost, so run the same + // per-session teardown the loss path performs in wireServicesIfConnected. + tearDownAppStateSessionState() + } + + /// Connect to a device via WiFi/TCP + func connectViaWiFi(host: String, port: UInt16, forceFullSync: Bool = false) async throws { + // Hide disconnected pill when starting new connection + connectionUI.hideDisconnectedPill() + try await connectionManager.connectViaWiFi(host: host, port: port, forceFullSync: forceFullSync) + await wireServicesIfConnected() + } + + // MARK: - Advertising + + /// Broadcast a self-advertisement from the connected radio, refreshing GPS + /// location first when the device's policy and the user's per-device + /// preference allow it. `allowLocationPrompt` is false from the App Intent: a + /// background Siri context cannot present a location-permission dialog, so the + /// refresh there uses only already-authorized location instead of stalling on + /// a prompt that can never resolve. + func sendSelfAdvert(flood: Bool, allowLocationPrompt: Bool = true) async throws { + if let source = advertGPSSource(device: connectedDevice, store: DevicePreferenceStore()) { + await updateLocationFromGPS(source: source, allowLocationPrompt: allowLocationPrompt) } - - /// Connect to a device via WiFi/TCP - func connectViaWiFi(host: String, port: UInt16, forceFullSync: Bool = false) async throws { - // Hide disconnected pill when starting new connection - connectionUI.hideDisconnectedPill() - try await connectionManager.connectViaWiFi(host: host, port: port, forceFullSync: forceFullSync) - await wireServicesIfConnected() + guard let advertisementService = services?.advertisementService else { + throw AdvertisementError.notConnected } - - // MARK: - Advertising - - /// Broadcast a self-advertisement from the connected radio, refreshing GPS - /// location first when the device's policy and the user's per-device - /// preference allow it. `allowLocationPrompt` is false from the App Intent: a - /// background Siri context cannot present a location-permission dialog, so the - /// refresh there uses only already-authorized location instead of stalling on - /// a prompt that can never resolve. - func sendSelfAdvert(flood: Bool, allowLocationPrompt: Bool = true) async throws { - if let source = advertGPSSource(device: connectedDevice, store: DevicePreferenceStore()) { - await updateLocationFromGPS(source: source, allowLocationPrompt: allowLocationPrompt) - } - guard let advertisementService = services?.advertisementService else { - throw AdvertisementError.notConnected - } - try await advertisementService.sendSelfAdvertisement(flood: flood) + try await advertisementService.sendSelfAdvertisement(flood: flood) + } + + /// The GPS source to refresh before an advert, or nil when the device's advert + /// policy or the user's per-device preference disables auto-update. Pure over + /// its inputs so the privacy gate is testable with an injected store. + func advertGPSSource(device: DeviceDTO?, store: DevicePreferenceStore) -> GPSSource? { + guard let device, + device.sharesLocationPublicly, + store.isAutoUpdateLocationEnabled(deviceID: device.id) else { + return nil } - - /// The GPS source to refresh before an advert, or nil when the device's advert - /// policy or the user's per-device preference disables auto-update. Pure over - /// its inputs so the privacy gate is testable with an injected store. - func advertGPSSource(device: DeviceDTO?, store: DevicePreferenceStore) -> GPSSource? { - guard let device, - device.sharesLocationPublicly, - store.isAutoUpdateLocationEnabled(deviceID: device.id) else { - return nil + return store.gpsSource(deviceID: device.id) + } + + private func updateLocationFromGPS(source: GPSSource, allowLocationPrompt: Bool) async { + let settingsService = services?.settingsService + do { + switch source { + case .phone: + let location: CLLocation + if allowLocationPrompt || locationService.isAuthorized { + do { + location = try await locationService.requestCurrentLocation() + } catch { + guard let cached = locationService.currentLocation else { throw error } + location = cached + } + } else { + guard let cached = locationService.currentLocation else { return } + location = cached } - return store.gpsSource(deviceID: device.id) - } - - private func updateLocationFromGPS(source: GPSSource, allowLocationPrompt: Bool) async { - let settingsService = services?.settingsService - do { - switch source { - case .phone: - let location: CLLocation - if allowLocationPrompt || locationService.isAuthorized { - do { - location = try await locationService.requestCurrentLocation() - } catch { - guard let cached = locationService.currentLocation else { throw error } - location = cached - } - } else { - guard let cached = locationService.currentLocation else { return } - location = cached - } - _ = try await settingsService?.setLocationVerified( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude - ) - case .device: - let gpsState = try await settingsService?.getDeviceGPSState() - if gpsState?.isEnabled != true { - _ = try await settingsService?.setDeviceGPSEnabledVerified(true) - } - } - } catch { - logger.warning("Failed to update location from GPS: \(error.localizedDescription)") + _ = try await settingsService?.setLocationVerified( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + case .device: + let gpsState = try await settingsService?.getDeviceGPSState() + if gpsState?.isEnabled != true { + _ = try await settingsService?.setDeviceGPSEnabledVerified(true) } + } + } catch { + logger.warning("Failed to update location from GPS: \(error.localizedDescription)") } + } } diff --git a/MC1/State/AppState+Lifecycle.swift b/MC1/State/AppState+Lifecycle.swift index 27da780a..3d699286 100644 --- a/MC1/State/AppState+Lifecycle.swift +++ b/MC1/State/AppState+Lifecycle.swift @@ -4,102 +4,101 @@ import MC1Services // MARK: - App Lifecycle extension AppState { - - private enum BLELifecycleTransition { - case enterBackground - case becomeActive - } - - @discardableResult - private func enqueueBLELifecycleTransition(_ transition: BLELifecycleTransition) -> Task { - let priorTask = bleLifecycleTransitionTask - let manager = connectionManager - - let transitionTask = Task { @MainActor in - await priorTask?.value - -#if DEBUG - switch transition { - case .enterBackground: - if let override = bleEnterBackgroundOverride { - await override() - return - } - case .becomeActive: - if let override = bleBecomeActiveOverride { - await override() - return - } - } -#endif - - switch transition { - case .enterBackground: - await manager.appDidEnterBackground() - case .becomeActive: - await manager.appDidBecomeActive() - } + private enum BLELifecycleTransition { + case enterBackground + case becomeActive + } + + @discardableResult + private func enqueueBLELifecycleTransition(_ transition: BLELifecycleTransition) -> Task { + let priorTask = bleLifecycleTransitionTask + let manager = connectionManager + + let transitionTask = Task { @MainActor in + await priorTask?.value + + #if DEBUG + switch transition { + case .enterBackground: + if let override = bleEnterBackgroundOverride { + await override() + return + } + case .becomeActive: + if let override = bleBecomeActiveOverride { + await override() + return + } } - - bleLifecycleTransitionTask = transitionTask - return transitionTask + #endif + + switch transition { + case .enterBackground: + await manager.appDidEnterBackground() + case .becomeActive: + await manager.appDidBecomeActive() + } } - /// Called when app enters background - func handleEnterBackground() { - activeRecoveryFallbackTask?.cancel() - activeRecoveryFallbackTask = nil - - liveActivityManager.handleEnterBackground() + bleLifecycleTransitionTask = transitionTask + return transitionTask + } - // Keep battery polling alive when the live activity is visible on the lock screen - if !liveActivityManager.hasActiveActivity { - batteryMonitor.stop() - } + /// Called when app enters background + func handleEnterBackground() { + activeRecoveryFallbackTask?.cancel() + activeRecoveryFallbackTask = nil - // Stop room keepalives to save battery/bandwidth - Task { - await services?.remoteNodeService.stopAllKeepAlives() - } + liveActivityManager.handleEnterBackground() - // Queue BLE lifecycle transition so background/foreground hooks stay ordered. - enqueueBLELifecycleTransition(.enterBackground) + // Keep battery polling alive when the live activity is visible on the lock screen + if !liveActivityManager.hasActiveActivity { + batteryMonitor.stop() } - /// Called when app returns to foreground - func handleReturnToForeground() async { - // Update badge count from database - await services?.notificationService.updateBadgeCount() - - // Room keepalives are managed by RoomConversationView lifecycle - // (started on view appear, stopped on disappear, restarted via scenePhase) - - // Reconcile transport state first (WiFi check + BLE lifecycle transition, - // which internally fires checkBLEConnectionHealth) so any stale "connected" - // state gets cleaned up via - // handleConnectionLoss → onConnectionLost → liveActivityManager.handleConnectionLost - // before the Live Activity tries to validate or restart. - await connectionManager.checkWiFiConnectionHealth() - await enqueueBLELifecycleTransition(.becomeActive).value - - liveActivityManager.handleReturnToForeground() - await liveActivityManager.validateActivityState() - await restartLiveActivityIfMissing() - - // Check for expired ACKs - if connectionState == .ready { - try? await services?.messageService.checkExpiredAcks() - } + // Stop room keepalives to save battery/bandwidth + Task { + await services?.remoteNodeService.stopAllKeepAlives() + } - // Trigger resync if sync failed while connected - await connectionManager.checkSyncHealth() + // Queue BLE lifecycle transition so background/foreground hooks stay ordered. + enqueueBLELifecycleTransition(.enterBackground) + } + + /// Called when app returns to foreground + func handleReturnToForeground() async { + // Update badge count from database + await services?.notificationService.updateBadgeCount() + + // Room keepalives are managed by RoomConversationView lifecycle + // (started on view appear, stopped on disappear, restarted via scenePhase) + + // Reconcile transport state first (WiFi check + BLE lifecycle transition, + // which internally fires checkBLEConnectionHealth) so any stale "connected" + // state gets cleaned up via + // handleConnectionLoss → onConnectionLost → liveActivityManager.handleConnectionLost + // before the Live Activity tries to validate or restart. + await connectionManager.checkWiFiConnectionHealth() + await enqueueBLELifecycleTransition(.becomeActive).value + + liveActivityManager.handleReturnToForeground() + await liveActivityManager.validateActivityState() + await restartLiveActivityIfMissing() + + // Check for expired ACKs + if connectionState == .ready { + try? await services?.messageService.checkExpiredAcks() + } - // Check for missed battery thresholds and restart polling if connected - if let services { - await batteryMonitor.checkMissedBatteryThreshold(device: connectedDevice, services: services) - batteryMonitor.startRefreshLoop(services: services, device: connectedDevice) - } + // Trigger resync if sync failed while connected + await connectionManager.checkSyncHealth() - offlineMapService.resumeAllPacks() + // Check for missed battery thresholds and restart polling if connected + if let services { + await batteryMonitor.checkMissedBatteryThreshold(device: connectedDevice, services: services) + batteryMonitor.startRefreshLoop(services: services, device: connectedDevice) } + + offlineMapService.resumeAllPacks() + } } diff --git a/MC1/State/AppState+NotificationHandlers.swift b/MC1/State/AppState+NotificationHandlers.swift index fbd96509..94583320 100644 --- a/MC1/State/AppState+NotificationHandlers.swift +++ b/MC1/State/AppState+NotificationHandlers.swift @@ -4,50 +4,49 @@ import MC1Services // MARK: - Notification Handlers extension AppState { + /// Configure notification handlers once services are available. + /// The transaction scripts live in `NotificationActionHandler`; this + /// installs thin forwarders and injects the app-layer inputs. + func configureNotificationHandlers() { + guard let services else { return } + + // Navigation-related notification tap handlers (delegated to NavigationCoordinator) + navigation.configureNotificationHandlers( + notificationService: services.notificationService, + dataStore: services.dataStore, + connectedDevice: { [weak self] in self?.connectedDevice } + ) + + let handler = services.notificationActionHandler + handler.configure( + isConnectionReady: { [weak self] in self?.connectionState == .ready }, + localNodeName: { [weak self] in self?.connectedDevice?.nodeName } + ) + + services.notificationService.onQuickReply = { contactID, text in + await handler.handleQuickReply(contactID: contactID, text: text) + } + + services.notificationService.onChannelQuickReply = { radioID, channelIndex, text in + await handler.handleChannelQuickReply(radioID: radioID, channelIndex: channelIndex, text: text) + } - /// Configure notification handlers once services are available. - /// The transaction scripts live in `NotificationActionHandler`; this - /// installs thin forwarders and injects the app-layer inputs. - func configureNotificationHandlers() { - guard let services else { return } - - // Navigation-related notification tap handlers (delegated to NavigationCoordinator) - navigation.configureNotificationHandlers( - notificationService: services.notificationService, - dataStore: services.dataStore, - connectedDevice: { [weak self] in self?.connectedDevice } - ) - - let handler = services.notificationActionHandler - handler.configure( - isConnectionReady: { [weak self] in self?.connectionState == .ready }, - localNodeName: { [weak self] in self?.connectedDevice?.nodeName } - ) - - services.notificationService.onQuickReply = { contactID, text in - await handler.handleQuickReply(contactID: contactID, text: text) - } - - services.notificationService.onChannelQuickReply = { radioID, channelIndex, text in - await handler.handleChannelQuickReply(radioID: radioID, channelIndex: channelIndex, text: text) - } - - services.notificationService.onMarkAsRead = { contactID, messageID in - await handler.handleMarkAsRead(contactID: contactID, messageID: messageID) - } - - services.notificationService.onChannelMarkAsRead = { radioID, channelIndex, messageID in - await handler.handleChannelMarkAsRead(radioID: radioID, channelIndex: channelIndex, messageID: messageID) - } - - services.notificationService.onRoomMarkAsRead = { sessionID, messageID in - await handler.handleRoomMarkAsRead(sessionID: sessionID, messageID: messageID) - } + services.notificationService.onMarkAsRead = { contactID, messageID in + await handler.handleMarkAsRead(contactID: contactID, messageID: messageID) } - /// Handle posting a notification when someone reacts to the user's message - func handleReactionNotification(messageID: UUID) async { - guard let services else { return } - await services.notificationActionHandler.handleReactionNotification(messageID: messageID) + services.notificationService.onChannelMarkAsRead = { radioID, channelIndex, messageID in + await handler.handleChannelMarkAsRead(radioID: radioID, channelIndex: channelIndex, messageID: messageID) } + + services.notificationService.onRoomMarkAsRead = { sessionID, messageID in + await handler.handleRoomMarkAsRead(sessionID: sessionID, messageID: messageID) + } + } + + /// Handle posting a notification when someone reacts to the user's message + func handleReactionNotification(messageID: UUID) async { + guard let services else { return } + await services.notificationActionHandler.handleReactionNotification(messageID: messageID) + } } diff --git a/MC1/State/AppState+Wiring.swift b/MC1/State/AppState+Wiring.swift index f2d1e245..362181e5 100644 --- a/MC1/State/AppState+Wiring.swift +++ b/MC1/State/AppState+Wiring.swift @@ -4,179 +4,178 @@ import MC1Services // MARK: - Service Wiring Helpers extension AppState { - - /// Consume the sync coordinator's data event stream for SwiftUI observation - /// (actors don't participate in SwiftUI's observation system). - /// Message events are ignored here; `MessageEventDispatcher` owns them. - /// Re-subscribes per connection because `ServiceContainer` is rebuilt. - func wireSyncDataEvents(services: ServiceContainer) { - syncDataEventsTask?.cancel() - let events = services.syncCoordinator.dataEvents() - syncDataEventsTask = Task { [weak self] in - for await event in events { - guard let self else { return } - switch event { - case .contactsChanged: - self.contactsVersion += 1 - case .conversationsChanged: - self.refreshConversations() - case .directMessageReceived, .channelMessageReceived, .roomMessageReceived, .reactionReceived: - break - } - } + /// Consume the sync coordinator's data event stream for SwiftUI observation + /// (actors don't participate in SwiftUI's observation system). + /// Message events are ignored here; `MessageEventDispatcher` owns them. + /// Re-subscribes per connection because `ServiceContainer` is rebuilt. + func wireSyncDataEvents(services: ServiceContainer) { + syncDataEventsTask?.cancel() + let events = services.syncCoordinator.dataEvents() + syncDataEventsTask = Task { [weak self] in + for await event in events { + guard let self else { return } + switch event { + case .contactsChanged: + contactsVersion += 1 + case .conversationsChanged: + refreshConversations() + case .directMessageReceived, .channelMessageReceived, .roomMessageReceived, .reactionReceived: + break } + } } + } - /// Consume settings service event stream. - /// Updates connectedDevice when settings are changed via SettingsService. - func wireSettingsEventStream(services: ServiceContainer) async { - settingsEventsTask?.cancel() - let events = await services.settingsService.events() - settingsEventsTask = Task { [weak self] in - for await event in events { - guard let self else { return } - switch event { - case .deviceUpdated(let selfInfo): - await MainActor.run { - self.connectionManager.updateDevice(from: selfInfo) - } - case .autoAddConfigUpdated(let config): - await MainActor.run { - self.connectionManager.updateAutoAddConfig(config) - // Clear storage full flag when overwrite oldest is enabled - if config.bitmask & AutoAddConfig.overwriteOldestBit != 0 { - self.connectionUI.isNodeStorageFull = false - } - } - case .clientRepeatUpdated(let enabled): - await MainActor.run { - self.connectionManager.updateClientRepeat(enabled) - } - case .pathHashModeUpdated(let mode): - await MainActor.run { - self.connectionManager.updatePathHashMode(mode) - } - case .allowedRepeatFreqUpdated(let ranges): - await MainActor.run { - self.connectionManager.allowedRepeatFreqRanges = ranges - } - case .defaultFloodScopeUpdated(let name): - await MainActor.run { - self.connectionManager.updateDefaultFloodScopeName(name) - } - } + /// Consume settings service event stream. + /// Updates connectedDevice when settings are changed via SettingsService. + func wireSettingsEventStream(services: ServiceContainer) async { + settingsEventsTask?.cancel() + let events = await services.settingsService.events() + settingsEventsTask = Task { [weak self] in + for await event in events { + guard let self else { return } + switch event { + case let .deviceUpdated(selfInfo): + await MainActor.run { + self.connectionManager.updateDevice(from: selfInfo) + } + case let .autoAddConfigUpdated(config): + await MainActor.run { + self.connectionManager.updateAutoAddConfig(config) + // Clear storage full flag when overwrite oldest is enabled + if config.bitmask & AutoAddConfig.overwriteOldestBit != 0 { + self.connectionUI.isNodeStorageFull = false } + } + case let .clientRepeatUpdated(enabled): + await MainActor.run { + self.connectionManager.updateClientRepeat(enabled) + } + case let .pathHashModeUpdated(mode): + await MainActor.run { + self.connectionManager.updatePathHashMode(mode) + } + case let .allowedRepeatFreqUpdated(ranges): + await MainActor.run { + self.connectionManager.allowedRepeatFreqRanges = ranges + } + case let .defaultFloodScopeUpdated(name): + await MainActor.run { + self.connectionManager.updateDefaultFloodScopeName(name) + } } + } } + } - /// Wire device update and contact change callbacks. - /// Updates connectedDevice when local device settings (like OCV) are changed via DeviceService, - /// and handles contact updates/deletions for real-time Discover page updates. - func wireDeviceUpdateCallbacks(services: ServiceContainer) async { - await services.deviceService.setDeviceUpdateCallback { [weak self] deviceDTO in - await MainActor.run { - self?.connectionManager.updateDevice(with: deviceDTO) - } - } + /// Wire device update and contact change callbacks. + /// Updates connectedDevice when local device settings (like OCV) are changed via DeviceService, + /// and handles contact updates/deletions for real-time Discover page updates. + func wireDeviceUpdateCallbacks(services: ServiceContainer) async { + await services.deviceService.setDeviceUpdateCallback { [weak self] deviceDTO in + await MainActor.run { + self?.connectionManager.updateDevice(with: deviceDTO) + } + } - // Contact updates bump contactsVersion for real-time Discover page - // updates; contact-deleted cleanup removes notifications and refreshes - // the badge when the device auto-deletes a contact via 0x8F. - // Re-subscribes per connection because ServiceContainer is rebuilt. - advertisementEventsTask?.cancel() - let advertisementEvents = services.advertisementService.events() - advertisementEventsTask = Task { [weak self] in - for await event in advertisementEvents { - guard let self else { return } - switch event { - case .contactUpdated: - self.contactsVersion += 1 - PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - .info("B4 contactUpdated bump contactsVersion=\(self.contactsVersion)") - case .contactDeletedCleanup(let contactID, _): - self.logger.info("Overwrite oldest: running cleanup for deleted contact \(contactID) - removing notifications and updating badge") - await self.services?.notificationService.removeDeliveredNotifications(forContactID: contactID) - await self.services?.notificationService.updateBadgeCount() - case .newContactDiscovered, .contactSyncRequested, .nodeStorageFullChanged, - .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: - break - } - } + // Contact updates bump contactsVersion for real-time Discover page + // updates; contact-deleted cleanup removes notifications and refreshes + // the badge when the device auto-deletes a contact via 0x8F. + // Re-subscribes per connection because ServiceContainer is rebuilt. + advertisementEventsTask?.cancel() + let advertisementEvents = services.advertisementService.events() + advertisementEventsTask = Task { [weak self] in + for await event in advertisementEvents { + guard let self else { return } + switch event { + case .contactUpdated: + contactsVersion += 1 + PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + .info("B4 contactUpdated bump contactsVersion=\(contactsVersion)") + case let .contactDeletedCleanup(contactID, _): + logger.info("Overwrite oldest: running cleanup for deleted contact \(contactID) - removing notifications and updating badge") + await self.services?.notificationService.removeDeliveredNotifications(forContactID: contactID) + await self.services?.notificationService.updateBadgeCount() + case .newContactDiscovered, .contactSyncRequested, .nodeStorageFullChanged, + .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: + break } + } } + } - /// Wire the message event streams. Delegates to `MessageEventDispatcher`, - /// which subscribes to the service event streams and fans them out to - /// `messageEventStream` and `sessionStateChangeCount`. - func wireMessageEvents(services: ServiceContainer) { - messageEventDispatcher.wire(services: services) - } + /// Wire the message event streams. Delegates to `MessageEventDispatcher`, + /// which subscribes to the service event streams and fans them out to + /// `messageEventStream` and `sessionStateChangeCount`. + func wireMessageEvents(services: ServiceContainer) { + messageEventDispatcher.wire(services: services) + } - /// Wire Live Activity callbacks for RX freshness, battery, and connection lifecycle. - func wireLiveActivityCallbacks(services: ServiceContainer) async { - // Every received RF packet refreshes Live Activity freshness and may - // trigger an overdue battery read. Re-subscribes per connection - // because ServiceContainer is rebuilt. - rxLogEventsTask?.cancel() - let rxLogEntries = services.rxLogService.entryStream() - rxLogEventsTask = Task { [weak self] in - for await _ in rxLogEntries { - guard let self else { return } - await self.liveActivityManager.handlePacketReceived() - if self.liveActivityManager.hasActiveActivity { - await self.batteryMonitor.fetchBatteryIfOverdue( - services: self.services, device: self.connectedDevice - ) - } - } + /// Wire Live Activity callbacks for RX freshness, battery, and connection lifecycle. + func wireLiveActivityCallbacks(services: ServiceContainer) async { + // Every received RF packet refreshes Live Activity freshness and may + // trigger an overdue battery read. Re-subscribes per connection + // because ServiceContainer is rebuilt. + rxLogEventsTask?.cancel() + let rxLogEntries = services.rxLogService.entryStream() + rxLogEventsTask = Task { [weak self] in + for await _ in rxLogEntries { + guard let self else { return } + await liveActivityManager.handlePacketReceived() + if liveActivityManager.hasActiveActivity { + await batteryMonitor.fetchBatteryIfOverdue( + services: self.services, device: connectedDevice + ) } + } + } - batteryMonitor.onBatteryChanged = { [weak self] battery in - Task { @MainActor [weak self] in - await self?.liveActivityManager.handleBatteryChanged(battery: battery) - } - } + batteryMonitor.onBatteryChanged = { [weak self] battery in + Task { @MainActor [weak self] in + await self?.liveActivityManager.handleBatteryChanged(battery: battery) + } + } - let device = connectedDevice - let ocvArray = batteryMonitor.activeBatteryOCVArray(for: device) - let unreadCount = await totalUnreadCount(from: services) + let device = connectedDevice + let ocvArray = batteryMonitor.activeBatteryOCVArray(for: device) + let unreadCount = await totalUnreadCount(from: services) - if let device { - await liveActivityManager.handleConnectionReady( - device: device, - ocvArray: ocvArray, - unreadCount: unreadCount - ) - } + if let device { + await liveActivityManager.handleConnectionReady( + device: device, + ocvArray: ocvArray, + unreadCount: unreadCount + ) } + } - /// `Activity.request` only succeeds in the foreground, so a `startActivity` - /// triggered from background (e.g. BLE auto-reconnect after the disconnect - /// grace timer fired) throws and leaves `currentActivity` nil. iOS may also - /// end an activity from background — 8-hour active cap, 12-hour total, or - /// memory pressure — in which case the `.dismissed` branch in - /// `validateActivityState` clears the reference without restarting. Both - /// leave the radio connected with no LA on screen. - func restartLiveActivityIfMissing() async { - guard connectionState.isConnected, - liveActivityManager.isEnabled, - !liveActivityManager.hasActiveActivity, - let device = connectedDevice, - let services else { return } + /// `Activity.request` only succeeds in the foreground, so a `startActivity` + /// triggered from background (e.g. BLE auto-reconnect after the disconnect + /// grace timer fired) throws and leaves `currentActivity` nil. iOS may also + /// end an activity from background — 8-hour active cap, 12-hour total, or + /// memory pressure — in which case the `.dismissed` branch in + /// `validateActivityState` clears the reference without restarting. Both + /// leave the radio connected with no LA on screen. + func restartLiveActivityIfMissing() async { + guard connectionState.isConnected, + liveActivityManager.isEnabled, + !liveActivityManager.hasActiveActivity, + let device = connectedDevice, + let services else { return } - let ocvArray = batteryMonitor.activeBatteryOCVArray(for: device) - let unreadCount = await totalUnreadCount(from: services) - await liveActivityManager.handleConnectionReady( - device: device, - ocvArray: ocvArray, - unreadCount: unreadCount - ) - } + let ocvArray = batteryMonitor.activeBatteryOCVArray(for: device) + let unreadCount = await totalUnreadCount(from: services) + await liveActivityManager.handleConnectionReady( + device: device, + ocvArray: ocvArray, + unreadCount: unreadCount + ) + } - func totalUnreadCount(from services: ServiceContainer) async -> Int { - guard let radioID = currentRadioID else { return 0 } - let counts = (try? await services.dataStore.getTotalUnreadCounts(radioID: radioID)) - ?? (contacts: 0, channels: 0, rooms: 0) - return counts.contacts + counts.channels + counts.rooms - } + func totalUnreadCount(from services: ServiceContainer) async -> Int { + guard let radioID = currentRadioID else { return 0 } + let counts = await (try? services.dataStore.getTotalUnreadCounts(radioID: radioID)) + ?? (contacts: 0, channels: 0, rooms: 0) + return counts.contacts + counts.channels + counts.rooms + } } diff --git a/MC1/State/AppState.swift b/MC1/State/AppState.swift index e628b57b..c3989841 100644 --- a/MC1/State/AppState.swift +++ b/MC1/State/AppState.swift @@ -1,11 +1,11 @@ import CoreLocation -import SwiftUI -import SwiftData -import UserNotifications import MC1Services import MeshCore import OSLog +import SwiftData +import SwiftUI import TipKit +import UserNotifications /// Simplified app-wide state management. /// Composes ConnectionManager for connection lifecycle. @@ -13,641 +13,635 @@ import TipKit @Observable @MainActor final class AppState { + // MARK: - Logging - // MARK: - Logging + let logger = Logger(subsystem: "com.mc1", category: "AppState") - let logger = Logger(subsystem: "com.mc1", category: "AppState") + // MARK: - Location - // MARK: - Location + /// App-wide location service for permission management + let locationService = LocationService() - /// App-wide location service for permission management - let locationService = LocationService() + // MARK: - Offline Maps - // MARK: - Offline Maps + /// Offline map pack management and network monitoring + let offlineMapService = OfflineMapService() - /// Offline map pack management and network monitoring - let offlineMapService = OfflineMapService() + // MARK: - Chat Drafts - // MARK: - Chat Drafts + /// Disk-backed store for unsent chat-composer text. Recreated on + /// before-first-unlock, but reads the same `UserDefaults` key, so the fresh + /// instance recovers every persisted draft — no in-memory-lifetime dependency. + let draftStore = DraftStore() - /// Disk-backed store for unsent chat-composer text. Recreated on - /// before-first-unlock, but reads the same `UserDefaults` key, so the fresh - /// instance recovers every persisted draft — no in-memory-lifetime dependency. - let draftStore = DraftStore() - - /// Best available location for proximity-based disambiguation. - var bestAvailableLocation: CLLocation? { - if let phoneLocation = locationService.currentLocation { - return phoneLocation - } - guard let device = connectedDevice, device.hasLocation else { - return nil - } - return CLLocation(latitude: device.latitude, longitude: device.longitude) + /// Best available location for proximity-based disambiguation. + var bestAvailableLocation: CLLocation? { + if let phoneLocation = locationService.currentLocation { + return phoneLocation } - - // MARK: - Region preference - - @ObservationIgnored lazy var regionResolver = RegionResolver(location: locationService) - - /// Suppresses the `regionSelection` `didSet` write-back during cold-start load. Without - /// this, `loadPersistedRegionSelection()` would re-encode the just-read JSON and rewrite - /// the same bytes to UserDefaults on every launch. - @ObservationIgnored private var suppressRegionPersist = false - - var regionSelection: RegionSelection? { - didSet { - guard !suppressRegionPersist else { return } - persistRegionSelection() - } - } - - private func persistRegionSelection() { - BackupUserDefaults.persistRegionSelection(regionSelection) + guard let device = connectedDevice, device.hasLocation else { + return nil } + return CLLocation(latitude: device.latitude, longitude: device.longitude) + } - private func loadPersistedRegionSelection() { - guard UserDefaults.standard.data(forKey: BackupUserDefaults.regionSelectionKey) != nil else { return } - guard let decoded = BackupUserDefaults.loadRegionSelection() else { - logger.warning("Failed to decode persisted region selection — clearing key") - UserDefaults.standard.removeObject(forKey: BackupUserDefaults.regionSelectionKey) - return - } - suppressRegionPersist = true - defer { suppressRegionPersist = false } - self.regionSelection = decoded - } - - // MARK: - Connection (via ConnectionManager) - - /// The connection manager for device lifecycle - let connectionManager: ConnectionManager - let storeState: StoreState - let themeService: ThemeService - private let bootstrapDebugLogBuffer: DebugLogBuffer - - // Convenience accessors - var connectionState: MC1Services.DeviceConnectionState { connectionManager.connectionState } - var connectedDevice: DeviceDTO? { connectionManager.connectedDevice } - var services: ServiceContainer? { connectionManager.services } - - /// Local node name with fallback for display purposes. - var localNodeName: String { connectedDevice?.nodeName ?? "Me" } - - /// The sync coordinator for data synchronization - private(set) var syncCoordinator: SyncCoordinator? + // MARK: - Region preference - /// Incremented when services change (device switch, reconnect). Views observe this to reload. - private(set) var servicesVersion: Int = 0 + @ObservationIgnored lazy var regionResolver = RegionResolver(location: locationService) - /// Identity of the `ServiceContainer` the last `servicesVersion` bump was for, - /// so redundant re-wires of the same container don't bump it again. - private var lastBumpedServicesID: ObjectIdentifier? + /// Suppresses the `regionSelection` `didSet` write-back during cold-start load. Without + /// this, `loadPersistedRegionSelection()` would re-encode the just-read JSON and rewrite + /// the same bytes to UserDefaults on every launch. + @ObservationIgnored private var suppressRegionPersist = false - // MARK: - Offline Data Access - - /// Per-conversation coordinator registry. Lives at AppState scope so - /// chat detail screens render stored messages while disconnected. The - /// registry's dataStore rebinds to services.dataStore on connect and is - /// torn down on services-left. - private(set) var chatCoordinatorRegistry: ChatCoordinatorRegistry? - - /// Cached standalone persistence store for offline browsing - private var cachedOfflineStore: PersistenceStore? - - /// Radio ID for data access - returns connected device's radio ID or last-connected radio ID for offline browsing - var currentRadioID: UUID? { - connectedDevice?.radioID ?? connectionManager.lastConnectedRadioID + var regionSelection: RegionSelection? { + didSet { + guard !suppressRegionPersist else { return } + persistRegionSelection() } - - /// Data store that works regardless of connection state - uses services when connected, - /// cached standalone store when disconnected - var offlineDataStore: PersistenceStore? { - if let services { - cachedOfflineStore = nil // Clear cache when services available - return services.dataStore - } - guard connectionManager.lastConnectedDeviceID != nil else { - cachedOfflineStore = nil - return nil - } - if cachedOfflineStore == nil { - cachedOfflineStore = connectionManager.createStandalonePersistenceStore() - } - return cachedOfflineStore + } + + private func persistRegionSelection() { + BackupUserDefaults.persistRegionSelection(regionSelection) + } + + private func loadPersistedRegionSelection() { + guard UserDefaults.standard.data(forKey: BackupUserDefaults.regionSelectionKey) != nil else { return } + guard let decoded = BackupUserDefaults.loadRegionSelection() else { + logger.warning("Failed to decode persisted region selection — clearing key") + UserDefaults.standard.removeObject(forKey: BackupUserDefaults.regionSelectionKey) + return } - - /// Ensures the chat coordinator registry exists, lazy-building one bound - /// to the offline data store if none has been built yet. Used by - /// `ChatViewModel` for the cold-launch-while-offline path where - /// `wireServicesIfConnected` has not yet run but - /// `connectionManager.lastConnectedDeviceID` is set so `offlineDataStore` - /// is non-nil. - func ensureChatCoordinatorRegistry() -> ChatCoordinatorRegistry? { - if let chatCoordinatorRegistry { return chatCoordinatorRegistry } - guard let store = offlineDataStore else { return nil } - chatCoordinatorRegistry = ChatCoordinatorRegistry(dataStore: store) - return chatCoordinatorRegistry + suppressRegionPersist = true + defer { suppressRegionPersist = false } + regionSelection = decoded + } + + // MARK: - Connection (via ConnectionManager) + + /// The connection manager for device lifecycle + let connectionManager: ConnectionManager + let storeState: StoreState + let themeService: ThemeService + private let bootstrapDebugLogBuffer: DebugLogBuffer + + /// Convenience accessors + var connectionState: MC1Services.DeviceConnectionState { + connectionManager.connectionState + } + + var connectedDevice: DeviceDTO? { + connectionManager.connectedDevice + } + + var services: ServiceContainer? { + connectionManager.services + } + + /// Local node name with fallback for display purposes. + var localNodeName: String { + connectedDevice?.nodeName ?? "Me" + } + + /// The sync coordinator for data synchronization + private(set) var syncCoordinator: SyncCoordinator? + + /// Incremented when services change (device switch, reconnect). Views observe this to reload. + private(set) var servicesVersion: Int = 0 + + /// Identity of the `ServiceContainer` the last `servicesVersion` bump was for, + /// so redundant re-wires of the same container don't bump it again. + private var lastBumpedServicesID: ObjectIdentifier? + + // MARK: - Offline Data Access + + /// Per-conversation coordinator registry. Lives at AppState scope so + /// chat detail screens render stored messages while disconnected. The + /// registry's dataStore rebinds to services.dataStore on connect and is + /// torn down on services-left. + private(set) var chatCoordinatorRegistry: ChatCoordinatorRegistry? + + /// Cached standalone persistence store for offline browsing + private var cachedOfflineStore: PersistenceStore? + + /// Radio ID for data access - returns connected device's radio ID or last-connected radio ID for offline browsing + var currentRadioID: UUID? { + connectedDevice?.radioID ?? connectionManager.lastConnectedRadioID + } + + /// Data store that works regardless of connection state - uses services when connected, + /// cached standalone store when disconnected + var offlineDataStore: PersistenceStore? { + if let services { + cachedOfflineStore = nil // Clear cache when services available + return services.dataStore } - - /// Incremented when contacts data changes. Views observe this to reload contact lists. - var contactsVersion: Int = 0 - - /// Incremented when conversations data changes. Views observe this to reload chat lists. - private(set) var conversationsVersion: Int = 0 - - /// Incremented when a remote-node session changes connection state. - /// `RoomConversationView` observes this counter via `.onChange` to refresh - /// its session DTO when re-authentication completes. - private(set) var sessionStateChangeCount: Int = 0 - - /// Called by `MessageEventDispatcher` when a remote-node session changes - /// connection state. Bundles refreshing conversations and bumping the - /// state-change counter so the dispatcher only needs one entry point. - func handleSessionStateChange() { - refreshConversations() - sessionStateChangeCount += 1 + guard connectionManager.lastConnectedDeviceID != nil else { + cachedOfflineStore = nil + return nil } - - /// Bumps `conversationsVersion` and drives - /// `liveActivityManager.handleUnreadCountChanged`. Used by both the sync - /// coordinator's data event stream and the session-state events - /// (`RemoteNodeEvent.sessionStateChanged` / `RoomServerEvent.connectionRecovered`) - /// to keep the conversations list and unread badge in lockstep. - func refreshConversations() { - conversationsVersion += 1 - Task { @MainActor [weak self] in - guard let self, let services = self.services else { return } - let total = await self.totalUnreadCount(from: services) - await self.liveActivityManager.handleUnreadCountChanged(unreadCount: total) - } + if cachedOfflineStore == nil { + cachedOfflineStore = connectionManager.createStandalonePersistenceStore() } - - /// Signals views observing `contactsVersion` / `conversationsVersion` to reload after - /// a backup restore writes directly to the persistence store. The normal sync-path - /// events don't fire for batch imports, so without this bump any currently-mounted - /// tabs keep showing their pre-restore snapshot until reconnect or relaunch. - /// Also re-reads the persisted region selection — the import wrote it to UserDefaults, - /// but `regionSelection` is only loaded once during `init`, so Settings → Region and - /// the radio-preset views would otherwise show pre-import data until next launch. - func notifyDataRestored() { - contactsVersion += 1 - conversationsVersion += 1 - loadPersistedRegionSelection() - themeService.refreshFromUserDefaults() + return cachedOfflineStore + } + + /// Ensures the chat coordinator registry exists, lazy-building one bound + /// to the offline data store if none has been built yet. Used by + /// `ChatViewModel` for the cold-launch-while-offline path where + /// `wireServicesIfConnected` has not yet run but + /// `connectionManager.lastConnectedDeviceID` is set so `offlineDataStore` + /// is non-nil. + func ensureChatCoordinatorRegistry() -> ChatCoordinatorRegistry? { + if let chatCoordinatorRegistry { return chatCoordinatorRegistry } + guard let store = offlineDataStore else { return nil } + chatCoordinatorRegistry = ChatCoordinatorRegistry(dataStore: store) + return chatCoordinatorRegistry + } + + /// Incremented when contacts data changes. Views observe this to reload contact lists. + var contactsVersion: Int = 0 + + /// Incremented when conversations data changes. Views observe this to reload chat lists. + private(set) var conversationsVersion: Int = 0 + + /// Incremented when a remote-node session changes connection state. + /// `RoomConversationView` observes this counter via `.onChange` to refresh + /// its session DTO when re-authentication completes. + private(set) var sessionStateChangeCount: Int = 0 + + /// Called by `MessageEventDispatcher` when a remote-node session changes + /// connection state. Bundles refreshing conversations and bumping the + /// state-change counter so the dispatcher only needs one entry point. + func handleSessionStateChange() { + refreshConversations() + sessionStateChangeCount += 1 + } + + /// Bumps `conversationsVersion` and drives + /// `liveActivityManager.handleUnreadCountChanged`. Used by both the sync + /// coordinator's data event stream and the session-state events + /// (`RemoteNodeEvent.sessionStateChanged` / `RoomServerEvent.connectionRecovered`) + /// to keep the conversations list and unread badge in lockstep. + func refreshConversations() { + conversationsVersion += 1 + Task { @MainActor [weak self] in + guard let self, let services else { return } + let total = await totalUnreadCount(from: services) + await liveActivityManager.handleUnreadCountChanged(unreadCount: total) } + } - // MARK: - Connection UI State + /// Signals views observing `contactsVersion` / `conversationsVersion` to reload after + /// a backup restore writes directly to the persistence store. The normal sync-path + /// events don't fire for batch imports, so without this bump any currently-mounted + /// tabs keep showing their pre-restore snapshot until reconnect or relaunch. + /// Also re-reads the persisted region selection — the import wrote it to UserDefaults, + /// but `regionSelection` is only loaded once during `init`, so Settings → Region and + /// the radio-preset views would otherwise show pre-import data until next launch. + func notifyDataRestored() { + contactsVersion += 1 + conversationsVersion += 1 + loadPersistedRegionSelection() + themeService.refreshFromUserDefaults() + } - /// Connection UI state (status pills, sync activity, alerts, pairing) - let connectionUI = ConnectionUIState() + // MARK: - Connection UI State - /// Battery monitoring (polling, thresholds, low-battery notifications) - let batteryMonitor = BatteryMonitor() + /// Connection UI state (status pills, sync activity, alerts, pairing) + let connectionUI = ConnectionUIState() - /// Live Activity lifecycle (start/update/stop on Lock Screen and Dynamic Island) - let liveActivityManager = LiveActivityManager() + /// Battery monitoring (polling, thresholds, low-battery notifications) + let batteryMonitor = BatteryMonitor() - /// Task chain that serializes BLE lifecycle transitions across scene-phase changes. - /// Do not cancel this task externally -- cancelling breaks the serialization - /// guarantee because Task.value returns immediately on cancellation. - var bleLifecycleTransitionTask: Task? + /// Live Activity lifecycle (start/update/stop on Lock Screen and Dynamic Island) + let liveActivityManager = LiveActivityManager() - /// Fallback task that re-runs foreground recovery shortly after activation when the - /// app is still disconnected. Covers edge cases where scene-phase callbacks are missed. - var activeRecoveryFallbackTask: Task? + /// Task chain that serializes BLE lifecycle transitions across scene-phase changes. + /// Do not cancel this task externally -- cancelling breaks the serialization + /// guarantee because Task.value returns immediately on cancellation. + var bleLifecycleTransitionTask: Task? - /// Task consuming SettingsService event stream, canceled on disconnect - var settingsEventsTask: Task? + /// Fallback task that re-runs foreground recovery shortly after activation when the + /// app is still disconnected. Covers edge cases where scene-phase callbacks are missed. + var activeRecoveryFallbackTask: Task? - /// Task consuming SyncCoordinator's data event stream, canceled on disconnect - var syncDataEventsTask: Task? + /// Task consuming SettingsService event stream, canceled on disconnect + var settingsEventsTask: Task? - /// Task consuming AdvertisementService's event stream, canceled on disconnect - var advertisementEventsTask: Task? + /// Task consuming SyncCoordinator's data event stream, canceled on disconnect + var syncDataEventsTask: Task? - /// Task consuming RxLogService's entry stream for Live Activity freshness, canceled on disconnect - var rxLogEventsTask: Task? + /// Task consuming AdvertisementService's event stream, canceled on disconnect + var advertisementEventsTask: Task? -#if DEBUG + /// Task consuming RxLogService's entry stream for Live Activity freshness, canceled on disconnect + var rxLogEventsTask: Task? + + #if DEBUG /// Optional test-only hooks for deterministic lifecycle ordering tests. var bleEnterBackgroundOverride: (@MainActor () async -> Void)? var bleBecomeActiveOverride: (@MainActor () async -> Void)? -#endif + #endif - // MARK: - Onboarding State + // MARK: - Onboarding State - /// Onboarding state (completion flag, navigation path) - let onboarding = OnboardingState() + /// Onboarding state (completion flag, navigation path) + let onboarding = OnboardingState() - // MARK: - What's New + // MARK: - What's New - /// What's New presentation gate (device-local baseline, pending release) - let whatsNew = WhatsNewState() + /// What's New presentation gate (device-local baseline, pending release) + let whatsNew = WhatsNewState() - // MARK: - Navigation State + // MARK: - Navigation State - /// Navigation coordinator (tab selection, pending targets, cross-tab navigation) - let navigation = NavigationCoordinator() + /// Navigation coordinator (tab selection, pending targets, cross-tab navigation) + let navigation = NavigationCoordinator() - // MARK: - UI Coordination + // MARK: - UI Coordination - /// AsyncStream-based distribution of `MessageEvent` to chat and room - /// consumers. Fed by the service event streams wired in `wireMessageEvents`. - let messageEventStream = MessageEventStream() + /// AsyncStream-based distribution of `MessageEvent` to chat and room + /// consumers. Fed by the service event streams wired in `wireMessageEvents`. + let messageEventStream = MessageEventStream() - /// Routes service event streams to `messageEventStream` and the - /// session-state counter. Lazy because it captures `self` and - /// `messageEventStream`. - @ObservationIgnored - lazy var messageEventDispatcher = MessageEventDispatcher( - appState: self, - stream: messageEventStream - ) + /// Routes service event streams to `messageEventStream` and the + /// session-state counter. Lazy because it captures `self` and + /// `messageEventStream`. + @ObservationIgnored + lazy var messageEventDispatcher = MessageEventDispatcher( + appState: self, + stream: messageEventStream + ) - // MARK: - CLI Tool + // MARK: - CLI Tool - /// Persistent CLI tool view model (survives tab switches, reset on device disconnect) - var cliToolViewModel: CLIToolViewModel? + /// Persistent CLI tool view model (survives tab switches, reset on device disconnect) + var cliToolViewModel: CLIToolViewModel? - /// Tracks the device ID for CLI state - reset CLI when device changes - private var lastConnectedDeviceIDForCLI: UUID? + /// Tracks the device ID for CLI state - reset CLI when device changes + private var lastConnectedDeviceIDForCLI: UUID? - // MARK: - Status Pill + // MARK: - Status Pill - /// The current status pill state, computed from all relevant conditions - /// Priority: failed > syncing > ready > connecting > disconnected > hidden - var statusPillState: StatusPillState { - if connectionUI.syncFailedPillVisible { - return .failed(message: L10n.Localizable.StatusPill.syncFailed) - } - if connectionUI.syncActivityCount > 0 || connectionState == .syncing { - return .syncing - } - if connectionUI.showReadyToast { - return .ready - } - if connectionState == .connecting { - return .connecting - } - if connectionUI.disconnectedPillVisible { - return .disconnected - } - return .hidden + /// The current status pill state, computed from all relevant conditions + /// Priority: failed > syncing > ready > connecting > disconnected > hidden + var statusPillState: StatusPillState { + if connectionUI.syncFailedPillVisible { + return .failed(message: L10n.Localizable.StatusPill.syncFailed) } - - /// Whether Settings startup reads should run right now. - var canRunSettingsStartupReads: Bool { - if connectionState == .ready { return true } - return connectionState == .connected && connectionUI.currentSyncPhase == .messages + if connectionUI.syncActivityCount > 0 || connectionState == .syncing { + return .syncing + } + if connectionUI.showReadyToast { + return .ready + } + if connectionState == .connecting { + return .connecting + } + if connectionUI.disconnectedPillVisible { + return .disconnected + } + return .hidden + } + + /// Whether Settings startup reads should run right now. + var canRunSettingsStartupReads: Bool { + if connectionState == .ready { return true } + return connectionState == .connected && connectionUI.currentSyncPhase == .messages + } + + // MARK: - Initialization + + init(modelContainer: ModelContainer) { + let bootstrapStore = PersistenceStore(modelContainer: modelContainer) + let bootstrapBuffer = DebugLogBuffer(dataStore: bootstrapStore) + bootstrapDebugLogBuffer = bootstrapBuffer + DebugLogBuffer.shared = bootstrapBuffer + + let store = StoreService() + let theme = ThemeService(store: store) + storeState = StoreState(service: store) + themeService = theme + + connectionManager = ConnectionManager(modelContainer: modelContainer) + + // Provide LiveActivityManager with current radio connection state so + // its restart/recovery/stale paths consult ground truth instead of + // the LA's last cached `isConnected`. Read from connectionState (a + // transport-link predicate) rather than connectedDevice: during iOS + // auto-reconnect connectedDevice is intentionally retained while the + // transport link is down, and using identity as a liveness signal + // would resurrect a "Connected" LA mid-reconnect. + liveActivityManager.connectionStateProvider = { [weak self] in + self?.connectionState.isConnected ?? false } - // MARK: - Initialization - - init(modelContainer: ModelContainer) { - let bootstrapStore = PersistenceStore(modelContainer: modelContainer) - let bootstrapBuffer = DebugLogBuffer(dataStore: bootstrapStore) - self.bootstrapDebugLogBuffer = bootstrapBuffer - DebugLogBuffer.shared = bootstrapBuffer - - let store = StoreService() - let theme = ThemeService(store: store) - self.storeState = StoreState(service: store) - self.themeService = theme - - self.connectionManager = ConnectionManager(modelContainer: modelContainer) - - // Provide LiveActivityManager with current radio connection state so - // its restart/recovery/stale paths consult ground truth instead of - // the LA's last cached `isConnected`. Read from connectionState (a - // transport-link predicate) rather than connectedDevice: during iOS - // auto-reconnect connectedDevice is intentionally retained while the - // transport link is down, and using identity as a liveness signal - // would resurrect a "Connected" LA mid-reconnect. - liveActivityManager.connectionStateProvider = { [weak self] in - self?.connectionState.isConnected ?? false - } - - // Wire app state provider for incremental sync support - connectionManager.appStateProvider = AppStateProviderImpl() - - // Wire connection ready callback - automatically updates UI when connection completes - connectionManager.onConnectionReady = { [weak self] in - await self?.wireServicesIfConnected() - } + // Wire app state provider for incremental sync support + connectionManager.appStateProvider = AppStateProviderImpl() - // Wire connection lost callback - updates UI when connection is lost - connectionManager.onConnectionLost = { [weak self] in - await self?.wireServicesIfConnected() - } + // Wire connection ready callback - automatically updates UI when connection completes + connectionManager.onConnectionReady = { [weak self] in + await self?.wireServicesIfConnected() + } - // Wire device synced callback - runs after sync completes and state is .ready - connectionManager.onDeviceSynced = { [weak self] in - self?.performStaleNodeCleanup() - } + // Wire connection lost callback - updates UI when connection is lost + connectionManager.onConnectionLost = { [weak self] in + await self?.wireServicesIfConnected() + } - loadPersistedRegionSelection() - - Task { [regionAlreadySet = regionSelection != nil] in - let suggested = await onboarding.suggestedStartingPath( - connectionManager: connectionManager, - locationAuthorizationStatus: locationService.authorizationStatus, - regionAlreadySet: regionAlreadySet - ) - if !suggested.isEmpty { - onboarding.onboardingPath = suggested - } - } + // Wire device synced callback - runs after sync completes and state is .ready + connectionManager.onDeviceSynced = { [weak self] in + self?.performStaleNodeCleanup() } - /// Releases process-scoped resources held by this `AppState` instance so the caller can - /// drop it. Currently only cancels `StoreService`'s `Transaction.updates` listener Task, - /// which otherwise self-retains via the `for await` loop and leaks across an `MC1App` BFU - /// reassignment of `appState`. Connection-layer teardown stays on `ConnectionManager`'s own - /// disconnect path and is intentionally not chained here. - func shutdown() { - storeState.service.shutdown() + loadPersistedRegionSelection() + + Task { [regionAlreadySet = regionSelection != nil] in + let suggested = await onboarding.suggestedStartingPath( + connectionManager: connectionManager, + locationAuthorizationStatus: locationService.authorizationStatus, + regionAlreadySet: regionAlreadySet + ) + if !suggested.isEmpty { + onboarding.onboardingPath = suggested + } } + } + + /// Releases process-scoped resources held by this `AppState` instance so the caller can + /// drop it. Currently only cancels `StoreService`'s `Transaction.updates` listener Task, + /// which otherwise self-retains via the `for await` loop and leaks across an `MC1App` BFU + /// reassignment of `appState`. Connection-layer teardown stays on `ConnectionManager`'s own + /// disconnect path and is intentionally not chained here. + func shutdown() { + storeState.service.shutdown() + } + + // MARK: - Lifecycle + + /// Initialize on app launch + func initialize() async { + // Decide What's New before activate() can drive any connection alert. The + // value of `hasCompletedOnboarding` read here is the "was onboarded at + // launch" signal that distinguishes a brand-new install from an upgrader. + #if DEBUG + let isScreenshotMode = ProcessInfo.processInfo.isScreenshotMode + #else + let isScreenshotMode = false + #endif + whatsNew.evaluate( + isOnboarded: onboarding.hasCompletedOnboarding, + isScreenshotMode: isScreenshotMode + ) - // MARK: - Lifecycle - - /// Initialize on app launch - func initialize() async { - // Decide What's New before activate() can drive any connection alert. The - // value of `hasCompletedOnboarding` read here is the "was onboarded at - // launch" signal that distinguishes a brand-new install from an upgrader. - #if DEBUG - let isScreenshotMode = ProcessInfo.processInfo.isScreenshotMode - #else - let isScreenshotMode = false - #endif - whatsNew.evaluate( - isOnboarded: onboarding.hasCompletedOnboarding, - isScreenshotMode: isScreenshotMode - ) - - // Recover any existing Live Activity before activate() so that onConnectionReady - // (which fires during activate) finds currentActivity populated and can update it. - await liveActivityManager.recoverExistingActivity() - liveActivityManager.startObservingEnablement() - await connectionManager.activate() - // Check if disconnected pill should show (for fresh launch after termination) - connectionUI.updateDisconnectedPillState( - connectionState: connectionState, - lastConnectedDeviceID: connectionManager.lastConnectedDeviceID, - shouldSuppressDisconnectedPill: connectionManager.shouldSuppressDisconnectedPill - ) + // Recover any existing Live Activity before activate() so that onConnectionReady + // (which fires during activate) finds currentActivity populated and can update it. + await liveActivityManager.recoverExistingActivity() + liveActivityManager.startObservingEnablement() + await connectionManager.activate() + // Check if disconnected pill should show (for fresh launch after termination) + connectionUI.updateDisconnectedPillState( + connectionState: connectionState, + lastConnectedDeviceID: connectionManager.lastConnectedDeviceID, + shouldSuppressDisconnectedPill: connectionManager.shouldSuppressDisconnectedPill + ) + } + + /// Per-session teardown shared by the connection-loss path and explicit + /// disconnect, which does not fire onConnectionLost. Cancels the event tasks + /// and releases the per-connection coordinators so a suspended task or a + /// torn-down store reference cannot survive into the next session. + func tearDownAppStateSessionState() { + settingsEventsTask?.cancel() + settingsEventsTask = nil + syncDataEventsTask?.cancel() + syncDataEventsTask = nil + advertisementEventsTask?.cancel() + advertisementEventsTask = nil + rxLogEventsTask?.cancel() + rxLogEventsTask = nil + messageEventDispatcher.cancelAll() + chatCoordinatorRegistry?.tearDown() + chatCoordinatorRegistry = nil + navigation.clearPendingLinks() + } + + /// Wire services-dependent callbacks after a successful connection. + func wireServicesIfConnected() async { + guard let services else { + tearDownAppStateSessionState() + syncCoordinator = nil + connectionUI.handleDisconnect( + connectionState: connectionState, + lastConnectedDeviceID: connectionManager.lastConnectedDeviceID, + shouldSuppressDisconnectedPill: connectionManager.shouldSuppressDisconnectedPill + ) + cliToolViewModel?.reset() + batteryMonitor.stop() + batteryMonitor.clearThresholds() + await liveActivityManager.handleConnectionLost() + lastBumpedServicesID = nil + return } - /// Per-session teardown shared by the connection-loss path and explicit - /// disconnect, which does not fire onConnectionLost. Cancels the event tasks - /// and releases the per-connection coordinators so a suspended task or a - /// torn-down store reference cannot survive into the next session. - func tearDownAppStateSessionState() { - settingsEventsTask?.cancel() - settingsEventsTask = nil - syncDataEventsTask?.cancel() - syncDataEventsTask = nil - advertisementEventsTask?.cancel() - advertisementEventsTask = nil - rxLogEventsTask?.cancel() - rxLogEventsTask = nil - messageEventDispatcher.cancelAll() - chatCoordinatorRegistry?.tearDown() - chatCoordinatorRegistry = nil - navigation.clearPendingLinks() + // Wire ConnectionUI callbacks (sync activity, node storage, pills, VoiceOver) + // Must be set before onConnectionEstablished to avoid a race condition + await connectionUI.wireCallbacks( + syncCoordinator: services.syncCoordinator, + advertisementService: services.advertisementService, + contactService: services.contactService, + connectionManager: connectionManager + ) + + // On a device switch onConnectionLost doesn't fire, so the disconnect teardown that + // resets per-radio UI state is skipped. Reset the CLI and every per-radio detail selection + // here so neither carries the previous radio's state into the new session. + if let newDeviceID = connectedDevice?.id, + let oldDeviceID = lastConnectedDeviceIDForCLI, + newDeviceID != oldDeviceID { + cliToolViewModel?.reset() + navigation.clearPerRadioSelection() } + lastConnectedDeviceIDForCLI = connectedDevice?.id - /// Wire services-dependent callbacks after a successful connection. - func wireServicesIfConnected() async { - guard let services else { - tearDownAppStateSessionState() - syncCoordinator = nil - connectionUI.handleDisconnect( - connectionState: connectionState, - lastConnectedDeviceID: connectionManager.lastConnectedDeviceID, - shouldSuppressDisconnectedPill: connectionManager.shouldSuppressDisconnectedPill - ) - cliToolViewModel?.reset() - batteryMonitor.stop() - batteryMonitor.clearThresholds() - await liveActivityManager.handleConnectionLost() - lastBumpedServicesID = nil - return - } + // Store syncCoordinator reference + syncCoordinator = services.syncCoordinator - // Wire ConnectionUI callbacks (sync activity, node storage, pills, VoiceOver) - // Must be set before onConnectionEstablished to avoid a race condition - await connectionUI.wireCallbacks( - syncCoordinator: services.syncCoordinator, - advertisementService: services.advertisementService, - contactService: services.contactService, - connectionManager: connectionManager - ) - - // On a device switch onConnectionLost doesn't fire, so the disconnect teardown that - // resets per-radio UI state is skipped. Reset the CLI and every per-radio detail selection - // here so neither carries the previous radio's state into the new session. - if let newDeviceID = connectedDevice?.id, - let oldDeviceID = lastConnectedDeviceIDForCLI, - newDeviceID != oldDeviceID { - cliToolViewModel?.reset() - navigation.clearPerRadioSelection() - } - lastConnectedDeviceIDForCLI = connectedDevice?.id + // Process-wide inline image cache learns where to persist probed dims. + await InlineImageCache.shared.attachDimensionsStore(services.inlineImageDimensionsStore) - // Store syncCoordinator reference - syncCoordinator = services.syncCoordinator + // Demo mode ships an inline image whose bytes are embedded offline; pre-seed the + // process-wide cache so the seeded DM renders it without a network fetch. + if connectedDevice?.id == MockDataProvider.simulatorDeviceID { + DemoInlineImageSeeder.seed() + } - // Process-wide inline image cache learns where to persist probed dims. - await InlineImageCache.shared.attachDimensionsStore(services.inlineImageDimensionsStore) + if let existing = chatCoordinatorRegistry { + existing.rebind(dataStore: services.dataStore) + } else { + chatCoordinatorRegistry = ChatCoordinatorRegistry(dataStore: services.dataStore) + } - // Demo mode ships an inline image whose bytes are embedded offline; pre-seed the - // process-wide cache so the seeded DM renders it without a network fetch. - if connectedDevice?.id == MockDataProvider.simulatorDeviceID { - DemoInlineImageSeeder.seed() - } + wireSyncDataEvents(services: services) + await wireSettingsEventStream(services: services) + await wireDeviceUpdateCallbacks(services: services) + wireMessageEvents(services: services) + await wireLiveActivityCallbacks(services: services) + + // Drop drafts for channel slots vacated by a delete or sync prune so a + // reused slot can't surface the prior channel's draft. + await services.channelService.setDraftClearHandler { [weak self] radioID, indices in + await MainActor.run { + self?.draftStore.clearChannelDrafts(radioID: radioID, indices: indices) + } + } - if let existing = chatCoordinatorRegistry { - existing.rebind(dataStore: services.dataStore) - } else { - chatCoordinatorRegistry = ChatCoordinatorRegistry(dataStore: services.dataStore) - } + // Bump the version (which drives `.task(id:)` reloads in chat, tools, and + // room views) only when the services container actually changed. A single + // connect both fires `onConnectionReady` and is followed by an explicit + // `wireServicesIfConnected()` call, and the Live Activity toggle re-wires the + // same container — none of those are a services change, so they must not + // trigger a reload. + let servicesID = ObjectIdentifier(services) + if lastBumpedServicesID != servicesID { + lastBumpedServicesID = servicesID + servicesVersion += 1 + + // A real services change is the one moment the addressable contact/channel + // set can differ from what saved shortcuts were resolved against, so re-resolve + // the App Intents parameter queries here (debounced by this same guard, and past + // the connection-lost early return so it never fires on disconnect). + refreshAppShortcutParameters() + } - wireSyncDataEvents(services: services) - await wireSettingsEventStream(services: services) - await wireDeviceUpdateCallbacks(services: services) - wireMessageEvents(services: services) - await wireLiveActivityCallbacks(services: services) - - // Drop drafts for channel slots vacated by a delete or sync prune so a - // reused slot can't surface the prior channel's draft. - await services.channelService.setDraftClearHandler { [weak self] radioID, indices in - await MainActor.run { - self?.draftStore.clearChannelDrafts(radioID: radioID, indices: indices) - } - } + // Set up notification center delegate, wire localized strings, then register categories + UNUserNotificationCenter.current().delegate = services.notificationService + services.notificationService.setStringProvider(NotificationStringProviderImpl()) + await services.notificationService.setup() + + // Configure badge count callback + services.notificationService.getBadgeCount = { [weak self, dataStore = services.dataStore] in + let radioID = await MainActor.run { self?.currentRadioID } + guard let radioID else { + return (contacts: 0, channels: 0, rooms: 0) + } + do { + return try await dataStore.getTotalUnreadCounts(radioID: radioID) + } catch { + return (contacts: 0, channels: 0, rooms: 0) + } + } - // Bump the version (which drives `.task(id:)` reloads in chat, tools, and - // room views) only when the services container actually changed. A single - // connect both fires `onConnectionReady` and is followed by an explicit - // `wireServicesIfConnected()` call, and the Live Activity toggle re-wires the - // same container — none of those are a services change, so they must not - // trigger a reload. - let servicesID = ObjectIdentifier(services) - if lastBumpedServicesID != servicesID { - lastBumpedServicesID = servicesID - servicesVersion += 1 - - // A real services change is the one moment the addressable contact/channel - // set can differ from what saved shortcuts were resolved against, so re-resolve - // the App Intents parameter queries here (debounced by this same guard, and past - // the connection-lost early return so it never fires on disconnect). - refreshAppShortcutParameters() - } + // Seed the badge from persisted unread messages now that the callback is wired; otherwise + // badgeCount stays 0 until a message arrives or a chat opens and recomputes it. + await services.notificationService.updateBadgeCount() - // Set up notification center delegate, wire localized strings, then register categories - UNUserNotificationCenter.current().delegate = services.notificationService - services.notificationService.setStringProvider(NotificationStringProviderImpl()) - await services.notificationService.setup() - - // Configure badge count callback - services.notificationService.getBadgeCount = { [weak self, dataStore = services.dataStore] in - let radioID = await MainActor.run { self?.currentRadioID } - guard let radioID else { - return (contacts: 0, channels: 0, rooms: 0) - } - do { - return try await dataStore.getTotalUnreadCounts(radioID: radioID) - } catch { - return (contacts: 0, channels: 0, rooms: 0) - } - } + // Configure notification interaction handlers + configureNotificationHandlers() - // Seed the badge from persisted unread messages now that the callback is wired; otherwise - // badgeCount stays 0 until a message arrives or a chat opens and recomputes it. - await services.notificationService.updateBadgeCount() + // Defer battery bootstrap so connection setup is not blocked by device request timeouts. + batteryMonitor.start(services: services, device: connectedDevice) + } - // Configure notification interaction handlers - configureNotificationHandlers() + // MARK: - Stale Node Cleanup - // Defer battery bootstrap so connection setup is not blocked by device request timeouts. - batteryMonitor.start(services: services, device: connectedDevice) + /// Runs automatic cleanup of stale non-favorite nodes if the threshold is configured. + /// - Parameter force: When `true`, skips the 6-hour cooldown (used when the user changes the setting). + func performStaleNodeCleanup(force: Bool = false) { + let threshold = UserDefaults.standard.integer(forKey: AppStorageKey.autoDeleteStaleNodesDays.rawValue) + guard threshold > 0 else { return } + if !force { + let lastRunTimestamp = UserDefaults.standard.double(forKey: AppStorageKey.lastStaleCleanupDate.rawValue) + let lastRun = lastRunTimestamp > 0 ? Date(timeIntervalSinceReferenceDate: lastRunTimestamp) : Date.distantPast + guard Date().timeIntervalSince(lastRun) >= 3 * 3600 else { + logger.debug("Stale node cleanup skipped — cooldown not expired") + return + } } - // MARK: - Stale Node Cleanup - - /// Runs automatic cleanup of stale non-favorite nodes if the threshold is configured. - /// - Parameter force: When `true`, skips the 6-hour cooldown (used when the user changes the setting). - func performStaleNodeCleanup(force: Bool = false) { - let threshold = UserDefaults.standard.integer(forKey: AppStorageKey.autoDeleteStaleNodesDays.rawValue) - guard threshold > 0 else { return } - - if !force { - let lastRunTimestamp = UserDefaults.standard.double(forKey: AppStorageKey.lastStaleCleanupDate.rawValue) - let lastRun = lastRunTimestamp > 0 ? Date(timeIntervalSinceReferenceDate: lastRunTimestamp) : Date.distantPast - guard Date().timeIntervalSince(lastRun) >= 3 * 3600 else { - logger.debug("Stale node cleanup skipped — cooldown not expired") - return - } - } - - Task { - do { - let result = try await connectionManager.removeStaleNodes(olderThanDays: threshold) - UserDefaults.standard.set(Date().timeIntervalSinceReferenceDate, forKey: AppStorageKey.lastStaleCleanupDate.rawValue) - if result.total > 0 { - logger.info("Stale node cleanup: removed \(result.removed) of \(result.total) nodes older than \(threshold) days") - } else { - logger.debug("Stale node cleanup: no stale nodes found") - } - } catch { - logger.warning("Stale node cleanup failed: \(error.localizedDescription)") - } + Task { + do { + let result = try await connectionManager.removeStaleNodes(olderThanDays: threshold) + UserDefaults.standard.set(Date().timeIntervalSinceReferenceDate, forKey: AppStorageKey.lastStaleCleanupDate.rawValue) + if result.total > 0 { + logger.info("Stale node cleanup: removed \(result.removed) of \(result.total) nodes older than \(threshold) days") + } else { + logger.debug("Stale node cleanup: no stale nodes found") } + } catch { + logger.warning("Stale node cleanup failed: \(error.localizedDescription)") + } } + } - // MARK: - Onboarding + // MARK: - Onboarding - func completeOnboarding() { - onboarding.completeOnboarding() - Task { - try? await Task.sleep(for: .seconds(1.5)) - await donateDeviceMenuTipIfOnValidTab() - } + func completeOnboarding() { + onboarding.completeOnboarding() + Task { + try? await Task.sleep(for: .seconds(1.5)) + await donateDeviceMenuTipIfOnValidTab() } - - /// Donates the tip if on a valid tab, otherwise marks it pending. - /// Thin coordinator that reads from both navigation and onboarding concerns. - func donateDeviceMenuTipIfOnValidTab() async { - if navigation.isOnValidTabForDeviceMenuTip { - navigation.pendingDeviceMenuTipDonation = false - await DeviceMenuTip.hasCompletedOnboarding.donate() - } else { - navigation.pendingDeviceMenuTipDonation = true - } + } + + /// Donates the tip if on a valid tab, otherwise marks it pending. + /// Thin coordinator that reads from both navigation and onboarding concerns. + func donateDeviceMenuTipIfOnValidTab() async { + if navigation.isOnValidTabForDeviceMenuTip { + navigation.pendingDeviceMenuTipDonation = false + await DeviceMenuTip.hasCompletedOnboarding.donate() + } else { + navigation.pendingDeviceMenuTipDonation = true } + } - /// Donates the tip unconditionally. Used on iPad where the radio is always - /// visible in the sidebar regardless of which section is selected. - func donateDeviceMenuTip() async { - navigation.pendingDeviceMenuTipDonation = false - await DeviceMenuTip.hasCompletedOnboarding.donate() - } + /// Donates the tip unconditionally. Used on iPad where the radio is always + /// visible in the sidebar regardless of which section is selected. + func donateDeviceMenuTip() async { + navigation.pendingDeviceMenuTipDonation = false + await DeviceMenuTip.hasCompletedOnboarding.donate() + } -#if DEBUG + #if DEBUG /// Test helper: Overrides BLE lifecycle operations for deterministic ordering tests. func setBLELifecycleOverridesForTesting( - enterBackground: (@MainActor () async -> Void)? = nil, - becomeActive: (@MainActor () async -> Void)? = nil + enterBackground: (@MainActor () async -> Void)? = nil, + becomeActive: (@MainActor () async -> Void)? = nil ) { - bleEnterBackgroundOverride = enterBackground - bleBecomeActiveOverride = becomeActive + bleEnterBackgroundOverride = enterBackground + bleBecomeActiveOverride = becomeActive } -#endif + #endif } // MARK: - Preview Support extension AppState { - /// Creates an AppState for previews using an in-memory container - @MainActor - convenience init() { - let schema = Schema([ - Device.self, - Contact.self, - Message.self, - Channel.self, - RemoteNodeSession.self, - RoomMessage.self - ]) - let config = ModelConfiguration(isStoredInMemoryOnly: true) - // swiftlint:disable:next force_try - let container = try! ModelContainer(for: schema, configurations: [config]) - self.init(modelContainer: container) - } -} - -// MARK: - Environment Key - -/// Environment key for AppState with safe default for background snapshot scenarios. -/// MainActor.assumeIsolated asserts we're on the main actor, which is always true -/// for SwiftUI environment access in views. -private struct AppStateKey: EnvironmentKey { - static var defaultValue: AppState { - MainActor.assumeIsolated { - AppState() - } - } + /// Creates an AppState for previews using an in-memory container + @MainActor + convenience init() { + let schema = Schema([ + Device.self, + Contact.self, + Message.self, + Channel.self, + RemoteNodeSession.self, + RoomMessage.self + ]) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + // swiftlint:disable:next force_try + let container = try! ModelContainer(for: schema, configurations: [config]) + self.init(modelContainer: container) + } } extension EnvironmentValues { - /// AppState environment value with safe default for background snapshot scenarios. - /// Having a default value ensures a value is always available, preventing crashes when - /// iOS takes app switcher snapshots or launches the app in background. - var appState: AppState { - get { self[AppStateKey.self] } - set { self[AppStateKey.self] = newValue } - } + // AppState environment value with safe default for background snapshot scenarios. + // Having a default value ensures a value is always available, preventing crashes when + // iOS takes app switcher snapshots or launches the app in background. + @Entry var appState: AppState = MainActor.assumeIsolated { + AppState() + } } diff --git a/MC1/State/AppTab.swift b/MC1/State/AppTab.swift index cfe8b43b..58dca95e 100644 --- a/MC1/State/AppTab.swift +++ b/MC1/State/AppTab.swift @@ -4,9 +4,9 @@ /// `NavigationCoordinator.selectedTab` remains `Int` (see the deferred /// Int-to-AppTab migration). enum AppTab: Int { - case chats - case nodes - case map - case tools - case settings + case chats + case nodes + case map + case tools + case settings } diff --git a/MC1/State/BatteryMonitor.swift b/MC1/State/BatteryMonitor.swift index dea7ad74..65ee464b 100644 --- a/MC1/State/BatteryMonitor.swift +++ b/MC1/State/BatteryMonitor.swift @@ -7,200 +7,199 @@ import OSLog @Observable @MainActor final class BatteryMonitor { + private let logger = Logger(subsystem: "com.mc1", category: "BatteryMonitor") - private let logger = Logger(subsystem: "com.mc1", category: "BatteryMonitor") + /// Current device battery info (nil if not fetched or disconnected) + var deviceBattery: BatteryInfo? - /// Current device battery info (nil if not fetched or disconnected) - var deviceBattery: BatteryInfo? + /// Task for initial battery fetch on connect (cancelled on disconnect) + private var bootstrapTask: Task? - /// Task for initial battery fetch on connect (cancelled on disconnect) - private var bootstrapTask: Task? + /// Task for periodic battery refresh (cancelled on disconnect/background) + private var batteryRefreshTask: Task? - /// Task for periodic battery refresh (cancelled on disconnect/background) - private var batteryRefreshTask: Task? + /// Thresholds that have already triggered a notification this session + private var notifiedBatteryThresholds: Set = [] - /// Thresholds that have already triggered a notification this session - private var notifiedBatteryThresholds: Set = [] + /// Tracks last successful battery fetch for background piggybacking + private var lastSuccessfulFetchDate: Date? - /// Tracks last successful battery fetch for background piggybacking - private var lastSuccessfulFetchDate: Date? + /// Battery warning threshold levels (percentage) + private let batteryWarningThresholds = [20, 10, 5] - /// Battery warning threshold levels (percentage) - private let batteryWarningThresholds = [20, 10, 5] + /// Called when battery info is updated, for Live Activity + var onBatteryChanged: ((_ battery: BatteryInfo) -> Void)? - /// Called when battery info is updated, for Live Activity - var onBatteryChanged: ((_ battery: BatteryInfo) -> Void)? + /// The active OCV array for the connected device + func activeBatteryOCVArray(for device: DeviceDTO?) -> [Int] { + device?.activeOCVArray ?? OCVPreset.liIon.ocvArray + } - /// The active OCV array for the connected device - func activeBatteryOCVArray(for device: DeviceDTO?) -> [Int] { - device?.activeOCVArray ?? OCVPreset.liIon.ocvArray - } - - // MARK: - Public API + // MARK: - Public API - /// Fetch device battery level on demand - func fetchDeviceBattery(services: ServiceContainer?, device: DeviceDTO?) async { - guard let settingsService = services?.settingsService else { return } + /// Fetch device battery level on demand + func fetchDeviceBattery(services: ServiceContainer?, device: DeviceDTO?) async { + guard let settingsService = services?.settingsService else { return } - do { - deviceBattery = try await settingsService.getBattery() - lastSuccessfulFetchDate = .now - if let battery = deviceBattery { - onBatteryChanged?(battery) - } - await checkBatteryThresholds(device: device, services: services) - } catch { - deviceBattery = nil - } + do { + deviceBattery = try await settingsService.getBattery() + lastSuccessfulFetchDate = .now + if let battery = deviceBattery { + onBatteryChanged?(battery) + } + await checkBatteryThresholds(device: device, services: services) + } catch { + deviceBattery = nil } + } + + private static let backgroundBatteryInterval: TimeInterval = 900 + + /// Fetch battery only if enough time has passed since last successful fetch. + /// Called from packet reception handler to piggyback on active BLE wake-ups. + func fetchBatteryIfOverdue(services: ServiceContainer?, device: DeviceDTO?) async { + if let last = lastSuccessfulFetchDate, + Date.now.timeIntervalSince(last) < Self.backgroundBatteryInterval { return } + await fetchDeviceBattery(services: services, device: device) + } + + /// Start battery monitoring for a newly connected device. + /// Defers bootstrap so connection setup is not blocked by device request timeouts. + func start(services: ServiceContainer, device: DeviceDTO?) { + bootstrapTask = Task { @MainActor [weak self] in + guard let self else { return } + + do { + deviceBattery = try await services.settingsService.getBattery() + lastSuccessfulFetchDate = .now + if let battery = deviceBattery { + onBatteryChanged?(battery) + } + } catch { + logger.debug("Deferred battery bootstrap failed: \(error.localizedDescription, privacy: .public)") + deviceBattery = nil + } - private static let backgroundBatteryInterval: TimeInterval = 900 + guard !Task.isCancelled else { return } - /// Fetch battery only if enough time has passed since last successful fetch. - /// Called from packet reception handler to piggyback on active BLE wake-ups. - func fetchBatteryIfOverdue(services: ServiceContainer?, device: DeviceDTO?) async { - if let last = lastSuccessfulFetchDate, - Date.now.timeIntervalSince(last) < Self.backgroundBatteryInterval { return } - await fetchDeviceBattery(services: services, device: device) + await initializeBatteryThresholds(device: device, services: services) + startRefreshLoop(services: services, device: device) } - - /// Start battery monitoring for a newly connected device. - /// Defers bootstrap so connection setup is not blocked by device request timeouts. - func start(services: ServiceContainer, device: DeviceDTO?) { - bootstrapTask = Task { @MainActor [weak self] in - guard let self else { return } - - do { - self.deviceBattery = try await services.settingsService.getBattery() - self.lastSuccessfulFetchDate = .now - if let battery = self.deviceBattery { - self.onBatteryChanged?(battery) - } - } catch { - self.logger.debug("Deferred battery bootstrap failed: \(error.localizedDescription, privacy: .public)") - self.deviceBattery = nil - } - - guard !Task.isCancelled else { return } - - await self.initializeBatteryThresholds(device: device, services: services) - self.startRefreshLoop(services: services, device: device) - } + } + + /// Stop battery monitoring (disconnect or background) + func stop() { + bootstrapTask?.cancel() + bootstrapTask = nil + batteryRefreshTask?.cancel() + batteryRefreshTask = nil + } + + /// Clear notification thresholds for a fresh connection + func clearThresholds() { + notifiedBatteryThresholds = [] + } + + /// Check for battery thresholds crossed while app was backgrounded. + /// Posts a single notification if any thresholds were missed. + func checkMissedBatteryThreshold(device: DeviceDTO?, services: ServiceContainer?) async { + guard let device, + let settingsService = services?.settingsService, + let notificationService = services?.notificationService else { return } + + do { + deviceBattery = try await settingsService.getBattery() + if let battery = deviceBattery { + onBatteryChanged?(battery) + } + } catch { + return } - /// Stop battery monitoring (disconnect or background) - func stop() { - bootstrapTask?.cancel() - bootstrapTask = nil - batteryRefreshTask?.cancel() - batteryRefreshTask = nil - } + guard let battery = deviceBattery else { return } + guard battery.isBatteryPresent else { return } + let percentage = battery.percentage(using: device.activeOCVArray) - /// Clear notification thresholds for a fresh connection - func clearThresholds() { - notifiedBatteryThresholds = [] + let missedThresholds = batteryWarningThresholds.filter { threshold in + percentage <= threshold && !notifiedBatteryThresholds.contains(threshold) } - /// Check for battery thresholds crossed while app was backgrounded. - /// Posts a single notification if any thresholds were missed. - func checkMissedBatteryThreshold(device: DeviceDTO?, services: ServiceContainer?) async { - guard let device, - let settingsService = services?.settingsService, - let notificationService = services?.notificationService else { return } + guard !missedThresholds.isEmpty else { return } + + for threshold in missedThresholds { + notifiedBatteryThresholds.insert(threshold) + } + await notificationService.postLowBatteryNotification( + deviceName: device.nodeName, + batteryPercentage: percentage + ) + } + + /// Restart the battery refresh loop (e.g., returning to foreground) + func startRefreshLoop(services: ServiceContainer, device: DeviceDTO?) { + batteryRefreshTask?.cancel() + batteryRefreshTask = Task { [weak self] in + while true { do { - deviceBattery = try await settingsService.getBattery() - if let battery = deviceBattery { - onBatteryChanged?(battery) - } + try await Task.sleep(for: .seconds(120)) } catch { - return - } - - guard let battery = deviceBattery else { return } - guard battery.isBatteryPresent else { return } - let percentage = battery.percentage(using: device.activeOCVArray) - - let missedThresholds = batteryWarningThresholds.filter { threshold in - percentage <= threshold && !notifiedBatteryThresholds.contains(threshold) + break } - - guard !missedThresholds.isEmpty else { return } - - for threshold in missedThresholds { - notifiedBatteryThresholds.insert(threshold) - } - - await notificationService.postLowBatteryNotification( - deviceName: device.nodeName, - batteryPercentage: percentage - ) + guard let self else { break } + await fetchDeviceBattery(services: services, device: device) + } + } + } + + /// Initialize battery thresholds based on current level and notify if already below a threshold + private func initializeBatteryThresholds(device: DeviceDTO?, services: ServiceContainer) async { + guard let battery = deviceBattery, + let device else { + notifiedBatteryThresholds = [] + return } - /// Restart the battery refresh loop (e.g., returning to foreground) - func startRefreshLoop(services: ServiceContainer, device: DeviceDTO?) { - batteryRefreshTask?.cancel() - batteryRefreshTask = Task { [weak self] in - while true { - do { - try await Task.sleep(for: .seconds(120)) - } catch { - break - } - guard let self else { break } - await self.fetchDeviceBattery(services: services, device: device) - } - } + guard battery.isBatteryPresent else { + notifiedBatteryThresholds = [] + return } - /// Initialize battery thresholds based on current level and notify if already below a threshold - private func initializeBatteryThresholds(device: DeviceDTO?, services: ServiceContainer) async { - guard let battery = deviceBattery, - let device else { - notifiedBatteryThresholds = [] - return - } + let percentage = battery.percentage(using: device.activeOCVArray) - guard battery.isBatteryPresent else { - notifiedBatteryThresholds = [] - return - } + let crossedThresholds = batteryWarningThresholds.filter { percentage <= $0 } - let percentage = battery.percentage(using: device.activeOCVArray) + notifiedBatteryThresholds = Set(crossedThresholds) - let crossedThresholds = batteryWarningThresholds.filter { percentage <= $0 } + if !crossedThresholds.isEmpty { + await services.notificationService.postLowBatteryNotification( + deviceName: device.nodeName, + batteryPercentage: percentage + ) + } + } - notifiedBatteryThresholds = Set(crossedThresholds) + /// Check battery level against thresholds and send notifications + private func checkBatteryThresholds(device: DeviceDTO?, services: ServiceContainer?) async { + guard let battery = deviceBattery, + let device, + let notificationService = services?.notificationService else { return } - if !crossedThresholds.isEmpty { - await services.notificationService.postLowBatteryNotification( - deviceName: device.nodeName, - batteryPercentage: percentage - ) - } - } + guard battery.isBatteryPresent else { return } - /// Check battery level against thresholds and send notifications - private func checkBatteryThresholds(device: DeviceDTO?, services: ServiceContainer?) async { - guard let battery = deviceBattery, - let device, - let notificationService = services?.notificationService else { return } - - guard battery.isBatteryPresent else { return } - - let percentage = battery.percentage(using: device.activeOCVArray) - - for threshold in batteryWarningThresholds { - if percentage <= threshold && !notifiedBatteryThresholds.contains(threshold) { - notifiedBatteryThresholds.insert(threshold) - await notificationService.postLowBatteryNotification( - deviceName: device.nodeName, - batteryPercentage: percentage - ) - break - } else if percentage > threshold && notifiedBatteryThresholds.contains(threshold) { - notifiedBatteryThresholds.remove(threshold) - } - } + let percentage = battery.percentage(using: device.activeOCVArray) + + for threshold in batteryWarningThresholds { + if percentage <= threshold, !notifiedBatteryThresholds.contains(threshold) { + notifiedBatteryThresholds.insert(threshold) + await notificationService.postLowBatteryNotification( + deviceName: device.nodeName, + batteryPercentage: percentage + ) + break + } else if percentage > threshold, notifiedBatteryThresholds.contains(threshold) { + notifiedBatteryThresholds.remove(threshold) + } } + } } diff --git a/MC1/State/ConnectionUIState.swift b/MC1/State/ConnectionUIState.swift index 9f47ae21..1ce9f77d 100644 --- a/MC1/State/ConnectionUIState.swift +++ b/MC1/State/ConnectionUIState.swift @@ -6,325 +6,324 @@ import MC1Services @Observable @MainActor final class ConnectionUIState { + // MARK: - Ready Toast - // MARK: - Ready Toast + /// Whether the "Ready" toast pill is visible (shown briefly after connection completes) + private(set) var showReadyToast = false - /// Whether the "Ready" toast pill is visible (shown briefly after connection completes) - private(set) var showReadyToast = false + /// Task managing the ready toast visibility timer + private var readyToastTask: Task? - /// Task managing the ready toast visibility timer - private var readyToastTask: Task? + // MARK: - Sync Failed Pill - // MARK: - Sync Failed Pill + /// Whether the "Sync Failed" pill is visible + private(set) var syncFailedPillVisible = false - /// Whether the "Sync Failed" pill is visible - private(set) var syncFailedPillVisible = false + /// Task managing the pill visibility timer + private var syncFailedPillTask: Task? - /// Task managing the pill visibility timer - private var syncFailedPillTask: Task? + // MARK: - Disconnected Pill - // MARK: - Disconnected Pill + /// Whether the "Disconnected" pill is visible (shown after 1s delay) + private(set) var disconnectedPillVisible = false - /// Whether the "Disconnected" pill is visible (shown after 1s delay) - private(set) var disconnectedPillVisible = false + /// Task managing the disconnected pill delay + private var disconnectedPillTask: Task? - /// Task managing the disconnected pill delay - private var disconnectedPillTask: Task? + // MARK: - Sync Activity - // MARK: - Sync Activity + /// Counter for sync/settings operations (on-demand) - shows pill + var syncActivityCount: Int = 0 - /// Counter for sync/settings operations (on-demand) - shows pill - var syncActivityCount: Int = 0 + /// Current sync phase reported by SyncCoordinator callbacks. + /// Used to defer non-essential settings reads during connect/sync. + var currentSyncPhase: SyncPhase? - /// Current sync phase reported by SyncCoordinator callbacks. - /// Used to defer non-essential settings reads during connect/sync. - var currentSyncPhase: SyncPhase? + // MARK: - Connection Alerts & Pairing - // MARK: - Connection Alerts & Pairing + /// Whether to show connection failure alert + var showingConnectionFailedAlert = false - /// Whether to show connection failure alert - var showingConnectionFailedAlert = false + /// Message for connection failure alert + var connectionFailedMessage: String? - /// Message for connection failure alert - var connectionFailedMessage: String? + /// Optional override for the connection-failed alert title. nil falls back + /// to L10n.Localizable.Alert.ConnectionFailed.title ("Connection Failed"). + var connectionFailedTitle: String? - /// Optional override for the connection-failed alert title. nil falls back - /// to L10n.Localizable.Alert.ConnectionFailed.title ("Connection Failed"). - var connectionFailedTitle: String? + /// Variant of the pairing-failure alert when `failedPairingDeviceID` is set. + /// Drives action-button selection in `ContentView` so the discriminant is an + /// explicit semantic signal rather than a "title text happens to be non-nil" + /// heuristic. A future caller that sets a title for non-auth reasons can't + /// silently flip the user from a non-destructive Try Again into a destructive + /// Remove and Try Again — that mistake destroys a working bond. + var pairingFailureKind: PairingFailureKind? - /// Variant of the pairing-failure alert when `failedPairingDeviceID` is set. - /// Drives action-button selection in `ContentView` so the discriminant is an - /// explicit semantic signal rather than a "title text happens to be non-nil" - /// heuristic. A future caller that sets a title for non-auth reasons can't - /// silently flip the user from a non-destructive Try Again into a destructive - /// Remove and Try Again — that mistake destroys a working bond. - var pairingFailureKind: PairingFailureKind? + /// Device ID that failed pairing (wrong PIN) - for recovery UI + var failedPairingDeviceID: UUID? - /// Device ID that failed pairing (wrong PIN) - for recovery UI - var failedPairingDeviceID: UUID? + /// Device ID that triggered "connected to other app" warning - alert shown when non-nil + var otherAppWarningDeviceID: UUID? - /// Device ID that triggered "connected to other app" warning - alert shown when non-nil - var otherAppWarningDeviceID: UUID? + /// Whether any user-initiated connection attempt is in flight — pairing + /// (`AppState.startDeviceScan`), the transient-failure retry path + /// (`AppState.retryFailedPairingConnect`), or simulator connect. Drives + /// spinners and disabled buttons across pairing and retry flows. Distinct from + /// `ConnectionManager.isPairingInProgress`, which is narrowly scoped to the + /// `pairNewDevice` flow and is consulted by the BLE-layer reconnect gate. + var isBusy = false - /// Whether any user-initiated connection attempt is in flight — pairing - /// (`AppState.startDeviceScan`), the transient-failure retry path - /// (`AppState.retryFailedPairingConnect`), or simulator connect. Drives - /// spinners and disabled buttons across pairing and retry flows. Distinct from - /// `ConnectionManager.isPairingInProgress`, which is narrowly scoped to the - /// `pairNewDevice` flow and is consulted by the BLE-layer reconnect gate. - var isBusy = false + /// Whether the device's node storage is full (set by 0x90 push, cleared on delete/overwrite) + var isNodeStorageFull = false - /// Whether the device's node storage is full (set by 0x90 push, cleared on delete/overwrite) - var isNodeStorageFull = false + /// Task consuming `AdvertisementService.events()` for storage-full state. + /// Re-subscribed per connection in `wireCallbacks`; cancelled on disconnect. + private var nodeStorageEventsTask: Task? - /// Task consuming `AdvertisementService.events()` for storage-full state. - /// Re-subscribed per connection in `wireCallbacks`; cancelled on disconnect. - private var nodeStorageEventsTask: Task? + /// Task consuming `ContactService.events()` for node-deletion events. + /// Re-subscribed per connection in `wireCallbacks`; cancelled on disconnect. + private var nodeDeletedEventsTask: Task? - /// Task consuming `ContactService.events()` for node-deletion events. - /// Re-subscribed per connection in `wireCallbacks`; cancelled on disconnect. - private var nodeDeletedEventsTask: Task? + /// Flag indicating ASK picker should be shown when app returns to foreground + var shouldShowPickerOnForeground = false - /// Flag indicating ASK picker should be shown when app returns to foreground - var shouldShowPickerOnForeground = false + // MARK: - Ready Toast Methods - // MARK: - Ready Toast Methods + /// Shows "Ready" toast pill for 2 seconds + func showReadyToastBriefly() { + readyToastTask?.cancel() + showReadyToast = true - /// Shows "Ready" toast pill for 2 seconds - func showReadyToastBriefly() { - readyToastTask?.cancel() - showReadyToast = true - - readyToastTask = Task { - try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled else { return } - showReadyToast = false - } + readyToastTask = Task { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + showReadyToast = false } + } - /// Hides the ready toast immediately (called on disconnect) - func hideReadyToast() { - readyToastTask?.cancel() - readyToastTask = nil - showReadyToast = false - } + /// Hides the ready toast immediately (called on disconnect) + func hideReadyToast() { + readyToastTask?.cancel() + readyToastTask = nil + showReadyToast = false + } - // MARK: - Sync Failed Pill Methods + // MARK: - Sync Failed Pill Methods - /// Shows "Sync Failed" pill for 7 seconds with VoiceOver announcement - func showSyncFailedPill() { - syncFailedPillTask?.cancel() - syncFailedPillVisible = true + /// Shows "Sync Failed" pill for 7 seconds with VoiceOver announcement + func showSyncFailedPill() { + syncFailedPillTask?.cancel() + syncFailedPillVisible = true - announceConnectionState(L10n.Localizable.Accessibility.Connection.syncFailedDisconnecting) + announceConnectionState(L10n.Localizable.Accessibility.Connection.syncFailedDisconnecting) - syncFailedPillTask = Task { - try? await Task.sleep(for: .seconds(7)) - guard !Task.isCancelled else { return } - syncFailedPillVisible = false - } + syncFailedPillTask = Task { + try? await Task.sleep(for: .seconds(7)) + guard !Task.isCancelled else { return } + syncFailedPillVisible = false } - - /// Hides the sync failed pill immediately (called when resync succeeds) - func hideSyncFailedPill() { - syncFailedPillTask?.cancel() - syncFailedPillTask = nil - syncFailedPillVisible = false + } + + /// Hides the sync failed pill immediately (called when resync succeeds) + func hideSyncFailedPill() { + syncFailedPillTask?.cancel() + syncFailedPillTask = nil + syncFailedPillVisible = false + } + + // MARK: - Disconnected Pill Methods + + /// Updates disconnected pill visibility based on connection state. + /// Called when connectionState changes or on app launch. + func updateDisconnectedPillState( + connectionState: MC1Services.DeviceConnectionState, + lastConnectedDeviceID: UUID?, + shouldSuppressDisconnectedPill: Bool + ) { + disconnectedPillTask?.cancel() + + guard connectionState == .disconnected, + lastConnectedDeviceID != nil, + !shouldSuppressDisconnectedPill else { + disconnectedPillVisible = false + return } - // MARK: - Disconnected Pill Methods - - /// Updates disconnected pill visibility based on connection state. - /// Called when connectionState changes or on app launch. - func updateDisconnectedPillState( - connectionState: MC1Services.DeviceConnectionState, - lastConnectedDeviceID: UUID?, - shouldSuppressDisconnectedPill: Bool - ) { - disconnectedPillTask?.cancel() - - guard connectionState == .disconnected, - lastConnectedDeviceID != nil, - !shouldSuppressDisconnectedPill else { - disconnectedPillVisible = false - return - } - - disconnectedPillTask = Task { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { return } - disconnectedPillVisible = true - } + disconnectedPillTask = Task { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + disconnectedPillVisible = true } + } - /// Hides disconnected pill immediately (called when connection starts) - func hideDisconnectedPill() { - disconnectedPillTask?.cancel() - disconnectedPillTask = nil - disconnectedPillVisible = false - } + /// Hides disconnected pill immediately (called when connection starts) + func hideDisconnectedPill() { + disconnectedPillTask?.cancel() + disconnectedPillTask = nil + disconnectedPillVisible = false + } - // MARK: - Activity Tracking + // MARK: - Activity Tracking -#if DEBUG + #if DEBUG /// Test helper: Simulates sync activity started callback func simulateSyncStarted() { - syncActivityCount += 1 + syncActivityCount += 1 } /// Test helper: Simulates sync activity ended callback (mirrors actual callback guard logic) func simulateSyncEnded(succeeded: Bool = false) { + guard syncActivityCount > 0 else { return } + syncActivityCount -= 1 + if syncActivityCount == 0, succeeded { + showReadyToastBriefly() + } + } + #endif + + // MARK: - Service Wiring + + /// Resets connection UI state when services become unavailable (disconnect). + func handleDisconnect( + connectionState: MC1Services.DeviceConnectionState, + lastConnectedDeviceID: UUID?, + shouldSuppressDisconnectedPill: Bool + ) { + announceConnectionState(L10n.Localizable.Accessibility.Connection.deviceConnectionLost) + nodeStorageEventsTask?.cancel() + nodeStorageEventsTask = nil + nodeDeletedEventsTask?.cancel() + nodeDeletedEventsTask = nil + syncActivityCount = 0 + currentSyncPhase = nil + hideReadyToast() + isNodeStorageFull = false + updateDisconnectedPillState( + connectionState: connectionState, + lastConnectedDeviceID: lastConnectedDeviceID, + shouldSuppressDisconnectedPill: shouldSuppressDisconnectedPill + ) + } + + /// Wires ConnectionUI-related callbacks on the sync coordinator and services. + func wireCallbacks( + syncCoordinator: SyncCoordinator, + advertisementService: AdvertisementService, + contactService: ContactService, + connectionManager: ConnectionManager + ) async { + hideDisconnectedPill() + + announceConnectionState(L10n.Localizable.Accessibility.Connection.deviceReconnected) + + // Sync activity callbacks for syncing pill display + // These are called for contacts and channels phases, NOT for messages + await syncCoordinator.setSyncActivityCallbacks( + onStarted: { @MainActor [weak self] in + self?.syncActivityCount += 1 + }, + onEnded: { @MainActor [weak self] succeeded in + guard let self else { return } + // Guard against double-decrement: onDisconnected and sync error path + // can both call this if WiFi drops or device switch during sync guard syncActivityCount > 0 else { return } syncActivityCount -= 1 - if syncActivityCount == 0 && succeeded { - showReadyToastBriefly() + // Show "Ready" toast only when all sync activity completes successfully + if syncActivityCount == 0, succeeded { + showReadyToastBriefly() } - } -#endif - - // MARK: - Service Wiring - - /// Resets connection UI state when services become unavailable (disconnect). - func handleDisconnect( - connectionState: MC1Services.DeviceConnectionState, - lastConnectedDeviceID: UUID?, - shouldSuppressDisconnectedPill: Bool - ) { - announceConnectionState(L10n.Localizable.Accessibility.Connection.deviceConnectionLost) - nodeStorageEventsTask?.cancel() - nodeStorageEventsTask = nil - nodeDeletedEventsTask?.cancel() - nodeDeletedEventsTask = nil - syncActivityCount = 0 - currentSyncPhase = nil - hideReadyToast() - isNodeStorageFull = false - updateDisconnectedPillState( - connectionState: connectionState, - lastConnectedDeviceID: lastConnectedDeviceID, - shouldSuppressDisconnectedPill: shouldSuppressDisconnectedPill - ) + }, + onPhaseChanged: { @MainActor [weak self] phase in + self?.currentSyncPhase = phase + } + ) + + // Resync failed callback for "Sync Failed" pill + connectionManager.onResyncFailed = { [weak self] in + self?.showSyncFailedPill() } - /// Wires ConnectionUI-related callbacks on the sync coordinator and services. - func wireCallbacks( - syncCoordinator: SyncCoordinator, - advertisementService: AdvertisementService, - contactService: ContactService, - connectionManager: ConnectionManager - ) async { - hideDisconnectedPill() - - announceConnectionState(L10n.Localizable.Accessibility.Connection.deviceReconnected) - - // Sync activity callbacks for syncing pill display - // These are called for contacts and channels phases, NOT for messages - await syncCoordinator.setSyncActivityCallbacks( - onStarted: { @MainActor [weak self] in - self?.syncActivityCount += 1 - }, - onEnded: { @MainActor [weak self] succeeded in - guard let self else { return } - // Guard against double-decrement: onDisconnected and sync error path - // can both call this if WiFi drops or device switch during sync - guard self.syncActivityCount > 0 else { return } - self.syncActivityCount -= 1 - // Show "Ready" toast only when all sync activity completes successfully - if self.syncActivityCount == 0 && succeeded { - self.showReadyToastBriefly() - } - }, - onPhaseChanged: { @MainActor [weak self] phase in - self?.currentSyncPhase = phase - } - ) - - // Resync failed callback for "Sync Failed" pill - connectionManager.onResyncFailed = { [weak self] in - self?.showSyncFailedPill() - } - - // Node storage full events (0x90 contactsFull or 0x8F contactDeleted push). - // Subscribed synchronously so the registration is live before - // onConnectionEstablished can emit storage events. - nodeStorageEventsTask?.cancel() - let advertisementEvents = advertisementService.events() - nodeStorageEventsTask = Task { [weak self] in - for await event in advertisementEvents { - guard let self else { return } - if case .nodeStorageFullChanged(let isFull) = event { - self.isNodeStorageFull = isFull - } - } - } - - // Node deleted events clear the storage-full flag when the user manually - // deletes a node. Subscribed synchronously so the registration is live - // before a delete can emit. - nodeDeletedEventsTask?.cancel() - let contactEvents = contactService.events() - nodeDeletedEventsTask = Task { [weak self] in - for await event in contactEvents { - guard let self else { return } - if case .nodeDeleted = event { - self.isNodeStorageFull = false - } - } + // Node storage full events (0x90 contactsFull or 0x8F contactDeleted push). + // Subscribed synchronously so the registration is live before + // onConnectionEstablished can emit storage events. + nodeStorageEventsTask?.cancel() + let advertisementEvents = advertisementService.events() + nodeStorageEventsTask = Task { [weak self] in + for await event in advertisementEvents { + guard let self else { return } + if case let .nodeStorageFullChanged(isFull) = event { + isNodeStorageFull = isFull } + } } - // MARK: - Accessibility - - /// Posts a VoiceOver announcement for connection state changes - func announceConnectionState(_ message: String) { - AccessibilityNotification.Announcement(message).post() + // Node deleted events clear the storage-full flag when the user manually + // deletes a node. Subscribed synchronously so the registration is live + // before a delete can emit. + nodeDeletedEventsTask?.cancel() + let contactEvents = contactService.events() + nodeDeletedEventsTask = Task { [weak self] in + for await event in contactEvents { + guard let self else { return } + if case .nodeDeleted = event { + isNodeStorageFull = false + } + } } - - // MARK: - Connection Failure Routing - - /// Routes a generic (non-pairing) connection failure. Clears - /// `connectionFailedTitle` and `pairingFailureKind` so a prior - /// `presentPairingFailure` can't leak stale state onto an unrelated failure. - func presentConnectionFailure(message: String?) { + } + + // MARK: - Accessibility + + /// Posts a VoiceOver announcement for connection state changes + func announceConnectionState(_ message: String) { + AccessibilityNotification.Announcement(message).post() + } + + // MARK: - Connection Failure Routing + + /// Routes a generic (non-pairing) connection failure. Clears + /// `connectionFailedTitle` and `pairingFailureKind` so a prior + /// `presentPairingFailure` can't leak stale state onto an unrelated failure. + func presentConnectionFailure(message: String?) { + connectionFailedTitle = nil + pairingFailureKind = nil + connectionFailedMessage = message + showingConnectionFailedAlert = true + } + + /// Routes a PairingError to the correct alert so every catch site produces + /// identical UX across the three pairing-failure paths. + func presentPairingFailure(_ error: PairingError) { + switch error { + case let .deviceConnectedToOtherApp(deviceID): + // Routes through the separate "Could Not Connect" alert binding. + otherAppWarningDeviceID = deviceID + + case let .connectionFailed(deviceID, _): + failedPairingDeviceID = deviceID + if error.isAuthenticationFailure { + connectionFailedTitle = L10n.Localizable.Alert.PairingFailed.title + connectionFailedMessage = L10n.Onboarding.DeviceScan.Error.authenticationFailed + pairingFailureKind = .authentication + } else { connectionFailedTitle = nil - pairingFailureKind = nil - connectionFailedMessage = message - showingConnectionFailedAlert = true - } - - /// Routes a PairingError to the correct alert so every catch site produces - /// identical UX across the three pairing-failure paths. - func presentPairingFailure(_ error: PairingError) { - switch error { - case .deviceConnectedToOtherApp(let deviceID): - // Routes through the separate "Could Not Connect" alert binding. - otherAppWarningDeviceID = deviceID - - case .connectionFailed(let deviceID, _): - failedPairingDeviceID = deviceID - if error.isAuthenticationFailure { - connectionFailedTitle = L10n.Localizable.Alert.PairingFailed.title - connectionFailedMessage = L10n.Onboarding.DeviceScan.Error.authenticationFailed - pairingFailureKind = .authentication - } else { - connectionFailedTitle = nil - connectionFailedMessage = L10n.Onboarding.DeviceScan.Error.connectionFailed - pairingFailureKind = .transient - } - showingConnectionFailedAlert = true - } + connectionFailedMessage = L10n.Onboarding.DeviceScan.Error.connectionFailed + pairingFailureKind = .transient + } + showingConnectionFailedAlert = true } + } } /// Variant of the pairing-failure alert. Determines whether the recovery action /// is destructive (auth: must remove the bond) or non-destructive (transient: /// keep the bond, just retry). -enum PairingFailureKind: Sendable { - /// Authentication failed — bond is bad. Recovery requires removing the bond - /// and re-pairing. - case authentication - - /// Transient connection failure — bond is good. Recovery prefers a plain - /// retry, with destructive remove available as a fallback. - case transient +enum PairingFailureKind { + /// Authentication failed — bond is bad. Recovery requires removing the bond + /// and re-pairing. + case authentication + + /// Transient connection failure — bond is good. Recovery prefers a plain + /// retry, with destructive remove available as a fallback. + case transient } diff --git a/MC1/State/LiveActivityManager.swift b/MC1/State/LiveActivityManager.swift index ef92a12b..1be19c79 100644 --- a/MC1/State/LiveActivityManager.swift +++ b/MC1/State/LiveActivityManager.swift @@ -1,549 +1,550 @@ @preconcurrency import ActivityKit import Foundation +import MC1Services import MeshCore import OSLog -import MC1Services @Observable @MainActor final class LiveActivityManager { - - static let enabledKey = AppStorageKey.liveActivityEnabled.rawValue - - private let logger = Logger(subsystem: "com.mc1", category: "LiveActivityManager") - - /// Returns whether BLE is currently connected at the radio layer. Defaults - /// to `false` so a missing wiring fails closed. - var connectionStateProvider: (@MainActor () -> Bool)? - - private var currentActivity: Activity? - private var decayTimer: Task? - private var disconnectTimer: Task? - private var enablementTask: Task? - private var stateObservationTask: Task? - private var throttleTask: Task? - private var ocvArray: [Int] = [] - private var recentPacketTimestamps: [Date] = [] - private var pendingUpdate: PendingUpdate? - private var lastFlushDate: Date = .distantPast - - static let decayInterval: TimeInterval = 15 - static let packetWindowSeconds: TimeInterval = 60 - static let secondsPerMinute: TimeInterval = 60 - static let connectedStaleInterval: TimeInterval = 30 - static let disconnectGracePeriod: TimeInterval = 300 - static let updateInterval: TimeInterval = 15 - - /// Packets in the trailing `packetWindowSeconds` as a per-minute rate. A - /// full-minute window makes this the true count for that minute, so the - /// displayed value matches the RX Log instead of extrapolating a burst. - private var recentPacketsPerMinute: Int { - Self.packetsPerMinute(timestamps: recentPacketTimestamps, now: .now) + static let enabledKey = AppStorageKey.liveActivityEnabled.rawValue + + private let logger = Logger(subsystem: "com.mc1", category: "LiveActivityManager") + + /// Returns whether BLE is currently connected at the radio layer. Defaults + /// to `false` so a missing wiring fails closed. + var connectionStateProvider: (@MainActor () -> Bool)? + + private var currentActivity: Activity? + private var decayTimer: Task? + private var disconnectTimer: Task? + private var enablementTask: Task? + private var stateObservationTask: Task? + private var throttleTask: Task? + private var ocvArray: [Int] = [] + private var recentPacketTimestamps: [Date] = [] + private var pendingUpdate: PendingUpdate? + private var lastFlushDate: Date = .distantPast + + static let decayInterval: TimeInterval = 15 + static let packetWindowSeconds: TimeInterval = 60 + static let secondsPerMinute: TimeInterval = 60 + static let connectedStaleInterval: TimeInterval = 30 + static let disconnectGracePeriod: TimeInterval = 300 + static let updateInterval: TimeInterval = 15 + + /// Packets in the trailing `packetWindowSeconds` as a per-minute rate. A + /// full-minute window makes this the true count for that minute, so the + /// displayed value matches the RX Log instead of extrapolating a burst. + private var recentPacketsPerMinute: Int { + Self.packetsPerMinute(timestamps: recentPacketTimestamps, now: .now) + } + + /// Packets within `window` ending at `now` as a per-minute rate. Pure so the + /// rate math is unit-testable without an `Activity`. + static func packetsPerMinute( + timestamps: [Date], + now: Date, + window: TimeInterval = packetWindowSeconds + ) -> Int { + let cutoff = now.addingTimeInterval(-window) + let count = timestamps.count(where: { $0 >= cutoff }) + return Int((Double(count) * secondsPerMinute / window).rounded()) + } + + var hasActiveActivity: Bool { + currentActivity != nil + } + + var isEnabled: Bool { + didSet { UserDefaults.standard.set(isEnabled, forKey: Self.enabledKey) } + } + + init() { + isEnabled = UserDefaults.standard.object(forKey: Self.enabledKey) as? Bool ?? AppStorageKey.defaultLiveActivityEnabled + } + + // MARK: - Pending Update + + private struct PendingUpdate { + var isConnected: Bool? + var battery: Int?? + var packetsPerMinute: Int? + var unreadCount: Int? + var disconnectedDate: Date?? + } + + // MARK: - Lifecycle + + func startObservingEnablement() { + enablementTask?.cancel() + enablementTask = Task { [weak self] in + for await enabled in ActivityAuthorizationInfo().activityEnablementUpdates { + guard let self else { break } + if !enabled { + await endActivity() + } + } } - - /// Packets within `window` ending at `now` as a per-minute rate. Pure so the - /// rate math is unit-testable without an `Activity`. - static func packetsPerMinute( - timestamps: [Date], - now: Date, - window: TimeInterval = packetWindowSeconds - ) -> Int { - let cutoff = now.addingTimeInterval(-window) - let count = timestamps.filter { $0 >= cutoff }.count - return Int((Double(count) * secondsPerMinute / window).rounded()) + } + + func handleConnectionReady( + device: DeviceDTO, + ocvArray: [Int], + unreadCount: Int + ) async { + self.ocvArray = ocvArray + + // If reconnecting to same device within grace period, restore connected state + if let activity = currentActivity, + let activityRadioID = activity.attributes.radioID, + activityRadioID == device.radioID { + disconnectTimer?.cancel() + disconnectTimer = nil + recentPacketTimestamps = [] + clearPendingUpdate() + await updateActivity( + isConnected: true, + battery: .some(nil), + packetsPerMinute: 0, + unreadCount: unreadCount, + disconnectedDate: .some(nil) + ) + startDecayTimer() + startObservingActivityState() + return } - var hasActiveActivity: Bool { currentActivity != nil } - - var isEnabled: Bool { - didSet { UserDefaults.standard.set(isEnabled, forKey: Self.enabledKey) } + // If reconnecting to a different device, end the old activity first + if currentActivity != nil { + await endActivity() } - init() { - isEnabled = UserDefaults.standard.object(forKey: Self.enabledKey) as? Bool ?? AppStorageKey.defaultLiveActivityEnabled + await startActivity( + deviceName: device.nodeName, + radioID: device.radioID, + unreadCount: unreadCount + ) + startDecayTimer() + } + + func handleConnectionLost() async { + guard currentActivity != nil else { return } + + stopDecayTimer() + recentPacketTimestamps = [] + clearPendingUpdate() + await updateActivity( + isConnected: false, + battery: .some(nil), + packetsPerMinute: 0, + unreadCount: 0, + disconnectedDate: .some(.now) + ) + + disconnectTimer?.cancel() + disconnectTimer = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.disconnectGracePeriod)) + guard !Task.isCancelled else { return } + await self?.endActivity() } + } - // MARK: - Pending Update + func handleEnterBackground() { + stopDecayTimer() + } - private struct PendingUpdate { - var isConnected: Bool? - var battery: Int?? - var packetsPerMinute: Int? - var unreadCount: Int? - var disconnectedDate: Date?? + func handleReturnToForeground() { + guard hasActiveActivity else { return } + startDecayTimer() + if pendingUpdate != nil { + Task { await flushPendingUpdate() } } - - // MARK: - Lifecycle - - func startObservingEnablement() { - enablementTask?.cancel() - enablementTask = Task { [weak self] in - for await enabled in ActivityAuthorizationInfo().activityEnablementUpdates { - guard let self else { break } - if !enabled { - await self.endActivity() - } - } - } + } + + func handlePacketReceived() async { + let now = Date.now + recentPacketTimestamps.append(now) + let cutoff = now.addingTimeInterval(-Self.packetWindowSeconds) + recentPacketTimestamps.removeAll { $0 < cutoff } + await scheduleUpdate(packetsPerMinute: recentPacketsPerMinute) + } + + func handleBatteryChanged(battery: BatteryInfo) async { + let percent = battery.percentage(using: ocvArray) + await scheduleUpdate(battery: .some(percent)) + await flushPendingUpdate() + } + + func handleUnreadCountChanged(unreadCount: Int) async { + await scheduleUpdate(unreadCount: unreadCount) + await flushPendingUpdate() + } + + func setEnabled(_ enabled: Bool) async { + isEnabled = enabled + if !enabled { + await endActivity() } + } - func handleConnectionReady( - device: DeviceDTO, - ocvArray: [Int], - unreadCount: Int - ) async { - self.ocvArray = ocvArray - - // If reconnecting to same device within grace period, restore connected state - if let activity = currentActivity, - let activityRadioID = activity.attributes.radioID, - activityRadioID == device.radioID { - disconnectTimer?.cancel() - disconnectTimer = nil - recentPacketTimestamps = [] - clearPendingUpdate() - await updateActivity( - isConnected: true, - battery: .some(nil), - packetsPerMinute: 0, - unreadCount: unreadCount, - disconnectedDate: .some(nil) - ) - startDecayTimer() - startObservingActivityState() - return - } + // MARK: - App relaunch recovery - // If reconnecting to a different device, end the old activity first - if currentActivity != nil { - await endActivity() - } + func recoverExistingActivity() async { + let allActivities = Activity.activities - await startActivity( - deviceName: device.nodeName, - radioID: device.radioID, - unreadCount: unreadCount - ) - startDecayTimer() + // Clean up ended/dismissed activities still visible per their dismissal policy. + for activity in allActivities where + activity.activityState == .ended || activity.activityState == .dismissed { + await activity.end(nil, dismissalPolicy: .immediate) } - func handleConnectionLost() async { - guard currentActivity != nil else { return } - - stopDecayTimer() - recentPacketTimestamps = [] - clearPendingUpdate() - await updateActivity( - isConnected: false, - battery: .some(nil), - packetsPerMinute: 0, - unreadCount: 0, - disconnectedDate: .some(.now) - ) - - disconnectTimer?.cancel() - disconnectTimer = Task { [weak self] in - try? await Task.sleep(for: .seconds(Self.disconnectGracePeriod)) - guard !Task.isCancelled else { return } - await self?.endActivity() - } + // Adopt the first active/stale activity; immediately end any extras as + // orphans so the user never sees two LAs at once. + let activeOrStale = allActivities.filter { + $0.activityState == .active || $0.activityState == .stale } - - func handleEnterBackground() { - stopDecayTimer() + for orphan in activeOrStale.dropFirst() { + logger.warning("Ending duplicate orphan Live Activity on recovery") + await orphan.end(nil, dismissalPolicy: .immediate) } - func handleReturnToForeground() { - guard hasActiveActivity else { return } - startDecayTimer() - if pendingUpdate != nil { - Task { await flushPendingUpdate() } - } - } + currentActivity = activeOrStale.first + guard let activity = currentActivity else { return } - func handlePacketReceived() async { - let now = Date.now - recentPacketTimestamps.append(now) - let cutoff = now.addingTimeInterval(-Self.packetWindowSeconds) - recentPacketTimestamps.removeAll { $0 < cutoff } - await scheduleUpdate(packetsPerMinute: recentPacketsPerMinute) - } + startObservingActivityState() - func handleBatteryChanged(battery: BatteryInfo) async { - let percent = battery.percentage(using: ocvArray) - await scheduleUpdate(battery: .some(percent)) - await flushPendingUpdate() + // If the LA is cached as connected, force re-validation. The radio may + // actually be disconnected (prior session crashed mid-connection, app + // was killed during a drop, etc.). handleConnectionReady's same-device + // branch will restore connected state once auto-reconnect lands. + if activity.content.state.isConnected { + logger.info("Recovered Live Activity in cached connected state — forcing re-validation as disconnected") + await handleConnectionLost() + return } - func handleUnreadCountChanged(unreadCount: Int) async { - await scheduleUpdate(unreadCount: unreadCount) - await flushPendingUpdate() + // Already disconnected — re-arm the grace timer using the persisted + // disconnectedDate. If it's missing or already expired, end now. + guard let disconnectedDate = activity.content.state.disconnectedDate else { + await endActivity() + return } - func setEnabled(_ enabled: Bool) async { - isEnabled = enabled - if !enabled { - await endActivity() - } + let elapsed = Date.now.timeIntervalSince(disconnectedDate) + let remaining = Self.disconnectGracePeriod - elapsed + + if remaining > 0 { + disconnectTimer = Task { [weak self] in + try? await Task.sleep(for: .seconds(remaining)) + guard !Task.isCancelled else { return } + await self?.endActivity() + } + } else { + await endActivity() } + } - // MARK: - App relaunch recovery - - func recoverExistingActivity() async { - let allActivities = Activity.activities - - // Clean up ended/dismissed activities still visible per their dismissal policy. - for activity in allActivities where - activity.activityState == .ended || activity.activityState == .dismissed { - await activity.end(nil, dismissalPolicy: .immediate) - } - - // Adopt the first active/stale activity; immediately end any extras as - // orphans so the user never sees two LAs at once. - let activeOrStale = allActivities.filter { - $0.activityState == .active || $0.activityState == .stale - } - for orphan in activeOrStale.dropFirst() { - logger.warning("Ending duplicate orphan Live Activity on recovery") - await orphan.end(nil, dismissalPolicy: .immediate) - } - - currentActivity = activeOrStale.first - guard let activity = currentActivity else { return } - - startObservingActivityState() - - // If the LA is cached as connected, force re-validation. The radio may - // actually be disconnected (prior session crashed mid-connection, app - // was killed during a drop, etc.). handleConnectionReady's same-device - // branch will restore connected state once auto-reconnect lands. - if activity.content.state.isConnected { - logger.info("Recovered Live Activity in cached connected state — forcing re-validation as disconnected") - await handleConnectionLost() - return - } - - // Already disconnected — re-arm the grace timer using the persisted - // disconnectedDate. If it's missing or already expired, end now. - guard let disconnectedDate = activity.content.state.disconnectedDate else { - await endActivity() - return - } + // MARK: - Private - let elapsed = Date.now.timeIntervalSince(disconnectedDate) - let remaining = Self.disconnectGracePeriod - elapsed - - if remaining > 0 { - disconnectTimer = Task { [weak self] in - try? await Task.sleep(for: .seconds(remaining)) - guard !Task.isCancelled else { return } - await self?.endActivity() - } - } else { - await endActivity() - } + private func startDecayTimer() { + stopDecayTimer() + decayTimer = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(Self.decayInterval)) + guard !Task.isCancelled, let self else { return } + let cutoff = Date.now.addingTimeInterval(-Self.packetWindowSeconds) + recentPacketTimestamps.removeAll { $0 < cutoff } + await scheduleUpdate(packetsPerMinute: recentPacketsPerMinute) + } } - - // MARK: - Private - - private func startDecayTimer() { - stopDecayTimer() - decayTimer = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(Self.decayInterval)) - guard !Task.isCancelled, let self else { return } - let cutoff = Date.now.addingTimeInterval(-Self.packetWindowSeconds) - self.recentPacketTimestamps.removeAll { $0 < cutoff } - await self.scheduleUpdate(packetsPerMinute: self.recentPacketsPerMinute) - } - } + } + + private func stopDecayTimer() { + decayTimer?.cancel() + decayTimer = nil + } + + private func clearActivityReference() { + currentActivity = nil + stateObservationTask?.cancel() + stateObservationTask = nil + // Everything else here is bound to the activity's lifetime: timers + // that would mutate it, throttled updates queued for it, packet + // timestamps used to compute its rate. If any of these survive the + // reference being cleared, they can leak onto the next activity (a + // stale disconnect timer ending a fresh LA, a queued flush applying + // old battery/rate, packet-rate carry-over). + disconnectTimer?.cancel() + disconnectTimer = nil + stopDecayTimer() + clearPendingUpdate() + recentPacketTimestamps = [] + } + + private func startActivity( + deviceName: String, + radioID: UUID?, + unreadCount: Int + ) async { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + logger.warning("Cannot start Live Activity: not authorized") + return + } + guard isEnabled else { + logger.debug("Cannot start Live Activity: disabled by user") + return + } + guard !DemoModeManager.shared.isEnabled else { return } + + // Reclaim slots held by ended/dismissed entries before requesting. + // iOS keeps them in `.activities` for up to 4 hours (Lock Screen + // dismissal window) and counts them against the per-app activity + // cap, so `Activity.request` would otherwise throw + // `ActivityAuthorizationError.targetMaximumExceeded`. Mirrors + // `recoverExistingActivity`. + for activity in Activity.activities where + activity.activityState == .ended || activity.activityState == .dismissed { + await activity.end(nil, dismissalPolicy: .immediate) } - private func stopDecayTimer() { - decayTimer?.cancel() - decayTimer = nil + guard !Activity.activities.contains(where: { + $0.activityState == .active || $0.activityState == .stale + }) else { + logger.warning("Cannot start Live Activity: one already running") + return } - private func clearActivityReference() { - currentActivity = nil - stateObservationTask?.cancel() - stateObservationTask = nil - // Everything else here is bound to the activity's lifetime: timers - // that would mutate it, throttled updates queued for it, packet - // timestamps used to compute its rate. If any of these survive the - // reference being cleared, they can leak onto the next activity (a - // stale disconnect timer ending a fresh LA, a queued flush applying - // old battery/rate, packet-rate carry-over). - disconnectTimer?.cancel() - disconnectTimer = nil - stopDecayTimer() - clearPendingUpdate() - recentPacketTimestamps = [] + let attributes = MeshStatusAttributes(deviceName: deviceName, radioID: radioID) + let state = MeshStatusAttributes.ContentState( + isConnected: true, + batteryPercent: nil, + packetsPerMinute: 0, + unreadCount: unreadCount, + disconnectedDate: nil + ) + let staleDate = Calendar.current.date(byAdding: .minute, value: 5, to: .now) + let content = ActivityContent(state: state, staleDate: staleDate) + + do { + currentActivity = try Activity.request( + attributes: attributes, + content: content, + pushType: nil + ) + LiveActivityTip.radioConnected.sendDonation() + startObservingActivityState() + logger.info("Started Live Activity for \(deviceName, privacy: .public)") + } catch { + logger.error("Failed to start Live Activity: \(error.localizedDescription, privacy: .public)") } + } - private func startActivity( - deviceName: String, - radioID: UUID?, - unreadCount: Int - ) async { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { - logger.warning("Cannot start Live Activity: not authorized") - return - } - guard isEnabled else { - logger.debug("Cannot start Live Activity: disabled by user") - return - } - guard !DemoModeManager.shared.isEnabled else { return } - - // Reclaim slots held by ended/dismissed entries before requesting. - // iOS keeps them in `.activities` for up to 4 hours (Lock Screen - // dismissal window) and counts them against the per-app activity - // cap, so `Activity.request` would otherwise throw - // `ActivityAuthorizationError.targetMaximumExceeded`. Mirrors - // `recoverExistingActivity`. - for activity in Activity.activities where - activity.activityState == .ended || activity.activityState == .dismissed { - await activity.end(nil, dismissalPolicy: .immediate) - } + // MARK: - Activity State Observation - guard !Activity.activities.contains(where: { - $0.activityState == .active || $0.activityState == .stale - }) else { - logger.warning("Cannot start Live Activity: one already running") - return - } + private func startObservingActivityState() { + stateObservationTask?.cancel() + guard let activity = currentActivity else { return } + stateObservationTask = Task { [weak self] in + for await state in activity.activityStateUpdates { + guard let self else { break } + switch state { + case .ended: + let lastState = activity.content.state + let deviceName = activity.attributes.deviceName + let radioID = activity.attributes.radioID + + // Clear the field synchronously before any await so a + // re-entrant @MainActor caller (e.g. handleConnectionReady) + // can't reassign currentActivity to a replacement and have + // it nulled out when we return. + clearActivityReference() + + // Dismiss the captured (now-detached) activity immediately + // so it doesn't linger on screen alongside any replacement + // (system-ended activities default to ~4h visible dismissal). + await activity.end(nil, dismissalPolicy: .immediate) + + // Don't resurrect a "connected" LA from cached state — + // only restart when the radio is actually connected. + let isCurrentlyConnected = connectionStateProvider?() ?? false + if isCurrentlyConnected { + logger.info("System ended Live Activity, restarting (radio still connected)") + await startActivity(deviceName: deviceName, radioID: radioID, unreadCount: lastState.unreadCount) + startDecayTimer() + } else { + logger.info("System ended Live Activity, not restarting (radio disconnected)") + } + return - let attributes = MeshStatusAttributes(deviceName: deviceName, radioID: radioID) - let state = MeshStatusAttributes.ContentState( - isConnected: true, - batteryPercent: nil, - packetsPerMinute: 0, - unreadCount: unreadCount, - disconnectedDate: nil - ) - let staleDate = Calendar.current.date(byAdding: .minute, value: 5, to: .now) - let content = ActivityContent(state: state, staleDate: staleDate) - - do { - currentActivity = try Activity.request( - attributes: attributes, - content: content, - pushType: nil - ) - LiveActivityTip.radioConnected.sendDonation() - startObservingActivityState() - logger.info("Started Live Activity for \(deviceName, privacy: .public)") - } catch { - logger.error("Failed to start Live Activity: \(error.localizedDescription, privacy: .public)") - } - } + case .dismissed: + clearActivityReference() + logger.info("Live Activity dismissed by user") + return + + case .stale: + let currentState = activity.content.state + let isCurrentlyConnected = connectionStateProvider?() ?? false + + if currentState.isConnected, !isCurrentlyConnected { + // LA cached as connected, but the radio is actually + // disconnected (we missed a notification somewhere). + // Force the disconnect path instead of refreshing. + logger.warning("Stale Live Activity cached connected but radio disconnected — forcing handleConnectionLost") + await handleConnectionLost() + } else if currentState.isConnected, currentState.packetsPerMinute > 0 { + logger.debug("Live Activity stale with active rate, resetting to 0") + recentPacketTimestamps = [] + clearPendingUpdate() + await updateActivity(packetsPerMinute: 0) + } else if pendingUpdate != nil { + await flushPendingUpdate() + } else { + await updateActivity() + } - // MARK: - Activity State Observation - - private func startObservingActivityState() { - stateObservationTask?.cancel() - guard let activity = currentActivity else { return } - stateObservationTask = Task { [weak self] in - for await state in activity.activityStateUpdates { - guard let self else { break } - switch state { - case .ended: - let lastState = activity.content.state - let deviceName = activity.attributes.deviceName - let radioID = activity.attributes.radioID - - // Clear the field synchronously before any await so a - // re-entrant @MainActor caller (e.g. handleConnectionReady) - // can't reassign currentActivity to a replacement and have - // it nulled out when we return. - self.clearActivityReference() - - // Dismiss the captured (now-detached) activity immediately - // so it doesn't linger on screen alongside any replacement - // (system-ended activities default to ~4h visible dismissal). - await activity.end(nil, dismissalPolicy: .immediate) - - // Don't resurrect a "connected" LA from cached state — - // only restart when the radio is actually connected. - let isCurrentlyConnected = self.connectionStateProvider?() ?? false - if isCurrentlyConnected { - logger.info("System ended Live Activity, restarting (radio still connected)") - await self.startActivity(deviceName: deviceName, radioID: radioID, unreadCount: lastState.unreadCount) - self.startDecayTimer() - } else { - logger.info("System ended Live Activity, not restarting (radio disconnected)") - } - return - - case .dismissed: - self.clearActivityReference() - logger.info("Live Activity dismissed by user") - return - - case .stale: - let currentState = activity.content.state - let isCurrentlyConnected = self.connectionStateProvider?() ?? false - - if currentState.isConnected && !isCurrentlyConnected { - // LA cached as connected, but the radio is actually - // disconnected (we missed a notification somewhere). - // Force the disconnect path instead of refreshing. - logger.warning("Stale Live Activity cached connected but radio disconnected — forcing handleConnectionLost") - await self.handleConnectionLost() - } else if currentState.isConnected && currentState.packetsPerMinute > 0 { - logger.debug("Live Activity stale with active rate, resetting to 0") - self.recentPacketTimestamps = [] - self.clearPendingUpdate() - await self.updateActivity(packetsPerMinute: 0) - } else if self.pendingUpdate != nil { - await self.flushPendingUpdate() - } else { - await self.updateActivity() - } - - case .active: - break - - case .pending: - break - - @unknown default: - break - } - } - } - } + case .active: + break - // MARK: - Throttled Updates - - private func scheduleUpdate( - isConnected: Bool? = nil, - battery: Int?? = nil, - packetsPerMinute: Int? = nil, - unreadCount: Int? = nil, - disconnectedDate: Date?? = nil - ) async { - guard currentActivity != nil else { return } - - // Merge into pending update (last-writer-wins per field) - var pending = pendingUpdate ?? PendingUpdate() - if let isConnected { pending.isConnected = isConnected } - if let battery { pending.battery = battery } - if let packetsPerMinute { pending.packetsPerMinute = packetsPerMinute } - if let unreadCount { pending.unreadCount = unreadCount } - if let disconnectedDate { pending.disconnectedDate = disconnectedDate } - pendingUpdate = pending - - // Leading edge: flush immediately if enough time has passed - if Date.now.timeIntervalSince(lastFlushDate) >= Self.updateInterval { - await flushPendingUpdate() - return - } + case .pending: + break - // Trailing edge: schedule flush if not already scheduled - guard throttleTask == nil else { return } - let delay = Self.updateInterval - Date.now.timeIntervalSince(lastFlushDate) - throttleTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(delay)) - guard !Task.isCancelled, let self else { return } - await self.flushPendingUpdate() + @unknown default: + break } + } } - - private func flushPendingUpdate() async { - guard let pending = pendingUpdate else { return } - pendingUpdate = nil - throttleTask?.cancel() - throttleTask = nil - lastFlushDate = .now - await updateActivity( - isConnected: pending.isConnected, - battery: pending.battery, - packetsPerMinute: pending.packetsPerMinute, - unreadCount: pending.unreadCount, - disconnectedDate: pending.disconnectedDate - ) + } + + // MARK: - Throttled Updates + + private func scheduleUpdate( + isConnected: Bool? = nil, + battery: Int?? = nil, + packetsPerMinute: Int? = nil, + unreadCount: Int? = nil, + disconnectedDate: Date?? = nil + ) async { + guard currentActivity != nil else { return } + + // Merge into pending update (last-writer-wins per field) + var pending = pendingUpdate ?? PendingUpdate() + if let isConnected { pending.isConnected = isConnected } + if let battery { pending.battery = battery } + if let packetsPerMinute { pending.packetsPerMinute = packetsPerMinute } + if let unreadCount { pending.unreadCount = unreadCount } + if let disconnectedDate { pending.disconnectedDate = disconnectedDate } + pendingUpdate = pending + + // Leading edge: flush immediately if enough time has passed + if Date.now.timeIntervalSince(lastFlushDate) >= Self.updateInterval { + await flushPendingUpdate() + return } - private func clearPendingUpdate() { - pendingUpdate = nil - throttleTask?.cancel() - throttleTask = nil + // Trailing edge: schedule flush if not already scheduled + guard throttleTask == nil else { return } + let delay = Self.updateInterval - Date.now.timeIntervalSince(lastFlushDate) + throttleTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + await flushPendingUpdate() } - - /// Updates the Live Activity state. Pass `nil` to keep the current value, `.some(value)` to override. - private func updateActivity( - isConnected: Bool? = nil, - battery: Int?? = nil, - packetsPerMinute: Int? = nil, - unreadCount: Int? = nil, - disconnectedDate: Date?? = nil - ) async { - guard let current = currentActivity?.content.state else { return } - let state = MeshStatusAttributes.ContentState( - isConnected: isConnected ?? current.isConnected, - batteryPercent: battery ?? current.batteryPercent, - packetsPerMinute: packetsPerMinute ?? current.packetsPerMinute, - unreadCount: unreadCount ?? current.unreadCount, - disconnectedDate: disconnectedDate ?? current.disconnectedDate - ) - let staleDate: Date? = if state.isConnected && state.packetsPerMinute > 0 { - Date.now.addingTimeInterval(Self.connectedStaleInterval) - } else { - Calendar.current.date(byAdding: .minute, value: 5, to: .now) - } - let content = ActivityContent(state: state, staleDate: staleDate) - await currentActivity?.update(content) + } + + private func flushPendingUpdate() async { + guard let pending = pendingUpdate else { return } + pendingUpdate = nil + throttleTask?.cancel() + throttleTask = nil + lastFlushDate = .now + await updateActivity( + isConnected: pending.isConnected, + battery: pending.battery, + packetsPerMinute: pending.packetsPerMinute, + unreadCount: pending.unreadCount, + disconnectedDate: pending.disconnectedDate + ) + } + + private func clearPendingUpdate() { + pendingUpdate = nil + throttleTask?.cancel() + throttleTask = nil + } + + /// Updates the Live Activity state. Pass `nil` to keep the current value, `.some(value)` to override. + private func updateActivity( + isConnected: Bool? = nil, + battery: Int?? = nil, + packetsPerMinute: Int? = nil, + unreadCount: Int? = nil, + disconnectedDate: Date?? = nil + ) async { + guard let current = currentActivity?.content.state else { return } + let state = MeshStatusAttributes.ContentState( + isConnected: isConnected ?? current.isConnected, + batteryPercent: battery ?? current.batteryPercent, + packetsPerMinute: packetsPerMinute ?? current.packetsPerMinute, + unreadCount: unreadCount ?? current.unreadCount, + disconnectedDate: disconnectedDate ?? current.disconnectedDate + ) + let staleDate: Date? = if state.isConnected, state.packetsPerMinute > 0 { + Date.now.addingTimeInterval(Self.connectedStaleInterval) + } else { + Calendar.current.date(byAdding: .minute, value: 5, to: .now) } - - /// Checks whether the current activity reference is still valid. - /// If the activity was ended while suspended (and `activityStateUpdates` didn't fire), this catches it. - func validateActivityState() async { - guard let activity = currentActivity else { return } - switch activity.activityState { - case .ended: - let lastState = activity.content.state - let deviceName = activity.attributes.deviceName - let radioID = activity.attributes.radioID - - // Clear the field synchronously before the await so a re-entrant - // main-actor caller can't have its replacement currentActivity - // nulled out when we resume. - clearActivityReference() - await activity.end(nil, dismissalPolicy: .immediate) - - let isCurrentlyConnected = connectionStateProvider?() ?? false - if isCurrentlyConnected { - logger.info("Detected ended Live Activity on foreground, restarting (radio still connected)") - await startActivity(deviceName: deviceName, radioID: radioID, unreadCount: lastState.unreadCount) - startDecayTimer() - } else { - logger.info("Detected ended Live Activity on foreground, not restarting (radio disconnected)") - } - case .dismissed: - clearActivityReference() - case .active, .stale: - break - case .pending: - break - @unknown default: - break - } + let content = ActivityContent(state: state, staleDate: staleDate) + await currentActivity?.update(content) + } + + /// Checks whether the current activity reference is still valid. + /// If the activity was ended while suspended (and `activityStateUpdates` didn't fire), this catches it. + func validateActivityState() async { + guard let activity = currentActivity else { return } + switch activity.activityState { + case .ended: + let lastState = activity.content.state + let deviceName = activity.attributes.deviceName + let radioID = activity.attributes.radioID + + // Clear the field synchronously before the await so a re-entrant + // main-actor caller can't have its replacement currentActivity + // nulled out when we resume. + clearActivityReference() + await activity.end(nil, dismissalPolicy: .immediate) + + let isCurrentlyConnected = connectionStateProvider?() ?? false + if isCurrentlyConnected { + logger.info("Detected ended Live Activity on foreground, restarting (radio still connected)") + await startActivity(deviceName: deviceName, radioID: radioID, unreadCount: lastState.unreadCount) + startDecayTimer() + } else { + logger.info("Detected ended Live Activity on foreground, not restarting (radio disconnected)") + } + case .dismissed: + clearActivityReference() + case .active, .stale: + break + case .pending: + break + @unknown default: + break } - - func endActivity() async { - stopDecayTimer() - stateObservationTask?.cancel() - stateObservationTask = nil - disconnectTimer?.cancel() - disconnectTimer = nil - clearPendingUpdate() - recentPacketTimestamps = [] - for activity in Activity.activities { - await activity.end(nil, dismissalPolicy: .immediate) - } - currentActivity = nil - logger.info("Ended Live Activity") + } + + func endActivity() async { + stopDecayTimer() + stateObservationTask?.cancel() + stateObservationTask = nil + disconnectTimer?.cancel() + disconnectTimer = nil + clearPendingUpdate() + recentPacketTimestamps = [] + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) } + currentActivity = nil + logger.info("Ended Live Activity") + } } diff --git a/MC1/State/MapFocusRequest.swift b/MC1/State/MapFocusRequest.swift index 871afbdb..172b72a0 100644 --- a/MC1/State/MapFocusRequest.swift +++ b/MC1/State/MapFocusRequest.swift @@ -5,10 +5,10 @@ import CoreLocation /// Wraps the coordinate in an `Equatable` value because `CLLocationCoordinate2D` /// is not `Equatable`, which `MapView`'s `.onChange(of:)` requires. struct MapFocusRequest: Equatable { - let latitude: Double - let longitude: Double + let latitude: Double + let longitude: Double - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - } + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } diff --git a/MC1/State/MessageEvent.swift b/MC1/State/MessageEvent.swift index 1da4f7fc..b3593a5b 100644 --- a/MC1/State/MessageEvent.swift +++ b/MC1/State/MessageEvent.swift @@ -7,30 +7,30 @@ import MC1Services /// speculative or unreachable cases. Consumers should switch /// exhaustively (no `default`) so a new case becomes a compile error /// rather than a silent skip. -enum MessageEvent: Sendable, Equatable { - case directMessageReceived(message: MessageDTO, contact: ContactDTO) - case channelMessageReceived(message: MessageDTO, channelIndex: UInt8) - case roomMessageReceived(message: RoomMessageDTO, sessionID: UUID) - /// Fired when a message's status resolves to .sent or .delivered for an - /// original (non-resend) send. `roundTripTime` is supplied only when firmware - /// reports it (.delivered via end-to-end ACK); .sent transitions and - /// finalizeSend-path .delivered pass nil. Consumers may animate the bubble - /// status footer in place without a DB fetch — the dispatcher writes the DB - /// row before firing for all five sites that route through this case. - case messageStatusResolved(messageID: UUID, status: MessageStatus, roundTripTime: UInt32? = nil) - /// Fired after a channel-message resend (`MessageService.resendChannelMessage`) - /// completes. Carries no status payload because the resend path mutates - /// `heardRepeats` and `sendCount` alongside the status flip, and the bubble - /// status row reads both fields off the DTO. Consumers must route this case - /// through `enqueueReload` so the next refresh re-fetches every affected - /// field — the in-place `applyStatusUpdate` helper cannot refresh - /// `heardRepeats`/`sendCount`. - case messageResent(messageID: UUID) - case messageFailed(messageID: UUID) - case messageRetrying(messageID: UUID, attempt: Int, maxAttempts: Int) - case heardRepeatRecorded(messageID: UUID, count: Int) - case reactionReceived(messageID: UUID, summary: String) - case routingChanged(contactID: UUID, isFlood: Bool) - case roomMessageStatusUpdated(messageID: UUID) - case roomMessageFailed(messageID: UUID) +enum MessageEvent: Equatable { + case directMessageReceived(message: MessageDTO, contact: ContactDTO) + case channelMessageReceived(message: MessageDTO, channelIndex: UInt8) + case roomMessageReceived(message: RoomMessageDTO, sessionID: UUID) + /// Fired when a message's status resolves to .sent or .delivered for an + /// original (non-resend) send. `roundTripTime` is supplied only when firmware + /// reports it (.delivered via end-to-end ACK); .sent transitions and + /// finalizeSend-path .delivered pass nil. Consumers may animate the bubble + /// status footer in place without a DB fetch — the dispatcher writes the DB + /// row before firing for all five sites that route through this case. + case messageStatusResolved(messageID: UUID, status: MessageStatus, roundTripTime: UInt32? = nil) + /// Fired after a channel-message resend (`MessageService.resendChannelMessage`) + /// completes. Carries no status payload because the resend path mutates + /// `heardRepeats` and `sendCount` alongside the status flip, and the bubble + /// status row reads both fields off the DTO. Consumers must route this case + /// through `enqueueReload` so the next refresh re-fetches every affected + /// field — the in-place `applyStatusUpdate` helper cannot refresh + /// `heardRepeats`/`sendCount`. + case messageResent(messageID: UUID) + case messageFailed(messageID: UUID) + case messageRetrying(messageID: UUID, attempt: Int, maxAttempts: Int) + case heardRepeatRecorded(messageID: UUID, count: Int) + case reactionReceived(messageID: UUID, summary: String) + case routingChanged(contactID: UUID, isFlood: Bool) + case roomMessageStatusUpdated(messageID: UUID) + case roomMessageFailed(messageID: UUID) } diff --git a/MC1/State/MessageEventDispatcher.swift b/MC1/State/MessageEventDispatcher.swift index c64345e9..db87c5b7 100644 --- a/MC1/State/MessageEventDispatcher.swift +++ b/MC1/State/MessageEventDispatcher.swift @@ -16,118 +16,118 @@ import MC1Services /// stale subscription can outlive a reconnect. @MainActor final class MessageEventDispatcher { - private weak var appState: AppState? - private let stream: MessageEventStream + private weak var appState: AppState? + private let stream: MessageEventStream - /// Stream-consuming tasks, cancelled on re-wire and on disconnect. - /// Each consumed stream also ends when its `ServiceContainer` is torn down. - private var tasks: [Task] = [] + /// Stream-consuming tasks, cancelled on re-wire and on disconnect. + /// Each consumed stream also ends when its `ServiceContainer` is torn down. + private var tasks: [Task] = [] - init(appState: AppState, stream: MessageEventStream) { - self.appState = appState - self.stream = stream - } + init(appState: AppState, stream: MessageEventStream) { + self.appState = appState + self.stream = stream + } - func wire(services: ServiceContainer) { - cancelAll() - wireSyncCoordinator(services.syncCoordinator) - wireHeardRepeats(services.heardRepeatsService) - wireRemoteNode(services.remoteNodeService) - wireRoomServer(services.roomServerService) - wireMessageService(services.messageService) - } + func wire(services: ServiceContainer) { + cancelAll() + wireSyncCoordinator(services.syncCoordinator) + wireHeardRepeats(services.heardRepeatsService) + wireRemoteNode(services.remoteNodeService) + wireRoomServer(services.roomServerService) + wireMessageService(services.messageService) + } - /// Cancels every stream-consuming task. Called before re-wiring against a - /// fresh `ServiceContainer` and from `AppState`'s disconnect teardown. - func cancelAll() { - for task in tasks { - task.cancel() - } - tasks.removeAll() + /// Cancels every stream-consuming task. Called before re-wiring against a + /// fresh `ServiceContainer` and from `AppState`'s disconnect teardown. + func cancelAll() { + for task in tasks { + task.cancel() } + tasks.removeAll() + } - private func wireSyncCoordinator(_ syncCoordinator: SyncCoordinator) { - let events = syncCoordinator.dataEvents() - let task = Task { [weak appState, stream] in - for await event in events { - switch event { - case .directMessageReceived(let message, let contact): - stream.send(.directMessageReceived(message: message, contact: contact)) - case .channelMessageReceived(let message, let channelIndex): - stream.send(.channelMessageReceived(message: message, channelIndex: channelIndex)) - case .roomMessageReceived(let message): - stream.send(.roomMessageReceived(message: message, sessionID: message.sessionID)) - case .reactionReceived(let messageID, let summary): - stream.send(.reactionReceived(messageID: messageID, summary: summary)) - await appState?.handleReactionNotification(messageID: messageID) - case .contactsChanged, .conversationsChanged: - break - } - } + private func wireSyncCoordinator(_ syncCoordinator: SyncCoordinator) { + let events = syncCoordinator.dataEvents() + let task = Task { [weak appState, stream] in + for await event in events { + switch event { + case let .directMessageReceived(message, contact): + stream.send(.directMessageReceived(message: message, contact: contact)) + case let .channelMessageReceived(message, channelIndex): + stream.send(.channelMessageReceived(message: message, channelIndex: channelIndex)) + case let .roomMessageReceived(message): + stream.send(.roomMessageReceived(message: message, sessionID: message.sessionID)) + case let .reactionReceived(messageID, summary): + stream.send(.reactionReceived(messageID: messageID, summary: summary)) + await appState?.handleReactionNotification(messageID: messageID) + case .contactsChanged, .conversationsChanged: + break } - tasks.append(task) + } } + tasks.append(task) + } - private func wireHeardRepeats(_ heardRepeatsService: HeardRepeatsService) { - let events = heardRepeatsService.events() - let task = Task { [stream] in - for await event in events { - stream.send(.heardRepeatRecorded(messageID: event.messageID, count: event.count)) - } - } - tasks.append(task) + private func wireHeardRepeats(_ heardRepeatsService: HeardRepeatsService) { + let events = heardRepeatsService.events() + let task = Task { [stream] in + for await event in events { + stream.send(.heardRepeatRecorded(messageID: event.messageID, count: event.count)) + } } + tasks.append(task) + } - private func wireRemoteNode(_ remoteNodeService: RemoteNodeService) { - let events = remoteNodeService.events() - let task = Task { [weak appState] in - for await event in events { - switch event { - case .sessionStateChanged: - appState?.handleSessionStateChange() - } - } + private func wireRemoteNode(_ remoteNodeService: RemoteNodeService) { + let events = remoteNodeService.events() + let task = Task { [weak appState] in + for await event in events { + switch event { + case .sessionStateChanged: + appState?.handleSessionStateChange() } - tasks.append(task) + } } + tasks.append(task) + } - private func wireRoomServer(_ roomServerService: RoomServerService) { - let events = roomServerService.events() - let task = Task { [weak appState, stream] in - for await event in events { - switch event { - case .statusUpdated(let messageID, let status): - if status == .failed { - stream.send(.roomMessageFailed(messageID: messageID)) - } else { - stream.send(.roomMessageStatusUpdated(messageID: messageID)) - } - case .connectionRecovered: - appState?.handleSessionStateChange() - } - } + private func wireRoomServer(_ roomServerService: RoomServerService) { + let events = roomServerService.events() + let task = Task { [weak appState, stream] in + for await event in events { + switch event { + case let .statusUpdated(messageID, status): + if status == .failed { + stream.send(.roomMessageFailed(messageID: messageID)) + } else { + stream.send(.roomMessageStatusUpdated(messageID: messageID)) + } + case .connectionRecovered: + appState?.handleSessionStateChange() } - tasks.append(task) + } } + tasks.append(task) + } - private func wireMessageService(_ messageService: MessageService) { - let events = messageService.statusEvents() - let task = Task { [stream] in - for await event in events { - switch event { - case .statusResolved(let messageID, let status, let roundTripTime): - stream.send(.messageStatusResolved(messageID: messageID, status: status, roundTripTime: roundTripTime)) - case .resent(let messageID): - stream.send(.messageResent(messageID: messageID)) - case .retrying(let messageID, let attempt, let maxAttempts): - stream.send(.messageRetrying(messageID: messageID, attempt: attempt, maxAttempts: maxAttempts)) - case .routingChanged(let contactID, let isFlood): - stream.send(.routingChanged(contactID: contactID, isFlood: isFlood)) - case .failed(let messageID): - stream.send(.messageFailed(messageID: messageID)) - } - } + private func wireMessageService(_ messageService: MessageService) { + let events = messageService.statusEvents() + let task = Task { [stream] in + for await event in events { + switch event { + case let .statusResolved(messageID, status, roundTripTime): + stream.send(.messageStatusResolved(messageID: messageID, status: status, roundTripTime: roundTripTime)) + case let .resent(messageID): + stream.send(.messageResent(messageID: messageID)) + case let .retrying(messageID, attempt, maxAttempts): + stream.send(.messageRetrying(messageID: messageID, attempt: attempt, maxAttempts: maxAttempts)) + case let .routingChanged(contactID, isFlood): + stream.send(.routingChanged(contactID: contactID, isFlood: isFlood)) + case let .failed(messageID): + stream.send(.messageFailed(messageID: messageID)) } - tasks.append(task) + } } + tasks.append(task) + } } diff --git a/MC1/State/MessageEventStream.swift b/MC1/State/MessageEventStream.swift index e95b7868..f01c8c2c 100644 --- a/MC1/State/MessageEventStream.swift +++ b/MC1/State/MessageEventStream.swift @@ -15,34 +15,35 @@ import MC1Services /// consumers would force duplicating `SyncCoordinator`'s resolution. @MainActor final class MessageEventStream { + private var continuations: [UUID: AsyncStream.Continuation] = [:] - private var continuations: [UUID: AsyncStream.Continuation] = [:] - - func events() -> AsyncStream { - let id = UUID() - return AsyncStream { continuation in - self.continuations[id] = continuation - continuation.onTermination = { [weak self, id] _ in - Task { @MainActor in - self?.continuations.removeValue(forKey: id) - } - } + func events() -> AsyncStream { + let id = UUID() + return AsyncStream { continuation in + self.continuations[id] = continuation + continuation.onTermination = { [weak self, id] _ in + Task { @MainActor in + self?.continuations.removeValue(forKey: id) } + } } + } - func send(_ event: MessageEvent) { - var staleIDs: [UUID] = [] - for (id, continuation) in continuations { - if case .terminated = continuation.yield(event) { - staleIDs.append(id) - } - } - for id in staleIDs { continuations.removeValue(forKey: id) } + func send(_ event: MessageEvent) { + var staleIDs: [UUID] = [] + for (id, continuation) in continuations { + if case .terminated = continuation.yield(event) { + staleIDs.append(id) + } + } + for id in staleIDs { + continuations.removeValue(forKey: id) } + } - #if DEBUG + #if DEBUG func subscriberCount() -> Int { - continuations.count + continuations.count } - #endif + #endif } diff --git a/MC1/State/NavigationCoordinator.swift b/MC1/State/NavigationCoordinator.swift index 51efb07a..d0da6454 100644 --- a/MC1/State/NavigationCoordinator.swift +++ b/MC1/State/NavigationCoordinator.swift @@ -1,274 +1,273 @@ -import SwiftUI -import MC1Services import CoreLocation +import MC1Services +import SwiftUI /// Manages tab selection, pending navigation targets, and cross-tab navigation coordination. @Observable @MainActor final class NavigationCoordinator { + /// Selected tab index + var selectedTab: Int = 0 - /// Selected tab index - var selectedTab: Int = 0 - - var tabBarVisibility: Visibility = .visible - - /// Whether the split container can tile all three columns side by side, measured at the shell. - /// When true, row selection leaves the sidebar tiled open instead of collapsing it. Defaults to - /// false so an unmeasured layout takes the safe collapse-on-selection branch rather than claiming - /// wide while actually narrow and overlaying the sidebar on a too-narrow container. - var isSidebarWide = false - - /// Contact to navigate to - var pendingChatContact: ContactDTO? - - /// The currently selected route in the Chats split view detail pane - var chatsSelectedRoute: ChatRoute? - - /// Channel to navigate to - var pendingChannel: ChannelDTO? - - /// Room session to navigate to - var pendingRoomSession: RemoteNodeSessionDTO? - - /// Room session a notification tap wants the user to authenticate into, set - /// when the tapped room is not currently connected. ChatsView presents - /// RoomAuthenticationSheet, mirroring a disconnected-room list tap. - var pendingRoomAuthentication: RemoteNodeSessionDTO? - - /// Whether to navigate to Discovery - var pendingDiscoveryNavigation = false - - /// Contact to navigate to (for detail view on Contacts tab) - var pendingContactDetail: ContactDTO? - - /// The currently selected contact in the Nodes split view detail pane. Kept in memory - /// only and never persisted: it carries a public key and `radioID` (identity-bearing - /// data) that must not be written outside the encrypted backup envelope. - var selectedContact: ContactDTO? - - /// Whether the Nodes split view detail pane is showing Discovery rather than a contact. - /// Shared so the iPad content and detail columns agree on which detail to render. - var nodesShowingDiscovery = false - - /// The selected tool on the Tools tab, shared so the iPad content and detail columns agree on - /// which tool is open. Held here rather than in the split view so it survives the section-switch - /// teardown of the inactive Tools tree. Radio-only tools clear on disconnect (`requiresRadio`). - var selectedTool: ToolSelection? - - /// The selected settings page on the Settings tab, shared so the iPad content and detail columns - /// agree on which page is open. Held here rather than in the split view so it survives the - /// section-switch teardown of the inactive Settings tree. My Device pages clear on disconnect - /// (`requiresDevice`). - var selectedSetting: SettingsDetail? - - /// Message to scroll to after navigation (for reaction notifications) - var pendingScrollToMessageID: UUID? - - /// Whether device menu tip donation is pending (waiting for valid tab) - var pendingDeviceMenuTipDonation = false - - /// Coordinate the Map tab should drop a pin on and center, set by a chat coordinate tap. - var pendingMapFocus: MapFocusRequest? - - /// Pending contact-add confirmation triggered by a `meshcore://contact/add` link tap inside any chat surface. - var pendingContactLink: MeshCoreURLParser.ContactResult? - - /// Pending channel-join confirmation triggered by a `meshcore://channel/...` link tap inside any chat surface. - var pendingChannelLink: MeshCoreURLParser.ChannelResult? - - /// Pending hashtag-channel join sheet triggered by a `meshcoreone://hashtag/...` link tap inside any chat surface. - var pendingHashtag: HashtagJoinRequest? - - // MARK: - Navigation - - func navigateToChat(with contact: ContactDTO, scrollToMessageID: UUID? = nil) { - tabBarVisibility = .hidden // Hide tab bar BEFORE switching tabs - pendingChatContact = contact - pendingScrollToMessageID = scrollToMessageID - chatsSelectedRoute = .direct(contact) - selectedTab = 0 - } - - func navigateToRoom(with session: RemoteNodeSessionDTO) { - tabBarVisibility = .hidden // Hide tab bar BEFORE switching tabs - pendingRoomSession = session - chatsSelectedRoute = .room(session) - selectedTab = 0 - } - - func navigateToChannel(with channel: ChannelDTO, scrollToMessageID: UUID? = nil) { - tabBarVisibility = .hidden - pendingChannel = channel - pendingScrollToMessageID = scrollToMessageID - chatsSelectedRoute = .channel(channel) - selectedTab = 0 - } - - func navigateToDiscovery() { - pendingDiscoveryNavigation = true - selectedTab = 1 - } - - func navigateToContacts() { - selectedTab = 1 - } - - func navigateToContactDetail(_ contact: ContactDTO) { - pendingContactDetail = contact - selectedTab = 1 - } - - func navigateToMap(coordinate: CLLocationCoordinate2D) { - pendingMapFocus = MapFocusRequest(latitude: coordinate.latitude, - longitude: coordinate.longitude) - selectedTab = AppTab.map.rawValue - } - - func clearPendingNavigation() { - pendingChatContact = nil - } + var tabBarVisibility: Visibility = .visible + + /// Whether the split container can tile all three columns side by side, measured at the shell. + /// When true, row selection leaves the sidebar tiled open instead of collapsing it. Defaults to + /// false so an unmeasured layout takes the safe collapse-on-selection branch rather than claiming + /// wide while actually narrow and overlaying the sidebar on a too-narrow container. + var isSidebarWide = false - func clearPendingRoomNavigation() { - pendingRoomSession = nil + /// Contact to navigate to + var pendingChatContact: ContactDTO? + + /// The currently selected route in the Chats split view detail pane + var chatsSelectedRoute: ChatRoute? + + /// Channel to navigate to + var pendingChannel: ChannelDTO? + + /// Room session to navigate to + var pendingRoomSession: RemoteNodeSessionDTO? + + /// Room session a notification tap wants the user to authenticate into, set + /// when the tapped room is not currently connected. ChatsView presents + /// RoomAuthenticationSheet, mirroring a disconnected-room list tap. + var pendingRoomAuthentication: RemoteNodeSessionDTO? + + /// Whether to navigate to Discovery + var pendingDiscoveryNavigation = false + + /// Contact to navigate to (for detail view on Contacts tab) + var pendingContactDetail: ContactDTO? + + /// The currently selected contact in the Nodes split view detail pane. Kept in memory + /// only and never persisted: it carries a public key and `radioID` (identity-bearing + /// data) that must not be written outside the encrypted backup envelope. + var selectedContact: ContactDTO? + + /// Whether the Nodes split view detail pane is showing Discovery rather than a contact. + /// Shared so the iPad content and detail columns agree on which detail to render. + var nodesShowingDiscovery = false + + /// The selected tool on the Tools tab, shared so the iPad content and detail columns agree on + /// which tool is open. Held here rather than in the split view so it survives the section-switch + /// teardown of the inactive Tools tree. Radio-only tools clear on disconnect (`requiresRadio`). + var selectedTool: ToolSelection? + + /// The selected settings page on the Settings tab, shared so the iPad content and detail columns + /// agree on which page is open. Held here rather than in the split view so it survives the + /// section-switch teardown of the inactive Settings tree. My Device pages clear on disconnect + /// (`requiresDevice`). + var selectedSetting: SettingsDetail? + + /// Message to scroll to after navigation (for reaction notifications) + var pendingScrollToMessageID: UUID? + + /// Whether device menu tip donation is pending (waiting for valid tab) + var pendingDeviceMenuTipDonation = false + + /// Coordinate the Map tab should drop a pin on and center, set by a chat coordinate tap. + var pendingMapFocus: MapFocusRequest? + + /// Pending contact-add confirmation triggered by a `meshcore://contact/add` link tap inside any chat surface. + var pendingContactLink: MeshCoreURLParser.ContactResult? + + /// Pending channel-join confirmation triggered by a `meshcore://channel/...` link tap inside any chat surface. + var pendingChannelLink: MeshCoreURLParser.ChannelResult? + + /// Pending hashtag-channel join sheet triggered by a `meshcoreone://hashtag/...` link tap inside any chat surface. + var pendingHashtag: HashtagJoinRequest? + + // MARK: - Navigation + + func navigateToChat(with contact: ContactDTO, scrollToMessageID: UUID? = nil) { + tabBarVisibility = .hidden // Hide tab bar BEFORE switching tabs + pendingChatContact = contact + pendingScrollToMessageID = scrollToMessageID + chatsSelectedRoute = .direct(contact) + selectedTab = 0 + } + + func navigateToRoom(with session: RemoteNodeSessionDTO) { + tabBarVisibility = .hidden // Hide tab bar BEFORE switching tabs + pendingRoomSession = session + chatsSelectedRoute = .room(session) + selectedTab = 0 + } + + func navigateToChannel(with channel: ChannelDTO, scrollToMessageID: UUID? = nil) { + tabBarVisibility = .hidden + pendingChannel = channel + pendingScrollToMessageID = scrollToMessageID + chatsSelectedRoute = .channel(channel) + selectedTab = 0 + } + + func navigateToDiscovery() { + pendingDiscoveryNavigation = true + selectedTab = 1 + } + + func navigateToContacts() { + selectedTab = 1 + } + + func navigateToContactDetail(_ contact: ContactDTO) { + pendingContactDetail = contact + selectedTab = 1 + } + + func navigateToMap(coordinate: CLLocationCoordinate2D) { + pendingMapFocus = MapFocusRequest(latitude: coordinate.latitude, + longitude: coordinate.longitude) + selectedTab = AppTab.map.rawValue + } + + func clearPendingNavigation() { + pendingChatContact = nil + } + + func clearPendingRoomNavigation() { + pendingRoomSession = nil + } + + func clearPendingRoomAuthentication() { + pendingRoomAuthentication = nil + } + + func clearPendingChannelNavigation() { + pendingChannel = nil + } + + func clearPendingDiscoveryNavigation() { + pendingDiscoveryNavigation = false + } + + func clearPendingScrollToMessage() { + pendingScrollToMessageID = nil + } + + func clearPendingContactDetailNavigation() { + pendingContactDetail = nil + } + + func clearPendingMapFocus() { + pendingMapFocus = nil + } + + func clearPendingContactLink() { + pendingContactLink = nil + } + + func clearPendingChannelLink() { + pendingChannelLink = nil + } + + func clearPendingHashtag() { + pendingHashtag = nil + } + + /// Clears deep-link confirmation state and every per-radio detail selection so a pending sheet + /// cannot re-present, and a selection made on the previous radio cannot drive a detail pane, + /// after the connection is torn down and replaced. + func clearPendingLinks() { + pendingContactLink = nil + pendingChannelLink = nil + pendingHashtag = nil + clearPerRadioSelection() + } + + /// Resets every detail selection scoped to the current radio so a stale selection cannot aim a + /// section's detail pane at the wrong radio. Runs on disconnect and on a direct radio-to-radio + /// switch. Line of Sight is preserved because it runs offline and is not radio-scoped. + func clearPerRadioSelection() { + selectedContact = nil + nodesShowingDiscovery = false + chatsSelectedRoute = nil + clearPerDeviceSelection() + } + + /// Clears only device-scoped selections (the radio-requiring tool and the My Device settings + /// page), but keeps an open Chats/Nodes detail so a manual disconnect does not eject the user from + /// an open conversation. Line of Sight and app-wide settings are radio-independent and preserved. + func clearPerDeviceSelection() { + if selectedTool?.requiresRadio == true { + selectedTool = nil } - - func clearPendingRoomAuthentication() { - pendingRoomAuthentication = nil + if selectedSetting?.requiresDevice == true { + selectedSetting = nil } - - func clearPendingChannelNavigation() { - pendingChannel = nil - } - - func clearPendingDiscoveryNavigation() { - pendingDiscoveryNavigation = false + } + + /// Tabs where BLEStatusIndicatorView exists and the device menu tip can anchor (Chats, Contacts, Map). + var isOnValidTabForDeviceMenuTip: Bool { + selectedTab == AppTab.chats.rawValue + || selectedTab == AppTab.nodes.rawValue + || selectedTab == AppTab.map.rawValue + } + + // MARK: - Notification Handlers + + /// Configure notification tap handlers that navigate to conversations. + /// Called from AppState.configureNotificationHandlers() when services become available. + func configureNotificationHandlers( + notificationService: NotificationService, + dataStore: PersistenceStore, + connectedDevice: @escaping @Sendable @MainActor () -> DeviceDTO? + ) { + // Direct message notification tap + notificationService.onNotificationTapped = { [weak self] contactID in + guard let self else { return } + guard let contact = try? await dataStore.fetchContact(id: contactID) else { return } + navigateToChat(with: contact) } - func clearPendingScrollToMessage() { - pendingScrollToMessageID = nil - } - - func clearPendingContactDetailNavigation() { - pendingContactDetail = nil - } - - func clearPendingMapFocus() { - pendingMapFocus = nil - } - - func clearPendingContactLink() { - pendingContactLink = nil - } - - func clearPendingChannelLink() { - pendingChannelLink = nil - } - - func clearPendingHashtag() { - pendingHashtag = nil - } - - /// Clears deep-link confirmation state and every per-radio detail selection so a pending sheet - /// cannot re-present, and a selection made on the previous radio cannot drive a detail pane, - /// after the connection is torn down and replaced. - func clearPendingLinks() { - pendingContactLink = nil - pendingChannelLink = nil - pendingHashtag = nil - clearPerRadioSelection() - } - - /// Resets every detail selection scoped to the current radio so a stale selection cannot aim a - /// section's detail pane at the wrong radio. Runs on disconnect and on a direct radio-to-radio - /// switch. Line of Sight is preserved because it runs offline and is not radio-scoped. - func clearPerRadioSelection() { - selectedContact = nil - nodesShowingDiscovery = false - chatsSelectedRoute = nil - clearPerDeviceSelection() - } - - /// Clears only device-scoped selections (the radio-requiring tool and the My Device settings - /// page), but keeps an open Chats/Nodes detail so a manual disconnect does not eject the user from - /// an open conversation. Line of Sight and app-wide settings are radio-independent and preserved. - func clearPerDeviceSelection() { - if selectedTool?.requiresRadio == true { - selectedTool = nil - } - if selectedSetting?.requiresDevice == true { - selectedSetting = nil + // New contact notification tap + notificationService.onNewContactNotificationTapped = { [weak self] contactID in + guard let self else { return } + if connectedDevice()?.manualAddContacts == true { + navigateToDiscovery() + } else { + guard let contact = try? await dataStore.fetchContact(id: contactID) else { + navigateToContacts() + return } + navigateToContactDetail(contact) + } } - /// Tabs where BLEStatusIndicatorView exists and the device menu tip can anchor (Chats, Contacts, Map). - var isOnValidTabForDeviceMenuTip: Bool { - selectedTab == AppTab.chats.rawValue - || selectedTab == AppTab.nodes.rawValue - || selectedTab == AppTab.map.rawValue + // Channel notification tap + notificationService.onChannelNotificationTapped = { [weak self] radioID, channelIndex in + guard let self else { return } + guard let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) else { return } + navigateToChannel(with: channel) } - // MARK: - Notification Handlers - - /// Configure notification tap handlers that navigate to conversations. - /// Called from AppState.configureNotificationHandlers() when services become available. - func configureNotificationHandlers( - notificationService: NotificationService, - dataStore: PersistenceStore, - connectedDevice: @escaping @Sendable @MainActor () -> DeviceDTO? - ) { - // Direct message notification tap - notificationService.onNotificationTapped = { [weak self] contactID in - guard let self else { return } - guard let contact = try? await dataStore.fetchContact(id: contactID) else { return } - self.navigateToChat(with: contact) - } - - // New contact notification tap - notificationService.onNewContactNotificationTapped = { [weak self] contactID in - guard let self else { return } - if connectedDevice()?.manualAddContacts == true { - self.navigateToDiscovery() - } else { - guard let contact = try? await dataStore.fetchContact(id: contactID) else { - self.navigateToContacts() - return - } - self.navigateToContactDetail(contact) - } - } - - // Channel notification tap - notificationService.onChannelNotificationTapped = { [weak self] radioID, channelIndex in - guard let self else { return } - guard let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) else { return } - self.navigateToChannel(with: channel) - } - - // Reaction notification tap - notificationService.onReactionNotificationTapped = { [weak self] contactID, channelIndex, radioID, messageID in - guard let self else { return } - if let contactID, - let contact = try? await dataStore.fetchContact(id: contactID) { - self.navigateToChat(with: contact, scrollToMessageID: messageID) - } else if let channelIndex, let radioID, - let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { - self.navigateToChannel(with: channel, scrollToMessageID: messageID) - } - } + // Reaction notification tap + notificationService.onReactionNotificationTapped = { [weak self] contactID, channelIndex, radioID, messageID in + guard let self else { return } + if let contactID, + let contact = try? await dataStore.fetchContact(id: contactID) { + navigateToChat(with: contact, scrollToMessageID: messageID) + } else if let channelIndex, let radioID, + let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { + navigateToChannel(with: channel, scrollToMessageID: messageID) + } + } - // Room notification tap. Resolves the full session from the stable - // sessionID carried in the notification. A connected room opens directly; - // a disconnected room is routed to the auth sheet, mirroring the list-row - // gate, instead of the iPad detail pane rendering an ungated read-only room. - notificationService.onRoomNotificationTapped = { [weak self] sessionID in - guard let self else { return } - guard let session = try? await dataStore.fetchRemoteNodeSession(id: sessionID) else { return } - if session.isConnected { - self.navigateToRoom(with: session) - } else { - self.selectedTab = 0 - self.pendingRoomAuthentication = session - } - } + // Room notification tap. Resolves the full session from the stable + // sessionID carried in the notification. A connected room opens directly; + // a disconnected room is routed to the auth sheet, mirroring the list-row + // gate, instead of the iPad detail pane rendering an ungated read-only room. + notificationService.onRoomNotificationTapped = { [weak self] sessionID in + guard let self else { return } + guard let session = try? await dataStore.fetchRemoteNodeSession(id: sessionID) else { return } + if session.isConnected { + navigateToRoom(with: session) + } else { + selectedTab = 0 + pendingRoomAuthentication = session + } } + } } diff --git a/MC1/State/OnboardingState.swift b/MC1/State/OnboardingState.swift index 2c490a10..7262982e 100644 --- a/MC1/State/OnboardingState.swift +++ b/MC1/State/OnboardingState.swift @@ -4,83 +4,82 @@ import MC1Services import UserNotifications enum OnboardingStep: String, CaseIterable, Hashable, Codable { - case welcome - case permissions - case pair - case region - case preset + case welcome + case permissions + case pair + case region + case preset } /// Manages onboarding completion flag and navigation path. @Observable @MainActor final class OnboardingState { + private let defaults: UserDefaults - private let defaults: UserDefaults - - /// Whether onboarding is complete (persisted to UserDefaults) - var hasCompletedOnboarding: Bool { - didSet { - defaults.set(hasCompletedOnboarding, forKey: AppStorageKey.hasCompletedOnboarding.rawValue) - } + /// Whether onboarding is complete (persisted to UserDefaults) + var hasCompletedOnboarding: Bool { + didSet { + defaults.set(hasCompletedOnboarding, forKey: AppStorageKey.hasCompletedOnboarding.rawValue) } + } - /// Navigation path for onboarding flow - var onboardingPath: [OnboardingStep] = [] + /// Navigation path for onboarding flow + var onboardingPath: [OnboardingStep] = [] - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - self.hasCompletedOnboarding = defaults.bool(forKey: AppStorageKey.hasCompletedOnboarding.rawValue) - } + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + hasCompletedOnboarding = defaults.bool(forKey: AppStorageKey.hasCompletedOnboarding.rawValue) + } - /// Mark onboarding as complete - func completeOnboarding() { - hasCompletedOnboarding = true - } + /// Mark onboarding as complete + func completeOnboarding() { + hasCompletedOnboarding = true + } - /// Reset onboarding state - func resetOnboarding() { - hasCompletedOnboarding = false - onboardingPath = [] - } + /// Reset onboarding state + func resetOnboarding() { + hasCompletedOnboarding = false + onboardingPath = [] + } } extension OnboardingState { - /// Computes the `NavigationStack` starting path. The view at the top of the - /// path is what the user lands on; `WelcomeView()` is the root, so any - /// returned path is pushed *above* it. - /// - /// Reads notification authorization directly from `UNUserNotificationCenter` - /// rather than `PermissionsCoordinator` (view-scoped, async-init). - /// - /// `regionAlreadySet` lets a returning user skip past the region step when - /// `AppState.regionSelection` is already populated — without it, partially - /// onboarded users land on `.region` even though they answered it last time. - func suggestedStartingPath( - connectionManager: ConnectionManager, - locationAuthorizationStatus: CLAuthorizationStatus, - regionAlreadySet: Bool - ) async -> [OnboardingStep] { - guard !hasCompletedOnboarding else { return [] } - // `pairedAccessoriesCount` is the AccessorySetupKit count, always 0 on macOS (no - // registry), so fall back to `lastConnectedDeviceID` — set only after a real successful - // connect and cleared when a device is forgotten, never by a backup import — as the - // platform-independent "this install has actually connected a radio" signal. A raw - // saved-device count would wrongly resume for backup-restored shadows and demoted ghosts. - guard connectionManager.pairedAccessoriesCount > 0 - || connectionManager.lastConnectedDeviceID != nil else { return [] } + /// Computes the `NavigationStack` starting path. The view at the top of the + /// path is what the user lands on; `WelcomeView()` is the root, so any + /// returned path is pushed *above* it. + /// + /// Reads notification authorization directly from `UNUserNotificationCenter` + /// rather than `PermissionsCoordinator` (view-scoped, async-init). + /// + /// `regionAlreadySet` lets a returning user skip past the region step when + /// `AppState.regionSelection` is already populated — without it, partially + /// onboarded users land on `.region` even though they answered it last time. + func suggestedStartingPath( + connectionManager: ConnectionManager, + locationAuthorizationStatus: CLAuthorizationStatus, + regionAlreadySet: Bool + ) async -> [OnboardingStep] { + guard !hasCompletedOnboarding else { return [] } + // `pairedAccessoriesCount` is the AccessorySetupKit count, always 0 on macOS (no + // registry), so fall back to `lastConnectedDeviceID` — set only after a real successful + // connect and cleared when a device is forgotten, never by a backup import — as the + // platform-independent "this install has actually connected a radio" signal. A raw + // saved-device count would wrongly resume for backup-restored shadows and demoted ghosts. + guard connectionManager.pairedAccessoriesCount > 0 + || connectionManager.lastConnectedDeviceID != nil else { return [] } - let notificationStatus = await UNUserNotificationCenter.current() - .notificationSettings().authorizationStatus + let notificationStatus = await UNUserNotificationCenter.current() + .notificationSettings().authorizationStatus - let permissionsHandled = locationAuthorizationStatus != .notDetermined - && notificationStatus != .notDetermined + let permissionsHandled = locationAuthorizationStatus != .notDetermined + && notificationStatus != .notDetermined - guard permissionsHandled else { return [.permissions] } + guard permissionsHandled else { return [.permissions] } - if regionAlreadySet { - return [.permissions, .pair, .preset] - } - return [.permissions, .pair, .region] + if regionAlreadySet { + return [.permissions, .pair, .preset] } + return [.permissions, .pair, .region] + } } diff --git a/MC1/State/PendingPurchase.swift b/MC1/State/PendingPurchase.swift index df8a91d5..e446084d 100644 --- a/MC1/State/PendingPurchase.swift +++ b/MC1/State/PendingPurchase.swift @@ -2,7 +2,7 @@ import Foundation /// An in-flight Ask-to-Buy / SCA purchase awaiting approval. Drives the pending banner. /// Resolution arrives later via `StoreService`'s `Transaction.updates` listener. -struct PendingPurchase: Sendable, Equatable { - let productID: String - let displayName: String +struct PendingPurchase: Equatable { + let productID: String + let displayName: String } diff --git a/MC1/State/RestoreState.swift b/MC1/State/RestoreState.swift index 756e31ec..f639cb1e 100644 --- a/MC1/State/RestoreState.swift +++ b/MC1/State/RestoreState.swift @@ -3,10 +3,10 @@ import Foundation /// Drives the Restore Purchases button: disabled + `ProgressView` while `.syncing`, /// `.sensoryFeedback(.success)` on `.completed` only. `.cancelled` is distinct from `.completed` /// so a user dismissing the iCloud password sheet does not trigger the success haptic. -enum RestoreState: Sendable, Equatable { - case idle - case syncing - case completed - case cancelled - case failed +enum RestoreState: Equatable { + case idle + case syncing + case completed + case cancelled + case failed } diff --git a/MC1/State/StatusPillState.swift b/MC1/State/StatusPillState.swift index 127eb9f0..d5086489 100644 --- a/MC1/State/StatusPillState.swift +++ b/MC1/State/StatusPillState.swift @@ -2,42 +2,42 @@ import SwiftUI /// Represents the current state of the status pill UI component enum StatusPillState: Hashable { - case hidden - case connecting - case syncing - case ready - case disconnected - case failed(message: String) + case hidden + case connecting + case syncing + case ready + case disconnected + case failed(message: String) - var displayText: String { - switch self { - case .failed(let message): message - case .syncing: L10n.Localizable.Common.Status.syncing - case .connecting: L10n.Localizable.Common.Status.connecting - case .ready: L10n.Localizable.Common.Status.ready - case .disconnected: L10n.Localizable.Common.Status.disconnected - case .hidden: "" - } + var displayText: String { + switch self { + case let .failed(message): message + case .syncing: L10n.Localizable.Common.Status.syncing + case .connecting: L10n.Localizable.Common.Status.connecting + case .ready: L10n.Localizable.Common.Status.ready + case .disconnected: L10n.Localizable.Common.Status.disconnected + case .hidden: "" } + } - var systemImageName: String { - switch self { - case .failed: "exclamationmark.triangle.fill" - case .disconnected: "exclamationmark.triangle" - case .ready: "checkmark.circle" - case .connecting, .syncing: "arrow.trianglehead.2.clockwise" - case .hidden: "" - } + var systemImageName: String { + switch self { + case .failed: "exclamationmark.triangle.fill" + case .disconnected: "exclamationmark.triangle" + case .ready: "checkmark.circle" + case .connecting, .syncing: "arrow.trianglehead.2.clockwise" + case .hidden: "" } + } - var isFailure: Bool { - if case .failed = self { return true } - return false - } + var isFailure: Bool { + if case .failed = self { return true } + return false + } - var textColor: Color { - if isFailure { return .red } - if case .disconnected = self { return .orange } - return .primary - } + var textColor: Color { + if isFailure { return .red } + if case .disconnected = self { return .orange } + return .primary + } } diff --git a/MC1/State/StoreState.swift b/MC1/State/StoreState.swift index 66606144..08a87025 100644 --- a/MC1/State/StoreState.swift +++ b/MC1/State/StoreState.swift @@ -1,6 +1,6 @@ -import SwiftUI -import StoreKit import MC1Services +import StoreKit +import SwiftUI /// View-model wrapper over `StoreService` for the Support Development screen. Owned by `AppState`. /// Presents the project-standard `errorMessage: String?` (surfaced via `.errorAlert`) and maps @@ -8,128 +8,128 @@ import MC1Services @Observable @MainActor final class StoreState { - let service: StoreService + let service: StoreService - var errorMessage: String? - var restoreState: RestoreState = .idle - var pendingPurchase: PendingPurchase? + var errorMessage: String? + var restoreState: RestoreState = .idle + var pendingPurchase: PendingPurchase? - /// Persisted so purchase diagnostics reach the Settings "Export Debug Logs" output for - /// triaging failures reported from TestFlight. `StoreService` logs the StoreKit-side flow; - /// this logs the view-driven entry, guards, and outcome. - private let logger = PersistentLogger(subsystem: "com.mc1", category: "Store") + /// Persisted so purchase diagnostics reach the Settings "Export Debug Logs" output for + /// triaging failures reported from TestFlight. `StoreService` logs the StoreKit-side flow; + /// this logs the view-driven entry, guards, and outcome. + private let logger = PersistentLogger(subsystem: "com.mc1", category: "Store") - init(service: StoreService) { - self.service = service - // Out-of-band consumable resolution: an Ask-to-Buy-approved tip is finished by - // `applyTransactionUpdate` but does not enter `ownedThemeIDs`, so `reconcilePendingPurchase` - // (which only checks ownership) cannot clear the pending banner. This callback fires - // after the tip's `transaction.finish()` so the banner clears immediately. - service.onConsumablePurchased = { [weak self] productID in - guard let self else { return } - if self.pendingPurchase?.productID == productID { self.pendingPurchase = nil } - } - // Surface load failures as an alert so the user can see why the catalog didn't load - // (network down, etc.) and use the `ErrorAlertModifier` retry button. Without this, - // a failed initial load is silent — the Support screen shows disabled '—' buttons - // with no signal that retry is the right action. - service.onLoadStateFailed = { [weak self] in - guard let self else { return } - self.errorMessage = self.localizedMessage(for: .productsNotLoaded) - } + init(service: StoreService) { + self.service = service + // Out-of-band consumable resolution: an Ask-to-Buy-approved tip is finished by + // `applyTransactionUpdate` but does not enter `ownedThemeIDs`, so `reconcilePendingPurchase` + // (which only checks ownership) cannot clear the pending banner. This callback fires + // after the tip's `transaction.finish()` so the banner clears immediately. + service.onConsumablePurchased = { [weak self] productID in + guard let self else { return } + if pendingPurchase?.productID == productID { pendingPurchase = nil } } - - /// Returns `true` only on `.purchased`; callers that gate post-success UI (e.g. ContributionRow's - /// "thank you" animation) check this to avoid celebrating cancels, failures, or Ask-to-Buy pending. - /// `purchase` performs the StoreKit purchase. The view passes SwiftUI's `@Environment(\.purchase)` - /// action so StoreKit resolves the confirmation scene from the view environment, which is robust - /// against the foreground-scene churn (lifecycle reconciliation during sheet presentation) that - /// makes a bare `Product.purchase()` return a spurious `.userCancelled` on iOS 18.2+. Taking it as - /// a closure keeps this method testable, since `PurchaseAction` cannot be constructed off a view. - @discardableResult - func purchase( - productID: String, - purchase: (Product) async throws -> Product.PurchaseResult - ) async -> Bool { - guard service.loadState == .loaded else { - // Without this guard, an empty `products` array (because load failed/never finished) - // would surface `.productNotFound` instead of the truer `.productsNotLoaded`, which - // the ErrorAlertModifier retry button knows how to resolve via `service.load()`. - logger.error("[IAP] purchase blocked: products not loaded (loadState=\(String(describing: service.loadState)))") - errorMessage = localizedMessage(for: .productsNotLoaded) - return false - } - guard let product = service.product(for: productID) else { - logger.error("[IAP] purchase blocked: product not found product=\(productID)") - errorMessage = localizedMessage(for: .productNotFound(productID: productID)) - return false - } - logger.notice("[IAP] purchase requested product=\(productID)") - do { - let result = try await purchase(product) - let outcome = try await service.completePurchase(result) - switch outcome { - case .purchased: - // Clear pendingPurchase only when the just-completed product matches; an unrelated - // .purchased (e.g. tipping Coffee while Ember Ask-to-Buy is still in flight) must - // not wipe a banner for a different in-flight purchase. - if pendingPurchase?.productID == productID { pendingPurchase = nil } - return true - case .userCancelled: - return false - case .pending: - pendingPurchase = PendingPurchase(productID: productID, displayName: product.displayName) - return false - } - } catch let error as StoreServiceError { - logger.error("[IAP] purchase failed product=\(productID): \(error.errorDescription ?? "\(error)")") - errorMessage = localizedMessage(for: error) - return false - } catch let error as StoreKitError { - // .userCancelled maps to nil and stays a silent cancel; anything else is a real failure. - if let mapped = StoreServiceError.from(error) { - logger.error("[IAP] purchase StoreKit error product=\(productID): \(String(describing: error))") - errorMessage = localizedMessage(for: mapped) - } - return false - } catch { - logger.error("[IAP] purchase error product=\(productID): \(error.localizedDescription)") - errorMessage = localizedMessage(for: .purchaseFailed(reason: error.localizedDescription)) - return false - } + // Surface load failures as an alert so the user can see why the catalog didn't load + // (network down, etc.) and use the `ErrorAlertModifier` retry button. Without this, + // a failed initial load is silent — the Support screen shows disabled '—' buttons + // with no signal that retry is the right action. + service.onLoadStateFailed = { [weak self] in + guard let self else { return } + errorMessage = localizedMessage(for: .productsNotLoaded) } + } - func restorePurchases() async { - restoreState = .syncing - do { - switch try await service.restorePurchases() { - case .completed: restoreState = .completed - case .cancelled: restoreState = .cancelled - } - } catch let error as StoreServiceError { - errorMessage = localizedMessage(for: error) - restoreState = .failed - } catch { - errorMessage = localizedMessage(for: .purchaseFailed(reason: error.localizedDescription)) - restoreState = .failed - } + /// Returns `true` only on `.purchased`; callers that gate post-success UI (e.g. ContributionRow's + /// "thank you" animation) check this to avoid celebrating cancels, failures, or Ask-to-Buy pending. + /// `purchase` performs the StoreKit purchase. The view passes SwiftUI's `@Environment(\.purchase)` + /// action so StoreKit resolves the confirmation scene from the view environment, which is robust + /// against the foreground-scene churn (lifecycle reconciliation during sheet presentation) that + /// makes a bare `Product.purchase()` return a spurious `.userCancelled` on iOS 18.2+. Taking it as + /// a closure keeps this method testable, since `PurchaseAction` cannot be constructed off a view. + @discardableResult + func purchase( + productID: String, + purchase: (Product) async throws -> Product.PurchaseResult + ) async -> Bool { + guard service.loadState == .loaded else { + // Without this guard, an empty `products` array (because load failed/never finished) + // would surface `.productNotFound` instead of the truer `.productsNotLoaded`, which + // the ErrorAlertModifier retry button knows how to resolve via `service.load()`. + logger.error("[IAP] purchase blocked: products not loaded (loadState=\(String(describing: service.loadState)))") + errorMessage = localizedMessage(for: .productsNotLoaded) + return false } - - /// Clears the pending banner once the pending product's entitlement has arrived. Driven by the - /// view's `.onChange(of: service.ownedThemeIDs)`. The bundle expands to every purchasable theme - /// ID (its own product ID never appears in `ownedThemeIDs`), so it resolves via a superset check. - func reconcilePendingPurchase() { - guard let pending = pendingPurchase else { return } - let owned = service.ownedThemeIDs - let isResolved = pending.productID == StoreCatalog.Theme.bundleAll - ? owned.isSuperset(of: StoreCatalog.Theme.bundledThemeIDs) - : owned.contains(pending.productID) - if isResolved { pendingPurchase = nil } + guard let product = service.product(for: productID) else { + logger.error("[IAP] purchase blocked: product not found product=\(productID)") + errorMessage = localizedMessage(for: .productNotFound(productID: productID)) + return false + } + logger.notice("[IAP] purchase requested product=\(productID)") + do { + let result = try await purchase(product) + let outcome = try await service.completePurchase(result) + switch outcome { + case .purchased: + // Clear pendingPurchase only when the just-completed product matches; an unrelated + // .purchased (e.g. tipping Coffee while Ember Ask-to-Buy is still in flight) must + // not wipe a banner for a different in-flight purchase. + if pendingPurchase?.productID == productID { pendingPurchase = nil } + return true + case .userCancelled: + return false + case .pending: + pendingPurchase = PendingPurchase(productID: productID, displayName: product.displayName) + return false + } + } catch let error as StoreServiceError { + logger.error("[IAP] purchase failed product=\(productID): \(error.errorDescription ?? "\(error)")") + errorMessage = localizedMessage(for: error) + return false + } catch let error as StoreKitError { + // .userCancelled maps to nil and stays a silent cancel; anything else is a real failure. + if let mapped = StoreServiceError.from(error) { + logger.error("[IAP] purchase StoreKit error product=\(productID): \(String(describing: error))") + errorMessage = localizedMessage(for: mapped) + } + return false + } catch { + logger.error("[IAP] purchase error product=\(productID): \(error.localizedDescription)") + errorMessage = localizedMessage(for: .purchaseFailed(reason: error.localizedDescription)) + return false } + } - /// Internal (not private) as a testability seam (see `StoreStateErrorMappingTests`). - /// Delegates to the shared `StoreServiceError.userFacingMessage` mapping. - func localizedMessage(for error: StoreServiceError) -> String { - error.userFacingMessage + func restorePurchases() async { + restoreState = .syncing + do { + switch try await service.restorePurchases() { + case .completed: restoreState = .completed + case .cancelled: restoreState = .cancelled + } + } catch let error as StoreServiceError { + errorMessage = localizedMessage(for: error) + restoreState = .failed + } catch { + errorMessage = localizedMessage(for: .purchaseFailed(reason: error.localizedDescription)) + restoreState = .failed } + } + + /// Clears the pending banner once the pending product's entitlement has arrived. Driven by the + /// view's `.onChange(of: service.ownedThemeIDs)`. The bundle expands to every purchasable theme + /// ID (its own product ID never appears in `ownedThemeIDs`), so it resolves via a superset check. + func reconcilePendingPurchase() { + guard let pending = pendingPurchase else { return } + let owned = service.ownedThemeIDs + let isResolved = pending.productID == StoreCatalog.Theme.bundleAll + ? owned.isSuperset(of: StoreCatalog.Theme.bundledThemeIDs) + : owned.contains(pending.productID) + if isResolved { pendingPurchase = nil } + } + + /// Internal (not private) as a testability seam (see `StoreStateErrorMappingTests`). + /// Delegates to the shared `StoreServiceError.userFacingMessage` mapping. + func localizedMessage(for error: StoreServiceError) -> String { + error.userFacingMessage + } } diff --git a/MC1/State/WhatsNewState.swift b/MC1/State/WhatsNewState.swift index 25a52079..27a87aa6 100644 --- a/MC1/State/WhatsNewState.swift +++ b/MC1/State/WhatsNewState.swift @@ -6,68 +6,67 @@ import SwiftUI @Observable @MainActor final class WhatsNewState { + private let defaults: UserDefaults - private let defaults: UserDefaults + /// The release to present, set by `evaluate` and cleared by `markShown`. + var pendingRelease: WhatsNewRelease? - /// The release to present, set by `evaluate` and cleared by `markShown`. - var pendingRelease: WhatsNewRelease? + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } - init(defaults: UserDefaults = .standard) { - self.defaults = defaults + /// The launch-time show/suppress decision as a pure function, testable without + /// `@MainActor` or `UserDefaults`. Returns the release to show, or `nil`. + static func resolve( + current: WhatsNewVersion, + baselineString: String?, + isOnboarded: Bool, + catalog: [WhatsNewRelease] + ) -> WhatsNewRelease? { + guard let release = catalog.first(where: { $0.version == current }), + !release.items.isEmpty else { + return nil } - - /// The launch-time show/suppress decision as a pure function, testable without - /// `@MainActor` or `UserDefaults`. Returns the release to show, or `nil`. - static func resolve( - current: WhatsNewVersion, - baselineString: String?, - isOnboarded: Bool, - catalog: [WhatsNewRelease] - ) -> WhatsNewRelease? { - guard let release = catalog.first(where: { $0.version == current }), - !release.items.isEmpty else { - return nil - } - guard let baselineString else { - // No baseline: an upgrader sees the notes, a brand-new install (still - // mid-onboarding) does not. The caller records the baseline either way. - return isOnboarded ? release : nil - } - guard let baseline = WhatsNewVersion(marketingVersion: baselineString) else { - return nil - } - return current > baseline ? release : nil + guard let baselineString else { + // No baseline: an upgrader sees the notes, a brand-new install (still + // mid-onboarding) does not. The caller records the baseline either way. + return isOnboarded ? release : nil + } + guard let baseline = WhatsNewVersion(marketingVersion: baselineString) else { + return nil } + return current > baseline ? release : nil + } - /// Runs the resolver once at launch. A show defers the baseline to `markShown`; a - /// suppress finalizes it now so the sheet never re-appears. An unparseable version - /// fails closed (neither presented nor recorded); screenshot mode is skipped. - func evaluate( - isOnboarded: Bool, - isScreenshotMode: Bool, - currentVersionString: String = Bundle.main.appVersion, - catalog: [WhatsNewRelease] = WhatsNewCatalog.releases - ) { - guard !isScreenshotMode else { return } - guard let current = WhatsNewVersion(marketingVersion: currentVersionString) else { return } + /// Runs the resolver once at launch. A show defers the baseline to `markShown`; a + /// suppress finalizes it now so the sheet never re-appears. An unparseable version + /// fails closed (neither presented nor recorded); screenshot mode is skipped. + func evaluate( + isOnboarded: Bool, + isScreenshotMode: Bool, + currentVersionString: String = Bundle.main.appVersion, + catalog: [WhatsNewRelease] = WhatsNewCatalog.releases + ) { + guard !isScreenshotMode else { return } + guard let current = WhatsNewVersion(marketingVersion: currentVersionString) else { return } - let baselineString = defaults.string(forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) - if let release = Self.resolve( - current: current, - baselineString: baselineString, - isOnboarded: isOnboarded, - catalog: catalog - ) { - pendingRelease = release - } else { - defaults.set(currentVersionString, forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) - } + let baselineString = defaults.string(forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) + if let release = Self.resolve( + current: current, + baselineString: baselineString, + isOnboarded: isOnboarded, + catalog: catalog + ) { + pendingRelease = release + } else { + defaults.set(currentVersionString, forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) } + } - /// Persists the baseline for the running version and clears `pendingRelease`. - /// Both swipe-to-dismiss and Continue route through here. - func markShown() { - defaults.set(Bundle.main.appVersion, forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) - pendingRelease = nil - } + /// Persists the baseline for the running version and clears `pendingRelease`. + /// Both swipe-to-dismiss and Continue route through here. + func markShown() { + defaults.set(Bundle.main.appVersion, forKey: AppStorageKey.lastShownWhatsNewVersion.rawValue) + pendingRelease = nil + } } diff --git a/MC1/Theme/AppColorSchemePreference.swift b/MC1/Theme/AppColorSchemePreference.swift index 75b9102a..35c86a13 100644 --- a/MC1/Theme/AppColorSchemePreference.swift +++ b/MC1/Theme/AppColorSchemePreference.swift @@ -2,21 +2,23 @@ import SwiftUI /// Global app appearance preference, independent of which theme is selected. /// Raw values are pinned (persisted + backed up); a rename must not change the on-disk format. -enum AppColorSchemePreference: String, Sendable, CaseIterable, Identifiable { - // swiftlint:disable redundant_string_enum_value - case system = "system" - case light = "light" - case dark = "dark" - // swiftlint:enable redundant_string_enum_value +enum AppColorSchemePreference: String, CaseIterable, Identifiable { + // swiftlint:disable redundant_string_enum_value + case system + case light + case dark + // swiftlint:enable redundant_string_enum_value - var id: String { rawValue } + var id: String { + rawValue + } - /// `nil` means "defer to the system" — the value passed to `.preferredColorScheme(_:)`. - var colorScheme: ColorScheme? { - switch self { - case .system: nil - case .light: .light - case .dark: .dark - } + /// `nil` means "defer to the system" — the value passed to `.preferredColorScheme(_:)`. + var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark } + } } diff --git a/MC1/Theme/AppColors.swift b/MC1/Theme/AppColors.swift index 98e118e1..ccf6c413 100644 --- a/MC1/Theme/AppColors.swift +++ b/MC1/Theme/AppColors.swift @@ -6,27 +6,26 @@ import SwiftUI /// - Identity palettes: Colors for identifying senders, contacts, and nodes /// - UI elements: Colors for interface components like message bubbles enum AppColors { + // MARK: - Radio Status - // MARK: - Radio Status + /// Colors for the BLE status indicator toolbar icon. + enum Radio { + static let repeatMode = Color(hex: 0xFF9500) + static let connecting = Color(hex: 0x007AFF) + static let ready = Color(hex: 0x34C759) + } - /// Colors for the BLE status indicator toolbar icon. - enum Radio { - static let repeatMode = Color(hex: 0xFF9500) - static let connecting = Color(hex: 0x007AFF) - static let ready = Color(hex: 0x34C759) - } - - // MARK: - UI Elements + // MARK: - UI Elements - /// Colors for message bubbles and related UI. - enum Message { - static let outgoingBubble = Color(hex: 0x2463EB) - static let incomingBubble = Color(.systemGray5) + /// Colors for message bubbles and related UI. + enum Message { + static let outgoingBubble = Color(hex: 0x2463EB) + static let incomingBubble = Color(.systemGray5) - /// Failed-bubble background. `highContrast: true` returns the - /// system-adaptive `.systemRed`; `false` keeps the legacy translucent red. - static func outgoingBubbleFailed(highContrast: Bool) -> Color { - highContrast ? Color(.systemRed) : Color.red.opacity(0.8) - } + /// Failed-bubble background. `highContrast: true` returns the + /// system-adaptive `.systemRed`; `false` keeps the legacy translucent red. + static func outgoingBubbleFailed(highContrast: Bool) -> Color { + highContrast ? Color(.systemRed) : Color.red.opacity(0.8) } + } } diff --git a/MC1/Theme/AppearanceToken.swift b/MC1/Theme/AppearanceToken.swift index c17ca1db..ec5eb002 100644 --- a/MC1/Theme/AppearanceToken.swift +++ b/MC1/Theme/AppearanceToken.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// A `Hashable` string capturing the appearance inputs that change a chat row's resolved /// rendering: light/dark, increased-contrast, and the Dynamic Type size. The chat table @@ -7,35 +7,35 @@ import MC1Services /// change, so sender names and avatars repaint on an appearance switch and message bubbles /// re-measure and reflow on a Dynamic Type change even though the bubble cells diff as equal. enum AppearanceToken { - static func make( - colorScheme: ColorScheme, - contrast: ColorSchemeContrast, - dynamicTypeSize: DynamicTypeSize - ) -> String { - let appearance = colorScheme == .dark ? "dark" : "light" - let contrastTag = contrast == .increased ? "hc" : "std" - return "\(appearance)-\(contrastTag)-\(contentSizeCategoryToken(dynamicTypeSize))" - } + static func make( + colorScheme: ColorScheme, + contrast: ColorSchemeContrast, + dynamicTypeSize: DynamicTypeSize + ) -> String { + let appearance = colorScheme == .dark ? "dark" : "light" + let contrastTag = contrast == .increased ? "hc" : "std" + return "\(appearance)-\(contrastTag)-\(contentSizeCategoryToken(dynamicTypeSize))" + } - /// Stable, `Hashable` string for a Dynamic Type size, mirroring `EnvInputs.contentSizeCategory` - /// and the bubble text view's size-cache key. `DynamicTypeSize` is not `RawRepresentable`, so - /// the mapping is explicit; `.large` resolves to the shared unscaled baseline so this token and - /// `EnvInputs.default.contentSizeCategory` agree. - static func contentSizeCategoryToken(_ size: DynamicTypeSize) -> String { - switch size { - case .xSmall: "xSmall" - case .small: "small" - case .medium: "medium" - case .large: EnvInputs.defaultContentSizeCategory - case .xLarge: "xLarge" - case .xxLarge: "xxLarge" - case .xxxLarge: "xxxLarge" - case .accessibility1: "accessibility1" - case .accessibility2: "accessibility2" - case .accessibility3: "accessibility3" - case .accessibility4: "accessibility4" - case .accessibility5: "accessibility5" - @unknown default: "unknown" - } + /// Stable, `Hashable` string for a Dynamic Type size, mirroring `EnvInputs.contentSizeCategory` + /// and the bubble text view's size-cache key. `DynamicTypeSize` is not `RawRepresentable`, so + /// the mapping is explicit; `.large` resolves to the shared unscaled baseline so this token and + /// `EnvInputs.default.contentSizeCategory` agree. + static func contentSizeCategoryToken(_ size: DynamicTypeSize) -> String { + switch size { + case .xSmall: "xSmall" + case .small: "small" + case .medium: "medium" + case .large: EnvInputs.defaultContentSizeCategory + case .xLarge: "xLarge" + case .xxLarge: "xxLarge" + case .xxxLarge: "xxxLarge" + case .accessibility1: "accessibility1" + case .accessibility2: "accessibility2" + case .accessibility3: "accessibility3" + case .accessibility4: "accessibility4" + case .accessibility5: "accessibility5" + @unknown default: "unknown" } + } } diff --git a/MC1/Theme/AvatarCategory.swift b/MC1/Theme/AvatarCategory.swift index 8e568367..70425d99 100644 --- a/MC1/Theme/AvatarCategory.swift +++ b/MC1/Theme/AvatarCategory.swift @@ -2,23 +2,23 @@ import Foundation /// The three avatar categories that get one fixed color per theme (no per-entity variation): /// channels in the Chats list, and repeater / room-server nodes in the Nodes list. -enum AvatarCategory: Sendable { - case channel - case repeater - case room +enum AvatarCategory { + case channel + case repeater + case room - /// Order the category avatars claim gamut anchors in. On a collision the later category steps to - /// the next free anchor, so earlier categories keep their preferred color. A channel and a room - /// can share a Chats list, so their colors must stay distinct. - static let anchorPriority: [AvatarCategory] = [.channel, .repeater, .room] + /// Order the category avatars claim gamut anchors in. On a collision the later category steps to + /// the next free anchor, so earlier categories keep their preferred color. A channel and a room + /// can share a Chats list, so their colors must stay distinct. + static let anchorPriority: [AvatarCategory] = [.channel, .repeater, .room] - /// Stable seed fed to a theme's `IdentityGamut` so each category resolves to a distinct, - /// on-theme color. Prefixed to keep it from colliding with a real contact or channel name. - var gamutSeed: String { - switch self { - case .channel: return "__avatar_category_channel__" - case .repeater: return "__avatar_category_repeater__" - case .room: return "__avatar_category_room__" - } + /// Stable seed fed to a theme's `IdentityGamut` so each category resolves to a distinct, + /// on-theme color. Prefixed to keep it from colliding with a real contact or channel name. + var gamutSeed: String { + switch self { + case .channel: "__avatar_category_channel__" + case .repeater: "__avatar_category_repeater__" + case .room: "__avatar_category_room__" } + } } diff --git a/MC1/Theme/CategoryAvatarColors.swift b/MC1/Theme/CategoryAvatarColors.swift index 3a07ba13..07374fb8 100644 --- a/MC1/Theme/CategoryAvatarColors.swift +++ b/MC1/Theme/CategoryAvatarColors.swift @@ -4,16 +4,16 @@ import SwiftUI /// channel / repeater / room avatars to their historical fixed values. Every other theme leaves /// this `nil` and derives its three category colors from its `IdentityGamut` instead, which keeps /// them on-theme and guarantees WCAG AA against the theme's surfaces. -struct CategoryAvatarColors: Sendable, Equatable { - let channel: Color - let repeaterNode: Color - let room: Color +struct CategoryAvatarColors: Equatable { + let channel: Color + let repeaterNode: Color + let room: Color - func color(for category: AvatarCategory) -> Color { - switch category { - case .channel: return channel - case .repeater: return repeaterNode - case .room: return room - } + func color(for category: AvatarCategory) -> Color { + switch category { + case .channel: channel + case .repeater: repeaterNode + case .room: room } + } } diff --git a/MC1/Theme/CategoryHues.swift b/MC1/Theme/CategoryHues.swift index 8aba8ab8..3a0591c9 100644 --- a/MC1/Theme/CategoryHues.swift +++ b/MC1/Theme/CategoryHues.swift @@ -4,16 +4,16 @@ import Foundation /// theme's own anchors to look good and stay distinct. The System theme pins full colors via /// `CategoryAvatarColors` and leaves this `nil`; a gamut theme that also leaves it `nil` falls back /// to a distinct on-anchor pick. -struct CategoryHues: Sendable, Equatable { - let channel: Double - let repeater: Double - let room: Double +struct CategoryHues: Equatable { + let channel: Double + let repeater: Double + let room: Double - func hue(for category: AvatarCategory) -> Double { - switch category { - case .channel: channel - case .repeater: repeater - case .room: room - } + func hue(for category: AvatarCategory) -> Double { + switch category { + case .channel: channel + case .repeater: repeater + case .room: room } + } } diff --git a/MC1/Theme/EnvironmentValues+AppTheme.swift b/MC1/Theme/EnvironmentValues+AppTheme.swift index e6cccbf9..6ffa49e3 100644 --- a/MC1/Theme/EnvironmentValues+AppTheme.swift +++ b/MC1/Theme/EnvironmentValues+AppTheme.swift @@ -1,9 +1,9 @@ import SwiftUI extension EnvironmentValues { - /// The active cosmetic theme. Set once at the `MC1App` scene root from - /// `AppState.themeService.current`; read by themed surfaces (chat bubble fill, - /// list/thread backgrounds). Defaults to `Theme.default` so previews and any - /// subtree that is not under the scene-root injection still resolve a valid theme. - @Entry var appTheme: Theme = .default + /// The active cosmetic theme. Set once at the `MC1App` scene root from + /// `AppState.themeService.current`; read by themed surfaces (chat bubble fill, + /// list/thread backgrounds). Defaults to `Theme.default` so previews and any + /// subtree that is not under the scene-root injection still resolve a valid theme. + @Entry var appTheme: Theme = .default } diff --git a/MC1/Theme/IdentityGamut.swift b/MC1/Theme/IdentityGamut.swift index 343e40ff..5ff0890e 100644 --- a/MC1/Theme/IdentityGamut.swift +++ b/MC1/Theme/IdentityGamut.swift @@ -9,218 +9,222 @@ import SwiftUI /// All math is pure `Double` work — no UIKit, no SwiftUI environment — so it can run off the main /// actor inside the message-text bake. Callers resolve their surface colors to relative-luminance /// values and pass them in. -struct IdentityGamut: Sendable, Equatable { - /// Hue centers (degrees, 0..<360) that define the theme. A name lands near one of these and is - /// jittered within the gap to its neighbors, so the union covers a continuous slice of the wheel - /// while every color still reads as on-theme. - let hueAnchors: [Double] - /// Saturation band the solver draws from. The solver may dip below `lowerBound` only when a hue - /// physically cannot reach the luminance a dark background demands at full saturation. - let saturation: ClosedRange - - // MARK: - Tunables - - private static let degreesPerCircle = 360.0 - /// Floor the solver desaturates toward when a vivid hue cannot otherwise reach the luminance a - /// dark surface requires. Near zero so legibility is always achievable (a gray reaches any - /// luminance); colors only wash out this far in the rare high-contrast-on-dark case that needs it. - private static let saturationReachFloor = 0.0 - /// Step used when relaxing saturation to reach an unreachable luminance target. - private static let saturationRelaxStep = 0.04 - /// Fraction of the AA-feasible luminance span used as the dark-text variety window. Names spread - /// across `[darkVarietyFloor, boundary]` so brightness varies per identity without breaching AA. - private static let darkVarietyFloor = 0.45 - /// Upper luminance cap for light-text (dark-appearance) colors so they never go pure white. - private static let lightLuminanceCap = 0.86 - /// Added to the target contrast ratio so rendering drift (HSB rounding, wide-gamut resolution) - /// cannot drop the rendered color below the real AA floor. - private static let contrastSafetyMargin = 0.35 - /// Binary-search iteration count for mapping a luminance target to a brightness value. - private static let brightnessSearchIterations = 24 - /// Luminance above which a background is treated as "light" (dark text) rather than "dark". - private static let appearanceMidpointLuminance = 0.3 - - private static let contrastFlare = 0.05 - - // MARK: - Public - - /// Resolves the on-theme identity color for `name`, guaranteed to clear the contrast floor - /// against every surface in `backgroundLuminances` (all expected to share the current - /// appearance's polarity — e.g. the canvas and incoming-bubble luminances for one appearance). - func color(forName name: String, backgroundLuminances: [Double], highContrast: Bool, atHue: Double? = nil, atVariety: Double? = nil) -> Color { - let resolved = resolve(forName: name, backgroundLuminances: backgroundLuminances, highContrast: highContrast, atHue: atHue, atVariety: atVariety) - // Build the color from the exact sRGB components the solver evaluated, rather than letting - // `Color(hue:saturation:brightness:)` re-derive them in a wider gamut: that would drift the - // rendered luminance off the solved value and could undercut the AA floor the solver targeted. - let rgb = Self.hsbToRGB(hue: resolved.hue, saturation: resolved.saturation, brightness: resolved.brightness) - return Color(.sRGB, red: rgb.red, green: rgb.green, blue: rgb.blue) +struct IdentityGamut: Equatable { + /// Hue centers (degrees, 0..<360) that define the theme. A name lands near one of these and is + /// jittered within the gap to its neighbors, so the union covers a continuous slice of the wheel + /// while every color still reads as on-theme. + let hueAnchors: [Double] + /// Saturation band the solver draws from. The solver may dip below `lowerBound` only when a hue + /// physically cannot reach the luminance a dark background demands at full saturation. + let saturation: ClosedRange + + // MARK: - Tunables + + private static let degreesPerCircle = 360.0 + /// Floor the solver desaturates toward when a vivid hue cannot otherwise reach the luminance a + /// dark surface requires. Near zero so legibility is always achievable (a gray reaches any + /// luminance); colors only wash out this far in the rare high-contrast-on-dark case that needs it. + private static let saturationReachFloor = 0.0 + /// Step used when relaxing saturation to reach an unreachable luminance target. + private static let saturationRelaxStep = 0.04 + /// Fraction of the AA-feasible luminance span used as the dark-text variety window. Names spread + /// across `[darkVarietyFloor, boundary]` so brightness varies per identity without breaching AA. + private static let darkVarietyFloor = 0.45 + /// Upper luminance cap for light-text (dark-appearance) colors so they never go pure white. + private static let lightLuminanceCap = 0.86 + /// Added to the target contrast ratio so rendering drift (HSB rounding, wide-gamut resolution) + /// cannot drop the rendered color below the real AA floor. + private static let contrastSafetyMargin = 0.35 + /// Binary-search iteration count for mapping a luminance target to a brightness value. + private static let brightnessSearchIterations = 24 + /// Luminance above which a background is treated as "light" (dark text) rather than "dark". + private static let appearanceMidpointLuminance = 0.3 + + private static let contrastFlare = 0.05 + + // MARK: - Public + + /// Resolves the on-theme identity color for `name`, guaranteed to clear the contrast floor + /// against every surface in `backgroundLuminances` (all expected to share the current + /// appearance's polarity — e.g. the canvas and incoming-bubble luminances for one appearance). + func color(forName name: String, backgroundLuminances: [Double], highContrast: Bool, atHue: Double? = nil, atVariety: Double? = nil) -> Color { + let resolved = resolve(forName: name, backgroundLuminances: backgroundLuminances, highContrast: highContrast, atHue: atHue, atVariety: atVariety) + // Build the color from the exact sRGB components the solver evaluated, rather than letting + // `Color(hue:saturation:brightness:)` re-derive them in a wider gamut: that would drift the + // rendered luminance off the solved value and could undercut the AA floor the solver targeted. + let rgb = Self.hsbToRGB(hue: resolved.hue, saturation: resolved.saturation, brightness: resolved.brightness) + return Color(.sRGB, red: rgb.red, green: rgb.green, blue: rgb.blue) + } + + /// Sorted hue anchors, the on-theme hues a name can land on before jitter. + var sortedAnchors: [Double] { + hueAnchors.sorted() + } + + /// Assigns each name in `names` a distinct anchor hue (no jitter), bumping to the next anchor on + /// collision so a small fixed set — the channel / repeater / room category avatars — never share a + /// color. Earlier names in the array win their preferred anchor; later ones step to the next free + /// one. Stays on-palette because every result is one of the theme's declared anchors. + func distinctAnchorHues(forNames names: [String]) -> [Double] { + let anchors = sortedAnchors + var taken = Set() + return names.map { name in + var index = Int(Self.fnv1a(name) % UInt64(anchors.count)) + while taken.contains(index) { + index = (index + 1) % anchors.count + } + taken.insert(index) + return anchors[index] } - - /// Sorted hue anchors, the on-theme hues a name can land on before jitter. - var sortedAnchors: [Double] { hueAnchors.sorted() } - - /// Assigns each name in `names` a distinct anchor hue (no jitter), bumping to the next anchor on - /// collision so a small fixed set — the channel / repeater / room category avatars — never share a - /// color. Earlier names in the array win their preferred anchor; later ones step to the next free - /// one. Stays on-palette because every result is one of the theme's declared anchors. - func distinctAnchorHues(forNames names: [String]) -> [Double] { - let anchors = sortedAnchors - var taken = Set() - return names.map { name in - var index = Int(Self.fnv1a(name) % UInt64(anchors.count)) - while taken.contains(index) { index = (index + 1) % anchors.count } - taken.insert(index) - return anchors[index] - } - } - - /// The glyph (initials / icon) color that reads on a fill of the given luminance: white when the - /// fill is dark enough, otherwise near-black. One of the two always clears AA for any fill. - static func glyphColor(forFillLuminance luminance: Double) -> Color { - let whiteContrast = (1.0 + contrastFlare) / (luminance + contrastFlare) - let blackContrast = (luminance + contrastFlare) / contrastFlare - return whiteContrast >= blackContrast ? .white : Color(white: 0.1) - } - - // MARK: - Solver (exposed at package level for tests) - - /// Hue (degrees), saturation, and brightness a name resolves to, before constructing the `Color`. - /// Exposed so tests can resolve the same values the renderer will and assert AA on the real color. - /// `atHue` overrides the jittered hue with a fixed one — used to pin category avatars to an anchor — - /// while saturation and brightness still derive from the name's seed. `atVariety` overrides the - /// per-name brightness draw (0 = darkest legible, 1 = lightest legible); category avatars pass 0 so - /// they render as deep, consistent swatches rather than washed-out brights. - func resolve( - forName name: String, - backgroundLuminances: [Double], - highContrast: Bool, - atHue: Double? = nil, - atVariety: Double? = nil - ) -> (hue: Double, saturation: Double, brightness: Double) { - let seed = Self.fnv1a(name) - let hue = atHue ?? self.hue(for: seed) - let baseSaturation = pickSaturation(for: seed) - let varietyFraction = atVariety ?? Self.fraction(seed, shift: 48) - let floor = (highContrast ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor) + Self.contrastSafetyMargin - - let backgrounds = backgroundLuminances.isEmpty ? [1.0] : backgroundLuminances - let darkText = (backgrounds.min() ?? 1.0) >= Self.appearanceMidpointLuminance - - if darkText { - // Light surfaces -> dark text. Binding surface is the darkest (least headroom). - let bindingBackground = backgrounds.min() ?? 1.0 - let maxLuminance = (bindingBackground + Self.contrastFlare) / floor - Self.contrastFlare - let safeMax = max(maxLuminance, 0) - let targetLuminance = safeMax * (Self.darkVarietyFloor + (1 - Self.darkVarietyFloor) * varietyFraction) - let brightness = brightnessFor(luminance: targetLuminance, hue: hue, saturation: baseSaturation) - return (hue, baseSaturation, brightness) - } else { - // Dark surfaces -> light text. Binding surface is the lightest (least headroom). - let bindingBackground = backgrounds.max() ?? 0.0 - // Required luminance to clear the floor. Not capped: a light dark-mode bubble can demand a - // near-white text, and undershooting it would breach AA. - let requiredLuminance = max(floor * (bindingBackground + Self.contrastFlare) - Self.contrastFlare, 0) - // A vivid hue caps the luminance it can reach at full brightness; desaturate as far as - // needed to reach the requirement. Legibility outranks saturation in the rare - // high-contrast-on-dark case that forces this; a gray reaches any luminance. - var saturation = baseSaturation - while saturation > Self.saturationReachFloor - && Self.luminance(hue: hue, saturation: saturation, brightness: 1.0) < requiredLuminance { - saturation = max(Self.saturationReachFloor, saturation - Self.saturationRelaxStep) - } - // Vary lighter than the minimum (lighter = more contrast = still AA), capped at what the - // hue can reach so the target is always achievable. - let reachableMax = Self.luminance(hue: hue, saturation: saturation, brightness: 1.0) - let ceiling = max(requiredLuminance, min(reachableMax, Self.lightLuminanceCap)) - let targetLuminance = min(requiredLuminance + (ceiling - requiredLuminance) * varietyFraction, reachableMax) - let brightness = brightnessFor(luminance: targetLuminance, hue: hue, saturation: saturation) - return (hue, saturation, brightness) - } - } - - // MARK: - Hue / saturation selection - - private func hue(for seed: UInt64) -> Double { - let sorted = hueAnchors.sorted() - let index = Int(seed % UInt64(sorted.count)) - let anchor = sorted[index] - // Gap to each neighbor on the circle; jitter within half the gap so anchors tile the wheel. - let next = sorted[(index + 1) % sorted.count] - let prev = sorted[(index - 1 + sorted.count) % sorted.count] - let gapNext = Self.circularGap(from: anchor, to: next) - let gapPrev = Self.circularGap(from: prev, to: anchor) - let fraction = Self.fraction(seed, shift: 16) * 2 - 1 - let jitter = fraction >= 0 ? fraction * gapNext / 2 : fraction * gapPrev / 2 - return (anchor + jitter).truncatingRemainder(dividingBy: Self.degreesPerCircle) + (anchor + jitter < 0 ? Self.degreesPerCircle : 0) - } - - private func pickSaturation(for seed: UInt64) -> Double { - saturation.lowerBound + (saturation.upperBound - saturation.lowerBound) * Self.fraction(seed, shift: 32) + } + + /// The glyph (initials / icon) color that reads on a fill of the given luminance: white when the + /// fill is dark enough, otherwise near-black. One of the two always clears AA for any fill. + static func glyphColor(forFillLuminance luminance: Double) -> Color { + let whiteContrast = (1.0 + contrastFlare) / (luminance + contrastFlare) + let blackContrast = (luminance + contrastFlare) / contrastFlare + return whiteContrast >= blackContrast ? .white : Color(white: 0.1) + } + + // MARK: - Solver (exposed at package level for tests) + + /// Hue (degrees), saturation, and brightness a name resolves to, before constructing the `Color`. + /// Exposed so tests can resolve the same values the renderer will and assert AA on the real color. + /// `atHue` overrides the jittered hue with a fixed one — used to pin category avatars to an anchor — + /// while saturation and brightness still derive from the name's seed. `atVariety` overrides the + /// per-name brightness draw (0 = darkest legible, 1 = lightest legible); category avatars pass 0 so + /// they render as deep, consistent swatches rather than washed-out brights. + func resolve( + forName name: String, + backgroundLuminances: [Double], + highContrast: Bool, + atHue: Double? = nil, + atVariety: Double? = nil + ) -> (hue: Double, saturation: Double, brightness: Double) { + let seed = Self.fnv1a(name) + let hue = atHue ?? hue(for: seed) + let baseSaturation = pickSaturation(for: seed) + let varietyFraction = atVariety ?? Self.fraction(seed, shift: 48) + let floor = (highContrast ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor) + Self.contrastSafetyMargin + + let backgrounds = backgroundLuminances.isEmpty ? [1.0] : backgroundLuminances + let darkText = (backgrounds.min() ?? 1.0) >= Self.appearanceMidpointLuminance + + if darkText { + // Light surfaces -> dark text. Binding surface is the darkest (least headroom). + let bindingBackground = backgrounds.min() ?? 1.0 + let maxLuminance = (bindingBackground + Self.contrastFlare) / floor - Self.contrastFlare + let safeMax = max(maxLuminance, 0) + let targetLuminance = safeMax * (Self.darkVarietyFloor + (1 - Self.darkVarietyFloor) * varietyFraction) + let brightness = brightnessFor(luminance: targetLuminance, hue: hue, saturation: baseSaturation) + return (hue, baseSaturation, brightness) + } else { + // Dark surfaces -> light text. Binding surface is the lightest (least headroom). + let bindingBackground = backgrounds.max() ?? 0.0 + // Required luminance to clear the floor. Not capped: a light dark-mode bubble can demand a + // near-white text, and undershooting it would breach AA. + let requiredLuminance = max(floor * (bindingBackground + Self.contrastFlare) - Self.contrastFlare, 0) + // A vivid hue caps the luminance it can reach at full brightness; desaturate as far as + // needed to reach the requirement. Legibility outranks saturation in the rare + // high-contrast-on-dark case that forces this; a gray reaches any luminance. + var saturation = baseSaturation + while saturation > Self.saturationReachFloor, + Self.luminance(hue: hue, saturation: saturation, brightness: 1.0) < requiredLuminance { + saturation = max(Self.saturationReachFloor, saturation - Self.saturationRelaxStep) + } + // Vary lighter than the minimum (lighter = more contrast = still AA), capped at what the + // hue can reach so the target is always achievable. + let reachableMax = Self.luminance(hue: hue, saturation: saturation, brightness: 1.0) + let ceiling = max(requiredLuminance, min(reachableMax, Self.lightLuminanceCap)) + let targetLuminance = min(requiredLuminance + (ceiling - requiredLuminance) * varietyFraction, reachableMax) + let brightness = brightnessFor(luminance: targetLuminance, hue: hue, saturation: saturation) + return (hue, saturation, brightness) } - - private static func circularGap(from: Double, to: Double) -> Double { - let raw = (to - from).truncatingRemainder(dividingBy: degreesPerCircle) - return raw <= 0 ? raw + degreesPerCircle : raw + } + + // MARK: - Hue / saturation selection + + private func hue(for seed: UInt64) -> Double { + let sorted = hueAnchors.sorted() + let index = Int(seed % UInt64(sorted.count)) + let anchor = sorted[index] + // Gap to each neighbor on the circle; jitter within half the gap so anchors tile the wheel. + let next = sorted[(index + 1) % sorted.count] + let prev = sorted[(index - 1 + sorted.count) % sorted.count] + let gapNext = Self.circularGap(from: anchor, to: next) + let gapPrev = Self.circularGap(from: prev, to: anchor) + let fraction = Self.fraction(seed, shift: 16) * 2 - 1 + let jitter = fraction >= 0 ? fraction * gapNext / 2 : fraction * gapPrev / 2 + return (anchor + jitter).truncatingRemainder(dividingBy: Self.degreesPerCircle) + (anchor + jitter < 0 ? Self.degreesPerCircle : 0) + } + + private func pickSaturation(for seed: UInt64) -> Double { + saturation.lowerBound + (saturation.upperBound - saturation.lowerBound) * Self.fraction(seed, shift: 32) + } + + private static func circularGap(from: Double, to: Double) -> Double { + let raw = (to - from).truncatingRemainder(dividingBy: degreesPerCircle) + return raw <= 0 ? raw + degreesPerCircle : raw + } + + // MARK: - Luminance / brightness math + + private func brightnessFor(luminance target: Double, hue: Double, saturation: Double) -> Double { + var low = 0.0 + var high = 1.0 + for _ in 0.. Double { - var low = 0.0 - var high = 1.0 - for _ in 0.. Double { + let rgb = hsbToRGB(hue: hue, saturation: saturation, brightness: brightness) + return WCAGContrast.relativeLuminance(red: rgb.red, green: rgb.green, blue: rgb.blue) + } + + /// Standard sRGB HSB-to-RGB conversion (`hue` in degrees). Matches `Color(hue:saturation:brightness:)` + /// closely enough that the solver's luminance prediction tracks the rendered color; the + /// `contrastSafetyMargin` absorbs any residual drift. + private static func hsbToRGB(hue: Double, saturation: Double, brightness: Double) -> (red: Double, green: Double, blue: Double) { + let h = (hue.truncatingRemainder(dividingBy: degreesPerCircle) + degreesPerCircle).truncatingRemainder(dividingBy: degreesPerCircle) / 60 + let sector = Int(h) % 6 + let fraction = h - Double(Int(h)) + let p = brightness * (1 - saturation) + let q = brightness * (1 - fraction * saturation) + let t = brightness * (1 - (1 - fraction) * saturation) + switch sector { + case 0: return (brightness, t, p) + case 1: return (q, brightness, p) + case 2: return (p, brightness, t) + case 3: return (p, q, brightness) + case 4: return (t, p, brightness) + default: return (brightness, p, q) } - - private static func luminance(hue: Double, saturation: Double, brightness: Double) -> Double { - let rgb = hsbToRGB(hue: hue, saturation: saturation, brightness: brightness) - return WCAGContrast.relativeLuminance(red: rgb.red, green: rgb.green, blue: rgb.blue) - } - - /// Standard sRGB HSB-to-RGB conversion (`hue` in degrees). Matches `Color(hue:saturation:brightness:)` - /// closely enough that the solver's luminance prediction tracks the rendered color; the - /// `contrastSafetyMargin` absorbs any residual drift. - private static func hsbToRGB(hue: Double, saturation: Double, brightness: Double) -> (red: Double, green: Double, blue: Double) { - let h = (hue.truncatingRemainder(dividingBy: degreesPerCircle) + degreesPerCircle).truncatingRemainder(dividingBy: degreesPerCircle) / 60 - let sector = Int(h) % 6 - let fraction = h - Double(Int(h)) - let p = brightness * (1 - saturation) - let q = brightness * (1 - fraction * saturation) - let t = brightness * (1 - (1 - fraction) * saturation) - switch sector { - case 0: return (brightness, t, p) - case 1: return (q, brightness, p) - case 2: return (p, brightness, t) - case 3: return (p, q, brightness) - case 4: return (t, p, brightness) - default: return (brightness, p, q) - } - } - - // MARK: - Hashing - - /// FNV-1a 64-bit: a stable, well-distributed hash. Stable across launches (unlike `Hasher`, which - /// is per-process seeded) and free of the order-insensitivity that makes XOR-folding collide - /// anagrams and cluster on a continuous hue space. - private static func fnv1a(_ string: String) -> UInt64 { - var hash: UInt64 = 0xcbf2_9ce4_8422_2325 - for byte in string.utf8 { - hash ^= UInt64(byte) - hash = hash &* 0x0000_0100_0000_01b3 - } - return hash - } - - /// A 0...1 fraction from 16 bits of the hash starting at `shift`, giving independent draws for - /// hue jitter, saturation, and brightness variety from one hash. - private static func fraction(_ seed: UInt64, shift: UInt64) -> Double { - Double((seed >> shift) & 0xFFFF) / Double(0xFFFF) + } + + // MARK: - Hashing + + /// FNV-1a 64-bit: a stable, well-distributed hash. Stable across launches (unlike `Hasher`, which + /// is per-process seeded) and free of the order-insensitivity that makes XOR-folding collide + /// anagrams and cluster on a continuous hue space. + private static func fnv1a(_ string: String) -> UInt64 { + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for byte in string.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 } + return hash + } + + /// A 0...1 fraction from 16 bits of the hash starting at `shift`, giving independent draws for + /// hue jitter, saturation, and brightness variety from one hash. + private static func fraction(_ seed: UInt64, shift: UInt64) -> Double { + Double((seed >> shift) & 0xFFFF) / Double(0xFFFF) + } } diff --git a/MC1/Theme/Theme+AvatarColors.swift b/MC1/Theme/Theme+AvatarColors.swift index cffe0f2c..46fea7ea 100644 --- a/MC1/Theme/Theme+AvatarColors.swift +++ b/MC1/Theme/Theme+AvatarColors.swift @@ -10,91 +10,92 @@ import UIKit /// already runs there. @MainActor extension Theme { + /// Relative luminances of the surfaces avatars and identity names sit on, resolved for the given + /// appearance. The canvas (or the system background, for the surfaceless default theme) and the + /// incoming bubble are the two surfaces an identity color must stay legible against. + func avatarSurfaceLuminances(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> [Double] { + let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) + let surfaces = [surfaces?.canvas ?? Color(.systemBackground), incomingBubbleColor] + return surfaces.map { WCAGContrast.relativeLuminance(of: UIColor($0).resolvedColor(with: traits)) } + } - /// Relative luminances of the surfaces avatars and identity names sit on, resolved for the given - /// appearance. The canvas (or the system background, for the surfaceless default theme) and the - /// incoming bubble are the two surfaces an identity color must stay legible against. - func avatarSurfaceLuminances(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> [Double] { - let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) - let surfaces = [self.surfaces?.canvas ?? Color(.systemBackground), incomingBubbleColor] - return surfaces.map { WCAGContrast.relativeLuminance(of: UIColor($0).resolvedColor(with: traits)) } - } - - /// Relative luminance of the only surface a category avatar (channel / repeater / room) sits on: - /// the list canvas. Unlike identity colors, these never render as a sender name on the incoming - /// bubble, so constraining them against it only forces them needlessly bright. - func categorySurfaceLuminance(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Double { - let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) - let canvas = self.surfaces?.canvas ?? Color(.systemBackground) - return WCAGContrast.relativeLuminance(of: UIColor(canvas).resolvedColor(with: traits)) - } + /// Relative luminance of the only surface a category avatar (channel / repeater / room) sits on: + /// the list canvas. Unlike identity colors, these never render as a sender name on the incoming + /// bubble, so constraining them against it only forces them needlessly bright. + func categorySurfaceLuminance(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Double { + let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) + let canvas = surfaces?.canvas ?? Color(.systemBackground) + return WCAGContrast.relativeLuminance(of: UIColor(canvas).resolvedColor(with: traits)) + } - /// Color for a contact avatar / channel sender name / mention with the given identity name. - func identityColor(forName name: String, colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Color { - identityGamut.color( - forName: name, - backgroundLuminances: avatarSurfaceLuminances(colorScheme: colorScheme, contrast: contrast), - highContrast: contrast == .increased - ) - } + /// Color for a contact avatar / channel sender name / mention with the given identity name. + func identityColor(forName name: String, colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Color { + identityGamut.color( + forName: name, + backgroundLuminances: avatarSurfaceLuminances(colorScheme: colorScheme, contrast: contrast), + highContrast: contrast == .increased + ) + } - /// The single fixed color for a channel / repeater / room avatar. The System theme pins these to - /// legacy values; every other theme resolves each category at its curated `categoryHue`, against - /// the list canvas only and at the darkest legible brightness, so the swatch is a deep, on-theme - /// color (and the three stay distinct even when a room and a channel share the Chats list). - func categoryAvatarColor(_ category: AvatarCategory, colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Color { - if let override = categoryAvatarOverride { return override.color(for: category) } - return identityGamut.color( - forName: category.gamutSeed, - backgroundLuminances: [categorySurfaceLuminance(colorScheme: colorScheme, contrast: contrast)], - highContrast: contrast == .increased, - atHue: categoryHue(for: category), - atVariety: Self.categoryDarkestVariety - ) - } + /// The single fixed color for a channel / repeater / room avatar. The System theme pins these to + /// legacy values; every other theme resolves each category at its curated `categoryHue`, against + /// the list canvas only and at the darkest legible brightness, so the swatch is a deep, on-theme + /// color (and the three stay distinct even when a room and a channel share the Chats list). + func categoryAvatarColor(_ category: AvatarCategory, colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> Color { + if let override = categoryAvatarOverride { return override.color(for: category) } + return identityGamut.color( + forName: category.gamutSeed, + backgroundLuminances: [categorySurfaceLuminance(colorScheme: colorScheme, contrast: contrast)], + highContrast: contrast == .increased, + atHue: categoryHue(for: category), + atVariety: Self.categoryDarkestVariety + ) + } - /// Glyph (initials / icon) color for an avatar of the given fill. The System category override - /// keeps the historical white glyph; gamut-derived avatars adapt so the glyph reads whether the - /// fill resolved light (dark appearance) or dark (light appearance). - func avatarGlyphColor( - forFill fill: Color, - usesCategoryOverride: Bool, - colorScheme: ColorScheme, - contrast: ColorSchemeContrast - ) -> Color { - guard !usesCategoryOverride else { return .white } - let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) - let luminance = WCAGContrast.relativeLuminance(of: UIColor(fill).resolvedColor(with: traits)) - return IdentityGamut.glyphColor(forFillLuminance: luminance) - } + /// Glyph (initials / icon) color for an avatar of the given fill. The System category override + /// keeps the historical white glyph; gamut-derived avatars adapt so the glyph reads whether the + /// fill resolved light (dark appearance) or dark (light appearance). + func avatarGlyphColor( + forFill fill: Color, + usesCategoryOverride: Bool, + colorScheme: ColorScheme, + contrast: ColorSchemeContrast + ) -> Color { + guard !usesCategoryOverride else { return .white } + let traits = Self.traitCollection(colorScheme: colorScheme, contrast: contrast) + let luminance = WCAGContrast.relativeLuminance(of: UIColor(fill).resolvedColor(with: traits)) + return IdentityGamut.glyphColor(forFillLuminance: luminance) + } - /// True when this theme pins its category avatars to fixed colors (System) rather than deriving - /// them from the gamut. Drives the glyph choice in the avatar views. - var usesCategoryAvatarOverride: Bool { categoryAvatarOverride != nil } + /// True when this theme pins its category avatars to fixed colors (System) rather than deriving + /// them from the gamut. Drives the glyph choice in the avatar views. + var usesCategoryAvatarOverride: Bool { + categoryAvatarOverride != nil + } - static func traitCollection(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> UITraitCollection { - UITraitCollection { traits in - traits.userInterfaceStyle = colorScheme == .dark ? .dark : .light - traits.accessibilityContrast = contrast == .increased ? .high : .unspecified - } + static func traitCollection(colorScheme: ColorScheme, contrast: ColorSchemeContrast) -> UITraitCollection { + UITraitCollection { traits in + traits.userInterfaceStyle = colorScheme == .dark ? .dark : .light + traits.accessibilityContrast = contrast == .increased ? .high : .unspecified } + } } extension Theme { - /// Brightness draw category avatars resolve at: the darkest legible value (no variety), so they - /// read as deep, on-theme swatches instead of the washed-out brights the bubble surface forced. - static let categoryDarkestVariety = 0.0 + /// Brightness draw category avatars resolve at: the darkest legible value (no variety), so they + /// read as deep, on-theme swatches instead of the washed-out brights the bubble surface forced. + static let categoryDarkestVariety = 0.0 - /// Hue (degrees) a category avatar resolves at: the theme's curated `categoryHues` value, or a - /// distinct on-anchor pick for a gamut theme that hasn't curated them. Pure hue selection, so it - /// stays off the main actor for the color bake and tests. - func categoryHue(for category: AvatarCategory) -> Double { - if let curated = categoryHues?.hue(for: category) { return curated } - let hues = identityGamut.distinctAnchorHues(forNames: AvatarCategory.anchorPriority.map(\.gamutSeed)) - switch category { - case .channel: return hues[0] - case .repeater: return hues[1] - case .room: return hues[2] - } + /// Hue (degrees) a category avatar resolves at: the theme's curated `categoryHues` value, or a + /// distinct on-anchor pick for a gamut theme that hasn't curated them. Pure hue selection, so it + /// stays off the main actor for the color bake and tests. + func categoryHue(for category: AvatarCategory) -> Double { + if let curated = categoryHues?.hue(for: category) { return curated } + let hues = identityGamut.distinctAnchorHues(forNames: AvatarCategory.anchorPriority.map(\.gamutSeed)) + switch category { + case .channel: return hues[0] + case .repeater: return hues[1] + case .room: return hues[2] } + } } diff --git a/MC1/Theme/Theme+LocalizedName.swift b/MC1/Theme/Theme+LocalizedName.swift index 2403cc38..c15e3be7 100644 --- a/MC1/Theme/Theme+LocalizedName.swift +++ b/MC1/Theme/Theme+LocalizedName.swift @@ -1,41 +1,41 @@ import Foundation extension Theme { - /// The user-facing theme name, resolved through static `L10n` members for the known themes - /// (compile-time-safe key references). A theme added to the registry without a case here is a - /// bug: it traps in debug (caught by `ThemeLocalizedNameTests`) and, in release, falls back to - /// a dynamic lookup of `displayNameKey` so the user never sees a raw dotted key string. - var localizedName: String { - switch id { - case Theme.default.id: return L10n.Settings.Support.Theme.default - case Theme.ember.id: return L10n.Settings.Support.Theme.ember - case Theme.fern.id: return L10n.Settings.Support.Theme.fern - case Theme.marine.id: return L10n.Settings.Support.Theme.marine - case Theme.olive.id: return L10n.Settings.Support.Theme.olive - case Theme.lavender.id: return L10n.Settings.Support.Theme.lavender - // Proper nouns, never translated: Sakura is romanized Japanese; Solarized, Nord, and - // Catppuccin are the names of the upstream MIT-licensed palettes. - case Theme.sakura.id: return "Sakura" - case Theme.solarized.id: return "Solarized" - case Theme.nord.id: return "Nord" - case Theme.catppuccin.id: return "Catppuccin" - default: - assertionFailure("Theme '\(id)' is missing a localizedName case") - return dynamicallyResolvedName - } + /// The user-facing theme name, resolved through static `L10n` members for the known themes + /// (compile-time-safe key references). A theme added to the registry without a case here is a + /// bug: it traps in debug (caught by `ThemeLocalizedNameTests`) and, in release, falls back to + /// a dynamic lookup of `displayNameKey` so the user never sees a raw dotted key string. + var localizedName: String { + switch id { + case Theme.default.id: return L10n.Settings.Support.Theme.default + case Theme.ember.id: return L10n.Settings.Support.Theme.ember + case Theme.fern.id: return L10n.Settings.Support.Theme.fern + case Theme.marine.id: return L10n.Settings.Support.Theme.marine + case Theme.olive.id: return L10n.Settings.Support.Theme.olive + case Theme.lavender.id: return L10n.Settings.Support.Theme.lavender + // Proper nouns, never translated: Sakura is romanized Japanese; Solarized, Nord, and + // Catppuccin are the names of the upstream MIT-licensed palettes. + case Theme.sakura.id: return "Sakura" + case Theme.solarized.id: return "Solarized" + case Theme.nord.id: return "Nord" + case Theme.catppuccin.id: return "Catppuccin" + default: + assertionFailure("Theme '\(id)' is missing a localizedName case") + return dynamicallyResolvedName } + } - /// Fallback for a registry theme that has a localization key but no explicit `localizedName` - /// case above. Resolves `displayNameKey` against the Settings strings table; `displayNameKey` is - /// the SwiftGen accessor path (`Settings.Support.Theme.X`), and the on-disk key is that path - /// minus the leading table component. Proper-noun themes carry no key — they return their fixed - /// name from the switch and never reach here; a keyless theme that somehow does falls back to its - /// raw id rather than crashing. - private var dynamicallyResolvedName: String { - guard let displayNameKey else { return id } - let table = "Settings" - let prefix = table + "." - let key = displayNameKey.hasPrefix(prefix) ? String(displayNameKey.dropFirst(prefix.count)) : displayNameKey - return Bundle.main.localizedString(forKey: key, value: displayNameKey, table: table) - } + /// Fallback for a registry theme that has a localization key but no explicit `localizedName` + /// case above. Resolves `displayNameKey` against the Settings strings table; `displayNameKey` is + /// the SwiftGen accessor path (`Settings.Support.Theme.X`), and the on-disk key is that path + /// minus the leading table component. Proper-noun themes carry no key — they return their fixed + /// name from the switch and never reach here; a keyless theme that somehow does falls back to its + /// raw id rather than crashing. + private var dynamicallyResolvedName: String { + guard let displayNameKey else { return id } + let table = "Settings" + let prefix = table + "." + let key = displayNameKey.hasPrefix(prefix) ? String(displayNameKey.dropFirst(prefix.count)) : displayNameKey + return Bundle.main.localizedString(forKey: key, value: displayNameKey, table: table) + } } diff --git a/MC1/Theme/Theme.swift b/MC1/Theme/Theme.swift index 495c252f..337e304c 100644 --- a/MC1/Theme/Theme.swift +++ b/MC1/Theme/Theme.swift @@ -1,255 +1,255 @@ -import SwiftUI import MC1Services +import SwiftUI /// A cosmetic theme: accent + chat colors + optional surfaces + optional forced color scheme. /// Built-in themes are static factory values (below), not subtypes. -struct Theme: Sendable, Identifiable, Equatable { - let id: String - /// SwiftGen key path (`Settings.Support.Theme.X`) for themes whose name is localized. `nil` for - /// proper-noun themes (Sakura, Solarized, Nord, Catppuccin) that are never translated and resolve - /// their fixed name from the explicit `localizedName` switch instead. - let displayNameKey: String? - let productID: String? - let accentColor: Color - let outgoingTextColor: Color - let hashtagColor: Color - let preferredColorScheme: ColorScheme? - let surfaces: Surfaces? - /// The theme's identity-color space for contact avatars, channel sender names, and mentions. - /// Varied per identity, drawn from the theme's character hues. - let identityGamut: IdentityGamut - /// Set only by the System theme to pin channel / repeater / room avatars to fixed legacy colors. - /// `nil` for every other theme, which derives its category colors from `identityGamut`. - let categoryAvatarOverride: CategoryAvatarColors? - /// Curated channel / repeater / room avatar hues for a gamut theme, each picked from the theme's - /// anchors to be on-palette and distinct. `nil` falls back to a distinct auto-anchor pick. - let categoryHues: CategoryHues? - - init( - id: String, - displayNameKey: String?, - productID: String?, - accentColor: Color, - outgoingTextColor: Color, - hashtagColor: Color, - preferredColorScheme: ColorScheme?, - surfaces: Surfaces? = nil, - identityGamut: IdentityGamut, - categoryAvatarOverride: CategoryAvatarColors? = nil, - categoryHues: CategoryHues? = nil - ) { - self.id = id - self.displayNameKey = displayNameKey - self.productID = productID - self.accentColor = accentColor - self.outgoingTextColor = outgoingTextColor - self.hashtagColor = hashtagColor - self.preferredColorScheme = preferredColorScheme - self.surfaces = surfaces - self.identityGamut = identityGamut - self.categoryAvatarOverride = categoryAvatarOverride - self.categoryHues = categoryHues - } +struct Theme: Identifiable, Equatable { + let id: String + /// SwiftGen key path (`Settings.Support.Theme.X`) for themes whose name is localized. `nil` for + /// proper-noun themes (Sakura, Solarized, Nord, Catppuccin) that are never translated and resolve + /// their fixed name from the explicit `localizedName` switch instead. + let displayNameKey: String? + let productID: String? + let accentColor: Color + let outgoingTextColor: Color + let hashtagColor: Color + let preferredColorScheme: ColorScheme? + let surfaces: Surfaces? + /// The theme's identity-color space for contact avatars, channel sender names, and mentions. + /// Varied per identity, drawn from the theme's character hues. + let identityGamut: IdentityGamut + /// Set only by the System theme to pin channel / repeater / room avatars to fixed legacy colors. + /// `nil` for every other theme, which derives its category colors from `identityGamut`. + let categoryAvatarOverride: CategoryAvatarColors? + /// Curated channel / repeater / room avatar hues for a gamut theme, each picked from the theme's + /// anchors to be on-palette and distinct. `nil` falls back to a distinct auto-anchor pick. + let categoryHues: CategoryHues? + + init( + id: String, + displayNameKey: String?, + productID: String?, + accentColor: Color, + outgoingTextColor: Color, + hashtagColor: Color, + preferredColorScheme: ColorScheme?, + surfaces: Surfaces? = nil, + identityGamut: IdentityGamut, + categoryAvatarOverride: CategoryAvatarColors? = nil, + categoryHues: CategoryHues? = nil + ) { + self.id = id + self.displayNameKey = displayNameKey + self.productID = productID + self.accentColor = accentColor + self.outgoingTextColor = outgoingTextColor + self.hashtagColor = hashtagColor + self.preferredColorScheme = preferredColorScheme + self.surfaces = surfaces + self.identityGamut = identityGamut + self.categoryAvatarOverride = categoryAvatarOverride + self.categoryHues = categoryHues + } } extension Theme { - /// Canvas (system grouped replacement) + card (secondary system grouped replacement) for paid themes. - /// `card == nil` means "paint the canvas but leave card rows on the system tier" (Ember). - struct Surfaces: Sendable, Equatable { - let canvas: Color - let card: Color? - - init(canvas: Color, card: Color? = nil) { - self.canvas = canvas - self.card = card - } - - /// Fill for a card-tier list row. Returns `nil` when `flatten` is set (the iPad Settings - /// sidebar column), so rows stay transparent: the column canvas shows through and the native - /// `.sidebar` selection highlight survives, which any `listRowBackground` would suppress. - /// Otherwise returns the card tier — the inset-grouped "card on grouped gray" look used - /// everywhere else. `nil` for surfaces without a card tier (Ember). - func rowFill(flatten: Bool) -> Color? { - flatten ? nil : card - } + /// Canvas (system grouped replacement) + card (secondary system grouped replacement) for paid themes. + /// `card == nil` means "paint the canvas but leave card rows on the system tier" (Ember). + struct Surfaces: Equatable { + let canvas: Color + let card: Color? + + init(canvas: Color, card: Color? = nil) { + self.canvas = canvas + self.card = card } - /// Tint to impose on global chrome (buttons, links, controls) at the scene root. - /// The default theme returns `nil` so chrome defers to the system tint; `accentColor` - /// is reserved for deliberate brand surfaces (chat bubbles, palette swatch). - var chromeTint: Color? { - id == Theme.default.id ? nil : accentColor - } - - /// Incoming message bubble fill. Themed surfaces use the card tier, which is tuned to - /// contrast with the canvas; the default theme (and canvas-only themes like Ember) keep - /// the system gray that reads correctly on `systemBackground`. - var incomingBubbleColor: Color { - surfaces?.card ?? AppColors.Message.incomingBubble + /// Fill for a card-tier list row. Returns `nil` when `flatten` is set (the iPad Settings + /// sidebar column), so rows stay transparent: the column canvas shows through and the native + /// `.sidebar` selection highlight survives, which any `listRowBackground` would suppress. + /// Otherwise returns the card tier — the inset-grouped "card on grouped gray" look used + /// everywhere else. `nil` for surfaces without a card tier (Ember). + func rowFill(flatten: Bool) -> Color? { + flatten ? nil : card } + } + + /// Tint to impose on global chrome (buttons, links, controls) at the scene root. + /// The default theme returns `nil` so chrome defers to the system tint; `accentColor` + /// is reserved for deliberate brand surfaces (chat bubbles, palette swatch). + var chromeTint: Color? { + id == Theme.default.id ? nil : accentColor + } + + /// Incoming message bubble fill. Themed surfaces use the card tier, which is tuned to + /// contrast with the canvas; the default theme (and canvas-only themes like Ember) keep + /// the system gray that reads correctly on `systemBackground`. + var incomingBubbleColor: Color { + surfaces?.card ?? AppColors.Message.incomingBubble + } } extension Theme { - static let `default` = Theme( - id: EnvInputs.defaultThemeID, - displayNameKey: "Settings.Support.Theme.Default", - productID: nil, - accentColor: Color("AppAccentColor"), - outgoingTextColor: .white, - hashtagColor: Color("HashtagDefault"), - preferredColorScheme: nil, - identityGamut: IdentityGamut( - hueAnchors: [18, 25, 44, 77, 120, 180, 215, 255, 307, 343], - saturation: 0.45...0.70 - ), - categoryAvatarOverride: CategoryAvatarColors( - channel: Color(hex: 0x336688), - repeaterNode: Color(hex: 0x00AAFF), - room: Color(hex: 0xFF8800) - ) - ) - - static let ember = Theme( - id: "ember", - displayNameKey: "Settings.Support.Theme.Ember", - productID: StoreCatalog.Theme.ember, - accentColor: Color("Theme/Ember/Accent"), - outgoingTextColor: Color("Theme/Ember/Text"), - hashtagColor: Color("Theme/Ember/Hashtag"), - preferredColorScheme: .dark, - surfaces: .init(canvas: .black), - identityGamut: IdentityGamut( - hueAnchors: [0, 8, 12, 24, 18, 345], - saturation: 0.50...0.82 - ), - categoryHues: CategoryHues(channel: 0, repeater: 24, room: 345) - ) - - static let fern = Theme( - id: "fern", - displayNameKey: "Settings.Support.Theme.Fern", - productID: StoreCatalog.Theme.fern, - accentColor: Color("Theme/Fern/Accent"), - outgoingTextColor: Color("Theme/Fern/OutgoingText"), - hashtagColor: Color("Theme/Fern/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Fern/Canvas"), card: Color("Theme/Fern/Card")), - identityGamut: IdentityGamut( - hueAnchors: [72, 90, 108, 128, 150, 165], - saturation: 0.35...0.65 - ), - categoryHues: CategoryHues(channel: 90, repeater: 128, room: 165) - ) - - static let marine = Theme( - id: "marine", - displayNameKey: "Settings.Support.Theme.Marine", - productID: StoreCatalog.Theme.marine, - accentColor: Color("Theme/Marine/Accent"), - outgoingTextColor: .white, - hashtagColor: Color("Theme/Marine/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Marine/Canvas"), card: Color("Theme/Marine/Card")), - identityGamut: IdentityGamut( - hueAnchors: [178, 188, 200, 215, 232, 245], - saturation: 0.40...0.72 - ), - categoryHues: CategoryHues(channel: 215, repeater: 188, room: 245) - ) - - static let olive = Theme( - id: "olive", - displayNameKey: "Settings.Support.Theme.Olive", - productID: StoreCatalog.Theme.olive, - accentColor: Color("Theme/Olive/Accent"), - outgoingTextColor: Color("Theme/Olive/OutgoingText"), - hashtagColor: Color("Theme/Olive/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Olive/Canvas"), card: Color("Theme/Olive/Card")), - identityGamut: IdentityGamut( - hueAnchors: [45, 55, 70, 85, 100, 112], - saturation: 0.35...0.62 - ), - categoryHues: CategoryHues(channel: 85, repeater: 112, room: 70) - ) - - static let lavender = Theme( - id: "lavender", - displayNameKey: "Settings.Support.Theme.Lavender", - productID: StoreCatalog.Theme.lavender, - accentColor: Color("Theme/Lavender/Accent"), - outgoingTextColor: Color("Theme/Lavender/OutgoingText"), - hashtagColor: Color("Theme/Lavender/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Lavender/Canvas"), card: Color("Theme/Lavender/Card")), - identityGamut: IdentityGamut( - hueAnchors: [222, 238, 248, 265, 285, 302], - saturation: 0.35...0.62 - ), - categoryHues: CategoryHues(channel: 265, repeater: 222, room: 302) - ) - - static let sakura = Theme( - id: "sakura", - displayNameKey: nil, - productID: StoreCatalog.Theme.sakura, - accentColor: Color("Theme/Sakura/Accent"), - outgoingTextColor: Color("Theme/Sakura/OutgoingText"), - hashtagColor: Color("Theme/Sakura/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Sakura/Canvas"), card: Color("Theme/Sakura/Card")), - identityGamut: IdentityGamut( - hueAnchors: [290, 305, 320, 335, 350, 8], - saturation: 0.40...0.70 - ), - categoryHues: CategoryHues(channel: 320, repeater: 290, room: 350) - ) - - static let solarized = Theme( - id: "solarized", - displayNameKey: nil, - productID: StoreCatalog.Theme.solarized, - accentColor: Color("Theme/Solarized/Accent"), - outgoingTextColor: Color("Theme/Solarized/OutgoingText"), - hashtagColor: Color("Theme/Solarized/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Solarized/Canvas"), card: Color("Theme/Solarized/Card")), - identityGamut: IdentityGamut( - hueAnchors: [1, 18, 45, 68, 175, 205, 237, 331], - saturation: 0.50...0.80 - ), - categoryHues: CategoryHues(channel: 205, repeater: 175, room: 331) - ) - - static let nord = Theme( - id: "nord", - displayNameKey: nil, - productID: StoreCatalog.Theme.nord, - accentColor: Color("Theme/Nord/Accent"), - outgoingTextColor: Color("Theme/Nord/OutgoingText"), - hashtagColor: Color("Theme/Nord/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Nord/Canvas"), card: Color("Theme/Nord/Card")), - identityGamut: IdentityGamut( - hueAnchors: [14, 40, 92, 178, 193, 210, 213, 240, 280, 311, 354], - saturation: 0.25...0.52 - ), - categoryHues: CategoryHues(channel: 210, repeater: 193, room: 280) - ) - - static let catppuccin = Theme( - id: "catppuccin", - displayNameKey: nil, - productID: StoreCatalog.Theme.catppuccin, - accentColor: Color("Theme/Catppuccin/Accent"), - outgoingTextColor: Color("Theme/Catppuccin/OutgoingText"), - hashtagColor: Color("Theme/Catppuccin/Hashtag"), - preferredColorScheme: nil, - surfaces: .init(canvas: Color("Theme/Catppuccin/Canvas"), card: Color("Theme/Catppuccin/Card")), - identityGamut: IdentityGamut( - hueAnchors: [0, 10, 23, 41, 115, 170, 189, 199, 217, 232, 267, 316, 343, 351], - saturation: 0.40...0.70 - ), - categoryHues: CategoryHues(channel: 267, repeater: 217, room: 316) + static let `default` = Theme( + id: EnvInputs.defaultThemeID, + displayNameKey: "Settings.Support.Theme.Default", + productID: nil, + accentColor: Color("AppAccentColor"), + outgoingTextColor: .white, + hashtagColor: Color("HashtagDefault"), + preferredColorScheme: nil, + identityGamut: IdentityGamut( + hueAnchors: [18, 25, 44, 77, 120, 180, 215, 255, 307, 343], + saturation: 0.45...0.70 + ), + categoryAvatarOverride: CategoryAvatarColors( + channel: Color(hex: 0x336688), + repeaterNode: Color(hex: 0x00AAFF), + room: Color(hex: 0xFF8800) ) + ) + + static let ember = Theme( + id: "ember", + displayNameKey: "Settings.Support.Theme.Ember", + productID: StoreCatalog.Theme.ember, + accentColor: Color("Theme/Ember/Accent"), + outgoingTextColor: Color("Theme/Ember/Text"), + hashtagColor: Color("Theme/Ember/Hashtag"), + preferredColorScheme: .dark, + surfaces: .init(canvas: .black), + identityGamut: IdentityGamut( + hueAnchors: [0, 8, 12, 24, 18, 345], + saturation: 0.50...0.82 + ), + categoryHues: CategoryHues(channel: 0, repeater: 24, room: 345) + ) + + static let fern = Theme( + id: "fern", + displayNameKey: "Settings.Support.Theme.Fern", + productID: StoreCatalog.Theme.fern, + accentColor: Color("Theme/Fern/Accent"), + outgoingTextColor: Color("Theme/Fern/OutgoingText"), + hashtagColor: Color("Theme/Fern/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Fern/Canvas"), card: Color("Theme/Fern/Card")), + identityGamut: IdentityGamut( + hueAnchors: [72, 90, 108, 128, 150, 165], + saturation: 0.35...0.65 + ), + categoryHues: CategoryHues(channel: 90, repeater: 128, room: 165) + ) + + static let marine = Theme( + id: "marine", + displayNameKey: "Settings.Support.Theme.Marine", + productID: StoreCatalog.Theme.marine, + accentColor: Color("Theme/Marine/Accent"), + outgoingTextColor: .white, + hashtagColor: Color("Theme/Marine/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Marine/Canvas"), card: Color("Theme/Marine/Card")), + identityGamut: IdentityGamut( + hueAnchors: [178, 188, 200, 215, 232, 245], + saturation: 0.40...0.72 + ), + categoryHues: CategoryHues(channel: 215, repeater: 188, room: 245) + ) + + static let olive = Theme( + id: "olive", + displayNameKey: "Settings.Support.Theme.Olive", + productID: StoreCatalog.Theme.olive, + accentColor: Color("Theme/Olive/Accent"), + outgoingTextColor: Color("Theme/Olive/OutgoingText"), + hashtagColor: Color("Theme/Olive/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Olive/Canvas"), card: Color("Theme/Olive/Card")), + identityGamut: IdentityGamut( + hueAnchors: [45, 55, 70, 85, 100, 112], + saturation: 0.35...0.62 + ), + categoryHues: CategoryHues(channel: 85, repeater: 112, room: 70) + ) + + static let lavender = Theme( + id: "lavender", + displayNameKey: "Settings.Support.Theme.Lavender", + productID: StoreCatalog.Theme.lavender, + accentColor: Color("Theme/Lavender/Accent"), + outgoingTextColor: Color("Theme/Lavender/OutgoingText"), + hashtagColor: Color("Theme/Lavender/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Lavender/Canvas"), card: Color("Theme/Lavender/Card")), + identityGamut: IdentityGamut( + hueAnchors: [222, 238, 248, 265, 285, 302], + saturation: 0.35...0.62 + ), + categoryHues: CategoryHues(channel: 265, repeater: 222, room: 302) + ) + + static let sakura = Theme( + id: "sakura", + displayNameKey: nil, + productID: StoreCatalog.Theme.sakura, + accentColor: Color("Theme/Sakura/Accent"), + outgoingTextColor: Color("Theme/Sakura/OutgoingText"), + hashtagColor: Color("Theme/Sakura/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Sakura/Canvas"), card: Color("Theme/Sakura/Card")), + identityGamut: IdentityGamut( + hueAnchors: [290, 305, 320, 335, 350, 8], + saturation: 0.40...0.70 + ), + categoryHues: CategoryHues(channel: 320, repeater: 290, room: 350) + ) + + static let solarized = Theme( + id: "solarized", + displayNameKey: nil, + productID: StoreCatalog.Theme.solarized, + accentColor: Color("Theme/Solarized/Accent"), + outgoingTextColor: Color("Theme/Solarized/OutgoingText"), + hashtagColor: Color("Theme/Solarized/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Solarized/Canvas"), card: Color("Theme/Solarized/Card")), + identityGamut: IdentityGamut( + hueAnchors: [1, 18, 45, 68, 175, 205, 237, 331], + saturation: 0.50...0.80 + ), + categoryHues: CategoryHues(channel: 205, repeater: 175, room: 331) + ) + + static let nord = Theme( + id: "nord", + displayNameKey: nil, + productID: StoreCatalog.Theme.nord, + accentColor: Color("Theme/Nord/Accent"), + outgoingTextColor: Color("Theme/Nord/OutgoingText"), + hashtagColor: Color("Theme/Nord/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Nord/Canvas"), card: Color("Theme/Nord/Card")), + identityGamut: IdentityGamut( + hueAnchors: [14, 40, 92, 178, 193, 210, 213, 240, 280, 311, 354], + saturation: 0.25...0.52 + ), + categoryHues: CategoryHues(channel: 210, repeater: 193, room: 280) + ) + + static let catppuccin = Theme( + id: "catppuccin", + displayNameKey: nil, + productID: StoreCatalog.Theme.catppuccin, + accentColor: Color("Theme/Catppuccin/Accent"), + outgoingTextColor: Color("Theme/Catppuccin/OutgoingText"), + hashtagColor: Color("Theme/Catppuccin/Hashtag"), + preferredColorScheme: nil, + surfaces: .init(canvas: Color("Theme/Catppuccin/Canvas"), card: Color("Theme/Catppuccin/Card")), + identityGamut: IdentityGamut( + hueAnchors: [0, 10, 23, 41, 115, 170, 189, 199, 217, 232, 267, 316, 343, 351], + saturation: 0.40...0.70 + ), + categoryHues: CategoryHues(channel: 267, repeater: 217, room: 316) + ) } diff --git a/MC1/Theme/ThemeCardMetrics.swift b/MC1/Theme/ThemeCardMetrics.swift index 070f789e..d9f6fd01 100644 --- a/MC1/Theme/ThemeCardMetrics.swift +++ b/MC1/Theme/ThemeCardMetrics.swift @@ -4,19 +4,19 @@ import SwiftUI /// radius in one place stops a card's background fill and its selection-stroke overlay from /// drifting out of sync. enum ThemeCardMetrics { - static let cornerRadius: CGFloat = 14 - static let contentPadding: CGFloat = 8 - static let selectionStrokeWidth: CGFloat = 2 - static let badgeIconSpacing: CGFloat = 3 + static let cornerRadius: CGFloat = 14 + static let contentPadding: CGFloat = 8 + static let selectionStrokeWidth: CGFloat = 2 + static let badgeIconSpacing: CGFloat = 3 - /// Shared theme-grid layout, kept here so the Appearance and Support card grids cannot drift. - static let gridItemMinimum: CGFloat = 160 - static let gridSpacing: CGFloat = 12 - static let gridRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + /// Shared theme-grid layout, kept here so the Appearance and Support card grids cannot drift. + static let gridItemMinimum: CGFloat = 160 + static let gridSpacing: CGFloat = 12 + static let gridRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) - /// Layout for the "all themes unlocked" celebration that replaces the card grid once every - /// purchasable theme is owned. - static let allUnlockedEmojiSize: CGFloat = 56 - static let allUnlockedSpacing: CGFloat = 12 - static let allUnlockedVerticalPadding: CGFloat = 24 + /// Layout for the "all themes unlocked" celebration that replaces the card grid once every + /// purchasable theme is owned. + static let allUnlockedEmojiSize: CGFloat = 56 + static let allUnlockedSpacing: CGFloat = 12 + static let allUnlockedVerticalPadding: CGFloat = 24 } diff --git a/MC1/Theme/ThemePaletteSwatch.swift b/MC1/Theme/ThemePaletteSwatch.swift index 6e3375ec..fa4e3c8c 100644 --- a/MC1/Theme/ThemePaletteSwatch.swift +++ b/MC1/Theme/ThemePaletteSwatch.swift @@ -3,83 +3,85 @@ import SwiftUI /// Shared theme preview for `ThemePreviewCard` and `ThemeSelectionCard`. The diagonal split /// signals light+dark support; an un-split swatch signals a dark-only theme (Ember). struct ThemePaletteSwatch: View { - let theme: Theme - /// `.tappable` enforces the 44pt HIG tap-target minimum (default; used by the preview and - /// selection cards). `.thumbnail` drops the minimum so a row of many swatches can fit a - /// shared parent width. - var size: SizeStyle = .tappable + let theme: Theme + /// `.tappable` enforces the 44pt HIG tap-target minimum (default; used by the preview and + /// selection cards). `.thumbnail` drops the minimum so a row of many swatches can fit a + /// shared parent width. + var size: SizeStyle = .tappable - enum SizeStyle: Sendable { - case tappable - case thumbnail - } + enum SizeStyle { + case tappable + case thumbnail + } - private enum Layout { - static let tappableMinSide: CGFloat = 44 - static let corner: CGFloat = 12 - static let bubbleCorner: CGFloat = 6 - static let bubbleInset: CGFloat = 8 - } + private enum Layout { + static let tappableMinSide: CGFloat = 44 + static let corner: CGFloat = 12 + static let bubbleCorner: CGFloat = 6 + static let bubbleInset: CGFloat = 8 + } - private var isDualMode: Bool { theme.preferredColorScheme == nil } + private var isDualMode: Bool { + theme.preferredColorScheme == nil + } - private var enforcedMinSide: CGFloat? { - size == .tappable ? Layout.tappableMinSide : nil - } + private var enforcedMinSide: CGFloat? { + size == .tappable ? Layout.tappableMinSide : nil + } - var body: some View { - ZStack { - if isDualMode { - palette - .environment(\.colorScheme, .light) - .clipShape(DiagonalHalf(top: true)) - palette - .environment(\.colorScheme, .dark) - .clipShape(DiagonalHalf(top: false)) - } else { - palette - .environment(\.colorScheme, .dark) - } - } - .frame(minWidth: enforcedMinSide, minHeight: enforcedMinSide) - .clipShape(RoundedRectangle(cornerRadius: Layout.corner)) - .accessibilityElement() - .accessibilityLabel(accessibilityLabel) + var body: some View { + ZStack { + if isDualMode { + palette + .environment(\.colorScheme, .light) + .clipShape(DiagonalHalf(top: true)) + palette + .environment(\.colorScheme, .dark) + .clipShape(DiagonalHalf(top: false)) + } else { + palette + .environment(\.colorScheme, .dark) + } } + .frame(minWidth: enforcedMinSide, minHeight: enforcedMinSide) + .clipShape(RoundedRectangle(cornerRadius: Layout.corner)) + .accessibilityElement() + .accessibilityLabel(accessibilityLabel) + } - /// Accent + background + a sample outgoing bubble, all theme-driven. - private var palette: some View { - ZStack { - (theme.surfaces?.canvas ?? Color(.secondarySystemBackground)) - RoundedRectangle(cornerRadius: Layout.bubbleCorner) - .fill(theme.accentColor) - .padding(Layout.bubbleInset) - } + /// Accent + background + a sample outgoing bubble, all theme-driven. + private var palette: some View { + ZStack { + (theme.surfaces?.canvas ?? Color(.secondarySystemBackground)) + RoundedRectangle(cornerRadius: Layout.bubbleCorner) + .fill(theme.accentColor) + .padding(Layout.bubbleInset) } + } - private var accessibilityLabel: String { - isDualMode - ? L10n.Settings.Appearance.Accessibility.Swatch.dualMode(theme.localizedName) - : L10n.Settings.Appearance.Accessibility.Swatch.darkOnly(theme.localizedName) - } + private var accessibilityLabel: String { + isDualMode + ? L10n.Settings.Appearance.Accessibility.Swatch.dualMode(theme.localizedName) + : L10n.Settings.Appearance.Accessibility.Swatch.darkOnly(theme.localizedName) + } } /// One triangular half of the swatch, split corner-to-corner (top-leading triangle when `top`). private struct DiagonalHalf: Shape { - let top: Bool + let top: Bool - func path(in rect: CGRect) -> Path { - var path = Path() - if top { - path.move(to: CGPoint(x: rect.minX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) - } else { - path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) - } - path.closeSubpath() - return path + func path(in rect: CGRect) -> Path { + var path = Path() + if top { + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + } else { + path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) } + path.closeSubpath() + return path + } } diff --git a/MC1/Theme/ThemeRegistry.swift b/MC1/Theme/ThemeRegistry.swift index a66d63c6..d9da7c1a 100644 --- a/MC1/Theme/ThemeRegistry.swift +++ b/MC1/Theme/ThemeRegistry.swift @@ -3,12 +3,12 @@ import Foundation /// The catalog of built-in themes. Plain enum with `Sendable` static values — no `@MainActor`, /// since the constants are immutable and require no actor hop to read. enum ThemeRegistry { - static let allThemes: [Theme] = [ - .default, .ember, .fern, .marine, .olive, .lavender, .sakura, - .solarized, .nord, .catppuccin - ] + static let allThemes: [Theme] = [ + .default, .ember, .fern, .marine, .olive, .lavender, .sakura, + .solarized, .nord, .catppuccin + ] - static func theme(forID id: String) -> Theme? { - allThemes.first { $0.id == id } - } + static func theme(forID id: String) -> Theme? { + allThemes.first { $0.id == id } + } } diff --git a/MC1/Theme/ThemeService.swift b/MC1/Theme/ThemeService.swift index 6679d4ec..c7f0744f 100644 --- a/MC1/Theme/ThemeService.swift +++ b/MC1/Theme/ThemeService.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Owns the selected theme and the global color-scheme preference, enforces the ownership /// access rule against `StoreService`, and reverts an inaccessible theme on entitlement loss. @@ -8,146 +8,146 @@ import MC1Services @Observable @MainActor final class ThemeService { - private(set) var current: Theme - private(set) var colorSchemePreference: AppColorSchemePreference - private let store: StoreService - private let defaults: UserDefaults - - init(store: StoreService, defaults: UserDefaults = .standard) { - self.store = store - self.defaults = defaults + private(set) var current: Theme + private(set) var colorSchemePreference: AppColorSchemePreference + private let store: StoreService + private let defaults: UserDefaults - // Init contract: registry-based fallback only. Do not enforce ownership here — - // StoreService.ownedThemeIDs is not yet populated (the entitlement walk is async, - // triggered by load() later). Enforcing access here would overwrite a paid user's - // persisted theme on every cold launch. Reversion happens only via the post-load callback. - let storedID = defaults.string(forKey: PersistenceKeys.selectedThemeID) - if let id = storedID, let theme = ThemeRegistry.theme(forID: id) { - self.current = theme // adopt as-is; no write-back - } else if storedID == nil { - self.current = .default // missing key: default in memory, no write-back - } else { - self.current = .default // unknown ID: fall back and overwrite the stale value - defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) - } + init(store: StoreService, defaults: UserDefaults = .standard) { + self.store = store + self.defaults = defaults - let storedScheme = defaults.string(forKey: PersistenceKeys.appColorSchemePreference) - if let raw = storedScheme, let preference = AppColorSchemePreference(rawValue: raw) { - self.colorSchemePreference = preference - } else if storedScheme == nil { - self.colorSchemePreference = .system // missing key: no write-back - } else { - self.colorSchemePreference = .system // unknown raw value: fall back and overwrite - defaults.set(AppColorSchemePreference.system.rawValue, - forKey: PersistenceKeys.appColorSchemePreference) - } - - // StoreService is constructed before ThemeService, so this callback is wired before - // any Transaction.updates emission. - store.onEntitlementsChanged = { [weak self] in - self?.revertToDefaultIfInaccessible() - } + // Init contract: registry-based fallback only. Do not enforce ownership here — + // StoreService.ownedThemeIDs is not yet populated (the entitlement walk is async, + // triggered by load() later). Enforcing access here would overwrite a paid user's + // persisted theme on every cold launch. Reversion happens only via the post-load callback. + let storedID = defaults.string(forKey: PersistenceKeys.selectedThemeID) + if let id = storedID, let theme = ThemeRegistry.theme(forID: id) { + current = theme // adopt as-is; no write-back + } else if storedID == nil { + current = .default // missing key: default in memory, no write-back + } else { + current = .default // unknown ID: fall back and overwrite the stale value + defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) } - func setCurrent(_ theme: Theme) throws { - guard Self.isAccessible(theme, store: store) else { - throw ThemeServiceError.notOwned(productID: theme.productID ?? "") - } - current = theme - defaults.set(theme.id, forKey: PersistenceKeys.selectedThemeID) + let storedScheme = defaults.string(forKey: PersistenceKeys.appColorSchemePreference) + if let raw = storedScheme, let preference = AppColorSchemePreference(rawValue: raw) { + colorSchemePreference = preference + } else if storedScheme == nil { + colorSchemePreference = .system // missing key: no write-back + } else { + colorSchemePreference = .system // unknown raw value: fall back and overwrite + defaults.set(AppColorSchemePreference.system.rawValue, + forKey: PersistenceKeys.appColorSchemePreference) } - func setColorSchemePreference(_ preference: AppColorSchemePreference) { - colorSchemePreference = preference - defaults.set(preference.rawValue, forKey: PersistenceKeys.appColorSchemePreference) + // StoreService is constructed before ThemeService, so this callback is wired before + // any Transaction.updates emission. + store.onEntitlementsChanged = { [weak self] in + self?.revertToDefaultIfInaccessible() } + } - /// Theme-forced override wins; otherwise the user's global preference applies. - var effectiveColorScheme: ColorScheme? { - current.preferredColorScheme ?? colorSchemePreference.colorScheme + func setCurrent(_ theme: Theme) throws { + guard Self.isAccessible(theme, store: store) else { + throw ThemeServiceError.notOwned(productID: theme.productID ?? "") } + current = theme + defaults.set(theme.id, forKey: PersistenceKeys.selectedThemeID) + } - func availableToCurrentUser() -> [Theme] { - ThemeRegistry.allThemes.filter { Self.isAccessible($0, store: store) } - } + func setColorSchemePreference(_ preference: AppColorSchemePreference) { + colorSchemePreference = preference + defaults.set(preference.rawValue, forKey: PersistenceKeys.appColorSchemePreference) + } - /// Re-reads both keys so a mid-session backup import (`AppState.notifyDataRestored`) - /// takes effect without a cold launch. Unlike `init`, the restore caller is the only - /// production path here, so this branch *does* enforce ownership: a backup imported from a - /// different Apple ID can carry a paid theme this user doesn't own, and without the check - /// the paid theme would render for free until the next entitlement walk (which never fires - /// on the restore path). Unknown stored values are corrected with a write-back, mirroring - /// `init`'s ladder. - func refreshFromUserDefaults() { - let resolvedTheme = resolveThemeFromDefaults() - if resolvedTheme.id != current.id { - current = resolvedTheme - if resolvedTheme.id == Theme.default.id { - AccessibilityNotification.Announcement(L10n.Settings.Appearance.Accessibility.themeReverted).post() - } - } + /// Theme-forced override wins; otherwise the user's global preference applies. + var effectiveColorScheme: ColorScheme? { + current.preferredColorScheme ?? colorSchemePreference.colorScheme + } - let resolvedScheme = resolveSchemePreferenceFromDefaults() - if resolvedScheme != colorSchemePreference { colorSchemePreference = resolvedScheme } - } + func availableToCurrentUser() -> [Theme] { + ThemeRegistry.allThemes.filter { Self.isAccessible($0, store: store) } + } - private func resolveThemeFromDefaults() -> Theme { - let storedID = defaults.string(forKey: PersistenceKeys.selectedThemeID) - if let id = storedID, let theme = ThemeRegistry.theme(forID: id) { - if Self.isAccessible(theme, store: store) { return theme } - // Known but inaccessible. Only revert + overwrite once entitlements are authoritative - // (`loadState == .loaded`). Before then `ownedThemeIDs` may be empty merely because the - // walk hasn't populated it (idle/loading) or failed (offline) — wiping the persisted - // selection there downgrades a paid user who restored a backup before the walk finished. - // Adopt the stored theme in memory without write-back, mirroring `init`; the post-load - // listener reverts it later if it is genuinely unowned. - guard store.loadState == .loaded else { return theme } - defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) - return .default - } - if storedID == nil { return .default } // missing key: no write-back - // Unknown ID (e.g., future-build theme via backup): fall back + corrective overwrite. - defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) - return .default + /// Re-reads both keys so a mid-session backup import (`AppState.notifyDataRestored`) + /// takes effect without a cold launch. Unlike `init`, the restore caller is the only + /// production path here, so this branch *does* enforce ownership: a backup imported from a + /// different Apple ID can carry a paid theme this user doesn't own, and without the check + /// the paid theme would render for free until the next entitlement walk (which never fires + /// on the restore path). Unknown stored values are corrected with a write-back, mirroring + /// `init`'s ladder. + func refreshFromUserDefaults() { + let resolvedTheme = resolveThemeFromDefaults() + if resolvedTheme.id != current.id { + current = resolvedTheme + if resolvedTheme.id == Theme.default.id { + AccessibilityNotification.Announcement(L10n.Settings.Appearance.Accessibility.themeReverted).post() + } } - private func resolveSchemePreferenceFromDefaults() -> AppColorSchemePreference { - let storedScheme = defaults.string(forKey: PersistenceKeys.appColorSchemePreference) - if let raw = storedScheme, let preference = AppColorSchemePreference(rawValue: raw) { - return preference - } - if storedScheme == nil { return .system } // missing key: no write-back - // Unknown raw value (e.g., a future-build case via backup): fall back + corrective overwrite. - defaults.set(AppColorSchemePreference.system.rawValue, - forKey: PersistenceKeys.appColorSchemePreference) - return .system - } + let resolvedScheme = resolveSchemePreferenceFromDefaults() + if resolvedScheme != colorSchemePreference { colorSchemePreference = resolvedScheme } + } - /// Idempotent: invoked from `StoreService.onEntitlementsChanged`. If the current theme is no - /// longer accessible (e.g. a refund), revert to default, persist, and announce for VoiceOver. - /// - /// Acts only once entitlements are authoritative (`loadState == .loaded`). The first walk fires - /// this from inside `load()` while still `.loading`, and on a cold storekitd that walk can read - /// an empty `ownedThemeIDs` for a user who does own the theme — persisting a revert there would - /// wipe their selection (and the listener never re-applies, so it is unrecoverable until they - /// re-select). Post-load walks (refund, restore) fire this while `.loaded`, where empty ownership - /// is trustworthy. - private func revertToDefaultIfInaccessible() { - guard store.loadState == .loaded else { return } - guard !Self.isAccessible(current, store: store) else { return } - current = .default - defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) - AccessibilityNotification.Announcement(L10n.Settings.Appearance.Accessibility.themeReverted).post() + private func resolveThemeFromDefaults() -> Theme { + let storedID = defaults.string(forKey: PersistenceKeys.selectedThemeID) + if let id = storedID, let theme = ThemeRegistry.theme(forID: id) { + if Self.isAccessible(theme, store: store) { return theme } + // Known but inaccessible. Only revert + overwrite once entitlements are authoritative + // (`loadState == .loaded`). Before then `ownedThemeIDs` may be empty merely because the + // walk hasn't populated it (idle/loading) or failed (offline) — wiping the persisted + // selection there downgrades a paid user who restored a backup before the walk finished. + // Adopt the stored theme in memory without write-back, mirroring `init`; the post-load + // listener reverts it later if it is genuinely unowned. + guard store.loadState == .loaded else { return theme } + defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) + return .default } + if storedID == nil { return .default } // missing key: no write-back + // Unknown ID (e.g., future-build theme via backup): fall back + corrective overwrite. + defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) + return .default + } - private static func isAccessible(_ theme: Theme, store: StoreService) -> Bool { - #if SIDELOAD - // Sideload builds have no App Store entitlement, so StoreKit can never populate - // ownedThemeIDs. Unlock every theme rather than leaving paid themes permanently locked. - return true - #else - guard let productID = theme.productID else { return true } // default is always accessible - return store.ownedThemeIDs.contains(productID) - #endif + private func resolveSchemePreferenceFromDefaults() -> AppColorSchemePreference { + let storedScheme = defaults.string(forKey: PersistenceKeys.appColorSchemePreference) + if let raw = storedScheme, let preference = AppColorSchemePreference(rawValue: raw) { + return preference } + if storedScheme == nil { return .system } // missing key: no write-back + // Unknown raw value (e.g., a future-build case via backup): fall back + corrective overwrite. + defaults.set(AppColorSchemePreference.system.rawValue, + forKey: PersistenceKeys.appColorSchemePreference) + return .system + } + + /// Idempotent: invoked from `StoreService.onEntitlementsChanged`. If the current theme is no + /// longer accessible (e.g. a refund), revert to default, persist, and announce for VoiceOver. + /// + /// Acts only once entitlements are authoritative (`loadState == .loaded`). The first walk fires + /// this from inside `load()` while still `.loading`, and on a cold storekitd that walk can read + /// an empty `ownedThemeIDs` for a user who does own the theme — persisting a revert there would + /// wipe their selection (and the listener never re-applies, so it is unrecoverable until they + /// re-select). Post-load walks (refund, restore) fire this while `.loaded`, where empty ownership + /// is trustworthy. + private func revertToDefaultIfInaccessible() { + guard store.loadState == .loaded else { return } + guard !Self.isAccessible(current, store: store) else { return } + current = .default + defaults.set(Theme.default.id, forKey: PersistenceKeys.selectedThemeID) + AccessibilityNotification.Announcement(L10n.Settings.Appearance.Accessibility.themeReverted).post() + } + + private static func isAccessible(_ theme: Theme, store: StoreService) -> Bool { + #if SIDELOAD + // Sideload builds have no App Store entitlement, so StoreKit can never populate + // ownedThemeIDs. Unlock every theme rather than leaving paid themes permanently locked. + return true + #else + guard let productID = theme.productID else { return true } // default is always accessible + return store.ownedThemeIDs.contains(productID) + #endif + } } diff --git a/MC1/Theme/ThemeServiceError.swift b/MC1/Theme/ThemeServiceError.swift index 8da1993b..3b61531a 100644 --- a/MC1/Theme/ThemeServiceError.swift +++ b/MC1/Theme/ThemeServiceError.swift @@ -2,10 +2,10 @@ import Foundation /// Errors surfaced by `ThemeService`. Localized via `L10n` (this type lives in the MC1 target, /// where the generated `L10n` enum is visible — unlike the MC1Services error types). -enum ThemeServiceError: LocalizedError, Sendable { - case notOwned(productID: String) +enum ThemeServiceError: LocalizedError { + case notOwned(productID: String) - var errorDescription: String? { - L10n.Settings.Support.Error.themeNotOwned - } + var errorDescription: String? { + L10n.Settings.Support.Error.themeNotOwned + } } diff --git a/MC1/Theme/ThemedSurfaceModifiers.swift b/MC1/Theme/ThemedSurfaceModifiers.swift index 5ecb6553..01d8271f 100644 --- a/MC1/Theme/ThemedSurfaceModifiers.swift +++ b/MC1/Theme/ThemedSurfaceModifiers.swift @@ -1,100 +1,99 @@ import SwiftUI extension View { - /// Paint the receiver's scroll/content background with the theme's canvas color, hiding the - /// SwiftUI default. No-op for themes without surfaces — preserves system rendering, including - /// iOS 26 Form Liquid Glass treatments that a manual `Color(.systemGroupedBackground)` would - /// override. - func themedCanvas(_ theme: Theme) -> some View { - modifier(ThemedCanvasModifier(theme: theme)) - } + /// Paint the receiver's scroll/content background with the theme's canvas color, hiding the + /// SwiftUI default. No-op for themes without surfaces — preserves system rendering, including + /// iOS 26 Form Liquid Glass treatments that a manual `Color(.systemGroupedBackground)` would + /// override. + func themedCanvas(_ theme: Theme) -> some View { + modifier(ThemedCanvasModifier(theme: theme)) + } - /// Paint a `Section`'s rows with the theme's card color. No-op for themes without a card tier - /// (Default, Ember) — preserves system `.secondarySystemGroupedBackground` rendering. - /// Apply per Section in `.insetGrouped` lists; use `themedPlainRowBackground` on `.plain` lists - /// instead. For a `.sidebar`-styled list (the iPad Settings split-view column) pass - /// `flatten: true`, so rows stay transparent and read flush with the canvas, not as cards. - func themedRowBackground(_ theme: Theme, flatten: Bool = false) -> some View { - modifier(ThemedRowBackgroundModifier(theme: theme, flatten: flatten)) - } + /// Paint a `Section`'s rows with the theme's card color. No-op for themes without a card tier + /// (Default, Ember) — preserves system `.secondarySystemGroupedBackground` rendering. + /// Apply per Section in `.insetGrouped` lists; use `themedPlainRowBackground` on `.plain` lists + /// instead. For a `.sidebar`-styled list (the iPad Settings split-view column) pass + /// `flatten: true`, so rows stay transparent and read flush with the canvas, not as cards. + func themedRowBackground(_ theme: Theme, flatten: Bool = false) -> some View { + modifier(ThemedRowBackgroundModifier(theme: theme, flatten: flatten)) + } - /// Paint a `.plain` list's rows with the theme canvas so the themed background shows through. - /// Plain rows draw an opaque `systemBackground` by default, which otherwise hides the canvas - /// painted by `themedCanvas`. No-op for themes without surfaces — preserves system rendering - /// on the default theme. - func themedPlainRowBackground(_ theme: Theme) -> some View { - modifier(ThemedPlainRowBackgroundModifier(theme: theme)) - } + /// Paint a `.plain` list's rows with the theme canvas so the themed background shows through. + /// Plain rows draw an opaque `systemBackground` by default, which otherwise hides the canvas + /// painted by `themedCanvas`. No-op for themes without surfaces — preserves system rendering + /// on the default theme. + func themedPlainRowBackground(_ theme: Theme) -> some View { + modifier(ThemedPlainRowBackgroundModifier(theme: theme)) + } - /// Match the navigation bar and tab bar backgrounds to the theme canvas so chrome blends - /// with themed content instead of reading as a mismatched neutral material. No-op for themes - /// without surfaces — preserves the system Liquid Glass bars on the default theme. - func themedChrome(_ theme: Theme) -> some View { - modifier(ThemedChromeModifier(theme: theme)) - } + /// Match the navigation bar and tab bar backgrounds to the theme canvas so chrome blends + /// with themed content instead of reading as a mismatched neutral material. No-op for themes + /// without surfaces — preserves the system Liquid Glass bars on the default theme. + func themedChrome(_ theme: Theme) -> some View { + modifier(ThemedChromeModifier(theme: theme)) + } } private struct ThemedCanvasModifier: ViewModifier { - let theme: Theme - func body(content: Content) -> some View { - if let canvas = theme.surfaces?.canvas { - content - .scrollContentBackground(.hidden) - .background(canvas) - } else { - content - } + let theme: Theme + func body(content: Content) -> some View { + if let canvas = theme.surfaces?.canvas { + content + .scrollContentBackground(.hidden) + .background(canvas) + } else { + content } + } } private struct ThemedRowBackgroundModifier: ViewModifier { - let theme: Theme - let flatten: Bool - func body(content: Content) -> some View { - if let rowFill = theme.surfaces?.rowFill(flatten: flatten) { - content.listRowBackground(rowFill) - } else { - content - } + let theme: Theme + let flatten: Bool + func body(content: Content) -> some View { + if let rowFill = theme.surfaces?.rowFill(flatten: flatten) { + content.listRowBackground(rowFill) + } else { + content } + } } private struct ThemedPlainRowBackgroundModifier: ViewModifier { - let theme: Theme + let theme: Theme - @ViewBuilder - func body(content: Content) -> some View { - if let canvas = theme.surfaces?.canvas { - content.listRowBackground(canvas) - } else { - content - } + func body(content: Content) -> some View { + if let canvas = theme.surfaces?.canvas { + content.listRowBackground(canvas) + } else { + content } + } } private struct ThemedChromeModifier: ViewModifier { - let theme: Theme + let theme: Theme - // Always applies the same modifier chain, varying only the values. Resolving the surface - // branch into computed values instead of a `ViewBuilder` `if`/`else` keeps this view's - // structural identity stable across theme changes. Because `themedChrome` wraps the TabView, - // a branch flip here would re-identify every tab and reset their NavigationStacks, popping any - // pushed screen — which is exactly what switching to or from the surface-less Default theme did. - func body(content: Content) -> some View { - content - .toolbarBackground(barStyle, for: .navigationBar, .tabBar) - .toolbarBackgroundVisibility(barVisibility, for: .navigationBar, .tabBar) - } + /// Always applies the same modifier chain, varying only the values. Resolving the surface + /// branch into computed values instead of a `ViewBuilder` `if`/`else` keeps this view's + /// structural identity stable across theme changes. Because `themedChrome` wraps the TabView, + /// a branch flip here would re-identify every tab and reset their NavigationStacks, popping any + /// pushed screen — which is exactly what switching to or from the surface-less Default theme did. + func body(content: Content) -> some View { + content + .toolbarBackground(barStyle, for: .navigationBar, .tabBar) + .toolbarBackgroundVisibility(barVisibility, for: .navigationBar, .tabBar) + } - private var barStyle: AnyShapeStyle { - if let canvas = theme.surfaces?.canvas { - AnyShapeStyle(canvas) - } else { - AnyShapeStyle(Material.bar) - } + private var barStyle: AnyShapeStyle { + if let canvas = theme.surfaces?.canvas { + AnyShapeStyle(canvas) + } else { + AnyShapeStyle(Material.bar) } + } - private var barVisibility: Visibility { - theme.surfaces?.canvas == nil ? .automatic : .visible - } + private var barVisibility: Visibility { + theme.surfaces?.canvas == nil ? .automatic : .visible + } } diff --git a/MC1/Theme/WCAGContrast.swift b/MC1/Theme/WCAGContrast.swift index d37e1f4b..d7290b29 100644 --- a/MC1/Theme/WCAGContrast.swift +++ b/MC1/Theme/WCAGContrast.swift @@ -8,53 +8,52 @@ import UIKit /// The pure-`Double` entry points carry no UIKit dependency, so the identity-color solver can /// run off the main actor (it is invoked from the off-main message-text bake). enum WCAGContrast { - - /// Standard WCAG AA floor for normal-size text. - static let aaFloor = 4.5 - - /// Floor applied when the user has Increased Contrast enabled. Targets the AAA ratio so the - /// identity palette tightens the same way the legacy high-contrast palette did. - static let increasedContrastFloor = 7.0 - - /// sRGB linearization threshold and curve constants from the WCAG 2.x definition. - private static let linearThreshold = 0.03928 - private static let linearDivisor = 12.92 - private static let curveOffset = 0.055 - private static let curveDivisor = 1.055 - private static let curveExponent = 2.4 - - /// Rec. 709 luminance coefficients (a standard, fixed table — not tunable). - private static let redCoefficient = 0.2126 - private static let greenCoefficient = 0.7152 - private static let blueCoefficient = 0.0722 - - /// Additive term in the WCAG contrast ratio, modelling ambient flare. - private static let contrastFlare = 0.05 - - /// Relative luminance of an sRGB color expressed as 0...1 components. Components are clamped - /// because a wide-gamut or HSB-derived color can resolve slightly outside the unit range. - static func relativeLuminance(red: Double, green: Double, blue: Double) -> Double { - func linearize(_ component: Double) -> Double { - let c = min(max(component, 0), 1) - return c <= linearThreshold ? c / linearDivisor : pow((c + curveOffset) / curveDivisor, curveExponent) - } - return redCoefficient * linearize(red) - + greenCoefficient * linearize(green) - + blueCoefficient * linearize(blue) - } - - /// Contrast ratio between two relative-luminance values (order-independent). - static func contrastRatio(_ lhs: Double, _ rhs: Double) -> Double { - let lighter = max(lhs, rhs) - let darker = min(lhs, rhs) - return (lighter + contrastFlare) / (darker + contrastFlare) - } - - /// Relative luminance of a resolved `UIColor`. Main-actor / UIKit path used by live views to - /// turn an adaptive theme surface into the `Double` the solver consumes. - static func relativeLuminance(of color: UIColor) -> Double { - var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 - color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - return relativeLuminance(red: Double(red), green: Double(green), blue: Double(blue)) + /// Standard WCAG AA floor for normal-size text. + static let aaFloor = 4.5 + + /// Floor applied when the user has Increased Contrast enabled. Targets the AAA ratio so the + /// identity palette tightens the same way the legacy high-contrast palette did. + static let increasedContrastFloor = 7.0 + + /// sRGB linearization threshold and curve constants from the WCAG 2.x definition. + private static let linearThreshold = 0.03928 + private static let linearDivisor = 12.92 + private static let curveOffset = 0.055 + private static let curveDivisor = 1.055 + private static let curveExponent = 2.4 + + /// Rec. 709 luminance coefficients (a standard, fixed table — not tunable). + private static let redCoefficient = 0.2126 + private static let greenCoefficient = 0.7152 + private static let blueCoefficient = 0.0722 + + /// Additive term in the WCAG contrast ratio, modelling ambient flare. + private static let contrastFlare = 0.05 + + /// Relative luminance of an sRGB color expressed as 0...1 components. Components are clamped + /// because a wide-gamut or HSB-derived color can resolve slightly outside the unit range. + static func relativeLuminance(red: Double, green: Double, blue: Double) -> Double { + func linearize(_ component: Double) -> Double { + let c = min(max(component, 0), 1) + return c <= linearThreshold ? c / linearDivisor : pow((c + curveOffset) / curveDivisor, curveExponent) } + return redCoefficient * linearize(red) + + greenCoefficient * linearize(green) + + blueCoefficient * linearize(blue) + } + + /// Contrast ratio between two relative-luminance values (order-independent). + static func contrastRatio(_ lhs: Double, _ rhs: Double) -> Double { + let lighter = max(lhs, rhs) + let darker = min(lhs, rhs) + return (lighter + contrastFlare) / (darker + contrastFlare) + } + + /// Relative luminance of a resolved `UIColor`. Main-actor / UIKit path used by live views to + /// turn an adaptive theme surface into the `Double` the solver consumes. + static func relativeLuminance(of color: UIColor) -> Double { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return relativeLuminance(red: Double(red), green: Double(green), blue: Double(blue)) + } } diff --git a/MC1/Tips/DeviceMenuTip.swift b/MC1/Tips/DeviceMenuTip.swift index 1e3fff30..100f4ee8 100644 --- a/MC1/Tips/DeviceMenuTip.swift +++ b/MC1/Tips/DeviceMenuTip.swift @@ -3,31 +3,31 @@ import TipKit /// Tip shown after onboarding to introduce the device menu and its controls struct DeviceMenuTip: Tip { - static let hasCompletedOnboarding = Tips.Event(id: "hasCompletedOnboarding") + static let hasCompletedOnboarding = Tips.Event(id: "hasCompletedOnboarding") - /// Live connection state. The tip introduces the connected device menu, so it - /// must stay hidden while disconnected, when the menu offers only Connect Device. - @Parameter - static var isConnected: Bool = false + /// Live connection state. The tip introduces the connected device menu, so it + /// must stay hidden while disconnected, when the menu offers only Connect Device. + @Parameter + static var isConnected: Bool = false - var title: Text { - Text(L10n.Chats.Chats.Tip.DeviceMenu.title) - } + var title: Text { + Text(L10n.Chats.Chats.Tip.DeviceMenu.title) + } - var message: Text? { - Text(L10n.Chats.Chats.Tip.DeviceMenu.message) - } + var message: Text? { + Text(L10n.Chats.Chats.Tip.DeviceMenu.message) + } - var image: Image? { - Image(systemName: "antenna.radiowaves.left.and.right") - } + var image: Image? { + Image(systemName: "antenna.radiowaves.left.and.right") + } - var options: [TipOption] { - [Tips.MaxDisplayCount(1)] - } + var options: [TipOption] { + [Tips.MaxDisplayCount(1)] + } - var rules: [Rule] { - #Rule(Self.hasCompletedOnboarding) { $0.donations.count >= 1 } - #Rule(Self.$isConnected) { $0 == true } - } + var rules: [Rule] { + #Rule(Self.hasCompletedOnboarding) { $0.donations.count >= 1 } + #Rule(Self.$isConnected) { $0 == true } + } } diff --git a/MC1/Tips/LiveActivityTip.swift b/MC1/Tips/LiveActivityTip.swift index a4cccfb2..4537cf66 100644 --- a/MC1/Tips/LiveActivityTip.swift +++ b/MC1/Tips/LiveActivityTip.swift @@ -3,21 +3,21 @@ import TipKit /// Tip shown after a Live Activity starts for the first time struct LiveActivityTip: Tip { - static let radioConnected = Tips.Event(id: "radioConnected") + static let radioConnected = Tips.Event(id: "radioConnected") - var title: Text { - Text(L10n.Settings.LiveActivity.Tip.title) - } + var title: Text { + Text(L10n.Settings.LiveActivity.Tip.title) + } - var message: Text? { - Text(L10n.Settings.LiveActivity.Tip.message) - } + var message: Text? { + Text(L10n.Settings.LiveActivity.Tip.message) + } - var options: [TipOption] { - [Tips.MaxDisplayCount(1)] - } + var options: [TipOption] { + [Tips.MaxDisplayCount(1)] + } - var rules: [Rule] { - #Rule(Self.radioConnected) { $0.donations.count >= 1 } - } + var rules: [Rule] { + #Rule(Self.radioConnected) { $0.donations.count >= 1 } + } } diff --git a/MC1/Utilities/AsyncSemaphore.swift b/MC1/Utilities/AsyncSemaphore.swift index 70d0c1b5..ec5e6681 100644 --- a/MC1/Utilities/AsyncSemaphore.swift +++ b/MC1/Utilities/AsyncSemaphore.swift @@ -2,29 +2,29 @@ import Foundation /// Simple async semaphore for limiting concurrent operations actor AsyncSemaphore { - private var count: Int - private var waiters: [CheckedContinuation] = [] + private var count: Int + private var waiters: [CheckedContinuation] = [] - init(value: Int) { - self.count = value - } + init(value: Int) { + count = value + } - func wait() async { - if count > 0 { - count -= 1 - return - } - await withCheckedContinuation { continuation in - waiters.append(continuation) - } + func wait() async { + if count > 0 { + count -= 1 + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) } + } - func signal() { - if let waiter = waiters.first { - waiters.removeFirst() - waiter.resume() - } else { - count += 1 - } + func signal() { + if let waiter = waiters.first { + waiters.removeFirst() + waiter.resume() + } else { + count += 1 } + } } diff --git a/MC1/Utilities/DemoModeManager.swift b/MC1/Utilities/DemoModeManager.swift index d09e9b46..25564281 100644 --- a/MC1/Utilities/DemoModeManager.swift +++ b/MC1/Utilities/DemoModeManager.swift @@ -1,33 +1,33 @@ import MC1Services -import SwiftUI import os +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "DemoMode") @Observable @MainActor final class DemoModeManager { - static let shared = DemoModeManager() + static let shared = DemoModeManager() - private let defaults: UserDefaults + private let defaults: UserDefaults - var isUnlocked: Bool { - didSet { defaults.set(isUnlocked, forKey: AppStorageKey.isDemoModeUnlocked.rawValue) } - } + var isUnlocked: Bool { + didSet { defaults.set(isUnlocked, forKey: AppStorageKey.isDemoModeUnlocked.rawValue) } + } - var isEnabled: Bool { - didSet { defaults.set(isEnabled, forKey: AppStorageKey.isDemoModeEnabled.rawValue) } - } + var isEnabled: Bool { + didSet { defaults.set(isEnabled, forKey: AppStorageKey.isDemoModeEnabled.rawValue) } + } - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - self.isUnlocked = defaults.bool(forKey: AppStorageKey.isDemoModeUnlocked.rawValue) - self.isEnabled = defaults.bool(forKey: AppStorageKey.isDemoModeEnabled.rawValue) - } + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + isUnlocked = defaults.bool(forKey: AppStorageKey.isDemoModeUnlocked.rawValue) + isEnabled = defaults.bool(forKey: AppStorageKey.isDemoModeEnabled.rawValue) + } - func unlock() { - logger.info("Demo mode unlocked and enabled") - isUnlocked = true - isEnabled = true - } + func unlock() { + logger.info("Demo mode unlocked and enabled") + isUnlocked = true + isEnabled = true + } } diff --git a/MC1/Utilities/FirmwareSuggestedTimeout.swift b/MC1/Utilities/FirmwareSuggestedTimeout.swift index 1399e46c..a6fe3e35 100644 --- a/MC1/Utilities/FirmwareSuggestedTimeout.swift +++ b/MC1/Utilities/FirmwareSuggestedTimeout.swift @@ -5,24 +5,24 @@ import Foundation /// for slack and reject implausible values (a hint of 0 would otherwise make /// every wait expire immediately). enum FirmwareSuggestedTimeout { - static let multiplier = 1.2 - static let minimumSeconds = 5.0 - static let maximumSeconds = 60.0 - static let defaultSeconds = 30.0 - private static let millisecondsPerSecond = 1000.0 + static let multiplier = 1.2 + static let minimumSeconds = 5.0 + static let maximumSeconds = 60.0 + static let defaultSeconds = 30.0 + private static let millisecondsPerSecond = 1000.0 - /// The scaled hint before bounds checking. Exposed so callers can log the - /// rejected value when `sanitizedSeconds` falls back to the default. - static func candidateSeconds(suggestedTimeoutMs: UInt32) -> Double { - Double(suggestedTimeoutMs) / millisecondsPerSecond * multiplier - } + /// The scaled hint before bounds checking. Exposed so callers can log the + /// rejected value when `sanitizedSeconds` falls back to the default. + static func candidateSeconds(suggestedTimeoutMs: UInt32) -> Double { + Double(suggestedTimeoutMs) / millisecondsPerSecond * multiplier + } - /// The scaled hint when it lands within sane bounds, otherwise `defaultSeconds`. - static func sanitizedSeconds(suggestedTimeoutMs: UInt32) -> Double { - let candidateSeconds = Self.candidateSeconds(suggestedTimeoutMs: suggestedTimeoutMs) - guard candidateSeconds >= minimumSeconds, candidateSeconds <= maximumSeconds else { - return defaultSeconds - } - return candidateSeconds + /// The scaled hint when it lands within sane bounds, otherwise `defaultSeconds`. + static func sanitizedSeconds(suggestedTimeoutMs: UInt32) -> Double { + let candidateSeconds = Self.candidateSeconds(suggestedTimeoutMs: suggestedTimeoutMs) + guard candidateSeconds >= minimumSeconds, candidateSeconds <= maximumSeconds else { + return defaultSeconds } + return candidateSeconds + } } diff --git a/MC1/Utilities/ImageByteCost.swift b/MC1/Utilities/ImageByteCost.swift index c31b7b79..45bcd3f8 100644 --- a/MC1/Utilities/ImageByteCost.swift +++ b/MC1/Utilities/ImageByteCost.swift @@ -13,17 +13,17 @@ import UIKit /// Animation-specific aggregation (e.g. summing GIF frames) stays at the /// caller, which invokes `bytes(for:)` once per frame. enum ImageByteCost { - private static let bytesPerPixelRGBA = 4 + private static let bytesPerPixelRGBA = 4 - static func bytes(for image: UIImage) -> Int { - if let cgImage = image.cgImage { - return cgImage.bytesPerRow * cgImage.height - } - return Int(image.size.width * image.size.height) * bytesPerPixelRGBA + static func bytes(for image: UIImage) -> Int { + if let cgImage = image.cgImage { + return cgImage.bytesPerRow * cgImage.height } + return Int(image.size.width * image.size.height) * bytesPerPixelRGBA + } - static func bytes(for image: UIImage?) -> Int { - guard let image else { return 0 } - return bytes(for: image) - } + static func bytes(for image: UIImage?) -> Int { + guard let image else { return 0 } + return bytes(for: image) + } } diff --git a/MC1/Utilities/MalwareDomainFilter.swift b/MC1/Utilities/MalwareDomainFilter.swift index 2e436b32..8a8f9501 100644 --- a/MC1/Utilities/MalwareDomainFilter.swift +++ b/MC1/Utilities/MalwareDomainFilter.swift @@ -4,135 +4,135 @@ import OSLog /// Manages a set of blocked malware domains sourced from the URLhaus blocklist. /// Uses a bundled snapshot as baseline, with background refresh from the network. actor MalwareDomainFilter { - static let shared = MalwareDomainFilter() + static let shared = MalwareDomainFilter() - private let logger = Logger(subsystem: "com.mc1", category: "MalwareDomainFilter") + private let logger = Logger(subsystem: "com.mc1", category: "MalwareDomainFilter") - private var blockedDomains: Set = [] - private var loadTask: Task? + private var blockedDomains: Set = [] + private var loadTask: Task? - private static let primaryURL = URL(string: "https://malware-filter.gitlab.io/malware-filter/urlhaus-filter-hosts-online.txt")! - private static let fallbackURL = URL(string: "https://curbengh.github.io/urlhaus-filter/urlhaus-filter-hosts-online.txt")! - private static let cacheFilename = "malware-domains.txt" - private static let refreshIntervalDays = 7 - private static let lastRefreshKey = "MalwareDomainFilter.lastRefresh" + private static let primaryURL = URL(string: "https://malware-filter.gitlab.io/malware-filter/urlhaus-filter-hosts-online.txt")! + private static let fallbackURL = URL(string: "https://curbengh.github.io/urlhaus-filter/urlhaus-filter-hosts-online.txt")! + private static let cacheFilename = "malware-domains.txt" + private static let refreshIntervalDays = 7 + private static let lastRefreshKey = "MalwareDomainFilter.lastRefresh" - /// Returns `true` if the given host is on the malware blocklist. - func isBlocked(_ host: String) async -> Bool { - await ensureLoaded() - return blockedDomains.contains(host) - } + /// Returns `true` if the given host is on the malware blocklist. + func isBlocked(_ host: String) async -> Bool { + await ensureLoaded() + return blockedDomains.contains(host) + } - // MARK: - Loading + // MARK: - Loading - /// Ensures domains are loaded exactly once, coalescing concurrent callers. - private func ensureLoaded() async { - if loadTask == nil { - loadTask = Task { await loadDomains() } - } - await loadTask?.value + /// Ensures domains are loaded exactly once, coalescing concurrent callers. + private func ensureLoaded() async { + if loadTask == nil { + loadTask = Task { await loadDomains() } } - - /// Loads domains from disk cache or bundle fallback, then triggers background refresh if stale. - private func loadDomains() async { - // Try disk cache first - if let cached = readDiskCache() { - blockedDomains = cached - logger.debug("Loaded \(cached.count) blocked domains from disk cache") - } else if let bundled = readBundledList() { - blockedDomains = bundled - logger.debug("Loaded \(bundled.count) blocked domains from bundle") - } - - // Trigger background refresh if stale - if shouldRefresh { - Task { await refresh() } - } + await loadTask?.value + } + + /// Loads domains from disk cache or bundle fallback, then triggers background refresh if stale. + private func loadDomains() async { + // Try disk cache first + if let cached = readDiskCache() { + blockedDomains = cached + logger.debug("Loaded \(cached.count) blocked domains from disk cache") + } else if let bundled = readBundledList() { + blockedDomains = bundled + logger.debug("Loaded \(bundled.count) blocked domains from bundle") } - // MARK: - Parsing - - /// Parses URLhaus hosts file format: lines of "0.0.0.0 domain" with # comments. - private func parseDomains(from text: String) -> Set { - var domains: Set = [] - for line in text.split(separator: "\n") { - if line.hasPrefix("#") { continue } - let parts = line.split(separator: " ", maxSplits: 1) - if parts.count == 2 { - domains.insert(String(parts[1])) - } - } - return domains + // Trigger background refresh if stale + if shouldRefresh { + Task { await refresh() } } - - // MARK: - Disk Cache - - private var cacheFileURL: URL { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return appSupport.appending(path: Self.cacheFilename) + } + + // MARK: - Parsing + + /// Parses URLhaus hosts file format: lines of "0.0.0.0 domain" with # comments. + private func parseDomains(from text: String) -> Set { + var domains: Set = [] + for line in text.split(separator: "\n") { + if line.hasPrefix("#") { continue } + let parts = line.split(separator: " ", maxSplits: 1) + if parts.count == 2 { + domains.insert(String(parts[1])) + } } - - private func readDiskCache() -> Set? { - guard let text = try? String(contentsOf: cacheFileURL, encoding: .utf8) else { return nil } - let domains = parseDomains(from: text) - return domains.isEmpty ? nil : domains + return domains + } + + // MARK: - Disk Cache + + private var cacheFileURL: URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return appSupport.appending(path: Self.cacheFilename) + } + + private func readDiskCache() -> Set? { + guard let text = try? String(contentsOf: cacheFileURL, encoding: .utf8) else { return nil } + let domains = parseDomains(from: text) + return domains.isEmpty ? nil : domains + } + + private func writeDiskCache(_ text: String) { + do { + let directory = cacheFileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try text.write(to: cacheFileURL, atomically: true, encoding: .utf8) + } catch { + logger.warning("Failed to write malware domain cache: \(error.localizedDescription)") } + } - private func writeDiskCache(_ text: String) { - do { - let directory = cacheFileURL.deletingLastPathComponent() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try text.write(to: cacheFileURL, atomically: true, encoding: .utf8) - } catch { - logger.warning("Failed to write malware domain cache: \(error.localizedDescription)") - } - } + // MARK: - Bundle Fallback - // MARK: - Bundle Fallback - - private func readBundledList() -> Set? { - guard let url = Bundle.main.url(forResource: "urlhaus-filter-hosts-online", withExtension: "txt"), - let text = try? String(contentsOf: url, encoding: .utf8) else { - return nil - } - return parseDomains(from: text) + private func readBundledList() -> Set? { + guard let url = Bundle.main.url(forResource: "urlhaus-filter-hosts-online", withExtension: "txt"), + let text = try? String(contentsOf: url, encoding: .utf8) else { + return nil } + return parseDomains(from: text) + } - // MARK: - Refresh + // MARK: - Refresh - private var shouldRefresh: Bool { - guard let last = UserDefaults.standard.object(forKey: Self.lastRefreshKey) as? Date else { - return true - } - let daysSince = Calendar.current.dateComponents([.day], from: last, to: .now).day ?? Int.max - return daysSince >= Self.refreshIntervalDays + private var shouldRefresh: Bool { + guard let last = UserDefaults.standard.object(forKey: Self.lastRefreshKey) as? Date else { + return true } - - /// Downloads the latest blocklist, updating cache and in-memory set on success. - /// Silently fails — stale data remains until the next attempt. - private func refresh() async { - for url in [Self.primaryURL, Self.fallbackURL] { - do { - let (data, response) = try await URLSession.shared.data(from: url) - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode), - let text = String(data: data, encoding: .utf8) else { - continue - } - - let domains = parseDomains(from: text) - guard !domains.isEmpty else { continue } - - blockedDomains = domains - writeDiskCache(text) - UserDefaults.standard.set(Date.now, forKey: Self.lastRefreshKey) - logger.info("Refreshed malware domain list: \(domains.count) domains") - return - } catch { - logger.debug("Failed to refresh from \(url): \(error.localizedDescription)") - continue - } + let daysSince = Calendar.current.dateComponents([.day], from: last, to: .now).day ?? Int.max + return daysSince >= Self.refreshIntervalDays + } + + /// Downloads the latest blocklist, updating cache and in-memory set on success. + /// Silently fails — stale data remains until the next attempt. + private func refresh() async { + for url in [Self.primaryURL, Self.fallbackURL] { + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode), + let text = String(data: data, encoding: .utf8) else { + continue } - logger.warning("All malware domain list refresh attempts failed") + + let domains = parseDomains(from: text) + guard !domains.isEmpty else { continue } + + blockedDomains = domains + writeDiskCache(text) + UserDefaults.standard.set(Date.now, forKey: Self.lastRefreshKey) + logger.info("Refreshed malware domain list: \(domains.count) domains") + return + } catch { + logger.debug("Failed to refresh from \(url): \(error.localizedDescription)") + continue + } } + logger.warning("All malware domain list refresh attempts failed") + } } diff --git a/MC1/Utilities/MeshCoreURLParser.swift b/MC1/Utilities/MeshCoreURLParser.swift index 811b6a8b..b20e000e 100644 --- a/MC1/Utilities/MeshCoreURLParser.swift +++ b/MC1/Utilities/MeshCoreURLParser.swift @@ -1,106 +1,107 @@ -import Foundation import CoreLocation +import Foundation import MC1Services /// Parses meshcore:// deep link URLs for channel and contact imports. enum MeshCoreURLParser { + static let scheme = "meshcore" + + /// Parsed channel data from a meshcore://channel/add URL + struct ChannelResult: Identifiable { + let name: String + let secret: Data - static let scheme = "meshcore" + var id: String { + secret.uppercaseHexString() + } + } + + /// Parsed contact data from a meshcore://contact/add URL. + /// The concrete type lives in MC1Services so the shared share-token utility can return it. + typealias ContactResult = MC1Services.ContactResult + + /// Parses a meshcore://channel/add URL string. + /// Returns nil if the string is not a valid channel URL. + static func parseChannelURL(_ string: String) -> ChannelResult? { + guard let url = URL(string: string), + url.scheme == "meshcore", + url.host() == "channel", + url.path() == "/add", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return nil + } - /// Parsed channel data from a meshcore://channel/add URL - struct ChannelResult: Identifiable { - let name: String - let secret: Data + let name = queryItems.first(where: { $0.name == "name" })?.value ?? "" + let secretHex = queryItems.first(where: { $0.name == "secret" })?.value ?? "" - var id: String { secret.uppercaseHexString() } + guard !name.isEmpty, + let secretData = Data(hexString: secretHex), + secretData.count == 16 else { + return nil } - /// Parsed contact data from a meshcore://contact/add URL. - /// The concrete type lives in MC1Services so the shared share-token utility can return it. - typealias ContactResult = MC1Services.ContactResult - - /// Parses a meshcore://channel/add URL string. - /// Returns nil if the string is not a valid channel URL. - static func parseChannelURL(_ string: String) -> ChannelResult? { - guard let url = URL(string: string), - url.scheme == "meshcore", - url.host() == "channel", - url.path() == "/add", - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { - return nil - } - - let name = queryItems.first(where: { $0.name == "name" })?.value ?? "" - let secretHex = queryItems.first(where: { $0.name == "secret" })?.value ?? "" - - guard !name.isEmpty, - let secretData = Data(hexString: secretHex), - secretData.count == 16 else { - return nil - } - - return ChannelResult(name: name, secret: secretData) + return ChannelResult(name: name, secret: secretData) + } + + /// Parses a meshcore://contact/add URL string. + /// Returns nil if the string is not a valid contact URL. + static func parseContactURL(_ string: String) -> ContactResult? { + guard let url = URL(string: string), + url.scheme == "meshcore", + url.host() == "contact", + url.path() == "/add", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return nil } - /// Parses a meshcore://contact/add URL string. - /// Returns nil if the string is not a valid contact URL. - static func parseContactURL(_ string: String) -> ContactResult? { - guard let url = URL(string: string), - url.scheme == "meshcore", - url.host() == "contact", - url.path() == "/add", - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { - return nil - } - - // Custom scheme, not x-www-form-urlencoded: a literal "+" is a name character, not a - // space. URLComponents has already percent-decoded the value, so use it verbatim. - let name = queryItems.first(where: { $0.name == "name" })?.value ?? "" - let publicKeyHex = queryItems.first(where: { $0.name == "public_key" })?.value ?? "" - - guard !name.isEmpty, - let keyData = Data(hexString: publicKeyHex), - keyData.count == ProtocolLimits.publicKeySize else { - return nil - } - - let typeValue = queryItems.first(where: { $0.name == "type" })?.value.flatMap { Int($0) } ?? 1 - let contactType = UInt8(exactly: typeValue).flatMap { ContactType(rawValue: $0) } ?? .chat - - return ContactResult(name: name, publicKey: keyData, contactType: contactType) + // Custom scheme, not x-www-form-urlencoded: a literal "+" is a name character, not a + // space. URLComponents has already percent-decoded the value, so use it verbatim. + let name = queryItems.first(where: { $0.name == "name" })?.value ?? "" + let publicKeyHex = queryItems.first(where: { $0.name == "public_key" })?.value ?? "" + + guard !name.isEmpty, + let keyData = Data(hexString: publicKeyHex), + keyData.count == ProtocolLimits.publicKeySize else { + return nil + } + + let typeValue = queryItems.first(where: { $0.name == "type" })?.value.flatMap { Int($0) } ?? 1 + let contactType = UInt8(exactly: typeValue).flatMap { ContactType(rawValue: $0) } ?? .chat + + return ContactResult(name: name, publicKey: keyData, contactType: contactType) + } + + /// Parses a meshcore://map?lat=&lon= URL into a coordinate. Returns nil if not a + /// valid map URL or if the values are out of range or not plain decimal degrees. + static func parseMapURL(_ string: String) -> CLLocationCoordinate2D? { + guard let url = URL(string: string), + url.scheme == scheme, + url.host() == "map", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return nil } - /// Parses a meshcore://map?lat=&lon= URL into a coordinate. Returns nil if not a - /// valid map URL or if the values are out of range or not plain decimal degrees. - static func parseMapURL(_ string: String) -> CLLocationCoordinate2D? { - guard let url = URL(string: string), - url.scheme == scheme, - url.host() == "map", - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { - return nil - } - - guard let latitude = decimalDegree(queryItems.first(where: { $0.name == "lat" })?.value), - let longitude = decimalDegree(queryItems.first(where: { $0.name == "lon" })?.value), - (-90.0...90.0).contains(latitude), - (-180.0...180.0).contains(longitude) else { - return nil - } - - return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + guard let latitude = decimalDegree(queryItems.first(where: { $0.name == "lat" })?.value), + let longitude = decimalDegree(queryItems.first(where: { $0.name == "lon" })?.value), + (-90.0...90.0).contains(latitude), + (-180.0...180.0).contains(longitude) else { + return nil } - /// Parses a plain decimal-degree string. The format gate rejects hex floats - /// (`0x1p4`), `inf`, and `nan` — all of which `Double(_:)` accepts and would - /// otherwise feed into MKCoordinateRegion / openInMaps. - private static func decimalDegree(_ value: String?) -> Double? { - guard let value, - value.range(of: #"^-?\d{1,3}(\.\d+)?$"#, options: .regularExpression) != nil else { - return nil - } - return Double(value) + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + + /// Parses a plain decimal-degree string. The format gate rejects hex floats + /// (`0x1p4`), `inf`, and `nan` — all of which `Double(_:)` accepts and would + /// otherwise feed into MKCoordinateRegion / openInMaps. + private static func decimalDegree(_ value: String?) -> Double? { + guard let value, + value.range(of: #"^-?\d{1,3}(\.\d+)?$"#, options: .regularExpression) != nil else { + return nil } + return Double(value) + } } diff --git a/MC1/Utilities/PingHelper.swift b/MC1/Utilities/PingHelper.swift index bdbdda42..f98cbf3b 100644 --- a/MC1/Utilities/PingHelper.swift +++ b/MC1/Utilities/PingHelper.swift @@ -4,77 +4,77 @@ import MC1Services import os enum PingError: Error { - case notConnected - case timeout + case notConnected + case timeout } @MainActor enum PingHelper { - private static let logger = Logger(subsystem: "com.mc1", category: "Ping") + private static let logger = Logger(subsystem: "com.mc1", category: "Ping") - /// Send a zero-hop trace ping to a contact and return the timed result. - /// Posts a VoiceOver announcement on completion. - static func zeroHopPing(contact: ContactDTO, appState: AppState) async -> PingResult { - let startTime = ContinuousClock.now - let tag = UInt32.random(in: 0.. PingResult { + let startTime = ContinuousClock.now + let tag = UInt32.random(in: 0..: Sendable { - let node: T - let matchKind: NodeNameMatchKind +struct ResolvedNode { + let node: T + let matchKind: NodeNameMatchKind } /// A neighbor resolution that also carries the resolved node's location so callers /// (the SNR map) can place it. The coordinate is stored as the `Double` pair (not /// `CLLocationCoordinate2D`, which is neither `Sendable` nor `Hashable`) so the struct /// stays `Sendable`, matching its file siblings. -struct ResolvedNeighbor: Sendable { - let displayName: String - let matchKind: NodeNameMatchKind - let latitude: Double? - let longitude: Double? - - var coordinate: CLLocationCoordinate2D? { - guard let latitude, let longitude else { return nil } - return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - } +struct ResolvedNeighbor { + let displayName: String + let matchKind: NodeNameMatchKind + let latitude: Double? + let longitude: Double? + + var coordinate: CLLocationCoordinate2D? { + guard let latitude, let longitude else { return nil } + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } /// A single routing hop (hash bytes shown as hex) resolved to a repeater name, ready to display. -struct ResolvedPathHop: Identifiable, Sendable { - let id: Int - let hex: String - let resolution: NodeNameResolution +struct ResolvedPathHop: Identifiable { + let id: Int + let hex: String + let resolution: NodeNameResolution } /// Resolves repeater collisions by proximity and recency. enum RepeaterResolver { - private static let exactPrefixLength = 6 - - /// Match using a PathHop: exact public key match first, then hash bytes fallback. - static func bestMatch( - for hop: PathHop, - in nodes: [T], - userLocation: CLLocation? - ) -> T? { - resolve(for: hop, in: nodes, userLocation: userLocation)?.node + private static let exactPrefixLength = 6 + + /// Match using a PathHop: exact public key match first, then hash bytes fallback. + static func bestMatch( + for hop: PathHop, + in nodes: [T], + userLocation: CLLocation? + ) -> T? { + resolve(for: hop, in: nodes, userLocation: userLocation)?.node + } + + static func resolve( + for hop: PathHop, + in nodes: [T], + userLocation: CLLocation? + ) -> ResolvedNode? { + if let key = hop.publicKey, + let exact = nodes.first(where: { $0.publicKey == key }) { + return ResolvedNode(node: exact, matchKind: .exact) } - - static func resolve( - for hop: PathHop, - in nodes: [T], - userLocation: CLLocation? - ) -> ResolvedNode? { - if let key = hop.publicKey, - let exact = nodes.first(where: { $0.publicKey == key }) { - return ResolvedNode(node: exact, matchKind: .exact) - } - return resolve(for: hop.hashBytes, in: nodes, userLocation: userLocation) + return resolve(for: hop.hashBytes, in: nodes, userLocation: userLocation) + } + + /// Match using hash bytes (1-3 byte prefix) + static func bestMatch( + for hashBytes: Data, + in nodes: [T], + userLocation: CLLocation? + ) -> T? { + resolve(for: hashBytes, in: nodes, userLocation: userLocation)?.node + } + + static func resolve( + for hashBytes: Data, + in nodes: [T], + userLocation: CLLocation? + ) -> ResolvedNode? { + guard !hashBytes.isEmpty else { return nil } + + let prefixLen = hashBytes.count + let candidates = nodes.compactMap { node -> (T, Double?)? in + guard node.publicKey.prefix(prefixLen) == hashBytes else { return nil } + + let distance: Double? + if let userLocation, node.hasLocation { + let nodeLocation = CLLocation(latitude: node.latitude, longitude: node.longitude) + distance = userLocation.distance(from: nodeLocation) + } else { + distance = nil + } + + return (node, distance) } - /// Match using hash bytes (1-3 byte prefix) - static func bestMatch( - for hashBytes: Data, - in nodes: [T], - userLocation: CLLocation? - ) -> T? { - resolve(for: hashBytes, in: nodes, userLocation: userLocation)?.node + guard !candidates.isEmpty else { return nil } + + let sorted = candidates.sorted { lhs, rhs in + switch (lhs.1, rhs.1) { + case let (left?, right?): + if left != right { return left < right } + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + break + } + + if lhs.0.lastAdvertTimestamp != rhs.0.lastAdvertTimestamp { + return lhs.0.lastAdvertTimestamp > rhs.0.lastAdvertTimestamp + } + + if lhs.0.recencyDate != rhs.0.recencyDate { + return lhs.0.recencyDate > rhs.0.recencyDate + } + + return lhs.0.resolvableName.localizedStandardCompare(rhs.0.resolvableName) == .orderedAscending } - static func resolve( - for hashBytes: Data, - in nodes: [T], - userLocation: CLLocation? - ) -> ResolvedNode? { - guard !hashBytes.isEmpty else { return nil } - - let prefixLen = hashBytes.count - let candidates = nodes.compactMap { node -> (T, Double?)? in - guard node.publicKey.prefix(prefixLen) == hashBytes else { return nil } - - let distance: Double? - if let userLocation, node.hasLocation { - let nodeLocation = CLLocation(latitude: node.latitude, longitude: node.longitude) - distance = userLocation.distance(from: nodeLocation) - } else { - distance = nil - } - - return (node, distance) - } - - guard !candidates.isEmpty else { return nil } - - let sorted = candidates.sorted { lhs, rhs in - switch (lhs.1, rhs.1) { - case let (left?, right?): - if left != right { return left < right } - case (.some, .none): - return true - case (.none, .some): - return false - case (.none, .none): - break - } - - if lhs.0.lastAdvertTimestamp != rhs.0.lastAdvertTimestamp { - return lhs.0.lastAdvertTimestamp > rhs.0.lastAdvertTimestamp - } - - if lhs.0.recencyDate != rhs.0.recencyDate { - return lhs.0.recencyDate > rhs.0.recencyDate - } - - return lhs.0.resolvableName.localizedStandardCompare(rhs.0.resolvableName) == .orderedAscending - } - - guard let node = sorted.first?.0 else { return nil } - let matchingPublicKeys = Set(candidates.map { $0.0.publicKey }) - let matchKind: NodeNameMatchKind = prefixLen >= exactPrefixLength || matchingPublicKeys.count == 1 - ? .exact - : .fallback - return ResolvedNode(node: node, matchKind: matchKind) - } + guard let node = sorted.first?.0 else { return nil } + let matchingPublicKeys = Set(candidates.map(\.0.publicKey)) + let matchKind: NodeNameMatchKind = prefixLen >= exactPrefixLength || matchingPublicKeys.count == 1 + ? .exact + : .fallback + return ResolvedNode(node: node, matchKind: matchKind) + } } enum NeighborNameResolver { - /// Single resolution core: resolves a neighbor prefix to a name, match confidence, and the - /// resolved node's location. Contacts are preferred over discovered nodes, and the cross-source - /// `matchKind` refinement is applied here so every caller reads identical name/matchKind/coordinate. - static func resolveLocated( - for prefix: Data, - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO], - userLocation: CLLocation? - ) -> ResolvedNeighbor? { - if let contact = RepeaterResolver.resolve(for: prefix, in: contacts, userLocation: userLocation) { - return located( - node: contact.node, - resolvedMatchKind: contact.matchKind, - prefix: prefix, - contacts: contacts, - discoveredNodes: discoveredNodes - ) - } - - if let node = RepeaterResolver.resolve(for: prefix, in: discoveredNodes, userLocation: userLocation) { - return located( - node: node.node, - resolvedMatchKind: node.matchKind, - prefix: prefix, - contacts: contacts, - discoveredNodes: discoveredNodes - ) - } - - return nil - } - - private static func located( - node: T, - resolvedMatchKind: NodeNameMatchKind, - prefix: Data, - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO] - ) -> ResolvedNeighbor { - ResolvedNeighbor( - displayName: node.resolvableName, - matchKind: matchKind(for: prefix, resolvedMatchKind: resolvedMatchKind, contacts: contacts, discoveredNodes: discoveredNodes), - latitude: node.hasLocation ? node.latitude : nil, - longitude: node.hasLocation ? node.longitude : nil - ) - } - - static func resolve( - for prefix: Data, - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO], - userLocation: CLLocation? - ) -> NodeNameResolution? { - guard let resolved = resolveLocated( - for: prefix, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) else { return nil } - - return NodeNameResolution(displayName: resolved.displayName, matchKind: resolved.matchKind) - } - - private static func matchKind( - for prefix: Data, - resolvedMatchKind: NodeNameMatchKind, - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO] - ) -> NodeNameMatchKind { - guard resolvedMatchKind != .unresolved, prefix.count < 6 else { - return resolvedMatchKind - } - - let matchingContactKeys = contacts - .filter { $0.publicKey.prefix(prefix.count) == prefix } - .map(\.publicKey) - let matchingDiscoveredKeys = discoveredNodes - .filter { $0.publicKey.prefix(prefix.count) == prefix } - .map(\.publicKey) - - return Set(matchingContactKeys + matchingDiscoveredKeys).count > 1 ? .fallback : .exact + /// Single resolution core: resolves a neighbor prefix to a name, match confidence, and the + /// resolved node's location. Contacts are preferred over discovered nodes, and the cross-source + /// `matchKind` refinement is applied here so every caller reads identical name/matchKind/coordinate. + static func resolveLocated( + for prefix: Data, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> ResolvedNeighbor? { + if let contact = RepeaterResolver.resolve(for: prefix, in: contacts, userLocation: userLocation) { + return located( + node: contact.node, + resolvedMatchKind: contact.matchKind, + prefix: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes + ) } - static func resolveName( - for prefix: Data, - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO], - userLocation: CLLocation? - ) -> String? { - resolve( - for: prefix, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - )?.displayName + if let node = RepeaterResolver.resolve(for: prefix, in: discoveredNodes, userLocation: userLocation) { + return located( + node: node.node, + resolvedMatchKind: node.matchKind, + prefix: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes + ) } - static func fallbackName(for prefix: Data) -> String { - prefix.prefix(4).uppercaseHexString() + return nil + } + + private static func located( + node: some RepeaterResolvable, + resolvedMatchKind: NodeNameMatchKind, + prefix: Data, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO] + ) -> ResolvedNeighbor { + ResolvedNeighbor( + displayName: node.resolvableName, + matchKind: matchKind(for: prefix, resolvedMatchKind: resolvedMatchKind, contacts: contacts, discoveredNodes: discoveredNodes), + latitude: node.hasLocation ? node.latitude : nil, + longitude: node.hasLocation ? node.longitude : nil + ) + } + + static func resolve( + for prefix: Data, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> NodeNameResolution? { + guard let resolved = resolveLocated( + for: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) else { return nil } + + return NodeNameResolution(displayName: resolved.displayName, matchKind: resolved.matchKind) + } + + private static func matchKind( + for prefix: Data, + resolvedMatchKind: NodeNameMatchKind, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO] + ) -> NodeNameMatchKind { + guard resolvedMatchKind != .unresolved, prefix.count < 6 else { + return resolvedMatchKind } - /// Resolves every hop of a node's stored path to a repeater name, falling back to a placeholder - /// when no repeater matches a hop. Only repeaters can relay, so contacts and discovered nodes are - /// narrowed to repeaters before matching, matching the login sheet's path display. - static func resolvePath( - _ hops: [(data: Data, hex: String)], - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO], - userLocation: CLLocation? - ) -> [ResolvedPathHop] { - let repeaters = contacts.filter { $0.type == .repeater } - let discoveredRepeaters = discoveredNodes.filter { $0.nodeType == .repeater } - - return hops.enumerated().map { index, hop in - let resolution = resolve( - for: hop.data, - contacts: repeaters, - discoveredNodes: discoveredRepeaters, - userLocation: userLocation - ) ?? NodeNameResolution( - displayName: L10n.RemoteNodes.RemoteNodes.Auth.pathHopUnknown, - matchKind: .unresolved - ) - return ResolvedPathHop(id: index, hex: hop.hex, resolution: resolution) - } + let matchingContactKeys = contacts + .filter { $0.publicKey.prefix(prefix.count) == prefix } + .map(\.publicKey) + let matchingDiscoveredKeys = discoveredNodes + .filter { $0.publicKey.prefix(prefix.count) == prefix } + .map(\.publicKey) + + return Set(matchingContactKeys + matchingDiscoveredKeys).count > 1 ? .fallback : .exact + } + + static func resolveName( + for prefix: Data, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> String? { + resolve( + for: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + )?.displayName + } + + static func fallbackName(for prefix: Data) -> String { + prefix.prefix(4).uppercaseHexString() + } + + /// Resolves every hop of a node's stored path to a repeater name, falling back to a placeholder + /// when no repeater matches a hop. Only repeaters can relay, so contacts and discovered nodes are + /// narrowed to repeaters before matching, matching the login sheet's path display. + static func resolvePath( + _ hops: [(data: Data, hex: String)], + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> [ResolvedPathHop] { + let repeaters = contacts.filter { $0.type == .repeater } + let discoveredRepeaters = discoveredNodes.filter { $0.nodeType == .repeater } + + return hops.enumerated().map { index, hop in + let resolution = resolve( + for: hop.data, + contacts: repeaters, + discoveredNodes: discoveredRepeaters, + userLocation: userLocation + ) ?? NodeNameResolution( + displayName: L10n.RemoteNodes.RemoteNodes.Auth.pathHopUnknown, + matchKind: .unresolved + ) + return ResolvedPathHop(id: index, hex: hop.hex, resolution: resolution) } + } } diff --git a/MC1/Utilities/URLSafetyChecker.swift b/MC1/Utilities/URLSafetyChecker.swift index 7ecb27c3..4e3955b1 100644 --- a/MC1/Utilities/URLSafetyChecker.swift +++ b/MC1/Utilities/URLSafetyChecker.swift @@ -4,165 +4,165 @@ import OSLog /// Validates URLs before fetching to prevent SSRF attacks via private network access. /// Rejects non-HTTP(S) schemes and private/reserved IPs. enum URLSafetyChecker { - private static let logger = Logger(subsystem: "com.mc1", category: "URLSafetyChecker") - - /// Hosts that bypass safety checks (known-safe CDN domains) - private static let allowedHosts: Set = [ - "media.giphy.com", - "i.giphy.com" - ] - - private static let dnsTimeoutSeconds: Duration = .seconds(5) - - // MARK: - Public API - - /// Returns `true` if the URL is safe to fetch, `false` otherwise. - /// Fails closed: DNS failure or timeout returns `false`. - static func isSafe(_ url: URL) async -> Bool { - // Only allow HTTP(S) - guard let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { - logger.warning("Rejected non-HTTP(S) scheme: \(url.scheme ?? "nil")") - return false - } - - // Must have a host - guard let host = url.host() else { - logger.warning("Rejected URL with no host") - return false - } - - // Allow-listed hosts bypass SSRF checks (known-safe CDN domains) - if allowedHosts.contains(host) { - return true - } - - // If host is an IP literal, check directly - if isPrivateOrReserved(host) { - logger.warning("Rejected private/reserved IP: \(host)") - return false - } - - // Resolve DNS and check all addresses - return await resolveAndCheck(host: host) + private static let logger = Logger(subsystem: "com.mc1", category: "URLSafetyChecker") + + /// Hosts that bypass safety checks (known-safe CDN domains) + private static let allowedHosts: Set = [ + "media.giphy.com", + "i.giphy.com" + ] + + private static let dnsTimeoutSeconds: Duration = .seconds(5) + + // MARK: - Public API + + /// Returns `true` if the URL is safe to fetch, `false` otherwise. + /// Fails closed: DNS failure or timeout returns `false`. + static func isSafe(_ url: URL) async -> Bool { + // Only allow HTTP(S) + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + logger.warning("Rejected non-HTTP(S) scheme: \(url.scheme ?? "nil")") + return false } - /// Checks whether an IP address string falls within private or reserved ranges. - static func isPrivateOrReserved(_ address: String) -> Bool { - var addr4 = in_addr() - if inet_pton(AF_INET, address, &addr4) == 1 { - return isPrivateIPv4(UInt32(bigEndian: addr4.s_addr)) - } - - var addr6 = in6_addr() - if inet_pton(AF_INET6, address, &addr6) == 1 { - let bytes = withUnsafeBytes(of: &addr6.__u6_addr.__u6_addr8) { Array($0) } - return isPrivateIPv6(bytes) - } - - // Not a valid IP literal — not private (will be resolved via DNS) - return false + // Must have a host + guard let host = url.host() else { + logger.warning("Rejected URL with no host") + return false } - // MARK: - Private IP Range Checks - - /// Checks whether a parsed IPv4 address falls within private or reserved ranges. - private static func isPrivateIPv4(_ ip: UInt32) -> Bool { - if ip == 0 { return true } // 0.0.0.0 - if ip >> 24 == 127 { return true } // 127.0.0.0/8 - if ip >> 24 == 10 { return true } // 10.0.0.0/8 - if ip >> 20 == 0xAC1 { return true } // 172.16.0.0/12 - if ip >> 16 == 0xC0A8 { return true } // 192.168.0.0/16 - if ip >> 16 == 0xA9FE { return true } // 169.254.0.0/16 (link-local) - if ip >> 28 == 0xE { return true } // 224.0.0.0/4 (multicast) - if ip >> 28 == 0xF { return true } // 240.0.0.0/4 (reserved) - return false + // Allow-listed hosts bypass SSRF checks (known-safe CDN domains) + if allowedHosts.contains(host) { + return true } - /// Checks whether a parsed IPv6 address falls within private or reserved ranges. - private static func isPrivateIPv6(_ bytes: [UInt8]) -> Bool { - if bytes.allSatisfy({ $0 == 0 }) { return true } // :: - if bytes.dropLast().allSatisfy({ $0 == 0 }) && bytes.last == 1 { return true } // ::1 - if bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80 { return true } // fe80::/10 - if (bytes[0] & 0xFE) == 0xFC { return true } // fc00::/7 - // IPv4-mapped: check the embedded IPv4 address - if bytes[0...9].allSatisfy({ $0 == 0 }) && bytes[10] == 0xFF && bytes[11] == 0xFF { - let mapped = String(format: "%d.%d.%d.%d", bytes[12], bytes[13], bytes[14], bytes[15]) - return isPrivateOrReserved(mapped) - } - if bytes[0] == 0xFF { return true } // ff00::/8 - return false + // If host is an IP literal, check directly + if isPrivateOrReserved(host) { + logger.warning("Rejected private/reserved IP: \(host)") + return false } - // MARK: - DNS Resolution - - /// Resolves the host via DNS and checks all returned addresses. - /// Uses a 5-second timeout to prevent thread pool starvation. - /// Returns `true` if all resolved addresses are public, `false` otherwise. - private static func resolveAndCheck(host: String) async -> Bool { - do { - return try await withThrowingTaskGroup(of: Bool.self) { group in - group.addTask { - await Task.detached { - resolveDNS(host: host) - }.value - } - group.addTask { - try await Task.sleep(for: dnsTimeoutSeconds) - throw CancellationError() - } - let result = try await group.next() ?? false - group.cancelAll() - return result - } - } catch { - logger.warning("DNS resolution timed out or failed for \(host)") - return false - } + // Resolve DNS and check all addresses + return await resolveAndCheck(host: host) + } + + /// Checks whether an IP address string falls within private or reserved ranges. + static func isPrivateOrReserved(_ address: String) -> Bool { + var addr4 = in_addr() + if inet_pton(AF_INET, address, &addr4) == 1 { + return isPrivateIPv4(UInt32(bigEndian: addr4.s_addr)) } - /// Synchronous DNS resolution + private IP check (runs on detached task). - private static func resolveDNS(host: String) -> Bool { - var hints = addrinfo() - hints.ai_family = AF_UNSPEC - hints.ai_socktype = SOCK_STREAM + var addr6 = in6_addr() + if inet_pton(AF_INET6, address, &addr6) == 1 { + let bytes = withUnsafeBytes(of: &addr6.__u6_addr.__u6_addr8) { Array($0) } + return isPrivateIPv6(bytes) + } - var result: UnsafeMutablePointer? - let status = getaddrinfo(host, nil, &hints, &result) - guard status == 0, let addrList = result else { - return false + // Not a valid IP literal — not private (will be resolved via DNS) + return false + } + + // MARK: - Private IP Range Checks + + /// Checks whether a parsed IPv4 address falls within private or reserved ranges. + private static func isPrivateIPv4(_ ip: UInt32) -> Bool { + if ip == 0 { return true } // 0.0.0.0 + if ip >> 24 == 127 { return true } // 127.0.0.0/8 + if ip >> 24 == 10 { return true } // 10.0.0.0/8 + if ip >> 20 == 0xAC1 { return true } // 172.16.0.0/12 + if ip >> 16 == 0xC0A8 { return true } // 192.168.0.0/16 + if ip >> 16 == 0xA9FE { return true } // 169.254.0.0/16 (link-local) + if ip >> 28 == 0xE { return true } // 224.0.0.0/4 (multicast) + if ip >> 28 == 0xF { return true } // 240.0.0.0/4 (reserved) + return false + } + + /// Checks whether a parsed IPv6 address falls within private or reserved ranges. + private static func isPrivateIPv6(_ bytes: [UInt8]) -> Bool { + if bytes.allSatisfy({ $0 == 0 }) { return true } // :: + if bytes.dropLast().allSatisfy({ $0 == 0 }), bytes.last == 1 { return true } // ::1 + if bytes[0] == 0xFE, (bytes[1] & 0xC0) == 0x80 { return true } // fe80::/10 + if (bytes[0] & 0xFE) == 0xFC { return true } // fc00::/7 + // IPv4-mapped: check the embedded IPv4 address + if bytes[0...9].allSatisfy({ $0 == 0 }), bytes[10] == 0xFF, bytes[11] == 0xFF { + let mapped = String(format: "%d.%d.%d.%d", bytes[12], bytes[13], bytes[14], bytes[15]) + return isPrivateOrReserved(mapped) + } + if bytes[0] == 0xFF { return true } // ff00::/8 + return false + } + + // MARK: - DNS Resolution + + /// Resolves the host via DNS and checks all returned addresses. + /// Uses a 5-second timeout to prevent thread pool starvation. + /// Returns `true` if all resolved addresses are public, `false` otherwise. + private static func resolveAndCheck(host: String) async -> Bool { + do { + return try await withThrowingTaskGroup(of: Bool.self) { group in + group.addTask { + await Task.detached { + resolveDNS(host: host) + }.value } - defer { freeaddrinfo(addrList) } - - var current: UnsafeMutablePointer? = addrList - while let info = current { - let address = extractAddress(from: info.pointee) - if let address, isPrivateOrReserved(address) { - return false - } - current = info.pointee.ai_next + group.addTask { + try await Task.sleep(for: dnsTimeoutSeconds) + throw CancellationError() } + let result = try await group.next() ?? false + group.cancelAll() + return result + } + } catch { + logger.warning("DNS resolution timed out or failed for \(host)") + return false + } + } + + /// Synchronous DNS resolution + private IP check (runs on detached task). + private static func resolveDNS(host: String) -> Bool { + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + + var result: UnsafeMutablePointer? + let status = getaddrinfo(host, nil, &hints, &result) + guard status == 0, let addrList = result else { + return false + } + defer { freeaddrinfo(addrList) } - return true + var current: UnsafeMutablePointer? = addrList + while let info = current { + let address = extractAddress(from: info.pointee) + if let address, isPrivateOrReserved(address) { + return false + } + current = info.pointee.ai_next } - /// Extracts a human-readable IP address string from an addrinfo struct. - private static func extractAddress(from info: addrinfo) -> String? { - var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + return true + } - switch info.ai_family { - case AF_INET: - var addr = info.ai_addr!.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } - inet_ntop(AF_INET, &addr.sin_addr, &buffer, socklen_t(INET_ADDRSTRLEN)) - return buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } + /// Extracts a human-readable IP address string from an addrinfo struct. + private static func extractAddress(from info: addrinfo) -> String? { + var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) - case AF_INET6: - var addr = info.ai_addr!.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } - inet_ntop(AF_INET6, &addr.sin6_addr, &buffer, socklen_t(INET6_ADDRSTRLEN)) - return buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } + switch info.ai_family { + case AF_INET: + var addr = info.ai_addr!.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } + inet_ntop(AF_INET, &addr.sin_addr, &buffer, socklen_t(INET_ADDRSTRLEN)) + return buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } - default: - return nil - } + case AF_INET6: + var addr = info.ai_addr!.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } + inet_ntop(AF_INET6, &addr.sin6_addr, &buffer, socklen_t(INET6_ADDRSTRLEN)) + return buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } + + default: + return nil } + } } diff --git a/MC1/Views/AppSidebar.swift b/MC1/Views/AppSidebar.swift index 4980482b..6e03a9a1 100644 --- a/MC1/Views/AppSidebar.swift +++ b/MC1/Views/AppSidebar.swift @@ -1,103 +1,103 @@ -import SwiftUI import MC1Services +import SwiftUI /// The sidebar column of the iPad three-column layout: the five `AppTab` rows, bridging /// row selection to `NavigationCoordinator.selectedTab`. The radio control lives in each /// section's own toolbar, since the icon-only sidebar is too narrow to host it. struct AppSidebar: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @Binding var columnVisibility: NavigationSplitViewVisibility + @Binding var columnVisibility: NavigationSplitViewVisibility - /// List single-selection requires an optional binding; a nil selection falls back to chats. - /// Selecting a row collapses the sidebar (Mail-style) in the same write as the tab change, so - /// the section rebuilds once with the right visibility for its column count — collapsing - /// reactively after the tab change instead would render one frame at the wrong shape (the map - /// section flashing its sidebar, a list section briefly missing its content column). The collapse - /// is gated on `isSidebarWide`. - private var selection: Binding { - Binding( - get: { AppTab(rawValue: appState.navigation.selectedTab) ?? .chats }, - set: { newValue in - let tab = newValue ?? .chats - appState.navigation.selectedTab = tab.rawValue - if !appState.navigation.isSidebarWide { - columnVisibility = tab.collapsedSidebarVisibility - } - } - ) - } + /// List single-selection requires an optional binding; a nil selection falls back to chats. + /// Selecting a row collapses the sidebar (Mail-style) in the same write as the tab change, so + /// the section rebuilds once with the right visibility for its column count — collapsing + /// reactively after the tab change instead would render one frame at the wrong shape (the map + /// section flashing its sidebar, a list section briefly missing its content column). The collapse + /// is gated on `isSidebarWide`. + private var selection: Binding { + Binding( + get: { AppTab(rawValue: appState.navigation.selectedTab) ?? .chats }, + set: { newValue in + let tab = newValue ?? .chats + appState.navigation.selectedTab = tab.rawValue + if !appState.navigation.isSidebarWide { + columnVisibility = tab.collapsedSidebarVisibility + } + } + ) + } - private var unreadCount: Int { - appState.services?.notificationService.badgeCount ?? 0 - } + private var unreadCount: Int { + appState.services?.notificationService.badgeCount ?? 0 + } - var body: some View { - List(selection: selection) { - // A row `.badge` reserves a trailing accessory slot that leading-aligns this icon-only - // label, shifting the glyph off the column's centerline that every other row sits on. - // The unread count is overlaid on the centered icon instead, leaving the glyph centered. - Label(L10n.Localizable.Tabs.chats, systemImage: "message.fill") - .overlay(alignment: .topTrailing) { unreadBadge } - .accessibilityValue(unreadCount > 0 - ? L10n.Localizable.Tabs.chatsUnreadAccessibilityValue(unreadCount) - : "") - .tag(AppTab.chats) + var body: some View { + List(selection: selection) { + // A row `.badge` reserves a trailing accessory slot that leading-aligns this icon-only + // label, shifting the glyph off the column's centerline that every other row sits on. + // The unread count is overlaid on the centered icon instead, leaving the glyph centered. + Label(L10n.Localizable.Tabs.chats, systemImage: "message.fill") + .overlay(alignment: .topTrailing) { unreadBadge } + .accessibilityValue(unreadCount > 0 + ? L10n.Localizable.Tabs.chatsUnreadAccessibilityValue(unreadCount) + : "") + .tag(AppTab.chats) - Label(L10n.Localizable.Tabs.nodes, systemImage: "flipphone") - .tag(AppTab.nodes) + Label(L10n.Localizable.Tabs.nodes, systemImage: "flipphone") + .tag(AppTab.nodes) - Label(L10n.Localizable.Tabs.map, systemImage: "map.fill") - .tag(AppTab.map) + Label(L10n.Localizable.Tabs.map, systemImage: "map.fill") + .tag(AppTab.map) - Label(L10n.Localizable.Tabs.tools, systemImage: "wrench.and.screwdriver") - .tag(AppTab.tools) + Label(L10n.Localizable.Tabs.tools, systemImage: "wrench.and.screwdriver") + .tag(AppTab.tools) - Label(L10n.Localizable.Tabs.settings, systemImage: "gear") - .tag(AppTab.settings) - } - // Icon-only sidebar: each Label's title is hidden visually but stays its row's VoiceOver - // label, so the titles are load-bearing for accessibility and must remain on every row. - .labelStyle(.iconOnly) + Label(L10n.Localizable.Tabs.settings, systemImage: "gear") + .tag(AppTab.settings) } + // Icon-only sidebar: each Label's title is hidden visually but stays its row's VoiceOver + // label, so the titles are load-bearing for accessibility and must remain on every row. + .labelStyle(.iconOnly) + } - @ViewBuilder - private var unreadBadge: some View { - if unreadCount > 0 { - Text(unreadCount > Self.unreadBadgeOverflowThreshold - ? L10n.Chats.Chats.ScrollButton.Badge.overflow - : "\(unreadCount)") - .font(.caption2.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.red, in: .capsule) - .fixedSize() - // The sidebar renders row content through a vibrancy effect that blends the capsule - // fill with the background; flattening to one layer keeps the red fully opaque. - .drawingGroup() - .offset(x: 8, y: -8) - } + @ViewBuilder + private var unreadBadge: some View { + if unreadCount > 0 { + Text(unreadCount > Self.unreadBadgeOverflowThreshold + ? L10n.Chats.Chats.ScrollButton.Badge.overflow + : "\(unreadCount)") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.red, in: .capsule) + .fixedSize() + // The sidebar renders row content through a vibrancy effect that blends the capsule + // fill with the background; flattening to one layer keeps the red fully opaque. + .drawingGroup() + .offset(x: 8, y: -8) } + } - private static let unreadBadgeOverflowThreshold = 99 + private static let unreadBadgeOverflowThreshold = 99 } extension AppTab { - /// The column visibility that hides the app sidebar for this section's split shape: a - /// two-column split (Map) hides its sidebar with `.detailOnly`, while a three-column split - /// (the list sections) hides the sidebar but keeps content + detail with `.doubleColumn`. - /// A single shared visibility value cannot collapse both shapes, so it is chosen per section. - var collapsedSidebarVisibility: NavigationSplitViewVisibility { - self == .map ? .detailOnly : .doubleColumn - } + /// The column visibility that hides the app sidebar for this section's split shape: a + /// two-column split (Map) hides its sidebar with `.detailOnly`, while a three-column split + /// (the list sections) hides the sidebar but keeps content + detail with `.doubleColumn`. + /// A single shared visibility value cannot collapse both shapes, so it is chosen per section. + var collapsedSidebarVisibility: NavigationSplitViewVisibility { + self == .map ? .detailOnly : .doubleColumn + } } #Preview { - NavigationSplitView { - AppSidebar(columnVisibility: .constant(.all)) - .environment(\.appState, AppState()) - } detail: { - Text(verbatim: "Detail") - } + NavigationSplitView { + AppSidebar(columnVisibility: .constant(.all)) + .environment(\.appState, AppState()) + } detail: { + Text(verbatim: "Detail") + } } diff --git a/MC1/Views/Appearance/AppearanceView.swift b/MC1/Views/Appearance/AppearanceView.swift index 06d80f3e..cf2af6ab 100644 --- a/MC1/Views/Appearance/AppearanceView.swift +++ b/MC1/Views/Appearance/AppearanceView.swift @@ -1,101 +1,105 @@ -import SwiftUI import MC1Services +import SwiftUI struct AppearanceView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var activeTheme - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - - @State private var schemeTrigger = 0 - @State private var selectionTrigger = 0 + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var activeTheme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize - private var themeService: ThemeService { appState.themeService } + @State private var schemeTrigger = 0 + @State private var selectionTrigger = 0 - private var availableThemes: [Theme] { themeService.availableToCurrentUser() } + private var themeService: ThemeService { + appState.themeService + } - private var columns: [GridItem] { - dynamicTypeSize.isAccessibilitySize - ? [GridItem(.flexible())] - : [GridItem(.adaptive(minimum: ThemeCardMetrics.gridItemMinimum), spacing: ThemeCardMetrics.gridSpacing)] - } + private var availableThemes: [Theme] { + themeService.availableToCurrentUser() + } - /// "Purchase More Themes" is shown only when at least one registry theme is unavailable. - static func shouldShowBrowseMore(available: [Theme]) -> Bool { - available.count < ThemeRegistry.allThemes.count - } + private var columns: [GridItem] { + dynamicTypeSize.isAccessibilitySize + ? [GridItem(.flexible())] + : [GridItem(.adaptive(minimum: ThemeCardMetrics.gridItemMinimum), spacing: ThemeCardMetrics.gridSpacing)] + } - var body: some View { - List { - schemeSection - themesSection - if Self.shouldShowBrowseMore(available: availableThemes) { - Section { - NavigationLink(L10n.Settings.Appearance.MoreThemes.link) { - SupportDevelopmentView() - } - } - .themedRowBackground(activeTheme) - } - } - .themedCanvas(activeTheme) - .navigationTitle(L10n.Settings.Appearance.title) - .navigationBarTitleDisplayMode(.inline) - .sensoryFeedback(.selection, trigger: schemeTrigger) - .sensoryFeedback(.selection, trigger: selectionTrigger) - } + /// "Purchase More Themes" is shown only when at least one registry theme is unavailable. + static func shouldShowBrowseMore(available: [Theme]) -> Bool { + available.count < ThemeRegistry.allThemes.count + } - private var schemeSection: some View { + var body: some View { + List { + schemeSection + themesSection + if Self.shouldShowBrowseMore(available: availableThemes) { Section { - Picker(L10n.Settings.Appearance.Scheme.header, selection: schemeBinding) { - ForEach(AppColorSchemePreference.allCases) { preference in - Text(schemeLabel(preference)).tag(preference) - } - } - .pickerStyle(.menu) + NavigationLink(L10n.Settings.Appearance.MoreThemes.link) { + SupportDevelopmentView() + } } .themedRowBackground(activeTheme) + } } + .themedCanvas(activeTheme) + .navigationTitle(L10n.Settings.Appearance.title) + .navigationBarTitleDisplayMode(.inline) + .sensoryFeedback(.selection, trigger: schemeTrigger) + .sensoryFeedback(.selection, trigger: selectionTrigger) + } - private var themesSection: some View { - Section { - LazyVGrid(columns: columns, spacing: ThemeCardMetrics.gridSpacing) { - ForEach(availableThemes) { theme in - ThemeSelectionCard( - theme: theme, - isSelected: theme.id == activeTheme.id, - onSelect: { select(theme) } - ) - } - } - .listRowInsets(ThemeCardMetrics.gridRowInsets) - .listRowBackground(Color.clear) - } header: { - Text(L10n.Settings.Appearance.Themes.header) + private var schemeSection: some View { + Section { + Picker(L10n.Settings.Appearance.Scheme.header, selection: schemeBinding) { + ForEach(AppColorSchemePreference.allCases) { preference in + Text(schemeLabel(preference)).tag(preference) } + } + .pickerStyle(.menu) } + .themedRowBackground(activeTheme) + } - private var schemeBinding: Binding { - Binding( - get: { themeService.colorSchemePreference }, - set: { - themeService.setColorSchemePreference($0) - schemeTrigger += 1 - } - ) - } - - private func schemeLabel(_ preference: AppColorSchemePreference) -> String { - switch preference { - case .system: L10n.Settings.Appearance.Scheme.system - case .light: L10n.Settings.Appearance.Scheme.light - case .dark: L10n.Settings.Appearance.Scheme.dark + private var themesSection: some View { + Section { + LazyVGrid(columns: columns, spacing: ThemeCardMetrics.gridSpacing) { + ForEach(availableThemes) { theme in + ThemeSelectionCard( + theme: theme, + isSelected: theme.id == activeTheme.id, + onSelect: { select(theme) } + ) } + } + .listRowInsets(ThemeCardMetrics.gridRowInsets) + .listRowBackground(Color.clear) + } header: { + Text(L10n.Settings.Appearance.Themes.header) } + } + + private var schemeBinding: Binding { + Binding( + get: { themeService.colorSchemePreference }, + set: { + themeService.setColorSchemePreference($0) + schemeTrigger += 1 + } + ) + } - private func select(_ theme: Theme) { - // The list is already filtered to accessible themes; the throw path is a defensive - // guard against a refund-driven revert landing mid-tap. - try? themeService.setCurrent(theme) - selectionTrigger += 1 + private func schemeLabel(_ preference: AppColorSchemePreference) -> String { + switch preference { + case .system: L10n.Settings.Appearance.Scheme.system + case .light: L10n.Settings.Appearance.Scheme.light + case .dark: L10n.Settings.Appearance.Scheme.dark } + } + + private func select(_ theme: Theme) { + // The list is already filtered to accessible themes; the throw path is a defensive + // guard against a refund-driven revert landing mid-tap. + try? themeService.setCurrent(theme) + selectionTrigger += 1 + } } diff --git a/MC1/Views/Appearance/Components/ThemeSelectionCard.swift b/MC1/Views/Appearance/Components/ThemeSelectionCard.swift index 24725208..be471b89 100644 --- a/MC1/Views/Appearance/Components/ThemeSelectionCard.swift +++ b/MC1/Views/Appearance/Components/ThemeSelectionCard.swift @@ -2,41 +2,41 @@ import SwiftUI /// An owned theme on the Appearance screen: swatch + name + (Selected badge | tap to apply). struct ThemeSelectionCard: View { - let theme: Theme - let isSelected: Bool - let onSelect: () -> Void + let theme: Theme + let isSelected: Bool + let onSelect: () -> Void - var body: some View { - Button(action: onSelect) { - VStack(spacing: 8) { - ThemePaletteSwatch(theme: theme) - Text(theme.localizedName) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - if isSelected { - HStack(spacing: ThemeCardMetrics.badgeIconSpacing) { - Image(systemName: "checkmark") - Text(L10n.Settings.Appearance.Themes.selected) - } - .font(.caption.weight(.semibold)) - .foregroundStyle(.primary) - } else { - Color.clear.frame(height: 1) - } - } - .padding(ThemeCardMetrics.contentPadding) - .frame(maxWidth: .infinity) - .overlay( - RoundedRectangle(cornerRadius: ThemeCardMetrics.cornerRadius) - .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: ThemeCardMetrics.selectionStrokeWidth) - ) + var body: some View { + Button(action: onSelect) { + VStack(spacing: 8) { + ThemePaletteSwatch(theme: theme) + Text(theme.localizedName) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + if isSelected { + HStack(spacing: ThemeCardMetrics.badgeIconSpacing) { + Image(systemName: "checkmark") + Text(L10n.Settings.Appearance.Themes.selected) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + } else { + Color.clear.frame(height: 1) } - .buttonStyle(.plain) - .disabled(isSelected) - .accessibilityElement(children: .ignore) - .accessibilityLabel(isSelected - ? L10n.Settings.Appearance.Accessibility.ThemeCard.selectedLabel(theme.localizedName) - : L10n.Settings.Appearance.Accessibility.ThemeCard.ownedLabel(theme.localizedName)) - .accessibilityHint(isSelected ? "" : L10n.Settings.Appearance.Accessibility.ThemeCard.ownedHint) + } + .padding(ThemeCardMetrics.contentPadding) + .frame(maxWidth: .infinity) + .overlay( + RoundedRectangle(cornerRadius: ThemeCardMetrics.cornerRadius) + .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: ThemeCardMetrics.selectionStrokeWidth) + ) } + .buttonStyle(.plain) + .disabled(isSelected) + .accessibilityElement(children: .ignore) + .accessibilityLabel(isSelected + ? L10n.Settings.Appearance.Accessibility.ThemeCard.selectedLabel(theme.localizedName) + : L10n.Settings.Appearance.Accessibility.ThemeCard.ownedLabel(theme.localizedName)) + .accessibilityHint(isSelected ? "" : L10n.Settings.Appearance.Accessibility.ThemeCard.ownedHint) + } } diff --git a/MC1/Views/Chats/BlockSenderContext.swift b/MC1/Views/Chats/BlockSenderContext.swift index f3cf70e5..f72f743b 100644 --- a/MC1/Views/Chats/BlockSenderContext.swift +++ b/MC1/Views/Chats/BlockSenderContext.swift @@ -2,7 +2,7 @@ import Foundation /// Context for presenting the block-sender sheet in channel conversations. struct BlockSenderContext: Identifiable { - let id = UUID() - let senderName: String - let radioID: UUID + let id = UUID() + let senderName: String + let radioID: UUID } diff --git a/MC1/Views/Chats/ChannelAvatar.swift b/MC1/Views/Chats/ChannelAvatar.swift index 40297e01..de5cd7ad 100644 --- a/MC1/Views/Chats/ChannelAvatar.swift +++ b/MC1/Views/Chats/ChannelAvatar.swift @@ -1,35 +1,35 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChannelAvatar: View { - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - let channel: ChannelDTO - let size: CGFloat + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + let channel: ChannelDTO + let size: CGFloat - var body: some View { - ZStack { - Circle() - .fill(fill) + var body: some View { + ZStack { + Circle() + .fill(fill) - Image(systemName: channel.isPublicChannel ? "globe" : (channel.name.hasPrefix("#") ? "number" : "lock")) - .font(.system(size: size * 0.4, weight: .bold)) - .foregroundStyle(glyph) - } - .frame(width: size, height: size) + Image(systemName: channel.isPublicChannel ? "globe" : (channel.name.hasPrefix("#") ? "number" : "lock")) + .font(.system(size: size * 0.4, weight: .bold)) + .foregroundStyle(glyph) } + .frame(width: size, height: size) + } - private var fill: Color { - theme.categoryAvatarColor(.channel, colorScheme: colorScheme, contrast: colorSchemeContrast) - } + private var fill: Color { + theme.categoryAvatarColor(.channel, colorScheme: colorScheme, contrast: colorSchemeContrast) + } - private var glyph: Color { - theme.avatarGlyphColor( - forFill: fill, - usesCategoryOverride: theme.usesCategoryAvatarOverride, - colorScheme: colorScheme, - contrast: colorSchemeContrast - ) - } + private var glyph: Color { + theme.avatarGlyphColor( + forFill: fill, + usesCategoryOverride: theme.usesCategoryAvatarOverride, + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + } } diff --git a/MC1/Views/Chats/ChannelConversationRow.swift b/MC1/Views/Chats/ChannelConversationRow.swift index c7f972c6..937a172f 100644 --- a/MC1/Views/Chats/ChannelConversationRow.swift +++ b/MC1/Views/Chats/ChannelConversationRow.swift @@ -1,54 +1,54 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChannelConversationRow: View { - private typealias Strings = L10n.Chats.Chats.Row - let channel: ChannelDTO - let viewModel: ChatViewModel - var referenceDate: Date? - - var body: some View { - HStack(spacing: 12) { - ChannelAvatar(channel: channel, size: 44) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text(channel.displayName) - .font(.headline) - .lineLimit(1) - - Spacer() - - NotificationLevelIndicator(level: channel.notificationLevel) - - if channel.isFavorite { - Image(systemName: "star.fill") - .foregroundStyle(.yellow) - .font(.caption) - .accessibilityLabel(Strings.favorite) - } - - if let date = channel.lastMessageDate { - ConversationTimestamp(date: date, referenceDate: referenceDate) - } - } - - HStack { - Text(viewModel.lastMessagePreview(id: channel.id) ?? Strings.noMessages) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - - Spacer() - - UnreadBadges( - unreadCount: channel.unreadCount, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: channel.notificationLevel - ) - } - } + private typealias Strings = L10n.Chats.Chats.Row + let channel: ChannelDTO + let viewModel: ChatViewModel + var referenceDate: Date? + + var body: some View { + HStack(spacing: 12) { + ChannelAvatar(channel: channel, size: 44) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(channel.displayName) + .font(.headline) + .lineLimit(1) + + Spacer() + + NotificationLevelIndicator(level: channel.notificationLevel) + + if channel.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .font(.caption) + .accessibilityLabel(Strings.favorite) + } + + if let date = channel.lastMessageDate { + ConversationTimestamp(date: date, referenceDate: referenceDate) + } + } + + HStack { + Text(viewModel.lastMessagePreview(id: channel.id) ?? Strings.noMessages) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + UnreadBadges( + unreadCount: channel.unreadCount, + unreadMentionCount: channel.unreadMentionCount, + notificationLevel: channel.notificationLevel + ) } - .padding(.vertical, 4) + } } + .padding(.vertical, 4) + } } diff --git a/MC1/Views/Chats/ChatConversationActions.swift b/MC1/Views/Chats/ChatConversationActions.swift index a9e6f88b..8aa79a4f 100644 --- a/MC1/Views/Chats/ChatConversationActions.swift +++ b/MC1/Views/Chats/ChatConversationActions.swift @@ -1,52 +1,51 @@ -import SwiftUI import MC1Services +import SwiftUI /// Layout-independent conversation actions shared by the compact `ChatsView` (stack) and the iPad /// `ChatsContentColumn` (split). Both list layouts run these service sequences identically; only the /// navigation glue around them (which stack/selection to update) differs, so that stays in each view. enum ChatConversationActions { + /// A channel deletion that failed, surfaced in a retry alert. + struct Failure { + let channel: ChannelDTO + let message: String + } - /// A channel deletion that failed, surfaced in a retry alert. - struct Failure { - let channel: ChannelDTO - let message: String - } + enum DeleteError: LocalizedError { + case servicesUnavailable - enum DeleteError: LocalizedError { - case servicesUnavailable - - var errorDescription: String? { - switch self { - case .servicesUnavailable: L10n.Chats.Chats.Error.servicesUnavailable - } - } + var errorDescription: String? { + switch self { + case .servicesUnavailable: L10n.Chats.Chats.Error.servicesUnavailable + } } + } - /// Clears a channel on the radio, then removes its delivered notifications and refreshes the badge. - @MainActor - static func deleteChannel(_ channel: ChannelDTO, appState: AppState) async throws { - guard let channelService = appState.services?.channelService else { - throw DeleteError.servicesUnavailable - } - try await channelService.clearChannel(radioID: channel.radioID, index: channel.index) - await appState.services?.notificationService.removeDeliveredNotifications( - forChannelIndex: channel.index, - radioID: channel.radioID - ) - await appState.services?.notificationService.updateBadgeCount() + /// Clears a channel on the radio, then removes its delivered notifications and refreshes the badge. + @MainActor + static func deleteChannel(_ channel: ChannelDTO, appState: AppState) async throws { + guard let channelService = appState.services?.channelService else { + throw DeleteError.servicesUnavailable } + try await channelService.clearChannel(radioID: channel.radioID, index: channel.index) + await appState.services?.notificationService.removeDeliveredNotifications( + forChannelIndex: channel.index, + radioID: channel.radioID + ) + await appState.services?.notificationService.updateBadgeCount() + } - /// Leaves a room session and removes its backing contact, then refreshes the badge. - @MainActor - static func leaveRoom(_ session: RemoteNodeSessionDTO, appState: AppState) async throws { - try await appState.services?.roomServerService.leaveRoom( - sessionID: session.id, - publicKey: session.publicKey - ) - try await appState.services?.contactService.removeContact( - radioID: session.radioID, - publicKey: session.publicKey - ) - await appState.services?.notificationService.updateBadgeCount() - } + /// Leaves a room session and removes its backing contact, then refreshes the badge. + @MainActor + static func leaveRoom(_ session: RemoteNodeSessionDTO, appState: AppState) async throws { + try await appState.services?.roomServerService.leaveRoom( + sessionID: session.id, + publicKey: session.publicKey + ) + try await appState.services?.contactService.removeContact( + radioID: session.radioID, + publicKey: session.publicKey + ) + await appState.services?.notificationService.updateBadgeCount() + } } diff --git a/MC1/Views/Chats/ChatConversationInputBar.swift b/MC1/Views/Chats/ChatConversationInputBar.swift index 89ddf7f6..1885cc41 100644 --- a/MC1/Views/Chats/ChatConversationInputBar.swift +++ b/MC1/Views/Chats/ChatConversationInputBar.swift @@ -1,59 +1,59 @@ -import SwiftUI import MC1Services +import SwiftUI /// Input bar for chat conversations, configured per conversation type. struct ChatConversationInputBar: View { - let conversationType: ChatConversationType - @Binding var composingText: String - @Binding var focusRequest: Int - let nodeNameByteCount: Int - let onSend: (String) async -> Void - let onWillSend: () -> Void + let conversationType: ChatConversationType + @Binding var composingText: String + @Binding var focusRequest: Int + let nodeNameByteCount: Int + let onSend: (String) async -> Void + let onWillSend: () -> Void - var body: some View { - switch conversationType { - case .dm: - ChatInputBar( - text: $composingText, - focusRequest: focusRequest, - placeholder: L10n.Chats.Chats.Input.Placeholder.directMessage, - maxBytes: ProtocolLimits.maxDirectMessageLength, - isEncrypted: true, - leading: { ChatShareMenu(onInsert: insertShared) } - ) { text in - onWillSend() - Task { await onSend(text) } - } + var body: some View { + switch conversationType { + case .dm: + ChatInputBar( + text: $composingText, + focusRequest: focusRequest, + placeholder: L10n.Chats.Chats.Input.Placeholder.directMessage, + maxBytes: ProtocolLimits.maxDirectMessageLength, + isEncrypted: true, + leading: { ChatShareMenu(onInsert: insertShared) } + ) { text in + onWillSend() + Task { await onSend(text) } + } - case .channel(let channel): - let maxBytes = ProtocolLimits.maxChannelMessageLength( - nodeNameByteCount: nodeNameByteCount - ) - ChatInputBar( - text: $composingText, - focusRequest: focusRequest, - placeholder: conversationType.isPublicStyleChannel - ? L10n.Chats.Chats.Channel.typePublic - : L10n.Chats.Chats.Channel.typePrivate, - maxBytes: maxBytes, - isEncrypted: channel.isEncryptedChannel, - leading: { ChatShareMenu(onInsert: insertShared) } - ) { text in - onWillSend() - Task { await onSend(text) } - } - } + case let .channel(channel): + let maxBytes = ProtocolLimits.maxChannelMessageLength( + nodeNameByteCount: nodeNameByteCount + ) + ChatInputBar( + text: $composingText, + focusRequest: focusRequest, + placeholder: conversationType.isPublicStyleChannel + ? L10n.Chats.Chats.Channel.typePublic + : L10n.Chats.Chats.Channel.typePrivate, + maxBytes: maxBytes, + isEncrypted: channel.isEncryptedChannel, + leading: { ChatShareMenu(onInsert: insertShared) } + ) { text in + onWillSend() + Task { await onSend(text) } + } } + } - /// Appends a shared token to the compose field and focuses it, matching the - /// reply and mention insertion flow. A single space separates the token from - /// existing text only when the field is non-empty and does not already end in - /// whitespace. - private func insertShared(_ shared: String) { - if !composingText.isEmpty, let last = composingText.last, !last.isWhitespace { - composingText.append(" ") - } - composingText.append(shared) - focusRequest += 1 + /// Appends a shared token to the compose field and focuses it, matching the + /// reply and mention insertion flow. A single space separates the token from + /// existing text only when the field is non-empty and does not already end in + /// whitespace. + private func insertShared(_ shared: String) { + if !composingText.isEmpty, let last = composingText.last, !last.isWhitespace { + composingText.append(" ") } + composingText.append(shared) + focusRequest += 1 + } } diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index 2fdc1232..32746b40 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -1,232 +1,232 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "ChatConversationMessagesContent") /// Unified inner content view for both DM and Channel conversations. /// Handles loading state, empty state, message table, bubble construction, and overlay buttons. struct ChatConversationMessagesContent: View { - // MARK: - Identity - - let conversationType: ChatConversationType - @Bindable var viewModel: ChatViewModel - let deviceName: String - let recentEmojisStore: RecentEmojisStore - - // MARK: - Display Preferences - - let envInputs: EnvInputs - - // MARK: - Scroll State Bindings - - @Binding var isAtBottom: Bool - @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - @Binding var scrollToMentionRequest: Int - @Binding var scrollToDividerRequest: Int - @Binding var isDividerVisible: Bool - - // MARK: - Mention State - - let unseenMentionIDs: [UUID] - @Binding var offscreenMentionIDs: [UUID] - let scrollToTargetID: UUID? - let newMessagesDividerMessageID: UUID? - - // MARK: - Sheet State Bindings - - @Binding var selectedMessageForActions: MessageDTO? - @Binding var imageViewerData: ImageViewerData? - - // MARK: - Callbacks - - let onMentionSeen: (UUID) async -> Bool - let onScrollToMention: () -> Void - let onRetryMessage: (MessageDTO) -> Void - - // MARK: - Body - - var body: some View { - Group { - if viewModel.renderState.phase == .loaded, viewModel.messages.isEmpty { - emptyState - } else if viewModel.messages.isEmpty { - Color.clear - } else { - ChatMessagesTableView( - viewModel: viewModel, - contactName: conversationType.navigationTitle, - deviceName: deviceName, - configuration: bubbleConfiguration, - recentEmojisStore: recentEmojisStore, - envInputs: envInputs, - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - scrollToDividerRequest: $scrollToDividerRequest, - isDividerVisible: $isDividerVisible, - selectedMessageForActions: $selectedMessageForActions, - imageViewerData: $imageViewerData, - unseenMentionIDs: unseenMentionIDs, - offscreenMentionIDs: $offscreenMentionIDs, - scrollToTargetID: scrollToTargetID, - newMessagesDividerMessageID: newMessagesDividerMessageID, - onMentionSeen: onMentionSeen, - onScrollToMention: onScrollToMention, - onRetryMessage: onRetryMessage - ) - } - } + // MARK: - Identity + + let conversationType: ChatConversationType + @Bindable var viewModel: ChatViewModel + let deviceName: String + let recentEmojisStore: RecentEmojisStore + + // MARK: - Display Preferences + + let envInputs: EnvInputs + + // MARK: - Scroll State Bindings + + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + @Binding var scrollToMentionRequest: Int + @Binding var scrollToDividerRequest: Int + @Binding var isDividerVisible: Bool + + // MARK: - Mention State + + let unseenMentionIDs: [UUID] + @Binding var offscreenMentionIDs: [UUID] + let scrollToTargetID: UUID? + let newMessagesDividerMessageID: UUID? + + // MARK: - Sheet State Bindings + + @Binding var selectedMessageForActions: MessageDTO? + @Binding var imageViewerData: ImageViewerData? + + // MARK: - Callbacks + + let onMentionSeen: (UUID) async -> Bool + let onScrollToMention: () -> Void + let onRetryMessage: (MessageDTO) -> Void + + // MARK: - Body + + var body: some View { + Group { + if viewModel.renderState.phase == .loaded, viewModel.messages.isEmpty { + emptyState + } else if viewModel.messages.isEmpty { + Color.clear + } else { + ChatMessagesTableView( + viewModel: viewModel, + contactName: conversationType.navigationTitle, + deviceName: deviceName, + configuration: bubbleConfiguration, + recentEmojisStore: recentEmojisStore, + envInputs: envInputs, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + scrollToDividerRequest: $scrollToDividerRequest, + isDividerVisible: $isDividerVisible, + selectedMessageForActions: $selectedMessageForActions, + imageViewerData: $imageViewerData, + unseenMentionIDs: unseenMentionIDs, + offscreenMentionIDs: $offscreenMentionIDs, + scrollToTargetID: scrollToTargetID, + newMessagesDividerMessageID: newMessagesDividerMessageID, + onMentionSeen: onMentionSeen, + onScrollToMention: onScrollToMention, + onRetryMessage: onRetryMessage + ) + } } - - // MARK: - Empty State - - @ViewBuilder - private var emptyState: some View { - switch conversationType { - case .dm(let contact): - DMEmptyMessagesView(contact: contact) - case .channel(let channel): - ChannelEmptyMessagesView( - channel: channel, - displayName: conversationType.navigationTitle, - isPublicStyle: conversationType.isPublicStyleChannel - ) - } + } + + // MARK: - Empty State + + @ViewBuilder + private var emptyState: some View { + switch conversationType { + case let .dm(contact): + DMEmptyMessagesView(contact: contact) + case let .channel(channel): + ChannelEmptyMessagesView( + channel: channel, + displayName: conversationType.navigationTitle, + isPublicStyle: conversationType.isPublicStyleChannel + ) } + } - // MARK: - Bubble Configuration + // MARK: - Bubble Configuration - private var bubbleConfiguration: MessageBubbleConfiguration { - switch conversationType { - case .dm: - .directMessage - case .channel: - .channel(isPublic: conversationType.isPublicStyleChannel) - } + private var bubbleConfiguration: MessageBubbleConfiguration { + switch conversationType { + case .dm: + .directMessage + case .channel: + .channel(isPublic: conversationType.isPublicStyleChannel) } + } } // MARK: - DM Empty Messages View private struct DMEmptyMessagesView: View { - let contact: ContactDTO - - var body: some View { - VStack(spacing: 16) { - ContactAvatar(contact: contact, size: 80) - - Text(contact.displayName) - .font(.title2) - .bold() - - Text(L10n.Chats.Chats.EmptyState.startConversation) - .foregroundStyle(.secondary) - - if contact.hasLocation { - Label(L10n.Chats.Chats.ContactInfo.hasLocation, systemImage: "location.fill") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() + let contact: ContactDTO + + var body: some View { + VStack(spacing: 16) { + ContactAvatar(contact: contact, size: 80) + + Text(contact.displayName) + .font(.title2) + .bold() + + Text(L10n.Chats.Chats.EmptyState.startConversation) + .foregroundStyle(.secondary) + + if contact.hasLocation { + Label(L10n.Chats.Chats.ContactInfo.hasLocation, systemImage: "location.fill") + .font(.caption) + .foregroundStyle(.secondary) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } } // MARK: - Channel Empty Messages View private struct ChannelEmptyMessagesView: View { - let channel: ChannelDTO - let displayName: String - let isPublicStyle: Bool - - var body: some View { - VStack(spacing: 16) { - ChannelAvatar(channel: channel, size: 80) - - Text(displayName) - .font(.title2) - .bold() - - Text(L10n.Chats.Chats.Channel.EmptyState.noMessages) - .foregroundStyle(.secondary) - - Text(isPublicStyle - ? L10n.Chats.Chats.Channel.EmptyState.publicDescription - : L10n.Chats.Chats.Channel.EmptyState.privateDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() + let channel: ChannelDTO + let displayName: String + let isPublicStyle: Bool + + var body: some View { + VStack(spacing: 16) { + ChannelAvatar(channel: channel, size: 80) + + Text(displayName) + .font(.title2) + .bold() + + Text(L10n.Chats.Chats.Channel.EmptyState.noMessages) + .foregroundStyle(.secondary) + + Text(isPublicStyle + ? L10n.Chats.Chats.Channel.EmptyState.publicDescription + : L10n.Chats.Chats.Channel.EmptyState.privateDescription) + .font(.caption) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } } // MARK: - Previews #Preview("DM Conversation") { - NavigationStack { - ChatConversationMessagesContent( - conversationType: .dm(ContactDTO(from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Alice" - ))), - viewModel: ChatViewModel(), - deviceName: "My Device", - recentEmojisStore: RecentEmojisStore(), - envInputs: .default, - isAtBottom: .constant(true), - unreadCount: .constant(0), - scrollToBottomRequest: .constant(0), - scrollToMentionRequest: .constant(0), - scrollToDividerRequest: .constant(0), - isDividerVisible: .constant(false), - unseenMentionIDs: [], - offscreenMentionIDs: .constant([]), - scrollToTargetID: nil, - newMessagesDividerMessageID: nil, - selectedMessageForActions: .constant(nil), - imageViewerData: .constant(nil), - onMentionSeen: { _ in true }, - onScrollToMention: {}, - onRetryMessage: { _ in } - ) - } - .environment(\.appState, AppState()) + NavigationStack { + ChatConversationMessagesContent( + conversationType: .dm(ContactDTO(from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Alice" + ))), + viewModel: ChatViewModel(), + deviceName: "My Device", + recentEmojisStore: RecentEmojisStore(), + envInputs: .default, + isAtBottom: .constant(true), + unreadCount: .constant(0), + scrollToBottomRequest: .constant(0), + scrollToMentionRequest: .constant(0), + scrollToDividerRequest: .constant(0), + isDividerVisible: .constant(false), + unseenMentionIDs: [], + offscreenMentionIDs: .constant([]), + scrollToTargetID: nil, + newMessagesDividerMessageID: nil, + selectedMessageForActions: .constant(nil), + imageViewerData: .constant(nil), + onMentionSeen: { _ in true }, + onScrollToMention: {}, + onRetryMessage: { _ in } + ) + } + .environment(\.appState, AppState()) } #Preview("Channel Conversation") { - NavigationStack { - ChatConversationMessagesContent( - conversationType: .channel(ChannelDTO(from: Channel( - radioID: UUID(), - index: 1, - name: "General" - ))), - viewModel: ChatViewModel(), - deviceName: "My Device", - recentEmojisStore: RecentEmojisStore(), - envInputs: .default, - isAtBottom: .constant(true), - unreadCount: .constant(0), - scrollToBottomRequest: .constant(0), - scrollToMentionRequest: .constant(0), - scrollToDividerRequest: .constant(0), - isDividerVisible: .constant(false), - unseenMentionIDs: [], - offscreenMentionIDs: .constant([]), - scrollToTargetID: nil, - newMessagesDividerMessageID: nil, - selectedMessageForActions: .constant(nil), - imageViewerData: .constant(nil), - onMentionSeen: { _ in true }, - onScrollToMention: {}, - onRetryMessage: { _ in } - ) - } - .environment(\.appState, AppState()) + NavigationStack { + ChatConversationMessagesContent( + conversationType: .channel(ChannelDTO(from: Channel( + radioID: UUID(), + index: 1, + name: "General" + ))), + viewModel: ChatViewModel(), + deviceName: "My Device", + recentEmojisStore: RecentEmojisStore(), + envInputs: .default, + isAtBottom: .constant(true), + unreadCount: .constant(0), + scrollToBottomRequest: .constant(0), + scrollToMentionRequest: .constant(0), + scrollToDividerRequest: .constant(0), + isDividerVisible: .constant(false), + unseenMentionIDs: [], + offscreenMentionIDs: .constant([]), + scrollToTargetID: nil, + newMessagesDividerMessageID: nil, + selectedMessageForActions: .constant(nil), + imageViewerData: .constant(nil), + onMentionSeen: { _ in true }, + onScrollToMention: {}, + onRetryMessage: { _ in } + ) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/ChatConversationType.swift b/MC1/Views/Chats/ChatConversationType.swift index cdf43265..42a50cee 100644 --- a/MC1/Views/Chats/ChatConversationType.swift +++ b/MC1/Views/Chats/ChatConversationType.swift @@ -3,152 +3,152 @@ import MC1Services /// Conversation type discriminator for the unified chat view. /// Not `@MainActor` — no mutable state. `@State` on the view provides main-actor isolation. -enum ChatConversationType: Sendable { - case dm(ContactDTO) - case channel(ChannelDTO) - - // MARK: - Computed Properties - - var navigationTitle: String { - switch self { - case .dm(let contact): - contact.displayName - case .channel(let channel): - channel.displayName - } +enum ChatConversationType { + case dm(ContactDTO) + case channel(ChannelDTO) + + // MARK: - Computed Properties + + var navigationTitle: String { + switch self { + case let .dm(contact): + contact.displayName + case let .channel(channel): + channel.displayName } - - func navigationSubtitle(deviceDefaultFloodScopeName: String?) -> String { - switch self { - case .dm(let contact): - if contact.isFloodRouted { - return L10n.Chats.Chats.ConnectionStatus.floodRouting - } else { - return L10n.Chats.Chats.ConnectionStatus.direct(contact.pathHopCount) - } - case .channel(let channel): - let base = channelTypeSubtitle(for: channel) - if let region = effectiveRegionName(for: channel, deviceDefaultFloodScopeName: deviceDefaultFloodScopeName) { - let regionDisplay = (region == deviceDefaultFloodScopeName) - ? L10n.Chats.Chats.ChannelInfo.Region.scopedDefault(region) - : region - return "\(base) \u{00B7} \(regionDisplay)" - } - return base - } - } - - /// Accessibility label for the subtitle, providing a VoiceOver-friendly description - /// when a region scope is active (the middle dot separator may be read literally). - func navigationSubtitleAccessibilityLabel(deviceDefaultFloodScopeName: String?) -> String? { - switch self { - case .dm: - return nil - case .channel(let channel): - guard let region = effectiveRegionName(for: channel, deviceDefaultFloodScopeName: deviceDefaultFloodScopeName) else { - return nil - } - let typeSubtitle = channelTypeSubtitle(for: channel) - if region == deviceDefaultFloodScopeName { - return L10n.Chats.Chats.ChannelInfo.Region.defaultScopedAccessibility(typeSubtitle, region) - } - return L10n.Chats.Chats.ChannelInfo.Region.scopedAccessibility(typeSubtitle, region) - } - } - - // MARK: - Private Helpers - - private func channelTypeSubtitle(for channel: ChannelDTO) -> String { - if channel.isPublicChannel { - L10n.Chats.Chats.Channel.typePublic - } else if channel.name.hasPrefix("#") { - L10n.Chats.Chats.ChannelInfo.ChannelType.hashtag - } else { - L10n.Chats.Chats.Channel.typePrivate - } + } + + func navigationSubtitle(deviceDefaultFloodScopeName: String?) -> String { + switch self { + case let .dm(contact): + if contact.isFloodRouted { + return L10n.Chats.Chats.ConnectionStatus.floodRouting + } else { + return L10n.Chats.Chats.ConnectionStatus.direct(contact.pathHopCount) + } + case let .channel(channel): + let base = channelTypeSubtitle(for: channel) + if let region = effectiveRegionName(for: channel, deviceDefaultFloodScopeName: deviceDefaultFloodScopeName) { + let regionDisplay = (region == deviceDefaultFloodScopeName) + ? L10n.Chats.Chats.ChannelInfo.Region.scopedDefault(region) + : region + return "\(base) \u{00B7} \(regionDisplay)" + } + return base } - - /// Resolves the region name to display alongside the channel subtitle. Delegates to - /// ``ChannelFloodScopeResolver`` so the banner stays in sync with the FloodScope - /// actually pushed to the radio. - private func effectiveRegionName( - for channel: ChannelDTO, - deviceDefaultFloodScopeName: String? - ) -> String? { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: channel.floodScope, - deviceDefaultFloodScopeName: deviceDefaultFloodScopeName, - supportsUnscopedFloodSend: false - ) - if case .scope(.region(let name)) = resolved { return name } + } + + /// Accessibility label for the subtitle, providing a VoiceOver-friendly description + /// when a region scope is active (the middle dot separator may be read literally). + func navigationSubtitleAccessibilityLabel(deviceDefaultFloodScopeName: String?) -> String? { + switch self { + case .dm: + return nil + case let .channel(channel): + guard let region = effectiveRegionName(for: channel, deviceDefaultFloodScopeName: deviceDefaultFloodScopeName) else { return nil + } + let typeSubtitle = channelTypeSubtitle(for: channel) + if region == deviceDefaultFloodScopeName { + return L10n.Chats.Chats.ChannelInfo.Region.defaultScopedAccessibility(typeSubtitle, region) + } + return L10n.Chats.Chats.ChannelInfo.Region.scopedAccessibility(typeSubtitle, region) } + } - var conversationID: UUID { - switch self { - case .dm(let contact): - contact.id - case .channel(let channel): - channel.id - } - } + // MARK: - Private Helpers - /// Stable key for the per-radio draft store. The channel case keys on the slot - /// `index`, not `conversationID` (a UUID), to align with the slot-based draft - /// cleanup on channel delete, sync prune, and backup-import relocation. - var draftConversationID: ChatConversationID { - switch self { - case .dm(let contact): - .dm(radioID: contact.radioID, contactID: contact.id) - case .channel(let channel): - .channel(radioID: channel.radioID, channelIndex: channel.index) - } + private func channelTypeSubtitle(for channel: ChannelDTO) -> String { + if channel.isPublicChannel { + L10n.Chats.Chats.Channel.typePublic + } else if channel.name.hasPrefix("#") { + L10n.Chats.Chats.ChannelInfo.ChannelType.hashtag + } else { + L10n.Chats.Chats.Channel.typePrivate } - - var radioID: UUID { - switch self { - case .dm(let contact): - contact.radioID - case .channel(let channel): - channel.radioID - } + } + + /// Resolves the region name to display alongside the channel subtitle. Delegates to + /// ``ChannelFloodScopeResolver`` so the banner stays in sync with the FloodScope + /// actually pushed to the radio. + private func effectiveRegionName( + for channel: ChannelDTO, + deviceDefaultFloodScopeName: String? + ) -> String? { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: channel.floodScope, + deviceDefaultFloodScopeName: deviceDefaultFloodScopeName, + supportsUnscopedFloodSend: false + ) + if case let .scope(.region(name)) = resolved { return name } + return nil + } + + var conversationID: UUID { + switch self { + case let .dm(contact): + contact.id + case let .channel(channel): + channel.id } - - var isPublicStyleChannel: Bool { - switch self { - case .dm: - false - case .channel(let channel): - !channel.isEncryptedChannel - } + } + + /// Stable key for the per-radio draft store. The channel case keys on the slot + /// `index`, not `conversationID` (a UUID), to align with the slot-based draft + /// cleanup on channel delete, sync prune, and backup-import relocation. + var draftConversationID: ChatConversationID { + switch self { + case let .dm(contact): + .dm(radioID: contact.radioID, contactID: contact.id) + case let .channel(channel): + .channel(radioID: channel.radioID, channelIndex: channel.index) } - - /// Channels with this name (case-insensitive) suppress the inline map-preview - /// thumbnail, so the app doesn't flood the map API - private static let mapPreviewSuppressedChannelName = "wardriving" - - /// Whether map preview thumbnails should be hidden for this conversation, - /// independent of the global show-map-previews setting. DMs never suppress. - /// Matches case-insensitively and tolerates the leading "#" hashtag-channel - /// convention, so both "wardriving" and "#wardriving" suppress. - var suppressesMapPreviews: Bool { - guard case .channel(let channel) = self else { return false } - let trimmed = channel.name.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - return normalized.caseInsensitiveCompare(Self.mapPreviewSuppressedChannelName) == .orderedSame + } + + var radioID: UUID { + switch self { + case let .dm(contact): + contact.radioID + case let .channel(channel): + channel.radioID } - - // MARK: - Transforms - - /// Returns a copy with the contact replaced (DM only). Returns self unchanged for channels. - func replacingContact(_ contact: ContactDTO) -> ChatConversationType { - guard case .dm = self else { return self } - return .dm(contact) - } - - /// Returns a copy with the channel replaced (channel only). Returns self unchanged for DMs. - func replacingChannel(_ channel: ChannelDTO) -> ChatConversationType { - guard case .channel = self else { return self } - return .channel(channel) + } + + var isPublicStyleChannel: Bool { + switch self { + case .dm: + false + case let .channel(channel): + !channel.isEncryptedChannel } + } + + /// Channels with this name (case-insensitive) suppress the inline map-preview + /// thumbnail, so the app doesn't flood the map API + private static let mapPreviewSuppressedChannelName = "wardriving" + + /// Whether map preview thumbnails should be hidden for this conversation, + /// independent of the global show-map-previews setting. DMs never suppress. + /// Matches case-insensitively and tolerates the leading "#" hashtag-channel + /// convention, so both "wardriving" and "#wardriving" suppress. + var suppressesMapPreviews: Bool { + guard case let .channel(channel) = self else { return false } + let trimmed = channel.name.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + return normalized.caseInsensitiveCompare(Self.mapPreviewSuppressedChannelName) == .orderedSame + } + + // MARK: - Transforms + + /// Returns a copy with the contact replaced (DM only). Returns self unchanged for channels. + func replacingContact(_ contact: ContactDTO) -> ChatConversationType { + guard case .dm = self else { return self } + return .dm(contact) + } + + /// Returns a copy with the channel replaced (channel only). Returns self unchanged for DMs. + func replacingChannel(_ channel: ChannelDTO) -> ChatConversationType { + guard case .channel = self else { return self } + return .channel(channel) + } } diff --git a/MC1/Views/Chats/ChatConversationView.swift b/MC1/Views/Chats/ChatConversationView.swift index 03926d84..d57e8c97 100644 --- a/MC1/Views/Chats/ChatConversationView.swift +++ b/MC1/Views/Chats/ChatConversationView.swift @@ -1,7 +1,7 @@ -import SwiftUI -import UIKit // UIPasteboard for .copy action import MC1Services import OSLog +import SwiftUI +import UIKit // UIPasteboard for .copy action private let logger = Logger(subsystem: "com.mc1", category: "ChatConversationView") @@ -11,876 +11,876 @@ private let draftSaveDebounce: Duration = .milliseconds(500) /// Unified chat conversation view supporting both DMs and Channels. struct ChatConversationView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(\.linkPreviewCache) private var linkPreviewCache - @Environment(\.scenePhase) private var scenePhase - - @State private var conversationType: ChatConversationType - let parentViewModel: ChatViewModel? - - @State private var chatViewModel = ChatViewModel() - - // MARK: - Scroll State - - @State private var isAtBottom = true - @State private var unreadCount = 0 - @State private var scrollToBottomRequest = 0 - @State private var scrollToMentionRequest = 0 - @State private var unseenMentionIDs: [UUID] = [] - /// The subset of `unseenMentionIDs` currently off screen, reported up by the chat table. - /// Drives the scroll-to-mention button and is the source for its tap target. - @State private var offscreenMentionIDs: [UUID] = [] - @State private var scrollToTargetID: UUID? - @State private var mentionScrollTask: Task? - @State private var scrollToDividerRequest = 0 - @State private var isDividerVisible = false - - /// Pending debounced draft persist; cancelled and restarted on each keystroke, - /// cancelled-then-flushed synchronously on view teardown and app suspension. - @State private var draftSaveTask: Task? - - // MARK: - Sheet State - - @State private var showingInfo = false - @State private var selectedMessageForActions: MessageDTO? - @State private var blockSenderContext: BlockSenderContext? - @State private var sendDMContext: SendDMContext? - @State private var imageViewerData: ImageViewerData? - - // MARK: - Other State - - @State private var recentEmojisStore = RecentEmojisStore() - @State private var mentionSenderOrder: [String: UInt32]? - /// Focus-request token: each increment asks the composer to raise the - /// keyboard once. See `ChatComposerTextView` for why a token, not `@FocusState`. - @State private var inputFocusRequest = 0 - - // MARK: - AppStorage - - @AppStorage(AppStorageKey.showInlineImages.rawValue) private var showInlineImages = AppStorageKey.defaultShowInlineImages - @AppStorage(AppStorageKey.autoPlayGIFs.rawValue) private var autoPlayGIFs = AppStorageKey.defaultAutoPlayGIFs - @AppStorage(AppStorageKey.showIncomingPath.rawValue) private var showIncomingPath = AppStorageKey.defaultShowIncomingPath - @AppStorage(AppStorageKey.showIncomingHopCount.rawValue) private var showIncomingHopCount = AppStorageKey.defaultShowIncomingHopCount - @AppStorage(AppStorageKey.showIncomingRegion.rawValue) private var showIncomingRegion = AppStorageKey.defaultShowIncomingRegion - @AppStorage(AppStorageKey.showIncomingSendTime.rawValue) private var showIncomingSendTime = AppStorageKey.defaultShowIncomingSendTime - @AppStorage(AppStorageKey.linkPreviewsEnabled.rawValue) private var previewsEnabled = AppStorageKey.defaultLinkPreviewsEnabled - @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote - @AppStorage(AppStorageKey.showMapPreviewThumbnails.rawValue) private var showMapPreviewThumbnails = AppStorageKey.defaultShowMapPreviewThumbnails - - // MARK: - Environment - - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.appTheme) private var theme - - /// Snapshot of env-derived inputs the view model needs to construct - /// MessageItems at write time. Recomputed on every render — Equatable - /// drives `.onChange(of: envInputs)` in ChatMessagesTableView. - private var currentEnvInputs: EnvInputs { - EnvInputs( - showInlineImages: showInlineImages, - autoPlayGIFs: autoPlayGIFs, - showIncomingPath: showIncomingPath, - showIncomingHopCount: showIncomingHopCount, - showIncomingRegion: showIncomingRegion, - showIncomingSendTime: showIncomingSendTime, - previewsEnabled: previewsEnabled, - isHighContrast: colorSchemeContrast == .increased, - isDark: colorScheme == .dark, - showMapPreviews: showMapPreviewThumbnails && !conversationType.suppressesMapPreviews, - isOffline: !appState.offlineMapService.isNetworkAvailable, - currentUserName: appState.localNodeName, - themeID: theme.id, - contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) - ) - } - - // MARK: - Init - - init(conversationType: ChatConversationType, parentViewModel: ChatViewModel? = nil) { - self._conversationType = State(initialValue: conversationType) - self.parentViewModel = parentViewModel - } - - // MARK: - Body - - var body: some View { - ChatConversationMessagesContent( - conversationType: conversationType, - viewModel: chatViewModel, - deviceName: appState.localNodeName, - recentEmojisStore: recentEmojisStore, - envInputs: currentEnvInputs, - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - scrollToDividerRequest: $scrollToDividerRequest, - isDividerVisible: $isDividerVisible, - unseenMentionIDs: unseenMentionIDs, - offscreenMentionIDs: $offscreenMentionIDs, - scrollToTargetID: scrollToTargetID, - newMessagesDividerMessageID: chatViewModel.newMessagesDividerMessageID, - selectedMessageForActions: $selectedMessageForActions, - imageViewerData: $imageViewerData, - onMentionSeen: { await markMentionSeen(messageID: $0) }, - onScrollToMention: { scrollToNextMention() }, - onRetryMessage: { retryMessage($0) } - ) - .mentionTapHandling( - contacts: chatViewModel.allContacts, - radioID: conversationType.radioID, - shouldSuppressOpen: { selectedMessageForActions != nil } - ) - // Banner is applied innermost so its safe-area inset stacks above the - // input bar inset that follows, placing the strip between content and - // the input bar (and lifting it with the keyboard). - .chatErrorBanner(chatViewModel: chatViewModel) - .safeAreaInset(edge: .bottom, spacing: 8) { - ChatConversationInputBar( - conversationType: conversationType, - composingText: $chatViewModel.composingText, - focusRequest: $inputFocusRequest, - nodeNameByteCount: appState.connectedDevice?.nodeName.utf8.count ?? 0, - onSend: { text in - switch conversationType { - case .dm: - await chatViewModel.sendMessage(text: text) - case .channel: - await chatViewModel.sendChannelMessage(text: text) - } - }, - onWillSend: { scrollToBottomRequest += 1 } - ) - } - .overlay(alignment: .bottom) { - ChatConversationMentionOverlay( - suggestions: mentionSuggestions, - onSelectMention: { insertMention(for: $0) } - ) - } - .navigationHeader( - title: conversationType.navigationTitle, - subtitle: conversationType.navigationSubtitle( - deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName - ), - subtitleAccessibilityLabel: conversationType.navigationSubtitleAccessibilityLabel( - deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName - ) - ) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(L10n.Chats.Chats.Common.info, systemImage: "info.circle") { - showingInfo = true - } - } - } - // Info sheet — type-specific - .sheet(isPresented: $showingInfo, onDismiss: { - switch conversationType { - case .dm: - Task { await refreshContact() } - case .channel: - Task { await refreshChannel() } - } - }, content: { - ChatConversationInfoSheet( - conversationType: conversationType, - chatViewModel: chatViewModel, - onClearChannelMessages: { - guard case .channel(let channel) = conversationType else { return } - await chatViewModel.loadChannelMessages(for: channel) - parentViewModel?.requestConversationReload() - }, - onClearDirectMessages: { - guard case .dm(let contact) = conversationType else { return } - await chatViewModel.loadMessages(for: contact) - parentViewModel?.requestConversationReload() - }, - onDeleteChannel: { - // Clear the composer so the teardown flush doesn't re-persist a draft for the - // freed slot — a channel later reusing that slot would otherwise surface it. - chatViewModel.composingText = "" - dismiss() - } - ) - }) - // Long-press / secondary-click actions sheet. Bound to a captured value, so - // messages arriving behind it never re-anchor it to a different bubble. - .sheet(item: $selectedMessageForActions) { message in - messageActionsSheet(for: message) - .environment(\.horizontalSizeClass, horizontalSizeClass) - } - // Block sender sheet — channel only - .sheet(item: $blockSenderContext) { context in - BlockSenderSheet( - senderName: context.senderName, - radioID: context.radioID - ) { blockedContactIDs in - Task { - await performBlock( - senderName: context.senderName, - radioID: context.radioID, - contactIDs: blockedContactIDs - ) - } - } - } - .sheet(item: $sendDMContext) { context in - SendDMSheet( - senderName: context.senderName, - radioID: context.radioID - ) { contact in - appState.navigation.navigateToChat(with: contact) - } - } - .fullScreenCover(item: $imageViewerData) { data in - FullScreenImageViewer(data: data) - } - .task(id: appState.servicesVersion) { - await performInitialLoad() - } - .onDisappear { - // Load-bearing on iPad: MainSidebarView pins the Chats detail stack with - // `.id(chatsSelectedRoute.conversationID)`, so a detail swap tears down this view's - // @State (including draftSaveTask) before the debounce fires — flush here. - flushDraft() - performCleanup() - } - .onChange(of: chatViewModel.composingText) { _, _ in - scheduleDraftSave() - } - .onChange(of: scenePhase) { _, newPhase in - // Notifications usually arrive while the app is backgrounded with this - // chat already on screen, so re-clear the tray when we return to the - // foreground — the open hook alone never re-fires in that case. - switch newPhase { - case .active: - Task { await clearDeliveredNotifications() } - case .background, .inactive: - // The OS can suspend the process before the debounce fires, so flush - // the draft synchronously here. - flushDraft() - @unknown default: - break - } - } - .onChange(of: activeMentionQuery != nil) { _, isActive in - if isActive { - mentionSenderOrder = chatViewModel.channelSenderOrder - } else { - mentionSenderOrder = nil - } - } - .task { - for await event in appState.messageEventStream.events() { - await chatViewModel.handle(event) - } - } - .onChange(of: chatViewModel.contactRefreshSignal) { _, _ in - Task { await refreshContact() } - } - .onChange(of: chatViewModel.lastIncomingMention) { _, newMention in - guard let mention = newMention else { return } - handleIncomingMentionIfNeeded(mention.messageID) - } - .onChange(of: appState.contactsVersion) { _, _ in - // Keep the mention-resolution snapshot fresh: a contact added after the - // chat opened must be tappable without reopening the screen. - Task { await chatViewModel.loadAllContacts(radioID: conversationType.radioID) } - } - .chatErrorAlerts(chatViewModel: chatViewModel) - // Chrome theming comes from the stack-level themedChrome on the TabView. Re-declaring it - // on this pushed destination makes the nav bar appearance re-install after the push, which - // reflows the flipped table's top rows. - .background { - if let canvas = theme.surfaces?.canvas { - canvas.ignoresSafeArea() - } - } - } - - // MARK: - Initial Load (.task) - - private func performInitialLoad() async { - // Cancel any in-flight mention paging from a previous servicesVersion - mentionScrollTask?.cancel() - mentionScrollTask = nil - - // Capture pending scroll target before loading - let pendingTarget = appState.navigation.pendingScrollToMessageID - if pendingTarget != nil { - appState.navigation.clearPendingScrollToMessage() - } - - chatViewModel.configure( - dependencies: ChatViewModel.Dependencies( - dataStore: { appState.offlineDataStore }, - messageService: { appState.services?.messageService }, - notificationService: { appState.services?.notificationService }, - channelService: { appState.services?.channelService }, - roomServerService: { appState.services?.roomServerService }, - contactService: { appState.services?.contactService }, - syncCoordinator: { appState.syncCoordinator }, - connectionState: { appState.connectionState }, - connectedDevice: { appState.connectedDevice }, - currentRadioID: { appState.currentRadioID }, - session: { appState.services?.session }, - reactionService: { appState.services?.reactionService }, - chatSendQueueService: { appState.services?.chatSendQueueService }, - inlineImageDimensionsStore: { appState.services?.inlineImageDimensionsStore }, - prefetchDataStore: { appState.services?.dataStore } - ), - onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, - linkPreviewCache: linkPreviewCache, - chatCoordinatorRegistry: appState.ensureChatCoordinatorRegistry(), - conversation: conversationType - ) - chatViewModel.applyEnvInputs(currentEnvInputs) - - switch conversationType { - case .dm(let contact): - await chatViewModel.loadMessages(for: contact) - await chatViewModel.loadConversations(radioID: contact.radioID) - await chatViewModel.loadAllContacts(radioID: contact.radioID) - chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) - - case .channel(let channel): - // Load contacts first so contactNameSet is populated before buildChannelSenders runs - await chatViewModel.loadAllContacts(radioID: channel.radioID) - await chatViewModel.loadChannelMessages(for: channel) - await chatViewModel.loadConversations(radioID: channel.radioID) - chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) - } - + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.linkPreviewCache) private var linkPreviewCache + @Environment(\.scenePhase) private var scenePhase + + @State private var conversationType: ChatConversationType + let parentViewModel: ChatViewModel? + + @State private var chatViewModel = ChatViewModel() + + // MARK: - Scroll State + + @State private var isAtBottom = true + @State private var unreadCount = 0 + @State private var scrollToBottomRequest = 0 + @State private var scrollToMentionRequest = 0 + @State private var unseenMentionIDs: [UUID] = [] + /// The subset of `unseenMentionIDs` currently off screen, reported up by the chat table. + /// Drives the scroll-to-mention button and is the source for its tap target. + @State private var offscreenMentionIDs: [UUID] = [] + @State private var scrollToTargetID: UUID? + @State private var mentionScrollTask: Task? + @State private var scrollToDividerRequest = 0 + @State private var isDividerVisible = false + + /// Pending debounced draft persist; cancelled and restarted on each keystroke, + /// cancelled-then-flushed synchronously on view teardown and app suspension. + @State private var draftSaveTask: Task? + + // MARK: - Sheet State + + @State private var showingInfo = false + @State private var selectedMessageForActions: MessageDTO? + @State private var blockSenderContext: BlockSenderContext? + @State private var sendDMContext: SendDMContext? + @State private var imageViewerData: ImageViewerData? + + // MARK: - Other State + + @State private var recentEmojisStore = RecentEmojisStore() + @State private var mentionSenderOrder: [String: UInt32]? + /// Focus-request token: each increment asks the composer to raise the + /// keyboard once. See `ChatComposerTextView` for why a token, not `@FocusState`. + @State private var inputFocusRequest = 0 + + // MARK: - AppStorage + + @AppStorage(AppStorageKey.showInlineImages.rawValue) private var showInlineImages = AppStorageKey.defaultShowInlineImages + @AppStorage(AppStorageKey.autoPlayGIFs.rawValue) private var autoPlayGIFs = AppStorageKey.defaultAutoPlayGIFs + @AppStorage(AppStorageKey.showIncomingPath.rawValue) private var showIncomingPath = AppStorageKey.defaultShowIncomingPath + @AppStorage(AppStorageKey.showIncomingHopCount.rawValue) private var showIncomingHopCount = AppStorageKey.defaultShowIncomingHopCount + @AppStorage(AppStorageKey.showIncomingRegion.rawValue) private var showIncomingRegion = AppStorageKey.defaultShowIncomingRegion + @AppStorage(AppStorageKey.showIncomingSendTime.rawValue) private var showIncomingSendTime = AppStorageKey.defaultShowIncomingSendTime + @AppStorage(AppStorageKey.linkPreviewsEnabled.rawValue) private var previewsEnabled = AppStorageKey.defaultLinkPreviewsEnabled + @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote + @AppStorage(AppStorageKey.showMapPreviewThumbnails.rawValue) private var showMapPreviewThumbnails = AppStorageKey.defaultShowMapPreviewThumbnails + + // MARK: - Environment + + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.appTheme) private var theme + + /// Snapshot of env-derived inputs the view model needs to construct + /// MessageItems at write time. Recomputed on every render — Equatable + /// drives `.onChange(of: envInputs)` in ChatMessagesTableView. + private var currentEnvInputs: EnvInputs { + EnvInputs( + showInlineImages: showInlineImages, + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + showIncomingRegion: showIncomingRegion, + showIncomingSendTime: showIncomingSendTime, + previewsEnabled: previewsEnabled, + isHighContrast: colorSchemeContrast == .increased, + isDark: colorScheme == .dark, + showMapPreviews: showMapPreviewThumbnails && !conversationType.suppressesMapPreviews, + isOffline: !appState.offlineMapService.isNetworkAvailable, + currentUserName: appState.localNodeName, + themeID: theme.id, + contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) + ) + } + + // MARK: - Init + + init(conversationType: ChatConversationType, parentViewModel: ChatViewModel? = nil) { + _conversationType = State(initialValue: conversationType) + self.parentViewModel = parentViewModel + } + + // MARK: - Body + + var body: some View { + ChatConversationMessagesContent( + conversationType: conversationType, + viewModel: chatViewModel, + deviceName: appState.localNodeName, + recentEmojisStore: recentEmojisStore, + envInputs: currentEnvInputs, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + scrollToDividerRequest: $scrollToDividerRequest, + isDividerVisible: $isDividerVisible, + unseenMentionIDs: unseenMentionIDs, + offscreenMentionIDs: $offscreenMentionIDs, + scrollToTargetID: scrollToTargetID, + newMessagesDividerMessageID: chatViewModel.newMessagesDividerMessageID, + selectedMessageForActions: $selectedMessageForActions, + imageViewerData: $imageViewerData, + onMentionSeen: { await markMentionSeen(messageID: $0) }, + onScrollToMention: { scrollToNextMention() }, + onRetryMessage: { retryMessage($0) } + ) + .mentionTapHandling( + contacts: chatViewModel.allContacts, + radioID: conversationType.radioID, + shouldSuppressOpen: { selectedMessageForActions != nil } + ) + // Banner is applied innermost so its safe-area inset stacks above the + // input bar inset that follows, placing the strip between content and + // the input bar (and lifting it with the keyboard). + .chatErrorBanner(chatViewModel: chatViewModel) + .safeAreaInset(edge: .bottom, spacing: 8) { + ChatConversationInputBar( + conversationType: conversationType, + composingText: $chatViewModel.composingText, + focusRequest: $inputFocusRequest, + nodeNameByteCount: appState.connectedDevice?.nodeName.utf8.count ?? 0, + onSend: { text in + switch conversationType { + case .dm: + await chatViewModel.sendMessage(text: text) + case .channel: + await chatViewModel.sendChannelMessage(text: text) + } + }, + onWillSend: { scrollToBottomRequest += 1 } + ) + } + .overlay(alignment: .bottom) { + ChatConversationMentionOverlay( + suggestions: mentionSuggestions, + onSelectMention: { insertMention(for: $0) } + ) + } + .navigationHeader( + title: conversationType.navigationTitle, + subtitle: conversationType.navigationSubtitle( + deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName + ), + subtitleAccessibilityLabel: conversationType.navigationSubtitleAccessibilityLabel( + deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName + ) + ) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.Chats.Chats.Common.info, systemImage: "info.circle") { + showingInfo = true + } + } + } + // Info sheet — type-specific + .sheet(isPresented: $showingInfo, onDismiss: { + switch conversationType { + case .dm: + Task { await refreshContact() } + case .channel: + Task { await refreshChannel() } + } + }, content: { + ChatConversationInfoSheet( + conversationType: conversationType, + chatViewModel: chatViewModel, + onClearChannelMessages: { + guard case let .channel(channel) = conversationType else { return } + await chatViewModel.loadChannelMessages(for: channel) + parentViewModel?.requestConversationReload() + }, + onClearDirectMessages: { + guard case let .dm(contact) = conversationType else { return } + await chatViewModel.loadMessages(for: contact) + parentViewModel?.requestConversationReload() + }, + onDeleteChannel: { + // Clear the composer so the teardown flush doesn't re-persist a draft for the + // freed slot — a channel later reusing that slot would otherwise surface it. + chatViewModel.composingText = "" + dismiss() + } + ) + }) + // Long-press / secondary-click actions sheet. Bound to a captured value, so + // messages arriving behind it never re-anchor it to a different bubble. + .sheet(item: $selectedMessageForActions) { message in + messageActionsSheet(for: message) + .environment(\.horizontalSizeClass, horizontalSizeClass) + } + // Block sender sheet — channel only + .sheet(item: $blockSenderContext) { context in + BlockSenderSheet( + senderName: context.senderName, + radioID: context.radioID + ) { blockedContactIDs in + Task { + await performBlock( + senderName: context.senderName, + radioID: context.radioID, + contactIDs: blockedContactIDs + ) + } + } + } + .sheet(item: $sendDMContext) { context in + SendDMSheet( + senderName: context.senderName, + radioID: context.radioID + ) { contact in + appState.navigation.navigateToChat(with: contact) + } + } + .fullScreenCover(item: $imageViewerData) { data in + FullScreenImageViewer(data: data) + } + .task(id: appState.servicesVersion) { + await performInitialLoad() + } + .onDisappear { + // Load-bearing on iPad: MainSidebarView pins the Chats detail stack with + // `.id(chatsSelectedRoute.conversationID)`, so a detail swap tears down this view's + // @State (including draftSaveTask) before the debounce fires — flush here. + flushDraft() + performCleanup() + } + .onChange(of: chatViewModel.composingText) { _, _ in + scheduleDraftSave() + } + .onChange(of: scenePhase) { _, newPhase in + // Notifications usually arrive while the app is backgrounded with this + // chat already on screen, so re-clear the tray when we return to the + // foreground — the open hook alone never re-fires in that case. + switch newPhase { + case .active: + Task { await clearDeliveredNotifications() } + case .background, .inactive: + // The OS can suspend the process before the debounce fires, so flush + // the draft synchronously here. + flushDraft() + @unknown default: + break + } + } + .onChange(of: activeMentionQuery != nil) { _, isActive in + if isActive { + mentionSenderOrder = chatViewModel.channelSenderOrder + } else { + mentionSenderOrder = nil + } + } + .task { + for await event in appState.messageEventStream.events() { + await chatViewModel.handle(event) + } + } + .onChange(of: chatViewModel.contactRefreshSignal) { _, _ in + Task { await refreshContact() } + } + .onChange(of: chatViewModel.lastIncomingMention) { _, newMention in + guard let mention = newMention else { return } + handleIncomingMentionIfNeeded(mention.messageID) + } + .onChange(of: appState.contactsVersion) { _, _ in + // Keep the mention-resolution snapshot fresh: a contact added after the + // chat opened must be tappable without reopening the screen. + Task { await chatViewModel.loadAllContacts(radioID: conversationType.radioID) } + } + .chatErrorAlerts(chatViewModel: chatViewModel) + // Chrome theming comes from the stack-level themedChrome on the TabView. Re-declaring it + // on this pushed destination makes the nav bar appearance re-install after the push, which + // reflows the flipped table's top rows. + .background { + if let canvas = theme.surfaces?.canvas { + canvas.ignoresSafeArea() + } + } + } + + // MARK: - Initial Load (.task) + + private func performInitialLoad() async { + // Cancel any in-flight mention paging from a previous servicesVersion + mentionScrollTask?.cancel() + mentionScrollTask = nil + + // Capture pending scroll target before loading + let pendingTarget = appState.navigation.pendingScrollToMessageID + if pendingTarget != nil { + appState.navigation.clearPendingScrollToMessage() + } + + chatViewModel.configure( + dependencies: ChatViewModel.Dependencies( + dataStore: { appState.offlineDataStore }, + messageService: { appState.services?.messageService }, + notificationService: { appState.services?.notificationService }, + channelService: { appState.services?.channelService }, + roomServerService: { appState.services?.roomServerService }, + contactService: { appState.services?.contactService }, + syncCoordinator: { appState.syncCoordinator }, + connectionState: { appState.connectionState }, + connectedDevice: { appState.connectedDevice }, + currentRadioID: { appState.currentRadioID }, + session: { appState.services?.session }, + reactionService: { appState.services?.reactionService }, + chatSendQueueService: { appState.services?.chatSendQueueService }, + inlineImageDimensionsStore: { appState.services?.inlineImageDimensionsStore }, + prefetchDataStore: { appState.services?.dataStore } + ), + onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, + linkPreviewCache: linkPreviewCache, + chatCoordinatorRegistry: appState.ensureChatCoordinatorRegistry(), + conversation: conversationType + ) + chatViewModel.applyEnvInputs(currentEnvInputs) + + switch conversationType { + case let .dm(contact): + await chatViewModel.loadMessages(for: contact) + await chatViewModel.loadConversations(radioID: contact.radioID) + await chatViewModel.loadAllContacts(radioID: contact.radioID) + chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) + + case let .channel(channel): + // Load contacts first so contactNameSet is populated before buildChannelSenders runs + await chatViewModel.loadAllContacts(radioID: channel.radioID) + await chatViewModel.loadChannelMessages(for: channel) + await chatViewModel.loadConversations(radioID: channel.radioID) + chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) + } + + await loadUnseenMentions() + + // Trigger scroll to target message if pending (notification deeplink) + if let targetID = pendingTarget { + scrollToTargetID = targetID + scrollToMentionRequest += 1 + } + + // Clear any notifications for this conversation still sitting in the tray + // (delivered while the app was backgrounded). The load above already + // cleared the unread count, so the recomputed badge stays correct. + await clearDeliveredNotifications() + } + + /// Removes delivered lock-screen / Notification Center notifications for the + /// conversation the user just opened, then recomputes the app badge. + private func clearDeliveredNotifications() async { + guard let notificationService = appState.services?.notificationService else { return } + switch conversationType { + case let .dm(contact): + await notificationService.removeDeliveredNotifications(forContactID: contact.id) + case let .channel(channel): + await notificationService.removeDeliveredNotifications( + forChannelIndex: channel.index, + radioID: channel.radioID + ) + } + await notificationService.updateBadgeCount() + } + + // MARK: - Draft Persistence + + /// Restarts the debounced draft persist. The post-sleep cancellation guard is + /// required: `Task.cancel()` only sets a flag and `try?` swallows + /// `Task.sleep`'s `CancellationError`, so without it a synchronous flush that + /// already saved could be overwritten by the resuming task re-saving stale text. + private func scheduleDraftSave() { + draftSaveTask?.cancel() + let id = conversationType.draftConversationID + draftSaveTask = Task { + try? await Task.sleep(for: draftSaveDebounce) + guard !Task.isCancelled else { return } + chatViewModel.saveDraft(to: appState.draftStore, id: id) + } + } + + /// Cancels any pending debounce and persists the current draft synchronously, reading the + /// store from the view's non-optional `@Environment(\.appState)` (matching `performCleanup`). + private func flushDraft() { + draftSaveTask?.cancel() + draftSaveTask = nil + chatViewModel.saveDraft(to: appState.draftStore, id: conversationType.draftConversationID) + } + + // MARK: - Cleanup (.onDisappear) + + private func performCleanup() { + mentionScrollTask?.cancel() + mentionScrollTask = nil + + // Clear notification suppression only if this conversation still owns the + // active slot; a newer conversation's open may have already claimed it + // before this view tears down. + let service = appState.services?.notificationService + switch conversationType { + case let .dm(contact): + if service?.activeContactID == contact.id { + service?.activeContactID = nil + } + case let .channel(channel): + if service?.activeChannelIndex == channel.index, + service?.activeChannelRadioID == channel.radioID { + service?.activeChannelIndex = nil + service?.activeChannelRadioID = nil + } + } + + // Refresh parent conversation list when leaving + parentViewModel?.requestConversationReload() + } + + private func handleIncomingMentionIfNeeded(_ messageID: UUID) { + // Self-mention gating happens upstream in + // `ChatViewModel.recordIncomingMentionIfNeeded`, which only assigns + // `lastIncomingMention` when `containsSelfMention` is true. + Task { + if isAtBottom { + await markNewArrivalMentionSeen(messageID: messageID) + } else { await loadUnseenMentions() - - // Trigger scroll to target message if pending (notification deeplink) - if let targetID = pendingTarget { - scrollToTargetID = targetID - scrollToMentionRequest += 1 - } - - // Clear any notifications for this conversation still sitting in the tray - // (delivered while the app was backgrounded). The load above already - // cleared the unread count, so the recomputed badge stays correct. - await clearDeliveredNotifications() - } - - /// Removes delivered lock-screen / Notification Center notifications for the - /// conversation the user just opened, then recomputes the app badge. - private func clearDeliveredNotifications() async { - guard let notificationService = appState.services?.notificationService else { return } - switch conversationType { - case .dm(let contact): - await notificationService.removeDeliveredNotifications(forContactID: contact.id) - case .channel(let channel): - await notificationService.removeDeliveredNotifications( - forChannelIndex: channel.index, - radioID: channel.radioID - ) - } - await notificationService.updateBadgeCount() - } - - // MARK: - Draft Persistence - - /// Restarts the debounced draft persist. The post-sleep cancellation guard is - /// required: `Task.cancel()` only sets a flag and `try?` swallows - /// `Task.sleep`'s `CancellationError`, so without it a synchronous flush that - /// already saved could be overwritten by the resuming task re-saving stale text. - private func scheduleDraftSave() { - draftSaveTask?.cancel() - let id = conversationType.draftConversationID - draftSaveTask = Task { - try? await Task.sleep(for: draftSaveDebounce) - guard !Task.isCancelled else { return } - chatViewModel.saveDraft(to: appState.draftStore, id: id) - } - } - - /// Cancels any pending debounce and persists the current draft synchronously, reading the - /// store from the view's non-optional `@Environment(\.appState)` (matching `performCleanup`). - private func flushDraft() { - draftSaveTask?.cancel() - draftSaveTask = nil - chatViewModel.saveDraft(to: appState.draftStore, id: conversationType.draftConversationID) + } } + } - // MARK: - Cleanup (.onDisappear) - - private func performCleanup() { - mentionScrollTask?.cancel() - mentionScrollTask = nil - - // Clear notification suppression only if this conversation still owns the - // active slot; a newer conversation's open may have already claimed it - // before this view tears down. - let service = appState.services?.notificationService - switch conversationType { - case .dm(let contact): - if service?.activeContactID == contact.id { - service?.activeContactID = nil - } - case .channel(let channel): - if service?.activeChannelIndex == channel.index, - service?.activeChannelRadioID == channel.radioID { - service?.activeChannelIndex = nil - service?.activeChannelRadioID = nil - } - } - - // Refresh parent conversation list when leaving - parentViewModel?.requestConversationReload() - } + // MARK: - Conversation Refresh - private func handleIncomingMentionIfNeeded(_ messageID: UUID) { - // Self-mention gating happens upstream in - // `ChatViewModel.recordIncomingMentionIfNeeded`, which only assigns - // `lastIncomingMention` when `containsSelfMention` is true. - Task { - if isAtBottom { - await markNewArrivalMentionSeen(messageID: messageID) - } else { - await loadUnseenMentions() - } - } + private func refreshContact() async { + guard case let .dm(contact) = conversationType else { return } + if let updated = try? await appState.services?.dataStore.fetchContact(id: contact.id) { + conversationType = conversationType.replacingContact(updated) + chatViewModel.currentContact = updated } + } - // MARK: - Conversation Refresh - - private func refreshContact() async { - guard case .dm(let contact) = conversationType else { return } - if let updated = try? await appState.services?.dataStore.fetchContact(id: contact.id) { - conversationType = conversationType.replacingContact(updated) - chatViewModel.currentContact = updated - } + private func refreshChannel() async { + guard case let .channel(channel) = conversationType else { return } + if let updated = try? await appState.offlineDataStore?.fetchChannel(id: channel.id) { + conversationType = conversationType.replacingChannel(updated) } + } - private func refreshChannel() async { - guard case .channel(let channel) = conversationType else { return } - if let updated = try? await appState.offlineDataStore?.fetchChannel(id: channel.id) { - conversationType = conversationType.replacingChannel(updated) - } - } + // MARK: - Mention Tracking - // MARK: - Mention Tracking + private func loadUnseenMentions() async { + switch conversationType { + case let .dm(contact): + guard let dataStore = appState.services?.dataStore else { return } + do { + unseenMentionIDs = try await dataStore.fetchUnseenMentionIDs(contactID: contact.id) + } catch { + logger.error("Failed to load unseen mentions: \(error)") + } - private func loadUnseenMentions() async { - switch conversationType { - case .dm(let contact): - guard let dataStore = appState.services?.dataStore else { return } - do { - unseenMentionIDs = try await dataStore.fetchUnseenMentionIDs(contactID: contact.id) - } catch { - logger.error("Failed to load unseen mentions: \(error)") - } + case let .channel(channel): + guard let services = appState.services else { return } + do { + let allIDs = try await services.dataStore.fetchUnseenChannelMentionIDs( + radioID: channel.radioID, + channelIndex: channel.index + ) - case .channel(let channel): - guard let services = appState.services else { return } - do { - let allIDs = try await services.dataStore.fetchUnseenChannelMentionIDs( - radioID: channel.radioID, - channelIndex: channel.index - ) - - let blockedNames = await services.syncCoordinator.blockedSenderNames() - if blockedNames.isEmpty { - unseenMentionIDs = allIDs - return - } - - var filteredIDs: [UUID] = [] - for id in allIDs { - do { - if let message = try await services.dataStore.fetchMessage(id: id), - let senderName = message.senderNodeName, - blockedNames.contains(senderName) { - try await services.dataStore.markMentionSeen(messageID: id) - continue - } - } catch { - logger.error("Failed to check/filter mention \(id): \(error)") - } - filteredIDs.append(id) - } - unseenMentionIDs = filteredIDs - } catch { - logger.error("Failed to load unseen channel mentions: \(error)") - } + let blockedNames = await services.syncCoordinator.blockedSenderNames() + if blockedNames.isEmpty { + unseenMentionIDs = allIDs + return } - } - /// Marks a mention seen and reports whether the result is settled. Returns true when the - /// mention was already seen or the persist succeeded; false only when the persist failed, - /// so the caller can re-attempt rather than treat the id as handled. - @discardableResult - private func markMentionSeen(messageID: UUID) async -> Bool { - guard unseenMentionIDs.contains(messageID) else { return true } - guard await persistMentionSeen(messageID: messageID) else { return false } - unseenMentionIDs.removeAll { $0 == messageID } - return true - } - - private func markNewArrivalMentionSeen(messageID: UUID) async { - _ = await persistMentionSeen(messageID: messageID) - } - - private func persistMentionSeen(messageID: UUID) async -> Bool { - guard let dataStore = appState.services?.dataStore else { return false } - do { - try await dataStore.markMentionSeen(messageID: messageID) - switch conversationType { - case .dm(let contact): - try await dataStore.decrementUnreadMentionCount(contactID: contact.id) - parentViewModel?.requestConversationReload() - case .channel(let channel): - try await dataStore.decrementChannelUnreadMentionCount(channelID: channel.id) - parentViewModel?.requestConversationReload() + var filteredIDs: [UUID] = [] + for id in allIDs { + do { + if let message = try await services.dataStore.fetchMessage(id: id), + let senderName = message.senderNodeName, + blockedNames.contains(senderName) { + try await services.dataStore.markMentionSeen(messageID: id) + continue } - return true - } catch { - logger.error("Failed to mark mention seen: \(error)") - return false - } - } - - // MARK: - Mention Navigation - - private func scrollToNextMention() { - // Target the newest off-screen mention so the first tap lands on the latest unread the - // user hasn't reached; repeated taps walk upward through older mentions, showing the - // earliest last. An on-screen mention is already in view and would make the tap a no-op, - // so the off-screen subset is the right source. - guard let targetID = ChatScrollToMentionPolicy.nextTarget(offscreenMentions: offscreenMentionIDs) else { return } - - if chatViewModel.items.contains(where: { $0.id == targetID }) { - issueMentionScroll(to: targetID) - return - } - - mentionScrollTask?.cancel() - mentionScrollTask = Task { - do { - let deadline = ContinuousClock.now + .seconds(10) - // Page on the authoritative coordinator-backed messages, not the lagging - // rendered items. loadOlderMessages mutates messages synchronously but only - // schedules the off-main items rebuild, so gating on items overshoots a page - // per spin and can exhaust history before a render lands, tripping the - // destructive not-found branch that hides a real unread mention. - while !chatViewModel.messages.contains(where: { $0.id == targetID }) { - guard chatViewModel.hasMoreMessages else { - logger.warning("Mention \(targetID) not found after exhausting history, removing") - if let dataStore = appState.services?.dataStore { - try? await dataStore.markMentionSeen(messageID: targetID) - } - unseenMentionIDs.removeAll { $0 == targetID } - break - } - // offscreenMentionIDs is an async mirror; a target marked seen mid-paging is - // already gone from unseenMentionIDs, so stop rather than page for a read mention. - guard unseenMentionIDs.contains(targetID) else { break } - guard ContinuousClock.now < deadline else { - logger.warning("Mention \(targetID) paging timed out") - break - } - if chatViewModel.isLoadingOlder { - try await Task.sleep(for: .milliseconds(50)) - continue - } - await chatViewModel.loadOlderMessages() - try Task.checkCancellation() - } - - // Target is in the model; wait (bounded) for the rebuild to surface it in the - // rendered items, which scrollToItem addresses by row, before scrolling. - while chatViewModel.messages.contains(where: { $0.id == targetID }), - !chatViewModel.items.contains(where: { $0.id == targetID }), - ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(50)) - try Task.checkCancellation() - } - - if chatViewModel.items.contains(where: { $0.id == targetID }) { - issueMentionScroll(to: targetID) - } - } catch is CancellationError { - // Expected when view disappears during paging - } catch { - logger.error("Failed to scroll to mention: \(error)") + } catch { + logger.error("Failed to check/filter mention \(id): \(error)") + } + filteredIDs.append(id) + } + unseenMentionIDs = filteredIDs + } catch { + logger.error("Failed to load unseen channel mentions: \(error)") + } + } + } + + /// Marks a mention seen and reports whether the result is settled. Returns true when the + /// mention was already seen or the persist succeeded; false only when the persist failed, + /// so the caller can re-attempt rather than treat the id as handled. + @discardableResult + private func markMentionSeen(messageID: UUID) async -> Bool { + guard unseenMentionIDs.contains(messageID) else { return true } + guard await persistMentionSeen(messageID: messageID) else { return false } + unseenMentionIDs.removeAll { $0 == messageID } + return true + } + + private func markNewArrivalMentionSeen(messageID: UUID) async { + _ = await persistMentionSeen(messageID: messageID) + } + + private func persistMentionSeen(messageID: UUID) async -> Bool { + guard let dataStore = appState.services?.dataStore else { return false } + do { + try await dataStore.markMentionSeen(messageID: messageID) + switch conversationType { + case let .dm(contact): + try await dataStore.decrementUnreadMentionCount(contactID: contact.id) + parentViewModel?.requestConversationReload() + case let .channel(channel): + try await dataStore.decrementChannelUnreadMentionCount(channelID: channel.id) + parentViewModel?.requestConversationReload() + } + return true + } catch { + logger.error("Failed to mark mention seen: \(error)") + return false + } + } + + // MARK: - Mention Navigation + + private func scrollToNextMention() { + // Target the newest off-screen mention so the first tap lands on the latest unread the + // user hasn't reached; repeated taps walk upward through older mentions, showing the + // earliest last. An on-screen mention is already in view and would make the tap a no-op, + // so the off-screen subset is the right source. + guard let targetID = ChatScrollToMentionPolicy.nextTarget(offscreenMentions: offscreenMentionIDs) else { return } + + if chatViewModel.items.contains(where: { $0.id == targetID }) { + issueMentionScroll(to: targetID) + return + } + + mentionScrollTask?.cancel() + mentionScrollTask = Task { + do { + let deadline = ContinuousClock.now + .seconds(10) + // Page on the authoritative coordinator-backed messages, not the lagging + // rendered items. loadOlderMessages mutates messages synchronously but only + // schedules the off-main items rebuild, so gating on items overshoots a page + // per spin and can exhaust history before a render lands, tripping the + // destructive not-found branch that hides a real unread mention. + while !chatViewModel.messages.contains(where: { $0.id == targetID }) { + guard chatViewModel.hasMoreMessages else { + logger.warning("Mention \(targetID) not found after exhausting history, removing") + if let dataStore = appState.services?.dataStore { + try? await dataStore.markMentionSeen(messageID: targetID) } + unseenMentionIDs.removeAll { $0 == targetID } + break + } + // offscreenMentionIDs is an async mirror; a target marked seen mid-paging is + // already gone from unseenMentionIDs, so stop rather than page for a read mention. + guard unseenMentionIDs.contains(targetID) else { break } + guard ContinuousClock.now < deadline else { + logger.warning("Mention \(targetID) paging timed out") + break + } + if chatViewModel.isLoadingOlder { + try await Task.sleep(for: .milliseconds(50)) + continue + } + await chatViewModel.loadOlderMessages() + try Task.checkCancellation() + } + + // Target is in the model; wait (bounded) for the rebuild to surface it in the + // rendered items, which scrollToItem addresses by row, before scrolling. + while chatViewModel.messages.contains(where: { $0.id == targetID }), + !chatViewModel.items.contains(where: { $0.id == targetID }), + ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(50)) + try Task.checkCancellation() } - } - - /// Scrolls to a mention and advances the queue. The seen-transition is driven directly - /// rather than waiting for the row's visibility tick, which never fires when the target - /// is already centered (scrollToRow does not move contentOffset), leaving the tap a no-op. - private func issueMentionScroll(to targetID: UUID) { - scrollToTargetID = targetID - scrollToMentionRequest += 1 - Task { await markMentionSeen(messageID: targetID) } - } - - // MARK: - Mention Suggestions - - private var activeMentionQuery: String? { - MentionUtilities.detectActiveMention(in: chatViewModel.composingText) - } - - private var mentionSuggestions: [ContactDTO] { - guard let query = activeMentionQuery else { return [] } - switch conversationType { - case .dm: - return MentionUtilities.filterContacts(chatViewModel.allContacts, query: query) - case .channel: - let combined = chatViewModel.allContacts + chatViewModel.channelSenders - let order = mentionSenderOrder ?? chatViewModel.channelSenderOrder - return MentionUtilities.filterContacts(combined, query: query, senderOrder: order) - } - } - - private func insertMention(for contact: ContactDTO) { - guard let query = MentionUtilities.detectActiveMention(in: chatViewModel.composingText) else { return } - - let searchPattern = "@" + query - if let range = chatViewModel.composingText.range(of: searchPattern, options: .backwards) { - let mention = MentionUtilities.createMention(for: contact.name) - chatViewModel.composingText.replaceSubrange(range, with: mention + " ") - } - } - // MARK: - Message Actions Sheet - - /// Builds the drift-proof message actions sheet for a captured message value. - /// Presented via `.sheet(item:)`, which binds to the value rather than a cell, - /// so incoming messages reorder the table behind the modal without re-anchoring - /// it to a different bubble. - private func messageActionsSheet(for message: MessageDTO) -> MessageActionsSheet { - let resolution = senderResolution(for: message) - return MessageActionsSheet( - message: message, - senderName: resolution.displayName, - senderMatchKind: resolution.matchKind, - recentEmojis: recentEmojisStore.recentEmojis, - onAction: { action in - dispatch(action, for: message) - } + if chatViewModel.items.contains(where: { $0.id == targetID }) { + issueMentionScroll(to: targetID) + } + } catch is CancellationError { + // Expected when view disappears during paging + } catch { + logger.error("Failed to scroll to mention: \(error)") + } + } + } + + /// Scrolls to a mention and advances the queue. The seen-transition is driven directly + /// rather than waiting for the row's visibility tick, which never fires when the target + /// is already centered (scrollToRow does not move contentOffset), leaving the tap a no-op. + private func issueMentionScroll(to targetID: UUID) { + scrollToTargetID = targetID + scrollToMentionRequest += 1 + Task { await markMentionSeen(messageID: targetID) } + } + + // MARK: - Mention Suggestions + + private var activeMentionQuery: String? { + MentionUtilities.detectActiveMention(in: chatViewModel.composingText) + } + + private var mentionSuggestions: [ContactDTO] { + guard let query = activeMentionQuery else { return [] } + switch conversationType { + case .dm: + return MentionUtilities.filterContacts(chatViewModel.allContacts, query: query) + case .channel: + let combined = chatViewModel.allContacts + chatViewModel.channelSenders + let order = mentionSenderOrder ?? chatViewModel.channelSenderOrder + return MentionUtilities.filterContacts(combined, query: query, senderOrder: order) + } + } + + private func insertMention(for contact: ContactDTO) { + guard let query = MentionUtilities.detectActiveMention(in: chatViewModel.composingText) else { return } + + let searchPattern = "@" + query + if let range = chatViewModel.composingText.range(of: searchPattern, options: .backwards) { + let mention = MentionUtilities.createMention(for: contact.name) + chatViewModel.composingText.replaceSubrange(range, with: mention + " ") + } + } + + // MARK: - Message Actions Sheet + + /// Builds the drift-proof message actions sheet for a captured message value. + /// Presented via `.sheet(item:)`, which binds to the value rather than a cell, + /// so incoming messages reorder the table behind the modal without re-anchoring + /// it to a different bubble. + private func messageActionsSheet(for message: MessageDTO) -> MessageActionsSheet { + let resolution = senderResolution(for: message) + return MessageActionsSheet( + message: message, + senderName: resolution.displayName, + senderMatchKind: resolution.matchKind, + recentEmojis: recentEmojisStore.recentEmojis, + onAction: { action in + dispatch(action, for: message) + } + ) + } + + private func senderResolution(for message: MessageDTO) -> NodeNameResolution { + if message.isOutgoing { + return NodeNameResolution(displayName: appState.localNodeName, matchKind: .exact) + } + switch conversationType { + case let .dm(contact): + return NodeNameResolution(displayName: contact.displayName, matchKind: .exact) + case .channel: + if let name = message.senderNodeName, !name.isEmpty { + return NodeNameResolution(displayName: name, matchKind: .exact) + } + if let prefix = message.senderKeyPrefix, + let result = NeighborNameResolver.resolve( + for: prefix, contacts: chatViewModel.allContacts, discoveredNodes: [], userLocation: nil + ) { + return result + } + return NodeNameResolution( + displayName: L10n.Chats.Chats.Message.Sender.unknown, matchKind: .unresolved + ) + } + } + + // MARK: - Message Action Handling + + /// Dispatches a MessageAction by routing to the appropriate handler. The + /// `switch action` body preserves compile-time exhaustiveness — adding a new + /// MessageAction case forces this method to handle it. Each case calls an + /// extracted private method that captures the view-local context it needs + /// (focus state, AppStorage flags, sheet-presentation contexts). + private func dispatch(_ action: MessageAction, for message: MessageDTO) { + switch action { + case let .react(emoji): + handleReact(emoji: emoji, for: message) + case .reply: + handleReply(for: message) + case .copy: + handleCopy(for: message) + case .sendAgain: + handleSendAgain(for: message) + case .blockSender: + handleBlockSender(for: message) + case .sendDM: + handleSendDM(for: message) + case .delete: + handleDelete(for: message) + } + } + + private func handleReact(emoji: String, for message: MessageDTO) { + recentEmojisStore.recordUsage(emoji) + Task { await chatViewModel.sendReaction(emoji: emoji, to: message) } + } + + private func handleReply(for message: MessageDTO) { + let mentionName: String = switch conversationType { + case let .dm(contact): + contact.name + case .channel: + message.senderNodeName ?? L10n.Chats.Chats.Message.Sender.unknown + } + if replyWithQuote { + chatViewModel.composingText = MentionUtilities.buildReplyText(mentionName: mentionName, messageText: message.text) + } else { + chatViewModel.composingText = MentionUtilities.appendMention( + for: mentionName, + to: chatViewModel.composingText + ) + } + // Raise the keyboard only after the actions sheet has finished dismissing; + // a focus request issued while the sheet is still animating away is lost. + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + inputFocusRequest += 1 + } + } + + private func handleCopy(for message: MessageDTO) { + UIPasteboard.general.string = message.text + } + + private func handleSendAgain(for message: MessageDTO) { + Task { await chatViewModel.sendAgain(message) } + } + + private func handleBlockSender(for message: MessageDTO) { + guard case let .channel(channel) = conversationType, + let name = message.senderNodeName else { return } + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + blockSenderContext = BlockSenderContext(senderName: name, radioID: channel.radioID) + } + } + + private func handleSendDM(for message: MessageDTO) { + guard case let .channel(channel) = conversationType, + let name = message.senderNodeName else { return } + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + sendDMContext = SendDMContext(senderName: name, radioID: channel.radioID) + } + } + + private func handleDelete(for message: MessageDTO) { + Task { await chatViewModel.deleteMessage(message) } + } + + private func retryMessage(_ message: MessageDTO) { + Task { + switch conversationType { + case .dm: + await chatViewModel.retryMessage(message) + case .channel: + await chatViewModel.retryChannelMessage(message) + } + } + } + + // MARK: - Blocking (Channel only) + + private func performBlock(senderName: String, radioID: UUID, contactIDs: Set) async { + guard let services = appState.services else { return } + + let dto = BlockedChannelSenderDTO(name: senderName, radioID: radioID) + do { + try await services.dataStore.saveBlockedChannelSender(dto) + } catch { + logger.error("Failed to save blocked channel sender: \(error)") + return + } + + // Delete existing channel messages from the blocked sender + try? await services.dataStore.deleteChannelMessages(fromSender: senderName, radioID: radioID) + + for contactID in contactIDs { + do { + try await services.contactService.updateContactPreferences( + contactID: contactID, + isBlocked: true ) + } catch { + logger.error("Failed to block contact \(contactID): \(error)") + } } - private func senderResolution(for message: MessageDTO) -> NodeNameResolution { - if message.isOutgoing { - return NodeNameResolution(displayName: appState.localNodeName, matchKind: .exact) - } - switch conversationType { - case .dm(let contact): - return NodeNameResolution(displayName: contact.displayName, matchKind: .exact) - case .channel: - if let name = message.senderNodeName, !name.isEmpty { - return NodeNameResolution(displayName: name, matchKind: .exact) - } - if let prefix = message.senderKeyPrefix, - let result = NeighborNameResolver.resolve( - for: prefix, contacts: chatViewModel.allContacts, discoveredNodes: [], userLocation: nil) { - return result - } - return NodeNameResolution( - displayName: L10n.Chats.Chats.Message.Sender.unknown, matchKind: .unresolved) - } - } - - // MARK: - Message Action Handling - - /// Dispatches a MessageAction by routing to the appropriate handler. The - /// `switch action` body preserves compile-time exhaustiveness — adding a new - /// MessageAction case forces this method to handle it. Each case calls an - /// extracted private method that captures the view-local context it needs - /// (focus state, AppStorage flags, sheet-presentation contexts). - private func dispatch(_ action: MessageAction, for message: MessageDTO) { - switch action { - case .react(let emoji): - handleReact(emoji: emoji, for: message) - case .reply: - handleReply(for: message) - case .copy: - handleCopy(for: message) - case .sendAgain: - handleSendAgain(for: message) - case .blockSender: - handleBlockSender(for: message) - case .sendDM: - handleSendDM(for: message) - case .delete: - handleDelete(for: message) - } - } - - private func handleReact(emoji: String, for message: MessageDTO) { - recentEmojisStore.recordUsage(emoji) - Task { await chatViewModel.sendReaction(emoji: emoji, to: message) } - } - - private func handleReply(for message: MessageDTO) { - let mentionName: String - switch conversationType { - case .dm(let contact): - mentionName = contact.name - case .channel: - mentionName = message.senderNodeName ?? L10n.Chats.Chats.Message.Sender.unknown - } - if replyWithQuote { - chatViewModel.composingText = MentionUtilities.buildReplyText(mentionName: mentionName, messageText: message.text) - } else { - chatViewModel.composingText = MentionUtilities.appendMention( - for: mentionName, - to: chatViewModel.composingText - ) - } - // Raise the keyboard only after the actions sheet has finished dismissing; - // a focus request issued while the sheet is still animating away is lost. - Task { - try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) - inputFocusRequest += 1 - } - } - - private func handleCopy(for message: MessageDTO) { - UIPasteboard.general.string = message.text - } - - private func handleSendAgain(for message: MessageDTO) { - Task { await chatViewModel.sendAgain(message) } - } - - private func handleBlockSender(for message: MessageDTO) { - guard case .channel(let channel) = conversationType, - let name = message.senderNodeName else { return } - Task { - try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) - blockSenderContext = BlockSenderContext(senderName: name, radioID: channel.radioID) - } - } - - private func handleSendDM(for message: MessageDTO) { - guard case .channel(let channel) = conversationType, - let name = message.senderNodeName else { return } - Task { - try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) - sendDMContext = SendDMContext(senderName: name, radioID: channel.radioID) - } - } + await services.syncCoordinator.refreshBlockedContactsCache( + radioID: radioID, + dataStore: services.dataStore + ) - private func handleDelete(for message: MessageDTO) { - Task { await chatViewModel.deleteMessage(message) } - } - - private func retryMessage(_ message: MessageDTO) { - Task { - switch conversationType { - case .dm: - await chatViewModel.retryMessage(message) - case .channel: - await chatViewModel.retryChannelMessage(message) - } - } + if !contactIDs.isEmpty { + services.syncCoordinator.notifyContactsChanged() } - // MARK: - Blocking (Channel only) - - private func performBlock(senderName: String, radioID: UUID, contactIDs: Set) async { - guard let services = appState.services else { return } - - let dto = BlockedChannelSenderDTO(name: senderName, radioID: radioID) - do { - try await services.dataStore.saveBlockedChannelSender(dto) - } catch { - logger.error("Failed to save blocked channel sender: \(error)") - return - } - - // Delete existing channel messages from the blocked sender - try? await services.dataStore.deleteChannelMessages(fromSender: senderName, radioID: radioID) - - for contactID in contactIDs { - do { - try await services.contactService.updateContactPreferences( - contactID: contactID, - isBlocked: true - ) - } catch { - logger.error("Failed to block contact \(contactID): \(error)") - } - } - - await services.syncCoordinator.refreshBlockedContactsCache( - radioID: radioID, - dataStore: services.dataStore - ) - - if !contactIDs.isEmpty { - services.syncCoordinator.notifyContactsChanged() - } - - if case .channel(let channel) = conversationType { - await chatViewModel.loadChannelMessages(for: channel) - } - services.syncCoordinator.notifyConversationsChanged() + if case let .channel(channel) = conversationType { + await chatViewModel.loadChannelMessages(for: channel) } + services.syncCoordinator.notifyConversationsChanged() + } } // MARK: - Error Alerts private extension View { - /// Applies the chat modal-alert surfaces in one modifier so the conversation - /// view body stays within the type-checker's expression budget. Two modal - /// alerts: generic "Error" for open-conversation load failures (so a - /// re-open failure cannot be missed), and "Unable to Send" for queue drain - /// failures. The passive banner for pagination failures is mounted - /// separately via `chatErrorBanner` so it can sit above the input bar. - func chatErrorAlerts(chatViewModel: ChatViewModel) -> some View { - @Bindable var vm = chatViewModel - return self - .errorAlert($vm.errorMessage) - .errorAlert($vm.sendErrorMessage, title: L10n.Chats.Chats.Alert.UnableToSend.title) - } - - /// Mounts the passive error banner used for background failures (e.g. - /// older-message pagination). Applied before the input-bar safe-area inset - /// so the banner appears between the message list and the input bar, and - /// rises with the keyboard alongside the input bar. - func chatErrorBanner(chatViewModel: ChatViewModel) -> some View { - @Bindable var vm = chatViewModel - return errorBanner($vm.errorBannerMessage) - } + /// Applies the chat modal-alert surfaces in one modifier so the conversation + /// view body stays within the type-checker's expression budget. Two modal + /// alerts: generic "Error" for open-conversation load failures (so a + /// re-open failure cannot be missed), and "Unable to Send" for queue drain + /// failures. The passive banner for pagination failures is mounted + /// separately via `chatErrorBanner` so it can sit above the input bar. + func chatErrorAlerts(chatViewModel: ChatViewModel) -> some View { + @Bindable var vm = chatViewModel + return errorAlert($vm.errorMessage) + .errorAlert($vm.sendErrorMessage, title: L10n.Chats.Chats.Alert.UnableToSend.title) + } + + /// Mounts the passive error banner used for background failures (e.g. + /// older-message pagination). Applied before the input-bar safe-area inset + /// so the banner appears between the message list and the input bar, and + /// rises with the keyboard alongside the input bar. + func chatErrorBanner(chatViewModel: ChatViewModel) -> some View { + @Bindable var vm = chatViewModel + return errorBanner($vm.errorBannerMessage) + } } // MARK: - Previews #Preview("DM") { - NavigationStack { - ChatConversationView( - conversationType: .dm(ContactDTO(from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Alice" - ))) - ) - } - .environment(\.appState, AppState()) + NavigationStack { + ChatConversationView( + conversationType: .dm(ContactDTO(from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Alice" + ))) + ) + } + .environment(\.appState, AppState()) } #Preview("Channel") { - NavigationStack { - ChatConversationView( - conversationType: .channel(ChannelDTO(from: Channel( - radioID: UUID(), - index: 1, - name: "General" - ))) - ) - } - .environment(\.appState, AppState()) + NavigationStack { + ChatConversationView( + conversationType: .channel(ChannelDTO(from: Channel( + radioID: UUID(), + index: 1, + name: "General" + ))) + ) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/ChatCoordinateDetector.swift b/MC1/Views/Chats/ChatCoordinateDetector.swift index 0fdd8f9f..a2fbfdd2 100644 --- a/MC1/Views/Chats/ChatCoordinateDetector.swift +++ b/MC1/Views/Chats/ChatCoordinateDetector.swift @@ -10,67 +10,67 @@ import Foundation /// thumbnail path: the thumbnail forwards the parsed coordinate straight to the /// snapshotter camera and `navigateToMap`, neither of which re-validates. enum ChatCoordinateDetector { - struct Match { - let range: Range - let coordinate: CLLocationCoordinate2D - } + struct Match { + let range: Range + let coordinate: CLLocationCoordinate2D + } - private static let latitudeRange = -90.0...90.0 - private static let longitudeRange = -180.0...180.0 + private static let latitudeRange = -90.0...90.0 + private static let longitudeRange = -180.0...180.0 - // swiftlint:disable force_try - /// Matches a decimal-degree pair: each component is a sign-optional 1-3 digit - /// integer part with a required decimal point and at least one fractional digit. - /// The lookbehind excludes an adjacent word char or `.` so the pass does not match - /// inside a longer number or after a version prefix (`v1.2, 3.4`). The trailing - /// lookahead rejects a following version segment (`.digit`, as in `3.4.5`) or word - /// char, but allows trailing punctuation so a coordinate ending a sentence still matches. - /// `try!` is intentional: the pattern is a literal, so an init failure here - /// is a programmer error that must crash at first use rather than silently - /// disable coordinate linking (no log, no test signal). - private static let coordinateRegex = try! NSRegularExpression( - pattern: #"(? [Match] { - // Common-case fast path: a coordinate pair must contain a comma. Skips - // NSRange construction and regex engine entry for the vast majority of - // bodies, which matters during full chat rebuilds (theme toggle, env - // flip) that re-run this over hundreds of messages. - guard text.contains(",") else { return [] } - let nsRange = NSRange(text.startIndex..., in: text) - var results: [Match] = [] - for match in coordinateRegex.matches(in: text, range: nsRange) { - guard match.numberOfRanges == 3, - let fullRange = Range(match.range, in: text), - let latRange = Range(match.range(at: 1), in: text), - let lonRange = Range(match.range(at: 2), in: text), - let latitude = Double(String(text[latRange])), - let longitude = Double(String(text[lonRange])), - latitudeRange.contains(latitude), - longitudeRange.contains(longitude) else { continue } + /// All valid coordinate matches in document order. Applies the regex, the + /// range clamp, and the decimal-list guard. Already-linked-range skipping is + /// the linkifier's concern (it needs the `AttributedString`) and is not done here. + static func matches(in text: String) -> [Match] { + // Common-case fast path: a coordinate pair must contain a comma. Skips + // NSRange construction and regex engine entry for the vast majority of + // bodies, which matters during full chat rebuilds (theme toggle, env + // flip) that re-run this over hundreds of messages. + guard text.contains(",") else { return [] } + let nsRange = NSRange(text.startIndex..., in: text) + var results: [Match] = [] + for match in coordinateRegex.matches(in: text, range: nsRange) { + guard match.numberOfRanges == 3, + let fullRange = Range(match.range, in: text), + let latRange = Range(match.range(at: 1), in: text), + let lonRange = Range(match.range(at: 2), in: text), + let latitude = Double(String(text[latRange])), + let longitude = Double(String(text[lonRange])), + latitudeRange.contains(latitude), + longitudeRange.contains(longitude) else { continue } - // Decimal-list guard: a third comma-number following the pair means this - // is a numeric list (`1.0, 2.0, 3.0`), not a coordinate. Skip it. - let tail = text[fullRange.upperBound...] - if tail.range(of: #"\s*,\s*-?\d"#, options: [.regularExpression, .anchored]) != nil { - continue - } + // Decimal-list guard: a third comma-number following the pair means this + // is a numeric list (`1.0, 2.0, 3.0`), not a coordinate. Skip it. + let tail = text[fullRange.upperBound...] + if tail.range(of: #"\s*,\s*-?\d"#, options: [.regularExpression, .anchored]) != nil { + continue + } - results.append(Match( - range: fullRange, - coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - )) - } - return results + results.append(Match( + range: fullRange, + coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + )) } + return results + } - /// The first valid coordinate in document order, or nil. - static func firstCoordinate(in text: String) -> CLLocationCoordinate2D? { - matches(in: text).first?.coordinate - } + /// The first valid coordinate in document order, or nil. + static func firstCoordinate(in text: String) -> CLLocationCoordinate2D? { + matches(in: text).first?.coordinate + } } diff --git a/MC1/Views/Chats/ChatFilterPicker.swift b/MC1/Views/Chats/ChatFilterPicker.swift index 1a047253..83df51d0 100644 --- a/MC1/Views/Chats/ChatFilterPicker.swift +++ b/MC1/Views/Chats/ChatFilterPicker.swift @@ -2,15 +2,15 @@ import SwiftUI /// Pinned glass filter bar for the Chats tab. struct ChatFilterPicker: View { - @Binding var selection: ChatFilter - @Environment(\.isSearching) private var isSearching + @Binding var selection: ChatFilter + @Environment(\.isSearching) private var isSearching - var body: some View { - GlassFilterBar( - selection: $selection, - isSearching: isSearching, - pickerLabel: L10n.Chats.Chats.Filter.title, - title: { $0.localizedName } - ) - } + var body: some View { + GlassFilterBar( + selection: $selection, + isSearching: isSearching, + pickerLabel: L10n.Chats.Chats.Filter.title, + title: { $0.localizedName } + ) + } } diff --git a/MC1/Views/Chats/ChatListActions.swift b/MC1/Views/Chats/ChatListActions.swift index d73c639d..34eb816d 100644 --- a/MC1/Views/Chats/ChatListActions.swift +++ b/MC1/Views/Chats/ChatListActions.swift @@ -1,6 +1,6 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let chatListActionsLogger = Logger(subsystem: "com.mc1", category: "ChatListActions") @@ -11,125 +11,125 @@ private let chatListActionsLogger = Logger(subsystem: "com.mc1", category: "Chat /// at each view's own `@State`, so the captured state stays live. @MainActor struct ChatListActions { - let viewModel: ChatViewModel - let appState: AppState - let roomToDelete: Binding - let showRoomDeleteAlert: Binding - let channelDeleteFailure: Binding - let showChannelDeleteFailed: Binding - let roomToAuthenticate: Binding - let navigate: (ChatRoute) -> Void - let clearNavigationIfActive: (ChatRoute) -> Void + let viewModel: ChatViewModel + let appState: AppState + let roomToDelete: Binding + let showRoomDeleteAlert: Binding + let channelDeleteFailure: Binding + let showChannelDeleteFailed: Binding + let roomToAuthenticate: Binding + let navigate: (ChatRoute) -> Void + let clearNavigationIfActive: (ChatRoute) -> Void - func handleDeleteConversation(_ conversation: Conversation) { - switch conversation { - case .direct(let contact): - deleteDirectConversation(contact) + func handleDeleteConversation(_ conversation: Conversation) { + switch conversation { + case let .direct(contact): + deleteDirectConversation(contact) - case .channel(let channel): - deleteChannelConversation(channel) + case let .channel(channel): + deleteChannelConversation(channel) - case .room(let session): - roomToDelete.wrappedValue = session - showRoomDeleteAlert.wrappedValue = true - } + case let .room(session): + roomToDelete.wrappedValue = session + showRoomDeleteAlert.wrappedValue = true } + } - /// Direct conversations clear via a local SwiftData write only, so the row is hidden - /// optimistically and restored if the write throws. - func deleteDirectConversation(_ contact: ContactDTO) { - guard !viewModel.isDeletePending(contact.id) else { return } - clearNavigationIfActive(.direct(contact)) - viewModel.removeConversation(.direct(contact)) + /// Direct conversations clear via a local SwiftData write only, so the row is hidden + /// optimistically and restored if the write throws. + func deleteDirectConversation(_ contact: ContactDTO) { + guard !viewModel.isDeletePending(contact.id) else { return } + clearNavigationIfActive(.direct(contact)) + viewModel.removeConversation(.direct(contact)) - Task { - do { - try await viewModel.deleteDirectConversation(for: contact) - // Confirm immediately rather than waiting for the reload; an inbound message that - // re-sets lastMessageDate mid-delete would otherwise keep the row masked forever. - viewModel.confirmDirectRemoval(contact) - viewModel.requestConversationReload() - } catch { - viewModel.restoreConversation(.direct(contact)) - viewModel.errorMessage = error.userFacingMessage - } - } + Task { + do { + try await viewModel.deleteDirectConversation(for: contact) + // Confirm immediately rather than waiting for the reload; an inbound message that + // re-sets lastMessageDate mid-delete would otherwise keep the row masked forever. + viewModel.confirmDirectRemoval(contact) + viewModel.requestConversationReload() + } catch { + viewModel.restoreConversation(.direct(contact)) + viewModel.errorMessage = error.userFacingMessage + } } + } - /// Channel deletion sends a radio command. The row stays put with a spinner until the - /// command acks, then is hidden once; a failure or timeout leaves it in place with a retry alert. - func deleteChannelConversation(_ channel: ChannelDTO) { - guard !viewModel.isDeletePending(channel.id) else { return } - viewModel.deletingIDs.insert(channel.id) - Task { - defer { viewModel.deletingIDs.remove(channel.id) } - do { - try await withTimeout(RadioCommandTimeout.delete, operationName: "clearChannel") { - try await ChatConversationActions.deleteChannel(channel, appState: appState) - } - clearNavigationIfActive(.channel(channel)) - viewModel.removeConversation(.channel(channel)) - } catch { - channelDeleteFailure.wrappedValue = ChatConversationActions.Failure( - channel: channel, - message: error.userFacingMessage - ) - showChannelDeleteFailed.wrappedValue = true - } - viewModel.requestConversationReload() + /// Channel deletion sends a radio command. The row stays put with a spinner until the + /// command acks, then is hidden once; a failure or timeout leaves it in place with a retry alert. + func deleteChannelConversation(_ channel: ChannelDTO) { + guard !viewModel.isDeletePending(channel.id) else { return } + viewModel.deletingIDs.insert(channel.id) + Task { + defer { viewModel.deletingIDs.remove(channel.id) } + do { + try await withTimeout(RadioCommandTimeout.delete, operationName: "clearChannel") { + try await ChatConversationActions.deleteChannel(channel, appState: appState) } + clearNavigationIfActive(.channel(channel)) + viewModel.removeConversation(.channel(channel)) + } catch { + channelDeleteFailure.wrappedValue = ChatConversationActions.Failure( + channel: channel, + message: error.userFacingMessage + ) + showChannelDeleteFailed.wrappedValue = true + } + viewModel.requestConversationReload() } + } - /// Room leave sends radio commands (logout, remove contact). The row keeps its spinner until - /// they ack, then is hidden once; a failure leaves it in place and the trailing reload - /// reconciles a partial failure rather than blindly re-inserting. - func deleteRoom(_ session: RemoteNodeSessionDTO) async { - guard !viewModel.isDeletePending(session.id) else { return } - viewModel.deletingIDs.insert(session.id) - defer { viewModel.deletingIDs.remove(session.id) } - do { - try await withTimeout(RadioCommandTimeout.delete, operationName: "leaveRoom") { - try await ChatConversationActions.leaveRoom(session, appState: appState) - } - clearNavigationIfActive(.room(session)) - viewModel.removeConversation(.room(session)) - } catch { - chatListActionsLogger.error("Failed to delete room: \(error)") - viewModel.errorMessage = error.userFacingMessage - } - viewModel.requestConversationReload() + /// Room leave sends radio commands (logout, remove contact). The row keeps its spinner until + /// they ack, then is hidden once; a failure leaves it in place and the trailing reload + /// reconciles a partial failure rather than blindly re-inserting. + func deleteRoom(_ session: RemoteNodeSessionDTO) async { + guard !viewModel.isDeletePending(session.id) else { return } + viewModel.deletingIDs.insert(session.id) + defer { viewModel.deletingIDs.remove(session.id) } + do { + try await withTimeout(RadioCommandTimeout.delete, operationName: "leaveRoom") { + try await ChatConversationActions.leaveRoom(session, appState: appState) + } + clearNavigationIfActive(.room(session)) + viewModel.removeConversation(.room(session)) + } catch { + chatListActionsLogger.error("Failed to delete room: \(error)") + viewModel.errorMessage = error.userFacingMessage } + viewModel.requestConversationReload() + } - func handlePendingNavigation() { - guard let contact = appState.navigation.pendingChatContact else { return } - navigate(.direct(contact)) - appState.navigation.clearPendingNavigation() - } + func handlePendingNavigation() { + guard let contact = appState.navigation.pendingChatContact else { return } + navigate(.direct(contact)) + appState.navigation.clearPendingNavigation() + } - func handlePendingChannelNavigation() { - guard let channel = appState.navigation.pendingChannel else { return } - navigate(.channel(channel)) - appState.navigation.clearPendingChannelNavigation() - } + func handlePendingChannelNavigation() { + guard let channel = appState.navigation.pendingChannel else { return } + navigate(.channel(channel)) + appState.navigation.clearPendingChannelNavigation() + } - func handlePendingRoomNavigation() { - guard let session = appState.navigation.pendingRoomSession else { return } - navigate(.room(session)) - appState.navigation.clearPendingRoomNavigation() - } + func handlePendingRoomNavigation() { + guard let session = appState.navigation.pendingRoomSession else { return } + navigate(.room(session)) + appState.navigation.clearPendingRoomNavigation() + } - /// Presents the room auth sheet for a disconnected room a notification tap - /// wants to open, reusing the same sheet a disconnected-room list tap uses. - func consumePendingRoomAuthentication() { - guard let session = appState.navigation.pendingRoomAuthentication else { return } - roomToAuthenticate.wrappedValue = session - appState.navigation.clearPendingRoomAuthentication() - } + /// Presents the room auth sheet for a disconnected room a notification tap + /// wants to open, reusing the same sheet a disconnected-room list tap uses. + func consumePendingRoomAuthentication() { + guard let session = appState.navigation.pendingRoomAuthentication else { return } + roomToAuthenticate.wrappedValue = session + appState.navigation.clearPendingRoomAuthentication() + } - func announceOfflineStateIfNeeded() { - guard appState.connectionState == .disconnected, - appState.currentRadioID != nil else { return } + func announceOfflineStateIfNeeded() { + guard appState.connectionState == .disconnected, + appState.currentRadioID != nil else { return } - AccessibilityNotification.Announcement(L10n.Chats.Chats.Accessibility.offlineAnnouncement).post() - } + AccessibilityNotification.Announcement(L10n.Chats.Chats.Accessibility.offlineAnnouncement).post() + } } diff --git a/MC1/Views/Chats/ChatsListModifiers.swift b/MC1/Views/Chats/ChatsListModifiers.swift index d242211f..8d3b89df 100644 --- a/MC1/Views/Chats/ChatsListModifiers.swift +++ b/MC1/Views/Chats/ChatsListModifiers.swift @@ -1,102 +1,102 @@ -import SwiftUI import MC1Services +import SwiftUI /// Shared modifiers applied to the conversation list in both stack and split layouts. struct ChatsListModifiers: ViewModifier { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - let viewModel: ChatViewModel + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @Binding var searchText: String - @Binding var showingNewChat: Bool - @Binding var showingChannelOptions: Bool + let viewModel: ChatViewModel - let onAnnounceOfflineStateIfNeeded: () -> Void - let onHandlePendingNavigation: () -> Void - let onHandlePendingChannelNavigation: () -> Void - let onHandlePendingRoomNavigation: () -> Void + @Binding var searchText: String + @Binding var showingNewChat: Bool + @Binding var showingChannelOptions: Bool - func body(content: Content) -> some View { - content - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.title) - .searchable(text: $searchText, prompt: L10n.Chats.Chats.Search.placeholder) - .toolbar { - bleStatusToolbarItem() - ToolbarItem(placement: .automatic) { - Menu { - Button { - showingNewChat = true - } label: { - Label(L10n.Chats.Chats.Compose.newChat, systemImage: "person") - } + let onAnnounceOfflineStateIfNeeded: () -> Void + let onHandlePendingNavigation: () -> Void + let onHandlePendingChannelNavigation: () -> Void + let onHandlePendingRoomNavigation: () -> Void - Button { - showingChannelOptions = true - } label: { - Label(L10n.Chats.Chats.Compose.newChannel, systemImage: "number") - } - } label: { - Label(L10n.Chats.Chats.Compose.newMessage, systemImage: "square.and.pencil") - } - } - } - .task { - viewModel.configure( - dependencies: ChatViewModel.Dependencies( - dataStore: { appState.offlineDataStore }, - messageService: { appState.services?.messageService }, - notificationService: { appState.services?.notificationService }, - channelService: { appState.services?.channelService }, - roomServerService: { appState.services?.roomServerService }, - contactService: { appState.services?.contactService }, - syncCoordinator: { appState.syncCoordinator }, - connectionState: { appState.connectionState }, - connectedDevice: { appState.connectedDevice }, - currentRadioID: { appState.currentRadioID }, - session: { appState.services?.session }, - reactionService: { appState.services?.reactionService }, - chatSendQueueService: { appState.services?.chatSendQueueService }, - inlineImageDimensionsStore: { nil }, - prefetchDataStore: { nil } - ), - onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, - linkPreviewCache: nil, - chatCoordinatorRegistry: nil, - conversation: nil - ) - await viewModel.requestConversationReload()?.value - onAnnounceOfflineStateIfNeeded() - onHandlePendingNavigation() - onHandlePendingChannelNavigation() - onHandlePendingRoomNavigation() - } - .onChange(of: appState.navigation.pendingChatContact) { _, _ in - onHandlePendingNavigation() + func body(content: Content) -> some View { + content + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.title) + .searchable(text: $searchText, prompt: L10n.Chats.Chats.Search.placeholder) + .toolbar { + bleStatusToolbarItem() + ToolbarItem(placement: .automatic) { + Menu { + Button { + showingNewChat = true + } label: { + Label(L10n.Chats.Chats.Compose.newChat, systemImage: "person") } - .onChange(of: appState.navigation.pendingChannel) { _, _ in - onHandlePendingChannelNavigation() - } - .onChange(of: appState.navigation.pendingRoomSession) { _, _ in - onHandlePendingRoomNavigation() - } - .onChange(of: appState.servicesVersion) { _, _ in - viewModel.requestConversationReload() - } - .onChange(of: appState.conversationsVersion) { _, _ in - viewModel.requestConversationReload() - } - .onChange(of: appState.connectionState) { _, newState in - if newState == .disconnected { - viewModel.requestConversationReload() - } + + Button { + showingChannelOptions = true + } label: { + Label(L10n.Chats.Chats.Compose.newChannel, systemImage: "number") } - // Surfaces direct-message and room delete failures; the channel retry alert lives on - // ChatsConversationSheets, and only one can present since deletes happen one at a time. - .errorAlert(Binding( - get: { viewModel.errorMessage }, - set: { viewModel.errorMessage = $0 } - )) - } + } label: { + Label(L10n.Chats.Chats.Compose.newMessage, systemImage: "square.and.pencil") + } + } + } + .task { + viewModel.configure( + dependencies: ChatViewModel.Dependencies( + dataStore: { appState.offlineDataStore }, + messageService: { appState.services?.messageService }, + notificationService: { appState.services?.notificationService }, + channelService: { appState.services?.channelService }, + roomServerService: { appState.services?.roomServerService }, + contactService: { appState.services?.contactService }, + syncCoordinator: { appState.syncCoordinator }, + connectionState: { appState.connectionState }, + connectedDevice: { appState.connectedDevice }, + currentRadioID: { appState.currentRadioID }, + session: { appState.services?.session }, + reactionService: { appState.services?.reactionService }, + chatSendQueueService: { appState.services?.chatSendQueueService }, + inlineImageDimensionsStore: { nil }, + prefetchDataStore: { nil } + ), + onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, + linkPreviewCache: nil, + chatCoordinatorRegistry: nil, + conversation: nil + ) + await viewModel.requestConversationReload()?.value + onAnnounceOfflineStateIfNeeded() + onHandlePendingNavigation() + onHandlePendingChannelNavigation() + onHandlePendingRoomNavigation() + } + .onChange(of: appState.navigation.pendingChatContact) { _, _ in + onHandlePendingNavigation() + } + .onChange(of: appState.navigation.pendingChannel) { _, _ in + onHandlePendingChannelNavigation() + } + .onChange(of: appState.navigation.pendingRoomSession) { _, _ in + onHandlePendingRoomNavigation() + } + .onChange(of: appState.servicesVersion) { _, _ in + viewModel.requestConversationReload() + } + .onChange(of: appState.conversationsVersion) { _, _ in + viewModel.requestConversationReload() + } + .onChange(of: appState.connectionState) { _, newState in + if newState == .disconnected { + viewModel.requestConversationReload() + } + } + // Surfaces direct-message and room delete failures; the channel retry alert lives on + // ChatsConversationSheets, and only one can present since deletes happen one at a time. + .errorAlert(Binding( + get: { viewModel.errorMessage }, + set: { viewModel.errorMessage = $0 } + )) + } } diff --git a/MC1/Views/Chats/ChatsView.swift b/MC1/Views/Chats/ChatsView.swift index ab0c6769..1981aaea 100644 --- a/MC1/Views/Chats/ChatsView.swift +++ b/MC1/Views/Chats/ChatsView.swift @@ -1,137 +1,137 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChatsView: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @State private var viewModel = ChatViewModel() - @State private var searchText = "" - @State private var selectedFilter: ChatFilter = .all - @State private var showingNewChat = false - @State private var showingChannelOptions = false + @State private var viewModel = ChatViewModel() + @State private var searchText = "" + @State private var selectedFilter: ChatFilter = .all + @State private var showingNewChat = false + @State private var showingChannelOptions = false - @State private var navigationPath = NavigationPath() - @State private var activeRoute: ChatRoute? + @State private var navigationPath = NavigationPath() + @State private var activeRoute: ChatRoute? - @State private var roomToAuthenticate: RemoteNodeSessionDTO? - @State private var roomToDelete: RemoteNodeSessionDTO? - @State private var showRoomDeleteAlert = false - @State private var showChannelDeleteFailed = false - @State private var channelDeleteFailure: ChatConversationActions.Failure? - @State private var pendingChatContact: ContactDTO? - @State private var pendingChannel: ChannelDTO? + @State private var roomToAuthenticate: RemoteNodeSessionDTO? + @State private var roomToDelete: RemoteNodeSessionDTO? + @State private var showRoomDeleteAlert = false + @State private var showChannelDeleteFailed = false + @State private var channelDeleteFailure: ChatConversationActions.Failure? + @State private var pendingChatContact: ContactDTO? + @State private var pendingChannel: ChannelDTO? - private var filteredFavorites: [Conversation] { - viewModel.favoriteConversations.filtered(by: selectedFilter, searchText: searchText) - } + private var filteredFavorites: [Conversation] { + viewModel.favoriteConversations.filtered(by: selectedFilter, searchText: searchText) + } - private var filteredOthers: [Conversation] { - viewModel.nonFavoriteConversations.filtered(by: selectedFilter, searchText: searchText) - } + private var filteredOthers: [Conversation] { + viewModel.nonFavoriteConversations.filtered(by: selectedFilter, searchText: searchText) + } - private var emptyStateMessage: (title: String, description: String, systemImage: String) { - switch selectedFilter { - case .all: - return (L10n.Chats.Chats.EmptyState.NoConversations.title, L10n.Chats.Chats.EmptyState.NoConversations.description, "message") - case .unread: - return (L10n.Chats.Chats.EmptyState.NoUnread.title, L10n.Chats.Chats.EmptyState.NoUnread.description, "checkmark.circle") - case .directMessages: - return (L10n.Chats.Chats.EmptyState.NoDirectMessages.title, L10n.Chats.Chats.EmptyState.NoDirectMessages.description, "person") - case .channels: - return (L10n.Chats.Chats.EmptyState.NoChannels.title, L10n.Chats.Chats.EmptyState.NoChannels.description, "number") - case .rooms: - return (L10n.Chats.Chats.EmptyState.NoRooms.title, L10n.Chats.Chats.EmptyState.NoRooms.description, "door.left.hand.open") - } + private var emptyStateMessage: (title: String, description: String, systemImage: String) { + switch selectedFilter { + case .all: + (L10n.Chats.Chats.EmptyState.NoConversations.title, L10n.Chats.Chats.EmptyState.NoConversations.description, "message") + case .unread: + (L10n.Chats.Chats.EmptyState.NoUnread.title, L10n.Chats.Chats.EmptyState.NoUnread.description, "checkmark.circle") + case .directMessages: + (L10n.Chats.Chats.EmptyState.NoDirectMessages.title, L10n.Chats.Chats.EmptyState.NoDirectMessages.description, "person") + case .channels: + (L10n.Chats.Chats.EmptyState.NoChannels.title, L10n.Chats.Chats.EmptyState.NoChannels.description, "number") + case .rooms: + (L10n.Chats.Chats.EmptyState.NoRooms.title, L10n.Chats.Chats.EmptyState.NoRooms.description, "door.left.hand.open") } + } - private var actions: ChatListActions { - ChatListActions( - viewModel: viewModel, - appState: appState, - roomToDelete: $roomToDelete, - showRoomDeleteAlert: $showRoomDeleteAlert, - channelDeleteFailure: $channelDeleteFailure, - showChannelDeleteFailed: $showChannelDeleteFailed, - roomToAuthenticate: $roomToAuthenticate, - navigate: { navigate(to: $0) }, - clearNavigationIfActive: clearNavigationIfActive - ) - } + private var actions: ChatListActions { + ChatListActions( + viewModel: viewModel, + appState: appState, + roomToDelete: $roomToDelete, + showRoomDeleteAlert: $showRoomDeleteAlert, + channelDeleteFailure: $channelDeleteFailure, + showChannelDeleteFailed: $showChannelDeleteFailed, + roomToAuthenticate: $roomToAuthenticate, + navigate: { navigate(to: $0) }, + clearNavigationIfActive: clearNavigationIfActive + ) + } - var body: some View { - ChatsStackLayout( - viewModel: viewModel, - navigationPath: $navigationPath, - activeRoute: $activeRoute - ) { - ChatsStackRootContent( - viewModel: viewModel, - filteredFavorites: filteredFavorites, - filteredOthers: filteredOthers, - emptyStateMessage: emptyStateMessage, - hasLoadedOnce: viewModel.hasLoadedOnce, - selectedFilter: $selectedFilter, - searchText: $searchText, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - roomToAuthenticate: $roomToAuthenticate, - navigationPath: $navigationPath, - onDeleteConversation: actions.handleDeleteConversation, - onHandlePendingNavigation: actions.handlePendingNavigation, - onHandlePendingChannelNavigation: actions.handlePendingChannelNavigation, - onHandlePendingRoomNavigation: actions.handlePendingRoomNavigation, - onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded - ) - } - .task { - actions.consumePendingRoomAuthentication() - } - .onChange(of: appState.navigation.pendingRoomAuthentication) { _, _ in - actions.consumePendingRoomAuthentication() - } - // Keep the pushed route's payload current as the snapshot recomputes; a route - // whose conversation was removed resolves to nil and the push unwinds. - .onChange(of: viewModel.snapshotGeneration) { _, _ in - activeRoute = activeRoute?.refreshedPayload(from: viewModel.allConversations) - } - .modifier(ChatsConversationSheets( - viewModel: viewModel, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - roomToAuthenticate: $roomToAuthenticate, - roomToDelete: $roomToDelete, - showRoomDeleteAlert: $showRoomDeleteAlert, - channelDeleteFailure: $channelDeleteFailure, - showChannelDeleteFailed: $showChannelDeleteFailed, - pendingChatContact: $pendingChatContact, - pendingChannel: $pendingChannel, - navigate: { navigate(to: $0) }, - deleteChannelConversation: actions.deleteChannelConversation, - deleteRoom: actions.deleteRoom - )) + var body: some View { + ChatsStackLayout( + viewModel: viewModel, + navigationPath: $navigationPath, + activeRoute: $activeRoute + ) { + ChatsStackRootContent( + viewModel: viewModel, + filteredFavorites: filteredFavorites, + filteredOthers: filteredOthers, + emptyStateMessage: emptyStateMessage, + hasLoadedOnce: viewModel.hasLoadedOnce, + selectedFilter: $selectedFilter, + searchText: $searchText, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + roomToAuthenticate: $roomToAuthenticate, + navigationPath: $navigationPath, + onDeleteConversation: actions.handleDeleteConversation, + onHandlePendingNavigation: actions.handlePendingNavigation, + onHandlePendingChannelNavigation: actions.handlePendingChannelNavigation, + onHandlePendingRoomNavigation: actions.handlePendingRoomNavigation, + onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded + ) } + .task { + actions.consumePendingRoomAuthentication() + } + .onChange(of: appState.navigation.pendingRoomAuthentication) { _, _ in + actions.consumePendingRoomAuthentication() + } + // Keep the pushed route's payload current as the snapshot recomputes; a route + // whose conversation was removed resolves to nil and the push unwinds. + .onChange(of: viewModel.snapshotGeneration) { _, _ in + activeRoute = activeRoute?.refreshedPayload(from: viewModel.allConversations) + } + .modifier(ChatsConversationSheets( + viewModel: viewModel, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + roomToAuthenticate: $roomToAuthenticate, + roomToDelete: $roomToDelete, + showRoomDeleteAlert: $showRoomDeleteAlert, + channelDeleteFailure: $channelDeleteFailure, + showChannelDeleteFailed: $showChannelDeleteFailed, + pendingChatContact: $pendingChatContact, + pendingChannel: $pendingChannel, + navigate: { navigate(to: $0) }, + deleteChannelConversation: actions.deleteChannelConversation, + deleteRoom: actions.deleteRoom + )) + } - private func navigate(to route: ChatRoute) { - if case .room(let session) = route, !session.isConnected { - roomToAuthenticate = session - return - } - - appState.navigation.tabBarVisibility = .hidden - navigationPath.removeLast(navigationPath.count) - navigationPath.append(route) + private func navigate(to route: ChatRoute) { + if case let .room(session) = route, !session.isConnected { + roomToAuthenticate = session + return } - private func clearNavigationIfActive(_ route: ChatRoute) { - if activeRoute == route { - navigationPath.removeLast(navigationPath.count) - activeRoute = nil - appState.navigation.tabBarVisibility = .visible - } + appState.navigation.tabBarVisibility = .hidden + navigationPath.removeLast(navigationPath.count) + navigationPath.append(route) + } + + private func clearNavigationIfActive(_ route: ChatRoute) { + if activeRoute == route { + navigationPath.removeLast(navigationPath.count) + activeRoute = nil + appState.navigation.tabBarVisibility = .visible } + } } #Preview { - ChatsView() - .environment(\.appState, AppState()) + ChatsView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Components/AnimatedGIFView.swift b/MC1/Views/Chats/Components/AnimatedGIFView.swift index 7098764c..c1b867ba 100644 --- a/MC1/Views/Chats/Components/AnimatedGIFView.swift +++ b/MC1/Views/Chats/Components/AnimatedGIFView.swift @@ -3,23 +3,23 @@ import UIKit /// UIViewRepresentable that renders animated GIF data using UIImageView struct AnimatedGIFView: UIViewRepresentable { - let image: UIImage + let image: UIImage - func makeUIView(context: Context) -> UIImageView { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFit - imageView.clipsToBounds = true - imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - imageView.image = image - return imageView - } + func makeUIView(context: Context) -> UIImageView { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + imageView.image = image + return imageView + } - func updateUIView(_ imageView: UIImageView, context: Context) { - imageView.image = image - } + func updateUIView(_ imageView: UIImageView, context: Context) { + imageView.image = image + } - static func dismantleUIView(_ imageView: UIImageView, coordinator: ()) { - imageView.image = nil - } + static func dismantleUIView(_ imageView: UIImageView, coordinator: ()) { + imageView.image = nil + } } diff --git a/MC1/Views/Chats/Components/BubbleActions.swift b/MC1/Views/Chats/Components/BubbleActions.swift index 85561638..0fdc3175 100644 --- a/MC1/Views/Chats/Components/BubbleActions.swift +++ b/MC1/Views/Chats/Components/BubbleActions.swift @@ -1,7 +1,7 @@ import CoreLocation +import MC1Services import SwiftUI import UIKit -import MC1Services /// Per-row action wiring for `MessageBubbleView`. Each closure is invoked /// in response to a user interaction on a bubble and forwards to the @@ -18,17 +18,17 @@ import MC1Services /// message identity, which is captured by `MessageItem.id`. @MainActor struct BubbleActions { - let onRetryMessage: (MessageDTO) -> Void - let onReaction: (String, MessageDTO) -> Void - let onLongPress: (MessageDTO) -> Void - let onImageTap: (MessageDTO) -> Void - let onRetryInlineImage: (UUID) -> Void - let onRequestPreviewFetch: (UUID) -> Void - let onManualPreviewFetch: (UUID) -> Void - let onMapPreviewTap: (CLLocationCoordinate2D) -> Void - /// Map snapshot providers, injected so the bubble resolves, requests, and - /// retries thumbnails without reaching `MapSnapshotStore.shared`. - let snapshotResolver: (MapSnapshotRequest) -> UIImage? - let requestSnapshot: (MapSnapshotRequest) -> Void - let retrySnapshot: (MapSnapshotRequest) -> Void + let onRetryMessage: (MessageDTO) -> Void + let onReaction: (String, MessageDTO) -> Void + let onLongPress: (MessageDTO) -> Void + let onImageTap: (MessageDTO) -> Void + let onRetryInlineImage: (UUID) -> Void + let onRequestPreviewFetch: (UUID) -> Void + let onManualPreviewFetch: (UUID) -> Void + let onMapPreviewTap: (CLLocationCoordinate2D) -> Void + /// Map snapshot providers, injected so the bubble resolves, requests, and + /// retries thumbnails without reaching `MapSnapshotStore.shared`. + let snapshotResolver: (MapSnapshotRequest) -> UIImage? + let requestSnapshot: (MapSnapshotRequest) -> Void + let retrySnapshot: (MapSnapshotRequest) -> Void } diff --git a/MC1/Views/Chats/Components/BubbleContentPadding.swift b/MC1/Views/Chats/Components/BubbleContentPadding.swift index 9a0457fe..47b689eb 100644 --- a/MC1/Views/Chats/Components/BubbleContentPadding.swift +++ b/MC1/Views/Chats/Components/BubbleContentPadding.swift @@ -1,8 +1,8 @@ import SwiftUI extension View { - func bubbleContentPadding() -> some View { - padding(.horizontal, 10) - .padding(.vertical, 8) - } + func bubbleContentPadding() -> some View { + padding(.horizontal, 10) + .padding(.vertical, 8) + } } diff --git a/MC1/Views/Chats/Components/BubbleFooterRow.swift b/MC1/Views/Chats/Components/BubbleFooterRow.swift index 41e3c8fd..79b8c754 100644 --- a/MC1/Views/Chats/Components/BubbleFooterRow.swift +++ b/MC1/Views/Chats/Components/BubbleFooterRow.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// SF Symbol shown beside the send time when the sender's clock was invalid and the /// app substituted a corrected value, signalling that the displayed time was adjusted. @@ -12,111 +12,111 @@ private let correctedClockBadgeSymbol = "clock.badge.exclamationmark" /// `.accessibilityElement(children: .combine)` so VoiceOver surfaces the row as /// a single rotor stop. struct BubbleFooterRow: View { - let footer: MessageFooter - let dynamicTypeSize: DynamicTypeSize + let footer: MessageFooter + let dynamicTypeSize: DynamicTypeSize - var body: some View { - if dynamicTypeSize.isAccessibilitySize { - VStack(alignment: .leading, spacing: 2) { - footerContents(allowsWrap: true) - } - .accessibilityElement(children: .combine) - } else { - HStack(spacing: 4) { - footerContents(allowsWrap: false) - } - .accessibilityElement(children: .combine) - } + var body: some View { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 2) { + footerContents(allowsWrap: true) + } + .accessibilityElement(children: .combine) + } else { + HStack(spacing: 4) { + footerContents(allowsWrap: false) + } + .accessibilityElement(children: .combine) } + } - @ViewBuilder - private func footerContents(allowsWrap: Bool) -> some View { - if let sendTime = footer.sendTimeToShow { - BubbleSendTimeFooter(date: sendTime, wasCorrected: footer.sendTimeWasCorrected) - } - if footer.showHop { - BubbleHopCountFooter(hopCount: footer.hopCount) - } - if let formattedPath = footer.formattedPath { - BubblePathFooter(formattedPath: formattedPath) - } - if let region = footer.regionToShow { - BubbleRegionFooter(regionName: region, allowsWrap: allowsWrap) - } + @ViewBuilder + private func footerContents(allowsWrap: Bool) -> some View { + if let sendTime = footer.sendTimeToShow { + BubbleSendTimeFooter(date: sendTime, wasCorrected: footer.sendTimeWasCorrected) + } + if footer.showHop { + BubbleHopCountFooter(hopCount: footer.hopCount) + } + if let formattedPath = footer.formattedPath { + BubblePathFooter(formattedPath: formattedPath) } + if let region = footer.regionToShow { + BubbleRegionFooter(regionName: region, allowsWrap: allowsWrap) + } + } } private struct BubbleSendTimeFooter: View { - let date: Date - let wasCorrected: Bool + let date: Date + let wasCorrected: Bool - private var timeText: String { - date.formatted(date: .omitted, time: .shortened) - } + private var timeText: String { + date.formatted(date: .omitted, time: .shortened) + } - private var accessibilityLabel: String { - wasCorrected - ? L10n.Chats.Chats.Message.SendTime.correctedAccessibilityLabel(timeText) - : L10n.Chats.Chats.Message.SendTime.accessibilityLabel(timeText) - } + private var accessibilityLabel: String { + wasCorrected + ? L10n.Chats.Chats.Message.SendTime.correctedAccessibilityLabel(timeText) + : L10n.Chats.Chats.Message.SendTime.accessibilityLabel(timeText) + } - var body: some View { - HStack(spacing: 4) { - if wasCorrected { - Image(systemName: correctedClockBadgeSymbol) - } - Text(timeText) - } - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) + var body: some View { + HStack(spacing: 4) { + if wasCorrected { + Image(systemName: correctedClockBadgeSymbol) + } + Text(timeText) } + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } } private struct BubbleHopCountFooter: View { - let hopCount: Int + let hopCount: Int - var body: some View { - HStack(spacing: 4) { - Image(systemName: "arrowshape.bounce.right") - Text("\(hopCount)") - } - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(hopCount)) + var body: some View { + HStack(spacing: 4) { + Image(systemName: "arrowshape.bounce.right") + Text("\(hopCount)") } + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(hopCount)) + } } private struct BubblePathFooter: View { - let formattedPath: String + let formattedPath: String - var body: some View { - HStack(spacing: 4) { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - Text(formattedPath) - } - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath)) + var body: some View { + HStack(spacing: 4) { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + Text(formattedPath) } + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath)) + } } private struct BubbleRegionFooter: View { - let regionName: String - let allowsWrap: Bool + let regionName: String + let allowsWrap: Bool - var body: some View { - HStack(spacing: 4) { - Image(systemName: "globe") - Text(regionName) - .lineLimit(allowsWrap ? nil : 1) - } - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Message.Region.accessibilityLabel(regionName)) + var body: some View { + HStack(spacing: 4) { + Image(systemName: "globe") + Text(regionName) + .lineLimit(allowsWrap ? nil : 1) } + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Message.Region.accessibilityLabel(regionName)) + } } diff --git a/MC1/Views/Chats/Components/BubbleFragmentStack.swift b/MC1/Views/Chats/Components/BubbleFragmentStack.swift index 30deb37f..44b5eb74 100644 --- a/MC1/Views/Chats/Components/BubbleFragmentStack.swift +++ b/MC1/Views/Chats/Components/BubbleFragmentStack.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// The colored, clipped bubble box: text, optional footer, and optional inline /// image. Reactions, malware warnings, and link previews are emitted as @@ -10,68 +10,68 @@ import MC1Services /// invalidate body on every visible cell. Closures and dynamic-type /// environment changes propagate through the parent rebody path. struct BubbleFragmentStack: View, Equatable { - /// Corner radius for the bubble box, shared by the text-only shape-fill - /// background, the inline-image clip path, and the context-menu lift shape - /// applied by `UnifiedMessageBubble`. - static let cornerRadius: CGFloat = 16 + /// Corner radius for the bubble box, shared by the text-only shape-fill + /// background, the inline-image clip path, and the context-menu lift shape + /// applied by `UnifiedMessageBubble`. + static let cornerRadius: CGFloat = 16 - let item: MessageItem - /// The box-resident text and inline image from the shared partition. - /// Excluded from `==` (which stays `item`/`bubbleColor`): it is a pure - /// function of `item.content`, so equal items yield equal box fragments. - let layout: FragmentLayout - let bubbleColor: Color - let callbacks: MessageBubbleCallbacks - let imageResolver: (ImageReference) -> UIImage? + let item: MessageItem + /// The box-resident text and inline image from the shared partition. + /// Excluded from `==` (which stays `item`/`bubbleColor`): it is a pure + /// function of `item.content`, so equal items yield equal box fragments. + let layout: FragmentLayout + let bubbleColor: Color + let callbacks: MessageBubbleCallbacks + let imageResolver: (ImageReference) -> UIImage? - @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dynamicTypeSize) private var dynamicTypeSize - nonisolated static func == (lhs: BubbleFragmentStack, rhs: BubbleFragmentStack) -> Bool { - lhs.item == rhs.item && lhs.bubbleColor == rhs.bubbleColor - } - - private var hasFooter: Bool { - item.footer.sendTimeToShow != nil - || item.footer.showHop - || item.footer.formattedPath != nil - || item.footer.regionToShow != nil - } + nonisolated static func == (lhs: BubbleFragmentStack, rhs: BubbleFragmentStack) -> Bool { + lhs.item == rhs.item && lhs.bubbleColor == rhs.bubbleColor + } - var body: some View { - let stack = VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 4) { - if let textPayload = layout.textPayload { - MessageTextView(text: textPayload) - } + private var hasFooter: Bool { + item.footer.sendTimeToShow != nil + || item.footer.showHop + || item.footer.formattedPath != nil + || item.footer.regionToShow != nil + } - if !item.envelope.isOutgoing && hasFooter { - BubbleFooterRow(footer: item.footer, dynamicTypeSize: dynamicTypeSize) - } - } - .bubbleContentPadding() - - if let inlineImage = layout.inlineImage { - InlineImageFragmentView( - inlineImage: inlineImage, - isOutgoing: item.envelope.isOutgoing, - imageResolver: imageResolver, - onTap: { callbacks.onImageTap?() }, - onRetry: { callbacks.onRetryInlineImage?() } - ) - } + var body: some View { + let stack = VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + if let textPayload = layout.textPayload { + MessageTextView(text: textPayload) } - if layout.inlineImage == nil { - // Text-only bubbles fill a rounded shape directly. Drawing the - // background as a shape avoids the mask `.clipShape` installs, which - // forces an offscreen render pass per bubble while scrolling. - stack.background(bubbleColor, in: .rect(cornerRadius: Self.cornerRadius)) - } else { - // Image bubbles keep the clip so the edge-to-edge image inherits the - // rounded corners. - stack - .background(bubbleColor) - .clipShape(.rect(cornerRadius: Self.cornerRadius)) + if !item.envelope.isOutgoing, hasFooter { + BubbleFooterRow(footer: item.footer, dynamicTypeSize: dynamicTypeSize) } + } + .bubbleContentPadding() + + if let inlineImage = layout.inlineImage { + InlineImageFragmentView( + inlineImage: inlineImage, + isOutgoing: item.envelope.isOutgoing, + imageResolver: imageResolver, + onTap: { callbacks.onImageTap?() }, + onRetry: { callbacks.onRetryInlineImage?() } + ) + } + } + + if layout.inlineImage == nil { + // Text-only bubbles fill a rounded shape directly. Drawing the + // background as a shape avoids the mask `.clipShape` installs, which + // forces an offscreen render pass per bubble while scrolling. + stack.background(bubbleColor, in: .rect(cornerRadius: Self.cornerRadius)) + } else { + // Image bubbles keep the clip so the edge-to-edge image inherits the + // rounded corners. + stack + .background(bubbleColor) + .clipShape(.rect(cornerRadius: Self.cornerRadius)) } + } } diff --git a/MC1/Views/Chats/Components/BubbleResolver.swift b/MC1/Views/Chats/Components/BubbleResolver.swift index 0e7bf401..4f085382 100644 --- a/MC1/Views/Chats/Components/BubbleResolver.swift +++ b/MC1/Views/Chats/Components/BubbleResolver.swift @@ -1,6 +1,6 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services /// Read-only per-message lookups consumed by `MessageBubbleView`. /// @@ -17,31 +17,31 @@ import MC1Services /// during `rebuildDisplayItem`. @MainActor struct BubbleResolver { - /// Resolves the full `MessageDTO` for a `MessageItem`. Returns `nil` - /// when the message has been deleted out from under the timeline. - let message: (MessageItem) -> MessageDTO? + /// Resolves the full `MessageDTO` for a `MessageItem`. Returns `nil` + /// when the message has been deleted out from under the timeline. + let message: (MessageItem) -> MessageDTO? - /// Resolves a decoded `UIImage` for an `ImageReference` (inline, link - /// preview hero, or link preview icon). - let image: (ImageReference) -> UIImage? + /// Resolves a decoded `UIImage` for an `ImageReference` (inline, link + /// preview hero, or link preview icon). + let image: (ImageReference) -> UIImage? } extension BubbleResolver { - /// Convenience: build a resolver that proxies to a `ChatViewModel`. - /// Per-message reads route through the VM's existing storage. - init(viewModel: ChatViewModel) { - self.init( - message: { item in viewModel.message(for: item) }, - image: { ref in - switch ref.role { - case .inline: - return viewModel.decodedImage(for: ref.cacheKey) - case .linkPreviewImage: - return viewModel.decodedPreviewImage(for: ref.cacheKey) - case .linkPreviewIcon: - return viewModel.decodedPreviewIcon(for: ref.cacheKey) - } - } - ) - } + /// Convenience: build a resolver that proxies to a `ChatViewModel`. + /// Per-message reads route through the VM's existing storage. + init(viewModel: ChatViewModel) { + self.init( + message: { item in viewModel.message(for: item) }, + image: { ref in + switch ref.role { + case .inline: + viewModel.decodedImage(for: ref.cacheKey) + case .linkPreviewImage: + viewModel.decodedPreviewImage(for: ref.cacheKey) + case .linkPreviewIcon: + viewModel.decodedPreviewIcon(for: ref.cacheKey) + } + } + ) + } } diff --git a/MC1/Views/Chats/Components/BubbleStatusRow.swift b/MC1/Views/Chats/Components/BubbleStatusRow.swift index 118ddf2b..52362f50 100644 --- a/MC1/Views/Chats/Components/BubbleStatusRow.swift +++ b/MC1/Views/Chats/Components/BubbleStatusRow.swift @@ -1,96 +1,96 @@ -import SwiftUI import MC1Services +import SwiftUI /// Outgoing-message status row: retry button (when failed), failure glyph, and /// status text. The static `statusText(for:)` helper is also consumed by /// `UnifiedMessageBubble.accessibilityMessageLabel` so VoiceOver surfaces the /// same text as the visual row. struct BubbleStatusRow: View { - let item: MessageItem - let onRetry: (() -> Void)? + let item: MessageItem + let onRetry: (() -> Void)? - @State private var retryInvocationCounter: Int = 0 + @State private var retryInvocationCounter: Int = 0 - var body: some View { - HStack(spacing: 4) { - if item.footer.status == .failed, let onRetry { - Button { - retryInvocationCounter &+= 1 - onRetry() - } label: { - HStack(spacing: 2) { - Image(systemName: "arrow.clockwise") - Text(L10n.Chats.Chats.Message.Status.retry) - } - .font(.caption2) - .contentShape(.rect) - } - .buttonStyle(.plain) - .foregroundStyle(.blue) - .sensoryFeedback(.selection, trigger: retryInvocationCounter) - } + var body: some View { + HStack(spacing: 4) { + if item.footer.status == .failed, let onRetry { + Button { + retryInvocationCounter &+= 1 + onRetry() + } label: { + HStack(spacing: 2) { + Image(systemName: "arrow.clockwise") + Text(L10n.Chats.Chats.Message.Status.retry) + } + .font(.caption2) + .contentShape(.rect) + } + .buttonStyle(.plain) + .foregroundStyle(.blue) + .sensoryFeedback(.selection, trigger: retryInvocationCounter) + } - if item.footer.status == .failed { - Image(systemName: "exclamationmark.circle") - .font(.caption2) - .foregroundStyle(.red) - } + if item.footer.status == .failed { + Image(systemName: "exclamationmark.circle") + .font(.caption2) + .foregroundStyle(.red) + } - Text(Self.statusText(for: item)) - .font(.caption2) - .foregroundStyle(.secondary) - .contentTransition(.opacity) - } - .padding(.trailing, 4) + Text(Self.statusText(for: item)) + .font(.caption2) + .foregroundStyle(.secondary) + .contentTransition(.opacity) } + .padding(.trailing, 4) + } - static func statusText(for item: MessageItem) -> String { - switch item.footer.status { - case .pending, .sending: - return L10n.Chats.Chats.Message.Status.sending - case .sent: - // A DM `.sent` means only that the radio queued the packet, not that it - // was delivered; a missing end-to-end ACK later flips the row to `.failed`. - // Render it as in-progress so the user never sees a settled "Sent" that - // becomes "Failed". A channel `.sent` has no ACK and is terminal success. - guard item.footer.isChannelMessage else { - return L10n.Chats.Chats.Message.Status.sending - } - var parts: [String] = [] - if item.footer.heardRepeats > 0 { - let repeatWord = item.footer.heardRepeats == 1 - ? L10n.Chats.Chats.Message.Repeat.singular - : L10n.Chats.Chats.Message.Repeat.plural - parts.append("\(item.footer.heardRepeats) \(repeatWord)") - } - if item.footer.sendCount > 1 { - parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(item.footer.sendCount)) - } else { - parts.append(L10n.Chats.Chats.Message.Status.sent) - } - return parts.joined(separator: " • ") - case .delivered: - var parts: [String] = [] - if item.footer.heardRepeats > 0 { - let repeatWord = item.footer.heardRepeats == 1 - ? L10n.Chats.Chats.Message.Repeat.singular - : L10n.Chats.Chats.Message.Repeat.plural - parts.append("\(item.footer.heardRepeats) \(repeatWord)") - } - parts.append(L10n.Chats.Chats.Message.Status.delivered) - if item.footer.sendCount > 1 { - parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(item.footer.sendCount)) - } - return parts.joined(separator: " • ") - case .failed: - return L10n.Chats.Chats.Message.Status.failed - case .retrying: - let displayAttempt = item.footer.retryAttempt + 1 - let maxAttempts = item.footer.maxRetryAttempts - if maxAttempts > 0 { - return L10n.Chats.Chats.Message.Status.retryingAttempt(displayAttempt, maxAttempts) - } - return L10n.Chats.Chats.Message.Status.retrying - } + static func statusText(for item: MessageItem) -> String { + switch item.footer.status { + case .pending, .sending: + return L10n.Chats.Chats.Message.Status.sending + case .sent: + // A DM `.sent` means only that the radio queued the packet, not that it + // was delivered; a missing end-to-end ACK later flips the row to `.failed`. + // Render it as in-progress so the user never sees a settled "Sent" that + // becomes "Failed". A channel `.sent` has no ACK and is terminal success. + guard item.footer.isChannelMessage else { + return L10n.Chats.Chats.Message.Status.sending + } + var parts: [String] = [] + if item.footer.heardRepeats > 0 { + let repeatWord = item.footer.heardRepeats == 1 + ? L10n.Chats.Chats.Message.Repeat.singular + : L10n.Chats.Chats.Message.Repeat.plural + parts.append("\(item.footer.heardRepeats) \(repeatWord)") + } + if item.footer.sendCount > 1 { + parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(item.footer.sendCount)) + } else { + parts.append(L10n.Chats.Chats.Message.Status.sent) + } + return parts.joined(separator: " • ") + case .delivered: + var parts: [String] = [] + if item.footer.heardRepeats > 0 { + let repeatWord = item.footer.heardRepeats == 1 + ? L10n.Chats.Chats.Message.Repeat.singular + : L10n.Chats.Chats.Message.Repeat.plural + parts.append("\(item.footer.heardRepeats) \(repeatWord)") + } + parts.append(L10n.Chats.Chats.Message.Status.delivered) + if item.footer.sendCount > 1 { + parts.append(L10n.Chats.Chats.Message.Status.sentMultiple(item.footer.sendCount)) + } + return parts.joined(separator: " • ") + case .failed: + return L10n.Chats.Chats.Message.Status.failed + case .retrying: + let displayAttempt = item.footer.retryAttempt + 1 + let maxAttempts = item.footer.maxRetryAttempts + if maxAttempts > 0 { + return L10n.Chats.Chats.Message.Status.retryingAttempt(displayAttempt, maxAttempts) + } + return L10n.Chats.Chats.Message.Status.retrying } + } } diff --git a/MC1/Views/Chats/Components/ChatCellContentFactory.swift b/MC1/Views/Chats/Components/ChatCellContentFactory.swift index cd474fb2..c08f1397 100644 --- a/MC1/Views/Chats/Components/ChatCellContentFactory.swift +++ b/MC1/Views/Chats/Components/ChatCellContentFactory.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Builds the SwiftUI body for a chat cell from a `MessageItem`. Owned by /// `ChatMessagesTableView` for the lifetime of the bound `ChatViewModel` @@ -12,36 +12,36 @@ import MC1Services /// `Sendable`; bubble views already run on the main actor. @MainActor struct ChatCellContentFactory { - let contactName: String - let deviceName: String - let configuration: MessageBubbleConfiguration - /// The active theme, injected into each hosted cell. Custom environment values do not - /// auto-cross the `UIHostingConfiguration` boundary, so the bubble fill would otherwise - /// see only `Theme.default`. The factory carries the current theme on every render, but a - /// visible cell adopts a theme change only when it is reloaded — driven by - /// `reconfigureAllItems()`, which the table fires when its tracked theme id changes — or - /// recycled; re-wiring the cell closure alone does not re-host live cells. - let theme: Theme - /// The chat-content link router, injected into each hosted cell. Like `\.appTheme`, the - /// `\.openURL` action does not cross the `UIHostingConfiguration` boundary, so a message - /// link (coordinate, mention, hashtag, contact, channel) would otherwise reach the default - /// system handler and never route through `ChatLinkRouter`. The factory carries the action - /// the surrounding `mentionTapHandling` installed; a live cell adopts a change only on - /// reconfigure or recycle, matching the theme injection. - let openURL: OpenURLAction - let resolver: BubbleResolver - let actions: BubbleActions + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + /// The active theme, injected into each hosted cell. Custom environment values do not + /// auto-cross the `UIHostingConfiguration` boundary, so the bubble fill would otherwise + /// see only `Theme.default`. The factory carries the current theme on every render, but a + /// visible cell adopts a theme change only when it is reloaded — driven by + /// `reconfigureAllItems()`, which the table fires when its tracked theme id changes — or + /// recycled; re-wiring the cell closure alone does not re-host live cells. + let theme: Theme + /// The chat-content link router, injected into each hosted cell. Like `\.appTheme`, the + /// `\.openURL` action does not cross the `UIHostingConfiguration` boundary, so a message + /// link (coordinate, mention, hashtag, contact, channel) would otherwise reach the default + /// system handler and never route through `ChatLinkRouter`. The factory carries the action + /// the surrounding `mentionTapHandling` installed; a live cell adopts a change only on + /// reconfigure or recycle, matching the theme injection. + let openURL: OpenURLAction + let resolver: BubbleResolver + let actions: BubbleActions - func makeContent(for item: MessageItem) -> some View { - MessageBubbleView( - item: item, - contactName: contactName, - deviceName: deviceName, - configuration: configuration, - resolver: resolver, - actions: actions - ) - .environment(\.appTheme, theme) - .environment(\.openURL, openURL) - } + func makeContent(for item: MessageItem) -> some View { + MessageBubbleView( + item: item, + contactName: contactName, + deviceName: deviceName, + configuration: configuration, + resolver: resolver, + actions: actions + ) + .environment(\.appTheme, theme) + .environment(\.openURL, openURL) + } } diff --git a/MC1/Views/Chats/Components/ChatComposerProxy.swift b/MC1/Views/Chats/Components/ChatComposerProxy.swift index d512d640..660e290a 100644 --- a/MC1/Views/Chats/Components/ChatComposerProxy.swift +++ b/MC1/Views/Chats/Components/ChatComposerProxy.swift @@ -5,9 +5,9 @@ import UIKit /// send. `ChatComposerTextView` wires its text view in on creation. @MainActor final class ChatComposerProxy { - weak var textView: ChatComposerUITextView? + weak var textView: ChatComposerUITextView? - func commitPendingInput() { - textView?.commitPendingInput() - } + func commitPendingInput() { + textView?.commitPendingInput() + } } diff --git a/MC1/Views/Chats/Components/ChatComposerTextView.swift b/MC1/Views/Chats/Components/ChatComposerTextView.swift index a7ff61fc..69c168f8 100644 --- a/MC1/Views/Chats/Components/ChatComposerTextView.swift +++ b/MC1/Views/Chats/Components/ChatComposerTextView.swift @@ -20,217 +20,219 @@ import UIKit /// `maxVisibleLines`, so the field wraps to the offered width and grows downward /// instead of stretching the input bar. struct ChatComposerTextView: UIViewRepresentable { - @Binding var text: String - /// Incremented by the parent to request focus; compared against the - /// coordinator's last-applied value so each request fires exactly once. - let focusRequest: Int - let isEncrypted: Bool - /// Receives the text view on creation so the parent can finalize IME - /// composition before reading the text to send. - let proxy: ChatComposerProxy - /// Attempts a send. Returns `true` when a message was sent (Return is then - /// consumed and focus retained), `false` when gated off (Return inserts a - /// newline instead). - let onSend: () -> Bool - - func makeUIView(context: Context) -> ChatComposerUITextView { - let textView = ChatComposerUITextView(usingTextLayoutManager: false) - textView.delegate = context.coordinator - textView.onSend = onSend - textView.font = UIFont.preferredFont(forTextStyle: .body) - textView.adjustsFontForContentSizeCategory = true - textView.backgroundColor = .clear - textView.inlinePredictionType = .default - // Keep scrolling enabled at all sizes. When it is disabled, UITextView's - // private scroll-to-visible adjusts the containing scroll view instead of - // itself, which throws the caret outside the field while it collapses after - // a send. Height is driven by `sizeThatFits`, so the view only ever scrolls - // its own content once it exceeds the visible-line cap. - textView.isScrollEnabled = true - textView.alwaysBounceVertical = false - textView.textContainerInset = UIEdgeInsets( - top: ChatComposerUITextView.verticalInset, - left: 0, - bottom: ChatComposerUITextView.verticalInset, - right: 0 - ) - textView.textContainer.lineFragmentPadding = 0 - textView.setContentHuggingPriority(.defaultLow, for: .horizontal) - textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - proxy.textView = textView - - textView.accessibilityLabel = L10n.Chats.Chats.Input.accessibilityLabel - textView.accessibilityHint = L10n.Chats.Chats.Input.accessibilityHint - return textView + @Binding var text: String + /// Incremented by the parent to request focus; compared against the + /// coordinator's last-applied value so each request fires exactly once. + let focusRequest: Int + let isEncrypted: Bool + /// Receives the text view on creation so the parent can finalize IME + /// composition before reading the text to send. + let proxy: ChatComposerProxy + /// Attempts a send. Returns `true` when a message was sent (Return is then + /// consumed and focus retained), `false` when gated off (Return inserts a + /// newline instead). + let onSend: () -> Bool + + func makeUIView(context: Context) -> ChatComposerUITextView { + let textView = ChatComposerUITextView(usingTextLayoutManager: false) + textView.delegate = context.coordinator + textView.onSend = onSend + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.adjustsFontForContentSizeCategory = true + textView.backgroundColor = .clear + textView.inlinePredictionType = .default + // Keep scrolling enabled at all sizes. When it is disabled, UITextView's + // private scroll-to-visible adjusts the containing scroll view instead of + // itself, which throws the caret outside the field while it collapses after + // a send. Height is driven by `sizeThatFits`, so the view only ever scrolls + // its own content once it exceeds the visible-line cap. + textView.isScrollEnabled = true + textView.alwaysBounceVertical = false + textView.textContainerInset = UIEdgeInsets( + top: ChatComposerUITextView.verticalInset, + left: 0, + bottom: ChatComposerUITextView.verticalInset, + right: 0 + ) + textView.textContainer.lineFragmentPadding = 0 + textView.setContentHuggingPriority(.defaultLow, for: .horizontal) + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + proxy.textView = textView + + textView.accessibilityLabel = L10n.Chats.Chats.Input.accessibilityLabel + textView.accessibilityHint = L10n.Chats.Chats.Input.accessibilityHint + return textView + } + + func updateUIView(_ textView: ChatComposerUITextView, context: Context) { + context.coordinator.parent = self + textView.onSend = onSend + textView.accessibilityValue = isEncrypted + ? L10n.Chats.Chats.Input.encrypted + : L10n.Chats.Chats.Input.notEncrypted + + if textView.text != text { + if text.isEmpty { + textView.clearAfterSend() + } else { + textView.text = text + } } - func updateUIView(_ textView: ChatComposerUITextView, context: Context) { - context.coordinator.parent = self - textView.onSend = onSend - textView.accessibilityValue = isEncrypted - ? L10n.Chats.Chats.Input.encrypted - : L10n.Chats.Chats.Input.notEncrypted - - if textView.text != text { - if text.isEmpty { - textView.clearAfterSend() - } else { - textView.text = text - } - } - - // Become first responder once per token increment. There is no resign - // path, so native dismissal is left untouched. - if focusRequest != context.coordinator.lastFocusRequest { - context.coordinator.lastFocusRequest = focusRequest - if !textView.isFirstResponder { - Task { @MainActor in - guard textView.window != nil else { return } - textView.becomeFirstResponder() - } - } + // Become first responder once per token increment. There is no resign + // path, so native dismissal is left untouched. + if focusRequest != context.coordinator.lastFocusRequest { + context.coordinator.lastFocusRequest = focusRequest + if !textView.isFirstResponder { + Task { @MainActor in + guard textView.window != nil else { return } + textView.becomeFirstResponder() } + } } + } - func sizeThatFits(_ proposal: ProposedViewSize, uiView: ChatComposerUITextView, context: Context) -> CGSize? { - if let width = proposal.width, width.isFinite, width > 0 { - return CGSize(width: width, height: uiView.clampedHeight(forWidth: width)) - } - // Ideal/unbounded query: claim minimal width so the surrounding HStack - // treats the field as fully flexible and hands it the leftover width, - // rather than stretching to the text's single-line content width. - return CGSize(width: 0, height: uiView.clampedHeight(forWidth: max(uiView.bounds.width, 1))) + func sizeThatFits(_ proposal: ProposedViewSize, uiView: ChatComposerUITextView, context: Context) -> CGSize? { + if let width = proposal.width, width.isFinite, width > 0 { + return CGSize(width: width, height: uiView.clampedHeight(forWidth: width)) + } + // Ideal/unbounded query: claim minimal width so the surrounding HStack + // treats the field as fully flexible and hands it the leftover width, + // rather than stretching to the text's single-line content width. + return CGSize(width: 0, height: uiView.clampedHeight(forWidth: max(uiView.bounds.width, 1))) + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + @MainActor + final class Coordinator: NSObject, UITextViewDelegate { + var parent: ChatComposerTextView + /// Last `focusRequest` value acted on, so a request fires only once. + var lastFocusRequest: Int + + init(_ parent: ChatComposerTextView) { + self.parent = parent + lastFocusRequest = parent.focusRequest } - func makeCoordinator() -> Coordinator { Coordinator(self) } - - @MainActor - final class Coordinator: NSObject, UITextViewDelegate { - var parent: ChatComposerTextView - /// Last `focusRequest` value acted on, so a request fires only once. - var lastFocusRequest: Int - - init(_ parent: ChatComposerTextView) { - self.parent = parent - self.lastFocusRequest = parent.focusRequest - } - - func textViewDidChange(_ textView: UITextView) { - // Skip the redundant write when the text already matches the binding. - // The post-send clear edits the field from inside `updateUIView`, which - // fires this delegate synchronously; writing the binding mid-update is - // disallowed, and the value is already empty there, so guarding avoids it. - if parent.text != textView.text { - parent.text = textView.text - } - } + func textViewDidChange(_ textView: UITextView) { + // Skip the redundant write when the text already matches the binding. + // The post-send clear edits the field from inside `updateUIView`, which + // fires this delegate synchronously; writing the binding mid-update is + // disallowed, and the value is already empty there, so guarding avoids it. + if parent.text != textView.text { + parent.text = textView.text + } } + } } /// `UITextView` subclass that sends on an unmodified hardware Return and measures a /// wrapped height from one line up to `maxVisibleLines`, scrolling beyond that. final class ChatComposerUITextView: UITextView { - static let verticalInset: CGFloat = 8 - static let maxVisibleLines = 5 - - /// Key-command input for a hardware Return. - private static let returnInput = "\r" - /// Key-command input for a physical numpad Enter (Mac). - private static let numpadEnterInput = "\u{3}" - - var onSend: (() -> Bool)? - - /// Clears the field after a send through the text-input editing path. - /// - /// Assigning `text = ""` runs a private caret-reset that lives in the text - /// input system, outside `UIView`/`CATransaction` control, so it can't be - /// suppressed by `performWithoutAnimation`. Replacing the full range instead - /// moves the caret as a discrete edit, the same as deleting, which keeps it - /// from animating to the start as the field collapses. `unmarkText` first - /// commits any in-progress IME composition so the replace deletes everything. - /// - /// The `inputDelegate` notifications bracket the edit so the keyboard resyncs - /// its prediction context to the empty document; otherwise the edit bypasses the - /// text-input pipeline and leaves stale ghost-text or extends the last sent word. - func clearAfterSend() { - inputDelegate?.textWillChange(self) - inputDelegate?.selectionWillChange(self) - unmarkText() - if let fullRange = textRange(from: beginningOfDocument, to: endOfDocument) { - replace(fullRange, withText: "") - } - inputDelegate?.selectionDidChange(self) - inputDelegate?.textDidChange(self) - contentOffset = .zero - } - - /// Commits a marked IME composition and any pending autocorrect so a send - /// captures what the field shows. The input-delegate notifications flush the - /// autocorrect candidate; `unmarkText` commits the composition. Both are - /// synchronous, so the bound text is current the moment this returns. - func commitPendingInput() { - guard isFirstResponder else { return } - inputDelegate?.selectionWillChange(self) - inputDelegate?.selectionDidChange(self) - unmarkText() + static let verticalInset: CGFloat = 8 + static let maxVisibleLines = 5 + + /// Key-command input for a hardware Return. + private static let returnInput = "\r" + /// Key-command input for a physical numpad Enter (Mac). + private static let numpadEnterInput = "\u{3}" + + var onSend: (() -> Bool)? + + /// Clears the field after a send through the text-input editing path. + /// + /// Assigning `text = ""` runs a private caret-reset that lives in the text + /// input system, outside `UIView`/`CATransaction` control, so it can't be + /// suppressed by `performWithoutAnimation`. Replacing the full range instead + /// moves the caret as a discrete edit, the same as deleting, which keeps it + /// from animating to the start as the field collapses. `unmarkText` first + /// commits any in-progress IME composition so the replace deletes everything. + /// + /// The `inputDelegate` notifications bracket the edit so the keyboard resyncs + /// its prediction context to the empty document; otherwise the edit bypasses the + /// text-input pipeline and leaves stale ghost-text or extends the last sent word. + func clearAfterSend() { + inputDelegate?.textWillChange(self) + inputDelegate?.selectionWillChange(self) + unmarkText() + if let fullRange = textRange(from: beginningOfDocument, to: endOfDocument) { + replace(fullRange, withText: "") } - - private var lineHeight: CGFloat { - (font ?? UIFont.preferredFont(forTextStyle: .body)).lineHeight - } - - private var minHeight: CGFloat { - ceil(lineHeight) + textContainerInset.top + textContainerInset.bottom - } - - private var maxHeight: CGFloat { - ceil(lineHeight * CGFloat(Self.maxVisibleLines)) + textContainerInset.top + textContainerInset.bottom - } - - /// Height the text needs when wrapped to `width`, clamped to the visible-line - /// range, enabling internal scrolling once the text exceeds the cap. - func clampedHeight(forWidth width: CGFloat) -> CGFloat { - let innerWidth = max(1, width - textContainerInset.left - textContainerInset.right) - let measuringFont = font ?? UIFont.preferredFont(forTextStyle: .body) - let used = (text as NSString).boundingRect( - with: CGSize(width: innerWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: measuringFont], - context: nil - ).height - let full = ceil(used) + textContainerInset.top + textContainerInset.bottom - return min(max(full, minHeight), maxHeight) - } - - override var keyCommands: [UIKeyCommand]? { - // Register nothing during IME composition so Return commits the candidate. Shift - // and Option Return get explicit newline commands; without them the unmodified - // command's priority captures the modified press and would send. - guard markedTextRange == nil else { return nil } - let action = #selector(handleReturnCommand(_:)) - let commands = [ - UIKeyCommand(input: Self.returnInput, modifierFlags: [], action: action), - UIKeyCommand(input: Self.numpadEnterInput, modifierFlags: [], action: action), - UIKeyCommand(input: Self.returnInput, modifierFlags: .shift, action: action), - UIKeyCommand(input: Self.returnInput, modifierFlags: .alternate, action: action) - ] - commands.forEach { $0.wantsPriorityOverSystemBehavior = true } - return commands + inputDelegate?.selectionDidChange(self) + inputDelegate?.textDidChange(self) + contentOffset = .zero + } + + /// Commits a marked IME composition and any pending autocorrect so a send + /// captures what the field shows. The input-delegate notifications flush the + /// autocorrect candidate; `unmarkText` commits the composition. Both are + /// synchronous, so the bound text is current the moment this returns. + func commitPendingInput() { + guard isFirstResponder else { return } + inputDelegate?.selectionWillChange(self) + inputDelegate?.selectionDidChange(self) + unmarkText() + } + + private var lineHeight: CGFloat { + (font ?? UIFont.preferredFont(forTextStyle: .body)).lineHeight + } + + private var minHeight: CGFloat { + ceil(lineHeight) + textContainerInset.top + textContainerInset.bottom + } + + private var maxHeight: CGFloat { + ceil(lineHeight * CGFloat(Self.maxVisibleLines)) + textContainerInset.top + textContainerInset.bottom + } + + /// Height the text needs when wrapped to `width`, clamped to the visible-line + /// range, enabling internal scrolling once the text exceeds the cap. + func clampedHeight(forWidth width: CGFloat) -> CGFloat { + let innerWidth = max(1, width - textContainerInset.left - textContainerInset.right) + let measuringFont = font ?? UIFont.preferredFont(forTextStyle: .body) + let used = (text as NSString).boundingRect( + with: CGSize(width: innerWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: measuringFont], + context: nil + ).height + let full = ceil(used) + textContainerInset.top + textContainerInset.bottom + return min(max(full, minHeight), maxHeight) + } + + override var keyCommands: [UIKeyCommand]? { + // Register nothing during IME composition so Return commits the candidate. Shift + // and Option Return get explicit newline commands; without them the unmodified + // command's priority captures the modified press and would send. + guard markedTextRange == nil else { return nil } + let action = #selector(handleReturnCommand(_:)) + let commands = [ + UIKeyCommand(input: Self.returnInput, modifierFlags: [], action: action), + UIKeyCommand(input: Self.numpadEnterInput, modifierFlags: [], action: action), + UIKeyCommand(input: Self.returnInput, modifierFlags: .shift, action: action), + UIKeyCommand(input: Self.returnInput, modifierFlags: .alternate, action: action) + ] + commands.forEach { $0.wantsPriorityOverSystemBehavior = true } + return commands + } + + @objc private func handleReturnCommand(_ command: UIKeyCommand) { + // A live composition takes Return as a candidate commit, never a send or + // newline. Re-checked here because the command list is built ahead of + // dispatch and may be served from before composition began. + guard markedTextRange == nil else { + unmarkText() + return } - - @objc private func handleReturnCommand(_ command: UIKeyCommand) { - // A live composition takes Return as a candidate commit, never a send or - // newline. Re-checked here because the command list is built ahead of - // dispatch and may be served from before composition began. - guard markedTextRange == nil else { - unmarkText() - return - } - // Shift+Return and Option+Return insert a newline; an unmodified Return or - // numpad Enter sends, falling back to a newline when the send is gated off. - let insertsNewline = !command.modifierFlags.isDisjoint(with: [.shift, .alternate]) - if insertsNewline || onSend?() != true { - insertText("\n") - } + // Shift+Return and Option+Return insert a newline; an unmodified Return or + // numpad Enter sends, falling back to a newline when the send is gated off. + let insertsNewline = !command.modifierFlags.isDisjoint(with: [.shift, .alternate]) + if insertsNewline || onSend?() != true { + insertText("\n") } + } } diff --git a/MC1/Views/Chats/Components/ChatInputBar.swift b/MC1/Views/Chats/Components/ChatInputBar.swift index 38788b38..e1a15bad 100644 --- a/MC1/Views/Chats/Components/ChatInputBar.swift +++ b/MC1/Views/Chats/Components/ChatInputBar.swift @@ -1,313 +1,312 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services /// Reusable chat input bar with configurable styling struct ChatInputBar: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Binding var text: String - /// Focus-request token forwarded to the composer; see `ChatComposerTextView`. - let focusRequest: Int - let placeholder: String - let maxBytes: Int - let isEncrypted: Bool - @ViewBuilder let leading: () -> Leading - let onSend: (String) -> Void - - @State private var isCoolingDown = false - @State private var sendInvocationCounter: Int = 0 - @State private var composerProxy = ChatComposerProxy() - - private var byteCount: Int { - text.utf8.count - } - - private var isOverLimit: Bool { - byteCount > maxBytes - } - - private var shouldShowCharacterCount: Bool { - // Show when within 20 bytes of limit or over limit - byteCount >= maxBytes - 20 - } - - var body: some View { - HStack(alignment: .bottom, spacing: 12) { - leading() - ChatInputTextField( - text: $text, - placeholder: placeholder, - focusRequest: focusRequest, - isEncrypted: isEncrypted, - proxy: composerProxy, - onSend: handleHardwareSend - ) - ChatSendButtonWithCounter( - canSend: canSend, - isOverLimit: isOverLimit, - shouldShowCharacterCount: shouldShowCharacterCount, - byteCount: byteCount, - maxBytes: maxBytes, - sendAccessibilityLabel: sendAccessibilityLabel, - sendAccessibilityHint: sendAccessibilityHint, - onSend: send - ) - } - .padding(.horizontal) - .padding(.vertical, 8) - .inputBarBackground(themedCanvas: theme.surfaces?.canvas) - .sensoryFeedback(.start, trigger: sendInvocationCounter) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Binding var text: String + /// Focus-request token forwarded to the composer; see `ChatComposerTextView`. + let focusRequest: Int + let placeholder: String + let maxBytes: Int + let isEncrypted: Bool + @ViewBuilder let leading: () -> Leading + let onSend: (String) -> Void + + @State private var isCoolingDown = false + @State private var sendInvocationCounter: Int = 0 + @State private var composerProxy = ChatComposerProxy() + + private var byteCount: Int { + text.utf8.count + } + + private var isOverLimit: Bool { + byteCount > maxBytes + } + + private var shouldShowCharacterCount: Bool { + // Show when within 20 bytes of limit or over limit + byteCount >= maxBytes - 20 + } + + var body: some View { + HStack(alignment: .bottom, spacing: 12) { + leading() + ChatInputTextField( + text: $text, + placeholder: placeholder, + focusRequest: focusRequest, + isEncrypted: isEncrypted, + proxy: composerProxy, + onSend: handleHardwareSend + ) + ChatSendButtonWithCounter( + canSend: canSend, + isOverLimit: isOverLimit, + shouldShowCharacterCount: shouldShowCharacterCount, + byteCount: byteCount, + maxBytes: maxBytes, + sendAccessibilityLabel: sendAccessibilityLabel, + sendAccessibilityHint: sendAccessibilityHint, + onSend: send + ) } - - private var sendAccessibilityLabel: String { - if isOverLimit { - return L10n.Chats.Chats.Input.tooLong - } else { - return L10n.Chats.Chats.Input.sendMessage - } + .padding(.horizontal) + .padding(.vertical, 8) + .inputBarBackground(themedCanvas: theme.surfaces?.canvas) + .sensoryFeedback(.start, trigger: sendInvocationCounter) + } + + private var sendAccessibilityLabel: String { + if isOverLimit { + L10n.Chats.Chats.Input.tooLong + } else { + L10n.Chats.Chats.Input.sendMessage } - - private var sendAccessibilityHint: String { - if isOverLimit { - return L10n.Chats.Chats.Input.removeCharacters(byteCount - maxBytes) - } else if appState.connectionState != .ready { - return L10n.Chats.Chats.Input.requiresConnection - } else if canSend { - return L10n.Chats.Chats.Input.tapToSend - } else { - return L10n.Chats.Chats.Input.typeFirst - } + } + + private var sendAccessibilityHint: String { + if isOverLimit { + L10n.Chats.Chats.Input.removeCharacters(byteCount - maxBytes) + } else if appState.connectionState != .ready { + L10n.Chats.Chats.Input.requiresConnection + } else if canSend { + L10n.Chats.Chats.Input.tapToSend + } else { + L10n.Chats.Chats.Input.typeFirst } - - private var canSend: Bool { - !isCoolingDown && - appState.connectionState == .ready && - !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isOverLimit - } - - /// Sends in response to an unmodified hardware Return from the composer, - /// honoring the same gating as the send button. Returns `true` when a message - /// was sent so the composer consumes the Return; `false` when gated off so the - /// composer inserts a newline instead. The composer keeps focus on its own, so - /// no re-focus is needed here. - private func handleHardwareSend() -> Bool { - guard canSend else { return false } - send() - return true - } - - private func send() { - composerProxy.commitPendingInput() - let captured = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !captured.isEmpty else { return } - isCoolingDown = true - text = "" - sendInvocationCounter &+= 1 - onSend(captured) - Task { - try? await Task.sleep(for: .seconds(1)) - isCoolingDown = false - } + } + + private var canSend: Bool { + !isCoolingDown && + appState.connectionState == .ready && + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isOverLimit + } + + /// Sends in response to an unmodified hardware Return from the composer, + /// honoring the same gating as the send button. Returns `true` when a message + /// was sent so the composer consumes the Return; `false` when gated off so the + /// composer inserts a newline instead. The composer keeps focus on its own, so + /// no re-focus is needed here. + private func handleHardwareSend() -> Bool { + guard canSend else { return false } + send() + return true + } + + private func send() { + composerProxy.commitPendingInput() + let captured = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !captured.isEmpty else { return } + isCoolingDown = true + text = "" + sendInvocationCounter &+= 1 + onSend(captured) + Task { + try? await Task.sleep(for: .seconds(1)) + isCoolingDown = false } + } } extension ChatInputBar where Leading == EmptyView { - /// Builds an input bar with no leading accessory, preserving the original - /// call sites that pass only a trailing `onSend` closure. - init( - text: Binding, - focusRequest: Int, - placeholder: String, - maxBytes: Int, - isEncrypted: Bool, - onSend: @escaping (String) -> Void - ) { - self.init( - text: text, - focusRequest: focusRequest, - placeholder: placeholder, - maxBytes: maxBytes, - isEncrypted: isEncrypted, - leading: { EmptyView() }, - onSend: onSend - ) - } + /// Builds an input bar with no leading accessory, preserving the original + /// call sites that pass only a trailing `onSend` closure. + init( + text: Binding, + focusRequest: Int, + placeholder: String, + maxBytes: Int, + isEncrypted: Bool, + onSend: @escaping (String) -> Void + ) { + self.init( + text: text, + focusRequest: focusRequest, + placeholder: placeholder, + maxBytes: maxBytes, + isEncrypted: isEncrypted, + leading: { EmptyView() }, + onSend: onSend + ) + } } // MARK: - Extracted Views private struct ChatInputTextField: View { - @Binding var text: String - let placeholder: String - let focusRequest: Int - let isEncrypted: Bool - let proxy: ChatComposerProxy - let onSend: () -> Bool - - var body: some View { - ChatComposerTextView( - text: $text, - focusRequest: focusRequest, - isEncrypted: isEncrypted, - proxy: proxy, - onSend: onSend - ) - .frame(maxWidth: .infinity) - .overlay(alignment: .topLeading) { - if text.isEmpty { - Text(placeholder) - .font(.body) - .foregroundStyle(Color(uiColor: .placeholderText)) - .padding(.top, ChatComposerUITextView.verticalInset) - .allowsHitTesting(false) - .accessibilityHidden(true) - } - } - .padding(.leading, 12) - .padding(.trailing, 28) - .overlay(alignment: .trailing) { - Image(systemName: isEncrypted ? "lock.fill" : "lock.open.fill") - .font(.footnote) - .foregroundStyle(isEncrypted ? .blue : .orange) - .padding(.trailing, 10) - .accessibilityHidden(true) - } - .textFieldBackground() + @Binding var text: String + let placeholder: String + let focusRequest: Int + let isEncrypted: Bool + let proxy: ChatComposerProxy + let onSend: () -> Bool + + var body: some View { + ChatComposerTextView( + text: $text, + focusRequest: focusRequest, + isEncrypted: isEncrypted, + proxy: proxy, + onSend: onSend + ) + .frame(maxWidth: .infinity) + .overlay(alignment: .topLeading) { + if text.isEmpty { + Text(placeholder) + .font(.body) + .foregroundStyle(Color(uiColor: .placeholderText)) + .padding(.top, ChatComposerUITextView.verticalInset) + .allowsHitTesting(false) + .accessibilityHidden(true) + } } + .padding(.leading, 12) + .padding(.trailing, 28) + .overlay(alignment: .trailing) { + Image(systemName: isEncrypted ? "lock.fill" : "lock.open.fill") + .font(.footnote) + .foregroundStyle(isEncrypted ? .blue : .orange) + .padding(.trailing, 10) + .accessibilityHidden(true) + } + .textFieldBackground() + } } private struct ChatSendButtonWithCounter: View { - let canSend: Bool - let isOverLimit: Bool - let shouldShowCharacterCount: Bool - let byteCount: Int - let maxBytes: Int - let sendAccessibilityLabel: String - let sendAccessibilityHint: String - let onSend: () -> Void - - var body: some View { - VStack(spacing: 4) { - ChatSendButton( - canSend: canSend, - sendAccessibilityLabel: sendAccessibilityLabel, - sendAccessibilityHint: sendAccessibilityHint, - onSend: onSend - ) - if shouldShowCharacterCount { - ChatCharacterCountLabel( - byteCount: byteCount, - maxBytes: maxBytes, - isOverLimit: isOverLimit - ) - } - } + let canSend: Bool + let isOverLimit: Bool + let shouldShowCharacterCount: Bool + let byteCount: Int + let maxBytes: Int + let sendAccessibilityLabel: String + let sendAccessibilityHint: String + let onSend: () -> Void + + var body: some View { + VStack(spacing: 4) { + ChatSendButton( + canSend: canSend, + sendAccessibilityLabel: sendAccessibilityLabel, + sendAccessibilityHint: sendAccessibilityHint, + onSend: onSend + ) + if shouldShowCharacterCount { + ChatCharacterCountLabel( + byteCount: byteCount, + maxBytes: maxBytes, + isOverLimit: isOverLimit + ) + } } + } } private struct ChatCharacterCountLabel: View { - let byteCount: Int - let maxBytes: Int - let isOverLimit: Bool - - var body: some View { - Text("\(byteCount)/\(maxBytes)") - .font(.caption2) - .monospacedDigit() - .foregroundStyle(isOverLimit ? .red : .secondary) - .accessibilityLabel(L10n.Chats.Chats.Input.characterCount(byteCount, maxBytes)) - } + let byteCount: Int + let maxBytes: Int + let isOverLimit: Bool + + var body: some View { + Text("\(byteCount)/\(maxBytes)") + .font(.caption2) + .monospacedDigit() + .foregroundStyle(isOverLimit ? .red : .secondary) + .accessibilityLabel(L10n.Chats.Chats.Input.characterCount(byteCount, maxBytes)) + } } private struct ChatSendButton: View { - let canSend: Bool - let sendAccessibilityLabel: String - let sendAccessibilityHint: String - let onSend: () -> Void - - @Environment(\.appTheme) private var theme - - private var sendButtonFont: Font { - if #available(iOS 26.0, *) { .title2 } else { .title } - } - - var body: some View { - Button(action: onSend) { - Image(systemName: "arrow.up.circle.fill") - .font(sendButtonFont) - .foregroundStyle(canSend ? theme.accentColor : .secondary) - } - .sendButtonStyle() - .disabled(!canSend) - .accessibilityLabel(sendAccessibilityLabel) - .accessibilityHint(sendAccessibilityHint) + let canSend: Bool + let sendAccessibilityLabel: String + let sendAccessibilityHint: String + let onSend: () -> Void + + @Environment(\.appTheme) private var theme + + private var sendButtonFont: Font { + if #available(iOS 26.0, *) { .title2 } else { .title } + } + + var body: some View { + Button(action: onSend) { + Image(systemName: "arrow.up.circle.fill") + .font(sendButtonFont) + .foregroundStyle(canSend ? theme.accentColor : .secondary) } + .sendButtonStyle() + .disabled(!canSend) + .accessibilityLabel(sendAccessibilityLabel) + .accessibilityHint(sendAccessibilityHint) + } } // MARK: - Platform-Conditional Styling private extension View { - @ViewBuilder - func sendButtonStyle() -> some View { - if #available(iOS 26.0, *) { - self.buttonStyle(.glass) - } else { - self.padding(.vertical, 4) - } + @ViewBuilder + func sendButtonStyle() -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.glass) + } else { + padding(.vertical, 4) } - - @ViewBuilder - func textFieldBackground() -> some View { - if #available(iOS 26.0, *) { - self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 20)) - } else { - self - .background(Color(.systemGray6)) - .clipShape(.rect(cornerRadius: 20)) - } + } + + @ViewBuilder + func textFieldBackground() -> some View { + if #available(iOS 26.0, *) { + glassEffect(.regular.interactive(), in: .rect(cornerRadius: 20)) + } else { + background(Color(.systemGray6)) + .clipShape(.rect(cornerRadius: 20)) } - - @ViewBuilder - func inputBarBackground(themedCanvas: Color?) -> some View { - if let themedCanvas { - self.background(themedCanvas) - } else if #available(iOS 26.0, *) { - self - } else { - self.background(.bar) - } + } + + @ViewBuilder + func inputBarBackground(themedCanvas: Color?) -> some View { + if let themedCanvas { + background(themedCanvas) + } else if #available(iOS 26.0, *) { + self + } else { + background(.bar) } + } } // MARK: - Preview private struct ChatInputBarPreviewHost: View { - @State private var plainText = "" - @State private var leadingText = "" - - var body: some View { - VStack(spacing: 24) { - ChatInputBar( - text: $plainText, - focusRequest: 0, - placeholder: "No leading accessory", - maxBytes: 140, - isEncrypted: true - ) { _ in } - - ChatInputBar( - text: $leadingText, - focusRequest: 0, - placeholder: "With leading accessory", - maxBytes: 140, - isEncrypted: false, - leading: { Image(systemName: "plus") } - ) { _ in } - } + @State private var plainText = "" + @State private var leadingText = "" + + var body: some View { + VStack(spacing: 24) { + ChatInputBar( + text: $plainText, + focusRequest: 0, + placeholder: "No leading accessory", + maxBytes: 140, + isEncrypted: true + ) { _ in } + + ChatInputBar( + text: $leadingText, + focusRequest: 0, + placeholder: "With leading accessory", + maxBytes: 140, + isEncrypted: false, + leading: { Image(systemName: "plus") } + ) { _ in } } + } } #Preview { - ChatInputBarPreviewHost() + ChatInputBarPreviewHost() } diff --git a/MC1/Views/Chats/Components/ChatMessagesTableView.swift b/MC1/Views/Chats/Components/ChatMessagesTableView.swift index c249d158..9541776f 100644 --- a/MC1/Views/Chats/Components/ChatMessagesTableView.swift +++ b/MC1/Views/Chats/Components/ChatMessagesTableView.swift @@ -1,165 +1,165 @@ -import SwiftUI import MC1Services +import SwiftUI /// Messages table with ChatTableView, overlay scroll buttons, and divider state management struct ChatMessagesTableView: View { - @Bindable var viewModel: ChatViewModel - let contactName: String - let deviceName: String - let configuration: MessageBubbleConfiguration - let recentEmojisStore: RecentEmojisStore - let envInputs: EnvInputs + @Bindable var viewModel: ChatViewModel + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + let recentEmojisStore: RecentEmojisStore + let envInputs: EnvInputs - @Binding var isAtBottom: Bool - @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - @Binding var scrollToMentionRequest: Int - @Binding var scrollToDividerRequest: Int - @Binding var isDividerVisible: Bool - @Binding var selectedMessageForActions: MessageDTO? - @Binding var imageViewerData: ImageViewerData? + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + @Binding var scrollToMentionRequest: Int + @Binding var scrollToDividerRequest: Int + @Binding var isDividerVisible: Bool + @Binding var selectedMessageForActions: MessageDTO? + @Binding var imageViewerData: ImageViewerData? - let unseenMentionIDs: [UUID] - @Binding var offscreenMentionIDs: [UUID] - let scrollToTargetID: UUID? - let newMessagesDividerMessageID: UUID? - let onMentionSeen: (UUID) async -> Bool - let onScrollToMention: () -> Void - let onRetryMessage: (MessageDTO) -> Void + let unseenMentionIDs: [UUID] + @Binding var offscreenMentionIDs: [UUID] + let scrollToTargetID: UUID? + let newMessagesDividerMessageID: UUID? + let onMentionSeen: (UUID) async -> Bool + let onScrollToMention: () -> Void + let onRetryMessage: (MessageDTO) -> Void - @State private var hasDismissedDividerButton = false - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.openURL) private var openURL + @State private var hasDismissedDividerButton = false + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.openURL) private var openURL - private var showDividerButton: Bool { - newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerButton - } + private var showDividerButton: Bool { + newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerButton + } - var body: some View { - let mentionIDSet = Set(unseenMentionIDs) - let factory = ChatCellContentFactory( - contactName: contactName, - deviceName: deviceName, - configuration: configuration, - theme: theme, - openURL: openURL, - resolver: BubbleResolver(viewModel: viewModel), - actions: BubbleActions( - onRetryMessage: onRetryMessage, - onReaction: { emoji, message in - recentEmojisStore.recordUsage(emoji) - Task { await viewModel.sendReaction(emoji: emoji, to: message) } - }, - onLongPress: { message in selectedMessageForActions = message }, - onImageTap: { message in - if let data = viewModel.imageData(for: message.id) { - imageViewerData = ImageViewerData( - imageData: data, - isGIF: viewModel.isGIFImage(for: message.id) - ) - } - }, - onRetryInlineImage: { messageID in - Task { await viewModel.retryImageFetch(for: messageID) } - }, - onRequestPreviewFetch: { messageID in - if viewModel.shouldRequestImageFetch(for: messageID) { - viewModel.requestImageFetch(for: messageID) - } else { - viewModel.requestPreviewFetch(for: messageID) - } - }, - onManualPreviewFetch: { messageID in - Task { await viewModel.manualFetchPreview(for: messageID) } - }, - onMapPreviewTap: { coordinate in - viewModel.navigateToMap(coordinate) - }, - snapshotResolver: { MapSnapshotStore.shared.image(for: $0) }, - requestSnapshot: { MapSnapshotStore.shared.request($0) }, - retrySnapshot: { MapSnapshotStore.shared.retry($0) } + var body: some View { + let mentionIDSet = Set(unseenMentionIDs) + let factory = ChatCellContentFactory( + contactName: contactName, + deviceName: deviceName, + configuration: configuration, + theme: theme, + openURL: openURL, + resolver: BubbleResolver(viewModel: viewModel), + actions: BubbleActions( + onRetryMessage: onRetryMessage, + onReaction: { emoji, message in + recentEmojisStore.recordUsage(emoji) + Task { await viewModel.sendReaction(emoji: emoji, to: message) } + }, + onLongPress: { message in selectedMessageForActions = message }, + onImageTap: { message in + if let data = viewModel.imageData(for: message.id) { + imageViewerData = ImageViewerData( + imageData: data, + isGIF: viewModel.isGIFImage(for: message.id) ) - ) - ChatTableView( - items: viewModel.items, - cellContent: factory.makeContent(for:), - contentBackground: theme.surfaces?.canvas, - themeID: theme.id, - appearanceToken: AppearanceToken.make( - colorScheme: colorScheme, - contrast: colorSchemeContrast, - dynamicTypeSize: dynamicTypeSize - ), - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - isUnseenMention: { item in - item.envelope.containsSelfMention - && !item.envelope.mentionSeen - && mentionIDSet.contains(item.id) - }, - unseenMentionIDs: unseenMentionIDs, - offscreenMentionIDs: $offscreenMentionIDs, - onMentionBecameVisible: { id in - await onMentionSeen(id) - }, - onSecondaryClick: { item in - if let message = viewModel.message(for: item) { - selectedMessageForActions = message - } - }, - mentionTargetID: scrollToTargetID, - scrollToDividerRequest: $scrollToDividerRequest, - dividerItemID: newMessagesDividerMessageID, - isDividerVisible: $isDividerVisible, - onNearTop: { release in - Task { @MainActor in - await viewModel.loadOlderMessages() - release() - } - }, - isLoadingOlderMessages: viewModel.isLoadingOlder - ) - .overlay(alignment: .bottomTrailing) { - VStack(spacing: 12) { - if showDividerButton { - ScrollToDividerButton( - onTap: { - scrollToDividerRequest += 1 - hasDismissedDividerButton = true - } - ) - .transition(.scale.combined(with: .opacity)) - } - - if !offscreenMentionIDs.isEmpty { - ScrollToMentionButton( - unreadMentionCount: offscreenMentionIDs.count, - onTap: { onScrollToMention() } - ) - .transition(.scale.combined(with: .opacity)) - } - - ScrollToBottomButton( - isVisible: !isAtBottom, - unreadCount: unreadCount, - onTap: { scrollToBottomRequest += 1 } - ) - } - .animation(.snappy(duration: 0.2), value: showDividerButton) - .animation(.snappy(duration: 0.2), value: offscreenMentionIDs.isEmpty) - .padding(.trailing, 16) - .padding(.bottom, 8) + } + }, + onRetryInlineImage: { messageID in + Task { await viewModel.retryImageFetch(for: messageID) } + }, + onRequestPreviewFetch: { messageID in + if viewModel.shouldRequestImageFetch(for: messageID) { + viewModel.requestImageFetch(for: messageID) + } else { + viewModel.requestPreviewFetch(for: messageID) + } + }, + onManualPreviewFetch: { messageID in + Task { await viewModel.manualFetchPreview(for: messageID) } + }, + onMapPreviewTap: { coordinate in + viewModel.navigateToMap(coordinate) + }, + snapshotResolver: { MapSnapshotStore.shared.image(for: $0) }, + requestSnapshot: { MapSnapshotStore.shared.request($0) }, + retrySnapshot: { MapSnapshotStore.shared.retry($0) } + ) + ) + ChatTableView( + items: viewModel.items, + cellContent: factory.makeContent(for:), + contentBackground: theme.surfaces?.canvas, + themeID: theme.id, + appearanceToken: AppearanceToken.make( + colorScheme: colorScheme, + contrast: colorSchemeContrast, + dynamicTypeSize: dynamicTypeSize + ), + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + isUnseenMention: { item in + item.envelope.containsSelfMention + && !item.envelope.mentionSeen + && mentionIDSet.contains(item.id) + }, + unseenMentionIDs: unseenMentionIDs, + offscreenMentionIDs: $offscreenMentionIDs, + onMentionBecameVisible: { id in + await onMentionSeen(id) + }, + onSecondaryClick: { item in + if let message = viewModel.message(for: item) { + selectedMessageForActions = message } - .onChange(of: newMessagesDividerMessageID) { _, _ in - hasDismissedDividerButton = false + }, + mentionTargetID: scrollToTargetID, + scrollToDividerRequest: $scrollToDividerRequest, + dividerItemID: newMessagesDividerMessageID, + isDividerVisible: $isDividerVisible, + onNearTop: { release in + Task { @MainActor in + await viewModel.loadOlderMessages() + release() } - .onChange(of: envInputs) { _, new in - viewModel.applyEnvInputs(new) + }, + isLoadingOlderMessages: viewModel.isLoadingOlder + ) + .overlay(alignment: .bottomTrailing) { + VStack(spacing: 12) { + if showDividerButton { + ScrollToDividerButton( + onTap: { + scrollToDividerRequest += 1 + hasDismissedDividerButton = true + } + ) + .transition(.scale.combined(with: .opacity)) + } + + if !offscreenMentionIDs.isEmpty { + ScrollToMentionButton( + unreadMentionCount: offscreenMentionIDs.count, + onTap: { onScrollToMention() } + ) + .transition(.scale.combined(with: .opacity)) } + + ScrollToBottomButton( + isVisible: !isAtBottom, + unreadCount: unreadCount, + onTap: { scrollToBottomRequest += 1 } + ) + } + .animation(.snappy(duration: 0.2), value: showDividerButton) + .animation(.snappy(duration: 0.2), value: offscreenMentionIDs.isEmpty) + .padding(.trailing, 16) + .padding(.bottom, 8) + } + .onChange(of: newMessagesDividerMessageID) { _, _ in + hasDismissedDividerButton = false + } + .onChange(of: envInputs) { _, new in + viewModel.applyEnvInputs(new) } + } } diff --git a/MC1/Views/Chats/Components/ChatScrollConstants.swift b/MC1/Views/Chats/Components/ChatScrollConstants.swift index 27189cde..817d6573 100644 --- a/MC1/Views/Chats/Components/ChatScrollConstants.swift +++ b/MC1/Views/Chats/Components/ChatScrollConstants.swift @@ -4,35 +4,35 @@ import Foundation /// enum (not nested in the generic controller) so static stored properties are /// legal. enum ChatScrollConstants { - /// Maximum times a scroll-to-target is retried while the applied snapshot - /// catches up to the items model before the target is abandoned. - static let pendingScrollMaxRetries = 3 + /// Maximum times a scroll-to-target is retried while the applied snapshot + /// catches up to the items model before the target is abandoned. + static let pendingScrollMaxRetries = 3 - /// Delay between scroll-to-target retries while waiting for the applied - /// snapshot to drain. - static let pendingScrollRetryDelay: Duration = .milliseconds(80) + /// Delay between scroll-to-target retries while waiting for the applied + /// snapshot to drain. + static let pendingScrollRetryDelay: Duration = .milliseconds(80) - /// contentOffset.y at or below which the flipped table is treated as resting - /// at the visual bottom. Small to absorb float imprecision only. - static let bottomDetectionEpsilon: CGFloat = 1 + /// contentOffset.y at or below which the flipped table is treated as resting + /// at the visual bottom. Small to absorb float imprecision only. + static let bottomDetectionEpsilon: CGFloat = 1 - /// Looser bottom threshold used right after a programmatic scroll-to-bottom, - /// whose animation may not land exactly at zero. - static let bottomLandingEpsilon: CGFloat = 10 + /// Looser bottom threshold used right after a programmatic scroll-to-bottom, + /// whose animation may not land exactly at zero. + static let bottomLandingEpsilon: CGFloat = 10 - /// Distance (in rows) from the oldest loaded message at which to trigger - /// loading the next older page. - static let nearTopTriggerDistance = 10 + /// Distance (in rows) from the oldest loaded message at which to trigger + /// loading the next older page. + static let nearTopTriggerDistance = 10 - /// Delay that lets a SwiftUI/table layout pass settle before a follow-up - /// scroll or visibility check reads positions. - static let layoutSettleDelay: Duration = .milliseconds(100) + /// Delay that lets a SwiftUI/table layout pass settle before a follow-up + /// scroll or visibility check reads positions. + static let layoutSettleDelay: Duration = .milliseconds(100) - /// Delay before replaying a pending scroll-to-target queued during an items - /// apply, giving the snapshot time to install the target row. - static let pendingScrollInitialDelay: Duration = .milliseconds(120) + /// Delay before replaying a pending scroll-to-target queued during an items + /// apply, giving the snapshot time to install the target row. + static let pendingScrollInitialDelay: Duration = .milliseconds(120) - /// Delay before an animated scroll-to-target begins, letting the target row's - /// self-sizing layout settle so the landing offset is correct. - static let scrollToTargetDelay: Duration = .milliseconds(180) + /// Delay before an animated scroll-to-target begins, letting the target row's + /// self-sizing layout settle so the landing offset is correct. + static let scrollToTargetDelay: Duration = .milliseconds(180) } diff --git a/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift b/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift index bdb7824f..dfc72a22 100644 --- a/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift +++ b/MC1/Views/Chats/Components/ChatScrollDisplayLinkProxy.swift @@ -7,9 +7,9 @@ import UIKit /// representable class type the proxy could store directly. @MainActor final class ChatScrollDisplayLinkProxy: NSObject { - var onTick: (() -> Void)? + var onTick: (() -> Void)? - @objc func tick(_ link: CADisplayLink) { - onTick?() - } + @objc func tick(_ link: CADisplayLink) { + onTick?() + } } diff --git a/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift b/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift index 8ceb57e8..b2b68f21 100644 --- a/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift +++ b/MC1/Views/Chats/Components/ChatScrollToMentionPolicy.swift @@ -1,15 +1,15 @@ import Foundation enum ChatScrollToMentionPolicy { - static func shouldScrollToBottom(mentionTargetID: UUID?, newestItemID: UUID?) -> Bool { - guard let mentionTargetID, let newestItemID else { return false } - return mentionTargetID == newestItemID - } + static func shouldScrollToBottom(mentionTargetID: UUID?, newestItemID: UUID?) -> Bool { + guard let mentionTargetID, let newestItemID else { return false } + return mentionTargetID == newestItemID + } - /// Picks which off-screen mention a button tap scrolls to. `offscreenMentions` is ordered - /// oldest-to-newest, so the newest (last) is the latest unread the user hasn't reached; - /// repeated taps consume it and walk upward through older mentions, showing the earliest last. - static func nextTarget(offscreenMentions: [UUID]) -> UUID? { - offscreenMentions.last - } + /// Picks which off-screen mention a button tap scrolls to. `offscreenMentions` is ordered + /// oldest-to-newest, so the newest (last) is the latest unread the user hasn't reached; + /// repeated taps consume it and walk upward through older mentions, showing the earliest last. + static func nextTarget(offscreenMentions: [UUID]) -> UUID? { + offscreenMentions.last + } } diff --git a/MC1/Views/Chats/Components/ChatShareMenu.swift b/MC1/Views/Chats/Components/ChatShareMenu.swift index 1e70dc3e..f4b759bd 100644 --- a/MC1/Views/Chats/Components/ChatShareMenu.swift +++ b/MC1/Views/Chats/Components/ChatShareMenu.swift @@ -9,152 +9,152 @@ import SwiftUI /// reads the compose text. The contact list is fetched lazily inside /// `ShareContactPickerSheet`, so no contact array is built here. struct ChatShareMenu: View { - let onInsert: (String) -> Void + let onInsert: (String) -> Void - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @State private var isShowingContactPicker = false - @State private var locationTask: Task? + @State private var isShowingContactPicker = false + @State private var locationTask: Task? - /// Decimal-degree format for shared coordinates. Six fractional digits give - /// roughly 0.1 m resolution, enough to round-trip a phone or node fix. - private static let coordinateFormat = "%.6f, %.6f" + /// Decimal-degree format for shared coordinates. Six fractional digits give + /// roughly 0.1 m resolution, enough to round-trip a phone or node fix. + private static let coordinateFormat = "%.6f, %.6f" - /// Timeout for the on-tap location fix. Shorter than the service default - /// because it runs in response to a user tap and should not stall the menu. - private static let locationFixTimeout: Duration = .seconds(5) + /// Timeout for the on-tap location fix. Shorter than the service default + /// because it runs in response to a user tap and should not stall the menu. + private static let locationFixTimeout: Duration = .seconds(5) - /// Already-published phone fix, only when location access is granted. - private var publishedPhoneCoordinate: CLLocationCoordinate2D? { - guard appState.locationService.isAuthorized, - let location = appState.locationService.currentLocation else { - return nil - } - return location.coordinate + /// Already-published phone fix, only when location access is granted. + private var publishedPhoneCoordinate: CLLocationCoordinate2D? { + guard appState.locationService.isAuthorized, + let location = appState.locationService.currentLocation else { + return nil } + return location.coordinate + } - /// Connected node's last-known coordinate, when it carries a valid fix. - private var nodeCoordinate: CLLocationCoordinate2D? { - guard let device = appState.connectedDevice, device.hasLocation else { - return nil - } - return CLLocationCoordinate2D(latitude: device.latitude, longitude: device.longitude) + /// Connected node's last-known coordinate, when it carries a valid fix. + private var nodeCoordinate: CLLocationCoordinate2D? { + guard let device = appState.connectedDevice, device.hasLocation else { + return nil } - - /// A coordinate is shareable when the phone or the node can supply one, or when location - /// access is granted, since tapping then fetches a fresh fix via `requestCurrentLocation`. - nonisolated static func canShareLocation( - phoneCoordinate: CLLocationCoordinate2D?, - nodeCoordinate: CLLocationCoordinate2D?, - locationAuthorized: Bool - ) -> Bool { - phoneCoordinate != nil || nodeCoordinate != nil || locationAuthorized - } - - private var canShareLocation: Bool { - Self.canShareLocation( - phoneCoordinate: publishedPhoneCoordinate, - nodeCoordinate: nodeCoordinate, - locationAuthorized: appState.locationService.isAuthorized - ) + return CLLocationCoordinate2D(latitude: device.latitude, longitude: device.longitude) + } + + /// A coordinate is shareable when the phone or the node can supply one, or when location + /// access is granted, since tapping then fetches a fresh fix via `requestCurrentLocation`. + nonisolated static func canShareLocation( + phoneCoordinate: CLLocationCoordinate2D?, + nodeCoordinate: CLLocationCoordinate2D?, + locationAuthorized: Bool + ) -> Bool { + phoneCoordinate != nil || nodeCoordinate != nil || locationAuthorized + } + + private var canShareLocation: Bool { + Self.canShareLocation( + phoneCoordinate: publishedPhoneCoordinate, + nodeCoordinate: nodeCoordinate, + locationAuthorized: appState.locationService.isAuthorized + ) + } + + /// Own info is shareable only with a full-length key and a name that is not blank, so the + /// emitted share token always carries a usable, non-empty identity. + nonisolated static func canShareMyInfo(publicKey: Data, nodeName: String) -> Bool { + publicKey.count == ProtocolLimits.publicKeySize + && !nodeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var canShareMyInfo: Bool { + guard let device = appState.connectedDevice else { return false } + return Self.canShareMyInfo(publicKey: device.publicKey, nodeName: device.nodeName) + } + + /// Matches the send button's font so the two input-bar buttons are the same height. + private var plusButtonFont: Font { + if #available(iOS 26.0, *) { .title2 } else { .title } + } + + var body: some View { + Menu { + Button { + shareLocation() + } label: { + Label(L10n.Chats.Chats.Share.location, systemImage: "location.fill") + } + .disabled(!canShareLocation) + + Button { + isShowingContactPicker = true + } label: { + Label(L10n.Chats.Chats.Share.contact, systemImage: "person.crop.circle") + } + + Button { + shareMyInfo() + } label: { + Label(L10n.Chats.Chats.Share.myInfo, systemImage: "person.text.rectangle") + } + .disabled(!canShareMyInfo) + } label: { + Image(systemName: "plus.circle.fill") + .font(plusButtonFont) } - - /// Own info is shareable only with a full-length key and a name that is not blank, so the - /// emitted share token always carries a usable, non-empty identity. - nonisolated static func canShareMyInfo(publicKey: Data, nodeName: String) -> Bool { - publicKey.count == ProtocolLimits.publicKeySize - && !nodeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + .shareButtonStyle() + .accessibilityLabel(L10n.Chats.Chats.Input.ShareButton.accessibilityLabel) + .sheet(isPresented: $isShowingContactPicker) { + ShareContactPickerSheet(onInsert: onInsert) } - - private var canShareMyInfo: Bool { - guard let device = appState.connectedDevice else { return false } - return Self.canShareMyInfo(publicKey: device.publicKey, nodeName: device.nodeName) - } - - /// Matches the send button's font so the two input-bar buttons are the same height. - private var plusButtonFont: Font { - if #available(iOS 26.0, *) { .title2 } else { .title } - } - - var body: some View { - Menu { - Button { - shareLocation() - } label: { - Label(L10n.Chats.Chats.Share.location, systemImage: "location.fill") - } - .disabled(!canShareLocation) - - Button { - isShowingContactPicker = true - } label: { - Label(L10n.Chats.Chats.Share.contact, systemImage: "person.crop.circle") - } - - Button { - shareMyInfo() - } label: { - Label(L10n.Chats.Chats.Share.myInfo, systemImage: "person.text.rectangle") - } - .disabled(!canShareMyInfo) - } label: { - Image(systemName: "plus.circle.fill") - .font(plusButtonFont) - } - .shareButtonStyle() - .accessibilityLabel(L10n.Chats.Chats.Input.ShareButton.accessibilityLabel) - .sheet(isPresented: $isShowingContactPicker) { - ShareContactPickerSheet(onInsert: onInsert) - } - .onDisappear { locationTask?.cancel() } - } - - /// Shares a coordinate, preferring a fresh phone fix, then the last published - /// phone fix, then the node's coordinate. `requestCurrentLocation` is only - /// called when access is already granted, so no permission prompt can appear. - private func shareLocation() { - locationTask?.cancel() - locationTask = Task { - var fresh: CLLocationCoordinate2D? - if appState.locationService.isAuthorized { - fresh = (try? await appState.locationService - .requestCurrentLocation(timeout: Self.locationFixTimeout))?.coordinate - } - // The menu may have been dismissed while awaiting the fix; do not write to a gone view. - guard !Task.isCancelled else { return } - guard let coordinate = fresh ?? publishedPhoneCoordinate ?? nodeCoordinate else { return } - onInsert(String(format: Self.coordinateFormat, coordinate.latitude, coordinate.longitude)) - } - } - - private func shareMyInfo() { - guard let device = appState.connectedDevice, - Self.canShareMyInfo(publicKey: device.publicKey, nodeName: device.nodeName) else { return } - let token = ContactShareUtilities.formatShare( - publicKey: device.publicKey, - type: .chat, - name: device.nodeName - ) - onInsert(token) + .onDisappear { locationTask?.cancel() } + } + + /// Shares a coordinate, preferring a fresh phone fix, then the last published + /// phone fix, then the node's coordinate. `requestCurrentLocation` is only + /// called when access is already granted, so no permission prompt can appear. + private func shareLocation() { + locationTask?.cancel() + locationTask = Task { + var fresh: CLLocationCoordinate2D? + if appState.locationService.isAuthorized { + fresh = await (try? appState.locationService + .requestCurrentLocation(timeout: Self.locationFixTimeout))?.coordinate + } + // The menu may have been dismissed while awaiting the fix; do not write to a gone view. + guard !Task.isCancelled else { return } + guard let coordinate = fresh ?? publishedPhoneCoordinate ?? nodeCoordinate else { return } + onInsert(String(format: Self.coordinateFormat, coordinate.latitude, coordinate.longitude)) } + } + + private func shareMyInfo() { + guard let device = appState.connectedDevice, + Self.canShareMyInfo(publicKey: device.publicKey, nodeName: device.nodeName) else { return } + let token = ContactShareUtilities.formatShare( + publicKey: device.publicKey, + type: .chat, + name: device.nodeName + ) + onInsert(token) + } } // MARK: - Platform-Conditional Styling private extension View { - /// Mirrors the chat send button's treatment for visual parity: a glass button - /// on iOS 26+, and the same plain vertical padding fallback on earlier versions. - @ViewBuilder - func shareButtonStyle() -> some View { - if #available(iOS 26.0, *) { - self.buttonStyle(.glass) - } else { - self.padding(.vertical, 4) - } + /// Mirrors the chat send button's treatment for visual parity: a glass button + /// on iOS 26+, and the same plain vertical padding fallback on earlier versions. + @ViewBuilder + func shareButtonStyle() -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.glass) + } else { + padding(.vertical, 4) } + } } #Preview { - ChatShareMenu(onInsert: { _ in }) - .padding() + ChatShareMenu(onInsert: { _ in }) + .padding() } diff --git a/MC1/Views/Chats/Components/ChatTableView.swift b/MC1/Views/Chats/Components/ChatTableView.swift index 75330a2a..10d142f8 100644 --- a/MC1/Views/Chats/Components/ChatTableView.swift +++ b/MC1/Views/Chats/Components/ChatTableView.swift @@ -1,394 +1,397 @@ -import UIKit -import SwiftUI import os +import SwiftUI +import UIKit -// Logs the bottom safe-area inset across keyboard transitions so a residual -// inset that fails to collapse after dismissal can be diagnosed. +/// Logs the bottom safe-area inset across keyboard transitions so a residual +/// inset that fails to collapse after dismissal can be diagnosed. private let chatKeyboardLogger = Logger(subsystem: "com.mc1", category: "ChatKeyboard") /// UIKit table view controller with flipped orientation for chat-style scrolling /// Newest messages appear at visual bottom, keyboard handling via native UIKit @MainActor final class ChatTableViewController: UITableViewController, UIContextMenuInteractionDelegate, UIGestureRecognizerDelegate where Item.ID == UUID { - - // MARK: - Types - - private enum Section: Hashable { - case main + // MARK: - Types + + private enum Section: Hashable { + case main + } + + private struct SnapshotApplyRequest { + var snapshot: NSDiffableDataSourceSnapshot + var animatingDifferences: Bool + var completion: (() -> Void)? + /// `true` when this request was issued by `reconfigureAllItems` (live theme switch). + /// The pending-slot coalescer carries this flag — and a re-applied + /// `reconfigureItems(allCurrentIDs)` — forward into any superseding request so the + /// reconfigure intent isn't silently dropped when latest-wins overwrites pendingSnapshot. + var reconfigureAll: Bool = false + } + + // MARK: - Properties + + private var items: [Item] = [] + /// O(1) lookup for items by ID (replaces O(n) first(where:) in cell provider) + private var itemsByID: [Item.ID: Item] = [:] + /// O(1) index lookup for scroll-to-item (replaces O(n) firstIndex(where:)) + private var itemIndexByID: [Item.ID: Int] = [:] + private var cellContentProvider: ((Item) -> CellContent)? + var defaultTableBackgroundColor: UIColor? + private var dataSource: UITableViewDiffableDataSource? + /// Snapshot scheduled while a previous apply was still running. Latest + /// wins: when a new request arrives mid-apply, it replaces this field + /// and the intermediate snapshot is skipped. Diffable data source + /// derives the visual result from the final snapshot alone, so + /// dropping intermediates is safe. + private var pendingSnapshot: SnapshotApplyRequest? + /// Completions from snapshot requests whose snapshot was superseded + /// before it landed. They still need to run because callers (notably + /// the pagination prepend path) use them to restore the anchor row's + /// viewport position after layout settles; the anchor row is part of + /// the superseding snapshot too, so measuring against the post-apply + /// layout is correct. Drained in order after the latest apply lands. + private var pendingCompletions: [() -> Void] = [] + + /// Bundled interaction/intent/apply/deferred axes for the scroll surface. + private(set) var scrollState: ChatScrollState = .idle + + /// Tracks scroll position relative to bottom + private(set) var isAtBottom: Bool = true + + /// Count of unread messages (messages added while scrolled up) + private(set) var unreadCount: Int = 0 + + /// ID of last message user has seen (for unread tracking) + private var lastSeenItemID: Item.ID? + + /// Callback when scroll state changes + var onScrollStateChanged: ((Bool, Int) -> Void)? + + /// Callback when user scrolls near the top (oldest messages). The closure receives a release + /// callback the consumer must invoke when pagination work completes (success or short-circuit) + /// so the request latch clears even when the view model's isLoadingOlder never visibly flips. + var onNearTop: ((@escaping @MainActor () -> Void) -> Void)? + + /// Whether pagination is in progress (skip auto-scroll during pagination) + var isLoadingOlderMessages = false + + /// Suppresses duplicate onNearTop fires while the view model's isLoadingOlder propagates back through SwiftUI + private var isNearTopRequestInFlight = false + + /// Callback when a mention becomes visible. Returns whether the seen-state was + /// persisted; a false result means the id must not stay marked, so it can re-fire. + var onMentionBecameVisible: ((Item.ID) async -> Bool)? + + /// Callback when a row receives a secondary (right) click from a pointer. On Mac the click + /// reaches the context-menu system (`UIContextMenuInteraction`); on iPad a trackpad or mouse + /// secondary click arrives as an indirect-pointer tap, handled by a dedicated recognizer. Touch + /// long-press is never routed here (it opens the sheet through the bubble's own gesture), so the + /// two paths can't double-trigger. Surfaces that leave this nil install neither, so they don't + /// suppress the native context menu in exchange for nothing. + var onSecondaryClick: ((Item) -> Void)? { + didSet { + installMacSecondaryClickIfNeeded() + installIPadSecondaryClickIfNeeded() } - - private struct SnapshotApplyRequest { - var snapshot: NSDiffableDataSourceSnapshot - var animatingDifferences: Bool - var completion: (() -> Void)? - /// `true` when this request was issued by `reconfigureAllItems` (live theme switch). - /// The pending-slot coalescer carries this flag — and a re-applied - /// `reconfigureItems(allCurrentIDs)` — forward into any superseding request so the - /// reconfigure intent isn't silently dropped when latest-wins overwrites pendingSnapshot. - var reconfigureAll: Bool = false + } + + /// Guards the one-time install of the Mac secondary-click interaction. + private var hasInstalledMacSecondaryClick = false + + /// Guards the one-time install of the iPad secondary-click recognizer. + private var hasInstalledIPadSecondaryClick = false + + /// Closure to check if an item contains an unseen self-mention + var isUnseenMention: ((Item) -> Bool)? + + /// Item ID of the new messages divider (for visibility tracking) + var dividerItemID: Item.ID? + + /// Callback when the divider row's visibility changes + var onDividerVisibilityChanged: ((Bool) -> Void)? + + /// Last reported divider visibility (change detection to avoid redundant callbacks) + private var lastDividerVisible: Bool? + + /// The full set of unseen self-mention ids, the source for the off-screen subset. + var unseenMentionIDs: [Item.ID] = [] + + /// Reports the unseen mentions not currently on screen, ordered oldest-to-newest as in + /// `unseenMentionIDs`. Their count drives the scroll-to-mention button; the last (newest) is + /// its scroll target. + var onOffscreenMentionsChanged: (([Item.ID]) -> Void)? + + /// Last reported off-screen mentions (change detection to avoid redundant callbacks) + private var lastReportedOffscreenMentionIDs: [Item.ID]? + + /// Tracks mention IDs that have already been reported as visible (prevents duplicate callbacks) + private var markedMentionIDs: Set = [] + + private var pendingScrollTargetID: Item.ID? + private var pendingScrollTask: Task? + private var checkVisibleMentionsTask: Task? + + /// Latest-wins buffer for updateItems calls received mid-drag. Applying a + /// snapshot mid-drag shifts contentOffset and fights the gesture; draining + /// on drag-end lets the offset adjust on settled content. + private var deferredItemsApply: (newItems: [Item], animated: Bool)? + + /// Coalesces the four scroll-tracking callbacks + /// (`updateIsAtBottom`, `checkVisibleMentions`, `checkDividerVisibility`, + /// `checkNearTop`) to at most one invocation per display frame. + /// `scrollViewDidScroll` only sets a flag; the display link's tick drains it. + private var hasPendingScrollObservation = false + private var scrollDisplayLink: CADisplayLink? + private let scrollDisplayLinkProxy = ChatScrollDisplayLinkProxy() + + /// Target item ID for programmatic scroll, derived from the active scroll intent. + private var scrollTargetItemID: Item.ID? { + if case let .toTarget(id) = scrollState.intent { return id } + return nil + } + + /// True when an auto-scroll-to-bottom was suppressed because the user was interacting. + /// Fired on drag end so messages arriving mid-drag aren't silently dropped. + var deferredScrollToBottomPending: Bool { + scrollState.deferredScroll != nil + } + + /// Count of messages deferred while user is interacting; counted as unread if they release scrolled away. + var deferredScrollMessageCount: Int { + scrollState.deferredScroll?.targetMessageCount ?? 0 + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + // Flip the table view for chat-style bottom anchoring + tableView.transform = CGAffineTransform(scaleX: 1, y: -1) + + // UIKit keyboard handling - bypasses SwiftUI bugs + tableView.keyboardDismissMode = .onDrag + + // Visual setup + tableView.separatorStyle = .none + // estimatedRowHeight intentionally unset: UIHostingConfiguration + // self-sizing produces an exact contentSize, so pagination prepends + // don't shift contentOffset as estimates are replaced with measurements. + + // Flipped table (scaleX: 1, y: -1) inverts top/bottom, so automatic + // content-inset adjustment applies safe-area padding to the wrong edges. + // SwiftUI's .safeAreaInset already handles the input bar, so disable UIKit's. + tableView.contentInsetAdjustmentBehavior = .never + + if #available(iOS 26.0, *) { + // Clear and non-opaque allows Liquid Glass effects on nav/input bars + tableView.backgroundColor = .clear + tableView.isOpaque = false + + // Scroll edge effects don't work correctly with flipped table transform. + // Hide both - the nav bar and input bar provide their own Liquid Glass blur. + tableView.topEdgeEffect.isHidden = true + tableView.bottomEdgeEffect.isHidden = true + } else { + tableView.backgroundColor = .systemBackground } - - // MARK: - Properties - - private var items: [Item] = [] - /// O(1) lookup for items by ID (replaces O(n) first(where:) in cell provider) - private var itemsByID: [Item.ID: Item] = [:] - /// O(1) index lookup for scroll-to-item (replaces O(n) firstIndex(where:)) - private var itemIndexByID: [Item.ID: Int] = [:] - private var cellContentProvider: ((Item) -> CellContent)? - var defaultTableBackgroundColor: UIColor? - private var dataSource: UITableViewDiffableDataSource? - /// Snapshot scheduled while a previous apply was still running. Latest - /// wins: when a new request arrives mid-apply, it replaces this field - /// and the intermediate snapshot is skipped. Diffable data source - /// derives the visual result from the final snapshot alone, so - /// dropping intermediates is safe. - private var pendingSnapshot: SnapshotApplyRequest? - /// Completions from snapshot requests whose snapshot was superseded - /// before it landed. They still need to run because callers (notably - /// the pagination prepend path) use them to restore the anchor row's - /// viewport position after layout settles; the anchor row is part of - /// the superseding snapshot too, so measuring against the post-apply - /// layout is correct. Drained in order after the latest apply lands. - private var pendingCompletions: [() -> Void] = [] - - /// Bundled interaction/intent/apply/deferred axes for the scroll surface. - private(set) var scrollState: ChatScrollState = .idle - - /// Tracks scroll position relative to bottom - private(set) var isAtBottom: Bool = true - - /// Count of unread messages (messages added while scrolled up) - private(set) var unreadCount: Int = 0 - - /// ID of last message user has seen (for unread tracking) - private var lastSeenItemID: Item.ID? - - /// Callback when scroll state changes - var onScrollStateChanged: ((Bool, Int) -> Void)? - - /// Callback when user scrolls near the top (oldest messages). The closure receives a release - /// callback the consumer must invoke when pagination work completes (success or short-circuit) - /// so the request latch clears even when the view model's isLoadingOlder never visibly flips. - var onNearTop: ((@escaping @MainActor () -> Void) -> Void)? - - /// Whether pagination is in progress (skip auto-scroll during pagination) - var isLoadingOlderMessages = false - - /// Suppresses duplicate onNearTop fires while the view model's isLoadingOlder propagates back through SwiftUI - private var isNearTopRequestInFlight = false - - /// Callback when a mention becomes visible. Returns whether the seen-state was - /// persisted; a false result means the id must not stay marked, so it can re-fire. - var onMentionBecameVisible: ((Item.ID) async -> Bool)? - - /// Callback when a row receives a secondary (right) click from a pointer. On Mac the click - /// reaches the context-menu system (`UIContextMenuInteraction`); on iPad a trackpad or mouse - /// secondary click arrives as an indirect-pointer tap, handled by a dedicated recognizer. Touch - /// long-press is never routed here (it opens the sheet through the bubble's own gesture), so the - /// two paths can't double-trigger. Surfaces that leave this nil install neither, so they don't - /// suppress the native context menu in exchange for nothing. - var onSecondaryClick: ((Item) -> Void)? { - didSet { - installMacSecondaryClickIfNeeded() - installIPadSecondaryClickIfNeeded() - } + tableView.allowsSelection = false + + // Register cell + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + + // Configure data source + configureDataSource() + + // Manual keyboard observation (UIKit auto-adjustment doesn't work in SwiftUI embed) + setupKeyboardObservers() + + // Coalesces scroll-tracking callbacks at display-frame cadence + setupScrollDisplayLink() + } + + /// Installs the Mac secondary-click interaction the first time a handler is set. Gated on Mac and + /// on a handler existing, so iPad uses its own recognizer instead and a surface without a handler + /// (a room with no actions sheet) leaves the native context menu untouched. + private func installMacSecondaryClickIfNeeded() { + guard ProcessInfo.processInfo.isiOSAppOnMac, + !hasInstalledMacSecondaryClick, onSecondaryClick != nil, isViewLoaded else { return } + hasInstalledMacSecondaryClick = true + tableView.addInteraction(UIContextMenuInteraction(delegate: self)) + } + + /// Installs the iPad secondary-click recognizer the first time a handler is set. A trackpad or + /// mouse secondary click arrives as an indirect-pointer event, not through the context-menu + /// system, so the Mac `UIContextMenuInteraction` never fires for it. `buttonMaskRequired` plus + /// the `shouldReceive` guard scope the recognizer to a sole secondary click, so a finger touch + /// (including the bubble long-press) is never delivered here and the two paths can't + /// double-trigger the sheet. + private func installIPadSecondaryClickIfNeeded() { + guard !ProcessInfo.processInfo.isiOSAppOnMac, + !hasInstalledIPadSecondaryClick, onSecondaryClick != nil, isViewLoaded else { return } + hasInstalledIPadSecondaryClick = true + let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleSecondaryClick(_:))) + recognizer.buttonMaskRequired = .secondary + recognizer.cancelsTouchesInView = false + recognizer.delegate = self + tableView.addGestureRecognizer(recognizer) + } + + /// Resolves the model item under a point in the table view's coordinate space. + private func itemForRow(at point: CGPoint) -> Item? { + guard let indexPath = tableView.indexPathForRow(at: point), + let itemID = dataSource?.itemIdentifier(for: indexPath) else { return nil } + return itemsByID[itemID] + } + + /// On the class, not an extension: a generic class can carry `@objc` conformance + /// only in its primary declaration. + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + guard let item = itemForRow(at: location) else { return nil } + onSecondaryClick?(item) + // No configuration suppresses the native menu, leaving the sheet the only actions surface. + return nil + } + + @objc private func handleSecondaryClick(_ recognizer: UITapGestureRecognizer) { + guard let item = itemForRow(at: recognizer.location(in: tableView)) else { return } + onSecondaryClick?(item) + } + + /// Scope the iPad recognizer to a sole secondary-button click. A direct touch reports an empty + /// button mask, so this rejects finger presses (and the bubble long-press), leaving an + /// indirect-pointer secondary click the only event that reaches `handleSecondaryClick`. The mask + /// is read from the event because the recognizer's own mask isn't updated with it yet here. + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool { + event.buttonMask == .secondary + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + /// Swift 6.3.2 EarlyPerfInliner crashes (infinite recursion in + /// `isCallerAndCalleeLayoutConstraintsCompatible`) when optimizing this + /// generic UITableViewController subclass's deinit under -O. Opting the + /// deinit out of optimization sidesteps the crash without changing + /// runtime behavior. Drop the attribute once a future Swift release + /// fixes the underlying inliner bug. + @_optimize(none) + isolated deinit { + NotificationCenter.default.removeObserver(self) + scrollDisplayLink?.invalidate() + checkVisibleMentionsTask?.cancel() + } + + // MARK: - Keyboard Handling + + private func setupKeyboardObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow(_:)), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChangeFrame(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + } + + private func keyboardFrameEnd(_ notification: Notification) -> CGRect? { + notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + } + + @objc private func keyboardWillHide(_ notification: Notification) { + let frameEnd = keyboardFrameEnd(notification) ?? .null + chatKeyboardLogger.debug( + "keyboardWillHide frameEnd=\(frameEnd.debugDescription, privacy: .public) safeAreaBottom=\(view.safeAreaInsets.bottom, privacy: .public)" + ) + } + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + let frameEnd = keyboardFrameEnd(notification) ?? .null + chatKeyboardLogger.debug( + "keyboardWillChangeFrame frameEnd=\(frameEnd.debugDescription, privacy: .public) safeAreaBottom=\(view.safeAreaInsets.bottom, privacy: .public)" + ) + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + chatKeyboardLogger.debug( + "viewSafeAreaInsetsDidChange bottom=\(view.safeAreaInsets.bottom, privacy: .public)" + ) + } + + // MARK: - Scroll Coalescing + + /// Creates the `CADisplayLink` that drains coalesced scroll observations. + /// The link retains its target (the proxy), not the controller, so the + /// `deinit` path stays clean. Starts paused; `scrollViewDidScroll` unpauses. + private func setupScrollDisplayLink() { + scrollDisplayLinkProxy.onTick = { [weak self] in + self?.coalescedScrollTick() } - - /// Guards the one-time install of the Mac secondary-click interaction. - private var hasInstalledMacSecondaryClick = false - - /// Guards the one-time install of the iPad secondary-click recognizer. - private var hasInstalledIPadSecondaryClick = false - - /// Closure to check if an item contains an unseen self-mention - var isUnseenMention: ((Item) -> Bool)? - - /// Item ID of the new messages divider (for visibility tracking) - var dividerItemID: Item.ID? - - /// Callback when the divider row's visibility changes - var onDividerVisibilityChanged: ((Bool) -> Void)? - - /// Last reported divider visibility (change detection to avoid redundant callbacks) - private var lastDividerVisible: Bool? - - /// The full set of unseen self-mention ids, the source for the off-screen subset. - var unseenMentionIDs: [Item.ID] = [] - - /// Reports the unseen mentions not currently on screen, ordered oldest-to-newest as in - /// `unseenMentionIDs`. Their count drives the scroll-to-mention button; the last (newest) is - /// its scroll target. - var onOffscreenMentionsChanged: (([Item.ID]) -> Void)? - - /// Last reported off-screen mentions (change detection to avoid redundant callbacks) - private var lastReportedOffscreenMentionIDs: [Item.ID]? - - /// Tracks mention IDs that have already been reported as visible (prevents duplicate callbacks) - private var markedMentionIDs: Set = [] - - private var pendingScrollTargetID: Item.ID? - private var pendingScrollTask: Task? - private var checkVisibleMentionsTask: Task? - - /// Latest-wins buffer for updateItems calls received mid-drag. Applying a - /// snapshot mid-drag shifts contentOffset and fights the gesture; draining - /// on drag-end lets the offset adjust on settled content. - private var deferredItemsApply: (newItems: [Item], animated: Bool)? - - /// Coalesces the four scroll-tracking callbacks - /// (`updateIsAtBottom`, `checkVisibleMentions`, `checkDividerVisibility`, - /// `checkNearTop`) to at most one invocation per display frame. - /// `scrollViewDidScroll` only sets a flag; the display link's tick drains it. - private var hasPendingScrollObservation = false - private var scrollDisplayLink: CADisplayLink? - private let scrollDisplayLinkProxy = ChatScrollDisplayLinkProxy() - - /// Target item ID for programmatic scroll, derived from the active scroll intent. - private var scrollTargetItemID: Item.ID? { - if case .toTarget(let id) = scrollState.intent { return id } - return nil + let link = CADisplayLink( + target: scrollDisplayLinkProxy, + selector: #selector(ChatScrollDisplayLinkProxy.tick(_:)) + ) + link.add(to: .main, forMode: .common) + link.isPaused = true + scrollDisplayLink = link + } + + /// Drains pending scroll observations once per display frame. If a callback + /// re-arms the flag during processing, the link stays unpaused so the next + /// frame picks it up; otherwise the link pauses to avoid waking the run loop. + private func coalescedScrollTick() { + let hadWork = hasPendingScrollObservation + hasPendingScrollObservation = false + if hadWork { + updateIsAtBottom() + let visible = visibleItems() + checkVisibleMentions(visible: visible) + reportOffscreenMentions(visible: visible) + checkDividerVisibility() + checkNearTop() } - - /// True when an auto-scroll-to-bottom was suppressed because the user was interacting. - /// Fired on drag end so messages arriving mid-drag aren't silently dropped. - var deferredScrollToBottomPending: Bool { scrollState.deferredScroll != nil } - - /// Count of messages deferred while user is interacting; counted as unread if they release scrolled away. - var deferredScrollMessageCount: Int { scrollState.deferredScroll?.targetMessageCount ?? 0 } - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - // Flip the table view for chat-style bottom anchoring - tableView.transform = CGAffineTransform(scaleX: 1, y: -1) - - // UIKit keyboard handling - bypasses SwiftUI bugs - tableView.keyboardDismissMode = .onDrag - - // Visual setup - tableView.separatorStyle = .none - // estimatedRowHeight intentionally unset: UIHostingConfiguration - // self-sizing produces an exact contentSize, so pagination prepends - // don't shift contentOffset as estimates are replaced with measurements. - - // Flipped table (scaleX: 1, y: -1) inverts top/bottom, so automatic - // content-inset adjustment applies safe-area padding to the wrong edges. - // SwiftUI's .safeAreaInset already handles the input bar, so disable UIKit's. - tableView.contentInsetAdjustmentBehavior = .never - - if #available(iOS 26.0, *) { - // Clear and non-opaque allows Liquid Glass effects on nav/input bars - tableView.backgroundColor = .clear - tableView.isOpaque = false - - // Scroll edge effects don't work correctly with flipped table transform. - // Hide both - the nav bar and input bar provide their own Liquid Glass blur. - tableView.topEdgeEffect.isHidden = true - tableView.bottomEdgeEffect.isHidden = true - } else { - tableView.backgroundColor = .systemBackground - } - tableView.allowsSelection = false - - // Register cell - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - - // Configure data source - configureDataSource() - - // Manual keyboard observation (UIKit auto-adjustment doesn't work in SwiftUI embed) - setupKeyboardObservers() - - // Coalesces scroll-tracking callbacks at display-frame cadence - setupScrollDisplayLink() - } - - /// Installs the Mac secondary-click interaction the first time a handler is set. Gated on Mac and - /// on a handler existing, so iPad uses its own recognizer instead and a surface without a handler - /// (a room with no actions sheet) leaves the native context menu untouched. - private func installMacSecondaryClickIfNeeded() { - guard ProcessInfo.processInfo.isiOSAppOnMac, - !hasInstalledMacSecondaryClick, onSecondaryClick != nil, isViewLoaded else { return } - hasInstalledMacSecondaryClick = true - tableView.addInteraction(UIContextMenuInteraction(delegate: self)) - } - - /// Installs the iPad secondary-click recognizer the first time a handler is set. A trackpad or - /// mouse secondary click arrives as an indirect-pointer event, not through the context-menu - /// system, so the Mac `UIContextMenuInteraction` never fires for it. `buttonMaskRequired` plus - /// the `shouldReceive` guard scope the recognizer to a sole secondary click, so a finger touch - /// (including the bubble long-press) is never delivered here and the two paths can't - /// double-trigger the sheet. - private func installIPadSecondaryClickIfNeeded() { - guard !ProcessInfo.processInfo.isiOSAppOnMac, - !hasInstalledIPadSecondaryClick, onSecondaryClick != nil, isViewLoaded else { return } - hasInstalledIPadSecondaryClick = true - let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleSecondaryClick(_:))) - recognizer.buttonMaskRequired = .secondary - recognizer.cancelsTouchesInView = false - recognizer.delegate = self - tableView.addGestureRecognizer(recognizer) - } - - /// Resolves the model item under a point in the table view's coordinate space. - private func itemForRow(at point: CGPoint) -> Item? { - guard let indexPath = tableView.indexPathForRow(at: point), - let itemID = dataSource?.itemIdentifier(for: indexPath) else { return nil } - return itemsByID[itemID] - } - - // On the class, not an extension: a generic class can carry `@objc` conformance - // only in its primary declaration. - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - configurationForMenuAtLocation location: CGPoint - ) -> UIContextMenuConfiguration? { - guard let item = itemForRow(at: location) else { return nil } - onSecondaryClick?(item) - // No configuration suppresses the native menu, leaving the sheet the only actions surface. - return nil - } - - @objc private func handleSecondaryClick(_ recognizer: UITapGestureRecognizer) { - guard let item = itemForRow(at: recognizer.location(in: tableView)) else { return } - onSecondaryClick?(item) - } - - // Scope the iPad recognizer to a sole secondary-button click. A direct touch reports an empty - // button mask, so this rejects finger presses (and the bubble long-press), leaving an - // indirect-pointer secondary click the only event that reaches `handleSecondaryClick`. The mask - // is read from the event because the recognizer's own mask isn't updated with it yet here. - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool { - event.buttonMask == .secondary - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - true - } - - // Swift 6.3.2 EarlyPerfInliner crashes (infinite recursion in - // `isCallerAndCalleeLayoutConstraintsCompatible`) when optimizing this - // generic UITableViewController subclass's deinit under -O. Opting the - // deinit out of optimization sidesteps the crash without changing - // runtime behavior. Drop the attribute once a future Swift release - // fixes the underlying inliner bug. - @_optimize(none) - isolated deinit { - NotificationCenter.default.removeObserver(self) - scrollDisplayLink?.invalidate() - checkVisibleMentionsTask?.cancel() + if !hasPendingScrollObservation { + scrollDisplayLink?.isPaused = true } + } - // MARK: - Keyboard Handling - - private func setupKeyboardObservers() { - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillShow(_:)), - name: UIResponder.keyboardWillShowNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillHide(_:)), - name: UIResponder.keyboardWillHideNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillChangeFrame(_:)), - name: UIResponder.keyboardWillChangeFrameNotification, - object: nil - ) - } - - private func keyboardFrameEnd(_ notification: Notification) -> CGRect? { - notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect - } - - @objc private func keyboardWillHide(_ notification: Notification) { - let frameEnd = keyboardFrameEnd(notification) ?? .null - chatKeyboardLogger.debug( - "keyboardWillHide frameEnd=\(frameEnd.debugDescription, privacy: .public) safeAreaBottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" - ) - } - - @objc private func keyboardWillChangeFrame(_ notification: Notification) { - let frameEnd = keyboardFrameEnd(notification) ?? .null - chatKeyboardLogger.debug( - "keyboardWillChangeFrame frameEnd=\(frameEnd.debugDescription, privacy: .public) safeAreaBottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" - ) - } - - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - chatKeyboardLogger.debug( - "viewSafeAreaInsetsDidChange bottom=\(self.view.safeAreaInsets.bottom, privacy: .public)" - ) - } - - // MARK: - Scroll Coalescing - - /// Creates the `CADisplayLink` that drains coalesced scroll observations. - /// The link retains its target (the proxy), not the controller, so the - /// `deinit` path stays clean. Starts paused; `scrollViewDidScroll` unpauses. - private func setupScrollDisplayLink() { - scrollDisplayLinkProxy.onTick = { [weak self] in - self?.coalescedScrollTick() - } - let link = CADisplayLink( - target: scrollDisplayLinkProxy, - selector: #selector(ChatScrollDisplayLinkProxy.tick(_:)) - ) - link.add(to: .main, forMode: .common) - link.isPaused = true - scrollDisplayLink = link - } - - /// Drains pending scroll observations once per display frame. If a callback - /// re-arms the flag during processing, the link stays unpaused so the next - /// frame picks it up; otherwise the link pauses to avoid waking the run loop. - private func coalescedScrollTick() { - let hadWork = hasPendingScrollObservation - hasPendingScrollObservation = false - if hadWork { - updateIsAtBottom() - let visible = visibleItems() - checkVisibleMentions(visible: visible) - reportOffscreenMentions(visible: visible) - checkDividerVisibility() - checkNearTop() - } - if !hasPendingScrollObservation { - scrollDisplayLink?.isPaused = true - } - } - - #if DEBUG + #if DEBUG /// Drains pending scroll observations synchronously. Production code /// relies on the display link; this entry point lets unit tests verify /// scroll callbacks without waiting for a real frame tick. func flushScrollObservationsForTests() { - coalescedScrollTick() + coalescedScrollTick() } - #endif + #endif - #if DEBUG + #if DEBUG /// Exposes snapshot-derived scroll-row resolution so unit tests can assert that /// scroll targets are sourced from the applied snapshot (nil-safe for ids not /// yet applied) rather than the controller's leading items model. func resolvedScrollRowForTests(id: Item.ID) -> IndexPath? { - snapshotRow(for: id) + snapshotRow(for: id) } - #endif + #endif - #if DEBUG + #if DEBUG /// Advances the items model (items/itemsByID/itemIndexByID) without applying a /// diffable snapshot, reproducing the model-ahead-of-snapshot state the apply-lag /// window produces — where a model-derived row can exceed the applied row count @@ -397,13 +400,13 @@ final class ChatTableViewController CellContent) { - self.cellContentProvider = cellContent + chatKeyboardLogger.debug( + "keyboardWillShow frameEnd=\((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect ?? .null).debugDescription, privacy: .public) safeAreaBottom=\(view.safeAreaInsets.bottom, privacy: .public)" + ) + + let wasAtBottom = isAtBottom + + // SwiftUI handles frame changes for keyboard, so we don't add content inset. + // Just scroll to bottom after layout settles if we were at bottom. + if wasAtBottom { + // Set intent now to prevent scroll delegate from reacting to contentOffset + // oscillations during keyboard animation. Critical when content is shorter + // than visible area - the bouncing would otherwise cause isAtBottom to flip. + scrollState.startIntent(.toBottom) + + // Delay to let SwiftUI complete its layout pass + Task { @MainActor [weak self] in + try? await Task.sleep(for: ChatScrollConstants.layoutSettleDelay) + self?.scrollToBottom(animated: true) + } } + } + + // MARK: - Configuration + + func configure(cellContent: @escaping (Item) -> CellContent) { + cellContentProvider = cellContent + } + + // MARK: - Data Source + + /// Row for an item in the *applied* diffable snapshot, or nil if the snapshot + /// has not yet caught up with the controller's items model. updateItems mutates + /// items/itemIndexByID synchronously while the snapshot apply can lag (queued + /// behind an in-flight apply), so model-derived rows can exceed the table's + /// applied row count and abort scrollToRow. All scroll-row lookups must go + /// through here. Mirrors the snapshot-derived lookup in restorePrependAnchor. + private func snapshotRow(for id: Item.ID) -> IndexPath? { + dataSource?.indexPath(for: id) + } + + private func configureDataSource() { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemID in + guard let self, + let item = itemsByID[itemID] else { + return UITableViewCell() + } + + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + + // Flip cell back to normal orientation (must be cell, not contentView, + // because UIHostingConfiguration replaces contentView hierarchy) + cell.transform = CGAffineTransform(scaleX: 1, y: -1) + cell.backgroundColor = .clear + cell.selectionStyle = .none + // Allow long-press scale-up and shadow on message bubbles to extend + // past the cell frame instead of being clipped at cell edges. + cell.clipsToBounds = false + cell.contentView.clipsToBounds = false + + // Embed SwiftUI content + if let contentProvider = cellContentProvider { + if #available(iOS 26.0, *) { + cell.contentConfiguration = UIHostingConfiguration { + contentProvider(item) + } + .margins(.all, 0) + .minSize(width: 0, height: 0) + .background(.clear) + } else { + cell.contentConfiguration = UIHostingConfiguration { + contentProvider(item) + } + .margins(.all, 0) + .minSize(width: 0, height: 0) + } + } - // MARK: - Data Source - - /// Row for an item in the *applied* diffable snapshot, or nil if the snapshot - /// has not yet caught up with the controller's items model. updateItems mutates - /// items/itemIndexByID synchronously while the snapshot apply can lag (queued - /// behind an in-flight apply), so model-derived rows can exceed the table's - /// applied row count and abort scrollToRow. All scroll-row lookups must go - /// through here. Mirrors the snapshot-derived lookup in restorePrependAnchor. - private func snapshotRow(for id: Item.ID) -> IndexPath? { - dataSource?.indexPath(for: id) + return cell } - - private func configureDataSource() { - dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemID in - guard let self, - let item = self.itemsByID[itemID] else { - return UITableViewCell() - } - - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - - // Flip cell back to normal orientation (must be cell, not contentView, - // because UIHostingConfiguration replaces contentView hierarchy) - cell.transform = CGAffineTransform(scaleX: 1, y: -1) - cell.backgroundColor = .clear - cell.selectionStyle = .none - // Allow long-press scale-up and shadow on message bubbles to extend - // past the cell frame instead of being clipped at cell edges. - cell.clipsToBounds = false - cell.contentView.clipsToBounds = false - - // Embed SwiftUI content - if let contentProvider = self.cellContentProvider { - if #available(iOS 26.0, *) { - cell.contentConfiguration = UIHostingConfiguration { - contentProvider(item) - } - .margins(.all, 0) - .minSize(width: 0, height: 0) - .background(.clear) - } else { - cell.contentConfiguration = UIHostingConfiguration { - contentProvider(item) - } - .margins(.all, 0) - .minSize(width: 0, height: 0) - } - } - - return cell + } + + // MARK: - Update Items + + /// When true, updateItems will skip auto-scroll (caller will scroll explicitly) + private var skipAutoScroll = false + + private func applySnapshot( + _ snapshot: NSDiffableDataSourceSnapshot, + animatingDifferences: Bool, + completion: (() -> Void)? = nil, + reconfigureAll: Bool = false + ) { + var request = SnapshotApplyRequest( + snapshot: snapshot, + animatingDifferences: animatingDifferences, + completion: completion, + reconfigureAll: reconfigureAll + ) + + if scrollState.isApplyingSnapshot { + if let existing = pendingSnapshot { + // Latest-wins for the snapshot itself, but preserve the superseded + // request's completion so prepend anchor restores still fire after + // the final apply lands. + if let superseded = existing.completion { + pendingCompletions.append(superseded) } + // Carry the superseded snapshot's reconfigure intent forward into the + // incoming snapshot so latest-wins doesn't silently drop a repaint. A + // content-only reconfigure parked here would otherwise never reach an + // applied snapshot, stranding its cell on stale content. Only items still + // present in the incoming snapshot are carried; superseded-then-deleted + // items are gone regardless. + let carriedReconfigures = existing.snapshot.reconfiguredItemIdentifiers + .filter { request.snapshot.itemIdentifiers.contains($0) } + if !carriedReconfigures.isEmpty { + request.snapshot.reconfigureItems(carriedReconfigures) + } + // A reconfigure-all (live theme switch) additionally repaints rows added by + // the incoming snapshot, so extend the intent to its full identifier set. + if existing.reconfigureAll { + request.snapshot.reconfigureItems(request.snapshot.itemIdentifiers) + request.reconfigureAll = true + } + } + // The opposite ordering is intentionally not mirrored: an incoming reconfigure-all + // replacing a pending content snapshot adopts the reconfigure's (already-applied) + // identifier set, so the superseded snapshot's not-yet-applied items wait for the next + // apply. That is safe because a theme change also drives buildItems() with the full + // current set within a few frames, so the deferred rows reappear without a visible gap. + pendingSnapshot = request + return } - // MARK: - Update Items - - /// When true, updateItems will skip auto-scroll (caller will scroll explicitly) - private var skipAutoScroll = false - - private func applySnapshot( - _ snapshot: NSDiffableDataSourceSnapshot, - animatingDifferences: Bool, - completion: (() -> Void)? = nil, - reconfigureAll: Bool = false - ) { - var request = SnapshotApplyRequest( - snapshot: snapshot, - animatingDifferences: animatingDifferences, - completion: completion, - reconfigureAll: reconfigureAll - ) + applySnapshotRequest(request) + } - if scrollState.isApplyingSnapshot { - if let existing = pendingSnapshot { - // Latest-wins for the snapshot itself, but preserve the superseded - // request's completion so prepend anchor restores still fire after - // the final apply lands. - if let superseded = existing.completion { - pendingCompletions.append(superseded) - } - // Carry the superseded snapshot's reconfigure intent forward into the - // incoming snapshot so latest-wins doesn't silently drop a repaint. A - // content-only reconfigure parked here would otherwise never reach an - // applied snapshot, stranding its cell on stale content. Only items still - // present in the incoming snapshot are carried; superseded-then-deleted - // items are gone regardless. - let carriedReconfigures = existing.snapshot.reconfiguredItemIdentifiers - .filter { request.snapshot.itemIdentifiers.contains($0) } - if !carriedReconfigures.isEmpty { - request.snapshot.reconfigureItems(carriedReconfigures) - } - // A reconfigure-all (live theme switch) additionally repaints rows added by - // the incoming snapshot, so extend the intent to its full identifier set. - if existing.reconfigureAll { - request.snapshot.reconfigureItems(request.snapshot.itemIdentifiers) - request.reconfigureAll = true - } - } - // The opposite ordering is intentionally not mirrored: an incoming reconfigure-all - // replacing a pending content snapshot adopts the reconfigure's (already-applied) - // identifier set, so the superseded snapshot's not-yet-applied items wait for the next - // apply. That is safe because a theme change also drives buildItems() with the full - // current set within a few frames, so the deferred rows reappear without a visible gap. - pendingSnapshot = request - return - } - - applySnapshotRequest(request) + private func applySnapshotRequest(_ request: SnapshotApplyRequest) { + guard let dataSource else { + pendingSnapshot = nil + pendingCompletions.removeAll() + scrollState.endApplying() + return } - private func applySnapshotRequest(_ request: SnapshotApplyRequest) { - guard let dataSource else { - pendingSnapshot = nil - pendingCompletions.removeAll() - scrollState.endApplying() - return - } - - #if DEBUG - appliedReconfiguredItemIDsForTests.append(contentsOf: request.snapshot.reconfiguredItemIdentifiers) - #endif + #if DEBUG + appliedReconfiguredItemIDsForTests.append(contentsOf: request.snapshot.reconfiguredItemIdentifiers) + #endif - scrollState.startApplying() - let shouldAnimate = request.animatingDifferences && view.window != nil + scrollState.startApplying() + let shouldAnimate = request.animatingDifferences && view.window != nil - if shouldAnimate { - dataSource.apply(request.snapshot, animatingDifferences: true) { [weak self] in - Task { @MainActor [weak self] in - request.completion?() - self?.drainSnapshotQueue() - } - } - } else { - dataSource.apply(request.snapshot, animatingDifferences: false) - request.completion?() - drainSnapshotQueue() + if shouldAnimate { + dataSource.apply(request.snapshot, animatingDifferences: true) { [weak self] in + Task { @MainActor [weak self] in + request.completion?() + self?.drainSnapshotQueue() } + } + } else { + dataSource.apply(request.snapshot, animatingDifferences: false) + request.completion?() + drainSnapshotQueue() } - - private func drainSnapshotQueue() { - scrollState.endApplying() - // Loop in case a new pending snapshot is enqueued while draining; - // each iteration applies the latest request and clears the field. - while let next = pendingSnapshot { - pendingSnapshot = nil - applySnapshotRequest(next) - } - // Fire superseded completions only when truly idle. An animated apply - // re-enters `scrollState.startApplying()` and finishes its drain in an - // async callback; firing now would run the completions before the - // final layout settles. The async callback will re-enter this method - // and reach the idle branch then. - if !scrollState.isApplyingSnapshot && !pendingCompletions.isEmpty { - let completions = pendingCompletions - pendingCompletions.removeAll() - for completion in completions { - completion() - } - } + } + + private func drainSnapshotQueue() { + scrollState.endApplying() + // Loop in case a new pending snapshot is enqueued while draining; + // each iteration applies the latest request and clears the field. + while let next = pendingSnapshot { + pendingSnapshot = nil + applySnapshotRequest(next) } - - private struct PrependAnchor { - let itemID: Item.ID - /// rect.minY - contentOffset.y at capture time (viewport-relative position) - let distanceFromContentOffset: CGFloat + // Fire superseded completions only when truly idle. An animated apply + // re-enters `scrollState.startApplying()` and finishes its drain in an + // async callback; firing now would run the completions before the + // final layout settles. The async callback will re-enter this method + // and reach the idle branch then. + if !scrollState.isApplyingSnapshot, !pendingCompletions.isEmpty { + let completions = pendingCompletions + pendingCompletions.removeAll() + for completion in completions { + completion() + } } + } - private func capturePrependAnchor(in oldItems: [Item]) -> PrependAnchor? { - guard let visibleRows = tableView.indexPathsForVisibleRows, !visibleRows.isEmpty else { - return nil - } - let midIndexPath = visibleRows[visibleRows.count / 2] - let chronologicalIndex = oldItems.count - 1 - midIndexPath.row - guard chronologicalIndex >= 0, chronologicalIndex < oldItems.count else { return nil } - let rect = tableView.rectForRow(at: midIndexPath) - return PrependAnchor( - itemID: oldItems[chronologicalIndex].id, - distanceFromContentOffset: rect.minY - tableView.contentOffset.y - ) - } + private struct PrependAnchor { + let itemID: Item.ID + /// rect.minY - contentOffset.y at capture time (viewport-relative position) + let distanceFromContentOffset: CGFloat + } - private func restorePrependAnchor(_ anchor: PrependAnchor) { - // Read the row from the data source's current snapshot, not the controller's mutable items, - // because a newer updateItems call may have overwritten items/itemIndexByID while the - // queued prepend apply (whose completion fires here) was waiting to drain. - guard let dataSource, - let indexPath = dataSource.indexPath(for: anchor.itemID) else { return } - tableView.layoutIfNeeded() - let newRect = tableView.rectForRow(at: indexPath) - CATransaction.begin() - CATransaction.setDisableActions(true) - tableView.contentOffset.y = newRect.minY - anchor.distanceFromContentOffset - CATransaction.commit() + private func capturePrependAnchor(in oldItems: [Item]) -> PrependAnchor? { + guard let visibleRows = tableView.indexPathsForVisibleRows, !visibleRows.isEmpty else { + return nil + } + let midIndexPath = visibleRows[visibleRows.count / 2] + let chronologicalIndex = oldItems.count - 1 - midIndexPath.row + guard chronologicalIndex >= 0, chronologicalIndex < oldItems.count else { return nil } + let rect = tableView.rectForRow(at: midIndexPath) + return PrependAnchor( + itemID: oldItems[chronologicalIndex].id, + distanceFromContentOffset: rect.minY - tableView.contentOffset.y + ) + } + + private func restorePrependAnchor(_ anchor: PrependAnchor) { + // Read the row from the data source's current snapshot, not the controller's mutable items, + // because a newer updateItems call may have overwritten items/itemIndexByID while the + // queued prepend apply (whose completion fires here) was waiting to drain. + guard let dataSource, + let indexPath = dataSource.indexPath(for: anchor.itemID) else { return } + tableView.layoutIfNeeded() + let newRect = tableView.rectForRow(at: indexPath) + CATransaction.begin() + CATransaction.setDisableActions(true) + tableView.contentOffset.y = newRect.minY - anchor.distanceFromContentOffset + CATransaction.commit() + } + + func updateItems(_ newItems: [Item], animated: Bool = true) { + if tableView.isDragging { + deferredItemsApply = (newItems, animated) + return } - func updateItems(_ newItems: [Item], animated: Bool = true) { - if tableView.isDragging { - deferredItemsApply = (newItems, animated) - return - } - - let previousCount = items.count - let wasAtBottom = isAtBottom - let oldItems = items - items = newItems - - // Build O(1) lookup dictionaries - itemsByID = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) - itemIndexByID = Dictionary(uniqueKeysWithValues: newItems.enumerated().map { ($0.element.id, $0.offset) }) - - // Detect prepend (pagination) vs append (new messages). A pure prepend changes the - // first id but not the last; requiring an unchanged tail keeps a combined prepend+append - // from being misclassified as prepend, which would skip the unread and auto-scroll branches. - let hasNewItems = newItems.count > previousCount - let wasPrepend = previousCount > 0 && hasNewItems - && oldItems.first?.id != newItems.first?.id - && oldItems.last?.id == newItems.last?.id - - // For prepends, capture a measured anchor row so we can restore the visible - // content's screen position after the snapshot apply changes contentSize. - let prependAnchor = wasPrepend ? capturePrependAnchor(in: oldItems) : nil - - // Apply snapshot with reversed order: newest-first for flipped table - // Row 0 = newest message → appears at visual bottom after flip - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(newItems.reversed().map(\.id)) - - // Find items that changed content (same ID, different hash). - // Without reconfiguring these, diffable data source won't update cells for items with same ID. - let oldItemsByID = Dictionary(uniqueKeysWithValues: oldItems.map { ($0.id, $0) }) - let changedIDs = newItems.compactMap { newItem -> Item.ID? in - guard let oldItem = oldItemsByID[newItem.id] else { return nil } - return oldItem != newItem ? newItem.id : nil - } + let previousCount = items.count + let wasAtBottom = isAtBottom + let oldItems = items + items = newItems + + // Build O(1) lookup dictionaries + itemsByID = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) + itemIndexByID = Dictionary(uniqueKeysWithValues: newItems.enumerated().map { ($0.element.id, $0.offset) }) + + // Detect prepend (pagination) vs append (new messages). A pure prepend changes the + // first id but not the last; requiring an unchanged tail keeps a combined prepend+append + // from being misclassified as prepend, which would skip the unread and auto-scroll branches. + let hasNewItems = newItems.count > previousCount + let wasPrepend = previousCount > 0 && hasNewItems + && oldItems.first?.id != newItems.first?.id + && oldItems.last?.id == newItems.last?.id + + // For prepends, capture a measured anchor row so we can restore the visible + // content's screen position after the snapshot apply changes contentSize. + let prependAnchor = wasPrepend ? capturePrependAnchor(in: oldItems) : nil + + // Apply snapshot with reversed order: newest-first for flipped table + // Row 0 = newest message → appears at visual bottom after flip + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(newItems.reversed().map(\.id)) + + // Find items that changed content (same ID, different hash). + // Without reconfiguring these, diffable data source won't update cells for items with same ID. + let oldItemsByID = Dictionary(uniqueKeysWithValues: oldItems.map { ($0.id, $0) }) + let changedIDs = newItems.compactMap { newItem -> Item.ID? in + guard let oldItem = oldItemsByID[newItem.id] else { return nil } + return oldItem != newItem ? newItem.id : nil + } - // Two-phase apply to handle structural changes and content updates differently: - // 1. Structural changes (new/deleted items) - animate for smooth UX, except prepends - // 2. Content updates (status changes) - no animation to prevent flash - let hasStructuralChanges = newItems.count != oldItems.count || - Set(newItems.map(\.id)) != Set(oldItems.map(\.id)) - - // Skip the apply when nothing changed. Otherwise re-renders triggered by - // non-content state (e.g. ChatRenderState.isLoadingOlder toggling) reach - // applySnapshot without prepend-anchor protection and can shift - // contentOffset, producing a visible jump while scrolling. - if previousCount > 0 && !hasStructuralChanges && changedIDs.isEmpty { - return - } + // Two-phase apply to handle structural changes and content updates differently: + // 1. Structural changes (new/deleted items) - animate for smooth UX, except prepends + // 2. Content updates (status changes) - no animation to prevent flash + let hasStructuralChanges = newItems.count != oldItems.count || + Set(newItems.map(\.id)) != Set(oldItems.map(\.id)) + + // Skip the apply when nothing changed. Otherwise re-renders triggered by + // non-content state (e.g. ChatRenderState.isLoadingOlder toggling) reach + // applySnapshot without prepend-anchor protection and can shift + // contentOffset, producing a visible jump while scrolling. + if previousCount > 0, !hasStructuralChanges, changedIDs.isEmpty { + return + } - // Prepends apply non-animated so anchor restoration in the apply completion runs - // against the post-apply layout, not against a coalesced animation in flight - let restoreClosure: (() -> Void)? = prependAnchor.map { anchor in - { [weak self] in self?.restorePrependAnchor(anchor) } - } + // Prepends apply non-animated so anchor restoration in the apply completion runs + // against the post-apply layout, not against a coalesced animation in flight + let restoreClosure: (() -> Void)? = prependAnchor.map { anchor in + { [weak self] in self?.restorePrependAnchor(anchor) } + } - if hasStructuralChanges { - let animateStructural = animated && previousCount > 0 && !wasPrepend - let structuralIsLastApply = changedIDs.isEmpty - applySnapshot( - snapshot, - animatingDifferences: animateStructural, - completion: structuralIsLastApply ? restoreClosure : nil - ) - - if !changedIDs.isEmpty { - var reconfigureSnapshot = snapshot - reconfigureSnapshot.reconfigureItems(changedIDs) - applySnapshot(reconfigureSnapshot, animatingDifferences: false, completion: restoreClosure) - } - } else if !changedIDs.isEmpty { - snapshot.reconfigureItems(changedIDs) - applySnapshot(snapshot, animatingDifferences: false, completion: restoreClosure) - } else { - applySnapshot(snapshot, animatingDifferences: false, completion: restoreClosure) - } + if hasStructuralChanges { + let animateStructural = animated && previousCount > 0 && !wasPrepend + let structuralIsLastApply = changedIDs.isEmpty + applySnapshot( + snapshot, + animatingDifferences: animateStructural, + completion: structuralIsLastApply ? restoreClosure : nil + ) + + if !changedIDs.isEmpty { + var reconfigureSnapshot = snapshot + reconfigureSnapshot.reconfigureItems(changedIDs) + applySnapshot(reconfigureSnapshot, animatingDifferences: false, completion: restoreClosure) + } + } else if !changedIDs.isEmpty { + snapshot.reconfigureItems(changedIDs) + applySnapshot(snapshot, animatingDifferences: false, completion: restoreClosure) + } else { + applySnapshot(snapshot, animatingDifferences: false, completion: restoreClosure) + } - // Handle unread tracking - if !wasAtBottom && previousCount > 0 && hasNewItems && !wasPrepend { - // New messages arrived while scrolled up (not pagination) - let newMessageCount = newItems.count - previousCount - unreadCount += newMessageCount - onScrollStateChanged?(isAtBottom, unreadCount) - } else if wasAtBottom && hasNewItems && !skipAutoScroll && scrollState.intent != .toBottom && !wasPrepend { - lastSeenItemID = newItems.last?.id - if scrollState.isUserDriven { - // Defer until drag ends — scrolling mid-drag fights the gesture and bounces - let accumulatedCount = (scrollState.deferredScroll?.targetMessageCount ?? 0) + (newItems.count - previousCount) - scrollState.scheduleDeferredScroll( - DeferredScroll(targetMessageCount: accumulatedCount) - ) - } else { - scrollToBottom(animated: animated && previousCount > 0) - } - } + // Handle unread tracking + if !wasAtBottom, previousCount > 0, hasNewItems, !wasPrepend { + // New messages arrived while scrolled up (not pagination) + let newMessageCount = newItems.count - previousCount + unreadCount += newMessageCount + onScrollStateChanged?(isAtBottom, unreadCount) + } else if wasAtBottom, hasNewItems, !skipAutoScroll, scrollState.intent != .toBottom, !wasPrepend { + lastSeenItemID = newItems.last?.id + if scrollState.isUserDriven { + // Defer until drag ends — scrolling mid-drag fights the gesture and bounces + let accumulatedCount = (scrollState.deferredScroll?.targetMessageCount ?? 0) + (newItems.count - previousCount) + scrollState.scheduleDeferredScroll( + DeferredScroll(targetMessageCount: accumulatedCount) + ) + } else { + scrollToBottom(animated: animated && previousCount > 0) + } + } - // Re-evaluate visible mentions and divider after layout settles. Both initialize - // assuming a scroll will correct them; without this an on-load viewport that already - // contains the divider or a mention is never reconciled until the first scroll. - scheduleVisibleMentionsRecheck() + // Re-evaluate visible mentions and divider after layout settles. Both initialize + // assuming a scroll will correct them; without this an on-load viewport that already + // contains the divider or a mention is never reconciled until the first scroll. + scheduleVisibleMentionsRecheck() - if let pendingID = pendingScrollTargetID { - schedulePendingScroll(for: pendingID, delay: ChatScrollConstants.pendingScrollInitialDelay) - } + if let pendingID = pendingScrollTargetID { + schedulePendingScroll(for: pendingID, delay: ChatScrollConstants.pendingScrollInitialDelay) + } + } + + // MARK: - Scroll Control + + /// Called before updateItems when user sends a message. + /// Sets isAtBottom = true so updateItems won't increment unread. + func prepareForUserSend() { + isAtBottom = true + unreadCount = 0 + _ = scrollState.consumeDeferredScroll() + skipAutoScroll = true // Prevent updateItems from calling scrollToBottom (we'll do it explicitly) + } + + func scrollToBottom(animated: Bool) { + guard !items.isEmpty else { return } + + let alreadyAtBottom = tableView.contentOffset.y <= ChatScrollConstants.bottomDetectionEpsilon + + // Set state before scroll to prevent scroll delegate from overriding + isAtBottom = true + unreadCount = 0 + lastSeenItemID = items.last?.id + + // If already at bottom, just update state - no scroll needed. + // In a flipped table view with short content, scrollToRow miscalculates + // the target position and over-scrolls, pushing messages off screen. + if alreadyAtBottom { + scrollState.clearIntent() + onScrollStateChanged?(isAtBottom, unreadCount) + skipAutoScroll = false + return } - // MARK: - Scroll Control - - /// Called before updateItems when user sends a message. - /// Sets isAtBottom = true so updateItems won't increment unread. - func prepareForUserSend() { - isAtBottom = true - unreadCount = 0 - _ = scrollState.consumeDeferredScroll() - skipAutoScroll = true // Prevent updateItems from calling scrollToBottom (we'll do it explicitly) + // Only update intent if not already toBottom (keyboardWillShow may have set it) + if scrollState.intent != .toBottom, animated { + scrollState.startIntent(.toBottom) } - func scrollToBottom(animated: Bool) { - guard !items.isEmpty else { return } + // In flipped table with reversed data: row 0 = newest message + // Scroll row 0 to .top anchor (which is visual bottom in flipped table) + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: animated) - let alreadyAtBottom = tableView.contentOffset.y <= ChatScrollConstants.bottomDetectionEpsilon + if !animated { + scrollState.clearIntent() + } - // Set state before scroll to prevent scroll delegate from overriding - isAtBottom = true - unreadCount = 0 - lastSeenItemID = items.last?.id - - // If already at bottom, just update state - no scroll needed. - // In a flipped table view with short content, scrollToRow miscalculates - // the target position and over-scrolls, pushing messages off screen. - if alreadyAtBottom { - scrollState.clearIntent() - onScrollStateChanged?(isAtBottom, unreadCount) - skipAutoScroll = false - return - } + onScrollStateChanged?(isAtBottom, unreadCount) - // Only update intent if not already toBottom (keyboardWillShow may have set it) - if scrollState.intent != .toBottom && animated { - scrollState.startIntent(.toBottom) - } + // Clear skipAutoScroll after explicit scroll (it was set by prepareForUserSend) + skipAutoScroll = false + } - // In flipped table with reversed data: row 0 = newest message - // Scroll row 0 to .top anchor (which is visual bottom in flipped table) - tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: animated) + func scrollToItem(id: Item.ID, animated: Bool) { + // Use O(1) dictionary lookup instead of O(n) firstIndex + guard itemIndexByID[id] != nil else { return } - if !animated { - scrollState.clearIntent() - } + // Set target intent so the computed scrollTargetItemID picks up the post-scroll reload target + // and so checkNearTop blocks pagination during the scroll-to-target animation. + scrollState.startIntent(.toTarget(id: id)) + pendingScrollTargetID = id - onScrollStateChanged?(isAtBottom, unreadCount) + pendingScrollTask?.cancel() + pendingScrollTask = nil - // Clear skipAutoScroll after explicit scroll (it was set by prepareForUserSend) - skipAutoScroll = false + if animated { + schedulePendingScroll(for: id, delay: ChatScrollConstants.scrollToTargetDelay) + } else { + pendingScrollTargetID = nil + centerItem(id: id, animated: false) + reloadTargetCell() } + } - func scrollToItem(id: Item.ID, animated: Bool) { - // Use O(1) dictionary lookup instead of O(n) firstIndex - guard itemIndexByID[id] != nil else { return } - - // Set target intent so the computed scrollTargetItemID picks up the post-scroll reload target - // and so checkNearTop blocks pagination during the scroll-to-target animation. - scrollState.startIntent(.toTarget(id: id)) - pendingScrollTargetID = id + func scrollToItemIfNotVisible(id: Item.ID, animated: Bool) { + guard let itemIndex = itemIndexByID[id] else { return } + let rowIndex = items.count - 1 - itemIndex + let indexPath = IndexPath(row: rowIndex, section: 0) - pendingScrollTask?.cancel() - pendingScrollTask = nil - - if animated { - schedulePendingScroll(for: id, delay: ChatScrollConstants.scrollToTargetDelay) - } else { - pendingScrollTargetID = nil - centerItem(id: id, animated: false) - reloadTargetCell() - } + if let visibleRows = tableView.indexPathsForVisibleRows, + visibleRows.contains(indexPath) { + return } - func scrollToItemIfNotVisible(id: Item.ID, animated: Bool) { - guard let itemIndex = itemIndexByID[id] else { return } - let rowIndex = items.count - 1 - itemIndex - let indexPath = IndexPath(row: rowIndex, section: 0) + scrollToItem(id: id, animated: animated) + } - if let visibleRows = tableView.indexPathsForVisibleRows, - visibleRows.contains(indexPath) { - return - } + /// Reloads the scroll target cell to fix UIHostingConfiguration layout timing issues + private func reloadTargetCell() { + guard let targetID = scrollTargetItemID else { return } + scrollState.clearIntent() - scrollToItem(id: id, animated: animated) + // Force cell reconfiguration via snapshot reload + var snapshot = dataSource?.snapshot() ?? NSDiffableDataSourceSnapshot() + if snapshot.itemIdentifiers.contains(targetID) { + snapshot.reloadItems([targetID]) + applySnapshot(snapshot, animatingDifferences: false) } - - /// Reloads the scroll target cell to fix UIHostingConfiguration layout timing issues - private func reloadTargetCell() { - guard let targetID = scrollTargetItemID else { return } + } + + /// Reconfigures every current row in place so render-time, non-`MessageItem` styling + /// (the themed bubble fill) repaints on a live theme switch. `reconfigureItems` preserves + /// identity (no insert/delete, no scroll jump) and routes through the same serialized + /// `applySnapshot` path as every other apply. + func reconfigureAllItems() { + guard let dataSource else { return } + var snapshot = dataSource.snapshot() + guard !snapshot.itemIdentifiers.isEmpty else { return } + snapshot.reconfigureItems(snapshot.itemIdentifiers) + applySnapshot(snapshot, animatingDifferences: false, reconfigureAll: true) + } + + /// Returns true if the target was found in the applied snapshot and scrolled. + /// Returns false when the snapshot has not yet caught up to the items model, + /// letting the caller retry instead of silently dropping the scroll. + @discardableResult + private func centerItem(id: Item.ID, animated: Bool) -> Bool { + guard let indexPath = snapshotRow(for: id) else { return false } + tableView.layoutIfNeeded() + // Intent is already .toTarget(id:) from scrollToItem; no need to set again here. + tableView.scrollToRow(at: indexPath, at: .middle, animated: animated) + return true + } + + private func schedulePendingScroll( + for id: Item.ID, + delay: Duration, + retriesRemaining: Int = ChatScrollConstants.pendingScrollMaxRetries + ) { + pendingScrollTask?.cancel() + pendingScrollTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard let self, !Task.isCancelled else { return } + guard pendingScrollTargetID == id, !scrollState.isUserDriven else { return } + pendingScrollTask = nil + if centerItem(id: id, animated: true) { + pendingScrollTargetID = nil + } else if retriesRemaining > 0 { + // The applied snapshot has not caught up to the items model yet. + // Keep the target armed and retry once it has had a chance to drain. + schedulePendingScroll( + for: id, + delay: ChatScrollConstants.pendingScrollRetryDelay, + retriesRemaining: retriesRemaining - 1 + ) + } else { + // No scrollToRow fired, so reloadTargetCell never clears the .toTarget + // intent; clear it here or checkNearTop will block pagination. + pendingScrollTargetID = nil scrollState.clearIntent() - - // Force cell reconfiguration via snapshot reload - var snapshot = dataSource?.snapshot() ?? NSDiffableDataSourceSnapshot() - if snapshot.itemIdentifiers.contains(targetID) { - snapshot.reloadItems([targetID]) - applySnapshot(snapshot, animatingDifferences: false) - } + } } + } - /// Reconfigures every current row in place so render-time, non-`MessageItem` styling - /// (the themed bubble fill) repaints on a live theme switch. `reconfigureItems` preserves - /// identity (no insert/delete, no scroll jump) and routes through the same serialized - /// `applySnapshot` path as every other apply. - func reconfigureAllItems() { - guard let dataSource else { return } - var snapshot = dataSource.snapshot() - guard !snapshot.itemIdentifiers.isEmpty else { return } - snapshot.reconfigureItems(snapshot.itemIdentifiers) - applySnapshot(snapshot, animatingDifferences: false, reconfigureAll: true) - } + // MARK: - Scroll Tracking - /// Returns true if the target was found in the applied snapshot and scrolled. - /// Returns false when the snapshot has not yet caught up to the items model, - /// letting the caller retry instead of silently dropping the scroll. - @discardableResult - private func centerItem(id: Item.ID, animated: Bool) -> Bool { - guard let indexPath = snapshotRow(for: id) else { return false } - tableView.layoutIfNeeded() - // Intent is already .toTarget(id:) from scrollToItem; no need to set again here. - tableView.scrollToRow(at: indexPath, at: .middle, animated: animated) - return true + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + // Arm the coalescer; the display link's next tick drains the callbacks. + // Unpause only on the first hit of each burst — flipping `isPaused` is + // cheap but unnecessary when already running. + if !hasPendingScrollObservation { + hasPendingScrollObservation = true + scrollDisplayLink?.isPaused = false } - - private func schedulePendingScroll( - for id: Item.ID, - delay: Duration, - retriesRemaining: Int = ChatScrollConstants.pendingScrollMaxRetries - ) { - pendingScrollTask?.cancel() - pendingScrollTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: delay) - guard let self, !Task.isCancelled else { return } - guard self.pendingScrollTargetID == id, !self.scrollState.isUserDriven else { return } - self.pendingScrollTask = nil - if self.centerItem(id: id, animated: true) { - self.pendingScrollTargetID = nil - } else if retriesRemaining > 0 { - // The applied snapshot has not caught up to the items model yet. - // Keep the target armed and retry once it has had a chance to drain. - self.schedulePendingScroll( - for: id, - delay: ChatScrollConstants.pendingScrollRetryDelay, - retriesRemaining: retriesRemaining - 1 - ) - } else { - // No scrollToRow fired, so reloadTargetCell never clears the .toTarget - // intent; clear it here or checkNearTop will block pagination. - self.pendingScrollTargetID = nil - self.scrollState.clearIntent() - } - } + } + + /// Re-checks mention and divider state after layout settles. The settle delay lets a snapshot + /// apply or a freshly loaded `unseenMentionIDs` finish before row positions are read. + func scheduleVisibleMentionsRecheck() { + checkVisibleMentionsTask?.cancel() + checkVisibleMentionsTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: ChatScrollConstants.layoutSettleDelay) + guard let self, !Task.isCancelled else { return } + let visible = visibleItems() + checkVisibleMentions(visible: visible) + reportOffscreenMentions(visible: visible) + checkDividerVisibility() } - - // MARK: - Scroll Tracking - - override func scrollViewDidScroll(_ scrollView: UIScrollView) { - // Arm the coalescer; the display link's next tick drains the callbacks. - // Unpause only on the first hit of each burst — flipping `isPaused` is - // cheap but unnecessary when already running. - if !hasPendingScrollObservation { - hasPendingScrollObservation = true - scrollDisplayLink?.isPaused = false - } + } + + /// Items backing the currently visible rows, resolved through the applied snapshot so a model + /// that is momentarily ahead of its snapshot can't map a visible row to the wrong item. + private func visibleItems() -> [Item] { + guard let visibleIndexPaths = tableView.indexPathsForVisibleRows else { return [] } + return visibleIndexPaths.compactMap { indexPath in + guard let id = dataSource?.itemIdentifier(for: indexPath) else { return nil } + return itemsByID[id] } - - /// Re-checks mention and divider state after layout settles. The settle delay lets a snapshot - /// apply or a freshly loaded `unseenMentionIDs` finish before row positions are read. - func scheduleVisibleMentionsRecheck() { - checkVisibleMentionsTask?.cancel() - checkVisibleMentionsTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: ChatScrollConstants.layoutSettleDelay) - guard let self, !Task.isCancelled else { return } - let visible = self.visibleItems() - self.checkVisibleMentions(visible: visible) - self.reportOffscreenMentions(visible: visible) - self.checkDividerVisibility() - } + } + + /// Reports the unseen mentions not currently on screen, the subset that drives the + /// scroll-to-mention button; a mention already in view is excluded. + private func reportOffscreenMentions(visible: [Item]) { + guard let onOffscreenMentionsChanged else { return } + let offscreen: [Item.ID] + if unseenMentionIDs.isEmpty { + offscreen = [] + } else { + let visibleIDs = Set(visible.map(\.id)) + offscreen = unseenMentionIDs.filter { !visibleIDs.contains($0) } } - - /// Items backing the currently visible rows, resolved through the applied snapshot so a model - /// that is momentarily ahead of its snapshot can't map a visible row to the wrong item. - private func visibleItems() -> [Item] { - guard let visibleIndexPaths = tableView.indexPathsForVisibleRows else { return [] } - return visibleIndexPaths.compactMap { indexPath in - guard let id = dataSource?.itemIdentifier(for: indexPath) else { return nil } - return itemsByID[id] - } + if offscreen != lastReportedOffscreenMentionIDs { + lastReportedOffscreenMentionIDs = offscreen + onOffscreenMentionsChanged(offscreen) } - - /// Reports the unseen mentions not currently on screen, the subset that drives the - /// scroll-to-mention button; a mention already in view is excluded. - private func reportOffscreenMentions(visible: [Item]) { - guard let onOffscreenMentionsChanged else { return } - let offscreen: [Item.ID] - if unseenMentionIDs.isEmpty { - offscreen = [] - } else { - let visibleIDs = Set(visible.map(\.id)) - offscreen = unseenMentionIDs.filter { !visibleIDs.contains($0) } - } - if offscreen != lastReportedOffscreenMentionIDs { - lastReportedOffscreenMentionIDs = offscreen - onOffscreenMentionsChanged(offscreen) + } + + private func checkVisibleMentions(visible: [Item]) { + guard let isUnseenMention, let onMentionBecameVisible else { return } + + for item in visible { + // Report each mention once per session. Mark optimistically to debounce in-flight + // reports, then drop the mark if the async seen-persist fails, so a failed save does + // not strand a still-unread mention that a later scroll could otherwise re-fire. + if !markedMentionIDs.contains(item.id), isUnseenMention(item) { + let id = item.id + markedMentionIDs.insert(id) + Task { @MainActor [weak self] in + let persisted = await onMentionBecameVisible(id) + if !persisted { self?.markedMentionIDs.remove(id) } } + } } - - private func checkVisibleMentions(visible: [Item]) { - guard let isUnseenMention, let onMentionBecameVisible else { return } - - for item in visible { - // Report each mention once per session. Mark optimistically to debounce in-flight - // reports, then drop the mark if the async seen-persist fails, so a failed save does - // not strand a still-unread mention that a later scroll could otherwise re-fire. - if !markedMentionIDs.contains(item.id) && isUnseenMention(item) { - let id = item.id - markedMentionIDs.insert(id) - Task { @MainActor [weak self] in - let persisted = await onMentionBecameVisible(id) - if !persisted { self?.markedMentionIDs.remove(id) } - } - } - } + } + + private func checkDividerVisibility() { + guard let dividerItemID, + let indexPath = snapshotRow(for: dividerItemID), + let onDividerVisibilityChanged else { + // No divider configured or not yet in the applied snapshot — report + // not visible if we previously reported visible. + if lastDividerVisible == true { + lastDividerVisible = false + onDividerVisibilityChanged?(false) + } + return } - private func checkDividerVisibility() { - guard let dividerItemID, - let indexPath = snapshotRow(for: dividerItemID), - let onDividerVisibilityChanged else { - // No divider configured or not yet in the applied snapshot — report - // not visible if we previously reported visible. - if lastDividerVisible == true { - lastDividerVisible = false - self.onDividerVisibilityChanged?(false) - } - return - } - - let isVisible = tableView.indexPathsForVisibleRows?.contains(indexPath) ?? false + let isVisible = tableView.indexPathsForVisibleRows?.contains(indexPath) ?? false - if isVisible != lastDividerVisible { - lastDividerVisible = isVisible - onDividerVisibilityChanged(isVisible) - } + if isVisible != lastDividerVisible { + lastDividerVisible = isVisible + onDividerVisibilityChanged(isVisible) } + } - override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if !decelerate { - scrollState.endDragging() - finalizeScrollPosition() - fireDeferredScrollIfNeeded() - } - drainDeferredItemsApply() + override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + scrollState.endDragging() + finalizeScrollPosition() + fireDeferredScrollIfNeeded() } - - override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - scrollState.endDragging() - finalizeScrollPosition() - fireDeferredScrollIfNeeded() - drainDeferredItemsApply() + drainDeferredItemsApply() + } + + override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + scrollState.endDragging() + finalizeScrollPosition() + fireDeferredScrollIfNeeded() + drainDeferredItemsApply() + } + + private func drainDeferredItemsApply() { + guard let deferred = deferredItemsApply else { return } + deferredItemsApply = nil + updateItems(deferred.newItems, animated: deferred.animated) + } + + private func fireDeferredScrollIfNeeded() { + guard let deferred = scrollState.consumeDeferredScroll() else { return } + if isAtBottom { + scrollToBottom(animated: true) + } else { + // User dragged away mid-message — the messages they didn't see become unread + unreadCount += deferred.targetMessageCount + onScrollStateChanged?(isAtBottom, unreadCount) } + } - private func drainDeferredItemsApply() { - guard let deferred = deferredItemsApply else { return } - deferredItemsApply = nil - updateItems(deferred.newItems, animated: deferred.animated) + override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + // Capture whether the completed animation was a scroll-to-bottom before mutating intent. + let wasScrollingToBottom = scrollState.intent == .toBottom + if wasScrollingToBottom { + scrollState.clearIntent() } - private func fireDeferredScrollIfNeeded() { - guard let deferred = scrollState.consumeDeferredScroll() else { return } - if isAtBottom { - scrollToBottom(animated: true) - } else { - // User dragged away mid-message — the messages they didn't see become unread - unreadCount += deferred.targetMessageCount - onScrollStateChanged?(isAtBottom, unreadCount) - } - } + // Reload target cell after scroll completes to fix UIHostingConfiguration layout timing. + // reloadTargetCell clears any .toTarget intent. + reloadTargetCell() - override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - // Capture whether the completed animation was a scroll-to-bottom before mutating intent. - let wasScrollingToBottom = scrollState.intent == .toBottom - if wasScrollingToBottom { - scrollState.clearIntent() - } + if wasScrollingToBottom { + // We just finished a programmatic scroll-to-bottom + // Use larger threshold since animation might not land exactly at 0 + let atBottom = scrollView.contentOffset.y <= ChatScrollConstants.bottomLandingEpsilon + if atBottom { + // Confirm we're at bottom - this is authoritative + isAtBottom = true + unreadCount = 0 + onScrollStateChanged?(isAtBottom, unreadCount) + return + } + } - // Reload target cell after scroll completes to fix UIHostingConfiguration layout timing. - // reloadTargetCell clears any .toTarget intent. - reloadTargetCell() - - if wasScrollingToBottom { - // We just finished a programmatic scroll-to-bottom - // Use larger threshold since animation might not land exactly at 0 - let atBottom = scrollView.contentOffset.y <= ChatScrollConstants.bottomLandingEpsilon - if atBottom { - // Confirm we're at bottom - this is authoritative - isAtBottom = true - unreadCount = 0 - onScrollStateChanged?(isAtBottom, unreadCount) - return - } - } + // For user-initiated scrolls or if we didn't land at bottom, use normal check + updateIsAtBottom() + } - // For user-initiated scrolls or if we didn't land at bottom, use normal check - updateIsAtBottom() + private func updateIsAtBottom() { + // Don't override isAtBottom during programmatic scroll-to-bottom animation + // This prevents the scroll-to-bottom button from flickering when user sends a message + if scrollState.intent == .toBottom { + return } - private func updateIsAtBottom() { - // Don't override isAtBottom during programmatic scroll-to-bottom animation - // This prevents the scroll-to-bottom button from flickering when user sends a message - if scrollState.intent == .toBottom { - return - } - - // In flipped table, visual bottom = contentOffset.y near 0 - // Use small threshold to handle float imprecision - let newIsAtBottom = tableView.contentOffset.y <= ChatScrollConstants.bottomDetectionEpsilon + // In flipped table, visual bottom = contentOffset.y near 0 + // Use small threshold to handle float imprecision + let newIsAtBottom = tableView.contentOffset.y <= ChatScrollConstants.bottomDetectionEpsilon - if newIsAtBottom != isAtBottom { - isAtBottom = newIsAtBottom - onScrollStateChanged?(isAtBottom, unreadCount) - } + if newIsAtBottom != isAtBottom { + isAtBottom = newIsAtBottom + onScrollStateChanged?(isAtBottom, unreadCount) } - - private func finalizeScrollPosition() { - if isAtBottom { - // User scrolled to bottom, clear unread - unreadCount = 0 - lastSeenItemID = items.last?.id - onScrollStateChanged?(isAtBottom, unreadCount) - } + } + + private func finalizeScrollPosition() { + if isAtBottom { + // User scrolled to bottom, clear unread + unreadCount = 0 + lastSeenItemID = items.last?.id + onScrollStateChanged?(isAtBottom, unreadCount) } + } - /// Check if user has scrolled near the top (oldest messages) and trigger callback - private func checkNearTop() { - if scrollState.intent != .none || isLoadingOlderMessages || isNearTopRequestInFlight { - return - } - guard let visibleRows = tableView.indexPathsForVisibleRows, - let highestRow = visibleRows.map(\.row).max() else { return } - - let totalRows = items.count - let distanceFromTop = totalRows - highestRow - - // Trigger when within nearTopTriggerDistance messages of the oldest - if distanceFromTop <= ChatScrollConstants.nearTopTriggerDistance { - isNearTopRequestInFlight = true - onNearTop? { @MainActor [weak self] in - self?.isNearTopRequestInFlight = false - } - } + /// Check if user has scrolled near the top (oldest messages) and trigger callback + private func checkNearTop() { + if scrollState.intent != .none || isLoadingOlderMessages || isNearTopRequestInFlight { + return } - - override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - scrollState.enterDragging() - pendingScrollTargetID = nil - pendingScrollTask?.cancel() - pendingScrollTask = nil + guard let visibleRows = tableView.indexPathsForVisibleRows, + let highestRow = visibleRows.map(\.row).max() else { return } + + let totalRows = items.count + let distanceFromTop = totalRows - highestRow + + // Trigger when within nearTopTriggerDistance messages of the oldest + if distanceFromTop <= ChatScrollConstants.nearTopTriggerDistance { + isNearTopRequestInFlight = true + onNearTop? { @MainActor [weak self] in + self?.isNearTopRequestInFlight = false + } } + } + + override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollState.enterDragging() + pendingScrollTargetID = nil + pendingScrollTask?.cancel() + pendingScrollTask = nil + } } // MARK: - SwiftUI Wrapper /// SwiftUI wrapper for ChatTableViewController struct ChatTableView: UIViewControllerRepresentable where Item.ID == UUID { - - let items: [Item] - let cellContent: (Item) -> Content - /// Themed canvas color for themes that paint a canvas (Ember → black, paid themes → - /// asset-catalog tint); `nil` leaves the table's default system background untouched, so - /// themes without surfaces are unchanged. - var contentBackground: Color? - /// Active theme id (`Theme.id`). Drives a one-shot reconfigure of all rows on a theme change - /// so the render-time bubble fill (`\.appTheme.accentColor`, not part of `MessageItem`) repaints - /// even when no baked text changed — e.g. switching between two themes that share white text. - var themeID: String = Theme.default.id - /// Appearance fingerprint (light/dark + contrast). Like `themeID`, a change reconfigures all rows - /// once so identity colors that depend on the appearance repaint in place. - var appearanceToken: String = "" - @Binding var isAtBottom: Bool - @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - @Binding var scrollToMentionRequest: Int - var isUnseenMention: ((Item) -> Bool)? - /// The current unseen self-mention ids. Feeds the controller's off-screen subset and - /// triggers a recheck when the set changes; the per-row test goes through `isUnseenMention`. - var unseenMentionIDs: [Item.ID] = [] - /// Unseen mentions currently off screen, reported up from the controller; drives the - /// scroll-to-mention button's visibility, count, and scroll target. - @Binding var offscreenMentionIDs: [Item.ID] - var onMentionBecameVisible: ((Item.ID) async -> Bool)? - var onSecondaryClick: ((Item) -> Void)? - var mentionTargetID: Item.ID? - @Binding var scrollToDividerRequest: Int - var dividerItemID: Item.ID? - @Binding var isDividerVisible: Bool - var onNearTop: ((@escaping @MainActor () -> Void) -> Void)? - var isLoadingOlderMessages: Bool = false - - func makeUIViewController(context: Context) -> ChatTableViewController { - let controller = ChatTableViewController() - controller.defaultTableBackgroundColor = controller.tableView.backgroundColor - controller.configure { item in - cellContent(item) - } - // Callback set up in updateUIViewController - context.coordinator.lastScrollRequest = scrollToBottomRequest - controller.isUnseenMention = isUnseenMention - context.coordinator.lastMentionRequest = scrollToMentionRequest - context.coordinator.lastDividerScrollRequest = scrollToDividerRequest - return controller + let items: [Item] + let cellContent: (Item) -> Content + /// Themed canvas color for themes that paint a canvas (Ember → black, paid themes → + /// asset-catalog tint); `nil` leaves the table's default system background untouched, so + /// themes without surfaces are unchanged. + var contentBackground: Color? + /// Active theme id (`Theme.id`). Drives a one-shot reconfigure of all rows on a theme change + /// so the render-time bubble fill (`\.appTheme.accentColor`, not part of `MessageItem`) repaints + /// even when no baked text changed — e.g. switching between two themes that share white text. + var themeID: String = Theme.default.id + /// Appearance fingerprint (light/dark + contrast). Like `themeID`, a change reconfigures all rows + /// once so identity colors that depend on the appearance repaint in place. + var appearanceToken: String = "" + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + @Binding var scrollToMentionRequest: Int + var isUnseenMention: ((Item) -> Bool)? + /// The current unseen self-mention ids. Feeds the controller's off-screen subset and + /// triggers a recheck when the set changes; the per-row test goes through `isUnseenMention`. + var unseenMentionIDs: [Item.ID] = [] + /// Unseen mentions currently off screen, reported up from the controller; drives the + /// scroll-to-mention button's visibility, count, and scroll target. + @Binding var offscreenMentionIDs: [Item.ID] + var onMentionBecameVisible: ((Item.ID) async -> Bool)? + var onSecondaryClick: ((Item) -> Void)? + var mentionTargetID: Item.ID? + @Binding var scrollToDividerRequest: Int + var dividerItemID: Item.ID? + @Binding var isDividerVisible: Bool + var onNearTop: ((@escaping @MainActor () -> Void) -> Void)? + var isLoadingOlderMessages: Bool = false + + func makeUIViewController(context: Context) -> ChatTableViewController { + let controller = ChatTableViewController() + controller.defaultTableBackgroundColor = controller.tableView.backgroundColor + controller.configure { item in + cellContent(item) + } + // Callback set up in updateUIViewController + context.coordinator.lastScrollRequest = scrollToBottomRequest + controller.isUnseenMention = isUnseenMention + context.coordinator.lastMentionRequest = scrollToMentionRequest + context.coordinator.lastDividerScrollRequest = scrollToDividerRequest + return controller + } + + func updateUIViewController(_ controller: ChatTableViewController, context: Context) { + // Update cell content provider each render cycle so reconfigured cells + // get fresh closures (e.g., onRetry callback when message status changes) + controller.configure { item in + cellContent(item) + } + controller.tableView.backgroundColor = contentBackground.map(UIColor.init) ?? controller.defaultTableBackgroundColor + + // Repaint visible bubbles whose render-time accent fill is not part of `MessageItem`: + // a theme-id change reconfigures all rows in place once. The gate skips the first + // pass (no previous id) so appearance does not trigger a needless reconfigure. + let themeChanged = context.coordinator.lastThemeID.map { $0 != themeID } ?? false + let appearanceChanged = context.coordinator.lastAppearanceToken.map { $0 != appearanceToken } ?? false + if themeChanged || appearanceChanged { + controller.reconfigureAllItems() + } + context.coordinator.lastThemeID = themeID + context.coordinator.lastAppearanceToken = appearanceToken + + // Store current binding setters in coordinator (updated each render cycle) + // This ensures deferred callbacks always use fresh bindings + context.coordinator.setIsAtBottom = { [self] in isAtBottom = $0 } + context.coordinator.setUnreadCount = { [self] in unreadCount = $0 } + + // Controller callback defers to next MainActor yield via coordinator. + // SwiftUI blocks binding updates during updateUIViewController, so we must + // defer the update to after the current update cycle completes. + controller.onScrollStateChanged = { [weak coordinator = context.coordinator] atBottom, unread in + Task { @MainActor in + coordinator?.setIsAtBottom?(atBottom) + coordinator?.setUnreadCount?(unread) + } } - func updateUIViewController(_ controller: ChatTableViewController, context: Context) { - // Update cell content provider each render cycle so reconfigured cells - // get fresh closures (e.g., onRetry callback when message status changes) - controller.configure { item in - cellContent(item) - } - controller.tableView.backgroundColor = contentBackground.map(UIColor.init) ?? controller.defaultTableBackgroundColor - - // Repaint visible bubbles whose render-time accent fill is not part of `MessageItem`: - // a theme-id change reconfigures all rows in place once. The gate skips the first - // pass (no previous id) so appearance does not trigger a needless reconfigure. - let themeChanged = context.coordinator.lastThemeID.map { $0 != themeID } ?? false - let appearanceChanged = context.coordinator.lastAppearanceToken.map { $0 != appearanceToken } ?? false - if themeChanged || appearanceChanged { - controller.reconfigureAllItems() - } - context.coordinator.lastThemeID = themeID - context.coordinator.lastAppearanceToken = appearanceToken - - // Store current binding setters in coordinator (updated each render cycle) - // This ensures deferred callbacks always use fresh bindings - context.coordinator.setIsAtBottom = { [self] in isAtBottom = $0 } - context.coordinator.setUnreadCount = { [self] in unreadCount = $0 } - - // Controller callback defers to next MainActor yield via coordinator. - // SwiftUI blocks binding updates during updateUIViewController, so we must - // defer the update to after the current update cycle completes. - controller.onScrollStateChanged = { [weak coordinator = context.coordinator] atBottom, unread in - Task { @MainActor in - coordinator?.setIsAtBottom?(atBottom) - coordinator?.setUnreadCount?(unread) - } - } - - // Update mention detection closures - controller.isUnseenMention = isUnseenMention - controller.unseenMentionIDs = unseenMentionIDs - controller.onMentionBecameVisible = onMentionBecameVisible - controller.onSecondaryClick = onSecondaryClick - - context.coordinator.setOffscreenMentionIDs = { [self] in offscreenMentionIDs = $0 } - controller.onOffscreenMentionsChanged = { [weak coordinator = context.coordinator] ids in - // Defer: SwiftUI forbids binding writes during updateUIViewController. - Task { @MainActor in - coordinator?.setOffscreenMentionIDs?(ids) - } - } - - // unseenMentionIDs can load after the first render, with no item change to trigger the - // recheck inside updateItems. Re-run it here so the off-screen set reflects the new ids. - if context.coordinator.lastUnseenMentionIDs != unseenMentionIDs { - context.coordinator.lastUnseenMentionIDs = unseenMentionIDs - controller.scheduleVisibleMentionsRecheck() - } - - // Update divider visibility tracking - controller.dividerItemID = dividerItemID - context.coordinator.setIsDividerVisible = { [self] in isDividerVisible = $0 } - controller.onDividerVisibilityChanged = { [weak coordinator = context.coordinator] visible in - Task { @MainActor in - coordinator?.setIsDividerVisible?(visible) - } - } - - // Update pagination state - controller.onNearTop = onNearTop - controller.isLoadingOlderMessages = isLoadingOlderMessages + // Update mention detection closures + controller.isUnseenMention = isUnseenMention + controller.unseenMentionIDs = unseenMentionIDs + controller.onMentionBecameVisible = onMentionBecameVisible + controller.onSecondaryClick = onSecondaryClick + + context.coordinator.setOffscreenMentionIDs = { [self] in offscreenMentionIDs = $0 } + controller.onOffscreenMentionsChanged = { [weak coordinator = context.coordinator] ids in + // Defer: SwiftUI forbids binding writes during updateUIViewController. + Task { @MainActor in + coordinator?.setOffscreenMentionIDs?(ids) + } + } - // Check for scroll-to-mention request - let shouldScrollToMention = scrollToMentionRequest != context.coordinator.lastMentionRequest - var shouldScrollMentionToBottom = false - var mentionScrollTargetID: Item.ID? + // unseenMentionIDs can load after the first render, with no item change to trigger the + // recheck inside updateItems. Re-run it here so the off-screen set reflects the new ids. + if context.coordinator.lastUnseenMentionIDs != unseenMentionIDs { + context.coordinator.lastUnseenMentionIDs = unseenMentionIDs + controller.scheduleVisibleMentionsRecheck() + } - if shouldScrollToMention { - context.coordinator.lastMentionRequest = scrollToMentionRequest - mentionScrollTargetID = mentionTargetID + // Update divider visibility tracking + controller.dividerItemID = dividerItemID + context.coordinator.setIsDividerVisible = { [self] in isDividerVisible = $0 } + controller.onDividerVisibilityChanged = { [weak coordinator = context.coordinator] visible in + Task { @MainActor in + coordinator?.setIsDividerVisible?(visible) + } + } - let newestItemID = items.last?.id - shouldScrollMentionToBottom = ChatScrollToMentionPolicy.shouldScrollToBottom( - mentionTargetID: mentionTargetID, - newestItemID: newestItemID - ) - } + // Update pagination state + controller.onNearTop = onNearTop + controller.isLoadingOlderMessages = isLoadingOlderMessages - // Check for scroll-to-divider request (new messages divider) - let shouldScrollToDivider = scrollToDividerRequest != context.coordinator.lastDividerScrollRequest - if shouldScrollToDivider { - context.coordinator.lastDividerScrollRequest = scrollToDividerRequest - } + // Check for scroll-to-mention request + let shouldScrollToMention = scrollToMentionRequest != context.coordinator.lastMentionRequest + var shouldScrollMentionToBottom = false + var mentionScrollTargetID: Item.ID? - // Check for scroll-to-bottom request before updating items - // This ensures user sends don't trigger unread badge - let shouldForceScroll = scrollToBottomRequest != context.coordinator.lastScrollRequest + if shouldScrollToMention { + context.coordinator.lastMentionRequest = scrollToMentionRequest + mentionScrollTargetID = mentionTargetID - if shouldForceScroll { - context.coordinator.lastScrollRequest = scrollToBottomRequest - // Mark as at bottom so updateItems won't increment unread - controller.prepareForUserSend() - } + let newestItemID = items.last?.id + shouldScrollMentionToBottom = ChatScrollToMentionPolicy.shouldScrollToBottom( + mentionTargetID: mentionTargetID, + newestItemID: newestItemID + ) + } - controller.updateItems(items) - - // Perform the scroll after items are updated - if shouldForceScroll { - controller.scrollToBottom(animated: true) - } else if shouldScrollToMention { - if shouldScrollMentionToBottom { - controller.scrollToBottom(animated: true) - } else if let targetID = mentionScrollTargetID { - controller.scrollToItem(id: targetID, animated: true) - } - } else if shouldScrollToDivider, let targetID = dividerItemID { - controller.scrollToItem(id: targetID, animated: true) - } + // Check for scroll-to-divider request (new messages divider) + let shouldScrollToDivider = scrollToDividerRequest != context.coordinator.lastDividerScrollRequest + if shouldScrollToDivider { + context.coordinator.lastDividerScrollRequest = scrollToDividerRequest } - func makeCoordinator() -> Coordinator { - Coordinator() + // Check for scroll-to-bottom request before updating items + // This ensures user sends don't trigger unread badge + let shouldForceScroll = scrollToBottomRequest != context.coordinator.lastScrollRequest + + if shouldForceScroll { + context.coordinator.lastScrollRequest = scrollToBottomRequest + // Mark as at bottom so updateItems won't increment unread + controller.prepareForUserSend() } - @MainActor - class Coordinator { - var lastScrollRequest: Int = 0 - var lastMentionRequest: Int = 0 - var lastDividerScrollRequest: Int = 0 - var lastThemeID: String? - var lastAppearanceToken: String? - var setIsAtBottom: ((Bool) -> Void)? - var setUnreadCount: ((Int) -> Void)? - var setIsDividerVisible: ((Bool) -> Void)? - var setOffscreenMentionIDs: (([Item.ID]) -> Void)? - var lastUnseenMentionIDs: [Item.ID] = [] + controller.updateItems(items) + + // Perform the scroll after items are updated + if shouldForceScroll { + controller.scrollToBottom(animated: true) + } else if shouldScrollToMention { + if shouldScrollMentionToBottom { + controller.scrollToBottom(animated: true) + } else if let targetID = mentionScrollTargetID { + controller.scrollToItem(id: targetID, animated: true) + } + } else if shouldScrollToDivider, let targetID = dividerItemID { + controller.scrollToItem(id: targetID, animated: true) } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + @MainActor + class Coordinator { + var lastScrollRequest: Int = 0 + var lastMentionRequest: Int = 0 + var lastDividerScrollRequest: Int = 0 + var lastThemeID: String? + var lastAppearanceToken: String? + var setIsAtBottom: ((Bool) -> Void)? + var setUnreadCount: ((Int) -> Void)? + var setIsDividerVisible: ((Bool) -> Void)? + var setOffscreenMentionIDs: (([Item.ID]) -> Void)? + var lastUnseenMentionIDs: [Item.ID] = [] + } } diff --git a/MC1/Views/Chats/Components/FallbackMatchIndicatorView.swift b/MC1/Views/Chats/Components/FallbackMatchIndicatorView.swift index c36eb94c..75671b51 100644 --- a/MC1/Views/Chats/Components/FallbackMatchIndicatorView.swift +++ b/MC1/Views/Chats/Components/FallbackMatchIndicatorView.swift @@ -3,50 +3,50 @@ import SwiftUI /// Tappable indicator showing a node name was resolved from a short prefix with multiple matches. struct FallbackMatchIndicatorView: View { - let accessibilityLabel: String - let accessibilityHint: String - let title: String - let explanation: String + let accessibilityLabel: String + let accessibilityHint: String + let title: String + let explanation: String - @State private var isShowingExplanation = false + @State private var isShowingExplanation = false - init( - accessibilityLabel: String = L10n.Chats.Chats.Path.Hop.possibleMatch, - accessibilityHint: String = L10n.Chats.Chats.Path.Hop.possibleMatchExplanation, - title: String = L10n.Chats.Chats.Path.Hop.possibleMatchTitle, - explanation: String = L10n.Chats.Chats.Path.Hop.possibleMatchExplanation - ) { - self.accessibilityLabel = accessibilityLabel - self.accessibilityHint = accessibilityHint - self.title = title - self.explanation = explanation - } + init( + accessibilityLabel: String = L10n.Chats.Chats.Path.Hop.possibleMatch, + accessibilityHint: String = L10n.Chats.Chats.Path.Hop.possibleMatchExplanation, + title: String = L10n.Chats.Chats.Path.Hop.possibleMatchTitle, + explanation: String = L10n.Chats.Chats.Path.Hop.possibleMatchExplanation + ) { + self.accessibilityLabel = accessibilityLabel + self.accessibilityHint = accessibilityHint + self.title = title + self.explanation = explanation + } - var body: some View { - Button { - isShowingExplanation = true - } label: { - Image(systemName: "questionmark.circle") - .imageScale(.small) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(accessibilityLabel) - .accessibilityHint(accessibilityHint) - .popover(isPresented: $isShowingExplanation) { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.headline) + var body: some View { + Button { + isShowingExplanation = true + } label: { + Image(systemName: "questionmark.circle") + .imageScale(.small) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(accessibilityHint) + .popover(isPresented: $isShowingExplanation) { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) - Text(explanation) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding() - .frame(idealWidth: 280, maxWidth: 300) - .presentationSizing(.fitted) - .presentationCompactAdaptation(.popover) - } + Text(explanation) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + .frame(idealWidth: 280, maxWidth: 300) + .presentationSizing(.fitted) + .presentationCompactAdaptation(.popover) } + } } diff --git a/MC1/Views/Chats/Components/FragmentLayout.swift b/MC1/Views/Chats/Components/FragmentLayout.swift index 80e000a4..c685a803 100644 --- a/MC1/Views/Chats/Components/FragmentLayout.swift +++ b/MC1/Views/Chats/Components/FragmentLayout.swift @@ -14,34 +14,34 @@ import MC1Services /// a plain value. It is never stored on `MessageItem` and never participates in /// any bubble view's `==`, so the item-only Equatable seam stays intact. struct FragmentLayout { - /// The single text fragment that fills the bubble box, if any. - let textPayload: MessageTextPayload? + /// The single text fragment that fills the bubble box, if any. + let textPayload: MessageTextPayload? - /// The single inline image attached to the bubble box, if any. - let inlineImage: InlineImage? + /// The single inline image attached to the bubble box, if any. + let inlineImage: InlineImage? - /// Fragments rendered below the box, in document order. Excludes the - /// box-resident text and inline image. - let siblings: [MessageFragment] + /// Fragments rendered below the box, in document order. Excludes the + /// box-resident text and inline image. + let siblings: [MessageFragment] - init(content: [MessageFragment]) { - var textPayload: MessageTextPayload? - var inlineImage: InlineImage? - var siblings: [MessageFragment] = [] + init(content: [MessageFragment]) { + var textPayload: MessageTextPayload? + var inlineImage: InlineImage? + var siblings: [MessageFragment] = [] - for fragment in content { - switch fragment { - case .text(let payload): - if textPayload == nil { textPayload = payload } - case .inlineImage(let image): - if inlineImage == nil { inlineImage = image } - case .linkPreview, .mapPreview, .malwareWarning, .reactionSummary: - siblings.append(fragment) - } - } - - self.textPayload = textPayload - self.inlineImage = inlineImage - self.siblings = siblings + for fragment in content { + switch fragment { + case let .text(payload): + if textPayload == nil { textPayload = payload } + case let .inlineImage(image): + if inlineImage == nil { inlineImage = image } + case .linkPreview, .mapPreview, .malwareWarning, .reactionSummary: + siblings.append(fragment) + } } + + self.textPayload = textPayload + self.inlineImage = inlineImage + self.siblings = siblings + } } diff --git a/MC1/Views/Chats/Components/Fragments/InlineImageFragmentView.swift b/MC1/Views/Chats/Components/Fragments/InlineImageFragmentView.swift index f66946aa..18ca4e8c 100644 --- a/MC1/Views/Chats/Components/Fragments/InlineImageFragmentView.swift +++ b/MC1/Views/Chats/Components/Fragments/InlineImageFragmentView.swift @@ -1,97 +1,96 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services /// Fragment-level view that renders the inline-image slot of a message bubble. /// Reserves correct height before bytes arrive using `InlineImage.cachedAspect` /// (falling back to 16:9), so the bubble does not jump when the image loads. struct InlineImageFragmentView: View { - let inlineImage: InlineImage - let isOutgoing: Bool - let imageResolver: (ImageReference) -> UIImage? - let onTap: () -> Void - let onRetry: () -> Void + let inlineImage: InlineImage + let isOutgoing: Bool + let imageResolver: (ImageReference) -> UIImage? + let onTap: () -> Void + let onRetry: () -> Void - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @ScaledMetric(relativeTo: .body) private var minHeight: CGFloat = RichPreviewMetrics.minHeroHeight - @ScaledMetric(relativeTo: .body) private var maxHeight: CGFloat = RichPreviewMetrics.maxHeroHeight + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ScaledMetric(relativeTo: .body) private var minHeight: CGFloat = RichPreviewMetrics.minHeroHeight + @ScaledMetric(relativeTo: .body) private var maxHeight: CGFloat = RichPreviewMetrics.maxHeroHeight - private static let crossFadeDuration: Double = 0.2 - private static let skeletonCornerRadius: CGFloat = RichPreviewMetrics.cornerRadius - private static let retryIconSpacing: CGFloat = 8 - private static let retryForegroundOpacity: Double = 0.7 + private static let crossFadeDuration: Double = 0.2 + private static let skeletonCornerRadius: CGFloat = RichPreviewMetrics.cornerRadius + private static let retryIconSpacing: CGFloat = 8 + private static let retryForegroundOpacity: Double = 0.7 - private var aspect: Double { - inlineImage.cachedAspect ?? RichPreviewMetrics.fallbackAspect - } + private var aspect: Double { + inlineImage.cachedAspect ?? RichPreviewMetrics.fallbackAspect + } - var body: some View { - // No card chrome here: the inline image is edge-to-edge in the bubble - // box, which supplies the rounding and the surface, so the reserved - // frame carries no corner clip or background of its own. - RichPreviewCard( - aspect: CGFloat(aspect), - minHeight: minHeight, - maxHeight: maxHeight - ) { - ZStack { - PreviewSkeleton() - .opacity(isLoaded ? 0 : 1) + var body: some View { + // No card chrome here: the inline image is edge-to-edge in the bubble + // box, which supplies the rounding and the surface, so the reserved + // frame carries no corner clip or background of its own. + RichPreviewCard( + aspect: CGFloat(aspect), + minHeight: minHeight, + maxHeight: maxHeight + ) { + ZStack { + PreviewSkeleton() + .opacity(isLoaded ? 0 : 1) - loadedLayer - .opacity(isLoaded ? 1 : 0) + loadedLayer + .opacity(isLoaded ? 1 : 0) - if case .failed = inlineImage.state { - retryLayer - } - } + if case .failed = inlineImage.state { + retryLayer } - .animation( - reduceMotion ? nil : .easeOut(duration: Self.crossFadeDuration), - value: isLoaded - ) + } } + .animation( + reduceMotion ? nil : .easeOut(duration: Self.crossFadeDuration), + value: isLoaded + ) + } - private var isLoaded: Bool { - if case .loaded(let ref, _) = inlineImage.state, - imageResolver(ref) != nil { - return true - } - return false + private var isLoaded: Bool { + if case let .loaded(ref, _) = inlineImage.state, + imageResolver(ref) != nil { + return true } + return false + } - @ViewBuilder - private var loadedLayer: some View { - if case .loaded(let ref, let isGIF) = inlineImage.state, - let image = imageResolver(ref) { - InlineImageView( - image: image, - isGIF: isGIF, - autoPlayGIFs: inlineImage.autoPlayGIFs, - isEmbedded: true, - onTap: onTap - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + @ViewBuilder + private var loadedLayer: some View { + if case let .loaded(ref, isGIF) = inlineImage.state, + let image = imageResolver(ref) { + InlineImageView( + image: image, + isGIF: isGIF, + autoPlayGIFs: inlineImage.autoPlayGIFs, + isEmbedded: true, + onTap: onTap + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } + } - @ViewBuilder - private var retryLayer: some View { - Button(action: onRetry) { - ZStack { - RoundedRectangle(cornerRadius: Self.skeletonCornerRadius, style: .continuous) - .fill(Color(.tertiarySystemFill)) - HStack(spacing: Self.retryIconSpacing) { - Image(systemName: "arrow.clockwise") - .foregroundStyle(isOutgoing ? .white.opacity(Self.retryForegroundOpacity) : .secondary) - Text(L10n.Chats.Chats.InlineImage.tapToRetry) - .font(.subheadline) - .foregroundStyle(isOutgoing ? .white.opacity(Self.retryForegroundOpacity) : .secondary) - } - } + private var retryLayer: some View { + Button(action: onRetry) { + ZStack { + RoundedRectangle(cornerRadius: Self.skeletonCornerRadius, style: .continuous) + .fill(Color(.tertiarySystemFill)) + HStack(spacing: Self.retryIconSpacing) { + Image(systemName: "arrow.clockwise") + .foregroundStyle(isOutgoing ? .white.opacity(Self.retryForegroundOpacity) : .secondary) + Text(L10n.Chats.Chats.InlineImage.tapToRetry) + .font(.subheadline) + .foregroundStyle(isOutgoing ? .white.opacity(Self.retryForegroundOpacity) : .secondary) } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Chats.Chats.InlineImage.failedLabel) - .accessibilityHint(L10n.Chats.Chats.InlineImage.retryHint) + } } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Chats.Chats.InlineImage.failedLabel) + .accessibilityHint(L10n.Chats.Chats.InlineImage.retryHint) + } } diff --git a/MC1/Views/Chats/Components/Fragments/LinkPreviewFragmentView.swift b/MC1/Views/Chats/Components/Fragments/LinkPreviewFragmentView.swift index cc47b2a8..5d95e3f7 100644 --- a/MC1/Views/Chats/Components/Fragments/LinkPreviewFragmentView.swift +++ b/MC1/Views/Chats/Components/Fragments/LinkPreviewFragmentView.swift @@ -1,70 +1,60 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services /// Fragment-level view that renders the link-preview slot of a message bubble. /// Driven by a `LinkPreviewFragmentState` payload plus a closure-based image /// resolver — keeps the view free of view-model lookups so it stays a pure /// function of its inputs. struct LinkPreviewFragmentView: View { - let state: LinkPreviewFragmentState - let imageResolver: (ImageReference) -> UIImage? - let onManualPreviewFetch: (() -> Void)? + let state: LinkPreviewFragmentState + let imageResolver: (ImageReference) -> UIImage? + let onManualPreviewFetch: (() -> Void)? - @Environment(\.openURL) private var openURL - - init( - state: LinkPreviewFragmentState, - imageResolver: @escaping (ImageReference) -> UIImage?, - onManualPreviewFetch: (() -> Void)? - ) { - self.state = state - self.imageResolver = imageResolver - self.onManualPreviewFetch = onManualPreviewFetch - } + @Environment(\.openURL) private var openURL - var body: some View { - switch state.mode { - case .loaded(let preview, let imageRef, let iconRef): - if let url = state.primaryURL { - let resolvedImage = imageRef.flatMap(imageResolver) - if imageRef != nil && resolvedImage == nil { - // Image bytes still downloading — reserve hero space. - LinkPreviewLoadingCard(state: state) - } else { - LinkPreviewCard( - url: url, - title: preview.title, - image: resolvedImage, - icon: iconRef.flatMap(imageResolver), - imageWidth: preview.imageWidth, - imageHeight: preview.imageHeight, - onTap: { openURL(url) } - ) - } - } - case .loading: - LinkPreviewLoadingCard(state: state) - case .disabled(let url): - TapToLoadPreview( - url: url, - isLoading: false, - onTap: { onManualPreviewFetch?() } - ) - case .legacy(_, let title, let imageRef, let iconRef): - if let url = state.primaryURL { - LinkPreviewCard( - url: url, - title: title, - image: imageRef.flatMap(imageResolver), - icon: iconRef.flatMap(imageResolver), - imageWidth: nil, - imageHeight: nil, - onTap: { openURL(url) } - ) - } - case .idle, .noPreview: - EmptyView() + var body: some View { + switch state.mode { + case let .loaded(preview, imageRef, iconRef): + if let url = state.primaryURL { + let resolvedImage = imageRef.flatMap(imageResolver) + if imageRef != nil, resolvedImage == nil { + // Image bytes still downloading — reserve hero space. + LinkPreviewLoadingCard(state: state) + } else { + LinkPreviewCard( + url: url, + title: preview.title, + image: resolvedImage, + icon: iconRef.flatMap(imageResolver), + imageWidth: preview.imageWidth, + imageHeight: preview.imageHeight, + onTap: { openURL(url) } + ) } + } + case .loading: + LinkPreviewLoadingCard(state: state) + case let .disabled(url): + TapToLoadPreview( + url: url, + isLoading: false, + onTap: { onManualPreviewFetch?() } + ) + case let .legacy(_, title, imageRef, iconRef): + if let url = state.primaryURL { + LinkPreviewCard( + url: url, + title: title, + image: imageRef.flatMap(imageResolver), + icon: iconRef.flatMap(imageResolver), + imageWidth: nil, + imageHeight: nil, + onTap: { openURL(url) } + ) + } + case .idle, .noPreview: + EmptyView() } + } } diff --git a/MC1/Views/Chats/Components/Fragments/MapPreviewFragmentView.swift b/MC1/Views/Chats/Components/Fragments/MapPreviewFragmentView.swift index 51a5f720..b5a3a3db 100644 --- a/MC1/Views/Chats/Components/Fragments/MapPreviewFragmentView.swift +++ b/MC1/Views/Chats/Components/Fragments/MapPreviewFragmentView.swift @@ -9,106 +9,106 @@ import UIKit /// failed to render, a retry control overlays the fallback so the user can /// recover without waiting for the next offline-to-online edge. struct MapPreviewFragmentView: View { - let state: MapPreviewFragmentState - let snapshotResolver: (MapSnapshotRequest) -> UIImage? - let onTap: (CLLocationCoordinate2D) -> Void - let onRequestSnapshot: (MapSnapshotRequest) -> Void - let onRetry: (MapSnapshotRequest) -> Void + let state: MapPreviewFragmentState + let snapshotResolver: (MapSnapshotRequest) -> UIImage? + let onTap: (CLLocationCoordinate2D) -> Void + let onRequestSnapshot: (MapSnapshotRequest) -> Void + let onRetry: (MapSnapshotRequest) -> Void - private static let retryControlPadding: CGFloat = 8 + private static let retryControlPadding: CGFloat = 8 - private var request: MapSnapshotRequest { - MapSnapshotRequest( - latitude: state.latitude, - longitude: state.longitude, - isDark: state.isDark, - isOffline: state.isOffline - ) - } + private var request: MapSnapshotRequest { + MapSnapshotRequest( + latitude: state.latitude, + longitude: state.longitude, + isDark: state.isDark, + isOffline: state.isOffline + ) + } - private var coordinateText: String { - state.coordinate.formattedString - } + private var coordinateText: String { + state.coordinate.formattedString + } - /// True only when the render attempt resolved as a failure and the cache - /// has no image — i.e., the `fallback` branch is on screen. The skeleton - /// state is excluded so the retry icon never flashes during the initial - /// render-in-progress window. - private var isShowingFallback: Bool { - state.isReady && snapshotResolver(request) == nil - } + /// True only when the render attempt resolved as a failure and the cache + /// has no image — i.e., the `fallback` branch is on screen. The skeleton + /// state is excluded so the retry icon never flashes during the initial + /// render-in-progress window. + private var isShowingFallback: Bool { + state.isReady && snapshotResolver(request) == nil + } - var body: some View { - ZStack(alignment: .topTrailing) { - content - .frame(width: MapSnapshotLayout.width, height: MapSnapshotLayout.height) - .clipShape(.rect(cornerRadius: MapSnapshotLayout.cornerRadius)) - .contentShape(Rectangle()) - .tapYieldingToLongPress { onTap(state.coordinate) } - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.Map.Map.Preview.accessibilityLabel) - .accessibilityValue(coordinateText) - .accessibilityHint(L10n.Map.Map.Preview.accessibilityHint) - .accessibilityAddTraits(.isButton) - .accessibilityAction { onTap(state.coordinate) } + var body: some View { + ZStack(alignment: .topTrailing) { + content + .frame(width: MapSnapshotLayout.width, height: MapSnapshotLayout.height) + .clipShape(.rect(cornerRadius: MapSnapshotLayout.cornerRadius)) + .contentShape(Rectangle()) + .tapYieldingToLongPress { onTap(state.coordinate) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Map.Map.Preview.accessibilityLabel) + .accessibilityValue(coordinateText) + .accessibilityHint(L10n.Map.Map.Preview.accessibilityHint) + .accessibilityAddTraits(.isButton) + .accessibilityAction { onTap(state.coordinate) } - if isShowingFallback { - retryButton - .padding(Self.retryControlPadding) - } - } + if isShowingFallback { + retryButton + .padding(Self.retryControlPadding) + } } + } - private var retryButton: some View { - Button { - onRetry(request) - } label: { - Image(systemName: "arrow.clockwise.circle.fill") - .font(.title2) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.primary) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Map.Map.Preview.RetryButton.accessibilityLabel) - .accessibilityAddTraits(.isButton) + private var retryButton: some View { + Button { + onRetry(request) + } label: { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.primary) } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Map.Map.Preview.RetryButton.accessibilityLabel) + .accessibilityAddTraits(.isButton) + } - @ViewBuilder - private var content: some View { - if let image = snapshotResolver(request) { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - // No cached image: the fallback once the attempt resolved (a failed - // render), otherwise the loading skeleton. Re-request in both cases — - // `request()` is a no-op for cached/failed/in-flight, so this only does - // work after a cache eviction dropped a previously rendered snapshot. - // `.onAppear` lives on each branch so a flip between skeleton and - // fallback (e.g. failure cleared by network recovery) re-fires the - // request; SwiftUI fires `.onAppear` on a `Group` only once per Group - // lifetime, not on inner-branch swaps. - if state.isReady { - fallback - .onAppear { onRequestSnapshot(request) } - } else { - skeleton - .onAppear { onRequestSnapshot(request) } - } - } + @ViewBuilder + private var content: some View { + if let image = snapshotResolver(request) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + // No cached image: the fallback once the attempt resolved (a failed + // render), otherwise the loading skeleton. Re-request in both cases — + // `request()` is a no-op for cached/failed/in-flight, so this only does + // work after a cache eviction dropped a previously rendered snapshot. + // `.onAppear` lives on each branch so a flip between skeleton and + // fallback (e.g. failure cleared by network recovery) re-fires the + // request; SwiftUI fires `.onAppear` on a `Group` only once per Group + // lifetime, not on inner-branch swaps. + if state.isReady { + fallback + .onAppear { onRequestSnapshot(request) } + } else { + skeleton + .onAppear { onRequestSnapshot(request) } + } } + } - private var skeleton: some View { - PreviewSkeleton(cornerRadius: MapSnapshotLayout.cornerRadius) - } + private var skeleton: some View { + PreviewSkeleton(cornerRadius: MapSnapshotLayout.cornerRadius) + } - private var fallback: some View { - ZStack { - Color(.secondarySystemBackground) - Image(systemName: "mappin.circle.fill") - .font(.largeTitle) - .foregroundStyle(.secondary) - } - .accessibilityHidden(true) + private var fallback: some View { + ZStack { + Color(.secondarySystemBackground) + Image(systemName: "mappin.circle.fill") + .font(.largeTitle) + .foregroundStyle(.secondary) } + .accessibilityHidden(true) + } } diff --git a/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift b/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift index b041f0df..48ff6444 100644 --- a/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift +++ b/MC1/Views/Chats/Components/Fragments/MessageBodyTextView.swift @@ -17,274 +17,274 @@ private let logger = Logger(subsystem: "com.mc1", category: "MessageBodyTextView /// actor with the live trait collection, rather than storing a non-`Sendable` `NSAttributedString` /// on the payload. struct MessageBodyTextView: UIViewRepresentable { - /// The single authored representation. The UIKit string is derived from this, never authored - /// in parallel, so every link kind's color/underline/bold/link has one source of truth. - let attributedString: AttributedString - - /// The base text color slot resolved by the bubble. Incoming bodies use `.primary` (mapped to - /// `UIColor.label` at bridge time); outgoing bodies use the filled-bubble text color, which - /// already bridges to a stable `UIColor`. - let baseColor: Color - - /// Dynamic Type token threaded from the SwiftUI environment so a category change invalidates - /// the Coordinator size cache. `updateUIView` runs on any environment change, so a reflow of - /// already-visible bubbles is driven from here in concert with the table's reconfigure. - let contentSizeCategoryToken: String - - @Environment(\.openURL) private var openURL - - func makeCoordinator() -> Coordinator { - Coordinator() + /// The single authored representation. The UIKit string is derived from this, never authored + /// in parallel, so every link kind's color/underline/bold/link has one source of truth. + let attributedString: AttributedString + + /// The base text color slot resolved by the bubble. Incoming bodies use `.primary` (mapped to + /// `UIColor.label` at bridge time); outgoing bodies use the filled-bubble text color, which + /// already bridges to a stable `UIColor`. + let baseColor: Color + + /// Dynamic Type token threaded from the SwiftUI environment so a category change invalidates + /// the Coordinator size cache. `updateUIView` runs on any environment change, so a reflow of + /// already-visible bubbles is driven from here in concert with the table's reconfigure. + let contentSizeCategoryToken: String + + @Environment(\.openURL) private var openURL + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> BubbleBodyTextView { + let textView = BubbleBodyTextView(usingTextLayoutManager: false) + textView.configureForBubble() + + textView.isEditable = false + textView.isScrollEnabled = false + textView.adjustsFontForContentSizeCategory = true + + // Non-selectable: a passive renderer installs no link, selection, or edit interactions, so + // a long-press falls through to the bubble's gesture and a secondary click falls through to + // the table's context-menu interaction. Link taps are detected by the recognizer below. + textView.isSelectable = false + textView.isUserInteractionEnabled = true + + // A long-press must not become a drag or a text loupe; both would steal the gesture the + // bubble needs. Dropping the drop interaction prevents a long-press-into-drop hijack too. + textView.textDragInteraction?.isEnabled = false + if let dropInteraction = textView.textDropInteraction { + textView.removeInteraction(dropInteraction) } - func makeUIView(context: Context) -> BubbleBodyTextView { - let textView = BubbleBodyTextView(usingTextLayoutManager: false) - textView.configureForBubble() - - textView.isEditable = false - textView.isScrollEnabled = false - textView.adjustsFontForContentSizeCategory = true - - // Non-selectable: a passive renderer installs no link, selection, or edit interactions, so - // a long-press falls through to the bubble's gesture and a secondary click falls through to - // the table's context-menu interaction. Link taps are detected by the recognizer below. - textView.isSelectable = false - textView.isUserInteractionEnabled = true - - // A long-press must not become a drag or a text loupe; both would steal the gesture the - // bubble needs. Dropping the drop interaction prevents a long-press-into-drop hijack too. - textView.textDragInteraction?.isEnabled = false - if let dropInteraction = textView.textDropInteraction { - textView.removeInteraction(dropInteraction) - } - - textView.backgroundColor = .clear - textView.textContainerInset = .zero - textView.textContainer.lineFragmentPadding = 0 - textView.contentInsetAdjustmentBehavior = .never - - // Hug content in both axes so the hosting cell self-sizes from the measured text. - textView.setContentHuggingPriority(.required, for: .vertical) - textView.setContentCompressionResistancePriority(.required, for: .vertical) - textView.setContentHuggingPriority(.defaultLow, for: .horizontal) - - // Single tap routes a link through the injected OpenURLAction; a tap that hits no link is - // not consumed (cancelsTouchesInView = false), so the bubble's tap and long-press still fire. - let tapRecognizer = UITapGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleTap(_:)) - ) - tapRecognizer.cancelsTouchesInView = false - // Off Mac, the text view is the recognizer's delegate so the bubble's long-press wins a - // contested press (see `BubbleBodyTextView`). On Mac the secondary click routes through the - // table's context-menu interaction, which this delegate must not disturb. - if !ProcessInfo.processInfo.isiOSAppOnMac { - tapRecognizer.delegate = textView - } - textView.addGestureRecognizer(tapRecognizer) - - apply(to: textView, context: context) - return textView + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.contentInsetAdjustmentBehavior = .never + + // Hug content in both axes so the hosting cell self-sizes from the measured text. + textView.setContentHuggingPriority(.required, for: .vertical) + textView.setContentCompressionResistancePriority(.required, for: .vertical) + textView.setContentHuggingPriority(.defaultLow, for: .horizontal) + + // Single tap routes a link through the injected OpenURLAction; a tap that hits no link is + // not consumed (cancelsTouchesInView = false), so the bubble's tap and long-press still fire. + let tapRecognizer = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(_:)) + ) + tapRecognizer.cancelsTouchesInView = false + // Off Mac, the text view is the recognizer's delegate so the bubble's long-press wins a + // contested press (see `BubbleBodyTextView`). On Mac the secondary click routes through the + // table's context-menu interaction, which this delegate must not disturb. + if !ProcessInfo.processInfo.isiOSAppOnMac { + tapRecognizer.delegate = textView } - - func updateUIView(_ textView: BubbleBodyTextView, context: Context) { - apply(to: textView, context: context) + textView.addGestureRecognizer(tapRecognizer) + + apply(to: textView, context: context) + return textView + } + + func updateUIView(_ textView: BubbleBodyTextView, context: Context) { + apply(to: textView, context: context) + } + + /// Bridges the SwiftUI string to UIKit against the live trait collection, refreshes the live + /// `OpenURLAction` on the Coordinator (so a recycled cell picks up the current action), and + /// invalidates the size cache when the bridged content or Dynamic Type token changes. + private func apply(to textView: BubbleBodyTextView, context: Context) { + let coordinator = context.coordinator + coordinator.openURL = openURL + + let bridged = Self.makeAttributedText( + from: attributedString, + baseColor: baseColor, + traitCollection: textView.traitCollection + ) + + let contentHash = Self.contentHash( + attributedString: attributedString, + contentSizeCategoryToken: contentSizeCategoryToken + ) + if coordinator.contentHash != contentHash { + coordinator.contentHash = contentHash + coordinator.invalidateSizeCache() } - /// Bridges the SwiftUI string to UIKit against the live trait collection, refreshes the live - /// `OpenURLAction` on the Coordinator (so a recycled cell picks up the current action), and - /// invalidates the size cache when the bridged content or Dynamic Type token changes. - private func apply(to textView: BubbleBodyTextView, context: Context) { - let coordinator = context.coordinator - coordinator.openURL = openURL - - let bridged = Self.makeAttributedText( - from: attributedString, - baseColor: baseColor, - traitCollection: textView.traitCollection - ) - - let contentHash = Self.contentHash( - attributedString: attributedString, - contentSizeCategoryToken: contentSizeCategoryToken - ) - if coordinator.contentHash != contentHash { - coordinator.contentHash = contentHash - coordinator.invalidateSizeCache() - } - - textView.attributedText = bridged + textView.attributedText = bridged + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: BubbleBodyTextView, + context: Context + ) -> CGSize? { + let width = proposal.width ?? UIView.layoutFittingCompressedSize.width + guard width.isFinite, width > 0 else { return nil } + + let coordinator = context.coordinator + let key = SizeCacheKey(width: Double(width), contentHash: coordinator.contentHash) + if let cached = coordinator.computedSizes[key] { + return cached } - func sizeThatFits( - _ proposal: ProposedViewSize, - uiView: BubbleBodyTextView, - context: Context - ) -> CGSize? { - let width = proposal.width ?? UIView.layoutFittingCompressedSize.width - guard width.isFinite, width > 0 else { return nil } - - let coordinator = context.coordinator - let key = SizeCacheKey(width: Double(width), contentHash: coordinator.contentHash) - if let cached = coordinator.computedSizes[key] { - return cached - } - - let fitting = uiView.sizeThatFits( - CGSize(width: width, height: UIView.layoutFittingCompressedSize.height) - ) - let resolved = CGSize(width: min(fitting.width, width), height: fitting.height) - - // A size-cache miss is the highest-attention surface: a stale height here would surface as a - // diffable reflow glitch, so each miss is logged for diagnosis. - logger.debug( - "size cache miss width=\(width, format: .fixed(precision: 1)) hash=\(coordinator.contentHash) measured=\(resolved.height, format: .fixed(precision: 1))" + let fitting = uiView.sizeThatFits( + CGSize(width: width, height: UIView.layoutFittingCompressedSize.height) + ) + let resolved = CGSize(width: min(fitting.width, width), height: fitting.height) + + // A size-cache miss is the highest-attention surface: a stale height here would surface as a + // diffable reflow glitch, so each miss is logged for diagnosis. + logger.debug( + "size cache miss width=\(width, format: .fixed(precision: 1)) hash=\(coordinator.contentHash) measured=\(resolved.height, format: .fixed(precision: 1))" + ) + + // Writing the cache synchronously inside sizeThatFits would mutate Coordinator state during + // layout, which is the surface this project's diffable scroll crashes are tied to. Defer it, + // and store the entry only if the content discriminator still matches: if the bridged + // content or Dynamic Type token changed between this miss and the deferred block, the key is + // dead and writing it back would leak a stale entry. + DispatchQueue.main.async { + guard coordinator.contentHash == key.contentHash else { return } + coordinator.computedSizes[key] = resolved + } + return resolved + } + + // MARK: - Render-time bridging + + /// Base text style for the whole string. Dynamic Type comes for free because the run carries + /// the text style and `adjustsFontForContentSizeCategory` is on. + private static let baseFont = UIFont.preferredFont(forTextStyle: .body) + + /// Derives the UIKit `NSAttributedString` from the SwiftUI string for the given trait + /// collection. Pure and trait-driven so the bridging test can exercise it off-screen in both + /// a light and a dark/high-contrast collection. The `.link` runs carry across from the default + /// bridge; the font, the hashtag bold trait, the foreground color, and the underline are + /// overlaid because they either do not survive the default bridge into the UIKit attribute set + /// (the SwiftUI underline lands under a SwiftUI-scope key, not `NSAttributedString.Key`) or do + /// not resolve to a stable `UIColor` (`.primary`): + /// 1. Overlay the base font across the whole string (the SwiftUI string carries no font). + /// 2. Resolve hashtag bold from `inlinePresentationIntent == .stronglyEmphasized`. + /// 3. Resolve every foreground color against the live trait collection so `.primary` and any + /// gamut-derived mention/hashtag/contact color render correctly in dark/high-contrast + /// rather than falling through to grey. The `.primary` slot maps to `UIColor.label`. + /// 4. Re-apply the underline as the UIKit `.underlineStyle` for runs the SwiftUI string + /// underlined, since SwiftUI's underline does not bridge into the UIKit key. + /// 5. Re-apply the self-mention `.backgroundColor` for runs that carry one, resolved against + /// the live trait collection. SwiftUI's `.backgroundColor` is a SwiftUI-scope attribute that + /// does not survive the default bridge into the UIKit key, so the highlight is lost without it. + static func makeAttributedText( + from attributedString: AttributedString, + baseColor: Color, + traitCollection: UITraitCollection + ) -> NSAttributedString { + let result = NSMutableAttributedString(attributedString) + let fullRange = NSRange(location: 0, length: result.length) + + result.addAttribute(.font, value: baseFont, range: fullRange) + + for run in attributedString.runs { + let nsRange = NSRange(run.range, in: attributedString) + guard nsRange.length > 0 else { continue } + + let foreground = resolvedColor(run.foregroundColor ?? baseColor, traitCollection: traitCollection) + result.addAttribute(.foregroundColor, value: foreground, range: nsRange) + + if run.inlinePresentationIntent?.contains(.stronglyEmphasized) == true { + result.addAttribute(.font, value: boldFont(from: baseFont), range: nsRange) + } + + if run.underlineStyle != nil { + result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: nsRange) + } + + if let background = run.backgroundColor { + result.addAttribute( + .backgroundColor, + value: resolvedColor(background, traitCollection: traitCollection), + range: nsRange ) - - // Writing the cache synchronously inside sizeThatFits would mutate Coordinator state during - // layout, which is the surface this project's diffable scroll crashes are tied to. Defer it, - // and store the entry only if the content discriminator still matches: if the bridged - // content or Dynamic Type token changed between this miss and the deferred block, the key is - // dead and writing it back would leak a stale entry. - DispatchQueue.main.async { - guard coordinator.contentHash == key.contentHash else { return } - coordinator.computedSizes[key] = resolved - } - return resolved + } } - // MARK: - Render-time bridging - - /// Base text style for the whole string. Dynamic Type comes for free because the run carries - /// the text style and `adjustsFontForContentSizeCategory` is on. - private static let baseFont = UIFont.preferredFont(forTextStyle: .body) - - /// Derives the UIKit `NSAttributedString` from the SwiftUI string for the given trait - /// collection. Pure and trait-driven so the bridging test can exercise it off-screen in both - /// a light and a dark/high-contrast collection. The `.link` runs carry across from the default - /// bridge; the font, the hashtag bold trait, the foreground color, and the underline are - /// overlaid because they either do not survive the default bridge into the UIKit attribute set - /// (the SwiftUI underline lands under a SwiftUI-scope key, not `NSAttributedString.Key`) or do - /// not resolve to a stable `UIColor` (`.primary`): - /// 1. Overlay the base font across the whole string (the SwiftUI string carries no font). - /// 2. Resolve hashtag bold from `inlinePresentationIntent == .stronglyEmphasized`. - /// 3. Resolve every foreground color against the live trait collection so `.primary` and any - /// gamut-derived mention/hashtag/contact color render correctly in dark/high-contrast - /// rather than falling through to grey. The `.primary` slot maps to `UIColor.label`. - /// 4. Re-apply the underline as the UIKit `.underlineStyle` for runs the SwiftUI string - /// underlined, since SwiftUI's underline does not bridge into the UIKit key. - /// 5. Re-apply the self-mention `.backgroundColor` for runs that carry one, resolved against - /// the live trait collection. SwiftUI's `.backgroundColor` is a SwiftUI-scope attribute that - /// does not survive the default bridge into the UIKit key, so the highlight is lost without it. - static func makeAttributedText( - from attributedString: AttributedString, - baseColor: Color, - traitCollection: UITraitCollection - ) -> NSAttributedString { - let result = NSMutableAttributedString(attributedString) - let fullRange = NSRange(location: 0, length: result.length) - - result.addAttribute(.font, value: baseFont, range: fullRange) - - for run in attributedString.runs { - let nsRange = NSRange(run.range, in: attributedString) - guard nsRange.length > 0 else { continue } - - let foreground = resolvedColor(run.foregroundColor ?? baseColor, traitCollection: traitCollection) - result.addAttribute(.foregroundColor, value: foreground, range: nsRange) - - if run.inlinePresentationIntent?.contains(.stronglyEmphasized) == true { - result.addAttribute(.font, value: boldFont(from: baseFont), range: nsRange) - } - - if run.underlineStyle != nil { - result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: nsRange) - } - - if let background = run.backgroundColor { - result.addAttribute( - .backgroundColor, - value: resolvedColor(background, traitCollection: traitCollection), - range: nsRange - ) - } - } - - return result - } + return result + } - /// Resolves a SwiftUI `Color` to a `UIColor` for the live trait collection. SwiftUI's `.primary` - /// does not bridge to a stable `UIColor`, so it is mapped to `UIColor.label` (matching - /// `BaseColorSlot.swiftUIColor`'s `.primary`); every other color resolves directly. - private static func resolvedColor(_ color: Color, traitCollection: UITraitCollection) -> UIColor { - if color == .primary { - return UIColor.label.resolvedColor(with: traitCollection) - } - return UIColor(color).resolvedColor(with: traitCollection) + /// Resolves a SwiftUI `Color` to a `UIColor` for the live trait collection. SwiftUI's `.primary` + /// does not bridge to a stable `UIColor`, so it is mapped to `UIColor.label` (matching + /// `BaseColorSlot.swiftUIColor`'s `.primary`); every other color resolves directly. + private static func resolvedColor(_ color: Color, traitCollection: UITraitCollection) -> UIColor { + if color == .primary { + return UIColor.label.resolvedColor(with: traitCollection) } - - /// Body text style metrics shared so the bold (hashtag) variant scales with Dynamic Type in - /// lockstep with the surrounding `.body` runs, rather than freezing at a single point size. - private static let bodyMetrics = UIFontMetrics(forTextStyle: .body) - - /// Adds the bold symbolic trait while preserving `.body` Dynamic Type scaling. Building the bold - /// font at a fixed `pointSize` would strip the text-style metadata, so a hashtag run would not - /// rescale with `adjustsFontForContentSizeCategory` while its neighbours do. Wrapping the bold - /// descriptor in `UIFontMetrics(forTextStyle: .body)` keeps bold and body runs scaling together. - private static func boldFont(from font: UIFont) -> UIFont { - guard let descriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) else { - return font - } - return bodyMetrics.scaledFont(for: UIFont(descriptor: descriptor, size: 0)) + return UIColor(color).resolvedColor(with: traitCollection) + } + + /// Body text style metrics shared so the bold (hashtag) variant scales with Dynamic Type in + /// lockstep with the surrounding `.body` runs, rather than freezing at a single point size. + private static let bodyMetrics = UIFontMetrics(forTextStyle: .body) + + /// Adds the bold symbolic trait while preserving `.body` Dynamic Type scaling. Building the bold + /// font at a fixed `pointSize` would strip the text-style metadata, so a hashtag run would not + /// rescale with `adjustsFontForContentSizeCategory` while its neighbours do. Wrapping the bold + /// descriptor in `UIFontMetrics(forTextStyle: .body)` keeps bold and body runs scaling together. + private static func boldFont(from font: UIFont) -> UIFont { + guard let descriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) else { + return font } - - /// Combines the SwiftUI string's hash with the Dynamic Type token. The SwiftUI string already - /// folds in every contracted attribute (link, color, underline, bold), so its hash plus the - /// category token fully discriminates the bridged output for the size cache. - private static func contentHash(attributedString: AttributedString, contentSizeCategoryToken: String) -> Int { - var hasher = Hasher() - hasher.combine(attributedString) - hasher.combine(contentSizeCategoryToken) - return hasher.finalize() + return bodyMetrics.scaledFont(for: UIFont(descriptor: descriptor, size: 0)) + } + + /// Combines the SwiftUI string's hash with the Dynamic Type token. The SwiftUI string already + /// folds in every contracted attribute (link, color, underline, bold), so its hash plus the + /// category token fully discriminates the bridged output for the size cache. + private static func contentHash(attributedString: AttributedString, contentSizeCategoryToken: String) -> Int { + var hasher = Hasher() + hasher.combine(attributedString) + hasher.combine(contentSizeCategoryToken) + return hasher.finalize() + } + + // MARK: - Coordinator + + @MainActor + final class Coordinator: NSObject { + /// `(width, contentHash)` size cache, held here rather than in `@State` to keep the cache + /// write off the SwiftUI layout path that this project's diffable crashes are tied to. + var computedSizes: [SizeCacheKey: CGSize] = [:] + + /// Discriminator for the current bridged content plus Dynamic Type token; a change clears + /// the size cache so a stale height cannot strand a reflowed bubble. + var contentHash: Int = 0 + + /// The live `OpenURLAction`, refreshed every `updateUIView` so a recycled cell taps through + /// the current action (the one `MentionTapHandler` installs with its suppression gate). + var openURL: OpenURLAction? + + func invalidateSizeCache() { + computedSizes.removeAll(keepingCapacity: true) } - // MARK: - Coordinator - - @MainActor - final class Coordinator: NSObject { - /// `(width, contentHash)` size cache, held here rather than in `@State` to keep the cache - /// write off the SwiftUI layout path that this project's diffable crashes are tied to. - var computedSizes: [SizeCacheKey: CGSize] = [:] - - /// Discriminator for the current bridged content plus Dynamic Type token; a change clears - /// the size cache so a stale height cannot strand a reflowed bubble. - var contentHash: Int = 0 - - /// The live `OpenURLAction`, refreshed every `updateUIView` so a recycled cell taps through - /// the current action (the one `MentionTapHandler` installs with its suppression gate). - var openURL: OpenURLAction? - - func invalidateSizeCache() { - computedSizes.removeAll(keepingCapacity: true) - } - - /// Routes a tap on a linked glyph through the single injected `OpenURLAction`, so the - /// suppression gate and mention/contact/channel/map routing stay unchanged. A tap that hits - /// no link does nothing and is not consumed, so the bubble's own gestures still fire. - @objc func handleTap(_ recognizer: UITapGestureRecognizer) { - guard let textView = recognizer.view as? BubbleBodyTextView, - let url = textView.linkURL(at: recognizer.location(in: textView)) else { return } - openURL?(url) - } + /// Routes a tap on a linked glyph through the single injected `OpenURLAction`, so the + /// suppression gate and mention/contact/channel/map routing stay unchanged. A tap that hits + /// no link does nothing and is not consumed, so the bubble's own gestures still fire. + @objc func handleTap(_ recognizer: UITapGestureRecognizer) { + guard let textView = recognizer.view as? BubbleBodyTextView, + let url = textView.linkURL(at: recognizer.location(in: textView)) else { return } + openURL?(url) } + } } /// `(width, contentHash)` key for the body text view's size cache. `contentHash` discriminates the /// bridged string plus the Dynamic Type token, so a width reuse across two different bodies, or the /// same body at a new Dynamic Type size, cannot return a stale height. struct SizeCacheKey: Hashable { - let width: Double - let contentHash: Int + let width: Double + let contentHash: Int } /// Passive `UITextView` subclass for a message body. It empties `linkTextAttributes` so `.link` @@ -295,45 +295,45 @@ struct SizeCacheKey: Hashable { /// long-press, so a sustained press opens the actions sheet instead of firing a link tap, while the /// table's pan recognizer is left untouched. final class BubbleBodyTextView: UITextView, UIGestureRecognizerDelegate { - /// Empties `linkTextAttributes` so `.link` runs render in their authored `.foregroundColor` - /// rather than the view's `tintColor`. Applied from `makeUIView`: `init(usingTextLayoutManager:)` - /// does not funnel through a `UITextView` designated-init override, so the policy is set here. - func configureForBubble() { - linkTextAttributes = [:] - } - - /// The link URL at a point in the view, or nil if the point is not on a linked glyph. Resolves - /// the glyph under the point with TextKit 1 and rejects the nearest-glyph over-hit that - /// `glyphIndex(for:in:)` returns for a tap past end-of-line, in trailing whitespace, or below - /// the last line, by requiring the point to lie inside that glyph's bounding rect. - func linkURL(at point: CGPoint) -> URL? { - guard textStorage.length > 0 else { return nil } - - let containerPoint = CGPoint( - x: point.x - textContainerInset.left, - y: point.y - textContainerInset.top - ) - let glyphIndex = layoutManager.glyphIndex(for: containerPoint, in: textContainer) - let glyphRect = layoutManager.boundingRect( - forGlyphRange: NSRange(location: glyphIndex, length: 1), - in: textContainer - ) - guard glyphRect.contains(containerPoint) else { return nil } - - let charIndex = layoutManager.characterIndexForGlyph(at: glyphIndex) - guard charIndex < textStorage.length else { return nil } - - switch textStorage.attribute(.link, at: charIndex, effectiveRange: nil) { - case let url as URL: return url - case let string as String: return URL(string: string) - default: return nil - } - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - !(otherGestureRecognizer is UILongPressGestureRecognizer) + /// Empties `linkTextAttributes` so `.link` runs render in their authored `.foregroundColor` + /// rather than the view's `tintColor`. Applied from `makeUIView`: `init(usingTextLayoutManager:)` + /// does not funnel through a `UITextView` designated-init override, so the policy is set here. + func configureForBubble() { + linkTextAttributes = [:] + } + + /// The link URL at a point in the view, or nil if the point is not on a linked glyph. Resolves + /// the glyph under the point with TextKit 1 and rejects the nearest-glyph over-hit that + /// `glyphIndex(for:in:)` returns for a tap past end-of-line, in trailing whitespace, or below + /// the last line, by requiring the point to lie inside that glyph's bounding rect. + func linkURL(at point: CGPoint) -> URL? { + guard textStorage.length > 0 else { return nil } + + let containerPoint = CGPoint( + x: point.x - textContainerInset.left, + y: point.y - textContainerInset.top + ) + let glyphIndex = layoutManager.glyphIndex(for: containerPoint, in: textContainer) + let glyphRect = layoutManager.boundingRect( + forGlyphRange: NSRange(location: glyphIndex, length: 1), + in: textContainer + ) + guard glyphRect.contains(containerPoint) else { return nil } + + let charIndex = layoutManager.characterIndexForGlyph(at: glyphIndex) + guard charIndex < textStorage.length else { return nil } + + switch textStorage.attribute(.link, at: charIndex, effectiveRange: nil) { + case let url as URL: return url + case let string as String: return URL(string: string) + default: return nil } + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + !(otherGestureRecognizer is UILongPressGestureRecognizer) + } } diff --git a/MC1/Views/Chats/Components/Fragments/MessageTextView.swift b/MC1/Views/Chats/Components/Fragments/MessageTextView.swift index 67254478..00fccc3e 100644 --- a/MC1/Views/Chats/Components/Fragments/MessageTextView.swift +++ b/MC1/Views/Chats/Components/Fragments/MessageTextView.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Fragment-level wrapper over the body text renderer, driven by a typed `MessageTextPayload` /// data carrier. Renders through `MessageBodyTextView` (a `UITextView`-backed representable) so @@ -7,32 +7,32 @@ import MC1Services /// `Text(AttributedString)` path it replaced installed its own long-press link menu that /// out-prioritized the bubble's gesture. struct MessageTextView: View { - let text: MessageTextPayload + let text: MessageTextPayload - // Reflow of already-visible bubbles on a Dynamic Type change is driven primarily by the - // AppearanceToken reconfigure re-host; this per-view token is a secondary discriminator that - // guards the in-Coordinator size cache so a recycled view cannot reuse a height from another size. - @Environment(\.dynamicTypeSize) private var dynamicTypeSize + /// Reflow of already-visible bubbles on a Dynamic Type change is driven primarily by the + /// AppearanceToken reconfigure re-host; this per-view token is a secondary discriminator that + /// guards the in-Coordinator size cache so a recycled view cannot reuse a height from another size. + @Environment(\.dynamicTypeSize) private var dynamicTypeSize - var body: some View { - MessageBodyTextView( - attributedString: text.formatted ?? AttributedString(text.raw), - baseColor: text.baseColor.swiftUIColor, - contentSizeCategoryToken: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) - ) - } + var body: some View { + MessageBodyTextView( + attributedString: text.formatted ?? AttributedString(text.raw), + baseColor: text.baseColor.swiftUIColor, + contentSizeCategoryToken: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) + ) + } } extension BaseColorSlot { - /// Resolves the direction-tagged slot to a concrete SwiftUI `Color` at - /// render time. Outgoing bubbles render on a filled background and need - /// white text; incoming bubbles use the system primary colour. Lives in - /// the MC1 layer because MC1Services intentionally has no SwiftUI - /// dependency. - var swiftUIColor: Color { - switch self { - case .outgoing: .white - case .incoming: .primary - } + /// Resolves the direction-tagged slot to a concrete SwiftUI `Color` at + /// render time. Outgoing bubbles render on a filled background and need + /// white text; incoming bubbles use the system primary colour. Lives in + /// the MC1 layer because MC1Services intentionally has no SwiftUI + /// dependency. + var swiftUIColor: Color { + switch self { + case .outgoing: .white + case .incoming: .primary } + } } diff --git a/MC1/Views/Chats/Components/Fragments/ReactionsFragmentView.swift b/MC1/Views/Chats/Components/Fragments/ReactionsFragmentView.swift index 17643e92..6e42fd56 100644 --- a/MC1/Views/Chats/Components/Fragments/ReactionsFragmentView.swift +++ b/MC1/Views/Chats/Components/Fragments/ReactionsFragmentView.swift @@ -1,25 +1,25 @@ -import SwiftUI import MC1Services +import SwiftUI /// Fragment-level view that renders the reactions slot of a message bubble. /// Wraps `ReactionBadgesView` with the offset and bottom padding the bubble /// applies inline so the call site stays a single typed input. struct ReactionsFragmentView: View { - /// Raw summary string (`"👍:3,❤️:2"`). Non-optional because the - /// fragment is only emitted for non-nil non-empty values. - /// `ReactionBadgesView` takes `summary: String?`; Swift auto-wraps the - /// non-optional into `Optional` at the call site. - let summary: String - let onTapReaction: (String) -> Void - let onLongPress: () -> Void + /// Raw summary string (`"👍:3,❤️:2"`). Non-optional because the + /// fragment is only emitted for non-nil non-empty values. + /// `ReactionBadgesView` takes `summary: String?`; Swift auto-wraps the + /// non-optional into `Optional` at the call site. + let summary: String + let onTapReaction: (String) -> Void + let onLongPress: () -> Void - var body: some View { - ReactionBadgesView( - summary: summary, - onTapReaction: onTapReaction, - onLongPress: onLongPress - ) - .offset(y: -6) - .padding(.bottom, -6) - } + var body: some View { + ReactionBadgesView( + summary: summary, + onTapReaction: onTapReaction, + onLongPress: onLongPress + ) + .offset(y: -6) + .padding(.bottom, -6) + } } diff --git a/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift b/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift index d99c653b..c40ca19a 100644 --- a/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift +++ b/MC1/Views/Chats/Components/Fragments/TapYieldingToLongPress.swift @@ -12,68 +12,68 @@ import UIKit /// delegate is set only off Mac; on Mac the secondary click routes through the table's context-menu /// interaction, which this delegate must not disturb. struct TapYieldingToLongPress: UIViewRepresentable { - let onTap: () -> Void + let onTap: () -> Void - func makeCoordinator() -> Coordinator { - Coordinator(onTap: onTap) - } + func makeCoordinator() -> Coordinator { + Coordinator(onTap: onTap) + } - func makeUIView(context: Context) -> UIView { - let view = UIView() - view.backgroundColor = .clear - view.addGestureRecognizer( - Self.makeRecognizer( - coordinator: context.coordinator, - isMac: ProcessInfo.processInfo.isiOSAppOnMac - ) - ) - return view - } + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + view.addGestureRecognizer( + Self.makeRecognizer( + coordinator: context.coordinator, + isMac: ProcessInfo.processInfo.isiOSAppOnMac + ) + ) + return view + } - func updateUIView(_ uiView: UIView, context: Context) { - context.coordinator.onTap = onTap - } + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.onTap = onTap + } - /// Builds the tap recognizer with the yielding policy. Pure and `isMac`-parameterized so the - /// `cancelsTouchesInView` flag and the off-Mac delegate gate can be exercised in a unit test. - static func makeRecognizer(coordinator: Coordinator, isMac: Bool) -> UITapGestureRecognizer { - let recognizer = UITapGestureRecognizer( - target: coordinator, - action: #selector(Coordinator.handleTap) - ) - recognizer.cancelsTouchesInView = false - if !isMac { - recognizer.delegate = coordinator - } - return recognizer + /// Builds the tap recognizer with the yielding policy. Pure and `isMac`-parameterized so the + /// `cancelsTouchesInView` flag and the off-Mac delegate gate can be exercised in a unit test. + static func makeRecognizer(coordinator: Coordinator, isMac: Bool) -> UITapGestureRecognizer { + let recognizer = UITapGestureRecognizer( + target: coordinator, + action: #selector(Coordinator.handleTap) + ) + recognizer.cancelsTouchesInView = false + if !isMac { + recognizer.delegate = coordinator } + return recognizer + } - @MainActor - final class Coordinator: NSObject, UIGestureRecognizerDelegate { - var onTap: () -> Void + @MainActor + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var onTap: () -> Void - init(onTap: @escaping () -> Void) { - self.onTap = onTap - } + init(onTap: @escaping () -> Void) { + self.onTap = onTap + } - @objc func handleTap() { - onTap() - } + @objc func handleTap() { + onTap() + } - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - !(otherGestureRecognizer is UILongPressGestureRecognizer) - } + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + !(otherGestureRecognizer is UILongPressGestureRecognizer) } + } } extension View { - /// Routes a quick tap to `perform` while yielding a sustained press to the bubble's long-press. - /// Use on interactive bubble fragments instead of a `Button`, whose press gesture would cancel - /// the bubble's `.onLongPressGesture` before the actions sheet can open. - func tapYieldingToLongPress(perform: @escaping () -> Void) -> some View { - overlay { TapYieldingToLongPress(onTap: perform) } - } + /// Routes a quick tap to `perform` while yielding a sustained press to the bubble's long-press. + /// Use on interactive bubble fragments instead of a `Button`, whose press gesture would cancel + /// the bubble's `.onLongPressGesture` before the actions sheet can open. + func tapYieldingToLongPress(perform: @escaping () -> Void) -> some View { + overlay { TapYieldingToLongPress(onTap: perform) } + } } diff --git a/MC1/Views/Chats/Components/FullScreenImageViewer.swift b/MC1/Views/Chats/Components/FullScreenImageViewer.swift index 5b342f29..431a8a52 100644 --- a/MC1/Views/Chats/Components/FullScreenImageViewer.swift +++ b/MC1/Views/Chats/Components/FullScreenImageViewer.swift @@ -4,240 +4,240 @@ import UniformTypeIdentifiers /// Data needed to present the full-screen image viewer struct ImageViewerData: Identifiable { - let id = UUID() - let imageData: Data - let isGIF: Bool + let id = UUID() + let imageData: Data + let isGIF: Bool } /// Full-screen image viewer with pinch-to-zoom, pan, and share struct FullScreenImageViewer: View { - let data: ImageViewerData - @Environment(\.dismiss) private var dismiss - @State private var dragOffset: CGFloat = 0 - - private var image: UIImage? { - UIImage(data: data.imageData) - } - - private var isDragging: Bool { - dragOffset != 0 - } - - private var backgroundOpacity: Double { - let progress = min(abs(dragOffset) / 300, 1) - return 1 - progress * 0.5 - } - - var body: some View { - NavigationStack { - ZStack { - Color.black.opacity(backgroundOpacity).ignoresSafeArea() - - if let image { - ZoomableImageView( - image: image, - onDragChanged: { offset in - dragOffset = offset - }, - onDragEnded: { offset, velocity in - if abs(offset) > 150 || abs(velocity) > 1000 { - dismiss() - } else { - withAnimation(.spring()) { - dragOffset = 0 - } - } - } - ) - .offset(y: dragOffset) - .ignoresSafeArea() - } - } - .toolbar(isDragging ? .hidden : .visible, for: .navigationBar) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.ImageViewer.close) { - dismiss() - } - .tint(.white) - } - ToolbarItem(placement: .primaryAction) { - ShareLink(item: ShareableImage(data: data.imageData), - preview: .init(L10n.Chats.Chats.ImageViewer.share)) - .tint(.white) + let data: ImageViewerData + @Environment(\.dismiss) private var dismiss + @State private var dragOffset: CGFloat = 0 + + private var image: UIImage? { + UIImage(data: data.imageData) + } + + private var isDragging: Bool { + dragOffset != 0 + } + + private var backgroundOpacity: Double { + let progress = min(abs(dragOffset) / 300, 1) + return 1 - progress * 0.5 + } + + var body: some View { + NavigationStack { + ZStack { + Color.black.opacity(backgroundOpacity).ignoresSafeArea() + + if let image { + ZoomableImageView( + image: image, + onDragChanged: { offset in + dragOffset = offset + }, + onDragEnded: { offset, velocity in + if abs(offset) > 150 || abs(velocity) > 1000 { + dismiss() + } else { + withAnimation(.spring()) { + dragOffset = 0 } + } } - .toolbarBackground(.hidden, for: .navigationBar) + ) + .offset(y: dragOffset) + .ignoresSafeArea() } - .accessibilityAction(.magicTap) { + } + .toolbar(isDragging ? .hidden : .visible, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.ImageViewer.close) { dismiss() + } + .tint(.white) } + ToolbarItem(placement: .primaryAction) { + ShareLink(item: ShareableImage(data: data.imageData), + preview: .init(L10n.Chats.Chats.ImageViewer.share)) + .tint(.white) + } + } + .toolbarBackground(.hidden, for: .navigationBar) + } + .accessibilityAction(.magicTap) { + dismiss() } + } } // MARK: - Zoomable Scroll View /// UIScrollView subclass that sizes its imageView on layout class ZoomableScrollView: UIScrollView { - let imageView = UIImageView() - - override func layoutSubviews() { - super.layoutSubviews() - guard bounds.width > 0, bounds.height > 0 else { return } - // Only update at 1x zoom to avoid fighting the zoom transform - if zoomScale == minimumZoomScale, imageView.frame.size != bounds.size { - imageView.frame = bounds - contentSize = bounds.size - } + let imageView = UIImageView() + + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.width > 0, bounds.height > 0 else { return } + // Only update at 1x zoom to avoid fighting the zoom transform + if zoomScale == minimumZoomScale, imageView.frame.size != bounds.size { + imageView.frame = bounds + contentSize = bounds.size } + } } // MARK: - Zoomable Image View /// UIViewRepresentable wrapping UIScrollView for native zoom/pan behavior struct ZoomableImageView: UIViewRepresentable { - let image: UIImage - var onDragChanged: ((CGFloat) -> Void)? - var onDragEnded: ((CGFloat, CGFloat) -> Void)? - - func makeCoordinator() -> Coordinator { - Coordinator(onDragChanged: onDragChanged, onDragEnded: onDragEnded) + let image: UIImage + var onDragChanged: ((CGFloat) -> Void)? + var onDragEnded: ((CGFloat, CGFloat) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator(onDragChanged: onDragChanged, onDragEnded: onDragEnded) + } + + func makeUIView(context: Context) -> ZoomableScrollView { + let scrollView = ZoomableScrollView() + scrollView.minimumZoomScale = 1.0 + scrollView.maximumZoomScale = 5.0 + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.backgroundColor = .clear + scrollView.delegate = context.coordinator + + let imageView = scrollView.imageView + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.image = image + + scrollView.addSubview(imageView) + context.coordinator.imageView = imageView + context.coordinator.scrollView = scrollView + + let doubleTap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleDoubleTap(_:)) + ) + doubleTap.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTap) + + let dismissPan = UIPanGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleDismissPan(_:)) + ) + dismissPan.delegate = context.coordinator + scrollView.addGestureRecognizer(dismissPan) + context.coordinator.dismissPanGesture = dismissPan + + return scrollView + } + + func updateUIView(_ scrollView: ZoomableScrollView, context: Context) {} + + static func dismantleUIView(_ scrollView: ZoomableScrollView, coordinator: Coordinator) { + scrollView.imageView.image = nil + } + + final class Coordinator: NSObject, UIScrollViewDelegate, UIGestureRecognizerDelegate { + var imageView: UIImageView? + weak var scrollView: UIScrollView? + weak var dismissPanGesture: UIPanGestureRecognizer? + let onDragChanged: ((CGFloat) -> Void)? + let onDragEnded: ((CGFloat, CGFloat) -> Void)? + + init(onDragChanged: ((CGFloat) -> Void)?, onDragEnded: ((CGFloat, CGFloat) -> Void)?) { + self.onDragChanged = onDragChanged + self.onDragEnded = onDragEnded } - func makeUIView(context: Context) -> ZoomableScrollView { - let scrollView = ZoomableScrollView() - scrollView.minimumZoomScale = 1.0 - scrollView.maximumZoomScale = 5.0 - scrollView.showsVerticalScrollIndicator = false - scrollView.showsHorizontalScrollIndicator = false - scrollView.backgroundColor = .clear - scrollView.delegate = context.coordinator - - let imageView = scrollView.imageView - imageView.contentMode = .scaleAspectFit - imageView.clipsToBounds = true - imageView.image = image - - scrollView.addSubview(imageView) - context.coordinator.imageView = imageView - context.coordinator.scrollView = scrollView - - let doubleTap = UITapGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleDoubleTap(_:)) - ) - doubleTap.numberOfTapsRequired = 2 - scrollView.addGestureRecognizer(doubleTap) - - let dismissPan = UIPanGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleDismissPan(_:)) - ) - dismissPan.delegate = context.coordinator - scrollView.addGestureRecognizer(dismissPan) - context.coordinator.dismissPanGesture = dismissPan - - return scrollView + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView } - func updateUIView(_ scrollView: ZoomableScrollView, context: Context) {} - - static func dismantleUIView(_ scrollView: ZoomableScrollView, coordinator: Coordinator) { - scrollView.imageView.image = nil + func scrollViewDidZoom(_ scrollView: UIScrollView) { + guard let imageView else { return } + let offsetX = max((scrollView.bounds.width - scrollView.contentSize.width) / 2, 0) + let offsetY = max((scrollView.bounds.height - scrollView.contentSize.height) / 2, 0) + imageView.center = CGPoint( + x: scrollView.contentSize.width / 2 + offsetX, + y: scrollView.contentSize.height / 2 + offsetY + ) } - final class Coordinator: NSObject, UIScrollViewDelegate, UIGestureRecognizerDelegate { - var imageView: UIImageView? - weak var scrollView: UIScrollView? - weak var dismissPanGesture: UIPanGestureRecognizer? - let onDragChanged: ((CGFloat) -> Void)? - let onDragEnded: ((CGFloat, CGFloat) -> Void)? - - init(onDragChanged: ((CGFloat) -> Void)?, onDragEnded: ((CGFloat, CGFloat) -> Void)?) { - self.onDragChanged = onDragChanged - self.onDragEnded = onDragEnded - } - - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - imageView - } - - func scrollViewDidZoom(_ scrollView: UIScrollView) { - guard let imageView else { return } - let offsetX = max((scrollView.bounds.width - scrollView.contentSize.width) / 2, 0) - let offsetY = max((scrollView.bounds.height - scrollView.contentSize.height) / 2, 0) - imageView.center = CGPoint( - x: scrollView.contentSize.width / 2 + offsetX, - y: scrollView.contentSize.height / 2 + offsetY - ) - } - - @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { - guard let scrollView else { return } - if scrollView.zoomScale > scrollView.minimumZoomScale { - scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) - } else { - let location = gesture.location(in: scrollView) - let zoomScale: CGFloat = 3.0 - let width = scrollView.bounds.width / zoomScale - let height = scrollView.bounds.height / zoomScale - let rect = CGRect( - x: location.x - width / 2, - y: location.y - height / 2, - width: width, - height: height - ) - scrollView.zoom(to: rect, animated: true) - } - } + @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + guard let scrollView else { return } + if scrollView.zoomScale > scrollView.minimumZoomScale { + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) + } else { + let location = gesture.location(in: scrollView) + let zoomScale: CGFloat = 3.0 + let width = scrollView.bounds.width / zoomScale + let height = scrollView.bounds.height / zoomScale + let rect = CGRect( + x: location.x - width / 2, + y: location.y - height / 2, + width: width, + height: height + ) + scrollView.zoom(to: rect, animated: true) + } + } - // MARK: - Dismiss pan gesture + // MARK: - Dismiss pan gesture - @objc func handleDismissPan(_ gesture: UIPanGestureRecognizer) { - let translation = gesture.translation(in: gesture.view).y - let velocity = gesture.velocity(in: gesture.view).y + @objc func handleDismissPan(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: gesture.view).y + let velocity = gesture.velocity(in: gesture.view).y - switch gesture.state { - case .changed: - onDragChanged?(translation) - case .ended, .cancelled: - onDragEnded?(translation, velocity) - default: - break - } - } + switch gesture.state { + case .changed: + onDragChanged?(translation) + case .ended, .cancelled: + onDragEnded?(translation, velocity) + default: + break + } + } - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard gestureRecognizer === dismissPanGesture, - let pan = gestureRecognizer as? UIPanGestureRecognizer, - let scrollView else { - return true - } - guard scrollView.zoomScale <= scrollView.minimumZoomScale else { - return false - } - let velocity = pan.velocity(in: scrollView) - return abs(velocity.y) > abs(velocity.x) - } + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === dismissPanGesture, + let pan = gestureRecognizer as? UIPanGestureRecognizer, + let scrollView else { + return true + } + guard scrollView.zoomScale <= scrollView.minimumZoomScale else { + return false + } + let velocity = pan.velocity(in: scrollView) + return abs(velocity.y) > abs(velocity.x) + } - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer - ) -> Bool { - gestureRecognizer === dismissPanGesture - } + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === dismissPanGesture } + } } // MARK: - Shareable Image /// Transferable type for sharing image data. struct ShareableImage: Transferable { - let data: Data + let data: Data - static var transferRepresentation: some TransferRepresentation { - DataRepresentation(exportedContentType: .image) { item in - item.data - } + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .image) { item in + item.data } + } } diff --git a/MC1/Views/Chats/Components/InlineImageView.swift b/MC1/Views/Chats/Components/InlineImageView.swift index 6652869d..4e55e9a6 100644 --- a/MC1/Views/Chats/Components/InlineImageView.swift +++ b/MC1/Views/Chats/Components/InlineImageView.swift @@ -3,144 +3,144 @@ import UIKit /// Displays an inline image preview in the chat timeline struct InlineImageView: View { - let image: UIImage - let isGIF: Bool - let autoPlayGIFs: Bool - let isEmbedded: Bool - let onTap: () -> Void + let image: UIImage + let isGIF: Bool + let autoPlayGIFs: Bool + let isEmbedded: Bool + let onTap: () -> Void - private static let maxWidth: CGFloat = 280 - private static let maxHeight: CGFloat = 300 + private static let maxWidth: CGFloat = 280 + private static let maxHeight: CGFloat = 300 - private var displaySize: CGSize { - let w = image.size.width - let h = image.size.height - guard w > 0, h > 0 else { - return CGSize(width: Self.maxWidth, height: Self.maxHeight) - } - let aspect = w / h - var width = min(Self.maxWidth, w) - var height = width / aspect - if height > Self.maxHeight { - height = Self.maxHeight - width = height * aspect - } - return CGSize(width: width, height: height) + private var displaySize: CGSize { + let w = image.size.width + let h = image.size.height + guard w > 0, h > 0 else { + return CGSize(width: Self.maxWidth, height: Self.maxHeight) + } + let aspect = w / h + var width = min(Self.maxWidth, w) + var height = width / aspect + if height > Self.maxHeight { + height = Self.maxHeight + width = height * aspect } + return CGSize(width: width, height: height) + } - var body: some View { - if isGIF { - GIFContentView( - image: image, - autoPlayGIFs: autoPlayGIFs, - isEmbedded: isEmbedded, - displaySize: displaySize - ) - } else { - StaticImageContentView( - image: image, - isEmbedded: isEmbedded, - displaySize: displaySize, - onTap: onTap - ) - } + var body: some View { + if isGIF { + GIFContentView( + image: image, + autoPlayGIFs: autoPlayGIFs, + isEmbedded: isEmbedded, + displaySize: displaySize + ) + } else { + StaticImageContentView( + image: image, + isEmbedded: isEmbedded, + displaySize: displaySize, + onTap: onTap + ) } + } } // MARK: - GIF Content private struct GIFContentView: View { - let image: UIImage - let autoPlayGIFs: Bool - let isEmbedded: Bool - let displaySize: CGSize + let image: UIImage + let autoPlayGIFs: Bool + let isEmbedded: Bool + let displaySize: CGSize - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var isPlaying = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isPlaying = false - private var staticFrame: UIImage { - image.images?.first ?? image - } + private var staticFrame: UIImage { + image.images?.first ?? image + } - var body: some View { - Group { - if isPlaying { - AnimatedGIFView(image: image) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .allowsHitTesting(false) - } else { - Image(uiImage: staticFrame) - .resizable() - .aspectRatio(contentMode: isEmbedded ? .fit : .fill) - } - } - .frame( - width: isEmbedded ? nil : displaySize.width, - height: isEmbedded ? nil : displaySize.height - ) - .overlay { - if !isPlaying { - ZStack { - Color.black.opacity(0.3) - Image(systemName: "play.circle.fill") - .font(.system(size: 44)) - .foregroundStyle(.white) - .shadow(radius: 2) - } - .allowsHitTesting(false) - } - } - .background { - if !isEmbedded { - Color.clear.background(.regularMaterial) - } - } - .clipShape(.rect(cornerRadius: isEmbedded ? 0 : 12)) - .contentShape(Rectangle()) - .tapYieldingToLongPress { isPlaying.toggle() } - .onAppear { - isPlaying = autoPlayGIFs && !reduceMotion - } - .onChange(of: autoPlayGIFs) { _, newValue in - isPlaying = newValue && !reduceMotion - } - .onChange(of: reduceMotion) { _, newValue in - if newValue { isPlaying = false } + var body: some View { + Group { + if isPlaying { + AnimatedGIFView(image: image) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + } else { + Image(uiImage: staticFrame) + .resizable() + .aspectRatio(contentMode: isEmbedded ? .fit : .fill) + } + } + .frame( + width: isEmbedded ? nil : displaySize.width, + height: isEmbedded ? nil : displaySize.height + ) + .overlay { + if !isPlaying { + ZStack { + Color.black.opacity(0.3) + Image(systemName: "play.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.white) + .shadow(radius: 2) } - .accessibilityLabel(L10n.Chats.Chats.InlineImage.animatedAccessibility) - .accessibilityHint(L10n.Chats.Chats.InlineImage.tapHint) - .accessibilityAddTraits(.isButton) - .accessibilityAction { isPlaying.toggle() } + .allowsHitTesting(false) + } + } + .background { + if !isEmbedded { + Color.clear.background(.regularMaterial) + } + } + .clipShape(.rect(cornerRadius: isEmbedded ? 0 : 12)) + .contentShape(Rectangle()) + .tapYieldingToLongPress { isPlaying.toggle() } + .onAppear { + isPlaying = autoPlayGIFs && !reduceMotion } + .onChange(of: autoPlayGIFs) { _, newValue in + isPlaying = newValue && !reduceMotion + } + .onChange(of: reduceMotion) { _, newValue in + if newValue { isPlaying = false } + } + .accessibilityLabel(L10n.Chats.Chats.InlineImage.animatedAccessibility) + .accessibilityHint(L10n.Chats.Chats.InlineImage.tapHint) + .accessibilityAddTraits(.isButton) + .accessibilityAction { isPlaying.toggle() } + } } // MARK: - Static Image Content private struct StaticImageContentView: View { - let image: UIImage - let isEmbedded: Bool - let displaySize: CGSize - let onTap: () -> Void + let image: UIImage + let isEmbedded: Bool + let displaySize: CGSize + let onTap: () -> Void - var body: some View { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: isEmbedded ? .fit : .fill) - .frame( - width: isEmbedded ? nil : displaySize.width, - height: isEmbedded ? nil : displaySize.height - ) - .background { - if !isEmbedded { - Color.clear.background(.regularMaterial) - } - } - .clipShape(.rect(cornerRadius: isEmbedded ? 0 : 12)) - .contentShape(Rectangle()) - .tapYieldingToLongPress { onTap() } - .accessibilityLabel(L10n.Chats.Chats.InlineImage.imageAccessibility) - .accessibilityHint(L10n.Chats.Chats.InlineImage.tapHint) - .accessibilityAddTraits(.isButton) - .accessibilityAction { onTap() } - } + var body: some View { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: isEmbedded ? .fit : .fill) + .frame( + width: isEmbedded ? nil : displaySize.width, + height: isEmbedded ? nil : displaySize.height + ) + .background { + if !isEmbedded { + Color.clear.background(.regularMaterial) + } + } + .clipShape(.rect(cornerRadius: isEmbedded ? 0 : 12)) + .contentShape(Rectangle()) + .tapYieldingToLongPress { onTap() } + .accessibilityLabel(L10n.Chats.Chats.InlineImage.imageAccessibility) + .accessibilityHint(L10n.Chats.Chats.InlineImage.tapHint) + .accessibilityAddTraits(.isButton) + .accessibilityAction { onTap() } + } } diff --git a/MC1/Views/Chats/Components/LinkPreviewCard.swift b/MC1/Views/Chats/Components/LinkPreviewCard.swift index 49b9322b..f6e8bc6c 100644 --- a/MC1/Views/Chats/Components/LinkPreviewCard.swift +++ b/MC1/Views/Chats/Components/LinkPreviewCard.swift @@ -4,133 +4,133 @@ import SwiftUI /// reserved from the image's aspect ratio (clamped to a min/max height) so the /// bubble does not jump when image bytes arrive after layout. struct LinkPreviewCard: View { - let url: URL - let title: String? - let image: UIImage? - let icon: UIImage? - let imageWidth: Int? - let imageHeight: Int? - let onTap: () -> Void + let url: URL + let title: String? + let image: UIImage? + let icon: UIImage? + let imageWidth: Int? + let imageHeight: Int? + let onTap: () -> Void - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @ScaledMetric(relativeTo: .body) private var minHeroHeight: CGFloat = RichPreviewMetrics.minHeroHeight - @ScaledMetric(relativeTo: .body) private var maxHeroHeight: CGFloat = RichPreviewMetrics.maxHeroHeight + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ScaledMetric(relativeTo: .body) private var minHeroHeight: CGFloat = RichPreviewMetrics.minHeroHeight + @ScaledMetric(relativeTo: .body) private var maxHeroHeight: CGFloat = RichPreviewMetrics.maxHeroHeight - private static let cardPadding: CGFloat = 10 - private static let headerSpacing: CGFloat = 8 - private static let iconSize: CGFloat = 16 - private static let iconCornerRadius: CGFloat = 4 + private static let cardPadding: CGFloat = 10 + private static let headerSpacing: CGFloat = 8 + private static let iconSize: CGFloat = 16 + private static let iconCornerRadius: CGFloat = 4 - init( - url: URL, - title: String?, - image: UIImage?, - icon: UIImage?, - imageWidth: Int? = nil, - imageHeight: Int? = nil, - onTap: @escaping () -> Void - ) { - self.url = url - self.title = title - self.image = image - self.icon = icon - self.imageWidth = imageWidth - self.imageHeight = imageHeight - self.onTap = onTap - } + init( + url: URL, + title: String?, + image: UIImage?, + icon: UIImage?, + imageWidth: Int? = nil, + imageHeight: Int? = nil, + onTap: @escaping () -> Void + ) { + self.url = url + self.title = title + self.image = image + self.icon = icon + self.imageWidth = imageWidth + self.imageHeight = imageHeight + self.onTap = onTap + } - private var domain: String { - url.host ?? url.absoluteString - } + private var domain: String { + url.host ?? url.absoluteString + } - /// Allow more lines for larger accessibility text sizes - private var titleLineLimit: Int { - dynamicTypeSize.isAccessibilitySize ? 4 : 2 - } + /// Allow more lines for larger accessibility text sizes + private var titleLineLimit: Int { + dynamicTypeSize.isAccessibilitySize ? 4 : 2 + } - private var domainLineLimit: Int { - dynamicTypeSize.isAccessibilitySize ? 2 : 1 - } + private var domainLineLimit: Int { + dynamicTypeSize.isAccessibilitySize ? 2 : 1 + } - private var heroAspect: CGFloat { - RichPreviewMetrics.heroAspect(imageWidth: imageWidth, imageHeight: imageHeight) - } + private var heroAspect: CGFloat { + RichPreviewMetrics.heroAspect(imageWidth: imageWidth, imageHeight: imageHeight) + } - var body: some View { - VStack(alignment: .leading, spacing: 0) { - if let image { - RichPreviewCard( - aspect: heroAspect, - minHeight: minHeroHeight, - maxHeight: maxHeroHeight, - cornerStyle: .top - ) { - Image(uiImage: image) - .resizable() - .scaledToFill() - } - } - - HStack(spacing: Self.headerSpacing) { - if let icon { - Image(uiImage: icon) - .resizable() - .frame(width: Self.iconSize, height: Self.iconSize) - .clipShape(.rect(cornerRadius: Self.iconCornerRadius)) - } else { - Image(systemName: "globe") - .font(.caption) - .foregroundStyle(.secondary) - } + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let image { + RichPreviewCard( + aspect: heroAspect, + minHeight: minHeroHeight, + maxHeight: maxHeroHeight, + cornerStyle: .top + ) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } + } - VStack(alignment: .leading, spacing: 2) { - if let title, !title.isEmpty { - Text(title) - .font(.subheadline) - .bold() - .lineLimit(titleLineLimit) - .foregroundStyle(.primary) - } + HStack(spacing: Self.headerSpacing) { + if let icon { + Image(uiImage: icon) + .resizable() + .frame(width: Self.iconSize, height: Self.iconSize) + .clipShape(.rect(cornerRadius: Self.iconCornerRadius)) + } else { + Image(systemName: "globe") + .font(.caption) + .foregroundStyle(.secondary) + } - Text(domain) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(domainLineLimit) - } + VStack(alignment: .leading, spacing: 2) { + if let title, !title.isEmpty { + Text(title) + .font(.subheadline) + .bold() + .lineLimit(titleLineLimit) + .foregroundStyle(.primary) + } - Spacer() - } - .padding(Self.cardPadding) + Text(domain) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(domainLineLimit) } - .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) - .contentShape(Rectangle()) - .tapYieldingToLongPress { onTap() } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.LinkPreview.Accessibility.label(title ?? domain, domain)) - .accessibilityHint(L10n.Chats.Chats.LinkPreview.Accessibility.hint) - .accessibilityAddTraits(.isButton) - .accessibilityAction { onTap() } + + Spacer() + } + .padding(Self.cardPadding) } + .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) + .contentShape(Rectangle()) + .tapYieldingToLongPress { onTap() } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.LinkPreview.Accessibility.label(title ?? domain, domain)) + .accessibilityHint(L10n.Chats.Chats.LinkPreview.Accessibility.hint) + .accessibilityAddTraits(.isButton) + .accessibilityAction { onTap() } + } } #Preview("With Image") { - LinkPreviewCard( - url: URL(string: "https://apple.com/iphone")!, - title: "iPhone 16 Pro - Apple", - image: nil, - icon: nil, - onTap: {} - ) - .padding() + LinkPreviewCard( + url: URL(string: "https://apple.com/iphone")!, + title: "iPhone 16 Pro - Apple", + image: nil, + icon: nil, + onTap: {} + ) + .padding() } #Preview("Without Image") { - LinkPreviewCard( - url: URL(string: "https://example.com/article")!, - title: "An Interesting Article About Technology", - image: nil, - icon: nil, - onTap: {} - ) - .padding() + LinkPreviewCard( + url: URL(string: "https://example.com/article")!, + title: "An Interesting Article About Technology", + image: nil, + icon: nil, + onTap: {} + ) + .padding() } diff --git a/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift b/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift index b11a007e..0efac2ad 100644 --- a/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift +++ b/MC1/Views/Chats/Components/LinkPreviewLoadingCard.swift @@ -1,142 +1,140 @@ -import SwiftUI import MC1Services +import SwiftUI /// Placeholder card shown while a link preview is being resolved or while the /// hero image bytes are downloading. Driven by `LinkPreviewFragmentState.Mode` /// so the shape matches what `LinkPreviewCard` will eventually render — that /// keeps the bubble's reserved height stable across the transition. struct LinkPreviewLoadingCard: View { - let state: LinkPreviewFragmentState + let state: LinkPreviewFragmentState - @ScaledMetric(relativeTo: .body) private var minHeroHeight: CGFloat = RichPreviewMetrics.minHeroHeight - @ScaledMetric(relativeTo: .body) private var maxHeroHeight: CGFloat = RichPreviewMetrics.maxHeroHeight + @ScaledMetric(relativeTo: .body) private var minHeroHeight: CGFloat = RichPreviewMetrics.minHeroHeight + @ScaledMetric(relativeTo: .body) private var maxHeroHeight: CGFloat = RichPreviewMetrics.maxHeroHeight - private static let shimmerCornerRadius: CGFloat = 4 - private static let cardPadding: CGFloat = 10 - private static let textRowSpacing: CGFloat = 6 - private static let headerSpacing: CGFloat = 8 - private static let titleRowHeight: CGFloat = 14 - private static let domainRowHeight: CGFloat = 10 - private static let titleRowWidthFraction: CGFloat = 0.72 - private static let domainRowWidthFraction: CGFloat = 0.4 + private static let shimmerCornerRadius: CGFloat = 4 + private static let cardPadding: CGFloat = 10 + private static let textRowSpacing: CGFloat = 6 + private static let headerSpacing: CGFloat = 8 + private static let titleRowHeight: CGFloat = 14 + private static let domainRowHeight: CGFloat = 10 + private static let titleRowWidthFraction: CGFloat = 0.72 + private static let domainRowWidthFraction: CGFloat = 0.4 - var body: some View { - switch state.mode { - case .idle: - unknownImageCard(url: nil) - case .loading(let url): - unknownImageCard(url: url) - case .loaded(let preview, _, _): - confirmedImageCard(preview: preview) - default: - EmptyView() - } + var body: some View { + switch state.mode { + case .idle: + unknownImageCard(url: nil) + case let .loading(url): + unknownImageCard(url: url) + case let .loaded(preview, _, _): + confirmedImageCard(preview: preview) + default: + EmptyView() } + } - // Mode 1: shimmer hero + shimmer text rows. URL may be nil for `.idle`. - @ViewBuilder - private func unknownImageCard(url: URL?) -> some View { - let host = url?.host ?? "" - VStack(alignment: .leading, spacing: 0) { - shimmerHero(aspect: CGFloat(RichPreviewMetrics.fallbackAspect)) + /// Mode 1: shimmer hero + shimmer text rows. URL may be nil for `.idle`. + @ViewBuilder + private func unknownImageCard(url: URL?) -> some View { + let host = url?.host ?? "" + VStack(alignment: .leading, spacing: 0) { + shimmerHero(aspect: CGFloat(RichPreviewMetrics.fallbackAspect)) - VStack(alignment: .leading, spacing: Self.textRowSpacing) { - shimmerRow(height: Self.titleRowHeight, widthFraction: Self.titleRowWidthFraction) - shimmerRow(height: Self.domainRowHeight, widthFraction: Self.domainRowWidthFraction) - } - .padding(Self.cardPadding) - } - .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.Chats.Chats.Preview.loadingAccessibility(host)) - .accessibilityHint(L10n.Chats.Chats.Preview.loadingHint) + VStack(alignment: .leading, spacing: Self.textRowSpacing) { + shimmerRow(height: Self.titleRowHeight, widthFraction: Self.titleRowWidthFraction) + shimmerRow(height: Self.domainRowHeight, widthFraction: Self.domainRowWidthFraction) + } + .padding(Self.cardPadding) } + .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Chats.Chats.Preview.loadingAccessibility(host)) + .accessibilityHint(L10n.Chats.Chats.Preview.loadingHint) + } - // Mode 2: placeholder hero at the eventual size + real title + domain. - @ViewBuilder - private func confirmedImageCard(preview: LinkPreviewDataDTO) -> some View { - let url = URL(string: preview.url) - let host = url?.host ?? preview.url + /// Mode 2: placeholder hero at the eventual size + real title + domain. + @ViewBuilder + private func confirmedImageCard(preview: LinkPreviewDataDTO) -> some View { + let url = URL(string: preview.url) + let host = url?.host ?? preview.url - VStack(alignment: .leading, spacing: 0) { - shimmerHero(aspect: RichPreviewMetrics.heroAspect( - imageWidth: preview.imageWidth, - imageHeight: preview.imageHeight - )) + VStack(alignment: .leading, spacing: 0) { + shimmerHero(aspect: RichPreviewMetrics.heroAspect( + imageWidth: preview.imageWidth, + imageHeight: preview.imageHeight + )) - HStack(spacing: Self.headerSpacing) { - Image(systemName: "globe") - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: Self.headerSpacing) { + Image(systemName: "globe") + .font(.caption) + .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - if let title = preview.title, !title.isEmpty { - Text(title) - .font(.subheadline) - .bold() - .lineLimit(2) - .foregroundStyle(.primary) - } - Text(host) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - } - .padding(Self.cardPadding) + VStack(alignment: .leading, spacing: 2) { + if let title = preview.title, !title.isEmpty { + Text(title) + .font(.subheadline) + .bold() + .lineLimit(2) + .foregroundStyle(.primary) + } + Text(host) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } - .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.Chats.Chats.Preview.loadingAccessibility(host)) - .accessibilityHint(L10n.Chats.Chats.Preview.loadingHint) + + Spacer() + } + .padding(Self.cardPadding) } + .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: RichPreviewMetrics.cornerRadius)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Chats.Chats.Preview.loadingAccessibility(host)) + .accessibilityHint(L10n.Chats.Chats.Preview.loadingHint) + } - // The hero placeholder reserves the eventual image's footprint. Top corners - // are rounded; the bottom meets the in-card text rows squarely. - @ViewBuilder - private func shimmerHero(aspect: CGFloat) -> some View { - RichPreviewCard( - aspect: aspect, - minHeight: minHeroHeight, - maxHeight: maxHeroHeight, - cornerStyle: .top - ) { - PreviewSkeleton(cornerRadius: 0) - } + /// The hero placeholder reserves the eventual image's footprint. Top corners + /// are rounded; the bottom meets the in-card text rows squarely. + private func shimmerHero(aspect: CGFloat) -> some View { + RichPreviewCard( + aspect: aspect, + minHeight: minHeroHeight, + maxHeight: maxHeroHeight, + cornerStyle: .top + ) { + PreviewSkeleton(cornerRadius: 0) } + } - @ViewBuilder - private func shimmerRow(height: CGFloat, widthFraction: CGFloat) -> some View { - GeometryReader { proxy in - PreviewSkeleton(cornerRadius: Self.shimmerCornerRadius) - .frame(width: proxy.size.width * widthFraction, height: height) - } - .frame(height: height) - .accessibilityHidden(true) + private func shimmerRow(height: CGFloat, widthFraction: CGFloat) -> some View { + GeometryReader { proxy in + PreviewSkeleton(cornerRadius: Self.shimmerCornerRadius) + .frame(width: proxy.size.width * widthFraction, height: height) } + .frame(height: height) + .accessibilityHidden(true) + } } #Preview("Loading - unknown image") { - LinkPreviewLoadingCard( - state: .init(mode: .loading(URL(string: "https://example.com/article")!)) - ) - .padding() + LinkPreviewLoadingCard( + state: .init(mode: .loading(URL(string: "https://example.com/article")!)) + ) + .padding() } #Preview("Loaded - awaiting image bytes") { - LinkPreviewLoadingCard( - state: .init(mode: .loaded( - LinkPreviewDataDTO( - url: "https://apple.com/iphone", - title: "iPhone 16 Pro - Apple", - imageWidth: 1200, - imageHeight: 630 - ), - image: nil, - icon: nil - )) - ) - .padding() + LinkPreviewLoadingCard( + state: .init(mode: .loaded( + LinkPreviewDataDTO( + url: "https://apple.com/iphone", + title: "iPhone 16 Pro - Apple", + imageWidth: 1200, + imageHeight: 630 + ), + image: nil, + icon: nil + )) + ) + .padding() } diff --git a/MC1/Views/Chats/Components/MalwareWarningCard.swift b/MC1/Views/Chats/Components/MalwareWarningCard.swift index 86df0888..cf5f9fdd 100644 --- a/MC1/Views/Chats/Components/MalwareWarningCard.swift +++ b/MC1/Views/Chats/Components/MalwareWarningCard.swift @@ -3,64 +3,64 @@ import SwiftUI /// Warning card displayed when a message contains a URL from a known malware domain. /// Always shown regardless of link preview settings — this is a security alert, not a preview. struct MalwareWarningCard: View { - let url: URL + let url: URL - @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dynamicTypeSize) private var dynamicTypeSize - private var domain: String { - url.host() ?? url.absoluteString - } + private var domain: String { + url.host() ?? url.absoluteString + } - private var titleLineLimit: Int { - dynamicTypeSize.isAccessibilitySize ? 4 : 2 - } + private var titleLineLimit: Int { + dynamicTypeSize.isAccessibilitySize ? 4 : 2 + } - private var domainLineLimit: Int { - dynamicTypeSize.isAccessibilitySize ? 2 : 1 - } + private var domainLineLimit: Int { + dynamicTypeSize.isAccessibilitySize ? 2 : 1 + } - var body: some View { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.shield.fill") - .foregroundStyle(.red) - .font(.title3) + var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.shield.fill") + .foregroundStyle(.red) + .font(.title3) - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Chats.Chats.MalwareWarning.title) - .font(.subheadline) - .bold() - .lineLimit(titleLineLimit) + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Chats.Chats.MalwareWarning.title) + .font(.subheadline) + .bold() + .lineLimit(titleLineLimit) - Text(domain) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(domainLineLimit) - } + Text(domain) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(domainLineLimit) + } - Spacer() - } - .padding(10) - .background( - .red.opacity(0.12), - in: .rect(cornerRadius: 12) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder(.red.opacity(0.3), lineWidth: 0.5) - ) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.MalwareWarning.accessibility(domain)) - .accessibilityAddTraits(.isStaticText) + Spacer() } + .padding(10) + .background( + .red.opacity(0.12), + in: .rect(cornerRadius: 12) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(.red.opacity(0.3), lineWidth: 0.5) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.MalwareWarning.accessibility(domain)) + .accessibilityAddTraits(.isStaticText) + } } #Preview("Malware Warning") { - MalwareWarningCard(url: URL(string: "https://evil-domain.com/malware")!) - .padding() + MalwareWarningCard(url: URL(string: "https://evil-domain.com/malware")!) + .padding() } #Preview("Dark Mode") { - MalwareWarningCard(url: URL(string: "https://evil-domain.com/malware")!) - .padding() - .preferredColorScheme(.dark) + MalwareWarningCard(url: URL(string: "https://evil-domain.com/malware")!) + .padding() + .preferredColorScheme(.dark) } diff --git a/MC1/Views/Chats/Components/MentionSuggestionRow.swift b/MC1/Views/Chats/Components/MentionSuggestionRow.swift index 9c67a0fa..ed96facd 100644 --- a/MC1/Views/Chats/Components/MentionSuggestionRow.swift +++ b/MC1/Views/Chats/Components/MentionSuggestionRow.swift @@ -1,26 +1,26 @@ -import SwiftUI import MC1Services +import SwiftUI /// A single row in the mention suggestions popup struct MentionSuggestionRow: View { - let contact: ContactDTO + let contact: ContactDTO - var body: some View { - HStack(spacing: 12) { - ContactAvatar(contact: contact, size: 32) + var body: some View { + HStack(spacing: 12) { + ContactAvatar(contact: contact, size: 32) - Text(contact.displayName) - .lineLimit(1) + Text(contact.displayName) + .lineLimit(1) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .contentShape(.rect) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Mention.Accessibility.label(contact.displayName)) - .accessibilityHint(contact.publicKey.isEmpty - ? L10n.Chats.Chats.Mention.Accessibility.hintChannel - : L10n.Chats.Chats.Mention.Accessibility.hintContact) + Spacer() } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(.rect) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Mention.Accessibility.label(contact.displayName)) + .accessibilityHint(contact.publicKey.isEmpty + ? L10n.Chats.Chats.Mention.Accessibility.hintChannel + : L10n.Chats.Chats.Mention.Accessibility.hintContact) + } } diff --git a/MC1/Views/Chats/Components/MentionSuggestionView.swift b/MC1/Views/Chats/Components/MentionSuggestionView.swift index ebdc20df..f48b8371 100644 --- a/MC1/Views/Chats/Components/MentionSuggestionView.swift +++ b/MC1/Views/Chats/Components/MentionSuggestionView.swift @@ -1,51 +1,51 @@ -import SwiftUI import MC1Services +import SwiftUI /// Floating popup displaying mention suggestions struct MentionSuggestionView: View { - let contacts: [ContactDTO] - let onSelect: (ContactDTO) -> Void + let contacts: [ContactDTO] + let onSelect: (ContactDTO) -> Void - private let maxHeight: CGFloat = 200 - private let rowHeight: CGFloat = 48 // Avatar 32 + vertical padding 16 - private let maxSuggestions = 20 + private let maxHeight: CGFloat = 200 + private let rowHeight: CGFloat = 48 // Avatar 32 + vertical padding 16 + private let maxSuggestions = 20 - private var suggestions: ArraySlice { - contacts.prefix(maxSuggestions) - } + private var suggestions: ArraySlice { + contacts.prefix(maxSuggestions) + } - private var contentHeight: CGFloat { - let count = suggestions.count - let dividerHeight: CGFloat = 1 - let totalHeight = CGFloat(count) * rowHeight + CGFloat(max(0, count - 1)) * dividerHeight - return min(totalHeight, maxHeight) - } + private var contentHeight: CGFloat { + let count = suggestions.count + let dividerHeight: CGFloat = 1 + let totalHeight = CGFloat(count) * rowHeight + CGFloat(max(0, count - 1)) * dividerHeight + return min(totalHeight, maxHeight) + } - var body: some View { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(suggestions) { contact in - VStack(spacing: 0) { - Button { - onSelect(contact) - } label: { - MentionSuggestionRow(contact: contact) - } - .buttonStyle(.plain) + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(suggestions) { contact in + VStack(spacing: 0) { + Button { + onSelect(contact) + } label: { + MentionSuggestionRow(contact: contact) + } + .buttonStyle(.plain) - if contact.id != suggestions.last?.id { - Divider() - .padding(.leading, 44) - } - } - } + if contact.id != suggestions.last?.id { + Divider() + .padding(.leading, 44) } + } } - .frame(height: contentHeight) - .background(.regularMaterial) - .clipShape(.rect(cornerRadius: 12)) - .shadow(color: .black.opacity(0.15), radius: 8, y: 2) - .animation(.spring(response: 0.25, dampingFraction: 0.9), value: contacts.count) - .accessibilityLabel(L10n.Chats.Chats.Suggestions.accessibilityLabel) + } } + .frame(height: contentHeight) + .background(.regularMaterial) + .clipShape(.rect(cornerRadius: 12)) + .shadow(color: .black.opacity(0.15), radius: 8, y: 2) + .animation(.spring(response: 0.25, dampingFraction: 0.9), value: contacts.count) + .accessibilityLabel(L10n.Chats.Chats.Suggestions.accessibilityLabel) + } } diff --git a/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift b/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift index a4ebd815..cc472f39 100644 --- a/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift +++ b/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift @@ -8,15 +8,15 @@ import UIKit /// snapshot store so the bubble resolves, requests, and retries thumbnails through providers rather /// than reaching `MapSnapshotStore.shared` from the view body. struct MessageBubbleCallbacks { - var onRetry: (() -> Void)? - var onReaction: ((String) -> Void)? - var onLongPress: (() -> Void)? - var onImageTap: (() -> Void)? - var onRetryInlineImage: (() -> Void)? - var onRequestPreviewFetch: (() -> Void)? - var onManualPreviewFetch: (() -> Void)? - var onMapPreviewTap: ((CLLocationCoordinate2D) -> Void)? - var snapshotResolver: ((MapSnapshotRequest) -> UIImage?)? - var requestSnapshot: ((MapSnapshotRequest) -> Void)? - var retrySnapshot: ((MapSnapshotRequest) -> Void)? + var onRetry: (() -> Void)? + var onReaction: ((String) -> Void)? + var onLongPress: (() -> Void)? + var onImageTap: (() -> Void)? + var onRetryInlineImage: (() -> Void)? + var onRequestPreviewFetch: (() -> Void)? + var onManualPreviewFetch: (() -> Void)? + var onMapPreviewTap: ((CLLocationCoordinate2D) -> Void)? + var snapshotResolver: ((MapSnapshotRequest) -> UIImage?)? + var requestSnapshot: ((MapSnapshotRequest) -> Void)? + var retrySnapshot: ((MapSnapshotRequest) -> Void)? } diff --git a/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift b/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift index 67ebd5a5..23ef9210 100644 --- a/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift +++ b/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift @@ -1,49 +1,49 @@ import MC1Services /// View-layer formatting flags for a message bubble. -struct MessageBubbleConfiguration: Sendable { - var showSenderName: Bool +struct MessageBubbleConfiguration { + var showSenderName: Bool - static let directMessage = MessageBubbleConfiguration(showSenderName: false) + static let directMessage = MessageBubbleConfiguration(showSenderName: false) - static func channel(isPublic: Bool) -> MessageBubbleConfiguration { - MessageBubbleConfiguration(showSenderName: true) - } + static func channel(isPublic: Bool) -> MessageBubbleConfiguration { + MessageBubbleConfiguration(showSenderName: true) + } - /// Resolves the display name for a message's sender from the contacts list. - /// Used by `ChatViewModel+ItemBuild` to bake the resolved name into - /// `MessageItem.envelope.senderResolution` upstream. - static func resolveSenderName(for message: MessageDTO, contacts: [ContactDTO]) -> NodeNameResolution { - // First, try parsed sender name from channel message - if let senderName = message.senderNodeName, !senderName.isEmpty { - return NodeNameResolution(displayName: senderName, matchKind: .exact) - } + /// Resolves the display name for a message's sender from the contacts list. + /// Used by `ChatViewModel+ItemBuild` to bake the resolved name into + /// `MessageItem.envelope.senderResolution` upstream. + static func resolveSenderName(for message: MessageDTO, contacts: [ContactDTO]) -> NodeNameResolution { + // First, try parsed sender name from channel message + if let senderName = message.senderNodeName, !senderName.isEmpty { + return NodeNameResolution(displayName: senderName, matchKind: .exact) + } - // Fallback: key prefix lookup - guard let prefix = message.senderKeyPrefix else { - return NodeNameResolution( - displayName: L10n.Chats.Chats.Message.Sender.unknown, - matchKind: .unresolved - ) - } + // Fallback: key prefix lookup + guard let prefix = message.senderKeyPrefix else { + return NodeNameResolution( + displayName: L10n.Chats.Chats.Message.Sender.unknown, + matchKind: .unresolved + ) + } - // Try to find matching contact - if let result = NeighborNameResolver.resolve( - for: prefix, - contacts: contacts, - discoveredNodes: [], - userLocation: nil - ) { - return result - } + // Try to find matching contact + if let result = NeighborNameResolver.resolve( + for: prefix, + contacts: contacts, + discoveredNodes: [], + userLocation: nil + ) { + return result + } - // Fallback to hex representation - if prefix.count >= 2 { - return NodeNameResolution(displayName: prefix.prefix(2).uppercaseHexString(), matchKind: .unresolved) - } - return NodeNameResolution( - displayName: L10n.Chats.Chats.Message.Sender.unknown, - matchKind: .unresolved - ) + // Fallback to hex representation + if prefix.count >= 2 { + return NodeNameResolution(displayName: prefix.prefix(2).uppercaseHexString(), matchKind: .unresolved) } + return NodeNameResolution( + displayName: L10n.Chats.Chats.Message.Sender.unknown, + matchKind: .unresolved + ) + } } diff --git a/MC1/Views/Chats/Components/MessageBubbleLongPress.swift b/MC1/Views/Chats/Components/MessageBubbleLongPress.swift index 43e722e3..5e839481 100644 --- a/MC1/Views/Chats/Components/MessageBubbleLongPress.swift +++ b/MC1/Views/Chats/Components/MessageBubbleLongPress.swift @@ -5,70 +5,70 @@ import SwiftUI /// (`RoomMessageBubble`) bubbles and their conversation views so the interaction /// feels identical across surfaces. enum MessageActionsPresentation { - /// Seconds the user must hold before the actions sheet opens: long enough a - /// quick tap can never reach it, short enough a deliberate hold stays responsive. - static let longPressConfirmDuration: Double = 0.6 + /// Seconds the user must hold before the actions sheet opens: long enough a + /// quick tap can never reach it, short enough a deliberate hold stays responsive. + static let longPressConfirmDuration: Double = 0.6 - /// Spring `response` (natural period) for the press-in/release scale animation. - static let longPressSpringResponse: Double = 0.7 + /// Spring `response` (natural period) for the press-in/release scale animation. + static let longPressSpringResponse: Double = 0.7 - /// Delay before the press-in scale animates, so a brief accidental tap shows - /// no lift. No delay on release so the bubble retracts immediately. - static let longPressInDelay: Double = 0.1 + /// Delay before the press-in scale animates, so a brief accidental tap shows + /// no lift. No delay on release so the bubble retracts immediately. + static let longPressInDelay: Double = 0.1 - /// Scale the bubble shrinks to while pressed, before the actions sheet opens. - static let longPressPressedScale: CGFloat = 0.95 + /// Scale the bubble shrinks to while pressed, before the actions sheet opens. + static let longPressPressedScale: CGFloat = 0.95 - /// Delay after the actions sheet dismisses before raising the keyboard or - /// presenting a follow-on sheet. A focus request issued mid-dismissal is lost, - /// and on iPad UIKit cancels a new sheet presented before the old one finishes - /// animating away, leaving the user looking at nothing. - static let dismissalDelay: Duration = .milliseconds(300) + /// Delay after the actions sheet dismisses before raising the keyboard or + /// presenting a follow-on sheet. A focus request issued mid-dismissal is lost, + /// and on iPad UIKit cancels a new sheet presented before the old one finishes + /// animating away, leaving the user looking at nothing. + static let dismissalDelay: Duration = .milliseconds(300) } extension View { - /// Attaches the bubble's actions-sheet long-press: a sustained press fires - /// `onFire`, drives the shared press state, and bumps the haptic trigger. - /// Apply to every sub-view that should open the actions sheet; the visual - /// response is rendered once by `messageBubbleLongPressEffect`. - func messageBubbleLongPressGesture( - isPressing: Binding, - trigger: Binding, - onFire: @escaping () -> Void - ) -> some View { - onLongPressGesture( - minimumDuration: MessageActionsPresentation.longPressConfirmDuration, - perform: { - trigger.wrappedValue += 1 - onFire() - }, - onPressingChanged: { isPressing.wrappedValue = $0 } - ) - } + /// Attaches the bubble's actions-sheet long-press: a sustained press fires + /// `onFire`, drives the shared press state, and bumps the haptic trigger. + /// Apply to every sub-view that should open the actions sheet; the visual + /// response is rendered once by `messageBubbleLongPressEffect`. + func messageBubbleLongPressGesture( + isPressing: Binding, + trigger: Binding, + onFire: @escaping () -> Void + ) -> some View { + onLongPressGesture( + minimumDuration: MessageActionsPresentation.longPressConfirmDuration, + perform: { + trigger.wrappedValue += 1 + onFire() + }, + onPressingChanged: { isPressing.wrappedValue = $0 } + ) + } - /// Renders the press-in shrink and haptic for a message bubble. Apply once to - /// the view that should scale; pair with `messageBubbleLongPressGesture`. - func messageBubbleLongPressEffect(isPressing: Bool, trigger: Int) -> some View { - modifier(MessageBubbleLongPressEffect(isPressing: isPressing, trigger: trigger)) - } + /// Renders the press-in shrink and haptic for a message bubble. Apply once to + /// the view that should scale; pair with `messageBubbleLongPressGesture`. + func messageBubbleLongPressEffect(isPressing: Bool, trigger: Int) -> some View { + modifier(MessageBubbleLongPressEffect(isPressing: isPressing, trigger: trigger)) + } } private struct MessageBubbleLongPressEffect: ViewModifier { - let isPressing: Bool - let trigger: Int + let isPressing: Bool + let trigger: Int - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceMotion) private var reduceMotion - func body(content: Content) -> some View { - content - .scaleEffect(isPressing ? MessageActionsPresentation.longPressPressedScale : 1.0) - .animation( - reduceMotion - ? nil - : .spring(response: MessageActionsPresentation.longPressSpringResponse) - .delay(isPressing ? MessageActionsPresentation.longPressInDelay : 0), - value: isPressing - ) - .sensoryFeedback(.impact(flexibility: .solid), trigger: trigger) - } + func body(content: Content) -> some View { + content + .scaleEffect(isPressing ? MessageActionsPresentation.longPressPressedScale : 1.0) + .animation( + reduceMotion + ? nil + : .spring(response: MessageActionsPresentation.longPressSpringResponse) + .delay(isPressing ? MessageActionsPresentation.longPressInDelay : 0), + value: isPressing + ) + .sensoryFeedback(.impact(flexibility: .solid), trigger: trigger) + } } diff --git a/MC1/Views/Chats/Components/MessageBubbleView.swift b/MC1/Views/Chats/Components/MessageBubbleView.swift index 05caa1ec..bfe5e0ef 100644 --- a/MC1/Views/Chats/Components/MessageBubbleView.swift +++ b/MC1/Views/Chats/Components/MessageBubbleView.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Renders a `UnifiedMessageBubble` for a stored `MessageItem`. Resolves /// the message DTO and per-message assets through `BubbleResolver` and @@ -8,46 +8,46 @@ import MC1Services /// `Equatable` on `MessageItem` alone so SwiftUI can skip rebodies when /// the row identity and content are unchanged. struct MessageBubbleView: View, Equatable { - let item: MessageItem - let contactName: String - let deviceName: String - let configuration: MessageBubbleConfiguration - let resolver: BubbleResolver - let actions: BubbleActions + let item: MessageItem + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + let resolver: BubbleResolver + let actions: BubbleActions - nonisolated static func == (lhs: MessageBubbleView, rhs: MessageBubbleView) -> Bool { - lhs.item == rhs.item - } + nonisolated static func == (lhs: MessageBubbleView, rhs: MessageBubbleView) -> Bool { + lhs.item == rhs.item + } - var body: some View { - if let message = resolver.message(item) { - UnifiedMessageBubble( - message: message, - contactName: contactName, - deviceName: deviceName, - configuration: configuration, - item: item, - layout: FragmentLayout(content: item.content), - imageResolver: { ref in resolver.image(ref) }, - callbacks: MessageBubbleCallbacks( - onRetry: { actions.onRetryMessage(message) }, - onReaction: { emoji in actions.onReaction(emoji, message) }, - onLongPress: { actions.onLongPress(message) }, - onImageTap: { actions.onImageTap(message) }, - onRetryInlineImage: { actions.onRetryInlineImage(message.id) }, - onRequestPreviewFetch: { actions.onRequestPreviewFetch(message.id) }, - onManualPreviewFetch: { actions.onManualPreviewFetch(message.id) }, - onMapPreviewTap: { coordinate in actions.onMapPreviewTap(coordinate) }, - snapshotResolver: actions.snapshotResolver, - requestSnapshot: actions.requestSnapshot, - retrySnapshot: actions.retrySnapshot - ) - ) - } else { - Text(L10n.Chats.Chats.Message.unavailable) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Chats.Chats.Message.unavailableAccessibility) - } + var body: some View { + if let message = resolver.message(item) { + UnifiedMessageBubble( + message: message, + contactName: contactName, + deviceName: deviceName, + configuration: configuration, + item: item, + layout: FragmentLayout(content: item.content), + imageResolver: { ref in resolver.image(ref) }, + callbacks: MessageBubbleCallbacks( + onRetry: { actions.onRetryMessage(message) }, + onReaction: { emoji in actions.onReaction(emoji, message) }, + onLongPress: { actions.onLongPress(message) }, + onImageTap: { actions.onImageTap(message) }, + onRetryInlineImage: { actions.onRetryInlineImage(message.id) }, + onRequestPreviewFetch: { actions.onRequestPreviewFetch(message.id) }, + onManualPreviewFetch: { actions.onManualPreviewFetch(message.id) }, + onMapPreviewTap: { coordinate in actions.onMapPreviewTap(coordinate) }, + snapshotResolver: actions.snapshotResolver, + requestSnapshot: actions.requestSnapshot, + retrySnapshot: actions.retrySnapshot + ) + ) + } else { + Text(L10n.Chats.Chats.Message.unavailable) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Chats.Chats.Message.unavailableAccessibility) } + } } diff --git a/MC1/Views/Chats/Components/MessageDayDividerView.swift b/MC1/Views/Chats/Components/MessageDayDividerView.swift index 118fe73b..ebc1b1d9 100644 --- a/MC1/Views/Chats/Components/MessageDayDividerView.swift +++ b/MC1/Views/Chats/Components/MessageDayDividerView.swift @@ -4,62 +4,62 @@ import SwiftUI /// relative day so the time markers (`MessageTimestampView`) stay time-only. /// Wrapped in `TimelineView` so "Today" rolls to "Yesterday" at midnight. struct MessageDayDividerView: View { - let date: Date + let date: Date - var body: some View { - TimelineView(.everyMinute) { context in - Text(label(relativeTo: context.date)) - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 4) - } + var body: some View { + TimelineView(.everyMinute) { context in + Text(label(relativeTo: context.date)) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 4) } + } - private func label(relativeTo now: Date) -> String { - let calendar = Calendar.current + private func label(relativeTo now: Date) -> String { + let calendar = Calendar.current - if calendar.isDateInToday(date) { - return L10n.Chats.Chats.Timestamp.today - } else if calendar.isDateInYesterday(date) { - return L10n.Chats.Chats.Timestamp.yesterday - } + if calendar.isDateInToday(date) { + return L10n.Chats.Chats.Timestamp.today + } else if calendar.isDateInYesterday(date) { + return L10n.Chats.Chats.Timestamp.yesterday + } - // Weekday name for the past week, capped at 6 days so it cannot collide - // with today's weekday (which 7 days ago would share). - let dayOffset = calendar.dateComponents( - [.day], - from: calendar.startOfDay(for: date), - to: calendar.startOfDay(for: now) - ).day ?? 0 + // Weekday name for the past week, capped at 6 days so it cannot collide + // with today's weekday (which 7 days ago would share). + let dayOffset = calendar.dateComponents( + [.day], + from: calendar.startOfDay(for: date), + to: calendar.startOfDay(for: now) + ).day ?? 0 - if (2...6).contains(dayOffset) { - return date.formatted(.dateTime.weekday(.wide)) - } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { - return date.formatted(.dateTime.month(.abbreviated).day()) - } else { - return date.formatted(.dateTime.month(.abbreviated).day().year()) - } + if (2...6).contains(dayOffset) { + return date.formatted(.dateTime.weekday(.wide)) + } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { + return date.formatted(.dateTime.month(.abbreviated).day()) + } else { + return date.formatted(.dateTime.month(.abbreviated).day().year()) } + } } #Preview("Today") { - MessageDayDividerView(date: Date()) - .padding() + MessageDayDividerView(date: Date()) + .padding() } #Preview("Yesterday") { - MessageDayDividerView(date: Calendar.current.date(byAdding: .day, value: -1, to: Date())!) - .padding() + MessageDayDividerView(date: Calendar.current.date(byAdding: .day, value: -1, to: Date())!) + .padding() } #Preview("Weekday") { - MessageDayDividerView(date: Calendar.current.date(byAdding: .day, value: -3, to: Date())!) - .padding() + MessageDayDividerView(date: Calendar.current.date(byAdding: .day, value: -3, to: Date())!) + .padding() } #Preview("Last Year") { - MessageDayDividerView(date: Calendar.current.date(byAdding: .year, value: -1, to: Date())!) - .padding() + MessageDayDividerView(date: Calendar.current.date(byAdding: .year, value: -1, to: Date())!) + .padding() } diff --git a/MC1/Views/Chats/Components/MessagePathContent.swift b/MC1/Views/Chats/Components/MessagePathContent.swift index 856a4b02..209b50ca 100644 --- a/MC1/Views/Chats/Components/MessagePathContent.swift +++ b/MC1/Views/Chats/Components/MessagePathContent.swift @@ -6,81 +6,81 @@ import SwiftUI /// Inline content for message path visualization, extracted from MessagePathSheet. /// Shows sender, intermediate hops, receiver, raw path hex, and a copy button. struct MessagePathContent: View { - let message: MessageDTO - let viewModel: MessagePathViewModel - let receiverName: String - let userLocation: CLLocation? + let message: MessageDTO + let viewModel: MessagePathViewModel + let receiverName: String + let userLocation: CLLocation? - @State private var copyHapticTrigger = 0 + @State private var copyHapticTrigger = 0 - var body: some View { - if viewModel.isLoading { - ProgressView() - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if message.pathNodes == nil { - ContentUnavailableView( - L10n.Chats.Chats.Path.Unavailable.title, - systemImage: "point.topleft.down.to.point.bottomright.curvepath", - description: Text(L10n.Chats.Chats.Path.Unavailable.description) - ) - } else { - let senderResolution = viewModel.senderResolution(for: message) - let pathHops = message.pathHops + var body: some View { + if viewModel.isLoading { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else if message.pathNodes == nil { + ContentUnavailableView( + L10n.Chats.Chats.Path.Unavailable.title, + systemImage: "point.topleft.down.to.point.bottomright.curvepath", + description: Text(L10n.Chats.Chats.Path.Unavailable.description) + ) + } else { + let senderResolution = viewModel.senderResolution(for: message) + let pathHops = message.pathHops - // Sender - PathHopRowView( - hopType: .sender, - nodeName: senderResolution.displayName, - nodeID: viewModel.senderNodeID(for: message), - snr: nil, - matchKind: senderResolution.matchKind - ) + // Sender + PathHopRowView( + hopType: .sender, + nodeName: senderResolution.displayName, + nodeID: viewModel.senderNodeID(for: message), + snr: nil, + matchKind: senderResolution.matchKind + ) - // Intermediate hops - ForEach(Array(pathHops.enumerated()), id: \.offset) { index, hop in - let repeaterResolution = viewModel.repeaterResolution( - for: hop.data, - userLocation: userLocation - ) - PathHopRowView( - hopType: .intermediate(index + 1), - nodeName: repeaterResolution.displayName, - nodeID: hop.hex, - snr: nil, - matchKind: repeaterResolution.matchKind - ) - } + // Intermediate hops + ForEach(Array(pathHops.enumerated()), id: \.offset) { index, hop in + let repeaterResolution = viewModel.repeaterResolution( + for: hop.data, + userLocation: userLocation + ) + PathHopRowView( + hopType: .intermediate(index + 1), + nodeName: repeaterResolution.displayName, + nodeID: hop.hex, + snr: nil, + matchKind: repeaterResolution.matchKind + ) + } - // Receiver - PathHopRowView( - hopType: .receiver, - nodeName: receiverName, - nodeID: nil, - snr: message.snr - ) + // Receiver + PathHopRowView( + hopType: .receiver, + nodeName: receiverName, + nodeID: nil, + snr: message.snr + ) - // Raw path hex + copy button - if !pathHops.isEmpty { - HStack { - Button(L10n.Chats.Chats.Path.copyButton, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - UIPasteboard.general.string = message.pathStringForClipboard - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - .accessibilityLabel(L10n.Chats.Chats.Path.copyAccessibility) - .accessibilityHint(L10n.Chats.Chats.Path.copyHint) + // Raw path hex + copy button + if !pathHops.isEmpty { + HStack { + Button(L10n.Chats.Chats.Path.copyButton, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = message.pathStringForClipboard + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .accessibilityLabel(L10n.Chats.Chats.Path.copyAccessibility) + .accessibilityHint(L10n.Chats.Chats.Path.copyHint) - Text(message.pathString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + Text(message.pathString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) - Spacer() - } - .padding(.top, 8) - .sensoryFeedback(.success, trigger: copyHapticTrigger) - } + Spacer() } + .padding(.top, 8) + .sensoryFeedback(.success, trigger: copyHapticTrigger) + } } + } } diff --git a/MC1/Views/Chats/Components/MessagePathFormatter.swift b/MC1/Views/Chats/Components/MessagePathFormatter.swift index 50e016d7..3e116acc 100644 --- a/MC1/Views/Chats/Components/MessagePathFormatter.swift +++ b/MC1/Views/Chats/Components/MessagePathFormatter.swift @@ -3,34 +3,34 @@ import MC1Services /// Formats message routing path for display in message bubbles enum MessagePathFormatter { - /// Formats the routing path for display - /// - Parameter message: The message DTO containing path information - /// - Returns: Formatted path string (e.g., "Direct", "Flood", "A3,7F,42", or "A3,7F…B2,C1") - static func format(_ message: MessageDTO) -> String { - if message.isDirectRouted { - return L10n.Chats.Chats.Message.Path.direct - } - - // Destination marker: single 0xFF byte indicates direct message - if let pathNodes = message.pathNodes, - pathNodes.count == 1, - pathNodes[0] == 0xFF { - return L10n.Chats.Chats.Message.Path.direct - } + /// Formats the routing path for display + /// - Parameter message: The message DTO containing path information + /// - Returns: Formatted path string (e.g., "Direct", "Flood", "A3,7F,42", or "A3,7F…B2,C1") + static func format(_ message: MessageDTO) -> String { + if message.isDirectRouted { + return L10n.Chats.Chats.Message.Path.direct + } - let nodes = message.pathNodesHex + // Destination marker: single 0xFF byte indicates direct message + if let pathNodes = message.pathNodes, + pathNodes.count == 1, + pathNodes[0] == 0xFF { + return L10n.Chats.Chats.Message.Path.direct + } - if nodes.isEmpty { - return L10n.Chats.Chats.Message.Path.flood - } + let nodes = message.pathNodesHex - // Truncate if more than 6 nodes: show first 3 + ellipsis + last 3 - if nodes.count > 6 { - let first = nodes.prefix(3).joined(separator: ",") - let last = nodes.suffix(3).joined(separator: ",") - return "\(first)…\(last)" - } + if nodes.isEmpty { + return L10n.Chats.Chats.Message.Path.flood + } - return nodes.joined(separator: ",") + // Truncate if more than 6 nodes: show first 3 + ellipsis + last 3 + if nodes.count > 6 { + let first = nodes.prefix(3).joined(separator: ",") + let last = nodes.suffix(3).joined(separator: ",") + return "\(first)…\(last)" } + + return nodes.joined(separator: ",") + } } diff --git a/MC1/Views/Chats/Components/MessagePathMapView.swift b/MC1/Views/Chats/Components/MessagePathMapView.swift index ed7c33ff..3506c4cb 100644 --- a/MC1/Views/Chats/Components/MessagePathMapView.swift +++ b/MC1/Views/Chats/Components/MessagePathMapView.swift @@ -5,246 +5,249 @@ import MC1Services import SwiftUI struct MessagePathMapView: View { - /// Span used when the path resolves to a single node, with no bounding box to fit. - private static let singleNodeSpanDelta: CLLocationDegrees = 0.05 - /// Padding around the multi-node bounding region, wider than `boundingRegion`'s - /// 1.5 default to keep the path clear of the floating map-controls toolbar. - private static let pathBoundingPaddingMultiplier: Double = 2.5 - - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme - - let message: MessageDTO - let pathViewModel: MessagePathViewModel - - @State private var cameraRegion: MKCoordinateRegion? - @State private var cameraRegionVersion = 0 - @State private var mapStyle: MapStyleSelection = .standard - @State private var isNorthLocked = false - @State private var showLabels = true - @State private var showingLayersMenu = false - @State private var isStyleLoaded = false - @State private var hasInitiallyFit = false - @State private var locatedNodes: [(point: MapPoint, coordinate: CLLocationCoordinate2D)] = [] - - private var mapPoints: [MapPoint] { locatedNodes.map(\.point) } - - private var mapLines: [MapLine] { - let coords = locatedNodes.map(\.coordinate) - guard coords.count >= 2 else { return [] } - return [MapLine(id: "message-path", coordinates: coords, style: .messagePath, opacity: 1.0)] - } + /// Span used when the path resolves to a single node, with no bounding box to fit. + private static let singleNodeSpanDelta: CLLocationDegrees = 0.05 + /// Padding around the multi-node bounding region, wider than `boundingRegion`'s + /// 1.5 default to keep the path clear of the floating map-controls toolbar. + private static let pathBoundingPaddingMultiplier: Double = 2.5 + + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + let message: MessageDTO + let pathViewModel: MessagePathViewModel + + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 + @State private var mapStyle: MapStyleSelection = .standard + @State private var isNorthLocked = false + @State private var showLabels = true + @State private var showingLayersMenu = false + @State private var isStyleLoaded = false + @State private var hasInitiallyFit = false + @State private var locatedNodes: [(point: MapPoint, coordinate: CLLocationCoordinate2D)] = [] + + private var mapPoints: [MapPoint] { + locatedNodes.map(\.point) + } + + private var mapLines: [MapLine] { + let coords = locatedNodes.map(\.coordinate) + guard coords.count >= 2 else { return [] } + return [MapLine(id: "message-path", coordinates: coords, style: .messagePath, opacity: 1.0)] + } + + /// Length of the drawn path, over only the nodes we could place. Nil until at + /// least two nodes resolve to coordinates, so the pill's distance always + /// matches the polyline in `mapLines`. + private var totalPathDistance: CLLocationDistance? { + locatedNodes.map(\.coordinate).totalDistance() + } + + /// Total intermediate-hop count from the message's path, including hops too + /// ambiguous to plot. A `nil` `pathNodes` yields 0, which is indistinguishable + /// between a genuine direct message and firmware that omits path data. + private var hopCount: Int { + message.pathHops.count + } + + var body: some View { + NavigationStack { + Group { + if pathViewModel.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if locatedNodes.isEmpty { + ContentUnavailableView( + L10n.Chats.Chats.Path.Unavailable.title, + systemImage: "map", + description: Text(L10n.Chats.Chats.Path.Unavailable.description) + ) + } else { + ZStack(alignment: .bottomTrailing) { + MC1MapView( + points: mapPoints, + lines: mapLines, + mapStyle: mapStyle, + isDarkMode: colorScheme == .dark, + showLabels: showLabels, + showsUserLocation: false, + isInteractive: true, + showsScale: true, + isNorthLocked: isNorthLocked, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { cameraRegion = $0 }, + isStyleLoaded: $isStyleLoaded + ) + .ignoresSafeArea() - /// Length of the drawn path, over only the nodes we could place. Nil until at - /// least two nodes resolve to coordinates, so the pill's distance always - /// matches the polyline in `mapLines`. - private var totalPathDistance: CLLocationDistance? { - locatedNodes.map(\.coordinate).totalDistance() - } + PathDistanceBanner( + hopCount: hopCount, + totalPathDistance: totalPathDistance + ) - /// Total intermediate-hop count from the message's path, including hops too - /// ambiguous to plot. A `nil` `pathNodes` yields 0, which is indistinguishable - /// between a genuine direct message and firmware that omits path data. - private var hopCount: Int { message.pathHops.count } - - var body: some View { - NavigationStack { - Group { - if pathViewModel.isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if locatedNodes.isEmpty { - ContentUnavailableView( - L10n.Chats.Chats.Path.Unavailable.title, - systemImage: "map", - description: Text(L10n.Chats.Chats.Path.Unavailable.description) - ) - } else { - ZStack(alignment: .bottomTrailing) { - MC1MapView( - points: mapPoints, - lines: mapLines, - mapStyle: mapStyle, - isDarkMode: colorScheme == .dark, - showLabels: showLabels, - showsUserLocation: false, - isInteractive: true, - showsScale: true, - isNorthLocked: isNorthLocked, - cameraRegion: $cameraRegion, - cameraRegionVersion: cameraRegionVersion, - onPointTap: nil, - onMapTap: nil, - onCameraRegionChange: { cameraRegion = $0 }, - isStyleLoaded: $isStyleLoaded - ) - .ignoresSafeArea() - - PathDistanceBanner( - hopCount: hopCount, - totalPathDistance: totalPathDistance - ) - - VStack { - Spacer() - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: centerOnUserLocation, - isNorthLocked: $isNorthLocked, - showLabels: $showLabels, - showingLayersMenu: $showingLayersMenu - ) { - if !locatedNodes.isEmpty { - Button(L10n.Chats.Chats.Path.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { - fitCameraToPath() - } - .mapControlButton(tint: .primary) - } - } - } - } - .overlay(alignment: .bottomTrailing) { - if showingLayersMenu { - LayersMenu( - selection: $mapStyle, - isPresented: $showingLayersMenu, - viewportBounds: cameraRegion?.toMLNCoordinateBounds() - ) - .padding(.trailing, 16) - .padding(.bottom, 160) - .transition(.scale.combined(with: .opacity)) - } - } - .animation(.spring(response: 0.3), value: showingLayersMenu) + VStack { + Spacer() + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: centerOnUserLocation, + isNorthLocked: $isNorthLocked, + showLabels: $showLabels, + showingLayersMenu: $showingLayersMenu + ) { + if !locatedNodes.isEmpty { + Button(L10n.Chats.Chats.Path.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { + fitCameraToPath() } + .mapControlButton(tint: .primary) + } } + } } - .navigationTitle(L10n.Chats.Chats.Path.map) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Localizable.Common.done) { dismiss() } - } - } - .onAppear { - locatedNodes = buildLocatedNodes() - } - .onChange(of: isStyleLoaded) { _, loaded in - guard loaded, !hasInitiallyFit else { return } - hasInitiallyFit = true - fitCameraToPath() + .overlay(alignment: .bottomTrailing) { + if showingLayersMenu { + LayersMenu( + selection: $mapStyle, + isPresented: $showingLayersMenu, + viewportBounds: cameraRegion?.toMLNCoordinateBounds() + ) + .padding(.trailing, 16) + .padding(.bottom, 160) + .transition(.scale.combined(with: .opacity)) + } } + .animation(.spring(response: 0.3), value: showingLayersMenu) + } } - } - - private func fitCameraToPath() { - let coords = locatedNodes.map(\.coordinate) - if coords.count == 1 { - cameraRegion = MKCoordinateRegion( - center: coords[0], - span: MKCoordinateSpan( - latitudeDelta: Self.singleNodeSpanDelta, - longitudeDelta: Self.singleNodeSpanDelta - ) - ) - } else if let region = coords.boundingRegion(paddingMultiplier: Self.pathBoundingPaddingMultiplier) { - cameraRegion = region + } + .navigationTitle(L10n.Chats.Chats.Path.map) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Localizable.Common.done) { dismiss() } } - cameraRegionVersion += 1 + } + .onAppear { + locatedNodes = buildLocatedNodes() + } + .onChange(of: isStyleLoaded) { _, loaded in + guard loaded, !hasInitiallyFit else { return } + hasInitiallyFit = true + fitCameraToPath() + } } - - private func centerOnUserLocation() { - guard let location = appState.bestAvailableLocation else { - appState.locationService.requestLocation() - return - } - cameraRegion = MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan( - latitudeDelta: Self.singleNodeSpanDelta, - longitudeDelta: Self.singleNodeSpanDelta - ) + } + + private func fitCameraToPath() { + let coords = locatedNodes.map(\.coordinate) + if coords.count == 1 { + cameraRegion = MKCoordinateRegion( + center: coords[0], + span: MKCoordinateSpan( + latitudeDelta: Self.singleNodeSpanDelta, + longitudeDelta: Self.singleNodeSpanDelta ) - cameraRegionVersion += 1 + ) + } else if let region = coords.boundingRegion(paddingMultiplier: Self.pathBoundingPaddingMultiplier) { + cameraRegion = region } + cameraRegionVersion += 1 + } - private func buildLocatedNodes() -> [(point: MapPoint, coordinate: CLLocationCoordinate2D)] { - var nodes: [(MapPoint, CLLocationCoordinate2D)] = [] - - // Sender - if let keyPrefix = message.senderKeyPrefix, - let sender = pathViewModel.contacts.first(where: { $0.publicKeyPrefix == keyPrefix }), - sender.hasLocation { - let coord = CLLocationCoordinate2D(latitude: sender.latitude, longitude: sender.longitude) - nodes.append((MapPoint( - id: sender.id, - coordinate: coord, - pinStyle: .pointA, - label: sender.displayName, - isClusterable: false, - hopIndex: nil, - badgeText: nil - ), coord)) - } - - // Repeater hops. A 1-byte path hash can't tell apart repeaters sharing a - // first key byte, so only plot hops that resolve to a single, unambiguous - // repeater (exact match). Ambiguous hops — where several known repeaters - // share the hash — are skipped rather than guessed at by proximity, which - // is what produced the criss-crossing pile-ups. - if let pathNodes = message.pathNodes { - let size = message.pathHashSize - let hops = stride(from: 0, to: pathNodes.count, by: size).map { start -> Data in - Data(pathNodes[start..() - for (index, hashBytes) in hops.enumerated() { - let hopNumber = index + 1 - let resolvedContact = RepeaterResolver.resolve(for: hashBytes, in: pathViewModel.repeaters, userLocation: appState.bestAvailableLocation) - let resolvedNode = RepeaterResolver.resolve(for: hashBytes, in: pathViewModel.discoveredRepeaters, userLocation: appState.bestAvailableLocation) - let resolved: (node: any RepeaterResolvable, matchKind: NodeNameMatchKind)? = - resolvedContact.map { ($0.node, $0.matchKind) } ?? resolvedNode.map { ($0.node, $0.matchKind) } - guard let resolved, resolved.matchKind == .exact else { continue } - let r = resolved.node - if r.hasLocation, seenKeys.insert(r.publicKey).inserted { - let coord = CLLocationCoordinate2D(latitude: r.latitude, longitude: r.longitude) - nodes.append((MapPoint( - id: UUID(), - coordinate: coord, - pinStyle: .repeaterHop, - label: r.resolvableName, - isClusterable: false, - hopIndex: hopNumber, - badgeText: nil - ), coord)) - } - } - } + private func centerOnUserLocation() { + guard let location = appState.bestAvailableLocation else { + appState.locationService.requestLocation() + return + } + cameraRegion = MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan( + latitudeDelta: Self.singleNodeSpanDelta, + longitudeDelta: Self.singleNodeSpanDelta + ) + ) + cameraRegionVersion += 1 + } + + private func buildLocatedNodes() -> [(point: MapPoint, coordinate: CLLocationCoordinate2D)] { + var nodes: [(MapPoint, CLLocationCoordinate2D)] = [] + + // Sender + if let keyPrefix = message.senderKeyPrefix, + let sender = pathViewModel.contacts.first(where: { $0.publicKeyPrefix == keyPrefix }), + sender.hasLocation { + let coord = CLLocationCoordinate2D(latitude: sender.latitude, longitude: sender.longitude) + nodes.append((MapPoint( + id: sender.id, + coordinate: coord, + pinStyle: .pointA, + label: sender.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + ), coord)) + } - // Receiver (this device) - let receiverLocation: CLLocation? - if let device = appState.connectedDevice, device.hasLocation { - receiverLocation = CLLocation(latitude: device.latitude, longitude: device.longitude) - } else { - receiverLocation = appState.bestAvailableLocation + // Repeater hops. A 1-byte path hash can't tell apart repeaters sharing a + // first key byte, so only plot hops that resolve to a single, unambiguous + // repeater (exact match). Ambiguous hops — where several known repeaters + // share the hash — are skipped rather than guessed at by proximity, which + // is what produced the criss-crossing pile-ups. + if let pathNodes = message.pathNodes { + let size = message.pathHashSize + let hops = stride(from: 0, to: pathNodes.count, by: size).map { start -> Data in + Data(pathNodes[start..() + for (index, hashBytes) in hops.enumerated() { + let hopNumber = index + 1 + let resolvedContact = RepeaterResolver.resolve(for: hashBytes, in: pathViewModel.repeaters, userLocation: appState.bestAvailableLocation) + let resolvedNode = RepeaterResolver.resolve(for: hashBytes, in: pathViewModel.discoveredRepeaters, userLocation: appState.bestAvailableLocation) + let resolved: (node: any RepeaterResolvable, matchKind: NodeNameMatchKind)? = + resolvedContact.map { ($0.node, $0.matchKind) } ?? resolvedNode.map { ($0.node, $0.matchKind) } + guard let resolved, resolved.matchKind == .exact else { continue } + let r = resolved.node + if r.hasLocation, seenKeys.insert(r.publicKey).inserted { + let coord = CLLocationCoordinate2D(latitude: r.latitude, longitude: r.longitude) + nodes.append((MapPoint( + id: UUID(), + coordinate: coord, + pinStyle: .repeaterHop, + label: r.resolvableName, + isClusterable: false, + hopIndex: hopNumber, + badgeText: nil + ), coord)) } + } + } - if let loc = receiverLocation { - let coord = loc.coordinate - nodes.append((MapPoint( - id: UUID(), - coordinate: coord, - pinStyle: .pointB, - label: appState.connectedDevice?.nodeName, - isClusterable: false, - hopIndex: nil, - badgeText: nil - ), coord)) - } + // Receiver (this device) + let receiverLocation: CLLocation? = if let device = appState.connectedDevice, device.hasLocation { + CLLocation(latitude: device.latitude, longitude: device.longitude) + } else { + appState.bestAvailableLocation + } - return nodes + if let loc = receiverLocation { + let coord = loc.coordinate + nodes.append((MapPoint( + id: UUID(), + coordinate: coord, + pinStyle: .pointB, + label: appState.connectedDevice?.nodeName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + ), coord)) } + + return nodes + } } diff --git a/MC1/Views/Chats/Components/MessagePathViewModel.swift b/MC1/Views/Chats/Components/MessagePathViewModel.swift index a3cae8dd..27fca6fb 100644 --- a/MC1/Views/Chats/Components/MessagePathViewModel.swift +++ b/MC1/Views/Chats/Components/MessagePathViewModel.swift @@ -1,85 +1,85 @@ import CoreLocation -import OSLog import MC1Services +import OSLog import SwiftUI @Observable @MainActor final class MessagePathViewModel { - var contacts: [ContactDTO] = [] - var repeaters: [ContactDTO] = [] - var discoveredRepeaters: [DiscoveredNodeDTO] = [] - var isLoading = true - - private let logger = Logger(subsystem: "com.mc1", category: "MessagePathViewModel") + var contacts: [ContactDTO] = [] + var repeaters: [ContactDTO] = [] + var discoveredRepeaters: [DiscoveredNodeDTO] = [] + var isLoading = true - func loadContacts(services: ServiceContainer?, radioID: UUID) async { - isLoading = true - guard let services else { - isLoading = false - return - } + private let logger = Logger(subsystem: "com.mc1", category: "MessagePathViewModel") - do { - let fetched = try await services.dataStore.fetchContacts(radioID: radioID) - contacts = fetched - repeaters = fetched.filter { $0.type == .repeater } - let nodes = try await services.dataStore.fetchDiscoveredNodes(radioID: radioID) - discoveredRepeaters = nodes.filter { $0.nodeType == .repeater } - } catch { - logger.error("Failed to load contacts: \(error.localizedDescription)") - contacts = [] - repeaters = [] - discoveredRepeaters = [] - } - - isLoading = false + func loadContacts(services: ServiceContainer?, radioID: UUID) async { + isLoading = true + guard let services else { + isLoading = false + return } - func senderResolution(for message: MessageDTO) -> NodeNameResolution { - if message.isChannelMessage, let nodeName = message.senderNodeName { - return NodeNameResolution(displayName: nodeName, matchKind: .exact) - } + do { + let fetched = try await services.dataStore.fetchContacts(radioID: radioID) + contacts = fetched + repeaters = fetched.filter { $0.type == .repeater } + let nodes = try await services.dataStore.fetchDiscoveredNodes(radioID: radioID) + discoveredRepeaters = nodes.filter { $0.nodeType == .repeater } + } catch { + logger.error("Failed to load contacts: \(error.localizedDescription)") + contacts = [] + repeaters = [] + discoveredRepeaters = [] + } - if let keyPrefix = message.senderKeyPrefix, - let result = NeighborNameResolver.resolve( - for: keyPrefix, - contacts: contacts, - discoveredNodes: [], - userLocation: nil - ) { - return result - } + isLoading = false + } - return NodeNameResolution( - displayName: L10n.Chats.Chats.Path.Hop.unknown, - matchKind: .unresolved - ) + func senderResolution(for message: MessageDTO) -> NodeNameResolution { + if message.isChannelMessage, let nodeName = message.senderNodeName { + return NodeNameResolution(displayName: nodeName, matchKind: .exact) } - func senderName(for message: MessageDTO) -> String { - senderResolution(for: message).displayName + if let keyPrefix = message.senderKeyPrefix, + let result = NeighborNameResolver.resolve( + for: keyPrefix, + contacts: contacts, + discoveredNodes: [], + userLocation: nil + ) { + return result } - func senderNodeID(for message: MessageDTO) -> String? { - guard let keyPrefix = message.senderKeyPrefix, - let firstByte = keyPrefix.first else { return nil } - return String(format: "%02X", firstByte) - } + return NodeNameResolution( + displayName: L10n.Chats.Chats.Path.Hop.unknown, + matchKind: .unresolved + ) + } - func repeaterResolution(for hashBytes: Data, userLocation: CLLocation?) -> NodeNameResolution { - NeighborNameResolver.resolve( - for: hashBytes, - contacts: repeaters, - discoveredNodes: discoveredRepeaters, - userLocation: userLocation - ) ?? NodeNameResolution( - displayName: L10n.Chats.Chats.Path.Hop.unknown, - matchKind: .unresolved - ) - } + func senderName(for message: MessageDTO) -> String { + senderResolution(for: message).displayName + } - func repeaterName(for hashBytes: Data, userLocation: CLLocation?) -> String { - repeaterResolution(for: hashBytes, userLocation: userLocation).displayName - } + func senderNodeID(for message: MessageDTO) -> String? { + guard let keyPrefix = message.senderKeyPrefix, + let firstByte = keyPrefix.first else { return nil } + return String(format: "%02X", firstByte) + } + + func repeaterResolution(for hashBytes: Data, userLocation: CLLocation?) -> NodeNameResolution { + NeighborNameResolver.resolve( + for: hashBytes, + contacts: repeaters, + discoveredNodes: discoveredRepeaters, + userLocation: userLocation + ) ?? NodeNameResolution( + displayName: L10n.Chats.Chats.Path.Hop.unknown, + matchKind: .unresolved + ) + } + + func repeaterName(for hashBytes: Data, userLocation: CLLocation?) -> String { + repeaterResolution(for: hashBytes, userLocation: userLocation).displayName + } } diff --git a/MC1/Views/Chats/Components/MessageText.swift b/MC1/Views/Chats/Components/MessageText.swift index 731a4655..af3d0468 100644 --- a/MC1/Views/Chats/Components/MessageText.swift +++ b/MC1/Views/Chats/Components/MessageText.swift @@ -1,165 +1,165 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// A Text view that formats message content with tappable links and styled mentions struct MessageText: View { - let text: String - let baseColor: Color - let isOutgoing: Bool - let currentUserName: String? - let precomputedText: AttributedString? - - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.colorScheme) private var colorScheme - @Environment(\.appTheme) private var theme - - init( - _ text: String, - baseColor: Color = .primary, - isOutgoing: Bool = false, - currentUserName: String? = nil, - precomputedText: AttributedString? = nil - ) { - self.text = text - self.baseColor = baseColor - self.isOutgoing = isOutgoing - self.currentUserName = currentUserName - self.precomputedText = precomputedText - } - - var body: some View { - Text(precomputedText ?? formattedText) - } - - /// Exposes formatted text for testing - var testableFormattedText: AttributedString { - formattedText - } - - private var formattedText: AttributedString { - MessageText.buildFormattedText( - text: text, - isOutgoing: isOutgoing, - currentUserName: currentUserName, - isHighContrast: colorSchemeContrast == .increased, - outgoingTextColor: theme.outgoingTextColor, - hashtagColor: theme.hashtagColor, - identityGamut: theme.identityGamut, - identityBackgroundLuminances: theme.avatarSurfaceLuminances( - colorScheme: colorScheme, - contrast: colorSchemeContrast - ) - ).text - } - - /// Builds an AttributedString with mention, contact-share, URL, meshcore-link, hashtag, - /// and coordinate formatting in three stages: a string-shrinking pre-pass - /// (`MessageTextNormalizer`), single-pass detection into a sorted non-overlapping token - /// stream (`MessageLinkTokenizer`), then one styling step (`MessageLinkStyler`). Static so - /// it can be called from both the view and the ViewModel cache; the `(text:mapCoordinate:)` - /// return shape is the contract `makeBuildInputs` depends on. - static func buildFormattedText( - text: String, - isOutgoing: Bool, - currentUserName: String?, - isHighContrast: Bool, - outgoingTextColor: Color, - hashtagColor: Color, - identityGamut: IdentityGamut, - identityBackgroundLuminances: [Double] - ) -> (text: AttributedString, mapCoordinate: CLLocationCoordinate2D?) { - let baseColor: Color = isOutgoing ? outgoingTextColor : .primary - - let normalized = MessageTextNormalizer.normalize( - text, - context: MessageTextNormalizer.StyleContext( - baseColor: baseColor, - isOutgoing: isOutgoing, - currentUserName: currentUserName, - isHighContrast: isHighContrast, - outgoingTextColor: outgoingTextColor, - identityGamut: identityGamut, - identityBackgroundLuminances: identityBackgroundLuminances - ) - ) - - let tokenized = MessageLinkTokenizer.tokenize( - normalized: normalized.string, - preSpans: normalized.spans, - context: MessageLinkTokenizer.StyleContext( - baseColor: baseColor, - isOutgoing: isOutgoing, - outgoingTextColor: outgoingTextColor, - hashtagColor: hashtagColor - ) - ) - - let styled = MessageLinkStyler.style( - normalized: normalized.string, - tokens: tokenized.tokens, - baseColor: baseColor - ) - - return (styled, tokenized.mapCoordinate) - } - - /// Strips invisible and control Unicode scalars from an inbound contact name. Shared - /// entry point for the mention-tap path, which sanitizes the same way the linkifier does. - static func displayName(for name: String) -> String { - MessageTextNormalizer.displayName(for: name) - } + let text: String + let baseColor: Color + let isOutgoing: Bool + let currentUserName: String? + let precomputedText: AttributedString? + + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.colorScheme) private var colorScheme + @Environment(\.appTheme) private var theme + + init( + _ text: String, + baseColor: Color = .primary, + isOutgoing: Bool = false, + currentUserName: String? = nil, + precomputedText: AttributedString? = nil + ) { + self.text = text + self.baseColor = baseColor + self.isOutgoing = isOutgoing + self.currentUserName = currentUserName + self.precomputedText = precomputedText + } + + var body: some View { + Text(precomputedText ?? formattedText) + } + + /// Exposes formatted text for testing + var testableFormattedText: AttributedString { + formattedText + } + + private var formattedText: AttributedString { + MessageText.buildFormattedText( + text: text, + isOutgoing: isOutgoing, + currentUserName: currentUserName, + isHighContrast: colorSchemeContrast == .increased, + outgoingTextColor: theme.outgoingTextColor, + hashtagColor: theme.hashtagColor, + identityGamut: theme.identityGamut, + identityBackgroundLuminances: theme.avatarSurfaceLuminances( + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + ).text + } + + /// Builds an AttributedString with mention, contact-share, URL, meshcore-link, hashtag, + /// and coordinate formatting in three stages: a string-shrinking pre-pass + /// (`MessageTextNormalizer`), single-pass detection into a sorted non-overlapping token + /// stream (`MessageLinkTokenizer`), then one styling step (`MessageLinkStyler`). Static so + /// it can be called from both the view and the ViewModel cache; the `(text:mapCoordinate:)` + /// return shape is the contract `makeBuildInputs` depends on. + static func buildFormattedText( + text: String, + isOutgoing: Bool, + currentUserName: String?, + isHighContrast: Bool, + outgoingTextColor: Color, + hashtagColor: Color, + identityGamut: IdentityGamut, + identityBackgroundLuminances: [Double] + ) -> (text: AttributedString, mapCoordinate: CLLocationCoordinate2D?) { + let baseColor: Color = isOutgoing ? outgoingTextColor : .primary + + let normalized = MessageTextNormalizer.normalize( + text, + context: MessageTextNormalizer.StyleContext( + baseColor: baseColor, + isOutgoing: isOutgoing, + currentUserName: currentUserName, + isHighContrast: isHighContrast, + outgoingTextColor: outgoingTextColor, + identityGamut: identityGamut, + identityBackgroundLuminances: identityBackgroundLuminances + ) + ) + + let tokenized = MessageLinkTokenizer.tokenize( + normalized: normalized.string, + preSpans: normalized.spans, + context: MessageLinkTokenizer.StyleContext( + baseColor: baseColor, + isOutgoing: isOutgoing, + outgoingTextColor: outgoingTextColor, + hashtagColor: hashtagColor + ) + ) + + let styled = MessageLinkStyler.style( + normalized: normalized.string, + tokens: tokenized.tokens, + baseColor: baseColor + ) + + return (styled, tokenized.mapCoordinate) + } + + /// Strips invisible and control Unicode scalars from an inbound contact name. Shared + /// entry point for the mention-tap path, which sanitizes the same way the linkifier does. + static func displayName(for name: String) -> String { + MessageTextNormalizer.displayName(for: name) + } } #Preview("Plain text") { - MessageText("Hello, world!") - .padding() + MessageText("Hello, world!") + .padding() } #Preview("With mention") { - MessageText("Hey @[Alice], check this out!") - .padding() + MessageText("Hey @[Alice], check this out!") + .padding() } #Preview("With self-mention") { - MessageText("Hey @[Me], you were mentioned!", currentUserName: "Me") - .padding() + MessageText("Hey @[Me], you were mentioned!", currentUserName: "Me") + .padding() } #Preview("With link") { - MessageText("Check out https://apple.com for more info") - .padding() + MessageText("Check out https://apple.com for more info") + .padding() } #Preview("With mention and link") { - MessageText("@[Bob] look at https://example.com/article") - .padding() + MessageText("@[Bob] look at https://example.com/article") + .padding() } #Preview("Outgoing message") { - MessageText("Visit https://github.com", baseColor: .white, isOutgoing: true) - .padding() - .background(.blue) + MessageText("Visit https://github.com", baseColor: .white, isOutgoing: true) + .padding() + .background(.blue) } #Preview("Outgoing with mention") { - MessageText("Hey @[Alice], check this out!", baseColor: .white, isOutgoing: true) - .padding() - .background(.blue) + MessageText("Hey @[Alice], check this out!", baseColor: .white, isOutgoing: true) + .padding() + .background(.blue) } #Preview("Outgoing with self-mention") { - MessageText("@[MyDevice] check this!", baseColor: .white, isOutgoing: true, currentUserName: "MyDevice") - .padding() - .background(.blue) + MessageText("@[MyDevice] check this!", baseColor: .white, isOutgoing: true, currentUserName: "MyDevice") + .padding() + .background(.blue) } #Preview("With hashtag") { - MessageText("Join #general for updates") - .padding() + MessageText("Join #general for updates") + .padding() } #Preview("With hashtag and URL") { - MessageText("Check https://example.com#anchor and #general") - .padding() + MessageText("Check https://example.com#anchor and #general") + .padding() } diff --git a/MC1/Views/Chats/Components/MessageTimestampView.swift b/MC1/Views/Chats/Components/MessageTimestampView.swift index 9419f15c..0783a8e9 100644 --- a/MC1/Views/Chats/Components/MessageTimestampView.swift +++ b/MC1/Views/Chats/Components/MessageTimestampView.swift @@ -3,20 +3,20 @@ import SwiftUI /// Centered, time-only marker shown between message clusters when a time gap /// breaks grouping. The day is carried separately by `MessageDayDividerView`. struct MessageTimestampView: View { - let date: Date + let date: Date - var body: some View { - Text(date.formatted(date: .omitted, time: .shortened)) - .font(.caption2) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - } + var body: some View { + Text(date.formatted(date: .omitted, time: .shortened)) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } } #Preview { - VStack(spacing: 20) { - MessageTimestampView(date: Date()) - MessageTimestampView(date: Date().addingTimeInterval(-3600)) // 1 hour ago - } - .padding() + VStack(spacing: 20) { + MessageTimestampView(date: Date()) + MessageTimestampView(date: Date().addingTimeInterval(-3600)) // 1 hour ago + } + .padding() } diff --git a/MC1/Views/Chats/Components/NewMessagesDividerView.swift b/MC1/Views/Chats/Components/NewMessagesDividerView.swift index 0522bab4..3c5efbf1 100644 --- a/MC1/Views/Chats/Components/NewMessagesDividerView.swift +++ b/MC1/Views/Chats/Components/NewMessagesDividerView.swift @@ -3,24 +3,24 @@ import SwiftUI /// Horizontal divider line with centered "New Messages" label. /// Rendered above the first unread message in a conversation. struct NewMessagesDividerView: View { - var body: some View { - HStack { - VStack { Divider() } - Text(L10n.Chats.Chats.Divider.newMessages) - .font(.caption2) - .bold() - .foregroundStyle(.blue) - VStack { Divider() } - } - .accessibilityLabel(L10n.Chats.Chats.Divider.newMessagesAccessibility) + var body: some View { + HStack { + VStack { Divider() } + Text(L10n.Chats.Chats.Divider.newMessages) + .font(.caption2) + .bold() + .foregroundStyle(.blue) + VStack { Divider() } } + .accessibilityLabel(L10n.Chats.Chats.Divider.newMessagesAccessibility) + } } #Preview { - VStack(spacing: 20) { - Text("Previous message") - NewMessagesDividerView() - Text("First unread message") - } - .padding() + VStack(spacing: 20) { + Text("Previous message") + NewMessagesDividerView() + Text("First unread message") + } + .padding() } diff --git a/MC1/Views/Chats/Components/PathHopRowView.swift b/MC1/Views/Chats/Components/PathHopRowView.swift index b59720fa..7e0142b0 100644 --- a/MC1/Views/Chats/Components/PathHopRowView.swift +++ b/MC1/Views/Chats/Components/PathHopRowView.swift @@ -1,112 +1,116 @@ // MC1/Views/Chats/Components/PathHopRowView.swift -import SwiftUI import MC1Services +import SwiftUI /// Type of hop in the message path. enum PathHopType { - case sender - case intermediate(Int) - case receiver + case sender + case intermediate(Int) + case receiver } /// Row displaying a single hop in the message path. struct PathHopRowView: View { - let hopType: PathHopType - let nodeName: String - let nodeID: String? - let snr: Double? - let matchKind: NodeNameMatchKind + let hopType: PathHopType + let nodeName: String + let nodeID: String? + let snr: Double? + let matchKind: NodeNameMatchKind - init( - hopType: PathHopType, - nodeName: String, - nodeID: String?, - snr: Double?, - matchKind: NodeNameMatchKind = .exact - ) { - self.hopType = hopType - self.nodeName = nodeName - self.nodeID = nodeID - self.snr = snr - self.matchKind = matchKind - } + init( + hopType: PathHopType, + nodeName: String, + nodeID: String?, + snr: Double?, + matchKind: NodeNameMatchKind = .exact + ) { + self.hopType = hopType + self.nodeName = nodeName + self.nodeID = nodeID + self.snr = snr + self.matchKind = matchKind + } - var body: some View { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - if let nodeID { - Text(nodeID) - .font(.body) - .foregroundStyle(.secondary) - .monospaced() - } + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if let nodeID { + Text(nodeID) + .font(.body) + .foregroundStyle(.secondary) + .monospaced() + } - Text(nodeName) - .font(.body) + Text(nodeName) + .font(.body) - if matchKind == .fallback { - FallbackMatchIndicatorView() - } - } + if matchKind == .fallback { + FallbackMatchIndicatorView() + } + } - Text(hopLabel) - .font(.caption) - .foregroundStyle(.secondary) - } + Text(hopLabel) + .font(.caption) + .foregroundStyle(.secondary) + } - Spacer() + Spacer() - // Show signal info only on receiver (where we have SNR) - if case .receiver = hopType, let snr { - VStack(alignment: .trailing, spacing: 2) { - Image(systemName: "cellularbars", variableValue: snrQuality.barLevel) - .foregroundStyle(snrQuality.color) + // Show signal info only on receiver (where we have SNR) + if case .receiver = hopType, let snr { + VStack(alignment: .trailing, spacing: 2) { + Image(systemName: "cellularbars", variableValue: snrQuality.barLevel) + .foregroundStyle(snrQuality.color) - Text("SNR \(snr, format: .number.precision(.fractionLength(1))) dB") - .font(.caption) - .foregroundStyle(.secondary) - } - } + Text("SNR \(snr, format: .number.precision(.fractionLength(1))) dB") + .font(.caption) + .foregroundStyle(.secondary) } - .padding(.vertical, 1) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(hopLabel): \(nodeName)") - .accessibilityValue(accessibilityValueText) + } } + .padding(.vertical, 1) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(hopLabel): \(nodeName)") + .accessibilityValue(accessibilityValueText) + } - private var hopLabel: String { - switch hopType { - case .sender: - return L10n.Chats.Chats.Path.Hop.sender - case .intermediate(let index): - return L10n.Chats.Chats.Path.Hop.number(index) - case .receiver: - return L10n.Chats.Chats.Path.Receiver.label - } + private var hopLabel: String { + switch hopType { + case .sender: + L10n.Chats.Chats.Path.Hop.sender + case let .intermediate(index): + L10n.Chats.Chats.Path.Hop.number(index) + case .receiver: + L10n.Chats.Chats.Path.Receiver.label } + } - private var accessibilityValueText: String { - var values: [String] = [] - if case .receiver = hopType, let snr { - let snrText = snr.formatted(.number.precision(.fractionLength(1))) - values.append(L10n.Chats.Chats.Path.Hop.signalQuality(signalQualityText, snrText)) - } - if let nodeID { - values.append(L10n.Chats.Chats.Path.Hop.nodeId(nodeID)) - } - return values.joined(separator: ", ") + private var accessibilityValueText: String { + var values: [String] = [] + if case .receiver = hopType, let snr { + let snrText = snr.formatted(.number.precision(.fractionLength(1))) + values.append(L10n.Chats.Chats.Path.Hop.signalQuality(signalQualityText, snrText)) } + if let nodeID { + values.append(L10n.Chats.Chats.Path.Hop.nodeId(nodeID)) + } + return values.joined(separator: ", ") + } - private var snrQuality: SNRQuality { SNRQuality(snr: snr) } + private var snrQuality: SNRQuality { + SNRQuality(snr: snr) + } - private var signalQualityText: String { snrQuality.localizedLabel } + private var signalQualityText: String { + snrQuality.localizedLabel + } } #Preview { - List { - PathHopRowView(hopType: .sender, nodeName: "AlphaNode", nodeID: "A3", snr: nil) - PathHopRowView(hopType: .intermediate(1), nodeName: "RelayNode", nodeID: "7F", snr: nil) - PathHopRowView(hopType: .receiver, nodeName: "MyDevice", nodeID: nil, snr: 6.2) - } + List { + PathHopRowView(hopType: .sender, nodeName: "AlphaNode", nodeID: "A3", snr: nil) + PathHopRowView(hopType: .intermediate(1), nodeName: "RelayNode", nodeID: "7F", snr: nil) + PathHopRowView(hopType: .receiver, nodeName: "MyDevice", nodeID: nil, snr: 6.2) + } } diff --git a/MC1/Views/Chats/Components/PreviewSkeleton.swift b/MC1/Views/Chats/Components/PreviewSkeleton.swift index 5a64e4bc..9487bd9c 100644 --- a/MC1/Views/Chats/Components/PreviewSkeleton.swift +++ b/MC1/Views/Chats/Components/PreviewSkeleton.swift @@ -8,14 +8,14 @@ import SwiftUI /// `Shimmer(isActive:)` wiring, and is accessibility-hidden because it carries /// no information of its own. struct PreviewSkeleton: View { - var cornerRadius: CGFloat = RichPreviewMetrics.cornerRadius + var cornerRadius: CGFloat = RichPreviewMetrics.cornerRadius - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceMotion) private var reduceMotion - var body: some View { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Color(.tertiarySystemFill)) - .modifier(Shimmer(isActive: !reduceMotion)) - .accessibilityHidden(true) - } + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color(.tertiarySystemFill)) + .modifier(Shimmer(isActive: !reduceMotion)) + .accessibilityHidden(true) + } } diff --git a/MC1/Views/Chats/Components/RepeatDetailsContent.swift b/MC1/Views/Chats/Components/RepeatDetailsContent.swift index 7ae7a6c1..cd59ef01 100644 --- a/MC1/Views/Chats/Components/RepeatDetailsContent.swift +++ b/MC1/Views/Chats/Components/RepeatDetailsContent.swift @@ -6,41 +6,41 @@ import SwiftUI /// Inline content for repeat details, extracted from RepeatDetailsSheet. /// Shows repeat rows, a loading spinner, or an empty state. struct RepeatDetailsContent: View { - let repeats: [MessageRepeatDTO]? - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let userLocation: CLLocation? + let repeats: [MessageRepeatDTO]? + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? - private var repeaters: [ContactDTO] { - contacts.filter { $0.type == .repeater } - } + private var repeaters: [ContactDTO] { + contacts.filter { $0.type == .repeater } + } - private var discoveredRepeaters: [DiscoveredNodeDTO] { - discoveredNodes.filter { $0.nodeType == .repeater } - } + private var discoveredRepeaters: [DiscoveredNodeDTO] { + discoveredNodes.filter { $0.nodeType == .repeater } + } - var body: some View { - if let repeats { - if repeats.isEmpty { - ContentUnavailableView( - L10n.Chats.Chats.Repeats.EmptyState.title, - systemImage: "arrow.triangle.branch", - description: Text(L10n.Chats.Chats.Repeats.EmptyState.description) - ) - } else { - ForEach(repeats) { repeatEntry in - RepeatRowView( - repeatEntry: repeatEntry, - repeaters: repeaters, - discoveredRepeaters: discoveredRepeaters, - userLocation: userLocation - ) - } - } - } else { - ProgressView() - .frame(maxWidth: .infinity, alignment: .center) - .padding() + var body: some View { + if let repeats { + if repeats.isEmpty { + ContentUnavailableView( + L10n.Chats.Chats.Repeats.EmptyState.title, + systemImage: "arrow.triangle.branch", + description: Text(L10n.Chats.Chats.Repeats.EmptyState.description) + ) + } else { + ForEach(repeats) { repeatEntry in + RepeatRowView( + repeatEntry: repeatEntry, + repeaters: repeaters, + discoveredRepeaters: discoveredRepeaters, + userLocation: userLocation + ) } + } + } else { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .padding() } + } } diff --git a/MC1/Views/Chats/Components/RepeatRowView.swift b/MC1/Views/Chats/Components/RepeatRowView.swift index 2c789d1d..38ee22de 100644 --- a/MC1/Views/Chats/Components/RepeatRowView.swift +++ b/MC1/Views/Chats/Components/RepeatRowView.swift @@ -5,112 +5,118 @@ import SwiftUI /// Row displaying a single heard repeat with repeater info and signal quality. struct RepeatRowView: View { - let repeatEntry: MessageRepeatDTO - let repeaters: [ContactDTO] - let discoveredRepeaters: [DiscoveredNodeDTO] - let userLocation: CLLocation? - - var body: some View { - HStack(alignment: .top) { - // Left side: Repeater ID + name, hop count - VStack(alignment: .leading, spacing: 2) { - HStack { - Text(repeatEntry.repeaterHashFormatted) - .font(.body) - .foregroundStyle(.secondary) - .monospaced() - Text(repeaterName) - .font(.body) - } - - Text(hopCountText) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - // Right side: Signal bars and metrics - VStack(alignment: .trailing, spacing: 2) { - Image(systemName: "cellularbars", variableValue: repeatEntry.snrLevel) - .foregroundStyle(signalColor) - - Text("SNR \(repeatEntry.snrFormatted)") - .font(.caption) - .foregroundStyle(.secondary) - - Text("RSSI \(repeatEntry.rssiFormatted)") - .font(.caption) - .foregroundStyle(.secondary) - } + let repeatEntry: MessageRepeatDTO + let repeaters: [ContactDTO] + let discoveredRepeaters: [DiscoveredNodeDTO] + let userLocation: CLLocation? + + var body: some View { + HStack(alignment: .top) { + // Left side: Repeater ID + name, hop count + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(repeatEntry.repeaterHashFormatted) + .font(.body) + .foregroundStyle(.secondary) + .monospaced() + Text(repeaterName) + .font(.body) } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Repeats.Row.accessibility(repeaterName)) - .accessibilityValue(L10n.Chats.Chats.Repeats.Row.accessibilityValue(signalQuality, repeatEntry.snrFormatted, repeatEntry.rssiFormatted)) - } - // MARK: - Helpers + Text(hopCountText) + .font(.caption) + .foregroundStyle(.secondary) + } - private var snrQuality: SNRQuality { repeatEntry.snrQuality } + Spacer() - private var signalColor: Color { snrQuality.color } + // Right side: Signal bars and metrics + VStack(alignment: .trailing, spacing: 2) { + Image(systemName: "cellularbars", variableValue: repeatEntry.snrLevel) + .foregroundStyle(signalColor) - /// Signal quality description for accessibility - private var signalQuality: String { snrQuality.localizedLabel } + Text("SNR \(repeatEntry.snrFormatted)") + .font(.caption) + .foregroundStyle(.secondary) - /// Hop count text with proper pluralization - private var hopCountText: String { - let count = repeatEntry.hopCount - return count == 1 ? L10n.Chats.Chats.Repeats.Hop.singular : L10n.Chats.Chats.Repeats.Hop.plural(count) + Text("RSSI \(repeatEntry.rssiFormatted)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Repeats.Row.accessibility(repeaterName)) + .accessibilityValue(L10n.Chats.Chats.Repeats.Row.accessibilityValue(signalQuality, repeatEntry.snrFormatted, repeatEntry.rssiFormatted)) + } + + // MARK: - Helpers + + private var snrQuality: SNRQuality { + repeatEntry.snrQuality + } + + private var signalColor: Color { + snrQuality.color + } + + /// Signal quality description for accessibility + private var signalQuality: String { + snrQuality.localizedLabel + } + + /// Hop count text with proper pluralization + private var hopCountText: String { + let count = repeatEntry.hopCount + return count == 1 ? L10n.Chats.Chats.Repeats.Hop.singular : L10n.Chats.Chats.Repeats.Hop.plural(count) + } + + /// Resolve repeater name from repeaters list or show placeholder + private var repeaterName: String { + guard let repeaterHash = repeatEntry.repeaterHash else { + return L10n.Chats.Chats.Repeats.unknownRepeater } - /// Resolve repeater name from repeaters list or show placeholder - private var repeaterName: String { - guard let repeaterHash = repeatEntry.repeaterHash else { - return L10n.Chats.Chats.Repeats.unknownRepeater - } - - if let repeater = RepeaterResolver.bestMatch(for: repeaterHash, in: repeaters, userLocation: userLocation) { - return repeater.resolvableName - } - - if let node = RepeaterResolver.bestMatch(for: repeaterHash, in: discoveredRepeaters, userLocation: userLocation) { - return node.resolvableName - } + if let repeater = RepeaterResolver.bestMatch(for: repeaterHash, in: repeaters, userLocation: userLocation) { + return repeater.resolvableName + } - return L10n.Chats.Chats.Repeats.unknownRepeater + if let node = RepeaterResolver.bestMatch(for: repeaterHash, in: discoveredRepeaters, userLocation: userLocation) { + return node.resolvableName } + + return L10n.Chats.Chats.Repeats.unknownRepeater + } } #Preview { - List { - RepeatRowView( - repeatEntry: MessageRepeatDTO( - messageID: UUID(), - receivedAt: Date(), - pathNodes: Data([0xA3]), - snr: 6.2, - rssi: -85, - rxLogEntryID: nil - ), - repeaters: [], - discoveredRepeaters: [], - userLocation: nil - ) - - RepeatRowView( - repeatEntry: MessageRepeatDTO( - messageID: UUID(), - receivedAt: Date(), - pathNodes: Data([0x7F]), - snr: 2.1, - rssi: -102, - rxLogEntryID: nil - ), - repeaters: [], - discoveredRepeaters: [], - userLocation: nil - ) - } + List { + RepeatRowView( + repeatEntry: MessageRepeatDTO( + messageID: UUID(), + receivedAt: Date(), + pathNodes: Data([0xA3]), + snr: 6.2, + rssi: -85, + rxLogEntryID: nil + ), + repeaters: [], + discoveredRepeaters: [], + userLocation: nil + ) + + RepeatRowView( + repeatEntry: MessageRepeatDTO( + messageID: UUID(), + receivedAt: Date(), + pathNodes: Data([0x7F]), + snr: 2.1, + rssi: -102, + rxLogEntryID: nil + ), + repeaters: [], + discoveredRepeaters: [], + userLocation: nil + ) + } } diff --git a/MC1/Views/Chats/Components/RichPreviewCard.swift b/MC1/Views/Chats/Components/RichPreviewCard.swift index c2ca71a7..1cc80d36 100644 --- a/MC1/Views/Chats/Components/RichPreviewCard.swift +++ b/MC1/Views/Chats/Components/RichPreviewCard.swift @@ -11,35 +11,35 @@ import SwiftUI /// use glass because they must stay readable under outdoor glare and in /// high-contrast modes. struct RichPreviewCard: View { - /// Which corners receive the card radius. - enum CornerStyle { - /// Top corners only, for a hero stacked above in-card text rows. - case top - /// No clip; the enclosing view supplies the rounding. - case none - } + /// Which corners receive the card radius. + enum CornerStyle { + /// Top corners only, for a hero stacked above in-card text rows. + case top + /// No clip; the enclosing view supplies the rounding. + case none + } - let aspect: CGFloat - let minHeight: CGFloat - let maxHeight: CGFloat - var cornerStyle: CornerStyle = .none - @ViewBuilder let content: () -> Content + let aspect: CGFloat + let minHeight: CGFloat + let maxHeight: CGFloat + var cornerStyle: CornerStyle = .none + @ViewBuilder let content: () -> Content - var body: some View { - let hero = Color.clear - .aspectRatio(aspect, contentMode: .fit) - .frame(minHeight: minHeight, maxHeight: maxHeight) - .frame(maxWidth: .infinity) - .overlay { content() } + var body: some View { + let hero = Color.clear + .aspectRatio(aspect, contentMode: .fit) + .frame(minHeight: minHeight, maxHeight: maxHeight) + .frame(maxWidth: .infinity) + .overlay { content() } - switch cornerStyle { - case .top: - hero.clipShape(.rect( - topLeadingRadius: RichPreviewMetrics.cornerRadius, - topTrailingRadius: RichPreviewMetrics.cornerRadius - )) - case .none: - hero - } + switch cornerStyle { + case .top: + hero.clipShape(.rect( + topLeadingRadius: RichPreviewMetrics.cornerRadius, + topTrailingRadius: RichPreviewMetrics.cornerRadius + )) + case .none: + hero } + } } diff --git a/MC1/Views/Chats/Components/RichPreviewMetrics.swift b/MC1/Views/Chats/Components/RichPreviewMetrics.swift index 089e15d5..02a5686f 100644 --- a/MC1/Views/Chats/Components/RichPreviewMetrics.swift +++ b/MC1/Views/Chats/Components/RichPreviewMetrics.swift @@ -7,26 +7,26 @@ import CoreGraphics /// wrappers stay per-view (they need the view's environment) and read these /// named constants as their base values. enum RichPreviewMetrics { - /// Fallback hero aspect when the source provides no intrinsic dimensions. - static let fallbackAspect: Double = 16.0 / 9.0 + /// Fallback hero aspect when the source provides no intrinsic dimensions. + static let fallbackAspect: Double = 16.0 / 9.0 - /// Lower bound for a reserved hero, so a very tall image does not collapse. - static let minHeroHeight: CGFloat = 100 + /// Lower bound for a reserved hero, so a very tall image does not collapse. + static let minHeroHeight: CGFloat = 100 - /// Upper bound for a reserved hero, so a very wide image does not dominate. - static let maxHeroHeight: CGFloat = 250 + /// Upper bound for a reserved hero, so a very wide image does not dominate. + static let maxHeroHeight: CGFloat = 250 - /// Card and hero corner radius, matching the chat map thumbnail. - static let cornerRadius: CGFloat = 12 + /// Card and hero corner radius, matching the chat map thumbnail. + static let cornerRadius: CGFloat = 12 - /// Hero aspect from intrinsic pixel dimensions, falling back to - /// `fallbackAspect` when either dimension is missing or non-positive. - /// Shared by the link card and its loading placeholder so the reserved hero - /// keeps the same shape across the byte-arrival transition. - static func heroAspect(imageWidth: Int?, imageHeight: Int?) -> CGFloat { - guard let imageWidth, let imageHeight, imageWidth > 0, imageHeight > 0 else { - return CGFloat(fallbackAspect) - } - return CGFloat(imageWidth) / CGFloat(imageHeight) + /// Hero aspect from intrinsic pixel dimensions, falling back to + /// `fallbackAspect` when either dimension is missing or non-positive. + /// Shared by the link card and its loading placeholder so the reserved hero + /// keeps the same shape across the byte-arrival transition. + static func heroAspect(imageWidth: Int?, imageHeight: Int?) -> CGFloat { + guard let imageWidth, let imageHeight, imageWidth > 0, imageHeight > 0 else { + return CGFloat(fallbackAspect) } + return CGFloat(imageWidth) / CGFloat(imageHeight) + } } diff --git a/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift b/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift index fa6de1b6..3bc6d933 100644 --- a/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift +++ b/MC1/Views/Chats/Components/ScrollState/ChatScrollState.swift @@ -1,76 +1,90 @@ import Foundation /// Whether the user is actively driving the scroll surface. -enum InteractionState: Equatable, Sendable { - case idle - case dragging +enum InteractionState: Equatable { + case idle + case dragging } /// What the controller wants the scroll surface to do, if anything. -enum ScrollIntent: Equatable, Sendable { - case none - case toBottom - case toTarget(id: UUID) +enum ScrollIntent: Equatable { + case none + case toBottom + case toTarget(id: UUID) } /// Whether a snapshot apply is in flight. -enum ApplyState: Equatable, Sendable { - case idle - case applying +enum ApplyState: Equatable { + case idle + case applying } /// Captures a scroll-to-bottom request that was deferred because the user was /// dragging. Coexists with InteractionState.dragging. Resolved when interaction /// ends. -struct DeferredScroll: Equatable, Sendable { - let targetMessageCount: Int +struct DeferredScroll: Equatable { + let targetMessageCount: Int } /// Container that bundles the three axes plus DeferredScroll for atomic /// observation. Pure value-type; mutations go through helper methods. -struct ChatScrollState: Equatable, Sendable { - var interaction: InteractionState - var intent: ScrollIntent - var apply: ApplyState - var deferredScroll: DeferredScroll? - - static let idle = ChatScrollState( - interaction: .idle, intent: .none, apply: .idle, deferredScroll: nil - ) - - /// True when the controller should not start a new scroll intent because - /// the user is dragging. The dragging axis is independent of intent — - /// existing intents continue. - var isUserDriven: Bool { interaction == .dragging } - - /// True when any programmatic scroll is in flight. Coexists with - /// interaction.dragging (e.g., reloadTargetCell applies a snapshot during - /// scroll-to-target). - var isApplyingSnapshot: Bool { apply == .applying } +struct ChatScrollState: Equatable { + var interaction: InteractionState + var intent: ScrollIntent + var apply: ApplyState + var deferredScroll: DeferredScroll? + + static let idle = ChatScrollState( + interaction: .idle, intent: .none, apply: .idle, deferredScroll: nil + ) + + /// True when the controller should not start a new scroll intent because + /// the user is dragging. The dragging axis is independent of intent — + /// existing intents continue. + var isUserDriven: Bool { + interaction == .dragging + } + + /// True when any programmatic scroll is in flight. Coexists with + /// interaction.dragging (e.g., reloadTargetCell applies a snapshot during + /// scroll-to-target). + var isApplyingSnapshot: Bool { + apply == .applying + } } extension ChatScrollState { - mutating func enterDragging() { interaction = .dragging } + mutating func enterDragging() { + interaction = .dragging + } + + mutating func endDragging() { + interaction = .idle + } + + mutating func startApplying() { + apply = .applying + } - mutating func endDragging() { - interaction = .idle - } + mutating func endApplying() { + apply = .idle + } - mutating func startApplying() { apply = .applying } - mutating func endApplying() { apply = .idle } + mutating func startIntent(_ next: ScrollIntent) { + intent = next + } - mutating func startIntent(_ next: ScrollIntent) { - intent = next - } - mutating func clearIntent() { intent = .none } + mutating func clearIntent() { + intent = .none + } - /// Stores a scroll request to replay once the user stops dragging. - mutating func scheduleDeferredScroll(_ scroll: DeferredScroll) { - deferredScroll = scroll - } + /// Stores a scroll request to replay once the user stops dragging. + mutating func scheduleDeferredScroll(_ scroll: DeferredScroll) { + deferredScroll = scroll + } - mutating func consumeDeferredScroll() -> DeferredScroll? { - defer { deferredScroll = nil } - return deferredScroll - } + mutating func consumeDeferredScroll() -> DeferredScroll? { + defer { deferredScroll = nil } + return deferredScroll + } } diff --git a/MC1/Views/Chats/Components/ScrollToBottomButton.swift b/MC1/Views/Chats/Components/ScrollToBottomButton.swift index f75aee31..8404fb36 100644 --- a/MC1/Views/Chats/Components/ScrollToBottomButton.swift +++ b/MC1/Views/Chats/Components/ScrollToBottomButton.swift @@ -2,42 +2,42 @@ import SwiftUI /// Button to scroll to latest message with unread badge struct ScrollToBottomButton: View { - let isVisible: Bool - let unreadCount: Int - let onTap: () -> Void + let isVisible: Bool + let unreadCount: Int + let onTap: () -> Void - var body: some View { - Button(action: onTap) { - Image(systemName: "chevron.down") - .font(.body.bold()) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .contentShape(.circle) - .liquidGlassInteractive(in: .circle) - .overlay(alignment: .topTrailing) { - UnreadBadge(count: unreadCount, tint: .blue) - } - .opacity(isVisible ? 1 : 0) - .scaleEffect(isVisible ? 1 : 0.5) - .animation(.snappy(duration: 0.2), value: isVisible) - .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToBottom.accessibilityLabel) - .accessibilityValue(unreadCount > 0 ? L10n.Chats.Chats.ScrollButton.ScrollToBottom.accessibilityValue(unreadCount) : "") - .accessibilityHidden(!isVisible) + var body: some View { + Button(action: onTap) { + Image(systemName: "chevron.down") + .font(.body.bold()) + .frame(width: 44, height: 44) } + .buttonStyle(.plain) + .contentShape(.circle) + .liquidGlassInteractive(in: .circle) + .overlay(alignment: .topTrailing) { + UnreadBadge(count: unreadCount, tint: .blue) + } + .opacity(isVisible ? 1 : 0) + .scaleEffect(isVisible ? 1 : 0.5) + .animation(.snappy(duration: 0.2), value: isVisible) + .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToBottom.accessibilityLabel) + .accessibilityValue(unreadCount > 0 ? L10n.Chats.Chats.ScrollButton.ScrollToBottom.accessibilityValue(unreadCount) : "") + .accessibilityHidden(!isVisible) + } } #Preview("Visible with unread") { - ScrollToBottomButton(isVisible: true, unreadCount: 5, onTap: {}) - .padding(50) + ScrollToBottomButton(isVisible: true, unreadCount: 5, onTap: {}) + .padding(50) } #Preview("Visible no unread") { - ScrollToBottomButton(isVisible: true, unreadCount: 0, onTap: {}) - .padding(50) + ScrollToBottomButton(isVisible: true, unreadCount: 0, onTap: {}) + .padding(50) } #Preview("Hidden") { - ScrollToBottomButton(isVisible: false, unreadCount: 3, onTap: {}) - .padding(50) + ScrollToBottomButton(isVisible: false, unreadCount: 3, onTap: {}) + .padding(50) } diff --git a/MC1/Views/Chats/Components/ScrollToDividerButton.swift b/MC1/Views/Chats/Components/ScrollToDividerButton.swift index cb218c45..04a85f0b 100644 --- a/MC1/Views/Chats/Components/ScrollToDividerButton.swift +++ b/MC1/Views/Chats/Components/ScrollToDividerButton.swift @@ -2,23 +2,23 @@ import SwiftUI /// Button to scroll to the new messages divider struct ScrollToDividerButton: View { - let onTap: () -> Void + let onTap: () -> Void - var body: some View { - Button(action: onTap) { - Image(systemName: "chevron.up") - .font(.body.bold()) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .contentShape(.circle) - .liquidGlassInteractive(in: .circle) - .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToDivider.accessibilityLabel) - .accessibilityHint(L10n.Chats.Chats.ScrollButton.ScrollToDivider.accessibilityHint) + var body: some View { + Button(action: onTap) { + Image(systemName: "chevron.up") + .font(.body.bold()) + .frame(width: 44, height: 44) } + .buttonStyle(.plain) + .contentShape(.circle) + .liquidGlassInteractive(in: .circle) + .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToDivider.accessibilityLabel) + .accessibilityHint(L10n.Chats.Chats.ScrollButton.ScrollToDivider.accessibilityHint) + } } #Preview { - ScrollToDividerButton(onTap: {}) - .padding(50) + ScrollToDividerButton(onTap: {}) + .padding(50) } diff --git a/MC1/Views/Chats/Components/ScrollToMentionButton.swift b/MC1/Views/Chats/Components/ScrollToMentionButton.swift index b866ea6d..3179bd2b 100644 --- a/MC1/Views/Chats/Components/ScrollToMentionButton.swift +++ b/MC1/Views/Chats/Components/ScrollToMentionButton.swift @@ -2,33 +2,33 @@ import SwiftUI /// Button to scroll to unread mentions struct ScrollToMentionButton: View { - let unreadMentionCount: Int - let onTap: () -> Void + let unreadMentionCount: Int + let onTap: () -> Void - var body: some View { - Button(action: onTap) { - Image(systemName: "at") - .font(.body.bold()) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .contentShape(.circle) - .liquidGlassInteractive(in: .circle) - .overlay(alignment: .topTrailing) { - UnreadBadge(count: unreadMentionCount, tint: .red) - } - .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityLabel) - .accessibilityValue(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityValue(unreadMentionCount)) - .accessibilityHint(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityHint) + var body: some View { + Button(action: onTap) { + Image(systemName: "at") + .font(.body.bold()) + .frame(width: 44, height: 44) } + .buttonStyle(.plain) + .contentShape(.circle) + .liquidGlassInteractive(in: .circle) + .overlay(alignment: .topTrailing) { + UnreadBadge(count: unreadMentionCount, tint: .red) + } + .accessibilityLabel(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityLabel) + .accessibilityValue(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityValue(unreadMentionCount)) + .accessibilityHint(L10n.Chats.Chats.ScrollButton.ScrollToMention.accessibilityHint) + } } #Preview("With multiple") { - ScrollToMentionButton(unreadMentionCount: 5, onTap: {}) - .padding(50) + ScrollToMentionButton(unreadMentionCount: 5, onTap: {}) + .padding(50) } #Preview("With one") { - ScrollToMentionButton(unreadMentionCount: 1, onTap: {}) - .padding(50) + ScrollToMentionButton(unreadMentionCount: 1, onTap: {}) + .padding(50) } diff --git a/MC1/Views/Chats/Components/Shimmer.swift b/MC1/Views/Chats/Components/Shimmer.swift index 3f55b704..dccffed3 100644 --- a/MC1/Views/Chats/Components/Shimmer.swift +++ b/MC1/Views/Chats/Components/Shimmer.swift @@ -3,39 +3,39 @@ import SwiftUI /// Animated linear-gradient overlay driven by `TimelineView(.animation)` so /// off-screen reused cells naturally pause. Static when Reduce Motion is on. struct Shimmer: ViewModifier { - let isActive: Bool + let isActive: Bool - private static let duration: Double = 1.5 - private static let highlightOpacity: Double = 0.35 - private static let frameInterval: Double = 1.0 / 60.0 + private static let duration: Double = 1.5 + private static let highlightOpacity: Double = 0.35 + private static let frameInterval: Double = 1.0 / 60.0 - func body(content: Content) -> some View { - if isActive { - content - .overlay { - TimelineView(.animation(minimumInterval: Self.frameInterval)) { context in - let elapsed = context.date.timeIntervalSinceReferenceDate - let progress = elapsed.truncatingRemainder(dividingBy: Self.duration) / Self.duration - let phase = CGFloat(progress) * 2 - 1 - GeometryReader { proxy in - let width = proxy.size.width - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .white.opacity(Self.highlightOpacity), location: 0.5), - .init(color: .clear, location: 1) - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: width) - .offset(x: phase * width) - } - } - } - .clipped() - } else { - content + func body(content: Content) -> some View { + if isActive { + content + .overlay { + TimelineView(.animation(minimumInterval: Self.frameInterval)) { context in + let elapsed = context.date.timeIntervalSinceReferenceDate + let progress = elapsed.truncatingRemainder(dividingBy: Self.duration) / Self.duration + let phase = CGFloat(progress) * 2 - 1 + GeometryReader { proxy in + let width = proxy.size.width + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .white.opacity(Self.highlightOpacity), location: 0.5), + .init(color: .clear, location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: width) + .offset(x: phase * width) + } + } } + .clipped() + } else { + content } + } } diff --git a/MC1/Views/Chats/Components/TapToLoadPreview.swift b/MC1/Views/Chats/Components/TapToLoadPreview.swift index f0a84e10..bb3ca333 100644 --- a/MC1/Views/Chats/Components/TapToLoadPreview.swift +++ b/MC1/Views/Chats/Components/TapToLoadPreview.swift @@ -2,43 +2,43 @@ import SwiftUI /// Placeholder shown when auto-resolve is disabled but previews are enabled struct TapToLoadPreview: View { - let url: URL - let isLoading: Bool - let onTap: () -> Void + let url: URL + let isLoading: Bool + let onTap: () -> Void - var body: some View { - HStack(spacing: 8) { - if isLoading { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "globe") - .foregroundStyle(.secondary) - } + var body: some View { + HStack(spacing: 8) { + if isLoading { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "globe") + .foregroundStyle(.secondary) + } - Text(isLoading ? L10n.Chats.Chats.Preview.loading : L10n.Chats.Chats.Preview.tapToLoad) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(.regularMaterial, in: .rect(cornerRadius: 12)) - .contentShape(Rectangle()) - .tapYieldingToLongPress { if !isLoading { onTap() } } - .accessibilityLabel(isLoading ? L10n.Chats.Chats.Preview.loadingAccessibility(url.host ?? "link") : L10n.Chats.Chats.Preview.tapAccessibility(url.host ?? "link")) - .accessibilityHint(isLoading ? L10n.Chats.Chats.Preview.loadingHint : L10n.Chats.Chats.Preview.tapHint) - .accessibilityAddTraits(.isButton) - .accessibilityAction { if !isLoading { onTap() } } - .disabled(isLoading) + Text(isLoading ? L10n.Chats.Chats.Preview.loading : L10n.Chats.Chats.Preview.tapToLoad) + .font(.subheadline) + .foregroundStyle(.secondary) } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.regularMaterial, in: .rect(cornerRadius: 12)) + .contentShape(Rectangle()) + .tapYieldingToLongPress { if !isLoading { onTap() } } + .accessibilityLabel(isLoading ? L10n.Chats.Chats.Preview.loadingAccessibility(url.host ?? "link") : L10n.Chats.Chats.Preview.tapAccessibility(url.host ?? "link")) + .accessibilityHint(isLoading ? L10n.Chats.Chats.Preview.loadingHint : L10n.Chats.Chats.Preview.tapHint) + .accessibilityAddTraits(.isButton) + .accessibilityAction { if !isLoading { onTap() } } + .disabled(isLoading) + } } #Preview("Idle") { - TapToLoadPreview(url: URL(string: "https://example.com")!, isLoading: false, onTap: {}) - .padding() + TapToLoadPreview(url: URL(string: "https://example.com")!, isLoading: false, onTap: {}) + .padding() } #Preview("Loading") { - TapToLoadPreview(url: URL(string: "https://example.com")!, isLoading: true, onTap: {}) - .padding() + TapToLoadPreview(url: URL(string: "https://example.com")!, isLoading: true, onTap: {}) + .padding() } diff --git a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift index e99a0cf5..b1e65ecb 100644 --- a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Two-layer drop shadow shown only while the bubble is lifted: a tight contact /// shadow under the bubble plus a softer ambient one for depth. @@ -22,319 +22,319 @@ private let bubbleRowOppositeEdgeMinInset: CGFloat = 40 /// render-affecting input is encoded into `MessageItem` during /// `rebuildDisplayItem`. struct UnifiedMessageBubble: View, Equatable { - let message: MessageDTO - let contactName: String - let deviceName: String - let configuration: MessageBubbleConfiguration - let item: MessageItem - /// Box-vs-sibling partition derived once in `MessageBubbleView.body`. - /// Excluded from `==` (which stays `item`-only): it is a pure function of - /// `item.content`, so equal items yield equal layouts. - let layout: FragmentLayout - let imageResolver: (ImageReference) -> UIImage? - let callbacks: MessageBubbleCallbacks + let message: MessageDTO + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + let item: MessageItem + /// Box-vs-sibling partition derived once in `MessageBubbleView.body`. + /// Excluded from `==` (which stays `item`-only): it is a pure function of + /// `item.content`, so equal items yield equal layouts. + let layout: FragmentLayout + let imageResolver: (ImageReference) -> UIImage? + let callbacks: MessageBubbleCallbacks - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.colorScheme) private var colorScheme - @Environment(\.openURL) private var openURL - @Environment(\.appTheme) private var theme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.colorScheme) private var colorScheme + @Environment(\.openURL) private var openURL + @Environment(\.appTheme) private var theme - @State private var showingReactionDetails = false - @State private var isLongPressing = false - @State private var longPressTrigger = 0 + @State private var showingReactionDetails = false + @State private var isLongPressing = false + @State private var longPressTrigger = 0 - nonisolated static func == (lhs: UnifiedMessageBubble, rhs: UnifiedMessageBubble) -> Bool { - lhs.item == rhs.item - } + nonisolated static func == (lhs: UnifiedMessageBubble, rhs: UnifiedMessageBubble) -> Bool { + lhs.item == rhs.item + } - init( - message: MessageDTO, - contactName: String, - deviceName: String = "Me", - configuration: MessageBubbleConfiguration, - item: MessageItem, - layout: FragmentLayout, - imageResolver: @escaping (ImageReference) -> UIImage? = { _ in nil }, - callbacks: MessageBubbleCallbacks = .init() - ) { - self.message = message - self.contactName = contactName - self.deviceName = deviceName - self.configuration = configuration - self.item = item - self.layout = layout - self.imageResolver = imageResolver - self.callbacks = callbacks - } + init( + message: MessageDTO, + contactName: String, + deviceName: String = "Me", + configuration: MessageBubbleConfiguration, + item: MessageItem, + layout: FragmentLayout, + imageResolver: @escaping (ImageReference) -> UIImage? = { _ in nil }, + callbacks: MessageBubbleCallbacks = .init() + ) { + self.message = message + self.contactName = contactName + self.deviceName = deviceName + self.configuration = configuration + self.item = item + self.layout = layout + self.imageResolver = imageResolver + self.callbacks = callbacks + } - var body: some View { - VStack(spacing: 0) { - if item.grouping.showNewMessagesDivider { - NewMessagesDividerView() - .padding(.bottom, 4) - } + var body: some View { + VStack(spacing: 0) { + if item.grouping.showNewMessagesDivider { + NewMessagesDividerView() + .padding(.bottom, 4) + } - if item.grouping.showDayDivider { - MessageDayDividerView(date: item.envelope.date) - } + if item.grouping.showDayDivider { + MessageDayDividerView(date: item.envelope.date) + } - // The day divider already anchors the cluster in time, so a time-only - // marker directly beneath it would be redundant; suppress it there. - if item.grouping.showTimestamp && !item.grouping.showDayDivider { - MessageTimestampView(date: item.envelope.date) - } + // The day divider already anchors the cluster in time, so a time-only + // marker directly beneath it would be redundant; suppress it there. + if item.grouping.showTimestamp, !item.grouping.showDayDivider { + MessageTimestampView(date: item.envelope.date) + } - HStack(alignment: .bottom, spacing: 4) { - if item.envelope.isOutgoing { - Spacer(minLength: bubbleRowOppositeEdgeMinInset) - } - - VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 0) { - if !item.envelope.isOutgoing - && configuration.showSenderName - && item.grouping.showSenderName { - HStack(spacing: 4) { - Text(item.envelope.senderName) - .font(.footnote) - .bold() - .foregroundStyle(senderColor) + HStack(alignment: .bottom, spacing: 4) { + if item.envelope.isOutgoing { + Spacer(minLength: bubbleRowOppositeEdgeMinInset) + } - if item.envelope.senderResolution.isFallback { - FallbackMatchIndicatorView() - } - } - } + VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 0) { + if !item.envelope.isOutgoing, + configuration.showSenderName, + item.grouping.showSenderName { + HStack(spacing: 4) { + Text(item.envelope.senderName) + .font(.footnote) + .bold() + .foregroundStyle(senderColor) - bubbleActionsLongPress( - BubbleFragmentStack( - item: item, - layout: layout, - bubbleColor: resolvedBubbleColor, - callbacks: callbacks, - imageResolver: imageResolver - ) - .shadow( - color: Color.black.opacity(isLongPressing ? liftContactShadowOpacity : 0), - radius: liftContactShadowRadius, - x: 0, - y: liftContactShadowYOffset - ) - .shadow( - color: Color.black.opacity(isLongPressing ? liftAmbientShadowOpacity : 0), - radius: liftAmbientShadowRadius, - x: 0, - y: liftAmbientShadowYOffset - ) - ) + if item.envelope.senderResolution.isFallback { + FallbackMatchIndicatorView() + } + } + } - ForEach(Array(layout.siblings.enumerated()), id: \.offset) { _, fragment in - siblingFragmentView(fragment) - } + bubbleActionsLongPress( + BubbleFragmentStack( + item: item, + layout: layout, + bubbleColor: resolvedBubbleColor, + callbacks: callbacks, + imageResolver: imageResolver + ) + .shadow( + color: Color.black.opacity(isLongPressing ? liftContactShadowOpacity : 0), + radius: liftContactShadowRadius, + x: 0, + y: liftContactShadowYOffset + ) + .shadow( + color: Color.black.opacity(isLongPressing ? liftAmbientShadowOpacity : 0), + radius: liftAmbientShadowRadius, + x: 0, + y: liftAmbientShadowYOffset + ) + ) - if item.footer.showStatusRow { - BubbleStatusRow(item: item, onRetry: callbacks.onRetry) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityMessageLabel) - .accessibilityAction { - callbacks.onLongPress?() - } - .accessibilityActions { - if item.footer.showStatusRow, - item.footer.status == .failed, - let onRetry = callbacks.onRetry { - Button(L10n.Chats.Chats.Message.Action.retry) { onRetry() } - } - if hasReactionSummary { - Button(L10n.Chats.Chats.Message.Action.viewReactions) { - showingReactionDetails = true - } - } - ForEach(MessageLinkAccessibility.actions( - previewURL: linkPreviewURL, - formatted: layout.textPayload?.formatted - )) { action in - Button(action.name) { openURL(action.url) } - } - if let inline = inlineImage { - switch inline.state { - case .loaded: - if let onImageTap = callbacks.onImageTap { - Button(L10n.Chats.Chats.Message.Action.viewImage) { onImageTap() } - } - case .failed: - if let onRetryInlineImage = callbacks.onRetryInlineImage { - Button(L10n.Chats.Chats.Message.Action.retryImage) { onRetryInlineImage() } - } - case .loading, .idle: - EmptyView() - } - } - } - .messageBubbleLongPressEffect(isPressing: isLongPressing, trigger: longPressTrigger) + ForEach(Array(layout.siblings.enumerated()), id: \.offset) { _, fragment in + siblingFragmentView(fragment) + } - if !item.envelope.isOutgoing { - Spacer(minLength: bubbleRowOppositeEdgeMinInset) - } - } + if item.footer.showStatusRow { + BubbleStatusRow(item: item, onRetry: callbacks.onRetry) + } } - .padding(.horizontal, 12) - .padding(.top, paddingTop) - .padding(.bottom, 0) - // Keyed on `previewFetchTaskID` rather than `.onAppear`: the URL that - // satisfies the fetch precondition is detected asynchronously and lands - // as a reconfigure after the cell has already appeared, so a one-shot - // appear trigger never starts the fetch. The id is message-scoped so - // cell reuse can't drop the trigger. - .task(id: item.previewFetchTaskID) { - if item.shouldRequestPreviewFetch { - callbacks.onRequestPreviewFetch?() + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityMessageLabel) + .accessibilityAction { + callbacks.onLongPress?() + } + .accessibilityActions { + if item.footer.showStatusRow, + item.footer.status == .failed, + let onRetry = callbacks.onRetry { + Button(L10n.Chats.Chats.Message.Action.retry) { onRetry() } + } + if hasReactionSummary { + Button(L10n.Chats.Chats.Message.Action.viewReactions) { + showingReactionDetails = true + } + } + ForEach(MessageLinkAccessibility.actions( + previewURL: linkPreviewURL, + formatted: layout.textPayload?.formatted + )) { action in + Button(action.name) { openURL(action.url) } + } + if let inline = inlineImage { + switch inline.state { + case .loaded: + if let onImageTap = callbacks.onImageTap { + Button(L10n.Chats.Chats.Message.Action.viewImage) { onImageTap() } + } + case .failed: + if let onRetryInlineImage = callbacks.onRetryInlineImage { + Button(L10n.Chats.Chats.Message.Action.retryImage) { onRetryInlineImage() } + } + case .loading, .idle: + EmptyView() } + } } - .sheet(isPresented: $showingReactionDetails) { - ReactionDetailsSheet(messageID: message.id) + .messageBubbleLongPressEffect(isPressing: isLongPressing, trigger: longPressTrigger) + + if !item.envelope.isOutgoing { + Spacer(minLength: bubbleRowOppositeEdgeMinInset) } + } } - - /// Applies the bubble's actions-sheet long-press: a sustained press fires `onLongPress`, drives - /// the lift, and bumps the haptic trigger. Shared by the box and the content-card siblings so a - /// press anywhere on the bubble opens the same sheet. - private func bubbleActionsLongPress(_ content: some View) -> some View { - content.messageBubbleLongPressGesture( - isPressing: $isLongPressing, - trigger: $longPressTrigger, - onFire: { callbacks.onLongPress?() } - ) + .padding(.horizontal, 12) + .padding(.top, paddingTop) + .padding(.bottom, 0) + // Keyed on `previewFetchTaskID` rather than `.onAppear`: the URL that + // satisfies the fetch precondition is detected asynchronously and lands + // as a reconfigure after the cell has already appeared, so a one-shot + // appear trigger never starts the fetch. The id is message-scoped so + // cell reuse can't drop the trigger. + .task(id: item.previewFetchTaskID) { + if item.shouldRequestPreviewFetch { + callbacks.onRequestPreviewFetch?() + } } - - /// Renders one fragment from `layout.siblings` (reactions, malware warning, link preview, map - /// preview). Content cards carry the bubble's long-press so a press anywhere opens the actions - /// sheet; reactions keep their own. The text and inline-image kinds never reach the sibling list - /// (they render inside `BubbleFragmentStack`), so their arm exists only to keep the switch - /// exhaustive. - @ViewBuilder - private func siblingFragmentView(_ fragment: MessageFragment) -> some View { - let content = siblingFragmentBody(fragment) - if Self.siblingWantsActionsLongPress(fragment) { - bubbleActionsLongPress(content) - } else { - content - } + .sheet(isPresented: $showingReactionDetails) { + ReactionDetailsSheet(messageID: message.id) } + } - @ViewBuilder - private func siblingFragmentBody(_ fragment: MessageFragment) -> some View { - switch fragment { - case .reactionSummary(let summary): - ReactionsFragmentView( - summary: summary, - onTapReaction: { emoji in callbacks.onReaction?(emoji) }, - onLongPress: { showingReactionDetails = true } - ) - case .malwareWarning(let url): - MalwareWarningCard(url: url) - case .linkPreview(let state): - LinkPreviewFragmentView( - state: state, - imageResolver: imageResolver, - onManualPreviewFetch: callbacks.onManualPreviewFetch - ) - case .mapPreview(let state): - MapPreviewFragmentView( - state: state, - snapshotResolver: { callbacks.snapshotResolver?($0) }, - onTap: { callbacks.onMapPreviewTap?($0) }, - onRequestSnapshot: { callbacks.requestSnapshot?($0) }, - onRetry: { callbacks.retrySnapshot?($0) } - ) - case .text, .inlineImage: - EmptyView() - } - } + /// Applies the bubble's actions-sheet long-press: a sustained press fires `onLongPress`, drives + /// the lift, and bumps the haptic trigger. Shared by the box and the content-card siblings so a + /// press anywhere on the bubble opens the same sheet. + private func bubbleActionsLongPress(_ content: some View) -> some View { + content.messageBubbleLongPressGesture( + isPressing: $isLongPressing, + trigger: $longPressTrigger, + onFire: { callbacks.onLongPress?() } + ) + } - /// Whether a sibling fragment carries the bubble's actions-sheet long-press. Content cards - /// (link, map, malware) do, so a press anywhere on the bubble opens the sheet. Reactions keep - /// their own long-press; text and inline image render in the box, which already carries it. - static func siblingWantsActionsLongPress(_ fragment: MessageFragment) -> Bool { - switch fragment { - case .linkPreview, .mapPreview, .malwareWarning: - return true - case .reactionSummary, .text, .inlineImage: - return false - } + /// Renders one fragment from `layout.siblings` (reactions, malware warning, link preview, map + /// preview). Content cards carry the bubble's long-press so a press anywhere opens the actions + /// sheet; reactions keep their own. The text and inline-image kinds never reach the sibling list + /// (they render inside `BubbleFragmentStack`), so their arm exists only to keep the switch + /// exhaustive. + @ViewBuilder + private func siblingFragmentView(_ fragment: MessageFragment) -> some View { + let content = siblingFragmentBody(fragment) + if Self.siblingWantsActionsLongPress(fragment) { + bubbleActionsLongPress(content) + } else { + content } + } - // MARK: - Computed Properties + @ViewBuilder + private func siblingFragmentBody(_ fragment: MessageFragment) -> some View { + switch fragment { + case let .reactionSummary(summary): + ReactionsFragmentView( + summary: summary, + onTapReaction: { emoji in callbacks.onReaction?(emoji) }, + onLongPress: { showingReactionDetails = true } + ) + case let .malwareWarning(url): + MalwareWarningCard(url: url) + case let .linkPreview(state): + LinkPreviewFragmentView( + state: state, + imageResolver: imageResolver, + onManualPreviewFetch: callbacks.onManualPreviewFetch + ) + case let .mapPreview(state): + MapPreviewFragmentView( + state: state, + snapshotResolver: { callbacks.snapshotResolver?($0) }, + onTap: { callbacks.onMapPreviewTap?($0) }, + onRequestSnapshot: { callbacks.requestSnapshot?($0) }, + onRetry: { callbacks.retrySnapshot?($0) } + ) + case .text, .inlineImage: + EmptyView() + } + } - private var resolvedBubbleColor: Color { - if item.envelope.isOutgoing { - if item.envelope.hasFailed { - return AppColors.Message.outgoingBubbleFailed( - highContrast: colorSchemeContrast == .increased - ) - } - return theme.accentColor - } - return theme.incomingBubbleColor + /// Whether a sibling fragment carries the bubble's actions-sheet long-press. Content cards + /// (link, map, malware) do, so a press anywhere on the bubble opens the sheet. Reactions keep + /// their own long-press; text and inline image render in the box, which already carries it. + static func siblingWantsActionsLongPress(_ fragment: MessageFragment) -> Bool { + switch fragment { + case .linkPreview, .mapPreview, .malwareWarning: + true + case .reactionSummary, .text, .inlineImage: + false } + } - private var senderColor: Color { - theme.identityColor( - forName: item.envelope.senderName, - colorScheme: colorScheme, - contrast: colorSchemeContrast + // MARK: - Computed Properties + + private var resolvedBubbleColor: Color { + if item.envelope.isOutgoing { + if item.envelope.hasFailed { + return AppColors.Message.outgoingBubbleFailed( + highContrast: colorSchemeContrast == .increased ) + } + return theme.accentColor } + return theme.incomingBubbleColor + } - private var paddingTop: CGFloat { - if item.grouping.showDirectionGap { return 6 } - if item.grouping.showSenderName { return 4 } - return item.envelope.isOutgoing ? 1 : 2 - } + private var senderColor: Color { + theme.identityColor( + forName: item.envelope.senderName, + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + } - var accessibilityMessageLabel: String { - var label = "" - if !item.envelope.isOutgoing && configuration.showSenderName { - label = "\(item.envelope.senderName): " - if item.envelope.senderResolution.isFallback { - label += "\(L10n.Chats.Chats.Message.Sender.possibleMatch), " - } - } - label += message.text - if item.envelope.isOutgoing { - label += ", \(BubbleStatusRow.statusText(for: item))" - } - if !item.envelope.isOutgoing { - if item.footer.showHop { - label += ", \(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(item.footer.hopCount))" - } - if let formattedPath = item.footer.formattedPath { - label += ", \(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath))" - } - if let region = item.footer.regionToShow { - label += ", \(L10n.Chats.Chats.Message.Region.accessibilityLabel(region))" - } - } - return label + private var paddingTop: CGFloat { + if item.grouping.showDirectionGap { return 6 } + if item.grouping.showSenderName { return 4 } + return item.envelope.isOutgoing ? 1 : 2 + } + + var accessibilityMessageLabel: String { + var label = "" + if !item.envelope.isOutgoing, configuration.showSenderName { + label = "\(item.envelope.senderName): " + if item.envelope.senderResolution.isFallback { + label += "\(L10n.Chats.Chats.Message.Sender.possibleMatch), " + } } + label += message.text + if item.envelope.isOutgoing { + label += ", \(BubbleStatusRow.statusText(for: item))" + } + if !item.envelope.isOutgoing { + if item.footer.showHop { + label += ", \(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(item.footer.hopCount))" + } + if let formattedPath = item.footer.formattedPath { + label += ", \(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath))" + } + if let region = item.footer.regionToShow { + label += ", \(L10n.Chats.Chats.Message.Region.accessibilityLabel(region))" + } + } + return label + } } private extension UnifiedMessageBubble { - var inlineImage: InlineImage? { - layout.inlineImage - } + var inlineImage: InlineImage? { + layout.inlineImage + } - var hasReactionSummary: Bool { - layout.siblings.contains { if case .reactionSummary = $0 { return true } else { return false } } - } + var hasReactionSummary: Bool { + layout.siblings.contains { if case .reactionSummary = $0 { true } else { false } } + } - var linkPreviewURL: URL? { - for fragment in layout.siblings { - if case .linkPreview(let state) = fragment { - return state.primaryURL - } - } - return nil + var linkPreviewURL: URL? { + for fragment in layout.siblings { + if case let .linkPreview(state) = fragment { + return state.primaryURL + } } + return nil + } } diff --git a/MC1/Views/Chats/Components/UnreadBadge.swift b/MC1/Views/Chats/Components/UnreadBadge.swift index d643e352..dbc74b73 100644 --- a/MC1/Views/Chats/Components/UnreadBadge.swift +++ b/MC1/Views/Chats/Components/UnreadBadge.swift @@ -4,21 +4,21 @@ import SwiftUI /// scroll-to-bottom (unread) and scroll-to-mention buttons; only the count and /// tint differ between them. struct UnreadBadge: View { - let count: Int - let tint: Color + let count: Int + let tint: Color - /// Counts above this render as an overflow glyph rather than the exact number. - static let badgeOverflowThreshold = 99 + /// Counts above this render as an overflow glyph rather than the exact number. + static let badgeOverflowThreshold = 99 - var body: some View { - if count > 0 { - Text(count > Self.badgeOverflowThreshold ? L10n.Chats.Chats.ScrollButton.Badge.overflow : "\(count)") - .font(.caption2.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(tint, in: .capsule) - .offset(x: 8, y: -8) - } + var body: some View { + if count > 0 { + Text(count > Self.badgeOverflowThreshold ? L10n.Chats.Chats.ScrollButton.Badge.overflow : "\(count)") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(tint, in: .capsule) + .offset(x: 8, y: -8) } + } } diff --git a/MC1/Views/Chats/ContactMatchRow.swift b/MC1/Views/Chats/ContactMatchRow.swift index 18a91992..b04a12a7 100644 --- a/MC1/Views/Chats/ContactMatchRow.swift +++ b/MC1/Views/Chats/ContactMatchRow.swift @@ -4,119 +4,119 @@ import SwiftUI /// Reusable row showing a contact matched by a channel sender's name. struct ContactMatchRow: View { - enum SelectionStyle { - case toggle(isSelected: Bool) - case tap - } + enum SelectionStyle { + case toggle(isSelected: Bool) + case tap + } + + let contact: ContactDTO + let style: SelectionStyle + let userLocation: CLLocation? + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .top, spacing: 12) { + leadingSelectionGlyph + + ContactAvatar(contact: contact, size: 40) + + VStack(alignment: .leading, spacing: 4) { + Text(contact.displayName) + .font(.body) + .bold() + .foregroundStyle(.primary) + + RelativeTimestampText(timestamp: contact.lastAdvertTimestamp) + + HStack(spacing: 4) { + Text(contactTypeLabel) + .font(.caption) + .foregroundStyle(.secondary) + + if contact.hasLocation { + Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") + .labelStyle(.iconOnly) + .font(.caption) + .foregroundStyle(.green) - let contact: ContactDTO - let style: SelectionStyle - let userLocation: CLLocation? - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(alignment: .top, spacing: 12) { - leadingSelectionGlyph - - ContactAvatar(contact: contact, size: 40) - - VStack(alignment: .leading, spacing: 4) { - Text(contact.displayName) - .font(.body) - .bold() - .foregroundStyle(.primary) - - RelativeTimestampText(timestamp: contact.lastAdvertTimestamp) - - HStack(spacing: 4) { - Text(contactTypeLabel) - .font(.caption) - .foregroundStyle(.secondary) - - if contact.hasLocation { - Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") - .labelStyle(.iconOnly) - .font(.caption) - .foregroundStyle(.green) - - if let distance = distanceText { - Text(distance) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - Text(L10n.Chats.Chats.ContactMatch.key(contact.publicKey.uppercaseHexString(separator: " "))) - .font(.caption) - .monospaced() - .foregroundStyle(.tertiary) - .lineLimit(2) - } - - trailingChevron + if let distance = distanceText { + Text(distance) + .font(.caption) + .foregroundStyle(.secondary) + } } - .contentShape(.rect) - } - .buttonStyle(.plain) - .accessibilityElement(children: .combine) - .accessibilityLabel(contact.displayName) - .accessibilityValue(accessibilityValue) - .accessibilityAddTraits(accessibilityTraits) - } + } - @ViewBuilder - private var leadingSelectionGlyph: some View { - if case .toggle(let isSelected) = style { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? .blue : .secondary) - .font(.title3) + Text(L10n.Chats.Chats.ContactMatch.key(contact.publicKey.uppercaseHexString(separator: " "))) + .font(.caption) + .monospaced() + .foregroundStyle(.tertiary) + .lineLimit(2) } - } - @ViewBuilder - private var trailingChevron: some View { - if case .tap = style { - Spacer(minLength: 0) - Image(systemName: "chevron.right") - .foregroundStyle(.tertiary) - .font(.caption) - } + trailingChevron + } + .contentShape(.rect) } - - private var accessibilityValue: String { - switch style { - case .toggle(let isSelected): - isSelected - ? L10n.Chats.Chats.ContactMatch.Accessibility.selected - : L10n.Chats.Chats.ContactMatch.Accessibility.notSelected - case .tap: - "" - } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityLabel(contact.displayName) + .accessibilityValue(accessibilityValue) + .accessibilityAddTraits(accessibilityTraits) + } + + @ViewBuilder + private var leadingSelectionGlyph: some View { + if case let .toggle(isSelected) = style { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? .blue : .secondary) + .font(.title3) } - - private var accessibilityTraits: AccessibilityTraits { - switch style { - case .toggle: .isToggle - case .tap: .isButton - } + } + + @ViewBuilder + private var trailingChevron: some View { + if case .tap = style { + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + .font(.caption) } - - private var contactTypeLabel: String { - contact.type.localizedName + } + + private var accessibilityValue: String { + switch style { + case let .toggle(isSelected): + isSelected + ? L10n.Chats.Chats.ContactMatch.Accessibility.selected + : L10n.Chats.Chats.ContactMatch.Accessibility.notSelected + case .tap: + "" } + } - private var distanceText: String? { - guard let userLocation, contact.hasLocation else { return nil } - - let contactLocation = CLLocation( - latitude: contact.latitude, - longitude: contact.longitude - ) - let meters = userLocation.distance(from: contactLocation) - let measurement = Measurement(value: meters, unit: UnitLength.meters) - let formatted = measurement.formatted(.measurement(width: .abbreviated, usage: .road)) - return L10n.Contacts.Contacts.Row.away(formatted) + private var accessibilityTraits: AccessibilityTraits { + switch style { + case .toggle: .isToggle + case .tap: .isButton } + } + + private var contactTypeLabel: String { + contact.type.localizedName + } + + private var distanceText: String? { + guard let userLocation, contact.hasLocation else { return nil } + + let contactLocation = CLLocation( + latitude: contact.latitude, + longitude: contact.longitude + ) + let meters = userLocation.distance(from: contactLocation) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + let formatted = measurement.formatted(.measurement(width: .abbreviated, usage: .road)) + return L10n.Contacts.Contacts.Row.away(formatted) + } } diff --git a/MC1/Views/Chats/ConversationActionError.swift b/MC1/Views/Chats/ConversationActionError.swift index c8688534..aa22fb6f 100644 --- a/MC1/Views/Chats/ConversationActionError.swift +++ b/MC1/Views/Chats/ConversationActionError.swift @@ -4,11 +4,11 @@ import Foundation /// message routes through the shared `.errorAlert`. Only the connection precondition lives here; /// radio-command timeouts surface `MC1Services.withTimeout`'s `TimeoutError` directly. enum ConversationActionError: LocalizedError { - case notConnected + case notConnected - var errorDescription: String? { - switch self { - case .notConnected: L10n.Chats.Chats.Error.notConnectedToDelete - } + var errorDescription: String? { + switch self { + case .notConnected: L10n.Chats.Chats.Error.notConnectedToDelete } + } } diff --git a/MC1/Views/Chats/ConversationContextMenuModifier.swift b/MC1/Views/Chats/ConversationContextMenuModifier.swift index a102fb02..c80c1adc 100644 --- a/MC1/Views/Chats/ConversationContextMenuModifier.swift +++ b/MC1/Views/Chats/ConversationContextMenuModifier.swift @@ -3,67 +3,67 @@ import SwiftUI /// Long-press context-menu actions for a conversation row: delete, mute/unmute, favorite/unfavorite. /// Delete is gated while a removal is in flight so a rapid re-press can't double-fire. struct ConversationContextMenuModifier: ViewModifier { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let conversation: Conversation - let viewModel: ChatViewModel - let onDelete: () -> Void + let conversation: Conversation + let viewModel: ChatViewModel + let onDelete: () -> Void - private var isConnected: Bool { - appState.connectionState == .ready - } + private var isConnected: Bool { + appState.connectionState == .ready + } - private var isTogglingFavorite: Bool { - guard case .direct(let contact) = conversation else { return false } - return viewModel.togglingFavoriteID == contact.id - } + private var isTogglingFavorite: Bool { + guard case let .direct(contact) = conversation else { return false } + return viewModel.togglingFavoriteID == contact.id + } - func body(content: Content) -> some View { - content.contextMenu { - Button(role: .destructive) { - onDelete() - } label: { - Label(L10n.Chats.Chats.Action.delete, systemImage: "trash") - } - .disabled(!isConnected || viewModel.isDeletePending(conversation.id)) + func body(content: Content) -> some View { + content.contextMenu { + Button(role: .destructive) { + onDelete() + } label: { + Label(L10n.Chats.Chats.Action.delete, systemImage: "trash") + } + .disabled(!isConnected || viewModel.isDeletePending(conversation.id)) - Button { - Task { - await viewModel.toggleMute(conversation) - } - } label: { - Label( - conversation.isMuted ? L10n.Chats.Chats.Action.unmute : L10n.Chats.Chats.Action.mute, - systemImage: conversation.isMuted ? "bell" : "bell.slash" - ) - } - .disabled(!isConnected) + Button { + Task { + await viewModel.toggleMute(conversation) + } + } label: { + Label( + conversation.isMuted ? L10n.Chats.Chats.Action.unmute : L10n.Chats.Chats.Action.mute, + systemImage: conversation.isMuted ? "bell" : "bell.slash" + ) + } + .disabled(!isConnected) - Button { - Task { - await viewModel.toggleFavorite(conversation, disableAnimation: true) - } - } label: { - Label( - conversation.isFavorite ? L10n.Chats.Chats.Action.unfavorite : L10n.Chats.Chats.Action.favorite, - systemImage: conversation.isFavorite ? "star.slash" : "star.fill" - ) - } - .disabled(!isConnected || isTogglingFavorite) + Button { + Task { + await viewModel.toggleFavorite(conversation, disableAnimation: true) } + } label: { + Label( + conversation.isFavorite ? L10n.Chats.Chats.Action.unfavorite : L10n.Chats.Chats.Action.favorite, + systemImage: conversation.isFavorite ? "star.slash" : "star.fill" + ) + } + .disabled(!isConnected || isTogglingFavorite) } + } } extension View { - func conversationContextMenu( - conversation: Conversation, - viewModel: ChatViewModel, - onDelete: @escaping () -> Void - ) -> some View { - modifier(ConversationContextMenuModifier( - conversation: conversation, - viewModel: viewModel, - onDelete: onDelete - )) - } + func conversationContextMenu( + conversation: Conversation, + viewModel: ChatViewModel, + onDelete: @escaping () -> Void + ) -> some View { + modifier(ConversationContextMenuModifier( + conversation: conversation, + viewModel: viewModel, + onDelete: onDelete + )) + } } diff --git a/MC1/Views/Chats/ConversationListContent.swift b/MC1/Views/Chats/ConversationListContent.swift index 92b9137b..701f671f 100644 --- a/MC1/Views/Chats/ConversationListContent.swift +++ b/MC1/Views/Chats/ConversationListContent.swift @@ -1,253 +1,257 @@ -import SwiftUI import MC1Services +import SwiftUI /// The conversation list rendered as a `ScrollView` + `LazyVStack` rather than a `List`. /// `List` is backed by `UpdateCoalescingCollectionView`, whose batch-consistency assertion /// is violated when the selected row is deleted; a `LazyVStack` has no collection view, so /// that crash cannot occur. Row actions live in a `.contextMenu`. struct ConversationListContent: View { - enum ListMode { - case selection(Binding) - case navigation(onNavigate: (ChatRoute) -> Void, onRequestRoomAuth: (RemoteNodeSessionDTO) -> Void) - } + enum ListMode { + case selection(Binding) + case navigation(onNavigate: (ChatRoute) -> Void, onRequestRoomAuth: (RemoteNodeSessionDTO) -> Void) + } - @Environment(\.appTheme) private var theme - - private let viewModel: ChatViewModel - private let favoriteConversations: [Conversation] - private let otherConversations: [Conversation] - private let mode: ListMode - private let hasLoadedOnce: Bool - private let emptyStateMessage: (title: String, description: String, systemImage: String) - private let onDeleteConversation: (Conversation) -> Void - @Binding private var selectedFilter: ChatFilter - - /// Leading inset for the inter-row divider, aligning it under the row text past the avatar - /// (row horizontal padding 16 + avatar 44 + avatar-to-text spacing 12). - private static let rowSeparatorLeadingInset: CGFloat = 72 - - init( - viewModel: ChatViewModel, - favoriteConversations: [Conversation], - otherConversations: [Conversation], - selectedFilter: Binding, - hasLoadedOnce: Bool, - emptyStateMessage: (title: String, description: String, systemImage: String), - selection: Binding, - onDeleteConversation: @escaping (Conversation) -> Void - ) { - self.viewModel = viewModel - self.favoriteConversations = favoriteConversations - self.otherConversations = otherConversations - self._selectedFilter = selectedFilter - self.hasLoadedOnce = hasLoadedOnce - self.emptyStateMessage = emptyStateMessage - self.mode = .selection(selection) - self.onDeleteConversation = onDeleteConversation - } + @Environment(\.appTheme) private var theme - init( - viewModel: ChatViewModel, - favoriteConversations: [Conversation], - otherConversations: [Conversation], - selectedFilter: Binding, - hasLoadedOnce: Bool, - emptyStateMessage: (title: String, description: String, systemImage: String), - onNavigate: @escaping (ChatRoute) -> Void, - onRequestRoomAuth: @escaping (RemoteNodeSessionDTO) -> Void, - onDeleteConversation: @escaping (Conversation) -> Void - ) { - self.viewModel = viewModel - self.favoriteConversations = favoriteConversations - self.otherConversations = otherConversations - self._selectedFilter = selectedFilter - self.hasLoadedOnce = hasLoadedOnce - self.emptyStateMessage = emptyStateMessage - self.mode = .navigation(onNavigate: onNavigate, onRequestRoomAuth: onRequestRoomAuth) - self.onDeleteConversation = onDeleteConversation - } + private let viewModel: ChatViewModel + private let favoriteConversations: [Conversation] + private let otherConversations: [Conversation] + private let mode: ListMode + private let hasLoadedOnce: Bool + private let emptyStateMessage: (title: String, description: String, systemImage: String) + private let onDeleteConversation: (Conversation) -> Void + @Binding private var selectedFilter: ChatFilter + + /// Leading inset for the inter-row divider, aligning it under the row text past the avatar + /// (row horizontal padding 16 + avatar 44 + avatar-to-text spacing 12). + private static let rowSeparatorLeadingInset: CGFloat = 72 - var body: some View { - Group { - if !hasLoadedOnce { - loadingBody - } else { - TimelineView(.everyMinute) { context in - loadedBody(referenceDate: context.date) - } - } + init( + viewModel: ChatViewModel, + favoriteConversations: [Conversation], + otherConversations: [Conversation], + selectedFilter: Binding, + hasLoadedOnce: Bool, + emptyStateMessage: (title: String, description: String, systemImage: String), + selection: Binding, + onDeleteConversation: @escaping (Conversation) -> Void + ) { + self.viewModel = viewModel + self.favoriteConversations = favoriteConversations + self.otherConversations = otherConversations + _selectedFilter = selectedFilter + self.hasLoadedOnce = hasLoadedOnce + self.emptyStateMessage = emptyStateMessage + mode = .selection(selection) + self.onDeleteConversation = onDeleteConversation + } + + init( + viewModel: ChatViewModel, + favoriteConversations: [Conversation], + otherConversations: [Conversation], + selectedFilter: Binding, + hasLoadedOnce: Bool, + emptyStateMessage: (title: String, description: String, systemImage: String), + onNavigate: @escaping (ChatRoute) -> Void, + onRequestRoomAuth: @escaping (RemoteNodeSessionDTO) -> Void, + onDeleteConversation: @escaping (Conversation) -> Void + ) { + self.viewModel = viewModel + self.favoriteConversations = favoriteConversations + self.otherConversations = otherConversations + _selectedFilter = selectedFilter + self.hasLoadedOnce = hasLoadedOnce + self.emptyStateMessage = emptyStateMessage + mode = .navigation(onNavigate: onNavigate, onRequestRoomAuth: onRequestRoomAuth) + self.onDeleteConversation = onDeleteConversation + } + + var body: some View { + Group { + if !hasLoadedOnce { + loadingBody + } else { + TimelineView(.everyMinute) { context in + loadedBody(referenceDate: context.date) } + } } + } - private var loadingBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { } header: { pinnedFilterHeader } - } - } - .overlay { ProgressView() } + private var loadingBody: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section {} header: { pinnedFilterHeader } + } } + .overlay { ProgressView() } + } - private func loadedBody(referenceDate: Date) -> some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - if hasNoConversations { - emptyState - } else { - rows(referenceDate: referenceDate) - } - } header: { - pinnedFilterHeader - } - } + private func loadedBody(referenceDate: Date) -> some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + if hasNoConversations { + emptyState + } else { + rows(referenceDate: referenceDate) + } + } header: { + pinnedFilterHeader } + } } + } - /// Filter bar as the pinned section header; `pinnedFilterHeaderBackground` documents the - /// per-OS backing. - private var pinnedFilterHeader: some View { - ChatFilterPicker(selection: $selectedFilter) - .frame(maxWidth: .infinity) - .pinnedFilterHeaderBackground(theme) - } + /// Filter bar as the pinned section header; `pinnedFilterHeaderBackground` documents the + /// per-OS backing. + private var pinnedFilterHeader: some View { + ChatFilterPicker(selection: $selectedFilter) + .frame(maxWidth: .infinity) + .pinnedFilterHeaderBackground(theme) + } - private var hasNoConversations: Bool { - favoriteConversations.isEmpty && otherConversations.isEmpty - } + private var hasNoConversations: Bool { + favoriteConversations.isEmpty && otherConversations.isEmpty + } - private var emptyState: some View { - ContentUnavailableView { - Label(emptyStateMessage.title, systemImage: emptyStateMessage.systemImage) - } description: { - Text(emptyStateMessage.description) - } actions: { - if selectedFilter != .all { - Button(L10n.Chats.Chats.Filter.clear) { - selectedFilter = .all - } - } + private var emptyState: some View { + ContentUnavailableView { + Label(emptyStateMessage.title, systemImage: emptyStateMessage.systemImage) + } description: { + Text(emptyStateMessage.description) + } actions: { + if selectedFilter != .all { + Button(L10n.Chats.Chats.Filter.clear) { + selectedFilter = .all } - .containerRelativeFrame([.horizontal, .vertical]) + } } + .containerRelativeFrame([.horizontal, .vertical]) + } - /// One unified section, favorites first by concatenation, with an inset divider between rows. - private func rows(referenceDate: Date) -> some View { - let ordered = favoriteConversations + otherConversations - return ForEach(Array(ordered.enumerated()), id: \.element.id) { index, conversation in - rowView(conversation, referenceDate: referenceDate) - .transition(.opacity) - if index < ordered.count - 1 { - Divider().padding(.leading, Self.rowSeparatorLeadingInset) - } - } + /// One unified section, favorites first by concatenation, with an inset divider between rows. + private func rows(referenceDate: Date) -> some View { + let ordered = favoriteConversations + otherConversations + return ForEach(Array(ordered.enumerated()), id: \.element.id) { index, conversation in + rowView(conversation, referenceDate: referenceDate) + .transition(.opacity) + if index < ordered.count - 1 { + Divider().padding(.leading, Self.rowSeparatorLeadingInset) + } } + } - @ViewBuilder - private func rowView(_ conversation: Conversation, referenceDate: Date) -> some View { - switch mode { - case .selection(let selection): - ConversationSelectionRow( - conversation: conversation, - viewModel: viewModel, - referenceDate: referenceDate, - isSelected: selection.wrappedValue == ChatRoute(conversation: conversation), - onSelect: { selection.wrappedValue = ChatRoute(conversation: conversation) }, - onDelete: { onDeleteConversation(conversation) } - ) - case .navigation(let onNavigate, let onRequestRoomAuth): - ConversationNavigationRow( - conversation: conversation, - viewModel: viewModel, - referenceDate: referenceDate, - onNavigate: onNavigate, - onRequestRoomAuth: onRequestRoomAuth, - onDelete: { onDeleteConversation(conversation) } - ) - } + @ViewBuilder + private func rowView(_ conversation: Conversation, referenceDate: Date) -> some View { + switch mode { + case let .selection(selection): + ConversationSelectionRow( + conversation: conversation, + viewModel: viewModel, + referenceDate: referenceDate, + isSelected: selection.wrappedValue == ChatRoute(conversation: conversation), + onSelect: { selection.wrappedValue = ChatRoute(conversation: conversation) }, + onDelete: { onDeleteConversation(conversation) } + ) + case let .navigation(onNavigate, onRequestRoomAuth): + ConversationNavigationRow( + conversation: conversation, + viewModel: viewModel, + referenceDate: referenceDate, + onNavigate: onNavigate, + onRequestRoomAuth: onRequestRoomAuth, + onDelete: { onDeleteConversation(conversation) } + ) } + } } // MARK: - Row Layout private enum ConversationRowLayout { - static let horizontalPadding: CGFloat = 16 - static let verticalPadding: CGFloat = 6 + static let horizontalPadding: CGFloat = 16 + static let verticalPadding: CGFloat = 6 } /// Renders a conversation's row body, shared by the selection and navigation rows. private struct ConversationRowLabel: View { - let conversation: Conversation - let viewModel: ChatViewModel - let referenceDate: Date - - var body: some View { - Group { - switch conversation { - case .direct(let contact): - ConversationRow(contact: contact, viewModel: viewModel, referenceDate: referenceDate) - case .channel(let channel): - ChannelConversationRow(channel: channel, viewModel: viewModel, referenceDate: referenceDate) - case .room(let session): - RoomConversationRow(session: session, referenceDate: referenceDate) - } - } - .padding(.horizontal, ConversationRowLayout.horizontalPadding) - .padding(.vertical, ConversationRowLayout.verticalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) + let conversation: Conversation + let viewModel: ChatViewModel + let referenceDate: Date + + var body: some View { + Group { + switch conversation { + case let .direct(contact): + ConversationRow(contact: contact, viewModel: viewModel, referenceDate: referenceDate) + case let .channel(channel): + ChannelConversationRow(channel: channel, viewModel: viewModel, referenceDate: referenceDate) + case let .room(session): + RoomConversationRow(session: session, referenceDate: referenceDate) + } } + .padding(.horizontal, ConversationRowLayout.horizontalPadding) + .padding(.vertical, ConversationRowLayout.verticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } } // MARK: - Extracted Rows private struct ConversationSelectionRow: View { - let conversation: Conversation - let viewModel: ChatViewModel - let referenceDate: Date - let isSelected: Bool - let onSelect: () -> Void - let onDelete: () -> Void - - private var isDeleting: Bool { viewModel.deletingIDs.contains(conversation.id) } - - var body: some View { - Button(action: onSelect) { - ConversationRowLabel(conversation: conversation, viewModel: viewModel, referenceDate: referenceDate) - } - .buttonStyle(.plain) - .selectedRowHighlight(isSelected: isSelected) - .accessibilityAddTraits(isSelected ? .isSelected : []) - .deletingRowOverlay(isDeleting: isDeleting) - .conversationContextMenu(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + let conversation: Conversation + let viewModel: ChatViewModel + let referenceDate: Date + let isSelected: Bool + let onSelect: () -> Void + let onDelete: () -> Void + + private var isDeleting: Bool { + viewModel.deletingIDs.contains(conversation.id) + } + + var body: some View { + Button(action: onSelect) { + ConversationRowLabel(conversation: conversation, viewModel: viewModel, referenceDate: referenceDate) } + .buttonStyle(.plain) + .selectedRowHighlight(isSelected: isSelected) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .deletingRowOverlay(isDeleting: isDeleting) + .conversationContextMenu(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + } } private struct ConversationNavigationRow: View { - let conversation: Conversation - let viewModel: ChatViewModel - let referenceDate: Date - let onNavigate: (ChatRoute) -> Void - let onRequestRoomAuth: (RemoteNodeSessionDTO) -> Void - let onDelete: () -> Void - - private var isDeleting: Bool { viewModel.deletingIDs.contains(conversation.id) } - - var body: some View { - Button(action: tap) { - ConversationRowLabel(conversation: conversation, viewModel: viewModel, referenceDate: referenceDate) - } - .buttonStyle(.plain) - .deletingRowOverlay(isDeleting: isDeleting) - .conversationContextMenu(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + let conversation: Conversation + let viewModel: ChatViewModel + let referenceDate: Date + let onNavigate: (ChatRoute) -> Void + let onRequestRoomAuth: (RemoteNodeSessionDTO) -> Void + let onDelete: () -> Void + + private var isDeleting: Bool { + viewModel.deletingIDs.contains(conversation.id) + } + + var body: some View { + Button(action: tap) { + ConversationRowLabel(conversation: conversation, viewModel: viewModel, referenceDate: referenceDate) } + .buttonStyle(.plain) + .deletingRowOverlay(isDeleting: isDeleting) + .conversationContextMenu(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + } - private func tap() { - let route = ChatRoute(conversation: conversation) - if case .room(let session) = conversation, !session.isConnected { - onRequestRoomAuth(session) - } else { - onNavigate(route) - } + private func tap() { + let route = ChatRoute(conversation: conversation) + if case let .room(session) = conversation, !session.isConnected { + onRequestRoomAuth(session) + } else { + onNavigate(route) } + } } diff --git a/MC1/Views/Chats/ConversationRow.swift b/MC1/Views/Chats/ConversationRow.swift index fae0cfae..028e5132 100644 --- a/MC1/Views/Chats/ConversationRow.swift +++ b/MC1/Views/Chats/ConversationRow.swift @@ -1,56 +1,56 @@ -import SwiftUI import MC1Services +import SwiftUI struct ConversationRow: View { - let contact: ContactDTO - let viewModel: ChatViewModel - var referenceDate: Date? - - var body: some View { - HStack(spacing: 12) { - ContactAvatar(contact: contact, size: 44) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text(contact.displayName) - .font(.headline) - .lineLimit(1) - - Spacer() - - NotificationLevelIndicator(level: contact.isMuted ? .muted : .all) - - if viewModel.togglingFavoriteID == contact.id { - ProgressView() - .controlSize(.small) - } else if contact.isFavorite { - Image(systemName: "star.fill") - .foregroundStyle(.yellow) - .font(.caption) - .accessibilityLabel(L10n.Chats.Chats.Row.favorite) - } - - if let date = contact.lastMessageDate { - ConversationTimestamp(date: date, referenceDate: referenceDate) - } - } - - HStack { - Text(viewModel.lastMessagePreview(id: contact.id) ?? L10n.Chats.Chats.Row.noMessages) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - - Spacer() - - UnreadBadges( - unreadCount: contact.unreadCount, - unreadMentionCount: contact.unreadMentionCount, - notificationLevel: contact.isMuted ? .muted : .all - ) - } - } + let contact: ContactDTO + let viewModel: ChatViewModel + var referenceDate: Date? + + var body: some View { + HStack(spacing: 12) { + ContactAvatar(contact: contact, size: 44) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(contact.displayName) + .font(.headline) + .lineLimit(1) + + Spacer() + + NotificationLevelIndicator(level: contact.isMuted ? .muted : .all) + + if viewModel.togglingFavoriteID == contact.id { + ProgressView() + .controlSize(.small) + } else if contact.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .font(.caption) + .accessibilityLabel(L10n.Chats.Chats.Row.favorite) + } + + if let date = contact.lastMessageDate { + ConversationTimestamp(date: date, referenceDate: referenceDate) + } + } + + HStack { + Text(viewModel.lastMessagePreview(id: contact.id) ?? L10n.Chats.Chats.Row.noMessages) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + UnreadBadges( + unreadCount: contact.unreadCount, + unreadMentionCount: contact.unreadMentionCount, + notificationLevel: contact.isMuted ? .muted : .all + ) } - .padding(.vertical, 4) + } } + .padding(.vertical, 4) + } } diff --git a/MC1/Views/Chats/ConversationSnapshot.swift b/MC1/Views/Chats/ConversationSnapshot.swift index fb807750..7db68ed3 100644 --- a/MC1/Views/Chats/ConversationSnapshot.swift +++ b/MC1/Views/Chats/ConversationSnapshot.swift @@ -3,9 +3,9 @@ import Foundation /// Complete, internally consistent favorite/other split of the conversation list. /// Committed as a single observed value so the list can never diff favorites from /// one fetch generation against others from another. -struct ConversationSnapshot: Equatable, Sendable { - var favorites: [Conversation] - var others: [Conversation] +struct ConversationSnapshot: Equatable { + var favorites: [Conversation] + var others: [Conversation] - static let empty = ConversationSnapshot(favorites: [], others: []) + static let empty = ConversationSnapshot(favorites: [], others: []) } diff --git a/MC1/Views/Chats/CreatePrivateChannelView.swift b/MC1/Views/Chats/CreatePrivateChannelView.swift index 7fa281f3..e7ec1027 100644 --- a/MC1/Views/Chats/CreatePrivateChannelView.swift +++ b/MC1/Views/Chats/CreatePrivateChannelView.swift @@ -1,264 +1,266 @@ -import SwiftUI -import MC1Services import CoreImage.CIFilterBuiltins +import MC1Services +import SwiftUI /// View for creating a private channel with auto-generated secret and QR code struct CreatePrivateChannelView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - let availableSlots: [UInt8] - let onComplete: (ChannelDTO?) -> Void - - @State private var channelName = "" - @State private var selectedSlot: UInt8 - @State private var generatedSecret: Data? - @State private var isCreating = false - @State private var createdChannel: ChannelDTO? - @State private var errorMessage: String? - @State private var copyHapticTrigger = 0 - - private var isCreated: Bool { createdChannel != nil } - - init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { - self.availableSlots = availableSlots - self.onComplete = onComplete - self._selectedSlot = State(initialValue: availableSlots.first ?? 1) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + let availableSlots: [UInt8] + let onComplete: (ChannelDTO?) -> Void + + @State private var channelName = "" + @State private var selectedSlot: UInt8 + @State private var generatedSecret: Data? + @State private var isCreating = false + @State private var createdChannel: ChannelDTO? + @State private var errorMessage: String? + @State private var copyHapticTrigger = 0 + + private var isCreated: Bool { + createdChannel != nil + } + + init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { + self.availableSlots = availableSlots + self.onComplete = onComplete + _selectedSlot = State(initialValue: availableSlots.first ?? 1) + } + + var body: some View { + Form { + if !isCreated { + CreateChannelFormContent( + channelName: $channelName, + generatedSecret: generatedSecret, + isCreating: isCreating, + errorMessage: errorMessage, + onCreate: { Task { await createChannel() } } + ) + } else { + ShareChannelContent( + channelName: channelName, + generatedSecret: generatedSecret, + copyHapticTrigger: $copyHapticTrigger + ) + } } - - var body: some View { - Form { - if !isCreated { - CreateChannelFormContent( - channelName: $channelName, - generatedSecret: generatedSecret, - isCreating: isCreating, - errorMessage: errorMessage, - onCreate: { Task { await createChannel() } } - ) - } else { - ShareChannelContent( - channelName: channelName, - generatedSecret: generatedSecret, - copyHapticTrigger: $copyHapticTrigger - ) - } + .themedCanvas(theme) + .navigationTitle(isCreated ? L10n.Chats.Chats.CreatePrivate.titleShare : L10n.Chats.Chats.CreatePrivate.titleCreate) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if isCreated { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Chats.Chats.Common.done) { + onComplete(createdChannel) + } } - .themedCanvas(theme) - .navigationTitle(isCreated ? L10n.Chats.Chats.CreatePrivate.titleShare : L10n.Chats.Chats.CreatePrivate.titleCreate) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if isCreated { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Chats.Chats.Common.done) { - onComplete(createdChannel) - } - } - } - } - .onAppear { - generateSecret() - } - .sensoryFeedback(.success, trigger: copyHapticTrigger) + } } - - // MARK: - Private Methods - - private func generateSecret() { - var bytes = [UInt8](repeating: 0, count: 16) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - generatedSecret = Data(bytes) + .onAppear { + generateSecret() + } + .sensoryFeedback(.success, trigger: copyHapticTrigger) + } + + // MARK: - Private Methods + + private func generateSecret() { + var bytes = [UInt8](repeating: 0, count: 16) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + generatedSecret = Data(bytes) + } + + private func createChannel() async { + guard let radioID = appState.connectedDevice?.radioID, + let secret = generatedSecret else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return } - private func createChannel() async { - guard let radioID = appState.connectedDevice?.radioID, - let secret = generatedSecret else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return - } - - isCreating = true - defer { isCreating = false } - errorMessage = nil - - do { - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return - } - try await channelService.setChannelWithSecret( - radioID: radioID, - index: selectedSlot, - name: channelName, - secret: secret - ) - - // Fetch the created channel to return it - if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { - createdChannel = channels.first { $0.index == selectedSlot } - } - } catch { - errorMessage = error.userFacingMessage - } + isCreating = true + defer { isCreating = false } + errorMessage = nil + + do { + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return + } + try await channelService.setChannelWithSecret( + radioID: radioID, + index: selectedSlot, + name: channelName, + secret: secret + ) + + // Fetch the created channel to return it + if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { + createdChannel = channels.first { $0.index == selectedSlot } + } + } catch { + errorMessage = error.userFacingMessage } + } } // MARK: - Extracted Views private struct CreateChannelFormContent: View { - @Environment(\.appTheme) private var theme - @Binding var channelName: String - let generatedSecret: Data? - let isCreating: Bool - let errorMessage: String? - let onCreate: () -> Void - - var body: some View { - Group { - Section { - TextField(L10n.Chats.Chats.CreatePrivate.channelName, text: $channelName) - .textContentType(.name) - .onChange(of: channelName) { _, newValue in - if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { - channelName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - } - } - } header: { - Text(L10n.Chats.Chats.CreatePrivate.Section.details) + @Environment(\.appTheme) private var theme + @Binding var channelName: String + let generatedSecret: Data? + let isCreating: Bool + let errorMessage: String? + let onCreate: () -> Void + + var body: some View { + Group { + Section { + TextField(L10n.Chats.Chats.CreatePrivate.channelName, text: $channelName) + .textContentType(.name) + .onChange(of: channelName) { _, newValue in + if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { + channelName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) } - .themedRowBackground(theme) - - Section { - if let secret = generatedSecret { - LabeledContent(L10n.Chats.Chats.ChannelInfo.secretKey) { - Text(secret.uppercaseHexString()) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } header: { - Text(L10n.Chats.Chats.CreatePrivate.Section.secret) - } footer: { - Text(L10n.Chats.Chats.CreatePrivate.secretFooter) - } - .themedRowBackground(theme) - - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } - - Section { - Button(action: onCreate) { - HStack { - Spacer() - if isCreating { - ProgressView() - } else { - Text(L10n.Chats.Chats.CreatePrivate.createButton) - } - Spacer() - } - } - .disabled(channelName.isEmpty || isCreating || generatedSecret == nil) + } + } header: { + Text(L10n.Chats.Chats.CreatePrivate.Section.details) + } + .themedRowBackground(theme) + + Section { + if let secret = generatedSecret { + LabeledContent(L10n.Chats.Chats.ChannelInfo.secretKey) { + Text(secret.uppercaseHexString()) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } header: { + Text(L10n.Chats.Chats.CreatePrivate.Section.secret) + } footer: { + Text(L10n.Chats.Chats.CreatePrivate.secretFooter) + } + .themedRowBackground(theme) + + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + .themedRowBackground(theme) + } + + Section { + Button(action: onCreate) { + HStack { + Spacer() + if isCreating { + ProgressView() + } else { + Text(L10n.Chats.Chats.CreatePrivate.createButton) } - .themedRowBackground(theme) + Spacer() + } } + .disabled(channelName.isEmpty || isCreating || generatedSecret == nil) + } + .themedRowBackground(theme) } + } } private struct ShareChannelContent: View { - @Environment(\.appTheme) private var theme - let channelName: String - let generatedSecret: Data? - @Binding var copyHapticTrigger: Int - - var body: some View { - Group { - Section { - HStack { - Spacer() - VStack(spacing: 16) { - if let qrImage = generateQRCode() { - Image(uiImage: qrImage) - .interpolation(.none) - .resizable() - .scaledToFit() - .frame(width: 200, height: 200) - } - - Text(channelName) - .font(.headline) - - Text(L10n.Chats.Chats.ChannelInfo.scanToJoin) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - Spacer() - } + @Environment(\.appTheme) private var theme + let channelName: String + let generatedSecret: Data? + @Binding var copyHapticTrigger: Int + + var body: some View { + Group { + Section { + HStack { + Spacer() + VStack(spacing: 16) { + if let qrImage = generateQRCode() { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) } - .themedRowBackground(theme) - - Section { - if let secret = generatedSecret { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Chats.Chats.ChannelInfo.secretKey) - .font(.caption) - .foregroundStyle(.secondary) - - HStack { - Text(secret.uppercaseHexString()) - .font(.system(.body, design: .monospaced)) - - Spacer() - - Button(L10n.Chats.Chats.ChannelInfo.copy, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - UIPasteboard.general.string = secret.uppercaseHexString() - } - .labelStyle(.iconOnly) - } - } - } - } header: { - Text(L10n.Chats.Chats.CreatePrivate.Section.shareManually) - } footer: { - Text(L10n.Chats.Chats.CreatePrivate.shareManuallyFooter) + + Text(channelName) + .font(.headline) + + Text(L10n.Chats.Chats.ChannelInfo.scanToJoin) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + Spacer() + } + } + .themedRowBackground(theme) + + Section { + if let secret = generatedSecret { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Chats.Chats.ChannelInfo.secretKey) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Text(secret.uppercaseHexString()) + .font(.system(.body, design: .monospaced)) + + Spacer() + + Button(L10n.Chats.Chats.ChannelInfo.copy, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = secret.uppercaseHexString() + } + .labelStyle(.iconOnly) } - .themedRowBackground(theme) + } } + } header: { + Text(L10n.Chats.Chats.CreatePrivate.Section.shareManually) + } footer: { + Text(L10n.Chats.Chats.CreatePrivate.shareManuallyFooter) + } + .themedRowBackground(theme) } + } - private func generateQRCode() -> UIImage? { - guard let secret = generatedSecret else { return nil } + private func generateQRCode() -> UIImage? { + guard let secret = generatedSecret else { return nil } - // Format: meshcore://channel/add?name=&secret= - let urlString = "meshcore://channel/add?name=\(channelName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")&secret=\(secret.uppercaseHexString())" + // Format: meshcore://channel/add?name=&secret= + let urlString = "meshcore://channel/add?name=\(channelName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")&secret=\(secret.uppercaseHexString())" - let context = CIContext() - let filter = CIFilter.qrCodeGenerator() - filter.message = Data(urlString.utf8) - filter.correctionLevel = "M" + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(urlString.utf8) + filter.correctionLevel = "M" - guard let outputImage = filter.outputImage else { return nil } + guard let outputImage = filter.outputImage else { return nil } - // Scale up for better quality - let scale = 10.0 - let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + // Scale up for better quality + let scale = 10.0 + let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) - guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } - return UIImage(cgImage: cgImage) - } + return UIImage(cgImage: cgImage) + } } #Preview { - NavigationStack { - CreatePrivateChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) - } - .environment(\.appState, AppState()) + NavigationStack { + CreatePrivateChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/DecodedPreviewAssets.swift b/MC1/Views/Chats/DecodedPreviewAssets.swift index f4f135cb..f7f0bd64 100644 --- a/MC1/Views/Chats/DecodedPreviewAssets.swift +++ b/MC1/Views/Chats/DecodedPreviewAssets.swift @@ -2,6 +2,6 @@ import UIKit /// Decoded preview hero image and icon, stored together to batch Observable notifications struct DecodedPreviewAssets { - var image: UIImage? - var icon: UIImage? + var image: UIImage? + var icon: UIImage? } diff --git a/MC1/Views/Chats/HashtagDeeplinkSupport.swift b/MC1/Views/Chats/HashtagDeeplinkSupport.swift index 1ec55b9e..0a061989 100644 --- a/MC1/Views/Chats/HashtagDeeplinkSupport.swift +++ b/MC1/Views/Chats/HashtagDeeplinkSupport.swift @@ -2,33 +2,33 @@ import Foundation import MC1Services struct HashtagJoinRequest: Identifiable, Hashable { - let id: String + let id: String } enum HashtagDeeplinkSupport { - static let scheme = "meshcoreone" - static let host = "hashtag" + static let scheme = "meshcoreone" + static let host = "hashtag" - static func channelNameFromURL(_ url: URL) -> String? { - guard url.scheme == scheme, url.host == host else { return nil } - let path = url.pathComponents.dropFirst() // drop leading "/" - return path.first - } + static func channelNameFromURL(_ url: URL) -> String? { + guard url.scheme == scheme, url.host == host else { return nil } + let path = url.pathComponents.dropFirst() // drop leading "/" + return path.first + } - static func fullChannelName(from rawName: String) -> String? { - let normalizedName = HashtagUtilities.normalizeHashtagName(rawName) - guard HashtagUtilities.isValidHashtagName(normalizedName) else { return nil } - return "#\(normalizedName)" - } + static func fullChannelName(from rawName: String) -> String? { + let normalizedName = HashtagUtilities.normalizeHashtagName(rawName) + guard HashtagUtilities.isValidHashtagName(normalizedName) else { return nil } + return "#\(normalizedName)" + } - static func findChannelByName( - _ name: String, - radioID: UUID, - fetchChannels: @Sendable (UUID) async throws -> [ChannelDTO] - ) async throws -> ChannelDTO? { - let channels = try await fetchChannels(radioID) - return channels.first(where: { channel in - channel.name.localizedCaseInsensitiveCompare(name) == .orderedSame - }) - } + static func findChannelByName( + _ name: String, + radioID: UUID, + fetchChannels: @Sendable (UUID) async throws -> [ChannelDTO] + ) async throws -> ChannelDTO? { + let channels = try await fetchChannels(radioID) + return channels.first(where: { channel in + channel.name.localizedCaseInsensitiveCompare(name) == .orderedSame + }) + } } diff --git a/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift b/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift index 0c27fbfd..27922dd9 100644 --- a/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift +++ b/MC1/Views/Chats/Linkify/MessageLinkAccessibility.swift @@ -11,88 +11,89 @@ import SwiftUI /// `item` seam, so it is never rebuilt while scrolling; and SwiftUI realizes the action descriptors /// only when an assistive technology focuses the element, so they cost nothing when VoiceOver is off. enum MessageLinkAccessibility { - - /// One openable link surfaced as a VoiceOver custom action. - struct Action: Identifiable { - let name: String - let url: URL - var id: URL { url } + /// One openable link surfaced as a VoiceOver custom action. + struct Action: Identifiable { + let name: String + let url: URL + var id: URL { + url } + } - /// Upper bound on the actions a single message contributes, so a message packed with links - /// cannot flood the rotor. Links past the cap stay visible but are not separately activatable. - static let maxActions = 8 + /// Upper bound on the actions a single message contributes, so a message packed with links + /// cannot flood the rotor. Links past the cap stay visible but are not separately activatable. + static let maxActions = 8 - /// Openable links in document order: the preview card's URL first (if any), then each linked - /// run in the body text. Deduplicated by URL and capped at `maxActions`. - static func actions(previewURL: URL?, formatted: AttributedString?) -> [Action] { - var seen: Set = [] - var actions: [Action] = [] + /// Openable links in document order: the preview card's URL first (if any), then each linked + /// run in the body text. Deduplicated by URL and capped at `maxActions`. + static func actions(previewURL: URL?, formatted: AttributedString?) -> [Action] { + var seen: Set = [] + var actions: [Action] = [] - func append(_ url: URL) { - guard actions.count < maxActions, seen.insert(url).inserted else { return } - actions.append(Action(name: name(for: url), url: url)) - } + func append(_ url: URL) { + guard actions.count < maxActions, seen.insert(url).inserted else { return } + actions.append(Action(name: name(for: url), url: url)) + } - if let previewURL { - append(previewURL) - } - if let formatted { - for run in formatted.runs { - guard let url = run.link else { continue } - append(url) - } - } - return actions + if let previewURL { + append(previewURL) } + if let formatted { + for run in formatted.runs { + guard let url = run.link else { continue } + append(url) + } + } + return actions + } - /// A localized, target-bearing action name so a VoiceOver user can tell two links apart. Reuses - /// the same parsers the tap router does, and falls back to the generic "Open Link" when a kind - /// exposes no readable target. - private static func name(for url: URL) -> String { - let openLink = L10n.Chats.Chats.Message.Action.openLink + /// A localized, target-bearing action name so a VoiceOver user can tell two links apart. Reuses + /// the same parsers the tap router does, and falls back to the generic "Open Link" when a kind + /// exposes no readable target. + private static func name(for url: URL) -> String { + let openLink = L10n.Chats.Chats.Message.Action.openLink - switch url.scheme?.lowercased() { - case "http", "https": - guard let host = url.host(), !host.isEmpty else { return openLink } - return L10n.Chats.Chats.Message.Action.openWebLink(host) + switch url.scheme?.lowercased() { + case "http", "https": + guard let host = url.host(), !host.isEmpty else { return openLink } + return L10n.Chats.Chats.Message.Action.openWebLink(host) - case MeshCoreURLParser.scheme: - switch url.host() { - case "map": - return L10n.Chats.Chats.Message.Action.openMapLink - case "contact": - guard let name = MeshCoreURLParser.parseContactURL(url.absoluteString)?.name else { return openLink } - return L10n.Chats.Chats.Message.Action.addContact(name) - case "channel": - guard let name = MeshCoreURLParser.parseChannelURL(url.absoluteString)?.name else { return openLink } - return L10n.Chats.Chats.Message.Action.openChannel(name) - default: - return openLink - } + case MeshCoreURLParser.scheme: + switch url.host() { + case "map": + return L10n.Chats.Chats.Message.Action.openMapLink + case "contact": + guard let name = MeshCoreURLParser.parseContactURL(url.absoluteString)?.name else { return openLink } + return L10n.Chats.Chats.Message.Action.addContact(name) + case "channel": + guard let name = MeshCoreURLParser.parseChannelURL(url.absoluteString)?.name else { return openLink } + return L10n.Chats.Chats.Message.Action.openChannel(name) + default: + return openLink + } - case MentionDeeplinkSupport.scheme: - switch url.host() { - case MentionDeeplinkSupport.host: - guard let name = MentionDeeplinkSupport.name(from: url) else { return openLink } - return L10n.Chats.Chats.Message.Action.openMention(name) - case "hashtag": - guard let channel = hashtagName(from: url) else { return openLink } - return L10n.Chats.Chats.Message.Action.openHashtag(channel) - default: - return openLink - } + case MentionDeeplinkSupport.scheme: + switch url.host() { + case MentionDeeplinkSupport.host: + guard let name = MentionDeeplinkSupport.name(from: url) else { return openLink } + return L10n.Chats.Chats.Message.Action.openMention(name) + case "hashtag": + guard let channel = hashtagName(from: url) else { return openLink } + return L10n.Chats.Chats.Message.Action.openHashtag(channel) + default: + return openLink + } - default: - return openLink - } + default: + return openLink } + } - /// Last path component of a `meshcoreone://hashtag/` link, percent-decoded. - private static func hashtagName(from url: URL) -> String? { - var path = url.path(percentEncoded: true) - if path.hasPrefix("/") { path.removeFirst() } - guard !path.isEmpty, let decoded = path.removingPercentEncoding, !decoded.isEmpty else { return nil } - return decoded - } + /// Last path component of a `meshcoreone://hashtag/` link, percent-decoded. + private static func hashtagName(from url: URL) -> String? { + var path = url.path(percentEncoded: true) + if path.hasPrefix("/") { path.removeFirst() } + guard !path.isEmpty, let decoded = path.removingPercentEncoding, !decoded.isEmpty else { return nil } + return decoded + } } diff --git a/MC1/Views/Chats/Linkify/MessageLinkStyler.swift b/MC1/Views/Chats/Linkify/MessageLinkStyler.swift index 05f603ec..4f543e37 100644 --- a/MC1/Views/Chats/Linkify/MessageLinkStyler.swift +++ b/MC1/Views/Chats/Linkify/MessageLinkStyler.swift @@ -4,32 +4,31 @@ import SwiftUI /// non-overlapping `[LinkToken]`. This is the only authored attributed-string representation; /// the render-time UIKit string is derived from it, never authored in parallel. enum MessageLinkStyler { + /// Applies the base color across the whole string, then each token's link, color, + /// underline, bold, and self-mention background over its span. Tokens are non-overlapping, + /// so application order does not affect the result. + static func style(normalized: String, tokens: [LinkToken], baseColor: Color) -> AttributedString { + var result = AttributedString(normalized) + result.foregroundColor = baseColor - /// Applies the base color across the whole string, then each token's link, color, - /// underline, bold, and self-mention background over its span. Tokens are non-overlapping, - /// so application order does not affect the result. - static func style(normalized: String, tokens: [LinkToken], baseColor: Color) -> AttributedString { - var result = AttributedString(normalized) - result.foregroundColor = baseColor + for token in tokens { + guard let attrRange = Range(token.range, in: result) else { continue } - for token in tokens { - guard let attrRange = Range(token.range, in: result) else { continue } - - if let url = token.url { - result[attrRange].link = url - } - result[attrRange].foregroundColor = token.foregroundColor - if let background = token.backgroundColor { - result[attrRange].backgroundColor = background - } - if token.underline { - result[attrRange].underlineStyle = .single - } - if token.bold { - result[attrRange].inlinePresentationIntent = .stronglyEmphasized - } - } - - return result + if let url = token.url { + result[attrRange].link = url + } + result[attrRange].foregroundColor = token.foregroundColor + if let background = token.backgroundColor { + result[attrRange].backgroundColor = background + } + if token.underline { + result[attrRange].underlineStyle = .single + } + if token.bold { + result[attrRange].inlinePresentationIntent = .stronglyEmphasized + } } + + return result + } } diff --git a/MC1/Views/Chats/Linkify/MessageLinkToken.swift b/MC1/Views/Chats/Linkify/MessageLinkToken.swift index ac0cddc8..f60e89f3 100644 --- a/MC1/Views/Chats/Linkify/MessageLinkToken.swift +++ b/MC1/Views/Chats/Linkify/MessageLinkToken.swift @@ -6,43 +6,43 @@ import SwiftUI /// whether to underline, whether to embolden) is frozen on the token here, so the /// styler stays a mechanical apply step with no per-kind branching. struct LinkToken { - /// Detector kinds in their fixed overlap-resolution priority, highest first (the case - /// order is the priority). When two detections cover overlapping characters the - /// higher-priority case wins and the lower is dropped, so the merged stream is - /// non-overlapping. The order encodes how each overlapping pair resolves: a contact chip - /// or mention shadows any link, hashtag, or coordinate inside its run; a URL shadows a - /// hashtag or coordinate it contains; and a hashtag sitting inside a meshcore link wins - /// over that link, so the meshcore link is left unlinked and only the hashtag is styled. - enum Kind: Int, Comparable { - case contactShare - case mention - case url - case hashtag - case meshcoreLink - case coordinate - - static func < (lhs: Kind, rhs: Kind) -> Bool { - lhs.rawValue < rhs.rawValue - } + /// Detector kinds in their fixed overlap-resolution priority, highest first (the case + /// order is the priority). When two detections cover overlapping characters the + /// higher-priority case wins and the lower is dropped, so the merged stream is + /// non-overlapping. The order encodes how each overlapping pair resolves: a contact chip + /// or mention shadows any link, hashtag, or coordinate inside its run; a URL shadows a + /// hashtag or coordinate it contains; and a hashtag sitting inside a meshcore link wins + /// over that link, so the meshcore link is left unlinked and only the hashtag is styled. + enum Kind: Int, Comparable { + case contactShare + case mention + case url + case hashtag + case meshcoreLink + case coordinate + + static func < (lhs: Kind, rhs: Kind) -> Bool { + lhs.rawValue < rhs.rawValue } + } - let range: Range - let kind: Kind + let range: Range + let kind: Kind - /// The link to install, or nil when a span is styled but not tappable (a mention - /// whose name cannot be percent-encoded keeps its color and underline but no link). - let url: URL? + /// The link to install, or nil when a span is styled but not tappable (a mention + /// whose name cannot be percent-encoded keeps its color and underline but no link). + let url: URL? - /// Foreground color for the span. Resolved at detection time against the live - /// theme/contrast inputs, so the styler does not re-derive any color. - let foregroundColor: Color + /// Foreground color for the span. Resolved at detection time against the live + /// theme/contrast inputs, so the styler does not re-derive any color. + let foregroundColor: Color - /// Self-mention highlight, applied behind the span when present. - let backgroundColor: Color? + /// Self-mention highlight, applied behind the span when present. + let backgroundColor: Color? - let underline: Bool + let underline: Bool - /// Hashtags render bold via `inlinePresentationIntent = .stronglyEmphasized`; - /// no other kind sets it. - let bold: Bool + /// Hashtags render bold via `inlinePresentationIntent = .stronglyEmphasized`; + /// no other kind sets it. + let bold: Bool } diff --git a/MC1/Views/Chats/Linkify/MessageLinkTokenizer.swift b/MC1/Views/Chats/Linkify/MessageLinkTokenizer.swift index 0575844d..3d1b8c1c 100644 --- a/MC1/Views/Chats/Linkify/MessageLinkTokenizer.swift +++ b/MC1/Views/Chats/Linkify/MessageLinkTokenizer.swift @@ -1,6 +1,6 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// Detects every link kind in one pass over the normalized message string and merges the /// results into a single sorted, non-overlapping `[LinkToken]`. Each detector runs exactly @@ -8,207 +8,204 @@ import MC1Services /// linkifier's threaded `urlRanges`/`linkRanges`/`excludedRanges` snapshots and its /// reverse-iteration index bookkeeping. enum MessageLinkTokenizer { - - /// Style inputs threaded from the live theme/contrast environment for the kinds the - /// tokenizer detects directly (URL, meshcore link, hashtag, coordinate). Mention and - /// contact-share colors are already resolved by the pre-pass. - struct StyleContext { - let baseColor: Color - let isOutgoing: Bool - let outgoingTextColor: Color - let hashtagColor: Color - } - - struct Result { - let tokens: [LinkToken] - /// The first surviving coordinate in document order, used to drive the map preview. - let mapCoordinate: CLLocationCoordinate2D? - } - - /// One shared http/https detector. `HashtagUtilities` receives this detector's ranges - /// rather than running a second `NSDataDetector`. - private static let urlDetector: NSDataDetector? = { - try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - }() - - private static let meshCoreLinkRegex = try? NSRegularExpression(pattern: #"meshcore://[^\s<>"]+"#) - - /// A coordinate detection paired with its parsed value, so the map coordinate can be - /// read from the first surviving coordinate token after the overlap merge. - private struct CoordinateHit { - let token: LinkToken - let coordinate: CLLocationCoordinate2D - } - - static func tokenize( - normalized: String, - preSpans: [LinkToken], - context: StyleContext - ) -> Result { - // One http/https scan feeds both the URL tokens and the ranges the hashtag detector - // uses to skip a `#fragment` that lives inside a URL. - let urls = detectURLs(in: normalized) - - var hits: [LinkToken] = preSpans - hits.append(contentsOf: urls.map { urlToken(range: $0.range, url: $0.url, context: context) }) - hits.append(contentsOf: detectMeshCoreLinkTokens(in: normalized, context: context)) - hits.append(contentsOf: detectHashtagTokens(in: normalized, urlRanges: urls.map(\.range), context: context)) - - let coordinateHits = detectCoordinateHits(in: normalized, context: context) - hits.append(contentsOf: coordinateHits.map(\.token)) - - let merged = resolveOverlaps(hits) - - // The map preview follows the first coordinate that survived overlap resolution, in - // document order. A coordinate shadowed by a higher-priority token (a contact chip, - // say) does not drive the preview. - let survivingCoordinateRanges = Set(merged.lazy.filter { $0.kind == .coordinate }.map(\.range)) - let mapCoordinate = coordinateHits - .filter { survivingCoordinateRanges.contains($0.token.range) } - .min { $0.token.range.lowerBound < $1.token.range.lowerBound }? - .coordinate - - return Result(tokens: merged, mapCoordinate: mapCoordinate) + /// Style inputs threaded from the live theme/contrast environment for the kinds the + /// tokenizer detects directly (URL, meshcore link, hashtag, coordinate). Mention and + /// contact-share colors are already resolved by the pre-pass. + struct StyleContext { + let baseColor: Color + let isOutgoing: Bool + let outgoingTextColor: Color + let hashtagColor: Color + } + + struct Result { + let tokens: [LinkToken] + /// The first surviving coordinate in document order, used to drive the map preview. + let mapCoordinate: CLLocationCoordinate2D? + } + + /// One shared http/https detector. `HashtagUtilities` receives this detector's ranges + /// rather than running a second `NSDataDetector`. + private static let urlDetector: NSDataDetector? = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + + private static let meshCoreLinkRegex = try? NSRegularExpression(pattern: #"meshcore://[^\s<>"]+"#) + + /// A coordinate detection paired with its parsed value, so the map coordinate can be + /// read from the first surviving coordinate token after the overlap merge. + private struct CoordinateHit { + let token: LinkToken + let coordinate: CLLocationCoordinate2D + } + + static func tokenize( + normalized: String, + preSpans: [LinkToken], + context: StyleContext + ) -> Result { + // One http/https scan feeds both the URL tokens and the ranges the hashtag detector + // uses to skip a `#fragment` that lives inside a URL. + let urls = detectURLs(in: normalized) + + var hits: [LinkToken] = preSpans + hits.append(contentsOf: urls.map { urlToken(range: $0.range, url: $0.url, context: context) }) + hits.append(contentsOf: detectMeshCoreLinkTokens(in: normalized, context: context)) + hits.append(contentsOf: detectHashtagTokens(in: normalized, urlRanges: urls.map(\.range), context: context)) + + let coordinateHits = detectCoordinateHits(in: normalized, context: context) + hits.append(contentsOf: coordinateHits.map(\.token)) + + let merged = resolveOverlaps(hits) + + // The map preview follows the first coordinate that survived overlap resolution, in + // document order. A coordinate shadowed by a higher-priority token (a contact chip, + // say) does not drive the preview. + let survivingCoordinateRanges = Set(merged.lazy.filter { $0.kind == .coordinate }.map(\.range)) + let mapCoordinate = coordinateHits + .filter { survivingCoordinateRanges.contains($0.token.range) } + .min { $0.token.range.lowerBound < $1.token.range.lowerBound }? + .coordinate + + return Result(tokens: merged, mapCoordinate: mapCoordinate) + } + + // MARK: - Overlap resolution + + /// Accepts detections highest priority first, dropping any that overlaps one already + /// accepted, then returns the survivors in document order. Resolving by `LinkToken.Kind` + /// priority rather than by start index is what lets a higher-priority kind that begins + /// inside a lower-priority run still win the overlap: a `hashtag` fragment embedded in a + /// `meshcoreLink` keeps its hashtag styling and leaves the surrounding link unlinked. The + /// result is sorted and non-overlapping. + private static func resolveOverlaps(_ tokens: [LinkToken]) -> [LinkToken] { + let byPriority = tokens.sorted { lhs, rhs in + if lhs.kind != rhs.kind { + return lhs.kind < rhs.kind + } + return lhs.range.lowerBound < rhs.range.lowerBound } - // MARK: - Overlap resolution - - /// Accepts detections highest priority first, dropping any that overlaps one already - /// accepted, then returns the survivors in document order. Resolving by `LinkToken.Kind` - /// priority rather than by start index is what lets a higher-priority kind that begins - /// inside a lower-priority run still win the overlap: a `hashtag` fragment embedded in a - /// `meshcoreLink` keeps its hashtag styling and leaves the surrounding link unlinked. The - /// result is sorted and non-overlapping. - private static func resolveOverlaps(_ tokens: [LinkToken]) -> [LinkToken] { - let byPriority = tokens.sorted { lhs, rhs in - if lhs.kind != rhs.kind { - return lhs.kind < rhs.kind - } - return lhs.range.lowerBound < rhs.range.lowerBound - } - - var accepted: [LinkToken] = [] - for token in byPriority where !accepted.contains(where: { $0.range.overlaps(token.range) }) { - accepted.append(token) - } - return accepted.sorted { $0.range.lowerBound < $1.range.lowerBound } + var accepted: [LinkToken] = [] + for token in byPriority where !accepted.contains(where: { $0.range.overlaps(token.range) }) { + accepted.append(token) } - - // MARK: - URLs - - private struct DetectedURL { - let range: Range - let url: URL - } - - private static func detectURLs(in text: String) -> [DetectedURL] { - guard let detector = urlDetector else { return [] } - let nsRange = NSRange(text.startIndex..., in: text) - return detector.matches(in: text, options: [], range: nsRange).compactMap { match in - guard let url = match.url, - let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https", - let range = Range(match.range, in: text) else { return nil } - return DetectedURL(range: range, url: url) - } + return accepted.sorted { $0.range.lowerBound < $1.range.lowerBound } + } + + // MARK: - URLs + + private struct DetectedURL { + let range: Range + let url: URL + } + + private static func detectURLs(in text: String) -> [DetectedURL] { + guard let detector = urlDetector else { return [] } + let nsRange = NSRange(text.startIndex..., in: text) + return detector.matches(in: text, options: [], range: nsRange).compactMap { match in + guard let url = match.url, + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let range = Range(match.range, in: text) else { return nil } + return DetectedURL(range: range, url: url) } - - private static func urlToken(range: Range, url: URL, context: StyleContext) -> LinkToken { - LinkToken( - range: range, - kind: .url, - url: url, - foregroundColor: context.baseColor, - backgroundColor: nil, - underline: true, - bold: false - ) + } + + private static func urlToken(range: Range, url: URL, context: StyleContext) -> LinkToken { + LinkToken( + range: range, + kind: .url, + url: url, + foregroundColor: context.baseColor, + backgroundColor: nil, + underline: true, + bold: false + ) + } + + // MARK: - MeshCore links + + private static func detectMeshCoreLinkTokens(in text: String, context: StyleContext) -> [LinkToken] { + guard let regex = meshCoreLinkRegex else { return [] } + let nsRange = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: nsRange).compactMap { match in + guard var matchRange = Range(match.range, in: text) else { return nil } + + // Strip trailing punctuation the regex may over-capture. + while let last = text[matchRange].last, ".,;:!?)".contains(last) { + matchRange = matchRange.lowerBound.. [LinkToken] { - guard let regex = meshCoreLinkRegex else { return [] } - let nsRange = NSRange(text.startIndex..., in: text) - return regex.matches(in: text, range: nsRange).compactMap { match in - guard var matchRange = Range(match.range, in: text) else { return nil } - - // Strip trailing punctuation the regex may over-capture. - while let last = text[matchRange].last, ".,;:!?)".contains(last) { - matchRange = matchRange.lowerBound..], + context: StyleContext + ) -> [LinkToken] { + let hashtags = HashtagUtilities.extractHashtags(from: text, urlRanges: urlRanges) + return hashtags.compactMap { hashtag in + let channelName = HashtagUtilities.normalizeHashtagName(hashtag.name) + guard let url = URL(string: "meshcoreone://hashtag/\(channelName)") else { return nil } + return LinkToken( + range: hashtag.range, + kind: .hashtag, + url: url, + foregroundColor: context.isOutgoing ? context.outgoingTextColor : context.hashtagColor, + backgroundColor: nil, + underline: false, + bold: true + ) } - - // MARK: - Hashtags - - private static func detectHashtagTokens( - in text: String, - urlRanges: [Range], - context: StyleContext - ) -> [LinkToken] { - let hashtags = HashtagUtilities.extractHashtags(from: text, urlRanges: urlRanges) - return hashtags.compactMap { hashtag in - let channelName = HashtagUtilities.normalizeHashtagName(hashtag.name) - guard let url = URL(string: "meshcoreone://hashtag/\(channelName)") else { return nil } - return LinkToken( - range: hashtag.range, - kind: .hashtag, - url: url, - foregroundColor: context.isOutgoing ? context.outgoingTextColor : context.hashtagColor, - backgroundColor: nil, - underline: false, - bold: true - ) - } - } - - // MARK: - Coordinates - - private static func detectCoordinateHits(in text: String, context: StyleContext) -> [CoordinateHit] { - ChatCoordinateDetector.matches(in: text).compactMap { match in - guard let url = mapURL( - latitude: match.coordinate.latitude, - longitude: match.coordinate.longitude - ) else { return nil } - let token = LinkToken( - range: match.range, - kind: .coordinate, - url: url, - foregroundColor: context.baseColor, - backgroundColor: nil, - underline: true, - bold: false - ) - return CoordinateHit(token: token, coordinate: match.coordinate) - } - } - - /// Builds `meshcore://map?lat=&lon=` with locale-independent `%.6f` values so the link - /// round-trips through `MeshCoreURLParser.parseMapURL` on every locale. A comma-decimal - /// locale's `.formatted()` would emit `37,334900`, which the parser's decimal gate rejects. - private static func mapURL(latitude: Double, longitude: Double) -> URL? { - var components = URLComponents() - components.scheme = MeshCoreURLParser.scheme - components.host = "map" - components.queryItems = [ - URLQueryItem(name: "lat", value: String(format: "%.6f", latitude)), - URLQueryItem(name: "lon", value: String(format: "%.6f", longitude)), - ] - return components.url + } + + // MARK: - Coordinates + + private static func detectCoordinateHits(in text: String, context: StyleContext) -> [CoordinateHit] { + ChatCoordinateDetector.matches(in: text).compactMap { match in + guard let url = mapURL( + latitude: match.coordinate.latitude, + longitude: match.coordinate.longitude + ) else { return nil } + let token = LinkToken( + range: match.range, + kind: .coordinate, + url: url, + foregroundColor: context.baseColor, + backgroundColor: nil, + underline: true, + bold: false + ) + return CoordinateHit(token: token, coordinate: match.coordinate) } + } + + /// Builds `meshcore://map?lat=&lon=` with locale-independent `%.6f` values so the link + /// round-trips through `MeshCoreURLParser.parseMapURL` on every locale. A comma-decimal + /// locale's `.formatted()` would emit `37,334900`, which the parser's decimal gate rejects. + private static func mapURL(latitude: Double, longitude: Double) -> URL? { + var components = URLComponents() + components.scheme = MeshCoreURLParser.scheme + components.host = "map" + components.queryItems = [ + URLQueryItem(name: "lat", value: String(format: "%.6f", latitude)), + URLQueryItem(name: "lon", value: String(format: "%.6f", longitude)), + ] + return components.url + } } diff --git a/MC1/Views/Chats/Linkify/MessageTextNormalizer.swift b/MC1/Views/Chats/Linkify/MessageTextNormalizer.swift index 25989375..decb02ff 100644 --- a/MC1/Views/Chats/Linkify/MessageTextNormalizer.swift +++ b/MC1/Views/Chats/Linkify/MessageTextNormalizer.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// The string-shrinking pre-pass of the linkifier. Two transforms rewrite the raw /// message body to a shorter display string before any range-based detection runs: @@ -11,198 +11,197 @@ import MC1Services /// later detector (URL, hashtag, meshcore, coordinate) runs on one stable string, so the /// index-desync trap of the old interleaved passes is gone by construction. enum MessageTextNormalizer { - - /// Style inputs threaded from the live theme/contrast environment, used to resolve a - /// mention's identity color at normalization time so downstream stages carry only - /// resolved colors. - struct StyleContext { - let baseColor: Color - let isOutgoing: Bool - let currentUserName: String? - let isHighContrast: Bool - let outgoingTextColor: Color - let identityGamut: IdentityGamut - let identityBackgroundLuminances: [Double] - } - - /// The normalized display string plus the spans produced by the two rewrites, with - /// ranges into the normalized string. - struct Result { - let string: String - let spans: [LinkToken] - } - - /// A pending substitution discovered on the original string, carrying the replacement - /// text and the span attributes to emit once normalized ranges are known. - private struct Replacement { - let originalRange: Range - let replacement: String - let kind: LinkToken.Kind - let url: URL? - let foregroundColor: Color - let backgroundColor: Color? + /// Style inputs threaded from the live theme/contrast environment, used to resolve a + /// mention's identity color at normalization time so downstream stages carry only + /// resolved colors. + struct StyleContext { + let baseColor: Color + let isOutgoing: Bool + let currentUserName: String? + let isHighContrast: Bool + let outgoingTextColor: Color + let identityGamut: IdentityGamut + let identityBackgroundLuminances: [Double] + } + + /// The normalized display string plus the spans produced by the two rewrites, with + /// ranges into the normalized string. + struct Result { + let string: String + let spans: [LinkToken] + } + + /// A pending substitution discovered on the original string, carrying the replacement + /// text and the span attributes to emit once normalized ranges are known. + private struct Replacement { + let originalRange: Range + let replacement: String + let kind: LinkToken.Kind + let url: URL? + let foregroundColor: Color + let backgroundColor: Color? + } + + static func normalize(_ text: String, context: StyleContext) -> Result { + let contactTokenRanges = contactShareTokenRanges(in: text) + + var replacements: [Replacement] = [] + replacements.append(contentsOf: mentionReplacements( + in: text, + context: context, + excludedRanges: contactTokenRanges + )) + replacements.append(contentsOf: contactShareReplacements(in: text, context: context)) + + // A mention can never sit inside a contact token (those mentions are excluded + // above) and contact tokens never overlap each other, so sorting by start index + // yields a clean left-to-right rewrite with no overlapping originals. + replacements.sort { $0.originalRange.lowerBound < $1.originalRange.lowerBound } + + guard !replacements.isEmpty else { + return Result(string: text, spans: []) } - static func normalize(_ text: String, context: StyleContext) -> Result { - let contactTokenRanges = contactShareTokenRanges(in: text) - - var replacements: [Replacement] = [] - replacements.append(contentsOf: mentionReplacements( - in: text, - context: context, - excludedRanges: contactTokenRanges - )) - replacements.append(contentsOf: contactShareReplacements(in: text, context: context)) - - // A mention can never sit inside a contact token (those mentions are excluded - // above) and contact tokens never overlap each other, so sorting by start index - // yields a clean left-to-right rewrite with no overlapping originals. - replacements.sort { $0.originalRange.lowerBound < $1.originalRange.lowerBound } - - guard !replacements.isEmpty else { - return Result(string: text, spans: []) - } - - var normalized = "" - var spans: [LinkToken] = [] - var cursor = text.startIndex - - for replacement in replacements { - normalized += text[cursor..] - ) -> [Replacement] { - guard let regex = MentionUtilities.mentionRegex else { return [] } - - let nsRange = NSRange(text.startIndex..., in: text) - return regex.matches(in: text, range: nsRange).compactMap { match in - guard let matchRange = Range(match.range, in: text), - let nameRange = Range(match.range(at: 1), in: text) else { return nil } - - // A contact share token's name is attacker-controlled and may itself contain - // `@[name]`; skip mentions inside one so the contact-share rewrite owns that text. - if excludedRanges.contains(where: { $0.overlaps(matchRange) }) { return nil } - - let name = String(text[nameRange]) - let isSelfMention = context.currentUserName.map { - name.localizedCaseInsensitiveCompare($0) == .orderedSame - } ?? false - - let foreground: Color - var background: Color? - if context.isOutgoing { - foreground = context.baseColor - if isSelfMention { - background = context.baseColor.opacity(0.3) - } - } else { - let mentionColor = context.identityGamut.color( - forName: name, - backgroundLuminances: context.identityBackgroundLuminances, - highContrast: context.isHighContrast - ) - foreground = mentionColor - if isSelfMention { - background = mentionColor.opacity(0.15) - } - } - - return Replacement( - originalRange: matchRange, - replacement: "@\(name)", - kind: .mention, - url: MentionDeeplinkSupport.url(forName: name), - foregroundColor: foreground, - backgroundColor: background - ) + normalized += text[cursor...] + + return Result(string: normalized, spans: spans) + } + + // MARK: - Mentions + + private static func mentionReplacements( + in text: String, + context: StyleContext, + excludedRanges: [Range] + ) -> [Replacement] { + guard let regex = MentionUtilities.mentionRegex else { return [] } + + let nsRange = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: nsRange).compactMap { match in + guard let matchRange = Range(match.range, in: text), + let nameRange = Range(match.range(at: 1), in: text) else { return nil } + + // A contact share token's name is attacker-controlled and may itself contain + // `@[name]`; skip mentions inside one so the contact-share rewrite owns that text. + if excludedRanges.contains(where: { $0.overlaps(matchRange) }) { return nil } + + let name = String(text[nameRange]) + let isSelfMention = context.currentUserName.map { + name.localizedCaseInsensitiveCompare($0) == .orderedSame + } ?? false + + let foreground: Color + var background: Color? + if context.isOutgoing { + foreground = context.baseColor + if isSelfMention { + background = context.baseColor.opacity(0.3) } - } - - // MARK: - Contact share - - /// Opening delimiter of a contact share token; gates the cheap fast-path skip. - private static let tokenOpen = "<" - - private static func contactShareTokenRanges(in text: String) -> [Range] { - guard text.contains(tokenOpen), let regex = ContactShareUtilities.shareTokenRegex else { return [] } - let nsRange = NSRange(text.startIndex..., in: text) - return regex.matches(in: text, range: nsRange).compactMap { Range($0.range, in: text) } - } - - private static func contactShareReplacements(in text: String, context: StyleContext) -> [Replacement] { - guard text.contains(tokenOpen), let regex = ContactShareUtilities.shareTokenRegex else { return [] } - - let nsRange = NSRange(text.startIndex..., in: text) - return regex.matches(in: text, range: nsRange).compactMap { match in - guard let matchRange = Range(match.range, in: text), - let result = ContactShareUtilities.parseShare(String(text[matchRange])) else { return nil } - - // Sanitize once and carry the cleaned name through both the visible chip and - // the link URL. If sanitizing leaves nothing, keep the literal token rather - // than emit an empty, invisible chip. - let cleanName = displayName(for: result.name) - guard !cleanName.isEmpty, - let url = URL(string: ContactService.exportContactURI( - name: cleanName, - publicKey: result.publicKey, - type: result.contactType - )) else { return nil } - - return Replacement( - originalRange: matchRange, - replacement: cleanName, - kind: .contactShare, - url: url, - foregroundColor: context.baseColor, - backgroundColor: nil - ) + } else { + let mentionColor = context.identityGamut.color( + forName: name, + backgroundLuminances: context.identityBackgroundLuminances, + highContrast: context.isHighContrast + ) + foreground = mentionColor + if isSelfMention { + background = mentionColor.opacity(0.15) } + } + + return Replacement( + originalRange: matchRange, + replacement: "@\(name)", + kind: .mention, + url: MentionDeeplinkSupport.url(forName: name), + foregroundColor: foreground, + backgroundColor: background + ) } - - /// Strips invisible and control Unicode scalars from an inbound contact name. The name - /// is attacker-controlled, so the cleaned form is used for both the visible chip and the - /// add-contact link URL, keeping the confirmation sheet and the persisted contact free - /// of bidi overrides, zero-width joiners, and line breaks that could hide or reorder the - /// visible identity. - static func displayName(for name: String) -> String { - String(String.UnicodeScalarView(name.unicodeScalars.filter { !isStrippableScalar($0) })) + } + + // MARK: - Contact share + + /// Opening delimiter of a contact share token; gates the cheap fast-path skip. + private static let tokenOpen = "<" + + private static func contactShareTokenRanges(in text: String) -> [Range] { + guard text.contains(tokenOpen), let regex = ContactShareUtilities.shareTokenRegex else { return [] } + let nsRange = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: nsRange).compactMap { Range($0.range, in: text) } + } + + private static func contactShareReplacements(in text: String, context: StyleContext) -> [Replacement] { + guard text.contains(tokenOpen), let regex = ContactShareUtilities.shareTokenRegex else { return [] } + + let nsRange = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: nsRange).compactMap { match in + guard let matchRange = Range(match.range, in: text), + let result = ContactShareUtilities.parseShare(String(text[matchRange])) else { return nil } + + // Sanitize once and carry the cleaned name through both the visible chip and + // the link URL. If sanitizing leaves nothing, keep the literal token rather + // than emit an empty, invisible chip. + let cleanName = displayName(for: result.name) + guard !cleanName.isEmpty, + let url = URL(string: ContactService.exportContactURI( + name: cleanName, + publicKey: result.publicKey, + type: result.contactType + )) else { return nil } + + return Replacement( + originalRange: matchRange, + replacement: cleanName, + kind: .contactShare, + url: url, + foregroundColor: context.baseColor, + backgroundColor: nil + ) } - - private static func isStrippableScalar(_ scalar: Unicode.Scalar) -> Bool { - if scalar.properties.isBidiControl || scalar.properties.isDefaultIgnorableCodePoint { - return true - } - switch scalar.properties.generalCategory { - case .control, .format, .lineSeparator, .paragraphSeparator: - return true - default: - return false - } + } + + /// Strips invisible and control Unicode scalars from an inbound contact name. The name + /// is attacker-controlled, so the cleaned form is used for both the visible chip and the + /// add-contact link URL, keeping the confirmation sheet and the persisted contact free + /// of bidi overrides, zero-width joiners, and line breaks that could hide or reorder the + /// visible identity. + static func displayName(for name: String) -> String { + String(String.UnicodeScalarView(name.unicodeScalars.filter { !isStrippableScalar($0) })) + } + + private static func isStrippableScalar(_ scalar: Unicode.Scalar) -> Bool { + if scalar.properties.isBidiControl || scalar.properties.isDefaultIgnorableCodePoint { + return true + } + switch scalar.properties.generalCategory { + case .control, .format, .lineSeparator, .paragraphSeparator: + return true + default: + return false } + } } diff --git a/MC1/Views/Chats/Mentions/ChatConversationMentionOverlay.swift b/MC1/Views/Chats/Mentions/ChatConversationMentionOverlay.swift index 7aee67d5..3569fac1 100644 --- a/MC1/Views/Chats/Mentions/ChatConversationMentionOverlay.swift +++ b/MC1/Views/Chats/Mentions/ChatConversationMentionOverlay.swift @@ -1,30 +1,30 @@ -import SwiftUI import MC1Services +import SwiftUI /// Floating mention suggestions overlay for the chat input. struct ChatConversationMentionOverlay: View { - let suggestions: [ContactDTO] - let onSelectMention: (ContactDTO) -> Void + let suggestions: [ContactDTO] + let onSelectMention: (ContactDTO) -> Void - var body: some View { - if !suggestions.isEmpty { - VStack { - Spacer() - MentionSuggestionView(contacts: suggestions) { contact in - onSelectMention(contact) - } - .padding(.horizontal) - .padding(.bottom, 60) - .transition( - .asymmetric( - insertion: .move(edge: .bottom) - .combined(with: .opacity) - .combined(with: .scale(scale: 0.95, anchor: .bottom)), - removal: .move(edge: .bottom).combined(with: .opacity) - ) - ) - } - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: suggestions.isEmpty) + var body: some View { + if !suggestions.isEmpty { + VStack { + Spacer() + MentionSuggestionView(contacts: suggestions) { contact in + onSelectMention(contact) } + .padding(.horizontal) + .padding(.bottom, 60) + .transition( + .asymmetric( + insertion: .move(edge: .bottom) + .combined(with: .opacity) + .combined(with: .scale(scale: 0.95, anchor: .bottom)), + removal: .move(edge: .bottom).combined(with: .opacity) + ) + ) + } + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: suggestions.isEmpty) } + } } diff --git a/MC1/Views/Chats/Mentions/MentionDeeplinkSupport.swift b/MC1/Views/Chats/Mentions/MentionDeeplinkSupport.swift index 4578b986..a0a61a19 100644 --- a/MC1/Views/Chats/Mentions/MentionDeeplinkSupport.swift +++ b/MC1/Views/Chats/Mentions/MentionDeeplinkSupport.swift @@ -5,40 +5,40 @@ import Foundation /// scheme, host, and percent-encoding here keeps the producer and consumer from /// diverging — a mismatch would route a mention tap to the wrong contact. enum MentionDeeplinkSupport { - static let scheme = "meshcoreone" - static let host = "mention" + static let scheme = "meshcoreone" + static let host = "mention" - /// `/` is removed from the allowed set so a name containing a slash is - /// percent-encoded into a single path component instead of splitting the - /// URL path (which would drop everything before the last slash). - private static let nameAllowed = CharacterSet.urlPathAllowed - .subtracting(CharacterSet(charactersIn: "/")) + /// `/` is removed from the allowed set so a name containing a slash is + /// percent-encoded into a single path component instead of splitting the + /// URL path (which would drop everything before the last slash). + private static let nameAllowed = CharacterSet.urlPathAllowed + .subtracting(CharacterSet(charactersIn: "/")) - /// Builds `meshcoreone://mention/` for a mention run, - /// or `nil` if the name cannot be encoded or is empty. - static func url(forName name: String) -> URL? { - guard let encoded = name.addingPercentEncoding(withAllowedCharacters: nameAllowed), - !encoded.isEmpty else { - return nil - } - return URL(string: "\(scheme)://\(host)/\(encoded)") + /// Builds `meshcoreone://mention/` for a mention run, + /// or `nil` if the name cannot be encoded or is empty. + static func url(forName name: String) -> URL? { + guard let encoded = name.addingPercentEncoding(withAllowedCharacters: nameAllowed), + !encoded.isEmpty else { + return nil } + return URL(string: "\(scheme)://\(host)/\(encoded)") + } - /// Returns the decoded mention name if `url` is a mention deep link, else - /// `nil`. Reads the still-encoded path and decodes it exactly once; - /// `lastPathComponent` is avoided because it decodes `%2F` back to `/` and - /// then re-splits, dropping everything before the slash. - static func name(from url: URL) -> String? { - guard url.scheme == scheme, url.host == host else { return nil } - var encodedPath = url.path(percentEncoded: true) - if encodedPath.hasPrefix("/") { - encodedPath.removeFirst() - } - guard !encodedPath.isEmpty, - let decoded = encodedPath.removingPercentEncoding, - !decoded.isEmpty else { - return nil - } - return decoded + /// Returns the decoded mention name if `url` is a mention deep link, else + /// `nil`. Reads the still-encoded path and decodes it exactly once; + /// `lastPathComponent` is avoided because it decodes `%2F` back to `/` and + /// then re-splits, dropping everything before the slash. + static func name(from url: URL) -> String? { + guard url.scheme == scheme, url.host == host else { return nil } + var encodedPath = url.path(percentEncoded: true) + if encodedPath.hasPrefix("/") { + encodedPath.removeFirst() } + guard !encodedPath.isEmpty, + let decoded = encodedPath.removingPercentEncoding, + !decoded.isEmpty else { + return nil + } + return decoded + } } diff --git a/MC1/Views/Chats/Mentions/MentionPickerContext.swift b/MC1/Views/Chats/Mentions/MentionPickerContext.swift index 28771264..d39ca6dc 100644 --- a/MC1/Views/Chats/Mentions/MentionPickerContext.swift +++ b/MC1/Views/Chats/Mentions/MentionPickerContext.swift @@ -7,9 +7,9 @@ import MC1Services /// surface. Single-match resolutions never construct one — they navigate /// directly. struct MentionPickerContext: Identifiable { - let id = UUID() - let name: String - let radioID: UUID - let matches: [ContactDTO] - let isSelfMention: Bool + let id = UUID() + let name: String + let radioID: UUID + let matches: [ContactDTO] + let isSelfMention: Bool } diff --git a/MC1/Views/Chats/Mentions/MentionPickerSheet.swift b/MC1/Views/Chats/Mentions/MentionPickerSheet.swift index 2049e28f..3406bbb3 100644 --- a/MC1/Views/Chats/Mentions/MentionPickerSheet.swift +++ b/MC1/Views/Chats/Mentions/MentionPickerSheet.swift @@ -1,67 +1,67 @@ -import SwiftUI import MC1Services +import SwiftUI /// Disambiguation / status sheet shown when a tapped `@mention` does not resolve /// to a single saved contact. Presented by `MentionTapHandler` on any chat /// surface that renders mention links (DMs, channels, and rooms). struct MentionPickerSheet: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let context: MentionPickerContext - let onSelect: (ContactDTO) -> Void - let onDismiss: () -> Void + let context: MentionPickerContext + let onSelect: (ContactDTO) -> Void + let onDismiss: () -> Void - private static let listSpacing: CGFloat = 12 - private static let compactDetentHeight: CGFloat = 180 + private static let listSpacing: CGFloat = 12 + private static let compactDetentHeight: CGFloat = 180 - var body: some View { - NavigationStack { - Group { - if context.isSelfMention { - ContentUnavailableView { - Label(L10n.Chats.Chats.Mention.Picker.selfTitle, - systemImage: "person.crop.circle") - } description: { - Text(L10n.Chats.Chats.Mention.Picker.selfSubtitle(context.name)) - } - } else if context.matches.isEmpty { - ContentUnavailableView { - Label(L10n.Chats.Chats.Mention.Picker.notSavedTitle, - systemImage: "person.crop.circle.badge.questionmark") - } description: { - Text(L10n.Chats.Chats.Mention.Picker.notSavedSubtitle(context.name)) - } - } else { - ScrollView { - VStack(alignment: .leading, spacing: Self.listSpacing) { - Text(L10n.Chats.Chats.Mention.Picker.matchingContacts) - .font(.subheadline) - .foregroundStyle(.secondary) + var body: some View { + NavigationStack { + Group { + if context.isSelfMention { + ContentUnavailableView { + Label(L10n.Chats.Chats.Mention.Picker.selfTitle, + systemImage: "person.crop.circle") + } description: { + Text(L10n.Chats.Chats.Mention.Picker.selfSubtitle(context.name)) + } + } else if context.matches.isEmpty { + ContentUnavailableView { + Label(L10n.Chats.Chats.Mention.Picker.notSavedTitle, + systemImage: "person.crop.circle.badge.questionmark") + } description: { + Text(L10n.Chats.Chats.Mention.Picker.notSavedSubtitle(context.name)) + } + } else { + ScrollView { + VStack(alignment: .leading, spacing: Self.listSpacing) { + Text(L10n.Chats.Chats.Mention.Picker.matchingContacts) + .font(.subheadline) + .foregroundStyle(.secondary) - ForEach(context.matches) { match in - ContactMatchRow( - contact: match, - style: .tap, - userLocation: appState.bestAvailableLocation, - action: { onSelect(match) } - ) - } - } - .padding() - } - } - } - .navigationTitle(L10n.Chats.Chats.Mention.Picker.title(context.name)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.done) { - onDismiss() - } - } + ForEach(context.matches) { match in + ContactMatchRow( + contact: match, + style: .tap, + userLocation: appState.bestAvailableLocation, + action: { onSelect(match) } + ) + } } + .padding() + } + } + } + .navigationTitle(L10n.Chats.Chats.Mention.Picker.title(context.name)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.done) { + onDismiss() + } } - .presentationDetents([.height(Self.compactDetentHeight), .medium]) - .presentationDragIndicator(.visible) + } } + .presentationDetents([.height(Self.compactDetentHeight), .medium]) + .presentationDragIndicator(.visible) + } } diff --git a/MC1/Views/Chats/Mentions/MentionTapEvaluator.swift b/MC1/Views/Chats/Mentions/MentionTapEvaluator.swift index ce9c0503..5ae00a26 100644 --- a/MC1/Views/Chats/Mentions/MentionTapEvaluator.swift +++ b/MC1/Views/Chats/Mentions/MentionTapEvaluator.swift @@ -7,57 +7,57 @@ import MC1Services /// effect for `.navigate` outcomes and presents the picker for `.picker`. @MainActor enum MentionTapEvaluator { - enum Outcome { - case navigate(ContactDTO) - case picker(MentionPickerContext) + enum Outcome { + case navigate(ContactDTO) + case picker(MentionPickerContext) + } + + static func evaluate( + rawName: String, + contacts: [ContactDTO], + connectedDeviceName: String?, + radioID: UUID + ) -> Outcome { + let sanitized = MessageText.displayName(for: rawName) + let trimmed = sanitized.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + return .picker(MentionPickerContext( + name: sanitized, + radioID: radioID, + matches: [], + isSelfMention: false + )) } - static func evaluate( - rawName: String, - contacts: [ContactDTO], - connectedDeviceName: String?, - radioID: UUID - ) -> Outcome { - let sanitized = MessageText.displayName(for: rawName) - let trimmed = sanitized.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.isEmpty { - return .picker(MentionPickerContext( - name: sanitized, - radioID: radioID, - matches: [], - isSelfMention: false - )) - } - - let isSelf: Bool = connectedDeviceName.map { - sanitized.localizedCaseInsensitiveCompare($0) == .orderedSame - } ?? false - - if isSelf { - return .picker(MentionPickerContext( - name: sanitized, - radioID: radioID, - matches: [], - isSelfMention: true - )) - } - - let matches = SenderContactMatcher.filter( - contacts: contacts, - senderName: sanitized, - excludeBlocked: false - ) - - if matches.count == 1 { - return .navigate(matches[0]) - } - - return .picker(MentionPickerContext( - name: sanitized, - radioID: radioID, - matches: matches, - isSelfMention: false - )) + let isSelf: Bool = connectedDeviceName.map { + sanitized.localizedCaseInsensitiveCompare($0) == .orderedSame + } ?? false + + if isSelf { + return .picker(MentionPickerContext( + name: sanitized, + radioID: radioID, + matches: [], + isSelfMention: true + )) } + + let matches = SenderContactMatcher.filter( + contacts: contacts, + senderName: sanitized, + excludeBlocked: false + ) + + if matches.count == 1 { + return .navigate(matches[0]) + } + + return .picker(MentionPickerContext( + name: sanitized, + radioID: radioID, + matches: matches, + isSelfMention: false + )) + } } diff --git a/MC1/Views/Chats/Mentions/MentionTapHandler.swift b/MC1/Views/Chats/Mentions/MentionTapHandler.swift index fda7ed8c..dcc0df04 100644 --- a/MC1/Views/Chats/Mentions/MentionTapHandler.swift +++ b/MC1/Views/Chats/Mentions/MentionTapHandler.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Installs the chat-content `OpenURLAction` and the mention disambiguation /// sheet on any chat surface that renders `MessageText`. Mention links @@ -9,81 +9,81 @@ import MC1Services /// `RoomConversationView` so mention taps behave identically across all chat /// surfaces rather than dying silently where no interceptor is installed. struct MentionTapHandler: ViewModifier { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let contacts: [ContactDTO] - let radioID: UUID - /// A secondary (right) click that opens the actions sheet also activates the - /// link-preview card's `Button`, which routes its open through this same - /// action. This returns true while the sheet is presenting so that trailing - /// open is discarded instead of navigating away over the sheet. - let shouldSuppressOpen: () -> Bool + let contacts: [ContactDTO] + let radioID: UUID + /// A secondary (right) click that opens the actions sheet also activates the + /// link-preview card's `Button`, which routes its open through this same + /// action. This returns true while the sheet is presenting so that trailing + /// open is discarded instead of navigating away over the sheet. + let shouldSuppressOpen: () -> Bool - @State private var pickerContext: MentionPickerContext? - @State private var resolverTask: Task? - - func body(content: Content) -> some View { - content - .environment(\.openURL, OpenURLAction { url in - if shouldSuppressOpen() { return .discarded } - if let mentionName = MentionDeeplinkSupport.name(from: url) { - handleMentionTap(name: mentionName) - return .handled - } - return ChatLinkRouter.route(url, appState: appState) ? .handled : .systemAction - }) - .sheet(item: $pickerContext) { context in - MentionPickerSheet( - context: context, - onSelect: { contact in - pickerContext = nil - appState.navigation.navigateToContactDetail(contact) - }, - onDismiss: { pickerContext = nil } - ) - } - .onDisappear { - resolverTask?.cancel() - resolverTask = nil - } - } + @State private var pickerContext: MentionPickerContext? + @State private var resolverTask: Task? - /// Defers resolution into a `@MainActor` task so the sheet mutation lands on - /// a later runloop turn rather than inside the synchronous `openURL` - /// callback, matching `ChatLinkRouter`'s deferral convention. - private func handleMentionTap(name: String) { - resolverTask?.cancel() - resolverTask = Task { @MainActor in - let outcome = MentionTapEvaluator.evaluate( - rawName: name, - contacts: contacts, - connectedDeviceName: appState.connectedDevice?.nodeName, - radioID: radioID - ) - guard !Task.isCancelled else { return } - switch outcome { - case .navigate(let contact): - appState.navigation.navigateToContactDetail(contact) - case .picker(let context): - pickerContext = context - } + func body(content: Content) -> some View { + content + .environment(\.openURL, OpenURLAction { url in + if shouldSuppressOpen() { return .discarded } + if let mentionName = MentionDeeplinkSupport.name(from: url) { + handleMentionTap(name: mentionName) + return .handled } + return ChatLinkRouter.route(url, appState: appState) ? .handled : .systemAction + }) + .sheet(item: $pickerContext) { context in + MentionPickerSheet( + context: context, + onSelect: { contact in + pickerContext = nil + appState.navigation.navigateToContactDetail(contact) + }, + onDismiss: { pickerContext = nil } + ) + } + .onDisappear { + resolverTask?.cancel() + resolverTask = nil + } + } + + /// Defers resolution into a `@MainActor` task so the sheet mutation lands on + /// a later runloop turn rather than inside the synchronous `openURL` + /// callback, matching `ChatLinkRouter`'s deferral convention. + private func handleMentionTap(name: String) { + resolverTask?.cancel() + resolverTask = Task { @MainActor in + let outcome = MentionTapEvaluator.evaluate( + rawName: name, + contacts: contacts, + connectedDeviceName: appState.connectedDevice?.nodeName, + radioID: radioID + ) + guard !Task.isCancelled else { return } + switch outcome { + case let .navigate(contact): + appState.navigation.navigateToContactDetail(contact) + case let .picker(context): + pickerContext = context + } } + } } extension View { - /// Routes mention-link taps through `MentionTapEvaluator` and forwards every - /// other chat URL to `ChatLinkRouter`. Apply to any chat surface that renders - /// `MessageText`. - func mentionTapHandling( - contacts: [ContactDTO], - radioID: UUID, - shouldSuppressOpen: @escaping () -> Bool - ) -> some View { - modifier(MentionTapHandler( - contacts: contacts, - radioID: radioID, - shouldSuppressOpen: shouldSuppressOpen - )) - } + /// Routes mention-link taps through `MentionTapEvaluator` and forwards every + /// other chat URL to `ChatLinkRouter`. Apply to any chat surface that renders + /// `MessageText`. + func mentionTapHandling( + contacts: [ContactDTO], + radioID: UUID, + shouldSuppressOpen: @escaping () -> Bool + ) -> some View { + modifier(MentionTapHandler( + contacts: contacts, + radioID: radioID, + shouldSuppressOpen: shouldSuppressOpen + )) + } } diff --git a/MC1/Views/Chats/Navigation/ChatLinkRouter.swift b/MC1/Views/Chats/Navigation/ChatLinkRouter.swift index 2e370a1c..ecb0c120 100644 --- a/MC1/Views/Chats/Navigation/ChatLinkRouter.swift +++ b/MC1/Views/Chats/Navigation/ChatLinkRouter.swift @@ -1,6 +1,6 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let chatLinkRouterLogger = Logger(subsystem: "com.mc1", category: "ChatLinkRouter") @@ -19,102 +19,101 @@ private let chatLinkRouterLogger = Logger(subsystem: "com.mc1", category: "ChatL /// `OpenURLAction` before forwarding everything else to this router. @MainActor enum ChatLinkRouter { - - static func route(_ url: URL, appState: AppState) -> Bool { - if url.scheme == MeshCoreURLParser.scheme { - handleMeshCoreLink(url, appState: appState) - return true - } - if url.scheme == HashtagDeeplinkSupport.scheme, - url.host == HashtagDeeplinkSupport.host { - if let channelName = HashtagDeeplinkSupport.channelNameFromURL(url) { - handleHashtagTap(name: channelName, appState: appState) - } else { - chatLinkRouterLogger.error("Hashtag URL missing host: \(url.absoluteString, privacy: .public)") - } - return true - } - return false + static func route(_ url: URL, appState: AppState) -> Bool { + if url.scheme == MeshCoreURLParser.scheme { + handleMeshCoreLink(url, appState: appState) + return true + } + if url.scheme == HashtagDeeplinkSupport.scheme, + url.host == HashtagDeeplinkSupport.host { + if let channelName = HashtagDeeplinkSupport.channelNameFromURL(url) { + handleHashtagTap(name: channelName, appState: appState) + } else { + chatLinkRouterLogger.error("Hashtag URL missing host: \(url.absoluteString, privacy: .public)") + } + return true } + return false + } - private static func handleMeshCoreLink(_ url: URL, appState: AppState) { - let urlString = url.absoluteString + private static func handleMeshCoreLink(_ url: URL, appState: AppState) { + let urlString = url.absoluteString - if let coordinate = MeshCoreURLParser.parseMapURL(urlString) { - appState.navigation.navigateToMap(coordinate: coordinate) - } else if let contactResult = MeshCoreURLParser.parseContactURL(urlString) { - handleContactLink(contactResult, appState: appState) - } else if let channelResult = MeshCoreURLParser.parseChannelURL(urlString) { - handleChannelLink(channelResult, appState: appState) - } else { - chatLinkRouterLogger.error("Failed to parse meshcore URL: \(urlString, privacy: .public)") - } + if let coordinate = MeshCoreURLParser.parseMapURL(urlString) { + appState.navigation.navigateToMap(coordinate: coordinate) + } else if let contactResult = MeshCoreURLParser.parseContactURL(urlString) { + handleContactLink(contactResult, appState: appState) + } else if let channelResult = MeshCoreURLParser.parseChannelURL(urlString) { + handleChannelLink(channelResult, appState: appState) + } else { + chatLinkRouterLogger.error("Failed to parse meshcore URL: \(urlString, privacy: .public)") } + } - private static func handleContactLink( - _ result: MeshCoreURLParser.ContactResult, - appState: AppState - ) { - Task { @MainActor in - if result.publicKey == appState.connectedDevice?.publicKey { - return - } + private static func handleContactLink( + _ result: MeshCoreURLParser.ContactResult, + appState: AppState + ) { + Task { @MainActor in + if result.publicKey == appState.connectedDevice?.publicKey { + return + } - if let deviceID = appState.currentRadioID, - let existingContact = try? await appState.offlineDataStore?.fetchContact( - radioID: deviceID, - publicKey: result.publicKey - ) { - appState.navigation.navigateToContactDetail(existingContact) - } else { - appState.navigation.pendingContactLink = result - } - } + if let deviceID = appState.currentRadioID, + let existingContact = try? await appState.offlineDataStore?.fetchContact( + radioID: deviceID, + publicKey: result.publicKey + ) { + appState.navigation.navigateToContactDetail(existingContact) + } else { + appState.navigation.pendingContactLink = result + } } + } - private static func handleChannelLink( - _ result: MeshCoreURLParser.ChannelResult, - appState: AppState - ) { - Task { @MainActor in - if let deviceID = appState.currentRadioID, - let channels = try? await appState.offlineDataStore?.fetchChannels(radioID: deviceID), - let existingChannel = channels.first(where: { $0.secret == result.secret }) { - appState.navigation.navigateToChannel(with: existingChannel) - } else { - appState.navigation.pendingChannelLink = result - } - } + private static func handleChannelLink( + _ result: MeshCoreURLParser.ChannelResult, + appState: AppState + ) { + Task { @MainActor in + if let deviceID = appState.currentRadioID, + let channels = try? await appState.offlineDataStore?.fetchChannels(radioID: deviceID), + let existingChannel = channels.first(where: { $0.secret == result.secret }) { + appState.navigation.navigateToChannel(with: existingChannel) + } else { + appState.navigation.pendingChannelLink = result + } } + } - private static func handleHashtagTap(name: String, appState: AppState) { - Task { @MainActor in - guard let fullName = HashtagDeeplinkSupport.fullChannelName(from: name) else { - chatLinkRouterLogger.error("Invalid hashtag name in tap: \(name, privacy: .public)") - return - } + private static func handleHashtagTap(name: String, appState: AppState) { + Task { @MainActor in + guard let fullName = HashtagDeeplinkSupport.fullChannelName(from: name) else { + chatLinkRouterLogger.error("Invalid hashtag name in tap: \(name, privacy: .public)") + return + } - guard let deviceID = appState.currentRadioID else { - appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) - return - } + guard let deviceID = appState.currentRadioID else { + appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) + return + } - do { - if let channel = try await HashtagDeeplinkSupport.findChannelByName( - fullName, - radioID: deviceID, - fetchChannels: { deviceID in - try await appState.offlineDataStore?.fetchChannels(radioID: deviceID) ?? [] - } - ) { - appState.navigation.navigateToChannel(with: channel) - } else { - appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) - } - } catch { - chatLinkRouterLogger.error("Failed to fetch channels for hashtag lookup: \(error)") - appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) - } + do { + if let channel = try await HashtagDeeplinkSupport.findChannelByName( + fullName, + radioID: deviceID, + fetchChannels: { deviceID in + try await appState.offlineDataStore?.fetchChannels(radioID: deviceID) ?? [] + } + ) { + appState.navigation.navigateToChannel(with: channel) + } else { + appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) } + } catch { + chatLinkRouterLogger.error("Failed to fetch channels for hashtag lookup: \(error)") + appState.navigation.pendingHashtag = HashtagJoinRequest(id: fullName) + } } + } } diff --git a/MC1/Views/Chats/Navigation/ChatRoute.swift b/MC1/Views/Chats/Navigation/ChatRoute.swift index 8b6a63d9..167d4ce1 100644 --- a/MC1/Views/Chats/Navigation/ChatRoute.swift +++ b/MC1/Views/Chats/Navigation/ChatRoute.swift @@ -2,82 +2,82 @@ import Foundation import MC1Services enum ChatRoute: Hashable { - case direct(ContactDTO) - case channel(ChannelDTO) - case room(RemoteNodeSessionDTO) + case direct(ContactDTO) + case channel(ChannelDTO) + case room(RemoteNodeSessionDTO) - enum Kind: UInt8, Hashable { - case direct - case channel - case room - } + enum Kind: UInt8, Hashable { + case direct + case channel + case room + } - var kind: Kind { - switch self { - case .direct: - return .direct - case .channel: - return .channel - case .room: - return .room - } + var kind: Kind { + switch self { + case .direct: + .direct + case .channel: + .channel + case .room: + .room } + } - var conversationID: UUID { - switch self { - case .direct(let contact): - return contact.id - case .channel(let channel): - return channel.id - case .room(let session): - return session.id - } + var conversationID: UUID { + switch self { + case let .direct(contact): + contact.id + case let .channel(channel): + channel.id + case let .room(session): + session.id } + } - static func == (lhs: ChatRoute, rhs: ChatRoute) -> Bool { - lhs.kind == rhs.kind && lhs.conversationID == rhs.conversationID - } + static func == (lhs: ChatRoute, rhs: ChatRoute) -> Bool { + lhs.kind == rhs.kind && lhs.conversationID == rhs.conversationID + } - func hash(into hasher: inout Hasher) { - hasher.combine(kind) - hasher.combine(conversationID) - } + func hash(into hasher: inout Hasher) { + hasher.combine(kind) + hasher.combine(conversationID) + } - init(conversation: Conversation) { - switch conversation { - case .direct(let contact): - self = .direct(contact) - case .channel(let channel): - self = .channel(channel) - case .room(let session): - self = .room(session) - } + init(conversation: Conversation) { + switch conversation { + case let .direct(contact): + self = .direct(contact) + case let .channel(channel): + self = .channel(channel) + case let .room(session): + self = .room(session) } + } - var roomIsConnected: Bool? { - guard case .room(let session) = self else { return nil } - return session.isConnected - } + var roomIsConnected: Bool? { + guard case let .room(session) = self else { return nil } + return session.isConnected + } - func toConversation() -> Conversation { - switch self { - case .direct(let contact): - return .direct(contact) - case .channel(let channel): - return .channel(channel) - case .room(let session): - return .room(session) - } + func toConversation() -> Conversation { + switch self { + case let .direct(contact): + .direct(contact) + case let .channel(channel): + .channel(channel) + case let .room(session): + .room(session) } + } - func refreshedPayload(from conversations: [Conversation]) -> ChatRoute? { - guard let match = conversations.first(where: { conversation in - let route = ChatRoute(conversation: conversation) - return route.kind == kind && route.conversationID == conversationID - }) else { - return nil - } - - return ChatRoute(conversation: match) + func refreshedPayload(from conversations: [Conversation]) -> ChatRoute? { + guard let match = conversations.first(where: { conversation in + let route = ChatRoute(conversation: conversation) + return route.kind == kind && route.conversationID == conversationID + }) else { + return nil } + + return ChatRoute(conversation: match) + } } diff --git a/MC1/Views/Chats/Navigation/ChatsContentColumn.swift b/MC1/Views/Chats/Navigation/ChatsContentColumn.swift index 90524a0e..0baafdd0 100644 --- a/MC1/Views/Chats/Navigation/ChatsContentColumn.swift +++ b/MC1/Views/Chats/Navigation/ChatsContentColumn.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// The iPad sidebar's Chats content column. It mirrors the regular-width (split) path of /// `ChatsView`, supplying real action closures to `ChatsSplitSidebarContent` and attaching @@ -7,155 +7,155 @@ import MC1Services /// solely in `ChatsView`. The `viewModel` is passed in so the content and detail columns /// share one instance. struct ChatsContentColumn: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let viewModel: ChatViewModel + let viewModel: ChatViewModel - @State private var searchText = "" - @State private var selectedFilter: ChatFilter = .all - @State private var showingNewChat = false - @State private var showingChannelOptions = false + @State private var searchText = "" + @State private var selectedFilter: ChatFilter = .all + @State private var showingNewChat = false + @State private var showingChannelOptions = false - /// View-local mirror of `appState.navigation.chatsSelectedRoute`; the detail column keys - /// off the latter, and `ChatsSplitSidebarContent.onChange` keeps the two in sync. Re-seeded - /// from the preserved route in `.task` because leaving and re-entering Chats rebuilds this - /// column and resets the mirror, which would otherwise un-highlight the row the detail shows. - @State private var selectedRoute: ChatRoute? - @State private var lastSelectedRoomIsConnected: Bool? + /// View-local mirror of `appState.navigation.chatsSelectedRoute`; the detail column keys + /// off the latter, and `ChatsSplitSidebarContent.onChange` keeps the two in sync. Re-seeded + /// from the preserved route in `.task` because leaving and re-entering Chats rebuilds this + /// column and resets the mirror, which would otherwise un-highlight the row the detail shows. + @State private var selectedRoute: ChatRoute? + @State private var lastSelectedRoomIsConnected: Bool? - @State private var roomToAuthenticate: RemoteNodeSessionDTO? - @State private var roomToDelete: RemoteNodeSessionDTO? - @State private var showRoomDeleteAlert = false - @State private var showChannelDeleteFailed = false - @State private var channelDeleteFailure: ChatConversationActions.Failure? - @State private var pendingChatContact: ContactDTO? - @State private var pendingChannel: ChannelDTO? + @State private var roomToAuthenticate: RemoteNodeSessionDTO? + @State private var roomToDelete: RemoteNodeSessionDTO? + @State private var showRoomDeleteAlert = false + @State private var showChannelDeleteFailed = false + @State private var channelDeleteFailure: ChatConversationActions.Failure? + @State private var pendingChatContact: ContactDTO? + @State private var pendingChannel: ChannelDTO? - private var filteredFavorites: [Conversation] { - viewModel.favoriteConversations.filtered(by: selectedFilter, searchText: searchText) - } + private var filteredFavorites: [Conversation] { + viewModel.favoriteConversations.filtered(by: selectedFilter, searchText: searchText) + } - private var filteredOthers: [Conversation] { - viewModel.nonFavoriteConversations.filtered(by: selectedFilter, searchText: searchText) - } + private var filteredOthers: [Conversation] { + viewModel.nonFavoriteConversations.filtered(by: selectedFilter, searchText: searchText) + } - private var emptyStateMessage: (title: String, description: String, systemImage: String) { - switch selectedFilter { - case .all: - return (L10n.Chats.Chats.EmptyState.NoConversations.title, L10n.Chats.Chats.EmptyState.NoConversations.description, "message") - case .unread: - return (L10n.Chats.Chats.EmptyState.NoUnread.title, L10n.Chats.Chats.EmptyState.NoUnread.description, "checkmark.circle") - case .directMessages: - return (L10n.Chats.Chats.EmptyState.NoDirectMessages.title, L10n.Chats.Chats.EmptyState.NoDirectMessages.description, "person") - case .channels: - return (L10n.Chats.Chats.EmptyState.NoChannels.title, L10n.Chats.Chats.EmptyState.NoChannels.description, "number") - case .rooms: - return (L10n.Chats.Chats.EmptyState.NoRooms.title, L10n.Chats.Chats.EmptyState.NoRooms.description, "door.left.hand.open") - } + private var emptyStateMessage: (title: String, description: String, systemImage: String) { + switch selectedFilter { + case .all: + (L10n.Chats.Chats.EmptyState.NoConversations.title, L10n.Chats.Chats.EmptyState.NoConversations.description, "message") + case .unread: + (L10n.Chats.Chats.EmptyState.NoUnread.title, L10n.Chats.Chats.EmptyState.NoUnread.description, "checkmark.circle") + case .directMessages: + (L10n.Chats.Chats.EmptyState.NoDirectMessages.title, L10n.Chats.Chats.EmptyState.NoDirectMessages.description, "person") + case .channels: + (L10n.Chats.Chats.EmptyState.NoChannels.title, L10n.Chats.Chats.EmptyState.NoChannels.description, "number") + case .rooms: + (L10n.Chats.Chats.EmptyState.NoRooms.title, L10n.Chats.Chats.EmptyState.NoRooms.description, "door.left.hand.open") } + } - private var actions: ChatListActions { - ChatListActions( - viewModel: viewModel, - appState: appState, - roomToDelete: $roomToDelete, - showRoomDeleteAlert: $showRoomDeleteAlert, - channelDeleteFailure: $channelDeleteFailure, - showChannelDeleteFailed: $showChannelDeleteFailed, - roomToAuthenticate: $roomToAuthenticate, - navigate: { navigate(to: $0) }, - clearNavigationIfActive: clearNavigationIfActive - ) - } + private var actions: ChatListActions { + ChatListActions( + viewModel: viewModel, + appState: appState, + roomToDelete: $roomToDelete, + showRoomDeleteAlert: $showRoomDeleteAlert, + channelDeleteFailure: $channelDeleteFailure, + showChannelDeleteFailed: $showChannelDeleteFailed, + roomToAuthenticate: $roomToAuthenticate, + navigate: { navigate(to: $0) }, + clearNavigationIfActive: clearNavigationIfActive + ) + } - var body: some View { - ChatsSplitSidebarContent( - viewModel: viewModel, - filteredFavorites: filteredFavorites, - filteredOthers: filteredOthers, - emptyStateMessage: emptyStateMessage, - hasLoadedOnce: viewModel.hasLoadedOnce, - selectedRoute: $selectedRoute, - selectedFilter: $selectedFilter, - searchText: $searchText, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - roomToAuthenticate: $roomToAuthenticate, - lastSelectedRoomIsConnected: $lastSelectedRoomIsConnected, - onDeleteConversation: actions.handleDeleteConversation, - onHandlePendingNavigation: actions.handlePendingNavigation, - onHandlePendingChannelNavigation: actions.handlePendingChannelNavigation, - onHandlePendingRoomNavigation: actions.handlePendingRoomNavigation, - onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded - ) - .task { - seedSelectionFromPreservedRoute() - actions.consumePendingRoomAuthentication() - } - .onChange(of: appState.navigation.pendingRoomAuthentication) { _, _ in - actions.consumePendingRoomAuthentication() - } - .onChange(of: appState.navigation.chatsSelectedRoute) { _, newRoute in - // The detail column keys off chatsSelectedRoute, but the list highlight keys off the - // view-local mirror. An external clear (a radio switch runs clearPerRadioSelection while - // Chats stays mounted, so .task never re-seeds) only nils the coordinator route, so drop - // the mirror here too to keep the list highlight and detail pane in agreement. - if newRoute == nil { - selectedRoute = nil - lastSelectedRoomIsConnected = nil - } - } - // Refresh the selected route's payload as the snapshot recomputes and re-run the - // room reauth guard; a route whose conversation was removed resolves to nil and clears. - .onChange(of: viewModel.snapshotGeneration) { _, _ in - let refreshed = selectedRoute?.refreshedPayload(from: viewModel.allConversations) - selectedRoute = refreshed + var body: some View { + ChatsSplitSidebarContent( + viewModel: viewModel, + filteredFavorites: filteredFavorites, + filteredOthers: filteredOthers, + emptyStateMessage: emptyStateMessage, + hasLoadedOnce: viewModel.hasLoadedOnce, + selectedRoute: $selectedRoute, + selectedFilter: $selectedFilter, + searchText: $searchText, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + roomToAuthenticate: $roomToAuthenticate, + lastSelectedRoomIsConnected: $lastSelectedRoomIsConnected, + onDeleteConversation: actions.handleDeleteConversation, + onHandlePendingNavigation: actions.handlePendingNavigation, + onHandlePendingChannelNavigation: actions.handlePendingChannelNavigation, + onHandlePendingRoomNavigation: actions.handlePendingRoomNavigation, + onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded + ) + .task { + seedSelectionFromPreservedRoute() + actions.consumePendingRoomAuthentication() + } + .onChange(of: appState.navigation.pendingRoomAuthentication) { _, _ in + actions.consumePendingRoomAuthentication() + } + .onChange(of: appState.navigation.chatsSelectedRoute) { _, newRoute in + // The detail column keys off chatsSelectedRoute, but the list highlight keys off the + // view-local mirror. An external clear (a radio switch runs clearPerRadioSelection while + // Chats stays mounted, so .task never re-seeds) only nils the coordinator route, so drop + // the mirror here too to keep the list highlight and detail pane in agreement. + if newRoute == nil { + selectedRoute = nil + lastSelectedRoomIsConnected = nil + } + } + // Refresh the selected route's payload as the snapshot recomputes and re-run the + // room reauth guard; a route whose conversation was removed resolves to nil and clears. + .onChange(of: viewModel.snapshotGeneration) { _, _ in + let refreshed = selectedRoute?.refreshedPayload(from: viewModel.allConversations) + selectedRoute = refreshed - if lastSelectedRoomIsConnected == true, - case .room(let session) = selectedRoute, - !session.isConnected { - roomToAuthenticate = session - selectedRoute = nil - } + if lastSelectedRoomIsConnected == true, + case let .room(session) = selectedRoute, + !session.isConnected { + roomToAuthenticate = session + selectedRoute = nil + } - lastSelectedRoomIsConnected = selectedRoute?.roomIsConnected - } - .modifier(ChatsConversationSheets( - viewModel: viewModel, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - roomToAuthenticate: $roomToAuthenticate, - roomToDelete: $roomToDelete, - showRoomDeleteAlert: $showRoomDeleteAlert, - channelDeleteFailure: $channelDeleteFailure, - showChannelDeleteFailed: $showChannelDeleteFailed, - pendingChatContact: $pendingChatContact, - pendingChannel: $pendingChannel, - navigate: { navigate(to: $0) }, - deleteChannelConversation: actions.deleteChannelConversation, - deleteRoom: actions.deleteRoom - )) + lastSelectedRoomIsConnected = selectedRoute?.roomIsConnected } + .modifier(ChatsConversationSheets( + viewModel: viewModel, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + roomToAuthenticate: $roomToAuthenticate, + roomToDelete: $roomToDelete, + showRoomDeleteAlert: $showRoomDeleteAlert, + channelDeleteFailure: $channelDeleteFailure, + showChannelDeleteFailed: $showChannelDeleteFailed, + pendingChatContact: $pendingChatContact, + pendingChannel: $pendingChannel, + navigate: { navigate(to: $0) }, + deleteChannelConversation: actions.deleteChannelConversation, + deleteRoom: actions.deleteRoom + )) + } - private func navigate(to route: ChatRoute) { - selectedRoute = route - appState.navigation.chatsSelectedRoute = route - } + private func navigate(to route: ChatRoute) { + selectedRoute = route + appState.navigation.chatsSelectedRoute = route + } - private func clearNavigationIfActive(_ route: ChatRoute) { - if appState.navigation.chatsSelectedRoute == route { - selectedRoute = nil - appState.navigation.chatsSelectedRoute = nil - } + private func clearNavigationIfActive(_ route: ChatRoute) { + if appState.navigation.chatsSelectedRoute == route { + selectedRoute = nil + appState.navigation.chatsSelectedRoute = nil } + } - /// Restores the view-local selection from the route preserved in `NavigationCoordinator` when - /// this column is rebuilt on Chats re-entry, so the list re-highlights the row the detail pane - /// is still showing and the room-reauth guard regains its `lastSelectedRoomIsConnected` baseline. - /// Skips when a selection already exists so a pending navigation that ran first is not clobbered. - private func seedSelectionFromPreservedRoute() { - guard selectedRoute == nil, let route = appState.navigation.chatsSelectedRoute else { return } - selectedRoute = route - lastSelectedRoomIsConnected = route.roomIsConnected - } + /// Restores the view-local selection from the route preserved in `NavigationCoordinator` when + /// this column is rebuilt on Chats re-entry, so the list re-highlights the row the detail pane + /// is still showing and the room-reauth guard regains its `lastSelectedRoomIsConnected` baseline. + /// Skips when a selection already exists so a pending navigation that ran first is not clobbered. + private func seedSelectionFromPreservedRoute() { + guard selectedRoute == nil, let route = appState.navigation.chatsSelectedRoute else { return } + selectedRoute = route + lastSelectedRoomIsConnected = route.roomIsConnected + } } diff --git a/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift b/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift index 5a31178b..d6caa434 100644 --- a/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift +++ b/MC1/Views/Chats/Navigation/ChatsSplitDetailContent.swift @@ -1,24 +1,24 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChatsSplitDetailContent: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let viewModel: ChatViewModel + let viewModel: ChatViewModel - var body: some View { - switch appState.navigation.chatsSelectedRoute { - case .direct(let contact): - ChatConversationView(conversationType: .dm(contact), parentViewModel: viewModel) - .id(contact.id) - case .channel(let channel): - ChatConversationView(conversationType: .channel(channel), parentViewModel: viewModel) - .id(channel.id) - case .room(let session): - RoomConversationView(session: session) - .id(session.id) - case .none: - ContentUnavailableView(L10n.Chats.Chats.EmptyState.selectConversation, systemImage: "message") - } + var body: some View { + switch appState.navigation.chatsSelectedRoute { + case let .direct(contact): + ChatConversationView(conversationType: .dm(contact), parentViewModel: viewModel) + .id(contact.id) + case let .channel(channel): + ChatConversationView(conversationType: .channel(channel), parentViewModel: viewModel) + .id(channel.id) + case let .room(session): + RoomConversationView(session: session) + .id(session.id) + case .none: + ContentUnavailableView(L10n.Chats.Chats.EmptyState.selectConversation, systemImage: "message") } + } } diff --git a/MC1/Views/Chats/Navigation/ChatsSplitSidebarContent.swift b/MC1/Views/Chats/Navigation/ChatsSplitSidebarContent.swift index 5fb1c842..d36784c7 100644 --- a/MC1/Views/Chats/Navigation/ChatsSplitSidebarContent.swift +++ b/MC1/Views/Chats/Navigation/ChatsSplitSidebarContent.swift @@ -1,73 +1,72 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChatsSplitSidebarContent: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let viewModel: ChatViewModel - let filteredFavorites: [Conversation] - let filteredOthers: [Conversation] - let emptyStateMessage: (title: String, description: String, systemImage: String) - let hasLoadedOnce: Bool + let viewModel: ChatViewModel + let filteredFavorites: [Conversation] + let filteredOthers: [Conversation] + let emptyStateMessage: (title: String, description: String, systemImage: String) + let hasLoadedOnce: Bool - @Binding var selectedRoute: ChatRoute? - @Binding var selectedFilter: ChatFilter - @Binding var searchText: String - @Binding var showingNewChat: Bool - @Binding var showingChannelOptions: Bool - @Binding var roomToAuthenticate: RemoteNodeSessionDTO? - @Binding var lastSelectedRoomIsConnected: Bool? + @Binding var selectedRoute: ChatRoute? + @Binding var selectedFilter: ChatFilter + @Binding var searchText: String + @Binding var showingNewChat: Bool + @Binding var showingChannelOptions: Bool + @Binding var roomToAuthenticate: RemoteNodeSessionDTO? + @Binding var lastSelectedRoomIsConnected: Bool? - let onDeleteConversation: (Conversation) -> Void - let onHandlePendingNavigation: () -> Void - let onHandlePendingChannelNavigation: () -> Void - let onHandlePendingRoomNavigation: () -> Void - let onAnnounceOfflineStateIfNeeded: () -> Void + let onDeleteConversation: (Conversation) -> Void + let onHandlePendingNavigation: () -> Void + let onHandlePendingChannelNavigation: () -> Void + let onHandlePendingRoomNavigation: () -> Void + let onAnnounceOfflineStateIfNeeded: () -> Void - var body: some View { - ConversationListContent( - viewModel: viewModel, - favoriteConversations: filteredFavorites, - otherConversations: filteredOthers, - selectedFilter: $selectedFilter, - hasLoadedOnce: hasLoadedOnce, - emptyStateMessage: emptyStateMessage, - selection: $selectedRoute, - onDeleteConversation: onDeleteConversation - ) - .modifier(ChatsListModifiers( - viewModel: viewModel, - searchText: $searchText, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - onAnnounceOfflineStateIfNeeded: onAnnounceOfflineStateIfNeeded, - onHandlePendingNavigation: onHandlePendingNavigation, - onHandlePendingChannelNavigation: onHandlePendingChannelNavigation, - onHandlePendingRoomNavigation: onHandlePendingRoomNavigation - )) - .onChange(of: selectedRoute) { oldValue, newValue in - // Reload when navigating away from a selection. The funnel and pendingRemovalIDs - // keep a delete that cleared the selection from resurrecting the row through this reload. - if oldValue != nil { - viewModel.requestConversationReload() - } + var body: some View { + ConversationListContent( + viewModel: viewModel, + favoriteConversations: filteredFavorites, + otherConversations: filteredOthers, + selectedFilter: $selectedFilter, + hasLoadedOnce: hasLoadedOnce, + emptyStateMessage: emptyStateMessage, + selection: $selectedRoute, + onDeleteConversation: onDeleteConversation + ) + .modifier(ChatsListModifiers( + viewModel: viewModel, + searchText: $searchText, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + onAnnounceOfflineStateIfNeeded: onAnnounceOfflineStateIfNeeded, + onHandlePendingNavigation: onHandlePendingNavigation, + onHandlePendingChannelNavigation: onHandlePendingChannelNavigation, + onHandlePendingRoomNavigation: onHandlePendingRoomNavigation + )) + .onChange(of: selectedRoute) { oldValue, newValue in + // Reload when navigating away from a selection. The funnel and pendingRemovalIDs + // keep a delete that cleared the selection from resurrecting the row through this reload. + if oldValue != nil { + viewModel.requestConversationReload() + } - if case .room(let session) = newValue, !session.isConnected { - roomToAuthenticate = session - selectedRoute = nil - appState.navigation.chatsSelectedRoute = nil - lastSelectedRoomIsConnected = nil - return - } + if case let .room(session) = newValue, !session.isConnected { + roomToAuthenticate = session + selectedRoute = nil + appState.navigation.chatsSelectedRoute = nil + lastSelectedRoomIsConnected = nil + return + } - lastSelectedRoomIsConnected = newValue?.roomIsConnected + lastSelectedRoomIsConnected = newValue?.roomIsConnected - // Mirror the sidebar selection to AppState so the detail pane tracks it. Assigned - // unconditionally, including nil: the disconnected-room reload path clears the local - // selection without writing `chatsSelectedRoute`, so mirroring nil here is what dismisses - // the now-stale detail pane. - appState.navigation.chatsSelectedRoute = newValue - } + // Mirror the sidebar selection to AppState so the detail pane tracks it. Assigned + // unconditionally, including nil: the disconnected-room reload path clears the local + // selection without writing `chatsSelectedRoute`, so mirroring nil here is what dismisses + // the now-stale detail pane. + appState.navigation.chatsSelectedRoute = newValue } - + } } diff --git a/MC1/Views/Chats/Navigation/ChatsStackLayout.swift b/MC1/Views/Chats/Navigation/ChatsStackLayout.swift index 1f29b92a..ec3f0250 100644 --- a/MC1/Views/Chats/Navigation/ChatsStackLayout.swift +++ b/MC1/Views/Chats/Navigation/ChatsStackLayout.swift @@ -1,47 +1,47 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChatsStackLayout: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let viewModel: ChatViewModel - @Binding var navigationPath: NavigationPath - @Binding var activeRoute: ChatRoute? + let viewModel: ChatViewModel + @Binding var navigationPath: NavigationPath + @Binding var activeRoute: ChatRoute? - @ViewBuilder let rootContent: RootContent + @ViewBuilder let rootContent: RootContent - var body: some View { - NavigationStack(path: $navigationPath) { - rootContent - .navigationDestination(for: ChatRoute.self) { route in - Group { - switch route { - case .direct(let contact): - ChatConversationView(conversationType: .dm(contact), parentViewModel: viewModel) - .id(contact.id) + var body: some View { + NavigationStack(path: $navigationPath) { + rootContent + .navigationDestination(for: ChatRoute.self) { route in + Group { + switch route { + case let .direct(contact): + ChatConversationView(conversationType: .dm(contact), parentViewModel: viewModel) + .id(contact.id) - case .channel(let channel): - ChatConversationView(conversationType: .channel(channel), parentViewModel: viewModel) - .id(channel.id) + case let .channel(channel): + ChatConversationView(conversationType: .channel(channel), parentViewModel: viewModel) + .id(channel.id) - case .room(let session): - RoomConversationView(session: session) - .id(session.id) - } - } - .onAppear { - activeRoute = route - appState.navigation.tabBarVisibility = .hidden - } - } - .onChange(of: navigationPath) { _, newPath in - if newPath.isEmpty { - activeRoute = nil - appState.navigation.tabBarVisibility = .visible - viewModel.requestConversationReload() - } - } - .toolbarVisibility(appState.navigation.tabBarVisibility, for: .tabBar) + case let .room(session): + RoomConversationView(session: session) + .id(session.id) + } + } + .onAppear { + activeRoute = route + appState.navigation.tabBarVisibility = .hidden + } + } + .onChange(of: navigationPath) { _, newPath in + if newPath.isEmpty { + activeRoute = nil + appState.navigation.tabBarVisibility = .visible + viewModel.requestConversationReload() + } } + .toolbarVisibility(appState.navigation.tabBarVisibility, for: .tabBar) } + } } diff --git a/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift b/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift index 5a9519ed..9791909f 100644 --- a/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift +++ b/MC1/Views/Chats/Navigation/ChatsStackRootContent.swift @@ -1,47 +1,47 @@ -import SwiftUI import MC1Services +import SwiftUI struct ChatsStackRootContent: View { - let viewModel: ChatViewModel - let filteredFavorites: [Conversation] - let filteredOthers: [Conversation] - let emptyStateMessage: (title: String, description: String, systemImage: String) - let hasLoadedOnce: Bool + let viewModel: ChatViewModel + let filteredFavorites: [Conversation] + let filteredOthers: [Conversation] + let emptyStateMessage: (title: String, description: String, systemImage: String) + let hasLoadedOnce: Bool - @Binding var selectedFilter: ChatFilter - @Binding var searchText: String - @Binding var showingNewChat: Bool - @Binding var showingChannelOptions: Bool - @Binding var roomToAuthenticate: RemoteNodeSessionDTO? - @Binding var navigationPath: NavigationPath + @Binding var selectedFilter: ChatFilter + @Binding var searchText: String + @Binding var showingNewChat: Bool + @Binding var showingChannelOptions: Bool + @Binding var roomToAuthenticate: RemoteNodeSessionDTO? + @Binding var navigationPath: NavigationPath - let onDeleteConversation: (Conversation) -> Void - let onHandlePendingNavigation: () -> Void - let onHandlePendingChannelNavigation: () -> Void - let onHandlePendingRoomNavigation: () -> Void - let onAnnounceOfflineStateIfNeeded: () -> Void + let onDeleteConversation: (Conversation) -> Void + let onHandlePendingNavigation: () -> Void + let onHandlePendingChannelNavigation: () -> Void + let onHandlePendingRoomNavigation: () -> Void + let onAnnounceOfflineStateIfNeeded: () -> Void - var body: some View { - ConversationListContent( - viewModel: viewModel, - favoriteConversations: filteredFavorites, - otherConversations: filteredOthers, - selectedFilter: $selectedFilter, - hasLoadedOnce: hasLoadedOnce, - emptyStateMessage: emptyStateMessage, - onNavigate: { navigationPath.append($0) }, - onRequestRoomAuth: { roomToAuthenticate = $0 }, - onDeleteConversation: onDeleteConversation - ) - .modifier(ChatsListModifiers( - viewModel: viewModel, - searchText: $searchText, - showingNewChat: $showingNewChat, - showingChannelOptions: $showingChannelOptions, - onAnnounceOfflineStateIfNeeded: onAnnounceOfflineStateIfNeeded, - onHandlePendingNavigation: onHandlePendingNavigation, - onHandlePendingChannelNavigation: onHandlePendingChannelNavigation, - onHandlePendingRoomNavigation: onHandlePendingRoomNavigation - )) - } + var body: some View { + ConversationListContent( + viewModel: viewModel, + favoriteConversations: filteredFavorites, + otherConversations: filteredOthers, + selectedFilter: $selectedFilter, + hasLoadedOnce: hasLoadedOnce, + emptyStateMessage: emptyStateMessage, + onNavigate: { navigationPath.append($0) }, + onRequestRoomAuth: { roomToAuthenticate = $0 }, + onDeleteConversation: onDeleteConversation + ) + .modifier(ChatsListModifiers( + viewModel: viewModel, + searchText: $searchText, + showingNewChat: $showingNewChat, + showingChannelOptions: $showingChannelOptions, + onAnnounceOfflineStateIfNeeded: onAnnounceOfflineStateIfNeeded, + onHandlePendingNavigation: onHandlePendingNavigation, + onHandlePendingChannelNavigation: onHandlePendingChannelNavigation, + onHandlePendingRoomNavigation: onHandlePendingRoomNavigation + )) + } } diff --git a/MC1/Views/Chats/NewChatView.swift b/MC1/Views/Chats/NewChatView.swift index 4f0edc77..bcfd35d4 100644 --- a/MC1/Views/Chats/NewChatView.swift +++ b/MC1/Views/Chats/NewChatView.swift @@ -1,95 +1,95 @@ -import SwiftUI import MC1Services +import SwiftUI struct NewChatView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - let onSelectContact: (ContactDTO) -> Void + let onSelectContact: (ContactDTO) -> Void - @State private var contacts: [ContactDTO] = [] - @State private var searchText = "" - @State private var isLoading = false + @State private var contacts: [ContactDTO] = [] + @State private var searchText = "" + @State private var isLoading = false - private var filteredContacts: [ContactDTO] { - let eligible = contacts.filter { !$0.isBlocked && $0.type != .repeater && $0.type != .room } - guard !searchText.isEmpty else { return eligible } - return eligible.filter { contact in - contact.displayName.localizedStandardContains(searchText) - } + private var filteredContacts: [ContactDTO] { + let eligible = contacts.filter { !$0.isBlocked && $0.type != .repeater && $0.type != .room } + guard !searchText.isEmpty else { return eligible } + return eligible.filter { contact in + contact.displayName.localizedStandardContains(searchText) } + } - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if contacts.isEmpty { - ContentUnavailableView( - L10n.Chats.Chats.NewChat.EmptyState.title, - systemImage: "person.2", - description: Text(L10n.Chats.Chats.NewChat.EmptyState.description) - ) - } else { - List(filteredContacts) { contact in - Button { - onSelectContact(contact) - } label: { - HStack(spacing: 12) { - ContactAvatar(contact: contact, size: 40) + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if contacts.isEmpty { + ContentUnavailableView( + L10n.Chats.Chats.NewChat.EmptyState.title, + systemImage: "person.2", + description: Text(L10n.Chats.Chats.NewChat.EmptyState.description) + ) + } else { + List(filteredContacts) { contact in + Button { + onSelectContact(contact) + } label: { + HStack(spacing: 12) { + ContactAvatar(contact: contact, size: 40) - VStack(alignment: .leading) { - Text(contact.displayName) - .font(.headline) + VStack(alignment: .leading) { + Text(contact.displayName) + .font(.headline) - Text(contactTypeLabel(for: contact)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - } - .contentShape(.rect) - } - .buttonStyle(.plain) - } - .themedCanvas(theme) - } - } - .navigationTitle(L10n.Chats.Chats.NewChat.title) - .navigationBarTitleDisplayMode(.inline) - .searchable(text: $searchText, prompt: L10n.Chats.Chats.NewChat.Search.placeholder) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.cancel) { - dismiss() - } + Text(contactTypeLabel(for: contact)) + .font(.caption) + .foregroundStyle(.secondary) } + + Spacer() + } + .contentShape(.rect) } - .task { - await loadContacts() - } + .buttonStyle(.plain) + } + .themedCanvas(theme) + } + } + .navigationTitle(L10n.Chats.Chats.NewChat.title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: L10n.Chats.Chats.NewChat.Search.placeholder) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.cancel) { + dismiss() + } } + } + .task { + await loadContacts() + } } + } - private func loadContacts() async { - guard let radioID = appState.connectedDevice?.radioID else { return } + private func loadContacts() async { + guard let radioID = appState.connectedDevice?.radioID else { return } - isLoading = true - contacts = (try? await appState.services?.dataStore.fetchContacts(radioID: radioID)) ?? [] - isLoading = false - } + isLoading = true + contacts = await (try? appState.services?.dataStore.fetchContacts(radioID: radioID)) ?? [] + isLoading = false + } - private func contactTypeLabel(for contact: ContactDTO) -> String { - switch contact.type { - case .chat: - return contact.isFloodRouted ? L10n.Chats.Chats.ConnectionStatus.floodRouting : L10n.Chats.Chats.NewChat.ContactType.direct - case .repeater: - return L10n.Chats.Chats.NewChat.ContactType.repeater - case .room: - return "" - } + private func contactTypeLabel(for contact: ContactDTO) -> String { + switch contact.type { + case .chat: + contact.isFloodRouted ? L10n.Chats.Chats.ConnectionStatus.floodRouting : L10n.Chats.Chats.NewChat.ContactType.direct + case .repeater: + L10n.Chats.Chats.NewChat.ContactType.repeater + case .room: + "" } + } } diff --git a/MC1/Views/Chats/NotificationLevelIndicator.swift b/MC1/Views/Chats/NotificationLevelIndicator.swift index f1b84d9e..98975848 100644 --- a/MC1/Views/Chats/NotificationLevelIndicator.swift +++ b/MC1/Views/Chats/NotificationLevelIndicator.swift @@ -1,23 +1,23 @@ -import SwiftUI import MC1Services +import SwiftUI struct NotificationLevelIndicator: View { - let level: NotificationLevel + let level: NotificationLevel - var body: some View { - switch level { - case .muted: - Image(systemName: "bell.slash") - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Chats.Chats.Row.muted) - case .mentionsOnly: - Image(systemName: "at") - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Chats.Chats.Row.mentionsOnly) - case .all: - EmptyView() - } + var body: some View { + switch level { + case .muted: + Image(systemName: "bell.slash") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Chats.Chats.Row.muted) + case .mentionsOnly: + Image(systemName: "at") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Chats.Chats.Row.mentionsOnly) + case .all: + EmptyView() } + } } diff --git a/MC1/Views/Chats/Reactions/EmojiCategoryHeader.swift b/MC1/Views/Chats/Reactions/EmojiCategoryHeader.swift index b2c37b0f..f8498d64 100644 --- a/MC1/Views/Chats/Reactions/EmojiCategoryHeader.swift +++ b/MC1/Views/Chats/Reactions/EmojiCategoryHeader.swift @@ -2,25 +2,25 @@ import SwiftUI /// Section header for emoji picker categories struct EmojiCategoryHeader: View { - let title: String + let title: String - var body: some View { - HStack { - Text(title) - .font(.subheadline) - .bold() - .foregroundStyle(.secondary) - Spacer() - } - .padding(.top, 16) - .padding(.bottom, 8) + var body: some View { + HStack { + Text(title) + .font(.subheadline) + .bold() + .foregroundStyle(.secondary) + Spacer() } + .padding(.top, 16) + .padding(.bottom, 8) + } } #Preview { - VStack { - EmojiCategoryHeader(title: "Frequently Used") - EmojiCategoryHeader(title: "Smileys & People") - } - .padding() + VStack { + EmojiCategoryHeader(title: "Frequently Used") + EmojiCategoryHeader(title: "Smileys & People") + } + .padding() } diff --git a/MC1/Views/Chats/Reactions/EmojiPickerRow.swift b/MC1/Views/Chats/Reactions/EmojiPickerRow.swift index 170e2dbc..07f86fb8 100644 --- a/MC1/Views/Chats/Reactions/EmojiPickerRow.swift +++ b/MC1/Views/Chats/Reactions/EmojiPickerRow.swift @@ -3,64 +3,64 @@ import SwiftUI /// Horizontal row of emoji buttons for quick reaction selection. /// Uses `ViewThatFits` to center when content fits, scrolling when it overflows. struct EmojiPickerRow: View { - let emojis: [String] - let onSelect: (String) -> Void - let onOpenKeyboard: () -> Void + let emojis: [String] + let onSelect: (String) -> Void + let onOpenKeyboard: () -> Void - var body: some View { - ViewThatFits(in: .horizontal) { - EmojiButtonRow(emojis: emojis, onSelect: onSelect, onOpenKeyboard: onOpenKeyboard) - .padding(.horizontal) + var body: some View { + ViewThatFits(in: .horizontal) { + EmojiButtonRow(emojis: emojis, onSelect: onSelect, onOpenKeyboard: onOpenKeyboard) + .padding(.horizontal) - ScrollView(.horizontal) { - EmojiButtonRow(emojis: emojis, onSelect: onSelect, onOpenKeyboard: onOpenKeyboard) - .padding(.horizontal) - } - .scrollIndicators(.hidden) - } - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + ScrollView(.horizontal) { + EmojiButtonRow(emojis: emojis, onSelect: onSelect, onOpenKeyboard: onOpenKeyboard) + .padding(.horizontal) + } + .scrollIndicators(.hidden) } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + } } private struct EmojiButtonRow: View { - let emojis: [String] - let onSelect: (String) -> Void - let onOpenKeyboard: () -> Void - - var body: some View { - HStack(spacing: 8) { - ForEach(emojis, id: \.self) { emoji in - Button { - onSelect(emoji) - } label: { - Text(emoji) - .font(.title) - } - .buttonStyle(.plain) - .frame(width: 44, height: 44) - .background(.ultraThinMaterial, in: .circle) - .accessibilityLabel(emoji.emojiAccessibilityName) - } + let emojis: [String] + let onSelect: (String) -> Void + let onOpenKeyboard: () -> Void - Button(L10n.Chats.Reactions.moreEmojis, systemImage: "plus") { - onOpenKeyboard() - } - .font(.title2) - .foregroundStyle(.secondary) - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .frame(width: 44, height: 44) - .background(.ultraThinMaterial, in: .circle) + var body: some View { + HStack(spacing: 8) { + ForEach(emojis, id: \.self) { emoji in + Button { + onSelect(emoji) + } label: { + Text(emoji) + .font(.title) } + .buttonStyle(.plain) + .frame(width: 44, height: 44) + .background(.ultraThinMaterial, in: .circle) + .accessibilityLabel(emoji.emojiAccessibilityName) + } + + Button(L10n.Chats.Reactions.moreEmojis, systemImage: "plus") { + onOpenKeyboard() + } + .font(.title2) + .foregroundStyle(.secondary) + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .frame(width: 44, height: 44) + .background(.ultraThinMaterial, in: .circle) } + } } #Preview { - EmojiPickerRow( - emojis: ["👍", "👎", "❤️", "😂", "😮", "😢"], - onSelect: { print("Selected: \($0)") }, - onOpenKeyboard: { print("Open keyboard") } - ) - .padding() - .background(.gray.opacity(0.3)) + EmojiPickerRow( + emojis: ["👍", "👎", "❤️", "😂", "😮", "😢"], + onSelect: { print("Selected: \($0)") }, + onOpenKeyboard: { print("Open keyboard") } + ) + .padding() + .background(.gray.opacity(0.3)) } diff --git a/MC1/Views/Chats/Reactions/EmojiPickerSheet.swift b/MC1/Views/Chats/Reactions/EmojiPickerSheet.swift index aef5fc06..168cae94 100644 --- a/MC1/Views/Chats/Reactions/EmojiPickerSheet.swift +++ b/MC1/Views/Chats/Reactions/EmojiPickerSheet.swift @@ -1,73 +1,73 @@ -import SwiftUI import Emojibase +import SwiftUI /// Full emoji picker sheet with categories, search, and frequently used struct EmojiPickerSheet: View { - let onSelect: (String) -> Void + let onSelect: (String) -> Void - @Environment(\.dismiss) private var dismiss - @State private var viewModel = EmojiPickerViewModel() + @Environment(\.dismiss) private var dismiss + @State private var viewModel = EmojiPickerViewModel() - @ScaledMetric private var emojiSize: CGFloat = 44 + @ScaledMetric private var emojiSize: CGFloat = 44 - var body: some View { - NavigationStack { - ScrollView { - LazyVGrid( - columns: [GridItem(.adaptive(minimum: emojiSize))], - spacing: 8 - ) { - ForEach(viewModel.categories) { category in - Section { - ForEach(category.emojis) { emoji in - emojiButton(emoji) - } - } header: { - EmojiCategoryHeader(title: category.localizedName) - } - } - } - .padding(.horizontal) - } - .navigationTitle(L10n.Chats.Reactions.title) - .navigationBarTitleDisplayMode(.inline) - .searchable( - text: $viewModel.searchQuery, - placement: .navigationBarDrawer(displayMode: .always), - prompt: L10n.Chats.Reactions.Emoji.searchPlaceholder - ) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - dismiss() - } - } - } - .task { - await viewModel.load() + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: emojiSize))], + spacing: 8 + ) { + ForEach(viewModel.categories) { category in + Section { + ForEach(category.emojis) { emoji in + emojiButton(emoji) + } + } header: { + EmojiCategoryHeader(title: category.localizedName) } + } } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - - private func emojiButton(_ emoji: EmojiItem) -> some View { - Button { - viewModel.markAsFrequentlyUsed(emoji.unicode) - onSelect(emoji.unicode) + .padding(.horizontal) + } + .navigationTitle(L10n.Chats.Reactions.title) + .navigationBarTitleDisplayMode(.inline) + .searchable( + text: $viewModel.searchQuery, + placement: .navigationBarDrawer(displayMode: .always), + prompt: L10n.Chats.Reactions.Emoji.searchPlaceholder + ) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { dismiss() - } label: { - Text(emoji.unicode) - .font(.largeTitle) - .frame(width: emojiSize, height: emojiSize) + } } - .buttonStyle(.plain) - .accessibilityLabel(emoji.label.isEmpty ? emoji.unicode : emoji.label) + } + .task { + await viewModel.load() + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + private func emojiButton(_ emoji: EmojiItem) -> some View { + Button { + viewModel.markAsFrequentlyUsed(emoji.unicode) + onSelect(emoji.unicode) + dismiss() + } label: { + Text(emoji.unicode) + .font(.largeTitle) + .frame(width: emojiSize, height: emojiSize) } + .buttonStyle(.plain) + .accessibilityLabel(emoji.label.isEmpty ? emoji.unicode : emoji.label) + } } #Preview { - EmojiPickerSheet { emoji in - print("Selected: \(emoji)") - } + EmojiPickerSheet { emoji in + print("Selected: \(emoji)") + } } diff --git a/MC1/Views/Chats/Reactions/EmojiPickerViewModel.swift b/MC1/Views/Chats/Reactions/EmojiPickerViewModel.swift index a25ede83..4b0485e1 100644 --- a/MC1/Views/Chats/Reactions/EmojiPickerViewModel.swift +++ b/MC1/Views/Chats/Reactions/EmojiPickerViewModel.swift @@ -5,32 +5,32 @@ import Observation @Observable @MainActor final class EmojiPickerViewModel { - private let provider = EmojiProvider() - private var searchTask: Task? + private let provider = EmojiProvider() + private var searchTask: Task? - var searchQuery: String = "" { - didSet { - searchTask?.cancel() - searchTask = Task { - await updateCategories() - } - } + var searchQuery: String = "" { + didSet { + searchTask?.cancel() + searchTask = Task { + await updateCategories() + } } + } - private(set) var categories: [EmojiCategoryData] = [] + private(set) var categories: [EmojiCategoryData] = [] - func load() async { - await updateCategories() - } + func load() async { + await updateCategories() + } - func markAsFrequentlyUsed(_ emoji: String) { - provider.markAsFrequentlyUsed(emoji) - } + func markAsFrequentlyUsed(_ emoji: String) { + provider.markAsFrequentlyUsed(emoji) + } - private func updateCategories() async { - let query = searchQuery.isEmpty ? nil : searchQuery - let result = await provider.categories(searchQuery: query) - guard !Task.isCancelled else { return } - categories = result - } + private func updateCategories() async { + let query = searchQuery.isEmpty ? nil : searchQuery + let result = await provider.categories(searchQuery: query) + guard !Task.isCancelled else { return } + categories = result + } } diff --git a/MC1/Views/Chats/Reactions/EmojiProvider.swift b/MC1/Views/Chats/Reactions/EmojiProvider.swift index 260ee712..d78373b0 100644 --- a/MC1/Views/Chats/Reactions/EmojiProvider.swift +++ b/MC1/Views/Chats/Reactions/EmojiProvider.swift @@ -1,165 +1,165 @@ -import Foundation -import SwiftUI import Emojibase +import Foundation import MC1Services +import SwiftUI /// View data for a single emoji in the picker struct EmojiItem: Identifiable, Equatable { - let id: String - let unicode: String - let label: String - - init(id: String, unicode: String, label: String) { - self.id = id - self.unicode = unicode - self.label = label - } - - init(emoji: Emoji, categoryID: String) { - self.id = "\(categoryID)-\(emoji.hexcode)" - self.unicode = emoji.unicode - self.label = emoji.label - } + let id: String + let unicode: String + let label: String + + init(id: String, unicode: String, label: String) { + self.id = id + self.unicode = unicode + self.label = label + } + + init(emoji: Emoji, categoryID: String) { + id = "\(categoryID)-\(emoji.hexcode)" + unicode = emoji.unicode + label = emoji.label + } } /// View data for an emoji category struct EmojiCategoryData: Identifiable { - let id: String - let emojis: [EmojiItem] - - var localizedName: String { - switch id { - case "frequent": - return L10n.Chats.Reactions.Emoji.Category.frequent - case EmojibaseCategory.people.rawValue: - return L10n.Chats.Reactions.Emoji.Category.people - case EmojibaseCategory.nature.rawValue: - return L10n.Chats.Reactions.Emoji.Category.nature - case EmojibaseCategory.foods.rawValue: - return L10n.Chats.Reactions.Emoji.Category.foods - case EmojibaseCategory.activity.rawValue: - return L10n.Chats.Reactions.Emoji.Category.activity - case EmojibaseCategory.places.rawValue: - return L10n.Chats.Reactions.Emoji.Category.places - case EmojibaseCategory.objects.rawValue: - return L10n.Chats.Reactions.Emoji.Category.objects - case EmojibaseCategory.symbols.rawValue: - return L10n.Chats.Reactions.Emoji.Category.symbols - case EmojibaseCategory.flags.rawValue: - return L10n.Chats.Reactions.Emoji.Category.flags - default: - return id.capitalized - } + let id: String + let emojis: [EmojiItem] + + var localizedName: String { + switch id { + case "frequent": + L10n.Chats.Reactions.Emoji.Category.frequent + case EmojibaseCategory.people.rawValue: + L10n.Chats.Reactions.Emoji.Category.people + case EmojibaseCategory.nature.rawValue: + L10n.Chats.Reactions.Emoji.Category.nature + case EmojibaseCategory.foods.rawValue: + L10n.Chats.Reactions.Emoji.Category.foods + case EmojibaseCategory.activity.rawValue: + L10n.Chats.Reactions.Emoji.Category.activity + case EmojibaseCategory.places.rawValue: + L10n.Chats.Reactions.Emoji.Category.places + case EmojibaseCategory.objects.rawValue: + L10n.Chats.Reactions.Emoji.Category.objects + case EmojibaseCategory.symbols.rawValue: + L10n.Chats.Reactions.Emoji.Category.symbols + case EmojibaseCategory.flags.rawValue: + L10n.Chats.Reactions.Emoji.Category.flags + default: + id.capitalized } + } } /// Loading state for emoji data enum EmojiProviderState { - case notLoaded - case loading - case loaded - case failed(Error) + case notLoaded + case loading + case loaded + case failed(Error) } /// Provides emoji data for the picker with search and frequently-used tracking @Observable @MainActor final class EmojiProvider { - private(set) var state: EmojiProviderState = .notLoaded - private var store: EmojibaseStore? - - @ObservationIgnored - @AppStorage(AppStorageKey.frequentEmojis.rawValue) private var frequentEmojisData: Data = Data() - - private static let maxFrequentEmojis = 20 - private static let categoryOrder: [EmojibaseCategory] = [ - .people, .nature, .foods, .activity, .places, .objects, .symbols, .flags - ] - - /// Loads emoji data if not already loaded - func loadIfNeeded() async { - guard case .notLoaded = state else { return } - - state = .loading - do { - let datasource = EmojibaseDatasource() - store = try await datasource.load() - state = .loaded - } catch { - state = .failed(error) - } + private(set) var state: EmojiProviderState = .notLoaded + private var store: EmojibaseStore? + + @ObservationIgnored + @AppStorage(AppStorageKey.frequentEmojis.rawValue) private var frequentEmojisData: Data = .init() + + private static let maxFrequentEmojis = 20 + private static let categoryOrder: [EmojibaseCategory] = [ + .people, .nature, .foods, .activity, .places, .objects, .symbols, .flags + ] + + /// Loads emoji data if not already loaded + func loadIfNeeded() async { + guard case .notLoaded = state else { return } + + state = .loading + do { + let datasource = EmojibaseDatasource() + store = try await datasource.load() + state = .loaded + } catch { + state = .failed(error) } + } - /// Returns categories filtered by search query - func categories(searchQuery: String?) async -> [EmojiCategoryData] { - await loadIfNeeded() + /// Returns categories filtered by search query + func categories(searchQuery: String?) async -> [EmojiCategoryData] { + await loadIfNeeded() - guard let store else { return [] } + guard let store else { return [] } - var result: [EmojiCategoryData] = [] + var result: [EmojiCategoryData] = [] - // Add frequently used section if no search query - if searchQuery == nil || searchQuery?.isEmpty == true { - let frequent = frequentlyUsedEmojis() - if !frequent.isEmpty { - let items = frequent.enumerated().map { index, unicode in - EmojiItem(id: "frequent-\(index)", unicode: unicode, label: "") - } - result.append(EmojiCategoryData(id: "frequent", emojis: items)) - } + // Add frequently used section if no search query + if searchQuery == nil || searchQuery?.isEmpty == true { + let frequent = frequentlyUsedEmojis() + if !frequent.isEmpty { + let items = frequent.enumerated().map { index, unicode in + EmojiItem(id: "frequent-\(index)", unicode: unicode, label: "") } + result.append(EmojiCategoryData(id: "frequent", emojis: items)) + } + } - // Add standard categories - for category in Self.categoryOrder { - guard let emojis = store.emojisFor(category: category) else { continue } - - let filtered: [Emoji] - if let query = searchQuery, !query.isEmpty { - let lowercasedQuery = query.lowercased() - filtered = emojis.filter { emoji in - emoji.label.localizedStandardContains(lowercasedQuery) || - emoji.shortcodes.contains { $0.localizedStandardContains(lowercasedQuery) } || - emoji.tags?.contains { $0.localizedStandardContains(lowercasedQuery) } == true - } - } else { - filtered = emojis - } - - if !filtered.isEmpty { - let items = filtered.map { EmojiItem(emoji: $0, categoryID: category.rawValue) } - result.append(EmojiCategoryData(id: category.rawValue, emojis: items)) - } + // Add standard categories + for category in Self.categoryOrder { + guard let emojis = store.emojisFor(category: category) else { continue } + + let filtered: [Emoji] + if let query = searchQuery, !query.isEmpty { + let lowercasedQuery = query.lowercased() + filtered = emojis.filter { emoji in + emoji.label.localizedStandardContains(lowercasedQuery) || + emoji.shortcodes.contains { $0.localizedStandardContains(lowercasedQuery) } || + emoji.tags?.contains { $0.localizedStandardContains(lowercasedQuery) } == true } - - return result + } else { + filtered = emojis + } + + if !filtered.isEmpty { + let items = filtered.map { EmojiItem(emoji: $0, categoryID: category.rawValue) } + result.append(EmojiCategoryData(id: category.rawValue, emojis: items)) + } } - /// Marks an emoji as frequently used - func markAsFrequentlyUsed(_ emoji: String) { - var frequent = frequentlyUsedEmojis() + return result + } - // Remove if already present (will re-add at front) - frequent.removeAll { $0 == emoji } + /// Marks an emoji as frequently used + func markAsFrequentlyUsed(_ emoji: String) { + var frequent = frequentlyUsedEmojis() - // Add to front - frequent.insert(emoji, at: 0) + // Remove if already present (will re-add at front) + frequent.removeAll { $0 == emoji } - // Trim to max size - if frequent.count > Self.maxFrequentEmojis { - frequent = Array(frequent.prefix(Self.maxFrequentEmojis)) - } + // Add to front + frequent.insert(emoji, at: 0) - // Persist - if let data = try? JSONEncoder().encode(frequent) { - frequentEmojisData = data - } + // Trim to max size + if frequent.count > Self.maxFrequentEmojis { + frequent = Array(frequent.prefix(Self.maxFrequentEmojis)) } - /// Returns the list of frequently used emojis - func frequentlyUsedEmojis() -> [String] { - guard let emojis = try? JSONDecoder().decode([String].self, from: frequentEmojisData) else { - return [] - } - return emojis + // Persist + if let data = try? JSONEncoder().encode(frequent) { + frequentEmojisData = data + } + } + + /// Returns the list of frequently used emojis + func frequentlyUsedEmojis() -> [String] { + guard let emojis = try? JSONDecoder().decode([String].self, from: frequentEmojisData) else { + return [] } + return emojis + } } diff --git a/MC1/Views/Chats/Reactions/MessageAction.swift b/MC1/Views/Chats/Reactions/MessageAction.swift index 32a7304f..ff25e5ac 100644 --- a/MC1/Views/Chats/Reactions/MessageAction.swift +++ b/MC1/Views/Chats/Reactions/MessageAction.swift @@ -1,11 +1,11 @@ /// User actions dispatched from the message actions sheet, routed to handlers /// by `ChatConversationView.dispatch(_:for:)`. enum MessageAction: Equatable { - case react(String) - case reply - case copy - case sendAgain - case sendDM - case blockSender - case delete + case react(String) + case reply + case copy + case sendAgain + case sendDM + case blockSender + case delete } diff --git a/MC1/Views/Chats/Reactions/MessageActionAvailability.swift b/MC1/Views/Chats/Reactions/MessageActionAvailability.swift index 4440b537..c16c080a 100644 --- a/MC1/Views/Chats/Reactions/MessageActionAvailability.swift +++ b/MC1/Views/Chats/Reactions/MessageActionAvailability.swift @@ -3,26 +3,26 @@ import MC1Services /// Determines which message actions are available based on message state. /// Extracted for testability and reuse across UI components. struct MessageActionAvailability { - let canReply: Bool - let canCopy: Bool - let canSendAgain: Bool - let canBlockSender: Bool - let canSendDM: Bool - let canShowRepeatDetails: Bool - let canViewPath: Bool - let canDelete: Bool + let canReply: Bool + let canCopy: Bool + let canSendAgain: Bool + let canBlockSender: Bool + let canSendDM: Bool + let canShowRepeatDetails: Bool + let canViewPath: Bool + let canDelete: Bool - init(message: MessageDTO) { - canReply = !message.isOutgoing - canCopy = true - canSendAgain = message.isOutgoing - let hasChannelSender = message.isChannelMessage && !message.isOutgoing && message.senderNodeName != nil - canBlockSender = hasChannelSender - canSendDM = hasChannelSender - canShowRepeatDetails = message.isOutgoing && message.heardRepeats > 0 - canViewPath = !message.isOutgoing - && message.isFloodRouted - && !(message.pathNodes?.isEmpty ?? true) - canDelete = true - } + init(message: MessageDTO) { + canReply = !message.isOutgoing + canCopy = true + canSendAgain = message.isOutgoing + let hasChannelSender = message.isChannelMessage && !message.isOutgoing && message.senderNodeName != nil + canBlockSender = hasChannelSender + canSendDM = hasChannelSender + canShowRepeatDetails = message.isOutgoing && message.heardRepeats > 0 + canViewPath = !message.isOutgoing + && message.isFloodRouted + && !(message.pathNodes?.isEmpty ?? true) + canDelete = true + } } diff --git a/MC1/Views/Chats/Reactions/MessageActionsSheet.swift b/MC1/Views/Chats/Reactions/MessageActionsSheet.swift index 4c9d80c5..bdfbbb39 100644 --- a/MC1/Views/Chats/Reactions/MessageActionsSheet.swift +++ b/MC1/Views/Chats/Reactions/MessageActionsSheet.swift @@ -2,162 +2,161 @@ import MC1Services import SwiftUI struct MessageActionsSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - let message: MessageDTO - let senderName: String - let senderMatchKind: NodeNameMatchKind - let recentEmojis: [String] - let onAction: (MessageAction) -> Void + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + let message: MessageDTO + let senderName: String + let senderMatchKind: NodeNameMatchKind + let recentEmojis: [String] + let onAction: (MessageAction) -> Void - private var availability: MessageActionAvailability { - MessageActionAvailability(message: message) - } + private var availability: MessageActionAvailability { + MessageActionAvailability(message: message) + } - private func performAction(_ action: MessageAction) { - if action == .delete || action == .blockSender { - destructiveHapticTrigger += 1 - } - onAction(action) - dismiss() + private func performAction(_ action: MessageAction) { + if action == .delete || action == .blockSender { + destructiveHapticTrigger += 1 } + onAction(action) + dismiss() + } - @State private var destructiveHapticTrigger = 0 - @State private var showEmojiPicker = false - @State private var isDetailExpanded = false - @State private var repeats: [MessageRepeatDTO]? - @State private var contacts: [ContactDTO] = [] - @State private var discoveredNodes: [DiscoveredNodeDTO] = [] - @State private var pathViewModel = MessagePathViewModel() + @State private var destructiveHapticTrigger = 0 + @State private var showEmojiPicker = false + @State private var isDetailExpanded = false + @State private var repeats: [MessageRepeatDTO]? + @State private var contacts: [ContactDTO] = [] + @State private var discoveredNodes: [DiscoveredNodeDTO] = [] + @State private var pathViewModel = MessagePathViewModel() - var body: some View { - VStack(spacing: 0) { - ActionsPreviewHeader( - message: message, - senderName: senderName, - senderMatchKind: senderMatchKind - ) + var body: some View { + VStack(spacing: 0) { + ActionsPreviewHeader( + message: message, + senderName: senderName, + senderMatchKind: senderMatchKind + ) - Divider() + Divider() - if !dynamicTypeSize.isAccessibilitySize { - ActionsEmojiSection( - recentEmojis: recentEmojis, - showEmojiPicker: $showEmojiPicker, - onSelectEmoji: { performAction(.react($0)) } - ) - Divider() - } + if !dynamicTypeSize.isAccessibilitySize { + ActionsEmojiSection( + recentEmojis: recentEmojis, + showEmojiPicker: $showEmojiPicker, + onSelectEmoji: { performAction(.react($0)) } + ) + Divider() + } - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 0) { - if dynamicTypeSize.isAccessibilitySize { - ActionsEmojiSection( - recentEmojis: recentEmojis, - showEmojiPicker: $showEmojiPicker, - onSelectEmoji: { performAction(.react($0)) } - ) - Divider() - } - ActionsButtonsSection( - availability: availability, - onSelectAction: performAction - ) - ActionsDetailsSection( - message: message, - availability: availability, - isDetailExpanded: $isDetailExpanded, - repeats: repeats, - contacts: contacts, - discoveredNodes: discoveredNodes, - pathViewModel: pathViewModel - ) - ActionsDestructiveSection( - availability: availability, - onSelectAction: performAction - ) - } - } - .onChange(of: isDetailExpanded) { _, expanded in - if expanded { - withAnimation(reduceMotion ? nil : .default) { - proxy.scrollTo("expandedContent", anchor: .top) - } - } - } + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + if dynamicTypeSize.isAccessibilitySize { + ActionsEmojiSection( + recentEmojis: recentEmojis, + showEmojiPicker: $showEmojiPicker, + onSelectEmoji: { performAction(.react($0)) } + ) + Divider() } + ActionsButtonsSection( + availability: availability, + onSelectAction: performAction + ) + ActionsDetailsSection( + message: message, + availability: availability, + isDetailExpanded: $isDetailExpanded, + repeats: repeats, + contacts: contacts, + discoveredNodes: discoveredNodes, + pathViewModel: pathViewModel + ) + ActionsDestructiveSection( + availability: availability, + onSelectAction: performAction + ) + } } - .presentationDetents( - (horizontalSizeClass == .regular || dynamicTypeSize.isAccessibilitySize) - ? [.large] : [.medium, .large] - ) - .presentationContentInteraction(.scrolls) - .presentationDragIndicator(.visible) - .presentationBackground(Color(.systemBackground)) - .sensoryFeedback(.warning, trigger: destructiveHapticTrigger) - .task { - guard let services = appState.services else { return } - if availability.canShowRepeatDetails { - // The three loads share no data, so run them concurrently rather - // than stacking three actor round-trips while the detail rows are blank. - async let fetchedRepeats = services.heardRepeatsService.refreshRepeats(for: message.id) - do { - async let fetchedContacts = services.dataStore.fetchContacts(radioID: message.radioID) - async let fetchedNodes = services.dataStore.fetchDiscoveredNodes(radioID: message.radioID) - contacts = try await fetchedContacts - discoveredNodes = try await fetchedNodes - } catch { - contacts = [] - discoveredNodes = [] - } - repeats = await fetchedRepeats - } else if availability.canViewPath { - await pathViewModel.loadContacts(services: services, radioID: message.radioID) + .onChange(of: isDetailExpanded) { _, expanded in + if expanded { + withAnimation(reduceMotion ? nil : .default) { + proxy.scrollTo("expandedContent", anchor: .top) } + } } + } } - + .presentationDetents( + (horizontalSizeClass == .regular || dynamicTypeSize.isAccessibilitySize) + ? [.large] : [.medium, .large] + ) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackground(Color(.systemBackground)) + .sensoryFeedback(.warning, trigger: destructiveHapticTrigger) + .task { + guard let services = appState.services else { return } + if availability.canShowRepeatDetails { + // The three loads share no data, so run them concurrently rather + // than stacking three actor round-trips while the detail rows are blank. + async let fetchedRepeats = services.heardRepeatsService.refreshRepeats(for: message.id) + do { + async let fetchedContacts = services.dataStore.fetchContacts(radioID: message.radioID) + async let fetchedNodes = services.dataStore.fetchDiscoveredNodes(radioID: message.radioID) + contacts = try await fetchedContacts + discoveredNodes = try await fetchedNodes + } catch { + contacts = [] + discoveredNodes = [] + } + repeats = await fetchedRepeats + } else if availability.canViewPath { + await pathViewModel.loadContacts(services: services, radioID: message.radioID) + } + } + } } #Preview("Outgoing Message") { - let message = Message( - radioID: UUID(), - contactID: UUID(), - text: "Hello world!", - directionRawValue: MessageDirection.outgoing.rawValue, - statusRawValue: MessageStatus.delivered.rawValue - ) - message.roundTripTime = 234 - message.heardRepeats = 2 - return MessageActionsSheet( - message: MessageDTO(from: message), - senderName: "My Device", - senderMatchKind: .exact, - recentEmojis: RecentEmojisStore.defaultEmojis, - onAction: { print("Action: \($0)") } - ) + let message = Message( + radioID: UUID(), + contactID: UUID(), + text: "Hello world!", + directionRawValue: MessageDirection.outgoing.rawValue, + statusRawValue: MessageStatus.delivered.rawValue + ) + message.roundTripTime = 234 + message.heardRepeats = 2 + return MessageActionsSheet( + message: MessageDTO(from: message), + senderName: "My Device", + senderMatchKind: .exact, + recentEmojis: RecentEmojisStore.defaultEmojis, + onAction: { print("Action: \($0)") } + ) } #Preview("Incoming Message") { - let message = Message( - radioID: UUID(), - contactID: UUID(), - text: "Hey, can you meet me at the coffee shop downtown later today? I have something important to discuss.", - directionRawValue: MessageDirection.incoming.rawValue, - statusRawValue: MessageStatus.delivered.rawValue, - pathLength: 2 - ) - message.pathNodes = Data([0xA3, 0x7F]) - message.snr = 8.5 - return MessageActionsSheet( - message: MessageDTO(from: message), - senderName: "Alice", - senderMatchKind: .exact, - recentEmojis: RecentEmojisStore.defaultEmojis, - onAction: { print("Action: \($0)") } - ) + let message = Message( + radioID: UUID(), + contactID: UUID(), + text: "Hey, can you meet me at the coffee shop downtown later today? I have something important to discuss.", + directionRawValue: MessageDirection.incoming.rawValue, + statusRawValue: MessageStatus.delivered.rawValue, + pathLength: 2 + ) + message.pathNodes = Data([0xA3, 0x7F]) + message.snr = 8.5 + return MessageActionsSheet( + message: MessageDTO(from: message), + senderName: "Alice", + senderMatchKind: .exact, + recentEmojis: RecentEmojisStore.defaultEmojis, + onAction: { print("Action: \($0)") } + ) } diff --git a/MC1/Views/Chats/Reactions/ReactionBadgesView.swift b/MC1/Views/Chats/Reactions/ReactionBadgesView.swift index 1a33062d..02e4ac9e 100644 --- a/MC1/Views/Chats/Reactions/ReactionBadgesView.swift +++ b/MC1/Views/Chats/Reactions/ReactionBadgesView.swift @@ -1,122 +1,122 @@ -import SwiftUI import MC1Services +import SwiftUI /// Horizontal row of reaction badges displayed below message bubbles struct ReactionBadgesView: View { - let summary: String? // Format: "👍:3,❤️:2,😂:1" - let onTapReaction: (String) -> Void - let onLongPress: () -> Void + let summary: String? // Format: "👍:3,❤️:2,😂:1" + let onTapReaction: (String) -> Void + let onLongPress: () -> Void - @State private var longPressTriggered = false + @State private var longPressTriggered = false - private var reactions: [(emoji: String, count: Int)] { - ReactionParser.parseSummary(summary) - } + private var reactions: [(emoji: String, count: Int)] { + ReactionParser.parseSummary(summary) + } - private var visibleReactions: [(emoji: String, count: Int)] { - Array(reactions.prefix(3)) - } + private var visibleReactions: [(emoji: String, count: Int)] { + Array(reactions.prefix(3)) + } - private var overflowCount: Int { - max(0, reactions.count - 3) - } + private var overflowCount: Int { + max(0, reactions.count - 3) + } - var body: some View { - if !reactions.isEmpty { - HStack(spacing: 0) { - ForEach(visibleReactions, id: \.emoji) { reaction in - Button { - onTapReaction(reaction.emoji) - } label: { - ReactionBadge(emoji: reaction.emoji, count: reaction.count) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Chats.Reactions.badge(reaction.emoji.emojiAccessibilityName, reaction.count)) - .accessibilityHint(L10n.Chats.Reactions.badgeHint) - } + var body: some View { + if !reactions.isEmpty { + HStack(spacing: 0) { + ForEach(visibleReactions, id: \.emoji) { reaction in + Button { + onTapReaction(reaction.emoji) + } label: { + ReactionBadge(emoji: reaction.emoji, count: reaction.count) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Chats.Reactions.badge(reaction.emoji.emojiAccessibilityName, reaction.count)) + .accessibilityHint(L10n.Chats.Reactions.badgeHint) + } - if overflowCount > 0 { - Button { - onLongPress() - } label: { - OverflowBadge(count: overflowCount) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Chats.Reactions.moreBadge(overflowCount)) - .accessibilityHint(L10n.Chats.Reactions.moreBadgeHint) - } - } - .simultaneousGesture( - LongPressGesture(minimumDuration: 0.3) - .onEnded { _ in - longPressTriggered.toggle() - onLongPress() - } - ) - .sensoryFeedback(.impact(weight: .medium), trigger: longPressTriggered) - .accessibilityAction(named: L10n.Chats.Reactions.viewDetails) { - onLongPress() - } + if overflowCount > 0 { + Button { + onLongPress() + } label: { + OverflowBadge(count: overflowCount) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Chats.Reactions.moreBadge(overflowCount)) + .accessibilityHint(L10n.Chats.Reactions.moreBadgeHint) } + } + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.3) + .onEnded { _ in + longPressTriggered.toggle() + onLongPress() + } + ) + .sensoryFeedback(.impact(weight: .medium), trigger: longPressTriggered) + .accessibilityAction(named: L10n.Chats.Reactions.viewDetails) { + onLongPress() + } } + } } private struct ReactionBadge: View { - @Environment(\.appTheme) private var theme - let emoji: String - let count: Int + @Environment(\.appTheme) private var theme + let emoji: String + let count: Int - var body: some View { - HStack(spacing: 4) { - Text(emoji) - .font(.subheadline) - if count > 1 { - Text(count, format: .number) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(theme.incomingBubbleColor, in: .capsule) - .overlay(Capsule().strokeBorder(theme.surfaces?.canvas ?? Color(.systemBackground), lineWidth: 2)) + var body: some View { + HStack(spacing: 4) { + Text(emoji) + .font(.subheadline) + if count > 1 { + Text(count, format: .number) + .font(.footnote) + .foregroundStyle(.secondary) + } } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(theme.incomingBubbleColor, in: .capsule) + .overlay(Capsule().strokeBorder(theme.surfaces?.canvas ?? Color(.systemBackground), lineWidth: 2)) + } } private struct OverflowBadge: View { - @Environment(\.appTheme) private var theme - let count: Int + @Environment(\.appTheme) private var theme + let count: Int - var body: some View { - Text("+\(count)") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(theme.incomingBubbleColor, in: .capsule) - .overlay(Capsule().strokeBorder(theme.surfaces?.canvas ?? Color(.systemBackground), lineWidth: 2)) - } + var body: some View { + Text("+\(count)") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(theme.incomingBubbleColor, in: .capsule) + .overlay(Capsule().strokeBorder(theme.surfaces?.canvas ?? Color(.systemBackground), lineWidth: 2)) + } } #Preview { - VStack(spacing: 20) { - ReactionBadgesView( - summary: "👍:3,❤️:2,😂:1", - onTapReaction: { _ in }, - onLongPress: {} - ) + VStack(spacing: 20) { + ReactionBadgesView( + summary: "👍:3,❤️:2,😂:1", + onTapReaction: { _ in }, + onLongPress: {} + ) - ReactionBadgesView( - summary: "👍:5,❤️:3,😂:2,😮:1,😢:1,🎉:1", - onTapReaction: { _ in }, - onLongPress: {} - ) + ReactionBadgesView( + summary: "👍:5,❤️:3,😂:2,😮:1,😢:1,🎉:1", + onTapReaction: { _ in }, + onLongPress: {} + ) - ReactionBadgesView( - summary: nil, - onTapReaction: { _ in }, - onLongPress: {} - ) - } - .padding() + ReactionBadgesView( + summary: nil, + onTapReaction: { _ in }, + onLongPress: {} + ) + } + .padding() } diff --git a/MC1/Views/Chats/Reactions/ReactionDetailsSheet.swift b/MC1/Views/Chats/Reactions/ReactionDetailsSheet.swift index 690aeb54..07869c6d 100644 --- a/MC1/Views/Chats/Reactions/ReactionDetailsSheet.swift +++ b/MC1/Views/Chats/Reactions/ReactionDetailsSheet.swift @@ -1,143 +1,143 @@ // MC1/Views/Chats/Reactions/ReactionDetailsSheet.swift -import SwiftUI import MC1Services import OSLog +import SwiftUI /// Sheet showing who reacted with each emoji. struct ReactionDetailsSheet: View { - let messageID: UUID + let messageID: UUID - @Environment(\.appState) private var appState - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.appTheme) private var theme - private let logger = Logger(subsystem: "com.mc1", category: "ReactionDetailsSheet") + private let logger = Logger(subsystem: "com.mc1", category: "ReactionDetailsSheet") - @State private var reactions: [ReactionDTO] = [] - @State private var selectedEmoji: String? - @State private var isLoading = true + @State private var reactions: [ReactionDTO] = [] + @State private var selectedEmoji: String? + @State private var isLoading = true - private var emojiGroups: [(emoji: String, reactions: [ReactionDTO])] { - Dictionary(grouping: reactions, by: \.emoji) - .map { (emoji: $0.key, reactions: $0.value) } - .sorted { lhs, rhs in - if lhs.reactions.count != rhs.reactions.count { - return lhs.reactions.count > rhs.reactions.count - } - let lhsEarliest = lhs.reactions.map(\.receivedAt).min() ?? .distantPast - let rhsEarliest = rhs.reactions.map(\.receivedAt).min() ?? .distantPast - return lhsEarliest < rhsEarliest - } - } + private var emojiGroups: [(emoji: String, reactions: [ReactionDTO])] { + Dictionary(grouping: reactions, by: \.emoji) + .map { (emoji: $0.key, reactions: $0.value) } + .sorted { lhs, rhs in + if lhs.reactions.count != rhs.reactions.count { + return lhs.reactions.count > rhs.reactions.count + } + let lhsEarliest = lhs.reactions.map(\.receivedAt).min() ?? .distantPast + let rhsEarliest = rhs.reactions.map(\.receivedAt).min() ?? .distantPast + return lhsEarliest < rhsEarliest + } + } - private var selectedReactions: [ReactionDTO] { - emojiGroups.first { $0.emoji == selectedEmoji }?.reactions ?? [] - } + private var selectedReactions: [ReactionDTO] { + emojiGroups.first { $0.emoji == selectedEmoji }?.reactions ?? [] + } - var body: some View { - NavigationStack { - VStack(spacing: 0) { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if reactions.isEmpty { - ContentUnavailableView( - L10n.Chats.Reactions.EmptyState.title, - systemImage: "face.smiling", - description: Text(L10n.Chats.Reactions.EmptyState.description) - ) - } else { - emojiTabsView - Divider() - senderListView - } - } - .navigationTitle(L10n.Chats.Reactions.title) - .task { - await loadReactions() - } + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if reactions.isEmpty { + ContentUnavailableView( + L10n.Chats.Reactions.EmptyState.title, + systemImage: "face.smiling", + description: Text(L10n.Chats.Reactions.EmptyState.description) + ) + } else { + emojiTabsView + Divider() + senderListView } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) + } + .navigationTitle(L10n.Chats.Reactions.title) + .task { + await loadReactions() + } } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } - private var emojiTabsView: some View { - ScrollView(.horizontal) { - HStack(spacing: 8) { - ForEach(emojiGroups, id: \.emoji) { group in - Button { - withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) { - selectedEmoji = group.emoji - } - } label: { - EmojiTab( - emoji: group.emoji, - count: group.reactions.count, - isSelected: selectedEmoji == group.emoji - ) - } - .buttonStyle(.plain) - } + private var emojiTabsView: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(emojiGroups, id: \.emoji) { group in + Button { + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) { + selectedEmoji = group.emoji } - .padding() + } label: { + EmojiTab( + emoji: group.emoji, + count: group.reactions.count, + isSelected: selectedEmoji == group.emoji + ) + } + .buttonStyle(.plain) } - .scrollIndicators(.hidden) + } + .padding() } + .scrollIndicators(.hidden) + } - private var senderListView: some View { - List(selectedReactions) { reaction in - HStack { - Text(reaction.senderName) - Spacer() - Text(reaction.receivedAt, format: .relative(presentation: .named)) - .foregroundStyle(.secondary) - .font(.caption) - } - } - .listStyle(.plain) - .themedCanvas(theme) + private var senderListView: some View { + List(selectedReactions) { reaction in + HStack { + Text(reaction.senderName) + Spacer() + Text(reaction.receivedAt, format: .relative(presentation: .named)) + .foregroundStyle(.secondary) + .font(.caption) + } } + .listStyle(.plain) + .themedCanvas(theme) + } - private func loadReactions() async { - guard let dataStore = appState.services?.dataStore else { - isLoading = false - return - } - - do { - reactions = try await dataStore.fetchReactions(for: messageID) - if let first = emojiGroups.first { - selectedEmoji = first.emoji - } - } catch { - logger.debug("Failed to fetch reactions for message \(messageID): \(error)") - } + private func loadReactions() async { + guard let dataStore = appState.services?.dataStore else { + isLoading = false + return + } - isLoading = false + do { + reactions = try await dataStore.fetchReactions(for: messageID) + if let first = emojiGroups.first { + selectedEmoji = first.emoji + } + } catch { + logger.debug("Failed to fetch reactions for message \(messageID): \(error)") } + + isLoading = false + } } private struct EmojiTab: View { - let emoji: String - let count: Int - let isSelected: Bool + let emoji: String + let count: Int + let isSelected: Bool - var body: some View { - HStack(spacing: 4) { - Text(emoji) - Text(count, format: .number) - .font(.caption) - .bold() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(isSelected ? Color.accentColor : Color.clear, in: .capsule) - .foregroundStyle(isSelected ? .white : .primary) - .accessibilityAddTraits(isSelected ? .isSelected : []) + var body: some View { + HStack(spacing: 4) { + Text(emoji) + Text(count, format: .number) + .font(.caption) + .bold() } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? Color.accentColor : Color.clear, in: .capsule) + .foregroundStyle(isSelected ? .white : .primary) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } } #Preview { - ReactionDetailsSheet(messageID: UUID()) - .environment(\.appState, AppState()) + ReactionDetailsSheet(messageID: UUID()) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Reactions/RecentEmojisStore.swift b/MC1/Views/Chats/Reactions/RecentEmojisStore.swift index 571403f8..6b7c42de 100644 --- a/MC1/Views/Chats/Reactions/RecentEmojisStore.swift +++ b/MC1/Views/Chats/Reactions/RecentEmojisStore.swift @@ -5,30 +5,30 @@ import MC1Services @Observable @MainActor final class RecentEmojisStore { - private static let key = AppStorageKey.recentReactionEmojis.rawValue - private static let maxRecent = 6 + private static let key = AppStorageKey.recentReactionEmojis.rawValue + private static let maxRecent = 6 - /// Default emojis shown before any usage - static let defaultEmojis = ["👍", "👎", "❤️", "😂", "😮", "😢"] + /// Default emojis shown before any usage + static let defaultEmojis = ["👍", "👎", "❤️", "😂", "😮", "😢"] - /// Recently used emojis (most recent first), falls back to defaults - private(set) var recentEmojis: [String] + /// Recently used emojis (most recent first), falls back to defaults + private(set) var recentEmojis: [String] - init() { - if let stored = UserDefaults.standard.stringArray(forKey: Self.key), !stored.isEmpty { - self.recentEmojis = stored - } else { - self.recentEmojis = Self.defaultEmojis - } + init() { + if let stored = UserDefaults.standard.stringArray(forKey: Self.key), !stored.isEmpty { + recentEmojis = stored + } else { + recentEmojis = Self.defaultEmojis } + } - /// Records emoji usage, moving it to front of recent list - func recordUsage(_ emoji: String) { - var recent = recentEmojis - recent.removeAll { $0 == emoji } - recent.insert(emoji, at: 0) - recent = Array(recent.prefix(Self.maxRecent)) - recentEmojis = recent - UserDefaults.standard.set(recent, forKey: Self.key) - } + /// Records emoji usage, moving it to front of recent list + func recordUsage(_ emoji: String) { + var recent = recentEmojis + recent.removeAll { $0 == emoji } + recent.insert(emoji, at: 0) + recent = Array(recent.prefix(Self.maxRecent)) + recentEmojis = recent + UserDefaults.standard.set(recent, forKey: Self.key) + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionButton.swift b/MC1/Views/Chats/Reactions/Sections/ActionButton.swift index 8d211ec7..28f8f6d4 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionButton.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionButton.swift @@ -1,20 +1,20 @@ import SwiftUI struct ActionButton: View { - let title: String - let icon: String - var isDestructive: Bool = false - let action: () -> Void + let title: String + let icon: String + var isDestructive: Bool = false + let action: () -> Void - var body: some View { - Button(role: isDestructive ? .destructive : nil, action: action) { - HStack { - Label(title, systemImage: icon) - Spacer() - } - .padding() - .contentShape(.rect) - } - .foregroundStyle(isDestructive ? .red : .primary) + var body: some View { + Button(role: isDestructive ? .destructive : nil, action: action) { + HStack { + Label(title, systemImage: icon) + Spacer() + } + .padding() + .contentShape(.rect) } + .foregroundStyle(isDestructive ? .red : .primary) + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionInfoRow.swift b/MC1/Views/Chats/Reactions/Sections/ActionInfoRow.swift index ed8080a8..1483cefb 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionInfoRow.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionInfoRow.swift @@ -3,23 +3,23 @@ import SwiftUI /// A single read-only metadata row in a message actions sheet: a label with an /// optional leading icon. Shared across the chat and room actions sheets. struct ActionInfoRow: View { - let text: String - var icon: String? + let text: String + var icon: String? - var body: some View { - HStack { - if let icon { - Image(systemName: icon) - .foregroundStyle(.secondary) - } - Text(text) - Spacer() - } - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.horizontal) - .padding(.vertical, 6) - .accessibilityElement(children: .combine) - .accessibilityLabel(text) + var body: some View { + HStack { + if let icon { + Image(systemName: icon) + .foregroundStyle(.secondary) + } + Text(text) + Spacer() } + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + .accessibilityLabel(text) + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift index 8e98c87e..c27e86a1 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift @@ -2,42 +2,42 @@ import MC1Services import SwiftUI struct ActionsButtonsSection: View { - let availability: MessageActionAvailability - let onSelectAction: (MessageAction) -> Void + let availability: MessageActionAvailability + let onSelectAction: (MessageAction) -> Void - @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote + @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote - var body: some View { - if availability.canReply { - ActionButton( - title: replyWithQuote - ? L10n.Chats.Chats.Message.Action.reply - : L10n.Chats.Chats.Message.Action.mention, - icon: "arrowshape.turn.up.left", - action: { onSelectAction(.reply) } - ) - } + var body: some View { + if availability.canReply { + ActionButton( + title: replyWithQuote + ? L10n.Chats.Chats.Message.Action.reply + : L10n.Chats.Chats.Message.Action.mention, + icon: "arrowshape.turn.up.left", + action: { onSelectAction(.reply) } + ) + } - if availability.canSendDM { - ActionButton( - title: L10n.Chats.Chats.Message.Action.sendDM, - icon: "bubble.left.and.bubble.right", - action: { onSelectAction(.sendDM) } - ) - } + if availability.canSendDM { + ActionButton( + title: L10n.Chats.Chats.Message.Action.sendDM, + icon: "bubble.left.and.bubble.right", + action: { onSelectAction(.sendDM) } + ) + } - ActionButton( - title: L10n.Chats.Chats.Message.Action.copy, - icon: "doc.on.doc", - action: { onSelectAction(.copy) } - ) + ActionButton( + title: L10n.Chats.Chats.Message.Action.copy, + icon: "doc.on.doc", + action: { onSelectAction(.copy) } + ) - if availability.canSendAgain { - ActionButton( - title: L10n.Chats.Chats.Message.Action.sendAgain, - icon: "arrow.uturn.forward", - action: { onSelectAction(.sendAgain) } - ) - } + if availability.canSendAgain { + ActionButton( + title: L10n.Chats.Chats.Message.Action.sendAgain, + icon: "arrow.uturn.forward", + action: { onSelectAction(.sendAgain) } + ) } + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsDestructiveSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsDestructiveSection.swift index 265af1db..0e7f4ea5 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsDestructiveSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsDestructiveSection.swift @@ -5,29 +5,29 @@ import SwiftUI /// separator rule in one place, instead of a hidden contract between sibling /// sections about which one draws it. struct ActionsDestructiveSection: View { - let availability: MessageActionAvailability - let onSelectAction: (MessageAction) -> Void + let availability: MessageActionAvailability + let onSelectAction: (MessageAction) -> Void - var body: some View { - if availability.canBlockSender || availability.canDelete { - Divider() - .padding(.vertical, 8) - if availability.canBlockSender { - ActionButton( - title: L10n.Chats.Chats.Message.Action.blockSender, - icon: "hand.raised", - isDestructive: true, - action: { onSelectAction(.blockSender) } - ) - } - if availability.canDelete { - ActionButton( - title: L10n.Chats.Chats.Message.Action.delete, - icon: "trash", - isDestructive: true, - action: { onSelectAction(.delete) } - ) - } - } + var body: some View { + if availability.canBlockSender || availability.canDelete { + Divider() + .padding(.vertical, 8) + if availability.canBlockSender { + ActionButton( + title: L10n.Chats.Chats.Message.Action.blockSender, + icon: "hand.raised", + isDestructive: true, + action: { onSelectAction(.blockSender) } + ) + } + if availability.canDelete { + ActionButton( + title: L10n.Chats.Chats.Message.Action.delete, + icon: "trash", + isDestructive: true, + action: { onSelectAction(.delete) } + ) + } } + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift index dc313ed0..e722eb19 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift @@ -2,227 +2,231 @@ import MC1Services import SwiftUI struct ActionsDetailsSection: View { - let message: MessageDTO - let availability: MessageActionAvailability - @Binding var isDetailExpanded: Bool - let repeats: [MessageRepeatDTO]? - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let pathViewModel: MessagePathViewModel - - @State private var showPathMap = false - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - if availability.canViewPath { - pathMapButton - } - - if availability.canShowRepeatDetails || availability.canViewPath { - ActionsExpandableDetailRow( - message: message, - availability: availability, - isDetailExpanded: $isDetailExpanded, - repeats: repeats, - contacts: contacts, - discoveredNodes: discoveredNodes, - pathViewModel: pathViewModel - ) - } - - Text(L10n.Chats.Chats.Message.Action.details) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 4) - - if message.isOutgoing { - ActionsOutgoingDetailsRows(message: message) - } else { - ActionsIncomingDetailsRows(message: message) - } - } - .sheet(isPresented: $showPathMap) { - MessagePathMapView(message: message, pathViewModel: pathViewModel) - } + let message: MessageDTO + let availability: MessageActionAvailability + @Binding var isDetailExpanded: Bool + let repeats: [MessageRepeatDTO]? + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let pathViewModel: MessagePathViewModel + + @State private var showPathMap = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if availability.canViewPath { + pathMapButton + } + + if availability.canShowRepeatDetails || availability.canViewPath { + ActionsExpandableDetailRow( + message: message, + availability: availability, + isDetailExpanded: $isDetailExpanded, + repeats: repeats, + contacts: contacts, + discoveredNodes: discoveredNodes, + pathViewModel: pathViewModel + ) + } + + Text(L10n.Chats.Chats.Message.Action.details) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 4) + + if message.isOutgoing { + ActionsOutgoingDetailsRows(message: message) + } else { + ActionsIncomingDetailsRows(message: message) + } } - - private var pathMapButton: some View { - Button { - showPathMap = true - } label: { - HStack { - Label(L10n.Chats.Chats.Path.map, systemImage: "map") - Spacer() - } - .padding() - .contentShape(.rect) - } - .foregroundStyle(.primary) + .sheet(isPresented: $showPathMap) { + MessagePathMapView(message: message, pathViewModel: pathViewModel) + } + } + + private var pathMapButton: some View { + Button { + showPathMap = true + } label: { + HStack { + Label(L10n.Chats.Chats.Path.map, systemImage: "map") + Spacer() + } + .padding() + .contentShape(.rect) } + .foregroundStyle(.primary) + } } private struct ActionsExpandableDetailRow: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - let message: MessageDTO - let availability: MessageActionAvailability - @Binding var isDetailExpanded: Bool - let repeats: [MessageRepeatDTO]? - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let pathViewModel: MessagePathViewModel - - var body: some View { - VStack(spacing: 0) { - Button { - withAnimation(reduceMotion ? nil : .default) { - isDetailExpanded.toggle() - } - } label: { - HStack { - Label( - availability.canShowRepeatDetails - ? L10n.Chats.Chats.Message.Action.repeatDetails - : L10n.Chats.Chats.Message.Action.viewPath, - systemImage: availability.canShowRepeatDetails - ? "arrow.triangle.branch" - : "point.topleft.down.to.point.bottomright.curvepath" - ) - Spacer() - Image(systemName: "chevron.right") - .rotationEffect(.degrees(isDetailExpanded ? 90 : 0)) - .foregroundStyle(.secondary) - .font(.caption) - .accessibilityHidden(true) - } - .padding() - .contentShape(.rect) - } - .foregroundStyle(.primary) - .accessibilityValue( - isDetailExpanded - ? L10n.Chats.Chats.Message.Action.expanded - : L10n.Chats.Chats.Message.Action.collapsed - ) - - if isDetailExpanded { - Divider() - .padding(.horizontal) - ActionsExpandedContent( - message: message, - availability: availability, - repeats: repeats, - contacts: contacts, - discoveredNodes: discoveredNodes, - pathViewModel: pathViewModel - ) - .padding(.horizontal) - .padding(.bottom) - .id("expandedContent") - } + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + let message: MessageDTO + let availability: MessageActionAvailability + @Binding var isDetailExpanded: Bool + let repeats: [MessageRepeatDTO]? + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let pathViewModel: MessagePathViewModel + + var body: some View { + VStack(spacing: 0) { + Button { + withAnimation(reduceMotion ? nil : .default) { + isDetailExpanded.toggle() } + } label: { + HStack { + Label( + availability.canShowRepeatDetails + ? L10n.Chats.Chats.Message.Action.repeatDetails + : L10n.Chats.Chats.Message.Action.viewPath, + systemImage: availability.canShowRepeatDetails + ? "arrow.triangle.branch" + : "point.topleft.down.to.point.bottomright.curvepath" + ) + Spacer() + Image(systemName: "chevron.right") + .rotationEffect(.degrees(isDetailExpanded ? 90 : 0)) + .foregroundStyle(.secondary) + .font(.caption) + .accessibilityHidden(true) + } + .padding() + .contentShape(.rect) + } + .foregroundStyle(.primary) + .accessibilityValue( + isDetailExpanded + ? L10n.Chats.Chats.Message.Action.expanded + : L10n.Chats.Chats.Message.Action.collapsed + ) + + if isDetailExpanded { + Divider() + .padding(.horizontal) + ActionsExpandedContent( + message: message, + availability: availability, + repeats: repeats, + contacts: contacts, + discoveredNodes: discoveredNodes, + pathViewModel: pathViewModel + ) + .padding(.horizontal) + .padding(.bottom) + .id("expandedContent") + } } + } } private struct ActionsExpandedContent: View { - @Environment(\.appState) private var appState - - let message: MessageDTO - let availability: MessageActionAvailability - let repeats: [MessageRepeatDTO]? - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let pathViewModel: MessagePathViewModel - - var body: some View { - if availability.canShowRepeatDetails { - RepeatDetailsContent( - repeats: repeats, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: appState.bestAvailableLocation - ) - } else if availability.canViewPath { - MessagePathContent( - message: message, - viewModel: pathViewModel, - receiverName: appState.connectedDevice?.nodeName ?? L10n.Chats.Chats.Path.Receiver.you, - userLocation: appState.bestAvailableLocation - ) - } + @Environment(\.appState) private var appState + + let message: MessageDTO + let availability: MessageActionAvailability + let repeats: [MessageRepeatDTO]? + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let pathViewModel: MessagePathViewModel + + var body: some View { + if availability.canShowRepeatDetails { + RepeatDetailsContent( + repeats: repeats, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: appState.bestAvailableLocation + ) + } else if availability.canViewPath { + MessagePathContent( + message: message, + viewModel: pathViewModel, + receiverName: appState.connectedDevice?.nodeName ?? L10n.Chats.Chats.Path.Receiver.you, + userLocation: appState.bestAvailableLocation + ) } + } } private struct ActionsOutgoingDetailsRows: View { - let message: MessageDTO + let message: MessageDTO - var body: some View { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( - message.senderDate.formatted(date: .abbreviated, time: .standard))) + var body: some View { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( + message.senderDate.formatted(date: .abbreviated, time: .standard) + )) - if let rtt = message.roundTripTime { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.roundTrip(Int(rtt))) - } + if let rtt = message.roundTripTime { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.roundTrip(Int(rtt))) + } - if message.heardRepeats > 0 { - let word = message.heardRepeats == 1 - ? L10n.Chats.Chats.Message.Repeat.singular - : L10n.Chats.Chats.Message.Repeat.plural - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.heardRepeats(message.heardRepeats, word)) - } + if message.heardRepeats > 0 { + let word = message.heardRepeats == 1 + ? L10n.Chats.Chats.Message.Repeat.singular + : L10n.Chats.Chats.Message.Repeat.plural + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.heardRepeats(message.heardRepeats, word)) } + } } private struct ActionsIncomingDetailsRows: View { - let message: MessageDTO + let message: MessageDTO - var body: some View { - ActionInfoRow( - text: L10n.Chats.Chats.Message.Info.hops(hopCountFormatted(message)), - icon: "arrowshape.bounce.right" - ) + var body: some View { + ActionInfoRow( + text: L10n.Chats.Chats.Message.Info.hops(hopCountFormatted(message)), + icon: "arrowshape.bounce.right" + ) - if let hashSize = message.pathHashSizeIfKnown { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.pathHash(hashSize)) - } + if let hashSize = message.pathHashSizeIfKnown { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.pathHash(hashSize)) + } - if message.routeType == .tcFlood { - ActionInfoRow( - text: message.regionScope.map { L10n.Chats.Chats.Message.Info.floodedUnder($0) } - ?? L10n.Chats.Chats.Message.Info.regionUnresolved, - icon: "globe" - ) - } + if message.routeType == .tcFlood { + ActionInfoRow( + text: message.regionScope.map { L10n.Chats.Chats.Message.Info.floodedUnder($0) } + ?? L10n.Chats.Chats.Message.Info.regionUnresolved, + icon: "globe" + ) + } - let sentText = L10n.Chats.Chats.Message.Info.sent( - message.senderDate.formatted(date: .abbreviated, time: .standard)) - let adjusted = message.timestampCorrected ? " " + L10n.Chats.Chats.Message.Info.adjusted : "" - ActionInfoRow(text: sentText + adjusted) + let sentText = L10n.Chats.Chats.Message.Info.sent( + message.senderDate.formatted(date: .abbreviated, time: .standard) + ) + let adjusted = message.timestampCorrected ? " " + L10n.Chats.Chats.Message.Info.adjusted : "" + ActionInfoRow(text: sentText + adjusted) - if message.timestampCorrected { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.originalSendTime( - message.wireSentDate.formatted(date: .abbreviated, time: .standard))) - } + if message.timestampCorrected { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.originalSendTime( + message.wireSentDate.formatted(date: .abbreviated, time: .standard) + )) + } - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.received( - message.createdAt.formatted(date: .abbreviated, time: .standard))) + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.received( + message.createdAt.formatted(date: .abbreviated, time: .standard) + )) - if let snr = message.snr { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.snr(snrFormatted(snr))) - } + if let snr = message.snr { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.snr(snrFormatted(snr))) } + } - private func snrFormatted(_ snr: Double) -> String { - let quality = SNRQuality(snr: snr).localizedLabel - return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB (\(quality))" - } + private func snrFormatted(_ snr: Double) -> String { + let quality = SNRQuality(snr: snr).localizedLabel + return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB (\(quality))" + } - private func hopCountFormatted(_ message: MessageDTO) -> String { - if message.isDirectRouted { - return L10n.Chats.Chats.Message.Hops.direct - } - return "\(message.hopCount)" + private func hopCountFormatted(_ message: MessageDTO) -> String { + if message.isDirectRouted { + return L10n.Chats.Chats.Message.Hops.direct } + return "\(message.hopCount)" + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsEmojiSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsEmojiSection.swift index 04a0cba9..c973508b 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsEmojiSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsEmojiSection.swift @@ -1,29 +1,29 @@ import SwiftUI struct ActionsEmojiSection: View { - let recentEmojis: [String] - @Binding var showEmojiPicker: Bool - let onSelectEmoji: (String) -> Void + let recentEmojis: [String] + @Binding var showEmojiPicker: Bool + let onSelectEmoji: (String) -> Void - @State private var pickerSelection: String? + @State private var pickerSelection: String? - var body: some View { - EmojiPickerRow( - emojis: recentEmojis, - onSelect: onSelectEmoji, - onOpenKeyboard: { showEmojiPicker = true } - ) - .padding(.vertical, 4) - // The full picker is a child of the actions sheet. Reacting also dismisses - // the actions sheet, so defer that to the picker's onDismiss: dismissing the - // parent while the child is still presented can strand the actions sheet open. - .sheet(isPresented: $showEmojiPicker, onDismiss: { - if let emoji = pickerSelection { - pickerSelection = nil - onSelectEmoji(emoji) - } - }) { - EmojiPickerSheet(onSelect: { pickerSelection = $0 }) - } + var body: some View { + EmojiPickerRow( + emojis: recentEmojis, + onSelect: onSelectEmoji, + onOpenKeyboard: { showEmojiPicker = true } + ) + .padding(.vertical, 4) + // The full picker is a child of the actions sheet. Reacting also dismisses + // the actions sheet, so defer that to the picker's onDismiss: dismissing the + // parent while the child is still presented can strand the actions sheet open. + .sheet(isPresented: $showEmojiPicker, onDismiss: { + if let emoji = pickerSelection { + pickerSelection = nil + onSelectEmoji(emoji) + } + }) { + EmojiPickerSheet(onSelect: { pickerSelection = $0 }) } + } } diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsPreviewHeader.swift b/MC1/Views/Chats/Reactions/Sections/ActionsPreviewHeader.swift index 3f5109ba..c21cb122 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsPreviewHeader.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsPreviewHeader.swift @@ -2,82 +2,82 @@ import MC1Services import SwiftUI struct ActionsPreviewHeader: View { - let message: MessageDTO - let senderName: String - let senderMatchKind: NodeNameMatchKind + let message: MessageDTO + let senderName: String + let senderMatchKind: NodeNameMatchKind - @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dynamicTypeSize) private var dynamicTypeSize - private var senderNodeID: String? { - guard !message.isOutgoing, - let keyPrefix = message.senderKeyPrefix, - let firstByte = keyPrefix.first else { return nil } - return String(format: "%02X", firstByte) - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - ViewThatFits(in: .horizontal) { - HStack { - senderNodeIDLabel - senderLabel - Spacer() - ActionsTimestampLabel(message: message) - } + private var senderNodeID: String? { + guard !message.isOutgoing, + let keyPrefix = message.senderKeyPrefix, + let firstByte = keyPrefix.first else { return nil } + return String(format: "%02X", firstByte) + } - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - senderNodeIDLabel - senderLabel - } - ActionsTimestampLabel(message: message) - } - } + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ViewThatFits(in: .horizontal) { + HStack { + senderNodeIDLabel + senderLabel + Spacer() + ActionsTimestampLabel(message: message) + } - Text(message.text) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + senderNodeIDLabel + senderLabel + } + ActionsTimestampLabel(message: message) } - .padding() - // Only collapse to a single rotor stop when there is no interactive - // descendant. The fallback-match indicator (inside senderLabel) is a - // Button with its own label/hint/popover; .combine would destroy that - // affordance. .contain preserves the indicator and adds a parent - // container that VoiceOver users can land on. - .accessibilityElement(children: senderMatchKind == .fallback ? .contain : .combine) + } + + Text(message.text) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) } + .padding() + // Only collapse to a single rotor stop when there is no interactive + // descendant. The fallback-match indicator (inside senderLabel) is a + // Button with its own label/hint/popover; .combine would destroy that + // affordance. .contain preserves the indicator and adds a parent + // container that VoiceOver users can land on. + .accessibilityElement(children: senderMatchKind == .fallback ? .contain : .combine) + } - @ViewBuilder - private var senderNodeIDLabel: some View { - if let senderNodeID { - Text(senderNodeID) - .font(.subheadline) - .foregroundStyle(.secondary) - .monospaced() - .accessibilityHidden(true) - } + @ViewBuilder + private var senderNodeIDLabel: some View { + if let senderNodeID { + Text(senderNodeID) + .font(.subheadline) + .foregroundStyle(.secondary) + .monospaced() + .accessibilityHidden(true) } + } - private var senderLabel: some View { - HStack(spacing: 4) { - Text(senderName) - .font(.subheadline) - .bold() + private var senderLabel: some View { + HStack(spacing: 4) { + Text(senderName) + .font(.subheadline) + .bold() - if senderMatchKind == .fallback { - FallbackMatchIndicatorView() - } - } + if senderMatchKind == .fallback { + FallbackMatchIndicatorView() + } } + } } private struct ActionsTimestampLabel: View { - let message: MessageDTO + let message: MessageDTO - var body: some View { - Text(message.date, format: .dateTime.hour().minute()) - .font(.subheadline) - .foregroundStyle(.secondary) - } + var body: some View { + Text(message.date, format: .dateTime.hour().minute()) + .font(.subheadline) + .foregroundStyle(.secondary) + } } diff --git a/MC1/Views/Chats/Reactions/String+EmojiAccessibilityName.swift b/MC1/Views/Chats/Reactions/String+EmojiAccessibilityName.swift index 33132863..355146cd 100644 --- a/MC1/Views/Chats/Reactions/String+EmojiAccessibilityName.swift +++ b/MC1/Views/Chats/Reactions/String+EmojiAccessibilityName.swift @@ -1,14 +1,14 @@ import Foundation extension String { - /// Spoken VoiceOver name for an emoji, derived from its Unicode character name - /// (e.g. "👍" becomes "thumbs up sign"). Reaction controls use this so they - /// announce a stable, app-controlled label rather than relying on VoiceOver's - /// built-in emoji pronunciation, which varies by locale and OS version. - var emojiAccessibilityName: String { - let cfstr = NSMutableString(string: self) as CFMutableString - CFStringTransform(cfstr, nil, kCFStringTransformToUnicodeName, false) - let name = cfstr as String - return name.replacing("\\N{", with: "").replacing("}", with: "").lowercased() - } + /// Spoken VoiceOver name for an emoji, derived from its Unicode character name + /// (e.g. "👍" becomes "thumbs up sign"). Reaction controls use this so they + /// announce a stable, app-controlled label rather than relying on VoiceOver's + /// built-in emoji pronunciation, which varies by locale and OS version. + var emojiAccessibilityName: String { + let cfstr = NSMutableString(string: self) as CFMutableString + CFStringTransform(cfstr, nil, kCFStringTransformToUnicodeName, false) + let name = cfstr as String + return name.replacing("\\N{", with: "").replacing("}", with: "").lowercased() + } } diff --git a/MC1/Views/Chats/Room/RoomAuthenticationSheet.swift b/MC1/Views/Chats/Room/RoomAuthenticationSheet.swift index ba7b6d08..b990a151 100644 --- a/MC1/Views/Chats/Room/RoomAuthenticationSheet.swift +++ b/MC1/Views/Chats/Room/RoomAuthenticationSheet.swift @@ -1,41 +1,41 @@ -import SwiftUI import MC1Services +import SwiftUI struct RoomAuthenticationSheet: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let session: RemoteNodeSessionDTO - let onSuccess: (RemoteNodeSessionDTO) -> Void + let session: RemoteNodeSessionDTO + let onSuccess: (RemoteNodeSessionDTO) -> Void - @State private var contact: ContactDTO? - @State private var isLoading = true + @State private var contact: ContactDTO? + @State private var isLoading = true - var body: some View { - Group { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let contact { - NodeAuthenticationSheet( - contact: contact, - role: .roomServer, - hideNodeDetails: true, - onSuccess: onSuccess - ) - } else { - ContentUnavailableView( - L10n.Chats.Chats.RoomAuth.NotFound.title, - systemImage: "exclamationmark.triangle", - description: Text(L10n.Chats.Chats.RoomAuth.NotFound.description) - ) - } - } - .task { - contact = try? await appState.services?.dataStore.fetchContact( - radioID: session.radioID, - publicKey: session.publicKey - ) - isLoading = false - } + var body: some View { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let contact { + NodeAuthenticationSheet( + contact: contact, + role: .roomServer, + hideNodeDetails: true, + onSuccess: onSuccess + ) + } else { + ContentUnavailableView( + L10n.Chats.Chats.RoomAuth.NotFound.title, + systemImage: "exclamationmark.triangle", + description: Text(L10n.Chats.Chats.RoomAuth.NotFound.description) + ) + } + } + .task { + contact = try? await appState.services?.dataStore.fetchContact( + radioID: session.radioID, + publicKey: session.publicKey + ) + isLoading = false } + } } diff --git a/MC1/Views/Chats/Room/RoomConversationRow.swift b/MC1/Views/Chats/Room/RoomConversationRow.swift index 951807b5..a5618449 100644 --- a/MC1/Views/Chats/Room/RoomConversationRow.swift +++ b/MC1/Views/Chats/Room/RoomConversationRow.swift @@ -1,63 +1,63 @@ -import SwiftUI import MC1Services +import SwiftUI struct RoomConversationRow: View { - @Environment(\.appState) private var appState - let session: RemoteNodeSessionDTO - var referenceDate: Date? - - var body: some View { - HStack(spacing: 12) { - NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 44) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text(session.name) - .font(.headline) - .lineLimit(1) - - Spacer() - - NotificationLevelIndicator(level: session.notificationLevel) - - if session.isFavorite { - Image(systemName: "star.fill") - .foregroundStyle(.yellow) - .font(.caption) - .accessibilityLabel(L10n.Chats.Chats.Row.favorite) - } - - if let date = session.lastMessageDate { - ConversationTimestamp(date: date, referenceDate: referenceDate) - } - } - - HStack { - if session.isConnected && appState.connectionState == .ready { - Label(L10n.Chats.Chats.Room.connected, systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) - } else { - Text(L10n.Chats.Chats.Room.tapToReconnect) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - - UnreadBadges( - unreadCount: session.unreadCount, - notificationLevel: session.notificationLevel - ) - } - } - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - .accessibilityHidden(true) + @Environment(\.appState) private var appState + let session: RemoteNodeSessionDTO + var referenceDate: Date? + + var body: some View { + HStack(spacing: 12) { + NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 44) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(session.name) + .font(.headline) + .lineLimit(1) + + Spacer() + + NotificationLevelIndicator(level: session.notificationLevel) + + if session.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .font(.caption) + .accessibilityLabel(L10n.Chats.Chats.Row.favorite) + } + + if let date = session.lastMessageDate { + ConversationTimestamp(date: date, referenceDate: referenceDate) + } } - .padding(.vertical, 4) - .contentShape(.rect) + + HStack { + if session.isConnected, appState.connectionState == .ready { + Label(L10n.Chats.Chats.Room.connected, systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + Text(L10n.Chats.Chats.Room.tapToReconnect) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + UnreadBadges( + unreadCount: session.unreadCount, + notificationLevel: session.notificationLevel + ) + } + } + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) } + .padding(.vertical, 4) + .contentShape(.rect) + } } diff --git a/MC1/Views/Chats/Room/RoomInfoSheet.swift b/MC1/Views/Chats/Room/RoomInfoSheet.swift index 314773c2..661b6c64 100644 --- a/MC1/Views/Chats/Room/RoomInfoSheet.swift +++ b/MC1/Views/Chats/Room/RoomInfoSheet.swift @@ -1,122 +1,122 @@ -import SwiftUI import MC1Services +import SwiftUI private typealias Strings = L10n.RemoteNodes.RemoteNodes.Room struct RoomInfoSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.chatViewModel) private var viewModel - @Environment(\.appTheme) private var theme - - let session: RemoteNodeSessionDTO + @Environment(\.dismiss) private var dismiss + @Environment(\.chatViewModel) private var viewModel + @Environment(\.appTheme) private var theme - @State private var notificationLevel: NotificationLevel - @State private var isFavorite: Bool - @State private var notificationTask: Task? - @State private var favoriteTask: Task? - @State private var showTelemetry = false - @State private var showSettings = false - - init(session: RemoteNodeSessionDTO) { - self.session = session - self._notificationLevel = State(initialValue: session.notificationLevel) - self._isFavorite = State(initialValue: session.isFavorite) - } + let session: RemoteNodeSessionDTO - var body: some View { - NavigationStack { - List { - Section { - HStack { - Spacer() - NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 80) - Spacer() - } - .listRowBackground(Color.clear) - } + @State private var notificationLevel: NotificationLevel + @State private var isFavorite: Bool + @State private var notificationTask: Task? + @State private var favoriteTask: Task? + @State private var showTelemetry = false + @State private var showSettings = false - ConversationQuickActionsSection( - notificationLevel: $notificationLevel, - isFavorite: $isFavorite, - availableLevels: NotificationLevel.roomLevels - ) - .onChange(of: notificationLevel) { _, newValue in - notificationTask?.cancel() - notificationTask = Task { - await viewModel?.setNotificationLevel(.room(session), level: newValue) - } - } - .onChange(of: isFavorite) { _, newValue in - favoriteTask?.cancel() - favoriteTask = Task { - await viewModel?.setFavorite(.room(session), isFavorite: newValue) - } - } - .onDisappear { - notificationTask?.cancel() - favoriteTask?.cancel() - } + init(session: RemoteNodeSessionDTO) { + self.session = session + _notificationLevel = State(initialValue: session.notificationLevel) + _isFavorite = State(initialValue: session.isFavorite) + } - if session.isConnected { - Section { - Button { showTelemetry = true } label: { - Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") - } - if session.isAdmin { - Button { showSettings = true } label: { - Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") - } - } - } - .themedRowBackground(theme) - } - - Section(Strings.details) { - LabeledContent(L10n.RemoteNodes.RemoteNodes.name, value: session.name) - LabeledContent(Strings.permission, value: session.permissionLevel.localizedName) - if session.isConnected { - LabeledContent(Strings.status, value: Strings.connected) - } - } - .themedRowBackground(theme) + var body: some View { + NavigationStack { + List { + Section { + HStack { + Spacer() + NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 80) + Spacer() + } + .listRowBackground(Color.clear) + } - if let lastConnected = session.lastConnectedDate { - Section(Strings.activity) { - LabeledContent(Strings.lastConnected) { - Text(lastConnected, format: .relative(presentation: .named)) - } - } - .themedRowBackground(theme) - } + ConversationQuickActionsSection( + notificationLevel: $notificationLevel, + isFavorite: $isFavorite, + availableLevels: NotificationLevel.roomLevels + ) + .onChange(of: notificationLevel) { _, newValue in + notificationTask?.cancel() + notificationTask = Task { + await viewModel?.setNotificationLevel(.room(session), level: newValue) + } + } + .onChange(of: isFavorite) { _, newValue in + favoriteTask?.cancel() + favoriteTask = Task { + await viewModel?.setFavorite(.room(session), isFavorite: newValue) + } + } + .onDisappear { + notificationTask?.cancel() + favoriteTask?.cancel() + } - Section(Strings.identification) { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.publicKey) - .font(.caption) - .foregroundStyle(.secondary) - Text(session.publicKeyHex) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } - .themedRowBackground(theme) + if session.isConnected { + Section { + Button { showTelemetry = true } label: { + Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") } - .themedCanvas(theme) - .navigationTitle(Strings.infoTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.done) { dismiss() } - } + if session.isAdmin { + Button { showSettings = true } label: { + Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") + } } + } + .themedRowBackground(theme) } - .sheet(isPresented: $showTelemetry) { - RoomStatusView(session: session) + + Section(Strings.details) { + LabeledContent(L10n.RemoteNodes.RemoteNodes.name, value: session.name) + LabeledContent(Strings.permission, value: session.permissionLevel.localizedName) + if session.isConnected { + LabeledContent(Strings.status, value: Strings.connected) + } } - .sheet(isPresented: $showSettings) { - NavigationStack { - RoomSettingsView(session: session) + .themedRowBackground(theme) + + if let lastConnected = session.lastConnectedDate { + Section(Strings.activity) { + LabeledContent(Strings.lastConnected) { + Text(lastConnected, format: .relative(presentation: .named)) } + } + .themedRowBackground(theme) + } + + Section(Strings.identification) { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.publicKey) + .font(.caption) + .foregroundStyle(.secondary) + Text(session.publicKeyHex) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(Strings.infoTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.done) { dismiss() } } + } + } + .sheet(isPresented: $showTelemetry) { + RoomStatusView(session: session) + } + .sheet(isPresented: $showSettings) { + NavigationStack { + RoomSettingsView(session: session) + } } + } } diff --git a/MC1/Views/Chats/ScanChannelQRView.swift b/MC1/Views/Chats/ScanChannelQRView.swift index 2406cac8..6a57920d 100644 --- a/MC1/Views/Chats/ScanChannelQRView.swift +++ b/MC1/Views/Chats/ScanChannelQRView.swift @@ -1,309 +1,309 @@ -import SwiftUI -import VisionKit import AudioToolbox import MC1Services +import SwiftUI +import VisionKit /// View for scanning a channel QR code to join struct ScanChannelQRView: View { - @Environment(\.appState) private var appState - - let availableSlots: [UInt8] - let onComplete: (ChannelDTO?) -> Void - - @State private var scannedChannel: MeshCoreURLParser.ChannelResult? - @State private var selectedSlot: UInt8 - @State private var isJoining = false - @State private var errorMessage: String? - @State private var cameraPermissionDenied = false - - init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { - self.availableSlots = availableSlots - self.onComplete = onComplete - self._selectedSlot = State(initialValue: availableSlots.first ?? 1) - } - - var body: some View { - Group { - if let channel = scannedChannel { - ScanConfirmationContent( - scannedChannel: channel, - isJoining: isJoining, - errorMessage: errorMessage, - onJoin: { Task { await joinChannel() } }, - onScanAgain: { - scannedChannel = nil - errorMessage = nil - } - ) - } else if cameraPermissionDenied { - CameraPermissionDeniedContent() - } else { - ScannerContent( - onScanResult: handleScanResult, - cameraPermissionDenied: $cameraPermissionDenied - ) - } - } - .navigationTitle(L10n.Chats.Chats.ScanQR.title) - .navigationBarTitleDisplayMode(.inline) + @Environment(\.appState) private var appState + + let availableSlots: [UInt8] + let onComplete: (ChannelDTO?) -> Void + + @State private var scannedChannel: MeshCoreURLParser.ChannelResult? + @State private var selectedSlot: UInt8 + @State private var isJoining = false + @State private var errorMessage: String? + @State private var cameraPermissionDenied = false + + init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { + self.availableSlots = availableSlots + self.onComplete = onComplete + _selectedSlot = State(initialValue: availableSlots.first ?? 1) + } + + var body: some View { + Group { + if let channel = scannedChannel { + ScanConfirmationContent( + scannedChannel: channel, + isJoining: isJoining, + errorMessage: errorMessage, + onJoin: { Task { await joinChannel() } }, + onScanAgain: { + scannedChannel = nil + errorMessage = nil + } + ) + } else if cameraPermissionDenied { + CameraPermissionDeniedContent() + } else { + ScannerContent( + onScanResult: handleScanResult, + cameraPermissionDenied: $cameraPermissionDenied + ) + } } + .navigationTitle(L10n.Chats.Chats.ScanQR.title) + .navigationBarTitleDisplayMode(.inline) + } - // MARK: - Private Methods + // MARK: - Private Methods - private func handleScanResult(_ result: String) { - guard let parsed = MeshCoreURLParser.parseChannelURL(result) else { - errorMessage = L10n.Chats.Chats.ScanQR.Error.invalidFormat - return - } - scannedChannel = parsed + private func handleScanResult(_ result: String) { + guard let parsed = MeshCoreURLParser.parseChannelURL(result) else { + errorMessage = L10n.Chats.Chats.ScanQR.Error.invalidFormat + return + } + scannedChannel = parsed + } + + private func joinChannel() async { + guard let radioID = appState.connectedDevice?.radioID, + let channel = scannedChannel else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return } - private func joinChannel() async { - guard let radioID = appState.connectedDevice?.radioID, - let channel = scannedChannel else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return - } - - isJoining = true - defer { isJoining = false } - errorMessage = nil - - do { - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return - } - try await channelService.setChannelWithSecret( - radioID: radioID, - index: selectedSlot, - name: channel.name, - secret: channel.secret - ) - - // Fetch the joined channel to return it - var joinedChannel: ChannelDTO? - if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { - joinedChannel = channels.first { $0.index == selectedSlot } - } - onComplete(joinedChannel) - } catch { - errorMessage = error.userFacingMessage - } + isJoining = true + defer { isJoining = false } + errorMessage = nil + + do { + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return + } + try await channelService.setChannelWithSecret( + radioID: radioID, + index: selectedSlot, + name: channel.name, + secret: channel.secret + ) + + // Fetch the joined channel to return it + var joinedChannel: ChannelDTO? + if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { + joinedChannel = channels.first { $0.index == selectedSlot } + } + onComplete(joinedChannel) + } catch { + errorMessage = error.userFacingMessage } + } } // MARK: - Extracted Views private struct ScannerContent: View { - let onScanResult: (String) -> Void - @Binding var cameraPermissionDenied: Bool - - var body: some View { - ZStack { - if QRDataScannerView.isSupported && QRDataScannerView.isAvailable { - QRDataScannerView { result in - onScanResult(result) - } onPermissionDenied: { - cameraPermissionDenied = true - } - } else { - // Fallback for unsupported devices - ContentUnavailableView( - L10n.Chats.Chats.ScanQR.NotAvailable.title, - systemImage: "qrcode.viewfinder", - description: Text(L10n.Chats.Chats.ScanQR.NotAvailable.description) - ) - } + let onScanResult: (String) -> Void + @Binding var cameraPermissionDenied: Bool + + var body: some View { + ZStack { + if QRDataScannerView.isSupported, QRDataScannerView.isAvailable { + QRDataScannerView { result in + onScanResult(result) + } onPermissionDenied: { + cameraPermissionDenied = true + } + } else { + // Fallback for unsupported devices + ContentUnavailableView( + L10n.Chats.Chats.ScanQR.NotAvailable.title, + systemImage: "qrcode.viewfinder", + description: Text(L10n.Chats.Chats.ScanQR.NotAvailable.description) + ) + } - // Overlay with scan frame - VStack { - Spacer() + // Overlay with scan frame + VStack { + Spacer() - RoundedRectangle(cornerRadius: 20) - .stroke(.white, lineWidth: 3) - .frame(width: 250, height: 250) + RoundedRectangle(cornerRadius: 20) + .stroke(.white, lineWidth: 3) + .frame(width: 250, height: 250) - Spacer() + Spacer() - Text(L10n.Chats.Chats.ScanQR.instruction) - .font(.subheadline) - .foregroundStyle(.white) - .padding() - .background(.black.opacity(0.6), in: .capsule) - .padding(.bottom, 50) - } - } - .ignoresSafeArea() + Text(L10n.Chats.Chats.ScanQR.instruction) + .font(.subheadline) + .foregroundStyle(.white) + .padding() + .background(.black.opacity(0.6), in: .capsule) + .padding(.bottom, 50) + } } + .ignoresSafeArea() + } } private struct ScanConfirmationContent: View { - @Environment(\.appTheme) private var theme - - let scannedChannel: MeshCoreURLParser.ChannelResult - let isJoining: Bool - let errorMessage: String? - let onJoin: () -> Void - let onScanAgain: () -> Void - - var body: some View { - Form { - Section { - LabeledContent(L10n.Chats.Chats.CreatePrivate.channelName, value: scannedChannel.name) - - LabeledContent(L10n.Chats.Chats.ChannelInfo.secretKey) { - Text(scannedChannel.secret.uppercaseHexString()) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - } header: { - Text(L10n.Chats.Chats.CreatePrivate.Section.details) - } - .themedRowBackground(theme) - - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } - - Section { - Button(action: onJoin) { - HStack { - Spacer() - if isJoining { - ProgressView() - } else { - Text(L10n.Chats.Chats.JoinPrivate.joinButton) - } - Spacer() - } - } - .disabled(isJoining) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - - Button(action: onScanAgain) { - HStack { - Spacer() - Text(L10n.Chats.Chats.ScanQR.scanAgain) - Spacer() - } - } + @Environment(\.appTheme) private var theme + + let scannedChannel: MeshCoreURLParser.ChannelResult + let isJoining: Bool + let errorMessage: String? + let onJoin: () -> Void + let onScanAgain: () -> Void + + var body: some View { + Form { + Section { + LabeledContent(L10n.Chats.Chats.CreatePrivate.channelName, value: scannedChannel.name) + + LabeledContent(L10n.Chats.Chats.ChannelInfo.secretKey) { + Text(scannedChannel.secret.uppercaseHexString()) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } header: { + Text(L10n.Chats.Chats.CreatePrivate.Section.details) + } + .themedRowBackground(theme) + + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + .themedRowBackground(theme) + } + + Section { + Button(action: onJoin) { + HStack { + Spacer() + if isJoining { + ProgressView() + } else { + Text(L10n.Chats.Chats.JoinPrivate.joinButton) } - .themedRowBackground(theme) + Spacer() + } } - .themedCanvas(theme) + .disabled(isJoining) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + + Button(action: onScanAgain) { + HStack { + Spacer() + Text(L10n.Chats.Chats.ScanQR.scanAgain) + Spacer() + } + } + } + .themedRowBackground(theme) } + .themedCanvas(theme) + } } private struct CameraPermissionDeniedContent: View { - var body: some View { - VStack(spacing: 20) { - Image(systemName: "camera.fill") - .font(.system(size: 60)) - .foregroundStyle(.secondary) - - Text(L10n.Chats.Chats.ScanQR.PermissionDenied.title) - .font(.title2) - .bold() - - Text(L10n.Chats.Chats.ScanQR.PermissionDenied.message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Button(L10n.Chats.Chats.ScanQR.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } - .buttonStyle(.borderedProminent) + var body: some View { + VStack(spacing: 20) { + Image(systemName: "camera.fill") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + + Text(L10n.Chats.Chats.ScanQR.PermissionDenied.title) + .font(.title2) + .bold() + + Text(L10n.Chats.Chats.ScanQR.PermissionDenied.message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button(L10n.Chats.Chats.ScanQR.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) } - .padding() + } + .buttonStyle(.borderedProminent) } + .padding() + } } // MARK: - QR Scanner using DataScannerViewController struct QRDataScannerView: UIViewControllerRepresentable { - let onScan: (String) -> Void - let onPermissionDenied: () -> Void - - static var isSupported: Bool { - DataScannerViewController.isSupported + let onScan: (String) -> Void + let onPermissionDenied: () -> Void + + static var isSupported: Bool { + DataScannerViewController.isSupported + } + + static var isAvailable: Bool { + DataScannerViewController.isAvailable + } + + func makeUIViewController(context: Context) -> DataScannerViewController { + let scanner = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.qr])], + qualityLevel: .balanced, + recognizesMultipleItems: false, + isHighFrameRateTrackingEnabled: false, + isHighlightingEnabled: true + ) + scanner.delegate = context.coordinator + return scanner + } + + func updateUIViewController(_ controller: DataScannerViewController, context: Context) { + // Start scanning when view appears + if !controller.isScanning { + try? controller.startScanning() } + } - static var isAvailable: Bool { - DataScannerViewController.isAvailable - } + static func dismantleUIViewController(_ controller: DataScannerViewController, coordinator: Coordinator) { + controller.stopScanning() + } - func makeUIViewController(context: Context) -> DataScannerViewController { - let scanner = DataScannerViewController( - recognizedDataTypes: [.barcode(symbologies: [.qr])], - qualityLevel: .balanced, - recognizesMultipleItems: false, - isHighFrameRateTrackingEnabled: false, - isHighlightingEnabled: true - ) - scanner.delegate = context.coordinator - return scanner - } + func makeCoordinator() -> Coordinator { + Coordinator(onScan: onScan) + } - func updateUIViewController(_ controller: DataScannerViewController, context: Context) { - // Start scanning when view appears - if !controller.isScanning { - try? controller.startScanning() - } - } + @MainActor + class Coordinator: NSObject, DataScannerViewControllerDelegate { + let onScan: (String) -> Void + private var hasScanned = false - static func dismantleUIViewController(_ controller: DataScannerViewController, coordinator: Coordinator) { - controller.stopScanning() + init(onScan: @escaping (String) -> Void) { + self.onScan = onScan } - func makeCoordinator() -> Coordinator { - Coordinator(onScan: onScan) + func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) { + processItem(item, scanner: dataScanner) } - @MainActor - class Coordinator: NSObject, DataScannerViewControllerDelegate { - let onScan: (String) -> Void - private var hasScanned = false - - init(onScan: @escaping (String) -> Void) { - self.onScan = onScan - } - - func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) { - processItem(item, scanner: dataScanner) - } - - func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { - // Auto-capture first QR code detected - guard !hasScanned, let item = addedItems.first else { return } - processItem(item, scanner: dataScanner) - } + func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { + // Auto-capture first QR code detected + guard !hasScanned, let item = addedItems.first else { return } + processItem(item, scanner: dataScanner) + } - private func processItem(_ item: RecognizedItem, scanner: DataScannerViewController) { - guard !hasScanned else { return } + private func processItem(_ item: RecognizedItem, scanner: DataScannerViewController) { + guard !hasScanned else { return } - if case .barcode(let barcode) = item, - let payload = barcode.payloadStringValue { - hasScanned = true - scanner.stopScanning() - AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) - onScan(payload) - } - } + if case let .barcode(barcode) = item, + let payload = barcode.payloadStringValue { + hasScanned = true + scanner.stopScanning() + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + onScan(payload) + } } + } } #Preview { - NavigationStack { - ScanChannelQRView(availableSlots: [1, 2, 3], onComplete: { _ in }) - } - .environment(\.appState, AppState()) + NavigationStack { + ScanChannelQRView(availableSlots: [1, 2, 3], onComplete: { _ in }) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/SendDMContext.swift b/MC1/Views/Chats/SendDMContext.swift index ba8b4466..5cc486c6 100644 --- a/MC1/Views/Chats/SendDMContext.swift +++ b/MC1/Views/Chats/SendDMContext.swift @@ -2,7 +2,7 @@ import Foundation /// Context for presenting the send-DM sheet from a channel message sender. struct SendDMContext: Identifiable { - let id = UUID() - let senderName: String - let radioID: UUID + let id = UUID() + let senderName: String + let radioID: UUID } diff --git a/MC1/Views/Chats/SenderContactMatcher.swift b/MC1/Views/Chats/SenderContactMatcher.swift index e5d2b261..7049f6c2 100644 --- a/MC1/Views/Chats/SenderContactMatcher.swift +++ b/MC1/Views/Chats/SenderContactMatcher.swift @@ -7,20 +7,20 @@ import MC1Services /// features that need to act on the sender (Block Sender, Send DM) rely on /// case-insensitive name matching against the local contact list. enum SenderContactMatcher { - /// Returns contacts whose `name` matches `senderName` case-insensitively. - /// - /// - Parameters: - /// - contacts: The contact list to filter, typically fetched from `PersistenceStore`. - /// - senderName: The sender display name from the channel message. - /// - excludeBlocked: When `true`, already-blocked contacts are omitted. - static func filter( - contacts: [ContactDTO], - senderName: String, - excludeBlocked: Bool = false - ) -> [ContactDTO] { - contacts.filter { contact in - (!excludeBlocked || !contact.isBlocked) - && contact.name.localizedCaseInsensitiveCompare(senderName) == .orderedSame - } + /// Returns contacts whose `name` matches `senderName` case-insensitively. + /// + /// - Parameters: + /// - contacts: The contact list to filter, typically fetched from `PersistenceStore`. + /// - senderName: The sender display name from the channel message. + /// - excludeBlocked: When `true`, already-blocked contacts are omitted. + static func filter( + contacts: [ContactDTO], + senderName: String, + excludeBlocked: Bool = false + ) -> [ContactDTO] { + contacts.filter { contact in + (!excludeBlocked || !contact.isBlocked) + && contact.name.localizedCaseInsensitiveCompare(senderName) == .orderedSame } + } } diff --git a/MC1/Views/Chats/Sheets/AddContactConfirmationSheet.swift b/MC1/Views/Chats/Sheets/AddContactConfirmationSheet.swift index d2e81e7c..ba8cb0ad 100644 --- a/MC1/Views/Chats/Sheets/AddContactConfirmationSheet.swift +++ b/MC1/Views/Chats/Sheets/AddContactConfirmationSheet.swift @@ -1,199 +1,198 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "AddContactConfirmationSheet") /// Confirmation sheet shown when tapping a meshcore://contact/add link in a chat message @MainActor struct AddContactConfirmationSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - - let contactResult: MeshCoreURLParser.ContactResult - let onComplete: (ContactDTO?) -> Void - - @State private var isAdding = false - @State private var errorMessage: String? - @State private var successTrigger = 0 - - private var isMissingDevice: Bool { - appState.connectedDevice == nil - } - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - if isMissingDevice { - ContactMissingDeviceContent( - contactName: contactResult.name, - onDismiss: { - onComplete(nil) - dismiss() - } - ) - } else { - ContactAddConfirmationContent( - contactResult: contactResult, - errorMessage: errorMessage, - isAdding: isAdding, - onAdd: { Task { await addContact() } } - ) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .navigationTitle(L10n.Contacts.Contacts.Add.nodeTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Contacts.Contacts.Common.cancel) { - onComplete(nil) - dismiss() - } - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + + let contactResult: MeshCoreURLParser.ContactResult + let onComplete: (ContactDTO?) -> Void + + @State private var isAdding = false + @State private var errorMessage: String? + @State private var successTrigger = 0 + + private var isMissingDevice: Bool { + appState.connectedDevice == nil + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if isMissingDevice { + ContactMissingDeviceContent( + contactName: contactResult.name, + onDismiss: { + onComplete(nil) + dismiss() } - .sensoryFeedback(.success, trigger: successTrigger) - .sensoryFeedback(.error, trigger: errorMessage) + ) + } else { + ContactAddConfirmationContent( + contactResult: contactResult, + errorMessage: errorMessage, + isAdding: isAdding, + onAdd: { Task { await addContact() } } + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .navigationTitle(L10n.Contacts.Contacts.Add.nodeTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Common.cancel) { + onComplete(nil) + dismiss() + } } + } + .sensoryFeedback(.success, trigger: successTrigger) + .sensoryFeedback(.error, trigger: errorMessage) } + } - // MARK: - Private Methods + // MARK: - Private Methods - private func addContact() async { - guard let radioID = appState.connectedDevice?.radioID else { return } - - guard let contactService = appState.services?.contactService, - let dataStore = appState.services?.dataStore else { - errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected - return - } + private func addContact() async { + guard let radioID = appState.connectedDevice?.radioID else { return } - isAdding = true - errorMessage = nil - - do { - let contact = ContactFrame( - publicKey: contactResult.publicKey, - type: contactResult.contactType, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: contactResult.name, - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: UInt32(Date().timeIntervalSince1970) - ) - - try await contactService.addOrUpdateContact( - radioID: radioID, - contact: contact - ) - - if let addedContact = try await dataStore.fetchContact( - radioID: radioID, - publicKey: contactResult.publicKey - ) { - successTrigger += 1 - onComplete(addedContact) - dismiss() - } else { - errorMessage = L10n.Contacts.Contacts.Common.errorOccurred - } - } catch { - logger.error("Failed to add contact from link: \(error)") - errorMessage = error.userFacingMessage - } + guard let contactService = appState.services?.contactService, + let dataStore = appState.services?.dataStore else { + errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected + return + } - isAdding = false + isAdding = true + errorMessage = nil + + do { + let contact = ContactFrame( + publicKey: contactResult.publicKey, + type: contactResult.contactType, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: contactResult.name, + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: UInt32(Date().timeIntervalSince1970) + ) + + try await contactService.addOrUpdateContact( + radioID: radioID, + contact: contact + ) + + if let addedContact = try await dataStore.fetchContact( + radioID: radioID, + publicKey: contactResult.publicKey + ) { + successTrigger += 1 + onComplete(addedContact) + dismiss() + } else { + errorMessage = L10n.Contacts.Contacts.Common.errorOccurred + } + } catch { + logger.error("Failed to add contact from link: \(error)") + errorMessage = error.userFacingMessage } + + isAdding = false + } } // MARK: - Extracted Views private struct ContactMissingDeviceContent: View { - let contactName: String - let onDismiss: () -> Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Localizable.Common.Status.disconnected, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Contacts.Contacts.Add.Error.notConnected) - } actions: { - Button(L10n.Contacts.Contacts.Common.ok, action: onDismiss) - .liquidGlassProminentButtonStyle() - } + let contactName: String + let onDismiss: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Localizable.Common.Status.disconnected, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Contacts.Contacts.Add.Error.notConnected) + } actions: { + Button(L10n.Contacts.Contacts.Common.ok, action: onDismiss) + .liquidGlassProminentButtonStyle() } + } } private struct ContactAddConfirmationContent: View { - let contactResult: MeshCoreURLParser.ContactResult - let errorMessage: String? - let isAdding: Bool - let onAdd: () -> Void - - var body: some View { - VStack(spacing: 24) { - Spacer() - - VStack(spacing: 16) { - ZStack { - Circle() - .fill(contactResult.contactType.displayColor) - .frame(width: 80, height: 80) - - Image(systemName: contactResult.contactType.iconSystemName) - .font(.system(size: 36, weight: .bold)) - .foregroundStyle(.white) - } - - Text(contactResult.name) - .font(.title) - .bold() - - Text(contactResult.contactType.localizedName) - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(contactResult.publicKey.uppercaseHexString()) - .font(.caption) - .monospaced() - .foregroundStyle(.tertiary) - .textSelection(.enabled) - } - - if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundStyle(.red) - .padding(.horizontal) - } + let contactResult: MeshCoreURLParser.ContactResult + let errorMessage: String? + let isAdding: Bool + let onAdd: () -> Void + + var body: some View { + VStack(spacing: 24) { + Spacer() + + VStack(spacing: 16) { + ZStack { + Circle() + .fill(contactResult.contactType.displayColor) + .frame(width: 80, height: 80) + + Image(systemName: contactResult.contactType.iconSystemName) + .font(.system(size: 36, weight: .bold)) + .foregroundStyle(.white) + } - Button(action: onAdd) { - if isAdding { - ProgressView() - } else { - Text(L10n.Contacts.Contacts.Add.add) - } - } - .liquidGlassProminentButtonStyle() - .disabled(isAdding) - .padding(.horizontal, 48) - .padding(.bottom, 32) + Text(contactResult.name) + .font(.title) + .bold() + + Text(contactResult.contactType.localizedName) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(contactResult.publicKey.uppercaseHexString()) + .font(.caption) + .monospaced() + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + .padding(.horizontal) + } + + Button(action: onAdd) { + if isAdding { + ProgressView() + } else { + Text(L10n.Contacts.Contacts.Add.add) } + } + .liquidGlassProminentButtonStyle() + .disabled(isAdding) + .padding(.horizontal, 48) + .padding(.bottom, 32) } - + } } #Preview { - let result = MeshCoreURLParser.ContactResult( - name: "TestRepeater", - publicKey: Data(repeating: 0xAA, count: 32), - contactType: .repeater - ) - AddContactConfirmationSheet(contactResult: result) { _ in } - .environment(\.appState, AppState()) - .presentationDetents([.medium, .large]) + let result = MeshCoreURLParser.ContactResult( + name: "TestRepeater", + publicKey: Data(repeating: 0xAA, count: 32), + contactType: .repeater + ) + AddContactConfirmationSheet(contactResult: result) { _ in } + .environment(\.appState, AppState()) + .presentationDetents([.medium, .large]) } diff --git a/MC1/Views/Chats/Sheets/BlockSenderSheet.swift b/MC1/Views/Chats/Sheets/BlockSenderSheet.swift index 6da23719..bd25763d 100644 --- a/MC1/Views/Chats/Sheets/BlockSenderSheet.swift +++ b/MC1/Views/Chats/Sheets/BlockSenderSheet.swift @@ -1,6 +1,6 @@ import CoreLocation -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "BlockSenderSheet") @@ -8,105 +8,105 @@ private let logger = Logger(subsystem: "com.mc1", category: "BlockSenderSheet") /// Confirmation sheet for blocking a channel sender name. /// Shows name-based limitation warning and any matching contacts the user can optionally block. struct BlockSenderSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss - let senderName: String - let radioID: UUID - let onBlock: (_ blockedContactIDs: Set) -> Void + let senderName: String + let radioID: UUID + let onBlock: (_ blockedContactIDs: Set) -> Void - @State private var matchingContacts: [ContactDTO] = [] - @State private var selectedContactIDs: Set = [] + @State private var matchingContacts: [ContactDTO] = [] + @State private var selectedContactIDs: Set = [] - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text(L10n.Chats.Chats.BlockSender.limitation) - .font(.subheadline) - .foregroundStyle(.secondary) + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(L10n.Chats.Chats.BlockSender.limitation) + .font(.subheadline) + .foregroundStyle(.secondary) - if !matchingContacts.isEmpty { - ContactMatchSection( - contacts: matchingContacts, - selectedIDs: $selectedContactIDs, - userLocation: appState.bestAvailableLocation - ) - } - } - .padding() - } - .navigationTitle(L10n.Chats.Chats.BlockSender.title(senderName)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.BlockSender.cancel) { - dismiss() - } - } - ToolbarItem(placement: .destructiveAction) { - Button(L10n.Chats.Chats.BlockSender.blockAnyway, role: .destructive) { - onBlock(selectedContactIDs) - dismiss() - } - } - } - .task { - await loadMatchingContacts() - } + if !matchingContacts.isEmpty { + ContactMatchSection( + contacts: matchingContacts, + selectedIDs: $selectedContactIDs, + userLocation: appState.bestAvailableLocation + ) + } + } + .padding() + } + .navigationTitle(L10n.Chats.Chats.BlockSender.title(senderName)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.BlockSender.cancel) { + dismiss() + } } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .presentationBackground(.background) + ToolbarItem(placement: .destructiveAction) { + Button(L10n.Chats.Chats.BlockSender.blockAnyway, role: .destructive) { + onBlock(selectedContactIDs) + dismiss() + } + } + } + .task { + await loadMatchingContacts() + } } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(.background) + } - private func loadMatchingContacts() async { - guard let store = appState.offlineDataStore else { - logger.warning("No data store available for contact matching") - return - } + private func loadMatchingContacts() async { + guard let store = appState.offlineDataStore else { + logger.warning("No data store available for contact matching") + return + } - do { - let allContacts = try await store.fetchContacts(radioID: radioID) - matchingContacts = SenderContactMatcher.filter( - contacts: allContacts, - senderName: senderName, - excludeBlocked: true - ) - logger.info("Found \(matchingContacts.count) matching contacts for sender '\(senderName)'") - } catch { - logger.error("Failed to fetch contacts for matching: \(error)") - } + do { + let allContacts = try await store.fetchContacts(radioID: radioID) + matchingContacts = SenderContactMatcher.filter( + contacts: allContacts, + senderName: senderName, + excludeBlocked: true + ) + logger.info("Found \(matchingContacts.count) matching contacts for sender '\(senderName)'") + } catch { + logger.error("Failed to fetch contacts for matching: \(error)") } + } } // MARK: - Contact Match Section private struct ContactMatchSection: View { - let contacts: [ContactDTO] - @Binding var selectedIDs: Set - let userLocation: CLLocation? + let contacts: [ContactDTO] + @Binding var selectedIDs: Set + let userLocation: CLLocation? - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.Chats.Chats.BlockSender.matchingContacts) - .font(.subheadline) - .foregroundStyle(.secondary) + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.Chats.Chats.BlockSender.matchingContacts) + .font(.subheadline) + .foregroundStyle(.secondary) - ForEach(contacts) { contact in - ContactMatchRow( - contact: contact, - style: .toggle(isSelected: selectedIDs.contains(contact.id)), - userLocation: userLocation, - action: { - if selectedIDs.contains(contact.id) { - selectedIDs.remove(contact.id) - } else { - selectedIDs.insert(contact.id) - } - } - ) + ForEach(contacts) { contact in + ContactMatchRow( + contact: contact, + style: .toggle(isSelected: selectedIDs.contains(contact.id)), + userLocation: userLocation, + action: { + if selectedIDs.contains(contact.id) { + selectedIDs.remove(contact.id) + } else { + selectedIDs.insert(contact.id) } - } + } + ) + } } + } } diff --git a/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift b/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift index e92bc6ad..91eefd22 100644 --- a/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift +++ b/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift @@ -1,704 +1,704 @@ -import SwiftUI -import MC1Services import CoreImage.CIFilterBuiltins +import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "ChannelInfoSheet") /// Sheet displaying channel info with sharing and deletion options struct ChannelInfoSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.chatViewModel) private var viewModel - @Environment(\.appTheme) private var theme - - let channel: ChannelDTO - let onClearMessages: () -> Void - let onDelete: () -> Void - - @State private var notificationLevel: NotificationLevel - @State private var isFavorite: Bool - @State private var isDeleting = false - @State private var isClearingMessages = false - @State private var showingDeleteConfirmation = false - @State private var showingClearMessagesConfirmation = false - @State private var errorMessage: String? - @State private var copyHapticTrigger = 0 - @State private var notificationTask: Task? - @State private var favoriteTask: Task? - @State private var isRegionExpanded = false - @State private var isDiscoveringRegions = false - @State private var discoveryMessage: String? - @State private var showingRegionManagement = false - @State private var discoveryTask: Task? - @State private var discoveredNewRegions: [String] = [] - @State private var showingDiscoveryResults = false - @State private var selectedFloodScope: ChannelFloodScope - - init(channel: ChannelDTO, onClearMessages: @escaping () -> Void, onDelete: @escaping () -> Void) { - self.channel = channel - self.onClearMessages = onClearMessages - self.onDelete = onDelete - self._notificationLevel = State(initialValue: channel.notificationLevel) - self._isFavorite = State(initialValue: channel.isFavorite) - self._selectedFloodScope = State(initialValue: channel.floodScope) - } + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.chatViewModel) private var viewModel + @Environment(\.appTheme) private var theme + + let channel: ChannelDTO + let onClearMessages: () -> Void + let onDelete: () -> Void + + @State private var notificationLevel: NotificationLevel + @State private var isFavorite: Bool + @State private var isDeleting = false + @State private var isClearingMessages = false + @State private var showingDeleteConfirmation = false + @State private var showingClearMessagesConfirmation = false + @State private var errorMessage: String? + @State private var copyHapticTrigger = 0 + @State private var notificationTask: Task? + @State private var favoriteTask: Task? + @State private var isRegionExpanded = false + @State private var isDiscoveringRegions = false + @State private var discoveryMessage: String? + @State private var showingRegionManagement = false + @State private var discoveryTask: Task? + @State private var discoveredNewRegions: [String] = [] + @State private var showingDiscoveryResults = false + @State private var selectedFloodScope: ChannelFloodScope + + init(channel: ChannelDTO, onClearMessages: @escaping () -> Void, onDelete: @escaping () -> Void) { + self.channel = channel + self.onClearMessages = onClearMessages + self.onDelete = onDelete + _notificationLevel = State(initialValue: channel.notificationLevel) + _isFavorite = State(initialValue: channel.isFavorite) + _selectedFloodScope = State(initialValue: channel.floodScope) + } + + var body: some View { + NavigationStack { + Form { + // Channel Header Section + ChannelInfoHeaderSection(channel: channel) + + // Quick Actions Section + ConversationQuickActionsSection( + notificationLevel: $notificationLevel, + isFavorite: $isFavorite, + availableLevels: NotificationLevel.channelLevels + ) + .onChange(of: notificationLevel) { _, newValue in + notificationTask?.cancel() + notificationTask = Task { + await viewModel?.setNotificationLevel(.channel(channel), level: newValue) + } + } + .onChange(of: isFavorite) { _, newValue in + favoriteTask?.cancel() + favoriteTask = Task { + await viewModel?.setFavorite(.channel(channel), isFavorite: newValue) + } + } + .onDisappear { + notificationTask?.cancel() + favoriteTask?.cancel() + discoveryTask?.cancel() + } - var body: some View { - NavigationStack { - Form { - // Channel Header Section - ChannelInfoHeaderSection(channel: channel) - - // Quick Actions Section - ConversationQuickActionsSection( - notificationLevel: $notificationLevel, - isFavorite: $isFavorite, - availableLevels: NotificationLevel.channelLevels - ) - .onChange(of: notificationLevel) { _, newValue in - notificationTask?.cancel() - notificationTask = Task { - await viewModel?.setNotificationLevel(.channel(channel), level: newValue) - } - } - .onChange(of: isFavorite) { _, newValue in - favoriteTask?.cancel() - favoriteTask = Task { - await viewModel?.setFavorite(.channel(channel), isFavorite: newValue) - } - } - .onDisappear { - notificationTask?.cancel() - favoriteTask?.cancel() - discoveryTask?.cancel() - } - - // Region Scope Section - ChannelInfoRegionSection( - knownRegions: knownRegions, - selectedFloodScope: selectedFloodScope, - deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName, - isExpanded: $isRegionExpanded, - onFloodScopeSelected: { scope in - Task { await selectFloodScope(scope) } - }, - onManageTapped: { - showingRegionManagement = true - } - ) - - // QR Code Section (only for private channels with secrets) - if channel.hasSecret && !channel.isPublicChannel { - ChannelInfoQRCodeSection(channel: channel) - } - - // Secret Key Section (only for private channels) - if channel.hasSecret && !channel.isPublicChannel { - ChannelInfoSecretKeySection( - channel: channel, - copyHapticTrigger: $copyHapticTrigger - ) - } - - // Error Section - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - Button(L10n.Localizable.Common.tryAgain) { - Task { await deleteChannel() } - } - } - .themedRowBackground(theme) - } - - // Actions Section - ChannelInfoActionsSection( - isActionInProgress: isDeleting || isClearingMessages, - isClearingMessages: isClearingMessages, - isDeleting: isDeleting, - showingClearMessagesConfirmation: $showingClearMessagesConfirmation, - showingDeleteConfirmation: $showingDeleteConfirmation - ) - } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.ChannelInfo.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.done) { - dismiss() - } - } - } - .navigationDestination(isPresented: $showingRegionManagement) { - RegionManagementView( - knownRegions: knownRegions, - isDiscovering: $isDiscoveringRegions, - discoveryMessage: $discoveryMessage, - onRemoveRegion: { region in - appState.connectionManager.removeKnownRegion(region) - }, - onAddRegion: { region in - appState.connectionManager.addKnownRegion(region) - }, - onDiscoverTapped: { - runDiscovery { newRegions in - discoveredNewRegions = newRegions - showingDiscoveryResults = true - } - } - ) - } - .navigationDestination(isPresented: $showingDiscoveryResults) { - RegionDiscoveryResultsView(discoveredRegions: discoveredNewRegions) { selected in - for region in selected { - appState.connectionManager.addKnownRegion(region) - } - } - } + // Region Scope Section + ChannelInfoRegionSection( + knownRegions: knownRegions, + selectedFloodScope: selectedFloodScope, + deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName, + isExpanded: $isRegionExpanded, + onFloodScopeSelected: { scope in + Task { await selectFloodScope(scope) } + }, + onManageTapped: { + showingRegionManagement = true + } + ) + + // QR Code Section (only for private channels with secrets) + if channel.hasSecret, !channel.isPublicChannel { + ChannelInfoQRCodeSection(channel: channel) } - .confirmationDialog( - L10n.Chats.Chats.ChannelInfo.ClearMessagesConfirm.title, - isPresented: $showingClearMessagesConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Chats.Chats.ChannelInfo.clearMessagesButton, role: .destructive) { - Task { - await clearMessages() - } - } - Button(L10n.Chats.Chats.Common.cancel, role: .cancel) {} - } message: { - Text(L10n.Chats.Chats.ChannelInfo.ClearMessagesConfirm.message) + + // Secret Key Section (only for private channels) + if channel.hasSecret, !channel.isPublicChannel { + ChannelInfoSecretKeySection( + channel: channel, + copyHapticTrigger: $copyHapticTrigger + ) } - .confirmationDialog( - L10n.Chats.Chats.ChannelInfo.DeleteConfirm.title, - isPresented: $showingDeleteConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Chats.Chats.ChannelInfo.deleteButton, role: .destructive) { - Task { - await deleteChannel() - } + + // Error Section + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + Button(L10n.Localizable.Common.tryAgain) { + Task { await deleteChannel() } } - Button(L10n.Chats.Chats.Common.cancel, role: .cancel) {} - } message: { - Text(L10n.Chats.Chats.ChannelInfo.DeleteConfirm.message) + } + .themedRowBackground(theme) } - .sensoryFeedback(.success, trigger: copyHapticTrigger) - } - - // MARK: - Private Methods - private func clearNotificationsForChannel(radioID: UUID) async { - await appState.services?.notificationService.removeDeliveredNotifications( - forChannelIndex: channel.index, - radioID: radioID + // Actions Section + ChannelInfoActionsSection( + isActionInProgress: isDeleting || isClearingMessages, + isClearingMessages: isClearingMessages, + isDeleting: isDeleting, + showingClearMessagesConfirmation: $showingClearMessagesConfirmation, + showingDeleteConfirmation: $showingDeleteConfirmation ) - await appState.services?.notificationService.updateBadgeCount() + } + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.ChannelInfo.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.done) { + dismiss() + } + } + } + .navigationDestination(isPresented: $showingRegionManagement) { + RegionManagementView( + knownRegions: knownRegions, + isDiscovering: $isDiscoveringRegions, + discoveryMessage: $discoveryMessage, + onRemoveRegion: { region in + appState.connectionManager.removeKnownRegion(region) + }, + onAddRegion: { region in + appState.connectionManager.addKnownRegion(region) + }, + onDiscoverTapped: { + runDiscovery { newRegions in + discoveredNewRegions = newRegions + showingDiscoveryResults = true + } + } + ) + } + .navigationDestination(isPresented: $showingDiscoveryResults) { + RegionDiscoveryResultsView(discoveredRegions: discoveredNewRegions) { selected in + for region in selected { + appState.connectionManager.addKnownRegion(region) + } + } + } } - - private func deleteChannel() async { - guard let radioID = appState.connectedDevice?.radioID else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return + .confirmationDialog( + L10n.Chats.Chats.ChannelInfo.ClearMessagesConfirm.title, + isPresented: $showingClearMessagesConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Chats.Chats.ChannelInfo.clearMessagesButton, role: .destructive) { + Task { + await clearMessages() } - - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return + } + Button(L10n.Chats.Chats.Common.cancel, role: .cancel) {} + } message: { + Text(L10n.Chats.Chats.ChannelInfo.ClearMessagesConfirm.message) + } + .confirmationDialog( + L10n.Chats.Chats.ChannelInfo.DeleteConfirm.title, + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Chats.Chats.ChannelInfo.deleteButton, role: .destructive) { + Task { + await deleteChannel() } + } + Button(L10n.Chats.Chats.Common.cancel, role: .cancel) {} + } message: { + Text(L10n.Chats.Chats.ChannelInfo.DeleteConfirm.message) + } + .sensoryFeedback(.success, trigger: copyHapticTrigger) + } - isDeleting = true - errorMessage = nil + // MARK: - Private Methods - do { - // Clear channel on device (sends empty name + zero secret via BLE) - // and deletes from local database - try await channelService.clearChannel( - radioID: radioID, - index: channel.index - ) + private func clearNotificationsForChannel(radioID: UUID) async { + await appState.services?.notificationService.removeDeliveredNotifications( + forChannelIndex: channel.index, + radioID: radioID + ) + await appState.services?.notificationService.updateBadgeCount() + } - await clearNotificationsForChannel(radioID: radioID) + private func deleteChannel() async { + guard let radioID = appState.connectedDevice?.radioID else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return + } - dismiss() - onDelete() - } catch { - errorMessage = error.userFacingMessage - isDeleting = false - } + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return } - private func clearMessages() async { - guard let radioID = appState.connectedDevice?.radioID else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return - } + isDeleting = true + errorMessage = nil - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return - } + do { + // Clear channel on device (sends empty name + zero secret via BLE) + // and deletes from local database + try await channelService.clearChannel( + radioID: radioID, + index: channel.index + ) - isClearingMessages = true - errorMessage = nil + await clearNotificationsForChannel(radioID: radioID) - do { - try await channelService.clearChannelMessages( - radioID: radioID, - channelIndex: channel.index - ) + dismiss() + onDelete() + } catch { + errorMessage = error.userFacingMessage + isDeleting = false + } + } - await clearNotificationsForChannel(radioID: radioID) + private func clearMessages() async { + guard let radioID = appState.connectedDevice?.radioID else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return + } - onClearMessages() - dismiss() - } catch { - errorMessage = error.userFacingMessage - isClearingMessages = false - } + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return } - private func selectFloodScope(_ scope: ChannelFloodScope) async { - let previous = selectedFloodScope - selectedFloodScope = scope - do { - try await appState.offlineDataStore?.setChannelFloodScope(channel.id, floodScope: scope) - } catch { - logger.error("Failed to save flood scope: \(error.localizedDescription)") - selectedFloodScope = previous - return - } + isClearingMessages = true + errorMessage = nil - if let session = appState.services?.session { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: scope, - deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName, - supportsUnscopedFloodSend: appState.connectedDevice?.supportsUnscopedFloodSend ?? false - ) - switch resolved { - case .unscoped: - try? await session.setFloodScopeUnscoped() - case .scope(let scope): - try? await session.setFloodScope(scope) - } - } + do { + try await channelService.clearChannelMessages( + radioID: radioID, + channelIndex: channel.index + ) + + await clearNotificationsForChannel(radioID: radioID) + + onClearMessages() + dismiss() + } catch { + errorMessage = error.userFacingMessage + isClearingMessages = false + } + } + + private func selectFloodScope(_ scope: ChannelFloodScope) async { + let previous = selectedFloodScope + selectedFloodScope = scope + do { + try await appState.offlineDataStore?.setChannelFloodScope(channel.id, floodScope: scope) + } catch { + logger.error("Failed to save flood scope: \(error.localizedDescription)") + selectedFloodScope = previous + return } - private var knownRegions: [String] { - appState.connectedDevice?.knownRegions ?? [] + if let session = appState.services?.session { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: scope, + deviceDefaultFloodScopeName: appState.connectedDevice?.defaultFloodScopeName, + supportsUnscopedFloodSend: appState.connectedDevice?.supportsUnscopedFloodSend ?? false + ) + switch resolved { + case .unscoped: + try? await session.setFloodScopeUnscoped() + case let .scope(scope): + try? await session.setFloodScope(scope) + } } + } - private func runDiscovery(onNewRegions: @escaping ([String]) async -> Void) { - discoveryTask?.cancel() - discoveryTask = Task { - isDiscoveringRegions = true - discoveryMessage = nil + private var knownRegions: [String] { + appState.connectedDevice?.knownRegions ?? [] + } - let newRegions = await discoverNewRegions() + private func runDiscovery(onNewRegions: @escaping ([String]) async -> Void) { + discoveryTask?.cancel() + discoveryTask = Task { + isDiscoveringRegions = true + discoveryMessage = nil - guard !Task.isCancelled else { - isDiscoveringRegions = false - return - } + let newRegions = await discoverNewRegions() - if newRegions.isEmpty { - if discoveryMessage == nil { - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noNewRegions - } - } else { - await onNewRegions(newRegions) - } - isDiscoveringRegions = false + guard !Task.isCancelled else { + isDiscoveringRegions = false + return + } + + if newRegions.isEmpty { + if discoveryMessage == nil { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noNewRegions } + } else { + await onNewRegions(newRegions) + } + isDiscoveringRegions = false } + } - /// Broadcasts a discover probe to find nearby repeaters, then queries only those for regions - private func discoverNewRegions() async -> [String] { - guard let session = appState.services?.session, - let contactService = appState.services?.contactService else { - return [] - } + /// Broadcasts a discover probe to find nearby repeaters, then queries only those for regions + private func discoverNewRegions() async -> [String] { + guard let session = appState.services?.session, + let contactService = appState.services?.contactService else { + return [] + } - let outcome = await RegionDiscoveryService.discover( - session: session, - contactService: contactService, - dataStore: appState.offlineDataStore, - radioID: channel.radioID, - knownRegions: knownRegions, - supportsAdHocRequest: appState.connectedDevice?.supportsAdHocRepeaterRequest ?? false - ) + let outcome = await RegionDiscoveryService.discover( + session: session, + contactService: contactService, + dataStore: appState.offlineDataStore, + radioID: channel.radioID, + knownRegions: knownRegions, + supportsAdHocRequest: appState.connectedDevice?.supportsAdHocRepeaterRequest ?? false + ) - switch outcome { - case .sendFailed: - return [] - case .noRepeatersResponded: - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noRepeatersResponded - return [] - case .errorLoadingRepeaters: - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errLoadingRepeaters - return [] - case let .completed(newRegions, allRepeatersTableFull): - if newRegions.isEmpty && allRepeatersTableFull { - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errRadioContactsFull - } - return newRegions - } + switch outcome { + case .sendFailed: + return [] + case .noRepeatersResponded: + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noRepeatersResponded + return [] + case .errorLoadingRepeaters: + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errLoadingRepeaters + return [] + case let .completed(newRegions, allRepeatersTableFull): + if newRegions.isEmpty, allRepeatersTableFull { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errRadioContactsFull + } + return newRegions } + } } // MARK: - Extracted Views private struct ChannelInfoHeaderSection: View { - let channel: ChannelDTO - - private var channelTypeLabel: String { - if channel.isPublicChannel { - return L10n.Chats.Chats.ChannelInfo.ChannelType.`public` - } else if channel.name.hasPrefix("#") { - return L10n.Chats.Chats.ChannelInfo.ChannelType.hashtag - } else { - return L10n.Chats.Chats.ChannelInfo.ChannelType.`private` - } + let channel: ChannelDTO + + private var channelTypeLabel: String { + if channel.isPublicChannel { + L10n.Chats.Chats.ChannelInfo.ChannelType.public + } else if channel.name.hasPrefix("#") { + L10n.Chats.Chats.ChannelInfo.ChannelType.hashtag + } else { + L10n.Chats.Chats.ChannelInfo.ChannelType.private } + } - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 12) { - ChannelAvatar(channel: channel, size: 80) - - Text(channel.displayName) - .font(.title2) - .bold() - - Text(channelTypeLabel) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - } - .listRowBackground(Color.clear) + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 12) { + ChannelAvatar(channel: channel, size: 80) + + Text(channel.displayName) + .font(.title2) + .bold() + + Text(channelTypeLabel) + .font(.subheadline) + .foregroundStyle(.secondary) } + Spacer() + } + .listRowBackground(Color.clear) } + } } private struct ChannelInfoQRCodeSection: View { - @Environment(\.appTheme) private var theme - let channel: ChannelDTO - - @State private var qrImage: UIImage? - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 12) { - if let qrImage { - Image(uiImage: qrImage) - .interpolation(.none) - .resizable() - .scaledToFit() - .frame(width: 180, height: 180) - } - - Text(L10n.Chats.Chats.ChannelInfo.scanToJoin) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - } - } header: { - Text(L10n.Chats.Chats.ChannelInfo.shareChannel) - } - .themedRowBackground(theme) - .task { - qrImage = generateQRCode() + @Environment(\.appTheme) private var theme + let channel: ChannelDTO + + @State private var qrImage: UIImage? + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 12) { + if let qrImage { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 180, height: 180) + } + + Text(L10n.Chats.Chats.ChannelInfo.scanToJoin) + .font(.caption) + .foregroundStyle(.secondary) } + Spacer() + } + } header: { + Text(L10n.Chats.Chats.ChannelInfo.shareChannel) + } + .themedRowBackground(theme) + .task { + qrImage = generateQRCode() } + } - private func generateQRCode() -> UIImage? { - // Format: meshcore://channel/add?name=&secret= - let urlString = "meshcore://channel/add?name=\(channel.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")&secret=\(channel.secret.uppercaseHexString())" + private func generateQRCode() -> UIImage? { + // Format: meshcore://channel/add?name=&secret= + let urlString = "meshcore://channel/add?name=\(channel.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")&secret=\(channel.secret.uppercaseHexString())" - let context = CIContext() - let filter = CIFilter.qrCodeGenerator() - filter.message = Data(urlString.utf8) - filter.correctionLevel = "M" + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(urlString.utf8) + filter.correctionLevel = "M" - guard let outputImage = filter.outputImage else { return nil } + guard let outputImage = filter.outputImage else { return nil } - // Scale up for better quality - let scale = 10.0 - let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + // Scale up for better quality + let scale = 10.0 + let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) - guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } - return UIImage(cgImage: cgImage) - } + return UIImage(cgImage: cgImage) + } } private struct ChannelInfoSecretKeySection: View { - @Environment(\.appTheme) private var theme - let channel: ChannelDTO - @Binding var copyHapticTrigger: Int - - var body: some View { - Section { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Chats.Chats.ChannelInfo.secretKey) - .font(.caption) - .foregroundStyle(.secondary) - - HStack { - Text(channel.secret.uppercaseHexString()) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - - Spacer() - - Button(L10n.Chats.Chats.ChannelInfo.copy, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - UIPasteboard.general.string = channel.secret.uppercaseHexString() - } - .labelStyle(.iconOnly) - } - } - } header: { - Text(L10n.Chats.Chats.ChannelInfo.manualSharing) - } footer: { - Text(L10n.Chats.Chats.ChannelInfo.manualSharingFooter) + @Environment(\.appTheme) private var theme + let channel: ChannelDTO + @Binding var copyHapticTrigger: Int + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Chats.Chats.ChannelInfo.secretKey) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Text(channel.secret.uppercaseHexString()) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + + Spacer() + + Button(L10n.Chats.Chats.ChannelInfo.copy, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = channel.secret.uppercaseHexString() + } + .labelStyle(.iconOnly) } - .themedRowBackground(theme) + } + } header: { + Text(L10n.Chats.Chats.ChannelInfo.manualSharing) + } footer: { + Text(L10n.Chats.Chats.ChannelInfo.manualSharingFooter) } + .themedRowBackground(theme) + } } private struct ChannelInfoActionsSection: View { - @Environment(\.appTheme) private var theme - let isActionInProgress: Bool - let isClearingMessages: Bool - let isDeleting: Bool - @Binding var showingClearMessagesConfirmation: Bool - @Binding var showingDeleteConfirmation: Bool - - var body: some View { - Section { - Button { - showingClearMessagesConfirmation = true - } label: { - HStack { - Spacer() - if isClearingMessages { - ProgressView() - } else { - Label(L10n.Chats.Chats.ChannelInfo.clearMessagesButton, systemImage: "xmark.circle") - } - Spacer() - } - } - .disabled(isActionInProgress) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - - Button(role: .destructive) { - showingDeleteConfirmation = true - } label: { - HStack { - Spacer() - if isDeleting { - ProgressView() - } else { - Label(L10n.Chats.Chats.ChannelInfo.deleteButton, systemImage: "trash") - } - Spacer() - } - } - .disabled(isActionInProgress) - } footer: { - Text(L10n.Chats.Chats.ChannelInfo.deleteFooter) + @Environment(\.appTheme) private var theme + let isActionInProgress: Bool + let isClearingMessages: Bool + let isDeleting: Bool + @Binding var showingClearMessagesConfirmation: Bool + @Binding var showingDeleteConfirmation: Bool + + var body: some View { + Section { + Button { + showingClearMessagesConfirmation = true + } label: { + HStack { + Spacer() + if isClearingMessages { + ProgressView() + } else { + Label(L10n.Chats.Chats.ChannelInfo.clearMessagesButton, systemImage: "xmark.circle") + } + Spacer() + } + } + .disabled(isActionInProgress) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + + Button(role: .destructive) { + showingDeleteConfirmation = true + } label: { + HStack { + Spacer() + if isDeleting { + ProgressView() + } else { + Label(L10n.Chats.Chats.ChannelInfo.deleteButton, systemImage: "trash") + } + Spacer() } - .themedRowBackground(theme) + } + .disabled(isActionInProgress) + } footer: { + Text(L10n.Chats.Chats.ChannelInfo.deleteFooter) } + .themedRowBackground(theme) + } } private struct ChannelInfoRegionSection: View { - @Environment(\.appTheme) private var theme - let knownRegions: [String] - let selectedFloodScope: ChannelFloodScope - let deviceDefaultFloodScopeName: String? - @Binding var isExpanded: Bool - let onFloodScopeSelected: (ChannelFloodScope) -> Void - let onManageTapped: () -> Void - - private var sortedPartitioned: (public: [String], private: [String]) { - let sorted = knownRegions.sorted { $0.localizedStandardCompare($1) == .orderedAscending } - return (sorted.filter { !$0.isPrivateRegion }, sorted.filter { $0.isPrivateRegion }) + @Environment(\.appTheme) private var theme + let knownRegions: [String] + let selectedFloodScope: ChannelFloodScope + let deviceDefaultFloodScopeName: String? + @Binding var isExpanded: Bool + let onFloodScopeSelected: (ChannelFloodScope) -> Void + let onManageTapped: () -> Void + + private var sortedPartitioned: (public: [String], private: [String]) { + let sorted = knownRegions.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + return (sorted.filter { !$0.isPrivateRegion }, sorted.filter(\.isPrivateRegion)) + } + + private var regionValueLabel: String { + if knownRegions.isEmpty { + return L10n.Chats.Chats.ChannelInfo.Region.notConfigured } - - private var regionValueLabel: String { - if knownRegions.isEmpty { - return L10n.Chats.Chats.ChannelInfo.Region.notConfigured - } - switch selectedFloodScope { - case .inherit: - if let name = deviceDefaultFloodScopeName, !name.isEmpty { - return L10n.Chats.Chats.ChannelInfo.Region.useDefaultFormat(name) - } - return L10n.Chats.Chats.ChannelInfo.Region.allRegions - case .allRegions: - return L10n.Chats.Chats.ChannelInfo.Region.allRegions - case .region(let name): - return name - } + switch selectedFloodScope { + case .inherit: + if let name = deviceDefaultFloodScopeName, !name.isEmpty { + return L10n.Chats.Chats.ChannelInfo.Region.useDefaultFormat(name) + } + return L10n.Chats.Chats.ChannelInfo.Region.allRegions + case .allRegions: + return L10n.Chats.Chats.ChannelInfo.Region.allRegions + case let .region(name): + return name } + } - var body: some View { - Section { - DisclosureGroup(isExpanded: $isExpanded) { - if knownRegions.isEmpty { - ChannelInfoRegionEmptyContent(onManageTapped: onManageTapped) - } else { - ChannelInfoRegionPickerContent( - selectedFloodScope: selectedFloodScope, - deviceDefaultFloodScopeName: deviceDefaultFloodScopeName, - publicRegions: sortedPartitioned.public, - privateRegions: sortedPartitioned.private, - onFloodScopeSelected: onFloodScopeSelected, - onManageTapped: onManageTapped - ) - } - } label: { - ChannelInfoRegionLabel(regionValueLabel: regionValueLabel) - } + var body: some View { + Section { + DisclosureGroup(isExpanded: $isExpanded) { + if knownRegions.isEmpty { + ChannelInfoRegionEmptyContent(onManageTapped: onManageTapped) + } else { + ChannelInfoRegionPickerContent( + selectedFloodScope: selectedFloodScope, + deviceDefaultFloodScopeName: deviceDefaultFloodScopeName, + publicRegions: sortedPartitioned.public, + privateRegions: sortedPartitioned.private, + onFloodScopeSelected: onFloodScopeSelected, + onManageTapped: onManageTapped + ) } - .themedRowBackground(theme) + } label: { + ChannelInfoRegionLabel(regionValueLabel: regionValueLabel) + } } + .themedRowBackground(theme) + } } private struct ChannelInfoRegionLabel: View { - let regionValueLabel: String - - var body: some View { - HStack { - Label(L10n.Chats.Chats.ChannelInfo.region, systemImage: "globe") - Spacer() - Text(regionValueLabel) - .foregroundStyle(.secondary) - } + let regionValueLabel: String + + var body: some View { + HStack { + Label(L10n.Chats.Chats.ChannelInfo.region, systemImage: "globe") + Spacer() + Text(regionValueLabel) + .foregroundStyle(.secondary) } + } } private struct ChannelInfoRegionActions: View { - let onManageTapped: () -> Void + let onManageTapped: () -> Void - var body: some View { - Button(L10n.Chats.Chats.ChannelInfo.Region.manageRegions, systemImage: "list.bullet") { - onManageTapped() - } + var body: some View { + Button(L10n.Chats.Chats.ChannelInfo.Region.manageRegions, systemImage: "list.bullet") { + onManageTapped() } + } } private struct ChannelInfoRegionEmptyContent: View { - let onManageTapped: () -> Void + let onManageTapped: () -> Void - var body: some View { - Text(L10n.Chats.Chats.ChannelInfo.Region.explanation) - .font(.subheadline) - .foregroundStyle(.secondary) + var body: some View { + Text(L10n.Chats.Chats.ChannelInfo.Region.explanation) + .font(.subheadline) + .foregroundStyle(.secondary) - ChannelInfoRegionActions(onManageTapped: onManageTapped) - } + ChannelInfoRegionActions(onManageTapped: onManageTapped) + } } private struct ChannelInfoRegionPickerContent: View { - let selectedFloodScope: ChannelFloodScope - let deviceDefaultFloodScopeName: String? - let publicRegions: [String] - let privateRegions: [String] - let onFloodScopeSelected: (ChannelFloodScope) -> Void - let onManageTapped: () -> Void - - private var effectiveDefault: String? { - guard let name = deviceDefaultFloodScopeName, !name.isEmpty else { return nil } - return name + let selectedFloodScope: ChannelFloodScope + let deviceDefaultFloodScopeName: String? + let publicRegions: [String] + let privateRegions: [String] + let onFloodScopeSelected: (ChannelFloodScope) -> Void + let onManageTapped: () -> Void + + private var effectiveDefault: String? { + guard let name = deviceDefaultFloodScopeName, !name.isEmpty else { return nil } + return name + } + + var body: some View { + if let defaultName = effectiveDefault { + pickerRow( + title: L10n.Chats.Chats.ChannelInfo.Region.useDefaultFormat(defaultName), + isSelected: selectedFloodScope == .inherit, + scope: .inherit + ) } - var body: some View { - if let defaultName = effectiveDefault { - pickerRow( - title: L10n.Chats.Chats.ChannelInfo.Region.useDefaultFormat(defaultName), - isSelected: selectedFloodScope == .inherit, - scope: .inherit - ) - } - - pickerRow( - title: L10n.Chats.Chats.ChannelInfo.Region.allRegions, - isSelected: selectedFloodScope == allRegionsSelection, - scope: allRegionsSelection - ) - - ForEach(publicRegions, id: \.self) { region in - pickerRow( - title: region, - isSelected: selectedFloodScope == .region(region), - scope: .region(region) - ) - } - - // Private regions (shown disabled — can't scope to these) - ForEach(privateRegions, id: \.self) { region in - HStack { - Text(region) - Spacer() - Text(L10n.Chats.Chats.ChannelInfo.Region.`private`) - .font(.caption) - .foregroundStyle(.tertiary) - } - .foregroundStyle(.secondary) - } + pickerRow( + title: L10n.Chats.Chats.ChannelInfo.Region.allRegions, + isSelected: selectedFloodScope == allRegionsSelection, + scope: allRegionsSelection + ) - ChannelInfoRegionActions(onManageTapped: onManageTapped) + ForEach(publicRegions, id: \.self) { region in + pickerRow( + title: region, + isSelected: selectedFloodScope == .region(region), + scope: .region(region) + ) } - /// When no device default is set, `.inherit` and `.allRegions` collapse into a - /// single row — writing `.inherit` is the conservative choice so a later default - /// change flows through naturally. - private var allRegionsSelection: ChannelFloodScope { - effectiveDefault != nil ? .allRegions : .inherit + // Private regions (shown disabled — can't scope to these) + ForEach(privateRegions, id: \.self) { region in + HStack { + Text(region) + Spacer() + Text(L10n.Chats.Chats.ChannelInfo.Region.private) + .font(.caption) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.secondary) } - private func pickerRow(title: String, isSelected: Bool, scope: ChannelFloodScope) -> some View { - Button { - onFloodScopeSelected(scope) - } label: { - HStack { - Text(title) - Spacer() - if isSelected { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - .contentShape(.rect) + ChannelInfoRegionActions(onManageTapped: onManageTapped) + } + + /// When no device default is set, `.inherit` and `.allRegions` collapse into a + /// single row — writing `.inherit` is the conservative choice so a later default + /// change flows through naturally. + private var allRegionsSelection: ChannelFloodScope { + effectiveDefault != nil ? .allRegions : .inherit + } + + private func pickerRow(title: String, isSelected: Bool, scope: ChannelFloodScope) -> some View { + Button { + onFloodScopeSelected(scope) + } label: { + HStack { + Text(title) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(.tint) } - .buttonStyle(.plain) + } + .contentShape(.rect) } + .buttonStyle(.plain) + } } #Preview { - ChannelInfoSheet( - channel: ChannelDTO(from: Channel( - radioID: UUID(), - index: 1, - name: "General", - secret: Data(repeating: 0xAB, count: 16) - )), - onClearMessages: {}, - onDelete: {} - ) - .environment(\.appState, AppState()) + ChannelInfoSheet( + channel: ChannelDTO(from: Channel( + radioID: UUID(), + index: 1, + name: "General", + secret: Data(repeating: 0xAB, count: 16) + )), + onClearMessages: {}, + onDelete: {} + ) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Sheets/ChannelOptionsSheet.swift b/MC1/Views/Chats/Sheets/ChannelOptionsSheet.swift index eff83209..350bcc50 100644 --- a/MC1/Views/Chats/Sheets/ChannelOptionsSheet.swift +++ b/MC1/Views/Chats/Sheets/ChannelOptionsSheet.swift @@ -1,232 +1,234 @@ -import SwiftUI import MC1Services +import SwiftUI /// Sheet presenting channel creation and joining options struct ChannelOptionsSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - let onChannelCreated: ((ChannelDTO) -> Void)? - - @State private var selectedOption: ChannelOption? - @State private var availableSlots: [UInt8] = [] - @State private var hasPublicChannel = false - @State private var isLoading = true - - init(onChannelCreated: ((ChannelDTO) -> Void)? = nil) { - self.onChannelCreated = onChannelCreated - } - - enum ChannelOption: Identifiable { - case createPrivate - case joinPrivate - case joinPublic - case joinHashtag - case scanQR - - var id: Self { self } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + let onChannelCreated: ((ChannelDTO) -> Void)? + + @State private var selectedOption: ChannelOption? + @State private var availableSlots: [UInt8] = [] + @State private var hasPublicChannel = false + @State private var isLoading = true + + init(onChannelCreated: ((ChannelDTO) -> Void)? = nil) { + self.onChannelCreated = onChannelCreated + } + + enum ChannelOption: Identifiable { + case createPrivate + case joinPrivate + case joinPublic + case joinHashtag + case scanQR + + var id: Self { + self } - - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView(L10n.Chats.Chats.ChannelOptions.loading) - } else { - optionsList - } - } - .navigationTitle(L10n.Chats.Chats.ChannelOptions.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.cancel) { - dismiss() - } - } - } - .task { - await loadChannelState() - } - .navigationDestination(item: $selectedOption) { option in - switch option { - case .createPrivate: - CreatePrivateChannelView(availableSlots: availableSlots) { channel in - if let channel { onChannelCreated?(channel) } - dismiss() - } - case .joinPrivate: - JoinPrivateChannelView(availableSlots: availableSlots) { channel in - if let channel { onChannelCreated?(channel) } - dismiss() - } - case .joinPublic: - JoinPublicChannelView { channel in - if let channel { onChannelCreated?(channel) } - dismiss() - } - case .joinHashtag: - JoinHashtagChannelView(availableSlots: availableSlots) { channel in - if let channel { onChannelCreated?(channel) } - dismiss() - } - case .scanQR: - ScanChannelQRView(availableSlots: availableSlots) { channel in - if let channel { onChannelCreated?(channel) } - dismiss() - } - } - } + } + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView(L10n.Chats.Chats.ChannelOptions.loading) + } else { + optionsList } - } - - private var optionsList: some View { - List { - Section { - // Create Private Channel - Button { - selectedOption = .createPrivate - } label: { - ChannelOptionRow( - title: L10n.Chats.Chats.ChannelOptions.CreatePrivate.title, - description: L10n.Chats.Chats.ChannelOptions.CreatePrivate.description, - icon: "lock.fill", - iconColor: .blue - ) - } - .buttonStyle(.plain) - .disabled(availableSlots.isEmpty) - - // Join Private Channel - Button { - selectedOption = .joinPrivate - } label: { - ChannelOptionRow( - title: L10n.Chats.Chats.ChannelOptions.JoinPrivate.title, - description: L10n.Chats.Chats.ChannelOptions.JoinPrivate.description, - icon: "key.fill", - iconColor: .orange - ) - } - .buttonStyle(.plain) - .disabled(availableSlots.isEmpty) - - // Scan QR Code - Button { - selectedOption = .scanQR - } label: { - ChannelOptionRow( - title: L10n.Chats.Chats.ChannelOptions.ScanQR.title, - description: L10n.Chats.Chats.ChannelOptions.ScanQR.description, - icon: "qrcode.viewfinder", - iconColor: .purple - ) - } - .buttonStyle(.plain) - .disabled(availableSlots.isEmpty) - } header: { - Text(L10n.Chats.Chats.ChannelOptions.Section.`private`) - } - .themedRowBackground(theme) - - Section { - // Join Public Channel - Button { - selectedOption = .joinPublic - } label: { - ChannelOptionRow( - title: L10n.Chats.Chats.ChannelOptions.JoinPublic.title, - description: L10n.Chats.Chats.ChannelOptions.JoinPublic.description, - icon: "globe", - iconColor: .green - ) - } - .buttonStyle(.plain) - .disabled(hasPublicChannel) - - // Join Hashtag Channel - Button { - selectedOption = .joinHashtag - } label: { - ChannelOptionRow( - title: L10n.Chats.Chats.ChannelOptions.JoinHashtag.title, - description: L10n.Chats.Chats.ChannelOptions.JoinHashtag.description, - icon: "number", - iconColor: .cyan - ) - } - .buttonStyle(.plain) - .disabled(availableSlots.isEmpty) - } header: { - Text(L10n.Chats.Chats.ChannelOptions.Section.public) - } footer: { - if availableSlots.isEmpty { - Text(L10n.Chats.Chats.ChannelOptions.Footer.noSlots) - } else if hasPublicChannel { - Text(L10n.Chats.Chats.ChannelOptions.Footer.hasPublic) - } - } - .themedRowBackground(theme) + } + .navigationTitle(L10n.Chats.Chats.ChannelOptions.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.cancel) { + dismiss() + } + } + } + .task { + await loadChannelState() + } + .navigationDestination(item: $selectedOption) { option in + switch option { + case .createPrivate: + CreatePrivateChannelView(availableSlots: availableSlots) { channel in + if let channel { onChannelCreated?(channel) } + dismiss() + } + case .joinPrivate: + JoinPrivateChannelView(availableSlots: availableSlots) { channel in + if let channel { onChannelCreated?(channel) } + dismiss() + } + case .joinPublic: + JoinPublicChannelView { channel in + if let channel { onChannelCreated?(channel) } + dismiss() + } + case .joinHashtag: + JoinHashtagChannelView(availableSlots: availableSlots) { channel in + if let channel { onChannelCreated?(channel) } + dismiss() + } + case .scanQR: + ScanChannelQRView(availableSlots: availableSlots) { channel in + if let channel { onChannelCreated?(channel) } + dismiss() + } } - .themedCanvas(theme) + } } - - private func loadChannelState() async { - guard let radioID = appState.connectedDevice?.radioID else { - isLoading = false - return + } + + private var optionsList: some View { + List { + Section { + // Create Private Channel + Button { + selectedOption = .createPrivate + } label: { + ChannelOptionRow( + title: L10n.Chats.Chats.ChannelOptions.CreatePrivate.title, + description: L10n.Chats.Chats.ChannelOptions.CreatePrivate.description, + icon: "lock.fill", + iconColor: .blue + ) } - - do { - let existingChannels = try await appState.services?.dataStore.fetchChannels(radioID: radioID) ?? [] - let usedSlots = Set(existingChannels.map(\.index)) - - // Check if public channel exists - hasPublicChannel = usedSlots.contains(0) - - // Slots 1 through (maxChannels-1) are available for user channels - // Slot 0 is reserved for public channel - let maxChannels = appState.connectedDevice?.maxChannels ?? 0 - if maxChannels > 1 { - availableSlots = (1.. 1 { + availableSlots = (1.. Void - let onClearDirectMessages: () async -> Void - let onDeleteChannel: () -> Void + let conversationType: ChatConversationType + let chatViewModel: ChatViewModel + let onClearChannelMessages: () async -> Void + let onClearDirectMessages: () async -> Void + let onDeleteChannel: () -> Void - var body: some View { - switch conversationType { - case .dm(let contact): - NavigationStack { - ContactDetailView( - contact: contact, - showFromDirectChat: true, - onClearMessages: { Task { await onClearDirectMessages() } } - ) - } + var body: some View { + switch conversationType { + case let .dm(contact): + NavigationStack { + ContactDetailView( + contact: contact, + showFromDirectChat: true, + onClearMessages: { Task { await onClearDirectMessages() } } + ) + } - case .channel(let channel): - ChannelInfoSheet( - channel: channel, - onClearMessages: { - Task { await onClearChannelMessages() } - }, - onDelete: { - onDeleteChannel() - } - ) - .environment(\.chatViewModel, chatViewModel) + case let .channel(channel): + ChannelInfoSheet( + channel: channel, + onClearMessages: { + Task { await onClearChannelMessages() } + }, + onDelete: { + onDeleteChannel() } + ) + .environment(\.chatViewModel, chatViewModel) } + } } diff --git a/MC1/Views/Chats/Sheets/ChatsConversationSheets.swift b/MC1/Views/Chats/Sheets/ChatsConversationSheets.swift index 78f3a490..0c38bba6 100644 --- a/MC1/Views/Chats/Sheets/ChatsConversationSheets.swift +++ b/MC1/Views/Chats/Sheets/ChatsConversationSheets.swift @@ -1,123 +1,123 @@ -import SwiftUI import MC1Services +import SwiftUI /// The deep-link sheets, conversation sheets, and destructive-action alerts shared by the compact /// `ChatsView` (stack) and the iPad `ChatsContentColumn` (split). Both attach an identical surface; /// only the navigation glue (`navigate` and the delete handlers) differs between /// the stack and split paths, so those are injected as closures. struct ChatsConversationSheets: ViewModifier { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let viewModel: ChatViewModel + let viewModel: ChatViewModel - @Binding var showingNewChat: Bool - @Binding var showingChannelOptions: Bool - @Binding var roomToAuthenticate: RemoteNodeSessionDTO? - @Binding var roomToDelete: RemoteNodeSessionDTO? - @Binding var showRoomDeleteAlert: Bool - @Binding var channelDeleteFailure: ChatConversationActions.Failure? - @Binding var showChannelDeleteFailed: Bool - @Binding var pendingChatContact: ContactDTO? - @Binding var pendingChannel: ChannelDTO? + @Binding var showingNewChat: Bool + @Binding var showingChannelOptions: Bool + @Binding var roomToAuthenticate: RemoteNodeSessionDTO? + @Binding var roomToDelete: RemoteNodeSessionDTO? + @Binding var showRoomDeleteAlert: Bool + @Binding var channelDeleteFailure: ChatConversationActions.Failure? + @Binding var showChannelDeleteFailed: Bool + @Binding var pendingChatContact: ContactDTO? + @Binding var pendingChannel: ChannelDTO? - let navigate: (ChatRoute) -> Void - let deleteChannelConversation: (ChannelDTO) -> Void - let deleteRoom: (RemoteNodeSessionDTO) async -> Void + let navigate: (ChatRoute) -> Void + let deleteChannelConversation: (ChannelDTO) -> Void + let deleteRoom: (RemoteNodeSessionDTO) async -> Void - func body(content: Content) -> some View { - content - .environment(\.openURL, OpenURLAction { url in - ChatLinkRouter.route(url, appState: appState) ? .handled : .systemAction - }) - .sheet(item: Binding( - get: { appState.navigation.pendingHashtag }, - set: { appState.navigation.pendingHashtag = $0 } - )) { request in - JoinHashtagFromMessageView(channelName: request.id) { channel in - appState.navigation.clearPendingHashtag() - if let channel { - navigate(.channel(channel)) - } - } - .presentationDetents([.medium]) - } - .sheet(item: Binding( - get: { appState.navigation.pendingContactLink }, - set: { appState.navigation.pendingContactLink = $0 } - )) { result in - AddContactConfirmationSheet(contactResult: result) { addedContact in - appState.navigation.clearPendingContactLink() - if let addedContact { - appState.navigation.navigateToContactDetail(addedContact) - } - } - .presentationDetents([.medium, .large]) - } - .sheet(item: Binding( - get: { appState.navigation.pendingChannelLink }, - set: { appState.navigation.pendingChannelLink = $0 } - )) { result in - JoinChannelConfirmationSheet(channelResult: result) { newChannel in - appState.navigation.clearPendingChannelLink() - if let newChannel { - navigate(.channel(newChannel)) - } - } - .presentationDetents([.medium, .large]) - } - .sheet(isPresented: $showingNewChat, onDismiss: { - if let contact = pendingChatContact { - pendingChatContact = nil - navigate(.direct(contact)) - } - }) { - NewChatView { contact in - pendingChatContact = contact - showingNewChat = false - } - } - .sheet(isPresented: $showingChannelOptions, onDismiss: { - viewModel.requestConversationReload() - if let channel = pendingChannel { - pendingChannel = nil - navigate(.channel(channel)) - } - }) { - ChannelOptionsSheet { channel in - pendingChannel = channel - } - } - .sheet(item: $roomToAuthenticate) { session in - RoomAuthenticationSheet(session: session) { authenticatedSession in - roomToAuthenticate = nil - navigate(.room(authenticatedSession)) - } - .presentationSizing(.page) - } - .alert(L10n.Chats.Chats.Alert.LeaveRoom.title, isPresented: $showRoomDeleteAlert) { - Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { - roomToDelete = nil - } - Button(L10n.Chats.Chats.Alert.LeaveRoom.confirm, role: .destructive) { - Task { - if let session = roomToDelete { await deleteRoom(session) } - roomToDelete = nil - } - } - } message: { - Text(L10n.Chats.Chats.Alert.LeaveRoom.message) - } - .alert( - L10n.Chats.Chats.ChannelInfo.DeleteFailed.title, - isPresented: $showChannelDeleteFailed, - presenting: channelDeleteFailure - ) { failure in - Button(L10n.Localizable.Common.tryAgain) { - deleteChannelConversation(failure.channel) - } - Button(L10n.Chats.Chats.Common.ok, role: .cancel) { } - } message: { failure in - Text(failure.message) - } - } + func body(content: Content) -> some View { + content + .environment(\.openURL, OpenURLAction { url in + ChatLinkRouter.route(url, appState: appState) ? .handled : .systemAction + }) + .sheet(item: Binding( + get: { appState.navigation.pendingHashtag }, + set: { appState.navigation.pendingHashtag = $0 } + )) { request in + JoinHashtagFromMessageView(channelName: request.id) { channel in + appState.navigation.clearPendingHashtag() + if let channel { + navigate(.channel(channel)) + } + } + .presentationDetents([.medium]) + } + .sheet(item: Binding( + get: { appState.navigation.pendingContactLink }, + set: { appState.navigation.pendingContactLink = $0 } + )) { result in + AddContactConfirmationSheet(contactResult: result) { addedContact in + appState.navigation.clearPendingContactLink() + if let addedContact { + appState.navigation.navigateToContactDetail(addedContact) + } + } + .presentationDetents([.medium, .large]) + } + .sheet(item: Binding( + get: { appState.navigation.pendingChannelLink }, + set: { appState.navigation.pendingChannelLink = $0 } + )) { result in + JoinChannelConfirmationSheet(channelResult: result) { newChannel in + appState.navigation.clearPendingChannelLink() + if let newChannel { + navigate(.channel(newChannel)) + } + } + .presentationDetents([.medium, .large]) + } + .sheet(isPresented: $showingNewChat, onDismiss: { + if let contact = pendingChatContact { + pendingChatContact = nil + navigate(.direct(contact)) + } + }) { + NewChatView { contact in + pendingChatContact = contact + showingNewChat = false + } + } + .sheet(isPresented: $showingChannelOptions, onDismiss: { + viewModel.requestConversationReload() + if let channel = pendingChannel { + pendingChannel = nil + navigate(.channel(channel)) + } + }) { + ChannelOptionsSheet { channel in + pendingChannel = channel + } + } + .sheet(item: $roomToAuthenticate) { session in + RoomAuthenticationSheet(session: session) { authenticatedSession in + roomToAuthenticate = nil + navigate(.room(authenticatedSession)) + } + .presentationSizing(.page) + } + .alert(L10n.Chats.Chats.Alert.LeaveRoom.title, isPresented: $showRoomDeleteAlert) { + Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { + roomToDelete = nil + } + Button(L10n.Chats.Chats.Alert.LeaveRoom.confirm, role: .destructive) { + Task { + if let session = roomToDelete { await deleteRoom(session) } + roomToDelete = nil + } + } + } message: { + Text(L10n.Chats.Chats.Alert.LeaveRoom.message) + } + .alert( + L10n.Chats.Chats.ChannelInfo.DeleteFailed.title, + isPresented: $showChannelDeleteFailed, + presenting: channelDeleteFailure + ) { failure in + Button(L10n.Localizable.Common.tryAgain) { + deleteChannelConversation(failure.channel) + } + Button(L10n.Chats.Chats.Common.ok, role: .cancel) {} + } message: { failure in + Text(failure.message) + } + } } diff --git a/MC1/Views/Chats/Sheets/JoinChannelConfirmationSheet.swift b/MC1/Views/Chats/Sheets/JoinChannelConfirmationSheet.swift index b89cd080..2acf950d 100644 --- a/MC1/Views/Chats/Sheets/JoinChannelConfirmationSheet.swift +++ b/MC1/Views/Chats/Sheets/JoinChannelConfirmationSheet.swift @@ -1,244 +1,244 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "JoinChannelConfirmationSheet") /// Confirmation sheet shown when tapping a meshcore://channel/add link in a chat message @MainActor struct JoinChannelConfirmationSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - - let channelResult: MeshCoreURLParser.ChannelResult - let onComplete: (ChannelDTO?) -> Void - - @State private var isJoining = false - @State private var isLoading = true - @State private var availableSlots: [UInt8] = [] - @State private var errorMessage: String? - @State private var successTrigger = 0 - - private var isMissingDevice: Bool { - appState.connectedDevice == nil - } - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - if isLoading { - ProgressView(L10n.Chats.Chats.JoinFromMessage.loading) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if isMissingDevice { - ChannelMissingDeviceContent( - channelName: channelResult.name, - onDismiss: { - onComplete(nil) - dismiss() - } - ) - } else if availableSlots.isEmpty { - ChannelNoSlotsContent( - channelName: channelResult.name, - onDismiss: { - onComplete(nil) - dismiss() - } - ) - } else { - ChannelJoinConfirmationContent( - channelResult: channelResult, - errorMessage: errorMessage, - isJoining: isJoining, - onJoin: { Task { await joinChannel() } } - ) - } - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + + let channelResult: MeshCoreURLParser.ChannelResult + let onComplete: (ChannelDTO?) -> Void + + @State private var isJoining = false + @State private var isLoading = true + @State private var availableSlots: [UInt8] = [] + @State private var errorMessage: String? + @State private var successTrigger = 0 + + private var isMissingDevice: Bool { + appState.connectedDevice == nil + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if isLoading { + ProgressView(L10n.Chats.Chats.JoinFromMessage.loading) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .navigationTitle(L10n.Chats.Chats.JoinFromMessage.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.cancel) { - onComplete(nil) - dismiss() - } - } + } else if isMissingDevice { + ChannelMissingDeviceContent( + channelName: channelResult.name, + onDismiss: { + onComplete(nil) + dismiss() } - .task { - await loadAvailableSlots() + ) + } else if availableSlots.isEmpty { + ChannelNoSlotsContent( + channelName: channelResult.name, + onDismiss: { + onComplete(nil) + dismiss() } - .sensoryFeedback(.success, trigger: successTrigger) - .sensoryFeedback(.error, trigger: errorMessage) + ) + } else { + ChannelJoinConfirmationContent( + channelResult: channelResult, + errorMessage: errorMessage, + isJoining: isJoining, + onJoin: { Task { await joinChannel() } } + ) } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .navigationTitle(L10n.Chats.Chats.JoinFromMessage.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.cancel) { + onComplete(nil) + dismiss() + } + } + } + .task { + await loadAvailableSlots() + } + .sensoryFeedback(.success, trigger: successTrigger) + .sensoryFeedback(.error, trigger: errorMessage) } + } - // MARK: - Private Methods + // MARK: - Private Methods - private func loadAvailableSlots() async { - guard let radioID = appState.connectedDevice?.radioID else { - isLoading = false - return - } + private func loadAvailableSlots() async { + guard let radioID = appState.connectedDevice?.radioID else { + isLoading = false + return + } - do { - let existingChannels = try await appState.services?.dataStore.fetchChannels(radioID: radioID) ?? [] - let usedSlots = Set(existingChannels.map(\.index)) + do { + let existingChannels = try await appState.services?.dataStore.fetchChannels(radioID: radioID) ?? [] + let usedSlots = Set(existingChannels.map(\.index)) - let maxChannels = appState.connectedDevice?.maxChannels ?? 0 - if maxChannels > 1 { - availableSlots = (1.. 1 { + availableSlots = (1.. Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Chats.Chats.JoinFromMessage.NoDevice.title, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Chats.Chats.JoinFromMessage.NoDevice.description(channelName)) - } actions: { - Button(L10n.Chats.Chats.Common.ok, action: onDismiss) - .liquidGlassProminentButtonStyle() - } + let channelName: String + let onDismiss: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.JoinFromMessage.NoDevice.title, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Chats.Chats.JoinFromMessage.NoDevice.description(channelName)) + } actions: { + Button(L10n.Chats.Chats.Common.ok, action: onDismiss) + .liquidGlassProminentButtonStyle() } + } } private struct ChannelNoSlotsContent: View { - let channelName: String - let onDismiss: () -> Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Chats.Chats.JoinFromMessage.NoSlots.title, systemImage: "number.circle.fill") - } description: { - Text(L10n.Chats.Chats.JoinFromMessage.NoSlots.description(channelName)) - } actions: { - Button(L10n.Chats.Chats.Common.ok, action: onDismiss) - .liquidGlassProminentButtonStyle() - } + let channelName: String + let onDismiss: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.JoinFromMessage.NoSlots.title, systemImage: "number.circle.fill") + } description: { + Text(L10n.Chats.Chats.JoinFromMessage.NoSlots.description(channelName)) + } actions: { + Button(L10n.Chats.Chats.Common.ok, action: onDismiss) + .liquidGlassProminentButtonStyle() } + } } private struct ChannelJoinConfirmationContent: View { - let channelResult: MeshCoreURLParser.ChannelResult - let errorMessage: String? - let isJoining: Bool - let onJoin: () -> Void - - private var truncatedSecret: String { - let hex = channelResult.secret.uppercaseHexString() - guard hex.count >= 16 else { return hex } - let start = hex.prefix(8) - let end = hex.suffix(8) - return "\(start)...\(end)" - } - - var body: some View { - VStack(spacing: 24) { - Spacer() - - VStack(spacing: 16) { - ZStack { - Circle() - .fill(.cyan) - .frame(width: 80, height: 80) - - Image(systemName: "number") - .font(.system(size: 36, weight: .bold)) - .foregroundStyle(.white) - } - - Text(channelResult.name) - .font(.title) - .bold() - - Text(truncatedSecret) - .font(.caption) - .monospaced() - .foregroundStyle(.tertiary) - } - - if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundStyle(.red) - .padding(.horizontal) - } + let channelResult: MeshCoreURLParser.ChannelResult + let errorMessage: String? + let isJoining: Bool + let onJoin: () -> Void + + private var truncatedSecret: String { + let hex = channelResult.secret.uppercaseHexString() + guard hex.count >= 16 else { return hex } + let start = hex.prefix(8) + let end = hex.suffix(8) + return "\(start)...\(end)" + } + + var body: some View { + VStack(spacing: 24) { + Spacer() + + VStack(spacing: 16) { + ZStack { + Circle() + .fill(.cyan) + .frame(width: 80, height: 80) + + Image(systemName: "number") + .font(.system(size: 36, weight: .bold)) + .foregroundStyle(.white) + } - Button(action: onJoin) { - if isJoining { - ProgressView() - } else { - Text(L10n.Chats.Chats.JoinPrivate.joinButton) - } - } - .liquidGlassProminentButtonStyle() - .disabled(isJoining) - .padding(.horizontal, 48) - .padding(.bottom, 32) + Text(channelResult.name) + .font(.title) + .bold() + + Text(truncatedSecret) + .font(.caption) + .monospaced() + .foregroundStyle(.tertiary) + } + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + .padding(.horizontal) + } + + Button(action: onJoin) { + if isJoining { + ProgressView() + } else { + Text(L10n.Chats.Chats.JoinPrivate.joinButton) } + } + .liquidGlassProminentButtonStyle() + .disabled(isJoining) + .padding(.horizontal, 48) + .padding(.bottom, 32) } + } } #Preview { - let result = MeshCoreURLParser.ChannelResult( - name: "EmergencyOps", - secret: Data(repeating: 0xBB, count: 16) - ) - JoinChannelConfirmationSheet(channelResult: result) { _ in } - .environment(\.appState, AppState()) - .presentationDetents([.medium, .large]) + let result = MeshCoreURLParser.ChannelResult( + name: "EmergencyOps", + secret: Data(repeating: 0xBB, count: 16) + ) + JoinChannelConfirmationSheet(channelResult: result) { _ in } + .environment(\.appState, AppState()) + .presentationDetents([.medium, .large]) } diff --git a/MC1/Views/Chats/Sheets/JoinHashtagChannelView.swift b/MC1/Views/Chats/Sheets/JoinHashtagChannelView.swift index 2017daa0..6b0b26a2 100644 --- a/MC1/Views/Chats/Sheets/JoinHashtagChannelView.swift +++ b/MC1/Views/Chats/Sheets/JoinHashtagChannelView.swift @@ -1,183 +1,183 @@ -import SwiftUI import MC1Services +import SwiftUI /// View for joining a hashtag channel (public, name-based) @MainActor struct JoinHashtagChannelView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - let availableSlots: [UInt8] - let onComplete: (ChannelDTO?) -> Void - - @State private var channelName = "" - @State private var selectedSlot: UInt8 - @State private var isJoining = false - @State private var errorMessage: String? - @State private var existingChannels: [ChannelDTO] = [] - - private var existingChannel: ChannelDTO? { - guard !channelName.isEmpty else { return nil } - let fullName = "#\(channelName)" - return existingChannels.first { - $0.name.localizedCaseInsensitiveCompare(fullName) == .orderedSame - } - } - - init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { - self.availableSlots = availableSlots - self.onComplete = onComplete - self._selectedSlot = State(initialValue: availableSlots.first ?? 1) - } - - private var isValidName: Bool { - HashtagUtilities.isValidHashtagName(channelName) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + let availableSlots: [UInt8] + let onComplete: (ChannelDTO?) -> Void + + @State private var channelName = "" + @State private var selectedSlot: UInt8 + @State private var isJoining = false + @State private var errorMessage: String? + @State private var existingChannels: [ChannelDTO] = [] + + private var existingChannel: ChannelDTO? { + guard !channelName.isEmpty else { return nil } + let fullName = "#\(channelName)" + return existingChannels.first { + $0.name.localizedCaseInsensitiveCompare(fullName) == .orderedSame } - - var body: some View { - Form { - Section { - HStack { - Text("#") - .font(.title2) - .foregroundStyle(.secondary) - - TextField(L10n.Chats.Chats.JoinHashtag.placeholder, text: $channelName) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .onChange(of: channelName) { _, newValue in - channelName = HashtagUtilities.sanitizeHashtagNameInput(newValue) - } - } - } header: { - Text(L10n.Chats.Chats.JoinHashtag.Section.header) - } footer: { - Text(L10n.Chats.Chats.JoinHashtag.footer) - } - .themedRowBackground(theme) - - Section { - HStack { - Spacer() - VStack(spacing: 8) { - Image(systemName: "number") - .font(.system(size: 40)) - .foregroundStyle(.cyan) - - if !channelName.isEmpty { - Text("#\(channelName)") - .font(.headline) - } - - Text(L10n.Chats.Chats.JoinHashtag.encryptionDescription) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding() - Spacer() - } - } - .themedRowBackground(theme) - - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - .themedRowBackground(theme) + } + + init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { + self.availableSlots = availableSlots + self.onComplete = onComplete + _selectedSlot = State(initialValue: availableSlots.first ?? 1) + } + + private var isValidName: Bool { + HashtagUtilities.isValidHashtagName(channelName) + } + + var body: some View { + Form { + Section { + HStack { + Text("#") + .font(.title2) + .foregroundStyle(.secondary) + + TextField(L10n.Chats.Chats.JoinHashtag.placeholder, text: $channelName) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onChange(of: channelName) { _, newValue in + channelName = HashtagUtilities.sanitizeHashtagNameInput(newValue) } - - Section { - Button { - Task { - guard !isJoining else { return } - if let existing = existingChannel { - onComplete(existing) - } else { - await joinChannel() - } - } - } label: { - HStack { - Spacer() - if isJoining { - ProgressView() - } else if existingChannel != nil { - Text(L10n.Chats.Chats.JoinHashtag.goToButton(channelName)) - } else { - Text(L10n.Chats.Chats.JoinHashtag.joinButton(channelName)) - } - Spacer() - } - } - .disabled(!isValidName || (isJoining && existingChannel == nil)) - .accessibilityHint(existingChannel != nil ? L10n.Chats.Chats.JoinHashtag.existingHint : L10n.Chats.Chats.JoinHashtag.newHint) - } footer: { - if existingChannel != nil { - Label(L10n.Chats.Chats.JoinHashtag.alreadyJoined, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .accessibilityLabel(L10n.Chats.Chats.JoinHashtag.alreadyJoinedAccessibility) - .frame(maxWidth: .infinity) - } - } - .themedRowBackground(theme) } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.JoinHashtag.title) - .navigationBarTitleDisplayMode(.inline) - .task { - guard let radioID = appState.connectedDevice?.radioID, - let dataStore = appState.services?.dataStore else { return } - do { - existingChannels = try await dataStore.fetchChannels(radioID: radioID) - } catch { - // Fail open - allow creation if fetch fails + } header: { + Text(L10n.Chats.Chats.JoinHashtag.Section.header) + } footer: { + Text(L10n.Chats.Chats.JoinHashtag.footer) + } + .themedRowBackground(theme) + + Section { + HStack { + Spacer() + VStack(spacing: 8) { + Image(systemName: "number") + .font(.system(size: 40)) + .foregroundStyle(.cyan) + + if !channelName.isEmpty { + Text("#\(channelName)") + .font(.headline) } - } - } - private func joinChannel() async { - guard let radioID = appState.connectedDevice?.radioID else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return + Text(L10n.Chats.Chats.JoinHashtag.encryptionDescription) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + Spacer() } + } + .themedRowBackground(theme) - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) } - - isJoining = true - errorMessage = nil - - do { - // For hashtag channels, hash the full name including "#" prefix - // to match meshcore spec: sha256("#channelname")[0:16] - try await channelService.setChannel( - radioID: radioID, - index: selectedSlot, - name: "#\(channelName)", - passphrase: "#\(channelName)" - ) - - // Fetch the joined channel to return it - var joinedChannel: ChannelDTO? - if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { - joinedChannel = channels.first { $0.index == selectedSlot } + .themedRowBackground(theme) + } + + Section { + Button { + Task { + guard !isJoining else { return } + if let existing = existingChannel { + onComplete(existing) + } else { + await joinChannel() + } + } + } label: { + HStack { + Spacer() + if isJoining { + ProgressView() + } else if existingChannel != nil { + Text(L10n.Chats.Chats.JoinHashtag.goToButton(channelName)) + } else { + Text(L10n.Chats.Chats.JoinHashtag.joinButton(channelName)) } - onComplete(joinedChannel) - } catch { - errorMessage = error.userFacingMessage + Spacer() + } } + .disabled(!isValidName || (isJoining && existingChannel == nil)) + .accessibilityHint(existingChannel != nil ? L10n.Chats.Chats.JoinHashtag.existingHint : L10n.Chats.Chats.JoinHashtag.newHint) + } footer: { + if existingChannel != nil { + Label(L10n.Chats.Chats.JoinHashtag.alreadyJoined, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .accessibilityLabel(L10n.Chats.Chats.JoinHashtag.alreadyJoinedAccessibility) + .frame(maxWidth: .infinity) + } + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.JoinHashtag.title) + .navigationBarTitleDisplayMode(.inline) + .task { + guard let radioID = appState.connectedDevice?.radioID, + let dataStore = appState.services?.dataStore else { return } + do { + existingChannels = try await dataStore.fetchChannels(radioID: radioID) + } catch { + // Fail open - allow creation if fetch fails + } + } + } - isJoining = false + private func joinChannel() async { + guard let radioID = appState.connectedDevice?.radioID else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return } + + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return + } + + isJoining = true + errorMessage = nil + + do { + // For hashtag channels, hash the full name including "#" prefix + // to match meshcore spec: sha256("#channelname")[0:16] + try await channelService.setChannel( + radioID: radioID, + index: selectedSlot, + name: "#\(channelName)", + passphrase: "#\(channelName)" + ) + + // Fetch the joined channel to return it + var joinedChannel: ChannelDTO? + if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { + joinedChannel = channels.first { $0.index == selectedSlot } + } + onComplete(joinedChannel) + } catch { + errorMessage = error.userFacingMessage + } + + isJoining = false + } } #Preview { - NavigationStack { - JoinHashtagChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) - } - .environment(\.appState, AppState()) + NavigationStack { + JoinHashtagChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Sheets/JoinHashtagFromMessageView.swift b/MC1/Views/Chats/Sheets/JoinHashtagFromMessageView.swift index 33362541..20315303 100644 --- a/MC1/Views/Chats/Sheets/JoinHashtagFromMessageView.swift +++ b/MC1/Views/Chats/Sheets/JoinHashtagFromMessageView.swift @@ -1,255 +1,255 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "JoinHashtagFromMessageView") /// Sheet view for joining a hashtag channel tapped in a message @MainActor struct JoinHashtagFromMessageView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - - let channelName: String - let onComplete: (ChannelDTO?) -> Void - - @State private var availableSlots: [UInt8] = [] - @State private var isJoining = false - @State private var isLoading = true - @State private var isMissingDevice = false - @State private var errorMessage: String? - @State private var successTrigger = 0 - - private var normalizedName: String { - HashtagUtilities.normalizeHashtagName(channelName) - } - - private var fullChannelName: String { - "#\(normalizedName)" - } - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - if isLoading { - HashtagLoadingContent() - } else if isMissingDevice { - HashtagMissingDeviceContent( - fullChannelName: fullChannelName, - onDismiss: { - onComplete(nil) - dismiss() - } - ) - } else if availableSlots.isEmpty { - HashtagNoSlotsContent( - fullChannelName: fullChannelName, - onDismiss: { - onComplete(nil) - dismiss() - } - ) - } else { - HashtagJoinConfirmationContent( - fullChannelName: fullChannelName, - errorMessage: errorMessage, - isJoining: isJoining, - onJoin: { Task { await joinChannel() } } - ) - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + + let channelName: String + let onComplete: (ChannelDTO?) -> Void + + @State private var availableSlots: [UInt8] = [] + @State private var isJoining = false + @State private var isLoading = true + @State private var isMissingDevice = false + @State private var errorMessage: String? + @State private var successTrigger = 0 + + private var normalizedName: String { + HashtagUtilities.normalizeHashtagName(channelName) + } + + private var fullChannelName: String { + "#\(normalizedName)" + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if isLoading { + HashtagLoadingContent() + } else if isMissingDevice { + HashtagMissingDeviceContent( + fullChannelName: fullChannelName, + onDismiss: { + onComplete(nil) + dismiss() } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .navigationTitle(L10n.Chats.Chats.JoinFromMessage.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.cancel) { - onComplete(nil) - dismiss() - } - } + ) + } else if availableSlots.isEmpty { + HashtagNoSlotsContent( + fullChannelName: fullChannelName, + onDismiss: { + onComplete(nil) + dismiss() } - .task { - await loadAvailableSlots() - } - .sensoryFeedback(.success, trigger: successTrigger) + ) + } else { + HashtagJoinConfirmationContent( + fullChannelName: fullChannelName, + errorMessage: errorMessage, + isJoining: isJoining, + onJoin: { Task { await joinChannel() } } + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .navigationTitle(L10n.Chats.Chats.JoinFromMessage.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.cancel) { + onComplete(nil) + dismiss() + } } + } + .task { + await loadAvailableSlots() + } + .sensoryFeedback(.success, trigger: successTrigger) } + } - // MARK: - Private Methods + // MARK: - Private Methods - private func loadAvailableSlots() async { - guard let radioID = appState.connectedDevice?.radioID else { - isMissingDevice = true - isLoading = false - return - } + private func loadAvailableSlots() async { + guard let radioID = appState.connectedDevice?.radioID else { + isMissingDevice = true + isLoading = false + return + } - isMissingDevice = false + isMissingDevice = false - do { - let existingChannels = try await appState.services?.dataStore.fetchChannels(radioID: radioID) ?? [] - let usedSlots = Set(existingChannels.map(\.index)) + do { + let existingChannels = try await appState.services?.dataStore.fetchChannels(radioID: radioID) ?? [] + let usedSlots = Set(existingChannels.map(\.index)) - let maxChannels = appState.connectedDevice?.maxChannels ?? 0 - if maxChannels > 1 { - availableSlots = (1.. 1 { + availableSlots = (1.. Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Chats.Chats.JoinFromMessage.NoDevice.title, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Chats.Chats.JoinFromMessage.NoDevice.description(fullChannelName)) - } actions: { - Button(L10n.Chats.Chats.Common.ok, action: onDismiss) - .liquidGlassProminentButtonStyle() - } + let fullChannelName: String + let onDismiss: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.JoinFromMessage.NoDevice.title, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Chats.Chats.JoinFromMessage.NoDevice.description(fullChannelName)) + } actions: { + Button(L10n.Chats.Chats.Common.ok, action: onDismiss) + .liquidGlassProminentButtonStyle() } + } } private struct HashtagNoSlotsContent: View { - let fullChannelName: String - let onDismiss: () -> Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Chats.Chats.JoinFromMessage.NoSlots.title, systemImage: "number.circle.fill") - } description: { - Text(L10n.Chats.Chats.JoinFromMessage.NoSlots.description(fullChannelName)) - } actions: { - Button(L10n.Chats.Chats.Common.ok, action: onDismiss) - .liquidGlassProminentButtonStyle() - } + let fullChannelName: String + let onDismiss: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.JoinFromMessage.NoSlots.title, systemImage: "number.circle.fill") + } description: { + Text(L10n.Chats.Chats.JoinFromMessage.NoSlots.description(fullChannelName)) + } actions: { + Button(L10n.Chats.Chats.Common.ok, action: onDismiss) + .liquidGlassProminentButtonStyle() } + } } private struct HashtagJoinConfirmationContent: View { - let fullChannelName: String - let errorMessage: String? - let isJoining: Bool - let onJoin: () -> Void - - var body: some View { - VStack(spacing: 24) { - Spacer() - - VStack(spacing: 16) { - ZStack { - Circle() - .fill(.cyan) - .frame(width: 80, height: 80) - - Image(systemName: "number") - .font(.system(size: 36, weight: .bold)) - .foregroundStyle(.white) - } - - Text(fullChannelName) - .font(.title) - .bold() - - Text(L10n.Chats.Chats.JoinFromMessage.description) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - - Spacer() - - if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundStyle(.red) - .padding(.horizontal) - } + let fullChannelName: String + let errorMessage: String? + let isJoining: Bool + let onJoin: () -> Void + + var body: some View { + VStack(spacing: 24) { + Spacer() + + VStack(spacing: 16) { + ZStack { + Circle() + .fill(.cyan) + .frame(width: 80, height: 80) + + Image(systemName: "number") + .font(.system(size: 36, weight: .bold)) + .foregroundStyle(.white) + } - Button(action: onJoin) { - if isJoining { - ProgressView() - } else { - Text(L10n.Chats.Chats.JoinFromMessage.joinButton(fullChannelName)) - } - } - .liquidGlassProminentButtonStyle() - .disabled(isJoining) - .padding(.horizontal, 48) - .padding(.bottom, 32) + Text(fullChannelName) + .font(.title) + .bold() + + Text(L10n.Chats.Chats.JoinFromMessage.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + .padding(.horizontal) + } + + Button(action: onJoin) { + if isJoining { + ProgressView() + } else { + Text(L10n.Chats.Chats.JoinFromMessage.joinButton(fullChannelName)) } + } + .liquidGlassProminentButtonStyle() + .disabled(isJoining) + .padding(.horizontal, 48) + .padding(.bottom, 32) } + } } #Preview { - JoinHashtagFromMessageView(channelName: "#general") { _ in } - .environment(\.appState, AppState()) - .presentationDetents([.medium]) + JoinHashtagFromMessageView(channelName: "#general") { _ in } + .environment(\.appState, AppState()) + .presentationDetents([.medium]) } diff --git a/MC1/Views/Chats/Sheets/JoinPrivateChannelView.swift b/MC1/Views/Chats/Sheets/JoinPrivateChannelView.swift index f20917a9..5e2ca07d 100644 --- a/MC1/Views/Chats/Sheets/JoinPrivateChannelView.swift +++ b/MC1/Views/Chats/Sheets/JoinPrivateChannelView.swift @@ -1,137 +1,137 @@ -import SwiftUI import MC1Services +import SwiftUI /// View for joining a private channel by entering name and hex secret key struct JoinPrivateChannelView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - let availableSlots: [UInt8] - let onComplete: (ChannelDTO?) -> Void + let availableSlots: [UInt8] + let onComplete: (ChannelDTO?) -> Void - @State private var channelName = "" - @State private var secretKeyHex = "" - @State private var selectedSlot: UInt8 - @State private var isJoining = false - @State private var errorMessage: String? + @State private var channelName = "" + @State private var secretKeyHex = "" + @State private var selectedSlot: UInt8 + @State private var isJoining = false + @State private var errorMessage: String? - init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { - self.availableSlots = availableSlots - self.onComplete = onComplete - self._selectedSlot = State(initialValue: availableSlots.first ?? 1) - } - - private var isValidSecret: Bool { - let cleaned = secretKeyHex.replacing(" ", with: "").uppercased() - return cleaned.count == ProtocolLimits.channelSecretSize * 2 && cleaned.allSatisfy { $0.isHexDigit } - } + init(availableSlots: [UInt8], onComplete: @escaping (ChannelDTO?) -> Void) { + self.availableSlots = availableSlots + self.onComplete = onComplete + _selectedSlot = State(initialValue: availableSlots.first ?? 1) + } - var body: some View { - Form { - Section { - TextField(L10n.Chats.Chats.CreatePrivate.channelName, text: $channelName) - .textContentType(.name) - .onChange(of: channelName) { _, newValue in - if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { - channelName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - } - } + private var isValidSecret: Bool { + let cleaned = secretKeyHex.replacing(" ", with: "").uppercased() + return cleaned.count == ProtocolLimits.channelSecretSize * 2 && cleaned.allSatisfy(\.isHexDigit) + } - TextField(L10n.Chats.Chats.JoinPrivate.secretKeyPlaceholder, text: $secretKeyHex) - .textContentType(.password) - .font(.system(.body, design: .monospaced)) - .textInputAutocapitalization(.characters) - .onChange(of: secretKeyHex) { _, newValue in - // Clean and format as user types - secretKeyHex = newValue.uppercased().filter { $0.isHexDigit } - } - } header: { - Text(L10n.Chats.Chats.CreatePrivate.Section.details) - } footer: { - if !secretKeyHex.isEmpty && !isValidSecret { - Text(L10n.Chats.Chats.JoinPrivate.Error.invalidSecret) - .foregroundStyle(.red) - } else { - Text(L10n.Chats.Chats.JoinPrivate.footer) - } + var body: some View { + Form { + Section { + TextField(L10n.Chats.Chats.CreatePrivate.channelName, text: $channelName) + .textContentType(.name) + .onChange(of: channelName) { _, newValue in + if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { + channelName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) } - .themedRowBackground(theme) + } - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } + TextField(L10n.Chats.Chats.JoinPrivate.secretKeyPlaceholder, text: $secretKeyHex) + .textContentType(.password) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.characters) + .onChange(of: secretKeyHex) { _, newValue in + // Clean and format as user types + secretKeyHex = newValue.uppercased().filter(\.isHexDigit) + } + } header: { + Text(L10n.Chats.Chats.CreatePrivate.Section.details) + } footer: { + if !secretKeyHex.isEmpty, !isValidSecret { + Text(L10n.Chats.Chats.JoinPrivate.Error.invalidSecret) + .foregroundStyle(.red) + } else { + Text(L10n.Chats.Chats.JoinPrivate.footer) + } + } + .themedRowBackground(theme) + + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + .themedRowBackground(theme) + } - Section { - Button { - Task { - await joinChannel() - } - } label: { - HStack { - Spacer() - if isJoining { - ProgressView() - } else { - Text(L10n.Chats.Chats.JoinPrivate.joinButton) - } - Spacer() - } - } - .disabled(channelName.isEmpty || !isValidSecret || isJoining) + Section { + Button { + Task { + await joinChannel() + } + } label: { + HStack { + Spacer() + if isJoining { + ProgressView() + } else { + Text(L10n.Chats.Chats.JoinPrivate.joinButton) } - .themedRowBackground(theme) + Spacer() + } } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.JoinPrivate.title) - .navigationBarTitleDisplayMode(.inline) + .disabled(channelName.isEmpty || !isValidSecret || isJoining) + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.JoinPrivate.title) + .navigationBarTitleDisplayMode(.inline) + } - private func joinChannel() async { - guard let radioID = appState.connectedDevice?.radioID else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return - } + private func joinChannel() async { + guard let radioID = appState.connectedDevice?.radioID else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return + } - guard let secret = Data(hexString: secretKeyHex) else { - errorMessage = L10n.Chats.Chats.JoinPrivate.Error.invalidFormat - return - } + guard let secret = Data(hexString: secretKeyHex) else { + errorMessage = L10n.Chats.Chats.JoinPrivate.Error.invalidFormat + return + } - isJoining = true - defer { isJoining = false } - errorMessage = nil + isJoining = true + defer { isJoining = false } + errorMessage = nil - do { - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return - } - try await channelService.setChannelWithSecret( - radioID: radioID, - index: selectedSlot, - name: channelName, - secret: secret - ) + do { + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return + } + try await channelService.setChannelWithSecret( + radioID: radioID, + index: selectedSlot, + name: channelName, + secret: secret + ) - // Fetch the joined channel to return it - var joinedChannel: ChannelDTO? - if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { - joinedChannel = channels.first { $0.index == selectedSlot } - } - onComplete(joinedChannel) - } catch { - errorMessage = error.userFacingMessage - } + // Fetch the joined channel to return it + var joinedChannel: ChannelDTO? + if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { + joinedChannel = channels.first { $0.index == selectedSlot } + } + onComplete(joinedChannel) + } catch { + errorMessage = error.userFacingMessage } + } } #Preview { - NavigationStack { - JoinPrivateChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) - } - .environment(\.appState, AppState()) + NavigationStack { + JoinPrivateChannelView(availableSlots: [1, 2, 3], onComplete: { _ in }) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Sheets/JoinPublicChannelView.swift b/MC1/Views/Chats/Sheets/JoinPublicChannelView.swift index 5fe502f9..4279bd26 100644 --- a/MC1/Views/Chats/Sheets/JoinPublicChannelView.swift +++ b/MC1/Views/Chats/Sheets/JoinPublicChannelView.swift @@ -1,107 +1,107 @@ -import SwiftUI import MC1Services +import SwiftUI /// View for re-adding the public channel on slot 0 struct JoinPublicChannelView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - let onComplete: (ChannelDTO?) -> Void + let onComplete: (ChannelDTO?) -> Void - @State private var isJoining = false - @State private var errorMessage: String? + @State private var isJoining = false + @State private var errorMessage: String? - var body: some View { - Form { - Section { - HStack { - Spacer() - VStack(spacing: 16) { - Image(systemName: "globe") - .font(.system(size: 60)) - .foregroundStyle(.green) + var body: some View { + Form { + Section { + HStack { + Spacer() + VStack(spacing: 16) { + Image(systemName: "globe") + .font(.system(size: 60)) + .foregroundStyle(.green) - Text(L10n.Chats.Chats.JoinPublic.channelName) - .font(.title2) - .bold() + Text(L10n.Chats.Chats.JoinPublic.channelName) + .font(.title2) + .bold() - Text(L10n.Chats.Chats.JoinPublic.description) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding() - Spacer() - } - } - .themedRowBackground(theme) + Text(L10n.Chats.Chats.JoinPublic.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + Spacer() + } + } + .themedRowBackground(theme) - if let errorMessage { - Section { - Text(errorMessage) - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + .themedRowBackground(theme) + } - Section { - Button { - Task { - await joinPublicChannel() - } - } label: { - HStack { - Spacer() - if isJoining { - ProgressView() - } else { - Text(L10n.Chats.Chats.JoinPublic.addButton) - } - Spacer() - } - } - .disabled(isJoining) + Section { + Button { + Task { + await joinPublicChannel() + } + } label: { + HStack { + Spacer() + if isJoining { + ProgressView() + } else { + Text(L10n.Chats.Chats.JoinPublic.addButton) } - .themedRowBackground(theme) + Spacer() + } } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.JoinPublic.title) - .navigationBarTitleDisplayMode(.inline) + .disabled(isJoining) + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.JoinPublic.title) + .navigationBarTitleDisplayMode(.inline) + } - private func joinPublicChannel() async { - guard let radioID = appState.connectedDevice?.radioID else { - errorMessage = L10n.Chats.Chats.Error.noDeviceConnected - return - } - - isJoining = true - errorMessage = nil + private func joinPublicChannel() async { + guard let radioID = appState.connectedDevice?.radioID else { + errorMessage = L10n.Chats.Chats.Error.noDeviceConnected + return + } - do { - guard let channelService = appState.services?.channelService else { - errorMessage = L10n.Chats.Chats.Error.servicesUnavailable - return - } - try await channelService.setupPublicChannel(radioID: radioID) + isJoining = true + errorMessage = nil - // Fetch the public channel (slot 0) to return it - var publicChannel: ChannelDTO? - if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { - publicChannel = channels.first { $0.index == 0 } - } - onComplete(publicChannel) - } catch { - errorMessage = error.userFacingMessage - } + do { + guard let channelService = appState.services?.channelService else { + errorMessage = L10n.Chats.Chats.Error.servicesUnavailable + return + } + try await channelService.setupPublicChannel(radioID: radioID) - isJoining = false + // Fetch the public channel (slot 0) to return it + var publicChannel: ChannelDTO? + if let channels = try? await appState.services?.dataStore.fetchChannels(radioID: radioID) { + publicChannel = channels.first { $0.index == 0 } + } + onComplete(publicChannel) + } catch { + errorMessage = error.userFacingMessage } + + isJoining = false + } } #Preview { - NavigationStack { - JoinPublicChannelView(onComplete: { _ in }) - } - .environment(\.appState, AppState()) + NavigationStack { + JoinPublicChannelView(onComplete: { _ in }) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Chats/Sheets/SendDMSheet.swift b/MC1/Views/Chats/Sheets/SendDMSheet.swift index 751c8f14..8dd7c815 100644 --- a/MC1/Views/Chats/Sheets/SendDMSheet.swift +++ b/MC1/Views/Chats/Sheets/SendDMSheet.swift @@ -1,5 +1,5 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "SendDMSheet") @@ -7,90 +7,90 @@ private let logger = Logger(subsystem: "com.mc1", category: "SendDMSheet") /// Picker sheet for starting a DM with a channel sender. /// Resolves the sender's display name to matching Contacts (case-insensitive) and lets the user pick one. struct SendDMSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss - let senderName: String - let radioID: UUID - let onSelect: (ContactDTO) -> Void + let senderName: String + let radioID: UUID + let onSelect: (ContactDTO) -> Void - @State private var matchingContacts: [ContactDTO] = [] - @State private var isLoading = true + @State private var matchingContacts: [ContactDTO] = [] + @State private var isLoading = true - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if matchingContacts.isEmpty { - ContentUnavailableView( - L10n.Chats.Chats.SendDM.noMatches(senderName), - systemImage: "person.crop.circle.badge.questionmark" - ) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.Chats.Chats.SendDM.limitation) - .font(.subheadline) - .foregroundStyle(.secondary) + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if matchingContacts.isEmpty { + ContentUnavailableView( + L10n.Chats.Chats.SendDM.noMatches(senderName), + systemImage: "person.crop.circle.badge.questionmark" + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.Chats.Chats.SendDM.limitation) + .font(.subheadline) + .foregroundStyle(.secondary) - Text(L10n.Chats.Chats.SendDM.matchingContacts) - .font(.subheadline) - .foregroundStyle(.secondary) + Text(L10n.Chats.Chats.SendDM.matchingContacts) + .font(.subheadline) + .foregroundStyle(.secondary) - ForEach(matchingContacts) { contact in - ContactMatchRow( - contact: contact, - style: .tap, - userLocation: appState.bestAvailableLocation, - action: { - onSelect(contact) - dismiss() - } - ) - } - } - .padding() - } - } - } - .navigationTitle(L10n.Chats.Chats.SendDM.title(senderName)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.SendDM.cancel) { - dismiss() - } - } - } - .task { - await loadMatchingContacts() + ForEach(matchingContacts) { contact in + ContactMatchRow( + contact: contact, + style: .tap, + userLocation: appState.bestAvailableLocation, + action: { + onSelect(contact) + dismiss() + } + ) + } } + .padding() + } } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .presentationBackground(.background) + } + .navigationTitle(L10n.Chats.Chats.SendDM.title(senderName)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.SendDM.cancel) { + dismiss() + } + } + } + .task { + await loadMatchingContacts() + } } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(.background) + } - private func loadMatchingContacts() async { - defer { isLoading = false } + private func loadMatchingContacts() async { + defer { isLoading = false } - guard let store = appState.offlineDataStore else { - logger.warning("No data store available for contact matching") - return - } + guard let store = appState.offlineDataStore else { + logger.warning("No data store available for contact matching") + return + } - do { - let allContacts = try await store.fetchContacts(radioID: radioID) - matchingContacts = SenderContactMatcher.filter( - contacts: allContacts, - senderName: senderName, - excludeBlocked: true - ) - logger.info("Found \(matchingContacts.count) matching contacts for sender '\(senderName)'") - } catch { - logger.error("Failed to fetch contacts for matching: \(error)") - } + do { + let allContacts = try await store.fetchContacts(radioID: radioID) + matchingContacts = SenderContactMatcher.filter( + contacts: allContacts, + senderName: senderName, + excludeBlocked: true + ) + logger.info("Found \(matchingContacts.count) matching contacts for sender '\(senderName)'") + } catch { + logger.error("Failed to fetch contacts for matching: \(error)") } + } } diff --git a/MC1/Views/Chats/Sheets/ShareContactPickerSheet.swift b/MC1/Views/Chats/Sheets/ShareContactPickerSheet.swift index f41e930e..e8fd2a84 100644 --- a/MC1/Views/Chats/Sheets/ShareContactPickerSheet.swift +++ b/MC1/Views/Chats/Sheets/ShareContactPickerSheet.swift @@ -1,134 +1,134 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "ShareContactPickerSheet") private enum Layout { - static let avatarSize: CGFloat = 40 - static let rowSpacing: CGFloat = 12 - static let rowVerticalSpacing: CGFloat = 2 + static let avatarSize: CGFloat = 40 + static let rowSpacing: CGFloat = 12 + static let rowVerticalSpacing: CGFloat = 2 } /// Sheet for picking a contact to share as a MeshCore contact-share token. /// Calls `onInsert` with the formatted token and dismisses on selection. struct ShareContactPickerSheet: View { - let onInsert: (String) -> Void - - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.appTheme) private var theme - - @State private var contacts: [ContactDTO] = [] - @State private var searchText = "" - @State private var isLoading = true - @State private var errorMessage: String? - - private var filteredContacts: [ContactDTO] { - guard !searchText.isEmpty else { return contacts } - return contacts.filter { contact in - contact.name.localizedCaseInsensitiveContains(searchText) || - (contact.nickname?.localizedCaseInsensitiveContains(searchText) ?? false) || - contact.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) - } + let onInsert: (String) -> Void + + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.appTheme) private var theme + + @State private var contacts: [ContactDTO] = [] + @State private var searchText = "" + @State private var isLoading = true + @State private var errorMessage: String? + + private var filteredContacts: [ContactDTO] { + guard !searchText.isEmpty else { return contacts } + return contacts.filter { contact in + contact.name.localizedCaseInsensitiveContains(searchText) || + (contact.nickname?.localizedCaseInsensitiveContains(searchText) ?? false) || + contact.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) } - - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if filteredContacts.isEmpty { - ContentUnavailableView( - L10n.Chats.Chats.ContactPicker.emptyState, - systemImage: "person.2" - ) - } else { - List(filteredContacts) { contact in - Button { - let token = ContactShareUtilities.formatShare( - publicKey: contact.publicKey, - type: contact.type, - name: contact.name - ) - onInsert(token) - dismiss() - } label: { - HStack(spacing: Layout.rowSpacing) { - ContactAvatar(contact: contact, size: Layout.avatarSize) - - VStack(alignment: .leading, spacing: Layout.rowVerticalSpacing) { - (Text(idPrefixHex(for: contact)) - .monospaced() - .foregroundStyle(.secondary) - + Text(" \(contact.displayName)")) - .font(.headline) - .accessibilityLabel(contact.displayName) - - Text(contactTypeLabel(for: contact)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - } - .contentShape(.rect) - } - .buttonStyle(.plain) - } - .themedCanvas(theme) + } + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filteredContacts.isEmpty { + ContentUnavailableView( + L10n.Chats.Chats.ContactPicker.emptyState, + systemImage: "person.2" + ) + } else { + List(filteredContacts) { contact in + Button { + let token = ContactShareUtilities.formatShare( + publicKey: contact.publicKey, + type: contact.type, + name: contact.name + ) + onInsert(token) + dismiss() + } label: { + HStack(spacing: Layout.rowSpacing) { + ContactAvatar(contact: contact, size: Layout.avatarSize) + + VStack(alignment: .leading, spacing: Layout.rowVerticalSpacing) { + (Text(idPrefixHex(for: contact)) + .monospaced() + .foregroundStyle(.secondary) + + Text(" \(contact.displayName)")) + .font(.headline) + .accessibilityLabel(contact.displayName) + + Text(contactTypeLabel(for: contact)) + .font(.caption) + .foregroundStyle(.secondary) } - } - .navigationTitle(L10n.Chats.Chats.ContactPicker.title) - .navigationBarTitleDisplayMode(.inline) - .searchable(text: $searchText, prompt: L10n.Chats.Chats.ContactPicker.Search.placeholder) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Chats.Chats.Common.cancel) { - dismiss() - } - } - } - .task { - await loadContacts() - } - .errorAlert($errorMessage) - } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .presentationBackground(.background) - } - private func loadContacts() async { - defer { isLoading = false } - - guard let radioID = appState.currentRadioID, - let store = appState.offlineDataStore else { - logger.warning("No radio or data store available for contact picker") - return + Spacer() + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } + .themedCanvas(theme) } - - do { - contacts = try await store.fetchContacts(radioID: radioID) - .filter { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - logger.info("Loaded \(contacts.count) contacts for share picker") - } catch { - logger.error("Failed to fetch contacts for share picker: \(error)") - errorMessage = error.userFacingMessage + } + .navigationTitle(L10n.Chats.Chats.ContactPicker.title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: L10n.Chats.Chats.ContactPicker.Search.placeholder) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Chats.Chats.Common.cancel) { + dismiss() + } } + } + .task { + await loadContacts() + } + .errorAlert($errorMessage) } - - private func idPrefixHex(for contact: ContactDTO) -> String { - let hashSize = appState.connectedDevice?.hashSize ?? 1 - return contact.publicKey.prefix(hashSize).uppercaseHexString() + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(.background) + } + + private func loadContacts() async { + defer { isLoading = false } + + guard let radioID = appState.currentRadioID, + let store = appState.offlineDataStore else { + logger.warning("No radio or data store available for contact picker") + return } - private func contactTypeLabel(for contact: ContactDTO) -> String { - contact.type.localizedName + do { + contacts = try await store.fetchContacts(radioID: radioID) + .filter { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + logger.info("Loaded \(contacts.count) contacts for share picker") + } catch { + logger.error("Failed to fetch contacts for share picker: \(error)") + errorMessage = error.userFacingMessage } + } + + private func idPrefixHex(for contact: ContactDTO) -> String { + let hashSize = appState.connectedDevice?.hashSize ?? 1 + return contact.publicKey.prefix(hashSize).uppercaseHexString() + } + + private func contactTypeLabel(for contact: ContactDTO) -> String { + contact.type.localizedName + } } #Preview("Empty state") { - ShareContactPickerSheet(onInsert: { _ in }) + ShareContactPickerSheet(onInsert: { _ in }) } diff --git a/MC1/Views/Chats/UnreadBadges.swift b/MC1/Views/Chats/UnreadBadges.swift index fe0668d4..cfd0062d 100644 --- a/MC1/Views/Chats/UnreadBadges.swift +++ b/MC1/Views/Chats/UnreadBadges.swift @@ -2,37 +2,37 @@ import MC1Services import SwiftUI struct UnreadBadges: View { - let unreadCount: Int - var unreadMentionCount: Int = 0 - var notificationLevel: NotificationLevel = .all + let unreadCount: Int + var unreadMentionCount: Int = 0 + var notificationLevel: NotificationLevel = .all - private var mentionBadgeColor: Color { - notificationLevel == .muted ? .secondary : .blue - } + private var mentionBadgeColor: Color { + notificationLevel == .muted ? .secondary : .blue + } - private var unreadBadgeColor: Color { - notificationLevel == .all ? .blue : .secondary - } + private var unreadBadgeColor: Color { + notificationLevel == .all ? .blue : .secondary + } - var body: some View { - HStack(spacing: 4) { - if unreadMentionCount > 0 { - Text("@") - .font(.caption.bold()) - .foregroundStyle(.white) - .frame(width: 18, height: 18) - .background(mentionBadgeColor, in: .circle) - } + var body: some View { + HStack(spacing: 4) { + if unreadMentionCount > 0 { + Text("@") + .font(.caption.bold()) + .foregroundStyle(.white) + .frame(width: 18, height: 18) + .background(mentionBadgeColor, in: .circle) + } - if unreadCount > 0 { - Text(unreadCount, format: .number) - .font(.caption2) - .bold() - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(unreadBadgeColor, in: .capsule) - } - } + if unreadCount > 0 { + Text(unreadCount, format: .number) + .font(.caption2) + .bold() + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(unreadBadgeColor, in: .capsule) + } } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift index f977abf3..9a23465d 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift @@ -2,316 +2,315 @@ import Foundation import MC1Services extension ChatViewModel { + // MARK: - Channel Messages + + /// Load messages for a channel + func loadChannelMessages(for channel: ChannelDTO) async { + // Close the per-conversation empty-state gate while the fetch is + // in flight. No-op when the coordinator is already past + // `.uninitialized` (warm rebind, refresh). + coordinator?.beginLoading() + + guard let dataStore else { + coordinator?.markLoaded() + return + } - // MARK: - Channel Messages - - /// Load messages for a channel - func loadChannelMessages(for channel: ChannelDTO) async { - // Close the per-conversation empty-state gate while the fetch is - // in flight. No-op when the coordinator is already past - // `.uninitialized` (warm rebind, refresh). - coordinator?.beginLoading() - - guard let dataStore else { - coordinator?.markLoaded() - return - } + // Clear preview state only when switching to a different conversation + if currentChannel?.id != channel.id { + clearPreviewState() + newMessagesDividerMessageID = nil + dividerComputed = false + lastSetRegionScope = .unknown + } - // Clear preview state only when switching to a different conversation - if currentChannel?.id != channel.id { - clearPreviewState() - newMessagesDividerMessageID = nil - dividerComputed = false - lastSetRegionScope = .unknown + currentChannel = channel + currentContact = nil + + // Track active channel for notification suppression + notificationService?.setActiveConversation( + channelIndex: channel.index, + channelRadioID: channel.radioID + ) + + // Sync the device's session-scoped flood key with the effective scope for this + // channel. The effective scope combines the per-channel preference with the + // device-level default — `.inherit` means "fall through to the default". + let deviceDefault = connectedDeviceProvider()?.defaultFloodScopeName + let desiredState: ChatViewModel.RegionScopeState = .pushed( + channel.floodScope, + deviceDefault: deviceDefault + ) + if lastSetRegionScope != desiredState, let session = sessionProvider() { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: channel.floodScope, + deviceDefaultFloodScopeName: deviceDefault, + supportsUnscopedFloodSend: connectedDeviceProvider()?.supportsUnscopedFloodSend ?? false + ) + do { + switch resolved { + case .unscoped: + try await session.setFloodScopeUnscoped() + case let .scope(scope): + try await session.setFloodScope(scope) } + lastSetRegionScope = desiredState + } catch is CancellationError { + // Benign: a superseding load (reconnect / conversation switch) cancelled this one. + } catch { + logger.error("Failed to set flood scope: \(error.localizedDescription)") + } + } - currentChannel = channel - currentContact = nil - - // Track active channel for notification suppression - notificationService?.setActiveConversation( - channelIndex: channel.index, - channelRadioID: channel.radioID - ) + isLoading = true + // Dual-reset: this function is shared between passive load and user-initiated + // retry paths, so both surfaces must clear at entry to avoid stale state. + errorMessage = nil + errorBannerMessage = nil - // Sync the device's session-scoped flood key with the effective scope for this - // channel. The effective scope combines the per-channel preference with the - // device-level default — `.inherit` means "fall through to the default". - let deviceDefault = connectedDeviceProvider()?.defaultFloodScopeName - let desiredState: ChatViewModel.RegionScopeState = .pushed( - channel.floodScope, - deviceDefault: deviceDefault - ) - if lastSetRegionScope != desiredState, let session = sessionProvider() { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: channel.floodScope, - deviceDefaultFloodScopeName: deviceDefault, - supportsUnscopedFloodSend: connectedDeviceProvider()?.supportsUnscopedFloodSend ?? false - ) - do { - switch resolved { - case .unscoped: - try await session.setFloodScopeUnscoped() - case .scope(let scope): - try await session.setFloodScope(scope) - } - lastSetRegionScope = desiredState - } catch is CancellationError { - // Benign: a superseding load (reconnect / conversation switch) cancelled this one. - } catch { - logger.error("Failed to set flood scope: \(error.localizedDescription)") - } - } + // Reset pagination state for new conversation + coordinator?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } - isLoading = true - // Dual-reset: this function is shared between passive load and user-initiated - // retry paths, so both surfaces must clear at entry to avoid stale state. - errorMessage = nil - errorBannerMessage = nil - - // Reset pagination state for new conversation - coordinator?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } - - do { - var fetchedMessages = try await dataStore.fetchMessages(radioID: channel.radioID, channelIndex: channel.index, limit: ChatCoordinator.pageSize, offset: 0) - let unfilteredCount = fetchedMessages.count - coordinator?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } - - // Compute divider position before filtering, using unfiltered array - computeDividerPosition(from: fetchedMessages, unreadCount: channel.unreadCount, isDM: false) - - // Hide sent reaction messages (unless failed) - fetchedMessages = filterOutgoingReactionMessages(fetchedMessages, isDM: false) - - // Use unfiltered count to determine if more messages exist - coordinator?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == ChatCoordinator.pageSize) } - coordinator?.replaceAll(fetchedMessages) - - buildChannelSenders(radioID: channel.radioID) - buildItems() - - // Index loaded messages for reaction matching and process any pending reactions - if let reactionService = reactionServiceProvider() { - await indexMessagesForReactions( - fetchedMessages, - scope: .channel(channel, localNodeName: connectedDeviceProvider()?.nodeName), - reactionService: reactionService, - dataStore: dataStore - ) - } - - // Clear unread count and mention badge, then notify UI to refresh chat list. - // The messages already rendered, so a bookkeeping failure here is logged - // rather than surfaced as a load error. - do { - try await dataStore.clearChannelUnreadCount(channelID: channel.id) - try await dataStore.clearChannelUnreadMentionCount(channelID: channel.id) - } catch { - logger.warning("loadChannelMessages: failed to clear unread counts - \(error.localizedDescription)") - } - syncCoordinator?.notifyConversationsChanged() - - // Update app badge - await notificationService?.updateBadgeCount() - } catch is CancellationError { - // Benign cancellation; the superseding load will refetch. - } catch { - errorMessage = error.userFacingMessage - } + do { + var fetchedMessages = try await dataStore.fetchMessages(radioID: channel.radioID, channelIndex: channel.index, limit: ChatCoordinator.pageSize, offset: 0) + let unfilteredCount = fetchedMessages.count + coordinator?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } - // Ensures the empty-state gate opens even when the fetch threw — - // `replaceAll` is the success path; this catches the failure path. - coordinator?.markLoaded() - isLoading = false - } + // Compute divider position before filtering, using unfiltered array + computeDividerPosition(from: fetchedMessages, unreadCount: channel.unreadCount, isDM: false) - // MARK: - Channel Actions + // Hide sent reaction messages (unless failed) + fetchedMessages = filterOutgoingReactionMessages(fetchedMessages, isDM: false) - /// Send a channel message optimistically — shows immediately, sends in background. - func sendChannelMessage(text: String) async { - guard let channel = currentChannel, - let messageService, - !text.isEmpty else { - return - } + // Use unfiltered count to determine if more messages exist + coordinator?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == ChatCoordinator.pageSize) } + coordinator?.replaceAll(fetchedMessages) - errorMessage = nil - - let message: MessageDTO - do { - message = try await messageService.createPendingChannelMessage( - text: text, - channelIndex: channel.index, - radioID: channel.radioID - ) - appendMessageIfNew(message) - schedulePrefetchForOutgoingMessage(message, isChannelMessage: true) - } catch { - errorMessage = error.userFacingMessage - return - } + buildChannelSenders(radioID: channel.radioID) + buildItems() - let envelope = ChannelMessageEnvelope( - messageID: message.id, - channelIndex: channel.index, - isResend: false, - messageText: message.text, - messageTimestamp: message.timestamp, - localNodeName: connectedDeviceProvider()?.nodeName + // Index loaded messages for reaction matching and process any pending reactions + if let reactionService = reactionServiceProvider() { + await indexMessagesForReactions( + fetchedMessages, + scope: .channel(channel, localNodeName: connectedDeviceProvider()?.nodeName), + reactionService: reactionService, + dataStore: dataStore ) - do { - try await enqueueChannel(envelope) - } catch { - logger.error("enqueueChannel failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) - } + } + + // Clear unread count and mention badge, then notify UI to refresh chat list. + // The messages already rendered, so a bookkeeping failure here is logged + // rather than surfaced as a load error. + do { + try await dataStore.clearChannelUnreadCount(channelID: channel.id) + try await dataStore.clearChannelUnreadMentionCount(channelID: channel.id) + } catch { + logger.warning("loadChannelMessages: failed to clear unread counts - \(error.localizedDescription)") + } + syncCoordinator?.notifyConversationsChanged() + + // Update app badge + await notificationService?.updateBadgeCount() + } catch is CancellationError { + // Benign cancellation; the superseding load will refetch. + } catch { + errorMessage = error.userFacingMessage } - /// Retry sending a failed channel message in place. The drain stamps a - /// fresh timestamp via `resendChannelMessage` so the retry packet hashes - /// differently from the original — the mesh dedup table is a 128-slot - /// cyclic ring with no time-based eviction, so reusing the original - /// timestamp would be silently dropped at every neighbour until 127 - /// unrelated packets evict the slot. The `retryInFlight` guard prevents - /// reentrant double-tap during the synchronous status-update + reload + - /// enqueue window. Once status flips to `.pending`, the bubble's retry - /// button hides (UI gate), so a fresh tap cannot enqueue again until the - /// channel send later fails and the row returns to `.failed`. - func retryChannelMessage(_ message: MessageDTO) async { - guard messageService != nil, - currentChannel != nil, - let channelIndex = message.channelIndex, - !retryInFlight else { return } - - retryInFlight = true - defer { retryInFlight = false } - - // Stand in for the surfaces loadChannelMessages would have reset. - errorMessage = nil - errorBannerMessage = nil - - // Capture once so the three status-update writes below all target the - // same container even if a reconnect fires between the awaits. - let dataStore = self.dataStore - - // Release any prior queue ownership before enqueuing a fresh - // envelope. Channel sends never reach `.delivered` (no end-to-end - // ACK), so the `.delivered` clobber risk that motivates the DM - // guard doesn't apply here — but the queue's `hasPendingSend` gate - // and the symmetric call shape with `retryMessage` are worth the - // extra delete. Best-effort; the gate self-corrects. - try? await dataStore?.deletePendingSendsForMessage(messageID: message.id) - - // Flip the row in place for instant "Sending" feedback rather than a - // full loadChannelMessages refetch, which would also reset paging - // state. The coordinator's applyStatusUpdate guards against - // downgrading a row that resolved concurrently. Swap to the - // unless-delivered variant for shape symmetry with `retryMessage` - // and so a stray ACK landing doesn't get clobbered if a future - // refactor introduces channel-side delivery. - _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .pending) - coordinator?.applyStatusUpdate(messageID: message.id, status: .pending, userInitiated: true) - - let envelope = ChannelMessageEnvelope( - messageID: message.id, - channelIndex: channelIndex, - isResend: true, - messageText: message.text, - messageTimestamp: message.timestamp, - localNodeName: connectedDeviceProvider()?.nodeName - ) - do { - try await enqueueChannel(envelope) - } catch { - logger.error("enqueueChannel retry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) - } - } + // Ensures the empty-state gate opens even when the fetch threw — + // `replaceAll` is the success path; this catches the failure path. + coordinator?.markLoaded() + isLoading = false + } - // MARK: - In-Place Updates + // MARK: - Channel Actions - /// Update heard repeat count for a message in place without a full reload. - func updateHeardRepeats(for messageID: UUID, count: Int) { - updateMessage(id: messageID) { $0.heardRepeats = count } + /// Send a channel message optimistically — shows immediately, sends in background. + func sendChannelMessage(text: String) async { + guard let channel = currentChannel, + let messageService, + !text.isEmpty else { + return } - // MARK: - Channel Sender Tracking - - /// Build synthetic contacts from channel message senders not in contacts. - /// Called after loading channel messages to populate mention picker. - /// Builds into local collections first to avoid multiple @Observable updates. - private func buildChannelSenders(radioID: UUID) { - var localNames: Set = [] - var localSenders: [ContactDTO] = [] - var localOrder: [String: UInt32] = [:] - - for message in messages { - if let name = message.senderNodeName { - let trimmed = name.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, trimmed.count <= 128 else { continue } + errorMessage = nil + + let message: MessageDTO + do { + message = try await messageService.createPendingChannelMessage( + text: text, + channelIndex: channel.index, + radioID: channel.radioID + ) + appendMessageIfNew(message) + schedulePrefetchForOutgoingMessage(message, isChannelMessage: true) + } catch { + errorMessage = error.userFacingMessage + return + } - // Track latest timestamp for all senders (contacts and non-contacts) - localOrder[trimmed] = max(message.timestamp, localOrder[trimmed] ?? 0) + let envelope = ChannelMessageEnvelope( + messageID: message.id, + channelIndex: channel.index, + isResend: false, + messageText: message.text, + messageTimestamp: message.timestamp, + localNodeName: connectedDeviceProvider()?.nodeName + ) + do { + try await enqueueChannel(envelope) + } catch { + logger.error("enqueueChannel failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) + coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + sendErrorMessage = Self.copyForEnqueueFailure(error) + } + } + + /// Retry sending a failed channel message in place. The drain stamps a + /// fresh timestamp via `resendChannelMessage` so the retry packet hashes + /// differently from the original — the mesh dedup table is a 128-slot + /// cyclic ring with no time-based eviction, so reusing the original + /// timestamp would be silently dropped at every neighbour until 127 + /// unrelated packets evict the slot. The `retryInFlight` guard prevents + /// reentrant double-tap during the synchronous status-update + reload + + /// enqueue window. Once status flips to `.pending`, the bubble's retry + /// button hides (UI gate), so a fresh tap cannot enqueue again until the + /// channel send later fails and the row returns to `.failed`. + func retryChannelMessage(_ message: MessageDTO) async { + guard messageService != nil, + currentChannel != nil, + let channelIndex = message.channelIndex, + !retryInFlight else { return } + + retryInFlight = true + defer { retryInFlight = false } + + // Stand in for the surfaces loadChannelMessages would have reset. + errorMessage = nil + errorBannerMessage = nil + + // Capture once so the three status-update writes below all target the + // same container even if a reconnect fires between the awaits. + let dataStore = dataStore + + // Release any prior queue ownership before enqueuing a fresh + // envelope. Channel sends never reach `.delivered` (no end-to-end + // ACK), so the `.delivered` clobber risk that motivates the DM + // guard doesn't apply here — but the queue's `hasPendingSend` gate + // and the symmetric call shape with `retryMessage` are worth the + // extra delete. Best-effort; the gate self-corrects. + try? await dataStore?.deletePendingSendsForMessage(messageID: message.id) + + // Flip the row in place for instant "Sending" feedback rather than a + // full loadChannelMessages refetch, which would also reset paging + // state. The coordinator's applyStatusUpdate guards against + // downgrading a row that resolved concurrently. Swap to the + // unless-delivered variant for shape symmetry with `retryMessage` + // and so a stray ACK landing doesn't get clobbered if a future + // refactor introduces channel-side delivery. + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .pending) + coordinator?.applyStatusUpdate(messageID: message.id, status: .pending, userInitiated: true) + + let envelope = ChannelMessageEnvelope( + messageID: message.id, + channelIndex: channelIndex, + isResend: true, + messageText: message.text, + messageTimestamp: message.timestamp, + localNodeName: connectedDeviceProvider()?.nodeName + ) + do { + try await enqueueChannel(envelope) + } catch { + logger.error("enqueueChannel retry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) + coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + sendErrorMessage = Self.copyForEnqueueFailure(error) + } + } - // Build synthetic contacts only for non-contact senders - guard !contactNameSet.contains(trimmed), - !localNames.contains(trimmed) else { continue } + // MARK: - In-Place Updates - localNames.insert(trimmed) - localSenders.append(makeSyntheticContact(name: trimmed, radioID: radioID)) - } - } + /// Update heard repeat count for a message in place without a full reload. + func updateHeardRepeats(for messageID: UUID, count: Int) { + updateMessage(id: messageID) { $0.heardRepeats = count } + } - // Assign once to minimize observation updates - channelSenderNames = localNames - channelSenders = localSenders - channelSenderOrder = localOrder + // MARK: - Channel Sender Tracking - logger.info("Built \(self.channelSenders.count) synthetic contacts from channel senders") - } + /// Build synthetic contacts from channel message senders not in contacts. + /// Called after loading channel messages to populate mention picker. + /// Builds into local collections first to avoid multiple @Observable updates. + private func buildChannelSenders(radioID: UUID) { + var localNames: Set = [] + var localSenders: [ContactDTO] = [] + var localOrder: [String: UInt32] = [:] - /// Register a channel sender for the mention picker. Always max-merges the - /// timestamp into `channelSenderOrder` so older messages contribute to - /// recency ranking; inserts a synthetic contact only when the sender is - /// neither a known contact nor already tracked. - func addChannelSenderIfNew(_ name: String, radioID: UUID, timestamp: UInt32) { + for message in messages { + if let name = message.senderNodeName { let trimmed = name.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, trimmed.count <= 128 else { return } + guard !trimmed.isEmpty, trimmed.count <= 128 else { continue } - channelSenderOrder[trimmed] = max(timestamp, channelSenderOrder[trimmed] ?? 0) + // Track latest timestamp for all senders (contacts and non-contacts) + localOrder[trimmed] = max(message.timestamp, localOrder[trimmed] ?? 0) + // Build synthetic contacts only for non-contact senders guard !contactNameSet.contains(trimmed), - !channelSenderNames.contains(trimmed) else { return } + !localNames.contains(trimmed) else { continue } - channelSenderNames.insert(trimmed) - channelSenders.append(makeSyntheticContact(name: trimmed, radioID: radioID)) + localNames.insert(trimmed) + localSenders.append(makeSyntheticContact(name: trimmed, radioID: radioID)) + } } - /// Create a synthetic ContactDTO for a channel sender not in contacts. - private func makeSyntheticContact(name: String, radioID: UUID) -> ContactDTO { - ContactDTO( - id: name.stableUUID, - radioID: radioID, - publicKey: Data(), - name: name, - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0.0, - longitude: 0.0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - } + // Assign once to minimize observation updates + channelSenderNames = localNames + channelSenders = localSenders + channelSenderOrder = localOrder + + logger.info("Built \(channelSenders.count) synthetic contacts from channel senders") + } + + /// Register a channel sender for the mention picker. Always max-merges the + /// timestamp into `channelSenderOrder` so older messages contribute to + /// recency ranking; inserts a synthetic contact only when the sender is + /// neither a known contact nor already tracked. + func addChannelSenderIfNew(_ name: String, radioID: UUID, timestamp: UInt32) { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, trimmed.count <= 128 else { return } + + channelSenderOrder[trimmed] = max(timestamp, channelSenderOrder[trimmed] ?? 0) + + guard !contactNameSet.contains(trimmed), + !channelSenderNames.contains(trimmed) else { return } + + channelSenderNames.insert(trimmed) + channelSenders.append(makeSyntheticContact(name: trimmed, radioID: radioID)) + } + + /// Create a synthetic ContactDTO for a channel sender not in contacts. + private func makeSyntheticContact(name: String, radioID: UUID) -> ContactDTO { + ContactDTO( + id: name.stableUUID, + radioID: radioID, + publicKey: Data(), + name: name, + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0.0, + longitude: 0.0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationCache.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationCache.swift index d3e56347..30bde11d 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationCache.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationCache.swift @@ -2,67 +2,70 @@ import Foundation import MC1Services extension ChatViewModel { + // MARK: - Combined Conversations - // MARK: - Combined Conversations + /// Combined conversations (contacts + channels + rooms) - favorites first + var allConversations: [Conversation] { + conversationSnapshot.favorites + conversationSnapshot.others + } - /// Combined conversations (contacts + channels + rooms) - favorites first - var allConversations: [Conversation] { - conversationSnapshot.favorites + conversationSnapshot.others - } + /// Favorite conversations sorted by last message date + var favoriteConversations: [Conversation] { + conversationSnapshot.favorites + } - /// Favorite conversations sorted by last message date - var favoriteConversations: [Conversation] { conversationSnapshot.favorites } + /// Non-favorite conversations sorted by last message date + var nonFavoriteConversations: [Conversation] { + conversationSnapshot.others + } - /// Non-favorite conversations sorted by last message date - var nonFavoriteConversations: [Conversation] { conversationSnapshot.others } + // MARK: - Snapshot Recompute - // MARK: - Snapshot Recompute + /// Fallback date for conversations with no messages, used to sort them to the end. + static let noMessageSentinel = Date.distantPast - /// Fallback date for conversations with no messages, used to sort them to the end. - static let noMessageSentinel = Date.distantPast + /// Recomputes the observed snapshot from the fetch buffers in one synchronous pass, + /// excluding `pendingRemovalIDs` so a just-deleted row stays hidden across a racing reload. + /// Never wraps the assignment in `withAnimation`; only `removeConversation`/`restoreConversation` + /// do, so a delete animates once while reload-driven recomputes stay unanimated. + func recomputeSnapshot() { + let contactConversations = conversations + .filter { $0.type != .repeater && !$0.isBlocked && !pendingRemovalIDs.contains($0.id) } + .map { Conversation.direct($0) } + let channelConversations = channels + .filter { (!$0.name.isEmpty || $0.hasSecret) && !pendingRemovalIDs.contains($0.id) } + .map { Conversation.channel($0) } + let roomConversations = roomSessions + .filter { !pendingRemovalIDs.contains($0.id) } + .map { Conversation.room($0) } + let all = contactConversations + channelConversations + roomConversations - /// Recomputes the observed snapshot from the fetch buffers in one synchronous pass, - /// excluding `pendingRemovalIDs` so a just-deleted row stays hidden across a racing reload. - /// Never wraps the assignment in `withAnimation`; only `removeConversation`/`restoreConversation` - /// do, so a delete animates once while reload-driven recomputes stay unanimated. - func recomputeSnapshot() { - let contactConversations = conversations - .filter { $0.type != .repeater && !$0.isBlocked && !pendingRemovalIDs.contains($0.id) } - .map { Conversation.direct($0) } - let channelConversations = channels - .filter { (!$0.name.isEmpty || $0.hasSecret) && !pendingRemovalIDs.contains($0.id) } - .map { Conversation.channel($0) } - let roomConversations = roomSessions - .filter { !pendingRemovalIDs.contains($0.id) } - .map { Conversation.room($0) } - let all = contactConversations + channelConversations + roomConversations + let newSnapshot = ConversationSnapshot( + favorites: sortedByLastMessage(all.filter(\.isFavorite)), + others: sortedByLastMessage(all.filter { !$0.isFavorite }) + ) - let newSnapshot = ConversationSnapshot( - favorites: sortedByLastMessage(all.filter { $0.isFavorite }), - others: sortedByLastMessage(all.filter { !$0.isFavorite }) - ) + // Skip republishing an identical snapshot to avoid a needless re-diff. Safe because + // pendingRemovalIDs masks a deleted row, so a stale reload recomputes this same snapshot + // rather than a row-present one that would slip past the guard. + guard newSnapshot != conversationSnapshot else { return } - // Skip republishing an identical snapshot to avoid a needless re-diff. Safe because - // pendingRemovalIDs masks a deleted row, so a stale reload recomputes this same snapshot - // rather than a row-present one that would slip past the guard. - guard newSnapshot != conversationSnapshot else { return } + conversationSnapshot = newSnapshot + snapshotGeneration &+= 1 + } - conversationSnapshot = newSnapshot - snapshotGeneration &+= 1 - } + /// Drops pending ids the fresh fetch confirms are gone. Run at the top of every + /// reload commit so a confirmed deletion self-heals and the set can't leak. + func reconcilePendingRemovals() { + guard !pendingRemovalIDs.isEmpty else { return } + let presentIDs = Set(conversations.map(\.id)) + .union(channels.map(\.id)) + .union(roomSessions.map(\.id)) + pendingRemovalIDs.formIntersection(presentIDs) + } - /// Drops pending ids the fresh fetch confirms are gone. Run at the top of every - /// reload commit so a confirmed deletion self-heals and the set can't leak. - func reconcilePendingRemovals() { - guard !pendingRemovalIDs.isEmpty else { return } - let presentIDs = Set(conversations.map(\.id)) - .union(channels.map(\.id)) - .union(roomSessions.map(\.id)) - pendingRemovalIDs.formIntersection(presentIDs) - } - - /// Sorts conversations by last message date, most recent first. - func sortedByLastMessage(_ items: [Conversation]) -> [Conversation] { - items.sorted { ($0.lastMessageDate ?? Self.noMessageSentinel) > ($1.lastMessageDate ?? Self.noMessageSentinel) } - } + /// Sorts conversations by last message date, most recent first. + func sortedByLastMessage(_ items: [Conversation]) -> [Conversation] { + items.sorted { ($0.lastMessageDate ?? Self.noMessageSentinel) > ($1.lastMessageDate ?? Self.noMessageSentinel) } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift index a74962bf..011c31d4 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift @@ -2,169 +2,168 @@ import Foundation import MC1Services extension ChatViewModel { - - // MARK: - Display Items - - /// Optimistically append a message if not already present. Called from - /// the incoming admission path after the receive-time prefetch resolves - /// or hits its timeout, and from the outgoing send paths immediately - /// after `createPendingMessage`. Preserves unread-counter math via the - /// `newItems.count` delta in `ChatTableView.updateItems`. - func appendMessageIfNew(_ message: MessageDTO) { - guard let coordinator else { return } - let previous = messages.last - - // Synchronous append: coordinator append, render item insertion, and - // channel sender bookkeeping all mutate Observable state on the main - // actor in one call frame, so SwiftUI already invalidates dependent - // views once per change cycle without an explicit transaction. - guard coordinator.append(message) else { return } - let newItem = makeItem(for: message, previous: previous) - coordinator.appendRenderItem(newItem) - if let senderName = message.senderNodeName, - let radioID = currentChannel?.radioID { - addChannelSenderIfNew(senderName, radioID: radioID, timestamp: message.timestamp) - } - - // URL detection writes `cachedURLs[messageID]` from a background task - // and lands as its own invalidation cycle. - let messageID = message.id - let text = message.text - let generation = urlDetectionGeneration - Task { [weak self] in - guard let self else { return } - await self.updateURLForDisplayItem(messageID: messageID, text: text, generation: generation) - } + // MARK: - Display Items + + /// Optimistically append a message if not already present. Called from + /// the incoming admission path after the receive-time prefetch resolves + /// or hits its timeout, and from the outgoing send paths immediately + /// after `createPendingMessage`. Preserves unread-counter math via the + /// `newItems.count` delta in `ChatTableView.updateItems`. + func appendMessageIfNew(_ message: MessageDTO) { + guard let coordinator else { return } + let previous = messages.last + + // Synchronous append: coordinator append, render item insertion, and + // channel sender bookkeeping all mutate Observable state on the main + // actor in one call frame, so SwiftUI already invalidates dependent + // views once per change cycle without an explicit transaction. + guard coordinator.append(message) else { return } + let newItem = makeItem(for: message, previous: previous) + coordinator.appendRenderItem(newItem) + if let senderName = message.senderNodeName, + let radioID = currentChannel?.radioID { + addChannelSenderIfNew(senderName, radioID: radioID, timestamp: message.timestamp) } - /// Update URL detection for a single message by ID. - /// Uses O(1) dictionary lookup to handle concurrent array modifications. - private func updateURLForDisplayItem(messageID: UUID, text: String, generation: UInt64) async { - let detectedURL = await Task.detached(priority: .userInitiated) { - LinkPreviewService.extractFirstURL(from: text) - }.value - - // Drop stale writes after a buildItems rebuild — Task.cancel only kills the latest chain link. - // Single-row rebuilds via rebuildDisplayItem(for:) do not write cachedURLs and need no - // generation gating; only this URL-detection writer does. - guard urlDetectionGeneration == generation else { return } - - // Gate every per-VM write on the message still being present so a - // delete between detection-start and detection-end can never seed - // orphan state that no cleanup path purges before conversation - // switch. - guard let coordinator, - let message = coordinator.messagesByID[messageID] else { - logger.warning("URL update for missing message id \(messageID)") - return - } - - cachedURLs[messageID] = detectedURL - - // Rehydrate decoded-image state from the singleton when this is a - // fresh chat-entry on a URL whose decode already completed in a - // prior session. Painting `.loaded` plus the dict entry in the - // same call frame means the next render skips the shimmer - // transition entirely. - rehydrateInlineImageStateIfCached(messageID: messageID, url: detectedURL) - rehydratePreviewStateIfCached(messageID: messageID, url: detectedURL) - - let previous = previousMessage(for: messageID) - coordinator.updateRenderItem(id: messageID) { _ in - makeItem(for: message, previous: previous) - } + // URL detection writes `cachedURLs[messageID]` from a background task + // and lands as its own invalidation cycle. + let messageID = message.id + let text = message.text + let generation = urlDetectionGeneration + Task { [weak self] in + guard let self else { return } + await updateURLForDisplayItem(messageID: messageID, text: text, generation: generation) } - - /// Seed `decodedImages` / `imageIsGIF` / `previewStates = .loaded` - /// atomically when the singleton has a decoded image for this URL. - /// Also restores raw bytes into `loadedImageData` for static images so - /// the full-screen viewer and share sheet (which need original - /// resolution and `Data`) keep working post-rehydration. Idempotent - /// and a no-op for non-image URLs, the inline-image AppStorage toggle - /// being off, or a per-VM state that has already advanced past `.idle`. - private func rehydrateInlineImageStateIfCached(messageID: UUID, url: URL?) { - guard envInputs.showInlineImages, - let url, - ImageURLClassifier.isImageURL(url) else { return } - let existingState = previewStates[messageID] - guard existingState == nil || existingState == .idle else { return } - let directURL = ImageURLClassifier.directImageURL(for: url) - guard let cached = InlineImageCache.shared.decoded(for: directURL) else { return } - applyDecodedImage(cached, for: messageID) + } + + /// Update URL detection for a single message by ID. + /// Uses O(1) dictionary lookup to handle concurrent array modifications. + private func updateURLForDisplayItem(messageID: UUID, text: String, generation: UInt64) async { + let detectedURL = await Task.detached(priority: .userInitiated) { + LinkPreviewService.extractFirstURL(from: text) + }.value + + // Drop stale writes after a buildItems rebuild — Task.cancel only kills the latest chain link. + // Single-row rebuilds via rebuildDisplayItem(for:) do not write cachedURLs and need no + // generation gating; only this URL-detection writer does. + guard urlDetectionGeneration == generation else { return } + + // Gate every per-VM write on the message still being present so a + // delete between detection-start and detection-end can never seed + // orphan state that no cleanup path purges before conversation + // switch. + guard let coordinator, + let message = coordinator.messagesByID[messageID] else { + logger.warning("URL update for missing message id \(messageID)") + return } - /// Seed `loadedPreviews` / `decodedPreviewAssets` / `previewStates = .loaded` - /// atomically when `DecodedPreviewCache` already holds a decoded card for - /// this URL. Painting `.loaded` in the same call frame as URL detection - /// means the bubble skips the loading shimmer on chat re-entry. Idempotent - /// and a no-op once state has advanced past `.idle`; image URLs are handled - /// by `rehydrateInlineImageStateIfCached` and have no preview entry here. - private func rehydratePreviewStateIfCached(messageID: UUID, url: URL?) { - guard let url else { return } - let existingState = previewStates[messageID] - guard existingState == nil || existingState == .idle else { return } - guard let cached = DecodedPreviewCache.shared.decoded(for: url) else { return } - loadedPreviews[messageID] = cached.dto - decodedPreviewAssets[messageID] = DecodedPreviewAssets(image: cached.hero, icon: cached.icon) - previewStates[messageID] = .loaded - } + cachedURLs[messageID] = detectedURL - /// Build MessageItems with pre-computed properties. Snapshots view-model - /// state on the main actor and delegates the per-message builder loop to - /// `ChatCoordinator.rebuildItems`, which performs the off-actor hop and - /// applies on main only when the captured `renderStateID` still matches. - func buildItems() { - guard let coordinator else { return } - urlDetectionGeneration &+= 1 - let urlGeneration = urlDetectionGeneration - - // Drop stale entries from the previous build before `makeBuildInputs` - // re-inserts. Theme toggle and offline-state flip both rebuild items - // under a new request key for the same message; without this, the old - // key's bucket lingers and a late resolution could rebuild a row whose - // current request key has changed. - mapPreviewRequestIndex.removeAll() - - var uncachedMessageIDs: [(UUID, String)] = [] - let messagesSnapshot = coordinator.messages - - let inputs: [(MessageDTO, MessageBuildInputs)] = messagesSnapshot.enumerated().map { index, message in - let previous: MessageDTO? = index > 0 ? messagesSnapshot[index - 1] : nil - - if cachedURLs[message.id] == nil - && previewStates[message.id] == nil - && loadedPreviews[message.id] == nil { - uncachedMessageIDs.append((message.id, message.text)) - } - - return (message, makeBuildInputs(for: message, previous: previous)) - } + // Rehydrate decoded-image state from the singleton when this is a + // fresh chat-entry on a URL whose decode already completed in a + // prior session. Painting `.loaded` plus the dict entry in the + // same call frame means the next render skips the shimmer + // transition entirely. + rehydrateInlineImageStateIfCached(messageID: messageID, url: detectedURL) + rehydratePreviewStateIfCached(messageID: messageID, url: detectedURL) - let messagesToDetect = uncachedMessageIDs - - coordinator.rebuildItems(inputs: inputs, envInputs: envInputs) { [weak self] in - guard let self else { return } - if !messagesToDetect.isEmpty { - self.urlDetectionTask?.cancel() - self.urlDetectionTask = Task { [weak self] in - guard let self else { return } - for (messageID, text) in messagesToDetect { - guard !Task.isCancelled, self.urlDetectionGeneration == urlGeneration else { return } - await self.updateURLForDisplayItem(messageID: messageID, text: text, generation: urlGeneration) - } - } - } - self.decodeLegacyPreviewImages() - } + let previous = previousMessage(for: messageID) + coordinator.updateRenderItem(id: messageID) { _ in + makeItem(for: message, previous: previous) + } + } + + /// Seed `decodedImages` / `imageIsGIF` / `previewStates = .loaded` + /// atomically when the singleton has a decoded image for this URL. + /// Also restores raw bytes into `loadedImageData` for static images so + /// the full-screen viewer and share sheet (which need original + /// resolution and `Data`) keep working post-rehydration. Idempotent + /// and a no-op for non-image URLs, the inline-image AppStorage toggle + /// being off, or a per-VM state that has already advanced past `.idle`. + private func rehydrateInlineImageStateIfCached(messageID: UUID, url: URL?) { + guard envInputs.showInlineImages, + let url, + ImageURLClassifier.isImageURL(url) else { return } + let existingState = previewStates[messageID] + guard existingState == nil || existingState == .idle else { return } + let directURL = ImageURLClassifier.directImageURL(for: url) + guard let cached = InlineImageCache.shared.decoded(for: directURL) else { return } + applyDecodedImage(cached, for: messageID) + } + + /// Seed `loadedPreviews` / `decodedPreviewAssets` / `previewStates = .loaded` + /// atomically when `DecodedPreviewCache` already holds a decoded card for + /// this URL. Painting `.loaded` in the same call frame as URL detection + /// means the bubble skips the loading shimmer on chat re-entry. Idempotent + /// and a no-op once state has advanced past `.idle`; image URLs are handled + /// by `rehydrateInlineImageStateIfCached` and have no preview entry here. + private func rehydratePreviewStateIfCached(messageID: UUID, url: URL?) { + guard let url else { return } + let existingState = previewStates[messageID] + guard existingState == nil || existingState == .idle else { return } + guard let cached = DecodedPreviewCache.shared.decoded(for: url) else { return } + loadedPreviews[messageID] = cached.dto + decodedPreviewAssets[messageID] = DecodedPreviewAssets(image: cached.hero, icon: cached.icon) + previewStates[messageID] = .loaded + } + + /// Build MessageItems with pre-computed properties. Snapshots view-model + /// state on the main actor and delegates the per-message builder loop to + /// `ChatCoordinator.rebuildItems`, which performs the off-actor hop and + /// applies on main only when the captured `renderStateID` still matches. + func buildItems() { + guard let coordinator else { return } + urlDetectionGeneration &+= 1 + let urlGeneration = urlDetectionGeneration + + // Drop stale entries from the previous build before `makeBuildInputs` + // re-inserts. Theme toggle and offline-state flip both rebuild items + // under a new request key for the same message; without this, the old + // key's bucket lingers and a late resolution could rebuild a row whose + // current request key has changed. + mapPreviewRequestIndex.removeAll() + + var uncachedMessageIDs: [(UUID, String)] = [] + let messagesSnapshot = coordinator.messages + + let inputs: [(MessageDTO, MessageBuildInputs)] = messagesSnapshot.enumerated().map { index, message in + let previous: MessageDTO? = index > 0 ? messagesSnapshot[index - 1] : nil + + if cachedURLs[message.id] == nil, + previewStates[message.id] == nil, + loadedPreviews[message.id] == nil { + uncachedMessageIDs.append((message.id, message.text)) + } + + return (message, makeBuildInputs(for: message, previous: previous)) } - /// Get full message DTO for a MessageItem. - /// Logs a warning if lookup fails (indicates data inconsistency). - func message(for item: MessageItem) -> MessageDTO? { - guard let message = messagesByID[item.id] else { - logger.warning("Message lookup failed for item id=\(item.id)") - return nil + let messagesToDetect = uncachedMessageIDs + + coordinator.rebuildItems(inputs: inputs, envInputs: envInputs) { [weak self] in + guard let self else { return } + if !messagesToDetect.isEmpty { + urlDetectionTask?.cancel() + urlDetectionTask = Task { [weak self] in + guard let self else { return } + for (messageID, text) in messagesToDetect { + guard !Task.isCancelled, urlDetectionGeneration == urlGeneration else { return } + await updateURLForDisplayItem(messageID: messageID, text: text, generation: urlGeneration) + } } - return message + } + decodeLegacyPreviewImages() + } + } + + /// Get full message DTO for a MessageItem. + /// Logs a warning if lookup fails (indicates data inconsistency). + func message(for item: MessageItem) -> MessageDTO? { + guard let message = messagesByID[item.id] else { + logger.warning("Message lookup failed for item id=\(item.id)") + return nil } + return message + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift index b1365599..a812c401 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift @@ -3,77 +3,76 @@ import MC1Services import SwiftUI extension ChatViewModel { + /// Fold a `MessageEvent` from `MessageEventStream` into view-model state. + /// Called on the main actor from a SwiftUI `.task` consumer in + /// `ChatConversationView`. The exhaustive switch is deliberate — a new + /// `MessageEvent` case becomes a compile error rather than a silent skip. + /// + /// The function is `async` so the incoming-message admission path can + /// await its prefetch race inline. The event stream is the canonical + /// ordering source for received messages; admitting incoming bubbles + /// via a detached `Task { ... }` would let a fast plain-text message + /// overtake a slow URL-bearing one and reorder the timeline. + func handle(_ event: MessageEvent) async { + switch event { + case let .directMessageReceived(message, contact): + guard let current = currentContact, current.id == contact.id else { return } + await admitIncomingMessage(message, isChannelMessage: false) + recordIncomingMentionIfNeeded(message) - /// Fold a `MessageEvent` from `MessageEventStream` into view-model state. - /// Called on the main actor from a SwiftUI `.task` consumer in - /// `ChatConversationView`. The exhaustive switch is deliberate — a new - /// `MessageEvent` case becomes a compile error rather than a silent skip. - /// - /// The function is `async` so the incoming-message admission path can - /// await its prefetch race inline. The event stream is the canonical - /// ordering source for received messages; admitting incoming bubbles - /// via a detached `Task { ... }` would let a fast plain-text message - /// overtake a slow URL-bearing one and reorder the timeline. - func handle(_ event: MessageEvent) async { - switch event { - case .directMessageReceived(let message, let contact): - guard let current = currentContact, current.id == contact.id else { return } - await admitIncomingMessage(message, isChannelMessage: false) - recordIncomingMentionIfNeeded(message) + case let .channelMessageReceived(message, channelIndex): + guard let channel = currentChannel, + channel.index == channelIndex, + message.radioID == channel.radioID else { return } + await admitIncomingMessage(message, isChannelMessage: true) + recordIncomingMentionIfNeeded(message) - case .channelMessageReceived(let message, let channelIndex): - guard let channel = currentChannel, - channel.index == channelIndex, - message.radioID == channel.radioID else { return } - await admitIncomingMessage(message, isChannelMessage: true) - recordIncomingMentionIfNeeded(message) + case let .messageStatusResolved(messageID, status, roundTripTime): + // Status-only resolution: apply in place so the bubble's status + // footer crossfades from "Sent" to "Delivered" rather than + // restarting on a fresh item identity. No DB fetch — the + // dispatcher writes the DB row before firing this case. + withAnimation { + coordinator?.applyStatusUpdate( + messageID: messageID, + status: status, + roundTripTime: roundTripTime + ) + } - case let .messageStatusResolved(messageID, status, roundTripTime): - // Status-only resolution: apply in place so the bubble's status - // footer crossfades from "Sent" to "Delivered" rather than - // restarting on a fresh item identity. No DB fetch — the - // dispatcher writes the DB row before firing this case. - withAnimation { - coordinator?.applyStatusUpdate( - messageID: messageID, - status: status, - roundTripTime: roundTripTime - ) - } + case let .messageRetrying(messageID, _, _): + // Payload-bearing variant routed straight to the reload chokepoint; + // not coalescer-eligible because attempt/maxAttempts are per-event. + coordinator?.enqueueReload(messageID: messageID) - case .messageRetrying(let messageID, _, _): - // Payload-bearing variant routed straight to the reload chokepoint; - // not coalescer-eligible because attempt/maxAttempts are per-event. - coordinator?.enqueueReload(messageID: messageID) + case let .messageResent(messageID), + let .messageFailed(messageID): + coordinator?.enqueueReload(messageID: messageID) - case .messageResent(let messageID), - .messageFailed(let messageID): - coordinator?.enqueueReload(messageID: messageID) + case let .heardRepeatRecorded(messageID, _), + let .reactionReceived(messageID, _): + coordinator?.enqueueReload(messageID: messageID) - case .heardRepeatRecorded(let messageID, _), - .reactionReceived(let messageID, _): - coordinator?.enqueueReload(messageID: messageID) + case let .routingChanged(contactID, _): + guard let current = currentContact, current.id == contactID else { return } + requestContactRefresh() - case .routingChanged(let contactID, _): - guard let current = currentContact, current.id == contactID else { return } - requestContactRefresh() - - case .roomMessageReceived, .roomMessageStatusUpdated, .roomMessageFailed: - // Room events go to RemoteNodes via MessageEventStream subscription - // in RoomConversationView. Enumerated explicitly so adding a new - // MessageEvent case surfaces as a non-exhaustive switch compile - // error rather than a silent skip. - break - } + case .roomMessageReceived, .roomMessageStatusUpdated, .roomMessageFailed: + // Room events go to RemoteNodes via MessageEventStream subscription + // in RoomConversationView. Enumerated explicitly so adding a new + // MessageEvent case surfaces as a non-exhaustive switch compile + // error rather than a silent skip. + break } + } - private func requestContactRefresh() { - contactRefreshSignal &+= 1 - } + private func requestContactRefresh() { + contactRefreshSignal &+= 1 + } - private func recordIncomingMentionIfNeeded(_ message: MessageDTO) { - guard message.containsSelfMention else { return } - mentionSequence &+= 1 - lastIncomingMention = MentionEvent(messageID: message.id, sequence: mentionSequence) - } + private func recordIncomingMentionIfNeeded(_ message: MessageDTO) { + guard message.containsSelfMention else { return } + mentionSequence &+= 1 + lastIncomingMention = MentionEvent(messageID: message.id, sequence: mentionSequence) + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift index 28bee13f..f468231f 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift @@ -1,182 +1,180 @@ import CoreLocation import Foundation -import SwiftUI import MC1Services +import SwiftUI extension ChatViewModel { - - // MARK: - Display Flags - - /// Time gap (in seconds) that breaks message grouping for timestamps and sender names. - static let messageGroupingGapSeconds = 300 - - /// Pre-computed display flags for a single message. - struct DisplayFlags { - let showTimestamp: Bool - let showDirectionGap: Bool - let showSenderName: Bool - let showDayDivider: Bool - } - - /// Computes all display flags in a single pass to avoid redundant message lookups. - /// Used by buildItems() for O(n) performance instead of O(3n). - static func computeDisplayFlags(for message: MessageDTO, previous: MessageDTO?) -> DisplayFlags { - guard let previous else { - return DisplayFlags(showTimestamp: true, showDirectionGap: false, showSenderName: true, showDayDivider: true) - } - - // Keys on send time (senderDate), not the sortDate sort key. Under block-at-reconnect - // a drained batch shares one sortDate, so grouping on it would collapse every in-block - // divider; send time keeps honest separators inside the block. The fetch's timestamp - // secondary key orders the block by send time, so headers stay monotonic within it. - let timeGap = abs(Int(message.senderDate.timeIntervalSince(previous.senderDate))) - - // Keys on senderDate (the value the divider and time marker display) so the boundary - // matches the times beneath it; a backlog synced in one session shares one receive - // day but still divides on real send days. - let dayChanged = !Calendar.current.isDate(message.senderDate, inSameDayAs: previous.senderDate) - - let showTimestamp = timeGap > messageGroupingGapSeconds - let showDirectionGap = message.direction != previous.direction - - let showSenderName: Bool - if message.contactID != nil || message.isOutgoing { - // UI suppresses the sender name for direct messages anyway; the branch - // keeps the channel-message logic from running with a missing senderNodeName. - showSenderName = true - } else if previous.isOutgoing || timeGap > messageGroupingGapSeconds { - showSenderName = true - } else if let currentName = message.senderNodeName, let previousName = previous.senderNodeName { - showSenderName = currentName != previousName - } else { - // No senderNodeName available on either side; show the name to be safe. - showSenderName = true - } - - return DisplayFlags(showTimestamp: showTimestamp, showDirectionGap: showDirectionGap, showSenderName: showSenderName, showDayDivider: dayChanged) - } - - // MARK: - Item Build - - /// Assemble `MessageBuildInputs` from current view-model state. Reads - /// `previewStates`, `cachedURLs`, `decodedImages`, etc. plus `envInputs` - /// (`@MainActor` state). The returned snapshot is deterministic w.r.t. the - /// inputs and `Sendable`, so it is safe to feed to the off-main builder. Has - /// one `@MainActor` side effect: it records the map-preview snapshot request - /// in `mapPreviewRequestIndex` so a late resolution can rebuild only the - /// affected rows — hence it must be called on the main actor (the batch - /// `buildItems()` path already does). - func makeBuildInputs(for message: MessageDTO, previous: MessageDTO?) -> MessageBuildInputs { - let flags = Self.computeDisplayFlags(for: message, previous: previous) - let cachedURL = cachedURLs[message.id].flatMap { $0 } - let inlineImageAspect: Double? = { - guard let cachedURL, - ImageURLClassifier.isImageURL(cachedURL), - let store = inlineImageDimensionsStore else { return nil } - let directURL = ImageURLClassifier.directImageURL(for: cachedURL) - return store.aspect(for: directURL) ?? store.aspect(for: cachedURL) - }() - - let theme = ThemeRegistry.theme(forID: envInputs.themeID) ?? .default - let identityBackgroundLuminances = theme.avatarSurfaceLuminances( - colorScheme: envInputs.isDark ? .dark : .light, - contrast: envInputs.isHighContrast ? .increased : .standard - ) - - let formatted = MessageText.buildFormattedText( - text: message.text, - isOutgoing: message.isOutgoing, - currentUserName: envInputs.currentUserName, - isHighContrast: envInputs.isHighContrast, - outgoingTextColor: theme.outgoingTextColor, - hashtagColor: theme.hashtagColor, - identityGamut: theme.identityGamut, - identityBackgroundLuminances: identityBackgroundLuminances - ) - - var isMapPreviewReady = false - // Gate the snapshot index on the privacy toggle so the index stays empty for - // users who turned thumbnails off — `MessageFragmentBuilder` makes the same - // check before appending the fragment, so the render request never fires. - if envInputs.showMapPreviews, let coordinate = formatted.mapCoordinate { - let request = MapSnapshotRequest( - latitude: coordinate.latitude, - longitude: coordinate.longitude, - isDark: envInputs.isDark, - isOffline: envInputs.isOffline - ) - mapPreviewRequestIndex[request, default: []].insert(message.id) - isMapPreviewReady = MapSnapshotStore.shared.isResolved(request) - } - - return MessageBuildInputs( - messageID: message.id, - previewState: previewStates[message.id] ?? .idle, - loadedPreview: loadedPreviews[message.id], - cachedURL: cachedURL, - hasInlineImageRef: decodedImages[message.id] != nil, - hasPreviewImageRef: decodedPreviewAssets[message.id]?.image != nil, - hasPreviewIconRef: decodedPreviewAssets[message.id]?.icon != nil, - imageIsGIF: imageIsGIF[message.id] ?? false, - inlineImageAspect: inlineImageAspect, - mapPreviewLatitude: formatted.mapCoordinate?.latitude, - mapPreviewLongitude: formatted.mapCoordinate?.longitude, - isMapPreviewReady: isMapPreviewReady, - formattedText: formatted.text, - baseColor: message.isOutgoing ? .outgoing : .incoming, - formattedPath: (envInputs.showIncomingPath && !message.isOutgoing) - ? MessagePathFormatter.format(message) - : nil, - senderResolution: senderResolutionFor(message), - showTimestamp: flags.showTimestamp, - showDirectionGap: flags.showDirectionGap, - showSenderName: flags.showSenderName, - showNewMessagesDivider: message.id == newMessagesDividerMessageID, - showDayDivider: flags.showDayDivider - ) + // MARK: - Display Flags + + /// Time gap (in seconds) that breaks message grouping for timestamps and sender names. + static let messageGroupingGapSeconds = 300 + + /// Pre-computed display flags for a single message. + struct DisplayFlags { + let showTimestamp: Bool + let showDirectionGap: Bool + let showSenderName: Bool + let showDayDivider: Bool + } + + /// Computes all display flags in a single pass to avoid redundant message lookups. + /// Used by buildItems() for O(n) performance instead of O(3n). + static func computeDisplayFlags(for message: MessageDTO, previous: MessageDTO?) -> DisplayFlags { + guard let previous else { + return DisplayFlags(showTimestamp: true, showDirectionGap: false, showSenderName: true, showDayDivider: true) } - /// Single-message convenience that pairs `makeBuildInputs` with the pure - /// `MessageFragmentBuilder`. Single-row callers (`appendMessageIfNew`, - /// `rebuildDisplayItem`, `updateURLForDisplayItem`) keep using this; the - /// batch path in `buildItems()` calls `makeBuildInputs` on main and then - /// invokes the builder off-actor with the resulting snapshot. - func makeItem(for message: MessageDTO, previous: MessageDTO?) -> MessageItem { - MessageFragmentBuilder.makeItem( - for: message, - inputs: makeBuildInputs(for: message, previous: previous), - envInputs: envInputs - ) + // Keys on send time (senderDate), not the sortDate sort key. Under block-at-reconnect + // a drained batch shares one sortDate, so grouping on it would collapse every in-block + // divider; send time keeps honest separators inside the block. The fetch's timestamp + // secondary key orders the block by send time, so headers stay monotonic within it. + let timeGap = abs(Int(message.senderDate.timeIntervalSince(previous.senderDate))) + + // Keys on senderDate (the value the divider and time marker display) so the boundary + // matches the times beneath it; a backlog synced in one session shares one receive + // day but still divides on real send days. + let dayChanged = !Calendar.current.isDate(message.senderDate, inSameDayAs: previous.senderDate) + + let showTimestamp = timeGap > messageGroupingGapSeconds + let showDirectionGap = message.direction != previous.direction + + let showSenderName: Bool = if message.contactID != nil || message.isOutgoing { + // UI suppresses the sender name for direct messages anyway; the branch + // keeps the channel-message logic from running with a missing senderNodeName. + true + } else if previous.isOutgoing || timeGap > messageGroupingGapSeconds { + true + } else if let currentName = message.senderNodeName, let previousName = previous.senderNodeName { + currentName != previousName + } else { + // No senderNodeName available on either side; show the name to be safe. + true } - /// Recover the previous message in display order from the canonical - /// `messages` array. Survives reordering side effects (e.g., - /// `reorderSameSenderClusters`) because it reads the current array at - /// call time, not an item-index snapshot. - func previousMessage(for messageID: UUID) -> MessageDTO? { - guard let index = messages.firstIndex(where: { $0.id == messageID }), - index > 0 else { return nil } - return messages[index - 1] + return DisplayFlags(showTimestamp: showTimestamp, showDirectionGap: showDirectionGap, showSenderName: showSenderName, showDayDivider: dayChanged) + } + + // MARK: - Item Build + + /// Assemble `MessageBuildInputs` from current view-model state. Reads + /// `previewStates`, `cachedURLs`, `decodedImages`, etc. plus `envInputs` + /// (`@MainActor` state). The returned snapshot is deterministic w.r.t. the + /// inputs and `Sendable`, so it is safe to feed to the off-main builder. Has + /// one `@MainActor` side effect: it records the map-preview snapshot request + /// in `mapPreviewRequestIndex` so a late resolution can rebuild only the + /// affected rows — hence it must be called on the main actor (the batch + /// `buildItems()` path already does). + func makeBuildInputs(for message: MessageDTO, previous: MessageDTO?) -> MessageBuildInputs { + let flags = Self.computeDisplayFlags(for: message, previous: previous) + let cachedURL = cachedURLs[message.id].flatMap(\.self) + let inlineImageAspect: Double? = { + guard let cachedURL, + ImageURLClassifier.isImageURL(cachedURL), + let store = inlineImageDimensionsStore else { return nil } + let directURL = ImageURLClassifier.directImageURL(for: cachedURL) + return store.aspect(for: directURL) ?? store.aspect(for: cachedURL) + }() + + let theme = ThemeRegistry.theme(forID: envInputs.themeID) ?? .default + let identityBackgroundLuminances = theme.avatarSurfaceLuminances( + colorScheme: envInputs.isDark ? .dark : .light, + contrast: envInputs.isHighContrast ? .increased : .standard + ) + + let formatted = MessageText.buildFormattedText( + text: message.text, + isOutgoing: message.isOutgoing, + currentUserName: envInputs.currentUserName, + isHighContrast: envInputs.isHighContrast, + outgoingTextColor: theme.outgoingTextColor, + hashtagColor: theme.hashtagColor, + identityGamut: theme.identityGamut, + identityBackgroundLuminances: identityBackgroundLuminances + ) + + var isMapPreviewReady = false + // Gate the snapshot index on the privacy toggle so the index stays empty for + // users who turned thumbnails off — `MessageFragmentBuilder` makes the same + // check before appending the fragment, so the render request never fires. + if envInputs.showMapPreviews, let coordinate = formatted.mapCoordinate { + let request = MapSnapshotRequest( + latitude: coordinate.latitude, + longitude: coordinate.longitude, + isDark: envInputs.isDark, + isOffline: envInputs.isOffline + ) + mapPreviewRequestIndex[request, default: []].insert(message.id) + isMapPreviewReady = MapSnapshotStore.shared.isResolved(request) } - /// Resolve a sender display name for a message. Channels run the - /// contact-aware resolver; DMs fall back to the unknown sentinel - /// because DM bubbles never display the sender row. - /// - /// Dispatch is keyed on `message.channelIndex` rather than the view - /// model's `currentChannel`: `currentChannel` is nil during early - /// rebuilds, so keying off it would mis-route channel rows to the - /// DM path and bake the unknown sentinel into cached items. - func senderResolutionFor(_ message: MessageDTO) -> NodeNameResolution { - if message.channelIndex != nil { - return MessageBubbleConfiguration.resolveSenderName( - for: message, - contacts: allContacts - ) - } - return NodeNameResolution( - displayName: L10n.Chats.Chats.Message.Sender.unknown, - matchKind: .unresolved - ) + return MessageBuildInputs( + messageID: message.id, + previewState: previewStates[message.id] ?? .idle, + loadedPreview: loadedPreviews[message.id], + cachedURL: cachedURL, + hasInlineImageRef: decodedImages[message.id] != nil, + hasPreviewImageRef: decodedPreviewAssets[message.id]?.image != nil, + hasPreviewIconRef: decodedPreviewAssets[message.id]?.icon != nil, + imageIsGIF: imageIsGIF[message.id] ?? false, + inlineImageAspect: inlineImageAspect, + mapPreviewLatitude: formatted.mapCoordinate?.latitude, + mapPreviewLongitude: formatted.mapCoordinate?.longitude, + isMapPreviewReady: isMapPreviewReady, + formattedText: formatted.text, + baseColor: message.isOutgoing ? .outgoing : .incoming, + formattedPath: (envInputs.showIncomingPath && !message.isOutgoing) + ? MessagePathFormatter.format(message) + : nil, + senderResolution: senderResolutionFor(message), + showTimestamp: flags.showTimestamp, + showDirectionGap: flags.showDirectionGap, + showSenderName: flags.showSenderName, + showNewMessagesDivider: message.id == newMessagesDividerMessageID, + showDayDivider: flags.showDayDivider + ) + } + + /// Single-message convenience that pairs `makeBuildInputs` with the pure + /// `MessageFragmentBuilder`. Single-row callers (`appendMessageIfNew`, + /// `rebuildDisplayItem`, `updateURLForDisplayItem`) keep using this; the + /// batch path in `buildItems()` calls `makeBuildInputs` on main and then + /// invokes the builder off-actor with the resulting snapshot. + func makeItem(for message: MessageDTO, previous: MessageDTO?) -> MessageItem { + MessageFragmentBuilder.makeItem( + for: message, + inputs: makeBuildInputs(for: message, previous: previous), + envInputs: envInputs + ) + } + + /// Recover the previous message in display order from the canonical + /// `messages` array. Survives reordering side effects (e.g., + /// `reorderSameSenderClusters`) because it reads the current array at + /// call time, not an item-index snapshot. + func previousMessage(for messageID: UUID) -> MessageDTO? { + guard let index = messages.firstIndex(where: { $0.id == messageID }), + index > 0 else { return nil } + return messages[index - 1] + } + + /// Resolve a sender display name for a message. Channels run the + /// contact-aware resolver; DMs fall back to the unknown sentinel + /// because DM bubbles never display the sender row. + /// + /// Dispatch is keyed on `message.channelIndex` rather than the view + /// model's `currentChannel`: `currentChannel` is nil during early + /// rebuilds, so keying off it would mis-route channel rows to the + /// DM path and bake the unknown sentinel into cached items. + func senderResolutionFor(_ message: MessageDTO) -> NodeNameResolution { + if message.channelIndex != nil { + return MessageBubbleConfiguration.resolveSenderName( + for: message, + contacts: allContacts + ) } + return NodeNameResolution( + displayName: L10n.Chats.Chats.Message.Sender.unknown, + matchKind: .unresolved + ) + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift index 6fcfef6b..64b7998e 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift @@ -1,164 +1,163 @@ -import SwiftUI import MC1Services +import SwiftUI extension ChatViewModel { - - // MARK: - Message Actions - - /// Retry sending a failed direct message through the persistent send - /// queue. Replaces the prior multi-await sequence (delete + - /// status flip + direct service call) with a single - /// `PersistenceStore.replacePendingSendForRetry` transaction followed - /// by a no-persist `signalDMEnqueued`. The queue drain owns - /// transport-open parking and process-restart durability, so a - /// disconnect or app death mid-retry now replays from the persisted - /// `PendingSend` row on the next hydrate. - func retryMessage(_ message: MessageDTO) async { - logger.info("retryMessage called for message: \(message.id)") - - guard !retryInFlight else { return } - retryInFlight = true - defer { retryInFlight = false } - - guard let contact = currentContact, - let dataStore else { - logger.warning("retryMessage: missing currentContact or dataStore") - return - } - - errorMessage = nil - errorBannerMessage = nil - - let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id) - let dto = PendingSendDTO(envelope: envelope, radioID: contact.radioID) - - do { - _ = try await dataStore.replacePendingSendForRetry(messageID: message.id, dto: dto) - } catch { - logger.error("retryMessage: replacePendingSendForRetry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - sendErrorMessage = Self.copyForEnqueueFailure(error) - return - } - - coordinator?.applyStatusUpdate( - messageID: message.id, - status: .pending, - userInitiated: true - ) - - do { - try await signalDMEnqueued(envelope) - } catch { - logger.error("retryMessage: signalDMEnqueued failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - sendErrorMessage = Self.copyForEnqueueFailure(error) - } + // MARK: - Message Actions + + /// Retry sending a failed direct message through the persistent send + /// queue. Replaces the prior multi-await sequence (delete + + /// status flip + direct service call) with a single + /// `PersistenceStore.replacePendingSendForRetry` transaction followed + /// by a no-persist `signalDMEnqueued`. The queue drain owns + /// transport-open parking and process-restart durability, so a + /// disconnect or app death mid-retry now replays from the persisted + /// `PendingSend` row on the next hydrate. + func retryMessage(_ message: MessageDTO) async { + logger.info("retryMessage called for message: \(message.id)") + + guard !retryInFlight else { return } + retryInFlight = true + defer { retryInFlight = false } + + guard let contact = currentContact, + let dataStore else { + logger.warning("retryMessage: missing currentContact or dataStore") + return } - /// Resend a channel message in place, or copy text for direct messages. - /// Used for "Send Again" context menu action. - func sendAgain(_ message: MessageDTO) async { - if let channelIndex = message.channelIndex { - // Channel messages: enqueue with isResend: true so the queue drain - // refreshes the mesh timestamp via resendChannelMessage. Reusing the - // original timestamp would hash identically to the failed packet and - // be dropped by the 128-slot cyclic dedup table at every neighbor. - let envelope = ChannelMessageEnvelope( - messageID: message.id, - channelIndex: channelIndex, - isResend: true, - messageText: message.text, - messageTimestamp: message.timestamp, - localNodeName: connectedDeviceProvider()?.nodeName - ) - do { - try await enqueueChannel(envelope) - } catch { - logger.error("enqueueChannel sendAgain failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) - } - } else { - // Identity-preserving DM resend. Mirrors retryMessage: route through - // the shared PersistenceStore.replacePendingSendForRetry transaction - // so delete-existing-PendingSend + status flip + new-PendingSend - // insert land atomically, then signal the queue. retryMessage and - // sendAgain intentionally remain two separate functions: retryMessage - // debounces via retryInFlight, sendAgain via actions-sheet dismissal — - // extracting a shared body would lose that distinction. - guard let contact = currentContact, - let dataStore else { - logger.warning("sendAgain (DM): missing currentContact or dataStore") - return - } - - let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id, isResend: true) - let dto = PendingSendDTO(envelope: envelope, radioID: contact.radioID) - - do { - _ = try await dataStore.replacePendingSendForRetry(messageID: message.id, dto: dto) - } catch { - logger.error("sendAgain (DM): replacePendingSendForRetry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - sendErrorMessage = Self.copyForEnqueueFailure(error) - return - } - - coordinator?.applyStatusUpdate( - messageID: message.id, - status: .pending, - userInitiated: true - ) - - do { - try await signalDMEnqueued(envelope) - } catch { - logger.error("sendAgain (DM): signalDMEnqueued failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - sendErrorMessage = Self.copyForEnqueueFailure(error) - } - } - } + errorMessage = nil + errorBannerMessage = nil + + let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id) + let dto = PendingSendDTO(envelope: envelope, radioID: contact.radioID) - /// Delete a single message - func deleteMessage(_ message: MessageDTO) async { - guard connectionStateProvider() == .ready else { return } - guard let dataStore else { return } - - do { - try await dataStore.deleteMessage(id: message.id) - - // Remove from all local collections - coordinator?.remove(messageID: message.id) - coordinator?.removeRenderItem(id: message.id) - - // Clean up preview state for deleted message - cleanupPreviewState(for: message.id) - - // Re-derive the conversation's last-message date from the database - // rather than the paginated in-memory window: older messages beyond - // the loaded page must keep the conversation visible. Always refresh - // the chat list afterward — deleting the newest message shifts the - // conversation's sort position and preview even when older messages - // remain, and deleting the last one removes it entirely. - if let currentContact { - try await dataStore.recomputeContactLastMessageDate(contactID: currentContact.id) - syncCoordinator?.notifyConversationsChanged() - } - } catch { - errorMessage = error.userFacingMessage - } + do { + _ = try await dataStore.replacePendingSendForRetry(messageID: message.id, dto: dto) + } catch { + logger.error("retryMessage: replacePendingSendForRetry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + sendErrorMessage = Self.copyForEnqueueFailure(error) + return } - /// Clears a direct conversation by dropping its messages and last-message date (a local - /// SwiftData write, no radio command). Throws `ConversationActionError.notConnected` rather - /// than returning silently so the caller can roll back the optimistic hide and surface an error. - func deleteDirectConversation(for contact: ContactDTO) async throws { - guard connectionStateProvider() == .ready, let dataStore else { - throw ConversationActionError.notConnected - } - - try await dataStore.deleteMessagesForContact(contactID: contact.id) - try await dataStore.clearUnreadCount(contactID: contact.id) - try await dataStore.updateContactLastMessage(contactID: contact.id, date: nil) - await notificationService?.updateBadgeCount() + coordinator?.applyStatusUpdate( + messageID: message.id, + status: .pending, + userInitiated: true + ) + + do { + try await signalDMEnqueued(envelope) + } catch { + logger.error("retryMessage: signalDMEnqueued failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + sendErrorMessage = Self.copyForEnqueueFailure(error) } + } + + /// Resend a channel message in place, or copy text for direct messages. + /// Used for "Send Again" context menu action. + func sendAgain(_ message: MessageDTO) async { + if let channelIndex = message.channelIndex { + // Channel messages: enqueue with isResend: true so the queue drain + // refreshes the mesh timestamp via resendChannelMessage. Reusing the + // original timestamp would hash identically to the failed packet and + // be dropped by the 128-slot cyclic dedup table at every neighbor. + let envelope = ChannelMessageEnvelope( + messageID: message.id, + channelIndex: channelIndex, + isResend: true, + messageText: message.text, + messageTimestamp: message.timestamp, + localNodeName: connectedDeviceProvider()?.nodeName + ) + do { + try await enqueueChannel(envelope) + } catch { + logger.error("enqueueChannel sendAgain failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) + coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + sendErrorMessage = Self.copyForEnqueueFailure(error) + } + } else { + // Identity-preserving DM resend. Mirrors retryMessage: route through + // the shared PersistenceStore.replacePendingSendForRetry transaction + // so delete-existing-PendingSend + status flip + new-PendingSend + // insert land atomically, then signal the queue. retryMessage and + // sendAgain intentionally remain two separate functions: retryMessage + // debounces via retryInFlight, sendAgain via actions-sheet dismissal — + // extracting a shared body would lose that distinction. + guard let contact = currentContact, + let dataStore else { + logger.warning("sendAgain (DM): missing currentContact or dataStore") + return + } + + let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id, isResend: true) + let dto = PendingSendDTO(envelope: envelope, radioID: contact.radioID) + + do { + _ = try await dataStore.replacePendingSendForRetry(messageID: message.id, dto: dto) + } catch { + logger.error("sendAgain (DM): replacePendingSendForRetry failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + sendErrorMessage = Self.copyForEnqueueFailure(error) + return + } + + coordinator?.applyStatusUpdate( + messageID: message.id, + status: .pending, + userInitiated: true + ) + + do { + try await signalDMEnqueued(envelope) + } catch { + logger.error("sendAgain (DM): signalDMEnqueued failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + sendErrorMessage = Self.copyForEnqueueFailure(error) + } + } + } + + /// Delete a single message + func deleteMessage(_ message: MessageDTO) async { + guard connectionStateProvider() == .ready else { return } + guard let dataStore else { return } + + do { + try await dataStore.deleteMessage(id: message.id) + + // Remove from all local collections + coordinator?.remove(messageID: message.id) + coordinator?.removeRenderItem(id: message.id) + + // Clean up preview state for deleted message + cleanupPreviewState(for: message.id) + + // Re-derive the conversation's last-message date from the database + // rather than the paginated in-memory window: older messages beyond + // the loaded page must keep the conversation visible. Always refresh + // the chat list afterward — deleting the newest message shifts the + // conversation's sort position and preview even when older messages + // remain, and deleting the last one removes it entirely. + if let currentContact { + try await dataStore.recomputeContactLastMessageDate(contactID: currentContact.id) + syncCoordinator?.notifyConversationsChanged() + } + } catch { + errorMessage = error.userFacingMessage + } + } + + /// Clears a direct conversation by dropping its messages and last-message date (a local + /// SwiftData write, no radio command). Throws `ConversationActionError.notConnected` rather + /// than returning silently so the caller can roll back the optimistic hide and surface an error. + func deleteDirectConversation(for contact: ContactDTO) async throws { + guard connectionStateProvider() == .ready, let dataStore else { + throw ConversationActionError.notConnected + } + + try await dataStore.deleteMessagesForContact(contactID: contact.id) + try await dataStore.clearUnreadCount(contactID: contact.id) + try await dataStore.updateContactLastMessage(contactID: contact.id, date: nil) + await notificationService?.updateBadgeCount() + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift index 387773b0..d799d53c 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift @@ -1,574 +1,572 @@ -import SwiftUI import MC1Services +import SwiftUI extension ChatViewModel { - - // MARK: - Notification Level - - /// Sets notification level for a conversation with optimistic UI update - func setNotificationLevel(_ conversation: Conversation, level: NotificationLevel) async { - guard connectionStateProvider() == .ready else { return } - let originalLevel = conversation.notificationLevel - - // Capture once so the write and the badge update target the same container. - let dataStore = self.dataStore - let notificationService = self.notificationService - - // Optimistic UI update - updateConversationNotificationLevel(conversation, level: level) - - do { - switch conversation { - case .direct(let contact): - // Contacts still use boolean muted - try await dataStore?.setContactMuted(contact.id, isMuted: level == .muted) - case .channel(let channel): - try await dataStore?.setChannelNotificationLevel(channel.id, level: level) - case .room(let session): - try await dataStore?.setSessionNotificationLevel(session.id, level: level) - } - await notificationService?.updateBadgeCount() - } catch { - // Rollback on failure - updateConversationNotificationLevel(conversation, level: originalLevel) - logger.error("Failed to set notification level: \(error)") - } + // MARK: - Notification Level + + /// Sets notification level for a conversation with optimistic UI update + func setNotificationLevel(_ conversation: Conversation, level: NotificationLevel) async { + guard connectionStateProvider() == .ready else { return } + let originalLevel = conversation.notificationLevel + + // Capture once so the write and the badge update target the same container. + let dataStore = dataStore + let notificationService = notificationService + + // Optimistic UI update + updateConversationNotificationLevel(conversation, level: level) + + do { + switch conversation { + case let .direct(contact): + // Contacts still use boolean muted + try await dataStore?.setContactMuted(contact.id, isMuted: level == .muted) + case let .channel(channel): + try await dataStore?.setChannelNotificationLevel(channel.id, level: level) + case let .room(session): + try await dataStore?.setSessionNotificationLevel(session.id, level: level) + } + await notificationService?.updateBadgeCount() + } catch { + // Rollback on failure + updateConversationNotificationLevel(conversation, level: originalLevel) + logger.error("Failed to set notification level: \(error)") } - - /// Toggles between muted and all (for swipe action) - func toggleMute(_ conversation: Conversation) async { - let newLevel: NotificationLevel = conversation.isMuted ? .all : .muted - await setNotificationLevel(conversation, level: newLevel) + } + + /// Toggles between muted and all (for swipe action) + func toggleMute(_ conversation: Conversation) async { + let newLevel: NotificationLevel = conversation.isMuted ? .all : .muted + await setNotificationLevel(conversation, level: newLevel) + } + + /// Updates the notification level in the local conversations array + private func updateConversationNotificationLevel(_ conversation: Conversation, level: NotificationLevel) { + switch conversation { + case let .direct(contact): + if let index = conversations.firstIndex(where: { $0.id == contact.id }) { + conversations[index] = conversations[index].with(isMuted: level == .muted) + } + case let .channel(channel): + if let index = channels.firstIndex(where: { $0.id == channel.id }) { + channels[index] = channels[index].with(notificationLevel: level) + } + case let .room(session): + if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { + roomSessions[index] = roomSessions[index].with(notificationLevel: level) + } } - - /// Updates the notification level in the local conversations array - private func updateConversationNotificationLevel(_ conversation: Conversation, level: NotificationLevel) { - switch conversation { - case .direct(let contact): - if let index = conversations.firstIndex(where: { $0.id == contact.id }) { - conversations[index] = conversations[index].with(isMuted: level == .muted) - } - case .channel(let channel): - if let index = channels.firstIndex(where: { $0.id == channel.id }) { - channels[index] = channels[index].with(notificationLevel: level) - } - case .room(let session): - if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { - roomSessions[index] = roomSessions[index].with(notificationLevel: level) - } - } - recomputeSnapshot() + recomputeSnapshot() + } + + // MARK: - Favorite + + /// Sets favorite state for a conversation with optimistic UI update + func setFavorite(_ conversation: Conversation, isFavorite: Bool) async { + guard connectionStateProvider() == .ready else { return } + guard conversation.isFavorite != isFavorite else { return } + + // Reuse existing toggle logic + await toggleFavorite(conversation) + } + + /// Toggles favorite state for a conversation. + /// + /// For direct messages (contacts), this pushes the change to the device and waits + /// for confirmation before updating the UI. For channels and rooms (app-only), + /// this uses optimistic updates. + /// + /// - Parameters: + /// - conversation: The conversation to toggle + /// - disableAnimation: When true, disables SwiftUI List animations to prevent + /// conflicts with swipe action dismissal animations + func toggleFavorite(_ conversation: Conversation, disableAnimation: Bool = false) async { + guard connectionStateProvider() == .ready else { return } + let originalState = conversation.isFavorite + let newState = !originalState + + switch conversation { + case let .direct(contact): + // Contacts sync with device - wait for confirmation + togglingFavoriteID = contact.id + defer { togglingFavoriteID = nil } + + do { + try await contactService?.setContactFavorite(contact.id, isFavorite: newState) + // Device confirmed - update local UI + applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) + } catch { + logger.error("Failed to toggle contact favorite: \(error)") + } + + case let .channel(channel): + // Channels are app-only - optimistic update + applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) + + do { + try await dataStore?.setChannelFavorite(channel.id, isFavorite: newState) + } catch { + // Rollback on failure + applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) + logger.error("Failed to toggle channel favorite: \(error)") + } + + case let .room(session): + // Rooms are app-only - optimistic update + applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) + + do { + try await dataStore?.setSessionFavorite(session.id, isFavorite: newState) + } catch { + // Rollback on failure + applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) + logger.error("Failed to toggle room favorite: \(error)") + } } - - // MARK: - Favorite - - /// Sets favorite state for a conversation with optimistic UI update - func setFavorite(_ conversation: Conversation, isFavorite: Bool) async { - guard connectionStateProvider() == .ready else { return } - guard conversation.isFavorite != isFavorite else { return } - - // Reuse existing toggle logic - await toggleFavorite(conversation) + } + + private func applyFavoriteUpdate(_ conversation: Conversation, isFavorite: Bool, disableAnimation: Bool) { + if disableAnimation { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + updateConversationFavoriteState(conversation, isFavorite: isFavorite) + } + } else { + updateConversationFavoriteState(conversation, isFavorite: isFavorite) } - - /// Toggles favorite state for a conversation. - /// - /// For direct messages (contacts), this pushes the change to the device and waits - /// for confirmation before updating the UI. For channels and rooms (app-only), - /// this uses optimistic updates. - /// - /// - Parameters: - /// - conversation: The conversation to toggle - /// - disableAnimation: When true, disables SwiftUI List animations to prevent - /// conflicts with swipe action dismissal animations - func toggleFavorite(_ conversation: Conversation, disableAnimation: Bool = false) async { - guard connectionStateProvider() == .ready else { return } - let originalState = conversation.isFavorite - let newState = !originalState - - switch conversation { - case .direct(let contact): - // Contacts sync with device - wait for confirmation - togglingFavoriteID = contact.id - defer { togglingFavoriteID = nil } - - do { - try await contactService?.setContactFavorite(contact.id, isFavorite: newState) - // Device confirmed - update local UI - applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) - } catch { - logger.error("Failed to toggle contact favorite: \(error)") - } - - case .channel(let channel): - // Channels are app-only - optimistic update - applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) - - do { - try await dataStore?.setChannelFavorite(channel.id, isFavorite: newState) - } catch { - // Rollback on failure - applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) - logger.error("Failed to toggle channel favorite: \(error)") - } - - case .room(let session): - // Rooms are app-only - optimistic update - applyFavoriteUpdate(conversation, isFavorite: newState, disableAnimation: disableAnimation) - - do { - try await dataStore?.setSessionFavorite(session.id, isFavorite: newState) - } catch { - // Rollback on failure - applyFavoriteUpdate(conversation, isFavorite: originalState, disableAnimation: disableAnimation) - logger.error("Failed to toggle room favorite: \(error)") - } - } + } + + /// Updates the favorite state in the local buffers. `recomputeSnapshot()` runs synchronously + /// after the mutation so it stays inside any `disablesAnimations` transaction the caller opens. + private func updateConversationFavoriteState(_ conversation: Conversation, isFavorite: Bool) { + switch conversation { + case let .direct(contact): + if let index = conversations.firstIndex(where: { $0.id == contact.id }) { + conversations[index] = conversations[index].with(isFavorite: isFavorite) + } + case let .channel(channel): + if let index = channels.firstIndex(where: { $0.id == channel.id }) { + channels[index] = channels[index].with(isFavorite: isFavorite) + } + case let .room(session): + if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { + roomSessions[index] = roomSessions[index].with(isFavorite: isFavorite) + } } - - private func applyFavoriteUpdate(_ conversation: Conversation, isFavorite: Bool, disableAnimation: Bool) { - if disableAnimation { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - updateConversationFavoriteState(conversation, isFavorite: isFavorite) - } - } else { - updateConversationFavoriteState(conversation, isFavorite: isFavorite) - } + recomputeSnapshot() + } + + // MARK: - Conversation List + + /// Clears all conversation data from the view model. + /// Called when the device is forgotten or removed so the list doesn't show stale entries. + func clearConversations() { + conversations = [] + channels = [] + roomSessions = [] + pendingRemovalIDs = [] + deletingIDs = [] + allContacts = [] + channelSenders = [] + channelSenderNames = [] + channelSenderOrder = [:] + contactNameSet = [] + lastMessageCache = [:] + recomputeSnapshot() + } + + /// True while an optimistic hide or a confirmation-gated radio delete is in flight for + /// `id`. Gates the delete action so a rapid re-tap can't double-fire the same removal. + func isDeletePending(_ id: UUID) -> Bool { + pendingRemovalIDs.contains(id) || deletingIDs.contains(id) + } + + /// Hides a conversation, recording the id in `pendingRemovalIDs` so a racing reload can't + /// resurrect it; `reconcilePendingRemovals()` drops the id once the fetch confirms it's gone. + func removeConversation(_ conversation: Conversation) { + pendingRemovalIDs.insert(conversation.id) + withAnimation(.snappy) { + switch conversation { + case let .direct(contact): + conversations = conversations.filter { $0.id != contact.id } + case let .channel(channel): + channels = channels.filter { $0.id != channel.id } + case let .room(session): + roomSessions = roomSessions.filter { $0.id != session.id } + } + recomputeSnapshot() } - - /// Updates the favorite state in the local buffers. `recomputeSnapshot()` runs synchronously - /// after the mutation so it stays inside any `disablesAnimations` transaction the caller opens. - private func updateConversationFavoriteState(_ conversation: Conversation, isFavorite: Bool) { - switch conversation { - case .direct(let contact): - if let index = conversations.firstIndex(where: { $0.id == contact.id }) { - conversations[index] = conversations[index].with(isFavorite: isFavorite) - } - case .channel(let channel): - if let index = channels.firstIndex(where: { $0.id == channel.id }) { - channels[index] = channels[index].with(isFavorite: isFavorite) - } - case .room(let session): - if let index = roomSessions.firstIndex(where: { $0.id == session.id }) { - roomSessions[index] = roomSessions[index].with(isFavorite: isFavorite) - } + } + + /// Re-admits a row after its delete failed: drops the mask and re-inserts the caller-held DTO. + /// Reusing the held DTO rather than re-fetching keeps the rollback independent of reload timing. + func restoreConversation(_ conversation: Conversation) { + pendingRemovalIDs.remove(conversation.id) + withAnimation(.snappy) { + switch conversation { + case let .direct(contact): + if !conversations.contains(where: { $0.id == contact.id }) { + conversations.append(contact) } - recomputeSnapshot() - } - - // MARK: - Conversation List - - /// Clears all conversation data from the view model. - /// Called when the device is forgotten or removed so the list doesn't show stale entries. - func clearConversations() { - conversations = [] - channels = [] - roomSessions = [] - pendingRemovalIDs = [] - deletingIDs = [] - allContacts = [] - channelSenders = [] - channelSenderNames = [] - channelSenderOrder = [:] - contactNameSet = [] - lastMessageCache = [:] - recomputeSnapshot() - } - - /// True while an optimistic hide or a confirmation-gated radio delete is in flight for - /// `id`. Gates the delete action so a rapid re-tap can't double-fire the same removal. - func isDeletePending(_ id: UUID) -> Bool { - pendingRemovalIDs.contains(id) || deletingIDs.contains(id) - } - - /// Hides a conversation, recording the id in `pendingRemovalIDs` so a racing reload can't - /// resurrect it; `reconcilePendingRemovals()` drops the id once the fetch confirms it's gone. - func removeConversation(_ conversation: Conversation) { - pendingRemovalIDs.insert(conversation.id) - withAnimation(.snappy) { - switch conversation { - case .direct(let contact): - conversations = conversations.filter { $0.id != contact.id } - case .channel(let channel): - channels = channels.filter { $0.id != channel.id } - case .room(let session): - roomSessions = roomSessions.filter { $0.id != session.id } - } - recomputeSnapshot() + case let .channel(channel): + if !channels.contains(where: { $0.id == channel.id }) { + channels.append(channel) } - } - - /// Re-admits a row after its delete failed: drops the mask and re-inserts the caller-held DTO. - /// Reusing the held DTO rather than re-fetching keeps the rollback independent of reload timing. - func restoreConversation(_ conversation: Conversation) { - pendingRemovalIDs.remove(conversation.id) - withAnimation(.snappy) { - switch conversation { - case .direct(let contact): - if !conversations.contains(where: { $0.id == contact.id }) { - conversations.append(contact) - } - case .channel(let channel): - if !channels.contains(where: { $0.id == channel.id }) { - channels.append(channel) - } - case .room(let session): - if !roomSessions.contains(where: { $0.id == session.id }) { - roomSessions.append(session) - } - } - recomputeSnapshot() + case let .room(session): + if !roomSessions.contains(where: { $0.id == session.id }) { + roomSessions.append(session) } + } + recomputeSnapshot() } - - /// Confirms a direct conversation's local clear: drops the mask and purges the fetch buffer - /// together. Purging matters because a stale reload could have re-added the contact while it - /// was masked, and a later recompute would then republish it; a re-created row still returns - /// via the next reload's fetch. - func confirmDirectRemoval(_ contact: ContactDTO) { - pendingRemovalIDs.remove(contact.id) - conversations.removeAll { $0.id == contact.id } - recomputeSnapshot() + } + + /// Confirms a direct conversation's local clear: drops the mask and purges the fetch buffer + /// together. Purging matters because a stale reload could have re-added the contact while it + /// was masked, and a later recompute would then republish it; a re-created row still returns + /// via the next reload's fetch. + func confirmDirectRemoval(_ contact: ContactDTO) { + pendingRemovalIDs.remove(contact.id) + conversations.removeAll { $0.id == contact.id } + recomputeSnapshot() + } + + /// Load conversations for a device + func loadConversations(radioID: UUID) async { + guard let dataStore else { return } + + isLoading = true + errorBannerMessage = nil + + do { + conversations = try await dataStore.fetchConversations(radioID: radioID) + recomputeSnapshot() + } catch { + errorBannerMessage = L10n.Chats.Chats.Error.loadConversationsFailed + logger.error("loadConversations failed: \(error.localizedDescription)") } - /// Load conversations for a device - func loadConversations(radioID: UUID) async { - guard let dataStore else { return } + hasLoadedOnce = true + isLoading = false + } - isLoading = true - errorBannerMessage = nil - - do { - conversations = try await dataStore.fetchConversations(radioID: radioID) - recomputeSnapshot() - } catch { - errorBannerMessage = L10n.Chats.Chats.Error.loadConversationsFailed - logger.error("loadConversations failed: \(error.localizedDescription)") - } + /// Load all contacts for mention autocomplete + func loadAllContacts(radioID: UUID) async { + guard let dataStore else { return } - hasLoadedOnce = true - isLoading = false + do { + allContacts = try await dataStore.fetchContacts(radioID: radioID) + contactNameSet = Set(allContacts.map(\.name)) + } catch { + logger.warning("Failed to load contacts for mentions: \(error.localizedDescription)") } + } - /// Load all contacts for mention autocomplete - func loadAllContacts(radioID: UUID) async { - guard let dataStore else { return } + /// Load channels for a device + func loadChannels(radioID: UUID) async { + guard let dataStore else { return } - do { - allContacts = try await dataStore.fetchContacts(radioID: radioID) - contactNameSet = Set(allContacts.map(\.name)) - } catch { - logger.warning("Failed to load contacts for mentions: \(error.localizedDescription)") - } + do { + channels = try await dataStore.fetchChannels(radioID: radioID) + recomputeSnapshot() + } catch { + // Silently handle - channels are optional } - - /// Load channels for a device - func loadChannels(radioID: UUID) async { - guard let dataStore else { return } - - do { - channels = try await dataStore.fetchChannels(radioID: radioID) - recomputeSnapshot() - } catch { - // Silently handle - channels are optional - } + } + + /// Single entry point for every list reload. Cancel-and-replaces any in-flight reload so the + /// latest request wins and a superseded one returns at an `isCancelled` gate before committing. + /// Returns the new task so a caller that needs ordering (initial load) can await `.value`. + @discardableResult + func requestConversationReload() -> Task? { + reloadTask?.cancel() + guard let radioID = currentRadioIDProvider() else { + reloadTask = nil + clearConversations() + return nil } - - /// Single entry point for every list reload. Cancel-and-replaces any in-flight reload so the - /// latest request wins and a superseded one returns at an `isCancelled` gate before committing. - /// Returns the new task so a caller that needs ordering (initial load) can await `.value`. - @discardableResult - func requestConversationReload() -> Task? { - reloadTask?.cancel() - guard let radioID = currentRadioIDProvider() else { - reloadTask = nil - clearConversations() - return nil - } - let task = Task { @MainActor [weak self] in - guard let self else { return } - await self.performConversationReload(radioID: radioID) - } - reloadTask = task - return task + let task = Task { @MainActor [weak self] in + guard let self else { return } + await performConversationReload(radioID: radioID) } - - /// Fetches contacts, channels, and rooms into locals, then commits one consistent snapshot. - /// No `await` may sit between the last `isCancelled` check and the assignment, so no other - /// reload can interleave a mismatched commit on the main actor. - private func performConversationReload(radioID: UUID) async { - guard let dataStore else { return } - isLoading = true - defer { isLoading = false } - - // Only fetchConversations sets the error banner; channel/room failures stay silent. - var banner: String? - let fetchedConversations: [ContactDTO]? - do { - fetchedConversations = try await dataStore.fetchConversations(radioID: radioID) - } catch { - fetchedConversations = nil - banner = L10n.Chats.Chats.Error.loadConversationsFailed - logger.error("performConversationReload fetchConversations failed: \(error.localizedDescription)") - } - if Task.isCancelled { return } - #if DEBUG - await reloadInterleaveHook?() - if Task.isCancelled { return } - #endif - - let fetchedChannels = try? await dataStore.fetchChannels(radioID: radioID) - if Task.isCancelled { return } - let fetchedRooms = (try? await dataStore.fetchRemoteNodeSessions(radioID: radioID))? - .filter { $0.isRoom } - if Task.isCancelled { return } - - if let fetchedConversations { conversations = fetchedConversations } - if let fetchedChannels { channels = fetchedChannels } - if let fetchedRooms { roomSessions = fetchedRooms } - errorBannerMessage = banner - reconcilePendingRemovals() - recomputeSnapshot() - hasLoadedOnce = true - - // Skip the trailing preview load if this reload was superseded. - if Task.isCancelled { return } - await loadLastMessagePreviews() + reloadTask = task + return task + } + + /// Fetches contacts, channels, and rooms into locals, then commits one consistent snapshot. + /// No `await` may sit between the last `isCancelled` check and the assignment, so no other + /// reload can interleave a mismatched commit on the main actor. + private func performConversationReload(radioID: UUID) async { + guard let dataStore else { return } + isLoading = true + defer { isLoading = false } + + // Only fetchConversations sets the error banner; channel/room failures stay silent. + var banner: String? + let fetchedConversations: [ContactDTO]? + do { + fetchedConversations = try await dataStore.fetchConversations(radioID: radioID) + } catch { + fetchedConversations = nil + banner = L10n.Chats.Chats.Error.loadConversationsFailed + logger.error("performConversationReload fetchConversations failed: \(error.localizedDescription)") } - - // MARK: - Messages - - /// Load messages for a contact - func loadMessages(for contact: ContactDTO) async { - // Close the per-conversation empty-state gate while the fetch is - // in flight. No-op when the coordinator is already past - // `.uninitialized` (warm rebind, refresh). - coordinator?.beginLoading() - - guard let dataStore else { - coordinator?.markLoaded() - return - } - - // Clear preview state only when switching to a different conversation - if currentContact?.id != contact.id { - clearPreviewState() - newMessagesDividerMessageID = nil - dividerComputed = false - } - - currentContact = contact - currentChannel = nil - - // Track active conversation for notification suppression - notificationService?.setActiveConversation(contactID: contact.id) - - isLoading = true - // Dual-reset: this function is shared between passive load and user-initiated - // retry paths, so both surfaces must clear at entry to avoid stale state. - errorMessage = nil - errorBannerMessage = nil - - // Reset pagination state for new conversation - coordinator?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } - - do { - var fetchedMessages = try await dataStore.fetchMessages(contactID: contact.id, limit: ChatCoordinator.pageSize, offset: 0) - let unfilteredCount = fetchedMessages.count - coordinator?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } - - // Compute divider position before filtering, using unfiltered array - computeDividerPosition(from: fetchedMessages, unreadCount: contact.unreadCount, isDM: true) - - // Hide sent reaction messages (unless failed) - fetchedMessages = filterOutgoingReactionMessages(fetchedMessages, isDM: true) - - // Use unfiltered count to determine if more messages exist - coordinator?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == ChatCoordinator.pageSize) } - coordinator?.replaceAll(fetchedMessages) - - buildItems() - - // Index loaded messages for reaction matching and process any pending reactions - if let reactionService = reactionServiceProvider() { - await indexMessagesForReactions( - fetchedMessages, - scope: .direct(contact), - reactionService: reactionService, - dataStore: dataStore - ) - } - - // Clear unread count and mention badge, then notify UI to refresh chat list. - // The messages already rendered, so a bookkeeping failure here is logged - // rather than surfaced as a load error. - do { - try await dataStore.clearUnreadCount(contactID: contact.id) - try await dataStore.clearUnreadMentionCount(contactID: contact.id) - } catch { - logger.warning("loadMessages: failed to clear unread counts - \(error.localizedDescription)") - } - syncCoordinator?.notifyConversationsChanged() - - // Update app badge - await notificationService?.updateBadgeCount() - } catch is CancellationError { - // Benign cancellation; the superseding load will refetch. - } catch { - errorMessage = error.userFacingMessage - } - - // Ensures the empty-state gate opens even when the fetch threw — - // `replaceAll` is the success path; this catches the failure path. - coordinator?.markLoaded() - isLoading = false + if Task.isCancelled { return } + #if DEBUG + await reloadInterleaveHook?() + if Task.isCancelled { return } + #endif + + let fetchedChannels = try? await dataStore.fetchChannels(radioID: radioID) + if Task.isCancelled { return } + let fetchedRooms = await (try? dataStore.fetchRemoteNodeSessions(radioID: radioID))? + .filter(\.isRoom) + if Task.isCancelled { return } + + if let fetchedConversations { conversations = fetchedConversations } + if let fetchedChannels { channels = fetchedChannels } + if let fetchedRooms { roomSessions = fetchedRooms } + errorBannerMessage = banner + reconcilePendingRemovals() + recomputeSnapshot() + hasLoadedOnce = true + + // Skip the trailing preview load if this reload was superseded. + if Task.isCancelled { return } + await loadLastMessagePreviews() + } + + // MARK: - Messages + + /// Load messages for a contact + func loadMessages(for contact: ContactDTO) async { + // Close the per-conversation empty-state gate while the fetch is + // in flight. No-op when the coordinator is already past + // `.uninitialized` (warm rebind, refresh). + coordinator?.beginLoading() + + guard let dataStore else { + coordinator?.markLoaded() + return } - /// Load any saved draft for the current contact - /// Drafts are consumed (removed) after loading to prevent re-display - /// If no draft exists, this method does nothing - func loadDraftIfExists() { - guard let contact = currentContact, - let notificationService, - let draft = notificationService.consumeDraft(for: contact.id) else { - return - } - composingText = draft + // Clear preview state only when switching to a different conversation + if currentContact?.id != contact.id { + clearPreviewState() + newMessagesDividerMessageID = nil + dividerComputed = false } - /// Restores the composer for `id` on conversation entry, applying restore sources in a - /// fixed priority order so the precedence can't be reversed by reordering at the call site: - /// a pending notification quick-reply draft (assigned unconditionally) wins over an older - /// persisted disk draft (applied only when the field is still empty). - func restoreComposerDraft(from store: DraftStore, id: ChatConversationID) { - loadDraftIfExists() - loadDraft(from: store, id: id) + currentContact = contact + currentChannel = nil + + // Track active conversation for notification suppression + notificationService?.setActiveConversation(contactID: contact.id) + + isLoading = true + // Dual-reset: this function is shared between passive load and user-initiated + // retry paths, so both surfaces must clear at entry to avoid stale state. + errorMessage = nil + errorBannerMessage = nil + + // Reset pagination state for new conversation + coordinator?.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } + + do { + var fetchedMessages = try await dataStore.fetchMessages(contactID: contact.id, limit: ChatCoordinator.pageSize, offset: 0) + let unfilteredCount = fetchedMessages.count + coordinator?.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } + + // Compute divider position before filtering, using unfiltered array + computeDividerPosition(from: fetchedMessages, unreadCount: contact.unreadCount, isDM: true) + + // Hide sent reaction messages (unless failed) + fetchedMessages = filterOutgoingReactionMessages(fetchedMessages, isDM: true) + + // Use unfiltered count to determine if more messages exist + coordinator?.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == ChatCoordinator.pageSize) } + coordinator?.replaceAll(fetchedMessages) + + buildItems() + + // Index loaded messages for reaction matching and process any pending reactions + if let reactionService = reactionServiceProvider() { + await indexMessagesForReactions( + fetchedMessages, + scope: .direct(contact), + reactionService: reactionService, + dataStore: dataStore + ) + } + + // Clear unread count and mention badge, then notify UI to refresh chat list. + // The messages already rendered, so a bookkeeping failure here is logged + // rather than surfaced as a load error. + do { + try await dataStore.clearUnreadCount(contactID: contact.id) + try await dataStore.clearUnreadMentionCount(contactID: contact.id) + } catch { + logger.warning("loadMessages: failed to clear unread counts - \(error.localizedDescription)") + } + syncCoordinator?.notifyConversationsChanged() + + // Update app badge + await notificationService?.updateBadgeCount() + } catch is CancellationError { + // Benign cancellation; the superseding load will refetch. + } catch { + errorMessage = error.userFacingMessage } - /// Restores the persisted composer draft for `id`, but only when the field is empty — so a - /// reconnect-driven reload can't clobber in-progress text and a quick-reply draft applied - /// first keeps precedence. The store is passed in from the view's environment, never a - /// stored dependency, so a stale configure can't silently skip the restore. - func loadDraft(from store: DraftStore, id: ChatConversationID) { - if let restored = store.draftToApply(over: composingText, for: id) { - composingText = restored - } + // Ensures the empty-state gate opens even when the fetch threw — + // `replaceAll` is the success path; this catches the failure path. + coordinator?.markLoaded() + isLoading = false + } + + /// Load any saved draft for the current contact + /// Drafts are consumed (removed) after loading to prevent re-display + /// If no draft exists, this method does nothing + func loadDraftIfExists() { + guard let contact = currentContact, + let notificationService, + let draft = notificationService.consumeDraft(for: contact.id) else { + return } - - /// Persists the current composer text as the draft for `id`, or removes it when empty. - func saveDraft(to store: DraftStore, id: ChatConversationID) { - store.setDraft(composingText, for: id) + composingText = draft + } + + /// Restores the composer for `id` on conversation entry, applying restore sources in a + /// fixed priority order so the precedence can't be reversed by reordering at the call site: + /// a pending notification quick-reply draft (assigned unconditionally) wins over an older + /// persisted disk draft (applied only when the field is still empty). + func restoreComposerDraft(from store: DraftStore, id: ChatConversationID) { + loadDraftIfExists() + loadDraft(from: store, id: id) + } + + /// Restores the persisted composer draft for `id`, but only when the field is empty — so a + /// reconnect-driven reload can't clobber in-progress text and a quick-reply draft applied + /// first keeps precedence. The store is passed in from the view's environment, never a + /// stored dependency, so a stale configure can't silently skip the restore. + func loadDraft(from store: DraftStore, id: ChatConversationID) { + if let restored = store.draftToApply(over: composingText, for: id) { + composingText = restored } - - /// Send a message to the current contact - /// This is non-blocking - message is created and shown immediately, sent in background - func sendMessage(text: String) async { - guard let contact = currentContact, - let messageService, - !text.isEmpty else { - return - } - - errorMessage = nil - - let message: MessageDTO - do { - message = try await messageService.createPendingMessage(text: text, to: contact) - appendMessageIfNew(message) - schedulePrefetchForOutgoingMessage(message, isChannelMessage: false) - syncCoordinator?.notifyConversationsChanged() - } catch { - errorMessage = error.userFacingMessage - return - } - - let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id) - do { - try await enqueueDM(envelope) - } catch { - logger.error("enqueueDM failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") - _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) - coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) - } + } + + /// Persists the current composer text as the draft for `id`, or removes it when empty. + func saveDraft(to store: DraftStore, id: ChatConversationID) { + store.setDraft(composingText, for: id) + } + + /// Send a message to the current contact + /// This is non-blocking - message is created and shown immediately, sent in background + func sendMessage(text: String) async { + guard let contact = currentContact, + let messageService, + !text.isEmpty else { + return } - /// Refresh messages for current contact - func refreshMessages() async { - guard let contact = currentContact else { return } - await loadMessages(for: contact) + errorMessage = nil + + let message: MessageDTO + do { + message = try await messageService.createPendingMessage(text: text, to: contact) + appendMessageIfNew(message) + schedulePrefetchForOutgoingMessage(message, isChannelMessage: false) + syncCoordinator?.notifyConversationsChanged() + } catch { + errorMessage = error.userFacingMessage + return } - // MARK: - Message Previews - - /// Get the cached last-message preview text for a conversation, keyed by its id. - func lastMessagePreview(id: UUID) -> String? { - lastMessageCache[id]?.text + let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contact.id) + do { + try await enqueueDM(envelope) + } catch { + logger.error("enqueueDM failed for messageID=\(message.id, privacy: .public): \(String(describing: error))") + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: message.id, status: .failed) + coordinator?.applyStatusUpdate(messageID: message.id, status: .failed) + sendErrorMessage = Self.copyForEnqueueFailure(error) } - - /// Load last message previews for all conversations. - /// Uses batch fetch methods to minimize actor hops (2 hops instead of N). - func loadLastMessagePreviews() async { - guard let dataStore else { return } - - // Batch fetch contact message previews (single actor hop) - if !conversations.isEmpty { - do { - let contactMessages = try await dataStore.fetchLastMessages(contactIDs: conversations.map(\.id), limit: 10) - for contact in conversations { - // Find the last non-reaction message (skip outgoing reactions unless failed) - let lastMessage = contactMessages[contact.id]?.last { message in - guard message.direction == .outgoing, - ReactionParser.parseDM(message.text) != nil else { - return true - } - return message.status == .failed - } - - // Evict the cached preview only when no messages remain, so a cleared DM - // (still listed via lastMessageDate) shows "No messages"; a contact whose - // recent messages are all filtered-out reactions keeps its prior preview. - if let lastMessage { - lastMessageCache[contact.id] = lastMessage - } else if contactMessages[contact.id]?.isEmpty ?? true { - lastMessageCache.removeValue(forKey: contact.id) - } - } - } catch { - logger.warning("Failed to load contact message previews: \(error)") + } + + /// Refresh messages for current contact + func refreshMessages() async { + guard let contact = currentContact else { return } + await loadMessages(for: contact) + } + + // MARK: - Message Previews + + /// Get the cached last-message preview text for a conversation, keyed by its id. + func lastMessagePreview(id: UUID) -> String? { + lastMessageCache[id]?.text + } + + /// Load last message previews for all conversations. + /// Uses batch fetch methods to minimize actor hops (2 hops instead of N). + func loadLastMessagePreviews() async { + guard let dataStore else { return } + + // Batch fetch contact message previews (single actor hop) + if !conversations.isEmpty { + do { + let contactMessages = try await dataStore.fetchLastMessages(contactIDs: conversations.map(\.id), limit: 10) + for contact in conversations { + // Find the last non-reaction message (skip outgoing reactions unless failed) + let lastMessage = contactMessages[contact.id]?.last { message in + guard message.direction == .outgoing, + ReactionParser.parseDM(message.text) != nil else { + return true } + return message.status == .failed + } + + // Evict the cached preview only when no messages remain, so a cleared DM + // (still listed via lastMessageDate) shows "No messages"; a contact whose + // recent messages are all filtered-out reactions keeps its prior preview. + if let lastMessage { + lastMessageCache[contact.id] = lastMessage + } else if contactMessages[contact.id]?.isEmpty ?? true { + lastMessageCache.removeValue(forKey: contact.id) + } } + } catch { + logger.warning("Failed to load contact message previews: \(error)") + } + } - // Batch fetch channel message previews (single actor hop) - if !channels.isEmpty { - do { - let channelParams = channels.map { (radioID: $0.radioID, channelIndex: $0.index, id: $0.id) } - let channelMessages = try await dataStore.fetchLastChannelMessages(channels: channelParams, limit: 20) - for channel in channels { - guard let messages = channelMessages[channel.id] else { continue } - - // Filter out outgoing reactions (keep failed ones visible) - let lastMessage = messages.last { message in - if message.direction == .outgoing, - ReactionParser.parse(message.text) != nil, - message.status != .failed { - return false - } - return true - } - - if let lastMessage { - lastMessageCache[channel.id] = lastMessage - } else { - lastMessageCache.removeValue(forKey: channel.id) - } - } - } catch { - logger.warning("Failed to load channel message previews: \(error)") + // Batch fetch channel message previews (single actor hop) + if !channels.isEmpty { + do { + let channelParams = channels.map { (radioID: $0.radioID, channelIndex: $0.index, id: $0.id) } + let channelMessages = try await dataStore.fetchLastChannelMessages(channels: channelParams, limit: 20) + for channel in channels { + guard let messages = channelMessages[channel.id] else { continue } + + // Filter out outgoing reactions (keep failed ones visible) + let lastMessage = messages.last { message in + if message.direction == .outgoing, + ReactionParser.parse(message.text) != nil, + message.status != .failed { + return false } + return true + } + + if let lastMessage { + lastMessageCache[channel.id] = lastMessage + } else { + lastMessageCache.removeValue(forKey: channel.id) + } } + } catch { + logger.warning("Failed to load channel message previews: \(error)") + } } - + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift index a49900e1..5722db47 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Pagination.swift @@ -1,115 +1,114 @@ -import SwiftUI import MC1Services +import SwiftUI extension ChatViewModel { - - // MARK: - Pagination - - /// Load older messages when user scrolls near the top - func loadOlderMessages() async { - // Guard against duplicate fetches and end of history - guard !isLoadingOlder, hasMoreMessages else { return } - guard let dataStore else { return } - - coordinator?.updateRenderState { $0.with(isLoadingOlder: true) } - - // Snapshot conversation context before any await — actor reentrancy - // means currentContact/currentChannel can change during suspensions - let contact = currentContact - let channel = currentChannel - - do { - let currentOffset = totalFetchedCount - var olderMessages: [MessageDTO] - - if let contact { - olderMessages = try await dataStore.fetchMessages( - contactID: contact.id, - limit: ChatCoordinator.pageSize, - offset: currentOffset - ) - } else if let channel { - olderMessages = try await dataStore.fetchMessages( - radioID: channel.radioID, - channelIndex: channel.index, - limit: ChatCoordinator.pageSize, - offset: currentOffset - ) - } else { - coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } - return - } - - // Use unfiltered count to determine if more messages exist - let unfilteredCount = olderMessages.count - coordinator?.updateRenderState { current in - current.with( - hasMoreMessages: unfilteredCount < ChatCoordinator.pageSize ? false : current.hasMoreMessages, - totalFetchedCount: current.totalFetchedCount + unfilteredCount - ) - } - - // Hide sent reaction messages (unless failed) - let isDM = contact != nil - olderMessages = filterOutgoingReactionMessages(olderMessages, isDM: isDM) - - // Filter out messages already in array (race condition: appendMessageIfNew can add - // a message while this fetch is in-flight, causing duplicates) - let existingIDs = Set(messages.map(\.id)) - olderMessages = olderMessages.filter { !existingIDs.contains($0.id) } - - // Prepend older messages (they're chronologically earlier). - // Re-run same-sender reordering across the page boundary to handle - // clusters that were split between the existing and newly loaded pages. - coordinator?.prepend(olderMessages) - let reordered = MessageDTO.reorderSameSenderClusters(messages) - coordinator?.replaceMessagesPreservingByID(reordered) - - // Register senders from the older page; without this, scrolling - // back to a sender who only appears in older pages leaves them - // missing from the @-autocomplete list. - if let channel { - for message in olderMessages { - if let senderName = message.senderNodeName { - addChannelSenderIfNew(senderName, radioID: channel.radioID, timestamp: message.timestamp) - } - } - } - - buildItems() - - // Clear the spinner now that the prepended messages are visible. - // Reaction indexing below awaits the actor and can take many - // hundreds of milliseconds on a busy channel; gating the spinner - // through it leaves pagination feeling locked-up. - coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } - - // Index older channel messages for reaction matching and process pending reactions - if let channel, - let reactionService = reactionServiceProvider() { - await indexMessagesForReactions( - olderMessages, - scope: .channel(channel, localNodeName: connectedDeviceProvider()?.nodeName), - reactionService: reactionService, - dataStore: dataStore - ) - } - - // Index older DM messages for reaction matching and process pending reactions - if let contact, - let reactionService = reactionServiceProvider() { - await indexMessagesForReactions( - olderMessages, - scope: .direct(contact), - reactionService: reactionService, - dataStore: dataStore - ) - } - - } catch { - coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } - errorBannerMessage = L10n.Chats.Chats.Error.loadOlderMessagesFailed - logger.error("Failed to load older messages: \(error)") + // MARK: - Pagination + + /// Load older messages when user scrolls near the top + func loadOlderMessages() async { + // Guard against duplicate fetches and end of history + guard !isLoadingOlder, hasMoreMessages else { return } + guard let dataStore else { return } + + coordinator?.updateRenderState { $0.with(isLoadingOlder: true) } + + // Snapshot conversation context before any await — actor reentrancy + // means currentContact/currentChannel can change during suspensions + let contact = currentContact + let channel = currentChannel + + do { + let currentOffset = totalFetchedCount + var olderMessages: [MessageDTO] + + if let contact { + olderMessages = try await dataStore.fetchMessages( + contactID: contact.id, + limit: ChatCoordinator.pageSize, + offset: currentOffset + ) + } else if let channel { + olderMessages = try await dataStore.fetchMessages( + radioID: channel.radioID, + channelIndex: channel.index, + limit: ChatCoordinator.pageSize, + offset: currentOffset + ) + } else { + coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } + return + } + + // Use unfiltered count to determine if more messages exist + let unfilteredCount = olderMessages.count + coordinator?.updateRenderState { current in + current.with( + hasMoreMessages: unfilteredCount < ChatCoordinator.pageSize ? false : current.hasMoreMessages, + totalFetchedCount: current.totalFetchedCount + unfilteredCount + ) + } + + // Hide sent reaction messages (unless failed) + let isDM = contact != nil + olderMessages = filterOutgoingReactionMessages(olderMessages, isDM: isDM) + + // Filter out messages already in array (race condition: appendMessageIfNew can add + // a message while this fetch is in-flight, causing duplicates) + let existingIDs = Set(messages.map(\.id)) + olderMessages = olderMessages.filter { !existingIDs.contains($0.id) } + + // Prepend older messages (they're chronologically earlier). + // Re-run same-sender reordering across the page boundary to handle + // clusters that were split between the existing and newly loaded pages. + coordinator?.prepend(olderMessages) + let reordered = MessageDTO.reorderSameSenderClusters(messages) + coordinator?.replaceMessagesPreservingByID(reordered) + + // Register senders from the older page; without this, scrolling + // back to a sender who only appears in older pages leaves them + // missing from the @-autocomplete list. + if let channel { + for message in olderMessages { + if let senderName = message.senderNodeName { + addChannelSenderIfNew(senderName, radioID: channel.radioID, timestamp: message.timestamp) + } } + } + + buildItems() + + // Clear the spinner now that the prepended messages are visible. + // Reaction indexing below awaits the actor and can take many + // hundreds of milliseconds on a busy channel; gating the spinner + // through it leaves pagination feeling locked-up. + coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } + + // Index older channel messages for reaction matching and process pending reactions + if let channel, + let reactionService = reactionServiceProvider() { + await indexMessagesForReactions( + olderMessages, + scope: .channel(channel, localNodeName: connectedDeviceProvider()?.nodeName), + reactionService: reactionService, + dataStore: dataStore + ) + } + + // Index older DM messages for reaction matching and process pending reactions + if let contact, + let reactionService = reactionServiceProvider() { + await indexMessagesForReactions( + olderMessages, + scope: .direct(contact), + reactionService: reactionService, + dataStore: dataStore + ) + } + + } catch { + coordinator?.updateRenderState { $0.with(isLoadingOlder: false) } + errorBannerMessage = L10n.Chats.Chats.Error.loadOlderMessagesFailed + logger.error("Failed to load older messages: \(error)") } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift index a40c56da..1bb1ff90 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Prefetch.swift @@ -1,472 +1,471 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services extension ChatViewModel { - - // MARK: - Receive-Time Prefetch - - /// Default maximum time the receive pipeline waits for a prefetch to - /// resolve before admitting the bubble at its text-only size. After - /// this, the bubble may still morph in later when the background - /// fetch lands; see `handleDimensionResolution(_:)`. - static let defaultPrefetchTimeout: Duration = .seconds(3) - - /// Admit an incoming message to the display-items array after racing - /// its URL prefetches against `prefetchTimeout` (default: 3s). - /// Messages without URLs admit immediately. Outgoing messages bypass - /// this and use the instant-render path in the send methods. - func admitIncomingMessage(_ message: MessageDTO, isChannelMessage: Bool) async { - guard let prefetcher else { - appendMessageIfNew(message) - return - } - guard !LinkPreviewService.extractAllURLs(in: message.text).isEmpty else { - appendMessageIfNew(message) - return - } - - let text = message.text - let timeout = prefetchTimeout - await withTaskGroup(of: Void.self) { group in - group.addTask { - await prefetcher.prefetch(urlsIn: text, isChannelMessage: isChannelMessage) - } - group.addTask { - try? await Task.sleep(for: timeout) - } - for await _ in group { - group.cancelAll() - break - } - } - - appendMessageIfNew(message) + // MARK: - Receive-Time Prefetch + + /// Default maximum time the receive pipeline waits for a prefetch to + /// resolve before admitting the bubble at its text-only size. After + /// this, the bubble may still morph in later when the background + /// fetch lands; see `handleDimensionResolution(_:)`. + static let defaultPrefetchTimeout: Duration = .seconds(3) + + /// Admit an incoming message to the display-items array after racing + /// its URL prefetches against `prefetchTimeout` (default: 3s). + /// Messages without URLs admit immediately. Outgoing messages bypass + /// this and use the instant-render path in the send methods. + func admitIncomingMessage(_ message: MessageDTO, isChannelMessage: Bool) async { + guard let prefetcher else { + appendMessageIfNew(message) + return } - - /// Fan-out hook for the inline image dimensions resolution stream. - /// Triggered when a probe lands after a message has already been admitted — - /// either because the receive-time prefetch hit its timeout, the user - /// retried, or the message arrived during background sync. Every message - /// whose body contains the resolved URL is rebuilt so the bubble picks up - /// the now-known `cachedAspect`. - func handleDimensionResolution(_ url: URL) async { - guard let coordinator else { return } - let target = url.absoluteString - let affected = coordinator.messages - .filter { $0.text.contains(target) } - .map(\.id) - for messageID in affected { - rebuildDisplayItem(for: messageID) - } + guard !LinkPreviewService.extractAllURLs(in: message.text).isEmpty else { + appendMessageIfNew(message) + return } - /// A snapshot finished: rebuild only the rows that show it, found via the - /// request-keyed index (O(matches)), never by scanning every loaded message. - func handleSnapshotResolution(_ request: MapSnapshotRequest) { - guard let messageIDs = mapPreviewRequestIndex[request] else { return } - for messageID in messageIDs { - rebuildDisplayItem(for: messageID) - } + let text = message.text + let timeout = prefetchTimeout + await withTaskGroup(of: Void.self) { group in + group.addTask { + await prefetcher.prefetch(urlsIn: text, isChannelMessage: isChannelMessage) + } + group.addTask { + try? await Task.sleep(for: timeout) + } + for await _ in group { + group.cancelAll() + break + } } - /// Outgoing-message exception to the withhold-and-release rule: the - /// bubble was added immediately at text-only height for instant send - /// feedback, so the prefetch runs in parallel and the cell height - /// morphs in via `rebuildDisplayItem` once the prefetch lands. The - /// `.easeOut(0.25)` cross-fade lives at the view layer. - func schedulePrefetchForOutgoingMessage(_ message: MessageDTO, isChannelMessage: Bool) { - guard let prefetcher else { return } - guard !LinkPreviewService.extractAllURLs(in: message.text).isEmpty else { return } - let messageID = message.id - let text = message.text - Task { [weak self] in - await prefetcher.prefetch(urlsIn: text, isChannelMessage: isChannelMessage) - self?.rebuildDisplayItem(for: messageID) - } + appendMessageIfNew(message) + } + + /// Fan-out hook for the inline image dimensions resolution stream. + /// Triggered when a probe lands after a message has already been admitted — + /// either because the receive-time prefetch hit its timeout, the user + /// retried, or the message arrived during background sync. Every message + /// whose body contains the resolved URL is rebuilt so the bubble picks up + /// the now-known `cachedAspect`. + func handleDimensionResolution(_ url: URL) async { + guard let coordinator else { return } + let target = url.absoluteString + let affected = coordinator.messages + .filter { $0.text.contains(target) } + .map(\.id) + for messageID in affected { + rebuildDisplayItem(for: messageID) } - - // MARK: - Preview State Management - - /// Request preview fetch for a message (called when cell becomes visible) - func requestPreviewFetch(for messageID: UUID) { - guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { return } - guard let url = cachedURLs[messageID].flatMap({ $0 }) else { return } - - let isChannel = currentChannel != nil - - previewFetchTasks[messageID] = Task { - await fetchPreview(for: messageID, url: url, isChannelMessage: isChannel) - } + } + + /// A snapshot finished: rebuild only the rows that show it, found via the + /// request-keyed index (O(matches)), never by scanning every loaded message. + func handleSnapshotResolution(_ request: MapSnapshotRequest) { + guard let messageIDs = mapPreviewRequestIndex[request] else { return } + for messageID in messageIDs { + rebuildDisplayItem(for: messageID) } + } + + /// Outgoing-message exception to the withhold-and-release rule: the + /// bubble was added immediately at text-only height for instant send + /// feedback, so the prefetch runs in parallel and the cell height + /// morphs in via `rebuildDisplayItem` once the prefetch lands. The + /// `.easeOut(0.25)` cross-fade lives at the view layer. + func schedulePrefetchForOutgoingMessage(_ message: MessageDTO, isChannelMessage: Bool) { + guard let prefetcher else { return } + guard !LinkPreviewService.extractAllURLs(in: message.text).isEmpty else { return } + let messageID = message.id + let text = message.text + Task { [weak self] in + await prefetcher.prefetch(urlsIn: text, isChannelMessage: isChannelMessage) + self?.rebuildDisplayItem(for: messageID) + } + } - /// Fetch preview for a message and update state - private func fetchPreview(for messageID: UUID, url: URL, isChannelMessage: Bool) async { - guard let dataStore, let linkPreviewCache else { return } - - // Check malware domain blocklist before fetching - if let host = url.host(), await MalwareDomainFilter.shared.isBlocked(host) { - previewStates[messageID] = .malwareWarning - rebuildDisplayItem(for: messageID) - return - } - - // Update to loading state - previewStates[messageID] = .loading - rebuildDisplayItem(for: messageID) - - // Get preview from cache (handles all tiers: memory, database, network) - let result = await linkPreviewCache.preview( - for: url, - using: dataStore, - isChannelMessage: isChannelMessage - ) - - // Check if task was cancelled (message scrolled away or conversation changed) - guard !Task.isCancelled else { - previewFetchTasks.removeValue(forKey: messageID) - return - } - - // Update state based on result - switch result { - case .loaded(let dto): - await decodeAndStorePreviewImages(from: dto, for: messageID) - previewStates[messageID] = .loaded - loadedPreviews[messageID] = dto - - case .loading: - // Still loading (duplicate request), keep current state - break + // MARK: - Preview State Management - case .noPreviewAvailable, .failed: - previewStates[messageID] = .noPreview + /// Request preview fetch for a message (called when cell becomes visible) + func requestPreviewFetch(for messageID: UUID) { + guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { return } + guard let url = cachedURLs[messageID].flatMap(\.self) else { return } - case .disabled: - previewStates[messageID] = .disabled - } + let isChannel = currentChannel != nil - previewFetchTasks.removeValue(forKey: messageID) - rebuildDisplayItem(for: messageID) + previewFetchTasks[messageID] = Task { + await fetchPreview(for: messageID, url: url, isChannelMessage: isChannel) } + } - /// Manually fetch preview (for tap-to-load when previews disabled) - func manualFetchPreview(for messageID: UUID) async { - guard let url = cachedURLs[messageID].flatMap({ $0 }), - let dataStore, - let linkPreviewCache else { return } - - previewStates[messageID] = .loading - rebuildDisplayItem(for: messageID) - - let result = await linkPreviewCache.manualFetch(for: url, using: dataStore) - - switch result { - case .loaded(let dto): - await decodeAndStorePreviewImages(from: dto, for: messageID) - previewStates[messageID] = .loaded - loadedPreviews[messageID] = dto - case .loading: - break - case .noPreviewAvailable, .failed, .disabled: - previewStates[messageID] = .noPreview - } + /// Fetch preview for a message and update state + private func fetchPreview(for messageID: UUID, url: URL, isChannelMessage: Bool) async { + guard let dataStore, let linkPreviewCache else { return } - rebuildDisplayItem(for: messageID) + // Check malware domain blocklist before fetching + if let host = url.host(), await MalwareDomainFilter.shared.isBlocked(host) { + previewStates[messageID] = .malwareWarning + rebuildDisplayItem(for: messageID) + return } - /// Decode preview hero image and icon off the main thread and store results - private func decodeAndStorePreviewImages(from dto: LinkPreviewDataDTO, for messageID: UUID) async { - async let heroResult: UIImage? = { - guard let data = dto.imageData else { return nil } - return await Task.detached { ImageURLDetector.downsampledImage(from: data) }.value - }() - async let iconResult: UIImage? = { - guard let data = dto.iconData else { return nil } - return await Task.detached { ImageURLDetector.downsampledImage(from: data) }.value - }() - let (hero, icon) = await (heroResult, iconResult) - - // Warm the process-lifetime cache before the per-VM apply so a - // chat-exit mid-decode still surfaces the card on the next visit, and a - // later re-entry repaints loaded without re-decoding. Cache every - // resolved preview, including title-only cards with no hero or icon, so - // those skip the loading shimmer on re-entry too. - if let url = URL(string: dto.url) { - DecodedPreviewCache.shared.store( - CachedDecodedPreview(dto: dto, hero: hero, icon: icon), - for: url - ) - } - - guard hero != nil || icon != nil else { return } - decodedPreviewAssets[messageID] = DecodedPreviewAssets(image: hero, icon: icon) + // Update to loading state + previewStates[messageID] = .loading + rebuildDisplayItem(for: messageID) + + // Get preview from cache (handles all tiers: memory, database, network) + let result = await linkPreviewCache.preview( + for: url, + using: dataStore, + isChannelMessage: isChannelMessage + ) + + // Check if task was cancelled (message scrolled away or conversation changed) + guard !Task.isCancelled else { + previewFetchTasks.removeValue(forKey: messageID) + return } - /// Update a message in place and rebuild its display item. - func updateMessage(id: UUID, mutation: (inout MessageDTO) -> Void) { - guard let coordinator, - coordinator.messagesByID[id] != nil else { return } - coordinator.update(messageID: id, mutation) - rebuildDisplayItem(for: id) - } + // Update state based on result + switch result { + case let .loaded(dto): + await decodeAndStorePreviewImages(from: dto, for: messageID) + previewStates[messageID] = .loaded + loadedPreviews[messageID] = dto - /// Rebuild a single MessageItem with current preview, image, and message - /// state. No-ops when the message is no longer present. - func rebuildDisplayItem(for messageID: UUID) { - guard let coordinator, - let message = coordinator.messagesByID[messageID] else { - logger.warning("rebuild requested for missing message id \(messageID)") - return - } - let previous = previousMessage(for: messageID) - coordinator.updateRenderItem(id: messageID) { _ in - makeItem(for: message, previous: previous) - } - } + case .loading: + // Still loading (duplicate request), keep current state + break - /// Cancel preview fetch for a message (called when cell scrolls away) - func cancelPreviewFetch(for messageID: UUID) { - previewFetchTasks[messageID]?.cancel() - previewFetchTasks.removeValue(forKey: messageID) - } + case .noPreviewAvailable, .failed: + previewStates[messageID] = .noPreview - /// Clear all preview state (called on conversation switch) - func clearPreviewState() { - previewFetchTasks.values.forEach { $0.cancel() } - previewFetchTasks.removeAll() - previewStates.removeAll() - loadedPreviews.removeAll() - decodedPreviewAssets.removeAll() - legacyPreviewDecodeInFlight.removeAll() - cachedURLs.removeAll() - mapPreviewRequestIndex.removeAll() - clearImageState() + case .disabled: + previewStates[messageID] = .disabled } - /// Clean up preview state for a specific message (called on message deletion) - func cleanupPreviewState(for messageID: UUID) { - previewStates.removeValue(forKey: messageID) - loadedPreviews.removeValue(forKey: messageID) - decodedPreviewAssets.removeValue(forKey: messageID) - previewFetchTasks[messageID]?.cancel() - previewFetchTasks.removeValue(forKey: messageID) - removeFromMapPreviewIndex(messageID) - cleanupImageState(for: messageID) + previewFetchTasks.removeValue(forKey: messageID) + rebuildDisplayItem(for: messageID) + } + + /// Manually fetch preview (for tap-to-load when previews disabled) + func manualFetchPreview(for messageID: UUID) async { + guard let url = cachedURLs[messageID].flatMap(\.self), + let dataStore, + let linkPreviewCache else { return } + + previewStates[messageID] = .loading + rebuildDisplayItem(for: messageID) + + let result = await linkPreviewCache.manualFetch(for: url, using: dataStore) + + switch result { + case let .loaded(dto): + await decodeAndStorePreviewImages(from: dto, for: messageID) + previewStates[messageID] = .loaded + loadedPreviews[messageID] = dto + case .loading: + break + case .noPreviewAvailable, .failed, .disabled: + previewStates[messageID] = .noPreview } - /// Drops a deleted message from every map-preview request bucket so a late - /// snapshot resolution does not try to rebuild a row that no longer exists. - private func removeFromMapPreviewIndex(_ messageID: UUID) { - for request in Array(mapPreviewRequestIndex.keys) { - guard var ids = mapPreviewRequestIndex[request], ids.remove(messageID) != nil else { continue } - if ids.isEmpty { - mapPreviewRequestIndex.removeValue(forKey: request) - } else { - mapPreviewRequestIndex[request] = ids - } - } + rebuildDisplayItem(for: messageID) + } + + /// Decode preview hero image and icon off the main thread and store results + private func decodeAndStorePreviewImages(from dto: LinkPreviewDataDTO, for messageID: UUID) async { + async let heroResult: UIImage? = { + guard let data = dto.imageData else { return nil } + return await Task.detached { ImageURLDetector.downsampledImage(from: data) }.value + }() + async let iconResult: UIImage? = { + guard let data = dto.iconData else { return nil } + return await Task.detached { ImageURLDetector.downsampledImage(from: data) }.value + }() + let (hero, icon) = await (heroResult, iconResult) + + // Warm the process-lifetime cache before the per-VM apply so a + // chat-exit mid-decode still surfaces the card on the next visit, and a + // later re-entry repaints loaded without re-decoding. Cache every + // resolved preview, including title-only cards with no hero or icon, so + // those skip the loading shimmer on re-entry too. + if let url = URL(string: dto.url) { + DecodedPreviewCache.shared.store( + CachedDecodedPreview(dto: dto, hero: hero, icon: icon), + for: url + ) } - // MARK: - Inline Image State Management - - /// Atomically seeds the per-VM image state from a decoded cache entry. - /// Restores `loadedImageData` from the entry's raw bytes when available - /// so the full-screen viewer and share sheet keep working after - /// rehydration. Does not rebuild the render item; the caller owns that - /// step. - func applyDecodedImage(_ cached: CachedDecodedImage, for messageID: UUID) { - decodedImages[messageID] = cached.image - imageIsGIF[messageID] = cached.isGIF - if let data = cached.data { - loadedImageData.setObject(data as NSData, forKey: messageID as NSUUID, cost: data.count) - } - previewStates[messageID] = .loaded + guard hero != nil || icon != nil else { return } + decodedPreviewAssets[messageID] = DecodedPreviewAssets(image: hero, icon: icon) + } + + /// Update a message in place and rebuild its display item. + func updateMessage(id: UUID, mutation: (inout MessageDTO) -> Void) { + guard let coordinator, + coordinator.messagesByID[id] != nil else { return } + coordinator.update(messageID: id, mutation) + rebuildDisplayItem(for: id) + } + + /// Rebuild a single MessageItem with current preview, image, and message + /// state. No-ops when the message is no longer present. + func rebuildDisplayItem(for messageID: UUID) { + guard let coordinator, + let message = coordinator.messagesByID[messageID] else { + logger.warning("rebuild requested for missing message id \(messageID)") + return } - - /// Returns the pre-decoded UIImage for a message, if available - func decodedImage(for messageID: UUID) -> UIImage? { - decodedImages[messageID] + let previous = previousMessage(for: messageID) + coordinator.updateRenderItem(id: messageID) { _ in + makeItem(for: message, previous: previous) } - - /// Returns the pre-decoded link preview hero image for a message - func decodedPreviewImage(for messageID: UUID) -> UIImage? { - decodedPreviewAssets[messageID]?.image + } + + /// Cancel preview fetch for a message (called when cell scrolls away) + func cancelPreviewFetch(for messageID: UUID) { + previewFetchTasks[messageID]?.cancel() + previewFetchTasks.removeValue(forKey: messageID) + } + + /// Clear all preview state (called on conversation switch) + func clearPreviewState() { + previewFetchTasks.values.forEach { $0.cancel() } + previewFetchTasks.removeAll() + previewStates.removeAll() + loadedPreviews.removeAll() + decodedPreviewAssets.removeAll() + legacyPreviewDecodeInFlight.removeAll() + cachedURLs.removeAll() + mapPreviewRequestIndex.removeAll() + clearImageState() + } + + /// Clean up preview state for a specific message (called on message deletion) + func cleanupPreviewState(for messageID: UUID) { + previewStates.removeValue(forKey: messageID) + loadedPreviews.removeValue(forKey: messageID) + decodedPreviewAssets.removeValue(forKey: messageID) + previewFetchTasks[messageID]?.cancel() + previewFetchTasks.removeValue(forKey: messageID) + removeFromMapPreviewIndex(messageID) + cleanupImageState(for: messageID) + } + + /// Drops a deleted message from every map-preview request bucket so a late + /// snapshot resolution does not try to rebuild a row that no longer exists. + private func removeFromMapPreviewIndex(_ messageID: UUID) { + for request in Array(mapPreviewRequestIndex.keys) { + guard var ids = mapPreviewRequestIndex[request], ids.remove(messageID) != nil else { continue } + if ids.isEmpty { + mapPreviewRequestIndex.removeValue(forKey: request) + } else { + mapPreviewRequestIndex[request] = ids + } } - - /// Returns the pre-decoded link preview icon for a message - func decodedPreviewIcon(for messageID: UUID) -> UIImage? { - decodedPreviewAssets[messageID]?.icon + } + + // MARK: - Inline Image State Management + + /// Atomically seeds the per-VM image state from a decoded cache entry. + /// Restores `loadedImageData` from the entry's raw bytes when available + /// so the full-screen viewer and share sheet keep working after + /// rehydration. Does not rebuild the render item; the caller owns that + /// step. + func applyDecodedImage(_ cached: CachedDecodedImage, for messageID: UUID) { + decodedImages[messageID] = cached.image + imageIsGIF[messageID] = cached.isGIF + if let data = cached.data { + loadedImageData.setObject(data as NSData, forKey: messageID as NSUUID, cost: data.count) } - - /// Pre-decode images for legacy messages with embedded preview data - func decodeLegacyPreviewImages() { - for message in messages where message.linkPreviewURL != nil { - let id = message.id - let existing = decodedPreviewAssets[id] - let needsImageDecode = message.linkPreviewImageData != nil && existing?.image == nil - let needsIconDecode = message.linkPreviewIconData != nil && existing?.icon == nil - guard needsImageDecode || needsIconDecode, - !legacyPreviewDecodeInFlight.contains(id) else { continue } - - let imageData = message.linkPreviewImageData - let iconData = message.linkPreviewIconData - - legacyPreviewDecodeInFlight.insert(id) - Task { [weak self] in - async let heroResult: UIImage? = if needsImageDecode, let imageData { - await Task.detached { ImageURLDetector.downsampledImage(from: imageData) }.value - } else { - existing?.image - } - async let iconResult: UIImage? = if needsIconDecode, let iconData { - await Task.detached { ImageURLDetector.downsampledImage(from: iconData) }.value - } else { - existing?.icon - } - let (hero, icon) = await (heroResult, iconResult) - if hero != nil || icon != nil { - self?.decodedPreviewAssets[id] = DecodedPreviewAssets(image: hero, icon: icon) - self?.rebuildDisplayItem(for: id) - } - self?.legacyPreviewDecodeInFlight.remove(id) - } + previewStates[messageID] = .loaded + } + + /// Returns the pre-decoded UIImage for a message, if available + func decodedImage(for messageID: UUID) -> UIImage? { + decodedImages[messageID] + } + + /// Returns the pre-decoded link preview hero image for a message + func decodedPreviewImage(for messageID: UUID) -> UIImage? { + decodedPreviewAssets[messageID]?.image + } + + /// Returns the pre-decoded link preview icon for a message + func decodedPreviewIcon(for messageID: UUID) -> UIImage? { + decodedPreviewAssets[messageID]?.icon + } + + /// Pre-decode images for legacy messages with embedded preview data + func decodeLegacyPreviewImages() { + for message in messages where message.linkPreviewURL != nil { + let id = message.id + let existing = decodedPreviewAssets[id] + let needsImageDecode = message.linkPreviewImageData != nil && existing?.image == nil + let needsIconDecode = message.linkPreviewIconData != nil && existing?.icon == nil + guard needsImageDecode || needsIconDecode, + !legacyPreviewDecodeInFlight.contains(id) else { continue } + + let imageData = message.linkPreviewImageData + let iconData = message.linkPreviewIconData + + legacyPreviewDecodeInFlight.insert(id) + Task { [weak self] in + async let heroResult: UIImage? = if needsImageDecode, let imageData { + await Task.detached { ImageURLDetector.downsampledImage(from: imageData) }.value + } else { + existing?.image + } + async let iconResult: UIImage? = if needsIconDecode, let iconData { + await Task.detached { ImageURLDetector.downsampledImage(from: iconData) }.value + } else { + existing?.icon } + let (hero, icon) = await (heroResult, iconResult) + if hero != nil || icon != nil { + self?.decodedPreviewAssets[id] = DecodedPreviewAssets(image: hero, icon: icon) + self?.rebuildDisplayItem(for: id) + } + self?.legacyPreviewDecodeInFlight.remove(id) + } } - - /// Returns whether the image for a message is a GIF - func isGIFImage(for messageID: UUID) -> Bool { - imageIsGIF[messageID] ?? false + } + + /// Returns whether the image for a message is a GIF + func isGIFImage(for messageID: UUID) -> Bool { + imageIsGIF[messageID] ?? false + } + + /// Returns the raw image data for a message, if available + func imageData(for messageID: UUID) -> Data? { + loadedImageData.object(forKey: messageID as NSUUID).map { Data(referencing: $0) } + } + + /// Clears the negative cache entry for a failed image and re-triggers the fetch. + func retryImageFetch(for messageID: UUID) async { + guard previewStates[messageID] != .malwareWarning else { return } + guard let url = cachedURLs[messageID].flatMap(\.self) else { return } + + let directURL = ImageURLClassifier.directImageURL(for: url) + await InlineImageCache.shared.clearFailure(for: directURL) + + previewStates[messageID] = .idle + rebuildDisplayItem(for: messageID) + requestImageFetch(for: messageID) + } + + /// Whether the `onRequestPreviewFetch` callback should route to image + /// fetching instead of link-preview fetching for the given message. + /// Encapsulates the cached-URL + image-URL + env-toggle gate so the cell + /// callback stays a single line. + func shouldRequestImageFetch(for messageID: UUID) -> Bool { + guard envInputs.showInlineImages, + let url = cachedURLs[messageID].flatMap(\.self) else { + return false } - - /// Returns the raw image data for a message, if available - func imageData(for messageID: UUID) -> Data? { - loadedImageData.object(forKey: messageID as NSUUID).map { Data(referencing: $0) } + return ImageURLClassifier.isImageURL(url) + } + + /// Request inline image fetch for a message (called when cell becomes visible) + func requestImageFetch(for messageID: UUID) { + guard envInputs.showInlineImages else { return } + guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { return } + guard let url = cachedURLs[messageID].flatMap(\.self), + ImageURLClassifier.isImageURL(url) else { return } + + imageFetchTasks[messageID] = Task { + await fetchInlineImage(for: messageID, url: url) } + } - /// Clears the negative cache entry for a failed image and re-triggers the fetch. - func retryImageFetch(for messageID: UUID) async { - guard previewStates[messageID] != .malwareWarning else { return } - guard let url = cachedURLs[messageID].flatMap({ $0 }) else { return } - - let directURL = ImageURLClassifier.directImageURL(for: url) - await InlineImageCache.shared.clearFailure(for: directURL) + /// Fetch inline image data and update state + private func fetchInlineImage(for messageID: UUID, url: URL) async { + let directURL = ImageURLClassifier.directImageURL(for: url) - previewStates[messageID] = .idle - rebuildDisplayItem(for: messageID) - requestImageFetch(for: messageID) + // Check malware domain blocklist before fetching + if let host = directURL.host(), await MalwareDomainFilter.shared.isBlocked(host) { + previewStates[messageID] = .malwareWarning + rebuildDisplayItem(for: messageID) + return } - /// Whether the `onRequestPreviewFetch` callback should route to image - /// fetching instead of link-preview fetching for the given message. - /// Encapsulates the cached-URL + image-URL + env-toggle gate so the cell - /// callback stays a single line. - func shouldRequestImageFetch(for messageID: UUID) -> Bool { - guard envInputs.showInlineImages, - let url = cachedURLs[messageID].flatMap({ $0 }) else { - return false - } - return ImageURLClassifier.isImageURL(url) - } + previewStates[messageID] = .loading + rebuildDisplayItem(for: messageID) + let result = await InlineImageCache.shared.fetchImageData(for: directURL) - /// Request inline image fetch for a message (called when cell becomes visible) - func requestImageFetch(for messageID: UUID) { - guard envInputs.showInlineImages else { return } - guard previewStates[messageID] == nil || previewStates[messageID] == .idle else { return } - guard let url = cachedURLs[messageID].flatMap({ $0 }), - ImageURLClassifier.isImageURL(url) else { return } - - imageFetchTasks[messageID] = Task { - await fetchInlineImage(for: messageID, url: url) - } + guard !Task.isCancelled else { + imageFetchTasks.removeValue(forKey: messageID) + return } - - /// Fetch inline image data and update state - private func fetchInlineImage(for messageID: UUID, url: URL) async { - let directURL = ImageURLClassifier.directImageURL(for: url) - - // Check malware domain blocklist before fetching - if let host = directURL.host(), await MalwareDomainFilter.shared.isBlocked(host) { - previewStates[messageID] = .malwareWarning - rebuildDisplayItem(for: messageID) - return - } - - previewStates[messageID] = .loading - rebuildDisplayItem(for: messageID) - let result = await InlineImageCache.shared.fetchImageData(for: directURL) - - guard !Task.isCancelled else { - imageFetchTasks.removeValue(forKey: messageID) - return - } - guard itemIndexByID[messageID] != nil else { - imageFetchTasks.removeValue(forKey: messageID) - return - } - - switch result { - case .loaded(let data): - let isGIF = ImageURLDetector.isGIFData(data) - let entry: CachedDecodedImage? = await Task.detached { () -> CachedDecodedImage? in - let decoded: UIImage? = isGIF - ? ImageURLDetector.decodeGIFImage(from: data) - : ImageURLDetector.downsampledImage(from: data) - guard let decoded else { return nil } - return CachedDecodedImage( - image: decoded, - isGIF: isGIF, - data: isGIF ? nil : data - ) - }.value - // Persist before the cancellation/teardown guards so a - // scroll-away or chat-exit mid-decode still surfaces the - // pixels on the next chat re-entry. - if let entry { - InlineImageCache.shared.storeDecoded(entry, for: directURL) - } - guard !Task.isCancelled, let entry else { - imageFetchTasks.removeValue(forKey: messageID) - return - } - guard itemIndexByID[messageID] != nil else { - imageFetchTasks.removeValue(forKey: messageID) - return - } - applyDecodedImage(entry, for: messageID) - - case .loading: - break - - case .failed: - previewStates[messageID] = .noPreview - } - - imageFetchTasks.removeValue(forKey: messageID) - rebuildDisplayItem(for: messageID) + guard itemIndexByID[messageID] != nil else { + imageFetchTasks.removeValue(forKey: messageID) + return } - /// Cancel image fetch for a message - func cancelImageFetch(for messageID: UUID) { - imageFetchTasks[messageID]?.cancel() + switch result { + case let .loaded(data): + let isGIF = ImageURLDetector.isGIFData(data) + let entry: CachedDecodedImage? = await Task.detached { () -> CachedDecodedImage? in + let decoded: UIImage? = isGIF + ? ImageURLDetector.decodeGIFImage(from: data) + : ImageURLDetector.downsampledImage(from: data) + guard let decoded else { return nil } + return CachedDecodedImage( + image: decoded, + isGIF: isGIF, + data: isGIF ? nil : data + ) + }.value + // Persist before the cancellation/teardown guards so a + // scroll-away or chat-exit mid-decode still surfaces the + // pixels on the next chat re-entry. + if let entry { + InlineImageCache.shared.storeDecoded(entry, for: directURL) + } + guard !Task.isCancelled, let entry else { imageFetchTasks.removeValue(forKey: messageID) - } - - /// Clean up image state for a specific message - private func cleanupImageState(for messageID: UUID) { - loadedImageData.removeObject(forKey: messageID as NSUUID) - decodedImages.removeValue(forKey: messageID) - imageIsGIF.removeValue(forKey: messageID) - imageFetchTasks[messageID]?.cancel() + return + } + guard itemIndexByID[messageID] != nil else { imageFetchTasks.removeValue(forKey: messageID) - } + return + } + applyDecodedImage(entry, for: messageID) - /// Clear all image state (called on conversation switch) - private func clearImageState() { - imageFetchTasks.values.forEach { $0.cancel() } - imageFetchTasks.removeAll() - loadedImageData.removeAllObjects() - decodedImages.removeAll() - imageIsGIF.removeAll() + case .loading: + break + + case .failed: + previewStates[messageID] = .noPreview } + + imageFetchTasks.removeValue(forKey: messageID) + rebuildDisplayItem(for: messageID) + } + + /// Cancel image fetch for a message + func cancelImageFetch(for messageID: UUID) { + imageFetchTasks[messageID]?.cancel() + imageFetchTasks.removeValue(forKey: messageID) + } + + /// Clean up image state for a specific message + private func cleanupImageState(for messageID: UUID) { + loadedImageData.removeObject(forKey: messageID as NSUUID) + decodedImages.removeValue(forKey: messageID) + imageIsGIF.removeValue(forKey: messageID) + imageFetchTasks[messageID]?.cancel() + imageFetchTasks.removeValue(forKey: messageID) + } + + /// Clear all image state (called on conversation switch) + private func clearImageState() { + imageFetchTasks.values.forEach { $0.cancel() } + imageFetchTasks.removeAll() + loadedImageData.removeAllObjects() + decodedImages.removeAll() + imageIsGIF.removeAll() + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ReactionIndexing.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ReactionIndexing.swift index 0e7a4899..a349bac8 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ReactionIndexing.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ReactionIndexing.swift @@ -2,118 +2,116 @@ import Foundation import MC1Services extension ChatViewModel { + // MARK: - Reaction Indexing - // MARK: - Reaction Indexing + /// Conversation keying for `indexMessagesForReactions`: channels match by + /// (channel index, sender name) and persist `channelIndex`; DMs match by + /// contact and persist `contactID`. + enum ReactionIndexScope { + case channel(ChannelDTO, localNodeName: String?) + case direct(ContactDTO) + } - /// Conversation keying for `indexMessagesForReactions`: channels match by - /// (channel index, sender name) and persist `channelIndex`; DMs match by - /// contact and persist `contactID`. - enum ReactionIndexScope { - case channel(ChannelDTO, localNodeName: String?) - case direct(ContactDTO) - } - - /// Indexes a freshly fetched page of messages for reaction matching and - /// persists any pending reactions that now have their target, applying the - /// refreshed summaries to the in-memory timeline. Awaits the - /// `ReactionService` actor serially per message, so callers run it after - /// `buildItems()` to keep the visible timeline from being gated on it. - func indexMessagesForReactions( - _ fetchedMessages: [MessageDTO], - scope: ReactionIndexScope, - reactionService: ReactionService, - dataStore: DataStore - ) async { - switch scope { - case .channel(let channel, let localNodeName): - // The channel's own radioID, never the live connection's: a mid-load - // disconnect would otherwise mint a fresh UUID into persisted rows. - let radioID = channel.radioID - for message in fetchedMessages { - let senderName: String? - if message.isOutgoing { - senderName = localNodeName - } else { - senderName = message.senderNodeName - } - guard let senderName else { continue } + /// Indexes a freshly fetched page of messages for reaction matching and + /// persists any pending reactions that now have their target, applying the + /// refreshed summaries to the in-memory timeline. Awaits the + /// `ReactionService` actor serially per message, so callers run it after + /// `buildItems()` to keep the visible timeline from being gated on it. + func indexMessagesForReactions( + _ fetchedMessages: [MessageDTO], + scope: ReactionIndexScope, + reactionService: ReactionService, + dataStore: DataStore + ) async { + switch scope { + case let .channel(channel, localNodeName): + // The channel's own radioID, never the live connection's: a mid-load + // disconnect would otherwise mint a fresh UUID into persisted rows. + let radioID = channel.radioID + for message in fetchedMessages { + let senderName: String? = if message.isOutgoing { + localNodeName + } else { + message.senderNodeName + } + guard let senderName else { continue } - let pendingMatches = await reactionService.indexMessage( - id: message.id, - channelIndex: channel.index, - senderName: senderName, - text: message.text, - timestamp: message.timestamp - ) + let pendingMatches = await reactionService.indexMessage( + id: message.id, + channelIndex: channel.index, + senderName: senderName, + text: message.text, + timestamp: message.timestamp + ) - // Process any pending reactions that now have their target - for pending in pendingMatches { - let reactionDTO = ReactionDTO( - messageID: message.id, - emoji: pending.parsed.emoji, - senderName: pending.senderNodeName, - messageHash: pending.parsed.messageHash, - rawText: pending.rawText, - channelIndex: pending.channelIndex, - radioID: radioID - ) - await persistPendingReactionIfNew( - reactionDTO, - reactionService: reactionService, - dataStore: dataStore - ) - } - } + // Process any pending reactions that now have their target + for pending in pendingMatches { + let reactionDTO = ReactionDTO( + messageID: message.id, + emoji: pending.parsed.emoji, + senderName: pending.senderNodeName, + messageHash: pending.parsed.messageHash, + rawText: pending.rawText, + channelIndex: pending.channelIndex, + radioID: radioID + ) + await persistPendingReactionIfNew( + reactionDTO, + reactionService: reactionService, + dataStore: dataStore + ) + } + } - case .direct(let contact): - for message in fetchedMessages { - let pendingMatches = await reactionService.indexDMMessage( - id: message.id, - contactID: contact.id, - text: message.text, - timestamp: message.reactionTimestamp - ) + case let .direct(contact): + for message in fetchedMessages { + let pendingMatches = await reactionService.indexDMMessage( + id: message.id, + contactID: contact.id, + text: message.text, + timestamp: message.reactionTimestamp + ) - // Process any pending reactions that now have their target - for pending in pendingMatches { - let reactionDTO = ReactionDTO( - messageID: message.id, - emoji: pending.parsed.emoji, - senderName: pending.senderName, - messageHash: pending.parsed.messageHash, - rawText: pending.rawText, - contactID: contact.id, - radioID: contact.radioID - ) - await persistPendingReactionIfNew( - reactionDTO, - reactionService: reactionService, - dataStore: dataStore - ) - } - } + // Process any pending reactions that now have their target + for pending in pendingMatches { + let reactionDTO = ReactionDTO( + messageID: message.id, + emoji: pending.parsed.emoji, + senderName: pending.senderName, + messageHash: pending.parsed.messageHash, + rawText: pending.rawText, + contactID: contact.id, + radioID: contact.radioID + ) + await persistPendingReactionIfNew( + reactionDTO, + reactionService: reactionService, + dataStore: dataStore + ) } + } } + } - /// Persists `reaction` unless an identical (message, sender, emoji) row - /// already exists, then applies the refreshed summary to the in-memory message. - private func persistPendingReactionIfNew( - _ reaction: ReactionDTO, - reactionService: ReactionService, - dataStore: DataStore - ) async { - let exists = try? await dataStore.reactionExists( - messageID: reaction.messageID, - senderName: reaction.senderName, - emoji: reaction.emoji - ) - guard exists != true else { return } + /// Persists `reaction` unless an identical (message, sender, emoji) row + /// already exists, then applies the refreshed summary to the in-memory message. + private func persistPendingReactionIfNew( + _ reaction: ReactionDTO, + reactionService: ReactionService, + dataStore: DataStore + ) async { + let exists = try? await dataStore.reactionExists( + messageID: reaction.messageID, + senderName: reaction.senderName, + emoji: reaction.emoji + ) + guard exists != true else { return } - if let result = await reactionService.persistReactionAndUpdateSummary( - reaction, - using: dataStore - ) { - updateReactionSummary(for: result.messageID, summary: result.summary) - } + if let result = await reactionService.persistReactionAndUpdateSummary( + reaction, + using: dataStore + ) { + updateReactionSummary(for: result.messageID, summary: result.summary) } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Reactions.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Reactions.swift index ac5615b3..6074d392 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Reactions.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Reactions.swift @@ -2,200 +2,199 @@ import Foundation import MC1Services extension ChatViewModel { - - // MARK: - Reactions - - /// Send a reaction emoji to a message (channel or DM) - func sendReaction(emoji: String, to message: MessageDTO) async { - guard reactionServiceProvider() != nil, - messageService != nil, - let dataStore else { - return - } - - // Prevent duplicate sends from rapid taps - let reactionKey = "\(message.id)-\(emoji)" - guard !inFlightReactions.contains(reactionKey) else { - logger.debug("Reaction \(emoji) already in flight for message \(message.id), ignoring") - return - } - inFlightReactions.insert(reactionKey) - defer { inFlightReactions.remove(reactionKey) } - - let localNodeName = connectedDeviceProvider()?.nodeName ?? "Me" - - // Check if user already reacted with this emoji - if let alreadyReacted = try? await dataStore.reactionExists( - messageID: message.id, - senderName: localNodeName, - emoji: emoji - ), alreadyReacted { - logger.debug("User already reacted with \(emoji), ignoring") - return - } - - // Handle channel vs DM - if let channelIndex = message.channelIndex { - await sendChannelReaction( - emoji: emoji, - to: message, - channelIndex: channelIndex, - localNodeName: localNodeName - ) - } else if let contactID = message.contactID { - await sendDMReaction( - emoji: emoji, - to: message, - contactID: contactID, - localNodeName: localNodeName - ) - } + // MARK: - Reactions + + /// Send a reaction emoji to a message (channel or DM) + func sendReaction(emoji: String, to message: MessageDTO) async { + guard reactionServiceProvider() != nil, + messageService != nil, + let dataStore else { + return } - private func sendChannelReaction( - emoji: String, - to message: MessageDTO, - channelIndex: UInt8, - localNodeName: String - ) async { - guard let reactionService = reactionServiceProvider(), - let messageService, - let dataStore else { return } - - // Determine target sender name - let targetSenderName: String - if message.isOutgoing { - targetSenderName = localNodeName - } else { - guard let senderName = message.senderNodeName else { return } - targetSenderName = senderName - } - - let reactionText = reactionService.buildReactionText( - emoji: emoji, - targetSender: targetSenderName, - targetText: message.text, - targetTimestamp: message.reactionTimestamp - ) - - do { - _ = try await messageService.sendChannelMessage( - text: reactionText, - channelIndex: channelIndex, - radioID: message.radioID - ) - - // Optimistic local update - let messageHash = ReactionParser.generateMessageHash( - text: message.text, - timestamp: message.reactionTimestamp - ) - let reactionDTO = ReactionDTO( - messageID: message.id, - emoji: emoji, - senderName: localNodeName, - messageHash: messageHash, - rawText: reactionText, - channelIndex: channelIndex, - radioID: message.radioID - ) - if let result = await reactionService.persistReactionAndUpdateSummary( - reactionDTO, - using: dataStore - ) { - updateReactionSummary(for: result.messageID, summary: result.summary) - } - } catch { - logger.error("Failed to send channel reaction: \(error)") - errorMessage = error.userFacingMessage - } + // Prevent duplicate sends from rapid taps + let reactionKey = "\(message.id)-\(emoji)" + guard !inFlightReactions.contains(reactionKey) else { + logger.debug("Reaction \(emoji) already in flight for message \(message.id), ignoring") + return + } + inFlightReactions.insert(reactionKey) + defer { inFlightReactions.remove(reactionKey) } + + let localNodeName = connectedDeviceProvider()?.nodeName ?? "Me" + + // Check if user already reacted with this emoji + if let alreadyReacted = try? await dataStore.reactionExists( + messageID: message.id, + senderName: localNodeName, + emoji: emoji + ), alreadyReacted { + logger.debug("User already reacted with \(emoji), ignoring") + return } - private func sendDMReaction( - emoji: String, - to message: MessageDTO, - contactID: UUID, - localNodeName: String - ) async { - guard let reactionService = reactionServiceProvider(), - let messageService, - let dataStore else { return } - - // Fetch the contact for sending - guard let contact = try? await dataStore.fetchContact(id: contactID) else { - logger.error("Failed to fetch contact for DM reaction") - return - } - - let reactionText = reactionService.buildDMReactionText( - emoji: emoji, - targetText: message.text, - targetTimestamp: message.reactionTimestamp - ) - do { - // Send as DM to the contact - _ = try await messageService.sendDirectMessage( - text: reactionText, - to: contact - ) - - // Optimistic local update - let messageHash = ReactionParser.generateMessageHash( - text: message.text, - timestamp: message.reactionTimestamp - ) - let reactionDTO = ReactionDTO( - messageID: message.id, - emoji: emoji, - senderName: localNodeName, - messageHash: messageHash, - rawText: reactionText, - contactID: contactID, - radioID: message.radioID - ) - if let result = await reactionService.persistReactionAndUpdateSummary( - reactionDTO, - using: dataStore - ) { - updateReactionSummary(for: result.messageID, summary: result.summary) - } - } catch { - logger.error("Failed to send DM reaction: \(error)") - errorMessage = error.userFacingMessage - } + // Handle channel vs DM + if let channelIndex = message.channelIndex { + await sendChannelReaction( + emoji: emoji, + to: message, + channelIndex: channelIndex, + localNodeName: localNodeName + ) + } else if let contactID = message.contactID { + await sendDMReaction( + emoji: emoji, + to: message, + contactID: contactID, + localNodeName: localNodeName + ) + } + } + + private func sendChannelReaction( + emoji: String, + to message: MessageDTO, + channelIndex: UInt8, + localNodeName: String + ) async { + guard let reactionService = reactionServiceProvider(), + let messageService, + let dataStore else { return } + + // Determine target sender name + let targetSenderName: String + if message.isOutgoing { + targetSenderName = localNodeName + } else { + guard let senderName = message.senderNodeName else { return } + targetSenderName = senderName } - // MARK: - Reaction Filtering + let reactionText = reactionService.buildReactionText( + emoji: emoji, + targetSender: targetSenderName, + targetText: message.text, + targetTimestamp: message.reactionTimestamp + ) + + do { + _ = try await messageService.sendChannelMessage( + text: reactionText, + channelIndex: channelIndex, + radioID: message.radioID + ) + + // Optimistic local update + let messageHash = ReactionParser.generateMessageHash( + text: message.text, + timestamp: message.reactionTimestamp + ) + let reactionDTO = ReactionDTO( + messageID: message.id, + emoji: emoji, + senderName: localNodeName, + messageHash: messageHash, + rawText: reactionText, + channelIndex: channelIndex, + radioID: message.radioID + ) + if let result = await reactionService.persistReactionAndUpdateSummary( + reactionDTO, + using: dataStore + ) { + updateReactionSummary(for: result.messageID, summary: result.summary) + } + } catch { + logger.error("Failed to send channel reaction: \(error)") + errorMessage = error.userFacingMessage + } + } + + private func sendDMReaction( + emoji: String, + to message: MessageDTO, + contactID: UUID, + localNodeName: String + ) async { + guard let reactionService = reactionServiceProvider(), + let messageService, + let dataStore else { return } + + // Fetch the contact for sending + guard let contact = try? await dataStore.fetchContact(id: contactID) else { + logger.error("Failed to fetch contact for DM reaction") + return + } - /// Filter out outgoing reaction messages unless they failed to send. - /// Reaction messages are hidden from the UI to avoid clutter since they're displayed as badges. - /// - Parameters: - /// - messages: The messages to filter - /// - isDM: Whether these are DM messages (uses parseDM) or channel messages (uses parse) - /// - Returns: Filtered messages with successful outgoing reactions removed - func filterOutgoingReactionMessages(_ messages: [MessageDTO], isDM: Bool) -> [MessageDTO] { - messages.filter { !isHiddenOutgoingReaction($0, isDM: isDM) } + let reactionText = reactionService.buildDMReactionText( + emoji: emoji, + targetText: message.text, + targetTimestamp: message.reactionTimestamp + ) + do { + // Send as DM to the contact + _ = try await messageService.sendDirectMessage( + text: reactionText, + to: contact + ) + + // Optimistic local update + let messageHash = ReactionParser.generateMessageHash( + text: message.text, + timestamp: message.reactionTimestamp + ) + let reactionDTO = ReactionDTO( + messageID: message.id, + emoji: emoji, + senderName: localNodeName, + messageHash: messageHash, + rawText: reactionText, + contactID: contactID, + radioID: message.radioID + ) + if let result = await reactionService.persistReactionAndUpdateSummary( + reactionDTO, + using: dataStore + ) { + updateReactionSummary(for: result.messageID, summary: result.summary) + } + } catch { + logger.error("Failed to send DM reaction: \(error)") + errorMessage = error.userFacingMessage } + } - /// Whether a message is a successfully-sent outgoing reaction, which is rendered - /// as a badge and so hidden from the timeline by `filterOutgoingReactionMessages`. - /// Failed reactions stay visible so the user can retry them. - func isHiddenOutgoingReaction(_ message: MessageDTO, isDM: Bool) -> Bool { - guard message.direction == .outgoing else { return false } + // MARK: - Reaction Filtering - let isReaction = isDM - ? ReactionParser.parseDM(message.text) != nil - : ReactionParser.parse(message.text) != nil + /// Filter out outgoing reaction messages unless they failed to send. + /// Reaction messages are hidden from the UI to avoid clutter since they're displayed as badges. + /// - Parameters: + /// - messages: The messages to filter + /// - isDM: Whether these are DM messages (uses parseDM) or channel messages (uses parse) + /// - Returns: Filtered messages with successful outgoing reactions removed + func filterOutgoingReactionMessages(_ messages: [MessageDTO], isDM: Bool) -> [MessageDTO] { + messages.filter { !isHiddenOutgoingReaction($0, isDM: isDM) } + } - guard isReaction else { return false } + /// Whether a message is a successfully-sent outgoing reaction, which is rendered + /// as a badge and so hidden from the timeline by `filterOutgoingReactionMessages`. + /// Failed reactions stay visible so the user can retry them. + func isHiddenOutgoingReaction(_ message: MessageDTO, isDM: Bool) -> Bool { + guard message.direction == .outgoing else { return false } - return message.status != .failed - } + let isReaction = isDM + ? ReactionParser.parseDM(message.text) != nil + : ReactionParser.parse(message.text) != nil - // MARK: - Reaction Updates + guard isReaction else { return false } - /// Update reaction summary for a specific message inline (O(1) update) - func updateReactionSummary(for messageID: UUID, summary: String) { - updateMessage(id: messageID) { $0.reactionSummary = summary } - } + return message.status != .failed + } + + // MARK: - Reaction Updates + + /// Update reaction summary for a specific message inline (O(1) update) + func updateReactionSummary(for messageID: UUID, summary: String) { + updateMessage(id: messageID) { $0.reactionSummary = summary } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+SendQueues.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+SendQueues.swift index d43ccd17..afb96ae9 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+SendQueues.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+SendQueues.swift @@ -2,39 +2,38 @@ import Foundation import MC1Services extension ChatViewModel { - - /// Route a DM enqueue through the service-owned send queue. The - /// service persists the row and drives the drain. Throws - /// `.notConnected` if the connection has been torn down between the - /// optimistic DB write and this call, or `.persistFailed` if the - /// SwiftData write fails. The caller's catch flips the message to - /// `.failed` and surfaces the error. - func enqueueDM(_ envelope: DirectMessageEnvelope) async throws { - guard let queue = chatSendQueueServiceProvider() else { - throw ChatSendQueueServiceError.notConnected - } - try await queue.enqueueDM(envelope) + /// Route a DM enqueue through the service-owned send queue. The + /// service persists the row and drives the drain. Throws + /// `.notConnected` if the connection has been torn down between the + /// optimistic DB write and this call, or `.persistFailed` if the + /// SwiftData write fails. The caller's catch flips the message to + /// `.failed` and surfaces the error. + func enqueueDM(_ envelope: DirectMessageEnvelope) async throws { + guard let queue = chatSendQueueServiceProvider() else { + throw ChatSendQueueServiceError.notConnected } + try await queue.enqueueDM(envelope) + } - /// Route a channel enqueue through the service-owned send queue. - /// See `enqueueDM` for the error contract. - func enqueueChannel(_ envelope: ChannelMessageEnvelope) async throws { - guard let queue = chatSendQueueServiceProvider() else { - throw ChatSendQueueServiceError.notConnected - } - try await queue.enqueueChannel(envelope) + /// Route a channel enqueue through the service-owned send queue. + /// See `enqueueDM` for the error contract. + func enqueueChannel(_ envelope: ChannelMessageEnvelope) async throws { + guard let queue = chatSendQueueServiceProvider() else { + throw ChatSendQueueServiceError.notConnected } + try await queue.enqueueChannel(envelope) + } - /// Signal-only DM enqueue for the manual retry path. The caller has - /// already persisted the `PendingSend` row via - /// `PersistenceStore.replacePendingSendForRetry`, so the service must - /// not double-persist. Throws `.notConnected` if the connection has - /// been torn down between the persist and this call — the caller's - /// catch surfaces the failure via `sendErrorMessage`. - func signalDMEnqueued(_ envelope: DirectMessageEnvelope) async throws { - guard let queue = chatSendQueueServiceProvider() else { - throw ChatSendQueueServiceError.notConnected - } - await queue.signalDMEnqueued(envelope) + /// Signal-only DM enqueue for the manual retry path. The caller has + /// already persisted the `PendingSend` row via + /// `PersistenceStore.replacePendingSendForRetry`, so the service must + /// not double-persist. Throws `.notConnected` if the connection has + /// been torn down between the persist and this call — the caller's + /// catch surfaces the failure via `sendErrorMessage`. + func signalDMEnqueued(_ envelope: DirectMessageEnvelope) async throws { + guard let queue = chatSendQueueServiceProvider() else { + throw ChatSendQueueServiceError.notConnected } + await queue.signalDMEnqueued(envelope) + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel.swift b/MC1/Views/Chats/ViewModel/ChatViewModel.swift index 319aebdf..4b9e1914 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel.swift @@ -1,545 +1,574 @@ -import SwiftUI -import UIKit +import CoreLocation import MC1Services import OSLog -import CoreLocation +import SwiftUI +import UIKit /// ViewModel for chat operations @Observable @MainActor final class ChatViewModel { + /// Tracks the last flood scope we pushed to the device for the current channel. + /// Keyed by the input pair (per-channel preference, device default) so that a + /// change in either triggers a fresh push next time `loadChannelMessages` runs. + enum RegionScopeState: Equatable { + case unknown + case pushed(ChannelFloodScope, deviceDefault: String?) + } - /// Tracks the last flood scope we pushed to the device for the current channel. - /// Keyed by the input pair (per-channel preference, device default) so that a - /// change in either triggers a fresh push next time `loadChannelMessages` runs. - enum RegionScopeState: Equatable { - case unknown - case pushed(ChannelFloodScope, deviceDefault: String?) - } + /// Identifies an incoming self-mention with a per-mention sequence number so + /// `.onChange(of:)` fires for consecutive mentions of the same message. + /// `Equatable` is auto-synthesised from `UUID` + `UInt64`; adding a non-Equatable + /// field would break `.onChange` propagation. + struct MentionEvent: Equatable { + let messageID: UUID + let sequence: UInt64 + } - /// Identifies an incoming self-mention with a per-mention sequence number so - /// `.onChange(of:)` fires for consecutive mentions of the same message. - /// `Equatable` is auto-synthesised from `UUID` + `UInt64`; adding a non-Equatable - /// field would break `.onChange` propagation. - struct MentionEvent: Equatable, Sendable { - let messageID: UUID - let sequence: UInt64 - } - - // MARK: - Properties + // MARK: - Properties - let logger = Logger(subsystem: "com.mc1", category: "ChatViewModel") + let logger = Logger(subsystem: "com.mc1", category: "ChatViewModel") - /// Observed source of truth the list diffs against. Stays observed because that - /// observation drives the diff; assigned only by `recomputeSnapshot()`. - var conversationSnapshot: ConversationSnapshot = .empty + /// Observed source of truth the list diffs against. Stays observed because that + /// observation drives the diff; assigned only by `recomputeSnapshot()`. + var conversationSnapshot: ConversationSnapshot = .empty - /// Cheap Equatable surrogate for `onChange(of:)`; bumped only in `recomputeSnapshot()`. - var snapshotGeneration: Int = 0 + /// Cheap Equatable surrogate for `onChange(of:)`; bumped only in `recomputeSnapshot()`. + var snapshotGeneration: Int = 0 - /// Non-observed fetch buffer feeding `recomputeSnapshot()`; `internal` so tests can seed it. - @ObservationIgnored var conversations: [ContactDTO] = [] + /// Non-observed fetch buffer feeding `recomputeSnapshot()`; `internal` so tests can seed it. + @ObservationIgnored var conversations: [ContactDTO] = [] - /// All contacts for mention autocomplete (includes contacts without messages) - var allContacts: [ContactDTO] = [] + /// All contacts for mention autocomplete (includes contacts without messages) + var allContacts: [ContactDTO] = [] - /// Synthetic contacts for channel senders not in contacts - var channelSenders: [ContactDTO] = [] + /// Synthetic contacts for channel senders not in contacts + var channelSenders: [ContactDTO] = [] - /// O(1) lookup for channel sender names - var channelSenderNames: Set = [] + /// O(1) lookup for channel sender names + var channelSenderNames: Set = [] - /// Sender name → latest message timestamp (for mention sort order) - var channelSenderOrder: [String: UInt32] = [:] + /// Sender name → latest message timestamp (for mention sort order) + var channelSenderOrder: [String: UInt32] = [:] - /// O(1) lookup for contact names - var contactNameSet: Set = [] + /// O(1) lookup for contact names + var contactNameSet: Set = [] - /// Current channels with messages. Non-observed fetch buffer feeding `recomputeSnapshot()`. - @ObservationIgnored var channels: [ChannelDTO] = [] + /// Current channels with messages. Non-observed fetch buffer feeding `recomputeSnapshot()`. + @ObservationIgnored var channels: [ChannelDTO] = [] - /// Current room sessions. Non-observed fetch buffer feeding `recomputeSnapshot()`. - @ObservationIgnored var roomSessions: [RemoteNodeSessionDTO] = [] + /// Current room sessions. Non-observed fetch buffer feeding `recomputeSnapshot()`. + @ObservationIgnored var roomSessions: [RemoteNodeSessionDTO] = [] - /// Ids hidden optimistically on delete until a DB-confirming reload drops them. - /// `reconcilePendingRemovals()` self-heals this once the fetch confirms the row is gone. - @ObservationIgnored var pendingRemovalIDs: Set = [] + /// Ids hidden optimistically on delete until a DB-confirming reload drops them. + /// `reconcilePendingRemovals()` self-heals this once the fetch confirms the row is gone. + @ObservationIgnored var pendingRemovalIDs: Set = [] - /// Rows showing a delete spinner during a radio-backed delete (channel clear, room leave). - /// Observed presentation state read by the rows; never filters the snapshot. Distinct from - /// `pendingRemovalIDs`, the optimistic-hide mask. - var deletingIDs: Set = [] + /// Rows showing a delete spinner during a radio-backed delete (channel clear, room leave). + /// Observed presentation state read by the rows; never filters the snapshot. Distinct from + /// `pendingRemovalIDs`, the optimistic-hide mask. + var deletingIDs: Set = [] - /// Cancel-and-replace token for the serialized reload funnel. No view reads it. - @ObservationIgnored var reloadTask: Task? + /// Cancel-and-replace token for the serialized reload funnel. No view reads it. + @ObservationIgnored var reloadTask: Task? - #if DEBUG + #if DEBUG /// Test-only interleave hook, awaited once mid-reload so a test can suspend reload #1 /// between fetches and commit reload #2 first. Compiled out of release builds. @ObservationIgnored var reloadInterleaveHook: (@MainActor () async -> Void)? - #endif - - // MARK: - Conversation Cache Storage - - @ObservationIgnored var urlDetectionTask: Task? - /// Bumped on every buildItems rebuild. Only the URL-detection writer - /// checks this before mutating cachedURLs; single-row rebuilds via - /// rebuildDisplayItem do not write cachedURLs and do not need gating. - @ObservationIgnored var urlDetectionGeneration: UInt64 = 0 - /// Tracks the last region scope sent to the device via setFloodScope. - @ObservationIgnored var lastSetRegionScope: RegionScopeState = .unknown - - /// Per-(radio, conversation) coordinator that owns the canonical chat - /// state for this view model. Bound by `configure(...)` once the - /// conversation identity is known. Two `ChatViewModel`s on the same - /// conversation share one coordinator via the registry, so an update - /// applied from one view is visible to the other. - var coordinator: ChatCoordinator? - - /// Messages for the current conversation. Forwards to the bound - /// coordinator; empty when no coordinator is bound (e.g., conversation - /// list views). - var messages: [MessageDTO] { coordinator?.messages ?? [] } - - /// Immutable snapshot of the timeline as the view sees it. Forwards - /// to the bound coordinator. - var renderState: ChatRenderState { coordinator?.renderState ?? .empty } - - /// Environment-derived inputs (six `@AppStorage` toggles, contrast, - /// current user name) that feed `MessageItem` construction. - /// `ChatConversationView` pushes via `applyEnvInputs(_:)` before - /// `loadMessages` and on subsequent toggle changes. - var envInputs: EnvInputs = .default - - /// Update env-derived inputs and trigger a full rebuild when the value - /// changes and there are messages to rebuild. Idempotent on no-change. - func applyEnvInputs(_ new: EnvInputs) { - guard envInputs != new else { return } - // When the network transitions from unavailable to available, drop the - // sticky map-snapshot failures so renders that failed during the outage - // retry on the next rebuild. Without this, an offline-pack miss stays - // poisoned until a memory warning evicts the failed set. - if envInputs.isOffline && !new.isOffline { - MapSnapshotStore.shared.clearFailures() - } - envInputs = new - guard !messages.isEmpty else { return } - buildItems() + #endif + + // MARK: - Conversation Cache Storage + + @ObservationIgnored var urlDetectionTask: Task? + /// Bumped on every buildItems rebuild. Only the URL-detection writer + /// checks this before mutating cachedURLs; single-row rebuilds via + /// rebuildDisplayItem do not write cachedURLs and do not need gating. + @ObservationIgnored var urlDetectionGeneration: UInt64 = 0 + /// Tracks the last region scope sent to the device via setFloodScope. + @ObservationIgnored var lastSetRegionScope: RegionScopeState = .unknown + + /// Per-(radio, conversation) coordinator that owns the canonical chat + /// state for this view model. Bound by `configure(...)` once the + /// conversation identity is known. Two `ChatViewModel`s on the same + /// conversation share one coordinator via the registry, so an update + /// applied from one view is visible to the other. + var coordinator: ChatCoordinator? + + /// Messages for the current conversation. Forwards to the bound + /// coordinator; empty when no coordinator is bound (e.g., conversation + /// list views). + var messages: [MessageDTO] { + coordinator?.messages ?? [] + } + + /// Immutable snapshot of the timeline as the view sees it. Forwards + /// to the bound coordinator. + var renderState: ChatRenderState { + coordinator?.renderState ?? .empty + } + + /// Environment-derived inputs (six `@AppStorage` toggles, contrast, + /// current user name) that feed `MessageItem` construction. + /// `ChatConversationView` pushes via `applyEnvInputs(_:)` before + /// `loadMessages` and on subsequent toggle changes. + var envInputs: EnvInputs = .default + + /// Update env-derived inputs and trigger a full rebuild when the value + /// changes and there are messages to rebuild. Idempotent on no-change. + func applyEnvInputs(_ new: EnvInputs) { + guard envInputs != new else { return } + // When the network transitions from unavailable to available, drop the + // sticky map-snapshot failures so renders that failed during the outage + // retry on the next rebuild. Without this, an offline-pack miss stays + // poisoned until a memory warning evicts the failed set. + if envInputs.isOffline, !new.isOffline { + MapSnapshotStore.shared.clearFailures() } - - /// Monotonic counter bumped when a `MessageEvent` indicates the current - /// contact (route, profile) should be re-fetched. Observed by the view. - var contactRefreshSignal: UInt64 = 0 - - /// Most recent incoming self-mention, with a per-mention sequence number - /// so consecutive mentions of the same message still fire `.onChange`. - var lastIncomingMention: MentionEvent? - - /// Internal mention counter; never observed by the view, so it stays out - /// of the `@Observable` graph. - @ObservationIgnored var mentionSequence: UInt64 = 0 - - /// Stored timeline rows. The cell-content closure consumes this directly; - /// the bubble view reads `MessageItem` fields without any per-render rebuild. - var items: [MessageItem] { renderState.items } - - /// O(1) item-index lookup by message ID, forwarded from the render state. - var itemIndexByID: [UUID: Int] { renderState.itemIndexByID } - - /// O(1) message lookup by ID (used by views to get full DTO when needed). - /// Forwards to the bound coordinator. - var messagesByID: [UUID: MessageDTO] { coordinator?.messagesByID ?? [:] } - - /// Current contact being chatted with - var currentContact: ContactDTO? - - /// Current channel being viewed - var currentChannel: ChannelDTO? - - /// Loading state - var isLoading = false - - /// True once a list-level conversation fetch has completed. Gates the - /// list-level empty-state placeholder. The per-conversation timeline - /// has its own gate on `ChatRenderState.LoadPhase`. - var hasLoadedOnce = false - - /// Error message if any - var errorMessage: String? - - /// Error state for send-only failures (queue drains, retry call site). - /// Separate from `errorMessage`, which surfaces load and fetch errors - /// with the generic "Error" alert title. `sendErrorMessage` surfaces - /// with the "Unable to Send" title so retry-context errors read as - /// user-actionable rather than generic. - /// - /// Routing: - /// - Send-queue drain errors are assigned post-`loadMessages` so the - /// load reset of `errorMessage = nil` does not clobber the alert. - /// - Load errors continue to flow to `errorMessage`. - /// - Passive load and prefetch failures route to `errorBannerMessage` for - /// the non-modal banner surface. - var sendErrorMessage: String? - - /// Error message for passive failure surfaces. Driven by `errorBanner(_:)` - /// and rendered as a tap-to-dismiss strip between the message list and the - /// input bar. Use this instead of `errorMessage` for failures the user did - /// not initiate (prefetch, scroll-triggered pagination). User-initiated - /// failures and initial-open load failures continue to surface via - /// `errorMessage` (modal "Error" alert) or `sendErrorMessage` (modal - /// "Unable to Send" alert). - var errorBannerMessage: String? - - /// Message text being composed - var composingText = "" - - /// Reentry guard for retry actions. A single `ChatViewModel` is bound to one - /// conversation at a time (`currentChannel` xor `currentContact`), so this - /// flag covers both DM and channel retry paths — they can never overlap. - @ObservationIgnored var retryInFlight = false - - /// Last message previews cache - var lastMessageCache: [UUID: MessageDTO] = [:] - - // The preview-cache block below (`previewStates`, `cachedURLs`, - // `decodedImages`, `loadedImageData`) is per-VM state, not shared - // across multiple VMs binding to the same `ChatCoordinator`. On iPad - // split view each VM rebuilds its own cache; the two views can - // diverge until the next rebuild on each side. See - // `ChatCoordinatorRegistry.coordinator(for:)` for the accepted - // trade-off context. - - /// Preview state per message (keyed by message ID) - var previewStates: [UUID: PreviewLoadState] = [:] - - /// Loaded preview data per message (keyed by message ID) - var loadedPreviews: [UUID: LinkPreviewDataDTO] = [:] - - /// In-flight preview fetch tasks (prevents duplicate fetches) - var previewFetchTasks: [UUID: Task] = [:] - - /// Total cost limit for `loadedImageData`. `NSCache` evicts entries to - /// stay under this byte budget and also responds to system memory - /// pressure on its own. - private static let imageDataCacheLimitBytes = 50 * 1024 * 1024 - - /// Raw image data per message (keyed by message ID). Backed by - /// `NSCache` so memory pressure and the configured cost limit drive - /// eviction instead of an unbounded dictionary. `@ObservationIgnored` - /// because mutations should not trigger SwiftUI redraws — consumers - /// read this via explicit method calls when an image is tapped. - @ObservationIgnored - let loadedImageData: NSCache = { - let cache = NSCache() - cache.totalCostLimit = ChatViewModel.imageDataCacheLimitBytes - return cache - }() - - /// Pre-decoded UIImage per message (avoids decoding in view body) - var decodedImages: [UUID: UIImage] = [:] - - /// Pre-decoded link preview assets (single dictionary to batch Observable notifications) - var decodedPreviewAssets: [UUID: DecodedPreviewAssets] = [:] - - /// Tracks in-flight legacy preview decode tasks to prevent duplicates - var legacyPreviewDecodeInFlight: Set = [] - - /// Whether each image message is a GIF (computed once during decode) - var imageIsGIF: [UUID: Bool] = [:] - - /// In-flight image fetch tasks - var imageFetchTasks: [UUID: Task] = [:] - - /// In-flight reaction sends (prevents duplicate reactions on rapid taps) - /// Key format: "{messageID}-{emoji}" - var inFlightReactions: Set = [] - - /// Cached URL detection results to avoid re-running NSDataDetector on rebuilds - var cachedURLs: [UUID: URL?] = [:] - - /// Maps a snapshot request to the messages that show its thumbnail, so a late - /// `resolutionStream` event rebuilds only those rows (O(matches)) instead of - /// regex-scanning every loaded message. Populated in `makeBuildInputs`, - /// cleared on conversation switch. - @ObservationIgnored var mapPreviewRequestIndex: [MapSnapshotRequest: Set] = [:] - - // MARK: - Pagination State - - /// Whether currently fetching older messages (exposed for UI binding) - var isLoadingOlder: Bool { renderState.isLoadingOlder } - - /// Whether more messages exist beyond what's loaded - var hasMoreMessages: Bool { renderState.hasMoreMessages } - - /// Total messages fetched from database (unfiltered, for accurate offset calculation) - var totalFetchedCount: Int { renderState.totalFetchedCount } - - /// Message ID that should show the "New Messages" divider above it - var newMessagesDividerMessageID: UUID? - - /// Whether the divider position has been computed for the current conversation - var dividerComputed = false - - /// Unread count above which the "New Messages" divider is shown (strictly greater). - private let newMessagesDividerThreshold = 10 - - /// Computes the divider message ID from a fetched (unfiltered) message array. - /// Must be called before filtering. Sets `dividerComputed = true`. - /// - /// Positional: the divider sits `unreadCount` rows from the end. This relies on unread - /// messages occupying the array tail, which block-at-reconnect upholds — every unread row - /// (live or drained) takes a sortDate at or after its receive/drain time, later than any - /// already-read row, so unread always sorts to the tail. Do not switch this to a - /// `first(where: { !$0.isRead })` scan: per-message `isRead` is not maintained on chat open - /// (only the unread counter is cleared), so the scan would land on the first row of the page. - /// - /// The boundary row may be a sent outgoing reaction that `filterOutgoingReactionMessages` - /// drops before the items are built; the divider id must survive that filter, so advance - /// past any hidden row to the next visible one (toward newer), which renders at the same - /// visual position. - func computeDividerPosition(from messages: [MessageDTO], unreadCount: Int, isDM: Bool) { - guard !dividerComputed, unreadCount > newMessagesDividerThreshold else { return } - var dividerIndex = max(0, messages.count - unreadCount) - while dividerIndex < messages.count, isHiddenOutgoingReaction(messages[dividerIndex], isDM: isDM) { - dividerIndex += 1 - } - if dividerIndex < messages.count { - newMessagesDividerMessageID = messages[dividerIndex].id - } - dividerComputed = true + envInputs = new + guard !messages.isEmpty else { return } + buildItems() + } + + /// Monotonic counter bumped when a `MessageEvent` indicates the current + /// contact (route, profile) should be re-fetched. Observed by the view. + var contactRefreshSignal: UInt64 = 0 + + /// Most recent incoming self-mention, with a per-mention sequence number + /// so consecutive mentions of the same message still fire `.onChange`. + var lastIncomingMention: MentionEvent? + + /// Internal mention counter; never observed by the view, so it stays out + /// of the `@Observable` graph. + @ObservationIgnored var mentionSequence: UInt64 = 0 + + /// Stored timeline rows. The cell-content closure consumes this directly; + /// the bubble view reads `MessageItem` fields without any per-render rebuild. + var items: [MessageItem] { + renderState.items + } + + /// O(1) item-index lookup by message ID, forwarded from the render state. + var itemIndexByID: [UUID: Int] { + renderState.itemIndexByID + } + + /// O(1) message lookup by ID (used by views to get full DTO when needed). + /// Forwards to the bound coordinator. + var messagesByID: [UUID: MessageDTO] { + coordinator?.messagesByID ?? [:] + } + + /// Current contact being chatted with + var currentContact: ContactDTO? + + /// Current channel being viewed + var currentChannel: ChannelDTO? + + /// Loading state + var isLoading = false + + /// True once a list-level conversation fetch has completed. Gates the + /// list-level empty-state placeholder. The per-conversation timeline + /// has its own gate on `ChatRenderState.LoadPhase`. + var hasLoadedOnce = false + + /// Error message if any + var errorMessage: String? + + /// Error state for send-only failures (queue drains, retry call site). + /// Separate from `errorMessage`, which surfaces load and fetch errors + /// with the generic "Error" alert title. `sendErrorMessage` surfaces + /// with the "Unable to Send" title so retry-context errors read as + /// user-actionable rather than generic. + /// + /// Routing: + /// - Send-queue drain errors are assigned post-`loadMessages` so the + /// load reset of `errorMessage = nil` does not clobber the alert. + /// - Load errors continue to flow to `errorMessage`. + /// - Passive load and prefetch failures route to `errorBannerMessage` for + /// the non-modal banner surface. + var sendErrorMessage: String? + + /// Error message for passive failure surfaces. Driven by `errorBanner(_:)` + /// and rendered as a tap-to-dismiss strip between the message list and the + /// input bar. Use this instead of `errorMessage` for failures the user did + /// not initiate (prefetch, scroll-triggered pagination). User-initiated + /// failures and initial-open load failures continue to surface via + /// `errorMessage` (modal "Error" alert) or `sendErrorMessage` (modal + /// "Unable to Send" alert). + var errorBannerMessage: String? + + /// Message text being composed + var composingText = "" + + /// Reentry guard for retry actions. A single `ChatViewModel` is bound to one + /// conversation at a time (`currentChannel` xor `currentContact`), so this + /// flag covers both DM and channel retry paths — they can never overlap. + @ObservationIgnored var retryInFlight = false + + /// Last message previews cache + var lastMessageCache: [UUID: MessageDTO] = [:] + + // The preview-cache block below (`previewStates`, `cachedURLs`, + // `decodedImages`, `loadedImageData`) is per-VM state, not shared + // across multiple VMs binding to the same `ChatCoordinator`. On iPad + // split view each VM rebuilds its own cache; the two views can + // diverge until the next rebuild on each side. See + // `ChatCoordinatorRegistry.coordinator(for:)` for the accepted + // trade-off context. + + /// Preview state per message (keyed by message ID) + var previewStates: [UUID: PreviewLoadState] = [:] + + /// Loaded preview data per message (keyed by message ID) + var loadedPreviews: [UUID: LinkPreviewDataDTO] = [:] + + /// In-flight preview fetch tasks (prevents duplicate fetches) + var previewFetchTasks: [UUID: Task] = [:] + + /// Total cost limit for `loadedImageData`. `NSCache` evicts entries to + /// stay under this byte budget and also responds to system memory + /// pressure on its own. + private static let imageDataCacheLimitBytes = 50 * 1024 * 1024 + + /// Raw image data per message (keyed by message ID). Backed by + /// `NSCache` so memory pressure and the configured cost limit drive + /// eviction instead of an unbounded dictionary. `@ObservationIgnored` + /// because mutations should not trigger SwiftUI redraws — consumers + /// read this via explicit method calls when an image is tapped. + @ObservationIgnored + let loadedImageData: NSCache = { + let cache = NSCache() + cache.totalCostLimit = ChatViewModel.imageDataCacheLimitBytes + return cache + }() + + /// Pre-decoded UIImage per message (avoids decoding in view body) + var decodedImages: [UUID: UIImage] = [:] + + /// Pre-decoded link preview assets (single dictionary to batch Observable notifications) + var decodedPreviewAssets: [UUID: DecodedPreviewAssets] = [:] + + /// Tracks in-flight legacy preview decode tasks to prevent duplicates + var legacyPreviewDecodeInFlight: Set = [] + + /// Whether each image message is a GIF (computed once during decode) + var imageIsGIF: [UUID: Bool] = [:] + + /// In-flight image fetch tasks + var imageFetchTasks: [UUID: Task] = [:] + + /// In-flight reaction sends (prevents duplicate reactions on rapid taps) + /// Key format: "{messageID}-{emoji}" + var inFlightReactions: Set = [] + + /// Cached URL detection results to avoid re-running NSDataDetector on rebuilds + var cachedURLs: [UUID: URL?] = [:] + + /// Maps a snapshot request to the messages that show its thumbnail, so a late + /// `resolutionStream` event rebuilds only those rows (O(matches)) instead of + /// regex-scanning every loaded message. Populated in `makeBuildInputs`, + /// cleared on conversation switch. + @ObservationIgnored var mapPreviewRequestIndex: [MapSnapshotRequest: Set] = [:] + + // MARK: - Pagination State + + /// Whether currently fetching older messages (exposed for UI binding) + var isLoadingOlder: Bool { + renderState.isLoadingOlder + } + + /// Whether more messages exist beyond what's loaded + var hasMoreMessages: Bool { + renderState.hasMoreMessages + } + + /// Total messages fetched from database (unfiltered, for accurate offset calculation) + var totalFetchedCount: Int { + renderState.totalFetchedCount + } + + /// Message ID that should show the "New Messages" divider above it + var newMessagesDividerMessageID: UUID? + + /// Whether the divider position has been computed for the current conversation + var dividerComputed = false + + /// Unread count above which the "New Messages" divider is shown (strictly greater). + private let newMessagesDividerThreshold = 10 + + /// Computes the divider message ID from a fetched (unfiltered) message array. + /// Must be called before filtering. Sets `dividerComputed = true`. + /// + /// Positional: the divider sits `unreadCount` rows from the end. This relies on unread + /// messages occupying the array tail, which block-at-reconnect upholds — every unread row + /// (live or drained) takes a sortDate at or after its receive/drain time, later than any + /// already-read row, so unread always sorts to the tail. Do not switch this to a + /// `first(where: { !$0.isRead })` scan: per-message `isRead` is not maintained on chat open + /// (only the unread counter is cleared), so the scan would land on the first row of the page. + /// + /// The boundary row may be a sent outgoing reaction that `filterOutgoingReactionMessages` + /// drops before the items are built; the divider id must survive that filter, so advance + /// past any hidden row to the next visible one (toward newer), which renders at the same + /// visual position. + func computeDividerPosition(from messages: [MessageDTO], unreadCount: Int, isDM: Bool) { + guard !dividerComputed, unreadCount > newMessagesDividerThreshold else { return } + var dividerIndex = max(0, messages.count - unreadCount) + while dividerIndex < messages.count, isHiddenOutgoingReaction(messages[dividerIndex], isDM: isDM) { + dividerIndex += 1 } - - // MARK: - Dependencies - - /// Groups all provider closures for the `configure` call. Provider closures - /// are re-evaluated at every use so a disconnect (or a reconnect's fresh - /// per-connection services) is picked up live; a nil read means disconnected. - struct Dependencies { - var dataStore: @MainActor () -> DataStore? - var messageService: @MainActor () -> MessageService? - var notificationService: @MainActor () -> NotificationService? - var channelService: @MainActor () -> ChannelService? - var roomServerService: @MainActor () -> RoomServerService? - var contactService: @MainActor () -> ContactService? - var syncCoordinator: @MainActor () -> SyncCoordinator? - var connectionState: @MainActor () -> DeviceConnectionState - var connectedDevice: @MainActor () -> DeviceDTO? - var currentRadioID: @MainActor () -> UUID? - var session: @MainActor () -> MeshCoreSession? - var reactionService: @MainActor () -> ReactionService? - var chatSendQueueService: @MainActor () -> ChatSendQueueService? - var inlineImageDimensionsStore: @MainActor () -> InlineImageDimensionsStore? - var prefetchDataStore: @MainActor () -> (any PersistenceStoreProtocol)? + if dividerIndex < messages.count { + newMessagesDividerMessageID = messages[dividerIndex].id } - - @ObservationIgnored private var dataStoreProvider: @MainActor () -> DataStore? = { nil } - var dataStore: DataStore? { dataStoreProvider() } - - @ObservationIgnored private var messageServiceProvider: @MainActor () -> MessageService? = { nil } - var messageService: MessageService? { messageServiceProvider() } - - @ObservationIgnored private var notificationServiceProvider: @MainActor () -> NotificationService? = { nil } - var notificationService: NotificationService? { notificationServiceProvider() } - - @ObservationIgnored private var channelServiceProvider: @MainActor () -> ChannelService? = { nil } - private var channelService: ChannelService? { channelServiceProvider() } - - @ObservationIgnored private var roomServerServiceProvider: @MainActor () -> RoomServerService? = { nil } - private var roomServerService: RoomServerService? { roomServerServiceProvider() } - - @ObservationIgnored private var contactServiceProvider: @MainActor () -> ContactService? = { nil } - var contactService: ContactService? { contactServiceProvider() } - - @ObservationIgnored private var syncCoordinatorProvider: @MainActor () -> SyncCoordinator? = { nil } - var syncCoordinator: SyncCoordinator? { syncCoordinatorProvider() } - - @ObservationIgnored var connectionStateProvider: @MainActor () -> DeviceConnectionState = { .disconnected } - @ObservationIgnored var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } - @ObservationIgnored var currentRadioIDProvider: @MainActor () -> UUID? = { nil } - @ObservationIgnored var sessionProvider: @MainActor () -> MeshCoreSession? = { nil } - @ObservationIgnored var reactionServiceProvider: @MainActor () -> ReactionService? = { nil } - @ObservationIgnored var chatSendQueueServiceProvider: @MainActor () -> ChatSendQueueService? = { nil } - - @ObservationIgnored private var inlineImageDimensionsStoreProvider: @MainActor () -> InlineImageDimensionsStore? = { nil } - var inlineImageDimensionsStore: InlineImageDimensionsStore? { inlineImageDimensionsStoreProvider() } - - @ObservationIgnored private var prefetchDataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } - - /// App-lifetime cache for link previews. Passed directly from the environment; - /// held for the screen's lifetime and used in `fetchPreview` and `manualFetchPreview`. - @ObservationIgnored var linkPreviewCache: (any LinkPreviewCaching)? - - /// Navigation sink for map-thumbnail taps; nil makes the tap a no-op - /// (the always-present text link remains the baseline). - @ObservationIgnored var onNavigateToMap: ((CLLocationCoordinate2D) -> Void)? - - /// Drives receive-time prefetch of inline image dimensions and link - /// preview metadata so message bubbles render at final size on first - /// paint. Constructed in `configure(...)` when services are available; - /// nil while disconnected (offline browse never receives new messages). - @ObservationIgnored var prefetcher: InlineImagePrefetcher? - - /// Long-running subscription to `InlineImageDimensionsStore.resolutionStream`. - /// On each emitted URL, every message whose body contains that URL is - /// rebuilt so the bubble picks up the now-known `cachedAspect`. - @ObservationIgnored var dimensionResolutionTask: Task? - - /// Long-running subscription to `MapSnapshotStore.shared.resolutionStream`. - /// Started once (the store is a process-lifetime singleton). - @ObservationIgnored var snapshotResolutionTask: Task? - - /// Per-instance override of the receive-time prefetch timeout. Production - /// callers leave this at `defaultPrefetchTimeout` (3s); tests can shorten - /// it to bound their wall-clock budget. - @ObservationIgnored var prefetchTimeout: Duration = ChatViewModel.defaultPrefetchTimeout - - /// Contact ID currently having its favorite status toggled (for loading UI) - var togglingFavoriteID: UUID? - - // MARK: - Initialization - - init() {} - - /// Forwards a map-thumbnail tap to the same navigation sink the coordinate - /// text link uses. `onNavigateToMap` is optional; if nil, the tap is a - /// no-op (the always-present text link remains the baseline). - func navigateToMap(_ coordinate: CLLocationCoordinate2D) { - onNavigateToMap?(coordinate) + dividerComputed = true + } + + // MARK: - Dependencies + + /// Groups all provider closures for the `configure` call. Provider closures + /// are re-evaluated at every use so a disconnect (or a reconnect's fresh + /// per-connection services) is picked up live; a nil read means disconnected. + struct Dependencies { + var dataStore: @MainActor () -> DataStore? + var messageService: @MainActor () -> MessageService? + var notificationService: @MainActor () -> NotificationService? + var channelService: @MainActor () -> ChannelService? + var roomServerService: @MainActor () -> RoomServerService? + var contactService: @MainActor () -> ContactService? + var syncCoordinator: @MainActor () -> SyncCoordinator? + var connectionState: @MainActor () -> DeviceConnectionState + var connectedDevice: @MainActor () -> DeviceDTO? + var currentRadioID: @MainActor () -> UUID? + var session: @MainActor () -> MeshCoreSession? + var reactionService: @MainActor () -> ReactionService? + var chatSendQueueService: @MainActor () -> ChatSendQueueService? + var inlineImageDimensionsStore: @MainActor () -> InlineImageDimensionsStore? + var prefetchDataStore: @MainActor () -> (any PersistenceStoreProtocol)? + } + + @ObservationIgnored private var dataStoreProvider: @MainActor () -> DataStore? = { nil } + var dataStore: DataStore? { + dataStoreProvider() + } + + @ObservationIgnored private var messageServiceProvider: @MainActor () -> MessageService? = { nil } + var messageService: MessageService? { + messageServiceProvider() + } + + @ObservationIgnored private var notificationServiceProvider: @MainActor () -> NotificationService? = { nil } + var notificationService: NotificationService? { + notificationServiceProvider() + } + + @ObservationIgnored private var channelServiceProvider: @MainActor () -> ChannelService? = { nil } + private var channelService: ChannelService? { + channelServiceProvider() + } + + @ObservationIgnored private var roomServerServiceProvider: @MainActor () -> RoomServerService? = { nil } + private var roomServerService: RoomServerService? { + roomServerServiceProvider() + } + + @ObservationIgnored private var contactServiceProvider: @MainActor () -> ContactService? = { nil } + var contactService: ContactService? { + contactServiceProvider() + } + + @ObservationIgnored private var syncCoordinatorProvider: @MainActor () -> SyncCoordinator? = { nil } + var syncCoordinator: SyncCoordinator? { + syncCoordinatorProvider() + } + + @ObservationIgnored var connectionStateProvider: @MainActor () -> DeviceConnectionState = { .disconnected } + @ObservationIgnored var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } + @ObservationIgnored var currentRadioIDProvider: @MainActor () -> UUID? = { nil } + @ObservationIgnored var sessionProvider: @MainActor () -> MeshCoreSession? = { nil } + @ObservationIgnored var reactionServiceProvider: @MainActor () -> ReactionService? = { nil } + @ObservationIgnored var chatSendQueueServiceProvider: @MainActor () -> ChatSendQueueService? = { nil } + + @ObservationIgnored private var inlineImageDimensionsStoreProvider: @MainActor () -> InlineImageDimensionsStore? = { nil } + var inlineImageDimensionsStore: InlineImageDimensionsStore? { + inlineImageDimensionsStoreProvider() + } + + @ObservationIgnored private var prefetchDataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } + + /// App-lifetime cache for link previews. Passed directly from the environment; + /// held for the screen's lifetime and used in `fetchPreview` and `manualFetchPreview`. + @ObservationIgnored var linkPreviewCache: (any LinkPreviewCaching)? + + /// Navigation sink for map-thumbnail taps; nil makes the tap a no-op + /// (the always-present text link remains the baseline). + @ObservationIgnored var onNavigateToMap: ((CLLocationCoordinate2D) -> Void)? + + /// Drives receive-time prefetch of inline image dimensions and link + /// preview metadata so message bubbles render at final size on first + /// paint. Constructed in `configure(...)` when services are available; + /// nil while disconnected (offline browse never receives new messages). + @ObservationIgnored var prefetcher: InlineImagePrefetcher? + + /// Long-running subscription to `InlineImageDimensionsStore.resolutionStream`. + /// On each emitted URL, every message whose body contains that URL is + /// rebuilt so the bubble picks up the now-known `cachedAspect`. + @ObservationIgnored var dimensionResolutionTask: Task? + + /// Long-running subscription to `MapSnapshotStore.shared.resolutionStream`. + /// Started once (the store is a process-lifetime singleton). + @ObservationIgnored var snapshotResolutionTask: Task? + + /// Per-instance override of the receive-time prefetch timeout. Production + /// callers leave this at `defaultPrefetchTimeout` (3s); tests can shorten + /// it to bound their wall-clock budget. + @ObservationIgnored var prefetchTimeout: Duration = ChatViewModel.defaultPrefetchTimeout + + /// Contact ID currently having its favorite status toggled (for loading UI) + var togglingFavoriteID: UUID? + + // MARK: - Initialization + + init() {} + + /// Forwards a map-thumbnail tap to the same navigation sink the coordinate + /// text link uses. `onNavigateToMap` is optional; if nil, the tap is a + /// no-op (the always-present text link remains the baseline). + func navigateToMap(_ coordinate: CLLocationCoordinate2D) { + onNavigateToMap?(coordinate) + } + + /// Configure the chat view model for a conversation list or a specific conversation. + /// Conversation views also pass `linkPreviewCache`, `chatCoordinatorRegistry`, and + /// `conversation` so the per-conversation `ChatCoordinator` is bound before the + /// first view body evaluates; list views omit them. + func configure( + dependencies: Dependencies, + onNavigateToMap: ((CLLocationCoordinate2D) -> Void)?, + linkPreviewCache: (any LinkPreviewCaching)?, + chatCoordinatorRegistry: ChatCoordinatorRegistry?, + conversation: ChatConversationType? + ) { + dataStoreProvider = dependencies.dataStore + messageServiceProvider = dependencies.messageService + notificationServiceProvider = dependencies.notificationService + channelServiceProvider = dependencies.channelService + roomServerServiceProvider = dependencies.roomServerService + contactServiceProvider = dependencies.contactService + syncCoordinatorProvider = dependencies.syncCoordinator + connectionStateProvider = dependencies.connectionState + connectedDeviceProvider = dependencies.connectedDevice + currentRadioIDProvider = dependencies.currentRadioID + sessionProvider = dependencies.session + reactionServiceProvider = dependencies.reactionService + chatSendQueueServiceProvider = dependencies.chatSendQueueService + inlineImageDimensionsStoreProvider = dependencies.inlineImageDimensionsStore + prefetchDataStoreProvider = dependencies.prefetchDataStore + self.onNavigateToMap = onNavigateToMap + lastSetRegionScope = .unknown + if let linkPreviewCache { + self.linkPreviewCache = linkPreviewCache + configurePrefetcher( + linkPreviewCache: linkPreviewCache, + dimensionsStore: inlineImageDimensionsStoreProvider(), + prefetchDataStore: prefetchDataStoreProvider() + ) } - - /// Configure the chat view model for a conversation list or a specific conversation. - /// Conversation views also pass `linkPreviewCache`, `chatCoordinatorRegistry`, and - /// `conversation` so the per-conversation `ChatCoordinator` is bound before the - /// first view body evaluates; list views omit them. - func configure( - dependencies: Dependencies, - onNavigateToMap: ((CLLocationCoordinate2D) -> Void)?, - linkPreviewCache: (any LinkPreviewCaching)?, - chatCoordinatorRegistry: ChatCoordinatorRegistry?, - conversation: ChatConversationType? - ) { - self.dataStoreProvider = dependencies.dataStore - self.messageServiceProvider = dependencies.messageService - self.notificationServiceProvider = dependencies.notificationService - self.channelServiceProvider = dependencies.channelService - self.roomServerServiceProvider = dependencies.roomServerService - self.contactServiceProvider = dependencies.contactService - self.syncCoordinatorProvider = dependencies.syncCoordinator - self.connectionStateProvider = dependencies.connectionState - self.connectedDeviceProvider = dependencies.connectedDevice - self.currentRadioIDProvider = dependencies.currentRadioID - self.sessionProvider = dependencies.session - self.reactionServiceProvider = dependencies.reactionService - self.chatSendQueueServiceProvider = dependencies.chatSendQueueService - self.inlineImageDimensionsStoreProvider = dependencies.inlineImageDimensionsStore - self.prefetchDataStoreProvider = dependencies.prefetchDataStore - self.onNavigateToMap = onNavigateToMap - self.lastSetRegionScope = .unknown - if let linkPreviewCache { - self.linkPreviewCache = linkPreviewCache - configurePrefetcher( - linkPreviewCache: linkPreviewCache, - dimensionsStore: inlineImageDimensionsStoreProvider(), - prefetchDataStore: prefetchDataStoreProvider() - ) - } - bindCoordinator(registry: chatCoordinatorRegistry, conversation: conversation) + bindCoordinator(registry: chatCoordinatorRegistry, conversation: conversation) + } + + private func bindCoordinator(registry: ChatCoordinatorRegistry?, conversation: ChatConversationType?) { + guard let conversation else { return } + guard let registry else { return } + let id: ChatConversationID = switch conversation { + case let .dm(contact): + .dm(radioID: contact.radioID, contactID: contact.id) + case let .channel(channel): + .channel(radioID: channel.radioID, channelIndex: channel.index) } - - private func bindCoordinator(registry: ChatCoordinatorRegistry?, conversation: ChatConversationType?) { - guard let conversation else { return } - guard let registry else { return } - let id: ChatConversationID - switch conversation { - case .dm(let contact): - id = .dm(radioID: contact.radioID, contactID: contact.id) - case .channel(let channel): - id = .channel(radioID: channel.radioID, channelIndex: channel.index) - } - let resolved = registry.coordinator(for: id) - // The most recently bound view model owns the per-ID rebuild hook - // the coordinator invokes after `applyReloadedIDs`. With two view - // models on the same conversation (iPad split view) the rendered - // state stays consistent because both observe the shared - // coordinator's `renderState` — only the per-view-model snapshot - // inputs (preview state, decoded images) come from the bound - // rebuilder. - resolved.renderItemRebuilder = { [weak self] messageID in - self?.rebuildDisplayItem(for: messageID) - } - resolved.renderStateInvalidated = { [weak self] in - self?.handleRenderStateInvalidated() - } - coordinator = resolved + let resolved = registry.coordinator(for: id) + // The most recently bound view model owns the per-ID rebuild hook + // the coordinator invokes after `applyReloadedIDs`. With two view + // models on the same conversation (iPad split view) the rendered + // state stays consistent because both observe the shared + // coordinator's `renderState` — only the per-view-model snapshot + // inputs (preview state, decoded images) come from the bound + // rebuilder. + resolved.renderItemRebuilder = { [weak self] messageID in + self?.rebuildDisplayItem(for: messageID) } - - private func handleRenderStateInvalidated() { - buildItems() + resolved.renderStateInvalidated = { [weak self] in + self?.handleRenderStateInvalidated() } - - /// Build the receive-time prefetcher and start (or restart) the - /// dimension-resolution subscription. Called from `configure(...)` when a - /// `linkPreviewCache` is supplied. The per-connection inputs are nil while - /// disconnected (offline browse), which tears the prefetcher down. - private func configurePrefetcher( - linkPreviewCache: any LinkPreviewCaching, - dimensionsStore: InlineImageDimensionsStore?, - prefetchDataStore: (any PersistenceStoreProtocol)? - ) { - // The snapshot-resolution stream is a process-lifetime singleton with no - // dependency on per-connection services, so subscribe once regardless of - // connection state: offline browse of cached coordinate messages still - // needs late snapshot resolutions to refresh their thumbnails. - if snapshotResolutionTask == nil { - snapshotResolutionTask = Task { [weak self] in - for await request in MapSnapshotStore.shared.resolutionStream() { - guard !Task.isCancelled else { return } - self?.handleSnapshotResolution(request) - } - } - } - - guard let dimensionsStore, let prefetchDataStore else { - prefetcher = nil - dimensionResolutionTask?.cancel() - dimensionResolutionTask = nil - return + coordinator = resolved + } + + private func handleRenderStateInvalidated() { + buildItems() + } + + /// Build the receive-time prefetcher and start (or restart) the + /// dimension-resolution subscription. Called from `configure(...)` when a + /// `linkPreviewCache` is supplied. The per-connection inputs are nil while + /// disconnected (offline browse), which tears the prefetcher down. + private func configurePrefetcher( + linkPreviewCache: any LinkPreviewCaching, + dimensionsStore: InlineImageDimensionsStore?, + prefetchDataStore: (any PersistenceStoreProtocol)? + ) { + // The snapshot-resolution stream is a process-lifetime singleton with no + // dependency on per-connection services, so subscribe once regardless of + // connection state: offline browse of cached coordinate messages still + // needs late snapshot resolutions to refresh their thumbnails. + if snapshotResolutionTask == nil { + snapshotResolutionTask = Task { [weak self] in + for await request in MapSnapshotStore.shared.resolutionStream() { + guard !Task.isCancelled else { return } + self?.handleSnapshotResolution(request) } - prefetcher = InlineImagePrefetcher( - imageCache: InlineImageCache.shared, - linkPreviewCache: linkPreviewCache, - dimensionsStore: dimensionsStore, - dataStore: prefetchDataStore - ) - startObservingDimensionResolutions(store: dimensionsStore) + } } - private func startObservingDimensionResolutions(store: InlineImageDimensionsStore) { - dimensionResolutionTask?.cancel() - dimensionResolutionTask = Task { [weak self] in - for await resolvedURL in store.resolutionStream { - guard !Task.isCancelled else { return } - await self?.handleDimensionResolution(resolvedURL) - } - } + guard let dimensionsStore, let prefetchDataStore else { + prefetcher = nil + dimensionResolutionTask?.cancel() + dimensionResolutionTask = nil + return } - - deinit { - dimensionResolutionTask?.cancel() - snapshotResolutionTask?.cancel() + prefetcher = InlineImagePrefetcher( + imageCache: InlineImageCache.shared, + linkPreviewCache: linkPreviewCache, + dimensionsStore: dimensionsStore, + dataStore: prefetchDataStore + ) + startObservingDimensionResolutions(store: dimensionsStore) + } + + private func startObservingDimensionResolutions(store: InlineImageDimensionsStore) { + dimensionResolutionTask?.cancel() + dimensionResolutionTask = Task { [weak self] in + for await resolvedURL in store.resolutionStream { + guard !Task.isCancelled else { return } + await self?.handleDimensionResolution(resolvedURL) + } } + } - static func copyForEnqueueFailure(_ error: Error) -> String { - if case ChatSendQueueServiceError.notConnected = error { - return L10n.Chats.Chats.Alert.UnableToSend.message - } - return L10n.Chats.Chats.Error.sendQueuePersistFailed - } + deinit { + dimensionResolutionTask?.cancel() + snapshotResolutionTask?.cancel() + } + static func copyForEnqueueFailure(_ error: Error) -> String { + if case ChatSendQueueServiceError.notConnected = error { + return L10n.Chats.Chats.Alert.UnableToSend.message + } + return L10n.Chats.Chats.Error.sendQueuePersistFailed + } } // MARK: - Environment Key extension EnvironmentValues { - @Entry var chatViewModel: ChatViewModel? + @Entry var chatViewModel: ChatViewModel? } diff --git a/MC1/Views/Components/AsyncActionLabel.swift b/MC1/Views/Components/AsyncActionLabel.swift index c3d480e9..d1ee5930 100644 --- a/MC1/Views/Components/AsyncActionLabel.swift +++ b/MC1/Views/Components/AsyncActionLabel.swift @@ -5,24 +5,24 @@ import SwiftUI /// Used for buttons that trigger async firmware commands and need visual feedback. /// The success checkmark auto-animates in with a scale+opacity transition. struct AsyncActionLabel: View { - let isLoading: Bool - let showSuccess: Bool - @ViewBuilder let idle: () -> Idle + let isLoading: Bool + let showSuccess: Bool + @ViewBuilder let idle: () -> Idle - var body: some View { - HStack { - Spacer() - if isLoading { - ProgressView() - } else if showSuccess { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.scale.combined(with: .opacity)) - } else { - idle() - } - Spacer() - } - .animation(.default, value: showSuccess) + var body: some View { + HStack { + Spacer() + if isLoading { + ProgressView() + } else if showSuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + idle() + } + Spacer() } + .animation(.default, value: showSuccess) + } } diff --git a/MC1/Views/Components/BLEStatusIndicatorView.swift b/MC1/Views/Components/BLEStatusIndicatorView.swift index af87c679..09cc9ca6 100644 --- a/MC1/Views/Components/BLEStatusIndicatorView.swift +++ b/MC1/Views/Components/BLEStatusIndicatorView.swift @@ -1,216 +1,216 @@ +import MC1Services import OSLog import SwiftUI import TipKit -import MC1Services private let logger = Logger(subsystem: "com.mc1", category: "BLEStatus") /// BLE connection status indicator for toolbar display /// Shows connection state via color-coded icon with menu details struct BLEStatusIndicatorView: View { - @Environment(\.appState) private var appState - @State private var showingDeviceSelection = false - @State private var showingAdvancedSettings = false - @State private var isSendingAdvert = false - @State private var successFeedbackTrigger = false - @State private var errorFeedbackTrigger = false - - private let deviceMenuTip = DeviceMenuTip() - - // One always-present Menu, never an if/else between a Menu and a Button: SwiftUI - // gives a branch's two arms distinct identities, so toggling on connection state - // rebuilds the hosted toolbar item mid-update and trips a graph re-entrancy crash - // on iOS 26. Varying only the label and menu content by value updates it in place. - var body: some View { - ToolbarMenu { - menuContent - } label: { - StatusIcon(iconName: iconName, iconColor: iconColor, isAnimating: isAnimating) - } - .popoverTip(deviceMenuTip) - .dynamicTypeSize(...DynamicTypeSize.xLarge) - .sensoryFeedback(.success, trigger: successFeedbackTrigger) - .sensoryFeedback(.error, trigger: errorFeedbackTrigger) - .accessibilityLabel(L10n.Settings.BleStatus.accessibilityLabel) - .accessibilityValue(statusTitle) - .accessibilityHint(accessibilityHint) - .onChange(of: appState.connectedDevice != nil, initial: true) { _, isConnected in - DeviceMenuTip.isConnected = isConnected - } - .sheet(isPresented: $showingDeviceSelection) { - DeviceSelectionSheet() - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - .navigationDestination(isPresented: $showingAdvancedSettings) { - AdvancedSettingsView() - } + @Environment(\.appState) private var appState + @State private var showingDeviceSelection = false + @State private var showingAdvancedSettings = false + @State private var isSendingAdvert = false + @State private var successFeedbackTrigger = false + @State private var errorFeedbackTrigger = false + + private let deviceMenuTip = DeviceMenuTip() + + /// One always-present Menu, never an if/else between a Menu and a Button: SwiftUI + /// gives a branch's two arms distinct identities, so toggling on connection state + /// rebuilds the hosted toolbar item mid-update and trips a graph re-entrancy crash + /// on iOS 26. Varying only the label and menu content by value updates it in place. + var body: some View { + ToolbarMenu { + menuContent + } label: { + StatusIcon(iconName: iconName, iconColor: iconColor, isAnimating: isAnimating) } - - // MARK: - Menu Content - - @ViewBuilder - private var menuContent: some View { - if let device = appState.connectedDevice { - Section { - if device.clientRepeat { - Label(L10n.Settings.BleStatus.repeatModeActive, systemImage: "repeat") - .foregroundStyle(AppColors.Radio.repeatMode) - } - VStack(alignment: .leading) { - Label(device.nodeName, systemImage: "antenna.radiowaves.left.and.right") - if let battery = appState.batteryMonitor.deviceBattery { - let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) - Label( - "\(battery.percentage(using: ocvArray))% (\(battery.voltage, format: .number.precision(.fractionLength(2)))v)", - systemImage: battery.iconName(using: ocvArray) - ) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Button { - showingDeviceSelection = true - } label: { - Label(L10n.Settings.BleStatus.changeDevice, systemImage: "flipphone") - } - - Button(role: .destructive) { - logger.info("Disconnect tapped in BLE status menu") - Task { - await appState.disconnect(reason: .statusMenuDisconnectTap) - } - } label: { - Label(L10n.Settings.BleStatus.disconnect, systemImage: "eject") - } - } - - Section { - Button { - sendAdvert(flood: false) - } label: { - Label(L10n.Settings.BleStatus.sendZeroHopAdvert, systemImage: "dot.radiowaves.right") - } - .radioDisabled(for: appState.connectionState, or: isSendingAdvert) - .accessibilityHint(L10n.Settings.BleStatus.SendZeroHopAdvert.hint) - - Button { - sendAdvert(flood: true) - } label: { - Label(L10n.Settings.BleStatus.sendFloodAdvert, systemImage: "dot.radiowaves.left.and.right") - } - .radioDisabled(for: appState.connectionState, or: isSendingAdvert) - .accessibilityHint(L10n.Settings.BleStatus.SendFloodAdvert.hint) - } - - Section { - Button { - showingAdvancedSettings = true - } label: { - Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") - } - } - } else { - Button { - showingDeviceSelection = true - } label: { - Label(L10n.Settings.Device.connect, systemImage: "antenna.radiowaves.left.and.right") - } - } + .popoverTip(deviceMenuTip) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + .sensoryFeedback(.success, trigger: successFeedbackTrigger) + .sensoryFeedback(.error, trigger: errorFeedbackTrigger) + .accessibilityLabel(L10n.Settings.BleStatus.accessibilityLabel) + .accessibilityValue(statusTitle) + .accessibilityHint(accessibilityHint) + .onChange(of: appState.connectedDevice != nil, initial: true) { _, isConnected in + DeviceMenuTip.isConnected = isConnected } - - private var accessibilityHint: String { - appState.connectedDevice != nil - ? L10n.Settings.BleStatus.AccessibilityHint.connected - : L10n.Settings.BleStatus.AccessibilityHint.disconnected + .sheet(isPresented: $showingDeviceSelection) { + DeviceSelectionSheet() + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) } + .navigationDestination(isPresented: $showingAdvancedSettings) { + AdvancedSettingsView() + } + } - // MARK: - Computed Properties + // MARK: - Menu Content - private var iconName: String { - switch appState.connectionState { - case .disconnected: - "antenna.radiowaves.left.and.right.slash" - case .connecting, .connected, .syncing, .ready: - "antenna.radiowaves.left.and.right" + @ViewBuilder + private var menuContent: some View { + if let device = appState.connectedDevice { + Section { + if device.clientRepeat { + Label(L10n.Settings.BleStatus.repeatModeActive, systemImage: "repeat") + .foregroundStyle(AppColors.Radio.repeatMode) + } + VStack(alignment: .leading) { + Label(device.nodeName, systemImage: "antenna.radiowaves.left.and.right") + if let battery = appState.batteryMonitor.deviceBattery { + let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) + Label( + "\(battery.percentage(using: ocvArray))% (\(battery.voltage, format: .number.precision(.fractionLength(2)))v)", + systemImage: battery.iconName(using: ocvArray) + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Button { + showingDeviceSelection = true + } label: { + Label(L10n.Settings.BleStatus.changeDevice, systemImage: "flipphone") } - } - private var iconColor: Color { - if appState.connectedDevice?.clientRepeat == true { - return AppColors.Radio.repeatMode + Button(role: .destructive) { + logger.info("Disconnect tapped in BLE status menu") + Task { + await appState.disconnect(reason: .statusMenuDisconnectTap) + } + } label: { + Label(L10n.Settings.BleStatus.disconnect, systemImage: "eject") } - switch appState.connectionState { - case .disconnected: - return .secondary - case .connecting, .connected, .syncing: - return AppColors.Radio.connecting - case .ready: - return AppColors.Radio.ready + } + + Section { + Button { + sendAdvert(flood: false) + } label: { + Label(L10n.Settings.BleStatus.sendZeroHopAdvert, systemImage: "dot.radiowaves.right") } - } + .radioDisabled(for: appState.connectionState, or: isSendingAdvert) + .accessibilityHint(L10n.Settings.BleStatus.SendZeroHopAdvert.hint) - private var isAnimating: Bool { - appState.connectionState == .connecting - } + Button { + sendAdvert(flood: true) + } label: { + Label(L10n.Settings.BleStatus.sendFloodAdvert, systemImage: "dot.radiowaves.left.and.right") + } + .radioDisabled(for: appState.connectionState, or: isSendingAdvert) + .accessibilityHint(L10n.Settings.BleStatus.SendFloodAdvert.hint) + } - private var statusTitle: String { - switch appState.connectionState { - case .disconnected: - L10n.Settings.BleStatus.Status.disconnected - case .connecting: - L10n.Settings.BleStatus.Status.connecting - case .connected: - L10n.Settings.BleStatus.Status.connected - case .syncing: - L10n.Settings.BleStatus.Status.syncing - case .ready: - L10n.Settings.BleStatus.Status.ready + Section { + Button { + showingAdvancedSettings = true + } label: { + Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") } + } + } else { + Button { + showingDeviceSelection = true + } label: { + Label(L10n.Settings.Device.connect, systemImage: "antenna.radiowaves.left.and.right") + } } + } + + private var accessibilityHint: String { + appState.connectedDevice != nil + ? L10n.Settings.BleStatus.AccessibilityHint.connected + : L10n.Settings.BleStatus.AccessibilityHint.disconnected + } + + // MARK: - Computed Properties + + private var iconName: String { + switch appState.connectionState { + case .disconnected: + "antenna.radiowaves.left.and.right.slash" + case .connecting, .connected, .syncing, .ready: + "antenna.radiowaves.left.and.right" + } + } - // MARK: - Actions - - private func sendAdvert(flood: Bool) { - guard !isSendingAdvert else { return } - isSendingAdvert = true - - Task { - do { - try await appState.sendSelfAdvert(flood: flood) - successFeedbackTrigger.toggle() - } catch { - logger.error("Failed to send advert (flood=\(flood)): \(error.localizedDescription)") - errorFeedbackTrigger.toggle() - } - isSendingAdvert = false - } + private var iconColor: Color { + if appState.connectedDevice?.clientRepeat == true { + return AppColors.Radio.repeatMode + } + switch appState.connectionState { + case .disconnected: + return .secondary + case .connecting, .connected, .syncing: + return AppColors.Radio.connecting + case .ready: + return AppColors.Radio.ready + } + } + + private var isAnimating: Bool { + appState.connectionState == .connecting + } + + private var statusTitle: String { + switch appState.connectionState { + case .disconnected: + L10n.Settings.BleStatus.Status.disconnected + case .connecting: + L10n.Settings.BleStatus.Status.connecting + case .connected: + L10n.Settings.BleStatus.Status.connected + case .syncing: + L10n.Settings.BleStatus.Status.syncing + case .ready: + L10n.Settings.BleStatus.Status.ready } + } + + // MARK: - Actions + + private func sendAdvert(flood: Bool) { + guard !isSendingAdvert else { return } + isSendingAdvert = true + + Task { + do { + try await appState.sendSelfAdvert(flood: flood) + successFeedbackTrigger.toggle() + } catch { + logger.error("Failed to send advert (flood=\(flood)): \(error.localizedDescription)") + errorFeedbackTrigger.toggle() + } + isSendingAdvert = false + } + } } // MARK: - Status Icon private struct StatusIcon: View { - let iconName: String - let iconColor: Color - let isAnimating: Bool - - var body: some View { - Image(systemName: iconName) - .foregroundStyle(iconColor) - .symbolEffect(.pulse, isActive: isAnimating) - } + let iconName: String + let iconColor: Color + let isAnimating: Bool + + var body: some View { + Image(systemName: iconName) + .foregroundStyle(iconColor) + .symbolEffect(.pulse, isActive: isAnimating) + } } #Preview("Disconnected") { - NavigationStack { - Text("Content") - .toolbar { - ToolbarItem(placement: .topBarLeading) { - BLEStatusIndicatorView() - } - } - } - .environment(\.appState, AppState()) + NavigationStack { + Text("Content") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + BLEStatusIndicatorView() + } + } + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Components/BLEStatusToolbarItem.swift b/MC1/Views/Components/BLEStatusToolbarItem.swift index 5df96b3f..1d73eb64 100644 --- a/MC1/Views/Components/BLEStatusToolbarItem.swift +++ b/MC1/Views/Components/BLEStatusToolbarItem.swift @@ -8,7 +8,7 @@ import SwiftUI @MainActor @ToolbarContentBuilder func bleStatusToolbarItem(placement: ToolbarItemPlacement = .topBarLeading) -> some ToolbarContent { - ToolbarItem(placement: placement) { - BLEStatusIndicatorView() - } + ToolbarItem(placement: placement) { + BLEStatusIndicatorView() + } } diff --git a/MC1/Views/Components/ContactAvatar.swift b/MC1/Views/Components/ContactAvatar.swift index 55b2b36c..c75a8b65 100644 --- a/MC1/Views/Components/ContactAvatar.swift +++ b/MC1/Views/Components/ContactAvatar.swift @@ -1,52 +1,52 @@ -import SwiftUI import MC1Services +import SwiftUI struct ContactAvatar: View { - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - let name: String - let size: CGFloat + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + let name: String + let size: CGFloat - init(contact: ContactDTO, size: CGFloat) { - self.name = contact.displayName - self.size = size - } + init(contact: ContactDTO, size: CGFloat) { + name = contact.displayName + self.size = size + } - init(name: String, size: CGFloat) { - self.name = name - self.size = size - } + init(name: String, size: CGFloat) { + self.name = name + self.size = size + } - var body: some View { - Text(initials) - .font(.system(size: size * 0.4, weight: .semibold)) - .foregroundStyle(glyphColor) - .frame(width: size, height: size) - .background(avatarColor, in: .circle) - } + var body: some View { + Text(initials) + .font(.system(size: size * 0.4, weight: .semibold)) + .foregroundStyle(glyphColor) + .frame(width: size, height: size) + .background(avatarColor, in: .circle) + } - private var initials: String { - if let emoji = name.first(where: \.isEmoji) { - return String(emoji) - } - let words = name.split(separator: " ") - if words.count >= 2 { - return String(words[0].prefix(1) + words[1].prefix(1)).uppercased() - } - return String(name.prefix(1)).uppercased() + private var initials: String { + if let emoji = name.first(where: \.isEmoji) { + return String(emoji) } - - private var avatarColor: Color { - theme.identityColor(forName: name, colorScheme: colorScheme, contrast: colorSchemeContrast) + let words = name.split(separator: " ") + if words.count >= 2 { + return String(words[0].prefix(1) + words[1].prefix(1)).uppercased() } + return String(name.prefix(1)).uppercased() + } - private var glyphColor: Color { - theme.avatarGlyphColor( - forFill: avatarColor, - usesCategoryOverride: false, - colorScheme: colorScheme, - contrast: colorSchemeContrast - ) - } + private var avatarColor: Color { + theme.identityColor(forName: name, colorScheme: colorScheme, contrast: colorSchemeContrast) + } + + private var glyphColor: Color { + theme.avatarGlyphColor( + forFill: avatarColor, + usesCategoryOverride: false, + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + } } diff --git a/MC1/Views/Components/ConversationQuickActionsSection.swift b/MC1/Views/Components/ConversationQuickActionsSection.swift index 071644a1..aa6375dc 100644 --- a/MC1/Views/Components/ConversationQuickActionsSection.swift +++ b/MC1/Views/Components/ConversationQuickActionsSection.swift @@ -1,31 +1,31 @@ -import SwiftUI import MC1Services +import SwiftUI struct ConversationQuickActionsSection: View { - @Environment(\.appTheme) private var theme - @Binding var notificationLevel: NotificationLevel - @Binding var isFavorite: Bool - let availableLevels: [NotificationLevel] + @Environment(\.appTheme) private var theme + @Binding var notificationLevel: NotificationLevel + @Binding var isFavorite: Bool + let availableLevels: [NotificationLevel] - init( - notificationLevel: Binding, - isFavorite: Binding, - availableLevels: [NotificationLevel] = NotificationLevel.allCases - ) { - self._notificationLevel = notificationLevel - self._isFavorite = isFavorite - self.availableLevels = availableLevels - } + init( + notificationLevel: Binding, + isFavorite: Binding, + availableLevels: [NotificationLevel] = NotificationLevel.allCases + ) { + _notificationLevel = notificationLevel + _isFavorite = isFavorite + self.availableLevels = availableLevels + } - var body: some View { - Section { - VStack(spacing: 16) { - NotificationLevelPicker(selection: $notificationLevel, availableLevels: availableLevels) + var body: some View { + Section { + VStack(spacing: 16) { + NotificationLevelPicker(selection: $notificationLevel, availableLevels: availableLevels) - FavoriteToggleRow(isFavorite: $isFavorite) - } - .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) - } - .themedRowBackground(theme) + FavoriteToggleRow(isFavorite: $isFavorite) + } + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Components/ConversationTimestamp.swift b/MC1/Views/Components/ConversationTimestamp.swift index dfc0c255..b3daeef6 100644 --- a/MC1/Views/Components/ConversationTimestamp.swift +++ b/MC1/Views/Components/ConversationTimestamp.swift @@ -1,34 +1,34 @@ import SwiftUI struct ConversationTimestamp: View { - let date: Date - var font: Font = .caption - var referenceDate: Date? + let date: Date + var font: Font = .caption + var referenceDate: Date? - var body: some View { - if let referenceDate { - Text(formattedDate(relativeTo: referenceDate)) - .font(font) - .foregroundStyle(.secondary) - } else { - TimelineView(.everyMinute) { context in - Text(formattedDate(relativeTo: context.date)) - .font(font) - .foregroundStyle(.secondary) - } - } + var body: some View { + if let referenceDate { + Text(formattedDate(relativeTo: referenceDate)) + .font(font) + .foregroundStyle(.secondary) + } else { + TimelineView(.everyMinute) { context in + Text(formattedDate(relativeTo: context.date)) + .font(font) + .foregroundStyle(.secondary) + } } + } - private func formattedDate(relativeTo now: Date) -> String { - let calendar = Calendar.current + private func formattedDate(relativeTo now: Date) -> String { + let calendar = Calendar.current - if calendar.isDate(date, inSameDayAs: now) { - return date.formatted(date: .omitted, time: .shortened) - } else if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), - calendar.isDate(date, inSameDayAs: yesterday) { - return date.formatted(.relative(presentation: .named)) - } else { - return date.formatted(.dateTime.month(.abbreviated).day()) - } + if calendar.isDate(date, inSameDayAs: now) { + return date.formatted(date: .omitted, time: .shortened) + } else if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), + calendar.isDate(date, inSameDayAs: yesterday) { + return date.formatted(.relative(presentation: .named)) + } else { + return date.formatted(.dateTime.month(.abbreviated).day()) } + } } diff --git a/MC1/Views/Components/DeletingRowOverlay.swift b/MC1/Views/Components/DeletingRowOverlay.swift index 8030f473..5ea26a3d 100644 --- a/MC1/Views/Components/DeletingRowOverlay.swift +++ b/MC1/Views/Components/DeletingRowOverlay.swift @@ -3,26 +3,26 @@ import SwiftUI /// A trailing spinner plus dim shown while a confirmation-gated delete is in flight for a row. /// Shared by the conversation and node lists. struct DeletingRowOverlay: ViewModifier { - let isDeleting: Bool + let isDeleting: Bool - private static let spinnerTrailingInset: CGFloat = 16 - private static let deletingOpacity: Double = 0.5 + private static let spinnerTrailingInset: CGFloat = 16 + private static let deletingOpacity: Double = 0.5 - func body(content: Content) -> some View { - content - .overlay(alignment: .trailing) { - if isDeleting { - ProgressView().padding(.trailing, Self.spinnerTrailingInset) - } - } - .opacity(isDeleting ? Self.deletingOpacity : 1) - .allowsHitTesting(!isDeleting) - .animation(.easeInOut, value: isDeleting) - } + func body(content: Content) -> some View { + content + .overlay(alignment: .trailing) { + if isDeleting { + ProgressView().padding(.trailing, Self.spinnerTrailingInset) + } + } + .opacity(isDeleting ? Self.deletingOpacity : 1) + .allowsHitTesting(!isDeleting) + .animation(.easeInOut, value: isDeleting) + } } extension View { - func deletingRowOverlay(isDeleting: Bool) -> some View { - modifier(DeletingRowOverlay(isDeleting: isDeleting)) - } + func deletingRowOverlay(isDeleting: Bool) -> some View { + modifier(DeletingRowOverlay(isDeleting: isDeleting)) + } } diff --git a/MC1/Views/Components/ErrorAlertModifier.swift b/MC1/Views/Components/ErrorAlertModifier.swift index 980d4e54..aba554d8 100644 --- a/MC1/Views/Components/ErrorAlertModifier.swift +++ b/MC1/Views/Components/ErrorAlertModifier.swift @@ -5,45 +5,45 @@ import SwiftUI /// `retryAction`, when provided, adds a "Try Again" button alongside OK — used by /// action contexts (product load, chat retry) where a user-initiated retry is meaningful. struct ErrorAlertModifier: ViewModifier { - @Binding var errorMessage: String? - let title: String? - let retryAction: (() -> Void)? + @Binding var errorMessage: String? + let title: String? + let retryAction: (() -> Void)? - private var resolvedTitle: String { - title ?? L10n.Settings.Alert.Error.title - } + private var resolvedTitle: String { + title ?? L10n.Settings.Alert.Error.title + } - private var isPresented: Binding { - Binding( - get: { errorMessage != nil }, - set: { if !$0 { errorMessage = nil } } - ) - } + private var isPresented: Binding { + Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + } - func body(content: Content) -> some View { - content - .alert(resolvedTitle, isPresented: isPresented) { - if let retryAction { - Button(L10n.Localizable.Common.tryAgain) { - errorMessage = nil - retryAction() - } - } - Button(L10n.Localizable.Common.ok) { - errorMessage = nil - } - } message: { - Text(errorMessage ?? "") - } - } + func body(content: Content) -> some View { + content + .alert(resolvedTitle, isPresented: isPresented) { + if let retryAction { + Button(L10n.Localizable.Common.tryAgain) { + errorMessage = nil + retryAction() + } + } + Button(L10n.Localizable.Common.ok) { + errorMessage = nil + } + } message: { + Text(errorMessage ?? "") + } + } } extension View { - func errorAlert( - _ errorMessage: Binding, - title: String? = nil, - retryAction: (() -> Void)? = nil - ) -> some View { - modifier(ErrorAlertModifier(errorMessage: errorMessage, title: title, retryAction: retryAction)) - } + func errorAlert( + _ errorMessage: Binding, + title: String? = nil, + retryAction: (() -> Void)? = nil + ) -> some View { + modifier(ErrorAlertModifier(errorMessage: errorMessage, title: title, retryAction: retryAction)) + } } diff --git a/MC1/Views/Components/ErrorBannerModifier.swift b/MC1/Views/Components/ErrorBannerModifier.swift index 5b3eea57..69560bca 100644 --- a/MC1/Views/Components/ErrorBannerModifier.swift +++ b/MC1/Views/Components/ErrorBannerModifier.swift @@ -5,51 +5,51 @@ import SwiftUI /// strip to dismiss; call sites typically clear the binding on the next /// successful load. struct ErrorBannerModifier: ViewModifier { - @Binding var errorMessage: String? + @Binding var errorMessage: String? - func body(content: Content) -> some View { - content.safeAreaInset(edge: .bottom, spacing: 0) { - if let message = errorMessage { - Button { - errorMessage = nil - } label: { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.circle") - .foregroundStyle(.red) - Text(message) - .font(.footnote) - .foregroundStyle(.primary) - .multilineTextAlignment(.leading) - Spacer(minLength: 0) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) - .contentShape(.rect) - .background( - Color(.systemRed).opacity(0.12), - in: .rect(cornerRadius: 12) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color(.systemRed).opacity(0.25), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .accessibilityLabel(message) - .accessibilityHint(L10n.Chats.Chats.Error.Banner.dismissAccessibilityHint) - } + func body(content: Content) -> some View { + content.safeAreaInset(edge: .bottom, spacing: 0) { + if let message = errorMessage { + Button { + errorMessage = nil + } label: { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.circle") + .foregroundStyle(.red) + Text(message) + .font(.footnote) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .contentShape(.rect) + .background( + Color(.systemRed).opacity(0.12), + in: .rect(cornerRadius: 12) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(.systemRed).opacity(0.25), lineWidth: 1) + ) } + .buttonStyle(.plain) + .accessibilityLabel(message) + .accessibilityHint(L10n.Chats.Chats.Error.Banner.dismissAccessibilityHint) + } } + } } extension View { - /// Mounts a passive error banner driven by a `Binding`. The banner - /// appears at the bottom safe-area inset when the binding is non-nil and - /// stays visible until the user taps it or a call site clears the binding - /// on the next successful load. Use for background failures (loads, - /// prefetch). Use `.errorAlert(...)` for user-initiated failures. - func errorBanner(_ errorMessage: Binding) -> some View { - modifier(ErrorBannerModifier(errorMessage: errorMessage)) - } + /// Mounts a passive error banner driven by a `Binding`. The banner + /// appears at the bottom safe-area inset when the binding is non-nil and + /// stays visible until the user taps it or a call site clears the binding + /// on the next successful load. Use for background failures (loads, + /// prefetch). Use `.errorAlert(...)` for user-initiated failures. + func errorBanner(_ errorMessage: Binding) -> some View { + modifier(ErrorBannerModifier(errorMessage: errorMessage)) + } } diff --git a/MC1/Views/Components/ExpandableSettingsSection.swift b/MC1/Views/Components/ExpandableSettingsSection.swift index a58c9e73..f8930e87 100644 --- a/MC1/Views/Components/ExpandableSettingsSection.swift +++ b/MC1/Views/Components/ExpandableSettingsSection.swift @@ -3,126 +3,126 @@ import SwiftUI /// A collapsible section that auto-loads data when expanded /// More iOS-native than explicit "Load" buttons struct ExpandableSettingsSection: View { - @Environment(\.appTheme) private var theme - let title: String - let icon: String + @Environment(\.appTheme) private var theme + let title: String + let icon: String - @Binding var isExpanded: Bool - let isLoaded: () -> Bool // Closure instead of binding (supports computed properties) - @Binding var isLoading: Bool - @Binding var hasError: Bool + @Binding var isExpanded: Bool + let isLoaded: () -> Bool // Closure instead of binding (supports computed properties) + @Binding var isLoading: Bool + @Binding var hasError: Bool - let onLoad: () async -> Void - let footer: String? - @ViewBuilder let content: () -> Content + let onLoad: () async -> Void + let footer: String? + @ViewBuilder let content: () -> Content - init( - title: String, - icon: String, - isExpanded: Binding, - isLoaded: @escaping () -> Bool, - isLoading: Binding, - hasError: Binding, - onLoad: @escaping () async -> Void, - footer: String? = nil, - @ViewBuilder content: @escaping () -> Content - ) { - self.title = title - self.icon = icon - self._isExpanded = isExpanded - self.isLoaded = isLoaded - self._isLoading = isLoading - self._hasError = hasError - self.onLoad = onLoad - self.footer = footer - self.content = content - } + init( + title: String, + icon: String, + isExpanded: Binding, + isLoaded: @escaping () -> Bool, + isLoading: Binding, + hasError: Binding, + onLoad: @escaping () async -> Void, + footer: String? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.icon = icon + _isExpanded = isExpanded + self.isLoaded = isLoaded + _isLoading = isLoading + _hasError = hasError + self.onLoad = onLoad + self.footer = footer + self.content = content + } - var body: some View { - Section { - DisclosureGroup(isExpanded: $isExpanded) { - // Always show content - individual fields handle nil/loading states - // with "loading..." overlays when their values haven't arrived yet - content() + var body: some View { + Section { + DisclosureGroup(isExpanded: $isExpanded) { + // Always show content - individual fields handle nil/loading states + // with "loading..." overlays when their values haven't arrived yet + content() - // Show error banner if something failed - if hasError && !isLoaded() { - VStack(spacing: 12) { - Label(L10n.Localizable.Common.Error.failedToLoad, systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) - Button(L10n.Localizable.Common.tryAgain) { - Task { await onLoad() } - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets()) - } - } label: { - HStack { - Label(title, systemImage: icon) - Spacer() - if isLoading { - ProgressView() - .scaleEffect(0.8) - .padding(.trailing) - } else if isExpanded && isLoaded() { - Button { - Task { await onLoad() } - } label: { - Image(systemName: "arrow.clockwise") - .font(.caption) - } - .buttonStyle(.borderless) - .padding(.trailing) - } - } - } - } footer: { - if let footer { - Text(footer) - } - } - .themedRowBackground(theme) - .onChange(of: isExpanded) { _, expanded in - if expanded && !isLoaded() && !isLoading { - Task { await onLoad() } + // Show error banner if something failed + if hasError, !isLoaded() { + VStack(spacing: 12) { + Label(L10n.Localizable.Common.Error.failedToLoad, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + Button(L10n.Localizable.Common.tryAgain) { + Task { await onLoad() } } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) } - .task { - // Trigger initial load if section starts expanded - // (onChange only fires when value changes, not on initial render) - if isExpanded && !isLoaded() && !isLoading { - await onLoad() + } label: { + HStack { + Label(title, systemImage: icon) + Spacer() + if isLoading { + ProgressView() + .scaleEffect(0.8) + .padding(.trailing) + } else if isExpanded, isLoaded() { + Button { + Task { await onLoad() } + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption) } + .buttonStyle(.borderless) + .padding(.trailing) + } } + } + } footer: { + if let footer { + Text(footer) + } + } + .themedRowBackground(theme) + .onChange(of: isExpanded) { _, expanded in + if expanded, !isLoaded(), !isLoading { + Task { await onLoad() } + } + } + .task { + // Trigger initial load if section starts expanded + // (onChange only fires when value changes, not on initial render) + if isExpanded, !isLoaded(), !isLoading { + await onLoad() + } } + } } #Preview { - @Previewable @State var isExpanded = false - @Previewable @State var isLoading = false - @Previewable @State var hasError = false - @Previewable @State var data: String? + @Previewable @State var isExpanded = false + @Previewable @State var isLoading = false + @Previewable @State var hasError = false + @Previewable @State var data: String? - Form { - ExpandableSettingsSection( - title: "Device Info", - icon: "info.circle", - isExpanded: $isExpanded, - isLoaded: { data != nil }, - isLoading: $isLoading, - hasError: $hasError, - onLoad: { - isLoading = true - try? await Task.sleep(for: .seconds(1)) - data = "Loaded!" - isLoading = false - } - ) { - Text(data ?? "") - } + Form { + ExpandableSettingsSection( + title: "Device Info", + icon: "info.circle", + isExpanded: $isExpanded, + isLoaded: { data != nil }, + isLoading: $isLoading, + hasError: $hasError, + onLoad: { + isLoading = true + try? await Task.sleep(for: .seconds(1)) + data = "Loaded!" + isLoading = false + } + ) { + Text(data ?? "") } + } } diff --git a/MC1/Views/Components/FavoriteToggleRow.swift b/MC1/Views/Components/FavoriteToggleRow.swift index 114085ff..c0949c9f 100644 --- a/MC1/Views/Components/FavoriteToggleRow.swift +++ b/MC1/Views/Components/FavoriteToggleRow.swift @@ -1,22 +1,22 @@ import SwiftUI struct FavoriteToggleRow: View { - @Binding var isFavorite: Bool + @Binding var isFavorite: Bool - var body: some View { - HStack { - Image(systemName: isFavorite ? "star.fill" : "star") - .foregroundStyle(isFavorite ? .yellow : .secondary) + var body: some View { + HStack { + Image(systemName: isFavorite ? "star.fill" : "star") + .foregroundStyle(isFavorite ? .yellow : .secondary) - Text(L10n.Chats.Chats.Row.favorite) + Text(L10n.Chats.Chats.Row.favorite) - Spacer() + Spacer() - Toggle("", isOn: $isFavorite) - .labelsHidden() - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Row.favorite) - .accessibilityValue(isFavorite ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) + Toggle("", isOn: $isFavorite) + .labelsHidden() } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Chats.Chats.Row.favorite) + .accessibilityValue(isFavorite ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) + } } diff --git a/MC1/Views/Components/GlassFilterBar.swift b/MC1/Views/Components/GlassFilterBar.swift index a33091ee..76d7a5a4 100644 --- a/MC1/Views/Components/GlassFilterBar.swift +++ b/MC1/Views/Components/GlassFilterBar.swift @@ -23,176 +23,175 @@ private let selectedSegmentTintDark = Color.white.opacity(0.16) /// Designed to be hosted via `.safeAreaInset(edge: .top)` so list content /// scrolls behind the glass on iOS 26. struct GlassFilterBar: View -where Filter.AllCases: RandomAccessCollection { - - /// Pill sizing. `regular` is the compact default for the chat/contact filters; - /// `large` gives the roomier pills used by the node management sheet. - enum Size { - case regular - case large - - var horizontalPadding: CGFloat { - switch self { - case .regular: regularPillHorizontalPadding - case .large: largePillHorizontalPadding - } - } - - var verticalPadding: CGFloat { - switch self { - case .regular: regularPillVerticalPadding - case .large: largePillVerticalPadding - } - } - - var font: Font { - switch self { - case .regular: .subheadline - case .large: .callout - } - } - - var controlSize: ControlSize { - switch self { - case .regular: .regular - case .large: .large - } - } + where Filter.AllCases: RandomAccessCollection { + /// Pill sizing. `regular` is the compact default for the chat/contact filters; + /// `large` gives the roomier pills used by the node management sheet. + enum Size { + case regular + case large + + var horizontalPadding: CGFloat { + switch self { + case .regular: regularPillHorizontalPadding + case .large: largePillHorizontalPadding + } } - @Binding var selection: Filter - let isSearching: Bool - let pickerLabel: String - let title: @Sendable (Filter) -> String - var size: Size = .regular - - @Namespace private var glassNamespace - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.colorScheme) private var colorScheme - - private var selectedSegmentTint: Color { - colorScheme == .dark ? selectedSegmentTintDark : selectedSegmentTintLight + var verticalPadding: CGFloat { + switch self { + case .regular: regularPillVerticalPadding + case .large: largePillVerticalPadding + } } - var body: some View { - Group { - if #available(iOS 26.0, *) { - glassPills - } else { - segmentedFallback - } - } - .opacity(isSearching ? dimmedOpacity : 1.0) - .disabled(isSearching) + var font: Font { + switch self { + case .regular: .subheadline + case .large: .callout + } } - @available(iOS 26.0, *) - private var glassPills: some View { - ViewThatFits(in: .horizontal) { - pillsRow - ScrollView(.horizontal, showsIndicators: false) { - pillsRow - } - .scrollClipDisabled() - } - .frame(maxWidth: .infinity) - // Scope the pill morph to the bar so it doesn't leak a transaction into consumer - // content (a list's row transitions would otherwise animate on every filter tap). - .animation(reduceMotion ? nil : .smooth, value: selection) + var controlSize: ControlSize { + switch self { + case .regular: .regular + case .large: .large + } } - - @available(iOS 26.0, *) - private var pillsRow: some View { - GlassEffectContainer(spacing: pillSpacing) { - HStack(spacing: pillSpacing) { - ForEach(Filter.allCases, id: \.self) { filter in - pill(for: filter) - } - } - .padding(.horizontal, barHorizontalPadding) - .padding(.top, barTopPadding) - .padding(.bottom, barBottomPadding) - } + } + + @Binding var selection: Filter + let isSearching: Bool + let pickerLabel: String + let title: @Sendable (Filter) -> String + var size: Size = .regular + + @Namespace private var glassNamespace + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorScheme) private var colorScheme + + private var selectedSegmentTint: Color { + colorScheme == .dark ? selectedSegmentTintDark : selectedSegmentTintLight + } + + var body: some View { + Group { + if #available(iOS 26.0, *) { + glassPills + } else { + segmentedFallback + } } - - @available(iOS 26.0, *) - @ViewBuilder - private func pill(for filter: Filter) -> some View { - let isSelected = selection == filter - Button { - selection = filter - } label: { - Text(title(filter)) - .lineLimit(1) - .font(size.font) - .foregroundStyle(.primary) - .padding(.horizontal, size.horizontalPadding) - .padding(.vertical, size.verticalPadding) - .contentShape(.capsule) - } - .buttonStyle(.plain) - .glassEffect( - isSelected ? .regular.tint(selectedSegmentTint).interactive() : .regular.interactive(), - in: .capsule - ) - .glassEffectID(filter, in: glassNamespace) + .opacity(isSearching ? dimmedOpacity : 1.0) + .disabled(isSearching) + } + + @available(iOS 26.0, *) + private var glassPills: some View { + ViewThatFits(in: .horizontal) { + pillsRow + ScrollView(.horizontal, showsIndicators: false) { + pillsRow + } + .scrollClipDisabled() } - - private var segmentedFallback: some View { - Picker(pickerLabel, selection: $selection) { - ForEach(Filter.allCases, id: \.self) { filter in - Text(title(filter)).tag(filter) - } + .frame(maxWidth: .infinity) + // Scope the pill morph to the bar so it doesn't leak a transaction into consumer + // content (a list's row transitions would otherwise animate on every filter tap). + .animation(reduceMotion ? nil : .smooth, value: selection) + } + + @available(iOS 26.0, *) + private var pillsRow: some View { + GlassEffectContainer(spacing: pillSpacing) { + HStack(spacing: pillSpacing) { + ForEach(Filter.allCases, id: \.self) { filter in + pill(for: filter) } - .pickerStyle(.segmented) - .controlSize(size.controlSize) - .padding(.horizontal, barHorizontalPadding) - .padding(.top, segmentedTopPadding) - .padding(.bottom, barBottomPadding) + } + .padding(.horizontal, barHorizontalPadding) + .padding(.top, barTopPadding) + .padding(.bottom, barBottomPadding) + } + } + + @available(iOS 26.0, *) + @ViewBuilder + private func pill(for filter: Filter) -> some View { + let isSelected = selection == filter + Button { + selection = filter + } label: { + Text(title(filter)) + .lineLimit(1) + .font(size.font) + .foregroundStyle(.primary) + .padding(.horizontal, size.horizontalPadding) + .padding(.vertical, size.verticalPadding) + .contentShape(.capsule) + } + .buttonStyle(.plain) + .glassEffect( + isSelected ? .regular.tint(selectedSegmentTint).interactive() : .regular.interactive(), + in: .capsule + ) + .glassEffectID(filter, in: glassNamespace) + } + + private var segmentedFallback: some View { + Picker(pickerLabel, selection: $selection) { + ForEach(Filter.allCases, id: \.self) { filter in + Text(title(filter)).tag(filter) + } } + .pickerStyle(.segmented) + .controlSize(size.controlSize) + .padding(.horizontal, barHorizontalPadding) + .padding(.top, segmentedTopPadding) + .padding(.bottom, barBottomPadding) + } } extension View { - /// Backs the pinned filter header with the themed canvas on iOS 18, where the fallback - /// segmented `Picker` is transparent and would let scrolling rows show through. iOS 26 glass - /// pills carry their own material and float over the content, so no backing is applied. - @ViewBuilder - func pinnedFilterHeaderBackground(_ theme: Theme) -> some View { - if #available(iOS 26.0, *) { - self - } else { - background(theme.surfaces?.canvas ?? Color(.systemBackground)) - } + /// Backs the pinned filter header with the themed canvas on iOS 18, where the fallback + /// segmented `Picker` is transparent and would let scrolling rows show through. iOS 26 glass + /// pills carry their own material and float over the content, so no backing is applied. + @ViewBuilder + func pinnedFilterHeaderBackground(_ theme: Theme) -> some View { + if #available(iOS 26.0, *) { + self + } else { + background(theme.surfaces?.canvas ?? Color(.systemBackground)) } + } } -private enum FilterBarPreviewFilter: String, CaseIterable, Sendable { - case all, unread, dms, channels, rooms +private enum FilterBarPreviewFilter: String, CaseIterable { + case all, unread, dms, channels, rooms - var label: String { - switch self { - case .all: "All" - case .unread: "Unread" - case .dms: "DMs" - case .channels: "Channels" - case .rooms: "Rooms" - } + var label: String { + switch self { + case .all: "All" + case .unread: "Unread" + case .dms: "DMs" + case .channels: "Channels" + case .rooms: "Rooms" } + } } #Preview { - @Previewable @State var selection = FilterBarPreviewFilter.all - - List(0..<20) { row in - Text("Row \(row)") - } - .safeAreaInset(edge: .top, spacing: 0) { - GlassFilterBar( - selection: $selection, - isSearching: false, - pickerLabel: "View", - title: { $0.label } - ) - .frame(maxWidth: .infinity) - } + @Previewable @State var selection = FilterBarPreviewFilter.all + + List(0..<20) { row in + Text("Row \(row)") + } + .safeAreaInset(edge: .top, spacing: 0) { + GlassFilterBar( + selection: $selection, + isSearching: false, + pickerLabel: "View", + title: { $0.label } + ) + .frame(maxWidth: .infinity) + } } diff --git a/MC1/Views/Components/MapControlsToolbar.swift b/MC1/Views/Components/MapControlsToolbar.swift index 1685d105..1e24fac2 100644 --- a/MC1/Views/Components/MapControlsToolbar.swift +++ b/MC1/Views/Components/MapControlsToolbar.swift @@ -3,113 +3,113 @@ import SwiftUI /// Shared liquid-glass toolbar hosting the controls every interactive map uses /// (north lock, location, layers, labels) plus a slot for one map-specific button. struct MapControlsToolbar: View { - /// Centers the map on the user's location. - var onLocationTap: () -> Void + /// Centers the map on the user's location. + var onLocationTap: () -> Void - @Binding var isNorthLocked: Bool - @Binding var showLabels: Bool + @Binding var isNorthLocked: Bool + @Binding var showLabels: Bool - /// Controls layers menu visibility. Parent view handles menu presentation. - @Binding var showingLayersMenu: Bool + /// Controls layers menu visibility. Parent view handles menu presentation. + @Binding var showingLayersMenu: Bool - /// One map-specific button shown below the standard controls. - @ViewBuilder var trailingContent: () -> TrailingContent + /// One map-specific button shown below the standard controls. + @ViewBuilder var trailingContent: () -> TrailingContent - var body: some View { - VStack(spacing: 0) { - northLockButton + var body: some View { + VStack(spacing: 0) { + northLockButton - Divider() - .frame(width: MapToolbarLayout.dividerWidth) + Divider() + .frame(width: MapToolbarLayout.dividerWidth) - locationButton + locationButton - Divider() - .frame(width: MapToolbarLayout.dividerWidth) + Divider() + .frame(width: MapToolbarLayout.dividerWidth) - layersButton + layersButton - Divider() - .frame(width: MapToolbarLayout.dividerWidth) + Divider() + .frame(width: MapToolbarLayout.dividerWidth) - labelsButton + labelsButton - CustomContentStack { - trailingContent() - } - } - .liquidGlass(in: .rect(cornerRadius: MapToolbarLayout.cornerRadius)) - .shadow(color: .black.opacity(0.15), radius: 4, y: 2) - .padding() + CustomContentStack { + trailingContent() + } } - - // MARK: - North Lock Button - - private var northLockButton: some View { - Button( - isNorthLocked ? L10n.Map.Map.Controls.unlockNorth : L10n.Map.Map.Controls.lockNorth, - systemImage: isNorthLocked ? "location.north.line.fill" : "location.north.line" - ) { - withAnimation { - isNorthLocked.toggle() - } - } - .mapControlButton(tint: isNorthLocked ? .blue : .primary) + .liquidGlass(in: .rect(cornerRadius: MapToolbarLayout.cornerRadius)) + .shadow(color: .black.opacity(0.15), radius: 4, y: 2) + .padding() + } + + // MARK: - North Lock Button + + private var northLockButton: some View { + Button( + isNorthLocked ? L10n.Map.Map.Controls.unlockNorth : L10n.Map.Map.Controls.lockNorth, + systemImage: isNorthLocked ? "location.north.line.fill" : "location.north.line" + ) { + withAnimation { + isNorthLocked.toggle() + } } + .mapControlButton(tint: isNorthLocked ? .blue : .primary) + } - // MARK: - Location Button + // MARK: - Location Button - private var locationButton: some View { - Button(L10n.Map.Map.Controls.centerOnMyLocation, systemImage: "location.fill", action: onLocationTap) - .mapControlButton(tint: .primary) - } + private var locationButton: some View { + Button(L10n.Map.Map.Controls.centerOnMyLocation, systemImage: "location.fill", action: onLocationTap) + .mapControlButton(tint: .primary) + } - // MARK: - Layers Button + // MARK: - Layers Button - private var layersButton: some View { - Button(L10n.Map.Map.Controls.layers, systemImage: "square.3.layers.3d.down.right") { - withAnimation(.spring(response: 0.3)) { - showingLayersMenu.toggle() - } - } - .mapControlButton(tint: .primary) + private var layersButton: some View { + Button(L10n.Map.Map.Controls.layers, systemImage: "square.3.layers.3d.down.right") { + withAnimation(.spring(response: 0.3)) { + showingLayersMenu.toggle() + } } - - // MARK: - Labels Button - - private var labelsButton: some View { - Button( - showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, - systemImage: "character.textbox" - ) { - withAnimation { - showLabels.toggle() - } - } - .mapControlButton(tint: showLabels ? .blue : .primary) + .mapControlButton(tint: .primary) + } + + // MARK: - Labels Button + + private var labelsButton: some View { + Button( + showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, + systemImage: "character.textbox" + ) { + withAnimation { + showLabels.toggle() + } } + .mapControlButton(tint: showLabels ? .blue : .primary) + } } // MARK: - Layout private enum MapToolbarLayout { - static let dividerWidth: CGFloat = 36 - static let cornerRadius: CGFloat = 8 + static let dividerWidth: CGFloat = 36 + static let cornerRadius: CGFloat = 8 } // MARK: - Custom Content Stack /// Wraps trailing content and inserts a divider before each child view. private struct CustomContentStack: View { - @ViewBuilder var content: Content - - var body: some View { - Group(subviews: content) { subviews in - ForEach(subviews) { subview in - Divider() - .frame(width: MapToolbarLayout.dividerWidth) - subview - } - } + @ViewBuilder var content: Content + + var body: some View { + Group(subviews: content) { subviews in + ForEach(subviews) { subview in + Divider() + .frame(width: MapToolbarLayout.dividerWidth) + subview + } } + } } diff --git a/MC1/Views/Components/NavigationHeaderModifier.swift b/MC1/Views/Components/NavigationHeaderModifier.swift index 81e4a3c2..483de076 100644 --- a/MC1/Views/Components/NavigationHeaderModifier.swift +++ b/MC1/Views/Components/NavigationHeaderModifier.swift @@ -4,72 +4,72 @@ import SwiftUI /// On iOS 26+, uses native `.navigationSubtitle()` which animates with the navigation transition. /// On iOS 18-25, uses a custom toolbar principal item that appears after the view renders. struct NavigationHeaderModifier: ViewModifier { - /// Minimum scale floor for the legacy iOS 18-25 subtitle, anchored on iPhone SE-class - /// width (375pt) — caption2 (~12pt) × 0.7 ≈ 8.4pt keeps long region names readable - /// rather than clipped. - private static let legacySubtitleMinimumScaleFactor: CGFloat = 0.7 + /// Minimum scale floor for the legacy iOS 18-25 subtitle, anchored on iPhone SE-class + /// width (375pt) — caption2 (~12pt) × 0.7 ≈ 8.4pt keeps long region names readable + /// rather than clipped. + private static let legacySubtitleMinimumScaleFactor: CGFloat = 0.7 - let title: String - let subtitle: String - let subtitleAccessibilityLabel: String? + let title: String + let subtitle: String + let subtitleAccessibilityLabel: String? - @State private var showHeader = false + @State private var showHeader = false - func body(content: Content) -> some View { - #if os(iOS) - if #available(iOS 26, *) { - // TODO: subtitleAccessibilityLabel is not applied here — .navigationSubtitle() - // renders in system chrome with no public API to override its accessibility label. - // VoiceOver may read separators (e.g. "·") literally. Verify with VoiceOver testing. - content - .navigationTitle(title) - .navigationSubtitle(subtitle) - .navigationBarTitleDisplayMode(.inline) - } else { - legacyHeader(content: content) - } - #else + func body(content: Content) -> some View { + #if os(iOS) + if #available(iOS 26, *) { + // TODO: subtitleAccessibilityLabel is not applied here — .navigationSubtitle() + // renders in system chrome with no public API to override its accessibility label. + // VoiceOver may read separators (e.g. "·") literally. Verify with VoiceOver testing. + content + .navigationTitle(title) + .navigationSubtitle(subtitle) + .navigationBarTitleDisplayMode(.inline) + } else { legacyHeader(content: content) - #endif - } + } + #else + legacyHeader(content: content) + #endif + } - private func legacyHeader(content: Content) -> some View { - content - .navigationTitle(title) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - if showHeader { - ToolbarItem(placement: .principal) { - VStack(spacing: 0) { - Text(title) - .font(.headline) + private func legacyHeader(content: Content) -> some View { + content + .navigationTitle(title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + if showHeader { + ToolbarItem(placement: .principal) { + VStack(spacing: 0) { + Text(title) + .font(.headline) - Text(subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(Self.legacySubtitleMinimumScaleFactor) - .truncationMode(.tail) - .accessibilityLabel(subtitleAccessibilityLabel ?? subtitle) - } - } - } + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(Self.legacySubtitleMinimumScaleFactor) + .truncationMode(.tail) + .accessibilityLabel(subtitleAccessibilityLabel ?? subtitle) } - .task { - // .task runs after first render, so header appears after navigation begins - withAnimation { - showHeader = true - } - } - } + } + } + } + .task { + // .task runs after first render, so header appears after navigation begins + withAnimation { + showHeader = true + } + } + } } extension View { - /// Applies an animated navigation header with title and subtitle. - /// Uses native `.navigationSubtitle()` on iOS 26+, with animated fallback for earlier versions. - func navigationHeader(title: String, subtitle: String, subtitleAccessibilityLabel: String? = nil) -> some View { - modifier(NavigationHeaderModifier(title: title, subtitle: subtitle, subtitleAccessibilityLabel: subtitleAccessibilityLabel)) - } + /// Applies an animated navigation header with title and subtitle. + /// Uses native `.navigationSubtitle()` on iOS 26+, with animated fallback for earlier versions. + func navigationHeader(title: String, subtitle: String, subtitleAccessibilityLabel: String? = nil) -> some View { + modifier(NavigationHeaderModifier(title: title, subtitle: subtitle, subtitleAccessibilityLabel: subtitleAccessibilityLabel)) + } } diff --git a/MC1/Views/Components/NodeAvatar.swift b/MC1/Views/Components/NodeAvatar.swift index f94b68f7..c617ebf4 100644 --- a/MC1/Views/Components/NodeAvatar.swift +++ b/MC1/Views/Components/NodeAvatar.swift @@ -1,67 +1,67 @@ -import SwiftUI import MC1Services +import SwiftUI /// Avatar view for remote nodes (room servers and repeaters). All repeaters share one theme color, /// all room servers share another — distinct per category, fixed per theme. struct NodeAvatar: View { - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - let publicKey: Data - let role: RemoteNodeRole - let size: CGFloat + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + let publicKey: Data + let role: RemoteNodeRole + let size: CGFloat - var body: some View { - ZStack { - Circle() - .fill(fill) + var body: some View { + ZStack { + Circle() + .fill(fill) - Image(systemName: iconName) - .font(.system(size: size * 0.45, weight: .semibold)) - .foregroundStyle(glyph) - } - .frame(width: size, height: size) + Image(systemName: iconName) + .font(.system(size: size * 0.45, weight: .semibold)) + .foregroundStyle(glyph) } + .frame(width: size, height: size) + } - private var iconName: String { - switch role { - case .roomServer: - return "door.left.hand.closed" - case .repeater: - return "antenna.radiowaves.left.and.right" - } + private var iconName: String { + switch role { + case .roomServer: + "door.left.hand.closed" + case .repeater: + "antenna.radiowaves.left.and.right" } + } - private var category: AvatarCategory { - role == .roomServer ? .room : .repeater - } + private var category: AvatarCategory { + role == .roomServer ? .room : .repeater + } - private var fill: Color { - theme.categoryAvatarColor(category, colorScheme: colorScheme, contrast: colorSchemeContrast) - } + private var fill: Color { + theme.categoryAvatarColor(category, colorScheme: colorScheme, contrast: colorSchemeContrast) + } - private var glyph: Color { - theme.avatarGlyphColor( - forFill: fill, - usesCategoryOverride: theme.usesCategoryAvatarOverride, - colorScheme: colorScheme, - contrast: colorSchemeContrast - ) - } + private var glyph: Color { + theme.avatarGlyphColor( + forFill: fill, + usesCategoryOverride: theme.usesCategoryAvatarOverride, + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + } } #Preview("Room Server") { - NodeAvatar( - publicKey: Data(repeating: 0x42, count: 32), - role: .roomServer, - size: 60 - ) + NodeAvatar( + publicKey: Data(repeating: 0x42, count: 32), + role: .roomServer, + size: 60 + ) } #Preview("Repeater") { - NodeAvatar( - publicKey: Data(repeating: 0x55, count: 32), - role: .repeater, - size: 60 - ) + NodeAvatar( + publicKey: Data(repeating: 0x55, count: 32), + role: .repeater, + size: 60 + ) } diff --git a/MC1/Views/Components/NotificationLevelPicker.swift b/MC1/Views/Components/NotificationLevelPicker.swift index c0b24eae..0012ff4b 100644 --- a/MC1/Views/Components/NotificationLevelPicker.swift +++ b/MC1/Views/Components/NotificationLevelPicker.swift @@ -1,60 +1,60 @@ -import SwiftUI import MC1Services +import SwiftUI struct NotificationLevelPicker: View { - @Binding var selection: NotificationLevel - let availableLevels: [NotificationLevel] + @Binding var selection: NotificationLevel + let availableLevels: [NotificationLevel] - init(selection: Binding, availableLevels: [NotificationLevel] = NotificationLevel.allCases) { - self._selection = selection - self.availableLevels = availableLevels - } + init(selection: Binding, availableLevels: [NotificationLevel] = NotificationLevel.allCases) { + _selection = selection + self.availableLevels = availableLevels + } - var body: some View { - HStack(spacing: 12) { - ForEach(availableLevels, id: \.self) { level in - NotificationLevelPill( - level: level, - isSelected: selection == level - ) { - selection = level - } - } + var body: some View { + HStack(spacing: 12) { + ForEach(availableLevels, id: \.self) { level in + NotificationLevelPill( + level: level, + isSelected: selection == level + ) { + selection = level } - .accessibilityElement(children: .contain) - .accessibilityLabel(L10n.Chats.Chats.NotificationLevel.label) - .accessibilityValue(selection.localizedAccessibilityDescription) - .accessibilityHint(L10n.Chats.Chats.NotificationLevel.hint) + } } + .accessibilityElement(children: .contain) + .accessibilityLabel(L10n.Chats.Chats.NotificationLevel.label) + .accessibilityValue(selection.localizedAccessibilityDescription) + .accessibilityHint(L10n.Chats.Chats.NotificationLevel.hint) + } } private struct NotificationLevelPill: View { - let level: NotificationLevel - let isSelected: Bool - let action: () -> Void + let level: NotificationLevel + let isSelected: Bool + let action: () -> Void - var body: some View { - Button(action: action) { - VStack(spacing: 4) { - Image(systemName: level.iconName) - .font(.title3) - Text(level.localizedName) - .font(.caption) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background { - if isSelected { - Color.accentColor - } else { - Color(uiColor: .tertiarySystemFill) - } - } - .foregroundStyle(isSelected ? .white : .primary) - .clipShape(.rect(cornerRadius: 10)) + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + Image(systemName: level.iconName) + .font(.title3) + Text(level.localizedName) + .font(.caption) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background { + if isSelected { + Color.accentColor + } else { + Color(uiColor: .tertiarySystemFill) } - .buttonStyle(.plain) - .accessibilityLabel(level.localizedName) - .accessibilityAddTraits(isSelected ? .isSelected : []) + } + .foregroundStyle(isSelected ? .white : .primary) + .clipShape(.rect(cornerRadius: 10)) } + .buttonStyle(.plain) + .accessibilityLabel(level.localizedName) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } } diff --git a/MC1/Views/Components/RSSIScanTracker.swift b/MC1/Views/Components/RSSIScanTracker.swift index 4de42391..37c1c238 100644 --- a/MC1/Views/Components/RSSIScanTracker.swift +++ b/MC1/Views/Components/RSSIScanTracker.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Shared BLE scan-orchestration model for the device pickers. /// @@ -12,59 +12,59 @@ import MC1Services @Observable @MainActor final class RSSIScanTracker { - /// Currently-advertising peripherals keyed by id, each carrying its smoothed RSSI and most - /// recently advertised name. - private(set) var devices: [UUID: DiscoveredDevice] = [:] - - private var signalTiers: [UUID: RSSITuning.SignalTier] = [:] - private var lastSeen: [UUID: Date] = [:] + /// Currently-advertising peripherals keyed by id, each carrying its smoothed RSSI and most + /// recently advertised name. + private(set) var devices: [UUID: DiscoveredDevice] = [:] - /// The smoothed signal tier for a peripheral, or `nil` if it is not currently advertising. - func signalTier(for id: UUID) -> RSSITuning.SignalTier? { - signalTiers[id] - } + private var signalTiers: [UUID: RSSITuning.SignalTier] = [:] + private var lastSeen: [UUID: Date] = [:] - /// Whether the peripheral has a live, unexpired RSSI sample. - func isAdvertising(_ id: UUID) -> Bool { - devices[id] != nil - } + /// The smoothed signal tier for a peripheral, or `nil` if it is not currently advertising. + func signalTier(for id: UUID) -> RSSITuning.SignalTier? { + signalTiers[id] + } - /// Consumes a discovery stream until the calling task is cancelled, smoothing RSSI, - /// recomputing tiers, and expiring stale peripherals on the shared `RSSITuning.expiryTick` - /// cadence (after `RSSITuning.staleWindow` without a fresh advertisement). Scanning stops - /// automatically when the stream's producer is torn down on task cancellation. - func consume(_ stream: AsyncStream) async { - let expiry = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: RSSITuning.expiryTick) - self?.expireStale() - } - } - defer { expiry.cancel() } + /// Whether the peripheral has a live, unexpired RSSI sample. + func isAdvertising(_ id: UUID) -> Bool { + devices[id] != nil + } - for await discovery in stream { - guard RSSITuning.isUsable(discovery.rssi) else { continue } - ingest(discovery) - } + /// Consumes a discovery stream until the calling task is cancelled, smoothing RSSI, + /// recomputing tiers, and expiring stale peripherals on the shared `RSSITuning.expiryTick` + /// cadence (after `RSSITuning.staleWindow` without a fresh advertisement). Scanning stops + /// automatically when the stream's producer is torn down on task cancellation. + func consume(_ stream: AsyncStream) async { + let expiry = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: RSSITuning.expiryTick) + self?.expireStale() + } } + defer { expiry.cancel() } - private func ingest(_ discovery: DiscoveredDevice) { - let smoothed = RSSITuning.smooth(newRSSI: discovery.rssi, previousRSSI: devices[discovery.id]?.rssi) - // Preserve a previously-advertised name when a later packet omits it. - let name = discovery.name ?? devices[discovery.id]?.name - devices[discovery.id] = DiscoveredDevice(id: discovery.id, name: name, rssi: smoothed) - signalTiers[discovery.id] = RSSITuning.tier(currentTier: signalTiers[discovery.id], smoothedRSSI: smoothed) - lastSeen[discovery.id] = .now + for await discovery in stream { + guard RSSITuning.isUsable(discovery.rssi) else { continue } + ingest(discovery) } + } + + private func ingest(_ discovery: DiscoveredDevice) { + let smoothed = RSSITuning.smooth(newRSSI: discovery.rssi, previousRSSI: devices[discovery.id]?.rssi) + // Preserve a previously-advertised name when a later packet omits it. + let name = discovery.name ?? devices[discovery.id]?.name + devices[discovery.id] = DiscoveredDevice(id: discovery.id, name: name, rssi: smoothed) + signalTiers[discovery.id] = RSSITuning.tier(currentTier: signalTiers[discovery.id], smoothedRSSI: smoothed) + lastSeen[discovery.id] = .now + } - /// Drops peripherals not seen within `RSSITuning.staleWindow` of `now`. `now` is a seam so - /// tests can force expiry deterministically; production callers use the default. - func expireStale(asOf now: Date = .now) { - let cutoff = now.addingTimeInterval(-RSSITuning.staleWindow) - for (id, seen) in lastSeen where seen < cutoff { - lastSeen.removeValue(forKey: id) - devices.removeValue(forKey: id) - signalTiers.removeValue(forKey: id) - } + /// Drops peripherals not seen within `RSSITuning.staleWindow` of `now`. `now` is a seam so + /// tests can force expiry deterministically; production callers use the default. + func expireStale(asOf now: Date = .now) { + let cutoff = now.addingTimeInterval(-RSSITuning.staleWindow) + for (id, seen) in lastSeen where seen < cutoff { + lastSeen.removeValue(forKey: id) + devices.removeValue(forKey: id) + signalTiers.removeValue(forKey: id) } + } } diff --git a/MC1/Views/Components/RSSITuning.swift b/MC1/Views/Components/RSSITuning.swift index 54a732ec..bde67c88 100644 --- a/MC1/Views/Components/RSSITuning.swift +++ b/MC1/Views/Components/RSSITuning.swift @@ -7,85 +7,85 @@ import SwiftUI /// hysteresis. The scan and expiry orchestration that drives this math is shared via /// `RSSIScanTracker`, so neither the math nor its orchestration can drift between the pickers. enum RSSITuning { - /// Discrete signal-strength tier derived from a smoothed RSSI reading. Raw values are stable - /// (0 weak, 1 medium, 2 strong) so the glyph fill and color helpers map them directly. - enum SignalTier: Int { - case weak = 0 - case medium = 1 - case strong = 2 - } + /// Discrete signal-strength tier derived from a smoothed RSSI reading. Raw values are stable + /// (0 weak, 1 medium, 2 strong) so the glyph fill and color helpers map them directly. + enum SignalTier: Int { + case weak = 0 + case medium = 1 + case strong = 2 + } - /// RSSI (dBm) at or above which signal is shown as full strength (tier 2 / green). - static let strongThreshold = -60 - /// RSSI (dBm) at or above which signal is shown as medium (tier 1 / yellow); - /// below this is weak (tier 0 / red). - static let mediumThreshold = -80 - /// Margin (dBm) a reading must clear before the displayed tier changes, so the - /// glyph does not flicker when the signal hovers at a boundary. - static let tierHysteresis = 3 - /// Weight applied to the newest sample in the exponential RSSI smoothing - /// (`new · weight + previous · (1 − weight)`). - static let smoothingNewWeight = 0.2 - /// Sentinel RSSI CoreBluetooth reports when signal strength is unavailable. - static let unavailableRSSI = -127 - /// Cadence of the stale-peripheral expiry sweep. - static let expiryTick: Duration = .seconds(2) - /// Seconds without a fresh advertisement before a peripheral is treated as gone. - /// Shared by both pickers so a device drops (macOS scanner) or goes non-tappable - /// (iOS picker) on the same schedule; with the 2s sweep, real staleness is up to - /// `staleWindow + expiryTick`. - static let staleWindow: TimeInterval = 4 + /// RSSI (dBm) at or above which signal is shown as full strength (tier 2 / green). + static let strongThreshold = -60 + /// RSSI (dBm) at or above which signal is shown as medium (tier 1 / yellow); + /// below this is weak (tier 0 / red). + static let mediumThreshold = -80 + /// Margin (dBm) a reading must clear before the displayed tier changes, so the + /// glyph does not flicker when the signal hovers at a boundary. + static let tierHysteresis = 3 + /// Weight applied to the newest sample in the exponential RSSI smoothing + /// (`new · weight + previous · (1 − weight)`). + static let smoothingNewWeight = 0.2 + /// Sentinel RSSI CoreBluetooth reports when signal strength is unavailable. + static let unavailableRSSI = -127 + /// Cadence of the stale-peripheral expiry sweep. + static let expiryTick: Duration = .seconds(2) + /// Seconds without a fresh advertisement before a peripheral is treated as gone. + /// Shared by both pickers so a device drops (macOS scanner) or goes non-tappable + /// (iOS picker) on the same schedule; with the 2s sweep, real staleness is up to + /// `staleWindow + expiryTick`. + static let staleWindow: TimeInterval = 4 - /// Whether an RSSI reading is usable: a negative dBm value that is not the - /// `unavailableRSSI` sentinel (0 or positive readings also indicate unavailable). - static func isUsable(_ rssi: Int) -> Bool { - rssi < 0 && rssi != unavailableRSSI - } + /// Whether an RSSI reading is usable: a negative dBm value that is not the + /// `unavailableRSSI` sentinel (0 or positive readings also indicate unavailable). + static func isUsable(_ rssi: Int) -> Bool { + rssi < 0 && rssi != unavailableRSSI + } - /// Exponentially smooths a new RSSI sample against the previous smoothed value. - /// Returns the new sample unchanged when there is no prior reading. - static func smooth(newRSSI: Int, previousRSSI: Int?) -> Int { - guard let previousRSSI else { return newRSSI } - return Int(smoothingNewWeight * Double(newRSSI) + (1 - smoothingNewWeight) * Double(previousRSSI)) - } + /// Exponentially smooths a new RSSI sample against the previous smoothed value. + /// Returns the new sample unchanged when there is no prior reading. + static func smooth(newRSSI: Int, previousRSSI: Int?) -> Int { + guard let previousRSSI else { return newRSSI } + return Int(smoothingNewWeight * Double(newRSSI) + (1 - smoothingNewWeight) * Double(previousRSSI)) + } - /// Signal tier with hysteresis: a reading must clear a threshold by `tierHysteresis` dBm - /// before the displayed tier changes. The first reading (`currentTier == nil`) maps directly - /// with no hysteresis. - static func tier(currentTier: SignalTier?, smoothedRSSI: Int) -> SignalTier { - switch currentTier { - case .strong: // drop a tier only if clearly below threshold - return smoothedRSSI < strongThreshold - tierHysteresis - ? (smoothedRSSI < mediumThreshold - tierHysteresis ? .weak : .medium) : .strong - case .medium: // need margin to move up or down - if smoothedRSSI >= strongThreshold + tierHysteresis { return .strong } - if smoothedRSSI < mediumThreshold - tierHysteresis { return .weak } - return .medium - case .weak: // need margin to move up - return smoothedRSSI >= mediumThreshold + tierHysteresis - ? (smoothedRSSI >= strongThreshold + tierHysteresis ? .strong : .medium) : .weak - case nil: // first reading, no hysteresis - if smoothedRSSI >= strongThreshold { return .strong } - if smoothedRSSI >= mediumThreshold { return .medium } - return .weak - } + /// Signal tier with hysteresis: a reading must clear a threshold by `tierHysteresis` dBm + /// before the displayed tier changes. The first reading (`currentTier == nil`) maps directly + /// with no hysteresis. + static func tier(currentTier: SignalTier?, smoothedRSSI: Int) -> SignalTier { + switch currentTier { + case .strong: // drop a tier only if clearly below threshold + return smoothedRSSI < strongThreshold - tierHysteresis + ? (smoothedRSSI < mediumThreshold - tierHysteresis ? .weak : .medium) : .strong + case .medium: // need margin to move up or down + if smoothedRSSI >= strongThreshold + tierHysteresis { return .strong } + if smoothedRSSI < mediumThreshold - tierHysteresis { return .weak } + return .medium + case .weak: // need margin to move up + return smoothedRSSI >= mediumThreshold + tierHysteresis + ? (smoothedRSSI >= strongThreshold + tierHysteresis ? .strong : .medium) : .weak + case nil: // first reading, no hysteresis + if smoothedRSSI >= strongThreshold { return .strong } + if smoothedRSSI >= mediumThreshold { return .medium } + return .weak } + } - /// Fill level (0...1) for the `cellularbars` glyph at a given tier. - static func fillLevel(forTier tier: SignalTier) -> Double { - switch tier { - case .strong: 1.0 - case .medium: 0.66 - case .weak: 0.33 - } + /// Fill level (0...1) for the `cellularbars` glyph at a given tier. + static func fillLevel(forTier tier: SignalTier) -> Double { + switch tier { + case .strong: 1.0 + case .medium: 0.66 + case .weak: 0.33 } + } - /// Color for the signal glyph at a given tier. - static func color(forTier tier: SignalTier) -> Color { - switch tier { - case .strong: .green - case .medium: .yellow - case .weak: .red - } + /// Color for the signal glyph at a given tier. + static func color(forTier tier: SignalTier) -> Color { + switch tier { + case .strong: .green + case .medium: .yellow + case .weak: .red } + } } diff --git a/MC1/Views/Components/RadioCommandTimeout.swift b/MC1/Views/Components/RadioCommandTimeout.swift index 21f0b0a0..3ca2e3df 100644 --- a/MC1/Views/Components/RadioCommandTimeout.swift +++ b/MC1/Views/Components/RadioCommandTimeout.swift @@ -2,8 +2,8 @@ import Foundation /// Shared bounds for radio-backed commands issued from list actions. enum RadioCommandTimeout { - /// Upper bound for a delete that round-trips to the radio (channel clear, room leave, remove - /// node). Too short re-admits a slow-but-successful delete as a spurious error; on the gated - /// paths an expiry surfaces on a still-visible row, never a hidden one. - static let delete: Duration = .seconds(7) + /// Upper bound for a delete that round-trips to the radio (channel clear, room leave, remove + /// node). Too short re-admits a slow-but-successful delete as a spurious error; on the gated + /// paths an expiry surfaces on a still-visible row, never a hidden one. + static let delete: Duration = .seconds(7) } diff --git a/MC1/Views/Components/RadioParameterText.swift b/MC1/Views/Components/RadioParameterText.swift index 7e913f63..172a0cf0 100644 --- a/MC1/Views/Components/RadioParameterText.swift +++ b/MC1/Views/Components/RadioParameterText.swift @@ -1,18 +1,18 @@ -import SwiftUI import MC1Services +import SwiftUI /// Displays a radio parameter summary line, e.g. "915.000 MHz • BW125 kHz • SF12 • CR8". /// Uses POSIX locale to always render a period decimal separator regardless of user locale. struct RadioParameterText: View { - let frequencyMHz: Double - let bandwidthKHz: Double - let spreadingFactor: UInt8 - let codingRate: UInt8 + let frequencyMHz: Double + let bandwidthKHz: Double + let spreadingFactor: UInt8 + let codingRate: UInt8 - var body: some View { - Text(frequencyMHz.formatted(.number.precision(.fractionLength(3)).locale(.posix))) - .font(.caption.monospacedDigit()) + - Text(" MHz \u{2022} BW\(bandwidthKHz.formatted(.number.locale(.posix))) kHz \u{2022} SF\(spreadingFactor) \u{2022} CR\(codingRate)") - .font(.caption) - } + var body: some View { + Text(frequencyMHz.formatted(.number.precision(.fractionLength(3)).locale(.posix))) + .font(.caption.monospacedDigit()) + + Text(" MHz \u{2022} BW\(bandwidthKHz.formatted(.number.locale(.posix))) kHz \u{2022} SF\(spreadingFactor) \u{2022} CR\(codingRate)") + .font(.caption) + } } diff --git a/MC1/Views/Components/RegionDiscoveryResultsView.swift b/MC1/Views/Components/RegionDiscoveryResultsView.swift index f0cd75af..3e4d62d2 100644 --- a/MC1/Views/Components/RegionDiscoveryResultsView.swift +++ b/MC1/Views/Components/RegionDiscoveryResultsView.swift @@ -2,64 +2,64 @@ import SwiftUI /// Pushed view showing discovered regions with toggleable selection struct RegionDiscoveryResultsView: View { - let sortedRegions: [String] - let onAdd: ([String]) -> Void + let sortedRegions: [String] + let onAdd: ([String]) -> Void - @Environment(\.dismiss) private var dismiss - @Environment(\.appTheme) private var theme - @State private var selectedRegions: Set + @Environment(\.dismiss) private var dismiss + @Environment(\.appTheme) private var theme + @State private var selectedRegions: Set - init(discoveredRegions: [String], onAdd: @escaping ([String]) -> Void) { - let sorted = discoveredRegions.sorted() - self.sortedRegions = sorted - self.onAdd = onAdd - self._selectedRegions = State(initialValue: Set(discoveredRegions)) - } + init(discoveredRegions: [String], onAdd: @escaping ([String]) -> Void) { + let sorted = discoveredRegions.sorted() + sortedRegions = sorted + self.onAdd = onAdd + _selectedRegions = State(initialValue: Set(discoveredRegions)) + } - var body: some View { - Form { - Section { - ForEach(sortedRegions, id: \.self) { region in - Button { - toggleSelection(region) - } label: { - HStack { - Text(region) - if region.isPrivateRegion { - Text(L10n.Chats.Chats.ChannelInfo.Region.`private`) - .font(.caption) - .foregroundStyle(.tertiary) - } - Spacer() - if selectedRegions.contains(region) { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - } - .tint(.primary) - } + var body: some View { + Form { + Section { + ForEach(sortedRegions, id: \.self) { region in + Button { + toggleSelection(region) + } label: { + HStack { + Text(region) + if region.isPrivateRegion { + Text(L10n.Chats.Chats.ChannelInfo.Region.private) + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + if selectedRegions.contains(region) { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } } - .themedRowBackground(theme) + } + .tint(.primary) + } + } + .themedRowBackground(theme) - Section { - Button(L10n.Chats.Chats.ChannelInfo.Region.addSelected) { - onAdd(Array(selectedRegions)) - dismiss() - } - .disabled(selectedRegions.isEmpty) - } - .themedRowBackground(theme) + Section { + Button(L10n.Chats.Chats.ChannelInfo.Region.addSelected) { + onAdd(Array(selectedRegions)) + dismiss() } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.ChannelInfo.Region.discover) + .disabled(selectedRegions.isEmpty) + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.ChannelInfo.Region.discover) + } - private func toggleSelection(_ region: String) { - if selectedRegions.contains(region) { - selectedRegions.remove(region) - } else { - selectedRegions.insert(region) - } + private func toggleSelection(_ region: String) { + if selectedRegions.contains(region) { + selectedRegions.remove(region) + } else { + selectedRegions.insert(region) } + } } diff --git a/MC1/Views/Components/RegionManagementView.swift b/MC1/Views/Components/RegionManagementView.swift index 6d6cd9a1..ecbbdb3a 100644 --- a/MC1/Views/Components/RegionManagementView.swift +++ b/MC1/Views/Components/RegionManagementView.swift @@ -2,184 +2,184 @@ import SwiftUI /// Form-based view for managing known regions with add/delete functionality struct RegionManagementView: View { - let knownRegions: [String] - @Binding var isDiscovering: Bool - @Binding var discoveryMessage: String? - - let onRemoveRegion: (String) -> Void - let onAddRegion: (String) -> Void - let onDiscoverTapped: () -> Void - - @Environment(\.appTheme) private var theme - @State private var searchText = "" - @State private var showingAddAlert = false - @State private var newRegionName = "" - @State private var validationMessage: String? - - private var filteredRegions: [String] { - let sorted = knownRegions.sorted { $0.localizedStandardCompare($1) == .orderedAscending } - guard !searchText.isEmpty else { return sorted } - return sorted.filter { $0.localizedStandardContains(searchText) } - } - - var body: some View { - Form { - if knownRegions.isEmpty { - RegionManagementEmptyState() - } else { - KnownRegionsSection( - regions: filteredRegions, - onDelete: removeRegions - ) - } - - ActionsSection( - isDiscovering: isDiscovering, - discoveryMessage: discoveryMessage, - onDiscoverTapped: onDiscoverTapped, - onAddTapped: { - newRegionName = "" - showingAddAlert = true - } - ) - } - .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.ChannelInfo.Region.manage) - .modifier(SearchableModifier(searchText: $searchText, isEnabled: knownRegions.count >= 15)) - .alert(L10n.Chats.Chats.ChannelInfo.Region.addRegionTitle, isPresented: $showingAddAlert) { - TextField(L10n.Chats.Chats.ChannelInfo.Region.addRegionPlaceholder, text: $newRegionName) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Button(L10n.Chats.Chats.ChannelInfo.Region.addSelected) { - if let error = RegionNameValidator.validate(newRegionName, existingRegions: knownRegions) { - validationMessage = validationText(for: error) - Task { showingAddAlert = true } - return - } - validationMessage = nil - onAddRegion(newRegionName.trimmingCharacters(in: .whitespaces)) - } - Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { - validationMessage = nil - } - } message: { - if let validationMessage { - Text(validationMessage) - } + let knownRegions: [String] + @Binding var isDiscovering: Bool + @Binding var discoveryMessage: String? + + let onRemoveRegion: (String) -> Void + let onAddRegion: (String) -> Void + let onDiscoverTapped: () -> Void + + @Environment(\.appTheme) private var theme + @State private var searchText = "" + @State private var showingAddAlert = false + @State private var newRegionName = "" + @State private var validationMessage: String? + + private var filteredRegions: [String] { + let sorted = knownRegions.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + guard !searchText.isEmpty else { return sorted } + return sorted.filter { $0.localizedStandardContains(searchText) } + } + + var body: some View { + Form { + if knownRegions.isEmpty { + RegionManagementEmptyState() + } else { + KnownRegionsSection( + regions: filteredRegions, + onDelete: removeRegions + ) + } + + ActionsSection( + isDiscovering: isDiscovering, + discoveryMessage: discoveryMessage, + onDiscoverTapped: onDiscoverTapped, + onAddTapped: { + newRegionName = "" + showingAddAlert = true } + ) } - - private func validationText(for error: RegionNameValidator.ValidationError) -> String? { - switch error { - case .empty: nil - case .invalidCharacters: L10n.Chats.Chats.ChannelInfo.Region.invalidName - case .tooLong(let maxBytes): L10n.Chats.Chats.ChannelInfo.Region.nameTooLong(maxBytes) - case .duplicate: L10n.Chats.Chats.ChannelInfo.Region.duplicate + .themedCanvas(theme) + .navigationTitle(L10n.Chats.Chats.ChannelInfo.Region.manage) + .modifier(SearchableModifier(searchText: $searchText, isEnabled: knownRegions.count >= 15)) + .alert(L10n.Chats.Chats.ChannelInfo.Region.addRegionTitle, isPresented: $showingAddAlert) { + TextField(L10n.Chats.Chats.ChannelInfo.Region.addRegionPlaceholder, text: $newRegionName) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button(L10n.Chats.Chats.ChannelInfo.Region.addSelected) { + if let error = RegionNameValidator.validate(newRegionName, existingRegions: knownRegions) { + validationMessage = validationText(for: error) + Task { showingAddAlert = true } + return } + validationMessage = nil + onAddRegion(newRegionName.trimmingCharacters(in: .whitespaces)) + } + Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { + validationMessage = nil + } + } message: { + if let validationMessage { + Text(validationMessage) + } } + } + + private func validationText(for error: RegionNameValidator.ValidationError) -> String? { + switch error { + case .empty: nil + case .invalidCharacters: L10n.Chats.Chats.ChannelInfo.Region.invalidName + case let .tooLong(maxBytes): L10n.Chats.Chats.ChannelInfo.Region.nameTooLong(maxBytes) + case .duplicate: L10n.Chats.Chats.ChannelInfo.Region.duplicate + } + } - private func removeRegions(at offsets: IndexSet) { - let regionsToRemove = offsets.map { filteredRegions[$0] } - for region in regionsToRemove { - onRemoveRegion(region) - } + private func removeRegions(at offsets: IndexSet) { + let regionsToRemove = offsets.map { filteredRegions[$0] } + for region in regionsToRemove { + onRemoveRegion(region) } + } } // MARK: - Extracted Views private struct RegionManagementEmptyState: View { - var body: some View { - ContentUnavailableView { - Label(L10n.Chats.Chats.ChannelInfo.Region.noRegions, systemImage: "map") - } description: { - Text(L10n.Chats.Chats.ChannelInfo.Region.noRegionsDescription) - } + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.ChannelInfo.Region.noRegions, systemImage: "map") + } description: { + Text(L10n.Chats.Chats.ChannelInfo.Region.noRegionsDescription) } + } } private struct KnownRegionsSection: View { - @Environment(\.appTheme) private var theme - let regions: [String] - let onDelete: (IndexSet) -> Void - - var body: some View { - Section { - ForEach(regions, id: \.self) { region in - RegionRow(name: region) - } - .onDelete(perform: onDelete) - } - .themedRowBackground(theme) + @Environment(\.appTheme) private var theme + let regions: [String] + let onDelete: (IndexSet) -> Void + + var body: some View { + Section { + ForEach(regions, id: \.self) { region in + RegionRow(name: region) + } + .onDelete(perform: onDelete) } + .themedRowBackground(theme) + } } private struct RegionRow: View { - let name: String - - private var isPrivate: Bool { - name.isPrivateRegion - } - - var body: some View { - HStack { - Text(name) - if isPrivate { - Spacer() - Text(L10n.Chats.Chats.ChannelInfo.Region.`private`) - .font(.caption) - .foregroundStyle(.tertiary) - } - } + let name: String + + private var isPrivate: Bool { + name.isPrivateRegion + } + + var body: some View { + HStack { + Text(name) + if isPrivate { + Spacer() + Text(L10n.Chats.Chats.ChannelInfo.Region.private) + .font(.caption) + .foregroundStyle(.tertiary) + } } + } } private struct ActionsSection: View { - @Environment(\.appTheme) private var theme - let isDiscovering: Bool - let discoveryMessage: String? - let onDiscoverTapped: () -> Void - let onAddTapped: () -> Void - - var body: some View { - Section { - if isDiscovering { - HStack { - ProgressView() - Text(L10n.Chats.Chats.ChannelInfo.Region.discovering) - .foregroundStyle(.secondary) - } - } else if let discoveryMessage { - Text(discoveryMessage) - .font(.caption) - .foregroundStyle(.secondary) - } - - Button(L10n.Chats.Chats.ChannelInfo.Region.discover, systemImage: "antenna.radiowaves.left.and.right") { - onDiscoverTapped() - } - .disabled(isDiscovering) - - Button(L10n.Chats.Chats.ChannelInfo.Region.addManually, systemImage: "plus") { - onAddTapped() - } - } footer: { - Text(L10n.Chats.Chats.ChannelInfo.Region.invalidName) + @Environment(\.appTheme) private var theme + let isDiscovering: Bool + let discoveryMessage: String? + let onDiscoverTapped: () -> Void + let onAddTapped: () -> Void + + var body: some View { + Section { + if isDiscovering { + HStack { + ProgressView() + Text(L10n.Chats.Chats.ChannelInfo.Region.discovering) + .foregroundStyle(.secondary) } - .themedRowBackground(theme) + } else if let discoveryMessage { + Text(discoveryMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + + Button(L10n.Chats.Chats.ChannelInfo.Region.discover, systemImage: "antenna.radiowaves.left.and.right") { + onDiscoverTapped() + } + .disabled(isDiscovering) + + Button(L10n.Chats.Chats.ChannelInfo.Region.addManually, systemImage: "plus") { + onAddTapped() + } + } footer: { + Text(L10n.Chats.Chats.ChannelInfo.Region.invalidName) } + .themedRowBackground(theme) + } } /// Conditionally applies `.searchable()` when the region count warrants it private struct SearchableModifier: ViewModifier { - @Binding var searchText: String - let isEnabled: Bool - - func body(content: Content) -> some View { - if isEnabled { - content.searchable(text: $searchText) - } else { - content - } + @Binding var searchText: String + let isEnabled: Bool + + func body(content: Content) -> some View { + if isEnabled { + content.searchable(text: $searchText) + } else { + content } + } } diff --git a/MC1/Views/Components/RegionNameValidator.swift b/MC1/Views/Components/RegionNameValidator.swift index b4192217..3a018403 100644 --- a/MC1/Views/Components/RegionNameValidator.swift +++ b/MC1/Views/Components/RegionNameValidator.swift @@ -2,34 +2,36 @@ import Foundation import MC1Services extension String { - /// Whether this region name represents a private region (prefixed with "$") - var isPrivateRegion: Bool { hasPrefix("$") } + /// Whether this region name represents a private region (prefixed with "$") + var isPrivateRegion: Bool { + hasPrefix("$") + } } /// Validates region names before adding them to the device's known regions list enum RegionNameValidator { - enum ValidationError: Equatable { - case empty - case invalidCharacters - case tooLong(maxBytes: Int) - case duplicate - } + enum ValidationError: Equatable { + case empty + case invalidCharacters + case tooLong(maxBytes: Int) + case duplicate + } - static func validate(_ name: String, existingRegions: [String]) -> ValidationError? { - let trimmed = name.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { return .empty } - if !trimmed.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }) { - return .invalidCharacters - } - let maxBytes = ProtocolLimits.maxDefaultFloodScopeNameBytes - if trimmed.utf8.count > maxBytes { - return .tooLong(maxBytes: maxBytes) - } - if existingRegions.contains(trimmed) { return .duplicate } - return nil + static func validate(_ name: String, existingRegions: [String]) -> ValidationError? { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .empty } + if !trimmed.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }) { + return .invalidCharacters } - - static func isValid(_ name: String, existingRegions: [String]) -> Bool { - validate(name, existingRegions: existingRegions) == nil + let maxBytes = ProtocolLimits.maxDefaultFloodScopeNameBytes + if trimmed.utf8.count > maxBytes { + return .tooLong(maxBytes: maxBytes) } + if existingRegions.contains(trimmed) { return .duplicate } + return nil + } + + static func isValid(_ name: String, existingRegions: [String]) -> Bool { + validate(name, existingRegions: existingRegions) == nil + } } diff --git a/MC1/Views/Components/RelativeTimestampText.swift b/MC1/Views/Components/RelativeTimestampText.swift index aeed3b17..816939f0 100644 --- a/MC1/Views/Components/RelativeTimestampText.swift +++ b/MC1/Views/Components/RelativeTimestampText.swift @@ -2,49 +2,49 @@ import SwiftUI /// Displays a relative timestamp using Apple's localized relative date formatting struct RelativeTimestampText: View { - let timestamp: UInt32 - - private static let relativeFormatter: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter - }() - - private static let weekThreshold: TimeInterval = 604_800 - private static let nowThreshold: TimeInterval = 60 - - var body: some View { - TimelineView(.everyMinute) { context in - Text(Self.format(timestamp: timestamp, relativeTo: context.date)) - .font(.caption2) - .foregroundStyle(.secondary) - } + let timestamp: UInt32 + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() + + private static let weekThreshold: TimeInterval = 604_800 + private static let nowThreshold: TimeInterval = 60 + + var body: some View { + TimelineView(.everyMinute) { context in + Text(Self.format(timestamp: timestamp, relativeTo: context.date)) + .font(.caption2) + .foregroundStyle(.secondary) } + } - /// Formats a timestamp relative to the given date. Exposed for testing. - static func format(timestamp: UInt32, relativeTo now: Date) -> String { - let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) - let interval = now.timeIntervalSince(date) + /// Formats a timestamp relative to the given date. Exposed for testing. + static func format(timestamp: UInt32, relativeTo now: Date) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let interval = now.timeIntervalSince(date) - if interval < nowThreshold { - return L10n.Chats.Chats.Timestamp.now - } - - if interval >= weekThreshold { - return date.formatted(.dateTime.month(.abbreviated).day()) - } + if interval < nowThreshold { + return L10n.Chats.Chats.Timestamp.now + } - return relativeFormatter.localizedString(for: date, relativeTo: now) + if interval >= weekThreshold { + return date.formatted(.dateTime.month(.abbreviated).day()) } + + return relativeFormatter.localizedString(for: date, relativeTo: now) + } } #Preview { - VStack(alignment: .trailing, spacing: 8) { - RelativeTimestampText(timestamp: UInt32(Date().timeIntervalSince1970)) - RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-120).timeIntervalSince1970)) - RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-3600).timeIntervalSince1970)) - RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-86400).timeIntervalSince1970)) - RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-259200).timeIntervalSince1970)) - } - .padding() + VStack(alignment: .trailing, spacing: 8) { + RelativeTimestampText(timestamp: UInt32(Date().timeIntervalSince1970)) + RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-120).timeIntervalSince1970)) + RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-3600).timeIntervalSince1970)) + RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-86400).timeIntervalSince1970)) + RelativeTimestampText(timestamp: UInt32(Date().addingTimeInterval(-259_200).timeIntervalSince1970)) + } + .padding() } diff --git a/MC1/Views/Components/SectionReloadButton.swift b/MC1/Views/Components/SectionReloadButton.swift index ef2446ba..689fe3e5 100644 --- a/MC1/Views/Components/SectionReloadButton.swift +++ b/MC1/Views/Components/SectionReloadButton.swift @@ -6,33 +6,33 @@ import SwiftUI /// The button also appears when `hasError` is set so a first-load failure still /// exposes a retry affordance instead of leaving only the inline error text. struct SectionReloadButton: View { - let isLoading: Bool - let isLoaded: Bool - let hasError: Bool - let isDisabled: Bool - let accessibilityLabel: String - let onReload: () async -> Void + let isLoading: Bool + let isLoaded: Bool + let hasError: Bool + let isDisabled: Bool + let accessibilityLabel: String + let onReload: () async -> Void - private enum Layout { - static let spinnerScale: CGFloat = 0.8 - } + private enum Layout { + static let spinnerScale: CGFloat = 0.8 + } - var body: some View { - if isLoading { - ProgressView() - .scaleEffect(Layout.spinnerScale) - .padding(.trailing) - } else if isLoaded || hasError { - Button { - Task { await onReload() } - } label: { - Image(systemName: "arrow.clockwise") - .font(.caption) - } - .buttonStyle(.borderless) - .padding(.trailing) - .disabled(isDisabled) - .accessibilityLabel(accessibilityLabel) - } + var body: some View { + if isLoading { + ProgressView() + .scaleEffect(Layout.spinnerScale) + .padding(.trailing) + } else if isLoaded || hasError { + Button { + Task { await onReload() } + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption) + } + .buttonStyle(.borderless) + .padding(.trailing) + .disabled(isDisabled) + .accessibilityLabel(accessibilityLabel) } + } } diff --git a/MC1/Views/Components/SelectedRowHighlight.swift b/MC1/Views/Components/SelectedRowHighlight.swift index e16b53c8..04d031a3 100644 --- a/MC1/Views/Components/SelectedRowHighlight.swift +++ b/MC1/Views/Components/SelectedRowHighlight.swift @@ -3,29 +3,29 @@ import SwiftUI /// Rounded accent fill marking the selected row in a manually-driven list (a tap `Button` rather /// than native `List(selection:)`). Shared by the conversation and node split lists. struct SelectedRowHighlight: ViewModifier { - let isSelected: Bool + let isSelected: Bool - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - private static let cornerRadius: CGFloat = 10 - private static let fillOpacity: Double = 0.18 - private static let insetVertical: CGFloat = 2 - private static let insetHorizontal: CGFloat = 8 + private static let cornerRadius: CGFloat = 10 + private static let fillOpacity: Double = 0.18 + private static let insetVertical: CGFloat = 2 + private static let insetHorizontal: CGFloat = 8 - func body(content: Content) -> some View { - content.background { - if isSelected { - RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) - .fill(theme.accentColor.opacity(Self.fillOpacity)) - .padding(.vertical, Self.insetVertical) - .padding(.horizontal, Self.insetHorizontal) - } - } + func body(content: Content) -> some View { + content.background { + if isSelected { + RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .fill(theme.accentColor.opacity(Self.fillOpacity)) + .padding(.vertical, Self.insetVertical) + .padding(.horizontal, Self.insetHorizontal) + } } + } } extension View { - func selectedRowHighlight(isSelected: Bool) -> some View { - modifier(SelectedRowHighlight(isSelected: isSelected)) - } + func selectedRowHighlight(isSelected: Bool) -> some View { + modifier(SelectedRowHighlight(isSelected: isSelected)) + } } diff --git a/MC1/Views/Components/SignalBars.swift b/MC1/Views/Components/SignalBars.swift index 9bc8b74c..29aae5a9 100644 --- a/MC1/Views/Components/SignalBars.swift +++ b/MC1/Views/Components/SignalBars.swift @@ -3,26 +3,26 @@ import SwiftUI /// Signal-strength glyph shared by the device pickers (`DeviceScannerSheet`, `DeviceSelectionSheet`) /// so both render the `cellularbars` symbol with identical `RSSITuning` fill and tint. struct SignalBars: View { - let tier: RSSITuning.SignalTier - /// When set, the glyph announces this label to VoiceOver; when `nil`, the glyph is hidden from - /// VoiceOver so the enclosing row can announce the tier itself. - var accessibilityLabel: String? + let tier: RSSITuning.SignalTier + /// When set, the glyph announces this label to VoiceOver; when `nil`, the glyph is hidden from + /// VoiceOver so the enclosing row can announce the tier itself. + var accessibilityLabel: String? - var body: some View { - Image(systemName: "cellularbars", variableValue: RSSITuning.fillLevel(forTier: tier)) - .foregroundStyle(RSSITuning.color(forTier: tier)) - .font(.body) - .accessibilityHidden(accessibilityLabel == nil) - .accessibilityLabel(accessibilityLabel ?? "") - } + var body: some View { + Image(systemName: "cellularbars", variableValue: RSSITuning.fillLevel(forTier: tier)) + .foregroundStyle(RSSITuning.color(forTier: tier)) + .font(.body) + .accessibilityHidden(accessibilityLabel == nil) + .accessibilityLabel(accessibilityLabel ?? "") + } - /// VoiceOver descriptor for a signal tier, shared by both device pickers so the - /// tier-to-text mapping and its localized strings live in one place. - static func accessibilityDescription(forTier tier: RSSITuning.SignalTier) -> String { - switch tier { - case .strong: L10n.Localizable.Accessibility.SignalStrength.strong - case .medium: L10n.Localizable.Accessibility.SignalStrength.medium - case .weak: L10n.Localizable.Accessibility.SignalStrength.weak - } + /// VoiceOver descriptor for a signal tier, shared by both device pickers so the + /// tier-to-text mapping and its localized strings live in one place. + static func accessibilityDescription(forTier tier: RSSITuning.SignalTier) -> String { + switch tier { + case .strong: L10n.Localizable.Accessibility.SignalStrength.strong + case .medium: L10n.Localizable.Accessibility.SignalStrength.medium + case .weak: L10n.Localizable.Accessibility.SignalStrength.weak } + } } diff --git a/MC1/Views/Components/SyncingPillOverlay.swift b/MC1/Views/Components/SyncingPillOverlay.swift index 5b47863d..98ac110b 100644 --- a/MC1/Views/Components/SyncingPillOverlay.swift +++ b/MC1/Views/Components/SyncingPillOverlay.swift @@ -5,70 +5,70 @@ import SwiftUI /// and spring timing stay identical across layouts. `displayedPillState` lags `statusPillState` /// by one animated step so the pill can finish its exit animation before being removed. struct SyncingPillOverlay: ViewModifier { - @Environment(\.appState) private var appState - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.appState) private var appState + @Environment(\.accessibilityReduceMotion) private var reduceMotion - let onDisconnectedTap: () -> Void + let onDisconnectedTap: () -> Void - @State private var displayedPillState: StatusPillState = .hidden + @State private var displayedPillState: StatusPillState = .hidden - private let topInset: CGFloat = 8 - private let transitionDuration: TimeInterval = 0.3 - private let offscreenOffset: CGFloat = -100 + private let topInset: CGFloat = 8 + private let transitionDuration: TimeInterval = 0.3 + private let offscreenOffset: CGFloat = -100 - private let readySpringDuration: TimeInterval = 0.4 - private let readySpringBounce = 0.15 - private let alertSpringDuration: TimeInterval = 0.35 - private let alertSpringBounce = 0.2 - private let defaultSpringDuration: TimeInterval = 0.4 + private let readySpringDuration: TimeInterval = 0.4 + private let readySpringBounce = 0.15 + private let alertSpringDuration: TimeInterval = 0.35 + private let alertSpringBounce = 0.2 + private let defaultSpringDuration: TimeInterval = 0.4 - private var pillAnimation: Animation { - if reduceMotion { return .linear(duration: 0) } + private var pillAnimation: Animation { + if reduceMotion { return .linear(duration: 0) } - switch appState.statusPillState { - case .ready: - return .spring(duration: readySpringDuration, bounce: readySpringBounce) - case .failed, .disconnected: - return .spring(duration: alertSpringDuration, bounce: alertSpringBounce) - default: - return .spring(duration: defaultSpringDuration) - } + switch appState.statusPillState { + case .ready: + return .spring(duration: readySpringDuration, bounce: readySpringBounce) + case .failed, .disconnected: + return .spring(duration: alertSpringDuration, bounce: alertSpringBounce) + default: + return .spring(duration: defaultSpringDuration) } + } - /// Animates the pill's content swap, suppressed under Reduce Motion to match `pillAnimation`. - private var contentAnimation: Animation { - reduceMotion ? .linear(duration: 0) : .spring(duration: transitionDuration) - } + /// Animates the pill's content swap, suppressed under Reduce Motion to match `pillAnimation`. + private var contentAnimation: Animation { + reduceMotion ? .linear(duration: 0) : .spring(duration: transitionDuration) + } - func body(content: Content) -> some View { - ZStack(alignment: .top) { - content + func body(content: Content) -> some View { + ZStack(alignment: .top) { + content - SyncingPillView( - state: displayedPillState, - onDisconnectedTap: onDisconnectedTap - ) - .animation(contentAnimation, value: displayedPillState) - .padding(.top, topInset) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .offset(y: appState.statusPillState == .hidden ? offscreenOffset : 0) - .opacity(appState.statusPillState == .hidden ? 0 : 1) - .animation(pillAnimation, value: appState.statusPillState) - .allowsHitTesting(appState.statusPillState != .hidden) - } - .onChange(of: appState.statusPillState, initial: true) { _, new in - if new != .hidden { - withAnimation(pillAnimation) { - displayedPillState = new - } - } + SyncingPillView( + state: displayedPillState, + onDisconnectedTap: onDisconnectedTap + ) + .animation(contentAnimation, value: displayedPillState) + .padding(.top, topInset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .offset(y: appState.statusPillState == .hidden ? offscreenOffset : 0) + .opacity(appState.statusPillState == .hidden ? 0 : 1) + .animation(pillAnimation, value: appState.statusPillState) + .allowsHitTesting(appState.statusPillState != .hidden) + } + .onChange(of: appState.statusPillState, initial: true) { _, new in + if new != .hidden { + withAnimation(pillAnimation) { + displayedPillState = new } + } } + } } extension View { - /// Pins the connection syncing pill to the top of this shell. See `SyncingPillOverlay`. - func syncingPillOverlay(onDisconnectedTap: @escaping () -> Void) -> some View { - modifier(SyncingPillOverlay(onDisconnectedTap: onDisconnectedTap)) - } + /// Pins the connection syncing pill to the top of this shell. See `SyncingPillOverlay`. + func syncingPillOverlay(onDisconnectedTap: @escaping () -> Void) -> some View { + modifier(SyncingPillOverlay(onDisconnectedTap: onDisconnectedTap)) + } } diff --git a/MC1/Views/Components/SyncingPillView.swift b/MC1/Views/Components/SyncingPillView.swift index 858755f3..c10729ee 100644 --- a/MC1/Views/Components/SyncingPillView.swift +++ b/MC1/Views/Components/SyncingPillView.swift @@ -2,89 +2,88 @@ import SwiftUI /// A pill-shaped indicator that appears at the top of the app during sync and connection operations struct SyncingPillView: View { - let state: StatusPillState - var onDisconnectedTap: (() -> Void)? + let state: StatusPillState + var onDisconnectedTap: (() -> Void)? - var body: some View { - if case .disconnected = state, let onDisconnectedTap { - Button(action: onDisconnectedTap) { - pillBody - } - .buttonStyle(.plain) - .accessibilityHint(L10n.Localizable.Common.Accessibility.connectHint) - } else { - pillBody - } + var body: some View { + if case .disconnected = state, let onDisconnectedTap { + Button(action: onDisconnectedTap) { + pillBody + } + .buttonStyle(.plain) + .accessibilityHint(L10n.Localizable.Common.Accessibility.connectHint) + } else { + pillBody } + } - private var pillBody: some View { - HStack(spacing: 8) { - icon - Text(state.displayText) - .font(.subheadline) - .fontWeight(state.isFailure ? .bold : .medium) - .foregroundStyle(state.textColor) - .contentTransition(.identity) - } - .geometryGroup() - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background { - Capsule() - .fill(backgroundStyle) - .shadow(color: .black.opacity(0.15), radius: 8, y: 4) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(state.displayText) - .accessibilityAddTraits(state.isFailure ? [] : .updatesFrequently) + private var pillBody: some View { + HStack(spacing: 8) { + icon + Text(state.displayText) + .font(.subheadline) + .fontWeight(state.isFailure ? .bold : .medium) + .foregroundStyle(state.textColor) + .contentTransition(.identity) + } + .geometryGroup() + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background { + Capsule() + .fill(backgroundStyle) + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) } + .accessibilityElement(children: .combine) + .accessibilityLabel(state.displayText) + .accessibilityAddTraits(state.isFailure ? [] : .updatesFrequently) + } - @ViewBuilder - private var icon: some View { - Group { - switch state { - case .failed: - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - case .disconnected: - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.orange) - case .ready: - Image(systemName: "checkmark.circle") - .foregroundStyle(.green) - case .connecting, .syncing: - Image(systemName: "arrow.trianglehead.2.clockwise") - .symbolEffect(.rotate, isActive: true) - case .hidden: - EmptyView() - } - } - .font(.subheadline) - .frame(width: 16, height: 16) + private var icon: some View { + Group { + switch state { + case .failed: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + case .disconnected: + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.orange) + case .ready: + Image(systemName: "checkmark.circle") + .foregroundStyle(.green) + case .connecting, .syncing: + Image(systemName: "arrow.trianglehead.2.clockwise") + .symbolEffect(.rotate, isActive: true) + case .hidden: + EmptyView() + } } + .font(.subheadline) + .frame(width: 16, height: 16) + } - private var backgroundStyle: AnyShapeStyle { - if state.isFailure { - AnyShapeStyle(.red.opacity(0.15)) - } else { - AnyShapeStyle(.regularMaterial) - } + private var backgroundStyle: AnyShapeStyle { + if state.isFailure { + AnyShapeStyle(.red.opacity(0.15)) + } else { + AnyShapeStyle(.regularMaterial) } + } } #Preview("All States") { - ZStack { - Color.gray.opacity(0.3) - .ignoresSafeArea() + ZStack { + Color.gray.opacity(0.3) + .ignoresSafeArea() - VStack(spacing: 12) { - SyncingPillView(state: .connecting) - SyncingPillView(state: .syncing) - SyncingPillView(state: .ready) - SyncingPillView(state: .disconnected) - SyncingPillView(state: .failed(message: "Sync Failed")) - Spacer() - } - .padding(.top, 60) + VStack(spacing: 12) { + SyncingPillView(state: .connecting) + SyncingPillView(state: .syncing) + SyncingPillView(state: .ready) + SyncingPillView(state: .disconnected) + SyncingPillView(state: .failed(message: "Sync Failed")) + Spacer() } + .padding(.top, 60) + } } diff --git a/MC1/Views/Components/TintedLabel.swift b/MC1/Views/Components/TintedLabel.swift index 680495b2..bcc6d6fb 100644 --- a/MC1/Views/Components/TintedLabel.swift +++ b/MC1/Views/Components/TintedLabel.swift @@ -3,20 +3,20 @@ import SwiftUI /// A Label whose icon renders in the accent color. /// Use in NavigationSplitView sidebars where automatic icon tinting is suppressed. struct TintedLabel: View { - let title: String - let systemImage: String + let title: String + let systemImage: String - init(_ title: String, systemImage: String) { - self.title = title - self.systemImage = systemImage - } + init(_ title: String, systemImage: String) { + self.title = title + self.systemImage = systemImage + } - var body: some View { - Label { - Text(title) - } icon: { - Image(systemName: systemImage) - .foregroundStyle(.tint) - } + var body: some View { + Label { + Text(title) + } icon: { + Image(systemName: systemImage) + .foregroundStyle(.tint) } + } } diff --git a/MC1/Views/Components/WiFiAddressFields.swift b/MC1/Views/Components/WiFiAddressFields.swift index 52964102..1aafeedc 100644 --- a/MC1/Views/Components/WiFiAddressFields.swift +++ b/MC1/Views/Components/WiFiAddressFields.swift @@ -1,97 +1,97 @@ import SwiftUI enum WiFiField: Hashable { - case ipAddress, port + case ipAddress, port } /// Shared IP address and port input fields used by WiFi connection sheets. struct WiFiAddressFields: View { - @Binding var ipAddress: String - @Binding var port: String - var focusedField: FocusState.Binding - let sectionHeader: String - let sectionFooter: String - let onPortSubmit: () -> Void + @Binding var ipAddress: String + @Binding var port: String + var focusedField: FocusState.Binding + let sectionHeader: String + let sectionFooter: String + let onPortSubmit: () -> Void - @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.horizontalSizeClass) private var horizontalSizeClass - private var usesFullKeyboardInput: Bool { - horizontalSizeClass == .regular - } - - var body: some View { - Section { - HStack { - TextField(L10n.Onboarding.WifiConnection.IpAddress.placeholder, text: $ipAddress) - .keyboardType(usesFullKeyboardInput ? .numbersAndPunctuation : .decimalPad) - .environment(\.locale, Locale(identifier: "en_US")) - .textContentType(.none) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.next) - .focused(focusedField, equals: .ipAddress) - .onChange(of: ipAddress) { _, newValue in - let replaced = newValue.replacing(",", with: ".") - if replaced != newValue { - ipAddress = replaced - } - } - .onSubmit { - focusedField.wrappedValue = .port - } + private var usesFullKeyboardInput: Bool { + horizontalSizeClass == .regular + } - if !ipAddress.isEmpty { - Button { - ipAddress = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Onboarding.WifiConnection.IpAddress.clearAccessibility) - } + var body: some View { + Section { + HStack { + TextField(L10n.Onboarding.WifiConnection.IpAddress.placeholder, text: $ipAddress) + .keyboardType(usesFullKeyboardInput ? .numbersAndPunctuation : .decimalPad) + .environment(\.locale, Locale(identifier: "en_US")) + .textContentType(.none) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.next) + .focused(focusedField, equals: .ipAddress) + .onChange(of: ipAddress) { _, newValue in + let replaced = newValue.replacing(",", with: ".") + if replaced != newValue { + ipAddress = replaced } + } + .onSubmit { + focusedField.wrappedValue = .port + } - HStack { - TextField(L10n.Onboarding.WifiConnection.Port.placeholder, text: $port) - .keyboardType(usesFullKeyboardInput ? .numbersAndPunctuation : .numberPad) - .submitLabel(.done) - .focused(focusedField, equals: .port) - .onSubmit { - onPortSubmit() - } - - if !port.isEmpty { - Button { - port = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Onboarding.WifiConnection.Port.clearAccessibility) - } - } - } header: { - Text(sectionHeader) - } footer: { - Text(sectionFooter) + if !ipAddress.isEmpty { + Button { + ipAddress = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Onboarding.WifiConnection.IpAddress.clearAccessibility) } - } + } - // MARK: - Validation + HStack { + TextField(L10n.Onboarding.WifiConnection.Port.placeholder, text: $port) + .keyboardType(usesFullKeyboardInput ? .numbersAndPunctuation : .numberPad) + .submitLabel(.done) + .focused(focusedField, equals: .port) + .onSubmit { + onPortSubmit() + } - static func isValidIPAddress(_ ip: String) -> Bool { - let parts = ip.split(separator: ".") - guard parts.count == 4 else { return false } - return parts.allSatisfy { part in - guard let num = Int(part) else { return false } - return num >= 0 && num <= 255 + if !port.isEmpty { + Button { + port = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Onboarding.WifiConnection.Port.clearAccessibility) } + } + } header: { + Text(sectionHeader) + } footer: { + Text(sectionFooter) } + } + + // MARK: - Validation - static func isValidPort(_ port: String) -> Bool { - guard let num = UInt16(port) else { return false } - return num > 0 + static func isValidIPAddress(_ ip: String) -> Bool { + let parts = ip.split(separator: ".") + guard parts.count == 4 else { return false } + return parts.allSatisfy { part in + guard let num = Int(part) else { return false } + return num >= 0 && num <= 255 } + } + + static func isValidPort(_ port: String) -> Bool { + guard let num = UInt16(port) else { return false } + return num > 0 + } } diff --git a/MC1/Views/Components/WiFiSheetToolbarModifier.swift b/MC1/Views/Components/WiFiSheetToolbarModifier.swift index 3f7844b5..9c5013eb 100644 --- a/MC1/Views/Components/WiFiSheetToolbarModifier.swift +++ b/MC1/Views/Components/WiFiSheetToolbarModifier.swift @@ -5,53 +5,53 @@ import SwiftUI /// Provides cancel (leading), Done for iPad (top-bar-trailing when focused), /// and Done for compact-width keyboard toolbar. struct WiFiSheetToolbarModifier: ViewModifier { - var focusedField: FocusState.Binding - let isProcessing: Bool + var focusedField: FocusState.Binding + let isProcessing: Bool - @Environment(\.dismiss) private var dismiss - @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass - private var usesFullKeyboardInput: Bool { - horizontalSizeClass == .regular - } + private var usesFullKeyboardInput: Bool { + horizontalSizeClass == .regular + } - func body(content: Content) -> some View { - content - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - focusedField.wrappedValue = nil - dismiss() - } - .disabled(isProcessing) - } - ToolbarItem(placement: .topBarTrailing) { - if usesFullKeyboardInput, focusedField.wrappedValue != nil { - Button(L10n.Localizable.Common.done) { - focusedField.wrappedValue = nil - } - } - } - if !usesFullKeyboardInput { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.Localizable.Common.done) { - focusedField.wrappedValue = nil - } - } - } + func body(content: Content) -> some View { + content + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { + focusedField.wrappedValue = nil + dismiss() + } + .disabled(isProcessing) + } + ToolbarItem(placement: .topBarTrailing) { + if usesFullKeyboardInput, focusedField.wrappedValue != nil { + Button(L10n.Localizable.Common.done) { + focusedField.wrappedValue = nil } - } + } + } + if !usesFullKeyboardInput { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.Localizable.Common.done) { + focusedField.wrappedValue = nil + } + } + } + } + } } extension View { - func wifiSheetToolbar( - focusedField: FocusState.Binding, - isProcessing: Bool - ) -> some View { - modifier(WiFiSheetToolbarModifier( - focusedField: focusedField, - isProcessing: isProcessing - )) - } + func wifiSheetToolbar( + focusedField: FocusState.Binding, + isProcessing: Bool + ) -> some View { + modifier(WiFiSheetToolbarModifier( + focusedField: focusedField, + isProcessing: isProcessing + )) + } } diff --git a/MC1/Views/Contacts/AddContactSheet.swift b/MC1/Views/Contacts/AddContactSheet.swift index aa0ffeb2..d6177422 100644 --- a/MC1/Views/Contacts/AddContactSheet.swift +++ b/MC1/Views/Contacts/AddContactSheet.swift @@ -1,314 +1,314 @@ -import SwiftUI import MC1Services import os +import SwiftUI /// Sheet for manually adding a contact or scanning a QR code struct AddContactSheet: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - - @State private var selectedType: ContactType = .chat - @State private var contactName = "" - @State private var publicKeyHex = "" - @State private var showScanner = false - @State private var isSubmitting = false - @State private var errorMessage: String? - - private let logger = Logger(subsystem: "com.mc1", category: "AddContactSheet") - - // MARK: - Validation - - private var normalizedPublicKeyHex: String { - publicKeyHex.filter { $0.isHexDigit }.lowercased() - } - - private var isValidPublicKey: Bool { - normalizedPublicKeyHex.count == Constants.publicKeyHexLength - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + + @State private var selectedType: ContactType = .chat + @State private var contactName = "" + @State private var publicKeyHex = "" + @State private var showScanner = false + @State private var isSubmitting = false + @State private var errorMessage: String? + + private let logger = Logger(subsystem: "com.mc1", category: "AddContactSheet") + + // MARK: - Validation + + private var normalizedPublicKeyHex: String { + publicKeyHex.filter(\.isHexDigit).lowercased() + } + + private var isValidPublicKey: Bool { + normalizedPublicKeyHex.count == Constants.publicKeyHexLength + } + + private var canAdd: Bool { + !contactName.isEmpty && isValidPublicKey && !isSubmitting + } + + // MARK: - Body + + var body: some View { + NavigationStack { + Form { + ScannerSection(showScanner: $showScanner) + .themedRowBackground(theme) + + TypePickerSection(selectedType: $selectedType) + .themedRowBackground(theme) + + NameInputSection(contactName: $contactName) + .themedRowBackground(theme) + + PublicKeyInputSection( + publicKeyHex: $publicKeyHex, + normalizedCount: normalizedPublicKeyHex.count, + isValid: isValidPublicKey + ) + .themedRowBackground(theme) + + PasteURLSection { result in + contactName = result.name + publicKeyHex = result.publicKey.hexString + selectedType = result.contactType + errorMessage = nil + } + .themedRowBackground(theme) - private var canAdd: Bool { - !contactName.isEmpty && isValidPublicKey && !isSubmitting - } + if let errorMessage { + ErrorSection(message: errorMessage) + .themedRowBackground(theme) + } + } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.Add.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Common.cancel) { + dismiss() + } + } - // MARK: - Body - - var body: some View { - NavigationStack { - Form { - ScannerSection(showScanner: $showScanner) - .themedRowBackground(theme) - - TypePickerSection(selectedType: $selectedType) - .themedRowBackground(theme) - - NameInputSection(contactName: $contactName) - .themedRowBackground(theme) - - PublicKeyInputSection( - publicKeyHex: $publicKeyHex, - normalizedCount: normalizedPublicKeyHex.count, - isValid: isValidPublicKey - ) - .themedRowBackground(theme) - - PasteURLSection { result in - contactName = result.name - publicKeyHex = result.publicKey.hexString - selectedType = result.contactType - errorMessage = nil - } - .themedRowBackground(theme) - - if let errorMessage { - ErrorSection(message: errorMessage) - .themedRowBackground(theme) - } - } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.Add.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Contacts.Contacts.Common.cancel) { - dismiss() - } - } - - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Contacts.Contacts.Add.add) { - Task { - await handleAdd() - } - } - .disabled(!canAdd) - } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Add.add) { + Task { + await handleAdd() } - .navigationDestination(isPresented: $showScanner) { - ScanContactQRView { _, _ in - // Scanner handles import automatically - // Dismiss both sheets on success - showScanner = false - dismiss() - } - } - .disabled(isSubmitting) + } + .disabled(!canAdd) + } + } + .navigationDestination(isPresented: $showScanner) { + ScanContactQRView { _, _ in + // Scanner handles import automatically + // Dismiss both sheets on success + showScanner = false + dismiss() } + } + .disabled(isSubmitting) } + } - // MARK: - Actions - - @MainActor - private func handleAdd() async { - guard let services = appState.services, - let device = appState.connectedDevice else { - logger.error("Services or device not available") - errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected - return - } + // MARK: - Actions - let radioID = device.radioID - let maxContacts = device.maxContacts + @MainActor + private func handleAdd() async { + guard let services = appState.services, + let device = appState.connectedDevice else { + logger.error("Services or device not available") + errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected + return + } - guard let publicKeyData = Data(hexString: normalizedPublicKeyHex) else { - logger.error("Failed to convert hex string to data: \(normalizedPublicKeyHex)") - errorMessage = L10n.Contacts.Contacts.Add.Error.invalidFormat - return - } + let radioID = device.radioID + let maxContacts = device.maxContacts - guard publicKeyData.count == ProtocolLimits.publicKeySize else { - logger.error("Public key is not \(ProtocolLimits.publicKeySize) bytes: \(publicKeyData.count)") - errorMessage = L10n.Contacts.Contacts.Add.Error.invalidSize(ProtocolLimits.publicKeySize, Constants.publicKeyHexLength) - return - } + guard let publicKeyData = Data(hexString: normalizedPublicKeyHex) else { + logger.error("Failed to convert hex string to data: \(normalizedPublicKeyHex)") + errorMessage = L10n.Contacts.Contacts.Add.Error.invalidFormat + return + } - isSubmitting = true - errorMessage = nil - - do { - let currentTimestamp = UInt32(Date.now.timeIntervalSince1970) - - let contactFrame = ContactFrame( - publicKey: publicKeyData, - type: selectedType, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: contactName, - lastAdvertTimestamp: 0, // Never advertised - latitude: 0, - longitude: 0, - lastModified: currentTimestamp - ) - - logger.info("Adding contact: \(contactName) (\(publicKeyData.hexString))") - try await services.contactService.addOrUpdateContact(radioID: radioID, contact: contactFrame) - logger.info("Contact added successfully") + guard publicKeyData.count == ProtocolLimits.publicKeySize else { + logger.error("Public key is not \(ProtocolLimits.publicKeySize) bytes: \(publicKeyData.count)") + errorMessage = L10n.Contacts.Contacts.Add.Error.invalidSize(ProtocolLimits.publicKeySize, Constants.publicKeyHexLength) + return + } - dismiss() - } catch ContactServiceError.contactTableFull { - logger.error("Node list is full") - errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) - isSubmitting = false - } catch { - logger.error("Failed to add contact: \(error.localizedDescription)") - errorMessage = "\(L10n.Contacts.Contacts.Common.error): \(error.userFacingMessage)" - isSubmitting = false - } + isSubmitting = true + errorMessage = nil + + do { + let currentTimestamp = UInt32(Date.now.timeIntervalSince1970) + + let contactFrame = ContactFrame( + publicKey: publicKeyData, + type: selectedType, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: contactName, + lastAdvertTimestamp: 0, // Never advertised + latitude: 0, + longitude: 0, + lastModified: currentTimestamp + ) + + logger.info("Adding contact: \(contactName) (\(publicKeyData.hexString))") + try await services.contactService.addOrUpdateContact(radioID: radioID, contact: contactFrame) + logger.info("Contact added successfully") + + dismiss() + } catch ContactServiceError.contactTableFull { + logger.error("Node list is full") + errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) + isSubmitting = false + } catch { + logger.error("Failed to add contact: \(error.localizedDescription)") + errorMessage = "\(L10n.Contacts.Contacts.Common.error): \(error.userFacingMessage)" + isSubmitting = false } + } } // MARK: - Constants private enum Constants { - static let publicKeyHexLength = ProtocolLimits.publicKeySize * 2 + static let publicKeyHexLength = ProtocolLimits.publicKeySize * 2 } // MARK: - Scanner Section private struct ScannerSection: View { - @Binding var showScanner: Bool - - var body: some View { - Section { - Button { - showScanner = true - } label: { - Label(L10n.Contacts.Contacts.Add.scanQR, systemImage: "camera") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } + @Binding var showScanner: Bool + + var body: some View { + Section { + Button { + showScanner = true + } label: { + Label(L10n.Contacts.Contacts.Add.scanQR, systemImage: "camera") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) } + } } // MARK: - Type Picker Section private struct TypePickerSection: View { - @Binding var selectedType: ContactType - - var body: some View { - Section { - Picker(L10n.Contacts.Contacts.Add.type, selection: $selectedType) { - Text(L10n.Contacts.Contacts.NodeKind.chat).tag(ContactType.chat) - Text(L10n.Contacts.Contacts.NodeKind.repeater).tag(ContactType.repeater) - Text(L10n.Contacts.Contacts.NodeKind.room).tag(ContactType.room) - } - .pickerStyle(.segmented) - } header: { - Text(L10n.Contacts.Contacts.Add.type) - } + @Binding var selectedType: ContactType + + var body: some View { + Section { + Picker(L10n.Contacts.Contacts.Add.type, selection: $selectedType) { + Text(L10n.Contacts.Contacts.NodeKind.chat).tag(ContactType.chat) + Text(L10n.Contacts.Contacts.NodeKind.repeater).tag(ContactType.repeater) + Text(L10n.Contacts.Contacts.NodeKind.room).tag(ContactType.room) + } + .pickerStyle(.segmented) + } header: { + Text(L10n.Contacts.Contacts.Add.type) } + } } // MARK: - Name Input Section private struct NameInputSection: View { - @Binding var contactName: String - - var body: some View { - Section { - TextField(L10n.Contacts.Contacts.Add.contactName, text: $contactName) - .textInputAutocapitalization(.words) - .autocorrectionDisabled() - .onChange(of: contactName) { _, newValue in - if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { - contactName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - } - } - } header: { - Text(L10n.Contacts.Contacts.Add.name) + @Binding var contactName: String + + var body: some View { + Section { + TextField(L10n.Contacts.Contacts.Add.contactName, text: $contactName) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .onChange(of: contactName) { _, newValue in + if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { + contactName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + } } + } header: { + Text(L10n.Contacts.Contacts.Add.name) } + } } // MARK: - Public Key Input Section private struct PublicKeyInputSection: View { - @Binding var publicKeyHex: String - let normalizedCount: Int - let isValid: Bool - - var body: some View { - Section { - TextField(L10n.Contacts.Contacts.Add.hexPlaceholder(Constants.publicKeyHexLength), text: $publicKeyHex) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.asciiCapable) - .font(.system(.body, design: .monospaced)) - .onChange(of: publicKeyHex) { _, newValue in - // Filter to hex chars only and lowercase - let filtered = newValue.filter { $0.isHexDigit }.lowercased() - if filtered != newValue { - publicKeyHex = filtered - } - } - - if !publicKeyHex.isEmpty { - HStack { - if isValid { - Label(L10n.Contacts.Contacts.Add.valid, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } else { - Label(L10n.Contacts.Contacts.Add.characterCount(normalizedCount, Constants.publicKeyHexLength), systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } - } - .font(.caption) - } - } header: { - Text(L10n.Contacts.Contacts.Add.publicKey) - } footer: { - Text(L10n.Contacts.Contacts.Add.publicKeyFooter(Constants.publicKeyHexLength)) + @Binding var publicKeyHex: String + let normalizedCount: Int + let isValid: Bool + + var body: some View { + Section { + TextField(L10n.Contacts.Contacts.Add.hexPlaceholder(Constants.publicKeyHexLength), text: $publicKeyHex) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .font(.system(.body, design: .monospaced)) + .onChange(of: publicKeyHex) { _, newValue in + // Filter to hex chars only and lowercase + let filtered = newValue.filter(\.isHexDigit).lowercased() + if filtered != newValue { + publicKeyHex = filtered + } + } + + if !publicKeyHex.isEmpty { + HStack { + if isValid { + Label(L10n.Contacts.Contacts.Add.valid, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Label(L10n.Contacts.Contacts.Add.characterCount(normalizedCount, Constants.publicKeyHexLength), systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } } + .font(.caption) + } + } header: { + Text(L10n.Contacts.Contacts.Add.publicKey) + } footer: { + Text(L10n.Contacts.Contacts.Add.publicKeyFooter(Constants.publicKeyHexLength)) } + } } // MARK: - Error Section private struct ErrorSection: View { - let message: String + let message: String - var body: some View { - Section { - Text(message) - .foregroundStyle(.red) - .font(.caption) - } + var body: some View { + Section { + Text(message) + .foregroundStyle(.red) + .font(.caption) } + } } // MARK: - Paste URL Section private struct PasteURLSection: View { - let onParsed: (MeshCoreURLParser.ContactResult) -> Void - - @State private var showError = false - - var body: some View { - Section { - Button(L10n.Contacts.Contacts.Add.pasteURL, systemImage: "doc.on.clipboard") { - guard let clipboard = UIPasteboard.general.string, - let result = MeshCoreURLParser.parseContactURL(clipboard) else { - showError = true - return - } - showError = false - onParsed(result) - } + let onParsed: (MeshCoreURLParser.ContactResult) -> Void - if showError { - Text(L10n.Contacts.Contacts.Add.Error.invalidURL) - .foregroundStyle(.red) - .font(.caption) - } - } footer: { - Text(L10n.Contacts.Contacts.Add.pasteURLFooter) + @State private var showError = false + + var body: some View { + Section { + Button(L10n.Contacts.Contacts.Add.pasteURL, systemImage: "doc.on.clipboard") { + guard let clipboard = UIPasteboard.general.string, + let result = MeshCoreURLParser.parseContactURL(clipboard) else { + showError = true + return } + showError = false + onParsed(result) + } + + if showError { + Text(L10n.Contacts.Contacts.Add.Error.invalidURL) + .foregroundStyle(.red) + .font(.caption) + } + } footer: { + Text(L10n.Contacts.Contacts.Add.pasteURLFooter) } + } } #Preview { - AddContactSheet() - .environment(\.appState, AppState()) + AddContactSheet() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Contacts/BlockedContactsView.swift b/MC1/Views/Contacts/BlockedContactsView.swift index 30ba9e3f..d42b7b31 100644 --- a/MC1/Views/Contacts/BlockedContactsView.swift +++ b/MC1/Views/Contacts/BlockedContactsView.swift @@ -1,80 +1,80 @@ -import SwiftUI import MC1Services +import SwiftUI /// View showing only blocked contacts for management struct BlockedContactsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var contacts: [ContactDTO] = [] - @State private var isLoading = false - @State private var errorMessage: String? + @State private var contacts: [ContactDTO] = [] + @State private var isLoading = false + @State private var errorMessage: String? - var body: some View { - Group { - if isLoading { - ProgressView(L10n.Contacts.Contacts.Blocked.loading) - } else if contacts.isEmpty { - ContentUnavailableView( - L10n.Contacts.Contacts.Blocked.Empty.title, - systemImage: "hand.raised.slash", - description: Text(L10n.Contacts.Contacts.Blocked.Empty.description) - ) - } else { - blockedList - } - } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.Blocked.title) - .errorAlert($errorMessage) - .task { - await loadBlockedContacts() - } - .onChange(of: appState.contactsVersion) { _, _ in - Task { - await loadBlockedContacts() - } - } + var body: some View { + Group { + if isLoading { + ProgressView(L10n.Contacts.Contacts.Blocked.loading) + } else if contacts.isEmpty { + ContentUnavailableView( + L10n.Contacts.Contacts.Blocked.Empty.title, + systemImage: "hand.raised.slash", + description: Text(L10n.Contacts.Contacts.Blocked.Empty.description) + ) + } else { + blockedList + } + } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.Blocked.title) + .errorAlert($errorMessage) + .task { + await loadBlockedContacts() } + .onChange(of: appState.contactsVersion) { _, _ in + Task { + await loadBlockedContacts() + } + } + } - /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. - private static let rowSeparatorLeadingInset: CGFloat = 72 - private static let rowHorizontalPadding: CGFloat = 16 - private static let rowVerticalPadding: CGFloat = 6 + /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. + private static let rowSeparatorLeadingInset: CGFloat = 72 + private static let rowHorizontalPadding: CGFloat = 16 + private static let rowVerticalPadding: CGFloat = 6 - private var blockedList: some View { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in - NavigationLink(value: ContactRoute.detail(contact)) { - ContactRowView(contact: contact) - .padding(.horizontal, Self.rowHorizontalPadding) - .padding(.vertical, Self.rowVerticalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } - .buttonStyle(.plain) - .transition(.opacity) - if index < contacts.count - 1 { - Divider().padding(.leading, Self.rowSeparatorLeadingInset) - } - } - } + private var blockedList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in + NavigationLink(value: ContactRoute.detail(contact)) { + ContactRowView(contact: contact) + .padding(.horizontal, Self.rowHorizontalPadding) + .padding(.vertical, Self.rowVerticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .transition(.opacity) + if index < contacts.count - 1 { + Divider().padding(.leading, Self.rowSeparatorLeadingInset) + } } + } } + } - private func loadBlockedContacts() async { - guard let services = appState.services, - let radioID = appState.connectedDevice?.radioID else { return } - isLoading = true - defer { isLoading = false } + private func loadBlockedContacts() async { + guard let services = appState.services, + let radioID = appState.connectedDevice?.radioID else { return } + isLoading = true + defer { isLoading = false } - do { - contacts = try await services.dataStore.fetchBlockedContacts( - radioID: radioID - ) - } catch { - errorMessage = error.userFacingMessage - } + do { + contacts = try await services.dataStore.fetchBlockedContacts( + radioID: radioID + ) + } catch { + errorMessage = error.userFacingMessage } + } } diff --git a/MC1/Views/Contacts/ContactContextMenuModifier.swift b/MC1/Views/Contacts/ContactContextMenuModifier.swift index b362e162..cc1a8da1 100644 --- a/MC1/Views/Contacts/ContactContextMenuModifier.swift +++ b/MC1/Views/Contacts/ContactContextMenuModifier.swift @@ -1,61 +1,61 @@ -import SwiftUI import MC1Services +import SwiftUI /// Long-press context-menu actions for a node row: delete, block/unblock, favorite/unfavorite, /// matching the conversation list. Delete is gated while a removal is in flight so a rapid re-press /// can't double-fire. struct ContactContextMenuModifier: ViewModifier { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let contact: ContactDTO - let viewModel: ContactsViewModel + let contact: ContactDTO + let viewModel: ContactsViewModel - private var isConnected: Bool { - appState.connectionState == .ready - } + private var isConnected: Bool { + appState.connectionState == .ready + } - func body(content: Content) -> some View { - content.contextMenu { - Button(role: .destructive) { - Task { - await viewModel.deleteContact(contact) - } - } label: { - Label(L10n.Contacts.Contacts.Common.delete, systemImage: "trash") - } - .disabled(!isConnected || viewModel.isDeletePending(contact.id)) + func body(content: Content) -> some View { + content.contextMenu { + Button(role: .destructive) { + Task { + await viewModel.deleteContact(contact) + } + } label: { + Label(L10n.Contacts.Contacts.Common.delete, systemImage: "trash") + } + .disabled(!isConnected || viewModel.isDeletePending(contact.id)) - if contact.type == .chat { - Button { - Task { - await viewModel.toggleBlocked(contact: contact) - } - } label: { - Label( - contact.isBlocked ? L10n.Contacts.Contacts.Action.unblock : L10n.Contacts.Contacts.Action.block, - systemImage: contact.isBlocked ? "hand.raised.slash" : "hand.raised" - ) - } - .disabled(!isConnected) - } + if contact.type == .chat { + Button { + Task { + await viewModel.toggleBlocked(contact: contact) + } + } label: { + Label( + contact.isBlocked ? L10n.Contacts.Contacts.Action.unblock : L10n.Contacts.Contacts.Action.block, + systemImage: contact.isBlocked ? "hand.raised.slash" : "hand.raised" + ) + } + .disabled(!isConnected) + } - Button { - Task { - await viewModel.toggleFavorite(contact: contact) - } - } label: { - Label( - contact.isFavorite ? L10n.Contacts.Contacts.Action.unfavorite : L10n.Contacts.Contacts.Row.favorite, - systemImage: contact.isFavorite ? "star.slash" : "star.fill" - ) - } - .disabled(!isConnected || viewModel.togglingFavoriteID == contact.id) + Button { + Task { + await viewModel.toggleFavorite(contact: contact) } + } label: { + Label( + contact.isFavorite ? L10n.Contacts.Contacts.Action.unfavorite : L10n.Contacts.Contacts.Row.favorite, + systemImage: contact.isFavorite ? "star.slash" : "star.fill" + ) + } + .disabled(!isConnected || viewModel.togglingFavoriteID == contact.id) } + } } extension View { - func contactContextMenu(contact: ContactDTO, viewModel: ContactsViewModel) -> some View { - modifier(ContactContextMenuModifier(contact: contact, viewModel: viewModel)) - } + func contactContextMenu(contact: ContactDTO, viewModel: ContactsViewModel) -> some View { + modifier(ContactContextMenuModifier(contact: contact, viewModel: viewModel)) + } } diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index dc1afe19..e670d946 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -5,1216 +5,1216 @@ import UIKit /// Result of a ping operation enum PingResult { - case success(latencyMs: Int, snrThere: Double, snrBack: Double) - case error(String) + case success(latencyMs: Int, snrThere: Double, snrBack: Double) + case error(String) } /// Displays ping result with latency and bidirectional SNR struct PingResultRow: View { - let result: PingResult - - var body: some View { - switch result { - case .success(let latencyMs, let snrThere, let snrBack): - let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(2)) - Label { - Text("\(latencyMs) ms · SNR ↑ \(snrThere, format: snrFormat) dB ↓ \(snrBack, format: snrFormat) dB") - .font(.subheadline) - } icon: { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Contacts.Contacts.Detail.pingSuccessLabel(latencyMs, Int(snrThere), Int(snrBack))) - case .error(let message): - Label { - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - } icon: { - Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(.orange) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Contacts.Contacts.Detail.pingFailureLabel(message)) - } + let result: PingResult + + var body: some View { + switch result { + case let .success(latencyMs, snrThere, snrBack): + let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(2)) + Label { + Text("\(latencyMs) ms · SNR ↑ \(snrThere, format: snrFormat) dB ↓ \(snrBack, format: snrFormat) dB") + .font(.subheadline) + } icon: { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Contacts.Contacts.Detail.pingSuccessLabel(latencyMs, Int(snrThere), Int(snrBack))) + case let .error(message): + Label { + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Contacts.Contacts.Detail.pingFailureLabel(message)) } + } } /// Detailed view for a single contact struct ContactDetailView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - - let contact: ContactDTO - let showFromDirectChat: Bool - let onClearMessages: () -> Void - - /// Sheet types for the contact detail view - private enum ActiveSheet: Identifiable, Hashable { - case nodeAuth - case repeaterStatus(RemoteNodeSessionDTO) - case roomStatus(RemoteNodeSessionDTO) - case nodeTelemetry(ContactDTO) - case adminSettings(RemoteNodeSessionDTO) - - var id: String { - switch self { - case .nodeAuth: return "auth" - case .repeaterStatus(let session): return "status-\(session.id)" - case .roomStatus(let session): return "room-status-\(session.id)" - case .nodeTelemetry(let contact): return "telemetry-\(contact.id)" - case .adminSettings(let session): return "admin-settings-\(session.id)" - } - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + + let contact: ContactDTO + let showFromDirectChat: Bool + let onClearMessages: () -> Void + + /// Sheet types for the contact detail view + private enum ActiveSheet: Identifiable, Hashable { + case nodeAuth + case repeaterStatus(RemoteNodeSessionDTO) + case roomStatus(RemoteNodeSessionDTO) + case nodeTelemetry(ContactDTO) + case adminSettings(RemoteNodeSessionDTO) + + var id: String { + switch self { + case .nodeAuth: "auth" + case let .repeaterStatus(session): "status-\(session.id)" + case let .roomStatus(session): "room-status-\(session.id)" + case let .nodeTelemetry(contact): "telemetry-\(contact.id)" + case let .adminSettings(session): "admin-settings-\(session.id)" + } } - - @State private var currentContact: ContactDTO - @State private var nickname = "" - @State private var isEditingNickname = false - @State private var showingBlockAlert = false - @State private var showingDeleteAlert = false - @State private var showingClearMessagesAlert = false - @State private var isClearingMessages = false - @State private var isSaving = false - @State private var isTogglingFavorite = false - @State private var errorMessage: String? - @State private var pathViewModel = PathManagementViewModel() - @State private var showRoomJoinSheet = false - @State private var activeSheet: ActiveSheet? - @State private var pendingSheet: ActiveSheet? - // Admin access navigation state (separate from telemetry sheet flow) - @State private var showRepeaterAdminAuth = false - @State private var adminSession: RemoteNodeSessionDTO? - // QR sharing state - @State private var showQRShareSheet = false - // Ping state - @State private var isPinging = false - @State private var pingResult: PingResult? - @State private var isSharing = false - @State private var showShareSuccess = false - - init(contact: ContactDTO, showFromDirectChat: Bool = false, onClearMessages: @escaping () -> Void = {}) { - self.contact = contact - self.showFromDirectChat = showFromDirectChat - self.onClearMessages = onClearMessages - self._currentContact = State(initialValue: contact) + } + + @State private var currentContact: ContactDTO + @State private var nickname = "" + @State private var isEditingNickname = false + @State private var showingBlockAlert = false + @State private var showingDeleteAlert = false + @State private var showingClearMessagesAlert = false + @State private var isClearingMessages = false + @State private var isSaving = false + @State private var isTogglingFavorite = false + @State private var errorMessage: String? + @State private var pathViewModel = PathManagementViewModel() + @State private var showRoomJoinSheet = false + @State private var activeSheet: ActiveSheet? + @State private var pendingSheet: ActiveSheet? + // Admin access navigation state (separate from telemetry sheet flow) + @State private var showRepeaterAdminAuth = false + @State private var adminSession: RemoteNodeSessionDTO? + /// QR sharing state + @State private var showQRShareSheet = false + // Ping state + @State private var isPinging = false + @State private var pingResult: PingResult? + @State private var isSharing = false + @State private var showShareSuccess = false + + init(contact: ContactDTO, showFromDirectChat: Bool = false, onClearMessages: @escaping () -> Void = {}) { + self.contact = contact + self.showFromDirectChat = showFromDirectChat + self.onClearMessages = onClearMessages + _currentContact = State(initialValue: contact) + } + + var body: some View { + List { + // Profile header + ContactProfileSection( + currentContact: currentContact, + contactTypeLabel: contactTypeLabel + ) + + // Quick actions + ContactActionsSection( + currentContact: currentContact, + showFromDirectChat: showFromDirectChat, + isPinging: isPinging, + isTogglingFavorite: isTogglingFavorite, + pingResult: pingResult, + onJoinRoom: { showRoomJoinSheet = true }, + onShowTelemetry: { + if currentContact.type == .chat { + activeSheet = .nodeTelemetry(currentContact) + } else { + activeSheet = .nodeAuth + } + }, + onShowAdminAccess: { + adminSession = nil + showRepeaterAdminAuth = true + }, + onPingRepeater: { Task { await pingRepeater() } }, + onToggleFavorite: { Task { await toggleFavorite() } }, + onShareQR: { showQRShareSheet = true }, + onShareViaAdvert: { Task { await shareContact() } }, + isSharing: isSharing, + showShareSuccess: showShareSuccess + ) + .themedRowBackground(theme) + + // Info section + ContactInfoSection( + currentContact: currentContact, + nickname: $nickname, + isEditingNickname: $isEditingNickname, + isSaving: isSaving, + onSaveNickname: { Task { await saveNickname() } } + ) + .themedRowBackground(theme) + + // Location section (if available) + if currentContact.hasLocation { + ContactLocationSection(currentContact: currentContact) + .themedRowBackground(theme) + } + + // Network path controls + ContactNetworkPathSection( + currentContact: currentContact, + pathViewModel: pathViewModel + ) + .themedRowBackground(theme) + + // Technical details + ContactTechnicalSection( + currentContact: currentContact, + contactTypeLabel: contactTypeLabel + ) + .themedRowBackground(theme) + + // Danger zone + ContactDangerSection( + currentContact: currentContact, + contactTypeLabel: contactTypeLabel, + isClearingMessages: isClearingMessages, + onClearMessages: { showingClearMessagesAlert = true }, + onToggleBlock: { + if currentContact.isBlocked { + Task { await toggleBlocked() } + } else { + showingBlockAlert = true + } + }, + onDelete: { showingDeleteAlert = true } + ) + .themedRowBackground(theme) } - - var body: some View { - List { - // Profile header - ContactProfileSection( - currentContact: currentContact, - contactTypeLabel: contactTypeLabel - ) - - // Quick actions - ContactActionsSection( - currentContact: currentContact, - showFromDirectChat: showFromDirectChat, - isPinging: isPinging, - isTogglingFavorite: isTogglingFavorite, - pingResult: pingResult, - onJoinRoom: { showRoomJoinSheet = true }, - onShowTelemetry: { - if currentContact.type == .chat { - activeSheet = .nodeTelemetry(currentContact) - } else { - activeSheet = .nodeAuth - } - }, - onShowAdminAccess: { - adminSession = nil - showRepeaterAdminAuth = true - }, - onPingRepeater: { Task { await pingRepeater() } }, - onToggleFavorite: { Task { await toggleFavorite() } }, - onShareQR: { showQRShareSheet = true }, - onShareViaAdvert: { Task { await shareContact() } }, - isSharing: isSharing, - showShareSuccess: showShareSuccess - ) - .themedRowBackground(theme) - - // Info section - ContactInfoSection( - currentContact: currentContact, - nickname: $nickname, - isEditingNickname: $isEditingNickname, - isSaving: isSaving, - onSaveNickname: { Task { await saveNickname() } } - ) - .themedRowBackground(theme) - - // Location section (if available) - if currentContact.hasLocation { - ContactLocationSection(currentContact: currentContact) - .themedRowBackground(theme) - } - - // Network path controls - ContactNetworkPathSection( - currentContact: currentContact, - pathViewModel: pathViewModel - ) - .themedRowBackground(theme) - - // Technical details - ContactTechnicalSection( - currentContact: currentContact, - contactTypeLabel: contactTypeLabel - ) - .themedRowBackground(theme) - - // Danger zone - ContactDangerSection( - currentContact: currentContact, - contactTypeLabel: contactTypeLabel, - isClearingMessages: isClearingMessages, - onClearMessages: { showingClearMessagesAlert = true }, - onToggleBlock: { - if currentContact.isBlocked { - Task { await toggleBlocked() } - } else { - showingBlockAlert = true - } - }, - onDelete: { showingDeleteAlert = true } - ) - .themedRowBackground(theme) - } - .themedCanvas(theme) - .errorAlert($errorMessage) - .navigationTitle(contactTypeLabel) - .navigationBarTitleDisplayMode(.inline) - .alert(L10n.Contacts.Contacts.Detail.Alert.Block.title, isPresented: $showingBlockAlert) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - Button(L10n.Contacts.Contacts.Action.block, role: .destructive) { - Task { - await toggleBlocked() - } - } - } message: { - Text(L10n.Contacts.Contacts.Detail.Alert.Block.message(currentContact.displayName)) - } - .alert(L10n.Contacts.Contacts.Detail.Alert.Delete.title(contactTypeLabel), isPresented: $showingDeleteAlert) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { - Task { - await deleteContact() - } - } - } message: { - Text(L10n.Contacts.Contacts.Detail.Alert.Delete.message(currentContact.displayName)) - } - .alert(L10n.Contacts.Contacts.Detail.Alert.ClearMessages.title, isPresented: $showingClearMessagesAlert) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - Button(L10n.Contacts.Contacts.Detail.clearMessages, role: .destructive) { - Task { - await clearMessages() - } - } - } message: { - Text(L10n.Contacts.Contacts.Detail.Alert.ClearMessages.message(currentContact.displayName)) - } - .onAppear { - nickname = currentContact.nickname ?? "" - } - .task { - pathViewModel.configure( - dataStore: { appState.services?.dataStore }, - contactService: { appState.services?.contactService }, - connectedDevice: { appState.connectedDevice } - ) { - Task { @MainActor in - await refreshContact() - } - } - await pathViewModel.loadContacts(radioID: currentContact.radioID) - - // Fetch fresh contact data from device to catch external changes - // (e.g., user modified path in official MeshCore app) - if let freshContact = try? await appState.services?.contactService.getContact( - radioID: currentContact.radioID, - publicKey: currentContact.publicKey - ) { - currentContact = freshContact - } - - // React to path discovery push responses while this view is open. - // The view-scoped task cancels the subscription on dismiss; the - // stream is multicast, so a second detail column (iPad split view) - // can subscribe concurrently. - guard let advertisementService = appState.services?.advertisementService else { return } - for await event in advertisementService.events() { - if case .pathDiscoveryResponse(let response) = event { - pathViewModel.handleDiscoveryResponse(hopCount: response.outHopCount) - } - } + .themedCanvas(theme) + .errorAlert($errorMessage) + .navigationTitle(contactTypeLabel) + .navigationBarTitleDisplayMode(.inline) + .alert(L10n.Contacts.Contacts.Detail.Alert.Block.title, isPresented: $showingBlockAlert) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.Action.block, role: .destructive) { + Task { + await toggleBlocked() } - .onDisappear { - pathViewModel.cancelDiscovery() + } + } message: { + Text(L10n.Contacts.Contacts.Detail.Alert.Block.message(currentContact.displayName)) + } + .alert(L10n.Contacts.Contacts.Detail.Alert.Delete.title(contactTypeLabel), isPresented: $showingDeleteAlert) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { + Task { + await deleteContact() } - .sheet( - isPresented: $pathViewModel.showingPathEditor, - onDismiss: { pathViewModel.insertionIntent = nil } - ) { - PathEditingSheet(viewModel: pathViewModel, contact: currentContact) + } + } message: { + Text(L10n.Contacts.Contacts.Detail.Alert.Delete.message(currentContact.displayName)) + } + .alert(L10n.Contacts.Contacts.Detail.Alert.ClearMessages.title, isPresented: $showingClearMessagesAlert) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.Detail.clearMessages, role: .destructive) { + Task { + await clearMessages() } - .alert( - L10n.Contacts.Contacts.Detail.Alert.pathError, - isPresented: Binding( - get: { pathViewModel.errorMessage != nil }, - set: { if !$0 { pathViewModel.errorMessage = nil } } - ) - ) { - Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) { } - } message: { - Text(pathViewModel.errorMessage ?? L10n.Contacts.Contacts.Common.errorOccurred) + } + } message: { + Text(L10n.Contacts.Contacts.Detail.Alert.ClearMessages.message(currentContact.displayName)) + } + .onAppear { + nickname = currentContact.nickname ?? "" + } + .task { + pathViewModel.configure( + dataStore: { appState.services?.dataStore }, + contactService: { appState.services?.contactService }, + connectedDevice: { appState.connectedDevice } + ) { + Task { @MainActor in + await refreshContact() } - .alert(L10n.Contacts.Contacts.Detail.Alert.pathDiscovery, isPresented: $pathViewModel.showDiscoveryResult) { - Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) { } - } message: { - Text(pathViewModel.discoveryResult?.description ?? "") + } + await pathViewModel.loadContacts(radioID: currentContact.radioID) + + // Fetch fresh contact data from device to catch external changes + // (e.g., user modified path in official MeshCore app) + if let freshContact = try? await appState.services?.contactService.getContact( + radioID: currentContact.radioID, + publicKey: currentContact.publicKey + ) { + currentContact = freshContact + } + + // React to path discovery push responses while this view is open. + // The view-scoped task cancels the subscription on dismiss; the + // stream is multicast, so a second detail column (iPad split view) + // can subscribe concurrently. + guard let advertisementService = appState.services?.advertisementService else { return } + for await event in advertisementService.events() { + if case let .pathDiscoveryResponse(response) = event { + pathViewModel.handleDiscoveryResponse(hopCount: response.outHopCount) } - .sheet(isPresented: $showRoomJoinSheet) { - if let role = RemoteNodeRole(contactType: currentContact.type) { - NodeAuthenticationSheet(contact: currentContact, role: role) { session in - // Navigate to Chats tab with the room conversation - appState.navigation.navigateToRoom(with: session) - } - .presentationSizing(.page) - } + } + } + .onDisappear { + pathViewModel.cancelDiscovery() + } + .sheet( + isPresented: $pathViewModel.showingPathEditor, + onDismiss: { pathViewModel.insertionIntent = nil } + ) { + PathEditingSheet(viewModel: pathViewModel, contact: currentContact) + } + .alert( + L10n.Contacts.Contacts.Detail.Alert.pathError, + isPresented: Binding( + get: { pathViewModel.errorMessage != nil }, + set: { if !$0 { pathViewModel.errorMessage = nil } } + ) + ) { + Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) {} + } message: { + Text(pathViewModel.errorMessage ?? L10n.Contacts.Contacts.Common.errorOccurred) + } + .alert(L10n.Contacts.Contacts.Detail.Alert.pathDiscovery, isPresented: $pathViewModel.showDiscoveryResult) { + Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) {} + } message: { + Text(pathViewModel.discoveryResult?.description ?? "") + } + .sheet(isPresented: $showRoomJoinSheet) { + if let role = RemoteNodeRole(contactType: currentContact.type) { + NodeAuthenticationSheet(contact: currentContact, role: role) { session in + // Navigate to Chats tab with the room conversation + appState.navigation.navigateToRoom(with: session) } - .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in - switch sheet { - case .nodeAuth: - if let role = RemoteNodeRole(contactType: currentContact.type) { - NodeAuthenticationSheet( - contact: currentContact, - role: role, - customTitle: L10n.Contacts.Contacts.Detail.telemetryAccess - ) { session in - if currentContact.type == .room { - pendingSheet = .roomStatus(session) - } else { - pendingSheet = .repeaterStatus(session) - } - activeSheet = nil // Triggers dismissal, then onDismiss fires - } - .presentationSizing(.page) - } - case .repeaterStatus(let session): - RepeaterStatusView(session: session) - case .roomStatus(let session): - RoomStatusView(session: session) - case .nodeTelemetry(let contact): - NodeTelemetryView(contact: contact) - case .adminSettings(let session): - // A sheet with its own stack, not a push onto the value/path-based Contacts stack: - // the telemetry tab's history graphs push value-based routes, and pushing this screen - // there instead would rebuild it and reset the selected tab whenever a graph is tapped. - NavigationStack { - Group { - if session.isRoom { - RoomSettingsView(session: session) - } else { - RepeaterSettingsView(session: session) - } - } - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.RemoteNodes.RemoteNodes.done) { activeSheet = nil } - } - } - } - .presentationSizing(.page) + .presentationSizing(.page) + } + } + .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in + switch sheet { + case .nodeAuth: + if let role = RemoteNodeRole(contactType: currentContact.type) { + NodeAuthenticationSheet( + contact: currentContact, + role: role, + customTitle: L10n.Contacts.Contacts.Detail.telemetryAccess + ) { session in + if currentContact.type == .room { + pendingSheet = .roomStatus(session) + } else { + pendingSheet = .repeaterStatus(session) } + activeSheet = nil // Triggers dismissal, then onDismiss fires + } + .presentationSizing(.page) } - .sheet(isPresented: $showRepeaterAdminAuth, onDismiss: { - // Trigger navigation after sheet is fully dismissed to avoid race conditions - if let session = adminSession { - if session.isAdmin { - activeSheet = .adminSettings(session) - } else if session.isRoom { - activeSheet = .roomStatus(session) - } else { - activeSheet = .repeaterStatus(session) - } + case let .repeaterStatus(session): + RepeaterStatusView(session: session) + case let .roomStatus(session): + RoomStatusView(session: session) + case let .nodeTelemetry(contact): + NodeTelemetryView(contact: contact) + case let .adminSettings(session): + // A sheet with its own stack, not a push onto the value/path-based Contacts stack: + // the telemetry tab's history graphs push value-based routes, and pushing this screen + // there instead would rebuild it and reset the selected tab whenever a graph is tapped. + NavigationStack { + Group { + if session.isRoom { + RoomSettingsView(session: session) + } else { + RepeaterSettingsView(session: session) } - }) { - if let role = RemoteNodeRole(contactType: currentContact.type) { - NodeAuthenticationSheet(contact: currentContact, role: role) { session in - adminSession = session - showRepeaterAdminAuth = false - // Navigation triggers in onDismiss above - } - .presentationSizing(.page) + } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.RemoteNodes.RemoteNodes.done) { activeSheet = nil } } + } } - .sheet(isPresented: $showQRShareSheet) { - ContactQRShareSheet( - contactName: currentContact.name, - publicKey: currentContact.publicKey, - contactType: currentContact.type - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) + .presentationSizing(.page) + } + } + .sheet(isPresented: $showRepeaterAdminAuth, onDismiss: { + // Trigger navigation after sheet is fully dismissed to avoid race conditions + if let session = adminSession { + if session.isAdmin { + activeSheet = .adminSettings(session) + } else if session.isRoom { + activeSheet = .roomStatus(session) + } else { + activeSheet = .repeaterStatus(session) } - .navigationDestination(for: ContactRoute.TelemetryHistory.self) { route in - TelemetryHistoryOverviewView( - publicKey: route.publicKey, - radioID: route.radioID, - showNeighbors: route.showNeighbors - ) + } + }) { + if let role = RemoteNodeRole(contactType: currentContact.type) { + NodeAuthenticationSheet(contact: currentContact, role: role) { session in + adminSession = session + showRepeaterAdminAuth = false + // Navigation triggers in onDismiss above } + .presentationSizing(.page) + } } - - // MARK: - Sheet Management - - private func presentPendingSheet() { - if let next = pendingSheet { - pendingSheet = nil - activeSheet = next - } + .sheet(isPresented: $showQRShareSheet) { + ContactQRShareSheet( + contactName: currentContact.name, + publicKey: currentContact.publicKey, + contactType: currentContact.type + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) } + .navigationDestination(for: ContactRoute.TelemetryHistory.self) { route in + TelemetryHistoryOverviewView( + publicKey: route.publicKey, + radioID: route.radioID, + showNeighbors: route.showNeighbors + ) + } + } - // MARK: - Actions - - private func toggleFavorite() async { - isTogglingFavorite = true - defer { isTogglingFavorite = false } + // MARK: - Sheet Management - do { - try await appState.services?.contactService.setContactFavorite( - currentContact.id, - isFavorite: !currentContact.isFavorite - ) - await refreshContact() - } catch { - errorMessage = error.userFacingMessage - } + private func presentPendingSheet() { + if let next = pendingSheet { + pendingSheet = nil + activeSheet = next } - - private func toggleBlocked() async { - do { - try await appState.services?.contactService.updateContactPreferences( - contactID: currentContact.id, - isBlocked: !currentContact.isBlocked - ) - await refreshContact() - } catch { - errorMessage = error.userFacingMessage - } + } + + // MARK: - Actions + + private func toggleFavorite() async { + isTogglingFavorite = true + defer { isTogglingFavorite = false } + + do { + try await appState.services?.contactService.setContactFavorite( + currentContact.id, + isFavorite: !currentContact.isFavorite + ) + await refreshContact() + } catch { + errorMessage = error.userFacingMessage } - - private func deleteContact() async { - do { - try await appState.services?.contactService.removeContact( - radioID: currentContact.radioID, - publicKey: currentContact.publicKey - ) - dismiss() - } catch { - errorMessage = error.userFacingMessage - } + } + + private func toggleBlocked() async { + do { + try await appState.services?.contactService.updateContactPreferences( + contactID: currentContact.id, + isBlocked: !currentContact.isBlocked + ) + await refreshContact() + } catch { + errorMessage = error.userFacingMessage } - - private func clearMessages() async { - guard let contactService = appState.services?.contactService else { - errorMessage = L10n.Contacts.Contacts.Detail.Error.servicesUnavailable - return - } - - isClearingMessages = true - errorMessage = nil - - do { - try await contactService.clearContactMessages(contactID: currentContact.id) - await appState.services?.notificationService.removeDeliveredNotifications(forContactID: currentContact.id) - await appState.services?.notificationService.updateBadgeCount() - onClearMessages() - dismiss() - } catch { - errorMessage = error.userFacingMessage - isClearingMessages = false - } + } + + private func deleteContact() async { + do { + try await appState.services?.contactService.removeContact( + radioID: currentContact.radioID, + publicKey: currentContact.publicKey + ) + dismiss() + } catch { + errorMessage = error.userFacingMessage } + } - private func shareContact() async { - isSharing = true - do { - try await appState.services?.contactService.shareContact(publicKey: currentContact.publicKey) - isSharing = false - withAnimation { showShareSuccess = true } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { showShareSuccess = false } - } catch ContactServiceError.shareContactUnavailable { - isSharing = false - errorMessage = L10n.Contacts.Contacts.Detail.shareContactUnavailable - } catch { - isSharing = false - errorMessage = error.userFacingMessage - } + private func clearMessages() async { + guard let contactService = appState.services?.contactService else { + errorMessage = L10n.Contacts.Contacts.Detail.Error.servicesUnavailable + return } - private func pingRepeater() async { - guard !isPinging else { return } - isPinging = true - pingResult = nil - pingResult = await PingHelper.zeroHopPing(contact: currentContact, appState: appState) - isPinging = false + isClearingMessages = true + errorMessage = nil + + do { + try await contactService.clearContactMessages(contactID: currentContact.id) + await appState.services?.notificationService.removeDeliveredNotifications(forContactID: currentContact.id) + await appState.services?.notificationService.updateBadgeCount() + onClearMessages() + dismiss() + } catch { + errorMessage = error.userFacingMessage + isClearingMessages = false } - - private func refreshContact() async { - if let updated = try? await appState.services?.dataStore.fetchContact(id: currentContact.id) { - currentContact = updated - } + } + + private func shareContact() async { + isSharing = true + do { + try await appState.services?.contactService.shareContact(publicKey: currentContact.publicKey) + isSharing = false + withAnimation { showShareSuccess = true } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { showShareSuccess = false } + } catch ContactServiceError.shareContactUnavailable { + isSharing = false + errorMessage = L10n.Contacts.Contacts.Detail.shareContactUnavailable + } catch { + isSharing = false + errorMessage = error.userFacingMessage } - - // MARK: - Helpers - - private var contactTypeLabel: String { - currentContact.type.localizedName + } + + private func pingRepeater() async { + guard !isPinging else { return } + isPinging = true + pingResult = nil + pingResult = await PingHelper.zeroHopPing(contact: currentContact, appState: appState) + isPinging = false + } + + private func refreshContact() async { + if let updated = try? await appState.services?.dataStore.fetchContact(id: currentContact.id) { + currentContact = updated } - - private func saveNickname() async { - isSaving = true - do { - try await appState.services?.contactService.updateContactPreferences( - contactID: currentContact.id, - nickname: nickname.isEmpty ? nil : nickname - ) - await refreshContact() - } catch { - errorMessage = error.userFacingMessage - } - isEditingNickname = false - isSaving = false + } + + // MARK: - Helpers + + private var contactTypeLabel: String { + currentContact.type.localizedName + } + + private func saveNickname() async { + isSaving = true + do { + try await appState.services?.contactService.updateContactPreferences( + contactID: currentContact.id, + nickname: nickname.isEmpty ? nil : nickname + ) + await refreshContact() + } catch { + errorMessage = error.userFacingMessage } + isEditingNickname = false + isSaving = false + } } // MARK: - Extracted Views private struct ContactDetailAvatarView: View { - let contact: ContactDTO - - var body: some View { - switch contact.type { - case .chat: - ContactAvatar(contact: contact, size: 100) - case .repeater: - NodeAvatar(publicKey: contact.publicKey, role: .repeater, size: 100) - case .room: - NodeAvatar(publicKey: contact.publicKey, role: .roomServer, size: 100) - } + let contact: ContactDTO + + var body: some View { + switch contact.type { + case .chat: + ContactAvatar(contact: contact, size: 100) + case .repeater: + NodeAvatar(publicKey: contact.publicKey, role: .repeater, size: 100) + case .room: + NodeAvatar(publicKey: contact.publicKey, role: .roomServer, size: 100) } + } } private struct ContactProfileSection: View { - let currentContact: ContactDTO - let contactTypeLabel: String - - var body: some View { - Section { - VStack(spacing: 16) { - ContactDetailAvatarView(contact: currentContact) - - VStack(spacing: 4) { - Text(currentContact.displayName) - .font(.title2) - .bold() - - Text(contactTypeLabel) - .font(.subheadline) - .foregroundStyle(.secondary) - - // Status indicators - HStack(spacing: 12) { - if currentContact.isFavorite { - Label(L10n.Contacts.Contacts.Detail.favorite, systemImage: "star.fill") - .font(.caption) - .foregroundStyle(.yellow) - } - - if currentContact.isBlocked { - Label(L10n.Contacts.Contacts.Detail.blocked, systemImage: "hand.raised.fill") - .font(.caption) - .foregroundStyle(.orange) - } - - if currentContact.hasLocation { - Label(L10n.Contacts.Contacts.Detail.hasLocation, systemImage: "location.fill") - .font(.caption) - .foregroundStyle(.green) - } - } - } + let currentContact: ContactDTO + let contactTypeLabel: String + + var body: some View { + Section { + VStack(spacing: 16) { + ContactDetailAvatarView(contact: currentContact) + + VStack(spacing: 4) { + Text(currentContact.displayName) + .font(.title2) + .bold() + + Text(contactTypeLabel) + .font(.subheadline) + .foregroundStyle(.secondary) + + // Status indicators + HStack(spacing: 12) { + if currentContact.isFavorite { + Label(L10n.Contacts.Contacts.Detail.favorite, systemImage: "star.fill") + .font(.caption) + .foregroundStyle(.yellow) + } + + if currentContact.isBlocked { + Label(L10n.Contacts.Contacts.Detail.blocked, systemImage: "hand.raised.fill") + .font(.caption) + .foregroundStyle(.orange) + } + + if currentContact.hasLocation { + Label(L10n.Contacts.Contacts.Detail.hasLocation, systemImage: "location.fill") + .font(.caption) + .foregroundStyle(.green) } - .frame(maxWidth: .infinity) - .listRowBackground(Color.clear) + } } + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) } + } } private struct ContactActionsSection: View { - @Environment(\.appState) private var appState - - let currentContact: ContactDTO - let showFromDirectChat: Bool - let isPinging: Bool - let isTogglingFavorite: Bool - let pingResult: PingResult? - let onJoinRoom: () -> Void - let onShowTelemetry: () -> Void - let onShowAdminAccess: () -> Void - let onPingRepeater: () -> Void - let onToggleFavorite: () -> Void - let onShareQR: () -> Void - let onShareViaAdvert: () -> Void - let isSharing: Bool - let showShareSuccess: Bool - - var body: some View { - Section { - // Role-specific actions based on contact type - switch currentContact.type { - case .room: - Button(action: onJoinRoom) { - Label(L10n.Contacts.Contacts.Detail.joinRoom, systemImage: "door.left.hand.open") - } - .radioDisabled(for: appState.connectionState) - - NodeActionRows( - contact: currentContact, - pingLabel: L10n.Contacts.Contacts.Detail.ping, - isPinging: isPinging, - pingResult: pingResult, - connectionState: appState.connectionState, - onShowTelemetry: onShowTelemetry, - onShowAdminAccess: onShowAdminAccess, - onPing: onPingRepeater - ) - - case .repeater: - NodeActionRows( - contact: currentContact, - pingLabel: L10n.Contacts.Contacts.Detail.ping, - isPinging: isPinging, - pingResult: pingResult, - connectionState: appState.connectionState, - onShowTelemetry: onShowTelemetry, - onShowAdminAccess: onShowAdminAccess, - onPing: onPingRepeater - ) - - case .chat: - // Send message - only show when NOT from direct chat and NOT blocked - if !showFromDirectChat && !currentContact.isBlocked { - Button { - appState.navigation.navigateToChat(with: currentContact) - } label: { - Label(L10n.Contacts.Contacts.Detail.sendMessage, systemImage: "message.fill") - } - .radioDisabled(for: appState.connectionState) - } - - Button(action: onShowTelemetry) { - Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") - } - .radioDisabled(for: appState.connectionState) - - NavigationLink(value: ContactRoute.TelemetryHistory( - publicKey: currentContact.publicKey, - radioID: currentContact.radioID, - showNeighbors: false - )) { - Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") - .foregroundStyle(.tint) - } - } - - // Toggle favorite (for all contact types) - Button(action: onToggleFavorite) { - HStack { - Label( - currentContact.isFavorite ? L10n.Contacts.Contacts.Detail.removeFromFavorites : L10n.Contacts.Contacts.Detail.addToFavorites, - systemImage: currentContact.isFavorite ? "star.slash" : "star" - ) - if isTogglingFavorite { - Spacer() - ProgressView() - } - } - } - .disabled(isTogglingFavorite) - .radioDisabled(for: appState.connectionState) + @Environment(\.appState) private var appState + + let currentContact: ContactDTO + let showFromDirectChat: Bool + let isPinging: Bool + let isTogglingFavorite: Bool + let pingResult: PingResult? + let onJoinRoom: () -> Void + let onShowTelemetry: () -> Void + let onShowAdminAccess: () -> Void + let onPingRepeater: () -> Void + let onToggleFavorite: () -> Void + let onShareQR: () -> Void + let onShareViaAdvert: () -> Void + let isSharing: Bool + let showShareSuccess: Bool + + var body: some View { + Section { + // Role-specific actions based on contact type + switch currentContact.type { + case .room: + Button(action: onJoinRoom) { + Label(L10n.Contacts.Contacts.Detail.joinRoom, systemImage: "door.left.hand.open") + } + .radioDisabled(for: appState.connectionState) + + NodeActionRows( + contact: currentContact, + pingLabel: L10n.Contacts.Contacts.Detail.ping, + isPinging: isPinging, + pingResult: pingResult, + connectionState: appState.connectionState, + onShowTelemetry: onShowTelemetry, + onShowAdminAccess: onShowAdminAccess, + onPing: onPingRepeater + ) - // Share Contact via QR - Button(action: onShareQR) { - Label(L10n.Contacts.Contacts.Detail.shareContact, systemImage: "square.and.arrow.up") - } + case .repeater: + NodeActionRows( + contact: currentContact, + pingLabel: L10n.Contacts.Contacts.Detail.ping, + isPinging: isPinging, + pingResult: pingResult, + connectionState: appState.connectionState, + onShowTelemetry: onShowTelemetry, + onShowAdminAccess: onShowAdminAccess, + onPing: onPingRepeater + ) - // Share Contact via Advert - Button(action: onShareViaAdvert) { - if isSharing || showShareSuccess { - AsyncActionLabel(isLoading: isSharing, showSuccess: showShareSuccess) { - EmptyView() - } - } else { - Label(L10n.Contacts.Contacts.Detail.shareViaAdvert, systemImage: "antenna.radiowaves.left.and.right") - } - } - .radioDisabled(for: appState.connectionState, or: isSharing || showShareSuccess) + case .chat: + // Send message - only show when NOT from direct chat and NOT blocked + if !showFromDirectChat, !currentContact.isBlocked { + Button { + appState.navigation.navigateToChat(with: currentContact) + } label: { + Label(L10n.Contacts.Contacts.Detail.sendMessage, systemImage: "message.fill") + } + .radioDisabled(for: appState.connectionState) } - } -} -private struct NodeActionRows: View { - let contact: ContactDTO - let pingLabel: String - let isPinging: Bool - let pingResult: PingResult? - let connectionState: DeviceConnectionState - let onShowTelemetry: () -> Void - let onShowAdminAccess: () -> Void - let onPing: () -> Void - - var body: some View { Button(action: onShowTelemetry) { - Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") + Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") } - .radioDisabled(for: connectionState) + .radioDisabled(for: appState.connectionState) NavigationLink(value: ContactRoute.TelemetryHistory( - publicKey: contact.publicKey, - radioID: contact.radioID + publicKey: currentContact.publicKey, + radioID: currentContact.radioID, + showNeighbors: false )) { - Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") - .foregroundStyle(.tint) + Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .foregroundStyle(.tint) } - - Button(action: onShowAdminAccess) { - Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") + } + + // Toggle favorite (for all contact types) + Button(action: onToggleFavorite) { + HStack { + Label( + currentContact.isFavorite ? L10n.Contacts.Contacts.Detail.removeFromFavorites : L10n.Contacts.Contacts.Detail.addToFavorites, + systemImage: currentContact.isFavorite ? "star.slash" : "star" + ) + if isTogglingFavorite { + Spacer() + ProgressView() + } } - .radioDisabled(for: connectionState) - - Button(action: onPing) { - HStack { - Label(pingLabel, systemImage: "wave.3.right") - if isPinging { - Spacer() - ProgressView() - } - } + } + .disabled(isTogglingFavorite) + .radioDisabled(for: appState.connectionState) + + // Share Contact via QR + Button(action: onShareQR) { + Label(L10n.Contacts.Contacts.Detail.shareContact, systemImage: "square.and.arrow.up") + } + + // Share Contact via Advert + Button(action: onShareViaAdvert) { + if isSharing || showShareSuccess { + AsyncActionLabel(isLoading: isSharing, showSuccess: showShareSuccess) { + EmptyView() + } + } else { + Label(L10n.Contacts.Contacts.Detail.shareViaAdvert, systemImage: "antenna.radiowaves.left.and.right") } - .disabled(isPinging) - .radioDisabled(for: connectionState) + } + .radioDisabled(for: appState.connectionState, or: isSharing || showShareSuccess) + } + } +} - if let result = pingResult { - PingResultRow(result: result) +private struct NodeActionRows: View { + let contact: ContactDTO + let pingLabel: String + let isPinging: Bool + let pingResult: PingResult? + let connectionState: DeviceConnectionState + let onShowTelemetry: () -> Void + let onShowAdminAccess: () -> Void + let onPing: () -> Void + + var body: some View { + Button(action: onShowTelemetry) { + Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + .radioDisabled(for: connectionState) + + NavigationLink(value: ContactRoute.TelemetryHistory( + publicKey: contact.publicKey, + radioID: contact.radioID + )) { + Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .foregroundStyle(.tint) + } + + Button(action: onShowAdminAccess) { + Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") + } + .radioDisabled(for: connectionState) + + Button(action: onPing) { + HStack { + Label(pingLabel, systemImage: "wave.3.right") + if isPinging { + Spacer() + ProgressView() } + } } + .disabled(isPinging) + .radioDisabled(for: connectionState) + + if let result = pingResult { + PingResultRow(result: result) + } + } } private struct ContactInfoSection: View { - let currentContact: ContactDTO - @Binding var nickname: String - @Binding var isEditingNickname: Bool - let isSaving: Bool - let onSaveNickname: () -> Void - - var body: some View { - Section { - // Nickname - HStack { - Text(L10n.Contacts.Contacts.Detail.nickname) - - Spacer() - - if isEditingNickname { - TextField(L10n.Contacts.Contacts.Detail.nickname, text: $nickname) - .textFieldStyle(.roundedBorder) - .frame(width: 150) - .onSubmit { - onSaveNickname() - } - - Button(L10n.Contacts.Contacts.Common.save) { - onSaveNickname() - } - .disabled(isSaving) - } else { - Text(currentContact.nickname ?? L10n.Contacts.Contacts.Detail.nicknameNone) - .foregroundStyle(.secondary) - - Button(L10n.Contacts.Contacts.Common.edit) { - isEditingNickname = true - } - .buttonStyle(.borderless) - } - } - - // Original name - HStack { - Text(L10n.Contacts.Contacts.Detail.name) - Spacer() - Text(currentContact.name) - .foregroundStyle(.secondary) + let currentContact: ContactDTO + @Binding var nickname: String + @Binding var isEditingNickname: Bool + let isSaving: Bool + let onSaveNickname: () -> Void + + var body: some View { + Section { + // Nickname + HStack { + Text(L10n.Contacts.Contacts.Detail.nickname) + + Spacer() + + if isEditingNickname { + TextField(L10n.Contacts.Contacts.Detail.nickname, text: $nickname) + .textFieldStyle(.roundedBorder) + .frame(width: 150) + .onSubmit { + onSaveNickname() } - // Last advert - if currentContact.lastAdvertTimestamp > 0 { - HStack { - Text(L10n.Contacts.Contacts.Detail.lastAdvert) - Spacer() - ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(currentContact.lastAdvertTimestamp)), font: .body) - } - } + Button(L10n.Contacts.Contacts.Common.save) { + onSaveNickname() + } + .disabled(isSaving) + } else { + Text(currentContact.nickname ?? L10n.Contacts.Contacts.Detail.nicknameNone) + .foregroundStyle(.secondary) - // Unread count - if currentContact.unreadCount > 0 { - HStack { - Text(L10n.Contacts.Contacts.Detail.unreadMessages) - Spacer() - Text(currentContact.unreadCount, format: .number) - .foregroundStyle(.blue) - } - } - } header: { - Text(L10n.Contacts.Contacts.Detail.info) + Button(L10n.Contacts.Contacts.Common.edit) { + isEditingNickname = true + } + .buttonStyle(.borderless) + } + } + + // Original name + HStack { + Text(L10n.Contacts.Contacts.Detail.name) + Spacer() + Text(currentContact.name) + .foregroundStyle(.secondary) + } + + // Last advert + if currentContact.lastAdvertTimestamp > 0 { + HStack { + Text(L10n.Contacts.Contacts.Detail.lastAdvert) + Spacer() + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(currentContact.lastAdvertTimestamp)), font: .body) + } + } + + // Unread count + if currentContact.unreadCount > 0 { + HStack { + Text(L10n.Contacts.Contacts.Detail.unreadMessages) + Spacer() + Text(currentContact.unreadCount, format: .number) + .foregroundStyle(.blue) } + } + } header: { + Text(L10n.Contacts.Contacts.Detail.info) } + } } private struct ContactLocationSection: View { - @Environment(\.appState) private var appState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.appTheme) private var theme - - let currentContact: ContactDTO - - @State private var showFullMap = false - - var body: some View { - Section { - // Mini map - ZStack(alignment: .topTrailing) { - MC1MapView( - points: [MapPoint( - id: currentContact.id, - coordinate: currentContact.coordinate, - pinStyle: currentContact.type.pinStyle, - label: currentContact.displayName, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )], - lines: [], - mapStyle: .standard, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: false, - showsUserLocation: false, - isInteractive: false, - showsScale: false, - cameraRegion: .constant(MKCoordinateRegion( - center: currentContact.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - )), - cameraRegionVersion: currentContact.latitude.hashValue ^ currentContact.longitude.hashValue, - onPointTap: { _, _ in showFullMap = true }, - onMapTap: { _ in showFullMap = true }, - onCameraRegionChange: nil - ) - - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.caption.weight(.semibold)) - .padding(6) - .background(.regularMaterial, in: .rect(cornerRadius: 6)) - .padding(8) - .allowsHitTesting(false) - .accessibilityHidden(true) - } - .frame(height: 200) - .clipShape(.rect(cornerRadius: 12)) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .padding(.bottom, 8) - .listRowSeparator(.hidden) - .sheet(isPresented: $showFullMap) { - ContactFullMapView(contact: currentContact) - } + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.appTheme) private var theme + + let currentContact: ContactDTO + + @State private var showFullMap = false + + var body: some View { + Section { + // Mini map + ZStack(alignment: .topTrailing) { + MC1MapView( + points: [MapPoint( + id: currentContact.id, + coordinate: currentContact.coordinate, + pinStyle: currentContact.type.pinStyle, + label: currentContact.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: false, + showsUserLocation: false, + isInteractive: false, + showsScale: false, + cameraRegion: .constant(MKCoordinateRegion( + center: currentContact.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + )), + cameraRegionVersion: currentContact.latitude.hashValue ^ currentContact.longitude.hashValue, + onPointTap: { _, _ in showFullMap = true }, + onMapTap: { _ in showFullMap = true }, + onCameraRegionChange: nil + ) - // Coordinates - HStack { - Text(L10n.Contacts.Contacts.Detail.coordinates) - Spacer() - Text("\(currentContact.latitude, format: .number.precision(.fractionLength(4))), \(currentContact.longitude, format: .number.precision(.fractionLength(4)))") - .foregroundStyle(.secondary) - } - .listRowBackground( - UnevenRoundedRectangle(topLeadingRadius: 10, topTrailingRadius: 10) - .fill(theme.surfaces?.card ?? Color(.secondarySystemGroupedBackground)) - ) - - // Open in Maps - Button { - openInMaps() - } label: { - Label(L10n.Contacts.Contacts.Detail.openInMaps, systemImage: "map") - } - } header: { - Text(L10n.Contacts.Contacts.Detail.location) - } + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.caption.weight(.semibold)) + .padding(6) + .background(.regularMaterial, in: .rect(cornerRadius: 6)) + .padding(8) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + .frame(height: 200) + .clipShape(.rect(cornerRadius: 12)) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .padding(.bottom, 8) + .listRowSeparator(.hidden) + .sheet(isPresented: $showFullMap) { + ContactFullMapView(contact: currentContact) + } + + // Coordinates + HStack { + Text(L10n.Contacts.Contacts.Detail.coordinates) + Spacer() + Text("\(currentContact.latitude, format: .number.precision(.fractionLength(4))), \(currentContact.longitude, format: .number.precision(.fractionLength(4)))") + .foregroundStyle(.secondary) + } + .listRowBackground( + UnevenRoundedRectangle(topLeadingRadius: 10, topTrailingRadius: 10) + .fill(theme.surfaces?.card ?? Color(.secondarySystemGroupedBackground)) + ) + + // Open in Maps + Button { + openInMaps() + } label: { + Label(L10n.Contacts.Contacts.Detail.openInMaps, systemImage: "map") + } + } header: { + Text(L10n.Contacts.Contacts.Detail.location) } + } - private func openInMaps() { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: currentContact.coordinate)) - mapItem.name = currentContact.displayName - mapItem.openInMaps() - } + private func openInMaps() { + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: currentContact.coordinate)) + mapItem.name = currentContact.displayName + mapItem.openInMaps() + } } private struct ContactFullMapView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme - let contact: ContactDTO + let contact: ContactDTO - @State private var cameraRegion: MKCoordinateRegion? - @State private var cameraRegionVersion = 0 + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 - var body: some View { - NavigationStack { - MC1MapView( - points: [MapPoint( - id: contact.id, - coordinate: contact.coordinate, - pinStyle: contact.type.pinStyle, - label: contact.displayName, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )], - lines: [], - mapStyle: .standard, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: true, - showsUserLocation: true, - isInteractive: true, - showsScale: true, - cameraRegion: $cameraRegion, - cameraRegionVersion: cameraRegionVersion, - onPointTap: nil, - onMapTap: nil, - onCameraRegionChange: { cameraRegion = $0 } - ) - .ignoresSafeArea() - .navigationTitle(contact.displayName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Localizable.Common.done) { dismiss() } - } - } - .onAppear { - cameraRegion = MKCoordinateRegion( - center: contact.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - ) - cameraRegionVersion = 1 - } + var body: some View { + NavigationStack { + MC1MapView( + points: [MapPoint( + id: contact.id, + coordinate: contact.coordinate, + pinStyle: contact.type.pinStyle, + label: contact.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: true, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { cameraRegion = $0 } + ) + .ignoresSafeArea() + .navigationTitle(contact.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Localizable.Common.done) { dismiss() } } + } + .onAppear { + cameraRegion = MKCoordinateRegion( + center: contact.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + ) + cameraRegionVersion = 1 + } } + } } private struct ContactNetworkPathSection: View { - @Environment(\.appState) private var appState - - let currentContact: ContactDTO - let pathViewModel: PathManagementViewModel - - private var pathDisplayWithNames: String { - let pathData = currentContact.outPath - let byteLength = currentContact.pathByteLength - let hashSize = currentContact.pathHashSize - guard byteLength > 0 else { return L10n.Contacts.Contacts.Route.direct } - - let relevantPath = pathData.prefix(byteLength) - return stride(from: 0, to: relevantPath.count, by: hashSize).map { start in - let end = min(start + hashSize, relevantPath.count) - let hopBytes = Data(relevantPath[start.. 0 else { return L10n.Contacts.Contacts.Route.direct } + + let relevantPath = pathData.prefix(byteLength) + return stride(from: 0, to: relevantPath.count, by: hashSize).map { start in + let end = min(start + hashSize, relevantPath.count) + let hopBytes = Data(relevantPath[start.. String { + if currentContact.isFloodRouted { + L10n.Contacts.Contacts.Route.flood + } else if currentContact.pathHopCount == 0 { + L10n.Contacts.Contacts.Route.direct + } else { + pathDisplay } + } - private func routeDisplayText(pathDisplay: String) -> String { - if currentContact.isFloodRouted { - return L10n.Contacts.Contacts.Route.flood - } else if currentContact.pathHopCount == 0 { - return L10n.Contacts.Contacts.Route.direct - } else { - return pathDisplay - } + private var networkPathFooterText: String { + if currentContact.isFloodRouted { + L10n.Contacts.Contacts.Detail.floodFooter + } else { + L10n.Contacts.Contacts.Detail.pathFooter } - - private var networkPathFooterText: String { - if currentContact.isFloodRouted { - return L10n.Contacts.Contacts.Detail.floodFooter - } else { - return L10n.Contacts.Contacts.Detail.pathFooter - } + } + + private func pathAccessibilityLabel(pathDisplay: String) -> String { + if currentContact.isFloodRouted { + L10n.Contacts.Contacts.Detail.routeFlood + } else if currentContact.pathHopCount == 0 { + L10n.Contacts.Contacts.Detail.routeDirect + } else { + L10n.Contacts.Contacts.Detail.routePrefix(pathDisplay) } - - private func pathAccessibilityLabel(pathDisplay: String) -> String { - if currentContact.isFloodRouted { - return L10n.Contacts.Contacts.Detail.routeFlood - } else if currentContact.pathHopCount == 0 { - return L10n.Contacts.Contacts.Detail.routeDirect - } else { - return L10n.Contacts.Contacts.Detail.routePrefix(pathDisplay) + } + + var body: some View { + let pathDisplay = pathDisplayWithNames + let isRoutePopulated = !currentContact.isFloodRouted && currentContact.pathHopCount > 0 + let routeIDPrefixes = currentContact.pathNodesHex.joined(separator: ",") + return Section { + // Current routing path + Label { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Contacts.Contacts.Detail.route) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(routeDisplayText(pathDisplay: pathDisplay)) + .font(.caption.monospaced()) + .foregroundStyle(.primary) } - } - - var body: some View { - let pathDisplay = pathDisplayWithNames - let isRoutePopulated = !currentContact.isFloodRouted && currentContact.pathHopCount > 0 - let routeIDPrefixes = currentContact.pathNodesHex.joined(separator: ",") - return Section { - // Current routing path - Label { - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Contacts.Contacts.Detail.route) - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(routeDisplayText(pathDisplay: pathDisplay)) - .font(.caption.monospaced()) - .foregroundStyle(.primary) - } - } icon: { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - .foregroundStyle(.secondary) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(pathAccessibilityLabel(pathDisplay: pathDisplay)) - .copyRouteContextMenu(route: routeIDPrefixes, enabled: isRoutePopulated) - .accessibilityAction(named: L10n.Contacts.Contacts.Detail.copyRoute) { - if isRoutePopulated { UIPasteboard.general.string = routeIDPrefixes } - } - - // Hops away: only when a deliberate or discovered out-path exists, not the passively - // heard inbound advert hops - if !currentContact.isFloodRouted { - Label { - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Contacts.Contacts.Detail.hopsAway) - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(currentContact.pathHopCount, format: .number) - .font(.caption.monospaced()) - .foregroundStyle(.primary) - } - } icon: { - Image(systemName: "arrowshape.bounce.right") - .foregroundStyle(.secondary) - } - } - - // Path Discovery button (prominent) - if pathViewModel.isDiscovering { - VStack(alignment: .leading, spacing: 4) { - HStack { - Label(L10n.Contacts.Contacts.Detail.discoveringPath, systemImage: "antenna.radiowaves.left.and.right") - Spacer() - ProgressView() - Button(L10n.Contacts.Contacts.Common.cancel) { - pathViewModel.cancelDiscovery() - } - .buttonStyle(.borderless) - .font(.subheadline) - } - - if let remaining = pathViewModel.discoverySecondsRemaining, remaining > 0 { - Text(L10n.Contacts.Contacts.Detail.secondsRemaining(remaining)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } else { - Button { - Task { - await pathViewModel.discoverPath(for: currentContact) - } - } label: { - Label(L10n.Contacts.Contacts.Detail.discoverPath, systemImage: "antenna.radiowaves.left.and.right") - } - .radioDisabled(for: appState.connectionState) - } - - // Edit Path button (secondary) - Button { - Task { - await pathViewModel.loadContacts(radioID: currentContact.radioID) - pathViewModel.initializeEditablePath(from: currentContact) - pathViewModel.showingPathEditor = true - } - } label: { - Label(L10n.Contacts.Contacts.Detail.editPath, systemImage: "pencil") - } - .radioDisabled(for: appState.connectionState) - - // Reset Path button (destructive, disabled when already flood) - Button(role: .destructive) { - Task { - await pathViewModel.resetPath(for: currentContact) - } - } label: { - HStack { - Label(L10n.Contacts.Contacts.Detail.resetPath, systemImage: "arrow.triangle.2.circlepath") - if pathViewModel.isSettingPath { - Spacer() - ProgressView() - .scaleEffect(0.8) - } - } + } icon: { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(pathAccessibilityLabel(pathDisplay: pathDisplay)) + .copyRouteContextMenu(route: routeIDPrefixes, enabled: isRoutePopulated) + .accessibilityAction(named: L10n.Contacts.Contacts.Detail.copyRoute) { + if isRoutePopulated { UIPasteboard.general.string = routeIDPrefixes } + } + + // Hops away: only when a deliberate or discovered out-path exists, not the passively + // heard inbound advert hops + if !currentContact.isFloodRouted { + Label { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Contacts.Contacts.Detail.hopsAway) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(currentContact.pathHopCount, format: .number) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + } + } icon: { + Image(systemName: "arrowshape.bounce.right") + .foregroundStyle(.secondary) + } + } + + // Path Discovery button (prominent) + if pathViewModel.isDiscovering { + VStack(alignment: .leading, spacing: 4) { + HStack { + Label(L10n.Contacts.Contacts.Detail.discoveringPath, systemImage: "antenna.radiowaves.left.and.right") + Spacer() + ProgressView() + Button(L10n.Contacts.Contacts.Common.cancel) { + pathViewModel.cancelDiscovery() } - .radioDisabled(for: appState.connectionState, or: pathViewModel.isSettingPath || currentContact.isFloodRouted) - } header: { - Text(L10n.Contacts.Contacts.Detail.outboundPath) - } footer: { - Text(networkPathFooterText) + .buttonStyle(.borderless) + .font(.subheadline) + } + + if let remaining = pathViewModel.discoverySecondsRemaining, remaining > 0 { + Text(L10n.Contacts.Contacts.Detail.secondsRemaining(remaining)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } else { + Button { + Task { + await pathViewModel.discoverPath(for: currentContact) + } + } label: { + Label(L10n.Contacts.Contacts.Detail.discoverPath, systemImage: "antenna.radiowaves.left.and.right") + } + .radioDisabled(for: appState.connectionState) + } + + // Edit Path button (secondary) + Button { + Task { + await pathViewModel.loadContacts(radioID: currentContact.radioID) + pathViewModel.initializeEditablePath(from: currentContact) + pathViewModel.showingPathEditor = true + } + } label: { + Label(L10n.Contacts.Contacts.Detail.editPath, systemImage: "pencil") + } + .radioDisabled(for: appState.connectionState) + + // Reset Path button (destructive, disabled when already flood) + Button(role: .destructive) { + Task { + await pathViewModel.resetPath(for: currentContact) + } + } label: { + HStack { + Label(L10n.Contacts.Contacts.Detail.resetPath, systemImage: "arrow.triangle.2.circlepath") + if pathViewModel.isSettingPath { + Spacer() + ProgressView() + .scaleEffect(0.8) + } } + } + .radioDisabled(for: appState.connectionState, or: pathViewModel.isSettingPath || currentContact.isFloodRouted) + } header: { + Text(L10n.Contacts.Contacts.Detail.outboundPath) + } footer: { + Text(networkPathFooterText) } + } } private struct ContactTechnicalSection: View { - let currentContact: ContactDTO - let contactTypeLabel: String - - var body: some View { - Section { - // Public key - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Contacts.Contacts.Detail.publicKey) - .font(.caption) - .foregroundStyle(.secondary) - Text(currentContact.publicKey.uppercaseHexString(separator: " ")) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - - // Contact type - HStack { - Text(L10n.Contacts.Contacts.Detail.type) - Spacer() - Text(contactTypeLabel) - .foregroundStyle(.secondary) - } - } header: { - Text(L10n.Contacts.Contacts.Detail.technical) - } + let currentContact: ContactDTO + let contactTypeLabel: String + + var body: some View { + Section { + // Public key + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Contacts.Contacts.Detail.publicKey) + .font(.caption) + .foregroundStyle(.secondary) + Text(currentContact.publicKey.uppercaseHexString(separator: " ")) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + + // Contact type + HStack { + Text(L10n.Contacts.Contacts.Detail.type) + Spacer() + Text(contactTypeLabel) + .foregroundStyle(.secondary) + } + } header: { + Text(L10n.Contacts.Contacts.Detail.technical) } + } } private struct ContactDangerSection: View { - @Environment(\.appState) private var appState - - let currentContact: ContactDTO - let contactTypeLabel: String - let isClearingMessages: Bool - let onClearMessages: () -> Void - let onToggleBlock: () -> Void - let onDelete: () -> Void - - var body: some View { - Section { - if currentContact.type == .chat { - Button(role: .destructive, action: onClearMessages) { - HStack { - Label(L10n.Contacts.Contacts.Detail.clearMessages, systemImage: "xmark.circle") - if isClearingMessages { - Spacer() - ProgressView() - } - } - } - .disabled(isClearingMessages) - - Button(action: onToggleBlock) { - Label( - currentContact.isBlocked ? L10n.Contacts.Contacts.Detail.unblockContact : L10n.Contacts.Contacts.Detail.blockContact, - systemImage: currentContact.isBlocked ? "hand.raised.slash" : "hand.raised" - ) - } - .radioDisabled(for: appState.connectionState) + @Environment(\.appState) private var appState + + let currentContact: ContactDTO + let contactTypeLabel: String + let isClearingMessages: Bool + let onClearMessages: () -> Void + let onToggleBlock: () -> Void + let onDelete: () -> Void + + var body: some View { + Section { + if currentContact.type == .chat { + Button(role: .destructive, action: onClearMessages) { + HStack { + Label(L10n.Contacts.Contacts.Detail.clearMessages, systemImage: "xmark.circle") + if isClearingMessages { + Spacer() + ProgressView() } + } + } + .disabled(isClearingMessages) - Button(role: .destructive, action: onDelete) { - Label(L10n.Contacts.Contacts.Detail.deleteType(contactTypeLabel), systemImage: "trash") - } - .radioDisabled(for: appState.connectionState) - } header: { - Text(L10n.Contacts.Contacts.Detail.dangerZone) + Button(action: onToggleBlock) { + Label( + currentContact.isBlocked ? L10n.Contacts.Contacts.Detail.unblockContact : L10n.Contacts.Contacts.Detail.blockContact, + systemImage: currentContact.isBlocked ? "hand.raised.slash" : "hand.raised" + ) } + .radioDisabled(for: appState.connectionState) + } + + Button(role: .destructive, action: onDelete) { + Label(L10n.Contacts.Contacts.Detail.deleteType(contactTypeLabel), systemImage: "trash") + } + .radioDisabled(for: appState.connectionState) + } header: { + Text(L10n.Contacts.Contacts.Detail.dangerZone) } + } } #Preview("Default") { - NavigationStack { - ContactDetailView(contact: ContactDTO(from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Alice", - latitude: 37.7749, - longitude: -122.4194, - isFavorite: true - ))) - } - .environment(\.appState, AppState()) + NavigationStack { + ContactDetailView(contact: ContactDTO(from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Alice", + latitude: 37.7749, + longitude: -122.4194, + isFavorite: true + ))) + } + .environment(\.appState, AppState()) } #Preview("From Direct Chat") { - NavigationStack { - ContactDetailView( - contact: ContactDTO(from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Alice", - latitude: 37.7749, - longitude: -122.4194, - isFavorite: true - )), - showFromDirectChat: true - ) - } - .environment(\.appState, AppState()) + NavigationStack { + ContactDetailView( + contact: ContactDTO(from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Alice", + latitude: 37.7749, + longitude: -122.4194, + isFavorite: true + )), + showFromDirectChat: true + ) + } + .environment(\.appState, AppState()) } private extension View { - /// Gates the whole `contextMenu`, not the button inside it: an always-present - /// menu with an empty body still triggers the press-and-hold lift with no items. - @ViewBuilder - func copyRouteContextMenu(route: String, enabled: Bool) -> some View { - if enabled { - contextMenu { - Button { - UIPasteboard.general.string = route - } label: { - Label(L10n.Contacts.Contacts.Detail.copyRoute, systemImage: "doc.on.doc") - } - } - } else { - self + /// Gates the whole `contextMenu`, not the button inside it: an always-present + /// menu with an empty body still triggers the press-and-hold lift with no items. + @ViewBuilder + func copyRouteContextMenu(route: String, enabled: Bool) -> some View { + if enabled { + contextMenu { + Button { + UIPasteboard.general.string = route + } label: { + Label(L10n.Contacts.Contacts.Detail.copyRoute, systemImage: "doc.on.doc") } + } + } else { + self } + } } diff --git a/MC1/Views/Contacts/ContactListActions.swift b/MC1/Views/Contacts/ContactListActions.swift index ca832b74..3795b1f9 100644 --- a/MC1/Views/Contacts/ContactListActions.swift +++ b/MC1/Views/Contacts/ContactListActions.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Layout-independent Nodes-list derived state and actions shared by the compact `ContactsListView` /// (stack) and the iPad `ContactsContentColumn` (split). Both compute the same filtered list and run @@ -7,52 +7,52 @@ import MC1Services /// fresh per body evaluation; `syncSuccessTrigger` points at each view's own `@State`. @MainActor struct ContactListActions { - let viewModel: ContactsViewModel - let appState: AppState - let syncSuccessTrigger: Binding - - /// Filters and sorts contacts, falling back to lastHeard sort when distance is selected but no - /// location is available. - func filteredContacts(searchText: String, segment: NodeSegment, sortOrder: NodeSortOrder) -> [ContactDTO] { - let effectiveSortOrder = (sortOrder == .distance && appState.bestAvailableLocation == nil) - ? .lastHeard - : sortOrder - - return viewModel.filteredContacts( - searchText: searchText, - segment: segment, - sortOrder: effectiveSortOrder, - userLocation: appState.bestAvailableLocation - ) - } - - var searchPrompt: String { - let count = viewModel.contacts.count - return count > 0 - ? L10n.Contacts.Contacts.List.searchPromptWithCount(count) - : L10n.Contacts.Contacts.List.searchPrompt - } - - func loadContacts() async { - guard let deviceID = appState.currentRadioID else { return } - viewModel.configure( - dataStore: { [appState] in appState.offlineDataStore }, - contactService: { [appState] in appState.services?.contactService }, - advertisementService: { [appState] in appState.services?.advertisementService } - ) - await viewModel.loadContacts(radioID: deviceID) - } - - func syncContacts() async { - guard let deviceID = appState.currentRadioID else { return } - await viewModel.syncContacts(radioID: deviceID) - syncSuccessTrigger.wrappedValue.toggle() - } - - func announceOfflineStateIfNeeded() { - guard appState.connectionState == .disconnected, - appState.currentRadioID != nil else { return } - - AccessibilityNotification.Announcement(L10n.Contacts.Contacts.List.offlineAnnouncement).post() - } + let viewModel: ContactsViewModel + let appState: AppState + let syncSuccessTrigger: Binding + + /// Filters and sorts contacts, falling back to lastHeard sort when distance is selected but no + /// location is available. + func filteredContacts(searchText: String, segment: NodeSegment, sortOrder: NodeSortOrder) -> [ContactDTO] { + let effectiveSortOrder = (sortOrder == .distance && appState.bestAvailableLocation == nil) + ? .lastHeard + : sortOrder + + return viewModel.filteredContacts( + searchText: searchText, + segment: segment, + sortOrder: effectiveSortOrder, + userLocation: appState.bestAvailableLocation + ) + } + + var searchPrompt: String { + let count = viewModel.contacts.count + return count > 0 + ? L10n.Contacts.Contacts.List.searchPromptWithCount(count) + : L10n.Contacts.Contacts.List.searchPrompt + } + + func loadContacts() async { + guard let deviceID = appState.currentRadioID else { return } + viewModel.configure( + dataStore: { [appState] in appState.offlineDataStore }, + contactService: { [appState] in appState.services?.contactService }, + advertisementService: { [appState] in appState.services?.advertisementService } + ) + await viewModel.loadContacts(radioID: deviceID) + } + + func syncContacts() async { + guard let deviceID = appState.currentRadioID else { return } + await viewModel.syncContacts(radioID: deviceID) + syncSuccessTrigger.wrappedValue.toggle() + } + + func announceOfflineStateIfNeeded() { + guard appState.connectionState == .disconnected, + appState.currentRadioID != nil else { return } + + AccessibilityNotification.Announcement(L10n.Contacts.Contacts.List.offlineAnnouncement).post() + } } diff --git a/MC1/Views/Contacts/ContactQRShareSheet.swift b/MC1/Views/Contacts/ContactQRShareSheet.swift index df437d1a..1770032d 100644 --- a/MC1/Views/Contacts/ContactQRShareSheet.swift +++ b/MC1/Views/Contacts/ContactQRShareSheet.swift @@ -1,200 +1,200 @@ -import SwiftUI -import MC1Services import CoreImage.CIFilterBuiltins +import MC1Services +import SwiftUI /// Sheet for sharing a contact via QR code struct ContactQRShareSheet: View { - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - - let contactName: String - let publicKey: Data - let contactType: ContactType - - @State private var showCopyFeedback = false - @State private var qrImage: UIImage? - @State private var copyHapticTrigger = 0 - - private var contactURI: String { - ContactService.exportContactURI(name: contactName, publicKey: publicKey, type: contactType) - } - - var body: some View { - NavigationStack { - Form { - // QR Code Section - QRCodeSection(contactName: contactName, qrImage: qrImage) - .themedRowBackground(theme) - - // Contact Info Section - ContactInfoSection(publicKey: publicKey) - .themedRowBackground(theme) - - // Actions Section - ActionsSection( - qrImage: qrImage, - shareText: shareText, - contactName: contactName, - showCopyFeedback: $showCopyFeedback, - copyToClipboard: copyToClipboard - ) - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.Qr.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Contacts.Contacts.Common.done) { - dismiss() - } - } - } - .onAppear { - qrImage = generateQRCode() - } - .sensoryFeedback(.success, trigger: copyHapticTrigger) + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + + let contactName: String + let publicKey: Data + let contactType: ContactType + + @State private var showCopyFeedback = false + @State private var qrImage: UIImage? + @State private var copyHapticTrigger = 0 + + private var contactURI: String { + ContactService.exportContactURI(name: contactName, publicKey: publicKey, type: contactType) + } + + var body: some View { + NavigationStack { + Form { + // QR Code Section + QRCodeSection(contactName: contactName, qrImage: qrImage) + .themedRowBackground(theme) + + // Contact Info Section + ContactInfoSection(publicKey: publicKey) + .themedRowBackground(theme) + + // Actions Section + ActionsSection( + qrImage: qrImage, + shareText: shareText, + contactName: contactName, + showCopyFeedback: $showCopyFeedback, + copyToClipboard: copyToClipboard + ) + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.Qr.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Common.done) { + dismiss() + } } + } + .onAppear { + qrImage = generateQRCode() + } + .sensoryFeedback(.success, trigger: copyHapticTrigger) } + } - // MARK: - Private Methods + // MARK: - Private Methods - private func generateQRCode() -> UIImage? { - let context = CIContext() - let filter = CIFilter.qrCodeGenerator() - filter.message = Data(contactURI.utf8) - filter.correctionLevel = Constants.qrCorrectionLevel + private func generateQRCode() -> UIImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(contactURI.utf8) + filter.correctionLevel = Constants.qrCorrectionLevel - guard let outputImage = filter.outputImage else { return nil } + guard let outputImage = filter.outputImage else { return nil } - // Scale up for better quality - let transform = CGAffineTransform(scaleX: Constants.qrScale, y: Constants.qrScale) - let scaledImage = outputImage.transformed(by: transform) + // Scale up for better quality + let transform = CGAffineTransform(scaleX: Constants.qrScale, y: Constants.qrScale) + let scaledImage = outputImage.transformed(by: transform) - guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } - return UIImage(cgImage: cgImage) - } + return UIImage(cgImage: cgImage) + } - private var shareText: String { - """ - \(L10n.Contacts.Contacts.Share.contactLabel(contactName)) - \(L10n.Contacts.Contacts.Share.keyLabel(publicKey.hexString)) - \(contactURI) - """ - } + private var shareText: String { + """ + \(L10n.Contacts.Contacts.Share.contactLabel(contactName)) + \(L10n.Contacts.Contacts.Share.keyLabel(publicKey.hexString)) + \(contactURI) + """ + } - private func copyToClipboard() { - copyHapticTrigger += 1 - UIPasteboard.general.string = publicKey.hexString - showCopyFeedback = true + private func copyToClipboard() { + copyHapticTrigger += 1 + UIPasteboard.general.string = publicKey.hexString + showCopyFeedback = true - Task { - try? await Task.sleep(for: Constants.copyFeedbackDuration) - showCopyFeedback = false - } + Task { + try? await Task.sleep(for: Constants.copyFeedbackDuration) + showCopyFeedback = false } + } } // MARK: - Constants private enum Constants { - static let qrScale = 10.0 - static let qrCorrectionLevel = "M" - static let copyFeedbackDuration = Duration.seconds(2) - static let qrCodeSize: CGFloat = 200 + static let qrScale = 10.0 + static let qrCorrectionLevel = "M" + static let copyFeedbackDuration = Duration.seconds(2) + static let qrCodeSize: CGFloat = 200 } // MARK: - QR Code Section private struct QRCodeSection: View { - let contactName: String - let qrImage: UIImage? - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 12) { - if let qrImage { - Image(uiImage: qrImage) - .interpolation(.none) - .resizable() - .scaledToFit() - .frame(width: Constants.qrCodeSize, height: Constants.qrCodeSize) - } - - Text(contactName) - .font(.title2) - .bold() - } - Spacer() - } + let contactName: String + let qrImage: UIImage? + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 12) { + if let qrImage { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: Constants.qrCodeSize, height: Constants.qrCodeSize) + } + + Text(contactName) + .font(.title2) + .bold() } + Spacer() + } } + } } // MARK: - Contact Info Section private struct ContactInfoSection: View { - let publicKey: Data - - var body: some View { - Section { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Contacts.Contacts.Add.publicKey) - .font(.caption) - .foregroundStyle(.secondary) - - Text(publicKey.uppercaseHexString(separator: " ")) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - } - } + let publicKey: Data + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Contacts.Contacts.Add.publicKey) + .font(.caption) + .foregroundStyle(.secondary) + + Text(publicKey.uppercaseHexString(separator: " ")) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } } + } } // MARK: - Actions Section private struct ActionsSection: View { - let qrImage: UIImage? - let shareText: String - let contactName: String - @Binding var showCopyFeedback: Bool - let copyToClipboard: () -> Void - - var body: some View { - Section { - Button { - copyToClipboard() - } label: { - HStack { - Spacer() - Label( - showCopyFeedback - ? L10n.Contacts.Contacts.Qr.copied - : L10n.Contacts.Contacts.Qr.copy, - systemImage: "doc.on.doc" - ) - Spacer() - } - } - .disabled(showCopyFeedback) - .alignmentGuide(.listRowSeparatorLeading) { dimensions in dimensions[.leading] } - - if let qrImage { - ShareLink( - item: shareText, - subject: Text(L10n.Contacts.Contacts.Qr.shareSubject), - preview: SharePreview(contactName, image: Image(uiImage: qrImage)) - ) { - HStack { - Spacer() - Label(L10n.Contacts.Contacts.Qr.share, systemImage: "square.and.arrow.up") - Spacer() - } - } - } + let qrImage: UIImage? + let shareText: String + let contactName: String + @Binding var showCopyFeedback: Bool + let copyToClipboard: () -> Void + + var body: some View { + Section { + Button { + copyToClipboard() + } label: { + HStack { + Spacer() + Label( + showCopyFeedback + ? L10n.Contacts.Contacts.Qr.copied + : L10n.Contacts.Contacts.Qr.copy, + systemImage: "doc.on.doc" + ) + Spacer() + } + } + .disabled(showCopyFeedback) + .alignmentGuide(.listRowSeparatorLeading) { dimensions in dimensions[.leading] } + + if let qrImage { + ShareLink( + item: shareText, + subject: Text(L10n.Contacts.Contacts.Qr.shareSubject), + preview: SharePreview(contactName, image: Image(uiImage: qrImage)) + ) { + HStack { + Spacer() + Label(L10n.Contacts.Contacts.Qr.share, systemImage: "square.and.arrow.up") + Spacer() + } } + } } + } } diff --git a/MC1/Views/Contacts/ContactRoute.swift b/MC1/Views/Contacts/ContactRoute.swift index 428512cb..e84661bd 100644 --- a/MC1/Views/Contacts/ContactRoute.swift +++ b/MC1/Views/Contacts/ContactRoute.swift @@ -6,45 +6,45 @@ import MC1Services /// Equality and hashing use the contact's stable id, so path identity survives row updates /// while the carried payload still lets the destination build before the list has loaded. enum ContactRoute: Hashable { - case detail(ContactDTO) - case blockedContacts + case detail(ContactDTO) + case blockedContacts - /// Telemetry history push destination. `ContactDetailView` registers and pushes this - /// itself because it is hosted by three different stacks (Contacts stack, iPad detail - /// column, chat info sheet), so the destination must travel with the view. - struct TelemetryHistory: Hashable { - let publicKey: Data - let radioID: UUID - var showNeighbors = true - } + /// Telemetry history push destination. `ContactDetailView` registers and pushes this + /// itself because it is hosted by three different stacks (Contacts stack, iPad detail + /// column, chat info sheet), so the destination must travel with the view. + struct TelemetryHistory: Hashable { + let publicKey: Data + let radioID: UUID + var showNeighbors = true + } - private enum Kind: UInt8, Hashable { - case detail - case blockedContacts - } + private enum Kind: UInt8, Hashable { + case detail + case blockedContacts + } - private var kind: Kind { - switch self { - case .detail: .detail - case .blockedContacts: .blockedContacts - } + private var kind: Kind { + switch self { + case .detail: .detail + case .blockedContacts: .blockedContacts } + } - static func == (lhs: ContactRoute, rhs: ContactRoute) -> Bool { - switch (lhs, rhs) { - case (.detail(let lhsContact), .detail(let rhsContact)): - lhsContact.id == rhsContact.id - case (.blockedContacts, .blockedContacts): - true - default: - false - } + static func == (lhs: ContactRoute, rhs: ContactRoute) -> Bool { + switch (lhs, rhs) { + case let (.detail(lhsContact), .detail(rhsContact)): + lhsContact.id == rhsContact.id + case (.blockedContacts, .blockedContacts): + true + default: + false } + } - func hash(into hasher: inout Hasher) { - hasher.combine(kind) - if case .detail(let contact) = self { - hasher.combine(contact.id) - } + func hash(into hasher: inout Hasher) { + hasher.combine(kind) + if case let .detail(contact) = self { + hasher.combine(contact.id) } + } } diff --git a/MC1/Views/Contacts/ContactRowView.swift b/MC1/Views/Contacts/ContactRowView.swift index 9d8881c0..ffc628cc 100644 --- a/MC1/Views/Contacts/ContactRowView.swift +++ b/MC1/Views/Contacts/ContactRowView.swift @@ -1,146 +1,146 @@ -import SwiftUI import CoreLocation import MC1Services +import SwiftUI struct ContactRowView: View { - @Environment(\.appState) private var appState - let contact: ContactDTO - let showTypeLabel: Bool - let userLocation: CLLocation? - let isTogglingFavorite: Bool - let inboundHopCount: Int? - - init( - contact: ContactDTO, - showTypeLabel: Bool = false, - userLocation: CLLocation? = nil, - isTogglingFavorite: Bool = false, - inboundHopCount: Int? = nil - ) { - self.contact = contact - self.showTypeLabel = showTypeLabel - self.userLocation = userLocation - self.isTogglingFavorite = isTogglingFavorite - self.inboundHopCount = inboundHopCount - } - - var body: some View { - HStack(spacing: 12) { - avatarView - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - (Text(idPrefixHex) - .monospaced() - .foregroundStyle(.secondary) - + Text(" \(contact.displayName)") - .fontWeight(.medium)) - .font(.body) - .accessibilityLabel(contact.displayName) - - if contact.isBlocked { - Image(systemName: "hand.raised.fill") - .font(.caption) - .foregroundStyle(.orange) - .accessibilityLabel(L10n.Contacts.Contacts.Row.blocked) - } - - Spacer() - - if isTogglingFavorite { - ProgressView() - .controlSize(.small) - } else if contact.isFavorite { - Image(systemName: "star.fill") - .font(.caption) - .foregroundStyle(.yellow) - .accessibilityLabel(L10n.Contacts.Contacts.Row.favorite) - } - - RelativeTimestampText(timestamp: contact.lastModified) - } - - HStack(spacing: 8) { - // Show type label only in search results - if showTypeLabel { - Text(contactTypeLabel) - .font(.caption) - .foregroundStyle(.secondary) - - Text("\u{00B7}") - .font(.caption) - .foregroundStyle(.secondary) - } - - // Route indicator - Text(routeLabel) - .font(.caption) - .foregroundStyle(.secondary) - - // Location indicator with optional distance - if contact.hasLocation { - Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") - .labelStyle(.iconOnly) - .font(.caption) - .foregroundStyle(.green) - - if let distance = distanceToContact { - Text(distance) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } + @Environment(\.appState) private var appState + let contact: ContactDTO + let showTypeLabel: Bool + let userLocation: CLLocation? + let isTogglingFavorite: Bool + let inboundHopCount: Int? + + init( + contact: ContactDTO, + showTypeLabel: Bool = false, + userLocation: CLLocation? = nil, + isTogglingFavorite: Bool = false, + inboundHopCount: Int? = nil + ) { + self.contact = contact + self.showTypeLabel = showTypeLabel + self.userLocation = userLocation + self.isTogglingFavorite = isTogglingFavorite + self.inboundHopCount = inboundHopCount + } + + var body: some View { + HStack(spacing: 12) { + avatarView + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + (Text(idPrefixHex) + .monospaced() + .foregroundStyle(.secondary) + + Text(" \(contact.displayName)") + .fontWeight(.medium)) + .font(.body) + .accessibilityLabel(contact.displayName) + + if contact.isBlocked { + Image(systemName: "hand.raised.fill") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityLabel(L10n.Contacts.Contacts.Row.blocked) + } + + Spacer() + + if isTogglingFavorite { + ProgressView() + .controlSize(.small) + } else if contact.isFavorite { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(.yellow) + .accessibilityLabel(L10n.Contacts.Contacts.Row.favorite) + } + + RelativeTimestampText(timestamp: contact.lastModified) } - .padding(.vertical, 4) - } - @ViewBuilder - private var avatarView: some View { - switch contact.type { - case .chat: - ContactAvatar(contact: contact, size: 44) - case .repeater: - NodeAvatar(publicKey: contact.publicKey, role: .repeater, size: 44) - case .room: - NodeAvatar(publicKey: contact.publicKey, role: .roomServer, size: 44) + HStack(spacing: 8) { + // Show type label only in search results + if showTypeLabel { + Text(contactTypeLabel) + .font(.caption) + .foregroundStyle(.secondary) + + Text("\u{00B7}") + .font(.caption) + .foregroundStyle(.secondary) + } + + // Route indicator + Text(routeLabel) + .font(.caption) + .foregroundStyle(.secondary) + + // Location indicator with optional distance + if contact.hasLocation { + Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") + .labelStyle(.iconOnly) + .font(.caption) + .foregroundStyle(.green) + + if let distance = distanceToContact { + Text(distance) + .font(.caption) + .foregroundStyle(.secondary) + } + } } + } } - - private var idPrefixHex: String { - let hashSize = appState.connectedDevice?.hashSize ?? 1 - return contact.publicKey.prefix(hashSize).uppercaseHexString() - } - - private var contactTypeLabel: String { - contact.type.localizedName + .padding(.vertical, 4) + } + + @ViewBuilder + private var avatarView: some View { + switch contact.type { + case .chat: + ContactAvatar(contact: contact, size: 44) + case .repeater: + NodeAvatar(publicKey: contact.publicKey, role: .repeater, size: 44) + case .room: + NodeAvatar(publicKey: contact.publicKey, role: .roomServer, size: 44) } - - private var routeLabel: String { - if !contact.isFloodRouted, contact.pathHopCount == 0 { - return L10n.Contacts.Contacts.Route.direct - } else if let hops = contact.displayedHopCount(inboundHopCount: inboundHopCount) { - return L10n.Contacts.Contacts.Route.hops(hops) - } else { - return L10n.Contacts.Contacts.Route.flood - } - } - - private var distanceToContact: String? { - guard let userLocation, contact.hasLocation else { return nil } - - let contactLocation = CLLocation( - latitude: contact.latitude, - longitude: contact.longitude - ) - let meters = userLocation.distance(from: contactLocation) - let measurement = Measurement(value: meters, unit: UnitLength.meters) - - let formattedDistance = measurement.formatted(.measurement( - width: .abbreviated, - usage: .road - )) - return L10n.Contacts.Contacts.Row.away(formattedDistance) + } + + private var idPrefixHex: String { + let hashSize = appState.connectedDevice?.hashSize ?? 1 + return contact.publicKey.prefix(hashSize).uppercaseHexString() + } + + private var contactTypeLabel: String { + contact.type.localizedName + } + + private var routeLabel: String { + if !contact.isFloodRouted, contact.pathHopCount == 0 { + L10n.Contacts.Contacts.Route.direct + } else if let hops = contact.displayedHopCount(inboundHopCount: inboundHopCount) { + L10n.Contacts.Contacts.Route.hops(hops) + } else { + L10n.Contacts.Contacts.Route.flood } + } + + private var distanceToContact: String? { + guard let userLocation, contact.hasLocation else { return nil } + + let contactLocation = CLLocation( + latitude: contact.latitude, + longitude: contact.longitude + ) + let meters = userLocation.distance(from: contactLocation) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + + let formattedDistance = measurement.formatted(.measurement( + width: .abbreviated, + usage: .road + )) + return L10n.Contacts.Contacts.Row.away(formattedDistance) + } } diff --git a/MC1/Views/Contacts/ContactsContentColumn.swift b/MC1/Views/Contacts/ContactsContentColumn.swift index 3eecc458..9ebe3912 100644 --- a/MC1/Views/Contacts/ContactsContentColumn.swift +++ b/MC1/Views/Contacts/ContactsContentColumn.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// The iPad sidebar's Nodes content column. It mirrors the regular-width (split) path of /// `ContactsListView`, hosting `ContactsSidebarContent` with the same toolbar, searchable, @@ -11,58 +11,58 @@ import MC1Services /// deep link (e.g. a notification tap) the first time Nodes is entered, and this view is /// instantiated whenever Nodes is selected. struct ContactsContentColumn: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @State private var viewModel = ContactsViewModel() - @State private var searchText = "" - @State private var selectedSegment: NodeSegment = .contacts - @AppStorage(AppStorageKey.nodesSortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard - @State private var syncSuccessTrigger = false - @State private var showShareMyContact = false - @State private var showAddContact = false - @State private var showLocationDeniedAlert = false - @State private var showOfflineRefreshAlert = false + @State private var viewModel = ContactsViewModel() + @State private var searchText = "" + @State private var selectedSegment: NodeSegment = .contacts + @AppStorage(AppStorageKey.nodesSortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard + @State private var syncSuccessTrigger = false + @State private var showShareMyContact = false + @State private var showAddContact = false + @State private var showLocationDeniedAlert = false + @State private var showOfflineRefreshAlert = false - private var actions: ContactListActions { - ContactListActions(viewModel: viewModel, appState: appState, syncSuccessTrigger: $syncSuccessTrigger) - } + private var actions: ContactListActions { + ContactListActions(viewModel: viewModel, appState: appState, syncSuccessTrigger: $syncSuccessTrigger) + } - var body: some View { - @Bindable var navigation = appState.navigation + var body: some View { + @Bindable var navigation = appState.navigation - ContactsSidebarContent( - viewModel: viewModel, - filteredContacts: actions.filteredContacts(searchText: searchText, segment: selectedSegment, sortOrder: sortOrder), - isSearching: !searchText.isEmpty, - searchPrompt: actions.searchPrompt, - shouldUseSplitView: true, - selectedSegment: $selectedSegment, - selectedContact: $navigation.selectedContact, - searchText: $searchText, - sortOrder: $sortOrder, - showDiscovery: $navigation.nodesShowingDiscovery, - syncSuccessTrigger: $syncSuccessTrigger, - showShareMyContact: $showShareMyContact, - showAddContact: $showAddContact, - showLocationDeniedAlert: $showLocationDeniedAlert, - showOfflineRefreshAlert: $showOfflineRefreshAlert, - // Compact-only navigation, unused on the split path which drives selection via selectedContact. - navigationPath: .constant(NavigationPath()), - onLoadContacts: actions.loadContacts, - onSyncContacts: actions.syncContacts, - onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded - ) - .onChange(of: appState.navigation.selectedContact) { _, newContact in - if newContact != nil { - appState.navigation.nodesShowingDiscovery = false - } - } + ContactsSidebarContent( + viewModel: viewModel, + filteredContacts: actions.filteredContacts(searchText: searchText, segment: selectedSegment, sortOrder: sortOrder), + isSearching: !searchText.isEmpty, + searchPrompt: actions.searchPrompt, + shouldUseSplitView: true, + selectedSegment: $selectedSegment, + selectedContact: $navigation.selectedContact, + searchText: $searchText, + sortOrder: $sortOrder, + showDiscovery: $navigation.nodesShowingDiscovery, + syncSuccessTrigger: $syncSuccessTrigger, + showShareMyContact: $showShareMyContact, + showAddContact: $showAddContact, + showLocationDeniedAlert: $showLocationDeniedAlert, + showOfflineRefreshAlert: $showOfflineRefreshAlert, + // Compact-only navigation, unused on the split path which drives selection via selectedContact. + navigationPath: .constant(NavigationPath()), + onLoadContacts: actions.loadContacts, + onSyncContacts: actions.syncContacts, + onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded + ) + .onChange(of: appState.navigation.selectedContact) { _, newContact in + if newContact != nil { + appState.navigation.nodesShowingDiscovery = false + } } + } } #Preview { - NavigationStack { - ContactsContentColumn() - } - .environment(\.appState, AppState()) + NavigationStack { + ContactsContentColumn() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Contacts/ContactsDetailColumn.swift b/MC1/Views/Contacts/ContactsDetailColumn.swift index 4057ab6e..750ccd3d 100644 --- a/MC1/Views/Contacts/ContactsDetailColumn.swift +++ b/MC1/Views/Contacts/ContactsDetailColumn.swift @@ -1,28 +1,28 @@ -import SwiftUI import MC1Services +import SwiftUI /// The iPad sidebar's Nodes detail column. It reproduces the regular-width (split) detail /// branch of `ContactsListView`: Discovery wins when active, otherwise the selected contact, /// otherwise an empty placeholder. Selection and discovery state are read from /// `appState.navigation` so this column stays in sync with `ContactsContentColumn`. struct ContactsDetailColumn: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - var body: some View { - if appState.navigation.nodesShowingDiscovery { - DiscoveryView() - } else if let selectedContact = appState.navigation.selectedContact { - ContactDetailView(contact: selectedContact) - .id(selectedContact.id) - } else { - ContentUnavailableView(L10n.Contacts.Contacts.List.selectNode, systemImage: "flipphone") - } + var body: some View { + if appState.navigation.nodesShowingDiscovery { + DiscoveryView() + } else if let selectedContact = appState.navigation.selectedContact { + ContactDetailView(contact: selectedContact) + .id(selectedContact.id) + } else { + ContentUnavailableView(L10n.Contacts.Contacts.List.selectNode, systemImage: "flipphone") } + } } #Preview { - NavigationStack { - ContactsDetailColumn() - } - .environment(\.appState, AppState()) + NavigationStack { + ContactsDetailColumn() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Contacts/ContactsEmptyView.swift b/MC1/Views/Contacts/ContactsEmptyView.swift index 31d29aab..7ae607b4 100644 --- a/MC1/Views/Contacts/ContactsEmptyView.swift +++ b/MC1/Views/Contacts/ContactsEmptyView.swift @@ -1,34 +1,34 @@ import SwiftUI struct ContactsEmptyView: View { - let selectedSegment: NodeSegment + let selectedSegment: NodeSegment - var body: some View { - switch selectedSegment { - case .favorites: - ContentUnavailableView( - L10n.Contacts.Contacts.List.Empty.Favorites.title, - systemImage: "star", - description: Text(L10n.Contacts.Contacts.List.Empty.Favorites.description) - ) - case .contacts: - ContentUnavailableView( - L10n.Contacts.Contacts.List.Empty.Contacts.title, - systemImage: "person.2", - description: Text(L10n.Contacts.Contacts.List.Empty.Contacts.description) - ) - case .repeaters: - ContentUnavailableView( - L10n.Contacts.Contacts.List.Empty.Repeaters.title, - systemImage: "antenna.radiowaves.left.and.right", - description: Text(L10n.Contacts.Contacts.List.Empty.Repeaters.description) - ) - case .rooms: - ContentUnavailableView( - L10n.Contacts.Contacts.List.Empty.Rooms.title, - systemImage: "door.left.hand.open", - description: Text(L10n.Contacts.Contacts.List.Empty.Rooms.description) - ) - } + var body: some View { + switch selectedSegment { + case .favorites: + ContentUnavailableView( + L10n.Contacts.Contacts.List.Empty.Favorites.title, + systemImage: "star", + description: Text(L10n.Contacts.Contacts.List.Empty.Favorites.description) + ) + case .contacts: + ContentUnavailableView( + L10n.Contacts.Contacts.List.Empty.Contacts.title, + systemImage: "person.2", + description: Text(L10n.Contacts.Contacts.List.Empty.Contacts.description) + ) + case .repeaters: + ContentUnavailableView( + L10n.Contacts.Contacts.List.Empty.Repeaters.title, + systemImage: "antenna.radiowaves.left.and.right", + description: Text(L10n.Contacts.Contacts.List.Empty.Repeaters.description) + ) + case .rooms: + ContentUnavailableView( + L10n.Contacts.Contacts.List.Empty.Rooms.title, + systemImage: "door.left.hand.open", + description: Text(L10n.Contacts.Contacts.List.Empty.Rooms.description) + ) } + } } diff --git a/MC1/Views/Contacts/ContactsListContent.swift b/MC1/Views/Contacts/ContactsListContent.swift index 51942813..ffca9fb7 100644 --- a/MC1/Views/Contacts/ContactsListContent.swift +++ b/MC1/Views/Contacts/ContactsListContent.swift @@ -1,6 +1,6 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// The nodes list rendered as a `ScrollView` + `LazyVStack` rather than a `List`. `List` is backed /// by `UpdateCoalescingCollectionView`, whose batch-consistency assertion is violated when the @@ -8,181 +8,184 @@ import MC1Services /// actions live in a `.contextMenu`. One view serves both layouts: the compact stack navigates via /// `NavigationLink`, the iPad split drives a selection binding the detail column reads. struct ContactsListContent: View { - enum ListMode { - case selection(Binding) - case navigation + enum ListMode { + case selection(Binding) + case navigation + } + + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + let mode: ListMode + @Binding var selectedSegment: NodeSegment + let isSearching: Bool + let searchText: String + let filteredContacts: [ContactDTO] + let hasLoadedOnce: Bool + let viewModel: ContactsViewModel + + /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. + private static let rowSeparatorLeadingInset: CGFloat = 72 + + var body: some View { + Group { + if !hasLoadedOnce { + loadingBody + } else { + loadedBody + } } - - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - let mode: ListMode - @Binding var selectedSegment: NodeSegment - let isSearching: Bool - let searchText: String - let filteredContacts: [ContactDTO] - let hasLoadedOnce: Bool - let viewModel: ContactsViewModel - - /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. - private static let rowSeparatorLeadingInset: CGFloat = 72 - - var body: some View { - Group { - if !hasLoadedOnce { - loadingBody - } else { - loadedBody - } - } - .themedCanvas(theme) + .themedCanvas(theme) + } + + private var loadingBody: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section {} header: { pinnedSegmentHeader } + } } - - private var loadingBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { } header: { pinnedSegmentHeader } - } + .overlay { ProgressView() } + } + + private var loadedBody: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + if filteredContacts.isEmpty { + emptyState + } else { + rows + } + } header: { + pinnedSegmentHeader } - .overlay { ProgressView() } + } } - - private var loadedBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - if filteredContacts.isEmpty { - emptyState - } else { - rows - } - } header: { - pinnedSegmentHeader - } - } - } + } + + /// Segment picker as the pinned section header; `pinnedFilterHeaderBackground` documents the + /// per-OS backing. + private var pinnedSegmentHeader: some View { + NodeSegmentPicker(selection: $selectedSegment, isSearching: isSearching) + .frame(maxWidth: .infinity) + .pinnedFilterHeaderBackground(theme) + } + + private var emptyState: some View { + Group { + if isSearching { + ContactsSearchEmptyView(searchText: searchText) + } else { + ContactsEmptyView(selectedSegment: selectedSegment) + } } - - /// Segment picker as the pinned section header; `pinnedFilterHeaderBackground` documents the - /// per-OS backing. - private var pinnedSegmentHeader: some View { - NodeSegmentPicker(selection: $selectedSegment, isSearching: isSearching) - .frame(maxWidth: .infinity) - .pinnedFilterHeaderBackground(theme) + .containerRelativeFrame([.horizontal, .vertical]) + } + + private var rows: some View { + ForEach(Array(filteredContacts.enumerated()), id: \.element.id) { index, contact in + rowView(contact) + .transition(.opacity) + if index < filteredContacts.count - 1 { + Divider().padding(.leading, Self.rowSeparatorLeadingInset) + } } - - @ViewBuilder - private var emptyState: some View { - Group { - if isSearching { - ContactsSearchEmptyView(searchText: searchText) - } else { - ContactsEmptyView(selectedSegment: selectedSegment) - } - } - .containerRelativeFrame([.horizontal, .vertical]) - } - - private var rows: some View { - ForEach(Array(filteredContacts.enumerated()), id: \.element.id) { index, contact in - rowView(contact) - .transition(.opacity) - if index < filteredContacts.count - 1 { - Divider().padding(.leading, Self.rowSeparatorLeadingInset) - } - } - } - - @ViewBuilder - private func rowView(_ contact: ContactDTO) -> some View { - switch mode { - case .selection(let selection): - ContactSelectionRow( - contact: contact, - viewModel: viewModel, - isSearching: isSearching, - userLocation: appState.bestAvailableLocation, - isSelected: selection.wrappedValue?.id == contact.id, - onSelect: { selection.wrappedValue = contact } - ) - case .navigation: - ContactNavigationRow( - contact: contact, - viewModel: viewModel, - isSearching: isSearching, - userLocation: appState.bestAvailableLocation - ) - } + } + + @ViewBuilder + private func rowView(_ contact: ContactDTO) -> some View { + switch mode { + case let .selection(selection): + ContactSelectionRow( + contact: contact, + viewModel: viewModel, + isSearching: isSearching, + userLocation: appState.bestAvailableLocation, + isSelected: selection.wrappedValue?.id == contact.id, + onSelect: { selection.wrappedValue = contact } + ) + case .navigation: + ContactNavigationRow( + contact: contact, + viewModel: viewModel, + isSearching: isSearching, + userLocation: appState.bestAvailableLocation + ) } + } } // MARK: - Row Layout private enum ContactRowLayout { - static let horizontalPadding: CGFloat = 16 - static let verticalPadding: CGFloat = 6 + static let horizontalPadding: CGFloat = 16 + static let verticalPadding: CGFloat = 6 } /// Renders a node's row body, shared by the selection and navigation rows. private struct ContactListRowLabel: View { - let contact: ContactDTO - let viewModel: ContactsViewModel - let isSearching: Bool - let userLocation: CLLocation? - - var body: some View { - ContactRowView( - contact: contact, - showTypeLabel: isSearching, - userLocation: userLocation, - isTogglingFavorite: viewModel.togglingFavoriteID == contact.id, - inboundHopCount: viewModel.inboundHopByKey[contact.publicKey] - ) - .padding(.horizontal, ContactRowLayout.horizontalPadding) - .padding(.vertical, ContactRowLayout.verticalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } + let contact: ContactDTO + let viewModel: ContactsViewModel + let isSearching: Bool + let userLocation: CLLocation? + + var body: some View { + ContactRowView( + contact: contact, + showTypeLabel: isSearching, + userLocation: userLocation, + isTogglingFavorite: viewModel.togglingFavoriteID == contact.id, + inboundHopCount: viewModel.inboundHopByKey[contact.publicKey] + ) + .padding(.horizontal, ContactRowLayout.horizontalPadding) + .padding(.vertical, ContactRowLayout.verticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } } // MARK: - Extracted Rows private struct ContactSelectionRow: View { - let contact: ContactDTO - let viewModel: ContactsViewModel - let isSearching: Bool - let userLocation: CLLocation? - let isSelected: Bool - let onSelect: () -> Void - - private var isDeleting: Bool { viewModel.deletingIDs.contains(contact.id) } - - var body: some View { - Button(action: onSelect) { - ContactListRowLabel(contact: contact, viewModel: viewModel, isSearching: isSearching, userLocation: userLocation) - } - .buttonStyle(.plain) - .selectedRowHighlight(isSelected: isSelected) - .accessibilityAddTraits(isSelected ? .isSelected : []) - .deletingRowOverlay(isDeleting: isDeleting) - .contactContextMenu(contact: contact, viewModel: viewModel) + let contact: ContactDTO + let viewModel: ContactsViewModel + let isSearching: Bool + let userLocation: CLLocation? + let isSelected: Bool + let onSelect: () -> Void + + private var isDeleting: Bool { + viewModel.deletingIDs.contains(contact.id) + } + + var body: some View { + Button(action: onSelect) { + ContactListRowLabel(contact: contact, viewModel: viewModel, isSearching: isSearching, userLocation: userLocation) } + .buttonStyle(.plain) + .selectedRowHighlight(isSelected: isSelected) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .deletingRowOverlay(isDeleting: isDeleting) + .contactContextMenu(contact: contact, viewModel: viewModel) + } } private struct ContactNavigationRow: View { - let contact: ContactDTO - let viewModel: ContactsViewModel - let isSearching: Bool - let userLocation: CLLocation? - - private var isDeleting: Bool { viewModel.deletingIDs.contains(contact.id) } - - var body: some View { - NavigationLink(value: ContactRoute.detail(contact)) { - ContactListRowLabel(contact: contact, viewModel: viewModel, isSearching: isSearching, userLocation: userLocation) - } - .buttonStyle(.plain) - .deletingRowOverlay(isDeleting: isDeleting) - .contactContextMenu(contact: contact, viewModel: viewModel) + let contact: ContactDTO + let viewModel: ContactsViewModel + let isSearching: Bool + let userLocation: CLLocation? + + private var isDeleting: Bool { + viewModel.deletingIDs.contains(contact.id) + } + + var body: some View { + NavigationLink(value: ContactRoute.detail(contact)) { + ContactListRowLabel(contact: contact, viewModel: viewModel, isSearching: isSearching, userLocation: userLocation) } + .buttonStyle(.plain) + .deletingRowOverlay(isDeleting: isDeleting) + .contactContextMenu(contact: contact, viewModel: viewModel) + } } diff --git a/MC1/Views/Contacts/ContactsListView.swift b/MC1/Views/Contacts/ContactsListView.swift index 88afa665..c453b98b 100644 --- a/MC1/Views/Contacts/ContactsListView.swift +++ b/MC1/Views/Contacts/ContactsListView.swift @@ -1,65 +1,65 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let nodesListLogger = Logger(subsystem: "com.mc1", category: "NodesListView") /// List of all contacts discovered on the mesh network struct ContactsListView: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @State private var viewModel = ContactsViewModel() - @State private var navigationPath = NavigationPath() - @State private var searchText = "" - @State private var selectedSegment: NodeSegment = .contacts - @AppStorage(AppStorageKey.nodesSortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard - @State private var showDiscovery = false - @State private var syncSuccessTrigger = false - @State private var showShareMyContact = false - @State private var showAddContact = false - @State private var showLocationDeniedAlert = false - @State private var showOfflineRefreshAlert = false + @State private var viewModel = ContactsViewModel() + @State private var navigationPath = NavigationPath() + @State private var searchText = "" + @State private var selectedSegment: NodeSegment = .contacts + @AppStorage(AppStorageKey.nodesSortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard + @State private var showDiscovery = false + @State private var syncSuccessTrigger = false + @State private var showShareMyContact = false + @State private var showAddContact = false + @State private var showLocationDeniedAlert = false + @State private var showOfflineRefreshAlert = false - private var actions: ContactListActions { - ContactListActions(viewModel: viewModel, appState: appState, syncSuccessTrigger: $syncSuccessTrigger) - } + private var actions: ContactListActions { + ContactListActions(viewModel: viewModel, appState: appState, syncSuccessTrigger: $syncSuccessTrigger) + } - var body: some View { - NavigationStack(path: $navigationPath) { - sidebarContent - .navigationDestination(isPresented: $showDiscovery) { - DiscoveryView() - } + var body: some View { + NavigationStack(path: $navigationPath) { + sidebarContent + .navigationDestination(isPresented: $showDiscovery) { + DiscoveryView() } } + } - private var sidebarContent: some View { - ContactsSidebarContent( - viewModel: viewModel, - filteredContacts: actions.filteredContacts(searchText: searchText, segment: selectedSegment, sortOrder: sortOrder), - isSearching: !searchText.isEmpty, - searchPrompt: actions.searchPrompt, - shouldUseSplitView: false, - selectedSegment: $selectedSegment, - // Split-only: the compact stack navigates via navigationPath, so it has no selection. - selectedContact: .constant(nil), - searchText: $searchText, - sortOrder: $sortOrder, - showDiscovery: $showDiscovery, - syncSuccessTrigger: $syncSuccessTrigger, - showShareMyContact: $showShareMyContact, - showAddContact: $showAddContact, - showLocationDeniedAlert: $showLocationDeniedAlert, - showOfflineRefreshAlert: $showOfflineRefreshAlert, - navigationPath: $navigationPath, - onLoadContacts: actions.loadContacts, - onSyncContacts: actions.syncContacts, - onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded - ) - } + private var sidebarContent: some View { + ContactsSidebarContent( + viewModel: viewModel, + filteredContacts: actions.filteredContacts(searchText: searchText, segment: selectedSegment, sortOrder: sortOrder), + isSearching: !searchText.isEmpty, + searchPrompt: actions.searchPrompt, + shouldUseSplitView: false, + selectedSegment: $selectedSegment, + // Split-only: the compact stack navigates via navigationPath, so it has no selection. + selectedContact: .constant(nil), + searchText: $searchText, + sortOrder: $sortOrder, + showDiscovery: $showDiscovery, + syncSuccessTrigger: $syncSuccessTrigger, + showShareMyContact: $showShareMyContact, + showAddContact: $showAddContact, + showLocationDeniedAlert: $showLocationDeniedAlert, + showOfflineRefreshAlert: $showOfflineRefreshAlert, + navigationPath: $navigationPath, + onLoadContacts: actions.loadContacts, + onSyncContacts: actions.syncContacts, + onAnnounceOfflineStateIfNeeded: actions.announceOfflineStateIfNeeded + ) + } } #Preview { - ContactsListView() - .environment(\.appState, AppState()) + ContactsListView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Contacts/ContactsSearchEmptyView.swift b/MC1/Views/Contacts/ContactsSearchEmptyView.swift index 017d1497..1471ef88 100644 --- a/MC1/Views/Contacts/ContactsSearchEmptyView.swift +++ b/MC1/Views/Contacts/ContactsSearchEmptyView.swift @@ -1,13 +1,13 @@ import SwiftUI struct ContactsSearchEmptyView: View { - let searchText: String + let searchText: String - var body: some View { - ContentUnavailableView( - L10n.Contacts.Contacts.List.Empty.Search.title, - systemImage: "magnifyingglass", - description: Text(L10n.Contacts.Contacts.List.Empty.Search.description(searchText)) - ) - } + var body: some View { + ContentUnavailableView( + L10n.Contacts.Contacts.List.Empty.Search.title, + systemImage: "magnifyingglass", + description: Text(L10n.Contacts.Contacts.List.Empty.Search.description(searchText)) + ) + } } diff --git a/MC1/Views/Contacts/ContactsSidebarContent.swift b/MC1/Views/Contacts/ContactsSidebarContent.swift index e6b0367d..80e03eb1 100644 --- a/MC1/Views/Contacts/ContactsSidebarContent.swift +++ b/MC1/Views/Contacts/ContactsSidebarContent.swift @@ -1,234 +1,234 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let sidebarLogger = Logger(subsystem: "com.mc1", category: "NodesListView") struct ContactsSidebarContent: View { - @Environment(\.appState) private var appState - - @Bindable var viewModel: ContactsViewModel - let filteredContacts: [ContactDTO] - let isSearching: Bool - let searchPrompt: String - let shouldUseSplitView: Bool - - @Binding var selectedSegment: NodeSegment - @Binding var selectedContact: ContactDTO? - @Binding var searchText: String - @Binding var sortOrder: NodeSortOrder - @Binding var showDiscovery: Bool - @Binding var syncSuccessTrigger: Bool - @Binding var showShareMyContact: Bool - @Binding var showAddContact: Bool - @Binding var showLocationDeniedAlert: Bool - @Binding var showOfflineRefreshAlert: Bool - @Binding var navigationPath: NavigationPath - - let onLoadContacts: () async -> Void - let onSyncContacts: () async -> Void - let onAnnounceOfflineStateIfNeeded: () -> Void - - var body: some View { - ContactsListContent( - mode: shouldUseSplitView ? .selection($selectedContact) : .navigation, - selectedSegment: $selectedSegment, - isSearching: isSearching, - searchText: searchText, - filteredContacts: filteredContacts, - hasLoadedOnce: viewModel.hasLoadedOnce, - viewModel: viewModel - ) - .navigationTitle(L10n.Contacts.Contacts.List.title) - .navigationDestination(for: ContactRoute.self) { route in - switch route { - case .detail(let contact): - // Prefer the freshest row from the loaded list; fall back to the carried - // payload for pushes that precede a load (e.g. a notification deep link). - ContactDetailView(contact: viewModel.contacts.first { $0.id == contact.id } ?? contact) - .id(contact.id) - case .blockedContacts: - BlockedContactsView() + @Environment(\.appState) private var appState + + @Bindable var viewModel: ContactsViewModel + let filteredContacts: [ContactDTO] + let isSearching: Bool + let searchPrompt: String + let shouldUseSplitView: Bool + + @Binding var selectedSegment: NodeSegment + @Binding var selectedContact: ContactDTO? + @Binding var searchText: String + @Binding var sortOrder: NodeSortOrder + @Binding var showDiscovery: Bool + @Binding var syncSuccessTrigger: Bool + @Binding var showShareMyContact: Bool + @Binding var showAddContact: Bool + @Binding var showLocationDeniedAlert: Bool + @Binding var showOfflineRefreshAlert: Bool + @Binding var navigationPath: NavigationPath + + let onLoadContacts: () async -> Void + let onSyncContacts: () async -> Void + let onAnnounceOfflineStateIfNeeded: () -> Void + + var body: some View { + ContactsListContent( + mode: shouldUseSplitView ? .selection($selectedContact) : .navigation, + selectedSegment: $selectedSegment, + isSearching: isSearching, + searchText: searchText, + filteredContacts: filteredContacts, + hasLoadedOnce: viewModel.hasLoadedOnce, + viewModel: viewModel + ) + .navigationTitle(L10n.Contacts.Contacts.List.title) + .navigationDestination(for: ContactRoute.self) { route in + switch route { + case let .detail(contact): + // Prefer the freshest row from the loaded list; fall back to the carried + // payload for pushes that precede a load (e.g. a notification deep link). + ContactDetailView(contact: viewModel.contacts.first { $0.id == contact.id } ?? contact) + .id(contact.id) + case .blockedContacts: + BlockedContactsView() + } + } + .searchable(text: $searchText, prompt: searchPrompt) + .toolbar { + bleStatusToolbarItem() + + ToolbarItem(placement: .automatic) { + Menu { + ForEach(NodeSortOrder.allCases, id: \.self) { order in + Button { + sortOrder = order + } label: { + if sortOrder == order { + Label(order.localizedTitle, systemImage: "checkmark") + } else { + Text(order.localizedTitle) + } } + } + } label: { + Label(L10n.Contacts.Contacts.List.sort, systemImage: "arrow.up.arrow.down") } - .searchable(text: $searchText, prompt: searchPrompt) - .toolbar { - bleStatusToolbarItem() - - ToolbarItem(placement: .automatic) { - Menu { - ForEach(NodeSortOrder.allCases, id: \.self) { order in - Button { - sortOrder = order - } label: { - if sortOrder == order { - Label(order.localizedTitle, systemImage: "checkmark") - } else { - Text(order.localizedTitle) - } - } - } - } label: { - Label(L10n.Contacts.Contacts.List.sort, systemImage: "arrow.up.arrow.down") - } - } + } - ToolbarItem(placement: .automatic) { - Menu { - NavigationLink(value: ContactRoute.blockedContacts) { - Label(L10n.Contacts.Contacts.List.blockedContacts, systemImage: "hand.raised.fill") - } - - Divider() - - Button { - showShareMyContact = true - } label: { - Label(L10n.Contacts.Contacts.List.shareMyContact, systemImage: "square.and.arrow.up") - } - - Button { - showAddContact = true - } label: { - Label(L10n.Contacts.Contacts.List.addContact, systemImage: "plus") - } - - Divider() - - Button { - if shouldUseSplitView { - selectedContact = nil - } - showDiscovery = true - } label: { - Label(L10n.Contacts.Contacts.List.discover, systemImage: "antenna.radiowaves.left.and.right") - } - - Divider() - - Button { - Task { - if appState.connectionState != .ready { - showOfflineRefreshAlert = true - } else { - await onSyncContacts() - } - } - } label: { - Label(L10n.Contacts.Contacts.List.syncNodes, systemImage: "arrow.triangle.2.circlepath") - } - .disabled(viewModel.isSyncing) - } label: { - Label(L10n.Contacts.Contacts.List.options, systemImage: "ellipsis.circle") - } - } - } - .refreshable { - if appState.connectionState != .ready { - showOfflineRefreshAlert = true - } else { - // SwiftUI cancels a ScrollView's `.refreshable` task when observed state mutates - // mid-refresh (syncContacts sets `isSyncing`, re-evaluating this view), aborting the - // sync within a frame. Running it in a detached task shields it from that cancellation; - // awaiting the value keeps the refresh spinner up until the sync finishes. - await Task { await onSyncContacts() }.value - } - } - .alert(L10n.Contacts.Contacts.List.cannotRefresh, isPresented: $showOfflineRefreshAlert) { - Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) { } - } message: { - Text(L10n.Contacts.Contacts.List.connectToSync) - } - .sensoryFeedback(.success, trigger: syncSuccessTrigger) - .task { - sidebarLogger.info("NodesListView: task started, services=\(appState.services != nil)") - viewModel.configure( - dataStore: { [appState] in appState.offlineDataStore }, - contactService: { [appState] in appState.services?.contactService }, - advertisementService: { [appState] in appState.services?.advertisementService } - ) - await onLoadContacts() - sidebarLogger.info("NodesListView: loaded, contacts=\(viewModel.contacts.count)") - onAnnounceOfflineStateIfNeeded() - - // Request location for distance display (only if already authorized) - if appState.locationService.isAuthorized { - appState.locationService.requestLocation() - } - } - .task(id: sortOrder) { - if sortOrder == .distance { - if appState.locationService.isAuthorized { - appState.locationService.requestLocation() - } else if appState.locationService.isLocationDenied { - showLocationDeniedAlert = true - } else { - appState.locationService.requestPermissionIfNeeded() - } - } - } - .onChange(of: appState.servicesVersion) { _, _ in - Task { - await onLoadContacts() - } - } - .onChange(of: appState.contactsVersion) { _, _ in - Task { - await onLoadContacts() - } - } - .onChange(of: appState.navigation.pendingDiscoveryNavigation, initial: true) { _, shouldNavigate in - if shouldNavigate { - showDiscovery = true - appState.navigation.clearPendingDiscoveryNavigation() - } - } - .onChange(of: appState.navigation.pendingContactDetail, initial: true) { _, contact in - guard let contact else { return } + ToolbarItem(placement: .automatic) { + Menu { + NavigationLink(value: ContactRoute.blockedContacts) { + Label(L10n.Contacts.Contacts.List.blockedContacts, systemImage: "hand.raised.fill") + } + + Divider() + Button { + showShareMyContact = true + } label: { + Label(L10n.Contacts.Contacts.List.shareMyContact, systemImage: "square.and.arrow.up") + } + + Button { + showAddContact = true + } label: { + Label(L10n.Contacts.Contacts.List.addContact, systemImage: "plus") + } + + Divider() + + Button { if shouldUseSplitView { - selectedContact = contact - } else { - navigationPath.removeLast(navigationPath.count) - navigationPath.append(ContactRoute.detail(contact)) + selectedContact = nil } + showDiscovery = true + } label: { + Label(L10n.Contacts.Contacts.List.discover, systemImage: "antenna.radiowaves.left.and.right") + } - appState.navigation.clearPendingContactDetailNavigation() - } - .onChange(of: appState.locationService.authorizationStatus) { _, status in - if sortOrder == .distance { - switch status { - case .authorizedWhenInUse, .authorizedAlways: - appState.locationService.requestLocation() - case .denied, .restricted: - showLocationDeniedAlert = true - default: - break - } + Divider() + + Button { + Task { + if appState.connectionState != .ready { + showOfflineRefreshAlert = true + } else { + await onSyncContacts() + } } + } label: { + Label(L10n.Contacts.Contacts.List.syncNodes, systemImage: "arrow.triangle.2.circlepath") + } + .disabled(viewModel.isSyncing) + } label: { + Label(L10n.Contacts.Contacts.List.options, systemImage: "ellipsis.circle") } - .sheet(isPresented: $showShareMyContact) { - if let device = appState.connectedDevice { - ContactQRShareSheet( - contactName: device.nodeName, - publicKey: device.publicKey, - contactType: .chat - ) - } + } + } + .refreshable { + if appState.connectionState != .ready { + showOfflineRefreshAlert = true + } else { + // SwiftUI cancels a ScrollView's `.refreshable` task when observed state mutates + // mid-refresh (syncContacts sets `isSyncing`, re-evaluating this view), aborting the + // sync within a frame. Running it in a detached task shields it from that cancellation; + // awaiting the value keeps the refresh spinner up until the sync finishes. + await Task { await onSyncContacts() }.value + } + } + .alert(L10n.Contacts.Contacts.List.cannotRefresh, isPresented: $showOfflineRefreshAlert) { + Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) {} + } message: { + Text(L10n.Contacts.Contacts.List.connectToSync) + } + .sensoryFeedback(.success, trigger: syncSuccessTrigger) + .task { + sidebarLogger.info("NodesListView: task started, services=\(appState.services != nil)") + viewModel.configure( + dataStore: { [appState] in appState.offlineDataStore }, + contactService: { [appState] in appState.services?.contactService }, + advertisementService: { [appState] in appState.services?.advertisementService } + ) + await onLoadContacts() + sidebarLogger.info("NodesListView: loaded, contacts=\(viewModel.contacts.count)") + onAnnounceOfflineStateIfNeeded() + + // Request location for distance display (only if already authorized) + if appState.locationService.isAuthorized { + appState.locationService.requestLocation() + } + } + .task(id: sortOrder) { + if sortOrder == .distance { + if appState.locationService.isAuthorized { + appState.locationService.requestLocation() + } else if appState.locationService.isLocationDenied { + showLocationDeniedAlert = true + } else { + appState.locationService.requestPermissionIfNeeded() } - .sheet(isPresented: $showAddContact) { - AddContactSheet() + } + } + .onChange(of: appState.servicesVersion) { _, _ in + Task { + await onLoadContacts() + } + } + .onChange(of: appState.contactsVersion) { _, _ in + Task { + await onLoadContacts() + } + } + .onChange(of: appState.navigation.pendingDiscoveryNavigation, initial: true) { _, shouldNavigate in + if shouldNavigate { + showDiscovery = true + appState.navigation.clearPendingDiscoveryNavigation() + } + } + .onChange(of: appState.navigation.pendingContactDetail, initial: true) { _, contact in + guard let contact else { return } + + if shouldUseSplitView { + selectedContact = contact + } else { + navigationPath.removeLast(navigationPath.count) + navigationPath.append(ContactRoute.detail(contact)) + } + + appState.navigation.clearPendingContactDetailNavigation() + } + .onChange(of: appState.locationService.authorizationStatus) { _, status in + if sortOrder == .distance { + switch status { + case .authorizedWhenInUse, .authorizedAlways: + appState.locationService.requestLocation() + case .denied, .restricted: + showLocationDeniedAlert = true + default: + break } - .alert(L10n.Contacts.Contacts.List.locationUnavailable, isPresented: $showLocationDeniedAlert) { - Button(L10n.Contacts.Contacts.List.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - } message: { - Text(L10n.Contacts.Contacts.List.distanceRequiresLocation) + } + } + .sheet(isPresented: $showShareMyContact) { + if let device = appState.connectedDevice { + ContactQRShareSheet( + contactName: device.nodeName, + publicKey: device.publicKey, + contactType: .chat + ) + } + } + .sheet(isPresented: $showAddContact) { + AddContactSheet() + } + .alert(L10n.Contacts.Contacts.List.locationUnavailable, isPresented: $showLocationDeniedAlert) { + Button(L10n.Contacts.Contacts.List.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) } - .errorAlert($viewModel.errorMessage, title: L10n.Contacts.Contacts.Common.error) + } + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + } message: { + Text(L10n.Contacts.Contacts.List.distanceRequiresLocation) } + .errorAlert($viewModel.errorMessage, title: L10n.Contacts.Contacts.Common.error) + } } diff --git a/MC1/Views/Contacts/ContactsViewModel.swift b/MC1/Views/Contacts/ContactsViewModel.swift index 1b399fe6..9ca7b9f5 100644 --- a/MC1/Views/Contacts/ContactsViewModel.swift +++ b/MC1/Views/Contacts/ContactsViewModel.swift @@ -1,386 +1,392 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// Segment for the nodes picker enum NodeSegment: String, CaseIterable { - case favorites - case contacts - case repeaters - case rooms - - var localizedTitle: String { - switch self { - case .favorites: L10n.Contacts.Contacts.Segment.favorites - case .contacts: L10n.Contacts.Contacts.Segment.contacts - case .repeaters: L10n.Contacts.Contacts.Segment.repeaters - case .rooms: L10n.Contacts.Contacts.Segment.rooms - } + case favorites + case contacts + case repeaters + case rooms + + var localizedTitle: String { + switch self { + case .favorites: L10n.Contacts.Contacts.Segment.favorites + case .contacts: L10n.Contacts.Contacts.Segment.contacts + case .repeaters: L10n.Contacts.Contacts.Segment.repeaters + case .rooms: L10n.Contacts.Contacts.Segment.rooms } + } } /// Sort order for nodes list enum NodeSortOrder: String, CaseIterable { - case lastHeard - case name - case distance - case hops - - var localizedTitle: String { - switch self { - case .lastHeard: L10n.Contacts.Contacts.Sort.lastHeard - case .name: L10n.Contacts.Contacts.Sort.name - case .distance: L10n.Contacts.Contacts.Sort.distance - case .hops: L10n.Contacts.Contacts.Sort.hops - } + case lastHeard + case name + case distance + case hops + + var localizedTitle: String { + switch self { + case .lastHeard: L10n.Contacts.Contacts.Sort.lastHeard + case .name: L10n.Contacts.Contacts.Sort.name + case .distance: L10n.Contacts.Contacts.Sort.distance + case .hops: L10n.Contacts.Contacts.Sort.hops } + } } /// ViewModel for contact management @Observable @MainActor final class ContactsViewModel { + // MARK: - Properties - // MARK: - Properties + /// All contacts + var contacts: [ContactDTO] = [] - /// All contacts - var contacts: [ContactDTO] = [] + /// Inbound advert hop count per contact public key, sourced from the volatile discovered-node + /// table and refreshed on each load. A contact's "Hops" falls back to this when it has no + /// out-path; absence (evicted or never heard via advert) just leaves the fallback unavailable. + var inboundHopByKey: [Data: Int] = [:] - /// Inbound advert hop count per contact public key, sourced from the volatile discovered-node - /// table and refreshed on each load. A contact's "Hops" falls back to this when it has no - /// out-path; absence (evicted or never heard via advert) just leaves the fallback unavailable. - var inboundHopByKey: [Data: Int] = [:] + /// Loading state + var isLoading = false - /// Loading state - var isLoading = false + /// Whether data has been loaded at least once (prevents empty state flash) + var hasLoadedOnce = false - /// Whether data has been loaded at least once (prevents empty state flash) - var hasLoadedOnce = false + /// Syncing state + var isSyncing = false - /// Syncing state - var isSyncing = false + /// Sync progress (current, total) + var syncProgress: (Int, Int)? - /// Sync progress (current, total) - var syncProgress: (Int, Int)? + /// Error message if any + var errorMessage: String? - /// Error message if any - var errorMessage: String? + /// User's current location for distance sorting (optional) + var userLocation: CLLocation? - /// User's current location for distance sorting (optional) - var userLocation: CLLocation? + /// Contact ID currently having its favorite status toggled (for loading UI) + var togglingFavoriteID: UUID? - /// Contact ID currently having its favorite status toggled (for loading UI) - var togglingFavoriteID: UUID? + /// Mask of rows hidden after a confirmed delete, held until a reload sees the row gone so a + /// racing reload can't resurrect it. Observed because `filteredContacts` reads it during body + /// evaluation. Distinct from `deletingIDs`, the in-flight presentation set. + var pendingRemovalIDs: Set = [] - /// Mask of rows hidden after a confirmed delete, held until a reload sees the row gone so a - /// racing reload can't resurrect it. Observed because `filteredContacts` reads it during body - /// evaluation. Distinct from `deletingIDs`, the in-flight presentation set. - var pendingRemovalIDs: Set = [] + /// Rows with a delete command in flight, surfaced as a spinner. Distinct from + /// `pendingRemovalIDs`, the post-confirmation mask. + var deletingIDs: Set = [] - /// Rows with a delete command in flight, surfaced as a spinner. Distinct from - /// `pendingRemovalIDs`, the post-confirmation mask. - var deletingIDs: Set = [] + /// True while a delete for this row is either confirmed-but-unreloaded or in flight. + func isDeletePending(_ id: UUID) -> Bool { + pendingRemovalIDs.contains(id) || deletingIDs.contains(id) + } - /// True while a delete for this row is either confirmed-but-unreloaded or in flight. - func isDeletePending(_ id: UUID) -> Bool { - pendingRemovalIDs.contains(id) || deletingIDs.contains(id) - } + // MARK: - Dependencies - // MARK: - Dependencies + private var dataStoreProvider: @MainActor () -> DataStore? = { nil } + private var contactServiceProvider: @MainActor () -> ContactService? = { nil } + private var advertisementServiceProvider: @MainActor () -> AdvertisementService? = { nil } - private var dataStoreProvider: @MainActor () -> DataStore? = { nil } - private var contactServiceProvider: @MainActor () -> ContactService? = { nil } - private var advertisementServiceProvider: @MainActor () -> AdvertisementService? = { nil } + private var dataStore: DataStore? { + dataStoreProvider() + } - private var dataStore: DataStore? { dataStoreProvider() } - private var contactService: ContactService? { contactServiceProvider() } - private var advertisementService: AdvertisementService? { advertisementServiceProvider() } + private var contactService: ContactService? { + contactServiceProvider() + } - // MARK: - Initialization + private var advertisementService: AdvertisementService? { + advertisementServiceProvider() + } - init() {} + // MARK: - Initialization - /// Configure with the services this view model uses; a provider returning nil mirrors a disconnected state. - func configure( - dataStore: @escaping @MainActor () -> DataStore?, - contactService: @escaping @MainActor () -> ContactService?, - advertisementService: @escaping @MainActor () -> AdvertisementService? - ) { - dataStoreProvider = dataStore - contactServiceProvider = contactService - advertisementServiceProvider = advertisementService - } - - // MARK: - Load Contacts + init() {} - /// Load contacts from local database - func loadContacts(radioID: UUID) async { - guard let dataStore else { return } + /// Configure with the services this view model uses; a provider returning nil mirrors a disconnected state. + func configure( + dataStore: @escaping @MainActor () -> DataStore?, + contactService: @escaping @MainActor () -> ContactService?, + advertisementService: @escaping @MainActor () -> AdvertisementService? + ) { + dataStoreProvider = dataStore + contactServiceProvider = contactService + advertisementServiceProvider = advertisementService + } - isLoading = true - errorMessage = nil + // MARK: - Load Contacts - do { - contacts = try await dataStore.fetchContacts(radioID: radioID) - // Self-heal the mask: once a deleted row is gone from the fetch, stop masking it. - pendingRemovalIDs.formIntersection(Set(contacts.map(\.id))) - } catch { - errorMessage = error.userFacingMessage - } + /// Load contacts from local database + func loadContacts(radioID: UUID) async { + guard let dataStore else { return } - // Best-effort inbound-hop fallback from the volatile discovered-node table; a failure here - // must not fail the contact load, so it is fetched outside the throwing load path. - inboundHopByKey = (try? await dataStore.fetchDiscoveredNodes(radioID: radioID))? - .reduce(into: [:]) { map, node in - if let inbound = node.inboundHopCount { map[node.publicKey] = inbound } - } ?? [:] + isLoading = true + errorMessage = nil - hasLoadedOnce = true - isLoading = false + do { + contacts = try await dataStore.fetchContacts(radioID: radioID) + // Self-heal the mask: once a deleted row is gone from the fetch, stop masking it. + pendingRemovalIDs.formIntersection(Set(contacts.map(\.id))) + } catch { + errorMessage = error.userFacingMessage } - // MARK: - Sync Contacts - - /// Sync contacts from device - func syncContacts(radioID: UUID) async { - guard let contactService else { return } - // Claim the sync synchronously so a re-trigger while one is in flight is a no-op. - guard !isSyncing else { return } + // Best-effort inbound-hop fallback from the volatile discovered-node table; a failure here + // must not fail the contact load, so it is fetched outside the throwing load path. + inboundHopByKey = await (try? dataStore.fetchDiscoveredNodes(radioID: radioID))? + .reduce(into: [:]) { map, node in + if let inbound = node.inboundHopCount { map[node.publicKey] = inbound } + } ?? [:] - isSyncing = true - syncProgress = nil - errorMessage = nil + hasLoadedOnce = true + isLoading = false + } - if let advertisementService { - await advertisementService.setSyncingContacts(true) - } - - // Subscribed synchronously before the sync starts so no progress event - // is missed; scoped to this sync and cancelled when it completes. - let events = contactService.events() - let progressTask = Task { [weak self] in - for await event in events { - if case .syncProgress(let received, let total) = event { - self?.syncProgress = (received, total) - } - } - } - defer { progressTask.cancel() } + // MARK: - Sync Contacts - do { - _ = try await contactService.syncContacts(radioID: radioID) + /// Sync contacts from device + func syncContacts(radioID: UUID) async { + guard let contactService else { return } + // Claim the sync synchronously so a re-trigger while one is in flight is a no-op. + guard !isSyncing else { return } - // Reload from database - await loadContacts(radioID: radioID) + isSyncing = true + syncProgress = nil + errorMessage = nil - // Clear sync progress - syncProgress = nil - } catch is CancellationError { - // Pull-to-refresh interrupted (tab switch, view teardown); not a failure to report. - } catch { - errorMessage = error.userFacingMessage - } + if let advertisementService { + await advertisementService.setSyncingContacts(true) + } - // Awaited rather than deferred to an unstructured Task so this sync's reset cannot - // land after a later sync's setSyncingContacts(true) and clear the flag mid-sync. - if let advertisementService { - await advertisementService.setSyncingContacts(false) + // Subscribed synchronously before the sync starts so no progress event + // is missed; scoped to this sync and cancelled when it completes. + let events = contactService.events() + let progressTask = Task { [weak self] in + for await event in events { + if case let .syncProgress(received, total) = event { + self?.syncProgress = (received, total) } - - isSyncing = false + } } + defer { progressTask.cancel() } - // MARK: - Contact Actions - - /// Toggle favorite status on device and update local state - func toggleFavorite(contact: ContactDTO) async { - guard let contactService else { return } + do { + _ = try await contactService.syncContacts(radioID: radioID) - togglingFavoriteID = contact.id - defer { togglingFavoriteID = nil } + // Reload from database + await loadContacts(radioID: radioID) - do { - try await contactService.setContactFavorite(contact.id, isFavorite: !contact.isFavorite) + // Clear sync progress + syncProgress = nil + } catch is CancellationError { + // Pull-to-refresh interrupted (tab switch, view teardown); not a failure to report. + } catch { + errorMessage = error.userFacingMessage + } - // Reload to get updated state - if contacts.contains(where: { $0.id == contact.id }) { - await loadContacts(radioID: contact.radioID) - } - } catch { - errorMessage = error.userFacingMessage - } + // Awaited rather than deferred to an unstructured Task so this sync's reset cannot + // land after a later sync's setSyncingContacts(true) and clear the flag mid-sync. + if let advertisementService { + await advertisementService.setSyncingContacts(false) } - /// Toggle blocked status - func toggleBlocked(contact: ContactDTO) async { - guard let contactService else { return } + isSyncing = false + } - do { - try await contactService.updateContactPreferences( - contactID: contact.id, - isBlocked: !contact.isBlocked - ) + // MARK: - Contact Actions - // Update local list - await loadContacts(radioID: contact.radioID) - } catch { - errorMessage = error.userFacingMessage - } - } + /// Toggle favorite status on device and update local state + func toggleFavorite(contact: ContactDTO) async { + guard let contactService else { return } - /// Update nickname - func updateNickname(contact: ContactDTO, nickname: String?) async { - guard let contactService else { return } + togglingFavoriteID = contact.id + defer { togglingFavoriteID = nil } - do { - try await contactService.updateContactPreferences( - contactID: contact.id, - nickname: nickname?.isEmpty == true ? nil : nickname - ) + do { + try await contactService.setContactFavorite(contact.id, isFavorite: !contact.isFavorite) - // Update local list - await loadContacts(radioID: contact.radioID) - } catch { - errorMessage = error.userFacingMessage - } + // Reload to get updated state + if contacts.contains(where: { $0.id == contact.id }) { + await loadContacts(radioID: contact.radioID) + } + } catch { + errorMessage = error.userFacingMessage } - - /// Delete a contact. Removing a node is a real radio command, so the row stays in place with a - /// spinner until the radio acks, then is hidden once; a failure or timeout leaves it untouched - /// with an error rather than bouncing it out and back. - func deleteContact(_ contact: ContactDTO) async { - guard let contactService else { - errorMessage = L10n.Contacts.Contacts.ViewModel.connectToDelete - return - } - guard !isDeletePending(contact.id) else { return } - - deletingIDs.insert(contact.id) - defer { deletingIDs.remove(contact.id) } - - do { - try await withTimeout(RadioCommandTimeout.delete, operationName: "removeContact") { - try await contactService.removeContact( - radioID: contact.radioID, - publicKey: contact.publicKey - ) - } - hideDeletedContact(contact) - } catch ContactServiceError.contactNotFound { - // The radio no longer knows this contact (e.g. a prior attempt's command landed but its - // local delete was interrupted). Clear the orphaned local row so it stops reappearing. - do { - try await contactService.removeLocalContact(contactID: contact.id, publicKey: contact.publicKey) - hideDeletedContact(contact) - } catch { - errorMessage = error.userFacingMessage - } - } catch is TimeoutError { - errorMessage = L10n.Contacts.Contacts.ViewModel.removeTimedOut - } catch { - errorMessage = error.userFacingMessage - } + } + + /// Toggle blocked status + func toggleBlocked(contact: ContactDTO) async { + guard let contactService else { return } + + do { + try await contactService.updateContactPreferences( + contactID: contact.id, + isBlocked: !contact.isBlocked + ) + + // Update local list + await loadContacts(radioID: contact.radioID) + } catch { + errorMessage = error.userFacingMessage } - - /// Masks and removes a row whose deletion the radio confirmed, in one animation. - /// The mask insert is observed (read live by `filteredContacts`), so it must land inside - /// the same transaction as the array removal or it hides the row unanimated first. - private func hideDeletedContact(_ contact: ContactDTO) { - withAnimation(.snappy) { - pendingRemovalIDs.insert(contact.id) - contacts.removeAll { $0.id == contact.id } - } + } + + /// Update nickname + func updateNickname(contact: ContactDTO, nickname: String?) async { + guard let contactService else { return } + + do { + try await contactService.updateContactPreferences( + contactID: contact.id, + nickname: nickname?.isEmpty == true ? nil : nickname + ) + + // Update local list + await loadContacts(radioID: contact.radioID) + } catch { + errorMessage = error.userFacingMessage } - - // MARK: - Filtering - - /// Returns contacts filtered by segment and sorted - func filteredContacts( - searchText: String, - segment: NodeSegment, - sortOrder: NodeSortOrder, - userLocation: CLLocation? - ) -> [ContactDTO] { - var result = contacts.filter { !pendingRemovalIDs.contains($0.id) } - - // If searching, show all types (ignore segment) - if searchText.isEmpty { - // Filter by segment - switch segment { - case .favorites: - result = result.filter(\.isFavorite) - case .contacts: - result = result.filter { $0.type == .chat } - case .repeaters: - result = result.filter { $0.type == .repeater } - case .rooms: - result = result.filter { $0.type == .room } - } - } else { - // Filter by search text only - result = result.filter { contact in - contact.displayName.localizedStandardContains(searchText) - || contact.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) - } - } - - // Sort - result = sorted(result, by: sortOrder, userLocation: userLocation) - - return result + } + + /// Delete a contact. Removing a node is a real radio command, so the row stays in place with a + /// spinner until the radio acks, then is hidden once; a failure or timeout leaves it untouched + /// with an error rather than bouncing it out and back. + func deleteContact(_ contact: ContactDTO) async { + guard let contactService else { + errorMessage = L10n.Contacts.Contacts.ViewModel.connectToDelete + return + } + guard !isDeletePending(contact.id) else { return } + + deletingIDs.insert(contact.id) + defer { deletingIDs.remove(contact.id) } + + do { + try await withTimeout(RadioCommandTimeout.delete, operationName: "removeContact") { + try await contactService.removeContact( + radioID: contact.radioID, + publicKey: contact.publicKey + ) + } + hideDeletedContact(contact) + } catch ContactServiceError.contactNotFound { + // The radio no longer knows this contact (e.g. a prior attempt's command landed but its + // local delete was interrupted). Clear the orphaned local row so it stops reappearing. + do { + try await contactService.removeLocalContact(contactID: contact.id, publicKey: contact.publicKey) + hideDeletedContact(contact) + } catch { + errorMessage = error.userFacingMessage + } + } catch is TimeoutError { + errorMessage = L10n.Contacts.Contacts.ViewModel.removeTimedOut + } catch { + errorMessage = error.userFacingMessage + } + } + + /// Masks and removes a row whose deletion the radio confirmed, in one animation. + /// The mask insert is observed (read live by `filteredContacts`), so it must land inside + /// the same transaction as the array removal or it hides the row unanimated first. + private func hideDeletedContact(_ contact: ContactDTO) { + withAnimation(.snappy) { + pendingRemovalIDs.insert(contact.id) + contacts.removeAll { $0.id == contact.id } + } + } + + // MARK: - Filtering + + /// Returns contacts filtered by segment and sorted + func filteredContacts( + searchText: String, + segment: NodeSegment, + sortOrder: NodeSortOrder, + userLocation: CLLocation? + ) -> [ContactDTO] { + var result = contacts.filter { !pendingRemovalIDs.contains($0.id) } + + // If searching, show all types (ignore segment) + if searchText.isEmpty { + // Filter by segment + switch segment { + case .favorites: + result = result.filter(\.isFavorite) + case .contacts: + result = result.filter { $0.type == .chat } + case .repeaters: + result = result.filter { $0.type == .repeater } + case .rooms: + result = result.filter { $0.type == .room } + } + } else { + // Filter by search text only + result = result.filter { contact in + contact.displayName.localizedStandardContains(searchText) + || contact.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) + } } - /// Sort contacts by the given order - private func sorted( - _ contacts: [ContactDTO], - by order: NodeSortOrder, - userLocation: CLLocation? - ) -> [ContactDTO] { - switch order { - case .lastHeard: - return contacts.sorted { $0.lastModified > $1.lastModified } - case .name: - return contacts.sorted { - $0.displayName.localizedCompare($1.displayName) == .orderedAscending - } - case .distance: - return contacts.sorted { orderedByDistanceThenName($0, $1, from: userLocation) } - case .hops: - return contacts.sorted { lhs, rhs in - let lhsHops = lhs.displayedHopCount(inboundHopCount: inboundHopByKey[lhs.publicKey]) - let rhsHops = rhs.displayedHopCount(inboundHopCount: inboundHopByKey[rhs.publicKey]) - // A nil hop count (flood-routed and never heard via advert) sorts to the bottom. - if (lhsHops == nil) != (rhsHops == nil) { - return lhsHops != nil - } - if let lhsHops, let rhsHops, lhsHops != rhsHops { - return lhsHops < rhsHops - } - return orderedByDistanceThenName(lhs, rhs, from: userLocation) - } + // Sort + result = sorted(result, by: sortOrder, userLocation: userLocation) + + return result + } + + /// Sort contacts by the given order + private func sorted( + _ contacts: [ContactDTO], + by order: NodeSortOrder, + userLocation: CLLocation? + ) -> [ContactDTO] { + switch order { + case .lastHeard: + contacts.sorted { $0.lastModified > $1.lastModified } + case .name: + contacts.sorted { + $0.displayName.localizedCompare($1.displayName) == .orderedAscending + } + case .distance: + contacts.sorted { orderedByDistanceThenName($0, $1, from: userLocation) } + case .hops: + contacts.sorted { lhs, rhs in + let lhsHops = lhs.displayedHopCount(inboundHopCount: inboundHopByKey[lhs.publicKey]) + let rhsHops = rhs.displayedHopCount(inboundHopCount: inboundHopByKey[rhs.publicKey]) + // A nil hop count (flood-routed and never heard via advert) sorts to the bottom. + if (lhsHops == nil) != (rhsHops == nil) { + return lhsHops != nil + } + if let lhsHops, let rhsHops, lhsHops != rhsHops { + return lhsHops < rhsHops } + return orderedByDistanceThenName(lhs, rhs, from: userLocation) + } } - - /// Located nodes first, then nearest to `userLocation`, then by name. Falls back to name when - /// there is no user location, neither node has coordinates, or the distances tie. - private func orderedByDistanceThenName( - _ lhs: ContactDTO, - _ rhs: ContactDTO, - from userLocation: CLLocation? - ) -> Bool { - if let userLocation { - if lhs.hasLocation != rhs.hasLocation { - return lhs.hasLocation - } - if lhs.hasLocation { - let lhsDistance = CLLocation(latitude: lhs.latitude, longitude: lhs.longitude).distance(from: userLocation) - let rhsDistance = CLLocation(latitude: rhs.latitude, longitude: rhs.longitude).distance(from: userLocation) - if lhsDistance != rhsDistance { - return lhsDistance < rhsDistance - } - } + } + + /// Located nodes first, then nearest to `userLocation`, then by name. Falls back to name when + /// there is no user location, neither node has coordinates, or the distances tie. + private func orderedByDistanceThenName( + _ lhs: ContactDTO, + _ rhs: ContactDTO, + from userLocation: CLLocation? + ) -> Bool { + if let userLocation { + if lhs.hasLocation != rhs.hasLocation { + return lhs.hasLocation + } + if lhs.hasLocation { + let lhsDistance = CLLocation(latitude: lhs.latitude, longitude: lhs.longitude).distance(from: userLocation) + let rhsDistance = CLLocation(latitude: rhs.latitude, longitude: rhs.longitude).distance(from: userLocation) + if lhsDistance != rhsDistance { + return lhsDistance < rhsDistance } - return lhs.displayName.localizedCompare(rhs.displayName) == .orderedAscending + } } - + return lhs.displayName.localizedCompare(rhs.displayName) == .orderedAscending + } } diff --git a/MC1/Views/Contacts/DiscoverSegmentPicker.swift b/MC1/Views/Contacts/DiscoverSegmentPicker.swift index ceacb81e..23c1345c 100644 --- a/MC1/Views/Contacts/DiscoverSegmentPicker.swift +++ b/MC1/Views/Contacts/DiscoverSegmentPicker.swift @@ -2,15 +2,15 @@ import SwiftUI /// Pinned glass filter bar for the Discovery sub-view. struct DiscoverSegmentPicker: View { - @Binding var selection: DiscoverSegment - let isSearching: Bool + @Binding var selection: DiscoverSegment + let isSearching: Bool - var body: some View { - GlassFilterBar( - selection: $selection, - isSearching: isSearching, - pickerLabel: L10n.Contacts.Contacts.Discovery.Segment.pickerLabel, - title: { $0.localizedTitle } - ) - } + var body: some View { + GlassFilterBar( + selection: $selection, + isSearching: isSearching, + pickerLabel: L10n.Contacts.Contacts.Discovery.Segment.pickerLabel, + title: { $0.localizedTitle } + ) + } } diff --git a/MC1/Views/Contacts/DiscoveryView.swift b/MC1/Views/Contacts/DiscoveryView.swift index 6c709f8d..e8cc4647 100644 --- a/MC1/Views/Contacts/DiscoveryView.swift +++ b/MC1/Views/Contacts/DiscoveryView.swift @@ -1,438 +1,437 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// Shows contacts discovered via advertisement that haven't been added to the device struct DiscoveryView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var viewModel = DiscoveryViewModel() - @State private var searchText = "" - @State private var selectedSegment: DiscoverSegment = .all - @AppStorage(AppStorageKey.discoverySortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard - @State private var addingNodeID: UUID? - @State private var showClearConfirmation = false - - private var filteredNodes: [DiscoveredNodeDTO] { - let effectiveSortOrder = (sortOrder == .distance && appState.bestAvailableLocation == nil) - ? .lastHeard - : sortOrder - - return viewModel.filteredNodes( - searchText: searchText, - segment: selectedSegment, - sortOrder: effectiveSortOrder, - userLocation: appState.bestAvailableLocation + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var viewModel = DiscoveryViewModel() + @State private var searchText = "" + @State private var selectedSegment: DiscoverSegment = .all + @AppStorage(AppStorageKey.discoverySortOrder.rawValue) private var sortOrder: NodeSortOrder = .lastHeard + @State private var addingNodeID: UUID? + @State private var showClearConfirmation = false + + private var filteredNodes: [DiscoveredNodeDTO] { + let effectiveSortOrder = (sortOrder == .distance && appState.bestAvailableLocation == nil) + ? .lastHeard + : sortOrder + + return viewModel.filteredNodes( + searchText: searchText, + segment: selectedSegment, + sortOrder: effectiveSortOrder, + userLocation: appState.bestAvailableLocation + ) + } + + private var isSearching: Bool { + !searchText.isEmpty + } + + /// Segment picker as the pinned section header; `pinnedFilterHeaderBackground` documents the + /// per-OS backing. + private var pinnedFilterHeader: some View { + DiscoverSegmentPicker(selection: $selectedSegment, isSearching: isSearching) + .frame(maxWidth: .infinity) + .pinnedFilterHeaderBackground(theme) + } + + private var emptyState: some View { + Group { + if isSearching { + DiscoverySearchEmptyView(searchText: searchText) + } else { + DiscoveryEmptyView() + } + } + .containerRelativeFrame([.horizontal, .vertical]) + } + + var body: some View { + Group { + if !viewModel.hasLoadedOnce { + loadingBody + } else { + loadedBody + } + } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.Discovery.title) + .toolbar { + ToolbarItem(placement: .automatic) { + DiscoverySortMenu(sortOrder: $sortOrder) + } + + ToolbarItem(placement: .automatic) { + DiscoveryMoreMenu( + isEmpty: viewModel.discoveredNodes.isEmpty, + showClearConfirmation: $showClearConfirmation ) + } } - - private var isSearching: Bool { - !searchText.isEmpty + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: L10n.Contacts.Contacts.Discovery.searchPrompt + ) + .onChange(of: searchText) { oldValue, newValue in + if oldValue.isEmpty, !newValue.isEmpty { + AccessibilityNotification.Announcement(L10n.Contacts.Contacts.Discovery.searchingAllTypes).post() + } } - - /// Segment picker as the pinned section header; `pinnedFilterHeaderBackground` documents the - /// per-OS backing. - private var pinnedFilterHeader: some View { - DiscoverSegmentPicker(selection: $selectedSegment, isSearching: isSearching) - .frame(maxWidth: .infinity) - .pinnedFilterHeaderBackground(theme) + .task { + viewModel.configure(dataStore: { [appState] in appState.offlineDataStore }) + await loadDiscoveredNodes() } - - @ViewBuilder - private var emptyState: some View { - Group { - if isSearching { - DiscoverySearchEmptyView(searchText: searchText) - } else { - DiscoveryEmptyView() - } - } - .containerRelativeFrame([.horizontal, .vertical]) + .onChange(of: appState.servicesVersion) { _, _ in + Task { + await loadDiscoveredNodes() + } } - - var body: some View { - Group { - if !viewModel.hasLoadedOnce { - loadingBody - } else { - loadedBody - } - } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.Discovery.title) - .toolbar { - ToolbarItem(placement: .automatic) { - DiscoverySortMenu(sortOrder: $sortOrder) - } - - ToolbarItem(placement: .automatic) { - DiscoveryMoreMenu( - isEmpty: viewModel.discoveredNodes.isEmpty, - showClearConfirmation: $showClearConfirmation - ) - } - } - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: L10n.Contacts.Contacts.Discovery.searchPrompt - ) - .onChange(of: searchText) { oldValue, newValue in - if oldValue.isEmpty, !newValue.isEmpty { - AccessibilityNotification.Announcement(L10n.Contacts.Contacts.Discovery.searchingAllTypes).post() - } - } - .task { - viewModel.configure(dataStore: { [appState] in appState.offlineDataStore }) - await loadDiscoveredNodes() - } - .onChange(of: appState.servicesVersion) { _, _ in - Task { - await loadDiscoveredNodes() - } - } - .onChange(of: appState.contactsVersion) { _, _ in - Task { - await loadDiscoveredNodes() - } - } - .errorAlert($viewModel.errorMessage, title: L10n.Contacts.Contacts.Common.error) - .confirmationDialog( - L10n.Contacts.Contacts.Discovery.Clear.title, - isPresented: $showClearConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Discovery.Clear.confirm, role: .destructive) { - Task { - await clearAllDiscoveredNodes() - } - } - } message: { - Text(L10n.Contacts.Contacts.Discovery.Clear.message) + .onChange(of: appState.contactsVersion) { _, _ in + Task { + await loadDiscoveredNodes() + } + } + .errorAlert($viewModel.errorMessage, title: L10n.Contacts.Contacts.Common.error) + .confirmationDialog( + L10n.Contacts.Contacts.Discovery.Clear.title, + isPresented: $showClearConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Discovery.Clear.confirm, role: .destructive) { + Task { + await clearAllDiscoveredNodes() } + } + } message: { + Text(L10n.Contacts.Contacts.Discovery.Clear.message) } + } - /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. - private static let rowSeparatorLeadingInset: CGFloat = 72 + /// Leading inset for the inter-row divider, aligning it under the row text past the avatar. + private static let rowSeparatorLeadingInset: CGFloat = 72 - private var loadingBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { } header: { pinnedFilterHeader } - } - } - .overlay { ProgressView() } + private var loadingBody: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section {} header: { pinnedFilterHeader } + } } - - private var loadedBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - if filteredNodes.isEmpty { - emptyState - } else { - rows - } - } header: { - pinnedFilterHeader - } - } + .overlay { ProgressView() } + } + + private var loadedBody: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + if filteredNodes.isEmpty { + emptyState + } else { + rows + } + } header: { + pinnedFilterHeader } + } } - - private var rows: some View { - ForEach(Array(filteredNodes.enumerated()), id: \.element.id) { index, node in - DiscoveryNodeRow( - node: node, - isAdded: viewModel.isAdded(node), - isAdding: addingNodeID == node.id, - onAdd: { addNode(node) }, - onDelete: { - Task { - await viewModel.deleteDiscoveredNode(node) - } - } - ) - .transition(.opacity) - if index < filteredNodes.count - 1 { - Divider().padding(.leading, Self.rowSeparatorLeadingInset) - } + } + + private var rows: some View { + ForEach(Array(filteredNodes.enumerated()), id: \.element.id) { index, node in + DiscoveryNodeRow( + node: node, + isAdded: viewModel.isAdded(node), + isAdding: addingNodeID == node.id, + onAdd: { addNode(node) }, + onDelete: { + Task { + await viewModel.deleteDiscoveredNode(node) + } } + ) + .transition(.opacity) + if index < filteredNodes.count - 1 { + Divider().padding(.leading, Self.rowSeparatorLeadingInset) + } } - - private func loadDiscoveredNodes() async { - guard let radioID = appState.connectedDevice?.radioID else { return } - viewModel.configure(dataStore: { [appState] in appState.offlineDataStore }) - await viewModel.loadDiscoveredNodes(radioID: radioID) - } - - private func addNode(_ node: DiscoveredNodeDTO) { - guard let contactService = appState.services?.contactService else { return } - - addingNodeID = node.id - Task { - do { - let frame = ContactFrame( - publicKey: node.publicKey, - type: node.nodeType, - flags: 0, - outPathLength: node.outPathLength, - outPath: node.outPath, - name: node.name, - lastAdvertTimestamp: node.lastAdvertTimestamp, - latitude: node.latitude, - longitude: node.longitude, - lastModified: UInt32(Date().timeIntervalSince1970) - ) - try await contactService.addOrUpdateContact(radioID: node.radioID, contact: frame) - await viewModel.loadDiscoveredNodes(radioID: node.radioID) - } catch ContactServiceError.contactTableFull { - let maxContacts = appState.connectedDevice?.maxContacts - if let maxContacts { - viewModel.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) - } else { - viewModel.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFullSimple - } - } catch { - viewModel.errorMessage = error.userFacingMessage - } - addingNodeID = nil + } + + private func loadDiscoveredNodes() async { + guard let radioID = appState.connectedDevice?.radioID else { return } + viewModel.configure(dataStore: { [appState] in appState.offlineDataStore }) + await viewModel.loadDiscoveredNodes(radioID: radioID) + } + + private func addNode(_ node: DiscoveredNodeDTO) { + guard let contactService = appState.services?.contactService else { return } + + addingNodeID = node.id + Task { + do { + let frame = ContactFrame( + publicKey: node.publicKey, + type: node.nodeType, + flags: 0, + outPathLength: node.outPathLength, + outPath: node.outPath, + name: node.name, + lastAdvertTimestamp: node.lastAdvertTimestamp, + latitude: node.latitude, + longitude: node.longitude, + lastModified: UInt32(Date().timeIntervalSince1970) + ) + try await contactService.addOrUpdateContact(radioID: node.radioID, contact: frame) + await viewModel.loadDiscoveredNodes(radioID: node.radioID) + } catch ContactServiceError.contactTableFull { + let maxContacts = appState.connectedDevice?.maxContacts + if let maxContacts { + viewModel.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) + } else { + viewModel.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFullSimple } + } catch { + viewModel.errorMessage = error.userFacingMessage + } + addingNodeID = nil } + } - private func clearAllDiscoveredNodes() async { - guard let radioID = appState.connectedDevice?.radioID else { return } - await viewModel.clearAllDiscoveredNodes(radioID: radioID) + private func clearAllDiscoveredNodes() async { + guard let radioID = appState.connectedDevice?.radioID else { return } + await viewModel.clearAllDiscoveredNodes(radioID: radioID) - AccessibilityNotification.Announcement(L10n.Contacts.Contacts.Discovery.clearedAllNodes).post() - } + AccessibilityNotification.Announcement(L10n.Contacts.Contacts.Discovery.clearedAllNodes).post() + } } // MARK: - Empty View private struct DiscoveryEmptyView: View { - var body: some View { - ContentUnavailableView( - L10n.Contacts.Contacts.Discovery.Empty.title, - systemImage: "antenna.radiowaves.left.and.right", - description: Text(L10n.Contacts.Contacts.Discovery.Empty.description) - ) - } + var body: some View { + ContentUnavailableView( + L10n.Contacts.Contacts.Discovery.Empty.title, + systemImage: "antenna.radiowaves.left.and.right", + description: Text(L10n.Contacts.Contacts.Discovery.Empty.description) + ) + } } // MARK: - Search Empty View private struct DiscoverySearchEmptyView: View { - let searchText: String - - var body: some View { - ContentUnavailableView( - L10n.Contacts.Contacts.Discovery.Empty.Search.title, - systemImage: "magnifyingglass", - description: Text(L10n.Contacts.Contacts.Discovery.Empty.Search.description(searchText)) - ) - } + let searchText: String + + var body: some View { + ContentUnavailableView( + L10n.Contacts.Contacts.Discovery.Empty.Search.title, + systemImage: "magnifyingglass", + description: Text(L10n.Contacts.Contacts.Discovery.Empty.Search.description(searchText)) + ) + } } // MARK: - Row Layout private enum DiscoveryListLayout { - static let rowHorizontalPadding: CGFloat = 16 + static let rowHorizontalPadding: CGFloat = 16 } // MARK: - Node Row private struct DiscoveryNodeRow: View { - @Environment(\.appState) private var appState - let node: DiscoveredNodeDTO - let isAdded: Bool - let isAdding: Bool - let onAdd: () -> Void - let onDelete: () -> Void - - var body: some View { - HStack { - avatarView - - VStack(alignment: .leading, spacing: 2) { - Text(node.name) - .font(.body) - .bold() - - Text(node.publicKey.uppercaseHexString()) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - HStack(spacing: 4) { - Text(nodeTypeLabel) - - if node.hasLocation { - Text("·") - - Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") - .labelStyle(.iconOnly) - .foregroundStyle(.green) - - if let distance = distanceToNode { - Text(distance) - } - } - } - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 4) { - Image(systemName: "arrowshape.bounce.right") - if !node.isFloodRouted, node.pathHopCount == 0 { - Text(L10n.Contacts.Contacts.Route.direct) - } else if let hops = node.displayedHopCount { - Text("\(hops)") - - let pathNodes = node.pathNodesHex - if !node.isFloodRouted, !pathNodes.isEmpty { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - Text(formattedPath(pathNodes)) - .monospaced() - } - } else { - Text(L10n.Contacts.Contacts.Route.flood) - } - } - .font(.caption2) - .foregroundStyle(.secondary) - } - - Spacer() - - RelativeTimestampText(timestamp: node.lastAdvertTimestamp) - - if isAdded { - Button(L10n.Contacts.Contacts.Discovery.added) {} - .buttonStyle(.bordered) - .disabled(true) - .accessibilityLabel(L10n.Contacts.Contacts.Discovery.addedAccessibility) - } else { - Button(L10n.Contacts.Contacts.Discovery.add) { - onAdd() - } - .buttonStyle(.borderedProminent) - .disabled(isAdding) + @Environment(\.appState) private var appState + let node: DiscoveredNodeDTO + let isAdded: Bool + let isAdding: Bool + let onAdd: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack { + avatarView + + VStack(alignment: .leading, spacing: 2) { + Text(node.name) + .font(.body) + .bold() + + Text(node.publicKey.uppercaseHexString()) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + HStack(spacing: 4) { + Text(nodeTypeLabel) + + if node.hasLocation { + Text("·") + + Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") + .labelStyle(.iconOnly) + .foregroundStyle(.green) + + if let distance = distanceToNode { + Text(distance) } + } } - .padding(.horizontal, DiscoveryListLayout.rowHorizontalPadding) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - .contextMenu { - Button(role: .destructive) { - onDelete() - } label: { - Label(L10n.Contacts.Contacts.Discovery.remove, systemImage: "trash") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 4) { + Image(systemName: "arrowshape.bounce.right") + if !node.isFloodRouted, node.pathHopCount == 0 { + Text(L10n.Contacts.Contacts.Route.direct) + } else if let hops = node.displayedHopCount { + Text("\(hops)") + + let pathNodes = node.pathNodesHex + if !node.isFloodRouted, !pathNodes.isEmpty { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + Text(formattedPath(pathNodes)) + .monospaced() } + } else { + Text(L10n.Contacts.Contacts.Route.flood) + } } - } - - @ViewBuilder - private var avatarView: some View { - switch node.nodeType { - case .chat: - ContactAvatar(name: node.name, size: 44) - case .repeater: - NodeAvatar(publicKey: node.publicKey, role: .repeater, size: 44) - case .room: - NodeAvatar(publicKey: node.publicKey, role: .roomServer, size: 44) + .font(.caption2) + .foregroundStyle(.secondary) + } + + Spacer() + + RelativeTimestampText(timestamp: node.lastAdvertTimestamp) + + if isAdded { + Button(L10n.Contacts.Contacts.Discovery.added) {} + .buttonStyle(.bordered) + .disabled(true) + .accessibilityLabel(L10n.Contacts.Contacts.Discovery.addedAccessibility) + } else { + Button(L10n.Contacts.Contacts.Discovery.add) { + onAdd() } + .buttonStyle(.borderedProminent) + .disabled(isAdding) + } } - - private var nodeTypeLabel: String { - switch node.nodeType { - case .chat: return L10n.Contacts.Contacts.NodeKind.chat - case .repeater: return L10n.Contacts.Contacts.NodeKind.repeater - case .room: return L10n.Contacts.Contacts.NodeKind.room - } + .padding(.horizontal, DiscoveryListLayout.rowHorizontalPadding) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + .contextMenu { + Button(role: .destructive) { + onDelete() + } label: { + Label(L10n.Contacts.Contacts.Discovery.remove, systemImage: "trash") + } } - - private func formattedPath(_ nodes: [String]) -> String { - if nodes.count > 6 { - let first = nodes.prefix(3).joined(separator: ",") - let last = nodes.suffix(3).joined(separator: ",") - return "\(first)…\(last)" - } - return nodes.joined(separator: ",") + } + + @ViewBuilder + private var avatarView: some View { + switch node.nodeType { + case .chat: + ContactAvatar(name: node.name, size: 44) + case .repeater: + NodeAvatar(publicKey: node.publicKey, role: .repeater, size: 44) + case .room: + NodeAvatar(publicKey: node.publicKey, role: .roomServer, size: 44) } + } - private var distanceToNode: String? { - guard let userLocation = appState.bestAvailableLocation, - node.hasLocation else { return nil } + private var nodeTypeLabel: String { + switch node.nodeType { + case .chat: L10n.Contacts.Contacts.NodeKind.chat + case .repeater: L10n.Contacts.Contacts.NodeKind.repeater + case .room: L10n.Contacts.Contacts.NodeKind.room + } + } - let nodeLocation = CLLocation( - latitude: node.latitude, - longitude: node.longitude - ) - let meters = userLocation.distance(from: nodeLocation) - let measurement = Measurement(value: meters, unit: UnitLength.meters) - - let formattedDistance = measurement.formatted(.measurement( - width: .abbreviated, - usage: .road - )) - return L10n.Contacts.Contacts.Row.away(formattedDistance) + private func formattedPath(_ nodes: [String]) -> String { + if nodes.count > 6 { + let first = nodes.prefix(3).joined(separator: ",") + let last = nodes.suffix(3).joined(separator: ",") + return "\(first)…\(last)" } + return nodes.joined(separator: ",") + } + + private var distanceToNode: String? { + guard let userLocation = appState.bestAvailableLocation, + node.hasLocation else { return nil } + + let nodeLocation = CLLocation( + latitude: node.latitude, + longitude: node.longitude + ) + let meters = userLocation.distance(from: nodeLocation) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + + let formattedDistance = measurement.formatted(.measurement( + width: .abbreviated, + usage: .road + )) + return L10n.Contacts.Contacts.Row.away(formattedDistance) + } } // MARK: - Sort Menu private struct DiscoverySortMenu: View { - @Binding var sortOrder: NodeSortOrder - - var body: some View { - Menu { - ForEach(NodeSortOrder.allCases, id: \.self) { order in - Button { - sortOrder = order - } label: { - if sortOrder == order { - Label(order.localizedTitle, systemImage: "checkmark") - } else { - Text(order.localizedTitle) - } - } - } + @Binding var sortOrder: NodeSortOrder + + var body: some View { + Menu { + ForEach(NodeSortOrder.allCases, id: \.self) { order in + Button { + sortOrder = order } label: { - Label(L10n.Contacts.Contacts.List.sort, systemImage: "arrow.up.arrow.down") + if sortOrder == order { + Label(order.localizedTitle, systemImage: "checkmark") + } else { + Text(order.localizedTitle) + } } - .liquidGlassSecondaryButtonStyle() - .accessibilityLabel(L10n.Contacts.Contacts.Discovery.sortMenu) - .accessibilityHint(L10n.Contacts.Contacts.Discovery.sortMenuHint) + } + } label: { + Label(L10n.Contacts.Contacts.List.sort, systemImage: "arrow.up.arrow.down") } + .liquidGlassSecondaryButtonStyle() + .accessibilityLabel(L10n.Contacts.Contacts.Discovery.sortMenu) + .accessibilityHint(L10n.Contacts.Contacts.Discovery.sortMenuHint) + } } // MARK: - More Menu private struct DiscoveryMoreMenu: View { - let isEmpty: Bool - @Binding var showClearConfirmation: Bool - - var body: some View { - Menu { - Button(role: .destructive) { - showClearConfirmation = true - } label: { - Label(L10n.Contacts.Contacts.Discovery.clear, systemImage: "trash") - } - .disabled(isEmpty) - } label: { - Label(L10n.Contacts.Contacts.Discovery.menu, systemImage: "ellipsis.circle") - } - .liquidGlassSecondaryButtonStyle() + let isEmpty: Bool + @Binding var showClearConfirmation: Bool + + var body: some View { + Menu { + Button(role: .destructive) { + showClearConfirmation = true + } label: { + Label(L10n.Contacts.Contacts.Discovery.clear, systemImage: "trash") + } + .disabled(isEmpty) + } label: { + Label(L10n.Contacts.Contacts.Discovery.menu, systemImage: "ellipsis.circle") } + .liquidGlassSecondaryButtonStyle() + } } #Preview { - NavigationStack { - DiscoveryView() - } - .environment(\.appState, AppState()) + NavigationStack { + DiscoveryView() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Contacts/DiscoveryViewModel.swift b/MC1/Views/Contacts/DiscoveryViewModel.swift index de02385f..da0a4a50 100644 --- a/MC1/Views/Contacts/DiscoveryViewModel.swift +++ b/MC1/Views/Contacts/DiscoveryViewModel.swift @@ -1,205 +1,206 @@ import CoreLocation -import SwiftUI import MC1Services +import SwiftUI /// Segment for the discovery picker enum DiscoverSegment: String, CaseIterable { - case all - case contacts - case repeaters - case rooms - - var localizedTitle: String { - switch self { - case .all: L10n.Contacts.Contacts.Discovery.Segment.all - case .contacts: L10n.Contacts.Contacts.Discovery.Segment.contacts - case .repeaters: L10n.Contacts.Contacts.Discovery.Segment.repeaters - case .rooms: L10n.Contacts.Contacts.Discovery.Segment.rooms - } + case all + case contacts + case repeaters + case rooms + + var localizedTitle: String { + switch self { + case .all: L10n.Contacts.Contacts.Discovery.Segment.all + case .contacts: L10n.Contacts.Contacts.Discovery.Segment.contacts + case .repeaters: L10n.Contacts.Contacts.Discovery.Segment.repeaters + case .rooms: L10n.Contacts.Contacts.Discovery.Segment.rooms } + } } /// ViewModel for discovery view @Observable @MainActor final class DiscoveryViewModel { + // MARK: - Properties - // MARK: - Properties - - /// Discovered nodes from the mesh network - var discoveredNodes: [DiscoveredNodeDTO] = [] + /// Discovered nodes from the mesh network + var discoveredNodes: [DiscoveredNodeDTO] = [] - /// Public keys of contacts that have been added - var addedPublicKeys: Set = [] + /// Public keys of contacts that have been added + var addedPublicKeys: Set = [] - /// Loading state - var isLoading = false + /// Loading state + var isLoading = false - /// Whether data has been loaded at least once (prevents empty state flash) - var hasLoadedOnce = false + /// Whether data has been loaded at least once (prevents empty state flash) + var hasLoadedOnce = false - /// Error message to display - var errorMessage: String? + /// Error message to display + var errorMessage: String? - // MARK: - Dependencies + // MARK: - Dependencies - private var dataStoreProvider: @MainActor () -> DataStore? = { nil } - private var dataStore: DataStore? { dataStoreProvider() } + private var dataStoreProvider: @MainActor () -> DataStore? = { nil } + private var dataStore: DataStore? { + dataStoreProvider() + } - /// Temporary Discover trace; filter by category "discover-trace". Remove - /// once the "no new nodes after clear" report is closed. - private let discoverTrace = PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + /// Temporary Discover trace; filter by category "discover-trace". Remove + /// once the "no new nodes after clear" report is closed. + private let discoverTrace = PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - // MARK: - Initialization + // MARK: - Initialization - init() {} + init() {} - /// Configure with the data store this view model uses; a provider returning nil mirrors a disconnected state. - func configure(dataStore: @escaping @MainActor () -> DataStore?) { - dataStoreProvider = dataStore - } - - // MARK: - Load Nodes + /// Configure with the data store this view model uses; a provider returning nil mirrors a disconnected state. + func configure(dataStore: @escaping @MainActor () -> DataStore?) { + dataStoreProvider = dataStore + } - func loadDiscoveredNodes(radioID: UUID) async { - guard let dataStore else { return } + // MARK: - Load Nodes - isLoading = true - errorMessage = nil + func loadDiscoveredNodes(radioID: UUID) async { + guard let dataStore else { return } - do { - let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) + isLoading = true + errorMessage = nil - // Single batch query for all contact public keys (O(1) vs O(N)) - let addedKeys = try await dataStore.fetchContactPublicKeys(radioID: radioID) + do { + let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) - discoveredNodes = nodes - addedPublicKeys = addedKeys - discoverTrace.info("B4 view reload loaded=\(nodes.count) addedKeys=\(addedKeys.count) radio=\(radioID)") - } catch { - errorMessage = error.userFacingMessage - discoverTrace.error("B4 view reload FAILED radio=\(radioID): \(error.localizedDescription)") - } + // Single batch query for all contact public keys (O(1) vs O(N)) + let addedKeys = try await dataStore.fetchContactPublicKeys(radioID: radioID) - hasLoadedOnce = true - isLoading = false + discoveredNodes = nodes + addedPublicKeys = addedKeys + discoverTrace.info("B4 view reload loaded=\(nodes.count) addedKeys=\(addedKeys.count) radio=\(radioID)") + } catch { + errorMessage = error.userFacingMessage + discoverTrace.error("B4 view reload FAILED radio=\(radioID): \(error.localizedDescription)") } - // MARK: - Added State - - /// Check if a node has already been added as a contact - func isAdded(_ node: DiscoveredNodeDTO) -> Bool { - addedPublicKeys.contains(node.publicKey) - } + hasLoadedOnce = true + isLoading = false + } - // MARK: - Delete + // MARK: - Added State - func deleteDiscoveredNode(_ node: DiscoveredNodeDTO) async { - guard let dataStore else { return } + /// Check if a node has already been added as a contact + func isAdded(_ node: DiscoveredNodeDTO) -> Bool { + addedPublicKeys.contains(node.publicKey) + } - // Remove from UI immediately - discoveredNodes.removeAll { $0.id == node.id } + // MARK: - Delete - do { - try await dataStore.deleteDiscoveredNode(id: node.id) - } catch { - errorMessage = error.userFacingMessage - } - } + func deleteDiscoveredNode(_ node: DiscoveredNodeDTO) async { + guard let dataStore else { return } - func clearAllDiscoveredNodes(radioID: UUID) async { - guard let dataStore else { return } + // Remove from UI immediately + discoveredNodes.removeAll { $0.id == node.id } - do { - try await dataStore.clearDiscoveredNodes(radioID: radioID) - discoveredNodes = [] - } catch { - errorMessage = error.userFacingMessage - } + do { + try await dataStore.deleteDiscoveredNode(id: node.id) + } catch { + errorMessage = error.userFacingMessage } + } - // MARK: - Filtering - - func filteredNodes( - searchText: String, - segment: DiscoverSegment, - sortOrder: NodeSortOrder, - userLocation: CLLocation? - ) -> [DiscoveredNodeDTO] { - var result = discoveredNodes - - if searchText.isEmpty { - switch segment { - case .all: - break - case .contacts: - result = result.filter { $0.nodeType == .chat } - case .repeaters: - result = result.filter { $0.nodeType == .repeater } - case .rooms: - result = result.filter { $0.nodeType == .room } - } - } else { - result = result.filter { node in - node.name.localizedStandardContains(searchText) - || node.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) - } - } + func clearAllDiscoveredNodes(radioID: UUID) async { + guard let dataStore else { return } - return sorted(result, by: sortOrder, userLocation: userLocation) + do { + try await dataStore.clearDiscoveredNodes(radioID: radioID) + discoveredNodes = [] + } catch { + errorMessage = error.userFacingMessage + } + } + + // MARK: - Filtering + + func filteredNodes( + searchText: String, + segment: DiscoverSegment, + sortOrder: NodeSortOrder, + userLocation: CLLocation? + ) -> [DiscoveredNodeDTO] { + var result = discoveredNodes + + if searchText.isEmpty { + switch segment { + case .all: + break + case .contacts: + result = result.filter { $0.nodeType == .chat } + case .repeaters: + result = result.filter { $0.nodeType == .repeater } + case .rooms: + result = result.filter { $0.nodeType == .room } + } + } else { + result = result.filter { node in + node.name.localizedStandardContains(searchText) + || node.publicKey.uppercaseHexString().hasPrefix(searchText.uppercased()) + } } - // MARK: - Sorting - - private func sorted( - _ nodes: [DiscoveredNodeDTO], - by order: NodeSortOrder, - userLocation: CLLocation? - ) -> [DiscoveredNodeDTO] { - switch order { - case .lastHeard: - return nodes.sorted { $0.lastAdvertTimestamp > $1.lastAdvertTimestamp } - case .name: - return nodes.sorted { - $0.name.localizedCompare($1.name) == .orderedAscending - } - case .distance: - return nodes.sorted { orderedByDistanceThenName($0, $1, from: userLocation) } - case .hops: - return nodes.sorted { lhs, rhs in - let lhsHops = lhs.displayedHopCount - let rhsHops = rhs.displayedHopCount - // A nil hop count (flood-routed and never heard via advert) sorts to the bottom. - if (lhsHops == nil) != (rhsHops == nil) { - return lhsHops != nil - } - if let lhsHops, let rhsHops, lhsHops != rhsHops { - return lhsHops < rhsHops - } - return orderedByDistanceThenName(lhs, rhs, from: userLocation) - } + return sorted(result, by: sortOrder, userLocation: userLocation) + } + + // MARK: - Sorting + + private func sorted( + _ nodes: [DiscoveredNodeDTO], + by order: NodeSortOrder, + userLocation: CLLocation? + ) -> [DiscoveredNodeDTO] { + switch order { + case .lastHeard: + nodes.sorted { $0.lastAdvertTimestamp > $1.lastAdvertTimestamp } + case .name: + nodes.sorted { + $0.name.localizedCompare($1.name) == .orderedAscending + } + case .distance: + nodes.sorted { orderedByDistanceThenName($0, $1, from: userLocation) } + case .hops: + nodes.sorted { lhs, rhs in + let lhsHops = lhs.displayedHopCount + let rhsHops = rhs.displayedHopCount + // A nil hop count (flood-routed and never heard via advert) sorts to the bottom. + if (lhsHops == nil) != (rhsHops == nil) { + return lhsHops != nil } + if let lhsHops, let rhsHops, lhsHops != rhsHops { + return lhsHops < rhsHops + } + return orderedByDistanceThenName(lhs, rhs, from: userLocation) + } } - - /// Located nodes first, then nearest to `userLocation`, then by name. Falls back to name when - /// there is no user location, neither node has coordinates, or the distances tie. - private func orderedByDistanceThenName( - _ lhs: DiscoveredNodeDTO, - _ rhs: DiscoveredNodeDTO, - from userLocation: CLLocation? - ) -> Bool { - if let userLocation { - if lhs.hasLocation != rhs.hasLocation { - return lhs.hasLocation - } - if lhs.hasLocation { - let lhsDistance = CLLocation(latitude: lhs.latitude, longitude: lhs.longitude).distance(from: userLocation) - let rhsDistance = CLLocation(latitude: rhs.latitude, longitude: rhs.longitude).distance(from: userLocation) - if lhsDistance != rhsDistance { - return lhsDistance < rhsDistance - } - } + } + + /// Located nodes first, then nearest to `userLocation`, then by name. Falls back to name when + /// there is no user location, neither node has coordinates, or the distances tie. + private func orderedByDistanceThenName( + _ lhs: DiscoveredNodeDTO, + _ rhs: DiscoveredNodeDTO, + from userLocation: CLLocation? + ) -> Bool { + if let userLocation { + if lhs.hasLocation != rhs.hasLocation { + return lhs.hasLocation + } + if lhs.hasLocation { + let lhsDistance = CLLocation(latitude: lhs.latitude, longitude: lhs.longitude).distance(from: userLocation) + let rhsDistance = CLLocation(latitude: rhs.latitude, longitude: rhs.longitude).distance(from: userLocation) + if lhsDistance != rhsDistance { + return lhsDistance < rhsDistance } - return lhs.name.localizedCompare(rhs.name) == .orderedAscending + } } + return lhs.name.localizedCompare(rhs.name) == .orderedAscending + } } diff --git a/MC1/Views/Contacts/NodeSegmentPicker.swift b/MC1/Views/Contacts/NodeSegmentPicker.swift index 674c9a6b..9b57ff46 100644 --- a/MC1/Views/Contacts/NodeSegmentPicker.swift +++ b/MC1/Views/Contacts/NodeSegmentPicker.swift @@ -2,15 +2,15 @@ import SwiftUI /// Pinned glass filter bar for the Nodes tab. struct NodeSegmentPicker: View { - @Binding var selection: NodeSegment - let isSearching: Bool + @Binding var selection: NodeSegment + let isSearching: Bool - var body: some View { - GlassFilterBar( - selection: $selection, - isSearching: isSearching, - pickerLabel: L10n.Contacts.Contacts.Segment.pickerLabel, - title: { $0.localizedTitle } - ) - } + var body: some View { + GlassFilterBar( + selection: $selection, + isSearching: isSearching, + pickerLabel: L10n.Contacts.Contacts.Segment.pickerLabel, + title: { $0.localizedTitle } + ) + } } diff --git a/MC1/Views/Contacts/ScanContactQRView.swift b/MC1/Views/Contacts/ScanContactQRView.swift index f1e8b323..a571ad1a 100644 --- a/MC1/Views/Contacts/ScanContactQRView.swift +++ b/MC1/Views/Contacts/ScanContactQRView.swift @@ -1,213 +1,213 @@ -import SwiftUI -import VisionKit import MC1Services import os +import SwiftUI +import VisionKit /// View for scanning a contact QR code to import struct ScanContactQRView: View { - @Environment(\.appState) private var appState - @Environment(\.openURL) private var openURL - @Environment(\.dismiss) private var dismiss - - let onScan: (String, Data) -> Void - - @State private var isImporting = false - @State private var errorMessage: String? - @State private var cameraPermissionDenied = false - @State private var scanSuccessTrigger = false - - private let logger = Logger(subsystem: "com.mc1", category: "ScanContactQRView") - - // MARK: - Constants - - private enum Constants { - static let scanFrameSize: CGFloat = 250 - static let overlayOpacity: CGFloat = 0.6 - static let errorOpacity: CGFloat = 0.8 - static let bottomPadding: CGFloat = 50 + @Environment(\.appState) private var appState + @Environment(\.openURL) private var openURL + @Environment(\.dismiss) private var dismiss + + let onScan: (String, Data) -> Void + + @State private var isImporting = false + @State private var errorMessage: String? + @State private var cameraPermissionDenied = false + @State private var scanSuccessTrigger = false + + private let logger = Logger(subsystem: "com.mc1", category: "ScanContactQRView") + + // MARK: - Constants + + private enum Constants { + static let scanFrameSize: CGFloat = 250 + static let overlayOpacity: CGFloat = 0.6 + static let errorOpacity: CGFloat = 0.8 + static let bottomPadding: CGFloat = 50 + } + + var body: some View { + Group { + if cameraPermissionDenied { + cameraPermissionDeniedView + } else { + scannerView + } } - - var body: some View { - Group { - if cameraPermissionDenied { - cameraPermissionDeniedView - } else { - scannerView - } + .navigationTitle(L10n.Contacts.Contacts.Scan.title) + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Scanner View + + private var scannerView: some View { + ZStack { + if QRDataScannerView.isSupported, QRDataScannerView.isAvailable { + QRDataScannerView { result in + handleScanResult(result) + } onPermissionDenied: { + cameraPermissionDenied = true } - .navigationTitle(L10n.Contacts.Contacts.Scan.title) - .navigationBarTitleDisplayMode(.inline) - } - - // MARK: - Scanner View - - private var scannerView: some View { - ZStack { - if QRDataScannerView.isSupported && QRDataScannerView.isAvailable { - QRDataScannerView { result in - handleScanResult(result) - } onPermissionDenied: { - cameraPermissionDenied = true - } - } else { - // Fallback for unsupported devices - ContentUnavailableView( - L10n.Contacts.Contacts.Scan.Unavailable.title, - systemImage: "qrcode.viewfinder", - description: Text(L10n.Contacts.Contacts.Scan.Unavailable.description) - ) - } - - // Overlay with scan frame - VStack { - Spacer() - - RoundedRectangle(cornerRadius: 20) - .stroke(.white, lineWidth: 3) - .frame(width: Constants.scanFrameSize, height: Constants.scanFrameSize) - - Spacer() - - if isImporting { - VStack(spacing: 12) { - ProgressView() - Text(L10n.Contacts.Contacts.Scan.importing) - } - .font(.subheadline) - .foregroundStyle(.white) - .padding() - .background(.black.opacity(Constants.overlayOpacity), in: .capsule) - .padding(.bottom, Constants.bottomPadding) - } else if let errorMessage { - Button { - self.errorMessage = nil - } label: { - Text(errorMessage) - .font(.subheadline) - .foregroundStyle(.white) - .padding() - .background(.red.opacity(Constants.errorOpacity), in: .capsule) - } - .buttonStyle(.plain) - .padding(.bottom, Constants.bottomPadding) - } else { - Text(L10n.Contacts.Contacts.Scan.instruction) - .font(.subheadline) - .foregroundStyle(.white) - .padding() - .background(.black.opacity(Constants.overlayOpacity), in: .capsule) - .padding(.bottom, Constants.bottomPadding) - } - } + } else { + // Fallback for unsupported devices + ContentUnavailableView( + L10n.Contacts.Contacts.Scan.Unavailable.title, + systemImage: "qrcode.viewfinder", + description: Text(L10n.Contacts.Contacts.Scan.Unavailable.description) + ) + } + + // Overlay with scan frame + VStack { + Spacer() + + RoundedRectangle(cornerRadius: 20) + .stroke(.white, lineWidth: 3) + .frame(width: Constants.scanFrameSize, height: Constants.scanFrameSize) + + Spacer() + + if isImporting { + VStack(spacing: 12) { + ProgressView() + Text(L10n.Contacts.Contacts.Scan.importing) + } + .font(.subheadline) + .foregroundStyle(.white) + .padding() + .background(.black.opacity(Constants.overlayOpacity), in: .capsule) + .padding(.bottom, Constants.bottomPadding) + } else if let errorMessage { + Button { + self.errorMessage = nil + } label: { + Text(errorMessage) + .font(.subheadline) + .foregroundStyle(.white) + .padding() + .background(.red.opacity(Constants.errorOpacity), in: .capsule) + } + .buttonStyle(.plain) + .padding(.bottom, Constants.bottomPadding) + } else { + Text(L10n.Contacts.Contacts.Scan.instruction) + .font(.subheadline) + .foregroundStyle(.white) + .padding() + .background(.black.opacity(Constants.overlayOpacity), in: .capsule) + .padding(.bottom, Constants.bottomPadding) } - .sensoryFeedback(.success, trigger: scanSuccessTrigger) - .ignoresSafeArea() + } } - - // MARK: - Permission Denied View - - private var cameraPermissionDeniedView: some View { - VStack(spacing: 20) { - Image(systemName: "camera.fill") - .font(.system(size: 60)) - .foregroundStyle(.secondary) - - Text(L10n.Contacts.Contacts.Scan.Permission.title) - .font(.title2) - .bold() - - Text(L10n.Contacts.Contacts.Scan.Permission.description) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Button(L10n.Contacts.Contacts.List.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } - .buttonStyle(.borderedProminent) + .sensoryFeedback(.success, trigger: scanSuccessTrigger) + .ignoresSafeArea() + } + + // MARK: - Permission Denied View + + private var cameraPermissionDeniedView: some View { + VStack(spacing: 20) { + Image(systemName: "camera.fill") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + + Text(L10n.Contacts.Contacts.Scan.Permission.title) + .font(.title2) + .bold() + + Text(L10n.Contacts.Contacts.Scan.Permission.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button(L10n.Contacts.Contacts.List.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) } - .padding() + } + .buttonStyle(.borderedProminent) } + .padding() + } - // MARK: - Private Methods + // MARK: - Private Methods - private func handleScanResult(_ result: String) { - guard !isImporting else { return } + private func handleScanResult(_ result: String) { + guard !isImporting else { return } - guard let parsed = MeshCoreURLParser.parseContactURL(result) else { - logger.error("Invalid QR code format: \(result)") - errorMessage = L10n.Contacts.Contacts.Scan.Error.invalidFormat - return - } + guard let parsed = MeshCoreURLParser.parseContactURL(result) else { + logger.error("Invalid QR code format: \(result)") + errorMessage = L10n.Contacts.Contacts.Scan.Error.invalidFormat + return + } - scanSuccessTrigger.toggle() + scanSuccessTrigger.toggle() - // Claim the import synchronously so a second DataScanner callback can't slip - // past the guard before the async import flips the flag. - isImporting = true - errorMessage = nil + // Claim the import synchronously so a second DataScanner callback can't slip + // past the guard before the async import flips the flag. + isImporting = true + errorMessage = nil - Task { - await importContact(parsed) - } + Task { + await importContact(parsed) + } + } + + @MainActor + private func importContact(_ contact: MeshCoreURLParser.ContactResult) async { + guard let services = appState.services, + let device = appState.connectedDevice else { + logger.error("Services or device not available") + errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected + isImporting = false + return } - @MainActor - private func importContact(_ contact: MeshCoreURLParser.ContactResult) async { - guard let services = appState.services, - let device = appState.connectedDevice else { - logger.error("Services or device not available") - errorMessage = L10n.Contacts.Contacts.Add.Error.notConnected - isImporting = false - return - } - - let radioID = device.radioID - let maxContacts = device.maxContacts - - do { - let currentTimestamp = UInt32(Date().timeIntervalSince1970) - - let contactFrame = ContactFrame( - publicKey: contact.publicKey, - type: contact.contactType, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: contact.name, - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: currentTimestamp - ) - - logger.info("Importing contact: \(contact.name) (\(contact.publicKey.uppercaseHexString()))") - try await services.contactService.addOrUpdateContact(radioID: radioID, contact: contactFrame) - logger.info("Contact imported successfully") - - // Reset state and dismiss before calling completion handler - isImporting = false - dismiss() - - onScan(contact.name, contact.publicKey) - } catch ContactServiceError.contactTableFull { - logger.error("Node list is full") - errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) - isImporting = false - } catch { - logger.error("Failed to import contact: \(error.localizedDescription)") - errorMessage = L10n.Contacts.Contacts.Scan.Error.importFailed(error.userFacingMessage) - isImporting = false - } + let radioID = device.radioID + let maxContacts = device.maxContacts + + do { + let currentTimestamp = UInt32(Date().timeIntervalSince1970) + + let contactFrame = ContactFrame( + publicKey: contact.publicKey, + type: contact.contactType, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: contact.name, + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: currentTimestamp + ) + + logger.info("Importing contact: \(contact.name) (\(contact.publicKey.uppercaseHexString()))") + try await services.contactService.addOrUpdateContact(radioID: radioID, contact: contactFrame) + logger.info("Contact imported successfully") + + // Reset state and dismiss before calling completion handler + isImporting = false + dismiss() + + onScan(contact.name, contact.publicKey) + } catch ContactServiceError.contactTableFull { + logger.error("Node list is full") + errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) + isImporting = false + } catch { + logger.error("Failed to import contact: \(error.localizedDescription)") + errorMessage = L10n.Contacts.Contacts.Scan.Error.importFailed(error.userFacingMessage) + isImporting = false } + } } #Preview { - NavigationStack { - ScanContactQRView { _, _ in } - } - .environment(\.appState, AppState()) + NavigationStack { + ScanContactQRView { _, _ in } + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/MainSidebarView.swift b/MC1/Views/MainSidebarView.swift index fbdc5713..93d5c0c6 100644 --- a/MC1/Views/MainSidebarView.swift +++ b/MC1/Views/MainSidebarView.swift @@ -1,268 +1,268 @@ -import SwiftUI import MC1Services +import SwiftUI /// The iPad sidebar shell: a sidebar selecting the active `AppTab`, plus that section's columns /// (list sections are three-column, Map is two-column). Per-section selection that must survive the /// section-switch teardown lives in `NavigationCoordinator`; the shared view models are hosted here /// so each section's content and detail columns read one instance. struct MainSidebarView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var columnVisibility: NavigationSplitViewVisibility = .all - @State private var showingDeviceSelection = false + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var showingDeviceSelection = false - /// Moves the VoiceOver cursor into the active section's content when the sidebar auto-collapses - /// on selection. The collapse removes the just-tapped sidebar row, which would otherwise strand - /// VoiceOver focus off-screen; setting this to the selected tab relocates focus to that section's - /// content (and VoiceOver reads its label). A per-section value rather than a Bool guarantees each - /// switch is a distinct change so focus re-moves every time; it is a no-op when VoiceOver is off. - @AccessibilityFocusState private var focusedColumn: AppTab? + /// Moves the VoiceOver cursor into the active section's content when the sidebar auto-collapses + /// on selection. The collapse removes the just-tapped sidebar row, which would otherwise strand + /// VoiceOver focus off-screen; setting this to the selected tab relocates focus to that section's + /// content (and VoiceOver reads its label). A per-section value rather than a Bool guarantees each + /// switch is a distinct change so focus re-moves every time; it is a no-op when VoiceOver is off. + @AccessibilityFocusState private var focusedColumn: AppTab? - // Shared Chats view model, hosted once so content + detail columns share it. - @State private var chatViewModel = ChatViewModel() + /// Shared Chats view model, hosted once so content + detail columns share it. + @State private var chatViewModel = ChatViewModel() - // Shared Line of Sight view model, so the Tools content panel and detail map stay in sync. - @State private var lineOfSightViewModel = LineOfSightViewModel() + /// Shared Line of Sight view model, so the Tools content panel and detail map stay in sync. + @State private var lineOfSightViewModel = LineOfSightViewModel() - // Shared Settings state, mirroring SettingsView's regular-width path. - @State private var settingsShowingDeviceSelection = false - @State private var settingsDemoModeManager = DemoModeManager.shared + // Shared Settings state, mirroring SettingsView's regular-width path. + @State private var settingsShowingDeviceSelection = false + @State private var settingsDemoModeManager = DemoModeManager.shared - private static let lineOfSightPanelWidthMin: CGFloat = 380 - private static let lineOfSightPanelWidthIdeal: CGFloat = 440 - private static let lineOfSightPanelWidthMax: CGFloat = 560 + private static let lineOfSightPanelWidthMin: CGFloat = 380 + private static let lineOfSightPanelWidthIdeal: CGFloat = 440 + private static let lineOfSightPanelWidthMax: CGFloat = 560 - // sidebarColumnWidth is pinned on AppSidebar in both shell shapes (via navigationSplitViewColumnWidth) - // so the sidebar renders at exactly this width whether the section is two- or three-column. Without - // the pin the two-column Map shell gives its sidebar a wider system default than the three-column - // list shell, so the sidebar visibly jumps width when switching to Map. detailColumnApproxMinWidth is - // the detail-column floor feeding the tiling breakpoint below: an approximation of the width the detail - // column settles at on our supported iPads, validated on device. Both are `nonisolated` because - // sidebarTileableMinWidth's nonisolated initializer reads them. - nonisolated static let sidebarColumnWidth: CGFloat = 64 - nonisolated static let detailColumnApproxMinWidth: CGFloat = 320 + // sidebarColumnWidth is pinned on AppSidebar in both shell shapes (via navigationSplitViewColumnWidth) + // so the sidebar renders at exactly this width whether the section is two- or three-column. Without + // the pin the two-column Map shell gives its sidebar a wider system default than the three-column + // list shell, so the sidebar visibly jumps width when switching to Map. detailColumnApproxMinWidth is + // the detail-column floor feeding the tiling breakpoint below: an approximation of the width the detail + // column settles at on our supported iPads, validated on device. Both are `nonisolated` because + // sidebarTileableMinWidth's nonisolated initializer reads them. + nonisolated static let sidebarColumnWidth: CGFloat = 64 + nonisolated static let detailColumnApproxMinWidth: CGFloat = 320 - // Content-column floor feeding the tiling breakpoint: the widest minimum any section's content - // column takes, so the breakpoint clears for every section. A deliberate, team-owned value kept as - // its own constant, not a reuse of lineOfSightPanelWidthMin, so retuning the Line of Sight panel for - // comfort cannot shift when the sidebar tiles. `nonisolated` because sidebarTileableMinWidth's - // nonisolated initializer reads it. - nonisolated static let contentColumnTileableMinWidth: CGFloat = 380 + /// Content-column floor feeding the tiling breakpoint: the widest minimum any section's content + /// column takes, so the breakpoint clears for every section. A deliberate, team-owned value kept as + /// its own constant, not a reuse of lineOfSightPanelWidthMin, so retuning the Line of Sight panel for + /// comfort cannot shift when the sidebar tiles. `nonisolated` because sidebarTileableMinWidth's + /// nonisolated initializer reads it. + nonisolated static let contentColumnTileableMinWidth: CGFloat = 380 - /// Three columns tile only when the container is at least the sum of the three column widths: the - /// pinned icon sidebar plus a content column at its min plus detail at its min. Below it the system - /// overlays the sidebar, so the not-wide branch keeps collapse-on-selection rather than leaving a - /// sidebar overlay on a too-narrow container. `nonisolated` so the @Sendable onGeometryChange - /// transform can read it; a main-actor-isolated static is not referenceable from a Sendable closure. - nonisolated static let sidebarTileableMinWidth: CGFloat = - sidebarColumnWidth + contentColumnTileableMinWidth + detailColumnApproxMinWidth + /// Three columns tile only when the container is at least the sum of the three column widths: the + /// pinned icon sidebar plus a content column at its min plus detail at its min. Below it the system + /// overlays the sidebar, so the not-wide branch keeps collapse-on-selection rather than leaving a + /// sidebar overlay on a too-narrow container. `nonisolated` so the @Sendable onGeometryChange + /// transform can read it; a main-actor-isolated static is not referenceable from a Sendable closure. + nonisolated static let sidebarTileableMinWidth: CGFloat = + sidebarColumnWidth + contentColumnTileableMinWidth + detailColumnApproxMinWidth - /// Pure mapping from container width and section to the desired sidebar visibility: wide tiles the - /// sidebar (`.all`), narrow collapses to the section's hidden shape, and a sidebar-collapsing tool - /// overrides both. `nonisolated` so the @Sendable-adjacent geometry action and the layout test can - /// both call it. - nonisolated static func sidebarVisibility( - isWide: Bool, - toolCollapsesSidebar: Bool, - sectionCollapsed: NavigationSplitViewVisibility - ) -> NavigationSplitViewVisibility { - if toolCollapsesSidebar { return sectionCollapsed } - return isWide ? .all : sectionCollapsed - } + /// Pure mapping from container width and section to the desired sidebar visibility: wide tiles the + /// sidebar (`.all`), narrow collapses to the section's hidden shape, and a sidebar-collapsing tool + /// overrides both. `nonisolated` so the @Sendable-adjacent geometry action and the layout test can + /// both call it. + nonisolated static func sidebarVisibility( + isWide: Bool, + toolCollapsesSidebar: Bool, + sectionCollapsed: NavigationSplitViewVisibility + ) -> NavigationSplitViewVisibility { + if toolCollapsesSidebar { return sectionCollapsed } + return isWide ? .all : sectionCollapsed + } - private var selectedTab: AppTab { - AppTab(rawValue: appState.navigation.selectedTab) ?? .chats - } + private var selectedTab: AppTab { + AppTab(rawValue: appState.navigation.selectedTab) ?? .chats + } - /// True while the Tools section is showing a tool whose `prefersCollapsedSidebar` is set. - private var isSidebarCollapsingToolOpen: Bool { - selectedTab == .tools && appState.navigation.selectedTool?.prefersCollapsedSidebar == true - } + /// True while the Tools section is showing a tool whose `prefersCollapsedSidebar` is set. + private var isSidebarCollapsingToolOpen: Bool { + selectedTab == .tools && appState.navigation.selectedTool?.prefersCollapsedSidebar == true + } - private func desiredSidebarVisibility(isWide: Bool) -> NavigationSplitViewVisibility { - Self.sidebarVisibility( - isWide: isWide, - toolCollapsesSidebar: isSidebarCollapsingToolOpen, - sectionCollapsed: selectedTab.collapsedSidebarVisibility - ) - } + private func desiredSidebarVisibility(isWide: Bool) -> NavigationSplitViewVisibility { + Self.sidebarVisibility( + isWide: isWide, + toolCollapsesSidebar: isSidebarCollapsingToolOpen, + sectionCollapsed: selectedTab.collapsedSidebarVisibility + ) + } - /// Width override for the Tools content column: applied only when the Line of Sight analysis panel - /// is open, where the column must widen to fit the RF figures. In the plain tool-list state this is - /// nil so the column inherits the system-default content width, matching the Chats/Nodes/Settings - /// columns, which set no width modifier. - private var toolsContentWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat)? { - appState.navigation.selectedTool == .lineOfSight - ? (Self.lineOfSightPanelWidthMin, Self.lineOfSightPanelWidthIdeal, Self.lineOfSightPanelWidthMax) - : nil - } + /// Width override for the Tools content column: applied only when the Line of Sight analysis panel + /// is open, where the column must widen to fit the RF figures. In the plain tool-list state this is + /// nil so the column inherits the system-default content width, matching the Chats/Nodes/Settings + /// columns, which set no width modifier. + private var toolsContentWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat)? { + appState.navigation.selectedTool == .lineOfSight + ? (Self.lineOfSightPanelWidthMin, Self.lineOfSightPanelWidthIdeal, Self.lineOfSightPanelWidthMax) + : nil + } - var body: some View { - shell - // Width is read at the shell because a content column misreports horizontalSizeClass as - // .compact on regular iPad. The transform stays the bare comparison (allocation-free); the - // action fires once on first layout and once per threshold crossing (the Bool dedups - // intermediate resize frames). This action is the single width-driven writer of - // columnVisibility and runs on first layout, so visibility is decided once measurement exists. - .onGeometryChange(for: Bool.self) { proxy in - proxy.size.width >= Self.sidebarTileableMinWidth - } action: { isWide in - appState.navigation.isSidebarWide = isWide - // Wide reveals the tiled sidebar; narrow collapses to the section's hidden shape so a - // too-narrow container shows a single column rather than a sidebar overlay. selectedTab - // is read live so the collapsed shape matches the currently mounted shell. - columnVisibility = desiredSidebarVisibility(isWide: isWide) - } - .navigationSplitViewStyle(.balanced) - .themedChrome(theme) - .syncingPillOverlay(onDisconnectedTap: { showingDeviceSelection = true }) - .onChange(of: appState.navigation.selectedTab) { _, newValue in - clearToolSelectionWhenLeavingTools() - let newTab = AppTab(rawValue: newValue) ?? .chats - // Backstop for programmatic tab changes (deep links, notification taps) that bypass the - // sidebar's selection setter. Width-gated and idempotent so it never re-collapses a wide - // sidebar or duplicates a write the setter already made. - if !appState.navigation.isSidebarWide && columnVisibility != newTab.collapsedSidebarVisibility { - columnVisibility = newTab.collapsedSidebarVisibility - } - // The collapse drops the tapped sidebar row, so steer VoiceOver into the new content. - focusedColumn = newTab - // The radio sits in the section toolbar on every tab, so donate the tip when one waits. - if appState.navigation.pendingDeviceMenuTipDonation { - Task { - await appState.donateDeviceMenuTip() - } - } - } - .onChange(of: appState.navigation.selectedTool) { _, _ in - // A tool's sidebar preference can change the desired visibility, so re-derive it whenever - // the selected tool changes. - columnVisibility = desiredSidebarVisibility(isWide: appState.navigation.isSidebarWide) - } - .onChange(of: appState.connectedDevice) { _, newDevice in - // An explicit (status-menu) disconnect tears down the connection without firing - // onConnectionLost, so clearPerRadioSelection never runs for it. Clear a now-dead - // radio-only tool and per-device settings page here; a radio-to-radio switch (device - // stays non-nil) is handled by clearPerRadioSelection instead. - if newDevice == nil { - appState.navigation.clearPerDeviceSelection() - } - } - .sheet(isPresented: $showingDeviceSelection) { - DeviceSelectionSheet() - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - } + var body: some View { + shell + // Width is read at the shell because a content column misreports horizontalSizeClass as + // .compact on regular iPad. The transform stays the bare comparison (allocation-free); the + // action fires once on first layout and once per threshold crossing (the Bool dedups + // intermediate resize frames). This action is the single width-driven writer of + // columnVisibility and runs on first layout, so visibility is decided once measurement exists. + .onGeometryChange(for: Bool.self) { proxy in + proxy.size.width >= Self.sidebarTileableMinWidth + } action: { isWide in + appState.navigation.isSidebarWide = isWide + // Wide reveals the tiled sidebar; narrow collapses to the section's hidden shape so a + // too-narrow container shows a single column rather than a sidebar overlay. selectedTab + // is read live so the collapsed shape matches the currently mounted shell. + columnVisibility = desiredSidebarVisibility(isWide: isWide) + } + .navigationSplitViewStyle(.balanced) + .themedChrome(theme) + .syncingPillOverlay(onDisconnectedTap: { showingDeviceSelection = true }) + .onChange(of: appState.navigation.selectedTab) { _, newValue in + clearToolSelectionWhenLeavingTools() + let newTab = AppTab(rawValue: newValue) ?? .chats + // Backstop for programmatic tab changes (deep links, notification taps) that bypass the + // sidebar's selection setter. Width-gated and idempotent so it never re-collapses a wide + // sidebar or duplicates a write the setter already made. + if !appState.navigation.isSidebarWide, columnVisibility != newTab.collapsedSidebarVisibility { + columnVisibility = newTab.collapsedSidebarVisibility + } + // The collapse drops the tapped sidebar row, so steer VoiceOver into the new content. + focusedColumn = newTab + // The radio sits in the section toolbar on every tab, so donate the tip when one waits. + if appState.navigation.pendingDeviceMenuTipDonation { + Task { + await appState.donateDeviceMenuTip() + } + } + } + .onChange(of: appState.navigation.selectedTool) { _, _ in + // A tool's sidebar preference can change the desired visibility, so re-derive it whenever + // the selected tool changes. + columnVisibility = desiredSidebarVisibility(isWide: appState.navigation.isSidebarWide) + } + .onChange(of: appState.connectedDevice) { _, newDevice in + // An explicit (status-menu) disconnect tears down the connection without firing + // onConnectionLost, so clearPerRadioSelection never runs for it. Clear a now-dead + // radio-only tool and per-device settings page here; a radio-to-radio switch (device + // stays non-nil) is handled by clearPerRadioSelection instead. + if newDevice == nil { + appState.navigation.clearPerDeviceSelection() + } + } + .sheet(isPresented: $showingDeviceSelection) { + DeviceSelectionSheet() + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + } - // MARK: - Section shell + // MARK: - Section shell - @ViewBuilder - private var shell: some View { - switch selectedTab { - case .map: - NavigationSplitView(columnVisibility: $columnVisibility) { - AppSidebar(columnVisibility: $columnVisibility) - .navigationSplitViewColumnWidth(Self.sidebarColumnWidth) - } detail: { - // The map already draws full-bleed (its canvas ignores the safe area), so it - // extends under the floating sidebar and status bar with real tiles. The - // backgroundExtensionEffect used for static section backgrounds would instead - // mirror the map's edges into the safe area, so it is deliberately omitted here. - MapView() - .accessibilityFocused($focusedColumn, equals: .map) - } - case .chats, .nodes, .settings, .tools: - NavigationSplitView(columnVisibility: $columnVisibility) { - AppSidebar(columnVisibility: $columnVisibility) - .navigationSplitViewColumnWidth(Self.sidebarColumnWidth) - } content: { - contentColumn - } detail: { - detailColumn - } - } + @ViewBuilder + private var shell: some View { + switch selectedTab { + case .map: + NavigationSplitView(columnVisibility: $columnVisibility) { + AppSidebar(columnVisibility: $columnVisibility) + .navigationSplitViewColumnWidth(Self.sidebarColumnWidth) + } detail: { + // The map already draws full-bleed (its canvas ignores the safe area), so it + // extends under the floating sidebar and status bar with real tiles. The + // backgroundExtensionEffect used for static section backgrounds would instead + // mirror the map's edges into the safe area, so it is deliberately omitted here. + MapView() + .accessibilityFocused($focusedColumn, equals: .map) + } + case .chats, .nodes, .settings, .tools: + NavigationSplitView(columnVisibility: $columnVisibility) { + AppSidebar(columnVisibility: $columnVisibility) + .navigationSplitViewColumnWidth(Self.sidebarColumnWidth) + } content: { + contentColumn + } detail: { + detailColumn + } } + } - // MARK: - Content column + // MARK: - Content column - @ViewBuilder - private var contentColumn: some View { - switch selectedTab { - case .chats: - NavigationStack { - ChatsContentColumn(viewModel: chatViewModel) - .accessibilityFocused($focusedColumn, equals: .chats) - } - .modifier(SidebarContentColumnBackground(theme: theme)) - case .nodes: - NavigationStack { - ContactsContentColumn() - .accessibilityFocused($focusedColumn, equals: .nodes) - } - .modifier(SidebarContentColumnBackground(theme: theme)) - case .settings: - SettingsListContent( - showingDeviceSelection: $settingsShowingDeviceSelection, - demoModeManager: settingsDemoModeManager, - isSidebar: true - ) - .accessibilityFocused($focusedColumn, equals: .settings) - .modifier(SidebarContentColumnBackground(theme: theme)) - case .tools: - // Unlike the other sections, Tools applies SidebarContentColumnBackground itself (on the - // tool list only) so the Line of Sight analysis panel keeps its opaque background. - ToolsContentColumn(lineOfSightViewModel: lineOfSightViewModel) - .accessibilityFocused($focusedColumn, equals: .tools) - .modifier(OptionalColumnWidth(width: toolsContentWidth)) - case .map: - // Map renders through the two-column shell, never this column. - EmptyView() - } + @ViewBuilder + private var contentColumn: some View { + switch selectedTab { + case .chats: + NavigationStack { + ChatsContentColumn(viewModel: chatViewModel) + .accessibilityFocused($focusedColumn, equals: .chats) + } + .modifier(SidebarContentColumnBackground(theme: theme)) + case .nodes: + NavigationStack { + ContactsContentColumn() + .accessibilityFocused($focusedColumn, equals: .nodes) + } + .modifier(SidebarContentColumnBackground(theme: theme)) + case .settings: + SettingsListContent( + showingDeviceSelection: $settingsShowingDeviceSelection, + demoModeManager: settingsDemoModeManager, + isSidebar: true + ) + .accessibilityFocused($focusedColumn, equals: .settings) + .modifier(SidebarContentColumnBackground(theme: theme)) + case .tools: + // Unlike the other sections, Tools applies SidebarContentColumnBackground itself (on the + // tool list only) so the Line of Sight analysis panel keeps its opaque background. + ToolsContentColumn(lineOfSightViewModel: lineOfSightViewModel) + .accessibilityFocused($focusedColumn, equals: .tools) + .modifier(OptionalColumnWidth(width: toolsContentWidth)) + case .map: + // Map renders through the two-column shell, never this column. + EmptyView() } + } - // MARK: - Detail column + // MARK: - Detail column - @ViewBuilder - private var detailColumn: some View { - switch selectedTab { - case .chats: - NavigationStack { - ChatsSplitDetailContent(viewModel: chatViewModel) - } - .id(appState.navigation.chatsSelectedRoute?.conversationID) - case .nodes: - NavigationStack { - ContactsDetailColumn() - } - .id(appState.navigation.selectedContact?.id) - case .settings: - NavigationStack { - if let setting = appState.navigation.selectedSetting { - SettingsDetailView(detail: setting) - } else { - ContentUnavailableView(L10n.Settings.selectSetting, systemImage: "gear") - } - } - .id(appState.navigation.selectedSetting) - case .tools: - ToolsDetailColumn(lineOfSightViewModel: lineOfSightViewModel) - case .map: - // Map renders through the two-column shell, never this column. - EmptyView() + @ViewBuilder + private var detailColumn: some View { + switch selectedTab { + case .chats: + NavigationStack { + ChatsSplitDetailContent(viewModel: chatViewModel) + } + .id(appState.navigation.chatsSelectedRoute?.conversationID) + case .nodes: + NavigationStack { + ContactsDetailColumn() + } + .id(appState.navigation.selectedContact?.id) + case .settings: + NavigationStack { + if let setting = appState.navigation.selectedSetting { + SettingsDetailView(detail: setting) + } else { + ContentUnavailableView(L10n.Settings.selectSetting, systemImage: "gear") } + } + .id(appState.navigation.selectedSetting) + case .tools: + ToolsDetailColumn(lineOfSightViewModel: lineOfSightViewModel) + case .map: + // Map renders through the two-column shell, never this column. + EmptyView() } + } - // MARK: - Tool selection lifecycle + // MARK: - Tool selection lifecycle - /// Tools selection persists in `NavigationCoordinator`; clear it when leaving the Tools tab so - /// returning lands on the tool list rather than a previously open tool. - private func clearToolSelectionWhenLeavingTools() { - if selectedTab != .tools { - appState.navigation.selectedTool = nil - } + /// Tools selection persists in `NavigationCoordinator`; clear it when leaving the Tools tab so + /// returning lands on the tool list rather than a previously open tool. + private func clearToolSelectionWhenLeavingTools() { + if selectedTab != .tools { + appState.navigation.selectedTool = nil } + } } /// Applies a content-column width override only when one is supplied, leaving the column at the @@ -271,18 +271,18 @@ struct MainSidebarView: View { /// rather than animating, which is acceptable because the only caller swaps the column's content on /// the same toggle and drives it from external state. private struct OptionalColumnWidth: ViewModifier { - let width: (min: CGFloat, ideal: CGFloat, max: CGFloat)? + let width: (min: CGFloat, ideal: CGFloat, max: CGFloat)? - func body(content: Content) -> some View { - if let width { - content.navigationSplitViewColumnWidth(min: width.min, ideal: width.ideal, max: width.max) - } else { - content - } + func body(content: Content) -> some View { + if let width { + content.navigationSplitViewColumnWidth(min: width.min, ideal: width.ideal, max: width.max) + } else { + content } + } } #Preview { - MainSidebarView() - .environment(\.appState, AppState()) + MainSidebarView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Map/ContactCalloutContent.swift b/MC1/Views/Map/ContactCalloutContent.swift index 3757c51a..68cd3150 100644 --- a/MC1/Views/Map/ContactCalloutContent.swift +++ b/MC1/Views/Map/ContactCalloutContent.swift @@ -1,93 +1,93 @@ -import SwiftUI import MC1Services +import SwiftUI /// SwiftUI content displayed in a popover callout when a map pin is tapped struct ContactCalloutContent: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - let contact: ContactDTO - let onDetail: () -> Void - let onMessage: () -> Void + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + let contact: ContactDTO + let onDetail: () -> Void + let onMessage: () -> Void - var body: some View { - VStack(alignment: .leading, spacing: 8) { - (Text(idPrefixHex) - .monospaced() - .foregroundStyle(.secondary) - + Text(" \(contact.displayName)")) - .font(.headline) - .accessibilityLabel(contact.displayName) + var body: some View { + VStack(alignment: .leading, spacing: 8) { + (Text(idPrefixHex) + .monospaced() + .foregroundStyle(.secondary) + + Text(" \(contact.displayName)")) + .font(.headline) + .accessibilityLabel(contact.displayName) - HStack(spacing: 6) { - Image(systemName: contact.type.iconSystemName) - .foregroundStyle(contact.type.displayColor) - Text(typeDisplayName) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .accessibilityElement(children: .combine) + HStack(spacing: 6) { + Image(systemName: contact.type.iconSystemName) + .foregroundStyle(contact.type.displayColor) + Text(typeDisplayName) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) - Divider() + Divider() - // Action buttons - same width - VStack(spacing: 6) { - Button(L10n.Map.Map.Callout.details, systemImage: "info.circle", action: onDetail) - .buttonStyle(.bordered) - .accessibilityHint(contact.displayName) + // Action buttons - same width + VStack(spacing: 6) { + Button(L10n.Map.Map.Callout.details, systemImage: "info.circle", action: onDetail) + .buttonStyle(.bordered) + .accessibilityHint(contact.displayName) - if contact.type == .chat { - Button { - dismiss() - onMessage() - } label: { - Label(L10n.Map.Map.Callout.message, systemImage: "message.fill") - } - .buttonStyle(.bordered) - .accessibilityHint(contact.displayName) - } - } - .frame(maxWidth: .infinity) + if contact.type == .chat { + Button { + dismiss() + onMessage() + } label: { + Label(L10n.Map.Map.Callout.message, systemImage: "message.fill") + } + .buttonStyle(.bordered) + .accessibilityHint(contact.displayName) } - .padding(12) - .frame(minWidth: 160) + } + .frame(maxWidth: .infinity) } + .padding(12) + .frame(minWidth: 160) + } - // MARK: - Computed Properties + // MARK: - Computed Properties - private var idPrefixHex: String { - let hashSize = appState.connectedDevice?.hashSize ?? 1 - return contact.publicKey.prefix(hashSize).uppercaseHexString() - } + private var idPrefixHex: String { + let hashSize = appState.connectedDevice?.hashSize ?? 1 + return contact.publicKey.prefix(hashSize).uppercaseHexString() + } - private var typeDisplayName: String { - switch contact.type { - case .chat: - L10n.Map.Map.Callout.NodeKind.contact - case .repeater: - L10n.Map.Map.Callout.NodeKind.repeater - case .room: - L10n.Map.Map.Callout.NodeKind.room - } + private var typeDisplayName: String { + switch contact.type { + case .chat: + L10n.Map.Map.Callout.NodeKind.contact + case .repeater: + L10n.Map.Map.Callout.NodeKind.repeater + case .room: + L10n.Map.Map.Callout.NodeKind.room } + } } // MARK: - Preview #Preview { - ContactCalloutContent( - contact: ContactDTO( - from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x01, count: 32), - name: "Alice", - typeRawValue: 0, - latitude: 37.7749, - longitude: -122.4194, - isFavorite: true - ) - ), - onDetail: {}, - onMessage: {} - ) - .background(Color(.systemBackground)) + ContactCalloutContent( + contact: ContactDTO( + from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x01, count: 32), + name: "Alice", + typeRawValue: 0, + latitude: 37.7749, + longitude: -122.4194, + isFavorite: true + ) + ), + onDetail: {}, + onMessage: {} + ) + .background(Color(.systemBackground)) } diff --git a/MC1/Views/Map/ContactDetailSheet.swift b/MC1/Views/Map/ContactDetailSheet.swift index 15301864..5447d27f 100644 --- a/MC1/Views/Map/ContactDetailSheet.swift +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -1,354 +1,353 @@ -import SwiftUI import MC1Services +import SwiftUI // MARK: - Contact Detail Sheet struct ContactDetailSheet: View { - let contact: ContactDTO - let onMessage: () -> Void - let onDelete: () -> Void - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - - init(contact: ContactDTO, onMessage: @escaping () -> Void, onDelete: @escaping () -> Void) { - self.contact = contact - self.onMessage = onMessage - self.onDelete = onDelete - _isFavorite = State(initialValue: contact.isFavorite) + let contact: ContactDTO + let onMessage: () -> Void + let onDelete: () -> Void + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + + init(contact: ContactDTO, onMessage: @escaping () -> Void, onDelete: @escaping () -> Void) { + self.contact = contact + self.onMessage = onMessage + self.onDelete = onDelete + _isFavorite = State(initialValue: contact.isFavorite) + } + + /// Sheet types for repeater flows + private enum ActiveSheet: Identifiable, Hashable { + case telemetryAuth + case telemetryStatus(RemoteNodeSessionDTO) + case adminAuth + case adminSettings(RemoteNodeSessionDTO) + case roomJoin + + var id: String { + switch self { + case .telemetryAuth: "telemetryAuth" + case let .telemetryStatus(s): "telemetryStatus-\(s.id)" + case .adminAuth: "adminAuth" + case let .adminSettings(s): "adminSettings-\(s.id)" + case .roomJoin: "roomJoin" + } } + } + + @State private var activeSheet: ActiveSheet? + @State private var pendingSheet: ActiveSheet? + @State private var isPinging = false + @State private var pingResult: PingResult? + @State private var showingDeleteAlert = false + @State private var isDeleting = false + @State private var isFavorite: Bool + @State private var isTogglingFavorite = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + List { + // Basic info section + Section(L10n.Map.Map.Detail.Section.contactInfo) { + LabeledContent(L10n.Map.Map.Detail.name, value: contact.displayName) + + LabeledContent(L10n.Map.Map.Detail.type) { + HStack { + Image(systemName: contact.type.iconSystemName) + Text(typeDisplayName) + } + .foregroundStyle(contact.type.displayColor) + } + + if isFavorite { + LabeledContent(L10n.Map.Map.Detail.status) { + HStack { + Image(systemName: "star.fill") + Text(L10n.Map.Map.Detail.favorite) + } + .foregroundStyle(.orange) + } + } - /// Sheet types for repeater flows - private enum ActiveSheet: Identifiable, Hashable { - case telemetryAuth - case telemetryStatus(RemoteNodeSessionDTO) - case adminAuth - case adminSettings(RemoteNodeSessionDTO) - case roomJoin - - var id: String { - switch self { - case .telemetryAuth: "telemetryAuth" - case .telemetryStatus(let s): "telemetryStatus-\(s.id)" - case .adminAuth: "adminAuth" - case .adminSettings(let s): "adminSettings-\(s.id)" - case .roomJoin: "roomJoin" + if contact.lastAdvertTimestamp > 0 { + LabeledContent(L10n.Map.Map.Detail.lastAdvert) { + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) } + } + + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Map.Map.Detail.publicKey) + .font(.caption) + .foregroundStyle(.secondary) + Text(contact.publicKey.uppercaseHexString(separator: " ")) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } } - } - @State private var activeSheet: ActiveSheet? - @State private var pendingSheet: ActiveSheet? - @State private var isPinging = false - @State private var pingResult: PingResult? - @State private var showingDeleteAlert = false - @State private var isDeleting = false - @State private var isFavorite: Bool - @State private var isTogglingFavorite = false - @State private var errorMessage: String? - - var body: some View { - NavigationStack { - List { - // Basic info section - Section(L10n.Map.Map.Detail.Section.contactInfo) { - LabeledContent(L10n.Map.Map.Detail.name, value: contact.displayName) - - LabeledContent(L10n.Map.Map.Detail.type) { - HStack { - Image(systemName: contact.type.iconSystemName) - Text(typeDisplayName) - } - .foregroundStyle(contact.type.displayColor) - } - - if isFavorite { - LabeledContent(L10n.Map.Map.Detail.status) { - HStack { - Image(systemName: "star.fill") - Text(L10n.Map.Map.Detail.favorite) - } - .foregroundStyle(.orange) - } - } - - if contact.lastAdvertTimestamp > 0 { - LabeledContent(L10n.Map.Map.Detail.lastAdvert) { - ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) - } - } - - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Map.Map.Detail.publicKey) - .font(.caption) - .foregroundStyle(.secondary) - Text(contact.publicKey.uppercaseHexString(separator: " ")) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } + // Location section + Section(L10n.Map.Map.Detail.Section.location) { + LabeledContent(L10n.Map.Map.Detail.latitude) { + Text(contact.latitude, format: .number.precision(.fractionLength(6))) + } - // Location section - Section(L10n.Map.Map.Detail.Section.location) { - LabeledContent(L10n.Map.Map.Detail.latitude) { - Text(contact.latitude, format: .number.precision(.fractionLength(6))) - } + LabeledContent(L10n.Map.Map.Detail.longitude) { + Text(contact.longitude, format: .number.precision(.fractionLength(6))) + } + } - LabeledContent(L10n.Map.Map.Detail.longitude) { - Text(contact.longitude, format: .number.precision(.fractionLength(6))) - } - } + // Path info section + Section(L10n.Map.Map.Detail.Section.outboundPath) { + if contact.isFloodRouted { + LabeledContent(L10n.Map.Map.Detail.routing, value: L10n.Map.Map.Detail.routingFlood) + } else { + let hopCount = contact.pathHopCount + LabeledContent(L10n.Map.Map.Detail.pathLength, value: hopCount == 1 ? L10n.Map.Map.Detail.hopSingular : L10n.Map.Map.Detail.hops(hopCount)) + } + } - // Path info section - Section(L10n.Map.Map.Detail.Section.outboundPath) { - if contact.isFloodRouted { - LabeledContent(L10n.Map.Map.Detail.routing, value: L10n.Map.Map.Detail.routingFlood) - } else { - let hopCount = contact.pathHopCount - LabeledContent(L10n.Map.Map.Detail.pathLength, value: hopCount == 1 ? L10n.Map.Map.Detail.hopSingular : L10n.Map.Map.Detail.hops(hopCount)) - } - } + // Actions section + Section { + switch contact.type { + case .repeater: + Button { + activeSheet = .telemetryAuth + } label: { + Label(L10n.Map.Map.Detail.Action.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + .radioDisabled(for: appState.connectionState) - // Actions section - Section { - switch contact.type { - case .repeater: - Button { - activeSheet = .telemetryAuth - } label: { - Label(L10n.Map.Map.Detail.Action.telemetry, systemImage: "chart.line.uptrend.xyaxis") - } - .radioDisabled(for: appState.connectionState) - - Button { - activeSheet = .adminAuth - } label: { - Label(L10n.Map.Map.Detail.Action.management, systemImage: "gearshape.2") - } - .radioDisabled(for: appState.connectionState) - - pingButton - - case .room: - Button { - activeSheet = .roomJoin - } label: { - Label(L10n.Map.Map.Detail.Action.joinRoom, systemImage: "door.left.hand.open") - } - .radioDisabled(for: appState.connectionState) - - pingButton - - case .chat: - Button { - dismiss() - onMessage() - } label: { - Label(L10n.Map.Map.Detail.Action.sendMessage, systemImage: "message.fill") - } - .radioDisabled(for: appState.connectionState) - } - } + Button { + activeSheet = .adminAuth + } label: { + Label(L10n.Map.Map.Detail.Action.management, systemImage: "gearshape.2") + } + .radioDisabled(for: appState.connectionState) - // Favorite section - Section { - Button { - Task { await toggleFavorite() } - } label: { - HStack { - Label( - isFavorite ? L10n.Contacts.Contacts.Detail.removeFromFavorites : L10n.Contacts.Contacts.Detail.addToFavorites, - systemImage: isFavorite ? "star.slash" : "star" - ) - if isTogglingFavorite { - Spacer() - ProgressView() - } - } - } - .disabled(isTogglingFavorite) - .radioDisabled(for: appState.connectionState) - } + pingButton - // Delete section - Section { - Button(role: .destructive) { - showingDeleteAlert = true - } label: { - HStack { - Label(L10n.Contacts.Contacts.Common.delete, systemImage: "trash") - if isDeleting { - Spacer() - ProgressView() - } - } - } - .disabled(isDeleting) - .radioDisabled(for: appState.connectionState) - } - } - .navigationTitle(contact.displayName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Map.Map.Common.done) { - dismiss() - } - } + case .room: + Button { + activeSheet = .roomJoin + } label: { + Label(L10n.Map.Map.Detail.Action.joinRoom, systemImage: "door.left.hand.open") } - .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in - switch sheet { - case .telemetryAuth: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet( - contact: contact, - role: role, - customTitle: L10n.Map.Map.Detail.Action.telemetryAccessTitle - ) { session in - pendingSheet = .telemetryStatus(session) - activeSheet = nil - } - .presentationSizing(.page) - } - - case .telemetryStatus(let session): - RepeaterStatusView(session: session) - - case .adminAuth: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet(contact: contact, role: role) { session in - pendingSheet = .adminSettings(session) - activeSheet = nil - } - .presentationSizing(.page) - } - - case .adminSettings(let session): - NavigationStack { - RepeaterSettingsView(session: session) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Map.Map.Common.done) { - activeSheet = nil - } - } - } - } - .presentationSizing(.page) - - case .roomJoin: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet(contact: contact, role: role) { session in - activeSheet = nil - dismiss() - appState.navigation.navigateToRoom(with: session) - } - .presentationSizing(.page) - } - } - } - .alert( - L10n.Contacts.Contacts.Detail.Alert.Delete.title(typeDisplayName), - isPresented: $showingDeleteAlert - ) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { - Task { await deleteContact() } - } - } message: { - Text(L10n.Contacts.Contacts.Detail.Alert.Delete.message(contact.displayName)) + .radioDisabled(for: appState.connectionState) + + pingButton + + case .chat: + Button { + dismiss() + onMessage() + } label: { + Label(L10n.Map.Map.Detail.Action.sendMessage, systemImage: "message.fill") } - .errorAlert($errorMessage) + .radioDisabled(for: appState.connectionState) + } } - } - // MARK: - Favorite - - private func toggleFavorite() async { - isTogglingFavorite = true - defer { isTogglingFavorite = false } - let newValue = !isFavorite - do { - try await appState.services?.contactService.setContactFavorite(contact.id, isFavorite: newValue) - isFavorite = newValue - } catch { - errorMessage = error.userFacingMessage + // Favorite section + Section { + Button { + Task { await toggleFavorite() } + } label: { + HStack { + Label( + isFavorite ? L10n.Contacts.Contacts.Detail.removeFromFavorites : L10n.Contacts.Contacts.Detail.addToFavorites, + systemImage: isFavorite ? "star.slash" : "star" + ) + if isTogglingFavorite { + Spacer() + ProgressView() + } + } + } + .disabled(isTogglingFavorite) + .radioDisabled(for: appState.connectionState) } - } - // MARK: - Delete - - private func deleteContact() async { - guard let contactService = appState.services?.contactService else { return } - isDeleting = true - defer { isDeleting = false } - do { - try await contactService.removeContact(radioID: contact.radioID, publicKey: contact.publicKey) - dismiss() - onDelete() - } catch ContactServiceError.contactNotFound { - do { - try await contactService.removeLocalContact(contactID: contact.id, publicKey: contact.publicKey) - dismiss() - onDelete() - } catch { - errorMessage = error.userFacingMessage + // Delete section + Section { + Button(role: .destructive) { + showingDeleteAlert = true + } label: { + HStack { + Label(L10n.Contacts.Contacts.Common.delete, systemImage: "trash") + if isDeleting { + Spacer() + ProgressView() + } } - } catch { - errorMessage = error.userFacingMessage + } + .disabled(isDeleting) + .radioDisabled(for: appState.connectionState) } - } + } + .navigationTitle(contact.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Map.Map.Common.done) { + dismiss() + } + } + } + .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in + switch sheet { + case .telemetryAuth: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet( + contact: contact, + role: role, + customTitle: L10n.Map.Map.Detail.Action.telemetryAccessTitle + ) { session in + pendingSheet = .telemetryStatus(session) + activeSheet = nil + } + .presentationSizing(.page) + } - // MARK: - Ping + case let .telemetryStatus(session): + RepeaterStatusView(session: session) - @ViewBuilder - private var pingButton: some View { - Button { - Task { await pingContact() } - } label: { - HStack { - Label(L10n.Contacts.Contacts.Detail.ping, systemImage: "wave.3.right") - if isPinging { - Spacer() - ProgressView() + case .adminAuth: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet(contact: contact, role: role) { session in + pendingSheet = .adminSettings(session) + activeSheet = nil + } + .presentationSizing(.page) + } + + case let .adminSettings(session): + NavigationStack { + RepeaterSettingsView(session: session) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Map.Map.Common.done) { + activeSheet = nil + } } + } + } + .presentationSizing(.page) + + case .roomJoin: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet(contact: contact, role: role) { session in + activeSheet = nil + dismiss() + appState.navigation.navigateToRoom(with: session) } + .presentationSizing(.page) + } } - .disabled(isPinging) - .radioDisabled(for: appState.connectionState) - - if let result = pingResult { - PingResultRow(result: result) + } + .alert( + L10n.Contacts.Contacts.Detail.Alert.Delete.title(typeDisplayName), + isPresented: $showingDeleteAlert + ) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { + Task { await deleteContact() } } + } message: { + Text(L10n.Contacts.Contacts.Detail.Alert.Delete.message(contact.displayName)) + } + .errorAlert($errorMessage) } - - private func pingContact() async { - guard !isPinging else { return } - isPinging = true - pingResult = nil - pingResult = await PingHelper.zeroHopPing(contact: contact, appState: appState) - isPinging = false + } + + // MARK: - Favorite + + private func toggleFavorite() async { + isTogglingFavorite = true + defer { isTogglingFavorite = false } + let newValue = !isFavorite + do { + try await appState.services?.contactService.setContactFavorite(contact.id, isFavorite: newValue) + isFavorite = newValue + } catch { + errorMessage = error.userFacingMessage } - - // MARK: - Sheet Management - - private func presentPendingSheet() { - if let next = pendingSheet { - pendingSheet = nil - activeSheet = next - } + } + + // MARK: - Delete + + private func deleteContact() async { + guard let contactService = appState.services?.contactService else { return } + isDeleting = true + defer { isDeleting = false } + do { + try await contactService.removeContact(radioID: contact.radioID, publicKey: contact.publicKey) + dismiss() + onDelete() + } catch ContactServiceError.contactNotFound { + do { + try await contactService.removeLocalContact(contactID: contact.id, publicKey: contact.publicKey) + dismiss() + onDelete() + } catch { + errorMessage = error.userFacingMessage + } + } catch { + errorMessage = error.userFacingMessage } - - // MARK: - Computed Properties - - private var typeDisplayName: String { - switch contact.type { - case .chat: - L10n.Map.Map.NodeKind.chatContact - case .repeater: - L10n.Map.Map.NodeKind.repeater - case .room: - L10n.Map.Map.NodeKind.room + } + + // MARK: - Ping + + @ViewBuilder + private var pingButton: some View { + Button { + Task { await pingContact() } + } label: { + HStack { + Label(L10n.Contacts.Contacts.Detail.ping, systemImage: "wave.3.right") + if isPinging { + Spacer() + ProgressView() } + } } + .disabled(isPinging) + .radioDisabled(for: appState.connectionState) + if let result = pingResult { + PingResultRow(result: result) + } + } + + private func pingContact() async { + guard !isPinging else { return } + isPinging = true + pingResult = nil + pingResult = await PingHelper.zeroHopPing(contact: contact, appState: appState) + isPinging = false + } + + // MARK: - Sheet Management + + private func presentPendingSheet() { + if let next = pendingSheet { + pendingSheet = nil + activeSheet = next + } + } + + // MARK: - Computed Properties + + private var typeDisplayName: String { + switch contact.type { + case .chat: + L10n.Map.Map.NodeKind.chatContact + case .repeater: + L10n.Map.Map.NodeKind.repeater + case .room: + L10n.Map.Map.NodeKind.room + } + } } diff --git a/MC1/Views/Map/DroppedPinCallout.swift b/MC1/Views/Map/DroppedPinCallout.swift index 042fc02c..418e363e 100644 --- a/MC1/Views/Map/DroppedPinCallout.swift +++ b/MC1/Views/Map/DroppedPinCallout.swift @@ -1,41 +1,41 @@ -import SwiftUI import MapKit +import SwiftUI /// Popover callout for a chat-dropped map pin: the coordinate, an Apple Maps hand-off, /// and a clear action. Sized like the contact callout. struct DroppedPinCallout: View { - let coordinate: CLLocationCoordinate2D - let onClear: () -> Void + let coordinate: CLLocationCoordinate2D + let onClear: () -> Void - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(coordinate.formattedString) - .font(.headline) - .monospaced() + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(coordinate.formattedString) + .font(.headline) + .monospaced() - Divider() + Divider() - VStack(spacing: 6) { - Button(L10n.Contacts.Contacts.Detail.openInMaps, systemImage: "map") { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coordinate)) - mapItem.openInMaps() - } - .buttonStyle(.bordered) - - Button(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark", action: onClear) - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity) + VStack(spacing: 6) { + Button(L10n.Contacts.Contacts.Detail.openInMaps, systemImage: "map") { + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coordinate)) + mapItem.openInMaps() } - .padding(12) - .frame(minWidth: 160) + .buttonStyle(.bordered) + + Button(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark", action: onClear) + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity) } + .padding(12) + .frame(minWidth: 160) + } } #Preview { - DroppedPinCallout( - coordinate: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.00902), - onClear: {} - ) - .background(Color(.systemBackground)) + DroppedPinCallout( + coordinate: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.00902), + onClear: {} + ) + .background(Color(.systemBackground)) } diff --git a/MC1/Views/Map/LayersMenu.swift b/MC1/Views/Map/LayersMenu.swift index 4f577cd9..7aeef0d3 100644 --- a/MC1/Views/Map/LayersMenu.swift +++ b/MC1/Views/Map/LayersMenu.swift @@ -3,73 +3,73 @@ import SwiftUI /// Dropdown menu for selecting map layers struct LayersMenu: View { - @Environment(\.appState) private var appState - @Binding var selection: MapStyleSelection - @Binding var isPresented: Bool - var viewportBounds: MLNCoordinateBounds? + @Environment(\.appState) private var appState + @Binding var selection: MapStyleSelection + @Binding var isPresented: Bool + var viewportBounds: MLNCoordinateBounds? - var body: some View { - VStack(spacing: 0) { - ForEach(MapStyleSelection.allCases, id: \.self) { style in - let isDisabled = !appState.offlineMapService.isNetworkAvailable - && (style.requiresNetwork - || !hasOfflineCoverage(for: style)) + var body: some View { + VStack(spacing: 0) { + ForEach(MapStyleSelection.allCases, id: \.self) { style in + let isDisabled = !appState.offlineMapService.isNetworkAvailable + && (style.requiresNetwork + || !hasOfflineCoverage(for: style)) - Button { - selection = style - withAnimation { - isPresented = false - } - } label: { - HStack { - Text(style.label) - .foregroundStyle(isDisabled ? .secondary : .primary) - Spacer() - if selection == style { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .disabled(isDisabled) - .accessibilityHint(isDisabled ? disabledReason(for: style) : "") - - if style != MapStyleSelection.allCases.last { - Divider() - } + Button { + selection = style + withAnimation { + isPresented = false + } + } label: { + HStack { + Text(style.label) + .foregroundStyle(isDisabled ? .secondary : .primary) + Spacer() + if selection == style { + Image(systemName: "checkmark") + .foregroundStyle(.tint) } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) } - .frame(width: 140) - .liquidGlass(in: .rect(cornerRadius: 12)) - .shadow(color: .black.opacity(0.2), radius: 8, y: 4) - .accessibilityElement(children: .contain) - .accessibilityLabel(L10n.Map.Map.Style.accessibilityLabel) - } + .disabled(isDisabled) + .accessibilityHint(isDisabled ? disabledReason(for: style) : "") - private func hasOfflineCoverage(for style: MapStyleSelection) -> Bool { - if let viewportBounds { - appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer, overlapping: viewportBounds) - } else { - appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer) + if style != MapStyleSelection.allCases.last { + Divider() } + } } + .frame(width: 140) + .liquidGlass(in: .rect(cornerRadius: 12)) + .shadow(color: .black.opacity(0.2), radius: 8, y: 4) + .accessibilityElement(children: .contain) + .accessibilityLabel(L10n.Map.Map.Style.accessibilityLabel) + } - private func disabledReason(for style: MapStyleSelection) -> String { - if style.requiresNetwork { - L10n.Map.Map.Style.requiresNetwork - } else { - L10n.Map.Map.Style.noOfflineCoverage - } + private func hasOfflineCoverage(for style: MapStyleSelection) -> Bool { + if let viewportBounds { + appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer, overlapping: viewportBounds) + } else { + appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer) + } + } + + private func disabledReason(for style: MapStyleSelection) -> String { + if style.requiresNetwork { + L10n.Map.Map.Style.requiresNetwork + } else { + L10n.Map.Map.Style.noOfflineCoverage } + } } #Preview { - LayersMenu( - selection: .constant(.standard), - isPresented: .constant(true) - ) - .padding() - .environment(\.appState, AppState()) + LayersMenu( + selection: .constant(.standard), + isPresented: .constant(true) + ) + .padding() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Map/LocationPickerView.swift b/MC1/Views/Map/LocationPickerView.swift index 62c3a473..0dc0441b 100644 --- a/MC1/Views/Map/LocationPickerView.swift +++ b/MC1/Views/Map/LocationPickerView.swift @@ -1,302 +1,302 @@ -import SwiftUI -import MapKit import CoreLocation +import MapKit import MC1Services +import SwiftUI /// Map-based location picker for setting node position struct LocationPickerView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.colorScheme) private var colorScheme - - // Configuration - private let initialCoordinate: CLLocationCoordinate2D? - private let onSave: (CLLocationCoordinate2D) async throws -> Void - - // Stable marker identity - private let markerID = UUID() - - // UI State - @State private var cameraRegion: MKCoordinateRegion? - @State private var cameraRegionVersion = 0 - @State private var selectedCoordinate: CLLocationCoordinate2D? - @State private var isSaving = false - @State private var errorMessage: String? - - /// Generic initializer for any location-setting context - init( - initialCoordinate: CLLocationCoordinate2D? = nil, - onSave: @escaping (CLLocationCoordinate2D) async throws -> Void - ) { - self.initialCoordinate = initialCoordinate - self.onSave = onSave - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + + // Configuration + private let initialCoordinate: CLLocationCoordinate2D? + private let onSave: (CLLocationCoordinate2D) async throws -> Void + + /// Stable marker identity + private let markerID = UUID() + + // UI State + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 + @State private var selectedCoordinate: CLLocationCoordinate2D? + @State private var isSaving = false + @State private var errorMessage: String? + + /// Generic initializer for any location-setting context + init( + initialCoordinate: CLLocationCoordinate2D? = nil, + onSave: @escaping (CLLocationCoordinate2D) async throws -> Void + ) { + self.initialCoordinate = initialCoordinate + self.onSave = onSave + } + + var body: some View { + NavigationStack { + ZStack { + MC1MapView( + points: markerPoints, + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + showLabels: false, + showsUserLocation: true, + isInteractive: true, + showsScale: false, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + onPointTap: nil, + onMapTap: { coord in selectedCoordinate = coord }, + onCameraRegionChange: { region in cameraRegion = region } + ) - var body: some View { - NavigationStack { - ZStack { - MC1MapView( - points: markerPoints, - lines: [], - mapStyle: .standard, - isDarkMode: colorScheme == .dark, - showLabels: false, - showsUserLocation: true, - isInteractive: true, - showsScale: false, - cameraRegion: $cameraRegion, - cameraRegionVersion: cameraRegionVersion, - onPointTap: nil, - onMapTap: { coord in selectedCoordinate = coord }, - onCameraRegionChange: { region in cameraRegion = region } - ) - - // Center crosshair for precise placement - Image(systemName: "plus") - .font(.title) - .foregroundStyle(.secondary) - - // Coordinate display and actions - VStack { - Spacer() - - if let coord = selectedCoordinate { - VStack(spacing: 4) { - CoordinateText( - label: L10n.Settings.LocationPicker.latitude, - value: coord.latitude - ) - CoordinateText( - label: L10n.Settings.LocationPicker.longitude, - value: coord.longitude - ) - } - .font(.caption.monospacedDigit()) - .padding() - .background { - if #available(iOS 26.0, *) { - Color.clear - } else { - RoundedRectangle(cornerRadius: 8) - .fill(.ultraThinMaterial) - } - } - .modifier(CoordinateGlassModifier()) - } - - Group { - if #available(iOS 26.0, *) { - GlassEffectContainer { - makeButtonContent() - } - } else { - makeButtonContent() - } - } - .padding() - .padding(.bottom, 16) - } + // Center crosshair for precise placement + Image(systemName: "plus") + .font(.title) + .foregroundStyle(.secondary) + + // Coordinate display and actions + VStack { + Spacer() + + if let coord = selectedCoordinate { + VStack(spacing: 4) { + CoordinateText( + label: L10n.Settings.LocationPicker.latitude, + value: coord.latitude + ) + CoordinateText( + label: L10n.Settings.LocationPicker.longitude, + value: coord.longitude + ) } - .navigationTitle(L10n.Settings.LocationPicker.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Localizable.Common.save) { saveLocation() } - .radioDisabled(for: appState.connectionState, or: isSaving) - } + .font(.caption.monospacedDigit()) + .padding() + .background { + if #available(iOS 26.0, *) { + Color.clear + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.ultraThinMaterial) + } } - .onAppear { - loadCurrentLocation() + .modifier(CoordinateGlassModifier()) + } + + Group { + if #available(iOS 26.0, *) { + GlassEffectContainer { + makeButtonContent() + } + } else { + makeButtonContent() } - .onChange(of: appState.locationService.currentLocation) { _, newLocation in - // Only react if we haven't set a camera region yet (no saved location case) - guard let newLocation, - initialCoordinate == nil - || (initialCoordinate?.latitude == 0 && initialCoordinate?.longitude == 0), - cameraRegion == nil else { return } - - cameraRegion = MKCoordinateRegion( - center: newLocation.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - ) - cameraRegionVersion += 1 - } - .errorAlert($errorMessage) + } + .padding() + .padding(.bottom, 16) } - } - - private var markerPoints: [MapPoint] { - guard let coord = selectedCoordinate else { return [] } - return [MapPoint( - id: markerID, - coordinate: coord, - pinStyle: .pointA, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )] - } - - private func loadCurrentLocation() { - // Case 1: Existing saved location - if let coord = initialCoordinate, - CLLocationCoordinate2DIsValid(coord), - coord.latitude != 0 || coord.longitude != 0 { - selectedCoordinate = coord - cameraRegion = MKCoordinateRegion( - center: coord, - span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - ) - cameraRegionVersion += 1 - return + } + .navigationTitle(L10n.Settings.LocationPicker.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { dismiss() } } - - // Invalid coordinates (not nil, not zero, but out of range) — show default map - if let coord = initialCoordinate, - coord.latitude != 0 || coord.longitude != 0 { - return - } - - // Case 2: No saved location (nil or 0,0), check user location - let locationService = appState.locationService - if locationService.isAuthorized { - if let userLocation = locationService.currentLocation { - cameraRegion = MKCoordinateRegion( - center: userLocation.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - ) - cameraRegionVersion += 1 - } else if !locationService.isRequestingLocation { - locationService.requestLocation() - } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Localizable.Common.save) { saveLocation() } + .radioDisabled(for: appState.connectionState, or: isSaving) } + } + .onAppear { + loadCurrentLocation() + } + .onChange(of: appState.locationService.currentLocation) { _, newLocation in + // Only react if we haven't set a camera region yet (no saved location case) + guard let newLocation, + initialCoordinate == nil + || (initialCoordinate?.latitude == 0 && initialCoordinate?.longitude == 0), + cameraRegion == nil else { return } + + cameraRegion = MKCoordinateRegion( + center: newLocation.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) + cameraRegionVersion += 1 + } + .errorAlert($errorMessage) } - - private func dropPinAtCenter() { - if let region = cameraRegion { - selectedCoordinate = region.center - } + } + + private var markerPoints: [MapPoint] { + guard let coord = selectedCoordinate else { return [] } + return [MapPoint( + id: markerID, + coordinate: coord, + pinStyle: .pointA, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )] + } + + private func loadCurrentLocation() { + // Case 1: Existing saved location + if let coord = initialCoordinate, + CLLocationCoordinate2DIsValid(coord), + coord.latitude != 0 || coord.longitude != 0 { + selectedCoordinate = coord + cameraRegion = MKCoordinateRegion( + center: coord, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) + cameraRegionVersion += 1 + return } - private func saveLocation() { - let coord = selectedCoordinate ?? CLLocationCoordinate2D(latitude: 0, longitude: 0) - - isSaving = true - Task { - do { - try await onSave(coord) - dismiss() - } catch { - errorMessage = error.userFacingMessage - } - isSaving = false - } + // Invalid coordinates (not nil, not zero, but out of range) — show default map + if let coord = initialCoordinate, + coord.latitude != 0 || coord.longitude != 0 { + return } - private func makeButtonContent() -> some View { - ButtonContent( - hasSelectedCoordinate: selectedCoordinate != nil, - onClear: { selectedCoordinate = nil }, - onDropPin: { dropPinAtCenter() } + // Case 2: No saved location (nil or 0,0), check user location + let locationService = appState.locationService + if locationService.isAuthorized { + if let userLocation = locationService.currentLocation { + cameraRegion = MKCoordinateRegion( + center: userLocation.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) ) + cameraRegionVersion += 1 + } else if !locationService.isRequestingLocation { + locationService.requestLocation() + } } + } + + private func dropPinAtCenter() { + if let region = cameraRegion { + selectedCoordinate = region.center + } + } + + private func saveLocation() { + let coord = selectedCoordinate ?? CLLocationCoordinate2D(latitude: 0, longitude: 0) + + isSaving = true + Task { + do { + try await onSave(coord) + dismiss() + } catch { + errorMessage = error.userFacingMessage + } + isSaving = false + } + } + + private func makeButtonContent() -> some View { + ButtonContent( + hasSelectedCoordinate: selectedCoordinate != nil, + onClear: { selectedCoordinate = nil }, + onDropPin: { dropPinAtCenter() } + ) + } } private struct ButtonContent: View { - let hasSelectedCoordinate: Bool - let onClear: () -> Void - let onDropPin: () -> Void - - var body: some View { - HStack(spacing: 12) { - if hasSelectedCoordinate { - Button(L10n.Settings.LocationPicker.clearLocation, role: .destructive) { - onClear() - } - .liquidGlassSecondaryButtonStyle() - .controlSize(.regular) - } - - Button(L10n.Settings.LocationPicker.dropPin) { - onDropPin() - } - .liquidGlassProminentButtonStyle() - .controlSize(.regular) + let hasSelectedCoordinate: Bool + let onClear: () -> Void + let onDropPin: () -> Void + + var body: some View { + HStack(spacing: 12) { + if hasSelectedCoordinate { + Button(L10n.Settings.LocationPicker.clearLocation, role: .destructive) { + onClear() } + .liquidGlassSecondaryButtonStyle() + .controlSize(.regular) + } + + Button(L10n.Settings.LocationPicker.dropPin) { + onDropPin() + } + .liquidGlassProminentButtonStyle() + .controlSize(.regular) } + } } // MARK: - Local Device Convenience extension LocationPickerView { - /// Convenience initializer for local device location setting (Settings screen) - /// Retry handling for SettingsServiceError is handled at the call site since it requires RetryAlertState - static func forLocalDevice(appState: AppState) -> LocationPickerView { - let device = appState.connectedDevice - let initialCoord = device.map { - CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) - } - let devicePreferenceStore = DevicePreferenceStore() - - return LocationPickerView(initialCoordinate: initialCoord) { coordinate in - guard let settingsService = appState.services?.settingsService else { - throw SettingsServiceError.notConnected - } - let previousDevice = appState.connectedDevice - let verifiedInfo = try await settingsService.setManualLocationVerified( - latitude: coordinate.latitude, - longitude: coordinate.longitude - ) - - guard let previousDevice else { return } - - let wasUsingDeviceGPSAutoUpdate = - devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: previousDevice.id) && - devicePreferenceStore.gpsSource(deviceID: previousDevice.id) == .device - - if wasUsingDeviceGPSAutoUpdate { - devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: previousDevice.id) - } - - if previousDevice.sharesLocationPublicly, - previousDevice.advertLocationPolicyMode == .share { - let telemetryModes = TelemetryModes( - base: verifiedInfo.telemetryModeBase, - location: verifiedInfo.telemetryModeLocation, - environment: verifiedInfo.telemetryModeEnvironment - ) - _ = try await settingsService.setOtherParamsVerified( - autoAddContacts: !verifiedInfo.manualAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicy: .prefs, - multiAcks: verifiedInfo.multiAcks - ) - } - } + /// Convenience initializer for local device location setting (Settings screen) + /// Retry handling for SettingsServiceError is handled at the call site since it requires RetryAlertState + static func forLocalDevice(appState: AppState) -> LocationPickerView { + let device = appState.connectedDevice + let initialCoord = device.map { + CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } + let devicePreferenceStore = DevicePreferenceStore() + + return LocationPickerView(initialCoordinate: initialCoord) { coordinate in + guard let settingsService = appState.services?.settingsService else { + throw SettingsServiceError.notConnected + } + let previousDevice = appState.connectedDevice + let verifiedInfo = try await settingsService.setManualLocationVerified( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + + guard let previousDevice else { return } + + let wasUsingDeviceGPSAutoUpdate = + devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: previousDevice.id) && + devicePreferenceStore.gpsSource(deviceID: previousDevice.id) == .device + + if wasUsingDeviceGPSAutoUpdate { + devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: previousDevice.id) + } + + if previousDevice.sharesLocationPublicly, + previousDevice.advertLocationPolicyMode == .share { + let telemetryModes = TelemetryModes( + base: verifiedInfo.telemetryModeBase, + location: verifiedInfo.telemetryModeLocation, + environment: verifiedInfo.telemetryModeEnvironment + ) + _ = try await settingsService.setOtherParamsVerified( + autoAddContacts: !verifiedInfo.manualAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: .prefs, + multiAcks: verifiedInfo.multiAcks + ) + } + } + } } // MARK: - Liquid Glass Modifiers private struct CoordinateGlassModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.glassEffect(.regular, in: .rect(cornerRadius: 8)) - } else { - content - } + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.glassEffect(.regular, in: .rect(cornerRadius: 8)) + } else { + content } + } } private struct CoordinateText: View { - let label: String - let value: Double + let label: String + let value: Double - var body: some View { - Text("\(label) \(value, format: .number.precision(.fractionLength(6)))") - } + var body: some View { + Text("\(label) \(value, format: .number.precision(.fractionLength(6)))") + } } diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 39fce98d..ceb59194 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -10,441 +10,440 @@ private nonisolated(unsafe) let mapFontNames = NSExpression(forConstantValue: [" // MARK: - Layer and source identifiers enum MapLayerID { - static let clusterCircles = "cluster-circles" - static let clusterLabels = "cluster-labels" - static let unclusteredIcons = "unclustered-icons" - static let nameLabels = "name-labels" - static let badgeText = "badge-text" - static let fixedIcons = "fixed-icons" - static let fixedNameLabels = "fixed-name-labels" - static let fixedBadgeText = "fixed-badge-text" - static let lineLOS = "line-los" - static let lineLOSCasing = "line-los-casing" - static let lineTraceUntraced = "line-trace-untraced" - static let lineTraceWeak = "line-trace-weak" - static let lineTraceMedium = "line-trace-medium" - static let lineTraceGood = "line-trace-good" - static let lineTraceUntracedCasing = "line-trace-untraced-casing" - static let lineTraceWeakCasing = "line-trace-weak-casing" - static let lineTraceMediumCasing = "line-trace-medium-casing" - static let lineTraceGoodCasing = "line-trace-good-casing" - static let lineMessagePath = "line-message-path" - static let lineMessagePathCasing = "line-message-path-casing" - static let satelliteLayer = "satellite-layer" - static let topoLayer = "topo-layer" + static let clusterCircles = "cluster-circles" + static let clusterLabels = "cluster-labels" + static let unclusteredIcons = "unclustered-icons" + static let nameLabels = "name-labels" + static let badgeText = "badge-text" + static let fixedIcons = "fixed-icons" + static let fixedNameLabels = "fixed-name-labels" + static let fixedBadgeText = "fixed-badge-text" + static let lineLOS = "line-los" + static let lineLOSCasing = "line-los-casing" + static let lineTraceUntraced = "line-trace-untraced" + static let lineTraceWeak = "line-trace-weak" + static let lineTraceMedium = "line-trace-medium" + static let lineTraceGood = "line-trace-good" + static let lineTraceUntracedCasing = "line-trace-untraced-casing" + static let lineTraceWeakCasing = "line-trace-weak-casing" + static let lineTraceMediumCasing = "line-trace-medium-casing" + static let lineTraceGoodCasing = "line-trace-good-casing" + static let lineMessagePath = "line-message-path" + static let lineMessagePathCasing = "line-message-path-casing" + static let satelliteLayer = "satellite-layer" + static let topoLayer = "topo-layer" } enum MapSourceID { - static let points = "points" - static let fixedPoints = "fixed-points" - static let lines = "lines" - static let satelliteTiles = "satellite-tiles" - static let topoTiles = "topo-tiles" + static let points = "points" + static let fixedPoints = "fixed-points" + static let lines = "lines" + static let satelliteTiles = "satellite-tiles" + static let topoTiles = "topo-tiles" } extension MC1MapView.Coordinator { - - // MARK: - Update point source data - - /// Point sources and layers use deferred creation: they are created here - /// on first data arrival, not during style load. This avoids a MapLibre - /// bug where sources initialized without features ignore later `.shape` - /// updates. - func updatePointSource(mapView: MLNMapView) { - guard let style = mapView.style else { return } - - var clusterablePoints: [MapPoint] = [] - var fixedPoints: [MapPoint] = [] - for point in currentPoints { - if point.isClusterable { - clusterablePoints.append(point) - } else { - fixedPoints.append(point) - } - } - - // Clustered (contact) source — rebuild only when the clusterable subset changed, - // so toggling a fixed pin (the chat-dropped pin) does not re-cluster all contacts. - if clusterablePoints != lastAppliedClusterablePoints { - if let source = clusterSource { - source.shape = MLNShapeCollectionFeature( - shapes: clusterablePoints.map { pointFeature(for: $0) } - ) - } else if !clusterablePoints.isEmpty { - let features = clusterablePoints.map { pointFeature(for: $0) } - let source = MLNShapeSource( - identifier: MapSourceID.points, - features: features, - options: [ - .clustered: true, - .clusterRadius: 44, - .maximumZoomLevelForClustering: 14, - ] - ) - style.addSource(source) - self.clusterSource = source - addClusteredPointLayers(source: source, style: style) - } - lastAppliedClusterablePoints = clusterablePoints - } - - // Fixed source — rebuild only when the fixed subset changed. - if fixedPoints != lastAppliedFixedPoints { - if let source = fixedSource { - source.shape = MLNShapeCollectionFeature( - shapes: fixedPoints.map { pointFeature(for: $0) } - ) - } else if !fixedPoints.isEmpty { - let features = fixedPoints.map { pointFeature(for: $0) } - let source = MLNShapeSource(identifier: MapSourceID.fixedPoints, features: features, options: nil) - style.addSource(source) - self.fixedSource = source - addFixedPointLayers(source: source, style: style) - } - lastAppliedFixedPoints = fixedPoints - } - } - - func updateLabelVisibility(mapView: MLNMapView, showLabels: Bool) { - for layerId in [MapLayerID.nameLabels, MapLayerID.fixedNameLabels] { - guard let layer = mapView.style?.layer(withIdentifier: layerId) as? MLNSymbolStyleLayer else { continue } - layer.isVisible = showLabels - } + // MARK: - Update point source data + + /// Point sources and layers use deferred creation: they are created here + /// on first data arrival, not during style load. This avoids a MapLibre + /// bug where sources initialized without features ignore later `.shape` + /// updates. + func updatePointSource(mapView: MLNMapView) { + guard let style = mapView.style else { return } + + var clusterablePoints: [MapPoint] = [] + var fixedPoints: [MapPoint] = [] + for point in currentPoints { + if point.isClusterable { + clusterablePoints.append(point) + } else { + fixedPoints.append(point) + } } - // MARK: - Clustered point layers - - private func addClusteredPointLayers(source: MLNShapeSource, style: MLNStyle) { - // Cluster circles - let circleLayer = MLNCircleStyleLayer(identifier: MapLayerID.clusterCircles, source: source) - circleLayer.predicate = NSPredicate(format: "cluster == YES") - let radiusStops: [NSNumber: NSNumber] = [0: 18, 50: 24, 100: 30, 200: 38] - circleLayer.circleRadius = NSExpression( - forMLNStepping: NSExpression(forKeyPath: "point_count"), - from: NSExpression(forConstantValue: 18), - stops: NSExpression(forConstantValue: radiusStops) + // Clustered (contact) source — rebuild only when the clusterable subset changed, + // so toggling a fixed pin (the chat-dropped pin) does not re-cluster all contacts. + if clusterablePoints != lastAppliedClusterablePoints { + if let source = clusterSource { + source.shape = MLNShapeCollectionFeature( + shapes: clusterablePoints.map { pointFeature(for: $0) } + ) + } else if !clusterablePoints.isEmpty { + let features = clusterablePoints.map { pointFeature(for: $0) } + let source = MLNShapeSource( + identifier: MapSourceID.points, + features: features, + options: [ + .clustered: true, + .clusterRadius: 44, + .maximumZoomLevelForClustering: 14, + ] ) - circleLayer.circleColor = NSExpression(forConstantValue: UIColor.systemBlue) - circleLayer.circleOpacity = NSExpression(forConstantValue: 0.85) - circleLayer.circleStrokeColor = NSExpression(forConstantValue: UIColor.white.withAlphaComponent(0.8)) - circleLayer.circleStrokeWidth = NSExpression(forConstantValue: 2) - style.addLayer(circleLayer) - - // Cluster count labels - let clusterLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.clusterLabels, source: source) - clusterLabelLayer.predicate = NSPredicate(format: "cluster == YES") - clusterLabelLayer.text = NSExpression(format: "CAST(point_count, 'NSString')") - clusterLabelLayer.textColor = NSExpression(forConstantValue: UIColor.white) - clusterLabelLayer.textFontSize = NSExpression(forConstantValue: 13) - clusterLabelLayer.textFontNames = mapFontNames - clusterLabelLayer.textAllowsOverlap = NSExpression(forConstantValue: true) - clusterLabelLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) - style.addLayer(clusterLabelLayer) - - // Unclustered pin icons - let iconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.unclusteredIcons, source: source) - iconLayer.predicate = NSPredicate(format: "cluster != YES") - iconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") - iconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") - iconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) - iconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) - iconLayer.text = nil - style.addLayer(iconLayer) - - // Name labels (above pins) with pill background - let nameLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.nameLabels, source: source) - nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND labelSpriteName != nil") - configureNameLabelLayer(nameLabelLayer) - style.addLayer(nameLabelLayer) - - // Stats badge text (trace path midpoints) with pill background - let badgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.badgeText, source: source) - badgeLayer.predicate = NSPredicate(format: "cluster != YES AND badgeText != nil") - configureBadgeLayer(badgeLayer) - style.addLayer(badgeLayer) - } - - // MARK: - Fixed point layers - - private func addFixedPointLayers(source: MLNShapeSource, style: MLNStyle) { - let fixedIconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedIcons, source: source) - fixedIconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") - fixedIconLayer.iconAnchor = NSExpression(forKeyPath: "anchorType") - fixedIconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) - fixedIconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) - fixedIconLayer.text = nil - style.addLayer(fixedIconLayer) - - let fixedNameLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedNameLabels, source: source) - fixedNameLayer.predicate = NSPredicate(format: "labelSpriteName != nil") - configureNameLabelLayer(fixedNameLayer) - style.addLayer(fixedNameLayer) - - let fixedBadgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedBadgeText, source: source) - fixedBadgeLayer.predicate = NSPredicate(format: "badgeText != nil") - configureBadgeLayer(fixedBadgeLayer) - style.addLayer(fixedBadgeLayer) - } - - // MARK: - Line layers - - func setupLineLayers(style: MLNStyle) { - guard style.source(withIdentifier: MapSourceID.lines) == nil else { return } - let source = MLNShapeSource(identifier: MapSourceID.lines, features: [], options: nil) style.addSource(source) - - let losCasing = MLNLineStyleLayer(identifier: MapLayerID.lineLOSCasing, source: source) - losCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) - losCasing.lineColor = NSExpression(forConstantValue: UIColor.white) - losCasing.lineOpacity = NSExpression(forConstantValue: 0.8) - losCasing.lineWidth = NSExpression(forConstantValue: 6) - losCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) - losCasing.lineJoin = NSExpression(forConstantValue: "round") - losCasing.lineCap = NSExpression(forConstantValue: "round") - style.addLayer(losCasing) - - let losLayer = MLNLineStyleLayer(identifier: MapLayerID.lineLOS, source: source) - losLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) - losLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) - losLayer.lineWidth = NSExpression(forConstantValue: 3) - losLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) - losLayer.lineJoin = NSExpression(forConstantValue: "round") - losLayer.lineCap = NSExpression(forConstantValue: "round") - losLayer.lineOpacity = NSExpression(forKeyPath: "segmentOpacity") - style.addLayer(losLayer) - - let white = NSExpression(forConstantValue: UIColor.white) - let casingOpacity = NSExpression(forConstantValue: 0.8) - let roundJoin = NSExpression(forConstantValue: "round") - let roundCap = NSExpression(forConstantValue: "round") - - let untracedCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntracedCasing, source: source) - untracedCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) - untracedCasing.lineColor = white - untracedCasing.lineOpacity = casingOpacity - untracedCasing.lineWidth = NSExpression(forConstantValue: 5) - untracedCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) - untracedCasing.lineJoin = roundJoin - untracedCasing.lineCap = roundCap - style.addLayer(untracedCasing) - - let untracedLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntraced, source: source) - untracedLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) - untracedLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGray) - untracedLayer.lineWidth = NSExpression(forConstantValue: 2) - untracedLayer.lineDashPattern = NSExpression(forConstantValue: [1.75, 3.25]) - untracedLayer.lineJoin = roundJoin - untracedLayer.lineCap = roundCap - style.addLayer(untracedLayer) - - let weakCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeakCasing, source: source) - weakCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) - weakCasing.lineColor = white - weakCasing.lineOpacity = casingOpacity - weakCasing.lineWidth = NSExpression(forConstantValue: 6) - weakCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) - weakCasing.lineJoin = roundJoin - weakCasing.lineCap = roundCap - style.addLayer(weakCasing) - - let weakLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeak, source: source) - weakLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) - weakLayer.lineColor = NSExpression(forConstantValue: SNRQuality.poor.uiColor) - weakLayer.lineWidth = NSExpression(forConstantValue: 3) - weakLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) - weakLayer.lineJoin = roundJoin - weakLayer.lineCap = roundCap - style.addLayer(weakLayer) - - let mediumCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMediumCasing, source: source) - mediumCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) - mediumCasing.lineColor = white - mediumCasing.lineOpacity = casingOpacity - mediumCasing.lineWidth = NSExpression(forConstantValue: 6) - mediumCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) - mediumCasing.lineJoin = roundJoin - mediumCasing.lineCap = roundCap - style.addLayer(mediumCasing) - - let mediumLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMedium, source: source) - mediumLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) - mediumLayer.lineColor = NSExpression(forConstantValue: SNRQuality.fair.uiColor) - mediumLayer.lineWidth = NSExpression(forConstantValue: 3) - mediumLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) - mediumLayer.lineJoin = roundJoin - mediumLayer.lineCap = roundCap - style.addLayer(mediumLayer) - - // Good: width 4, solid → casing width 7 - let goodCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGoodCasing, source: source) - goodCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) - goodCasing.lineColor = white - goodCasing.lineOpacity = casingOpacity - goodCasing.lineWidth = NSExpression(forConstantValue: 7) - goodCasing.lineJoin = roundJoin - goodCasing.lineCap = roundCap - style.addLayer(goodCasing) - - let goodLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGood, source: source) - goodLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) - goodLayer.lineColor = NSExpression(forConstantValue: SNRQuality.good.uiColor) - goodLayer.lineWidth = NSExpression(forConstantValue: 4) - style.addLayer(goodLayer) - - // Message path: solid blue with a white casing (no dashes) — a direct - // node-to-node route, visually distinct from the dashed LOS line. - let messagePathCasing = MLNLineStyleLayer(identifier: MapLayerID.lineMessagePathCasing, source: source) - messagePathCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.messagePath.rawValue) - messagePathCasing.lineColor = white - messagePathCasing.lineOpacity = casingOpacity - messagePathCasing.lineWidth = NSExpression(forConstantValue: 6) - messagePathCasing.lineJoin = roundJoin - messagePathCasing.lineCap = roundCap - style.addLayer(messagePathCasing) - - let messagePathLayer = MLNLineStyleLayer(identifier: MapLayerID.lineMessagePath, source: source) - messagePathLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.messagePath.rawValue) - messagePathLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) - messagePathLayer.lineWidth = NSExpression(forConstantValue: 3) - messagePathLayer.lineJoin = roundJoin - messagePathLayer.lineCap = roundCap - style.addLayer(messagePathLayer) + clusterSource = source + addClusteredPointLayers(source: source, style: style) + } + lastAppliedClusterablePoints = clusterablePoints } - func updateLineSource(mapView: MLNMapView) { - guard let source = mapView.style?.source(withIdentifier: MapSourceID.lines) as? MLNShapeSource else { return } - - let features = currentLines.map { line -> MLNPolylineFeature in - var coords = line.coordinates - let feature = MLNPolylineFeature(coordinates: &coords, count: UInt(coords.count)) - feature.attributes = [ - "lineStyle": line.style.rawValue, - "segmentOpacity": line.opacity, - ] - return feature - } - source.shape = MLNShapeCollectionFeature(shapes: features) - } - - // MARK: - Raster tile sources - - func setupRasterSources(style: MLNStyle, mapView: MLNMapView) { - guard style.source(withIdentifier: MapSourceID.satelliteTiles) == nil else { - updateRasterLayerVisibility(mapView: mapView) - return - } - let satSource = MLNRasterTileSource( - identifier: MapSourceID.satelliteTiles, - tileURLTemplates: [MapTileURLs.esriWorldImagery], - options: [ - .tileSize: 256, - .maximumZoomLevel: 19, - .attributionHTMLString: "Esri", - ] - ) - style.addSource(satSource) - let satLayer = MLNRasterStyleLayer(identifier: MapLayerID.satelliteLayer, source: satSource) - satLayer.isVisible = false - style.addLayer(satLayer) - - let topoSource = MLNRasterTileSource( - identifier: MapSourceID.topoTiles, - tileURLTemplates: [MapTileURLs.openTopoMapA, MapTileURLs.openTopoMapB, MapTileURLs.openTopoMapC], - options: [ - .tileSize: 256, - .maximumZoomLevel: 17, - .attributionHTMLString: "OpenTopoMap", - ] + // Fixed source — rebuild only when the fixed subset changed. + if fixedPoints != lastAppliedFixedPoints { + if let source = fixedSource { + source.shape = MLNShapeCollectionFeature( + shapes: fixedPoints.map { pointFeature(for: $0) } ) - style.addSource(topoSource) - let topoLayer = MLNRasterStyleLayer(identifier: MapLayerID.topoLayer, source: topoSource) - topoLayer.isVisible = false - style.addLayer(topoLayer) - - updateRasterLayerVisibility(mapView: mapView) + } else if !fixedPoints.isEmpty { + let features = fixedPoints.map { pointFeature(for: $0) } + let source = MLNShapeSource(identifier: MapSourceID.fixedPoints, features: features, options: nil) + style.addSource(source) + fixedSource = source + addFixedPointLayers(source: source, style: style) + } + lastAppliedFixedPoints = fixedPoints } + } - func updateRasterLayerVisibility(mapView: MLNMapView) { - guard let style = mapView.style else { return } - style.layer(withIdentifier: MapLayerID.satelliteLayer)?.isVisible = currentMapStyle == .satellite - style.layer(withIdentifier: MapLayerID.topoLayer)?.isVisible = currentMapStyle == .topo + func updateLabelVisibility(mapView: MLNMapView, showLabels: Bool) { + for layerId in [MapLayerID.nameLabels, MapLayerID.fixedNameLabels] { + guard let layer = mapView.style?.layer(withIdentifier: layerId) as? MLNSymbolStyleLayer else { continue } + layer.isVisible = showLabels } - - // MARK: - Shared layer configuration - - private func configureNameLabelLayer(_ layer: MLNSymbolStyleLayer) { - layer.iconImageName = NSExpression(forKeyPath: "labelSpriteName") - layer.iconAnchor = NSExpression(forConstantValue: "bottom") - layer.iconOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -46))) - layer.symbolSortKey = NSExpression(forKeyPath: "hopIndex") - layer.iconAllowsOverlap = NSExpression(forConstantValue: true) - layer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + } + + // MARK: - Clustered point layers + + private func addClusteredPointLayers(source: MLNShapeSource, style: MLNStyle) { + // Cluster circles + let circleLayer = MLNCircleStyleLayer(identifier: MapLayerID.clusterCircles, source: source) + circleLayer.predicate = NSPredicate(format: "cluster == YES") + let radiusStops: [NSNumber: NSNumber] = [0: 18, 50: 24, 100: 30, 200: 38] + circleLayer.circleRadius = NSExpression( + forMLNStepping: NSExpression(forKeyPath: "point_count"), + from: NSExpression(forConstantValue: 18), + stops: NSExpression(forConstantValue: radiusStops) + ) + circleLayer.circleColor = NSExpression(forConstantValue: UIColor.systemBlue) + circleLayer.circleOpacity = NSExpression(forConstantValue: 0.85) + circleLayer.circleStrokeColor = NSExpression(forConstantValue: UIColor.white.withAlphaComponent(0.8)) + circleLayer.circleStrokeWidth = NSExpression(forConstantValue: 2) + style.addLayer(circleLayer) + + // Cluster count labels + let clusterLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.clusterLabels, source: source) + clusterLabelLayer.predicate = NSPredicate(format: "cluster == YES") + clusterLabelLayer.text = NSExpression(format: "CAST(point_count, 'NSString')") + clusterLabelLayer.textColor = NSExpression(forConstantValue: UIColor.white) + clusterLabelLayer.textFontSize = NSExpression(forConstantValue: 13) + clusterLabelLayer.textFontNames = mapFontNames + clusterLabelLayer.textAllowsOverlap = NSExpression(forConstantValue: true) + clusterLabelLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) + style.addLayer(clusterLabelLayer) + + // Unclustered pin icons + let iconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.unclusteredIcons, source: source) + iconLayer.predicate = NSPredicate(format: "cluster != YES") + iconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") + iconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") + iconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) + iconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + iconLayer.text = nil + style.addLayer(iconLayer) + + // Name labels (above pins) with pill background + let nameLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.nameLabels, source: source) + nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND labelSpriteName != nil") + configureNameLabelLayer(nameLabelLayer) + style.addLayer(nameLabelLayer) + + // Stats badge text (trace path midpoints) with pill background + let badgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.badgeText, source: source) + badgeLayer.predicate = NSPredicate(format: "cluster != YES AND badgeText != nil") + configureBadgeLayer(badgeLayer) + style.addLayer(badgeLayer) + } + + // MARK: - Fixed point layers + + private func addFixedPointLayers(source: MLNShapeSource, style: MLNStyle) { + let fixedIconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedIcons, source: source) + fixedIconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") + fixedIconLayer.iconAnchor = NSExpression(forKeyPath: "anchorType") + fixedIconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) + fixedIconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + fixedIconLayer.text = nil + style.addLayer(fixedIconLayer) + + let fixedNameLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedNameLabels, source: source) + fixedNameLayer.predicate = NSPredicate(format: "labelSpriteName != nil") + configureNameLabelLayer(fixedNameLayer) + style.addLayer(fixedNameLayer) + + let fixedBadgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedBadgeText, source: source) + fixedBadgeLayer.predicate = NSPredicate(format: "badgeText != nil") + configureBadgeLayer(fixedBadgeLayer) + style.addLayer(fixedBadgeLayer) + } + + // MARK: - Line layers + + func setupLineLayers(style: MLNStyle) { + guard style.source(withIdentifier: MapSourceID.lines) == nil else { return } + let source = MLNShapeSource(identifier: MapSourceID.lines, features: [], options: nil) + style.addSource(source) + + let losCasing = MLNLineStyleLayer(identifier: MapLayerID.lineLOSCasing, source: source) + losCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) + losCasing.lineColor = NSExpression(forConstantValue: UIColor.white) + losCasing.lineOpacity = NSExpression(forConstantValue: 0.8) + losCasing.lineWidth = NSExpression(forConstantValue: 6) + losCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) + losCasing.lineJoin = NSExpression(forConstantValue: "round") + losCasing.lineCap = NSExpression(forConstantValue: "round") + style.addLayer(losCasing) + + let losLayer = MLNLineStyleLayer(identifier: MapLayerID.lineLOS, source: source) + losLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) + losLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) + losLayer.lineWidth = NSExpression(forConstantValue: 3) + losLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + losLayer.lineJoin = NSExpression(forConstantValue: "round") + losLayer.lineCap = NSExpression(forConstantValue: "round") + losLayer.lineOpacity = NSExpression(forKeyPath: "segmentOpacity") + style.addLayer(losLayer) + + let white = NSExpression(forConstantValue: UIColor.white) + let casingOpacity = NSExpression(forConstantValue: 0.8) + let roundJoin = NSExpression(forConstantValue: "round") + let roundCap = NSExpression(forConstantValue: "round") + + let untracedCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntracedCasing, source: source) + untracedCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) + untracedCasing.lineColor = white + untracedCasing.lineOpacity = casingOpacity + untracedCasing.lineWidth = NSExpression(forConstantValue: 5) + untracedCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) + untracedCasing.lineJoin = roundJoin + untracedCasing.lineCap = roundCap + style.addLayer(untracedCasing) + + let untracedLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntraced, source: source) + untracedLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) + untracedLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGray) + untracedLayer.lineWidth = NSExpression(forConstantValue: 2) + untracedLayer.lineDashPattern = NSExpression(forConstantValue: [1.75, 3.25]) + untracedLayer.lineJoin = roundJoin + untracedLayer.lineCap = roundCap + style.addLayer(untracedLayer) + + let weakCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeakCasing, source: source) + weakCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) + weakCasing.lineColor = white + weakCasing.lineOpacity = casingOpacity + weakCasing.lineWidth = NSExpression(forConstantValue: 6) + weakCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) + weakCasing.lineJoin = roundJoin + weakCasing.lineCap = roundCap + style.addLayer(weakCasing) + + let weakLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeak, source: source) + weakLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) + weakLayer.lineColor = NSExpression(forConstantValue: SNRQuality.poor.uiColor) + weakLayer.lineWidth = NSExpression(forConstantValue: 3) + weakLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + weakLayer.lineJoin = roundJoin + weakLayer.lineCap = roundCap + style.addLayer(weakLayer) + + let mediumCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMediumCasing, source: source) + mediumCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) + mediumCasing.lineColor = white + mediumCasing.lineOpacity = casingOpacity + mediumCasing.lineWidth = NSExpression(forConstantValue: 6) + mediumCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) + mediumCasing.lineJoin = roundJoin + mediumCasing.lineCap = roundCap + style.addLayer(mediumCasing) + + let mediumLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMedium, source: source) + mediumLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) + mediumLayer.lineColor = NSExpression(forConstantValue: SNRQuality.fair.uiColor) + mediumLayer.lineWidth = NSExpression(forConstantValue: 3) + mediumLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + mediumLayer.lineJoin = roundJoin + mediumLayer.lineCap = roundCap + style.addLayer(mediumLayer) + + // Good: width 4, solid → casing width 7 + let goodCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGoodCasing, source: source) + goodCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) + goodCasing.lineColor = white + goodCasing.lineOpacity = casingOpacity + goodCasing.lineWidth = NSExpression(forConstantValue: 7) + goodCasing.lineJoin = roundJoin + goodCasing.lineCap = roundCap + style.addLayer(goodCasing) + + let goodLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGood, source: source) + goodLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) + goodLayer.lineColor = NSExpression(forConstantValue: SNRQuality.good.uiColor) + goodLayer.lineWidth = NSExpression(forConstantValue: 4) + style.addLayer(goodLayer) + + // Message path: solid blue with a white casing (no dashes) — a direct + // node-to-node route, visually distinct from the dashed LOS line. + let messagePathCasing = MLNLineStyleLayer(identifier: MapLayerID.lineMessagePathCasing, source: source) + messagePathCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.messagePath.rawValue) + messagePathCasing.lineColor = white + messagePathCasing.lineOpacity = casingOpacity + messagePathCasing.lineWidth = NSExpression(forConstantValue: 6) + messagePathCasing.lineJoin = roundJoin + messagePathCasing.lineCap = roundCap + style.addLayer(messagePathCasing) + + let messagePathLayer = MLNLineStyleLayer(identifier: MapLayerID.lineMessagePath, source: source) + messagePathLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.messagePath.rawValue) + messagePathLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) + messagePathLayer.lineWidth = NSExpression(forConstantValue: 3) + messagePathLayer.lineJoin = roundJoin + messagePathLayer.lineCap = roundCap + style.addLayer(messagePathLayer) + } + + func updateLineSource(mapView: MLNMapView) { + guard let source = mapView.style?.source(withIdentifier: MapSourceID.lines) as? MLNShapeSource else { return } + + let features = currentLines.map { line -> MLNPolylineFeature in + var coords = line.coordinates + let feature = MLNPolylineFeature(coordinates: &coords, count: UInt(coords.count)) + feature.attributes = [ + "lineStyle": line.style.rawValue, + "segmentOpacity": line.opacity, + ] + return feature } + source.shape = MLNShapeCollectionFeature(shapes: features) + } - private func configureBadgeLayer(_ layer: MLNSymbolStyleLayer) { - layer.text = NSExpression(forKeyPath: "badgeText") - layer.textFontSize = NSExpression(forConstantValue: 11) - layer.textFontNames = mapFontNames - layer.textColor = NSExpression(forConstantValue: UIColor.black) - layer.textAllowsOverlap = NSExpression(forConstantValue: true) - layer.textIgnoresPlacement = NSExpression(forConstantValue: true) - layer.iconImageName = NSExpression(forConstantValue: "pill-bg") - layer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) - layer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8))) - } + // MARK: - Raster tile sources - // MARK: - Private helpers - - private func pointFeature(for point: MapPoint) -> MLNPointFeature { - let feature = MLNPointFeature() - feature.coordinate = point.coordinate - var attributes: [String: Any] = [ - "pointId": point.id.uuidString, - "spriteName": spriteName(for: point), - "anchorType": iconAnchor(for: point), - ] - if let label = point.label { - attributes["labelSpriteName"] = "\(PinSpriteRenderer.labelSpritePrefix)\(label)" - } - if let hopIndex = point.hopIndex { attributes["hopIndex"] = hopIndex } - if let badgeText = point.badgeText { attributes["badgeText"] = badgeText } - feature.attributes = attributes - return feature + func setupRasterSources(style: MLNStyle, mapView: MLNMapView) { + guard style.source(withIdentifier: MapSourceID.satelliteTiles) == nil else { + updateRasterLayerVisibility(mapView: mapView) + return } - - private func iconAnchor(for point: MapPoint) -> String { - switch point.pinStyle { - case .crosshair, .obstruction: "center" - default: "bottom" - } + let satSource = MLNRasterTileSource( + identifier: MapSourceID.satelliteTiles, + tileURLTemplates: [MapTileURLs.esriWorldImagery], + options: [ + .tileSize: 256, + .maximumZoomLevel: 19, + .attributionHTMLString: "Esri", + ] + ) + style.addSource(satSource) + let satLayer = MLNRasterStyleLayer(identifier: MapLayerID.satelliteLayer, source: satSource) + satLayer.isVisible = false + style.addLayer(satLayer) + + let topoSource = MLNRasterTileSource( + identifier: MapSourceID.topoTiles, + tileURLTemplates: [MapTileURLs.openTopoMapA, MapTileURLs.openTopoMapB, MapTileURLs.openTopoMapC], + options: [ + .tileSize: 256, + .maximumZoomLevel: 17, + .attributionHTMLString: "OpenTopoMap", + ] + ) + style.addSource(topoSource) + let topoLayer = MLNRasterStyleLayer(identifier: MapLayerID.topoLayer, source: topoSource) + topoLayer.isVisible = false + style.addLayer(topoLayer) + + updateRasterLayerVisibility(mapView: mapView) + } + + func updateRasterLayerVisibility(mapView: MLNMapView) { + guard let style = mapView.style else { return } + style.layer(withIdentifier: MapLayerID.satelliteLayer)?.isVisible = currentMapStyle == .satellite + style.layer(withIdentifier: MapLayerID.topoLayer)?.isVisible = currentMapStyle == .topo + } + + // MARK: - Shared layer configuration + + private func configureNameLabelLayer(_ layer: MLNSymbolStyleLayer) { + layer.iconImageName = NSExpression(forKeyPath: "labelSpriteName") + layer.iconAnchor = NSExpression(forConstantValue: "bottom") + layer.iconOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -46))) + layer.symbolSortKey = NSExpression(forKeyPath: "hopIndex") + layer.iconAllowsOverlap = NSExpression(forConstantValue: true) + layer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + } + + private func configureBadgeLayer(_ layer: MLNSymbolStyleLayer) { + layer.text = NSExpression(forKeyPath: "badgeText") + layer.textFontSize = NSExpression(forConstantValue: 11) + layer.textFontNames = mapFontNames + layer.textColor = NSExpression(forConstantValue: UIColor.black) + layer.textAllowsOverlap = NSExpression(forConstantValue: true) + layer.textIgnoresPlacement = NSExpression(forConstantValue: true) + layer.iconImageName = NSExpression(forConstantValue: "pill-bg") + layer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) + layer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8))) + } + + // MARK: - Private helpers + + private func pointFeature(for point: MapPoint) -> MLNPointFeature { + let feature = MLNPointFeature() + feature.coordinate = point.coordinate + var attributes: [String: Any] = [ + "pointId": point.id.uuidString, + "spriteName": spriteName(for: point), + "anchorType": iconAnchor(for: point), + ] + if let label = point.label { + attributes["labelSpriteName"] = "\(PinSpriteRenderer.labelSpritePrefix)\(label)" } - - private func spriteName(for point: MapPoint) -> String { - switch point.pinStyle { - case .contactChat: "pin-chat" - case .contactRepeater: "pin-repeater" - case .contactRoom: "pin-room" - case .repeater: "pin-repeater" - case .repeaterRingBlue: "pin-repeater-ring-blue" - case .repeaterRingGreen: "pin-repeater-ring-green" - case .repeaterRingWhite: - if let hop = point.hopIndex { - "pin-repeater-ring-white-hop-\(min(hop, PinSpriteRenderer.maxHopBadge))" - } else { - "pin-repeater-ring-white" - } - case .repeaterHop: - if let hop = point.hopIndex { - "pin-repeater-hop-\(min(hop, PinSpriteRenderer.maxHopBadge))" - } else { - "pin-repeater" - } - case .pointA: "pin-point-a" - case .pointB: "pin-point-b" - case .crosshair: "pin-crosshair" - case .obstruction: "pin-obstruction" - case .droppedPin: "pin-dropped" - case .badge: "pin-badge" - } + if let hopIndex = point.hopIndex { attributes["hopIndex"] = hopIndex } + if let badgeText = point.badgeText { attributes["badgeText"] = badgeText } + feature.attributes = attributes + return feature + } + + private func iconAnchor(for point: MapPoint) -> String { + switch point.pinStyle { + case .crosshair, .obstruction: "center" + default: "bottom" + } + } + + private func spriteName(for point: MapPoint) -> String { + switch point.pinStyle { + case .contactChat: "pin-chat" + case .contactRepeater: "pin-repeater" + case .contactRoom: "pin-room" + case .repeater: "pin-repeater" + case .repeaterRingBlue: "pin-repeater-ring-blue" + case .repeaterRingGreen: "pin-repeater-ring-green" + case .repeaterRingWhite: + if let hop = point.hopIndex { + "pin-repeater-ring-white-hop-\(min(hop, PinSpriteRenderer.maxHopBadge))" + } else { + "pin-repeater-ring-white" + } + case .repeaterHop: + if let hop = point.hopIndex { + "pin-repeater-hop-\(min(hop, PinSpriteRenderer.maxHopBadge))" + } else { + "pin-repeater" + } + case .pointA: "pin-point-a" + case .pointB: "pin-point-b" + case .crosshair: "pin-crosshair" + case .obstruction: "pin-obstruction" + case .droppedPin: "pin-dropped" + case .badge: "pin-badge" } + } } diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 39a68107..0363872d 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -1,5 +1,5 @@ -import MapLibre import MapKit +import MapLibre import ObjectiveC import OSLog import SwiftUI @@ -15,532 +15,533 @@ private let logger = Logger(subsystem: "com.mc1", category: "MapPins") /// MapLibre's internal Metal UIView so the wrong scale is never stored. /// Upstream issue: https://github.com/maplibre/maplibre-native/issues/3214 private enum MetalLayerScaleFix { + @MainActor + static func apply(to mapView: MLNMapView) { + guard let metalView = findMetalView(in: mapView) else { return } + + let selector = NSSelectorFromString("setDrawableSize:") + guard metalView.responds(to: selector) else { return } + + guard let originalClass: AnyClass = object_getClass(metalView) else { return } + let name = "_MC1FixedScale_\(NSStringFromClass(originalClass))" + + let fixedClass: AnyClass + if let existing = objc_getClass(name) as? AnyClass { + fixedClass = existing + } else { + guard let subclass = objc_allocateClassPair(originalClass, name, 0) else { return } + addDrawableSizeOverride(to: subclass, originalClass: originalClass) + addContentScaleFactorOverride(to: subclass, originalClass: originalClass) + objc_registerClassPair(subclass) + fixedClass = subclass + } - @MainActor - static func apply(to mapView: MLNMapView) { - guard let metalView = findMetalView(in: mapView) else { return } - - let selector = NSSelectorFromString("setDrawableSize:") - guard metalView.responds(to: selector) else { return } - - guard let originalClass: AnyClass = object_getClass(metalView) else { return } - let name = "_MC1FixedScale_\(NSStringFromClass(originalClass))" - - let fixedClass: AnyClass - if let existing = objc_getClass(name) as? AnyClass { - fixedClass = existing - } else { - guard let subclass = objc_allocateClassPair(originalClass, name, 0) else { return } - addDrawableSizeOverride(to: subclass, originalClass: originalClass) - addContentScaleFactorOverride(to: subclass, originalClass: originalClass) - objc_registerClassPair(subclass) - fixedClass = subclass - } + object_setClass(metalView, fixedClass) + } - object_setClass(metalView, fixedClass) + @MainActor + private static func findMetalView(in view: UIView) -> UIView? { + for subview in view.subviews where subview.layer is CAMetalLayer { + return subview } - - @MainActor - private static func findMetalView(in view: UIView) -> UIView? { - for subview in view.subviews where subview.layer is CAMetalLayer { - return subview - } - return nil + return nil + } + + @MainActor + private static func findMapView(from metalView: UIView) -> MLNMapView? { + var parent: UIView? = metalView.superview + while let v = parent, !(v is MLNMapView) { + parent = v.superview } + return parent as? MLNMapView + } + + private static func addDrawableSizeOverride( + to subclass: AnyClass, + originalClass: AnyClass + ) { + let selector = NSSelectorFromString("setDrawableSize:") + guard let original = class_getInstanceMethod(originalClass, selector) else { return } + let originalIMP = method_getImplementation(original) + typealias SetDrawableSizeFn = @convention(c) @Sendable (AnyObject, Selector, CGSize) -> Void + let callOriginal = unsafeBitCast(originalIMP, to: SetDrawableSizeFn.self) + + let block: @convention(block) (UIView, CGSize) -> Void = { metalView, proposedSize in + dispatchPrecondition(condition: .onQueue(.main)) + MainActor.assumeIsolated { + guard let mapView = findMapView(from: metalView), + mapView.bounds.size.width > 0, + mapView.bounds.size.height > 0, + let screen = mapView.window?.screen else { + callOriginal(metalView, selector, proposedSize) + return + } - @MainActor - private static func findMapView(from metalView: UIView) -> MLNMapView? { - var parent: UIView? = metalView.superview - while let v = parent, !(v is MLNMapView) { parent = v.superview } - return parent as? MLNMapView - } + let correctScale = screen.nativeScale + let correctSize = CGSize( + width: mapView.bounds.width * correctScale, + height: mapView.bounds.height * correctScale + ) - private static func addDrawableSizeOverride( - to subclass: AnyClass, - originalClass: AnyClass - ) { - let selector = NSSelectorFromString("setDrawableSize:") - guard let original = class_getInstanceMethod(originalClass, selector) else { return } - let originalIMP = method_getImplementation(original) - typealias SetDrawableSizeFn = @convention(c) @Sendable (AnyObject, Selector, CGSize) -> Void - let callOriginal = unsafeBitCast(originalIMP, to: SetDrawableSizeFn.self) - - let block: @convention(block) (UIView, CGSize) -> Void = { metalView, proposedSize in - dispatchPrecondition(condition: .onQueue(.main)) - MainActor.assumeIsolated { - guard let mapView = findMapView(from: metalView), - mapView.bounds.size.width > 0, - mapView.bounds.size.height > 0, - let screen = mapView.window?.screen else { - callOriginal(metalView, selector, proposedSize) - return - } - - let correctScale = screen.nativeScale - let correctSize = CGSize( - width: mapView.bounds.width * correctScale, - height: mapView.bounds.height * correctScale - ) - - if let layer = metalView.layer as? CAMetalLayer, - layer.drawableSize == correctSize { - return - } - - callOriginal(metalView, selector, correctSize) - } + if let layer = metalView.layer as? CAMetalLayer, + layer.drawableSize == correctSize { + return } - let imp = imp_implementationWithBlock(block) - class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + callOriginal(metalView, selector, correctSize) + } } - private static func addContentScaleFactorOverride( - to subclass: AnyClass, - originalClass: AnyClass - ) { - let selector = NSSelectorFromString("setContentScaleFactor:") - guard let original = class_getInstanceMethod(originalClass, selector) else { return } - let originalIMP = method_getImplementation(original) - typealias SetScaleFn = @convention(c) @Sendable (AnyObject, Selector, CGFloat) -> Void - let callOriginal = unsafeBitCast(originalIMP, to: SetScaleFn.self) - - let block: @convention(block) (UIView, CGFloat) -> Void = { metalView, _ in - dispatchPrecondition(condition: .onQueue(.main)) - MainActor.assumeIsolated { - guard let mapView = findMapView(from: metalView), - let screen = mapView.window?.screen else { - return - } - - let correctScale = screen.nativeScale - if metalView.contentScaleFactor == correctScale { - return - } - - callOriginal(metalView, selector, correctScale) - } + let imp = imp_implementationWithBlock(block) + class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + } + + private static func addContentScaleFactorOverride( + to subclass: AnyClass, + originalClass: AnyClass + ) { + let selector = NSSelectorFromString("setContentScaleFactor:") + guard let original = class_getInstanceMethod(originalClass, selector) else { return } + let originalIMP = method_getImplementation(original) + typealias SetScaleFn = @convention(c) @Sendable (AnyObject, Selector, CGFloat) -> Void + let callOriginal = unsafeBitCast(originalIMP, to: SetScaleFn.self) + + let block: @convention(block) (UIView, CGFloat) -> Void = { metalView, _ in + dispatchPrecondition(condition: .onQueue(.main)) + MainActor.assumeIsolated { + guard let mapView = findMapView(from: metalView), + let screen = mapView.window?.screen else { + return + } + + let correctScale = screen.nativeScale + if metalView.contentScaleFactor == correctScale { + return } - let imp = imp_implementationWithBlock(block) - class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + callOriginal(metalView, selector, correctScale) + } } + + let imp = imp_implementationWithBlock(block) + class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + } } /// Applies the isa-swizzle once the view is attached to a window. private final class ScaledMLNMapView: MLNMapView { - override func didMoveToWindow() { - super.didMoveToWindow() - guard window != nil else { return } - MetalLayerScaleFix.apply(to: self) - } + override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil else { return } + MetalLayerScaleFix.apply(to: self) + } } struct MC1MapView: UIViewRepresentable { - // Data - let points: [MapPoint] - let lines: [MapLine] - let mapStyle: MapStyleSelection - let isDarkMode: Bool - var isOffline: Bool = false - - // Configuration - let showLabels: Bool - let showsUserLocation: Bool - let isInteractive: Bool - let showsScale: Bool - var isNorthLocked: Bool = false - - // Camera - @Binding var cameraRegion: MKCoordinateRegion? - let cameraRegionVersion: Int - var cameraEdgePadding: UIEdgeInsets = .zero - var cameraBottomSheetFraction: CGFloat? - - // Output callbacks - let onPointTap: ((MapPoint, CGPoint) -> Void)? - let onMapTap: ((CLLocationCoordinate2D) -> Void)? - let onCameraRegionChange: ((MKCoordinateRegion) -> Void)? - - // Optional features - var isStyleLoaded: Binding = .constant(true) - - func makeCoordinator() -> Coordinator { - Coordinator() + // Data + let points: [MapPoint] + let lines: [MapLine] + let mapStyle: MapStyleSelection + let isDarkMode: Bool + var isOffline: Bool = false + + // Configuration + let showLabels: Bool + let showsUserLocation: Bool + let isInteractive: Bool + let showsScale: Bool + var isNorthLocked: Bool = false + + // Camera + @Binding var cameraRegion: MKCoordinateRegion? + let cameraRegionVersion: Int + var cameraEdgePadding: UIEdgeInsets = .zero + var cameraBottomSheetFraction: CGFloat? + + // Output callbacks + let onPointTap: ((MapPoint, CGPoint) -> Void)? + let onMapTap: ((CLLocationCoordinate2D) -> Void)? + let onCameraRegionChange: ((MKCoordinateRegion) -> Void)? + + /// Optional features + var isStyleLoaded: Binding = .constant(true) + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> MLNMapView { + let mapView = context.coordinator.mapView + mapView.delegate = context.coordinator + + mapView.showsUserLocation = showsUserLocation + mapView.compassViewPosition = .topRight + mapView.compassViewMargins = CGPoint(x: 8, y: 8) + mapView.attributionButtonPosition = .bottomLeft + mapView.attributionButtonMargins = CGPoint(x: 4, y: 30) + + if showsScale { + mapView.showsScale = true } - func makeUIView(context: Context) -> MLNMapView { - let mapView = context.coordinator.mapView - mapView.delegate = context.coordinator - - mapView.showsUserLocation = showsUserLocation - mapView.compassViewPosition = .topRight - mapView.compassViewMargins = CGPoint(x: 8, y: 8) - mapView.attributionButtonPosition = .bottomLeft - mapView.attributionButtonMargins = CGPoint(x: 4, y: 30) - - if showsScale { - mapView.showsScale = true - } - - if !isInteractive { - mapView.isScrollEnabled = false - mapView.isZoomEnabled = false - mapView.isRotateEnabled = false - mapView.isPitchEnabled = false - mapView.compassView.isHidden = true - } - - // Disable quick-zoom (tap-then-hold-drag) gesture - mapView.gestureRecognizers? - .compactMap { $0 as? UILongPressGestureRecognizer } - .filter { $0.numberOfTapsRequired == 1 && $0.minimumPressDuration == 0 } - .forEach { $0.isEnabled = false } + if !isInteractive { + mapView.isScrollEnabled = false + mapView.isZoomEnabled = false + mapView.isRotateEnabled = false + mapView.isPitchEnabled = false + mapView.compassView.isHidden = true + } - // Tap gesture for feature queries - let tap = UITapGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleTap(_:)) - ) - tap.delegate = context.coordinator - mapView.addGestureRecognizer(tap) + // Disable quick-zoom (tap-then-hold-drag) gesture + mapView.gestureRecognizers? + .compactMap { $0 as? UILongPressGestureRecognizer } + .filter { $0.numberOfTapsRequired == 1 && $0.minimumPressDuration == 0 } + .forEach { $0.isEnabled = false } + + // Tap gesture for feature queries + let tap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(_:)) + ) + tap.delegate = context.coordinator + mapView.addGestureRecognizer(tap) + + return mapView + } + + static func dismantleUIView(_ mapView: MLNMapView, coordinator: Coordinator) { + coordinator.pendingRegionTask?.cancel() + mapView.delegate = nil + } + + func updateUIView(_ mapView: MLNMapView, context: Context) { + let coordinator = context.coordinator + coordinator.isUpdatingFromSwiftUI = true + defer { coordinator.isUpdatingFromSwiftUI = false } + + // Refresh callbacks + coordinator.onPointTap = onPointTap + coordinator.onMapTap = onMapTap + coordinator.onCameraRegionChange = onCameraRegionChange + coordinator.setIsStyleLoaded = { isStyleLoaded.wrappedValue = $0 } + coordinator.currentPoints = points + coordinator.currentLines = lines + + // Style URL change — compare against our tracked value, not mapView.styleURL + // which MapLibre may transiently nil during layout/rotation. + let newStyleURL = mapStyle.styleURL(isDarkMode: isDarkMode, isOffline: isOffline) + if coordinator.lastAppliedStyleURL != newStyleURL { + coordinator.lastAppliedStyleURL = newStyleURL + coordinator.isStyleLoaded = false + mapView.styleURL = newStyleURL + } + coordinator.currentMapStyle = mapStyle - return mapView + // User location + if mapView.showsUserLocation != showsUserLocation { + mapView.showsUserLocation = showsUserLocation } - static func dismantleUIView(_ mapView: MLNMapView, coordinator: Coordinator) { - coordinator.pendingRegionTask?.cancel() - mapView.delegate = nil + // North lock + if isInteractive { + mapView.isRotateEnabled = !isNorthLocked + if isNorthLocked, mapView.direction != 0 { + mapView.setDirection(0, animated: true) + } } - func updateUIView(_ mapView: MLNMapView, context: Context) { - let coordinator = context.coordinator - coordinator.isUpdatingFromSwiftUI = true - defer { coordinator.isUpdatingFromSwiftUI = false } - - // Refresh callbacks - coordinator.onPointTap = onPointTap - coordinator.onMapTap = onMapTap - coordinator.onCameraRegionChange = onCameraRegionChange - coordinator.setIsStyleLoaded = { isStyleLoaded.wrappedValue = $0 } - coordinator.currentPoints = points - coordinator.currentLines = lines - - // Style URL change — compare against our tracked value, not mapView.styleURL - // which MapLibre may transiently nil during layout/rotation. - let newStyleURL = mapStyle.styleURL(isDarkMode: isDarkMode, isOffline: isOffline) - if coordinator.lastAppliedStyleURL != newStyleURL { - coordinator.lastAppliedStyleURL = newStyleURL - coordinator.isStyleLoaded = false - mapView.styleURL = newStyleURL - } - coordinator.currentMapStyle = mapStyle + // Update data layers (only when style is loaded and not mid-gesture). + // Compare against lastApplied* so updates arriving during a gesture + // are applied once the gesture ends. + if coordinator.isStyleLoaded, !coordinator.isUserInteracting { + if coordinator.lastAppliedMapStyle != mapStyle { + coordinator.updateRasterLayerVisibility(mapView: mapView) + coordinator.lastAppliedMapStyle = mapStyle + } + if coordinator.lastAppliedPoints != points { + coordinator.updatePointSource(mapView: mapView) + coordinator.lastAppliedPoints = points + } + if coordinator.lastAppliedLines != lines { + coordinator.updateLineSource(mapView: mapView) + coordinator.lastAppliedLines = lines + } + if coordinator.currentShowLabels != showLabels { + coordinator.currentShowLabels = showLabels + coordinator.updateLabelVisibility(mapView: mapView, showLabels: showLabels) + } + } - // User location - if mapView.showsUserLocation != showsUserLocation { - mapView.showsUserLocation = showsUserLocation - } + // Camera region (version-number pattern) + updateCameraRegion(in: mapView, coordinator: coordinator) + } - // North lock - if isInteractive { - mapView.isRotateEnabled = !isNorthLocked - if isNorthLocked && mapView.direction != 0 { - mapView.setDirection(0, animated: true) - } - } + /// Maximum absolute latitude MapLibre's `mbgl::LatLng` accepts; it throws an + /// uncaught `std::domain_error` (aborting the app) for any value beyond ±90. + private static let latitudeLimit = 90.0 - // Update data layers (only when style is loaded and not mid-gesture). - // Compare against lastApplied* so updates arriving during a gesture - // are applied once the gesture ends. - if coordinator.isStyleLoaded, !coordinator.isUserInteracting { - if coordinator.lastAppliedMapStyle != mapStyle { - coordinator.updateRasterLayerVisibility(mapView: mapView) - coordinator.lastAppliedMapStyle = mapStyle - } - if coordinator.lastAppliedPoints != points { - coordinator.updatePointSource(mapView: mapView) - coordinator.lastAppliedPoints = points - } - if coordinator.lastAppliedLines != lines { - coordinator.updateLineSource(mapView: mapView) - coordinator.lastAppliedLines = lines - } - if coordinator.currentShowLabels != showLabels { - coordinator.currentShowLabels = showLabels - coordinator.updateLabelVisibility(mapView: mapView, showLabels: showLabels) - } - } + private func updateCameraRegion(in mapView: MLNMapView, coordinator: Coordinator) { + guard let region = cameraRegion else { return } + guard cameraRegionVersion != coordinator.lastAppliedRegionVersion else { return } - // Camera region (version-number pattern) - updateCameraRegion(in: mapView, coordinator: coordinator) + guard CLLocationCoordinate2DIsValid(region.center) else { + coordinator.lastAppliedRegionVersion = cameraRegionVersion + return } - /// Maximum absolute latitude MapLibre's `mbgl::LatLng` accepts; it throws an - /// uncaught `std::domain_error` (aborting the app) for any value beyond ±90. - private static let latitudeLimit = 90.0 - - private func updateCameraRegion(in mapView: MLNMapView, coordinator: Coordinator) { - guard let region = cameraRegion else { return } - guard cameraRegionVersion != coordinator.lastAppliedRegionVersion else { return } - - guard CLLocationCoordinate2DIsValid(region.center) else { - coordinator.lastAppliedRegionVersion = cameraRegionVersion - return - } - - // Corners are center ± span/2, so a non-finite span makes MapLibre's LatLng - // constructor throw and abort the process — and the latitude clamp below can't - // catch it because Swift's max/min propagate NaN. Skip the update when non-finite. - guard region.span.latitudeDelta.isFinite, - region.span.longitudeDelta.isFinite else { - coordinator.lastAppliedRegionVersion = cameraRegionVersion - return - } + // Corners are center ± span/2, so a non-finite span makes MapLibre's LatLng + // constructor throw and abort the process — and the latitude clamp below can't + // catch it because Swift's max/min propagate NaN. Skip the update when non-finite. + guard region.span.latitudeDelta.isFinite, + region.span.longitudeDelta.isFinite else { + coordinator.lastAppliedRegionVersion = cameraRegionVersion + return + } - let isInflated = mapView.window.map { mapView.bounds.height > $0.bounds.height * 1.5 } ?? false - let animated = coordinator.lastAppliedRegionVersion > 0 && !isInflated - coordinator.lastAppliedRegionVersion = cameraRegionVersion - - // Clamp latitude so a near-pole center can't push a corner past ±90 (another - // LatLng abort). Longitude is left unclamped because MapLibre wraps it. - let limit = Self.latitudeLimit - let bounds = MLNCoordinateBounds( - sw: CLLocationCoordinate2D( - latitude: max(-limit, region.center.latitude - region.span.latitudeDelta / 2), - longitude: region.center.longitude - region.span.longitudeDelta / 2 - ), - ne: CLLocationCoordinate2D( - latitude: min(limit, region.center.latitude + region.span.latitudeDelta / 2), - longitude: region.center.longitude + region.span.longitudeDelta / 2 - ) - ) - var padding = cameraEdgePadding - if let sheetFraction = cameraBottomSheetFraction { - let insets = mapView.safeAreaInsets - padding.top = max(padding.top, insets.top + 20) - padding.left = max(padding.left, insets.left + 20) - if sheetFraction > 0 { - let stableHeight = mapView.window?.bounds.height ?? mapView.bounds.height - padding.bottom = max(padding.bottom, stableHeight * sheetFraction) - } - } + let isInflated = mapView.window.map { mapView.bounds.height > $0.bounds.height * 1.5 } ?? false + let animated = coordinator.lastAppliedRegionVersion > 0 && !isInflated + coordinator.lastAppliedRegionVersion = cameraRegionVersion + + // Clamp latitude so a near-pole center can't push a corner past ±90 (another + // LatLng abort). Longitude is left unclamped because MapLibre wraps it. + let limit = Self.latitudeLimit + let bounds = MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: max(-limit, region.center.latitude - region.span.latitudeDelta / 2), + longitude: region.center.longitude - region.span.longitudeDelta / 2 + ), + ne: CLLocationCoordinate2D( + latitude: min(limit, region.center.latitude + region.span.latitudeDelta / 2), + longitude: region.center.longitude + region.span.longitudeDelta / 2 + ) + ) + var padding = cameraEdgePadding + if let sheetFraction = cameraBottomSheetFraction { + let insets = mapView.safeAreaInsets + padding.top = max(padding.top, insets.top + 20) + padding.left = max(padding.left, insets.left + 20) + if sheetFraction > 0 { + let stableHeight = mapView.window?.bounds.height ?? mapView.bounds.height + padding.bottom = max(padding.bottom, stableHeight * sheetFraction) + } + } - if let windowSize = mapView.window?.bounds.size, - mapView.bounds.height > windowSize.height * 1.5 { - let centerLat = (bounds.sw.latitude + bounds.ne.latitude) / 2 - let centerLon = (bounds.sw.longitude + bounds.ne.longitude) / 2 - let latSpanMeters = abs(bounds.ne.latitude - bounds.sw.latitude) * 111_000 - let lonSpanMeters = abs(bounds.ne.longitude - bounds.sw.longitude) * 111_000 - * cos(centerLat * .pi / 180) - - let usableWidth = max(1, Double(windowSize.width) - Double(padding.left + padding.right)) - let usableHeight = max(1, Double(windowSize.height) - Double(padding.top + padding.bottom)) - - let mppForLat = latSpanMeters / usableHeight - let mppForLon = lonSpanMeters / usableWidth - let requiredMPP = max(mppForLat, mppForLon) - - let currentMPP = mapView.metersPerPoint(atLatitude: centerLat) - let targetZoom = mapView.zoomLevel + log2(currentMPP / requiredMPP) - - let pixelOffset = (Double(padding.top) - Double(padding.bottom)) / 2 - let offsetDeg = pixelOffset * requiredMPP / 111_000 - let center = CLLocationCoordinate2D( - latitude: min(limit, max(-limit, centerLat + offsetDeg)), - longitude: centerLon - ) - - mapView.setCenter(center, zoomLevel: targetZoom, animated: false) - } else { - mapView.setVisibleCoordinateBounds( - bounds, - edgePadding: padding, - animated: animated, - completionHandler: nil - ) - } + if let windowSize = mapView.window?.bounds.size, + mapView.bounds.height > windowSize.height * 1.5 { + let centerLat = (bounds.sw.latitude + bounds.ne.latitude) / 2 + let centerLon = (bounds.sw.longitude + bounds.ne.longitude) / 2 + let latSpanMeters = abs(bounds.ne.latitude - bounds.sw.latitude) * 111_000 + let lonSpanMeters = abs(bounds.ne.longitude - bounds.sw.longitude) * 111_000 + * cos(centerLat * .pi / 180) + + let usableWidth = max(1, Double(windowSize.width) - Double(padding.left + padding.right)) + let usableHeight = max(1, Double(windowSize.height) - Double(padding.top + padding.bottom)) + + let mppForLat = latSpanMeters / usableHeight + let mppForLon = lonSpanMeters / usableWidth + let requiredMPP = max(mppForLat, mppForLon) + + let currentMPP = mapView.metersPerPoint(atLatitude: centerLat) + let targetZoom = mapView.zoomLevel + log2(currentMPP / requiredMPP) + + let pixelOffset = (Double(padding.top) - Double(padding.bottom)) / 2 + let offsetDeg = pixelOffset * requiredMPP / 111_000 + let center = CLLocationCoordinate2D( + latitude: min(limit, max(-limit, centerLat + offsetDeg)), + longitude: centerLon + ) + + mapView.setCenter(center, zoomLevel: targetZoom, animated: false) + } else { + mapView.setVisibleCoordinateBounds( + bounds, + edgePadding: padding, + animated: animated, + completionHandler: nil + ) } + } } // MARK: - Coordinator extension MC1MapView { - @MainActor - class Coordinator: NSObject, @preconcurrency MLNMapViewDelegate, UIGestureRecognizerDelegate { - // Non-zero frame avoids MapLibre zero-size Metal init (issue #67). - let mapView: MLNMapView = ScaledMLNMapView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) - - // Callbacks - var onPointTap: ((MapPoint, CGPoint) -> Void)? - var onMapTap: ((CLLocationCoordinate2D) -> Void)? - var onCameraRegionChange: ((MKCoordinateRegion) -> Void)? - var setIsStyleLoaded: ((Bool) -> Void)? - - // State - var isUserInteracting = false - var isUpdatingFromSwiftUI = false - var isStyleLoaded = false - var lastAppliedRegionVersion = 0 - var pendingRegionTask: Task? - var currentShowLabels = true - var lastAppliedStyleURL: URL? - var currentMapStyle: MapStyleSelection? - var lastAppliedMapStyle: MapStyleSelection? - var currentPoints: [MapPoint] = [] - var currentLines: [MapLine] = [] - var lastAppliedPoints: [MapPoint] = [] - var lastAppliedClusterablePoints: [MapPoint] = [] - var lastAppliedFixedPoints: [MapPoint] = [] - var lastAppliedLines: [MapLine] = [] - var clusterSource: MLNShapeSource? - var fixedSource: MLNShapeSource? - - // MARK: - Style loading - - func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { - isStyleLoaded = true - setIsStyleLoaded?(true) - - // Clear stale source/state references from the previous style. - // Reset currentShowLabels to the new layer default (visible) so - // updateUIView detects the mismatch and reapplies the user's preference. - // A reload rebuilds the raster layers with isVisible == false, so clear - // lastAppliedMapStyle to force updateUIView to re-apply the selected overlay. - clusterSource = nil - fixedSource = nil - lastAppliedPoints = [] - lastAppliedClusterablePoints = [] - lastAppliedFixedPoints = [] - lastAppliedLines = [] - lastAppliedMapStyle = nil - currentShowLabels = true - - PinSpriteRenderer.renderAll(into: style) - setupRasterSources(style: style, mapView: mapView) - setupLineLayers(style: style) - - updatePointSource(mapView: mapView) - updateLineSource(mapView: mapView) - } + @MainActor + class Coordinator: NSObject, @preconcurrency MLNMapViewDelegate, UIGestureRecognizerDelegate { + /// Non-zero frame avoids MapLibre zero-size Metal init (issue #67). + let mapView: MLNMapView = ScaledMLNMapView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) + + // Callbacks + var onPointTap: ((MapPoint, CGPoint) -> Void)? + var onMapTap: ((CLLocationCoordinate2D) -> Void)? + var onCameraRegionChange: ((MKCoordinateRegion) -> Void)? + var setIsStyleLoaded: ((Bool) -> Void)? + + // State + var isUserInteracting = false + var isUpdatingFromSwiftUI = false + var isStyleLoaded = false + var lastAppliedRegionVersion = 0 + var pendingRegionTask: Task? + var currentShowLabels = true + var lastAppliedStyleURL: URL? + var currentMapStyle: MapStyleSelection? + var lastAppliedMapStyle: MapStyleSelection? + var currentPoints: [MapPoint] = [] + var currentLines: [MapLine] = [] + var lastAppliedPoints: [MapPoint] = [] + var lastAppliedClusterablePoints: [MapPoint] = [] + var lastAppliedFixedPoints: [MapPoint] = [] + var lastAppliedLines: [MapLine] = [] + var clusterSource: MLNShapeSource? + var fixedSource: MLNShapeSource? + + // MARK: - Style loading + + func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { + isStyleLoaded = true + setIsStyleLoaded?(true) + + // Clear stale source/state references from the previous style. + // Reset currentShowLabels to the new layer default (visible) so + // updateUIView detects the mismatch and reapplies the user's preference. + // A reload rebuilds the raster layers with isVisible == false, so clear + // lastAppliedMapStyle to force updateUIView to re-apply the selected overlay. + clusterSource = nil + fixedSource = nil + lastAppliedPoints = [] + lastAppliedClusterablePoints = [] + lastAppliedFixedPoints = [] + lastAppliedLines = [] + lastAppliedMapStyle = nil + currentShowLabels = true + + PinSpriteRenderer.renderAll(into: style) + setupRasterSources(style: style, mapView: mapView) + setupLineLayers(style: style) + + updatePointSource(mapView: mapView) + updateLineSource(mapView: mapView) + } - func mapView(_ mapView: MLNMapView, didFailToLoadImage imageName: String) -> UIImage? { - if let style = mapView.style, - let image = PinSpriteRenderer.renderOnDemand(name: imageName, into: style) { - return image - } - logger.error("didFailToLoadImage: \(imageName)") - return nil - } + func mapView(_ mapView: MLNMapView, didFailToLoadImage imageName: String) -> UIImage? { + if let style = mapView.style, + let image = PinSpriteRenderer.renderOnDemand(name: imageName, into: style) { + return image + } + logger.error("didFailToLoadImage: \(imageName)") + return nil + } - // MARK: - Region changes + // MARK: - Region changes - private static let userGestureReasons: MLNCameraChangeReason = [ - .gesturePan, .gesturePinch, .gestureZoomIn, .gestureZoomOut, - .gestureRotate, .gestureTilt, .gestureOneFingerZoom - ] + private static let userGestureReasons: MLNCameraChangeReason = [ + .gesturePan, .gesturePinch, .gestureZoomIn, .gestureZoomOut, + .gestureRotate, .gestureTilt, .gestureOneFingerZoom + ] - func mapViewRegionIsChanging(_ mapView: MLNMapView) { - isUserInteracting = true - } + func mapViewRegionIsChanging(_ mapView: MLNMapView) { + isUserInteracting = true + } - func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated: Bool) { - isUserInteracting = false - guard !isUpdatingFromSwiftUI else { return } - - let isUserGesture = !reason.isDisjoint(with: Self.userGestureReasons) - guard isUserGesture else { return } - - // Debounce: cancel previous pending write-back - pendingRegionTask?.cancel() - pendingRegionTask = Task { - try? await Task.sleep(for: .milliseconds(50)) - guard !Task.isCancelled else { return } - let region = mapView.mlnRegion - self.onCameraRegionChange?(region) - } - } + func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated: Bool) { + isUserInteracting = false + guard !isUpdatingFromSwiftUI else { return } + + let isUserGesture = !reason.isDisjoint(with: Self.userGestureReasons) + guard isUserGesture else { return } + + // Debounce: cancel previous pending write-back + pendingRegionTask?.cancel() + pendingRegionTask = Task { + try? await Task.sleep(for: .milliseconds(50)) + guard !Task.isCancelled else { return } + let region = mapView.mlnRegion + self.onCameraRegionChange?(region) + } + } - // MARK: - Gesture recognizer delegate + // MARK: - Gesture recognizer delegate - nonisolated func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer - ) -> Bool { - true - } + nonisolated func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer + ) -> Bool { + true + } - // MARK: - Tap handling - - @objc func handleTap(_ sender: UITapGestureRecognizer) { - guard sender.state == .ended else { return } - let point = sender.location(in: mapView) - let clusterRect = CGRect(x: point.x - 22, y: point.y - 22, width: 44, height: 44) - logger.debug("handleTap at \(point.x, privacy: .public), \(point.y, privacy: .public)") - - // 1. Check cluster layers - let clusterFeatures = mapView.visibleFeatures( - in: clusterRect, - styleLayerIdentifiers: [MapLayerID.clusterCircles] - ) - if let cluster = clusterFeatures.first(where: { $0 is MLNPointFeatureCluster }) as? MLNPointFeatureCluster, - let source = mapView.style?.source(withIdentifier: MapSourceID.points) as? MLNShapeSource { - let zoom = source.zoomLevel(forExpanding: cluster) - guard zoom >= 0 else { return } - mapView.setCenter(cluster.coordinate, zoomLevel: zoom + 2.0, animated: true) - return - } - - // 2. Check point and name label layers (both clustered and fixed) - let pointFeatures = mapView.visibleFeatures( - at: point, - styleLayerIdentifiers: [ - MapLayerID.unclusteredIcons, MapLayerID.fixedIcons, - MapLayerID.nameLabels, MapLayerID.fixedNameLabels - ] - ) - logger.debug("pointFeatures: \(pointFeatures.count, privacy: .public), clusterFeatures: \(clusterFeatures.count, privacy: .public)") - if let feature = pointFeatures.first, - let idString = feature.attribute(forKey: "pointId") as? String, - let id = UUID(uuidString: idString), - let mapPoint = currentPoints.first(where: { $0.id == id }) { - logger.debug("Matched pin: \(mapPoint.label ?? "unnamed", privacy: .public)") - let pinScreenPos = mapView.convert(mapPoint.coordinate, toPointTo: mapView) - let calloutAnchor = CGPoint(x: pinScreenPos.x, y: pinScreenPos.y - PinSpriteRenderer.standardHeight) - onPointTap?(mapPoint, calloutAnchor) - return - } - - // 3. Check badge text layers — dismiss any open callout but don't select - let badgeFeatures = mapView.visibleFeatures( - at: point, - styleLayerIdentifiers: [MapLayerID.badgeText, MapLayerID.fixedBadgeText] - ) - if badgeFeatures.first != nil { - let coordinate = mapView.convert(point, toCoordinateFrom: mapView) - onMapTap?(coordinate) - return - } - - // 4. Map background tap - let coordinate = mapView.convert(point, toCoordinateFrom: mapView) - onMapTap?(coordinate) - } + // MARK: - Tap handling + + @objc func handleTap(_ sender: UITapGestureRecognizer) { + guard sender.state == .ended else { return } + let point = sender.location(in: mapView) + let clusterRect = CGRect(x: point.x - 22, y: point.y - 22, width: 44, height: 44) + logger.debug("handleTap at \(point.x, privacy: .public), \(point.y, privacy: .public)") + + // 1. Check cluster layers + let clusterFeatures = mapView.visibleFeatures( + in: clusterRect, + styleLayerIdentifiers: [MapLayerID.clusterCircles] + ) + if let cluster = clusterFeatures.first(where: { $0 is MLNPointFeatureCluster }) as? MLNPointFeatureCluster, + let source = mapView.style?.source(withIdentifier: MapSourceID.points) as? MLNShapeSource { + let zoom = source.zoomLevel(forExpanding: cluster) + guard zoom >= 0 else { return } + mapView.setCenter(cluster.coordinate, zoomLevel: zoom + 2.0, animated: true) + return + } + + // 2. Check point and name label layers (both clustered and fixed) + let pointFeatures = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: [ + MapLayerID.unclusteredIcons, MapLayerID.fixedIcons, + MapLayerID.nameLabels, MapLayerID.fixedNameLabels + ] + ) + logger.debug("pointFeatures: \(pointFeatures.count, privacy: .public), clusterFeatures: \(clusterFeatures.count, privacy: .public)") + if let feature = pointFeatures.first, + let idString = feature.attribute(forKey: "pointId") as? String, + let id = UUID(uuidString: idString), + let mapPoint = currentPoints.first(where: { $0.id == id }) { + logger.debug("Matched pin: \(mapPoint.label ?? "unnamed", privacy: .public)") + let pinScreenPos = mapView.convert(mapPoint.coordinate, toPointTo: mapView) + let calloutAnchor = CGPoint(x: pinScreenPos.x, y: pinScreenPos.y - PinSpriteRenderer.standardHeight) + onPointTap?(mapPoint, calloutAnchor) + return + } + + // 3. Check badge text layers — dismiss any open callout but don't select + let badgeFeatures = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: [MapLayerID.badgeText, MapLayerID.fixedBadgeText] + ) + if badgeFeatures.first != nil { + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + onMapTap?(coordinate) + return + } + + // 4. Map background tap + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + onMapTap?(coordinate) } + } } // MARK: - MLNMapView region helper extension MLNMapView { - var mlnRegion: MKCoordinateRegion { - let bounds = visibleCoordinateBounds - let center = CLLocationCoordinate2D( - latitude: (bounds.sw.latitude + bounds.ne.latitude) / 2, - longitude: (bounds.sw.longitude + bounds.ne.longitude) / 2 - ) - let span = MKCoordinateSpan( - latitudeDelta: bounds.ne.latitude - bounds.sw.latitude, - longitudeDelta: bounds.ne.longitude - bounds.sw.longitude - ) - return MKCoordinateRegion(center: center, span: span) - } + var mlnRegion: MKCoordinateRegion { + let bounds = visibleCoordinateBounds + let center = CLLocationCoordinate2D( + latitude: (bounds.sw.latitude + bounds.ne.latitude) / 2, + longitude: (bounds.sw.longitude + bounds.ne.longitude) / 2 + ) + let span = MKCoordinateSpan( + latitudeDelta: bounds.ne.latitude - bounds.sw.latitude, + longitudeDelta: bounds.ne.longitude - bounds.sw.longitude + ) + return MKCoordinateRegion(center: center, span: span) + } } diff --git a/MC1/Views/Map/MapCameraStore.swift b/MC1/Views/Map/MapCameraStore.swift index c6a72b7e..8446c736 100644 --- a/MC1/Views/Map/MapCameraStore.swift +++ b/MC1/Views/Map/MapCameraStore.swift @@ -5,26 +5,25 @@ import MapKit /// MapLibre (invalid coordinate, non-finite or non-positive span), so a malformed /// restoration archive can never feed `MLNCoordinateBounds` a process-aborting value. enum MapCameraStore { + /// Component count of the `lat,lon,latDelta,lonDelta` encoding. + private static let componentCount = 4 - /// Component count of the `lat,lon,latDelta,lonDelta` encoding. - private static let componentCount = 4 + static func encode(_ region: MKCoordinateRegion) -> String { + "\(region.center.latitude),\(region.center.longitude)," + + "\(region.span.latitudeDelta),\(region.span.longitudeDelta)" + } - static func encode(_ region: MKCoordinateRegion) -> String { - "\(region.center.latitude),\(region.center.longitude)," - + "\(region.span.latitudeDelta),\(region.span.longitudeDelta)" - } - - static func decode(_ string: String) -> MKCoordinateRegion? { - let parts = string.split(separator: ",").compactMap { Double($0) } - guard parts.count == componentCount else { return nil } + static func decode(_ string: String) -> MKCoordinateRegion? { + let parts = string.split(separator: ",").compactMap { Double($0) } + guard parts.count == componentCount else { return nil } - let center = CLLocationCoordinate2D(latitude: parts[0], longitude: parts[1]) - let span = MKCoordinateSpan(latitudeDelta: parts[2], longitudeDelta: parts[3]) - guard CLLocationCoordinate2DIsValid(center), - span.latitudeDelta.isFinite, span.longitudeDelta.isFinite, - span.latitudeDelta > 0, span.longitudeDelta > 0 else { - return nil - } - return MKCoordinateRegion(center: center, span: span) + let center = CLLocationCoordinate2D(latitude: parts[0], longitude: parts[1]) + let span = MKCoordinateSpan(latitudeDelta: parts[2], longitudeDelta: parts[3]) + guard CLLocationCoordinate2DIsValid(center), + span.latitudeDelta.isFinite, span.longitudeDelta.isFinite, + span.latitudeDelta > 0, span.longitudeDelta > 0 else { + return nil } + return MKCoordinateRegion(center: center, span: span) + } } diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift index bab510be..fac8e3e8 100644 --- a/MC1/Views/Map/MapCanvasView.swift +++ b/MC1/Views/Map/MapCanvasView.swift @@ -1,132 +1,131 @@ import MapKit import MapLibre -import SwiftUI import MC1Services +import SwiftUI /// Canvas wrapping the map content with offline badge, floating controls, and layers menu overlay struct MapCanvasView: View { - @Environment(\.appState) private var appState - @Bindable var viewModel: MapViewModel - @Binding var mapStyleSelection: MapStyleSelection - @Binding var showLabels: Bool - @Binding var selectedCalloutContact: ContactDTO? - @Binding var selectedPointScreenPosition: CGPoint? - @Binding var isStyleLoaded: Bool - let onShowContactDetail: (ContactDTO) -> Void - let onNavigateToChat: (ContactDTO) -> Void - let onCenterOnUser: () -> Void - let onClearSelection: () -> Void - let onPersistCamera: (MKCoordinateRegion) -> Void + @Environment(\.appState) private var appState + @Bindable var viewModel: MapViewModel + @Binding var mapStyleSelection: MapStyleSelection + @Binding var showLabels: Bool + @Binding var selectedCalloutContact: ContactDTO? + @Binding var selectedPointScreenPosition: CGPoint? + @Binding var isStyleLoaded: Bool + let onShowContactDetail: (ContactDTO) -> Void + let onNavigateToChat: (ContactDTO) -> Void + let onCenterOnUser: () -> Void + let onClearSelection: () -> Void + let onPersistCamera: (MKCoordinateRegion) -> Void - var body: some View { - ZStack { - MapContentView( - viewModel: viewModel, - mapStyleSelection: mapStyleSelection, - showLabels: showLabels, - selectedCalloutContact: $selectedCalloutContact, - selectedPointScreenPosition: $selectedPointScreenPosition, - isStyleLoaded: $isStyleLoaded, - onShowContactDetail: onShowContactDetail, - onNavigateToChat: onNavigateToChat, - onPersistCamera: onPersistCamera - ) - .ignoresSafeArea() + var body: some View { + ZStack { + MapContentView( + viewModel: viewModel, + mapStyleSelection: mapStyleSelection, + showLabels: showLabels, + selectedCalloutContact: $selectedCalloutContact, + selectedPointScreenPosition: $selectedPointScreenPosition, + isStyleLoaded: $isStyleLoaded, + onShowContactDetail: onShowContactDetail, + onNavigateToChat: onNavigateToChat, + onPersistCamera: onPersistCamera + ) + .ignoresSafeArea() - // Offline badge - if !appState.offlineMapService.isNetworkAvailable { - OfflineBadge() - } + // Offline badge + if !appState.offlineMapService.isNetworkAvailable { + OfflineBadge() + } - // Floating controls - VStack { - Spacer() - MapCanvasControls( - isNorthLocked: $viewModel.isNorthLocked, - showingLayersMenu: $viewModel.showingLayersMenu, - showLabels: $showLabels, - contactsEmpty: viewModel.contactsWithLocation.isEmpty, - onLocationTap: { onCenterOnUser() }, - onClearSelection: onClearSelection, - onCenterAll: { viewModel.centerOnAllContacts() } - ) - } + // Floating controls + VStack { + Spacer() + MapCanvasControls( + isNorthLocked: $viewModel.isNorthLocked, + showingLayersMenu: $viewModel.showingLayersMenu, + showLabels: $showLabels, + contactsEmpty: viewModel.contactsWithLocation.isEmpty, + onLocationTap: { onCenterOnUser() }, + onClearSelection: onClearSelection, + onCenterAll: { viewModel.centerOnAllContacts() } + ) + } - // Layers menu overlay - if viewModel.showingLayersMenu { - Button { - withAnimation { - viewModel.showingLayersMenu = false - } - } label: { - Color.black.opacity(0.3) - .ignoresSafeArea() - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) + // Layers menu overlay + if viewModel.showingLayersMenu { + Button { + withAnimation { + viewModel.showingLayersMenu = false + } + } label: { + Color.black.opacity(0.3) + .ignoresSafeArea() + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) - VStack { - Spacer() - HStack { - Spacer() - LayersMenu( - selection: $mapStyleSelection, - isPresented: $viewModel.showingLayersMenu, - viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() - ) - .padding(.trailing, 72) - .padding(.bottom) - } - } - } + VStack { + Spacer() + HStack { + Spacer() + LayersMenu( + selection: $mapStyleSelection, + isPresented: $viewModel.showingLayersMenu, + viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() + ) + .padding(.trailing, 72) + .padding(.bottom) + } } + } } - + } } // MARK: - Map Controls private struct MapCanvasControls: View { - @Binding var isNorthLocked: Bool - @Binding var showingLayersMenu: Bool - @Binding var showLabels: Bool - let contactsEmpty: Bool - let onLocationTap: () -> Void - let onClearSelection: () -> Void - let onCenterAll: () -> Void + @Binding var isNorthLocked: Bool + @Binding var showingLayersMenu: Bool + @Binding var showLabels: Bool + let contactsEmpty: Bool + let onLocationTap: () -> Void + let onClearSelection: () -> Void + let onCenterAll: () -> Void - var body: some View { - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: onLocationTap, - isNorthLocked: $isNorthLocked, - showLabels: $showLabels, - showingLayersMenu: $showingLayersMenu - ) { - CenterAllButton( - isEmpty: contactsEmpty, - onClearSelection: onClearSelection, - onCenterAll: onCenterAll - ) - } - } + var body: some View { + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: onLocationTap, + isNorthLocked: $isNorthLocked, + showLabels: $showLabels, + showingLayersMenu: $showingLayersMenu + ) { + CenterAllButton( + isEmpty: contactsEmpty, + onClearSelection: onClearSelection, + onCenterAll: onCenterAll + ) + } } + } } // MARK: - Control Buttons private struct CenterAllButton: View { - let isEmpty: Bool - let onClearSelection: () -> Void - let onCenterAll: () -> Void + let isEmpty: Bool + let onClearSelection: () -> Void + let onCenterAll: () -> Void - var body: some View { - Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { - onClearSelection() - onCenterAll() - } - .mapControlButton(tint: isEmpty ? .secondary : .primary) - .disabled(isEmpty) + var body: some View { + Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { + onClearSelection() + onCenterAll() } + .mapControlButton(tint: isEmpty ? .secondary : .primary) + .disabled(isEmpty) + } } diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift index 3a9adbc3..e4b4af88 100644 --- a/MC1/Views/Map/MapContentView.swift +++ b/MC1/Views/Map/MapContentView.swift @@ -1,119 +1,118 @@ -import SwiftUI -import MC1Services import CoreLocation import MapKit +import MC1Services +import SwiftUI /// Map content displaying MC1MapView with contact points and popover callouts struct MapContentView: View { - @Environment(\.appState) private var appState - @Environment(\.colorScheme) private var colorScheme - @Bindable var viewModel: MapViewModel - let mapStyleSelection: MapStyleSelection - let showLabels: Bool - @Binding var selectedCalloutContact: ContactDTO? - @Binding var selectedPointScreenPosition: CGPoint? - @Binding var isStyleLoaded: Bool - let onShowContactDetail: (ContactDTO) -> Void - let onNavigateToChat: (ContactDTO) -> Void - let onPersistCamera: (MKCoordinateRegion) -> Void + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + @Bindable var viewModel: MapViewModel + let mapStyleSelection: MapStyleSelection + let showLabels: Bool + @Binding var selectedCalloutContact: ContactDTO? + @Binding var selectedPointScreenPosition: CGPoint? + @Binding var isStyleLoaded: Bool + let onShowContactDetail: (ContactDTO) -> Void + let onNavigateToChat: (ContactDTO) -> Void + let onPersistCamera: (MKCoordinateRegion) -> Void - @State private var selectedDroppedPin: DroppedPinSelection? + @State private var selectedDroppedPin: DroppedPinSelection? - var body: some View { - MC1MapView( - points: viewModel.mapPoints, - lines: [], - mapStyle: mapStyleSelection, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: showLabels, - showsUserLocation: true, - isInteractive: true, - showsScale: true, - isNorthLocked: viewModel.isNorthLocked, - cameraRegion: $viewModel.cameraRegion, - cameraRegionVersion: viewModel.cameraRegionVersion, - onPointTap: { point, screenPosition in - if point.pinStyle == .droppedPin { - selectedCalloutContact = nil - selectedDroppedPin = DroppedPinSelection(coordinate: point.coordinate) - } else { - selectedDroppedPin = nil - selectedCalloutContact = viewModel.contactsWithLocation.first { $0.id == point.id } - } - selectedPointScreenPosition = screenPosition - }, - onMapTap: { _ in - selectedCalloutContact = nil - selectedDroppedPin = nil - selectedPointScreenPosition = nil - }, - onCameraRegionChange: { region in - viewModel.cameraRegion = region - onPersistCamera(region) - if selectedCalloutContact != nil || selectedDroppedPin != nil { - selectedCalloutContact = nil - selectedDroppedPin = nil - selectedPointScreenPosition = nil - } - }, - isStyleLoaded: $isStyleLoaded - ) - .popover( - item: $selectedCalloutContact, - attachmentAnchor: .rect(.rect(CGRect( - origin: selectedPointScreenPosition ?? .zero, - size: CGSize(width: 1, height: 1) - ))), - arrowEdge: .bottom - ) { contact in - ContactCalloutContent( - contact: contact, - onDetail: { onShowContactDetail(contact) }, - onMessage: { onNavigateToChat(contact) } - ) - .presentationCompactAdaptation(.popover) - } - .popover( - item: $selectedDroppedPin, - attachmentAnchor: .rect(.rect(CGRect( - origin: selectedPointScreenPosition ?? .zero, - size: CGSize(width: 1, height: 1) - ))), - arrowEdge: .bottom - ) { selection in - DroppedPinCallout(coordinate: selection.coordinate) { - viewModel.clearFocusedPin() - selectedDroppedPin = nil - } - .presentationCompactAdaptation(.popover) + var body: some View { + MC1MapView( + points: viewModel.mapPoints, + lines: [], + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: viewModel.isNorthLocked, + cameraRegion: $viewModel.cameraRegion, + cameraRegionVersion: viewModel.cameraRegionVersion, + onPointTap: { point, screenPosition in + if point.pinStyle == .droppedPin { + selectedCalloutContact = nil + selectedDroppedPin = DroppedPinSelection(coordinate: point.coordinate) + } else { + selectedDroppedPin = nil + selectedCalloutContact = viewModel.contactsWithLocation.first { $0.id == point.id } } - .overlay { - if !isStyleLoaded { - ProgressView() - .scaleEffect(1.5) - } else if viewModel.isLoading { - MapLoadingOverlay() - } + selectedPointScreenPosition = screenPosition + }, + onMapTap: { _ in + selectedCalloutContact = nil + selectedDroppedPin = nil + selectedPointScreenPosition = nil + }, + onCameraRegionChange: { region in + viewModel.cameraRegion = region + onPersistCamera(region) + if selectedCalloutContact != nil || selectedDroppedPin != nil { + selectedCalloutContact = nil + selectedDroppedPin = nil + selectedPointScreenPosition = nil } + }, + isStyleLoaded: $isStyleLoaded + ) + .popover( + item: $selectedCalloutContact, + attachmentAnchor: .rect(.rect(CGRect( + origin: selectedPointScreenPosition ?? .zero, + size: CGSize(width: 1, height: 1) + ))), + arrowEdge: .bottom + ) { contact in + ContactCalloutContent( + contact: contact, + onDetail: { onShowContactDetail(contact) }, + onMessage: { onNavigateToChat(contact) } + ) + .presentationCompactAdaptation(.popover) } - + .popover( + item: $selectedDroppedPin, + attachmentAnchor: .rect(.rect(CGRect( + origin: selectedPointScreenPosition ?? .zero, + size: CGSize(width: 1, height: 1) + ))), + arrowEdge: .bottom + ) { selection in + DroppedPinCallout(coordinate: selection.coordinate) { + viewModel.clearFocusedPin() + selectedDroppedPin = nil + } + .presentationCompactAdaptation(.popover) + } + .overlay { + if !isStyleLoaded { + ProgressView() + .scaleEffect(1.5) + } else if viewModel.isLoading { + MapLoadingOverlay() + } + } + } } // MARK: - Loading Overlay private struct MapLoadingOverlay: View { - var body: some View { - ZStack { - Color.black.opacity(0.1) - ProgressView() - .padding() - .background(.regularMaterial, in: .rect(cornerRadius: 8)) - } + var body: some View { + ZStack { + Color.black.opacity(0.1) + ProgressView() + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 8)) } + } } private struct DroppedPinSelection: Identifiable { - let id = UUID() - let coordinate: CLLocationCoordinate2D + let id = UUID() + let coordinate: CLLocationCoordinate2D } diff --git a/MC1/Views/Map/MapLine+SNR.swift b/MC1/Views/Map/MapLine+SNR.swift index d0c7552d..82bb2621 100644 --- a/MC1/Views/Map/MapLine+SNR.swift +++ b/MC1/Views/Map/MapLine+SNR.swift @@ -3,68 +3,68 @@ import Foundation import MC1Services extension MapLine.LineStyle { - /// Maps an SNR value to a trace line style. `SNRQuality` owns the thresholds, so no bare - /// SNR literals are re-encoded here. - static func forSNR(_ snr: Double?) -> MapLine.LineStyle { - switch SNRQuality(snr: snr) { - case .excellent, .good: .traceGood - case .fair: .traceMedium - case .poor: .traceWeak - case .unknown: .traceUntraced - } + /// Maps an SNR value to a trace line style. `SNRQuality` owns the thresholds, so no bare + /// SNR literals are re-encoded here. + static func forSNR(_ snr: Double?) -> MapLine.LineStyle { + switch SNRQuality(snr: snr) { + case .excellent, .good: .traceGood + case .fair: .traceMedium + case .poor: .traceWeak + case .unknown: .traceUntraced } + } } extension MapLine { - /// Builds the midpoint badge label shared by the trace and neighbor SNR maps, of the form - /// " · dB", with the `dB` unit routed through `L10n`. The space before the unit - /// is composed here so whitespace-trimming tooling can't strip it out of the localized value. - static func snrBadgeText(distance: Double, snr: Double) -> String { - let distFormatted = Measurement(value: distance, unit: UnitLength.meters) - .formatted(.measurement(width: .abbreviated, usage: .road)) - let snrFormatted = snr.formatted(.number.precision(.fractionLength(1))) - return "\(distFormatted) · \(snrFormatted) \(L10n.RemoteNodes.RemoteNodes.Status.snrBadgeUnit)" - } + /// Builds the midpoint badge label shared by the trace and neighbor SNR maps, of the form + /// " · dB", with the `dB` unit routed through `L10n`. The space before the unit + /// is composed here so whitespace-trimming tooling can't strip it out of the localized value. + static func snrBadgeText(distance: Double, snr: Double) -> String { + let distFormatted = Measurement(value: distance, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .road)) + let snrFormatted = snr.formatted(.number.precision(.fractionLength(1))) + return "\(distFormatted) · \(snrFormatted) \(L10n.RemoteNodes.RemoteNodes.Status.snrBadgeUnit)" + } - /// The midpoint distance/SNR badge pin for the link between two coordinates, shared by the trace - /// and neighbor SNR maps. Callers pass `id` so the trace map keeps its deterministic - /// `UUID(hopIndex:)` for diffing while the neighbor map mints a fresh one. - static func snrBadge( - id: UUID, - from: CLLocationCoordinate2D, - to: CLLocationCoordinate2D, - snr: Double - ) -> MapPoint { - let distance = CLLocation(latitude: from.latitude, longitude: from.longitude) - .distance(from: CLLocation(latitude: to.latitude, longitude: to.longitude)) - return MapPoint( - id: id, - coordinate: midpoint(from: from, to: to), - pinStyle: .badge, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: snrBadgeText(distance: distance, snr: snr) - ) - } + /// The midpoint distance/SNR badge pin for the link between two coordinates, shared by the trace + /// and neighbor SNR maps. Callers pass `id` so the trace map keeps its deterministic + /// `UUID(hopIndex:)` for diffing while the neighbor map mints a fresh one. + static func snrBadge( + id: UUID, + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D, + snr: Double + ) -> MapPoint { + let distance = CLLocation(latitude: from.latitude, longitude: from.longitude) + .distance(from: CLLocation(latitude: to.latitude, longitude: to.longitude)) + return MapPoint( + id: id, + coordinate: midpoint(from: from, to: to), + pinStyle: .badge, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: snrBadgeText(distance: distance, snr: snr) + ) + } - /// Geographic midpoint of two coordinates, shifting one longitude by 360° before averaging when - /// the pair straddles the antimeridian so the badge lands between them rather than on the - /// opposite hemisphere. - private static func midpoint( - from: CLLocationCoordinate2D, - to: CLLocationCoordinate2D - ) -> CLLocationCoordinate2D { - var lon1 = from.longitude - var lon2 = to.longitude - if abs(lon1 - lon2) > 180 { - if lon1 < lon2 { lon1 += 360 } else { lon2 += 360 } - } - var midLongitude = (lon1 + lon2) / 2 - if midLongitude > 180 { midLongitude -= 360 } - return CLLocationCoordinate2D( - latitude: (from.latitude + to.latitude) / 2, - longitude: midLongitude - ) + /// Geographic midpoint of two coordinates, shifting one longitude by 360° before averaging when + /// the pair straddles the antimeridian so the badge lands between them rather than on the + /// opposite hemisphere. + private static func midpoint( + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D + ) -> CLLocationCoordinate2D { + var lon1 = from.longitude + var lon2 = to.longitude + if abs(lon1 - lon2) > 180 { + if lon1 < lon2 { lon1 += 360 } else { lon2 += 360 } } + var midLongitude = (lon1 + lon2) / 2 + if midLongitude > 180 { midLongitude -= 360 } + return CLLocationCoordinate2D( + latitude: (from.latitude + to.latitude) / 2, + longitude: midLongitude + ) + } } diff --git a/MC1/Views/Map/MapLine.swift b/MC1/Views/Map/MapLine.swift index ed8d6843..40060254 100644 --- a/MC1/Views/Map/MapLine.swift +++ b/MC1/Views/Map/MapLine.swift @@ -1,28 +1,28 @@ import CoreLocation struct MapLine: Identifiable, Equatable { - let id: String - let coordinates: [CLLocationCoordinate2D] - let style: LineStyle - let opacity: Double - var pathIndex: Int? + let id: String + let coordinates: [CLLocationCoordinate2D] + let style: LineStyle + let opacity: Double + var pathIndex: Int? - enum LineStyle: String, Hashable { - case los - case traceUntraced - case traceWeak - case traceMedium - case traceGood - case messagePath - } + enum LineStyle: String, Hashable { + case los + case traceUntraced + case traceWeak + case traceMedium + case traceGood + case messagePath + } - static func == (lhs: MapLine, rhs: MapLine) -> Bool { - lhs.id == rhs.id - && lhs.style == rhs.style - && lhs.opacity == rhs.opacity - && lhs.coordinates.count == rhs.coordinates.count - && zip(lhs.coordinates, rhs.coordinates).allSatisfy { - $0.latitude == $1.latitude && $0.longitude == $1.longitude - } - } + static func == (lhs: MapLine, rhs: MapLine) -> Bool { + lhs.id == rhs.id + && lhs.style == rhs.style + && lhs.opacity == rhs.opacity + && lhs.coordinates.count == rhs.coordinates.count + && zip(lhs.coordinates, rhs.coordinates).allSatisfy { + $0.latitude == $1.latitude && $0.longitude == $1.longitude + } + } } diff --git a/MC1/Views/Map/MapPoint.swift b/MC1/Views/Map/MapPoint.swift index e394b3b6..8b1fbc6f 100644 --- a/MC1/Views/Map/MapPoint.swift +++ b/MC1/Views/Map/MapPoint.swift @@ -1,40 +1,40 @@ import CoreLocation struct MapPoint: Identifiable, Equatable { - let id: UUID - let coordinate: CLLocationCoordinate2D - let pinStyle: PinStyle - let label: String? - let isClusterable: Bool + let id: UUID + let coordinate: CLLocationCoordinate2D + let pinStyle: PinStyle + let label: String? + let isClusterable: Bool - enum PinStyle: String, Hashable { - case contactChat - case contactRepeater - case contactRoom - case repeater - case repeaterRingBlue - case repeaterRingGreen - case repeaterRingWhite - case repeaterHop - case pointA - case pointB - case crosshair - case obstruction - case badge - case droppedPin - } + enum PinStyle: String, Hashable { + case contactChat + case contactRepeater + case contactRoom + case repeater + case repeaterRingBlue + case repeaterRingGreen + case repeaterRingWhite + case repeaterHop + case pointA + case pointB + case crosshair + case obstruction + case badge + case droppedPin + } - let hopIndex: Int? - let badgeText: String? + let hopIndex: Int? + let badgeText: String? - static func == (lhs: MapPoint, rhs: MapPoint) -> Bool { - lhs.id == rhs.id - && lhs.coordinate.latitude == rhs.coordinate.latitude - && lhs.coordinate.longitude == rhs.coordinate.longitude - && lhs.pinStyle == rhs.pinStyle - && lhs.label == rhs.label - && lhs.isClusterable == rhs.isClusterable - && lhs.hopIndex == rhs.hopIndex - && lhs.badgeText == rhs.badgeText - } + static func == (lhs: MapPoint, rhs: MapPoint) -> Bool { + lhs.id == rhs.id + && lhs.coordinate.latitude == rhs.coordinate.latitude + && lhs.coordinate.longitude == rhs.coordinate.longitude + && lhs.pinStyle == rhs.pinStyle + && lhs.label == rhs.label + && lhs.isClusterable == rhs.isClusterable + && lhs.hopIndex == rhs.hopIndex + && lhs.badgeText == rhs.badgeText + } } diff --git a/MC1/Views/Map/MapSnapshotLayout.swift b/MC1/Views/Map/MapSnapshotLayout.swift index 4d7bfafc..a5390575 100644 --- a/MC1/Views/Map/MapSnapshotLayout.swift +++ b/MC1/Views/Map/MapSnapshotLayout.swift @@ -5,10 +5,10 @@ import CoreGraphics /// not part of `MapSnapshotRequest` — it never varies, so it must not shard the /// cache. enum MapSnapshotLayout { - static let width: CGFloat = 250 - static let height: CGFloat = 150 - static let cornerRadius: CGFloat = 12 - /// `MLNMapSnapshotOptions` has no MapKit-style span; zoom is the framing - /// control. Approximate framing — the exact pin is shown on tap. Tune visually. - static let zoomLevel: Double = 14 + static let width: CGFloat = 250 + static let height: CGFloat = 150 + static let cornerRadius: CGFloat = 12 + /// `MLNMapSnapshotOptions` has no MapKit-style span; zoom is the framing + /// control. Approximate framing — the exact pin is shown on tap. Tune visually. + static let zoomLevel: Double = 14 } diff --git a/MC1/Views/Map/MapSnapshotRenderer.swift b/MC1/Views/Map/MapSnapshotRenderer.swift index 6c88a790..d4e603ba 100644 --- a/MC1/Views/Map/MapSnapshotRenderer.swift +++ b/MC1/Views/Map/MapSnapshotRenderer.swift @@ -10,71 +10,71 @@ import UIKit /// only used briefly to build options and is suspended during the GL work. @MainActor final class MapSnapshotRenderer: MapSnapshotRendering { - func render(_ request: MapSnapshotRequest) async -> UIImage? { - let sprite = PinSpriteRenderer.droppedPinSprite() - let size = CGSize(width: MapSnapshotLayout.width, height: MapSnapshotLayout.height) - let latitude = request.latitude - let longitude = request.longitude - let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - let styleURL = MapStyleSelection.standard.styleURL( - isDarkMode: request.isDark, - isOffline: request.isOffline - ) + func render(_ request: MapSnapshotRequest) async -> UIImage? { + let sprite = PinSpriteRenderer.droppedPinSprite() + let size = CGSize(width: MapSnapshotLayout.width, height: MapSnapshotLayout.height) + let latitude = request.latitude + let longitude = request.longitude + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + let styleURL = MapStyleSelection.standard.styleURL( + isDarkMode: request.isDark, + isOffline: request.isOffline + ) - let camera = MLNMapCamera( - lookingAtCenter: coordinate, - altitude: 0, - pitch: 0, - heading: 0 - ) - let options = MLNMapSnapshotOptions(styleURL: styleURL, camera: camera, size: size) - options.zoomLevel = MapSnapshotLayout.zoomLevel - options.showsLogo = false - // Attribution is suppressed on the thumbnail; the full Map tab the user - // taps into shows the OSM/MapLibre attribution control. - options.showsAttribution = false + let camera = MLNMapCamera( + lookingAtCenter: coordinate, + altitude: 0, + pitch: 0, + heading: 0 + ) + let options = MLNMapSnapshotOptions(styleURL: styleURL, camera: camera, size: size) + options.zoomLevel = MapSnapshotLayout.zoomLevel + options.showsLogo = false + // Attribution is suppressed on the thumbnail; the full Map tab the user + // taps into shows the OSM/MapLibre attribution control. + options.showsAttribution = false - let snapshotter = MLNMapSnapshotter(options: options) - // The `snapshotter.start(...)` call below retains `snapshotter` for the - // duration of the underlying GL work — no extra anchor is needed to - // keep it alive across the `await` suspension. - let snapshotterRef = SnapshotterRef(snapshotter) + let snapshotter = MLNMapSnapshotter(options: options) + // The `snapshotter.start(...)` call below retains `snapshotter` for the + // duration of the underlying GL work — no extra anchor is needed to + // keep it alive across the `await` suspension. + let snapshotterRef = SnapshotterRef(snapshotter) - // `@Sendable` breaks `@MainActor` inheritance from the enclosing - // `withTaskCancellationHandler` operation closure; without it the - // runtime executor-isolation assertion (`dispatch_assert_queue_fail`) - // trips when MapLibre invokes the overlay handler off-main. - let overlayHandler: @Sendable (MLNMapSnapshotOverlay) -> Void = { overlay in - let point = overlay.point( - for: CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - ) - UIGraphicsPushContext(overlay.context) - sprite.draw(in: CGRect( - x: point.x - sprite.size.width / 2, - y: point.y - sprite.size.height, - width: sprite.size.width, - height: sprite.size.height - )) - UIGraphicsPopContext() - } + // `@Sendable` breaks `@MainActor` inheritance from the enclosing + // `withTaskCancellationHandler` operation closure; without it the + // runtime executor-isolation assertion (`dispatch_assert_queue_fail`) + // trips when MapLibre invokes the overlay handler off-main. + let overlayHandler: @Sendable (MLNMapSnapshotOverlay) -> Void = { overlay in + let point = overlay.point( + for: CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + UIGraphicsPushContext(overlay.context) + sprite.draw(in: CGRect( + x: point.x - sprite.size.width / 2, + y: point.y - sprite.size.height, + width: sprite.size.width, + height: sprite.size.height + )) + UIGraphicsPopContext() + } - return await withTaskCancellationHandler { - await withCheckedContinuation { (continuation: CheckedContinuation) in - snapshotter.start( - overlayHandler: overlayHandler, - completionHandler: { snapshot, _ in - continuation.resume(returning: snapshot?.image) - } - ) - } - } onCancel: { [snapshotterRef] in - // The cancel handler runs on whichever actor triggered cancellation; - // hop to the main actor so `MLNMapSnapshotter` (non-`Sendable`) is - // touched only from its owning context. Cancellation flows back to - // the awaiter via the `completionHandler` resuming with `nil`. - Task { @MainActor in snapshotterRef.snapshotter.cancel() } - } + return await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + snapshotter.start( + overlayHandler: overlayHandler, + completionHandler: { snapshot, _ in + continuation.resume(returning: snapshot?.image) + } + ) + } + } onCancel: { [snapshotterRef] in + // The cancel handler runs on whichever actor triggered cancellation; + // hop to the main actor so `MLNMapSnapshotter` (non-`Sendable`) is + // touched only from its owning context. Cancellation flows back to + // the awaiter via the `completionHandler` resuming with `nil`. + Task { @MainActor in snapshotterRef.snapshotter.cancel() } } + } } /// `@unchecked Sendable` shuttle so the cancellation closure (which is @@ -82,6 +82,8 @@ final class MapSnapshotRenderer: MapSnapshotRendering { /// across actors. The closure only reads the property and immediately hops /// back to the main actor before touching it. private final class SnapshotterRef: @unchecked Sendable { - let snapshotter: MLNMapSnapshotter - init(_ snapshotter: MLNMapSnapshotter) { self.snapshotter = snapshotter } + let snapshotter: MLNMapSnapshotter + init(_ snapshotter: MLNMapSnapshotter) { + self.snapshotter = snapshotter + } } diff --git a/MC1/Views/Map/MapSnapshotRendering.swift b/MC1/Views/Map/MapSnapshotRendering.swift index a9f589bc..eeea955e 100644 --- a/MC1/Views/Map/MapSnapshotRendering.swift +++ b/MC1/Views/Map/MapSnapshotRendering.swift @@ -2,7 +2,7 @@ import UIKit @MainActor protocol MapSnapshotRendering { - /// Renders a static map thumbnail with the dropped pin composited at the - /// coordinate, or nil on failure. Heavy work runs off-main internally. - func render(_ request: MapSnapshotRequest) async -> UIImage? + /// Renders a static map thumbnail with the dropped pin composited at the + /// coordinate, or nil on failure. Heavy work runs off-main internally. + func render(_ request: MapSnapshotRequest) async -> UIImage? } diff --git a/MC1/Views/Map/MapSnapshotRequest.swift b/MC1/Views/Map/MapSnapshotRequest.swift index 710db832..c221a64e 100644 --- a/MC1/Views/Map/MapSnapshotRequest.swift +++ b/MC1/Views/Map/MapSnapshotRequest.swift @@ -9,34 +9,34 @@ import Foundation /// versa). Render size is a constant (`MapSnapshotLayout`), deliberately /// not in the key. struct MapSnapshotRequest: Hashable { - let latitude: Double - let longitude: Double - let isDark: Bool - let isOffline: Bool + let latitude: Double + let longitude: Double + let isDark: Bool + let isOffline: Bool - private static let coordinatePrecision = 100_000.0 + private static let coordinatePrecision = 100_000.0 - init(latitude: Double, longitude: Double, isDark: Bool, isOffline: Bool) { - // Normalize -0.0 to 0.0. -0.0 and 0.0 are `Hashable`-equal (so they dedupe - // as one request and share an index entry), but string interpolation in - // `cacheKey` renders them "-0.0" vs "0.0" — two distinct cache slots. - // Collapsing the sign keeps the cache key consistent with equality. - let roundedLatitude = (latitude * Self.coordinatePrecision).rounded() / Self.coordinatePrecision - let roundedLongitude = (longitude * Self.coordinatePrecision).rounded() / Self.coordinatePrecision - self.latitude = roundedLatitude == 0 ? 0 : roundedLatitude - self.longitude = roundedLongitude == 0 ? 0 : roundedLongitude - // `MapStyleSelection.styleURL(isDarkMode:isOffline:)` collapses - // `useDark = isDarkMode && !isOffline`, so offline renders never use - // the dark style. Collapsing `isDark` to `false` when offline keeps - // the cache, in-flight, failed, and resolvedKeys sets from sharding - // identical offline images across two slots. - self.isDark = isOffline ? false : isDark - self.isOffline = isOffline - } + init(latitude: Double, longitude: Double, isDark: Bool, isOffline: Bool) { + // Normalize -0.0 to 0.0. -0.0 and 0.0 are `Hashable`-equal (so they dedupe + // as one request and share an index entry), but string interpolation in + // `cacheKey` renders them "-0.0" vs "0.0" — two distinct cache slots. + // Collapsing the sign keeps the cache key consistent with equality. + let roundedLatitude = (latitude * Self.coordinatePrecision).rounded() / Self.coordinatePrecision + let roundedLongitude = (longitude * Self.coordinatePrecision).rounded() / Self.coordinatePrecision + self.latitude = roundedLatitude == 0 ? 0 : roundedLatitude + self.longitude = roundedLongitude == 0 ? 0 : roundedLongitude + // `MapStyleSelection.styleURL(isDarkMode:isOffline:)` collapses + // `useDark = isDarkMode && !isOffline`, so offline renders never use + // the dark style. Collapsing `isDark` to `false` when offline keeps + // the cache, in-flight, failed, and resolvedKeys sets from sharding + // identical offline images across two slots. + self.isDark = isOffline ? false : isDark + self.isOffline = isOffline + } - /// `NSString` key for the backing `NSCache`, mirroring `InlineImageCache`'s - /// `url.absoluteString as NSString` pattern. - var cacheKey: NSString { - "\(latitude),\(longitude),\(isDark),\(isOffline)" as NSString - } + /// `NSString` key for the backing `NSCache`, mirroring `InlineImageCache`'s + /// `url.absoluteString as NSString` pattern. + var cacheKey: NSString { + "\(latitude),\(longitude),\(isDark),\(isOffline)" as NSString + } } diff --git a/MC1/Views/Map/MapStyleSelection.swift b/MC1/Views/Map/MapStyleSelection.swift index 40cfdc50..e080eb52 100644 --- a/MC1/Views/Map/MapStyleSelection.swift +++ b/MC1/Views/Map/MapStyleSelection.swift @@ -2,43 +2,43 @@ import SwiftUI /// Map style options for the Map tab enum MapStyleSelection: String, CaseIterable, Hashable { - case standard - case satellite - case topo + case standard + case satellite + case topo - var label: String { - switch self { - case .standard: L10n.Map.Map.Style.standard - case .satellite: L10n.Map.Map.Style.satellite - case .topo: L10n.Map.Map.Style.topo - } + var label: String { + switch self { + case .standard: L10n.Map.Map.Style.standard + case .satellite: L10n.Map.Map.Style.satellite + case .topo: L10n.Map.Map.Style.topo } + } - var requiresNetwork: Bool { - switch self { - case .standard: false - case .satellite: true - case .topo: false - } + var requiresNetwork: Bool { + switch self { + case .standard: false + case .satellite: true + case .topo: false } + } - var offlineMapLayer: OfflineMapLayer { - switch self { - case .standard: .base - case .satellite: .base - case .topo: .topo - } + var offlineMapLayer: OfflineMapLayer { + switch self { + case .standard: .base + case .satellite: .base + case .topo: .topo } + } - /// All styles use the same base vector style; satellite/topo add raster overlays at runtime. - /// When offline, always returns Liberty — offline packs are downloaded against that style - /// and MapLibre serves cached tiles only for the exact style URL used during download. - func styleURL(isDarkMode: Bool, isOffline: Bool = false) -> URL { - let useDark = isDarkMode && !isOffline - let url = useDark ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty - guard let result = URL(string: url) else { - fatalError("Invalid map tile URL constant: \(url)") - } - return result + /// All styles use the same base vector style; satellite/topo add raster overlays at runtime. + /// When offline, always returns Liberty — offline packs are downloaded against that style + /// and MapLibre serves cached tiles only for the exact style URL used during download. + func styleURL(isDarkMode: Bool, isOffline: Bool = false) -> URL { + let useDark = isDarkMode && !isOffline + let url = useDark ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty + guard let result = URL(string: url) else { + fatalError("Invalid map tile URL constant: \(url)") } + return result + } } diff --git a/MC1/Views/Map/MapTileURLs.swift b/MC1/Views/Map/MapTileURLs.swift index 466a3476..ca8aa9db 100644 --- a/MC1/Views/Map/MapTileURLs.swift +++ b/MC1/Views/Map/MapTileURLs.swift @@ -1,8 +1,8 @@ enum MapTileURLs { - static let openFreeMapLiberty = "https://tiles.openfreemap.org/styles/liberty" - static let openFreeMapDark = "https://tiles.openfreemap.org/styles/dark" - static let esriWorldImagery = "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" - static let openTopoMapA = "https://a.tile.opentopomap.org/{z}/{x}/{y}.png" - static let openTopoMapB = "https://b.tile.opentopomap.org/{z}/{x}/{y}.png" - static let openTopoMapC = "https://c.tile.opentopomap.org/{z}/{x}/{y}.png" + static let openFreeMapLiberty = "https://tiles.openfreemap.org/styles/liberty" + static let openFreeMapDark = "https://tiles.openfreemap.org/styles/dark" + static let esriWorldImagery = "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" + static let openTopoMapA = "https://a.tile.opentopomap.org/{z}/{x}/{y}.png" + static let openTopoMapB = "https://b.tile.opentopomap.org/{z}/{x}/{y}.png" + static let openTopoMapC = "https://c.tile.opentopomap.org/{z}/{x}/{y}.png" } diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index 6f99dfd2..a5ed86d7 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -1,121 +1,121 @@ -import SwiftUI import MapKit import MC1Services +import SwiftUI /// Map view displaying contacts with their locations struct MapView: View { - @Environment(\.appState) private var appState - @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard - @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels - @SceneStorage(SceneStorageKey.mapCameraRegion.rawValue) private var savedCameraRegion = "" - @State private var viewModel = MapViewModel() - @State private var selectedCalloutContact: ContactDTO? - @State private var selectedPointScreenPosition: CGPoint? - @State private var selectedContactForDetail: ContactDTO? - @State private var isStyleLoaded = false + @Environment(\.appState) private var appState + @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels + @SceneStorage(SceneStorageKey.mapCameraRegion.rawValue) private var savedCameraRegion = "" + @State private var viewModel = MapViewModel() + @State private var selectedCalloutContact: ContactDTO? + @State private var selectedPointScreenPosition: CGPoint? + @State private var selectedContactForDetail: ContactDTO? + @State private var isStyleLoaded = false - var body: some View { - NavigationStack { - MapCanvasView( - viewModel: viewModel, - mapStyleSelection: $mapStyleSelection, - showLabels: $showLabels, - selectedCalloutContact: $selectedCalloutContact, - selectedPointScreenPosition: $selectedPointScreenPosition, - isStyleLoaded: $isStyleLoaded, - onShowContactDetail: { showContactDetail($0) }, - onNavigateToChat: { navigateToChat(with: $0) }, - onCenterOnUser: { centerOnUserLocation() }, - onClearSelection: { clearSelection() }, - onPersistCamera: { savedCameraRegion = MapCameraStore.encode($0) } - ) - .toolbar { - bleStatusToolbarItem() - ToolbarItem(placement: .topBarTrailing) { - MapRefreshButton(viewModel: viewModel) - } - } - .task { - appState.locationService.requestPermissionIfNeeded() - appState.locationService.requestLocation() - viewModel.configure( - dataStore: { [appState] in appState.offlineDataStore }, - radioID: { [appState] in appState.currentRadioID } - ) - await viewModel.loadContactsWithLocation() - // On first appearance the view model has no camera region; restoring here - // keeps the map where the user left it across launches instead of re-framing. - viewModel.applyInitialCamera( - saved: MapCameraStore.decode(savedCameraRegion), - hasPendingFocus: appState.navigation.pendingMapFocus != nil - ) - } - .onChange(of: appState.navigation.pendingMapFocus, initial: true) { _, request in - guard let request else { return } - viewModel.focusOnCoordinate(request.coordinate) - appState.navigation.clearPendingMapFocus() - } - .sheet(item: $selectedContactForDetail) { contact in - ContactDetailSheet( - contact: contact, - onMessage: { navigateToChat(with: contact) }, - onDelete: { Task { await viewModel.loadContactsWithLocation() } } - ) - .presentationDetents([.large]) - } - .liquidGlassToolbarBackground() + var body: some View { + NavigationStack { + MapCanvasView( + viewModel: viewModel, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels, + selectedCalloutContact: $selectedCalloutContact, + selectedPointScreenPosition: $selectedPointScreenPosition, + isStyleLoaded: $isStyleLoaded, + onShowContactDetail: { showContactDetail($0) }, + onNavigateToChat: { navigateToChat(with: $0) }, + onCenterOnUser: { centerOnUserLocation() }, + onClearSelection: { clearSelection() }, + onPersistCamera: { savedCameraRegion = MapCameraStore.encode($0) } + ) + .toolbar { + bleStatusToolbarItem() + ToolbarItem(placement: .topBarTrailing) { + MapRefreshButton(viewModel: viewModel) } + } + .task { + appState.locationService.requestPermissionIfNeeded() + appState.locationService.requestLocation() + viewModel.configure( + dataStore: { [appState] in appState.offlineDataStore }, + radioID: { [appState] in appState.currentRadioID } + ) + await viewModel.loadContactsWithLocation() + // On first appearance the view model has no camera region; restoring here + // keeps the map where the user left it across launches instead of re-framing. + viewModel.applyInitialCamera( + saved: MapCameraStore.decode(savedCameraRegion), + hasPendingFocus: appState.navigation.pendingMapFocus != nil + ) + } + .onChange(of: appState.navigation.pendingMapFocus, initial: true) { _, request in + guard let request else { return } + viewModel.focusOnCoordinate(request.coordinate) + appState.navigation.clearPendingMapFocus() + } + .sheet(item: $selectedContactForDetail) { contact in + ContactDetailSheet( + contact: contact, + onMessage: { navigateToChat(with: contact) }, + onDelete: { Task { await viewModel.loadContactsWithLocation() } } + ) + .presentationDetents([.large]) + } + .liquidGlassToolbarBackground() } + } - // MARK: - Actions + // MARK: - Actions - private func clearSelection() { - selectedCalloutContact = nil - selectedPointScreenPosition = nil - } + private func clearSelection() { + selectedCalloutContact = nil + selectedPointScreenPosition = nil + } - private func navigateToChat(with contact: ContactDTO) { - clearSelection() - appState.navigation.navigateToChat(with: contact) - } + private func navigateToChat(with contact: ContactDTO) { + clearSelection() + appState.navigation.navigateToChat(with: contact) + } - private func showContactDetail(_ contact: ContactDTO) { - clearSelection() - selectedContactForDetail = contact - } + private func showContactDetail(_ contact: ContactDTO) { + clearSelection() + selectedContactForDetail = contact + } - private func centerOnUserLocation() { - guard let location = appState.bestAvailableLocation else { return } - let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - viewModel.setCameraRegion(MKCoordinateRegion(center: location.coordinate, span: span)) - } + private func centerOnUserLocation() { + guard let location = appState.bestAvailableLocation else { return } + let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + viewModel.setCameraRegion(MKCoordinateRegion(center: location.coordinate, span: span)) + } } // MARK: - Map Refresh Button private struct MapRefreshButton: View { - var viewModel: MapViewModel + var viewModel: MapViewModel - var body: some View { - Button(L10n.Map.Map.Controls.refresh, systemImage: "arrow.clockwise") { - Task { - await viewModel.loadContactsWithLocation() - } - } - .labelStyle(.iconOnly) - .disabled(viewModel.isLoading) - .opacity(viewModel.isLoading ? 0 : 1) - .overlay { - if viewModel.isLoading { - ProgressView() - } - } + var body: some View { + Button(L10n.Map.Map.Controls.refresh, systemImage: "arrow.clockwise") { + Task { + await viewModel.loadContactsWithLocation() + } + } + .labelStyle(.iconOnly) + .disabled(viewModel.isLoading) + .opacity(viewModel.isLoading ? 0 : 1) + .overlay { + if viewModel.isLoading { + ProgressView() + } } + } } // MARK: - Preview #Preview { - MapView() - .environment(\.appState, AppState()) + MapView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Map/MapViewModel.swift b/MC1/Views/Map/MapViewModel.swift index ec2bcb70..df94439d 100644 --- a/MC1/Views/Map/MapViewModel.swift +++ b/MC1/Views/Map/MapViewModel.swift @@ -1,157 +1,161 @@ -import SwiftUI import MapKit import MC1Services +import SwiftUI /// ViewModel for map contact locations @Observable @MainActor final class MapViewModel { + // MARK: - Properties - // MARK: - Properties - - /// All contacts with valid locations - var contactsWithLocation: [ContactDTO] = [] - - /// Map points derived from contacts — stored to avoid reallocation on every body eval. - private(set) var mapPoints: [MapPoint] = [] - - /// A user-dropped pin from a chat coordinate tap. Folded into `mapPoints` so it - /// survives contact refreshes. Exactly one exists at a time (single-pin invariant). - private(set) var focusedPin: MapPoint? - - /// Loading state - var isLoading = false + /// All contacts with valid locations + var contactsWithLocation: [ContactDTO] = [] - /// Error message if any - var errorMessage: String? + /// Map points derived from contacts — stored to avoid reallocation on every body eval. + private(set) var mapPoints: [MapPoint] = [] - /// Camera region for map centering - var cameraRegion: MKCoordinateRegion? + /// A user-dropped pin from a chat coordinate tap. Folded into `mapPoints` so it + /// survives contact refreshes. Exactly one exists at a time (single-pin invariant). + private(set) var focusedPin: MapPoint? - /// Version counter for the camera region, incremented to signal a new camera target - private(set) var cameraRegionVersion = 0 + /// Loading state + var isLoading = false - /// Whether the map bearing is locked to true north - var isNorthLocked = false + /// Error message if any + var errorMessage: String? - /// Whether the layers menu is showing - var showingLayersMenu = false + /// Camera region for map centering + var cameraRegion: MKCoordinateRegion? - // MARK: - Dependencies + /// Version counter for the camera region, incremented to signal a new camera target + private(set) var cameraRegionVersion = 0 - private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } - private var radioIDProvider: @MainActor () -> UUID? = { nil } + /// Whether the map bearing is locked to true north + var isNorthLocked = false - private var dataStore: PersistenceStore? { dataStoreProvider() } - private var radioID: UUID? { radioIDProvider() } + /// Whether the layers menu is showing + var showingLayersMenu = false - // MARK: - Initialization + // MARK: - Dependencies - /// Stable id so the dropped pin does not churn across rebuilds and `onPointTap` can identify it. - private static let focusedPinID = UUID() + private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } + private var radioIDProvider: @MainActor () -> UUID? = { nil } - /// Span for "exactly here" framing (about 1 km across). - private static let focusSpan = 0.01 + private var dataStore: PersistenceStore? { + dataStoreProvider() + } - init() {} + private var radioID: UUID? { + radioIDProvider() + } - /// Configure with the data store and radio this view model uses; a provider returning nil mirrors a disconnected state. - func configure( - dataStore: @escaping @MainActor () -> PersistenceStore?, - radioID: @escaping @MainActor () -> UUID? - ) { - dataStoreProvider = dataStore - radioIDProvider = radioID - } + // MARK: - Initialization - // MARK: - Load Contacts + /// Stable id so the dropped pin does not churn across rebuilds and `onPointTap` can identify it. + private static let focusedPinID = UUID() - /// Load contacts with valid locations from the database - func loadContactsWithLocation() async { - guard let dataStore, let radioID else { return } + /// Span for "exactly here" framing (about 1 km across). + private static let focusSpan = 0.01 - isLoading = true - errorMessage = nil + init() {} - do { - let allContacts = try await dataStore.fetchContacts(radioID: radioID) - contactsWithLocation = allContacts.filter(\.hasLocation) - rebuildMapPoints() - } catch { - errorMessage = error.userFacingMessage - } + /// Configure with the data store and radio this view model uses; a provider returning nil mirrors a disconnected state. + func configure( + dataStore: @escaping @MainActor () -> PersistenceStore?, + radioID: @escaping @MainActor () -> UUID? + ) { + dataStoreProvider = dataStore + radioIDProvider = radioID + } - isLoading = false - } + // MARK: - Load Contacts - // MARK: - Map Points - - private func rebuildMapPoints() { - mapPoints = contactsWithLocation.map { contact in - MapPoint( - id: contact.id, - coordinate: contact.coordinate, - pinStyle: contact.type.pinStyle, - label: contact.displayName, - isClusterable: true, - hopIndex: nil, - badgeText: nil - ) - } - if let focusedPin { - mapPoints.append(focusedPin) - } - } + /// Load contacts with valid locations from the database + func loadContactsWithLocation() async { + guard let dataStore, let radioID else { return } - // MARK: - Map Interaction + isLoading = true + errorMessage = nil - func setCameraRegion(_ region: MKCoordinateRegion?) { - cameraRegion = region - cameraRegionVersion += 1 + do { + let allContacts = try await dataStore.fetchContacts(radioID: radioID) + contactsWithLocation = allContacts.filter(\.hasLocation) + rebuildMapPoints() + } catch { + errorMessage = error.userFacingMessage } - /// Drop a distinct pin at `coordinate`, fold it into `mapPoints`, and center the camera on it. - func focusOnCoordinate(_ coordinate: CLLocationCoordinate2D) { - focusedPin = MapPoint( - id: Self.focusedPinID, - coordinate: coordinate, - pinStyle: .droppedPin, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - ) - rebuildMapPoints() - let span = MKCoordinateSpan(latitudeDelta: Self.focusSpan, longitudeDelta: Self.focusSpan) - setCameraRegion(MKCoordinateRegion(center: coordinate, span: span)) + isLoading = false + } + + // MARK: - Map Points + + private func rebuildMapPoints() { + mapPoints = contactsWithLocation.map { contact in + MapPoint( + id: contact.id, + coordinate: contact.coordinate, + pinStyle: contact.type.pinStyle, + label: contact.displayName, + isClusterable: true, + hopIndex: nil, + badgeText: nil + ) } - - /// Remove the dropped pin. - func clearFocusedPin() { - focusedPin = nil - rebuildMapPoints() + if let focusedPin { + mapPoints.append(focusedPin) } - - /// Center map to show all contacts - func centerOnAllContacts() { - guard !contactsWithLocation.isEmpty else { - cameraRegion = nil - return - } - - let coordinates = contactsWithLocation.map(\.coordinate) - setCameraRegion(coordinates.boundingRegion()) + } + + // MARK: - Map Interaction + + func setCameraRegion(_ region: MKCoordinateRegion?) { + cameraRegion = region + cameraRegionVersion += 1 + } + + /// Drop a distinct pin at `coordinate`, fold it into `mapPoints`, and center the camera on it. + func focusOnCoordinate(_ coordinate: CLLocationCoordinate2D) { + focusedPin = MapPoint( + id: Self.focusedPinID, + coordinate: coordinate, + pinStyle: .droppedPin, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + ) + rebuildMapPoints() + let span = MKCoordinateSpan(latitudeDelta: Self.focusSpan, longitudeDelta: Self.focusSpan) + setCameraRegion(MKCoordinateRegion(center: coordinate, span: span)) + } + + /// Remove the dropped pin. + func clearFocusedPin() { + focusedPin = nil + rebuildMapPoints() + } + + /// Center map to show all contacts + func centerOnAllContacts() { + guard !contactsWithLocation.isEmpty else { + cameraRegion = nil + return } - /// On entering the map, restore the user's saved camera if present, otherwise frame all - /// contacts. A focus or dropped-pin target sets `cameraRegion` synchronously before this - /// runs, so a non-nil region or an existing pin means a target already owns the framing. - func applyInitialCamera(saved: MKCoordinateRegion?, hasPendingFocus: Bool) { - guard !hasPendingFocus, focusedPin == nil, cameraRegion == nil else { return } - if let saved { - setCameraRegion(saved) - } else { - centerOnAllContacts() - } + let coordinates = contactsWithLocation.map(\.coordinate) + setCameraRegion(coordinates.boundingRegion()) + } + + /// On entering the map, restore the user's saved camera if present, otherwise frame all + /// contacts. A focus or dropped-pin target sets `cameraRegion` synchronously before this + /// runs, so a non-nil region or an existing pin means a target already owns the framing. + func applyInitialCamera(saved: MKCoordinateRegion?, hasPendingFocus: Bool) { + guard !hasPendingFocus, focusedPin == nil, cameraRegion == nil else { return } + if let saved { + setCameraRegion(saved) + } else { + centerOnAllContacts() } + } } diff --git a/MC1/Views/Map/OfflineBadge.swift b/MC1/Views/Map/OfflineBadge.swift index f54a1d19..f1879c9b 100644 --- a/MC1/Views/Map/OfflineBadge.swift +++ b/MC1/Views/Map/OfflineBadge.swift @@ -4,19 +4,19 @@ import SwiftUI // MARK: - Offline Badge struct OfflineBadge: View { - var body: some View { - Text(L10n.Map.Map.OfflineBadge.label) - .font(.caption) - .bold() - .padding(.horizontal) - .padding(.vertical, 6) - .background(.ultraThinMaterial, in: .capsule) - .accessibilityAddTraits(.isStaticText) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .padding(.trailing) - .padding(.top) - .onAppear { - AccessibilityNotification.Announcement(L10n.Map.Map.OfflineBadge.label).post() - } - } + var body: some View { + Text(L10n.Map.Map.OfflineBadge.label) + .font(.caption) + .bold() + .padding(.horizontal) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + .accessibilityAddTraits(.isStaticText) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.trailing) + .padding(.top) + .onAppear { + AccessibilityNotification.Announcement(L10n.Map.Map.OfflineBadge.label).post() + } + } } diff --git a/MC1/Views/Map/PathDistanceBanner.swift b/MC1/Views/Map/PathDistanceBanner.swift index 6f9f34f0..604df2b2 100644 --- a/MC1/Views/Map/PathDistanceBanner.swift +++ b/MC1/Views/Map/PathDistanceBanner.swift @@ -4,31 +4,31 @@ import SwiftUI /// Floating top-of-map capsule summarizing a path: hop count and, when at least /// two nodes have coordinates, the drawn-path distance. struct PathDistanceBanner: View { - private static let horizontalPadding: CGFloat = 16 - private static let verticalPadding: CGFloat = 10 + private static let horizontalPadding: CGFloat = 16 + private static let verticalPadding: CGFloat = 10 - let hopCount: Int - let totalPathDistance: CLLocationDistance? + let hopCount: Int + let totalPathDistance: CLLocationDistance? - var body: some View { - VStack { - HStack { - Text(L10n.Contacts.Contacts.Trace.Map.hops(hopCount)) - if let distance = totalPathDistance { - Text("•") - Text(Measurement(value: distance, unit: UnitLength.meters), - format: .measurement(width: .abbreviated, usage: .road)) - } - } - .font(.subheadline.weight(.medium)) - .padding(.horizontal, Self.horizontalPadding) - .padding(.vertical, Self.verticalPadding) - .liquidGlass(in: .capsule) - - Spacer() + var body: some View { + VStack { + HStack { + Text(L10n.Contacts.Contacts.Trace.Map.hops(hopCount)) + if let distance = totalPathDistance { + Text("•") + Text(Measurement(value: distance, unit: UnitLength.meters), + format: .measurement(width: .abbreviated, usage: .road)) } - .frame(maxWidth: .infinity) - .safeAreaPadding(.top) - .transition(.move(edge: .top).combined(with: .opacity)) + } + .font(.subheadline.weight(.medium)) + .padding(.horizontal, Self.horizontalPadding) + .padding(.vertical, Self.verticalPadding) + .liquidGlass(in: .capsule) + + Spacer() } + .frame(maxWidth: .infinity) + .safeAreaPadding(.top) + .transition(.move(edge: .top).combined(with: .opacity)) + } } diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index 40548e0b..343332c9 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -3,452 +3,452 @@ import UIKit @MainActor enum PinSpriteRenderer { - /// Height of a standard pin sprite in points (circle + triangle pointer). - /// Used by the map Coordinator to position callout anchors above the pin icon. - static let standardHeight: CGFloat = 43 // 36 (circle) + 10 (triangle) - 3 (overlap) - - static let labelSpritePrefix = "label-" - - /// Largest hop number a pin badge will render. Hops beyond this clamp to the - /// cap rather than failing to resolve a sprite. MeshCore paths top out at 64 hops. - static let maxHopBadge = 64 - - private static var cachedImages: [String: UIImage]? - - /// Single cached dropped-pin sprite for the chat map thumbnail. Distinct from - /// `cachedImages`, which is only populated after the Map tab loads its GL - /// style; the chat snapshot path never loads that style. - private static var cachedDroppedPin: UIImage? - - /// The dropped-pin sprite (systemPink circle + `mappin`), rendered once and - /// reused. Coordinate-independent, so the chat thumbnail composites the exact - /// pin the Map tab drops. Must be called on the main actor. - static func droppedPinSprite() -> UIImage { - if let cached = cachedDroppedPin { return cached } - guard let spec = allSpecs.first(where: { $0.name == "pin-dropped" }) else { - return UIImage() - } - let image = render(spec) - cachedDroppedPin = image - return image + /// Height of a standard pin sprite in points (circle + triangle pointer). + /// Used by the map Coordinator to position callout anchors above the pin icon. + static let standardHeight: CGFloat = 43 // 36 (circle) + 10 (triangle) - 3 (overlap) + + static let labelSpritePrefix = "label-" + + /// Largest hop number a pin badge will render. Hops beyond this clamp to the + /// cap rather than failing to resolve a sprite. MeshCore paths top out at 64 hops. + static let maxHopBadge = 64 + + private static var cachedImages: [String: UIImage]? + + /// Single cached dropped-pin sprite for the chat map thumbnail. Distinct from + /// `cachedImages`, which is only populated after the Map tab loads its GL + /// style; the chat snapshot path never loads that style. + private static var cachedDroppedPin: UIImage? + + /// The dropped-pin sprite (systemPink circle + `mappin`), rendered once and + /// reused. Coordinate-independent, so the chat thumbnail composites the exact + /// pin the Map tab drops. Must be called on the main actor. + static func droppedPinSprite() -> UIImage { + if let cached = cachedDroppedPin { return cached } + guard let spec = allSpecs.first(where: { $0.name == "pin-dropped" }) else { + return UIImage() } - - /// Registers base pin sprites into the style. Hop-ring variants are rendered - /// lazily via `renderOnDemand(name:into:)` when MapLibre requests a missing image. - static func renderAll(into style: MLNStyle) { - var rendered: [String: UIImage] = [:] - for spec in allSpecs { - rendered[spec.name] = render(spec) - } - rendered["pin-badge"] = UIGraphicsImageRenderer( - size: CGSize(width: 1, height: 1), format: .preferred() - ).image { _ in } - rendered["pill-bg"] = renderPillBackground() - cachedImages = rendered - - for (name, image) in rendered { - style.setImage(image, forName: name) - } + let image = render(spec) + cachedDroppedPin = image + return image + } + + /// Registers base pin sprites into the style. Hop-ring variants are rendered + /// lazily via `renderOnDemand(name:into:)` when MapLibre requests a missing image. + static func renderAll(into style: MLNStyle) { + var rendered: [String: UIImage] = [:] + for spec in allSpecs { + rendered[spec.name] = render(spec) } - - /// Renders a hop-ring sprite on demand when MapLibre requests a missing image name. - /// Returns the rendered image so the caller can pass it back to MapLibre as - /// the immediate fallback, avoiding a single-frame blink. - static func renderOnDemand(name: String, into style: MLNStyle) -> UIImage? { - if let cached = cachedImages?[name] { - style.setImage(cached, forName: name) - return cached - } - - let image: UIImage - if name.hasPrefix("pin-repeater-ring-white-hop-") { - guard let hopString = name.split(separator: "-").last, - let hop = Int(hopString), - (1...maxHopBadge).contains(hop), - let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) else { - return nil - } - image = render(ringWhiteSpec, hopIndex: hop) - } else if name.hasPrefix("pin-repeater-hop-") { - guard let hopString = name.split(separator: "-").last, - let hop = Int(hopString), - (1...maxHopBadge).contains(hop), - let repeaterSpec = allSpecs.first(where: { $0.name == "pin-repeater" }) else { - return nil - } - image = render(repeaterSpec, hopIndex: hop) - } else if name.hasPrefix(labelSpritePrefix) { - let text = String(name.dropFirst(labelSpritePrefix.count)) - guard !text.isEmpty else { return nil } - image = renderLabelSprite(text: text) - } else { - return nil - } - - cachedImages?[name] = image - style.setImage(image, forName: name) - return image + rendered["pin-badge"] = UIGraphicsImageRenderer( + size: CGSize(width: 1, height: 1), format: .preferred() + ).image { _ in } + rendered["pill-bg"] = renderPillBackground() + cachedImages = rendered + + for (name, image) in rendered { + style.setImage(image, forName: name) } - - // MARK: - Sprite specifications - - private enum RenderStyle { - case standard - case crosshair - case obstruction + } + + /// Renders a hop-ring sprite on demand when MapLibre requests a missing image name. + /// Returns the rendered image so the caller can pass it back to MapLibre as + /// the immediate fallback, avoiding a single-frame blink. + static func renderOnDemand(name: String, into style: MLNStyle) -> UIImage? { + if let cached = cachedImages?[name] { + style.setImage(cached, forName: name) + return cached } - private struct SpriteSpec { - let name: String - let circleColor: UIColor - let iconName: String? // SF Symbol name - let text: String? // e.g. "A", "B" for point pins - let ringColor: UIColor? // selection ring - let renderStyle: RenderStyle + let image: UIImage + if name.hasPrefix("pin-repeater-ring-white-hop-") { + guard let hopString = name.split(separator: "-").last, + let hop = Int(hopString), + (1...maxHopBadge).contains(hop), + let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) else { + return nil + } + image = render(ringWhiteSpec, hopIndex: hop) + } else if name.hasPrefix("pin-repeater-hop-") { + guard let hopString = name.split(separator: "-").last, + let hop = Int(hopString), + (1...maxHopBadge).contains(hop), + let repeaterSpec = allSpecs.first(where: { $0.name == "pin-repeater" }) else { + return nil + } + image = render(repeaterSpec, hopIndex: hop) + } else if name.hasPrefix(labelSpritePrefix) { + let text = String(name.dropFirst(labelSpritePrefix.count)) + guard !text.isEmpty else { return nil } + image = renderLabelSprite(text: text) + } else { + return nil } - private static let allSpecs: [SpriteSpec] = [ - // Main map contacts - SpriteSpec(name: "pin-chat", circleColor: UIColor(red: 204 / 255, green: 122 / 255, blue: 92 / 255, alpha: 1), - iconName: "person.fill", text: nil, ringColor: nil, renderStyle: .standard), - SpriteSpec(name: "pin-repeater", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: nil, renderStyle: .standard), - SpriteSpec(name: "pin-room", circleColor: UIColor(red: 1, green: 136 / 255, blue: 0, alpha: 1), - iconName: "person.3.fill", text: nil, ringColor: nil, renderStyle: .standard), - - // LOS/TracePath repeater states - SpriteSpec(name: "pin-repeater-ring-blue", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemBlue, renderStyle: .standard), - SpriteSpec(name: "pin-repeater-ring-green", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemGreen, renderStyle: .standard), - SpriteSpec(name: "pin-repeater-ring-white", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .white, renderStyle: .standard), - - // LOS point pins - SpriteSpec(name: "pin-point-a", circleColor: .systemBlue, - iconName: nil, text: "A", ringColor: nil, renderStyle: .standard), - SpriteSpec(name: "pin-point-b", circleColor: .systemGreen, - iconName: nil, text: "B", ringColor: nil, renderStyle: .standard), - - // LOS crosshair target - SpriteSpec(name: "pin-crosshair", circleColor: .systemPurple, - iconName: nil, text: "R", ringColor: nil, renderStyle: .crosshair), - - // LOS obstruction marker - SpriteSpec(name: "pin-obstruction", circleColor: .systemRed, - iconName: nil, text: nil, ringColor: nil, renderStyle: .obstruction), - - // Chat-dropped coordinate pin - SpriteSpec(name: "pin-dropped", circleColor: .systemPink, - iconName: "mappin", text: nil, ringColor: nil, renderStyle: .standard), - ] - - // MARK: - Rendering - - private static func render(_ spec: SpriteSpec, hopIndex: Int? = nil) -> UIImage { - switch spec.renderStyle { - case .crosshair: return renderCrosshair(spec) - case .obstruction: return renderObstruction() - case .standard: break - } - - let circleSize: CGFloat = 36 - let iconSize: CGFloat = 16 - let triangleSize: CGFloat = 10 - let ringPadding: CGFloat = spec.ringColor != nil ? 4 : 0 - let ringSize: CGFloat = spec.ringColor != nil ? 44 : 0 - // A hop badge overhangs the circle's right edge by 4pt; widen the canvas - // (symmetrically, to keep the triangle tip horizontally centered on the - // coordinate) so a ringless badged pin isn't clipped. - let badgeRoom: CGFloat = hopIndex != nil ? 44 : 0 - let totalWidth = max(circleSize, ringSize, badgeRoom) - let totalHeight = circleSize + triangleSize - 3 + ringPadding - - let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalWidth, height: totalHeight), format: .preferred()) - return renderer.image { ctx in - let cgContext = ctx.cgContext - let centerX = totalWidth / 2 - - // Selection ring, centered on the circle head so it encircles the - // round portion rather than the triangle tail. - if let ringColor = spec.ringColor { - let circleCenterY = ringPadding + circleSize / 2 - let ringRect = CGRect( - x: centerX - ringSize / 2, - y: circleCenterY - ringSize / 2, - width: ringSize, - height: ringSize - ) - ringColor.setStroke() - cgContext.setLineWidth(3) - cgContext.strokeEllipse(in: ringRect.insetBy(dx: 1.5, dy: 1.5)) - } - - // Circle shadow - cgContext.saveGState() - cgContext.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor) - let circleRect = CGRect( - x: centerX - circleSize / 2, - y: ringPadding, - width: circleSize, - height: circleSize - ) - spec.circleColor.setFill() - cgContext.fillEllipse(in: circleRect) - cgContext.restoreGState() - - // Circle (again without shadow for crisp edge) - spec.circleColor.setFill() - cgContext.fillEllipse(in: circleRect) - - // Icon or text - if let iconName = spec.iconName { - let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .regular) - if let icon = UIImage(systemName: iconName, withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal) { - let iconRect = CGRect( - x: centerX - icon.size.width / 2, - y: circleRect.midY - icon.size.height / 2, - width: icon.size.width, - height: icon.size.height - ) - icon.draw(in: iconRect) - } - } else if let text = spec.text { - let font = UIFont.systemFont(ofSize: 14, weight: .bold) - let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] - let size = (text as NSString).size(withAttributes: attrs) - let textRect = CGRect( - x: centerX - size.width / 2, - y: circleRect.midY - size.height / 2, - width: size.width, - height: size.height - ) - (text as NSString).draw(in: textRect, withAttributes: attrs) - } - - // Triangle pointer - let triangleTop = circleRect.maxY - 3 - let path = UIBezierPath() - path.move(to: CGPoint(x: centerX - triangleSize / 2, y: triangleTop)) - path.addLine(to: CGPoint(x: centerX + triangleSize / 2, y: triangleTop)) - path.addLine(to: CGPoint(x: centerX, y: triangleTop + triangleSize)) - path.close() - spec.circleColor.setFill() - path.fill() - - // Hop badge overlay - if let hopIndex { - let badgeSize: CGFloat = 18 - let badgeX = circleRect.maxX + 4 - badgeSize - let badgeY = circleRect.minY - let badgeRect = CGRect(x: badgeX, y: badgeY, width: badgeSize, height: badgeSize) - - UIColor.systemBlue.setFill() - cgContext.fillEllipse(in: badgeRect) - - let text = "\(hopIndex)" - let font = UIFont.systemFont(ofSize: 11, weight: .bold) - let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] - let textSize = (text as NSString).size(withAttributes: attrs) - let textRect = CGRect( - x: badgeRect.midX - textSize.width / 2, - y: badgeRect.midY - textSize.height / 2, - width: textSize.width, - height: textSize.height - ) - (text as NSString).draw(in: textRect, withAttributes: attrs) - } - } + cachedImages?[name] = image + style.setImage(image, forName: name) + return image + } + + // MARK: - Sprite specifications + + private enum RenderStyle { + case standard + case crosshair + case obstruction + } + + private struct SpriteSpec { + let name: String + let circleColor: UIColor + let iconName: String? // SF Symbol name + let text: String? // e.g. "A", "B" for point pins + let ringColor: UIColor? // selection ring + let renderStyle: RenderStyle + } + + private static let allSpecs: [SpriteSpec] = [ + // Main map contacts + SpriteSpec(name: "pin-chat", circleColor: UIColor(red: 204 / 255, green: 122 / 255, blue: 92 / 255, alpha: 1), + iconName: "person.fill", text: nil, ringColor: nil, renderStyle: .standard), + SpriteSpec(name: "pin-repeater", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: nil, renderStyle: .standard), + SpriteSpec(name: "pin-room", circleColor: UIColor(red: 1, green: 136 / 255, blue: 0, alpha: 1), + iconName: "person.3.fill", text: nil, ringColor: nil, renderStyle: .standard), + + // LOS/TracePath repeater states + SpriteSpec(name: "pin-repeater-ring-blue", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemBlue, renderStyle: .standard), + SpriteSpec(name: "pin-repeater-ring-green", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemGreen, renderStyle: .standard), + SpriteSpec(name: "pin-repeater-ring-white", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .white, renderStyle: .standard), + + // LOS point pins + SpriteSpec(name: "pin-point-a", circleColor: .systemBlue, + iconName: nil, text: "A", ringColor: nil, renderStyle: .standard), + SpriteSpec(name: "pin-point-b", circleColor: .systemGreen, + iconName: nil, text: "B", ringColor: nil, renderStyle: .standard), + + // LOS crosshair target + SpriteSpec(name: "pin-crosshair", circleColor: .systemPurple, + iconName: nil, text: "R", ringColor: nil, renderStyle: .crosshair), + + // LOS obstruction marker + SpriteSpec(name: "pin-obstruction", circleColor: .systemRed, + iconName: nil, text: nil, ringColor: nil, renderStyle: .obstruction), + + // Chat-dropped coordinate pin + SpriteSpec(name: "pin-dropped", circleColor: .systemPink, + iconName: "mappin", text: nil, ringColor: nil, renderStyle: .standard), + ] + + // MARK: - Rendering + + private static func render(_ spec: SpriteSpec, hopIndex: Int? = nil) -> UIImage { + switch spec.renderStyle { + case .crosshair: return renderCrosshair(spec) + case .obstruction: return renderObstruction() + case .standard: break } - // MARK: - Pill sprites - - /// Semi-transparent stretchable pill for stats badges. - /// Registered as a resizable image so MapLibre's `iconTextFit` can stretch - /// the flat center while preserving the rounded caps. - private static func renderPillBackground() -> UIImage { - let cornerRadius: CGFloat = 4 - let size: CGFloat = 2 * cornerRadius + 2 - let shadowPadding: CGFloat = 1 - let totalSize = size + shadowPadding * 2 - let capInset = cornerRadius + shadowPadding - - let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) - let image = renderer.image { ctx in - let cgContext = ctx.cgContext - let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: size, height: size) - let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) - - // Shadow pass - cgContext.saveGState() - cgContext.setShadow( - offset: CGSize(width: 0, height: 0.5), - blur: 1, - color: UIColor.black.withAlphaComponent(0.15).cgColor - ) - UIColor.white.setFill() - pillPath.fill() - cgContext.restoreGState() - - // Light fill for readability in both light and dark mode - UIColor.white.withAlphaComponent(0.85).setFill() - pillPath.fill() + let circleSize: CGFloat = 36 + let iconSize: CGFloat = 16 + let triangleSize: CGFloat = 10 + let ringPadding: CGFloat = spec.ringColor != nil ? 4 : 0 + let ringSize: CGFloat = spec.ringColor != nil ? 44 : 0 + // A hop badge overhangs the circle's right edge by 4pt; widen the canvas + // (symmetrically, to keep the triangle tip horizontally centered on the + // coordinate) so a ringless badged pin isn't clipped. + let badgeRoom: CGFloat = hopIndex != nil ? 44 : 0 + let totalWidth = max(circleSize, ringSize, badgeRoom) + let totalHeight = circleSize + triangleSize - 3 + ringPadding + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalWidth, height: totalHeight), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let centerX = totalWidth / 2 + + // Selection ring, centered on the circle head so it encircles the + // round portion rather than the triangle tail. + if let ringColor = spec.ringColor { + let circleCenterY = ringPadding + circleSize / 2 + let ringRect = CGRect( + x: centerX - ringSize / 2, + y: circleCenterY - ringSize / 2, + width: ringSize, + height: ringSize + ) + ringColor.setStroke() + cgContext.setLineWidth(3) + cgContext.strokeEllipse(in: ringRect.insetBy(dx: 1.5, dy: 1.5)) + } + + // Circle shadow + cgContext.saveGState() + cgContext.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor) + let circleRect = CGRect( + x: centerX - circleSize / 2, + y: ringPadding, + width: circleSize, + height: circleSize + ) + spec.circleColor.setFill() + cgContext.fillEllipse(in: circleRect) + cgContext.restoreGState() + + // Circle (again without shadow for crisp edge) + spec.circleColor.setFill() + cgContext.fillEllipse(in: circleRect) + + // Icon or text + if let iconName = spec.iconName { + let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .regular) + if let icon = UIImage(systemName: iconName, withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal) { + let iconRect = CGRect( + x: centerX - icon.size.width / 2, + y: circleRect.midY - icon.size.height / 2, + width: icon.size.width, + height: icon.size.height + ) + icon.draw(in: iconRect) } - - return image.resizableImage( - withCapInsets: UIEdgeInsets(top: capInset, left: capInset, bottom: capInset, right: capInset), - resizingMode: .stretch + } else if let text = spec.text { + let font = UIFont.systemFont(ofSize: 14, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let size = (text as NSString).size(withAttributes: attrs) + let textRect = CGRect( + x: centerX - size.width / 2, + y: circleRect.midY - size.height / 2, + width: size.width, + height: size.height ) - } - - private static func renderLabelSprite(text: String) -> UIImage { - let font = UIFont.systemFont(ofSize: 12, weight: .bold) - let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.black] + (text as NSString).draw(in: textRect, withAttributes: attrs) + } + + // Triangle pointer + let triangleTop = circleRect.maxY - 3 + let path = UIBezierPath() + path.move(to: CGPoint(x: centerX - triangleSize / 2, y: triangleTop)) + path.addLine(to: CGPoint(x: centerX + triangleSize / 2, y: triangleTop)) + path.addLine(to: CGPoint(x: centerX, y: triangleTop + triangleSize)) + path.close() + spec.circleColor.setFill() + path.fill() + + // Hop badge overlay + if let hopIndex { + let badgeSize: CGFloat = 18 + let badgeX = circleRect.maxX + 4 - badgeSize + let badgeY = circleRect.minY + let badgeRect = CGRect(x: badgeX, y: badgeY, width: badgeSize, height: badgeSize) + + UIColor.systemBlue.setFill() + cgContext.fillEllipse(in: badgeRect) + + let text = "\(hopIndex)" + let font = UIFont.systemFont(ofSize: 11, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] let textSize = (text as NSString).size(withAttributes: attrs) - - let horizontalPadding: CGFloat = 6 - let verticalPadding: CGFloat = 4 - let cornerRadius: CGFloat = 4 - let shadowPadding: CGFloat = 1 - - let pillWidth = textSize.width + horizontalPadding * 2 - let pillHeight = textSize.height + verticalPadding * 2 - let totalWidth = pillWidth + shadowPadding * 2 - let totalHeight = pillHeight + shadowPadding * 2 - - let renderer = UIGraphicsImageRenderer( - size: CGSize(width: totalWidth, height: totalHeight), - format: .preferred() + let textRect = CGRect( + x: badgeRect.midX - textSize.width / 2, + y: badgeRect.midY - textSize.height / 2, + width: textSize.width, + height: textSize.height ) - return renderer.image { ctx in - let cgContext = ctx.cgContext - let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: pillWidth, height: pillHeight) - let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) - - cgContext.saveGState() - cgContext.setShadow( - offset: CGSize(width: 0, height: 0.5), - blur: 1, - color: UIColor.black.withAlphaComponent(0.15).cgColor - ) - UIColor.white.setFill() - pillPath.fill() - cgContext.restoreGState() - - UIColor.white.withAlphaComponent(0.85).setFill() - pillPath.fill() - - let textRect = CGRect( - x: shadowPadding + (pillWidth - textSize.width) / 2, - y: shadowPadding + (pillHeight - textSize.height) / 2, - width: textSize.width, - height: textSize.height - ) - (text as NSString).draw(in: textRect, withAttributes: attrs) - } + (text as NSString).draw(in: textRect, withAttributes: attrs) + } } - - private static func renderObstruction() -> UIImage { - let size: CGFloat = 20 - let padding: CGFloat = 3 - let totalSize = size + padding * 2 - let armLength: CGFloat = size / 2 - 1 - let casingWidth: CGFloat = 6 - let strokeWidth: CGFloat = 2.5 - - let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) - return renderer.image { ctx in - let cgContext = ctx.cgContext - let center = CGPoint(x: totalSize / 2, y: totalSize / 2) - - // Draw white casing (thick white stroke behind the red X) - cgContext.setStrokeColor(UIColor.white.cgColor) - cgContext.setLineWidth(casingWidth) - cgContext.setLineCap(.round) - - cgContext.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) - cgContext.addLine(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) - cgContext.move(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) - cgContext.addLine(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) - cgContext.strokePath() - - // Draw red X on top - cgContext.setStrokeColor(UIColor.systemRed.cgColor) - cgContext.setLineWidth(strokeWidth) - cgContext.setLineCap(.round) - - cgContext.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) - cgContext.addLine(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) - cgContext.move(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) - cgContext.addLine(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) - cgContext.strokePath() - } + } + + // MARK: - Pill sprites + + /// Semi-transparent stretchable pill for stats badges. + /// Registered as a resizable image so MapLibre's `iconTextFit` can stretch + /// the flat center while preserving the rounded caps. + private static func renderPillBackground() -> UIImage { + let cornerRadius: CGFloat = 4 + let size: CGFloat = 2 * cornerRadius + 2 + let shadowPadding: CGFloat = 1 + let totalSize = size + shadowPadding * 2 + let capInset = cornerRadius + shadowPadding + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) + let image = renderer.image { ctx in + let cgContext = ctx.cgContext + let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: size, height: size) + let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) + + // Shadow pass + cgContext.saveGState() + cgContext.setShadow( + offset: CGSize(width: 0, height: 0.5), + blur: 1, + color: UIColor.black.withAlphaComponent(0.15).cgColor + ) + UIColor.white.setFill() + pillPath.fill() + cgContext.restoreGState() + + // Light fill for readability in both light and dark mode + UIColor.white.withAlphaComponent(0.85).setFill() + pillPath.fill() } - private static func renderCrosshair(_ spec: SpriteSpec) -> UIImage { - let casingWidth: CGFloat = 6 - let capInset = casingWidth / 2 - let size: CGFloat = 44 + capInset * 2 - let gapRadius: CGFloat = 4 - let outerRadius: CGFloat = 22 - let badgeHeight: CGFloat = 20 - let badgeGap: CGFloat = 2 - // Top padding so the crosshair center sits at the image's vertical midpoint - let topPadding = badgeHeight + badgeGap - let totalHeight = topPadding + size + badgeGap + badgeHeight - - let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: totalHeight), format: .preferred()) - return renderer.image { ctx in - let cgContext = ctx.cgContext - let center = CGPoint(x: size / 2, y: topPadding + size / 2) - - // White casing behind crosshair lines - cgContext.setStrokeColor(UIColor.white.cgColor) - cgContext.setLineWidth(6) - cgContext.setLineCap(.round) - - cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) - cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) - cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) - cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) - cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) - cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) - cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) - cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) - cgContext.strokePath() - - // Crosshair lines - cgContext.setStrokeColor(UIColor.systemPurple.cgColor) - cgContext.setLineWidth(2) - cgContext.setLineCap(.round) - - cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) - cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) - cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) - cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) - cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) - cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) - cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) - cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) - cgContext.strokePath() - - // "R" badge - let badgeWidth: CGFloat = 20 - let badgeRect = CGRect(x: center.x - badgeWidth / 2, y: topPadding + size + badgeGap, width: badgeWidth, height: badgeHeight) - let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 9) - UIColor.systemPurple.setFill() - badgePath.fill() - - let font = UIFont.systemFont(ofSize: 11, weight: .bold) - let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] - let textSize = ("R" as NSString).size(withAttributes: attrs) - let textRect = CGRect( - x: badgeRect.midX - textSize.width / 2, - y: badgeRect.midY - textSize.height / 2, - width: textSize.width, - height: textSize.height - ) - ("R" as NSString).draw(in: textRect, withAttributes: attrs) - } + return image.resizableImage( + withCapInsets: UIEdgeInsets(top: capInset, left: capInset, bottom: capInset, right: capInset), + resizingMode: .stretch + ) + } + + private static func renderLabelSprite(text: String) -> UIImage { + let font = UIFont.systemFont(ofSize: 12, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.black] + let textSize = (text as NSString).size(withAttributes: attrs) + + let horizontalPadding: CGFloat = 6 + let verticalPadding: CGFloat = 4 + let cornerRadius: CGFloat = 4 + let shadowPadding: CGFloat = 1 + + let pillWidth = textSize.width + horizontalPadding * 2 + let pillHeight = textSize.height + verticalPadding * 2 + let totalWidth = pillWidth + shadowPadding * 2 + let totalHeight = pillHeight + shadowPadding * 2 + + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: totalWidth, height: totalHeight), + format: .preferred() + ) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: pillWidth, height: pillHeight) + let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) + + cgContext.saveGState() + cgContext.setShadow( + offset: CGSize(width: 0, height: 0.5), + blur: 1, + color: UIColor.black.withAlphaComponent(0.15).cgColor + ) + UIColor.white.setFill() + pillPath.fill() + cgContext.restoreGState() + + UIColor.white.withAlphaComponent(0.85).setFill() + pillPath.fill() + + let textRect = CGRect( + x: shadowPadding + (pillWidth - textSize.width) / 2, + y: shadowPadding + (pillHeight - textSize.height) / 2, + width: textSize.width, + height: textSize.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } + } + + private static func renderObstruction() -> UIImage { + let size: CGFloat = 20 + let padding: CGFloat = 3 + let totalSize = size + padding * 2 + let armLength: CGFloat = size / 2 - 1 + let casingWidth: CGFloat = 6 + let strokeWidth: CGFloat = 2.5 + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let center = CGPoint(x: totalSize / 2, y: totalSize / 2) + + // Draw white casing (thick white stroke behind the red X) + cgContext.setStrokeColor(UIColor.white.cgColor) + cgContext.setLineWidth(casingWidth) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) + cgContext.move(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) + cgContext.strokePath() + + // Draw red X on top + cgContext.setStrokeColor(UIColor.systemRed.cgColor) + cgContext.setLineWidth(strokeWidth) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) + cgContext.move(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) + cgContext.strokePath() + } + } + + private static func renderCrosshair(_ spec: SpriteSpec) -> UIImage { + let casingWidth: CGFloat = 6 + let capInset = casingWidth / 2 + let size: CGFloat = 44 + capInset * 2 + let gapRadius: CGFloat = 4 + let outerRadius: CGFloat = 22 + let badgeHeight: CGFloat = 20 + let badgeGap: CGFloat = 2 + // Top padding so the crosshair center sits at the image's vertical midpoint + let topPadding = badgeHeight + badgeGap + let totalHeight = topPadding + size + badgeGap + badgeHeight + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: totalHeight), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let center = CGPoint(x: size / 2, y: topPadding + size / 2) + + // White casing behind crosshair lines + cgContext.setStrokeColor(UIColor.white.cgColor) + cgContext.setLineWidth(6) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) + cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) + cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) + cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) + cgContext.strokePath() + + // Crosshair lines + cgContext.setStrokeColor(UIColor.systemPurple.cgColor) + cgContext.setLineWidth(2) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) + cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) + cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) + cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) + cgContext.strokePath() + + // "R" badge + let badgeWidth: CGFloat = 20 + let badgeRect = CGRect(x: center.x - badgeWidth / 2, y: topPadding + size + badgeGap, width: badgeWidth, height: badgeHeight) + let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 9) + UIColor.systemPurple.setFill() + badgePath.fill() + + let font = UIFont.systemFont(ofSize: 11, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let textSize = ("R" as NSString).size(withAttributes: attrs) + let textRect = CGRect( + x: badgeRect.midX - textSize.width / 2, + y: badgeRect.midY - textSize.height / 2, + width: textSize.width, + height: textSize.height + ) + ("R" as NSString).draw(in: textRect, withAttributes: attrs) } + } } diff --git a/MC1/Views/Onboarding/DeviceScanView.swift b/MC1/Views/Onboarding/DeviceScanView.swift index db751252..53d83e63 100644 --- a/MC1/Views/Onboarding/DeviceScanView.swift +++ b/MC1/Views/Onboarding/DeviceScanView.swift @@ -1,242 +1,241 @@ -import SwiftUI import MC1Services +import SwiftUI struct DeviceScanView: View { - @Environment(\.appState) private var appState - @State private var showTroubleshooting = false - @State private var showingWiFiConnection = false - @State private var showingNoDeviceSheet = false - @State private var pairingSuccessTrigger = false - @State private var failureHapticTrigger = false - @State private var demoModeUnlockTrigger = false - @State private var didInitiatePairing = false - @State private var showDemoModeAlert = false - @State private var otherAppDeviceID: UUID? - private var demoModeManager = DemoModeManager.shared - - private var hasConnectedDevice: Bool { - appState.connectionState == .ready - } + @Environment(\.appState) private var appState + @State private var showTroubleshooting = false + @State private var showingWiFiConnection = false + @State private var showingNoDeviceSheet = false + @State private var pairingSuccessTrigger = false + @State private var failureHapticTrigger = false + @State private var demoModeUnlockTrigger = false + @State private var didInitiatePairing = false + @State private var showDemoModeAlert = false + @State private var otherAppDeviceID: UUID? + private var demoModeManager = DemoModeManager.shared + + private var hasConnectedDevice: Bool { + appState.connectionState == .ready + } + + var body: some View { + VStack(spacing: OnboardingMetrics.largeSpacing) { + VStack(spacing: OnboardingMetrics.mediumSpacing) { + PulsingAntenna() + + Text(L10n.Onboarding.DeviceScan.title) + .font(.largeTitle) + .bold() + .accessibilityAddTraits(.isHeader) + .simultaneousGesture( + TapGesture(count: 3).onEnded { unlockDemoMode() } + ) + + if !hasConnectedDevice { + Text(L10n.Onboarding.DeviceScan.subtitle) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } - var body: some View { - VStack(spacing: OnboardingMetrics.largeSpacing) { - VStack(spacing: OnboardingMetrics.mediumSpacing) { - PulsingAntenna() - - Text(L10n.Onboarding.DeviceScan.title) - .font(.largeTitle) - .bold() - .accessibilityAddTraits(.isHeader) - .simultaneousGesture( - TapGesture(count: 3).onEnded { unlockDemoMode() } - ) - - if !hasConnectedDevice { - Text(L10n.Onboarding.DeviceScan.subtitle) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } + Spacer() - Spacer() + if hasConnectedDevice, !didInitiatePairing { + VStack(spacing: OnboardingMetrics.mediumSpacing) { + Text(L10n.Onboarding.DeviceScan.alreadyPaired) + .font(.title2) + .multilineTextAlignment(.center) + } + .padding() + } + + Spacer() + + VStack(spacing: OnboardingMetrics.mediumSpacing) { + if hasConnectedDevice { + Button { + appState.onboarding.onboardingPath.append(.region) + } label: { + Text(L10n.Onboarding.DeviceScan.continue) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + } else { + primaryCTA - if hasConnectedDevice && !didInitiatePairing { - VStack(spacing: OnboardingMetrics.mediumSpacing) { - Text(L10n.Onboarding.DeviceScan.alreadyPaired) - .font(.title2) - .multilineTextAlignment(.center) - } - .padding() + ViewThatFits { + HStack(spacing: OnboardingMetrics.largeSpacing) { + secondaryButtons } - - Spacer() - VStack(spacing: OnboardingMetrics.mediumSpacing) { - if hasConnectedDevice { - Button { - appState.onboarding.onboardingPath.append(.region) - } label: { - Text(L10n.Onboarding.DeviceScan.continue) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - } else { - primaryCTA - - ViewThatFits { - HStack(spacing: OnboardingMetrics.largeSpacing) { - secondaryButtons - } - VStack(spacing: OnboardingMetrics.mediumSpacing) { - secondaryButtons - } - } - .frame(minHeight: OnboardingMetrics.minHitTarget) - - Button(L10n.Onboarding.DeviceScan.noDeviceYet) { - showingNoDeviceSheet = true - } - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.vertical, 8) - .frame(minHeight: OnboardingMetrics.minHitTarget) - } + secondaryButtons } - .padding(.horizontal) - .padding(.bottom) - } - .sensoryFeedback(.success, trigger: pairingSuccessTrigger) - .sensoryFeedback(.success, trigger: demoModeUnlockTrigger) - .sensoryFeedback(.error, trigger: failureHapticTrigger) - .onChange(of: appState.connectionUI.otherAppWarningDeviceID) { _, newValue in - // retryFailedPairingConnect surfaces other-app failures via the ConnectionUI alert - // without going through startPairing's local catch, so we mirror the warning ID - // into local state to keep the recovery CTA pinned to "Retry connection" after - // the user dismisses the alert. - if let id = newValue { otherAppDeviceID = id } - } - .sheet(isPresented: $showTroubleshooting) { - TroubleshootingSheet() - } - .sheet(isPresented: $showingWiFiConnection) { - WiFiConnectionSheet() - } - .sheet(isPresented: $showingNoDeviceSheet) { - NoDeviceSheet() - } - .alert(L10n.Onboarding.DeviceScan.DemoModeAlert.title, isPresented: $showDemoModeAlert) { - Button(L10n.Localizable.Common.ok) { } - } message: { - Text(L10n.Onboarding.DeviceScan.DemoModeAlert.message) + } + .frame(minHeight: OnboardingMetrics.minHitTarget) + + Button(L10n.Onboarding.DeviceScan.noDeviceYet) { + showingNoDeviceSheet = true + } + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.vertical, 8) + .frame(minHeight: OnboardingMetrics.minHitTarget) } + } + .padding(.horizontal) + .padding(.bottom) } - - @ViewBuilder - private var primaryCTA: some View { - #if targetEnvironment(simulator) - Button { connectSimulator() } label: { ctaLabel(systemImage: "laptopcomputer.and.iphone", - text: L10n.Onboarding.DeviceScan.connectSimulator) } - .liquidGlassProminentButtonStyle() - .disabled(appState.connectionUI.isBusy) - #else - if demoModeManager.isEnabled { - Button { connectSimulator() } label: { ctaLabel(systemImage: "play.circle.fill", - text: L10n.Onboarding.DeviceScan.continueDemo) } - .liquidGlassProminentButtonStyle() - .disabled(appState.connectionUI.isBusy) - } else if let deviceID = otherAppDeviceID { - Button { retryConnection(deviceID: deviceID) } label: { ctaLabel(systemImage: "arrow.clockwise.circle.fill", - text: L10n.Onboarding.DeviceScan.retryConnection) } - .liquidGlassProminentButtonStyle() - .disabled(appState.connectionUI.isBusy) - } else { - Button { startPairing() } label: { ctaLabel(systemImage: "plus.circle.fill", - text: L10n.Onboarding.DeviceScan.addDevice) } - .liquidGlassProminentButtonStyle() - .disabled(appState.connectionUI.isBusy) - } - #endif + .sensoryFeedback(.success, trigger: pairingSuccessTrigger) + .sensoryFeedback(.success, trigger: demoModeUnlockTrigger) + .sensoryFeedback(.error, trigger: failureHapticTrigger) + .onChange(of: appState.connectionUI.otherAppWarningDeviceID) { _, newValue in + // retryFailedPairingConnect surfaces other-app failures via the ConnectionUI alert + // without going through startPairing's local catch, so we mirror the warning ID + // into local state to keep the recovery CTA pinned to "Retry connection" after + // the user dismisses the alert. + if let id = newValue { otherAppDeviceID = id } } - - @ViewBuilder - private var secondaryButtons: some View { - Button(L10n.Onboarding.DeviceScan.connectViaWifi) { showingWiFiConnection = true } - .font(.subheadline) - .foregroundStyle(.secondary) - - Button(L10n.Onboarding.DeviceScan.deviceNotAppearing) { showTroubleshooting = true } - .font(.subheadline) - .foregroundStyle(.secondary) + .sheet(isPresented: $showTroubleshooting) { + TroubleshootingSheet() } - - @ViewBuilder - private func ctaLabel(systemImage: String, text: String) -> some View { - HStack(spacing: 8) { - if appState.connectionUI.isBusy { - ProgressView().controlSize(.small) - Text(L10n.Onboarding.DeviceScan.connecting) - } else { - Image(systemName: systemImage) - Text(text) - } - } - .font(.headline) - .frame(maxWidth: .infinity) - .padding() + .sheet(isPresented: $showingWiFiConnection) { + WiFiConnectionSheet() } - - private func startPairing() { - appState.connectionUI.isBusy = true - didInitiatePairing = true - appState.connectionUI.failedPairingDeviceID = nil - - Task { @MainActor in - defer { appState.connectionUI.isBusy = false } - do { - try await appState.connectionManager.pairNewDevice() - await appState.wireServicesIfConnected() - pairingSuccessTrigger.toggle() - appState.onboarding.onboardingPath.append(.region) - } catch DevicePairingError.cancelled { - } catch DevicePairingError.alreadyInProgress { - } catch let pairingError as PairingError { - if case .deviceConnectedToOtherApp(let deviceID) = pairingError { - otherAppDeviceID = deviceID - } - failureHapticTrigger.toggle() - appState.connectionUI.presentPairingFailure(pairingError) - } catch { - appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) - } - } + .sheet(isPresented: $showingNoDeviceSheet) { + NoDeviceSheet() } - - private func retryConnection(deviceID: UUID) { - appState.connectionUI.isBusy = true - Task { @MainActor in - defer { appState.connectionUI.isBusy = false } - do { - try await appState.connectionManager.connect(to: deviceID, forceReconnect: true) - await appState.wireServicesIfConnected() - pairingSuccessTrigger.toggle() - appState.onboarding.onboardingPath.append(.region) - } catch BLEError.deviceConnectedToOtherApp { - appState.connectionUI.otherAppWarningDeviceID = deviceID - } catch { - appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) - } - } + .alert(L10n.Onboarding.DeviceScan.DemoModeAlert.title, isPresented: $showDemoModeAlert) { + Button(L10n.Localizable.Common.ok) {} + } message: { + Text(L10n.Onboarding.DeviceScan.DemoModeAlert.message) } - - private func connectSimulator() { - appState.connectionUI.isBusy = true - didInitiatePairing = true - Task { @MainActor in - defer { appState.connectionUI.isBusy = false } - do { - try await appState.connectionManager.simulatorConnect() - await appState.wireServicesIfConnected() - pairingSuccessTrigger.toggle() - appState.onboarding.onboardingPath.append(.region) - } catch { - appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) - } + } + + @ViewBuilder + private var primaryCTA: some View { + #if targetEnvironment(simulator) + Button { connectSimulator() } label: { ctaLabel(systemImage: "laptopcomputer.and.iphone", + text: L10n.Onboarding.DeviceScan.connectSimulator) } + .liquidGlassProminentButtonStyle() + .disabled(appState.connectionUI.isBusy) + #else + if demoModeManager.isEnabled { + Button { connectSimulator() } label: { ctaLabel(systemImage: "play.circle.fill", + text: L10n.Onboarding.DeviceScan.continueDemo) } + .liquidGlassProminentButtonStyle() + .disabled(appState.connectionUI.isBusy) + } else if let deviceID = otherAppDeviceID { + Button { retryConnection(deviceID: deviceID) } label: { ctaLabel(systemImage: "arrow.clockwise.circle.fill", + text: L10n.Onboarding.DeviceScan.retryConnection) } + .liquidGlassProminentButtonStyle() + .disabled(appState.connectionUI.isBusy) + } else { + Button { startPairing() } label: { ctaLabel(systemImage: "plus.circle.fill", + text: L10n.Onboarding.DeviceScan.addDevice) } + .liquidGlassProminentButtonStyle() + .disabled(appState.connectionUI.isBusy) + } + #endif + } + + @ViewBuilder + private var secondaryButtons: some View { + Button(L10n.Onboarding.DeviceScan.connectViaWifi) { showingWiFiConnection = true } + .font(.subheadline) + .foregroundStyle(.secondary) + + Button(L10n.Onboarding.DeviceScan.deviceNotAppearing) { showTroubleshooting = true } + .font(.subheadline) + .foregroundStyle(.secondary) + } + + private func ctaLabel(systemImage: String, text: String) -> some View { + HStack(spacing: 8) { + if appState.connectionUI.isBusy { + ProgressView().controlSize(.small) + Text(L10n.Onboarding.DeviceScan.connecting) + } else { + Image(systemName: systemImage) + Text(text) + } + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + + private func startPairing() { + appState.connectionUI.isBusy = true + didInitiatePairing = true + appState.connectionUI.failedPairingDeviceID = nil + + Task { @MainActor in + defer { appState.connectionUI.isBusy = false } + do { + try await appState.connectionManager.pairNewDevice() + await appState.wireServicesIfConnected() + pairingSuccessTrigger.toggle() + appState.onboarding.onboardingPath.append(.region) + } catch DevicePairingError.cancelled { + } catch DevicePairingError.alreadyInProgress { + } catch let pairingError as PairingError { + if case let .deviceConnectedToOtherApp(deviceID) = pairingError { + otherAppDeviceID = deviceID } + failureHapticTrigger.toggle() + appState.connectionUI.presentPairingFailure(pairingError) + } catch { + appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) + } } - - /// 3-tap easter egg for App Store reviewers — preserved per CLAUDE.md `demo-mode-is-for-app-store-reviewers`. - private func unlockDemoMode() { - demoModeManager.unlock() - demoModeUnlockTrigger.toggle() - showDemoModeAlert = true + } + + private func retryConnection(deviceID: UUID) { + appState.connectionUI.isBusy = true + Task { @MainActor in + defer { appState.connectionUI.isBusy = false } + do { + try await appState.connectionManager.connect(to: deviceID, forceReconnect: true) + await appState.wireServicesIfConnected() + pairingSuccessTrigger.toggle() + appState.onboarding.onboardingPath.append(.region) + } catch BLEError.deviceConnectedToOtherApp { + appState.connectionUI.otherAppWarningDeviceID = deviceID + } catch { + appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) + } + } + } + + private func connectSimulator() { + appState.connectionUI.isBusy = true + didInitiatePairing = true + Task { @MainActor in + defer { appState.connectionUI.isBusy = false } + do { + try await appState.connectionManager.simulatorConnect() + await appState.wireServicesIfConnected() + pairingSuccessTrigger.toggle() + appState.onboarding.onboardingPath.append(.region) + } catch { + appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) + } } + } + + /// 3-tap easter egg for App Store reviewers — preserved per CLAUDE.md `demo-mode-is-for-app-store-reviewers`. + private func unlockDemoMode() { + demoModeManager.unlock() + demoModeUnlockTrigger.toggle() + showDemoModeAlert = true + } } #Preview { - DeviceScanView() - .environment(\.appState, AppState()) + DeviceScanView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/DeviceScannerSheet.swift b/MC1/Views/Onboarding/DeviceScannerSheet.swift index a91da3c6..d0931593 100644 --- a/MC1/Views/Onboarding/DeviceScannerSheet.swift +++ b/MC1/Views/Onboarding/DeviceScannerSheet.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// In-app BLE device picker used on macOS "Designed for iPad", where AccessorySetupKit's /// system picker is unavailable. @@ -10,125 +10,125 @@ import MC1Services /// the user taps a device (`select`) or cancels (`cancel`). The selected UUID then flows /// through the same `connect(to:)` ceremony as the AccessorySetupKit path on iOS. struct DeviceScannerSheet: View { - @Environment(\.appState) private var appState - let picker: BluetoothScanPairingService + @Environment(\.appState) private var appState + let picker: BluetoothScanPairingService - @State private var tracker = RSSIScanTracker() + @State private var tracker = RSSIScanTracker() - /// Stable ordering: by name, then id. Avoids row reshuffling as RSSI fluctuates. - private var sortedDevices: [DiscoveredDevice] { - tracker.devices.values.sorted { lhs, rhs in - let lName = lhs.name ?? "" - let rName = rhs.name ?? "" - if lName != rName { return lName.localizedCaseInsensitiveCompare(rName) == .orderedAscending } - return lhs.id.uuidString < rhs.id.uuidString - } + /// Stable ordering: by name, then id. Avoids row reshuffling as RSSI fluctuates. + private var sortedDevices: [DiscoveredDevice] { + tracker.devices.values.sorted { lhs, rhs in + let lName = lhs.name ?? "" + let rName = rhs.name ?? "" + if lName != rName { return lName.localizedCaseInsensitiveCompare(rName) == .orderedAscending } + return lhs.id.uuidString < rhs.id.uuidString } + } - var body: some View { - NavigationStack { - Group { - switch appState.connectionManager.bluetoothAvailability { - case .poweredOff: - bluetoothRemedy( - title: L10n.Onboarding.DeviceScanner.BluetoothOff.title, - message: L10n.Onboarding.DeviceScanner.BluetoothOff.message - ) - case .unauthorized: - bluetoothRemedy( - title: L10n.Onboarding.DeviceScanner.BluetoothUnauthorized.title, - message: L10n.Onboarding.DeviceScanner.BluetoothUnauthorized.message - ) - case .ready: - if sortedDevices.isEmpty { - scanningState - } else { - deviceList - } - } - } - .navigationTitle(L10n.Onboarding.DeviceScanner.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - picker.cancel() - } - } - } - .task { await tracker.consume(appState.connectionManager.startBLEScanning()) } + var body: some View { + NavigationStack { + Group { + switch appState.connectionManager.bluetoothAvailability { + case .poweredOff: + bluetoothRemedy( + title: L10n.Onboarding.DeviceScanner.BluetoothOff.title, + message: L10n.Onboarding.DeviceScanner.BluetoothOff.message + ) + case .unauthorized: + bluetoothRemedy( + title: L10n.Onboarding.DeviceScanner.BluetoothUnauthorized.title, + message: L10n.Onboarding.DeviceScanner.BluetoothUnauthorized.message + ) + case .ready: + if sortedDevices.isEmpty { + scanningState + } else { + deviceList + } } + } + .navigationTitle(L10n.Onboarding.DeviceScanner.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { + picker.cancel() + } + } + } + .task { await tracker.consume(appState.connectionManager.startBLEScanning()) } } + } - /// Scanning empty state with an inline spinner, so the picker reads as actively searching - /// rather than stalled before the first peripheral resolves. - private var scanningState: some View { - ContentUnavailableView { - Label(L10n.Onboarding.DeviceScanner.scanning, - systemImage: "antenna.radiowaves.left.and.right") - } description: { - ProgressView() - .controlSize(.small) - } + /// Scanning empty state with an inline spinner, so the picker reads as actively searching + /// rather than stalled before the first peripheral resolves. + private var scanningState: some View { + ContentUnavailableView { + Label(L10n.Onboarding.DeviceScanner.scanning, + systemImage: "antenna.radiowaves.left.and.right") + } description: { + ProgressView() + .controlSize(.small) } + } - private var deviceList: some View { - List(sortedDevices) { device in - Button { - picker.select(device.id) - } label: { - DeviceScannerRow( - name: device.name ?? L10n.Onboarding.DeviceScanner.unknownDevice, - signalTier: tracker.signalTier(for: device.id) ?? .weak - ) - .contentShape(.rect) - } - .buttonStyle(.plain) - } + private var deviceList: some View { + List(sortedDevices) { device in + Button { + picker.select(device.id) + } label: { + DeviceScannerRow( + name: device.name ?? L10n.Onboarding.DeviceScanner.unknownDevice, + signalTier: tracker.signalTier(for: device.id) ?? .weak + ) + .contentShape(.rect) + } + .buttonStyle(.plain) } + } - /// Remedy state for the macOS scanner when Bluetooth is off or unauthorized: scanning cannot - /// surface any peripheral, so the picker explains what to fix instead of spinning indefinitely. - private func bluetoothRemedy(title: String, message: String) -> some View { - ContentUnavailableView { - Label(title, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(message) - } + /// Remedy state for the macOS scanner when Bluetooth is off or unauthorized: scanning cannot + /// surface any peripheral, so the picker explains what to fix instead of spinning indefinitely. + private func bluetoothRemedy(title: String, message: String) -> some View { + ContentUnavailableView { + Label(title, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(message) } + } } // MARK: - Row private struct DeviceScannerRow: View { - let name: String - let signalTier: RSSITuning.SignalTier + let name: String + let signalTier: RSSITuning.SignalTier - var body: some View { - HStack(spacing: 12) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.title2) - .foregroundStyle(.green) - .frame(width: 40, height: 40) - .background(.green.opacity(0.1), in: .circle) - .accessibilityHidden(true) + var body: some View { + HStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.title2) + .foregroundStyle(.green) + .frame(width: 40, height: 40) + .background(.green.opacity(0.1), in: .circle) + .accessibilityHidden(true) - Text(name) - .font(.headline) + Text(name) + .font(.headline) - Spacer() + Spacer() - SignalBars(tier: signalTier, accessibilityLabel: signalDescription) - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) + SignalBars(tier: signalTier, accessibilityLabel: signalDescription) } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + } - /// VoiceOver descriptor for the signal glyph, so the only in-range/barely-reachable - /// differentiator is announced rather than silent (the combined element otherwise reads - /// the device name alone). - private var signalDescription: String { - SignalBars.accessibilityDescription(forTier: signalTier) - } + /// VoiceOver descriptor for the signal glyph, so the only in-range/barely-reachable + /// differentiator is announced rather than silent (the combined element otherwise reads + /// the device name alone). + private var signalDescription: String { + SignalBars.accessibilityDescription(forTier: signalTier) + } } diff --git a/MC1/Views/Onboarding/MeshAnimationView.swift b/MC1/Views/Onboarding/MeshAnimationView.swift index 13912860..3037fbea 100644 --- a/MC1/Views/Onboarding/MeshAnimationView.swift +++ b/MC1/Views/Onboarding/MeshAnimationView.swift @@ -2,173 +2,173 @@ import SwiftUI /// Animated mesh network visualization showing interconnected nodes with a traveling message. struct MeshAnimationView: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - private struct Node: Identifiable { - let id = UUID() - var position: CGPoint - let isUserNode: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private struct Node: Identifiable { + let id = UUID() + var position: CGPoint + let isUserNode: Bool + } + + private struct Edge: Identifiable { + let id = UUID() + let from: Int + let to: Int + } + + // MARK: - Animation Constants + + private let nodeRadius: CGFloat = 8 + private let userNodeRadius: CGFloat = 12 + private let messageRadius: CGFloat = 4 + private let edgeLineWidth: CGFloat = 1.5 + + private let edgeFadeFrequency: Double = 0.8 + private let edgeMinOpacity: Double = 0.3 + private let edgeFadeAmplitude: Double = 0.3 + private let edgePhaseOffset: Double = 0.7 + + private let nodePulseFrequency: Double = 1.2 + private let nodePulseAmplitude: Double = 0.15 + private let nodePhaseOffset: Double = 0.5 + + private let messageCycleDuration: Double = 4.0 + + private let nodes: [Node] = [ + Node(position: CGPoint(x: 0.2, y: 0.3), isUserNode: false), + Node(position: CGPoint(x: 0.5, y: 0.15), isUserNode: false), + Node(position: CGPoint(x: 0.8, y: 0.25), isUserNode: false), + Node(position: CGPoint(x: 0.15, y: 0.7), isUserNode: false), + Node(position: CGPoint(x: 0.5, y: 0.55), isUserNode: true), + Node(position: CGPoint(x: 0.85, y: 0.75), isUserNode: false), + ] + + private let edges: [Edge] = [ + Edge(from: 0, to: 1), + Edge(from: 1, to: 2), + Edge(from: 0, to: 4), + Edge(from: 1, to: 4), + Edge(from: 3, to: 4), + Edge(from: 4, to: 5), + Edge(from: 2, to: 5), + ] + + private let messagePath: [Int] = [3, 4, 1, 2] + + var body: some View { + Group { + if reduceMotion { + Canvas { context, size in + drawMesh(context: context, size: size, time: 0) + } + } else { + TimelineView(.animation(minimumInterval: 1 / 30)) { timeline in + Canvas { context, size in + let time = timeline.date.timeIntervalSinceReferenceDate + drawMesh(context: context, size: size, time: time) + } + } + } } - - private struct Edge: Identifiable { - let id = UUID() - let from: Int - let to: Int + .frame(height: 150) + .accessibilityLabel(L10n.Onboarding.MeshAnimation.accessibilityLabel) + .accessibilityAddTraits(.isImage) + } + + private func drawMesh(context: GraphicsContext, size: CGSize, time: Double) { + // Draw edges with fading effect + for (index, edge) in edges.enumerated() { + let fromNode = nodes[edge.from] + let toNode = nodes[edge.to] + + let fromPoint = CGPoint( + x: fromNode.position.x * size.width, + y: fromNode.position.y * size.height + ) + let toPoint = CGPoint( + x: toNode.position.x * size.width, + y: toNode.position.y * size.height + ) + + let phase = Double(index) * edgePhaseOffset + let opacity = edgeMinOpacity + edgeFadeAmplitude * sin(time * edgeFadeFrequency + phase) + + var path = Path() + path.move(to: fromPoint) + path.addLine(to: toPoint) + + context.stroke( + path, + with: .color(Color.accentColor.opacity(opacity)), + lineWidth: edgeLineWidth + ) } - // MARK: - Animation Constants - - private let nodeRadius: CGFloat = 8 - private let userNodeRadius: CGFloat = 12 - private let messageRadius: CGFloat = 4 - private let edgeLineWidth: CGFloat = 1.5 - - private let edgeFadeFrequency: Double = 0.8 - private let edgeMinOpacity: Double = 0.3 - private let edgeFadeAmplitude: Double = 0.3 - private let edgePhaseOffset: Double = 0.7 - - private let nodePulseFrequency: Double = 1.2 - private let nodePulseAmplitude: Double = 0.15 - private let nodePhaseOffset: Double = 0.5 - - private let messageCycleDuration: Double = 4.0 - - private let nodes: [Node] = [ - Node(position: CGPoint(x: 0.2, y: 0.3), isUserNode: false), - Node(position: CGPoint(x: 0.5, y: 0.15), isUserNode: false), - Node(position: CGPoint(x: 0.8, y: 0.25), isUserNode: false), - Node(position: CGPoint(x: 0.15, y: 0.7), isUserNode: false), - Node(position: CGPoint(x: 0.5, y: 0.55), isUserNode: true), - Node(position: CGPoint(x: 0.85, y: 0.75), isUserNode: false), - ] - - private let edges: [Edge] = [ - Edge(from: 0, to: 1), - Edge(from: 1, to: 2), - Edge(from: 0, to: 4), - Edge(from: 1, to: 4), - Edge(from: 3, to: 4), - Edge(from: 4, to: 5), - Edge(from: 2, to: 5), - ] - - private let messagePath: [Int] = [3, 4, 1, 2] - - var body: some View { - Group { - if reduceMotion { - Canvas { context, size in - drawMesh(context: context, size: size, time: 0) - } - } else { - TimelineView(.animation(minimumInterval: 1/30)) { timeline in - Canvas { context, size in - let time = timeline.date.timeIntervalSinceReferenceDate - drawMesh(context: context, size: size, time: time) - } - } - } - } - .frame(height: 150) - .accessibilityLabel(L10n.Onboarding.MeshAnimation.accessibilityLabel) - .accessibilityAddTraits(.isImage) + // Draw nodes with pulse effect + for (index, node) in nodes.enumerated() { + let center = CGPoint( + x: node.position.x * size.width, + y: node.position.y * size.height + ) + + let phase = Double(index) * nodePhaseOffset + let scale = 1.0 + nodePulseAmplitude * sin(time * nodePulseFrequency + phase) + let baseRadius = node.isUserNode ? userNodeRadius : nodeRadius + let radius = baseRadius * scale + + let rect = CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + ) + + context.fill( + Path(ellipseIn: rect), + with: .color(Color.accentColor.opacity(node.isUserNode ? 1.0 : 0.7)) + ) } - private func drawMesh(context: GraphicsContext, size: CGSize, time: Double) { - // Draw edges with fading effect - for (index, edge) in edges.enumerated() { - let fromNode = nodes[edge.from] - let toNode = nodes[edge.to] - - let fromPoint = CGPoint( - x: fromNode.position.x * size.width, - y: fromNode.position.y * size.height - ) - let toPoint = CGPoint( - x: toNode.position.x * size.width, - y: toNode.position.y * size.height - ) - - let phase = Double(index) * edgePhaseOffset - let opacity = edgeMinOpacity + edgeFadeAmplitude * sin(time * edgeFadeFrequency + phase) - - var path = Path() - path.move(to: fromPoint) - path.addLine(to: toPoint) - - context.stroke( - path, - with: .color(Color.accentColor.opacity(opacity)), - lineWidth: edgeLineWidth - ) - } - - // Draw nodes with pulse effect - for (index, node) in nodes.enumerated() { - let center = CGPoint( - x: node.position.x * size.width, - y: node.position.y * size.height - ) - - let phase = Double(index) * nodePhaseOffset - let scale = 1.0 + nodePulseAmplitude * sin(time * nodePulseFrequency + phase) - let baseRadius = node.isUserNode ? userNodeRadius : nodeRadius - let radius = baseRadius * scale - - let rect = CGRect( - x: center.x - radius, - y: center.y - radius, - width: radius * 2, - height: radius * 2 - ) - - context.fill( - Path(ellipseIn: rect), - with: .color(Color.accentColor.opacity(node.isUserNode ? 1.0 : 0.7)) - ) - } - - // Draw traveling message - let progress = (time.truncatingRemainder(dividingBy: messageCycleDuration)) / messageCycleDuration - let segmentCount = messagePath.count - 1 - let totalProgress = progress * Double(segmentCount) - let segmentIndex = min(Int(totalProgress), segmentCount - 1) - let segmentProgress = totalProgress - Double(segmentIndex) - - if segmentIndex < segmentCount { - let fromNode = nodes[messagePath[segmentIndex]] - let toNode = nodes[messagePath[segmentIndex + 1]] - - let fromPoint = CGPoint( - x: fromNode.position.x * size.width, - y: fromNode.position.y * size.height - ) - let toPoint = CGPoint( - x: toNode.position.x * size.width, - y: toNode.position.y * size.height - ) - - let messageX = fromPoint.x + (toPoint.x - fromPoint.x) * segmentProgress - let messageY = fromPoint.y + (toPoint.y - fromPoint.y) * segmentProgress - - let messageRect = CGRect( - x: messageX - messageRadius, - y: messageY - messageRadius, - width: messageRadius * 2, - height: messageRadius * 2 - ) - - context.fill( - Path(ellipseIn: messageRect), - with: .color(Color.accentColor) - ) - } + // Draw traveling message + let progress = (time.truncatingRemainder(dividingBy: messageCycleDuration)) / messageCycleDuration + let segmentCount = messagePath.count - 1 + let totalProgress = progress * Double(segmentCount) + let segmentIndex = min(Int(totalProgress), segmentCount - 1) + let segmentProgress = totalProgress - Double(segmentIndex) + + if segmentIndex < segmentCount { + let fromNode = nodes[messagePath[segmentIndex]] + let toNode = nodes[messagePath[segmentIndex + 1]] + + let fromPoint = CGPoint( + x: fromNode.position.x * size.width, + y: fromNode.position.y * size.height + ) + let toPoint = CGPoint( + x: toNode.position.x * size.width, + y: toNode.position.y * size.height + ) + + let messageX = fromPoint.x + (toPoint.x - fromPoint.x) * segmentProgress + let messageY = fromPoint.y + (toPoint.y - fromPoint.y) * segmentProgress + + let messageRect = CGRect( + x: messageX - messageRadius, + y: messageY - messageRadius, + width: messageRadius * 2, + height: messageRadius * 2 + ) + + context.fill( + Path(ellipseIn: messageRect), + with: .color(Color.accentColor) + ) } + } } #Preview { - MeshAnimationView() - .padding() - .background(.black.opacity(0.1)) + MeshAnimationView() + .padding() + .background(.black.opacity(0.1)) } diff --git a/MC1/Views/Onboarding/NoDeviceSheet.swift b/MC1/Views/Onboarding/NoDeviceSheet.swift index d0afea45..497a00a4 100644 --- a/MC1/Views/Onboarding/NoDeviceSheet.swift +++ b/MC1/Views/Onboarding/NoDeviceSheet.swift @@ -4,58 +4,58 @@ import SwiftUI /// Confirms exiting onboarding into the empty main app — does NOT unlock demo /// mode and does NOT route through `.region`/`.preset`. struct NoDeviceSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @State private var dismissTrigger = false + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @State private var dismissTrigger = false - var body: some View { - VStack(spacing: OnboardingMetrics.largeSpacing) { - Text(L10n.Onboarding.NoDevice.Sheet.title) - .font(.title2) - .bold() - .accessibilityHeading(.h1) - .padding(.top, OnboardingMetrics.sheetTopPadding) + var body: some View { + VStack(spacing: OnboardingMetrics.largeSpacing) { + Text(L10n.Onboarding.NoDevice.Sheet.title) + .font(.title2) + .bold() + .accessibilityHeading(.h1) + .padding(.top, OnboardingMetrics.sheetTopPadding) - Text(L10n.Onboarding.NoDevice.Sheet.body) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) + Text(L10n.Onboarding.NoDevice.Sheet.body) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) - Spacer() + Spacer() - VStack(spacing: OnboardingMetrics.mediumSpacing) { - Button { - dismissTrigger.toggle() - appState.completeOnboarding() - dismiss() - } label: { - Text(L10n.Onboarding.NoDevice.Sheet.confirm) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .frame(minHeight: OnboardingMetrics.minHitTarget) + VStack(spacing: OnboardingMetrics.mediumSpacing) { + Button { + dismissTrigger.toggle() + appState.completeOnboarding() + dismiss() + } label: { + Text(L10n.Onboarding.NoDevice.Sheet.confirm) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .frame(minHeight: OnboardingMetrics.minHitTarget) - Button(L10n.Onboarding.NoDevice.Sheet.cancel) { - dismiss() - } - .buttonStyle(.bordered) - .tint(.secondary) - .frame(minHeight: OnboardingMetrics.minHitTarget) - } - .padding(.horizontal) - .padding(.bottom, OnboardingMetrics.largeSpacing) + Button(L10n.Onboarding.NoDevice.Sheet.cancel) { + dismiss() } - .sensoryFeedback(.selection, trigger: dismissTrigger) - .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.medium]) - .presentationDragIndicator(.visible) + .buttonStyle(.bordered) + .tint(.secondary) + .frame(minHeight: OnboardingMetrics.minHitTarget) + } + .padding(.horizontal) + .padding(.bottom, OnboardingMetrics.largeSpacing) } + .sensoryFeedback(.selection, trigger: dismissTrigger) + .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.medium]) + .presentationDragIndicator(.visible) + } } #Preview { - Color.clear.sheet(isPresented: .constant(true)) { NoDeviceSheet() } - .environment(\.appState, AppState()) + Color.clear.sheet(isPresented: .constant(true)) { NoDeviceSheet() } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/OnboardingMetrics.swift b/MC1/Views/Onboarding/OnboardingMetrics.swift index 7a94856a..d42e5ac0 100644 --- a/MC1/Views/Onboarding/OnboardingMetrics.swift +++ b/MC1/Views/Onboarding/OnboardingMetrics.swift @@ -3,16 +3,16 @@ import Foundation /// Named layout constants for onboarding screens. Avoids bare numeric literals /// scattered through `WelcomeView`, `PermissionsView`, `DeviceScanView`, etc. enum OnboardingMetrics { - static let heroSize: CGFloat = 130 - static let iconSize: CGFloat = 60 - static let cardCornerRadius: CGFloat = 12 - static let compactSpacing: CGFloat = 4 - static let titleStackSpacing: CGFloat = 8 - static let mediumSpacing: CGFloat = 12 - static let cardSpacing: CGFloat = 16 - static let contentPadding: CGFloat = 20 - static let largeSpacing: CGFloat = 24 - static let sheetTopPadding: CGFloat = 32 - static let minHitTarget: CGFloat = 44 - static let headerTopPadding: CGFloat = 40 + static let heroSize: CGFloat = 130 + static let iconSize: CGFloat = 60 + static let cardCornerRadius: CGFloat = 12 + static let compactSpacing: CGFloat = 4 + static let titleStackSpacing: CGFloat = 8 + static let mediumSpacing: CGFloat = 12 + static let cardSpacing: CGFloat = 16 + static let contentPadding: CGFloat = 20 + static let largeSpacing: CGFloat = 24 + static let sheetTopPadding: CGFloat = 32 + static let minHitTarget: CGFloat = 44 + static let headerTopPadding: CGFloat = 40 } diff --git a/MC1/Views/Onboarding/PermissionCard.swift b/MC1/Views/Onboarding/PermissionCard.swift index 1f4e6530..3ea7b30e 100644 --- a/MC1/Views/Onboarding/PermissionCard.swift +++ b/MC1/Views/Onboarding/PermissionCard.swift @@ -1,80 +1,80 @@ import SwiftUI struct PermissionCard: View { - @Environment(\.openURL) private var openURL + @Environment(\.openURL) private var openURL - let icon: String - let title: String - let description: String - let isGranted: Bool - let isDenied: Bool - var isOptional: Bool = false - let action: () -> Void + let icon: String + let title: String + let description: String + let isGranted: Bool + let isDenied: Bool + var isOptional: Bool = false + let action: () -> Void - var body: some View { - HStack(spacing: 16) { - // Icon - Image(systemName: icon) - .font(.title2) - .foregroundStyle(iconColor) - .frame(width: 44, height: 44) - .background(iconColor.opacity(0.1), in: .circle) + var body: some View { + HStack(spacing: 16) { + // Icon + Image(systemName: icon) + .font(.title2) + .foregroundStyle(iconColor) + .frame(width: 44, height: 44) + .background(iconColor.opacity(0.1), in: .circle) - // Text - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(title) - .font(.headline) + // Text + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(title) + .font(.headline) - if isOptional { - Text(L10n.Onboarding.Permissions.optional) - .font(.caption) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.secondary.opacity(0.2), in: .capsule) - .foregroundStyle(.secondary) - } - } + if isOptional { + Text(L10n.Onboarding.Permissions.optional) + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.secondary.opacity(0.2), in: .capsule) + .foregroundStyle(.secondary) + } + } - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - } + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + } - Spacer() + Spacer() - // Status/Action - if isGranted { - Image(systemName: "checkmark.circle.fill") - .font(.title2) - .foregroundStyle(.green) - } else if isDenied { - Button(L10n.Onboarding.Permissions.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } - .buttonStyle(.bordered) - .controlSize(.small) - } else { - Button(L10n.Onboarding.Permissions.request) { - action() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } + // Status/Action + if isGranted { + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundStyle(.green) + } else if isDenied { + Button(L10n.Onboarding.Permissions.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } else { + Button(L10n.Onboarding.Permissions.request) { + action() } - .padding() - .liquidGlass(in: .rect(cornerRadius: 12)) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } } + .padding() + .liquidGlass(in: .rect(cornerRadius: 12)) + } - private var iconColor: Color { - if isGranted { - return .green - } else if isDenied { - return .orange - } else { - return .accentColor - } + private var iconColor: Color { + if isGranted { + .green + } else if isDenied { + .orange + } else { + .accentColor } + } } diff --git a/MC1/Views/Onboarding/PermissionsCoordinator.swift b/MC1/Views/Onboarding/PermissionsCoordinator.swift index d207934b..c0d74d08 100644 --- a/MC1/Views/Onboarding/PermissionsCoordinator.swift +++ b/MC1/Views/Onboarding/PermissionsCoordinator.swift @@ -6,61 +6,61 @@ import UserNotifications @Observable @MainActor final class PermissionsCoordinator: NSObject, CLLocationManagerDelegate { - var locationAuthorization: CLAuthorizationStatus = .notDetermined - var notificationAuthorization: UNAuthorizationStatus = .notDetermined + var locationAuthorization: CLAuthorizationStatus = .notDetermined + var notificationAuthorization: UNAuthorizationStatus = .notDetermined - private var locationManager: CLLocationManager? + private var locationManager: CLLocationManager? - override init() { - super.init() - // Create location manager early to check current authorization - locationManager = CLLocationManager() - locationManager?.delegate = self - locationAuthorization = locationManager?.authorizationStatus ?? .notDetermined + override init() { + super.init() + // Create location manager early to check current authorization + locationManager = CLLocationManager() + locationManager?.delegate = self + locationAuthorization = locationManager?.authorizationStatus ?? .notDetermined - // Check notification authorization - Task { - await checkNotificationAuthorization() - } + // Check notification authorization + Task { + await checkNotificationAuthorization() } + } - func requestLocation() { - locationManager?.requestWhenInUseAuthorization() - } + func requestLocation() { + locationManager?.requestWhenInUseAuthorization() + } - func requestNotifications() { - Task { - do { - let granted = try await UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .sound, .badge] - ) - notificationAuthorization = granted ? .authorized : .denied - } catch { - notificationAuthorization = .denied - } - } + func requestNotifications() { + Task { + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .sound, .badge] + ) + notificationAuthorization = granted ? .authorized : .denied + } catch { + notificationAuthorization = .denied + } } + } - func checkPermissions() { - if let lm = locationManager { - locationAuthorization = lm.authorizationStatus - } - Task { - await checkNotificationAuthorization() - } + func checkPermissions() { + if let lm = locationManager { + locationAuthorization = lm.authorizationStatus } - - private func checkNotificationAuthorization() async { - let settings = await UNUserNotificationCenter.current().notificationSettings() - notificationAuthorization = settings.authorizationStatus + Task { + await checkNotificationAuthorization() } + } + + private func checkNotificationAuthorization() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + notificationAuthorization = settings.authorizationStatus + } - // MARK: - CLLocationManagerDelegate + // MARK: - CLLocationManagerDelegate - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - Task { @MainActor in - self.locationAuthorization = status - } + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + self.locationAuthorization = status } + } } diff --git a/MC1/Views/Onboarding/PermissionsView.swift b/MC1/Views/Onboarding/PermissionsView.swift index 6a2c1e09..44b8699e 100644 --- a/MC1/Views/Onboarding/PermissionsView.swift +++ b/MC1/Views/Onboarding/PermissionsView.swift @@ -1,100 +1,100 @@ -import SwiftUI import CoreLocation +import SwiftUI struct PermissionsView: View { - @Environment(\.appState) private var appState - @Environment(\.scenePhase) private var scenePhase - @State private var coordinator = PermissionsCoordinator() - @State private var permissionGrantTrigger = false + @Environment(\.appState) private var appState + @Environment(\.scenePhase) private var scenePhase + @State private var coordinator = PermissionsCoordinator() + @State private var permissionGrantTrigger = false - var body: some View { - VStack(spacing: OnboardingMetrics.sheetTopPadding) { - VStack(spacing: OnboardingMetrics.mediumSpacing) { - Image(systemName: "checkmark.shield.fill") - .font(.system(size: OnboardingMetrics.iconSize)) - .foregroundStyle(.tint) + var body: some View { + VStack(spacing: OnboardingMetrics.sheetTopPadding) { + VStack(spacing: OnboardingMetrics.mediumSpacing) { + Image(systemName: "checkmark.shield.fill") + .font(.system(size: OnboardingMetrics.iconSize)) + .foregroundStyle(.tint) - Text(L10n.Onboarding.Permissions.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) + Text(L10n.Onboarding.Permissions.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) - Text(L10n.Onboarding.Permissions.subtitle) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - .padding(.top, OnboardingMetrics.headerTopPadding) + Text(L10n.Onboarding.Permissions.subtitle) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding(.top, OnboardingMetrics.headerTopPadding) - GeometryReader { proxy in - ScrollView { - VStack(spacing: 0) { - Spacer(minLength: 0) + GeometryReader { proxy in + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: 0) - LiquidGlassContainer(spacing: OnboardingMetrics.contentPadding) { - VStack(spacing: OnboardingMetrics.cardSpacing) { - PermissionCard( - icon: "bell.fill", - title: L10n.Onboarding.Permissions.Notifications.title, - description: L10n.Onboarding.Permissions.Notifications.description, - isGranted: coordinator.notificationAuthorization == .authorized, - isDenied: coordinator.notificationAuthorization == .denied, - action: coordinator.requestNotifications - ) + LiquidGlassContainer(spacing: OnboardingMetrics.contentPadding) { + VStack(spacing: OnboardingMetrics.cardSpacing) { + PermissionCard( + icon: "bell.fill", + title: L10n.Onboarding.Permissions.Notifications.title, + description: L10n.Onboarding.Permissions.Notifications.description, + isGranted: coordinator.notificationAuthorization == .authorized, + isDenied: coordinator.notificationAuthorization == .denied, + action: coordinator.requestNotifications + ) - PermissionCard( - icon: "location.fill", - title: L10n.Onboarding.Permissions.Location.title, - description: L10n.Onboarding.Permissions.Location.description, - isGranted: coordinator.locationAuthorization == .authorizedWhenInUse - || coordinator.locationAuthorization == .authorizedAlways, - isDenied: coordinator.locationAuthorization == .denied, - action: coordinator.requestLocation - ) - } - } - .padding(.horizontal) - - Spacer(minLength: 0) - } - .frame(minHeight: proxy.size.height) - } - .scrollBounceBehavior(.basedOnSize) + PermissionCard( + icon: "location.fill", + title: L10n.Onboarding.Permissions.Location.title, + description: L10n.Onboarding.Permissions.Location.description, + isGranted: coordinator.locationAuthorization == .authorizedWhenInUse + || coordinator.locationAuthorization == .authorizedAlways, + isDenied: coordinator.locationAuthorization == .denied, + action: coordinator.requestLocation + ) + } } - - Button { - appState.onboarding.onboardingPath.append(.pair) - } label: { - Text(L10n.Onboarding.Permissions.continue) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() .padding(.horizontal) - .padding(.bottom) - } - .sensoryFeedback(.success, trigger: permissionGrantTrigger) - .onChange(of: coordinator.locationAuthorization) { _, new in - if new == .authorizedWhenInUse || new == .authorizedAlways { - permissionGrantTrigger.toggle() - } - } - .onChange(of: coordinator.notificationAuthorization) { _, new in - if new == .authorized { - permissionGrantTrigger.toggle() - } - } - .onChange(of: scenePhase) { - if scenePhase == .active { - coordinator.checkPermissions() - } + + Spacer(minLength: 0) + } + .frame(minHeight: proxy.size.height) } + .scrollBounceBehavior(.basedOnSize) + } + + Button { + appState.onboarding.onboardingPath.append(.pair) + } label: { + Text(L10n.Onboarding.Permissions.continue) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .padding(.horizontal) + .padding(.bottom) + } + .sensoryFeedback(.success, trigger: permissionGrantTrigger) + .onChange(of: coordinator.locationAuthorization) { _, new in + if new == .authorizedWhenInUse || new == .authorizedAlways { + permissionGrantTrigger.toggle() + } + } + .onChange(of: coordinator.notificationAuthorization) { _, new in + if new == .authorized { + permissionGrantTrigger.toggle() + } + } + .onChange(of: scenePhase) { + if scenePhase == .active { + coordinator.checkPermissions() + } } + } } #Preview { - PermissionsView() - .environment(\.appState, AppState()) + PermissionsView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/PresetStepView.swift b/MC1/Views/Onboarding/PresetStepView.swift index 93dfb649..c8063842 100644 --- a/MC1/Views/Onboarding/PresetStepView.swift +++ b/MC1/Views/Onboarding/PresetStepView.swift @@ -1,253 +1,254 @@ -import SwiftUI import MC1Services +import SwiftUI /// Onboarding step 5. Lands on the region's recommended preset when one /// exists; falls back to locale-sorted alternatives when `regionSelection` is nil. struct PresetStepView: View { - @Environment(\.appState) private var appState - - @State private var selectedID: String? - @State private var isApplying = false - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var commitTrigger = false - @State private var forceShowPicker = false - - private var region: RegionSelection? { appState.regionSelection } - - private var recommended: RadioPreset? { - guard let region else { return nil } - return RadioPresets.recommended(for: region) + @Environment(\.appState) private var appState + + @State private var selectedID: String? + @State private var isApplying = false + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var commitTrigger = false + @State private var forceShowPicker = false + + private var region: RegionSelection? { + appState.regionSelection + } + + private var recommended: RadioPreset? { + guard let region else { return nil } + return RadioPresets.recommended(for: region) + } + + private var alternatives: [RadioPreset] { + let base: [RadioPreset] = if let region, !RadioPresets.presets(for: region).isEmpty { + RadioPresets.presets(for: region) + } else { + RadioPresets.presetsForLocale() } - - private var alternatives: [RadioPreset] { - let base: [RadioPreset] - if let region, !RadioPresets.presets(for: region).isEmpty { - base = RadioPresets.presets(for: region) - } else { - base = RadioPresets.presetsForLocale() - } - return base - .filter { RadioPresets.isSelectable($0, in: region) } - .sorted { $0.name < $1.name } + return base + .filter { RadioPresets.isSelectable($0, in: region) } + .sorted { $0.name < $1.name } + } + + private var visiblePresets: [RadioPreset] { + var result: [RadioPreset] = [] + if let recommended { + result.append(recommended) } - - private var visiblePresets: [RadioPreset] { - var result: [RadioPreset] = [] - if let recommended { - result.append(recommended) - } - result.append(contentsOf: alternatives.filter { $0.id != recommended?.id }) - return result + result.append(contentsOf: alternatives.filter { $0.id != recommended?.id }) + return result + } + + private var currentDevicePreset: RadioPreset? { + guard let device = appState.connectedDevice else { return nil } + return RadioPresets.matchingPreset( + frequencyKHz: device.frequency, + bandwidthKHz: device.bandwidth, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate + ) + } + + private var alreadyConfigured: Bool { + guard !forceShowPicker, let recommended, let currentDevicePreset else { return false } + return recommended.id == currentDevicePreset.id + } + + private var canApply: Bool { + appState.services?.settingsService != nil + } + + var body: some View { + Group { + if alreadyConfigured, let recommended { + alreadyConfiguredState(preset: recommended) + } else { + pickerState + } } - - private var currentDevicePreset: RadioPreset? { - guard let device = appState.connectedDevice else { return nil } - return RadioPresets.matchingPreset( - frequencyKHz: device.frequency, - bandwidthKHz: device.bandwidth, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - } - - private var alreadyConfigured: Bool { - guard !forceShowPicker, let recommended, let currentDevicePreset else { return false } - return recommended.id == currentDevicePreset.id - } - - private var canApply: Bool { - appState.services?.settingsService != nil - } - - var body: some View { - Group { - if alreadyConfigured, let recommended { - alreadyConfiguredState(preset: recommended) - } else { - pickerState - } + .sensoryFeedback(.success, trigger: commitTrigger) + .errorAlert($errorMessage) + .retryAlert(retryAlert) + .onAppear { selectedID = recommended?.id ?? alternatives.first?.id } + } + + private func alreadyConfiguredState(preset: RadioPreset) -> some View { + VStack(spacing: OnboardingMetrics.cardSpacing) { + Spacer() + Image(systemName: "checkmark.circle.fill") + .font(.system(size: OnboardingMetrics.iconSize)) + .foregroundStyle(.tint) + Text(L10n.Onboarding.Preset.AlreadyConfigured.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) + Text(L10n.Onboarding.Preset.AlreadyConfigured.subtitle( + preset.name, + region.map { RegionalAreas.displayName(for: $0) } ?? "" + )) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Spacer() + + VStack(spacing: OnboardingMetrics.mediumSpacing) { + Button { + commitTrigger.toggle() + appState.completeOnboarding() + } label: { + Text(L10n.Onboarding.Preset.AlreadyConfigured.done) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() } - .sensoryFeedback(.success, trigger: commitTrigger) - .errorAlert($errorMessage) - .retryAlert(retryAlert) - .onAppear { selectedID = recommended?.id ?? alternatives.first?.id } - } + .liquidGlassProminentButtonStyle() - private func alreadyConfiguredState(preset: RadioPreset) -> some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - Spacer() - Image(systemName: "checkmark.circle.fill") - .font(.system(size: OnboardingMetrics.iconSize)) - .foregroundStyle(.tint) - Text(L10n.Onboarding.Preset.AlreadyConfigured.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) - Text(L10n.Onboarding.Preset.AlreadyConfigured.subtitle( - preset.name, - region.map { RegionalAreas.displayName(for: $0) } ?? "" - )) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Spacer() - - VStack(spacing: OnboardingMetrics.mediumSpacing) { - Button { - commitTrigger.toggle() - appState.completeOnboarding() - } label: { - Text(L10n.Onboarding.Preset.AlreadyConfigured.done) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - - Button(L10n.Onboarding.Preset.AlreadyConfigured.choose) { - forceShowPicker = true - } - .buttonStyle(.bordered) - .tint(.accentColor) - .frame(minHeight: OnboardingMetrics.minHitTarget) - } - .padding(.horizontal) - .padding(.bottom) + Button(L10n.Onboarding.Preset.AlreadyConfigured.choose) { + forceShowPicker = true } + .buttonStyle(.bordered) + .tint(.accentColor) + .frame(minHeight: OnboardingMetrics.minHitTarget) + } + .padding(.horizontal) + .padding(.bottom) } - - private var pickerState: some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - VStack(spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Preset.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) - if let region { - Text(L10n.Onboarding.Preset.Subtitle.recommended(RegionalAreas.displayName(for: region))) - .font(.body) - .foregroundStyle(.secondary) - } else { - Text(L10n.Onboarding.Preset.Subtitle.locale) - .font(.body) - .foregroundStyle(.secondary) - } - } - .padding(.top, OnboardingMetrics.headerTopPadding) - - ScrollView { - VStack(spacing: OnboardingMetrics.mediumSpacing) { - ForEach(visiblePresets) { preset in - rowCard(preset) - } - if visiblePresets.count > 1 { - Text( - (try? AttributedString(markdown: L10n.Onboarding.Preset.discordHelp)) - ?? AttributedString(L10n.Onboarding.Preset.discordHelp) - ) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.top, OnboardingMetrics.mediumSpacing) - } - } - .padding(.horizontal) - } - - Spacer() - - Button { - if let id = selectedID { - apply(id: id) - } - } label: { - Text(applyCTAText) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .disabled(isApplying || selectedID == nil || !canApply) - .padding(.horizontal) - .padding(.bottom) + } + + private var pickerState: some View { + VStack(spacing: OnboardingMetrics.cardSpacing) { + VStack(spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Preset.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) + if let region { + Text(L10n.Onboarding.Preset.Subtitle.recommended(RegionalAreas.displayName(for: region))) + .font(.body) + .foregroundStyle(.secondary) + } else { + Text(L10n.Onboarding.Preset.Subtitle.locale) + .font(.body) + .foregroundStyle(.secondary) } - } - - private var applyCTAText: String { - guard let preset = alternatives.first(where: { $0.id == selectedID }) ?? recommended else { - return L10n.Onboarding.Preset.continue + } + .padding(.top, OnboardingMetrics.headerTopPadding) + + ScrollView { + VStack(spacing: OnboardingMetrics.mediumSpacing) { + ForEach(visiblePresets) { preset in + rowCard(preset) + } + if visiblePresets.count > 1 { + Text( + (try? AttributedString(markdown: L10n.Onboarding.Preset.discordHelp)) + ?? AttributedString(L10n.Onboarding.Preset.discordHelp) + ) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.top, OnboardingMetrics.mediumSpacing) + } } - return L10n.Onboarding.Preset.use(preset.name) - } + .padding(.horizontal) + } - private func rowCard(_ preset: RadioPreset) -> some View { - Button { - selectedID = preset.id - } label: { - HStack { - VStack(alignment: .leading, spacing: OnboardingMetrics.compactSpacing) { - Text(preset.name) - .font(.body) - Text("\(preset.frequencyMHz, format: .number.precision(.fractionLength(3)).locale(.posix)) MHz") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if selectedID == preset.id { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - .padding() - .frame(maxWidth: .infinity, minHeight: OnboardingMetrics.minHitTarget) - .contentShape(.rect) + Spacer() + + Button { + if let id = selectedID { + apply(id: id) } - .buttonStyle(.plain) - .accessibilityElement(children: .combine) - .accessibilityHint(L10n.Onboarding.Preset.Row.accessibilityHint) + } label: { + Text(applyCTAText) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .disabled(isApplying || selectedID == nil || !canApply) + .padding(.horizontal) + .padding(.bottom) } + } - private func apply(id: String) { - guard let preset = alternatives.first(where: { $0.id == id }) ?? recommended else { return } - // Mock device has no radio to configure. - if appState.connectedDevice?.id == MockDataProvider.simulatorDeviceID { - commitTrigger.toggle() - appState.completeOnboarding() - return - } - guard let settingsService = appState.services?.settingsService else { - // Defensive: CTA is disabled when services is nil, but if reconnect ends mid-tap - // we surface the error rather than swallowing it silently. - errorMessage = L10n.Onboarding.Preset.Error.notConnected - return + private var applyCTAText: String { + guard let preset = alternatives.first(where: { $0.id == selectedID }) ?? recommended else { + return L10n.Onboarding.Preset.continue + } + return L10n.Onboarding.Preset.use(preset.name) + } + + private func rowCard(_ preset: RadioPreset) -> some View { + Button { + selectedID = preset.id + } label: { + HStack { + VStack(alignment: .leading, spacing: OnboardingMetrics.compactSpacing) { + Text(preset.name) + .font(.body) + Text("\(preset.frequencyMHz, format: .number.precision(.fractionLength(3)).locale(.posix)) MHz") + .font(.caption) + .foregroundStyle(.secondary) } - isApplying = true - Task { - do { - _ = try await settingsService.applyRadioPresetVerified(preset) - retryAlert.reset() - commitTrigger.toggle() - appState.completeOnboarding() - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { apply(id: id) }, - onMaxRetriesExceeded: { - errorMessage = L10n.Settings.Alert.Retry.fallbackMessage - } - ) - } catch { - errorMessage = error.userFacingMessage - } - isApplying = false + Spacer() + if selectedID == preset.id { + Image(systemName: "checkmark") + .foregroundStyle(.tint) } + } + .padding() + .frame(maxWidth: .infinity, minHeight: OnboardingMetrics.minHitTarget) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityHint(L10n.Onboarding.Preset.Row.accessibilityHint) + } + + private func apply(id: String) { + guard let preset = alternatives.first(where: { $0.id == id }) ?? recommended else { return } + // Mock device has no radio to configure. + if appState.connectedDevice?.id == MockDataProvider.simulatorDeviceID { + commitTrigger.toggle() + appState.completeOnboarding() + return + } + guard let settingsService = appState.services?.settingsService else { + // Defensive: CTA is disabled when services is nil, but if reconnect ends mid-tap + // we surface the error rather than swallowing it silently. + errorMessage = L10n.Onboarding.Preset.Error.notConnected + return + } + isApplying = true + Task { + do { + _ = try await settingsService.applyRadioPresetVerified(preset) + retryAlert.reset() + commitTrigger.toggle() + appState.completeOnboarding() + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { apply(id: id) }, + onMaxRetriesExceeded: { + errorMessage = L10n.Settings.Alert.Retry.fallbackMessage + } + ) + } catch { + errorMessage = error.userFacingMessage + } + isApplying = false } + } } #Preview { - PresetStepView() - .environment(\.appState, AppState()) + PresetStepView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/PulsingAntenna.swift b/MC1/Views/Onboarding/PulsingAntenna.swift index ef110e0c..2cfba623 100644 --- a/MC1/Views/Onboarding/PulsingAntenna.swift +++ b/MC1/Views/Onboarding/PulsingAntenna.swift @@ -3,14 +3,14 @@ import SwiftUI /// Hero icon for the Pair onboarding step. Pulses by default; honors /// `accessibilityReduceMotion` by rendering statically. struct PulsingAntenna: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceMotion) private var reduceMotion - var body: some View { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: OnboardingMetrics.heroSize / 2)) - .foregroundStyle(.tint) - .frame(height: OnboardingMetrics.heroSize) - .symbolEffect(.pulse, isActive: !reduceMotion) - .accessibilityHidden(true) - } + var body: some View { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: OnboardingMetrics.heroSize / 2)) + .foregroundStyle(.tint) + .frame(height: OnboardingMetrics.heroSize) + .symbolEffect(.pulse, isActive: !reduceMotion) + .accessibilityHidden(true) + } } diff --git a/MC1/Views/Onboarding/RegionPickerView.swift b/MC1/Views/Onboarding/RegionPickerView.swift index 163c5f4f..118fbe1a 100644 --- a/MC1/Views/Onboarding/RegionPickerView.swift +++ b/MC1/Views/Onboarding/RegionPickerView.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Country + state/province picker used in onboarding step 4 and Settings → Region. /// Hides the State/Province row for countries with no sub-region presets. @@ -9,144 +9,149 @@ import MC1Services /// own "Continue" CTA below the picker for navigation; Settings relies on the /// system back chevron and the inline write. struct RegionPickerView: View { - @Binding var selection: RegionSelection? - - @State private var showingCountrySheet = false - @State private var showingSubdivisionSheet = false - - private static let emptyValue = "—" - - private var country: String? { selection?.countryCode } - private var subdivision: String? { selection?.administrativeAreaCode } - - private var availableSubdivisions: [RegionalAreas.Subdivision] { - RegionalAreas.subdivisions(for: country) - } - - var body: some View { - Form { - Section { - Button { - showingCountrySheet = true - } label: { - LabeledContent(L10n.Onboarding.Region.country) { - Text(countryDisplay) - } - } - // Hide single-entry pickers — a "pick" with one option is dead-end UX. - if availableSubdivisions.count > 1 { - Button { - showingSubdivisionSheet = true - } label: { - LabeledContent(L10n.Onboarding.Region.administrativeArea) { - Text(subdivisionDisplay) - } - } - } - } - } - .sheet(isPresented: $showingCountrySheet) { - CountryPickerSheet(selectedCountry: country) { newCountry in - selectCountry(newCountry) - } + @Binding var selection: RegionSelection? + + @State private var showingCountrySheet = false + @State private var showingSubdivisionSheet = false + + private static let emptyValue = "—" + + private var country: String? { + selection?.countryCode + } + + private var subdivision: String? { + selection?.administrativeAreaCode + } + + private var availableSubdivisions: [RegionalAreas.Subdivision] { + RegionalAreas.subdivisions(for: country) + } + + var body: some View { + Form { + Section { + Button { + showingCountrySheet = true + } label: { + LabeledContent(L10n.Onboarding.Region.country) { + Text(countryDisplay) + } } - .sheet(isPresented: $showingSubdivisionSheet) { - SubdivisionPickerSheet(country: country, selectedSubdivision: subdivision) { newSubdivision in - selectSubdivision(newSubdivision) + // Hide single-entry pickers — a "pick" with one option is dead-end UX. + if availableSubdivisions.count > 1 { + Button { + showingSubdivisionSheet = true + } label: { + LabeledContent(L10n.Onboarding.Region.administrativeArea) { + Text(subdivisionDisplay) } + } } + } } - - private var countryDisplay: String { - guard let country else { return Self.emptyValue } - return Locale.current.localizedString(forRegionCode: country) ?? country - } - - private var subdivisionDisplay: String { - guard let subdivision else { return Self.emptyValue } - return RegionalAreas.subdivisionDisplayName(subdivision) ?? subdivision - } - - private func selectCountry(_ newCountry: String) { - // Drop subdivision when country changes so a stale id (e.g. "US-CA") - // can't ride on a new country (e.g. "CA") into the persisted selection. - selection = RegionSelection( - countryCode: newCountry, - administrativeAreaCode: nil, - countyKey: nil, - source: .manual - ) + .sheet(isPresented: $showingCountrySheet) { + CountryPickerSheet(selectedCountry: country) { newCountry in + selectCountry(newCountry) + } } - - private func selectSubdivision(_ newSubdivision: String?) { - guard let country else { return } - selection = RegionSelection( - countryCode: country, - administrativeAreaCode: newSubdivision, - countyKey: nil, - source: .manual - ) + .sheet(isPresented: $showingSubdivisionSheet) { + SubdivisionPickerSheet(country: country, selectedSubdivision: subdivision) { newSubdivision in + selectSubdivision(newSubdivision) + } } + } + + private var countryDisplay: String { + guard let country else { return Self.emptyValue } + return Locale.current.localizedString(forRegionCode: country) ?? country + } + + private var subdivisionDisplay: String { + guard let subdivision else { return Self.emptyValue } + return RegionalAreas.subdivisionDisplayName(subdivision) ?? subdivision + } + + private func selectCountry(_ newCountry: String) { + // Drop subdivision when country changes so a stale id (e.g. "US-CA") + // can't ride on a new country (e.g. "CA") into the persisted selection. + selection = RegionSelection( + countryCode: newCountry, + administrativeAreaCode: nil, + countyKey: nil, + source: .manual + ) + } + + private func selectSubdivision(_ newSubdivision: String?) { + guard let country else { return } + selection = RegionSelection( + countryCode: country, + administrativeAreaCode: newSubdivision, + countyKey: nil, + source: .manual + ) + } } private struct CountryPickerSheet: View { - @Environment(\.dismiss) private var dismiss - let selectedCountry: String? - let onSelect: (String) -> Void - - @State private var search = "" - - private var filtered: [RegionalAreas.Country] { - let all = RegionalAreas.countriesSortedByLocalizedName - guard !search.isEmpty else { return all } - return all.filter { $0.localizedName.localizedStandardContains(search) } - } - - var body: some View { - NavigationStack { - List(filtered) { entry in - Button { - onSelect(entry.id) - dismiss() - } label: { - HStack { - Text(entry.localizedName) - Spacer() - if entry.id == selectedCountry { Image(systemName: "checkmark") } - } - } - } - .searchable(text: $search) - .navigationTitle(L10n.Onboarding.Region.country) + @Environment(\.dismiss) private var dismiss + let selectedCountry: String? + let onSelect: (String) -> Void + + @State private var search = "" + + private var filtered: [RegionalAreas.Country] { + let all = RegionalAreas.countriesSortedByLocalizedName + guard !search.isEmpty else { return all } + return all.filter { $0.localizedName.localizedStandardContains(search) } + } + + var body: some View { + NavigationStack { + List(filtered) { entry in + Button { + onSelect(entry.id) + dismiss() + } label: { + HStack { + Text(entry.localizedName) + Spacer() + if entry.id == selectedCountry { Image(systemName: "checkmark") } + } } + } + .searchable(text: $search) + .navigationTitle(L10n.Onboarding.Region.country) } + } } private struct SubdivisionPickerSheet: View { - @Environment(\.dismiss) private var dismiss - let country: String? - let selectedSubdivision: String? - let onSelect: (String) -> Void - - private var rows: [RegionalAreas.Subdivision] { - RegionalAreas.subdivisions(for: country) - } - - var body: some View { - NavigationStack { - List(rows) { row in - Button { - onSelect(row.id) - dismiss() - } label: { - HStack { - Text(RegionalAreas.subdivisionDisplayName(row.id) ?? row.id) - Spacer() - if row.id == selectedSubdivision { Image(systemName: "checkmark") } - } - } - } - .navigationTitle(L10n.Onboarding.Region.administrativeArea) + @Environment(\.dismiss) private var dismiss + let country: String? + let selectedSubdivision: String? + let onSelect: (String) -> Void + + private var rows: [RegionalAreas.Subdivision] { + RegionalAreas.subdivisions(for: country) + } + + var body: some View { + NavigationStack { + List(rows) { row in + Button { + onSelect(row.id) + dismiss() + } label: { + HStack { + Text(RegionalAreas.subdivisionDisplayName(row.id) ?? row.id) + Spacer() + if row.id == selectedSubdivision { Image(systemName: "checkmark") } + } } + } + .navigationTitle(L10n.Onboarding.Region.administrativeArea) } + } } diff --git a/MC1/Views/Onboarding/RegionStepView.swift b/MC1/Views/Onboarding/RegionStepView.swift index 07095a22..6a500ed8 100644 --- a/MC1/Views/Onboarding/RegionStepView.swift +++ b/MC1/Views/Onboarding/RegionStepView.swift @@ -1,186 +1,185 @@ -import SwiftUI import MC1Services +import SwiftUI /// Onboarding step 4. Resolves region from location when authorized; falls /// silently to the manual picker on any failure (denied, timeout, no network). struct RegionStepView: View { - @Environment(\.appState) private var appState - - @State private var resolved: RegionSelection? - @State private var isResolving = true - @State private var showManualPicker = false - @State private var manualSelection: RegionSelection? - @State private var commitTrigger = false - @State private var resolveAttempt = 0 - @State private var pendingUserRetry = false - @State private var errorMessage: String? - - private var locationGranted: Bool { - appState.locationService.isAuthorized + @Environment(\.appState) private var appState + + @State private var resolved: RegionSelection? + @State private var isResolving = true + @State private var showManualPicker = false + @State private var manualSelection: RegionSelection? + @State private var commitTrigger = false + @State private var resolveAttempt = 0 + @State private var pendingUserRetry = false + @State private var errorMessage: String? + + private var locationGranted: Bool { + appState.locationService.isAuthorized + } + + var body: some View { + Group { + if showManualPicker || !locationGranted { + manualPickerState + } else if let resolved { + detectedState(region: resolved) + } else if isResolving { + resolvingState + } else { + manualPickerState + } } - - var body: some View { - Group { - if showManualPicker || !locationGranted { - manualPickerState - } else if let resolved { - detectedState(region: resolved) - } else if isResolving { - resolvingState - } else { - manualPickerState - } - } - .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) - .sensoryFeedback(.success, trigger: commitTrigger) - .errorAlert($errorMessage) - .task(id: resolveAttempt) { - guard locationGranted, !showManualPicker else { - isResolving = false - return - } - isResolving = true - resolved = await appState.regionResolver.resolve() - isResolving = false - let wasUserRetry = pendingUserRetry - pendingUserRetry = false - if resolved == nil { - if wasUserRetry { - errorMessage = L10n.Onboarding.Region.UseMyLocation.failure - } - showManualPicker = true - } - } - .onChange(of: locationGranted) { _, _ in - // Permission flipped (user granted/revoked from Settings); re-fire the resolver. - resolveAttempt += 1 + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .sensoryFeedback(.success, trigger: commitTrigger) + .errorAlert($errorMessage) + .task(id: resolveAttempt) { + guard locationGranted, !showManualPicker else { + isResolving = false + return + } + isResolving = true + resolved = await appState.regionResolver.resolve() + isResolving = false + let wasUserRetry = pendingUserRetry + pendingUserRetry = false + if resolved == nil { + if wasUserRetry { + errorMessage = L10n.Onboarding.Region.UseMyLocation.failure } + showManualPicker = true + } } - - private var resolvingState: some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - Spacer() - ProgressView().controlSize(.large) - Text(L10n.Onboarding.Region.resolving) - .font(.body) - .foregroundStyle(.secondary) - Spacer() - } + .onChange(of: locationGranted) { _, _ in + // Permission flipped (user granted/revoked from Settings); re-fire the resolver. + resolveAttempt += 1 } - - private func detectedState(region: RegionSelection) -> some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - VStack(spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Region.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) - Text(L10n.Onboarding.Region.subtitle) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding(.top, OnboardingMetrics.headerTopPadding) - - Spacer() - - VStack(spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Region.Detected.tag) - .font(.caption.weight(.medium)) - .foregroundStyle(.tint) - - Text(RegionalAreas.displayName(for: region)) - .font(.title2.weight(.semibold)) - - Text(L10n.Onboarding.Region.Detected.source) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(OnboardingMetrics.contentPadding) - .frame(maxWidth: .infinity) - .liquidGlass(in: .rect(cornerRadius: OnboardingMetrics.cardCornerRadius)) - .padding(.horizontal) - .accessibilityElement(children: .combine) - - Button(L10n.Onboarding.Region.chooseAnother) { - showManualPicker = true - } - .font(.subheadline) - .buttonStyle(.bordered) - .tint(.accentColor) - .frame(minHeight: OnboardingMetrics.minHitTarget) - - Spacer() - - Button { - appState.regionSelection = region - commitTrigger.toggle() - appState.onboarding.onboardingPath.append(.preset) - } label: { - Text(L10n.Onboarding.Region.useThisRegion) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .padding(.horizontal) - .padding(.bottom) - } + } + + private var resolvingState: some View { + VStack(spacing: OnboardingMetrics.cardSpacing) { + Spacer() + ProgressView().controlSize(.large) + Text(L10n.Onboarding.Region.resolving) + .font(.body) + .foregroundStyle(.secondary) + Spacer() } - - private var manualPickerState: some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - VStack(spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Region.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) - Text(L10n.Onboarding.Region.subtitle) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding(.top, OnboardingMetrics.headerTopPadding) - - RegionPickerView(selection: $manualSelection) - - if locationGranted { - Button(L10n.Onboarding.Region.useMyLocation) { - // Clear any prior resolve so the view shows the spinner while the - // re-resolve runs, instead of flashing the previous detected region. - resolved = nil - showManualPicker = false - isResolving = true - pendingUserRetry = true - resolveAttempt += 1 - } - .font(.subheadline) - .foregroundStyle(.tint) - .frame(minHeight: OnboardingMetrics.minHitTarget) - } - - Button { - guard let manualSelection else { return } - appState.regionSelection = manualSelection - commitTrigger.toggle() - appState.onboarding.onboardingPath.append(.preset) - } label: { - Text(L10n.Onboarding.Region.continue) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .disabled(manualSelection == nil) - .padding(.horizontal) - .padding(.bottom) + } + + private func detectedState(region: RegionSelection) -> some View { + VStack(spacing: OnboardingMetrics.cardSpacing) { + VStack(spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Region.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) + Text(L10n.Onboarding.Region.subtitle) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, OnboardingMetrics.headerTopPadding) + + Spacer() + + VStack(spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Region.Detected.tag) + .font(.caption.weight(.medium)) + .foregroundStyle(.tint) + + Text(RegionalAreas.displayName(for: region)) + .font(.title2.weight(.semibold)) + + Text(L10n.Onboarding.Region.Detected.source) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(OnboardingMetrics.contentPadding) + .frame(maxWidth: .infinity) + .liquidGlass(in: .rect(cornerRadius: OnboardingMetrics.cardCornerRadius)) + .padding(.horizontal) + .accessibilityElement(children: .combine) + + Button(L10n.Onboarding.Region.chooseAnother) { + showManualPicker = true + } + .font(.subheadline) + .buttonStyle(.bordered) + .tint(.accentColor) + .frame(minHeight: OnboardingMetrics.minHitTarget) + + Spacer() + + Button { + appState.regionSelection = region + commitTrigger.toggle() + appState.onboarding.onboardingPath.append(.preset) + } label: { + Text(L10n.Onboarding.Region.useThisRegion) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .padding(.horizontal) + .padding(.bottom) + } + } + + private var manualPickerState: some View { + VStack(spacing: OnboardingMetrics.cardSpacing) { + VStack(spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Region.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) + Text(L10n.Onboarding.Region.subtitle) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, OnboardingMetrics.headerTopPadding) + + RegionPickerView(selection: $manualSelection) + + if locationGranted { + Button(L10n.Onboarding.Region.useMyLocation) { + // Clear any prior resolve so the view shows the spinner while the + // re-resolve runs, instead of flashing the previous detected region. + resolved = nil + showManualPicker = false + isResolving = true + pendingUserRetry = true + resolveAttempt += 1 } + .font(.subheadline) + .foregroundStyle(.tint) + .frame(minHeight: OnboardingMetrics.minHitTarget) + } + + Button { + guard let manualSelection else { return } + appState.regionSelection = manualSelection + commitTrigger.toggle() + appState.onboarding.onboardingPath.append(.preset) + } label: { + Text(L10n.Onboarding.Region.continue) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .disabled(manualSelection == nil) + .padding(.horizontal) + .padding(.bottom) } - + } } #Preview { - RegionStepView() - .environment(\.appState, AppState()) + RegionStepView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/TroubleshootingSheet.swift b/MC1/Views/Onboarding/TroubleshootingSheet.swift index 3e233801..02efd030 100644 --- a/MC1/Views/Onboarding/TroubleshootingSheet.swift +++ b/MC1/Views/Onboarding/TroubleshootingSheet.swift @@ -1,114 +1,114 @@ -import SwiftUI import MC1Services +import SwiftUI import UIKit /// Troubleshooting sheet for when devices don't appear in the ASK picker /// Per Apple Developer Forums: Factory-reset devices won't appear until the stale /// system pairing is removed via removeAccessory() struct TroubleshootingSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.openURL) private var openURL - @State private var isClearing = false - - var body: some View { - NavigationStack { - List { - Section { - Label(L10n.Onboarding.Troubleshooting.BasicChecks.powerOn, systemImage: "power") - Label(L10n.Onboarding.Troubleshooting.BasicChecks.moveCloser, systemImage: "iphone.radiowaves.left.and.right") - Label(L10n.Onboarding.Troubleshooting.BasicChecks.notConnectedElsewhere, systemImage: "app.dashed") - Label(L10n.Onboarding.Troubleshooting.BasicChecks.restart, systemImage: "arrow.clockwise") - } header: { - Text(L10n.Onboarding.Troubleshooting.BasicChecks.header) - } - - Section { - VStack(alignment: .leading, spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Troubleshooting.FactoryReset.explanation) - .font(.subheadline) - .foregroundStyle(.secondary) + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @State private var isClearing = false - Text(L10n.Onboarding.Troubleshooting.FactoryReset.confirmationNote) - .font(.caption) - .foregroundStyle(.secondary) + var body: some View { + NavigationStack { + List { + Section { + Label(L10n.Onboarding.Troubleshooting.BasicChecks.powerOn, systemImage: "power") + Label(L10n.Onboarding.Troubleshooting.BasicChecks.moveCloser, systemImage: "iphone.radiowaves.left.and.right") + Label(L10n.Onboarding.Troubleshooting.BasicChecks.notConnectedElsewhere, systemImage: "app.dashed") + Label(L10n.Onboarding.Troubleshooting.BasicChecks.restart, systemImage: "arrow.clockwise") + } header: { + Text(L10n.Onboarding.Troubleshooting.BasicChecks.header) + } - Button { - clearStalePairings() - } label: { - HStack { - if isClearing { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "trash") - } - Text(L10n.Onboarding.Troubleshooting.FactoryReset.clearPairing) - } - } - .disabled(isClearing || appState.connectionManager.pairedAccessoriesCount == 0) - } - } header: { - Text(L10n.Onboarding.Troubleshooting.FactoryReset.header) - } footer: { - if appState.connectionManager.pairedAccessoriesCount == 0 { - Text(L10n.Onboarding.Troubleshooting.FactoryReset.noPairings) - } else { - Text(L10n.Onboarding.Troubleshooting.FactoryReset.pairingsFound(appState.connectionManager.pairedAccessoriesCount)) - } - } + Section { + VStack(alignment: .leading, spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Troubleshooting.FactoryReset.explanation) + .font(.subheadline) + .foregroundStyle(.secondary) - Section { - VStack(alignment: .leading, spacing: OnboardingMetrics.titleStackSpacing) { - Text(L10n.Onboarding.Troubleshooting.SystemSettings.manageAccessories) - .font(.subheadline) - Text(L10n.Onboarding.Troubleshooting.SystemSettings.path) - .font(.subheadline) - .foregroundStyle(.secondary) - Button { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } label: { - Label( - L10n.Onboarding.Troubleshooting.SystemSettings.openSettings, - systemImage: "gear" - ) - } - } - } header: { - Text(L10n.Onboarding.Troubleshooting.SystemSettings.header) - } + Text(L10n.Onboarding.Troubleshooting.FactoryReset.confirmationNote) + .font(.caption) + .foregroundStyle(.secondary) - Section { - Text(L10n.Onboarding.Troubleshooting.StillNotAppearing.body) - .font(.subheadline) - .foregroundStyle(.secondary) - } header: { - Text(L10n.Onboarding.Troubleshooting.StillNotAppearing.header) + Button { + clearStalePairings() + } label: { + HStack { + if isClearing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "trash") } + Text(L10n.Onboarding.Troubleshooting.FactoryReset.clearPairing) + } } - .navigationTitle(L10n.Onboarding.Troubleshooting.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(L10n.Localizable.Common.done) { - dismiss() - } - } + .disabled(isClearing || appState.connectionManager.pairedAccessoriesCount == 0) + } + } header: { + Text(L10n.Onboarding.Troubleshooting.FactoryReset.header) + } footer: { + if appState.connectionManager.pairedAccessoriesCount == 0 { + Text(L10n.Onboarding.Troubleshooting.FactoryReset.noPairings) + } else { + Text(L10n.Onboarding.Troubleshooting.FactoryReset.pairingsFound(appState.connectionManager.pairedAccessoriesCount)) + } + } + + Section { + VStack(alignment: .leading, spacing: OnboardingMetrics.titleStackSpacing) { + Text(L10n.Onboarding.Troubleshooting.SystemSettings.manageAccessories) + .font(.subheadline) + Text(L10n.Onboarding.Troubleshooting.SystemSettings.path) + .font(.subheadline) + .foregroundStyle(.secondary) + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } label: { + Label( + L10n.Onboarding.Troubleshooting.SystemSettings.openSettings, + systemImage: "gear" + ) } + } + } header: { + Text(L10n.Onboarding.Troubleshooting.SystemSettings.header) + } + + Section { + Text(L10n.Onboarding.Troubleshooting.StillNotAppearing.body) + .font(.subheadline) + .foregroundStyle(.secondary) + } header: { + Text(L10n.Onboarding.Troubleshooting.StillNotAppearing.header) } + } + .navigationTitle(L10n.Onboarding.Troubleshooting.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(L10n.Localizable.Common.done) { + dismiss() + } + } + } } + } - private func clearStalePairings() { - isClearing = true + private func clearStalePairings() { + isClearing = true - Task { - defer { isClearing = false } + Task { + defer { isClearing = false } - await appState.connectionManager.clearStalePairings() + await appState.connectionManager.clearStalePairings() - dismiss() - } + dismiss() } + } } diff --git a/MC1/Views/Onboarding/WelcomeView.swift b/MC1/Views/Onboarding/WelcomeView.swift index 81a15020..a3217e17 100644 --- a/MC1/Views/Onboarding/WelcomeView.swift +++ b/MC1/Views/Onboarding/WelcomeView.swift @@ -1,47 +1,47 @@ import SwiftUI struct WelcomeView: View { - @Environment(\.appState) private var appState - - var body: some View { - VStack(spacing: OnboardingMetrics.cardSpacing * 2) { - Spacer() - - MeshAnimationView() - .frame(height: OnboardingMetrics.heroSize) - .padding(.horizontal) - - VStack(spacing: 12) { - Text(L10n.Onboarding.Welcome.title) - .font(.largeTitle) - .bold() - .accessibilityHeading(.h1) - - Text(L10n.Onboarding.Welcome.subtitle) - .font(.title3) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - Spacer() - - Button { - appState.onboarding.onboardingPath.append(.permissions) - } label: { - Text(L10n.Onboarding.Welcome.getStarted) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .padding(.horizontal) - .padding(.bottom) - } + @Environment(\.appState) private var appState + + var body: some View { + VStack(spacing: OnboardingMetrics.cardSpacing * 2) { + Spacer() + + MeshAnimationView() + .frame(height: OnboardingMetrics.heroSize) + .padding(.horizontal) + + VStack(spacing: 12) { + Text(L10n.Onboarding.Welcome.title) + .font(.largeTitle) + .bold() + .accessibilityHeading(.h1) + + Text(L10n.Onboarding.Welcome.subtitle) + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + Spacer() + + Button { + appState.onboarding.onboardingPath.append(.permissions) + } label: { + Text(L10n.Onboarding.Welcome.getStarted) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .padding(.horizontal) + .padding(.bottom) } + } } #Preview { - WelcomeView() - .environment(\.appState, AppState()) + WelcomeView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Onboarding/WiFiConnectionSheet.swift b/MC1/Views/Onboarding/WiFiConnectionSheet.swift index 944727e4..b510c58a 100644 --- a/MC1/Views/Onboarding/WiFiConnectionSheet.swift +++ b/MC1/Views/Onboarding/WiFiConnectionSheet.swift @@ -1,6 +1,6 @@ +import MC1Services import Network import SwiftUI -import MC1Services // MARK: - Local Network Permission Trigger @@ -12,163 +12,162 @@ import MC1Services /// /// Based on Apple's TN3179: Understanding local network privacy. private func triggerLocalNetworkPrivacyAlert() { - let addresses = selectedLinkLocalIPv6Addresses() - for address in addresses { - let sock6 = socket(AF_INET6, SOCK_DGRAM, 0) - guard sock6 >= 0 else { return } - defer { close(sock6) } - - withUnsafePointer(to: address) { sa6 in - sa6.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - _ = connect(sock6, sa, socklen_t(sa.pointee.sa_len)) >= 0 - } - } + let addresses = selectedLinkLocalIPv6Addresses() + for address in addresses { + let sock6 = socket(AF_INET6, SOCK_DGRAM, 0) + guard sock6 >= 0 else { return } + defer { close(sock6) } + + withUnsafePointer(to: address) { sa6 in + sa6.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + _ = connect(sock6, sa, socklen_t(sa.pointee.sa_len)) >= 0 + } } + } } /// Returns a selection of IPv6 addresses to connect to. private func selectedLinkLocalIPv6Addresses() -> [sockaddr_in6] { - let r1 = (0..<8).map { _ in UInt8.random(in: 0...255) } - let r2 = (0..<8).map { _ in UInt8.random(in: 0...255) } - return Array(ipv6AddressesOfBroadcastCapableInterfaces() - .filter { isIPv6AddressLinkLocal($0) } - .map { var addr = $0; addr.sin6_port = UInt16(9).bigEndian; return addr } - .map { [setIPv6LinkLocalAddressHostPart(of: $0, to: r1), setIPv6LinkLocalAddressHostPart(of: $0, to: r2)] } - .joined()) + let r1 = (0..<8).map { _ in UInt8.random(in: 0...255) } + let r2 = (0..<8).map { _ in UInt8.random(in: 0...255) } + return Array(ipv6AddressesOfBroadcastCapableInterfaces() + .filter { isIPv6AddressLinkLocal($0) } + .map { var addr = $0; addr.sin6_port = UInt16(9).bigEndian; return addr } + .map { [setIPv6LinkLocalAddressHostPart(of: $0, to: r1), setIPv6LinkLocalAddressHostPart(of: $0, to: r2)] } + .joined()) } private func setIPv6LinkLocalAddressHostPart(of address: sockaddr_in6, to hostPart: [UInt8]) -> sockaddr_in6 { - precondition(hostPart.count == 8) - var result = address - withUnsafeMutableBytes(of: &result.sin6_addr) { buf in - buf[8...].copyBytes(from: hostPart) - } - return result + precondition(hostPart.count == 8) + var result = address + withUnsafeMutableBytes(of: &result.sin6_addr) { buf in + buf[8...].copyBytes(from: hostPart) + } + return result } private func isIPv6AddressLinkLocal(_ address: sockaddr_in6) -> Bool { - address.sin6_addr.__u6_addr.__u6_addr8.0 == 0xfe - && (address.sin6_addr.__u6_addr.__u6_addr8.1 & 0xc0) == 0x80 + address.sin6_addr.__u6_addr.__u6_addr8.0 == 0xFE + && (address.sin6_addr.__u6_addr.__u6_addr8.1 & 0xC0) == 0x80 } private func ipv6AddressesOfBroadcastCapableInterfaces() -> [sockaddr_in6] { - var addrList: UnsafeMutablePointer? - let err = getifaddrs(&addrList) - guard err == 0, let start = addrList else { return [] } - defer { freeifaddrs(start) } - return sequence(first: start, next: { $0.pointee.ifa_next }) - .compactMap { i -> sockaddr_in6? in - guard - (i.pointee.ifa_flags & UInt32(bitPattern: IFF_BROADCAST)) != 0, - let sa = i.pointee.ifa_addr, - sa.pointee.sa_family == AF_INET6, - sa.pointee.sa_len >= MemoryLayout.size - else { return nil } - return UnsafeRawPointer(sa).load(as: sockaddr_in6.self) - } + var addrList: UnsafeMutablePointer? + let err = getifaddrs(&addrList) + guard err == 0, let start = addrList else { return [] } + defer { freeifaddrs(start) } + return sequence(first: start, next: { $0.pointee.ifa_next }) + .compactMap { i -> sockaddr_in6? in + guard + (i.pointee.ifa_flags & UInt32(bitPattern: IFF_BROADCAST)) != 0, + let sa = i.pointee.ifa_addr, + sa.pointee.sa_family == AF_INET6, + sa.pointee.sa_len >= MemoryLayout.size + else { return nil } + return UnsafeRawPointer(sa).load(as: sockaddr_in6.self) + } } /// Sheet for entering WiFi connection details (IP address and port). struct WiFiConnectionSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - @State private var ipAddress = "" - @State private var port = "5000" - @State private var isConnecting = false - @State private var errorMessage: String? - - @FocusState private var focusedField: WiFiField? - - private var isValidInput: Bool { - WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) - } - - private var usesFullKeyboardInput: Bool { - horizontalSizeClass == .regular - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @State private var ipAddress = "" + @State private var port = "5000" + @State private var isConnecting = false + @State private var errorMessage: String? + + @FocusState private var focusedField: WiFiField? + + private var isValidInput: Bool { + WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) + } + + private var usesFullKeyboardInput: Bool { + horizontalSizeClass == .regular + } + + var body: some View { + NavigationStack { + Form { + WiFiAddressFields( + ipAddress: $ipAddress, + port: $port, + focusedField: $focusedField, + sectionHeader: L10n.Onboarding.WifiConnection.ConnectionDetails.header, + sectionFooter: L10n.Onboarding.WifiConnection.ConnectionDetails.footer, + onPortSubmit: { connect() } + ) + + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } - var body: some View { - NavigationStack { - Form { - WiFiAddressFields( - ipAddress: $ipAddress, - port: $port, - focusedField: $focusedField, - sectionHeader: L10n.Onboarding.WifiConnection.ConnectionDetails.header, - sectionFooter: L10n.Onboarding.WifiConnection.ConnectionDetails.footer, - onPortSubmit: { connect() } - ) - - if let errorMessage { - Section { - Label(errorMessage, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - } - } - - Section { - Button { - connect() - } label: { - HStack { - Spacer() - if isConnecting { - ProgressView() - .controlSize(.small) - Text(L10n.Onboarding.WifiConnection.connecting) - } else { - Text(L10n.Onboarding.WifiConnection.connect) - } - Spacer() - } - } - .disabled(!isValidInput || isConnecting) - } - } - .navigationTitle(L10n.Onboarding.WifiConnection.title) - .navigationBarTitleDisplayMode(.inline) - .wifiSheetToolbar(focusedField: $focusedField, isProcessing: isConnecting) - .interactiveDismissDisabled(isConnecting) - .onAppear { - if !usesFullKeyboardInput { - focusedField = .ipAddress - } - triggerLocalNetworkPrivacyAlert() + Section { + Button { + connect() + } label: { + HStack { + Spacer() + if isConnecting { + ProgressView() + .controlSize(.small) + Text(L10n.Onboarding.WifiConnection.connecting) + } else { + Text(L10n.Onboarding.WifiConnection.connect) + } + Spacer() } + } + .disabled(!isValidInput || isConnecting) + } + } + .navigationTitle(L10n.Onboarding.WifiConnection.title) + .navigationBarTitleDisplayMode(.inline) + .wifiSheetToolbar(focusedField: $focusedField, isProcessing: isConnecting) + .interactiveDismissDisabled(isConnecting) + .onAppear { + if !usesFullKeyboardInput { + focusedField = .ipAddress } - .presentationSizing(.page) + triggerLocalNetworkPrivacyAlert() + } } + .presentationSizing(.page) + } - private func connect() { - focusedField = nil + private func connect() { + focusedField = nil - guard let portNumber = UInt16(port) else { - errorMessage = L10n.Onboarding.WifiConnection.Error.invalidPort - return - } - - isConnecting = true - errorMessage = nil - - Task { - do { - try await appState.connectViaWiFi(host: ipAddress, port: portNumber, forceFullSync: true) - await appState.wireServicesIfConnected() - dismiss() - // Navigate directly to radio settings - appState.onboarding.onboardingPath.append(.region) - } catch { - errorMessage = error.userFacingMessage - isConnecting = false - } - } + guard let portNumber = UInt16(port) else { + errorMessage = L10n.Onboarding.WifiConnection.Error.invalidPort + return } + isConnecting = true + errorMessage = nil + + Task { + do { + try await appState.connectViaWiFi(host: ipAddress, port: portNumber, forceFullSync: true) + await appState.wireServicesIfConnected() + dismiss() + // Navigate directly to radio settings + appState.onboarding.onboardingPath.append(.region) + } catch { + errorMessage = error.userFacingMessage + isConnecting = false + } + } + } } #Preview { - WiFiConnectionSheet() - .environment(\.appState, AppState()) + WiFiConnectionSheet() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/PathEditing/AddHopPickerPresentation.swift b/MC1/Views/PathEditing/AddHopPickerPresentation.swift index 10de00e7..d43283db 100644 --- a/MC1/Views/PathEditing/AddHopPickerPresentation.swift +++ b/MC1/Views/PathEditing/AddHopPickerPresentation.swift @@ -1,33 +1,33 @@ import SwiftUI extension View { - /// Presents the shared `AddHopPickerView` for the given intent binding. - /// - /// On the iPad app on Mac a `navigationDestination` push inside a split-view - /// detail column renders without the system back button, stranding the user on - /// the picker with no way back. Only a caller inside such a column knows it is - /// affected, so it opts in via `inDetailColumn`; there the picker is presented - /// as a sheet that carries its own dismiss control. Callers presenting from - /// their own `NavigationStack` (e.g. a modal sheet) keep the push, where the - /// system back button works on every platform. - @ViewBuilder - func addHopPicker( - for intent: Binding, - source: any HopPickerSource, - inDetailColumn: Bool = false - ) -> some View { - if inDetailColumn, ProcessInfo.processInfo.isiOSAppOnMac { - sheet(item: intent) { intent in - NavigationStack { - AddHopPickerView(viewModel: source, intent: intent, presentsOwnDismiss: true) - } - .presentationSizing(.page) - .presentationDragIndicator(.visible) - } - } else { - navigationDestination(item: intent) { intent in - AddHopPickerView(viewModel: source, intent: intent) - } + /// Presents the shared `AddHopPickerView` for the given intent binding. + /// + /// On the iPad app on Mac a `navigationDestination` push inside a split-view + /// detail column renders without the system back button, stranding the user on + /// the picker with no way back. Only a caller inside such a column knows it is + /// affected, so it opts in via `inDetailColumn`; there the picker is presented + /// as a sheet that carries its own dismiss control. Callers presenting from + /// their own `NavigationStack` (e.g. a modal sheet) keep the push, where the + /// system back button works on every platform. + @ViewBuilder + func addHopPicker( + for intent: Binding, + source: any HopPickerSource, + inDetailColumn: Bool = false + ) -> some View { + if inDetailColumn, ProcessInfo.processInfo.isiOSAppOnMac { + sheet(item: intent) { intent in + NavigationStack { + AddHopPickerView(viewModel: source, intent: intent, presentsOwnDismiss: true) } + .presentationSizing(.page) + .presentationDragIndicator(.visible) + } + } else { + navigationDestination(item: intent) { intent in + AddHopPickerView(viewModel: source, intent: intent) + } } + } } diff --git a/MC1/Views/PathEditing/AddHopPickerView.swift b/MC1/Views/PathEditing/AddHopPickerView.swift index ecc17d5b..5d7eff94 100644 --- a/MC1/Views/PathEditing/AddHopPickerView.swift +++ b/MC1/Views/PathEditing/AddHopPickerView.swift @@ -1,6 +1,6 @@ import Accessibility -import SwiftUI import MC1Services +import SwiftUI import UIKit /// Shared full-screen Add-Hop picker, pushed via `.navigationDestination(item:)` @@ -8,472 +8,488 @@ import UIKit /// builder (`TracePathListView`). Finds a node fast via name substring or hex /// prefix; a comma in the search field switches to bulk code entry. struct AddHopPickerView: View { - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - let viewModel: any HopPickerSource - let intent: AddHopIntent - /// Set when the picker is presented modally (a sheet) rather than pushed onto a - /// navigation stack, so it surfaces its own dismiss control in place of the - /// system back button. See `addHopPicker(for:source:)`. - var presentsOwnDismiss = false - - @State private var searchText = "" - @State private var filter: AddHopFilter = .all - @State private var addHapticTrigger = 0 - - /// Recent keys frozen at presentation. Tapping a row records the node into the - /// view model's live recents (for the next time the picker opens), but the - /// section partitioning reads this snapshot so a just-added row keeps its place - /// instead of jumping into the Recent section mid-interaction. - @State private var sessionRecentKeys: [Data] - - init(viewModel: any HopPickerSource, intent: AddHopIntent, presentsOwnDismiss: Bool = false) { - self.viewModel = viewModel - self.intent = intent - self.presentsOwnDismiss = presentsOwnDismiss - _sessionRecentKeys = State(initialValue: viewModel.recentPublicKeys) - } - - /// A comma in the query switches the picker to bulk code entry. - private var isBulkMode: Bool { searchText.contains(",") } - - var body: some View { - List { - if viewModel.isPathFull { - Section { - maxHopsReachedView - .listRowBackground(Color.clear) - } - } else if isBulkMode { - bulkAddContent - } else { - resultsContent - } - } - .listStyle(.insetGrouped) - .themedCanvas(theme) - .environment(\.editMode, .constant(.inactive)) // override parent's .active - .navigationHeader( - title: L10n.Contacts.Contacts.PathEdit.addHop, - subtitle: viewModel.isPathFull ? "" : bannerText - ) - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: L10n.Contacts.Contacts.PathEdit.searchPrompt - ) - .onChange(of: searchText) { _, newValue in - // A pasted bulk path can use a different hash width than the current - // default; adopt it before the bulk preview re-renders so the codes - // read as addable instead of invalid. - if newValue.contains(",") { viewModel.adoptHashSize(forPastedCodes: newValue) } - } - .safeAreaInset(edge: .top, spacing: 0) { - AddHopSegmentPicker(selection: $filter) - } - .sensoryFeedback(.impact(weight: .light), trigger: addHapticTrigger) - .toolbar { - if presentsOwnDismiss { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Contacts.Contacts.Common.done) { dismiss() } - } - } - ToolbarItem(placement: .primaryAction) { - // Pastes clipboard text into the search field; a comma in the - // pasted value switches the picker to bulk code entry. - Button(L10n.Contacts.Contacts.PathEdit.paste, systemImage: "doc.on.clipboard") { - guard let pasted = UIPasteboard.general.string? - .trimmingCharacters(in: .whitespacesAndNewlines), - !pasted.isEmpty else { return } - searchText = pasted - } - } - } - } - - @ViewBuilder - private var resultsContent: some View { - let results = buildResults() - repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.recent, results: results.recent) - repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.favorites, results: results.favorites) - repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.contacts, results: results.contacts) - repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.discovered, results: results.discovered) - repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.rooms, results: results.rooms) - if results.isEmpty { - Section { - emptyResultsView - .listRowBackground(Color.clear) - } - } - } - - @ViewBuilder - private func repeaterSection(_ title: String, results: [PickerNode]) -> some View { - if !results.isEmpty { - Section(title) { - ForEach(results, id: \.id) { node in - PickerRowView( - node: node, - intent: intent, - viewModel: viewModel, - addHapticTrigger: $addHapticTrigger - ) - } - } - .themedRowBackground(theme) - } - } - - // MARK: - Bulk add - - /// Bulk code entry: parse the comma-separated query, preview per-code validity, - /// and add every resolvable code on tap, then clear the search. - @ViewBuilder - private var bulkAddContent: some View { - let classifications = viewModel.classifyCodes(searchText) - let addableCodes = classifications.filter(\.willBeAdded).map(\.code) - Section { - ForEach(classifications) { classification in - BulkCodeRow(classification: classification) - } - } - .themedRowBackground(theme) + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + let viewModel: any HopPickerSource + let intent: AddHopIntent + /// Set when the picker is presented modally (a sheet) rather than pushed onto a + /// navigation stack, so it surfaces its own dismiss control in place of the + /// system back button. See `addHopPicker(for:source:)`. + var presentsOwnDismiss = false + + @State private var searchText = "" + @State private var filter: AddHopFilter = .all + @State private var addHapticTrigger = 0 + + /// Recent keys frozen at presentation. Tapping a row records the node into the + /// view model's live recents (for the next time the picker opens), but the + /// section partitioning reads this snapshot so a just-added row keeps its place + /// instead of jumping into the Recent section mid-interaction. + @State private var sessionRecentKeys: [Data] + + init(viewModel: any HopPickerSource, intent: AddHopIntent, presentsOwnDismiss: Bool = false) { + self.viewModel = viewModel + self.intent = intent + self.presentsOwnDismiss = presentsOwnDismiss + _sessionRecentKeys = State(initialValue: viewModel.recentPublicKeys) + } + + /// A comma in the query switches the picker to bulk code entry. + private var isBulkMode: Bool { + searchText.contains(",") + } + + var body: some View { + List { + if viewModel.isPathFull { Section { - Button { - let result = viewModel.addCodes(searchText) - if !result.added.isEmpty { - addHapticTrigger += 1 - AccessibilityNotification.Announcement(bannerText).post() - } - searchText = "" - } label: { - Text(addableCodes.isEmpty - ? L10n.Contacts.Contacts.PathEdit.BulkAdd.empty - : L10n.Contacts.Contacts.PathEdit.BulkAdd.action(addableCodes.joined(separator: ", "))) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(addableCodes.isEmpty) + maxHopsReachedView .listRowBackground(Color.clear) } + } else if isBulkMode { + bulkAddContent + } else { + resultsContent + } } - - // MARK: - Subtitle text - - private var bannerText: String { - Self.bannerText(for: viewModel, intent: intent) + .listStyle(.insetGrouped) + .themedCanvas(theme) + .environment(\.editMode, .constant(.inactive)) // override parent's .active + .navigationHeader( + title: L10n.Contacts.Contacts.PathEdit.addHop, + subtitle: viewModel.isPathFull ? "" : bannerText + ) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: L10n.Contacts.Contacts.PathEdit.searchPrompt + ) + .onChange(of: searchText) { _, newValue in + // A pasted bulk path can use a different hash width than the current + // default; adopt it before the bulk preview re-renders so the codes + // read as addable instead of invalid. + if newValue.contains(",") { viewModel.adoptHashSize(forPastedCodes: newValue) } } - - /// Shared text source so the navigation subtitle and the row-tap - /// announcement (posted from `PickerRowView.handleTap`) read identically. - @MainActor - static func bannerText(for viewModel: any HopPickerSource, intent: AddHopIntent) -> String { - if viewModel.isPathFull { - return L10n.Contacts.Contacts.PathEdit.MaxHops.reached + .safeAreaInset(edge: .top, spacing: 0) { + AddHopSegmentPicker(selection: $filter) + } + .sensoryFeedback(.impact(weight: .light), trigger: addHapticTrigger) + .toolbar { + if presentsOwnDismiss { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Common.done) { dismiss() } } - switch intent { - case .append: - return L10n.Contacts.Contacts.PathEdit.positionAppend(viewModel.currentHopCount + 1) + } + ToolbarItem(placement: .primaryAction) { + // Pastes clipboard text into the search field; a comma in the + // pasted value switches the picker to bulk code entry. + Button(L10n.Contacts.Contacts.PathEdit.paste, systemImage: "doc.on.clipboard") { + guard let pasted = UIPasteboard.general.string? + .trimmingCharacters(in: .whitespacesAndNewlines), + !pasted.isEmpty else { return } + searchText = pasted } + } } - - // MARK: - Result builders - - /// Results for all five sections, built once per body so row rendering and - /// the empty-state guard read from the same materialized state. - private struct PickerResults { - var recent: [PickerNode] = [] - var favorites: [PickerNode] = [] - var contacts: [PickerNode] = [] - var discovered: [PickerNode] = [] - var rooms: [PickerNode] = [] - - var isEmpty: Bool { - recent.isEmpty && favorites.isEmpty && contacts.isEmpty && discovered.isEmpty && rooms.isEmpty + } + + @ViewBuilder + private var resultsContent: some View { + let results = buildResults() + repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.recent, results: results.recent) + repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.favorites, results: results.favorites) + repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.contacts, results: results.contacts) + repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.discovered, results: results.discovered) + repeaterSection(L10n.Contacts.Contacts.PathEdit.Sections.rooms, results: results.rooms) + if results.isEmpty { + Section { + emptyResultsView + .listRowBackground(Color.clear) + } + } + } + + @ViewBuilder + private func repeaterSection(_ title: String, results: [PickerNode]) -> some View { + if !results.isEmpty { + Section(title) { + ForEach(results, id: \.id) { node in + PickerRowView( + node: node, + intent: intent, + viewModel: viewModel, + addHapticTrigger: $addHapticTrigger + ) } + } + .themedRowBackground(theme) } - - private func buildResults() -> PickerResults { - // Cross-section dedup only matters in `.all`, where every section is visible - // at once. A single-section filter shows nothing else, so excluding recent or - // contact keys there would hide a node that has no other section to appear in. - let isUnfiltered = filter == .all - let recentKeys = isUnfiltered ? Set(sessionRecentKeys) : [] - let contactKeys = isUnfiltered ? Set(viewModel.availableRepeaters.map(\.publicKey)) : [] - var results = PickerResults() - if showsRecent { results.recent = recentResults() } - if showsFavorites { results.favorites = favoriteResults(excluding: recentKeys) } - if showsContacts { results.contacts = contactResults(excluding: recentKeys) } - if showsDiscovered { results.discovered = discoveredResults(recentKeys: recentKeys, contactKeys: contactKeys) } - if showsRooms { results.rooms = roomResults(excluding: recentKeys) } - return results + } + + // MARK: - Bulk add + + /// Bulk code entry: parse the comma-separated query, preview per-code validity, + /// and add every resolvable code on tap, then clear the search. + @ViewBuilder + private var bulkAddContent: some View { + let classifications = viewModel.classifyCodes(searchText) + let addableCodes = classifications.filter(\.willBeAdded).map(\.code) + Section { + ForEach(classifications) { classification in + BulkCodeRow(classification: classification) + } } - - /// Recent hits resolved against contacts + discovered nodes, preserving LRU - /// order. Filtered against the current search query. - private func recentResults() -> [PickerNode] { - let resolved = sessionRecentKeys.compactMap { pubkey -> PickerNode? in - if let contact = viewModel.availableRepeaters.first(where: { $0.publicKey == pubkey }) { - return .contact(contact) - } - if let discovered = viewModel.discoveredRepeaters.first(where: { $0.publicKey == pubkey }) { - return .discovered(discovered) - } - return nil + .themedRowBackground(theme) + Section { + Button { + let result = viewModel.addCodes(searchText) + if !result.added.isEmpty { + addHapticTrigger += 1 + AccessibilityNotification.Announcement(bannerText).post() } - return HopNodeMatching.filtered(resolved, by: searchText) + searchText = "" + } label: { + Text(addableCodes.isEmpty + ? L10n.Contacts.Contacts.PathEdit.BulkAdd.empty + : L10n.Contacts.Contacts.PathEdit.BulkAdd.action(addableCodes.joined(separator: ", "))) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(addableCodes.isEmpty) + .listRowBackground(Color.clear) } + } - /// Favorite contacts minus anything already in Recent. - private func favoriteResults(excluding keySet: Set) -> [PickerNode] { - let nodes = viewModel.availableRepeaters - .filter { $0.isFavorite && !keySet.contains($0.publicKey) } - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - .map { PickerNode.contact($0) } - return HopNodeMatching.filtered(nodes, by: searchText) - } + // MARK: - Subtitle text - /// Non-favorite contact repeaters minus Recent. - private func contactResults(excluding keySet: Set) -> [PickerNode] { - let nodes = viewModel.availableRepeaters - .filter { !$0.isFavorite && !keySet.contains($0.publicKey) } - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - .map { PickerNode.contact($0) } - return HopNodeMatching.filtered(nodes, by: searchText) - } + private var bannerText: String { + Self.bannerText(for: viewModel, intent: intent) + } - /// Discovered repeaters minus any pubkey already present as a contact and - /// anything in Recent. - private func discoveredResults(recentKeys: Set, contactKeys: Set) -> [PickerNode] { - let nodes = viewModel.discoveredRepeaters - .filter { !contactKeys.contains($0.publicKey) && !recentKeys.contains($0.publicKey) } - .sorted { $0.resolvableName.localizedCaseInsensitiveCompare($1.resolvableName) == .orderedAscending } - .map { PickerNode.discovered($0) } - return HopNodeMatching.filtered(nodes, by: searchText) + /// Shared text source so the navigation subtitle and the row-tap + /// announcement (posted from `PickerRowView.handleTap`) read identically. + @MainActor + static func bannerText(for viewModel: any HopPickerSource, intent: AddHopIntent) -> String { + if viewModel.isPathFull { + return L10n.Contacts.Contacts.PathEdit.MaxHops.reached } - - /// Rooms (contact type == .room) — never double-listed. - private func roomResults(excluding keySet: Set) -> [PickerNode] { - let nodes = viewModel.availableRooms - .filter { !keySet.contains($0.publicKey) } - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - .map { PickerNode.contact($0) } - return HopNodeMatching.filtered(nodes, by: searchText) + switch intent { + case .append: + return L10n.Contacts.Contacts.PathEdit.positionAppend(viewModel.currentHopCount + 1) } + } - // MARK: - Visibility per filter + // MARK: - Result builders - private var showsRecent: Bool { filter == .all || filter == .recent } - private var showsFavorites: Bool { filter == .all || filter == .favorites } - private var showsContacts: Bool { filter == .all } - private var showsDiscovered: Bool { filter == .all || filter == .discovered } - private var showsRooms: Bool { filter == .all } + /// Results for all five sections, built once per body so row rendering and + /// the empty-state guard read from the same materialized state. + private struct PickerResults { + var recent: [PickerNode] = [] + var favorites: [PickerNode] = [] + var contacts: [PickerNode] = [] + var discovered: [PickerNode] = [] + var rooms: [PickerNode] = [] - // MARK: - Row + empty state - - /// Empty-state copy for the active filter. The curated subset filters get - /// their own wording; `.all` and `.discovered` share the generic discovery - /// copy, which is accurate for both. - private var emptyStateTitle: String { - switch filter { - case .favorites: L10n.Contacts.Contacts.PathEdit.NoFavorites.title - case .recent: L10n.Contacts.Contacts.PathEdit.NoRecent.title - case .all, .discovered: L10n.Contacts.Contacts.PathEdit.NoRepeaters.title - } + var isEmpty: Bool { + recent.isEmpty && favorites.isEmpty && contacts.isEmpty && discovered.isEmpty && rooms.isEmpty } - - private var emptyStateDescription: String { - switch filter { - case .favorites: L10n.Contacts.Contacts.PathEdit.NoFavorites.description - case .recent: L10n.Contacts.Contacts.PathEdit.NoRecent.description - case .all, .discovered: L10n.Contacts.Contacts.PathEdit.NoRepeaters.description - } + } + + private func buildResults() -> PickerResults { + // Cross-section dedup only matters in `.all`, where every section is visible + // at once. A single-section filter shows nothing else, so excluding recent or + // contact keys there would hide a node that has no other section to appear in. + let isUnfiltered = filter == .all + let recentKeys = isUnfiltered ? Set(sessionRecentKeys) : [] + let contactKeys = isUnfiltered ? Set(viewModel.availableRepeaters.map(\.publicKey)) : [] + var results = PickerResults() + if showsRecent { results.recent = recentResults() } + if showsFavorites { results.favorites = favoriteResults(excluding: recentKeys) } + if showsContacts { results.contacts = contactResults(excluding: recentKeys) } + if showsDiscovered { results.discovered = discoveredResults(recentKeys: recentKeys, contactKeys: contactKeys) } + if showsRooms { results.rooms = roomResults(excluding: recentKeys) } + return results + } + + /// Recent hits resolved against contacts + discovered nodes, preserving LRU + /// order. Filtered against the current search query. + private func recentResults() -> [PickerNode] { + let resolved = sessionRecentKeys.compactMap { pubkey -> PickerNode? in + if let contact = viewModel.availableRepeaters.first(where: { $0.publicKey == pubkey }) { + return .contact(contact) + } + if let discovered = viewModel.discoveredRepeaters.first(where: { $0.publicKey == pubkey }) { + return .discovered(discovered) + } + return nil + } + return HopNodeMatching.filtered(resolved, by: searchText) + } + + /// Favorite contacts minus anything already in Recent. + private func favoriteResults(excluding keySet: Set) -> [PickerNode] { + let nodes = viewModel.availableRepeaters + .filter { $0.isFavorite && !keySet.contains($0.publicKey) } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + .map { PickerNode.contact($0) } + return HopNodeMatching.filtered(nodes, by: searchText) + } + + /// Non-favorite contact repeaters minus Recent. + private func contactResults(excluding keySet: Set) -> [PickerNode] { + let nodes = viewModel.availableRepeaters + .filter { !$0.isFavorite && !keySet.contains($0.publicKey) } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + .map { PickerNode.contact($0) } + return HopNodeMatching.filtered(nodes, by: searchText) + } + + /// Discovered repeaters minus any pubkey already present as a contact and + /// anything in Recent. + private func discoveredResults(recentKeys: Set, contactKeys: Set) -> [PickerNode] { + let nodes = viewModel.discoveredRepeaters + .filter { !contactKeys.contains($0.publicKey) && !recentKeys.contains($0.publicKey) } + .sorted { $0.resolvableName.localizedCaseInsensitiveCompare($1.resolvableName) == .orderedAscending } + .map { PickerNode.discovered($0) } + return HopNodeMatching.filtered(nodes, by: searchText) + } + + /// Rooms (contact type == .room) — never double-listed. + private func roomResults(excluding keySet: Set) -> [PickerNode] { + let nodes = viewModel.availableRooms + .filter { !keySet.contains($0.publicKey) } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + .map { PickerNode.contact($0) } + return HopNodeMatching.filtered(nodes, by: searchText) + } + + // MARK: - Visibility per filter + + private var showsRecent: Bool { + filter == .all || filter == .recent + } + + private var showsFavorites: Bool { + filter == .all || filter == .favorites + } + + private var showsContacts: Bool { + filter == .all + } + + private var showsDiscovered: Bool { + filter == .all || filter == .discovered + } + + private var showsRooms: Bool { + filter == .all + } + + // MARK: - Row + empty state + + /// Empty-state copy for the active filter. The curated subset filters get + /// their own wording; `.all` and `.discovered` share the generic discovery + /// copy, which is accurate for both. + private var emptyStateTitle: String { + switch filter { + case .favorites: L10n.Contacts.Contacts.PathEdit.NoFavorites.title + case .recent: L10n.Contacts.Contacts.PathEdit.NoRecent.title + case .all, .discovered: L10n.Contacts.Contacts.PathEdit.NoRepeaters.title } + } - @ViewBuilder - private var emptyResultsView: some View { - if searchText.isEmpty { - ContentUnavailableView( - emptyStateTitle, - systemImage: "antenna.radiowaves.left.and.right.slash", - description: Text(emptyStateDescription) - ) + private var emptyStateDescription: String { + switch filter { + case .favorites: L10n.Contacts.Contacts.PathEdit.NoFavorites.description + case .recent: L10n.Contacts.Contacts.PathEdit.NoRecent.description + case .all, .discovered: L10n.Contacts.Contacts.PathEdit.NoRepeaters.description + } + } + + @ViewBuilder + private var emptyResultsView: some View { + if searchText.isEmpty { + ContentUnavailableView( + emptyStateTitle, + systemImage: "antenna.radiowaves.left.and.right.slash", + description: Text(emptyStateDescription) + ) + } else { + let roomsWouldMatch = filter != .all && viewModel.availableRooms.contains { room in + HopNodeMatching.matches(.contact(room), query: searchText) + } + ContentUnavailableView { + Label( + L10n.Contacts.Contacts.PathEdit.NoRepeaters.title, + systemImage: "magnifyingglass" + ) + } description: { + if roomsWouldMatch { + Text(L10n.Contacts.Contacts.PathEdit.Search.NoMatches.descriptionWithRoomsHint) } else { - let roomsWouldMatch = filter != .all && viewModel.availableRooms.contains { room in - HopNodeMatching.matches(.contact(room), query: searchText) - } - ContentUnavailableView { - Label( - L10n.Contacts.Contacts.PathEdit.NoRepeaters.title, - systemImage: "magnifyingglass" - ) - } description: { - if roomsWouldMatch { - Text(L10n.Contacts.Contacts.PathEdit.Search.NoMatches.descriptionWithRoomsHint) - } else { - Text(L10n.Contacts.Contacts.PathEdit.Search.NoMatches.description) - } - } + Text(L10n.Contacts.Contacts.PathEdit.Search.NoMatches.description) } + } } - - private var maxHopsReachedView: some View { - ContentUnavailableView { - Label( - L10n.Contacts.Contacts.PathEdit.MaxHops.reached, - systemImage: "checkmark.circle" - ) - } description: { - Text(L10n.Contacts.Contacts.PathEdit.MaxHops.description(viewModel.hopLimit ?? 0)) - } + } + + private var maxHopsReachedView: some View { + ContentUnavailableView { + Label( + L10n.Contacts.Contacts.PathEdit.MaxHops.reached, + systemImage: "checkmark.circle" + ) + } description: { + Text(L10n.Contacts.Contacts.PathEdit.MaxHops.description(viewModel.hopLimit ?? 0)) } + } } // MARK: - Picker row private struct PickerRowView: View { - let node: PickerNode - let intent: AddHopIntent - let viewModel: any HopPickerSource - @Binding var addHapticTrigger: Int - - @State private var showSuccess = false - @State private var resetTask: Task? - - private static let successDuration: Duration = .seconds(1.5) - - var body: some View { - Button(action: handleTap) { - HStack(spacing: PathEditMetrics.rowContentSpacing) { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: PathEditMetrics.badgeSpacing) { - Text(node.displayName) - .font(.body) - if node.isFavorite { - Image(systemName: "star.fill") - .font(.caption) - .foregroundStyle(.yellow) - .accessibilityHidden(true) - } - if node.isDiscovered { - NodeKindBadge( - text: L10n.Contacts.Contacts.NodeKind.discovered, - color: .blue - ) - } - if node.isRoom { - NodeKindBadge( - text: L10n.Contacts.Contacts.NodeKind.room, - color: .orange - ) - } - } - Text(node.publicKeyHex) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - trailingIcon + let node: PickerNode + let intent: AddHopIntent + let viewModel: any HopPickerSource + @Binding var addHapticTrigger: Int + + @State private var showSuccess = false + @State private var resetTask: Task? + + private static let successDuration: Duration = .seconds(1.5) + + var body: some View { + Button(action: handleTap) { + HStack(spacing: PathEditMetrics.rowContentSpacing) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: PathEditMetrics.badgeSpacing) { + Text(node.displayName) + .font(.body) + if node.isFavorite { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(.yellow) + .accessibilityHidden(true) } - .frame(minHeight: PathEditMetrics.tapTarget) - .contentShape(Rectangle()) + if node.isDiscovered { + NodeKindBadge( + text: L10n.Contacts.Contacts.NodeKind.discovered, + color: .blue + ) + } + if node.isRoom { + NodeKindBadge( + text: L10n.Contacts.Contacts.NodeKind.room, + color: .orange + ) + } + } + Text(node.publicKeyHex) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) } - .buttonStyle(.plain) - .accessibilityLabel(rowAccessibilityLabel) + Spacer() + trailingIcon + } + .frame(minHeight: PathEditMetrics.tapTarget) + .contentShape(Rectangle()) } - - @ViewBuilder - private var trailingIcon: some View { - if showSuccess { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.scale.combined(with: .opacity)) - } else { - Image(systemName: "plus.circle") - .foregroundStyle(.tint) - .transition(.opacity) - } + .buttonStyle(.plain) + .accessibilityLabel(rowAccessibilityLabel) + } + + @ViewBuilder + private var trailingIcon: some View { + if showSuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Image(systemName: "plus.circle") + .foregroundStyle(.tint) + .transition(.opacity) } - - private func handleTap() { - guard !viewModel.isPathFull else { return } - addHapticTrigger += 1 - viewModel.appendHop(node.underlying) - let updatedBanner = AddHopPickerView.bannerText(for: viewModel, intent: intent) - AccessibilityNotification.Announcement(updatedBanner).post() - resetTask?.cancel() - resetTask = Task { - withAnimation { showSuccess = true } - try? await Task.sleep(for: Self.successDuration) - if !Task.isCancelled { - withAnimation { showSuccess = false } - } - } + } + + private func handleTap() { + guard !viewModel.isPathFull else { return } + addHapticTrigger += 1 + viewModel.appendHop(node.underlying) + let updatedBanner = AddHopPickerView.bannerText(for: viewModel, intent: intent) + AccessibilityNotification.Announcement(updatedBanner).post() + resetTask?.cancel() + resetTask = Task { + withAnimation { showSuccess = true } + try? await Task.sleep(for: Self.successDuration) + if !Task.isCancelled { + withAnimation { showSuccess = false } + } } + } - private var rowAccessibilityLabel: String { - L10n.Contacts.Contacts.PathEdit.addToPathAsHop(node.displayName, viewModel.currentHopCount + 1) - } + private var rowAccessibilityLabel: String { + L10n.Contacts.Contacts.PathEdit.addToPathAsHop(node.displayName, viewModel.currentHopCount + 1) + } } // MARK: - Bulk code row /// One parsed code in the bulk-add preview, showing the per-code outcome. private struct BulkCodeRow: View { - let classification: HopCodeClassification - - var body: some View { - HStack(spacing: PathEditMetrics.rowContentSpacing) { - Text(rowText) - .font(.callout) - Spacer() - trailingIcon - } - .frame(minHeight: PathEditMetrics.tapTarget) - .accessibilityElement(children: .combine) + let classification: HopCodeClassification + + var body: some View { + HStack(spacing: PathEditMetrics.rowContentSpacing) { + Text(rowText) + .font(.callout) + Spacer() + trailingIcon } - - /// Only problem statuses get a glyph. Rows that will be added rely on the - /// single "Add codes" button, so they show no add-like affordance. - @ViewBuilder - private var trailingIcon: some View { - if let icon = iconName, let tint = iconTint { - Image(systemName: icon) - .foregroundStyle(tint) - } + .frame(minHeight: PathEditMetrics.tapTarget) + .accessibilityElement(children: .combine) + } + + /// Only problem statuses get a glyph. Rows that will be added rely on the + /// single "Add codes" button, so they show no add-like affordance. + @ViewBuilder + private var trailingIcon: some View { + if let icon = iconName, let tint = iconTint { + Image(systemName: icon) + .foregroundStyle(tint) } - - private var rowText: String { - let code = classification.code - switch classification.status { - case .willAdd: return L10n.Contacts.Contacts.PathEdit.BulkAdd.willAdd(code) - case .alreadyInPath: return L10n.Contacts.Contacts.CodeInput.Error.alreadyInPath(code) - case .notFound: return L10n.Contacts.Contacts.CodeInput.Error.notFound(code) - case .invalidFormat: return L10n.Contacts.Contacts.CodeInput.Error.invalidFormat(code) - case .pathFull: return L10n.Contacts.Contacts.PathEdit.BulkAdd.pathFull(code) - } + } + + private var rowText: String { + let code = classification.code + switch classification.status { + case .willAdd: return L10n.Contacts.Contacts.PathEdit.BulkAdd.willAdd(code) + case .alreadyInPath: return L10n.Contacts.Contacts.CodeInput.Error.alreadyInPath(code) + case .notFound: return L10n.Contacts.Contacts.CodeInput.Error.notFound(code) + case .invalidFormat: return L10n.Contacts.Contacts.CodeInput.Error.invalidFormat(code) + case .pathFull: return L10n.Contacts.Contacts.PathEdit.BulkAdd.pathFull(code) } - - private var iconName: String? { - switch classification.status { - case .willAdd: return nil - case .alreadyInPath: return "checkmark.circle.fill" - case .notFound: return "questionmark.circle" - case .invalidFormat: return "exclamationmark.triangle.fill" - case .pathFull: return "nosign" - } + } + + private var iconName: String? { + switch classification.status { + case .willAdd: nil + case .alreadyInPath: "checkmark.circle.fill" + case .notFound: "questionmark.circle" + case .invalidFormat: "exclamationmark.triangle.fill" + case .pathFull: "nosign" } - - private var iconTint: Color? { - switch classification.status { - case .willAdd: return nil - case .alreadyInPath: return .secondary - case .notFound: return .orange - case .invalidFormat: return .red - case .pathFull: return .secondary - } + } + + private var iconTint: Color? { + switch classification.status { + case .willAdd: nil + case .alreadyInPath: .secondary + case .notFound: .orange + case .invalidFormat: .red + case .pathFull: .secondary } + } } diff --git a/MC1/Views/PathEditing/AddHopSegmentPicker.swift b/MC1/Views/PathEditing/AddHopSegmentPicker.swift index 1c4ebfa8..dedb9a2e 100644 --- a/MC1/Views/PathEditing/AddHopSegmentPicker.swift +++ b/MC1/Views/PathEditing/AddHopSegmentPicker.swift @@ -6,15 +6,15 @@ import SwiftUI /// isn't reliable. While searching, the picker is muted and disabled (searches /// behave as if `All` were selected, matching `DiscoverSegmentPicker`). struct AddHopSegmentPicker: View { - @Binding var selection: AddHopFilter - @Environment(\.isSearching) private var isSearching + @Binding var selection: AddHopFilter + @Environment(\.isSearching) private var isSearching - var body: some View { - GlassFilterBar( - selection: $selection, - isSearching: isSearching, - pickerLabel: L10n.Contacts.Contacts.PathEdit.filterPickerLabel, - title: { $0.localizedLabel } - ) - } + var body: some View { + GlassFilterBar( + selection: $selection, + isSearching: isSearching, + pickerLabel: L10n.Contacts.Contacts.PathEdit.filterPickerLabel, + title: { $0.localizedLabel } + ) + } } diff --git a/MC1/Views/PathEditing/CodeInputResult.swift b/MC1/Views/PathEditing/CodeInputResult.swift index 3ec15b2e..8a3678d8 100644 --- a/MC1/Views/PathEditing/CodeInputResult.swift +++ b/MC1/Views/PathEditing/CodeInputResult.swift @@ -1,29 +1,29 @@ /// Result of parsing and adding repeater codes struct CodeInputResult { - var added: [String] = [] - var notFound: [String] = [] - var alreadyInPath: [String] = [] - var invalidFormat: [String] = [] + var added: [String] = [] + var notFound: [String] = [] + var alreadyInPath: [String] = [] + var invalidFormat: [String] = [] - var hasErrors: Bool { - !notFound.isEmpty || !alreadyInPath.isEmpty || !invalidFormat.isEmpty - } - - var errorMessage: String? { - guard hasErrors else { return nil } + var hasErrors: Bool { + !notFound.isEmpty || !alreadyInPath.isEmpty || !invalidFormat.isEmpty + } - var parts: [String] = [] + var errorMessage: String? { + guard hasErrors else { return nil } - if !invalidFormat.isEmpty { - parts.append(L10n.Contacts.Contacts.CodeInput.Error.invalidFormat(invalidFormat.joined(separator: ", "))) - } - if !notFound.isEmpty { - parts.append(L10n.Contacts.Contacts.CodeInput.Error.notFound(notFound.joined(separator: ", "))) - } - if !alreadyInPath.isEmpty { - parts.append(L10n.Contacts.Contacts.CodeInput.Error.alreadyInPath(alreadyInPath.joined(separator: ", "))) - } + var parts: [String] = [] - return parts.joined(separator: " · ") + if !invalidFormat.isEmpty { + parts.append(L10n.Contacts.Contacts.CodeInput.Error.invalidFormat(invalidFormat.joined(separator: ", "))) + } + if !notFound.isEmpty { + parts.append(L10n.Contacts.Contacts.CodeInput.Error.notFound(notFound.joined(separator: ", "))) } + if !alreadyInPath.isEmpty { + parts.append(L10n.Contacts.Contacts.CodeInput.Error.alreadyInPath(alreadyInPath.joined(separator: ", "))) + } + + return parts.joined(separator: " · ") + } } diff --git a/MC1/Views/PathEditing/HopCodeClassification.swift b/MC1/Views/PathEditing/HopCodeClassification.swift index ac385895..31099090 100644 --- a/MC1/Views/PathEditing/HopCodeClassification.swift +++ b/MC1/Views/PathEditing/HopCodeClassification.swift @@ -2,26 +2,28 @@ import Foundation /// Outcome of a single hex code parsed from a bulk-add entry. enum HopCodeStatus { - /// Valid, resolves to a node, and fits within the hop cap. Carries the - /// prebuilt hop so callers append without re-parsing or re-resolving. - case willAdd(PathHop) - case alreadyInPath - case notFound // valid hex but no matching node - case invalidFormat // wrong length or non-hex - case pathFull // valid and resolvable but past the hop cap + /// Valid, resolves to a node, and fits within the hop cap. Carries the + /// prebuilt hop so callers append without re-parsing or re-resolving. + case willAdd(PathHop) + case alreadyInPath + case notFound // valid hex but no matching node + case invalidFormat // wrong length or non-hex + case pathFull // valid and resolvable but past the hop cap } /// One parsed code from a bulk-add entry, with its status. struct HopCodeClassification: Identifiable { - /// The uppercased code, unique after de-duplication; also the stable id. - let code: String - let status: HopCodeStatus + /// The uppercased code, unique after de-duplication; also the stable id. + let code: String + let status: HopCodeStatus - var id: String { code } + var id: String { + code + } - /// True when tapping "Add" will append this code to the path. - var willBeAdded: Bool { - if case .willAdd = status { return true } - return false - } + /// True when tapping "Add" will append this code to the path. + var willBeAdded: Bool { + if case .willAdd = status { return true } + return false + } } diff --git a/MC1/Views/PathEditing/HopCodeParser.swift b/MC1/Views/PathEditing/HopCodeParser.swift index 356d1df4..848e5da9 100644 --- a/MC1/Views/PathEditing/HopCodeParser.swift +++ b/MC1/Views/PathEditing/HopCodeParser.swift @@ -5,64 +5,64 @@ import Foundation /// for both the bulk-add preview and the actual add, so the panel can never show /// a status that differs from what tapping "Add" produces. enum HopCodeParser { - /// Classify each code in `input` without mutating any path. - /// - /// - Parameters: - /// - hashSize: bytes per hop; a code must be exactly `hashSize * 2` hex digits. - /// - existingHashes: hash-byte prefixes already in the path (for `.alreadyInPath`). - /// - remainingCapacity: hops still addable, or `nil` when unlimited; resolvable - /// codes beyond it become `.pathFull`. - /// - resolve: maps a hash prefix to a node's full public key and display name, - /// or `nil` when no node matches. - static func classify( - input: String, - hashSize: Int, - existingHashes: Set, - remainingCapacity: Int?, - resolve: (Data) -> (publicKey: Data, name: String?)? - ) -> [HopCodeClassification] { - let codes = input - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces).uppercased() } - .filter { !$0.isEmpty } + /// Classify each code in `input` without mutating any path. + /// + /// - Parameters: + /// - hashSize: bytes per hop; a code must be exactly `hashSize * 2` hex digits. + /// - existingHashes: hash-byte prefixes already in the path (for `.alreadyInPath`). + /// - remainingCapacity: hops still addable, or `nil` when unlimited; resolvable + /// codes beyond it become `.pathFull`. + /// - resolve: maps a hash prefix to a node's full public key and display name, + /// or `nil` when no node matches. + static func classify( + input: String, + hashSize: Int, + existingHashes: Set, + remainingCapacity: Int?, + resolve: (Data) -> (publicKey: Data, name: String?)? + ) -> [HopCodeClassification] { + let codes = input + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces).uppercased() } + .filter { !$0.isEmpty } - var seen = Set() - let uniqueCodes = codes.filter { seen.insert($0).inserted } + var seen = Set() + let uniqueCodes = codes.filter { seen.insert($0).inserted } - var pathHashes = existingHashes - var added = 0 + var pathHashes = existingHashes + var added = 0 - return uniqueCodes.map { code in - guard let hashData = parseHex(code, hashSize: hashSize) else { - return HopCodeClassification(code: code, status: .invalidFormat) - } - if pathHashes.contains(hashData) { - return HopCodeClassification(code: code, status: .alreadyInPath) - } - guard let resolved = resolve(hashData) else { - return HopCodeClassification(code: code, status: .notFound) - } - if let remainingCapacity, added >= remainingCapacity { - return HopCodeClassification(code: code, status: .pathFull) - } - added += 1 - pathHashes.insert(hashData) - let hop = PathHop(hashBytes: hashData, publicKey: resolved.publicKey, resolvedName: resolved.name) - return HopCodeClassification(code: code, status: .willAdd(hop)) - } + return uniqueCodes.map { code in + guard let hashData = parseHex(code, hashSize: hashSize) else { + return HopCodeClassification(code: code, status: .invalidFormat) + } + if pathHashes.contains(hashData) { + return HopCodeClassification(code: code, status: .alreadyInPath) + } + guard let resolved = resolve(hashData) else { + return HopCodeClassification(code: code, status: .notFound) + } + if let remainingCapacity, added >= remainingCapacity { + return HopCodeClassification(code: code, status: .pathFull) + } + added += 1 + pathHashes.insert(hashData) + let hop = PathHop(hashBytes: hashData, publicKey: resolved.publicKey, resolvedName: resolved.name) + return HopCodeClassification(code: code, status: .willAdd(hop)) } + } - /// Parse a hex `code` into exactly `hashSize` bytes, or `nil` if malformed. - private static func parseHex(_ code: String, hashSize: Int) -> Data? { - guard code.count == hashSize * 2, code.allSatisfy(\.isHexDigit) else { return nil } - var data = Data() - var idx = code.startIndex - while idx < code.endIndex { - let next = code.index(idx, offsetBy: 2) - guard let byte = UInt8(code[idx.. Data? { + guard code.count == hashSize * 2, code.allSatisfy(\.isHexDigit) else { return nil } + var data = Data() + var idx = code.startIndex + while idx < code.endIndex { + let next = code.index(idx, offsetBy: 2) + guard let byte = UInt8(code[idx.. Bool { - !query.isEmpty && query.allSatisfy(\.isHexDigit) - } + /// True when `query` is non-empty and every character is a hex digit. + /// All-digit names like "1234" therefore match both name and pubkey branches — + /// acceptable since both surfaces produce the same row. + static func isHexQuery(_ query: String) -> Bool { + !query.isEmpty && query.allSatisfy(\.isHexDigit) + } - /// True if `query` is empty, matches the node name as a substring, or (for a - /// hex query) prefixes the public-key hex. Name matching uses - /// `range(of:options:)` with `[.caseInsensitive, .diacriticInsensitive]` so - /// Turkish İ/ı and NFC/NFD Cyrillic fold correctly regardless of runtime locale. - static func matches(_ node: PickerNode, query: String) -> Bool { - guard !query.isEmpty else { return true } - let nameHit = node.displayName.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil - if isHexQuery(query) { - return nameHit || node.publicKeyHex.lowercased().hasPrefix(query.lowercased()) - } - return nameHit + /// True if `query` is empty, matches the node name as a substring, or (for a + /// hex query) prefixes the public-key hex. Name matching uses + /// `range(of:options:)` with `[.caseInsensitive, .diacriticInsensitive]` so + /// Turkish İ/ı and NFC/NFD Cyrillic fold correctly regardless of runtime locale. + static func matches(_ node: PickerNode, query: String) -> Bool { + guard !query.isEmpty else { return true } + let nameHit = node.displayName.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil + if isHexQuery(query) { + return nameHit || node.publicKeyHex.lowercased().hasPrefix(query.lowercased()) } + return nameHit + } - /// Filter `nodes` to those matching `query`, preserving order. - static func filtered(_ nodes: [PickerNode], by query: String) -> [PickerNode] { - guard !query.isEmpty else { return nodes } - return nodes.filter { matches($0, query: query) } - } + /// Filter `nodes` to those matching `query`, preserving order. + static func filtered(_ nodes: [PickerNode], by query: String) -> [PickerNode] { + guard !query.isEmpty else { return nodes } + return nodes.filter { matches($0, query: query) } + } } diff --git a/MC1/Views/PathEditing/HopPickerSource.swift b/MC1/Views/PathEditing/HopPickerSource.swift index 22d8ba7a..aa829f89 100644 --- a/MC1/Views/PathEditing/HopPickerSource.swift +++ b/MC1/Views/PathEditing/HopPickerSource.swift @@ -7,38 +7,38 @@ import MC1Services /// either concretely. @MainActor protocol HopPickerSource: AnyObject { - var availableRepeaters: [ContactDTO] { get } - var availableRooms: [ContactDTO] { get } - var discoveredRepeaters: [DiscoveredNodeDTO] { get } - var recentPublicKeys: [Data] { get } + var availableRepeaters: [ContactDTO] { get } + var availableRooms: [ContactDTO] { get } + var discoveredRepeaters: [DiscoveredNodeDTO] { get } + var recentPublicKeys: [Data] { get } - /// Hops currently in the path being built. - var currentHopCount: Int { get } - /// Maximum hops the path can hold, or `nil` when unlimited (trace). - var hopLimit: Int? { get } - /// Whether no further hops can be added. Defaults to `currentHopCount >= hopLimit`. - var isPathFull: Bool { get } + /// Hops currently in the path being built. + var currentHopCount: Int { get } + /// Maximum hops the path can hold, or `nil` when unlimited (trace). + var hopLimit: Int? { get } + /// Whether no further hops can be added. Defaults to `currentHopCount >= hopLimit`. + var isPathFull: Bool { get } - /// Append a single node to the path and record it as recent. - func appendHop(_ node: some RepeaterResolvable) - /// Add every resolvable code from a comma-separated bulk entry, honoring the hop cap. - func addCodes(_ input: String) -> CodeInputResult - /// Classify a comma-separated bulk entry per code without mutating the path, - /// for the bulk-add preview panel. - func classifyCodes(_ input: String) -> [HopCodeClassification] + /// Append a single node to the path and record it as recent. + func appendHop(_ node: some RepeaterResolvable) + /// Add every resolvable code from a comma-separated bulk entry, honoring the hop cap. + func addCodes(_ input: String) -> CodeInputResult + /// Classify a comma-separated bulk entry per code without mutating the path, + /// for the bulk-add preview panel. + func classifyCodes(_ input: String) -> [HopCodeClassification] - /// Adopt a hash size inferred from a bulk paste when the source supports a - /// per-entry override (trace). Fixed-width sources ignore it. - func adoptHashSize(forPastedCodes input: String) + /// Adopt a hash size inferred from a bulk paste when the source supports a + /// per-entry override (trace). Fixed-width sources ignore it. + func adoptHashSize(forPastedCodes input: String) } extension HopPickerSource { - /// A path with no `hopLimit` is never full; otherwise it fills at the cap. - var isPathFull: Bool { - guard let hopLimit else { return false } - return currentHopCount >= hopLimit - } + /// A path with no `hopLimit` is never full; otherwise it fills at the cap. + var isPathFull: Bool { + guard let hopLimit else { return false } + return currentHopCount >= hopLimit + } - /// Sources with a fixed hash size (the contact path editor) take no action. - func adoptHashSize(forPastedCodes input: String) {} + /// Sources with a fixed hash size (the contact path editor) take no action. + func adoptHashSize(forPastedCodes input: String) {} } diff --git a/MC1/Views/PathEditing/NodeKindBadge.swift b/MC1/Views/PathEditing/NodeKindBadge.swift index 5e7c9276..63f4f0c6 100644 --- a/MC1/Views/PathEditing/NodeKindBadge.swift +++ b/MC1/Views/PathEditing/NodeKindBadge.swift @@ -2,15 +2,15 @@ import SwiftUI /// Capsule badge for labeling node types (room, discovered, etc.) struct NodeKindBadge: View { - let text: String - let color: Color + let text: String + let color: Color - var body: some View { - Text(text) - .font(.caption2.weight(.medium)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(color.opacity(0.15), in: .capsule) - .foregroundStyle(color) - } + var body: some View { + Text(text) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.15), in: .capsule) + .foregroundStyle(color) + } } diff --git a/MC1/Views/PathEditing/PathEditCTALabel.swift b/MC1/Views/PathEditing/PathEditCTALabel.swift index 28ad5eca..921f11a1 100644 --- a/MC1/Views/PathEditing/PathEditCTALabel.swift +++ b/MC1/Views/PathEditing/PathEditCTALabel.swift @@ -6,19 +6,19 @@ import SwiftUI /// collapsing the SF Symbol to zero width; the spacers center the intrinsic-width /// pair within the stretched frame. struct PathEditCTALabel: View { - let title: String - let systemImage: String + let title: String + let systemImage: String - var body: some View { - HStack(spacing: PathEditMetrics.ctaIconSpacing) { - Spacer() - Image(systemName: systemImage) - .font(.body.weight(.semibold)) - .frame(width: PathEditMetrics.ctaIconSize, height: PathEditMetrics.ctaIconSize) - Text(title) - .font(.body.weight(.semibold)) - Spacer() - } - .frame(maxWidth: .infinity) + var body: some View { + HStack(spacing: PathEditMetrics.ctaIconSpacing) { + Spacer() + Image(systemName: systemImage) + .font(.body.weight(.semibold)) + .frame(width: PathEditMetrics.ctaIconSize, height: PathEditMetrics.ctaIconSize) + Text(title) + .font(.body.weight(.semibold)) + Spacer() } + .frame(maxWidth: .infinity) + } } diff --git a/MC1/Views/PathEditing/PathEditMetrics.swift b/MC1/Views/PathEditing/PathEditMetrics.swift index c70607d9..469949a8 100644 --- a/MC1/Views/PathEditing/PathEditMetrics.swift +++ b/MC1/Views/PathEditing/PathEditMetrics.swift @@ -4,21 +4,21 @@ import SwiftUI /// `AddHopSegmentPicker`. Top-level `private` in Swift is file-scoped, so this /// type must be `internal` to be referenced from the other files. enum PathEditMetrics { - /// 48pt tap target — glove-safe, above HIG's 44pt minimum. - static let tapTarget: CGFloat = 48 - static let rowInset: CGFloat = 16 - static let rowVerticalPadding: CGFloat = 8 - static let rowContentSpacing: CGFloat = 12 - static let badgeSpacing: CGFloat = 6 - static let segmentPickerVerticalInset: CGFloat = 8 - static let disabledOpacity: Double = 0.5 + /// 48pt tap target — glove-safe, above HIG's 44pt minimum. + static let tapTarget: CGFloat = 48 + static let rowInset: CGFloat = 16 + static let rowVerticalPadding: CGFloat = 8 + static let rowContentSpacing: CGFloat = 12 + static let badgeSpacing: CGFloat = 6 + static let segmentPickerVerticalInset: CGFloat = 8 + static let disabledOpacity: Double = 0.5 - /// Add Hop CTA label: icon box size and icon-to-text spacing. - static let ctaIconSize: CGFloat = 22 - static let ctaIconSpacing: CGFloat = 8 + /// Add Hop CTA label: icon box size and icon-to-text spacing. + static let ctaIconSize: CGFloat = 22 + static let ctaIconSpacing: CGFloat = 8 - /// Row insets for the full-width Add Hop / routing CTA buttons. - static var ctaRowInsets: EdgeInsets { - EdgeInsets(top: rowVerticalPadding, leading: rowInset, bottom: rowVerticalPadding, trailing: rowInset) - } + /// Row insets for the full-width Add Hop / routing CTA buttons. + static var ctaRowInsets: EdgeInsets { + EdgeInsets(top: rowVerticalPadding, leading: rowInset, bottom: rowVerticalPadding, trailing: rowInset) + } } diff --git a/MC1/Views/PathEditing/PathEditingSheet.swift b/MC1/Views/PathEditing/PathEditingSheet.swift index 0262e76a..af79cef7 100644 --- a/MC1/Views/PathEditing/PathEditingSheet.swift +++ b/MC1/Views/PathEditing/PathEditingSheet.swift @@ -5,266 +5,266 @@ import SwiftUI /// 1. Main screen (this view) — ordered hops, primary CTA, empty-state actions /// 2. Add Hop picker — pushed via navigationDestination(item:) (see `AddHopPickerView`) struct PathEditingSheet: View { - /// Firmware path-length sentinel: 0x00 = direct routing (no repeaters). - /// Distinct from the flood sentinel 0xFF and from `encodePathLen` output, - /// which only uses values where the top two bits encode the hash mode. - private static let directRoutingPathLength: UInt8 = 0x00 + /// Firmware path-length sentinel: 0x00 = direct routing (no repeaters). + /// Distinct from the flood sentinel 0xFF and from `encodePathLen` output, + /// which only uses values where the top two bits encode the hash mode. + private static let directRoutingPathLength: UInt8 = 0x00 - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @Bindable var viewModel: PathManagementViewModel - let contact: ContactDTO + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Bindable var viewModel: PathManagementViewModel + let contact: ContactDTO - @State private var dragHapticTrigger = 0 - @State private var deleteHapticTrigger = 0 - @State private var saveCompletedToken = 0 - @State private var routingConfirmedToken = 0 + @State private var dragHapticTrigger = 0 + @State private var deleteHapticTrigger = 0 + @State private var saveCompletedToken = 0 + @State private var routingConfirmedToken = 0 - @State private var showingDirectConfirmation = false - @State private var showingFloodConfirmation = false + @State private var showingDirectConfirmation = false + @State private var showingFloodConfirmation = false - var body: some View { - NavigationStack { - List { - headerSection - .themedRowBackground(theme) - if viewModel.editablePath.isEmpty { - emptyStateSection - } else { - currentPathSection - .themedRowBackground(theme) - addHopCtaSection - } - } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.PathEdit.title) - .navigationBarTitleDisplayMode(.inline) - .environment(\.editMode, .constant(.active)) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Contacts.Contacts.Common.cancel) { dismiss() } - } - if !viewModel.editablePath.isEmpty { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Contacts.Contacts.Common.save) { - Task { - await viewModel.saveEditedPath(for: contact) - guard viewModel.errorMessage == nil else { return } - saveCompletedToken += 1 - dismiss() - } - } - } - } - } - .addHopPicker(for: $viewModel.insertionIntent, source: viewModel) - .sensoryFeedback(.impact(weight: .light), trigger: dragHapticTrigger) - .sensoryFeedback(.impact(weight: .medium), trigger: deleteHapticTrigger) - .sensoryFeedback(.success, trigger: saveCompletedToken) - .sensoryFeedback(.selection, trigger: routingConfirmedToken) - .alert( - L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.title, - isPresented: $showingDirectConfirmation - ) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} - Button(L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.confirm, role: .destructive) { - Task { - await viewModel.setPath( - for: contact, - path: Data(), - pathLength: Self.directRoutingPathLength - ) - guard viewModel.errorMessage == nil else { return } - routingConfirmedToken += 1 - dismiss() - } - } - } message: { - Text(L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.message( - contact.displayName, - contact.displayName - )) - } - .alert( - L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.title, - isPresented: $showingFloodConfirmation - ) { - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} - Button(L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.confirm, role: .destructive) { - Task { - await viewModel.resetPath(for: contact) - guard viewModel.errorMessage == nil else { return } - routingConfirmedToken += 1 - dismiss() - } - } - } message: { - Text(L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.message( - contact.displayName - )) + var body: some View { + NavigationStack { + List { + headerSection + .themedRowBackground(theme) + if viewModel.editablePath.isEmpty { + emptyStateSection + } else { + currentPathSection + .themedRowBackground(theme) + addHopCtaSection + } + } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.PathEdit.title) + .navigationBarTitleDisplayMode(.inline) + .environment(\.editMode, .constant(.active)) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Common.cancel) { dismiss() } + } + if !viewModel.editablePath.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Common.save) { + Task { + await viewModel.saveEditedPath(for: contact) + guard viewModel.errorMessage == nil else { return } + saveCompletedToken += 1 + dismiss() + } } + } + } + } + .addHopPicker(for: $viewModel.insertionIntent, source: viewModel) + .sensoryFeedback(.impact(weight: .light), trigger: dragHapticTrigger) + .sensoryFeedback(.impact(weight: .medium), trigger: deleteHapticTrigger) + .sensoryFeedback(.success, trigger: saveCompletedToken) + .sensoryFeedback(.selection, trigger: routingConfirmedToken) + .alert( + L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.title, + isPresented: $showingDirectConfirmation + ) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.confirm, role: .destructive) { + Task { + await viewModel.setPath( + for: contact, + path: Data(), + pathLength: Self.directRoutingPathLength + ) + guard viewModel.errorMessage == nil else { return } + routingConfirmedToken += 1 + dismiss() + } } - .presentationDragIndicator(.visible) - .presentationSizing(.page) + } message: { + Text(L10n.Contacts.Contacts.PathEdit.DirectRouting.Confirm.message( + contact.displayName, + contact.displayName + )) + } + .alert( + L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.title, + isPresented: $showingFloodConfirmation + ) { + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.confirm, role: .destructive) { + Task { + await viewModel.resetPath(for: contact) + guard viewModel.errorMessage == nil else { return } + routingConfirmedToken += 1 + dismiss() + } + } + } message: { + Text(L10n.Contacts.Contacts.PathEdit.FloodRouting.Confirm.message( + contact.displayName + )) + } } + .presentationDragIndicator(.visible) + .presentationSizing(.page) + } - // MARK: - Sections + // MARK: - Sections - private var headerSection: some View { - Section { - Text(L10n.Contacts.Contacts.PathEdit.description(contact.displayName)) - .font(.subheadline) - .foregroundStyle(.secondary) - } + private var headerSection: some View { + Section { + Text(L10n.Contacts.Contacts.PathEdit.description(contact.displayName)) + .font(.subheadline) + .foregroundStyle(.secondary) } + } - /// Ordered hops with drag-to-reorder and swipe-to-delete. - /// Uses `.onDelete` (not `.swipeActions`) because the list is in - /// `.editMode == .active` for the always-visible drag handles, and - /// active edit mode suppresses `.swipeActions`. `.onDelete` provides - /// both the leading minus-circle and the trailing swipe gesture. - private var currentPathSection: some View { - Section { - ForEach(Array(viewModel.editablePath.enumerated()), id: \.element.id) { index, hop in - PathHopRow( - hop: hop, - index: index, - totalCount: viewModel.editablePath.count - ) - } - .onMove { source, destination in - dragHapticTrigger += 1 - viewModel.moveRepeater(from: source, to: destination) - } - .onDelete { indexSet in - deleteHapticTrigger += 1 - for index in indexSet.sorted().reversed() { - viewModel.removeRepeater(at: index) - } - } - } header: { - Text(L10n.Contacts.Contacts.PathEdit.currentPath) - } footer: { - Text(L10n.Contacts.Contacts.PathEdit.reorderHint) + /// Ordered hops with drag-to-reorder and swipe-to-delete. + /// Uses `.onDelete` (not `.swipeActions`) because the list is in + /// `.editMode == .active` for the always-visible drag handles, and + /// active edit mode suppresses `.swipeActions`. `.onDelete` provides + /// both the leading minus-circle and the trailing swipe gesture. + private var currentPathSection: some View { + Section { + ForEach(Array(viewModel.editablePath.enumerated()), id: \.element.id) { index, hop in + PathHopRow( + hop: hop, + index: index, + totalCount: viewModel.editablePath.count + ) + } + .onMove { source, destination in + dragHapticTrigger += 1 + viewModel.moveRepeater(from: source, to: destination) + } + .onDelete { indexSet in + deleteHapticTrigger += 1 + for index in indexSet.sorted().reversed() { + viewModel.removeRepeater(at: index) } + } + } header: { + Text(L10n.Contacts.Contacts.PathEdit.currentPath) + } footer: { + Text(L10n.Contacts.Contacts.PathEdit.reorderHint) } + } - private var addHopCtaSection: some View { - Section { - Button { - viewModel.insertionIntent = .append - } label: { - PathEditCTALabel( - title: viewModel.isPathFull - ? L10n.Contacts.Contacts.PathEdit.MaxHops.reached - : L10n.Contacts.Contacts.PathEdit.addHop, - systemImage: viewModel.isPathFull ? "checkmark.circle" : "plus.circle.fill" - ) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(viewModel.isPathFull) - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - } footer: { - if viewModel.isPathFull { - Text(L10n.Contacts.Contacts.PathEdit.MaxHops.footer(viewModel.maxHopCount)) - } - } + private var addHopCtaSection: some View { + Section { + Button { + viewModel.insertionIntent = .append + } label: { + PathEditCTALabel( + title: viewModel.isPathFull + ? L10n.Contacts.Contacts.PathEdit.MaxHops.reached + : L10n.Contacts.Contacts.PathEdit.addHop, + systemImage: viewModel.isPathFull ? "checkmark.circle" : "plus.circle.fill" + ) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(viewModel.isPathFull) + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) + } footer: { + if viewModel.isPathFull { + Text(L10n.Contacts.Contacts.PathEdit.MaxHops.footer(viewModel.maxHopCount)) + } } + } - @ViewBuilder - private var emptyStateSection: some View { - Section { - ContentUnavailableView { - Label( - L10n.Contacts.Contacts.PathEdit.Empty.title, - systemImage: "antenna.radiowaves.left.and.right.slash" - ) - } description: { - Text(L10n.Contacts.Contacts.PathEdit.Empty.description(contact.displayName)) - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - } + @ViewBuilder + private var emptyStateSection: some View { + Section { + ContentUnavailableView { + Label( + L10n.Contacts.Contacts.PathEdit.Empty.title, + systemImage: "antenna.radiowaves.left.and.right.slash" + ) + } description: { + Text(L10n.Contacts.Contacts.PathEdit.Empty.description(contact.displayName)) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } - Section { - Button { - viewModel.insertionIntent = .append - } label: { - PathEditCTALabel( - title: L10n.Contacts.Contacts.PathEdit.addHop, - systemImage: "plus.circle.fill" - ) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + Section { + Button { + viewModel.insertionIntent = .append + } label: { + PathEditCTALabel( + title: L10n.Contacts.Contacts.PathEdit.addHop, + systemImage: "plus.circle.fill" + ) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) - Button { - showingDirectConfirmation = true - } label: { - PathEditCTALabel( - title: L10n.Contacts.Contacts.PathEdit.useDirectRouting, - systemImage: "person.wave.2" - ) - } - .buttonStyle(.bordered) - .controlSize(.large) - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + Button { + showingDirectConfirmation = true + } label: { + PathEditCTALabel( + title: L10n.Contacts.Contacts.PathEdit.useDirectRouting, + systemImage: "person.wave.2" + ) + } + .buttonStyle(.bordered) + .controlSize(.large) + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) - Button { - showingFloodConfirmation = true - } label: { - PathEditCTALabel( - title: L10n.Contacts.Contacts.PathEdit.useFloodRouting, - systemImage: "dot.radiowaves.left.and.right" - ) - } - .buttonStyle(.bordered) - .controlSize(.large) - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } + Button { + showingFloodConfirmation = true + } label: { + PathEditCTALabel( + title: L10n.Contacts.Contacts.PathEdit.useFloodRouting, + systemImage: "dot.radiowaves.left.and.right" + ) + } + .buttonStyle(.bordered) + .controlSize(.large) + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } + } } // MARK: - Row views /// Row displaying a single hop in the path with an index capsule. private struct PathHopRow: View { - let hop: PathHop - let index: Int - let totalCount: Int + let hop: PathHop + let index: Int + let totalCount: Int - var body: some View { - VStack(alignment: .leading, spacing: 2) { - if let name = hop.resolvedName { - Text(name).font(.body) - Text(hop.hashHex) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } else { - Text(hop.hashHex).font(.body.monospaced()) - } - } - .frame(minHeight: PathEditMetrics.tapTarget) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityDescription) - .accessibilityHint(L10n.Contacts.Contacts.PathEdit.hopHint) + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let name = hop.resolvedName { + Text(name).font(.body) + Text(hop.hashHex) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } else { + Text(hop.hashHex).font(.body.monospaced()) + } } + .frame(minHeight: PathEditMetrics.tapTarget) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityDescription) + .accessibilityHint(L10n.Contacts.Contacts.PathEdit.hopHint) + } - private var accessibilityDescription: String { - if let name = hop.resolvedName { - return L10n.Contacts.Contacts.PathEdit.hopWithName(index + 1, totalCount, name) - } else { - return L10n.Contacts.Contacts.PathEdit.hopWithHex(index + 1, totalCount, hop.hashHex) - } + private var accessibilityDescription: String { + if let name = hop.resolvedName { + L10n.Contacts.Contacts.PathEdit.hopWithName(index + 1, totalCount, name) + } else { + L10n.Contacts.Contacts.PathEdit.hopWithHex(index + 1, totalCount, hop.hashHex) } + } } diff --git a/MC1/Views/PathEditing/PathManagementViewModel.swift b/MC1/Views/PathEditing/PathManagementViewModel.swift index 52a1e13d..cee0b50c 100644 --- a/MC1/Views/PathEditing/PathManagementViewModel.swift +++ b/MC1/Views/PathEditing/PathManagementViewModel.swift @@ -1,6 +1,6 @@ -import SwiftUI import MC1Services import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "PathManagement") @@ -8,651 +8,659 @@ private let logger = Logger(subsystem: "com.mc1", category: "PathManagement") /// Modeled as an enum (rather than `Bool` or `Void`) so the item binding has a /// concrete `Identifiable`/`Hashable` type. Only `.append` exists today. enum AddHopIntent: Hashable, Identifiable { - case append + case append - var id: Self { self } + var id: Self { + self + } } /// Sections the Add-Hop picker can narrow to. `.all` shows every section. enum AddHopFilter: String, CaseIterable, Identifiable { - case all, favorites, recent, discovered + case all, favorites, recent, discovered - var id: String { rawValue } + var id: String { + rawValue + } - var localizedLabel: String { - switch self { - case .all: L10n.Contacts.Contacts.PathEdit.Filter.all - case .favorites: L10n.Contacts.Contacts.PathEdit.Filter.favorites - case .recent: L10n.Contacts.Contacts.PathEdit.Filter.recent - case .discovered: L10n.Contacts.Contacts.PathEdit.Filter.discovered - } + var localizedLabel: String { + switch self { + case .all: L10n.Contacts.Contacts.PathEdit.Filter.all + case .favorites: L10n.Contacts.Contacts.PathEdit.Filter.favorites + case .recent: L10n.Contacts.Contacts.PathEdit.Filter.recent + case .discovered: L10n.Contacts.Contacts.PathEdit.Filter.discovered } + } } /// Represents a single hop in the routing path with stable identity for SwiftUI struct PathHop: Identifiable, Equatable { - let id = UUID() - var hashBytes: Data // Public key prefix bytes (1–3 bytes depending on hash mode) - var publicKey: Data? // Full 32-byte key when known (for unambiguous matching) - var resolvedName: String? // Contact name if resolved, nil if unknown + let id = UUID() + var hashBytes: Data // Public key prefix bytes (1–3 bytes depending on hash mode) + var publicKey: Data? // Full 32-byte key when known (for unambiguous matching) + var resolvedName: String? // Contact name if resolved, nil if unknown - var hashHex: String { - hashBytes.uppercaseHexString() - } + var hashHex: String { + hashBytes.uppercaseHexString() + } - var displayText: String { - if let name = resolvedName { - return "\(name) (\(hashHex))" - } - return hashHex + var displayText: String { + if let name = resolvedName { + return "\(name) (\(hashHex))" } + return hashHex + } } /// Result of a path discovery operation enum PathDiscoveryResult: Equatable { - case success(hopCount: Int) - case noPathFound - case failed(String) - - var description: String { - switch self { - case .success(let hopCount): - if hopCount == 0 { - return L10n.Contacts.Contacts.PathDiscovery.direct - } else if hopCount == 1 { - return L10n.Contacts.Contacts.PathDiscovery.Hops.singular - } else { - return L10n.Contacts.Contacts.PathDiscovery.Hops.plural(hopCount) - } - case .noPathFound: - return L10n.Contacts.Contacts.PathDiscovery.noResponse - case .failed(let message): - return L10n.Contacts.Contacts.PathDiscovery.failed(message) - } - } + case success(hopCount: Int) + case noPathFound + case failed(String) + + var description: String { + switch self { + case let .success(hopCount): + if hopCount == 0 { + L10n.Contacts.Contacts.PathDiscovery.direct + } else if hopCount == 1 { + L10n.Contacts.Contacts.PathDiscovery.Hops.singular + } else { + L10n.Contacts.Contacts.PathDiscovery.Hops.plural(hopCount) + } + case .noPathFound: + L10n.Contacts.Contacts.PathDiscovery.noResponse + case let .failed(message): + L10n.Contacts.Contacts.PathDiscovery.failed(message) + } + } } @Observable @MainActor final class PathManagementViewModel { - // MARK: - State - - var isDiscovering = false - var isSettingPath = false - var discoveryResult: PathDiscoveryResult? - var showDiscoveryResult = false - var errorMessage: String? - - // Path editing state - var showingPathEditor = false - /// Drives navigationDestination(item:) pushing the Add Hop picker. - var insertionIntent: AddHopIntent? - var editablePath: [PathHop] = [] // Current path being edited (stable identifiers) - - /// LRU of recently inserted public keys, most-recent first, capped at 8. - /// Radio-scoped: cleared and reloaded when `loadContacts(radioID:)` fires. - var recentPublicKeys: [Data] = [] - - /// Captured radio scope for persistence. Set by `loadRecentKeys(for:)`. - private var currentRadioID: UUID? - - /// Firmware `outPath` budget: 64 bytes. `encodePathLen` also clamps the - /// hop-count field to 6 bits (0…63). The effective cap is whichever is - /// smaller. - private static let pathByteBudget = 64 - private static let pathHopFieldCap = 63 - - var availableRepeaters: [ContactDTO] = [] // Known repeaters to add - var availableRooms: [ContactDTO] = [] // Known rooms (may act as repeaters) - var allContacts: [ContactDTO] = [] // All contacts for name resolution - var discoveredNodes: [DiscoveredNodeDTO] = [] - - /// Combined repeaters and rooms for resolution - var availableNodes: [ContactDTO] { - availableRepeaters + availableRooms - } - - /// Discovered repeaters available to add - var discoveredRepeaters: [DiscoveredNodeDTO] { - discoveredNodes.filter { $0.nodeType == .repeater } - } - - // Discovery cancellation - private var discoveryTask: Task? - - // Discovery countdown state - var discoverySecondsRemaining: Int? - private var countdownTask: Task? - private var discoveryStartTime: Date? - private var discoveryTimeoutSeconds: Double? - - // MARK: - Dependencies - - // Provider closures are re-evaluated at every use so a disconnect (or a - // container rebuild) between actions is observed live, never a stale snapshot. - private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } - private var contactServiceProvider: @MainActor () -> ContactService? = { nil } - private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } - private let recents: RecentHopsStore - - init(defaults: UserDefaults = .standard) { - self.recents = RecentHopsStore(defaults: defaults) - } - - /// Current hash size from device configuration (1, 2, or 3 bytes per hop). - /// Clamped to `1...3` so a firmware `pathHashMode` of 3 (reserved) never trips - /// `encodePathLen`'s `1...3` precondition on save. - var hashSize: Int { - let raw = connectedDeviceProvider()?.hashSize ?? 1 - return min(max(raw, 1), 3) - } - - /// Maximum number of hops the path can hold under the current `hashSize`. - /// Bounded by both the 6-bit hop-count field (0…63) and the 64-byte path - /// payload. Past this cap, firmware silently truncates. - var maxHopCount: Int { - min(Self.pathHopFieldCap, Self.pathByteBudget / hashSize) - } - - /// True when `editablePath.count` has hit `maxHopCount`. Callers (the - /// Add Hop CTA + picker) disable insert affordances when this is true. - var isPathFull: Bool { - editablePath.count >= maxHopCount - } - - // MARK: - Callbacks - - /// Called when path discovery completes and contact should be refreshed - var onContactNeedsRefresh: (() -> Void)? - - // MARK: - Configuration - - /// Each provider is read live at its point of use; a provider returning - /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. - func configure( - dataStore: @escaping @MainActor () -> PersistenceStore?, - contactService: @escaping @MainActor () -> ContactService?, - connectedDevice: @escaping @MainActor () -> DeviceDTO?, - onContactNeedsRefresh: @escaping () -> Void + // MARK: - State + + var isDiscovering = false + var isSettingPath = false + var discoveryResult: PathDiscoveryResult? + var showDiscoveryResult = false + var errorMessage: String? + + /// Path editing state + var showingPathEditor = false + /// Drives navigationDestination(item:) pushing the Add Hop picker. + var insertionIntent: AddHopIntent? + var editablePath: [PathHop] = [] // Current path being edited (stable identifiers) + + /// LRU of recently inserted public keys, most-recent first, capped at 8. + /// Radio-scoped: cleared and reloaded when `loadContacts(radioID:)` fires. + var recentPublicKeys: [Data] = [] + + /// Captured radio scope for persistence. Set by `loadRecentKeys(for:)`. + private var currentRadioID: UUID? + + /// Firmware `outPath` budget: 64 bytes. `encodePathLen` also clamps the + /// hop-count field to 6 bits (0…63). The effective cap is whichever is + /// smaller. + private static let pathByteBudget = 64 + private static let pathHopFieldCap = 63 + + var availableRepeaters: [ContactDTO] = [] // Known repeaters to add + var availableRooms: [ContactDTO] = [] // Known rooms (may act as repeaters) + var allContacts: [ContactDTO] = [] // All contacts for name resolution + var discoveredNodes: [DiscoveredNodeDTO] = [] + + /// Combined repeaters and rooms for resolution + var availableNodes: [ContactDTO] { + availableRepeaters + availableRooms + } + + /// Discovered repeaters available to add + var discoveredRepeaters: [DiscoveredNodeDTO] { + discoveredNodes.filter { $0.nodeType == .repeater } + } + + /// Discovery cancellation + private var discoveryTask: Task? + + // Discovery countdown state + var discoverySecondsRemaining: Int? + private var countdownTask: Task? + private var discoveryStartTime: Date? + private var discoveryTimeoutSeconds: Double? + + // MARK: - Dependencies + + // Provider closures are re-evaluated at every use so a disconnect (or a + // container rebuild) between actions is observed live, never a stale snapshot. + private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } + private var contactServiceProvider: @MainActor () -> ContactService? = { nil } + private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } + private let recents: RecentHopsStore + + init(defaults: UserDefaults = .standard) { + recents = RecentHopsStore(defaults: defaults) + } + + /// Current hash size from device configuration (1, 2, or 3 bytes per hop). + /// Clamped to `1...3` so a firmware `pathHashMode` of 3 (reserved) never trips + /// `encodePathLen`'s `1...3` precondition on save. + var hashSize: Int { + let raw = connectedDeviceProvider()?.hashSize ?? 1 + return min(max(raw, 1), 3) + } + + /// Maximum number of hops the path can hold under the current `hashSize`. + /// Bounded by both the 6-bit hop-count field (0…63) and the 64-byte path + /// payload. Past this cap, firmware silently truncates. + var maxHopCount: Int { + min(Self.pathHopFieldCap, Self.pathByteBudget / hashSize) + } + + /// True when `editablePath.count` has hit `maxHopCount`. Callers (the + /// Add Hop CTA + picker) disable insert affordances when this is true. + var isPathFull: Bool { + editablePath.count >= maxHopCount + } + + // MARK: - Callbacks + + /// Called when path discovery completes and contact should be refreshed + var onContactNeedsRefresh: (() -> Void)? + + // MARK: - Configuration + + /// Each provider is read live at its point of use; a provider returning + /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. + func configure( + dataStore: @escaping @MainActor () -> PersistenceStore?, + contactService: @escaping @MainActor () -> ContactService?, + connectedDevice: @escaping @MainActor () -> DeviceDTO?, + onContactNeedsRefresh: @escaping () -> Void + ) { + dataStoreProvider = dataStore + contactServiceProvider = contactService + connectedDeviceProvider = connectedDevice + self.onContactNeedsRefresh = onContactNeedsRefresh + } + + // MARK: - Name Resolution + + /// Resolve path hash bytes to a contact name if possible + /// Returns the contact name if exactly one contact matches, otherwise falls back to discovered nodes + func resolveHashToName(_ hashBytes: Data) -> String? { + let matches = allContacts.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } + if matches.count == 1 { + return matches[0].resolvableName + } + // Fall back to discovered nodes with same single-match rule + if matches.isEmpty { + let discoveredMatches = discoveredNodes.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } + if discoveredMatches.count == 1 { + return discoveredMatches[0].resolvableName + } + } + return nil // Ambiguous (multiple matches) or unknown + } + + /// Resolve path hash bytes to a node's full 32-byte public key if exactly one + /// match exists. Populating this on hops loaded from storage lets + /// `saveEditedPath` re-encode the path at the device's current `hashSize` + /// even if the stored path used a smaller size. + func resolveHashToPublicKey(_ hashBytes: Data) -> Data? { + let matches = allContacts.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } + if matches.count == 1 { + return matches[0].publicKey + } + if matches.isEmpty { + let discoveredMatches = discoveredNodes.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } + if discoveredMatches.count == 1 { + return discoveredMatches[0].publicKey + } + } + return nil + } + + /// Create a PathHop from hash bytes, resolving the name and full public key + /// when exactly one contact or discovered node matches the prefix. + func createPathHop(from hashBytes: Data) -> PathHop { + PathHop( + hashBytes: hashBytes, + publicKey: resolveHashToPublicKey(hashBytes), + resolvedName: resolveHashToName(hashBytes) + ) + } + + /// Load all contacts for name resolution and filter repeaters for adding. + /// Always refreshes recents for the given `radioID` so switching radios on + /// a reused view model picks up the right scope even if the contacts cache + /// lets us skip the network fetch. + func loadContacts(radioID: UUID, forceReload: Bool = false) async { + guard let dataStore = dataStoreProvider() else { return } + + loadRecentKeys(for: radioID) + + // Skip if already loaded + if !forceReload, !allContacts.isEmpty { + return + } + + do { + let contacts = try await dataStore.fetchContacts(radioID: radioID) + allContacts = contacts + availableRepeaters = contacts.filter { $0.type == .repeater } + availableRooms = contacts.filter { $0.type == .room } + let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) + discoveredNodes = nodes + } catch { + allContacts = [] + availableRepeaters = [] + availableRooms = [] + discoveredNodes = [] + } + } + + /// Initialize editable path from contact's current path with name resolution. + /// Resets `insertionIntent` so a stale value from a prior sheet presentation + /// can't auto-push the Add Hop picker on open. + /// + /// Hops are normalized to the device's current ``hashSize`` so the editor + /// never shows mixed-width hops after a `pathHashMode` change between edits. + /// Resolvable hops re-slice from their full `publicKey`; unresolvable wider + /// hops narrow via truncation; unresolvable narrower hops are left as-is so + /// ``saveRejection`` can flag them on save. + func initializeEditablePath(from contact: ContactDTO) { + insertionIntent = nil + let byteLength = contact.pathByteLength + let storedHashSize = contact.pathHashSize + let pathData = contact.outPath.prefix(byteLength) + let targetHashSize = hashSize + editablePath = stride(from: 0, to: pathData.count, by: storedHashSize).map { start in + let end = min(start + storedHashSize, pathData.count) + let bytes = Data(pathData[start.. PathHop { + precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") + if let publicKey = hop.publicKey { + return PathHop( + hashBytes: Data(publicKey.prefix(targetHashSize)), + publicKey: publicKey, + resolvedName: hop.resolvedName + ) + } + if hop.hashBytes.count > targetHashSize { + return PathHop( + hashBytes: Data(hop.hashBytes.prefix(targetHashSize)), + publicKey: nil, + resolvedName: hop.resolvedName + ) + } + return hop + } + + /// Append a node to the path. No-op when `isPathFull`. Records the pubkey + /// in recents. The picker stays on screen so users can add several hops in + /// a row; `insertionIntent` is cleared only when the user taps back + /// (SwiftUI writes `nil` through the navigation binding). + func insert(_ node: some RepeaterResolvable, at intent: AddHopIntent) { + guard !isPathFull else { return } + let hashBytes = Data(node.publicKey.prefix(hashSize)) + let hop = PathHop( + hashBytes: hashBytes, + publicKey: node.publicKey, + resolvedName: node.resolvableName + ) + switch intent { + case .append: + editablePath.append(hop) + } + recordRecent(node.publicKey) + } + + // MARK: - Recents persistence + + /// Internal — tests invoke this directly via `@testable import MC1` to seed + /// a specific radio scope without building a full `AppState`. + func loadRecentKeys(for radioID: UUID) { + currentRadioID = radioID + recentPublicKeys = recents.load(for: radioID) + } + + /// LRU insert via ``RecentHopsStore``, persisting under the captured radio scope. + func recordRecent(_ publicKey: Data) { + guard let radioID = currentRadioID else { return } + recentPublicKeys = recents.record(publicKey, into: recentPublicKeys, for: radioID) + } + + /// Remove a repeater from the path at index + func removeRepeater(at index: Int) { + guard editablePath.indices.contains(index) else { return } + editablePath.remove(at: index) + } + + /// Move a repeater within the path + func moveRepeater(from source: IndexSet, to destination: Int) { + editablePath.move(fromOffsets: source, toOffset: destination) + } + + /// Reason a candidate edit cannot be saved, or `nil` if encoding is safe. + /// Pure so tests can drive every branch without an `AppState`. + /// + /// - Precondition: `targetHashSize` is 1, 2, or 3. Matches `encodePathLen`'s + /// contract so bypassing the production call site (which clamps via + /// `hashSize`) still trips a clear assertion rather than a deep crash. + nonisolated static func saveRejection( + for hops: [PathHop], + targetHashSize: Int, + maxHopCount: Int + ) -> String? { + precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") + // `Data.prefix(n)` returns `min(count, n)` bytes — no padding. A hop + // whose stored `hashBytes` are narrower than `targetHashSize` and has no + // full `publicKey` to widen from would serialise short, and firmware + // would mis-parse the path against the declared `pathLength`. + let hopNeedsResize = hops.contains { hop in + hop.publicKey == nil && hop.hashBytes.count < targetHashSize + } + if hopNeedsResize { + return L10n.Contacts.Contacts.PathManagement.Error.hopResizeRequired + } + if hops.count > maxHopCount { + return L10n.Contacts.Contacts.PathManagement.Error.tooManyHops(maxHopCount) + } + return nil + } + + /// Encode the path payload at the device's current hash size. Prefers the + /// full 32-byte public key when available; falls back to stored + /// `hashBytes`. Callers must have called `saveRejection` first to ensure + /// the fallback never truncates below `targetHashSize`. + /// + /// - Precondition: `targetHashSize` is 1, 2, or 3. + nonisolated static func encodeEditablePath( + _ hops: [PathHop], + targetHashSize: Int + ) -> (path: Data, length: UInt8) { + precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") + let pathData = Data(hops.flatMap { hop -> Data in + if let publicKey = hop.publicKey { + return publicKey.prefix(targetHashSize) + } + return hop.hashBytes.prefix(targetHashSize) + }) + let pathLength = encodePathLen(hashSize: targetHashSize, hopCount: hops.count) + return (pathData, pathLength) + } + + /// Save the edited path to the contact. Hops may have been loaded from a + /// stored path with a smaller `hashSize` than the device currently reports + /// (user changed `pathHashMode`). `saveRejection` catches the cases where + /// we can't safely re-encode; otherwise `encodeEditablePath` re-encodes + /// every hop at the current `hashSize`. + func saveEditedPath(for contact: ContactDTO) async { + guard let contactService = contactServiceProvider() else { return } + + errorMessage = nil + let targetHashSize = hashSize + + if let rejection = Self.saveRejection( + for: editablePath, + targetHashSize: targetHashSize, + maxHopCount: maxHopCount ) { - dataStoreProvider = dataStore - contactServiceProvider = contactService - connectedDeviceProvider = connectedDevice - self.onContactNeedsRefresh = onContactNeedsRefresh - } - - // MARK: - Name Resolution - - /// Resolve path hash bytes to a contact name if possible - /// Returns the contact name if exactly one contact matches, otherwise falls back to discovered nodes - func resolveHashToName(_ hashBytes: Data) -> String? { - let matches = allContacts.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } - if matches.count == 1 { - return matches[0].resolvableName - } - // Fall back to discovered nodes with same single-match rule - if matches.isEmpty { - let discoveredMatches = discoveredNodes.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } - if discoveredMatches.count == 1 { - return discoveredMatches[0].resolvableName - } - } - return nil // Ambiguous (multiple matches) or unknown + errorMessage = rejection + return } - /// Resolve path hash bytes to a node's full 32-byte public key if exactly one - /// match exists. Populating this on hops loaded from storage lets - /// `saveEditedPath` re-encode the path at the device's current `hashSize` - /// even if the stored path used a smaller size. - func resolveHashToPublicKey(_ hashBytes: Data) -> Data? { - let matches = allContacts.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } - if matches.count == 1 { - return matches[0].publicKey - } - if matches.isEmpty { - let discoveredMatches = discoveredNodes.filter { $0.publicKey.prefix(hashBytes.count) == hashBytes } - if discoveredMatches.count == 1 { - return discoveredMatches[0].publicKey - } - } - return nil - } + isSettingPath = true - /// Create a PathHop from hash bytes, resolving the name and full public key - /// when exactly one contact or discovered node matches the prefix. - func createPathHop(from hashBytes: Data) -> PathHop { - PathHop( - hashBytes: hashBytes, - publicKey: resolveHashToPublicKey(hashBytes), - resolvedName: resolveHashToName(hashBytes) - ) + do { + let encoded = Self.encodeEditablePath(editablePath, targetHashSize: targetHashSize) + try await contactService.setPath( + radioID: contact.radioID, + publicKey: contact.publicKey, + path: encoded.path, + pathLength: encoded.length + ) + onContactNeedsRefresh?() + } catch { + errorMessage = L10n.Contacts.Contacts.PathManagement.Error.saveFailed(error.userFacingMessage) } - /// Load all contacts for name resolution and filter repeaters for adding. - /// Always refreshes recents for the given `radioID` so switching radios on - /// a reused view model picks up the right scope even if the contacts cache - /// lets us skip the network fetch. - func loadContacts(radioID: UUID, forceReload: Bool = false) async { - guard let dataStore = dataStoreProvider() else { return } + isSettingPath = false + } - loadRecentKeys(for: radioID) + // MARK: - Path Operations - // Skip if already loaded - if !forceReload && !allContacts.isEmpty { - return - } + /// Initiate path discovery for a contact (with cancel support). + /// Reports `.noPathFound` if the remote node doesn't respond before the + /// firmware-suggested timeout elapses. + func discoverPath(for contact: ContactDTO) async { + guard let contactService = contactServiceProvider() else { return } - do { - let contacts = try await dataStore.fetchContacts(radioID: radioID) - allContacts = contacts - availableRepeaters = contacts.filter { $0.type == .repeater } - availableRooms = contacts.filter { $0.type == .room } - let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) - discoveredNodes = nodes - } catch { - allContacts = [] - availableRepeaters = [] - availableRooms = [] - discoveredNodes = [] - } - } + // Cancel any existing discovery + discoveryTask?.cancel() - /// Initialize editable path from contact's current path with name resolution. - /// Resets `insertionIntent` so a stale value from a prior sheet presentation - /// can't auto-push the Add Hop picker on open. - /// - /// Hops are normalized to the device's current ``hashSize`` so the editor - /// never shows mixed-width hops after a `pathHashMode` change between edits. - /// Resolvable hops re-slice from their full `publicKey`; unresolvable wider - /// hops narrow via truncation; unresolvable narrower hops are left as-is so - /// ``saveRejection`` can flag them on save. - func initializeEditablePath(from contact: ContactDTO) { - insertionIntent = nil - let byteLength = contact.pathByteLength - let storedHashSize = contact.pathHashSize - let pathData = contact.outPath.prefix(byteLength) - let targetHashSize = hashSize - editablePath = stride(from: 0, to: pathData.count, by: storedHashSize).map { start in - let end = min(start + storedHashSize, pathData.count) - let bytes = Data(pathData[start.. PathHop { - precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") - if let publicKey = hop.publicKey { - return PathHop( - hashBytes: Data(publicKey.prefix(targetHashSize)), - publicKey: publicKey, - resolvedName: hop.resolvedName - ) - } - if hop.hashBytes.count > targetHashSize { - return PathHop( - hashBytes: Data(hop.hashBytes.prefix(targetHashSize)), - publicKey: nil, - resolvedName: hop.resolvedName - ) - } - return hop - } - - /// Append a node to the path. No-op when `isPathFull`. Records the pubkey - /// in recents. The picker stays on screen so users can add several hops in - /// a row; `insertionIntent` is cleared only when the user taps back - /// (SwiftUI writes `nil` through the navigation binding). - func insert(_ node: some RepeaterResolvable, at intent: AddHopIntent) { - guard !isPathFull else { return } - let hashBytes = Data(node.publicKey.prefix(hashSize)) - let hop = PathHop( - hashBytes: hashBytes, - publicKey: node.publicKey, - resolvedName: node.resolvableName + discoveryTask = Task { @MainActor in + do { + let sentResponse = try await contactService.sendPathDiscovery( + radioID: contact.radioID, + publicKey: contact.publicKey ) - switch intent { - case .append: - editablePath.append(hop) - } - recordRecent(node.publicKey) - } - // MARK: - Recents persistence - - /// Internal — tests invoke this directly via `@testable import MC1` to seed - /// a specific radio scope without building a full `AppState`. - func loadRecentKeys(for radioID: UUID) { - currentRadioID = radioID - recentPublicKeys = recents.load(for: radioID) - } - - /// LRU insert via ``RecentHopsStore``, persisting under the captured radio scope. - func recordRecent(_ publicKey: Data) { - guard let radioID = currentRadioID else { return } - recentPublicKeys = recents.record(publicKey, into: recentPublicKeys, for: radioID) - } - - /// Remove a repeater from the path at index - func removeRepeater(at index: Int) { - guard editablePath.indices.contains(index) else { return } - editablePath.remove(at: index) - } - - /// Move a repeater within the path - func moveRepeater(from source: IndexSet, to destination: Int) { - editablePath.move(fromOffsets: source, toOffset: destination) - } - - /// Reason a candidate edit cannot be saved, or `nil` if encoding is safe. - /// Pure so tests can drive every branch without an `AppState`. - /// - /// - Precondition: `targetHashSize` is 1, 2, or 3. Matches `encodePathLen`'s - /// contract so bypassing the production call site (which clamps via - /// `hashSize`) still trips a clear assertion rather than a deep crash. - nonisolated static func saveRejection( - for hops: [PathHop], - targetHashSize: Int, - maxHopCount: Int - ) -> String? { - precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") - // `Data.prefix(n)` returns `min(count, n)` bytes — no padding. A hop - // whose stored `hashBytes` are narrower than `targetHashSize` and has no - // full `publicKey` to widen from would serialise short, and firmware - // would mis-parse the path against the declared `pathLength`. - let hopNeedsResize = hops.contains { hop in - hop.publicKey == nil && hop.hashBytes.count < targetHashSize - } - if hopNeedsResize { - return L10n.Contacts.Contacts.PathManagement.Error.hopResizeRequired - } - if hops.count > maxHopCount { - return L10n.Contacts.Contacts.PathManagement.Error.tooManyHops(maxHopCount) - } - return nil - } - - /// Encode the path payload at the device's current hash size. Prefers the - /// full 32-byte public key when available; falls back to stored - /// `hashBytes`. Callers must have called `saveRejection` first to ensure - /// the fallback never truncates below `targetHashSize`. - /// - /// - Precondition: `targetHashSize` is 1, 2, or 3. - nonisolated static func encodeEditablePath( - _ hops: [PathHop], - targetHashSize: Int - ) -> (path: Data, length: UInt8) { - precondition(1...3 ~= targetHashSize, "targetHashSize must be 1, 2, or 3") - let pathData = Data(hops.flatMap { hop -> Data in - if let publicKey = hop.publicKey { - return publicKey.prefix(targetHashSize) - } - return hop.hashBytes.prefix(targetHashSize) - }) - let pathLength = encodePathLen(hashSize: targetHashSize, hopCount: hops.count) - return (pathData, pathLength) - } - - /// Save the edited path to the contact. Hops may have been loaded from a - /// stored path with a smaller `hashSize` than the device currently reports - /// (user changed `pathHashMode`). `saveRejection` catches the cases where - /// we can't safely re-encode; otherwise `encodeEditablePath` re-encodes - /// every hop at the current `hashSize`. - func saveEditedPath(for contact: ContactDTO) async { - guard let contactService = contactServiceProvider() else { return } - - errorMessage = nil - let targetHashSize = hashSize - - if let rejection = Self.saveRejection( - for: editablePath, - targetHashSize: targetHashSize, - maxHopCount: maxHopCount - ) { - errorMessage = rejection - return - } - - isSettingPath = true - - do { - let encoded = Self.encodeEditablePath(editablePath, targetHashSize: targetHashSize) - try await contactService.setPath( - radioID: contact.radioID, - publicKey: contact.publicKey, - path: encoded.path, - pathLength: encoded.length - ) - onContactNeedsRefresh?() - } catch { - errorMessage = L10n.Contacts.Contacts.PathManagement.Error.saveFailed(error.userFacingMessage) - } - - isSettingPath = false - } - - // MARK: - Path Operations - - /// Initiate path discovery for a contact (with cancel support). - /// Reports `.noPathFound` if the remote node doesn't respond before the - /// firmware-suggested timeout elapses. - func discoverPath(for contact: ContactDTO) async { - guard let contactService = contactServiceProvider() else { return } - - // Cancel any existing discovery - discoveryTask?.cancel() - - isDiscovering = true - discoveryResult = nil - errorMessage = nil - - discoveryTask = Task { @MainActor in - do { - let sentResponse = try await contactService.sendPathDiscovery( - radioID: contact.radioID, - publicKey: contact.publicKey - ) - - let candidateSeconds = FirmwareSuggestedTimeout.candidateSeconds(suggestedTimeoutMs: sentResponse.suggestedTimeoutMs) - let timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentResponse.suggestedTimeoutMs) - if timeoutSeconds == FirmwareSuggestedTimeout.defaultSeconds && candidateSeconds != timeoutSeconds { - logger.warning( - "Path discovery timeout fallback applied: raw=\(sentResponse.suggestedTimeoutMs)ms, candidate=\(candidateSeconds)s, fallback=\(timeoutSeconds)s" - ) - } else { - logger.info("Path discovery timeout: \(timeoutSeconds)s (firmware suggested: \(sentResponse.suggestedTimeoutMs)ms)") - } - - // Start countdown timer - self.discoveryTimeoutSeconds = timeoutSeconds - self.discoveryStartTime = Date.now - self.discoverySecondsRemaining = Int(timeoutSeconds) - self.startCountdownTask() - - // Wait for push notification with firmware-suggested timeout. - // AdvertisementService handler calls handleDiscoveryResponse() - // which cancels this task early if a response arrives; sleep throws. - try await Task.sleep(for: .seconds(timeoutSeconds)) - - // Timeout: the remote node did not respond - discoveryResult = .noPathFound - showDiscoveryResult = true - } catch is CancellationError { - // Whoever cancelled us (user cancel, push-response handler, or a - // fresh discoverPath re-entry) already resolved state. Bail so - // this task's tail doesn't clobber the newer run's state. - return - } catch { - discoveryResult = .failed(error.userFacingMessage) - showDiscoveryResult = true - } - - isDiscovering = false - cleanupCountdownState() - } - } - - /// Start the countdown task that updates remaining seconds every 5 seconds. - /// Cancels any prior countdown first so a `discoverPath` re-entry can't - /// leave an orphan task writing `discoverySecondsRemaining` alongside the - /// new one — the outer `discoveryTask` cancel doesn't propagate to this - /// sibling. - private func startCountdownTask() { - countdownTask?.cancel() - countdownTask = Task { - while !Task.isCancelled, let timeout = discoveryTimeoutSeconds, let startTime = discoveryStartTime { - do { - try await Task.sleep(for: .seconds(5)) - } catch { - break - } - // Re-check after sleep: cancellation can fire between iterations - // without sleep throwing. Without this, a stale task could write - // a stale remaining value over a fresh discovery's state. - guard !Task.isCancelled else { break } - let elapsed = Date.now.timeIntervalSince(startTime) - let remaining = max(0, Int(timeout - elapsed)) - discoverySecondsRemaining = remaining - } - } - } - - /// Clean up countdown state when discovery ends - private func cleanupCountdownState() { - countdownTask?.cancel() - countdownTask = nil - discoverySecondsRemaining = nil - discoveryStartTime = nil - discoveryTimeoutSeconds = nil - } - - /// Cancel an in-progress path discovery - func cancelDiscovery() { - discoveryTask?.cancel() - discoveryTask = nil - isDiscovering = false - cleanupCountdownState() - } - - /// Called when a path discovery response is received via push notification. - /// - /// `hopCount` is `nil` when the wire's `out_path_len` byte used the reserved - /// hash-size mode and couldn't be decoded. Treat that as "no valid path - /// returned" rather than silently reporting a direct route. - func handleDiscoveryResponse(hopCount: Int?) { - discoveryTask?.cancel() - isDiscovering = false - cleanupCountdownState() - - if let hopCount { - discoveryResult = .success(hopCount: hopCount) + let candidateSeconds = FirmwareSuggestedTimeout.candidateSeconds(suggestedTimeoutMs: sentResponse.suggestedTimeoutMs) + let timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentResponse.suggestedTimeoutMs) + if timeoutSeconds == FirmwareSuggestedTimeout.defaultSeconds, candidateSeconds != timeoutSeconds { + logger.warning( + "Path discovery timeout fallback applied: raw=\(sentResponse.suggestedTimeoutMs)ms, candidate=\(candidateSeconds)s, fallback=\(timeoutSeconds)s" + ) } else { - discoveryResult = .noPathFound + logger.info("Path discovery timeout: \(timeoutSeconds)s (firmware suggested: \(sentResponse.suggestedTimeoutMs)ms)") } - showDiscoveryResult = true - - // Signal that contact data should be refreshed to show new path - onContactNeedsRefresh?() - } - - /// Reset the path for a contact (force flood routing) - func resetPath(for contact: ContactDTO) async { - guard let contactService = contactServiceProvider() else { return } - - isSettingPath = true - errorMessage = nil - - do { - try await contactService.resetPath( - radioID: contact.radioID, - publicKey: contact.publicKey - ) - onContactNeedsRefresh?() - } catch { - errorMessage = L10n.Contacts.Contacts.PathManagement.Error.resetFailed(error.userFacingMessage) - } - - isSettingPath = false - } - /// Set a specific path for a contact - func setPath(for contact: ContactDTO, path: Data, pathLength: UInt8) async { - guard let contactService = contactServiceProvider() else { return } + // Start countdown timer + self.discoveryTimeoutSeconds = timeoutSeconds + self.discoveryStartTime = Date.now + self.discoverySecondsRemaining = Int(timeoutSeconds) + self.startCountdownTask() - isSettingPath = true - errorMessage = nil + // Wait for push notification with firmware-suggested timeout. + // AdvertisementService handler calls handleDiscoveryResponse() + // which cancels this task early if a response arrives; sleep throws. + try await Task.sleep(for: .seconds(timeoutSeconds)) + // Timeout: the remote node did not respond + discoveryResult = .noPathFound + showDiscoveryResult = true + } catch is CancellationError { + // Whoever cancelled us (user cancel, push-response handler, or a + // fresh discoverPath re-entry) already resolved state. Bail so + // this task's tail doesn't clobber the newer run's state. + return + } catch { + discoveryResult = .failed(error.userFacingMessage) + showDiscoveryResult = true + } + + isDiscovering = false + cleanupCountdownState() + } + } + + /// Start the countdown task that updates remaining seconds every 5 seconds. + /// Cancels any prior countdown first so a `discoverPath` re-entry can't + /// leave an orphan task writing `discoverySecondsRemaining` alongside the + /// new one — the outer `discoveryTask` cancel doesn't propagate to this + /// sibling. + private func startCountdownTask() { + countdownTask?.cancel() + countdownTask = Task { + while !Task.isCancelled, let timeout = discoveryTimeoutSeconds, let startTime = discoveryStartTime { do { - try await contactService.setPath( - radioID: contact.radioID, - publicKey: contact.publicKey, - path: path, - pathLength: pathLength - ) - onContactNeedsRefresh?() + try await Task.sleep(for: .seconds(5)) } catch { - errorMessage = L10n.Contacts.Contacts.PathManagement.Error.setFailed(error.userFacingMessage) + break } - - isSettingPath = false - } + // Re-check after sleep: cancellation can fire between iterations + // without sleep throwing. Without this, a stale task could write + // a stale remaining value over a fresh discovery's state. + guard !Task.isCancelled else { break } + let elapsed = Date.now.timeIntervalSince(startTime) + let remaining = max(0, Int(timeout - elapsed)) + discoverySecondsRemaining = remaining + } + } + } + + /// Clean up countdown state when discovery ends + private func cleanupCountdownState() { + countdownTask?.cancel() + countdownTask = nil + discoverySecondsRemaining = nil + discoveryStartTime = nil + discoveryTimeoutSeconds = nil + } + + /// Cancel an in-progress path discovery + func cancelDiscovery() { + discoveryTask?.cancel() + discoveryTask = nil + isDiscovering = false + cleanupCountdownState() + } + + /// Called when a path discovery response is received via push notification. + /// + /// `hopCount` is `nil` when the wire's `out_path_len` byte used the reserved + /// hash-size mode and couldn't be decoded. Treat that as "no valid path + /// returned" rather than silently reporting a direct route. + func handleDiscoveryResponse(hopCount: Int?) { + discoveryTask?.cancel() + isDiscovering = false + cleanupCountdownState() + + if let hopCount { + discoveryResult = .success(hopCount: hopCount) + } else { + discoveryResult = .noPathFound + } + showDiscoveryResult = true + + // Signal that contact data should be refreshed to show new path + onContactNeedsRefresh?() + } + + /// Reset the path for a contact (force flood routing) + func resetPath(for contact: ContactDTO) async { + guard let contactService = contactServiceProvider() else { return } + + isSettingPath = true + errorMessage = nil + + do { + try await contactService.resetPath( + radioID: contact.radioID, + publicKey: contact.publicKey + ) + onContactNeedsRefresh?() + } catch { + errorMessage = L10n.Contacts.Contacts.PathManagement.Error.resetFailed(error.userFacingMessage) + } + + isSettingPath = false + } + + /// Set a specific path for a contact + func setPath(for contact: ContactDTO, path: Data, pathLength: UInt8) async { + guard let contactService = contactServiceProvider() else { return } + + isSettingPath = true + errorMessage = nil + + do { + try await contactService.setPath( + radioID: contact.radioID, + publicKey: contact.publicKey, + path: path, + pathLength: pathLength + ) + onContactNeedsRefresh?() + } catch { + errorMessage = L10n.Contacts.Contacts.PathManagement.Error.setFailed(error.userFacingMessage) + } + + isSettingPath = false + } } // MARK: - HopPickerSource extension PathManagementViewModel: HopPickerSource { - var currentHopCount: Int { editablePath.count } - - /// Contact paths are capped; expose the cap so the shared picker can show the - /// max-hops state and stop bulk-add cleanly. - var hopLimit: Int? { maxHopCount } - - func appendHop(_ node: some RepeaterResolvable) { - insert(node, at: .append) - } - - func classifyCodes(_ input: String) -> [HopCodeClassification] { - let existing = Set(editablePath.map(\.hashBytes)) - let remaining = max(0, maxHopCount - editablePath.count) - return HopCodeParser.classify( - input: input, - hashSize: hashSize, - existingHashes: existing, - remainingCapacity: remaining - ) { hash in - guard let publicKey = resolveHashToPublicKey(hash) else { return nil } - return (publicKey, resolveHashToName(hash)) - } - } - - /// Bulk-add resolvable codes, honoring the hop cap. Codes past the cap are - /// classified `.pathFull` and skipped, so the path stops cleanly at `maxHopCount`. - @discardableResult - func addCodes(_ input: String) -> CodeInputResult { - var result = CodeInputResult() - for entry in classifyCodes(input) { - switch entry.status { - case .willAdd(let hop): - editablePath.append(hop) - if let publicKey = hop.publicKey { recordRecent(publicKey) } - result.added.append(entry.code) - case .alreadyInPath: result.alreadyInPath.append(entry.code) - case .notFound: result.notFound.append(entry.code) - case .invalidFormat: result.invalidFormat.append(entry.code) - case .pathFull: break - } - } - return result - } + var currentHopCount: Int { + editablePath.count + } + + /// Contact paths are capped; expose the cap so the shared picker can show the + /// max-hops state and stop bulk-add cleanly. + var hopLimit: Int? { + maxHopCount + } + + func appendHop(_ node: some RepeaterResolvable) { + insert(node, at: .append) + } + + func classifyCodes(_ input: String) -> [HopCodeClassification] { + let existing = Set(editablePath.map(\.hashBytes)) + let remaining = max(0, maxHopCount - editablePath.count) + return HopCodeParser.classify( + input: input, + hashSize: hashSize, + existingHashes: existing, + remainingCapacity: remaining + ) { hash in + guard let publicKey = resolveHashToPublicKey(hash) else { return nil } + return (publicKey, resolveHashToName(hash)) + } + } + + /// Bulk-add resolvable codes, honoring the hop cap. Codes past the cap are + /// classified `.pathFull` and skipped, so the path stops cleanly at `maxHopCount`. + @discardableResult + func addCodes(_ input: String) -> CodeInputResult { + var result = CodeInputResult() + for entry in classifyCodes(input) { + switch entry.status { + case let .willAdd(hop): + editablePath.append(hop) + if let publicKey = hop.publicKey { recordRecent(publicKey) } + result.added.append(entry.code) + case .alreadyInPath: result.alreadyInPath.append(entry.code) + case .notFound: result.notFound.append(entry.code) + case .invalidFormat: result.invalidFormat.append(entry.code) + case .pathFull: break + } + } + return result + } } diff --git a/MC1/Views/PathEditing/PickerNode.swift b/MC1/Views/PathEditing/PickerNode.swift index 4f3a4d2e..d42aaf0a 100644 --- a/MC1/Views/PathEditing/PickerNode.swift +++ b/MC1/Views/PathEditing/PickerNode.swift @@ -3,58 +3,58 @@ import MC1Services /// Unified node type for the repeater picker list enum PickerNode: Identifiable { - case contact(ContactDTO) - case discovered(DiscoveredNodeDTO) - - var id: UUID { - switch self { - case .contact(let c): c.id - case .discovered(let d): d.id - } - } + case contact(ContactDTO) + case discovered(DiscoveredNodeDTO) - var displayName: String { - switch self { - case .contact(let c): c.displayName - case .discovered(let d): d.name - } + var id: UUID { + switch self { + case let .contact(c): c.id + case let .discovered(d): d.id } + } - var publicKeyHex: String { - switch self { - case .contact(let c): c.publicKey.uppercaseHexString() - case .discovered(let d): d.publicKey.uppercaseHexString() - } + var displayName: String { + switch self { + case let .contact(c): c.displayName + case let .discovered(d): d.name } + } - var isRoom: Bool { - switch self { - case .contact(let c): c.type == .room - case .discovered: false - } + var publicKeyHex: String { + switch self { + case let .contact(c): c.publicKey.uppercaseHexString() + case let .discovered(d): d.publicKey.uppercaseHexString() } + } - var isDiscovered: Bool { - switch self { - case .contact: false - case .discovered: true - } + var isRoom: Bool { + switch self { + case let .contact(c): c.type == .room + case .discovered: false } + } - /// True iff this node is a contact flagged as a user favorite. - /// Discovered nodes are never favorites — `DiscoveredNodeDTO` has no `isFavorite` field. - var isFavorite: Bool { - switch self { - case .contact(let c): c.isFavorite - case .discovered: false - } + var isDiscovered: Bool { + switch self { + case .contact: false + case .discovered: true + } + } + + /// True iff this node is a contact flagged as a user favorite. + /// Discovered nodes are never favorites — `DiscoveredNodeDTO` has no `isFavorite` field. + var isFavorite: Bool { + switch self { + case let .contact(c): c.isFavorite + case .discovered: false } + } - /// The underlying DTO for passing to ViewModel methods - var underlying: any RepeaterResolvable { - switch self { - case .contact(let c): c - case .discovered(let d): d - } + /// The underlying DTO for passing to ViewModel methods + var underlying: any RepeaterResolvable { + switch self { + case let .contact(c): c + case let .discovered(d): d } + } } diff --git a/MC1/Views/PathEditing/RecentHopsStore.swift b/MC1/Views/PathEditing/RecentHopsStore.swift index 47974661..dad05d40 100644 --- a/MC1/Views/PathEditing/RecentHopsStore.swift +++ b/MC1/Views/PathEditing/RecentHopsStore.swift @@ -8,38 +8,38 @@ import MC1Services /// The owning view model holds the observable `[Data]` array; this type owns only /// load/persist and the LRU transform so SwiftUI observation stays on the view model. struct RecentHopsStore { - /// Frozen storage-key prefix. Existing contact-side recents are persisted under - /// this exact key and must keep loading after the trace side adopts the store. - private static let keyPrefix = "pathEdit.recentPublicKeys." - static let limit = 8 + /// Frozen storage-key prefix. Existing contact-side recents are persisted under + /// this exact key and must keep loading after the trace side adopts the store. + private static let keyPrefix = "pathEdit.recentPublicKeys." + static let limit = 8 - private let defaults: UserDefaults + private let defaults: UserDefaults - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } - static func defaultsKey(for radioID: UUID) -> String { - "\(keyPrefix)\(radioID.uuidString)" - } + static func defaultsKey(for radioID: UUID) -> String { + "\(keyPrefix)\(radioID.uuidString)" + } - /// Load the persisted recents for `radioID`, newest first. - func load(for radioID: UUID) -> [Data] { - let hexList = defaults.stringArray(forKey: Self.defaultsKey(for: radioID)) ?? [] - return hexList.compactMap(Data.init(hexString:)) - } + /// Load the persisted recents for `radioID`, newest first. + func load(for radioID: UUID) -> [Data] { + let hexList = defaults.stringArray(forKey: Self.defaultsKey(for: radioID)) ?? [] + return hexList.compactMap(Data.init(hexString:)) + } - /// LRU-insert `pubkey` into `current`, persist for `radioID`, and return the new list. - /// Moves an existing key to the front rather than duplicating; trims to ``limit``. - /// Stores lowercase hex (`Data.hexString`), which `Data(hexString:)` round-trips case-insensitively. - func record(_ pubkey: Data, into current: [Data], for radioID: UUID) -> [Data] { - var updated = current - updated.removeAll { $0 == pubkey } - updated.insert(pubkey, at: 0) - if updated.count > Self.limit { - updated = Array(updated.prefix(Self.limit)) - } - defaults.set(updated.map(\.hexString), forKey: Self.defaultsKey(for: radioID)) - return updated + /// LRU-insert `pubkey` into `current`, persist for `radioID`, and return the new list. + /// Moves an existing key to the front rather than duplicating; trims to ``limit``. + /// Stores lowercase hex (`Data.hexString`), which `Data(hexString:)` round-trips case-insensitively. + func record(_ pubkey: Data, into current: [Data], for radioID: UUID) -> [Data] { + var updated = current + updated.removeAll { $0 == pubkey } + updated.insert(pubkey, at: 0) + if updated.count > Self.limit { + updated = Array(updated.prefix(Self.limit)) } + defaults.set(updated.map(\.hexString), forKey: Self.defaultsKey(for: radioID)) + return updated + } } diff --git a/MC1/Views/RemoteNodes/ChannelGroup.swift b/MC1/Views/RemoteNodes/ChannelGroup.swift index ad8a7073..3db2cece 100644 --- a/MC1/Views/RemoteNodes/ChannelGroup.swift +++ b/MC1/Views/RemoteNodes/ChannelGroup.swift @@ -1,52 +1,56 @@ import MC1Services struct ChannelGroup: Identifiable { - let channel: Int - let charts: [TelemetryChartGroup] - var id: Int { channel } - - /// Builds channel groups from telemetry entries in the given snapshots, - /// grouping by channel and sensor type, sorted by chart priority then alphabetically. - static func groups(from snapshots: [NodeStatusSnapshotDTO]) -> [ChannelGroup] { - let allEntries = snapshots.flatMap { snapshot in - (snapshot.telemetryEntries ?? []).map { (snapshot: snapshot, entry: $0) } - } - - guard !allEntries.isEmpty else { return [] } - - var channelTypeGroups: [Int: [String: TelemetryChartGroup]] = [:] - - for item in allEntries { - let channel = item.entry.channel - let type = item.entry.type - let sensorType = LPPSensorType(name: type) - let point = MetricChartView.DataPoint( - id: item.snapshot.id, - date: item.snapshot.timestamp, - value: sensorType?.convertedValue(item.entry.value) ?? item.entry.value - ) - - channelTypeGroups[channel, default: [:]][type, default: TelemetryChartGroup( - key: "\(channel)-\(type)", title: sensorType?.localizedName ?? type, sensorType: sensorType, dataPoints: [] - )].dataPoints.append(point) - } - - return channelTypeGroups.keys.sorted().map { channel in - let charts = channelTypeGroups[channel]!.values.sorted { lhs, rhs in - let lhsPriority = lhs.sensorType?.chartSortPriority ?? 1 - let rhsPriority = rhs.sensorType?.chartSortPriority ?? 1 - if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } - return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending - } - return ChannelGroup(channel: channel, charts: charts) - } + let channel: Int + let charts: [TelemetryChartGroup] + var id: Int { + channel + } + + /// Builds channel groups from telemetry entries in the given snapshots, + /// grouping by channel and sensor type, sorted by chart priority then alphabetically. + static func groups(from snapshots: [NodeStatusSnapshotDTO]) -> [ChannelGroup] { + let allEntries = snapshots.flatMap { snapshot in + (snapshot.telemetryEntries ?? []).map { (snapshot: snapshot, entry: $0) } } + + guard !allEntries.isEmpty else { return [] } + + var channelTypeGroups: [Int: [String: TelemetryChartGroup]] = [:] + + for item in allEntries { + let channel = item.entry.channel + let type = item.entry.type + let sensorType = LPPSensorType(name: type) + let point = MetricChartView.DataPoint( + id: item.snapshot.id, + date: item.snapshot.timestamp, + value: sensorType?.convertedValue(item.entry.value) ?? item.entry.value + ) + + channelTypeGroups[channel, default: [:]][type, default: TelemetryChartGroup( + key: "\(channel)-\(type)", title: sensorType?.localizedName ?? type, sensorType: sensorType, dataPoints: [] + )].dataPoints.append(point) + } + + return channelTypeGroups.keys.sorted().map { channel in + let charts = channelTypeGroups[channel]!.values.sorted { lhs, rhs in + let lhsPriority = lhs.sensorType?.chartSortPriority ?? 1 + let rhsPriority = rhs.sensorType?.chartSortPriority ?? 1 + if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + return ChannelGroup(channel: channel, charts: charts) + } + } } struct TelemetryChartGroup: Identifiable { - let key: String - let title: String - let sensorType: LPPSensorType? - var dataPoints: [MetricChartView.DataPoint] - var id: String { key } + let key: String + let title: String + let sensorType: LPPSensorType? + var dataPoints: [MetricChartView.DataPoint] + var id: String { + key + } } diff --git a/MC1/Views/RemoteNodes/DisappearedNeighborRow.swift b/MC1/Views/RemoteNodes/DisappearedNeighborRow.swift index 97acfa5a..035df6b9 100644 --- a/MC1/Views/RemoteNodes/DisappearedNeighborRow.swift +++ b/MC1/Views/RemoteNodes/DisappearedNeighborRow.swift @@ -2,31 +2,31 @@ import MC1Services import SwiftUI struct DisappearedNeighborRow: View { - let neighbor: NeighborSnapshotEntry - let displayName: String - let matchKind: NodeNameMatchKind + let neighbor: NeighborSnapshotEntry + let displayName: String + let matchKind: NodeNameMatchKind - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(displayName) - if matchKind == .fallback { - FallbackMatchIndicatorView( - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, - accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, - title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, - explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation - ) - } - } - Text(L10n.RemoteNodes.RemoteNodes.History.notSeen) - .font(.caption2) - } - Spacer() - Text(L10n.RemoteNodes.RemoteNodes.Status.snrFormat(neighbor.snr.formatted(.number.precision(.fractionLength(1))))) - .font(.caption) + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(displayName) + if matchKind == .fallback { + FallbackMatchIndicatorView( + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, + accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, + title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, + explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation + ) + } } - .foregroundStyle(.tertiary) + Text(L10n.RemoteNodes.RemoteNodes.History.notSeen) + .font(.caption2) + } + Spacer() + Text(L10n.RemoteNodes.RemoteNodes.Status.snrFormat(neighbor.snr.formatted(.number.precision(.fractionLength(1))))) + .font(.caption) } + .foregroundStyle(.tertiary) + } } diff --git a/MC1/Views/RemoteNodes/HistoryTimeRangePicker.swift b/MC1/Views/RemoteNodes/HistoryTimeRangePicker.swift index bbdcd83e..f45c858a 100644 --- a/MC1/Views/RemoteNodes/HistoryTimeRangePicker.swift +++ b/MC1/Views/RemoteNodes/HistoryTimeRangePicker.swift @@ -2,42 +2,42 @@ import SwiftUI /// Time range for filtering history charts. enum HistoryTimeRange: String, CaseIterable { - case week, month, threeMonths, all + case week, month, threeMonths, all - static let `default`: HistoryTimeRange = .month + static let `default`: HistoryTimeRange = .month - var label: String { - switch self { - case .week: L10n.RemoteNodes.RemoteNodes.History.week - case .month: L10n.RemoteNodes.RemoteNodes.History.month - case .threeMonths: L10n.RemoteNodes.RemoteNodes.History.threeMonths - case .all: L10n.RemoteNodes.RemoteNodes.History.all - } + var label: String { + switch self { + case .week: L10n.RemoteNodes.RemoteNodes.History.week + case .month: L10n.RemoteNodes.RemoteNodes.History.month + case .threeMonths: L10n.RemoteNodes.RemoteNodes.History.threeMonths + case .all: L10n.RemoteNodes.RemoteNodes.History.all } + } - var startDate: Date? { - switch self { - case .week: Calendar.current.date(byAdding: .day, value: -7, to: .now) - case .month: Calendar.current.date(byAdding: .month, value: -1, to: .now) - case .threeMonths: Calendar.current.date(byAdding: .month, value: -3, to: .now) - case .all: nil - } + var startDate: Date? { + switch self { + case .week: Calendar.current.date(byAdding: .day, value: -7, to: .now) + case .month: Calendar.current.date(byAdding: .month, value: -1, to: .now) + case .threeMonths: Calendar.current.date(byAdding: .month, value: -3, to: .now) + case .all: nil } + } } /// Segmented picker for selecting a history time range, styled for use in a List section. struct HistoryTimeRangePicker: View { - @Binding var selection: HistoryTimeRange + @Binding var selection: HistoryTimeRange - var body: some View { - Section { - Picker(L10n.RemoteNodes.RemoteNodes.History.timeRange, selection: $selection) { - ForEach(HistoryTimeRange.allCases, id: \.self) { range in - Text(range.label).tag(range) - } - } - .pickerStyle(.segmented) - .listRowBackground(Color.clear) + var body: some View { + Section { + Picker(L10n.RemoteNodes.RemoteNodes.History.timeRange, selection: $selection) { + ForEach(HistoryTimeRange.allCases, id: \.self) { range in + Text(range.label).tag(range) } + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) } + } } diff --git a/MC1/Views/RemoteNodes/MetricChartView.swift b/MC1/Views/RemoteNodes/MetricChartView.swift index 94f5ae05..247d2111 100644 --- a/MC1/Views/RemoteNodes/MetricChartView.swift +++ b/MC1/Views/RemoteNodes/MetricChartView.swift @@ -4,204 +4,204 @@ import SwiftUI /// Reusable mini-chart for a single time-series metric. struct MetricChartView: View { - let title: String - let unit: String - let dataPoints: [DataPoint] - let accentColor: Color - var yAxisDomain: ClosedRange? - - @State private var selectedDate: Date? - - private var selectedPoint: DataPoint? { - guard let selectedDate else { return nil } - return dataPoints.min { abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate)) } + let title: String + let unit: String + let dataPoints: [DataPoint] + let accentColor: Color + var yAxisDomain: ClosedRange? + + @State private var selectedDate: Date? + + private var selectedPoint: DataPoint? { + guard let selectedDate else { return nil } + return dataPoints.min { abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate)) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + MetricChartHeader(title: title, unit: unit, selectedPoint: selectedPoint, accentColor: accentColor) + + if dataPoints.count < 2 { + MetricChartEmptyState(value: dataPoints.first?.value, unit: unit) + } else { + MetricChartContent(title: title, dataPoints: dataPoints, accentColor: accentColor, yAxisDomain: yAxisDomain, selectedDate: $selectedDate, selectedPoint: selectedPoint) + } } + } - var body: some View { - VStack(alignment: .leading, spacing: 4) { - MetricChartHeader(title: title, unit: unit, selectedPoint: selectedPoint, accentColor: accentColor) - - if dataPoints.count < 2 { - MetricChartEmptyState(value: dataPoints.first?.value, unit: unit) - } else { - MetricChartContent(title: title, dataPoints: dataPoints, accentColor: accentColor, yAxisDomain: yAxisDomain, selectedDate: $selectedDate, selectedPoint: selectedPoint) - } - } - } - - struct DataPoint: Identifiable { - let id: UUID - let date: Date - let value: Double - } + struct DataPoint: Identifiable { + let id: UUID + let date: Date + let value: Double + } } /// Header row that shows the title, and selected value + timestamp when scrubbing. private struct MetricChartHeader: View { - let title: String - let unit: String - let selectedPoint: MetricChartView.DataPoint? - let accentColor: Color - - var body: some View { - HStack(alignment: .firstTextBaseline) { - Text(title) - .font(.subheadline) - .bold() - - Spacer() - - if let selectedPoint { - Text("\(selectedPoint.value, format: .number) \(unit)") - .bold() - .foregroundStyle(accentColor) - + Text(" ") - + Text(selectedPoint.date, format: .dateTime.month(.abbreviated).day().hour().minute()) - .foregroundStyle(.secondary) - } - } - .font(.caption) - .animation(.none, value: selectedPoint?.id) + let title: String + let unit: String + let selectedPoint: MetricChartView.DataPoint? + let accentColor: Color + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.subheadline) + .bold() + + Spacer() + + if let selectedPoint { + Text("\(selectedPoint.value, format: .number) \(unit)") + .bold() + .foregroundStyle(accentColor) + + Text(" ") + + Text(selectedPoint.date, format: .dateTime.month(.abbreviated).day().hour().minute()) + .foregroundStyle(.secondary) + } } + .font(.caption) + .animation(.none, value: selectedPoint?.id) + } } /// Chart content with line and point marks. private struct MetricChartContent: View { - let title: String - let dataPoints: [MetricChartView.DataPoint] - let accentColor: Color - let yAxisDomain: ClosedRange? - @Binding var selectedDate: Date? - let selectedPoint: MetricChartView.DataPoint? - - @State private var isScrubbing = false - - var body: some View { - chart - .chartOverlay { proxy in - GeometryReader { geo in - let plotOriginX = proxy.plotFrame.map { geo[$0].origin.x } ?? 0 - Rectangle() - .fill(.clear) - .contentShape(Rectangle()) - .gesture( - ChartScrubGesture( - selectedDate: $selectedDate, - isScrubbing: $isScrubbing, - proxy: proxy, - plotOriginX: plotOriginX - ) - ) - } - } - .sensoryFeedback(.impact, trigger: isScrubbing) { old, new in !old && new } - .preference(key: ChartScrubbingPreferenceKey.self, value: isScrubbing) - .chartYAxis { - AxisMarks(position: .leading) - } - .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 4)) { _ in - AxisGridLine() - AxisTick() - AxisValueLabel(format: .dateTime.month(.abbreviated).day()) - } - } - .accessibilityLabel(title) - .frame(height: 180) - } - - @ViewBuilder - private var chart: some View { - let base = Chart { - ForEach(dataPoints) { point in - LineMark( - x: .value("Time", point.date), - y: .value(title, point.value) - ) - .interpolationMethod(.linear) - .foregroundStyle(accentColor.opacity(0.5)) - - PointMark( - x: .value("Time", point.date), - y: .value(title, point.value) - ) - .foregroundStyle(accentColor) - .symbolSize(30) - } - - if let selectedPoint { - RuleMark(x: .value("Selected", selectedPoint.date)) - .foregroundStyle(.secondary.opacity(0.3)) - .lineStyle(StrokeStyle(dash: [4, 4])) - .zIndex(-1) - } + let title: String + let dataPoints: [MetricChartView.DataPoint] + let accentColor: Color + let yAxisDomain: ClosedRange? + @Binding var selectedDate: Date? + let selectedPoint: MetricChartView.DataPoint? + + @State private var isScrubbing = false + + var body: some View { + chart + .chartOverlay { proxy in + GeometryReader { geo in + let plotOriginX = proxy.plotFrame.map { geo[$0].origin.x } ?? 0 + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .gesture( + ChartScrubGesture( + selectedDate: $selectedDate, + isScrubbing: $isScrubbing, + proxy: proxy, + plotOriginX: plotOriginX + ) + ) } - - if let yAxisDomain { - base.chartYScale(domain: yAxisDomain) - } else { - base + } + .sensoryFeedback(.impact, trigger: isScrubbing) { old, new in !old && new } + .preference(key: ChartScrubbingPreferenceKey.self, value: isScrubbing) + .chartYAxis { + AxisMarks(position: .leading) + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { _ in + AxisGridLine() + AxisTick() + AxisValueLabel(format: .dateTime.month(.abbreviated).day()) } + } + .accessibilityLabel(title) + .frame(height: 180) + } + + @ViewBuilder + private var chart: some View { + let base = Chart { + ForEach(dataPoints) { point in + LineMark( + x: .value("Time", point.date), + y: .value(title, point.value) + ) + .interpolationMethod(.linear) + .foregroundStyle(accentColor.opacity(0.5)) + + PointMark( + x: .value("Time", point.date), + y: .value(title, point.value) + ) + .foregroundStyle(accentColor) + .symbolSize(30) + } + + if let selectedPoint { + RuleMark(x: .value("Selected", selectedPoint.date)) + .foregroundStyle(.secondary.opacity(0.3)) + .lineStyle(StrokeStyle(dash: [4, 4])) + .zIndex(-1) + } + } + + if let yAxisDomain { + base.chartYScale(domain: yAxisDomain) + } else { + base } + } } // MARK: - Shared Packet-Count Domain extension [MetricChartView.DataPoint] { - /// Returns a common Y-axis domain spanning `0 ... max * 1.05` across all given arrays, - /// or `nil` when there is no positive max (empty or all-zero data). - static func sharedDomain(for arrays: [[MetricChartView.DataPoint]]) -> ClosedRange? { - let maxVal = arrays.flatMap(\.self).map(\.value).max() - guard let maxVal, maxVal > 0 else { return nil } - return 0 ... maxVal * 1.05 - } + /// Returns a common Y-axis domain spanning `0 ... max * 1.05` across all given arrays, + /// or `nil` when there is no positive max (empty or all-zero data). + static func sharedDomain(for arrays: [[MetricChartView.DataPoint]]) -> ClosedRange? { + let maxVal = arrays.flatMap(\.self).map(\.value).max() + guard let maxVal, maxVal > 0 else { return nil } + return 0...maxVal * 1.05 + } } // MARK: - OCV Chart Domain -extension Array where Element == Int { - /// Computes a chart Y-axis domain in volts from millivolt OCV values, with a ±buffer. - /// Unions the OCV range with actual data points so outliers are never clipped. - func voltageChartDomain( - dataPoints: [MetricChartView.DataPoint] = [], - bufferMV: Int = 500 - ) -> ClosedRange? { - guard let ocvMin = self.min(), let ocvMax = self.max() else { return nil } - var lo = Double(ocvMin) / 1000.0 - var hi = Double(ocvMax) / 1000.0 - let values = dataPoints.map(\.value) - if let dataMin = values.min() { lo = Swift.min(lo, dataMin) } - if let dataMax = values.max() { hi = Swift.max(hi, dataMax) } - let buffer = Double(bufferMV) / 1000.0 - return Swift.max(0, lo - buffer) ... hi + buffer - } +extension [Int] { + /// Computes a chart Y-axis domain in volts from millivolt OCV values, with a ±buffer. + /// Unions the OCV range with actual data points so outliers are never clipped. + func voltageChartDomain( + dataPoints: [MetricChartView.DataPoint] = [], + bufferMV: Int = 500 + ) -> ClosedRange? { + guard let ocvMin = self.min(), let ocvMax = self.max() else { return nil } + var lo = Double(ocvMin) / 1000.0 + var hi = Double(ocvMax) / 1000.0 + let values = dataPoints.map(\.value) + if let dataMin = values.min() { lo = Swift.min(lo, dataMin) } + if let dataMax = values.max() { hi = Swift.max(hi, dataMax) } + let buffer = Double(bufferMV) / 1000.0 + return Swift.max(0, lo - buffer)...hi + buffer + } } // MARK: - Chart Scrubbing Scroll Lock private struct ChartScrubbingPreferenceKey: PreferenceKey { - static let defaultValue = false - static func reduce(value: inout Bool, nextValue: () -> Bool) { - value = value || nextValue() - } + static let defaultValue = false + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = value || nextValue() + } } extension View { - /// Apply to a `List` or `ScrollView` containing `MetricChartView`s to disable - /// scrolling while the user is long-press scrubbing a chart. - func chartScrubbingScrollLock() -> some View { - modifier(ChartScrubbingScrollLockModifier()) - } + /// Apply to a `List` or `ScrollView` containing `MetricChartView`s to disable + /// scrolling while the user is long-press scrubbing a chart. + func chartScrubbingScrollLock() -> some View { + modifier(ChartScrubbingScrollLockModifier()) + } } private struct ChartScrubbingScrollLockModifier: ViewModifier { - @State private var isScrubbing = false + @State private var isScrubbing = false - func body(content: Content) -> some View { - content - .onPreferenceChange(ChartScrubbingPreferenceKey.self) { isScrubbing = $0 } - .scrollDisabled(isScrubbing) - } + func body(content: Content) -> some View { + content + .onPreferenceChange(ChartScrubbingPreferenceKey.self) { isScrubbing = $0 } + .scrollDisabled(isScrubbing) + } } // MARK: - Chart Scrub Gesture @@ -212,66 +212,66 @@ private struct ChartScrubbingScrollLockModifier: ViewModifier { /// The UIKit recognizer's delegate allows proper simultaneous recognition, /// and its `.changed` state reports continuous location updates after recognition. private struct ChartScrubGesture: UIGestureRecognizerRepresentable { - @Binding var selectedDate: Date? - @Binding var isScrubbing: Bool - let proxy: ChartProxy - let plotOriginX: CGFloat - - func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { - let recognizer = UILongPressGestureRecognizer() - recognizer.minimumPressDuration = 0.25 - recognizer.delegate = context.coordinator - return recognizer - } - - func handleUIGestureRecognizerAction(_ recognizer: UILongPressGestureRecognizer, context: Context) { - switch recognizer.state { - case .began: - isScrubbing = true - fallthrough - case .changed: - let x = context.converter.localLocation.x - plotOriginX - if let date: Date = proxy.value(atX: x) { - selectedDate = date - } - case .ended, .cancelled, .failed: - isScrubbing = false - selectedDate = nil - default: - break - } + @Binding var selectedDate: Date? + @Binding var isScrubbing: Bool + let proxy: ChartProxy + let plotOriginX: CGFloat + + func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { + let recognizer = UILongPressGestureRecognizer() + recognizer.minimumPressDuration = 0.25 + recognizer.delegate = context.coordinator + return recognizer + } + + func handleUIGestureRecognizerAction(_ recognizer: UILongPressGestureRecognizer, context: Context) { + switch recognizer.state { + case .began: + isScrubbing = true + fallthrough + case .changed: + let x = context.converter.localLocation.x - plotOriginX + if let date: Date = proxy.value(atX: x) { + selectedDate = date + } + case .ended, .cancelled, .failed: + isScrubbing = false + selectedDate = nil + default: + break } - - func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { - Coordinator() - } - - final class Coordinator: NSObject, UIGestureRecognizerDelegate { - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - !(otherGestureRecognizer is UIScreenEdgePanGestureRecognizer) - } + } + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { + Coordinator() + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + !(otherGestureRecognizer is UIScreenEdgePanGestureRecognizer) } + } } /// Empty state shown when fewer than 2 data points exist. private struct MetricChartEmptyState: View { - let value: Double? - let unit: String - - var body: some View { - VStack { - if let value { - Text("\(value.formatted()) \(unit)") - .font(.title2) - } - Text(L10n.RemoteNodes.RemoteNodes.History.checkBack) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .frame(height: 80) + let value: Double? + let unit: String + + var body: some View { + VStack { + if let value { + Text("\(value.formatted()) \(unit)") + .font(.title2) + } + Text(L10n.RemoteNodes.RemoteNodes.History.checkBack) + .font(.caption) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity) + .frame(height: 80) + } } diff --git a/MC1/Views/RemoteNodes/NeighborRow.swift b/MC1/Views/RemoteNodes/NeighborRow.swift index b34093b4..e391d1f1 100644 --- a/MC1/Views/RemoteNodes/NeighborRow.swift +++ b/MC1/Views/RemoteNodes/NeighborRow.swift @@ -2,96 +2,104 @@ import MC1Services import SwiftUI struct NeighborRow: View { - let neighbor: NeighbourInfo - let displayName: String - let matchKind: NodeNameMatchKind - let previousNeighbor: NeighborSnapshotEntry? - let hasPreviousSnapshot: Bool + let neighbor: NeighbourInfo + let displayName: String + let matchKind: NodeNameMatchKind + let previousNeighbor: NeighborSnapshotEntry? + let hasPreviousSnapshot: Bool - init( - neighbor: NeighbourInfo, - displayName: String, - matchKind: NodeNameMatchKind, - previousNeighbor: NeighborSnapshotEntry? = nil, - hasPreviousSnapshot: Bool = false - ) { - self.neighbor = neighbor - self.displayName = displayName - self.matchKind = matchKind - self.previousNeighbor = previousNeighbor - self.hasPreviousSnapshot = hasPreviousSnapshot - } + init( + neighbor: NeighbourInfo, + displayName: String, + matchKind: NodeNameMatchKind, + previousNeighbor: NeighborSnapshotEntry? = nil, + hasPreviousSnapshot: Bool = false + ) { + self.neighbor = neighbor + self.displayName = displayName + self.matchKind = matchKind + self.previousNeighbor = previousNeighbor + self.hasPreviousSnapshot = hasPreviousSnapshot + } - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(displayName) + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(displayName) - if hasPreviousSnapshot && previousNeighbor == nil { - Text(L10n.RemoteNodes.RemoteNodes.History.new) - .font(.caption2) - .bold() - .foregroundStyle(.green) - } + if hasPreviousSnapshot, previousNeighbor == nil { + Text(L10n.RemoteNodes.RemoteNodes.History.new) + .font(.caption2) + .bold() + .foregroundStyle(.green) + } - if matchKind == .fallback { - FallbackMatchIndicatorView( - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, - accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, - title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, - explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation - ) - } - } + if matchKind == .fallback { + FallbackMatchIndicatorView( + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, + accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, + title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, + explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation + ) + } + } - HStack(spacing: 4) { - Text(firstKeyByte) - .font(.system(.caption2, design: .monospaced)) - Text("·") - Text(lastSeenText) - .font(.caption2) - } - .foregroundStyle(.secondary) - } + HStack(spacing: 4) { + Text(firstKeyByte) + .font(.system(.caption2, design: .monospaced)) + Text("·") + Text(lastSeenText) + .font(.caption2) + } + .foregroundStyle(.secondary) + } - Spacer() + Spacer() - VStack(alignment: .trailing, spacing: 2) { - Image(systemName: "cellularbars", variableValue: snrLevel) - .foregroundStyle(snrColor) + VStack(alignment: .trailing, spacing: 2) { + Image(systemName: "cellularbars", variableValue: snrLevel) + .foregroundStyle(snrColor) - Text(L10n.RemoteNodes.RemoteNodes.Status.snrFormat(neighbor.snr.formatted(.number.precision(.fractionLength(1))))) - .font(.caption) - .foregroundStyle(.secondary) + Text(L10n.RemoteNodes.RemoteNodes.Status.snrFormat(neighbor.snr.formatted(.number.precision(.fractionLength(1))))) + .font(.caption) + .foregroundStyle(.secondary) - if let previous = previousNeighbor { - let snrDelta = neighbor.snr - previous.snr - if abs(snrDelta) >= 0.1 { - StatusDeltaView(delta: snrDelta, higherIsBetter: true, unit: " dB", fractionDigits: 1) - } - } - } + if let previous = previousNeighbor { + let snrDelta = neighbor.snr - previous.snr + if abs(snrDelta) >= 0.1 { + StatusDeltaView(delta: snrDelta, higherIsBetter: true, unit: " dB", fractionDigits: 1) + } } + } } + } - private var firstKeyByte: String { - guard let firstByte = neighbor.publicKeyPrefix.first else { return "" } - return Data([firstByte]).uppercaseHexString() - } + private var firstKeyByte: String { + guard let firstByte = neighbor.publicKeyPrefix.first else { return "" } + return Data([firstByte]).uppercaseHexString() + } - private var lastSeenText: String { - let seconds = neighbor.secondsAgo - if seconds < 60 { - return L10n.RemoteNodes.RemoteNodes.Status.secondsAgo(seconds) - } else if seconds < 3600 { - return L10n.RemoteNodes.RemoteNodes.Status.minutesAgo(seconds / 60) - } else { - return L10n.RemoteNodes.RemoteNodes.Status.hoursAgo(seconds / 3600) - } + private var lastSeenText: String { + let seconds = neighbor.secondsAgo + if seconds < 60 { + return L10n.RemoteNodes.RemoteNodes.Status.secondsAgo(seconds) + } else if seconds < 3600 { + return L10n.RemoteNodes.RemoteNodes.Status.minutesAgo(seconds / 60) + } else { + return L10n.RemoteNodes.RemoteNodes.Status.hoursAgo(seconds / 3600) } + } + + private var snrQuality: SNRQuality { + SNRQuality(snr: neighbor.snr) + } + + private var snrLevel: Double { + snrQuality.barLevel + } - private var snrQuality: SNRQuality { SNRQuality(snr: neighbor.snr) } - private var snrLevel: Double { snrQuality.barLevel } - private var snrColor: Color { snrQuality.color } + private var snrColor: Color { + snrQuality.color + } } diff --git a/MC1/Views/RemoteNodes/NeighborSNRChartView.swift b/MC1/Views/RemoteNodes/NeighborSNRChartView.swift index b11df1a1..e76ec063 100644 --- a/MC1/Views/RemoteNodes/NeighborSNRChartView.swift +++ b/MC1/Views/RemoteNodes/NeighborSNRChartView.swift @@ -2,44 +2,44 @@ import MC1Services import SwiftUI struct NeighborSNRChartView: View { - @Environment(\.appTheme) private var theme - let name: String - let neighborPrefix: Data - let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] + @Environment(\.appTheme) private var theme + let name: String + let neighborPrefix: Data + let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] - @State private var allDataPoints: [MetricChartView.DataPoint] = [] - @State private var timeRange: HistoryTimeRange = .default + @State private var allDataPoints: [MetricChartView.DataPoint] = [] + @State private var timeRange: HistoryTimeRange = .default - private var filteredDataPoints: [MetricChartView.DataPoint] { - guard let start = timeRange.startDate else { return allDataPoints } - return allDataPoints.filter { $0.date >= start } - } + private var filteredDataPoints: [MetricChartView.DataPoint] { + guard let start = timeRange.startDate else { return allDataPoints } + return allDataPoints.filter { $0.date >= start } + } - var body: some View { - List { - HistoryTimeRangePicker(selection: $timeRange) + var body: some View { + List { + HistoryTimeRangePicker(selection: $timeRange) - Section { - MetricChartView( - title: name, - unit: "dB", - dataPoints: filteredDataPoints, - accentColor: .blue - ) - } - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationTitle(name) - .liquidGlassToolbarBackground() - .task { - let snapshots = await fetchSnapshots() - allDataPoints = snapshots.compactMap { snapshot in - guard let neighbors = snapshot.neighborSnapshots, - let match = neighbors.first(where: { $0.publicKeyPrefix == neighborPrefix }) - else { return nil } - return MetricChartView.DataPoint(id: snapshot.id, date: snapshot.timestamp, value: match.snr) - } - } + Section { + MetricChartView( + title: name, + unit: "dB", + dataPoints: filteredDataPoints, + accentColor: .blue + ) + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(name) + .liquidGlassToolbarBackground() + .task { + let snapshots = await fetchSnapshots() + allDataPoints = snapshots.compactMap { snapshot in + guard let neighbors = snapshot.neighborSnapshots, + let match = neighbors.first(where: { $0.publicKeyPrefix == neighborPrefix }) + else { return nil } + return MetricChartView.DataPoint(id: snapshot.id, date: snapshot.timestamp, value: match.snr) + } } + } } diff --git a/MC1/Views/RemoteNodes/NodeAuthPathViewModel.swift b/MC1/Views/RemoteNodes/NodeAuthPathViewModel.swift index 2a5c1d09..6fb97100 100644 --- a/MC1/Views/RemoteNodes/NodeAuthPathViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeAuthPathViewModel.swift @@ -6,34 +6,34 @@ import SwiftUI @Observable @MainActor final class NodeAuthPathViewModel { - private(set) var hops: [ResolvedPathHop] = [] + private(set) var hops: [ResolvedPathHop] = [] - private let logger = Logger(subsystem: "com.mc1", category: "NodeAuthPathViewModel") + private let logger = Logger(subsystem: "com.mc1", category: "NodeAuthPathViewModel") - func load( - contact: ContactDTO, - services: ServiceContainer?, - radioID: UUID, - userLocation: CLLocation? - ) async { - guard let services else { - hops = [] - return - } + func load( + contact: ContactDTO, + services: ServiceContainer?, + radioID: UUID, + userLocation: CLLocation? + ) async { + guard let services else { + hops = [] + return + } - do { - let contacts = try await services.dataStore.fetchContacts(radioID: radioID) - let discoveredNodes = try await services.dataStore.fetchDiscoveredNodes(radioID: radioID) + do { + let contacts = try await services.dataStore.fetchContacts(radioID: radioID) + let discoveredNodes = try await services.dataStore.fetchDiscoveredNodes(radioID: radioID) - hops = NeighborNameResolver.resolvePath( - contact.pathHops, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - } catch { - logger.error("Failed to resolve path hops: \(error.localizedDescription)") - hops = [] - } + hops = NeighborNameResolver.resolvePath( + contact.pathHops, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + } catch { + logger.error("Failed to resolve path hops: \(error.localizedDescription)") + hops = [] } + } } diff --git a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift index 02a958b2..1eb547b4 100644 --- a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift +++ b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift @@ -1,455 +1,455 @@ +import MC1Services import os import SwiftUI -import MC1Services private let logger = Logger(subsystem: "com.mc1", category: "NodeAuthenticationSheet") /// Reusable password entry sheet for both room servers and repeaters struct NodeAuthenticationSheet: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - - let contact: ContactDTO - let role: RemoteNodeRole - /// When true, hides the Node Details section (used when re-joining known rooms from chat list) - let hideNodeDetails: Bool - /// Optional custom title. If nil, uses default based on role ("Join Room" or "Admin Access") - let customTitle: String? - let onSuccess: (RemoteNodeSessionDTO) -> Void - - @State private var password: String = "" - @State private var rememberPassword = true - @State private var useFloodRouting: Bool - @State private var didResetPath = false - @State private var isAuthenticating = false - @State private var errorMessage: String? - @State private var hasSavedPassword = false - @State private var pathViewModel = NodeAuthPathViewModel() - - // Countdown state - @State private var authSecondsRemaining: Int? - @State private var authStartTime: Date? - @State private var authTimeoutSeconds: Int? - @State private var countdownTask: Task? - @State private var authenticationTask: Task? - - private let maxPasswordLength = 15 - - init( - contact: ContactDTO, - role: RemoteNodeRole, - hideNodeDetails: Bool = false, - customTitle: String? = nil, - onSuccess: @escaping (RemoteNodeSessionDTO) -> Void - ) { - self.contact = contact - self.role = role - self.hideNodeDetails = hideNodeDetails - self.customTitle = customTitle - self.onSuccess = onSuccess - self._useFloodRouting = State(initialValue: contact.isFloodRouted) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + + let contact: ContactDTO + let role: RemoteNodeRole + /// When true, hides the Node Details section (used when re-joining known rooms from chat list) + let hideNodeDetails: Bool + /// Optional custom title. If nil, uses default based on role ("Join Room" or "Admin Access") + let customTitle: String? + let onSuccess: (RemoteNodeSessionDTO) -> Void + + @State private var password: String = "" + @State private var rememberPassword = true + @State private var useFloodRouting: Bool + @State private var didResetPath = false + @State private var isAuthenticating = false + @State private var errorMessage: String? + @State private var hasSavedPassword = false + @State private var pathViewModel = NodeAuthPathViewModel() + + // Countdown state + @State private var authSecondsRemaining: Int? + @State private var authStartTime: Date? + @State private var authTimeoutSeconds: Int? + @State private var countdownTask: Task? + @State private var authenticationTask: Task? + + private let maxPasswordLength = 15 + + init( + contact: ContactDTO, + role: RemoteNodeRole, + hideNodeDetails: Bool = false, + customTitle: String? = nil, + onSuccess: @escaping (RemoteNodeSessionDTO) -> Void + ) { + self.contact = contact + self.role = role + self.hideNodeDetails = hideNodeDetails + self.customTitle = customTitle + self.onSuccess = onSuccess + _useFloodRouting = State(initialValue: contact.isFloodRouted) + } + + var body: some View { + NavigationStack { + Form { + if !hideNodeDetails { + makeNodeDetailsSection() + } + makeAuthenticationSection() + makePathSection() + makeConnectButton() + } + .themedCanvas(theme) + .navigationTitle(customTitle ?? (role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.management)) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.RemoteNodes.RemoteNodes.Auth.cancel) { + authenticationTask?.cancel() + dismiss() + } + } + } + .task { + if let remoteNodeService = appState.services?.remoteNodeService, + let saved = await remoteNodeService.retrievePassword(forContact: contact) { + password = saved + hasSavedPassword = true + } + } + .task { + guard !contact.isFloodRouted, contact.pathHopCount > 0, + let radioID = appState.connectedDevice?.radioID else { return } + await pathViewModel.load( + contact: contact, + services: appState.services, + radioID: radioID, + userLocation: appState.bestAvailableLocation + ) + } + .sensoryFeedback(.error, trigger: errorMessage) + .onDisappear { + authenticationTask?.cancel() + cleanupCountdownState() + } } + } - var body: some View { - NavigationStack { - Form { - if !hideNodeDetails { - makeNodeDetailsSection() - } - makeAuthenticationSection() - makePathSection() - makeConnectButton() - } - .themedCanvas(theme) - .navigationTitle(customTitle ?? (role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.management)) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.RemoteNodes.RemoteNodes.Auth.cancel) { - authenticationTask?.cancel() - dismiss() - } - } - } - .task { - if let remoteNodeService = appState.services?.remoteNodeService, - let saved = await remoteNodeService.retrievePassword(forContact: contact) { - password = saved - hasSavedPassword = true - } - } - .task { - guard !contact.isFloodRouted, contact.pathHopCount > 0, - let radioID = appState.connectedDevice?.radioID else { return } - await pathViewModel.load( - contact: contact, - services: appState.services, - radioID: radioID, - userLocation: appState.bestAvailableLocation - ) - } - .sensoryFeedback(.error, trigger: errorMessage) - .onDisappear { - authenticationTask?.cancel() - cleanupCountdownState() - } + // MARK: - Sections + + private func makeNodeDetailsSection() -> some View { + NodeDetailsSection( + displayName: contact.displayName, + role: role + ) + } + + private func makeAuthenticationSection() -> some View { + AuthenticationSection( + password: $password, + rememberPassword: $rememberPassword, + errorMessage: $errorMessage, + authSecondsRemaining: $authSecondsRemaining, + role: role, + maxPasswordLength: maxPasswordLength + ) + } + + private func makePathSection() -> some View { + PathSection( + contact: contact, + useFloodRouting: $useFloodRouting, + pathViewModel: pathViewModel + ) + } + + private func makeConnectButton() -> some View { + ConnectButton( + role: role, + isAuthenticating: isAuthenticating, + onAuthenticate: { authenticate() } + ) + } + + // MARK: - Authentication + + private func authenticate() { + // Clear any previous error + errorMessage = nil + isAuthenticating = true + authenticationTask?.cancel() + cleanupCountdownState() + + authenticationTask = Task { + do { + guard let device = appState.connectedDevice else { + throw RemoteNodeError.notConnected } - } - // MARK: - Sections + guard let services = appState.services else { + throw RemoteNodeError.notConnected + } - private func makeNodeDetailsSection() -> some View { - NodeDetailsSection( - displayName: contact.displayName, - role: role - ) - } + // Reset the firmware's stored path so the login packet is flood-routed. + // Only needed once per session — subsequent retries skip the BLE round-trip. + let pathLength: UInt8 + if useFloodRouting && !contact.isFloodRouted && !didResetPath { + try await services.contactService.resetPath( + radioID: device.radioID, + publicKey: contact.publicKey + ) + didResetPath = true + pathLength = PacketBuilder.floodPathSentinel + } else if useFloodRouting { + pathLength = PacketBuilder.floodPathSentinel + } else { + pathLength = contact.outPathLength + } - private func makeAuthenticationSection() -> some View { - AuthenticationSection( - password: $password, - rememberPassword: $rememberPassword, - errorMessage: $errorMessage, - authSecondsRemaining: $authSecondsRemaining, - role: role, - maxPasswordLength: maxPasswordLength - ) - } + let session: RemoteNodeSessionDTO + // MeshCore repeaters and rooms only support 15-character passwords, truncate if needed + let passwordToUse = password.count > maxPasswordLength + ? String(password.prefix(maxPasswordLength)) + : password + + // Callback to start countdown when firmware timeout is known + let onTimeoutKnown: @Sendable (Int) async -> Void = { [self] seconds in + await MainActor.run { + authTimeoutSeconds = seconds + authStartTime = Date.now + authSecondsRemaining = seconds + startCountdownTask() + } + } - private func makePathSection() -> some View { - PathSection( + if role == .roomServer { + session = try await services.roomServerService.joinRoom( + radioID: device.radioID, contact: contact, - useFloodRouting: $useFloodRouting, - pathViewModel: pathViewModel - ) - } + password: passwordToUse, + rememberPassword: rememberPassword, + pathLength: pathLength, + onTimeoutKnown: onTimeoutKnown + ) + } else { + session = try await services.repeaterAdminService.connectAsAdmin( + radioID: device.radioID, + contact: contact, + password: passwordToUse, + rememberPassword: rememberPassword, + pathLength: pathLength, + onTimeoutKnown: onTimeoutKnown + ) + } - private func makeConnectButton() -> some View { - ConnectButton( - role: role, - isAuthenticating: isAuthenticating, - onAuthenticate: { authenticate() } - ) + // Delete saved password if user unchecked "Remember Password" + if hasSavedPassword, !rememberPassword { + do { + try await services.remoteNodeService.deletePassword(forContact: contact) + } catch { + logger.warning("Failed to delete saved password: \(error)") + } + } + + await MainActor.run { + authenticationTask = nil + cleanupCountdownState() + dismiss() + onSuccess(session) + } + } catch is CancellationError { + await MainActor.run { + authenticationTask = nil + cleanupCountdownState() + isAuthenticating = false + } + } catch RemoteNodeError.timeout { + await MainActor.run { + authenticationTask = nil + cleanupCountdownState() + errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + isAuthenticating = false + } + } catch { + await MainActor.run { + authenticationTask = nil + cleanupCountdownState() + errorMessage = error.userFacingMessage + isAuthenticating = false + } + } } + } - // MARK: - Authentication + // MARK: - Countdown - private func authenticate() { - // Clear any previous error - errorMessage = nil - isAuthenticating = true - authenticationTask?.cancel() - cleanupCountdownState() + private static let countdownTickInterval: Duration = .seconds(1) - authenticationTask = Task { - do { - guard let device = appState.connectedDevice else { - throw RemoteNodeError.notConnected - } - - guard let services = appState.services else { - throw RemoteNodeError.notConnected - } - - // Reset the firmware's stored path so the login packet is flood-routed. - // Only needed once per session — subsequent retries skip the BLE round-trip. - let pathLength: UInt8 - if useFloodRouting && !contact.isFloodRouted && !didResetPath { - try await services.contactService.resetPath( - radioID: device.radioID, - publicKey: contact.publicKey - ) - didResetPath = true - pathLength = PacketBuilder.floodPathSentinel - } else if useFloodRouting { - pathLength = PacketBuilder.floodPathSentinel - } else { - pathLength = contact.outPathLength - } - - let session: RemoteNodeSessionDTO - // MeshCore repeaters and rooms only support 15-character passwords, truncate if needed - let passwordToUse = password.count > maxPasswordLength - ? String(password.prefix(maxPasswordLength)) - : password - - // Callback to start countdown when firmware timeout is known - let onTimeoutKnown: @Sendable (Int) async -> Void = { [self] seconds in - await MainActor.run { - self.authTimeoutSeconds = seconds - self.authStartTime = Date.now - self.authSecondsRemaining = seconds - self.startCountdownTask() - } - } - - if role == .roomServer { - session = try await services.roomServerService.joinRoom( - radioID: device.radioID, - contact: contact, - password: passwordToUse, - rememberPassword: rememberPassword, - pathLength: pathLength, - onTimeoutKnown: onTimeoutKnown - ) - } else { - session = try await services.repeaterAdminService.connectAsAdmin( - radioID: device.radioID, - contact: contact, - password: passwordToUse, - rememberPassword: rememberPassword, - pathLength: pathLength, - onTimeoutKnown: onTimeoutKnown - ) - } - - // Delete saved password if user unchecked "Remember Password" - if hasSavedPassword && !rememberPassword { - do { - try await services.remoteNodeService.deletePassword(forContact: contact) - } catch { - logger.warning("Failed to delete saved password: \(error)") - } - } - - await MainActor.run { - authenticationTask = nil - cleanupCountdownState() - dismiss() - onSuccess(session) - } - } catch is CancellationError { - await MainActor.run { - authenticationTask = nil - cleanupCountdownState() - isAuthenticating = false - } - } catch RemoteNodeError.timeout { - await MainActor.run { - authenticationTask = nil - cleanupCountdownState() - errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut - isAuthenticating = false - } - } catch { - await MainActor.run { - authenticationTask = nil - cleanupCountdownState() - errorMessage = error.userFacingMessage - isAuthenticating = false - } - } + private func startCountdownTask() { + countdownTask = Task { + while !Task.isCancelled, let timeout = authTimeoutSeconds, let startTime = authStartTime { + do { + try await Task.sleep(for: Self.countdownTickInterval) + } catch { + break } - } - // MARK: - Countdown - - private static let countdownTickInterval: Duration = .seconds(1) - - private func startCountdownTask() { - countdownTask = Task { - while !Task.isCancelled, let timeout = authTimeoutSeconds, let startTime = authStartTime { - do { - try await Task.sleep(for: Self.countdownTickInterval) - } catch { - break - } - - let elapsed = Date.now.timeIntervalSince(startTime) - let remaining = max(0, timeout - Int(elapsed)) - authSecondsRemaining = remaining - if remaining == 0 { - break - } - } + let elapsed = Date.now.timeIntervalSince(startTime) + let remaining = max(0, timeout - Int(elapsed)) + authSecondsRemaining = remaining + if remaining == 0 { + break } + } } - - private func cleanupCountdownState() { - countdownTask?.cancel() - countdownTask = nil - authSecondsRemaining = nil - authStartTime = nil - authTimeoutSeconds = nil - } + } + + private func cleanupCountdownState() { + countdownTask?.cancel() + countdownTask = nil + authSecondsRemaining = nil + authStartTime = nil + authTimeoutSeconds = nil + } } // MARK: - Node Details Section private struct NodeDetailsSection: View { - @Environment(\.appTheme) private var theme - let displayName: String - let role: RemoteNodeRole - - var body: some View { - Section { - LabeledContent(L10n.RemoteNodes.RemoteNodes.Auth.name, value: displayName) - LabeledContent(L10n.RemoteNodes.RemoteNodes.Auth.type, value: role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.typeRoom : L10n.RemoteNodes.RemoteNodes.Auth.typeRepeater) - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Auth.nodeDetails) - } - .themedRowBackground(theme) + @Environment(\.appTheme) private var theme + let displayName: String + let role: RemoteNodeRole + + var body: some View { + Section { + LabeledContent(L10n.RemoteNodes.RemoteNodes.Auth.name, value: displayName) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Auth.type, value: role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.typeRoom : L10n.RemoteNodes.RemoteNodes.Auth.typeRepeater) + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Auth.nodeDetails) } + .themedRowBackground(theme) + } } // MARK: - Authentication Section private struct AuthenticationSection: View { - /// Countdown values at which VoiceOver gets a time-remaining announcement. - private static let announcementThresholds = [30, 15, 10] - /// Below this many seconds, every countdown tick is announced. - private static let finalCountdownSeconds = 5 - - @Environment(\.appTheme) private var theme - @Binding var password: String - @Binding var rememberPassword: Bool - @Binding var errorMessage: String? - @Binding var authSecondsRemaining: Int? - let role: RemoteNodeRole - let maxPasswordLength: Int - - var body: some View { - Section { - SecureField(L10n.RemoteNodes.RemoteNodes.Auth.password, text: $password) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - Toggle(L10n.RemoteNodes.RemoteNodes.Auth.rememberPassword, isOn: $rememberPassword) - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Auth.authentication) - } footer: { - if let errorMessage { - Label(errorMessage, systemImage: "exclamationmark.circle.fill") - .foregroundStyle(.orange) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Auth.errorPrefix(errorMessage)) - } else if password.count > maxPasswordLength { - Text(role == .repeater ? L10n.RemoteNodes.RemoteNodes.Auth.passwordTooLongRepeaters(maxPasswordLength) : L10n.RemoteNodes.RemoteNodes.Auth.passwordTooLongRooms(maxPasswordLength)) - } else if let remaining = authSecondsRemaining, remaining > 0 { - Text(L10n.RemoteNodes.RemoteNodes.Auth.secondsRemaining(remaining)) - } else { - Text(" ") - .accessibilityHidden(true) - } - } - .themedRowBackground(theme) - .onChange(of: password) { - if errorMessage != nil { - errorMessage = nil - } - } - .onChange(of: authSecondsRemaining) { oldValue, newValue in - guard let remaining = newValue, remaining > 0 else { return } - // Threshold-crossing rather than exact equality so a skipped tick - // can't silently drop an announcement. - let crossedThreshold = Self.announcementThresholds.contains { threshold in - remaining <= threshold && (oldValue ?? Int.max) > threshold - } - let shouldAnnounce = oldValue == nil || crossedThreshold || remaining <= Self.finalCountdownSeconds - if shouldAnnounce { - AccessibilityNotification.Announcement(L10n.RemoteNodes.RemoteNodes.Auth.secondsRemainingAnnouncement(remaining)).post() - } - } + /// Countdown values at which VoiceOver gets a time-remaining announcement. + private static let announcementThresholds = [30, 15, 10] + /// Below this many seconds, every countdown tick is announced. + private static let finalCountdownSeconds = 5 + + @Environment(\.appTheme) private var theme + @Binding var password: String + @Binding var rememberPassword: Bool + @Binding var errorMessage: String? + @Binding var authSecondsRemaining: Int? + let role: RemoteNodeRole + let maxPasswordLength: Int + + var body: some View { + Section { + SecureField(L10n.RemoteNodes.RemoteNodes.Auth.password, text: $password) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Toggle(L10n.RemoteNodes.RemoteNodes.Auth.rememberPassword, isOn: $rememberPassword) + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Auth.authentication) + } footer: { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Auth.errorPrefix(errorMessage)) + } else if password.count > maxPasswordLength { + Text(role == .repeater ? L10n.RemoteNodes.RemoteNodes.Auth.passwordTooLongRepeaters(maxPasswordLength) : L10n.RemoteNodes.RemoteNodes.Auth.passwordTooLongRooms(maxPasswordLength)) + } else if let remaining = authSecondsRemaining, remaining > 0 { + Text(L10n.RemoteNodes.RemoteNodes.Auth.secondsRemaining(remaining)) + } else { + Text(" ") + .accessibilityHidden(true) + } + } + .themedRowBackground(theme) + .onChange(of: password) { + if errorMessage != nil { + errorMessage = nil + } } + .onChange(of: authSecondsRemaining) { oldValue, newValue in + guard let remaining = newValue, remaining > 0 else { return } + // Threshold-crossing rather than exact equality so a skipped tick + // can't silently drop an announcement. + let crossedThreshold = Self.announcementThresholds.contains { threshold in + remaining <= threshold && (oldValue ?? Int.max) > threshold + } + let shouldAnnounce = oldValue == nil || crossedThreshold || remaining <= Self.finalCountdownSeconds + if shouldAnnounce { + AccessibilityNotification.Announcement(L10n.RemoteNodes.RemoteNodes.Auth.secondsRemainingAnnouncement(remaining)).post() + } + } + } } // MARK: - Path Section private struct PathSection: View { - @Environment(\.appTheme) private var theme - let contact: ContactDTO - @Binding var useFloodRouting: Bool - let pathViewModel: NodeAuthPathViewModel - - @State private var isPathExpanded = false - - private var hasStoredPath: Bool { - !contact.isFloodRouted - } - - var body: some View { - Section { - if hasStoredPath && !useFloodRouting { - if !pathViewModel.hops.isEmpty { - DisclosureGroup(isExpanded: $isPathExpanded) { - ForEach(pathViewModel.hops) { hop in - NodePathHopRow(hex: hop.hex, resolution: hop.resolution) - } - } label: { - NodePathSummaryLabel(contact: contact) - } - } else { - NodePathSummaryLabel(contact: contact) - } - } else if !hasStoredPath { - Label { - Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteSet) - .foregroundStyle(.secondary) - } icon: { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - .accessibilityHidden(true) - } - - Toggle(L10n.RemoteNodes.RemoteNodes.Auth.floodRouting, isOn: $useFloodRouting) - .disabled(!hasStoredPath) - .accessibilityHint(hasStoredPath ? "" : L10n.RemoteNodes.RemoteNodes.Auth.noRouteFooter) - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Auth.path) - } footer: { - if hasStoredPath { - Text(L10n.RemoteNodes.RemoteNodes.Auth.pathFooter) - } else { - Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteFooter) + @Environment(\.appTheme) private var theme + let contact: ContactDTO + @Binding var useFloodRouting: Bool + let pathViewModel: NodeAuthPathViewModel + + @State private var isPathExpanded = false + + private var hasStoredPath: Bool { + !contact.isFloodRouted + } + + var body: some View { + Section { + if hasStoredPath, !useFloodRouting { + if !pathViewModel.hops.isEmpty { + DisclosureGroup(isExpanded: $isPathExpanded) { + ForEach(pathViewModel.hops) { hop in + NodePathHopRow(hex: hop.hex, resolution: hop.resolution) } + } label: { + NodePathSummaryLabel(contact: contact) + } + } else { + NodePathSummaryLabel(contact: contact) } - .themedRowBackground(theme) - .animation(.default, value: useFloodRouting) + } else if !hasStoredPath { + Label { + Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteSet) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + .accessibilityHidden(true) + } + + Toggle(L10n.RemoteNodes.RemoteNodes.Auth.floodRouting, isOn: $useFloodRouting) + .disabled(!hasStoredPath) + .accessibilityHint(hasStoredPath ? "" : L10n.RemoteNodes.RemoteNodes.Auth.noRouteFooter) + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Auth.path) + } footer: { + if hasStoredPath { + Text(L10n.RemoteNodes.RemoteNodes.Auth.pathFooter) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteFooter) + } } + .themedRowBackground(theme) + .animation(.default, value: useFloodRouting) + } } // MARK: - Connect Button private struct ConnectButton: View { - @Environment(\.appTheme) private var theme - let role: RemoteNodeRole - let isAuthenticating: Bool - let onAuthenticate: () -> Void - - private var buttonLabel: String { - role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.connect - } - - var body: some View { - Section { - Button { - onAuthenticate() - } label: { - if isAuthenticating { - ProgressView() - .frame(maxWidth: .infinity) - } else { - Text(buttonLabel) - .frame(maxWidth: .infinity) - } - } - .disabled(isAuthenticating) + @Environment(\.appTheme) private var theme + let role: RemoteNodeRole + let isAuthenticating: Bool + let onAuthenticate: () -> Void + + private var buttonLabel: String { + role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.connect + } + + var body: some View { + Section { + Button { + onAuthenticate() + } label: { + if isAuthenticating { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text(buttonLabel) + .frame(maxWidth: .infinity) } - .themedRowBackground(theme) + } + .disabled(isAuthenticating) } + .themedRowBackground(theme) + } } #Preview { - NodeAuthenticationSheet( - contact: ContactDTO(from: Contact( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Room", - typeRawValue: ContactType.room.rawValue - )), - role: .roomServer, - onSuccess: { _ in } - ) - .environment(\.appState, AppState()) + NodeAuthenticationSheet( + contact: ContactDTO(from: Contact( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + typeRawValue: ContactType.room.rawValue + )), + role: .roomServer, + onSuccess: { _ in } + ) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/RemoteNodes/NodeCLIView.swift b/MC1/Views/RemoteNodes/NodeCLIView.swift index 193a4504..b808969d 100644 --- a/MC1/Views/RemoteNodes/NodeCLIView.swift +++ b/MC1/Views/RemoteNodes/NodeCLIView.swift @@ -4,79 +4,79 @@ import UIKit /// Hosts the terminal for a single managed node, owning the per-instance local /// state the terminal needs and wiring callbacks to `NodeCLIViewModel`. struct NodeCLIView: View { - @Bindable var viewModel: NodeCLIViewModel + @Bindable var viewModel: NodeCLIViewModel - @State private var isKeyboardFocused = false - @State private var scrollPosition = ScrollPosition(edge: .bottom) - @State private var cursorPosition: Int = 0 + @State private var isKeyboardFocused = false + @State private var scrollPosition = ScrollPosition(edge: .bottom) + @State private var cursorPosition: Int = 0 - var body: some View { - CLITerminalView( - outputLines: viewModel.outputLines, - promptText: viewModel.promptText, - ghostText: viewModel.ghostText, - tabSuggestions: viewModel.tabSuggestions, - tabSelectionIndex: viewModel.tabSelectionIndex, - isWaitingForResponse: viewModel.isWaitingForResponse, - showSessionsButton: false, - currentInput: $viewModel.currentInput, - isKeyboardFocused: $isKeyboardFocused, - scrollPosition: $scrollPosition, - cursorPosition: $cursorPosition, - onSubmit: { - if viewModel.applySelectedSuggestion() { - cursorPosition = viewModel.currentInput.count - } else { - viewModel.executeCommand(viewModel.currentInput) - } - }, - onHistoryUp: { - viewModel.historyUp() - cursorPosition = viewModel.currentInput.count - }, - onHistoryDown: { - viewModel.historyDown() - cursorPosition = viewModel.currentInput.count - }, - onRightArrowAtEnd: { - if !viewModel.ghostText.isEmpty { - viewModel.acceptGhostText() - cursorPosition = viewModel.currentInput.count - } - }, - onTabComplete: { - viewModel.tabComplete() - cursorPosition = viewModel.currentInput.count - }, - onMoveLeft: { - if cursorPosition > 0 { cursorPosition -= 1 } - }, - onMoveRight: { - if !viewModel.ghostText.isEmpty && cursorPosition >= viewModel.currentInput.count { - viewModel.acceptGhostText() - cursorPosition = viewModel.currentInput.count - } else if cursorPosition < viewModel.currentInput.count { - cursorPosition += 1 - } - }, - onPaste: { - viewModel.pasteFromClipboard(at: cursorPosition) - cursorPosition = min( - cursorPosition + (UIPasteboard.general.string?.count ?? 0), - viewModel.currentInput.count - ) - }, - onSessions: {}, - onCancel: { viewModel.cancelCurrentCommand() }, - onDismiss: { isKeyboardFocused = false }, - onClear: { viewModel.executeCommand("clear") }, - onUpdateGhostText: { cursorAtEnd in viewModel.updateGhostText(cursorAtEnd: cursorAtEnd) }, - onClearTabState: { viewModel.clearTabState() }, - onGetResponseBlock: { viewModel.getResponseBlock(containing: $0) } + var body: some View { + CLITerminalView( + outputLines: viewModel.outputLines, + promptText: viewModel.promptText, + ghostText: viewModel.ghostText, + tabSuggestions: viewModel.tabSuggestions, + tabSelectionIndex: viewModel.tabSelectionIndex, + isWaitingForResponse: viewModel.isWaitingForResponse, + showSessionsButton: false, + currentInput: $viewModel.currentInput, + isKeyboardFocused: $isKeyboardFocused, + scrollPosition: $scrollPosition, + cursorPosition: $cursorPosition, + onSubmit: { + if viewModel.applySelectedSuggestion() { + cursorPosition = viewModel.currentInput.count + } else { + viewModel.executeCommand(viewModel.currentInput) + } + }, + onHistoryUp: { + viewModel.historyUp() + cursorPosition = viewModel.currentInput.count + }, + onHistoryDown: { + viewModel.historyDown() + cursorPosition = viewModel.currentInput.count + }, + onRightArrowAtEnd: { + if !viewModel.ghostText.isEmpty { + viewModel.acceptGhostText() + cursorPosition = viewModel.currentInput.count + } + }, + onTabComplete: { + viewModel.tabComplete() + cursorPosition = viewModel.currentInput.count + }, + onMoveLeft: { + if cursorPosition > 0 { cursorPosition -= 1 } + }, + onMoveRight: { + if !viewModel.ghostText.isEmpty, cursorPosition >= viewModel.currentInput.count { + viewModel.acceptGhostText() + cursorPosition = viewModel.currentInput.count + } else if cursorPosition < viewModel.currentInput.count { + cursorPosition += 1 + } + }, + onPaste: { + viewModel.pasteFromClipboard(at: cursorPosition) + cursorPosition = min( + cursorPosition + (UIPasteboard.general.string?.count ?? 0), + viewModel.currentInput.count ) - // Restore the cursor to the end of surviving input when the view is - // recreated by a Settings/CLI segment toggle; cursorPosition is - // view-local @State and resets to 0, but currentInput lives on the VM. - .onAppear { cursorPosition = viewModel.currentInput.count } - } + }, + onSessions: {}, + onCancel: { viewModel.cancelCurrentCommand() }, + onDismiss: { isKeyboardFocused = false }, + onClear: { viewModel.executeCommand("clear") }, + onUpdateGhostText: { cursorAtEnd in viewModel.updateGhostText(cursorAtEnd: cursorAtEnd) }, + onClearTabState: { viewModel.clearTabState() }, + onGetResponseBlock: { viewModel.getResponseBlock(containing: $0) } + ) + // Restore the cursor to the end of surviving input when the view is + // recreated by a Settings/CLI segment toggle; cursorPosition is + // view-local @State and resets to 0, but currentInput lives on the VM. + .onAppear { cursorPosition = viewModel.currentInput.count } + } } diff --git a/MC1/Views/RemoteNodes/NodeCLIViewModel.swift b/MC1/Views/RemoteNodes/NodeCLIViewModel.swift index a8887df9..c768da14 100644 --- a/MC1/Views/RemoteNodes/NodeCLIViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeCLIViewModel.swift @@ -5,286 +5,286 @@ import UIKit @Observable @MainActor final class NodeCLIViewModel { - private static let maxOutputLines = 1000 - private static let maxHistoryEntries = 100 - private static let rebootTimeout: Duration = .seconds(2) - private static let defaultCommandTimeout: Duration = .seconds(10) - - // MARK: - Terminal State - - private(set) var outputLines: [CLIOutputLine] = [] - private(set) var commandHistory: [String] = [] - private(set) var historyIndex: Int? - var currentInput: String = "" - var isWaitingForResponse = false - - // MARK: - Completion State - - let completionEngine = CLICompletionEngine() - var ghostText: String = "" - var tabSuggestions: [String]? - var tabSelectionIndex: Int? + private static let maxOutputLines = 1000 + private static let maxHistoryEntries = 100 + private static let rebootTimeout: Duration = .seconds(2) + private static let defaultCommandTimeout: Duration = .seconds(10) + + // MARK: - Terminal State + + private(set) var outputLines: [CLIOutputLine] = [] + private(set) var commandHistory: [String] = [] + private(set) var historyIndex: Int? + var currentInput: String = "" + var isWaitingForResponse = false + + // MARK: - Completion State + + let completionEngine = CLICompletionEngine() + var ghostText: String = "" + var tabSuggestions: [String]? + var tabSelectionIndex: Int? + + // MARK: - Dependencies + + private var sessionName: String = "" + private var sendRawCommand: (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? + private var currentCommandTask: Task? + private var hasConfigured = false + + // MARK: - Prompt + + var promptText: String { + if isWaitingForResponse { return "" } + return "@\(sessionName)\(L10n.Tools.Tools.Cli.promptSuffix) " + } + + // MARK: - Setup + + /// Configures the node CLI with its display name and send closure. + /// Idempotent: the connection banner is appended only on the first call, + /// so toggling the Settings/CLI segment does not re-banner. + func configure( + sessionName: String, + sendRawCommand: @escaping @MainActor (_ command: String, _ timeout: Duration) async throws -> String + ) { + self.sessionName = sessionName + self.sendRawCommand = sendRawCommand + guard !hasConfigured else { return } + hasConfigured = true + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.bannerConnected(sessionName), type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.bannerHint, type: .response) + appendOutput("", type: .response) + } + + // MARK: - Command Execution + + func executeCommand(_ command: String) { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isWaitingForResponse else { return } + let promptPrefix = promptText.trimmingCharacters(in: .whitespaces) + + guard !trimmed.isEmpty else { + appendOutput(promptPrefix, type: .command) + return + } - // MARK: - Dependencies + addToHistory(trimmed) + appendOutput("\(promptPrefix) \(trimmed)", type: .command) - private var sessionName: String = "" - private var sendRawCommand: (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? - private var currentCommandTask: Task? - private var hasConfigured = false + let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) + let cmd = parts[0].lowercased() + let args = parts.count > 1 ? parts[1] : "" - // MARK: - Prompt + currentCommandTask = Task { await handleCommand(cmd, args: args, raw: trimmed) } + currentInput = "" + } - var promptText: String { - if isWaitingForResponse { return "" } - return "@\(sessionName)\(L10n.Tools.Tools.Cli.promptSuffix) " + func cancelCurrentCommand() { + currentCommandTask?.cancel() + currentCommandTask = nil + if isWaitingForResponse { + isWaitingForResponse = false + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.cancelled, type: .error) } + } - // MARK: - Setup - - /// Configures the node CLI with its display name and send closure. - /// Idempotent: the connection banner is appended only on the first call, - /// so toggling the Settings/CLI segment does not re-banner. - func configure( - sessionName: String, - sendRawCommand: @escaping @MainActor (_ command: String, _ timeout: Duration) async throws -> String - ) { - self.sessionName = sessionName - self.sendRawCommand = sendRawCommand - guard !hasConfigured else { return } - hasConfigured = true - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.bannerConnected(sessionName), type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.bannerHint, type: .response) - appendOutput("", type: .response) + private func handleCommand(_ cmd: String, args: String, raw: String) async { + switch cmd { + case "help": showHelp() + case "clear" where args.isEmpty: outputLines.removeAll() + default: await sendCommand(raw) } - - // MARK: - Command Execution - - func executeCommand(_ command: String) { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !isWaitingForResponse else { return } - let promptPrefix = promptText.trimmingCharacters(in: .whitespaces) - - guard !trimmed.isEmpty else { - appendOutput(promptPrefix, type: .command) - return - } - - addToHistory(trimmed) - appendOutput("\(promptPrefix) \(trimmed)", type: .command) - - let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) - let cmd = parts[0].lowercased() - let args = parts.count > 1 ? parts[1] : "" - - currentCommandTask = Task { await handleCommand(cmd, args: args, raw: trimmed) } - currentInput = "" + } + + private func sendCommand(_ command: String) async { + guard let sendRawCommand else { return } + + // Reboot does not reply; treat both success and timeout as success. + let normalized = command.lowercased() + if normalized == "reboot" || normalized == "reboot now" { + isWaitingForResponse = true + defer { isWaitingForResponse = false } + do { + _ = try await sendRawCommand(command, Self.rebootTimeout) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.rebootSent, type: .success) + } catch RemoteNodeError.timeout { + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.rebootSent, type: .success) + } catch is CancellationError { + } catch { + appendOutput(error.localizedDescription, type: .error) + } + return } - func cancelCurrentCommand() { - currentCommandTask?.cancel() - currentCommandTask = nil - if isWaitingForResponse { - isWaitingForResponse = false - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.cancelled, type: .error) - } + isWaitingForResponse = true + defer { isWaitingForResponse = false } + do { + let response = try await sendRawCommand(command, Self.defaultCommandTimeout) + guard !Task.isCancelled else { return } + appendOutput(response, type: .response) + } catch is CancellationError { + } catch { + appendOutput(error.localizedDescription, type: .error) } - - private func handleCommand(_ cmd: String, args: String, raw: String) async { - switch cmd { - case "help": showHelp() - case "clear" where args.isEmpty: outputLines.removeAll() - default: await sendCommand(raw) - } + } + + private func showHelp() { + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpHeader, type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpHelp, type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpClear, type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpClearStats, type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpReboot, type: .response) + appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpPassthrough, type: .response) + } + + // MARK: - History + + private func addToHistory(_ command: String) { + commandHistory.append(command) + if commandHistory.count > Self.maxHistoryEntries { + commandHistory.removeFirst() } - - private func sendCommand(_ command: String) async { - guard let sendRawCommand else { return } - - // Reboot does not reply; treat both success and timeout as success. - let normalized = command.lowercased() - if normalized == "reboot" || normalized == "reboot now" { - isWaitingForResponse = true - defer { isWaitingForResponse = false } - do { - _ = try await sendRawCommand(command, Self.rebootTimeout) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.rebootSent, type: .success) - } catch RemoteNodeError.timeout { - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.rebootSent, type: .success) - } catch is CancellationError { - } catch { - appendOutput(error.localizedDescription, type: .error) - } - return - } - - isWaitingForResponse = true - defer { isWaitingForResponse = false } - do { - let response = try await sendRawCommand(command, Self.defaultCommandTimeout) - guard !Task.isCancelled else { return } - appendOutput(response, type: .response) - } catch is CancellationError { - } catch { - appendOutput(error.localizedDescription, type: .error) - } + historyIndex = nil + } + + func historyUp() { + guard !commandHistory.isEmpty else { return } + if let index = historyIndex { + if index > 0 { historyIndex = index - 1 } + } else { + historyIndex = commandHistory.count - 1 } - - private func showHelp() { - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpHeader, type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpHelp, type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpClear, type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpClearStats, type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpReboot, type: .response) - appendOutput(L10n.RemoteNodes.RemoteNodes.NodeCli.helpPassthrough, type: .response) + if let index = historyIndex { currentInput = commandHistory[index] } + } + + func historyDown() { + guard let index = historyIndex else { return } + if index < commandHistory.count - 1 { + historyIndex = index + 1 + currentInput = commandHistory[index + 1] + } else { + historyIndex = nil + currentInput = "" } + } - // MARK: - History + // MARK: - Output - private func addToHistory(_ command: String) { - commandHistory.append(command) - if commandHistory.count > Self.maxHistoryEntries { - commandHistory.removeFirst() - } - historyIndex = nil + func appendOutput(_ text: String, type: CLIOutputType) { + outputLines.append(CLIOutputLine(text: text, type: type)) + if outputLines.count > Self.maxOutputLines { + outputLines.removeFirst() } - - func historyUp() { - guard !commandHistory.isEmpty else { return } - if let index = historyIndex { - if index > 0 { historyIndex = index - 1 } - } else { - historyIndex = commandHistory.count - 1 - } - if let index = historyIndex { currentInput = commandHistory[index] } + } + + /// Returns the full response block containing the given line, stripping + /// prompt and MeshCore "> " prefixes. Twin of `CLIToolViewModel.getResponseBlock(containing:)`; + /// keep the two in sync. + func getResponseBlock(containing line: CLIOutputLine) -> String { + guard let index = outputLines.firstIndex(where: { $0.id == line.id }) else { + return line.text } - - func historyDown() { - guard let index = historyIndex else { return } - if index < commandHistory.count - 1 { - historyIndex = index + 1 - currentInput = commandHistory[index + 1] - } else { - historyIndex = nil - currentInput = "" - } + if line.type == .command { + if let range = line.text.range(of: "> ") { + return String(line.text[range.upperBound...]) + } + return line.text } - - // MARK: - Output - - func appendOutput(_ text: String, type: CLIOutputType) { - outputLines.append(CLIOutputLine(text: text, type: type)) - if outputLines.count > Self.maxOutputLines { - outputLines.removeFirst() - } + var startIndex = index + while startIndex > 0, outputLines[startIndex - 1].type != .command { + startIndex -= 1 } - - /// Returns the full response block containing the given line, stripping - /// prompt and MeshCore "> " prefixes. Twin of `CLIToolViewModel.getResponseBlock(containing:)`; - /// keep the two in sync. - func getResponseBlock(containing line: CLIOutputLine) -> String { - guard let index = outputLines.firstIndex(where: { $0.id == line.id }) else { - return line.text - } - if line.type == .command { - if let range = line.text.range(of: "> ") { - return String(line.text[range.upperBound...]) - } - return line.text - } - var startIndex = index - while startIndex > 0 && outputLines[startIndex - 1].type != .command { - startIndex -= 1 - } - var endIndex = index - while endIndex < outputLines.count - 1 && outputLines[endIndex + 1].type != .command { - endIndex += 1 - } - return outputLines[startIndex...endIndex] - .map { $0.text.hasPrefix("> ") ? String($0.text.dropFirst(2)) : $0.text } - .joined(separator: "\n") - } - - func pasteFromClipboard(at cursorPosition: Int) { - guard let text = UIPasteboard.general.string else { return } - let safePosition = min(cursorPosition, currentInput.count) - let index = currentInput.index(currentInput.startIndex, offsetBy: safePosition) - currentInput.insert(contentsOf: text, at: index) + var endIndex = index + while endIndex < outputLines.count - 1, outputLines[endIndex + 1].type != .command { + endIndex += 1 } + return outputLines[startIndex...endIndex] + .map { $0.text.hasPrefix("> ") ? String($0.text.dropFirst(2)) : $0.text } + .joined(separator: "\n") + } + + func pasteFromClipboard(at cursorPosition: Int) { + guard let text = UIPasteboard.general.string else { return } + let safePosition = min(cursorPosition, currentInput.count) + let index = currentInput.index(currentInput.startIndex, offsetBy: safePosition) + currentInput.insert(contentsOf: text, at: index) + } } // MARK: - Completion extension NodeCLIViewModel { - /// Node CLI always operates on a remote session, so completion uses the - /// remote command set (`isLocal: false`) and excludes the app-CLI - /// session-management commands the node firmware can't handle. - func updateGhostText(cursorAtEnd: Bool) { - guard !currentInput.isEmpty, cursorAtEnd else { ghostText = ""; return } - let suggestions = completionEngine.completions(for: currentInput, isLocal: false, includeSessionCommands: false) - guard let first = suggestions.first else { ghostText = ""; return } - let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) - let lastPart = parts.last.map(String.init) ?? "" - ghostText = first.lowercased().hasPrefix(lastPart.lowercased()) - ? String(first.dropFirst(lastPart.count)) - : "" + /// Node CLI always operates on a remote session, so completion uses the + /// remote command set (`isLocal: false`) and excludes the app-CLI + /// session-management commands the node firmware can't handle. + func updateGhostText(cursorAtEnd: Bool) { + guard !currentInput.isEmpty, cursorAtEnd else { ghostText = ""; return } + let suggestions = completionEngine.completions(for: currentInput, isLocal: false, includeSessionCommands: false) + guard let first = suggestions.first else { ghostText = ""; return } + let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) + let lastPart = parts.last.map(String.init) ?? "" + ghostText = first.lowercased().hasPrefix(lastPart.lowercased()) + ? String(first.dropFirst(lastPart.count)) + : "" + } + + func acceptGhostText() { + guard !ghostText.isEmpty else { return } + currentInput += ghostText + ghostText = "" + } + + @discardableResult + func tabComplete() -> [String]? { + if let suggestions = tabSuggestions, !suggestions.isEmpty { + if let currentIndex = tabSelectionIndex { + tabSelectionIndex = (currentIndex + 1) % suggestions.count + } else { + tabSelectionIndex = 0 + } + return suggestions } - - func acceptGhostText() { - guard !ghostText.isEmpty else { return } - currentInput += ghostText - ghostText = "" - } - - @discardableResult - func tabComplete() -> [String]? { - if let suggestions = tabSuggestions, !suggestions.isEmpty { - if let currentIndex = tabSelectionIndex { - tabSelectionIndex = (currentIndex + 1) % suggestions.count - } else { - tabSelectionIndex = 0 - } - return suggestions - } - let suggestions = completionEngine.completions(for: currentInput, isLocal: false, includeSessionCommands: false) - guard !suggestions.isEmpty else { - tabSuggestions = nil - tabSelectionIndex = nil - return nil - } - if suggestions.count == 1 { - applyCompletion(suggestions[0]) - return nil - } - tabSuggestions = suggestions - tabSelectionIndex = nil - return suggestions + let suggestions = completionEngine.completions(for: currentInput, isLocal: false, includeSessionCommands: false) + guard !suggestions.isEmpty else { + tabSuggestions = nil + tabSelectionIndex = nil + return nil } - - func applySelectedSuggestion() -> Bool { - guard let suggestions = tabSuggestions, - let index = tabSelectionIndex, - index < suggestions.count else { - return false - } - applyCompletion(suggestions[index]) - clearTabState() - return true + if suggestions.count == 1 { + applyCompletion(suggestions[0]) + return nil } - - func clearTabState() { - tabSuggestions = nil - tabSelectionIndex = nil + tabSuggestions = suggestions + tabSelectionIndex = nil + return suggestions + } + + func applySelectedSuggestion() -> Bool { + guard let suggestions = tabSuggestions, + let index = tabSelectionIndex, + index < suggestions.count else { + return false } - - private func applyCompletion(_ suggestion: String) { - let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) - if parts.count <= 1 { - currentInput = suggestion + " " - } else { - var newParts = parts.dropLast().map(String.init) - newParts.append(suggestion) - currentInput = newParts.joined(separator: " ") + " " - } - ghostText = "" + applyCompletion(suggestions[index]) + clearTabState() + return true + } + + func clearTabState() { + tabSuggestions = nil + tabSelectionIndex = nil + } + + private func applyCompletion(_ suggestion: String) { + let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) + if parts.count <= 1 { + currentInput = suggestion + " " + } else { + var newParts = parts.dropLast().map(String.init) + newParts.append(suggestion) + currentInput = newParts.joined(separator: " ") + " " } + ghostText = "" + } } diff --git a/MC1/Views/RemoteNodes/NodeManagementTab.swift b/MC1/Views/RemoteNodes/NodeManagementTab.swift index fa92e05e..dce0c4ba 100644 --- a/MC1/Views/RemoteNodes/NodeManagementTab.swift +++ b/MC1/Views/RemoteNodes/NodeManagementTab.swift @@ -3,16 +3,16 @@ import Foundation /// Top-of-page segments on the repeater/room admin management page. /// No raw value: the selection is in-memory `@State`, never persisted, so /// `CaseIterable` (with the synthesized `Hashable`) is all the picker needs. -enum NodeManagementTab: CaseIterable, Sendable { - case settings - case cli - case telemetry +enum NodeManagementTab: CaseIterable { + case settings + case cli + case telemetry - var label: String { - switch self { - case .settings: L10n.RemoteNodes.RemoteNodes.Settings.Tab.settings - case .cli: L10n.RemoteNodes.RemoteNodes.Settings.Tab.cli - case .telemetry: L10n.RemoteNodes.RemoteNodes.Settings.Tab.telemetry - } + var label: String { + switch self { + case .settings: L10n.RemoteNodes.RemoteNodes.Settings.Tab.settings + case .cli: L10n.RemoteNodes.RemoteNodes.Settings.Tab.cli + case .telemetry: L10n.RemoteNodes.RemoteNodes.Settings.Tab.telemetry } + } } diff --git a/MC1/Views/RemoteNodes/NodeManagementTabPicker.swift b/MC1/Views/RemoteNodes/NodeManagementTabPicker.swift index f6d4094e..044a8833 100644 --- a/MC1/Views/RemoteNodes/NodeManagementTabPicker.swift +++ b/MC1/Views/RemoteNodes/NodeManagementTabPicker.swift @@ -3,15 +3,15 @@ import SwiftUI /// Pinned glass segment switcher for the repeater/room admin management page; /// floating Liquid Glass pills on iOS 26, a segmented `Picker` on iOS 18. struct NodeManagementTabPicker: View { - @Binding var selection: NodeManagementTab + @Binding var selection: NodeManagementTab - var body: some View { - GlassFilterBar( - selection: $selection, - isSearching: false, - pickerLabel: L10n.RemoteNodes.RemoteNodes.Settings.Tab.picker, - title: { $0.label }, - size: .large - ) - } + var body: some View { + GlassFilterBar( + selection: $selection, + isSearching: false, + pickerLabel: L10n.RemoteNodes.RemoteNodes.Settings.Tab.picker, + title: { $0.label }, + size: .large + ) + } } diff --git a/MC1/Views/RemoteNodes/NodeRoutePathSection.swift b/MC1/Views/RemoteNodes/NodeRoutePathSection.swift index fe900d29..96e41231 100644 --- a/MC1/Views/RemoteNodes/NodeRoutePathSection.swift +++ b/MC1/Views/RemoteNodes/NodeRoutePathSection.swift @@ -7,89 +7,89 @@ import SwiftUI /// toggle. Names are resolved synchronously from the contact/discovered-node lists the host already /// holds, so this is a dumb leaf with no view model of its own. struct NodeRoutePathSection: View { - @Environment(\.appTheme) private var theme - let contact: ContactDTO - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let userLocation: CLLocation? + @Environment(\.appTheme) private var theme + let contact: ContactDTO + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? - @State private var isExpanded = false + @State private var isExpanded = false - private var resolvedHops: [ResolvedPathHop] { - NeighborNameResolver.resolvePath( - contact.pathHops, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - } + private var resolvedHops: [ResolvedPathHop] { + NeighborNameResolver.resolvePath( + contact.pathHops, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + } - var body: some View { - Section { - content - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Auth.path) - } - .themedRowBackground(theme) + var body: some View { + Section { + content + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Auth.path) } + .themedRowBackground(theme) + } - @ViewBuilder - private var content: some View { - if contact.isFloodRouted { - Label { - Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteSet) - .foregroundStyle(.secondary) - } icon: { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - } else { - let hops = resolvedHops - if hops.isEmpty { - NodePathSummaryLabel(contact: contact) - } else { - DisclosureGroup(isExpanded: $isExpanded) { - ForEach(hops) { hop in - NodePathHopRow(hex: hop.hex, resolution: hop.resolution) - } - } label: { - NodePathSummaryLabel(contact: contact) - } - } + @ViewBuilder + private var content: some View { + if contact.isFloodRouted { + Label { + Text(L10n.RemoteNodes.RemoteNodes.Auth.noRouteSet) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + } else { + let hops = resolvedHops + if hops.isEmpty { + NodePathSummaryLabel(contact: contact) + } else { + DisclosureGroup(isExpanded: $isExpanded) { + ForEach(hops) { hop in + NodePathHopRow(hex: hop.hex, resolution: hop.resolution) + } + } label: { + NodePathSummaryLabel(contact: contact) } + } } + } } // MARK: - Path Summary Label /// The collapsed one-line route summary (`A3 → 7F → 42`, or `Direct`). struct NodePathSummaryLabel: View { - let contact: ContactDTO + let contact: ContactDTO - private var pathDisplayText: String { - contact.pathHopCount == 0 ? L10n.Contacts.Contacts.Route.direct : contact.pathString - } + private var pathDisplayText: String { + contact.pathHopCount == 0 ? L10n.Contacts.Contacts.Route.direct : contact.pathString + } - private var pathAccessibilityLabel: String { - contact.pathHopCount == 0 - ? L10n.Contacts.Contacts.Detail.routeDirect - : L10n.Contacts.Contacts.Detail.routePrefix(pathDisplayText) - } + private var pathAccessibilityLabel: String { + contact.pathHopCount == 0 + ? L10n.Contacts.Contacts.Detail.routeDirect + : L10n.Contacts.Contacts.Detail.routePrefix(pathDisplayText) + } - var body: some View { - Label { - Text(pathDisplayText) - .font(.caption.monospaced()) - .foregroundStyle(.primary) - .lineLimit(nil) - } icon: { - Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - .accessibilityLabel(pathAccessibilityLabel) + var body: some View { + Label { + Text(pathDisplayText) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + .lineLimit(nil) + } icon: { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + .foregroundStyle(.secondary) + .accessibilityHidden(true) } + .accessibilityLabel(pathAccessibilityLabel) + } } // MARK: - Path Hop Row @@ -97,26 +97,26 @@ struct NodePathSummaryLabel: View { /// A single expanded hop: hash hex plus the resolved repeater name, with a possible-match indicator /// when the name was matched only by a short prefix. struct NodePathHopRow: View { - let hex: String - let resolution: NodeNameResolution + let hex: String + let resolution: NodeNameResolution - var body: some View { - HStack(spacing: 6) { - Text(hex) - .font(.body.monospaced()) - .foregroundStyle(.secondary) + var body: some View { + HStack(spacing: 6) { + Text(hex) + .font(.body.monospaced()) + .foregroundStyle(.secondary) - Text(resolution.displayName) + Text(resolution.displayName) - if resolution.matchKind == .fallback { - FallbackMatchIndicatorView( - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, - accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, - title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, - explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation - ) - } - } - .accessibilityElement(children: .combine) + if resolution.matchKind == .fallback { + FallbackMatchIndicatorView( + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.possibleMatch, + accessibilityHint: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation, + title: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchTitle, + explanation: L10n.RemoteNodes.RemoteNodes.Status.possibleMatchExplanation + ) + } } + .accessibilityElement(children: .combine) + } } diff --git a/MC1/Views/RemoteNodes/NodeSettingsError.swift b/MC1/Views/RemoteNodes/NodeSettingsError.swift index 00bc5967..0899c844 100644 --- a/MC1/Views/RemoteNodes/NodeSettingsError.swift +++ b/MC1/Views/RemoteNodes/NodeSettingsError.swift @@ -2,11 +2,11 @@ import Foundation /// Shared error type for the repeater and room settings screens. enum NodeSettingsError: LocalizedError { - case noService + case noService - var errorDescription: String? { - switch self { - case .noService: return L10n.RemoteNodes.RemoteNodes.Settings.noService - } + var errorDescription: String? { + switch self { + case .noService: L10n.RemoteNodes.RemoteNodes.Settings.noService } + } } diff --git a/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift b/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift index 89f35c14..f5172170 100644 --- a/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift @@ -1,6 +1,6 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "NodeSettingsViewModel") @@ -10,660 +10,670 @@ private let logger = Logger(subsystem: "com.mc1", category: "NodeSettingsViewMod @Observable @MainActor final class NodeSettingsViewModel { + // MARK: - Session - // MARK: - Session + var session: RemoteNodeSessionDTO? - var session: RemoteNodeSessionDTO? + // MARK: - Device Info - // MARK: - Device Info + var firmwareVersion: String? + private var deviceTimeUTC: String? + var isLoadingDeviceInfo = false + var deviceInfoError = false + var deviceInfoLoaded: Bool { + deviceTimeUTC != nil + } - var firmwareVersion: String? - private var deviceTimeUTC: String? - var isLoadingDeviceInfo = false - var deviceInfoError = false - var deviceInfoLoaded: Bool { deviceTimeUTC != nil } + var deviceTime: String? { + guard let utcString = deviceTimeUTC else { return nil } + return Self.convertUTCToLocal(utcString) + } - var deviceTime: String? { - guard let utcString = deviceTimeUTC else { return nil } - return Self.convertUTCToLocal(utcString) + static func convertUTCToLocal(_ utcString: String) -> String { + guard let date = NodeSettingsResponseParser.utcDate(fromClockResponse: utcString) else { + return utcString } - static func convertUTCToLocal(_ utcString: String) -> String { - guard let date = NodeSettingsResponseParser.utcDate(fromClockResponse: utcString) else { - return utcString - } - - let timeString = date.formatted(date: .omitted, time: .shortened) - let dateString = date.formatted(.dateTime.year(.twoDigits).month(.twoDigits).day(.twoDigits)) - return "\(timeString) - \(dateString)" + let timeString = date.formatted(date: .omitted, time: .shortened) + let dateString = date.formatted(.dateTime.year(.twoDigits).month(.twoDigits).day(.twoDigits)) + return "\(timeString) - \(dateString)" + } + + // MARK: - Identity + + var name: String? + var latitude: Double? + var longitude: Double? + private(set) var originalName: String? + private(set) var originalLatitude: Double? + private(set) var originalLongitude: Double? + var isLoadingIdentity = false + var identityError = false + var identityLoaded: Bool { + originalLatitude != nil || originalLongitude != nil + } + + var identitySettingsModified: Bool { + (name != nil && name != originalName) || + (latitude != nil && latitude != originalLatitude) || + (longitude != nil && longitude != originalLongitude) + } + + // MARK: - Radio + + var frequency: Double? + var bandwidth: Double? + var spreadingFactor: Int? + var codingRate: Int? + var txPower: Int? + var isLoadingRadio = false + var radioError = false + var radioLoaded: Bool { + frequency != nil || txPower != nil + } + + var radioSettingsModified = false + + // MARK: - Contact Info + + /// Firmware limit on the `set owner.info` value length. + static let ownerInfoMaxLength = 119 + + var ownerInfo: String? + private(set) var originalOwnerInfo: String? + var isLoadingContactInfo = false + var contactInfoError = false + var contactInfoLoaded: Bool { + originalOwnerInfo != nil + } + + /// Gated on `contactInfoLoaded` so the text field's empty pre-fetch value can't + /// enable Apply and wipe the node's owner info before the current value arrives. + var contactInfoSettingsModified: Bool { + contactInfoLoaded && ownerInfo != originalOwnerInfo + } + + var ownerInfoCharCount: Int { + (ownerInfo ?? "").count + } + + var isOwnerInfoTooLong: Bool { + ownerInfoCharCount > Self.ownerInfoMaxLength + } + + // MARK: - Security + + var newPassword: String = "" + var confirmPassword: String = "" + + // MARK: - Expansion State + + var isDeviceInfoExpanded = false + var isRadioExpanded = false + var isIdentityExpanded = false + var isContactInfoExpanded = false + var isSecurityExpanded = false + + // MARK: - Global State + + var isApplying = false + var isRebooting = false + var errorMessage: String? + var successMessage: String? + var showSuccessAlert = false + var identityApplySuccess = false + var contactInfoApplySuccess = false + var changePasswordSuccess = false + var isSendingAdvert = false + + // MARK: - Service Closures + + private var sendCommandClosure: ((UUID, String, Duration) async throws -> String)? + private var sendRawCommandClosure: ((UUID, String, Duration) async throws -> String)? + + /// Called when firmware version or node info needs pre-fetching. + /// Repeater sets this to binary requestOwnerInfo; Room sets this to CLI `ver`. + var onPreFetchNodeInfo: (() async -> Void)? + + // MARK: - Configuration + + func configure( + session: RemoteNodeSessionDTO, + sendCommand: @escaping (UUID, String, Duration) async throws -> String, + sendRawCommand: @escaping (UUID, String, Duration) async throws -> String + ) { + self.session = session + sendCommandClosure = sendCommand + sendRawCommandClosure = sendRawCommand + } + + /// Set name and owner info from an external source (e.g., binary protocol pre-fetch) + func setNodeInfo(firmwareVersion: String?, name: String?, ownerInfo: String?) { + if let firmwareVersion { self.firmwareVersion = firmwareVersion } + if let name { + self.name = name + originalName = name } - - // MARK: - Identity - - var name: String? - var latitude: Double? - var longitude: Double? - private(set) var originalName: String? - private(set) var originalLatitude: Double? - private(set) var originalLongitude: Double? - var isLoadingIdentity = false - var identityError = false - var identityLoaded: Bool { originalLatitude != nil || originalLongitude != nil } - - var identitySettingsModified: Bool { - (name != nil && name != originalName) || - (latitude != nil && latitude != originalLatitude) || - (longitude != nil && longitude != originalLongitude) + if let ownerInfo { + self.ownerInfo = ownerInfo + originalOwnerInfo = ownerInfo + } + } + + func cleanup() { + sendCommandClosure = nil + sendRawCommandClosure = nil + onPreFetchNodeInfo = nil + } + + // MARK: - CLI Transport + + func sendAndWait( + _ command: String, + timeout: Duration = .seconds(5), + rawMatching: Bool = false + ) async throws -> String { + guard let session, let sendCmd = rawMatching ? sendRawCommandClosure : sendCommandClosure else { + throw NodeSettingsError.noService } - // MARK: - Radio - - var frequency: Double? - var bandwidth: Double? - var spreadingFactor: Int? - var codingRate: Int? - var txPower: Int? - var isLoadingRadio = false - var radioError = false - var radioLoaded: Bool { frequency != nil || txPower != nil } - var radioSettingsModified = false - - // MARK: - Contact Info + let response = try await sendCmd(session.id, command, timeout) + logger.debug("Command '\(command)' response: \(response.prefix(50))") + return response + } - /// Firmware limit on the `set owner.info` value length. - static let ownerInfoMaxLength = 119 + // MARK: - Fetch Methods - var ownerInfo: String? - private(set) var originalOwnerInfo: String? - var isLoadingContactInfo = false - var contactInfoError = false - var contactInfoLoaded: Bool { originalOwnerInfo != nil } + func fetchDeviceInfo() async { + isLoadingDeviceInfo = true + deviceInfoError = false - /// Gated on `contactInfoLoaded` so the text field's empty pre-fetch value can't - /// enable Apply and wipe the node's owner info before the current value arrives. - var contactInfoSettingsModified: Bool { - contactInfoLoaded && ownerInfo != originalOwnerInfo + if firmwareVersion == nil { + await onPreFetchNodeInfo?() } - var ownerInfoCharCount: Int { - (ownerInfo ?? "").count + if firmwareVersion == nil { + do { + let response = try await sendAndWait("ver") + if case let .version(version) = CLIResponse.parse(response, forQuery: "ver") { + firmwareVersion = version + } + } catch { + if case RemoteNodeError.timeout = error { + deviceInfoError = true + } + logger.warning("Failed to get firmware version: \(error)") + } } - var isOwnerInfoTooLong: Bool { - ownerInfoCharCount > Self.ownerInfoMaxLength + do { + let response = try await sendAndWait("clock") + if case let .deviceTime(time) = CLIResponse.parse(response, forQuery: "clock") { + deviceTimeUTC = time + } + } catch { + if case RemoteNodeError.timeout = error { + deviceInfoError = true + } + logger.warning("Failed to get device time: \(error)") } - // MARK: - Security - - var newPassword: String = "" - var confirmPassword: String = "" - - // MARK: - Expansion State + isLoadingDeviceInfo = false + } - var isDeviceInfoExpanded = false - var isRadioExpanded = false - var isIdentityExpanded = false - var isContactInfoExpanded = false - var isSecurityExpanded = false + func fetchIdentity() async { + isLoadingIdentity = true + identityError = false + var hadTimeout = false - // MARK: - Global State - - var isApplying = false - var isRebooting = false - var errorMessage: String? - var successMessage: String? - var showSuccessAlert = false - var identityApplySuccess = false - var contactInfoApplySuccess = false - var changePasswordSuccess = false - var isSendingAdvert = false - - // MARK: - Service Closures + if originalName == nil { + await onPreFetchNodeInfo?() + } - private var sendCommandClosure: ((UUID, String, Duration) async throws -> String)? - private var sendRawCommandClosure: ((UUID, String, Duration) async throws -> String)? + if originalName == nil { + do { + let response = try await sendAndWait("get name") + if case let .name(n) = CLIResponse.parse(response, forQuery: "get name") { + name = n + originalName = n + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get name: \(error)") + } + } - /// Called when firmware version or node info needs pre-fetching. - /// Repeater sets this to binary requestOwnerInfo; Room sets this to CLI `ver`. - var onPreFetchNodeInfo: (() async -> Void)? + do { + let response = try await sendAndWait("get lat") + if case let .latitude(lat) = CLIResponse.parse(response, forQuery: "get lat") { + latitude = lat + originalLatitude = lat + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get latitude: \(error)") + } - // MARK: - Configuration + do { + let response = try await sendAndWait("get lon") + if case let .longitude(lon) = CLIResponse.parse(response, forQuery: "get lon") { + longitude = lon + originalLongitude = lon + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get longitude: \(error)") + } - func configure( - session: RemoteNodeSessionDTO, - sendCommand: @escaping (UUID, String, Duration) async throws -> String, - sendRawCommand: @escaping (UUID, String, Duration) async throws -> String - ) { - self.session = session - self.sendCommandClosure = sendCommand - self.sendRawCommandClosure = sendRawCommand + if hadTimeout { + identityError = true } - /// Set name and owner info from an external source (e.g., binary protocol pre-fetch) - func setNodeInfo(firmwareVersion: String?, name: String?, ownerInfo: String?) { - if let firmwareVersion { self.firmwareVersion = firmwareVersion } - if let name { - self.name = name - self.originalName = name - } - if let ownerInfo { - self.ownerInfo = ownerInfo - self.originalOwnerInfo = ownerInfo - } + isLoadingIdentity = false + } + + func fetchRadioSettings() async { + isLoadingRadio = true + radioError = false + var hadTimeout = false + + do { + let response = try await sendAndWait("get tx") + if case let .txPower(power) = CLIResponse.parse(response, forQuery: "get tx") { + txPower = power + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get TX power: \(error)") } - func cleanup() { - sendCommandClosure = nil - sendRawCommandClosure = nil - onPreFetchNodeInfo = nil + do { + let response = try await sendAndWait("get radio") + if case let .radio(freq, bw, sf, cr) = CLIResponse.parse(response, forQuery: "get radio") { + frequency = freq + bandwidth = bw + spreadingFactor = sf + codingRate = cr + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get radio settings: \(error)") } - // MARK: - CLI Transport + if hadTimeout { + radioError = true + } - func sendAndWait( - _ command: String, - timeout: Duration = .seconds(5), - rawMatching: Bool = false - ) async throws -> String { - guard let session, let sendCmd = rawMatching ? sendRawCommandClosure : sendCommandClosure else { - throw NodeSettingsError.noService - } + isLoadingRadio = false + } - let response = try await sendCmd(session.id, command, timeout) - logger.debug("Command '\(command)' response: \(response.prefix(50))") - return response + func fetchContactInfo() async { + if originalOwnerInfo == nil { + await onPreFetchNodeInfo?() + } + if originalOwnerInfo != nil { return } + + isLoadingContactInfo = true + contactInfoError = false + + do { + let response = try await sendAndWait("get owner.info") + if case let .ownerInfo(info) = CLIResponse.parse(response, forQuery: "get owner.info") { + let displayText = NodeSettingsResponseParser.displayOwnerInfo(fromWire: info) + ownerInfo = displayText + originalOwnerInfo = displayText + } + } catch { + if case RemoteNodeError.timeout = error { + contactInfoError = true + } + logger.warning("Failed to get owner info: \(error)") } - // MARK: - Fetch Methods + isLoadingContactInfo = false + } - func fetchDeviceInfo() async { - isLoadingDeviceInfo = true - deviceInfoError = false + // MARK: - Success Flash - if firmwareVersion == nil { - await onPreFetchNodeInfo?() - } + /// How long an Apply button shows its success state before returning to idle. + static let successFlashDuration: Duration = .seconds(1.5) - if firmwareVersion == nil { - do { - let response = try await sendAndWait("ver") - if case .version(let version) = CLIResponse.parse(response, forQuery: "ver") { - self.firmwareVersion = version - } - } catch { - if case RemoteNodeError.timeout = error { - deviceInfoError = true - } - logger.warning("Failed to get firmware version: \(error)") - } - } + /// Drop the section's applying flag and flash its success indicator for + /// `successFlashDuration`. The closures target the section's own state, which + /// may live on this shared view model or on the owning view model. + func flashSuccess(setApplying: (Bool) -> Void, setSuccess: (Bool) -> Void) async { + withAnimation { + setApplying(false) + setSuccess(true) + } + try? await Task.sleep(for: Self.successFlashDuration) + withAnimation { setSuccess(false) } + } - do { - let response = try await sendAndWait("clock") - if case .deviceTime(let time) = CLIResponse.parse(response, forQuery: "clock") { - self.deviceTimeUTC = time - } - } catch { - if case RemoteNodeError.timeout = error { - deviceInfoError = true - } - logger.warning("Failed to get device time: \(error)") - } + // MARK: - Apply Methods - isLoadingDeviceInfo = false + func applyRadioSettings() async { + guard let frequency, let bandwidth, let spreadingFactor, let codingRate, let txPower else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioNotLoaded + return } - func fetchIdentity() async { - isLoadingIdentity = true - identityError = false - var hadTimeout = false + isApplying = true + errorMessage = nil + + do { + var allSucceeded = true + + let radioCommand = "set radio \(frequency),\(bandwidth),\(spreadingFactor),\(codingRate)" + let radioResponse = try await sendAndWait(radioCommand) + if case .ok = CLIResponse.parse(radioResponse) { + } else { + allSucceeded = false + } + + let txCommand = "set tx \(txPower)" + let txResponse = try await sendAndWait(txCommand) + if case .ok = CLIResponse.parse(txResponse) { + } else { + allSucceeded = false + } + + if allSucceeded { + radioSettingsModified = false + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioAppliedSuccess + showSuccessAlert = true + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioApplyFailed + } + } catch { + errorMessage = error.userFacingMessage + } - if originalName == nil { - await onPreFetchNodeInfo?() - } + isApplying = false + } - if originalName == nil { - do { - let response = try await sendAndWait("get name") - if case .name(let n) = CLIResponse.parse(response, forQuery: "get name") { - self.name = n - self.originalName = n - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get name: \(error)") - } - } + func applyIdentitySettings() async { + isApplying = true + errorMessage = nil - do { - let response = try await sendAndWait("get lat") - if case .latitude(let lat) = CLIResponse.parse(response, forQuery: "get lat") { - self.latitude = lat - self.originalLatitude = lat - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get latitude: \(error)") - } + do { + var allSucceeded = true - do { - let response = try await sendAndWait("get lon") - if case .longitude(let lon) = CLIResponse.parse(response, forQuery: "get lon") { - self.longitude = lon - self.originalLongitude = lon - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get longitude: \(error)") + if let name, name != originalName { + let response = try await sendAndWait("set name \(name)") + if case .ok = CLIResponse.parse(response) { + originalName = name + } else { + allSucceeded = false } + } - if hadTimeout { - identityError = true + if let latitude, latitude != originalLatitude { + let response = try await sendAndWait("set lat \(latitude)") + if case .ok = CLIResponse.parse(response) { + originalLatitude = latitude + } else { + allSucceeded = false } + } - isLoadingIdentity = false - } - - func fetchRadioSettings() async { - isLoadingRadio = true - radioError = false - var hadTimeout = false - - do { - let response = try await sendAndWait("get tx") - if case .txPower(let power) = CLIResponse.parse(response, forQuery: "get tx") { - self.txPower = power - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get TX power: \(error)") - } - - do { - let response = try await sendAndWait("get radio") - if case .radio(let freq, let bw, let sf, let cr) = CLIResponse.parse(response, forQuery: "get radio") { - self.frequency = freq - self.bandwidth = bw - self.spreadingFactor = sf - self.codingRate = cr - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get radio settings: \(error)") + if let longitude, longitude != originalLongitude { + let response = try await sendAndWait("set lon \(longitude)") + if case .ok = CLIResponse.parse(response) { + originalLongitude = longitude + } else { + allSucceeded = false } + } - if hadTimeout { - radioError = true - } + if allSucceeded { + await flashSuccess( + setApplying: { isApplying = $0 }, + setSuccess: { identityApplySuccess = $0 } + ) + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + errorMessage = error.userFacingMessage + } - isLoadingRadio = false + isApplying = false + } + + func applyContactInfoSettings() async { + isApplying = true + errorMessage = nil + + do { + let pipeText = NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: ownerInfo ?? "") + let response = try await sendAndWait("set owner.info \(pipeText)") + if case .ok = CLIResponse.parse(response) { + originalOwnerInfo = ownerInfo + await flashSuccess( + setApplying: { isApplying = $0 }, + setSuccess: { contactInfoApplySuccess = $0 } + ) + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + errorMessage = error.userFacingMessage } - func fetchContactInfo() async { - if originalOwnerInfo == nil { - await onPreFetchNodeInfo?() - } - if originalOwnerInfo != nil { return } - - isLoadingContactInfo = true - contactInfoError = false - - do { - let response = try await sendAndWait("get owner.info") - if case .ownerInfo(let info) = CLIResponse.parse(response, forQuery: "get owner.info") { - let displayText = NodeSettingsResponseParser.displayOwnerInfo(fromWire: info) - self.ownerInfo = displayText - self.originalOwnerInfo = displayText - } - } catch { - if case RemoteNodeError.timeout = error { - contactInfoError = true - } - logger.warning("Failed to get owner info: \(error)") - } + isApplying = false + } - isLoadingContactInfo = false - } + // MARK: - Location Picker - // MARK: - Success Flash + func setLocationFromPicker(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } - /// How long an Apply button shows its success state before returning to idle. - static let successFlashDuration: Duration = .seconds(1.5) + // MARK: - Security - /// Drop the section's applying flag and flash its success indicator for - /// `successFlashDuration`. The closures target the section's own state, which - /// may live on this shared view model or on the owning view model. - func flashSuccess(setApplying: (Bool) -> Void, setSuccess: (Bool) -> Void) async { - withAnimation { - setApplying(false) - setSuccess(true) - } - try? await Task.sleep(for: Self.successFlashDuration) - withAnimation { setSuccess(false) } + func changePassword() async { + guard !newPassword.isEmpty else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordEmpty + return + } + guard newPassword == confirmPassword else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordMismatch + return } - // MARK: - Apply Methods + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("password \(newPassword)", rawMatching: true) + if NodeSettingsResponseParser.isPasswordChangeSuccessful(response) { + newPassword = "" + confirmPassword = "" + await flashSuccess( + setApplying: { isApplying = $0 }, + setSuccess: { changePasswordSuccess = $0 } + ) + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangeFailed + } + } catch { + errorMessage = error.userFacingMessage + } - func applyRadioSettings() async { - guard let frequency, let bandwidth, let spreadingFactor, let codingRate, let txPower else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioNotLoaded - return - } + isApplying = false + } - isApplying = true - errorMessage = nil - - do { - var allSucceeded = true - - let radioCommand = "set radio \(frequency),\(bandwidth),\(spreadingFactor),\(codingRate)" - let radioResponse = try await sendAndWait(radioCommand) - if case .ok = CLIResponse.parse(radioResponse) { - } else { - allSucceeded = false - } - - let txCommand = "set tx \(txPower)" - let txResponse = try await sendAndWait(txCommand) - if case .ok = CLIResponse.parse(txResponse) { - } else { - allSucceeded = false - } - - if allSucceeded { - radioSettingsModified = false - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioAppliedSuccess - showSuccessAlert = true - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioApplyFailed - } - } catch { - errorMessage = error.userFacingMessage - } + // MARK: - Device Actions - isApplying = false - } - - func applyIdentitySettings() async { - isApplying = true - errorMessage = nil - - do { - var allSucceeded = true - - if let name, name != originalName { - let response = try await sendAndWait("set name \(name)") - if case .ok = CLIResponse.parse(response) { - originalName = name - } else { - allSucceeded = false - } - } - - if let latitude, latitude != originalLatitude { - let response = try await sendAndWait("set lat \(latitude)") - if case .ok = CLIResponse.parse(response) { - originalLatitude = latitude - } else { - allSucceeded = false - } - } - - if let longitude, longitude != originalLongitude { - let response = try await sendAndWait("set lon \(longitude)") - if case .ok = CLIResponse.parse(response) { - originalLongitude = longitude - } else { - allSucceeded = false - } - } - - if allSucceeded { - await flashSuccess( - setApplying: { isApplying = $0 }, - setSuccess: { identityApplySuccess = $0 } - ) - return - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - errorMessage = error.userFacingMessage - } + func reboot() async { + guard session != nil else { return } - isApplying = false - } - - func applyContactInfoSettings() async { - isApplying = true - errorMessage = nil - - do { - let pipeText = NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: ownerInfo ?? "") - let response = try await sendAndWait("set owner.info \(pipeText)") - if case .ok = CLIResponse.parse(response) { - originalOwnerInfo = ownerInfo - await flashSuccess( - setApplying: { isApplying = $0 }, - setSuccess: { contactInfoApplySuccess = $0 } - ) - return - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - errorMessage = error.userFacingMessage - } + isRebooting = true + errorMessage = nil - isApplying = false + do { + _ = try await sendAndWait("reboot") + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.rebootSent + showSuccessAlert = true + } catch { + errorMessage = error.userFacingMessage } - // MARK: - Location Picker - - func setLocationFromPicker(latitude: Double, longitude: Double) { - self.latitude = latitude - self.longitude = longitude + isRebooting = false + } + + func forceAdvert() async { + isSendingAdvert = true + defer { isSendingAdvert = false } + do { + _ = try await sendAndWait("advert") + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.advertSent + showSuccessAlert = true + } catch { + errorMessage = error.userFacingMessage } - - // MARK: - Security - - func changePassword() async { - guard !newPassword.isEmpty else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordEmpty - return - } - guard newPassword == confirmPassword else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordMismatch - return - } - - isApplying = true - errorMessage = nil - - do { - let response = try await sendAndWait("password \(newPassword)", rawMatching: true) - if NodeSettingsResponseParser.isPasswordChangeSuccessful(response) { - newPassword = "" - confirmPassword = "" - await flashSuccess( - setApplying: { isApplying = $0 }, - setSuccess: { changePasswordSuccess = $0 } - ) - return - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangeFailed - } - } catch { - errorMessage = error.userFacingMessage - } - - isApplying = false + } + + func syncTime() async { + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("clock sync") + switch NodeSettingsResponseParser.classifyClockSyncResponse(response) { + case .synced: + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced + showSuccessAlert = true + case .clockAhead: + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.clockAheadError + case let .failed(message): + errorMessage = message.isEmpty ? L10n.RemoteNodes.RemoteNodes.Settings.syncTimeFailed : message + case .unexpected: + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.unexpectedResponse(response) + } + } catch { + errorMessage = error.userFacingMessage } - // MARK: - Device Actions - - func reboot() async { - guard session != nil else { return } + isApplying = false + } - isRebooting = true - errorMessage = nil + // MARK: - Shared Validation - do { - _ = try await sendAndWait("reboot") - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.rebootSent - showSuccessAlert = true - } catch { - errorMessage = error.userFacingMessage - } + /// Firmware-accepted ranges for the behavior fields; 0 means disabled for + /// the two intervals and is validated separately. + static let advertIntervalMinutesRange = 60...240 + static let floodIntervalHoursRange = 3...168 + static let floodMaxHopsRange = 0...64 - isRebooting = false + struct BehaviorValidationErrors { + var advertInterval: String? + var floodInterval: String? + var floodMaxHops: String? + var hasErrors: Bool { + advertInterval != nil || floodInterval != nil || floodMaxHops != nil } - - func forceAdvert() async { - isSendingAdvert = true - defer { isSendingAdvert = false } - do { - _ = try await sendAndWait("advert") - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.advertSent - showSuccessAlert = true - } catch { - errorMessage = error.userFacingMessage - } + } + + static func validateBehaviorFields( + advertInterval: Int?, + floodInterval: Int?, + floodMaxHops: Int? + ) -> BehaviorValidationErrors { + var errors = BehaviorValidationErrors() + if let interval = advertInterval, interval != 0, !advertIntervalMinutesRange.contains(interval) { + errors.advertInterval = L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalValidation } - - func syncTime() async { - isApplying = true - errorMessage = nil - - do { - let response = try await sendAndWait("clock sync") - switch NodeSettingsResponseParser.classifyClockSyncResponse(response) { - case .synced: - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced - showSuccessAlert = true - case .clockAhead: - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.clockAheadError - case .failed(let message): - errorMessage = message.isEmpty ? L10n.RemoteNodes.RemoteNodes.Settings.syncTimeFailed : message - case .unexpected: - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.unexpectedResponse(response) - } - } catch { - errorMessage = error.userFacingMessage - } - - isApplying = false + if let interval = floodInterval, interval != 0, !floodIntervalHoursRange.contains(interval) { + errors.floodInterval = L10n.RemoteNodes.RemoteNodes.Settings.floodIntervalValidation } - - // MARK: - Shared Validation - - /// Firmware-accepted ranges for the behavior fields; 0 means disabled for - /// the two intervals and is validated separately. - static let advertIntervalMinutesRange = 60...240 - static let floodIntervalHoursRange = 3...168 - static let floodMaxHopsRange = 0...64 - - struct BehaviorValidationErrors { - var advertInterval: String? - var floodInterval: String? - var floodMaxHops: String? - var hasErrors: Bool { advertInterval != nil || floodInterval != nil || floodMaxHops != nil } + if let hops = floodMaxHops, !floodMaxHopsRange.contains(hops) { + errors.floodMaxHops = L10n.RemoteNodes.RemoteNodes.Settings.floodMaxValidation } - - static func validateBehaviorFields( - advertInterval: Int?, - floodInterval: Int?, - floodMaxHops: Int? - ) -> BehaviorValidationErrors { - var errors = BehaviorValidationErrors() - if let interval = advertInterval, interval != 0 && !advertIntervalMinutesRange.contains(interval) { - errors.advertInterval = L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalValidation - } - if let interval = floodInterval, interval != 0 && !floodIntervalHoursRange.contains(interval) { - errors.floodInterval = L10n.RemoteNodes.RemoteNodes.Settings.floodIntervalValidation - } - if let hops = floodMaxHops, !floodMaxHopsRange.contains(hops) { - errors.floodMaxHops = L10n.RemoteNodes.RemoteNodes.Settings.floodMaxValidation - } - return errors + return errors + } + + // MARK: - Late Response Handling + + /// The settings fields a late response may still fill, ordered so numeric + /// fields are tried before the free-form name (which matches any text). + private var missingLateResponseFields: [NodeSettingsResponseParser.SettingsField] { + var fields: [NodeSettingsResponseParser.SettingsField] = [] + if !isLoadingRadio, radioError { + if frequency == nil { fields.append(.radio) } + if txPower == nil { fields.append(.txPower) } } - - // MARK: - Late Response Handling - - /// The settings fields a late response may still fill, ordered so numeric - /// fields are tried before the free-form name (which matches any text). - private var missingLateResponseFields: [NodeSettingsResponseParser.SettingsField] { - var fields: [NodeSettingsResponseParser.SettingsField] = [] - if !isLoadingRadio && radioError { - if frequency == nil { fields.append(.radio) } - if txPower == nil { fields.append(.txPower) } - } - if !isLoadingDeviceInfo && deviceInfoError { - if firmwareVersion == nil { fields.append(.firmwareVersion) } - if deviceTimeUTC == nil { fields.append(.deviceTime) } - } - if !isLoadingIdentity && identityError { - if originalLatitude == nil { fields.append(.latitude) } - if originalLongitude == nil { fields.append(.longitude) } - if originalName == nil { fields.append(.name) } - } - if !isLoadingContactInfo && contactInfoError { - if originalOwnerInfo == nil { fields.append(.ownerInfo) } - } - return fields + if !isLoadingDeviceInfo, deviceInfoError { + if firmwareVersion == nil { fields.append(.firmwareVersion) } + if deviceTimeUTC == nil { fields.append(.deviceTime) } } - - /// Handle late CLI responses for shared sections. - /// Returns `true` if the response was consumed. - func handleCommonLateResponse(_ response: String) -> Bool { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: response, - checking: missingLateResponseFields - ) - guard let value else { return false } - - switch value { - case .radio(let frequency, let bandwidth, let spreadingFactor, let codingRate): - self.frequency = frequency - self.bandwidth = bandwidth - self.spreadingFactor = spreadingFactor - self.codingRate = codingRate - self.radioError = false - logger.info("Late response: received radio settings") - case .txPower(let power): - self.txPower = power - self.radioError = false - logger.info("Late response: received TX power") - case .firmwareVersion(let version): - self.firmwareVersion = version - self.deviceInfoError = false - logger.info("Late response: received firmware version") - case .deviceTime(let time): - self.deviceTimeUTC = time - self.deviceInfoError = false - logger.info("Late response: received device time") - case .latitude(let latitude): - self.latitude = latitude - self.originalLatitude = latitude - self.identityError = false - logger.info("Late response: received latitude") - case .longitude(let longitude): - self.longitude = longitude - self.originalLongitude = longitude - self.identityError = false - logger.info("Late response: received longitude") - case .name(let name): - self.name = name - self.originalName = name - self.identityError = false - logger.info("Late response: received name") - case .ownerInfo(let info): - let displayText = NodeSettingsResponseParser.displayOwnerInfo(fromWire: info) - self.ownerInfo = displayText - self.originalOwnerInfo = displayText - self.contactInfoError = false - logger.info("Late response: received owner info") - } - return true + if !isLoadingIdentity, identityError { + if originalLatitude == nil { fields.append(.latitude) } + if originalLongitude == nil { fields.append(.longitude) } + if originalName == nil { fields.append(.name) } + } + if !isLoadingContactInfo, contactInfoError { + if originalOwnerInfo == nil { fields.append(.ownerInfo) } + } + return fields + } + + /// Handle late CLI responses for shared sections. + /// Returns `true` if the response was consumed. + func handleCommonLateResponse(_ response: String) -> Bool { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: response, + checking: missingLateResponseFields + ) + guard let value else { return false } + + switch value { + case let .radio(frequency, bandwidth, spreadingFactor, codingRate): + self.frequency = frequency + self.bandwidth = bandwidth + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + radioError = false + logger.info("Late response: received radio settings") + case let .txPower(power): + txPower = power + radioError = false + logger.info("Late response: received TX power") + case let .firmwareVersion(version): + firmwareVersion = version + deviceInfoError = false + logger.info("Late response: received firmware version") + case let .deviceTime(time): + deviceTimeUTC = time + deviceInfoError = false + logger.info("Late response: received device time") + case let .latitude(latitude): + self.latitude = latitude + originalLatitude = latitude + identityError = false + logger.info("Late response: received latitude") + case let .longitude(longitude): + self.longitude = longitude + originalLongitude = longitude + identityError = false + logger.info("Late response: received longitude") + case let .name(name): + self.name = name + originalName = name + identityError = false + logger.info("Late response: received name") + case let .ownerInfo(info): + let displayText = NodeSettingsResponseParser.displayOwnerInfo(fromWire: info) + ownerInfo = displayText + originalOwnerInfo = displayText + contactInfoError = false + logger.info("Late response: received owner info") } + return true + } } diff --git a/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift b/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift index d9cbf70a..61395fbc 100644 --- a/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift +++ b/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift @@ -3,41 +3,40 @@ import SwiftUI /// Drill-down view showing historical charts for status metrics (battery, SNR, RSSI, noise floor). struct NodeStatusHistoryView: View { - @Environment(\.appTheme) private var theme - let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] - let ocvArray: [Int] + @Environment(\.appTheme) private var theme + let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] + let ocvArray: [Int] - @State private var snapshots: [NodeStatusSnapshotDTO] = [] - @State private var timeRange: HistoryTimeRange = .default + @State private var snapshots: [NodeStatusSnapshotDTO] = [] + @State private var timeRange: HistoryTimeRange = .default - private var filteredSnapshots: [NodeStatusSnapshotDTO] { - guard let start = timeRange.startDate else { return snapshots } - return snapshots.filter { $0.timestamp >= start } - } - - var body: some View { - List { - HistoryTimeRangePicker(selection: $timeRange) + private var filteredSnapshots: [NodeStatusSnapshotDTO] { + guard let start = timeRange.startDate else { return snapshots } + return snapshots.filter { $0.timestamp >= start } + } - RadioMetricCharts(snapshots: filteredSnapshots, ocvArray: ocvArray) { chart in - Section { - chart - } - .themedRowBackground(theme) - } + var body: some View { + List { + HistoryTimeRangePicker(selection: $timeRange) - Section { - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) - } - .themedRowBackground(theme) - } - .themedCanvas(theme) - .chartScrubbingScrollLock() - .navigationTitle(L10n.RemoteNodes.RemoteNodes.History.title) - .liquidGlassToolbarBackground() - .task { - snapshots = await fetchSnapshots() + RadioMetricCharts(snapshots: filteredSnapshots, ocvArray: ocvArray) { chart in + Section { + chart } + .themedRowBackground(theme) + } + + Section {} footer: { + Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .chartScrubbingScrollLock() + .navigationTitle(L10n.RemoteNodes.RemoteNodes.History.title) + .liquidGlassToolbarBackground() + .task { + snapshots = await fetchSnapshots() } + } } diff --git a/MC1/Views/RemoteNodes/NodeStatusRoute.swift b/MC1/Views/RemoteNodes/NodeStatusRoute.swift index 0fa6cee9..6d11d649 100644 --- a/MC1/Views/RemoteNodes/NodeStatusRoute.swift +++ b/MC1/Views/RemoteNodes/NodeStatusRoute.swift @@ -4,30 +4,30 @@ import SwiftUI /// History drill-downs pushed from the shared node status sections. Value-based so each /// push rebuilds the destination instead of reusing stale `@State` from a prior visit. enum NodeStatusRoute: Hashable { - case statusHistory - case telemetryHistory - case neighborChart(name: String, neighborPrefix: Data) + case statusHistory + case telemetryHistory + case neighborChart(name: String, neighborPrefix: Data) } extension View { - /// Registers the `NodeStatusRoute` destinations on the enclosing navigation stack, - /// resolving each route against the host's `NodeStatusViewModel`. Apply to the `List` - /// hosting the shared status sections, not to rows inside it. - @MainActor - func nodeStatusDestinations(helper: NodeStatusViewModel) -> some View { - navigationDestination(for: NodeStatusRoute.self) { route in - switch route { - case .statusHistory: - NodeStatusHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) - case .telemetryHistory: - TelemetryHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) - case .neighborChart(let name, let neighborPrefix): - NeighborSNRChartView( - name: name, - neighborPrefix: neighborPrefix, - fetchSnapshots: helper.fetchHistory - ) - } - } + /// Registers the `NodeStatusRoute` destinations on the enclosing navigation stack, + /// resolving each route against the host's `NodeStatusViewModel`. Apply to the `List` + /// hosting the shared status sections, not to rows inside it. + @MainActor + func nodeStatusDestinations(helper: NodeStatusViewModel) -> some View { + navigationDestination(for: NodeStatusRoute.self) { route in + switch route { + case .statusHistory: + NodeStatusHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) + case .telemetryHistory: + TelemetryHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) + case let .neighborChart(name, neighborPrefix): + NeighborSNRChartView( + name: name, + neighborPrefix: neighborPrefix, + fetchSnapshots: helper.fetchHistory + ) + } } + } } diff --git a/MC1/Views/RemoteNodes/NodeStatusViewModel.swift b/MC1/Views/RemoteNodes/NodeStatusViewModel.swift index b5400423..1556941a 100644 --- a/MC1/Views/RemoteNodes/NodeStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeStatusViewModel.swift @@ -1,5 +1,5 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "NodeStatusViewModel") @@ -10,493 +10,496 @@ private let logger = Logger(subsystem: "com.mc1", category: "NodeStatusViewModel @Observable @MainActor final class NodeStatusViewModel { + // MARK: - Properties - // MARK: - Properties - - /// Current session - var session: RemoteNodeSessionDTO? - - /// Public key for direct telemetry (no remote session). - /// Used for chat nodes that don't require login. - private var directPublicKey: Data? - - /// The public key to use for requests and history — prefers session, falls back to direct. - var effectivePublicKey: Data? { - session?.publicKey ?? directPublicKey - } - - /// 6-byte prefix for response matching. - var effectivePublicKeyPrefix: Data? { - session?.publicKeyPrefix ?? directPublicKey?.prefix(6) - } - - /// Last received status - var status: RemoteNodeStatus? - - /// Last received telemetry - var telemetry: TelemetryResponse? - - /// Cached decoded data points to avoid repeated LPP decoding. - private(set) var cachedDataPoints: [LPPDataPoint] = [] - - /// Loading states - var isLoadingStatus = false - var isLoadingTelemetry = false - - /// Whether a status response has been applied since the sheet opened. - /// Drives lazy loading of the status counters section. - var statusLoaded = false - - /// Whether the status disclosure group is expanded - var statusExpanded = false - - /// Whether telemetry has been loaded at least once (for refresh logic) - var telemetryLoaded = false + /// Current session + var session: RemoteNodeSessionDTO? - /// Whether the telemetry disclosure group is expanded - var telemetryExpanded = false + /// Public key for direct telemetry (no remote session). + /// Used for chat nodes that don't require login. + private var directPublicKey: Data? - /// Error text owned by the status counters section, scoped so a status - /// failure surfaces only under the status section once sections load independently. - var statusSectionError: String? + /// The public key to use for requests and history — prefers session, falls back to direct. + var effectivePublicKey: Data? { + session?.publicKey ?? directPublicKey + } - /// Error text owned by the telemetry section, scoped so a telemetry - /// failure surfaces only under the telemetry section once sections load independently. - var telemetrySectionError: String? + /// 6-byte prefix for response matching. + var effectivePublicKeyPrefix: Data? { + session?.publicKeyPrefix ?? directPublicKey?.prefix(6) + } - // MARK: - OCV Curve Properties + /// Last received status + var status: RemoteNodeStatus? + + /// Last received telemetry + var telemetry: TelemetryResponse? + + /// Cached decoded data points to avoid repeated LPP decoding. + private(set) var cachedDataPoints: [LPPDataPoint] = [] + + /// Loading states + var isLoadingStatus = false + var isLoadingTelemetry = false + + /// Whether a status response has been applied since the sheet opened. + /// Drives lazy loading of the status counters section. + var statusLoaded = false + + /// Whether the status disclosure group is expanded + var statusExpanded = false - var isBatteryCurveExpanded = false - var selectedOCVPreset: OCVPreset = .liIon - var ocvValues: [Int] = OCVPreset.liIon.ocvArray - var ocvError: String? - private var contactID: UUID? - - // MARK: - Dependencies - - private var contactServiceProvider: @MainActor () -> ContactService? = { nil } - var contactService: ContactService? { contactServiceProvider() } - private var nodeSnapshotServiceProvider: @MainActor () -> NodeSnapshotService? = { nil } - var nodeSnapshotService: NodeSnapshotService? { nodeSnapshotServiceProvider() } - - // MARK: - Snapshot State - - /// Previous status-bearing snapshot for the status deltas, sourced from the last - /// snapshot that actually captured status so a neighbor- or telemetry-only row - /// can't blank the delta. - private(set) var previousStatusSnapshot: NodeStatusSnapshotDTO? - - /// Previous neighbor-bearing snapshot for the neighbor SNR delta. Owned by the - /// neighbor load path so the delta does not depend on the status section being - /// expanded, and sourced from the last snapshot that actually captured neighbors. - private(set) var previousNeighborSnapshot: NodeStatusSnapshotDTO? - - // MARK: - Initialization - - func configure( - contactService: @escaping @MainActor () -> ContactService?, - nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? - ) { - self.contactServiceProvider = contactService - self.nodeSnapshotServiceProvider = nodeSnapshotService - } - - /// Configure for direct telemetry access (no login session). - /// Used for chat nodes that can be queried without authentication. - func configureForDirectTelemetry(publicKey: Data) { - self.directPublicKey = publicKey - } - - // MARK: - Transient Retry Machinery - - private static let requestTimeout: Duration = RemoteOperationTimeoutPolicy.binaryMaximum - - private static let transientRetryDelays: [Duration] = [ - .milliseconds(500), - .seconds(1), - .seconds(2), - ] - - func isTransientError(_ error: Error) -> Bool { - let meshError: MeshCoreError - switch error { - case let remoteError as RemoteNodeError: - guard case .sessionError(let inner) = remoteError else { return false } - meshError = inner - case let binaryError as BinaryProtocolError: - guard case .sessionError(let inner) = binaryError else { return false } - meshError = inner - default: - return false + /// Whether telemetry has been loaded at least once (for refresh logic) + var telemetryLoaded = false + + /// Whether the telemetry disclosure group is expanded + var telemetryExpanded = false + + /// Error text owned by the status counters section, scoped so a status + /// failure surfaces only under the status section once sections load independently. + var statusSectionError: String? + + /// Error text owned by the telemetry section, scoped so a telemetry + /// failure surfaces only under the telemetry section once sections load independently. + var telemetrySectionError: String? + + // MARK: - OCV Curve Properties + + var isBatteryCurveExpanded = false + var selectedOCVPreset: OCVPreset = .liIon + var ocvValues: [Int] = OCVPreset.liIon.ocvArray + var ocvError: String? + private var contactID: UUID? + + // MARK: - Dependencies + + private var contactServiceProvider: @MainActor () -> ContactService? = { nil } + var contactService: ContactService? { + contactServiceProvider() + } + + private var nodeSnapshotServiceProvider: @MainActor () -> NodeSnapshotService? = { nil } + var nodeSnapshotService: NodeSnapshotService? { + nodeSnapshotServiceProvider() + } + + // MARK: - Snapshot State + + /// Previous status-bearing snapshot for the status deltas, sourced from the last + /// snapshot that actually captured status so a neighbor- or telemetry-only row + /// can't blank the delta. + private(set) var previousStatusSnapshot: NodeStatusSnapshotDTO? + + /// Previous neighbor-bearing snapshot for the neighbor SNR delta. Owned by the + /// neighbor load path so the delta does not depend on the status section being + /// expanded, and sourced from the last snapshot that actually captured neighbors. + private(set) var previousNeighborSnapshot: NodeStatusSnapshotDTO? + + // MARK: - Initialization + + func configure( + contactService: @escaping @MainActor () -> ContactService?, + nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? + ) { + contactServiceProvider = contactService + nodeSnapshotServiceProvider = nodeSnapshotService + } + + /// Configure for direct telemetry access (no login session). + /// Used for chat nodes that can be queried without authentication. + func configureForDirectTelemetry(publicKey: Data) { + directPublicKey = publicKey + } + + // MARK: - Transient Retry Machinery + + private static let requestTimeout: Duration = RemoteOperationTimeoutPolicy.binaryMaximum + + private static let transientRetryDelays: [Duration] = [ + .milliseconds(500), + .seconds(1), + .seconds(2), + ] + + func isTransientError(_ error: Error) -> Bool { + let meshError: MeshCoreError + switch error { + case let remoteError as RemoteNodeError: + guard case let .sessionError(inner) = remoteError else { return false } + meshError = inner + case let binaryError as BinaryProtocolError: + guard case let .sessionError(inner) = binaryError else { return false } + meshError = inner + default: + return false + } + guard case let .deviceError(code) = meshError else { return false } + return code == FirmwareDeviceErrorCode.remoteNodeNoResponseYet + } + + private func remainingBudget(until deadline: ContinuousClock.Instant) -> Duration? { + let remaining = deadline - .now + return remaining > .zero ? remaining : nil + } + + private func waitForRetry(delay: Duration, until deadline: ContinuousClock.Instant) async throws { + guard let remaining = remainingBudget(until: deadline) else { + throw RemoteNodeError.timeout + } + try await Task.sleep(for: min(delay, remaining)) + } + + func performWithTransientRetries( + operationName: String, + operation: @escaping @Sendable (Duration) async throws -> T + ) async throws -> T { + let deadline = ContinuousClock.now.advanced(by: Self.requestTimeout) + var delayIterator = Self.transientRetryDelays.makeIterator() + + while true { + guard let timeout = remainingBudget(until: deadline) else { + logger.warning("\(operationName, privacy: .public) request exhausted its shared timeout budget") + throw RemoteNodeError.timeout + } + + do { + return try await operation(timeout) + } catch { + guard isTransientError(error), let delay = delayIterator.next() else { + throw error } - guard case .deviceError(let code) = meshError else { return false } - return code == FirmwareDeviceErrorCode.remoteNodeNoResponseYet - } - - private func remainingBudget(until deadline: ContinuousClock.Instant) -> Duration? { - let remaining = deadline - .now - return remaining > .zero ? remaining : nil - } - - private func waitForRetry(delay: Duration, until deadline: ContinuousClock.Instant) async throws { - guard let remaining = remainingBudget(until: deadline) else { - throw RemoteNodeError.timeout - } - try await Task.sleep(for: min(delay, remaining)) - } - - func performWithTransientRetries( - operationName: String, - operation: @escaping @Sendable (Duration) async throws -> T - ) async throws -> T { - let deadline = ContinuousClock.now.advanced(by: Self.requestTimeout) - var delayIterator = Self.transientRetryDelays.makeIterator() - - while true { - guard let timeout = remainingBudget(until: deadline) else { - logger.warning("\(operationName, privacy: .public) request exhausted its shared timeout budget") - throw RemoteNodeError.timeout + try await waitForRetry(delay: delay, until: deadline) + } + } + } + + /// Drive a section request through the shared retry budget, owning the + /// loading flag and section-error scaffold that the admin status view models + /// otherwise repeat verbatim. The `setLoading`/`setError` closures target the + /// section's own state (some live on this helper, some on the view model); + /// `onSuccess` applies the response. A `RemoteNodeError.timeout` surfaces the + /// shared timed-out string, any other error its user-facing message. + func runRetryingSectionRequest( + operationName: String, + setLoading: @MainActor (Bool) -> Void, + setError: @MainActor (String?) -> Void, + operation: @escaping @Sendable (Duration) async throws -> T, + onSuccess: @MainActor (T) async -> Void + ) async { + setLoading(true) + setError(nil) + do { + let response = try await performWithTransientRetries(operationName: operationName, operation: operation) + await onSuccess(response) + } catch RemoteNodeError.timeout { + setError(L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut) + } catch { + setError(error.userFacingMessage) + } + setLoading(false) + } + + // MARK: - Status Response Handling + + /// Handle a status response, saving a snapshot with role-specific fields. + /// `rxAirtimeSeconds` and `receiveErrors` are present in all wire frames + /// but rooms pass `nil` to skip persistence of repeater-specific metrics. + func handleStatusResponse( + _ response: RemoteNodeStatus, + rxAirtimeSeconds: UInt32? = nil, + receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) async { + guard let expectedPrefix = session?.publicKeyPrefix, + response.publicKeyPrefix == expectedPrefix else { + return + } + status = response + statusLoaded = true + isLoadingStatus = false + statusSectionError = nil + + guard let nodeSnapshotService, let session else { return } + + previousStatusSnapshot = await nodeSnapshotService.previousStatusSnapshot( + for: session.publicKey, + before: .now + ) + + let metrics = NodeStatusMetrics( + status: response, + rxAirtimeSeconds: rxAirtimeSeconds, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + _ = await nodeSnapshotService.recordSnapshot( + nodePublicKey: session.publicKey, + status: metrics + ) + } + + /// Capture neighbor data onto the node's current in-window snapshot, creating + /// one if none exists yet. Safe to call before a status response: the store + /// enriches the latest in-window row or inserts a neighbor-bearing snapshot. + func enrichNeighbors(_ entries: [NeighborSnapshotEntry]) async { + guard let nodeSnapshotService, let nodePublicKey = effectivePublicKey else { return } + // Capture the prior neighbor-bearing snapshot before persisting this reading, + // so the delta baseline is the previous distinct capture, not this one. + previousNeighborSnapshot = await nodeSnapshotService.previousNeighborSnapshot(for: nodePublicKey) + _ = await nodeSnapshotService.recordSnapshot( + nodePublicKey: nodePublicKey, + neighbors: entries + ) + } + + // MARK: - Telemetry Response Handling + + func handleTelemetryResponse(_ response: TelemetryResponse) async { + guard let expectedPrefix = effectivePublicKeyPrefix, + response.publicKeyPrefix == expectedPrefix else { + return + } + telemetry = response + cachedDataPoints = response.dataPoints.filter { $0.channel != 0 } + isLoadingTelemetry = false + telemetryLoaded = true + telemetrySectionError = nil + + let entries: [TelemetrySnapshotEntry] = cachedDataPoints.compactMap { dp in + let numericValue: Double? = switch dp.value { + case let .float(value): + value + case let .integer(value): + Double(value) + default: + nil + } + guard let value = numericValue else { return nil } + return TelemetrySnapshotEntry(channel: Int(dp.channel), type: dp.typeName, value: value) + } + guard !entries.isEmpty, + let nodeSnapshotService, + let nodePublicKey = effectivePublicKey else { return } + + _ = await nodeSnapshotService.recordSnapshot( + nodePublicKey: nodePublicKey, + telemetry: entries + ) + } + + // MARK: - Telemetry Grouping + + var hasMultipleChannels: Bool { + let channels = Set(cachedDataPoints.map(\.channel)) + return channels.count > 1 + } + + var groupedDataPoints: [(channel: UInt8, dataPoints: [LPPDataPoint])] { + Dictionary(grouping: cachedDataPoints, by: \.channel) + .sorted { $0.key < $1.key } + .map { (channel: $0.key, dataPoints: $0.value) } + } + + // MARK: - Display Formatters + + static let emDash = "—" + private static let secondsPerMinute: UInt32 = 60 + private static let secondsPerHour: UInt32 = 3600 + private static let secondsPerDay: UInt32 = 86400 + + var uptimeDisplay: String { + guard let uptime = status?.uptimeSeconds else { return Self.emDash } + return Self.formatDuration(uptime) + } + + var airtimeDisplay: String { + guard let status else { return Self.emDash } + let tx = Self.formatDuration(status.airtime) + let rx = Self.formatDuration(status.rxAirtime) + return "TX \(tx) / RX \(rx)" + } + + private static let airtimePercentFractionDigits = 1 + + var airtimePercentDisplay: String { + guard let status, status.uptimeSeconds > 0 else { return Self.emDash } + let denom = Double(status.uptimeSeconds) + let txPercent = Double(status.airtime) / denom * 100 + let rxPercent = Double(status.rxAirtime) / denom * 100 + return "TX \(Self.formatPercent(txPercent)) / RX \(Self.formatPercent(rxPercent))" + } + + private static func formatPercent(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(airtimePercentFractionDigits))) + "%" + } + + private static func formatDuration(_ seconds: UInt32) -> String { + let days = Int(seconds / secondsPerDay) + let hours = Int((seconds % secondsPerDay) / secondsPerHour) + let minutes = Int((seconds % secondsPerHour) / secondsPerMinute) + + if days > 0 { + if days == 1 { + return L10n.RemoteNodes.RemoteNodes.Status.uptime1Day(hours, minutes) + } else { + return L10n.RemoteNodes.RemoteNodes.Status.uptimeDays(days, hours, minutes) + } + } else if hours > 0 { + return L10n.RemoteNodes.RemoteNodes.Status.uptimeHours(hours, minutes) + } + return L10n.RemoteNodes.RemoteNodes.Status.uptimeMinutes(minutes) + } + + var batteryDisplay: String { + guard let mv = status?.batteryMillivolts else { return Self.emDash } + let volts = Double(mv) / 1000.0 + let battery = BatteryInfo(level: Int(mv)) + let percent = battery.percentage(using: ocvValues) + return "\(volts.formatted(.number.precision(.fractionLength(3))))V (\(percent)%)" + } + + var lastRSSIDisplay: String { + guard let rssi = status?.lastRSSI else { return Self.emDash } + return "\(rssi) dBm" + } + + var lastSNRDisplay: String { + guard let snr = status?.lastSNR else { return Self.emDash } + return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB" + } + + var noiseFloorDisplay: String { + guard let nf = status?.noiseFloor else { return Self.emDash } + return "\(nf) dBm" + } + + var packetsSentDisplay: String { + guard let count = status?.packetsSent else { return Self.emDash } + return count.formatted() + } + + var packetsReceivedDisplay: String { + guard let count = status?.packetsReceived else { return Self.emDash } + return count.formatted() + } + + // MARK: - Delta Display + + var previousSnapshotTimestamp: String? { + guard let prev = previousStatusSnapshot else { return nil } + let interval = prev.timestamp.distance(to: .now) + let secondsPerHour = TimeInterval(Self.secondsPerHour) + let secondsPerDay = TimeInterval(Self.secondsPerDay) + if interval < secondsPerHour { + return L10n.RemoteNodes.RemoteNodes.History.vsMinutesAgo(Int(interval / 60)) + } else if interval < secondsPerDay { + return L10n.RemoteNodes.RemoteNodes.History.vsHoursAgo(Int(interval / secondsPerHour)) + } else { + return L10n.RemoteNodes.RemoteNodes.History.vsDate(prev.timestamp.formatted(.dateTime.month().day())) + } + } + + var batteryDeltaMV: Int? { + guard let current = status?.batteryMillivolts, + let previous = previousStatusSnapshot?.batteryMillivolts else { return nil } + return Int(current) - Int(previous) + } + + var snrDelta: Double? { + guard let current = status?.lastSNR, + let previous = previousStatusSnapshot?.lastSNR else { return nil } + return current - previous + } + + var rssiDelta: Int? { + guard let current = status?.lastRSSI, + let previous = previousStatusSnapshot?.lastRSSI else { return nil } + return Int(current) - Int(previous) + } + + var noiseFloorDelta: Int? { + guard let current = status?.noiseFloor, + let previous = previousStatusSnapshot?.noiseFloor else { return nil } + return Int(current) - Int(previous) + } + + // MARK: - History + + func fetchHistory() async -> [NodeStatusSnapshotDTO] { + guard let nodeSnapshotService, let publicKey = effectivePublicKey else { + logger.warning("fetchHistory: nodeSnapshotService or public key is nil") + return [] + } + return await nodeSnapshotService.fetchSnapshots(for: publicKey) + } + + // MARK: - OCV Settings + + /// Load OCV settings for a contact by public key. Skips reload if already loaded. + func loadOCVSettings(publicKey: Data, radioID: UUID) async { + guard contactID == nil else { return } + guard let contactService else { return } + + do { + if let contact = try await contactService.getContact(radioID: radioID, publicKey: publicKey) { + contactID = contact.id + + if let presetName = contact.ocvPreset { + if presetName == OCVPreset.custom.rawValue, let customString = contact.customOCVArrayString { + let parsed = customString.split(separator: ",") + .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if parsed.count == 11 { + ocvValues = parsed + selectedOCVPreset = .custom + return } - - do { - return try await operation(timeout) - } catch { - guard isTransientError(error), let delay = delayIterator.next() else { - throw error - } - try await waitForRetry(delay: delay, until: deadline) - } - } - } - - /// Drive a section request through the shared retry budget, owning the - /// loading flag and section-error scaffold that the admin status view models - /// otherwise repeat verbatim. The `setLoading`/`setError` closures target the - /// section's own state (some live on this helper, some on the view model); - /// `onSuccess` applies the response. A `RemoteNodeError.timeout` surfaces the - /// shared timed-out string, any other error its user-facing message. - func runRetryingSectionRequest( - operationName: String, - setLoading: @MainActor (Bool) -> Void, - setError: @MainActor (String?) -> Void, - operation: @escaping @Sendable (Duration) async throws -> T, - onSuccess: @MainActor (T) async -> Void - ) async { - setLoading(true) - setError(nil) - do { - let response = try await performWithTransientRetries(operationName: operationName, operation: operation) - await onSuccess(response) - } catch RemoteNodeError.timeout { - setError(L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut) - } catch { - setError(error.userFacingMessage) - } - setLoading(false) - } - - // MARK: - Status Response Handling - - /// Handle a status response, saving a snapshot with role-specific fields. - /// `rxAirtimeSeconds` and `receiveErrors` are present in all wire frames - /// but rooms pass `nil` to skip persistence of repeater-specific metrics. - func handleStatusResponse( - _ response: RemoteNodeStatus, - rxAirtimeSeconds: UInt32? = nil, - receiveErrors: UInt32? = nil, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil - ) async { - guard let expectedPrefix = session?.publicKeyPrefix, - response.publicKeyPrefix == expectedPrefix else { + } + if let preset = OCVPreset(rawValue: presetName) { + selectedOCVPreset = preset + ocvValues = preset.ocvArray return + } } - self.status = response - self.statusLoaded = true - self.isLoadingStatus = false - self.statusSectionError = nil - - guard let nodeSnapshotService, let session else { return } - self.previousStatusSnapshot = await nodeSnapshotService.previousStatusSnapshot( - for: session.publicKey, - before: .now - ) - - let metrics = NodeStatusMetrics( - status: response, - rxAirtimeSeconds: rxAirtimeSeconds, - receiveErrors: receiveErrors, - postedCount: postedCount, - postPushCount: postPushCount - ) - _ = await nodeSnapshotService.recordSnapshot( - nodePublicKey: session.publicKey, - status: metrics - ) + selectedOCVPreset = .liIon + ocvValues = OCVPreset.liIon.ocvArray + } + } catch { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvLoadFailed } + } - /// Capture neighbor data onto the node's current in-window snapshot, creating - /// one if none exists yet. Safe to call before a status response: the store - /// enriches the latest in-window row or inserts a neighbor-bearing snapshot. - func enrichNeighbors(_ entries: [NeighborSnapshotEntry]) async { - guard let nodeSnapshotService, let nodePublicKey = effectivePublicKey else { return } - // Capture the prior neighbor-bearing snapshot before persisting this reading, - // so the delta baseline is the previous distinct capture, not this one. - self.previousNeighborSnapshot = await nodeSnapshotService.previousNeighborSnapshot(for: nodePublicKey) - _ = await nodeSnapshotService.recordSnapshot( - nodePublicKey: nodePublicKey, - neighbors: entries - ) + func saveOCVSettings(preset: OCVPreset, values: [Int]) async { + guard let contactService, + let contactID else { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveNoContact + return } - // MARK: - Telemetry Response Handling - - func handleTelemetryResponse(_ response: TelemetryResponse) async { - guard let expectedPrefix = effectivePublicKeyPrefix, - response.publicKeyPrefix == expectedPrefix else { - return - } - self.telemetry = response - self.cachedDataPoints = response.dataPoints.filter { $0.channel != 0 } - self.isLoadingTelemetry = false - self.telemetryLoaded = true - self.telemetrySectionError = nil - - let entries: [TelemetrySnapshotEntry] = cachedDataPoints.compactMap { dp in - let numericValue: Double? - switch dp.value { - case .float(let value): - numericValue = value - case .integer(let value): - numericValue = Double(value) - default: - numericValue = nil - } - guard let value = numericValue else { return nil } - return TelemetrySnapshotEntry(channel: Int(dp.channel), type: dp.typeName, value: value) - } - guard !entries.isEmpty, - let nodeSnapshotService, - let nodePublicKey = effectivePublicKey else { return } + ocvError = nil - _ = await nodeSnapshotService.recordSnapshot( - nodePublicKey: nodePublicKey, - telemetry: entries + do { + if preset == .custom { + let customString = values.map(String.init).joined(separator: ",") + try await contactService.updateContactOCVSettings( + contactID: contactID, + preset: OCVPreset.custom.rawValue, + customArray: customString ) - } - - // MARK: - Telemetry Grouping - - var hasMultipleChannels: Bool { - let channels = Set(cachedDataPoints.map(\.channel)) - return channels.count > 1 - } - - var groupedDataPoints: [(channel: UInt8, dataPoints: [LPPDataPoint])] { - Dictionary(grouping: cachedDataPoints, by: \.channel) - .sorted { $0.key < $1.key } - .map { (channel: $0.key, dataPoints: $0.value) } - } - - // MARK: - Display Formatters - - static let emDash = "—" - private static let secondsPerMinute: UInt32 = 60 - private static let secondsPerHour: UInt32 = 3_600 - private static let secondsPerDay: UInt32 = 86_400 - - var uptimeDisplay: String { - guard let uptime = status?.uptimeSeconds else { return Self.emDash } - return Self.formatDuration(uptime) - } - - var airtimeDisplay: String { - guard let status else { return Self.emDash } - let tx = Self.formatDuration(status.airtime) - let rx = Self.formatDuration(status.rxAirtime) - return "TX \(tx) / RX \(rx)" - } - - private static let airtimePercentFractionDigits = 1 - - var airtimePercentDisplay: String { - guard let status, status.uptimeSeconds > 0 else { return Self.emDash } - let denom = Double(status.uptimeSeconds) - let txPercent = Double(status.airtime) / denom * 100 - let rxPercent = Double(status.rxAirtime) / denom * 100 - return "TX \(Self.formatPercent(txPercent)) / RX \(Self.formatPercent(rxPercent))" - } - - private static func formatPercent(_ value: Double) -> String { - value.formatted(.number.precision(.fractionLength(airtimePercentFractionDigits))) + "%" - } - - private static func formatDuration(_ seconds: UInt32) -> String { - let days = Int(seconds / secondsPerDay) - let hours = Int((seconds % secondsPerDay) / secondsPerHour) - let minutes = Int((seconds % secondsPerHour) / secondsPerMinute) - - if days > 0 { - if days == 1 { - return L10n.RemoteNodes.RemoteNodes.Status.uptime1Day(hours, minutes) - } else { - return L10n.RemoteNodes.RemoteNodes.Status.uptimeDays(days, hours, minutes) - } - } else if hours > 0 { - return L10n.RemoteNodes.RemoteNodes.Status.uptimeHours(hours, minutes) - } - return L10n.RemoteNodes.RemoteNodes.Status.uptimeMinutes(minutes) - } - - var batteryDisplay: String { - guard let mv = status?.batteryMillivolts else { return Self.emDash } - let volts = Double(mv) / 1000.0 - let battery = BatteryInfo(level: Int(mv)) - let percent = battery.percentage(using: ocvValues) - return "\(volts.formatted(.number.precision(.fractionLength(3))))V (\(percent)%)" - } - - var lastRSSIDisplay: String { - guard let rssi = status?.lastRSSI else { return Self.emDash } - return "\(rssi) dBm" - } - - var lastSNRDisplay: String { - guard let snr = status?.lastSNR else { return Self.emDash } - return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB" - } - - var noiseFloorDisplay: String { - guard let nf = status?.noiseFloor else { return Self.emDash } - return "\(nf) dBm" - } - - var packetsSentDisplay: String { - guard let count = status?.packetsSent else { return Self.emDash } - return count.formatted() - } - - var packetsReceivedDisplay: String { - guard let count = status?.packetsReceived else { return Self.emDash } - return count.formatted() - } - - // MARK: - Delta Display - - var previousSnapshotTimestamp: String? { - guard let prev = previousStatusSnapshot else { return nil } - let interval = prev.timestamp.distance(to: .now) - let secondsPerHour = TimeInterval(Self.secondsPerHour) - let secondsPerDay = TimeInterval(Self.secondsPerDay) - if interval < secondsPerHour { - return L10n.RemoteNodes.RemoteNodes.History.vsMinutesAgo(Int(interval / 60)) - } else if interval < secondsPerDay { - return L10n.RemoteNodes.RemoteNodes.History.vsHoursAgo(Int(interval / secondsPerHour)) - } else { - return L10n.RemoteNodes.RemoteNodes.History.vsDate(prev.timestamp.formatted(.dateTime.month().day())) - } - } - - var batteryDeltaMV: Int? { - guard let current = status?.batteryMillivolts, - let previous = previousStatusSnapshot?.batteryMillivolts else { return nil } - return Int(current) - Int(previous) - } - - var snrDelta: Double? { - guard let current = status?.lastSNR, - let previous = previousStatusSnapshot?.lastSNR else { return nil } - return current - previous - } - - var rssiDelta: Int? { - guard let current = status?.lastRSSI, - let previous = previousStatusSnapshot?.lastRSSI else { return nil } - return Int(current) - Int(previous) - } - - var noiseFloorDelta: Int? { - guard let current = status?.noiseFloor, - let previous = previousStatusSnapshot?.noiseFloor else { return nil } - return Int(current) - Int(previous) - } - - // MARK: - History - - func fetchHistory() async -> [NodeStatusSnapshotDTO] { - guard let nodeSnapshotService, let publicKey = effectivePublicKey else { - logger.warning("fetchHistory: nodeSnapshotService or public key is nil") - return [] - } - return await nodeSnapshotService.fetchSnapshots(for: publicKey) - } - - // MARK: - OCV Settings - - /// Load OCV settings for a contact by public key. Skips reload if already loaded. - func loadOCVSettings(publicKey: Data, radioID: UUID) async { - guard contactID == nil else { return } - guard let contactService else { return } - - do { - if let contact = try await contactService.getContact(radioID: radioID, publicKey: publicKey) { - contactID = contact.id - - if let presetName = contact.ocvPreset { - if presetName == OCVPreset.custom.rawValue, let customString = contact.customOCVArrayString { - let parsed = customString.split(separator: ",") - .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } - if parsed.count == 11 { - ocvValues = parsed - selectedOCVPreset = .custom - return - } - } - if let preset = OCVPreset(rawValue: presetName) { - selectedOCVPreset = preset - ocvValues = preset.ocvArray - return - } - } - - selectedOCVPreset = .liIon - ocvValues = OCVPreset.liIon.ocvArray - } - } catch { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvLoadFailed - } - } - - func saveOCVSettings(preset: OCVPreset, values: [Int]) async { - guard let contactService, - let contactID else { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveNoContact - return - } - - ocvError = nil - - do { - if preset == .custom { - let customString = values.map(String.init).joined(separator: ",") - try await contactService.updateContactOCVSettings( - contactID: contactID, - preset: OCVPreset.custom.rawValue, - customArray: customString - ) - } else { - try await contactService.updateContactOCVSettings( - contactID: contactID, - preset: preset.rawValue, - customArray: nil - ) - } + } else { + try await contactService.updateContactOCVSettings( + contactID: contactID, + preset: preset.rawValue, + customArray: nil + ) + } - selectedOCVPreset = preset - ocvValues = values - } catch { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveFailed(error.userFacingMessage) - } + selectedOCVPreset = preset + ocvValues = values + } catch { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveFailed(error.userFacingMessage) } + } } diff --git a/MC1/Views/RemoteNodes/NodeTelemetryView.swift b/MC1/Views/RemoteNodes/NodeTelemetryView.swift index f9a86232..53d2a79c 100644 --- a/MC1/Views/RemoteNodes/NodeTelemetryView.swift +++ b/MC1/Views/RemoteNodes/NodeTelemetryView.swift @@ -2,60 +2,60 @@ import MC1Services import SwiftUI struct NodeTelemetryView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss - let contact: ContactDTO - @State private var viewModel = NodeTelemetryViewModel() + let contact: ContactDTO + @State private var viewModel = NodeTelemetryViewModel() - var body: some View { - NavigationStack { - List { - NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: appState.connectionState) { - await viewModel.requestTelemetry() - } - } - .nodeStatusDestinations(helper: viewModel.helper) - .themedCanvas(theme) - .navigationTitle(contact.displayName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } - } + var body: some View { + NavigationStack { + List { + NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: appState.connectionState) { + await viewModel.requestTelemetry() + } + } + .nodeStatusDestinations(helper: viewModel.helper) + .themedCanvas(theme) + .navigationTitle(contact.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } + } - ToolbarItem(placement: .primaryAction) { - Button { - Task { await viewModel.requestTelemetry() } - } label: { - Image(systemName: "arrow.clockwise") - } - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.refresh) - .radioDisabled( - for: appState.connectionState, - or: viewModel.helper.isLoadingTelemetry - ) - } - } - .task { - viewModel.configure( - binaryProtocolService: { appState.services?.binaryProtocolService }, - contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService }, - contact: contact - ) + ToolbarItem(placement: .primaryAction) { + Button { + Task { await viewModel.requestTelemetry() } + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.refresh) + .radioDisabled( + for: appState.connectionState, + or: viewModel.helper.isLoadingTelemetry + ) + } + } + .task { + viewModel.configure( + binaryProtocolService: { appState.services?.binaryProtocolService }, + contactService: { appState.services?.contactService }, + nodeSnapshotService: { appState.services?.nodeSnapshotService }, + contact: contact + ) - viewModel.helper.telemetryExpanded = true + viewModel.helper.telemetryExpanded = true - if let radioID = appState.connectedDevice?.radioID { - await viewModel.helper.loadOCVSettings( - publicKey: contact.publicKey, - radioID: radioID - ) - } - } + if let radioID = appState.connectedDevice?.radioID { + await viewModel.helper.loadOCVSettings( + publicKey: contact.publicKey, + radioID: radioID + ) } - .presentationDetents([.large]) + } } + .presentationDetents([.large]) + } } diff --git a/MC1/Views/RemoteNodes/NodeTelemetryViewModel.swift b/MC1/Views/RemoteNodes/NodeTelemetryViewModel.swift index 2b2e7f09..4dd79660 100644 --- a/MC1/Views/RemoteNodes/NodeTelemetryViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeTelemetryViewModel.swift @@ -1,5 +1,5 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "NodeTelemetryVM") @@ -7,61 +7,63 @@ private let logger = Logger(subsystem: "com.mc1", category: "NodeTelemetryVM") @Observable @MainActor final class NodeTelemetryViewModel { + // MARK: - Shared Helper - // MARK: - Shared Helper + var helper = NodeStatusViewModel() - var helper = NodeStatusViewModel() + // MARK: - Dependencies - // MARK: - Dependencies + private var binaryProtocolServiceProvider: @MainActor () -> BinaryProtocolService? = { nil } + var binaryProtocolService: BinaryProtocolService? { + binaryProtocolServiceProvider() + } - private var binaryProtocolServiceProvider: @MainActor () -> BinaryProtocolService? = { nil } - var binaryProtocolService: BinaryProtocolService? { binaryProtocolServiceProvider() } - private var publicKey: Data? + private var publicKey: Data? - // MARK: - Initialization + // MARK: - Initialization - /// Nil services mirror a disconnected state; requests then no-op. - func configure( - binaryProtocolService: @escaping @MainActor () -> BinaryProtocolService?, - contactService: @escaping @MainActor () -> ContactService?, - nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService?, - contact: ContactDTO - ) { - self.binaryProtocolServiceProvider = binaryProtocolService - self.publicKey = contact.publicKey - helper.configure( - contactService: contactService, - nodeSnapshotService: nodeSnapshotService - ) - helper.configureForDirectTelemetry(publicKey: contact.publicKey) - } + /// Nil services mirror a disconnected state; requests then no-op. + func configure( + binaryProtocolService: @escaping @MainActor () -> BinaryProtocolService?, + contactService: @escaping @MainActor () -> ContactService?, + nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService?, + contact: ContactDTO + ) { + binaryProtocolServiceProvider = binaryProtocolService + publicKey = contact.publicKey + helper.configure( + contactService: contactService, + nodeSnapshotService: nodeSnapshotService + ) + helper.configureForDirectTelemetry(publicKey: contact.publicKey) + } - // MARK: - Telemetry + // MARK: - Telemetry - func requestTelemetry() async { - guard let binaryProtocolService = binaryProtocolService, let publicKey else { return } + func requestTelemetry() async { + guard let binaryProtocolService, let publicKey else { return } - await helper.runRetryingSectionRequest( - operationName: "telemetry", - setLoading: { self.helper.isLoadingTelemetry = $0 }, - setError: { self.helper.telemetrySectionError = $0 }, - operation: { [binaryProtocolService, publicKey] _ in - // BinaryProtocolService relies on the session's own timeout; it has no - // timeout parameter, and reports a timeout as a wrapped session error, - // so map that to the telemetry-specific "may be disabled" copy. - do { - return try await binaryProtocolService.requestTelemetry(from: publicKey) - } catch BinaryProtocolError.sessionError(MeshCoreError.timeout) { - throw RemoteNodeError.timeout - } - }, - onSuccess: { await self.helper.handleTelemetryResponse($0) } - ) - - // The shared NodeStatusViewModel renders a generic timed-out string; telemetry has a more - // specific cause worth surfacing, so refine that one case. - if helper.telemetrySectionError == L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut { - helper.telemetrySectionError = L10n.RemoteNodes.RemoteNodes.Status.telemetryTimedOut + await helper.runRetryingSectionRequest( + operationName: "telemetry", + setLoading: { self.helper.isLoadingTelemetry = $0 }, + setError: { self.helper.telemetrySectionError = $0 }, + operation: { [binaryProtocolService, publicKey] _ in + // BinaryProtocolService relies on the session's own timeout; it has no + // timeout parameter, and reports a timeout as a wrapped session error, + // so map that to the telemetry-specific "may be disabled" copy. + do { + return try await binaryProtocolService.requestTelemetry(from: publicKey) + } catch BinaryProtocolError.sessionError(MeshCoreError.timeout) { + throw RemoteNodeError.timeout } + }, + onSuccess: { await self.helper.handleTelemetryResponse($0) } + ) + + // The shared NodeStatusViewModel renders a generic timed-out string; telemetry has a more + // specific cause worth surfacing, so refine that one case. + if helper.telemetrySectionError == L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut { + helper.telemetrySectionError = L10n.RemoteNodes.RemoteNodes.Status.telemetryTimedOut } + } } diff --git a/MC1/Views/RemoteNodes/RadioMetricCharts.swift b/MC1/Views/RemoteNodes/RadioMetricCharts.swift index 0ab31220..d43e83f2 100644 --- a/MC1/Views/RemoteNodes/RadioMetricCharts.swift +++ b/MC1/Views/RemoteNodes/RadioMetricCharts.swift @@ -7,102 +7,102 @@ import SwiftUI /// chrome (a themed `Section` in the drill-down list, a bare row inside the overview's /// disclosure group). struct RadioMetricCharts: View { - let snapshots: [NodeStatusSnapshotDTO] - let ocvArray: [Int] - @ViewBuilder let chartContainer: (MetricChartView) -> ChartContainer + let snapshots: [NodeStatusSnapshotDTO] + let ocvArray: [Int] + @ViewBuilder let chartContainer: (MetricChartView) -> ChartContainer - var body: some View { - let batteryPoints = snapshots.compactMap { s in - s.batteryMillivolts.map { - MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0) / 1000.0) - } - } - chart( - title: L10n.RemoteNodes.RemoteNodes.History.battery, unit: "V", color: .mint, - dataPoints: batteryPoints, - yAxisDomain: ocvArray.voltageChartDomain(dataPoints: batteryPoints) - ) + var body: some View { + let batteryPoints = snapshots.compactMap { s in + s.batteryMillivolts.map { + MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0) / 1000.0) + } + } + chart( + title: L10n.RemoteNodes.RemoteNodes.History.battery, unit: "V", color: .mint, + dataPoints: batteryPoints, + yAxisDomain: ocvArray.voltageChartDomain(dataPoints: batteryPoints) + ) - chart( - title: L10n.RemoteNodes.RemoteNodes.History.snr, unit: "dB", color: .blue, - dataPoints: snapshots.compactMap { s in - s.lastSNR.map { .init(id: s.id, date: s.timestamp, value: $0) } - } - ) + chart( + title: L10n.RemoteNodes.RemoteNodes.History.snr, unit: "dB", color: .blue, + dataPoints: snapshots.compactMap { s in + s.lastSNR.map { .init(id: s.id, date: s.timestamp, value: $0) } + } + ) - chart( - title: L10n.RemoteNodes.RemoteNodes.History.rssi, unit: "dBm", color: .purple, - dataPoints: snapshots.compactMap { s in - s.lastRSSI.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } - } - ) + chart( + title: L10n.RemoteNodes.RemoteNodes.History.rssi, unit: "dBm", color: .purple, + dataPoints: snapshots.compactMap { s in + s.lastRSSI.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) - chart( - title: L10n.RemoteNodes.RemoteNodes.History.noiseFloor, unit: "dBm", color: .indigo, - dataPoints: snapshots.compactMap { s in - s.noiseFloor.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } - } - ) + chart( + title: L10n.RemoteNodes.RemoteNodes.History.noiseFloor, unit: "dBm", color: .indigo, + dataPoints: snapshots.compactMap { s in + s.noiseFloor.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) - let packetsSentPoints = snapshots.compactMap { s in - s.packetsSent.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } - } - let packetsReceivedPoints = snapshots.compactMap { s in - s.packetsReceived.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } - } - let receiveErrorPoints = snapshots.compactMap { s in - s.receiveErrors.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } - } - let postsReceivedPoints = snapshots.compactMap { s in - s.postedCount.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } - } - let postsPushedPoints = snapshots.compactMap { s in - s.postPushCount.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } - } - let packetDomain = [MetricChartView.DataPoint].sharedDomain(for: [ - packetsSentPoints, packetsReceivedPoints, receiveErrorPoints - ]) + let packetsSentPoints = snapshots.compactMap { s in + s.packetsSent.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } + } + let packetsReceivedPoints = snapshots.compactMap { s in + s.packetsReceived.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } + } + let receiveErrorPoints = snapshots.compactMap { s in + s.receiveErrors.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } + } + let postsReceivedPoints = snapshots.compactMap { s in + s.postedCount.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } + } + let postsPushedPoints = snapshots.compactMap { s in + s.postPushCount.map { MetricChartView.DataPoint(id: s.id, date: s.timestamp, value: Double($0)) } + } + let packetDomain = [MetricChartView.DataPoint].sharedDomain(for: [ + packetsSentPoints, packetsReceivedPoints, receiveErrorPoints + ]) - chart( - title: L10n.RemoteNodes.RemoteNodes.History.packetsSent, unit: "", color: .green, - dataPoints: packetsSentPoints, yAxisDomain: packetDomain - ) + chart( + title: L10n.RemoteNodes.RemoteNodes.History.packetsSent, unit: "", color: .green, + dataPoints: packetsSentPoints, yAxisDomain: packetDomain + ) - chart( - title: L10n.RemoteNodes.RemoteNodes.History.packetsReceived, unit: "", color: .orange, - dataPoints: packetsReceivedPoints, yAxisDomain: packetDomain - ) + chart( + title: L10n.RemoteNodes.RemoteNodes.History.packetsReceived, unit: "", color: .orange, + dataPoints: packetsReceivedPoints, yAxisDomain: packetDomain + ) - chart( - title: L10n.RemoteNodes.RemoteNodes.History.receiveErrors, unit: "", color: .red, - dataPoints: receiveErrorPoints, yAxisDomain: packetDomain - ) + chart( + title: L10n.RemoteNodes.RemoteNodes.History.receiveErrors, unit: "", color: .red, + dataPoints: receiveErrorPoints, yAxisDomain: packetDomain + ) - chart( - title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, unit: "", color: .purple, - dataPoints: postsReceivedPoints - ) + chart( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, unit: "", color: .purple, + dataPoints: postsReceivedPoints + ) - chart( - title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, unit: "", color: .cyan, - dataPoints: postsPushedPoints - ) - } + chart( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, unit: "", color: .cyan, + dataPoints: postsPushedPoints + ) + } - @ViewBuilder - private func chart( - title: String, unit: String, color: Color, - dataPoints: [MetricChartView.DataPoint], - yAxisDomain: ClosedRange? = nil - ) -> some View { - if !dataPoints.isEmpty { - chartContainer( - MetricChartView( - title: title, unit: unit, - dataPoints: dataPoints, accentColor: color, - yAxisDomain: yAxisDomain - ) - ) - } + @ViewBuilder + private func chart( + title: String, unit: String, color: Color, + dataPoints: [MetricChartView.DataPoint], + yAxisDomain: ClosedRange? = nil + ) -> some View { + if !dataPoints.isEmpty { + chartContainer( + MetricChartView( + title: title, unit: unit, + dataPoints: dataPoints, accentColor: color, + yAxisDomain: yAxisDomain + ) + ) } + } } diff --git a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift index b3ed5c10..0dafebd2 100644 --- a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift +++ b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift @@ -7,118 +7,118 @@ import MC1Services /// neighbors, and the resolver inputs, with no per-connection dependency and nothing live to read. /// `PlottedNeighbors` holds non-`Sendable` MapKit types, so it is built and consumed on `@MainActor`. enum NeighborSNRMapBuilder { - struct PlottedNeighbors { - let points: [MapPoint] - let lines: [MapLine] - let region: MKCoordinateRegion? - let unplottable: [UnplottableNeighbor] - } - - /// A neighbor that could not be placed reliably (ambiguous match, no location, an invalid - /// coordinate, or unresolved), carried with its resolved name and confidence for the list card. - struct UnplottableNeighbor { - let neighbor: NeighbourInfo - let displayName: String - let matchKind: NodeNameMatchKind - } + struct PlottedNeighbors { + let points: [MapPoint] + let lines: [MapLine] + let region: MKCoordinateRegion? + let unplottable: [UnplottableNeighbor] + } - private static let lineOpacity = 1.0 + /// A neighbor that could not be placed reliably (ambiguous match, no location, an invalid + /// coordinate, or unresolved), carried with its resolved name and confidence for the list card. + struct UnplottableNeighbor { + let neighbor: NeighbourInfo + let displayName: String + let matchKind: NodeNameMatchKind + } - static func build( - session: RemoteNodeSessionDTO, - neighbors: [NeighbourInfo], - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO], - userLocation: CLLocation? - ) -> PlottedNeighbors { - var points: [MapPoint] = [] - var lines: [MapLine] = [] - var unplottable: [UnplottableNeighbor] = [] - var plottedCoordinates: [CLLocationCoordinate2D] = [] + private static let lineOpacity = 1.0 - let centerCoordinate = session.coordinate - if let centerCoordinate { - points.append(MapPoint( - id: UUID(), - coordinate: centerCoordinate, - pinStyle: .repeaterRingWhite, - label: session.name, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - plottedCoordinates.append(centerCoordinate) - } + static func build( + session: RemoteNodeSessionDTO, + neighbors: [NeighbourInfo], + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> PlottedNeighbors { + var points: [MapPoint] = [] + var lines: [MapLine] = [] + var unplottable: [UnplottableNeighbor] = [] + var plottedCoordinates: [CLLocationCoordinate2D] = [] - for (index, neighbor) in neighbors.enumerated() { - guard let resolved = NeighborNameResolver.resolveLocated( - for: neighbor.publicKeyPrefix, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) else { - unplottable.append(UnplottableNeighbor( - neighbor: neighbor, - displayName: NeighborNameResolver.fallbackName(for: neighbor.publicKeyPrefix), - matchKind: .unresolved - )) - continue - } + let centerCoordinate = session.coordinate + if let centerCoordinate { + points.append(MapPoint( + id: UUID(), + coordinate: centerCoordinate, + pinStyle: .repeaterRingWhite, + label: session.name, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + plottedCoordinates.append(centerCoordinate) + } - // Only an exact identity match with a trustworthy coordinate is plotted; the validity - // guard lives here because `DiscoveredNodeDTO.hasLocation` checks only non-(0,0) and - // would otherwise admit an out-of-range point. - guard resolved.matchKind == .exact, - let coordinate = resolved.coordinate, - isPlottable(coordinate) else { - unplottable.append(UnplottableNeighbor( - neighbor: neighbor, - displayName: resolved.displayName, - matchKind: resolved.matchKind - )) - continue - } + for (index, neighbor) in neighbors.enumerated() { + guard let resolved = NeighborNameResolver.resolveLocated( + for: neighbor.publicKeyPrefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) else { + unplottable.append(UnplottableNeighbor( + neighbor: neighbor, + displayName: NeighborNameResolver.fallbackName(for: neighbor.publicKeyPrefix), + matchKind: .unresolved + )) + continue + } - points.append(MapPoint( - id: UUID(), - coordinate: coordinate, - pinStyle: .repeater, - label: resolved.displayName, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - plottedCoordinates.append(coordinate) + // Only an exact identity match with a trustworthy coordinate is plotted; the validity + // guard lives here because `DiscoveredNodeDTO.hasLocation` checks only non-(0,0) and + // would otherwise admit an out-of-range point. + guard resolved.matchKind == .exact, + let coordinate = resolved.coordinate, + isPlottable(coordinate) else { + unplottable.append(UnplottableNeighbor( + neighbor: neighbor, + displayName: resolved.displayName, + matchKind: resolved.matchKind + )) + continue + } - // A line and distance/SNR badge are defensible only when the center is also located; - // without an anchor the neighbor pin still stands alone. - guard let centerCoordinate else { continue } + points.append(MapPoint( + id: UUID(), + coordinate: coordinate, + pinStyle: .repeater, + label: resolved.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + plottedCoordinates.append(coordinate) - lines.append(MapLine( - id: "neighbor-\(index)", - coordinates: [centerCoordinate, coordinate], - style: .forSNR(neighbor.snr), - opacity: lineOpacity, - pathIndex: nil - )) + // A line and distance/SNR badge are defensible only when the center is also located; + // without an anchor the neighbor pin still stands alone. + guard let centerCoordinate else { continue } - points.append(MapLine.snrBadge( - id: UUID(), - from: centerCoordinate, - to: coordinate, - snr: neighbor.snr - )) - } + lines.append(MapLine( + id: "neighbor-\(index)", + coordinates: [centerCoordinate, coordinate], + style: .forSNR(neighbor.snr), + opacity: lineOpacity, + pathIndex: nil + )) - return PlottedNeighbors( - points: points, - lines: lines, - region: plottedCoordinates.boundingRegion(), - unplottable: unplottable - ) + points.append(MapLine.snrBadge( + id: UUID(), + from: centerCoordinate, + to: coordinate, + snr: neighbor.snr + )) } - private static func isPlottable(_ coordinate: CLLocationCoordinate2D) -> Bool { - CLLocationCoordinate2DIsValid(coordinate) - } + return PlottedNeighbors( + points: points, + lines: lines, + region: plottedCoordinates.boundingRegion(), + unplottable: unplottable + ) + } + + private static func isPlottable(_ coordinate: CLLocationCoordinate2D) -> Bool { + CLLocationCoordinate2DIsValid(coordinate) + } } diff --git a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift index 3e682486..e3aafbf9 100644 --- a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift @@ -13,164 +13,164 @@ import SwiftUI /// sheet on the telemetry sheet that hosts it collapses both presentations. The no-location list is /// likewise a push, not a sheet, for the same reason. struct NeighborSNRMapView: View { - @Environment(\.appState) private var appState - @Environment(\.colorScheme) private var colorScheme - - let session: RemoteNodeSessionDTO - let neighbors: [NeighbourInfo] - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let userLocation: CLLocation? - - @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard - @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels - - @State private var cameraRegion: MKCoordinateRegion? - @State private var cameraRegionVersion = 0 - @State private var plotted: NeighborSNRMapBuilder.PlottedNeighbors? - @State private var isNorthLocked = false - @State private var showingLayersMenu = false - @State private var showingNoLocationList = false - @State private var isStyleLoaded = false - - private static let myLocationSpan = 0.02 - - /// Float the layers menu just above the toolbar's bottom-right corner. - private static let layersMenuTrailingPadding: CGFloat = 16 - private static let layersMenuBottomPadding: CGFloat = 240 - - var body: some View { - ZStack(alignment: .bottom) { - MC1MapView( - points: plotted?.points ?? [], - lines: plotted?.lines ?? [], - mapStyle: mapStyleSelection, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: showLabels, - showsUserLocation: true, - isInteractive: true, - showsScale: true, - isNorthLocked: isNorthLocked, - cameraRegion: $cameraRegion, - cameraRegionVersion: cameraRegionVersion, - cameraBottomSheetFraction: 0, - onPointTap: nil, - onMapTap: nil, - onCameraRegionChange: { cameraRegion = $0 }, - isStyleLoaded: $isStyleLoaded - ) - .ignoresSafeArea() - - toolbarOverlay - } - .overlay(alignment: .top) { - if let unplottable = plotted?.unplottable, !unplottable.isEmpty { - noLocationPill(count: unplottable.count) - } - } - .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.neighborsMapTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationDestination(isPresented: $showingNoLocationList) { - if let unplottable = plotted?.unplottable { - NeighborsNoLocationList(unplottable: unplottable) - } - } - .onAppear { - guard plotted == nil else { return } - let built = NeighborSNRMapBuilder.build( - session: session, - neighbors: neighbors, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - withAnimation { plotted = built } - setCameraRegion(built.region) - } - // The map gates camera moves until its style finishes loading, which usually lands after - // the on-appear fit. Re-issue the fit on that signal so the nodes frame deterministically - // rather than depending on an incidental re-render. - .onChange(of: isStyleLoaded) { _, loaded in - if loaded { setCameraRegion(plotted?.region) } - } + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + + let session: RemoteNodeSessionDTO + let neighbors: [NeighbourInfo] + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? + + @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels + + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 + @State private var plotted: NeighborSNRMapBuilder.PlottedNeighbors? + @State private var isNorthLocked = false + @State private var showingLayersMenu = false + @State private var showingNoLocationList = false + @State private var isStyleLoaded = false + + private static let myLocationSpan = 0.02 + + /// Float the layers menu just above the toolbar's bottom-right corner. + private static let layersMenuTrailingPadding: CGFloat = 16 + private static let layersMenuBottomPadding: CGFloat = 240 + + var body: some View { + ZStack(alignment: .bottom) { + MC1MapView( + points: plotted?.points ?? [], + lines: plotted?.lines ?? [], + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: isNorthLocked, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + cameraBottomSheetFraction: 0, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { cameraRegion = $0 }, + isStyleLoaded: $isStyleLoaded + ) + .ignoresSafeArea() + + toolbarOverlay } - - /// Glanceable count of neighbors that couldn't be placed; tapping pushes their list. Styled like - /// the trace path map's top banner so the two maps read the same. - private func noLocationPill(count: Int) -> some View { - Button { - showingNoLocationList = true - } label: { - HStack(spacing: 6) { - Image(systemName: "mappin.slash") - Text(L10n.RemoteNodes.RemoteNodes.Status.neighborsNotShown(count)) - } - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .liquidGlass(in: .capsule) - } - .buttonStyle(.plain) - .safeAreaPadding(.top) - .transition(.move(edge: .top).combined(with: .opacity)) + .overlay(alignment: .top) { + if let unplottable = plotted?.unplottable, !unplottable.isEmpty { + noLocationPill(count: unplottable.count) + } } - - /// Fixed bottom-right map controls. - private var toolbarOverlay: some View { - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: centerOnMyLocation, - isNorthLocked: $isNorthLocked, - showLabels: $showLabels, - showingLayersMenu: $showingLayersMenu - ) { - centerAllButton - } - .overlay(alignment: .bottomTrailing) { - if showingLayersMenu { - LayersMenu( - selection: $mapStyleSelection, - isPresented: $showingLayersMenu, - viewportBounds: cameraRegion?.toMLNCoordinateBounds() - ) - .padding(.trailing, Self.layersMenuTrailingPadding) - .padding(.bottom, Self.layersMenuBottomPadding) - .transition(.scale.combined(with: .opacity)) - } - } - } - .animation(.spring(response: 0.3), value: showingLayersMenu) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.neighborsMapTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $showingNoLocationList) { + if let unplottable = plotted?.unplottable { + NeighborsNoLocationList(unplottable: unplottable) + } } - - /// Centers and fits the camera in one step: `MC1MapView` ignores a region whose version - /// still matches the last applied one, so the region and the version bump must move together. - private func setCameraRegion(_ region: MKCoordinateRegion?) { - guard let region else { return } - cameraRegion = region - cameraRegionVersion += 1 + .onAppear { + guard plotted == nil else { return } + let built = NeighborSNRMapBuilder.build( + session: session, + neighbors: neighbors, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + withAnimation { plotted = built } + setCameraRegion(built.region) } - - private func centerOnMyLocation() { - if let location = appState.bestAvailableLocation { - setCameraRegion(MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan(latitudeDelta: Self.myLocationSpan, longitudeDelta: Self.myLocationSpan) - )) - } else { - appState.locationService.requestLocation() + // The map gates camera moves until its style finishes loading, which usually lands after + // the on-appear fit. Re-issue the fit on that signal so the nodes frame deterministically + // rather than depending on an incidental re-render. + .onChange(of: isStyleLoaded) { _, loaded in + if loaded { setCameraRegion(plotted?.region) } + } + } + + /// Glanceable count of neighbors that couldn't be placed; tapping pushes their list. Styled like + /// the trace path map's top banner so the two maps read the same. + private func noLocationPill(count: Int) -> some View { + Button { + showingNoLocationList = true + } label: { + HStack(spacing: 6) { + Image(systemName: "mappin.slash") + Text(L10n.RemoteNodes.RemoteNodes.Status.neighborsNotShown(count)) + } + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .liquidGlass(in: .capsule) + } + .buttonStyle(.plain) + .safeAreaPadding(.top) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + /// Fixed bottom-right map controls. + private var toolbarOverlay: some View { + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: centerOnMyLocation, + isNorthLocked: $isNorthLocked, + showLabels: $showLabels, + showingLayersMenu: $showingLayersMenu + ) { + centerAllButton + } + .overlay(alignment: .bottomTrailing) { + if showingLayersMenu { + LayersMenu( + selection: $mapStyleSelection, + isPresented: $showingLayersMenu, + viewportBounds: cameraRegion?.toMLNCoordinateBounds() + ) + .padding(.trailing, Self.layersMenuTrailingPadding) + .padding(.bottom, Self.layersMenuBottomPadding) + .transition(.scale.combined(with: .opacity)) } + } + } + .animation(.spring(response: 0.3), value: showingLayersMenu) + } + + /// Centers and fits the camera in one step: `MC1MapView` ignores a region whose version + /// still matches the last applied one, so the region and the version bump must move together. + private func setCameraRegion(_ region: MKCoordinateRegion?) { + guard let region else { return } + cameraRegion = region + cameraRegionVersion += 1 + } + + private func centerOnMyLocation() { + if let location = appState.bestAvailableLocation { + setCameraRegion(MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: Self.myLocationSpan, longitudeDelta: Self.myLocationSpan) + )) + } else { + appState.locationService.requestLocation() } + } - /// Re-fits the camera to the repeater and its plotted neighbors. Disabled when nothing is plottable. - private var centerAllButton: some View { - Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { - setCameraRegion(plotted?.region) - } - .mapControlButton(tint: plotted?.region == nil ? .secondary : .primary) - .disabled(plotted?.region == nil) + /// Re-fits the camera to the repeater and its plotted neighbors. Disabled when nothing is plottable. + private var centerAllButton: some View { + Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { + setCameraRegion(plotted?.region) } + .mapControlButton(tint: plotted?.region == nil ? .secondary : .primary) + .disabled(plotted?.region == nil) + } } // MARK: - No Location List @@ -178,17 +178,17 @@ struct NeighborSNRMapView: View { /// Pushed list of neighbors that could not be placed reliably (ambiguous, unlocated, or /// unresolved). Reuses `NeighborRow`, including its "?" fallback-match affordance. private struct NeighborsNoLocationList: View { - let unplottable: [NeighborSNRMapBuilder.UnplottableNeighbor] - - var body: some View { - List(unplottable, id: \.neighbor.publicKeyPrefix) { item in - NeighborRow( - neighbor: item.neighbor, - displayName: item.displayName, - matchKind: item.matchKind - ) - } - .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.neighborsNotShown(unplottable.count)) - .navigationBarTitleDisplayMode(.inline) + let unplottable: [NeighborSNRMapBuilder.UnplottableNeighbor] + + var body: some View { + List(unplottable, id: \.neighbor.publicKeyPrefix) { item in + NeighborRow( + neighbor: item.neighbor, + displayName: item.displayName, + matchKind: item.matchKind + ) } + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.neighborsNotShown(unplottable.count)) + .navigationBarTitleDisplayMode(.inline) + } } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift index 48f83352..62337dce 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift @@ -1,470 +1,470 @@ -import SwiftUI -import MC1Services import CoreLocation +import MC1Services +import SwiftUI struct RepeaterSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @FocusState private var focusedField: NodeSettingsField? - - let session: RemoteNodeSessionDTO - @State private var viewModel = RepeaterSettingsViewModel() - @State private var statusViewModel = RepeaterStatusViewModel() - @State private var managementTab: NodeManagementTab = .settings - @State private var cliViewModel = NodeCLIViewModel() - @State private var showRebootConfirmation = false - @State private var showingLocationPicker = false - @State private var telemetryConfigured = false - @State private var contacts: [ContactDTO] = [] - @State private var discoveredNodes: [DiscoveredNodeDTO] = [] - /// The node's contact, kept live so the route section reflects the path the firmware learns after - /// a flood login (delivered asynchronously as a contact update). - @State private var routeContact: ContactDTO? - - var body: some View { - // ZStack, not Group: a stable container keeps the navigation title hosted on one - // view across segment switches. Group would re-host it on each branch, animating - // a nav-bar item transition. - ZStack { - switch managementTab { - case .settings: settingsForm - case .cli: NodeCLIView(viewModel: cliViewModel) - case .telemetry: - RepeaterStatusContent( - viewModel: statusViewModel, - session: session, - connectionState: appState.connectionState, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: appState.bestAvailableLocation, - connectedDeviceID: appState.connectedDevice?.radioID, - routePathContact: routeContact - ) - } - } - .animation(nil, value: managementTab) - .navigationTitle(L10n.RemoteNodes.RemoteNodes.Settings.title) - .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .top, spacing: 0) { - if session.isAdmin { - NodeManagementTabPicker(selection: $managementTab) - .frame(maxWidth: .infinity) - .pinnedFilterHeaderBackground(theme) - } - } - .task { - await viewModel.configure( - repeaterAdminService: { appState.services?.repeaterAdminService }, - session: session - ) - if let send = viewModel.makeNodeCLISendClosure(session: session) { - cliViewModel.configure(sessionName: session.name, sendRawCommand: send) - } - // Loaded up front (not just on Telemetry reveal) so the Settings-tab route section can - // resolve hop hashes to repeater names. - if let radioID = appState.connectedDevice?.radioID, - let dataStore = appState.services?.dataStore { - contacts = (try? await dataStore.fetchContacts(radioID: radioID)) ?? [] - discoveredNodes = (try? await dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] - } - await refreshRouteContact() - } - .onChange(of: appState.contactsVersion) { - Task { await refreshRouteContact() } - } - .onChange(of: managementTab) { _, newTab in - guard newTab == .telemetry, !telemetryConfigured else { return } - telemetryConfigured = true - // Configure the status VM on first Telemetry reveal rather than on open: - // its handlers populate only the status/telemetry/neighbours slots, leaving the - // settings VM's CLI handler intact for the Settings/CLI surface. Guarded by - // telemetryConfigured because a segment switch recreates only the content subtree, - // so this must not re-run or duplicate handler registration. - statusViewModel.configure( - repeaterAdminService: { appState.services?.repeaterAdminService }, - contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } - ) - Task { - await statusViewModel.registerHandlers() - if let radioID = appState.connectedDevice?.radioID { - await statusViewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) - } - } - } - .onDisappear { - statusViewModel.stopDiscovery() - Task { - await statusViewModel.clearStatusHandlers() - await viewModel.cleanup() - } - } - .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) { } - } message: { - Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) - } - .sheet(isPresented: $showingLocationPicker) { - LocationPickerView( - initialCoordinate: CLLocationCoordinate2D( - latitude: viewModel.helper.latitude ?? 0, - longitude: viewModel.helper.longitude ?? 0 - ) - ) { coordinate in - viewModel.helper.setLocationFromPicker( - latitude: coordinate.latitude, - longitude: coordinate.longitude - ) - } - } - } - - private var settingsForm: some View { - Form { - NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) - makeRadioSettingsSection() - makeBehaviorSection() - makeRegionsSection() - makeIdentitySection() - makeContactInfoSection() - makeSecuritySection() - makeDeviceInfoSection() - makeActionsSection() - if let routeContact { - NodeRoutePathSection( - contact: routeContact, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: appState.bestAvailableLocation - ) - } - } - .themedCanvas(theme) - .nodeManagementHeaderTopMargin() - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { - focusedField = nil - } - } - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @FocusState private var focusedField: NodeSettingsField? + + let session: RemoteNodeSessionDTO + @State private var viewModel = RepeaterSettingsViewModel() + @State private var statusViewModel = RepeaterStatusViewModel() + @State private var managementTab: NodeManagementTab = .settings + @State private var cliViewModel = NodeCLIViewModel() + @State private var showRebootConfirmation = false + @State private var showingLocationPicker = false + @State private var telemetryConfigured = false + @State private var contacts: [ContactDTO] = [] + @State private var discoveredNodes: [DiscoveredNodeDTO] = [] + /// The node's contact, kept live so the route section reflects the path the firmware learns after + /// a flood login (delivered asynchronously as a contact update). + @State private var routeContact: ContactDTO? + + var body: some View { + // ZStack, not Group: a stable container keeps the navigation title hosted on one + // view across segment switches. Group would re-host it on each branch, animating + // a nav-bar item transition. + ZStack { + switch managementTab { + case .settings: settingsForm + case .cli: NodeCLIView(viewModel: cliViewModel) + case .telemetry: + RepeaterStatusContent( + viewModel: statusViewModel, + session: session, + connectionState: appState.connectionState, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: appState.bestAvailableLocation, + connectedDeviceID: appState.connectedDevice?.radioID, + routePathContact: routeContact + ) + } } - - // MARK: - Subviews - - private func makeDeviceInfoSection() -> some View { - NodeDeviceInfoSection(settings: viewModel.helper) + .animation(nil, value: managementTab) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Settings.title) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .top, spacing: 0) { + if session.isAdmin { + NodeManagementTabPicker(selection: $managementTab) + .frame(maxWidth: .infinity) + .pinnedFilterHeaderBackground(theme) + } } - - private func makeRadioSettingsSection() -> some View { - NodeRadioSettingsSection( - settings: viewModel.helper, - focusedField: $focusedField - ) + .task { + await viewModel.configure( + repeaterAdminService: { appState.services?.repeaterAdminService }, + session: session + ) + if let send = viewModel.makeNodeCLISendClosure(session: session) { + cliViewModel.configure(sessionName: session.name, sendRawCommand: send) + } + // Loaded up front (not just on Telemetry reveal) so the Settings-tab route section can + // resolve hop hashes to repeater names. + if let radioID = appState.connectedDevice?.radioID, + let dataStore = appState.services?.dataStore { + contacts = await (try? dataStore.fetchContacts(radioID: radioID)) ?? [] + discoveredNodes = await (try? dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] + } + await refreshRouteContact() } - - private func makeIdentitySection() -> some View { - RemoteNodeIdentitySection( - settings: viewModel.helper, - focusedField: $focusedField, - onPickLocation: { showingLocationPicker = true } - ) + .onChange(of: appState.contactsVersion) { + Task { await refreshRouteContact() } } - - private func makeContactInfoSection() -> some View { - NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) + .onChange(of: managementTab) { _, newTab in + guard newTab == .telemetry, !telemetryConfigured else { return } + telemetryConfigured = true + // Configure the status VM on first Telemetry reveal rather than on open: + // its handlers populate only the status/telemetry/neighbours slots, leaving the + // settings VM's CLI handler intact for the Settings/CLI surface. Guarded by + // telemetryConfigured because a segment switch recreates only the content subtree, + // so this must not re-run or duplicate handler registration. + statusViewModel.configure( + repeaterAdminService: { appState.services?.repeaterAdminService }, + contactService: { appState.services?.contactService }, + nodeSnapshotService: { appState.services?.nodeSnapshotService } + ) + Task { + await statusViewModel.registerHandlers() + if let radioID = appState.connectedDevice?.radioID { + await statusViewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) + } + } } - - private func makeBehaviorSection() -> some View { - BehaviorSection(viewModel: viewModel, focusedField: $focusedField) + .onDisappear { + statusViewModel.stopDiscovery() + Task { + await statusViewModel.clearStatusHandlers() + await viewModel.cleanup() + } } - - private func makeRegionsSection() -> some View { - RegionsSection(viewModel: viewModel) + .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) {} + } message: { + Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) } - - private func makeSecuritySection() -> some View { - NodeSecuritySection(settings: viewModel.helper) + .sheet(isPresented: $showingLocationPicker) { + LocationPickerView( + initialCoordinate: CLLocationCoordinate2D( + latitude: viewModel.helper.latitude ?? 0, + longitude: viewModel.helper.longitude ?? 0 + ) + ) { coordinate in + viewModel.helper.setLocationFromPicker( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + } } - - private func makeActionsSection() -> some View { - NodeActionsSection( - settings: viewModel.helper, - showRebootConfirmation: $showRebootConfirmation + } + + private var settingsForm: some View { + Form { + NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) + makeRadioSettingsSection() + makeBehaviorSection() + makeRegionsSection() + makeIdentitySection() + makeContactInfoSection() + makeSecuritySection() + makeDeviceInfoSection() + makeActionsSection() + if let routeContact { + NodeRoutePathSection( + contact: routeContact, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: appState.bestAvailableLocation ) + } } - - private func refreshRouteContact() async { - guard let dataStore = appState.services?.dataStore else { return } - if let updated = (try? await dataStore.fetchContact( - radioID: session.radioID, - publicKey: session.publicKey - )).flatMap({ $0 }) { - routeContact = updated + .themedCanvas(theme) + .nodeManagementHeaderTopMargin() + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { + focusedField = nil } + } } + } + + // MARK: - Subviews + + private func makeDeviceInfoSection() -> some View { + NodeDeviceInfoSection(settings: viewModel.helper) + } + + private func makeRadioSettingsSection() -> some View { + NodeRadioSettingsSection( + settings: viewModel.helper, + focusedField: $focusedField + ) + } + + private func makeIdentitySection() -> some View { + RemoteNodeIdentitySection( + settings: viewModel.helper, + focusedField: $focusedField, + onPickLocation: { showingLocationPicker = true } + ) + } + + private func makeContactInfoSection() -> some View { + NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) + } + + private func makeBehaviorSection() -> some View { + BehaviorSection(viewModel: viewModel, focusedField: $focusedField) + } + + private func makeRegionsSection() -> some View { + RegionsSection(viewModel: viewModel) + } + + private func makeSecuritySection() -> some View { + NodeSecuritySection(settings: viewModel.helper) + } + + private func makeActionsSection() -> some View { + NodeActionsSection( + settings: viewModel.helper, + showRebootConfirmation: $showRebootConfirmation + ) + } + + private func refreshRouteContact() async { + guard let dataStore = appState.services?.dataStore else { return } + if let updated = await (try? dataStore.fetchContact( + radioID: session.radioID, + publicKey: session.publicKey + )).flatMap(\.self) { + routeContact = updated + } + } } // MARK: - Behavior Section private struct BehaviorSection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - var focusedField: FocusState.Binding - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.behavior, - icon: "slider.horizontal.3", - isExpanded: $viewModel.isBehaviorExpanded, - isLoaded: { viewModel.behaviorLoaded }, - isLoading: $viewModel.isLoadingBehavior, - hasError: $viewModel.behaviorError, - onLoad: { await viewModel.fetchBehaviorSettings() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.behaviorFooter - ) { - Toggle(L10n.RemoteNodes.RemoteNodes.Settings.repeaterMode, isOn: Binding( - get: { viewModel.repeaterEnabled ?? false }, - set: { viewModel.repeaterEnabled = $0 } - )) - .disabled(viewModel.repeaterEnabled == nil) - .accessibilityValue( - viewModel.repeaterEnabled == nil - ? (viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad) - : (viewModel.repeaterEnabled == true ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) - ) - .overlay(alignment: .trailing) { - if viewModel.repeaterEnabled == nil { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - .padding(.trailing, 60) - .accessibilityHidden(true) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.advertInterval0Hop) - Spacer() - if let interval = viewModel.advertIntervalMinutes { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.min, value: Binding( - get: { interval }, - set: { viewModel.advertIntervalMinutes = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .advertInterval) - Text(L10n.RemoteNodes.RemoteNodes.Settings.min) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } - - if let error = viewModel.advertIntervalError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalFlood) - Spacer() - if let interval = viewModel.floodAdvertIntervalHours { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.hrs, value: Binding( - get: { interval }, - set: { viewModel.floodAdvertIntervalHours = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .floodAdvertInterval) - Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } - - if let error = viewModel.floodAdvertIntervalError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.maxFloodHops) - Spacer() - if let hops = viewModel.floodMaxHops { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.hops, value: Binding( - get: { hops }, - set: { viewModel.floodMaxHops = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .floodMaxHops) - Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } - - if let error = viewModel.floodMaxHopsError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - - Button { - Task { await viewModel.applyBehaviorSettings() } - } label: { - AsyncActionLabel(isLoading: viewModel.helper.isApplying, showSuccess: viewModel.behaviorApplySuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyBehaviorSettings) - .foregroundStyle(viewModel.behaviorSettingsModified ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .disabled(viewModel.helper.isApplying || viewModel.behaviorApplySuccess || !viewModel.behaviorSettingsModified) + @Bindable var viewModel: RepeaterSettingsViewModel + var focusedField: FocusState.Binding + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.behavior, + icon: "slider.horizontal.3", + isExpanded: $viewModel.isBehaviorExpanded, + isLoaded: { viewModel.behaviorLoaded }, + isLoading: $viewModel.isLoadingBehavior, + hasError: $viewModel.behaviorError, + onLoad: { await viewModel.fetchBehaviorSettings() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.behaviorFooter + ) { + Toggle(L10n.RemoteNodes.RemoteNodes.Settings.repeaterMode, isOn: Binding( + get: { viewModel.repeaterEnabled ?? false }, + set: { viewModel.repeaterEnabled = $0 } + )) + .disabled(viewModel.repeaterEnabled == nil) + .accessibilityValue( + viewModel.repeaterEnabled == nil + ? (viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad) + : (viewModel.repeaterEnabled == true ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) + ) + .overlay(alignment: .trailing) { + if viewModel.repeaterEnabled == nil { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + .padding(.trailing, 60) + .accessibilityHidden(true) + } + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertInterval0Hop) + Spacer() + if let interval = viewModel.advertIntervalMinutes { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.min, value: Binding( + get: { interval }, + set: { viewModel.advertIntervalMinutes = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .advertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.min) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) } + } + + if let error = viewModel.advertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalFlood) + Spacer() + if let interval = viewModel.floodAdvertIntervalHours { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hrs, value: Binding( + get: { interval }, + set: { viewModel.floodAdvertIntervalHours = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodAdvertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + } + } + + if let error = viewModel.floodAdvertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.maxFloodHops) + Spacer() + if let hops = viewModel.floodMaxHops { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hops, value: Binding( + get: { hops }, + set: { viewModel.floodMaxHops = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodMaxHops) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + } + } + + if let error = viewModel.floodMaxHopsError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Button { + Task { await viewModel.applyBehaviorSettings() } + } label: { + AsyncActionLabel(isLoading: viewModel.helper.isApplying, showSuccess: viewModel.behaviorApplySuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyBehaviorSettings) + .foregroundStyle(viewModel.behaviorSettingsModified ? Color.accentColor : .secondary) + .transition(.opacity) + } + } + .disabled(viewModel.helper.isApplying || viewModel.behaviorApplySuccess || !viewModel.behaviorSettingsModified) } + } } // MARK: - Regions Section private struct RegionsSection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - - /// Regions sorted: wildcard first, then alphabetical - private var sortedRegions: [RepeaterRegionEntry] { - viewModel.regions.sorted { lhs, rhs in - if lhs.isWildcard { return true } - if rhs.isWildcard { return false } - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - } - - /// Display name for a region entry - private func displayName(for region: RepeaterRegionEntry) -> String { - region.isWildcard - ? L10n.RemoteNodes.RemoteNodes.Settings.Regions.allTrafficWildcard - : region.name + @Bindable var viewModel: RepeaterSettingsViewModel + + /// Regions sorted: wildcard first, then alphabetical + private var sortedRegions: [RepeaterRegionEntry] { + viewModel.regions.sorted { lhs, rhs in + if lhs.isWildcard { return true } + if rhs.isWildcard { return false } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending } - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.regions, - icon: "globe", - isExpanded: $viewModel.isRegionsExpanded, - isLoaded: { viewModel.regionsLoaded }, - isLoading: $viewModel.isLoadingRegions, - hasError: $viewModel.regionsError, - onLoad: { await viewModel.fetchRegions() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.regionsFooter - ) { - if viewModel.regionsLoaded && viewModel.regions.isEmpty { - Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.empty) - .foregroundStyle(.secondary) - } - - // Home region picker - if !viewModel.regions.isEmpty { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.Regions.homeRegion, selection: Binding( - get: { - viewModel.regions.first(where: \.isHome)?.name - ?? RepeaterSettingsViewModel.wildcardName - }, - set: { newValue in - Task { await viewModel.setHomeRegion(name: newValue) } - } - )) { - ForEach(sortedRegions) { region in - Text(displayName(for: region)) - .tag(region.name) - } - } - .pickerStyle(.menu) - .tint(.primary) - } - - // Region list with flood toggles - ForEach(sortedRegions) { region in - Toggle( - displayName(for: region), - isOn: Binding( - get: { region.floodAllowed }, - set: { _ in - Task { await viewModel.toggleRegionFlood(name: region.name) } - } - ) - ) - .accessibilityLabel( - region.isWildcard - ? L10n.RemoteNodes.RemoteNodes.Settings.Regions.allTraffic - : region.name - ) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.Regions.floodToggleHint) - .disabled(viewModel.helper.isApplying) - } - .onDelete { offsets in - let sorted = sortedRegions - for offset in offsets { - let region = sorted[offset] - guard !region.isWildcard else { continue } - Task { await viewModel.removeRegion(name: region.name) } - } - } - - // Add region button - Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion, systemImage: "plus") { - viewModel.isAddingRegion = true - } - .disabled(viewModel.helper.isApplying) - - // Save to device button - if viewModel.regionsLoaded { - Button { - Task { await viewModel.saveRegions() } - } label: { - AsyncActionLabel(isLoading: viewModel.helper.isApplying, showSuccess: viewModel.regionsSaveSuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveToDevice) - .foregroundStyle(viewModel.hasUnsavedRegionChanges ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .disabled(viewModel.helper.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) - } + } + + /// Display name for a region entry + private func displayName(for region: RepeaterRegionEntry) -> String { + region.isWildcard + ? L10n.RemoteNodes.RemoteNodes.Settings.Regions.allTrafficWildcard + : region.name + } + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.regions, + icon: "globe", + isExpanded: $viewModel.isRegionsExpanded, + isLoaded: { viewModel.regionsLoaded }, + isLoading: $viewModel.isLoadingRegions, + hasError: $viewModel.regionsError, + onLoad: { await viewModel.fetchRegions() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.regionsFooter + ) { + if viewModel.regionsLoaded, viewModel.regions.isEmpty { + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.empty) + .foregroundStyle(.secondary) + } + + // Home region picker + if !viewModel.regions.isEmpty { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.Regions.homeRegion, selection: Binding( + get: { + viewModel.regions.first(where: \.isHome)?.name + ?? RepeaterSettingsViewModel.wildcardName + }, + set: { newValue in + Task { await viewModel.setHomeRegion(name: newValue) } + } + )) { + ForEach(sortedRegions) { region in + Text(displayName(for: region)) + .tag(region.name) + } } - .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $viewModel.isAddingRegion) { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.Regions.regionName, text: $viewModel.newRegionName) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion) { - Task { await viewModel.addRegion(name: viewModel.newRegionName) } - } - Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { - viewModel.newRegionName = "" + .pickerStyle(.menu) + .tint(.primary) + } + + // Region list with flood toggles + ForEach(sortedRegions) { region in + Toggle( + displayName(for: region), + isOn: Binding( + get: { region.floodAllowed }, + set: { _ in + Task { await viewModel.toggleRegionFlood(name: region.name) } } + ) + ) + .accessibilityLabel( + region.isWildcard + ? L10n.RemoteNodes.RemoteNodes.Settings.Regions.allTraffic + : region.name + ) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.Regions.floodToggleHint) + .disabled(viewModel.helper.isApplying) + } + .onDelete { offsets in + let sorted = sortedRegions + for offset in offsets { + let region = sorted[offset] + guard !region.isWildcard else { continue } + Task { await viewModel.removeRegion(name: region.name) } + } + } + + // Add region button + Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion, systemImage: "plus") { + viewModel.isAddingRegion = true + } + .disabled(viewModel.helper.isApplying) + + // Save to device button + if viewModel.regionsLoaded { + Button { + Task { await viewModel.saveRegions() } + } label: { + AsyncActionLabel(isLoading: viewModel.helper.isApplying, showSuccess: viewModel.regionsSaveSuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveToDevice) + .foregroundStyle(viewModel.hasUnsavedRegionChanges ? Color.accentColor : .secondary) + .transition(.opacity) + } } + .disabled(viewModel.helper.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) + } + } + .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $viewModel.isAddingRegion) { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.Regions.regionName, text: $viewModel.newRegionName) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion) { + Task { await viewModel.addRegion(name: viewModel.newRegionName) } + } + Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { + viewModel.newRegionName = "" + } } + } } #Preview { - NavigationStack { - RepeaterSettingsView( - session: RemoteNodeSessionDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Mountain Peak Repeater", - role: .repeater, - latitude: 37.7749, - longitude: -122.4194, - isConnected: true, - permissionLevel: .admin - ) - ) - .environment(\.appState, AppState()) - } + NavigationStack { + RepeaterSettingsView( + session: RemoteNodeSessionDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Mountain Peak Repeater", + role: .repeater, + latitude: 37.7749, + longitude: -122.4194, + isConnected: true, + permissionLevel: .admin + ) + ) + .environment(\.appState, AppState()) + } } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift index 0be87380..5ca556a1 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift @@ -1,513 +1,526 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI @Observable @MainActor final class RepeaterSettingsViewModel { - - // MARK: - Shared Helper - - var helper = NodeSettingsViewModel() - - // MARK: - Repeater-Only: Behavior Settings - - var advertIntervalMinutes: Int? - var floodAdvertIntervalHours: Int? - var floodMaxHops: Int? - var repeaterEnabled: Bool? - private var originalAdvertIntervalMinutes: Int? - private var originalFloodAdvertIntervalHours: Int? - private var originalFloodMaxHops: Int? - private var originalRepeaterEnabled: Bool? - var isLoadingBehavior = false - var behaviorError = false - var behaviorLoaded: Bool { repeaterEnabled != nil || advertIntervalMinutes != nil } - - var advertIntervalError: String? - var floodAdvertIntervalError: String? - var floodMaxHopsError: String? - - var behaviorApplySuccess = false - - var behaviorSettingsModified: Bool { - (repeaterEnabled != nil && repeaterEnabled != originalRepeaterEnabled) || - (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || - (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || - (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + // MARK: - Shared Helper + + var helper = NodeSettingsViewModel() + + // MARK: - Repeater-Only: Behavior Settings + + var advertIntervalMinutes: Int? + var floodAdvertIntervalHours: Int? + var floodMaxHops: Int? + var repeaterEnabled: Bool? + private var originalAdvertIntervalMinutes: Int? + private var originalFloodAdvertIntervalHours: Int? + private var originalFloodMaxHops: Int? + private var originalRepeaterEnabled: Bool? + var isLoadingBehavior = false + var behaviorError = false + var behaviorLoaded: Bool { + repeaterEnabled != nil || advertIntervalMinutes != nil + } + + var advertIntervalError: String? + var floodAdvertIntervalError: String? + var floodMaxHopsError: String? + + var behaviorApplySuccess = false + + var behaviorSettingsModified: Bool { + (repeaterEnabled != nil && repeaterEnabled != originalRepeaterEnabled) || + (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || + (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || + (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + } + + // MARK: - Repeater-Only: Region Settings + + nonisolated static let wildcardName = "*" + var regions: [RepeaterRegionEntry] = [] + private var originalRegions: [RepeaterRegionEntry]? + var isLoadingRegions = false + var regionsError = false + var regionsLoaded: Bool { + originalRegions != nil + } + + var hasUnsavedRegionChanges = false + var isAddingRegion = false + var newRegionName = "" + var regionsSaveSuccess = false + + // MARK: - Expansion State (repeater-only sections) + + var isBehaviorExpanded = false + var isRegionsExpanded = false + + // MARK: - Dependencies + + private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } + var repeaterAdminService: RepeaterAdminService? { + repeaterAdminServiceProvider() + } + + private let logger = Logger(subsystem: "com.mc1", category: "RepeaterSettings") + + // MARK: - Cleanup + + func cleanup() async { + await repeaterAdminService?.setCLIHandler { _, _ in } + helper.cleanup() + } + + // MARK: - Configuration + + /// Nil service mirrors a disconnected state; commands then no-op. + func configure(repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, session: RemoteNodeSessionDTO) async { + repeaterAdminServiceProvider = repeaterAdminService + + guard let repeaterAdminService = repeaterAdminService() else { return } + + helper.configure( + session: session, + sendCommand: { [repeaterAdminService] id, cmd, timeout in + try await repeaterAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) + }, + sendRawCommand: { [repeaterAdminService] id, cmd, timeout in + try await repeaterAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) + } + ) + + helper.name = session.name + + helper.onPreFetchNodeInfo = { [weak self] in + await self?.fetchNodeInfo() } - // MARK: - Repeater-Only: Region Settings - - nonisolated static let wildcardName = "*" - var regions: [RepeaterRegionEntry] = [] - private var originalRegions: [RepeaterRegionEntry]? - var isLoadingRegions = false - var regionsError = false - var regionsLoaded: Bool { originalRegions != nil } - var hasUnsavedRegionChanges = false - var isAddingRegion = false - var newRegionName = "" - var regionsSaveSuccess = false - - // MARK: - Expansion State (repeater-only sections) - - var isBehaviorExpanded = false - var isRegionsExpanded = false - - // MARK: - Dependencies - - private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } - var repeaterAdminService: RepeaterAdminService? { repeaterAdminServiceProvider() } - private let logger = Logger(subsystem: "com.mc1", category: "RepeaterSettings") - - // MARK: - Cleanup - - func cleanup() async { - await repeaterAdminService?.setCLIHandler { _, _ in } - helper.cleanup() + // Register CLI handler for late responses + await repeaterAdminService.setCLIHandler { [weak self] message, _ in + await MainActor.run { + self?.handleLateResponse(message.text) + } } - // MARK: - Configuration - - /// Nil service mirrors a disconnected state; commands then no-op. - func configure(repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, session: RemoteNodeSessionDTO) async { - self.repeaterAdminServiceProvider = repeaterAdminService - - guard let repeaterAdminService = repeaterAdminService() else { return } - - helper.configure( - session: session, - sendCommand: { [repeaterAdminService] id, cmd, timeout in - try await repeaterAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) - }, - sendRawCommand: { [repeaterAdminService] id, cmd, timeout in - try await repeaterAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) - } - ) - - helper.name = session.name - - helper.onPreFetchNodeInfo = { [weak self] in - await self?.fetchNodeInfo() + // Detached so configure returns immediately and the node CLI send + // closure wires without waiting on the owner-info round-trip (matches + // RoomSettingsViewModel's detached device-info fetch). + Task { await fetchNodeInfo() } + } + + /// Builds the node-CLI send closure, pre-binding this session's id and + /// capturing the private admin service (a thin pass-through to + /// `RemoteNodeService.sendRawCLICommand`). Returns nil if not configured. + func makeNodeCLISendClosure( + session: RemoteNodeSessionDTO + ) -> (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? { + guard let repeaterAdminService else { return nil } + return { [repeaterAdminService, sessionID = session.id] command, timeout in + try await repeaterAdminService.sendRawCommand( + sessionID: sessionID, command: command, timeout: timeout + ) + } + } + + private var isLoadingNodeInfo = false + + private func fetchNodeInfo() async { + guard !isLoadingNodeInfo, let session = helper.session, let repeaterAdminService else { return } + isLoadingNodeInfo = true + defer { isLoadingNodeInfo = false } + do { + let response = try await repeaterAdminService.requestOwnerInfo(sessionID: session.id) + helper.setNodeInfo( + firmwareVersion: response.firmwareVersion, + name: response.nodeName, + ownerInfo: response.ownerInfo + ) + } catch { + logger.warning("Failed to fetch node info via binary: \(error)") + } + } + + // MARK: - Late Response Handling + + private func handleLateResponse(_ response: String) { + // Try shared sections first + if helper.handleCommonLateResponse(response) { return } + + // Behavior settings + if !isLoadingBehavior, behaviorError { + if originalRepeaterEnabled == nil { + if case let .repeatMode(enabled) = CLIResponse.parse(response, forQuery: "get repeat") { + repeaterEnabled = enabled + originalRepeaterEnabled = enabled + behaviorError = false + logger.info("Late response: received repeat mode") + return } - - // Register CLI handler for late responses - await repeaterAdminService.setCLIHandler { [weak self] message, _ in - await MainActor.run { - self?.handleLateResponse(message.text) - } + } + + if let result = NodeSettingsResponseParser.behaviorLateResponse( + response, + hasAdvertInterval: originalAdvertIntervalMinutes != nil, + hasFloodInterval: originalFloodAdvertIntervalHours != nil, + hasFloodMaxHops: originalFloodMaxHops != nil + ) { + switch result { + case let .advertInterval(interval): + advertIntervalMinutes = interval + originalAdvertIntervalMinutes = interval + case let .floodAdvertInterval(interval): + floodAdvertIntervalHours = interval + originalFloodAdvertIntervalHours = interval + case let .floodMax(hops): + floodMaxHops = hops + originalFloodMaxHops = hops } - - // Detached so configure returns immediately and the node CLI send - // closure wires without waiting on the owner-info round-trip (matches - // RoomSettingsViewModel's detached device-info fetch). - Task { await fetchNodeInfo() } + behaviorError = false + return + } } - /// Builds the node-CLI send closure, pre-binding this session's id and - /// capturing the private admin service (a thin pass-through to - /// `RemoteNodeService.sendRawCLICommand`). Returns nil if not configured. - func makeNodeCLISendClosure( - session: RemoteNodeSessionDTO - ) -> (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? { - guard let repeaterAdminService else { return nil } - return { [repeaterAdminService, sessionID = session.id] command, timeout in - try await repeaterAdminService.sendRawCommand( - sessionID: sessionID, command: command, timeout: timeout) + // Regions + if !isLoadingRegions, regionsError { + if originalRegions == nil { + let parsed = Self.parseRegionTree(response) + if !parsed.isEmpty { + regions = parsed + originalRegions = parsed + regionsError = false + logger.info("Late response: received region tree (\(parsed.count) regions)") + return } + } } - - private var isLoadingNodeInfo = false - - private func fetchNodeInfo() async { - guard !isLoadingNodeInfo, let session = helper.session, let repeaterAdminService else { return } - isLoadingNodeInfo = true - defer { isLoadingNodeInfo = false } - do { - let response = try await repeaterAdminService.requestOwnerInfo(sessionID: session.id) - helper.setNodeInfo( - firmwareVersion: response.firmwareVersion, - name: response.nodeName, - ownerInfo: response.ownerInfo - ) - } catch { - logger.warning("Failed to fetch node info via binary: \(error)") - } + } + + // MARK: - Behavior Fetch/Apply + + func fetchBehaviorSettings() async { + isLoadingBehavior = true + behaviorError = false + var hadTimeout = false + + do { + let response = try await helper.sendAndWait("get repeat") + if case let .repeatMode(enabled) = CLIResponse.parse(response, forQuery: "get repeat") { + repeaterEnabled = enabled + originalRepeaterEnabled = enabled + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get repeat mode: \(error)") } - // MARK: - Late Response Handling - - private func handleLateResponse(_ response: String) { - // Try shared sections first - if helper.handleCommonLateResponse(response) { return } - - // Behavior settings - if !isLoadingBehavior && behaviorError { - if originalRepeaterEnabled == nil { - if case .repeatMode(let enabled) = CLIResponse.parse(response, forQuery: "get repeat") { - self.repeaterEnabled = enabled - self.originalRepeaterEnabled = enabled - self.behaviorError = false - logger.info("Late response: received repeat mode") - return - } - } - - if let result = NodeSettingsResponseParser.behaviorLateResponse( - response, - hasAdvertInterval: originalAdvertIntervalMinutes != nil, - hasFloodInterval: originalFloodAdvertIntervalHours != nil, - hasFloodMaxHops: originalFloodMaxHops != nil - ) { - switch result { - case .advertInterval(let interval): - self.advertIntervalMinutes = interval - self.originalAdvertIntervalMinutes = interval - case .floodAdvertInterval(let interval): - self.floodAdvertIntervalHours = interval - self.originalFloodAdvertIntervalHours = interval - case .floodMax(let hops): - self.floodMaxHops = hops - self.originalFloodMaxHops = hops - } - self.behaviorError = false - return - } - } + do { + let response = try await helper.sendAndWait("get advert.interval") + if case let .advertInterval(minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { + advertIntervalMinutes = minutes + originalAdvertIntervalMinutes = minutes + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get advert interval: \(error)") + } - // Regions - if !isLoadingRegions && regionsError { - if originalRegions == nil { - let parsed = Self.parseRegionTree(response) - if !parsed.isEmpty { - self.regions = parsed - self.originalRegions = parsed - self.regionsError = false - logger.info("Late response: received region tree (\(parsed.count) regions)") - return - } - } - } + do { + let response = try await helper.sendAndWait("get flood.advert.interval") + if case let .floodAdvertInterval(hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { + floodAdvertIntervalHours = hours + originalFloodAdvertIntervalHours = hours + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood advert interval: \(error)") } - // MARK: - Behavior Fetch/Apply + do { + let response = try await helper.sendAndWait("get flood.max") + if case let .floodMax(hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + floodMaxHops = hops + originalFloodMaxHops = hops + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood max: \(error)") + } - func fetchBehaviorSettings() async { - isLoadingBehavior = true - behaviorError = false - var hadTimeout = false - - do { - let response = try await helper.sendAndWait("get repeat") - if case .repeatMode(let enabled) = CLIResponse.parse(response, forQuery: "get repeat") { - self.repeaterEnabled = enabled - self.originalRepeaterEnabled = enabled - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get repeat mode: \(error)") - } + if hadTimeout { + behaviorError = true + } - do { - let response = try await helper.sendAndWait("get advert.interval") - if case .advertInterval(let minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { - self.advertIntervalMinutes = minutes - self.originalAdvertIntervalMinutes = minutes - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get advert interval: \(error)") + isLoadingBehavior = false + } + + func applyBehaviorSettings() async { + let validation = NodeSettingsViewModel.validateBehaviorFields( + advertInterval: advertIntervalMinutes, + floodInterval: floodAdvertIntervalHours, + floodMaxHops: floodMaxHops + ) + advertIntervalError = validation.advertInterval + floodAdvertIntervalError = validation.floodInterval + floodMaxHopsError = validation.floodMaxHops + + if validation.hasErrors { return } + + helper.isApplying = true + helper.errorMessage = nil + + do { + var allSucceeded = true + + if let repeaterEnabled, repeaterEnabled != originalRepeaterEnabled { + let response = try await helper.sendAndWait("set repeat \(repeaterEnabled ? "on" : "off")") + if case .ok = CLIResponse.parse(response) { + originalRepeaterEnabled = repeaterEnabled + } else { + allSucceeded = false } - - do { - let response = try await helper.sendAndWait("get flood.advert.interval") - if case .floodAdvertInterval(let hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { - self.floodAdvertIntervalHours = hours - self.originalFloodAdvertIntervalHours = hours - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get flood advert interval: \(error)") + } + + if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { + let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") + if case .ok = CLIResponse.parse(response) { + originalAdvertIntervalMinutes = advertIntervalMinutes + } else { + allSucceeded = false } - - do { - let response = try await helper.sendAndWait("get flood.max") - if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { - self.floodMaxHops = hops - self.originalFloodMaxHops = hops - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get flood max: \(error)") + } + + if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { + let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") + if case .ok = CLIResponse.parse(response) { + originalFloodAdvertIntervalHours = floodAdvertIntervalHours + } else { + allSucceeded = false } - - if hadTimeout { - behaviorError = true + } + + if let floodMaxHops, floodMaxHops != originalFloodMaxHops { + let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") + if case .ok = CLIResponse.parse(response) { + originalFloodMaxHops = floodMaxHops + } else { + allSucceeded = false } + } - isLoadingBehavior = false - } - - func applyBehaviorSettings() async { - let validation = NodeSettingsViewModel.validateBehaviorFields( - advertInterval: advertIntervalMinutes, - floodInterval: floodAdvertIntervalHours, - floodMaxHops: floodMaxHops + if allSucceeded { + await helper.flashSuccess( + setApplying: { helper.isApplying = $0 }, + setSuccess: { behaviorApplySuccess = $0 } ) - advertIntervalError = validation.advertInterval - floodAdvertIntervalError = validation.floodInterval - floodMaxHopsError = validation.floodMaxHops - - if validation.hasErrors { return } - - helper.isApplying = true - helper.errorMessage = nil - - do { - var allSucceeded = true - - if let repeaterEnabled, repeaterEnabled != originalRepeaterEnabled { - let response = try await helper.sendAndWait("set repeat \(repeaterEnabled ? "on" : "off")") - if case .ok = CLIResponse.parse(response) { - originalRepeaterEnabled = repeaterEnabled - } else { - allSucceeded = false - } - } - - if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { - let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") - if case .ok = CLIResponse.parse(response) { - originalAdvertIntervalMinutes = advertIntervalMinutes - } else { - allSucceeded = false - } - } - - if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { - let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") - if case .ok = CLIResponse.parse(response) { - originalFloodAdvertIntervalHours = floodAdvertIntervalHours - } else { - allSucceeded = false - } - } - - if let floodMaxHops, floodMaxHops != originalFloodMaxHops { - let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") - if case .ok = CLIResponse.parse(response) { - originalFloodMaxHops = floodMaxHops - } else { - allSucceeded = false - } - } - - if allSucceeded { - await helper.flashSuccess( - setApplying: { helper.isApplying = $0 }, - setSuccess: { behaviorApplySuccess = $0 } - ) - return - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - helper.errorMessage = error.userFacingMessage - } - - helper.isApplying = false + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + helper.errorMessage = error.userFacingMessage } - // MARK: - Region Methods - - func fetchRegions() async { - isLoadingRegions = true - regionsError = false - - do { - let treeResponse = try await helper.sendAndWait("region", timeout: .seconds(10), rawMatching: true) - let parsed = Self.parseRegionTree(treeResponse) - self.regions = parsed - self.originalRegions = parsed - } catch { - if case RemoteNodeError.timeout = error { - regionsError = true - } - logger.warning("Failed to fetch regions: \(error)") - } - - isLoadingRegions = false + helper.isApplying = false + } + + // MARK: - Region Methods + + func fetchRegions() async { + isLoadingRegions = true + regionsError = false + + do { + let treeResponse = try await helper.sendAndWait("region", timeout: .seconds(10), rawMatching: true) + let parsed = Self.parseRegionTree(treeResponse) + regions = parsed + originalRegions = parsed + } catch { + if case RemoteNodeError.timeout = error { + regionsError = true + } + logger.warning("Failed to fetch regions: \(error)") } - static func parseRegionTree(_ response: String) -> [RepeaterRegionEntry] { - var entries: [RepeaterRegionEntry] = [] - let lines = response.split(separator: "\n", omittingEmptySubsequences: true) - - for line in lines { - var text = String(line) - text = String(text.drop(while: { $0 == " " })) - guard !text.isEmpty else { continue } - - let floodAllowed: Bool - if text.hasSuffix(" F") { - floodAllowed = true - text = String(text.dropLast(2)) - } else { - floodAllowed = false - } - - let isHome: Bool - if text.hasSuffix("^") { - isHome = true - text = String(text.dropLast(1)) - } else { - isHome = false - } - - guard !text.isEmpty else { continue } - - entries.append(RepeaterRegionEntry( - name: text, - floodAllowed: floodAllowed, - isHome: isHome - )) - } - - return entries + isLoadingRegions = false + } + + static func parseRegionTree(_ response: String) -> [RepeaterRegionEntry] { + var entries: [RepeaterRegionEntry] = [] + let lines = response.split(separator: "\n", omittingEmptySubsequences: true) + + for line in lines { + var text = String(line) + text = String(text.drop(while: { $0 == " " })) + guard !text.isEmpty else { continue } + + let floodAllowed: Bool + if text.hasSuffix(" F") { + floodAllowed = true + text = String(text.dropLast(2)) + } else { + floodAllowed = false + } + + let isHome: Bool + if text.hasSuffix("^") { + isHome = true + text = String(text.dropLast(1)) + } else { + isHome = false + } + + guard !text.isEmpty else { continue } + + entries.append(RepeaterRegionEntry( + name: text, + floodAllowed: floodAllowed, + isHome: isHome + )) } - func toggleRegionFlood(name: String) async { - guard let index = regions.firstIndex(where: { $0.name == name }) else { return } - let currentlyAllowed = regions[index].floodAllowed - let command = currentlyAllowed ? "region denyf \(name)" : "region allowf \(name)" - - helper.isApplying = true - helper.errorMessage = nil - - do { - let response = try await helper.sendAndWait(command) - if case .ok = CLIResponse.parse(response) { - regions[index].floodAllowed = !currentlyAllowed - hasUnsavedRegionChanges = true - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion - } - } catch { - helper.errorMessage = error.userFacingMessage - } - - helper.isApplying = false + return entries + } + + func toggleRegionFlood(name: String) async { + guard let index = regions.firstIndex(where: { $0.name == name }) else { return } + let currentlyAllowed = regions[index].floodAllowed + let command = currentlyAllowed ? "region denyf \(name)" : "region allowf \(name)" + + helper.isApplying = true + helper.errorMessage = nil + + do { + let response = try await helper.sendAndWait(command) + if case .ok = CLIResponse.parse(response) { + regions[index].floodAllowed = !currentlyAllowed + hasUnsavedRegionChanges = true + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + } + } catch { + helper.errorMessage = error.userFacingMessage } - func setHomeRegion(name: String) async { - let command = "region home \(name)" - - helper.isApplying = true - helper.errorMessage = nil - - do { - let response = try await helper.sendAndWait(command, rawMatching: true) - if response.contains("home is now") { - for i in regions.indices { - regions[i].isHome = (regions[i].name == name) - } - hasUnsavedRegionChanges = true - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion - } - } catch { - helper.errorMessage = error.userFacingMessage - } + helper.isApplying = false + } - helper.isApplying = false - } + func setHomeRegion(name: String) async { + let command = "region home \(name)" - func addRegion(name: String) async { - let trimmed = name.trimmingCharacters(in: .whitespaces) - if let validationError = RegionNameValidator.validate(trimmed, existingRegions: regions.map(\.name)) { - switch validationError { - case .empty: return - case .invalidCharacters, .tooLong, .duplicate: - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed - } - return - } + helper.isApplying = true + helper.errorMessage = nil - helper.isApplying = true - helper.errorMessage = nil - - do { - let response = try await helper.sendAndWait("region put \(trimmed)") - if case .ok = CLIResponse.parse(response) { - regions.append(RepeaterRegionEntry( - name: trimmed, - floodAllowed: false, - isHome: false - )) - hasUnsavedRegionChanges = true - newRegionName = "" - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed - } - } catch { - helper.errorMessage = error.userFacingMessage + do { + let response = try await helper.sendAndWait(command, rawMatching: true) + if response.contains("home is now") { + for i in regions.indices { + regions[i].isHome = (regions[i].name == name) } + hasUnsavedRegionChanges = true + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + } + } catch { + helper.errorMessage = error.userFacingMessage + } - helper.isApplying = false + helper.isApplying = false + } + + func addRegion(name: String) async { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if let validationError = RegionNameValidator.validate(trimmed, existingRegions: regions.map(\.name)) { + switch validationError { + case .empty: return + case .invalidCharacters, .tooLong, .duplicate: + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed + } + return } - func removeRegion(name: String) async { - helper.isApplying = true - helper.errorMessage = nil - - do { - let response = try await helper.sendAndWait("region remove \(name)") - if case .ok = CLIResponse.parse(response) { - regions.removeAll { $0.name == name } - hasUnsavedRegionChanges = true - } else if response.contains("not empty") { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.removeFailed - } - } catch { - helper.errorMessage = error.userFacingMessage - } + helper.isApplying = true + helper.errorMessage = nil + + do { + let response = try await helper.sendAndWait("region put \(trimmed)") + if case .ok = CLIResponse.parse(response) { + regions.append(RepeaterRegionEntry( + name: trimmed, + floodAllowed: false, + isHome: false + )) + hasUnsavedRegionChanges = true + newRegionName = "" + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed + } + } catch { + helper.errorMessage = error.userFacingMessage + } - helper.isApplying = false + helper.isApplying = false + } + + func removeRegion(name: String) async { + helper.isApplying = true + helper.errorMessage = nil + + do { + let response = try await helper.sendAndWait("region remove \(name)") + if case .ok = CLIResponse.parse(response) { + regions.removeAll { $0.name == name } + hasUnsavedRegionChanges = true + } else if response.contains("not empty") { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.removeFailed + } + } catch { + helper.errorMessage = error.userFacingMessage } - func saveRegions() async { - helper.isApplying = true - helper.errorMessage = nil - - do { - let response = try await helper.sendAndWait("region save") - if case .ok = CLIResponse.parse(response) { - hasUnsavedRegionChanges = false - await helper.flashSuccess( - setApplying: { helper.isApplying = $0 }, - setSuccess: { regionsSaveSuccess = $0 } - ) - return - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveFailed - } - } catch { - helper.errorMessage = error.userFacingMessage - } + helper.isApplying = false + } - helper.isApplying = false + func saveRegions() async { + helper.isApplying = true + helper.errorMessage = nil + + do { + let response = try await helper.sendAndWait("region save") + if case .ok = CLIResponse.parse(response) { + hasUnsavedRegionChanges = false + await helper.flashSuccess( + setApplying: { helper.isApplying = $0 }, + setSuccess: { regionsSaveSuccess = $0 } + ) + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveFailed + } + } catch { + helper.errorMessage = error.userFacingMessage } + + helper.isApplying = false + } } // MARK: - Region Entry struct RepeaterRegionEntry: Identifiable, Equatable { - var id: String { name } - let name: String - var floodAllowed: Bool - var isHome: Bool - var isWildcard: Bool { name == RepeaterSettingsViewModel.wildcardName } + var id: String { + name + } + + let name: String + var floodAllowed: Bool + var isHome: Bool + var isWildcard: Bool { + name == RepeaterSettingsViewModel.wildcardName + } } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift index 5455f115..cc9fa5d6 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift @@ -9,63 +9,63 @@ import SwiftUI /// view model so switching hosts or segments preserves it. The content view does not own the discovery /// lifecycle either: only the guest host stops discovery on dismiss. struct RepeaterStatusContent: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - let viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let userLocation: CLLocation? - let connectedDeviceID: UUID? - /// Contact whose login route is shown at the bottom; nil hides the route section. - var routePathContact: ContactDTO? + let viewModel: RepeaterStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? + let connectedDeviceID: UUID? + /// Contact whose login route is shown at the bottom; nil hides the route section. + var routePathContact: ContactDTO? - var body: some View { - List { - NodeStatusHeaderSection(session: session) - StatusSection(viewModel: viewModel, session: session, connectionState: connectionState) - NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { - await viewModel.requestTelemetry(for: session) - } - NeighborsSection( - viewModel: viewModel, - session: session, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation, - connectionState: connectionState - ) - OwnerInfoSection(viewModel: viewModel, session: session, connectionState: connectionState) - NodeBatteryCurveDisclosureSection( - helper: viewModel.helper, - session: session, - connectionState: connectionState, - connectedDeviceID: connectedDeviceID - ) - if let routePathContact { - NodeRoutePathSection( - contact: routePathContact, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - } - } - .nodeStatusDestinations(helper: viewModel.helper) - .navigationDestination(for: NeighborMapRoute.self) { _ in - NeighborSNRMapView( - session: session, - neighbors: viewModel.neighbors, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - } - .themedCanvas(theme) - .nodeManagementHeaderTopMargin() - .scrollDismissesKeyboard(.interactively) + var body: some View { + List { + NodeStatusHeaderSection(session: session) + StatusSection(viewModel: viewModel, session: session, connectionState: connectionState) + NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { + await viewModel.requestTelemetry(for: session) + } + NeighborsSection( + viewModel: viewModel, + session: session, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation, + connectionState: connectionState + ) + OwnerInfoSection(viewModel: viewModel, session: session, connectionState: connectionState) + NodeBatteryCurveDisclosureSection( + helper: viewModel.helper, + session: session, + connectionState: connectionState, + connectedDeviceID: connectedDeviceID + ) + if let routePathContact { + NodeRoutePathSection( + contact: routePathContact, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + } } + .nodeStatusDestinations(helper: viewModel.helper) + .navigationDestination(for: NeighborMapRoute.self) { _ in + NeighborSNRMapView( + session: session, + neighbors: viewModel.neighbors, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + } + .themedCanvas(theme) + .nodeManagementHeaderTopMargin() + .scrollDismissesKeyboard(.interactively) + } } /// Value-based push identity for the neighbors map. Carries no payload: the destination @@ -76,209 +76,209 @@ private struct NeighborMapRoute: Hashable {} // MARK: - Owner Info Section private struct OwnerInfoSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState + @Environment(\.appTheme) private var theme + @Bindable var viewModel: RepeaterStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState - var body: some View { - Section { - DisclosureGroup(isExpanded: $viewModel.ownerInfoExpanded) { - if viewModel.isLoadingOwnerInfo { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let error = viewModel.ownerInfoError { - Text(error) - .foregroundStyle(.orange) - } else if let info = viewModel.ownerInfo, !info.isEmpty { - Text(info) - } else { - Text(L10n.RemoteNodes.RemoteNodes.Status.noOwnerInfo) - .foregroundStyle(.secondary) - } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Status.ownerInfo) - Spacer() - SectionReloadButton( - isLoading: viewModel.isLoadingOwnerInfo, - isLoaded: viewModel.ownerInfoLoaded, - hasError: viewModel.ownerInfoError != nil, - isDisabled: connectionState != .ready, - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadOwnerInfo, - onReload: { await viewModel.requestOwnerInfo(for: session) } - ) - } - } - .onChange(of: viewModel.ownerInfoExpanded) { _, isExpanded in - if isExpanded && !viewModel.ownerInfoLoaded && !viewModel.isLoadingOwnerInfo { - Task { - await viewModel.requestOwnerInfo(for: session) - } - } - } + var body: some View { + Section { + DisclosureGroup(isExpanded: $viewModel.ownerInfoExpanded) { + if viewModel.isLoadingOwnerInfo { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let error = viewModel.ownerInfoError { + Text(error) + .foregroundStyle(.orange) + } else if let info = viewModel.ownerInfo, !info.isEmpty { + Text(info) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Status.noOwnerInfo) + .foregroundStyle(.secondary) } - .themedRowBackground(theme) + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Status.ownerInfo) + Spacer() + SectionReloadButton( + isLoading: viewModel.isLoadingOwnerInfo, + isLoaded: viewModel.ownerInfoLoaded, + hasError: viewModel.ownerInfoError != nil, + isDisabled: connectionState != .ready, + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadOwnerInfo, + onReload: { await viewModel.requestOwnerInfo(for: session) } + ) + } + } + .onChange(of: viewModel.ownerInfoExpanded) { _, isExpanded in + if isExpanded, !viewModel.ownerInfoLoaded, !viewModel.isLoadingOwnerInfo { + Task { + await viewModel.requestOwnerInfo(for: session) + } + } + } } + .themedRowBackground(theme) + } } // MARK: - Status Section private struct StatusSection: View { - let viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState + let viewModel: RepeaterStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState - var body: some View { - NodeStatusSection(helper: viewModel.helper, connectionState: connectionState) { - await viewModel.requestStatus(for: session) - } rows: { - StatusRows(viewModel: viewModel) - } + var body: some View { + NodeStatusSection(helper: viewModel.helper, connectionState: connectionState) { + await viewModel.requestStatus(for: session) + } rows: { + StatusRows(viewModel: viewModel) } + } } // MARK: - Status Rows private struct StatusRows: View { - let viewModel: RepeaterStatusViewModel + let viewModel: RepeaterStatusViewModel - var body: some View { - NodeCommonStatusRows(helper: viewModel.helper) + var body: some View { + NodeCommonStatusRows(helper: viewModel.helper) - if let receiveErrors = viewModel.receiveErrorsDisplay { - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.receiveErrors, value: receiveErrors) - } + if let receiveErrors = viewModel.receiveErrorsDisplay { + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.receiveErrors, value: receiveErrors) } + } } // MARK: - Neighbors Section private struct NeighborsSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - let contacts: [ContactDTO] - let discoveredNodes: [DiscoveredNodeDTO] - let userLocation: CLLocation? - let connectionState: DeviceConnectionState + @Environment(\.appTheme) private var theme + @Bindable var viewModel: RepeaterStatusViewModel + let session: RemoteNodeSessionDTO + let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] + let userLocation: CLLocation? + let connectionState: DeviceConnectionState - var body: some View { - Section { - DisclosureGroup(isExpanded: $viewModel.neighborsExpanded) { - if viewModel.isLoadingNeighbors && !viewModel.isDiscovering { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let error = viewModel.neighborsSectionError, !viewModel.isDiscovering { - Text(error) - .foregroundStyle(.orange) - } else if viewModel.neighbors.isEmpty && !viewModel.isDiscovering { - Text(L10n.RemoteNodes.RemoteNodes.Status.noNeighbors) - .foregroundStyle(.secondary) - } else { - if !viewModel.neighbors.isEmpty { - NavigationLink(value: NeighborMapRoute()) { - Label(L10n.RemoteNodes.RemoteNodes.Status.viewOnMap, systemImage: "map") - } - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.Accessibility.viewNeighborsOnMap) - } + var body: some View { + Section { + DisclosureGroup(isExpanded: $viewModel.neighborsExpanded) { + if viewModel.isLoadingNeighbors, !viewModel.isDiscovering { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let error = viewModel.neighborsSectionError, !viewModel.isDiscovering { + Text(error) + .foregroundStyle(.orange) + } else if viewModel.neighbors.isEmpty, !viewModel.isDiscovering { + Text(L10n.RemoteNodes.RemoteNodes.Status.noNeighbors) + .foregroundStyle(.secondary) + } else { + if !viewModel.neighbors.isEmpty { + NavigationLink(value: NeighborMapRoute()) { + Label(L10n.RemoteNodes.RemoteNodes.Status.viewOnMap, systemImage: "map") + } + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.Accessibility.viewNeighborsOnMap) + } - ForEach(viewModel.neighbors, id: \.publicKeyPrefix) { neighbor in - let resolution = NeighborNameResolver.resolve( - for: neighbor.publicKeyPrefix, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - NavigationLink(value: NodeStatusRoute.neighborChart( - name: resolution?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, - neighborPrefix: neighbor.publicKeyPrefix - )) { - NeighborRow( - neighbor: neighbor, - displayName: resolution?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, - matchKind: resolution?.matchKind ?? .unresolved, - previousNeighbor: viewModel.helper.previousNeighborSnapshot?.neighborSnapshots?.first { - $0.publicKeyPrefix == neighbor.publicKeyPrefix - }, - hasPreviousSnapshot: viewModel.helper.previousNeighborSnapshot?.neighborSnapshots != nil - ) - } - } + ForEach(viewModel.neighbors, id: \.publicKeyPrefix) { neighbor in + let resolution = NeighborNameResolver.resolve( + for: neighbor.publicKeyPrefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + NavigationLink(value: NodeStatusRoute.neighborChart( + name: resolution?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, + neighborPrefix: neighbor.publicKeyPrefix + )) { + NeighborRow( + neighbor: neighbor, + displayName: resolution?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, + matchKind: resolution?.matchKind ?? .unresolved, + previousNeighbor: viewModel.helper.previousNeighborSnapshot?.neighborSnapshots?.first { + $0.publicKeyPrefix == neighbor.publicKeyPrefix + }, + hasPreviousSnapshot: viewModel.helper.previousNeighborSnapshot?.neighborSnapshots != nil + ) + } + } - if let previousNeighbors = viewModel.helper.previousNeighborSnapshot?.neighborSnapshots { - let currentPrefixes = Set(viewModel.neighbors.map(\.publicKeyPrefix)) - let disappeared = previousNeighbors.filter { !currentPrefixes.contains($0.publicKeyPrefix) } - ForEach(disappeared, id: \.publicKeyPrefix) { old in - let resolution = NeighborNameResolver.resolve( - for: old.publicKeyPrefix, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: userLocation - ) - DisappearedNeighborRow( - neighbor: old, - displayName: resolution?.displayName ?? NeighborNameResolver.fallbackName(for: old.publicKeyPrefix), - matchKind: resolution?.matchKind ?? .unresolved - ) - } - } - } + if let previousNeighbors = viewModel.helper.previousNeighborSnapshot?.neighborSnapshots { + let currentPrefixes = Set(viewModel.neighbors.map(\.publicKeyPrefix)) + let disappeared = previousNeighbors.filter { !currentPrefixes.contains($0.publicKeyPrefix) } + ForEach(disappeared, id: \.publicKeyPrefix) { old in + let resolution = NeighborNameResolver.resolve( + for: old.publicKeyPrefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation + ) + DisappearedNeighborRow( + neighbor: old, + displayName: resolution?.displayName ?? NeighborNameResolver.fallbackName(for: old.publicKeyPrefix), + matchKind: resolution?.matchKind ?? .unresolved + ) + } + } + } - if session.isAdmin { - Button { - if viewModel.isDiscovering { - viewModel.stopDiscovery() - } else { - viewModel.startDiscovery(for: session) - } - } label: { - HStack { - if viewModel.isDiscovering { - ProgressView() - .controlSize(.small) - Text(L10n.RemoteNodes.RemoteNodes.Status.discoveringSeconds(viewModel.discoverySecondsRemaining)) - } else { - Label(L10n.RemoteNodes.RemoteNodes.Status.discoverNeighbors, systemImage: "antenna.radiowaves.left.and.right") - } - } - } - .radioDisabled(for: connectionState, or: viewModel.isLoadingNeighbors && !viewModel.isDiscovering) - } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Status.neighbors) - Spacer() - if viewModel.neighborsLoaded { - Text("\(viewModel.neighbors.count)") - .foregroundStyle(.secondary) - } - SectionReloadButton( - isLoading: viewModel.isLoadingNeighbors && !viewModel.isDiscovering, - isLoaded: viewModel.neighborsLoaded, - hasError: viewModel.neighborsSectionError != nil, - isDisabled: connectionState != .ready || viewModel.isDiscovering, - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadNeighbors, - onReload: { await viewModel.requestNeighbors(for: session) } - ) - } + if session.isAdmin { + Button { + if viewModel.isDiscovering { + viewModel.stopDiscovery() + } else { + viewModel.startDiscovery(for: session) } - .onChange(of: viewModel.neighborsExpanded) { _, isExpanded in - if isExpanded && !viewModel.neighborsLoaded && !viewModel.isLoadingNeighbors { - Task { - await viewModel.requestNeighbors(for: session) - } - } + } label: { + HStack { + if viewModel.isDiscovering { + ProgressView() + .controlSize(.small) + Text(L10n.RemoteNodes.RemoteNodes.Status.discoveringSeconds(viewModel.discoverySecondsRemaining)) + } else { + Label(L10n.RemoteNodes.RemoteNodes.Status.discoverNeighbors, systemImage: "antenna.radiowaves.left.and.right") + } } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Status.neighborsFooter) + } + .radioDisabled(for: connectionState, or: viewModel.isLoadingNeighbors && !viewModel.isDiscovering) + } + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Status.neighbors) + Spacer() + if viewModel.neighborsLoaded { + Text("\(viewModel.neighbors.count)") + .foregroundStyle(.secondary) + } + SectionReloadButton( + isLoading: viewModel.isLoadingNeighbors && !viewModel.isDiscovering, + isLoaded: viewModel.neighborsLoaded, + hasError: viewModel.neighborsSectionError != nil, + isDisabled: connectionState != .ready || viewModel.isDiscovering, + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadNeighbors, + onReload: { await viewModel.requestNeighbors(for: session) } + ) + } + } + .onChange(of: viewModel.neighborsExpanded) { _, isExpanded in + if isExpanded, !viewModel.neighborsLoaded, !viewModel.isLoadingNeighbors { + Task { + await viewModel.requestNeighbors(for: session) + } } - .themedRowBackground(theme) + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Status.neighborsFooter) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift index 7cb78965..418757bc 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift @@ -3,98 +3,98 @@ import SwiftUI /// Guest standalone sheet for repeater stats, telemetry, and neighbors. struct RepeaterStatusView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss - let session: RemoteNodeSessionDTO - @State private var viewModel = RepeaterStatusViewModel() - @State private var contacts: [ContactDTO] = [] - @State private var discoveredNodes: [DiscoveredNodeDTO] = [] - /// The node's contact, kept live so the route section reflects the path the firmware learns after - /// a flood login (delivered asynchronously as a contact update). - @State private var routeContact: ContactDTO? + let session: RemoteNodeSessionDTO + @State private var viewModel = RepeaterStatusViewModel() + @State private var contacts: [ContactDTO] = [] + @State private var discoveredNodes: [DiscoveredNodeDTO] = [] + /// The node's contact, kept live so the route section reflects the path the firmware learns after + /// a flood login (delivered asynchronously as a contact update). + @State private var routeContact: ContactDTO? - var body: some View { - NavigationStack { - RepeaterStatusContent( - viewModel: viewModel, - session: session, - connectionState: appState.connectionState, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: appState.bestAvailableLocation, - connectedDeviceID: appState.connectedDevice?.radioID, - routePathContact: routeContact - ) - .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } - } - - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.RemoteNodes.RemoteNodes.done) { - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) - } - } - } - .task { - viewModel.configure( - repeaterAdminService: { appState.services?.repeaterAdminService }, - contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } - ) - await viewModel.registerHandlers() + var body: some View { + NavigationStack { + RepeaterStatusContent( + viewModel: viewModel, + session: session, + connectionState: appState.connectionState, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: appState.bestAvailableLocation, + connectedDeviceID: appState.connectedDevice?.radioID, + routePathContact: routeContact + ) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } + } - // Pre-load OCV settings and contacts for neighbor matching - if let radioID = appState.connectedDevice?.radioID { - await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) - if let dataStore = appState.services?.dataStore { - contacts = (try? await dataStore.fetchContacts(radioID: radioID)) ?? [] - discoveredNodes = (try? await dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] - } - } - await refreshRouteContact() - } - .onChange(of: appState.contactsVersion) { - Task { await refreshRouteContact() } - } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.done) { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } } - .onDisappear { - viewModel.stopDiscovery() - Task { await viewModel.cleanup() } + } + .task { + viewModel.configure( + repeaterAdminService: { appState.services?.repeaterAdminService }, + contactService: { appState.services?.contactService }, + nodeSnapshotService: { appState.services?.nodeSnapshotService } + ) + await viewModel.registerHandlers() + + // Pre-load OCV settings and contacts for neighbor matching + if let radioID = appState.connectedDevice?.radioID { + await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) + if let dataStore = appState.services?.dataStore { + contacts = await (try? dataStore.fetchContacts(radioID: radioID)) ?? [] + discoveredNodes = await (try? dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] + } } - .presentationDetents([.large]) + await refreshRouteContact() + } + .onChange(of: appState.contactsVersion) { + Task { await refreshRouteContact() } + } + } + .onDisappear { + viewModel.stopDiscovery() + Task { await viewModel.cleanup() } } + .presentationDetents([.large]) + } - private func refreshRouteContact() async { - guard let dataStore = appState.services?.dataStore else { return } - if let updated = (try? await dataStore.fetchContact( - radioID: session.radioID, - publicKey: session.publicKey - )).flatMap({ $0 }) { - routeContact = updated - } + private func refreshRouteContact() async { + guard let dataStore = appState.services?.dataStore else { return } + if let updated = await (try? dataStore.fetchContact( + radioID: session.radioID, + publicKey: session.publicKey + )).flatMap(\.self) { + routeContact = updated } + } } #Preview { - RepeaterStatusView( - session: RemoteNodeSessionDTO( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Repeater", - role: .repeater, - isConnected: true, - permissionLevel: .admin - ) + RepeaterStatusView( + session: RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Repeater", + role: .repeater, + isConnected: true, + permissionLevel: .admin ) - .environment(\.appState, AppState()) + ) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift index becfa06d..24bf58ea 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift @@ -1,5 +1,5 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "RepeaterStatusVM") @@ -8,244 +8,251 @@ private let logger = Logger(subsystem: "com.mc1", category: "RepeaterStatusVM") @Observable @MainActor final class RepeaterStatusViewModel { + // MARK: - Shared Helper - // MARK: - Shared Helper + var helper = NodeStatusViewModel() - var helper = NodeStatusViewModel() + // MARK: - Repeater-Only Properties - // MARK: - Repeater-Only Properties + /// Neighbor entries + var neighbors: [NeighbourInfo] = [] - /// Neighbor entries - var neighbors: [NeighbourInfo] = [] + /// Loading states + var isLoadingNeighbors = false - /// Loading states - var isLoadingNeighbors = false + /// Whether neighbors have been loaded at least once (for refresh logic) + var neighborsLoaded = false - /// Whether neighbors have been loaded at least once (for refresh logic) - var neighborsLoaded = false + /// Whether the neighbors disclosure group is expanded + var neighborsExpanded = false - /// Whether the neighbors disclosure group is expanded - var neighborsExpanded = false + /// Error scoped to the neighbors section, kept separate from other sections' errors. + var neighborsSectionError: String? - /// Error scoped to the neighbors section, kept separate from other sections' errors. - var neighborsSectionError: String? + /// Discovery state + var isDiscovering: Bool { + discoverTask != nil + } - /// Discovery state - var isDiscovering: Bool { discoverTask != nil } - var discoverySecondsRemaining = 0 - private var discoverTask: Task? + var discoverySecondsRemaining = 0 + private var discoverTask: Task? - private static let discoveryDuration = 60 - private static let pollIntervalTicks = 5 - private static let discoverCommand = "discover.neighbors" + private static let discoveryDuration = 60 + private static let pollIntervalTicks = 5 + private static let discoverCommand = "discover.neighbors" - /// Owner info text - var ownerInfo: String? + /// Owner info text + var ownerInfo: String? - /// Owner info loading/state - var isLoadingOwnerInfo = false - var ownerInfoLoaded: Bool { ownerInfo != nil } - var ownerInfoExpanded = false - var ownerInfoError: String? + /// Owner info loading/state + var isLoadingOwnerInfo = false + var ownerInfoLoaded: Bool { + ownerInfo != nil + } - // MARK: - Dependencies + var ownerInfoExpanded = false + var ownerInfoError: String? - private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } - var repeaterAdminService: RepeaterAdminService? { repeaterAdminServiceProvider() } + // MARK: - Dependencies - // MARK: - Initialization + private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } + var repeaterAdminService: RepeaterAdminService? { + repeaterAdminServiceProvider() + } - init() {} + // MARK: - Initialization - /// Nil services mirror a disconnected state; requests then no-op. - func configure( - repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, - contactService: @escaping @MainActor () -> ContactService?, - nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? - ) { - self.repeaterAdminServiceProvider = repeaterAdminService - helper.configure( - contactService: contactService, - nodeSnapshotService: nodeSnapshotService - ) - } + init() {} - /// Reads the live service from the provider so a reconnect-minted instance - /// is used at call time. Sets only the slots this view model owns; the admin - /// service is shared with the settings/CLI view model, so clearing here would - /// drop its CLI handler and silently break late CLI-response delivery. - func registerHandlers() async { - guard let repeaterAdminService = repeaterAdminService else { return } + /// Nil services mirror a disconnected state; requests then no-op. + func configure( + repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, + contactService: @escaping @MainActor () -> ContactService?, + nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? + ) { + repeaterAdminServiceProvider = repeaterAdminService + helper.configure( + contactService: contactService, + nodeSnapshotService: nodeSnapshotService + ) + } - await repeaterAdminService.setStatusHandler { [weak self] status in - await self?.handleStatusResponse(status) - } + /// Reads the live service from the provider so a reconnect-minted instance + /// is used at call time. Sets only the slots this view model owns; the admin + /// service is shared with the settings/CLI view model, so clearing here would + /// drop its CLI handler and silently break late CLI-response delivery. + func registerHandlers() async { + guard let repeaterAdminService else { return } - await repeaterAdminService.setNeighboursHandler { [weak self] response in - await self?.handleNeighboursResponse(response) - } - - await repeaterAdminService.setTelemetryHandler { [weak self] response in - await self?.helper.handleTelemetryResponse(response) - } + await repeaterAdminService.setStatusHandler { [weak self] status in + await self?.handleStatusResponse(status) } - /// Clear every handler slot on the shared admin service. Only for true - /// surface teardown (sheet dismiss); calling it on a segment switch would - /// wipe the CLI handler the settings view model relies on. - func cleanup() async { - guard let repeaterAdminService = repeaterAdminService else { return } - await repeaterAdminService.clearHandlers() + await repeaterAdminService.setNeighboursHandler { [weak self] response in + await self?.handleNeighboursResponse(response) } - /// Clear only this view model's status/neighbours/telemetry handler slots, leaving the - /// settings view model's CLI handler intact. For the merged admin surface's status-segment teardown. - func clearStatusHandlers() async { - guard let repeaterAdminService = repeaterAdminService else { return } - await repeaterAdminService.clearStatusHandlers() + await repeaterAdminService.setTelemetryHandler { [weak self] response in + await self?.helper.handleTelemetryResponse(response) } - - // MARK: - Status - - func requestStatus(for session: RemoteNodeSessionDTO) async { - guard let repeaterAdminService else { return } - if helper.session == nil { helper.session = session } - - await helper.runRetryingSectionRequest( - operationName: "status", - setLoading: { self.helper.isLoadingStatus = $0 }, - setError: { self.helper.statusSectionError = $0 }, - operation: { [repeaterAdminService] timeout in - try await repeaterAdminService.requestStatus(sessionID: session.id, timeout: timeout) - }, - onSuccess: { await self.handleStatusResponse($0) } - ) + } + + /// Clear every handler slot on the shared admin service. Only for true + /// surface teardown (sheet dismiss); calling it on a segment switch would + /// wipe the CLI handler the settings view model relies on. + func cleanup() async { + guard let repeaterAdminService else { return } + await repeaterAdminService.clearHandlers() + } + + /// Clear only this view model's status/neighbours/telemetry handler slots, leaving the + /// settings view model's CLI handler intact. For the merged admin surface's status-segment teardown. + func clearStatusHandlers() async { + guard let repeaterAdminService else { return } + await repeaterAdminService.clearStatusHandlers() + } + + // MARK: - Status + + func requestStatus(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "status", + setLoading: { self.helper.isLoadingStatus = $0 }, + setError: { self.helper.statusSectionError = $0 }, + operation: { [repeaterAdminService] timeout in + try await repeaterAdminService.requestStatus(sessionID: session.id, timeout: timeout) + }, + onSuccess: { await self.handleStatusResponse($0) } + ) + } + + private func handleStatusResponse(_ response: RemoteNodeStatus) async { + await helper.handleStatusResponse( + response, + rxAirtimeSeconds: response.repeaterRxAirtimeSeconds, + receiveErrors: response.receiveErrors + ) + } + + // MARK: - Neighbors + + func requestNeighbors(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "neighbors", + setLoading: { self.isLoadingNeighbors = $0 }, + setError: { self.neighborsSectionError = $0 }, + operation: { [repeaterAdminService] timeout in + try await repeaterAdminService.fetchAllNeighbors(sessionID: session.id, timeout: timeout) + }, + onSuccess: { await self.handleNeighboursResponse($0) } + ) + } + + func handleNeighboursResponse(_ response: NeighboursResponse) async { + neighbors = response.neighbours + isLoadingNeighbors = false + neighborsLoaded = true + + let entries = response.neighbours.map { + NeighborSnapshotEntry(publicKeyPrefix: $0.publicKeyPrefix, snr: $0.snr, secondsAgo: $0.secondsAgo) } + await helper.enrichNeighbors(entries) + } - private func handleStatusResponse(_ response: RemoteNodeStatus) async { - await helper.handleStatusResponse( - response, - rxAirtimeSeconds: response.repeaterRxAirtimeSeconds, - receiveErrors: response.receiveErrors - ) - } + // MARK: - Discovery - // MARK: - Neighbors + func startDiscovery(for session: RemoteNodeSessionDTO) { + guard let repeaterAdminService, !isDiscovering else { return } - func requestNeighbors(for session: RemoteNodeSessionDTO) async { - guard let repeaterAdminService else { return } - if helper.session == nil { helper.session = session } + discoverySecondsRemaining = Self.discoveryDuration - await helper.runRetryingSectionRequest( - operationName: "neighbors", - setLoading: { self.isLoadingNeighbors = $0 }, - setError: { self.neighborsSectionError = $0 }, - operation: { [repeaterAdminService] timeout in - try await repeaterAdminService.fetchAllNeighbors(sessionID: session.id, timeout: timeout) - }, - onSuccess: { await self.handleNeighboursResponse($0) } + discoverTask = Task { + do { + _ = try await repeaterAdminService.sendCommand( + sessionID: session.id, + command: Self.discoverCommand ) - } - - func handleNeighboursResponse(_ response: NeighboursResponse) async { - self.neighbors = response.neighbours - self.isLoadingNeighbors = false - self.neighborsLoaded = true - - let entries = response.neighbours.map { - NeighborSnapshotEntry(publicKeyPrefix: $0.publicKeyPrefix, snr: $0.snr, secondsAgo: $0.secondsAgo) - } - await helper.enrichNeighbors(entries) - } - - // MARK: - Discovery - - func startDiscovery(for session: RemoteNodeSessionDTO) { - guard let repeaterAdminService, !isDiscovering else { return } - - discoverySecondsRemaining = Self.discoveryDuration - - discoverTask = Task { - do { - _ = try await repeaterAdminService.sendCommand( - sessionID: session.id, - command: Self.discoverCommand - ) - } catch { - neighborsSectionError = error.userFacingMessage - discoverySecondsRemaining = 0 - discoverTask = nil - return - } - - let startTime = Date.now - var tickCount = 0 - - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { break } - - let elapsed = Int(Date.now.timeIntervalSince(startTime)) - let remaining = max(0, Self.discoveryDuration - elapsed) - discoverySecondsRemaining = remaining - - tickCount += 1 - if tickCount.isMultiple(of: Self.pollIntervalTicks) { - await requestNeighbors(for: session) - } - - if remaining <= 0 { break } - } - - discoverySecondsRemaining = 0 - discoverTask = nil - } - } - - func stopDiscovery() { - discoverTask?.cancel() - discoverTask = nil + } catch { + neighborsSectionError = error.userFacingMessage discoverySecondsRemaining = 0 - } - - // MARK: - Telemetry - - func requestTelemetry(for session: RemoteNodeSessionDTO) async { - guard let repeaterAdminService else { return } - if helper.session == nil { helper.session = session } + discoverTask = nil + return + } - await helper.runRetryingSectionRequest( - operationName: "telemetry", - setLoading: { self.helper.isLoadingTelemetry = $0 }, - setError: { self.helper.telemetrySectionError = $0 }, - operation: { [repeaterAdminService] timeout in - try await repeaterAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) - }, - onSuccess: { await self.helper.handleTelemetryResponse($0) } - ) - } + let startTime = Date.now + var tickCount = 0 - // MARK: - Owner Info + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { break } - func requestOwnerInfo(for session: RemoteNodeSessionDTO) async { - guard let repeaterAdminService else { return } - if helper.session == nil { helper.session = session } + let elapsed = Int(Date.now.timeIntervalSince(startTime)) + let remaining = max(0, Self.discoveryDuration - elapsed) + discoverySecondsRemaining = remaining - await helper.runRetryingSectionRequest( - operationName: "ownerInfo", - setLoading: { self.isLoadingOwnerInfo = $0 }, - setError: { self.ownerInfoError = $0 }, - operation: { [repeaterAdminService] timeout in - try await repeaterAdminService.requestOwnerInfo(sessionID: session.id, timeout: timeout) - }, - onSuccess: { self.ownerInfo = $0.ownerInfo } - ) - } + tickCount += 1 + if tickCount.isMultiple(of: Self.pollIntervalTicks) { + await requestNeighbors(for: session) + } - // MARK: - Repeater-Only Display + if remaining <= 0 { break } + } - var receiveErrorsDisplay: String? { - guard let count = helper.status?.receiveErrors, count > 0 else { return nil } - return count.formatted() + discoverySecondsRemaining = 0 + discoverTask = nil } + } + + func stopDiscovery() { + discoverTask?.cancel() + discoverTask = nil + discoverySecondsRemaining = 0 + } + + // MARK: - Telemetry + + func requestTelemetry(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "telemetry", + setLoading: { self.helper.isLoadingTelemetry = $0 }, + setError: { self.helper.telemetrySectionError = $0 }, + operation: { [repeaterAdminService] timeout in + try await repeaterAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) + }, + onSuccess: { await self.helper.handleTelemetryResponse($0) } + ) + } + + // MARK: - Owner Info + + func requestOwnerInfo(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "ownerInfo", + setLoading: { self.isLoadingOwnerInfo = $0 }, + setError: { self.ownerInfoError = $0 }, + operation: { [repeaterAdminService] timeout in + try await repeaterAdminService.requestOwnerInfo(sessionID: session.id, timeout: timeout) + }, + onSuccess: { self.ownerInfo = $0.ownerInfo } + ) + } + + // MARK: - Repeater-Only Display + + var receiveErrorsDisplay: String? { + guard let count = helper.status?.receiveErrors, count > 0 else { return nil } + return count.formatted() + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift index 5dc5c483..91b61423 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift @@ -1,442 +1,443 @@ +import MC1Services import SwiftUI import UIKit -import MC1Services /// Full room chat interface struct RoomConversationView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.scenePhase) private var scenePhase - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + @Environment(\.appTheme) private var theme - @State private var session: RemoteNodeSessionDTO - @State private var viewModel = RoomConversationViewModel() - @State private var chatViewModel = ChatViewModel() - @State private var showingRoomInfo = false - @State private var roomToAuthenticate: RemoteNodeSessionDTO? - @State private var selectedRoomMessage: RoomMessageDTO? - @State private var sendDMContext: SendDMContext? - @State private var inputFocusRequest = 0 - @State private var isAtBottom = true - @State private var unreadCount = 0 - @State private var scrollToBottomRequest = 0 + @State private var session: RemoteNodeSessionDTO + @State private var viewModel = RoomConversationViewModel() + @State private var chatViewModel = ChatViewModel() + @State private var showingRoomInfo = false + @State private var roomToAuthenticate: RemoteNodeSessionDTO? + @State private var selectedRoomMessage: RoomMessageDTO? + @State private var sendDMContext: SendDMContext? + @State private var inputFocusRequest = 0 + @State private var isAtBottom = true + @State private var unreadCount = 0 + @State private var scrollToBottomRequest = 0 - @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote + @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote - init(session: RemoteNodeSessionDTO) { - self._session = State(initialValue: session) - } + init(session: RemoteNodeSessionDTO) { + _session = State(initialValue: session) + } - var body: some View { - makeMessagesView() - .mentionTapHandling( - contacts: chatViewModel.allContacts, - radioID: session.radioID, - shouldSuppressOpen: { selectedRoomMessage != nil } - ) - .safeAreaInset(edge: .bottom, spacing: 0) { - if !session.isConnected { - makeDisconnectedBanner() - } else if session.canPost { - makeInputBar() - } else { - makeReadOnlyBanner() - } - } - .animation(.default, value: session.isConnected) - .navigationHeader(title: session.name, subtitle: connectionStatus) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(L10n.RemoteNodes.RemoteNodes.Room.infoTitle, systemImage: "info.circle") { - showingRoomInfo = true - } - } - } - .sheet(isPresented: $showingRoomInfo) { - RoomInfoSheet(session: session) - .environment(\.chatViewModel, chatViewModel) - } - .sheet(item: $roomToAuthenticate) { sessionToAuth in - RoomAuthenticationSheet(session: sessionToAuth) { authenticatedSession in - roomToAuthenticate = nil - session = authenticatedSession - } - .presentationSizing(.page) - } - .sheet(item: $selectedRoomMessage) { message in - RoomMessageActionsSheet( - message: message, - availability: RoomMessageActionAvailability(message: message, session: session), - onAction: { dispatch($0, for: message) } - ) - } - .sheet(item: $sendDMContext) { context in - SendDMSheet(senderName: context.senderName, radioID: context.radioID) { contact in - appState.navigation.navigateToChat(with: contact) - } - } - .task { - viewModel.configure( - roomServerService: { appState.services?.roomServerService }, - dataStore: { appState.services?.dataStore }, - syncCoordinator: { appState.syncCoordinator }, - notificationService: { appState.services?.notificationService } - ) - chatViewModel.configure( - dependencies: ChatViewModel.Dependencies( - dataStore: { appState.offlineDataStore }, - messageService: { appState.services?.messageService }, - notificationService: { appState.services?.notificationService }, - channelService: { appState.services?.channelService }, - roomServerService: { appState.services?.roomServerService }, - contactService: { appState.services?.contactService }, - syncCoordinator: { appState.syncCoordinator }, - connectionState: { appState.connectionState }, - connectedDevice: { appState.connectedDevice }, - currentRadioID: { appState.currentRadioID }, - session: { appState.services?.session }, - reactionService: { appState.services?.reactionService }, - chatSendQueueService: { appState.services?.chatSendQueueService }, - inlineImageDimensionsStore: { nil }, - prefetchDataStore: { nil } - ), - onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, - linkPreviewCache: nil, - chatCoordinatorRegistry: nil, - conversation: nil - ) - await chatViewModel.loadAllContacts(radioID: session.radioID) - await viewModel.loadMessages(for: session) - } - .onChange(of: appState.contactsVersion) { _, _ in - // Keep the mention-resolution snapshot fresh: a contact added after the - // room opened must be tappable without reopening the screen. - Task { await chatViewModel.loadAllContacts(radioID: session.radioID) } - } - .task(id: appState.servicesVersion) { - // Track the active room so foreground banners for it are suppressed. - // Keyed on servicesVersion so a reconnect, which mints a fresh - // NotificationService, re-asserts this on the new instance. - appState.services?.notificationService.setActiveConversation(roomSessionID: session.id) - } - .task { - for await event in appState.messageEventStream.events() { - await viewModel.handleEvent(event) - } - } - .onChange(of: appState.sessionStateChangeCount) { _, _ in - Task { - await viewModel.refreshSession() - if let updated = viewModel.session { - session = updated - } - } - } - .task(id: session.isConnected) { - guard session.isConnected else { return } - await appState.services?.remoteNodeService.startSessionKeepAlive( - sessionID: session.id, publicKey: session.publicKey - ) - } - .onChange(of: session.isConnected) { _, isConnected in - if isConnected { - AccessibilityNotification.Announcement( - L10n.RemoteNodes.RemoteNodes.Room.reconnected - ).post() - } - } - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { - // Re-clear the tray: notifications usually arrive while - // backgrounded with this room already on screen. - Task { - await appState.services?.notificationService - .removeDeliveredNotifications(forRoomSessionID: session.id) - await appState.services?.notificationService.updateBadgeCount() - } - if session.isConnected { - Task { - await appState.services?.remoteNodeService.startSessionKeepAlive( - sessionID: session.id, publicKey: session.publicKey - ) - } - } - } - } - .onDisappear { - // Only clear if this room still owns the active slot; a newer room's - // .task may have already claimed it before this view tears down. - if appState.services?.notificationService.activeRoomSessionID == session.id { - appState.services?.notificationService.activeRoomSessionID = nil - } - Task { - await appState.services?.remoteNodeService.stopSessionKeepAlive( - sessionID: session.id - ) - } + var body: some View { + makeMessagesView() + .mentionTapHandling( + contacts: chatViewModel.allContacts, + radioID: session.radioID, + shouldSuppressOpen: { selectedRoomMessage != nil } + ) + .safeAreaInset(edge: .bottom, spacing: 0) { + if !session.isConnected { + makeDisconnectedBanner() + } else if session.canPost { + makeInputBar() + } else { + makeReadOnlyBanner() + } + } + .animation(.default, value: session.isConnected) + .navigationHeader(title: session.name, subtitle: connectionStatus) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.RemoteNodes.RemoteNodes.Room.infoTitle, systemImage: "info.circle") { + showingRoomInfo = true + } + } + } + .sheet(isPresented: $showingRoomInfo) { + RoomInfoSheet(session: session) + .environment(\.chatViewModel, chatViewModel) + } + .sheet(item: $roomToAuthenticate) { sessionToAuth in + RoomAuthenticationSheet(session: sessionToAuth) { authenticatedSession in + roomToAuthenticate = nil + session = authenticatedSession + } + .presentationSizing(.page) + } + .sheet(item: $selectedRoomMessage) { message in + RoomMessageActionsSheet( + message: message, + availability: RoomMessageActionAvailability(message: message, session: session), + onAction: { dispatch($0, for: message) } + ) + } + .sheet(item: $sendDMContext) { context in + SendDMSheet(senderName: context.senderName, radioID: context.radioID) { contact in + appState.navigation.navigateToChat(with: contact) + } + } + .task { + viewModel.configure( + roomServerService: { appState.services?.roomServerService }, + dataStore: { appState.services?.dataStore }, + syncCoordinator: { appState.syncCoordinator }, + notificationService: { appState.services?.notificationService } + ) + chatViewModel.configure( + dependencies: ChatViewModel.Dependencies( + dataStore: { appState.offlineDataStore }, + messageService: { appState.services?.messageService }, + notificationService: { appState.services?.notificationService }, + channelService: { appState.services?.channelService }, + roomServerService: { appState.services?.roomServerService }, + contactService: { appState.services?.contactService }, + syncCoordinator: { appState.syncCoordinator }, + connectionState: { appState.connectionState }, + connectedDevice: { appState.connectedDevice }, + currentRadioID: { appState.currentRadioID }, + session: { appState.services?.session }, + reactionService: { appState.services?.reactionService }, + chatSendQueueService: { appState.services?.chatSendQueueService }, + inlineImageDimensionsStore: { nil }, + prefetchDataStore: { nil } + ), + onNavigateToMap: { appState.navigation.navigateToMap(coordinate: $0) }, + linkPreviewCache: nil, + chatCoordinatorRegistry: nil, + conversation: nil + ) + await chatViewModel.loadAllContacts(radioID: session.radioID) + await viewModel.loadMessages(for: session) + } + .onChange(of: appState.contactsVersion) { _, _ in + // Keep the mention-resolution snapshot fresh: a contact added after the + // room opened must be tappable without reopening the screen. + Task { await chatViewModel.loadAllContacts(radioID: session.radioID) } + } + .task(id: appState.servicesVersion) { + // Track the active room so foreground banners for it are suppressed. + // Keyed on servicesVersion so a reconnect, which mints a fresh + // NotificationService, re-asserts this on the new instance. + appState.services?.notificationService.setActiveConversation(roomSessionID: session.id) + } + .task { + for await event in appState.messageEventStream.events() { + await viewModel.handleEvent(event) + } + } + .onChange(of: appState.sessionStateChangeCount) { _, _ in + Task { + await viewModel.refreshSession() + if let updated = viewModel.session { + session = updated + } + } + } + .task(id: session.isConnected) { + guard session.isConnected else { return } + await appState.services?.remoteNodeService.startSessionKeepAlive( + sessionID: session.id, publicKey: session.publicKey + ) + } + .onChange(of: session.isConnected) { _, isConnected in + if isConnected { + AccessibilityNotification.Announcement( + L10n.RemoteNodes.RemoteNodes.Room.reconnected + ).post() + } + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + // Re-clear the tray: notifications usually arrive while + // backgrounded with this room already on screen. + Task { + await appState.services?.notificationService + .removeDeliveredNotifications(forRoomSessionID: session.id) + await appState.services?.notificationService.updateBadgeCount() + } + if session.isConnected { + Task { + await appState.services?.remoteNodeService.startSessionKeepAlive( + sessionID: session.id, publicKey: session.publicKey + ) } - } - - private var connectionStatus: String { - if session.isConnected { - return session.permissionLevel.localizedName + } + } + } + .onDisappear { + // Only clear if this room still owns the active slot; a newer room's + // .task may have already claimed it before this view tears down. + if appState.services?.notificationService.activeRoomSessionID == session.id { + appState.services?.notificationService.activeRoomSessionID = nil + } + Task { + await appState.services?.remoteNodeService.stopSessionKeepAlive( + sessionID: session.id + ) } - return L10n.RemoteNodes.RemoteNodes.Room.disconnected + } + } + + private var connectionStatus: String { + if session.isConnected { + return session.permissionLevel.localizedName } + return L10n.RemoteNodes.RemoteNodes.Room.disconnected + } - // MARK: - Subviews + // MARK: - Subviews - private func makeMessagesView() -> some View { - MessagesView( - hasLoadedOnce: viewModel.hasLoadedOnce, - messages: viewModel.messages, - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - session: session, - theme: theme, - onRetry: { id in - Task { await viewModel.retryMessage(id: id) } - }, - onLongPress: { selectedRoomMessage = $0 } - ) - } + private func makeMessagesView() -> some View { + MessagesView( + hasLoadedOnce: viewModel.hasLoadedOnce, + messages: viewModel.messages, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + session: session, + theme: theme, + onRetry: { id in + Task { await viewModel.retryMessage(id: id) } + }, + onLongPress: { selectedRoomMessage = $0 } + ) + } - private func makeInputBar() -> some View { - ChatInputBar( - text: $viewModel.composingText, - focusRequest: inputFocusRequest, - placeholder: L10n.RemoteNodes.RemoteNodes.Room.publicMessage, - maxBytes: ProtocolLimits.maxDirectMessageLength, - isEncrypted: false - ) { text in - scrollToBottomRequest += 1 - Task { await viewModel.sendMessage(text: text) } - } + private func makeInputBar() -> some View { + ChatInputBar( + text: $viewModel.composingText, + focusRequest: inputFocusRequest, + placeholder: L10n.RemoteNodes.RemoteNodes.Room.publicMessage, + maxBytes: ProtocolLimits.maxDirectMessageLength, + isEncrypted: false + ) { text in + scrollToBottomRequest += 1 + Task { await viewModel.sendMessage(text: text) } } + } - private func makeReadOnlyBanner() -> some View { - RoomStatusBanner( - icon: "eye", - title: L10n.RemoteNodes.RemoteNodes.Room.viewOnlyBanner, - hint: L10n.RemoteNodes.RemoteNodes.Room.viewOnlyHint, - style: AnyShapeStyle(.secondary), - isBold: false, - action: { roomToAuthenticate = session } - ) - } + private func makeReadOnlyBanner() -> some View { + RoomStatusBanner( + icon: "eye", + title: L10n.RemoteNodes.RemoteNodes.Room.viewOnlyBanner, + hint: L10n.RemoteNodes.RemoteNodes.Room.viewOnlyHint, + style: AnyShapeStyle(.secondary), + isBold: false, + action: { roomToAuthenticate = session } + ) + } - private func makeDisconnectedBanner() -> some View { - RoomStatusBanner( - icon: "exclamationmark.triangle.fill", - title: L10n.RemoteNodes.RemoteNodes.Room.disconnectedBanner, - hint: L10n.RemoteNodes.RemoteNodes.Room.disconnectedHint, - style: AnyShapeStyle(.orange), - isBold: true, - action: { roomToAuthenticate = session } - ) - } + private func makeDisconnectedBanner() -> some View { + RoomStatusBanner( + icon: "exclamationmark.triangle.fill", + title: L10n.RemoteNodes.RemoteNodes.Room.disconnectedBanner, + hint: L10n.RemoteNodes.RemoteNodes.Room.disconnectedHint, + style: AnyShapeStyle(.orange), + isBold: true, + action: { roomToAuthenticate = session } + ) + } } // MARK: - Message Actions extension RoomConversationView { - private func dispatch(_ action: RoomMessageAction, for message: RoomMessageDTO) { - switch action { - case .copy: - UIPasteboard.general.string = message.text - case .reply: - handleReply(for: message) - case .sendDM: - handleSendDM(for: message) - case .sendAgain: - Task { await viewModel.sendMessage(text: message.text) } - } + private func dispatch(_ action: RoomMessageAction, for message: RoomMessageDTO) { + switch action { + case .copy: + UIPasteboard.general.string = message.text + case .reply: + handleReply(for: message) + case .sendDM: + handleSendDM(for: message) + case .sendAgain: + Task { await viewModel.sendMessage(text: message.text) } } + } - private func handleReply(for message: RoomMessageDTO) { - if replyWithQuote { - viewModel.composingText = MentionUtilities.buildReplyText( - mentionName: message.authorDisplayName, messageText: message.text) - } else { - viewModel.composingText = MentionUtilities.appendMention( - for: message.authorDisplayName, - to: viewModel.composingText - ) - } - // Raise the keyboard only after the actions sheet has finished dismissing; - // a focus request issued while it is still animating away is lost. - Task { - try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) - inputFocusRequest += 1 - } + private func handleReply(for message: RoomMessageDTO) { + if replyWithQuote { + viewModel.composingText = MentionUtilities.buildReplyText( + mentionName: message.authorDisplayName, messageText: message.text + ) + } else { + viewModel.composingText = MentionUtilities.appendMention( + for: message.authorDisplayName, + to: viewModel.composingText + ) } + // Raise the keyboard only after the actions sheet has finished dismissing; + // a focus request issued while it is still animating away is lost. + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + inputFocusRequest += 1 + } + } - private func handleSendDM(for message: RoomMessageDTO) { - Task { - try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) - sendDMContext = SendDMContext(senderName: message.authorDisplayName, radioID: session.radioID) - } + private func handleSendDM(for message: RoomMessageDTO) { + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + sendDMContext = SendDMContext(senderName: message.authorDisplayName, radioID: session.radioID) } + } } // MARK: - Messages View private struct MessagesView: View { - let hasLoadedOnce: Bool - let messages: [RoomMessageDTO] - @Binding var isAtBottom: Bool - @Binding var unreadCount: Int - @Binding var scrollToBottomRequest: Int - let session: RemoteNodeSessionDTO - let theme: Theme - let onRetry: (UUID) -> Void - let onLongPress: (RoomMessageDTO) -> Void + let hasLoadedOnce: Bool + let messages: [RoomMessageDTO] + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + let session: RemoteNodeSessionDTO + let theme: Theme + let onRetry: (UUID) -> Void + let onLongPress: (RoomMessageDTO) -> Void - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.openURL) private var openURL + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.openURL) private var openURL - var body: some View { - Group { - if !hasLoadedOnce { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if messages.isEmpty { - EmptyMessagesView(session: session) - } else { - let timestampVisibleIDs = Self.timestampVisibleIDs(in: messages) - ChatTableView( - items: messages, - cellContent: { message in - messageBubble(for: message, showTimestamp: timestampVisibleIDs.contains(message.id)) - .environment(\.appTheme, theme) - .environment(\.openURL, openURL) - }, - contentBackground: theme.surfaces?.canvas, - themeID: theme.id, - appearanceToken: AppearanceToken.make( - colorScheme: colorScheme, - contrast: colorSchemeContrast, - dynamicTypeSize: dynamicTypeSize - ), - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: .constant(0), - offscreenMentionIDs: .constant([]), - onSecondaryClick: onLongPress, - scrollToDividerRequest: .constant(0), - isDividerVisible: .constant(false) - ) - .overlay(alignment: .bottomTrailing) { - ScrollToBottomButton( - isVisible: !isAtBottom, - unreadCount: unreadCount, - onTap: { scrollToBottomRequest += 1 } - ) - .padding(.trailing, 16) - .padding(.bottom, 8) - } - } + var body: some View { + Group { + if !hasLoadedOnce { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if messages.isEmpty { + EmptyMessagesView(session: session) + } else { + let timestampVisibleIDs = Self.timestampVisibleIDs(in: messages) + ChatTableView( + items: messages, + cellContent: { message in + messageBubble(for: message, showTimestamp: timestampVisibleIDs.contains(message.id)) + .environment(\.appTheme, theme) + .environment(\.openURL, openURL) + }, + contentBackground: theme.surfaces?.canvas, + themeID: theme.id, + appearanceToken: AppearanceToken.make( + colorScheme: colorScheme, + contrast: colorSchemeContrast, + dynamicTypeSize: dynamicTypeSize + ), + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: .constant(0), + offscreenMentionIDs: .constant([]), + onSecondaryClick: onLongPress, + scrollToDividerRequest: .constant(0), + isDividerVisible: .constant(false) + ) + .overlay(alignment: .bottomTrailing) { + ScrollToBottomButton( + isVisible: !isAtBottom, + unreadCount: unreadCount, + onTap: { scrollToBottomRequest += 1 } + ) + .padding(.trailing, 16) + .padding(.bottom, 8) } - .themedCanvas(theme) + } } + .themedCanvas(theme) + } - private func messageBubble(for message: RoomMessageDTO, showTimestamp: Bool) -> some View { - RoomMessageBubble( - message: message, - showTimestamp: showTimestamp, - onRetry: message.status == .failed ? { - onRetry(message.id) - } : nil, - onLongPress: onLongPress - ) - } + private func messageBubble(for message: RoomMessageDTO, showTimestamp: Bool) -> some View { + RoomMessageBubble( + message: message, + showTimestamp: showTimestamp, + onRetry: message.status == .failed ? { + onRetry(message.id) + } : nil, + onLongPress: onLongPress + ) + } - /// Single pass over the array producing the set of message IDs whose timestamp is shown, - /// so each cell does an O(1) lookup instead of an O(n) `firstIndex` per body evaluation. - private static func timestampVisibleIDs(in messages: [RoomMessageDTO]) -> Set { - var visible = Set() - for index in messages.indices where RoomConversationViewModel.shouldShowTimestamp(at: index, in: messages) { - visible.insert(messages[index].id) - } - return visible + /// Single pass over the array producing the set of message IDs whose timestamp is shown, + /// so each cell does an O(1) lookup instead of an O(n) `firstIndex` per body evaluation. + private static func timestampVisibleIDs(in messages: [RoomMessageDTO]) -> Set { + var visible = Set() + for index in messages.indices where RoomConversationViewModel.shouldShowTimestamp(at: index, in: messages) { + visible.insert(messages[index].id) } + return visible + } } // MARK: - Empty Messages View private struct EmptyMessagesView: View { - let session: RemoteNodeSessionDTO + let session: RemoteNodeSessionDTO - var body: some View { - VStack(spacing: 16) { - NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 80) + var body: some View { + VStack(spacing: 16) { + NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 80) - Text(session.name) - .font(.title2) - .bold() + Text(session.name) + .font(.title2) + .bold() - Text(L10n.RemoteNodes.RemoteNodes.Room.noMessagesYet) - .foregroundStyle(.secondary) + Text(L10n.RemoteNodes.RemoteNodes.Room.noMessagesYet) + .foregroundStyle(.secondary) - if session.canPost { - Text(L10n.RemoteNodes.RemoteNodes.Room.beFirstToPost) - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() + if session.canPost { + Text(L10n.RemoteNodes.RemoteNodes.Room.beFirstToPost) + .font(.caption) + .foregroundStyle(.tertiary) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } } // MARK: - Room Status Banner private struct RoomStatusBanner: View { - let icon: String - let title: String - let hint: String - let style: AnyShapeStyle - let isBold: Bool - let action: () -> Void + let icon: String + let title: String + let hint: String + let style: AnyShapeStyle + let isBold: Bool + let action: () -> Void - var body: some View { - Button(action: action) { - VStack(spacing: 2) { - HStack { - Image(systemName: icon) - Text(title) - } - .bold(isBold) - Text(hint) - .font(.caption) - } - .font(.subheadline) - .foregroundStyle(style) - .frame(maxWidth: .infinity) - .padding() - .background(.bar) + var body: some View { + Button(action: action) { + VStack(spacing: 2) { + HStack { + Image(systemName: icon) + Text(title) } - .accessibilityLabel(title) - .accessibilityHint(hint) + .bold(isBold) + Text(hint) + .font(.caption) + } + .font(.subheadline) + .foregroundStyle(style) + .frame(maxWidth: .infinity) + .padding() + .background(.bar) } + .accessibilityLabel(title) + .accessibilityHint(hint) + } } #Preview { - NavigationStack { - RoomConversationView( - session: RemoteNodeSessionDTO( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Room", - role: .roomServer, - isConnected: true, - permissionLevel: .readWrite - ) - ) - } - .environment(\.appState, AppState()) + NavigationStack { + RoomConversationView( + session: RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + role: .roomServer, + isConnected: true, + permissionLevel: .readWrite + ) + ) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift index 20a76dc7..5b4d59aa 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift @@ -1,221 +1,231 @@ -import SwiftUI import MC1Services +import SwiftUI /// ViewModel for room conversation operations @Observable @MainActor final class RoomConversationViewModel { - - // MARK: - Properties - - /// Current room session - var session: RemoteNodeSessionDTO? - - /// Room messages - var messages: [RoomMessageDTO] = [] - - /// Loading state - var isLoading = false - - /// Whether data has been loaded at least once (prevents empty state flash) - var hasLoadedOnce = false - - /// Error message if any - var errorMessage: String? - - /// Message text being composed - var composingText = "" - - /// Whether a message is being sent - var isSending = false - - // MARK: - Dependencies - - private var roomServerServiceProvider: @MainActor () -> RoomServerService? = { nil } - var roomServerService: RoomServerService? { roomServerServiceProvider() } - private var dataStoreProvider: @MainActor () -> DataStore? = { nil } - var dataStore: DataStore? { dataStoreProvider() } - private var syncCoordinatorProvider: @MainActor () -> SyncCoordinator? = { nil } - var syncCoordinator: SyncCoordinator? { syncCoordinatorProvider() } - private var notificationServiceProvider: @MainActor () -> NotificationService? = { nil } - var notificationService: NotificationService? { notificationServiceProvider() } - - /// Pending coalesced reload spawned by `handleEvent`. Non-nil while a reload - /// is scheduled but not yet fired, so a burst of room events triggers a - /// single `loadMessages` instead of one per event. - private var reloadTask: Task? - - /// Debounce window before a coalesced reload fires. Long enough to batch - /// the typical LoRa-paced room event burst, short enough that user-visible - /// state still feels fresh. - private static let reloadDebounce: Duration = .milliseconds(50) - - // MARK: - Initialization - - init() {} - - /// Nil services mirror a disconnected state; operations then no-op. - func configure( - roomServerService: @escaping @MainActor () -> RoomServerService?, - dataStore: @escaping @MainActor () -> DataStore?, - syncCoordinator: @escaping @MainActor () -> SyncCoordinator?, - notificationService: @escaping @MainActor () -> NotificationService? - ) { - self.roomServerServiceProvider = roomServerService - self.dataStoreProvider = dataStore - self.syncCoordinatorProvider = syncCoordinator - self.notificationServiceProvider = notificationService + // MARK: - Properties + + /// Current room session + var session: RemoteNodeSessionDTO? + + /// Room messages + var messages: [RoomMessageDTO] = [] + + /// Loading state + var isLoading = false + + /// Whether data has been loaded at least once (prevents empty state flash) + var hasLoadedOnce = false + + /// Error message if any + var errorMessage: String? + + /// Message text being composed + var composingText = "" + + /// Whether a message is being sent + var isSending = false + + // MARK: - Dependencies + + private var roomServerServiceProvider: @MainActor () -> RoomServerService? = { nil } + var roomServerService: RoomServerService? { + roomServerServiceProvider() + } + + private var dataStoreProvider: @MainActor () -> DataStore? = { nil } + var dataStore: DataStore? { + dataStoreProvider() + } + + private var syncCoordinatorProvider: @MainActor () -> SyncCoordinator? = { nil } + var syncCoordinator: SyncCoordinator? { + syncCoordinatorProvider() + } + + private var notificationServiceProvider: @MainActor () -> NotificationService? = { nil } + var notificationService: NotificationService? { + notificationServiceProvider() + } + + /// Pending coalesced reload spawned by `handleEvent`. Non-nil while a reload + /// is scheduled but not yet fired, so a burst of room events triggers a + /// single `loadMessages` instead of one per event. + private var reloadTask: Task? + + /// Debounce window before a coalesced reload fires. Long enough to batch + /// the typical LoRa-paced room event burst, short enough that user-visible + /// state still feels fresh. + private static let reloadDebounce: Duration = .milliseconds(50) + + // MARK: - Initialization + + init() {} + + /// Nil services mirror a disconnected state; operations then no-op. + func configure( + roomServerService: @escaping @MainActor () -> RoomServerService?, + dataStore: @escaping @MainActor () -> DataStore?, + syncCoordinator: @escaping @MainActor () -> SyncCoordinator?, + notificationService: @escaping @MainActor () -> NotificationService? + ) { + roomServerServiceProvider = roomServerService + dataStoreProvider = dataStore + syncCoordinatorProvider = syncCoordinator + notificationServiceProvider = notificationService + } + + // MARK: - Messages + + /// Load messages for the current session + func loadMessages(for session: RemoteNodeSessionDTO) async { + guard let roomServerService else { return } + let notificationService = notificationService + let syncCoordinator = syncCoordinator + + self.session = session + isLoading = true + errorMessage = nil + + do { + messages = try await roomServerService.fetchMessages(sessionID: session.id) + + // Clear unread count, remove any delivered notifications for this + // room still in the tray, and update the badge + try await roomServerService.markAsRead(sessionID: session.id) + await notificationService?.removeDeliveredNotifications(forRoomSessionID: session.id) + await notificationService?.updateBadgeCount() + syncCoordinator?.notifyConversationsChanged() + } catch { + errorMessage = error.userFacingMessage } - // MARK: - Messages - - /// Load messages for the current session - func loadMessages(for session: RemoteNodeSessionDTO) async { - guard let roomServerService = roomServerService else { return } - let notificationService = notificationService - let syncCoordinator = syncCoordinator - - self.session = session - isLoading = true - errorMessage = nil - - do { - messages = try await roomServerService.fetchMessages(sessionID: session.id) - - // Clear unread count, remove any delivered notifications for this - // room still in the tray, and update the badge - try await roomServerService.markAsRead(sessionID: session.id) - await notificationService?.removeDeliveredNotifications(forRoomSessionID: session.id) - await notificationService?.updateBadgeCount() - syncCoordinator?.notifyConversationsChanged() - } catch { - errorMessage = error.userFacingMessage - } - - hasLoadedOnce = true - isLoading = false + hasLoadedOnce = true + isLoading = false + } + + /// Optimistically append a message if not already present. + /// Called synchronously before async reload to ensure ChatTableView + /// sees the new count immediately for unread tracking. + func appendMessageIfNew(_ message: RoomMessageDTO) { + guard !messages.contains(where: { $0.id == message.id }) else { return } + messages.append(message) + } + + /// Send a message to the current room + func sendMessage(text: String) async { + guard let session, + let roomServerService, + !text.isEmpty else { + composingText = text + return } - /// Optimistically append a message if not already present. - /// Called synchronously before async reload to ensure ChatTableView - /// sees the new count immediately for unread tracking. - func appendMessageIfNew(_ message: RoomMessageDTO) { - guard !messages.contains(where: { $0.id == message.id }) else { return } - messages.append(message) - } - - /// Send a message to the current room - func sendMessage(text: String) async { - guard let session, - let roomServerService, - !text.isEmpty else { - composingText = text - return - } - - isSending = true - errorMessage = nil + isSending = true + errorMessage = nil - do { - let message = try await roomServerService.postMessage(sessionID: session.id, text: text) + do { + let message = try await roomServerService.postMessage(sessionID: session.id, text: text) - // Add to local array - messages.append(message) - } catch { - errorMessage = error.userFacingMessage - } - - isSending = false + // Add to local array + messages.append(message) + } catch { + errorMessage = error.userFacingMessage } - /// Refresh session state from database - func refreshSession() async { - guard let session, let dataStore else { return } + isSending = false + } - if let updated = try? await dataStore.fetchRemoteNodeSession(id: session.id) { - self.session = updated - } - } + /// Refresh session state from database + func refreshSession() async { + guard let session, let dataStore else { return } - /// Fold a `MessageEvent` from `MessageEventStream` into view-model state. - /// Called on the main actor from a SwiftUI `.task` consumer in - /// `RoomConversationView`. The exhaustive switch is deliberate — a new - /// `MessageEvent` case becomes a compile error rather than a silent skip. - func handleEvent(_ event: MessageEvent) async { - guard let session else { return } - - switch event { - case .roomMessageReceived(let message, let sessionID): - // Optimistic append first so the ChatTableView sees the new count - // immediately for unread tracking, then coalesce the reload so a - // burst of incoming room messages triggers one DB sync, not N. - guard sessionID == session.id else { return } - appendMessageIfNew(message) - scheduleCoalescedReload() - - case .roomMessageStatusUpdated(let messageID): - if messages.contains(where: { $0.id == messageID }) { - scheduleCoalescedReload() - } - - case .roomMessageFailed(let messageID): - if messages.contains(where: { $0.id == messageID }) { - scheduleCoalescedReload() - } - - case .directMessageReceived, .channelMessageReceived, - .messageStatusResolved, .messageResent, .messageFailed, .messageRetrying, - .heardRepeatRecorded, .reactionReceived, .routingChanged: - // Non-Room events are not Room-scoped. Enumerated explicitly so - // adding a new MessageEvent case surfaces as a non-exhaustive - // switch compile error rather than a silent skip. - break - } + if let updated = try? await dataStore.fetchRemoteNodeSession(id: session.id) { + self.session = updated } - - /// Schedules a debounced reload so bursts of room events trigger one - /// `loadMessages` instead of one per event. No-ops if a reload is - /// already pending. - private func scheduleCoalescedReload() { - guard reloadTask == nil else { return } - reloadTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: Self.reloadDebounce) - guard let self else { return } - self.reloadTask = nil - guard let session = self.session else { return } - await self.loadMessages(for: session) - } + } + + /// Fold a `MessageEvent` from `MessageEventStream` into view-model state. + /// Called on the main actor from a SwiftUI `.task` consumer in + /// `RoomConversationView`. The exhaustive switch is deliberate — a new + /// `MessageEvent` case becomes a compile error rather than a silent skip. + func handleEvent(_ event: MessageEvent) async { + guard let session else { return } + + switch event { + case let .roomMessageReceived(message, sessionID): + // Optimistic append first so the ChatTableView sees the new count + // immediately for unread tracking, then coalesce the reload so a + // burst of incoming room messages triggers one DB sync, not N. + guard sessionID == session.id else { return } + appendMessageIfNew(message) + scheduleCoalescedReload() + + case let .roomMessageStatusUpdated(messageID): + if messages.contains(where: { $0.id == messageID }) { + scheduleCoalescedReload() + } + + case let .roomMessageFailed(messageID): + if messages.contains(where: { $0.id == messageID }) { + scheduleCoalescedReload() + } + + case .directMessageReceived, .channelMessageReceived, + .messageStatusResolved, .messageResent, .messageFailed, .messageRetrying, + .heardRepeatRecorded, .reactionReceived, .routingChanged: + // Non-Room events are not Room-scoped. Enumerated explicitly so + // adding a new MessageEvent case surfaces as a non-exhaustive + // switch compile error rather than a silent skip. + break } - - /// Retry sending a failed room message - func retryMessage(id: UUID) async { - guard let roomServerService else { return } - - do { - let updatedMessage = try await roomServerService.retryMessage(id: id) - // Update local array - if let index = messages.firstIndex(where: { $0.id == id }) { - messages[index] = updatedMessage - } - } catch { - errorMessage = error.userFacingMessage - } + } + + /// Schedules a debounced reload so bursts of room events trigger one + /// `loadMessages` instead of one per event. No-ops if a reload is + /// already pending. + private func scheduleCoalescedReload() { + guard reloadTask == nil else { return } + reloadTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: Self.reloadDebounce) + guard let self else { return } + reloadTask = nil + guard let session else { return } + await loadMessages(for: session) + } + } + + /// Retry sending a failed room message + func retryMessage(id: UUID) async { + guard let roomServerService else { return } + + do { + let updatedMessage = try await roomServerService.retryMessage(id: id) + // Update local array + if let index = messages.firstIndex(where: { $0.id == id }) { + messages[index] = updatedMessage + } + } catch { + errorMessage = error.userFacingMessage } + } - // MARK: - Timestamp Helpers + // MARK: - Timestamp Helpers - /// Time gap (in seconds) that breaks message grouping for timestamps. - static let messageGroupingGapSeconds = 300 + /// Time gap (in seconds) that breaks message grouping for timestamps. + static let messageGroupingGapSeconds = 300 - /// Determines if a timestamp should be shown for a message at the given index. - /// Shows timestamp for first message or when there's a gap > 5 minutes. - static func shouldShowTimestamp(at index: Int, in messages: [RoomMessageDTO]) -> Bool { - guard index > 0 else { return true } + /// Determines if a timestamp should be shown for a message at the given index. + /// Shows timestamp for first message or when there's a gap > 5 minutes. + static func shouldShowTimestamp(at index: Int, in messages: [RoomMessageDTO]) -> Bool { + guard index > 0 else { return true } - let currentMessage = messages[index] - let previousMessage = messages[index - 1] + let currentMessage = messages[index] + let previousMessage = messages[index - 1] - let gap = abs(Int(currentMessage.timestamp) - Int(previousMessage.timestamp)) - return gap > messageGroupingGapSeconds - } + let gap = abs(Int(currentMessage.timestamp) - Int(previousMessage.timestamp)) + return gap > messageGroupingGapSeconds + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift index 4cce1a71..228af92b 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift @@ -1,7 +1,7 @@ /// An action a user can take on a room message from its long-press sheet. enum RoomMessageAction { - case copy - case reply - case sendDM - case sendAgain + case copy + case reply + case sendDM + case sendAgain } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift index a0803c00..7d884bea 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift @@ -4,13 +4,13 @@ import MC1Services /// Computed once at presentation so the buttons stay stable if the session's /// permission changes while the sheet is open. struct RoomMessageActionAvailability { - let canReply: Bool - let canSendDM: Bool - let canSendAgain: Bool + let canReply: Bool + let canSendDM: Bool + let canSendAgain: Bool - init(message: RoomMessageDTO, session: RemoteNodeSessionDTO) { - canReply = !message.isFromSelf && session.canPost - canSendDM = !message.isFromSelf && message.authorName != nil - canSendAgain = message.isFromSelf - } + init(message: RoomMessageDTO, session: RemoteNodeSessionDTO) { + canReply = !message.isFromSelf && session.canPost + canSendDM = !message.isFromSelf && message.authorName != nil + canSendAgain = message.isFromSelf + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift index e574c7ef..76eff048 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift @@ -5,183 +5,186 @@ import SwiftUI /// chat `MessageActionsSheet` layout but carries no reactions and only the /// metadata a server-pushed room message actually has. struct RoomMessageActionsSheet: View { - let message: RoomMessageDTO - let availability: RoomMessageActionAvailability - let onAction: (RoomMessageAction) -> Void - - @Environment(\.dismiss) private var dismiss - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote - - var body: some View { + let message: RoomMessageDTO + let availability: RoomMessageActionAvailability + let onAction: (RoomMessageAction) -> Void + + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote + + var body: some View { + VStack(spacing: 0) { + header + Divider() + ScrollView { VStack(spacing: 0) { - header - Divider() - ScrollView { - VStack(spacing: 0) { - buttons - details - } - } + buttons + details } - .presentationDetents( - (horizontalSizeClass == .regular || dynamicTypeSize.isAccessibilitySize) - ? [.large] : [.medium, .large] - ) - .presentationContentInteraction(.scrolls) - .presentationDragIndicator(.visible) - .presentationBackground(Color(.systemBackground)) + } } - - private func performAction(_ action: RoomMessageAction) { - onAction(action) - dismiss() + .presentationDetents( + (horizontalSizeClass == .regular || dynamicTypeSize.isAccessibilitySize) + ? [.large] : [.medium, .large] + ) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackground(Color(.systemBackground)) + } + + private func performAction(_ action: RoomMessageAction) { + onAction(action) + dismiss() + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(message.authorDisplayName) + .font(.subheadline) + .bold() + Spacer() + Text(message.date, format: .dateTime.hour().minute()) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Text(message.text) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) } - - private var header: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(message.authorDisplayName) - .font(.subheadline) - .bold() - Spacer() - Text(message.date, format: .dateTime.hour().minute()) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Text(message.text) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var buttons: some View { + if availability.canReply { + ActionButton( + title: replyWithQuote + ? L10n.Chats.Chats.Message.Action.reply + : L10n.Chats.Chats.Message.Action.mention, + icon: "arrowshape.turn.up.left", + action: { performAction(.reply) } + ) } - @ViewBuilder - private var buttons: some View { - if availability.canReply { - ActionButton( - title: replyWithQuote - ? L10n.Chats.Chats.Message.Action.reply - : L10n.Chats.Chats.Message.Action.mention, - icon: "arrowshape.turn.up.left", - action: { performAction(.reply) } - ) - } - - if availability.canSendDM { - ActionButton( - title: L10n.Chats.Chats.Message.Action.sendDM, - icon: "bubble.left.and.bubble.right", - action: { performAction(.sendDM) } - ) - } - - ActionButton( - title: L10n.Chats.Chats.Message.Action.copy, - icon: "doc.on.doc", - action: { performAction(.copy) } - ) - - if availability.canSendAgain { - ActionButton( - title: L10n.Chats.Chats.Message.Action.sendAgain, - icon: "arrow.uturn.forward", - action: { performAction(.sendAgain) } - ) - } + if availability.canSendDM { + ActionButton( + title: L10n.Chats.Chats.Message.Action.sendDM, + icon: "bubble.left.and.bubble.right", + action: { performAction(.sendDM) } + ) } - private var details: some View { - VStack(alignment: .leading, spacing: 0) { - Text(L10n.Chats.Chats.Message.Action.details) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 4) - - if message.isFromSelf { - outgoingRows - } else { - incomingRows - } - } - } + ActionButton( + title: L10n.Chats.Chats.Message.Action.copy, + icon: "doc.on.doc", + action: { performAction(.copy) } + ) - @ViewBuilder - private var incomingRows: some View { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( - message.date.formatted(date: .abbreviated, time: .standard))) - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.received( - message.createdAt.formatted(date: .abbreviated, time: .standard))) - ActionInfoRow(text: message.localizedStatusText) + if availability.canSendAgain { + ActionButton( + title: L10n.Chats.Chats.Message.Action.sendAgain, + icon: "arrow.uturn.forward", + action: { performAction(.sendAgain) } + ) } - - @ViewBuilder - private var outgoingRows: some View { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( - message.date.formatted(date: .abbreviated, time: .standard))) - ActionInfoRow(text: message.localizedStatusText) - - if let roundTripTime = message.roundTripTime { - ActionInfoRow(text: L10n.Chats.Chats.Message.Info.roundTrip(Int(roundTripTime))) - } + } + + private var details: some View { + VStack(alignment: .leading, spacing: 0) { + Text(L10n.Chats.Chats.Message.Action.details) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 4) + + if message.isFromSelf { + outgoingRows + } else { + incomingRows + } } + } + + @ViewBuilder + private var incomingRows: some View { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( + message.date.formatted(date: .abbreviated, time: .standard) + )) + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.received( + message.createdAt.formatted(date: .abbreviated, time: .standard) + )) + ActionInfoRow(text: message.localizedStatusText) + } + + @ViewBuilder + private var outgoingRows: some View { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( + message.date.formatted(date: .abbreviated, time: .standard) + )) + ActionInfoRow(text: message.localizedStatusText) + + if let roundTripTime = message.roundTripTime { + ActionInfoRow(text: L10n.Chats.Chats.Message.Info.roundTrip(Int(roundTripTime))) + } + } } #Preview("Incoming") { - let message = RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x55, count: 4), - authorName: "Alice", - text: "Anyone near the north trailhead? Roads are washed out past the bridge.", - timestamp: UInt32(Date().timeIntervalSince1970) - ) - let session = RemoteNodeSessionDTO( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Room", - role: .roomServer, - isConnected: true, - permissionLevel: .readWrite + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x55, count: 4), + authorName: "Alice", + text: "Anyone near the north trailhead? Roads are washed out past the bridge.", + timestamp: UInt32(Date().timeIntervalSince1970) + ) + let session = RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + role: .roomServer, + isConnected: true, + permissionLevel: .readWrite + ) + return Color.clear.sheet(isPresented: .constant(true)) { + RoomMessageActionsSheet( + message: message, + availability: RoomMessageActionAvailability(message: message, session: session), + onAction: { _ in } ) - return Color.clear.sheet(isPresented: .constant(true)) { - RoomMessageActionsSheet( - message: message, - availability: RoomMessageActionAvailability(message: message, session: session), - onAction: { _ in } - ) - } + } } #Preview("Outgoing") { - let message = RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x42, count: 4), - authorName: "Me", - text: "Copy that, heading your way.", - timestamp: UInt32(Date().timeIntervalSince1970), - isFromSelf: true, - status: .delivered, - roundTripTime: 842 + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x42, count: 4), + authorName: "Me", + text: "Copy that, heading your way.", + timestamp: UInt32(Date().timeIntervalSince1970), + isFromSelf: true, + status: .delivered, + roundTripTime: 842 + ) + let session = RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + role: .roomServer, + isConnected: true, + permissionLevel: .readWrite + ) + return Color.clear.sheet(isPresented: .constant(true)) { + RoomMessageActionsSheet( + message: message, + availability: RoomMessageActionAvailability(message: message, session: session), + onAction: { _ in } ) - let session = RemoteNodeSessionDTO( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Room", - role: .roomServer, - isConnected: true, - permissionLevel: .readWrite - ) - return Color.clear.sheet(isPresented: .constant(true)) { - RoomMessageActionsSheet( - message: message, - availability: RoomMessageActionAvailability(message: message, session: session), - onAction: { _ in } - ) - } + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift index 76ede30f..8cd13c97 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift @@ -1,235 +1,237 @@ -import SwiftUI import MC1Services +import SwiftUI /// Message bubble for room server messages struct RoomMessageBubble: View { - let message: RoomMessageDTO - let showTimestamp: Bool - var onRetry: (() -> Void)? - var onLongPress: ((RoomMessageDTO) -> Void)? + let message: RoomMessageDTO + let showTimestamp: Bool + var onRetry: (() -> Void)? + var onLongPress: ((RoomMessageDTO) -> Void)? - @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @State private var isLongPressing = false - @State private var longPressTrigger = 0 + @State private var isLongPressing = false + @State private var longPressTrigger = 0 - private var isFromSelf: Bool { message.isFromSelf } + private var isFromSelf: Bool { + message.isFromSelf + } - var body: some View { - VStack(spacing: 4) { - if showTimestamp { - makeTimestampView() - } + var body: some View { + VStack(spacing: 4) { + if showTimestamp { + makeTimestampView() + } - HStack(alignment: .bottom, spacing: 8) { - if isFromSelf { - Spacer(minLength: 60) - } + HStack(alignment: .bottom, spacing: 8) { + if isFromSelf { + Spacer(minLength: 60) + } - VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 2) { - makeBubbleContent() - makeStatusIndicator() - } + VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 2) { + makeBubbleContent() + makeStatusIndicator() + } - if !isFromSelf { - Spacer(minLength: 60) - } - } - .padding(.horizontal) + if !isFromSelf { + Spacer(minLength: 60) } + } + .padding(.horizontal) } + } - // MARK: - Subviews + // MARK: - Subviews - private func makeTimestampView() -> some View { - TimestampView(date: message.date) - } - - private func makeBubbleContent() -> some View { - BubbleContent( - message: message, - isFromSelf: isFromSelf, - highContrast: colorSchemeContrast == .increased - ) - .messageBubbleLongPressGesture( - isPressing: $isLongPressing, - trigger: $longPressTrigger, - onFire: { onLongPress?(message) } - ) - .messageBubbleLongPressEffect(isPressing: isLongPressing, trigger: longPressTrigger) - } + private func makeTimestampView() -> some View { + TimestampView(date: message.date) + } - private func makeStatusIndicator() -> some View { - StatusIndicator( - message: message, - isFromSelf: isFromSelf, - statusText: message.localizedStatusText, - accessibilityStatusLabel: message.accessibilityStatusLabel, - onRetry: onRetry - ) - } + private func makeBubbleContent() -> some View { + BubbleContent( + message: message, + isFromSelf: isFromSelf, + highContrast: colorSchemeContrast == .increased + ) + .messageBubbleLongPressGesture( + isPressing: $isLongPressing, + trigger: $longPressTrigger, + onFire: { onLongPress?(message) } + ) + .messageBubbleLongPressEffect(isPressing: isLongPressing, trigger: longPressTrigger) + } + + private func makeStatusIndicator() -> some View { + StatusIndicator( + message: message, + isFromSelf: isFromSelf, + statusText: message.localizedStatusText, + accessibilityStatusLabel: message.accessibilityStatusLabel, + onRetry: onRetry + ) + } } // MARK: - Timestamp View private struct TimestampView: View { - let date: Date - - var body: some View { - Text(date, format: .dateTime.month().day().hour().minute()) - .font(.caption2) - .foregroundStyle(.secondary) - .padding(.vertical, 8) - } + let date: Date + + var body: some View { + Text(date, format: .dateTime.month().day().hour().minute()) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.vertical, 8) + } } // MARK: - Bubble Content private struct BubbleContent: View { - let message: RoomMessageDTO - let isFromSelf: Bool - let highContrast: Bool - - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - - private var bubbleBackground: Color { - if isFromSelf { - if message.status == .failed { - return AppColors.Message.outgoingBubbleFailed(highContrast: highContrast) - } - return theme.accentColor // matches UnifiedMessageBubble.resolvedBubbleColor - } - return theme.incomingBubbleColor + let message: RoomMessageDTO + let isFromSelf: Bool + let highContrast: Bool + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + private var bubbleBackground: Color { + if isFromSelf { + if message.status == .failed { + return AppColors.Message.outgoingBubbleFailed(highContrast: highContrast) + } + return theme.accentColor // matches UnifiedMessageBubble.resolvedBubbleColor } - - private var textColor: Color { - isFromSelf ? theme.outgoingTextColor : .primary - } - - var body: some View { - VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 4) { - if !isFromSelf { - Text(message.authorDisplayName) - .font(.footnote) - .bold() - .foregroundStyle(theme.identityColor( - forName: message.authorDisplayName, - colorScheme: colorScheme, - contrast: highContrast ? .increased : .standard - )) - .padding(.horizontal, 12) - } - - MessageText(message.text, baseColor: textColor, isOutgoing: isFromSelf) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(bubbleBackground) - .clipShape(.rect(cornerRadius: 16, style: .continuous)) - } + return theme.incomingBubbleColor + } + + private var textColor: Color { + isFromSelf ? theme.outgoingTextColor : .primary + } + + var body: some View { + VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 4) { + if !isFromSelf { + Text(message.authorDisplayName) + .font(.footnote) + .bold() + .foregroundStyle(theme.identityColor( + forName: message.authorDisplayName, + colorScheme: colorScheme, + contrast: highContrast ? .increased : .standard + )) + .padding(.horizontal, 12) + } + + MessageText(message.text, baseColor: textColor, isOutgoing: isFromSelf) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(bubbleBackground) + .clipShape(.rect(cornerRadius: 16, style: .continuous)) } + } } // MARK: - Status Indicator private struct StatusIndicator: View { - let message: RoomMessageDTO - let isFromSelf: Bool - let statusText: String - let accessibilityStatusLabel: String - let onRetry: (() -> Void)? - - var body: some View { - if isFromSelf { - HStack(spacing: 4) { - if message.status == .failed, let onRetry { - Button { - onRetry() - } label: { - HStack(spacing: 2) { - Image(systemName: "arrow.clockwise") - Text(L10n.Chats.Chats.Message.Status.retry) - } - .font(.caption2) - .foregroundStyle(.blue) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Chats.Chats.Message.Status.retry) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Room.Message.retryHint) - } - - Text(statusText) - .font(.caption2) - .foregroundStyle(.secondary) - - if message.status == .failed { - Image(systemName: "exclamationmark.circle") - .font(.caption2) - .foregroundStyle(.red) - } + let message: RoomMessageDTO + let isFromSelf: Bool + let statusText: String + let accessibilityStatusLabel: String + let onRetry: (() -> Void)? + + var body: some View { + if isFromSelf { + HStack(spacing: 4) { + if message.status == .failed, let onRetry { + Button { + onRetry() + } label: { + HStack(spacing: 2) { + Image(systemName: "arrow.clockwise") + Text(L10n.Chats.Chats.Message.Status.retry) } - .padding(.trailing, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityStatusLabel) + .font(.caption2) + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Chats.Chats.Message.Status.retry) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Room.Message.retryHint) + } + + Text(statusText) + .font(.caption2) + .foregroundStyle(.secondary) + + if message.status == .failed { + Image(systemName: "exclamationmark.circle") + .font(.caption2) + .foregroundStyle(.red) } + } + .padding(.trailing, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityStatusLabel) } + } } #Preview("Self Message") { - RoomMessageBubble( - message: RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x42, count: 4), - authorName: "Me", - text: "Hello from me!", - timestamp: UInt32(Date().timeIntervalSince1970), - isFromSelf: true - ), - showTimestamp: true - ) + RoomMessageBubble( + message: RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x42, count: 4), + authorName: "Me", + text: "Hello from me!", + timestamp: UInt32(Date().timeIntervalSince1970), + isFromSelf: true + ), + showTimestamp: true + ) } #Preview("Other Message") { - RoomMessageBubble( - message: RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x55, count: 4), - authorName: "Alice", - text: "Hello from Alice!", - timestamp: UInt32(Date().timeIntervalSince1970), - isFromSelf: false - ), - showTimestamp: true - ) + RoomMessageBubble( + message: RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x55, count: 4), + authorName: "Alice", + text: "Hello from Alice!", + timestamp: UInt32(Date().timeIntervalSince1970), + isFromSelf: false + ), + showTimestamp: true + ) } #Preview("Pending Message") { - RoomMessageBubble( - message: RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x42, count: 4), - authorName: "Me", - text: "Sending...", - timestamp: UInt32(Date().timeIntervalSince1970), - isFromSelf: true, - status: .pending - ), - showTimestamp: true - ) + RoomMessageBubble( + message: RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x42, count: 4), + authorName: "Me", + text: "Sending...", + timestamp: UInt32(Date().timeIntervalSince1970), + isFromSelf: true, + status: .pending + ), + showTimestamp: true + ) } #Preview("Failed Message") { - RoomMessageBubble( - message: RoomMessageDTO( - sessionID: UUID(), - authorKeyPrefix: Data(repeating: 0x42, count: 4), - authorName: "Me", - text: "This failed to send", - timestamp: UInt32(Date().timeIntervalSince1970), - isFromSelf: true, - status: .failed - ), - showTimestamp: true, - onRetry: { print("Retry tapped") } - ) + RoomMessageBubble( + message: RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data(repeating: 0x42, count: 4), + authorName: "Me", + text: "This failed to send", + timestamp: UInt32(Date().timeIntervalSince1970), + isFromSelf: true, + status: .failed + ), + showTimestamp: true, + onRetry: { print("Retry tapped") } + ) } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageDTO+Status.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageDTO+Status.swift index 36a20aeb..2d5414ac 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageDTO+Status.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageDTO+Status.swift @@ -3,29 +3,29 @@ import MC1Services /// Localized status text lives in the app target because `L10n` is generated /// here, not in `MC1Services` where `RoomMessageDTO` is declared. extension RoomMessageDTO { - var localizedStatusText: String { - switch status { - case .pending, .sending: - return L10n.Chats.Chats.Message.Status.sending - case .sent: - return L10n.Chats.Chats.Message.Status.sent - case .delivered: - return L10n.Chats.Chats.Message.Status.delivered - case .failed: - return L10n.Chats.Chats.Message.Status.failed - case .retrying: - return L10n.Chats.Chats.Message.Status.retrying - } + var localizedStatusText: String { + switch status { + case .pending, .sending: + L10n.Chats.Chats.Message.Status.sending + case .sent: + L10n.Chats.Chats.Message.Status.sent + case .delivered: + L10n.Chats.Chats.Message.Status.delivered + case .failed: + L10n.Chats.Chats.Message.Status.failed + case .retrying: + L10n.Chats.Chats.Message.Status.retrying } + } - var accessibilityStatusLabel: String { - switch status { - case .failed: - return L10n.RemoteNodes.RemoteNodes.Room.Message.Status.failedLabel - case .pending, .sending, .retrying: - return L10n.RemoteNodes.RemoteNodes.Room.Message.Status.sendingLabel - default: - return L10n.RemoteNodes.RemoteNodes.Room.Message.Status.deliveredLabel - } + var accessibilityStatusLabel: String { + switch status { + case .failed: + L10n.RemoteNodes.RemoteNodes.Room.Message.Status.failedLabel + case .pending, .sending, .retrying: + L10n.RemoteNodes.RemoteNodes.Room.Message.Status.sendingLabel + default: + L10n.RemoteNodes.RemoteNodes.Room.Message.Status.deliveredLabel } + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift b/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift index 4b199213..3d353b16 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift @@ -1,328 +1,328 @@ -import SwiftUI -import MC1Services import CoreLocation +import MC1Services +import SwiftUI struct RoomSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @FocusState private var focusedField: NodeSettingsField? + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @FocusState private var focusedField: NodeSettingsField? - let session: RemoteNodeSessionDTO - @State private var viewModel = RoomSettingsViewModel() - @State private var statusViewModel = RoomStatusViewModel() - @State private var managementTab: NodeManagementTab = .settings - @State private var cliViewModel = NodeCLIViewModel() - @State private var showRebootConfirmation = false - @State private var showingLocationPicker = false - @State private var telemetryConfigured = false + let session: RemoteNodeSessionDTO + @State private var viewModel = RoomSettingsViewModel() + @State private var statusViewModel = RoomStatusViewModel() + @State private var managementTab: NodeManagementTab = .settings + @State private var cliViewModel = NodeCLIViewModel() + @State private var showRebootConfirmation = false + @State private var showingLocationPicker = false + @State private var telemetryConfigured = false - var body: some View { - // ZStack, not Group: a stable container keeps the navigation title hosted on one - // view across segment switches. Group would re-host it on each branch, animating - // a nav-bar item transition. - ZStack { - switch managementTab { - case .settings: settingsForm - case .cli: NodeCLIView(viewModel: cliViewModel) - case .telemetry: - RoomStatusContent( - viewModel: statusViewModel, - session: session, - connectionState: appState.connectionState, - connectedDeviceID: appState.connectedDevice?.radioID - ) - } - } - .animation(nil, value: managementTab) - .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomSettings.title) - .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .top, spacing: 0) { - if session.isAdmin { - NodeManagementTabPicker(selection: $managementTab) - .frame(maxWidth: .infinity) - .pinnedFilterHeaderBackground(theme) - } - } - .task { - await viewModel.configure( - roomAdminService: { appState.services?.roomAdminService }, - session: session - ) - if let send = viewModel.makeNodeCLISendClosure(session: session) { - cliViewModel.configure(sessionName: session.name, sendRawCommand: send) - } - } - .onChange(of: managementTab) { _, newTab in - guard newTab == .telemetry, !telemetryConfigured else { return } - telemetryConfigured = true - // Configure the status VM on first Telemetry reveal rather than on open: - // its handlers populate only the status/telemetry slots, leaving the settings VM's - // CLI handler intact for the Settings/CLI surface. Guarded by telemetryConfigured - // because a segment switch recreates only the content subtree, so this must not - // re-run or duplicate handler registration. - statusViewModel.configure( - roomAdminService: { appState.services?.roomAdminService }, - contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } - ) - Task { - await statusViewModel.registerHandlers() - if let radioID = appState.connectedDevice?.radioID { - await statusViewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) - } - } - } - .onDisappear { - Task { - await statusViewModel.clearStatusHandlers() - await viewModel.cleanup() - } - } - .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) { } - } message: { - Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) - } - .sheet(isPresented: $showingLocationPicker) { - LocationPickerView( - initialCoordinate: CLLocationCoordinate2D( - latitude: viewModel.helper.latitude ?? 0, - longitude: viewModel.helper.longitude ?? 0 - ) - ) { coordinate in - viewModel.helper.setLocationFromPicker( - latitude: coordinate.latitude, - longitude: coordinate.longitude - ) - } + var body: some View { + // ZStack, not Group: a stable container keeps the navigation title hosted on one + // view across segment switches. Group would re-host it on each branch, animating + // a nav-bar item transition. + ZStack { + switch managementTab { + case .settings: settingsForm + case .cli: NodeCLIView(viewModel: cliViewModel) + case .telemetry: + RoomStatusContent( + viewModel: statusViewModel, + session: session, + connectionState: appState.connectionState, + connectedDeviceID: appState.connectedDevice?.radioID + ) + } + } + .animation(nil, value: managementTab) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomSettings.title) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .top, spacing: 0) { + if session.isAdmin { + NodeManagementTabPicker(selection: $managementTab) + .frame(maxWidth: .infinity) + .pinnedFilterHeaderBackground(theme) + } + } + .task { + await viewModel.configure( + roomAdminService: { appState.services?.roomAdminService }, + session: session + ) + if let send = viewModel.makeNodeCLISendClosure(session: session) { + cliViewModel.configure(sessionName: session.name, sendRawCommand: send) + } + } + .onChange(of: managementTab) { _, newTab in + guard newTab == .telemetry, !telemetryConfigured else { return } + telemetryConfigured = true + // Configure the status VM on first Telemetry reveal rather than on open: + // its handlers populate only the status/telemetry slots, leaving the settings VM's + // CLI handler intact for the Settings/CLI surface. Guarded by telemetryConfigured + // because a segment switch recreates only the content subtree, so this must not + // re-run or duplicate handler registration. + statusViewModel.configure( + roomAdminService: { appState.services?.roomAdminService }, + contactService: { appState.services?.contactService }, + nodeSnapshotService: { appState.services?.nodeSnapshotService } + ) + Task { + await statusViewModel.registerHandlers() + if let radioID = appState.connectedDevice?.radioID { + await statusViewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) } + } } + .onDisappear { + Task { + await statusViewModel.clearStatusHandlers() + await viewModel.cleanup() + } + } + .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) {} + } message: { + Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) + } + .sheet(isPresented: $showingLocationPicker) { + LocationPickerView( + initialCoordinate: CLLocationCoordinate2D( + latitude: viewModel.helper.latitude ?? 0, + longitude: viewModel.helper.longitude ?? 0 + ) + ) { coordinate in + viewModel.helper.setLocationFromPicker( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + } + } + } - private var settingsForm: some View { - Form { - NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) - RoomAccessSection(viewModel: viewModel, focusedField: $focusedField) - NodeRadioSettingsSection( - settings: viewModel.helper, - focusedField: $focusedField, - radioRestartWarning: L10n.RemoteNodes.RemoteNodes.RoomSettings.radioRestartWarning - ) - RoomBehaviorSection(viewModel: viewModel, focusedField: $focusedField) - RemoteNodeIdentitySection( - settings: viewModel.helper, - focusedField: $focusedField, - onPickLocation: { showingLocationPicker = true } - ) - NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) - NodeSecuritySection(settings: viewModel.helper) - NodeDeviceInfoSection(settings: viewModel.helper) - NodeActionsSection( - settings: viewModel.helper, - showRebootConfirmation: $showRebootConfirmation, - rebootConfirmTitle: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootConfirmTitle, - rebootMessage: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootMessage - ) - } - .themedCanvas(theme) - .nodeManagementHeaderTopMargin() - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { - focusedField = nil - } - } + private var settingsForm: some View { + Form { + NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) + RoomAccessSection(viewModel: viewModel, focusedField: $focusedField) + NodeRadioSettingsSection( + settings: viewModel.helper, + focusedField: $focusedField, + radioRestartWarning: L10n.RemoteNodes.RemoteNodes.RoomSettings.radioRestartWarning + ) + RoomBehaviorSection(viewModel: viewModel, focusedField: $focusedField) + RemoteNodeIdentitySection( + settings: viewModel.helper, + focusedField: $focusedField, + onPickLocation: { showingLocationPicker = true } + ) + NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) + NodeSecuritySection(settings: viewModel.helper) + NodeDeviceInfoSection(settings: viewModel.helper) + NodeActionsSection( + settings: viewModel.helper, + showRebootConfirmation: $showRebootConfirmation, + rebootConfirmTitle: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootConfirmTitle, + rebootMessage: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootMessage + ) + } + .themedCanvas(theme) + .nodeManagementHeaderTopMargin() + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { + focusedField = nil } + } } + } } // MARK: - Room Access Section private struct RoomAccessSection: View { - @Bindable var viewModel: RoomSettingsViewModel - var focusedField: FocusState.Binding + @Bindable var viewModel: RoomSettingsViewModel + var focusedField: FocusState.Binding - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsSection, - icon: "person.badge.key", - isExpanded: $viewModel.isRoomAccessExpanded, - isLoaded: { viewModel.roomAccessLoaded }, - isLoading: $viewModel.isLoadingRoomAccess, - hasError: $viewModel.roomAccessError, - onLoad: { await viewModel.fetchRoomAccess() }, - footer: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsFooter - ) { - SecureField(L10n.RemoteNodes.RemoteNodes.RoomSettings.guestPassword, text: Binding( - get: { viewModel.guestPassword ?? "" }, - set: { viewModel.guestPassword = $0 } - )) - .focused(focusedField, equals: .guestPassword) - .disabled(viewModel.guestPassword == nil) - .overlay(alignment: .trailing) { - if viewModel.guestPassword == nil { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingRoomAccess, hasError: viewModel.roomAccessError) - .padding(.trailing, 8) - } - } + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsSection, + icon: "person.badge.key", + isExpanded: $viewModel.isRoomAccessExpanded, + isLoaded: { viewModel.roomAccessLoaded }, + isLoading: $viewModel.isLoadingRoomAccess, + hasError: $viewModel.roomAccessError, + onLoad: { await viewModel.fetchRoomAccess() }, + footer: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsFooter + ) { + SecureField(L10n.RemoteNodes.RemoteNodes.RoomSettings.guestPassword, text: Binding( + get: { viewModel.guestPassword ?? "" }, + set: { viewModel.guestPassword = $0 } + )) + .focused(focusedField, equals: .guestPassword) + .disabled(viewModel.guestPassword == nil) + .overlay(alignment: .trailing) { + if viewModel.guestPassword == nil { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingRoomAccess, hasError: viewModel.roomAccessError) + .padding(.trailing, 8) + } + } - Toggle(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnly, isOn: Binding( - get: { viewModel.allowReadOnly ?? false }, - set: { viewModel.allowReadOnly = $0 } - )) - .disabled(viewModel.allowReadOnly == nil) - .accessibilityValue( - viewModel.allowReadOnly == nil - ? (viewModel.isLoadingRoomAccess ? L10n.RemoteNodes.RemoteNodes.Settings.loading : L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad) - : (viewModel.allowReadOnly == true ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) - ) - .overlay(alignment: .trailing) { - if viewModel.allowReadOnly == nil { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingRoomAccess, hasError: viewModel.roomAccessError) - .padding(.trailing, 60) - .accessibilityHidden(true) - } - } + Toggle(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnly, isOn: Binding( + get: { viewModel.allowReadOnly ?? false }, + set: { viewModel.allowReadOnly = $0 } + )) + .disabled(viewModel.allowReadOnly == nil) + .accessibilityValue( + viewModel.allowReadOnly == nil + ? (viewModel.isLoadingRoomAccess ? L10n.RemoteNodes.RemoteNodes.Settings.loading : L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad) + : (viewModel.allowReadOnly == true ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) + ) + .overlay(alignment: .trailing) { + if viewModel.allowReadOnly == nil { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingRoomAccess, hasError: viewModel.roomAccessError) + .padding(.trailing, 60) + .accessibilityHidden(true) + } + } - Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnlyFooter) - .font(.caption) - .foregroundStyle(.secondary) + Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnlyFooter) + .font(.caption) + .foregroundStyle(.secondary) - Button { - Task { await viewModel.applyRoomAccess() } - } label: { - AsyncActionLabel(isLoading: viewModel.isApplyingRoomAccess, showSuccess: viewModel.roomAccessApplySuccess) { - Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.applyRoomSettings) - .foregroundStyle(viewModel.roomAccessModified ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .disabled(viewModel.isApplyingRoomAccess || viewModel.roomAccessApplySuccess || !viewModel.roomAccessModified) + Button { + Task { await viewModel.applyRoomAccess() } + } label: { + AsyncActionLabel(isLoading: viewModel.isApplyingRoomAccess, showSuccess: viewModel.roomAccessApplySuccess) { + Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.applyRoomSettings) + .foregroundStyle(viewModel.roomAccessModified ? Color.accentColor : .secondary) + .transition(.opacity) } + } + .disabled(viewModel.isApplyingRoomAccess || viewModel.roomAccessApplySuccess || !viewModel.roomAccessModified) } + } } // MARK: - Room Behavior Section private struct RoomBehaviorSection: View { - @Bindable var viewModel: RoomSettingsViewModel - var focusedField: FocusState.Binding + @Bindable var viewModel: RoomSettingsViewModel + var focusedField: FocusState.Binding - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.behavior, - icon: "slider.horizontal.3", - isExpanded: $viewModel.isBehaviorExpanded, - isLoaded: { viewModel.behaviorLoaded }, - isLoading: $viewModel.isLoadingBehavior, - hasError: $viewModel.behaviorError, - onLoad: { await viewModel.fetchBehaviorSettings() }, - footer: L10n.RemoteNodes.RemoteNodes.RoomSettings.behaviorFooter - ) { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.advertInterval0Hop) - Spacer() - if let interval = viewModel.advertIntervalMinutes { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.min, value: Binding( - get: { interval }, - set: { viewModel.advertIntervalMinutes = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .advertInterval) - Text(L10n.RemoteNodes.RemoteNodes.Settings.min) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.behavior, + icon: "slider.horizontal.3", + isExpanded: $viewModel.isBehaviorExpanded, + isLoaded: { viewModel.behaviorLoaded }, + isLoading: $viewModel.isLoadingBehavior, + hasError: $viewModel.behaviorError, + onLoad: { await viewModel.fetchBehaviorSettings() }, + footer: L10n.RemoteNodes.RemoteNodes.RoomSettings.behaviorFooter + ) { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertInterval0Hop) + Spacer() + if let interval = viewModel.advertIntervalMinutes { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.min, value: Binding( + get: { interval }, + set: { viewModel.advertIntervalMinutes = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .advertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.min) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + } + } - if let error = viewModel.advertIntervalError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } + if let error = viewModel.advertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalFlood) - Spacer() - if let interval = viewModel.floodAdvertIntervalHours { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.hrs, value: Binding( - get: { interval }, - set: { viewModel.floodAdvertIntervalHours = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .floodAdvertInterval) - Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalFlood) + Spacer() + if let interval = viewModel.floodAdvertIntervalHours { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hrs, value: Binding( + get: { interval }, + set: { viewModel.floodAdvertIntervalHours = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodAdvertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + } + } - if let error = viewModel.floodAdvertIntervalError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } + if let error = viewModel.floodAdvertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.maxFloodHops) - Spacer() - if let hops = viewModel.floodMaxHops { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.hops, value: Binding( - get: { hops }, - set: { viewModel.floodMaxHops = $0 } - ), format: .number) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .floodMaxHops) - Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) - .foregroundStyle(.secondary) - } else { - SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) - } - } + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.maxFloodHops) + Spacer() + if let hops = viewModel.floodMaxHops { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hops, value: Binding( + get: { hops }, + set: { viewModel.floodMaxHops = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodMaxHops) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) + .foregroundStyle(.secondary) + } else { + SettingsLoadPlaceholder(isLoading: viewModel.isLoadingBehavior, hasError: viewModel.behaviorError) + } + } - if let error = viewModel.floodMaxHopsError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } + if let error = viewModel.floodMaxHopsError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } - Button { - Task { await viewModel.applyBehaviorSettings() } - } label: { - AsyncActionLabel(isLoading: viewModel.isApplyingBehavior, showSuccess: viewModel.behaviorApplySuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyBehaviorSettings) - .foregroundStyle(viewModel.behaviorModified ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .disabled(viewModel.isApplyingBehavior || viewModel.behaviorApplySuccess || !viewModel.behaviorModified) + Button { + Task { await viewModel.applyBehaviorSettings() } + } label: { + AsyncActionLabel(isLoading: viewModel.isApplyingBehavior, showSuccess: viewModel.behaviorApplySuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyBehaviorSettings) + .foregroundStyle(viewModel.behaviorModified ? Color.accentColor : .secondary) + .transition(.opacity) } + } + .disabled(viewModel.isApplyingBehavior || viewModel.behaviorApplySuccess || !viewModel.behaviorModified) } + } } #Preview { - NavigationStack { - RoomSettingsView( - session: RemoteNodeSessionDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Community Room", - role: .roomServer, - latitude: 37.7749, - longitude: -122.4194, - isConnected: true, - permissionLevel: .admin - ) - ) - .environment(\.appState, AppState()) - } + NavigationStack { + RoomSettingsView( + session: RemoteNodeSessionDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Community Room", + role: .roomServer, + latitude: 37.7749, + longitude: -122.4194, + isConnected: true, + permissionLevel: .admin + ) + ) + .environment(\.appState, AppState()) + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomSettingsViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomSettingsViewModel.swift index 3e3f4091..b3414a83 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomSettingsViewModel.swift @@ -1,340 +1,346 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI @Observable @MainActor final class RoomSettingsViewModel { - - // MARK: - Shared Helper - - var helper = NodeSettingsViewModel() - - // MARK: - Room Access (guest password + read-only) - - var guestPassword: String? - var allowReadOnly: Bool? - private var originalGuestPassword: String? - private var originalAllowReadOnly: Bool? - var isLoadingRoomAccess = false - var roomAccessError = false - var isApplyingRoomAccess = false - var roomAccessApplySuccess = false - var isRoomAccessExpanded = false - - var roomAccessLoaded: Bool { guestPassword != nil || allowReadOnly != nil } - - var roomAccessModified: Bool { - (guestPassword != nil && guestPassword != originalGuestPassword) || - (allowReadOnly != nil && allowReadOnly != originalAllowReadOnly) + // MARK: - Shared Helper + + var helper = NodeSettingsViewModel() + + // MARK: - Room Access (guest password + read-only) + + var guestPassword: String? + var allowReadOnly: Bool? + private var originalGuestPassword: String? + private var originalAllowReadOnly: Bool? + var isLoadingRoomAccess = false + var roomAccessError = false + var isApplyingRoomAccess = false + var roomAccessApplySuccess = false + var isRoomAccessExpanded = false + + var roomAccessLoaded: Bool { + guestPassword != nil || allowReadOnly != nil + } + + var roomAccessModified: Bool { + (guestPassword != nil && guestPassword != originalGuestPassword) || + (allowReadOnly != nil && allowReadOnly != originalAllowReadOnly) + } + + // MARK: - Behavior (advert intervals + flood) + + var advertIntervalMinutes: Int? + var floodAdvertIntervalHours: Int? + var floodMaxHops: Int? + private var originalAdvertIntervalMinutes: Int? + private var originalFloodAdvertIntervalHours: Int? + private var originalFloodMaxHops: Int? + var isLoadingBehavior = false + var behaviorError = false + var isApplyingBehavior = false + var behaviorApplySuccess = false + var isBehaviorExpanded = false + + var advertIntervalError: String? + var floodAdvertIntervalError: String? + var floodMaxHopsError: String? + + var behaviorLoaded: Bool { + advertIntervalMinutes != nil || floodAdvertIntervalHours != nil || floodMaxHops != nil + } + + var behaviorModified: Bool { + (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || + (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || + (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + } + + // MARK: - Dependencies + + private var roomAdminServiceProvider: @MainActor () -> RoomAdminService? = { nil } + var roomAdminService: RoomAdminService? { + roomAdminServiceProvider() + } + + private let logger = Logger(subsystem: "com.mc1", category: "RoomSettings") + + // MARK: - Cleanup + + func cleanup() async { + await roomAdminService?.setCLIHandler { _, _ in } + helper.cleanup() + } + + // MARK: - Configuration + + /// Nil service mirrors a disconnected state; commands then no-op. + func configure(roomAdminService: @escaping @MainActor () -> RoomAdminService?, session: RemoteNodeSessionDTO) async { + roomAdminServiceProvider = roomAdminService + + guard let roomAdminService = roomAdminService() else { return } + + helper.configure( + session: session, + sendCommand: { [roomAdminService] id, cmd, timeout in + try await roomAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) + }, + sendRawCommand: { [roomAdminService] id, cmd, timeout in + try await roomAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) + } + ) + + helper.setNodeInfo(firmwareVersion: nil, name: session.name, ownerInfo: nil) + + // Room doesn't have binary protocol for node info — firmware fetched via CLI + helper.onPreFetchNodeInfo = nil + + // Register CLI handler for late responses + await roomAdminService.setCLIHandler { [weak self] message, _ in + await MainActor.run { + self?.handleLateResponse(message.text) + } } - // MARK: - Behavior (advert intervals + flood) - - var advertIntervalMinutes: Int? - var floodAdvertIntervalHours: Int? - var floodMaxHops: Int? - private var originalAdvertIntervalMinutes: Int? - private var originalFloodAdvertIntervalHours: Int? - private var originalFloodMaxHops: Int? - var isLoadingBehavior = false - var behaviorError = false - var isApplyingBehavior = false - var behaviorApplySuccess = false - var isBehaviorExpanded = false - - var advertIntervalError: String? - var floodAdvertIntervalError: String? - var floodMaxHopsError: String? - - var behaviorLoaded: Bool { advertIntervalMinutes != nil || floodAdvertIntervalHours != nil || floodMaxHops != nil } - - var behaviorModified: Bool { - (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || - (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || - (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + Task { await helper.fetchDeviceInfo() } + } + + /// Builds the node-CLI send closure, pre-binding this session's id and + /// capturing the private admin service (a thin pass-through to + /// `RemoteNodeService.sendRawCLICommand`). Returns nil if not configured. + func makeNodeCLISendClosure( + session: RemoteNodeSessionDTO + ) -> (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? { + guard let roomAdminService else { return nil } + return { [roomAdminService, sessionID = session.id] command, timeout in + try await roomAdminService.sendRawCommand( + sessionID: sessionID, command: command, timeout: timeout + ) } - - // MARK: - Dependencies - - private var roomAdminServiceProvider: @MainActor () -> RoomAdminService? = { nil } - var roomAdminService: RoomAdminService? { roomAdminServiceProvider() } - private let logger = Logger(subsystem: "com.mc1", category: "RoomSettings") - - // MARK: - Cleanup - - func cleanup() async { - await roomAdminService?.setCLIHandler { _, _ in } - helper.cleanup() + } + + // MARK: - Late Response Handling + + private func handleLateResponse(_ response: String) { + // Try shared sections first + if helper.handleCommonLateResponse(response) { return } + + // Behavior settings + if !isLoadingBehavior, behaviorError { + if let result = NodeSettingsResponseParser.behaviorLateResponse( + response, + hasAdvertInterval: originalAdvertIntervalMinutes != nil, + hasFloodInterval: originalFloodAdvertIntervalHours != nil, + hasFloodMaxHops: originalFloodMaxHops != nil + ) { + switch result { + case let .advertInterval(interval): + advertIntervalMinutes = interval + originalAdvertIntervalMinutes = interval + case let .floodAdvertInterval(interval): + floodAdvertIntervalHours = interval + originalFloodAdvertIntervalHours = interval + case let .floodMax(hops): + floodMaxHops = hops + originalFloodMaxHops = hops + } + behaviorError = false + return + } + } + } + + // MARK: - Room Access Fetch/Apply + + func fetchRoomAccess() async { + isLoadingRoomAccess = true + roomAccessError = false + + do { + let response = try await helper.sendAndWait("get guest.password", rawMatching: true) + let parsed = CLIResponse.parse(response, forQuery: "get guest.password") + switch parsed { + case .ok, .error, .unknownCommand: + guestPassword = "" + originalGuestPassword = "" + default: + let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) + let value = trimmed.hasPrefix("> ") ? String(trimmed.dropFirst(2)) : trimmed + guestPassword = value + originalGuestPassword = value + } + } catch { + if case RemoteNodeError.timeout = error { roomAccessError = true } + logger.warning("Failed to get guest password: \(error)") } - // MARK: - Configuration - - /// Nil service mirrors a disconnected state; commands then no-op. - func configure(roomAdminService: @escaping @MainActor () -> RoomAdminService?, session: RemoteNodeSessionDTO) async { - self.roomAdminServiceProvider = roomAdminService + do { + let response = try await helper.sendAndWait("get allow.read.only", rawMatching: true) + let parsed = CLIResponse.parse(response, forQuery: "get allow.read.only") + switch parsed { + case let .raw(value): + let isOn = value.lowercased() == "on" + allowReadOnly = isOn + originalAllowReadOnly = isOn + default: + break + } + } catch { + if case RemoteNodeError.timeout = error { roomAccessError = true } + logger.warning("Failed to get allow read only: \(error)") + } - guard let roomAdminService = roomAdminService() else { return } + isLoadingRoomAccess = false + } - helper.configure( - session: session, - sendCommand: { [roomAdminService] id, cmd, timeout in - try await roomAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) - }, - sendRawCommand: { [roomAdminService] id, cmd, timeout in - try await roomAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) - } - ) + func applyRoomAccess() async { + isApplyingRoomAccess = true + helper.errorMessage = nil - helper.setNodeInfo(firmwareVersion: nil, name: session.name, ownerInfo: nil) + do { + var allSucceeded = true - // Room doesn't have binary protocol for node info — firmware fetched via CLI - helper.onPreFetchNodeInfo = nil - - // Register CLI handler for late responses - await roomAdminService.setCLIHandler { [weak self] message, _ in - await MainActor.run { - self?.handleLateResponse(message.text) - } + if let guestPassword, guestPassword != originalGuestPassword { + let response = try await helper.sendAndWait("set guest.password \(guestPassword)") + if case .ok = CLIResponse.parse(response) { + originalGuestPassword = guestPassword + } else { + allSucceeded = false + } + } + + if let allowReadOnly, allowReadOnly != originalAllowReadOnly { + let response = try await helper.sendAndWait("set allow.read.only \(allowReadOnly ? "on" : "off")") + if case .ok = CLIResponse.parse(response) { + originalAllowReadOnly = allowReadOnly + } else { + allSucceeded = false } + } - Task { await helper.fetchDeviceInfo() } + if allSucceeded { + await helper.flashSuccess( + setApplying: { isApplyingRoomAccess = $0 }, + setSuccess: { roomAccessApplySuccess = $0 } + ) + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + helper.errorMessage = error.userFacingMessage } - /// Builds the node-CLI send closure, pre-binding this session's id and - /// capturing the private admin service (a thin pass-through to - /// `RemoteNodeService.sendRawCLICommand`). Returns nil if not configured. - func makeNodeCLISendClosure( - session: RemoteNodeSessionDTO - ) -> (@MainActor (_ command: String, _ timeout: Duration) async throws -> String)? { - guard let roomAdminService else { return nil } - return { [roomAdminService, sessionID = session.id] command, timeout in - try await roomAdminService.sendRawCommand( - sessionID: sessionID, command: command, timeout: timeout) - } + isApplyingRoomAccess = false + } + + // MARK: - Behavior Fetch/Apply + + func fetchBehaviorSettings() async { + isLoadingBehavior = true + behaviorError = false + var hadTimeout = false + + do { + let response = try await helper.sendAndWait("get advert.interval") + if case let .advertInterval(minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { + advertIntervalMinutes = minutes + originalAdvertIntervalMinutes = minutes + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get advert interval: \(error)") } - // MARK: - Late Response Handling - - private func handleLateResponse(_ response: String) { - // Try shared sections first - if helper.handleCommonLateResponse(response) { return } - - // Behavior settings - if !isLoadingBehavior && behaviorError { - if let result = NodeSettingsResponseParser.behaviorLateResponse( - response, - hasAdvertInterval: originalAdvertIntervalMinutes != nil, - hasFloodInterval: originalFloodAdvertIntervalHours != nil, - hasFloodMaxHops: originalFloodMaxHops != nil - ) { - switch result { - case .advertInterval(let interval): - self.advertIntervalMinutes = interval - self.originalAdvertIntervalMinutes = interval - case .floodAdvertInterval(let interval): - self.floodAdvertIntervalHours = interval - self.originalFloodAdvertIntervalHours = interval - case .floodMax(let hops): - self.floodMaxHops = hops - self.originalFloodMaxHops = hops - } - self.behaviorError = false - return - } - } + do { + let response = try await helper.sendAndWait("get flood.advert.interval") + if case let .floodAdvertInterval(hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { + floodAdvertIntervalHours = hours + originalFloodAdvertIntervalHours = hours + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood advert interval: \(error)") } - // MARK: - Room Access Fetch/Apply - - func fetchRoomAccess() async { - isLoadingRoomAccess = true - roomAccessError = false - - do { - let response = try await helper.sendAndWait("get guest.password", rawMatching: true) - let parsed = CLIResponse.parse(response, forQuery: "get guest.password") - switch parsed { - case .ok, .error, .unknownCommand: - self.guestPassword = "" - self.originalGuestPassword = "" - default: - let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) - let value = trimmed.hasPrefix("> ") ? String(trimmed.dropFirst(2)) : trimmed - self.guestPassword = value - self.originalGuestPassword = value - } - } catch { - if case RemoteNodeError.timeout = error { roomAccessError = true } - logger.warning("Failed to get guest password: \(error)") - } - - do { - let response = try await helper.sendAndWait("get allow.read.only", rawMatching: true) - let parsed = CLIResponse.parse(response, forQuery: "get allow.read.only") - switch parsed { - case .raw(let value): - let isOn = value.lowercased() == "on" - self.allowReadOnly = isOn - self.originalAllowReadOnly = isOn - default: - break - } - } catch { - if case RemoteNodeError.timeout = error { roomAccessError = true } - logger.warning("Failed to get allow read only: \(error)") - } - - isLoadingRoomAccess = false + do { + let response = try await helper.sendAndWait("get flood.max") + if case let .floodMax(hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + floodMaxHops = hops + originalFloodMaxHops = hops + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood max: \(error)") } - func applyRoomAccess() async { - isApplyingRoomAccess = true - helper.errorMessage = nil - - do { - var allSucceeded = true - - if let guestPassword, guestPassword != originalGuestPassword { - let response = try await helper.sendAndWait("set guest.password \(guestPassword)") - if case .ok = CLIResponse.parse(response) { - originalGuestPassword = guestPassword - } else { - allSucceeded = false - } - } - - if let allowReadOnly, allowReadOnly != originalAllowReadOnly { - let response = try await helper.sendAndWait("set allow.read.only \(allowReadOnly ? "on" : "off")") - if case .ok = CLIResponse.parse(response) { - originalAllowReadOnly = allowReadOnly - } else { - allSucceeded = false - } - } - - if allSucceeded { - await helper.flashSuccess( - setApplying: { isApplyingRoomAccess = $0 }, - setSuccess: { roomAccessApplySuccess = $0 } - ) - return - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - helper.errorMessage = error.userFacingMessage - } - - isApplyingRoomAccess = false + if hadTimeout { + behaviorError = true } - // MARK: - Behavior Fetch/Apply - - func fetchBehaviorSettings() async { - isLoadingBehavior = true - behaviorError = false - var hadTimeout = false - - do { - let response = try await helper.sendAndWait("get advert.interval") - if case .advertInterval(let minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { - self.advertIntervalMinutes = minutes - self.originalAdvertIntervalMinutes = minutes - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get advert interval: \(error)") + isLoadingBehavior = false + } + + func applyBehaviorSettings() async { + let validation = NodeSettingsViewModel.validateBehaviorFields( + advertInterval: advertIntervalMinutes, + floodInterval: floodAdvertIntervalHours, + floodMaxHops: floodMaxHops + ) + advertIntervalError = validation.advertInterval + floodAdvertIntervalError = validation.floodInterval + floodMaxHopsError = validation.floodMaxHops + + if validation.hasErrors { return } + + isApplyingBehavior = true + helper.errorMessage = nil + + do { + var allSucceeded = true + + if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { + let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") + if case .ok = CLIResponse.parse(response) { + originalAdvertIntervalMinutes = advertIntervalMinutes + } else { + allSucceeded = false } - - do { - let response = try await helper.sendAndWait("get flood.advert.interval") - if case .floodAdvertInterval(let hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { - self.floodAdvertIntervalHours = hours - self.originalFloodAdvertIntervalHours = hours - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get flood advert interval: \(error)") + } + + if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { + let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") + if case .ok = CLIResponse.parse(response) { + originalFloodAdvertIntervalHours = floodAdvertIntervalHours + } else { + allSucceeded = false } - - do { - let response = try await helper.sendAndWait("get flood.max") - if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { - self.floodMaxHops = hops - self.originalFloodMaxHops = hops - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get flood max: \(error)") - } - - if hadTimeout { - behaviorError = true + } + + if let floodMaxHops, floodMaxHops != originalFloodMaxHops { + let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") + if case .ok = CLIResponse.parse(response) { + originalFloodMaxHops = floodMaxHops + } else { + allSucceeded = false } + } - isLoadingBehavior = false - } - - func applyBehaviorSettings() async { - let validation = NodeSettingsViewModel.validateBehaviorFields( - advertInterval: advertIntervalMinutes, - floodInterval: floodAdvertIntervalHours, - floodMaxHops: floodMaxHops + if allSucceeded { + await helper.flashSuccess( + setApplying: { isApplyingBehavior = $0 }, + setSuccess: { behaviorApplySuccess = $0 } ) - advertIntervalError = validation.advertInterval - floodAdvertIntervalError = validation.floodInterval - floodMaxHopsError = validation.floodMaxHops - - if validation.hasErrors { return } - - isApplyingBehavior = true - helper.errorMessage = nil - - do { - var allSucceeded = true - - if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { - let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") - if case .ok = CLIResponse.parse(response) { - originalAdvertIntervalMinutes = advertIntervalMinutes - } else { - allSucceeded = false - } - } - - if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { - let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") - if case .ok = CLIResponse.parse(response) { - originalFloodAdvertIntervalHours = floodAdvertIntervalHours - } else { - allSucceeded = false - } - } - - if let floodMaxHops, floodMaxHops != originalFloodMaxHops { - let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") - if case .ok = CLIResponse.parse(response) { - originalFloodMaxHops = floodMaxHops - } else { - allSucceeded = false - } - } - - if allSucceeded { - await helper.flashSuccess( - setApplying: { isApplyingBehavior = $0 }, - setSuccess: { behaviorApplySuccess = $0 } - ) - return - } else { - helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - helper.errorMessage = error.userFacingMessage - } - - isApplyingBehavior = false + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + helper.errorMessage = error.userFacingMessage } + isApplyingBehavior = false + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift b/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift index d8b951de..2ecfd2db 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift @@ -7,58 +7,58 @@ import SwiftUI /// never create, replace, or reset it; expand/loaded/loading/error state all live on the view model so /// switching hosts or segments preserves it. struct RoomStatusContent: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - let viewModel: RoomStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState - let connectedDeviceID: UUID? + let viewModel: RoomStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState + let connectedDeviceID: UUID? - var body: some View { - List { - NodeStatusHeaderSection(session: session) - RoomStatusSection(viewModel: viewModel, session: session, connectionState: connectionState) - NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { - await viewModel.requestTelemetry(for: session) - } - NodeBatteryCurveDisclosureSection( - helper: viewModel.helper, - session: session, - connectionState: connectionState, - connectedDeviceID: connectedDeviceID - ) - } - .nodeStatusDestinations(helper: viewModel.helper) - .themedCanvas(theme) - .nodeManagementHeaderTopMargin() - .scrollDismissesKeyboard(.interactively) + var body: some View { + List { + NodeStatusHeaderSection(session: session) + RoomStatusSection(viewModel: viewModel, session: session, connectionState: connectionState) + NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { + await viewModel.requestTelemetry(for: session) + } + NodeBatteryCurveDisclosureSection( + helper: viewModel.helper, + session: session, + connectionState: connectionState, + connectedDeviceID: connectedDeviceID + ) } + .nodeStatusDestinations(helper: viewModel.helper) + .themedCanvas(theme) + .nodeManagementHeaderTopMargin() + .scrollDismissesKeyboard(.interactively) + } } // MARK: - Status Section private struct RoomStatusSection: View { - let viewModel: RoomStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState + let viewModel: RoomStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState - var body: some View { - NodeStatusSection(helper: viewModel.helper, connectionState: connectionState) { - await viewModel.requestStatus(for: session) - } rows: { - RoomStatusRows(viewModel: viewModel) - } + var body: some View { + NodeStatusSection(helper: viewModel.helper, connectionState: connectionState) { + await viewModel.requestStatus(for: session) + } rows: { + RoomStatusRows(viewModel: viewModel) } + } } // MARK: - Status Rows private struct RoomStatusRows: View { - let viewModel: RoomStatusViewModel + let viewModel: RoomStatusViewModel - var body: some View { - NodeCommonStatusRows(helper: viewModel.helper) - LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, value: viewModel.postsReceivedDisplay) - LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, value: viewModel.postsPushedDisplay) - } + var body: some View { + NodeCommonStatusRows(helper: viewModel.helper) + LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, value: viewModel.postsReceivedDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, value: viewModel.postsPushedDisplay) + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift b/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift index a1bf0394..d7fb522a 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift @@ -3,70 +3,70 @@ import SwiftUI /// Guest standalone sheet for room server stats, telemetry, and battery curve. struct RoomStatusView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss - let session: RemoteNodeSessionDTO - @State private var viewModel = RoomStatusViewModel() + let session: RemoteNodeSessionDTO + @State private var viewModel = RoomStatusViewModel() - var body: some View { - NavigationStack { - RoomStatusContent( - viewModel: viewModel, - session: session, - connectionState: appState.connectionState, - connectedDeviceID: appState.connectedDevice?.radioID - ) - .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomStatus.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } - } - - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.RemoteNodes.RemoteNodes.done) { - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) - } - } - } - .task { - viewModel.configure( - roomAdminService: { appState.services?.roomAdminService }, - contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } - ) - await viewModel.registerHandlers() + var body: some View { + NavigationStack { + RoomStatusContent( + viewModel: viewModel, + session: session, + connectionState: appState.connectionState, + connectedDeviceID: appState.connectedDevice?.radioID + ) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomStatus.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } + } - // Pre-load OCV settings - if let radioID = appState.connectedDevice?.radioID { - await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) - } - } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.done) { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } } - .onDisappear { - Task { await viewModel.cleanup() } + } + .task { + viewModel.configure( + roomAdminService: { appState.services?.roomAdminService }, + contactService: { appState.services?.contactService }, + nodeSnapshotService: { appState.services?.nodeSnapshotService } + ) + await viewModel.registerHandlers() + + // Pre-load OCV settings + if let radioID = appState.connectedDevice?.radioID { + await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) } - .presentationDetents([.large]) + } } + .onDisappear { + Task { await viewModel.cleanup() } + } + .presentationDetents([.large]) + } } #Preview { - RoomStatusView( - session: RemoteNodeSessionDTO( - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test Room", - role: .roomServer, - isConnected: true, - permissionLevel: .admin - ) + RoomStatusView( + session: RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + role: .roomServer, + isConnected: true, + permissionLevel: .admin ) - .environment(\.appState, AppState()) + ) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomStatusViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomStatusViewModel.swift index 47727f65..d5b6787c 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomStatusViewModel.swift @@ -5,115 +5,116 @@ import SwiftUI @Observable @MainActor final class RoomStatusViewModel { - - // MARK: - Shared Helper - - var helper = NodeStatusViewModel() - - // MARK: - Dependencies - - private var roomAdminServiceProvider: @MainActor () -> RoomAdminService? = { nil } - var roomAdminService: RoomAdminService? { roomAdminServiceProvider() } - - // MARK: - Initialization - - init() {} - - /// Nil services mirror a disconnected state; requests then no-op. - func configure( - roomAdminService: @escaping @MainActor () -> RoomAdminService?, - contactService: @escaping @MainActor () -> ContactService?, - nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? - ) { - self.roomAdminServiceProvider = roomAdminService - helper.configure( - contactService: contactService, - nodeSnapshotService: nodeSnapshotService - ) - } - - /// Reads the live service from the provider so a reconnect-minted instance - /// is used at call time. Sets only the slots this view model owns; the admin - /// service is shared with the settings/CLI view model, so clearing here would - /// drop its CLI handler and silently break late CLI-response delivery. - func registerHandlers() async { - guard let roomAdminService = roomAdminService else { return } - - await roomAdminService.setStatusHandler { [weak self] status in - await self?.handleStatusResponse(status) - } - - await roomAdminService.setTelemetryHandler { [weak self] response in - await self?.helper.handleTelemetryResponse(response) - } - } - - /// Clear every handler slot on the shared admin service. Only for true - /// surface teardown (sheet dismiss); calling it on a segment switch would - /// wipe the CLI handler the settings view model relies on. - func cleanup() async { - guard let roomAdminService = roomAdminService else { return } - await roomAdminService.clearHandlers() - } - - /// Clear only this view model's status/telemetry handler slots, leaving the settings view - /// model's CLI handler intact. For the merged admin surface's status-segment teardown. - func clearStatusHandlers() async { - guard let roomAdminService = roomAdminService else { return } - await roomAdminService.clearStatusHandlers() - } - - // MARK: - Status - - func requestStatus(for session: RemoteNodeSessionDTO) async { - guard let roomAdminService else { return } - if helper.session == nil { helper.session = session } - - await helper.runRetryingSectionRequest( - operationName: "status", - setLoading: { self.helper.isLoadingStatus = $0 }, - setError: { self.helper.statusSectionError = $0 }, - operation: { [roomAdminService] timeout in - try await roomAdminService.requestStatus(sessionID: session.id, timeout: timeout) - }, - onSuccess: { await self.handleStatusResponse($0) } - ) - } - - private func handleStatusResponse(_ response: RemoteNodeStatus) async { - await helper.handleStatusResponse( - response, - postedCount: response.roomServerPostedCount, - postPushCount: response.roomServerPostPushCount - ) - } - - // MARK: - Telemetry - - func requestTelemetry(for session: RemoteNodeSessionDTO) async { - guard let roomAdminService else { return } - if helper.session == nil { helper.session = session } - - await helper.runRetryingSectionRequest( - operationName: "telemetry", - setLoading: { self.helper.isLoadingTelemetry = $0 }, - setError: { self.helper.telemetrySectionError = $0 }, - operation: { [roomAdminService] timeout in - try await roomAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) - }, - onSuccess: { await self.helper.handleTelemetryResponse($0) } - ) - } - - // MARK: - Room-Only Display - - var postsReceivedDisplay: String { - guard let count = helper.status?.roomServerPostedCount else { return NodeStatusViewModel.emDash } - return count.formatted() + // MARK: - Shared Helper + + var helper = NodeStatusViewModel() + + // MARK: - Dependencies + + private var roomAdminServiceProvider: @MainActor () -> RoomAdminService? = { nil } + var roomAdminService: RoomAdminService? { + roomAdminServiceProvider() + } + + // MARK: - Initialization + + init() {} + + /// Nil services mirror a disconnected state; requests then no-op. + func configure( + roomAdminService: @escaping @MainActor () -> RoomAdminService?, + contactService: @escaping @MainActor () -> ContactService?, + nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? + ) { + roomAdminServiceProvider = roomAdminService + helper.configure( + contactService: contactService, + nodeSnapshotService: nodeSnapshotService + ) + } + + /// Reads the live service from the provider so a reconnect-minted instance + /// is used at call time. Sets only the slots this view model owns; the admin + /// service is shared with the settings/CLI view model, so clearing here would + /// drop its CLI handler and silently break late CLI-response delivery. + func registerHandlers() async { + guard let roomAdminService else { return } + + await roomAdminService.setStatusHandler { [weak self] status in + await self?.handleStatusResponse(status) } - var postsPushedDisplay: String { - guard let count = helper.status?.roomServerPostPushCount else { return NodeStatusViewModel.emDash } - return count.formatted() + await roomAdminService.setTelemetryHandler { [weak self] response in + await self?.helper.handleTelemetryResponse(response) } + } + + /// Clear every handler slot on the shared admin service. Only for true + /// surface teardown (sheet dismiss); calling it on a segment switch would + /// wipe the CLI handler the settings view model relies on. + func cleanup() async { + guard let roomAdminService else { return } + await roomAdminService.clearHandlers() + } + + /// Clear only this view model's status/telemetry handler slots, leaving the settings view + /// model's CLI handler intact. For the merged admin surface's status-segment teardown. + func clearStatusHandlers() async { + guard let roomAdminService else { return } + await roomAdminService.clearStatusHandlers() + } + + // MARK: - Status + + func requestStatus(for session: RemoteNodeSessionDTO) async { + guard let roomAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "status", + setLoading: { self.helper.isLoadingStatus = $0 }, + setError: { self.helper.statusSectionError = $0 }, + operation: { [roomAdminService] timeout in + try await roomAdminService.requestStatus(sessionID: session.id, timeout: timeout) + }, + onSuccess: { await self.handleStatusResponse($0) } + ) + } + + private func handleStatusResponse(_ response: RemoteNodeStatus) async { + await helper.handleStatusResponse( + response, + postedCount: response.roomServerPostedCount, + postPushCount: response.roomServerPostPushCount + ) + } + + // MARK: - Telemetry + + func requestTelemetry(for session: RemoteNodeSessionDTO) async { + guard let roomAdminService else { return } + if helper.session == nil { helper.session = session } + + await helper.runRetryingSectionRequest( + operationName: "telemetry", + setLoading: { self.helper.isLoadingTelemetry = $0 }, + setError: { self.helper.telemetrySectionError = $0 }, + operation: { [roomAdminService] timeout in + try await roomAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) + }, + onSuccess: { await self.helper.handleTelemetryResponse($0) } + ) + } + + // MARK: - Room-Only Display + + var postsReceivedDisplay: String { + guard let count = helper.status?.roomServerPostedCount else { return NodeStatusViewModel.emDash } + return count.formatted() + } + + var postsPushedDisplay: String { + guard let count = helper.status?.roomServerPostPushCount else { return NodeStatusViewModel.emDash } + return count.formatted() + } } diff --git a/MC1/Views/RemoteNodes/SettingsLoadPlaceholder.swift b/MC1/Views/RemoteNodes/SettingsLoadPlaceholder.swift index 2c075361..6624bae7 100644 --- a/MC1/Views/RemoteNodes/SettingsLoadPlaceholder.swift +++ b/MC1/Views/RemoteNodes/SettingsLoadPlaceholder.swift @@ -4,16 +4,16 @@ import SwiftUI /// fetch resolves: "Loading…" while in flight, "Failed to load" on error, an em /// dash otherwise. struct SettingsLoadPlaceholder: View { - let isLoading: Bool - let hasError: Bool + let isLoading: Bool + let hasError: Bool - var body: some View { - Text( - isLoading - ? L10n.RemoteNodes.RemoteNodes.Settings.loading - : (hasError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : NodeStatusViewModel.emDash) - ) - .font(.caption) - .foregroundStyle(.secondary) - } + var body: some View { + Text( + isLoading + ? L10n.RemoteNodes.RemoteNodes.Settings.loading + : (hasError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : NodeStatusViewModel.emDash) + ) + .font(.caption) + .foregroundStyle(.secondary) + } } diff --git a/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift b/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift index f744386c..0ec0b68d 100644 --- a/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift +++ b/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift @@ -4,8 +4,8 @@ import SwiftUI // MARK: - Unified Focus Field enum NodeSettingsField: Hashable { - case frequency, txPower, advertInterval, floodAdvertInterval, floodMaxHops - case identityName, contactInfo, guestPassword + case frequency, txPower, advertInterval, floodAdvertInterval, floodMaxHops + case identityName, contactInfo, guestPassword } // MARK: - Settings Header @@ -13,434 +13,434 @@ enum NodeSettingsField: Hashable { private let nodeHeaderTopContentMargin: CGFloat = 8 extension View { - /// Trims the grouped scroll view's default top inset so the node header avatar sits close to - /// the pinned management tab picker. Applied to the settings `Form` and telemetry `List` that - /// host `NodeSettingsHeaderSection` / `NodeStatusHeaderSection`. - func nodeManagementHeaderTopMargin() -> some View { - contentMargins(.top, nodeHeaderTopContentMargin, for: .scrollContent) - } + /// Trims the grouped scroll view's default top inset so the node header avatar sits close to + /// the pinned management tab picker. Applied to the settings `Form` and telemetry `List` that + /// host `NodeSettingsHeaderSection` / `NodeStatusHeaderSection`. + func nodeManagementHeaderTopMargin() -> some View { + contentMargins(.top, nodeHeaderTopContentMargin, for: .scrollContent) + } } struct NodeSettingsHeaderSection: View { - let publicKey: Data - let name: String - let role: RemoteNodeRole - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 8) { - NodeAvatar(publicKey: publicKey, role: role, size: 60) - Text(name) - .font(.headline) - } - Spacer() - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) + let publicKey: Data + let name: String + let role: RemoteNodeRole + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 8) { + NodeAvatar(publicKey: publicKey, role: role, size: 60) + Text(name) + .font(.headline) } - .listSectionSpacing(.compact) + Spacer() + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) } + .listSectionSpacing(.compact) + } } // MARK: - Device Info Section struct NodeDeviceInfoSection: View { - @Bindable var settings: NodeSettingsViewModel - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfo, - icon: "info.circle", - isExpanded: $settings.isDeviceInfoExpanded, - isLoaded: { settings.deviceInfoLoaded }, - isLoading: $settings.isLoadingDeviceInfo, - hasError: $settings.deviceInfoError, - onLoad: { await settings.fetchDeviceInfo() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfoFooter - ) { - LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.firmware, value: settings.firmwareVersion ?? NodeStatusViewModel.emDash) - LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.deviceTime, value: settings.deviceTime ?? NodeStatusViewModel.emDash) - } + @Bindable var settings: NodeSettingsViewModel + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfo, + icon: "info.circle", + isExpanded: $settings.isDeviceInfoExpanded, + isLoaded: { settings.deviceInfoLoaded }, + isLoading: $settings.isLoadingDeviceInfo, + hasError: $settings.deviceInfoError, + onLoad: { await settings.fetchDeviceInfo() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfoFooter + ) { + LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.firmware, value: settings.firmwareVersion ?? NodeStatusViewModel.emDash) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.deviceTime, value: settings.deviceTime ?? NodeStatusViewModel.emDash) } + } } // MARK: - Radio Settings Section struct NodeRadioSettingsSection: View { - @Bindable var settings: NodeSettingsViewModel - var focusedField: FocusState.Binding - var radioRestartWarning: String = L10n.RemoteNodes.RemoteNodes.Settings.radioRestartWarning - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.radioParameters, - icon: "antenna.radiowaves.left.and.right", - isExpanded: $settings.isRadioExpanded, - isLoaded: { settings.radioLoaded }, - isLoading: $settings.isLoadingRadio, - hasError: $settings.radioError, - onLoad: { await settings.fetchRadioSettings() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.radioFooter - ) { - if settings.radioSettingsModified { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - Text(radioRestartWarning) - .font(.subheadline) - } - .padding() - .frame(maxWidth: .infinity) - .background(.yellow.opacity(0.1)) - .clipShape(.rect(cornerRadius: 8)) - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.frequencyMHz) - Spacer() - if let frequency = settings.frequency { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.mhz, value: Binding( - get: { frequency }, - set: { settings.frequency = $0 } - ), format: .number.precision(.fractionLength(3)).locale(.posix)) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 100) - .focused(focusedField, equals: .frequency) - .onChange(of: settings.frequency) { _, _ in - settings.radioSettingsModified = true - } - } else { - SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) - .frame(width: 100, alignment: .trailing) - } - } - - if let bandwidth = settings.bandwidth { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz, selection: Binding( - get: { bandwidth }, - set: { settings.bandwidth = $0 } - )) { - ForEach(RadioOptions.bandwidthsKHz, id: \.self) { bwKHz in - Text(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000))) - .tag(bwKHz) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000)))) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthHint) - .onChange(of: settings.bandwidth) { _, _ in - settings.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz) - Spacer() - SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) - } - } - - if let spreadingFactor = settings.spreadingFactor { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor, selection: Binding( - get: { spreadingFactor }, - set: { settings.spreadingFactor = $0 } - )) { - ForEach(RadioOptions.spreadingFactors, id: \.self) { sf in - Text(sf, format: .number) - .tag(sf) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.spreadingFactorLabel(sf)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactorHint) - .onChange(of: settings.spreadingFactor) { _, _ in - settings.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor) - Spacer() - SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) - } - } - - if let codingRate = settings.codingRate { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.codingRate, selection: Binding( - get: { codingRate }, - set: { settings.codingRate = $0 } - )) { - ForEach(RadioOptions.codingRates, id: \.self) { cr in - Text("\(cr)") - .tag(cr) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.codingRateLabel(cr)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.codingRateHint) - .onChange(of: settings.codingRate) { _, _ in - settings.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.codingRate) - Spacer() - SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.txPowerDbm) - Spacer() - if let txPower = settings.txPower { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.dbm, value: Binding( - get: { txPower }, - set: { settings.txPower = $0 } - ), format: .number) - .keyboardType(.numbersAndPunctuation) - .multilineTextAlignment(.trailing) - .frame(width: 80) - .focused(focusedField, equals: .txPower) - .onChange(of: settings.txPower) { _, _ in - settings.radioSettingsModified = true - } - } else { - SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) - .frame(width: 80, alignment: .trailing) - } + @Bindable var settings: NodeSettingsViewModel + var focusedField: FocusState.Binding + var radioRestartWarning: String = L10n.RemoteNodes.RemoteNodes.Settings.radioRestartWarning + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.radioParameters, + icon: "antenna.radiowaves.left.and.right", + isExpanded: $settings.isRadioExpanded, + isLoaded: { settings.radioLoaded }, + isLoading: $settings.isLoadingRadio, + hasError: $settings.radioError, + onLoad: { await settings.fetchRadioSettings() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.radioFooter + ) { + if settings.radioSettingsModified { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(radioRestartWarning) + .font(.subheadline) + } + .padding() + .frame(maxWidth: .infinity) + .background(.yellow.opacity(0.1)) + .clipShape(.rect(cornerRadius: 8)) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.frequencyMHz) + Spacer() + if let frequency = settings.frequency { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.mhz, value: Binding( + get: { frequency }, + set: { settings.frequency = $0 } + ), format: .number.precision(.fractionLength(3)).locale(.posix)) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 100) + .focused(focusedField, equals: .frequency) + .onChange(of: settings.frequency) { _, _ in + settings.radioSettingsModified = true } - - Button { - Task { await settings.applyRadioSettings() } - } label: { - AsyncActionLabel(isLoading: settings.isApplying, showSuccess: false) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyRadioSettings) - .foregroundStyle(settings.radioSettingsModified ? Color.accentColor : .secondary) - .transition(.opacity) - } + } else { + SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) + .frame(width: 100, alignment: .trailing) + } + } + + if let bandwidth = settings.bandwidth { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz, selection: Binding( + get: { bandwidth }, + set: { settings.bandwidth = $0 } + )) { + ForEach(RadioOptions.bandwidthsKHz, id: \.self) { bwKHz in + Text(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000))) + .tag(bwKHz) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000)))) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthHint) + .onChange(of: settings.bandwidth) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz) + Spacer() + SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) + } + } + + if let spreadingFactor = settings.spreadingFactor { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor, selection: Binding( + get: { spreadingFactor }, + set: { settings.spreadingFactor = $0 } + )) { + ForEach(RadioOptions.spreadingFactors, id: \.self) { sf in + Text(sf, format: .number) + .tag(sf) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.spreadingFactorLabel(sf)) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactorHint) + .onChange(of: settings.spreadingFactor) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor) + Spacer() + SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) + } + } + + if let codingRate = settings.codingRate { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.codingRate, selection: Binding( + get: { codingRate }, + set: { settings.codingRate = $0 } + )) { + ForEach(RadioOptions.codingRates, id: \.self) { cr in + Text("\(cr)") + .tag(cr) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.codingRateLabel(cr)) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.codingRateHint) + .onChange(of: settings.codingRate) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.codingRate) + Spacer() + SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) + } + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.txPowerDbm) + Spacer() + if let txPower = settings.txPower { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.dbm, value: Binding( + get: { txPower }, + set: { settings.txPower = $0 } + ), format: .number) + .keyboardType(.numbersAndPunctuation) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .focused(focusedField, equals: .txPower) + .onChange(of: settings.txPower) { _, _ in + settings.radioSettingsModified = true } - .disabled(!settings.radioSettingsModified || settings.isApplying) + } else { + SettingsLoadPlaceholder(isLoading: settings.isLoadingRadio, hasError: settings.radioError) + .frame(width: 80, alignment: .trailing) + } + } + + Button { + Task { await settings.applyRadioSettings() } + } label: { + AsyncActionLabel(isLoading: settings.isApplying, showSuccess: false) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyRadioSettings) + .foregroundStyle(settings.radioSettingsModified ? Color.accentColor : .secondary) + .transition(.opacity) } + } + .disabled(!settings.radioSettingsModified || settings.isApplying) } + } } // MARK: - Identity Section struct RemoteNodeIdentitySection: View { - @Bindable var settings: NodeSettingsViewModel - var focusedField: FocusState.Binding - var onPickLocation: () -> Void - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.identityLocation, - icon: "person.text.rectangle", - isExpanded: $settings.isIdentityExpanded, - isLoaded: { settings.identityLoaded }, - isLoading: $settings.isLoadingIdentity, - hasError: $settings.identityError, - onLoad: { await settings.fetchIdentity() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.identityFooter - ) { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.name) - Spacer() - if let name = settings.name { - TextField(L10n.RemoteNodes.RemoteNodes.name, text: Binding( - get: { name }, - set: { settings.name = $0 } - )) - .multilineTextAlignment(.trailing) - .focused(focusedField, equals: .identityName) - } else { - SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.latitude) - Spacer() - if let latitude = settings.latitude { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.latitude, value: Binding( - get: { latitude }, - set: { settings.latitude = $0 } - ), format: .number.precision(.fractionLength(6))) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 140) - } else { - SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.longitude) - Spacer() - if let longitude = settings.longitude { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.longitude, value: Binding( - get: { longitude }, - set: { settings.longitude = $0 } - ), format: .number.precision(.fractionLength(6))) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 140) - } else { - SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) - } - } + @Bindable var settings: NodeSettingsViewModel + var focusedField: FocusState.Binding + var onPickLocation: () -> Void + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.identityLocation, + icon: "person.text.rectangle", + isExpanded: $settings.isIdentityExpanded, + isLoaded: { settings.identityLoaded }, + isLoading: $settings.isLoadingIdentity, + hasError: $settings.identityError, + onLoad: { await settings.fetchIdentity() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.identityFooter + ) { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.name) + Spacer() + if let name = settings.name { + TextField(L10n.RemoteNodes.RemoteNodes.name, text: Binding( + get: { name }, + set: { settings.name = $0 } + )) + .multilineTextAlignment(.trailing) + .focused(focusedField, equals: .identityName) + } else { + SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) + } + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.latitude) + Spacer() + if let latitude = settings.latitude { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.latitude, value: Binding( + get: { latitude }, + set: { settings.latitude = $0 } + ), format: .number.precision(.fractionLength(6))) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 140) + } else { + SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) + } + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.longitude) + Spacer() + if let longitude = settings.longitude { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.longitude, value: Binding( + get: { longitude }, + set: { settings.longitude = $0 } + ), format: .number.precision(.fractionLength(6))) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 140) + } else { + SettingsLoadPlaceholder(isLoading: settings.isLoadingIdentity, hasError: settings.identityError) + } + } - Button(L10n.RemoteNodes.RemoteNodes.Settings.pickOnMap, systemImage: "map") { - onPickLocation() - } + Button(L10n.RemoteNodes.RemoteNodes.Settings.pickOnMap, systemImage: "map") { + onPickLocation() + } - Button { - Task { await settings.applyIdentitySettings() } - } label: { - AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.identityApplySuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyIdentitySettings) - } - } - .disabled(!settings.identitySettingsModified || settings.isApplying) + Button { + Task { await settings.applyIdentitySettings() } + } label: { + AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.identityApplySuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyIdentitySettings) } + } + .disabled(!settings.identitySettingsModified || settings.isApplying) } + } } // MARK: - Contact Info Section struct NodeContactInfoSection: View { - @Bindable var settings: NodeSettingsViewModel - var focusedField: FocusState.Binding - @State private var contactText = "" - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.contactInfo, - icon: "person.crop.rectangle", - isExpanded: $settings.isContactInfoExpanded, - isLoaded: { settings.contactInfoLoaded }, - isLoading: $settings.isLoadingContactInfo, - hasError: $settings.contactInfoError, - onLoad: { await settings.fetchContactInfo() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.contactInfoFooter - ) { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, text: $contactText, axis: .vertical) - .lineLimit(3...6) - .focused(focusedField, equals: .contactInfo) - .overlay(alignment: .bottomTrailing) { - Text("\(settings.ownerInfoCharCount)/\(NodeSettingsViewModel.ownerInfoMaxLength)") - .font(.caption2) - .foregroundStyle(settings.isOwnerInfoTooLong ? .red : .secondary) - .padding(4) - } - .onChange(of: settings.ownerInfo, initial: true) { _, newValue in - contactText = newValue ?? "" - } - .onChange(of: contactText) { _, newValue in - settings.ownerInfo = newValue - } - - Button { - Task { await settings.applyContactInfoSettings() } - } label: { - AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.contactInfoApplySuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyContactInfo) - } - } - .disabled(!settings.contactInfoSettingsModified || settings.isApplying || settings.isOwnerInfoTooLong) + @Bindable var settings: NodeSettingsViewModel + var focusedField: FocusState.Binding + @State private var contactText = "" + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.contactInfo, + icon: "person.crop.rectangle", + isExpanded: $settings.isContactInfoExpanded, + isLoaded: { settings.contactInfoLoaded }, + isLoading: $settings.isLoadingContactInfo, + hasError: $settings.contactInfoError, + onLoad: { await settings.fetchContactInfo() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.contactInfoFooter + ) { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, text: $contactText, axis: .vertical) + .lineLimit(3...6) + .focused(focusedField, equals: .contactInfo) + .overlay(alignment: .bottomTrailing) { + Text("\(settings.ownerInfoCharCount)/\(NodeSettingsViewModel.ownerInfoMaxLength)") + .font(.caption2) + .foregroundStyle(settings.isOwnerInfoTooLong ? .red : .secondary) + .padding(4) + } + .onChange(of: settings.ownerInfo, initial: true) { _, newValue in + contactText = newValue ?? "" + } + .onChange(of: contactText) { _, newValue in + settings.ownerInfo = newValue } + + Button { + Task { await settings.applyContactInfoSettings() } + } label: { + AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.contactInfoApplySuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyContactInfo) + } + } + .disabled(!settings.contactInfoSettingsModified || settings.isApplying || settings.isOwnerInfoTooLong) } + } } // MARK: - Security Section struct NodeSecuritySection: View { - @Environment(\.appTheme) private var theme - @Bindable var settings: NodeSettingsViewModel - - var body: some View { - Section { - DisclosureGroup(isExpanded: $settings.isSecurityExpanded) { - SecureField(L10n.RemoteNodes.RemoteNodes.Settings.newPassword, text: $settings.newPassword) - SecureField(L10n.RemoteNodes.RemoteNodes.Settings.confirmPassword, text: $settings.confirmPassword) - - Button { - Task { await settings.changePassword() } - } label: { - AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.changePasswordSuccess) { - Text(L10n.RemoteNodes.RemoteNodes.Settings.changePassword) - } - } - .disabled(settings.isApplying || settings.changePasswordSuccess || settings.newPassword.isEmpty || settings.newPassword != settings.confirmPassword) - } label: { - Label(L10n.RemoteNodes.RemoteNodes.Settings.security, systemImage: "lock") - } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Settings.securityFooter) + @Environment(\.appTheme) private var theme + @Bindable var settings: NodeSettingsViewModel + + var body: some View { + Section { + DisclosureGroup(isExpanded: $settings.isSecurityExpanded) { + SecureField(L10n.RemoteNodes.RemoteNodes.Settings.newPassword, text: $settings.newPassword) + SecureField(L10n.RemoteNodes.RemoteNodes.Settings.confirmPassword, text: $settings.confirmPassword) + + Button { + Task { await settings.changePassword() } + } label: { + AsyncActionLabel(isLoading: settings.isApplying, showSuccess: settings.changePasswordSuccess) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.changePassword) + } } - .themedRowBackground(theme) + .disabled(settings.isApplying || settings.changePasswordSuccess || settings.newPassword.isEmpty || settings.newPassword != settings.confirmPassword) + } label: { + Label(L10n.RemoteNodes.RemoteNodes.Settings.security, systemImage: "lock") + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Settings.securityFooter) } + .themedRowBackground(theme) + } } // MARK: - Actions Section struct NodeActionsSection: View { - @Environment(\.appTheme) private var theme - let settings: NodeSettingsViewModel - @Binding var showRebootConfirmation: Bool - var rebootConfirmTitle: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootConfirmTitle - var rebootMessage: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootMessage - - var body: some View { - Section(L10n.RemoteNodes.RemoteNodes.Settings.deviceActions) { - Button { - Task { await settings.forceAdvert() } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.sendAdvert) - if settings.isSendingAdvert { - Spacer() - ProgressView() - } - } - } - .disabled(settings.isSendingAdvert) - - Button { - Task { await settings.syncTime() } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.syncTime) - if settings.isApplying { - Spacer() - ProgressView() - } - } - } - .disabled(settings.isApplying) - - Button(L10n.RemoteNodes.RemoteNodes.Settings.rebootDevice, role: .destructive) { - showRebootConfirmation = true - } - .disabled(settings.isRebooting) - .confirmationDialog(rebootConfirmTitle, isPresented: $showRebootConfirmation) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.reboot, role: .destructive) { - Task { await settings.reboot() } - } - Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { } - } message: { - Text(rebootMessage) - } - - if let error = settings.errorMessage { - Text(error) - .foregroundStyle(.orange) - .font(.caption) - } + @Environment(\.appTheme) private var theme + let settings: NodeSettingsViewModel + @Binding var showRebootConfirmation: Bool + var rebootConfirmTitle: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootConfirmTitle + var rebootMessage: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootMessage + + var body: some View { + Section(L10n.RemoteNodes.RemoteNodes.Settings.deviceActions) { + Button { + Task { await settings.forceAdvert() } + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.sendAdvert) + if settings.isSendingAdvert { + Spacer() + ProgressView() + } + } + } + .disabled(settings.isSendingAdvert) + + Button { + Task { await settings.syncTime() } + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.syncTime) + if settings.isApplying { + Spacer() + ProgressView() + } + } + } + .disabled(settings.isApplying) + + Button(L10n.RemoteNodes.RemoteNodes.Settings.rebootDevice, role: .destructive) { + showRebootConfirmation = true + } + .disabled(settings.isRebooting) + .confirmationDialog(rebootConfirmTitle, isPresented: $showRebootConfirmation) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.reboot, role: .destructive) { + Task { await settings.reboot() } } - .themedRowBackground(theme) + Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) {} + } message: { + Text(rebootMessage) + } + + if let error = settings.errorMessage { + Text(error) + .foregroundStyle(.orange) + .font(.caption) + } } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift b/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift index 8b2c9b0e..5a5ac423 100644 --- a/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift +++ b/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift @@ -4,304 +4,304 @@ import SwiftUI // MARK: - Status Header struct NodeStatusHeaderSection: View { - let session: RemoteNodeSessionDTO - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 8) { - NodeAvatar(publicKey: session.publicKey, role: session.role, size: 60) - - Text(session.name) - .font(.headline) - - if session.permissionLevel == .guest { - Text(L10n.RemoteNodes.RemoteNodes.Status.guestMode) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - Spacer() - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) + let session: RemoteNodeSessionDTO + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 8) { + NodeAvatar(publicKey: session.publicKey, role: session.role, size: 60) + + Text(session.name) + .font(.headline) + + if session.permissionLevel == .guest { + Text(L10n.RemoteNodes.RemoteNodes.Status.guestMode) + .font(.subheadline) + .foregroundStyle(.secondary) + } } - .listSectionSpacing(.compact) + Spacer() + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) } + .listSectionSpacing(.compact) + } } // MARK: - Common Status Rows struct NodeCommonStatusRows: View { - let helper: NodeStatusViewModel - - var body: some View { - NodeMetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.battery, - value: helper.batteryDisplay, - delta: helper.batteryDeltaMV.map { Double($0) / 1000.0 }, - higherIsBetter: true, unit: " V", fractionDigits: 3 - ) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.uptime, value: helper.uptimeDisplay) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.airtime, value: helper.airtimeDisplay) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.airtimePercent, value: helper.airtimePercentDisplay) - - NodeMetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.lastRssi, - value: helper.lastRSSIDisplay, - delta: helper.rssiDelta.map(Double.init), - higherIsBetter: true, unit: " dBm", fractionDigits: 0 - ) - - NodeMetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.lastSnr, - value: helper.lastSNRDisplay, - delta: helper.snrDelta, - higherIsBetter: true, unit: " dB", fractionDigits: 1 - ) - - NodeMetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.noiseFloor, - value: helper.noiseFloorDisplay, - delta: helper.noiseFloorDelta.map(Double.init), - higherIsBetter: false, unit: " dBm", fractionDigits: 0 - ) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsSent, value: helper.packetsSentDisplay) - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsReceived, value: helper.packetsReceivedDisplay) - } + let helper: NodeStatusViewModel + + var body: some View { + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.battery, + value: helper.batteryDisplay, + delta: helper.batteryDeltaMV.map { Double($0) / 1000.0 }, + higherIsBetter: true, unit: " V", fractionDigits: 3 + ) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.uptime, value: helper.uptimeDisplay) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.airtime, value: helper.airtimeDisplay) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.airtimePercent, value: helper.airtimePercentDisplay) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.lastRssi, + value: helper.lastRSSIDisplay, + delta: helper.rssiDelta.map(Double.init), + higherIsBetter: true, unit: " dBm", fractionDigits: 0 + ) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.lastSnr, + value: helper.lastSNRDisplay, + delta: helper.snrDelta, + higherIsBetter: true, unit: " dB", fractionDigits: 1 + ) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.noiseFloor, + value: helper.noiseFloorDisplay, + delta: helper.noiseFloorDelta.map(Double.init), + higherIsBetter: false, unit: " dBm", fractionDigits: 0 + ) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsSent, value: helper.packetsSentDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsReceived, value: helper.packetsReceivedDisplay) + } } // MARK: - Status Section struct NodeStatusSection: View { - @Environment(\.appTheme) private var theme - @Bindable var helper: NodeStatusViewModel - let connectionState: DeviceConnectionState - let onLoad: () async -> Void - @ViewBuilder let rows: () -> Rows - - var body: some View { - Section { - DisclosureGroup(isExpanded: $helper.statusExpanded) { - if helper.isLoadingStatus && helper.status == nil { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let errorMessage = helper.statusSectionError, helper.status == nil { - Text(errorMessage) - .foregroundStyle(.orange) - } else if helper.status != nil { - rows() - - if let timestamp = helper.previousSnapshotTimestamp { - Text(timestamp) - .font(.caption) - .foregroundStyle(.secondary) - } - - NavigationLink(value: NodeStatusRoute.statusHistory) { - Text(L10n.RemoteNodes.RemoteNodes.History.title) - } - } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Status.statusSection) - Spacer() - SectionReloadButton( - isLoading: helper.isLoadingStatus, - isLoaded: helper.statusLoaded, - hasError: helper.statusSectionError != nil, - isDisabled: connectionState != .ready, - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadStatus, - onReload: onLoad - ) - } - } - .onChange(of: helper.statusExpanded) { _, isExpanded in - if isExpanded && !helper.statusLoaded && !helper.isLoadingStatus { - Task { - await onLoad() - } - } - } + @Environment(\.appTheme) private var theme + @Bindable var helper: NodeStatusViewModel + let connectionState: DeviceConnectionState + let onLoad: () async -> Void + @ViewBuilder let rows: () -> Rows + + var body: some View { + Section { + DisclosureGroup(isExpanded: $helper.statusExpanded) { + if helper.isLoadingStatus, helper.status == nil { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let errorMessage = helper.statusSectionError, helper.status == nil { + Text(errorMessage) + .foregroundStyle(.orange) + } else if helper.status != nil { + rows() + + if let timestamp = helper.previousSnapshotTimestamp { + Text(timestamp) + .font(.caption) + .foregroundStyle(.secondary) + } + + NavigationLink(value: NodeStatusRoute.statusHistory) { + Text(L10n.RemoteNodes.RemoteNodes.History.title) + } + } + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Status.statusSection) + Spacer() + SectionReloadButton( + isLoading: helper.isLoadingStatus, + isLoaded: helper.statusLoaded, + hasError: helper.statusSectionError != nil, + isDisabled: connectionState != .ready, + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadStatus, + onReload: onLoad + ) } - .themedRowBackground(theme) + } + .onChange(of: helper.statusExpanded) { _, isExpanded in + if isExpanded, !helper.statusLoaded, !helper.isLoadingStatus { + Task { + await onLoad() + } + } + } } + .themedRowBackground(theme) + } } // MARK: - Metric Row struct NodeMetricRow: View { - let label: String - let value: String - let delta: Double? - let higherIsBetter: Bool - let unit: String - let fractionDigits: Int - - var body: some View { - LabeledContent { - VStack(alignment: .trailing, spacing: 2) { - Text(value) - if let delta { - StatusDeltaView(delta: delta, higherIsBetter: higherIsBetter, unit: unit, fractionDigits: fractionDigits) - } - } - } label: { - Text(label) + let label: String + let value: String + let delta: Double? + let higherIsBetter: Bool + let unit: String + let fractionDigits: Int + + var body: some View { + LabeledContent { + VStack(alignment: .trailing, spacing: 2) { + Text(value) + if let delta { + StatusDeltaView(delta: delta, higherIsBetter: higherIsBetter, unit: unit, fractionDigits: fractionDigits) } + } + } label: { + Text(label) } + } } // MARK: - Telemetry Row struct NodeTelemetryRow: View { - let dataPoint: LPPDataPoint - let ocvArray: [Int] - - var body: some View { - if dataPoint.type == .voltage, case .float(let voltage) = dataPoint.value { - let millivolts = Int(voltage * 1000) - let battery = BatteryInfo(level: millivolts) - let percentage = battery.percentage(using: ocvArray) - - LabeledContent(dataPoint.type.localizedName) { - VStack(alignment: .trailing, spacing: 2) { - Text(dataPoint.formattedValue) - Text("\(percentage)%") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } else { - LabeledContent(dataPoint.type.localizedName, value: dataPoint.formattedValue) + let dataPoint: LPPDataPoint + let ocvArray: [Int] + + var body: some View { + if dataPoint.type == .voltage, case let .float(voltage) = dataPoint.value { + let millivolts = Int(voltage * 1000) + let battery = BatteryInfo(level: millivolts) + let percentage = battery.percentage(using: ocvArray) + + LabeledContent(dataPoint.type.localizedName) { + VStack(alignment: .trailing, spacing: 2) { + Text(dataPoint.formattedValue) + Text("\(percentage)%") + .font(.caption) + .foregroundStyle(.secondary) } + } + } else { + LabeledContent(dataPoint.type.localizedName, value: dataPoint.formattedValue) } + } } // MARK: - Battery Curve Disclosure Section struct NodeBatteryCurveDisclosureSection: View { - @Environment(\.appTheme) private var theme - @Bindable var helper: NodeStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: DeviceConnectionState - let connectedDeviceID: UUID? - - var body: some View { - Section { - DisclosureGroup(isExpanded: $helper.isBatteryCurveExpanded) { - BatteryCurveSection( - availablePresets: OCVPreset.nodePresets, - headerText: "", - footerText: "", - selectedPreset: $helper.selectedOCVPreset, - voltageValues: $helper.ocvValues, - onSave: helper.saveOCVSettings, - isDisabled: connectionState != .ready - ) - - if let error = helper.ocvError { - Text(error) - .font(.caption) - .foregroundStyle(.orange) - } - } label: { - Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurve) - } - .onChange(of: helper.isBatteryCurveExpanded) { _, isExpanded in - if isExpanded, let deviceID = connectedDeviceID { - Task { - await helper.loadOCVSettings(publicKey: session.publicKey, radioID: deviceID) - } - } - } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurveFooter) + @Environment(\.appTheme) private var theme + @Bindable var helper: NodeStatusViewModel + let session: RemoteNodeSessionDTO + let connectionState: DeviceConnectionState + let connectedDeviceID: UUID? + + var body: some View { + Section { + DisclosureGroup(isExpanded: $helper.isBatteryCurveExpanded) { + BatteryCurveSection( + availablePresets: OCVPreset.nodePresets, + headerText: "", + footerText: "", + selectedPreset: $helper.selectedOCVPreset, + voltageValues: $helper.ocvValues, + onSave: helper.saveOCVSettings, + isDisabled: connectionState != .ready + ) + + if let error = helper.ocvError { + Text(error) + .font(.caption) + .foregroundStyle(.orange) + } + } label: { + Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurve) + } + .onChange(of: helper.isBatteryCurveExpanded) { _, isExpanded in + if isExpanded, let deviceID = connectedDeviceID { + Task { + await helper.loadOCVSettings(publicKey: session.publicKey, radioID: deviceID) + } } - .themedRowBackground(theme) + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurveFooter) } + .themedRowBackground(theme) + } } // MARK: - Telemetry Disclosure Section struct NodeTelemetryDisclosureSection: View { - @Environment(\.appTheme) private var theme - @Bindable var helper: NodeStatusViewModel - let connectionState: DeviceConnectionState - let onRequestTelemetry: () async -> Void - - var body: some View { - Section { - DisclosureGroup(isExpanded: $helper.telemetryExpanded) { - if helper.isLoadingTelemetry { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let errorMessage = helper.telemetrySectionError, helper.telemetry == nil { - Text(errorMessage) - .foregroundStyle(.orange) - } else if helper.telemetry != nil { - if helper.cachedDataPoints.isEmpty { - Text(L10n.RemoteNodes.RemoteNodes.Status.noSensorData) - .foregroundStyle(.secondary) - } else if helper.hasMultipleChannels { - ForEach(helper.groupedDataPoints, id: \.channel) { group in - Section { - ForEach(group.dataPoints, id: \.self) { dataPoint in - NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) - } - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Status.channel(Int(group.channel))) - .fontWeight(.semibold) - } - } - } else { - ForEach(helper.cachedDataPoints, id: \.self) { dataPoint in - NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) - } - } - - NavigationLink(value: NodeStatusRoute.telemetryHistory) { - Text(L10n.RemoteNodes.RemoteNodes.History.title) - } - } else { - Text(L10n.RemoteNodes.RemoteNodes.Status.noTelemetryData) - .foregroundStyle(.secondary) - } - } label: { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Status.telemetry) - Spacer() - SectionReloadButton( - isLoading: helper.isLoadingTelemetry, - isLoaded: helper.telemetryLoaded, - hasError: helper.telemetrySectionError != nil, - isDisabled: connectionState != .ready, - accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadTelemetry, - onReload: onRequestTelemetry - ) + @Environment(\.appTheme) private var theme + @Bindable var helper: NodeStatusViewModel + let connectionState: DeviceConnectionState + let onRequestTelemetry: () async -> Void + + var body: some View { + Section { + DisclosureGroup(isExpanded: $helper.telemetryExpanded) { + if helper.isLoadingTelemetry { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let errorMessage = helper.telemetrySectionError, helper.telemetry == nil { + Text(errorMessage) + .foregroundStyle(.orange) + } else if helper.telemetry != nil { + if helper.cachedDataPoints.isEmpty { + Text(L10n.RemoteNodes.RemoteNodes.Status.noSensorData) + .foregroundStyle(.secondary) + } else if helper.hasMultipleChannels { + ForEach(helper.groupedDataPoints, id: \.channel) { group in + Section { + ForEach(group.dataPoints, id: \.self) { dataPoint in + NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) } + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Status.channel(Int(group.channel))) + .fontWeight(.semibold) + } } - .onChange(of: helper.telemetryExpanded) { _, isExpanded in - if isExpanded && !helper.telemetryLoaded && !helper.isLoadingTelemetry { - Task { - await onRequestTelemetry() - } - } + } else { + ForEach(helper.cachedDataPoints, id: \.self) { dataPoint in + NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Status.telemetryFooter) + } + + NavigationLink(value: NodeStatusRoute.telemetryHistory) { + Text(L10n.RemoteNodes.RemoteNodes.History.title) + } + } else { + Text(L10n.RemoteNodes.RemoteNodes.Status.noTelemetryData) + .foregroundStyle(.secondary) + } + } label: { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Status.telemetry) + Spacer() + SectionReloadButton( + isLoading: helper.isLoadingTelemetry, + isLoaded: helper.telemetryLoaded, + hasError: helper.telemetrySectionError != nil, + isDisabled: connectionState != .ready, + accessibilityLabel: L10n.RemoteNodes.RemoteNodes.Status.Accessibility.reloadTelemetry, + onReload: onRequestTelemetry + ) + } + } + .onChange(of: helper.telemetryExpanded) { _, isExpanded in + if isExpanded, !helper.telemetryLoaded, !helper.isLoadingTelemetry { + Task { + await onRequestTelemetry() + } } - .themedRowBackground(theme) + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Status.telemetryFooter) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/RemoteNodes/StatusDeltaView.swift b/MC1/Views/RemoteNodes/StatusDeltaView.swift index 27ed8e53..4fb550c1 100644 --- a/MC1/Views/RemoteNodes/StatusDeltaView.swift +++ b/MC1/Views/RemoteNodes/StatusDeltaView.swift @@ -2,42 +2,42 @@ import SwiftUI /// Displays a trend arrow and delta value next to a status metric. struct StatusDeltaView: View { - let delta: Double - /// Whether higher values are better (true for battery/SNR, false for noise floor) - let higherIsBetter: Bool - let unit: String - /// Number of decimal places to display (0 for integers like mV/dBm, 1 for floats like SNR) - let fractionDigits: Int + let delta: Double + /// Whether higher values are better (true for battery/SNR, false for noise floor) + let higherIsBetter: Bool + let unit: String + /// Number of decimal places to display (0 for integers like mV/dBm, 1 for floats like SNR) + let fractionDigits: Int - var body: some View { - HStack(spacing: 2) { - Image(systemName: delta > 0 ? "arrow.up" : "arrow.down") - .imageScale(.small) - Text("\(abs(delta).formatted(.number.precision(.fractionLength(fractionDigits))))\(unit)") - } - .font(.caption) - .foregroundStyle(deltaColor) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityDescription) + var body: some View { + HStack(spacing: 2) { + Image(systemName: delta > 0 ? "arrow.up" : "arrow.down") + .imageScale(.small) + Text("\(abs(delta).formatted(.number.precision(.fractionLength(fractionDigits))))\(unit)") } + .font(.caption) + .foregroundStyle(deltaColor) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityDescription) + } - private var isImprovement: Bool { - higherIsBetter ? delta > 0 : delta < 0 - } + private var isImprovement: Bool { + higherIsBetter ? delta > 0 : delta < 0 + } - private var deltaColor: Color { - if abs(delta) < 0.01 { return .secondary } - return isImprovement ? .green : .orange - } + private var deltaColor: Color { + if abs(delta) < 0.01 { return .secondary } + return isImprovement ? .green : .orange + } - private var accessibilityDescription: String { - let direction = delta > 0 - ? L10n.RemoteNodes.RemoteNodes.History.A11y.increased - : L10n.RemoteNodes.RemoteNodes.History.A11y.decreased - let quality = isImprovement - ? L10n.RemoteNodes.RemoteNodes.History.A11y.improved - : L10n.RemoteNodes.RemoteNodes.History.A11y.degraded - let formatted = abs(delta).formatted(.number.precision(.fractionLength(fractionDigits))) - return L10n.RemoteNodes.RemoteNodes.History.A11y.deltaDescription(quality, direction, formatted, unit) - } + private var accessibilityDescription: String { + let direction = delta > 0 + ? L10n.RemoteNodes.RemoteNodes.History.A11y.increased + : L10n.RemoteNodes.RemoteNodes.History.A11y.decreased + let quality = isImprovement + ? L10n.RemoteNodes.RemoteNodes.History.A11y.improved + : L10n.RemoteNodes.RemoteNodes.History.A11y.degraded + let formatted = abs(delta).formatted(.number.precision(.fractionLength(fractionDigits))) + return L10n.RemoteNodes.RemoteNodes.History.A11y.deltaDescription(quality, direction, formatted, unit) + } } diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift index 39538d9a..dae151b0 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift @@ -3,211 +3,209 @@ import SwiftUI /// Offline-accessible overview of all historical telemetry charts for a repeater. struct TelemetryHistoryOverviewView: View { - let publicKey: Data - let radioID: UUID - let showNeighbors: Bool - - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var viewModel = TelemetryHistoryOverviewViewModel() - @State private var radioExpanded = true - @State private var sensorsExpanded: Bool - @State private var neighborsExpanded = false - - init(publicKey: Data, radioID: UUID, showNeighbors: Bool = true) { - self.publicKey = publicKey - self.radioID = radioID - self.showNeighbors = showNeighbors - self._sensorsExpanded = State(initialValue: !showNeighbors) - } - - var body: some View { - let filtered = viewModel.filteredSnapshots - List { - if !viewModel.hasSnapshots { - emptyState - } else { - HistoryTimeRangePicker(selection: $viewModel.timeRange) - radioSection(filtered: filtered) - sensorsSection(filtered: filtered) - if showNeighbors { - neighborsSection(filtered: filtered) - } - retentionFooter - } - } - .themedCanvas(theme) - .chartScrubbingScrollLock() - .navigationTitle(L10n.RemoteNodes.RemoteNodes.History.overviewTitle) - .liquidGlassToolbarBackground() - .task { - guard let store = appState.offlineDataStore else { return } - await viewModel.loadData( - dataStore: store, publicKey: publicKey, radioID: radioID - ) + let publicKey: Data + let radioID: UUID + let showNeighbors: Bool + + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var viewModel = TelemetryHistoryOverviewViewModel() + @State private var radioExpanded = true + @State private var sensorsExpanded: Bool + @State private var neighborsExpanded = false + + init(publicKey: Data, radioID: UUID, showNeighbors: Bool = true) { + self.publicKey = publicKey + self.radioID = radioID + self.showNeighbors = showNeighbors + _sensorsExpanded = State(initialValue: !showNeighbors) + } + + var body: some View { + let filtered = viewModel.filteredSnapshots + List { + if !viewModel.hasSnapshots { + emptyState + } else { + HistoryTimeRangePicker(selection: $viewModel.timeRange) + radioSection(filtered: filtered) + sensorsSection(filtered: filtered) + if showNeighbors { + neighborsSection(filtered: filtered) } + retentionFooter + } } - - // MARK: - Empty State - - @ViewBuilder - private var emptyState: some View { - ContentUnavailableView( - L10n.RemoteNodes.RemoteNodes.History.overviewTitle, - systemImage: "chart.line.uptrend.xyaxis", - description: Text(L10n.RemoteNodes.RemoteNodes.History.noSnapshotsMessage) - ) + .themedCanvas(theme) + .chartScrubbingScrollLock() + .navigationTitle(L10n.RemoteNodes.RemoteNodes.History.overviewTitle) + .liquidGlassToolbarBackground() + .task { + guard let store = appState.offlineDataStore else { return } + await viewModel.loadData( + dataStore: store, publicKey: publicKey, radioID: radioID + ) + } + } + + // MARK: - Empty State + + private var emptyState: some View { + ContentUnavailableView( + L10n.RemoteNodes.RemoteNodes.History.overviewTitle, + systemImage: "chart.line.uptrend.xyaxis", + description: Text(L10n.RemoteNodes.RemoteNodes.History.noSnapshotsMessage) + ) + } + + // MARK: - Radio Section + + @ViewBuilder + private func radioSection(filtered: [NodeStatusSnapshotDTO]) -> some View { + let hasRadioData = filtered.contains { + $0.batteryMillivolts != nil || $0.lastSNR != nil || + $0.lastRSSI != nil || $0.noiseFloor != nil || + $0.packetsSent != nil || $0.packetsReceived != nil || + $0.receiveErrors != nil || + $0.postedCount != nil || $0.postPushCount != nil } - // MARK: - Radio Section - - @ViewBuilder - private func radioSection(filtered: [NodeStatusSnapshotDTO]) -> some View { - let hasRadioData = filtered.contains { - $0.batteryMillivolts != nil || $0.lastSNR != nil || - $0.lastRSSI != nil || $0.noiseFloor != nil || - $0.packetsSent != nil || $0.packetsReceived != nil || - $0.receiveErrors != nil || - $0.postedCount != nil || $0.postPushCount != nil - } - - if hasRadioData { - Section { - DisclosureGroup( - L10n.RemoteNodes.RemoteNodes.History.radioSection, - isExpanded: $radioExpanded - ) { - RadioMetricCharts(snapshots: filtered, ocvArray: viewModel.ocvArray) { chart in - chart - } - } - } - .themedRowBackground(theme) + if hasRadioData { + Section { + DisclosureGroup( + L10n.RemoteNodes.RemoteNodes.History.radioSection, + isExpanded: $radioExpanded + ) { + RadioMetricCharts(snapshots: filtered, ocvArray: viewModel.ocvArray) { chart in + chart + } } + } + .themedRowBackground(theme) } - - // MARK: - Sensors Section - - @ViewBuilder - private func sensorsSection(filtered: [NodeStatusSnapshotDTO]) -> some View { - if viewModel.hasTelemetryData(in: filtered) { - let groups = ChannelGroup.groups(from: filtered) - Section { - DisclosureGroup( - L10n.RemoteNodes.RemoteNodes.History.sensorsSection, - isExpanded: $sensorsExpanded - ) { - if groups.count > 1 { - ForEach(groups) { group in - Section(L10n.RemoteNodes.RemoteNodes.Status.channel(group.channel)) { - ForEach(group.charts) { chart in - chartView(for: chart) - } - } - } - } else if let group = groups.first { - ForEach(group.charts) { chart in - chartView(for: chart) - } - } + } + + // MARK: - Sensors Section + + @ViewBuilder + private func sensorsSection(filtered: [NodeStatusSnapshotDTO]) -> some View { + if viewModel.hasTelemetryData(in: filtered) { + let groups = ChannelGroup.groups(from: filtered) + Section { + DisclosureGroup( + L10n.RemoteNodes.RemoteNodes.History.sensorsSection, + isExpanded: $sensorsExpanded + ) { + if groups.count > 1 { + ForEach(groups) { group in + Section(L10n.RemoteNodes.RemoteNodes.Status.channel(group.channel)) { + ForEach(group.charts) { chart in + chartView(for: chart) } + } } - .themedRowBackground(theme) - } else if viewModel.hasSnapshots { - Section { - Text(L10n.RemoteNodes.RemoteNodes.History.sectionNotCaptured( - L10n.RemoteNodes.RemoteNodes.History.sensorsSection - )) - .font(.subheadline) - .foregroundStyle(.secondary) + } else if let group = groups.first { + ForEach(group.charts) { chart in + chartView(for: chart) } - .themedRowBackground(theme) + } } + } + .themedRowBackground(theme) + } else if viewModel.hasSnapshots { + Section { + Text(L10n.RemoteNodes.RemoteNodes.History.sectionNotCaptured( + L10n.RemoteNodes.RemoteNodes.History.sensorsSection + )) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .themedRowBackground(theme) } - - // MARK: - Neighbors Section - - @ViewBuilder - private func neighborsSection(filtered: [NodeStatusSnapshotDTO]) -> some View { - if viewModel.hasNeighborData(in: filtered) { - let neighborCharts = buildNeighborCharts(from: filtered) - Section { - DisclosureGroup( - L10n.RemoteNodes.RemoteNodes.History.neighborsSection, - isExpanded: $neighborsExpanded - ) { - ForEach(neighborCharts, id: \.prefix) { neighbor in - MetricChartView( - title: neighbor.name, - unit: "dB", - dataPoints: neighbor.dataPoints, - accentColor: .blue - ) - } - } - } - .themedRowBackground(theme) - } else if viewModel.hasSnapshots { - Section { - Text(L10n.RemoteNodes.RemoteNodes.History.sectionNotCaptured( - L10n.RemoteNodes.RemoteNodes.History.neighborsSection - )) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .themedRowBackground(theme) + } + + // MARK: - Neighbors Section + + @ViewBuilder + private func neighborsSection(filtered: [NodeStatusSnapshotDTO]) -> some View { + if viewModel.hasNeighborData(in: filtered) { + let neighborCharts = buildNeighborCharts(from: filtered) + Section { + DisclosureGroup( + L10n.RemoteNodes.RemoteNodes.History.neighborsSection, + isExpanded: $neighborsExpanded + ) { + ForEach(neighborCharts, id: \.prefix) { neighbor in + MetricChartView( + title: neighbor.name, + unit: "dB", + dataPoints: neighbor.dataPoints, + accentColor: .blue + ) + } } + } + .themedRowBackground(theme) + } else if viewModel.hasSnapshots { + Section { + Text(L10n.RemoteNodes.RemoteNodes.History.sectionNotCaptured( + L10n.RemoteNodes.RemoteNodes.History.neighborsSection + )) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .themedRowBackground(theme) } - - // MARK: - Helpers - - private func chartView(for chart: TelemetryChartGroup) -> MetricChartView { - MetricChartView( - title: chart.title, - unit: chart.sensorType?.localizedUnitSymbol ?? "", - dataPoints: chart.dataPoints, - accentColor: chart.sensorType?.chartColor ?? .cyan, - yAxisDomain: chart.sensorType == .voltage ? viewModel.ocvArray.voltageChartDomain(dataPoints: chart.dataPoints) : nil + } + + // MARK: - Helpers + + private func chartView(for chart: TelemetryChartGroup) -> MetricChartView { + MetricChartView( + title: chart.title, + unit: chart.sensorType?.localizedUnitSymbol ?? "", + dataPoints: chart.dataPoints, + accentColor: chart.sensorType?.chartColor ?? .cyan, + yAxisDomain: chart.sensorType == .voltage ? viewModel.ocvArray.voltageChartDomain(dataPoints: chart.dataPoints) : nil + ) + } + + private func buildNeighborCharts(from filtered: [NodeStatusSnapshotDTO]) -> [NeighborChart] { + var charts: [Data: NeighborChart] = [:] + for snapshot in filtered { + for neighbor in snapshot.neighborSnapshots ?? [] { + let point = MetricChartView.DataPoint( + id: snapshot.id, date: snapshot.timestamp, value: neighbor.snr ) - } - - private func buildNeighborCharts(from filtered: [NodeStatusSnapshotDTO]) -> [NeighborChart] { - var charts: [Data: NeighborChart] = [:] - for snapshot in filtered { - for neighbor in snapshot.neighborSnapshots ?? [] { - let point = MetricChartView.DataPoint( - id: snapshot.id, date: snapshot.timestamp, value: neighbor.snr - ) - if charts[neighbor.publicKeyPrefix] != nil { - charts[neighbor.publicKeyPrefix]!.dataPoints.append(point) - } else { - let hexName = neighbor.publicKeyPrefix - .map { String(format: "%02X", $0) }.joined() - let resolvedName = viewModel.resolveNeighborName(prefix: neighbor.publicKeyPrefix) ?? hexName - charts[neighbor.publicKeyPrefix] = NeighborChart( - prefix: neighbor.publicKeyPrefix, - name: resolvedName, - dataPoints: [point] - ) - } - } + if charts[neighbor.publicKeyPrefix] != nil { + charts[neighbor.publicKeyPrefix]!.dataPoints.append(point) + } else { + let hexName = neighbor.publicKeyPrefix + .map { String(format: "%02X", $0) }.joined() + let resolvedName = viewModel.resolveNeighborName(prefix: neighbor.publicKeyPrefix) ?? hexName + charts[neighbor.publicKeyPrefix] = NeighborChart( + prefix: neighbor.publicKeyPrefix, + name: resolvedName, + dataPoints: [point] + ) } - return charts.values.sorted { $0.name < $1.name } + } } + return charts.values.sorted { $0.name < $1.name } + } - private var retentionFooter: some View { - Section { - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) - } - .themedRowBackground(theme) + private var retentionFooter: some View { + Section {} footer: { + Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) } + .themedRowBackground(theme) + } } // MARK: - Private Types private struct NeighborChart { - let prefix: Data - let name: String - var dataPoints: [MetricChartView.DataPoint] + let prefix: Data + let name: String + var dataPoints: [MetricChartView.DataPoint] } diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift index bd8896f5..60ce554d 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift @@ -5,75 +5,76 @@ import SwiftUI @Observable @MainActor final class TelemetryHistoryOverviewViewModel { - - // MARK: - State - - private(set) var snapshots: [NodeStatusSnapshotDTO] = [] - private(set) var ocvArray: [Int] = OCVPreset.liIon.ocvArray - private(set) var contacts: [ContactDTO] = [] - private(set) var discoveredNodes: [DiscoveredNodeDTO] = [] - var timeRange: HistoryTimeRange = .default - - // MARK: - Computed - - var filteredSnapshots: [NodeStatusSnapshotDTO] { - guard let start = timeRange.startDate else { return snapshots } - return snapshots.filter { $0.timestamp >= start } - } - - var hasSnapshots: Bool { !snapshots.isEmpty } - - var hasNeighborData: Bool { - hasNeighborData(in: filteredSnapshots) + // MARK: - State + + private(set) var snapshots: [NodeStatusSnapshotDTO] = [] + private(set) var ocvArray: [Int] = OCVPreset.liIon.ocvArray + private(set) var contacts: [ContactDTO] = [] + private(set) var discoveredNodes: [DiscoveredNodeDTO] = [] + var timeRange: HistoryTimeRange = .default + + // MARK: - Computed + + var filteredSnapshots: [NodeStatusSnapshotDTO] { + guard let start = timeRange.startDate else { return snapshots } + return snapshots.filter { $0.timestamp >= start } + } + + var hasSnapshots: Bool { + !snapshots.isEmpty + } + + var hasNeighborData: Bool { + hasNeighborData(in: filteredSnapshots) + } + + var hasTelemetryData: Bool { + hasTelemetryData(in: filteredSnapshots) + } + + var channelGroups: [ChannelGroup] { + ChannelGroup.groups(from: filteredSnapshots) + } + + func hasNeighborData(in snapshots: [NodeStatusSnapshotDTO]) -> Bool { + snapshots.contains { $0.neighborSnapshots?.isEmpty == false } + } + + func hasTelemetryData(in snapshots: [NodeStatusSnapshotDTO]) -> Bool { + snapshots.contains { $0.telemetryEntries?.isEmpty == false } + } + + // MARK: - Loading + + func loadData(dataStore: PersistenceStore, publicKey: Data, radioID: UUID) async { + do { + snapshots = try await dataStore.fetchNodeStatusSnapshots( + nodePublicKey: publicKey, since: nil + ) + } catch { + snapshots = [] } - var hasTelemetryData: Bool { - hasTelemetryData(in: filteredSnapshots) + do { + if let contact = try await dataStore.fetchContact( + radioID: radioID, publicKey: publicKey + ) { + ocvArray = contact.activeOCVArray + } + } catch { + // Keep default liIon } - var channelGroups: [ChannelGroup] { - ChannelGroup.groups(from: filteredSnapshots) - } - - func hasNeighborData(in snapshots: [NodeStatusSnapshotDTO]) -> Bool { - snapshots.contains { $0.neighborSnapshots?.isEmpty == false } - } - - func hasTelemetryData(in snapshots: [NodeStatusSnapshotDTO]) -> Bool { - snapshots.contains { $0.telemetryEntries?.isEmpty == false } - } - - // MARK: - Loading - - func loadData(dataStore: PersistenceStore, publicKey: Data, radioID: UUID) async { - do { - snapshots = try await dataStore.fetchNodeStatusSnapshots( - nodePublicKey: publicKey, since: nil - ) - } catch { - snapshots = [] - } - - do { - if let contact = try await dataStore.fetchContact( - radioID: radioID, publicKey: publicKey - ) { - ocvArray = contact.activeOCVArray - } - } catch { - // Keep default liIon - } - - contacts = (try? await dataStore.fetchContacts(radioID: radioID)) ?? [] - discoveredNodes = (try? await dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] - } - - func resolveNeighborName(prefix: Data) -> String? { - NeighborNameResolver.resolveName( - for: prefix, - contacts: contacts, - discoveredNodes: discoveredNodes, - userLocation: nil - ) - } + contacts = await (try? dataStore.fetchContacts(radioID: radioID)) ?? [] + discoveredNodes = await (try? dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] + } + + func resolveNeighborName(prefix: Data) -> String? { + NeighborNameResolver.resolveName( + for: prefix, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: nil + ) + } } diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryView.swift b/MC1/Views/RemoteNodes/TelemetryHistoryView.swift index f60ea361..56ba07c0 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryView.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryView.swift @@ -3,60 +3,59 @@ import SwiftUI /// Drill-down view showing historical charts for telemetry metrics grouped by channel and type. struct TelemetryHistoryView: View { - @Environment(\.appTheme) private var theme - let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] - let ocvArray: [Int] + @Environment(\.appTheme) private var theme + let fetchSnapshots: @Sendable () async -> [NodeStatusSnapshotDTO] + let ocvArray: [Int] - @State private var snapshots: [NodeStatusSnapshotDTO] = [] - @State private var timeRange: HistoryTimeRange = .default + @State private var snapshots: [NodeStatusSnapshotDTO] = [] + @State private var timeRange: HistoryTimeRange = .default - private var filteredSnapshots: [NodeStatusSnapshotDTO] { - guard let start = timeRange.startDate else { return snapshots } - return snapshots.filter { $0.timestamp >= start } - } + private var filteredSnapshots: [NodeStatusSnapshotDTO] { + guard let start = timeRange.startDate else { return snapshots } + return snapshots.filter { $0.timestamp >= start } + } - var body: some View { - List { - HistoryTimeRangePicker(selection: $timeRange) + var body: some View { + List { + HistoryTimeRangePicker(selection: $timeRange) - let groups = ChannelGroup.groups(from: filteredSnapshots) - if groups.count > 1 { - ForEach(groups) { channelGroup in - Section { - ForEach(channelGroup.charts) { chart in - chartView(for: chart) - } - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Status.channel(channelGroup.channel)) - } - .themedRowBackground(theme) - } - } else if let singleGroup = groups.first { - ForEach(singleGroup.charts) { chart in - Section { - chartView(for: chart) - } - .themedRowBackground(theme) - } + let groups = ChannelGroup.groups(from: filteredSnapshots) + if groups.count > 1 { + ForEach(groups) { channelGroup in + Section { + ForEach(channelGroup.charts) { chart in + chartView(for: chart) } + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Status.channel(channelGroup.channel)) + } + .themedRowBackground(theme) } - .themedCanvas(theme) - .chartScrubbingScrollLock() - .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.telemetry) - .liquidGlassToolbarBackground() - .task { - snapshots = await fetchSnapshots() + } else if let singleGroup = groups.first { + ForEach(singleGroup.charts) { chart in + Section { + chartView(for: chart) + } + .themedRowBackground(theme) } + } } - - private func chartView(for chart: TelemetryChartGroup) -> MetricChartView { - MetricChartView( - title: chart.title, - unit: chart.sensorType?.localizedUnitSymbol ?? "", - dataPoints: chart.dataPoints, - accentColor: chart.sensorType?.chartColor ?? .cyan, - yAxisDomain: chart.sensorType == .voltage ? ocvArray.voltageChartDomain(dataPoints: chart.dataPoints) : nil - ) + .themedCanvas(theme) + .chartScrubbingScrollLock() + .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.telemetry) + .liquidGlassToolbarBackground() + .task { + snapshots = await fetchSnapshots() } + } + private func chartView(for chart: TelemetryChartGroup) -> MetricChartView { + MetricChartView( + title: chart.title, + unit: chart.sensorType?.localizedUnitSymbol ?? "", + dataPoints: chart.dataPoints, + accentColor: chart.sensorType?.chartColor ?? .cyan, + yAxisDomain: chart.sensorType == .voltage ? ocvArray.voltageChartDomain(dataPoints: chart.dataPoints) : nil + ) + } } diff --git a/MC1/Views/Settings/AdvancedSettingsView.swift b/MC1/Views/Settings/AdvancedSettingsView.swift index 53561fe5..8285bc01 100644 --- a/MC1/Views/Settings/AdvancedSettingsView.swift +++ b/MC1/Views/Settings/AdvancedSettingsView.swift @@ -1,171 +1,171 @@ -import SwiftUI import MC1Services +import SwiftUI /// Advanced settings sheet for power users struct AdvancedSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - - @State private var selectedOCVPreset: OCVPreset = .liIon - @State private var ocvValues: [Int] = OCVPreset.liIon.ocvArray - @State private var showingImportKeySheet = false - @State private var showingRegenerateSheet = false - - var body: some View { - List { - // Manual Radio Configuration - AdvancedRadioSection() - - // Path Hash Mode (firmware v10+) - if appState.connectedDevice?.supportsPathHashMode == true { - PathHashModeSection() - } - - // Default Flood Scope (firmware v11+) - if appState.connectedDevice?.supportsDefaultFloodScope == true { - DefaultFloodScopeSection() - } - - // Nodes Settings - NodesSettingsSection() - - // Auto-Remove Old Nodes - StaleNodeCleanupSection() - - // Telemetry Settings - TelemetrySettingsSection() - - // Direct Messages Settings - DirectMessagesSettingsSection() - - // Battery Curve - BatteryCurveSection( - availablePresets: OCVPreset.selectablePresets, - headerText: L10n.Settings.BatteryCurve.header, - footerText: L10n.Settings.BatteryCurve.footer, - selectedPreset: $selectedOCVPreset, - voltageValues: $ocvValues, - onSave: saveOCVToDevice, - isDisabled: appState.connectionState != .ready - ) - - // Config Export/Import - ConfigExportImportSection() - - // Device Actions - DeviceActionsSection() - - // Identity - DeviceIdentitySection( - showingImportKeySheet: $showingImportKeySheet, - showingRegenerateSheet: $showingRegenerateSheet - ) - - // Danger Zone - DangerZoneSection() - } - .themedCanvas(theme) - .settingsSubpageDestinations() - .sheet(isPresented: $showingImportKeySheet) { - ImportKeySheet() - } - .sheet(isPresented: $showingRegenerateSheet) { - RegenerateIdentitySheet() - } - .scrollDismissesKeyboard(.interactively) - .navigationTitle(L10n.Settings.AdvancedSettings.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(L10n.Localizable.Common.done) { - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) - } - } - } - .task(id: refreshTaskID) { - await refreshDeviceSettings() - } - .task(id: appState.connectedDevice?.id) { - loadOCVFromDevice() - } - .onChange(of: appState.connectedDevice) { _, newDevice in - if newDevice == nil { - dismiss() - } - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + + @State private var selectedOCVPreset: OCVPreset = .liIon + @State private var ocvValues: [Int] = OCVPreset.liIon.ocvArray + @State private var showingImportKeySheet = false + @State private var showingRegenerateSheet = false + + var body: some View { + List { + // Manual Radio Configuration + AdvancedRadioSection() + + // Path Hash Mode (firmware v10+) + if appState.connectedDevice?.supportsPathHashMode == true { + PathHashModeSection() + } + + // Default Flood Scope (firmware v11+) + if appState.connectedDevice?.supportsDefaultFloodScope == true { + DefaultFloodScopeSection() + } + + // Nodes Settings + NodesSettingsSection() + + // Auto-Remove Old Nodes + StaleNodeCleanupSection() + + // Telemetry Settings + TelemetrySettingsSection() + + // Direct Messages Settings + DirectMessagesSettingsSection() + + // Battery Curve + BatteryCurveSection( + availablePresets: OCVPreset.selectablePresets, + headerText: L10n.Settings.BatteryCurve.header, + footerText: L10n.Settings.BatteryCurve.footer, + selectedPreset: $selectedOCVPreset, + voltageValues: $ocvValues, + onSave: saveOCVToDevice, + isDisabled: appState.connectionState != .ready + ) + + // Config Export/Import + ConfigExportImportSection() + + // Device Actions + DeviceActionsSection() + + // Identity + DeviceIdentitySection( + showingImportKeySheet: $showingImportKeySheet, + showingRegenerateSheet: $showingRegenerateSheet + ) + + // Danger Zone + DangerZoneSection() } - - private var refreshTaskID: String { - let deviceID = appState.connectedDevice?.id.uuidString ?? "none" - let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" - return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)" + .themedCanvas(theme) + .settingsSubpageDestinations() + .sheet(isPresented: $showingImportKeySheet) { + ImportKeySheet() } - - /// Fetch fresh device settings to ensure cache is up-to-date - private func refreshDeviceSettings() async { - // Wait until contact/channel sync contention is over before sending startup reads. - guard appState.canRunSettingsStartupReads, - let settingsService = appState.services?.settingsService else { return } - _ = try? await settingsService.getSelfInfo() - - // Only refresh autoAddConfig on v1.12+ firmware - if appState.connectedDevice?.supportsAutoAddConfig == true { - try? await settingsService.refreshAutoAddConfig() - } - - if appState.connectedDevice?.supportsDefaultFloodScope == true { - _ = try? await settingsService.getDefaultFloodScope() + .sheet(isPresented: $showingRegenerateSheet) { + RegenerateIdentitySheet() + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle(L10n.Settings.AdvancedSettings.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.Localizable.Common.done) { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) } + } + } + .task(id: refreshTaskID) { + await refreshDeviceSettings() + } + .task(id: appState.connectedDevice?.id) { + loadOCVFromDevice() + } + .onChange(of: appState.connectedDevice) { _, newDevice in + if newDevice == nil { + dismiss() + } + } + } + + private var refreshTaskID: String { + let deviceID = appState.connectedDevice?.id.uuidString ?? "none" + let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" + return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)" + } + + /// Fetch fresh device settings to ensure cache is up-to-date + private func refreshDeviceSettings() async { + // Wait until contact/channel sync contention is over before sending startup reads. + guard appState.canRunSettingsStartupReads, + let settingsService = appState.services?.settingsService else { return } + _ = try? await settingsService.getSelfInfo() + + // Only refresh autoAddConfig on v1.12+ firmware + if appState.connectedDevice?.supportsAutoAddConfig == true { + try? await settingsService.refreshAutoAddConfig() } - private func loadOCVFromDevice() { - guard let device = appState.connectedDevice else { return } - - if let presetName = device.ocvPreset { - if presetName == OCVPreset.custom.rawValue, let customString = device.customOCVArrayString { - let parsed = customString.split(separator: ",") - .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } - if parsed.count == 11 { - ocvValues = parsed - selectedOCVPreset = .custom - return - } - } - if let preset = OCVPreset(rawValue: presetName) { - selectedOCVPreset = preset - ocvValues = preset.ocvArray - return - } + if appState.connectedDevice?.supportsDefaultFloodScope == true { + _ = try? await settingsService.getDefaultFloodScope() + } + } + + private func loadOCVFromDevice() { + guard let device = appState.connectedDevice else { return } + + if let presetName = device.ocvPreset { + if presetName == OCVPreset.custom.rawValue, let customString = device.customOCVArrayString { + let parsed = customString.split(separator: ",") + .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if parsed.count == 11 { + ocvValues = parsed + selectedOCVPreset = .custom + return } - - selectedOCVPreset = .liIon - ocvValues = OCVPreset.liIon.ocvArray + } + if let preset = OCVPreset(rawValue: presetName) { + selectedOCVPreset = preset + ocvValues = preset.ocvArray + return + } } - private func saveOCVToDevice(preset: OCVPreset, values: [Int]) async { - guard let deviceService = appState.services?.deviceService, - let deviceID = appState.connectedDevice?.id else { return } - - if preset == .custom { - let customString = values.map(String.init).joined(separator: ",") - try? await deviceService.updateOCVSettings( - deviceID: deviceID, - preset: OCVPreset.custom.rawValue, - customArray: customString - ) - } else { - try? await deviceService.updateOCVSettings( - deviceID: deviceID, - preset: preset.rawValue, - customArray: nil - ) - } + selectedOCVPreset = .liIon + ocvValues = OCVPreset.liIon.ocvArray + } + + private func saveOCVToDevice(preset: OCVPreset, values: [Int]) async { + guard let deviceService = appState.services?.deviceService, + let deviceID = appState.connectedDevice?.id else { return } + + if preset == .custom { + let customString = values.map(String.init).joined(separator: ",") + try? await deviceService.updateOCVSettings( + deviceID: deviceID, + preset: OCVPreset.custom.rawValue, + customArray: customString + ) + } else { + try? await deviceService.updateOCVSettings( + deviceID: deviceID, + preset: preset.rawValue, + customArray: nil + ) } + } } diff --git a/MC1/Views/Settings/AppBackupDocument.swift b/MC1/Views/Settings/AppBackupDocument.swift index aa2000fa..1067c4b8 100644 --- a/MC1/Views/Settings/AppBackupDocument.swift +++ b/MC1/Views/Settings/AppBackupDocument.swift @@ -2,26 +2,28 @@ import SwiftUI import UniformTypeIdentifiers extension UTType { - static let mc1Backup = UTType(exportedAs: "com.pocketmesh.mc1.backup") + static let mc1Backup = UTType(exportedAs: "com.pocketmesh.mc1.backup") } struct AppBackupDocument: FileDocument { - static var readableContentTypes: [UTType] { [.mc1Backup] } + static var readableContentTypes: [UTType] { + [.mc1Backup] + } - let data: Data + let data: Data - init(data: Data) { - self.data = data - } + init(data: Data) { + self.data = data + } - init(configuration: ReadConfiguration) throws { - guard let data = configuration.file.regularFileContents else { - throw CocoaError(.fileReadCorruptFile) - } - self.data = data + init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents else { + throw CocoaError(.fileReadCorruptFile) } + self.data = data + } - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - FileWrapper(regularFileWithContents: data) - } + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } } diff --git a/MC1/Views/Settings/AppBackupError+Localized.swift b/MC1/Views/Settings/AppBackupError+Localized.swift index 392f2a35..07706cf0 100644 --- a/MC1/Views/Settings/AppBackupError+Localized.swift +++ b/MC1/Views/Settings/AppBackupError+Localized.swift @@ -2,28 +2,28 @@ import Foundation import MC1Services extension AppBackupError { - /// L10n-routed user-facing message for backup errors. The package-level - /// `errorDescription` provides an English fallback so MC1Services stays - /// independent of the app target's L10n; views should prefer this property. - var userFacingMessage: String { - switch self { - case .invalidFile: - return L10n.Settings.Settings.Backup.Error.invalidFile - case .fileTooLarge(let actualBytes, let maxBytes): - let actualMB = actualBytes / 1_048_576 - let maxMB = maxBytes / 1_048_576 - return L10n.Settings.Settings.Backup.Error.fileTooLarge(actualMB, maxMB) - case .decompressedTooLarge(let maxBytes): - let maxMB = maxBytes / 1_048_576 - return L10n.Settings.Settings.Backup.Error.decompressedTooLarge(maxMB) - case .unsupportedVersion(let found, let maxSupported): - return L10n.Settings.Settings.Backup.Error.unsupportedVersion(found, maxSupported) - case .corruptedManifest: - return L10n.Settings.Settings.Backup.Error.corruptedManifest - case .exportFailed(let underlying): - return L10n.Settings.Settings.Backup.Error.exportFailed(underlying.userFacingMessage) - case .importFailed(let underlying): - return L10n.Settings.Settings.Backup.Error.importFailed(underlying.userFacingMessage) - } + /// L10n-routed user-facing message for backup errors. The package-level + /// `errorDescription` provides an English fallback so MC1Services stays + /// independent of the app target's L10n; views should prefer this property. + var userFacingMessage: String { + switch self { + case .invalidFile: + return L10n.Settings.Settings.Backup.Error.invalidFile + case let .fileTooLarge(actualBytes, maxBytes): + let actualMB = actualBytes / 1_048_576 + let maxMB = maxBytes / 1_048_576 + return L10n.Settings.Settings.Backup.Error.fileTooLarge(actualMB, maxMB) + case let .decompressedTooLarge(maxBytes): + let maxMB = maxBytes / 1_048_576 + return L10n.Settings.Settings.Backup.Error.decompressedTooLarge(maxMB) + case let .unsupportedVersion(found, maxSupported): + return L10n.Settings.Settings.Backup.Error.unsupportedVersion(found, maxSupported) + case .corruptedManifest: + return L10n.Settings.Settings.Backup.Error.corruptedManifest + case let .exportFailed(underlying): + return L10n.Settings.Settings.Backup.Error.exportFailed(underlying.userFacingMessage) + case let .importFailed(underlying): + return L10n.Settings.Settings.Backup.Error.importFailed(underlying.userFacingMessage) } + } } diff --git a/MC1/Views/Settings/AppBackupViewModel.swift b/MC1/Views/Settings/AppBackupViewModel.swift index 56104eb7..e91768ec 100644 --- a/MC1/Views/Settings/AppBackupViewModel.swift +++ b/MC1/Views/Settings/AppBackupViewModel.swift @@ -7,320 +7,320 @@ import os /// (e.g. `isImporting && importResult != nil`) unrepresentable, and /// separates the sheet-scoped error surface from the top-level alert. enum ImportState { - case idle - case parsing - case preview(AppBackupEnvelope) - case importing - case success(ImportResult) - case failed(String) - case cancelled + case idle + case parsing + case preview(AppBackupEnvelope) + case importing + case success(ImportResult) + case failed(String) + case cancelled } /// Export flow state. Single owner of the export lifecycle so the /// `.fileExporter` binding can be a pure read of the current case and /// `handleExportResult` is the only code that transitions state. enum ExportState { - case idle - case exporting - case pending(AppBackupViewModel.PendingExport) - case success(AppBackupViewModel.ExportSuccessSummary) + case idle + case exporting + case pending(AppBackupViewModel.PendingExport) + case success(AppBackupViewModel.ExportSuccessSummary) } @Observable @MainActor final class AppBackupViewModel { - // MARK: - Nested types - - /// Snapshot of an in-flight export waiting for the system save picker to return. - struct PendingExport { - let data: Data - let manifest: BackupManifest - } - - struct ExportSuccessSummary: Identifiable, Equatable { - let id = UUID() - let filename: String - let byteCount: Int - let manifest: BackupManifest - } - - // MARK: - Export state - - var exportState: ExportState = .idle - var errorMessage: String? - - var isExporting: Bool { - if case .exporting = exportState { true } else { false } + // MARK: - Nested types + + /// Snapshot of an in-flight export waiting for the system save picker to return. + struct PendingExport { + let data: Data + let manifest: BackupManifest + } + + struct ExportSuccessSummary: Identifiable, Equatable { + let id = UUID() + let filename: String + let byteCount: Int + let manifest: BackupManifest + } + + // MARK: - Export state + + var exportState: ExportState = .idle + var errorMessage: String? + + var isExporting: Bool { + if case .exporting = exportState { true } else { false } + } + + var pendingExport: PendingExport? { + if case let .pending(pending) = exportState { pending } else { nil } + } + + var exportSummary: ExportSuccessSummary? { + if case let .success(summary) = exportState { summary } else { nil } + } + + // MARK: - Import state + + var importState: ImportState = .idle + /// Latched to `true` the instant the user taps Cancel during an active import, + /// so the UI can acknowledge the tap before the import task reaches a + /// cancellation point and actually tears down. + var isCancellingImport = false + private var parseTask: Task? + private var currentParseID: UUID? + private var importTask: Task? + + /// Computed view of the import flow. `.parsing` is treated as pre-sheet + /// so the spinner in the import row is the only visible cue until a + /// preview is ready; the sheet appears once state advances past parsing. + var isImportSheetActive: Bool { + switch importState { + case .idle, .parsing: false + case .preview, .importing, .success, .failed, .cancelled: true } - - var pendingExport: PendingExport? { - if case .pending(let pending) = exportState { pending } else { nil } - } - - var exportSummary: ExportSuccessSummary? { - if case .success(let summary) = exportState { summary } else { nil } - } - - // MARK: - Import state - - var importState: ImportState = .idle - /// Latched to `true` the instant the user taps Cancel during an active import, - /// so the UI can acknowledge the tap before the import task reaches a - /// cancellation point and actually tears down. - var isCancellingImport = false - private var parseTask: Task? - private var currentParseID: UUID? - private var importTask: Task? - - /// Computed view of the import flow. `.parsing` is treated as pre-sheet - /// so the spinner in the import row is the only visible cue until a - /// preview is ready; the sheet appears once state advances past parsing. - var isImportSheetActive: Bool { - switch importState { - case .idle, .parsing: false - case .preview, .importing, .success, .failed, .cancelled: true - } - } - - var isParsing: Bool { - if case .parsing = importState { true } else { false } - } - - var isImporting: Bool { - if case .importing = importState { true } else { false } - } - - var isCancelled: Bool { - if case .cancelled = importState { true } else { false } - } - - var previewEnvelope: AppBackupEnvelope? { - if case .preview(let env) = importState { env } else { nil } - } - - var importResult: ImportResult? { - if case .success(let result) = importState { result } else { nil } - } - - /// Error surfaced inside the import sheet (import-phase failure). Kept - /// separate from `errorMessage`, which is reserved for top-level alerts - /// shown when no sheet is active. - var sheetErrorMessage: String? { - if case .failed(let msg) = importState { msg } else { nil } - } - - // MARK: - Dependencies - - private let connectionManager: ConnectionManager - private let onImportRestoredData: (@MainActor () -> Void)? - private let onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? - private let backupService = AppBackupService() - private let logger = Logger(subsystem: "com.mc1", category: "AppBackupViewModel") - - init( - connectionManager: ConnectionManager, - onImportRestoredData: (@MainActor () -> Void)? = nil, - onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? = nil - ) { - self.connectionManager = connectionManager - self.onImportRestoredData = onImportRestoredData - self.onChannelDraftSlotsAffected = onChannelDraftSlotsAffected - } - - // MARK: - Export - - func performExport() { - guard case .idle = exportState else { return } - exportState = .exporting - errorMessage = nil - - Task { - do { - let result = try await backupService.export( - persistenceStore: preferredPersistenceStore() - ) - exportState = .pending(PendingExport(data: result.data, manifest: result.manifest)) - } catch { - exportState = .idle - errorMessage = error.userFacingMessage - logger.error("Export failed: \(error.localizedDescription)") - } - } - } - - func handleExportResult(_ result: Result) { - guard case .pending(let pending) = exportState else { return } - - switch result { - case .success(let url): - exportState = .success(ExportSuccessSummary( - filename: url.lastPathComponent, - byteCount: pending.data.count, - manifest: pending.manifest - )) - case .failure(let error): - exportState = .idle - guard !isUserCancelled(error) else { return } - errorMessage = error.userFacingMessage - } - } - - func dismissExportSuccess() { - guard case .success = exportState else { return } + } + + var isParsing: Bool { + if case .parsing = importState { true } else { false } + } + + var isImporting: Bool { + if case .importing = importState { true } else { false } + } + + var isCancelled: Bool { + if case .cancelled = importState { true } else { false } + } + + var previewEnvelope: AppBackupEnvelope? { + if case let .preview(env) = importState { env } else { nil } + } + + var importResult: ImportResult? { + if case let .success(result) = importState { result } else { nil } + } + + /// Error surfaced inside the import sheet (import-phase failure). Kept + /// separate from `errorMessage`, which is reserved for top-level alerts + /// shown when no sheet is active. + var sheetErrorMessage: String? { + if case let .failed(msg) = importState { msg } else { nil } + } + + // MARK: - Dependencies + + private let connectionManager: ConnectionManager + private let onImportRestoredData: (@MainActor () -> Void)? + private let onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? + private let backupService = AppBackupService() + private let logger = Logger(subsystem: "com.mc1", category: "AppBackupViewModel") + + init( + connectionManager: ConnectionManager, + onImportRestoredData: (@MainActor () -> Void)? = nil, + onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? = nil + ) { + self.connectionManager = connectionManager + self.onImportRestoredData = onImportRestoredData + self.onChannelDraftSlotsAffected = onChannelDraftSlotsAffected + } + + // MARK: - Export + + func performExport() { + guard case .idle = exportState else { return } + exportState = .exporting + errorMessage = nil + + Task { + do { + let result = try await backupService.export( + persistenceStore: preferredPersistenceStore() + ) + exportState = .pending(PendingExport(data: result.data, manifest: result.manifest)) + } catch { exportState = .idle + errorMessage = error.userFacingMessage + logger.error("Export failed: \(error.localizedDescription)") + } } - - // MARK: - Import - - func handleFileSelected(result: Result) { - switch result { - case .success(let url): - loadAndParseBackup(from: url) - case .failure(let error): - guard !isUserCancelled(error) else { return } - errorMessage = error.userFacingMessage - } - } - - func loadAndParseBackup(from url: URL) { - parseTask?.cancel() - importState = .parsing - let taskID = UUID() - currentParseID = taskID - let didAccessSecurityScope = url.startAccessingSecurityScopedResource() - - parseTask = Task.detached(priority: .userInitiated) { [weak self, url, didAccessSecurityScope] in - defer { - if didAccessSecurityScope { - url.stopAccessingSecurityScopedResource() - } - } - do { - // .mappedIfSafe lets the OS page-cache the read rather than - // copying the whole file into the heap. - let data = try Data(contentsOf: url, options: .mappedIfSafe) - try Task.checkCancellation() - let envelope = try parseBackup(data: data) - try Task.checkCancellation() - await self?.applyParseSuccess(envelope, for: taskID) - } catch is CancellationError { - // The new invocation owns importState; don't clobber it - return - } catch { - await self?.applyParseFailure(error.userFacingMessage, for: taskID) - } - } - } - - private func applyParseSuccess(_ envelope: AppBackupEnvelope, for taskID: UUID) { - guard currentParseID == taskID else { return } - importState = .preview(envelope) - errorMessage = nil + } + + func handleExportResult(_ result: Result) { + guard case let .pending(pending) = exportState else { return } + + switch result { + case let .success(url): + exportState = .success(ExportSuccessSummary( + filename: url.lastPathComponent, + byteCount: pending.data.count, + manifest: pending.manifest + )) + case let .failure(error): + exportState = .idle + guard !isUserCancelled(error) else { return } + errorMessage = error.userFacingMessage } - - /// Parse failure happens before the sheet opens, so surface via the top-level - /// alert, not the in-sheet error view. - private func applyParseFailure(_ message: String, for taskID: UUID) { - guard currentParseID == taskID else { return } - importState = .idle - errorMessage = message + } + + func dismissExportSuccess() { + guard case .success = exportState else { return } + exportState = .idle + } + + // MARK: - Import + + func handleFileSelected(result: Result) { + switch result { + case let .success(url): + loadAndParseBackup(from: url) + case let .failure(error): + guard !isUserCancelled(error) else { return } + errorMessage = error.userFacingMessage } - - func performImport() { - guard case .preview(let envelope) = importState else { return } - // Re-check the connection at the commit point, not only via the disabled import row: - // a foreground auto-reconnect can connect the radio while the user reads the preview. - // Bind the import to a standalone store so its rollback-on-failure runs on its own - // per-actor ModelContext and can never discard the live sync store's pending writes. - guard !connectionManager.connectionState.isConnected else { - dismissImportSheet() - return - } - let store = connectionManager.createStandalonePersistenceStore() - isCancellingImport = false - importState = .importing - - importTask = Task { - defer { importTask = nil } - do { - let result = try await backupService.importBackup( - envelope: envelope, - into: store - ) - importState = .success(result) - // Channel relocation during import can change which channel occupies a - // slot; clear drafts for the affected slots before they are re-entered. - if !result.channelSlotsAffectedByImport.isEmpty { - onChannelDraftSlotsAffected?(result.channelSlotsAffectedByImport) - } - if result.hasRestoredChanges { - // The import wrote directly to the persistence store, bypassing the - // sync-path callbacks that normally bump contacts/conversations - // versions. Fire the notifier so any mounted chat/contact tabs - // reload their data rather than show the pre-restore snapshot. - onImportRestoredData?() - // SyncCoordinator caches blocked names at connect time; restored - // BlockedChannelSender rows and blocked contacts would otherwise be - // ignored by the incoming-message filter until reconnect. - if let services = connectionManager.services, - let radioID = connectionManager.lastConnectedRadioID { - await services.syncCoordinator.refreshBlockedContactsCache( - radioID: radioID, - dataStore: services.dataStore - ) - } - } - } catch is CancellationError { - // Rollback is guaranteed by the `defer` in `importBackupDatabase`. - // Surface a terminal cancelled state so the user sees that nothing - // was changed instead of the sheet closing silently. - importState = .cancelled - } catch { - importState = .failed(error.userFacingMessage) - logger.error("Import failed: \(error.localizedDescription)") - } + } + + func loadAndParseBackup(from url: URL) { + parseTask?.cancel() + importState = .parsing + let taskID = UUID() + currentParseID = taskID + let didAccessSecurityScope = url.startAccessingSecurityScopedResource() + + parseTask = Task.detached(priority: .userInitiated) { [weak self, url, didAccessSecurityScope] in + defer { + if didAccessSecurityScope { + url.stopAccessingSecurityScopedResource() } + } + do { + // .mappedIfSafe lets the OS page-cache the read rather than + // copying the whole file into the heap. + let data = try Data(contentsOf: url, options: .mappedIfSafe) + try Task.checkCancellation() + let envelope = try parseBackup(data: data) + try Task.checkCancellation() + await self?.applyParseSuccess(envelope, for: taskID) + } catch is CancellationError { + // The new invocation owns importState; don't clobber it + return + } catch { + await self?.applyParseFailure(error.userFacingMessage, for: taskID) + } } - - func cancelImport() { - guard !isCancellingImport else { return } - isCancellingImport = true - importTask?.cancel() - } - - func dismissImportSheet() { - parseTask?.cancel() - parseTask = nil - currentParseID = nil - importState = .idle - isCancellingImport = false - errorMessage = nil + } + + private func applyParseSuccess(_ envelope: AppBackupEnvelope, for taskID: UUID) { + guard currentParseID == taskID else { return } + importState = .preview(envelope) + errorMessage = nil + } + + /// Parse failure happens before the sheet opens, so surface via the top-level + /// alert, not the in-sheet error view. + private func applyParseFailure(_ message: String, for taskID: UUID) { + guard currentParseID == taskID else { return } + importState = .idle + errorMessage = message + } + + func performImport() { + guard case let .preview(envelope) = importState else { return } + // Re-check the connection at the commit point, not only via the disabled import row: + // a foreground auto-reconnect can connect the radio while the user reads the preview. + // Bind the import to a standalone store so its rollback-on-failure runs on its own + // per-actor ModelContext and can never discard the live sync store's pending writes. + guard !connectionManager.connectionState.isConnected else { + dismissImportSheet() + return } - - // MARK: - Export file name - - var defaultExportFilename: String { - let timestamp = Date.now.formatted( - .iso8601 - .year() - .month() - .day() - .dateSeparator(.dash) - .dateTimeSeparator(.space) - .time(includingFractionalSeconds: false) - .timeSeparator(.omitted) + let store = connectionManager.createStandalonePersistenceStore() + isCancellingImport = false + importState = .importing + + importTask = Task { + defer { importTask = nil } + do { + let result = try await backupService.importBackup( + envelope: envelope, + into: store ) - return L10n.Settings.Settings.Backup.Export.defaultFilename(timestamp) - } - - private func preferredPersistenceStore() -> PersistenceStore { - // Reuse the live store actor when services are active so backup work serializes - // with the app's authoritative writer instead of creating a second store actor. - connectionManager.services?.dataStore ?? connectionManager.createStandalonePersistenceStore() - } - - private func isUserCancelled(_ error: any Error) -> Bool { - let nsError = error as NSError - return nsError.domain == NSCocoaErrorDomain && nsError.code == NSUserCancelledError + importState = .success(result) + // Channel relocation during import can change which channel occupies a + // slot; clear drafts for the affected slots before they are re-entered. + if !result.channelSlotsAffectedByImport.isEmpty { + onChannelDraftSlotsAffected?(result.channelSlotsAffectedByImport) + } + if result.hasRestoredChanges { + // The import wrote directly to the persistence store, bypassing the + // sync-path callbacks that normally bump contacts/conversations + // versions. Fire the notifier so any mounted chat/contact tabs + // reload their data rather than show the pre-restore snapshot. + onImportRestoredData?() + // SyncCoordinator caches blocked names at connect time; restored + // BlockedChannelSender rows and blocked contacts would otherwise be + // ignored by the incoming-message filter until reconnect. + if let services = connectionManager.services, + let radioID = connectionManager.lastConnectedRadioID { + await services.syncCoordinator.refreshBlockedContactsCache( + radioID: radioID, + dataStore: services.dataStore + ) + } + } + } catch is CancellationError { + // Rollback is guaranteed by the `defer` in `importBackupDatabase`. + // Surface a terminal cancelled state so the user sees that nothing + // was changed instead of the sheet closing silently. + importState = .cancelled + } catch { + importState = .failed(error.userFacingMessage) + logger.error("Import failed: \(error.localizedDescription)") + } } + } + + func cancelImport() { + guard !isCancellingImport else { return } + isCancellingImport = true + importTask?.cancel() + } + + func dismissImportSheet() { + parseTask?.cancel() + parseTask = nil + currentParseID = nil + importState = .idle + isCancellingImport = false + errorMessage = nil + } + + // MARK: - Export file name + + var defaultExportFilename: String { + let timestamp = Date.now.formatted( + .iso8601 + .year() + .month() + .day() + .dateSeparator(.dash) + .dateTimeSeparator(.space) + .time(includingFractionalSeconds: false) + .timeSeparator(.omitted) + ) + return L10n.Settings.Settings.Backup.Export.defaultFilename(timestamp) + } + + private func preferredPersistenceStore() -> PersistenceStore { + // Reuse the live store actor when services are active so backup work serializes + // with the app's authoritative writer instead of creating a second store actor. + connectionManager.services?.dataStore ?? connectionManager.createStandalonePersistenceStore() + } + + private func isUserCancelled(_ error: any Error) -> Bool { + let nsError = error as NSError + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSUserCancelledError + } } diff --git a/MC1/Views/Settings/BackupModelKind+Label.swift b/MC1/Views/Settings/BackupModelKind+Label.swift index 41ee9d02..114b3a37 100644 --- a/MC1/Views/Settings/BackupModelKind+Label.swift +++ b/MC1/Views/Settings/BackupModelKind+Label.swift @@ -1,22 +1,22 @@ import MC1Services extension BackupModelKind { - /// Localized display label used by backup preview, import-success, and - /// export-success rows. Lives in the MC1 target because `L10n` is - /// generated here by SwiftGen and is not visible from MC1Services. - var label: String { - switch self { - case .messages: L10n.Settings.Settings.Backup.Import.Preview.messages - case .contacts: L10n.Settings.Settings.Backup.Import.Preview.contacts - case .channels: L10n.Settings.Settings.Backup.Import.Preview.channels - case .devices: L10n.Settings.Settings.Backup.Import.Preview.devices - case .roomMessages: L10n.Settings.Settings.Backup.Import.Preview.roomMessages - case .reactions: L10n.Settings.Settings.Backup.Import.Preview.reactions - case .messageRepeats: L10n.Settings.Settings.Backup.Import.Preview.messageRepeats - case .savedTracePaths: L10n.Settings.Settings.Backup.Import.Preview.savedPaths - case .remoteNodeSessions: L10n.Settings.Settings.Backup.Import.Preview.remoteNodeSessions - case .blockedChannelSenders: L10n.Settings.Settings.Backup.Import.Preview.blockedSenders - case .nodeStatusSnapshots: L10n.Settings.Settings.Backup.Import.Preview.nodeStatusSnapshots - } + /// Localized display label used by backup preview, import-success, and + /// export-success rows. Lives in the MC1 target because `L10n` is + /// generated here by SwiftGen and is not visible from MC1Services. + var label: String { + switch self { + case .messages: L10n.Settings.Settings.Backup.Import.Preview.messages + case .contacts: L10n.Settings.Settings.Backup.Import.Preview.contacts + case .channels: L10n.Settings.Settings.Backup.Import.Preview.channels + case .devices: L10n.Settings.Settings.Backup.Import.Preview.devices + case .roomMessages: L10n.Settings.Settings.Backup.Import.Preview.roomMessages + case .reactions: L10n.Settings.Settings.Backup.Import.Preview.reactions + case .messageRepeats: L10n.Settings.Settings.Backup.Import.Preview.messageRepeats + case .savedTracePaths: L10n.Settings.Settings.Backup.Import.Preview.savedPaths + case .remoteNodeSessions: L10n.Settings.Settings.Backup.Import.Preview.remoteNodeSessions + case .blockedChannelSenders: L10n.Settings.Settings.Backup.Import.Preview.blockedSenders + case .nodeStatusSnapshots: L10n.Settings.Settings.Backup.Import.Preview.nodeStatusSnapshots } + } } diff --git a/MC1/Views/Settings/BackupRestoreView.swift b/MC1/Views/Settings/BackupRestoreView.swift index 3770636a..f89b861e 100644 --- a/MC1/Views/Settings/BackupRestoreView.swift +++ b/MC1/Views/Settings/BackupRestoreView.swift @@ -1,149 +1,149 @@ -import SwiftUI import MC1Services +import SwiftUI struct BackupRestoreView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var viewModel: AppBackupViewModel - @State private var showExportConfirmation = false - @State private var showFileImporter = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var viewModel: AppBackupViewModel + @State private var showExportConfirmation = false + @State private var showFileImporter = false - init( - connectionManager: ConnectionManager, - onImportRestoredData: (@MainActor () -> Void)? = nil, - onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? = nil - ) { - _viewModel = State(initialValue: AppBackupViewModel( - connectionManager: connectionManager, - onImportRestoredData: onImportRestoredData, - onChannelDraftSlotsAffected: onChannelDraftSlotsAffected - )) - } + init( + connectionManager: ConnectionManager, + onImportRestoredData: (@MainActor () -> Void)? = nil, + onChannelDraftSlotsAffected: (@MainActor ([UUID: Set]) -> Void)? = nil + ) { + _viewModel = State(initialValue: AppBackupViewModel( + connectionManager: connectionManager, + onImportRestoredData: onImportRestoredData, + onChannelDraftSlotsAffected: onChannelDraftSlotsAffected + )) + } - var body: some View { - List { - Section { - exportRow - importRow - } header: { - Text(L10n.Settings.Settings.Backup.FileBackup.header) - } footer: { - VStack(alignment: .leading, spacing: 8) { - if appState.connectionState.isConnected { - Text(L10n.Settings.Settings.Backup.Import.disabledWhenConnected) - } - Text(L10n.Settings.Settings.Backup.FileBackup.footer) - } - } - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Settings.Backup.title) - .alert(L10n.Settings.Settings.Backup.Export.Alert.title, isPresented: $showExportConfirmation) { - Button(L10n.Settings.Settings.Backup.Export.Alert.cancel, role: .cancel) {} - Button(L10n.Settings.Settings.Backup.Export.Alert.export) { - viewModel.performExport() - } - } message: { - Text(L10n.Settings.Settings.Backup.Export.Alert.message) + var body: some View { + List { + Section { + exportRow + importRow + } header: { + Text(L10n.Settings.Settings.Backup.FileBackup.header) + } footer: { + VStack(alignment: .leading, spacing: 8) { + if appState.connectionState.isConnected { + Text(L10n.Settings.Settings.Backup.Import.disabledWhenConnected) + } + Text(L10n.Settings.Settings.Backup.FileBackup.footer) } - .fileExporter( - isPresented: Binding( - get: { viewModel.pendingExport != nil }, - // A dismissal that only writes the binding must still resolve the pending - // export, or the export row stays wedged for the rest of the session. - // handleExportResult is idempotent, so a later onCompletion is a no-op. - set: { isPresented in - if !isPresented, viewModel.pendingExport != nil { - viewModel.handleExportResult(.failure(CocoaError(.userCancelled))) - } - } - ), - document: exportDocument, - contentType: .mc1Backup, - defaultFilename: viewModel.defaultExportFilename - ) { result in - viewModel.handleExportResult(result) - } - .fileImporter( - isPresented: $showFileImporter, - allowedContentTypes: [.mc1Backup] - ) { result in - viewModel.handleFileSelected(result: result) - } - .sheet(isPresented: Binding( - get: { viewModel.isImportSheetActive }, - set: { if !$0 { viewModel.dismissImportSheet() } } - )) { - ImportPreviewSheet(viewModel: viewModel) - } - .sheet(item: Binding( - get: { viewModel.exportSummary }, - set: { if $0 == nil { viewModel.dismissExportSuccess() } } - )) { summary in - NavigationStack { - ExportSuccessContent(summary: summary, onDismiss: viewModel.dismissExportSuccess) - } + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Settings.Backup.title) + .alert(L10n.Settings.Settings.Backup.Export.Alert.title, isPresented: $showExportConfirmation) { + Button(L10n.Settings.Settings.Backup.Export.Alert.cancel, role: .cancel) {} + Button(L10n.Settings.Settings.Backup.Export.Alert.export) { + viewModel.performExport() + } + } message: { + Text(L10n.Settings.Settings.Backup.Export.Alert.message) + } + .fileExporter( + isPresented: Binding( + get: { viewModel.pendingExport != nil }, + // A dismissal that only writes the binding must still resolve the pending + // export, or the export row stays wedged for the rest of the session. + // handleExportResult is idempotent, so a later onCompletion is a no-op. + set: { isPresented in + if !isPresented, viewModel.pendingExport != nil { + viewModel.handleExportResult(.failure(CocoaError(.userCancelled))) + } } - .errorAlert($viewModel.errorMessage) + ), + document: exportDocument, + contentType: .mc1Backup, + defaultFilename: viewModel.defaultExportFilename + ) { result in + viewModel.handleExportResult(result) + } + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.mc1Backup] + ) { result in + viewModel.handleFileSelected(result: result) + } + .sheet(isPresented: Binding( + get: { viewModel.isImportSheetActive }, + set: { if !$0 { viewModel.dismissImportSheet() } } + )) { + ImportPreviewSheet(viewModel: viewModel) + } + .sheet(item: Binding( + get: { viewModel.exportSummary }, + set: { if $0 == nil { viewModel.dismissExportSuccess() } } + )) { summary in + NavigationStack { + ExportSuccessContent(summary: summary, onDismiss: viewModel.dismissExportSuccess) + } } + .errorAlert($viewModel.errorMessage) + } - // MARK: - Rows + // MARK: - Rows - private var exportRow: some View { - Button { - showExportConfirmation = true - } label: { - HStack { - if viewModel.isExporting { - ProgressView() - Text(L10n.Settings.Settings.Backup.Export.progress) - } else { - Label(L10n.Settings.Settings.Backup.Export.title, systemImage: "square.and.arrow.up") - } - Spacer() - Text(L10n.Settings.Settings.Backup.Export.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } + private var exportRow: some View { + Button { + showExportConfirmation = true + } label: { + HStack { + if viewModel.isExporting { + ProgressView() + Text(L10n.Settings.Settings.Backup.Export.progress) + } else { + Label(L10n.Settings.Settings.Backup.Export.title, systemImage: "square.and.arrow.up") } - .disabled(viewModel.isExporting || viewModel.isImporting || viewModel.isParsing) + Spacer() + Text(L10n.Settings.Settings.Backup.Export.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } } + .disabled(viewModel.isExporting || viewModel.isImporting || viewModel.isParsing) + } - @ViewBuilder - private var importRow: some View { - let isRadioConnected = appState.connectionState.isConnected - let isBusy = viewModel.isExporting || viewModel.isImporting || viewModel.isParsing - let content = Button { - showFileImporter = true - } label: { - HStack { - if viewModel.isParsing { - ProgressView() - Text(L10n.Settings.Settings.Backup.Import.parsing) - } else { - Label(L10n.Settings.Settings.Backup.Import.title, systemImage: "square.and.arrow.down") - } - Spacer() - Text(L10n.Settings.Settings.Backup.Import.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - if isRadioConnected { - content - .disabled(true) - .foregroundStyle(.secondary) - .accessibilityHint(L10n.Settings.Settings.Backup.Import.disabledWhenConnected) + @ViewBuilder + private var importRow: some View { + let isRadioConnected = appState.connectionState.isConnected + let isBusy = viewModel.isExporting || viewModel.isImporting || viewModel.isParsing + let content = Button { + showFileImporter = true + } label: { + HStack { + if viewModel.isParsing { + ProgressView() + Text(L10n.Settings.Settings.Backup.Import.parsing) } else { - content.disabled(isBusy) + Label(L10n.Settings.Settings.Backup.Import.title, systemImage: "square.and.arrow.down") } + Spacer() + Text(L10n.Settings.Settings.Backup.Import.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } } + if isRadioConnected { + content + .disabled(true) + .foregroundStyle(.secondary) + .accessibilityHint(L10n.Settings.Settings.Backup.Import.disabledWhenConnected) + } else { + content.disabled(isBusy) + } + } - // MARK: - Helpers + // MARK: - Helpers - private var exportDocument: AppBackupDocument? { - guard let data = viewModel.pendingExport?.data else { return nil } - return AppBackupDocument(data: data) - } + private var exportDocument: AppBackupDocument? { + guard let data = viewModel.pendingExport?.data else { return nil } + return AppBackupDocument(data: data) + } } diff --git a/MC1/Views/Settings/BlockedChannelSendersView.swift b/MC1/Views/Settings/BlockedChannelSendersView.swift index 9395d655..4de050b3 100644 --- a/MC1/Views/Settings/BlockedChannelSendersView.swift +++ b/MC1/Views/Settings/BlockedChannelSendersView.swift @@ -1,95 +1,95 @@ -import OSLog import MC1Services +import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "BlockedChannelSendersView") /// Settings screen listing blocked channel sender names with swipe-to-unblock. struct BlockedChannelSendersView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var blockedSenders: [BlockedChannelSenderDTO] = [] - @State private var isLoading = false + @State private var blockedSenders: [BlockedChannelSenderDTO] = [] + @State private var isLoading = false - var body: some View { - Group { - if isLoading { - ProgressView() - } else if blockedSenders.isEmpty { - ContentUnavailableView( - L10n.Settings.Blocking.ChannelSenders.Empty.title, - systemImage: "hand.raised.slash", - description: Text(L10n.Settings.Blocking.ChannelSenders.Empty.description) - ) - } else { - List { - ForEach(blockedSenders) { sender in - VStack(alignment: .leading, spacing: 4) { - Text(sender.name) - .font(.body) - Text(sender.dateBlocked, format: .dateTime.month().day().year()) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .onDelete(perform: unblock) - .themedRowBackground(theme) - } - .themedCanvas(theme) - } - } - .navigationTitle(L10n.Settings.Blocking.ChannelSenders.title) - .toolbar { - if !blockedSenders.isEmpty { - EditButton() + var body: some View { + Group { + if isLoading { + ProgressView() + } else if blockedSenders.isEmpty { + ContentUnavailableView( + L10n.Settings.Blocking.ChannelSenders.Empty.title, + systemImage: "hand.raised.slash", + description: Text(L10n.Settings.Blocking.ChannelSenders.Empty.description) + ) + } else { + List { + ForEach(blockedSenders) { sender in + VStack(alignment: .leading, spacing: 4) { + Text(sender.name) + .font(.body) + Text(sender.dateBlocked, format: .dateTime.month().day().year()) + .font(.caption) + .foregroundStyle(.secondary) } + } + .onDelete(perform: unblock) + .themedRowBackground(theme) } - .task { - await loadBlockedSenders() - } + .themedCanvas(theme) + } + } + .navigationTitle(L10n.Settings.Blocking.ChannelSenders.title) + .toolbar { + if !blockedSenders.isEmpty { + EditButton() + } } + .task { + await loadBlockedSenders() + } + } - private func loadBlockedSenders() async { - guard let services = appState.services, - let radioID = appState.connectedDevice?.radioID else { return } - isLoading = true - defer { isLoading = false } + private func loadBlockedSenders() async { + guard let services = appState.services, + let radioID = appState.connectedDevice?.radioID else { return } + isLoading = true + defer { isLoading = false } - do { - blockedSenders = try await services.dataStore.fetchBlockedChannelSenders( - radioID: radioID - ) - } catch { - logger.error("Failed to load blocked channel senders: \(error)") - blockedSenders = [] - } + do { + blockedSenders = try await services.dataStore.fetchBlockedChannelSenders( + radioID: radioID + ) + } catch { + logger.error("Failed to load blocked channel senders: \(error)") + blockedSenders = [] } + } - private func unblock(at offsets: IndexSet) { - let sendersToUnblock = offsets.map { blockedSenders[$0] } - blockedSenders.remove(atOffsets: offsets) + private func unblock(at offsets: IndexSet) { + let sendersToUnblock = offsets.map { blockedSenders[$0] } + blockedSenders.remove(atOffsets: offsets) - Task { - guard let services = appState.services, - let radioID = appState.connectedDevice?.radioID else { return } - - for sender in sendersToUnblock { - do { - try await services.dataStore.deleteBlockedChannelSender( - radioID: radioID, - name: sender.name - ) - } catch { - logger.error("Failed to delete blocked sender '\(sender.name)': \(error)") - } - } + Task { + guard let services = appState.services, + let radioID = appState.connectedDevice?.radioID else { return } - await services.syncCoordinator.refreshBlockedContactsCache( - radioID: radioID, - dataStore: services.dataStore - ) - services.syncCoordinator.notifyConversationsChanged() + for sender in sendersToUnblock { + do { + try await services.dataStore.deleteBlockedChannelSender( + radioID: radioID, + name: sender.name + ) + } catch { + logger.error("Failed to delete blocked sender '\(sender.name)': \(error)") } + } + + await services.syncCoordinator.refreshBlockedContactsCache( + radioID: radioID, + dataStore: services.dataStore + ) + services.syncCoordinator.notifyConversationsChanged() } + } } diff --git a/MC1/Views/Settings/ChatSettingsView.swift b/MC1/Views/Settings/ChatSettingsView.swift index 73fdf91a..21f20f93 100644 --- a/MC1/Views/Settings/ChatSettingsView.swift +++ b/MC1/Views/Settings/ChatSettingsView.swift @@ -2,28 +2,28 @@ import MC1Services import SwiftUI struct ChatSettingsView: View { - @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote + @Environment(\.appTheme) private var theme + @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote - var body: some View { - List { - Section { - Toggle(isOn: $replyWithQuote) { - TintedLabel(L10n.Settings.ReplyWithQuote.toggle, systemImage: "text.quote") - } - } footer: { - Text(L10n.Settings.ReplyWithQuote.footer) - } - .themedRowBackground(theme) - - LinkPreviewSettingsSection() - MapPreviewSettingsSection() - MessagesSettingsSection() - BlockingSection() + var body: some View { + List { + Section { + Toggle(isOn: $replyWithQuote) { + TintedLabel(L10n.Settings.ReplyWithQuote.toggle, systemImage: "text.quote") } - .themedCanvas(theme) - .settingsSubpageDestinations() - .navigationTitle(L10n.Settings.ChatSettings.title) - .navigationBarTitleDisplayMode(.inline) + } footer: { + Text(L10n.Settings.ReplyWithQuote.footer) + } + .themedRowBackground(theme) + + LinkPreviewSettingsSection() + MapPreviewSettingsSection() + MessagesSettingsSection() + BlockingSection() } + .themedCanvas(theme) + .settingsSubpageDestinations() + .navigationTitle(L10n.Settings.ChatSettings.title) + .navigationBarTitleDisplayMode(.inline) + } } diff --git a/MC1/Views/Settings/Components/BatteryCurveChart.swift b/MC1/Views/Settings/Components/BatteryCurveChart.swift index 633a55ff..d52cd0dd 100644 --- a/MC1/Views/Settings/Components/BatteryCurveChart.swift +++ b/MC1/Views/Settings/Components/BatteryCurveChart.swift @@ -3,72 +3,75 @@ import SwiftUI /// Visual representation of an OCV discharge curve struct BatteryCurveChart: View { - let ocvArray: [Int] + let ocvArray: [Int] - private var dataPoints: [DataPoint] { - ocvArray.enumerated().map { index, voltage in - DataPoint( - voltage: Double(voltage) / 1000.0, // Convert to volts - percent: (10 - index) * 10 // 100, 90, 80, ... 0 - ) - } + private var dataPoints: [DataPoint] { + ocvArray.enumerated().map { index, voltage in + DataPoint( + voltage: Double(voltage) / 1000.0, // Convert to volts + percent: (10 - index) * 10 // 100, 90, 80, ... 0 + ) } + } - /// Y axis lower bound: min voltage - 100mV, rounded down to nearest 0.1V - private var yAxisMin: Double { - guard let minMV = ocvArray.min() else { return 1.0 } - return (Double(minMV - 100) / 1000.0).rounded(.down, precision: 1) - } + /// Y axis lower bound: min voltage - 100mV, rounded down to nearest 0.1V + private var yAxisMin: Double { + guard let minMV = ocvArray.min() else { return 1.0 } + return (Double(minMV - 100) / 1000.0).rounded(.down, precision: 1) + } - /// Y axis upper bound: max voltage + 100mV, rounded up to nearest 0.1V - private var yAxisMax: Double { - guard let maxMV = ocvArray.max() else { return 4.5 } - return (Double(maxMV + 100) / 1000.0).rounded(.up, precision: 1) - } + /// Y axis upper bound: max voltage + 100mV, rounded up to nearest 0.1V + private var yAxisMax: Double { + guard let maxMV = ocvArray.max() else { return 4.5 } + return (Double(maxMV + 100) / 1000.0).rounded(.up, precision: 1) + } - var body: some View { - Chart(dataPoints) { point in - AreaMark( - x: .value("Percent", point.percent), - yStart: .value("Baseline", yAxisMin), - yEnd: .value("Voltage", point.voltage) - ) - .foregroundStyle(.blue.opacity(0.2)) - .interpolationMethod(.monotone) + var body: some View { + Chart(dataPoints) { point in + AreaMark( + x: .value("Percent", point.percent), + yStart: .value("Baseline", yAxisMin), + yEnd: .value("Voltage", point.voltage) + ) + .foregroundStyle(.blue.opacity(0.2)) + .interpolationMethod(.monotone) - LineMark( - x: .value("Percent", point.percent), - y: .value("Voltage", point.voltage) - ) - .foregroundStyle(.blue) - .interpolationMethod(.monotone) - } - .chartXScale(domain: 0...100) - .chartXAxis { - AxisMarks(values: [0, 25, 50, 75, 100]) - } - .chartYScale(domain: yAxisMin...yAxisMax) - .chartXAxisLabel(L10n.Settings.Chart.percent) - .chartYAxisLabel(L10n.Settings.Chart.voltage) - .accessibilityLabel(L10n.Settings.Chart.accessibility) - .frame(height: 150) + LineMark( + x: .value("Percent", point.percent), + y: .value("Voltage", point.voltage) + ) + .foregroundStyle(.blue) + .interpolationMethod(.monotone) + } + .chartXScale(domain: 0...100) + .chartXAxis { + AxisMarks(values: [0, 25, 50, 75, 100]) } + .chartYScale(domain: yAxisMin...yAxisMax) + .chartXAxisLabel(L10n.Settings.Chart.percent) + .chartYAxisLabel(L10n.Settings.Chart.voltage) + .accessibilityLabel(L10n.Settings.Chart.accessibility) + .frame(height: 150) + } } private struct DataPoint: Identifiable { - var id: Int { percent } - let voltage: Double - let percent: Int + var id: Int { + percent + } + + let voltage: Double + let percent: Int } private extension Double { - func rounded(_ rule: FloatingPointRoundingRule, precision: Int) -> Double { - let multiplier = pow(10.0, Double(precision)) - return (self * multiplier).rounded(rule) / multiplier - } + func rounded(_ rule: FloatingPointRoundingRule, precision: Int) -> Double { + let multiplier = pow(10.0, Double(precision)) + return (self * multiplier).rounded(rule) / multiplier + } } #Preview { - BatteryCurveChart(ocvArray: [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100]) - .padding() + BatteryCurveChart(ocvArray: [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100]) + .padding() } diff --git a/MC1/Views/Settings/ConnectionSettingsView.swift b/MC1/Views/Settings/ConnectionSettingsView.swift index 5e2ba63e..176a8ad4 100644 --- a/MC1/Views/Settings/ConnectionSettingsView.swift +++ b/MC1/Views/Settings/ConnectionSettingsView.swift @@ -2,27 +2,27 @@ import SwiftUI /// Sub-page wrapping BluetoothSection or WiFiSection based on transport type struct ConnectionSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var showingWiFiEditSheet = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var showingWiFiEditSheet = false - private var isWiFi: Bool { - appState.connectionManager.currentTransportType == .wifi - } + private var isWiFi: Bool { + appState.connectionManager.currentTransportType == .wifi + } - var body: some View { - List { - if isWiFi { - WiFiSection(showingEditSheet: $showingWiFiEditSheet) - } else { - BluetoothSection() - } - } - .themedCanvas(theme) - .navigationTitle(isWiFi ? L10n.Settings.Wifi.header : L10n.Settings.Bluetooth.header) - .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showingWiFiEditSheet) { - WiFiEditSheet() - } + var body: some View { + List { + if isWiFi { + WiFiSection(showingEditSheet: $showingWiFiEditSheet) + } else { + BluetoothSection() + } + } + .themedCanvas(theme) + .navigationTitle(isWiFi ? L10n.Settings.Wifi.header : L10n.Settings.Bluetooth.header) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingWiFiEditSheet) { + WiFiEditSheet() } + } } diff --git a/MC1/Views/Settings/DeviceInfoView.swift b/MC1/Views/Settings/DeviceInfoView.swift index 0e0ac8cd..7c6583ed 100644 --- a/MC1/Views/Settings/DeviceInfoView.swift +++ b/MC1/Views/Settings/DeviceInfoView.swift @@ -1,344 +1,344 @@ -import SwiftUI import MC1Services import MeshCore import OSLog +import SwiftUI private let deviceInfoLogger = Logger(subsystem: "com.mc1", category: "DeviceInfoView") /// Detailed device information screen struct DeviceInfoView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var showShareSheet = false - - @State private var nodeName: String = "" - @State private var isEditingName = false - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var isSaving = false - - var body: some View { - List { - if let device = appState.connectedDevice { - // Device identity - Section { - DeviceIdentityHeader(device: device) - } header: { - Text(L10n.Settings.Device.header) - } - .themedRowBackground(theme) - - // Node settings (name, public key, share) - Section { - HStack { - TintedLabel(L10n.Settings.Node.name, systemImage: "person.text.rectangle") - Spacer() - Button(device.nodeName) { - nodeName = device.nodeName - isEditingName = true - } - .foregroundStyle(.secondary) - } - .radioDisabled(for: appState.connectionState, or: isSaving) - - NavigationLink(value: SettingsSubpage.publicKey(device.publicKey)) { - Label(L10n.Settings.DeviceInfo.publicKey, systemImage: "key") - } - - Button { - showShareSheet = true - } label: { - Label(L10n.Settings.DeviceInfo.shareContact, systemImage: "square.and.arrow.up") - } - } header: { - Text(L10n.Settings.Node.header) - } footer: { - Text(L10n.Settings.Node.footer) - } - .alert(L10n.Settings.Node.Alert.EditName.title, isPresented: $isEditingName) { - TextField(L10n.Settings.Node.name, text: $nodeName) - .onChange(of: nodeName) { _, newValue in - if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { - nodeName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - } - } - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Localizable.Common.save) { - saveNodeName() - } - } - .themedRowBackground(theme) - - // Connection status - Section { - HStack { - Label( - L10n.Settings.DeviceInfo.Connection.status, - systemImage: "antenna.radiowaves.left.and.right" - ) - Spacer() - HStack(spacing: 4) { - Circle() - .fill(.green) - .frame(width: 8, height: 8) - Text(L10n.Settings.Device.connected) - .foregroundStyle(.secondary) - } - } - } header: { - Text(L10n.Settings.DeviceInfo.Connection.header) - } - .themedRowBackground(theme) - - // Battery and storage - Section { - if let battery = appState.batteryMonitor.deviceBattery { - let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) - HStack { - Label( - L10n.Settings.DeviceInfo.battery, - systemImage: battery.iconName(using: ocvArray) - ) - .symbolRenderingMode(.multicolor) - Spacer() - Text(battery.percentage(using: ocvArray), format: .percent) - .foregroundStyle(battery.levelColor(using: ocvArray)) - Text("(\(formatVoltage(battery.voltage)))") - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack { - Label(L10n.Settings.DeviceInfo.storageUsed, systemImage: "internaldrive") - Spacer() - Text(formatStorage(used: battery.usedStorageKB ?? 0, total: battery.totalStorageKB ?? 0)) - .foregroundStyle(.secondary) - } - - StorageBar(used: battery.usedStorageKB ?? 0, total: battery.totalStorageKB ?? 0) - } else { - HStack { - Label(L10n.Settings.DeviceInfo.batteryAndStorage, systemImage: "battery.100") - Spacer() - ProgressView() - } - } - } header: { - Text(L10n.Settings.DeviceInfo.PowerStorage.header) - } - .themedRowBackground(theme) - - // Firmware info - Section { - HStack { - Label(L10n.Settings.DeviceInfo.firmwareVersion, systemImage: "memorychip") - Spacer() - Text( - device.firmwareVersionString.isEmpty - ? L10n.Settings.DeviceInfo.firmwareVersionFormat(device.firmwareVersion) - : device.firmwareVersionString - ) - .foregroundStyle(.secondary) - } - - HStack { - Label(L10n.Settings.DeviceInfo.buildDate, systemImage: "calendar") - Spacer() - Text( - device.buildDate.isEmpty - ? L10n.Settings.DeviceInfo.unknown - : device.buildDate - ) - .foregroundStyle(.secondary) - } - - HStack { - Label(L10n.Settings.DeviceInfo.manufacturer, systemImage: "building.2") - Spacer() - Text( - device.manufacturerName.isEmpty - ? L10n.Settings.DeviceInfo.unknown - : device.manufacturerName - ) - .foregroundStyle(.secondary) - } - } header: { - Text(L10n.Settings.DeviceInfo.Firmware.header) - } - .themedRowBackground(theme) - - // Capabilities - Section { - HStack { - Label(L10n.Settings.DeviceInfo.maxNodes, systemImage: "person.2") - Spacer() - Text("\(device.maxContacts)") - .foregroundStyle(.secondary) - } - - HStack { - Label(L10n.Settings.DeviceInfo.maxChannels, systemImage: "person.3") - Spacer() - Text("\(device.maxChannels)") - .foregroundStyle(.secondary) - } - - HStack { - Label(L10n.Settings.DeviceInfo.maxTxPower, systemImage: "bolt") - Spacer() - Text(L10n.Settings.DeviceInfo.txPowerFormat(device.maxTxPower)) - .foregroundStyle(.secondary) - } - } header: { - Text(L10n.Settings.DeviceInfo.Capabilities.header) - } - .themedRowBackground(theme) - - } else { - ContentUnavailableView( - L10n.Settings.DeviceInfo.NoDevice.title, - systemImage: "antenna.radiowaves.left.and.right.slash", - description: Text(L10n.Settings.DeviceInfo.NoDevice.description) - ) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var showShareSheet = false + + @State private var nodeName: String = "" + @State private var isEditingName = false + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var isSaving = false + + var body: some View { + List { + if let device = appState.connectedDevice { + // Device identity + Section { + DeviceIdentityHeader(device: device) + } header: { + Text(L10n.Settings.Device.header) + } + .themedRowBackground(theme) + + // Node settings (name, public key, share) + Section { + HStack { + TintedLabel(L10n.Settings.Node.name, systemImage: "person.text.rectangle") + Spacer() + Button(device.nodeName) { + nodeName = device.nodeName + isEditingName = true } + .foregroundStyle(.secondary) + } + .radioDisabled(for: appState.connectionState, or: isSaving) + + NavigationLink(value: SettingsSubpage.publicKey(device.publicKey)) { + Label(L10n.Settings.DeviceInfo.publicKey, systemImage: "key") + } + + Button { + showShareSheet = true + } label: { + Label(L10n.Settings.DeviceInfo.shareContact, systemImage: "square.and.arrow.up") + } + } header: { + Text(L10n.Settings.Node.header) + } footer: { + Text(L10n.Settings.Node.footer) } - .themedCanvas(theme) - .settingsSubpageDestinations() - .navigationTitle(L10n.Settings.DeviceInfo.title) - .errorAlert($errorMessage) - .retryAlert(retryAlert) - .refreshable { - await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) + .alert(L10n.Settings.Node.Alert.EditName.title, isPresented: $isEditingName) { + TextField(L10n.Settings.Node.name, text: $nodeName) + .onChange(of: nodeName) { _, newValue in + if newValue.utf8.count > ProtocolLimits.maxUsableNameBytes { + nodeName = newValue.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + } + } + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Localizable.Common.save) { + saveNodeName() + } } - .task { - deviceInfoLogger.info("DeviceInfoView: appeared, connectedDevice=\(appState.connectedDevice != nil)") - await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) + .themedRowBackground(theme) + + // Connection status + Section { + HStack { + Label( + L10n.Settings.DeviceInfo.Connection.status, + systemImage: "antenna.radiowaves.left.and.right" + ) + Spacer() + HStack(spacing: 4) { + Circle() + .fill(.green) + .frame(width: 8, height: 8) + Text(L10n.Settings.Device.connected) + .foregroundStyle(.secondary) + } + } + } header: { + Text(L10n.Settings.DeviceInfo.Connection.header) } - .sheet(isPresented: $showShareSheet) { - if let device = appState.connectedDevice { - ContactQRShareSheet( - contactName: device.nodeName, - publicKey: device.publicKey, - contactType: .chat - ) + .themedRowBackground(theme) + + // Battery and storage + Section { + if let battery = appState.batteryMonitor.deviceBattery { + let ocvArray = appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) + HStack { + Label( + L10n.Settings.DeviceInfo.battery, + systemImage: battery.iconName(using: ocvArray) + ) + .symbolRenderingMode(.multicolor) + Spacer() + Text(battery.percentage(using: ocvArray), format: .percent) + .foregroundStyle(battery.levelColor(using: ocvArray)) + Text("(\(formatVoltage(battery.voltage)))") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + Label(L10n.Settings.DeviceInfo.storageUsed, systemImage: "internaldrive") + Spacer() + Text(formatStorage(used: battery.usedStorageKB ?? 0, total: battery.totalStorageKB ?? 0)) + .foregroundStyle(.secondary) } + + StorageBar(used: battery.usedStorageKB ?? 0, total: battery.totalStorageKB ?? 0) + } else { + HStack { + Label(L10n.Settings.DeviceInfo.batteryAndStorage, systemImage: "battery.100") + Spacer() + ProgressView() + } + } + } header: { + Text(L10n.Settings.DeviceInfo.PowerStorage.header) } - } + .themedRowBackground(theme) - /// Firmware reports storage in binary kilobytes. - private static let bytesPerKilobyte = 1024 + // Firmware info + Section { + HStack { + Label(L10n.Settings.DeviceInfo.firmwareVersion, systemImage: "memorychip") + Spacer() + Text( + device.firmwareVersionString.isEmpty + ? L10n.Settings.DeviceInfo.firmwareVersionFormat(device.firmwareVersion) + : device.firmwareVersionString + ) + .foregroundStyle(.secondary) + } + + HStack { + Label(L10n.Settings.DeviceInfo.buildDate, systemImage: "calendar") + Spacer() + Text( + device.buildDate.isEmpty + ? L10n.Settings.DeviceInfo.unknown + : device.buildDate + ) + .foregroundStyle(.secondary) + } + + HStack { + Label(L10n.Settings.DeviceInfo.manufacturer, systemImage: "building.2") + Spacer() + Text( + device.manufacturerName.isEmpty + ? L10n.Settings.DeviceInfo.unknown + : device.manufacturerName + ) + .foregroundStyle(.secondary) + } + } header: { + Text(L10n.Settings.DeviceInfo.Firmware.header) + } + .themedRowBackground(theme) - private func formatStorage(used: Int, total: Int) -> String { - let style = ByteCountFormatStyle(style: .memory) - let usedBytes = Int64(used) * Int64(Self.bytesPerKilobyte) - let totalBytes = Int64(total) * Int64(Self.bytesPerKilobyte) - return "\(usedBytes.formatted(style)) / \(totalBytes.formatted(style))" - } + // Capabilities + Section { + HStack { + Label(L10n.Settings.DeviceInfo.maxNodes, systemImage: "person.2") + Spacer() + Text("\(device.maxContacts)") + .foregroundStyle(.secondary) + } - private func formatVoltage(_ volts: Double) -> String { - Measurement(value: volts, unit: UnitElectricPotentialDifference.volts) - .formatted(.measurement( - width: .abbreviated, - usage: .asProvided, - numberFormatStyle: .number.precision(.fractionLength(2)) - )) - } + HStack { + Label(L10n.Settings.DeviceInfo.maxChannels, systemImage: "person.3") + Spacer() + Text("\(device.maxChannels)") + .foregroundStyle(.secondary) + } - private func saveNodeName() { - let name = nodeName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, - let settingsService = appState.services?.settingsService else { return } - - isSaving = true - Task { - do { - _ = try await settingsService.setNodeNameVerified(name) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { saveNodeName() }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - } - isSaving = false + HStack { + Label(L10n.Settings.DeviceInfo.maxTxPower, systemImage: "bolt") + Spacer() + Text(L10n.Settings.DeviceInfo.txPowerFormat(device.maxTxPower)) + .foregroundStyle(.secondary) + } + } header: { + Text(L10n.Settings.DeviceInfo.Capabilities.header) } + .themedRowBackground(theme) + + } else { + ContentUnavailableView( + L10n.Settings.DeviceInfo.NoDevice.title, + systemImage: "antenna.radiowaves.left.and.right.slash", + description: Text(L10n.Settings.DeviceInfo.NoDevice.description) + ) + } + } + .themedCanvas(theme) + .settingsSubpageDestinations() + .navigationTitle(L10n.Settings.DeviceInfo.title) + .errorAlert($errorMessage) + .retryAlert(retryAlert) + .refreshable { + await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) } + .task { + deviceInfoLogger.info("DeviceInfoView: appeared, connectedDevice=\(appState.connectedDevice != nil)") + await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) + } + .sheet(isPresented: $showShareSheet) { + if let device = appState.connectedDevice { + ContactQRShareSheet( + contactName: device.nodeName, + publicKey: device.publicKey, + contactType: .chat + ) + } + } + } + + /// Firmware reports storage in binary kilobytes. + private static let bytesPerKilobyte = 1024 + + private func formatStorage(used: Int, total: Int) -> String { + let style = ByteCountFormatStyle(style: .memory) + let usedBytes = Int64(used) * Int64(Self.bytesPerKilobyte) + let totalBytes = Int64(total) * Int64(Self.bytesPerKilobyte) + return "\(usedBytes.formatted(style)) / \(totalBytes.formatted(style))" + } + + private func formatVoltage(_ volts: Double) -> String { + Measurement(value: volts, unit: UnitElectricPotentialDifference.volts) + .formatted(.measurement( + width: .abbreviated, + usage: .asProvided, + numberFormatStyle: .number.precision(.fractionLength(2)) + )) + } + + private func saveNodeName() { + let name = nodeName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, + let settingsService = appState.services?.settingsService else { return } + + isSaving = true + Task { + do { + _ = try await settingsService.setNodeNameVerified(name) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { saveNodeName() }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + } + isSaving = false + } + } } // MARK: - Device Identity Header private struct DeviceIdentityHeader: View { - let device: DeviceDTO - - var body: some View { - HStack(spacing: 16) { - Image(systemName: "cpu.fill") - .font(.system(size: 40)) - .foregroundStyle(.tint) - .frame(width: 60, height: 60) - .background(.tint.opacity(0.1), in: .circle) - - VStack(alignment: .leading, spacing: 4) { - Text(device.nodeName) - .font(.title2) - .bold() - - Text( - device.manufacturerName.isEmpty - ? L10n.Settings.DeviceInfo.defaultManufacturer - : device.manufacturerName - ) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - } - .padding(.vertical, 8) + let device: DeviceDTO + + var body: some View { + HStack(spacing: 16) { + Image(systemName: "cpu.fill") + .font(.system(size: 40)) + .foregroundStyle(.tint) + .frame(width: 60, height: 60) + .background(.tint.opacity(0.1), in: .circle) + + VStack(alignment: .leading, spacing: 4) { + Text(device.nodeName) + .font(.title2) + .bold() + + Text( + device.manufacturerName.isEmpty + ? L10n.Settings.DeviceInfo.defaultManufacturer + : device.manufacturerName + ) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() } + .padding(.vertical, 8) + } } // MARK: - Storage Bar private struct StorageBar: View { - let used: Int - let total: Int - - var body: some View { - GeometryReader { geometry in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(.secondary.opacity(0.2)) - - RoundedRectangle(cornerRadius: 4) - .fill(usageColor) - .frame(width: geometry.size.width * usageRatio) - } - } - .frame(height: 8) + let used: Int + let total: Int + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(.secondary.opacity(0.2)) + + RoundedRectangle(cornerRadius: 4) + .fill(usageColor) + .frame(width: geometry.size.width * usageRatio) + } } - - private var usageRatio: CGFloat { - guard total > 0 else { return 0 } - return CGFloat(used) / CGFloat(total) - } - - private var usageColor: Color { - switch usageRatio { - case 0..<0.7: return .green - case 0.7..<0.9: return .orange - default: return .red - } + .frame(height: 8) + } + + private var usageRatio: CGFloat { + guard total > 0 else { return 0 } + return CGFloat(used) / CGFloat(total) + } + + private var usageColor: Color { + switch usageRatio { + case 0..<0.7: .green + case 0.7..<0.9: .orange + default: .red } + } } #Preview { - NavigationStack { - DeviceInfoView() - .environment(\.appState, AppState()) - } + NavigationStack { + DeviceInfoView() + .environment(\.appState, AppState()) + } } diff --git a/MC1/Views/Settings/DeviceSelectionSheet.swift b/MC1/Views/Settings/DeviceSelectionSheet.swift index b319dcac..7cdc83fc 100644 --- a/MC1/Views/Settings/DeviceSelectionSheet.swift +++ b/MC1/Views/Settings/DeviceSelectionSheet.swift @@ -1,48 +1,48 @@ +import MC1Services import os import SwiftUI -import MC1Services private let logger = Logger(subsystem: "com.mc1", category: "DeviceSelectionSheet") /// Represents a device that can be selected for connection private enum SelectableDevice: Identifiable, Equatable { - case saved(DeviceDTO) - case accessory(id: UUID, name: String) + case saved(DeviceDTO) + case accessory(id: UUID, name: String) - var id: UUID { - switch self { - case .saved(let device): device.id - case .accessory(let id, _): id - } + var id: UUID { + switch self { + case let .saved(device): device.id + case let .accessory(id, _): id } + } - var name: String { - switch self { - case .saved(let device): device.nodeName - case .accessory(_, let name): name - } - } - - /// The primary connection method for display purposes. - /// WiFi methods are preferred over Bluetooth when available. - var primaryConnectionMethod: ConnectionMethod? { - switch self { - case .saved(let device): - // Prefer WiFi if available - device.connectionMethods.first { $0.isWiFi } ?? device.connectionMethods.first - case .accessory: - nil - } + var name: String { + switch self { + case let .saved(device): device.nodeName + case let .accessory(_, name): name } - - /// Whether this device will connect over WiFi (its preferred method is WiFi). - /// Such rows stay tappable regardless of BLE advertisement, since the connect - /// path routes them to WiFi; only BLE-reachable-only rows gate on a live signal. - /// A radio reachable over both transports is included here — its peripheral UUID - /// may differ from `id`, so a BLE-signal gate would strand a WiFi-connectable row. - var connectsViaWiFi: Bool { - primaryConnectionMethod?.isWiFi == true + } + + /// The primary connection method for display purposes. + /// WiFi methods are preferred over Bluetooth when available. + var primaryConnectionMethod: ConnectionMethod? { + switch self { + case let .saved(device): + // Prefer WiFi if available + device.connectionMethods.first { $0.isWiFi } ?? device.connectionMethods.first + case .accessory: + nil } + } + + /// Whether this device will connect over WiFi (its preferred method is WiFi). + /// Such rows stay tappable regardless of BLE advertisement, since the connect + /// path routes them to WiFi; only BLE-reachable-only rows gate on a live signal. + /// A radio reachable over both transports is included here — its peripheral UUID + /// may differ from `id`, so a BLE-signal gate would strand a WiFi-connectable row. + var connectsViaWiFi: Bool { + primaryConnectionMethod?.isWiFi == true + } } /// Filters saved `Device` rows to those the user can actually reach from this phone. @@ -54,370 +54,370 @@ private enum SelectableDevice: Identifiable, Equatable { /// when the user later pairs the radio, but they must not appear in the picker until /// then — they look like saved devices but no tap can connect them. enum DeviceSelectionFilter { - static func isConnectable(_ device: DeviceDTO, pairedAccessoryIDs: Set, hasSystemPairingRegistry: Bool = true) -> Bool { - if device.connectionMethods.contains(where: \.isWiFi) { return true } - guard hasSystemPairingRegistry else { - // No AccessorySetupKit registry to validate against (macOS). A real saved BLE - // device retains its `.bluetooth` connection method, while demoted ghosts and - // backup shadows have it stripped — so the method itself is the reachability signal. - return device.connectionMethods.contains(where: \.isBluetooth) - } - return pairedAccessoryIDs.contains(device.id) + static func isConnectable(_ device: DeviceDTO, pairedAccessoryIDs: Set, hasSystemPairingRegistry: Bool = true) -> Bool { + if device.connectionMethods.contains(where: \.isWiFi) { return true } + guard hasSystemPairingRegistry else { + // No AccessorySetupKit registry to validate against (macOS). A real saved BLE + // device retains its `.bluetooth` connection method, while demoted ghosts and + // backup shadows have it stripped — so the method itself is the reachability signal. + return device.connectionMethods.contains(where: \.isBluetooth) } + return pairedAccessoryIDs.contains(device.id) + } } /// Sheet for selecting and reconnecting to previously paired devices struct DeviceSelectionSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - - @State private var devices: [SelectableDevice] = [] - @State private var showingWiFiConnection = false - @State private var editingWiFiDevice: SelectableDevice? - @State private var devicesConnectedElsewhere: Set = [] - @State private var tracker = RSSIScanTracker() - - var body: some View { - NavigationStack { - Group { - if devices.isEmpty { - makeEmptyStateView() - } else { - makeDeviceListView() - } - } - .navigationTitle(L10n.Settings.DeviceSelection.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - dismiss() - } - } - } - .task { - await loadDevices() - await startBLEScanning() - } + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var devices: [SelectableDevice] = [] + @State private var showingWiFiConnection = false + @State private var editingWiFiDevice: SelectableDevice? + @State private var devicesConnectedElsewhere: Set = [] + @State private var tracker = RSSIScanTracker() + + var body: some View { + NavigationStack { + Group { + if devices.isEmpty { + makeEmptyStateView() + } else { + makeDeviceListView() } - } - - // MARK: - Subviews - - private func makeDeviceListView() -> some View { - DeviceListView( - devices: devices, - devicesConnectedElsewhere: devicesConnectedElsewhere, - tracker: tracker, - showingWiFiConnection: $showingWiFiConnection, - editingWiFiDevice: $editingWiFiDevice, - onConnect: { connectToDevice($0) }, - onDelete: { deleteDevice($0) }, - onScanForNew: { scanForNewDevice() } - ) - } - - private func makeEmptyStateView() -> some View { - EmptyStateView( - showingWiFiConnection: $showingWiFiConnection, - onScanForNew: { scanForNewDevice() } - ) - } - - // MARK: - Actions - - private func startBLEScanning() async { - await tracker.consume(appState.connectionManager.startBLEScanning()) - } - - private func loadDevices() async { - // Try to load from SwiftData first - let pairedAccessoryIDs = Set(appState.connectionManager.pairedAccessoryInfos.map(\.id)) - let hasSystemPairingRegistry = appState.connectionManager.hasSystemPairingRegistry - do { - let savedDevices = try await appState.connectionManager.fetchSavedDevices() - let connectableDevices = savedDevices.filter { - DeviceSelectionFilter.isConnectable($0, pairedAccessoryIDs: pairedAccessoryIDs, hasSystemPairingRegistry: hasSystemPairingRegistry) - } - if !connectableDevices.isEmpty { - devices = connectableDevices.map { .saved($0) } - - // Check which devices are connected elsewhere (BLE only) - var connectedElsewhere: Set = [] - for device in connectableDevices { - // Skip WiFi-only devices - let hasBluetooth = device.connectionMethods.isEmpty || - device.connectionMethods.contains { !$0.isWiFi } - if hasBluetooth { - if await appState.connectionManager.isDeviceConnectedToOtherApp(device.id) { - connectedElsewhere.insert(device.id) - } - } - } - devicesConnectedElsewhere = connectedElsewhere - return - } - } catch { - logger.error("Failed to load devices: \(error)") + } + .navigationTitle(L10n.Settings.DeviceSelection.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { + dismiss() + } } - - // Fall back to ASK accessories when the filter drops every saved row (or the DB is empty) - let accessories = appState.connectionManager.pairedAccessoryInfos - devices = accessories.map { .accessory(id: $0.id, name: $0.name) } - - // Check which accessories are connected elsewhere + } + .task { + await loadDevices() + await startBLEScanning() + } + } + } + + // MARK: - Subviews + + private func makeDeviceListView() -> some View { + DeviceListView( + devices: devices, + devicesConnectedElsewhere: devicesConnectedElsewhere, + tracker: tracker, + showingWiFiConnection: $showingWiFiConnection, + editingWiFiDevice: $editingWiFiDevice, + onConnect: { connectToDevice($0) }, + onDelete: { deleteDevice($0) }, + onScanForNew: { scanForNewDevice() } + ) + } + + private func makeEmptyStateView() -> some View { + EmptyStateView( + showingWiFiConnection: $showingWiFiConnection, + onScanForNew: { scanForNewDevice() } + ) + } + + // MARK: - Actions + + private func startBLEScanning() async { + await tracker.consume(appState.connectionManager.startBLEScanning()) + } + + private func loadDevices() async { + // Try to load from SwiftData first + let pairedAccessoryIDs = Set(appState.connectionManager.pairedAccessoryInfos.map(\.id)) + let hasSystemPairingRegistry = appState.connectionManager.hasSystemPairingRegistry + do { + let savedDevices = try await appState.connectionManager.fetchSavedDevices() + let connectableDevices = savedDevices.filter { + DeviceSelectionFilter.isConnectable($0, pairedAccessoryIDs: pairedAccessoryIDs, hasSystemPairingRegistry: hasSystemPairingRegistry) + } + if !connectableDevices.isEmpty { + devices = connectableDevices.map { .saved($0) } + + // Check which devices are connected elsewhere (BLE only) var connectedElsewhere: Set = [] - for accessory in accessories where await appState.connectionManager.isDeviceConnectedToOtherApp(accessory.id) { - connectedElsewhere.insert(accessory.id) + for device in connectableDevices { + // Skip WiFi-only devices + let hasBluetooth = device.connectionMethods.isEmpty || + device.connectionMethods.contains { !$0.isWiFi } + if hasBluetooth { + if await appState.connectionManager.isDeviceConnectedToOtherApp(device.id) { + connectedElsewhere.insert(device.id) + } + } } devicesConnectedElsewhere = connectedElsewhere + return + } + } catch { + logger.error("Failed to load devices: \(error)") } - private func scanForNewDevice() { - dismiss() - Task { - await appState.connectionManager.stopBLEScanning() - await appState.disconnect(reason: .switchingDevice) - // Trigger ASK picker flow via AppState - appState.startDeviceScan() - } - } + // Fall back to ASK accessories when the filter drops every saved row (or the DB is empty) + let accessories = appState.connectionManager.pairedAccessoryInfos + devices = accessories.map { .accessory(id: $0.id, name: $0.name) } - private func connectToDevice(_ device: SelectableDevice) { - dismiss() - Task { - logger.info("[UI] User tapped Connect for device: \(device.id.uuidString.prefix(8)), name: \(device.name)") - do { - if case .wifi(let host, let port, _) = device.primaryConnectionMethod { - try await appState.connectViaWiFi(host: host, port: port, forceFullSync: true) - } else { - try await appState.connectionManager.connect(to: device.id, forceFullSync: true, forceReconnect: true) - } - } catch BLEError.deviceConnectedToOtherApp { - appState.connectionUI.otherAppWarningDeviceID = device.id - } catch { - appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) - } - } + // Check which accessories are connected elsewhere + var connectedElsewhere: Set = [] + for accessory in accessories where await appState.connectionManager.isDeviceConnectedToOtherApp(accessory.id) { + connectedElsewhere.insert(accessory.id) } - - private func deleteDevice(_ device: SelectableDevice) { - guard case .saved(let deviceDTO) = device else { return } - - Task { - do { - try await appState.connectionManager.deleteDevice(id: deviceDTO.id) - devices.removeAll { $0.id == device.id } - } catch { - logger.error("Failed to delete device: \(error)") - } + devicesConnectedElsewhere = connectedElsewhere + } + + private func scanForNewDevice() { + dismiss() + Task { + await appState.connectionManager.stopBLEScanning() + await appState.disconnect(reason: .switchingDevice) + // Trigger ASK picker flow via AppState + appState.startDeviceScan() + } + } + + private func connectToDevice(_ device: SelectableDevice) { + dismiss() + Task { + logger.info("[UI] User tapped Connect for device: \(device.id.uuidString.prefix(8)), name: \(device.name)") + do { + if case let .wifi(host, port, _) = device.primaryConnectionMethod { + try await appState.connectViaWiFi(host: host, port: port, forceFullSync: true) + } else { + try await appState.connectionManager.connect(to: device.id, forceFullSync: true, forceReconnect: true) } + } catch BLEError.deviceConnectedToOtherApp { + appState.connectionUI.otherAppWarningDeviceID = device.id + } catch { + appState.connectionUI.presentConnectionFailure(message: error.userFacingMessage) + } } + } + + private func deleteDevice(_ device: SelectableDevice) { + guard case let .saved(deviceDTO) = device else { return } + + Task { + do { + try await appState.connectionManager.deleteDevice(id: deviceDTO.id) + devices.removeAll { $0.id == device.id } + } catch { + logger.error("Failed to delete device: \(error)") + } + } + } } // MARK: - Device List View private struct DeviceListView: View { - @Environment(\.appTheme) private var theme - let devices: [SelectableDevice] - let devicesConnectedElsewhere: Set - let tracker: RSSIScanTracker - @Binding var showingWiFiConnection: Bool - @Binding var editingWiFiDevice: SelectableDevice? - let onConnect: (SelectableDevice) -> Void - let onDelete: (SelectableDevice) -> Void - let onScanForNew: () -> Void - - var body: some View { - List { - Section { - ForEach(devices) { device in - let tier = device.connectsViaWiFi ? nil : tracker.signalTier(for: device.id) - let isDisabledByBLE = !device.connectsViaWiFi && !tracker.isAdvertising(device.id) - Button { - guard !isDisabledByBLE else { return } - onConnect(device) - } label: { - DeviceRow( - device: device, - connectsViaWiFi: device.connectsViaWiFi, - isConnectedElsewhere: devicesConnectedElsewhere.contains(device.id), - signalTier: tier - ) - .contentShape(.rect) - } - .buttonStyle(.plain) - .contextMenu { - Button(role: .destructive) { - onDelete(device) - } label: { - Label(L10n.Localizable.Common.delete, systemImage: "trash") - } - - if device.primaryConnectionMethod?.isWiFi == true { - Button { - editingWiFiDevice = device - } label: { - Label(L10n.Localizable.Common.edit, systemImage: "pencil") - } - } - } - } - } header: { - Text(L10n.Settings.DeviceSelection.previouslyPaired) + @Environment(\.appTheme) private var theme + let devices: [SelectableDevice] + let devicesConnectedElsewhere: Set + let tracker: RSSIScanTracker + @Binding var showingWiFiConnection: Bool + @Binding var editingWiFiDevice: SelectableDevice? + let onConnect: (SelectableDevice) -> Void + let onDelete: (SelectableDevice) -> Void + let onScanForNew: () -> Void + + var body: some View { + List { + Section { + ForEach(devices) { device in + let tier = device.connectsViaWiFi ? nil : tracker.signalTier(for: device.id) + let isDisabledByBLE = !device.connectsViaWiFi && !tracker.isAdvertising(device.id) + Button { + guard !isDisabledByBLE else { return } + onConnect(device) + } label: { + DeviceRow( + device: device, + connectsViaWiFi: device.connectsViaWiFi, + isConnectedElsewhere: devicesConnectedElsewhere.contains(device.id), + signalTier: tier + ) + .contentShape(.rect) + } + .buttonStyle(.plain) + .contextMenu { + Button(role: .destructive) { + onDelete(device) + } label: { + Label(L10n.Localizable.Common.delete, systemImage: "trash") } - .themedRowBackground(theme) - - Section { - Button { - showingWiFiConnection = true - } label: { - Label(L10n.Settings.DeviceSelection.connectViaWifi, systemImage: "wifi.circle") - } - - Button { - onScanForNew() - } label: { - Label(L10n.Settings.DeviceSelection.scanBluetooth, systemImage: "antenna.radiowaves.left.and.right") - } + + if device.primaryConnectionMethod?.isWiFi == true { + Button { + editingWiFiDevice = device + } label: { + Label(L10n.Localizable.Common.edit, systemImage: "pencil") + } } - .themedRowBackground(theme) + } } - .themedCanvas(theme) - .sheet(isPresented: $showingWiFiConnection) { - WiFiConnectionSheet() + } header: { + Text(L10n.Settings.DeviceSelection.previouslyPaired) + } + .themedRowBackground(theme) + + Section { + Button { + showingWiFiConnection = true + } label: { + Label(L10n.Settings.DeviceSelection.connectViaWifi, systemImage: "wifi.circle") } - .sheet(item: $editingWiFiDevice) { device in - if case .wifi(let host, let port, _) = device.primaryConnectionMethod { - WiFiEditSheet(initialHost: host, initialPort: port) - } + + Button { + onScanForNew() + } label: { + Label(L10n.Settings.DeviceSelection.scanBluetooth, systemImage: "antenna.radiowaves.left.and.right") } + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .sheet(isPresented: $showingWiFiConnection) { + WiFiConnectionSheet() + } + .sheet(item: $editingWiFiDevice) { device in + if case let .wifi(host, port, _) = device.primaryConnectionMethod { + WiFiEditSheet(initialHost: host, initialPort: port) + } + } + } } // MARK: - Empty State View private struct EmptyStateView: View { - @Binding var showingWiFiConnection: Bool - let onScanForNew: () -> Void - - var body: some View { - ContentUnavailableView { - Label(L10n.Settings.DeviceSelection.noPairedDevices, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - VStack(spacing: 20) { - Text(L10n.Settings.DeviceSelection.noPairedDescription) - - VStack(spacing: 12) { - Button(L10n.Settings.DeviceSelection.connectViaWifi, systemImage: "wifi.circle") { - showingWiFiConnection = true - } - .liquidGlassProminentButtonStyle() - - Button( - L10n.Settings.DeviceSelection.scanForDevices, - systemImage: "antenna.radiowaves.left.and.right" - ) { - onScanForNew() - } - .liquidGlassProminentButtonStyle() - } - } - } - .sheet(isPresented: $showingWiFiConnection) { - WiFiConnectionSheet() + @Binding var showingWiFiConnection: Bool + let onScanForNew: () -> Void + + var body: some View { + ContentUnavailableView { + Label(L10n.Settings.DeviceSelection.noPairedDevices, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + VStack(spacing: 20) { + Text(L10n.Settings.DeviceSelection.noPairedDescription) + + VStack(spacing: 12) { + Button(L10n.Settings.DeviceSelection.connectViaWifi, systemImage: "wifi.circle") { + showingWiFiConnection = true + } + .liquidGlassProminentButtonStyle() + + Button( + L10n.Settings.DeviceSelection.scanForDevices, + systemImage: "antenna.radiowaves.left.and.right" + ) { + onScanForNew() + } + .liquidGlassProminentButtonStyle() } + } + } + .sheet(isPresented: $showingWiFiConnection) { + WiFiConnectionSheet() } + } } // MARK: - Device Row private struct DeviceRow: View { - let device: SelectableDevice - let connectsViaWiFi: Bool - let isConnectedElsewhere: Bool - let signalTier: RSSITuning.SignalTier? - - private var isUnreachable: Bool { - !connectsViaWiFi && signalTier == nil + let device: SelectableDevice + let connectsViaWiFi: Bool + let isConnectedElsewhere: Bool + let signalTier: RSSITuning.SignalTier? + + private var isUnreachable: Bool { + !connectsViaWiFi && signalTier == nil + } + + private var transportIcon: String { + guard let method = device.primaryConnectionMethod else { + return "antenna.radiowaves.left.and.right" } + return method.isWiFi ? "wifi" : "antenna.radiowaves.left.and.right" + } - private var transportIcon: String { - guard let method = device.primaryConnectionMethod else { - return "antenna.radiowaves.left.and.right" - } - return method.isWiFi ? "wifi" : "antenna.radiowaves.left.and.right" - } - - private var transportColor: Color { - guard let method = device.primaryConnectionMethod else { - return .green - } - return method.isWiFi ? .blue : .green + private var transportColor: Color { + guard let method = device.primaryConnectionMethod else { + return .green } + return method.isWiFi ? .blue : .green + } - private var connectionDescription: String { - if let method = device.primaryConnectionMethod, method.isWiFi { - return method.shortDescription - } - return L10n.Settings.DeviceSelection.bluetooth + private var connectionDescription: String { + if let method = device.primaryConnectionMethod, method.isWiFi { + return method.shortDescription } - - var body: some View { - HStack(spacing: 12) { - Image(systemName: transportIcon) - .font(.title2) - .foregroundStyle(transportColor) - .frame(width: 40, height: 40) - .background(transportColor.opacity(0.1), in: .circle) - - VStack(alignment: .leading, spacing: 2) { - Text(device.name) - .font(.headline) - - if isConnectedElsewhere { - Label( - L10n.Settings.DeviceSelection.connectedElsewhere, - systemImage: "exclamationmark.triangle.fill" - ) - .font(.caption) - .foregroundStyle(.orange) - } else { - Text(connectionDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - if let tier = signalTier { - SignalBars(tier: tier) - } + return L10n.Settings.DeviceSelection.bluetooth + } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: transportIcon) + .font(.title2) + .foregroundStyle(transportColor) + .frame(width: 40, height: 40) + .background(transportColor.opacity(0.1), in: .circle) + + VStack(alignment: .leading, spacing: 2) { + Text(device.name) + .font(.headline) + + if isConnectedElsewhere { + Label( + L10n.Settings.DeviceSelection.connectedElsewhere, + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.orange) + } else { + Text(connectionDescription) + .font(.caption) + .foregroundStyle(.secondary) } - .padding(.vertical, 4) - .contentShape(.rect) - .opacity(isConnectedElsewhere || isUnreachable ? 0.4 : 1.0) - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilityLabel(isConnectedElsewhere - ? L10n.Settings.DeviceSelection.Accessibility.connectedElsewhereLabel(device.name) - : L10n.Settings.DeviceSelection.Accessibility.deviceLabel(device.name, connectionDescription)) - .accessibilityValue(signalDescription) - .accessibilityHint(isConnectedElsewhere - ? L10n.Settings.DeviceSelection.Accessibility.connectedElsewhereHint - : isUnreachable - ? L10n.Settings.DeviceSelection.Accessibility.outOfRangeHint - : L10n.Settings.DeviceSelection.Accessibility.selectHint) - } + } - // MARK: - Signal Tier Helpers + Spacer() - /// VoiceOver descriptor for the signal tier, announced as the row's accessibility value so - /// it survives the explicit `accessibilityLabel` above (which overrides combined children). - /// Empty when the device is out of range so VoiceOver announces no value. - private var signalDescription: String { - guard let tier = signalTier else { return "" } - return SignalBars.accessibilityDescription(forTier: tier) + if let tier = signalTier { + SignalBars(tier: tier) + } } + .padding(.vertical, 4) + .contentShape(.rect) + .opacity(isConnectedElsewhere || isUnreachable ? 0.4 : 1.0) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(isConnectedElsewhere + ? L10n.Settings.DeviceSelection.Accessibility.connectedElsewhereLabel(device.name) + : L10n.Settings.DeviceSelection.Accessibility.deviceLabel(device.name, connectionDescription)) + .accessibilityValue(signalDescription) + .accessibilityHint(isConnectedElsewhere + ? L10n.Settings.DeviceSelection.Accessibility.connectedElsewhereHint + : isUnreachable + ? L10n.Settings.DeviceSelection.Accessibility.outOfRangeHint + : L10n.Settings.DeviceSelection.Accessibility.selectHint) + } + + // MARK: - Signal Tier Helpers + + /// VoiceOver descriptor for the signal tier, announced as the row's accessibility value so + /// it survives the explicit `accessibilityLabel` above (which overrides combined children). + /// Empty when the device is out of range so VoiceOver announces no value. + private var signalDescription: String { + guard let tier = signalTier else { return "" } + return SignalBars.accessibilityDescription(forTier: tier) + } } diff --git a/MC1/Views/Settings/ExportSuccessContent.swift b/MC1/Views/Settings/ExportSuccessContent.swift index f25ea012..f0d53e7d 100644 --- a/MC1/Views/Settings/ExportSuccessContent.swift +++ b/MC1/Views/Settings/ExportSuccessContent.swift @@ -5,77 +5,77 @@ import SwiftUI /// `ImportSuccessContent` minus the "already here" section — export /// has no skip concept. Lives in the MC1 target because it uses `L10n`. struct ExportSuccessContent: View { - @Environment(\.appTheme) private var theme - let summary: AppBackupViewModel.ExportSuccessSummary - let onDismiss: @MainActor () -> Void + @Environment(\.appTheme) private var theme + let summary: AppBackupViewModel.ExportSuccessSummary + let onDismiss: @MainActor () -> Void - var body: some View { - List { - heroSection - .themedRowBackground(theme) - includedSection - .themedRowBackground(theme) - doneSection - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Settings.Backup.Export.Success.title) - .navigationBarTitleDisplayMode(.inline) - .onAppear { - AccessibilityNotification.Announcement( - L10n.Settings.Settings.Backup.Export.Success.announcement(summary.filename) - ).post() - } - .sensoryFeedback(.success, trigger: summary.id) + var body: some View { + List { + heroSection + .themedRowBackground(theme) + includedSection + .themedRowBackground(theme) + doneSection } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Settings.Backup.Export.Success.title) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + AccessibilityNotification.Announcement( + L10n.Settings.Settings.Backup.Export.Success.announcement(summary.filename) + ).post() + } + .sensoryFeedback(.success, trigger: summary.id) + } - private var heroSection: some View { - Section { - VStack(spacing: 12) { - Image(systemName: "checkmark.circle.fill") - .font(.system(.largeTitle)) - .imageScale(.large) - .foregroundStyle(.green) - .accessibilityHidden(true) - Text(L10n.Settings.Settings.Backup.Export.Success.title) - .font(.headline) - VStack(spacing: 2) { - Text(summary.filename) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - Text(summary.byteCount.formatted(.byteCount(style: .file))) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) + private var heroSection: some View { + Section { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(.largeTitle)) + .imageScale(.large) + .foregroundStyle(.green) + .accessibilityHidden(true) + Text(L10n.Settings.Settings.Backup.Export.Success.title) + .font(.headline) + VStack(spacing: 2) { + Text(summary.filename) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Text(summary.byteCount.formatted(.byteCount(style: .file))) + .font(.footnote) + .foregroundStyle(.secondary) } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) } + } - private var includedSection: some View { - Section(L10n.Settings.Settings.Backup.Export.Success.includedSection) { - ForEach(BackupModelKind.allCases, id: \.self) { kind in - let count = summary.manifest.count(for: kind) - if count > 0 { - LabeledContent(kind.label, value: "\(count)") - } - } + private var includedSection: some View { + Section(L10n.Settings.Settings.Backup.Export.Success.includedSection) { + ForEach(BackupModelKind.allCases, id: \.self) { kind in + let count = summary.manifest.count(for: kind) + if count > 0 { + LabeledContent(kind.label, value: "\(count)") } + } } + } - private var doneSection: some View { - Section { - Button { - onDismiss() - } label: { - Text(L10n.Settings.Settings.Backup.Export.Success.done) - .frame(maxWidth: .infinity) - } - .liquidGlassProminentButtonStyle() - .controlSize(.large) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - .listRowBackground(Color.clear) - } + private var doneSection: some View { + Section { + Button { + onDismiss() + } label: { + Text(L10n.Settings.Settings.Backup.Export.Success.done) + .frame(maxWidth: .infinity) + } + .liquidGlassProminentButtonStyle() + .controlSize(.large) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) } + } } diff --git a/MC1/Views/Settings/ImportPreviewSheet.swift b/MC1/Views/Settings/ImportPreviewSheet.swift index 90409945..16d893d6 100644 --- a/MC1/Views/Settings/ImportPreviewSheet.swift +++ b/MC1/Views/Settings/ImportPreviewSheet.swift @@ -1,205 +1,204 @@ -import SwiftUI import MC1Services +import SwiftUI struct ImportPreviewSheet: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: AppBackupViewModel - - var body: some View { - NavigationStack { - Group { - if viewModel.isImporting { - importingView - } else if let result = viewModel.importResult { - ImportSuccessContent(viewModel: viewModel, result: result) - } else if let error = viewModel.sheetErrorMessage { - errorView(message: error) - } else if viewModel.isCancelled { - cancelledView - } else if let envelope = viewModel.previewEnvelope { - previewView(envelope: envelope) - } - } - .navigationTitle(L10n.Settings.Settings.Backup.Import.Preview.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Settings.Settings.Backup.Import.Preview.cancel) { - if viewModel.isImporting { - viewModel.cancelImport() - } else { - viewModel.dismissImportSheet() - } - } - .disabled(viewModel.isCancellingImport) - } - } - .interactiveDismissDisabled(viewModel.isImporting) - .sensoryFeedback(trigger: viewModel.importResult) { _, newValue in - guard let result = newValue else { return nil } - return result.hasRestoredChanges ? .success : nil - } - .sensoryFeedback(.error, trigger: viewModel.sheetErrorMessage) { _, newValue in newValue != nil } + @Environment(\.appTheme) private var theme + @Bindable var viewModel: AppBackupViewModel + + var body: some View { + NavigationStack { + Group { + if viewModel.isImporting { + importingView + } else if let result = viewModel.importResult { + ImportSuccessContent(viewModel: viewModel, result: result) + } else if let error = viewModel.sheetErrorMessage { + errorView(message: error) + } else if viewModel.isCancelled { + cancelledView + } else if let envelope = viewModel.previewEnvelope { + previewView(envelope: envelope) } - } - - // MARK: - Preview - - private func previewView(envelope: AppBackupEnvelope) -> some View { - List { - Section(L10n.Settings.Settings.Backup.Import.Preview.details) { - LabeledContent(L10n.Settings.Settings.Backup.Import.Preview.exported, value: envelope.exportDate.formatted(date: .abbreviated, time: .shortened)) - LabeledContent(L10n.Settings.Settings.Backup.Import.Preview.appVersion, value: "\(envelope.appVersion) (\(envelope.appBuild))") - } - .themedRowBackground(theme) - - Section(L10n.Settings.Settings.Backup.Import.Preview.contents) { - manifestRows(envelope.manifest) - } - .themedRowBackground(theme) - - Section { - Label { - Text(L10n.Settings.Settings.Backup.Import.Preview.info) - } icon: { - Image(systemName: "info.circle.fill") - .foregroundStyle(.tint) - } - } - .themedRowBackground(theme) - - Section { - Button { - viewModel.performImport() - } label: { - Text(L10n.Settings.Settings.Backup.Import.Preview.button) - .frame(maxWidth: .infinity) - } - .liquidGlassProminentButtonStyle() - .controlSize(.large) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - .listRowBackground(Color.clear) + } + .navigationTitle(L10n.Settings.Settings.Backup.Import.Preview.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Settings.Settings.Backup.Import.Preview.cancel) { + if viewModel.isImporting { + viewModel.cancelImport() + } else { + viewModel.dismissImportSheet() } + } + .disabled(viewModel.isCancellingImport) } - .themedCanvas(theme) + } + .interactiveDismissDisabled(viewModel.isImporting) + .sensoryFeedback(trigger: viewModel.importResult) { _, newValue in + guard let result = newValue else { return nil } + return result.hasRestoredChanges ? .success : nil + } + .sensoryFeedback(.error, trigger: viewModel.sheetErrorMessage) { _, newValue in newValue != nil } } - - // MARK: - Importing - - private var importingView: some View { - VStack(spacing: 16) { - ProgressView() - .controlSize(.large) - Text(viewModel.isCancellingImport - ? L10n.Settings.Settings.Backup.Import.cancelling - : L10n.Settings.Settings.Backup.Import.progress) - .foregroundStyle(.secondary) + } + + // MARK: - Preview + + private func previewView(envelope: AppBackupEnvelope) -> some View { + List { + Section(L10n.Settings.Settings.Backup.Import.Preview.details) { + LabeledContent(L10n.Settings.Settings.Backup.Import.Preview.exported, value: envelope.exportDate.formatted(date: .abbreviated, time: .shortened)) + LabeledContent(L10n.Settings.Settings.Backup.Import.Preview.appVersion, value: "\(envelope.appVersion) (\(envelope.appBuild))") + } + .themedRowBackground(theme) + + Section(L10n.Settings.Settings.Backup.Import.Preview.contents) { + manifestRows(envelope.manifest) + } + .themedRowBackground(theme) + + Section { + Label { + Text(L10n.Settings.Settings.Backup.Import.Preview.info) + } icon: { + Image(systemName: "info.circle.fill") + .foregroundStyle(.tint) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.progress).post() + } + .themedRowBackground(theme) + + Section { + Button { + viewModel.performImport() + } label: { + Text(L10n.Settings.Settings.Backup.Import.Preview.button) + .frame(maxWidth: .infinity) } + .liquidGlassProminentButtonStyle() + .controlSize(.large) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + } } - - // MARK: - Cancelled - - private var cancelledView: some View { - List { - Section { - VStack(spacing: 12) { - Image(systemName: "info.circle.fill") - .font(.system(.largeTitle)) - .imageScale(.large) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - Text(L10n.Settings.Settings.Backup.Import.Cancelled.title) - .font(.headline) - Text(L10n.Settings.Settings.Backup.Import.Cancelled.message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - .themedRowBackground(theme) - - Section { - Button { - viewModel.dismissImportSheet() - } label: { - HStack { - Spacer() - Text(L10n.Settings.Settings.Backup.Import.Success.done) - .bold() - Spacer() - } - } - } - .themedRowBackground(theme) + .themedCanvas(theme) + } + + // MARK: - Importing + + private var importingView: some View { + VStack(spacing: 16) { + ProgressView() + .controlSize(.large) + Text(viewModel.isCancellingImport + ? L10n.Settings.Settings.Backup.Import.cancelling + : L10n.Settings.Settings.Backup.Import.progress) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.progress).post() + } + } + + // MARK: - Cancelled + + private var cancelledView: some View { + List { + Section { + VStack(spacing: 12) { + Image(systemName: "info.circle.fill") + .font(.system(.largeTitle)) + .imageScale(.large) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(L10n.Settings.Settings.Backup.Import.Cancelled.title) + .font(.headline) + Text(L10n.Settings.Settings.Backup.Import.Cancelled.message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } - .themedCanvas(theme) - .onAppear { - AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.Cancelled.title).post() + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .themedRowBackground(theme) + + Section { + Button { + viewModel.dismissImportSheet() + } label: { + HStack { + Spacer() + Text(L10n.Settings.Settings.Backup.Import.Success.done) + .bold() + Spacer() + } } + } + .themedRowBackground(theme) } - - // MARK: - Error - - private func errorView(message: String) -> some View { - List { - Section { - VStack(spacing: 12) { - Image(systemName: "xmark.circle.fill") - .font(.system(.largeTitle)) - .imageScale(.large) - .foregroundStyle(.red) - .accessibilityHidden(true) - Text(L10n.Settings.Settings.Backup.Import.Error.title) - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - .themedRowBackground(theme) - - Section { - Button { - viewModel.dismissImportSheet() - } label: { - HStack { - Spacer() - Text(L10n.Settings.Settings.Backup.Import.Error.dismiss) - .bold() - Spacer() - } - } - } - .themedRowBackground(theme) + .themedCanvas(theme) + .onAppear { + AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.Cancelled.title).post() + } + } + + // MARK: - Error + + private func errorView(message: String) -> some View { + List { + Section { + VStack(spacing: 12) { + Image(systemName: "xmark.circle.fill") + .font(.system(.largeTitle)) + .imageScale(.large) + .foregroundStyle(.red) + .accessibilityHidden(true) + Text(L10n.Settings.Settings.Backup.Import.Error.title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } - .themedCanvas(theme) - .onAppear { - AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.Error.title).post() + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .themedRowBackground(theme) + + Section { + Button { + viewModel.dismissImportSheet() + } label: { + HStack { + Spacer() + Text(L10n.Settings.Settings.Backup.Import.Error.dismiss) + .bold() + Spacer() + } } + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .onAppear { + AccessibilityNotification.Announcement(L10n.Settings.Settings.Backup.Import.Error.title).post() + } + } - // MARK: - Count rows + // MARK: - Count rows - @ViewBuilder - private func manifestRows(_ manifest: BackupManifest) -> some View { - ForEach(BackupModelKind.allCases, id: \.self) { kind in - manifestRow(kind.label, count: manifest.count(for: kind)) - } + private func manifestRows(_ manifest: BackupManifest) -> some View { + ForEach(BackupModelKind.allCases, id: \.self) { kind in + manifestRow(kind.label, count: manifest.count(for: kind)) } + } - @ViewBuilder - private func manifestRow(_ label: String, count: Int) -> some View { - if count > 0 { - LabeledContent(label, value: "\(count)") - } + @ViewBuilder + private func manifestRow(_ label: String, count: Int) -> some View { + if count > 0 { + LabeledContent(label, value: "\(count)") } + } } diff --git a/MC1/Views/Settings/ImportSuccessContent.swift b/MC1/Views/Settings/ImportSuccessContent.swift index 87195173..1790bfa4 100644 --- a/MC1/Views/Settings/ImportSuccessContent.swift +++ b/MC1/Views/Settings/ImportSuccessContent.swift @@ -5,161 +5,171 @@ import SwiftUI /// from `ImportPreviewSheet` so the success rendering can stay under the /// project's ~300-line-per-file guideline and keep its own disclosure state. struct ImportSuccessContent: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: AppBackupViewModel - let result: ImportResult - @State private var isAlreadyHereExpanded = false - @State private var isDroppedExpanded = false - - private var didAdd: Bool { result.totalInserted > 0 } - private var didMerge: Bool { result.totalMerged > 0 } - private var hasSkipped: Bool { result.totalSkipped > 0 } - private var hasDropped: Bool { result.totalDropped > 0 } - - private var heroTitle: String { - result.hasRestoredChanges - ? L10n.Settings.Settings.Backup.Import.Success.title - : L10n.Settings.Settings.Backup.Import.NothingToImport.title - } + @Environment(\.appTheme) private var theme + @Bindable var viewModel: AppBackupViewModel + let result: ImportResult + @State private var isAlreadyHereExpanded = false + @State private var isDroppedExpanded = false - private var heroSubtitle: String { - if didAdd { - return L10n.Settings.Settings.Backup.Import.Success.subtitleAdded(result.totalInserted) - } - if didMerge { - return L10n.Settings.Settings.Backup.Import.Success.subtitleRefreshed(result.totalMerged) - } - return L10n.Settings.Settings.Backup.Import.NothingToImport.subtitle - } + private var didAdd: Bool { + result.totalInserted > 0 + } - private var heroIconName: String { - result.hasRestoredChanges ? "checkmark.circle.fill" : "info.circle.fill" - } + private var didMerge: Bool { + result.totalMerged > 0 + } + + private var hasSkipped: Bool { + result.totalSkipped > 0 + } + + private var hasDropped: Bool { + result.totalDropped > 0 + } - private var heroIconColor: Color { - result.hasRestoredChanges ? .green : .secondary + private var heroTitle: String { + result.hasRestoredChanges + ? L10n.Settings.Settings.Backup.Import.Success.title + : L10n.Settings.Settings.Backup.Import.NothingToImport.title + } + + private var heroSubtitle: String { + if didAdd { + return L10n.Settings.Settings.Backup.Import.Success.subtitleAdded(result.totalInserted) + } + if didMerge { + return L10n.Settings.Settings.Backup.Import.Success.subtitleRefreshed(result.totalMerged) } + return L10n.Settings.Settings.Backup.Import.NothingToImport.subtitle + } - var body: some View { - List { - heroSection - .themedRowBackground(theme) - if didAdd { - addedSection - .themedRowBackground(theme) - } - if hasSkipped { - alreadyHereSection - .themedRowBackground(theme) - } - if hasDropped { - droppedSection - .themedRowBackground(theme) - } - doneSection - } - .themedCanvas(theme) - .onAppear { - AccessibilityNotification.Announcement("\(heroTitle). \(heroSubtitle)").post() - } + private var heroIconName: String { + result.hasRestoredChanges ? "checkmark.circle.fill" : "info.circle.fill" + } + + private var heroIconColor: Color { + result.hasRestoredChanges ? .green : .secondary + } + + var body: some View { + List { + heroSection + .themedRowBackground(theme) + if didAdd { + addedSection + .themedRowBackground(theme) + } + if hasSkipped { + alreadyHereSection + .themedRowBackground(theme) + } + if hasDropped { + droppedSection + .themedRowBackground(theme) + } + doneSection + } + .themedCanvas(theme) + .onAppear { + AccessibilityNotification.Announcement("\(heroTitle). \(heroSubtitle)").post() } + } - private var heroSection: some View { - Section { - VStack(spacing: 12) { - Image(systemName: heroIconName) - .font(.system(.largeTitle)) - .imageScale(.large) - .foregroundStyle(heroIconColor) - .accessibilityHidden(true) - Text(heroTitle) - .font(.headline) - Text(heroSubtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } + private var heroSection: some View { + Section { + VStack(spacing: 12) { + Image(systemName: heroIconName) + .font(.system(.largeTitle)) + .imageScale(.large) + .foregroundStyle(heroIconColor) + .accessibilityHidden(true) + Text(heroTitle) + .font(.headline) + Text(heroSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) } + } - private var addedSection: some View { - Section(L10n.Settings.Settings.Backup.Import.Success.addedSection) { - ForEach(BackupModelKind.allCases, id: \.self) { kind in - let inserted = result.counts[kind]?.inserted ?? 0 - if inserted > 0 { - LabeledContent(kind.label, value: "\(inserted)") - } - } + private var addedSection: some View { + Section(L10n.Settings.Settings.Backup.Import.Success.addedSection) { + ForEach(BackupModelKind.allCases, id: \.self) { kind in + let inserted = result.counts[kind]?.inserted ?? 0 + if inserted > 0 { + LabeledContent(kind.label, value: "\(inserted)") } + } } + } - private var alreadyHereSection: some View { - Section { - DisclosureGroup(isExpanded: $isAlreadyHereExpanded) { - ForEach(BackupModelKind.allCases, id: \.self) { kind in - let skipped = result.counts[kind]?.skipped ?? 0 - if skipped > 0 { - LabeledContent(kind.label, value: "\(skipped)") - } - } - } label: { - Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereSummary(result.totalSkipped)) - } - } header: { - Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereSection) - } footer: { - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereFooter) - if didMerge { - Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereRefreshed(result.totalMerged)) - } - } + private var alreadyHereSection: some View { + Section { + DisclosureGroup(isExpanded: $isAlreadyHereExpanded) { + ForEach(BackupModelKind.allCases, id: \.self) { kind in + let skipped = result.counts[kind]?.skipped ?? 0 + if skipped > 0 { + LabeledContent(kind.label, value: "\(skipped)") + } + } + } label: { + Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereSummary(result.totalSkipped)) + } + } header: { + Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereSection) + } footer: { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereFooter) + if didMerge { + Text(L10n.Settings.Settings.Backup.Import.Success.alreadyHereRefreshed(result.totalMerged)) } + } } + } - private var droppedSection: some View { - Section { - DisclosureGroup(isExpanded: $isDroppedExpanded) { - ForEach(BackupModelKind.allCases, id: \.self) { kind in - let dropped = result.counts[kind]?.dropped ?? 0 - if dropped > 0 { - LabeledContent(kind.label, value: "\(dropped)") - } - } - } label: { - Text(L10n.Settings.Settings.Backup.Import.Success.droppedSummary(result.totalDropped)) - } - } header: { - Text(L10n.Settings.Settings.Backup.Import.Success.droppedSection) - } footer: { - Text(L10n.Settings.Settings.Backup.Import.Success.droppedFooter) + private var droppedSection: some View { + Section { + DisclosureGroup(isExpanded: $isDroppedExpanded) { + ForEach(BackupModelKind.allCases, id: \.self) { kind in + let dropped = result.counts[kind]?.dropped ?? 0 + if dropped > 0 { + LabeledContent(kind.label, value: "\(dropped)") + } } + } label: { + Text(L10n.Settings.Settings.Backup.Import.Success.droppedSummary(result.totalDropped)) + } + } header: { + Text(L10n.Settings.Settings.Backup.Import.Success.droppedSection) + } footer: { + Text(L10n.Settings.Settings.Backup.Import.Success.droppedFooter) } + } - @ViewBuilder - private var doneSection: some View { - Section { - Group { - if result.hasRestoredChanges { - doneButton.liquidGlassProminentButtonStyle() - } else { - doneButton.liquidGlassButtonStyle() - } - } - .controlSize(.large) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - .listRowBackground(Color.clear) + private var doneSection: some View { + Section { + Group { + if result.hasRestoredChanges { + doneButton.liquidGlassProminentButtonStyle() + } else { + doneButton.liquidGlassButtonStyle() } + } + .controlSize(.large) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) } + } - private var doneButton: some View { - Button { - viewModel.dismissImportSheet() - } label: { - Text(L10n.Settings.Settings.Backup.Import.Success.done) - .frame(maxWidth: .infinity) - } + private var doneButton: some View { + Button { + viewModel.dismissImportSheet() + } label: { + Text(L10n.Settings.Settings.Backup.Import.Success.done) + .frame(maxWidth: .infinity) } + } } diff --git a/MC1/Views/Settings/LocationSettingsView.swift b/MC1/Views/Settings/LocationSettingsView.swift index 2e08748d..8e61ed88 100644 --- a/MC1/Views/Settings/LocationSettingsView.swift +++ b/MC1/Views/Settings/LocationSettingsView.swift @@ -2,19 +2,19 @@ import SwiftUI /// Sub-page wrapping LocationSettingsSection for the settings navigation struct LocationSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var showingLocationPicker = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var showingLocationPicker = false - var body: some View { - List { - LocationSettingsSection(showingLocationPicker: $showingLocationPicker) - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Location.header) - .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showingLocationPicker) { - LocationPickerView.forLocalDevice(appState: appState) - } + var body: some View { + List { + LocationSettingsSection(showingLocationPicker: $showingLocationPicker) } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Location.header) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingLocationPicker) { + LocationPickerView.forLocalDevice(appState: appState) + } + } } diff --git a/MC1/Views/Settings/NodeConfig/NodeConfigExportView.swift b/MC1/Views/Settings/NodeConfig/NodeConfigExportView.swift index d1ff46b0..271c6a2e 100644 --- a/MC1/Views/Settings/NodeConfig/NodeConfigExportView.swift +++ b/MC1/Views/Settings/NodeConfig/NodeConfigExportView.swift @@ -1,127 +1,127 @@ -import SwiftUI import MC1Services +import SwiftUI struct NodeConfigExportView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var viewModel = NodeConfigExportViewModel() - - var body: some View { - List { - Section { - Toggle( - L10n.Settings.ConfigExport.selectAll, - isOn: Binding( - get: { viewModel.sections.allSelected }, - set: { newValue in - if newValue { - viewModel.sections.selectAll() - } else { - viewModel.sections.deselectAll() - } - } - ) - ) - .bold() + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var viewModel = NodeConfigExportViewModel() - ExportToggleRow( - L10n.Settings.ConfigExport.nodeIdentity, - description: L10n.Settings.ConfigExport.NodeIdentity.description, - isOn: $viewModel.sections.nodeIdentity - ) - ExportToggleRow( - L10n.Settings.ConfigExport.radioSettings, - description: L10n.Settings.ConfigExport.RadioSettings.description, - isOn: $viewModel.sections.radioSettings - ) - ExportToggleRow( - L10n.Settings.ConfigExport.positionSettings, - description: L10n.Settings.ConfigExport.PositionSettings.description, - isOn: $viewModel.sections.positionSettings - ) - ExportToggleRow( - L10n.Settings.ConfigExport.otherSettings, - description: L10n.Settings.ConfigExport.OtherSettings.description, - isOn: $viewModel.sections.otherSettings - ) - ExportToggleRow( - L10n.Settings.ConfigExport.channels, - description: L10n.Settings.ConfigExport.Channels.description, - isOn: $viewModel.sections.channels - ) - ExportToggleRow( - L10n.Settings.ConfigExport.contacts, - description: L10n.Settings.ConfigExport.Contacts.description, - isOn: $viewModel.sections.contacts - ) + var body: some View { + List { + Section { + Toggle( + L10n.Settings.ConfigExport.selectAll, + isOn: Binding( + get: { viewModel.sections.allSelected }, + set: { newValue in + if newValue { + viewModel.sections.selectAll() + } else { + viewModel.sections.deselectAll() + } } - .themedRowBackground(theme) + ) + ) + .bold() - Section { - Button { - Task { - await viewModel.exportConfig( - nodeConfigService: appState.services?.nodeConfigService, - deviceNodeName: appState.connectedDevice?.nodeName - ) - } - } label: { - HStack { - Text(viewModel.sections.allSelected - ? L10n.Settings.ConfigExport.exportFull - : L10n.Settings.ConfigExport.exportSelected) - Spacer() - if viewModel.isExporting { - ProgressView() - } - } - } - .disabled(viewModel.isExporting || !viewModel.sections.anySectionSelected) - } - .themedRowBackground(theme) + ExportToggleRow( + L10n.Settings.ConfigExport.nodeIdentity, + description: L10n.Settings.ConfigExport.NodeIdentity.description, + isOn: $viewModel.sections.nodeIdentity + ) + ExportToggleRow( + L10n.Settings.ConfigExport.radioSettings, + description: L10n.Settings.ConfigExport.RadioSettings.description, + isOn: $viewModel.sections.radioSettings + ) + ExportToggleRow( + L10n.Settings.ConfigExport.positionSettings, + description: L10n.Settings.ConfigExport.PositionSettings.description, + isOn: $viewModel.sections.positionSettings + ) + ExportToggleRow( + L10n.Settings.ConfigExport.otherSettings, + description: L10n.Settings.ConfigExport.OtherSettings.description, + isOn: $viewModel.sections.otherSettings + ) + ExportToggleRow( + L10n.Settings.ConfigExport.channels, + description: L10n.Settings.ConfigExport.Channels.description, + isOn: $viewModel.sections.channels + ) + ExportToggleRow( + L10n.Settings.ConfigExport.contacts, + description: L10n.Settings.ConfigExport.Contacts.description, + isOn: $viewModel.sections.contacts + ) + } + .themedRowBackground(theme) - if let error = viewModel.errorMessage { - Section { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - } - .themedRowBackground(theme) + Section { + Button { + Task { + await viewModel.exportConfig( + nodeConfigService: appState.services?.nodeConfigService, + deviceNodeName: appState.connectedDevice?.nodeName + ) + } + } label: { + HStack { + Text(viewModel.sections.allSelected + ? L10n.Settings.ConfigExport.exportFull + : L10n.Settings.ConfigExport.exportSelected) + Spacer() + if viewModel.isExporting { + ProgressView() } + } } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.ConfigExport.title) - .fileExporter( - isPresented: $viewModel.showFileExporter, - document: viewModel.exportedDocument, - contentType: .json, - defaultFilename: viewModel.exportedDocument?.filename - ) { result in - if case .failure(let error) = result { - viewModel.errorMessage = error.userFacingMessage - } + .disabled(viewModel.isExporting || !viewModel.sections.anySectionSelected) + } + .themedRowBackground(theme) + + if let error = viewModel.errorMessage { + Section { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) } + .themedRowBackground(theme) + } + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.ConfigExport.title) + .fileExporter( + isPresented: $viewModel.showFileExporter, + document: viewModel.exportedDocument, + contentType: .json, + defaultFilename: viewModel.exportedDocument?.filename + ) { result in + if case let .failure(error) = result { + viewModel.errorMessage = error.userFacingMessage + } } + } } private struct ExportToggleRow: View { - let title: String - let description: String - @Binding var isOn: Bool + let title: String + let description: String + @Binding var isOn: Bool - init(_ title: String, description: String, isOn: Binding) { - self.title = title - self.description = description - self._isOn = isOn - } + init(_ title: String, description: String, isOn: Binding) { + self.title = title + self.description = description + _isOn = isOn + } - var body: some View { - Toggle(isOn: $isOn) { - VStack(alignment: .leading) { - Text(title) - Text(description) - .font(.footnote) - .foregroundStyle(.secondary) - } - } + var body: some View { + Toggle(isOn: $isOn) { + VStack(alignment: .leading) { + Text(title) + Text(description) + .font(.footnote) + .foregroundStyle(.secondary) + } } + } } diff --git a/MC1/Views/Settings/NodeConfig/NodeConfigExportViewModel.swift b/MC1/Views/Settings/NodeConfig/NodeConfigExportViewModel.swift index 9e0f80f9..a9d7095f 100644 --- a/MC1/Views/Settings/NodeConfig/NodeConfigExportViewModel.swift +++ b/MC1/Views/Settings/NodeConfig/NodeConfigExportViewModel.swift @@ -1,77 +1,79 @@ -import SwiftUI import MC1Services -import UniformTypeIdentifiers import OSLog +import SwiftUI +import UniformTypeIdentifiers @Observable @MainActor final class NodeConfigExportViewModel { - var sections = ConfigSections() - var isExporting = false - var exportedDocument: NodeConfigDocument? - var showFileExporter = false - var errorMessage: String? - - private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigExportVM") + var sections = ConfigSections() + var isExporting = false + var exportedDocument: NodeConfigDocument? + var showFileExporter = false + var errorMessage: String? - /// A nil service mirrors a disconnected state and is a no-op. - func exportConfig(nodeConfigService: NodeConfigService?, deviceNodeName: String?) async { - guard let service = nodeConfigService else { return } + private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigExportVM") - isExporting = true - errorMessage = nil + /// A nil service mirrors a disconnected state and is a no-op. + func exportConfig(nodeConfigService: NodeConfigService?, deviceNodeName: String?) async { + guard let service = nodeConfigService else { return } - do { - let config = try await service.exportConfig(sections: sections) + isExporting = true + errorMessage = nil - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(config) + do { + let config = try await service.exportConfig(sections: sections) - let nodeName = deviceNodeName ?? config.name ?? "unknown" - let sanitized = nodeName - .replacing(/[^a-zA-Z0-9_-]/, with: "_") - .replacing(/^_+|_+$/, with: "") + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) - let timestamp = Date.now.formatted( - .verbatim( - "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits)-\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased))\(minute: .twoDigits)\(second: .twoDigits)", - locale: Locale(identifier: "en_US_POSIX"), - timeZone: .current, - calendar: .init(identifier: .gregorian) - ) - ) - let filename = "\(sanitized)_meshcore_config_\(timestamp)" + let nodeName = deviceNodeName ?? config.name ?? "unknown" + let sanitized = nodeName + .replacing(/[^a-zA-Z0-9_-]/, with: "_") + .replacing(/^_+|_+$/, with: "") - exportedDocument = NodeConfigDocument(data: data, filename: filename) - showFileExporter = true - } catch { - logger.error("Export failed: \(error.localizedDescription)") - errorMessage = error.userFacingMessage - } + let timestamp = Date.now.formatted( + .verbatim( + "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits)-\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased))\(minute: .twoDigits)\(second: .twoDigits)", + locale: Locale(identifier: "en_US_POSIX"), + timeZone: .current, + calendar: .init(identifier: .gregorian) + ) + ) + let filename = "\(sanitized)_meshcore_config_\(timestamp)" - isExporting = false + exportedDocument = NodeConfigDocument(data: data, filename: filename) + showFileExporter = true + } catch { + logger.error("Export failed: \(error.localizedDescription)") + errorMessage = error.userFacingMessage } + + isExporting = false + } } /// Wraps exported JSON data for `.fileExporter` struct NodeConfigDocument: FileDocument { - static var readableContentTypes: [UTType] { [.json] } + static var readableContentTypes: [UTType] { + [.json] + } - let data: Data - let filename: String + let data: Data + let filename: String - init(data: Data, filename: String) { - self.data = data - self.filename = filename - } + init(data: Data, filename: String) { + self.data = data + self.filename = filename + } - init(configuration: ReadConfiguration) throws { - data = configuration.file.regularFileContents ?? Data() - filename = "config" - } + init(configuration: ReadConfiguration) throws { + data = configuration.file.regularFileContents ?? Data() + filename = "config" + } - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - FileWrapper(regularFileWithContents: data) - } + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } } diff --git a/MC1/Views/Settings/NodeConfig/NodeConfigImportView.swift b/MC1/Views/Settings/NodeConfig/NodeConfigImportView.swift index 31aaf6fd..483d7c9d 100644 --- a/MC1/Views/Settings/NodeConfig/NodeConfigImportView.swift +++ b/MC1/Views/Settings/NodeConfig/NodeConfigImportView.swift @@ -1,327 +1,329 @@ -import SwiftUI import MC1Services +import SwiftUI struct NodeConfigImportView: View { - @Environment(\.appState) private var appState - @State private var viewModel = NodeConfigImportViewModel() - - var body: some View { - Group { - if let config = viewModel.importedConfig { - ImportPreviewList(viewModel: viewModel, config: config) - } else { - SelectFileList(viewModel: viewModel) - } - } - .navigationTitle(L10n.Settings.ConfigImport.title) - .onDisappear { viewModel.handleDismissal() } - .fileImporter( - isPresented: $viewModel.showFilePicker, - allowedContentTypes: [.json] - ) { result in - switch result { - case .success(let url): - viewModel.parseFile(at: url) - Task { await viewModel.loadCurrentDeviceState(settingsService: appState.services?.settingsService) } - case .failure(let error): - viewModel.errorMessage = error.userFacingMessage - } - } - .alert( - viewModel.confirmTitle, - isPresented: $viewModel.showConfirmation - ) { - Button(viewModel.applyButtonLabel) { - viewModel.applyConfig( - nodeConfigService: appState.services?.nodeConfigService, - settingsService: appState.services?.settingsService, - radioID: appState.connectedDevice?.radioID - ) - } - Button(L10n.Localizable.Common.cancel, role: .cancel) {} - } message: { - Text(viewModel.confirmMessage(deviceName: appState.connectedDevice?.nodeName ?? L10n.Settings.ConfigImport.thisDevice)) - } + @Environment(\.appState) private var appState + @State private var viewModel = NodeConfigImportViewModel() + + var body: some View { + Group { + if let config = viewModel.importedConfig { + ImportPreviewList(viewModel: viewModel, config: config) + } else { + SelectFileList(viewModel: viewModel) + } + } + .navigationTitle(L10n.Settings.ConfigImport.title) + .onDisappear { viewModel.handleDismissal() } + .fileImporter( + isPresented: $viewModel.showFilePicker, + allowedContentTypes: [.json] + ) { result in + switch result { + case let .success(url): + viewModel.parseFile(at: url) + Task { await viewModel.loadCurrentDeviceState(settingsService: appState.services?.settingsService) } + case let .failure(error): + viewModel.errorMessage = error.userFacingMessage + } } + .alert( + viewModel.confirmTitle, + isPresented: $viewModel.showConfirmation + ) { + Button(viewModel.applyButtonLabel) { + viewModel.applyConfig( + nodeConfigService: appState.services?.nodeConfigService, + settingsService: appState.services?.settingsService, + radioID: appState.connectedDevice?.radioID + ) + } + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + } message: { + Text(viewModel.confirmMessage(deviceName: appState.connectedDevice?.nodeName ?? L10n.Settings.ConfigImport.thisDevice)) + } + } } // MARK: - Select File private struct SelectFileList: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - - var body: some View { - List { - Section { - Button { - viewModel.showFilePicker = true - } label: { - HStack { - Text(L10n.Settings.ConfigImport.selectFile) - if viewModel.isParsing { - Spacer() - ProgressView() - } - } - } - .disabled(viewModel.isParsing) - } - .themedRowBackground(theme) - - if let error = viewModel.errorMessage { - Section { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - } - .themedRowBackground(theme) + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + + var body: some View { + List { + Section { + Button { + viewModel.showFilePicker = true + } label: { + HStack { + Text(L10n.Settings.ConfigImport.selectFile) + if viewModel.isParsing { + Spacer() + ProgressView() } + } + } + .disabled(viewModel.isParsing) + } + .themedRowBackground(theme) + + if let error = viewModel.errorMessage { + Section { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) } - .themedCanvas(theme) + .themedRowBackground(theme) + } } + .themedCanvas(theme) + } } // MARK: - Import Preview private struct ImportPreviewList: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let config: MeshCoreNodeConfig - - var body: some View { - List { - if config.name != nil || config.privateKey != nil { - NodeIdentitySection(viewModel: viewModel, config: config) - } + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let config: MeshCoreNodeConfig - if let radio = config.radioSettings { - RadioSettingsSection(viewModel: viewModel, radio: radio, currentRadio: viewModel.currentRadio) - } + var body: some View { + List { + if config.name != nil || config.privateKey != nil { + NodeIdentitySection(viewModel: viewModel, config: config) + } - if let position = config.positionSettings { - PositionSection(viewModel: viewModel, position: position, currentPosition: viewModel.currentPosition) - } + if let radio = config.radioSettings { + RadioSettingsSection(viewModel: viewModel, radio: radio, currentRadio: viewModel.currentRadio) + } - if config.otherSettings != nil { - Section { - Toggle(L10n.Settings.ConfigExport.otherSettings, isOn: $viewModel.sections.otherSettings) - } - .themedRowBackground(theme) - } + if let position = config.positionSettings { + PositionSection(viewModel: viewModel, position: position, currentPosition: viewModel.currentPosition) + } - if let channels = config.channels { - ChannelsSection(viewModel: viewModel, channels: channels) - } + if config.otherSettings != nil { + Section { + Toggle(L10n.Settings.ConfigExport.otherSettings, isOn: $viewModel.sections.otherSettings) + } + .themedRowBackground(theme) + } - if let contacts = config.contacts { - ContactsSection(viewModel: viewModel, contacts: contacts) - } + if let channels = config.channels { + ChannelsSection(viewModel: viewModel, channels: channels) + } - Section { - Label(L10n.Settings.ConfigImport.proximityWarning, systemImage: "antenna.radiowaves.left.and.right") - .font(.footnote) - .foregroundStyle(.secondary) - } - .themedRowBackground(theme) + if let contacts = config.contacts { + ContactsSection(viewModel: viewModel, contacts: contacts) + } - ApplySection(viewModel: viewModel) + Section { + Label(L10n.Settings.ConfigImport.proximityWarning, systemImage: "antenna.radiowaves.left.and.right") + .font(.footnote) + .foregroundStyle(.secondary) + } + .themedRowBackground(theme) - if let error = viewModel.errorMessage { - Section { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } + ApplySection(viewModel: viewModel) - if viewModel.importComplete && !viewModel.isApplying { - Section { - Label(L10n.Settings.ConfigImport.importSuccess, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } - .themedRowBackground(theme) - } + if let error = viewModel.errorMessage { + Section { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) } - .themedCanvas(theme) - // Lock the section toggles while the preview round-trip is in flight, so the selection - // the confirmation copy was computed from matches the selection the apply uses. - .disabled(viewModel.isPreparingConfirmation) + .themedRowBackground(theme) + } + + if viewModel.importComplete, !viewModel.isApplying { + Section { + Label(L10n.Settings.ConfigImport.importSuccess, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + .themedRowBackground(theme) + } } + .themedCanvas(theme) + // Lock the section toggles while the preview round-trip is in flight, so the selection + // the confirmation copy was computed from matches the selection the apply uses. + .disabled(viewModel.isPreparingConfirmation) + } } // MARK: - Section Views private struct NodeIdentitySection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let config: MeshCoreNodeConfig + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let config: MeshCoreNodeConfig - var body: some View { - Section { - Toggle(isOn: $viewModel.sections.nodeIdentity) { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigExport.nodeIdentity) - if let newName = config.name { - DiffRow( - current: viewModel.currentName ?? "\u{2014}", - new: newName - ) - } - if config.privateKey != nil { - Label(L10n.Settings.ConfigImport.privateKeyWarning, systemImage: "exclamationmark.shield") - .font(.footnote) - .foregroundStyle(.orange) - } - } - } + var body: some View { + Section { + Toggle(isOn: $viewModel.sections.nodeIdentity) { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigExport.nodeIdentity) + if let newName = config.name { + DiffRow( + current: viewModel.currentName ?? "\u{2014}", + new: newName + ) + } + if config.privateKey != nil { + Label(L10n.Settings.ConfigImport.privateKeyWarning, systemImage: "exclamationmark.shield") + .font(.footnote) + .foregroundStyle(.orange) + } } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } private struct RadioSettingsSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let radio: MeshCoreNodeConfig.RadioSettings - let currentRadio: MeshCoreNodeConfig.RadioSettings? - - var body: some View { - Section { - Toggle(isOn: $viewModel.sections.radioSettings) { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigExport.radioSettings) - DiffRow( - current: currentRadio.map { RadioFormatter.format($0) } ?? "\u{2014}", - new: RadioFormatter.format(radio) - ) - if let current = currentRadio, current != radio { - Label(L10n.Settings.ConfigImport.radioWarning, systemImage: "exclamationmark.triangle") - .font(.footnote) - .foregroundStyle(.orange) - } - } - } + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let radio: MeshCoreNodeConfig.RadioSettings + let currentRadio: MeshCoreNodeConfig.RadioSettings? + + var body: some View { + Section { + Toggle(isOn: $viewModel.sections.radioSettings) { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigExport.radioSettings) + DiffRow( + current: currentRadio.map { RadioFormatter.format($0) } ?? "\u{2014}", + new: RadioFormatter.format(radio) + ) + if let current = currentRadio, current != radio { + Label(L10n.Settings.ConfigImport.radioWarning, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } private struct PositionSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let position: MeshCoreNodeConfig.PositionSettings - let currentPosition: MeshCoreNodeConfig.PositionSettings? - - var body: some View { - Section { - Toggle(isOn: $viewModel.sections.positionSettings) { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigExport.positionSettings) - DiffRow( - current: currentPosition.map { "\($0.latitude), \($0.longitude)" } ?? "\u{2014}", - new: "\(position.latitude), \(position.longitude)" - ) - } - } + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let position: MeshCoreNodeConfig.PositionSettings + let currentPosition: MeshCoreNodeConfig.PositionSettings? + + var body: some View { + Section { + Toggle(isOn: $viewModel.sections.positionSettings) { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigExport.positionSettings) + DiffRow( + current: currentPosition.map { "\($0.latitude), \($0.longitude)" } ?? "\u{2014}", + new: "\(position.latitude), \(position.longitude)" + ) } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } private struct ChannelsSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let channels: [MeshCoreNodeConfig.ChannelConfig] + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let channels: [MeshCoreNodeConfig.ChannelConfig] - var body: some View { - Section { - Toggle(isOn: $viewModel.sections.channels) { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigExport.channels) - Text(L10n.Settings.ConfigImport.channelCount(channels.count)) - .font(.footnote) - .foregroundStyle(.secondary) - } - } + var body: some View { + Section { + Toggle(isOn: $viewModel.sections.channels) { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigExport.channels) + Text(L10n.Settings.ConfigImport.channelCount(channels.count)) + .font(.footnote) + .foregroundStyle(.secondary) } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } private struct ContactsSection: View { - @Environment(\.appTheme) private var theme - @Bindable var viewModel: NodeConfigImportViewModel - let contacts: [MeshCoreNodeConfig.ContactConfig] + @Environment(\.appTheme) private var theme + @Bindable var viewModel: NodeConfigImportViewModel + let contacts: [MeshCoreNodeConfig.ContactConfig] - var body: some View { - Section { - Toggle(isOn: $viewModel.sections.contacts) { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigExport.contacts) - Text(L10n.Settings.ConfigImport.contactCount(contacts.count)) - .font(.footnote) - .foregroundStyle(.secondary) - } - } + var body: some View { + Section { + Toggle(isOn: $viewModel.sections.contacts) { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigExport.contacts) + Text(L10n.Settings.ConfigImport.contactCount(contacts.count)) + .font(.footnote) + .foregroundStyle(.secondary) } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } private struct ApplySection: View { - @Environment(\.appTheme) private var theme - @Environment(\.appState) private var appState - @Bindable var viewModel: NodeConfigImportViewModel - - var body: some View { - Section { - if viewModel.isApplying { - VStack { - ProgressView(value: viewModel.applyProgress) - Text(viewModel.applyStepDescription) - .font(.footnote) - .foregroundStyle(.secondary) - Button(L10n.Localizable.Common.cancel, role: .cancel) { - viewModel.cancelImport() - } - } - } else if !viewModel.importComplete { - Button(viewModel.applyButtonLabel) { - viewModel.prepareConfirmation(nodeConfigService: appState.services?.nodeConfigService) - } - .disabled(viewModel.isPreparingConfirmation) - } + @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Bindable var viewModel: NodeConfigImportViewModel + + var body: some View { + Section { + if viewModel.isApplying { + VStack { + ProgressView(value: viewModel.applyProgress) + Text(viewModel.applyStepDescription) + .font(.footnote) + .foregroundStyle(.secondary) + Button(L10n.Localizable.Common.cancel, role: .cancel) { + viewModel.cancelImport() + } } - .themedRowBackground(theme) + } else if !viewModel.importComplete { + Button(viewModel.applyButtonLabel) { + viewModel.prepareConfirmation(nodeConfigService: appState.services?.nodeConfigService) + } + .disabled(viewModel.isPreparingConfirmation) + } } + .themedRowBackground(theme) + } } // MARK: - Diff Row private struct DiffRow: View { - let current: String - let new: String - - private var hasChanged: Bool { current != new } - - var body: some View { - VStack(alignment: .leading) { - Text(L10n.Settings.ConfigImport.current(current)) - .font(.footnote) - .foregroundStyle(.secondary) - Text(L10n.Settings.ConfigImport.new(new)) - .font(.footnote) - .foregroundStyle(hasChanged ? .primary : .secondary) - } + let current: String + let new: String + + private var hasChanged: Bool { + current != new + } + + var body: some View { + VStack(alignment: .leading) { + Text(L10n.Settings.ConfigImport.current(current)) + .font(.footnote) + .foregroundStyle(.secondary) + Text(L10n.Settings.ConfigImport.new(new)) + .font(.footnote) + .foregroundStyle(hasChanged ? .primary : .secondary) } + } } // MARK: - Radio Formatter private enum RadioFormatter { - static func format(_ radio: MeshCoreNodeConfig.RadioSettings) -> String { - let freqMHz = (Double(radio.frequency) / 1000).formatted(.number.precision(.fractionLength(0...3)).locale(.posix)) - let bwKHz = (Double(radio.bandwidth) / 1000).formatted(.number.precision(.fractionLength(0...1)).locale(.posix)) - return "\(freqMHz) MHz, BW \(bwKHz) kHz, SF \(radio.spreadingFactor), CR \(radio.codingRate)" - } + static func format(_ radio: MeshCoreNodeConfig.RadioSettings) -> String { + let freqMHz = (Double(radio.frequency) / 1000).formatted(.number.precision(.fractionLength(0...3)).locale(.posix)) + let bwKHz = (Double(radio.bandwidth) / 1000).formatted(.number.precision(.fractionLength(0...1)).locale(.posix)) + return "\(freqMHz) MHz, BW \(bwKHz) kHz, SF \(radio.spreadingFactor), CR \(radio.codingRate)" + } } diff --git a/MC1/Views/Settings/NodeConfig/NodeConfigImportViewModel.swift b/MC1/Views/Settings/NodeConfig/NodeConfigImportViewModel.swift index e066d8a7..3e4c30b8 100644 --- a/MC1/Views/Settings/NodeConfig/NodeConfigImportViewModel.swift +++ b/MC1/Views/Settings/NodeConfig/NodeConfigImportViewModel.swift @@ -1,318 +1,318 @@ -import SwiftUI import MC1Services import OSLog +import SwiftUI @Observable @MainActor final class NodeConfigImportViewModel { - // Parse state - var importedConfig: MeshCoreNodeConfig? - var errorMessage: String? - var showFilePicker = false - var isParsing = false - - // Section selection - var sections = ConfigSections() - - // Current device state for diff - var currentName: String? - var currentRadio: MeshCoreNodeConfig.RadioSettings? - var currentPosition: MeshCoreNodeConfig.PositionSettings? - - // Apply state - var isApplying = false - var applyProgress: Double = 0 - var applyStepDescription = "" - var importComplete = false - var showConfirmation = false - var isPreparingConfirmation = false - - /// Set from a non-destructive ``NodeConfigService/previewImport(_:sections:)`` pass before the - /// confirmation alert. True when at least one selected channel would replace an already-configured - /// slot, so the channels section is not purely additive and the confirmation copy must say so. - private var channelsWouldOverwrite = false - - /// True once the import has reported progress, i.e. at least one destructive write reached the - /// device. Distinguishes "cancelled before anything changed" from "cancelled mid-write." - private var didApplyAnyWrite = false - - private var importTask: Task? - - /// Handle for the in-flight file parse, plus the ID guard that lets a newer parse - /// (or a dismissal) invalidate a stale result that raced past cancellation. - private var parseTask: Task? - private var currentParseID: UUID? - - /// Handle for the in-flight preview, which does a real BLE round-trip via `previewImport`. - /// Stored so it can be cancelled if the user leaves the screen or supersedes it. - private var previewTask: Task? - - private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigImportVM") - - // MARK: - Dynamic confirmation text - - private var hasOverwriteSections: Bool { - sections.radioSettings || sections.nodeIdentity || sections.positionSettings - || sections.otherSettings || (sections.channels && channelsWouldOverwrite) + // Parse state + var importedConfig: MeshCoreNodeConfig? + var errorMessage: String? + var showFilePicker = false + var isParsing = false + + /// Section selection + var sections = ConfigSections() + + // Current device state for diff + var currentName: String? + var currentRadio: MeshCoreNodeConfig.RadioSettings? + var currentPosition: MeshCoreNodeConfig.PositionSettings? + + // Apply state + var isApplying = false + var applyProgress: Double = 0 + var applyStepDescription = "" + var importComplete = false + var showConfirmation = false + var isPreparingConfirmation = false + + /// Set from a non-destructive ``NodeConfigService/previewImport(_:sections:)`` pass before the + /// confirmation alert. True when at least one selected channel would replace an already-configured + /// slot, so the channels section is not purely additive and the confirmation copy must say so. + private var channelsWouldOverwrite = false + + /// True once the import has reported progress, i.e. at least one destructive write reached the + /// device. Distinguishes "cancelled before anything changed" from "cancelled mid-write." + private var didApplyAnyWrite = false + + private var importTask: Task? + + /// Handle for the in-flight file parse, plus the ID guard that lets a newer parse + /// (or a dismissal) invalidate a stale result that raced past cancellation. + private var parseTask: Task? + private var currentParseID: UUID? + + /// Handle for the in-flight preview, which does a real BLE round-trip via `previewImport`. + /// Stored so it can be cancelled if the user leaves the screen or supersedes it. + private var previewTask: Task? + + private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigImportVM") + + // MARK: - Dynamic confirmation text + + private var hasOverwriteSections: Bool { + sections.radioSettings || sections.nodeIdentity || sections.positionSettings + || sections.otherSettings || (sections.channels && channelsWouldOverwrite) + } + + private var hasAdditiveSections: Bool { + sections.channels || sections.contacts + } + + var confirmTitle: String { + switch (hasOverwriteSections, hasAdditiveSections) { + case (false, true): L10n.Settings.ConfigImport.confirmTitleAdd + case (true, false): L10n.Settings.ConfigImport.confirmTitleOverwrite + default: L10n.Settings.ConfigImport.confirmTitle } + } - private var hasAdditiveSections: Bool { - sections.channels || sections.contacts + var applyButtonLabel: String { + switch (hasOverwriteSections, hasAdditiveSections) { + case (false, true): L10n.Settings.ConfigImport.applyButtonAdd + case (true, false): L10n.Settings.ConfigImport.applyButtonOverwrite + default: L10n.Settings.ConfigImport.applyButton } - - var confirmTitle: String { - switch (hasOverwriteSections, hasAdditiveSections) { - case (false, true): L10n.Settings.ConfigImport.confirmTitleAdd - case (true, false): L10n.Settings.ConfigImport.confirmTitleOverwrite - default: L10n.Settings.ConfigImport.confirmTitle - } + } + + func confirmMessage(deviceName: String) -> String { + switch (hasOverwriteSections, hasAdditiveSections) { + case (false, true): L10n.Settings.ConfigImport.confirmMessageAdd(deviceName) + case (true, false): L10n.Settings.ConfigImport.confirmMessageOverwrite(deviceName) + case (true, true): L10n.Settings.ConfigImport.confirmMessageMixed(deviceName) + default: L10n.Settings.ConfigImport.confirmMessage(deviceName) } - - var applyButtonLabel: String { - switch (hasOverwriteSections, hasAdditiveSections) { - case (false, true): L10n.Settings.ConfigImport.applyButtonAdd - case (true, false): L10n.Settings.ConfigImport.applyButtonOverwrite - default: L10n.Settings.ConfigImport.applyButton + } + + /// Parse a JSON file from a security-scoped URL. The read and decode run detached + /// because the URL can point at an undownloaded iCloud Drive file, where a + /// synchronous read on the main actor blocks the UI for the whole download. + func parseFile(at url: URL) { + parseTask?.cancel() + isParsing = true + errorMessage = nil + let taskID = UUID() + currentParseID = taskID + let didAccessSecurityScope = url.startAccessingSecurityScopedResource() + + parseTask = Task.detached(priority: .userInitiated) { [weak self, url, didAccessSecurityScope] in + defer { + if didAccessSecurityScope { + url.stopAccessingSecurityScopedResource() } + } + do { + // .mappedIfSafe lets the OS page-cache the read rather than + // copying the whole file into the heap. + let data = try Data(contentsOf: url, options: .mappedIfSafe) + try Task.checkCancellation() + let config = try JSONDecoder().decode(MeshCoreNodeConfig.self, from: data) + try Task.checkCancellation() + await self?.applyParseSuccess(config, for: taskID) + } catch is CancellationError { + // The new invocation or the dismissal owns the parse state; don't clobber it. + return + } catch { + await self?.applyParseFailure(error, for: taskID) + } } - - func confirmMessage(deviceName: String) -> String { - switch (hasOverwriteSections, hasAdditiveSections) { - case (false, true): L10n.Settings.ConfigImport.confirmMessageAdd(deviceName) - case (true, false): L10n.Settings.ConfigImport.confirmMessageOverwrite(deviceName) - case (true, true): L10n.Settings.ConfigImport.confirmMessageMixed(deviceName) - default: L10n.Settings.ConfigImport.confirmMessage(deviceName) - } + } + + private func applyParseSuccess(_ config: MeshCoreNodeConfig, for taskID: UUID) { + guard currentParseID == taskID else { return } + isParsing = false + importedConfig = config + errorMessage = nil + + // Auto-select only sections present in the file + sections.nodeIdentity = config.name != nil || config.publicKey != nil || config.privateKey != nil + sections.radioSettings = config.radioSettings != nil + sections.positionSettings = config.positionSettings != nil && !(config.positionSettings?.isZero ?? true) + sections.otherSettings = config.otherSettings != nil + sections.channels = config.channels != nil + sections.contacts = config.contacts != nil + } + + private func applyParseFailure(_ error: Error, for taskID: UUID) { + guard currentParseID == taskID else { return } + isParsing = false + errorMessage = error.userFacingMessage + logger.error("Failed to parse config: \(error.localizedDescription)") + } + + /// Load current device values for diff display. A nil service mirrors a disconnected state. + func loadCurrentDeviceState(settingsService: SettingsService?) async { + guard let settingsService else { return } + do { + let selfInfo = try await settingsService.getSelfInfo() + currentName = selfInfo.name + currentRadio = NodeConfigService.buildRadioSettings(from: selfInfo) + currentPosition = MeshCoreNodeConfig.PositionSettings( + latitude: String(selfInfo.latitude), + longitude: String(selfInfo.longitude) + ) + } catch { + logger.error("Failed to load device state: \(error.localizedDescription)") } - - /// Parse a JSON file from a security-scoped URL. The read and decode run detached - /// because the URL can point at an undownloaded iCloud Drive file, where a - /// synchronous read on the main actor blocks the UI for the whole download. - func parseFile(at url: URL) { - parseTask?.cancel() - isParsing = true + } + + /// Runs the non-destructive planner to classify the import (overwrite vs additive) and reject a + /// malformed config up front, then presents the confirmation alert. Surfacing a planner error here + /// means a poison file is caught before the user even confirms, and before any write. + func prepareConfirmation(nodeConfigService: NodeConfigService?) { + guard !isPreparingConfirmation, !isApplying else { return } + guard let config = importedConfig, + let service = nodeConfigService else { return } + + errorMessage = nil + isPreparingConfirmation = true + previewTask = Task { + defer { isPreparingConfirmation = false } + do { + let preview = try await service.previewImport(config, sections: sections) + // If the user left the screen while the round-trip was in flight, the dismissal has + // already reset the UI; presenting the confirmation now would pop an alert over a + // stale, off-screen state, so honour the cancellation even when the BLE call won the + // race and returned before observing it. + guard !Task.isCancelled else { return } + channelsWouldOverwrite = preview.channelsOverwriteExisting errorMessage = nil - let taskID = UUID() - currentParseID = taskID - let didAccessSecurityScope = url.startAccessingSecurityScopedResource() - - parseTask = Task.detached(priority: .userInitiated) { [weak self, url, didAccessSecurityScope] in - defer { - if didAccessSecurityScope { - url.stopAccessingSecurityScopedResource() - } - } - do { - // .mappedIfSafe lets the OS page-cache the read rather than - // copying the whole file into the heap. - let data = try Data(contentsOf: url, options: .mappedIfSafe) - try Task.checkCancellation() - let config = try JSONDecoder().decode(MeshCoreNodeConfig.self, from: data) - try Task.checkCancellation() - await self?.applyParseSuccess(config, for: taskID) - } catch is CancellationError { - // The new invocation or the dismissal owns the parse state; don't clobber it. - return - } catch { - await self?.applyParseFailure(error, for: taskID) - } - } - } - - private func applyParseSuccess(_ config: MeshCoreNodeConfig, for taskID: UUID) { - guard currentParseID == taskID else { return } - isParsing = false - importedConfig = config - errorMessage = nil - - // Auto-select only sections present in the file - sections.nodeIdentity = config.name != nil || config.publicKey != nil || config.privateKey != nil - sections.radioSettings = config.radioSettings != nil - sections.positionSettings = config.positionSettings != nil && !(config.positionSettings?.isZero ?? true) - sections.otherSettings = config.otherSettings != nil - sections.channels = config.channels != nil - sections.contacts = config.contacts != nil - } - - private func applyParseFailure(_ error: Error, for taskID: UUID) { - guard currentParseID == taskID else { return } - isParsing = false + showConfirmation = true + } catch is CancellationError { + // The user left the screen mid-preview — leave the UI untouched. + } catch { errorMessage = error.userFacingMessage - logger.error("Failed to parse config: \(error.localizedDescription)") + logger.error("Import preview failed: \(error.localizedDescription)") + } } - - /// Load current device values for diff display. A nil service mirrors a disconnected state. - func loadCurrentDeviceState(settingsService: SettingsService?) async { - guard let settingsService else { return } - do { - let selfInfo = try await settingsService.getSelfInfo() - currentName = selfInfo.name - currentRadio = NodeConfigService.buildRadioSettings(from: selfInfo) - currentPosition = MeshCoreNodeConfig.PositionSettings( - latitude: String(selfInfo.latitude), - longitude: String(selfInfo.longitude) - ) - } catch { - logger.error("Failed to load device state: \(error.localizedDescription)") + } + + /// Apply the imported config to the device. Nil parameters mirror a disconnected state. + func applyConfig(nodeConfigService: NodeConfigService?, settingsService: SettingsService?, radioID: UUID?) { + guard !isApplying else { return } + guard let config = importedConfig, + let service = nodeConfigService, + let radioID else { return } + + isApplying = true + applyProgress = 0 + errorMessage = nil + importComplete = false + didApplyAnyWrite = false + + importTask = Task { + // Deliver progress through one stream consumed in order on the main actor, so updates + // can't race or arrive out of sequence the way a per-update `Task { @MainActor }` would. + let (progressStream, progressContinuation) = AsyncStream.makeStream(of: ImportProgress.self) + let consumer = Task { @MainActor in + for await progress in progressStream { + didApplyAnyWrite = true + applyProgress = Double(progress.current) / Double(max(1, progress.total)) + applyStepDescription = Self.description(for: progress.step) } - } - - /// Runs the non-destructive planner to classify the import (overwrite vs additive) and reject a - /// malformed config up front, then presents the confirmation alert. Surfacing a planner error here - /// means a poison file is caught before the user even confirms, and before any write. - func prepareConfirmation(nodeConfigService: NodeConfigService?) { - guard !isPreparingConfirmation, !isApplying else { return } - guard let config = importedConfig, - let service = nodeConfigService else { return } - - errorMessage = nil - isPreparingConfirmation = true - previewTask = Task { - defer { isPreparingConfirmation = false } - do { - let preview = try await service.previewImport(config, sections: sections) - // If the user left the screen while the round-trip was in flight, the dismissal has - // already reset the UI; presenting the confirmation now would pop an alert over a - // stale, off-screen state, so honour the cancellation even when the BLE call won the - // race and returned before observing it. - guard !Task.isCancelled else { return } - channelsWouldOverwrite = preview.channelsOverwriteExisting - errorMessage = nil - showConfirmation = true - } catch is CancellationError { - // The user left the screen mid-preview — leave the UI untouched. - } catch { - errorMessage = error.userFacingMessage - logger.error("Import preview failed: \(error.localizedDescription)") - } + } + + do { + try await service.importConfig( + config, + sections: sections, + radioID: radioID + ) { progress in + progressContinuation.yield(progress) } - } - - /// Apply the imported config to the device. Nil parameters mirror a disconnected state. - func applyConfig(nodeConfigService: NodeConfigService?, settingsService: SettingsService?, radioID: UUID?) { - guard !isApplying else { return } - guard let config = importedConfig, - let service = nodeConfigService, - let radioID else { return } - - isApplying = true - applyProgress = 0 - errorMessage = nil - importComplete = false - didApplyAnyWrite = false - - importTask = Task { - // Deliver progress through one stream consumed in order on the main actor, so updates - // can't race or arrive out of sequence the way a per-update `Task { @MainActor }` would. - let (progressStream, progressContinuation) = AsyncStream.makeStream(of: ImportProgress.self) - let consumer = Task { @MainActor in - for await progress in progressStream { - didApplyAnyWrite = true - applyProgress = Double(progress.current) / Double(max(1, progress.total)) - applyStepDescription = Self.description(for: progress.step) - } - } - - do { - try await service.importConfig( - config, - sections: sections, - radioID: radioID - ) { progress in - progressContinuation.yield(progress) - } - progressContinuation.finish() - await consumer.value - // Refresh cached device state so Settings UI reflects imported values - if let settingsService { - try? await settingsService.refreshDeviceInfo() - } - isApplying = false - importComplete = true - try? await Task.sleep(for: .seconds(1.5)) - guard !Task.isCancelled else { return } - resetToFileSelection() - } catch is CancellationError { - progressContinuation.finish() - await consumer.value - isApplying = false - // "No progress reported" means no destructive write landed, so the device is untouched. - errorMessage = Self.cancellationMessage(didApplyAnyWrite: didApplyAnyWrite) - } catch { - progressContinuation.finish() - await consumer.value - isApplying = false - errorMessage = Self.failureMessage(for: error, didApplyAnyWrite: didApplyAnyWrite) - logger.error("Import failed: \(error.localizedDescription)") - } + progressContinuation.finish() + await consumer.value + // Refresh cached device state so Settings UI reflects imported values + if let settingsService { + try? await settingsService.refreshDeviceInfo() } - } - - func cancelImport() { - importTask?.cancel() - } - - /// Called when the screen goes away. Cancels any pending preview round-trip and, unless a - /// destructive import is in flight, resets back to the file picker so re-entering the screen - /// starts fresh instead of re-showing a stale preview or error. An in-flight import is left - /// running with its progress state intact: it is a destructive, safety-critical operation with - /// its own explicit cancel control, so a transient view disappearance must neither abort it - /// mid-write nor hide its result from a user who returns. - func handleDismissal() { - parseTask?.cancel() - currentParseID = nil - previewTask?.cancel() - guard !isApplying else { return } + isApplying = false + importComplete = true + try? await Task.sleep(for: .seconds(1.5)) + guard !Task.isCancelled else { return } resetToFileSelection() - } - - /// Maps a service-layer ``ImportStep`` to a localized progress description, keeping `L10n` - /// in the app layer so the service stays localization-free. - private static func description(for step: ImportStep) -> String { - switch step { - case .position: L10n.Settings.ConfigImport.stepPosition - case .otherParameters: L10n.Settings.ConfigImport.stepOtherParameters - case .privateKey: L10n.Settings.ConfigImport.stepPrivateKey - case .nodeName: L10n.Settings.ConfigImport.stepNodeName - case .radioParameters: L10n.Settings.ConfigImport.stepRadioParameters - case .txPower: L10n.Settings.ConfigImport.stepTxPower - case .channel(let name): L10n.Settings.ConfigImport.stepChannel(name) - case .contact(let name): L10n.Settings.ConfigImport.stepContact(name) - } - } - - /// Localized message for a cancelled import; distinguishes a clean cancel from one where a - /// destructive write already landed on the device. - static func cancellationMessage(didApplyAnyWrite: Bool) -> String { - didApplyAnyWrite - ? L10n.Settings.ConfigImport.cancelledPartial - : L10n.Settings.ConfigImport.cancelled - } - - /// Localized message for a failed import; distinguishes a clean failure from one where a - /// destructive write already landed. - static func failureMessage(for error: Error, didApplyAnyWrite: Bool) -> String { - didApplyAnyWrite - ? L10n.Settings.ConfigImport.failedPartial(error.userFacingMessage) - : error.userFacingMessage - } - - /// Reset to the initial file-selection state so the user can import another file. - private func resetToFileSelection() { - importedConfig = nil - errorMessage = nil - isParsing = false - sections = ConfigSections() - currentName = nil - currentRadio = nil - currentPosition = nil - applyProgress = 0 - applyStepDescription = "" - importComplete = false + } catch is CancellationError { + progressContinuation.finish() + await consumer.value isApplying = false - isPreparingConfirmation = false - showConfirmation = false - channelsWouldOverwrite = false - didApplyAnyWrite = false + // "No progress reported" means no destructive write landed, so the device is untouched. + errorMessage = Self.cancellationMessage(didApplyAnyWrite: didApplyAnyWrite) + } catch { + progressContinuation.finish() + await consumer.value + isApplying = false + errorMessage = Self.failureMessage(for: error, didApplyAnyWrite: didApplyAnyWrite) + logger.error("Import failed: \(error.localizedDescription)") + } + } + } + + func cancelImport() { + importTask?.cancel() + } + + /// Called when the screen goes away. Cancels any pending preview round-trip and, unless a + /// destructive import is in flight, resets back to the file picker so re-entering the screen + /// starts fresh instead of re-showing a stale preview or error. An in-flight import is left + /// running with its progress state intact: it is a destructive, safety-critical operation with + /// its own explicit cancel control, so a transient view disappearance must neither abort it + /// mid-write nor hide its result from a user who returns. + func handleDismissal() { + parseTask?.cancel() + currentParseID = nil + previewTask?.cancel() + guard !isApplying else { return } + resetToFileSelection() + } + + /// Maps a service-layer ``ImportStep`` to a localized progress description, keeping `L10n` + /// in the app layer so the service stays localization-free. + private static func description(for step: ImportStep) -> String { + switch step { + case .position: L10n.Settings.ConfigImport.stepPosition + case .otherParameters: L10n.Settings.ConfigImport.stepOtherParameters + case .privateKey: L10n.Settings.ConfigImport.stepPrivateKey + case .nodeName: L10n.Settings.ConfigImport.stepNodeName + case .radioParameters: L10n.Settings.ConfigImport.stepRadioParameters + case .txPower: L10n.Settings.ConfigImport.stepTxPower + case let .channel(name): L10n.Settings.ConfigImport.stepChannel(name) + case let .contact(name): L10n.Settings.ConfigImport.stepContact(name) } + } + + /// Localized message for a cancelled import; distinguishes a clean cancel from one where a + /// destructive write already landed on the device. + static func cancellationMessage(didApplyAnyWrite: Bool) -> String { + didApplyAnyWrite + ? L10n.Settings.ConfigImport.cancelledPartial + : L10n.Settings.ConfigImport.cancelled + } + + /// Localized message for a failed import; distinguishes a clean failure from one where a + /// destructive write already landed. + static func failureMessage(for error: Error, didApplyAnyWrite: Bool) -> String { + didApplyAnyWrite + ? L10n.Settings.ConfigImport.failedPartial(error.userFacingMessage) + : error.userFacingMessage + } + + /// Reset to the initial file-selection state so the user can import another file. + private func resetToFileSelection() { + importedConfig = nil + errorMessage = nil + isParsing = false + sections = ConfigSections() + currentName = nil + currentRadio = nil + currentPosition = nil + applyProgress = 0 + applyStepDescription = "" + importComplete = false + isApplying = false + isPreparingConfirmation = false + showConfirmation = false + channelsWouldOverwrite = false + didApplyAnyWrite = false + } } diff --git a/MC1/Views/Settings/NotificationSettingsView.swift b/MC1/Views/Settings/NotificationSettingsView.swift index 61f590f3..9221cd0f 100644 --- a/MC1/Views/Settings/NotificationSettingsView.swift +++ b/MC1/Views/Settings/NotificationSettingsView.swift @@ -1,14 +1,14 @@ import SwiftUI struct NotificationSettingsView: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - List { - NotificationSettingsSection() - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Notifications.header) - .navigationBarTitleDisplayMode(.inline) + var body: some View { + List { + NotificationSettingsSection() } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Notifications.header) + .navigationBarTitleDisplayMode(.inline) + } } diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift index 61fe1c99..c29ad94d 100644 --- a/MC1/Views/Settings/OfflineMapSettingsView.swift +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -3,452 +3,450 @@ import MapLibre import SwiftUI struct OfflineMapSettingsView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var showingRegionPicker = false - @State private var errorMessage: String? - - var body: some View { - Group { - if appState.offlineMapService.packs.isEmpty { - ContentUnavailableView { - Label(L10n.Settings.OfflineMaps.emptyTitle, systemImage: "map") - } description: { - Text(L10n.Settings.OfflineMaps.emptyDescription) - } actions: { - Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "arrow.down.circle") { - showingRegionPicker = true - } - .buttonStyle(.bordered) - } - } else { - List { - PacksSection() - StorageSection() - } - .themedCanvas(theme) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "plus") { - showingRegionPicker = true - } - } - } - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var showingRegionPicker = false + @State private var errorMessage: String? + + var body: some View { + Group { + if appState.offlineMapService.packs.isEmpty { + ContentUnavailableView { + Label(L10n.Settings.OfflineMaps.emptyTitle, systemImage: "map") + } description: { + Text(L10n.Settings.OfflineMaps.emptyDescription) + } actions: { + Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "arrow.down.circle") { + showingRegionPicker = true + } + .buttonStyle(.bordered) } - .navigationTitle(L10n.Settings.OfflineMaps.title) - .sheet(isPresented: $showingRegionPicker) { - RegionPickerSheet() + } else { + List { + PacksSection() + StorageSection() } - .onChange(of: appState.offlineMapService.lastPackError) { _, newValue in - if let newValue { - errorMessage = newValue - appState.offlineMapService.clearLastPackError() + .themedCanvas(theme) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "plus") { + showingRegionPicker = true } + } } - .errorAlert($errorMessage) + } } - + .navigationTitle(L10n.Settings.OfflineMaps.title) + .sheet(isPresented: $showingRegionPicker) { + RegionPickerSheet() + } + .onChange(of: appState.offlineMapService.lastPackError) { _, newValue in + if let newValue { + errorMessage = newValue + appState.offlineMapService.clearLastPackError() + } + } + .errorAlert($errorMessage) + } } // MARK: - Packs Section private struct PacksSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - var body: some View { - Section { - ForEach(appState.offlineMapService.packs) { pack in - OfflinePackRow(pack: pack) - } - .onDelete { indexSet in - if let index = indexSet.first { - let pack = appState.offlineMapService.packs[index] - Task { await appState.offlineMapService.deletePack(pack) } - } - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + var body: some View { + Section { + ForEach(appState.offlineMapService.packs) { pack in + OfflinePackRow(pack: pack) + } + .onDelete { indexSet in + if let index = indexSet.first { + let pack = appState.offlineMapService.packs[index] + Task { await appState.offlineMapService.deletePack(pack) } } - .themedRowBackground(theme) + } } + .themedRowBackground(theme) + } } // MARK: - Storage Section private struct StorageSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - var body: some View { - Section { - LabeledContent(L10n.Settings.OfflineMaps.storageUsed) { - Text(appState.offlineMapService.databaseSize, format: .byteCount(style: .file)) - } - } header: { - Text(L10n.Settings.OfflineMaps.storage) - } footer: { - Text(L10n.Settings.OfflineMaps.storageFooter) - } - .themedRowBackground(theme) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + var body: some View { + Section { + LabeledContent(L10n.Settings.OfflineMaps.storageUsed) { + Text(appState.offlineMapService.databaseSize, format: .byteCount(style: .file)) + } + } header: { + Text(L10n.Settings.OfflineMaps.storage) + } footer: { + Text(L10n.Settings.OfflineMaps.storageFooter) } + .themedRowBackground(theme) + } } // MARK: - Offline Pack Row private struct OfflinePackRow: View { - @Environment(\.appState) private var appState - let pack: OfflinePack - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(pack.name) - Text("— \(pack.layer.label)") - .foregroundStyle(.secondary) - } + @Environment(\.appState) private var appState + let pack: OfflinePack + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(pack.name) + Text("— \(pack.layer.label)") + .foregroundStyle(.secondary) + } + + HStack { + if pack.isComplete { + Text(L10n.Settings.OfflineMaps.complete) + .foregroundStyle(.secondary) + } else if pack.isPaused { + Text(L10n.Settings.OfflineMaps.paused) + .foregroundStyle(.secondary) + } else { + Text(L10n.Settings.OfflineMaps.downloading) + .foregroundStyle(.secondary) + } - HStack { - if pack.isComplete { - Text(L10n.Settings.OfflineMaps.complete) - .foregroundStyle(.secondary) - } else if pack.isPaused { - Text(L10n.Settings.OfflineMaps.paused) - .foregroundStyle(.secondary) - } else { - Text(L10n.Settings.OfflineMaps.downloading) - .foregroundStyle(.secondary) - } - - Spacer() - - VStack(alignment: .trailing) { - Text(Int64(pack.completedBytes), format: .byteCount(style: .file)) - if let speed = pack.downloadSpeed, speed > 0 { - Text("\(speed, format: .byteCount(style: .file))/s") - } - } - .foregroundStyle(.secondary) - } - .font(.caption) + Spacer() - if !pack.isComplete { - HStack { - ProgressView(value: pack.completedFraction) - - Button( - pack.isPaused - ? L10n.Settings.OfflineMaps.resume - : L10n.Settings.OfflineMaps.pause, - systemImage: pack.isPaused ? "play.fill" : "pause.fill" - ) { - if pack.isPaused { - appState.offlineMapService.resumePack(pack) - } else { - appState.offlineMapService.pausePack(pack) - } - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - } + VStack(alignment: .trailing) { + Text(Int64(pack.completedBytes), format: .byteCount(style: .file)) + if let speed = pack.downloadSpeed, speed > 0 { + Text("\(speed, format: .byteCount(style: .file))/s") + } + } + .foregroundStyle(.secondary) + } + .font(.caption) + + if !pack.isComplete { + HStack { + ProgressView(value: pack.completedFraction) + + Button( + pack.isPaused + ? L10n.Settings.OfflineMaps.resume + : L10n.Settings.OfflineMaps.pause, + systemImage: pack.isPaused ? "play.fill" : "pause.fill" + ) { + if pack.isPaused { + appState.offlineMapService.resumePack(pack) + } else { + appState.offlineMapService.pausePack(pack) } + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) } + } } + } } // MARK: - Region Picker Sheet private struct RegionPickerSheet: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme - - @State private var regionName = "" - @State private var cameraRegion: MKCoordinateRegion? - @State private var isDownloading = false - @State private var errorMessage: String? - @State private var mapSize: CGSize = .zero - @State private var includeTopo = false - @State private var isStyleLoaded = false - @State private var debouncedRegion: MKCoordinateRegion? - @State private var debounceTask: Task? - @State private var availableBytes: Int64? - - private static let selectionPadding: CGFloat = 40 - - var body: some View { - NavigationStack { - ZStack { - MC1MapView( - points: [], - lines: [], - mapStyle: .standard, - isDarkMode: colorScheme == .dark, - showLabels: false, - showsUserLocation: true, - isInteractive: true, - showsScale: false, - isNorthLocked: true, - cameraRegion: $cameraRegion, - cameraRegionVersion: 0, - onPointTap: nil, - onMapTap: nil, - onCameraRegionChange: { region in - cameraRegion = region - debounceTask?.cancel() - debounceTask = Task { - try? await Task.sleep(for: .milliseconds(200)) - guard !Task.isCancelled else { return } - debouncedRegion = region - } - }, - isStyleLoaded: $isStyleLoaded - ) - - // Selection rectangle overlay - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color.accentColor, lineWidth: 2) - .padding(Self.selectionPadding) - .allowsHitTesting(false) - } - .onGeometryChange(for: CGSize.self) { proxy in - proxy.size - } action: { newValue in - mapSize = newValue - } - .navigationTitle(L10n.Settings.OfflineMaps.pickRegion) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Settings.OfflineMaps.cancel) { - dismiss() - } - } - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Settings.OfflineMaps.download) { - downloadRegion() - } - .disabled( - regionName.isEmpty || isDownloading || exceedsAvailableSpace - || !appState.offlineMapService.isNetworkAvailable - || selectionBounds == nil - ) - } - } - .safeAreaInset(edge: .bottom) { - RegionPickerBottomCard( - regionName: $regionName, - includeTopo: $includeTopo, - estimatedDownloadBytes: estimatedDownloadBytes, - exceedsAvailableSpace: exceedsAvailableSpace, - isNetworkAvailable: appState.offlineMapService.isNetworkAvailable - ) + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + @State private var regionName = "" + @State private var cameraRegion: MKCoordinateRegion? + @State private var isDownloading = false + @State private var errorMessage: String? + @State private var mapSize: CGSize = .zero + @State private var includeTopo = false + @State private var isStyleLoaded = false + @State private var debouncedRegion: MKCoordinateRegion? + @State private var debounceTask: Task? + @State private var availableBytes: Int64? + + private static let selectionPadding: CGFloat = 40 + + var body: some View { + NavigationStack { + ZStack { + MC1MapView( + points: [], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + showLabels: false, + showsUserLocation: true, + isInteractive: true, + showsScale: false, + isNorthLocked: true, + cameraRegion: $cameraRegion, + cameraRegionVersion: 0, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { region in + cameraRegion = region + debounceTask?.cancel() + debounceTask = Task { + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled else { return } + debouncedRegion = region } - .errorAlert($errorMessage) - .onAppear { refreshAvailableBytes() } - .onChange(of: debouncedRegion?.center.latitude) { _, _ in refreshAvailableBytes() } - .onChange(of: debouncedRegion?.center.longitude) { _, _ in refreshAvailableBytes() } - } - } - - // MARK: - Download Estimate - - private var selectedLayers: Set { - var layers: Set = [.base] - if includeTopo { layers.insert(.topo) } - return layers - } + }, + isStyleLoaded: $isStyleLoaded + ) - private var estimatedDownloadBytes: Int64? { - guard let bounds = selectionBounds else { return nil } - return selectedLayers.reduce(Int64(0)) { total, layer in - total + OfflineMapService.estimatedDownloadSize(bounds: bounds, minZoom: 10, maxZoom: Int(layer.maxDownloadZoom), layer: layer) + // Selection rectangle overlay + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.accentColor, lineWidth: 2) + .padding(Self.selectionPadding) + .allowsHitTesting(false) + } + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newValue in + mapSize = newValue + } + .navigationTitle(L10n.Settings.OfflineMaps.pickRegion) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Settings.OfflineMaps.cancel) { + dismiss() + } } - } - - private var exceedsAvailableSpace: Bool { - guard let estimated = estimatedDownloadBytes, - let available = availableBytes else { return false } - return estimated > available - } - - private func refreshAvailableBytes() { - let values = try? URL.documentsDirectory.resourceValues( - forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Settings.OfflineMaps.download) { + downloadRegion() + } + .disabled( + regionName.isEmpty || isDownloading || exceedsAvailableSpace + || !appState.offlineMapService.isNetworkAvailable + || selectionBounds == nil + ) + } + } + .safeAreaInset(edge: .bottom) { + RegionPickerBottomCard( + regionName: $regionName, + includeTopo: $includeTopo, + estimatedDownloadBytes: estimatedDownloadBytes, + exceedsAvailableSpace: exceedsAvailableSpace, + isNetworkAvailable: appState.offlineMapService.isNetworkAvailable ) - availableBytes = values?.volumeAvailableCapacityForImportantUsage + } + .errorAlert($errorMessage) + .onAppear { refreshAvailableBytes() } + .onChange(of: debouncedRegion?.center.latitude) { _, _ in refreshAvailableBytes() } + .onChange(of: debouncedRegion?.center.longitude) { _, _ in refreshAvailableBytes() } } + } - // MARK: - Bounds - - private var selectionBounds: MLNCoordinateBounds? { - guard let region = debouncedRegion, - mapSize.width > 0, mapSize.height > 0 else { return nil } + // MARK: - Download Estimate - let lonFraction = Self.selectionPadding / (mapSize.width / 2) - let latFraction = Self.selectionPadding / (mapSize.height / 2) + private var selectedLayers: Set { + var layers: Set = [.base] + if includeTopo { layers.insert(.topo) } + return layers + } - let latInset = region.span.latitudeDelta * latFraction - let lonInset = region.span.longitudeDelta * lonFraction - - return MLNCoordinateBounds( - sw: CLLocationCoordinate2D( - latitude: region.center.latitude - (region.span.latitudeDelta / 2 - latInset), - longitude: region.center.longitude - (region.span.longitudeDelta / 2 - lonInset) - ), - ne: CLLocationCoordinate2D( - latitude: region.center.latitude + (region.span.latitudeDelta / 2 - latInset), - longitude: region.center.longitude + (region.span.longitudeDelta / 2 - lonInset) - ) - ) + private var estimatedDownloadBytes: Int64? { + guard let bounds = selectionBounds else { return nil } + return selectedLayers.reduce(Int64(0)) { total, layer in + total + OfflineMapService.estimatedDownloadSize(bounds: bounds, minZoom: 10, maxZoom: Int(layer.maxDownloadZoom), layer: layer) } - - // MARK: - Download - - private func downloadRegion() { - guard let bounds = selectionBounds else { return } - isDownloading = true - - let layers = selectedLayers - - Task { - defer { isDownloading = false } - do { - try await appState.offlineMapService.downloadRegion( - name: regionName, - bounds: bounds, - layers: layers - ) - dismiss() - } catch { - errorMessage = error.userFacingMessage - } - } + } + + private var exceedsAvailableSpace: Bool { + guard let estimated = estimatedDownloadBytes, + let available = availableBytes else { return false } + return estimated > available + } + + private func refreshAvailableBytes() { + let values = try? URL.documentsDirectory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ) + availableBytes = values?.volumeAvailableCapacityForImportantUsage + } + + // MARK: - Bounds + + private var selectionBounds: MLNCoordinateBounds? { + guard let region = debouncedRegion, + mapSize.width > 0, mapSize.height > 0 else { return nil } + + let lonFraction = Self.selectionPadding / (mapSize.width / 2) + let latFraction = Self.selectionPadding / (mapSize.height / 2) + + let latInset = region.span.latitudeDelta * latFraction + let lonInset = region.span.longitudeDelta * lonFraction + + return MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: region.center.latitude - (region.span.latitudeDelta / 2 - latInset), + longitude: region.center.longitude - (region.span.longitudeDelta / 2 - lonInset) + ), + ne: CLLocationCoordinate2D( + latitude: region.center.latitude + (region.span.latitudeDelta / 2 - latInset), + longitude: region.center.longitude + (region.span.longitudeDelta / 2 - lonInset) + ) + ) + } + + // MARK: - Download + + private func downloadRegion() { + guard let bounds = selectionBounds else { return } + isDownloading = true + + let layers = selectedLayers + + Task { + defer { isDownloading = false } + do { + try await appState.offlineMapService.downloadRegion( + name: regionName, + bounds: bounds, + layers: layers + ) + dismiss() + } catch { + errorMessage = error.userFacingMessage + } } + } } // MARK: - Region Picker Bottom Card private struct RegionPickerBottomCard: View { - @Binding var regionName: String - @Binding var includeTopo: Bool - let estimatedDownloadBytes: Int64? - let exceedsAvailableSpace: Bool - let isNetworkAvailable: Bool - - /// Warn when estimated download exceeds 500 MB. - private static let largeDownloadThreshold: Int64 = 500_000_000 - - @State private var footerMinHeight: CGFloat = 0 - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - TextField(L10n.Settings.OfflineMaps.regionName, text: $regionName) - .textFieldStyle(.plain) - - Divider() - - VStack(alignment: .leading) { - Text(L10n.Settings.OfflineMaps.layers) - .font(.caption) - .foregroundStyle(.secondary) - - Toggle(L10n.Settings.OfflineMaps.Layer.topo, isOn: $includeTopo) + @Binding var regionName: String + @Binding var includeTopo: Bool + let estimatedDownloadBytes: Int64? + let exceedsAvailableSpace: Bool + let isNetworkAvailable: Bool + + /// Warn when estimated download exceeds 500 MB. + private static let largeDownloadThreshold: Int64 = 500_000_000 + + @State private var footerMinHeight: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + TextField(L10n.Settings.OfflineMaps.regionName, text: $regionName) + .textFieldStyle(.plain) + + Divider() + + VStack(alignment: .leading) { + Text(L10n.Settings.OfflineMaps.layers) + .font(.caption) + .foregroundStyle(.secondary) + + Toggle(L10n.Settings.OfflineMaps.Layer.topo, isOn: $includeTopo) + } + .toggleStyle(.switch) + .controlSize(.mini) + + Divider() + + footerContent + .frame(minHeight: footerMinHeight, alignment: .top) + .background { + // Measure the tallest possible footer state (estimate + warning) + // to reserve stable space regardless of current state or Dynamic Type size. + tallestFooterState + .hidden() + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + footerMinHeight = height } - .toggleStyle(.switch) - .controlSize(.mini) - - Divider() - - footerContent - .frame(minHeight: footerMinHeight, alignment: .top) - .background { - // Measure the tallest possible footer state (estimate + warning) - // to reserve stable space regardless of current state or Dynamic Type size. - tallestFooterState - .hidden() - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - } action: { height in - footerMinHeight = height - } - } } - .padding() - .background(.regularMaterial, in: .rect(cornerRadius: 12)) - .padding(.horizontal) - .padding(.bottom, 4) } - - @ViewBuilder - private var footerContent: some View { - VStack(alignment: .leading, spacing: 12) { - if !isNetworkAvailable { - HStack { - Image(systemName: "wifi.slash") - .foregroundStyle(.red) - Text(L10n.Settings.OfflineMaps.noNetwork) - .foregroundStyle(.red) - } - .font(.caption) - } else if let bytes = estimatedDownloadBytes { - let isLarge = bytes > Self.largeDownloadThreshold - - HStack { - if exceedsAvailableSpace { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.red) - } else if isLarge { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } - - Text(L10n.Settings.OfflineMaps.estimatedSize( - bytes.formatted(.byteCount(style: .file)) - )) - .foregroundStyle(exceedsAvailableSpace ? .red : isLarge ? .orange : .secondary) - } - .font(.caption) - - if exceedsAvailableSpace { - Text(L10n.Settings.OfflineMaps.exceedsStorage) - .font(.caption) - .foregroundStyle(.red) - } else if isLarge { - Text(L10n.Settings.OfflineMaps.largeTileWarning) - .font(.caption) - .foregroundStyle(.orange) - } else { - Text(L10n.Settings.OfflineMaps.downloadHint) - .font(.caption) - .foregroundStyle(.secondary) - } - } else { - Text(L10n.Settings.OfflineMaps.downloadHint) - .font(.caption) - .foregroundStyle(.secondary) - } + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 12)) + .padding(.horizontal) + .padding(.bottom, 4) + } + + private var footerContent: some View { + VStack(alignment: .leading, spacing: 12) { + if !isNetworkAvailable { + HStack { + Image(systemName: "wifi.slash") + .foregroundStyle(.red) + Text(L10n.Settings.OfflineMaps.noNetwork) + .foregroundStyle(.red) } - } + .font(.caption) + } else if let bytes = estimatedDownloadBytes { + let isLarge = bytes > Self.largeDownloadThreshold + + HStack { + if exceedsAvailableSpace { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } else if isLarge { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + + Text(L10n.Settings.OfflineMaps.estimatedSize( + bytes.formatted(.byteCount(style: .file)) + )) + .foregroundStyle(exceedsAvailableSpace ? .red : isLarge ? .orange : .secondary) + } + .font(.caption) - /// The tallest possible single footer state: estimate line + the longest warning. - /// Rendered hidden to measure the height needed at the current Dynamic Type size. - private var tallestFooterState: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "xmark.circle.fill") - Text(L10n.Settings.OfflineMaps.estimatedSize("999 GB")) - } + if exceedsAvailableSpace { + Text(L10n.Settings.OfflineMaps.exceedsStorage) .font(.caption) - - Text(L10n.Settings.OfflineMaps.exceedsStorage) - .font(.caption) + .foregroundStyle(.red) + } else if isLarge { + Text(L10n.Settings.OfflineMaps.largeTileWarning) + .font(.caption) + .foregroundStyle(.orange) + } else { + Text(L10n.Settings.OfflineMaps.downloadHint) + .font(.caption) + .foregroundStyle(.secondary) } + } else { + Text(L10n.Settings.OfflineMaps.downloadHint) + .font(.caption) + .foregroundStyle(.secondary) + } } + } + + /// The tallest possible single footer state: estimate line + the longest warning. + /// Rendered hidden to measure the height needed at the current Dynamic Type size. + private var tallestFooterState: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "xmark.circle.fill") + Text(L10n.Settings.OfflineMaps.estimatedSize("999 GB")) + } + .font(.caption) + + Text(L10n.Settings.OfflineMaps.exceedsStorage) + .font(.caption) + } + } } #Preview { - NavigationStack { - OfflineMapSettingsView() - .environment(\.appState, AppState()) - } + NavigationStack { + OfflineMapSettingsView() + .environment(\.appState, AppState()) + } } diff --git a/MC1/Views/Settings/PublicKeyView.swift b/MC1/Views/Settings/PublicKeyView.swift index 76ea1923..55e55a3a 100644 --- a/MC1/Views/Settings/PublicKeyView.swift +++ b/MC1/Views/Settings/PublicKeyView.swift @@ -1,47 +1,47 @@ -import SwiftUI import MC1Services +import SwiftUI /// Read-only display of a device public key with copy support, pushed from `DeviceInfoView` /// via `SettingsSubpage.publicKey`. struct PublicKeyView: View { - let publicKey: Data + let publicKey: Data - @Environment(\.appTheme) private var theme - @State private var copyHapticTrigger = 0 + @Environment(\.appTheme) private var theme + @State private var copyHapticTrigger = 0 - var body: some View { - List { - Section { - Text(publicKey.uppercaseHexString(separator: " ")) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - } header: { - Text(L10n.Settings.PublicKey.header) - } footer: { - Text(L10n.Settings.PublicKey.footer) - } - .themedRowBackground(theme) + var body: some View { + List { + Section { + Text(publicKey.uppercaseHexString(separator: " ")) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } header: { + Text(L10n.Settings.PublicKey.header) + } footer: { + Text(L10n.Settings.PublicKey.footer) + } + .themedRowBackground(theme) - Section { - Button { - copyHapticTrigger += 1 - UIPasteboard.general.string = publicKey.uppercaseHexString() - } label: { - Label(L10n.Settings.PublicKey.copy, systemImage: "doc.on.doc") - } - - // Base64 representation - Text(publicKey.base64EncodedString()) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } header: { - Text(L10n.Settings.PublicKey.Base64.header) - } - .themedRowBackground(theme) + Section { + Button { + copyHapticTrigger += 1 + UIPasteboard.general.string = publicKey.uppercaseHexString() + } label: { + Label(L10n.Settings.PublicKey.copy, systemImage: "doc.on.doc") } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.PublicKey.title) - .sensoryFeedback(.success, trigger: copyHapticTrigger) + + // Base64 representation + Text(publicKey.base64EncodedString()) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } header: { + Text(L10n.Settings.PublicKey.Base64.header) + } + .themedRowBackground(theme) } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.PublicKey.title) + .sensoryFeedback(.success, trigger: copyHapticTrigger) + } } diff --git a/MC1/Views/Settings/RadioSettingsView.swift b/MC1/Views/Settings/RadioSettingsView.swift index 07260565..651022c1 100644 --- a/MC1/Views/Settings/RadioSettingsView.swift +++ b/MC1/Views/Settings/RadioSettingsView.swift @@ -2,14 +2,14 @@ import SwiftUI /// Sub-page wrapping RadioPresetSection for the settings navigation struct RadioSettingsView: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - List { - RadioPresetSection() - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Radio.header) - .navigationBarTitleDisplayMode(.inline) + var body: some View { + List { + RadioPresetSection() } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Radio.header) + .navigationBarTitleDisplayMode(.inline) + } } diff --git a/MC1/Views/Settings/Sections/AboutSection.swift b/MC1/Views/Settings/Sections/AboutSection.swift index f1e7b023..34e58206 100644 --- a/MC1/Views/Settings/Sections/AboutSection.swift +++ b/MC1/Views/Settings/Sections/AboutSection.swift @@ -2,75 +2,75 @@ import SwiftUI /// About and links section struct AboutSection: View { - @Environment(\.appTheme) private var theme - let isSidebar: Bool + @Environment(\.appTheme) private var theme + let isSidebar: Bool - var body: some View { - Section { - #if SIDELOAD - Link(destination: URL(string: "https://github.com/sponsors/Avi0n")!) { - HStack { - TintedLabel(L10n.Settings.Support.title, systemImage: "heart") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) - #else - SettingsDetailRow(detail: .support) { - TintedLabel(L10n.Settings.Support.title, systemImage: "heart") - } - #endif - - Link(destination: URL(string: "https://meshcore.io")!) { - HStack { - TintedLabel(L10n.Settings.About.website, systemImage: "globe") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) + var body: some View { + Section { + #if SIDELOAD + Link(destination: URL(string: "https://github.com/sponsors/Avi0n")!) { + HStack { + TintedLabel(L10n.Settings.Support.title, systemImage: "heart") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) + #else + SettingsDetailRow(detail: .support) { + TintedLabel(L10n.Settings.Support.title, systemImage: "heart") + } + #endif - Link(destination: URL(string: "https://map.meshcore.io/")!) { - HStack { - TintedLabel(L10n.Settings.About.onlineMap, systemImage: "map") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) + Link(destination: URL(string: "https://meshcore.io")!) { + HStack { + TintedLabel(L10n.Settings.About.website, systemImage: "globe") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) - Link(destination: URL(string: "https://github.com/Avi0n/MeshCoreOne")!) { - HStack { - TintedLabel(L10n.Settings.About.github, systemImage: "chevron.left.forwardslash.chevron.right") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) + Link(destination: URL(string: "https://map.meshcore.io/")!) { + HStack { + TintedLabel(L10n.Settings.About.onlineMap, systemImage: "map") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) - Link(destination: URL(string: "https://meshcoreone.com/privacy.html")!) { - HStack { - TintedLabel(L10n.Settings.About.privacyPolicy, systemImage: "hand.raised") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) + Link(destination: URL(string: "https://github.com/Avi0n/MeshCoreOne")!) { + HStack { + TintedLabel(L10n.Settings.About.github, systemImage: "chevron.left.forwardslash.chevron.right") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) - } header: { - Text(L10n.Settings.About.header) + Link(destination: URL(string: "https://meshcoreone.com/privacy.html")!) { + HStack { + TintedLabel(L10n.Settings.About.privacyPolicy, systemImage: "hand.raised") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) } - .themedRowBackground(theme, flatten: isSidebar) + } + .foregroundStyle(.primary) + + } header: { + Text(L10n.Settings.About.header) } + .themedRowBackground(theme, flatten: isSidebar) + } } diff --git a/MC1/Views/Settings/Sections/ActivityView.swift b/MC1/Views/Settings/Sections/ActivityView.swift index 94b70d87..6fb95ff3 100644 --- a/MC1/Views/Settings/Sections/ActivityView.swift +++ b/MC1/Views/Settings/Sections/ActivityView.swift @@ -5,11 +5,11 @@ import UIKit /// from SwiftUI state. `ShareLink` cannot be triggered after asynchronous work, so the /// debug-log export drives this via `.sheet(item:)` once the file is generated. struct ActivityView: UIViewControllerRepresentable { - let activityItems: [Any] + let activityItems: [Any] - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - } + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } diff --git a/MC1/Views/Settings/Sections/AdvancedRadioSection.swift b/MC1/Views/Settings/Sections/AdvancedRadioSection.swift index 6e3c2c19..940fb6e0 100644 --- a/MC1/Views/Settings/Sections/AdvancedRadioSection.swift +++ b/MC1/Views/Settings/Sections/AdvancedRadioSection.swift @@ -1,251 +1,251 @@ -import SwiftUI import MC1Services +import SwiftUI /// Manual radio parameter configuration struct AdvancedRadioSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var frequency: Double? // MHz - @State private var bandwidth: UInt32? // Hz - @State private var spreadingFactor: Int? - @State private var codingRate: Int? - @State private var txPower: Int? // dBm - @State private var clientRepeat: Bool? - @State private var hasLoaded = false - @State private var isApplying = false - @State private var showSuccess = false - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @FocusState private var focusedField: RadioField? - - private enum RadioField: Hashable { - case frequency - case txPower - } - - private var settingsModified: Bool { - guard let device = appState.connectedDevice else { return false } - return clientRepeat != appState.connectedDevice?.clientRepeat || - frequency != Double(device.frequency) / 1000.0 || - bandwidth != RadioOptions.nearestBandwidth(to: device.bandwidth) || - spreadingFactor != Int(device.spreadingFactor) || - codingRate != Int(device.codingRate) || - txPower != Int(device.txPower) - } - - private var canApply: Bool { - appState.connectionState == .ready && settingsModified && !isApplying && !showSuccess - } - - /// Combined hash of all radio settings for change detection - private var deviceRadioSettingsHash: Int { - var hasher = Hasher() - hasher.combine(appState.connectedDevice?.frequency) - hasher.combine(appState.connectedDevice?.bandwidth) - hasher.combine(appState.connectedDevice?.spreadingFactor) - hasher.combine(appState.connectedDevice?.codingRate) - hasher.combine(appState.connectedDevice?.txPower) - hasher.combine(appState.connectedDevice?.clientRepeat) - return hasher.finalize() - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var frequency: Double? // MHz + @State private var bandwidth: UInt32? // Hz + @State private var spreadingFactor: Int? + @State private var codingRate: Int? + @State private var txPower: Int? // dBm + @State private var clientRepeat: Bool? + @State private var hasLoaded = false + @State private var isApplying = false + @State private var showSuccess = false + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @FocusState private var focusedField: RadioField? + + private enum RadioField: Hashable { + case frequency + case txPower + } + + private var settingsModified: Bool { + guard let device = appState.connectedDevice else { return false } + return clientRepeat != appState.connectedDevice?.clientRepeat || + frequency != Double(device.frequency) / 1000.0 || + bandwidth != RadioOptions.nearestBandwidth(to: device.bandwidth) || + spreadingFactor != Int(device.spreadingFactor) || + codingRate != Int(device.codingRate) || + txPower != Int(device.txPower) + } + + private var canApply: Bool { + appState.connectionState == .ready && settingsModified && !isApplying && !showSuccess + } + + /// Combined hash of all radio settings for change detection + private var deviceRadioSettingsHash: Int { + var hasher = Hasher() + hasher.combine(appState.connectedDevice?.frequency) + hasher.combine(appState.connectedDevice?.bandwidth) + hasher.combine(appState.connectedDevice?.spreadingFactor) + hasher.combine(appState.connectedDevice?.codingRate) + hasher.combine(appState.connectedDevice?.txPower) + hasher.combine(appState.connectedDevice?.clientRepeat) + return hasher.finalize() + } + + var body: some View { + Section { + if !hasLoaded { + ProgressView() + .frame(maxWidth: .infinity) + } else { + HStack { + Text(L10n.Settings.AdvancedRadio.frequency) + Spacer() + TextField( + L10n.Settings.AdvancedRadio.frequencyPlaceholder, + value: $frequency, + format: .number.precision(.fractionLength(3)).locale(.posix) + ) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 100) + .focused($focusedField, equals: .frequency) + } - var body: some View { - Section { - if !hasLoaded { - ProgressView() - .frame(maxWidth: .infinity) - } else { - HStack { - Text(L10n.Settings.AdvancedRadio.frequency) - Spacer() - TextField( - L10n.Settings.AdvancedRadio.frequencyPlaceholder, - value: $frequency, - format: .number.precision(.fractionLength(3)).locale(.posix) - ) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 100) - .focused($focusedField, equals: .frequency) - } - - Picker(L10n.Settings.AdvancedRadio.bandwidth, selection: $bandwidth) { - ForEach(RadioOptions.bandwidthsHz, id: \.self) { bwHz in - Text(RadioOptions.formatBandwidth(bwHz)) - .tag(bwHz as UInt32?) - .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(bwHz))) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.bandwidthHint) - - Picker(L10n.Settings.AdvancedRadio.spreadingFactor, selection: $spreadingFactor) { - ForEach(RadioOptions.spreadingFactors, id: \.self) { spreadFactorOption in - Text(spreadFactorOption, format: .number) - .tag(spreadFactorOption as Int?) - .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.spreadingFactorLabel(spreadFactorOption)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.spreadingFactorHint) - - Picker(L10n.Settings.AdvancedRadio.codingRate, selection: $codingRate) { - ForEach(RadioOptions.codingRates, id: \.self) { codeRateOption in - Text("\(codeRateOption)") - .tag(codeRateOption as Int?) - .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.codingRateLabel(codeRateOption)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.codingRateHint) - - HStack { - Text(L10n.Settings.AdvancedRadio.txPower) - Spacer() - TextField(L10n.Settings.AdvancedRadio.txPowerPlaceholder, value: $txPower, format: .number) - .keyboardType(.numbersAndPunctuation) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused($focusedField, equals: .txPower) - } - - if appState.connectedDevice?.supportsClientRepeat == true { - Toggle(isOn: Binding( - get: { clientRepeat ?? false }, - set: { clientRepeat = $0 } - )) { - Text(L10n.Settings.AdvancedRadio.repeatMode) - Text(L10n.Settings.AdvancedRadio.RepeatMode.footer) - } - .accessibilityHint(L10n.Settings.Radio.RepeatMode.accessibilityHint) - .disabled(!hasLoaded) - } - - Button { - applySettings() - } label: { - AsyncActionLabel(isLoading: isApplying, showSuccess: showSuccess) { - Text(L10n.Settings.AdvancedRadio.apply) - .foregroundStyle(canApply ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .radioDisabled(for: appState.connectionState, or: isApplying || showSuccess || !settingsModified) - } - } header: { - Text(L10n.Settings.AdvancedRadio.header) - } footer: { - Text(L10n.Settings.AdvancedRadio.footer) + Picker(L10n.Settings.AdvancedRadio.bandwidth, selection: $bandwidth) { + ForEach(RadioOptions.bandwidthsHz, id: \.self) { bwHz in + Text(RadioOptions.formatBandwidth(bwHz)) + .tag(bwHz as UInt32?) + .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(bwHz))) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.bandwidthHint) + + Picker(L10n.Settings.AdvancedRadio.spreadingFactor, selection: $spreadingFactor) { + ForEach(RadioOptions.spreadingFactors, id: \.self) { spreadFactorOption in + Text(spreadFactorOption, format: .number) + .tag(spreadFactorOption as Int?) + .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.spreadingFactorLabel(spreadFactorOption)) + } } - .themedRowBackground(theme) - .onAppear { - loadCurrentSettings() + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.spreadingFactorHint) + + Picker(L10n.Settings.AdvancedRadio.codingRate, selection: $codingRate) { + ForEach(RadioOptions.codingRates, id: \.self) { codeRateOption in + Text("\(codeRateOption)") + .tag(codeRateOption as Int?) + .accessibilityLabel(L10n.Settings.AdvancedRadio.Accessibility.codingRateLabel(codeRateOption)) + } } - .onChange(of: deviceRadioSettingsHash) { _, _ in - loadCurrentSettings() + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.Settings.AdvancedRadio.Accessibility.codingRateHint) + + HStack { + Text(L10n.Settings.AdvancedRadio.txPower) + Spacer() + TextField(L10n.Settings.AdvancedRadio.txPowerPlaceholder, value: $txPower, format: .number) + .keyboardType(.numbersAndPunctuation) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused($focusedField, equals: .txPower) } - .errorAlert($errorMessage) - .retryAlert(retryAlert) + + if appState.connectedDevice?.supportsClientRepeat == true { + Toggle(isOn: Binding( + get: { clientRepeat ?? false }, + set: { clientRepeat = $0 } + )) { + Text(L10n.Settings.AdvancedRadio.repeatMode) + Text(L10n.Settings.AdvancedRadio.RepeatMode.footer) + } + .accessibilityHint(L10n.Settings.Radio.RepeatMode.accessibilityHint) + .disabled(!hasLoaded) + } + + Button { + applySettings() + } label: { + AsyncActionLabel(isLoading: isApplying, showSuccess: showSuccess) { + Text(L10n.Settings.AdvancedRadio.apply) + .foregroundStyle(canApply ? Color.accentColor : .secondary) + .transition(.opacity) + } + } + .radioDisabled(for: appState.connectionState, or: isApplying || showSuccess || !settingsModified) + } + } header: { + Text(L10n.Settings.AdvancedRadio.header) + } footer: { + Text(L10n.Settings.AdvancedRadio.footer) + } + .themedRowBackground(theme) + .onAppear { + loadCurrentSettings() + } + .onChange(of: deviceRadioSettingsHash) { _, _ in + loadCurrentSettings() + } + .errorAlert($errorMessage) + .retryAlert(retryAlert) + } + + private func loadCurrentSettings() { + guard let device = appState.connectedDevice else { return } + frequency = Double(device.frequency) / 1000.0 + // Use nearestBandwidth to handle devices with non-standard bandwidth values + // or firmware float precision issues (e.g., 7799 Hz instead of 7800 Hz) + bandwidth = RadioOptions.nearestBandwidth(to: device.bandwidth) + spreadingFactor = Int(device.spreadingFactor) + codingRate = Int(device.codingRate) + txPower = Int(device.txPower) + clientRepeat = device.clientRepeat + hasLoaded = true + } + + private func applySettings() { + guard let freqMHz = frequency, + let bandwidthHz = bandwidth, + let spreadFactor = spreadingFactor, + let codeRate = codingRate, + let power = txPower, + let settingsService = appState.services?.settingsService else { + errorMessage = L10n.Settings.AdvancedRadio.invalidInput + return } - private func loadCurrentSettings() { - guard let device = appState.connectedDevice else { return } - frequency = Double(device.frequency) / 1000.0 - // Use nearestBandwidth to handle devices with non-standard bandwidth values - // or firmware float precision issues (e.g., 7799 Hz instead of 7800 Hz) - bandwidth = RadioOptions.nearestBandwidth(to: device.bandwidth) - spreadingFactor = Int(device.spreadingFactor) - codingRate = Int(device.codingRate) - txPower = Int(device.txPower) - clientRepeat = device.clientRepeat - hasLoaded = true + // Pickers enforce bandwidth, SF, and CR; frequency and TX power are free-text, so + // validate them with non-trapping conversions before scaling into the wire fields. + let scaledFreqKHz = (freqMHz * 1000).rounded() + let freqInRange = freqMHz.isFinite + && scaledFreqKHz >= Double(PacketBuilder.frequencyRangeKHz.lowerBound) + && scaledFreqKHz <= Double(PacketBuilder.frequencyRangeKHz.upperBound) + let maxTxPower = appState.connectedDevice?.maxTxPower ?? PacketBuilder.txPowerFloor + guard freqInRange, + let frequencyKHz = UInt32(exactly: scaledFreqKHz), + let spreadFactorByte = UInt8(exactly: spreadFactor), + let codeRateByte = UInt8(exactly: codeRate), + let powerLevel = Int8(exactly: power), + powerLevel >= PacketBuilder.txPowerFloor, + powerLevel <= maxTxPower else { + errorMessage = L10n.Settings.AdvancedRadio.invalidInput + return } - private func applySettings() { - guard let freqMHz = frequency, - let bandwidthHz = bandwidth, - let spreadFactor = spreadingFactor, - let codeRate = codingRate, - let power = txPower, - let settingsService = appState.services?.settingsService else { - errorMessage = L10n.Settings.AdvancedRadio.invalidInput - return + isApplying = true + Task { + do { + // Save pre-repeat settings when enabling repeat mode + let wasRepeat = appState.connectedDevice?.clientRepeat ?? false + let willRepeat = clientRepeat ?? false + if !wasRepeat, willRepeat { + appState.connectionManager.savePreRepeatSettings() } - // Pickers enforce bandwidth, SF, and CR; frequency and TX power are free-text, so - // validate them with non-trapping conversions before scaling into the wire fields. - let scaledFreqKHz = (freqMHz * 1000).rounded() - let freqInRange = freqMHz.isFinite - && scaledFreqKHz >= Double(PacketBuilder.frequencyRangeKHz.lowerBound) - && scaledFreqKHz <= Double(PacketBuilder.frequencyRangeKHz.upperBound) - let maxTxPower = appState.connectedDevice?.maxTxPower ?? PacketBuilder.txPowerFloor - guard freqInRange, - let frequencyKHz = UInt32(exactly: scaledFreqKHz), - let spreadFactorByte = UInt8(exactly: spreadFactor), - let codeRateByte = UInt8(exactly: codeRate), - let powerLevel = Int8(exactly: power), - powerLevel >= PacketBuilder.txPowerFloor, - powerLevel <= maxTxPower else { - errorMessage = L10n.Settings.AdvancedRadio.invalidInput - return + // Set radio params first + _ = try await settingsService.setRadioParamsVerified( + frequencyKHz: frequencyKHz, + // Note: Parameter is misleadingly named "bandwidthKHz" but expects Hz. + // bandwidthHz is already UInt32 Hz from the picker, pass directly. + bandwidthKHz: bandwidthHz, + spreadingFactor: spreadFactorByte, + codingRate: codeRateByte, + clientRepeat: clientRepeat + ) + + // Clear pre-repeat settings when disabling repeat mode + if wasRepeat, !willRepeat { + appState.connectionManager.clearPreRepeatSettings() } - isApplying = true - Task { - do { - // Save pre-repeat settings when enabling repeat mode - let wasRepeat = appState.connectedDevice?.clientRepeat ?? false - let willRepeat = clientRepeat ?? false - if !wasRepeat && willRepeat { - appState.connectionManager.savePreRepeatSettings() - } - - // Set radio params first - _ = try await settingsService.setRadioParamsVerified( - frequencyKHz: frequencyKHz, - // Note: Parameter is misleadingly named "bandwidthKHz" but expects Hz. - // bandwidthHz is already UInt32 Hz from the picker, pass directly. - bandwidthKHz: bandwidthHz, - spreadingFactor: spreadFactorByte, - codingRate: codeRateByte, - clientRepeat: clientRepeat - ) - - // Clear pre-repeat settings when disabling repeat mode - if wasRepeat && !willRepeat { - appState.connectionManager.clearPreRepeatSettings() - } - - // Then set TX power - _ = try await settingsService.setTxPowerVerified(powerLevel) - - focusedField = nil // Dismiss keyboard on success - retryAlert.reset() - isApplying = false // Clear before showing success - - // Show success checkmark briefly - withAnimation { - showSuccess = true - } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { - showSuccess = false - } - return // Skip the isApplying = false at the end - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { applySettings() }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - } - isApplying = false + // Then set TX power + _ = try await settingsService.setTxPowerVerified(powerLevel) + + focusedField = nil // Dismiss keyboard on success + retryAlert.reset() + isApplying = false // Clear before showing success + + // Show success checkmark briefly + withAnimation { + showSuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { + showSuccess = false } + return // Skip the isApplying = false at the end + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { applySettings() }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + } + isApplying = false } + } } diff --git a/MC1/Views/Settings/Sections/AppearanceSection.swift b/MC1/Views/Settings/Sections/AppearanceSection.swift index 07d3de37..7c13cfed 100644 --- a/MC1/Views/Settings/Sections/AppearanceSection.swift +++ b/MC1/Views/Settings/Sections/AppearanceSection.swift @@ -3,18 +3,18 @@ import SwiftUI /// Settings → Appearance row. Trailing detail shows the active theme's name (matching the /// region/mapStyle row pattern). Placed near the top, with other display-style settings. struct AppearanceSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var activeTheme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var activeTheme - var body: some View { - SettingsDetailRow(detail: .appearance) { - HStack { - TintedLabel(L10n.Settings.Appearance.title, systemImage: "paintpalette") - Spacer() - Text(activeTheme.localizedName) - .foregroundStyle(.secondary) - } - } - .accessibilityValue(activeTheme.localizedName) + var body: some View { + SettingsDetailRow(detail: .appearance) { + HStack { + TintedLabel(L10n.Settings.Appearance.title, systemImage: "paintpalette") + Spacer() + Text(activeTheme.localizedName) + .foregroundStyle(.secondary) + } } + .accessibilityValue(activeTheme.localizedName) + } } diff --git a/MC1/Views/Settings/Sections/BatteryCurveSection.swift b/MC1/Views/Settings/Sections/BatteryCurveSection.swift index ff0de798..570803d3 100644 --- a/MC1/Views/Settings/Sections/BatteryCurveSection.swift +++ b/MC1/Views/Settings/Sections/BatteryCurveSection.swift @@ -1,209 +1,209 @@ -import SwiftUI import MC1Services +import SwiftUI /// Battery curve configuration section /// Pure UI component - caller provides data and save callback struct BatteryCurveSection: View { - @Environment(\.appTheme) private var theme - let availablePresets: [OCVPreset] - let headerText: String - let footerText: String - - @Binding var selectedPreset: OCVPreset - @Binding var voltageValues: [Int] - - let onSave: (OCVPreset, [Int]) async -> Void - var isDisabled: Bool = false - - @State private var isEditingValues = false - @State private var validationError: String? - - var body: some View { - Section { - // Preset picker - Picker(L10n.Settings.BatteryCurve.preset, selection: $selectedPreset) { - ForEach(availablePresets, id: \.self) { preset in - Text(preset.displayName).tag(preset) - } - if selectedPreset == .custom && !availablePresets.contains(.custom) { - Text(L10n.Settings.BatteryCurve.custom).tag(OCVPreset.custom) - } - } - .onChange(of: selectedPreset) { _, newValue in - if newValue != .custom { - voltageValues = newValue.ocvArray - Task { - await onSave(newValue, newValue.ocvArray) - } - } - } - .disabled(isDisabled) - - BatteryCurveChart(ocvArray: voltageValues) - - // Edit values disclosure - DisclosureGroup(L10n.Settings.BatteryCurve.editValues, isExpanded: $isEditingValues) { - VoltageFieldsGrid( - voltageValues: $voltageValues, - validationError: $validationError, - onCommit: handleCommit - ) - } - .disabled(isDisabled) - - // Validation error - if let error = validationError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - } header: { - if !headerText.isEmpty { - Text(headerText) - } - } footer: { - if !footerText.isEmpty { - Text(footerText) - } + @Environment(\.appTheme) private var theme + let availablePresets: [OCVPreset] + let headerText: String + let footerText: String + + @Binding var selectedPreset: OCVPreset + @Binding var voltageValues: [Int] + + let onSave: (OCVPreset, [Int]) async -> Void + var isDisabled: Bool = false + + @State private var isEditingValues = false + @State private var validationError: String? + + var body: some View { + Section { + // Preset picker + Picker(L10n.Settings.BatteryCurve.preset, selection: $selectedPreset) { + ForEach(availablePresets, id: \.self) { preset in + Text(preset.displayName).tag(preset) } - .themedRowBackground(theme) - } - - private func handleCommit() { - if let error = validateVoltageValues() { - validationError = error - return + if selectedPreset == .custom, !availablePresets.contains(.custom) { + Text(L10n.Settings.BatteryCurve.custom).tag(OCVPreset.custom) } - validationError = nil - - // A commit whose values still match the selected preset is a no-op (e.g. focus - // left a field just after a preset switch replaced the values); reclassifying - // it as a custom curve would flip the picker and re-save identical values. - if selectedPreset != .custom && voltageValues == selectedPreset.ocvArray { - return + } + .onChange(of: selectedPreset) { _, newValue in + if newValue != .custom { + voltageValues = newValue.ocvArray + Task { + await onSave(newValue, newValue.ocvArray) + } } + } + .disabled(isDisabled) + + BatteryCurveChart(ocvArray: voltageValues) + + // Edit values disclosure + DisclosureGroup(L10n.Settings.BatteryCurve.editValues, isExpanded: $isEditingValues) { + VoltageFieldsGrid( + voltageValues: $voltageValues, + validationError: $validationError, + onCommit: handleCommit + ) + } + .disabled(isDisabled) + + // Validation error + if let error = validationError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + if !headerText.isEmpty { + Text(headerText) + } + } footer: { + if !footerText.isEmpty { + Text(footerText) + } + } + .themedRowBackground(theme) + } - selectedPreset = .custom - Task { - await onSave(.custom, voltageValues) - } + private func handleCommit() { + if let error = validateVoltageValues() { + validationError = error + return } + validationError = nil - private func validateVoltageValues() -> String? { - for (index, value) in voltageValues.enumerated() { - if !OCVPreset.validMillivoltRange.contains(value) { - return L10n.Settings.BatteryCurve.Validation.outOfRange((10 - index) * 10) - } - } + // A commit whose values still match the selected preset is a no-op (e.g. focus + // left a field just after a preset switch replaced the values); reclassifying + // it as a custom curve would flip the picker and re-save identical values. + if selectedPreset != .custom, voltageValues == selectedPreset.ocvArray { + return + } - for (current, next) in zip(voltageValues, voltageValues.dropFirst()) where current <= next { - return L10n.Settings.BatteryCurve.Validation.notDescending - } + selectedPreset = .custom + Task { + await onSave(.custom, voltageValues) + } + } + + private func validateVoltageValues() -> String? { + for (index, value) in voltageValues.enumerated() { + if !OCVPreset.validMillivoltRange.contains(value) { + return L10n.Settings.BatteryCurve.Validation.outOfRange((10 - index) * 10) + } + } - return nil + for (current, next) in zip(voltageValues, voltageValues.dropFirst()) where current <= next { + return L10n.Settings.BatteryCurve.Validation.notDescending } + + return nil + } } /// Two-column grid of voltage input fields struct VoltageFieldsGrid: View { - @Binding var voltageValues: [Int] - @Binding var validationError: String? - let onCommit: () -> Void - - private let columns = [ - GridItem(.flexible()), - GridItem(.flexible()) - ] - - var body: some View { - LazyVGrid(columns: columns, spacing: 12) { - ForEach(0..<11, id: \.self) { index in - VoltageField( - percent: (10 - index) * 10, - value: $voltageValues[index], - hasError: fieldHasError(at: index), - onCommit: onCommit - ) - } - } - .padding(.vertical, 8) - } - - private func fieldHasError(at index: Int) -> Bool { - let value = voltageValues[index] - if !OCVPreset.validMillivoltRange.contains(value) { return true } - if index > 0 && voltageValues[index - 1] <= value { return true } - if index < 10 && value <= voltageValues[index + 1] { return true } - return false + @Binding var voltageValues: [Int] + @Binding var validationError: String? + let onCommit: () -> Void + + private let columns = [ + GridItem(.flexible()), + GridItem(.flexible()) + ] + + var body: some View { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(0..<11, id: \.self) { index in + VoltageField( + percent: (10 - index) * 10, + value: $voltageValues[index], + hasError: fieldHasError(at: index), + onCommit: onCommit + ) + } } + .padding(.vertical, 8) + } + + private func fieldHasError(at index: Int) -> Bool { + let value = voltageValues[index] + if !OCVPreset.validMillivoltRange.contains(value) { return true } + if index > 0, voltageValues[index - 1] <= value { return true } + if index < 10, value <= voltageValues[index + 1] { return true } + return false + } } /// Individual voltage input field. Commits when focus leaves the field (or on submit), /// not per keystroke, so transient mid-edit values never reach the device. struct VoltageField: View { - let percent: Int - @Binding var value: Int - let hasError: Bool - let onCommit: () -> Void - - @FocusState private var isFocused: Bool - @State private var valueWhenFocused: Int? - - var body: some View { - HStack { - Text("\(percent)%") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 40, alignment: .trailing) - - TextField("", value: $value, format: .number) - .keyboardType(.numberPad) - .textFieldStyle(.roundedBorder) - .focused($isFocused) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(hasError ? .red : .clear, lineWidth: 1) - ) - .onChange(of: isFocused) { _, focused in - if focused { - valueWhenFocused = value - } else { - commitEdit() - valueWhenFocused = nil - } - } - .onSubmit(commitEdit) - .accessibilityLabel(L10n.Settings.BatteryCurve.Accessibility.voltageLabel(percent)) - .accessibilityValue(L10n.Settings.BatteryCurve.Accessibility.voltageValue(value)) - .accessibilityHint(L10n.Settings.BatteryCurve.Accessibility.voltageHint) - - Text(L10n.Settings.BatteryCurve.mv) - .font(.caption) - .foregroundStyle(.secondary) + let percent: Int + @Binding var value: Int + let hasError: Bool + let onCommit: () -> Void + + @FocusState private var isFocused: Bool + @State private var valueWhenFocused: Int? + + var body: some View { + HStack { + Text("\(percent)%") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 40, alignment: .trailing) + + TextField("", value: $value, format: .number) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .focused($isFocused) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(hasError ? .red : .clear, lineWidth: 1) + ) + .onChange(of: isFocused) { _, focused in + if focused { + valueWhenFocused = value + } else { + commitEdit() + valueWhenFocused = nil + } } + .onSubmit(commitEdit) + .accessibilityLabel(L10n.Settings.BatteryCurve.Accessibility.voltageLabel(percent)) + .accessibilityValue(L10n.Settings.BatteryCurve.Accessibility.voltageValue(value)) + .accessibilityHint(L10n.Settings.BatteryCurve.Accessibility.voltageHint) + + Text(L10n.Settings.BatteryCurve.mv) + .font(.caption) + .foregroundStyle(.secondary) } + } - private func commitEdit() { - guard valueWhenFocused != value else { return } - valueWhenFocused = value - onCommit() - } + private func commitEdit() { + guard valueWhenFocused != value else { return } + valueWhenFocused = value + onCommit() + } } #Preview { - @Previewable @State var preset: OCVPreset = .liIon - @Previewable @State var values: [Int] = OCVPreset.liIon.ocvArray - - NavigationStack { - List { - BatteryCurveSection( - availablePresets: OCVPreset.selectablePresets, - headerText: "Battery Curve", - footerText: "Configure the voltage-to-percentage curve for your device's battery.", - selectedPreset: $preset, - voltageValues: $values, - onSave: { _, _ in } - ) - } + @Previewable @State var preset: OCVPreset = .liIon + @Previewable @State var values: [Int] = OCVPreset.liIon.ocvArray + + NavigationStack { + List { + BatteryCurveSection( + availablePresets: OCVPreset.selectablePresets, + headerText: "Battery Curve", + footerText: "Configure the voltage-to-percentage curve for your device's battery.", + selectedPreset: $preset, + voltageValues: $values, + onSave: { _, _ in } + ) } + } } diff --git a/MC1/Views/Settings/Sections/BlockingSection.swift b/MC1/Views/Settings/Sections/BlockingSection.swift index d3ff006b..6fa72ba5 100644 --- a/MC1/Views/Settings/Sections/BlockingSection.swift +++ b/MC1/Views/Settings/Sections/BlockingSection.swift @@ -3,20 +3,20 @@ import SwiftUI /// Settings section for managing blocked channel users and contacts. struct BlockingSection: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - Section { - NavigationLink(value: SettingsSubpage.blockedChannelSenders) { - TintedLabel(L10n.Settings.Blocking.channelSenders, systemImage: "person.crop.circle.badge.xmark") - } + var body: some View { + Section { + NavigationLink(value: SettingsSubpage.blockedChannelSenders) { + TintedLabel(L10n.Settings.Blocking.channelSenders, systemImage: "person.crop.circle.badge.xmark") + } - NavigationLink(value: SettingsSubpage.blockedContacts) { - TintedLabel(L10n.Settings.Blocking.contacts, systemImage: "hand.raised.slash") - } - } header: { - Text(L10n.Settings.Blocking.header) - } - .themedRowBackground(theme) + NavigationLink(value: SettingsSubpage.blockedContacts) { + TintedLabel(L10n.Settings.Blocking.contacts, systemImage: "hand.raised.slash") + } + } header: { + Text(L10n.Settings.Blocking.header) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Settings/Sections/BluetoothSection.swift b/MC1/Views/Settings/Sections/BluetoothSection.swift index 82f4f3f8..472ec5a4 100644 --- a/MC1/Views/Settings/Sections/BluetoothSection.swift +++ b/MC1/Views/Settings/Sections/BluetoothSection.swift @@ -1,285 +1,285 @@ -import SwiftUI import MC1Services +import SwiftUI /// Firmware BLE pairing PIN protocol values. The firmware treats both the factory /// default and zero as "no custom PIN configured". private enum BLEPin { - static let firmwareDefault: UInt32 = 123456 - static let unset: UInt32 = 0 - /// A custom PIN must be exactly six digits. - static let customRange: ClosedRange = 100000...999999 + static let firmwareDefault: UInt32 = 123_456 + static let unset: UInt32 = 0 + /// A custom PIN must be exactly six digits. + static let customRange: ClosedRange = 100_000...999_999 } /// Bluetooth PIN configuration struct BluetoothSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var pinType: BluetoothPinType = .default - @State private var customPin: String = "" - @State private var showingPinEntry = false - @State private var showingChangePinEntry = false - @State private var showingRemoveConfirmation = false - @State private var isChangingPin = false - @State private var errorMessage: String? - @State private var hasInitialized = false - @State private var isPinVisible = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var pinType: BluetoothPinType = .default + @State private var customPin: String = "" + @State private var showingPinEntry = false + @State private var showingChangePinEntry = false + @State private var showingRemoveConfirmation = false + @State private var isChangingPin = false + @State private var errorMessage: String? + @State private var hasInitialized = false + @State private var isPinVisible = false - // Track what the user intended before confirmation dialogs - @State private var pendingPinType: BluetoothPinType? - @State private var isRenaming = false + // Track what the user intended before confirmation dialogs + @State private var pendingPinType: BluetoothPinType? + @State private var isRenaming = false - enum BluetoothPinType: String, CaseIterable { - case `default` = "Default" - case custom = "Custom PIN" + enum BluetoothPinType: String, CaseIterable { + case `default` = "Default" + case custom = "Custom PIN" - var localizedName: String { - switch self { - case .default: L10n.Settings.Bluetooth.PinType.default - case .custom: L10n.Settings.Bluetooth.PinType.custom - } - } + var localizedName: String { + switch self { + case .default: L10n.Settings.Bluetooth.PinType.default + case .custom: L10n.Settings.Bluetooth.PinType.custom + } } + } - private var currentPinType: BluetoothPinType { - guard let device = appState.connectedDevice else { return .default } - if device.blePin == BLEPin.unset || device.blePin == BLEPin.firmwareDefault { - return .default - } else { - return .custom - } + private var currentPinType: BluetoothPinType { + guard let device = appState.connectedDevice else { return .default } + if device.blePin == BLEPin.unset || device.blePin == BLEPin.firmwareDefault { + return .default + } else { + return .custom } + } - var body: some View { - Section { - Picker(L10n.Settings.Bluetooth.pinType, selection: $pinType) { - ForEach(BluetoothPinType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type) - } - } - .onChange(of: pinType) { oldValue, newValue in - guard hasInitialized else { return } - handlePinTypeChange(from: oldValue, to: newValue) - } - .radioDisabled(for: appState.connectionState, or: isChangingPin) - - if pinType == .custom, let device = appState.connectedDevice, device.blePin > 0 { - Button { - isPinVisible.toggle() - } label: { - HStack { - Text(L10n.Settings.Bluetooth.currentPin) - Spacer() - Group { - if isPinVisible { - Text(device.blePin, format: .number.grouping(.never)) - } else { - Text("••••••") - } - } - .font(.body.monospacedDigit()) - .foregroundStyle(.secondary) - - Image(systemName: isPinVisible ? "eye" : "eye.slash") - .foregroundStyle(.tertiary) - .imageScale(.small) - } - .contentShape(.rect) - } - .buttonStyle(.plain) + var body: some View { + Section { + Picker(L10n.Settings.Bluetooth.pinType, selection: $pinType) { + ForEach(BluetoothPinType.allCases, id: \.self) { type in + Text(type.localizedName).tag(type) + } + } + .onChange(of: pinType) { oldValue, newValue in + guard hasInitialized else { return } + handlePinTypeChange(from: oldValue, to: newValue) + } + .radioDisabled(for: appState.connectionState, or: isChangingPin) - Button(L10n.Settings.Bluetooth.changePin) { - customPin = "" - showingChangePinEntry = true - } - .radioDisabled(for: appState.connectionState) + if pinType == .custom, let device = appState.connectedDevice, device.blePin > 0 { + Button { + isPinVisible.toggle() + } label: { + HStack { + Text(L10n.Settings.Bluetooth.currentPin) + Spacer() + Group { + if isPinVisible { + Text(device.blePin, format: .number.grouping(.never)) + } else { + Text("••••••") + } } + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) - if appState.connectionState == .ready, - let deviceID = appState.connectedDevice?.id, - appState.connectionManager.supportsDeviceRename, - appState.connectionManager.hasAccessory(for: deviceID) { - Button { - renameDevice() - } label: { - HStack { - Text(L10n.Settings.Bluetooth.changeDisplayName) - Spacer() - if isRenaming { - ProgressView() - .controlSize(.small) - } - } - } - .radioDisabled(for: appState.connectionState, or: isRenaming) - } - } header: { - Text(L10n.Settings.Bluetooth.header) - } footer: { - if pinType == .default { - Text(L10n.Settings.Bluetooth.defaultPinFooter) - } + Image(systemName: isPinVisible ? "eye" : "eye.slash") + .foregroundStyle(.tertiary) + .imageScale(.small) + } + .contentShape(.rect) } - .themedRowBackground(theme) - .onAppear { - isPinVisible = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } + .buttonStyle(.plain) + + Button(L10n.Settings.Bluetooth.changePin) { + customPin = "" + showingChangePinEntry = true } - .alert(L10n.Settings.Bluetooth.Alert.SetPin.title, isPresented: $showingPinEntry) { - TextField(L10n.Settings.Bluetooth.pinPlaceholder, text: $customPin) - .keyboardType(.numberPad) - Button(L10n.Localizable.Common.cancel, role: .cancel) { - // Revert to previous pin type without triggering onChange loop - hasInitialized = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } - } - Button(L10n.Settings.Bluetooth.setPin) { - setCustomPin() + .radioDisabled(for: appState.connectionState) + } + + if appState.connectionState == .ready, + let deviceID = appState.connectedDevice?.id, + appState.connectionManager.supportsDeviceRename, + appState.connectionManager.hasAccessory(for: deviceID) { + Button { + renameDevice() + } label: { + HStack { + Text(L10n.Settings.Bluetooth.changeDisplayName) + Spacer() + if isRenaming { + ProgressView() + .controlSize(.small) } - } message: { - Text(L10n.Settings.Bluetooth.Alert.SetPin.message) + } } - .alert(L10n.Settings.Bluetooth.Alert.ChangePin.title, isPresented: $showingChangePinEntry) { - TextField(L10n.Settings.Bluetooth.pinPlaceholder, text: $customPin) - .keyboardType(.numberPad) - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.Bluetooth.changePin) { - setCustomPin() - } - } message: { - Text(L10n.Settings.Bluetooth.Alert.ChangePin.message) + .radioDisabled(for: appState.connectionState, or: isRenaming) + } + } header: { + Text(L10n.Settings.Bluetooth.header) + } footer: { + if pinType == .default { + Text(L10n.Settings.Bluetooth.defaultPinFooter) + } + } + .themedRowBackground(theme) + .onAppear { + isPinVisible = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true + } + } + .alert(L10n.Settings.Bluetooth.Alert.SetPin.title, isPresented: $showingPinEntry) { + TextField(L10n.Settings.Bluetooth.pinPlaceholder, text: $customPin) + .keyboardType(.numberPad) + Button(L10n.Localizable.Common.cancel, role: .cancel) { + // Revert to previous pin type without triggering onChange loop + hasInitialized = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true } - .alert(L10n.Settings.Bluetooth.Alert.ChangePinType.title, isPresented: $showingRemoveConfirmation) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { - // Revert to previous pin type without triggering onChange loop - hasInitialized = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } - } - Button(L10n.Settings.Bluetooth.Alert.change) { - applyPendingPinType() - } - } message: { - Text(L10n.Settings.Bluetooth.Alert.ChangePinType.message) + } + Button(L10n.Settings.Bluetooth.setPin) { + setCustomPin() + } + } message: { + Text(L10n.Settings.Bluetooth.Alert.SetPin.message) + } + .alert(L10n.Settings.Bluetooth.Alert.ChangePin.title, isPresented: $showingChangePinEntry) { + TextField(L10n.Settings.Bluetooth.pinPlaceholder, text: $customPin) + .keyboardType(.numberPad) + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.Bluetooth.changePin) { + setCustomPin() + } + } message: { + Text(L10n.Settings.Bluetooth.Alert.ChangePin.message) + } + .alert(L10n.Settings.Bluetooth.Alert.ChangePinType.title, isPresented: $showingRemoveConfirmation) { + Button(L10n.Localizable.Common.cancel, role: .cancel) { + // Revert to previous pin type without triggering onChange loop + hasInitialized = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true } - .errorAlert($errorMessage) + } + Button(L10n.Settings.Bluetooth.Alert.change) { + applyPendingPinType() + } + } message: { + Text(L10n.Settings.Bluetooth.Alert.ChangePinType.message) } + .errorAlert($errorMessage) + } - private func handlePinTypeChange(from oldValue: BluetoothPinType, to newValue: BluetoothPinType) { - // Skip if picker is being synced to device's current state (handles initialization race condition) - guard newValue != currentPinType else { return } + private func handlePinTypeChange(from oldValue: BluetoothPinType, to newValue: BluetoothPinType) { + // Skip if picker is being synced to device's current state (handles initialization race condition) + guard newValue != currentPinType else { return } - // If changing to custom, show PIN entry - if newValue == .custom && oldValue != .custom { - showingPinEntry = true - return - } + // If changing to custom, show PIN entry + if newValue == .custom, oldValue != .custom { + showingPinEntry = true + return + } - // If changing from custom to default, show confirmation - if oldValue == .custom && newValue == .default { - pendingPinType = newValue - showingRemoveConfirmation = true - } + // If changing from custom to default, show confirmation + if oldValue == .custom, newValue == .default { + pendingPinType = newValue + showingRemoveConfirmation = true } + } - private func applyPendingPinType() { - guard let pending = pendingPinType else { return } - pendingPinType = nil + private func applyPendingPinType() { + guard let pending = pendingPinType else { return } + pendingPinType = nil - let pinValue: UInt32 = pending == .default ? BLEPin.firmwareDefault : BLEPin.unset + let pinValue: UInt32 = pending == .default ? BLEPin.firmwareDefault : BLEPin.unset - isChangingPin = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } + isChangingPin = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected + } - // Send PIN change command (written to RAM until reboot) - try await settingsService.setBlePin(pinValue) + // Send PIN change command (written to RAM until reboot) + try await settingsService.setBlePin(pinValue) - // Reboot device to apply PIN change - iOS auto-reconnects - // Timeout is expected since device disconnects before write callback - do { - try await settingsService.reboot() - } catch BLEError.operationTimeout { - // Expected - device reboots before BLE write callback arrives - } - } catch { - errorMessage = error.userFacingMessage - // Revert - hasInitialized = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } - } - isChangingPin = false + // Reboot device to apply PIN change - iOS auto-reconnects + // Timeout is expected since device disconnects before write callback + do { + try await settingsService.reboot() + } catch BLEError.operationTimeout { + // Expected - device reboots before BLE write callback arrives + } + } catch { + errorMessage = error.userFacingMessage + // Revert + hasInitialized = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true } + } + isChangingPin = false } + } - private func setCustomPin() { - guard let pin = UInt32(customPin), BLEPin.customRange.contains(pin) else { - errorMessage = L10n.Settings.Bluetooth.Error.invalidPin - customPin = "" - // Revert - hasInitialized = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } - return - } + private func setCustomPin() { + guard let pin = UInt32(customPin), BLEPin.customRange.contains(pin) else { + errorMessage = L10n.Settings.Bluetooth.Error.invalidPin + customPin = "" + // Revert + hasInitialized = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true + } + return + } - isChangingPin = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } + isChangingPin = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected + } - // Send PIN change command (written to RAM until reboot) - try await settingsService.setBlePin(pin) + // Send PIN change command (written to RAM until reboot) + try await settingsService.setBlePin(pin) - // Reboot device to apply PIN change - iOS auto-reconnects - // Timeout is expected since device disconnects before write callback - do { - try await settingsService.reboot() - } catch BLEError.operationTimeout { - // Expected - device reboots before BLE write callback arrives - } - } catch { - errorMessage = error.userFacingMessage - // Revert - hasInitialized = false - pinType = currentPinType - Task { @MainActor in - hasInitialized = true - } - } - isChangingPin = false - customPin = "" + // Reboot device to apply PIN change - iOS auto-reconnects + // Timeout is expected since device disconnects before write callback + do { + try await settingsService.reboot() + } catch BLEError.operationTimeout { + // Expected - device reboots before BLE write callback arrives } + } catch { + errorMessage = error.userFacingMessage + // Revert + hasInitialized = false + pinType = currentPinType + Task { @MainActor in + hasInitialized = true + } + } + isChangingPin = false + customPin = "" } + } - private func renameDevice() { - isRenaming = true - Task { @MainActor in - defer { isRenaming = false } + private func renameDevice() { + isRenaming = true + Task { @MainActor in + defer { isRenaming = false } - do { - try await appState.connectionManager.renameCurrentDevice() - } catch { - // User cancelled or rename failed - no error to show - // Rename is optional and user can try again - } - } + do { + try await appState.connectionManager.renameCurrentDevice() + } catch { + // User cancelled or rename failed - no error to show + // Rename is optional and user can try again + } } + } } diff --git a/MC1/Views/Settings/Sections/ConfigExportImportSection.swift b/MC1/Views/Settings/Sections/ConfigExportImportSection.swift index 7c17721e..d4800cdd 100644 --- a/MC1/Views/Settings/Sections/ConfigExportImportSection.swift +++ b/MC1/Views/Settings/Sections/ConfigExportImportSection.swift @@ -1,30 +1,30 @@ -import SwiftUI import MC1Services +import SwiftUI struct ConfigExportImportSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - private var isDisabled: Bool { - appState.connectionState != .ready - } + private var isDisabled: Bool { + appState.connectionState != .ready + } - var body: some View { - Section { - NavigationLink(value: SettingsSubpage.configExport) { - TintedLabel(L10n.Settings.ConfigExport.export, systemImage: "square.and.arrow.up") - } - .disabled(isDisabled) + var body: some View { + Section { + NavigationLink(value: SettingsSubpage.configExport) { + TintedLabel(L10n.Settings.ConfigExport.export, systemImage: "square.and.arrow.up") + } + .disabled(isDisabled) - NavigationLink(value: SettingsSubpage.configImport) { - TintedLabel(L10n.Settings.ConfigImport.importConfig, systemImage: "square.and.arrow.down") - } - .disabled(isDisabled) - } header: { - Text(L10n.Settings.ConfigExport.sectionTitle) - } footer: { - Text(L10n.Settings.ConfigExport.sectionFooter) - } - .themedRowBackground(theme) + NavigationLink(value: SettingsSubpage.configImport) { + TintedLabel(L10n.Settings.ConfigImport.importConfig, systemImage: "square.and.arrow.down") + } + .disabled(isDisabled) + } header: { + Text(L10n.Settings.ConfigExport.sectionTitle) + } footer: { + Text(L10n.Settings.ConfigExport.sectionFooter) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Settings/Sections/DangerZoneSection.swift b/MC1/Views/Settings/Sections/DangerZoneSection.swift index 21515393..b0933091 100644 --- a/MC1/Views/Settings/Sections/DangerZoneSection.swift +++ b/MC1/Views/Settings/Sections/DangerZoneSection.swift @@ -1,122 +1,122 @@ -import SwiftUI import MC1Services +import SwiftUI /// Destructive device actions struct DangerZoneSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var viewModel = DangerZoneViewModel() + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var viewModel = DangerZoneViewModel() - var body: some View { - Section { - Button(role: .destructive) { - Task { await viewModel.fetchUnfavoritedCount() } - } label: { - if viewModel.isRemovingUnfavorited { - HStack { - ProgressView() - Text(L10n.Settings.DangerZone.removing) - } - } else if viewModel.showRemoveSuccess { - Label(L10n.Settings.DangerZone.removed, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } else { - Label(L10n.Settings.DangerZone.removeUnfavorited, systemImage: "person.2.slash") - } - } - .radioDisabled( - for: appState.connectionState, - or: viewModel.isRemovingUnfavorited || viewModel.showRemoveSuccess - ) + var body: some View { + Section { + Button(role: .destructive) { + Task { await viewModel.fetchUnfavoritedCount() } + } label: { + if viewModel.isRemovingUnfavorited { + HStack { + ProgressView() + Text(L10n.Settings.DangerZone.removing) + } + } else if viewModel.showRemoveSuccess { + Label(L10n.Settings.DangerZone.removed, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Label(L10n.Settings.DangerZone.removeUnfavorited, systemImage: "person.2.slash") + } + } + .radioDisabled( + for: appState.connectionState, + or: viewModel.isRemovingUnfavorited || viewModel.showRemoveSuccess + ) - Button(role: .destructive) { - viewModel.showingForgetConfirmation = true - } label: { - Label(L10n.Settings.DangerZone.forgetDevice, systemImage: "trash") - } + Button(role: .destructive) { + viewModel.showingForgetConfirmation = true + } label: { + Label(L10n.Settings.DangerZone.forgetDevice, systemImage: "trash") + } - Button(role: .destructive) { - viewModel.showingResetAlert = true - } label: { - if viewModel.isResetting { - HStack { - ProgressView() - Text(L10n.Settings.DangerZone.resetting) - } - } else { - Label(L10n.Settings.DangerZone.factoryReset, systemImage: "exclamationmark.triangle") - } - } - .radioDisabled(for: appState.connectionState, or: viewModel.isResetting) - } header: { - Text(L10n.Settings.DangerZone.header) - } footer: { - Text(L10n.Settings.DangerZone.footer) - } - .themedRowBackground(theme) - .task { - viewModel.configure( - settingsService: { appState.services?.settingsService }, - connectedDevice: { appState.connectedDevice }, - connectionManager: appState.connectionManager - ) - } - .confirmationDialog( - L10n.Settings.DangerZone.Dialog.Forget.title, - isPresented: $viewModel.showingForgetConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Settings.DangerZone.Dialog.Forget.keepData, role: .destructive) { - forgetDevice(deleteData: false) - } - Button(L10n.Settings.DangerZone.Dialog.Forget.deleteAll, role: .destructive) { - forgetDevice(deleteData: true) - } - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - } message: { - Text(L10n.Settings.DangerZone.Dialog.Forget.message) - } - .alert(L10n.Settings.DangerZone.Alert.Reset.title, isPresented: $viewModel.showingResetAlert) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.DangerZone.Alert.Reset.confirm, role: .destructive) { - Task { - if await viewModel.factoryReset() { - dismiss() - } - } - } - } message: { - Text(L10n.Settings.DangerZone.Alert.Reset.message) - } - .alert( - L10n.Settings.DangerZone.Alert.RemoveUnfavorited.title, - isPresented: $viewModel.showingRemoveUnfavoritedAlert - ) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.DangerZone.Alert.RemoveUnfavorited.confirm, role: .destructive) { - viewModel.removeUnfavoritedNodes() - } - } message: { - Text(L10n.Settings.DangerZone.Alert.RemoveUnfavorited.message(viewModel.unfavoritedCount)) - } - .alert( - L10n.Settings.DangerZone.Alert.RemoveUnfavorited.resultTitle, - isPresented: $viewModel.showRemoveResult - ) { - Button(L10n.Localizable.Common.ok) { } - } message: { - Text(viewModel.removeResult ?? "") + Button(role: .destructive) { + viewModel.showingResetAlert = true + } label: { + if viewModel.isResetting { + HStack { + ProgressView() + Text(L10n.Settings.DangerZone.resetting) + } + } else { + Label(L10n.Settings.DangerZone.factoryReset, systemImage: "exclamationmark.triangle") } - .onDisappear { viewModel.cancelPendingRemoval() } - .errorAlert($viewModel.errorMessage) + } + .radioDisabled(for: appState.connectionState, or: viewModel.isResetting) + } header: { + Text(L10n.Settings.DangerZone.header) + } footer: { + Text(L10n.Settings.DangerZone.footer) } - - private func forgetDevice(deleteData: Bool) { + .themedRowBackground(theme) + .task { + viewModel.configure( + settingsService: { appState.services?.settingsService }, + connectedDevice: { appState.connectedDevice }, + connectionManager: appState.connectionManager + ) + } + .confirmationDialog( + L10n.Settings.DangerZone.Dialog.Forget.title, + isPresented: $viewModel.showingForgetConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Settings.DangerZone.Dialog.Forget.keepData, role: .destructive) { + forgetDevice(deleteData: false) + } + Button(L10n.Settings.DangerZone.Dialog.Forget.deleteAll, role: .destructive) { + forgetDevice(deleteData: true) + } + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + } message: { + Text(L10n.Settings.DangerZone.Dialog.Forget.message) + } + .alert(L10n.Settings.DangerZone.Alert.Reset.title, isPresented: $viewModel.showingResetAlert) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.DangerZone.Alert.Reset.confirm, role: .destructive) { Task { - if await viewModel.forgetDevice(deleteData: deleteData) { - dismiss() - } + if await viewModel.factoryReset() { + dismiss() + } } + } + } message: { + Text(L10n.Settings.DangerZone.Alert.Reset.message) + } + .alert( + L10n.Settings.DangerZone.Alert.RemoveUnfavorited.title, + isPresented: $viewModel.showingRemoveUnfavoritedAlert + ) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.DangerZone.Alert.RemoveUnfavorited.confirm, role: .destructive) { + viewModel.removeUnfavoritedNodes() + } + } message: { + Text(L10n.Settings.DangerZone.Alert.RemoveUnfavorited.message(viewModel.unfavoritedCount)) + } + .alert( + L10n.Settings.DangerZone.Alert.RemoveUnfavorited.resultTitle, + isPresented: $viewModel.showRemoveResult + ) { + Button(L10n.Localizable.Common.ok) {} + } message: { + Text(viewModel.removeResult ?? "") + } + .onDisappear { viewModel.cancelPendingRemoval() } + .errorAlert($viewModel.errorMessage) + } + + private func forgetDevice(deleteData: Bool) { + Task { + if await viewModel.forgetDevice(deleteData: deleteData) { + dismiss() + } } + } } diff --git a/MC1/Views/Settings/Sections/DangerZoneViewModel.swift b/MC1/Views/Settings/Sections/DangerZoneViewModel.swift index a163469d..d36f1f49 100644 --- a/MC1/Views/Settings/Sections/DangerZoneViewModel.swift +++ b/MC1/Views/Settings/Sections/DangerZoneViewModel.swift @@ -1,125 +1,131 @@ -import SwiftUI import MC1Services +import SwiftUI @Observable @MainActor final class DangerZoneViewModel { - var showingForgetConfirmation = false - var showingResetAlert = false - var isResetting = false - var errorMessage: String? - var showingRemoveUnfavoritedAlert = false - var isRemovingUnfavorited = false - var showRemoveSuccess = false - var unfavoritedCount = 0 - var showRemoveResult = false - var removeResult: String? - - private var removeTask: Task? - - // MARK: - Dependencies - - private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } - var settingsService: SettingsService? { settingsServiceProvider() } - private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } - var connectedDevice: DeviceDTO? { connectedDeviceProvider() } - private var connectionManager: ConnectionManager? - - /// Grace period for the radio to reboot after a factory reset before local cleanup. - private static let resetRebootGracePeriod: Duration = .seconds(1) - /// How long the transient "Removed" confirmation stays on the button label. - private static let removeSuccessDisplayDuration: Duration = .seconds(1.5) - - func configure( - settingsService: @escaping @MainActor () -> SettingsService?, - connectedDevice: @escaping @MainActor () -> DeviceDTO?, - connectionManager: ConnectionManager - ) { - self.settingsServiceProvider = settingsService - self.connectedDeviceProvider = connectedDevice - self.connectionManager = connectionManager - } + var showingForgetConfirmation = false + var showingResetAlert = false + var isResetting = false + var errorMessage: String? + var showingRemoveUnfavoritedAlert = false + var isRemovingUnfavorited = false + var showRemoveSuccess = false + var unfavoritedCount = 0 + var showRemoveResult = false + var removeResult: String? + + private var removeTask: Task? + + // MARK: - Dependencies + + private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } + var settingsService: SettingsService? { + settingsServiceProvider() + } + + private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } + var connectedDevice: DeviceDTO? { + connectedDeviceProvider() + } + + private var connectionManager: ConnectionManager? + + /// Grace period for the radio to reboot after a factory reset before local cleanup. + private static let resetRebootGracePeriod: Duration = .seconds(1) + /// How long the transient "Removed" confirmation stays on the button label. + private static let removeSuccessDisplayDuration: Duration = .seconds(1.5) - func cancelPendingRemoval() { - removeTask?.cancel() + func configure( + settingsService: @escaping @MainActor () -> SettingsService?, + connectedDevice: @escaping @MainActor () -> DeviceDTO?, + connectionManager: ConnectionManager + ) { + settingsServiceProvider = settingsService + connectedDeviceProvider = connectedDevice + self.connectionManager = connectionManager + } + + func cancelPendingRemoval() { + removeTask?.cancel() + } + + /// Returns true when the device was forgotten and the hosting page should dismiss. + func forgetDevice(deleteData: Bool) async -> Bool { + guard let connectionManager else { return false } + do { + try await connectionManager.forgetDevice(deleteData: deleteData) + return true + } catch { + errorMessage = error.userFacingMessage + return false } + } - /// Returns true when the device was forgotten and the hosting page should dismiss. - func forgetDevice(deleteData: Bool) async -> Bool { - guard let connectionManager else { return false } - do { - try await connectionManager.forgetDevice(deleteData: deleteData) - return true - } catch { - errorMessage = error.userFacingMessage - return false - } + /// Returns true when the reset flow finished and the hosting page should dismiss. + /// A nil service or device ID mirrors a disconnected state. + func factoryReset() async -> Bool { + guard let settingsService, + let deviceID = connectedDevice?.id, + let connectionManager else { + errorMessage = L10n.Settings.DangerZone.Error.servicesUnavailable + return false } - /// Returns true when the reset flow finished and the hosting page should dismiss. - /// A nil service or device ID mirrors a disconnected state. - func factoryReset() async -> Bool { - guard let settingsService = settingsService, - let deviceID = connectedDevice?.id, - let connectionManager else { - errorMessage = L10n.Settings.DangerZone.Error.servicesUnavailable - return false - } + isResetting = true + defer { isResetting = false } - isResetting = true - defer { isResetting = false } + // Send reset command. The device typically reboots before responding, + // so a timeout/connection error here is expected, not a failure. + do { + try await settingsService.factoryReset() + try await Task.sleep(for: Self.resetRebootGracePeriod) + } catch { + // Expected: device reboots before sending OK response + } - // Send reset command. The device typically reboots before responding, - // so a timeout/connection error here is expected, not a failure. - do { - try await settingsService.factoryReset() - try await Task.sleep(for: Self.resetRebootGracePeriod) - } catch { - // Expected: device reboots before sending OK response - } + // Always clean up: remove from ASK, disconnect, delete from SwiftData + await connectionManager.forgetDevice(id: deviceID) + return true + } - // Always clean up: remove from ASK, disconnect, delete from SwiftData - await connectionManager.forgetDevice(id: deviceID) - return true + func fetchUnfavoritedCount() async { + guard let connectionManager else { return } + do { + unfavoritedCount = try await connectionManager.unfavoritedNodeCount() + if unfavoritedCount == 0 { + removeResult = L10n.Settings.DangerZone.Alert.RemoveUnfavorited.noneFound + showRemoveResult = true + } else { + showingRemoveUnfavoritedAlert = true + } + } catch { + errorMessage = error.userFacingMessage } + } - func fetchUnfavoritedCount() async { - guard let connectionManager else { return } - do { - unfavoritedCount = try await connectionManager.unfavoritedNodeCount() - if unfavoritedCount == 0 { - removeResult = L10n.Settings.DangerZone.Alert.RemoveUnfavorited.noneFound - showRemoveResult = true - } else { - showingRemoveUnfavoritedAlert = true - } - } catch { - errorMessage = error.userFacingMessage + func removeUnfavoritedNodes() { + guard let connectionManager else { return } + isRemovingUnfavorited = true + removeTask = Task { + defer { isRemovingUnfavorited = false } + do { + let result = try await connectionManager.removeUnfavoritedNodes() + isRemovingUnfavorited = false + if result.removed == result.total { + withAnimation { showRemoveSuccess = true } + try await Task.sleep(for: Self.removeSuccessDisplayDuration) + withAnimation { showRemoveSuccess = false } + } else { + removeResult = L10n.Settings.DangerZone.Alert.RemoveUnfavorited + .partial(result.removed, result.total) + showRemoveResult = true } - } - - func removeUnfavoritedNodes() { - guard let connectionManager else { return } - isRemovingUnfavorited = true - removeTask = Task { - defer { isRemovingUnfavorited = false } - do { - let result = try await connectionManager.removeUnfavoritedNodes() - isRemovingUnfavorited = false - if result.removed == result.total { - withAnimation { showRemoveSuccess = true } - try await Task.sleep(for: Self.removeSuccessDisplayDuration) - withAnimation { showRemoveSuccess = false } - } else { - removeResult = L10n.Settings.DangerZone.Alert.RemoveUnfavorited - .partial(result.removed, result.total) - showRemoveResult = true - } - } catch { - if !(error is CancellationError) { - errorMessage = error.userFacingMessage - } - } + } catch { + if !(error is CancellationError) { + errorMessage = error.userFacingMessage } + } } + } } diff --git a/MC1/Views/Settings/Sections/DefaultFloodScopeSection.swift b/MC1/Views/Settings/Sections/DefaultFloodScopeSection.swift index 2adf5328..0ac348cc 100644 --- a/MC1/Views/Settings/Sections/DefaultFloodScopeSection.swift +++ b/MC1/Views/Settings/Sections/DefaultFloodScopeSection.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Section for configuring the device's persisted default flood scope (firmware v11+). /// @@ -8,190 +8,190 @@ import MC1Services /// Selections are sent via ``SettingsService/setDefaultFloodScopeVerified(name:)`` /// and the accepted value is cached in ``DeviceDTO/defaultFloodScopeName``. struct DefaultFloodScopeSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var isApplying = false - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var isDiscovering = false - @State private var discoveryMessage: String? - @State private var discoveryTask: Task? - @State private var showingRegionManagement = false - - var body: some View { - Section { - disabledRow - - ForEach(sortedKnownRegions, id: \.self) { region in - scopeRow(region) - } - - discoveryStatusRow - discoverButton - manageRegionsButton - } header: { - Text(L10n.Settings.DefaultFloodScope.header) - } footer: { - Text(L10n.Settings.DefaultFloodScope.footer) - } - .themedRowBackground(theme) - .radioDisabled(for: appState.connectionState, or: isApplying) - .errorAlert($errorMessage) - .retryAlert(retryAlert) - .onDisappear { discoveryTask?.cancel() } - .navigationDestination(isPresented: $showingRegionManagement) { - RegionManagementView( - knownRegions: appState.connectedDevice?.knownRegions ?? [], - isDiscovering: $isDiscovering, - discoveryMessage: $discoveryMessage, - onRemoveRegion: { region in appState.connectionManager.removeKnownRegion(region) }, - onAddRegion: { region in appState.connectionManager.addKnownRegion(region) }, - onDiscoverTapped: runDiscovery - ) - } + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var isApplying = false + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var isDiscovering = false + @State private var discoveryMessage: String? + @State private var discoveryTask: Task? + @State private var showingRegionManagement = false + + var body: some View { + Section { + disabledRow + + ForEach(sortedKnownRegions, id: \.self) { region in + scopeRow(region) + } + + discoveryStatusRow + discoverButton + manageRegionsButton + } header: { + Text(L10n.Settings.DefaultFloodScope.header) + } footer: { + Text(L10n.Settings.DefaultFloodScope.footer) + } + .themedRowBackground(theme) + .radioDisabled(for: appState.connectionState, or: isApplying) + .errorAlert($errorMessage) + .retryAlert(retryAlert) + .onDisappear { discoveryTask?.cancel() } + .navigationDestination(isPresented: $showingRegionManagement) { + RegionManagementView( + knownRegions: appState.connectedDevice?.knownRegions ?? [], + isDiscovering: $isDiscovering, + discoveryMessage: $discoveryMessage, + onRemoveRegion: { region in appState.connectionManager.removeKnownRegion(region) }, + onAddRegion: { region in appState.connectionManager.addKnownRegion(region) }, + onDiscoverTapped: runDiscovery + ) } + } - // MARK: - Rows + // MARK: - Rows - private var disabledRow: some View { - Button { - apply(name: nil) - } label: { - row(title: L10n.Settings.DefaultFloodScope.disabled, selected: currentScope == nil) - } - .buttonStyle(.plain) + private var disabledRow: some View { + Button { + apply(name: nil) + } label: { + row(title: L10n.Settings.DefaultFloodScope.disabled, selected: currentScope == nil) } - - private func scopeRow(_ region: String) -> some View { - Button { - apply(name: region) - } label: { - row(title: region, selected: currentScope == region) - } - .buttonStyle(.plain) + .buttonStyle(.plain) + } + + private func scopeRow(_ region: String) -> some View { + Button { + apply(name: region) + } label: { + row(title: region, selected: currentScope == region) } - - @ViewBuilder - private var discoveryStatusRow: some View { - if isDiscovering { - HStack { - ProgressView() - Text(L10n.Chats.Chats.ChannelInfo.Region.discovering) - .foregroundStyle(.secondary) - } - } else if let discoveryMessage { - Text(discoveryMessage) - .font(.caption) - .foregroundStyle(.secondary) - } + .buttonStyle(.plain) + } + + @ViewBuilder + private var discoveryStatusRow: some View { + if isDiscovering { + HStack { + ProgressView() + Text(L10n.Chats.Chats.ChannelInfo.Region.discovering) + .foregroundStyle(.secondary) + } + } else if let discoveryMessage { + Text(discoveryMessage) + .font(.caption) + .foregroundStyle(.secondary) } - - private var discoverButton: some View { - Button( - L10n.Chats.Chats.ChannelInfo.Region.discover, - systemImage: "antenna.radiowaves.left.and.right", - action: runDiscovery - ) - .disabled(isDiscovering) + } + + private var discoverButton: some View { + Button( + L10n.Chats.Chats.ChannelInfo.Region.discover, + systemImage: "antenna.radiowaves.left.and.right", + action: runDiscovery + ) + .disabled(isDiscovering) + } + + private var manageRegionsButton: some View { + Button(L10n.Chats.Chats.ChannelInfo.Region.manageRegions, systemImage: "list.bullet") { + showingRegionManagement = true } - - private var manageRegionsButton: some View { - Button(L10n.Chats.Chats.ChannelInfo.Region.manageRegions, systemImage: "list.bullet") { - showingRegionManagement = true - } + } + + private func row(title: String, selected: Bool) -> some View { + HStack { + Text(title) + .foregroundStyle(Color.primary) + Spacer() + if selected { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } } + .contentShape(.rect) + } - private func row(title: String, selected: Bool) -> some View { - HStack { - Text(title) - .foregroundStyle(Color.primary) - Spacer() - if selected { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - .contentShape(.rect) - } + // MARK: - Derived state - // MARK: - Derived state + private var currentScope: String? { + appState.connectedDevice?.defaultFloodScopeName + } - private var currentScope: String? { - appState.connectedDevice?.defaultFloodScopeName - } + private var sortedKnownRegions: [String] { + (appState.connectedDevice?.knownRegions ?? []) + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } - private var sortedKnownRegions: [String] { - (appState.connectedDevice?.knownRegions ?? []) - .sorted { $0.localizedStandardCompare($1) == .orderedAscending } - } + // MARK: - Actions - // MARK: - Actions - - private func apply(name: String?) { - isApplying = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } - _ = try await settingsService.setDefaultFloodScopeVerified(name: name) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { apply(name: name) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - } - isApplying = false + private func apply(name: String?) { + isApplying = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected } + _ = try await settingsService.setDefaultFloodScopeVerified(name: name) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { apply(name: name) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + } + isApplying = false } - - private func runDiscovery() { - discoveryTask?.cancel() - discoveryTask = Task { - isDiscovering = true - discoveryMessage = nil - defer { isDiscovering = false } - - guard let session = appState.services?.session, - let contactService = appState.services?.contactService, - let radioID = appState.connectedDevice?.radioID else { - return - } - - let outcome = await RegionDiscoveryService.discover( - session: session, - contactService: contactService, - dataStore: appState.offlineDataStore, - radioID: radioID, - knownRegions: appState.connectedDevice?.knownRegions ?? [], - supportsAdHocRequest: appState.connectedDevice?.supportsAdHocRepeaterRequest ?? false - ) - - guard !Task.isCancelled else { return } - - switch outcome { - case .sendFailed: - break - case .noRepeatersResponded: - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noRepeatersResponded - case .errorLoadingRepeaters: - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errLoadingRepeaters - case let .completed(newRegions, allRepeatersTableFull): - if newRegions.isEmpty && allRepeatersTableFull { - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errRadioContactsFull - } else if newRegions.isEmpty { - discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noNewRegions - } else { - for region in newRegions { - appState.connectionManager.addKnownRegion(region) - } - } - } + } + + private func runDiscovery() { + discoveryTask?.cancel() + discoveryTask = Task { + isDiscovering = true + discoveryMessage = nil + defer { isDiscovering = false } + + guard let session = appState.services?.session, + let contactService = appState.services?.contactService, + let radioID = appState.connectedDevice?.radioID else { + return + } + + let outcome = await RegionDiscoveryService.discover( + session: session, + contactService: contactService, + dataStore: appState.offlineDataStore, + radioID: radioID, + knownRegions: appState.connectedDevice?.knownRegions ?? [], + supportsAdHocRequest: appState.connectedDevice?.supportsAdHocRepeaterRequest ?? false + ) + + guard !Task.isCancelled else { return } + + switch outcome { + case .sendFailed: + break + case .noRepeatersResponded: + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noRepeatersResponded + case .errorLoadingRepeaters: + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errLoadingRepeaters + case let .completed(newRegions, allRepeatersTableFull): + if newRegions.isEmpty, allRepeatersTableFull { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.errRadioContactsFull + } else if newRegions.isEmpty { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noNewRegions + } else { + for region in newRegions { + appState.connectionManager.addKnownRegion(region) + } } + } } + } } diff --git a/MC1/Views/Settings/Sections/DeviceActionsSection.swift b/MC1/Views/Settings/Sections/DeviceActionsSection.swift index 72c5d54e..b1374764 100644 --- a/MC1/Views/Settings/Sections/DeviceActionsSection.swift +++ b/MC1/Views/Settings/Sections/DeviceActionsSection.swift @@ -1,57 +1,57 @@ -import SwiftUI import MC1Services +import SwiftUI /// Device maintenance actions (reboot) struct DeviceActionsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var showingRebootAlert = false - @State private var isRebooting = false - @State private var errorMessage: String? + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var showingRebootAlert = false + @State private var isRebooting = false + @State private var errorMessage: String? - var body: some View { - Section { - Button { - showingRebootAlert = true - } label: { - if isRebooting { - HStack { - ProgressView() - Text(L10n.Settings.DeviceActions.rebooting) - } - } else { - Label(L10n.Settings.DeviceActions.rebootDevice, systemImage: "arrow.clockwise") - } - } - .radioDisabled(for: appState.connectionState, or: isRebooting) - } header: { - Text(L10n.Settings.DeviceActions.header) - } - .themedRowBackground(theme) - .alert(L10n.Settings.DeviceActions.Alert.Reboot.title, isPresented: $showingRebootAlert) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.DeviceActions.Alert.Reboot.confirm) { - rebootDevice() - } - } message: { - Text(L10n.Settings.DeviceActions.Alert.Reboot.message) + var body: some View { + Section { + Button { + showingRebootAlert = true + } label: { + if isRebooting { + HStack { + ProgressView() + Text(L10n.Settings.DeviceActions.rebooting) + } + } else { + Label(L10n.Settings.DeviceActions.rebootDevice, systemImage: "arrow.clockwise") } - .errorAlert($errorMessage) + } + .radioDisabled(for: appState.connectionState, or: isRebooting) + } header: { + Text(L10n.Settings.DeviceActions.header) } + .themedRowBackground(theme) + .alert(L10n.Settings.DeviceActions.Alert.Reboot.title, isPresented: $showingRebootAlert) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.DeviceActions.Alert.Reboot.confirm) { + rebootDevice() + } + } message: { + Text(L10n.Settings.DeviceActions.Alert.Reboot.message) + } + .errorAlert($errorMessage) + } - private func rebootDevice() { - guard let settingsService = appState.services?.settingsService else { return } + private func rebootDevice() { + guard let settingsService = appState.services?.settingsService else { return } - isRebooting = true - Task { - defer { isRebooting = false } - do { - try await settingsService.reboot() - } catch BLEError.operationTimeout { - // Expected - device reboots before BLE write callback arrives - } catch { - errorMessage = error.userFacingMessage - } - } + isRebooting = true + Task { + defer { isRebooting = false } + do { + try await settingsService.reboot() + } catch BLEError.operationTimeout { + // Expected - device reboots before BLE write callback arrives + } catch { + errorMessage = error.userFacingMessage + } } + } } diff --git a/MC1/Views/Settings/Sections/DeviceIdentitySection.swift b/MC1/Views/Settings/Sections/DeviceIdentitySection.swift index 5e01484a..e61a5a4b 100644 --- a/MC1/Views/Settings/Sections/DeviceIdentitySection.swift +++ b/MC1/Views/Settings/Sections/DeviceIdentitySection.swift @@ -2,29 +2,29 @@ import SwiftUI /// Section for managing the device's cryptographic identity struct DeviceIdentitySection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Binding var showingImportKeySheet: Bool - @Binding var showingRegenerateSheet: Bool + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Binding var showingImportKeySheet: Bool + @Binding var showingRegenerateSheet: Bool - var body: some View { - Section { - Button { - showingImportKeySheet = true - } label: { - Label(L10n.Settings.ImportKey.title, systemImage: "square.and.arrow.down") - } - .radioDisabled(for: appState.connectionState) + var body: some View { + Section { + Button { + showingImportKeySheet = true + } label: { + Label(L10n.Settings.ImportKey.title, systemImage: "square.and.arrow.down") + } + .radioDisabled(for: appState.connectionState) - Button { - showingRegenerateSheet = true - } label: { - Label(L10n.Settings.RegenerateIdentity.title, systemImage: "arrow.triangle.2.circlepath") - } - .radioDisabled(for: appState.connectionState) - } header: { - Text(L10n.Settings.RegenerateIdentity.header) - } - .themedRowBackground(theme) + Button { + showingRegenerateSheet = true + } label: { + Label(L10n.Settings.RegenerateIdentity.title, systemImage: "arrow.triangle.2.circlepath") + } + .radioDisabled(for: appState.connectionState) + } header: { + Text(L10n.Settings.RegenerateIdentity.header) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Settings/Sections/DiagnosticsSection.swift b/MC1/Views/Settings/Sections/DiagnosticsSection.swift index 1d3bf91a..e2643fba 100644 --- a/MC1/Views/Settings/Sections/DiagnosticsSection.swift +++ b/MC1/Views/Settings/Sections/DiagnosticsSection.swift @@ -3,77 +3,77 @@ import SwiftUI /// Settings section for diagnostic tools including log export and clearing struct DiagnosticsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Binding var exportedFile: ExportedLogFile? - let isSidebar: Bool - @State private var isExporting = false - @State private var showingClearLogsAlert = false - @State private var errorMessage: String? + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Binding var exportedFile: ExportedLogFile? + let isSidebar: Bool + @State private var isExporting = false + @State private var showingClearLogsAlert = false + @State private var errorMessage: String? - var body: some View { - Section { - Button { - exportLogs() - } label: { - HStack { - TintedLabel(L10n.Settings.Diagnostics.exportLogs, systemImage: "square.and.arrow.up") - Spacer() - if isExporting { - ProgressView() - } - } - } - .disabled(isExporting) - - Button(role: .destructive) { - showingClearLogsAlert = true - } label: { - Label(L10n.Settings.Diagnostics.clearLogs, systemImage: "trash") - } - } header: { - Text(L10n.Settings.Diagnostics.header) - } footer: { - Text(L10n.Settings.Diagnostics.footer) - } - .themedRowBackground(theme, flatten: isSidebar) - .alert(L10n.Settings.Diagnostics.Alert.Clear.title, isPresented: $showingClearLogsAlert) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.Diagnostics.Alert.Clear.confirm, role: .destructive) { - clearDebugLogs() - } - } message: { - Text(L10n.Settings.Diagnostics.Alert.Clear.message) + var body: some View { + Section { + Button { + exportLogs() + } label: { + HStack { + TintedLabel(L10n.Settings.Diagnostics.exportLogs, systemImage: "square.and.arrow.up") + Spacer() + if isExporting { + ProgressView() + } } - .errorAlert($errorMessage) + } + .disabled(isExporting) + + Button(role: .destructive) { + showingClearLogsAlert = true + } label: { + Label(L10n.Settings.Diagnostics.clearLogs, systemImage: "trash") + } + } header: { + Text(L10n.Settings.Diagnostics.header) + } footer: { + Text(L10n.Settings.Diagnostics.footer) } + .themedRowBackground(theme, flatten: isSidebar) + .alert(L10n.Settings.Diagnostics.Alert.Clear.title, isPresented: $showingClearLogsAlert) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.Diagnostics.Alert.Clear.confirm, role: .destructive) { + clearDebugLogs() + } + } message: { + Text(L10n.Settings.Diagnostics.Alert.Clear.message) + } + .errorAlert($errorMessage) + } - private func exportLogs() { - let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() - isExporting = true + private func exportLogs() { + let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() + isExporting = true - Task { @MainActor in - if let url = await LogExportService.createExportFile( - appState: appState, - persistenceStore: dataStore - ) { - exportedFile = ExportedLogFile(url: url) - } else { - errorMessage = L10n.Settings.Diagnostics.Error.exportFailed - } - isExporting = false - } + Task { @MainActor in + if let url = await LogExportService.createExportFile( + appState: appState, + persistenceStore: dataStore + ) { + exportedFile = ExportedLogFile(url: url) + } else { + errorMessage = L10n.Settings.Diagnostics.Error.exportFailed + } + isExporting = false } + } - private func clearDebugLogs() { - let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() + private func clearDebugLogs() { + let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() - Task { - do { - try await dataStore.clearDebugLogEntries() - } catch { - errorMessage = error.userFacingMessage - } - } + Task { + do { + try await dataStore.clearDebugLogEntries() + } catch { + errorMessage = error.userFacingMessage + } } + } } diff --git a/MC1/Views/Settings/Sections/DirectMessagesSettingsSection.swift b/MC1/Views/Settings/Sections/DirectMessagesSettingsSection.swift index 03f1ce45..4899f85c 100644 --- a/MC1/Views/Settings/Sections/DirectMessagesSettingsSection.swift +++ b/MC1/Views/Settings/Sections/DirectMessagesSettingsSection.swift @@ -1,70 +1,72 @@ -import SwiftUI import MC1Services +import SwiftUI /// Settings section for direct message acknowledgment count struct DirectMessagesSettingsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var isSaving = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var isSaving = false - private var device: DeviceDTO? { appState.connectedDevice } + private var device: DeviceDTO? { + appState.connectedDevice + } - var body: some View { - Section { - Picker(L10n.Settings.DirectMessages.acknowledgments, selection: acksBinding) { - Text("1").tag(1) - Text("2").tag(2) - } - .pickerStyle(.menu) - .radioDisabled(for: appState.connectionState, or: isSaving) - } header: { - Text(L10n.Settings.DirectMessages.header) - } footer: { - Text(L10n.Settings.DirectMessages.footer) - } - .themedRowBackground(theme) - .errorAlert($errorMessage) - .retryAlert(retryAlert) + var body: some View { + Section { + Picker(L10n.Settings.DirectMessages.acknowledgments, selection: acksBinding) { + Text("1").tag(1) + Text("2").tag(2) + } + .pickerStyle(.menu) + .radioDisabled(for: appState.connectionState, or: isSaving) + } header: { + Text(L10n.Settings.DirectMessages.header) + } footer: { + Text(L10n.Settings.DirectMessages.footer) } + .themedRowBackground(theme) + .errorAlert($errorMessage) + .retryAlert(retryAlert) + } - // MARK: - Binding + // MARK: - Binding - private var acksBinding: Binding { - Binding( - get: { Int(device?.multiAcks ?? 0) + 1 }, - set: { saveMultiAcks(UInt8($0 - 1)) } - ) - } + private var acksBinding: Binding { + Binding( + get: { Int(device?.multiAcks ?? 0) + 1 }, + set: { saveMultiAcks(UInt8($0 - 1)) } + ) + } - // MARK: - Save + // MARK: - Save - private func saveMultiAcks(_ value: UInt8) { - guard let device, let settingsService = appState.services?.settingsService else { return } + private func saveMultiAcks(_ value: UInt8) { + guard let device, let settingsService = appState.services?.settingsService else { return } - isSaving = true - Task { - do { - _ = try await settingsService.setOtherParamsVerified(from: device, multiAcks: value) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { saveMultiAcks(value) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - } - isSaving = false - } + isSaving = true + Task { + do { + _ = try await settingsService.setOtherParamsVerified(from: device, multiAcks: value) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { saveMultiAcks(value) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + } + isSaving = false } + } } #Preview { - Form { - DirectMessagesSettingsSection() - } + Form { + DirectMessagesSettingsSection() + } } diff --git a/MC1/Views/Settings/Sections/ExportedLogFile.swift b/MC1/Views/Settings/Sections/ExportedLogFile.swift index dc776557..2db51fdb 100644 --- a/MC1/Views/Settings/Sections/ExportedLogFile.swift +++ b/MC1/Views/Settings/Sections/ExportedLogFile.swift @@ -2,6 +2,6 @@ import Foundation /// Identifiable wrapper so a generated debug-log export file can drive `.sheet(item:)`. struct ExportedLogFile: Identifiable { - let id = UUID() - let url: URL + let id = UUID() + let url: URL } diff --git a/MC1/Views/Settings/Sections/ImportKeySheet.swift b/MC1/Views/Settings/Sections/ImportKeySheet.swift index 0dd23391..1e8ae43e 100644 --- a/MC1/Views/Settings/Sections/ImportKeySheet.swift +++ b/MC1/Views/Settings/Sections/ImportKeySheet.swift @@ -3,99 +3,99 @@ import SwiftUI /// Sheet for importing an existing Ed25519 private key onto the device struct ImportKeySheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var viewModel = ImportKeyViewModel() + @State private var viewModel = ImportKeyViewModel() - var body: some View { - NavigationStack { - Form { - explanationSection - .themedRowBackground(theme) - keyInputSection - .themedRowBackground(theme) - importSection - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.ImportKey.Sheet.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - dismiss() - } - .disabled(viewModel.isImporting) - } - } - .interactiveDismissDisabled(viewModel.isImporting) - .alert( - L10n.Settings.RegenerateIdentity.Alert.Replace.title, - isPresented: $viewModel.showingReplaceAlert - ) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.RegenerateIdentity.Alert.Replace.confirm, role: .destructive) { - Task { - if await viewModel.importKey() { - dismiss() - } - } - } - } message: { - Text(L10n.Settings.RegenerateIdentity.Alert.Replace.message) - } - .errorAlert($viewModel.errorMessage) - .sensoryFeedback(.success, trigger: viewModel.successTrigger) - .task { - viewModel.configure(settingsService: { appState.services?.settingsService }) + var body: some View { + NavigationStack { + Form { + explanationSection + .themedRowBackground(theme) + keyInputSection + .themedRowBackground(theme) + importSection + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.ImportKey.Sheet.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { + dismiss() + } + .disabled(viewModel.isImporting) + } + } + .interactiveDismissDisabled(viewModel.isImporting) + .alert( + L10n.Settings.RegenerateIdentity.Alert.Replace.title, + isPresented: $viewModel.showingReplaceAlert + ) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.RegenerateIdentity.Alert.Replace.confirm, role: .destructive) { + Task { + if await viewModel.importKey() { + dismiss() } + } } + } message: { + Text(L10n.Settings.RegenerateIdentity.Alert.Replace.message) + } + .errorAlert($viewModel.errorMessage) + .sensoryFeedback(.success, trigger: viewModel.successTrigger) + .task { + viewModel.configure(settingsService: { appState.services?.settingsService }) + } } + } - // MARK: - Sections + // MARK: - Sections - private var explanationSection: some View { - Section { - Text(L10n.Settings.ImportKey.Sheet.explanation) - .foregroundStyle(.secondary) - } + private var explanationSection: some View { + Section { + Text(L10n.Settings.ImportKey.Sheet.explanation) + .foregroundStyle(.secondary) } + } - private var keyInputSection: some View { - Section { - TextField(L10n.Settings.ImportKey.KeyInput.placeholder, text: $viewModel.hexInput, axis: .vertical) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .font(.system(.body, design: .monospaced)) - .lineLimit(3...6) - .onChange(of: viewModel.hexInput) { _, newValue in - viewModel.sanitizeInput(newValue) - } - } header: { - Text(L10n.Settings.ImportKey.KeyInput.label) + private var keyInputSection: some View { + Section { + TextField(L10n.Settings.ImportKey.KeyInput.placeholder, text: $viewModel.hexInput, axis: .vertical) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .font(.system(.body, design: .monospaced)) + .lineLimit(3...6) + .onChange(of: viewModel.hexInput) { _, newValue in + viewModel.sanitizeInput(newValue) } + } header: { + Text(L10n.Settings.ImportKey.KeyInput.label) } + } - private var importSection: some View { - Section { - Button { - viewModel.validateAndConfirm() - } label: { - HStack { - Spacer() - if viewModel.isImporting { - ProgressView() - .controlSize(.small) - Text(L10n.Settings.ImportKey.importing) - } else { - Text(L10n.Settings.ImportKey.import) - } - Spacer() - } - } - .disabled(viewModel.isImporting || viewModel.hexInput.isEmpty) + private var importSection: some View { + Section { + Button { + viewModel.validateAndConfirm() + } label: { + HStack { + Spacer() + if viewModel.isImporting { + ProgressView() + .controlSize(.small) + Text(L10n.Settings.ImportKey.importing) + } else { + Text(L10n.Settings.ImportKey.import) + } + Spacer() } + } + .disabled(viewModel.isImporting || viewModel.hexInput.isEmpty) } + } } diff --git a/MC1/Views/Settings/Sections/ImportKeyViewModel.swift b/MC1/Views/Settings/Sections/ImportKeyViewModel.swift index c7c74497..6f498acb 100644 --- a/MC1/Views/Settings/Sections/ImportKeyViewModel.swift +++ b/MC1/Views/Settings/Sections/ImportKeyViewModel.swift @@ -1,81 +1,83 @@ -import SwiftUI import MC1Services +import SwiftUI @Observable @MainActor final class ImportKeyViewModel { - var hexInput = "" - var isImporting = false - var showingReplaceAlert = false - var errorMessage: String? - var successTrigger = 0 + var hexInput = "" + var isImporting = false + var showingReplaceAlert = false + var errorMessage: String? + var successTrigger = 0 - private var validatedKeyData: Data? + private var validatedKeyData: Data? - /// Restricts the pasted key text to hex digits. - func sanitizeInput(_ newValue: String) { - let filtered = String(newValue.uppercased().filter { $0.isASCII && $0.isHexDigit }) - if filtered != newValue { - hexInput = filtered - } + /// Restricts the pasted key text to hex digits. + func sanitizeInput(_ newValue: String) { + let filtered = String(newValue.uppercased().filter { $0.isASCII && $0.isHexDigit }) + if filtered != newValue { + hexInput = filtered } + } - func validateAndConfirm() { - // Parse hex and validate length - guard let keyData = Data(hexString: hexInput), - keyData.count == ProtocolLimits.privateKeySize else { - errorMessage = L10n.Settings.ImportKey.Error.invalidHex - return - } - - // Validate Ed25519 clamping - do { - try KeyGenerationService.validateExpandedKey(keyData) - } catch { - errorMessage = L10n.Settings.ImportKey.Error.invalidKey - return - } + func validateAndConfirm() { + // Parse hex and validate length + guard let keyData = Data(hexString: hexInput), + keyData.count == ProtocolLimits.privateKeySize else { + errorMessage = L10n.Settings.ImportKey.Error.invalidHex + return + } - validatedKeyData = keyData - showingReplaceAlert = true + // Validate Ed25519 clamping + do { + try KeyGenerationService.validateExpandedKey(keyData) + } catch { + errorMessage = L10n.Settings.ImportKey.Error.invalidKey + return } - // MARK: - Dependencies + validatedKeyData = keyData + showingReplaceAlert = true + } - private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } - var settingsService: SettingsService? { settingsServiceProvider() } + // MARK: - Dependencies - func configure(settingsService: @escaping @MainActor () -> SettingsService?) { - self.settingsServiceProvider = settingsService - } + private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } + var settingsService: SettingsService? { + settingsServiceProvider() + } + + func configure(settingsService: @escaping @MainActor () -> SettingsService?) { + settingsServiceProvider = settingsService + } - /// Returns true when the key was imported and the sheet should dismiss. - /// A nil service mirrors a disconnected state and is a no-op. - func importKey() async -> Bool { - guard let keyData = validatedKeyData, - let settingsService = settingsService else { return false } + /// Returns true when the key was imported and the sheet should dismiss. + /// A nil service mirrors a disconnected state and is a no-op. + func importKey() async -> Bool { + guard let keyData = validatedKeyData, + let settingsService else { return false } - isImporting = true - defer { isImporting = false } - do { - try await settingsService.importPrivateKey(keyData) - try await settingsService.refreshDeviceInfo() - successTrigger += 1 - return true - } catch let error as SettingsServiceError { - if case .sessionError(let meshError) = error, - case .featureDisabled = meshError { - errorMessage = L10n.Settings.RegenerateIdentity.Error.featureDisabled - } else if case .sessionError(let meshError) = error, - case .deviceError = meshError { - errorMessage = L10n.Settings.RegenerateIdentity.Error.deviceRejected - } else { - errorMessage = error.userFacingMessage - } - return false - } catch { - errorMessage = error.userFacingMessage - return false - } + isImporting = true + defer { isImporting = false } + do { + try await settingsService.importPrivateKey(keyData) + try await settingsService.refreshDeviceInfo() + successTrigger += 1 + return true + } catch let error as SettingsServiceError { + if case let .sessionError(meshError) = error, + case .featureDisabled = meshError { + errorMessage = L10n.Settings.RegenerateIdentity.Error.featureDisabled + } else if case let .sessionError(meshError) = error, + case .deviceError = meshError { + errorMessage = L10n.Settings.RegenerateIdentity.Error.deviceRejected + } else { + errorMessage = error.userFacingMessage + } + return false + } catch { + errorMessage = error.userFacingMessage + return false } + } } diff --git a/MC1/Views/Settings/Sections/LinkPreviewSettingsSection.swift b/MC1/Views/Settings/Sections/LinkPreviewSettingsSection.swift index 0c55aa58..ece33538 100644 --- a/MC1/Views/Settings/Sections/LinkPreviewSettingsSection.swift +++ b/MC1/Views/Settings/Sections/LinkPreviewSettingsSection.swift @@ -3,49 +3,49 @@ import SwiftUI /// Settings section for link preview preferences struct LinkPreviewSettingsSection: View { - @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.linkPreviewsEnabled.rawValue) private var previewsEnabled = AppStorageKey.defaultLinkPreviewsEnabled - @AppStorage(AppStorageKey.linkPreviewsAutoResolveDM.rawValue) private var autoResolveDM = AppStorageKey.defaultLinkPreviewsAutoResolveDM - @AppStorage(AppStorageKey.linkPreviewsAutoResolveChannels.rawValue) private var autoResolveChannels = AppStorageKey.defaultLinkPreviewsAutoResolveChannels - @AppStorage(AppStorageKey.showInlineImages.rawValue) private var showInlineImages = AppStorageKey.defaultShowInlineImages - @AppStorage(AppStorageKey.autoPlayGIFs.rawValue) private var autoPlayGIFs = AppStorageKey.defaultAutoPlayGIFs + @Environment(\.appTheme) private var theme + @AppStorage(AppStorageKey.linkPreviewsEnabled.rawValue) private var previewsEnabled = AppStorageKey.defaultLinkPreviewsEnabled + @AppStorage(AppStorageKey.linkPreviewsAutoResolveDM.rawValue) private var autoResolveDM = AppStorageKey.defaultLinkPreviewsAutoResolveDM + @AppStorage(AppStorageKey.linkPreviewsAutoResolveChannels.rawValue) private var autoResolveChannels = AppStorageKey.defaultLinkPreviewsAutoResolveChannels + @AppStorage(AppStorageKey.showInlineImages.rawValue) private var showInlineImages = AppStorageKey.defaultShowInlineImages + @AppStorage(AppStorageKey.autoPlayGIFs.rawValue) private var autoPlayGIFs = AppStorageKey.defaultAutoPlayGIFs - var body: some View { - Section { - Toggle(isOn: $previewsEnabled) { - TintedLabel(L10n.Settings.LinkPreviews.toggle, systemImage: "link") - } + var body: some View { + Section { + Toggle(isOn: $previewsEnabled) { + TintedLabel(L10n.Settings.LinkPreviews.toggle, systemImage: "link") + } - if previewsEnabled { - Toggle(L10n.Settings.LinkPreviews.showInDMs, isOn: $autoResolveDM) - Toggle(L10n.Settings.LinkPreviews.showInChannels, isOn: $autoResolveChannels) - } - } header: { - Text(L10n.Settings.LinkPreviews.header) - } footer: { - Text(L10n.Settings.LinkPreviews.footer) - } - .themedRowBackground(theme) + if previewsEnabled { + Toggle(L10n.Settings.LinkPreviews.showInDMs, isOn: $autoResolveDM) + Toggle(L10n.Settings.LinkPreviews.showInChannels, isOn: $autoResolveChannels) + } + } header: { + Text(L10n.Settings.LinkPreviews.header) + } footer: { + Text(L10n.Settings.LinkPreviews.footer) + } + .themedRowBackground(theme) - Section { - Toggle(isOn: $showInlineImages) { - TintedLabel(L10n.Settings.InlineImages.toggle, systemImage: "photo") - } + Section { + Toggle(isOn: $showInlineImages) { + TintedLabel(L10n.Settings.InlineImages.toggle, systemImage: "photo") + } - if showInlineImages { - Toggle(isOn: $autoPlayGIFs) { - TintedLabel(L10n.Settings.InlineImages.autoPlayGifs, systemImage: "play.square") - } - } - } footer: { - Text(L10n.Settings.InlineImages.footer) + if showInlineImages { + Toggle(isOn: $autoPlayGIFs) { + TintedLabel(L10n.Settings.InlineImages.autoPlayGifs, systemImage: "play.square") } - .themedRowBackground(theme) + } + } footer: { + Text(L10n.Settings.InlineImages.footer) } + .themedRowBackground(theme) + } } #Preview { - Form { - LinkPreviewSettingsSection() - } + Form { + LinkPreviewSettingsSection() + } } diff --git a/MC1/Views/Settings/Sections/LocationSettingsSection.swift b/MC1/Views/Settings/Sections/LocationSettingsSection.swift index 3d17ae28..f5650469 100644 --- a/MC1/Views/Settings/Sections/LocationSettingsSection.swift +++ b/MC1/Views/Settings/Sections/LocationSettingsSection.swift @@ -1,346 +1,346 @@ +import MC1Services import OSLog import SwiftUI -import MC1Services private let logger = Logger(subsystem: "com.mc1", category: "LocationSettings") /// Location settings: set location, share publicly, auto-update from GPS struct LocationSettingsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @Environment(\.openURL) private var openURL - @Binding var showingLocationPicker: Bool - @State private var shareLocation = false - @State private var autoUpdateLocation = false - @State private var gpsSource: GPSSource = .phone - @State private var deviceHasGPS = false - @State private var deviceGPSEnabled = false - @State private var errorMessage: String? - @State private var showLocationDeniedAlert = false - @State private var retryAlert = RetryAlertState() - @State private var isSaving = false - @State private var didLoad = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @Binding var showingLocationPicker: Bool + @State private var shareLocation = false + @State private var autoUpdateLocation = false + @State private var gpsSource: GPSSource = .phone + @State private var deviceHasGPS = false + @State private var deviceGPSEnabled = false + @State private var errorMessage: String? + @State private var showLocationDeniedAlert = false + @State private var retryAlert = RetryAlertState() + @State private var isSaving = false + @State private var didLoad = false - private let devicePreferenceStore = DevicePreferenceStore() + private let devicePreferenceStore = DevicePreferenceStore() - private var shouldPollDeviceGPS: Bool { - autoUpdateLocation && gpsSource == .device && deviceGPSEnabled - } + private var shouldPollDeviceGPS: Bool { + autoUpdateLocation && gpsSource == .device && deviceGPSEnabled + } - var body: some View { - Group { - Section { - Button { - showingLocationPicker = true - } label: { - HStack { - TintedLabel(L10n.Settings.Node.setLocation, systemImage: "mappin.and.ellipse") - Spacer() - if let device = appState.connectedDevice, - device.latitude != 0 || device.longitude != 0 { - Text(L10n.Settings.Node.locationSet) - .foregroundStyle(.secondary) - } else { - Text(L10n.Settings.Node.locationNotSet) - .foregroundStyle(.tertiary) - } - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) - .radioDisabled(for: appState.connectionState, or: isSaving || autoUpdateLocation) + var body: some View { + Group { + Section { + Button { + showingLocationPicker = true + } label: { + HStack { + TintedLabel(L10n.Settings.Node.setLocation, systemImage: "mappin.and.ellipse") + Spacer() + if let device = appState.connectedDevice, + device.latitude != 0 || device.longitude != 0 { + Text(L10n.Settings.Node.locationSet) + .foregroundStyle(.secondary) + } else { + Text(L10n.Settings.Node.locationNotSet) + .foregroundStyle(.tertiary) + } + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.primary) + .radioDisabled(for: appState.connectionState, or: isSaving || autoUpdateLocation) - Toggle(isOn: $shareLocation) { - TintedLabel(L10n.Settings.Node.shareLocationPublicly, systemImage: "location") - } - .onChange(of: shareLocation) { _, newValue in - guard didLoad else { return } - updateShareLocation(newValue) - } - .radioDisabled(for: appState.connectionState, or: isSaving) + Toggle(isOn: $shareLocation) { + TintedLabel(L10n.Settings.Node.shareLocationPublicly, systemImage: "location") + } + .onChange(of: shareLocation) { _, newValue in + guard didLoad else { return } + updateShareLocation(newValue) + } + .radioDisabled(for: appState.connectionState, or: isSaving) - Toggle(isOn: $autoUpdateLocation) { - TintedLabel(L10n.Settings.Location.autoUpdate, systemImage: "location.circle") - } - .onChange(of: autoUpdateLocation) { _, newValue in - handleAutoUpdateChange(newValue) - } - .radioDisabled(for: appState.connectionState, or: isSaving) + Toggle(isOn: $autoUpdateLocation) { + TintedLabel(L10n.Settings.Location.autoUpdate, systemImage: "location.circle") + } + .onChange(of: autoUpdateLocation) { _, newValue in + handleAutoUpdateChange(newValue) + } + .radioDisabled(for: appState.connectionState, or: isSaving) - if autoUpdateLocation { - if deviceHasGPS { - Picker(L10n.Settings.Location.gpsSource, selection: $gpsSource) { - Text(L10n.Settings.Location.GpsSource.phone).tag(GPSSource.phone) - Text(L10n.Settings.Location.GpsSource.device).tag(GPSSource.device) - } - .onChange(of: gpsSource) { oldValue, newValue in - handleGPSSourceChange(from: oldValue, to: newValue) - } - .radioDisabled(for: appState.connectionState, or: isSaving) - } else { - LabeledContent(L10n.Settings.Location.gpsSource) { - Text(L10n.Settings.Location.GpsSource.phone) - .foregroundStyle(.secondary) - } - } - } - } header: { - Text(L10n.Settings.Location.header) - } footer: { - Text(L10n.Settings.Location.footer) + if autoUpdateLocation { + if deviceHasGPS { + Picker(L10n.Settings.Location.gpsSource, selection: $gpsSource) { + Text(L10n.Settings.Location.GpsSource.phone).tag(GPSSource.phone) + Text(L10n.Settings.Location.GpsSource.device).tag(GPSSource.device) } - .themedRowBackground(theme) - - if deviceHasGPS { - Section { - Toggle(isOn: deviceGPSBinding) { - TintedLabel(L10n.Settings.Location.DeviceGps.toggle, systemImage: "location.circle") - } - .radioDisabled(for: appState.connectionState, or: isSaving) - } header: { - Text(L10n.Settings.Location.DeviceGps.header) - } footer: { - Text(L10n.Settings.Location.DeviceGps.footer) - } - .themedRowBackground(theme) + .onChange(of: gpsSource) { oldValue, newValue in + handleGPSSourceChange(from: oldValue, to: newValue) } - } - .task(id: startupTaskID) { - loadPreferences() - guard appState.canRunSettingsStartupReads else { - logger.debug("Deferring location settings startup reads until sync is less contended") - return + .radioDisabled(for: appState.connectionState, or: isSaving) + } else { + LabeledContent(L10n.Settings.Location.gpsSource) { + Text(L10n.Settings.Location.GpsSource.phone) + .foregroundStyle(.secondary) } - await loadDeviceGPSState() + } } - .task(id: shouldPollDeviceGPS) { - guard shouldPollDeviceGPS, - let settingsService = appState.services?.settingsService else { return } - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(3)) - guard let device = appState.connectedDevice, - device.latitude == 0, device.longitude == 0 else { break } - try? await settingsService.refreshDeviceInfo() - } + } header: { + Text(L10n.Settings.Location.header) + } footer: { + Text(L10n.Settings.Location.footer) + } + .themedRowBackground(theme) + + if deviceHasGPS { + Section { + Toggle(isOn: deviceGPSBinding) { + TintedLabel(L10n.Settings.Location.DeviceGps.toggle, systemImage: "location.circle") + } + .radioDisabled(for: appState.connectionState, or: isSaving) + } header: { + Text(L10n.Settings.Location.DeviceGps.header) + } footer: { + Text(L10n.Settings.Location.DeviceGps.footer) } - .errorAlert($errorMessage) - .retryAlert(retryAlert) - .alert(L10n.Onboarding.Permissions.LocationAlert.title, isPresented: $showLocationDeniedAlert) { - Button(L10n.Onboarding.Permissions.LocationAlert.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - } message: { - Text(L10n.Onboarding.Permissions.LocationAlert.message) + .themedRowBackground(theme) + } + } + .task(id: startupTaskID) { + loadPreferences() + guard appState.canRunSettingsStartupReads else { + logger.debug("Deferring location settings startup reads until sync is less contended") + return + } + await loadDeviceGPSState() + } + .task(id: shouldPollDeviceGPS) { + guard shouldPollDeviceGPS, + let settingsService = appState.services?.settingsService else { return } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + guard let device = appState.connectedDevice, + device.latitude == 0, device.longitude == 0 else { break } + try? await settingsService.refreshDeviceInfo() + } + } + .errorAlert($errorMessage) + .retryAlert(retryAlert) + .alert(L10n.Onboarding.Permissions.LocationAlert.title, isPresented: $showLocationDeniedAlert) { + Button(L10n.Onboarding.Permissions.LocationAlert.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) } + } + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + } message: { + Text(L10n.Onboarding.Permissions.LocationAlert.message) } + } - private var startupTaskID: String { - let deviceID = appState.connectedDevice?.id.uuidString ?? "none" - let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" - return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)-picker:\(showingLocationPicker)" - } + private var startupTaskID: String { + let deviceID = appState.connectedDevice?.id.uuidString ?? "none" + let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" + return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)-picker:\(showingLocationPicker)" + } - private var deviceGPSBinding: Binding { - Binding( - get: { deviceGPSEnabled }, - set: { updateDeviceGPSToggle($0) } - ) - } + private var deviceGPSBinding: Binding { + Binding( + get: { deviceGPSEnabled }, + set: { updateDeviceGPSToggle($0) } + ) + } - private func loadPreferences() { - if let device = appState.connectedDevice { - shareLocation = device.sharesLocationPublicly - autoUpdateLocation = devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: device.id) - gpsSource = devicePreferenceStore.gpsSource(deviceID: device.id) - } - didLoad = true + private func loadPreferences() { + if let device = appState.connectedDevice { + shareLocation = device.sharesLocationPublicly + autoUpdateLocation = devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: device.id) + gpsSource = devicePreferenceStore.gpsSource(deviceID: device.id) } + didLoad = true + } - private func loadDeviceGPSState() async { - guard appState.canRunSettingsStartupReads else { return } - guard let settingsService = appState.services?.settingsService else { return } - do { - let state = try await settingsService.getDeviceGPSState() - deviceHasGPS = state.isSupported - deviceGPSEnabled = state.isEnabled - if let deviceID = appState.connectedDevice?.id, - state.isEnabled, - !devicePreferenceStore.hasSetGPSSource(deviceID: deviceID) { - gpsSource = .device - devicePreferenceStore.setGPSSource(.device, deviceID: deviceID) - } - if state.isEnabled { - try? await settingsService.refreshDeviceInfo() - } - } catch { - deviceHasGPS = false - deviceGPSEnabled = false - } + private func loadDeviceGPSState() async { + guard appState.canRunSettingsStartupReads else { return } + guard let settingsService = appState.services?.settingsService else { return } + do { + let state = try await settingsService.getDeviceGPSState() + deviceHasGPS = state.isSupported + deviceGPSEnabled = state.isEnabled + if let deviceID = appState.connectedDevice?.id, + state.isEnabled, + !devicePreferenceStore.hasSetGPSSource(deviceID: deviceID) { + gpsSource = .device + devicePreferenceStore.setGPSSource(.device, deviceID: deviceID) + } + if state.isEnabled { + try? await settingsService.refreshDeviceInfo() + } + } catch { + deviceHasGPS = false + deviceGPSEnabled = false } + } - private func handleAutoUpdateChange(_ newValue: Bool) { - guard let deviceID = appState.connectedDevice?.id else { return } - if newValue, gpsSource == .phone, appState.locationService.isLocationDenied { + private func handleAutoUpdateChange(_ newValue: Bool) { + guard let deviceID = appState.connectedDevice?.id else { return } + if newValue, gpsSource == .phone, appState.locationService.isLocationDenied { + autoUpdateLocation = false + showLocationDeniedAlert = true + return + } + devicePreferenceStore.setAutoUpdateLocationEnabled(newValue, deviceID: deviceID) + if newValue, gpsSource == .phone { + appState.locationService.requestPermissionIfNeeded() + } + if gpsSource == .device { + if newValue { + saveDeviceGPS( + true, + onFailure: { autoUpdateLocation = false - showLocationDeniedAlert = true - return - } - devicePreferenceStore.setAutoUpdateLocationEnabled(newValue, deviceID: deviceID) - if newValue, gpsSource == .phone { - appState.locationService.requestPermissionIfNeeded() - } - if gpsSource == .device { - if newValue { - saveDeviceGPS( - true, - onFailure: { - autoUpdateLocation = false - devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: deviceID) - } - ) - } else if deviceGPSEnabled { - saveDeviceGPS(false) - } - } - if shareLocation { - updateShareLocation(shareLocation) - } + devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: deviceID) + } + ) + } else if deviceGPSEnabled { + saveDeviceGPS(false) + } } + if shareLocation { + updateShareLocation(shareLocation) + } + } - private func handleGPSSourceChange(from oldValue: GPSSource, to newValue: GPSSource) { - guard let deviceID = appState.connectedDevice?.id else { return } - if newValue == .phone, appState.locationService.isLocationDenied { - gpsSource = oldValue - showLocationDeniedAlert = true - return - } + private func handleGPSSourceChange(from oldValue: GPSSource, to newValue: GPSSource) { + guard let deviceID = appState.connectedDevice?.id else { return } + if newValue == .phone, appState.locationService.isLocationDenied { + gpsSource = oldValue + showLocationDeniedAlert = true + return + } - devicePreferenceStore.setGPSSource(newValue, deviceID: deviceID) - if newValue == .phone { - appState.locationService.requestPermissionIfNeeded() - if deviceGPSEnabled { - saveDeviceGPS(false) - } - } else if autoUpdateLocation { - saveDeviceGPS( - true, - onFailure: { - gpsSource = oldValue - devicePreferenceStore.setGPSSource(oldValue, deviceID: deviceID) - } - ) + devicePreferenceStore.setGPSSource(newValue, deviceID: deviceID) + if newValue == .phone { + appState.locationService.requestPermissionIfNeeded() + if deviceGPSEnabled { + saveDeviceGPS(false) + } + } else if autoUpdateLocation { + saveDeviceGPS( + true, + onFailure: { + gpsSource = oldValue + devicePreferenceStore.setGPSSource(oldValue, deviceID: deviceID) } + ) + } - if shareLocation { - updateShareLocation(shareLocation) - } + if shareLocation { + updateShareLocation(shareLocation) } + } - private func updateDeviceGPSToggle(_ enabled: Bool) { - guard let deviceID = appState.connectedDevice?.id else { return } - let shouldDisableAutoUpdate = !enabled && autoUpdateLocation && gpsSource == .device - saveDeviceGPS(enabled) { - if shouldDisableAutoUpdate { - autoUpdateLocation = false - devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: deviceID) - try await applyDeviceGPSDisabledSharePolicyIfNeeded() - } - } + private func updateDeviceGPSToggle(_ enabled: Bool) { + guard let deviceID = appState.connectedDevice?.id else { return } + let shouldDisableAutoUpdate = !enabled && autoUpdateLocation && gpsSource == .device + saveDeviceGPS(enabled) { + if shouldDisableAutoUpdate { + autoUpdateLocation = false + devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: deviceID) + try await applyDeviceGPSDisabledSharePolicyIfNeeded() + } } + } - private func updateShareLocation(_ share: Bool) { - guard let device = appState.connectedDevice, - let settingsService = appState.services?.settingsService else { return } - let policy = selectedAdvertLocationPolicy(share: share) + private func updateShareLocation(_ share: Bool) { + guard let device = appState.connectedDevice, + let settingsService = appState.services?.settingsService else { return } + let policy = selectedAdvertLocationPolicy(share: share) - if device.advertLocationPolicyMode == policy { - return - } + if device.advertLocationPolicyMode == policy { + return + } - isSaving = true - Task { - do { - try await applyShareLocationPolicy(policy, using: device, settingsService: settingsService) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - shareLocation = !share - retryAlert.show( - message: error.userFacingMessage, - onRetry: { updateShareLocation(share) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - shareLocation = !share - errorMessage = error.userFacingMessage - } - isSaving = false - } + isSaving = true + Task { + do { + try await applyShareLocationPolicy(policy, using: device, settingsService: settingsService) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + shareLocation = !share + retryAlert.show( + message: error.userFacingMessage, + onRetry: { updateShareLocation(share) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + shareLocation = !share + errorMessage = error.userFacingMessage + } + isSaving = false } + } - private func selectedAdvertLocationPolicy(share: Bool) -> AdvertLocationPolicy { - guard share else { return .none } - if autoUpdateLocation, deviceHasGPS, gpsSource == .device { - return .share - } - return .prefs + private func selectedAdvertLocationPolicy(share: Bool) -> AdvertLocationPolicy { + guard share else { return .none } + if autoUpdateLocation, deviceHasGPS, gpsSource == .device { + return .share } + return .prefs + } - private func saveDeviceGPS( - _ enabled: Bool, - onSuccess: (@MainActor () async throws -> Void)? = nil, - onFailure: (@MainActor () -> Void)? = nil - ) { - guard let settingsService = appState.services?.settingsService else { return } + private func saveDeviceGPS( + _ enabled: Bool, + onSuccess: (@MainActor () async throws -> Void)? = nil, + onFailure: (@MainActor () -> Void)? = nil + ) { + guard let settingsService = appState.services?.settingsService else { return } - let previousEnabled = deviceGPSEnabled - isSaving = true + let previousEnabled = deviceGPSEnabled + isSaving = true - Task { - do { - let state = try await settingsService.setDeviceGPSEnabledVerified(enabled) - deviceHasGPS = state.isSupported - deviceGPSEnabled = state.isEnabled - if let onSuccess { - try await onSuccess() - } - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - deviceGPSEnabled = previousEnabled - onFailure?() - retryAlert.show( - message: error.userFacingMessage, - onRetry: { saveDeviceGPS(enabled, onSuccess: onSuccess, onFailure: onFailure) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - deviceGPSEnabled = previousEnabled - onFailure?() - errorMessage = error.userFacingMessage - } - isSaving = false + Task { + do { + let state = try await settingsService.setDeviceGPSEnabledVerified(enabled) + deviceHasGPS = state.isSupported + deviceGPSEnabled = state.isEnabled + if let onSuccess { + try await onSuccess() } + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + deviceGPSEnabled = previousEnabled + onFailure?() + retryAlert.show( + message: error.userFacingMessage, + onRetry: { saveDeviceGPS(enabled, onSuccess: onSuccess, onFailure: onFailure) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + deviceGPSEnabled = previousEnabled + onFailure?() + errorMessage = error.userFacingMessage + } + isSaving = false } + } - private func applyDeviceGPSDisabledSharePolicyIfNeeded() async throws { - guard shareLocation, - let device = appState.connectedDevice, - device.advertLocationPolicyMode == .share, - let settingsService = appState.services?.settingsService else { return } + private func applyDeviceGPSDisabledSharePolicyIfNeeded() async throws { + guard shareLocation, + let device = appState.connectedDevice, + device.advertLocationPolicyMode == .share, + let settingsService = appState.services?.settingsService else { return } - try await applyShareLocationPolicy(.prefs, using: device, settingsService: settingsService) - } + try await applyShareLocationPolicy(.prefs, using: device, settingsService: settingsService) + } - private func applyShareLocationPolicy( - _ policy: AdvertLocationPolicy, - using device: DeviceDTO, - settingsService: SettingsService - ) async throws { - _ = try await settingsService.setOtherParamsVerified(from: device, advertLocationPolicy: policy) - } + private func applyShareLocationPolicy( + _ policy: AdvertLocationPolicy, + using device: DeviceDTO, + settingsService: SettingsService + ) async throws { + _ = try await settingsService.setOtherParamsVerified(from: device, advertLocationPolicy: policy) + } } diff --git a/MC1/Views/Settings/Sections/MapPreviewSettingsSection.swift b/MC1/Views/Settings/Sections/MapPreviewSettingsSection.swift index cdae14dd..56ab2705 100644 --- a/MC1/Views/Settings/Sections/MapPreviewSettingsSection.swift +++ b/MC1/Views/Settings/Sections/MapPreviewSettingsSection.swift @@ -6,26 +6,26 @@ import SwiftUI /// when off, `MessageFragmentBuilder` skips the fragment entirely and the /// coordinate text in the message body remains tappable. struct MapPreviewSettingsSection: View { - @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.showMapPreviewThumbnails.rawValue) - private var showMapPreviewThumbnails = AppStorageKey.defaultShowMapPreviewThumbnails + @Environment(\.appTheme) private var theme + @AppStorage(AppStorageKey.showMapPreviewThumbnails.rawValue) + private var showMapPreviewThumbnails = AppStorageKey.defaultShowMapPreviewThumbnails - var body: some View { - Section { - Toggle(isOn: $showMapPreviewThumbnails) { - TintedLabel(L10n.Settings.MapPreviews.toggle, systemImage: "map") - } - } header: { - Text(L10n.Settings.MapPreviews.header) - } footer: { - Text(L10n.Settings.MapPreviews.footer) - } - .themedRowBackground(theme) + var body: some View { + Section { + Toggle(isOn: $showMapPreviewThumbnails) { + TintedLabel(L10n.Settings.MapPreviews.toggle, systemImage: "map") + } + } header: { + Text(L10n.Settings.MapPreviews.header) + } footer: { + Text(L10n.Settings.MapPreviews.footer) } + .themedRowBackground(theme) + } } #Preview { - Form { - MapPreviewSettingsSection() - } + Form { + MapPreviewSettingsSection() + } } diff --git a/MC1/Views/Settings/Sections/MessagesSettingsSection.swift b/MC1/Views/Settings/Sections/MessagesSettingsSection.swift index 3ec78dc6..9d7837ac 100644 --- a/MC1/Views/Settings/Sections/MessagesSettingsSection.swift +++ b/MC1/Views/Settings/Sections/MessagesSettingsSection.swift @@ -3,29 +3,29 @@ import SwiftUI /// Chat settings section for incoming message routing info display struct MessagesSettingsSection: View { - @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.showIncomingSendTime.rawValue) private var showIncomingSendTime = false - @AppStorage(AppStorageKey.showIncomingPath.rawValue) private var showIncomingPath = false - @AppStorage(AppStorageKey.showIncomingHopCount.rawValue) private var showIncomingHopCount = false - @AppStorage(AppStorageKey.showIncomingRegion.rawValue) private var showIncomingRegion = false + @Environment(\.appTheme) private var theme + @AppStorage(AppStorageKey.showIncomingSendTime.rawValue) private var showIncomingSendTime = false + @AppStorage(AppStorageKey.showIncomingPath.rawValue) private var showIncomingPath = false + @AppStorage(AppStorageKey.showIncomingHopCount.rawValue) private var showIncomingHopCount = false + @AppStorage(AppStorageKey.showIncomingRegion.rawValue) private var showIncomingRegion = false - var body: some View { - Section { - Toggle(L10n.Settings.Messages.showIncomingSendTime, isOn: $showIncomingSendTime) - Toggle(L10n.Settings.Messages.showIncomingPath, isOn: $showIncomingPath) - Toggle(L10n.Settings.Messages.showIncomingHopCount, isOn: $showIncomingHopCount) - Toggle(L10n.Settings.Messages.showIncomingRegion, isOn: $showIncomingRegion) - } header: { - Text(L10n.Settings.Messages.header) - } footer: { - Text(L10n.Settings.Messages.footer) - } - .themedRowBackground(theme) + var body: some View { + Section { + Toggle(L10n.Settings.Messages.showIncomingSendTime, isOn: $showIncomingSendTime) + Toggle(L10n.Settings.Messages.showIncomingPath, isOn: $showIncomingPath) + Toggle(L10n.Settings.Messages.showIncomingHopCount, isOn: $showIncomingHopCount) + Toggle(L10n.Settings.Messages.showIncomingRegion, isOn: $showIncomingRegion) + } header: { + Text(L10n.Settings.Messages.header) + } footer: { + Text(L10n.Settings.Messages.footer) } + .themedRowBackground(theme) + } } #Preview { - Form { - MessagesSettingsSection() - } + Form { + MessagesSettingsSection() + } } diff --git a/MC1/Views/Settings/Sections/NoDeviceSection.swift b/MC1/Views/Settings/Sections/NoDeviceSection.swift index 0bf635c5..b77d6477 100644 --- a/MC1/Views/Settings/Sections/NoDeviceSection.swift +++ b/MC1/Views/Settings/Sections/NoDeviceSection.swift @@ -2,22 +2,22 @@ import SwiftUI /// Shown when no device is connected struct NoDeviceSection: View { - @Environment(\.appTheme) private var theme - @Binding var showingDeviceSelection: Bool - let isSidebar: Bool + @Environment(\.appTheme) private var theme + @Binding var showingDeviceSelection: Bool + let isSidebar: Bool - var body: some View { - Section { - Button { - showingDeviceSelection = true - } label: { - TintedLabel(L10n.Settings.Device.connect, systemImage: "antenna.radiowaves.left.and.right") - } - } header: { - Text(L10n.Settings.Device.header) - } footer: { - Text(L10n.Settings.Device.noDeviceConnected) - } - .themedRowBackground(theme, flatten: isSidebar) + var body: some View { + Section { + Button { + showingDeviceSelection = true + } label: { + TintedLabel(L10n.Settings.Device.connect, systemImage: "antenna.radiowaves.left.and.right") + } + } header: { + Text(L10n.Settings.Device.header) + } footer: { + Text(L10n.Settings.Device.noDeviceConnected) } + .themedRowBackground(theme, flatten: isSidebar) + } } diff --git a/MC1/Views/Settings/Sections/NodesSettingsSection.swift b/MC1/Views/Settings/Sections/NodesSettingsSection.swift index 83cf4aa5..8fc8d7d9 100644 --- a/MC1/Views/Settings/Sections/NodesSettingsSection.swift +++ b/MC1/Views/Settings/Sections/NodesSettingsSection.swift @@ -1,237 +1,239 @@ -import SwiftUI import MC1Services +import SwiftUI /// Auto-add mode and type settings for node discovery struct NodesSettingsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var isApplying = false - @State private var showSuccess = false - - // Local state for editing - @State private var autoAddMode: AutoAddMode = .manual - @State private var autoAddContacts = false - @State private var autoAddRepeaters = false - @State private var autoAddRoomServers = false - @State private var overwriteOldest = false - @State private var autoAddMaxHops: UInt8 = 0 - - private var device: DeviceDTO? { appState.connectedDevice } - - /// Whether the device supports v1.12+ auto-add config features - private var supportsAutoAddConfig: Bool { - device?.supportsAutoAddConfig ?? false - } - - private var supportsAutoAddMaxHops: Bool { - device?.supportsAutoAddMaxHops ?? false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var isApplying = false + @State private var showSuccess = false + + // Local state for editing + @State private var autoAddMode: AutoAddMode = .manual + @State private var autoAddContacts = false + @State private var autoAddRepeaters = false + @State private var autoAddRoomServers = false + @State private var overwriteOldest = false + @State private var autoAddMaxHops: UInt8 = 0 + + private var device: DeviceDTO? { + appState.connectedDevice + } + + /// Whether the device supports v1.12+ auto-add config features + private var supportsAutoAddConfig: Bool { + device?.supportsAutoAddConfig ?? false + } + + private var supportsAutoAddMaxHops: Bool { + device?.supportsAutoAddMaxHops ?? false + } + + private var settingsModified: Bool { + guard let device else { return false } + if device.supportsAutoAddConfig { + return autoAddMode != device.autoAddMode || + autoAddContacts != device.autoAddContacts || + autoAddRepeaters != device.autoAddRepeaters || + autoAddRoomServers != device.autoAddRoomServers || + overwriteOldest != device.overwriteOldest || + autoAddMaxHops != device.autoAddMaxHops + } else { + let deviceMode: AutoAddMode = device.manualAddContacts ? .manual : .all + return autoAddMode != deviceMode } - - private var settingsModified: Bool { - guard let device else { return false } - if device.supportsAutoAddConfig { - return autoAddMode != device.autoAddMode || - autoAddContacts != device.autoAddContacts || - autoAddRepeaters != device.autoAddRepeaters || - autoAddRoomServers != device.autoAddRoomServers || - overwriteOldest != device.overwriteOldest || - autoAddMaxHops != device.autoAddMaxHops - } else { - let deviceMode: AutoAddMode = device.manualAddContacts ? .manual : .all - return autoAddMode != deviceMode + } + + private var canApply: Bool { + appState.connectionState == .ready && settingsModified && !isApplying && !showSuccess + } + + /// Combined hash of all node settings for change detection + private var deviceNodeSettingsHash: Int { + var hasher = Hasher() + hasher.combine(appState.connectedDevice?.autoAddMode) + hasher.combine(appState.connectedDevice?.autoAddContacts) + hasher.combine(appState.connectedDevice?.autoAddRepeaters) + hasher.combine(appState.connectedDevice?.autoAddRoomServers) + hasher.combine(appState.connectedDevice?.overwriteOldest) + hasher.combine(appState.connectedDevice?.supportsAutoAddConfig) + hasher.combine(appState.connectedDevice?.autoAddMaxHops) + hasher.combine(appState.connectedDevice?.supportsAutoAddMaxHops) + return hasher.finalize() + } + + var body: some View { + Section { + Picker(L10n.Settings.Nodes.autoAddMode, selection: $autoAddMode) { + Text(L10n.Settings.Nodes.AutoAddMode.manual).tag(AutoAddMode.manual) + if supportsAutoAddConfig { + Text(L10n.Settings.Nodes.AutoAddMode.selectedTypes).tag(AutoAddMode.selectedTypes) } - } - - private var canApply: Bool { - appState.connectionState == .ready && settingsModified && !isApplying && !showSuccess - } - - /// Combined hash of all node settings for change detection - private var deviceNodeSettingsHash: Int { - var hasher = Hasher() - hasher.combine(appState.connectedDevice?.autoAddMode) - hasher.combine(appState.connectedDevice?.autoAddContacts) - hasher.combine(appState.connectedDevice?.autoAddRepeaters) - hasher.combine(appState.connectedDevice?.autoAddRoomServers) - hasher.combine(appState.connectedDevice?.overwriteOldest) - hasher.combine(appState.connectedDevice?.supportsAutoAddConfig) - hasher.combine(appState.connectedDevice?.autoAddMaxHops) - hasher.combine(appState.connectedDevice?.supportsAutoAddMaxHops) - return hasher.finalize() - } - - var body: some View { - Section { - Picker(L10n.Settings.Nodes.autoAddMode, selection: $autoAddMode) { - Text(L10n.Settings.Nodes.AutoAddMode.manual).tag(AutoAddMode.manual) - if supportsAutoAddConfig { - Text(L10n.Settings.Nodes.AutoAddMode.selectedTypes).tag(AutoAddMode.selectedTypes) - } - Text(L10n.Settings.Nodes.AutoAddMode.all).tag(AutoAddMode.all) - } - .pickerStyle(.menu) - .onChange(of: autoAddMode) { _, newValue in - // Mode changes reveal or hide the type and hop rows below the picker. - if newValue != .manual { - AccessibilityNotification.LayoutChanged().post() - } - } - - if supportsAutoAddConfig && autoAddMode == .selectedTypes { - Toggle(L10n.Settings.Nodes.autoAddContacts, isOn: $autoAddContacts) - Toggle(L10n.Settings.Nodes.autoAddRepeaters, isOn: $autoAddRepeaters) - Toggle(L10n.Settings.Nodes.autoAddRoomServers, isOn: $autoAddRoomServers) - } - - if supportsAutoAddMaxHops && autoAddMode != .manual { - Picker(L10n.Settings.Nodes.maxHops, selection: $autoAddMaxHops) { - Text(L10n.Settings.Nodes.MaxHops.noLimit).tag(UInt8(0)) - Text(L10n.Settings.Nodes.MaxHops.directOnly).tag(UInt8(1)) - Text(L10n.Settings.Nodes.MaxHops.oneHop).tag(UInt8(2)) - ForEach(Array(2...6), id: \.self) { hops in - Text(L10n.Settings.Nodes.MaxHops.hops(hops)).tag(UInt8(hops + 1)) - } - } - .pickerStyle(.menu) - } - - if supportsAutoAddConfig { - Toggle(isOn: $overwriteOldest) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Settings.Nodes.overwriteOldest) - Text(L10n.Settings.Nodes.overwriteOldestDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - Button { - applySettings() - } label: { - AsyncActionLabel(isLoading: isApplying, showSuccess: showSuccess) { - Text(L10n.Settings.AdvancedRadio.apply) - .foregroundStyle(canApply ? Color.accentColor : .secondary) - .transition(.opacity) - } - } - .radioDisabled(for: appState.connectionState, or: isApplying || showSuccess || !settingsModified) - } header: { - Text(L10n.Settings.Nodes.header) - } footer: { - Text(footerDescription) + Text(L10n.Settings.Nodes.AutoAddMode.all).tag(AutoAddMode.all) + } + .pickerStyle(.menu) + .onChange(of: autoAddMode) { _, newValue in + // Mode changes reveal or hide the type and hop rows below the picker. + if newValue != .manual { + AccessibilityNotification.LayoutChanged().post() } - .themedRowBackground(theme) - .radioDisabled(for: appState.connectionState, or: isApplying) - .onAppear { - loadFromDevice() + } + + if supportsAutoAddConfig, autoAddMode == .selectedTypes { + Toggle(L10n.Settings.Nodes.autoAddContacts, isOn: $autoAddContacts) + Toggle(L10n.Settings.Nodes.autoAddRepeaters, isOn: $autoAddRepeaters) + Toggle(L10n.Settings.Nodes.autoAddRoomServers, isOn: $autoAddRoomServers) + } + + if supportsAutoAddMaxHops, autoAddMode != .manual { + Picker(L10n.Settings.Nodes.maxHops, selection: $autoAddMaxHops) { + Text(L10n.Settings.Nodes.MaxHops.noLimit).tag(UInt8(0)) + Text(L10n.Settings.Nodes.MaxHops.directOnly).tag(UInt8(1)) + Text(L10n.Settings.Nodes.MaxHops.oneHop).tag(UInt8(2)) + ForEach(Array(2...6), id: \.self) { hops in + Text(L10n.Settings.Nodes.MaxHops.hops(hops)).tag(UInt8(hops + 1)) + } } - .onChange(of: deviceNodeSettingsHash) { _, _ in - loadFromDevice() + .pickerStyle(.menu) + } + + if supportsAutoAddConfig { + Toggle(isOn: $overwriteOldest) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Nodes.overwriteOldest) + Text(L10n.Settings.Nodes.overwriteOldestDescription) + .font(.caption) + .foregroundStyle(.secondary) + } } - .errorAlert($errorMessage) - .retryAlert(retryAlert) - } - - private var footerDescription: String { - var description = autoAddModeDescription - if supportsAutoAddMaxHops && autoAddMode != .manual && autoAddMaxHops > 0 { - description += "\n" + L10n.Settings.Nodes.MaxHops.footerActive + } + + Button { + applySettings() + } label: { + AsyncActionLabel(isLoading: isApplying, showSuccess: showSuccess) { + Text(L10n.Settings.AdvancedRadio.apply) + .foregroundStyle(canApply ? Color.accentColor : .secondary) + .transition(.opacity) } - return description + } + .radioDisabled(for: appState.connectionState, or: isApplying || showSuccess || !settingsModified) + } header: { + Text(L10n.Settings.Nodes.header) + } footer: { + Text(footerDescription) } - - private var autoAddModeDescription: String { - switch autoAddMode { - case .manual: - return L10n.Settings.Nodes.AutoAddMode.manualDescription - case .selectedTypes: - return L10n.Settings.Nodes.AutoAddMode.selectedTypesDescription - case .all: - return L10n.Settings.Nodes.AutoAddMode.allDescription - } + .themedRowBackground(theme) + .radioDisabled(for: appState.connectionState, or: isApplying) + .onAppear { + loadFromDevice() + } + .onChange(of: deviceNodeSettingsHash) { _, _ in + loadFromDevice() + } + .errorAlert($errorMessage) + .retryAlert(retryAlert) + } + + private var footerDescription: String { + var description = autoAddModeDescription + if supportsAutoAddMaxHops, autoAddMode != .manual, autoAddMaxHops > 0 { + description += "\n" + L10n.Settings.Nodes.MaxHops.footerActive + } + return description + } + + private var autoAddModeDescription: String { + switch autoAddMode { + case .manual: + L10n.Settings.Nodes.AutoAddMode.manualDescription + case .selectedTypes: + L10n.Settings.Nodes.AutoAddMode.selectedTypesDescription + case .all: + L10n.Settings.Nodes.AutoAddMode.allDescription } + } + + private func loadFromDevice() { + guard let device else { return } + + if device.supportsAutoAddConfig { + autoAddMode = device.autoAddMode + autoAddContacts = device.autoAddContacts + autoAddRepeaters = device.autoAddRepeaters + autoAddRoomServers = device.autoAddRoomServers + overwriteOldest = device.overwriteOldest + autoAddMaxHops = device.autoAddMaxHops + } else { + // Older firmware only supports manual/all toggle via manualAddContacts + autoAddMode = device.manualAddContacts ? .manual : .all + autoAddContacts = false + autoAddRepeaters = false + autoAddRoomServers = false + overwriteOldest = false + } + } + + private func applySettings() { + guard !isApplying else { return } + guard let device, let settingsService = appState.services?.settingsService else { return } + + isApplying = true + Task { + do { + // Protocol: manualAddContacts=true for .manual and .selectedTypes, false only for .all + let manualAdd = autoAddMode != .all - private func loadFromDevice() { - guard let device else { return } + // Save manualAddContacts (works on all firmware versions) + _ = try await settingsService.setOtherParamsVerified(from: device, autoAddContacts: !manualAdd) + // Save autoAddConfig only on v1.12+ firmware if device.supportsAutoAddConfig { - autoAddMode = device.autoAddMode - autoAddContacts = device.autoAddContacts - autoAddRepeaters = device.autoAddRepeaters - autoAddRoomServers = device.autoAddRoomServers - overwriteOldest = device.overwriteOldest - autoAddMaxHops = device.autoAddMaxHops - } else { - // Older firmware only supports manual/all toggle via manualAddContacts - autoAddMode = device.manualAddContacts ? .manual : .all - autoAddContacts = false - autoAddRepeaters = false - autoAddRoomServers = false - overwriteOldest = false + var config: UInt8 = 0 + if overwriteOldest { config |= AutoAddConfig.overwriteOldestBit } + + switch autoAddMode { + case .manual: + break + case .selectedTypes: + if autoAddContacts { config |= AutoAddConfig.contactsBit } + if autoAddRepeaters { config |= AutoAddConfig.repeatersBit } + if autoAddRoomServers { config |= AutoAddConfig.roomServersBit } + case .all: + break + } + + _ = try await settingsService.setAutoAddConfigVerified( + AutoAddConfig(bitmask: config, maxHops: autoAddMaxHops) + ) } - } - private func applySettings() { - guard !isApplying else { return } - guard let device, let settingsService = appState.services?.settingsService else { return } - - isApplying = true - Task { - do { - // Protocol: manualAddContacts=true for .manual and .selectedTypes, false only for .all - let manualAdd = autoAddMode != .all - - // Save manualAddContacts (works on all firmware versions) - _ = try await settingsService.setOtherParamsVerified(from: device, autoAddContacts: !manualAdd) - - // Save autoAddConfig only on v1.12+ firmware - if device.supportsAutoAddConfig { - var config: UInt8 = 0 - if overwriteOldest { config |= AutoAddConfig.overwriteOldestBit } - - switch autoAddMode { - case .manual: - break - case .selectedTypes: - if autoAddContacts { config |= AutoAddConfig.contactsBit } - if autoAddRepeaters { config |= AutoAddConfig.repeatersBit } - if autoAddRoomServers { config |= AutoAddConfig.roomServersBit } - case .all: - break - } - - _ = try await settingsService.setAutoAddConfigVerified( - AutoAddConfig(bitmask: config, maxHops: autoAddMaxHops) - ) - } - - retryAlert.reset() - isApplying = false - - withAnimation { - showSuccess = true - } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { - showSuccess = false - } - return - } catch let error as SettingsServiceError where error.isRetryable { - loadFromDevice() - retryAlert.show( - message: error.userFacingMessage, - onRetry: { applySettings() }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - loadFromDevice() - errorMessage = error.userFacingMessage - } - isApplying = false + retryAlert.reset() + isApplying = false + + withAnimation { + showSuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { + showSuccess = false } + return + } catch let error as SettingsServiceError where error.isRetryable { + loadFromDevice() + retryAlert.show( + message: error.userFacingMessage, + onRetry: { applySettings() }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + loadFromDevice() + errorMessage = error.userFacingMessage + } + isApplying = false } + } } diff --git a/MC1/Views/Settings/Sections/NotificationSettingsSection.swift b/MC1/Views/Settings/Sections/NotificationSettingsSection.swift index c953e610..06906e91 100644 --- a/MC1/Views/Settings/Sections/NotificationSettingsSection.swift +++ b/MC1/Views/Settings/Sections/NotificationSettingsSection.swift @@ -4,99 +4,99 @@ import UserNotifications /// Notification toggle settings struct NotificationSettingsSection: View { - @Environment(\.appTheme) private var theme - @Environment(\.openURL) private var openURL - @Environment(\.scenePhase) private var scenePhase - @State private var preferences = NotificationPreferencesStore() - @State private var authorizationStatus: UNAuthorizationStatus = .notDetermined + @Environment(\.appTheme) private var theme + @Environment(\.openURL) private var openURL + @Environment(\.scenePhase) private var scenePhase + @State private var preferences = NotificationPreferencesStore() + @State private var authorizationStatus: UNAuthorizationStatus = .notDetermined - var body: some View { - @Bindable var preferences = preferences - Section { - switch authorizationStatus { - case .notDetermined: - Button { - Task { - await requestAuthorization() - } - } label: { - TintedLabel(L10n.Settings.Notifications.enable, systemImage: "bell.badge") - } - - case .denied: - VStack(alignment: .leading, spacing: 8) { - Label(L10n.Settings.Notifications.disabled, systemImage: "bell.slash") - .foregroundStyle(.secondary) + var body: some View { + @Bindable var preferences = preferences + Section { + switch authorizationStatus { + case .notDetermined: + Button { + Task { + await requestAuthorization() + } + } label: { + TintedLabel(L10n.Settings.Notifications.enable, systemImage: "bell.badge") + } - Button(L10n.Settings.Notifications.openSettings) { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } - .font(.subheadline) - } + case .denied: + VStack(alignment: .leading, spacing: 8) { + Label(L10n.Settings.Notifications.disabled, systemImage: "bell.slash") + .foregroundStyle(.secondary) - default: - Toggle(isOn: $preferences.contactMessagesEnabled) { - TintedLabel(L10n.Settings.Notifications.contactMessages, systemImage: "message") - } - Toggle(isOn: $preferences.channelMessagesEnabled) { - TintedLabel(L10n.Settings.Notifications.channelMessages, systemImage: "person.3") - } - Toggle(isOn: $preferences.roomMessagesEnabled) { - TintedLabel(L10n.Settings.Notifications.roomMessages, systemImage: "bubble.left.and.bubble.right") - } - Toggle(isOn: $preferences.reactionNotificationsEnabled) { - TintedLabel(L10n.Settings.Notifications.reactions, systemImage: "face.smiling") - } - Toggle(isOn: $preferences.lowBatteryEnabled) { - TintedLabel(L10n.Settings.Notifications.lowBattery, systemImage: "battery.25") - } - Toggle(isOn: Binding( - get: { preferences.newContactDiscoveredEnabled }, - set: { newValue in - withAnimation { preferences.newContactDiscoveredEnabled = newValue } - } - )) { - TintedLabel(L10n.Settings.Notifications.newContactDiscovered, systemImage: "person.badge.plus") - } - if preferences.newContactDiscoveredEnabled { - Toggle(isOn: $preferences.discoveryContactEnabled) { - TintedLabel(L10n.Settings.Notifications.discoveryContact, systemImage: "person") - } - .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) - Toggle(isOn: $preferences.discoveryRepeaterEnabled) { - TintedLabel(L10n.Settings.Notifications.discoveryRepeater, systemImage: "antenna.radiowaves.left.and.right") - } - .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) - Toggle(isOn: $preferences.discoveryRoomEnabled) { - TintedLabel(L10n.Settings.Notifications.discoveryRoom, systemImage: "door.left.hand.open") - } - .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) - } + Button(L10n.Settings.Notifications.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) } + } + .font(.subheadline) } - .themedRowBackground(theme) - .task { - await refreshAuthorizationStatus() + + default: + Toggle(isOn: $preferences.contactMessagesEnabled) { + TintedLabel(L10n.Settings.Notifications.contactMessages, systemImage: "message") } - .onChange(of: scenePhase) { - if scenePhase == .active { - Task { - await refreshAuthorizationStatus() - } - } + Toggle(isOn: $preferences.channelMessagesEnabled) { + TintedLabel(L10n.Settings.Notifications.channelMessages, systemImage: "person.3") + } + Toggle(isOn: $preferences.roomMessagesEnabled) { + TintedLabel(L10n.Settings.Notifications.roomMessages, systemImage: "bubble.left.and.bubble.right") + } + Toggle(isOn: $preferences.reactionNotificationsEnabled) { + TintedLabel(L10n.Settings.Notifications.reactions, systemImage: "face.smiling") + } + Toggle(isOn: $preferences.lowBatteryEnabled) { + TintedLabel(L10n.Settings.Notifications.lowBattery, systemImage: "battery.25") } + Toggle(isOn: Binding( + get: { preferences.newContactDiscoveredEnabled }, + set: { newValue in + withAnimation { preferences.newContactDiscoveredEnabled = newValue } + } + )) { + TintedLabel(L10n.Settings.Notifications.newContactDiscovered, systemImage: "person.badge.plus") + } + if preferences.newContactDiscoveredEnabled { + Toggle(isOn: $preferences.discoveryContactEnabled) { + TintedLabel(L10n.Settings.Notifications.discoveryContact, systemImage: "person") + } + .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) + Toggle(isOn: $preferences.discoveryRepeaterEnabled) { + TintedLabel(L10n.Settings.Notifications.discoveryRepeater, systemImage: "antenna.radiowaves.left.and.right") + } + .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) + Toggle(isOn: $preferences.discoveryRoomEnabled) { + TintedLabel(L10n.Settings.Notifications.discoveryRoom, systemImage: "door.left.hand.open") + } + .listRowInsets(EdgeInsets(top: 0, leading: 52, bottom: 0, trailing: 20)) + } + } } - - private func refreshAuthorizationStatus() async { - let settings = await UNUserNotificationCenter.current().notificationSettings() - authorizationStatus = settings.authorizationStatus + .themedRowBackground(theme) + .task { + await refreshAuthorizationStatus() } - - private func requestAuthorization() async { - let granted = try? await UNUserNotificationCenter.current() - .requestAuthorization(options: [.alert, .sound, .badge]) - authorizationStatus = (granted == true) ? .authorized : .denied + .onChange(of: scenePhase) { + if scenePhase == .active { + Task { + await refreshAuthorizationStatus() + } + } } + } + + private func refreshAuthorizationStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + authorizationStatus = settings.authorizationStatus + } + + private func requestAuthorization() async { + let granted = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) + authorizationStatus = (granted == true) ? .authorized : .denied + } } diff --git a/MC1/Views/Settings/Sections/PathHashModeSection.swift b/MC1/Views/Settings/Sections/PathHashModeSection.swift index 889c85c4..89d63174 100644 --- a/MC1/Views/Settings/Sections/PathHashModeSection.swift +++ b/MC1/Views/Settings/Sections/PathHashModeSection.swift @@ -1,72 +1,72 @@ -import SwiftUI import MC1Services +import SwiftUI /// Picker for configuring the path hash size on firmware v10+ devices. struct PathHashModeSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var selectedMode: UInt8? - @State private var isApplying = false - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var selectedMode: UInt8? + @State private var isApplying = false + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() - var body: some View { - Section { - Picker(L10n.Settings.PathHashMode.label, selection: Binding( - get: { selectedMode ?? appState.connectedDevice?.pathHashMode ?? 0 }, - set: { newMode in - guard newMode != selectedMode else { return } - selectedMode = newMode - applyMode(newMode) - } - )) { - Text(L10n.Settings.PathHashMode.oneByte).tag(UInt8(0)) - Text(L10n.Settings.PathHashMode.twoBytes).tag(UInt8(1)) - Text(L10n.Settings.PathHashMode.threeBytes).tag(UInt8(2)) - } - .pickerStyle(.menu) - .tint(.primary) - .radioDisabled(for: appState.connectionState, or: isApplying) - } header: { - Text(L10n.Settings.PathHashMode.header) - } footer: { - Text(L10n.Settings.PathHashMode.footer) - } - .themedRowBackground(theme) - .onAppear { - selectedMode = appState.connectedDevice?.pathHashMode ?? 0 + var body: some View { + Section { + Picker(L10n.Settings.PathHashMode.label, selection: Binding( + get: { selectedMode ?? appState.connectedDevice?.pathHashMode ?? 0 }, + set: { newMode in + guard newMode != selectedMode else { return } + selectedMode = newMode + applyMode(newMode) } - .onChange(of: appState.connectedDevice?.pathHashMode) { _, newValue in - if let newValue { - selectedMode = newValue - } - } - .errorAlert($errorMessage) - .retryAlert(retryAlert) + )) { + Text(L10n.Settings.PathHashMode.oneByte).tag(UInt8(0)) + Text(L10n.Settings.PathHashMode.twoBytes).tag(UInt8(1)) + Text(L10n.Settings.PathHashMode.threeBytes).tag(UInt8(2)) + } + .pickerStyle(.menu) + .tint(.primary) + .radioDisabled(for: appState.connectionState, or: isApplying) + } header: { + Text(L10n.Settings.PathHashMode.header) + } footer: { + Text(L10n.Settings.PathHashMode.footer) + } + .themedRowBackground(theme) + .onAppear { + selectedMode = appState.connectedDevice?.pathHashMode ?? 0 + } + .onChange(of: appState.connectedDevice?.pathHashMode) { _, newValue in + if let newValue { + selectedMode = newValue + } } + .errorAlert($errorMessage) + .retryAlert(retryAlert) + } - private func applyMode(_ mode: UInt8) { - isApplying = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } - _ = try await settingsService.setPathHashModeVerified(mode) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - selectedMode = appState.connectedDevice?.pathHashMode ?? 0 - retryAlert.show( - message: error.userFacingMessage, - onRetry: { applyMode(mode) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - selectedMode = appState.connectedDevice?.pathHashMode ?? 0 - } - isApplying = false + private func applyMode(_ mode: UInt8) { + isApplying = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected } + _ = try await settingsService.setPathHashModeVerified(mode) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + selectedMode = appState.connectedDevice?.pathHashMode ?? 0 + retryAlert.show( + message: error.userFacingMessage, + onRetry: { applyMode(mode) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + selectedMode = appState.connectedDevice?.pathHashMode ?? 0 + } + isApplying = false } + } } diff --git a/MC1/Views/Settings/Sections/RadioPresetSection.swift b/MC1/Views/Settings/Sections/RadioPresetSection.swift index 160a32e6..6238c0bd 100644 --- a/MC1/Views/Settings/Sections/RadioPresetSection.swift +++ b/MC1/Views/Settings/Sections/RadioPresetSection.swift @@ -1,327 +1,327 @@ -import SwiftUI import MC1Services +import SwiftUI /// Radio preset selector with region-based filtering struct RadioPresetSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var selectedPresetID: String? - @State private var isApplying = false - @State private var errorMessage: String? - @State private var hasInitialized = false - @State private var retryAlert = RetryAlertState() - @State private var isRepeatEnabled: Bool = false - @State private var isApplyingRepeat = false - @State private var showRepeatConfirmation = false + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var selectedPresetID: String? + @State private var isApplying = false + @State private var errorMessage: String? + @State private var hasInitialized = false + @State private var retryAlert = RetryAlertState() + @State private var isRepeatEnabled: Bool = false + @State private var isApplyingRepeat = false + @State private var showRepeatConfirmation = false - private var startupTaskID: String { - let deviceID = appState.connectedDevice?.id.uuidString ?? "none" - let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" - return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)" - } + private var startupTaskID: String { + let deviceID = appState.connectedDevice?.id.uuidString ?? "none" + let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" + return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)" + } - private var presets: [RadioPreset] { - let region = appState.regionSelection - let activeID = currentPreset?.id - return RadioPresets.presetsForLocale().filter { - RadioPresets.isSelectable($0, in: region) || $0.id == activeID - } + private var presets: [RadioPreset] { + let region = appState.regionSelection + let activeID = currentPreset?.id + return RadioPresets.presetsForLocale().filter { + RadioPresets.isSelectable($0, in: region) || $0.id == activeID } + } - private var repeatPresets: [RadioPreset] { - RadioPresets.repeatPresets - } + private var repeatPresets: [RadioPreset] { + RadioPresets.repeatPresets + } - private var currentPreset: RadioPreset? { - guard let device = appState.connectedDevice else { return nil } - return RadioPresets.matchingPreset( - frequencyKHz: device.frequency, - bandwidthKHz: device.bandwidth, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - } + private var currentPreset: RadioPreset? { + guard let device = appState.connectedDevice else { return nil } + return RadioPresets.matchingPreset( + frequencyKHz: device.frequency, + bandwidthKHz: device.bandwidth, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate + ) + } - /// Finds the repeat preset closest to the device's current frequency. - private var closestRepeatPreset: RadioPreset? { - guard let device = appState.connectedDevice else { return nil } - let deviceFreqKHz = device.frequency - return repeatPresets.min(by: { - abs(Int($0.frequencyKHz) - Int(deviceFreqKHz)) < abs(Int($1.frequencyKHz) - Int(deviceFreqKHz)) - }) - } + /// Finds the repeat preset closest to the device's current frequency. + private var closestRepeatPreset: RadioPreset? { + guard let device = appState.connectedDevice else { return nil } + let deviceFreqKHz = device.frequency + return repeatPresets.min(by: { + abs(Int($0.frequencyKHz) - Int(deviceFreqKHz)) < abs(Int($1.frequencyKHz) - Int(deviceFreqKHz)) + }) + } - /// Matches the device's current radio params against repeat presets. - private var currentRepeatPreset: RadioPreset? { - guard let device = appState.connectedDevice else { return nil } - return RadioPresets.matchingRepeatPreset( - frequencyKHz: device.frequency, - bandwidthKHz: device.bandwidth, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - } - - private var mismatchHint: String? { - guard let region = appState.regionSelection, - let current = currentPreset else { return nil } - let regionPresets = RadioPresets.presets(for: region).map(\.id) - guard !regionPresets.contains(current.id) else { return nil } - return L10n.Settings.Radio.mismatchHint( - current.name, RegionalAreas.displayName(for: region) - ) - } - - private var currentMatchingPresetID: String? { - isRepeatEnabled ? currentRepeatPreset?.id : currentPreset?.id - } + /// Matches the device's current radio params against repeat presets. + private var currentRepeatPreset: RadioPreset? { + guard let device = appState.connectedDevice else { return nil } + return RadioPresets.matchingRepeatPreset( + frequencyKHz: device.frequency, + bandwidthKHz: device.bandwidth, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate + ) + } - var body: some View { - Section { - Picker(L10n.Settings.Radio.preset, selection: $selectedPresetID) { - if isRepeatEnabled { - Text(L10n.Settings.BatteryCurve.custom).tag(nil as String?) - ForEach(repeatPresets) { preset in - Section(preset.repeatSectionHeader ?? "") { - Text(preset.name).tag(preset.id as String?) - } - } - } else { - // Only show Custom when device is not using a preset - if currentPreset == nil { - Text(L10n.Settings.BatteryCurve.custom).tag(nil as String?) - } + private var mismatchHint: String? { + guard let region = appState.regionSelection, + let current = currentPreset else { return nil } + let regionPresets = RadioPresets.presets(for: region).map(\.id) + guard !regionPresets.contains(current.id) else { return nil } + return L10n.Settings.Radio.mismatchHint( + current.name, RegionalAreas.displayName(for: region) + ) + } - ForEach(RadioRegion.allCases, id: \.self) { region in - let regionPresets = presets.filter { $0.region == region } - if !regionPresets.isEmpty { - Section(region.rawValue) { - ForEach(regionPresets) { preset in - Text(preset.name).tag(preset.id as String?) - } - } - } - } - } - } - .onChange(of: selectedPresetID) { _, newValue in - // Skip the initial value set from onAppear - guard hasInitialized else { return } - // Apply if user selected a preset (newValue is non-nil) - guard let newID = newValue else { return } - applyPreset(id: newID) - } - .radioDisabled(for: appState.connectionState, or: isApplying || isApplyingRepeat) + private var currentMatchingPresetID: String? { + isRepeatEnabled ? currentRepeatPreset?.id : currentPreset?.id + } - let detailPresets = isRepeatEnabled ? repeatPresets : presets - if let preset = detailPresets.first(where: { $0.id == selectedPresetID }) { - RadioParameterText( - frequencyMHz: preset.frequencyMHz, - bandwidthKHz: preset.bandwidthKHz, - spreadingFactor: preset.spreadingFactor, - codingRate: preset.codingRate - ) - .foregroundStyle(.secondary) - } else if let device = appState.connectedDevice { - RadioParameterText( - frequencyMHz: Double(device.frequency) / 1000.0, - bandwidthKHz: Double(device.bandwidth) / 1000.0, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - .foregroundStyle(.secondary) + var body: some View { + Section { + Picker(L10n.Settings.Radio.preset, selection: $selectedPresetID) { + if isRepeatEnabled { + Text(L10n.Settings.BatteryCurve.custom).tag(nil as String?) + ForEach(repeatPresets) { preset in + Section(preset.repeatSectionHeader ?? "") { + Text(preset.name).tag(preset.id as String?) } + } + } else { + // Only show Custom when device is not using a preset + if currentPreset == nil { + Text(L10n.Settings.BatteryCurve.custom).tag(nil as String?) + } - if appState.connectedDevice?.supportsClientRepeat == true { - Toggle(isOn: $isRepeatEnabled) { - Text(L10n.Settings.Radio.repeatMode) - Text(L10n.Settings.Radio.RepeatMode.footer) - } - .accessibilityHint(L10n.Settings.Radio.RepeatMode.accessibilityHint) - .onChange(of: isRepeatEnabled) { _, newValue in - guard hasInitialized else { return } - if newValue { - hasInitialized = false - isRepeatEnabled = false - Task { @MainActor in - hasInitialized = true - } - showRepeatConfirmation = true - } else { - disableRepeatMode() - } - } - .disabled(isApplying || isApplyingRepeat) - } - } header: { - Text(L10n.Settings.Radio.header) - } footer: { - VStack(alignment: .leading, spacing: 6) { - Text(L10n.Settings.Radio.footer) - if let region = appState.regionSelection { - Text(L10n.Settings.Radio.regionFooter(RegionalAreas.displayName(for: region))) - } - if let mismatch = mismatchHint { - Text(mismatch) - .foregroundStyle(.orange) + ForEach(RadioRegion.allCases, id: \.self) { region in + let regionPresets = presets.filter { $0.region == region } + if !regionPresets.isEmpty { + Section(region.rawValue) { + ForEach(regionPresets) { preset in + Text(preset.name).tag(preset.id as String?) } + } } + } } - .themedRowBackground(theme) - .onAppear { - isRepeatEnabled = appState.connectedDevice?.clientRepeat ?? false - selectedPresetID = currentMatchingPresetID - // Mark as initialized after setting initial value - // Using task to defer to next run loop, after onChange processes - Task { @MainActor in - hasInitialized = true - } - } - .task(id: startupTaskID) { - guard appState.canRunSettingsStartupReads, - let settingsService = appState.services?.settingsService else { return } - _ = try? await settingsService.getSelfInfo() + } + .onChange(of: selectedPresetID) { _, newValue in + // Skip the initial value set from onAppear + guard hasInitialized else { return } + // Apply if user selected a preset (newValue is non-nil) + guard let newID = newValue else { return } + applyPreset(id: newID) + } + .radioDisabled(for: appState.connectionState, or: isApplying || isApplyingRepeat) + + let detailPresets = isRepeatEnabled ? repeatPresets : presets + if let preset = detailPresets.first(where: { $0.id == selectedPresetID }) { + RadioParameterText( + frequencyMHz: preset.frequencyMHz, + bandwidthKHz: preset.bandwidthKHz, + spreadingFactor: preset.spreadingFactor, + codingRate: preset.codingRate + ) + .foregroundStyle(.secondary) + } else if let device = appState.connectedDevice { + RadioParameterText( + frequencyMHz: Double(device.frequency) / 1000.0, + bandwidthKHz: Double(device.bandwidth) / 1000.0, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate + ) + .foregroundStyle(.secondary) + } + + if appState.connectedDevice?.supportsClientRepeat == true { + Toggle(isOn: $isRepeatEnabled) { + Text(L10n.Settings.Radio.repeatMode) + Text(L10n.Settings.Radio.RepeatMode.footer) } - .onChange(of: currentPreset?.id) { _, newPresetID in - // Sync picker when device settings change externally (e.g., from Advanced Settings) - guard !isRepeatEnabled else { return } + .accessibilityHint(L10n.Settings.Radio.RepeatMode.accessibilityHint) + .onChange(of: isRepeatEnabled) { _, newValue in + guard hasInitialized else { return } + if newValue { hasInitialized = false - selectedPresetID = newPresetID + isRepeatEnabled = false Task { @MainActor in - hasInitialized = true + hasInitialized = true } + showRepeatConfirmation = true + } else { + disableRepeatMode() + } } - .onChange(of: currentRepeatPreset?.id) { _, newPresetID in - guard isRepeatEnabled else { return } - hasInitialized = false - selectedPresetID = newPresetID - Task { @MainActor in - hasInitialized = true - } + .disabled(isApplying || isApplyingRepeat) + } + } header: { + Text(L10n.Settings.Radio.header) + } footer: { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.Settings.Radio.footer) + if let region = appState.regionSelection { + Text(L10n.Settings.Radio.regionFooter(RegionalAreas.displayName(for: region))) } - .onChange(of: appState.connectedDevice?.clientRepeat) { _, newValue in - let newRepeatEnabled = newValue ?? false - if newRepeatEnabled != isRepeatEnabled { - hasInitialized = false - isRepeatEnabled = newRepeatEnabled - selectedPresetID = currentMatchingPresetID - Task { @MainActor in - hasInitialized = true - } - } + if let mismatch = mismatchHint { + Text(mismatch) + .foregroundStyle(.orange) } - .errorAlert($errorMessage) - .retryAlert(retryAlert) - .alert(L10n.Settings.Radio.RepeatMode.Confirm.title, isPresented: $showRepeatConfirmation) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.Radio.RepeatMode.Confirm.enable) { - enableRepeatMode() - } - } message: { - Text(L10n.Settings.Radio.RepeatMode.Confirm.message) + } + } + .themedRowBackground(theme) + .onAppear { + isRepeatEnabled = appState.connectedDevice?.clientRepeat ?? false + selectedPresetID = currentMatchingPresetID + // Mark as initialized after setting initial value + // Using task to defer to next run loop, after onChange processes + Task { @MainActor in + hasInitialized = true + } + } + .task(id: startupTaskID) { + guard appState.canRunSettingsStartupReads, + let settingsService = appState.services?.settingsService else { return } + _ = try? await settingsService.getSelfInfo() + } + .onChange(of: currentPreset?.id) { _, newPresetID in + // Sync picker when device settings change externally (e.g., from Advanced Settings) + guard !isRepeatEnabled else { return } + hasInitialized = false + selectedPresetID = newPresetID + Task { @MainActor in + hasInitialized = true + } + } + .onChange(of: currentRepeatPreset?.id) { _, newPresetID in + guard isRepeatEnabled else { return } + hasInitialized = false + selectedPresetID = newPresetID + Task { @MainActor in + hasInitialized = true + } + } + .onChange(of: appState.connectedDevice?.clientRepeat) { _, newValue in + let newRepeatEnabled = newValue ?? false + if newRepeatEnabled != isRepeatEnabled { + hasInitialized = false + isRepeatEnabled = newRepeatEnabled + selectedPresetID = currentMatchingPresetID + Task { @MainActor in + hasInitialized = true } + } } + .errorAlert($errorMessage) + .retryAlert(retryAlert) + .alert(L10n.Settings.Radio.RepeatMode.Confirm.title, isPresented: $showRepeatConfirmation) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.Radio.RepeatMode.Confirm.enable) { + enableRepeatMode() + } + } message: { + Text(L10n.Settings.Radio.RepeatMode.Confirm.message) + } + } - private func applyPreset(id: String) { - let allPresets = isRepeatEnabled ? repeatPresets : presets - guard let preset = allPresets.first(where: { $0.id == id }) else { return } + private func applyPreset(id: String) { + let allPresets = isRepeatEnabled ? repeatPresets : presets + guard let preset = allPresets.first(where: { $0.id == id }) else { return } - isApplying = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } - if isRepeatEnabled { - _ = try await settingsService.setRadioParamsVerified( - frequencyKHz: preset.frequencyKHz, - bandwidthKHz: preset.bandwidthHz, - spreadingFactor: preset.spreadingFactor, - codingRate: preset.codingRate, - clientRepeat: true - ) - } else { - _ = try await settingsService.applyRadioPresetVerified(preset) - } - retryAlert.reset() - } catch { - selectedPresetID = currentMatchingPresetID - if let retryable = error as? SettingsServiceError, retryable.isRetryable { - retryAlert.show( - message: retryable.userFacingMessage, - onRetry: { applyPreset(id: id) }, - onMaxRetriesExceeded: { dismiss() } - ) - } else { - errorMessage = error.userFacingMessage - } - } - isApplying = false + isApplying = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected + } + if isRepeatEnabled { + _ = try await settingsService.setRadioParamsVerified( + frequencyKHz: preset.frequencyKHz, + bandwidthKHz: preset.bandwidthHz, + spreadingFactor: preset.spreadingFactor, + codingRate: preset.codingRate, + clientRepeat: true + ) + } else { + _ = try await settingsService.applyRadioPresetVerified(preset) + } + retryAlert.reset() + } catch { + selectedPresetID = currentMatchingPresetID + if let retryable = error as? SettingsServiceError, retryable.isRetryable { + retryAlert.show( + message: retryable.userFacingMessage, + onRetry: { applyPreset(id: id) }, + onMaxRetriesExceeded: { dismiss() } + ) + } else { + errorMessage = error.userFacingMessage } + } + isApplying = false } + } - private func enableRepeatMode() { - guard let preset = closestRepeatPreset else { return } + private func enableRepeatMode() { + guard let preset = closestRepeatPreset else { return } - // Persist current radio settings to Device model before switching - appState.connectionManager.savePreRepeatSettings() + // Persist current radio settings to Device model before switching + appState.connectionManager.savePreRepeatSettings() - // Swap picker to repeat presets and select closest frequency - hasInitialized = false - isRepeatEnabled = true - selectedPresetID = preset.id - Task { @MainActor in - hasInitialized = true - applyPreset(id: preset.id) - } + // Swap picker to repeat presets and select closest frequency + hasInitialized = false + isRepeatEnabled = true + selectedPresetID = preset.id + Task { @MainActor in + hasInitialized = true + applyPreset(id: preset.id) } + } - private func disableRepeatMode() { - guard let device = appState.connectedDevice else { return } - isApplyingRepeat = true - Task { - do { - guard let settingsService = appState.services?.settingsService else { - throw ConnectionError.notConnected - } + private func disableRepeatMode() { + guard let device = appState.connectedDevice else { return } + isApplyingRepeat = true + Task { + do { + guard let settingsService = appState.services?.settingsService else { + throw ConnectionError.notConnected + } - // Restore saved radio settings, or fall back to current params - let freq = device.preRepeatFrequency ?? device.frequency - let bw = device.preRepeatBandwidth ?? device.bandwidth - let sf = device.preRepeatSpreadingFactor ?? device.spreadingFactor - let cr = device.preRepeatCodingRate ?? device.codingRate + // Restore saved radio settings, or fall back to current params + let freq = device.preRepeatFrequency ?? device.frequency + let bw = device.preRepeatBandwidth ?? device.bandwidth + let sf = device.preRepeatSpreadingFactor ?? device.spreadingFactor + let cr = device.preRepeatCodingRate ?? device.codingRate - _ = try await settingsService.setRadioParamsVerified( - frequencyKHz: freq, - bandwidthKHz: bw, - spreadingFactor: sf, - codingRate: cr, - clientRepeat: false - ) + _ = try await settingsService.setRadioParamsVerified( + frequencyKHz: freq, + bandwidthKHz: bw, + spreadingFactor: sf, + codingRate: cr, + clientRepeat: false + ) - // Clear persisted pre-repeat settings - appState.connectionManager.clearPreRepeatSettings() + // Clear persisted pre-repeat settings + appState.connectionManager.clearPreRepeatSettings() - // Swap picker back to normal presets - hasInitialized = false - selectedPresetID = currentPreset?.id - Task { @MainActor in - hasInitialized = true - } - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - isRepeatEnabled = true // Revert - retryAlert.show( - message: error.userFacingMessage, - onRetry: { disableRepeatMode() }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - isRepeatEnabled = true // Revert - } - isApplyingRepeat = false + // Swap picker back to normal presets + hasInitialized = false + selectedPresetID = currentPreset?.id + Task { @MainActor in + hasInitialized = true } + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + isRepeatEnabled = true // Revert + retryAlert.show( + message: error.userFacingMessage, + onRetry: { disableRepeatMode() }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + errorMessage = error.userFacingMessage + isRepeatEnabled = true // Revert + } + isApplyingRepeat = false } + } } diff --git a/MC1/Views/Settings/Sections/RegenerateIdentitySheet.swift b/MC1/Views/Settings/Sections/RegenerateIdentitySheet.swift index 8f8a57eb..bf809288 100644 --- a/MC1/Views/Settings/Sections/RegenerateIdentitySheet.swift +++ b/MC1/Views/Settings/Sections/RegenerateIdentitySheet.swift @@ -3,158 +3,158 @@ import SwiftUI /// Sheet for generating a new Ed25519 identity and importing it to the device struct RegenerateIdentitySheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var viewModel = RegenerateIdentityViewModel() + @State private var viewModel = RegenerateIdentityViewModel() - var body: some View { - NavigationStack { - Form { - explanationSection - .themedRowBackground(theme) - prefixSection - .themedRowBackground(theme) - generateSection - .themedRowBackground(theme) - if let generatedKey = viewModel.generatedKey { - keyPreviewSection(generatedKey) - .themedRowBackground(theme) - replaceSection - .themedRowBackground(theme) - } - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.RegenerateIdentity.Sheet.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.Common.cancel) { - dismiss() - } - .disabled(viewModel.isBusy) - } - } - .interactiveDismissDisabled(viewModel.isBusy) - .alert( - L10n.Settings.RegenerateIdentity.Alert.Replace.title, - isPresented: $viewModel.showingReplaceAlert - ) { - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - Button(L10n.Settings.RegenerateIdentity.Alert.Replace.confirm, role: .destructive) { - Task { - if await viewModel.replaceIdentity() { - dismiss() - } - } - } - } message: { - Text(L10n.Settings.RegenerateIdentity.Alert.Replace.message) - } - .errorAlert($viewModel.errorMessage) - .sensoryFeedback(.success, trigger: viewModel.successTrigger) - .task { - viewModel.configure(settingsService: { appState.services?.settingsService }) - } + var body: some View { + NavigationStack { + Form { + explanationSection + .themedRowBackground(theme) + prefixSection + .themedRowBackground(theme) + generateSection + .themedRowBackground(theme) + if let generatedKey = viewModel.generatedKey { + keyPreviewSection(generatedKey) + .themedRowBackground(theme) + replaceSection + .themedRowBackground(theme) } - .onDisappear { - viewModel.cancelGeneration() + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.RegenerateIdentity.Sheet.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.Common.cancel) { + dismiss() + } + .disabled(viewModel.isBusy) } + } + .interactiveDismissDisabled(viewModel.isBusy) + .alert( + L10n.Settings.RegenerateIdentity.Alert.Replace.title, + isPresented: $viewModel.showingReplaceAlert + ) { + Button(L10n.Localizable.Common.cancel, role: .cancel) {} + Button(L10n.Settings.RegenerateIdentity.Alert.Replace.confirm, role: .destructive) { + Task { + if await viewModel.replaceIdentity() { + dismiss() + } + } + } + } message: { + Text(L10n.Settings.RegenerateIdentity.Alert.Replace.message) + } + .errorAlert($viewModel.errorMessage) + .sensoryFeedback(.success, trigger: viewModel.successTrigger) + .task { + viewModel.configure(settingsService: { appState.services?.settingsService }) + } + } + .onDisappear { + viewModel.cancelGeneration() } + } - // MARK: - Sections + // MARK: - Sections - private var explanationSection: some View { - Section { - Text(L10n.Settings.RegenerateIdentity.Sheet.explanation) - .foregroundStyle(.secondary) - } + private var explanationSection: some View { + Section { + Text(L10n.Settings.RegenerateIdentity.Sheet.explanation) + .foregroundStyle(.secondary) } + } - private var prefixSection: some View { - Section { - DisclosureGroup(L10n.Settings.RegenerateIdentity.Prefix.label) { - TextField(L10n.Settings.RegenerateIdentity.Prefix.placeholder, text: $viewModel.hexPrefix) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) - .onChange(of: viewModel.hexPrefix) { _, newValue in - viewModel.sanitizePrefix(newValue) - } + private var prefixSection: some View { + Section { + DisclosureGroup(L10n.Settings.RegenerateIdentity.Prefix.label) { + TextField(L10n.Settings.RegenerateIdentity.Prefix.placeholder, text: $viewModel.hexPrefix) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .onChange(of: viewModel.hexPrefix) { _, newValue in + viewModel.sanitizePrefix(newValue) + } - if let prefixError = viewModel.prefixError { - Label(prefixError, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - .font(.footnote) - .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) - } - } - } footer: { - Text(L10n.Settings.RegenerateIdentity.Prefix.footer) + if let prefixError = viewModel.prefixError { + Label(prefixError, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + .font(.footnote) + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) } + } + } footer: { + Text(L10n.Settings.RegenerateIdentity.Prefix.footer) } + } - private var generateSection: some View { - Section { - Button { - viewModel.generateKey() - } label: { - HStack { - Spacer() - if viewModel.isGenerating { - ProgressView() - .controlSize(.small) - .accessibilityLabel(L10n.Settings.RegenerateIdentity.generating) - Text(L10n.Settings.RegenerateIdentity.generating) - } else { - Text(L10n.Settings.RegenerateIdentity.generate) - } - Spacer() - } - } - .disabled(viewModel.isBusy) + private var generateSection: some View { + Section { + Button { + viewModel.generateKey() + } label: { + HStack { + Spacer() + if viewModel.isGenerating { + ProgressView() + .controlSize(.small) + .accessibilityLabel(L10n.Settings.RegenerateIdentity.generating) + Text(L10n.Settings.RegenerateIdentity.generating) + } else { + Text(L10n.Settings.RegenerateIdentity.generate) + } + Spacer() } + } + .disabled(viewModel.isBusy) } + } - private func keyPreviewSection(_ key: RegenerateIdentityViewModel.GeneratedKey) -> some View { - Group { - Section { - Text(key.publicKeyHex) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .accessibilityLabel(key.accessibilityLabel) - } header: { - Text(L10n.Settings.PublicKey.header) - } - Section { - Text(key.privateKeyHex) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - } header: { - Text(L10n.Settings.PrivateKey.header) - } - } + private func keyPreviewSection(_ key: RegenerateIdentityViewModel.GeneratedKey) -> some View { + Group { + Section { + Text(key.publicKeyHex) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .accessibilityLabel(key.accessibilityLabel) + } header: { + Text(L10n.Settings.PublicKey.header) + } + Section { + Text(key.privateKeyHex) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } header: { + Text(L10n.Settings.PrivateKey.header) + } } + } - private var replaceSection: some View { - Section { - Button { - viewModel.showingReplaceAlert = true - } label: { - HStack { - Spacer() - if viewModel.isImporting { - ProgressView() - .controlSize(.small) - Text(L10n.Settings.RegenerateIdentity.importing) - } else { - Text(L10n.Settings.RegenerateIdentity.replace) - } - Spacer() - } - } - .disabled(viewModel.isBusy) + private var replaceSection: some View { + Section { + Button { + viewModel.showingReplaceAlert = true + } label: { + HStack { + Spacer() + if viewModel.isImporting { + ProgressView() + .controlSize(.small) + Text(L10n.Settings.RegenerateIdentity.importing) + } else { + Text(L10n.Settings.RegenerateIdentity.replace) + } + Spacer() } + } + .disabled(viewModel.isBusy) } + } } diff --git a/MC1/Views/Settings/Sections/RegenerateIdentityViewModel.swift b/MC1/Views/Settings/Sections/RegenerateIdentityViewModel.swift index df95e790..31c80cd2 100644 --- a/MC1/Views/Settings/Sections/RegenerateIdentityViewModel.swift +++ b/MC1/Views/Settings/Sections/RegenerateIdentityViewModel.swift @@ -1,122 +1,126 @@ -import SwiftUI import MC1Services +import SwiftUI @Observable @MainActor final class RegenerateIdentityViewModel { - var hexPrefix = "" - var isGenerating = false - var isImporting = false - var generatedKey: GeneratedKey? - var showingReplaceAlert = false - var errorMessage: String? - var prefixError: String? - var successTrigger = 0 + var hexPrefix = "" + var isGenerating = false + var isImporting = false + var generatedKey: GeneratedKey? + var showingReplaceAlert = false + var errorMessage: String? + var prefixError: String? + var successTrigger = 0 - private var generateTask: Task? + private var generateTask: Task? - var isBusy: Bool { isGenerating || isImporting } + var isBusy: Bool { + isGenerating || isImporting + } - /// Maximum vanity-prefix length in hex digits (two key bytes). - private static let maxPrefixLength = 4 - /// Key prefixes the firmware reserves for special addressing. - private static let reservedPrefixes = ["00", "FF"] + /// Maximum vanity-prefix length in hex digits (two key bytes). + private static let maxPrefixLength = 4 + /// Key prefixes the firmware reserves for special addressing. + private static let reservedPrefixes = ["00", "FF"] - struct GeneratedKey { - let expandedKey: Data - let publicKeyHex: String - let privateKeyHex: String - let accessibilityLabel: String - } + struct GeneratedKey { + let expandedKey: Data + let publicKeyHex: String + let privateKeyHex: String + let accessibilityLabel: String + } - func cancelGeneration() { - generateTask?.cancel() - } + func cancelGeneration() { + generateTask?.cancel() + } - /// Restricts the vanity prefix to hex digits and clears stale validation feedback. - func sanitizePrefix(_ newValue: String) { - let filtered = String( - newValue.uppercased() - .filter { $0.isASCII && $0.isHexDigit } - .prefix(Self.maxPrefixLength) - ) - if filtered != newValue { - hexPrefix = filtered - } - prefixError = nil + /// Restricts the vanity prefix to hex digits and clears stale validation feedback. + func sanitizePrefix(_ newValue: String) { + let filtered = String( + newValue.uppercased() + .filter { $0.isASCII && $0.isHexDigit } + .prefix(Self.maxPrefixLength) + ) + if filtered != newValue { + hexPrefix = filtered } + prefixError = nil + } - func generateKey() { - prefixError = nil + func generateKey() { + prefixError = nil - // Validate prefix - let upper = hexPrefix.uppercased() - if upper.count >= 2, Self.reservedPrefixes.contains(where: upper.hasPrefix) { - prefixError = L10n.Settings.RegenerateIdentity.Prefix.Error.reserved - return - } + // Validate prefix + let upper = hexPrefix.uppercased() + if upper.count >= 2, Self.reservedPrefixes.contains(where: upper.hasPrefix) { + prefixError = L10n.Settings.RegenerateIdentity.Prefix.Error.reserved + return + } - isGenerating = true - generateTask = Task { - defer { isGenerating = false } - do { - let result = try await KeyGenerationService.generateIdentity( - hexPrefix: upper.isEmpty ? nil : upper - ) - withAnimation { - generatedKey = GeneratedKey( - expandedKey: result.expandedPrivateKey, - publicKeyHex: result.publicKey.uppercaseHexString(separator: " "), - privateKeyHex: result.expandedPrivateKey.uppercaseHexString(separator: " "), - accessibilityLabel: result.publicKey - .map { String(format: "%02X", $0) } - .joined(separator: ", ") - ) - } - } catch is CancellationError { - // Sheet dismissed during generation - } catch { - errorMessage = error.userFacingMessage - } + isGenerating = true + generateTask = Task { + defer { isGenerating = false } + do { + let result = try await KeyGenerationService.generateIdentity( + hexPrefix: upper.isEmpty ? nil : upper + ) + withAnimation { + generatedKey = GeneratedKey( + expandedKey: result.expandedPrivateKey, + publicKeyHex: result.publicKey.uppercaseHexString(separator: " "), + privateKeyHex: result.expandedPrivateKey.uppercaseHexString(separator: " "), + accessibilityLabel: result.publicKey + .map { String(format: "%02X", $0) } + .joined(separator: ", ") + ) } + } catch is CancellationError { + // Sheet dismissed during generation + } catch { + errorMessage = error.userFacingMessage + } } + } - // MARK: - Dependencies + // MARK: - Dependencies - private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } - var settingsService: SettingsService? { settingsServiceProvider() } + private var settingsServiceProvider: @MainActor () -> SettingsService? = { nil } + var settingsService: SettingsService? { + settingsServiceProvider() + } - func configure(settingsService: @escaping @MainActor () -> SettingsService?) { - self.settingsServiceProvider = settingsService - } + func configure(settingsService: @escaping @MainActor () -> SettingsService?) { + settingsServiceProvider = settingsService + } - /// Returns true when the new identity was imported and the sheet should dismiss. - /// A nil service mirrors a disconnected state and is a no-op. - func replaceIdentity() async -> Bool { - guard let expandedKey = generatedKey?.expandedKey, - let settingsService = settingsService else { return false } + /// Returns true when the new identity was imported and the sheet should dismiss. + /// A nil service mirrors a disconnected state and is a no-op. + func replaceIdentity() async -> Bool { + guard let expandedKey = generatedKey?.expandedKey, + let settingsService else { return false } - isImporting = true - defer { isImporting = false } - do { - try await settingsService.importPrivateKey(expandedKey) - try await settingsService.refreshDeviceInfo() - successTrigger += 1 - return true - } catch let error as SettingsServiceError { - if case .sessionError(let meshError) = error, - case .featureDisabled = meshError { - errorMessage = L10n.Settings.RegenerateIdentity.Error.featureDisabled - } else if case .sessionError(let meshError) = error, - case .deviceError = meshError { - errorMessage = L10n.Settings.RegenerateIdentity.Error.deviceRejected - } else { - errorMessage = error.userFacingMessage - } - return false - } catch { - errorMessage = error.userFacingMessage - return false - } + isImporting = true + defer { isImporting = false } + do { + try await settingsService.importPrivateKey(expandedKey) + try await settingsService.refreshDeviceInfo() + successTrigger += 1 + return true + } catch let error as SettingsServiceError { + if case let .sessionError(meshError) = error, + case .featureDisabled = meshError { + errorMessage = L10n.Settings.RegenerateIdentity.Error.featureDisabled + } else if case let .sessionError(meshError) = error, + case .deviceError = meshError { + errorMessage = L10n.Settings.RegenerateIdentity.Error.deviceRejected + } else { + errorMessage = error.userFacingMessage + } + return false + } catch { + errorMessage = error.userFacingMessage + return false } + } } diff --git a/MC1/Views/Settings/Sections/RetryAlertModifier.swift b/MC1/Views/Settings/Sections/RetryAlertModifier.swift index 44e00ef6..71db9f23 100644 --- a/MC1/Views/Settings/Sections/RetryAlertModifier.swift +++ b/MC1/Views/Settings/Sections/RetryAlertModifier.swift @@ -1,5 +1,5 @@ -import SwiftUI import MC1Services +import SwiftUI /// Maximum number of retries before showing final error private let maxRetries = 3 @@ -8,71 +8,71 @@ private let maxRetries = 3 @Observable @MainActor final class RetryAlertState { - var isPresented: Bool = false - var message: String = "" - var retryCount: Int = 0 - var onRetry: (() -> Void)? - var onMaxRetriesExceeded: (() -> Void)? + var isPresented: Bool = false + var message: String = "" + var retryCount: Int = 0 + var onRetry: (() -> Void)? + var onMaxRetriesExceeded: (() -> Void)? - /// Show a retry alert, incrementing the retry count - func show(message: String, onRetry: @escaping () -> Void, onMaxRetriesExceeded: @escaping () -> Void) { - self.retryCount += 1 - self.message = message - self.onRetry = onRetry - self.onMaxRetriesExceeded = onMaxRetriesExceeded - self.isPresented = true - } + /// Show a retry alert, incrementing the retry count + func show(message: String, onRetry: @escaping () -> Void, onMaxRetriesExceeded: @escaping () -> Void) { + retryCount += 1 + self.message = message + self.onRetry = onRetry + self.onMaxRetriesExceeded = onMaxRetriesExceeded + isPresented = true + } - /// Reset retry count (call when operation succeeds or user cancels) - func reset() { - retryCount = 0 - onRetry = nil - onMaxRetriesExceeded = nil - } + /// Reset retry count (call when operation succeeds or user cancels) + func reset() { + retryCount = 0 + onRetry = nil + onMaxRetriesExceeded = nil + } - /// Whether max retries have been exceeded - var isMaxRetriesExceeded: Bool { - retryCount >= maxRetries - } + /// Whether max retries have been exceeded + var isMaxRetriesExceeded: Bool { + retryCount >= maxRetries + } } /// ViewModifier for presenting retry alerts with retry counting struct RetryAlertModifier: ViewModifier { - @Bindable var state: RetryAlertState + @Bindable var state: RetryAlertState - func body(content: Content) -> some View { - content - .alert( - state.isMaxRetriesExceeded - ? L10n.Settings.Alert.Retry.unableToSave - : L10n.Settings.Alert.Retry.connectionError, - isPresented: $state.isPresented - ) { - if state.isMaxRetriesExceeded { - Button(L10n.Localizable.Common.ok) { - state.onMaxRetriesExceeded?() - state.reset() - } - } else { - Button(L10n.Settings.Alert.Retry.retry) { - state.onRetry?() - } - Button(L10n.Localizable.Common.cancel, role: .cancel) { - state.reset() - } - } - } message: { - if state.isMaxRetriesExceeded { - Text(L10n.Settings.Alert.Retry.ensureConnected) - } else { - Text(state.message) - } - } - } + func body(content: Content) -> some View { + content + .alert( + state.isMaxRetriesExceeded + ? L10n.Settings.Alert.Retry.unableToSave + : L10n.Settings.Alert.Retry.connectionError, + isPresented: $state.isPresented + ) { + if state.isMaxRetriesExceeded { + Button(L10n.Localizable.Common.ok) { + state.onMaxRetriesExceeded?() + state.reset() + } + } else { + Button(L10n.Settings.Alert.Retry.retry) { + state.onRetry?() + } + Button(L10n.Localizable.Common.cancel, role: .cancel) { + state.reset() + } + } + } message: { + if state.isMaxRetriesExceeded { + Text(L10n.Settings.Alert.Retry.ensureConnected) + } else { + Text(state.message) + } + } + } } extension View { - func retryAlert(_ state: RetryAlertState) -> some View { - modifier(RetryAlertModifier(state: state)) - } + func retryAlert(_ state: RetryAlertState) -> some View { + modifier(RetryAlertModifier(state: state)) + } } diff --git a/MC1/Views/Settings/Sections/StaleNodeCleanupSection.swift b/MC1/Views/Settings/Sections/StaleNodeCleanupSection.swift index 67d5035e..809ef9d6 100644 --- a/MC1/Views/Settings/Sections/StaleNodeCleanupSection.swift +++ b/MC1/Views/Settings/Sections/StaleNodeCleanupSection.swift @@ -3,69 +3,68 @@ import SwiftUI /// Settings section for automatic cleanup of stale non-favorite nodes struct StaleNodeCleanupSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.autoDeleteStaleNodesDays.rawValue) - private var threshold: Int = AppStorageKey.defaultAutoDeleteStaleNodesDays - @AppStorage(AppStorageKey.lastStaleCleanupDate.rawValue) - private var lastCleanupTimestamp: Double = AppStorageKey.defaultLastStaleCleanupDate + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @AppStorage(AppStorageKey.autoDeleteStaleNodesDays.rawValue) + private var threshold: Int = AppStorageKey.defaultAutoDeleteStaleNodesDays + @AppStorage(AppStorageKey.lastStaleCleanupDate.rawValue) + private var lastCleanupTimestamp: Double = AppStorageKey.defaultLastStaleCleanupDate - @State private var isEnabled = false + @State private var isEnabled = false - var body: some View { - Section { - Toggle(L10n.Settings.Nodes.StaleCleanup.header, isOn: $isEnabled) + var body: some View { + Section { + Toggle(L10n.Settings.Nodes.StaleCleanup.header, isOn: $isEnabled) - if isEnabled { - Picker(L10n.Settings.Nodes.StaleCleanup.threshold, selection: $threshold) { - Text(L10n.Settings.Nodes.StaleCleanup.select).tag(0) - Text(L10n.Settings.Nodes.StaleCleanup.days(7)).tag(7) - Text(L10n.Settings.Nodes.StaleCleanup.days(14)).tag(14) - Text(L10n.Settings.Nodes.StaleCleanup.days(30)).tag(30) - Text(L10n.Settings.Nodes.StaleCleanup.days(90)).tag(90) - } - .pickerStyle(.menu) - - if lastCleanupTimestamp > 0, threshold > 0 { - Text(L10n.Settings.Nodes.StaleCleanup.lastRun( - Date(timeIntervalSinceReferenceDate: lastCleanupTimestamp) - .formatted(.relative(presentation: .named)) - )) - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - } footer: { - if !isEnabled { - Text(L10n.Settings.Nodes.StaleCleanup.footerDisabled) - } else if threshold == 0 { - Text(L10n.Settings.Nodes.StaleCleanup.footerSelect) - } else if appState.connectionState == .ready { - Text(L10n.Settings.Nodes.StaleCleanup.footerEnabled(threshold)) - } else { - Text(L10n.Settings.Nodes.StaleCleanup.footerDisconnected) - } - } - .themedRowBackground(theme) - .radioDisabled(for: appState.connectionState) - .onAppear { - isEnabled = threshold > 0 - } - .onChange(of: isEnabled) { _, newValue in - if !newValue { - threshold = 0 - } + if isEnabled { + Picker(L10n.Settings.Nodes.StaleCleanup.threshold, selection: $threshold) { + Text(L10n.Settings.Nodes.StaleCleanup.select).tag(0) + Text(L10n.Settings.Nodes.StaleCleanup.days(7)).tag(7) + Text(L10n.Settings.Nodes.StaleCleanup.days(14)).tag(14) + Text(L10n.Settings.Nodes.StaleCleanup.days(30)).tag(30) + Text(L10n.Settings.Nodes.StaleCleanup.days(90)).tag(90) } - .onChange(of: threshold) { _, newThreshold in - guard newThreshold > 0, appState.connectionState == .ready else { return } - appState.performStaleNodeCleanup(force: true) + .pickerStyle(.menu) + + if lastCleanupTimestamp > 0, threshold > 0 { + Text(L10n.Settings.Nodes.StaleCleanup.lastRun( + Date(timeIntervalSinceReferenceDate: lastCleanupTimestamp) + .formatted(.relative(presentation: .named)) + )) + .foregroundStyle(.secondary) + .font(.subheadline) } + } + } footer: { + if !isEnabled { + Text(L10n.Settings.Nodes.StaleCleanup.footerDisabled) + } else if threshold == 0 { + Text(L10n.Settings.Nodes.StaleCleanup.footerSelect) + } else if appState.connectionState == .ready { + Text(L10n.Settings.Nodes.StaleCleanup.footerEnabled(threshold)) + } else { + Text(L10n.Settings.Nodes.StaleCleanup.footerDisconnected) + } } - + .themedRowBackground(theme) + .radioDisabled(for: appState.connectionState) + .onAppear { + isEnabled = threshold > 0 + } + .onChange(of: isEnabled) { _, newValue in + if !newValue { + threshold = 0 + } + } + .onChange(of: threshold) { _, newThreshold in + guard newThreshold > 0, appState.connectionState == .ready else { return } + appState.performStaleNodeCleanup(force: true) + } + } } #Preview { - Form { - StaleNodeCleanupSection() - } + Form { + StaleNodeCleanupSection() + } } diff --git a/MC1/Views/Settings/Sections/TelemetrySettingsSection.swift b/MC1/Views/Settings/Sections/TelemetrySettingsSection.swift index eb5a2f18..0e7e3945 100644 --- a/MC1/Views/Settings/Sections/TelemetrySettingsSection.swift +++ b/MC1/Views/Settings/Sections/TelemetrySettingsSection.swift @@ -1,158 +1,160 @@ -import SwiftUI import MC1Services +import SwiftUI /// Firmware telemetry permission levels, shared by the base, location, and environment modes. private enum TelemetryMode { - static let off: UInt8 = 0 - static let trustedOnly: UInt8 = 1 - static let everyone: UInt8 = 2 + static let off: UInt8 = 0 + static let trustedOnly: UInt8 = 1 + static let everyone: UInt8 = 2 } /// Telemetry sharing configuration struct TelemetrySettingsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var errorMessage: String? - @State private var retryAlert = RetryAlertState() - @State private var isSaving = false - - private var device: DeviceDTO? { appState.connectedDevice } - - var body: some View { - Section { - Toggle(isOn: telemetryEnabledBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Settings.Telemetry.allowRequests) - Text(L10n.Settings.Telemetry.allowRequestsDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .radioDisabled(for: appState.connectionState, or: isSaving) - - if device?.telemetryModeBase ?? TelemetryMode.off > TelemetryMode.off { - Toggle(isOn: locationEnabledBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Settings.Telemetry.includeLocation) - Text(L10n.Settings.Telemetry.includeLocationDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .radioDisabled(for: appState.connectionState, or: isSaving) - - Toggle(isOn: environmentEnabledBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Settings.Telemetry.includeEnvironment) - Text(L10n.Settings.Telemetry.includeEnvironmentDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .radioDisabled(for: appState.connectionState, or: isSaving) - - Toggle(isOn: filterByTrustedBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Settings.Telemetry.trustedOnly) - Text(L10n.Settings.Telemetry.trustedOnlyDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .radioDisabled(for: appState.connectionState, or: isSaving) - - if isFilterByTrusted { - NavigationLink(value: SettingsSubpage.trustedContacts) { - Text(L10n.Settings.Telemetry.manageTrusted) - } - } - } - } header: { - Text(L10n.Settings.Telemetry.header) - } footer: { - Text(L10n.Settings.Telemetry.footer) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var errorMessage: String? + @State private var retryAlert = RetryAlertState() + @State private var isSaving = false + + private var device: DeviceDTO? { + appState.connectedDevice + } + + var body: some View { + Section { + Toggle(isOn: telemetryEnabledBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Telemetry.allowRequests) + Text(L10n.Settings.Telemetry.allowRequestsDescription) + .font(.caption) + .foregroundStyle(.secondary) } - .themedRowBackground(theme) - .errorAlert($errorMessage) - .retryAlert(retryAlert) - } - - // MARK: - Bindings - - private var isFilterByTrusted: Bool { - device?.telemetryModeBase == TelemetryMode.trustedOnly - } - - /// Mode value for "enabled" telemetry: trusted-only if trusted filtering is active, everyone otherwise - private var enabledMode: UInt8 { - isFilterByTrusted ? TelemetryMode.trustedOnly : TelemetryMode.everyone - } + } + .radioDisabled(for: appState.connectionState, or: isSaving) + + if device?.telemetryModeBase ?? TelemetryMode.off > TelemetryMode.off { + Toggle(isOn: locationEnabledBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Telemetry.includeLocation) + Text(L10n.Settings.Telemetry.includeLocationDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .radioDisabled(for: appState.connectionState, or: isSaving) + + Toggle(isOn: environmentEnabledBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Telemetry.includeEnvironment) + Text(L10n.Settings.Telemetry.includeEnvironmentDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .radioDisabled(for: appState.connectionState, or: isSaving) + + Toggle(isOn: filterByTrustedBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Telemetry.trustedOnly) + Text(L10n.Settings.Telemetry.trustedOnlyDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .radioDisabled(for: appState.connectionState, or: isSaving) - private var telemetryEnabledBinding: Binding { - Binding( - get: { device?.telemetryModeBase ?? TelemetryMode.off > TelemetryMode.off }, - set: { saveTelemetry(base: $0 ? enabledMode : TelemetryMode.off) } - ) + if isFilterByTrusted { + NavigationLink(value: SettingsSubpage.trustedContacts) { + Text(L10n.Settings.Telemetry.manageTrusted) + } + } + } + } header: { + Text(L10n.Settings.Telemetry.header) + } footer: { + Text(L10n.Settings.Telemetry.footer) } - - private var locationEnabledBinding: Binding { - Binding( - get: { device?.telemetryModeLoc ?? TelemetryMode.off > TelemetryMode.off }, - set: { saveTelemetry(location: $0 ? enabledMode : TelemetryMode.off) } + .themedRowBackground(theme) + .errorAlert($errorMessage) + .retryAlert(retryAlert) + } + + // MARK: - Bindings + + private var isFilterByTrusted: Bool { + device?.telemetryModeBase == TelemetryMode.trustedOnly + } + + /// Mode value for "enabled" telemetry: trusted-only if trusted filtering is active, everyone otherwise + private var enabledMode: UInt8 { + isFilterByTrusted ? TelemetryMode.trustedOnly : TelemetryMode.everyone + } + + private var telemetryEnabledBinding: Binding { + Binding( + get: { device?.telemetryModeBase ?? TelemetryMode.off > TelemetryMode.off }, + set: { saveTelemetry(base: $0 ? enabledMode : TelemetryMode.off) } + ) + } + + private var locationEnabledBinding: Binding { + Binding( + get: { device?.telemetryModeLoc ?? TelemetryMode.off > TelemetryMode.off }, + set: { saveTelemetry(location: $0 ? enabledMode : TelemetryMode.off) } + ) + } + + private var environmentEnabledBinding: Binding { + Binding( + get: { device?.telemetryModeEnv ?? TelemetryMode.off > TelemetryMode.off }, + set: { saveTelemetry(environment: $0 ? enabledMode : TelemetryMode.off) } + ) + } + + private var filterByTrustedBinding: Binding { + Binding( + get: { device?.telemetryModeBase == TelemetryMode.trustedOnly }, + set: { newValue in + let mode: UInt8 = newValue ? TelemetryMode.trustedOnly : TelemetryMode.everyone + saveTelemetry( + base: (device?.telemetryModeBase ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off, + location: (device?.telemetryModeLoc ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off, + environment: (device?.telemetryModeEnv ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off ) - } - - private var environmentEnabledBinding: Binding { - Binding( - get: { device?.telemetryModeEnv ?? TelemetryMode.off > TelemetryMode.off }, - set: { saveTelemetry(environment: $0 ? enabledMode : TelemetryMode.off) } + } + ) + } + + // MARK: - Save + + private func saveTelemetry( + base: UInt8? = nil, + location: UInt8? = nil, + environment: UInt8? = nil + ) { + guard let device, let settingsService = appState.services?.settingsService else { return } + + isSaving = true + Task { + do { + let modes = TelemetryModes( + base: base ?? device.telemetryModeBase, + location: location ?? device.telemetryModeLoc, + environment: environment ?? device.telemetryModeEnv ) - } - - private var filterByTrustedBinding: Binding { - Binding( - get: { device?.telemetryModeBase == TelemetryMode.trustedOnly }, - set: { newValue in - let mode: UInt8 = newValue ? TelemetryMode.trustedOnly : TelemetryMode.everyone - saveTelemetry( - base: (device?.telemetryModeBase ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off, - location: (device?.telemetryModeLoc ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off, - environment: (device?.telemetryModeEnv ?? TelemetryMode.off) > TelemetryMode.off ? mode : TelemetryMode.off - ) - } + _ = try await settingsService.setOtherParamsVerified(from: device, telemetryModes: modes) + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + retryAlert.show( + message: error.userFacingMessage, + onRetry: { saveTelemetry(base: base, location: location, environment: environment) }, + onMaxRetriesExceeded: { dismiss() } ) + } catch { + errorMessage = error.userFacingMessage + } + isSaving = false } - - // MARK: - Save - - private func saveTelemetry( - base: UInt8? = nil, - location: UInt8? = nil, - environment: UInt8? = nil - ) { - guard let device, let settingsService = appState.services?.settingsService else { return } - - isSaving = true - Task { - do { - let modes = TelemetryModes( - base: base ?? device.telemetryModeBase, - location: location ?? device.telemetryModeLoc, - environment: environment ?? device.telemetryModeEnv - ) - _ = try await settingsService.setOtherParamsVerified(from: device, telemetryModes: modes) - retryAlert.reset() - } catch let error as SettingsServiceError where error.isRetryable { - retryAlert.show( - message: error.userFacingMessage, - onRetry: { saveTelemetry(base: base, location: location, environment: environment) }, - onMaxRetriesExceeded: { dismiss() } - ) - } catch { - errorMessage = error.userFacingMessage - } - isSaving = false - } - } + } } diff --git a/MC1/Views/Settings/Sections/WiFiEditSheet.swift b/MC1/Views/Settings/Sections/WiFiEditSheet.swift index 3b5cb4bf..e6453faf 100644 --- a/MC1/Views/Settings/Sections/WiFiEditSheet.swift +++ b/MC1/Views/Settings/Sections/WiFiEditSheet.swift @@ -1,137 +1,136 @@ -import SwiftUI import MC1Services +import SwiftUI /// Sheet for editing WiFi connection parameters. /// Pre-populates with current connection details and allows updating them. struct WiFiEditSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - /// Optional initial values for editing a saved (non-connected) device - var initialHost: String? - var initialPort: UInt16? - - @State private var ipAddress = "" - @State private var port = "5000" - @State private var isReconnecting = false - @State private var errorMessage: String? - - @FocusState private var focusedField: WiFiField? - - private var currentConnection: ConnectionMethod? { - appState.connectedDevice?.connectionMethods.first { $0.isWiFi } - } - - private var originalHost: String? { - if let initialHost { return initialHost } - if case .wifi(let host, _, _) = currentConnection { return host } - return nil - } - - private var originalPort: UInt16? { - if let initialPort { return initialPort } - if case .wifi(_, let port, _) = currentConnection { return port } - return nil - } - - private var isValidInput: Bool { - WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) - } - - private var hasChanges: Bool { - guard let host = originalHost, let currentPort = originalPort else { return true } - return ipAddress != host || port != String(currentPort) - } + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + /// Optional initial values for editing a saved (non-connected) device + var initialHost: String? + var initialPort: UInt16? + + @State private var ipAddress = "" + @State private var port = "5000" + @State private var isReconnecting = false + @State private var errorMessage: String? + + @FocusState private var focusedField: WiFiField? + + private var currentConnection: ConnectionMethod? { + appState.connectedDevice?.connectionMethods.first { $0.isWiFi } + } + + private var originalHost: String? { + if let initialHost { return initialHost } + if case let .wifi(host, _, _) = currentConnection { return host } + return nil + } + + private var originalPort: UInt16? { + if let initialPort { return initialPort } + if case let .wifi(_, port, _) = currentConnection { return port } + return nil + } + + private var isValidInput: Bool { + WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) + } + + private var hasChanges: Bool { + guard let host = originalHost, let currentPort = originalPort else { return true } + return ipAddress != host || port != String(currentPort) + } + + var body: some View { + NavigationStack { + Form { + WiFiAddressFields( + ipAddress: $ipAddress, + port: $port, + focusedField: $focusedField, + sectionHeader: L10n.Settings.WifiEdit.connectionDetails, + sectionFooter: L10n.Settings.WifiEdit.footer, + onPortSubmit: { saveChanges() } + ) + + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + .themedRowBackground(theme) + } - var body: some View { - NavigationStack { - Form { - WiFiAddressFields( - ipAddress: $ipAddress, - port: $port, - focusedField: $focusedField, - sectionHeader: L10n.Settings.WifiEdit.connectionDetails, - sectionFooter: L10n.Settings.WifiEdit.footer, - onPortSubmit: { saveChanges() } - ) - - if let errorMessage { - Section { - Label(errorMessage, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - } - .themedRowBackground(theme) - } - - Section { - Button { - saveChanges() - } label: { - HStack { - Spacer() - if isReconnecting { - ProgressView() - .controlSize(.small) - Text(L10n.Settings.WifiEdit.reconnecting) - } else { - Text(L10n.Settings.WifiEdit.saveChanges) - } - Spacer() - } - } - .disabled(!isValidInput || !hasChanges || isReconnecting) - } - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.WifiEdit.title) - .navigationBarTitleDisplayMode(.inline) - .wifiSheetToolbar(focusedField: $focusedField, isProcessing: isReconnecting) - .interactiveDismissDisabled(isReconnecting) - .onAppear { - populateCurrentValues() + Section { + Button { + saveChanges() + } label: { + HStack { + Spacer() + if isReconnecting { + ProgressView() + .controlSize(.small) + Text(L10n.Settings.WifiEdit.reconnecting) + } else { + Text(L10n.Settings.WifiEdit.saveChanges) + } + Spacer() } + } + .disabled(!isValidInput || !hasChanges || isReconnecting) } - .presentationSizing(.page) + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.WifiEdit.title) + .navigationBarTitleDisplayMode(.inline) + .wifiSheetToolbar(focusedField: $focusedField, isProcessing: isReconnecting) + .interactiveDismissDisabled(isReconnecting) + .onAppear { + populateCurrentValues() + } } + .presentationSizing(.page) + } - private func populateCurrentValues() { - if let host = originalHost { - ipAddress = host - } - if let currentPort = originalPort { - port = String(currentPort) - } + private func populateCurrentValues() { + if let host = originalHost { + ipAddress = host } + if let currentPort = originalPort { + port = String(currentPort) + } + } - private func saveChanges() { - focusedField = nil - - guard let portNumber = UInt16(port) else { - errorMessage = L10n.Settings.WifiEdit.Error.invalidPort - return - } + private func saveChanges() { + focusedField = nil - isReconnecting = true - errorMessage = nil - - Task { - do { - // Disconnect from current connection, then connect to new address - await appState.disconnect(reason: .wifiAddressChange) - try await appState.connectViaWiFi(host: ipAddress, port: portNumber, forceFullSync: true) - dismiss() - } catch { - errorMessage = error.userFacingMessage - isReconnecting = false - } - } + guard let portNumber = UInt16(port) else { + errorMessage = L10n.Settings.WifiEdit.Error.invalidPort + return } + isReconnecting = true + errorMessage = nil + + Task { + do { + // Disconnect from current connection, then connect to new address + await appState.disconnect(reason: .wifiAddressChange) + try await appState.connectViaWiFi(host: ipAddress, port: portNumber, forceFullSync: true) + dismiss() + } catch { + errorMessage = error.userFacingMessage + isReconnecting = false + } + } + } } #Preview { - WiFiEditSheet() - .environment(\.appState, AppState()) + WiFiEditSheet() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Settings/Sections/WiFiSection.swift b/MC1/Views/Settings/Sections/WiFiSection.swift index 6a328506..f5f35451 100644 --- a/MC1/Views/Settings/Sections/WiFiSection.swift +++ b/MC1/Views/Settings/Sections/WiFiSection.swift @@ -1,39 +1,39 @@ -import SwiftUI import MC1Services +import SwiftUI /// WiFi connection settings - shown when connected via WiFi instead of Bluetooth. struct WiFiSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Binding var showingEditSheet: Bool + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Binding var showingEditSheet: Bool - private var currentConnection: ConnectionMethod? { - appState.connectedDevice?.connectionMethods.first { $0.isWiFi } - } + private var currentConnection: ConnectionMethod? { + appState.connectedDevice?.connectionMethods.first { $0.isWiFi } + } - var body: some View { - Section { - if case .wifi(let host, let port, _) = currentConnection { - LabeledContent(L10n.Settings.Wifi.address, value: host) - LabeledContent(L10n.Settings.Wifi.port, value: "\(port)") - } + var body: some View { + Section { + if case let .wifi(host, port, _) = currentConnection { + LabeledContent(L10n.Settings.Wifi.address, value: host) + LabeledContent(L10n.Settings.Wifi.port, value: "\(port)") + } - Button(L10n.Settings.Wifi.editConnection) { - showingEditSheet = true - } - } header: { - Text(L10n.Settings.Wifi.header) - } footer: { - Text(L10n.Settings.Wifi.footer) - } - .themedRowBackground(theme) + Button(L10n.Settings.Wifi.editConnection) { + showingEditSheet = true + } + } header: { + Text(L10n.Settings.Wifi.header) + } footer: { + Text(L10n.Settings.Wifi.footer) } + .themedRowBackground(theme) + } } #Preview { - @Previewable @State var showingEditSheet = false - List { - WiFiSection(showingEditSheet: $showingEditSheet) - } - .environment(\.appState, AppState()) + @Previewable @State var showingEditSheet = false + List { + WiFiSection(showingEditSheet: $showingEditSheet) + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Settings/SettingsDetail.swift b/MC1/Views/Settings/SettingsDetail.swift index de3b4487..a16f6aaa 100644 --- a/MC1/Views/Settings/SettingsDetail.swift +++ b/MC1/Views/Settings/SettingsDetail.swift @@ -5,26 +5,26 @@ import SwiftUI /// (`SettingsListContent` list selection + `SettingsDetailView` detail), and persisted as the /// active selection on `NavigationCoordinator`. enum SettingsDetail: Hashable { - case deviceInfo - case radio - case location - case connection - case advanced - case notifications - case chats - case appearance - case offlineMaps - case backup - case support + case deviceInfo + case radio + case location + case connection + case advanced + case notifications + case chats + case appearance + case offlineMaps + case backup + case support - /// The My Device rows only exist while a radio is connected; clearing their selection on - /// disconnect or a radio switch keeps the detail pane from stranding a now-gone device page. - var requiresDevice: Bool { - switch self { - case .deviceInfo, .radio, .location, .connection, .advanced: - true - case .notifications, .chats, .appearance, .offlineMaps, .backup, .support: - false - } + /// The My Device rows only exist while a radio is connected; clearing their selection on + /// disconnect or a radio switch keeps the detail pane from stranding a now-gone device page. + var requiresDevice: Bool { + switch self { + case .deviceInfo, .radio, .location, .connection, .advanced: + true + case .notifications, .chats, .appearance, .offlineMaps, .backup, .support: + false } + } } diff --git a/MC1/Views/Settings/SettingsDetailRow.swift b/MC1/Views/Settings/SettingsDetailRow.swift index 0f6a6cb5..adf5df7d 100644 --- a/MC1/Views/Settings/SettingsDetailRow.swift +++ b/MC1/Views/Settings/SettingsDetailRow.swift @@ -5,12 +5,12 @@ import SwiftUI /// `List(selection:)` binding (`NavigationCoordinator.selectedSetting`, read by `SettingsDetailView` /// in the detail column). struct SettingsDetailRow: View { - let detail: SettingsDetail - @ViewBuilder let label: () -> Label + let detail: SettingsDetail + @ViewBuilder let label: () -> Label - var body: some View { - NavigationLink(value: detail) { - label() - } + var body: some View { + NavigationLink(value: detail) { + label() } + } } diff --git a/MC1/Views/Settings/SettingsDetailView.swift b/MC1/Views/Settings/SettingsDetailView.swift index e4cbd3dc..9788f0c3 100644 --- a/MC1/Views/Settings/SettingsDetailView.swift +++ b/MC1/Views/Settings/SettingsDetailView.swift @@ -4,39 +4,39 @@ import SwiftUI /// pages, used by the compact `SettingsView` (`navigationDestination`) and the iPad split's detail /// column (`MainSidebarView`). struct SettingsDetailView: View { - @Environment(\.appState) private var appState - let detail: SettingsDetail + @Environment(\.appState) private var appState + let detail: SettingsDetail - var body: some View { - switch detail { - case .deviceInfo: - DeviceInfoView() - case .radio: - RadioSettingsView() - case .location: - LocationSettingsView() - case .connection: - ConnectionSettingsView() - case .advanced: - AdvancedSettingsView() - case .notifications: - NotificationSettingsView() - case .chats: - ChatSettingsView() - case .appearance: - AppearanceView() - case .offlineMaps: - OfflineMapSettingsView() - case .backup: - BackupRestoreView( - connectionManager: appState.connectionManager, - onImportRestoredData: { [appState] in appState.notifyDataRestored() }, - onChannelDraftSlotsAffected: { [appState] slotsByRadio in - appState.draftStore.clearChannelDrafts(slotsByRadio: slotsByRadio) - } - ) - case .support: - SupportDevelopmentView() + var body: some View { + switch detail { + case .deviceInfo: + DeviceInfoView() + case .radio: + RadioSettingsView() + case .location: + LocationSettingsView() + case .connection: + ConnectionSettingsView() + case .advanced: + AdvancedSettingsView() + case .notifications: + NotificationSettingsView() + case .chats: + ChatSettingsView() + case .appearance: + AppearanceView() + case .offlineMaps: + OfflineMapSettingsView() + case .backup: + BackupRestoreView( + connectionManager: appState.connectionManager, + onImportRestoredData: { [appState] in appState.notifyDataRestored() }, + onChannelDraftSlotsAffected: { [appState] slotsByRadio in + appState.draftStore.clearChannelDrafts(slotsByRadio: slotsByRadio) } + ) + case .support: + SupportDevelopmentView() } + } } diff --git a/MC1/Views/Settings/SettingsListContent.swift b/MC1/Views/Settings/SettingsListContent.swift index 3e995acf..60dd4fc6 100644 --- a/MC1/Views/Settings/SettingsListContent.swift +++ b/MC1/Views/Settings/SettingsListContent.swift @@ -1,257 +1,256 @@ -import SwiftUI import MC1Services +import SwiftUI import TipKit /// The settings list itself, shared by the compact `SettingsView` (stack) and the iPad /// `MainSidebarView` content column (split). `isSidebar` switches the iPad-only selection binding /// and themed-row treatment; the compact stack pushes via value-based `NavigationLink` instead. struct SettingsListContent: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.openURL) private var openURL - @Binding var showingDeviceSelection: Bool - @Bindable var demoModeManager: DemoModeManager - /// `true` when this list is the iPad split-view sidebar column, whose `.sidebar` style draws - /// rows transparent so the column canvas shows through and the native selection highlight shows. - let isSidebar: Bool - @State private var exportedLogFile: ExportedLogFile? - private let liveActivityTip = LiveActivityTip() - - /// Drives the iPad split's selected settings page. The compact stack leaves it nil — inside a - /// `NavigationStack` a value-based `NavigationLink` drives the stack path (via `SettingsView`'s - /// `navigationDestination`), not the list selection — so persistent selection stays iPad-only. - private var settingSelection: Binding { - Binding( - get: { appState.navigation.selectedSetting }, - set: { appState.navigation.selectedSetting = $0 } - ) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.openURL) private var openURL + @Binding var showingDeviceSelection: Bool + @Bindable var demoModeManager: DemoModeManager + /// `true` when this list is the iPad split-view sidebar column, whose `.sidebar` style draws + /// rows transparent so the column canvas shows through and the native selection highlight shows. + let isSidebar: Bool + @State private var exportedLogFile: ExportedLogFile? + private let liveActivityTip = LiveActivityTip() + + /// Drives the iPad split's selected settings page. The compact stack leaves it nil — inside a + /// `NavigationStack` a value-based `NavigationLink` drives the stack path (via `SettingsView`'s + /// `navigationDestination`), not the list selection — so persistent selection stays iPad-only. + private var settingSelection: Binding { + Binding( + get: { appState.navigation.selectedSetting }, + set: { appState.navigation.selectedSetting = $0 } + ) + } + + var body: some View { + settingsList + .themedCanvas(theme) + .navigationTitle(L10n.Settings.title) + .toolbar { + bleStatusToolbarItem() + } + .sheet(isPresented: $showingDeviceSelection) { + DeviceSelectionSheet() + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + .sheet(item: $exportedLogFile) { file in + ActivityView(activityItems: [file.url]) + } + } + + /// Only the iPad sidebar column carries a selection binding, where it drives the detail pane. The + /// compact stack must omit it: a `List(selection:)` turns a row tap into a selection instead of + /// letting the value-based `NavigationLink` push, which would break compact navigation. + @ViewBuilder + private var settingsList: some View { + if isSidebar { + List(selection: settingSelection) { sections } + .listStyle(.sidebar) + } else { + List { sections } } - - var body: some View { - settingsList - .themedCanvas(theme) - .navigationTitle(L10n.Settings.title) - .toolbar { - bleStatusToolbarItem() - } - .sheet(isPresented: $showingDeviceSelection) { - DeviceSelectionSheet() - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - .sheet(item: $exportedLogFile) { file in - ActivityView(activityItems: [file.url]) - } + } + + @ViewBuilder + private var sections: some View { + if let device = appState.connectedDevice { + MyDeviceSection(device: device, isSidebar: isSidebar) + } else { + NoDeviceSection(showingDeviceSelection: $showingDeviceSelection, isSidebar: isSidebar) } - /// Only the iPad sidebar column carries a selection binding, where it drives the detail pane. The - /// compact stack must omit it: a `List(selection:)` turns a row tap into a selection instead of - /// letting the value-based `NavigationLink` push, which would break compact navigation. - @ViewBuilder - private var settingsList: some View { - if isSidebar { - List(selection: settingSelection) { sections } - .listStyle(.sidebar) - } else { - List { sections } - } - } + Section { + SettingsDetailRow(detail: .notifications) { + TintedLabel(L10n.Settings.Notifications.header, systemImage: "bell.badge") + } - @ViewBuilder - private var sections: some View { - if let device = appState.connectedDevice { - MyDeviceSection(device: device, isSidebar: isSidebar) - } else { - NoDeviceSection(showingDeviceSelection: $showingDeviceSelection, isSidebar: isSidebar) - } + SettingsDetailRow(detail: .chats) { + TintedLabel(L10n.Settings.ChatSettings.title, systemImage: "bubble.left.and.bubble.right") + } - Section { - SettingsDetailRow(detail: .notifications) { - TintedLabel(L10n.Settings.Notifications.header, systemImage: "bell.badge") - } + AppearanceSection() - SettingsDetailRow(detail: .chats) { - TintedLabel(L10n.Settings.ChatSettings.title, systemImage: "bubble.left.and.bubble.right") - } + TipView(liveActivityTip, arrowEdge: .bottom) - AppearanceSection() - - TipView(liveActivityTip, arrowEdge: .bottom) - - Toggle(isOn: Binding( - get: { appState.liveActivityManager.isEnabled }, - set: { newValue in - appState.liveActivityManager.isEnabled = newValue - Task { - if !newValue { - await appState.liveActivityManager.endActivity() - } else if appState.connectionState.isConnected { - await appState.wireServicesIfConnected() - } - } - } - )) { - TintedLabel(L10n.Settings.LiveActivity.title, systemImage: "platter.filled.bottom.and.arrow.down.iphone") + Toggle(isOn: Binding( + get: { appState.liveActivityManager.isEnabled }, + set: { newValue in + appState.liveActivityManager.isEnabled = newValue + Task { + if !newValue { + await appState.liveActivityManager.endActivity() + } else if appState.connectionState.isConnected { + await appState.wireServicesIfConnected() } + } + } + )) { + TintedLabel(L10n.Settings.LiveActivity.title, systemImage: "platter.filled.bottom.and.arrow.down.iphone") + } - Button { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } label: { - SettingsRow( - L10n.Settings.Language.title, - systemImage: "globe", - detail: currentLanguageDisplayName - ) - } + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } label: { + SettingsRow( + L10n.Settings.Language.title, + systemImage: "globe", + detail: currentLanguageDisplayName + ) + } - SettingsDetailRow(detail: .offlineMaps) { - TintedLabel(L10n.Settings.OfflineMaps.title, systemImage: "map.fill") - } + SettingsDetailRow(detail: .offlineMaps) { + TintedLabel(L10n.Settings.OfflineMaps.title, systemImage: "map.fill") + } - SettingsDetailRow(detail: .backup) { - TintedLabel(L10n.Settings.Settings.Backup.title, systemImage: "archivebox.fill") - } - } header: { - Text(L10n.Settings.AppSettings.header) - } - .themedRowBackground(theme, flatten: isSidebar) + SettingsDetailRow(detail: .backup) { + TintedLabel(L10n.Settings.Settings.Backup.title, systemImage: "archivebox.fill") + } + } header: { + Text(L10n.Settings.AppSettings.header) + } + .themedRowBackground(theme, flatten: isSidebar) - AboutSection(isSidebar: isSidebar) + AboutSection(isSidebar: isSidebar) - DiagnosticsSection(exportedFile: $exportedLogFile, isSidebar: isSidebar) + DiagnosticsSection(exportedFile: $exportedLogFile, isSidebar: isSidebar) - if demoModeManager.isUnlocked { - Section { - Toggle(L10n.Settings.DemoMode.enabled, isOn: $demoModeManager.isEnabled) - } header: { - Text(L10n.Settings.DemoMode.header) - } footer: { - Text(L10n.Settings.DemoMode.footer) - } - .themedRowBackground(theme, flatten: isSidebar) - } + if demoModeManager.isUnlocked { + Section { + Toggle(L10n.Settings.DemoMode.enabled, isOn: $demoModeManager.isEnabled) + } header: { + Text(L10n.Settings.DemoMode.header) + } footer: { + Text(L10n.Settings.DemoMode.footer) + } + .themedRowBackground(theme, flatten: isSidebar) + } - #if DEBUG - Section { - Button { - appState.onboarding.resetOnboarding() - } label: { - TintedLabel("Reset Onboarding", systemImage: "arrow.counterclockwise") - } - } header: { - Text("Debug") - } - .themedRowBackground(theme, flatten: isSidebar) - #endif - - Section { - } footer: { - let version = Bundle.main.appVersion - let build = Bundle.main.appBuild - VStack { - Text(L10n.Settings.version(version)) - Text(L10n.Settings.build(build)) - } - .padding(.top, -8) - .padding(.bottom) - .frame(maxWidth: .infinity) + #if DEBUG + Section { + Button { + appState.onboarding.resetOnboarding() + } label: { + TintedLabel("Reset Onboarding", systemImage: "arrow.counterclockwise") } - .themedRowBackground(theme, flatten: isSidebar) + } header: { + Text("Debug") + } + .themedRowBackground(theme, flatten: isSidebar) + #endif + + Section {} footer: { + let version = Bundle.main.appVersion + let build = Bundle.main.appBuild + VStack { + Text(L10n.Settings.version(version)) + Text(L10n.Settings.build(build)) + } + .padding(.top, -8) + .padding(.bottom) + .frame(maxWidth: .infinity) } + .themedRowBackground(theme, flatten: isSidebar) + } - private var currentLanguageDisplayName: String { - let code = Bundle.main.preferredLocalizations.first ?? "en" - return Locale.current.localizedString(forLanguageCode: code) ?? code - } + private var currentLanguageDisplayName: String { + let code = Bundle.main.preferredLocalizations.first ?? "en" + return Locale.current.localizedString(forLanguageCode: code) ?? code + } } // MARK: - My Device Section private struct MyDeviceSection: View { - let device: DeviceDTO - let isSidebar: Bool - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - - var body: some View { - Section { - SettingsDetailRow(detail: .deviceInfo) { - SettingsRow(L10n.Settings.DeviceInfo.title, systemImage: "cpu", detail: device.nodeName) - } - .accessibilityValue(device.nodeName) - - SettingsDetailRow(detail: .radio) { - SettingsRow(L10n.Settings.Radio.header, systemImage: "antenna.radiowaves.left.and.right", detail: radioDetailText) - } - .accessibilityValue(radioDetailText) - - SettingsDetailRow(detail: .location) { - SettingsRow(L10n.Settings.Location.header, systemImage: "location", detail: locationDetailText) - } - .accessibilityValue(locationDetailText) - - SettingsDetailRow(detail: .connection) { - let isWiFi = appState.connectionManager.currentTransportType == .wifi - TintedLabel( - isWiFi ? L10n.Settings.Wifi.header : L10n.Settings.Bluetooth.header, - systemImage: isWiFi ? "wifi" : "wave.3.right" - ) - } - - SettingsDetailRow(detail: .advanced) { - TintedLabel(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape.2") - } - } header: { - Text(L10n.Settings.MyDevice.header) - } - .themedRowBackground(theme, flatten: isSidebar) - } - - private var radioDetailText: String { - let preset = device.clientRepeat - ? RadioPresets.matchingRepeatPreset( - frequencyKHz: device.frequency, - bandwidthKHz: device.bandwidth, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - : RadioPresets.matchingPreset( - frequencyKHz: device.frequency, - bandwidthKHz: device.bandwidth, - spreadingFactor: device.spreadingFactor, - codingRate: device.codingRate - ) - return preset?.name ?? L10n.Settings.BatteryCurve.custom - } + let device: DeviceDTO + let isSidebar: Bool + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + + var body: some View { + Section { + SettingsDetailRow(detail: .deviceInfo) { + SettingsRow(L10n.Settings.DeviceInfo.title, systemImage: "cpu", detail: device.nodeName) + } + .accessibilityValue(device.nodeName) + + SettingsDetailRow(detail: .radio) { + SettingsRow(L10n.Settings.Radio.header, systemImage: "antenna.radiowaves.left.and.right", detail: radioDetailText) + } + .accessibilityValue(radioDetailText) + + SettingsDetailRow(detail: .location) { + SettingsRow(L10n.Settings.Location.header, systemImage: "location", detail: locationDetailText) + } + .accessibilityValue(locationDetailText) + + SettingsDetailRow(detail: .connection) { + let isWiFi = appState.connectionManager.currentTransportType == .wifi + TintedLabel( + isWiFi ? L10n.Settings.Wifi.header : L10n.Settings.Bluetooth.header, + systemImage: isWiFi ? "wifi" : "wave.3.right" + ) + } - private var locationDetailText: String { - device.sharesLocationPublicly - ? L10n.Settings.Location.sharingPublicly - : L10n.Settings.Location.notSharing + SettingsDetailRow(detail: .advanced) { + TintedLabel(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape.2") + } + } header: { + Text(L10n.Settings.MyDevice.header) } + .themedRowBackground(theme, flatten: isSidebar) + } + + private var radioDetailText: String { + let preset = device.clientRepeat + ? RadioPresets.matchingRepeatPreset( + frequencyKHz: device.frequency, + bandwidthKHz: device.bandwidth, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate + ) + : RadioPresets.matchingPreset( + frequencyKHz: device.frequency, + bandwidthKHz: device.bandwidth, + spreadingFactor: device.spreadingFactor, + codingRate: device.codingRate + ) + return preset?.name ?? L10n.Settings.BatteryCurve.custom + } + + private var locationDetailText: String { + device.sharesLocationPublicly + ? L10n.Settings.Location.sharingPublicly + : L10n.Settings.Location.notSharing + } } // MARK: - Settings Row with Detail Text private struct SettingsRow: View { - let title: String - let systemImage: String - let detail: String - - init(_ title: String, systemImage: String, detail: String) { - self.title = title - self.systemImage = systemImage - self.detail = detail - } - - var body: some View { - HStack { - TintedLabel(title, systemImage: systemImage) - Spacer() - Text(detail) - .foregroundStyle(.secondary) - } + let title: String + let systemImage: String + let detail: String + + init(_ title: String, systemImage: String, detail: String) { + self.title = title + self.systemImage = systemImage + self.detail = detail + } + + var body: some View { + HStack { + TintedLabel(title, systemImage: systemImage) + Spacer() + Text(detail) + .foregroundStyle(.secondary) } + } } diff --git a/MC1/Views/Settings/SettingsSubpage.swift b/MC1/Views/Settings/SettingsSubpage.swift index 29d5fa3f..c504d42c 100644 --- a/MC1/Views/Settings/SettingsSubpage.swift +++ b/MC1/Views/Settings/SettingsSubpage.swift @@ -4,36 +4,36 @@ import SwiftUI /// deeper. Value-based so each push rebuilds the destination instead of reusing stale /// `@State` from a prior visit. enum SettingsSubpage: Hashable { - case publicKey(Data) - case configExport - case configImport - case blockedChannelSenders - case blockedContacts - case trustedContacts + case publicKey(Data) + case configExport + case configImport + case blockedChannelSenders + case blockedContacts + case trustedContacts } extension View { - /// Registers the `SettingsSubpage` destinations on the enclosing navigation stack. Each - /// hosting page applies this to its own `List` so the pushes resolve in every stack that - /// hosts the page (the compact Settings stack, the iPad detail column, and the status-menu - /// push of `AdvancedSettingsView`). - @MainActor - func settingsSubpageDestinations() -> some View { - navigationDestination(for: SettingsSubpage.self) { subpage in - switch subpage { - case .publicKey(let publicKey): - PublicKeyView(publicKey: publicKey) - case .configExport: - NodeConfigExportView() - case .configImport: - NodeConfigImportView() - case .blockedChannelSenders: - BlockedChannelSendersView() - case .blockedContacts: - BlockedContactsView() - case .trustedContacts: - TrustedContactsPickerView() - } - } + /// Registers the `SettingsSubpage` destinations on the enclosing navigation stack. Each + /// hosting page applies this to its own `List` so the pushes resolve in every stack that + /// hosts the page (the compact Settings stack, the iPad detail column, and the status-menu + /// push of `AdvancedSettingsView`). + @MainActor + func settingsSubpageDestinations() -> some View { + navigationDestination(for: SettingsSubpage.self) { subpage in + switch subpage { + case let .publicKey(publicKey): + PublicKeyView(publicKey: publicKey) + case .configExport: + NodeConfigExportView() + case .configImport: + NodeConfigImportView() + case .blockedChannelSenders: + BlockedChannelSendersView() + case .blockedContacts: + BlockedContactsView() + case .trustedContacts: + TrustedContactsPickerView() + } } + } } diff --git a/MC1/Views/Settings/SettingsView.swift b/MC1/Views/Settings/SettingsView.swift index 790a705a..018bdbe5 100644 --- a/MC1/Views/Settings/SettingsView.swift +++ b/MC1/Views/Settings/SettingsView.swift @@ -1,28 +1,28 @@ -import SwiftUI import MC1Services +import SwiftUI /// Main settings screen for the compact (iPhone) tab shell — navigation-link rows for device /// settings plus always-visible app settings. The iPad regular-width layout renders settings /// through `MainSidebarView` → `SettingsListContent`, never this view. struct SettingsView: View { - @State private var showingDeviceSelection = false - private var demoModeManager = DemoModeManager.shared + @State private var showingDeviceSelection = false + private var demoModeManager = DemoModeManager.shared - var body: some View { - NavigationStack { - SettingsListContent( - showingDeviceSelection: $showingDeviceSelection, - demoModeManager: demoModeManager, - isSidebar: false - ) - .navigationDestination(for: SettingsDetail.self) { detail in - SettingsDetailView(detail: detail) - } - } + var body: some View { + NavigationStack { + SettingsListContent( + showingDeviceSelection: $showingDeviceSelection, + demoModeManager: demoModeManager, + isSidebar: false + ) + .navigationDestination(for: SettingsDetail.self) { detail in + SettingsDetailView(detail: detail) + } } + } } #Preview { - SettingsView() - .environment(\.appState, AppState()) + SettingsView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Settings/TrustedContactsPickerView.swift b/MC1/Views/Settings/TrustedContactsPickerView.swift index c240a505..47eb5f39 100644 --- a/MC1/Views/Settings/TrustedContactsPickerView.swift +++ b/MC1/Views/Settings/TrustedContactsPickerView.swift @@ -1,145 +1,145 @@ -import SwiftUI import MC1Services +import SwiftUI /// Picker for selecting trusted contacts for telemetry struct TrustedContactsPickerView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var contacts: [ContactDTO] = [] - @State private var searchText = "" - @State private var showFavoritesOnly = false - @State private var pendingTrustedIDs: Set = [] - @State private var initialTrustedIDs: Set = [] - @State private var isApplying = false - @State private var errorMessage: String? - @State private var successTrigger = 0 + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var contacts: [ContactDTO] = [] + @State private var searchText = "" + @State private var showFavoritesOnly = false + @State private var pendingTrustedIDs: Set = [] + @State private var initialTrustedIDs: Set = [] + @State private var isApplying = false + @State private var errorMessage: String? + @State private var successTrigger = 0 - private var settingsModified: Bool { - pendingTrustedIDs != initialTrustedIDs - } + private var settingsModified: Bool { + pendingTrustedIDs != initialTrustedIDs + } - private var canApply: Bool { - appState.connectionState == .ready && settingsModified && !isApplying - } + private var canApply: Bool { + appState.connectionState == .ready && settingsModified && !isApplying + } - private var filteredContacts: [ContactDTO] { - var result = contacts - if showFavoritesOnly { - result = result.filter(\.isFavorite) - } - if !searchText.isEmpty { - result = result.filter { $0.displayName.localizedStandardContains(searchText) } - } - return result + private var filteredContacts: [ContactDTO] { + var result = contacts + if showFavoritesOnly { + result = result.filter(\.isFavorite) } + if !searchText.isEmpty { + result = result.filter { $0.displayName.localizedStandardContains(searchText) } + } + return result + } - var body: some View { - List { - Section { - Toggle(L10n.Settings.TrustedContacts.favoritesOnly, isOn: $showFavoritesOnly) - } - .themedRowBackground(theme) + var body: some View { + List { + Section { + Toggle(L10n.Settings.TrustedContacts.favoritesOnly, isOn: $showFavoritesOnly) + } + .themedRowBackground(theme) - Section { - if filteredContacts.isEmpty { - if !searchText.isEmpty || showFavoritesOnly { - ContentUnavailableView( - L10n.Settings.TrustedContacts.noResults, - systemImage: "magnifyingglass", - description: Text(L10n.Settings.TrustedContacts.noResultsDescription(searchText)) - ) - } else { - ContentUnavailableView( - L10n.Settings.TrustedContacts.noContacts, - systemImage: "person.2.slash", - description: Text(L10n.Settings.TrustedContacts.noContactsDescription) - ) - } - } else { - ForEach(filteredContacts) { contact in - Button { - if pendingTrustedIDs.contains(contact.id) { - pendingTrustedIDs.remove(contact.id) - } else { - pendingTrustedIDs.insert(contact.id) - } - } label: { - HStack { - Text(contact.displayName) - Spacer() - if pendingTrustedIDs.contains(contact.id) { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - } - .foregroundStyle(.primary) - } - } - } - .themedRowBackground(theme) - } - .themedCanvas(theme) - .disabled(isApplying) - .searchable(text: $searchText, prompt: L10n.Settings.TrustedContacts.searchPrompt) - .navigationTitle(L10n.Settings.TrustedContacts.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { applyChanges() } label: { - Text(L10n.Settings.TrustedContacts.apply) + Section { + if filteredContacts.isEmpty { + if !searchText.isEmpty || showFavoritesOnly { + ContentUnavailableView( + L10n.Settings.TrustedContacts.noResults, + systemImage: "magnifyingglass", + description: Text(L10n.Settings.TrustedContacts.noResultsDescription(searchText)) + ) + } else { + ContentUnavailableView( + L10n.Settings.TrustedContacts.noContacts, + systemImage: "person.2.slash", + description: Text(L10n.Settings.TrustedContacts.noContactsDescription) + ) + } + } else { + ForEach(filteredContacts) { contact in + Button { + if pendingTrustedIDs.contains(contact.id) { + pendingTrustedIDs.remove(contact.id) + } else { + pendingTrustedIDs.insert(contact.id) + } + } label: { + HStack { + Text(contact.displayName) + Spacer() + if pendingTrustedIDs.contains(contact.id) { + Image(systemName: "checkmark") + .foregroundStyle(.tint) } - .disabled(!canApply) + } } + .foregroundStyle(.primary) + } } - .sensoryFeedback(.success, trigger: successTrigger) - .errorAlert($errorMessage) - .task { - await loadContacts() + } + .themedRowBackground(theme) + } + .themedCanvas(theme) + .disabled(isApplying) + .searchable(text: $searchText, prompt: L10n.Settings.TrustedContacts.searchPrompt) + .navigationTitle(L10n.Settings.TrustedContacts.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { applyChanges() } label: { + Text(L10n.Settings.TrustedContacts.apply) } + .disabled(!canApply) + } + } + .sensoryFeedback(.success, trigger: successTrigger) + .errorAlert($errorMessage) + .task { + await loadContacts() } + } - private func loadContacts() async { - guard let radioID = appState.connectedDevice?.radioID, - let contactService = appState.services?.contactService else { return } - do { - contacts = try await contactService.getContacts(radioID: radioID) - .filter { $0.type == .chat } - let trusted = Set( - contacts - .filter { ContactService.hasTelemetryPermissions(flags: $0.flags) } - .map(\.id) - ) - initialTrustedIDs = trusted - pendingTrustedIDs = trusted - } catch { - // User can navigate back and retry - } + private func loadContacts() async { + guard let radioID = appState.connectedDevice?.radioID, + let contactService = appState.services?.contactService else { return } + do { + contacts = try await contactService.getContacts(radioID: radioID) + .filter { $0.type == .chat } + let trusted = Set( + contacts + .filter { ContactService.hasTelemetryPermissions(flags: $0.flags) } + .map(\.id) + ) + initialTrustedIDs = trusted + pendingTrustedIDs = trusted + } catch { + // User can navigate back and retry } + } - private func applyChanges() { - guard !isApplying else { return } - guard let contactService = appState.services?.contactService else { return } + private func applyChanges() { + guard !isApplying else { return } + guard let contactService = appState.services?.contactService else { return } - isApplying = true - Task { - do { - let toGrant = pendingTrustedIDs.subtracting(initialTrustedIDs) - let toRevoke = initialTrustedIDs.subtracting(pendingTrustedIDs) + isApplying = true + Task { + do { + let toGrant = pendingTrustedIDs.subtracting(initialTrustedIDs) + let toRevoke = initialTrustedIDs.subtracting(pendingTrustedIDs) - for id in toGrant { - try await contactService.setTelemetryPermissions(id, granted: true) - } - for id in toRevoke { - try await contactService.setTelemetryPermissions(id, granted: false) - } - - initialTrustedIDs = pendingTrustedIDs - successTrigger += 1 - } catch { - errorMessage = error.userFacingMessage - } - isApplying = false + for id in toGrant { + try await contactService.setTelemetryPermissions(id, granted: true) } + for id in toRevoke { + try await contactService.setTelemetryPermissions(id, granted: false) + } + + initialTrustedIDs = pendingTrustedIDs + successTrigger += 1 + } catch { + errorMessage = error.userFacingMessage + } + isApplying = false } + } } diff --git a/MC1/Views/Sidebar/SidebarBackgroundExtension.swift b/MC1/Views/Sidebar/SidebarBackgroundExtension.swift index 5645b3b8..12ca8ff9 100644 --- a/MC1/Views/Sidebar/SidebarBackgroundExtension.swift +++ b/MC1/Views/Sidebar/SidebarBackgroundExtension.swift @@ -6,19 +6,19 @@ import SwiftUI let sidebarContentLeadingInset: CGFloat = 16 extension View { - /// Extends this view's background under the adjacent floating Liquid Glass sidebar on - /// iOS 26+ by mirroring and blurring its leading-edge pixels into the sidebar's safe-area - /// region. Below iOS 26 the sidebar is an opaque column and the modifier is omitted. - /// - /// Apply only to a static section background, not to fast-scrolling rows: the effect is a - /// GPU duplicate plus mirror plus blur, and Apple advises using it on a single instance of - /// background content with consideration of performance. - @ViewBuilder - func sidebarBackgroundExtension() -> some View { - if #available(iOS 26, *) { - self.backgroundExtensionEffect() - } else { - self - } + /// Extends this view's background under the adjacent floating Liquid Glass sidebar on + /// iOS 26+ by mirroring and blurring its leading-edge pixels into the sidebar's safe-area + /// region. Below iOS 26 the sidebar is an opaque column and the modifier is omitted. + /// + /// Apply only to a static section background, not to fast-scrolling rows: the effect is a + /// GPU duplicate plus mirror plus blur, and Apple advises using it on a single instance of + /// background content with consideration of performance. + @ViewBuilder + func sidebarBackgroundExtension() -> some View { + if #available(iOS 26, *) { + backgroundExtensionEffect() + } else { + self } + } } diff --git a/MC1/Views/Sidebar/SidebarContentColumnBackground.swift b/MC1/Views/Sidebar/SidebarContentColumnBackground.swift index 24e1a024..0c926398 100644 --- a/MC1/Views/Sidebar/SidebarContentColumnBackground.swift +++ b/MC1/Views/Sidebar/SidebarContentColumnBackground.swift @@ -10,22 +10,21 @@ import SwiftUI /// no-op; the floating sidebar simply overlays the unmodified system surface there. The leading /// inset keeps row content clear of the floating glass in both cases. struct SidebarContentColumnBackground: ViewModifier { - let theme: Theme + let theme: Theme - @ViewBuilder - func body(content: Content) -> some View { - if let canvas = theme.surfaces?.canvas { - content - .scrollContentBackground(.hidden) - .safeAreaPadding(.leading, sidebarContentLeadingInset) - .background { - canvas - .ignoresSafeArea() - .sidebarBackgroundExtension() - } - } else { - content - .safeAreaPadding(.leading, sidebarContentLeadingInset) + func body(content: Content) -> some View { + if let canvas = theme.surfaces?.canvas { + content + .scrollContentBackground(.hidden) + .safeAreaPadding(.leading, sidebarContentLeadingInset) + .background { + canvas + .ignoresSafeArea() + .sidebarBackgroundExtension() } + } else { + content + .safeAreaPadding(.leading, sidebarContentLeadingInset) } + } } diff --git a/MC1/Views/Support/Components/ContributionRow.swift b/MC1/Views/Support/Components/ContributionRow.swift index 6afdf3fc..4d49abfe 100644 --- a/MC1/Views/Support/Components/ContributionRow.swift +++ b/MC1/Views/Support/Components/ContributionRow.swift @@ -4,71 +4,71 @@ import SwiftUI /// confirm first to mitigate a fat-finger purchase. On success a checkmark briefly replaces the /// price. "Contribution" naming avoids the TipKit identifier collision. struct ContributionRow: View { - let displayName: String - let displayPrice: String? - let requiresConfirmation: Bool - let onPurchase: () async -> Bool + let displayName: String + let displayPrice: String? + let requiresConfirmation: Bool + let onPurchase: () async -> Bool - @State private var showingConfirmation = false - @State private var showingThanks = false - @State private var isPurchasing = false + @State private var showingConfirmation = false + @State private var showingThanks = false + @State private var isPurchasing = false - var body: some View { - HStack(alignment: .center, spacing: 12) { - Text(displayName).font(.body) - Spacer() - priceButton - } - .confirmationDialog( - L10n.Settings.Support.Contributions.highValueConfirm(displayName), - isPresented: $showingConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Settings.Support.Contributions.confirmButton(displayPrice ?? displayName)) { - Task { await runPurchase() } - } - Button(L10n.Localizable.Common.cancel, role: .cancel) { } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.Settings.Support.Accessibility.ContributionRow.label(displayName, displayPrice ?? "")) - .accessibilityHint(L10n.Settings.Support.Accessibility.ContributionRow.hint) + var body: some View { + HStack(alignment: .center, spacing: 12) { + Text(displayName).font(.body) + Spacer() + priceButton + } + .confirmationDialog( + L10n.Settings.Support.Contributions.highValueConfirm(displayName), + isPresented: $showingConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Settings.Support.Contributions.confirmButton(displayPrice ?? displayName)) { + Task { await runPurchase() } + } + Button(L10n.Localizable.Common.cancel, role: .cancel) {} } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Settings.Support.Accessibility.ContributionRow.label(displayName, displayPrice ?? "")) + .accessibilityHint(L10n.Settings.Support.Accessibility.ContributionRow.hint) + } - @ViewBuilder - private var priceButton: some View { - if showingThanks { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.opacity) + @ViewBuilder + private var priceButton: some View { + if showingThanks { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.opacity) + } else { + Button { + if requiresConfirmation { + showingConfirmation = true } else { - Button { - if requiresConfirmation { - showingConfirmation = true - } else { - Task { await runPurchase() } - } - } label: { - if isPurchasing { - ProgressView() - } else { - Text(displayPrice ?? "—").font(.callout.weight(.semibold)) - } - } - .buttonStyle(.bordered) - .disabled(displayPrice == nil || isPurchasing) + Task { await runPurchase() } } + } label: { + if isPurchasing { + ProgressView() + } else { + Text(displayPrice ?? "—").font(.callout.weight(.semibold)) + } + } + .buttonStyle(.bordered) + .disabled(displayPrice == nil || isPurchasing) } + } - private func runPurchase() async { - isPurchasing = true - let succeeded = await onPurchase() - isPurchasing = false - guard succeeded else { return } - withAnimation { showingThanks = true } - AccessibilityNotification.Announcement( - L10n.Settings.Support.Accessibility.tipConfirmAnnouncement - ).post() - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { showingThanks = false } - } + private func runPurchase() async { + isPurchasing = true + let succeeded = await onPurchase() + isPurchasing = false + guard succeeded else { return } + withAnimation { showingThanks = true } + AccessibilityNotification.Announcement( + L10n.Settings.Support.Accessibility.tipConfirmAnnouncement + ).post() + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { showingThanks = false } + } } diff --git a/MC1/Views/Support/Components/ThemeBundleCard.swift b/MC1/Views/Support/Components/ThemeBundleCard.swift index 85736ca4..b429b6b6 100644 --- a/MC1/Views/Support/Components/ThemeBundleCard.swift +++ b/MC1/Views/Support/Components/ThemeBundleCard.swift @@ -1,72 +1,72 @@ -import SwiftUI import MC1Services +import SwiftUI /// Full-width bundle card: the single purchase control for every theme. Shown only while the /// user does not already own the whole set, so it never renders an "owned" state. struct ThemeBundleCard: View { - let isPending: Bool - let displayPrice: String? - let onPurchase: () async -> Void + let isPending: Bool + let displayPrice: String? + let onPurchase: () async -> Void - @Environment(\.appTheme) private var theme - @State private var isPurchasing = false + @Environment(\.appTheme) private var theme + @State private var isPurchasing = false - var body: some View { - VStack(alignment: .center, spacing: 10) { - Text(L10n.Settings.Support.Bundle.title) - .font(.headline) - Text(L10n.Settings.Support.Bundle.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - action - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .center) - .background(theme.surfaces?.card ?? Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: ThemeCardMetrics.cornerRadius)) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) - .accessibilityHint(isActionable ? L10n.Settings.Support.Accessibility.BundleCard.lockedHint : "") - // Only carry the button trait when the price has loaded and no purchase is in flight, so a - // loading or pending card is not announced as something the user can activate. - .accessibilityAddTraits(isActionable ? .isButton : []) + var body: some View { + VStack(alignment: .center, spacing: 10) { + Text(L10n.Settings.Support.Bundle.title) + .font(.headline) + Text(L10n.Settings.Support.Bundle.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + action } + .padding(12) + .frame(maxWidth: .infinity, alignment: .center) + .background(theme.surfaces?.card ?? Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: ThemeCardMetrics.cornerRadius)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(isActionable ? L10n.Settings.Support.Accessibility.BundleCard.lockedHint : "") + // Only carry the button trait when the price has loaded and no purchase is in flight, so a + // loading or pending card is not announced as something the user can activate. + .accessibilityAddTraits(isActionable ? .isButton : []) + } - /// True once the card is an activatable purchase control (price loaded, nothing pending). - private var isActionable: Bool { - !isPending && displayPrice != nil - } + /// True once the card is an activatable purchase control (price loaded, nothing pending). + private var isActionable: Bool { + !isPending && displayPrice != nil + } - private var accessibilityLabel: String { - if !isPending, displayPrice == nil { - return L10n.Settings.Support.Accessibility.BundleCard.loadingLabel - } - return L10n.Settings.Support.Accessibility.BundleCard.lockedLabel(displayPrice ?? "") + private var accessibilityLabel: String { + if !isPending, displayPrice == nil { + return L10n.Settings.Support.Accessibility.BundleCard.loadingLabel } + return L10n.Settings.Support.Accessibility.BundleCard.lockedLabel(displayPrice ?? "") + } - @ViewBuilder - private var action: some View { - if isPurchasing { - ProgressView() - .frame(maxWidth: .infinity) - } else if isPending { - Label(L10n.Settings.Support.Pending.button, systemImage: "clock.badge.questionmark") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - } else { - Button { - Task { - isPurchasing = true - await onPurchase() - isPurchasing = false - } - } label: { - Text(displayPrice ?? "—") - .font(.subheadline.weight(.semibold)) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .disabled(displayPrice == nil) + @ViewBuilder + private var action: some View { + if isPurchasing { + ProgressView() + .frame(maxWidth: .infinity) + } else if isPending { + Label(L10n.Settings.Support.Pending.button, systemImage: "clock.badge.questionmark") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } else { + Button { + Task { + isPurchasing = true + await onPurchase() + isPurchasing = false } + } label: { + Text(displayPrice ?? "—") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(displayPrice == nil) } + } } diff --git a/MC1/Views/Support/Components/ThemePreviewCard.swift b/MC1/Views/Support/Components/ThemePreviewCard.swift index b495f63e..cf3e1d50 100644 --- a/MC1/Views/Support/Components/ThemePreviewCard.swift +++ b/MC1/Views/Support/Components/ThemePreviewCard.swift @@ -4,42 +4,42 @@ import SwiftUI /// Non-interactive — the bundle card is the only purchase control; applying happens on the /// Appearance screen. struct ThemePreviewCard: View { - let theme: Theme - let isOwned: Bool + let theme: Theme + let isOwned: Bool - var body: some View { - VStack(spacing: 8) { - ZStack(alignment: .topTrailing) { - ThemePaletteSwatch(theme: theme) - if !isOwned { - Image(systemName: "lock.fill") - .font(.caption) - .padding(6) - .background(.ultraThinMaterial, in: Circle()) - .padding(6) - } - } - Text(theme.localizedName) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - if isOwned { - HStack(spacing: ThemeCardMetrics.badgeIconSpacing) { - Image(systemName: "checkmark") - Text(L10n.Settings.Support.Themes.owned) - } - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } + var body: some View { + VStack(spacing: 8) { + ZStack(alignment: .topTrailing) { + ThemePaletteSwatch(theme: theme) + if !isOwned { + Image(systemName: "lock.fill") + .font(.caption) + .padding(6) + .background(.ultraThinMaterial, in: Circle()) + .padding(6) } - .padding(ThemeCardMetrics.contentPadding) - .frame(maxWidth: .infinity) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) + } + Text(theme.localizedName) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + if isOwned { + HStack(spacing: ThemeCardMetrics.badgeIconSpacing) { + Image(systemName: "checkmark") + Text(L10n.Settings.Support.Themes.owned) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } } + .padding(ThemeCardMetrics.contentPadding) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + } - private var accessibilityLabel: String { - isOwned - ? L10n.Settings.Support.Accessibility.ThemeCard.ownedLabel(theme.localizedName) - : L10n.Settings.Support.Accessibility.ThemeCard.lockedLabel(theme.localizedName) - } + private var accessibilityLabel: String { + isOwned + ? L10n.Settings.Support.Accessibility.ThemeCard.ownedLabel(theme.localizedName) + : L10n.Settings.Support.Accessibility.ThemeCard.lockedLabel(theme.localizedName) + } } diff --git a/MC1/Views/Support/Sections/ContributionsSection.swift b/MC1/Views/Support/Sections/ContributionsSection.swift index 517eee03..04f3903b 100644 --- a/MC1/Views/Support/Sections/ContributionsSection.swift +++ b/MC1/Views/Support/Sections/ContributionsSection.swift @@ -1,43 +1,45 @@ -import SwiftUI -import StoreKit import MC1Services +import StoreKit +import SwiftUI struct ContributionsSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.purchase) private var purchase + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.purchase) private var purchase - private var storeState: StoreState { appState.storeState } + private var storeState: StoreState { + appState.storeState + } - /// Ordered tip product IDs (low to high) with their high-value flag. - private var contributions: [(id: String, highValue: Bool)] { - [ - (StoreCatalog.Tip.coffee, false), - (StoreCatalog.Tip.lunch, false), - (StoreCatalog.Tip.dinner, false), - (StoreCatalog.Tip.generous, false), - (StoreCatalog.Tip.massive, true), - (StoreCatalog.Tip.epic, true) - ] - } + /// Ordered tip product IDs (low to high) with their high-value flag. + private var contributions: [(id: String, highValue: Bool)] { + [ + (StoreCatalog.Tip.coffee, false), + (StoreCatalog.Tip.lunch, false), + (StoreCatalog.Tip.dinner, false), + (StoreCatalog.Tip.generous, false), + (StoreCatalog.Tip.massive, true), + (StoreCatalog.Tip.epic, true) + ] + } - var body: some View { - Section { - ForEach(contributions, id: \.id) { contribution in - if let product = storeState.service.product(for: contribution.id) { - ContributionRow( - displayName: product.displayName, - displayPrice: product.displayPrice, - requiresConfirmation: contribution.highValue, - onPurchase: { await storeState.purchase(productID: contribution.id) { try await purchase($0) } } - ) - } - } - } header: { - Text(L10n.Settings.Support.Contributions.title) - } footer: { - Text(L10n.Settings.Support.Contributions.footer) + var body: some View { + Section { + ForEach(contributions, id: \.id) { contribution in + if let product = storeState.service.product(for: contribution.id) { + ContributionRow( + displayName: product.displayName, + displayPrice: product.displayPrice, + requiresConfirmation: contribution.highValue, + onPurchase: { await storeState.purchase(productID: contribution.id) { try await purchase($0) } } + ) } - .themedRowBackground(theme) + } + } header: { + Text(L10n.Settings.Support.Contributions.title) + } footer: { + Text(L10n.Settings.Support.Contributions.footer) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Support/Sections/PendingPurchaseBanner.swift b/MC1/Views/Support/Sections/PendingPurchaseBanner.swift index a73ff9d1..28e9a668 100644 --- a/MC1/Views/Support/Sections/PendingPurchaseBanner.swift +++ b/MC1/Views/Support/Sections/PendingPurchaseBanner.swift @@ -2,14 +2,14 @@ import SwiftUI /// Non-blocking "Awaiting approval" banner; rendered only when a pending purchase exists. struct PendingPurchaseBanner: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - Section { - Label(L10n.Settings.Support.Pending.banner, systemImage: "clock.badge.questionmark") - .font(.footnote) - .foregroundStyle(.secondary) - } - .themedRowBackground(theme) + var body: some View { + Section { + Label(L10n.Settings.Support.Pending.banner, systemImage: "clock.badge.questionmark") + .font(.footnote) + .foregroundStyle(.secondary) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Support/Sections/RefundLinkSection.swift b/MC1/Views/Support/Sections/RefundLinkSection.swift index 640a3c0e..acb1a876 100644 --- a/MC1/Views/Support/Sections/RefundLinkSection.swift +++ b/MC1/Views/Support/Sections/RefundLinkSection.swift @@ -1,46 +1,48 @@ -import SwiftUI -import StoreKit import MC1Services +import StoreKit +import SwiftUI /// "Request a refund" link for the owned theme bundle transaction. Resolved via /// `Transaction.latest(for:)`; hidden when the bundle is not owned. Only the bundle is /// purchasable, so only the bundle is refundable; individual themes and tips are excluded /// (Apple provides no in-app refund flow for consumables). struct RefundLinkSection: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var refundTransactionID: StoreKit.Transaction.ID? - @State private var showingRefundSheet = false + @State private var refundTransactionID: StoreKit.Transaction.ID? + @State private var showingRefundSheet = false - private var storeState: StoreState { appState.storeState } + private var storeState: StoreState { + appState.storeState + } - var body: some View { - Group { - if let transactionID = refundTransactionID { - Section { - Button(L10n.Settings.Support.Refund.link) { showingRefundSheet = true } - .refundRequestSheet(for: transactionID, isPresented: $showingRefundSheet) - } - .themedRowBackground(theme) - } - } - .task(id: storeState.service.ownedThemeIDs) { - refundTransactionID = await latestRefundableTransactionID() + var body: some View { + Group { + if let transactionID = refundTransactionID { + Section { + Button(L10n.Settings.Support.Refund.link) { showingRefundSheet = true } + .refundRequestSheet(for: transactionID, isPresented: $showingRefundSheet) } + .themedRowBackground(theme) + } } - - /// The verified, non-revoked bundle transaction's ID, or nil if none. Only the bundle is - /// purchasable, so it is the only refundable product. `Transaction.latest(for:)` keeps - /// refunded/revoked rows (sets `revocationDate` but doesn't drop them), so the filter must - /// exclude any non-nil `revocationDate` — otherwise the "Request a refund" link stays visible - /// after a refund and points at the just-revoked transaction, which then returns - /// `RefundRequestError.duplicateRequest`. - /// Internal (not private) for `@testable` access from `RefundLinkSectionTests`. - func latestRefundableTransactionID() async -> StoreKit.Transaction.ID? { - guard case .verified(let transaction) = await Transaction.latest(for: StoreCatalog.Theme.bundleAll), - transaction.revocationDate == nil - else { return nil } - return transaction.id + .task(id: storeState.service.ownedThemeIDs) { + refundTransactionID = await latestRefundableTransactionID() } + } + + /// The verified, non-revoked bundle transaction's ID, or nil if none. Only the bundle is + /// purchasable, so it is the only refundable product. `Transaction.latest(for:)` keeps + /// refunded/revoked rows (sets `revocationDate` but doesn't drop them), so the filter must + /// exclude any non-nil `revocationDate` — otherwise the "Request a refund" link stays visible + /// after a refund and points at the just-revoked transaction, which then returns + /// `RefundRequestError.duplicateRequest`. + /// Internal (not private) for `@testable` access from `RefundLinkSectionTests`. + func latestRefundableTransactionID() async -> StoreKit.Transaction.ID? { + guard case let .verified(transaction) = await Transaction.latest(for: StoreCatalog.Theme.bundleAll), + transaction.revocationDate == nil + else { return nil } + return transaction.id + } } diff --git a/MC1/Views/Support/Sections/SupportContactSection.swift b/MC1/Views/Support/Sections/SupportContactSection.swift index 181b0c0b..c499ec66 100644 --- a/MC1/Views/Support/Sections/SupportContactSection.swift +++ b/MC1/Views/Support/Sections/SupportContactSection.swift @@ -2,28 +2,28 @@ import SwiftUI /// Required by App Store Review §1.5. Mirrors the developer support address already in About. struct SupportContactSection: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - private enum Support { - static let email = "info@meshcoreone.com" - static let mailtoURL = URL(string: "mailto:\(email)") - } + private enum Support { + static let email = "info@meshcoreone.com" + static let mailtoURL = URL(string: "mailto:\(email)") + } - var body: some View { - Section { - if let mailtoURL = Support.mailtoURL { - Link(destination: mailtoURL) { - HStack { - TintedLabel(L10n.Settings.Support.Contact.link, systemImage: "envelope") - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .foregroundStyle(.primary) - } + var body: some View { + Section { + if let mailtoURL = Support.mailtoURL { + Link(destination: mailtoURL) { + HStack { + TintedLabel(L10n.Settings.Support.Contact.link, systemImage: "envelope") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } } - .themedRowBackground(theme) + .foregroundStyle(.primary) + } } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Support/Sections/SupportHeaderSection.swift b/MC1/Views/Support/Sections/SupportHeaderSection.swift index cdde10c3..bdc719b5 100644 --- a/MC1/Views/Support/Sections/SupportHeaderSection.swift +++ b/MC1/Views/Support/Sections/SupportHeaderSection.swift @@ -1,14 +1,14 @@ import SwiftUI struct SupportHeaderSection: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - Section { - Text(L10n.Settings.Support.Header.body) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .themedRowBackground(theme) + var body: some View { + Section { + Text(L10n.Settings.Support.Header.body) + .font(.subheadline) + .foregroundStyle(.secondary) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Support/Sections/ThemesPurchaseSection.swift b/MC1/Views/Support/Sections/ThemesPurchaseSection.swift index 82be7da4..a78e478a 100644 --- a/MC1/Views/Support/Sections/ThemesPurchaseSection.swift +++ b/MC1/Views/Support/Sections/ThemesPurchaseSection.swift @@ -1,77 +1,79 @@ -import SwiftUI -import StoreKit import MC1Services +import StoreKit +import SwiftUI struct ThemesPurchaseSection: View { - @Environment(\.appState) private var appState - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.purchase) private var purchase + @Environment(\.appState) private var appState + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.purchase) private var purchase - private var storeState: StoreState { appState.storeState } + private var storeState: StoreState { + appState.storeState + } - private var purchasableThemes: [Theme] { - ThemeRegistry.allThemes.filter { $0.productID != nil } - } - - private var ownsEveryTheme: Bool { - storeState.service.ownedThemeIDs.isSuperset(of: StoreCatalog.Theme.bundledThemeIDs) - } + private var purchasableThemes: [Theme] { + ThemeRegistry.allThemes.filter { $0.productID != nil } + } - private var columns: [GridItem] { - dynamicTypeSize.isAccessibilitySize - ? [GridItem(.flexible())] - : [GridItem(.adaptive(minimum: ThemeCardMetrics.gridItemMinimum), spacing: ThemeCardMetrics.gridSpacing)] - } + private var ownsEveryTheme: Bool { + storeState.service.ownedThemeIDs.isSuperset(of: StoreCatalog.Theme.bundledThemeIDs) + } - var body: some View { - Section { - if ownsEveryTheme { - allUnlockedCard - } else { - LazyVGrid(columns: columns, spacing: ThemeCardMetrics.gridSpacing) { - ForEach(purchasableThemes) { theme in - ThemePreviewCard(theme: theme, isOwned: isOwned(theme)) - } - } - .listRowInsets(ThemeCardMetrics.gridRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + private var columns: [GridItem] { + dynamicTypeSize.isAccessibilitySize + ? [GridItem(.flexible())] + : [GridItem(.adaptive(minimum: ThemeCardMetrics.gridItemMinimum), spacing: ThemeCardMetrics.gridSpacing)] + } - ThemeBundleCard( - isPending: storeState.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll, - displayPrice: storeState.service.product(for: StoreCatalog.Theme.bundleAll)?.displayPrice, - onPurchase: { await storeState.purchase(productID: StoreCatalog.Theme.bundleAll) { try await purchase($0) } } - ) - .listRowInsets(ThemeCardMetrics.gridRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - } header: { - Text(L10n.Settings.Support.Themes.title) - } footer: { - if !appState.themeService.availableToCurrentUser().filter({ $0.productID != nil }).isEmpty { - Text(L10n.Settings.Support.Themes.purchasedFooter) - } + var body: some View { + Section { + if ownsEveryTheme { + allUnlockedCard + } else { + LazyVGrid(columns: columns, spacing: ThemeCardMetrics.gridSpacing) { + ForEach(purchasableThemes) { theme in + ThemePreviewCard(theme: theme, isOwned: isOwned(theme)) + } } - } + .listRowInsets(ThemeCardMetrics.gridRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) - private var allUnlockedCard: some View { - VStack(spacing: ThemeCardMetrics.allUnlockedSpacing) { - Text(verbatim: "🎉") - .font(.system(size: ThemeCardMetrics.allUnlockedEmojiSize)) - Text(L10n.Settings.Support.Themes.allUnlocked) - .font(.headline) - } - .frame(maxWidth: .infinity) - .padding(.vertical, ThemeCardMetrics.allUnlockedVerticalPadding) + ThemeBundleCard( + isPending: storeState.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll, + displayPrice: storeState.service.product(for: StoreCatalog.Theme.bundleAll)?.displayPrice, + onPurchase: { await storeState.purchase(productID: StoreCatalog.Theme.bundleAll) { try await purchase($0) } } + ) .listRowInsets(ThemeCardMetrics.gridRowInsets) .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .accessibilityElement(children: .combine) + } + } header: { + Text(L10n.Settings.Support.Themes.title) + } footer: { + if !appState.themeService.availableToCurrentUser().filter({ $0.productID != nil }).isEmpty { + Text(L10n.Settings.Support.Themes.purchasedFooter) + } } + } - private func isOwned(_ theme: Theme) -> Bool { - guard let productID = theme.productID else { return true } - return storeState.service.ownedThemeIDs.contains(productID) + private var allUnlockedCard: some View { + VStack(spacing: ThemeCardMetrics.allUnlockedSpacing) { + Text(verbatim: "🎉") + .font(.system(size: ThemeCardMetrics.allUnlockedEmojiSize)) + Text(L10n.Settings.Support.Themes.allUnlocked) + .font(.headline) } + .frame(maxWidth: .infinity) + .padding(.vertical, ThemeCardMetrics.allUnlockedVerticalPadding) + .listRowInsets(ThemeCardMetrics.gridRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .accessibilityElement(children: .combine) + } + + private func isOwned(_ theme: Theme) -> Bool { + guard let productID = theme.productID else { return true } + return storeState.service.ownedThemeIDs.contains(productID) + } } diff --git a/MC1/Views/Support/SupportDevelopmentView.swift b/MC1/Views/Support/SupportDevelopmentView.swift index b750b531..56a488d7 100644 --- a/MC1/Views/Support/SupportDevelopmentView.swift +++ b/MC1/Views/Support/SupportDevelopmentView.swift @@ -1,72 +1,71 @@ -import SwiftUI import MC1Services +import SwiftUI struct SupportDevelopmentView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - @State private var hasRetriedThisAppearance = false - @State private var restoreSuccessTrigger = 0 + @State private var hasRetriedThisAppearance = false + @State private var restoreSuccessTrigger = 0 - var body: some View { - // A `@Bindable` local is required to project `$storeState.errorMessage` from the - // environment-owned `@Observable` (a plain computed property cannot supply a Binding). - @Bindable var storeState = appState.storeState + var body: some View { + // A `@Bindable` local is required to project `$storeState.errorMessage` from the + // environment-owned `@Observable` (a plain computed property cannot supply a Binding). + @Bindable var storeState = appState.storeState - return List { - SupportHeaderSection() + return List { + SupportHeaderSection() - if storeState.pendingPurchase != nil { - PendingPurchaseBanner() - } + if storeState.pendingPurchase != nil { + PendingPurchaseBanner() + } - ThemesPurchaseSection() - ContributionsSection() - restoreSection(storeState) - RefundLinkSection() - SupportContactSection() - } - .themedCanvas(theme) - .navigationTitle(L10n.Settings.Support.title) - .navigationBarTitleDisplayMode(.inline) - .errorAlert($storeState.errorMessage, retryAction: { Task { await storeState.service.load() } }) - .sensoryFeedback(.success, trigger: restoreSuccessTrigger) - .onChange(of: storeState.service.ownedThemeIDs) { - storeState.reconcilePendingPurchase() - } - .onChange(of: storeState.restoreState) { _, newValue in - if newValue == .completed { restoreSuccessTrigger += 1 } - } - .onAppear { - // Clear a pending banner whose entitlement already arrived while this screen was - // off-screen: the `.onChange(of: ownedThemeIDs)` above fires only on a live change, - // not on re-entry, so an Ask-to-Buy approval received elsewhere would otherwise leave - // a stale "Awaiting approval" banner until app close. - storeState.reconcilePendingPurchase() - if storeState.service.loadState == .failed, !hasRetriedThisAppearance { - hasRetriedThisAppearance = true - Task { await storeState.service.load() } - } - } - .onDisappear { hasRetriedThisAppearance = false } + ThemesPurchaseSection() + ContributionsSection() + restoreSection(storeState) + RefundLinkSection() + SupportContactSection() + } + .themedCanvas(theme) + .navigationTitle(L10n.Settings.Support.title) + .navigationBarTitleDisplayMode(.inline) + .errorAlert($storeState.errorMessage, retryAction: { Task { await storeState.service.load() } }) + .sensoryFeedback(.success, trigger: restoreSuccessTrigger) + .onChange(of: storeState.service.ownedThemeIDs) { + storeState.reconcilePendingPurchase() + } + .onChange(of: storeState.restoreState) { _, newValue in + if newValue == .completed { restoreSuccessTrigger += 1 } + } + .onAppear { + // Clear a pending banner whose entitlement already arrived while this screen was + // off-screen: the `.onChange(of: ownedThemeIDs)` above fires only on a live change, + // not on re-entry, so an Ask-to-Buy approval received elsewhere would otherwise leave + // a stale "Awaiting approval" banner until app close. + storeState.reconcilePendingPurchase() + if storeState.service.loadState == .failed, !hasRetriedThisAppearance { + hasRetriedThisAppearance = true + Task { await storeState.service.load() } + } } + .onDisappear { hasRetriedThisAppearance = false } + } - @ViewBuilder - private func restoreSection(_ storeState: StoreState) -> some View { - Section { - Button { - Task { await storeState.restorePurchases() } - } label: { - HStack { - Text(storeState.restoreState == .syncing - ? L10n.Settings.Support.Restore.syncing - : L10n.Settings.Support.Restore.button) - Spacer() - if storeState.restoreState == .syncing { ProgressView() } - } - } - .disabled(storeState.restoreState == .syncing) + private func restoreSection(_ storeState: StoreState) -> some View { + Section { + Button { + Task { await storeState.restorePurchases() } + } label: { + HStack { + Text(storeState.restoreState == .syncing + ? L10n.Settings.Support.Restore.syncing + : L10n.Settings.Support.Restore.button) + Spacer() + if storeState.restoreState == .syncing { ProgressView() } } - .themedRowBackground(theme) + } + .disabled(storeState.restoreState == .syncing) } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Tools/CLI/CLICompletionEngine.swift b/MC1/Views/Tools/CLI/CLICompletionEngine.swift index 1f23f33a..98542c10 100644 --- a/MC1/Views/Tools/CLI/CLICompletionEngine.swift +++ b/MC1/Views/Tools/CLI/CLICompletionEngine.swift @@ -3,254 +3,253 @@ import Foundation @Observable @MainActor final class CLICompletionEngine { + // MARK: - Command Definitions - // MARK: - Command Definitions + private static let builtInCommands = [ + "help", "clear" + ] - private static let builtInCommands = [ - "help", "clear" - ] + /// App-CLI session management; the node CLI is single-session and passes + /// these straight to firmware, which has no such commands. + private static let sessionCommands = [ + "session", "logout" + ] - // App-CLI session management; the node CLI is single-session and passes - // these straight to firmware, which has no such commands. - private static let sessionCommands = [ - "session", "logout" - ] + private static let localOnlyCommands = [ + "login", "nodes", "channels" + ] - private static let localOnlyCommands = [ - "login", "nodes", "channels" - ] + /// Per MeshCore CLI Reference - commands available via remote session + private static let repeaterCommands = [ + "ver", "board", "clock", "clkreboot", + "neighbors", "get", "set", "password", + "log", "reboot", "advert", "advert.zerohop", "setperm", "tempradio", "neighbor.remove", + "region", "gps", "powersaving", "clear", "discover.neighbors", + "start" + ] - // Per MeshCore CLI Reference - commands available via remote session - private static let repeaterCommands = [ - "ver", "board", "clock", "clkreboot", - "neighbors", "get", "set", "password", - "log", "reboot", "advert", "advert.zerohop", "setperm", "tempradio", "neighbor.remove", - "region", "gps", "powersaving", "clear", "discover.neighbors", - "start" - ] + private static let sessionSubcommands = ["list", "local"] - private static let sessionSubcommands = ["list", "local"] + private static let logSubcommands = ["start", "stop", "erase"] - private static let logSubcommands = ["start", "stop", "erase"] + private static let clearSubcommands = ["stats"] - private static let clearSubcommands = ["stats"] + private static let clockSubcommands = ["sync"] - private static let clockSubcommands = ["sync"] + /// Per MeshCore CLI Reference - region subcommands + private static let regionSubcommands = [ + "load", "get", "put", "remove", "allowf", "denyf", "home", "save", "list" + ] - // Per MeshCore CLI Reference - region subcommands - private static let regionSubcommands = [ - "load", "get", "put", "remove", "allowf", "denyf", "home", "save", "list" - ] + /// Per MeshCore CLI Reference - gps subcommands + private static let gpsSubcommands = ["on", "off", "sync", "setloc", "advert"] - // Per MeshCore CLI Reference - gps subcommands - private static let gpsSubcommands = ["on", "off", "sync", "setloc", "advert"] + private static let gpsAdvertValues = ["none", "share", "prefs"] - private static let gpsAdvertValues = ["none", "share", "prefs"] + private static let startSubcommands = ["ota"] - private static let startSubcommands = ["ota"] + private static let regionListValues = ["allowed", "denied"] - private static let regionListValues = ["allowed", "denied"] + private static let onOffValues = ["on", "off"] - private static let onOffValues = ["on", "off"] + private static let multiAcksValues = ["0", "1"] - private static let multiAcksValues = ["0", "1"] + private static let bridgeSourceValues = ["tx", "rx"] - private static let bridgeSourceValues = ["tx", "rx"] + /// Per MeshCore CLI Reference - all get/set parameters + private static let getSetParams = [ + "acl", "name", "radio", "tx", "repeat", "lat", "lon", + "af", "dutycycle", "flood.max", "flood.max.advert", "flood.max.unscoped", + "int.thresh", "agc.reset.interval", + "multi.acks", "advert.interval", "flood.advert.interval", + "guest.password", "allow.read.only", + "rxdelay", "txdelay", "direct.txdelay", + "bridge.enabled", "bridge.delay", "bridge.source", + "bridge.baud", "bridge.secret", "bridge.type", + "adc.multiplier", "public.key", "prv.key", "role", "freq", + "path.hash.mode", "loop.detect", "bootloader.ver", + "owner.info", "radio.rxgain", "bridge.channel", + "pwrmgt.support", "pwrmgt.source", "pwrmgt.bootreason", "pwrmgt.bootmv" + ] - // Per MeshCore CLI Reference - all get/set parameters - private static let getSetParams = [ - "acl", "name", "radio", "tx", "repeat", "lat", "lon", - "af", "dutycycle", "flood.max", "flood.max.advert", "flood.max.unscoped", - "int.thresh", "agc.reset.interval", - "multi.acks", "advert.interval", "flood.advert.interval", - "guest.password", "allow.read.only", - "rxdelay", "txdelay", "direct.txdelay", - "bridge.enabled", "bridge.delay", "bridge.source", - "bridge.baud", "bridge.secret", "bridge.type", - "adc.multiplier", "public.key", "prv.key", "role", "freq", - "path.hash.mode", "loop.detect", "bootloader.ver", - "owner.info", "radio.rxgain", "bridge.channel", - "pwrmgt.support", "pwrmgt.source", "pwrmgt.bootreason", "pwrmgt.bootmv" - ] + // Serial-only params excluded from remote session completions + private static let serialOnlyGetParams: Set = ["prv.key", "acl"] + private static let serialOnlySetParams: Set = ["freq"] - // Serial-only params excluded from remote session completions - private static let serialOnlyGetParams: Set = ["prv.key", "acl"] - private static let serialOnlySetParams: Set = ["freq"] + private static let pathHashModeValues = ["0", "1", "2"] - private static let pathHashModeValues = ["0", "1", "2"] + private static let loopDetectValues = ["off", "minimal", "moderate", "strict"] - private static let loopDetectValues = ["off", "minimal", "moderate", "strict"] + // MARK: - Node Names - // MARK: - Node Names + private(set) var nodeNames: [String] = [] - private(set) var nodeNames: [String] = [] + func updateNodeNames(_ names: [String]) { + nodeNames = names + } - func updateNodeNames(_ names: [String]) { - nodeNames = names - } - - // MARK: - Completion Logic - - func completions(for input: String, isLocal: Bool, includeSessionCommands: Bool = true) -> [String] { - let trimmed = input.trimmingCharacters(in: .whitespaces) + // MARK: - Completion Logic - // Empty or just spaces - return all applicable commands - if trimmed.isEmpty { - return availableCommands(isLocal: isLocal, includeSessionCommands: includeSessionCommands).sorted() - } + func completions(for input: String, isLocal: Bool, includeSessionCommands: Bool = true) -> [String] { + let trimmed = input.trimmingCharacters(in: .whitespaces) - let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false).map(String.init) - let command = parts[0].lowercased() + // Empty or just spaces - return all applicable commands + if trimmed.isEmpty { + return availableCommands(isLocal: isLocal, includeSessionCommands: includeSessionCommands).sorted() + } - // Single word - complete command name - if parts.count == 1 && !input.hasSuffix(" ") { - return availableCommands(isLocal: isLocal, includeSessionCommands: includeSessionCommands) - .filter { $0.hasPrefix(command) } - .sorted() - } + let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false).map(String.init) + let command = parts[0].lowercased() - // Command with space - complete arguments - let argPrefix = parts.count > 1 ? parts[1].lowercased() : "" - let endsWithSpace = input.hasSuffix(" ") - return completeArguments(for: command, parts: parts, prefix: argPrefix, endsWithSpace: endsWithSpace) + // Single word - complete command name + if parts.count == 1 && !input.hasSuffix(" ") { + return availableCommands(isLocal: isLocal, includeSessionCommands: includeSessionCommands) + .filter { $0.hasPrefix(command) } + .sorted() } - private func completeArguments( - for command: String, - parts: [String], - prefix: String, - endsWithSpace: Bool - ) -> [String] { - // Determine which argument position we're completing - // parts.count includes command, so parts.count - 1 = number of args started - // If endsWithSpace, we're starting a NEW argument (position = parts.count) - // If !endsWithSpace, we're still typing the CURRENT argument (position = parts.count - 1) - let argPosition = endsWithSpace ? parts.count : parts.count - 1 - - switch command { - case "session", "login", "log", "powersaving", "clear", "clock", "start": - // 1-arg commands: only complete when argPosition == 1 - guard argPosition == 1 else { return [] } - return completeFirstArg(for: command, prefix: prefix) - - case "get": - if argPosition == 1 { - return Self.getSetParams - .filter { !Self.serialOnlyGetParams.contains($0) } - .filter { $0.hasPrefix(prefix) }.sorted() - } - return [] - - case "set": - if argPosition == 1 { - return Self.getSetParams - .filter { !Self.serialOnlySetParams.contains($0) } - .filter { $0.hasPrefix(prefix) }.sorted() - } - if argPosition == 2, parts.count >= 2 { - return completeSetValue(param: parts[1].lowercased(), prefix: parts.count > 2 ? parts[2].lowercased() : "") - } - return [] - - case "gps": - return completeGpsArgs(argPosition: argPosition, parts: parts, prefix: prefix) - - case "region": - return completeRegionArgs(argPosition: argPosition, parts: parts, prefix: prefix) - - default: - return [] - } + // Command with space - complete arguments + let argPrefix = parts.count > 1 ? parts[1].lowercased() : "" + let endsWithSpace = input.hasSuffix(" ") + return completeArguments(for: command, parts: parts, prefix: argPrefix, endsWithSpace: endsWithSpace) + } + + private func completeArguments( + for command: String, + parts: [String], + prefix: String, + endsWithSpace: Bool + ) -> [String] { + // Determine which argument position we're completing + // parts.count includes command, so parts.count - 1 = number of args started + // If endsWithSpace, we're starting a NEW argument (position = parts.count) + // If !endsWithSpace, we're still typing the CURRENT argument (position = parts.count - 1) + let argPosition = endsWithSpace ? parts.count : parts.count - 1 + + switch command { + case "session", "login", "log", "powersaving", "clear", "clock", "start": + // 1-arg commands: only complete when argPosition == 1 + guard argPosition == 1 else { return [] } + return completeFirstArg(for: command, prefix: prefix) + + case "get": + if argPosition == 1 { + return Self.getSetParams + .filter { !Self.serialOnlyGetParams.contains($0) } + .filter { $0.hasPrefix(prefix) }.sorted() + } + return [] + + case "set": + if argPosition == 1 { + return Self.getSetParams + .filter { !Self.serialOnlySetParams.contains($0) } + .filter { $0.hasPrefix(prefix) }.sorted() + } + if argPosition == 2, parts.count >= 2 { + return completeSetValue(param: parts[1].lowercased(), prefix: parts.count > 2 ? parts[2].lowercased() : "") + } + return [] + + case "gps": + return completeGpsArgs(argPosition: argPosition, parts: parts, prefix: prefix) + + case "region": + return completeRegionArgs(argPosition: argPosition, parts: parts, prefix: prefix) + + default: + return [] } - - private func completeFirstArg(for command: String, prefix: String) -> [String] { - switch command { - case "session": - return completeSessionArgs(prefix: prefix) - case "login": - return completeLoginArgs(prefix: prefix) - case "log": - return Self.logSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - case "powersaving": - return Self.onOffValues.filter { $0.hasPrefix(prefix) }.sorted() - case "clear": - return Self.clearSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - case "clock": - return Self.clockSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - case "start": - return Self.startSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - default: - return [] - } + } + + private func completeFirstArg(for command: String, prefix: String) -> [String] { + switch command { + case "session": + completeSessionArgs(prefix: prefix) + case "login": + completeLoginArgs(prefix: prefix) + case "log": + Self.logSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + case "powersaving": + Self.onOffValues.filter { $0.hasPrefix(prefix) }.sorted() + case "clear": + Self.clearSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + case "clock": + Self.clockSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + case "start": + Self.startSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + default: + [] } + } - private func availableCommands(isLocal: Bool, includeSessionCommands: Bool) -> [String] { - var commands = Self.builtInCommands - - if includeSessionCommands { - commands.append(contentsOf: Self.sessionCommands) - } + private func availableCommands(isLocal: Bool, includeSessionCommands: Bool) -> [String] { + var commands = Self.builtInCommands - if isLocal { - commands.append(contentsOf: Self.localOnlyCommands) - } else { - commands.append(contentsOf: Self.repeaterCommands) - } - - return commands + if includeSessionCommands { + commands.append(contentsOf: Self.sessionCommands) } - private func completeSessionArgs(prefix: String) -> [String] { - var suggestions = Self.sessionSubcommands.filter { $0.hasPrefix(prefix) } - suggestions.append(contentsOf: nodeNames.filter { $0.lowercased().hasPrefix(prefix) }) - return suggestions.sorted() + if isLocal { + commands.append(contentsOf: Self.localOnlyCommands) + } else { + commands.append(contentsOf: Self.repeaterCommands) } - private func completeLoginArgs(prefix: String) -> [String] { - return nodeNames.filter { $0.lowercased().hasPrefix(prefix) }.sorted() + return commands + } + + private func completeSessionArgs(prefix: String) -> [String] { + var suggestions = Self.sessionSubcommands.filter { $0.hasPrefix(prefix) } + suggestions.append(contentsOf: nodeNames.filter { $0.lowercased().hasPrefix(prefix) }) + return suggestions.sorted() + } + + private func completeLoginArgs(prefix: String) -> [String] { + nodeNames.filter { $0.lowercased().hasPrefix(prefix) }.sorted() + } + + private func completeSetValue(param: String, prefix: String) -> [String] { + switch param { + case "path.hash.mode": + Self.pathHashModeValues.filter { $0.hasPrefix(prefix) }.sorted() + case "loop.detect": + Self.loopDetectValues.filter { $0.hasPrefix(prefix) }.sorted() + case "repeat", "allow.read.only", "bridge.enabled", "radio.rxgain": + Self.onOffValues.filter { $0.hasPrefix(prefix) }.sorted() + case "multi.acks": + Self.multiAcksValues.filter { $0.hasPrefix(prefix) }.sorted() + case "bridge.source": + Self.bridgeSourceValues.filter { $0.hasPrefix(prefix) }.sorted() + default: + [] } - - private func completeSetValue(param: String, prefix: String) -> [String] { - switch param { - case "path.hash.mode": - return Self.pathHashModeValues.filter { $0.hasPrefix(prefix) }.sorted() - case "loop.detect": - return Self.loopDetectValues.filter { $0.hasPrefix(prefix) }.sorted() - case "repeat", "allow.read.only", "bridge.enabled", "radio.rxgain": - return Self.onOffValues.filter { $0.hasPrefix(prefix) }.sorted() - case "multi.acks": - return Self.multiAcksValues.filter { $0.hasPrefix(prefix) }.sorted() - case "bridge.source": - return Self.bridgeSourceValues.filter { $0.hasPrefix(prefix) }.sorted() - default: - return [] - } + } + + private func completeRegionArgs(argPosition: Int, parts: [String], prefix: String) -> [String] { + switch argPosition { + case 1: + return Self.regionSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + case 2 where parts.count >= 2 && parts[1].lowercased() == "list": + let valuePrefix = parts.count > 2 ? parts[2].lowercased() : "" + return Self.regionListValues.filter { $0.hasPrefix(valuePrefix) }.sorted() + default: + return [] } - - private func completeRegionArgs(argPosition: Int, parts: [String], prefix: String) -> [String] { - switch argPosition { - case 1: - return Self.regionSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - case 2 where parts.count >= 2 && parts[1].lowercased() == "list": - let valuePrefix = parts.count > 2 ? parts[2].lowercased() : "" - return Self.regionListValues.filter { $0.hasPrefix(valuePrefix) }.sorted() - default: - return [] - } - } - - private func completeGpsArgs(argPosition: Int, parts: [String], prefix: String) -> [String] { - switch argPosition { - case 1: - // First argument: subcommand - return Self.gpsSubcommands.filter { $0.hasPrefix(prefix) }.sorted() - case 2 where parts.count >= 2 && parts[1].lowercased() == "advert": - // Second argument for "gps advert": value - let valuePrefix = parts.count > 2 ? parts[2].lowercased() : "" - return Self.gpsAdvertValues.filter { $0.hasPrefix(valuePrefix) }.sorted() - default: - // Command complete or non-advert subcommand (no second arg) - return [] - } + } + + private func completeGpsArgs(argPosition: Int, parts: [String], prefix: String) -> [String] { + switch argPosition { + case 1: + // First argument: subcommand + return Self.gpsSubcommands.filter { $0.hasPrefix(prefix) }.sorted() + case 2 where parts.count >= 2 && parts[1].lowercased() == "advert": + // Second argument for "gps advert": value + let valuePrefix = parts.count > 2 ? parts[2].lowercased() : "" + return Self.gpsAdvertValues.filter { $0.hasPrefix(valuePrefix) }.sorted() + default: + // Command complete or non-advert subcommand (no second arg) + return [] } + } } diff --git a/MC1/Views/Tools/CLI/CLIInputAccessoryView.swift b/MC1/Views/Tools/CLI/CLIInputAccessoryView.swift index 4fdb0113..005629e4 100644 --- a/MC1/Views/Tools/CLI/CLIInputAccessoryView.swift +++ b/MC1/Views/Tools/CLI/CLIInputAccessoryView.swift @@ -1,114 +1,114 @@ import SwiftUI struct CLIInputAccessoryView: View { - let isWaiting: Bool - var showSessionsButton: Bool = true - let onHistoryUp: () -> Void - let onHistoryDown: () -> Void - let onTabComplete: () -> Void - let onMoveLeft: () -> Void - let onMoveRight: () -> Void - let onPaste: () -> Void - let onSessions: () -> Void - let onCancel: () -> Void - let onDismiss: () -> Void + let isWaiting: Bool + var showSessionsButton: Bool = true + let onHistoryUp: () -> Void + let onHistoryDown: () -> Void + let onTabComplete: () -> Void + let onMoveLeft: () -> Void + let onMoveRight: () -> Void + let onPaste: () -> Void + let onSessions: () -> Void + let onCancel: () -> Void + let onDismiss: () -> Void - @State private var showCancel = false + @State private var showCancel = false - var body: some View { - HStack(spacing: 0) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - Button(action: onTabComplete) { - Image(systemName: "arrow.right.to.line") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.tabComplete) + var body: some View { + HStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + Button(action: onTabComplete) { + Image(systemName: "arrow.right.to.line") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.tabComplete) - Button(action: onHistoryUp) { - Image(systemName: "arrow.up") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.historyUp) + Button(action: onHistoryUp) { + Image(systemName: "arrow.up") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.historyUp) - Button(action: onHistoryDown) { - Image(systemName: "arrow.down") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.historyDown) + Button(action: onHistoryDown) { + Image(systemName: "arrow.down") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.historyDown) - Button(action: onMoveLeft) { - Image(systemName: "arrow.left") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.cursorLeft) + Button(action: onMoveLeft) { + Image(systemName: "arrow.left") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.cursorLeft) - Button(action: onMoveRight) { - Image(systemName: "arrow.right") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.cursorRight) + Button(action: onMoveRight) { + Image(systemName: "arrow.right") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.cursorRight) - Button(action: onPaste) { - Image(systemName: "doc.on.clipboard") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.paste) + Button(action: onPaste) { + Image(systemName: "doc.on.clipboard") + } + .accessibilityLabel(L10n.Tools.Tools.Cli.paste) - if showSessionsButton { - Divider() - .frame(height: 20) + if showSessionsButton { + Divider() + .frame(height: 20) - Button(action: onSessions) { - Image(systemName: "rectangle.stack") - } - .accessibilityLabel(L10n.Tools.Tools.Cli.sessions) - } - - Button(action: onCancel) { - Image(systemName: "stop.circle") - .foregroundStyle(showCancel ? .red : .secondary) - } - .disabled(!showCancel) - .accessibilityLabel(L10n.Tools.Tools.Cli.cancelOperation) - } - .font(.body) + Button(action: onSessions) { + Image(systemName: "rectangle.stack") } + .accessibilityLabel(L10n.Tools.Tools.Cli.sessions) + } - Spacer() - - Button(action: onDismiss) { - Image(systemName: "keyboard.chevron.compact.down") - } - .font(.body) - .accessibilityLabel(L10n.Tools.Tools.Cli.dismiss) + Button(action: onCancel) { + Image(systemName: "stop.circle") + .foregroundStyle(showCancel ? .red : .secondary) + } + .disabled(!showCancel) + .accessibilityLabel(L10n.Tools.Tools.Cli.cancelOperation) } - .padding(.horizontal) - .frame(height: 44) - .task(id: isWaiting) { - if isWaiting { - try? await Task.sleep(for: .milliseconds(150)) - showCancel = true - } else { - showCancel = false - } - } - .background { - if #available(iOS 26.0, *) { - Rectangle().fill(.clear).glassEffect() - } else { - Rectangle().fill(.ultraThinMaterial) - } - } - .padding(.horizontal, 4) + .font(.body) + } + + Spacer() + + Button(action: onDismiss) { + Image(systemName: "keyboard.chevron.compact.down") + } + .font(.body) + .accessibilityLabel(L10n.Tools.Tools.Cli.dismiss) + } + .padding(.horizontal) + .frame(height: 44) + .task(id: isWaiting) { + if isWaiting { + try? await Task.sleep(for: .milliseconds(150)) + showCancel = true + } else { + showCancel = false + } + } + .background { + if #available(iOS 26.0, *) { + Rectangle().fill(.clear).glassEffect() + } else { + Rectangle().fill(.ultraThinMaterial) + } } + .padding(.horizontal, 4) + } } #Preview { - CLIInputAccessoryView( - isWaiting: false, - onHistoryUp: {}, - onHistoryDown: {}, - onTabComplete: {}, - onMoveLeft: {}, - onMoveRight: {}, - onPaste: {}, - onSessions: {}, - onCancel: {}, - onDismiss: {} - ) + CLIInputAccessoryView( + isWaiting: false, + onHistoryUp: {}, + onHistoryDown: {}, + onTabComplete: {}, + onMoveLeft: {}, + onMoveRight: {}, + onPaste: {}, + onSessions: {}, + onCancel: {}, + onDismiss: {} + ) } diff --git a/MC1/Views/Tools/CLI/CLIOutputLine.swift b/MC1/Views/Tools/CLI/CLIOutputLine.swift index 22cc5b22..f67ade9e 100644 --- a/MC1/Views/Tools/CLI/CLIOutputLine.swift +++ b/MC1/Views/Tools/CLI/CLIOutputLine.swift @@ -1,24 +1,24 @@ import SwiftUI -enum CLIOutputType: Sendable { - case command // User-entered command (echoed) - case success // Success/acknowledgment - case error // Error message - case response // Node response data +enum CLIOutputType { + case command // User-entered command (echoed) + case success // Success/acknowledgment + case error // Error message + case response // Node response data - var color: Color { - switch self { - case .command: .secondary - case .success: .green - case .error: .orange - case .response: .primary - } + var color: Color { + switch self { + case .command: .secondary + case .success: .green + case .error: .orange + case .response: .primary } + } } -struct CLIOutputLine: Identifiable, Sendable { - let id = UUID() - let text: String - let type: CLIOutputType - let timestamp = Date() +struct CLIOutputLine: Identifiable { + let id = UUID() + let text: String + let type: CLIOutputType + let timestamp = Date() } diff --git a/MC1/Views/Tools/CLI/CLISession.swift b/MC1/Views/Tools/CLI/CLISession.swift index 38264b2d..43425677 100644 --- a/MC1/Views/Tools/CLI/CLISession.swift +++ b/MC1/Views/Tools/CLI/CLISession.swift @@ -1,16 +1,16 @@ import Foundation -struct CLISession: Identifiable, Hashable, Sendable { - let id: UUID - let name: String - let isLocal: Bool - let pathLength: UInt8 +struct CLISession: Identifiable, Hashable { + let id: UUID + let name: String + let isLocal: Bool + let pathLength: UInt8 - static func local(deviceName: String) -> CLISession { - CLISession(id: UUID(), name: deviceName, isLocal: true, pathLength: 0) - } + static func local(deviceName: String) -> CLISession { + CLISession(id: UUID(), name: deviceName, isLocal: true, pathLength: 0) + } - static func remote(id: UUID, name: String, pathLength: UInt8) -> CLISession { - CLISession(id: id, name: name, isLocal: false, pathLength: pathLength) - } + static func remote(id: UUID, name: String, pathLength: UInt8) -> CLISession { + CLISession(id: id, name: name, isLocal: false, pathLength: pathLength) + } } diff --git a/MC1/Views/Tools/CLI/CLITerminalView.swift b/MC1/Views/Tools/CLI/CLITerminalView.swift index c5d357c8..91b306d3 100644 --- a/MC1/Views/Tools/CLI/CLITerminalView.swift +++ b/MC1/Views/Tools/CLI/CLITerminalView.swift @@ -8,258 +8,258 @@ private let terminalFont = Font.caption.monospaced() /// Holds no view model: all state arrives as values/bindings and all events /// leave via callbacks, so each host wires its own view model. struct CLITerminalView: View { - let outputLines: [CLIOutputLine] - let promptText: String - let ghostText: String - let tabSuggestions: [String]? - let tabSelectionIndex: Int? - let isWaitingForResponse: Bool - let showSessionsButton: Bool + let outputLines: [CLIOutputLine] + let promptText: String + let ghostText: String + let tabSuggestions: [String]? + let tabSelectionIndex: Int? + let isWaitingForResponse: Bool + let showSessionsButton: Bool - @Binding var currentInput: String - @Binding var isKeyboardFocused: Bool - @Binding var scrollPosition: ScrollPosition - @Binding var cursorPosition: Int + @Binding var currentInput: String + @Binding var isKeyboardFocused: Bool + @Binding var scrollPosition: ScrollPosition + @Binding var cursorPosition: Int - let onSubmit: () -> Void - let onHistoryUp: () -> Void - let onHistoryDown: () -> Void - let onRightArrowAtEnd: () -> Void - let onTabComplete: () -> Void - let onMoveLeft: () -> Void - let onMoveRight: () -> Void - let onPaste: () -> Void - let onSessions: () -> Void - let onCancel: () -> Void - let onDismiss: () -> Void - let onClear: () -> Void - let onUpdateGhostText: (_ cursorAtEnd: Bool) -> Void - let onClearTabState: () -> Void - let onGetResponseBlock: (CLIOutputLine) -> String + let onSubmit: () -> Void + let onHistoryUp: () -> Void + let onHistoryDown: () -> Void + let onRightArrowAtEnd: () -> Void + let onTabComplete: () -> Void + let onMoveLeft: () -> Void + let onMoveRight: () -> Void + let onPaste: () -> Void + let onSessions: () -> Void + let onCancel: () -> Void + let onDismiss: () -> Void + let onClear: () -> Void + let onUpdateGhostText: (_ cursorAtEnd: Bool) -> Void + let onClearTabState: () -> Void + let onGetResponseBlock: (CLIOutputLine) -> String - var body: some View { - ZStack(alignment: .bottomTrailing) { - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(outputLines) { line in - Text(line.text) - .font(terminalFont) - .foregroundStyle(line.type.color) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - .id(line.id) - .contextMenu { - Button { - UIPasteboard.general.string = onGetResponseBlock(line) - } label: { - Label(L10n.Tools.Tools.RxLog.copy, systemImage: "doc.on.doc") - } - } - } - - inlinePrompt - .id("prompt") - - if let suggestions = tabSuggestions { - let columns = [GridItem(.adaptive(minimum: 120), alignment: .leading)] - LazyVGrid(columns: columns, alignment: .leading, spacing: 4) { - ForEach(Array(suggestions.enumerated()), id: \.offset) { index, suggestion in - Text(suggestion) - .font(terminalFont) - .foregroundStyle(index == tabSelectionIndex ? .primary : .secondary) - .accessibilityAddTraits(index == tabSelectionIndex ? .isSelected : []) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background { - if index == tabSelectionIndex { - RoundedRectangle(cornerRadius: 4) - .fill(Color.accentColor.opacity(0.3)) - } - } - } - } - .id("suggestions") - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - .padding(.top, 8) - .padding(.bottom, 8) - } - .scrollIndicators(.hidden) - .scrollDismissesKeyboard(.never) - .scrollPosition($scrollPosition) - .onChange(of: outputLines.count) { _, _ in - scrollPosition.scrollTo(edge: .bottom) - } - .onChange(of: isKeyboardFocused) { _, focused in - if focused { - Task { - try? await Task.sleep(for: .milliseconds(100)) - scrollPosition.scrollTo(edge: .bottom) - } - } - } - .onChange(of: currentInput) { _, _ in - onUpdateGhostText(cursorAtEnd) - onClearTabState() - } - .onChange(of: cursorPosition) { _, _ in - onUpdateGhostText(cursorAtEnd) - } - .onChange(of: tabSuggestions) { _, newSuggestions in - if newSuggestions != nil { - scrollPosition.scrollTo(edge: .bottom) + var body: some View { + ZStack(alignment: .bottomTrailing) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(outputLines) { line in + Text(line.text) + .font(terminalFont) + .foregroundStyle(line.type.color) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .id(line.id) + .contextMenu { + Button { + UIPasteboard.general.string = onGetResponseBlock(line) + } label: { + Label(L10n.Tools.Tools.RxLog.copy, systemImage: "doc.on.doc") } - } + } + } - HiddenTextViewFocusable( - text: $currentInput, - isFocused: $isKeyboardFocused, - cursorPosition: $cursorPosition, - onSubmit: onSubmit, - onHistoryUp: onHistoryUp, - onHistoryDown: onHistoryDown, - onRightArrowAtEnd: onRightArrowAtEnd, - onTabComplete: onTabComplete - ) - .frame(width: 1, height: 1) - .opacity(0.01) + inlinePrompt + .id("prompt") - if scrollPosition.isPositionedByUser { - CLIScrollToBottomButton { - scrollPosition.scrollTo(edge: .bottom) - } - } - } - .background(Color(.secondarySystemBackground)) - .contentShape(.rect) - // onTapGesture is intentional: a Button in .background can't receive - // taps through the ScrollView, and this is a non-semantic "tap anywhere - // to focus keyboard" gesture, not a discrete button action. - .onTapGesture { - isKeyboardFocused = true - } - .safeAreaInset(edge: .bottom) { - if isKeyboardFocused { - CLIInputAccessoryView( - isWaiting: isWaitingForResponse, - showSessionsButton: showSessionsButton, - onHistoryUp: onHistoryUp, - onHistoryDown: onHistoryDown, - onTabComplete: onTabComplete, - onMoveLeft: onMoveLeft, - onMoveRight: onMoveRight, - onPaste: onPaste, - onSessions: onSessions, - onCancel: onCancel, - onDismiss: onDismiss - ) - } - } - .onKeyPress(.upArrow) { - onHistoryUp() - return .handled - } - .onKeyPress(.downArrow) { - onHistoryDown() - return .handled - } - .onKeyPress(.tab, phases: [.down]) { _ in - onTabComplete() - return .handled - } - .onKeyPress(.escape) { - if tabSelectionIndex != nil { - onClearTabState() - return .handled - } - if isWaitingForResponse { - onCancel() - } else { - isKeyboardFocused = false + if let suggestions = tabSuggestions { + let columns = [GridItem(.adaptive(minimum: 120), alignment: .leading)] + LazyVGrid(columns: columns, alignment: .leading, spacing: 4) { + ForEach(Array(suggestions.enumerated()), id: \.offset) { index, suggestion in + Text(suggestion) + .font(terminalFont) + .foregroundStyle(index == tabSelectionIndex ? .primary : .secondary) + .accessibilityAddTraits(index == tabSelectionIndex ? .isSelected : []) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background { + if index == tabSelectionIndex { + RoundedRectangle(cornerRadius: 4) + .fill(Color.accentColor.opacity(0.3)) + } + } + } } - return .handled + .id("suggestions") + } } - .onKeyPress(phases: [.down]) { keyPress in - if keyPress.key == "k" && keyPress.modifiers.contains(.command) { - onClear() - return .handled - } - return .ignored + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.top, 8) + .padding(.bottom, 8) + } + .scrollIndicators(.hidden) + .scrollDismissesKeyboard(.never) + .scrollPosition($scrollPosition) + .onChange(of: outputLines.count) { _, _ in + scrollPosition.scrollTo(edge: .bottom) + } + .onChange(of: isKeyboardFocused) { _, focused in + if focused { + Task { + try? await Task.sleep(for: .milliseconds(100)) + scrollPosition.scrollTo(edge: .bottom) + } } - .onAppear { - isKeyboardFocused = true + } + .onChange(of: currentInput) { _, _ in + onUpdateGhostText(cursorAtEnd) + onClearTabState() + } + .onChange(of: cursorPosition) { _, _ in + onUpdateGhostText(cursorAtEnd) + } + .onChange(of: tabSuggestions) { _, newSuggestions in + if newSuggestions != nil { + scrollPosition.scrollTo(edge: .bottom) } - .onDisappear { - // Clear the focus request when leaving so the gated accessory bar - // can't persist across navigation and re-mount on return. - isKeyboardFocused = false + } + + HiddenTextViewFocusable( + text: $currentInput, + isFocused: $isKeyboardFocused, + cursorPosition: $cursorPosition, + onSubmit: onSubmit, + onHistoryUp: onHistoryUp, + onHistoryDown: onHistoryDown, + onRightArrowAtEnd: onRightArrowAtEnd, + onTabComplete: onTabComplete + ) + .frame(width: 1, height: 1) + .opacity(0.01) + + if scrollPosition.isPositionedByUser { + CLIScrollToBottomButton { + scrollPosition.scrollTo(edge: .bottom) } + } + } + .background(Color(.secondarySystemBackground)) + .contentShape(.rect) + // onTapGesture is intentional: a Button in .background can't receive + // taps through the ScrollView, and this is a non-semantic "tap anywhere + // to focus keyboard" gesture, not a discrete button action. + .onTapGesture { + isKeyboardFocused = true + } + .safeAreaInset(edge: .bottom) { + if isKeyboardFocused { + CLIInputAccessoryView( + isWaiting: isWaitingForResponse, + showSessionsButton: showSessionsButton, + onHistoryUp: onHistoryUp, + onHistoryDown: onHistoryDown, + onTabComplete: onTabComplete, + onMoveLeft: onMoveLeft, + onMoveRight: onMoveRight, + onPaste: onPaste, + onSessions: onSessions, + onCancel: onCancel, + onDismiss: onDismiss + ) + } + } + .onKeyPress(.upArrow) { + onHistoryUp() + return .handled + } + .onKeyPress(.downArrow) { + onHistoryDown() + return .handled + } + .onKeyPress(.tab, phases: [.down]) { _ in + onTabComplete() + return .handled + } + .onKeyPress(.escape) { + if tabSelectionIndex != nil { + onClearTabState() + return .handled + } + if isWaitingForResponse { + onCancel() + } else { + isKeyboardFocused = false + } + return .handled } + .onKeyPress(phases: [.down]) { keyPress in + if keyPress.key == "k", keyPress.modifiers.contains(.command) { + onClear() + return .handled + } + return .ignored + } + .onAppear { + isKeyboardFocused = true + } + .onDisappear { + // Clear the focus request when leaving so the gated accessory bar + // can't persist across navigation and re-mount on return. + isKeyboardFocused = false + } + } - private var inlinePrompt: some View { - HStack(spacing: 0) { - if !isWaitingForResponse { - Text(promptText) - .font(terminalFont) - .foregroundStyle(.primary) - .accessibilityLabel(L10n.Tools.Tools.Cli.commandPrompt) - .accessibilityValue(promptText) + private var inlinePrompt: some View { + HStack(spacing: 0) { + if !isWaitingForResponse { + Text(promptText) + .font(terminalFont) + .foregroundStyle(.primary) + .accessibilityLabel(L10n.Tools.Tools.Cli.commandPrompt) + .accessibilityValue(promptText) - Text(textBeforeCursor) - .font(terminalFont) - .accessibilityLabel(L10n.Tools.Tools.Cli.commandInput) + Text(textBeforeCursor) + .font(terminalFont) + .accessibilityLabel(L10n.Tools.Tools.Cli.commandInput) - if isKeyboardFocused { - Rectangle() - .fill(Color.primary) - .frame(width: 2, height: 14) - } + if isKeyboardFocused { + Rectangle() + .fill(Color.primary) + .frame(width: 2, height: 14) + } - Text(textAfterCursor) - .font(terminalFont) + Text(textAfterCursor) + .font(terminalFont) - if cursorAtEnd { - Text(ghostText) - .font(terminalFont) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - } else { - Rectangle() - .fill(Color.primary) - .frame(width: 8, height: 14) - } + if cursorAtEnd { + Text(ghostText) + .font(terminalFont) + .foregroundStyle(.secondary) + .accessibilityHidden(true) } + } else { + Rectangle() + .fill(Color.primary) + .frame(width: 8, height: 14) + } } + } - private var textBeforeCursor: String { - let index = currentInput.index(currentInput.startIndex, offsetBy: min(cursorPosition, currentInput.count)) - return String(currentInput[..= currentInput.count - } + private var cursorAtEnd: Bool { + cursorPosition >= currentInput.count + } } private struct CLIScrollToBottomButton: View { - let action: () -> Void + let action: () -> Void - var body: some View { - Button(action: action) { - Image(systemName: "arrow.down.circle.fill") - .font(.title) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.primary) - } - .accessibilityLabel(L10n.Tools.Tools.Cli.jumpToBottom) - .padding() + var body: some View { + Button(action: action) { + Image(systemName: "arrow.down.circle.fill") + .font(.title) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.primary) } + .accessibilityLabel(L10n.Tools.Tools.Cli.jumpToBottom) + .padding() + } } diff --git a/MC1/Views/Tools/CLI/CLIToolView.swift b/MC1/Views/Tools/CLI/CLIToolView.swift index e5df3af4..b3fc1031 100644 --- a/MC1/Views/Tools/CLI/CLIToolView.swift +++ b/MC1/Views/Tools/CLI/CLIToolView.swift @@ -1,153 +1,153 @@ -import SwiftUI import MC1Services +import SwiftUI import UIKit struct CLIToolView: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - @State private var isKeyboardFocused = false - @State private var scrollPosition = ScrollPosition(edge: .bottom) - @State private var cursorPosition: Int = 0 - - var body: some View { - content - .onAppear { - if appState.cliToolViewModel == nil { - appState.cliToolViewModel = CLIToolViewModel() - } else { - // Restore cursor to end of existing input when returning to CLI - cursorPosition = appState.cliToolViewModel?.currentInput.count ?? 0 - } - } - } + @State private var isKeyboardFocused = false + @State private var scrollPosition = ScrollPosition(edge: .bottom) + @State private var cursorPosition: Int = 0 - @ViewBuilder - private var content: some View { - if let viewModel = appState.cliToolViewModel { - CLIToolContent( - viewModel: viewModel, - appState: appState, - isKeyboardFocused: $isKeyboardFocused, - scrollPosition: $scrollPosition, - cursorPosition: $cursorPosition - ) + var body: some View { + content + .onAppear { + if appState.cliToolViewModel == nil { + appState.cliToolViewModel = CLIToolViewModel() } else { - ProgressView() + // Restore cursor to end of existing input when returning to CLI + cursorPosition = appState.cliToolViewModel?.currentInput.count ?? 0 } + } + } + + @ViewBuilder + private var content: some View { + if let viewModel = appState.cliToolViewModel { + CLIToolContent( + viewModel: viewModel, + appState: appState, + isKeyboardFocused: $isKeyboardFocused, + scrollPosition: $scrollPosition, + cursorPosition: $cursorPosition + ) + } else { + ProgressView() } + } } private struct CLIToolContent: View { - @Bindable var viewModel: CLIToolViewModel - let appState: AppState - @Binding var isKeyboardFocused: Bool - @Binding var scrollPosition: ScrollPosition - @Binding var cursorPosition: Int + @Bindable var viewModel: CLIToolViewModel + let appState: AppState + @Binding var isKeyboardFocused: Bool + @Binding var scrollPosition: ScrollPosition + @Binding var cursorPosition: Int - var body: some View { - Group { - if appState.services?.repeaterAdminService == nil { - disconnectedState - } else { - terminalView - } - } - .navigationTitle(L10n.Tools.Tools.cli) - .navigationBarTitleDisplayMode(.inline) - .liquidGlassToolbarBackground() - .task(id: appState.servicesVersion) { - viewModel.configure( - repeaterAdminService: { [appState] in appState.services?.repeaterAdminService }, - remoteNodeService: { [appState] in appState.services?.remoteNodeService }, - dataStore: { [appState] in appState.services?.dataStore }, - radioID: { [appState] in appState.connectedDevice?.radioID }, - localDeviceName: appState.connectedDevice?.nodeName ?? L10n.Tools.Tools.Cli.defaultDevice - ) - } + var body: some View { + Group { + if appState.services?.repeaterAdminService == nil { + disconnectedState + } else { + terminalView + } + } + .navigationTitle(L10n.Tools.Tools.cli) + .navigationBarTitleDisplayMode(.inline) + .liquidGlassToolbarBackground() + .task(id: appState.servicesVersion) { + viewModel.configure( + repeaterAdminService: { [appState] in appState.services?.repeaterAdminService }, + remoteNodeService: { [appState] in appState.services?.remoteNodeService }, + dataStore: { [appState] in appState.services?.dataStore }, + radioID: { [appState] in appState.connectedDevice?.radioID }, + localDeviceName: appState.connectedDevice?.nodeName ?? L10n.Tools.Tools.Cli.defaultDevice + ) } + } - // MARK: - Disconnected State + // MARK: - Disconnected State - private var disconnectedState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.Cli.notConnected, systemImage: "terminal") - } description: { - Text(L10n.Tools.Tools.Cli.notConnectedDescription) - } + private var disconnectedState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.Cli.notConnected, systemImage: "terminal") + } description: { + Text(L10n.Tools.Tools.Cli.notConnectedDescription) } + } - // MARK: - Terminal View + // MARK: - Terminal View - private var terminalView: some View { - CLITerminalView( - outputLines: viewModel.outputLines, - promptText: viewModel.promptText, - ghostText: viewModel.ghostText, - tabSuggestions: viewModel.tabSuggestions, - tabSelectionIndex: viewModel.tabSelectionIndex, - isWaitingForResponse: viewModel.isWaitingForResponse, - showSessionsButton: true, - currentInput: $viewModel.currentInput, - isKeyboardFocused: $isKeyboardFocused, - scrollPosition: $scrollPosition, - cursorPosition: $cursorPosition, - onSubmit: { - if viewModel.applySelectedSuggestion() { - cursorPosition = viewModel.currentInput.count - } else { - viewModel.executeCommand(viewModel.currentInput) - } - }, - onHistoryUp: { - viewModel.historyUp() - cursorPosition = viewModel.currentInput.count - }, - onHistoryDown: { - viewModel.historyDown() - cursorPosition = viewModel.currentInput.count - }, - onRightArrowAtEnd: { - if !viewModel.ghostText.isEmpty { - viewModel.acceptGhostText() - cursorPosition = viewModel.currentInput.count - } - }, - onTabComplete: { - viewModel.tabComplete() - cursorPosition = viewModel.currentInput.count - }, - onMoveLeft: { - if cursorPosition > 0 { cursorPosition -= 1 } - }, - onMoveRight: { - if !viewModel.ghostText.isEmpty && cursorPosition >= viewModel.currentInput.count { - viewModel.acceptGhostText() - cursorPosition = viewModel.currentInput.count - } else if cursorPosition < viewModel.currentInput.count { - cursorPosition += 1 - } - }, - onPaste: { - viewModel.pasteFromClipboard(at: cursorPosition) - cursorPosition = min( - cursorPosition + (UIPasteboard.general.string?.count ?? 0), - viewModel.currentInput.count - ) - }, - onSessions: { viewModel.executeCommand("session list") }, - onCancel: { viewModel.cancelCurrentCommand() }, - onDismiss: { isKeyboardFocused = false }, - onClear: { viewModel.executeCommand("clear") }, - onUpdateGhostText: { cursorAtEnd in viewModel.updateGhostText(cursorAtEnd: cursorAtEnd) }, - onClearTabState: { viewModel.clearTabState() }, - onGetResponseBlock: { viewModel.getResponseBlock(containing: $0) } + private var terminalView: some View { + CLITerminalView( + outputLines: viewModel.outputLines, + promptText: viewModel.promptText, + ghostText: viewModel.ghostText, + tabSuggestions: viewModel.tabSuggestions, + tabSelectionIndex: viewModel.tabSelectionIndex, + isWaitingForResponse: viewModel.isWaitingForResponse, + showSessionsButton: true, + currentInput: $viewModel.currentInput, + isKeyboardFocused: $isKeyboardFocused, + scrollPosition: $scrollPosition, + cursorPosition: $cursorPosition, + onSubmit: { + if viewModel.applySelectedSuggestion() { + cursorPosition = viewModel.currentInput.count + } else { + viewModel.executeCommand(viewModel.currentInput) + } + }, + onHistoryUp: { + viewModel.historyUp() + cursorPosition = viewModel.currentInput.count + }, + onHistoryDown: { + viewModel.historyDown() + cursorPosition = viewModel.currentInput.count + }, + onRightArrowAtEnd: { + if !viewModel.ghostText.isEmpty { + viewModel.acceptGhostText() + cursorPosition = viewModel.currentInput.count + } + }, + onTabComplete: { + viewModel.tabComplete() + cursorPosition = viewModel.currentInput.count + }, + onMoveLeft: { + if cursorPosition > 0 { cursorPosition -= 1 } + }, + onMoveRight: { + if !viewModel.ghostText.isEmpty, cursorPosition >= viewModel.currentInput.count { + viewModel.acceptGhostText() + cursorPosition = viewModel.currentInput.count + } else if cursorPosition < viewModel.currentInput.count { + cursorPosition += 1 + } + }, + onPaste: { + viewModel.pasteFromClipboard(at: cursorPosition) + cursorPosition = min( + cursorPosition + (UIPasteboard.general.string?.count ?? 0), + viewModel.currentInput.count ) - } + }, + onSessions: { viewModel.executeCommand("session list") }, + onCancel: { viewModel.cancelCurrentCommand() }, + onDismiss: { isKeyboardFocused = false }, + onClear: { viewModel.executeCommand("clear") }, + onUpdateGhostText: { cursorAtEnd in viewModel.updateGhostText(cursorAtEnd: cursorAtEnd) }, + onClearTabState: { viewModel.clearTabState() }, + onGetResponseBlock: { viewModel.getResponseBlock(containing: $0) } + ) + } } #Preview { - NavigationStack { - CLIToolView() - } - .environment(\.appState, AppState()) + NavigationStack { + CLIToolView() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/CLI/CLIToolViewModel+Completion.swift b/MC1/Views/Tools/CLI/CLIToolViewModel+Completion.swift index 599ad4f8..6e42e445 100644 --- a/MC1/Views/Tools/CLI/CLIToolViewModel+Completion.swift +++ b/MC1/Views/Tools/CLI/CLIToolViewModel+Completion.swift @@ -4,134 +4,133 @@ import OSLog // MARK: - Ghost Text Completion extension CLIToolViewModel { - - /// Updates ghost text based on current input. - /// - Parameter cursorAtEnd: Whether cursor is at end of input. Ghost text only shows when true. - func updateGhostText(cursorAtEnd: Bool) { - // No ghost text during password entry - guard pendingLoginContact == nil else { - ghostText = "" - return - } - - // No ghost text for empty input or cursor not at end - guard !currentInput.isEmpty, cursorAtEnd else { - ghostText = "" - return - } - - let isLocal = activeSession?.isLocal ?? true - let suggestions = completionEngine.completions(for: currentInput, isLocal: isLocal) - - guard let first = suggestions.first else { - ghostText = "" - return - } - - // Extract suffix: if input is "hel" and match is "help", ghost is "p" - // Handle argument completion: "session li" -> first might be "list" - let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) - let lastPart = parts.last.map(String.init) ?? "" - - if first.lowercased().hasPrefix(lastPart.lowercased()) { - ghostText = String(first.dropFirst(lastPart.count)) - } else { - ghostText = "" - } + /// Updates ghost text based on current input. + /// - Parameter cursorAtEnd: Whether cursor is at end of input. Ghost text only shows when true. + func updateGhostText(cursorAtEnd: Bool) { + // No ghost text during password entry + guard pendingLoginContact == nil else { + ghostText = "" + return } - /// Accepts the current ghost text, appending it to input. - func acceptGhostText() { - guard !ghostText.isEmpty else { return } - currentInput += ghostText - ghostText = "" + // No ghost text for empty input or cursor not at end + guard !currentInput.isEmpty, cursorAtEnd else { + ghostText = "" + return } - /// Handles tab press for completion. - /// - Returns: Array of suggestions if multiple matches, nil otherwise - @discardableResult - func tabComplete() -> [String]? { - guard pendingLoginContact == nil else { return nil } - - // If already showing suggestions, cycle selection - if let suggestions = tabSuggestions, !suggestions.isEmpty { - if let currentIndex = tabSelectionIndex { - tabSelectionIndex = (currentIndex + 1) % suggestions.count - } else { - tabSelectionIndex = 0 - } - return suggestions - } - - // Generate new suggestions - let isLocal = activeSession?.isLocal ?? true - let suggestions = completionEngine.completions(for: currentInput, isLocal: isLocal) - - guard !suggestions.isEmpty else { - tabSuggestions = nil - tabSelectionIndex = nil - return nil - } - - if suggestions.count == 1 { - applyCompletion(suggestions[0]) - return nil - } - - tabSuggestions = suggestions - tabSelectionIndex = nil - return suggestions - } + let isLocal = activeSession?.isLocal ?? true + let suggestions = completionEngine.completions(for: currentInput, isLocal: isLocal) - /// Applies the selected suggestion if in selection mode. - /// - Returns: true if suggestion was applied, false if not in selection mode - func applySelectedSuggestion() -> Bool { - guard let suggestions = tabSuggestions, - let index = tabSelectionIndex, - index < suggestions.count else { - return false - } - applyCompletion(suggestions[index]) - clearTabState() - return true + guard let first = suggestions.first else { + ghostText = "" + return } - /// Clears tab completion state (suggestions and selection). - func clearTabState() { - tabSuggestions = nil - tabSelectionIndex = nil + // Extract suffix: if input is "hel" and match is "help", ghost is "p" + // Handle argument completion: "session li" -> first might be "list" + let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) + let lastPart = parts.last.map(String.init) ?? "" + + if first.lowercased().hasPrefix(lastPart.lowercased()) { + ghostText = String(first.dropFirst(lastPart.count)) + } else { + ghostText = "" + } + } + + /// Accepts the current ghost text, appending it to input. + func acceptGhostText() { + guard !ghostText.isEmpty else { return } + currentInput += ghostText + ghostText = "" + } + + /// Handles tab press for completion. + /// - Returns: Array of suggestions if multiple matches, nil otherwise + @discardableResult + func tabComplete() -> [String]? { + guard pendingLoginContact == nil else { return nil } + + // If already showing suggestions, cycle selection + if let suggestions = tabSuggestions, !suggestions.isEmpty { + if let currentIndex = tabSelectionIndex { + tabSelectionIndex = (currentIndex + 1) % suggestions.count + } else { + tabSelectionIndex = 0 + } + return suggestions } - private func applyCompletion(_ suggestion: String) { - let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) - - if parts.count <= 1 { - currentInput = suggestion + " " - } else { - var newParts = parts.dropLast().map(String.init) - newParts.append(suggestion) - currentInput = newParts.joined(separator: " ") + " " - } - ghostText = "" + // Generate new suggestions + let isLocal = activeSession?.isLocal ?? true + let suggestions = completionEngine.completions(for: currentInput, isLocal: isLocal) + + guard !suggestions.isEmpty else { + tabSuggestions = nil + tabSelectionIndex = nil + return nil } - /// Clears ghost text and tab state when switching sessions. - func clearCompletionState() { - ghostText = "" - clearTabState() + if suggestions.count == 1 { + applyCompletion(suggestions[0]) + return nil } - func updateNodeNamesForCompletion() async { - guard let dataStore, let radioID else { return } - - do { - let contacts = try await dataStore.fetchContacts(radioID: radioID) - let names = contacts - .filter { $0.type == .repeater || $0.type == .room } - .map(\.name) - completionEngine.updateNodeNames(names) - } catch { - Self.logger.error("Failed to fetch contacts for completion: \(error)") - } + tabSuggestions = suggestions + tabSelectionIndex = nil + return suggestions + } + + /// Applies the selected suggestion if in selection mode. + /// - Returns: true if suggestion was applied, false if not in selection mode + func applySelectedSuggestion() -> Bool { + guard let suggestions = tabSuggestions, + let index = tabSelectionIndex, + index < suggestions.count else { + return false + } + applyCompletion(suggestions[index]) + clearTabState() + return true + } + + /// Clears tab completion state (suggestions and selection). + func clearTabState() { + tabSuggestions = nil + tabSelectionIndex = nil + } + + private func applyCompletion(_ suggestion: String) { + let parts = currentInput.split(separator: " ", omittingEmptySubsequences: false) + + if parts.count <= 1 { + currentInput = suggestion + " " + } else { + var newParts = parts.dropLast().map(String.init) + newParts.append(suggestion) + currentInput = newParts.joined(separator: " ") + " " + } + ghostText = "" + } + + /// Clears ghost text and tab state when switching sessions. + func clearCompletionState() { + ghostText = "" + clearTabState() + } + + func updateNodeNamesForCompletion() async { + guard let dataStore, let radioID else { return } + + do { + let contacts = try await dataStore.fetchContacts(radioID: radioID) + let names = contacts + .filter { $0.type == .repeater || $0.type == .room } + .map(\.name) + completionEngine.updateNodeNames(names) + } catch { + Self.logger.error("Failed to fetch contacts for completion: \(error)") } + } } diff --git a/MC1/Views/Tools/CLI/CLIToolViewModel+Sessions.swift b/MC1/Views/Tools/CLI/CLIToolViewModel+Sessions.swift index bf34401d..3c343116 100644 --- a/MC1/Views/Tools/CLI/CLIToolViewModel+Sessions.swift +++ b/MC1/Views/Tools/CLI/CLIToolViewModel+Sessions.swift @@ -1,345 +1,344 @@ import Foundation -import OSLog import MC1Services +import OSLog // MARK: - Session and Login Management extension CLIToolViewModel { - func handleSessionCommand(_ args: String) { - let subcommand = args.trimmingCharacters(in: .whitespaces).lowercased() - - if subcommand.isEmpty || subcommand == "list" { - showSessionList() - } else if subcommand == "local" { - switchToLocal() - } else { - switchToSession(named: args.trimmingCharacters(in: .whitespaces)) - } + func handleSessionCommand(_ args: String) { + let subcommand = args.trimmingCharacters(in: .whitespaces).lowercased() + + if subcommand.isEmpty || subcommand == "list" { + showSessionList() + } else if subcommand == "local" { + switchToLocal() + } else { + switchToSession(named: args.trimmingCharacters(in: .whitespaces)) } + } - func showSessionList() { - appendOutput(L10n.Tools.Tools.Cli.sessionListHeader, type: .response) + func showSessionList() { + appendOutput(L10n.Tools.Tools.Cli.sessionListHeader, type: .response) - let localMarker = (activeSession?.isLocal == true) ? "*" : " " - appendOutput(" \(localMarker) 1. \(localDeviceName) (\(L10n.Tools.Tools.Cli.sessionLocal))", type: .response) + let localMarker = (activeSession?.isLocal == true) ? "*" : " " + appendOutput(" \(localMarker) 1. \(localDeviceName) (\(L10n.Tools.Tools.Cli.sessionLocal))", type: .response) - for (index, session) in remoteSessions.enumerated() { - let marker = (activeSession?.id == session.id) ? "*" : " " - appendOutput(" \(marker) \(index + 2). @\(session.name)", type: .response) - } + for (index, session) in remoteSessions.enumerated() { + let marker = (activeSession?.id == session.id) ? "*" : " " + appendOutput(" \(marker) \(index + 2). @\(session.name)", type: .response) + } + } + + func switchToLocal() { + clearCompletionState() + activeSession = .local(deviceName: localDeviceName) + appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) \(localDeviceName)", type: .success) + } + + func switchToSession(named name: String) { + clearCompletionState() + // Check if input is a number + if let number = Int(name) { + if number == 1 { + switchToLocal() + return + } + let remoteIndex = number - 2 + if remoteIndex >= 0, remoteIndex < remoteSessions.count { + let session = remoteSessions[remoteIndex] + activeSession = session + appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) @\(session.name)", type: .success) + return + } + appendOutput("\(L10n.Tools.Tools.Cli.sessionNotFound) \(name)", type: .error) + return + } + // Match by name + let predicate: (CLISession) -> Bool = { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame } + if let session = remoteSessions.first(where: predicate) { + activeSession = session + appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) @\(session.name)", type: .success) + } else { + appendOutput("\(L10n.Tools.Tools.Cli.sessionNotFound) \(name)", type: .error) } + } - func switchToLocal() { - clearCompletionState() - activeSession = .local(deviceName: localDeviceName) - appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) \(localDeviceName)", type: .success) + func handleLogin(_ args: String) async { + guard activeSession?.isLocal == true else { + appendOutput(L10n.Tools.Tools.Cli.loginFromLocalOnly, type: .error) + return } - func switchToSession(named name: String) { - clearCompletionState() - // Check if input is a number - if let number = Int(name) { - if number == 1 { - switchToLocal() - return - } - let remoteIndex = number - 2 - if remoteIndex >= 0 && remoteIndex < remoteSessions.count { - let session = remoteSessions[remoteIndex] - activeSession = session - appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) @\(session.name)", type: .success) - return - } - appendOutput("\(L10n.Tools.Tools.Cli.sessionNotFound) \(name)", type: .error) - return - } + // Parse --forget or -f flag (must be first argument) + let trimmed = args.trimmingCharacters(in: .whitespaces) + let forgetPassword: Bool + let nodeName: String + + if trimmed.hasPrefix("--forget ") { + forgetPassword = true + nodeName = String(trimmed.dropFirst("--forget ".count)).trimmingCharacters(in: .whitespaces) + } else if trimmed.hasPrefix("-f ") { + forgetPassword = true + nodeName = String(trimmed.dropFirst("-f ".count)).trimmingCharacters(in: .whitespaces) + } else { + forgetPassword = false + nodeName = trimmed + } - // Match by name - let predicate: (CLISession) -> Bool = { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame } - if let session = remoteSessions.first(where: predicate) { - activeSession = session - appendOutput("\(L10n.Tools.Tools.Cli.sessionSwitched) @\(session.name)", type: .success) - } else { - appendOutput("\(L10n.Tools.Tools.Cli.sessionNotFound) \(name)", type: .error) - } + guard !nodeName.isEmpty else { + appendOutput(L10n.Tools.Tools.Cli.loginUsage, type: .error) + return } - func handleLogin(_ args: String) async { - guard activeSession?.isLocal == true else { - appendOutput(L10n.Tools.Tools.Cli.loginFromLocalOnly, type: .error) - return - } + guard let dataStore, let radioID, let remoteNodeService else { + appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) + return + } - // Parse --forget or -f flag (must be first argument) - let trimmed = args.trimmingCharacters(in: .whitespaces) - let forgetPassword: Bool - let nodeName: String - - if trimmed.hasPrefix("--forget ") { - forgetPassword = true - nodeName = String(trimmed.dropFirst("--forget ".count)).trimmingCharacters(in: .whitespaces) - } else if trimmed.hasPrefix("-f ") { - forgetPassword = true - nodeName = String(trimmed.dropFirst("-f ".count)).trimmingCharacters(in: .whitespaces) - } else { - forgetPassword = false - nodeName = trimmed - } + // Find contact by name (repeaters and room servers only) + let contact: ContactDTO + do { + let contacts = try await dataStore.fetchContacts(radioID: radioID) + guard let found = contacts.first(where: { + $0.name.localizedCaseInsensitiveCompare(nodeName) == .orderedSame + && ($0.type == .repeater || $0.type == .room) + }) else { + appendOutput("\(L10n.Tools.Tools.Cli.nodeNotFound) \(nodeName)", type: .error) + return + } + contact = found + } catch { + Self.logger.error("Failed to fetch contacts: \(error)") + appendOutput("\(L10n.Tools.Tools.Cli.nodeNotFound) \(nodeName)", type: .error) + return + } - guard !nodeName.isEmpty else { - appendOutput(L10n.Tools.Tools.Cli.loginUsage, type: .error) - return - } + // Clear saved password if --forget flag was used + if forgetPassword { + try? await remoteNodeService.deletePassword(forContact: contact) + } - guard let dataStore, let radioID, let remoteNodeService else { - appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) - return - } + // Check for stored password (will be nil if --forget was used) + if let storedPassword = await remoteNodeService.retrievePassword(forContact: contact) { + await completeLogin(contact: contact, password: storedPassword) + } else { + // Prompt for password + pendingLoginContact = contact + } + } - // Find contact by name (repeaters and room servers only) - let contact: ContactDTO - do { - let contacts = try await dataStore.fetchContacts(radioID: radioID) - guard let found = contacts.first(where: { - $0.name.localizedCaseInsensitiveCompare(nodeName) == .orderedSame - && ($0.type == .repeater || $0.type == .room) - }) else { - appendOutput("\(L10n.Tools.Tools.Cli.nodeNotFound) \(nodeName)", type: .error) - return - } - contact = found - } catch { - Self.logger.error("Failed to fetch contacts: \(error)") - appendOutput("\(L10n.Tools.Tools.Cli.nodeNotFound) \(nodeName)", type: .error) - return - } + func completeLogin(contact: ContactDTO, password: String) async { + guard let radioID, let remoteNodeService else { + appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) + return + } - // Clear saved password if --forget flag was used - if forgetPassword { - try? await remoteNodeService.deletePassword(forContact: contact) - } + defer { stopCountdown() } + + do { + // Create or reuse session + let remoteSession = try await remoteNodeService.createSession( + radioID: radioID, + contact: contact + ) - // Check for stored password (will be nil if --forget was used) - if let storedPassword = await remoteNodeService.retrievePassword(forContact: contact) { - await completeLogin(contact: contact, password: storedPassword) - } else { - // Prompt for password - pendingLoginContact = contact + guard !Task.isCancelled else { return } + + // Login with countdown + let loginResult = try await remoteNodeService.login( + sessionID: remoteSession.id, + password: password, + pathLength: contact.outPathLength, + onTimeoutKnown: { [weak self] seconds in + await self?.startCountdown(seconds) } + ) + + guard !Task.isCancelled else { return } + + guard loginResult.success else { + appendOutput( + "\(L10n.Tools.Tools.Cli.loginFailed) \(L10n.Tools.Tools.Cli.loginFailedAuth)", + type: .error + ) + return + } + + // Store password after successful login + try? await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) + + // Success - create CLI session + let cliSession = CLISession.remote( + id: remoteSession.id, + name: contact.name, + pathLength: contact.outPathLength + ) + remoteSessions.append(cliSession) + activeSession = cliSession + appendOutput("\(L10n.Tools.Tools.Cli.loginSuccess) @\(contact.name)", type: .success) + + } catch let error as RemoteNodeError { + switch error { + case .passwordNotFound: + appendOutput(L10n.Tools.Tools.Cli.passwordRequired, type: .error) + case .timeout: + appendOutput(L10n.Tools.Tools.Cli.timeout, type: .error) + case let .loginFailed(reason): + appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(reason)", type: .error) + case .cancelled: + appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) + default: + appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(error.localizedDescription)", type: .error) + } + } catch is CancellationError { + // Already handled by cancelCurrentCommand + } catch { + appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(error.localizedDescription)", type: .error) } + } - func completeLogin(contact: ContactDTO, password: String) async { - guard let radioID, let remoteNodeService else { - appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) - return - } + func handleLogout() async { + guard let session = activeSession, !session.isLocal else { + appendOutput(L10n.Tools.Tools.Cli.notLoggedIn, type: .error) + return + } - defer { stopCountdown() } - - do { - // Create or reuse session - let remoteSession = try await remoteNodeService.createSession( - radioID: radioID, - contact: contact - ) - - guard !Task.isCancelled else { return } - - // Login with countdown - let loginResult = try await remoteNodeService.login( - sessionID: remoteSession.id, - password: password, - pathLength: contact.outPathLength, - onTimeoutKnown: { [weak self] seconds in - await self?.startCountdown(seconds) - } - ) - - guard !Task.isCancelled else { return } - - guard loginResult.success else { - appendOutput( - "\(L10n.Tools.Tools.Cli.loginFailed) \(L10n.Tools.Tools.Cli.loginFailedAuth)", - type: .error - ) - return - } - - // Store password after successful login - try? await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) - - // Success - create CLI session - let cliSession = CLISession.remote( - id: remoteSession.id, - name: contact.name, - pathLength: contact.outPathLength - ) - remoteSessions.append(cliSession) - activeSession = cliSession - appendOutput("\(L10n.Tools.Tools.Cli.loginSuccess) @\(contact.name)", type: .success) - - } catch let error as RemoteNodeError { - switch error { - case .passwordNotFound: - appendOutput(L10n.Tools.Tools.Cli.passwordRequired, type: .error) - case .timeout: - appendOutput(L10n.Tools.Tools.Cli.timeout, type: .error) - case .loginFailed(let reason): - appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(reason)", type: .error) - case .cancelled: - appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) - default: - appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(error.localizedDescription)", type: .error) - } - } catch is CancellationError { - // Already handled by cancelCurrentCommand - } catch { - appendOutput("\(L10n.Tools.Tools.Cli.loginFailed) \(error.localizedDescription)", type: .error) - } + // Logout via RemoteNodeService (errors ignored per protocol design) + if let remoteNodeService { + try? await remoteNodeService.logout(sessionID: session.id) } - func handleLogout() async { - guard let session = activeSession, !session.isLocal else { - appendOutput(L10n.Tools.Tools.Cli.notLoggedIn, type: .error) - return - } + remoteSessions.removeAll { $0.id == session.id } + activeSession = .local(deviceName: localDeviceName) + appendOutput(L10n.Tools.Tools.Cli.logoutSuccess, type: .success) + } - // Logout via RemoteNodeService (errors ignored per protocol design) - if let remoteNodeService { - try? await remoteNodeService.logout(sessionID: session.id) - } + // MARK: - Remote Commands - remoteSessions.removeAll { $0.id == session.id } - activeSession = .local(deviceName: localDeviceName) - appendOutput(L10n.Tools.Tools.Cli.logoutSuccess, type: .success) + func sendRemoteCommand(_ command: String) async { + guard let session = activeSession, + !session.isLocal, + let service = repeaterAdminService else { + appendOutput(L10n.Tools.Tools.Cli.notLoggedIn, type: .error) + return } - // MARK: - Remote Commands + // Reboot won't return a response - treat timeout as success + if command.lowercased().hasPrefix("reboot") { + do { + _ = try await service.sendRawCommand( + sessionID: session.id, + command: command, + timeout: .seconds(2) + ) + appendOutput(L10n.Tools.Tools.Cli.rebootSent, type: .success) + } catch RemoteNodeError.timeout { + appendOutput(L10n.Tools.Tools.Cli.rebootSent, type: .success) + } catch { + appendOutput(error.localizedDescription, type: .error) + } + return + } - func sendRemoteCommand(_ command: String) async { - guard let session = activeSession, - !session.isLocal, - let service = repeaterAdminService else { - appendOutput(L10n.Tools.Tools.Cli.notLoggedIn, type: .error) - return - } + do { + let response = try await service.sendRawCommand(sessionID: session.id, command: command) + + guard !Task.isCancelled else { return } + + appendOutput(response, type: .response) + } catch is CancellationError { + // Already handled by cancelCurrentCommand + } catch let error as RemoteNodeError { + if case .timeout = error { + appendOutput(L10n.Tools.Tools.Cli.commandTimeout, type: .error) + } else { + appendOutput("\(error.localizedDescription)", type: .error) + } + } catch { + appendOutput("\(error.localizedDescription)", type: .error) + } + } - // Reboot won't return a response - treat timeout as success - if command.lowercased().hasPrefix("reboot") { - do { - _ = try await service.sendRawCommand( - sessionID: session.id, - command: command, - timeout: .seconds(2) - ) - appendOutput(L10n.Tools.Tools.Cli.rebootSent, type: .success) - } catch RemoteNodeError.timeout { - appendOutput(L10n.Tools.Tools.Cli.rebootSent, type: .success) - } catch { - appendOutput(error.localizedDescription, type: .error) - } - return - } + // MARK: - Local Command Handlers - do { - let response = try await service.sendRawCommand(sessionID: session.id, command: command) - - guard !Task.isCancelled else { return } - - appendOutput(response, type: .response) - } catch is CancellationError { - // Already handled by cancelCurrentCommand - } catch let error as RemoteNodeError { - if case .timeout = error { - appendOutput(L10n.Tools.Tools.Cli.commandTimeout, type: .error) - } else { - appendOutput("\(error.localizedDescription)", type: .error) - } - } catch { - appendOutput("\(error.localizedDescription)", type: .error) - } + func handleNodesCommand() async { + guard let dataStore, let radioID else { + appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) + return } - // MARK: - Local Command Handlers - - func handleNodesCommand() async { - guard let dataStore, let radioID else { - appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) - return - } + let contacts: [ContactDTO] + do { + contacts = try await dataStore.fetchContacts(radioID: radioID) + } catch { + Self.logger.error("Failed to fetch contacts: \(error)") + appendOutput(L10n.Tools.Tools.Cli.noNodes, type: .response) + return + } - let contacts: [ContactDTO] - do { - contacts = try await dataStore.fetchContacts(radioID: radioID) - } catch { - Self.logger.error("Failed to fetch contacts: \(error)") - appendOutput(L10n.Tools.Tools.Cli.noNodes, type: .response) - return + let nodes = contacts + .filter { $0.type == .repeater || $0.type == .room } + .sorted { lhs, rhs in + if lhs.type != rhs.type { + return lhs.type == .repeater } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } - let nodes = contacts - .filter { $0.type == .repeater || $0.type == .room } - .sorted { lhs, rhs in - if lhs.type != rhs.type { - return lhs.type == .repeater - } - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - - guard !nodes.isEmpty else { - appendOutput(L10n.Tools.Tools.Cli.noNodes, type: .response) - return - } + guard !nodes.isEmpty else { + appendOutput(L10n.Tools.Tools.Cli.noNodes, type: .response) + return + } - appendOutput(L10n.Tools.Tools.Cli.nodesHeader(nodes.count), type: .response) - for node in nodes { - let typeLabel = node.type == .repeater - ? L10n.Contacts.Contacts.NodeKind.repeater - : L10n.Contacts.Contacts.NodeKind.room - let route = formatRoute(node) - let padded = node.name.padding(toLength: 20, withPad: " ", startingAt: 0) - let label = typeLabel.padding(toLength: 10, withPad: " ", startingAt: 0) - appendOutput(" \(padded) \(label)\(route)", type: .response) - } + appendOutput(L10n.Tools.Tools.Cli.nodesHeader(nodes.count), type: .response) + for node in nodes { + let typeLabel = node.type == .repeater + ? L10n.Contacts.Contacts.NodeKind.repeater + : L10n.Contacts.Contacts.NodeKind.room + let route = formatRoute(node) + let padded = node.name.padding(toLength: 20, withPad: " ", startingAt: 0) + let label = typeLabel.padding(toLength: 10, withPad: " ", startingAt: 0) + appendOutput(" \(padded) \(label)\(route)", type: .response) } + } - func handleChannelsCommand() async { - guard let dataStore, let radioID else { - appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) - return - } + func handleChannelsCommand() async { + guard let dataStore, let radioID else { + appendOutput(L10n.Tools.Tools.Cli.notConnected, type: .error) + return + } - let channels: [ChannelDTO] - do { - channels = try await dataStore.fetchChannels(radioID: radioID) - } catch { - Self.logger.error("Failed to fetch channels: \(error)") - appendOutput(L10n.Tools.Tools.Cli.noChannels, type: .response) - return - } + let channels: [ChannelDTO] + do { + channels = try await dataStore.fetchChannels(radioID: radioID) + } catch { + Self.logger.error("Failed to fetch channels: \(error)") + appendOutput(L10n.Tools.Tools.Cli.noChannels, type: .response) + return + } - guard !channels.isEmpty else { - appendOutput(L10n.Tools.Tools.Cli.noChannels, type: .response) - return - } + guard !channels.isEmpty else { + appendOutput(L10n.Tools.Tools.Cli.noChannels, type: .response) + return + } - let sorted = channels.sorted { $0.index < $1.index } + let sorted = channels.sorted { $0.index < $1.index } - appendOutput(L10n.Tools.Tools.Cli.channelsHeader(sorted.count), type: .response) - for channel in sorted { - let name = channel.name.isEmpty ? "(\(L10n.Tools.Tools.Cli.channelEmpty))" : channel.name - appendOutput(" [\(channel.index)] \(name)", type: .response) - } + appendOutput(L10n.Tools.Tools.Cli.channelsHeader(sorted.count), type: .response) + for channel in sorted { + let name = channel.name.isEmpty ? "(\(L10n.Tools.Tools.Cli.channelEmpty))" : channel.name + appendOutput(" [\(channel.index)] \(name)", type: .response) } - - private func formatRoute(_ contact: ContactDTO) -> String { - if contact.isFloodRouted { - return L10n.Contacts.Contacts.Route.flood - } else if contact.pathHopCount == 0 { - return L10n.Contacts.Contacts.Route.direct - } else { - return L10n.Contacts.Contacts.Route.hops(contact.pathHopCount) - } + } + + private func formatRoute(_ contact: ContactDTO) -> String { + if contact.isFloodRouted { + L10n.Contacts.Contacts.Route.flood + } else if contact.pathHopCount == 0 { + L10n.Contacts.Contacts.Route.direct + } else { + L10n.Contacts.Contacts.Route.hops(contact.pathHopCount) } + } } diff --git a/MC1/Views/Tools/CLI/CLIToolViewModel.swift b/MC1/Views/Tools/CLI/CLIToolViewModel.swift index 6af543be..1646ecf3 100644 --- a/MC1/Views/Tools/CLI/CLIToolViewModel.swift +++ b/MC1/Views/Tools/CLI/CLIToolViewModel.swift @@ -1,413 +1,424 @@ import Foundation -import OSLog import MC1Services +import OSLog import UIKit @Observable @MainActor final class CLIToolViewModel { - private static let maxOutputLines = 1000 - private static let maxHistoryEntries = 100 - static let logger = Logger(subsystem: "com.mc1", category: "CLIToolViewModel") - - // MARK: - State + private static let maxOutputLines = 1000 + private static let maxHistoryEntries = 100 + static let logger = Logger(subsystem: "com.mc1", category: "CLIToolViewModel") - private(set) var outputLines: [CLIOutputLine] = [] - private(set) var commandHistory: [String] = [] - private(set) var historyIndex: Int? - var activeSession: CLISession? - var remoteSessions: [CLISession] = [] - var isWaitingForResponse = false - private var hasShownWelcome = false + // MARK: - State - var currentInput: String = "" - private var countdownTask: Task? - var remainingSeconds: Int? - var pendingLoginContact: ContactDTO? + private(set) var outputLines: [CLIOutputLine] = [] + private(set) var commandHistory: [String] = [] + private(set) var historyIndex: Int? + var activeSession: CLISession? + var remoteSessions: [CLISession] = [] + var isWaitingForResponse = false + private var hasShownWelcome = false - // MARK: - Completion State + var currentInput: String = "" + private var countdownTask: Task? + var remainingSeconds: Int? + var pendingLoginContact: ContactDTO? - let completionEngine = CLICompletionEngine() - var ghostText: String = "" - private var nodeNamesTask: Task? + // MARK: - Completion State - /// Current tab completion suggestions (nil when hidden) - var tabSuggestions: [String]? + let completionEngine = CLICompletionEngine() + var ghostText: String = "" + private var nodeNamesTask: Task? - /// Selected index within suggestions (nil = not in selection mode) - var tabSelectionIndex: Int? + /// Current tab completion suggestions (nil when hidden) + var tabSuggestions: [String]? - // MARK: - Task Management + /// Selected index within suggestions (nil = not in selection mode) + var tabSelectionIndex: Int? - private var currentCommandTask: Task? + // MARK: - Task Management - // MARK: - Dependencies + private var currentCommandTask: Task? - private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } - private var remoteNodeServiceProvider: @MainActor () -> RemoteNodeService? = { nil } - private var dataStoreProvider: @MainActor () -> PersistenceStoreProtocol? = { nil } - private var radioIDProvider: @MainActor () -> UUID? = { nil } + // MARK: - Dependencies - // Both old and new providers read the same live state, so change detection - // needs the instance seen at the previous configure. Weak, so a torn-down - // container's deallocated service reads as a change. - private weak var lastConfiguredAdminService: RepeaterAdminService? + private var repeaterAdminServiceProvider: @MainActor () -> RepeaterAdminService? = { nil } + private var remoteNodeServiceProvider: @MainActor () -> RemoteNodeService? = { nil } + private var dataStoreProvider: @MainActor () -> PersistenceStoreProtocol? = { nil } + private var radioIDProvider: @MainActor () -> UUID? = { nil } - var repeaterAdminService: RepeaterAdminService? { repeaterAdminServiceProvider() } - var remoteNodeService: RemoteNodeService? { remoteNodeServiceProvider() } - var dataStore: PersistenceStoreProtocol? { dataStoreProvider() } - var radioID: UUID? { radioIDProvider() } + /// Both old and new providers read the same live state, so change detection + /// needs the instance seen at the previous configure. Weak, so a torn-down + /// container's deallocated service reads as a change. + private weak var lastConfiguredAdminService: RepeaterAdminService? - var localDeviceName: String = "" + var repeaterAdminService: RepeaterAdminService? { + repeaterAdminServiceProvider() + } - // MARK: - Prompt + var remoteNodeService: RemoteNodeService? { + remoteNodeServiceProvider() + } - var promptText: String { - if let seconds = remainingSeconds { - return L10n.Tools.Tools.Cli.loggingIn(seconds) - } + var dataStore: PersistenceStoreProtocol? { + dataStoreProvider() + } - if isWaitingForResponse { - return "" - } + var radioID: UUID? { + radioIDProvider() + } - if pendingLoginContact != nil { - return "\(L10n.Tools.Tools.Cli.passwordPrompt) " - } + var localDeviceName: String = "" - guard let session = activeSession else { - return "\(L10n.Tools.Tools.Cli.disconnected)\(L10n.Tools.Tools.Cli.promptSuffix) " - } + // MARK: - Prompt - if session.isLocal { - return "\(session.name)\(L10n.Tools.Tools.Cli.promptSuffix) " - } else { - return "@\(session.name)\(L10n.Tools.Tools.Cli.promptSuffix) " - } + var promptText: String { + if let seconds = remainingSeconds { + return L10n.Tools.Tools.Cli.loggingIn(seconds) } - // MARK: - Setup - - func configure( - repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, - remoteNodeService: @escaping @MainActor () -> RemoteNodeService?, - dataStore: @escaping @MainActor () -> PersistenceStoreProtocol?, - radioID: @escaping @MainActor () -> UUID?, - localDeviceName: String - ) { - self.localDeviceName = localDeviceName - remoteNodeServiceProvider = remoteNodeService - dataStoreProvider = dataStore - radioIDProvider = radioID - - let incomingService = repeaterAdminService() - repeaterAdminServiceProvider = repeaterAdminService - - if lastConfiguredAdminService !== incomingService { - if incomingService != nil && activeSession == nil { - activeSession = .local(deviceName: localDeviceName) - showWelcomeBanner() - } else if incomingService == nil { - activeSession = nil - remoteSessions.removeAll() - } - } - lastConfiguredAdminService = incomingService - - // Update node names for completion - nodeNamesTask?.cancel() - nodeNamesTask = Task { - await updateNodeNamesForCompletion() - } + if isWaitingForResponse { + return "" } - func cleanup() { - currentCommandTask?.cancel() - currentCommandTask = nil - nodeNamesTask?.cancel() - nodeNamesTask = nil - stopCountdown() + if pendingLoginContact != nil { + return "\(L10n.Tools.Tools.Cli.passwordPrompt) " } - /// Resets state for new connection while preserving command history - func reset() { - cleanup() - outputLines = [] - currentInput = "" - ghostText = "" - activeSession = nil - remoteSessions = [] - isWaitingForResponse = false - pendingLoginContact = nil - hasShownWelcome = false - clearTabState() + guard let session = activeSession else { + return "\(L10n.Tools.Tools.Cli.disconnected)\(L10n.Tools.Tools.Cli.promptSuffix) " } - func stopCountdown() { - countdownTask?.cancel() - countdownTask = nil - remainingSeconds = nil + if session.isLocal { + return "\(session.name)\(L10n.Tools.Tools.Cli.promptSuffix) " + } else { + return "@\(session.name)\(L10n.Tools.Tools.Cli.promptSuffix) " } - - func startCountdown(_ seconds: Int) { - remainingSeconds = seconds - countdownTask = Task { - var remaining = seconds - while remaining > 0 && !Task.isCancelled { - try? await Task.sleep(for: .seconds(1)) - if !Task.isCancelled { - remaining -= 1 - remainingSeconds = remaining - } - } - } + } + + // MARK: - Setup + + func configure( + repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, + remoteNodeService: @escaping @MainActor () -> RemoteNodeService?, + dataStore: @escaping @MainActor () -> PersistenceStoreProtocol?, + radioID: @escaping @MainActor () -> UUID?, + localDeviceName: String + ) { + self.localDeviceName = localDeviceName + remoteNodeServiceProvider = remoteNodeService + dataStoreProvider = dataStore + radioIDProvider = radioID + + let incomingService = repeaterAdminService() + repeaterAdminServiceProvider = repeaterAdminService + + if lastConfiguredAdminService !== incomingService { + if incomingService != nil, activeSession == nil { + activeSession = .local(deviceName: localDeviceName) + showWelcomeBanner() + } else if incomingService == nil { + activeSession = nil + remoteSessions.removeAll() + } } + lastConfiguredAdminService = incomingService - private func showWelcomeBanner() { - guard !hasShownWelcome else { return } - hasShownWelcome = true - - appendOutput(L10n.Tools.Tools.Cli.welcomeLine1, type: .response) - appendOutput(L10n.Tools.Tools.Cli.welcomeConnected(localDeviceName), type: .response) - appendOutput(L10n.Tools.Tools.Cli.welcomeHint, type: .response) - appendOutput("", type: .response) + // Update node names for completion + nodeNamesTask?.cancel() + nodeNamesTask = Task { + await updateNodeNamesForCompletion() } - - // MARK: - Command Execution - - func executeCommand(_ command: String) { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !isWaitingForResponse else { return } - let promptPrefix = promptText.trimmingCharacters(in: .whitespaces) - - // Handle password input for pending login - if let contact = pendingLoginContact { - // Echo masked password - appendOutput("\(promptPrefix) ****", type: .command) - pendingLoginContact = nil - currentInput = "" - - // Empty password cancels login - guard !trimmed.isEmpty else { - appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) - return - } - - // Claim the busy state synchronously so a second submit can't pass - // the guard before the spawned task starts running. - isWaitingForResponse = true - var task: Task! - task = Task { - defer { clearWaitingIfCurrent(task) } - await completeLogin(contact: contact, password: trimmed) - } - currentCommandTask = task - return - } - - // Echo prompt when empty input is submitted, matching terminal behavior - guard !trimmed.isEmpty else { - appendOutput(promptPrefix, type: .command) - return - } - - addToHistory(trimmed) - appendOutput("\(promptPrefix) \(trimmed)", type: .command) - - // Parse and execute - let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) - let cmd = parts[0].lowercased() - let args = parts.count > 1 ? parts[1] : "" - - // Claim the busy state synchronously so a second submit can't pass the - // guard before the spawned task starts running. - isWaitingForResponse = true - var task: Task! - task = Task { - defer { clearWaitingIfCurrent(task) } - await handleCommand(cmd, args: args, raw: trimmed) + } + + func cleanup() { + currentCommandTask?.cancel() + currentCommandTask = nil + nodeNamesTask?.cancel() + nodeNamesTask = nil + stopCountdown() + } + + /// Resets state for new connection while preserving command history + func reset() { + cleanup() + outputLines = [] + currentInput = "" + ghostText = "" + activeSession = nil + remoteSessions = [] + isWaitingForResponse = false + pendingLoginContact = nil + hasShownWelcome = false + clearTabState() + } + + func stopCountdown() { + countdownTask?.cancel() + countdownTask = nil + remainingSeconds = nil + } + + func startCountdown(_ seconds: Int) { + remainingSeconds = seconds + countdownTask = Task { + var remaining = seconds + while remaining > 0, !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + if !Task.isCancelled { + remaining -= 1 + remainingSeconds = remaining } - currentCommandTask = task - - currentInput = "" + } } - - func cancelCurrentCommand() { - currentCommandTask?.cancel() - currentCommandTask = nil - if isWaitingForResponse { - isWaitingForResponse = false - appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) - } + } + + private func showWelcomeBanner() { + guard !hasShownWelcome else { return } + hasShownWelcome = true + + appendOutput(L10n.Tools.Tools.Cli.welcomeLine1, type: .response) + appendOutput(L10n.Tools.Tools.Cli.welcomeConnected(localDeviceName), type: .response) + appendOutput(L10n.Tools.Tools.Cli.welcomeHint, type: .response) + appendOutput("", type: .response) + } + + // MARK: - Command Execution + + func executeCommand(_ command: String) { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isWaitingForResponse else { return } + let promptPrefix = promptText.trimmingCharacters(in: .whitespaces) + + // Handle password input for pending login + if let contact = pendingLoginContact { + // Echo masked password + appendOutput("\(promptPrefix) ****", type: .command) + pendingLoginContact = nil + currentInput = "" + + // Empty password cancels login + guard !trimmed.isEmpty else { + appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) + return + } + + // Claim the busy state synchronously so a second submit can't pass + // the guard before the spawned task starts running. + isWaitingForResponse = true + var task: Task! + task = Task { + defer { clearWaitingIfCurrent(task) } + await completeLogin(contact: contact, password: trimmed) + } + currentCommandTask = task + return } - private func clearWaitingIfCurrent(_ task: Task) { - // A cancelled older command can resume after a newer one claimed the - // busy flag; only the task still owning currentCommandTask may clear it. - guard currentCommandTask == task else { return } - currentCommandTask = nil - isWaitingForResponse = false + // Echo prompt when empty input is submitted, matching terminal behavior + guard !trimmed.isEmpty else { + appendOutput(promptPrefix, type: .command) + return } - private func addToHistory(_ command: String) { - commandHistory.append(command) - if commandHistory.count > Self.maxHistoryEntries { - commandHistory.removeFirst() - } - historyIndex = nil + addToHistory(trimmed) + appendOutput("\(promptPrefix) \(trimmed)", type: .command) + + // Parse and execute + let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) + let cmd = parts[0].lowercased() + let args = parts.count > 1 ? parts[1] : "" + + // Claim the busy state synchronously so a second submit can't pass the + // guard before the spawned task starts running. + isWaitingForResponse = true + var task: Task! + task = Task { + defer { clearWaitingIfCurrent(task) } + await handleCommand(cmd, args: args, raw: trimmed) } + currentCommandTask = task - private func handleCommand(_ cmd: String, args: String, raw: String) async { - // Handle "s1", "s2", etc. as shorthand for "session 1", "session 2" - if let number = parseSessionShortcut(cmd) { - handleSessionCommand(String(number)) - return - } + currentInput = "" + } - switch cmd { - case "help": showHelp() - case "clear" where activeSession?.isLocal == true || args.isEmpty: clearOutput() - case "session": handleSessionCommand(args) - case "login": await handleLogin(args) - case "logout": await handleLogout() - case "nodes" where activeSession?.isLocal == true: await handleNodesCommand() - case "channels" where activeSession?.isLocal == true: await handleChannelsCommand() - default: await handleUnknownCommand(cmd, raw: raw) - } + func cancelCurrentCommand() { + currentCommandTask?.cancel() + currentCommandTask = nil + if isWaitingForResponse { + isWaitingForResponse = false + appendOutput(L10n.Tools.Tools.Cli.cancelled, type: .error) } - - private func parseSessionShortcut(_ cmd: String) -> Int? { - guard cmd.hasPrefix("s"), cmd.count > 1 else { return nil } - return Int(cmd.dropFirst()) + } + + private func clearWaitingIfCurrent(_ task: Task) { + // A cancelled older command can resume after a newer one claimed the + // busy flag; only the task still owning currentCommandTask may clear it. + guard currentCommandTask == task else { return } + currentCommandTask = nil + isWaitingForResponse = false + } + + private func addToHistory(_ command: String) { + commandHistory.append(command) + if commandHistory.count > Self.maxHistoryEntries { + commandHistory.removeFirst() } - - private func handleUnknownCommand(_ cmd: String, raw: String) async { - if activeSession?.isLocal == true { - appendOutput("\(L10n.Tools.Tools.Cli.unknownCommand) \(cmd)", type: .error) - } else if activeSession != nil { - await sendRemoteCommand(raw) - } else { - appendOutput("\(L10n.Tools.Tools.Cli.unknownCommand) \(cmd)", type: .error) - } + historyIndex = nil + } + + private func handleCommand(_ cmd: String, args: String, raw: String) async { + // Handle "s1", "s2", etc. as shorthand for "session 1", "session 2" + if let number = parseSessionShortcut(cmd) { + handleSessionCommand(String(number)) + return } - // MARK: - Built-in Commands - - private func showHelp() { - appendOutput(L10n.Tools.Tools.Cli.helpHeader, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpLogin, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpLogout, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpSessionList, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpSessionLocal, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpSessionName, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpSessionShortcut, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpClear, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpHelp, type: .response) - - if activeSession?.isLocal == true { - appendOutput(L10n.Tools.Tools.Cli.helpNodes, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpChannels, type: .response) - } else if activeSession != nil { - appendOutput("", type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpRepeaterHeader, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList1, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList2, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList3, type: .response) - appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList4, type: .response) - } + switch cmd { + case "help": showHelp() + case "clear" where activeSession?.isLocal == true || args.isEmpty: clearOutput() + case "session": handleSessionCommand(args) + case "login": await handleLogin(args) + case "logout": await handleLogout() + case "nodes" where activeSession?.isLocal == true: await handleNodesCommand() + case "channels" where activeSession?.isLocal == true: await handleChannelsCommand() + default: await handleUnknownCommand(cmd, raw: raw) } - - private func clearOutput() { - outputLines.removeAll() + } + + private func parseSessionShortcut(_ cmd: String) -> Int? { + guard cmd.hasPrefix("s"), cmd.count > 1 else { return nil } + return Int(cmd.dropFirst()) + } + + private func handleUnknownCommand(_ cmd: String, raw: String) async { + if activeSession?.isLocal == true { + appendOutput("\(L10n.Tools.Tools.Cli.unknownCommand) \(cmd)", type: .error) + } else if activeSession != nil { + await sendRemoteCommand(raw) + } else { + appendOutput("\(L10n.Tools.Tools.Cli.unknownCommand) \(cmd)", type: .error) + } + } + + // MARK: - Built-in Commands + + private func showHelp() { + appendOutput(L10n.Tools.Tools.Cli.helpHeader, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpLogin, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpLogout, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpSessionList, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpSessionLocal, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpSessionName, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpSessionShortcut, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpClear, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpHelp, type: .response) + + if activeSession?.isLocal == true { + appendOutput(L10n.Tools.Tools.Cli.helpNodes, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpChannels, type: .response) + } else if activeSession != nil { + appendOutput("", type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpRepeaterHeader, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList1, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList2, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList3, type: .response) + appendOutput(L10n.Tools.Tools.Cli.helpRepeaterList4, type: .response) } + } - // MARK: - History Navigation + private func clearOutput() { + outputLines.removeAll() + } - func historyUp() { - guard !commandHistory.isEmpty else { return } + // MARK: - History Navigation - if let index = historyIndex { - if index > 0 { - historyIndex = index - 1 - } - } else { - historyIndex = commandHistory.count - 1 - } + func historyUp() { + guard !commandHistory.isEmpty else { return } - if let index = historyIndex { - currentInput = commandHistory[index] - } + if let index = historyIndex { + if index > 0 { + historyIndex = index - 1 + } + } else { + historyIndex = commandHistory.count - 1 } - func historyDown() { - guard let index = historyIndex else { return } - - if index < commandHistory.count - 1 { - historyIndex = index + 1 - currentInput = commandHistory[index + 1] - } else { - historyIndex = nil - currentInput = "" - } + if let index = historyIndex { + currentInput = commandHistory[index] } + } - // MARK: - Output Management - - func appendOutput(_ text: String, type: CLIOutputType) { - let line = CLIOutputLine(text: text, type: type) - outputLines.append(line) + func historyDown() { + guard let index = historyIndex else { return } - if outputLines.count > Self.maxOutputLines { - outputLines.removeFirst() - } + if index < commandHistory.count - 1 { + historyIndex = index + 1 + currentInput = commandHistory[index + 1] + } else { + historyIndex = nil + currentInput = "" } + } - // MARK: - Clipboard + // MARK: - Output Management - /// Returns the full response block containing the given line. - /// A response block is all consecutive non-command lines. - func getResponseBlock(containing line: CLIOutputLine) -> String { - guard let index = outputLines.firstIndex(where: { $0.id == line.id }) else { - return line.text - } + func appendOutput(_ text: String, type: CLIOutputType) { + let line = CLIOutputLine(text: text, type: type) + outputLines.append(line) - // Command lines: strip prompt prefix (e.g., "local > " or "@node > ") - if line.type == .command { - if let range = line.text.range(of: "> ") { - return String(line.text[range.upperBound...]) - } - return line.text - } + if outputLines.count > Self.maxOutputLines { + outputLines.removeFirst() + } + } - // Find start of block (walk back until we hit a .command or start) - var startIndex = index - while startIndex > 0 && outputLines[startIndex - 1].type != .command { - startIndex -= 1 - } + // MARK: - Clipboard - // Find end of block (walk forward until we hit a .command or end) - var endIndex = index - while endIndex < outputLines.count - 1 && outputLines[endIndex + 1].type != .command { - endIndex += 1 - } + /// Returns the full response block containing the given line. + /// A response block is all consecutive non-command lines. + func getResponseBlock(containing line: CLIOutputLine) -> String { + guard let index = outputLines.firstIndex(where: { $0.id == line.id }) else { + return line.text + } - return outputLines[startIndex...endIndex] - .map { line in - // Strip "> " prefix from MeshCore CLI responses - if line.text.hasPrefix("> ") { - return String(line.text.dropFirst(2)) - } - return line.text - } - .joined(separator: "\n") + // Command lines: strip prompt prefix (e.g., "local > " or "@node > ") + if line.type == .command { + if let range = line.text.range(of: "> ") { + return String(line.text[range.upperBound...]) + } + return line.text } - /// Inserts clipboard text at the given cursor position. - func pasteFromClipboard(at cursorPosition: Int) { - guard let text = UIPasteboard.general.string else { return } + // Find start of block (walk back until we hit a .command or start) + var startIndex = index + while startIndex > 0, outputLines[startIndex - 1].type != .command { + startIndex -= 1 + } - let safePosition = min(cursorPosition, currentInput.count) - let index = currentInput.index(currentInput.startIndex, offsetBy: safePosition) - currentInput.insert(contentsOf: text, at: index) + // Find end of block (walk forward until we hit a .command or end) + var endIndex = index + while endIndex < outputLines.count - 1, outputLines[endIndex + 1].type != .command { + endIndex += 1 } + + return outputLines[startIndex...endIndex] + .map { line in + // Strip "> " prefix from MeshCore CLI responses + if line.text.hasPrefix("> ") { + return String(line.text.dropFirst(2)) + } + return line.text + } + .joined(separator: "\n") + } + + /// Inserts clipboard text at the given cursor position. + func pasteFromClipboard(at cursorPosition: Int) { + guard let text = UIPasteboard.general.string else { return } + + let safePosition = min(cursorPosition, currentInput.count) + let index = currentInput.index(currentInput.startIndex, offsetBy: safePosition) + currentInput.insert(contentsOf: text, at: index) + } } diff --git a/MC1/Views/Tools/CLI/HiddenTextView.swift b/MC1/Views/Tools/CLI/HiddenTextView.swift index 3c30bd16..370d9aa4 100644 --- a/MC1/Views/Tools/CLI/HiddenTextView.swift +++ b/MC1/Views/Tools/CLI/HiddenTextView.swift @@ -4,267 +4,266 @@ import UIKit /// Invisible UITextView that captures keyboard input while allowing visual rendering elsewhere. /// Tracks cursor position and supports cursor movement via callbacks. struct HiddenTextViewFocusable: UIViewRepresentable { - @Binding var text: String - @Binding var isFocused: Bool - @Binding var cursorPosition: Int - var onSubmit: () -> Void - var onHistoryUp: () -> Void - var onHistoryDown: () -> Void - var onRightArrowAtEnd: () -> Void - var onTabComplete: () -> Void - - func makeUIView(context: Context) -> FocusableTextView { - let textView = FocusableTextView() - textView.customDelegate = context.coordinator - textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) - textView.autocorrectionType = .no - textView.autocapitalizationType = .none - textView.spellCheckingType = .no - textView.returnKeyType = .default - - // Make invisible but still interactive - textView.backgroundColor = .clear - textView.textColor = .clear - textView.tintColor = .clear - - return textView + @Binding var text: String + @Binding var isFocused: Bool + @Binding var cursorPosition: Int + var onSubmit: () -> Void + var onHistoryUp: () -> Void + var onHistoryDown: () -> Void + var onRightArrowAtEnd: () -> Void + var onTabComplete: () -> Void + + func makeUIView(context: Context) -> FocusableTextView { + let textView = FocusableTextView() + textView.customDelegate = context.coordinator + textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + textView.autocorrectionType = .no + textView.autocapitalizationType = .none + textView.spellCheckingType = .no + textView.returnKeyType = .default + + // Make invisible but still interactive + textView.backgroundColor = .clear + textView.textColor = .clear + textView.tintColor = .clear + + return textView + } + + func updateUIView(_ textView: FocusableTextView, context: Context) { + // Update text if changed externally + if textView.text != text { + textView.text = text } - func updateUIView(_ textView: FocusableTextView, context: Context) { - // Update text if changed externally - if textView.text != text { - textView.text = text - } - - // Sync cursor position from SwiftUI to UITextView - let clampedPosition = min(cursorPosition, textView.text.count) - if let startPosition = textView.position(from: textView.beginningOfDocument, offset: clampedPosition) { - let currentOffset = textView.selectedTextRange.map { - textView.offset(from: textView.beginningOfDocument, to: $0.start) - } ?? 0 + // Sync cursor position from SwiftUI to UITextView + let clampedPosition = min(cursorPosition, textView.text.count) + if let startPosition = textView.position(from: textView.beginningOfDocument, offset: clampedPosition) { + let currentOffset = textView.selectedTextRange.map { + textView.offset(from: textView.beginningOfDocument, to: $0.start) + } ?? 0 - if currentOffset != clampedPosition { - textView.selectedTextRange = textView.textRange(from: startPosition, to: startPosition) - } - } + if currentOffset != clampedPosition { + textView.selectedTextRange = textView.textRange(from: startPosition, to: startPosition) + } + } - // Manage focus. The accessory bar is gated on `isFocused`, which the - // delegate clears only via `textViewDidEndEditing`. That never fires when - // the responder request can't be honored (view not yet in a window), so - // reconcile the binding here to keep the bar from stranding with no keyboard. - if isFocused && !textView.isFirstResponder { - Task { @MainActor in - guard textView.window != nil, textView.becomeFirstResponder() else { - isFocused = false - return - } - } - } else if !isFocused && textView.isFirstResponder { - textView.resignFirstResponder() + // Manage focus. The accessory bar is gated on `isFocused`, which the + // delegate clears only via `textViewDidEndEditing`. That never fires when + // the responder request can't be honored (view not yet in a window), so + // reconcile the binding here to keep the bar from stranding with no keyboard. + if isFocused, !textView.isFirstResponder { + Task { @MainActor in + guard textView.window != nil, textView.becomeFirstResponder() else { + isFocused = false + return } + } + } else if !isFocused, textView.isFirstResponder { + textView.resignFirstResponder() } - - func makeCoordinator() -> Coordinator { - Coordinator( - text: $text, - isFocused: $isFocused, - cursorPosition: $cursorPosition, - onSubmit: onSubmit, - onHistoryUp: onHistoryUp, - onHistoryDown: onHistoryDown, - onRightArrowAtEnd: onRightArrowAtEnd, - onTabComplete: onTabComplete - ) + } + + func makeCoordinator() -> Coordinator { + Coordinator( + text: $text, + isFocused: $isFocused, + cursorPosition: $cursorPosition, + onSubmit: onSubmit, + onHistoryUp: onHistoryUp, + onHistoryDown: onHistoryDown, + onRightArrowAtEnd: onRightArrowAtEnd, + onTabComplete: onTabComplete + ) + } + + class Coordinator: NSObject, FocusableTextViewDelegate { + @Binding var text: String + @Binding var isFocused: Bool + @Binding var cursorPosition: Int + let onSubmit: () -> Void + let onHistoryUp: () -> Void + let onHistoryDown: () -> Void + let onRightArrowAtEnd: () -> Void + let onTabComplete: () -> Void + + init( + text: Binding, + isFocused: Binding, + cursorPosition: Binding, + onSubmit: @escaping () -> Void, + onHistoryUp: @escaping () -> Void, + onHistoryDown: @escaping () -> Void, + onRightArrowAtEnd: @escaping () -> Void, + onTabComplete: @escaping () -> Void + ) { + _text = text + _isFocused = isFocused + _cursorPosition = cursorPosition + self.onSubmit = onSubmit + self.onHistoryUp = onHistoryUp + self.onHistoryDown = onHistoryDown + self.onRightArrowAtEnd = onRightArrowAtEnd + self.onTabComplete = onTabComplete } - class Coordinator: NSObject, FocusableTextViewDelegate { - @Binding var text: String - @Binding var isFocused: Bool - @Binding var cursorPosition: Int - let onSubmit: () -> Void - let onHistoryUp: () -> Void - let onHistoryDown: () -> Void - let onRightArrowAtEnd: () -> Void - let onTabComplete: () -> Void - - init( - text: Binding, - isFocused: Binding, - cursorPosition: Binding, - onSubmit: @escaping () -> Void, - onHistoryUp: @escaping () -> Void, - onHistoryDown: @escaping () -> Void, - onRightArrowAtEnd: @escaping () -> Void, - onTabComplete: @escaping () -> Void - ) { - _text = text - _isFocused = isFocused - _cursorPosition = cursorPosition - self.onSubmit = onSubmit - self.onHistoryUp = onHistoryUp - self.onHistoryDown = onHistoryDown - self.onRightArrowAtEnd = onRightArrowAtEnd - self.onTabComplete = onTabComplete - } - - func textViewDidChange(_ textView: UITextView) { - let currentText = textView.text ?? "" - if let newlineIndex = currentText.firstIndex(of: "\n") { - // A paste can deliver the command and its newline in one change; - // sync the binding before clearing so onSubmit reads the full text. - text = String(currentText[.., with event: UIPressesEvent?) { + guard let key = presses.first?.key else { + super.pressesBegan(presses, with: event) + return } - @objc private func handleTab() { - customDelegate?.tabComplete() - } - - // MARK: - Hardware Keyboard Press Handling - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let key = presses.first?.key else { - super.pressesBegan(presses, with: event) - return - } - - switch key.keyCode { - case .keyboardRightArrow: - // Check if cursor is at end of text - let cursorOffset: Int - if let selectedRange = selectedTextRange { - cursorOffset = offset(from: beginningOfDocument, to: selectedRange.start) - } else { - cursorOffset = text.count - } - - if cursorOffset >= text.count { - // Cursor at end - notify delegate for ghost text acceptance - customDelegate?.rightArrowAtEnd() - // Don't call super - prevent default behavior - return - } else { - // Cursor not at end - let UITextView handle normal cursor movement - super.pressesBegan(presses, with: event) - } - - default: - super.pressesBegan(presses, with: event) - } + switch key.keyCode { + case .keyboardRightArrow: + // Check if cursor is at end of text + let cursorOffset: Int = if let selectedRange = selectedTextRange { + offset(from: beginningOfDocument, to: selectedRange.start) + } else { + text.count + } + + if cursorOffset >= text.count { + // Cursor at end - notify delegate for ghost text acceptance + customDelegate?.rightArrowAtEnd() + // Don't call super - prevent default behavior + return + } else { + // Cursor not at end - let UITextView handle normal cursor movement + super.pressesBegan(presses, with: event) + } + + default: + super.pressesBegan(presses, with: event) } + } - // Public methods for programmatic cursor movement (called from accessory bar buttons) - func moveCursorLeft() { - customDelegate?.moveCursor(by: -1, in: self) - } + /// Public methods for programmatic cursor movement (called from accessory bar buttons) + func moveCursorLeft() { + customDelegate?.moveCursor(by: -1, in: self) + } - func moveCursorRight() { - customDelegate?.moveCursor(by: 1, in: self) - } + func moveCursorRight() { + customDelegate?.moveCursor(by: 1, in: self) + } - func moveCursorToEnd() { - customDelegate?.moveCursorToEnd(in: self) - } + func moveCursorToEnd() { + customDelegate?.moveCursorToEnd(in: self) + } } diff --git a/MC1/Views/Tools/LineOfSight/AddRepeaterRowView.swift b/MC1/Views/Tools/LineOfSight/AddRepeaterRowView.swift index 3bdcbabb..8e64ad03 100644 --- a/MC1/Views/Tools/LineOfSight/AddRepeaterRowView.swift +++ b/MC1/Views/Tools/LineOfSight/AddRepeaterRowView.swift @@ -1,34 +1,34 @@ import SwiftUI struct AddRepeaterRowView: View { - let onAdd: () -> Void + let onAdd: () -> Void - var body: some View { - Button { - onAdd() - } label: { - HStack { - // Purple R marker (matches full row) - Circle() - .fill(.purple) - .frame(width: 24, height: 24) - .overlay { - Text("R") - .font(.caption) - .bold() - .foregroundStyle(.white) - } + var body: some View { + Button { + onAdd() + } label: { + HStack { + // Purple R marker (matches full row) + Circle() + .fill(.purple) + .frame(width: 24, height: 24) + .overlay { + Text("R") + .font(.caption) + .bold() + .foregroundStyle(.white) + } - Text(L10n.Tools.Tools.LineOfSight.addRepeater) - .font(.subheadline) + Text(L10n.Tools.Tools.LineOfSight.addRepeater) + .font(.subheadline) - Spacer() + Spacer() - Image(systemName: "plus.circle.fill") - .foregroundStyle(.purple) - } - .padding(.vertical, 8) - } - .liquidGlassSecondaryButtonStyle() + Image(systemName: "plus.circle.fill") + .foregroundStyle(.purple) + } + .padding(.vertical, 8) } + .liquidGlassSecondaryButtonStyle() + } } diff --git a/MC1/Views/Tools/LineOfSight/AnalysisErrorView.swift b/MC1/Views/Tools/LineOfSight/AnalysisErrorView.swift index 129562b9..7eddb49c 100644 --- a/MC1/Views/Tools/LineOfSight/AnalysisErrorView.swift +++ b/MC1/Views/Tools/LineOfSight/AnalysisErrorView.swift @@ -1,30 +1,30 @@ import SwiftUI struct AnalysisErrorView: View { - let message: String - let hasRepeater: Bool - let onRetry: () -> Void + let message: String + let hasRepeater: Bool + let onRetry: () -> Void - var body: some View { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundStyle(.orange) + var body: some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) - Text(L10n.Tools.Tools.LineOfSight.analysisFailed) - .font(.headline) + Text(L10n.Tools.Tools.LineOfSight.analysisFailed) + .font(.headline) - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) - Button(L10n.Tools.Tools.LineOfSight.retry) { - onRetry() - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity) - .padding() + Button(L10n.Tools.Tools.LineOfSight.retry) { + onRetry() + } + .buttonStyle(.bordered) } + .frame(maxWidth: .infinity) + .padding() + } } diff --git a/MC1/Views/Tools/LineOfSight/ChartCoordinateSpace.swift b/MC1/Views/Tools/LineOfSight/ChartCoordinateSpace.swift index 7563b12e..f1a6264c 100644 --- a/MC1/Views/Tools/LineOfSight/ChartCoordinateSpace.swift +++ b/MC1/Views/Tools/LineOfSight/ChartCoordinateSpace.swift @@ -2,39 +2,39 @@ import SwiftUI /// Transforms data coordinates (meters) to canvas pixel coordinates struct ChartCoordinateSpace { - let canvasSize: CGSize - let padding: EdgeInsets - let xRange: ClosedRange // meters - let yRange: ClosedRange // meters + let canvasSize: CGSize + let padding: EdgeInsets + let xRange: ClosedRange // meters + let yRange: ClosedRange // meters - private var plotWidth: CGFloat { - canvasSize.width - padding.leading - padding.trailing - } + private var plotWidth: CGFloat { + canvasSize.width - padding.leading - padding.trailing + } - private var plotHeight: CGFloat { - canvasSize.height - padding.top - padding.bottom - } + private var plotHeight: CGFloat { + canvasSize.height - padding.top - padding.bottom + } - /// Convert x data value (meters) to pixel x coordinate - func xPixel(_ xMeters: Double) -> CGFloat { - let fraction = (xMeters - xRange.lowerBound) / (xRange.upperBound - xRange.lowerBound) - return padding.leading + fraction * plotWidth - } + /// Convert x data value (meters) to pixel x coordinate + func xPixel(_ xMeters: Double) -> CGFloat { + let fraction = (xMeters - xRange.lowerBound) / (xRange.upperBound - xRange.lowerBound) + return padding.leading + fraction * plotWidth + } - /// Convert y data value (meters) to pixel y coordinate (inverted for Canvas) - func yPixel(_ yMeters: Double) -> CGFloat { - let fraction = (yMeters - yRange.lowerBound) / (yRange.upperBound - yRange.lowerBound) - // Invert: Canvas origin is top-left, data origin is bottom-left - return canvasSize.height - padding.bottom - fraction * plotHeight - } + /// Convert y data value (meters) to pixel y coordinate (inverted for Canvas) + func yPixel(_ yMeters: Double) -> CGFloat { + let fraction = (yMeters - yRange.lowerBound) / (yRange.upperBound - yRange.lowerBound) + // Invert: Canvas origin is top-left, data origin is bottom-left + return canvasSize.height - padding.bottom - fraction * plotHeight + } - /// Convert data point (meters) to canvas pixel point - func point(x: Double, y: Double) -> CGPoint { - CGPoint(x: xPixel(x), y: yPixel(y)) - } + /// Convert data point (meters) to canvas pixel point + func point(x: Double, y: Double) -> CGPoint { + CGPoint(x: xPixel(x), y: yPixel(y)) + } - /// Format x value (meters) as km string for axis labels - func xLabel(_ xMeters: Double) -> String { - (xMeters / 1000).formatted(.number.precision(.fractionLength(1))) - } + /// Format x value (meters) as km string for axis labels + func xLabel(_ xMeters: Double) -> String { + (xMeters / 1000).formatted(.number.precision(.fractionLength(1))) + } } diff --git a/MC1/Views/Tools/LineOfSight/ClearanceStatus+UI.swift b/MC1/Views/Tools/LineOfSight/ClearanceStatus+UI.swift index e09ff7d0..500ff80a 100644 --- a/MC1/Views/Tools/LineOfSight/ClearanceStatus+UI.swift +++ b/MC1/Views/Tools/LineOfSight/ClearanceStatus+UI.swift @@ -2,33 +2,33 @@ import MC1Services import SwiftUI extension ClearanceStatus { - var color: Color { - switch self { - case .clear: .green - case .marginal: .yellow - case .partialObstruction: .orange - case .blocked: .red - } + var color: Color { + switch self { + case .clear: .green + case .marginal: .yellow + case .partialObstruction: .orange + case .blocked: .red } + } - var iconName: String { - switch self { - case .clear: "checkmark.circle.fill" - case .marginal, .partialObstruction: "exclamationmark.triangle.fill" - case .blocked: "xmark.octagon.fill" - } + var iconName: String { + switch self { + case .clear: "checkmark.circle.fill" + case .marginal, .partialObstruction: "exclamationmark.triangle.fill" + case .blocked: "xmark.octagon.fill" } + } - var localizedName: String { - switch self { - case .clear: L10n.Tools.Tools.LineOfSight.Status.clear - case .marginal: L10n.Tools.Tools.LineOfSight.Status.marginal - case .partialObstruction: L10n.Tools.Tools.LineOfSight.Status.partialObstruction - case .blocked: L10n.Tools.Tools.LineOfSight.Status.blocked - } + var localizedName: String { + switch self { + case .clear: L10n.Tools.Tools.LineOfSight.Status.clear + case .marginal: L10n.Tools.Tools.LineOfSight.Status.marginal + case .partialObstruction: L10n.Tools.Tools.LineOfSight.Status.partialObstruction + case .blocked: L10n.Tools.Tools.LineOfSight.Status.blocked } + } - static var blockedSubtitle: String { - L10n.Tools.Tools.LineOfSight.Status.blockedSubtitle - } + static var blockedSubtitle: String { + L10n.Tools.Tools.LineOfSight.Status.blockedSubtitle + } } diff --git a/MC1/Views/Tools/LineOfSight/Components/ClearanceStatusView.swift b/MC1/Views/Tools/LineOfSight/Components/ClearanceStatusView.swift index 254d6bac..d356d59e 100644 --- a/MC1/Views/Tools/LineOfSight/Components/ClearanceStatusView.swift +++ b/MC1/Views/Tools/LineOfSight/Components/ClearanceStatusView.swift @@ -3,49 +3,49 @@ import SwiftUI /// Displays clearance status with appropriate icon, color, and clearance percentage struct ClearanceStatusView: View { - let status: ClearanceStatus - let clearancePercent: Double - - var body: some View { - HStack(spacing: 6) { - Image(systemName: status.iconName) - .foregroundStyle(status.color) - .font(.body.weight(.semibold)) - - Text(status.localizedName) - .font(.headline) - - // Show clearance percentage only for non-blocked statuses - if status != .blocked { - Text("·") - .foregroundStyle(.secondary) - Text(L10n.Tools.Tools.LineOfSight.clearancePercent(clampedPercent)) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } + let status: ClearanceStatus + let clearancePercent: Double + + var body: some View { + HStack(spacing: 6) { + Image(systemName: status.iconName) + .foregroundStyle(status.color) + .font(.body.weight(.semibold)) + + Text(status.localizedName) + .font(.headline) + + // Show clearance percentage only for non-blocked statuses + if status != .blocked { + Text("·") + .foregroundStyle(.secondary) + Text(L10n.Tools.Tools.LineOfSight.clearancePercent(clampedPercent)) + .font(.subheadline) + .foregroundStyle(.secondary) + } } + } - /// Clamp percent to 0-100 range for display - private var clampedPercent: Int { - Int(max(0, min(100, clearancePercent))) - } + /// Clamp percent to 0-100 range for display + private var clampedPercent: Int { + Int(max(0, min(100, clearancePercent))) + } } // MARK: - Preview #Preview("Clear") { - ClearanceStatusView(status: .clear, clearancePercent: 92) + ClearanceStatusView(status: .clear, clearancePercent: 92) } #Preview("Marginal") { - ClearanceStatusView(status: .marginal, clearancePercent: 72) + ClearanceStatusView(status: .marginal, clearancePercent: 72) } #Preview("Partial Obstruction") { - ClearanceStatusView(status: .partialObstruction, clearancePercent: 47) + ClearanceStatusView(status: .partialObstruction, clearancePercent: 47) } #Preview("Blocked") { - ClearanceStatusView(status: .blocked, clearancePercent: -15) + ClearanceStatusView(status: .blocked, clearancePercent: -15) } diff --git a/MC1/Views/Tools/LineOfSight/Components/LOSFormatters.swift b/MC1/Views/Tools/LineOfSight/Components/LOSFormatters.swift index 3649a375..7cadb239 100644 --- a/MC1/Views/Tools/LineOfSight/Components/LOSFormatters.swift +++ b/MC1/Views/Tools/LineOfSight/Components/LOSFormatters.swift @@ -2,63 +2,62 @@ import Foundation /// Formatting utilities for Line of Sight results display enum LOSFormatters { - - /// Formats diffraction loss for display - /// - Parameter loss: Diffraction loss in dB - /// - Returns: Formatted string like "+ 8.4 dB" or nil if loss is negligible (< 0.1 dB) - static func formatDiffractionLoss(_ loss: Double) -> String? { - guard abs(loss) >= 0.1 else { return nil } - return "+ \(loss.formatted(.number.precision(.fractionLength(1)))) dB" - } - - /// Formats total path loss for display - /// - Parameter loss: Path loss in dB - /// - Returns: Formatted string like "126.6 dB" - static func formatPathLoss(_ loss: Double) -> String { - "\(loss.formatted(.number.precision(.fractionLength(1)))) dB" - } - - /// Formats clearance percentage for display - /// - Parameter percent: Clearance percentage (may be outside 0-100 range) - /// - Returns: Integer percentage clamped to 0-100 range - static func formatClearancePercent(_ percent: Double) -> Int { - Int(max(0, min(100, percent))) - } - - /// Formats distance using the locale's measurement system - /// - Parameter meters: Distance in meters to format - /// - Returns: Formatted string like "12.4 km" or "7.7 mi" - static func formatDistance(_ meters: Double) -> String { - Measurement(value: meters, unit: UnitLength.meters) - .formatted(.measurement(width: .abbreviated, usage: .road)) - } - - /// Formats frequency with a locale-aware unit symbol - /// - Parameter mhz: Frequency in MHz - /// - Returns: Formatted string like "906 MHz" or "915.5 MHz" - static func formatFrequency(_ mhz: Double) -> String { - let fractionDigits = mhz.truncatingRemainder(dividingBy: 1) == 0 ? 0 : 1 - return Measurement(value: mhz, unit: UnitFrequency.megahertz) - .formatted(.measurement( - width: .abbreviated, - usage: .asProvided, - numberFormatStyle: .number.precision(.fractionLength(fractionDigits)) - )) - } - - /// Formats k-factor for display - /// - Parameter k: Refraction k-factor - /// - Returns: Formatted string like "k=1.33" - static func formatKFactor(_ k: Double) -> String { - "k=\(k.formatted(.number.precision(.fractionLength(2))))" - } - - /// Formats complete assumptions line - /// - Parameters: - /// - frequencyMHz: Operating frequency in MHz - /// - k: Refraction k-factor - /// - Returns: Localized string like "906 MHz, k=1.33, 60% 1st Fresnel threshold" - static func formatAssumptions(frequencyMHz: Double, k: Double) -> String { - L10n.Tools.Tools.LineOfSight.assumptions(formatFrequency(frequencyMHz), formatKFactor(k)) - } + /// Formats diffraction loss for display + /// - Parameter loss: Diffraction loss in dB + /// - Returns: Formatted string like "+ 8.4 dB" or nil if loss is negligible (< 0.1 dB) + static func formatDiffractionLoss(_ loss: Double) -> String? { + guard abs(loss) >= 0.1 else { return nil } + return "+ \(loss.formatted(.number.precision(.fractionLength(1)))) dB" + } + + /// Formats total path loss for display + /// - Parameter loss: Path loss in dB + /// - Returns: Formatted string like "126.6 dB" + static func formatPathLoss(_ loss: Double) -> String { + "\(loss.formatted(.number.precision(.fractionLength(1)))) dB" + } + + /// Formats clearance percentage for display + /// - Parameter percent: Clearance percentage (may be outside 0-100 range) + /// - Returns: Integer percentage clamped to 0-100 range + static func formatClearancePercent(_ percent: Double) -> Int { + Int(max(0, min(100, percent))) + } + + /// Formats distance using the locale's measurement system + /// - Parameter meters: Distance in meters to format + /// - Returns: Formatted string like "12.4 km" or "7.7 mi" + static func formatDistance(_ meters: Double) -> String { + Measurement(value: meters, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .road)) + } + + /// Formats frequency with a locale-aware unit symbol + /// - Parameter mhz: Frequency in MHz + /// - Returns: Formatted string like "906 MHz" or "915.5 MHz" + static func formatFrequency(_ mhz: Double) -> String { + let fractionDigits = mhz.truncatingRemainder(dividingBy: 1) == 0 ? 0 : 1 + return Measurement(value: mhz, unit: UnitFrequency.megahertz) + .formatted(.measurement( + width: .abbreviated, + usage: .asProvided, + numberFormatStyle: .number.precision(.fractionLength(fractionDigits)) + )) + } + + /// Formats k-factor for display + /// - Parameter k: Refraction k-factor + /// - Returns: Formatted string like "k=1.33" + static func formatKFactor(_ k: Double) -> String { + "k=\(k.formatted(.number.precision(.fractionLength(2))))" + } + + /// Formats complete assumptions line + /// - Parameters: + /// - frequencyMHz: Operating frequency in MHz + /// - k: Refraction k-factor + /// - Returns: Localized string like "906 MHz, k=1.33, 60% 1st Fresnel threshold" + static func formatAssumptions(frequencyMHz: Double, k: Double) -> String { + L10n.Tools.Tools.LineOfSight.assumptions(formatFrequency(frequencyMHz), formatKFactor(k)) + } } diff --git a/MC1/Views/Tools/LineOfSight/Components/ResultsCardView.swift b/MC1/Views/Tools/LineOfSight/Components/ResultsCardView.swift index 41899d36..85a020aa 100644 --- a/MC1/Views/Tools/LineOfSight/Components/ResultsCardView.swift +++ b/MC1/Views/Tools/LineOfSight/Components/ResultsCardView.swift @@ -3,411 +3,411 @@ import SwiftUI /// Expandable card showing analysis results with progressive disclosure struct ResultsCardView: View { - let result: PathAnalysisResult - @Binding var isExpanded: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.Tools.Tools.LineOfSight.results) - .font(.headline) - - VStack(alignment: .leading, spacing: 0) { - // Collapsed summary (always visible) - collapsedContent - - // Expanded details - if isExpanded { - Divider() - .padding(.vertical, 12) - - expandedContent - } - } - .padding() + let result: PathAnalysisResult + @Binding var isExpanded: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.Tools.Tools.LineOfSight.results) + .font(.headline) + + VStack(alignment: .leading, spacing: 0) { + // Collapsed summary (always visible) + collapsedContent + + // Expanded details + if isExpanded { + Divider() + .padding(.vertical, 12) + + expandedContent } + } + .padding() } + } - // MARK: - Collapsed Content - - private var collapsedContent: some View { - VStack(alignment: .leading, spacing: 8) { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isExpanded.toggle() - } - } label: { - HStack { - ClearanceStatusView( - status: result.clearanceStatus, - clearancePercent: result.worstClearancePercent - ) - - Spacer() - - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundStyle(.secondary) - } - .contentShape(.rect) - } - .buttonStyle(.plain) - - // Blocked subtitle - if result.clearanceStatus == .blocked { - Text(ClearanceStatus.blockedSubtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - - Divider() - - HStack { - Text(LOSFormatters.formatDistance(result.distanceMeters)) - .font(.subheadline) - .foregroundStyle(.secondary) - - Spacer() - - Text(LOSFormatters.formatPathLoss(result.totalPathLoss) + " " + L10n.Tools.Tools.LineOfSight.loss) - .font(.subheadline) - .foregroundStyle(.secondary) - } + // MARK: - Collapsed Content + + private var collapsedContent: some View { + VStack(alignment: .leading, spacing: 8) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() } - } + } label: { + HStack { + ClearanceStatusView( + status: result.clearanceStatus, + clearancePercent: result.worstClearancePercent + ) - // MARK: - Expanded Content + Spacer() - private var expandedContent: some View { - VStack(alignment: .leading, spacing: 16) { - pathLossBreakdown - clearanceDetails - assumptionsFootnote + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) } + .contentShape(.rect) + } + .buttonStyle(.plain) + + // Blocked subtitle + if result.clearanceStatus == .blocked { + Text(ClearanceStatus.blockedSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + Text(LOSFormatters.formatDistance(result.distanceMeters)) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + Text(LOSFormatters.formatPathLoss(result.totalPathLoss) + " " + L10n.Tools.Tools.LineOfSight.loss) + .font(.subheadline) + .foregroundStyle(.secondary) + } } + } - // MARK: - Path Loss Breakdown - - private var pathLossBreakdown: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Tools.Tools.LineOfSight.pathLossBreakdown) - .font(.subheadline) - .bold() - - Grid(alignment: .leading, verticalSpacing: 6) { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.freeSpaceLoss) - .foregroundStyle(.secondary) - Spacer() - Text(LOSFormatters.formatPathLoss(result.freeSpacePathLoss)) - .monospacedDigit() - } - - if let diffractionText = LOSFormatters.formatDiffractionLoss(result.peakDiffractionLoss) { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.diffractionLoss) - .foregroundStyle(.secondary) - Spacer() - Text(diffractionText) - .monospacedDigit() - } - } - - Divider() - .gridCellColumns(2) - - GridRow { - Text(L10n.Tools.Tools.LineOfSight.total) - .bold() - Spacer() - Text(LOSFormatters.formatPathLoss(result.totalPathLoss)) - .monospacedDigit() - .bold() - } - } - .font(.subheadline) - } + // MARK: - Expanded Content + + private var expandedContent: some View { + VStack(alignment: .leading, spacing: 16) { + pathLossBreakdown + clearanceDetails + assumptionsFootnote } + } + + // MARK: - Path Loss Breakdown + + private var pathLossBreakdown: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Tools.Tools.LineOfSight.pathLossBreakdown) + .font(.subheadline) + .bold() + + Grid(alignment: .leading, verticalSpacing: 6) { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.freeSpaceLoss) + .foregroundStyle(.secondary) + Spacer() + Text(LOSFormatters.formatPathLoss(result.freeSpacePathLoss)) + .monospacedDigit() + } - // MARK: - Clearance Details - - private var clearanceDetails: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Tools.Tools.LineOfSight.clearance) - .font(.subheadline) - .bold() - - Grid(alignment: .leading, verticalSpacing: 6) { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.worstClearanceShort) - .foregroundStyle(.secondary) - Spacer() - Text("\(LOSFormatters.formatClearancePercent(result.worstClearancePercent))%") - .monospacedDigit() - } - - GridRow { - Text(L10n.Tools.Tools.LineOfSight.obstructionsFound) - .foregroundStyle(.secondary) - Spacer() - Text("\(result.obstructionPoints.count)") - .monospacedDigit() - } - } - .font(.subheadline) + if let diffractionText = LOSFormatters.formatDiffractionLoss(result.peakDiffractionLoss) { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.diffractionLoss) + .foregroundStyle(.secondary) + Spacer() + Text(diffractionText) + .monospacedDigit() + } } + + Divider() + .gridCellColumns(2) + + GridRow { + Text(L10n.Tools.Tools.LineOfSight.total) + .bold() + Spacer() + Text(LOSFormatters.formatPathLoss(result.totalPathLoss)) + .monospacedDigit() + .bold() + } + } + .font(.subheadline) } + } - // MARK: - Assumptions Footnote + // MARK: - Clearance Details - private var assumptionsFootnote: some View { - HStack(spacing: 6) { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) + private var clearanceDetails: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Tools.Tools.LineOfSight.clearance) + .font(.subheadline) + .bold() - Text(LOSFormatters.formatAssumptions( - frequencyMHz: result.frequencyMHz, - k: result.refractionK - )) + Grid(alignment: .leading, verticalSpacing: 6) { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.worstClearanceShort) .foregroundStyle(.secondary) + Spacer() + Text("\(LOSFormatters.formatClearancePercent(result.worstClearancePercent))%") + .monospacedDigit() } - .font(.caption) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) + + GridRow { + Text(L10n.Tools.Tools.LineOfSight.obstructionsFound) + .foregroundStyle(.secondary) + Spacer() + Text("\(result.obstructionPoints.count)") + .monospacedDigit() + } + } + .font(.subheadline) + } + } + + // MARK: - Assumptions Footnote + + private var assumptionsFootnote: some View { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + + Text(LOSFormatters.formatAssumptions( + frequencyMHz: result.frequencyMHz, + k: result.refractionK + )) + .foregroundStyle(.secondary) } + .font(.caption) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + } } // MARK: - Preview #Preview("Clear Path") { - let result = PathAnalysisResult( - distanceMeters: 12400, - freeSpacePathLoss: 118.2, - peakDiffractionLoss: 0, - totalPathLoss: 118.2, - clearanceStatus: .clear, - worstClearancePercent: 92, - obstructionPoints: [], - frequencyMHz: 906, - refractionK: 1.33 - ) - - return ResultsCardView(result: result, isExpanded: .constant(false)) - .padding() + let result = PathAnalysisResult( + distanceMeters: 12400, + freeSpacePathLoss: 118.2, + peakDiffractionLoss: 0, + totalPathLoss: 118.2, + clearanceStatus: .clear, + worstClearancePercent: 92, + obstructionPoints: [], + frequencyMHz: 906, + refractionK: 1.33 + ) + + return ResultsCardView(result: result, isExpanded: .constant(false)) + .padding() } #Preview("Partial Obstruction - Expanded") { - let result = PathAnalysisResult( - distanceMeters: 12400, - freeSpacePathLoss: 118.2, - peakDiffractionLoss: 8.4, - totalPathLoss: 126.6, - clearanceStatus: .partialObstruction, - worstClearancePercent: 47, - obstructionPoints: [ - ObstructionPoint(distanceFromAMeters: 5000, obstructionHeightMeters: 12, fresnelClearancePercent: 47), - ObstructionPoint(distanceFromAMeters: 7200, obstructionHeightMeters: 8, fresnelClearancePercent: 55) - ], - frequencyMHz: 906, - refractionK: 1.33 - ) - - return ResultsCardView(result: result, isExpanded: .constant(true)) - .padding() + let result = PathAnalysisResult( + distanceMeters: 12400, + freeSpacePathLoss: 118.2, + peakDiffractionLoss: 8.4, + totalPathLoss: 126.6, + clearanceStatus: .partialObstruction, + worstClearancePercent: 47, + obstructionPoints: [ + ObstructionPoint(distanceFromAMeters: 5000, obstructionHeightMeters: 12, fresnelClearancePercent: 47), + ObstructionPoint(distanceFromAMeters: 7200, obstructionHeightMeters: 8, fresnelClearancePercent: 55) + ], + frequencyMHz: 906, + refractionK: 1.33 + ) + + return ResultsCardView(result: result, isExpanded: .constant(true)) + .padding() } #Preview("Blocked - Expanded") { - let result = PathAnalysisResult( - distanceMeters: 8500, - freeSpacePathLoss: 112.5, - peakDiffractionLoss: 22.3, - totalPathLoss: 134.8, - clearanceStatus: .blocked, - worstClearancePercent: -15, - obstructionPoints: [ - ObstructionPoint(distanceFromAMeters: 4200, obstructionHeightMeters: 35, fresnelClearancePercent: -15) - ], - frequencyMHz: 915, - refractionK: 1.33 - ) - - return ResultsCardView(result: result, isExpanded: .constant(true)) - .padding() + let result = PathAnalysisResult( + distanceMeters: 8500, + freeSpacePathLoss: 112.5, + peakDiffractionLoss: 22.3, + totalPathLoss: 134.8, + clearanceStatus: .blocked, + worstClearancePercent: -15, + obstructionPoints: [ + ObstructionPoint(distanceFromAMeters: 4200, obstructionHeightMeters: 35, fresnelClearancePercent: -15) + ], + frequencyMHz: 915, + refractionK: 1.33 + ) + + return ResultsCardView(result: result, isExpanded: .constant(true)) + .padding() } // MARK: - Relay Results Card View /// Card view for relay path analysis results showing dual-segment analysis struct RelayResultsCardView: View { - let result: RelayPathAnalysisResult - @Binding var isExpanded: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.Tools.Tools.LineOfSight.results) - .font(.headline) - - VStack(alignment: .leading, spacing: 0) { - collapsedContent - - if isExpanded { - Divider() - .padding(.vertical, 12) - expandedContent - } - } - .padding() + let result: RelayPathAnalysisResult + @Binding var isExpanded: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.Tools.Tools.LineOfSight.results) + .font(.headline) + + VStack(alignment: .leading, spacing: 0) { + collapsedContent + + if isExpanded { + Divider() + .padding(.vertical, 12) + expandedContent } + } + .padding() } + } - // MARK: - Collapsed Content - - private var collapsedContent: some View { - VStack(alignment: .leading, spacing: 8) { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isExpanded.toggle() - } - } label: { - HStack { - ClearanceStatusView( - status: result.overallStatus, - clearancePercent: min( - result.segmentAR.worstClearancePercent, - result.segmentRB.worstClearancePercent - ) - ) - Spacer() - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundStyle(.secondary) - } - .contentShape(.rect) - } - .buttonStyle(.plain) - - Divider() - - // Segment summary - VStack(alignment: .leading, spacing: 4) { - segmentRow(segment: result.segmentAR) - segmentRow(segment: result.segmentRB) - } - - Divider() - - HStack { - Text("\(L10n.Tools.Tools.LineOfSight.total): \(LOSFormatters.formatDistance(result.totalDistanceMeters))") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } + // MARK: - Collapsed Content + + private var collapsedContent: some View { + VStack(alignment: .leading, spacing: 8) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack { + ClearanceStatusView( + status: result.overallStatus, + clearancePercent: min( + result.segmentAR.worstClearancePercent, + result.segmentRB.worstClearancePercent + ) + ) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) } + .contentShape(.rect) + } + .buttonStyle(.plain) + + Divider() + + // Segment summary + VStack(alignment: .leading, spacing: 4) { + segmentRow(segment: result.segmentAR) + segmentRow(segment: result.segmentRB) + } + + Divider() + + HStack { + Text("\(L10n.Tools.Tools.LineOfSight.total): \(LOSFormatters.formatDistance(result.totalDistanceMeters))") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } } + } - private func segmentRow(segment: SegmentAnalysisResult) -> some View { - HStack { - Circle() - .fill(segment.clearanceStatus.color) - .frame(width: 8, height: 8) + private func segmentRow(segment: SegmentAnalysisResult) -> some View { + HStack { + Circle() + .fill(segment.clearanceStatus.color) + .frame(width: 8, height: 8) - Text("\(segment.startLabel) \u{2192} \(segment.endLabel)") - .font(.caption) + Text("\(segment.startLabel) \u{2192} \(segment.endLabel)") + .font(.caption) - Text(segment.clearanceStatus.localizedName) - .font(.caption) - .foregroundStyle(.secondary) + Text(segment.clearanceStatus.localizedName) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Text(LOSFormatters.formatDistance(segment.distanceMeters)) - .font(.caption) - .foregroundStyle(.secondary) - } + Text(LOSFormatters.formatDistance(segment.distanceMeters)) + .font(.caption) + .foregroundStyle(.secondary) } + } - // MARK: - Expanded Content + // MARK: - Expanded Content - private var expandedContent: some View { - VStack(alignment: .leading, spacing: 16) { - segmentDetailView(segment: result.segmentAR) - segmentDetailView(segment: result.segmentRB) - } + private var expandedContent: some View { + VStack(alignment: .leading, spacing: 16) { + segmentDetailView(segment: result.segmentAR) + segmentDetailView(segment: result.segmentRB) } + } - private func segmentDetailView(segment: SegmentAnalysisResult) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("\(segment.startLabel) \u{2192} \(segment.endLabel)") - .font(.subheadline) - .bold() - - Grid(alignment: .leading, verticalSpacing: 4) { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.status) - .foregroundStyle(.secondary) - Spacer() - Text(segment.clearanceStatus.localizedName) - } - GridRow { - Text(L10n.Tools.Tools.LineOfSight.distance) - .foregroundStyle(.secondary) - Spacer() - Text(LOSFormatters.formatDistance(segment.distanceMeters)) - } - GridRow { - Text(L10n.Tools.Tools.LineOfSight.worstClearanceShort) - .foregroundStyle(.secondary) - Spacer() - Text("\(LOSFormatters.formatClearancePercent(segment.worstClearancePercent))%") - } - } - .font(.caption) + private func segmentDetailView(segment: SegmentAnalysisResult) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("\(segment.startLabel) \u{2192} \(segment.endLabel)") + .font(.subheadline) + .bold() + + Grid(alignment: .leading, verticalSpacing: 4) { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.status) + .foregroundStyle(.secondary) + Spacer() + Text(segment.clearanceStatus.localizedName) + } + GridRow { + Text(L10n.Tools.Tools.LineOfSight.distance) + .foregroundStyle(.secondary) + Spacer() + Text(LOSFormatters.formatDistance(segment.distanceMeters)) + } + GridRow { + Text(L10n.Tools.Tools.LineOfSight.worstClearanceShort) + .foregroundStyle(.secondary) + Spacer() + Text("\(LOSFormatters.formatClearancePercent(segment.worstClearancePercent))%") } - .padding() + } + .font(.caption) } + .padding() + } } // MARK: - Relay Results Previews #Preview("Relay - Clear Path") { - let result = RelayPathAnalysisResult( - segmentAR: SegmentAnalysisResult( - startLabel: "A", - endLabel: "R", - clearanceStatus: .clear, - distanceMeters: 5200, - worstClearancePercent: 85 - ), - segmentRB: SegmentAnalysisResult( - startLabel: "R", - endLabel: "B", - clearanceStatus: .clear, - distanceMeters: 7200, - worstClearancePercent: 92 - ) + let result = RelayPathAnalysisResult( + segmentAR: SegmentAnalysisResult( + startLabel: "A", + endLabel: "R", + clearanceStatus: .clear, + distanceMeters: 5200, + worstClearancePercent: 85 + ), + segmentRB: SegmentAnalysisResult( + startLabel: "R", + endLabel: "B", + clearanceStatus: .clear, + distanceMeters: 7200, + worstClearancePercent: 92 ) + ) - return RelayResultsCardView(result: result, isExpanded: .constant(false)) - .padding() + return RelayResultsCardView(result: result, isExpanded: .constant(false)) + .padding() } #Preview("Relay - Mixed Clearance - Expanded") { - let result = RelayPathAnalysisResult( - segmentAR: SegmentAnalysisResult( - startLabel: "A", - endLabel: "R", - clearanceStatus: .clear, - distanceMeters: 5200, - worstClearancePercent: 85 - ), - segmentRB: SegmentAnalysisResult( - startLabel: "R", - endLabel: "B", - clearanceStatus: .partialObstruction, - distanceMeters: 7200, - worstClearancePercent: 45 - ) + let result = RelayPathAnalysisResult( + segmentAR: SegmentAnalysisResult( + startLabel: "A", + endLabel: "R", + clearanceStatus: .clear, + distanceMeters: 5200, + worstClearancePercent: 85 + ), + segmentRB: SegmentAnalysisResult( + startLabel: "R", + endLabel: "B", + clearanceStatus: .partialObstruction, + distanceMeters: 7200, + worstClearancePercent: 45 ) + ) - return RelayResultsCardView(result: result, isExpanded: .constant(true)) - .padding() + return RelayResultsCardView(result: result, isExpanded: .constant(true)) + .padding() } diff --git a/MC1/Views/Tools/LineOfSight/FresnelZoneRenderer.swift b/MC1/Views/Tools/LineOfSight/FresnelZoneRenderer.swift index bc3e1066..8b50f9cd 100644 --- a/MC1/Views/Tools/LineOfSight/FresnelZoneRenderer.swift +++ b/MC1/Views/Tools/LineOfSight/FresnelZoneRenderer.swift @@ -2,115 +2,125 @@ import MC1Services import SwiftUI enum FresnelZoneRenderer { - - /// Calculate LOS height at a given distance along the path - /// - Parameters: - /// - atDistance: Distance from point A in meters - /// - totalDistance: Total path distance in meters - /// - heightA: Antenna height at A (ground + antenna) in meters - /// - heightB: Antenna height at B (ground + antenna) in meters - /// - Returns: LOS height in meters above sea level - static func losHeight( - atDistance: Double, - totalDistance: Double, - heightA: Double, - heightB: Double - ) -> Double { - guard totalDistance > 0 else { return heightA } - let fraction = atDistance / totalDistance - return heightA + fraction * (heightB - heightA) - } - - /// Build profile samples from elevation data - /// - Parameters: - /// - elevationProfile: Array of elevation samples from terrain API (can be a segment slice) - /// - pointAHeight: Antenna height at segment start in meters above ground - /// - pointBHeight: Antenna height at segment end in meters above ground - /// - frequencyMHz: Operating frequency for Fresnel zone calculation - /// - refractionK: Effective earth radius factor for earth bulge calculation - /// - Returns: Array of ProfileSample with computed LOS heights and Fresnel radii - /// - /// Note: When passing a segment slice (e.g., R→B), the samples may have non-zero - /// distanceFromAMeters values. This method calculates Fresnel zones relative to - /// the segment boundaries while preserving original x-coordinates for rendering. - static func buildProfileSamples( - from elevationProfile: [ElevationSample], - pointAHeight: Double, - pointBHeight: Double, - frequencyMHz: Double, - refractionK: Double - ) -> [ProfileSample] { - guard let first = elevationProfile.first, - let last = elevationProfile.last else { return [] } - - // Calculate segment-relative distances for Fresnel/LOS calculations - // This handles both full paths (offset=0) and segment slices (offset>0) - let segmentOffset = first.distanceFromAMeters - let segmentLength = last.distanceFromAMeters - segmentOffset - - let heightA = first.elevation + pointAHeight - let heightB = last.elevation + pointBHeight - - return elevationProfile.map { sample in - // Use segment-relative distances for physics calculations - let distanceFromSegmentStart = sample.distanceFromAMeters - segmentOffset - let distanceToSegmentEnd = segmentLength - distanceFromSegmentStart - - let yLOS = losHeight( - atDistance: distanceFromSegmentStart, - totalDistance: segmentLength, - heightA: heightA, - heightB: heightB - ) - - let radius = RFCalculator.fresnelRadius( - frequencyMHz: frequencyMHz, - distanceToAMeters: distanceFromSegmentStart, - distanceToBMeters: distanceToSegmentEnd - ) - - let earthBulge = RFCalculator.earthBulge( - distanceToAMeters: distanceFromSegmentStart, - distanceToBMeters: distanceToSegmentEnd, - refractionK: refractionK - ) - - // Preserve original x-coordinate for correct rendering position - return ProfileSample( - x: sample.distanceFromAMeters, - yTerrain: sample.elevation + earthBulge, - yLOS: yLOS, - fresnelRadius: radius - ) - } + /// Calculate LOS height at a given distance along the path + /// - Parameters: + /// - atDistance: Distance from point A in meters + /// - totalDistance: Total path distance in meters + /// - heightA: Antenna height at A (ground + antenna) in meters + /// - heightB: Antenna height at B (ground + antenna) in meters + /// - Returns: LOS height in meters above sea level + static func losHeight( + atDistance: Double, + totalDistance: Double, + heightA: Double, + heightB: Double + ) -> Double { + guard totalDistance > 0 else { return heightA } + let fraction = atDistance / totalDistance + return heightA + fraction * (heightB - heightA) + } + + /// Build profile samples from elevation data + /// - Parameters: + /// - elevationProfile: Array of elevation samples from terrain API (can be a segment slice) + /// - pointAHeight: Antenna height at segment start in meters above ground + /// - pointBHeight: Antenna height at segment end in meters above ground + /// - frequencyMHz: Operating frequency for Fresnel zone calculation + /// - refractionK: Effective earth radius factor for earth bulge calculation + /// - Returns: Array of ProfileSample with computed LOS heights and Fresnel radii + /// + /// Note: When passing a segment slice (e.g., R→B), the samples may have non-zero + /// distanceFromAMeters values. This method calculates Fresnel zones relative to + /// the segment boundaries while preserving original x-coordinates for rendering. + static func buildProfileSamples( + from elevationProfile: [ElevationSample], + pointAHeight: Double, + pointBHeight: Double, + frequencyMHz: Double, + refractionK: Double + ) -> [ProfileSample] { + guard let first = elevationProfile.first, + let last = elevationProfile.last else { return [] } + + // Calculate segment-relative distances for Fresnel/LOS calculations + // This handles both full paths (offset=0) and segment slices (offset>0) + let segmentOffset = first.distanceFromAMeters + let segmentLength = last.distanceFromAMeters - segmentOffset + + let heightA = first.elevation + pointAHeight + let heightB = last.elevation + pointBHeight + + return elevationProfile.map { sample in + // Use segment-relative distances for physics calculations + let distanceFromSegmentStart = sample.distanceFromAMeters - segmentOffset + let distanceToSegmentEnd = segmentLength - distanceFromSegmentStart + + let yLOS = losHeight( + atDistance: distanceFromSegmentStart, + totalDistance: segmentLength, + heightA: heightA, + heightB: heightB + ) + + let radius = RFCalculator.fresnelRadius( + frequencyMHz: frequencyMHz, + distanceToAMeters: distanceFromSegmentStart, + distanceToBMeters: distanceToSegmentEnd + ) + + let earthBulge = RFCalculator.earthBulge( + distanceToAMeters: distanceFromSegmentStart, + distanceToBMeters: distanceToSegmentEnd, + refractionK: refractionK + ) + + // Preserve original x-coordinate for correct rendering position + return ProfileSample( + x: sample.distanceFromAMeters, + yTerrain: sample.elevation + earthBulge, + yLOS: yLOS, + fresnelRadius: radius + ) } - + } } /// Sample point with all computed values for rendering struct ProfileSample { - let x: Double // distance from A in meters - let yTerrain: Double // terrain elevation in meters - let yLOS: Double // line of sight height in meters - let fresnelRadius: Double - - var yTop: Double { yLOS + fresnelRadius } - var yBottom: Double { yLOS - fresnelRadius } - - // Inner 60% zone boundaries (ideal clearance threshold) - var yTop60: Double { yLOS + fresnelRadius * 0.6 } - var yBottom60: Double { yLOS - fresnelRadius * 0.6 } - - /// Visible bottom of inner 60% zone (clamped) - var yVisibleBottom60: Double { - min(max(yTerrain, yBottom60), yTop60) - } - - /// Whether terrain intrudes past the 60% Fresnel clearance threshold at this point - var isObstructed: Bool { yTerrain > yBottom60 } - - /// Visible bottom of Fresnel zone (clamped to avoid path inversion) - var yVisibleBottom: Double { - min(max(yTerrain, yBottom), yTop) - } + let x: Double // distance from A in meters + let yTerrain: Double // terrain elevation in meters + let yLOS: Double // line of sight height in meters + let fresnelRadius: Double + + var yTop: Double { + yLOS + fresnelRadius + } + + var yBottom: Double { + yLOS - fresnelRadius + } + + /// Inner 60% zone boundaries (ideal clearance threshold) + var yTop60: Double { + yLOS + fresnelRadius * 0.6 + } + + var yBottom60: Double { + yLOS - fresnelRadius * 0.6 + } + + /// Visible bottom of inner 60% zone (clamped) + var yVisibleBottom60: Double { + min(max(yTerrain, yBottom60), yTop60) + } + + /// Whether terrain intrudes past the 60% Fresnel clearance threshold at this point + var isObstructed: Bool { + yTerrain > yBottom60 + } + + /// Visible bottom of Fresnel zone (clamped to avoid path inversion) + var yVisibleBottom: Double { + min(max(yTerrain, yBottom), yTop) + } } diff --git a/MC1/Views/Tools/LineOfSight/HeightEditorGrid.swift b/MC1/Views/Tools/LineOfSight/HeightEditorGrid.swift index c925607c..189b0ae5 100644 --- a/MC1/Views/Tools/LineOfSight/HeightEditorGrid.swift +++ b/MC1/Views/Tools/LineOfSight/HeightEditorGrid.swift @@ -1,77 +1,77 @@ import SwiftUI struct HeightEditorGrid: View { - let groundElevation: Double? - @Binding var additionalHeight: Double - let range: ClosedRange - var onHeightChanged: (() -> Void)? + let groundElevation: Double? + @Binding var additionalHeight: Double + let range: ClosedRange + var onHeightChanged: (() -> Void)? - private var heightStep: Double { - Locale.current.measurementSystem != .metric ? 0.3048 : 1.0 - } + private var heightStep: Double { + Locale.current.measurementSystem != .metric ? 0.3048 : 1.0 + } - var body: some View { - Grid(alignment: .leading, verticalSpacing: 8) { - if let groundElevation { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.groundElevation) - .font(.caption) - .foregroundStyle(.secondary) + var body: some View { + Grid(alignment: .leading, verticalSpacing: 8) { + if let groundElevation { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.groundElevation) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Text(Measurement(value: groundElevation, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) - .font(.caption) - .monospacedDigit() - } - } else { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.groundElevation) - .font(.caption) - .foregroundStyle(.secondary) + Text(Measurement(value: groundElevation, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + } + } else { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.groundElevation) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - ProgressView() - .controlSize(.mini) - } - } + ProgressView() + .controlSize(.mini) + } + } - GridRow { - Text(L10n.Tools.Tools.LineOfSight.additionalHeight) - .font(.caption) - .foregroundStyle(.secondary) + GridRow { + Text(L10n.Tools.Tools.LineOfSight.additionalHeight) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Stepper(value: $additionalHeight, in: range, step: heightStep) { - Text(Measurement(value: additionalHeight, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) - .font(.caption) - .monospacedDigit() - } - .controlSize(.small) - .onChange(of: additionalHeight) { - onHeightChanged?() - } - } + Stepper(value: $additionalHeight, in: range, step: heightStep) { + Text(Measurement(value: additionalHeight, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + } + .controlSize(.small) + .onChange(of: additionalHeight) { + onHeightChanged?() + } + } - if let groundElevation { - Divider() - .gridCellColumns(2) + if let groundElevation { + Divider() + .gridCellColumns(2) - GridRow { - Text(L10n.Tools.Tools.LineOfSight.totalHeight) - .font(.caption) - .bold() + GridRow { + Text(L10n.Tools.Tools.LineOfSight.totalHeight) + .font(.caption) + .bold() - Spacer() + Spacer() - Text(Measurement(value: groundElevation + additionalHeight, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) - .font(.caption) - .monospacedDigit() - .bold() - } - } + Text(Measurement(value: groundElevation + additionalHeight, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + .bold() } + } } + } } diff --git a/MC1/Views/Tools/LineOfSight/LineOfSightLayoutMode.swift b/MC1/Views/Tools/LineOfSight/LineOfSightLayoutMode.swift index e2e1076f..b49e2e87 100644 --- a/MC1/Views/Tools/LineOfSight/LineOfSightLayoutMode.swift +++ b/MC1/Views/Tools/LineOfSight/LineOfSightLayoutMode.swift @@ -1,5 +1,5 @@ enum LineOfSightLayoutMode { - case map - case panel - case mapWithSheet + case map + case panel + case mapWithSheet } diff --git a/MC1/Views/Tools/LineOfSight/LineOfSightView.swift b/MC1/Views/Tools/LineOfSight/LineOfSightView.swift index a465725b..600ce532 100644 --- a/MC1/Views/Tools/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/Tools/LineOfSight/LineOfSightView.swift @@ -11,493 +11,493 @@ private let analysisSheetDetentExpanded: PresentationDetent = .large /// Full-screen map view for analyzing line-of-sight between two points struct LineOfSightView: View { - @Environment(\.appState) private var appState - @Environment(\.dismiss) private var dismiss - - @State private var viewModel: LineOfSightViewModel - @State private var sheetDetent: PresentationDetent = analysisSheetDetentCollapsed - @State private var enableHalfDetent = false - @State private var showAnalysisSheet: Bool - @State private var editingPoint: PointID? - @State private var isDropPinMode = false - @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard - @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels - @State private var sheetBottomInset: CGFloat = 220 - @State private var isResultsExpanded = false - @State private var isRFSettingsExpanded = false - @State private var showingMapStyleMenu = false - @State private var copyHapticTrigger = 0 - - private let layoutMode: LineOfSightLayoutMode - - // One-time drag hint tooltip for repeater marker - @AppStorage(AppStorageKey.hasSeenRepeaterDragHint.rawValue) private var hasSeenDragHint = AppStorageKey.defaultHasSeenRepeaterDragHint - @State private var showDragHint = false - @State private var repeaterMarkerCenter: CGPoint? - @State private var isNavigatingBack = false - - private var isRelocating: Bool { viewModel.relocatingPoint != nil } - - private var shouldShowExpandedAnalysis: Bool { - sheetDetent != analysisSheetDetentCollapsed + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var viewModel: LineOfSightViewModel + @State private var sheetDetent: PresentationDetent = analysisSheetDetentCollapsed + @State private var enableHalfDetent = false + @State private var showAnalysisSheet: Bool + @State private var editingPoint: PointID? + @State private var isDropPinMode = false + @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels + @State private var sheetBottomInset: CGFloat = 220 + @State private var isResultsExpanded = false + @State private var isRFSettingsExpanded = false + @State private var showingMapStyleMenu = false + @State private var copyHapticTrigger = 0 + + private let layoutMode: LineOfSightLayoutMode + + // One-time drag hint tooltip for repeater marker + @AppStorage(AppStorageKey.hasSeenRepeaterDragHint.rawValue) private var hasSeenDragHint = AppStorageKey.defaultHasSeenRepeaterDragHint + @State private var showDragHint = false + @State private var repeaterMarkerCenter: CGPoint? + @State private var isNavigatingBack = false + + private var isRelocating: Bool { + viewModel.relocatingPoint != nil + } + + private var shouldShowExpandedAnalysis: Bool { + sheetDetent != analysisSheetDetentCollapsed + } + + private var mapOverlayBottomPadding: CGFloat { + showAnalysisSheet ? sheetBottomInset : 0 + } + + private var availableSheetDetents: Set { + if enableHalfDetent { + [analysisSheetDetentCollapsed, analysisSheetDetentHalf, analysisSheetDetentExpanded] + } else { + [analysisSheetDetentCollapsed, analysisSheetDetentExpanded] } + } - private var mapOverlayBottomPadding: CGFloat { - showAnalysisSheet ? sheetBottomInset : 0 - } + // MARK: - Initialization - private var availableSheetDetents: Set { - if enableHalfDetent { - [analysisSheetDetentCollapsed, analysisSheetDetentHalf, analysisSheetDetentExpanded] - } else { - [analysisSheetDetentCollapsed, analysisSheetDetentExpanded] - } - } + init(preselectedContact: ContactDTO? = nil) { + _viewModel = State(initialValue: LineOfSightViewModel(preselectedContact: preselectedContact)) + layoutMode = .mapWithSheet + _showAnalysisSheet = State(initialValue: true) + } - // MARK: - Initialization + init(viewModel: LineOfSightViewModel, layoutMode: LineOfSightLayoutMode) { + _viewModel = State(initialValue: viewModel) + self.layoutMode = layoutMode + _showAnalysisSheet = State(initialValue: layoutMode == .mapWithSheet) + } - init(preselectedContact: ContactDTO? = nil) { - _viewModel = State(initialValue: LineOfSightViewModel(preselectedContact: preselectedContact)) - layoutMode = .mapWithSheet - _showAnalysisSheet = State(initialValue: true) - } + // MARK: - Body - init(viewModel: LineOfSightViewModel, layoutMode: LineOfSightLayoutMode) { - _viewModel = State(initialValue: viewModel) - self.layoutMode = layoutMode - _showAnalysisSheet = State(initialValue: layoutMode == .mapWithSheet) - } + var body: some View { + switch layoutMode { + case .panel: + ScrollView { + analysisSheetContent + } + .scrollDismissesKeyboard(.immediately) - // MARK: - Body + case .map: + mapCanvasWithBehaviors(showSheet: false) - var body: some View { - switch layoutMode { - case .panel: - ScrollView { - analysisSheetContent - } - .scrollDismissesKeyboard(.immediately) - - case .map: - mapCanvasWithBehaviors(showSheet: false) + case .mapWithSheet: + mapCanvasWithBehaviors(showSheet: true) + } + } + + @ViewBuilder + private func mapCanvasWithBehaviors(showSheet: Bool) -> some View { + let base = LOSMapCanvasView( + viewModel: viewModel, + appState: appState, + mapStyleSelection: $mapStyleSelection, + showingMapStyleMenu: $showingMapStyleMenu, + showLabels: $showLabels, + isDropPinMode: $isDropPinMode, + mapOverlayBottomPadding: mapOverlayBottomPadding, + cameraBottomSheetFraction: showSheet ? 0.25 : 0, + onRepeaterTap: { handleRepeaterTap($0) }, + onMapTap: { handleMapTap(at: $0) } + ) + .onChange(of: viewModel.pointA) { oldValue, newValue in + if oldValue == nil, newValue != nil, viewModel.pointB != nil { + if showSheet { + enableHalfDetent = true + withAnimation { + sheetDetent = analysisSheetDetentHalf + } + } + } - case .mapWithSheet: - mapCanvasWithBehaviors(showSheet: true) + if showSheet, newValue == nil, viewModel.pointB == nil { + withAnimation { + sheetDetent = analysisSheetDetentCollapsed } + } } - - @ViewBuilder - private func mapCanvasWithBehaviors(showSheet: Bool) -> some View { - let base = LOSMapCanvasView( - viewModel: viewModel, - appState: appState, - mapStyleSelection: $mapStyleSelection, - showingMapStyleMenu: $showingMapStyleMenu, - showLabels: $showLabels, - isDropPinMode: $isDropPinMode, - mapOverlayBottomPadding: mapOverlayBottomPadding, - cameraBottomSheetFraction: showSheet ? 0.25 : 0, - onRepeaterTap: { handleRepeaterTap($0) }, - onMapTap: { handleMapTap(at: $0) } - ) - .onChange(of: viewModel.pointA) { oldValue, newValue in - if oldValue == nil, newValue != nil, viewModel.pointB != nil { - if showSheet { - enableHalfDetent = true - withAnimation { - sheetDetent = analysisSheetDetentHalf - } - } - } - - if showSheet, newValue == nil, viewModel.pointB == nil { - withAnimation { - sheetDetent = analysisSheetDetentCollapsed - } - } - } - .onChange(of: viewModel.pointB) { oldValue, newValue in - if oldValue == nil, newValue != nil, viewModel.pointA != nil { - if showSheet { - enableHalfDetent = true - withAnimation { - sheetDetent = analysisSheetDetentHalf - } - } - } - - if showSheet, newValue == nil, viewModel.pointA == nil { - withAnimation { - sheetDetent = analysisSheetDetentCollapsed - } - } - } - .onChange(of: sheetDetent) { oldValue, newValue in - guard showSheet else { return } - - if isRelocating, newValue != analysisSheetDetentCollapsed { - viewModel.relocatingPoint = nil - } - - // Disable half detent once user drags away from it - if oldValue == analysisSheetDetentHalf, newValue != analysisSheetDetentHalf { - enableHalfDetent = false - } - } - .onChange(of: viewModel.repeaterPoint) { oldValue, newValue in - if oldValue == nil, - newValue != nil, - newValue?.isOnPath == true, - !hasSeenDragHint { - withAnimation(.easeIn(duration: 0.3)) { - showDragHint = true - } - hasSeenDragHint = true - Task { - try? await Task.sleep(for: .seconds(5)) - withAnimation(.easeOut(duration: 0.3)) { - showDragHint = false - } - } - } - } - .onChange(of: viewModel.analysisStatus) { _, newStatus in - handleAnalysisStatusChange(newStatus, showSheet: showSheet) - } - .task { - appState.locationService.requestPermissionIfNeeded() - viewModel.configure( - dataStore: { [appState] in appState.offlineDataStore }, - radioID: { [appState] in appState.currentRadioID }, - deviceFrequencyKHz: appState.connectedDevice?.frequency - ) - viewModel.showLabels = showLabels - await viewModel.loadRepeaters() - viewModel.centerOnAllRepeaters() - } - .onChange(of: showLabels) { _, newValue in - viewModel.showLabels = newValue - } - + .onChange(of: viewModel.pointB) { oldValue, newValue in + if oldValue == nil, newValue != nil, viewModel.pointA != nil { if showSheet { - base - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - dismissLineOfSight() - } label: { - Label(L10n.Tools.Tools.LineOfSight.back, systemImage: "chevron.left") - .labelStyle(.titleAndIcon) - } - .accessibilityLabel(L10n.Tools.Tools.LineOfSight.back) - } - } - .liquidGlassToolbarBackground() - .onDisappear { - showAnalysisSheet = false - } - .sheet(isPresented: $showAnalysisSheet) { - analysisSheet - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - proxy.safeAreaInsets.bottom + 15 - } action: { inset in - if sheetDetent == analysisSheetDetentCollapsed { - sheetBottomInset = max(0, inset) - } - } - .presentationDetents(availableSheetDetents, selection: $sheetDetent) - .presentationDragIndicator(.visible) - .presentationBackgroundInteraction(.enabled) - .presentationBackground(.regularMaterial) - .interactiveDismissDisabled() - } - } else { - base - .liquidGlassToolbarBackground() + enableHalfDetent = true + withAnimation { + sheetDetent = analysisSheetDetentHalf + } } - } + } - @MainActor - private func dismissLineOfSight() { - guard !isNavigatingBack else { return } - isNavigatingBack = true + if showSheet, newValue == nil, viewModel.pointA == nil { + withAnimation { + sheetDetent = analysisSheetDetentCollapsed + } + } + } + .onChange(of: sheetDetent) { oldValue, newValue in + guard showSheet else { return } - showAnalysisSheet = false + if isRelocating, newValue != analysisSheetDetentCollapsed { viewModel.relocatingPoint = nil + } - // Yield to let showAnalysisSheet = false commit before dismiss fires, - // avoiding a sheet-dismissal animation conflict. - Task { @MainActor in - await Task.yield() - dismiss() + // Disable half detent once user drags away from it + if oldValue == analysisSheetDetentHalf, newValue != analysisSheetDetentHalf { + enableHalfDetent = false + } + } + .onChange(of: viewModel.repeaterPoint) { oldValue, newValue in + if oldValue == nil, + newValue != nil, + newValue?.isOnPath == true, + !hasSeenDragHint { + withAnimation(.easeIn(duration: 0.3)) { + showDragHint = true + } + hasSeenDragHint = true + Task { + try? await Task.sleep(for: .seconds(5)) + withAnimation(.easeOut(duration: 0.3)) { + showDragHint = false + } } + } + } + .onChange(of: viewModel.analysisStatus) { _, newStatus in + handleAnalysisStatusChange(newStatus, showSheet: showSheet) + } + .task { + appState.locationService.requestPermissionIfNeeded() + viewModel.configure( + dataStore: { [appState] in appState.offlineDataStore }, + radioID: { [appState] in appState.currentRadioID }, + deviceFrequencyKHz: appState.connectedDevice?.frequency + ) + viewModel.showLabels = showLabels + await viewModel.loadRepeaters() + viewModel.centerOnAllRepeaters() + } + .onChange(of: showLabels) { _, newValue in + viewModel.showLabels = newValue } - // MARK: - Analysis Sheet - - private var analysisSheet: some View { - NavigationStack { - ScrollView { - analysisSheetContent + if showSheet { + base + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismissLineOfSight() + } label: { + Label(L10n.Tools.Tools.LineOfSight.back, systemImage: "chevron.left") + .labelStyle(.titleAndIcon) } - .scrollDismissesKeyboard(.immediately) - .toolbar(.hidden, for: .navigationBar) + .accessibilityLabel(L10n.Tools.Tools.LineOfSight.back) + } } + .liquidGlassToolbarBackground() + .onDisappear { + showAnalysisSheet = false + } + .sheet(isPresented: $showAnalysisSheet) { + analysisSheet + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height - proxy.safeAreaInsets.bottom + 15 + } action: { inset in + if sheetDetent == analysisSheetDetentCollapsed { + sheetBottomInset = max(0, inset) + } + } + .presentationDetents(availableSheetDetents, selection: $sheetDetent) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled) + .presentationBackground(.regularMaterial) + .interactiveDismissDisabled() + } + } else { + base + .liquidGlassToolbarBackground() } + } - private var analysisSheetContent: some View { - VStack(alignment: .leading, spacing: 16) { - PointsSummarySectionView( - viewModel: viewModel, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: { withAnimation { sheetDetent = analysisSheetDetentCollapsed } } - ) - - // Before analysis: show analyze button, then RF settings - if viewModel.canAnalyze, !hasAnalysisResult { - analyzeButtonSection - RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) - } + @MainActor + private func dismissLineOfSight() { + guard !isNavigatingBack else { return } + isNavigatingBack = true - // After analysis: show button, results, terrain, then RF settings - if case .result(let result) = viewModel.analysisStatus { - analyzeButtonSection + showAnalysisSheet = false + viewModel.relocatingPoint = nil - resultSummarySection(result) + // Yield to let showAnalysisSheet = false commit before dismiss fires, + // avoiding a sheet-dismissal animation conflict. + Task { @MainActor in + await Task.yield() + dismiss() + } + } - if shouldShowExpandedAnalysis { - TerrainProfileSectionView( - viewModel: viewModel, - showDragHint: $showDragHint, - repeaterMarkerCenter: $repeaterMarkerCenter - ) - RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) - } - } + // MARK: - Analysis Sheet - // Relay analysis: show relay-specific results card - if case .relayResult(let result) = viewModel.analysisStatus { - analyzeButtonSection + private var analysisSheet: some View { + NavigationStack { + ScrollView { + analysisSheetContent + } + .scrollDismissesKeyboard(.immediately) + .toolbar(.hidden, for: .navigationBar) + } + } + + private var analysisSheetContent: some View { + VStack(alignment: .leading, spacing: 16) { + PointsSummarySectionView( + viewModel: viewModel, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: { withAnimation { sheetDetent = analysisSheetDetentCollapsed } } + ) + + // Before analysis: show analyze button, then RF settings + if viewModel.canAnalyze, !hasAnalysisResult { + analyzeButtonSection + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) + } + + // After analysis: show button, results, terrain, then RF settings + if case let .result(result) = viewModel.analysisStatus { + analyzeButtonSection + + resultSummarySection(result) + + if shouldShowExpandedAnalysis { + TerrainProfileSectionView( + viewModel: viewModel, + showDragHint: $showDragHint, + repeaterMarkerCenter: $repeaterMarkerCenter + ) + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) + } + } - RelayResultsCardView(result: result, isExpanded: $isResultsExpanded) + // Relay analysis: show relay-specific results card + if case let .relayResult(result) = viewModel.analysisStatus { + analyzeButtonSection - if shouldShowExpandedAnalysis { - TerrainProfileSectionView( - viewModel: viewModel, - showDragHint: $showDragHint, - repeaterMarkerCenter: $repeaterMarkerCenter - ) - RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) - } - } + RelayResultsCardView(result: result, isExpanded: $isResultsExpanded) - if case .error(let message) = viewModel.analysisStatus { - AnalysisErrorView( - message: message, - hasRepeater: viewModel.repeaterPoint != nil, - onRetry: { - if viewModel.repeaterPoint != nil { - viewModel.analyzeWithRepeater() - } else { - viewModel.analyze() - } - } - ) - } + if shouldShowExpandedAnalysis { + TerrainProfileSectionView( + viewModel: viewModel, + showDragHint: $showDragHint, + repeaterMarkerCenter: $repeaterMarkerCenter + ) + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) } - .padding() - } - - // MARK: - Analyze Button Section + } - private var analyzeButtonSection: some View { - AnalyzeButton( - viewModel: viewModel, - hasAnalysisResult: hasAnalysisResult, - onAnalyze: { - withAnimation { sheetDetent = analysisSheetDetentExpanded } + if case let .error(message) = viewModel.analysisStatus { + AnalysisErrorView( + message: message, + hasRepeater: viewModel.repeaterPoint != nil, + onRetry: { + if viewModel.repeaterPoint != nil { + viewModel.analyzeWithRepeater() + } else { + viewModel.analyze() } + } ) + } } + .padding() + } + + // MARK: - Analyze Button Section + + private var analyzeButtonSection: some View { + AnalyzeButton( + viewModel: viewModel, + hasAnalysisResult: hasAnalysisResult, + onAnalyze: { + withAnimation { sheetDetent = analysisSheetDetentExpanded } + } + ) + } - // MARK: - Result Summary Section + // MARK: - Result Summary Section - @ViewBuilder - private func resultSummarySection(_ result: PathAnalysisResult) -> some View { - ResultsCardView(result: result, isExpanded: $isResultsExpanded) - } + private func resultSummarySection(_ result: PathAnalysisResult) -> some View { + ResultsCardView(result: result, isExpanded: $isResultsExpanded) + } - // MARK: - Computed Properties + // MARK: - Computed Properties - private var analysisResult: PathAnalysisResult? { - if case .result(let result) = viewModel.analysisStatus { - return result - } - return nil + private var analysisResult: PathAnalysisResult? { + if case let .result(result) = viewModel.analysisStatus { + return result } - - private var hasAnalysisResult: Bool { - if case .result = viewModel.analysisStatus { return true } - if case .relayResult = viewModel.analysisStatus { return true } - return false + return nil + } + + private var hasAnalysisResult: Bool { + if case .result = viewModel.analysisStatus { return true } + if case .relayResult = viewModel.analysisStatus { return true } + return false + } + + // MARK: - Helper Methods + + private func handleMapTap(at coordinate: CLLocationCoordinate2D) { + // Handle relocation mode + if let relocating = viewModel.relocatingPoint { + handleRelocation(to: coordinate, for: relocating) + return } - // MARK: - Helper Methods - - private func handleMapTap(at coordinate: CLLocationCoordinate2D) { - // Handle relocation mode - if let relocating = viewModel.relocatingPoint { - handleRelocation(to: coordinate, for: relocating) - return - } - - // Handle drop pin mode - guard isDropPinMode else { return } - viewModel.selectPoint(at: coordinate) - isDropPinMode = false + // Handle drop pin mode + guard isDropPinMode else { return } + viewModel.selectPoint(at: coordinate) + isDropPinMode = false + } + + private func handleRelocation(to coordinate: CLLocationCoordinate2D, for pointID: PointID) { + switch pointID { + case .pointA: + viewModel.setPointA(coordinate: coordinate, contact: nil) + case .pointB: + viewModel.setPointB(coordinate: coordinate, contact: nil) + case .repeater: + viewModel.setRepeaterOffPath(coordinate: coordinate) } - private func handleRelocation(to coordinate: CLLocationCoordinate2D, for pointID: PointID) { - switch pointID { - case .pointA: - viewModel.setPointA(coordinate: coordinate, contact: nil) - case .pointB: - viewModel.setPointB(coordinate: coordinate, contact: nil) - case .repeater: - viewModel.setRepeaterOffPath(coordinate: coordinate) - } - - // Clear results and show Analyze button - viewModel.clearAnalysisResults() - viewModel.relocatingPoint = nil - enableHalfDetent = true - withAnimation { - sheetDetent = analysisSheetDetentHalf - } + // Clear results and show Analyze button + viewModel.clearAnalysisResults() + viewModel.relocatingPoint = nil + enableHalfDetent = true + withAnimation { + sheetDetent = analysisSheetDetentHalf } - - private func handleAnalysisStatusChange(_ status: AnalysisStatus, showSheet: Bool) { - switch status { - case .result: - if showSheet { - sheetDetent = analysisSheetDetentExpanded - } - case .relayResult: - break - default: - return - } - - if viewModel.shouldAutoZoomOnNextResult { - viewModel.shouldAutoZoomOnNextResult = false - viewModel.zoomToShowBothPoints() - } + } + + private func handleAnalysisStatusChange(_ status: AnalysisStatus, showSheet: Bool) { + switch status { + case .result: + if showSheet { + sheetDetent = analysisSheetDetentExpanded + } + case .relayResult: + break + default: + return } - private func handleRepeaterTap(_ contact: ContactDTO) { - viewModel.toggleContact(contact) + if viewModel.shouldAutoZoomOnNextResult { + viewModel.shouldAutoZoomOnNextResult = false + viewModel.zoomToShowBothPoints() } + } + + private func handleRepeaterTap(_ contact: ContactDTO) { + viewModel.toggleContact(contact) + } } // MARK: - Map Canvas View private struct LOSMapCanvasView: View { - @Bindable var viewModel: LineOfSightViewModel - let appState: AppState - @Environment(\.colorScheme) private var colorScheme - @Binding var mapStyleSelection: MapStyleSelection - @Binding var showingMapStyleMenu: Bool - @Binding var showLabels: Bool - @Binding var isDropPinMode: Bool - let mapOverlayBottomPadding: CGFloat - let cameraBottomSheetFraction: CGFloat? - let onRepeaterTap: (ContactDTO) -> Void - let onMapTap: (CLLocationCoordinate2D) -> Void - - var body: some View { - ZStack { - MC1MapView( - points: viewModel.mapPoints, - lines: viewModel.mapLines, - mapStyle: mapStyleSelection, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: showLabels, - showsUserLocation: true, - isInteractive: true, - showsScale: true, - isNorthLocked: viewModel.isNorthLocked, - cameraRegion: $viewModel.cameraRegion, - cameraRegionVersion: viewModel.cameraRegionVersion, - cameraBottomSheetFraction: cameraBottomSheetFraction, - onPointTap: { point, _ in - if let repeater = viewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { - onRepeaterTap(repeater) - } - }, - onMapTap: onMapTap, - onCameraRegionChange: { region in - viewModel.cameraRegion = region - }, - ) - .ignoresSafeArea() - - VStack { - Spacer() - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: { - Task { - if let location = try? await appState.locationService.requestCurrentLocation() { - viewModel.setCameraRegion(MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - )) - } - } - }, - isNorthLocked: $viewModel.isNorthLocked, - showLabels: $showLabels, - showingLayersMenu: $showingMapStyleMenu - ) { - Button(isDropPinMode ? L10n.Tools.Tools.LineOfSight.cancelDropPin : L10n.Tools.Tools.LineOfSight.dropPin, systemImage: isDropPinMode ? "mappin.slash" : "mappin") { - isDropPinMode.toggle() - } - .mapControlButton(tint: isDropPinMode ? .blue : .primary) - } + @Bindable var viewModel: LineOfSightViewModel + let appState: AppState + @Environment(\.colorScheme) private var colorScheme + @Binding var mapStyleSelection: MapStyleSelection + @Binding var showingMapStyleMenu: Bool + @Binding var showLabels: Bool + @Binding var isDropPinMode: Bool + let mapOverlayBottomPadding: CGFloat + let cameraBottomSheetFraction: CGFloat? + let onRepeaterTap: (ContactDTO) -> Void + let onMapTap: (CLLocationCoordinate2D) -> Void + + var body: some View { + ZStack { + MC1MapView( + points: viewModel.mapPoints, + lines: viewModel.mapLines, + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: viewModel.isNorthLocked, + cameraRegion: $viewModel.cameraRegion, + cameraRegionVersion: viewModel.cameraRegionVersion, + cameraBottomSheetFraction: cameraBottomSheetFraction, + onPointTap: { point, _ in + if let repeater = viewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { + onRepeaterTap(repeater) + } + }, + onMapTap: onMapTap, + onCameraRegionChange: { region in + viewModel.cameraRegion = region + }, + ) + .ignoresSafeArea() + + VStack { + Spacer() + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: { + Task { + if let location = try? await appState.locationService.requestCurrentLocation() { + viewModel.setCameraRegion(MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + )) } + } + }, + isNorthLocked: $viewModel.isNorthLocked, + showLabels: $showLabels, + showingLayersMenu: $showingMapStyleMenu + ) { + Button(isDropPinMode ? L10n.Tools.Tools.LineOfSight.cancelDropPin : L10n.Tools.Tools.LineOfSight.dropPin, systemImage: isDropPinMode ? "mappin.slash" : "mappin") { + isDropPinMode.toggle() } - .padding(.bottom, mapOverlayBottomPadding) + .mapControlButton(tint: isDropPinMode ? .blue : .primary) + } + } + } + .padding(.bottom, mapOverlayBottomPadding) - if showingMapStyleMenu { - Button { - withAnimation { showingMapStyleMenu = false } - } label: { - Color.black.opacity(0.3).ignoresSafeArea() - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) - - VStack { - Spacer() - HStack { - Spacer() - LayersMenu( - selection: $mapStyleSelection, - isPresented: $showingMapStyleMenu, - viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() - ) - .padding(.trailing) - } - } - .padding(.bottom, mapOverlayBottomPadding) - } + if showingMapStyleMenu { + Button { + withAnimation { showingMapStyleMenu = false } + } label: { + Color.black.opacity(0.3).ignoresSafeArea() } - } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) + VStack { + Spacer() + HStack { + Spacer() + LayersMenu( + selection: $mapStyleSelection, + isPresented: $showingMapStyleMenu, + viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() + ) + .padding(.trailing) + } + } + .padding(.bottom, mapOverlayBottomPadding) + } + } + } } // MARK: - Frequency Input Row @@ -506,132 +506,132 @@ private struct LOSMapCanvasView: View { /// This is necessary because @FocusState doesn't work properly when declared in a parent view /// and used in sheet content. struct FrequencyInputRow: View { - @Bindable var viewModel: LineOfSightViewModel - @FocusState private var isFocused: Bool - @State private var text: String = "" - - var body: some View { - HStack { - Label(L10n.Tools.Tools.LineOfSight.frequency, systemImage: "antenna.radiowaves.left.and.right") - .foregroundStyle(.secondary) - Spacer() - TextField(L10n.Tools.Tools.LineOfSight.mhz, text: $text) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 80) - .focused($isFocused) - .onChange(of: text) { _, newValue in - let replaced = newValue.replacing(",", with: ".") - if replaced != newValue { - text = replaced - } - } - .onChange(of: isFocused) { _, focused in - if focused { - // Sync text from view model when gaining focus - text = viewModel.formatFrequencyForEditing(viewModel.frequencyMHz) - } else { - // Commit when focus is lost - commitEdit() - } - } - - Text(L10n.Tools.Tools.LineOfSight.mhz) - .foregroundStyle(.secondary) - - if isFocused { - Button { - commitEdit() - isFocused = false - } label: { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.title2) - } - .buttonStyle(.plain) - } + @Bindable var viewModel: LineOfSightViewModel + @FocusState private var isFocused: Bool + @State private var text: String = "" + + var body: some View { + HStack { + Label(L10n.Tools.Tools.LineOfSight.frequency, systemImage: "antenna.radiowaves.left.and.right") + .foregroundStyle(.secondary) + Spacer() + TextField(L10n.Tools.Tools.LineOfSight.mhz, text: $text) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .focused($isFocused) + .onChange(of: text) { _, newValue in + let replaced = newValue.replacing(",", with: ".") + if replaced != newValue { + text = replaced + } } - .onAppear { + .onChange(of: isFocused) { _, focused in + if focused { + // Sync text from view model when gaining focus text = viewModel.formatFrequencyForEditing(viewModel.frequencyMHz) + } else { + // Commit when focus is lost + commitEdit() + } } - } - private func commitEdit() { - guard let parsed = viewModel.parseFrequency(text) else { - // Reject empty, unparseable, or non-positive input by restoring - // the last valid value rather than passing it to analysis. - text = viewModel.formatFrequencyForEditing(viewModel.frequencyMHz) - return + Text(L10n.Tools.Tools.LineOfSight.mhz) + .foregroundStyle(.secondary) + + if isFocused { + Button { + commitEdit() + isFocused = false + } label: { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.title2) } - viewModel.frequencyMHz = parsed - viewModel.commitFrequencyChange() + .buttonStyle(.plain) + } + } + .onAppear { + text = viewModel.formatFrequencyForEditing(viewModel.frequencyMHz) + } + } + + private func commitEdit() { + guard let parsed = viewModel.parseFrequency(text) else { + // Reject empty, unparseable, or non-positive input by restoring + // the last valid value rather than passing it to analysis. + text = viewModel.formatFrequencyForEditing(viewModel.frequencyMHz) + return } + viewModel.frequencyMHz = parsed + viewModel.commitFrequencyChange() + } } // MARK: - Analyze Button private struct AnalyzeButton: View { - var viewModel: LineOfSightViewModel - let hasAnalysisResult: Bool - let onAnalyze: () -> Void - - var body: some View { - Button { - viewModel.shouldAutoZoomOnNextResult = true - onAnalyze() - if viewModel.repeaterPoint != nil { - viewModel.analyzeWithRepeater() - } else { - viewModel.analyze() - } - } label: { - if viewModel.isAnalyzing { - HStack { - ProgressView() - .controlSize(.small) - Text(L10n.Tools.Tools.LineOfSight.analyzing) - } - .frame(maxWidth: .infinity) - } else { - Label(L10n.Tools.Tools.LineOfSight.analyze, systemImage: "waveform.path") - .frame(maxWidth: .infinity) - } + var viewModel: LineOfSightViewModel + let hasAnalysisResult: Bool + let onAnalyze: () -> Void + + var body: some View { + Button { + viewModel.shouldAutoZoomOnNextResult = true + onAnalyze() + if viewModel.repeaterPoint != nil { + viewModel.analyzeWithRepeater() + } else { + viewModel.analyze() + } + } label: { + if viewModel.isAnalyzing { + HStack { + ProgressView() + .controlSize(.small) + Text(L10n.Tools.Tools.LineOfSight.analyzing) } - .liquidGlassProminentButtonStyle() - .controlSize(.large) - .disabled(viewModel.isAnalyzing) + .frame(maxWidth: .infinity) + } else { + Label(L10n.Tools.Tools.LineOfSight.analyze, systemImage: "waveform.path") + .frame(maxWidth: .infinity) + } } + .liquidGlassProminentButtonStyle() + .controlSize(.large) + .disabled(viewModel.isAnalyzing) + } } // MARK: - Preview #Preview("Empty") { - LineOfSightView() - .environment(\.appState, AppState()) + LineOfSightView() + .environment(\.appState, AppState()) } #Preview("With Contact") { - let contact = ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x01, count: 32), - name: "Test Contact", - typeRawValue: 0, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 37.7749, - longitude: -122.4194, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - - LineOfSightView(preselectedContact: contact) - .environment(\.appState, AppState()) + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x01, count: 32), + name: "Test Contact", + typeRawValue: 0, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 37.7749, + longitude: -122.4194, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + + LineOfSightView(preselectedContact: contact) + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/Tools/LineOfSight/LineOfSightViewModel.swift index aa3c1c44..e2474629 100644 --- a/MC1/Views/Tools/LineOfSight/LineOfSightViewModel.swift +++ b/MC1/Views/Tools/LineOfSight/LineOfSightViewModel.swift @@ -1,8 +1,8 @@ import CoreLocation import MapKit import MC1Services -import SwiftUI import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "LineOfSight") @@ -10,15 +10,17 @@ private let logger = Logger(subsystem: "com.mc1", category: "LineOfSight") /// Identifies which point for editing in the UI enum PointID: Hashable { - case pointA - case pointB - case repeater + case pointA + case pointB + case repeater } // MARK: - PointID Identifiable Conformance extension PointID: Identifiable { - var id: Self { self } + var id: Self { + self + } } // MARK: - Repeater Point @@ -26,95 +28,95 @@ extension PointID: Identifiable { /// A repeater point for relay analysis. /// Can be on-path (from slider, uses cached profile) or off-path (relocated, needs fresh profiles). struct RepeaterPoint: Equatable { - /// The repeater's location - var coordinate: CLLocationCoordinate2D - - /// Ground elevation at coordinate (fetched async) - var groundElevation: Double? - - /// Additional height above ground in meters - var additionalHeight: Double - - /// True if repeater is on the A-B path (from slider), false if relocated off-path - var isOnPath: Bool - - /// Path fraction (only meaningful when isOnPath is true) - /// Used for terrain profile slider positioning - var pathFraction: Double { - didSet { - let clamped = pathFraction.clamped(to: 0.05...0.95) - if clamped != pathFraction { pathFraction = clamped } - } - } - - init(coordinate: CLLocationCoordinate2D, groundElevation: Double? = nil, additionalHeight: Double = 10, isOnPath: Bool = true, pathFraction: Double = 0.5) { - self.coordinate = coordinate - self.groundElevation = groundElevation - self.additionalHeight = additionalHeight - self.isOnPath = isOnPath - self.pathFraction = pathFraction.clamped(to: 0.05...0.95) - } - - static func == (lhs: RepeaterPoint, rhs: RepeaterPoint) -> Bool { - lhs.coordinate.latitude == rhs.coordinate.latitude && - lhs.coordinate.longitude == rhs.coordinate.longitude && - lhs.groundElevation == rhs.groundElevation && - lhs.additionalHeight == rhs.additionalHeight && - lhs.isOnPath == rhs.isOnPath && - lhs.pathFraction == rhs.pathFraction - } + /// The repeater's location + var coordinate: CLLocationCoordinate2D + + /// Ground elevation at coordinate (fetched async) + var groundElevation: Double? + + /// Additional height above ground in meters + var additionalHeight: Double + + /// True if repeater is on the A-B path (from slider), false if relocated off-path + var isOnPath: Bool + + /// Path fraction (only meaningful when isOnPath is true) + /// Used for terrain profile slider positioning + var pathFraction: Double { + didSet { + let clamped = pathFraction.clamped(to: 0.05...0.95) + if clamped != pathFraction { pathFraction = clamped } + } + } + + init(coordinate: CLLocationCoordinate2D, groundElevation: Double? = nil, additionalHeight: Double = 10, isOnPath: Bool = true, pathFraction: Double = 0.5) { + self.coordinate = coordinate + self.groundElevation = groundElevation + self.additionalHeight = additionalHeight + self.isOnPath = isOnPath + self.pathFraction = pathFraction.clamped(to: 0.05...0.95) + } + + static func == (lhs: RepeaterPoint, rhs: RepeaterPoint) -> Bool { + lhs.coordinate.latitude == rhs.coordinate.latitude && + lhs.coordinate.longitude == rhs.coordinate.longitude && + lhs.groundElevation == rhs.groundElevation && + lhs.additionalHeight == rhs.additionalHeight && + lhs.isOnPath == rhs.isOnPath && + lhs.pathFraction == rhs.pathFraction + } } private extension Comparable { - func clamped(to range: ClosedRange) -> Self { - min(max(self, range.lowerBound), range.upperBound) - } + func clamped(to range: ClosedRange) -> Self { + min(max(self, range.lowerBound), range.upperBound) + } } // MARK: - Selected Point /// A selected point for line of sight analysis struct SelectedPoint: Identifiable, Equatable { - let id = UUID() - let coordinate: CLLocationCoordinate2D - let contact: ContactDTO? - var groundElevation: Double? - var additionalHeight: Double = 7 - - var totalHeight: Double? { - groundElevation.map { $0 + additionalHeight } - } - - var displayName: String { - contact?.displayName ?? L10n.Tools.Tools.LineOfSight.droppedPin - } - - var isLoadingElevation: Bool { - groundElevation == nil - } - - static func == (lhs: SelectedPoint, rhs: SelectedPoint) -> Bool { - lhs.id == rhs.id && - lhs.additionalHeight == rhs.additionalHeight && - lhs.groundElevation == rhs.groundElevation - } + let id = UUID() + let coordinate: CLLocationCoordinate2D + let contact: ContactDTO? + var groundElevation: Double? + var additionalHeight: Double = 7 + + var totalHeight: Double? { + groundElevation.map { $0 + additionalHeight } + } + + var displayName: String { + contact?.displayName ?? L10n.Tools.Tools.LineOfSight.droppedPin + } + + var isLoadingElevation: Bool { + groundElevation == nil + } + + static func == (lhs: SelectedPoint, rhs: SelectedPoint) -> Bool { + lhs.id == rhs.id && + lhs.additionalHeight == rhs.additionalHeight && + lhs.groundElevation == rhs.groundElevation + } } // MARK: - Analysis Status /// Current status of path analysis enum AnalysisStatus: Equatable { - case idle - case result(PathAnalysisResult) - case relayResult(RelayPathAnalysisResult) - case error(String) + case idle + case result(PathAnalysisResult) + case relayResult(RelayPathAnalysisResult) + case error(String) } // MARK: - Repeater Selection Info /// Pre-computed selection state for a repeater annotation struct LOSRepeaterSelectionInfo { - let selectedAs: PointID? + let selectedAs: PointID? } // MARK: - View Model @@ -122,1117 +124,1125 @@ struct LOSRepeaterSelectionInfo { @Observable @MainActor final class LineOfSightViewModel { + // MARK: - Stable Map IDs - // MARK: - Stable Map IDs - - let pointAMapID = UUID() - let pointBMapID = UUID() - let repeaterTargetMapID = UUID() - - // MARK: - Point Selection State - - var pointA: SelectedPoint? { - didSet { rebuildSelectionState() } - } - var pointB: SelectedPoint? { - didSet { rebuildSelectionState() } - } - var relocatingPoint: PointID? { - didSet { rebuildMapLines() } - } - var shouldAutoZoomOnNextResult = false - - // MARK: - Camera State (MKMapView) - - var cameraRegion: MKCoordinateRegion? - private(set) var cameraRegionVersion = 0 - - // MARK: - RF Parameters - - /// Operating frequency in MHz - call `commitFrequencyChange()` after editing - var frequencyMHz: Double = 906.0 - - /// Refraction k-factor - auto-triggers re-analysis on change - var refractionK: Double = 1.0 { - didSet { - if oldValue != refractionK { - reanalyzeWithCachedProfileIfNeeded() - } - } - } - - /// Commits frequency change and triggers re-analysis with cached profile - func commitFrequencyChange() { - reanalyzeWithCachedProfileIfNeeded() - } - - // MARK: - Frequency Parsing - - /// Parses a user-entered frequency string into a positive MHz value. - /// - /// Accepts both "." and "," as the decimal separator so comma-decimal - /// locales round-trip, and parses with a fixed separator rather than the - /// current locale's. Returns nil for empty, unparseable, or non-positive - /// input so callers can ignore it instead of feeding analysis a bad value. - func parseFrequency(_ text: String) -> Double? { - let normalized = text.replacing(",", with: ".") - guard let value = Double(normalized), value > 0 else { return nil } - return value - } - - /// Renders a frequency value for editing using a locale-stable format, - /// so the displayed text always round-trips back through `parseFrequency`. - func formatFrequencyForEditing(_ value: Double) -> String { - if value.truncatingRemainder(dividingBy: 1) == 0 { - return String(Int(value)) - } - return String(format: "%.1f", value) - } - - // MARK: - Map Display State - - var isNorthLocked = false - var showLabels: Bool = true { - didSet { rebuildMapPoints() } - } - private(set) var mapPoints: [MapPoint] = [] - private(set) var mapLines: [MapLine] = [] - - // MARK: - Repeaters State - - private(set) var repeatersWithLocation: [ContactDTO] = [] { - didSet { rebuildSelectionState() } - } - - // MARK: - Repeater State - - /// The active repeater point (nil when not in use) - var repeaterPoint: RepeaterPoint? { - didSet { - rebuildMapPoints() - rebuildMapLines() - } - } - - /// Whether repeater row should be visible (analysis shows marginal or worse) - var shouldShowRepeaterRow: Bool { - // Always show if repeater exists (even after relocation clears results) - if repeaterPoint != nil { - return true - } - // Show placeholder when direct analysis is marginal or worse - return shouldShowRepeaterPlaceholder - } - - /// Whether to show the "Add Repeater" placeholder (no repeater exists, but analysis suggests one would help) - var shouldShowRepeaterPlaceholder: Bool { - guard case .result(let result) = analysisStatus else { - return false - } - // Only show if there are obstruction points (required by addRepeater()) - return result.clearanceStatus != .clear && !result.obstructionPoints.isEmpty - } - - /// Ground elevation at repeater position. - /// For on-path: interpolated from cached A→B profile. - /// For off-path: uses the fetched elevation stored in repeaterPoint. - var repeaterGroundElevation: Double? { - guard let repeaterPoint else { return nil } - if repeaterPoint.isOnPath { - return elevationAt(pathFraction: repeaterPoint.pathFraction) - } else { - return repeaterPoint.groundElevation - } - } - - /// Path fraction for repeater visualization in terrain profile - /// For on-path: uses pathFraction directly - /// For off-path: computes from A→R distance / total distance - var repeaterVisualizationPathFraction: Double? { - guard let repeaterPoint else { return nil } - if repeaterPoint.isOnPath { - return repeaterPoint.pathFraction - } - // For off-path, compute from stored profiles - guard let arLast = elevationProfileAR.last, - let rbLast = elevationProfileRB.last, - rbLast.distanceFromAMeters > 0 else { return nil } - return arLast.distanceFromAMeters / rbLast.distanceFromAMeters - } - - /// Segment A→R distance in meters (nil when on-path or no repeater) - var segmentARDistanceMeters: Double? { - guard let repeaterPoint, !repeaterPoint.isOnPath else { return nil } - guard case .relayResult(let result) = analysisStatus else { return nil } - return result.segmentAR.distanceMeters - } - - /// Segment R→B distance in meters (nil when on-path or no repeater) - var segmentRBDistanceMeters: Double? { - guard let repeaterPoint, !repeaterPoint.isOnPath else { return nil } - guard case .relayResult(let result) = analysisStatus else { return nil } - return result.segmentRB.distanceMeters - } - - // MARK: - Analysis State - - private(set) var analysisStatus: AnalysisStatus = .idle { - didSet { - rebuildMapPoints() - rebuildMapLines() - } - } - private(set) var isAnalyzing = false - private(set) var elevationProfile: [ElevationSample] = [] - - /// Profile samples for primary segment (A→B or A→R when repeater active) - private(set) var profileSamples: [ProfileSample] = [] - - /// Profile samples for R→B segment (empty when no repeater) - private(set) var profileSamplesRB: [ProfileSample] = [] - - /// Elevation profile A→R for off-path repeater - private(set) var elevationProfileAR: [ElevationSample] = [] - - /// Elevation profile R→B for off-path repeater - private(set) var elevationProfileRB: [ElevationSample] = [] - - /// Tracks whether any point elevation fetch failed (using sea level fallback) - private(set) var elevationFetchFailed = false - - // MARK: - Task Management - - private var analysisTask: Task? - private var pointAElevationTask: Task? - private var pointBElevationTask: Task? - private var repeaterElevationTask: Task? - - isolated deinit { - analysisTask?.cancel() - pointAElevationTask?.cancel() - pointBElevationTask?.cancel() - repeaterElevationTask?.cancel() - } - - // MARK: - Dependencies - - private let elevationService: ElevationServiceProtocol - private var dataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } - private var radioIDProvider: @MainActor () -> UUID? = { nil } - - private var dataStore: (any PersistenceStoreProtocol)? { dataStoreProvider() } - private var radioID: UUID? { radioIDProvider() } + let pointAMapID = UUID() + let pointBMapID = UUID() + let repeaterTargetMapID = UUID() - // MARK: - Computed Properties - - var canAnalyze: Bool { - pointA?.groundElevation != nil && pointB?.groundElevation != nil - } - - /// Pre-computed selection state for all repeaters. Rebuilt via `rebuildSelectionState()`. - private(set) var selectionState: [UUID: LOSRepeaterSelectionInfo] = [:] - - private func rebuildSelectionState() { - var result = [UUID: LOSRepeaterSelectionInfo]() - result.reserveCapacity(repeatersWithLocation.count) - - let pointAContactID = pointA?.contact?.id - let pointBContactID = pointB?.contact?.id - - for contact in repeatersWithLocation { - let selectedAs: PointID? - if contact.id == pointAContactID { - selectedAs = .pointA - } else if contact.id == pointBContactID { - selectedAs = .pointB - } else { - selectedAs = nil - } - result[contact.id] = LOSRepeaterSelectionInfo(selectedAs: selectedAs) - } - selectionState = result - rebuildMapPoints() - rebuildMapLines() - } + // MARK: - Point Selection State - // MARK: - Map Data Rebuild - - func rebuildMapPoints() { - var points: [MapPoint] = [] - - for repeater in repeatersWithLocation { - let selectedAs = selectionState[repeater.id]?.selectedAs - let style: MapPoint.PinStyle = switch selectedAs { - case .pointA: .repeaterRingBlue - case .pointB: .repeaterRingGreen - case .repeater, nil: .repeater - } - points.append(MapPoint( - id: repeater.id, - coordinate: repeater.coordinate, - pinStyle: style, - label: showLabels ? repeater.displayName : nil, - isClusterable: selectedAs == nil, - hopIndex: nil, - badgeText: nil - )) - } + var pointA: SelectedPoint? { + didSet { rebuildSelectionState() } + } - if let pointA, pointA.contact == nil { - points.append(MapPoint( - id: pointAMapID, - coordinate: pointA.coordinate, - pinStyle: .pointA, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } + var pointB: SelectedPoint? { + didSet { rebuildSelectionState() } + } - if let pointB, pointB.contact == nil { - points.append(MapPoint( - id: pointBMapID, - coordinate: pointB.coordinate, - pinStyle: .pointB, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } + var relocatingPoint: PointID? { + didSet { rebuildMapLines() } + } - if let target = repeaterPoint { - points.append(MapPoint( - id: repeaterTargetMapID, - coordinate: target.coordinate, - pinStyle: .crosshair, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } + var shouldAutoZoomOnNextResult = false - if repeaterPoint == nil, - case .result(let result) = analysisStatus, - result.clearanceStatus != .clear { - for obstruction in result.peakObstructionPerRegion { - let pathFraction = obstruction.distanceFromAMeters / result.distanceMeters - if let coordinate = coordinateAt(pathFraction: pathFraction) { - points.append(MapPoint( - id: obstruction.id, - coordinate: coordinate, - pinStyle: .obstruction, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } - } - } + // MARK: - Camera State (MKMapView) - mapPoints = points - } + var cameraRegion: MKCoordinateRegion? + private(set) var cameraRegionVersion = 0 - func rebuildMapLines() { - guard let a = pointA?.coordinate, - let b = pointB?.coordinate else { - mapLines = [] - return - } + // MARK: - RF Parameters - let activeOpacity = 0.7 - let dimOpacity = 0.3 - - if let r = repeaterPoint?.coordinate { - let opacityAR = relocatingPoint == .pointA ? dimOpacity : activeOpacity - let opacityRB = relocatingPoint == .pointB ? dimOpacity : activeOpacity - mapLines = [ - MapLine(id: "los-ar", coordinates: [a, r], - style: .los, opacity: relocatingPoint == .repeater ? dimOpacity : opacityAR), - MapLine(id: "los-rb", coordinates: [r, b], - style: .los, opacity: relocatingPoint == .repeater ? dimOpacity : opacityRB) - ] - } else { - let opacity = relocatingPoint != nil ? dimOpacity : activeOpacity - mapLines = [MapLine(id: "los-ab", coordinates: [a, b], style: .los, opacity: opacity)] - } - } - - // MARK: - Camera Methods - - func centerOnAllRepeaters() { - let coordinates = repeatersWithLocation.map(\.coordinate) - setCameraRegion(fitting: coordinates) - } - - func zoomToShowBothPoints() { - guard let pointA, let pointB else { return } - setCameraRegion(fitting: [pointA.coordinate, pointB.coordinate]) - } - - func setCameraRegion(_ region: MKCoordinateRegion) { - cameraRegion = region - cameraRegionVersion += 1 - } - - private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { - guard let region = coordinates.boundingRegion() else { return } - setCameraRegion(region) - } - - /// Returns the elevation profile to display in terrain visualization. - /// For on-path or no repeater: returns cached A-B profile. - /// For off-path: returns concatenated A→R→B profiles. - var terrainElevationProfile: [ElevationSample] { - guard let repeaterPoint, !repeaterPoint.isOnPath else { - return elevationProfile - } - - // For off-path repeater, concatenate A→R and R→B profiles - // Note: elevationProfileRB already has distances adjusted (offset by AR distance) - // from analyzeWithRepeaterOffPath(), so we use it directly without re-adjusting - guard !elevationProfileAR.isEmpty, !elevationProfileRB.isEmpty else { - return elevationProfile // Fallback if off-path profiles not yet loaded - } - - // dropFirst() removes the duplicate point at R (already in elevationProfileAR) - return elevationProfileAR + Array(elevationProfileRB.dropFirst()) - } - - // MARK: - Initialization - - init(elevationService: ElevationServiceProtocol = ElevationService()) { - self.elevationService = elevationService - } - - convenience init(preselectedContact: ContactDTO?) { - self.init() - if let contact = preselectedContact, contact.hasLocation { - let coordinate = CLLocationCoordinate2D( - latitude: contact.latitude, - longitude: contact.longitude - ) - setPointA(coordinate: coordinate, contact: contact) - } - } - - // MARK: - Configuration - - /// Configure with the data store and radio this view model uses; a provider returning nil mirrors a disconnected state. - /// Pass the connected device's frequency in kHz as a one-shot seed for the analysis frequency field. - func configure( - dataStore: @escaping @MainActor () -> (any PersistenceStoreProtocol)?, - radioID: @escaping @MainActor () -> UUID?, - deviceFrequencyKHz: UInt32? = nil - ) { - dataStoreProvider = dataStore - radioIDProvider = radioID - - // Device frequency is stored in kHz; analysis works in MHz. - // Treated as a one-shot seed: the user edits frequencyMHz independently after this. - if let deviceFrequencyKHz { - self.frequencyMHz = Double(deviceFrequencyKHz) / 1000.0 - } - } - - // MARK: - Load Repeaters - - func loadRepeaters() async { - guard let dataStore, let radioID else { return } - - do { - let allContacts = try await dataStore.fetchContacts(radioID: radioID) - repeatersWithLocation = allContacts.filter { $0.hasLocation && $0.type == .repeater } - } catch { - logger.error("Failed to load repeaters: \(error.localizedDescription)") - } - } + /// Operating frequency in MHz - call `commitFrequencyChange()` after editing + var frequencyMHz: Double = 906.0 - // MARK: - Point Selection - - /// Auto-assigns coordinate to A if empty, then B if A exists - func selectPoint(at coordinate: CLLocationCoordinate2D, from contact: ContactDTO? = nil) { - if pointA == nil { - setPointA(coordinate: coordinate, contact: contact) - } else if pointB == nil { - setPointB(coordinate: coordinate, contact: contact) - } else { - // Both points set, replace B - setPointB(coordinate: coordinate, contact: contact) - } - } - - func setPointA(coordinate: CLLocationCoordinate2D, contact: ContactDTO? = nil) { - // Cancel any pending elevation fetch for point A - pointAElevationTask?.cancel() - pointAElevationTask = nil - - // Reset analysis when points change - invalidateAnalysis() - - pointA = SelectedPoint( - coordinate: coordinate, - contact: contact, - groundElevation: nil - ) - - // Fetch elevation asynchronously - pointAElevationTask = Task { - await fetchElevationForPointA() - } - } - - func setPointB(coordinate: CLLocationCoordinate2D, contact: ContactDTO? = nil) { - // Check if B is same location as A - if let pointA = pointA, - pointA.coordinate.latitude == coordinate.latitude, - pointA.coordinate.longitude == coordinate.longitude { - logger.warning("Cannot set point B to same location as point A") - return - } - - // Cancel any pending elevation fetch for point B - pointBElevationTask?.cancel() - pointBElevationTask = nil - - // Reset analysis when points change - invalidateAnalysis() - - pointB = SelectedPoint( + /// Refraction k-factor - auto-triggers re-analysis on change + var refractionK: Double = 1.0 { + didSet { + if oldValue != refractionK { + reanalyzeWithCachedProfileIfNeeded() + } + } + } + + /// Commits frequency change and triggers re-analysis with cached profile + func commitFrequencyChange() { + reanalyzeWithCachedProfileIfNeeded() + } + + // MARK: - Frequency Parsing + + /// Parses a user-entered frequency string into a positive MHz value. + /// + /// Accepts both "." and "," as the decimal separator so comma-decimal + /// locales round-trip, and parses with a fixed separator rather than the + /// current locale's. Returns nil for empty, unparseable, or non-positive + /// input so callers can ignore it instead of feeding analysis a bad value. + func parseFrequency(_ text: String) -> Double? { + let normalized = text.replacing(",", with: ".") + guard let value = Double(normalized), value > 0 else { return nil } + return value + } + + /// Renders a frequency value for editing using a locale-stable format, + /// so the displayed text always round-trips back through `parseFrequency`. + func formatFrequencyForEditing(_ value: Double) -> String { + if value.truncatingRemainder(dividingBy: 1) == 0 { + return String(Int(value)) + } + return String(format: "%.1f", value) + } + + // MARK: - Map Display State + + var isNorthLocked = false + var showLabels: Bool = true { + didSet { rebuildMapPoints() } + } + + private(set) var mapPoints: [MapPoint] = [] + private(set) var mapLines: [MapLine] = [] + + // MARK: - Repeaters State + + private(set) var repeatersWithLocation: [ContactDTO] = [] { + didSet { rebuildSelectionState() } + } + + // MARK: - Repeater State + + /// The active repeater point (nil when not in use) + var repeaterPoint: RepeaterPoint? { + didSet { + rebuildMapPoints() + rebuildMapLines() + } + } + + /// Whether repeater row should be visible (analysis shows marginal or worse) + var shouldShowRepeaterRow: Bool { + // Always show if repeater exists (even after relocation clears results) + if repeaterPoint != nil { + return true + } + // Show placeholder when direct analysis is marginal or worse + return shouldShowRepeaterPlaceholder + } + + /// Whether to show the "Add Repeater" placeholder (no repeater exists, but analysis suggests one would help) + var shouldShowRepeaterPlaceholder: Bool { + guard case let .result(result) = analysisStatus else { + return false + } + // Only show if there are obstruction points (required by addRepeater()) + return result.clearanceStatus != .clear && !result.obstructionPoints.isEmpty + } + + /// Ground elevation at repeater position. + /// For on-path: interpolated from cached A→B profile. + /// For off-path: uses the fetched elevation stored in repeaterPoint. + var repeaterGroundElevation: Double? { + guard let repeaterPoint else { return nil } + if repeaterPoint.isOnPath { + return elevationAt(pathFraction: repeaterPoint.pathFraction) + } else { + return repeaterPoint.groundElevation + } + } + + /// Path fraction for repeater visualization in terrain profile + /// For on-path: uses pathFraction directly + /// For off-path: computes from A→R distance / total distance + var repeaterVisualizationPathFraction: Double? { + guard let repeaterPoint else { return nil } + if repeaterPoint.isOnPath { + return repeaterPoint.pathFraction + } + // For off-path, compute from stored profiles + guard let arLast = elevationProfileAR.last, + let rbLast = elevationProfileRB.last, + rbLast.distanceFromAMeters > 0 else { return nil } + return arLast.distanceFromAMeters / rbLast.distanceFromAMeters + } + + /// Segment A→R distance in meters (nil when on-path or no repeater) + var segmentARDistanceMeters: Double? { + guard let repeaterPoint, !repeaterPoint.isOnPath else { return nil } + guard case let .relayResult(result) = analysisStatus else { return nil } + return result.segmentAR.distanceMeters + } + + /// Segment R→B distance in meters (nil when on-path or no repeater) + var segmentRBDistanceMeters: Double? { + guard let repeaterPoint, !repeaterPoint.isOnPath else { return nil } + guard case let .relayResult(result) = analysisStatus else { return nil } + return result.segmentRB.distanceMeters + } + + // MARK: - Analysis State + + private(set) var analysisStatus: AnalysisStatus = .idle { + didSet { + rebuildMapPoints() + rebuildMapLines() + } + } + + private(set) var isAnalyzing = false + private(set) var elevationProfile: [ElevationSample] = [] + + /// Profile samples for primary segment (A→B or A→R when repeater active) + private(set) var profileSamples: [ProfileSample] = [] + + /// Profile samples for R→B segment (empty when no repeater) + private(set) var profileSamplesRB: [ProfileSample] = [] + + /// Elevation profile A→R for off-path repeater + private(set) var elevationProfileAR: [ElevationSample] = [] + + /// Elevation profile R→B for off-path repeater + private(set) var elevationProfileRB: [ElevationSample] = [] + + /// Tracks whether any point elevation fetch failed (using sea level fallback) + private(set) var elevationFetchFailed = false + + // MARK: - Task Management + + private var analysisTask: Task? + private var pointAElevationTask: Task? + private var pointBElevationTask: Task? + private var repeaterElevationTask: Task? + + isolated deinit { + analysisTask?.cancel() + pointAElevationTask?.cancel() + pointBElevationTask?.cancel() + repeaterElevationTask?.cancel() + } + + // MARK: - Dependencies + + private let elevationService: ElevationServiceProtocol + private var dataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } + private var radioIDProvider: @MainActor () -> UUID? = { nil } + + private var dataStore: (any PersistenceStoreProtocol)? { + dataStoreProvider() + } + + private var radioID: UUID? { + radioIDProvider() + } + + // MARK: - Computed Properties + + var canAnalyze: Bool { + pointA?.groundElevation != nil && pointB?.groundElevation != nil + } + + /// Pre-computed selection state for all repeaters. Rebuilt via `rebuildSelectionState()`. + private(set) var selectionState: [UUID: LOSRepeaterSelectionInfo] = [:] + + private func rebuildSelectionState() { + var result = [UUID: LOSRepeaterSelectionInfo]() + result.reserveCapacity(repeatersWithLocation.count) + + let pointAContactID = pointA?.contact?.id + let pointBContactID = pointB?.contact?.id + + for contact in repeatersWithLocation { + let selectedAs: PointID? = if contact.id == pointAContactID { + .pointA + } else if contact.id == pointBContactID { + .pointB + } else { + nil + } + result[contact.id] = LOSRepeaterSelectionInfo(selectedAs: selectedAs) + } + selectionState = result + rebuildMapPoints() + rebuildMapLines() + } + + // MARK: - Map Data Rebuild + + func rebuildMapPoints() { + var points: [MapPoint] = [] + + for repeater in repeatersWithLocation { + let selectedAs = selectionState[repeater.id]?.selectedAs + let style: MapPoint.PinStyle = switch selectedAs { + case .pointA: .repeaterRingBlue + case .pointB: .repeaterRingGreen + case .repeater, nil: .repeater + } + points.append(MapPoint( + id: repeater.id, + coordinate: repeater.coordinate, + pinStyle: style, + label: showLabels ? repeater.displayName : nil, + isClusterable: selectedAs == nil, + hopIndex: nil, + badgeText: nil + )) + } + + if let pointA, pointA.contact == nil { + points.append(MapPoint( + id: pointAMapID, + coordinate: pointA.coordinate, + pinStyle: .pointA, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if let pointB, pointB.contact == nil { + points.append(MapPoint( + id: pointBMapID, + coordinate: pointB.coordinate, + pinStyle: .pointB, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if let target = repeaterPoint { + points.append(MapPoint( + id: repeaterTargetMapID, + coordinate: target.coordinate, + pinStyle: .crosshair, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if repeaterPoint == nil, + case let .result(result) = analysisStatus, + result.clearanceStatus != .clear { + for obstruction in result.peakObstructionPerRegion { + let pathFraction = obstruction.distanceFromAMeters / result.distanceMeters + if let coordinate = coordinateAt(pathFraction: pathFraction) { + points.append(MapPoint( + id: obstruction.id, coordinate: coordinate, - contact: contact, - groundElevation: nil - ) - - // Fetch elevation asynchronously - pointBElevationTask = Task { - await fetchElevationForPointB() + pinStyle: .obstruction, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) } - } + } + } + + mapPoints = points + } + + func rebuildMapLines() { + guard let a = pointA?.coordinate, + let b = pointB?.coordinate else { + mapLines = [] + return + } - // MARK: - Height Adjustment - - func updateAdditionalHeight(for point: PointID, meters: Double) { - let clampedHeight = max(0.0, meters) - - switch point { - case .pointA: - guard pointA != nil else { return } - pointA?.additionalHeight = clampedHeight - case .pointB: - guard pointB != nil else { return } - pointB?.additionalHeight = clampedHeight - case .repeater: - // Repeater height is handled separately via updateRepeaterHeight - updateRepeaterHeight(meters: clampedHeight) - return - } + let activeOpacity = 0.7 + let dimOpacity = 0.3 + + if let r = repeaterPoint?.coordinate { + let opacityAR = relocatingPoint == .pointA ? dimOpacity : activeOpacity + let opacityRB = relocatingPoint == .pointB ? dimOpacity : activeOpacity + mapLines = [ + MapLine(id: "los-ar", coordinates: [a, r], + style: .los, opacity: relocatingPoint == .repeater ? dimOpacity : opacityAR), + MapLine(id: "los-rb", coordinates: [r, b], + style: .los, opacity: relocatingPoint == .repeater ? dimOpacity : opacityRB) + ] + } else { + let opacity = relocatingPoint != nil ? dimOpacity : activeOpacity + mapLines = [MapLine(id: "los-ab", coordinates: [a, b], style: .los, opacity: opacity)] + } + } + + // MARK: - Camera Methods + + func centerOnAllRepeaters() { + let coordinates = repeatersWithLocation.map(\.coordinate) + setCameraRegion(fitting: coordinates) + } + + func zoomToShowBothPoints() { + guard let pointA, let pointB else { return } + setCameraRegion(fitting: [pointA.coordinate, pointB.coordinate]) + } - // Height change invalidates analysis - invalidateAnalysis() + func setCameraRegion(_ region: MKCoordinateRegion) { + cameraRegion = region + cameraRegionVersion += 1 + } + + private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { + guard let region = coordinates.boundingRegion() else { return } + setCameraRegion(region) + } + + /// Returns the elevation profile to display in terrain visualization. + /// For on-path or no repeater: returns cached A-B profile. + /// For off-path: returns concatenated A→R→B profiles. + var terrainElevationProfile: [ElevationSample] { + guard let repeaterPoint, !repeaterPoint.isOnPath else { + return elevationProfile + } + + // For off-path repeater, concatenate A→R and R→B profiles + // Note: elevationProfileRB already has distances adjusted (offset by AR distance) + // from analyzeWithRepeaterOffPath(), so we use it directly without re-adjusting + guard !elevationProfileAR.isEmpty, !elevationProfileRB.isEmpty else { + return elevationProfile // Fallback if off-path profiles not yet loaded + } + + // dropFirst() removes the duplicate point at R (already in elevationProfileAR) + return elevationProfileAR + Array(elevationProfileRB.dropFirst()) + } + + // MARK: - Initialization + + init(elevationService: ElevationServiceProtocol = ElevationService()) { + self.elevationService = elevationService + } + + convenience init(preselectedContact: ContactDTO?) { + self.init() + if let contact = preselectedContact, contact.hasLocation { + let coordinate = CLLocationCoordinate2D( + latitude: contact.latitude, + longitude: contact.longitude + ) + setPointA(coordinate: coordinate, contact: contact) } + } - // MARK: - Contact Toggle Selection - - /// Toggle a contact as a selected point - /// - If contact is already selected as A or B, clear that point - /// - Otherwise, auto-assign to A (if empty) or B - func toggleContact(_ contact: ContactDTO) { - let coordinate = contact.coordinate - - // Check if already selected as point A - if let pointA, pointA.contact?.id == contact.id { - clearPointA() - return - } - - // Check if already selected as point B - if let pointB, pointB.contact?.id == contact.id { - clearPointB() - return - } + // MARK: - Configuration - // Auto-assign using existing logic - selectPoint(at: coordinate, from: contact) + /// Configure with the data store and radio this view model uses; a provider returning nil mirrors a disconnected state. + /// Pass the connected device's frequency in kHz as a one-shot seed for the analysis frequency field. + func configure( + dataStore: @escaping @MainActor () -> (any PersistenceStoreProtocol)?, + radioID: @escaping @MainActor () -> UUID?, + deviceFrequencyKHz: UInt32? = nil + ) { + dataStoreProvider = dataStore + radioIDProvider = radioID + + // Device frequency is stored in kHz; analysis works in MHz. + // Treated as a one-shot seed: the user edits frequencyMHz independently after this. + if let deviceFrequencyKHz { + frequencyMHz = Double(deviceFrequencyKHz) / 1000.0 + } + } + + // MARK: - Load Repeaters + + func loadRepeaters() async { + guard let dataStore, let radioID else { return } + + do { + let allContacts = try await dataStore.fetchContacts(radioID: radioID) + repeatersWithLocation = allContacts.filter { $0.hasLocation && $0.type == .repeater } + } catch { + logger.error("Failed to load repeaters: \(error.localizedDescription)") + } + } + + // MARK: - Point Selection + + /// Auto-assigns coordinate to A if empty, then B if A exists + func selectPoint(at coordinate: CLLocationCoordinate2D, from contact: ContactDTO? = nil) { + if pointA == nil { + setPointA(coordinate: coordinate, contact: contact) + } else if pointB == nil { + setPointB(coordinate: coordinate, contact: contact) + } else { + // Both points set, replace B + setPointB(coordinate: coordinate, contact: contact) } + } + + func setPointA(coordinate: CLLocationCoordinate2D, contact: ContactDTO? = nil) { + // Cancel any pending elevation fetch for point A + pointAElevationTask?.cancel() + pointAElevationTask = nil + + // Reset analysis when points change + invalidateAnalysis() - // MARK: - Clear Methods - - func clear() { - pointAElevationTask?.cancel() - pointBElevationTask?.cancel() - analysisTask?.cancel() - repeaterElevationTask?.cancel() - - pointAElevationTask = nil - pointBElevationTask = nil - analysisTask = nil - repeaterElevationTask = nil - - pointA = nil - pointB = nil - repeaterPoint = nil - elevationFetchFailed = false + pointA = SelectedPoint( + coordinate: coordinate, + contact: contact, + groundElevation: nil + ) + + // Fetch elevation asynchronously + pointAElevationTask = Task { + await fetchElevationForPointA() + } + } + + func setPointB(coordinate: CLLocationCoordinate2D, contact: ContactDTO? = nil) { + // Check if B is same location as A + if let pointA, + pointA.coordinate.latitude == coordinate.latitude, + pointA.coordinate.longitude == coordinate.longitude { + logger.warning("Cannot set point B to same location as point A") + return + } + + // Cancel any pending elevation fetch for point B + pointBElevationTask?.cancel() + pointBElevationTask = nil + + // Reset analysis when points change + invalidateAnalysis() + + pointB = SelectedPoint( + coordinate: coordinate, + contact: contact, + groundElevation: nil + ) + + // Fetch elevation asynchronously + pointBElevationTask = Task { + await fetchElevationForPointB() + } + } + + // MARK: - Height Adjustment + + func updateAdditionalHeight(for point: PointID, meters: Double) { + let clampedHeight = max(0.0, meters) + + switch point { + case .pointA: + guard pointA != nil else { return } + pointA?.additionalHeight = clampedHeight + case .pointB: + guard pointB != nil else { return } + pointB?.additionalHeight = clampedHeight + case .repeater: + // Repeater height is handled separately via updateRepeaterHeight + updateRepeaterHeight(meters: clampedHeight) + return + } + + // Height change invalidates analysis + invalidateAnalysis() + } + + // MARK: - Contact Toggle Selection + + /// Toggle a contact as a selected point + /// - If contact is already selected as A or B, clear that point + /// - Otherwise, auto-assign to A (if empty) or B + func toggleContact(_ contact: ContactDTO) { + let coordinate = contact.coordinate + + // Check if already selected as point A + if let pointA, pointA.contact?.id == contact.id { + clearPointA() + return + } + + // Check if already selected as point B + if let pointB, pointB.contact?.id == contact.id { + clearPointB() + return + } + + // Auto-assign using existing logic + selectPoint(at: coordinate, from: contact) + } + + // MARK: - Clear Methods + + func clear() { + pointAElevationTask?.cancel() + pointBElevationTask?.cancel() + analysisTask?.cancel() + repeaterElevationTask?.cancel() + + pointAElevationTask = nil + pointBElevationTask = nil + analysisTask = nil + repeaterElevationTask = nil + + pointA = nil + pointB = nil + repeaterPoint = nil + elevationFetchFailed = false + isAnalyzing = false + analysisStatus = .idle + elevationProfile = [] + elevationProfileAR = [] + elevationProfileRB = [] + } + + func clearPointA() { + pointAElevationTask?.cancel() + pointAElevationTask = nil + + pointA = nil + repeaterPoint = nil + invalidateAnalysis() + } + + func clearPointB() { + pointBElevationTask?.cancel() + pointBElevationTask = nil + + pointB = nil + repeaterPoint = nil + invalidateAnalysis() + } + + // MARK: - Repeater Methods + + /// Adds repeater at the worst obstruction point + func addRepeater() { + guard case let .result(result) = analysisStatus, + let worstPoint = result.worstObstructionPoint else { + return + } + + let pathFraction = worstPoint.distanceFromAMeters / result.distanceMeters + + // Get coordinate and elevation from cached profile + guard let coordinate = coordinateAt(pathFraction: pathFraction), + let elevation = elevationAt(pathFraction: pathFraction) else { return } + + repeaterPoint = RepeaterPoint( + coordinate: coordinate, + groundElevation: elevation, + additionalHeight: 10, + isOnPath: true, + pathFraction: pathFraction + ) + } + + /// Updates repeater position along the path (for on-path repeaters) + func updateRepeaterPosition(pathFraction: Double) { + guard var repeater = repeaterPoint, repeater.isOnPath else { return } + + // Update path fraction and derive coordinate/elevation from cached profile + repeater.pathFraction = pathFraction + if let coordinate = coordinateAt(pathFraction: pathFraction) { + repeater.coordinate = coordinate + } + if let elevation = elevationAt(pathFraction: pathFraction) { + repeater.groundElevation = elevation + } + + repeaterPoint = repeater + } + + /// Updates repeater height above ground + func updateRepeaterHeight(meters: Double) { + guard repeaterPoint != nil else { return } + repeaterPoint?.additionalHeight = max(0.0, meters) + } + + /// Sets repeater to an off-path location + /// - Parameter coordinate: The new coordinate for the repeater + func setRepeaterOffPath(coordinate: CLLocationCoordinate2D) { + let existingHeight = repeaterPoint?.additionalHeight ?? 10 + + repeaterPoint = RepeaterPoint( + coordinate: coordinate, + groundElevation: nil, // Will be fetched + additionalHeight: existingHeight, + isOnPath: false, + pathFraction: 0.5 // Not used for off-path + ) + + // Invalidate cached off-path profiles (coordinates changed) + elevationProfileAR = [] + elevationProfileRB = [] + + // Fetch elevation for the new coordinate + repeaterElevationTask?.cancel() + repeaterElevationTask = Task { + do { + let elevation = try await elevationService.fetchElevation(at: coordinate) + guard !Task.isCancelled else { return } + repeaterPoint?.groundElevation = elevation + } catch { + guard !Task.isCancelled else { return } + logger.error("Failed to fetch repeater elevation: \(error.localizedDescription)") + } + } + } + + /// Removes repeater and reverts to single-path analysis + func clearRepeater() { + // Cancel any in-flight off-path elevation fetch so it can't write a stale + // coordinate's elevation into a repeater added after this clear. + repeaterElevationTask?.cancel() + repeaterElevationTask = nil + + repeaterPoint = nil + reanalyzeWithCachedProfileIfNeeded() + } + + /// Analyzes the path with the current repeater position + func analyzeWithRepeater() { + guard let repeaterPoint, + let pointA, + let pointB else { return } + + if repeaterPoint.isOnPath { + analyzeWithRepeaterOnPath() + } else if !elevationProfileAR.isEmpty, !elevationProfileRB.isEmpty { + // Cached profiles exist — no network fetch needed + applyRelayAnalysis( + profileAR: elevationProfileAR, + profileRB: elevationProfileRB, + pointAHeight: pointA.additionalHeight, + repeaterHeight: repeaterPoint.additionalHeight, + pointBHeight: pointB.additionalHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + } else { + analysisTask?.cancel() + analysisTask = Task { + await analyzeWithRepeaterOffPath() + } + } + } + + /// On-path analysis using cached elevation profile + private func analyzeWithRepeaterOnPath() { + guard let repeaterPoint, + let pointA, + let pointB, + elevationProfile.count >= 2 else { return } + + let pathFraction = repeaterPoint.pathFraction + + // Calculate split index + let splitIndex = Int(pathFraction * Double(elevationProfile.count - 1)) + guard splitIndex > 0, splitIndex < elevationProfile.count - 1 else { return } + + applyRelayAnalysis( + profileAR: Array(elevationProfile[0...splitIndex]), + profileRB: Array(elevationProfile[splitIndex...]), + pointAHeight: pointA.additionalHeight, + repeaterHeight: repeaterPoint.additionalHeight, + pointBHeight: pointB.additionalHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + } + + /// Off-path analysis - fetches A→R and R→B profiles + private func analyzeWithRepeaterOffPath() async { + guard let repeaterPoint, + let pointA, + let pointB else { return } + + isAnalyzing = true + + do { + let pointACoord = pointA.coordinate + let repeaterCoord = repeaterPoint.coordinate + let pointBCoord = pointB.coordinate + + // Fetch A→R profile + let distanceAR = RFCalculator.distance(from: pointACoord, to: repeaterCoord) + let sampleCountAR = ElevationService.optimalSampleCount(distanceMeters: distanceAR) + let sampleCoordsAR = ElevationService.sampleCoordinates( + from: pointACoord, + to: repeaterCoord, + sampleCount: sampleCountAR + ) + // Fetch R→B profile (computed before async let to avoid capture issues) + let distanceRB = RFCalculator.distance(from: repeaterCoord, to: pointBCoord) + let sampleCountRB = ElevationService.optimalSampleCount(distanceMeters: distanceRB) + let sampleCoordsRB = ElevationService.sampleCoordinates( + from: repeaterCoord, + to: pointBCoord, + sampleCount: sampleCountRB + ) + + // Fetch both elevation profiles in parallel + async let profileARTask = elevationService.fetchElevations(along: sampleCoordsAR) + async let profileRBTask = elevationService.fetchElevations(along: sampleCoordsRB) + let (profileAR, profileRB) = try await (profileARTask, profileRBTask) + + // A superseded task must not overwrite newer results + if Task.isCancelled { isAnalyzing = false - analysisStatus = .idle - elevationProfile = [] - elevationProfileAR = [] - elevationProfileRB = [] - } - - func clearPointA() { - pointAElevationTask?.cancel() - pointAElevationTask = nil - - pointA = nil - repeaterPoint = nil - invalidateAnalysis() - } - - func clearPointB() { - pointBElevationTask?.cancel() - pointBElevationTask = nil - - pointB = nil - repeaterPoint = nil - invalidateAnalysis() - } - - // MARK: - Repeater Methods - - /// Adds repeater at the worst obstruction point - func addRepeater() { - guard case .result(let result) = analysisStatus, - let worstPoint = result.worstObstructionPoint else { - return - } - - let pathFraction = worstPoint.distanceFromAMeters / result.distanceMeters - - // Get coordinate and elevation from cached profile - guard let coordinate = coordinateAt(pathFraction: pathFraction), - let elevation = elevationAt(pathFraction: pathFraction) else { return } - - repeaterPoint = RepeaterPoint( - coordinate: coordinate, - groundElevation: elevation, - additionalHeight: 10, - isOnPath: true, - pathFraction: pathFraction + return + } + + // Offset R→B profile distances to continue from A→R endpoint + // (fetchElevations returns distances relative to segment start, not global A) + let profileRBAdjusted = profileRB.map { sample in + ElevationSample( + coordinate: sample.coordinate, + elevation: sample.elevation, + distanceFromAMeters: sample.distanceFromAMeters + distanceAR ) - } - - /// Updates repeater position along the path (for on-path repeaters) - func updateRepeaterPosition(pathFraction: Double) { - guard var repeater = repeaterPoint, repeater.isOnPath else { return } - - // Update path fraction and derive coordinate/elevation from cached profile - repeater.pathFraction = pathFraction - if let coordinate = coordinateAt(pathFraction: pathFraction) { - repeater.coordinate = coordinate - } - if let elevation = elevationAt(pathFraction: pathFraction) { - repeater.groundElevation = elevation - } - - repeaterPoint = repeater - } - - /// Updates repeater height above ground - func updateRepeaterHeight(meters: Double) { - guard repeaterPoint != nil else { return } - repeaterPoint?.additionalHeight = max(0.0, meters) - } - - /// Sets repeater to an off-path location - /// - Parameter coordinate: The new coordinate for the repeater - func setRepeaterOffPath(coordinate: CLLocationCoordinate2D) { - let existingHeight = repeaterPoint?.additionalHeight ?? 10 - - repeaterPoint = RepeaterPoint( - coordinate: coordinate, - groundElevation: nil, // Will be fetched - additionalHeight: existingHeight, - isOnPath: false, - pathFraction: 0.5 // Not used for off-path + } + + applyRelayAnalysis( + profileAR: profileAR, + profileRB: profileRBAdjusted, + pointAHeight: pointA.additionalHeight, + repeaterHeight: repeaterPoint.additionalHeight, + pointBHeight: pointB.additionalHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + + // Store profiles for terrain visualization + elevationProfileAR = profileAR + elevationProfileRB = profileRBAdjusted + + isAnalyzing = false + + } catch { + // A cancelled fetch is not a user-facing analysis failure + if Task.isCancelled { return } + isAnalyzing = false + analysisStatus = .error(error.localizedDescription) + logger.error("Off-path analysis failed: \(error.localizedDescription)") + } + } + + /// Shared relay analysis: analyzes both segments and updates profile samples and status + private func applyRelayAnalysis( + profileAR: [ElevationSample], + profileRB: [ElevationSample], + pointAHeight: Double, + repeaterHeight: Double, + pointBHeight: Double, + frequencyMHz: Double, + refractionK: Double + ) { + let arResult = RFCalculator.analyzePathSegment( + elevationProfile: profileAR[...], + startHeightMeters: pointAHeight, + endHeightMeters: repeaterHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + + let rbResult = RFCalculator.analyzePathSegment( + elevationProfile: profileRB[...], + startHeightMeters: repeaterHeight, + endHeightMeters: pointBHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + + let relayResult = RelayPathAnalysisResult( + segmentAR: SegmentAnalysisResult( + startLabel: "A", + endLabel: "R", + clearanceStatus: arResult.clearanceStatus, + distanceMeters: arResult.distanceMeters, + worstClearancePercent: arResult.worstClearancePercent + ), + segmentRB: SegmentAnalysisResult( + startLabel: "R", + endLabel: "B", + clearanceStatus: rbResult.clearanceStatus, + distanceMeters: rbResult.distanceMeters, + worstClearancePercent: rbResult.worstClearancePercent + ) + ) + + profileSamples = FresnelZoneRenderer.buildProfileSamples( + from: profileAR, + pointAHeight: pointAHeight, + pointBHeight: repeaterHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + profileSamplesRB = FresnelZoneRenderer.buildProfileSamples( + from: profileRB, + pointAHeight: repeaterHeight, + pointBHeight: pointBHeight, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + + analysisStatus = .relayResult(relayResult) + } + + // MARK: - Analysis + + /// Clears analysis results without clearing points + func clearAnalysisResults() { + isAnalyzing = false + analysisStatus = .idle + shouldAutoZoomOnNextResult = false + } + + func analyze() { + guard let pointA, + let pointB, + pointA.groundElevation != nil, + pointB.groundElevation != nil else { + logger.warning("Cannot analyze: missing point elevations") + return + } + + // Cancel any existing analysis + analysisTask?.cancel() + + isAnalyzing = true + + // Capture values for use in task + let pointACoord = pointA.coordinate + let pointBCoord = pointB.coordinate + let pointAHeight = pointA.additionalHeight + let pointBHeight = pointB.additionalHeight + let freq = frequencyMHz + let k = refractionK + + analysisTask = Task { + do { + let distance = RFCalculator.distance(from: pointACoord, to: pointBCoord) + let sampleCount = ElevationService.optimalSampleCount(distanceMeters: distance) + let sampleCoordinates = ElevationService.sampleCoordinates( + from: pointACoord, + to: pointBCoord, + sampleCount: sampleCount ) + let profile = try await elevationService.fetchElevations(along: sampleCoordinates) + if Task.isCancelled { return } - // Invalidate cached off-path profiles (coordinates changed) - elevationProfileAR = [] - elevationProfileRB = [] - - // Fetch elevation for the new coordinate - repeaterElevationTask?.cancel() - repeaterElevationTask = Task { - do { - let elevation = try await elevationService.fetchElevation(at: coordinate) - guard !Task.isCancelled else { return } - repeaterPoint?.groundElevation = elevation - } catch { - guard !Task.isCancelled else { return } - logger.error("Failed to fetch repeater elevation: \(error.localizedDescription)") - } - } - } + let result = await Task.detached { + RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: pointAHeight, + pointBHeightMeters: pointBHeight, + frequencyMHz: freq, + refractionK: k + ) + }.value - /// Removes repeater and reverts to single-path analysis - func clearRepeater() { - // Cancel any in-flight off-path elevation fetch so it can't write a stale - // coordinate's elevation into a repeater added after this clear. - repeaterElevationTask?.cancel() - repeaterElevationTask = nil - - repeaterPoint = nil - reanalyzeWithCachedProfileIfNeeded() - } - - /// Analyzes the path with the current repeater position - func analyzeWithRepeater() { - guard let repeaterPoint, - let pointA, - let pointB else { return } - - if repeaterPoint.isOnPath { - analyzeWithRepeaterOnPath() - } else if !elevationProfileAR.isEmpty, !elevationProfileRB.isEmpty { - // Cached profiles exist — no network fetch needed - applyRelayAnalysis( - profileAR: elevationProfileAR, - profileRB: elevationProfileRB, - pointAHeight: pointA.additionalHeight, - repeaterHeight: repeaterPoint.additionalHeight, - pointBHeight: pointB.additionalHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - } else { - analysisTask?.cancel() - analysisTask = Task { - await analyzeWithRepeaterOffPath() - } - } - } - - /// On-path analysis using cached elevation profile - private func analyzeWithRepeaterOnPath() { - guard let repeaterPoint, - let pointA, - let pointB, - elevationProfile.count >= 2 else { return } - - let pathFraction = repeaterPoint.pathFraction - - // Calculate split index - let splitIndex = Int(pathFraction * Double(elevationProfile.count - 1)) - guard splitIndex > 0, splitIndex < elevationProfile.count - 1 else { return } - - applyRelayAnalysis( - profileAR: Array(elevationProfile[0...splitIndex]), - profileRB: Array(elevationProfile[splitIndex...]), - pointAHeight: pointA.additionalHeight, - repeaterHeight: repeaterPoint.additionalHeight, - pointBHeight: pointB.additionalHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - } - - /// Off-path analysis - fetches A→R and R→B profiles - private func analyzeWithRepeaterOffPath() async { - guard let repeaterPoint, - let pointA, - let pointB else { return } - - isAnalyzing = true - - do { - let pointACoord = pointA.coordinate - let repeaterCoord = repeaterPoint.coordinate - let pointBCoord = pointB.coordinate - - // Fetch A→R profile - let distanceAR = RFCalculator.distance(from: pointACoord, to: repeaterCoord) - let sampleCountAR = ElevationService.optimalSampleCount(distanceMeters: distanceAR) - let sampleCoordsAR = ElevationService.sampleCoordinates( - from: pointACoord, - to: repeaterCoord, - sampleCount: sampleCountAR - ) - // Fetch R→B profile (computed before async let to avoid capture issues) - let distanceRB = RFCalculator.distance(from: repeaterCoord, to: pointBCoord) - let sampleCountRB = ElevationService.optimalSampleCount(distanceMeters: distanceRB) - let sampleCoordsRB = ElevationService.sampleCoordinates( - from: repeaterCoord, - to: pointBCoord, - sampleCount: sampleCountRB - ) - - // Fetch both elevation profiles in parallel - async let profileARTask = elevationService.fetchElevations(along: sampleCoordsAR) - async let profileRBTask = elevationService.fetchElevations(along: sampleCoordsRB) - let (profileAR, profileRB) = try await (profileARTask, profileRBTask) - - // A superseded task must not overwrite newer results - if Task.isCancelled { - isAnalyzing = false - return - } - - // Offset R→B profile distances to continue from A→R endpoint - // (fetchElevations returns distances relative to segment start, not global A) - let profileRBAdjusted = profileRB.map { sample in - ElevationSample( - coordinate: sample.coordinate, - elevation: sample.elevation, - distanceFromAMeters: sample.distanceFromAMeters + distanceAR - ) - } - - applyRelayAnalysis( - profileAR: profileAR, - profileRB: profileRBAdjusted, - pointAHeight: pointA.additionalHeight, - repeaterHeight: repeaterPoint.additionalHeight, - pointBHeight: pointB.additionalHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - - // Store profiles for terrain visualization - elevationProfileAR = profileAR - elevationProfileRB = profileRBAdjusted - - isAnalyzing = false - - } catch { - // A cancelled fetch is not a user-facing analysis failure - if Task.isCancelled { return } - isAnalyzing = false - analysisStatus = .error(error.localizedDescription) - logger.error("Off-path analysis failed: \(error.localizedDescription)") - } - } - - /// Shared relay analysis: analyzes both segments and updates profile samples and status - private func applyRelayAnalysis( - profileAR: [ElevationSample], - profileRB: [ElevationSample], - pointAHeight: Double, - repeaterHeight: Double, - pointBHeight: Double, - frequencyMHz: Double, - refractionK: Double - ) { - let arResult = RFCalculator.analyzePathSegment( - elevationProfile: profileAR[...], - startHeightMeters: pointAHeight, - endHeightMeters: repeaterHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - - let rbResult = RFCalculator.analyzePathSegment( - elevationProfile: profileRB[...], - startHeightMeters: repeaterHeight, - endHeightMeters: pointBHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - - let relayResult = RelayPathAnalysisResult( - segmentAR: SegmentAnalysisResult( - startLabel: "A", - endLabel: "R", - clearanceStatus: arResult.clearanceStatus, - distanceMeters: arResult.distanceMeters, - worstClearancePercent: arResult.worstClearancePercent - ), - segmentRB: SegmentAnalysisResult( - startLabel: "R", - endLabel: "B", - clearanceStatus: rbResult.clearanceStatus, - distanceMeters: rbResult.distanceMeters, - worstClearancePercent: rbResult.worstClearancePercent - ) - ) + if Task.isCancelled { return } + elevationProfile = profile profileSamples = FresnelZoneRenderer.buildProfileSamples( - from: profileAR, - pointAHeight: pointAHeight, - pointBHeight: repeaterHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK + from: profile, + pointAHeight: pointAHeight, + pointBHeight: pointBHeight, + frequencyMHz: freq, + refractionK: k ) - profileSamplesRB = FresnelZoneRenderer.buildProfileSamples( - from: profileRB, - pointAHeight: repeaterHeight, - pointBHeight: pointBHeight, - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) - - analysisStatus = .relayResult(relayResult) - } - - // MARK: - Analysis - - /// Clears analysis results without clearing points - func clearAnalysisResults() { + profileSamplesRB = [] isAnalyzing = false - analysisStatus = .idle - shouldAutoZoomOnNextResult = false - } - - func analyze() { - guard let pointA = pointA, - let pointB = pointB, - pointA.groundElevation != nil, - pointB.groundElevation != nil else { - logger.warning("Cannot analyze: missing point elevations") - return - } - - // Cancel any existing analysis - analysisTask?.cancel() - - isAnalyzing = true - - // Capture values for use in task - let pointACoord = pointA.coordinate - let pointBCoord = pointB.coordinate - let pointAHeight = pointA.additionalHeight - let pointBHeight = pointB.additionalHeight - let freq = frequencyMHz - let k = refractionK - - analysisTask = Task { - do { - let distance = RFCalculator.distance(from: pointACoord, to: pointBCoord) - let sampleCount = ElevationService.optimalSampleCount(distanceMeters: distance) - let sampleCoordinates = ElevationService.sampleCoordinates( - from: pointACoord, - to: pointBCoord, - sampleCount: sampleCount - ) - let profile = try await elevationService.fetchElevations(along: sampleCoordinates) - if Task.isCancelled { return } - - let result = await Task.detached { - RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: pointAHeight, - pointBHeightMeters: pointBHeight, - frequencyMHz: freq, - refractionK: k - ) - }.value - - if Task.isCancelled { return } - - elevationProfile = profile - profileSamples = FresnelZoneRenderer.buildProfileSamples( - from: profile, - pointAHeight: pointAHeight, - pointBHeight: pointBHeight, - frequencyMHz: freq, - refractionK: k - ) - profileSamplesRB = [] - isAnalyzing = false - analysisStatus = .result(result) - logger.info("Analysis complete: \(result.clearanceStatus.rawValue), \(result.distanceKm)km") - - } catch { - if Task.isCancelled { return } - isAnalyzing = false - analysisStatus = .error(error.localizedDescription) - logger.error("Analysis failed: \(error.localizedDescription)") - } - } - } - - // MARK: - Private Methods + analysisStatus = .result(result) + logger.info("Analysis complete: \(result.clearanceStatus.rawValue), \(result.distanceKm)km") - /// Invalidates analysis results but preserves cached elevation profile - /// Use when RF settings change (frequency, k-factor) - private func invalidateAnalysisOnly() { - analysisTask?.cancel() - analysisTask = nil + } catch { + if Task.isCancelled { return } isAnalyzing = false - analysisStatus = .idle - shouldAutoZoomOnNextResult = false - } - - /// Invalidates analysis and clears cached elevation profile - /// Use when points change (requires new elevation data) - private func invalidateAnalysis() { - invalidateAnalysisOnly() - repeaterElevationTask?.cancel() - repeaterElevationTask = nil - elevationProfile = [] - elevationProfileAR = [] - elevationProfileRB = [] - profileSamples = [] - profileSamplesRB = [] - elevationFetchFailed = false - repeaterPoint = nil // Repeater is invalid without profile - } - - /// Re-runs analysis using cached elevation profile when RF settings change - private func reanalyzeWithCachedProfileIfNeeded() { - // Only re-analyze if we have a cached profile and both points. - // Clear isAnalyzing on the bail path so a flag left set by a now-stale - // analysis (this method runs from a mid-analysis RF-setting change) can't - // strand the spinner. - guard !elevationProfile.isEmpty, - let pointA = pointA, - let pointB = pointB, - pointA.groundElevation != nil, - pointB.groundElevation != nil else { - isAnalyzing = false - return - } - - // If repeater is active, use relay analysis to preserve mode. - // Clear isAnalyzing first: the on-path/cached relay paths resolve - // synchronously, and the off-path path re-sets the flag itself. - if repeaterPoint != nil { - isAnalyzing = false - analyzeWithRepeater() - return - } - - // Cancel any existing analysis; this task takes over the isAnalyzing flag - analysisTask?.cancel() - isAnalyzing = true - - // Capture values for use in task - let profile = elevationProfile - let pointAHeight = pointA.additionalHeight - let pointBHeight = pointB.additionalHeight - let freq = frequencyMHz - let k = refractionK - - analysisTask = Task { - defer { isAnalyzing = false } - - let result = await Task.detached { - RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: pointAHeight, - pointBHeightMeters: pointBHeight, - frequencyMHz: freq, - refractionK: k - ) - }.value - - if Task.isCancelled { return } - - profileSamples = FresnelZoneRenderer.buildProfileSamples( - from: profile, - pointAHeight: pointAHeight, - pointBHeight: pointBHeight, - frequencyMHz: freq, - refractionK: k - ) - profileSamplesRB = [] - analysisStatus = .result(result) - logger.debug("Re-analyzed with cached profile: \(freq) MHz, k=\(k)") - } - } - - private func fetchElevationForPointA() async { - guard let coordinate = pointA?.coordinate else { return } - - do { - let elevation = try await elevationService.fetchElevation(at: coordinate) - if Task.isCancelled { return } - pointA?.groundElevation = elevation - logger.debug("Point A elevation: \(elevation)m") - } catch { - if Task.isCancelled { return } - logger.error("Failed to fetch point A elevation: \(error.localizedDescription)") - // Set to 0 as fallback so analysis can proceed (sea level approximation) - pointA?.groundElevation = 0 - elevationFetchFailed = true - } - } - - private func fetchElevationForPointB() async { - guard let coordinate = pointB?.coordinate else { return } - - do { - let elevation = try await elevationService.fetchElevation(at: coordinate) - if Task.isCancelled { return } - pointB?.groundElevation = elevation - logger.debug("Point B elevation: \(elevation)m") - } catch { - if Task.isCancelled { return } - logger.error("Failed to fetch point B elevation: \(error.localizedDescription)") - // Set to 0 as fallback so analysis can proceed (sea level approximation) - pointB?.groundElevation = 0 - elevationFetchFailed = true - } - } - - // MARK: - Elevation Interpolation - - /// Returns interpolation indices and factor for a given path fraction - /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range - /// - Returns: Tuple of (lowerIndex, upperIndex, interpolationFactor) or nil if profile has fewer than 2 samples - private func interpolationIndices(for pathFraction: Double) -> (lower: Int, upper: Int, t: Double)? { - guard elevationProfile.count >= 2 else { return nil } - - let clamped = pathFraction.clamped(to: 0.0...1.0) - let index = clamped * Double(elevationProfile.count - 1) - let lowerIndex = Int(index) - let upperIndex = min(lowerIndex + 1, elevationProfile.count - 1) - let t = index - Double(lowerIndex) - - return (lowerIndex, upperIndex, t) - } - - /// Interpolates ground elevation at a given path fraction - /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range - /// - Returns: Interpolated ground elevation in meters, or nil if profile has fewer than 2 samples - func elevationAt(pathFraction: Double) -> Double? { - guard let indices = interpolationIndices(for: pathFraction) else { return nil } - - let lowerElevation = elevationProfile[indices.lower].elevation - let upperElevation = elevationProfile[indices.upper].elevation - - return lowerElevation + indices.t * (upperElevation - lowerElevation) - } - - /// Interpolates coordinate at a given path fraction - /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range - /// - Returns: Interpolated coordinate, or nil if profile has fewer than 2 samples - func coordinateAt(pathFraction: Double) -> CLLocationCoordinate2D? { - guard let indices = interpolationIndices(for: pathFraction) else { return nil } - - let lower = elevationProfile[indices.lower].coordinate - let upper = elevationProfile[indices.upper].coordinate - - return CLLocationCoordinate2D( - latitude: lower.latitude + indices.t * (upper.latitude - lower.latitude), - longitude: lower.longitude + indices.t * (upper.longitude - lower.longitude) + analysisStatus = .error(error.localizedDescription) + logger.error("Analysis failed: \(error.localizedDescription)") + } + } + } + + // MARK: - Private Methods + + /// Invalidates analysis results but preserves cached elevation profile + /// Use when RF settings change (frequency, k-factor) + private func invalidateAnalysisOnly() { + analysisTask?.cancel() + analysisTask = nil + isAnalyzing = false + analysisStatus = .idle + shouldAutoZoomOnNextResult = false + } + + /// Invalidates analysis and clears cached elevation profile + /// Use when points change (requires new elevation data) + private func invalidateAnalysis() { + invalidateAnalysisOnly() + repeaterElevationTask?.cancel() + repeaterElevationTask = nil + elevationProfile = [] + elevationProfileAR = [] + elevationProfileRB = [] + profileSamples = [] + profileSamplesRB = [] + elevationFetchFailed = false + repeaterPoint = nil // Repeater is invalid without profile + } + + /// Re-runs analysis using cached elevation profile when RF settings change + private func reanalyzeWithCachedProfileIfNeeded() { + // Only re-analyze if we have a cached profile and both points. + // Clear isAnalyzing on the bail path so a flag left set by a now-stale + // analysis (this method runs from a mid-analysis RF-setting change) can't + // strand the spinner. + guard !elevationProfile.isEmpty, + let pointA, + let pointB, + pointA.groundElevation != nil, + pointB.groundElevation != nil else { + isAnalyzing = false + return + } + + // If repeater is active, use relay analysis to preserve mode. + // Clear isAnalyzing first: the on-path/cached relay paths resolve + // synchronously, and the off-path path re-sets the flag itself. + if repeaterPoint != nil { + isAnalyzing = false + analyzeWithRepeater() + return + } + + // Cancel any existing analysis; this task takes over the isAnalyzing flag + analysisTask?.cancel() + isAnalyzing = true + + // Capture values for use in task + let profile = elevationProfile + let pointAHeight = pointA.additionalHeight + let pointBHeight = pointB.additionalHeight + let freq = frequencyMHz + let k = refractionK + + analysisTask = Task { + defer { isAnalyzing = false } + + let result = await Task.detached { + RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: pointAHeight, + pointBHeightMeters: pointBHeight, + frequencyMHz: freq, + refractionK: k ) - } - - // MARK: - Testing Helpers - - #if DEBUG + }.value + + if Task.isCancelled { return } + + profileSamples = FresnelZoneRenderer.buildProfileSamples( + from: profile, + pointAHeight: pointAHeight, + pointBHeight: pointBHeight, + frequencyMHz: freq, + refractionK: k + ) + profileSamplesRB = [] + analysisStatus = .result(result) + logger.debug("Re-analyzed with cached profile: \(freq) MHz, k=\(k)") + } + } + + private func fetchElevationForPointA() async { + guard let coordinate = pointA?.coordinate else { return } + + do { + let elevation = try await elevationService.fetchElevation(at: coordinate) + if Task.isCancelled { return } + pointA?.groundElevation = elevation + logger.debug("Point A elevation: \(elevation)m") + } catch { + if Task.isCancelled { return } + logger.error("Failed to fetch point A elevation: \(error.localizedDescription)") + // Set to 0 as fallback so analysis can proceed (sea level approximation) + pointA?.groundElevation = 0 + elevationFetchFailed = true + } + } + + private func fetchElevationForPointB() async { + guard let coordinate = pointB?.coordinate else { return } + + do { + let elevation = try await elevationService.fetchElevation(at: coordinate) + if Task.isCancelled { return } + pointB?.groundElevation = elevation + logger.debug("Point B elevation: \(elevation)m") + } catch { + if Task.isCancelled { return } + logger.error("Failed to fetch point B elevation: \(error.localizedDescription)") + // Set to 0 as fallback so analysis can proceed (sea level approximation) + pointB?.groundElevation = 0 + elevationFetchFailed = true + } + } + + // MARK: - Elevation Interpolation + + /// Returns interpolation indices and factor for a given path fraction + /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range + /// - Returns: Tuple of (lowerIndex, upperIndex, interpolationFactor) or nil if profile has fewer than 2 samples + private func interpolationIndices(for pathFraction: Double) -> (lower: Int, upper: Int, t: Double)? { + guard elevationProfile.count >= 2 else { return nil } + + let clamped = pathFraction.clamped(to: 0.0...1.0) + let index = clamped * Double(elevationProfile.count - 1) + let lowerIndex = Int(index) + let upperIndex = min(lowerIndex + 1, elevationProfile.count - 1) + let t = index - Double(lowerIndex) + + return (lowerIndex, upperIndex, t) + } + + /// Interpolates ground elevation at a given path fraction + /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range + /// - Returns: Interpolated ground elevation in meters, or nil if profile has fewer than 2 samples + func elevationAt(pathFraction: Double) -> Double? { + guard let indices = interpolationIndices(for: pathFraction) else { return nil } + + let lowerElevation = elevationProfile[indices.lower].elevation + let upperElevation = elevationProfile[indices.upper].elevation + + return lowerElevation + indices.t * (upperElevation - lowerElevation) + } + + /// Interpolates coordinate at a given path fraction + /// - Parameter pathFraction: Position along path (0.0 = A, 1.0 = B), clamped to valid range + /// - Returns: Interpolated coordinate, or nil if profile has fewer than 2 samples + func coordinateAt(pathFraction: Double) -> CLLocationCoordinate2D? { + guard let indices = interpolationIndices(for: pathFraction) else { return nil } + + let lower = elevationProfile[indices.lower].coordinate + let upper = elevationProfile[indices.upper].coordinate + + return CLLocationCoordinate2D( + latitude: lower.latitude + indices.t * (upper.latitude - lower.latitude), + longitude: lower.longitude + indices.t * (upper.longitude - lower.longitude) + ) + } + + // MARK: - Testing Helpers + + #if DEBUG /// Testing helper to set analysis status directly func setAnalysisStatusForTesting(_ result: PathAnalysisResult) { - analysisStatus = .result(result) + analysisStatus = .result(result) } /// Testing helper to set elevation profile directly func setElevationProfileForTesting(_ profile: [ElevationSample]) { - elevationProfile = profile + elevationProfile = profile } - #endif + #endif } diff --git a/MC1/Views/Tools/LineOfSight/PointHeightEditorView.swift b/MC1/Views/Tools/LineOfSight/PointHeightEditorView.swift index 62b6ef1b..34abc9ba 100644 --- a/MC1/Views/Tools/LineOfSight/PointHeightEditorView.swift +++ b/MC1/Views/Tools/LineOfSight/PointHeightEditorView.swift @@ -1,18 +1,18 @@ import SwiftUI struct PointHeightEditorView: View { - var viewModel: LineOfSightViewModel - let point: SelectedPoint - let pointID: PointID + var viewModel: LineOfSightViewModel + let point: SelectedPoint + let pointID: PointID - var body: some View { - HeightEditorGrid( - groundElevation: point.groundElevation, - additionalHeight: Binding( - get: { point.additionalHeight }, - set: { viewModel.updateAdditionalHeight(for: pointID, meters: $0) } - ), - range: 0.0...200.0 - ) - } + var body: some View { + HeightEditorGrid( + groundElevation: point.groundElevation, + additionalHeight: Binding( + get: { point.additionalHeight }, + set: { viewModel.updateAdditionalHeight(for: pointID, meters: $0) } + ), + range: 0.0...200.0 + ) + } } diff --git a/MC1/Views/Tools/LineOfSight/PointRowButtonsView.swift b/MC1/Views/Tools/LineOfSight/PointRowButtonsView.swift index b3754a6a..576c9203 100644 --- a/MC1/Views/Tools/LineOfSight/PointRowButtonsView.swift +++ b/MC1/Views/Tools/LineOfSight/PointRowButtonsView.swift @@ -3,101 +3,101 @@ import MapKit import SwiftUI struct PointRowButtonsView: View { - var viewModel: LineOfSightViewModel - let pointID: PointID - let isEditing: Bool - @Binding var copyHapticTrigger: Int - @Binding var editingPoint: PointID? - let onRelocate: () -> Void - let onClear: () -> Void + var viewModel: LineOfSightViewModel + let pointID: PointID + let isEditing: Bool + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + let onClear: () -> Void - private let iconButtonSize: CGFloat = 22 + private let iconButtonSize: CGFloat = 22 - private var coordinate: CLLocationCoordinate2D? { - switch pointID { - case .pointA: viewModel.pointA?.coordinate - case .pointB: viewModel.pointB?.coordinate - case .repeater: viewModel.repeaterPoint?.coordinate - } + private var coordinate: CLLocationCoordinate2D? { + switch pointID { + case .pointA: viewModel.pointA?.coordinate + case .pointB: viewModel.pointB?.coordinate + case .repeater: viewModel.repeaterPoint?.coordinate } + } - var body: some View { - // Share menu - Menu { - if let coord = coordinate { - Button(L10n.Tools.Tools.LineOfSight.openInMaps, systemImage: "map") { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coord)) - mapItem.name = switch pointID { - case .pointA: L10n.Tools.Tools.LineOfSight.pointA - case .pointB: L10n.Tools.Tools.LineOfSight.pointB - case .repeater: L10n.Tools.Tools.LineOfSight.repeaterLocation - } - mapItem.openInMaps() - } - - Button(L10n.Tools.Tools.LineOfSight.copyCoordinates, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - UIPasteboard.general.string = coord.formattedString - } - - ShareLink(item: coord.formattedString) { - Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") - } - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.shareLabel, systemImage: "square.and.arrow.up") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) + var body: some View { + // Share menu + Menu { + if let coord = coordinate { + Button(L10n.Tools.Tools.LineOfSight.openInMaps, systemImage: "map") { + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coord)) + mapItem.name = switch pointID { + case .pointA: L10n.Tools.Tools.LineOfSight.pointA + case .pointB: L10n.Tools.Tools.LineOfSight.pointB + case .repeater: L10n.Tools.Tools.LineOfSight.repeaterLocation + } + mapItem.openInMaps() } - .liquidGlassSecondaryButtonStyle() - .sensoryFeedback(.success, trigger: copyHapticTrigger) - .controlSize(.small) - // Relocate button (toggles on/off) - Button { - if viewModel.relocatingPoint == pointID { - viewModel.relocatingPoint = nil - } else { - viewModel.relocatingPoint = pointID - onRelocate() - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.relocate, systemImage: "mappin") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) + Button(L10n.Tools.Tools.LineOfSight.copyCoordinates, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = coord.formattedString } - .liquidGlassSecondaryButtonStyle() - .controlSize(.small) - .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != pointID) - // Edit/Done toggle - Button { - withAnimation { - editingPoint = isEditing ? nil : pointID - } - } label: { - Group { - if isEditing { - Label(L10n.Tools.Tools.LineOfSight.done, systemImage: "checkmark") - .labelStyle(.iconOnly) - } else { - Label(L10n.Tools.Tools.LineOfSight.edit, systemImage: "ruler") - .labelStyle(.iconOnly) - .rotationEffect(.degrees(90)) - } - } - .frame(width: iconButtonSize, height: iconButtonSize) + ShareLink(item: coord.formattedString) { + Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") } - .liquidGlassSecondaryButtonStyle() - .controlSize(.small) + } + } label: { + Label(L10n.Tools.Tools.LineOfSight.shareLabel, systemImage: "square.and.arrow.up") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .sensoryFeedback(.success, trigger: copyHapticTrigger) + .controlSize(.small) - // Clear button - Button(action: onClear) { - Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) + // Relocate button (toggles on/off) + Button { + if viewModel.relocatingPoint == pointID { + viewModel.relocatingPoint = nil + } else { + viewModel.relocatingPoint = pointID + onRelocate() + } + } label: { + Label(L10n.Tools.Tools.LineOfSight.relocate, systemImage: "mappin") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != pointID) + + // Edit/Done toggle + Button { + withAnimation { + editingPoint = isEditing ? nil : pointID + } + } label: { + Group { + if isEditing { + Label(L10n.Tools.Tools.LineOfSight.done, systemImage: "checkmark") + .labelStyle(.iconOnly) + } else { + Label(L10n.Tools.Tools.LineOfSight.edit, systemImage: "ruler") + .labelStyle(.iconOnly) + .rotationEffect(.degrees(90)) } - .liquidGlassSecondaryButtonStyle() - .controlSize(.small) + } + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + + // Clear button + Button(action: onClear) { + Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + } } diff --git a/MC1/Views/Tools/LineOfSight/PointRowView.swift b/MC1/Views/Tools/LineOfSight/PointRowView.swift index 908aede2..1b88684e 100644 --- a/MC1/Views/Tools/LineOfSight/PointRowView.swift +++ b/MC1/Views/Tools/LineOfSight/PointRowView.swift @@ -1,86 +1,88 @@ import SwiftUI struct PointRowView: View { - var viewModel: LineOfSightViewModel - let label: String - let color: Color - let point: SelectedPoint? - let pointID: PointID - @Binding var copyHapticTrigger: Int - @Binding var editingPoint: PointID? - let onRelocate: () -> Void - let onClear: () -> Void + var viewModel: LineOfSightViewModel + let label: String + let color: Color + let point: SelectedPoint? + let pointID: PointID + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + let onClear: () -> Void - private var isEditing: Bool { editingPoint == pointID } + private var isEditing: Bool { + editingPoint == pointID + } - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header row (always visible) - HStack { - // Point marker - Circle() - .fill(point != nil ? color : .gray.opacity(0.3)) - .frame(width: 24, height: 24) - .overlay { - Text(label) - .font(.caption) - .bold() - .foregroundStyle(.white) - } + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row (always visible) + HStack { + // Point marker + Circle() + .fill(point != nil ? color : .gray.opacity(0.3)) + .frame(width: 24, height: 24) + .overlay { + Text(label) + .font(.caption) + .bold() + .foregroundStyle(.white) + } - // Point info - if let point { - VStack(alignment: .leading, spacing: 2) { - Text(point.displayName) - .font(.subheadline) - .lineLimit(1) + // Point info + if let point { + VStack(alignment: .leading, spacing: 2) { + Text(point.displayName) + .font(.subheadline) + .lineLimit(1) - if point.isLoadingElevation { - HStack(spacing: 4) { - ProgressView() - .controlSize(.mini) - Text(L10n.Tools.Tools.LineOfSight.loadingElevation) - .font(.caption) - .foregroundStyle(.secondary) - } - } else if let elevation = point.groundElevation { - Text(Measurement( - value: elevation + point.additionalHeight, - unit: UnitLength.meters - ).formatted()) - .font(.caption) - .foregroundStyle(.secondary) - } - } + if point.isLoadingElevation { + HStack(spacing: 4) { + ProgressView() + .controlSize(.mini) + Text(L10n.Tools.Tools.LineOfSight.loadingElevation) + .font(.caption) + .foregroundStyle(.secondary) + } + } else if let elevation = point.groundElevation { + Text(Measurement( + value: elevation + point.additionalHeight, + unit: UnitLength.meters + ).formatted()) + .font(.caption) + .foregroundStyle(.secondary) + } + } - Spacer() + Spacer() - PointRowButtonsView( - viewModel: viewModel, - pointID: pointID, - isEditing: isEditing, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: onRelocate, - onClear: onClear - ) - } else { - Text(L10n.Tools.Tools.LineOfSight.notSelected) - .font(.subheadline) - .foregroundStyle(.secondary) + PointRowButtonsView( + viewModel: viewModel, + pointID: pointID, + isEditing: isEditing, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: onClear + ) + } else { + Text(L10n.Tools.Tools.LineOfSight.notSelected) + .font(.subheadline) + .foregroundStyle(.secondary) - Spacer() - } - } + Spacer() + } + } - // Expanded editor (when editing) - if isEditing, let point { - Divider() + // Expanded editor (when editing) + if isEditing, let point { + Divider() - PointHeightEditorView(viewModel: viewModel, point: point, pointID: pointID) - } - } - .padding(12) - .animation(.easeInOut(duration: 0.2), value: isEditing) + PointHeightEditorView(viewModel: viewModel, point: point, pointID: pointID) + } } + .padding(12) + .animation(.easeInOut(duration: 0.2), value: isEditing) + } } diff --git a/MC1/Views/Tools/LineOfSight/PointsSummarySectionView.swift b/MC1/Views/Tools/LineOfSight/PointsSummarySectionView.swift index 60247239..f8bb9fa7 100644 --- a/MC1/Views/Tools/LineOfSight/PointsSummarySectionView.swift +++ b/MC1/Views/Tools/LineOfSight/PointsSummarySectionView.swift @@ -1,115 +1,117 @@ import SwiftUI struct PointsSummarySectionView: View { - var viewModel: LineOfSightViewModel - @Binding var copyHapticTrigger: Int - @Binding var editingPoint: PointID? - let onRelocate: () -> Void + var viewModel: LineOfSightViewModel + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void - private var isRelocating: Bool { viewModel.relocatingPoint != nil } + private var isRelocating: Bool { + viewModel.relocatingPoint != nil + } - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header with optional cancel button - HStack { - Text(L10n.Tools.Tools.LineOfSight.points) - .font(.headline) + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header with optional cancel button + HStack { + Text(L10n.Tools.Tools.LineOfSight.points) + .font(.headline) - Spacer() + Spacer() - if isRelocating { - Button(L10n.Tools.Tools.LineOfSight.cancel) { - viewModel.relocatingPoint = nil - } - .liquidGlassSecondaryButtonStyle() - .controlSize(.small) - } - } + if isRelocating { + Button(L10n.Tools.Tools.LineOfSight.cancel) { + viewModel.relocatingPoint = nil + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + } + } - // Show relocating message OR point rows - if let relocatingPoint = viewModel.relocatingPoint { - relocatingMessageView(for: relocatingPoint) - } else { - // Point A row - PointRowView( - viewModel: viewModel, - label: "A", - color: .blue, - point: viewModel.pointA, - pointID: .pointA, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: onRelocate, - onClear: { viewModel.clearPointA() } - ) + // Show relocating message OR point rows + if let relocatingPoint = viewModel.relocatingPoint { + relocatingMessageView(for: relocatingPoint) + } else { + // Point A row + PointRowView( + viewModel: viewModel, + label: "A", + color: .blue, + point: viewModel.pointA, + pointID: .pointA, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearPointA() } + ) - // Repeater row (placeholder or full, positioned between A and B) - // Inline check for repeaterPoint to ensure SwiftUI properly tracks the dependency - if viewModel.repeaterPoint != nil { - RepeaterRowView( - viewModel: viewModel, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: onRelocate - ) - } else if viewModel.shouldShowRepeaterPlaceholder { - AddRepeaterRowView { - viewModel.addRepeater() - viewModel.analyzeWithRepeater() - } - } + // Repeater row (placeholder or full, positioned between A and B) + // Inline check for repeaterPoint to ensure SwiftUI properly tracks the dependency + if viewModel.repeaterPoint != nil { + RepeaterRowView( + viewModel: viewModel, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate + ) + } else if viewModel.shouldShowRepeaterPlaceholder { + AddRepeaterRowView { + viewModel.addRepeater() + viewModel.analyzeWithRepeater() + } + } - // Point B row - PointRowView( - viewModel: viewModel, - label: "B", - color: .green, - point: viewModel.pointB, - pointID: .pointB, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: onRelocate, - onClear: { viewModel.clearPointB() } - ) + // Point B row + PointRowView( + viewModel: viewModel, + label: "B", + color: .green, + point: viewModel.pointB, + pointID: .pointB, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearPointB() } + ) - if viewModel.pointA == nil || viewModel.pointB == nil { - Text(L10n.Tools.Tools.LineOfSight.selectPointsHint) - .font(.caption) - .foregroundStyle(.secondary) - } + if viewModel.pointA == nil || viewModel.pointB == nil { + Text(L10n.Tools.Tools.LineOfSight.selectPointsHint) + .font(.caption) + .foregroundStyle(.secondary) + } - if viewModel.elevationFetchFailed { - Label( - L10n.Tools.Tools.LineOfSight.elevationUnavailable, - systemImage: "exclamationmark.triangle.fill" - ) - .font(.caption) - .foregroundStyle(.orange) - } - } + if viewModel.elevationFetchFailed { + Label( + L10n.Tools.Tools.LineOfSight.elevationUnavailable, + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.orange) } + } } + } - @ViewBuilder - private func relocatingMessageView(for pointID: PointID) -> some View { - let pointName: String = switch pointID { - case .pointA: L10n.Tools.Tools.LineOfSight.pointA - case .pointB: L10n.Tools.Tools.LineOfSight.pointB - case .repeater: L10n.Tools.Tools.LineOfSight.repeater - } + @ViewBuilder + private func relocatingMessageView(for pointID: PointID) -> some View { + let pointName: String = switch pointID { + case .pointA: L10n.Tools.Tools.LineOfSight.pointA + case .pointB: L10n.Tools.Tools.LineOfSight.pointB + case .repeater: L10n.Tools.Tools.LineOfSight.repeater + } - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Tools.Tools.LineOfSight.relocating(pointName)) - .font(.subheadline) - .bold() + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Tools.Tools.LineOfSight.relocating(pointName)) + .font(.subheadline) + .bold() - Text(L10n.Tools.Tools.LineOfSight.tapMapInstruction) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(L10n.Tools.Tools.LineOfSight.relocating(pointName)) \(L10n.Tools.Tools.LineOfSight.tapMapInstruction)") + Text(L10n.Tools.Tools.LineOfSight.tapMapInstruction) + .font(.caption) + .foregroundStyle(.secondary) } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(L10n.Tools.Tools.LineOfSight.relocating(pointName)) \(L10n.Tools.Tools.LineOfSight.tapMapInstruction)") + } } diff --git a/MC1/Views/Tools/LineOfSight/RFSettingsSectionView.swift b/MC1/Views/Tools/LineOfSight/RFSettingsSectionView.swift index 4855b08f..5470715c 100644 --- a/MC1/Views/Tools/LineOfSight/RFSettingsSectionView.swift +++ b/MC1/Views/Tools/LineOfSight/RFSettingsSectionView.swift @@ -1,38 +1,38 @@ import SwiftUI struct RFSettingsSectionView: View { - @Bindable var viewModel: LineOfSightViewModel - @Binding var isRFSettingsExpanded: Bool + @Bindable var viewModel: LineOfSightViewModel + @Binding var isRFSettingsExpanded: Bool - var body: some View { - DisclosureGroup(isExpanded: $isRFSettingsExpanded) { - VStack(spacing: 12) { - // Frequency input - extracted to separate view for @FocusState to work in sheet - FrequencyInputRow(viewModel: viewModel) + var body: some View { + DisclosureGroup(isExpanded: $isRFSettingsExpanded) { + VStack(spacing: 12) { + // Frequency input - extracted to separate view for @FocusState to work in sheet + FrequencyInputRow(viewModel: viewModel) - Divider() + Divider() - // Refraction k-factor picker - HStack { - Label(L10n.Tools.Tools.LineOfSight.refraction, systemImage: "globe") - .foregroundStyle(.secondary) - Spacer() - Picker("", selection: Binding( - get: { viewModel.refractionK }, - set: { viewModel.refractionK = $0 } - )) { - Text(L10n.Tools.Tools.LineOfSight.Refraction.none).tag(1.0) - Text(L10n.Tools.Tools.LineOfSight.Refraction.standard).tag(4.0 / 3.0) - Text(L10n.Tools.Tools.LineOfSight.Refraction.ducting).tag(4.0) - } - .pickerStyle(.menu) - } - } - .padding(.top, 8) - } label: { - Label(L10n.Tools.Tools.LineOfSight.rfSettings, systemImage: "antenna.radiowaves.left.and.right") - .font(.headline) + // Refraction k-factor picker + HStack { + Label(L10n.Tools.Tools.LineOfSight.refraction, systemImage: "globe") + .foregroundStyle(.secondary) + Spacer() + Picker("", selection: Binding( + get: { viewModel.refractionK }, + set: { viewModel.refractionK = $0 } + )) { + Text(L10n.Tools.Tools.LineOfSight.Refraction.none).tag(1.0) + Text(L10n.Tools.Tools.LineOfSight.Refraction.standard).tag(4.0 / 3.0) + Text(L10n.Tools.Tools.LineOfSight.Refraction.ducting).tag(4.0) + } + .pickerStyle(.menu) } - .tint(.primary) + } + .padding(.top, 8) + } label: { + Label(L10n.Tools.Tools.LineOfSight.rfSettings, systemImage: "antenna.radiowaves.left.and.right") + .font(.headline) } + .tint(.primary) + } } diff --git a/MC1/Views/Tools/LineOfSight/RepeaterHeightEditorView.swift b/MC1/Views/Tools/LineOfSight/RepeaterHeightEditorView.swift index cf14d92a..44e1378b 100644 --- a/MC1/Views/Tools/LineOfSight/RepeaterHeightEditorView.swift +++ b/MC1/Views/Tools/LineOfSight/RepeaterHeightEditorView.swift @@ -1,18 +1,18 @@ import SwiftUI struct RepeaterHeightEditorView: View { - var viewModel: LineOfSightViewModel - let repeaterPoint: RepeaterPoint + var viewModel: LineOfSightViewModel + let repeaterPoint: RepeaterPoint - var body: some View { - HeightEditorGrid( - groundElevation: viewModel.repeaterGroundElevation, - additionalHeight: Binding( - get: { repeaterPoint.additionalHeight }, - set: { viewModel.updateRepeaterHeight(meters: $0) } - ), - range: 0.0...200.0, - onHeightChanged: { viewModel.analyzeWithRepeater() } - ) - } + var body: some View { + HeightEditorGrid( + groundElevation: viewModel.repeaterGroundElevation, + additionalHeight: Binding( + get: { repeaterPoint.additionalHeight }, + set: { viewModel.updateRepeaterHeight(meters: $0) } + ), + range: 0.0...200.0, + onHeightChanged: { viewModel.analyzeWithRepeater() } + ) + } } diff --git a/MC1/Views/Tools/LineOfSight/RepeaterRowView.swift b/MC1/Views/Tools/LineOfSight/RepeaterRowView.swift index 386de089..9cb604a6 100644 --- a/MC1/Views/Tools/LineOfSight/RepeaterRowView.swift +++ b/MC1/Views/Tools/LineOfSight/RepeaterRowView.swift @@ -1,64 +1,66 @@ import SwiftUI struct RepeaterRowView: View { - var viewModel: LineOfSightViewModel - @Binding var copyHapticTrigger: Int - @Binding var editingPoint: PointID? - let onRelocate: () -> Void + var viewModel: LineOfSightViewModel + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void - private var isEditing: Bool { editingPoint == .repeater } + private var isEditing: Bool { + editingPoint == .repeater + } - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header row - HStack { - // Repeater marker (purple) - Circle() - .fill(.purple) - .frame(width: 24, height: 24) - .overlay { - Text("R") - .font(.caption) - .bold() - .foregroundStyle(.white) - } + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row + HStack { + // Repeater marker (purple) + Circle() + .fill(.purple) + .frame(width: 24, height: 24) + .overlay { + Text("R") + .font(.caption) + .bold() + .foregroundStyle(.white) + } - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Tools.Tools.LineOfSight.repeater) - .font(.subheadline) - .lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Tools.Tools.LineOfSight.repeater) + .font(.subheadline) + .lineLimit(1) - if let elevation = viewModel.repeaterGroundElevation { - let totalHeight = elevation + (viewModel.repeaterPoint?.additionalHeight ?? 0.0) - Text(Measurement( - value: totalHeight, - unit: UnitLength.meters - ).formatted()) - .font(.caption) - .foregroundStyle(.secondary) - } - } + if let elevation = viewModel.repeaterGroundElevation { + let totalHeight = elevation + (viewModel.repeaterPoint?.additionalHeight ?? 0.0) + Text(Measurement( + value: totalHeight, + unit: UnitLength.meters + ).formatted()) + .font(.caption) + .foregroundStyle(.secondary) + } + } - Spacer() + Spacer() - PointRowButtonsView( - viewModel: viewModel, - pointID: .repeater, - isEditing: isEditing, - copyHapticTrigger: $copyHapticTrigger, - editingPoint: $editingPoint, - onRelocate: onRelocate, - onClear: { viewModel.clearRepeater() } - ) - } + PointRowButtonsView( + viewModel: viewModel, + pointID: .repeater, + isEditing: isEditing, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearRepeater() } + ) + } - // Expanded editor - if isEditing, let repeaterPoint = viewModel.repeaterPoint { - Divider() - RepeaterHeightEditorView(viewModel: viewModel, repeaterPoint: repeaterPoint) - } - } - .padding(12) - .animation(.easeInOut(duration: 0.2), value: isEditing) + // Expanded editor + if isEditing, let repeaterPoint = viewModel.repeaterPoint { + Divider() + RepeaterHeightEditorView(viewModel: viewModel, repeaterPoint: repeaterPoint) + } } + .padding(12) + .animation(.easeInOut(duration: 0.2), value: isEditing) + } } diff --git a/MC1/Views/Tools/LineOfSight/TerrainProfileCanvas.swift b/MC1/Views/Tools/LineOfSight/TerrainProfileCanvas.swift index 724f7433..334e1d89 100644 --- a/MC1/Views/Tools/LineOfSight/TerrainProfileCanvas.swift +++ b/MC1/Views/Tools/LineOfSight/TerrainProfileCanvas.swift @@ -3,769 +3,764 @@ import SwiftUI /// Canvas-based terrain profile visualization with Fresnel zone struct TerrainProfileCanvas: View { - let elevationProfile: [ElevationSample] + let elevationProfile: [ElevationSample] - /// Profile samples for primary segment (A→B or A→R when repeater active) - let profileSamples: [ProfileSample] + /// Profile samples for primary segment (A→B or A→R when repeater active) + let profileSamples: [ProfileSample] - /// Profile samples for R→B segment (empty when no repeater) - var profileSamplesRB: [ProfileSample] = [] + /// Profile samples for R→B segment (empty when no repeater) + var profileSamplesRB: [ProfileSample] = [] - // Optional repeater parameters - var repeaterPathFraction: Double? - var repeaterHeight: Double? + // Optional repeater parameters + var repeaterPathFraction: Double? + var repeaterHeight: Double? - /// Callback to update repeater position during drag - var onRepeaterDrag: ((Double) -> Void)? + /// Callback to update repeater position during drag + var onRepeaterDrag: ((Double) -> Void)? - /// Callback to report repeater marker center position (for tooltip positioning) - var onRepeaterMarkerPosition: ((CGPoint) -> Void)? + /// Callback to report repeater marker center position (for tooltip positioning) + var onRepeaterMarkerPosition: ((CGPoint) -> Void)? - /// Segment distances for off-path repeater visualization (nil when on-path or no repeater) - var segmentARDistanceMeters: Double? - var segmentRBDistanceMeters: Double? + /// Segment distances for off-path repeater visualization (nil when on-path or no repeater) + var segmentARDistanceMeters: Double? + var segmentRBDistanceMeters: Double? - /// Whether the repeater is off the direct A→B path - private var isOffPath: Bool { - segmentARDistanceMeters != nil && segmentRBDistanceMeters != nil - } - - // Track drag state - @State private var isDragging = false - @State private var canvasSize: CGSize = .zero - - // Nudge animation state (one-time affordance when repeater first added) - @State private var hasAnimatedNudge = false - @State private var nudgeOffset: CGFloat = 0 - - // Repeater marker center (for tooltip positioning) - @State private var repeaterMarkerCenter: CGPoint? - - // MARK: - Colors (Colorblind-Safe) - - private let terrainFill = Color(red: 0.76, green: 0.70, blue: 0.60) - private let terrainStroke = Color(red: 0.55, green: 0.47, blue: 0.36) - private let fresnelOuter = Color.teal.opacity(0.25) - private let fresnelInner = Color.teal.opacity(0.50) - private let fresnelObstructed = Color.red.opacity(0.7) - private let fresnelBoundary = Color.teal.opacity(0.6) - private let losLineColor = Color.primary - private let gridColor = Color.gray.opacity(0.3) - private let repeaterColor = Color.purple - - // MARK: - Layout Constants + /// Whether the repeater is off the direct A→B path + private var isOffPath: Bool { + segmentARDistanceMeters != nil && segmentRBDistanceMeters != nil + } - private let padding = EdgeInsets(top: 24, leading: 45, bottom: 28, trailing: 16) - private let chartHeight: CGFloat = 200 + // Track drag state + @State private var isDragging = false + @State private var canvasSize: CGSize = .zero - // MARK: - Computed Properties + // Nudge animation state (one-time affordance when repeater first added) + @State private var hasAnimatedNudge = false + @State private var nudgeOffset: CGFloat = 0 - private var xRange: ClosedRange { - guard let last = elevationProfile.last else { return 0...1 } - return 0...max(1, last.distanceFromAMeters) - } - - private var yRange: ClosedRange { - let allSamples = profileSamples + profileSamplesRB - guard !allSamples.isEmpty else { return 0...100 } + /// Repeater marker center (for tooltip positioning) + @State private var repeaterMarkerCenter: CGPoint? - var minY = Double.infinity - var maxY = -Double.infinity + // MARK: - Colors (Colorblind-Safe) - for sample in allSamples { - minY = min(minY, sample.yTerrain) - maxY = max(maxY, sample.yTop) - } + private let terrainFill = Color(red: 0.76, green: 0.70, blue: 0.60) + private let terrainStroke = Color(red: 0.55, green: 0.47, blue: 0.36) + private let fresnelOuter = Color.teal.opacity(0.25) + private let fresnelInner = Color.teal.opacity(0.50) + private let fresnelObstructed = Color.red.opacity(0.7) + private let fresnelBoundary = Color.teal.opacity(0.6) + private let losLineColor = Color.primary + private let gridColor = Color.gray.opacity(0.3) + private let repeaterColor = Color.purple - guard minY.isFinite, maxY.isFinite, maxY > minY else { return 0...100 } + // MARK: - Layout Constants - let range = maxY - minY - return (minY - range * 0.1)...(maxY + range * 0.2) - } + private let padding = EdgeInsets(top: 24, leading: 45, bottom: 28, trailing: 16) + private let chartHeight: CGFloat = 200 - /// Calculated repeater marker center position in canvas coordinates - private var calculatedMarkerCenter: CGPoint? { - guard canvasSize != .zero, - let junctionSample = profileSamples.last, - repeaterPathFraction != nil else { return nil } + // MARK: - Computed Properties - let coords = ChartCoordinateSpace( - canvasSize: canvasSize, - padding: padding, - xRange: xRange, - yRange: yRange - ) - - let basePoint = coords.point(x: junctionSample.x, y: junctionSample.yLOS) - return CGPoint(x: basePoint.x + nudgeOffset, y: basePoint.y) - } + private var xRange: ClosedRange { + guard let last = elevationProfile.last else { return 0...1 } + return 0...max(1, last.distanceFromAMeters) + } - // MARK: - Body + private var yRange: ClosedRange { + let allSamples = profileSamples + profileSamplesRB + guard !allSamples.isEmpty else { return 0...100 } - var body: some View { - VStack(alignment: .leading, spacing: 8) { - if elevationProfile.isEmpty { - emptyState - } else { - chartCanvas - legendView - if isOffPath { - indirectRouteLabel - } - attributionText - } - } - } + var minY = Double.infinity + var maxY = -Double.infinity - private var emptyState: some View { - ContentUnavailableView( - L10n.Tools.Tools.LineOfSight.noData, - systemImage: "chart.xyaxis.line", - description: Text(L10n.Tools.Tools.LineOfSight.selectTwoPoints) - ) - .frame(height: chartHeight) - } - - private var chartCanvas: some View { - Canvas { context, size in - let coords = ChartCoordinateSpace( - canvasSize: size, - padding: padding, - xRange: xRange, - yRange: yRange - ) - - drawGrid(context: context, coords: coords) - drawFresnelZone(context: context, coords: coords) - drawFresnelBoundary(context: context, coords: coords) - drawTerrain(context: context, coords: coords) - drawObstructions(context: context, coords: coords) - drawLOSLine(context: context, coords: coords) - drawEndpointMarkers(context: context, coords: coords) - - // Draw vertical separator at R junction for off-path repeaters (behind R marker) - if let arDistance = segmentARDistanceMeters, isOffPath { - drawJunctionSeparator(context: context, coords: coords, atDistance: arDistance) - } - - // Draw repeater marker if present - if let pathFraction = repeaterPathFraction, - let height = repeaterHeight, - elevationProfile.count >= 2 { - // Interpolate ground elevation at repeater position - let index = pathFraction * Double(elevationProfile.count - 1) - let lowerIndex = Int(index) - let upperIndex = min(lowerIndex + 1, elevationProfile.count - 1) - let t = index - Double(lowerIndex) - let groundElevation = elevationProfile[lowerIndex].elevation + - t * (elevationProfile[upperIndex].elevation - elevationProfile[lowerIndex].elevation) - - drawRepeaterMarker( - context: context, - coords: coords, - pathFraction: pathFraction, - repeaterElevation: groundElevation, - height: height, - nudgeOffset: nudgeOffset - ) - } - } - .frame(height: chartHeight) - .onGeometryChange(for: CGSize.self) { proxy in - proxy.size - } action: { size in - canvasSize = size - } - .gesture(repeaterDragGesture, including: onRepeaterDrag != nil ? .all : .subviews) - .onChange(of: repeaterPathFraction, initial: true) { oldValue, newValue in - // Trigger one-time nudge animation when repeater is first added (on-path only) - if oldValue == nil, newValue != nil, !hasAnimatedNudge, !isOffPath { - hasAnimatedNudge = true - triggerNudgeAnimation() - } - } - .onChange(of: calculatedMarkerCenter) { _, newCenter in - if let center = newCenter { - onRepeaterMarkerPosition?(center) - } - } + for sample in allSamples { + minY = min(minY, sample.yTerrain) + maxY = max(maxY, sample.yTop) } - private var repeaterDragGesture: some Gesture { - DragGesture(minimumDistance: 0) - .onChanged { value in - guard repeaterPathFraction != nil else { return } - handleDrag(at: value.location) - } - .onEnded { _ in - isDragging = false - } - } + guard minY.isFinite, maxY.isFinite, maxY > minY else { return 0...100 } - private func handleDrag(at location: CGPoint) { - // Convert X position to path fraction - let chartWidth = canvasSize.width - padding.leading - padding.trailing - let relativeX = (location.x - padding.leading) / chartWidth - let pathFraction = max(0.05, min(0.95, relativeX)) + let range = maxY - minY + return (minY - range * 0.1)...(maxY + range * 0.2) + } - if !isDragging { - isDragging = true - } + /// Calculated repeater marker center position in canvas coordinates + private var calculatedMarkerCenter: CGPoint? { + guard canvasSize != .zero, + let junctionSample = profileSamples.last, + repeaterPathFraction != nil else { return nil } - onRepeaterDrag?(pathFraction) - } + let coords = ChartCoordinateSpace( + canvasSize: canvasSize, + padding: padding, + xRange: xRange, + yRange: yRange + ) - /// Performs a one-time horizontal nudge animation to hint at draggability - private func triggerNudgeAnimation() { - // Animate: shift right 4pt, then back to original position - withAnimation(.easeOut(duration: 0.15)) { - nudgeOffset = 4 - } - withAnimation(.easeInOut(duration: 0.2).delay(0.15)) { - nudgeOffset = 0 - } - } + let basePoint = coords.point(x: junctionSample.x, y: junctionSample.yLOS) + return CGPoint(x: basePoint.x + nudgeOffset, y: basePoint.y) + } - private var legendView: some View { - HStack(spacing: 16) { - legendItem(color: terrainStroke, label: L10n.Tools.Tools.LineOfSight.Legend.terrain) - legendItem(color: losLineColor, label: L10n.Tools.Tools.LineOfSight.Legend.los) - legendItem(color: fresnelOuter, label: L10n.Tools.Tools.LineOfSight.Legend.clear) - legendItem(color: fresnelObstructed, label: L10n.Tools.Tools.LineOfSight.Legend.obstructed) - if repeaterPathFraction != nil { - legendItem(color: repeaterColor, label: L10n.Tools.Tools.LineOfSight.repeater) - } - } - .font(.caption2) - .foregroundStyle(.secondary) - } + // MARK: - Body - private func legendItem(color: Color, label: String) -> some View { - HStack(spacing: 4) { - Circle() - .fill(color) - .frame(width: 8, height: 8) - Text(label) + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if elevationProfile.isEmpty { + emptyState + } else { + chartCanvas + legendView + if isOffPath { + indirectRouteLabel } + attributionText + } } + } - private var indirectRouteLabel: some View { - Text(L10n.Tools.Tools.LineOfSight.indirectRoute) - .font(.caption2) - .foregroundStyle(.secondary) - } - - private var attributionText: some View { - Text(L10n.Tools.Tools.LineOfSight.elevationAttribution) - .font(.caption2) - .foregroundStyle(.tertiary) - } + private var emptyState: some View { + ContentUnavailableView( + L10n.Tools.Tools.LineOfSight.noData, + systemImage: "chart.xyaxis.line", + description: Text(L10n.Tools.Tools.LineOfSight.selectTwoPoints) + ) + .frame(height: chartHeight) + } + + private var chartCanvas: some View { + Canvas { context, size in + let coords = ChartCoordinateSpace( + canvasSize: size, + padding: padding, + xRange: xRange, + yRange: yRange + ) + + drawGrid(context: context, coords: coords) + drawFresnelZone(context: context, coords: coords) + drawFresnelBoundary(context: context, coords: coords) + drawTerrain(context: context, coords: coords) + drawObstructions(context: context, coords: coords) + drawLOSLine(context: context, coords: coords) + drawEndpointMarkers(context: context, coords: coords) + + // Draw vertical separator at R junction for off-path repeaters (behind R marker) + if let arDistance = segmentARDistanceMeters, isOffPath { + drawJunctionSeparator(context: context, coords: coords, atDistance: arDistance) + } + + // Draw repeater marker if present + if let pathFraction = repeaterPathFraction, + let height = repeaterHeight, + elevationProfile.count >= 2 { + // Interpolate ground elevation at repeater position + let index = pathFraction * Double(elevationProfile.count - 1) + let lowerIndex = Int(index) + let upperIndex = min(lowerIndex + 1, elevationProfile.count - 1) + let t = index - Double(lowerIndex) + let groundElevation = elevationProfile[lowerIndex].elevation + + t * (elevationProfile[upperIndex].elevation - elevationProfile[lowerIndex].elevation) + + drawRepeaterMarker( + context: context, + coords: coords, + pathFraction: pathFraction, + repeaterElevation: groundElevation, + height: height, + nudgeOffset: nudgeOffset + ) + } + } + .frame(height: chartHeight) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { size in + canvasSize = size + } + .gesture(repeaterDragGesture, including: onRepeaterDrag != nil ? .all : .subviews) + .onChange(of: repeaterPathFraction, initial: true) { oldValue, newValue in + // Trigger one-time nudge animation when repeater is first added (on-path only) + if oldValue == nil, newValue != nil, !hasAnimatedNudge, !isOffPath { + hasAnimatedNudge = true + triggerNudgeAnimation() + } + } + .onChange(of: calculatedMarkerCenter) { _, newCenter in + if let center = newCenter { + onRepeaterMarkerPosition?(center) + } + } + } + + private var repeaterDragGesture: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { value in + guard repeaterPathFraction != nil else { return } + handleDrag(at: value.location) + } + .onEnded { _ in + isDragging = false + } + } + + private func handleDrag(at location: CGPoint) { + // Convert X position to path fraction + let chartWidth = canvasSize.width - padding.leading - padding.trailing + let relativeX = (location.x - padding.leading) / chartWidth + let pathFraction = max(0.05, min(0.95, relativeX)) + + if !isDragging { + isDragging = true + } + + onRepeaterDrag?(pathFraction) + } + + /// Performs a one-time horizontal nudge animation to hint at draggability + private func triggerNudgeAnimation() { + // Animate: shift right 4pt, then back to original position + withAnimation(.easeOut(duration: 0.15)) { + nudgeOffset = 4 + } + withAnimation(.easeInOut(duration: 0.2).delay(0.15)) { + nudgeOffset = 0 + } + } + + private var legendView: some View { + HStack(spacing: 16) { + legendItem(color: terrainStroke, label: L10n.Tools.Tools.LineOfSight.Legend.terrain) + legendItem(color: losLineColor, label: L10n.Tools.Tools.LineOfSight.Legend.los) + legendItem(color: fresnelOuter, label: L10n.Tools.Tools.LineOfSight.Legend.clear) + legendItem(color: fresnelObstructed, label: L10n.Tools.Tools.LineOfSight.Legend.obstructed) + if repeaterPathFraction != nil { + legendItem(color: repeaterColor, label: L10n.Tools.Tools.LineOfSight.repeater) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + private func legendItem(color: Color, label: String) -> some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(label) + } + } + + private var indirectRouteLabel: some View { + Text(L10n.Tools.Tools.LineOfSight.indirectRoute) + .font(.caption2) + .foregroundStyle(.secondary) + } + + private var attributionText: some View { + Text(L10n.Tools.Tools.LineOfSight.elevationAttribution) + .font(.caption2) + .foregroundStyle(.tertiary) + } } // MARK: - Axis Helpers extension TerrainProfileCanvas { - - /// Calculate a "nice" step value for axis ticks - /// Returns a step that produces clean numbers like 10, 25, 50, 100, etc. - private func niceStep(for range: Double, targetDivisions: Int) -> Double { - guard range > 0, targetDivisions > 0 else { return 1 } - - let roughStep = range / Double(targetDivisions) - let magnitude = pow(10, floor(log10(roughStep))) - let normalized = roughStep / magnitude - - // Snap to nice values: 1, 2, 2.5, 5, 10 - let niceNormalized: Double - if normalized <= 1 { - niceNormalized = 1 - } else if normalized <= 2 { - niceNormalized = 2 - } else if normalized <= 2.5 { - niceNormalized = 2.5 - } else if normalized <= 5 { - niceNormalized = 5 - } else { - niceNormalized = 10 - } - - return niceNormalized * magnitude - } - - /// Generate tick values that start at a nice boundary - private func tickValues(for range: ClosedRange, step: Double) -> [Double] { - guard step > 0 else { return [] } - - let start = ceil(range.lowerBound / step) * step - var ticks: [Double] = [] - var current = start - - while current <= range.upperBound { - ticks.append(current) - current += step - } - - return ticks - } + /// Calculate a "nice" step value for axis ticks + /// Returns a step that produces clean numbers like 10, 25, 50, 100, etc. + private func niceStep(for range: Double, targetDivisions: Int) -> Double { + guard range > 0, targetDivisions > 0 else { return 1 } + + let roughStep = range / Double(targetDivisions) + let magnitude = pow(10, floor(log10(roughStep))) + let normalized = roughStep / magnitude + + // Snap to nice values: 1, 2, 2.5, 5, 10 + let niceNormalized: Double = if normalized <= 1 { + 1 + } else if normalized <= 2 { + 2 + } else if normalized <= 2.5 { + 2.5 + } else if normalized <= 5 { + 5 + } else { + 10 + } + + return niceNormalized * magnitude + } + + /// Generate tick values that start at a nice boundary + private func tickValues(for range: ClosedRange, step: Double) -> [Double] { + guard step > 0 else { return [] } + + let start = ceil(range.lowerBound / step) * step + var ticks: [Double] = [] + var current = start + + while current <= range.upperBound { + ticks.append(current) + current += step + } + + return ticks + } } // MARK: - Draw Functions extension TerrainProfileCanvas { + private func drawGrid(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Calculate nice step values + let yStep = niceStep(for: yRange.upperBound - yRange.lowerBound, targetDivisions: 4) + let xStep = niceStep(for: xRange.upperBound - xRange.lowerBound, targetDivisions: 5) + + let yTicks = tickValues(for: yRange, step: yStep) + let xTicks = tickValues(for: xRange, step: xStep) + + // Draw horizontal grid lines + let gridPath = Path { path in + for y in yTicks { + let startPoint = coords.point(x: xRange.lowerBound, y: y) + let endPoint = coords.point(x: xRange.upperBound, y: y) + path.move(to: startPoint) + path.addLine(to: endPoint) + } + } + + context.stroke( + gridPath, + with: .color(gridColor), + style: StrokeStyle(lineWidth: 0.5, dash: [4, 4]) + ) - private func drawGrid(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Calculate nice step values - let yStep = niceStep(for: yRange.upperBound - yRange.lowerBound, targetDivisions: 4) - let xStep = niceStep(for: xRange.upperBound - xRange.lowerBound, targetDivisions: 5) - - let yTicks = tickValues(for: yRange, step: yStep) - let xTicks = tickValues(for: xRange, step: xStep) - - // Draw horizontal grid lines - let gridPath = Path { path in - for y in yTicks { - let startPoint = coords.point(x: xRange.lowerBound, y: y) - let endPoint = coords.point(x: xRange.upperBound, y: y) - path.move(to: startPoint) - path.addLine(to: endPoint) - } - } - - context.stroke( - gridPath, - with: .color(gridColor), - style: StrokeStyle(lineWidth: 0.5, dash: [4, 4]) - ) - - // Y-axis labels (elevation in meters) - for (index, y) in yTicks.enumerated() { - let labelPoint = coords.point(x: xRange.lowerBound, y: y) - let isLast = index == yTicks.count - 1 - let labelText = isLast ? "\(Int(y)) m" : "\(Int(y))" - let label = Text(labelText) - .font(.system(size: 9)) - .foregroundStyle(.secondary) - context.draw(label, at: CGPoint(x: labelPoint.x - 8, y: labelPoint.y), anchor: .trailing) - } - - // X-axis labels (distance in km) - for (index, x) in xTicks.enumerated() { - let labelPoint = coords.point(x: x, y: yRange.lowerBound) - let kmValue = x / 1000 - let isLast = index == xTicks.count - 1 - - // Format based on step size - use integers for whole km values - let labelText: String - if xStep >= 1000 && kmValue.truncatingRemainder(dividingBy: 1) == 0 { - labelText = isLast ? "\(Int(kmValue)) km" : "\(Int(kmValue))" - } else { - labelText = isLast - ? "\(kmValue.formatted(.number.precision(.fractionLength(1)))) km" - : kmValue.formatted(.number.precision(.fractionLength(1))) - } - - let label = Text(labelText) - .font(.system(size: 9)) - .foregroundStyle(.secondary) - context.draw(label, at: CGPoint(x: labelPoint.x, y: labelPoint.y + 10), anchor: .top) - } - } - - private func drawFresnelZone(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Draw primary segment (A→B or A→R) - drawFresnelForSamples(context: context, coords: coords, samples: profileSamples) - - // Draw secondary segment (R→B) if present - if !profileSamplesRB.isEmpty { - drawFresnelForSamples(context: context, coords: coords, samples: profileSamplesRB) - } + // Y-axis labels (elevation in meters) + for (index, y) in yTicks.enumerated() { + let labelPoint = coords.point(x: xRange.lowerBound, y: y) + let isLast = index == yTicks.count - 1 + let labelText = isLast ? "\(Int(y)) m" : "\(Int(y))" + let label = Text(labelText) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + context.draw(label, at: CGPoint(x: labelPoint.x - 8, y: labelPoint.y), anchor: .trailing) + } + + // X-axis labels (distance in km) + for (index, x) in xTicks.enumerated() { + let labelPoint = coords.point(x: x, y: yRange.lowerBound) + let kmValue = x / 1000 + let isLast = index == xTicks.count - 1 + + // Format based on step size - use integers for whole km values + let labelText: String = if xStep >= 1000, kmValue.truncatingRemainder(dividingBy: 1) == 0 { + isLast ? "\(Int(kmValue)) km" : "\(Int(kmValue))" + } else { + isLast + ? "\(kmValue.formatted(.number.precision(.fractionLength(1)))) km" + : kmValue.formatted(.number.precision(.fractionLength(1))) + } + + let label = Text(labelText) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + context.draw(label, at: CGPoint(x: labelPoint.x, y: labelPoint.y + 10), anchor: .top) } - - private func drawFresnelForSamples( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample] - ) { - guard samples.count >= 2 else { return } - - drawOuterFresnelFill(context: context, coords: coords, samples: samples) - drawInnerFresnelFill(context: context, coords: coords, samples: samples) + } + + private func drawFresnelZone(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Draw primary segment (A→B or A→R) + drawFresnelForSamples(context: context, coords: coords, samples: profileSamples) + + // Draw secondary segment (R→B) if present + if !profileSamplesRB.isEmpty { + drawFresnelForSamples(context: context, coords: coords, samples: profileSamplesRB) + } + } + + private func drawFresnelForSamples( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample] + ) { + guard samples.count >= 2 else { return } + + drawOuterFresnelFill(context: context, coords: coords, samples: samples) + drawInnerFresnelFill(context: context, coords: coords, samples: samples) + } + + private func drawObstructions(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Draw obstructions for primary segment + drawObstructionOverlay(context: context, coords: coords, samples: profileSamples) + + // Draw obstructions for secondary segment if present + if !profileSamplesRB.isEmpty { + drawObstructionOverlay(context: context, coords: coords, samples: profileSamplesRB) + } + } + + private func drawOuterFresnelFill( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample] + ) { + var path = Path() + + // Top edge: left to right + if let first = samples.first { + path.move(to: coords.point(x: first.x, y: first.yTop)) + } + for sample in samples.dropFirst() { + path.addLine(to: coords.point(x: sample.x, y: sample.yTop)) + } + + // Bottom edge: right to left (clamped to terrain) + for sample in samples.reversed() { + path.addLine(to: coords.point(x: sample.x, y: sample.yVisibleBottom)) + } + + path.closeSubpath() + context.fill(path, with: .color(fresnelOuter)) + } + + private func drawInnerFresnelFill( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample] + ) { + var path = Path() + + // Top edge: left to right + if let first = samples.first { + path.move(to: coords.point(x: first.x, y: first.yTop60)) + } + for sample in samples.dropFirst() { + path.addLine(to: coords.point(x: sample.x, y: sample.yTop60)) + } + + // Bottom edge: right to left (clamped to terrain) + for sample in samples.reversed() { + path.addLine(to: coords.point(x: sample.x, y: sample.yVisibleBottom60)) + } + + path.closeSubpath() + context.fill(path, with: .color(fresnelInner)) + } + + private func drawObstructionOverlay( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample] + ) { + // Find contiguous obstructed regions and draw overlay + var inObstructedRegion = false + var regionStart = 0 + + for (index, sample) in samples.enumerated() { + if sample.isObstructed, !inObstructedRegion { + // Start of obstructed region + inObstructedRegion = true + regionStart = index + } else if !sample.isObstructed, inObstructedRegion { + // End of obstructed region + drawObstructedRegion( + context: context, + coords: coords, + samples: samples, + startIndex: regionStart, + endIndex: index - 1 + ) + inObstructedRegion = false + } } - private func drawObstructions(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Draw obstructions for primary segment - drawObstructionOverlay(context: context, coords: coords, samples: profileSamples) - - // Draw obstructions for secondary segment if present - if !profileSamplesRB.isEmpty { - drawObstructionOverlay(context: context, coords: coords, samples: profileSamplesRB) - } + // Handle region that extends to end + if inObstructedRegion { + drawObstructedRegion( + context: context, + coords: coords, + samples: samples, + startIndex: regionStart, + endIndex: samples.count - 1 + ) } + } - private func drawOuterFresnelFill( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample] - ) { - var path = Path() + private func drawObstructedRegion( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample], + startIndex: Int, + endIndex: Int + ) { + guard startIndex <= endIndex else { return } - // Top edge: left to right - if let first = samples.first { - path.move(to: coords.point(x: first.x, y: first.yTop)) - } - for sample in samples.dropFirst() { - path.addLine(to: coords.point(x: sample.x, y: sample.yTop)) - } + let regionSamples = Array(samples[startIndex...endIndex]) + guard let first = regionSamples.first, let last = regionSamples.last else { return } - // Bottom edge: right to left (clamped to terrain) - for sample in samples.reversed() { - path.addLine(to: coords.point(x: sample.x, y: sample.yVisibleBottom)) - } - - path.closeSubpath() - context.fill(path, with: .color(fresnelOuter)) + // Ensure a minimum pixel width so single-sample obstructions are visible + let minWidth: CGFloat = 4 + var leftX = coords.xPixel(first.x) + var rightX = coords.xPixel(last.x) + if rightX - leftX < minWidth { + let center = (leftX + rightX) / 2 + leftX = center - minWidth / 2 + rightX = center + minWidth / 2 } - private func drawInnerFresnelFill( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample] - ) { - var path = Path() + let topY = coords.yPixel(yRange.upperBound) + let bottomY = coords.yPixel(yRange.lowerBound) - // Top edge: left to right - if let first = samples.first { - path.move(to: coords.point(x: first.x, y: first.yTop60)) - } - for sample in samples.dropFirst() { - path.addLine(to: coords.point(x: sample.x, y: sample.yTop60)) - } + var path = Path() + path.move(to: CGPoint(x: leftX, y: topY)) + path.addLine(to: CGPoint(x: rightX, y: topY)) + path.addLine(to: CGPoint(x: rightX, y: bottomY)) + path.addLine(to: CGPoint(x: leftX, y: bottomY)) + path.closeSubpath() - // Bottom edge: right to left (clamped to terrain) - for sample in samples.reversed() { - path.addLine(to: coords.point(x: sample.x, y: sample.yVisibleBottom60)) - } + context.fill(path, with: .color(fresnelObstructed)) + } - path.closeSubpath() - context.fill(path, with: .color(fresnelInner)) - } - - private func drawObstructionOverlay( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample] - ) { - // Find contiguous obstructed regions and draw overlay - var inObstructedRegion = false - var regionStart = 0 - - for (index, sample) in samples.enumerated() { - if sample.isObstructed && !inObstructedRegion { - // Start of obstructed region - inObstructedRegion = true - regionStart = index - } else if !sample.isObstructed && inObstructedRegion { - // End of obstructed region - drawObstructedRegion( - context: context, - coords: coords, - samples: samples, - startIndex: regionStart, - endIndex: index - 1 - ) - inObstructedRegion = false - } - } + private func drawFresnelBoundary(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Draw boundary for primary segment + drawFresnelBoundaryForSamples(context: context, coords: coords, samples: profileSamples) - // Handle region that extends to end - if inObstructedRegion { - drawObstructedRegion( - context: context, - coords: coords, - samples: samples, - startIndex: regionStart, - endIndex: samples.count - 1 - ) - } + // Draw boundary for secondary segment if present + if !profileSamplesRB.isEmpty { + drawFresnelBoundaryForSamples(context: context, coords: coords, samples: profileSamplesRB) } + } - private func drawObstructedRegion( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample], - startIndex: Int, - endIndex: Int - ) { - guard startIndex <= endIndex else { return } - - let regionSamples = Array(samples[startIndex...endIndex]) - guard let first = regionSamples.first, let last = regionSamples.last else { return } - - // Ensure a minimum pixel width so single-sample obstructions are visible - let minWidth: CGFloat = 4 - var leftX = coords.xPixel(first.x) - var rightX = coords.xPixel(last.x) - if rightX - leftX < minWidth { - let center = (leftX + rightX) / 2 - leftX = center - minWidth / 2 - rightX = center + minWidth / 2 - } - - let topY = coords.yPixel(yRange.upperBound) - let bottomY = coords.yPixel(yRange.lowerBound) - - var path = Path() - path.move(to: CGPoint(x: leftX, y: topY)) - path.addLine(to: CGPoint(x: rightX, y: topY)) - path.addLine(to: CGPoint(x: rightX, y: bottomY)) - path.addLine(to: CGPoint(x: leftX, y: bottomY)) - path.closeSubpath() + private func drawFresnelBoundaryForSamples( + context: GraphicsContext, + coords: ChartCoordinateSpace, + samples: [ProfileSample] + ) { + guard samples.count >= 2 else { return } - context.fill(path, with: .color(fresnelObstructed)) + // Top boundary (theoretical ellipse top) + var topPath = Path() + if let first = samples.first { + topPath.move(to: coords.point(x: first.x, y: first.yTop)) } - - private func drawFresnelBoundary(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Draw boundary for primary segment - drawFresnelBoundaryForSamples(context: context, coords: coords, samples: profileSamples) - - // Draw boundary for secondary segment if present - if !profileSamplesRB.isEmpty { - drawFresnelBoundaryForSamples(context: context, coords: coords, samples: profileSamplesRB) - } + for sample in samples.dropFirst() { + topPath.addLine(to: coords.point(x: sample.x, y: sample.yTop)) } - private func drawFresnelBoundaryForSamples( - context: GraphicsContext, - coords: ChartCoordinateSpace, - samples: [ProfileSample] - ) { - guard samples.count >= 2 else { return } - - // Top boundary (theoretical ellipse top) - var topPath = Path() - if let first = samples.first { - topPath.move(to: coords.point(x: first.x, y: first.yTop)) - } - for sample in samples.dropFirst() { - topPath.addLine(to: coords.point(x: sample.x, y: sample.yTop)) - } - - // Bottom boundary (theoretical ellipse bottom) - var bottomPath = Path() - if let first = samples.first { - bottomPath.move(to: coords.point(x: first.x, y: first.yBottom)) - } - for sample in samples.dropFirst() { - bottomPath.addLine(to: coords.point(x: sample.x, y: sample.yBottom)) - } - - let style = StrokeStyle(lineWidth: 1, dash: [4, 3]) - context.stroke(topPath, with: .color(fresnelBoundary), style: style) - context.stroke(bottomPath, with: .color(fresnelBoundary), style: style) + // Bottom boundary (theoretical ellipse bottom) + var bottomPath = Path() + if let first = samples.first { + bottomPath.move(to: coords.point(x: first.x, y: first.yBottom)) } - - private func drawTerrain(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Combine samples for full terrain (avoid duplicate at junction) - let allSamples: [ProfileSample] - if profileSamplesRB.isEmpty { - allSamples = profileSamples - } else { - allSamples = profileSamples + profileSamplesRB.dropFirst() - } - - guard allSamples.count >= 2 else { return } - - // Build terrain fill path - var fillPath = Path() - - // Start at bottom-left - let bottomLeft = coords.point(x: xRange.lowerBound, y: yRange.lowerBound) - fillPath.move(to: bottomLeft) - - // Trace terrain line - for sample in allSamples { - fillPath.addLine(to: coords.point(x: sample.x, y: sample.yTerrain)) - } - - // Close at bottom-right and back - let bottomRight = coords.point(x: xRange.upperBound, y: yRange.lowerBound) - fillPath.addLine(to: bottomRight) - fillPath.closeSubpath() - - // Fill terrain - context.fill(fillPath, with: .color(terrainFill)) - - // Build terrain stroke path (just the top edge) - var strokePath = Path() - if let first = allSamples.first { - strokePath.move(to: coords.point(x: first.x, y: first.yTerrain)) - } - for sample in allSamples.dropFirst() { - strokePath.addLine(to: coords.point(x: sample.x, y: sample.yTerrain)) - } - - // Stroke terrain outline - context.stroke( - strokePath, - with: .color(terrainStroke), - style: StrokeStyle(lineWidth: 1.5) - ) + for sample in samples.dropFirst() { + bottomPath.addLine(to: coords.point(x: sample.x, y: sample.yBottom)) } - private func drawLOSLine(context: GraphicsContext, coords: ChartCoordinateSpace) { - // Draw primary segment LOS line (A→B or A→R) - if let first = profileSamples.first, let last = profileSamples.last { - var path = Path() - path.move(to: coords.point(x: first.x, y: first.yLOS)) - path.addLine(to: coords.point(x: last.x, y: last.yLOS)) - context.stroke(path, with: .color(losLineColor), style: StrokeStyle(lineWidth: 2)) - } + let style = StrokeStyle(lineWidth: 1, dash: [4, 3]) + context.stroke(topPath, with: .color(fresnelBoundary), style: style) + context.stroke(bottomPath, with: .color(fresnelBoundary), style: style) + } - // Draw secondary segment LOS line (R→B) if present - if let first = profileSamplesRB.first, let last = profileSamplesRB.last { - var path = Path() - path.move(to: coords.point(x: first.x, y: first.yLOS)) - path.addLine(to: coords.point(x: last.x, y: last.yLOS)) - context.stroke(path, with: .color(losLineColor), style: StrokeStyle(lineWidth: 2)) - } + private func drawTerrain(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Combine samples for full terrain (avoid duplicate at junction) + let allSamples: [ProfileSample] = if profileSamplesRB.isEmpty { + profileSamples + } else { + profileSamples + profileSamplesRB.dropFirst() } - private func drawEndpointMarkers(context: GraphicsContext, coords: ChartCoordinateSpace) { - guard let sampleA = profileSamples.first else { return } - - // B is from R→B segment if present, otherwise from primary segment - let sampleB = profileSamplesRB.last ?? profileSamples.last - guard let sampleB else { return } - - let markerRadius: CGFloat = 6 - - // Point A marker (blue) - let pointA = coords.point(x: sampleA.x, y: sampleA.yLOS) - let circleA = Path(ellipseIn: CGRect( - x: pointA.x - markerRadius, - y: pointA.y - markerRadius, - width: markerRadius * 2, - height: markerRadius * 2 - )) - context.fill(circleA, with: .color(.blue)) - - // Point B marker (green) - let pointB = coords.point(x: sampleB.x, y: sampleB.yLOS) - let circleB = Path(ellipseIn: CGRect( - x: pointB.x - markerRadius, - y: pointB.y - markerRadius, - width: markerRadius * 2, - height: markerRadius * 2 - )) - context.fill(circleB, with: .color(.green)) - - // Labels inside markers (matching repeater style) - let labelA = Text("A") - .font(.system(size: 8, weight: .bold)) - .foregroundStyle(.white) - context.draw(labelA, at: pointA, anchor: .center) - - let labelB = Text("B") - .font(.system(size: 8, weight: .bold)) - .foregroundStyle(.white) - context.draw(labelB, at: pointB, anchor: .center) - } - - private func drawRepeaterMarker( - context: GraphicsContext, - coords: ChartCoordinateSpace, - pathFraction: Double, - repeaterElevation: Double, - height: Double, - nudgeOffset: CGFloat - ) { - // Get the LOS intersection point from profileSamples (junction of A→R and R→B) - // The repeater marker should be at the LOS line intersection, not ground level - guard let junctionSample = profileSamples.last else { return } - - let x = junctionSample.x - let losY = junctionSample.yLOS - let groundY = junctionSample.yTerrain - - // Draw vertical line from ground to LOS intersection (apply nudge offset) - let groundPoint = coords.point(x: x, y: groundY) - let losPoint = coords.point(x: x, y: losY) - let nudgedGroundPoint = CGPoint(x: groundPoint.x + nudgeOffset, y: groundPoint.y) - let nudgedLosPoint = CGPoint(x: losPoint.x + nudgeOffset, y: losPoint.y) - - var linePath = Path() - linePath.move(to: nudgedGroundPoint) - linePath.addLine(to: nudgedLosPoint) - context.stroke(linePath, with: .color(repeaterColor), lineWidth: 2) - - // Draw repeater marker circle at LOS intersection (16pt radius = 32pt diameter) - let markerRadius: CGFloat = 16 - let markerRect = CGRect( - x: nudgedLosPoint.x - markerRadius, - y: nudgedLosPoint.y - markerRadius, - width: markerRadius * 2, - height: markerRadius * 2 - ) - let markerPath = Circle().path(in: markerRect) + guard allSamples.count >= 2 else { return } - // Draw shadow first - context.drawLayer { shadowContext in - shadowContext.addFilter(.shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 2)) - shadowContext.fill(markerPath, with: .color(repeaterColor)) - } + // Build terrain fill path + var fillPath = Path() - // Draw the actual marker on top - context.fill(markerPath, with: .color(repeaterColor)) + // Start at bottom-left + let bottomLeft = coords.point(x: xRange.lowerBound, y: yRange.lowerBound) + fillPath.move(to: bottomLeft) - // Draw "R" label (larger font to match increased marker size) - let text = Text("R") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) - context.draw(text, at: nudgedLosPoint, anchor: .center) + // Trace terrain line + for sample in allSamples { + fillPath.addLine(to: coords.point(x: sample.x, y: sample.yTerrain)) } - private func drawJunctionSeparator( - context: GraphicsContext, - coords: ChartCoordinateSpace, - atDistance distance: Double - ) { - // Draw vertical dashed line at the junction point - let topPoint = coords.point(x: distance, y: yRange.upperBound) - let bottomPoint = coords.point(x: distance, y: yRange.lowerBound) + // Close at bottom-right and back + let bottomRight = coords.point(x: xRange.upperBound, y: yRange.lowerBound) + fillPath.addLine(to: bottomRight) + fillPath.closeSubpath() - var path = Path() - path.move(to: topPoint) - path.addLine(to: bottomPoint) + // Fill terrain + context.fill(fillPath, with: .color(terrainFill)) - let style = StrokeStyle(lineWidth: 1.5, dash: [6, 4]) - context.stroke(path, with: .color(.primary.opacity(0.35)), style: style) + // Build terrain stroke path (just the top edge) + var strokePath = Path() + if let first = allSamples.first { + strokePath.move(to: coords.point(x: first.x, y: first.yTerrain)) } -} - -#Preview("Canvas Profile") { - let sampleProfile: [ElevationSample] = (0...20).map { i in - let distance = Double(i) * 500 - let baseElevation = 100.0 - let hillFactor = sin(Double(i) / 20.0 * .pi) * 150 - - return ElevationSample( - coordinate: .init(latitude: 37.7749 + Double(i) * 0.001, longitude: -122.4194), - elevation: baseElevation + hillFactor, - distanceFromAMeters: distance - ) + for sample in allSamples.dropFirst() { + strokePath.addLine(to: coords.point(x: sample.x, y: sample.yTerrain)) } - let samples = FresnelZoneRenderer.buildProfileSamples( - from: sampleProfile, - pointAHeight: 10, - pointBHeight: 15, - frequencyMHz: 906, - refractionK: 4.0 / 3.0 + // Stroke terrain outline + context.stroke( + strokePath, + with: .color(terrainStroke), + style: StrokeStyle(lineWidth: 1.5) ) + } + + private func drawLOSLine(context: GraphicsContext, coords: ChartCoordinateSpace) { + // Draw primary segment LOS line (A→B or A→R) + if let first = profileSamples.first, let last = profileSamples.last { + var path = Path() + path.move(to: coords.point(x: first.x, y: first.yLOS)) + path.addLine(to: coords.point(x: last.x, y: last.yLOS)) + context.stroke(path, with: .color(losLineColor), style: StrokeStyle(lineWidth: 2)) + } + + // Draw secondary segment LOS line (R→B) if present + if let first = profileSamplesRB.first, let last = profileSamplesRB.last { + var path = Path() + path.move(to: coords.point(x: first.x, y: first.yLOS)) + path.addLine(to: coords.point(x: last.x, y: last.yLOS)) + context.stroke(path, with: .color(losLineColor), style: StrokeStyle(lineWidth: 2)) + } + } + + private func drawEndpointMarkers(context: GraphicsContext, coords: ChartCoordinateSpace) { + guard let sampleA = profileSamples.first else { return } + + // B is from R→B segment if present, otherwise from primary segment + let sampleB = profileSamplesRB.last ?? profileSamples.last + guard let sampleB else { return } + + let markerRadius: CGFloat = 6 + + // Point A marker (blue) + let pointA = coords.point(x: sampleA.x, y: sampleA.yLOS) + let circleA = Path(ellipseIn: CGRect( + x: pointA.x - markerRadius, + y: pointA.y - markerRadius, + width: markerRadius * 2, + height: markerRadius * 2 + )) + context.fill(circleA, with: .color(.blue)) + + // Point B marker (green) + let pointB = coords.point(x: sampleB.x, y: sampleB.yLOS) + let circleB = Path(ellipseIn: CGRect( + x: pointB.x - markerRadius, + y: pointB.y - markerRadius, + width: markerRadius * 2, + height: markerRadius * 2 + )) + context.fill(circleB, with: .color(.green)) + + // Labels inside markers (matching repeater style) + let labelA = Text("A") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white) + context.draw(labelA, at: pointA, anchor: .center) + + let labelB = Text("B") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white) + context.draw(labelB, at: pointB, anchor: .center) + } + + private func drawRepeaterMarker( + context: GraphicsContext, + coords: ChartCoordinateSpace, + pathFraction: Double, + repeaterElevation: Double, + height: Double, + nudgeOffset: CGFloat + ) { + // Get the LOS intersection point from profileSamples (junction of A→R and R→B) + // The repeater marker should be at the LOS line intersection, not ground level + guard let junctionSample = profileSamples.last else { return } + + let x = junctionSample.x + let losY = junctionSample.yLOS + let groundY = junctionSample.yTerrain + + // Draw vertical line from ground to LOS intersection (apply nudge offset) + let groundPoint = coords.point(x: x, y: groundY) + let losPoint = coords.point(x: x, y: losY) + let nudgedGroundPoint = CGPoint(x: groundPoint.x + nudgeOffset, y: groundPoint.y) + let nudgedLosPoint = CGPoint(x: losPoint.x + nudgeOffset, y: losPoint.y) + + var linePath = Path() + linePath.move(to: nudgedGroundPoint) + linePath.addLine(to: nudgedLosPoint) + context.stroke(linePath, with: .color(repeaterColor), lineWidth: 2) + + // Draw repeater marker circle at LOS intersection (16pt radius = 32pt diameter) + let markerRadius: CGFloat = 16 + let markerRect = CGRect( + x: nudgedLosPoint.x - markerRadius, + y: nudgedLosPoint.y - markerRadius, + width: markerRadius * 2, + height: markerRadius * 2 + ) + let markerPath = Circle().path(in: markerRect) + + // Draw shadow first + context.drawLayer { shadowContext in + shadowContext.addFilter(.shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 2)) + shadowContext.fill(markerPath, with: .color(repeaterColor)) + } + + // Draw the actual marker on top + context.fill(markerPath, with: .color(repeaterColor)) + + // Draw "R" label (larger font to match increased marker size) + let text = Text("R") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + context.draw(text, at: nudgedLosPoint, anchor: .center) + } + + private func drawJunctionSeparator( + context: GraphicsContext, + coords: ChartCoordinateSpace, + atDistance distance: Double + ) { + // Draw vertical dashed line at the junction point + let topPoint = coords.point(x: distance, y: yRange.upperBound) + let bottomPoint = coords.point(x: distance, y: yRange.lowerBound) + + var path = Path() + path.move(to: topPoint) + path.addLine(to: bottomPoint) + + let style = StrokeStyle(lineWidth: 1.5, dash: [6, 4]) + context.stroke(path, with: .color(.primary.opacity(0.35)), style: style) + } +} - return TerrainProfileCanvas( - elevationProfile: sampleProfile, - profileSamples: samples +#Preview("Canvas Profile") { + let sampleProfile: [ElevationSample] = (0...20).map { i in + let distance = Double(i) * 500 + let baseElevation = 100.0 + let hillFactor = sin(Double(i) / 20.0 * .pi) * 150 + + return ElevationSample( + coordinate: .init(latitude: 37.7749 + Double(i) * 0.001, longitude: -122.4194), + elevation: baseElevation + hillFactor, + distanceFromAMeters: distance ) - .padding() + } + + let samples = FresnelZoneRenderer.buildProfileSamples( + from: sampleProfile, + pointAHeight: 10, + pointBHeight: 15, + frequencyMHz: 906, + refractionK: 4.0 / 3.0 + ) + + return TerrainProfileCanvas( + elevationProfile: sampleProfile, + profileSamples: samples + ) + .padding() } diff --git a/MC1/Views/Tools/LineOfSight/TerrainProfileSectionView.swift b/MC1/Views/Tools/LineOfSight/TerrainProfileSectionView.swift index 0866ccd6..cc26725d 100644 --- a/MC1/Views/Tools/LineOfSight/TerrainProfileSectionView.swift +++ b/MC1/Views/Tools/LineOfSight/TerrainProfileSectionView.swift @@ -1,58 +1,58 @@ import SwiftUI struct TerrainProfileSectionView: View { - var viewModel: LineOfSightViewModel - @Binding var showDragHint: Bool - @Binding var repeaterMarkerCenter: CGPoint? + var viewModel: LineOfSightViewModel + @Binding var showDragHint: Bool + @Binding var repeaterMarkerCenter: CGPoint? - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text(L10n.Tools.Tools.LineOfSight.terrainProfile) - .font(.headline) + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L10n.Tools.Tools.LineOfSight.terrainProfile) + .font(.headline) - Spacer() + Spacer() - Label( - L10n.Tools.Tools.LineOfSight.earthCurvature(LOSFormatters.formatKFactor(viewModel.refractionK)), - systemImage: "globe" - ) - .font(.caption2) - .foregroundStyle(.secondary) - } + Label( + L10n.Tools.Tools.LineOfSight.earthCurvature(LOSFormatters.formatKFactor(viewModel.refractionK)), + systemImage: "globe" + ) + .font(.caption2) + .foregroundStyle(.secondary) + } - TerrainProfileCanvas( - elevationProfile: viewModel.terrainElevationProfile, - profileSamples: viewModel.profileSamples, - profileSamplesRB: viewModel.profileSamplesRB, - // Show repeater marker for both on-path and off-path - repeaterPathFraction: viewModel.repeaterVisualizationPathFraction, - repeaterHeight: viewModel.repeaterPoint.map { $0.additionalHeight }, - // Only enable drag for on-path repeaters - onRepeaterDrag: viewModel.repeaterPoint?.isOnPath == true ? { pathFraction in - viewModel.updateRepeaterPosition(pathFraction: pathFraction) - viewModel.analyzeWithRepeater() - } : nil, - onRepeaterMarkerPosition: { center in - repeaterMarkerCenter = center - }, - // Off-path segment distances for separator and labels - segmentARDistanceMeters: viewModel.segmentARDistanceMeters, - segmentRBDistanceMeters: viewModel.segmentRBDistanceMeters - ) - .overlay { - if showDragHint, let center = repeaterMarkerCenter { - Text(L10n.Tools.Tools.LineOfSight.dragToAdjust) - .font(.caption) - .foregroundStyle(.primary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.regularMaterial, in: .capsule) - .shadow(color: .black.opacity(0.15), radius: 4, y: 2) - .transition(.opacity.combined(with: .scale)) - .position(x: center.x, y: center.y + 30) - } - } + TerrainProfileCanvas( + elevationProfile: viewModel.terrainElevationProfile, + profileSamples: viewModel.profileSamples, + profileSamplesRB: viewModel.profileSamplesRB, + // Show repeater marker for both on-path and off-path + repeaterPathFraction: viewModel.repeaterVisualizationPathFraction, + repeaterHeight: viewModel.repeaterPoint.map(\.additionalHeight), + // Only enable drag for on-path repeaters + onRepeaterDrag: viewModel.repeaterPoint?.isOnPath == true ? { pathFraction in + viewModel.updateRepeaterPosition(pathFraction: pathFraction) + viewModel.analyzeWithRepeater() + } : nil, + onRepeaterMarkerPosition: { center in + repeaterMarkerCenter = center + }, + // Off-path segment distances for separator and labels + segmentARDistanceMeters: viewModel.segmentARDistanceMeters, + segmentRBDistanceMeters: viewModel.segmentRBDistanceMeters + ) + .overlay { + if showDragHint, let center = repeaterMarkerCenter { + Text(L10n.Tools.Tools.LineOfSight.dragToAdjust) + .font(.caption) + .foregroundStyle(.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.regularMaterial, in: .capsule) + .shadow(color: .black.opacity(0.15), radius: 4, y: 2) + .transition(.opacity.combined(with: .scale)) + .position(x: center.x, y: center.y + 30) } + } } + } } diff --git a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryRowView.swift b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryRowView.swift index 35afb0e2..d71317a6 100644 --- a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryRowView.swift +++ b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryRowView.swift @@ -2,97 +2,97 @@ import MC1Services import SwiftUI struct NodeDiscoveryRowView: View { - let result: NodeDiscoveryResult - var isAdded = false - var isAdding = false - var onAdd: (() -> Void)? - - var body: some View { - HStack { - avatarView - .padding(.trailing, 4) + let result: NodeDiscoveryResult + var isAdded = false + var isAdding = false + var onAdd: (() -> Void)? - VStack(alignment: .leading, spacing: 2) { - Text(result.name) - .font(.body) - .bold() - .lineLimit(1) + var body: some View { + HStack { + avatarView + .padding(.trailing, 4) - hexLine + VStack(alignment: .leading, spacing: 2) { + Text(result.name) + .font(.body) + .bold() + .lineLimit(1) - MetricsLine(result: result) - } + hexLine - if let onAdd { - Spacer() - if isAdded { - Button(L10n.Contacts.Contacts.Discovery.added) {} - .buttonStyle(.bordered) - .disabled(true) - .accessibilityLabel(L10n.Contacts.Contacts.Discovery.addedAccessibility) - } else { - Button(L10n.Contacts.Contacts.Discovery.add) { - onAdd() - } - .buttonStyle(.borderedProminent) - .disabled(isAdding) - } - } - } - .padding(.vertical, 4) - } + MetricsLine(result: result) + } - @ViewBuilder - private var avatarView: some View { - if result.scanFilter == .repeaters { - NodeAvatar(publicKey: result.publicKey, role: .repeater, size: 40) + if let onAdd { + Spacer() + if isAdded { + Button(L10n.Contacts.Contacts.Discovery.added) {} + .buttonStyle(.bordered) + .disabled(true) + .accessibilityLabel(L10n.Contacts.Contacts.Discovery.addedAccessibility) } else { - ZStack { - Circle().fill(.gray.opacity(0.3)) - Image(systemName: "sensor") - .font(.body.weight(.semibold)) - .foregroundStyle(.secondary) - } - .frame(width: 40, height: 40) + Button(L10n.Contacts.Contacts.Discovery.add) { + onAdd() + } + .buttonStyle(.borderedProminent) + .disabled(isAdding) } + } } + .padding(.vertical, 4) + } - private var hexLine: some View { - let hex = result.publicKey.map { String(format: "%02X", $0) }.joined() - return Text(hex) - .font(.caption) - .monospaced() - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.middle) + @ViewBuilder + private var avatarView: some View { + if result.scanFilter == .repeaters { + NodeAvatar(publicKey: result.publicKey, role: .repeater, size: 40) + } else { + ZStack { + Circle().fill(.gray.opacity(0.3)) + Image(systemName: "sensor") + .font(.body.weight(.semibold)) + .foregroundStyle(.secondary) + } + .frame(width: 40, height: 40) } + } + + private var hexLine: some View { + let hex = result.publicKey.map { String(format: "%02X", $0) }.joined() + return Text(hex) + .font(.caption) + .monospaced() + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } } // MARK: - Metrics Line extension NodeDiscoveryRowView { - private struct MetricsLine: View { - let result: NodeDiscoveryResult + private struct MetricsLine: View { + let result: NodeDiscoveryResult - var body: some View { - let nodeTypeLabel = result.scanFilter == .repeaters - ? L10n.Tools.Tools.NodeDiscovery.typeRepeater - : L10n.Tools.Tools.NodeDiscovery.typeSensor + var body: some View { + let nodeTypeLabel = result.scanFilter == .repeaters + ? L10n.Tools.Tools.NodeDiscovery.typeRepeater + : L10n.Tools.Tools.NodeDiscovery.typeSensor - let snrDown = L10n.Tools.Tools.NodeDiscovery.snrDown( - result.snr.formatted(.number.precision(.fractionLength(1))) - ) - let snrUp = L10n.Tools.Tools.NodeDiscovery.snrUp( - result.snrIn.formatted(.number.precision(.fractionLength(1))) - ) - let rssiText = L10n.Tools.Tools.NodeDiscovery.rssi( - result.rssi.formatted() - ) + let snrDown = L10n.Tools.Tools.NodeDiscovery.snrDown( + result.snr.formatted(.number.precision(.fractionLength(1))) + ) + let snrUp = L10n.Tools.Tools.NodeDiscovery.snrUp( + result.snrIn.formatted(.number.precision(.fractionLength(1))) + ) + let rssiText = L10n.Tools.Tools.NodeDiscovery.rssi( + result.rssi.formatted() + ) - Text("\(nodeTypeLabel) · \(snrDown) \(snrUp) · \(rssiText)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + Text("\(nodeTypeLabel) · \(snrDown) \(snrUp) · \(rssiText)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } + } } diff --git a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryView.swift b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryView.swift index 9e5fe8e8..b54cf823 100644 --- a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryView.swift +++ b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryView.swift @@ -2,211 +2,211 @@ import MC1Services import SwiftUI struct NodeDiscoveryView: View { - @Environment(\.appState) private var appState - @State private var viewModel = NodeDiscoveryViewModel() - - private var isConnected: Bool { - appState.services?.session != nil + @Environment(\.appState) private var appState + @State private var viewModel = NodeDiscoveryViewModel() + + private var isConnected: Bool { + appState.services?.session != nil + } + + var body: some View { + Group { + if !isConnected { + disconnectedState + } else if !viewModel.isScanning, viewModel.sortedResults.isEmpty, viewModel.errorMessage == nil { + initialState + } else { + ResultsList(viewModel: viewModel) + } } - - var body: some View { - Group { - if !isConnected { - disconnectedState - } else if !viewModel.isScanning && viewModel.sortedResults.isEmpty && viewModel.errorMessage == nil { - initialState - } else { - ResultsList(viewModel: viewModel) - } - } - .safeAreaInset(edge: .bottom) { - if isConnected { - ScanButtonBar(viewModel: viewModel) - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - SortMenu(viewModel: viewModel) - } - } - .errorAlert($viewModel.errorMessage, title: L10n.Tools.Tools.NodeDiscovery.errorTitle) - .sensoryFeedback(.impact(weight: .medium), trigger: viewModel.scanStartHapticTrigger) - .sensoryFeedback(.success, trigger: viewModel.scanSuccessHapticTrigger) - .sensoryFeedback(.warning, trigger: viewModel.scanEmptyHapticTrigger) - .sensoryFeedback(.success, trigger: viewModel.addSuccessHapticTrigger) - .sensoryFeedback(.error, trigger: viewModel.addErrorHapticTrigger) - .task(id: appState.servicesVersion) { - viewModel.configure(dependencies: NodeDiscoveryViewModel.Dependencies( - session: { [appState] in appState.services?.session }, - dataStore: { [appState] in appState.offlineDataStore }, - radioID: { [appState] in appState.connectedDevice?.radioID }, - contactService: { [appState] in appState.services?.contactService }, - maxContacts: { [appState] in appState.connectedDevice?.maxContacts } - )) - } - .onDisappear { - viewModel.stopScan() - } - .onChange(of: viewModel.filter) { _, _ in - viewModel.stopScan() - } + .safeAreaInset(edge: .bottom) { + if isConnected { + ScanButtonBar(viewModel: viewModel) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + SortMenu(viewModel: viewModel) + } + } + .errorAlert($viewModel.errorMessage, title: L10n.Tools.Tools.NodeDiscovery.errorTitle) + .sensoryFeedback(.impact(weight: .medium), trigger: viewModel.scanStartHapticTrigger) + .sensoryFeedback(.success, trigger: viewModel.scanSuccessHapticTrigger) + .sensoryFeedback(.warning, trigger: viewModel.scanEmptyHapticTrigger) + .sensoryFeedback(.success, trigger: viewModel.addSuccessHapticTrigger) + .sensoryFeedback(.error, trigger: viewModel.addErrorHapticTrigger) + .task(id: appState.servicesVersion) { + viewModel.configure(dependencies: NodeDiscoveryViewModel.Dependencies( + session: { [appState] in appState.services?.session }, + dataStore: { [appState] in appState.offlineDataStore }, + radioID: { [appState] in appState.connectedDevice?.radioID }, + contactService: { [appState] in appState.services?.contactService }, + maxContacts: { [appState] in appState.connectedDevice?.maxContacts } + )) + } + .onDisappear { + viewModel.stopScan() + } + .onChange(of: viewModel.filter) { _, _ in + viewModel.stopScan() } + } } // MARK: - States extension NodeDiscoveryView { - private var disconnectedState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Tools.Tools.NodeDiscovery.notConnectedDescription(viewModel.filter.localizedTitle)) - } + private var disconnectedState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Tools.Tools.NodeDiscovery.notConnectedDescription(viewModel.filter.localizedTitle)) } + } - private var initialState: some View { - VStack { - NodeDiscoverySegmentPicker(selection: $viewModel.filter, isScanning: viewModel.isScanning) + private var initialState: some View { + VStack { + NodeDiscoverySegmentPicker(selection: $viewModel.filter, isScanning: viewModel.isScanning) - Spacer() + Spacer() - ContentUnavailableView { - Label(L10n.Tools.Tools.NodeDiscovery.scanPrompt(viewModel.filter.localizedTitle), systemImage: "magnifyingglass") - } description: { - Text(L10n.Tools.Tools.NodeDiscovery.scanPromptDescription(viewModel.filter.localizedTitle)) - } + ContentUnavailableView { + Label(L10n.Tools.Tools.NodeDiscovery.scanPrompt(viewModel.filter.localizedTitle), systemImage: "magnifyingglass") + } description: { + Text(L10n.Tools.Tools.NodeDiscovery.scanPromptDescription(viewModel.filter.localizedTitle)) + } - Spacer() - } + Spacer() } + } } // MARK: - Results List extension NodeDiscoveryView { - private struct ResultsList: View { - @Bindable var viewModel: NodeDiscoveryViewModel - @Environment(\.appTheme) private var theme - - var body: some View { - List { - Section { - NodeDiscoverySegmentPicker(selection: $viewModel.filter, isScanning: viewModel.isScanning) - } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listSectionSeparator(.hidden) - - if viewModel.sortedResults.isEmpty && !viewModel.isScanning { - ContentUnavailableView { - Label(L10n.Tools.Tools.NodeDiscovery.noResults(viewModel.filter.localizedTitle), systemImage: "magnifyingglass") - } description: { - Text(L10n.Tools.Tools.NodeDiscovery.noResultsDescription(viewModel.filter.localizedTitle)) - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } else { - ForEach(viewModel.sortedResults) { result in - NodeDiscoveryRowView( - result: result, - isAdded: viewModel.isAdded(publicKey: result.publicKey), - isAdding: viewModel.addingPublicKey == result.publicKey, - onAdd: ContactType(rawValue: result.nodeType) != nil - ? { viewModel.addNode(result) } - : nil - ) - } - } - } - .listStyle(.plain) - .themedCanvas(theme) + private struct ResultsList: View { + @Bindable var viewModel: NodeDiscoveryViewModel + @Environment(\.appTheme) private var theme + + var body: some View { + List { + Section { + NodeDiscoverySegmentPicker(selection: $viewModel.filter, isScanning: viewModel.isScanning) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listSectionSeparator(.hidden) + + if viewModel.sortedResults.isEmpty, !viewModel.isScanning { + ContentUnavailableView { + Label(L10n.Tools.Tools.NodeDiscovery.noResults(viewModel.filter.localizedTitle), systemImage: "magnifyingglass") + } description: { + Text(L10n.Tools.Tools.NodeDiscovery.noResultsDescription(viewModel.filter.localizedTitle)) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else { + ForEach(viewModel.sortedResults) { result in + NodeDiscoveryRowView( + result: result, + isAdded: viewModel.isAdded(publicKey: result.publicKey), + isAdding: viewModel.addingPublicKey == result.publicKey, + onAdd: ContactType(rawValue: result.nodeType) != nil + ? { viewModel.addNode(result) } + : nil + ) + } } + } + .listStyle(.plain) + .themedCanvas(theme) } + } } // MARK: - Scan Button extension NodeDiscoveryView { - private struct ScanButtonBar: View { - let viewModel: NodeDiscoveryViewModel - - var body: some View { - LiquidGlassContainer { - Button { - if viewModel.isScanning { - viewModel.stopScan() - } else { - viewModel.scan() - } - } label: { - if viewModel.isScanning { - HStack { - ProgressView().controlSize(.small) - Text(L10n.Tools.Tools.NodeDiscovery.stopButton) - } - } else { - Text(L10n.Tools.Tools.NodeDiscovery.scanButton(viewModel.filter.localizedTitle)) - } - } - .liquidGlassProminentButtonStyle() + private struct ScanButtonBar: View { + let viewModel: NodeDiscoveryViewModel + + var body: some View { + LiquidGlassContainer { + Button { + if viewModel.isScanning { + viewModel.stopScan() + } else { + viewModel.scan() + } + } label: { + if viewModel.isScanning { + HStack { + ProgressView().controlSize(.small) + Text(L10n.Tools.Tools.NodeDiscovery.stopButton) } - .padding(.bottom, 24) + } else { + Text(L10n.Tools.Tools.NodeDiscovery.scanButton(viewModel.filter.localizedTitle)) + } } + .liquidGlassProminentButtonStyle() + } + .padding(.bottom, 24) } + } } // MARK: - Sort Menu extension NodeDiscoveryView { - private struct SortMenu: View { - let viewModel: NodeDiscoveryViewModel - - var body: some View { - Menu { - ForEach(NodeDiscoverySortOrder.allCases, id: \.self) { order in - Button { - viewModel.sortOrder = order - } label: { - if viewModel.sortOrder == order { - Label(order.localizedTitle, systemImage: "checkmark") - } else { - Text(order.localizedTitle) - } - } - } - } label: { - Label(L10n.Tools.Tools.NodeDiscovery.sortMenu, systemImage: "arrow.up.arrow.down") + private struct SortMenu: View { + let viewModel: NodeDiscoveryViewModel + + var body: some View { + Menu { + ForEach(NodeDiscoverySortOrder.allCases, id: \.self) { order in + Button { + viewModel.sortOrder = order + } label: { + if viewModel.sortOrder == order { + Label(order.localizedTitle, systemImage: "checkmark") + } else { + Text(order.localizedTitle) } - .liquidGlassButtonStyle() - .accessibilityLabel(L10n.Tools.Tools.NodeDiscovery.sortMenu) - .accessibilityHint(L10n.Tools.Tools.NodeDiscovery.sortMenuHint) + } } + } label: { + Label(L10n.Tools.Tools.NodeDiscovery.sortMenu, systemImage: "arrow.up.arrow.down") + } + .liquidGlassButtonStyle() + .accessibilityLabel(L10n.Tools.Tools.NodeDiscovery.sortMenu) + .accessibilityHint(L10n.Tools.Tools.NodeDiscovery.sortMenuHint) } + } } // MARK: - Segment Picker struct NodeDiscoverySegmentPicker: View { - @Binding var selection: NodeDiscoveryFilter - let isScanning: Bool - - var body: some View { - Picker(L10n.Tools.Tools.NodeDiscovery.repeaters, selection: $selection) { - ForEach(NodeDiscoveryFilter.allCases, id: \.self) { filter in - Text(filter.localizedTitle).tag(filter) - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - .padding(.vertical, 8) - .opacity(isScanning ? 0.5 : 1.0) - .disabled(isScanning) + @Binding var selection: NodeDiscoveryFilter + let isScanning: Bool + + var body: some View { + Picker(L10n.Tools.Tools.NodeDiscovery.repeaters, selection: $selection) { + ForEach(NodeDiscoveryFilter.allCases, id: \.self) { filter in + Text(filter.localizedTitle).tag(filter) + } } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + .opacity(isScanning ? 0.5 : 1.0) + .disabled(isScanning) + } } #Preview { - NavigationStack { - NodeDiscoveryView() - } - .environment(\.appState, AppState()) + NavigationStack { + NodeDiscoveryView() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryViewModel.swift b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryViewModel.swift index cd90c4d7..ea0928a4 100644 --- a/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryViewModel.swift +++ b/MC1/Views/Tools/NodeDiscovery/NodeDiscoveryViewModel.swift @@ -1,53 +1,53 @@ +import MC1Services import MeshCore import OSLog -import MC1Services import SwiftUI // MARK: - Filter & Sort enum NodeDiscoveryFilter: String, CaseIterable { - case repeaters - case sensors + case repeaters + case sensors - var filterValue: UInt8 { - switch self { - case .repeaters: 0x04 - case .sensors: 0x10 - } + var filterValue: UInt8 { + switch self { + case .repeaters: 0x04 + case .sensors: 0x10 } + } - var localizedTitle: String { - switch self { - case .repeaters: L10n.Tools.Tools.NodeDiscovery.repeaters - case .sensors: L10n.Tools.Tools.NodeDiscovery.sensors - } + var localizedTitle: String { + switch self { + case .repeaters: L10n.Tools.Tools.NodeDiscovery.repeaters + case .sensors: L10n.Tools.Tools.NodeDiscovery.sensors } + } } enum NodeDiscoverySortOrder: String, CaseIterable { - case snr - case name + case snr + case name - var localizedTitle: String { - switch self { - case .snr: L10n.Tools.Tools.NodeDiscovery.sortSignal - case .name: L10n.Tools.Tools.NodeDiscovery.sortName - } + var localizedTitle: String { + switch self { + case .snr: L10n.Tools.Tools.NodeDiscovery.sortSignal + case .name: L10n.Tools.Tools.NodeDiscovery.sortName } + } } // MARK: - Result -struct NodeDiscoveryResult: Identifiable, Sendable { - let id = UUID() - let name: String - let publicKey: Data - let nodeType: UInt8 - let snr: Double - let snrIn: Double - let rssi: Int - let scanFilter: NodeDiscoveryFilter - let receivedAt: Date +struct NodeDiscoveryResult: Identifiable { + let id = UUID() + let name: String + let publicKey: Data + let nodeType: UInt8 + let snr: Double + let snrIn: Double + let rssi: Int + let scanFilter: NodeDiscoveryFilter + let receivedAt: Date } // MARK: - View Model @@ -55,240 +55,254 @@ struct NodeDiscoveryResult: Identifiable, Sendable { @Observable @MainActor final class NodeDiscoveryViewModel { - private static let logger = Logger(subsystem: "com.mc1", category: "NodeDiscoveryViewModel") - private static let scanDuration: Duration = .seconds(15) - - // MARK: - Published state - - var results: [NodeDiscoveryResult] = [] - var isScanning = false - var errorMessage: String? - var filter: NodeDiscoveryFilter = .repeaters - var sortOrder: NodeDiscoverySortOrder = .snr - - var scanStartHapticTrigger = 0 - var scanSuccessHapticTrigger = 0 - var scanEmptyHapticTrigger = 0 - - var addedPublicKeys: Set = [] - var addingPublicKey: Data? - var addSuccessHapticTrigger = 0 - var addErrorHapticTrigger = 0 - - // MARK: - Dependencies - - struct Dependencies { - var session: @MainActor () -> MeshCoreSession? - var dataStore: @MainActor () -> PersistenceStore? - var radioID: @MainActor () -> UUID? - var contactService: @MainActor () -> ContactService? - var maxContacts: @MainActor () -> UInt16? - } + private static let logger = Logger(subsystem: "com.mc1", category: "NodeDiscoveryViewModel") + private static let scanDuration: Duration = .seconds(15) - private var deps = Dependencies( - session: { nil }, - dataStore: { nil }, - radioID: { nil }, - contactService: { nil }, - maxContacts: { nil } - ) + // MARK: - Published state - private var session: MeshCoreSession? { deps.session() } - private var dataStore: PersistenceStore? { deps.dataStore() } - private var radioID: UUID? { deps.radioID() } - private var contactService: ContactService? { deps.contactService() } - private var maxContacts: UInt16? { deps.maxContacts() } + var results: [NodeDiscoveryResult] = [] + var isScanning = false + var errorMessage: String? + var filter: NodeDiscoveryFilter = .repeaters + var sortOrder: NodeDiscoverySortOrder = .snr - // MARK: - Tasks + var scanStartHapticTrigger = 0 + var scanSuccessHapticTrigger = 0 + var scanEmptyHapticTrigger = 0 - private var scanTask: Task? - private var timeoutTask: Task? + var addedPublicKeys: Set = [] + var addingPublicKey: Data? + var addSuccessHapticTrigger = 0 + var addErrorHapticTrigger = 0 - // MARK: - Name resolution cache + // MARK: - Dependencies - private var namesByKey: [Data: String] = [:] + struct Dependencies { + var session: @MainActor () -> MeshCoreSession? + var dataStore: @MainActor () -> PersistenceStore? + var radioID: @MainActor () -> UUID? + var contactService: @MainActor () -> ContactService? + var maxContacts: @MainActor () -> UInt16? + } - // MARK: - Configuration + private var deps = Dependencies( + session: { nil }, + dataStore: { nil }, + radioID: { nil }, + contactService: { nil }, + maxContacts: { nil } + ) - /// Configure with the session, store, and services this view model uses; a provider returning nil mirrors a disconnected state. - func configure(dependencies: Dependencies) { - deps = dependencies - } + private var session: MeshCoreSession? { + deps.session() + } - // MARK: - Scan + private var dataStore: PersistenceStore? { + deps.dataStore() + } - func scan() { - guard let session else { - errorMessage = L10n.Tools.Tools.NodeDiscovery.notConnectedDescription(filter.localizedTitle) - return - } + private var radioID: UUID? { + deps.radioID() + } - guard let radioID else { return } - - stopScan() - results.removeAll { $0.scanFilter == filter } - errorMessage = nil - isScanning = true - scanStartHapticTrigger += 1 - - scanTask = Task { [weak self] in - guard let self else { return } - - do { - // Pre-load name resolution data and existing contact keys - await self.loadNameResolutionData(radioID: radioID) - - // Send discovery request - let tag = try await session.sendNodeDiscoverRequest( - filter: self.filter.filterValue, - prefixOnly: false - ) - let tagData = withUnsafeBytes(of: tag.littleEndian) { Data($0) } - - // Start timeout that cancels the scan task - self.timeoutTask = Task { [weak self] in - try? await Task.sleep(for: Self.scanDuration) - self?.scanTask?.cancel() - } - - // Listen for responses - let events = await session.events() - for await event in events { - guard !Task.isCancelled else { break } - - if case .discoverResponse(let response) = event, - response.tag == tagData { - self.appendOrUpdateResult(from: response) - } - } - } catch is CancellationError { - // Normal timeout cancellation — not an error - } catch { - Self.logger.error("Node discovery failed: \(error.localizedDescription)") - self.errorMessage = error.userFacingMessage - } - - self.finishScan() - } - } + private var contactService: ContactService? { + deps.contactService() + } - func stopScan() { - timeoutTask?.cancel() - timeoutTask = nil - scanTask?.cancel() - scanTask = nil - if isScanning { - finishScan() - } - } + private var maxContacts: UInt16? { + deps.maxContacts() + } - // MARK: - Sorted results + // MARK: - Tasks - var sortedResults: [NodeDiscoveryResult] { - let filtered = results.filter { $0.scanFilter == filter } - return switch sortOrder { - case .snr: - filtered.sorted { $0.snr > $1.snr } - case .name: - filtered.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - } - } + private var scanTask: Task? + private var timeoutTask: Task? - // MARK: - Private - - private func loadNameResolutionData(radioID: UUID) async { - guard let dataStore else { return } - do { - // Load discovered nodes first, then contacts — contacts take priority - let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) - namesByKey = Dictionary( - nodes.map { ($0.publicKey, $0.name) }, - uniquingKeysWith: { first, _ in first } - ) - let contacts = try await dataStore.fetchContacts(radioID: radioID) - for contact in contacts { - namesByKey[contact.publicKey] = contact.name - } - addedPublicKeys = Set(contacts.map(\.publicKey)) - } catch { - Self.logger.error("Failed to load name resolution data: \(error.localizedDescription)") - } - } + // MARK: - Name resolution cache - private func resolveName(for publicKey: Data) -> String { - if let name = namesByKey[publicKey] { - return name - } - let hexPrefix = publicKey.prefix(4).map { String(format: "%02X", $0) }.joined() - return "\(L10n.Tools.Tools.NodeDiscovery.unknownNode) (\(hexPrefix))" + private var namesByKey: [Data: String] = [:] + + // MARK: - Configuration + + /// Configure with the session, store, and services this view model uses; a provider returning nil mirrors a disconnected state. + func configure(dependencies: Dependencies) { + deps = dependencies + } + + // MARK: - Scan + + func scan() { + guard let session else { + errorMessage = L10n.Tools.Tools.NodeDiscovery.notConnectedDescription(filter.localizedTitle) + return } - private func appendOrUpdateResult(from response: DiscoverResponse) { - let result = NodeDiscoveryResult( - name: resolveName(for: response.publicKey), - publicKey: response.publicKey, - nodeType: response.nodeType, - snr: response.snr, - snrIn: response.snrIn, - rssi: response.rssi, - scanFilter: filter, - receivedAt: Date() + guard let radioID else { return } + + stopScan() + results.removeAll { $0.scanFilter == filter } + errorMessage = nil + isScanning = true + scanStartHapticTrigger += 1 + + scanTask = Task { [weak self] in + guard let self else { return } + + do { + // Pre-load name resolution data and existing contact keys + await loadNameResolutionData(radioID: radioID) + + // Send discovery request + let tag = try await session.sendNodeDiscoverRequest( + filter: filter.filterValue, + prefixOnly: false ) - if let existingIndex = results.firstIndex(where: { $0.publicKey == response.publicKey && $0.scanFilter == filter }) { - results[existingIndex] = result - } else { - results.append(result) + let tagData = withUnsafeBytes(of: tag.littleEndian) { Data($0) } + + // Start timeout that cancels the scan task + timeoutTask = Task { [weak self] in + try? await Task.sleep(for: Self.scanDuration) + self?.scanTask?.cancel() } - } - private func finishScan() { - isScanning = false - if results.contains(where: { $0.scanFilter == filter }) { - scanSuccessHapticTrigger += 1 - } else { - scanEmptyHapticTrigger += 1 + // Listen for responses + let events = await session.events() + for await event in events { + guard !Task.isCancelled else { break } + + if case let .discoverResponse(response) = event, + response.tag == tagData { + appendOrUpdateResult(from: response) + } } + } catch is CancellationError { + // Normal timeout cancellation — not an error + } catch { + Self.logger.error("Node discovery failed: \(error.localizedDescription)") + errorMessage = error.userFacingMessage + } + + finishScan() + } + } + + func stopScan() { + timeoutTask?.cancel() + timeoutTask = nil + scanTask?.cancel() + scanTask = nil + if isScanning { + finishScan() } + } - // MARK: - Add Node + // MARK: - Sorted results - func isAdded(publicKey: Data) -> Bool { - addedPublicKeys.contains(publicKey) + var sortedResults: [NodeDiscoveryResult] { + let filtered = results.filter { $0.scanFilter == filter } + return switch sortOrder { + case .snr: + filtered.sorted { $0.snr > $1.snr } + case .name: + filtered.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + } + + // MARK: - Private + + private func loadNameResolutionData(radioID: UUID) async { + guard let dataStore else { return } + do { + // Load discovered nodes first, then contacts — contacts take priority + let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) + namesByKey = Dictionary( + nodes.map { ($0.publicKey, $0.name) }, + uniquingKeysWith: { first, _ in first } + ) + let contacts = try await dataStore.fetchContacts(radioID: radioID) + for contact in contacts { + namesByKey[contact.publicKey] = contact.name + } + addedPublicKeys = Set(contacts.map(\.publicKey)) + } catch { + Self.logger.error("Failed to load name resolution data: \(error.localizedDescription)") } + } - func addNode(_ result: NodeDiscoveryResult) { - guard let contactService, let radioID else { return } - - addingPublicKey = result.publicKey - Task { [weak self] in - do { - let contact = ContactFrame( - publicKey: result.publicKey, - type: ContactType(rawValue: result.nodeType) ?? .repeater, - flags: 0, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: result.name, - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - try await contactService.addOrUpdateContact(radioID: radioID, contact: contact) - self?.addedPublicKeys.insert(result.publicKey) - self?.addSuccessHapticTrigger += 1 - } catch ContactServiceError.contactTableFull { - if let maxContacts = self?.maxContacts { - self?.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) - } else { - self?.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFullSimple - } - self?.addErrorHapticTrigger += 1 - } catch { - self?.errorMessage = error.userFacingMessage - self?.addErrorHapticTrigger += 1 - } - self?.addingPublicKey = nil + private func resolveName(for publicKey: Data) -> String { + if let name = namesByKey[publicKey] { + return name + } + let hexPrefix = publicKey.prefix(4).map { String(format: "%02X", $0) }.joined() + return "\(L10n.Tools.Tools.NodeDiscovery.unknownNode) (\(hexPrefix))" + } + + private func appendOrUpdateResult(from response: DiscoverResponse) { + let result = NodeDiscoveryResult( + name: resolveName(for: response.publicKey), + publicKey: response.publicKey, + nodeType: response.nodeType, + snr: response.snr, + snrIn: response.snrIn, + rssi: response.rssi, + scanFilter: filter, + receivedAt: Date() + ) + if let existingIndex = results.firstIndex(where: { $0.publicKey == response.publicKey && $0.scanFilter == filter }) { + results[existingIndex] = result + } else { + results.append(result) + } + } + + private func finishScan() { + isScanning = false + if results.contains(where: { $0.scanFilter == filter }) { + scanSuccessHapticTrigger += 1 + } else { + scanEmptyHapticTrigger += 1 + } + } + + // MARK: - Add Node + + func isAdded(publicKey: Data) -> Bool { + addedPublicKeys.contains(publicKey) + } + + func addNode(_ result: NodeDiscoveryResult) { + guard let contactService, let radioID else { return } + + addingPublicKey = result.publicKey + Task { [weak self] in + do { + let contact = ContactFrame( + publicKey: result.publicKey, + type: ContactType(rawValue: result.nodeType) ?? .repeater, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: result.name, + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + try await contactService.addOrUpdateContact(radioID: radioID, contact: contact) + self?.addedPublicKeys.insert(result.publicKey) + self?.addSuccessHapticTrigger += 1 + } catch ContactServiceError.contactTableFull { + if let maxContacts = self?.maxContacts { + self?.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFull(Int(maxContacts)) + } else { + self?.errorMessage = L10n.Contacts.Contacts.Add.Error.nodeListFullSimple } + self?.addErrorHapticTrigger += 1 + } catch { + self?.errorMessage = error.userFacingMessage + self?.addErrorHapticTrigger += 1 + } + self?.addingPublicKey = nil } + } } diff --git a/MC1/Views/Tools/NoiseFloorView.swift b/MC1/Views/Tools/NoiseFloorView.swift index 18fdbb8d..dc78ace6 100644 --- a/MC1/Views/Tools/NoiseFloorView.swift +++ b/MC1/Views/Tools/NoiseFloorView.swift @@ -1,319 +1,319 @@ -import SwiftUI import Charts +import SwiftUI struct NoiseFloorView: View { - @Environment(\.appState) private var appState - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @State private var viewModel = NoiseFloorViewModel() - @State private var chartStartTime = Date() - - private var isConnected: Bool { - appState.services?.session != nil + @Environment(\.appState) private var appState + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var viewModel = NoiseFloorViewModel() + @State private var chartStartTime = Date() + + private var isConnected: Bool { + appState.services?.session != nil + } + + var body: some View { + Group { + if !isConnected { + disconnectedState + } else if viewModel.readings.isEmpty { + collectingState + } else { + mainContent + } } - - var body: some View { - Group { - if !isConnected { - disconnectedState - } else if viewModel.readings.isEmpty { - collectingState - } else { - mainContent - } - } - .task(id: appState.servicesVersion) { - chartStartTime = Date() - viewModel.startPolling { appState.services?.session } - } - .onDisappear { - viewModel.stopPolling() - } + .task(id: appState.servicesVersion) { + chartStartTime = Date() + viewModel.startPolling { appState.services?.session } + } + .onDisappear { + viewModel.stopPolling() } + } } // MARK: - Empty States extension NoiseFloorView { - private var disconnectedState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Tools.Tools.NoiseFloor.notConnectedDescription) - } + private var disconnectedState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Tools.Tools.NoiseFloor.notConnectedDescription) } + } - private var collectingState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.NoiseFloor.collectingData, systemImage: "antenna.radiowaves.left.and.right") - } description: { - Text(L10n.Tools.Tools.NoiseFloor.collectingDataDescription) - } + private var collectingState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.NoiseFloor.collectingData, systemImage: "antenna.radiowaves.left.and.right") + } description: { + Text(L10n.Tools.Tools.NoiseFloor.collectingDataDescription) } + } } // MARK: - Main Content extension NoiseFloorView { - private var mainContent: some View { - VStack(spacing: 16) { - if let error = viewModel.errorMessage { - ErrorBanner(message: error) - } + private var mainContent: some View { + VStack(spacing: 16) { + if let error = viewModel.errorMessage { + ErrorBanner(message: error) + } - ChartSection(viewModel: viewModel, startTime: chartStartTime) - - if horizontalSizeClass == .compact { - VStack(spacing: 16) { - CurrentReadingSection(viewModel: viewModel) - StatisticsSection(viewModel: viewModel) - } - } else { - HStack(spacing: 16) { - CurrentReadingSection(viewModel: viewModel) - .frame(maxWidth: .infinity, maxHeight: .infinity) - StatisticsSection(viewModel: viewModel) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .fixedSize(horizontal: false, vertical: true) - } + ChartSection(viewModel: viewModel, startTime: chartStartTime) + + if horizontalSizeClass == .compact { + VStack(spacing: 16) { + CurrentReadingSection(viewModel: viewModel) + StatisticsSection(viewModel: viewModel) + } + } else { + HStack(spacing: 16) { + CurrentReadingSection(viewModel: viewModel) + .frame(maxWidth: .infinity, maxHeight: .infinity) + StatisticsSection(viewModel: viewModel) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .padding() + .fixedSize(horizontal: false, vertical: true) + } } + .padding() + } } // MARK: - Error Banner private struct ErrorBanner: View { - let message: String - - var body: some View { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - Text(message) - .font(.subheadline) - Spacer() - } - .padding() - .liquidGlass(in: .rect(cornerRadius: 12)) + let message: String + + var body: some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(message) + .font(.subheadline) + Spacer() } + .padding() + .liquidGlass(in: .rect(cornerRadius: 12)) + } } // MARK: - Current Reading Section private struct CurrentReadingSection: View { - let viewModel: NoiseFloorViewModel + let viewModel: NoiseFloorViewModel - private var displayValue: Int16 { - viewModel.currentReading?.noiseFloor ?? 0 - } + private var displayValue: Int16 { + viewModel.currentReading?.noiseFloor ?? 0 + } - var body: some View { - VStack(spacing: 8) { - Spacer(minLength: 0) + var body: some View { + VStack(spacing: 8) { + Spacer(minLength: 0) - Text(displayValue, format: .number) - .font(.largeTitle) - .fontDesign(.rounded) - .monospacedDigit() + Text(displayValue, format: .number) + .font(.largeTitle) + .fontDesign(.rounded) + .monospacedDigit() - Text(L10n.Tools.Tools.NoiseFloor.dBm) - .font(.title3) - .foregroundStyle(.secondary) + Text(L10n.Tools.Tools.NoiseFloor.dBm) + .font(.title3) + .foregroundStyle(.secondary) - qualityBadge + qualityBadge - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - .liquidGlass(in: .rect(cornerRadius: 12)) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) - .accessibilityAddTraits(.updatesFrequently) + Spacer(minLength: 0) } - - @ViewBuilder - private var qualityBadge: some View { - let quality = viewModel.qualityLevel - if quality != .unknown { - HStack(spacing: 4) { - Image(systemName: quality.icon) - Text(quality.label) - } - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(quality.color.opacity(0.2), in: .capsule) - .foregroundStyle(quality.color) - } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + .liquidGlass(in: .rect(cornerRadius: 12)) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.updatesFrequently) + } + + @ViewBuilder + private var qualityBadge: some View { + let quality = viewModel.qualityLevel + if quality != .unknown { + HStack(spacing: 4) { + Image(systemName: quality.icon) + Text(quality.label) + } + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(quality.color.opacity(0.2), in: .capsule) + .foregroundStyle(quality.color) } + } - private var accessibilityLabel: String { - guard let reading = viewModel.currentReading else { - return L10n.Tools.Tools.NoiseFloor.noReading - } - let quality = viewModel.qualityLevel - return "\(reading.noiseFloor) \(L10n.Tools.Tools.NoiseFloor.dBm), \(quality.label)" + private var accessibilityLabel: String { + guard let reading = viewModel.currentReading else { + return L10n.Tools.Tools.NoiseFloor.noReading } + let quality = viewModel.qualityLevel + return "\(reading.noiseFloor) \(L10n.Tools.Tools.NoiseFloor.dBm), \(quality.label)" + } } // MARK: - Chart Section private struct ChartSection: View { - let viewModel: NoiseFloorViewModel - let startTime: Date + let viewModel: NoiseFloorViewModel + let startTime: Date - private var trendDescription: String { - let readings = viewModel.readings - guard readings.count >= 4 else { return L10n.Tools.Tools.NoiseFloor.trendStable } + private var trendDescription: String { + let readings = viewModel.readings + guard readings.count >= 4 else { return L10n.Tools.Tools.NoiseFloor.trendStable } - let halfCount = readings.count / 2 - let firstHalf = readings.prefix(halfCount) - let secondHalf = readings.suffix(halfCount) + let halfCount = readings.count / 2 + let firstHalf = readings.prefix(halfCount) + let secondHalf = readings.suffix(halfCount) - let firstAvg = firstHalf.map { Int($0.noiseFloor) }.reduce(0, +) / max(1, halfCount) - let secondAvg = secondHalf.map { Int($0.noiseFloor) }.reduce(0, +) / max(1, halfCount) + let firstAvg = firstHalf.map { Int($0.noiseFloor) }.reduce(0, +) / max(1, halfCount) + let secondAvg = secondHalf.map { Int($0.noiseFloor) }.reduce(0, +) / max(1, halfCount) - if secondAvg > firstAvg + 3 { - return L10n.Tools.Tools.NoiseFloor.trendIncreasing - } else if secondAvg < firstAvg - 3 { - return L10n.Tools.Tools.NoiseFloor.trendDecreasing - } - return L10n.Tools.Tools.NoiseFloor.trendStable + if secondAvg > firstAvg + 3 { + return L10n.Tools.Tools.NoiseFloor.trendIncreasing + } else if secondAvg < firstAvg - 3 { + return L10n.Tools.Tools.NoiseFloor.trendDecreasing } + return L10n.Tools.Tools.NoiseFloor.trendStable + } - private var chartAccessibilityLabel: String { - let count = viewModel.readings.count - guard count > 0, let stats = viewModel.statistics else { - return L10n.Tools.Tools.NoiseFloor.chartAccessibilityEmpty - } - - return L10n.Tools.Tools.NoiseFloor.chartAccessibility(count, Int(stats.min), Int(stats.max), Int(stats.average), trendDescription) + private var chartAccessibilityLabel: String { + let count = viewModel.readings.count + guard count > 0, let stats = viewModel.statistics else { + return L10n.Tools.Tools.NoiseFloor.chartAccessibilityEmpty } - /// Visible chart window duration in seconds. - private let chartWindowSeconds: Double = 300 + return L10n.Tools.Tools.NoiseFloor.chartAccessibility(count, Int(stats.min), Int(stats.max), Int(stats.average), trendDescription) + } - private var chartDomain: ClosedRange { - guard let lastReading = viewModel.readings.last else { - return 0...chartWindowSeconds - } - let latestElapsed = lastReading.timestamp.timeIntervalSince(startTime) - if latestElapsed <= chartWindowSeconds { - return 0...chartWindowSeconds - } - return (latestElapsed - chartWindowSeconds)...latestElapsed - } + /// Visible chart window duration in seconds. + private let chartWindowSeconds: Double = 300 - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Tools.Tools.noiseFloor) - .font(.headline) - - Chart(viewModel.readings) { reading in - let elapsed = reading.timestamp.timeIntervalSince(startTime) - LineMark( - x: .value("Time", elapsed), - y: .value("dBm", reading.noiseFloor) - ) - .foregroundStyle(.blue.gradient) - - AreaMark( - x: .value("Time", elapsed), - yStart: .value("Min", -130), - yEnd: .value("dBm", reading.noiseFloor) - ) - .foregroundStyle(.blue.opacity(0.1)) - } - .chartYScale(domain: -130 ... -60) - .chartXScale(domain: chartDomain) - .chartXAxis { - AxisMarks(values: .stride(by: 60)) { value in - AxisGridLine() - AxisValueLabel { - if let seconds = value.as(Double.self) { - let minute = Int(seconds) / 60 - Text("\(minute):00") - .monospacedDigit() - } - } - } - } - .chartPlotStyle { content in - content.clipped() + private var chartDomain: ClosedRange { + guard let lastReading = viewModel.readings.last else { + return 0...chartWindowSeconds + } + let latestElapsed = lastReading.timestamp.timeIntervalSince(startTime) + if latestElapsed <= chartWindowSeconds { + return 0...chartWindowSeconds + } + return (latestElapsed - chartWindowSeconds)...latestElapsed + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Tools.Tools.noiseFloor) + .font(.headline) + + Chart(viewModel.readings) { reading in + let elapsed = reading.timestamp.timeIntervalSince(startTime) + LineMark( + x: .value("Time", elapsed), + y: .value("dBm", reading.noiseFloor) + ) + .foregroundStyle(.blue.gradient) + + AreaMark( + x: .value("Time", elapsed), + yStart: .value("Min", -130), + yEnd: .value("dBm", reading.noiseFloor) + ) + .foregroundStyle(.blue.opacity(0.1)) + } + .chartYScale(domain: -130 ... -60) + .chartXScale(domain: chartDomain) + .chartXAxis { + AxisMarks(values: .stride(by: 60)) { value in + AxisGridLine() + AxisValueLabel { + if let seconds = value.as(Double.self) { + let minute = Int(seconds) / 60 + Text("\(minute):00") + .monospacedDigit() } - .frame(maxHeight: .infinity) - .accessibilityLabel(chartAccessibilityLabel) + } } - .frame(maxHeight: .infinity) - .padding() - .liquidGlass(in: .rect(cornerRadius: 12)) + } + .chartPlotStyle { content in + content.clipped() + } + .frame(maxHeight: .infinity) + .accessibilityLabel(chartAccessibilityLabel) } + .frame(maxHeight: .infinity) + .padding() + .liquidGlass(in: .rect(cornerRadius: 12)) + } } // MARK: - Statistics Section private struct StatisticsSection: View { - let viewModel: NoiseFloorViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(L10n.Tools.Tools.NoiseFloor.statistics) - .font(.headline) - - if let stats = viewModel.statistics { - Grid(alignment: .leading, verticalSpacing: 6) { - statRow(label: L10n.Tools.Tools.NoiseFloor.minimum, value: Int(stats.min), unit: L10n.Tools.Tools.NoiseFloor.dBm) - statRow(label: L10n.Tools.Tools.NoiseFloor.average, value: stats.average, unit: L10n.Tools.Tools.NoiseFloor.dBm, precision: 1) - statRow(label: L10n.Tools.Tools.NoiseFloor.maximum, value: Int(stats.max), unit: L10n.Tools.Tools.NoiseFloor.dBm) - - Divider() - .gridCellColumns(4) - - if let reading = viewModel.currentReading { - statRow(label: L10n.Tools.Tools.NoiseFloor.lastRssi, value: Int(reading.lastRSSI), unit: L10n.Tools.Tools.NoiseFloor.dBm) - statRow(label: L10n.Tools.Tools.NoiseFloor.lastSnr, value: reading.lastSNR, unit: L10n.Tools.Tools.NoiseFloor.db, precision: 1) - } - } - .font(.subheadline) - } - - Spacer(minLength: 0) + let viewModel: NoiseFloorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(L10n.Tools.Tools.NoiseFloor.statistics) + .font(.headline) + + if let stats = viewModel.statistics { + Grid(alignment: .leading, verticalSpacing: 6) { + statRow(label: L10n.Tools.Tools.NoiseFloor.minimum, value: Int(stats.min), unit: L10n.Tools.Tools.NoiseFloor.dBm) + statRow(label: L10n.Tools.Tools.NoiseFloor.average, value: stats.average, unit: L10n.Tools.Tools.NoiseFloor.dBm, precision: 1) + statRow(label: L10n.Tools.Tools.NoiseFloor.maximum, value: Int(stats.max), unit: L10n.Tools.Tools.NoiseFloor.dBm) + + Divider() + .gridCellColumns(4) + + if let reading = viewModel.currentReading { + statRow(label: L10n.Tools.Tools.NoiseFloor.lastRssi, value: Int(reading.lastRSSI), unit: L10n.Tools.Tools.NoiseFloor.dBm) + statRow(label: L10n.Tools.Tools.NoiseFloor.lastSnr, value: reading.lastSNR, unit: L10n.Tools.Tools.NoiseFloor.db, precision: 1) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding() - .liquidGlass(in: .rect(cornerRadius: 12)) - } + .font(.subheadline) + } - private func statRow(label: String, value: Int, unit: String) -> some View { - GridRow { - Text(label) - .foregroundStyle(.secondary) - Spacer() - Text(value, format: .number) - .monospacedDigit() - Text(unit) - .foregroundStyle(.secondary) - } + Spacer(minLength: 0) } - - private func statRow(label: String, value: Double, unit: String, precision: Int) -> some View { - GridRow { - Text(label) - .foregroundStyle(.secondary) - Spacer() - Text(value, format: .number.precision(.fractionLength(precision))) - .monospacedDigit() - Text(unit) - .foregroundStyle(.secondary) - } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding() + .liquidGlass(in: .rect(cornerRadius: 12)) + } + + private func statRow(label: String, value: Int, unit: String) -> some View { + GridRow { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value, format: .number) + .monospacedDigit() + Text(unit) + .foregroundStyle(.secondary) } + } + + private func statRow(label: String, value: Double, unit: String, precision: Int) -> some View { + GridRow { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value, format: .number.precision(.fractionLength(precision))) + .monospacedDigit() + Text(unit) + .foregroundStyle(.secondary) + } + } } #Preview { - NavigationStack { - NoiseFloorView() - } - .environment(\.appState, AppState()) + NavigationStack { + NoiseFloorView() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/NoiseFloorViewModel.swift b/MC1/Views/Tools/NoiseFloorViewModel.swift index c91ed937..bed15e2c 100644 --- a/MC1/Views/Tools/NoiseFloorViewModel.swift +++ b/MC1/Views/Tools/NoiseFloorViewModel.swift @@ -1,156 +1,156 @@ -import SwiftUI -import MeshCore import MC1Services +import MeshCore +import SwiftUI struct NoiseFloorReading: Identifiable { - let id: UUID - let timestamp: Date - let noiseFloor: Int16 - let lastRSSI: Int8 - let lastSNR: Double + let id: UUID + let timestamp: Date + let noiseFloor: Int16 + let lastRSSI: Int8 + let lastSNR: Double } struct NoiseFloorStatistics { - let min: Int16 - let max: Int16 - let average: Double + let min: Int16 + let max: Int16 + let average: Double } enum NoiseFloorQuality: Equatable { - case excellent - case good - case fair - case poor - case unknown - - static func from(noiseFloor: Int16) -> NoiseFloorQuality { - switch noiseFloor { - case ...(-100): return .excellent - case ...(-90): return .good - case ...(-80): return .fair - default: return .poor - } + case excellent + case good + case fair + case poor + case unknown + + static func from(noiseFloor: Int16) -> NoiseFloorQuality { + switch noiseFloor { + case ...(-100): .excellent + case ...(-90): .good + case ...(-80): .fair + default: .poor } - - var label: String { - switch self { - case .excellent: L10n.Tools.Tools.NoiseFloor.Quality.excellent - case .good: L10n.Tools.Tools.NoiseFloor.Quality.good - case .fair: L10n.Tools.Tools.NoiseFloor.Quality.fair - case .poor: L10n.Tools.Tools.NoiseFloor.Quality.poor - case .unknown: L10n.Tools.Tools.NoiseFloor.Quality.unknown - } + } + + var label: String { + switch self { + case .excellent: L10n.Tools.Tools.NoiseFloor.Quality.excellent + case .good: L10n.Tools.Tools.NoiseFloor.Quality.good + case .fair: L10n.Tools.Tools.NoiseFloor.Quality.fair + case .poor: L10n.Tools.Tools.NoiseFloor.Quality.poor + case .unknown: L10n.Tools.Tools.NoiseFloor.Quality.unknown } - - var color: Color { - switch self { - case .excellent: .green - case .good: .blue - case .fair: .orange - case .poor: .red - case .unknown: .secondary - } + } + + var color: Color { + switch self { + case .excellent: .green + case .good: .blue + case .fair: .orange + case .poor: .red + case .unknown: .secondary } - - var icon: String { - switch self { - case .excellent: "checkmark.circle.fill" - case .good: "circle.fill" - case .fair: "exclamationmark.circle.fill" - case .poor: "xmark.circle.fill" - case .unknown: "questionmark.circle" - } + } + + var icon: String { + switch self { + case .excellent: "checkmark.circle.fill" + case .good: "circle.fill" + case .fair: "exclamationmark.circle.fill" + case .poor: "xmark.circle.fill" + case .unknown: "questionmark.circle" } + } } @Observable @MainActor final class NoiseFloorViewModel { - var currentReading: NoiseFloorReading? - var readings: [NoiseFloorReading] = [] - var isPolling = false - var errorMessage: String? - - private let maxReadings = 200 - private let pollingInterval: Duration = .seconds(1.5) - - // Re-evaluated each poll tick so a disconnect mid-poll surfaces immediately. - private var sessionProvider: @MainActor () -> MeshCoreSession? = { nil } - private var pollingTask: Task? - // Ignored so the statistics getter can fill the cache during view body - // evaluation without mutating observed state; readings drives invalidation. - @ObservationIgnored private var cachedStatistics: NoiseFloorStatistics? - - var statistics: NoiseFloorStatistics? { - if let cached = cachedStatistics { return cached } - guard !readings.isEmpty else { return nil } - let values = readings.map { $0.noiseFloor } - let computed = NoiseFloorStatistics( - min: values.min()!, - max: values.max()!, - average: Double(values.reduce(0) { $0 + Int($1) }) / Double(values.count) - ) - cachedStatistics = computed - return computed + var currentReading: NoiseFloorReading? + var readings: [NoiseFloorReading] = [] + var isPolling = false + var errorMessage: String? + + private let maxReadings = 200 + private let pollingInterval: Duration = .seconds(1.5) + + // Re-evaluated each poll tick so a disconnect mid-poll surfaces immediately. + private var sessionProvider: @MainActor () -> MeshCoreSession? = { nil } + private var pollingTask: Task? + /// Ignored so the statistics getter can fill the cache during view body + /// evaluation without mutating observed state; readings drives invalidation. + @ObservationIgnored private var cachedStatistics: NoiseFloorStatistics? + + var statistics: NoiseFloorStatistics? { + if let cached = cachedStatistics { return cached } + guard !readings.isEmpty else { return nil } + let values = readings.map(\.noiseFloor) + let computed = NoiseFloorStatistics( + min: values.min()!, + max: values.max()!, + average: Double(values.reduce(0) { $0 + Int($1) }) / Double(values.count) + ) + cachedStatistics = computed + return computed + } + + var qualityLevel: NoiseFloorQuality { + guard let reading = currentReading else { return .unknown } + return NoiseFloorQuality.from(noiseFloor: reading.noiseFloor) + } + + func appendReading(_ reading: NoiseFloorReading) { + currentReading = reading + readings.append(reading) + if readings.count > maxReadings { + readings.removeFirst() } + cachedStatistics = nil + errorMessage = nil + } - var qualityLevel: NoiseFloorQuality { - guard let reading = currentReading else { return .unknown } - return NoiseFloorQuality.from(noiseFloor: reading.noiseFloor) - } + func startPolling(sessionProvider: @escaping @MainActor () -> MeshCoreSession?) { + self.sessionProvider = sessionProvider + guard pollingTask == nil else { return } + isPolling = true - func appendReading(_ reading: NoiseFloorReading) { - currentReading = reading - readings.append(reading) - if readings.count > maxReadings { - readings.removeFirst() - } - cachedStatistics = nil - errorMessage = nil - } - - func startPolling(sessionProvider: @escaping @MainActor () -> MeshCoreSession?) { - self.sessionProvider = sessionProvider - guard pollingTask == nil else { return } - isPolling = true - - pollingTask = Task { [weak self] in - while true { - do { - guard let self else { break } - await self.fetchReading() - try await Task.sleep(for: self.pollingInterval) - } catch { - break - } - } + pollingTask = Task { [weak self] in + while true { + do { + guard let self else { break } + await fetchReading() + try await Task.sleep(for: pollingInterval) + } catch { + break } + } } - - func stopPolling() { - pollingTask?.cancel() - pollingTask = nil - isPolling = false + } + + func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + isPolling = false + } + + private func fetchReading() async { + guard let session = sessionProvider() else { + errorMessage = L10n.Tools.Tools.NoiseFloor.Error.disconnected + return } - private func fetchReading() async { - guard let session = sessionProvider() else { - errorMessage = L10n.Tools.Tools.NoiseFloor.Error.disconnected - return - } - - do { - let stats = try await session.getStatsRadio() - let reading = NoiseFloorReading( - id: UUID(), - timestamp: .now, - noiseFloor: stats.noiseFloor, - lastRSSI: stats.lastRSSI, - lastSNR: stats.lastSNR - ) - appendReading(reading) - } catch { - self.errorMessage = L10n.Tools.Tools.NoiseFloor.Error.unableToRead - } + do { + let stats = try await session.getStatsRadio() + let reading = NoiseFloorReading( + id: UUID(), + timestamp: .now, + noiseFloor: stats.noiseFloor, + lastRSSI: stats.lastRSSI, + lastSNR: stats.lastSNR + ) + appendReading(reading) + } catch { + errorMessage = L10n.Tools.Tools.NoiseFloor.Error.unableToRead } + } } diff --git a/MC1/Views/Tools/RxLogView.swift b/MC1/Views/Tools/RxLogView.swift index 25662ec3..78772ad2 100644 --- a/MC1/Views/Tools/RxLogView.swift +++ b/MC1/Views/Tools/RxLogView.swift @@ -1,591 +1,591 @@ -import SwiftUI -import UIKit import MC1Services import MeshCore +import SwiftUI +import UIKit struct RxLogView: View { - @Environment(\.appState) private var appState - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.appTheme) private var theme - - @State private var viewModel = RxLogViewModel() - @State private var expandedHashes: Set = [] - @State private var groupDuplicates = false - - var body: some View { - Group { - if appState.services?.rxLogService == nil { - disconnectedState - } else if viewModel.entries.isEmpty { - emptyState - } else { - entryList - } - } - .navigationTitle(L10n.Tools.Tools.rxLog) - .toolbar { - toolbarContent + @Environment(\.appState) private var appState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.appTheme) private var theme + + @State private var viewModel = RxLogViewModel() + @State private var expandedHashes: Set = [] + @State private var groupDuplicates = false + + var body: some View { + Group { + if appState.services?.rxLogService == nil { + disconnectedState + } else if viewModel.entries.isEmpty { + emptyState + } else { + entryList + } + } + .navigationTitle(L10n.Tools.Tools.rxLog) + .toolbar { + toolbarContent + } + .task(id: appState.servicesVersion) { + viewModel.configure( + rxLogService: { [appState] in appState.services?.rxLogService }, + dataStore: { [appState] in appState.services?.dataStore }, + radioID: { [appState] in appState.currentRadioID } + ) + await viewModel.subscribe() + await viewModel.loadNodeNames() + } + .onChange(of: appState.contactsVersion) { + Task { await viewModel.loadNodeNames() } + } + .onDisappear { + viewModel.unsubscribe() + } + } + + // MARK: - Empty State + + private var emptyState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.RxLog.listening, systemImage: "antenna.radiowaves.left.and.right") + } description: { + Text(L10n.Tools.Tools.RxLog.listeningDescription) + } + } + + private var disconnectedState: some View { + ContentUnavailableView { + Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") + } description: { + Text(L10n.Tools.Tools.RxLog.notConnectedDescription) + } + } + + // MARK: - Entry List + + private var entryList: some View { + List { + Section { + ForEach(displayEntries, id: \.id) { entry in + RxLogRowView( + entry: entry, + isExpanded: expandedBinding(for: entry.packetHash), + groupCount: groupDuplicates ? viewModel.groupCounts[entry.packetHash, default: 1] : 1, + localPublicKeyPrefix: appState.connectedDevice?.publicKeyPrefix, + nodeNames: viewModel.nodeNames + ) } - .task(id: appState.servicesVersion) { - viewModel.configure( - rxLogService: { [appState] in appState.services?.rxLogService }, - dataStore: { [appState] in appState.services?.dataStore }, - radioID: { [appState] in appState.currentRadioID } - ) - await viewModel.subscribe() - await viewModel.loadNodeNames() - } - .onChange(of: appState.contactsVersion) { - Task { await viewModel.loadNodeNames() } + } header: { + liveStatusHeader + .textCase(nil) + } + .themedRowBackground(theme) + } + .listStyle(.insetGrouped) + .themedCanvas(theme) + } + + private func expandedBinding(for hash: String) -> Binding { + Binding( + get: { expandedHashes.contains(hash) }, + set: { isExpanded in + if isExpanded { + expandedHashes.insert(hash) + } else { + expandedHashes.remove(hash) } - .onDisappear { - viewModel.unsubscribe() + } + ) + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + filterMenu + overflowMenu + } + } + } + + private var isConnected: Bool { + appState.services?.rxLogService != nil + } + + private var liveStatusHeader: some View { + HStack(spacing: 8) { + statusPill + + Text(L10n.Tools.Tools.RxLog.packetsCount(viewModel.entries.count)) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + } + .modifier(GlassContainerModifier()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(isConnected ? L10n.Tools.Tools.RxLog.live : L10n.Tools.Tools.RxLog.offline), \(L10n.Tools.Tools.RxLog.packetsCount(viewModel.entries.count))") + } + + private var statusPill: some View { + HStack(spacing: 6) { + Circle() + .fill(isConnected ? .green : .gray) + .frame(width: 8, height: 8) + .modifier(PulseAnimationModifier(isActive: isConnected && !reduceMotion)) + + Text(isConnected ? L10n.Tools.Tools.RxLog.live : L10n.Tools.Tools.RxLog.offline) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .modifier(GlassEffectModifier()) + } + + private var filterMenu: some View { + Menu { + Section(L10n.Tools.Tools.RxLog.routeType) { + ForEach(RxLogViewModel.RouteFilter.allCases, id: \.self) { filter in + Button { + viewModel.setRouteFilter(filter) + } label: { + HStack { + Text(filter.displayName) + if viewModel.routeFilter == filter { + Image(systemName: "checkmark") + } + } + } } - } - - // MARK: - Empty State + } - private var emptyState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.RxLog.listening, systemImage: "antenna.radiowaves.left.and.right") - } description: { - Text(L10n.Tools.Tools.RxLog.listeningDescription) + Section(L10n.Tools.Tools.RxLog.decryptStatus) { + ForEach(RxLogViewModel.DecryptFilter.allCases, id: \.self) { filter in + Button { + viewModel.setDecryptFilter(filter) + } label: { + HStack { + Text(filter.displayName) + if viewModel.decryptFilter == filter { + Image(systemName: "checkmark") + } + } + } } - } - - private var disconnectedState: some View { - ContentUnavailableView { - Label(L10n.Tools.Tools.RxLog.notConnected, systemImage: "antenna.radiowaves.left.and.right.slash") - } description: { - Text(L10n.Tools.Tools.RxLog.notConnectedDescription) + } + } label: { + Label(L10n.Tools.Tools.RxLog.filter, systemImage: viewModel.routeFilter == .all && viewModel.decryptFilter == .all + ? "line.3.horizontal.decrease.circle" + : "line.3.horizontal.decrease.circle.fill") + } + .liquidGlassSecondaryButtonStyle() + } + + @State private var showClearConfirmation = false + + private var overflowMenu: some View { + Menu { + Button { + groupDuplicates.toggle() + } label: { + HStack { + Text(L10n.Tools.Tools.RxLog.groupDuplicates) + if groupDuplicates { Image(systemName: "checkmark") } } - } - - // MARK: - Entry List - - private var entryList: some View { - List { - Section { - ForEach(displayEntries, id: \.id) { entry in - RxLogRowView( - entry: entry, - isExpanded: expandedBinding(for: entry.packetHash), - groupCount: groupDuplicates ? viewModel.groupCounts[entry.packetHash, default: 1] : 1, - localPublicKeyPrefix: appState.connectedDevice?.publicKeyPrefix, - nodeNames: viewModel.nodeNames - ) - } - } header: { - liveStatusHeader - .textCase(nil) - } - .themedRowBackground(theme) + } + + Divider() + + Button(role: .destructive) { + showClearConfirmation = true + } label: { + Label(L10n.Tools.Tools.RxLog.deleteLogs, systemImage: "trash") + } + } label: { + Label(L10n.Tools.Tools.RxLog.more, systemImage: "ellipsis.circle") + } + .liquidGlassSecondaryButtonStyle() + .confirmationDialog(L10n.Tools.Tools.RxLog.deleteConfirmation, isPresented: $showClearConfirmation, titleVisibility: .visible) { + Button(L10n.Tools.Tools.RxLog.delete, role: .destructive) { + clearLog() + } + } + } + + // MARK: - Helpers + + private var displayEntries: [RxLogEntryDTO] { + let filtered = viewModel.filteredEntries + if groupDuplicates { + var seen = Set() + return filtered.filter { entry in + if seen.contains(entry.packetHash) { + return false } - .listStyle(.insetGrouped) - .themedCanvas(theme) + seen.insert(entry.packetHash) + return true + } } + return filtered + } - private func expandedBinding(for hash: String) -> Binding { - Binding( - get: { expandedHashes.contains(hash) }, - set: { isExpanded in - if isExpanded { - expandedHashes.insert(hash) - } else { - expandedHashes.remove(hash) - } - } - ) + private func clearLog() { + Task { + await viewModel.clearLog() } + expandedHashes.removeAll() + } +} - // MARK: - Toolbar +// MARK: - Row View - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 16) { - filterMenu - overflowMenu - } +struct RxLogRowView: View { + let entry: RxLogEntryDTO + @Binding var isExpanded: Bool + let groupCount: Int + let localPublicKeyPrefix: Data? + let nodeNames: [Data: String] + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + expandedContent + } label: { + collapsedContent + } + .sensoryFeedback(.selection, trigger: isExpanded) + } + + // MARK: - Collapsed Content (3 lines) + + private var collapsedContent: some View { + VStack(alignment: .leading, spacing: 4) { + // Line 1: Route type, time, signal bars + HStack { + Text(entry.routeTypeSimple) + .font(.caption.bold()) + .foregroundStyle(entry.isFlood ? .green : .blue) + + Text(entry.receivedAt, format: .dateTime.month(.twoDigits).day(.twoDigits).hour().minute().second()) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + + Spacer() + + if entry.snr != nil { + Image(systemName: "cellularbars", variableValue: entry.snrLevel) + .foregroundStyle(entry.snrQuality.color) + .accessibilityLabel(L10n.Tools.Tools.RxLog.signalStrength(entry.snrQuality.localizedLabel)) } - } - - private var isConnected: Bool { - appState.services?.rxLogService != nil - } - - private var liveStatusHeader: some View { - HStack(spacing: 8) { - statusPill - - Text(L10n.Tools.Tools.RxLog.packetsCount(viewModel.entries.count)) - .font(.subheadline) - .foregroundStyle(.secondary) - - Spacer() + } + + // Line 2: Path visualization + From/To for direct text messages + HStack(spacing: 4) { + Text(pathDisplayString) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + if isDirectTextMessage, let sender = entry.senderPrefix, let recipient = entry.recipientPrefix { + Text("·") + .foregroundStyle(.tertiary) + Text("\(resolveHashLabel(sender)) → \(resolveHashLabel(recipient))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // Line 3: Message preview or packet info, SNR, duplicate count + HStack { + if let text = entry.decodedText { + Text("\"\(text)\"") + .font(.caption) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + } else { + let versionSuffix = entry.payloadVersion > 0 ? " v\(entry.payloadVersion)" : "" + Text("\(entry.payloadType.displayName)\(versionSuffix) · \(entry.rawPayload.count) \(L10n.Tools.Tools.RxLog.bytes)") + .font(.caption) + .foregroundStyle(.secondary) } - .modifier(GlassContainerModifier()) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(isConnected ? L10n.Tools.Tools.RxLog.live : L10n.Tools.Tools.RxLog.offline), \(L10n.Tools.Tools.RxLog.packetsCount(viewModel.entries.count))") - } - private var statusPill: some View { - HStack(spacing: 6) { - Circle() - .fill(isConnected ? .green : .gray) - .frame(width: 8, height: 8) - .modifier(PulseAnimationModifier(isActive: isConnected && !reduceMotion)) + Spacer() - Text(isConnected ? L10n.Tools.Tools.RxLog.live : L10n.Tools.Tools.RxLog.offline) - .font(.subheadline) - .foregroundStyle(.secondary) + if let snrString = entry.snrDisplayString { + Text(snrString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .modifier(GlassEffectModifier()) - } - - private var filterMenu: some View { - Menu { - Section(L10n.Tools.Tools.RxLog.routeType) { - ForEach(RxLogViewModel.RouteFilter.allCases, id: \.self) { filter in - Button { - viewModel.setRouteFilter(filter) - } label: { - HStack { - Text(filter.displayName) - if viewModel.routeFilter == filter { - Image(systemName: "checkmark") - } - } - } - } - } - Section(L10n.Tools.Tools.RxLog.decryptStatus) { - ForEach(RxLogViewModel.DecryptFilter.allCases, id: \.self) { filter in - Button { - viewModel.setDecryptFilter(filter) - } label: { - HStack { - Text(filter.displayName) - if viewModel.decryptFilter == filter { - Image(systemName: "checkmark") - } - } - } - } - } - } label: { - Label(L10n.Tools.Tools.RxLog.filter, systemImage: viewModel.routeFilter == .all && viewModel.decryptFilter == .all - ? "line.3.horizontal.decrease.circle" - : "line.3.horizontal.decrease.circle.fill") + if groupCount > 1 { + Text("×\(groupCount)") + .font(.caption.bold()) + .foregroundStyle(.orange) + .accessibilityLabel(L10n.Tools.Tools.RxLog.receivedTimes(groupCount)) } - .liquidGlassSecondaryButtonStyle() + } } + } - @State private var showClearConfirmation = false - - private var overflowMenu: some View { - Menu { - Button { - groupDuplicates.toggle() - } label: { - HStack { - Text(L10n.Tools.Tools.RxLog.groupDuplicates) - if groupDuplicates { Image(systemName: "checkmark") } - } - } + // MARK: - Path Display - Divider() + private var isTrace: Bool { + entry.payloadType == .trace + } - Button(role: .destructive) { - showClearConfirmation = true - } label: { - Label(L10n.Tools.Tools.RxLog.deleteLogs, systemImage: "trash") - } - } label: { - Label(L10n.Tools.Tools.RxLog.more, systemImage: "ellipsis.circle") - } - .liquidGlassSecondaryButtonStyle() - .confirmationDialog(L10n.Tools.Tools.RxLog.deleteConfirmation, isPresented: $showClearConfirmation, titleVisibility: .visible) { - Button(L10n.Tools.Tools.RxLog.delete, role: .destructive) { - clearLog() - } - } + private var pathDisplayString: String { + if entry.pathNodes.isEmpty { + return L10n.Tools.Tools.RxLog.direct } - // MARK: - Helpers - - private var displayEntries: [RxLogEntryDTO] { - let filtered = viewModel.filteredEntries - if groupDuplicates { - var seen = Set() - return filtered.filter { entry in - if seen.contains(entry.packetHash) { - return false - } - seen.insert(entry.packetHash) - return true - } - } - return filtered + if isTrace { + let routeParts = traceRouteIdParts + if !routeParts.isEmpty { + return truncatedJoin(routeParts, separator: " → ") + } + let count = entry.hopCount + let hopLabel = count == 1 ? L10n.Tools.Tools.RxLog.hopSingular : L10n.Tools.Tools.RxLog.hopPlural + return "\(count) \(hopLabel)" } - private func clearLog() { - Task { - await viewModel.clearLog() - } - expandedHashes.removeAll() - } -} + return truncatedJoin(hopIdParts, separator: " → ") + } -// MARK: - Row View - -struct RxLogRowView: View { - let entry: RxLogEntryDTO - @Binding var isExpanded: Bool - let groupCount: Int - let localPublicKeyPrefix: Data? - let nodeNames: [Data: String] - - var body: some View { - DisclosureGroup(isExpanded: $isExpanded) { - expandedContent - } label: { - collapsedContent - } - .sensoryFeedback(.selection, trigger: isExpanded) + private var pathDetailString: String { + if entry.pathNodes.isEmpty { + return L10n.Tools.Tools.RxLog.direct } + let hopCount = entry.hopCount + let hopLabel = hopCount == 1 ? L10n.Tools.Tools.RxLog.hopSingular : L10n.Tools.Tools.RxLog.hopPlural + return "\(hopCount) \(hopLabel) [\(hopIdParts.joined(separator: ", "))]" + } - // MARK: - Collapsed Content (3 lines) - - private var collapsedContent: some View { - VStack(alignment: .leading, spacing: 4) { - // Line 1: Route type, time, signal bars - HStack { - Text(entry.routeTypeSimple) - .font(.caption.bold()) - .foregroundStyle(entry.isFlood ? .green : .blue) - - Text(entry.receivedAt, format: .dateTime.month(.twoDigits).day(.twoDigits).hour().minute().second()) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + /// For TRACE packets: public key prefix IDs from traceTargetHashes. + private var traceRouteIdParts: [String] { + guard let targetHashes = entry.traceTargetHashes else { return [] } + return targetHashes.map { $0.uppercaseHexString() } + } - Spacer() + /// For non-TRACE packets: public key prefix IDs for each hop, chunked by hashSize. + private var hopIdParts: [String] { + let hashSize = entry.pathHashSize + return stride(from: 0, to: entry.pathNodes.count, by: hashSize).map { start in + let end = min(start + hashSize, entry.pathNodes.count) + let chunk = entry.pathNodes[start.. 0 ? " v\(entry.payloadVersion)" : "" - Text("\(entry.payloadType.displayName)\(versionSuffix) · \(entry.rawPayload.count) \(L10n.Tools.Tools.RxLog.bytes)") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - if let snrString = entry.snrDisplayString { - Text(snrString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } - - if groupCount > 1 { - Text("×\(groupCount)") - .font(.caption.bold()) - .foregroundStyle(.orange) - .accessibilityLabel(L10n.Tools.Tools.RxLog.receivedTimes(groupCount)) - } - } - } + return Data(chunk).uppercaseHexString() } + } - // MARK: - Path Display - - private var isTrace: Bool { - entry.payloadType == .trace + /// Join parts with separator, truncating to first 3 … last 3 if > 6 elements. + private func truncatedJoin(_ parts: [String], separator: String) -> String { + if parts.count > 6 { + let first = parts.prefix(3).joined(separator: separator) + let last = parts.suffix(3).joined(separator: separator) + return "\(first) \(separator) … \(separator) \(last)" } + return parts.joined(separator: separator) + } - private var pathDisplayString: String { - if entry.pathNodes.isEmpty { - return L10n.Tools.Tools.RxLog.direct - } - - if isTrace { - let routeParts = traceRouteIdParts - if !routeParts.isEmpty { - return truncatedJoin(routeParts, separator: " → ") - } - let count = entry.hopCount - let hopLabel = count == 1 ? L10n.Tools.Tools.RxLog.hopSingular : L10n.Tools.Tools.RxLog.hopPlural - return "\(count) \(hopLabel)" - } + private var isDirectTextMessage: Bool { + (entry.routeType == .direct || entry.routeType == .tcDirect) && entry.payloadType == .textMessage + } - return truncatedJoin(hopIdParts, separator: " → ") + private func resolveHashLabel(_ hashBytes: Data) -> String { + if let prefix = localPublicKeyPrefix, prefix.prefix(hashBytes.count) == hashBytes { + return L10n.Tools.Tools.RxLog.pathYou } - - private var pathDetailString: String { - if entry.pathNodes.isEmpty { - return L10n.Tools.Tools.RxLog.direct - } - let hopCount = entry.hopCount - let hopLabel = hopCount == 1 ? L10n.Tools.Tools.RxLog.hopSingular : L10n.Tools.Tools.RxLog.hopPlural - return "\(hopCount) \(hopLabel) [\(hopIdParts.joined(separator: ", "))]" + if let name = nodeNames[hashBytes] { + return name } + return hashBytes.uppercaseHexString() + } - /// For TRACE packets: public key prefix IDs from traceTargetHashes. - private var traceRouteIdParts: [String] { - guard let targetHashes = entry.traceTargetHashes else { return [] } - return targetHashes.map { $0.uppercaseHexString() } - } + // MARK: - Expanded Content - /// For non-TRACE packets: public key prefix IDs for each hop, chunked by hashSize. - private var hopIdParts: [String] { - let hashSize = entry.pathHashSize - return stride(from: 0, to: entry.pathNodes.count, by: hashSize).map { start in - let end = min(start + hashSize, entry.pathNodes.count) - let chunk = entry.pathNodes[start.. 6 elements. - private func truncatedJoin(_ parts: [String], separator: String) -> String { - if parts.count > 6 { - let first = parts.prefix(3).joined(separator: separator) - let last = parts.suffix(3).joined(separator: separator) - return "\(first) \(separator) … \(separator) \(last)" - } - return parts.joined(separator: separator) - } + DetailRow(label: L10n.Tools.Tools.RxLog.hashLabel, value: entry.packetHash, truncate: true) - private var isDirectTextMessage: Bool { - (entry.routeType == .direct || entry.routeType == .tcDirect) && entry.payloadType == .textMessage - } + if isDirectTextMessage, let sender = entry.senderPrefix, let recipient = entry.recipientPrefix { + DetailRow(label: L10n.Tools.Tools.RxLog.fromLabel, value: resolveHashLabel(sender)) + DetailRow(label: L10n.Tools.Tools.RxLog.toLabel, value: resolveHashLabel(recipient)) + } - private func resolveHashLabel(_ hashBytes: Data) -> String { - if let prefix = localPublicKeyPrefix, prefix.prefix(hashBytes.count) == hashBytes { - return L10n.Tools.Tools.RxLog.pathYou + // Channel message: show channel info + if entry.decryptStatus == .success { + if entry.channelIndex != nil, let channelHashByte = entry.packetPayload.first { + DetailRow(label: L10n.Tools.Tools.RxLog.channelHashLabel, value: String(format: "%02x", channelHashByte)) } - if let name = nodeNames[hashBytes] { - return name + if let channelName = entry.channelName { + DetailRow(label: L10n.Tools.Tools.RxLog.channelNameLabel, value: channelName) } - return hashBytes.uppercaseHexString() - } - - // MARK: - Expanded Content - - private var expandedContent: some View { - VStack(alignment: .leading, spacing: 4) { - if let rssi = entry.rssi { - DetailRow(label: L10n.Tools.Tools.RxLog.rssiLabel, value: "\(rssi) dBm") - } - if let snr = entry.snr { - DetailRow(label: L10n.Tools.Tools.RxLog.snrLabel, value: snr.formatted(.number.precision(.fractionLength(1))) + " dB") - } - - DetailRow(label: L10n.Tools.Tools.RxLog.typeLabel, value: entry.payloadType.displayName) - DetailRow(label: L10n.Tools.Tools.RxLog.sizeLabel, value: "\(entry.rawPayload.count) \(L10n.Tools.Tools.RxLog.bytes)") - - if isTrace { - let idParts = traceRouteIdParts - if !idParts.isEmpty { - DetailRow(label: L10n.Tools.Tools.RxLog.traceRouteLabel, value: idParts.joined(separator: " → "), wrapping: true) - } - } else { - DetailRow(label: L10n.Tools.Tools.RxLog.pathLabel, value: pathDetailString, wrapping: true) - } - - DetailRow(label: L10n.Tools.Tools.RxLog.hashLabel, value: entry.packetHash, truncate: true) - - if isDirectTextMessage, let sender = entry.senderPrefix, let recipient = entry.recipientPrefix { - DetailRow(label: L10n.Tools.Tools.RxLog.fromLabel, value: resolveHashLabel(sender)) - DetailRow(label: L10n.Tools.Tools.RxLog.toLabel, value: resolveHashLabel(recipient)) - } - - // Channel message: show channel info - if entry.decryptStatus == .success { - if entry.channelIndex != nil, let channelHashByte = entry.packetPayload.first { - DetailRow(label: L10n.Tools.Tools.RxLog.channelHashLabel, value: String(format: "%02x", channelHashByte)) - } - if let channelName = entry.channelName { - DetailRow(label: L10n.Tools.Tools.RxLog.channelNameLabel, value: channelName) - } - if let transportCode = entry.transportCode, !transportCode.isEmpty { - DetailRow( - label: L10n.Tools.Tools.RxLog.regionLabel, - value: entry.regionScope ?? L10n.Tools.Tools.RxLog.regionUnresolved - ) - } - if let text = entry.decodedText { - HStack(alignment: .top) { - Text(L10n.Tools.Tools.RxLog.textLabel) - .font(.caption) - .foregroundStyle(.secondary) - Text(text) - .font(.caption) - } - } - } - - RawPayloadSection(payload: entry.rawPayload) + if let transportCode = entry.transportCode, !transportCode.isEmpty { + DetailRow( + label: L10n.Tools.Tools.RxLog.regionLabel, + value: entry.regionScope ?? L10n.Tools.Tools.RxLog.regionUnresolved + ) + } + if let text = entry.decodedText { + HStack(alignment: .top) { + Text(L10n.Tools.Tools.RxLog.textLabel) + .font(.caption) + .foregroundStyle(.secondary) + Text(text) + .font(.caption) + } } + } + + RawPayloadSection(payload: entry.rawPayload) } + } } // MARK: - Detail Row private struct DetailRow: View { - let label: String - let value: String - var truncate: Bool = false - var wrapping: Bool = false - - var body: some View { - HStack { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.caption.monospaced()) - .lineLimit(wrapping ? nil : 1) - .truncationMode(truncate ? .middle : .tail) - } - } + let label: String + let value: String + var truncate: Bool = false + var wrapping: Bool = false + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.caption.monospaced()) + .lineLimit(wrapping ? nil : 1) + .truncationMode(truncate ? .middle : .tail) + } + } } // MARK: - Raw Payload Section private struct RawPayloadSection: View { - let payload: Data - - @State private var copied = false - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - HStack { - Text(L10n.Tools.Tools.RxLog.rawPayload) - .font(.caption.bold()) - .foregroundStyle(.secondary) - - Spacer() - - Button(L10n.Tools.Tools.RxLog.copy, systemImage: copied ? "checkmark" : "doc.on.doc", action: copyToClipboard) - .font(.caption) - .foregroundStyle(copied ? .green : .secondary) - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .sensoryFeedback(.success, trigger: copied) { _, newValue in newValue } - } - - Text(hexString) - .font(.caption2.monospaced()) - .lineLimit(3) - .truncationMode(.tail) - } - } - - private var hexString: String { - payload.map { String(format: "%02X", $0) }.joined(separator: " ") - } - - private func copyToClipboard() { - UIPasteboard.general.string = hexString - copied = true - Task { - try? await Task.sleep(for: .seconds(2)) - copied = false - } - } + let payload: Data + + @State private var copied = false + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(L10n.Tools.Tools.RxLog.rawPayload) + .font(.caption.bold()) + .foregroundStyle(.secondary) + + Spacer() + + Button(L10n.Tools.Tools.RxLog.copy, systemImage: copied ? "checkmark" : "doc.on.doc", action: copyToClipboard) + .font(.caption) + .foregroundStyle(copied ? .green : .secondary) + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .sensoryFeedback(.success, trigger: copied) { _, newValue in newValue } + } + + Text(hexString) + .font(.caption2.monospaced()) + .lineLimit(3) + .truncationMode(.tail) + } + } + + private var hexString: String { + payload.map { String(format: "%02X", $0) }.joined(separator: " ") + } + + private func copyToClipboard() { + UIPasteboard.general.string = hexString + copied = true + Task { + try? await Task.sleep(for: .seconds(2)) + copied = false + } + } } // MARK: - Glass Effect Modifiers private struct GlassEffectModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.glassEffect() - } else { - content.background(.ultraThinMaterial, in: .capsule) - } + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.glassEffect() + } else { + content.background(.ultraThinMaterial, in: .capsule) } + } } private struct GlassContainerModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - GlassEffectContainer { content } - } else { - content - } + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + GlassEffectContainer { content } + } else { + content } + } } // MARK: - Pulse Animation private struct PulseAnimationModifier: ViewModifier { - let isActive: Bool - - @State private var isPulsing = false - - func body(content: Content) -> some View { - content - .opacity(isActive && isPulsing ? 0.4 : 1.0) - .animation( - isActive ? .easeInOut(duration: 1.0).repeatForever(autoreverses: true) : .default, - value: isPulsing - ) - .onAppear { - if isActive { - isPulsing = true - } - } - .onChange(of: isActive) { _, newValue in - isPulsing = newValue - } - } + let isActive: Bool + + @State private var isPulsing = false + + func body(content: Content) -> some View { + content + .opacity(isActive && isPulsing ? 0.4 : 1.0) + .animation( + isActive ? .easeInOut(duration: 1.0).repeatForever(autoreverses: true) : .default, + value: isPulsing + ) + .onAppear { + if isActive { + isPulsing = true + } + } + .onChange(of: isActive) { _, newValue in + isPulsing = newValue + } + } } #Preview { - NavigationStack { - RxLogView() - } - .environment(\.appState, AppState()) + NavigationStack { + RxLogView() + } + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/RxLogViewModel.swift b/MC1/Views/Tools/RxLogViewModel.swift index 95be1c39..ed859ce4 100644 --- a/MC1/Views/Tools/RxLogViewModel.swift +++ b/MC1/Views/Tools/RxLogViewModel.swift @@ -4,207 +4,215 @@ import MC1Services @Observable @MainActor final class RxLogViewModel { - enum RouteFilter: String, CaseIterable { - case all - case floodOnly - case directOnly - - var displayName: String { - switch self { - case .all: L10n.Tools.Tools.RxLog.Filter.all - case .floodOnly: L10n.Tools.Tools.RxLog.Filter.floodOnly - case .directOnly: L10n.Tools.Tools.RxLog.Filter.directOnly - } - } + enum RouteFilter: String, CaseIterable { + case all + case floodOnly + case directOnly + + var displayName: String { + switch self { + case .all: L10n.Tools.Tools.RxLog.Filter.all + case .floodOnly: L10n.Tools.Tools.RxLog.Filter.floodOnly + case .directOnly: L10n.Tools.Tools.RxLog.Filter.directOnly + } } - - enum DecryptFilter: String, CaseIterable { - case all - case decrypted - case failed - - var displayName: String { - switch self { - case .all: L10n.Tools.Tools.RxLog.Filter.all - case .decrypted: L10n.Tools.Tools.RxLog.Filter.decrypted - case .failed: L10n.Tools.Tools.RxLog.Filter.failed - } - } + } + + enum DecryptFilter: String, CaseIterable { + case all + case decrypted + case failed + + var displayName: String { + switch self { + case .all: L10n.Tools.Tools.RxLog.Filter.all + case .decrypted: L10n.Tools.Tools.RxLog.Filter.decrypted + case .failed: L10n.Tools.Tools.RxLog.Filter.failed + } } - - private(set) var entries: [RxLogEntryDTO] = [] - private(set) var groupCounts: [String: Int] = [:] - private(set) var routeFilter: RouteFilter = .all - private(set) var decryptFilter: DecryptFilter = .all - - /// Maps path hash bytes (1, 2, or 3 byte prefixes) to contact display names. - /// Only populated for prefixes that uniquely identify a single contact. - private(set) var nodeNames: [Data: String] = [:] - - private var streamTask: Task? - - // MARK: - Dependencies - - private var rxLogServiceProvider: @MainActor () -> RxLogService? = { nil } - private var dataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } - private var radioIDProvider: @MainActor () -> UUID? = { nil } - - private var rxLogService: RxLogService? { rxLogServiceProvider() } - private var dataStore: (any PersistenceStoreProtocol)? { dataStoreProvider() } - private var radioID: UUID? { radioIDProvider() } - - // The provider reads live state, so change detection needs the instance - // seen at the previous subscribe. Weak, so a torn-down container's - // deallocated service reads as a change. - private weak var subscribedService: RxLogService? - - /// Each provider is read live at its point of use; a provider returning - /// nil mirrors a disconnected state, so unconfigured calls are no-ops. - func configure( - rxLogService: @escaping @MainActor () -> RxLogService?, - dataStore: @escaping @MainActor () -> (any PersistenceStoreProtocol)?, - radioID: @escaping @MainActor () -> UUID? - ) { - rxLogServiceProvider = rxLogService - dataStoreProvider = dataStore - radioIDProvider = radioID + } + + private(set) var entries: [RxLogEntryDTO] = [] + private(set) var groupCounts: [String: Int] = [:] + private(set) var routeFilter: RouteFilter = .all + private(set) var decryptFilter: DecryptFilter = .all + + /// Maps path hash bytes (1, 2, or 3 byte prefixes) to contact display names. + /// Only populated for prefixes that uniquely identify a single contact. + private(set) var nodeNames: [Data: String] = [:] + + private var streamTask: Task? + + // MARK: - Dependencies + + private var rxLogServiceProvider: @MainActor () -> RxLogService? = { nil } + private var dataStoreProvider: @MainActor () -> (any PersistenceStoreProtocol)? = { nil } + private var radioIDProvider: @MainActor () -> UUID? = { nil } + + private var rxLogService: RxLogService? { + rxLogServiceProvider() + } + + private var dataStore: (any PersistenceStoreProtocol)? { + dataStoreProvider() + } + + private var radioID: UUID? { + radioIDProvider() + } + + /// The provider reads live state, so change detection needs the instance + /// seen at the previous subscribe. Weak, so a torn-down container's + /// deallocated service reads as a change. + private weak var subscribedService: RxLogService? + + /// Each provider is read live at its point of use; a provider returning + /// nil mirrors a disconnected state, so unconfigured calls are no-ops. + func configure( + rxLogService: @escaping @MainActor () -> RxLogService?, + dataStore: @escaping @MainActor () -> (any PersistenceStoreProtocol)?, + radioID: @escaping @MainActor () -> UUID? + ) { + rxLogServiceProvider = rxLogService + dataStoreProvider = dataStore + radioIDProvider = radioID + } + + func setRouteFilter(_ filter: RouteFilter) { + routeFilter = filter + } + + func setDecryptFilter(_ filter: DecryptFilter) { + decryptFilter = filter + } + + /// Entries filtered by current filter settings. + var filteredEntries: [RxLogEntryDTO] { + entries.filter { entry in + // Route filter + switch routeFilter { + case .all: break + case .floodOnly: + guard entry.isFlood else { return false } + case .directOnly: + guard !entry.isFlood else { return false } + } + + // Decrypt filter + switch decryptFilter { + case .all: break + case .decrypted: + guard entry.decryptStatus == .success else { return false } + case .failed: + guard entry.decryptStatus == .hmacFailed + || entry.decryptStatus == .decryptFailed + || entry.decryptStatus == .noMatchingKey + || entry.decryptStatus == .dmNoMatchingKey else { return false } + } + + return true } + } - func setRouteFilter(_ filter: RouteFilter) { - routeFilter = filter - } - - func setDecryptFilter(_ filter: DecryptFilter) { - decryptFilter = filter - } - - /// Entries filtered by current filter settings. - var filteredEntries: [RxLogEntryDTO] { - entries.filter { entry in - // Route filter - switch routeFilter { - case .all: break - case .floodOnly: - guard entry.isFlood else { return false } - case .directOnly: - guard !entry.isFlood else { return false } - } - - // Decrypt filter - switch decryptFilter { - case .all: break - case .decrypted: - guard entry.decryptStatus == .success else { return false } - case .failed: - guard entry.decryptStatus == .hmacFailed - || entry.decryptStatus == .decryptFailed - || entry.decryptStatus == .noMatchingKey - || entry.decryptStatus == .dmNoMatchingKey else { return false } - } - - return true - } - } - - /// Subscribe to the live RxLogService for updates while view is visible. - func subscribe() async { - // Cancel any existing stream task so a re-subscribe (a `.task(id:)` re-fire - // against the same service) can't leave two streams appending each packet twice. - unsubscribe() - - guard let service = rxLogService else { return } - - // If service changed, reset state - if subscribedService !== service { - entries.removeAll() - groupCounts.removeAll() - } - subscribedService = service + /// Subscribe to the live RxLogService for updates while view is visible. + func subscribe() async { + // Cancel any existing stream task so a re-subscribe (a `.task(id:)` re-fire + // against the same service) can't leave two streams appending each packet twice. + unsubscribe() - entries = await service.loadExistingEntries() - rebuildGroupCounts() + guard let service = rxLogService else { return } - streamTask = Task { - for await entry in service.entryStream() { - guard !Task.isCancelled else { break } - appendEntry(entry) - } - } + // If service changed, reset state + if subscribedService !== service { + entries.removeAll() + groupCounts.removeAll() } + subscribedService = service - /// Stop listening to updates. - func unsubscribe() { - streamTask?.cancel() - streamTask = nil - } + entries = await service.loadExistingEntries() + rebuildGroupCounts() - /// Clear all log entries. - func clearLog() async { - await rxLogService?.clearEntries() - entries.removeAll() - groupCounts.removeAll() + streamTask = Task { + for await entry in service.entryStream() { + guard !Task.isCancelled else { break } + appendEntry(entry) + } } - - // MARK: - Incremental Updates - - private func appendEntry(_ entry: RxLogEntryDTO) { - // Insert at front to maintain newest-first order (matching DB fetch sort) - entries.insert(entry, at: 0) - groupCounts[entry.packetHash, default: 0] += 1 - - // Prune oldest (now at end) if over cap - if entries.count > 1000 { - let removed = entries.removeLast() - groupCounts[removed.packetHash, default: 1] -= 1 - if groupCounts[removed.packetHash] == 0 { - groupCounts.removeValue(forKey: removed.packetHash) - } - } + } + + /// Stop listening to updates. + func unsubscribe() { + streamTask?.cancel() + streamTask = nil + } + + /// Clear all log entries. + func clearLog() async { + await rxLogService?.clearEntries() + entries.removeAll() + groupCounts.removeAll() + } + + // MARK: - Incremental Updates + + private func appendEntry(_ entry: RxLogEntryDTO) { + // Insert at front to maintain newest-first order (matching DB fetch sort) + entries.insert(entry, at: 0) + groupCounts[entry.packetHash, default: 0] += 1 + + // Prune oldest (now at end) if over cap + if entries.count > 1000 { + let removed = entries.removeLast() + groupCounts[removed.packetHash, default: 1] -= 1 + if groupCounts[removed.packetHash] == 0 { + groupCounts.removeValue(forKey: removed.packetHash) + } } - - private func rebuildGroupCounts() { - groupCounts = Dictionary(grouping: entries, by: \.packetHash) - .mapValues(\.count) - } - - // MARK: - Node Name Resolution - - /// Load contact names for path hop resolution. Leaves `nodeNames` - /// unchanged while disconnected. - func loadNodeNames() async { - guard let dataStore, let radioID else { return } - do { - let contacts = try await dataStore.fetchContacts(radioID: radioID) - nodeNames = Self.buildNodeNameMap(from: contacts) - } catch { - nodeNames = [:] - } + } + + private func rebuildGroupCounts() { + groupCounts = Dictionary(grouping: entries, by: \.packetHash) + .mapValues(\.count) + } + + // MARK: - Node Name Resolution + + /// Load contact names for path hop resolution. Leaves `nodeNames` + /// unchanged while disconnected. + func loadNodeNames() async { + guard let dataStore, let radioID else { return } + do { + let contacts = try await dataStore.fetchContacts(radioID: radioID) + nodeNames = Self.buildNodeNameMap(from: contacts) + } catch { + nodeNames = [:] } + } - /// Build a map from public key prefixes (1, 2, 3 bytes) to display names. - /// Only stores entries where the prefix uniquely identifies a single contact. - static func buildNodeNameMap(from contacts: [ContactDTO]) -> [Data: String] { - var map: [Data: String] = [:] - - for prefixLength in 1...3 { - var prefixCounts: [Data: (name: String, count: Int)] = [:] + /// Build a map from public key prefixes (1, 2, 3 bytes) to display names. + /// Only stores entries where the prefix uniquely identifies a single contact. + static func buildNodeNameMap(from contacts: [ContactDTO]) -> [Data: String] { + var map: [Data: String] = [:] - for contact in contacts { - guard contact.publicKey.count >= prefixLength else { continue } - let prefix = contact.publicKey.prefix(prefixLength) + for prefixLength in 1...3 { + var prefixCounts: [Data: (name: String, count: Int)] = [:] - if let existing = prefixCounts[prefix] { - prefixCounts[prefix] = (existing.name, existing.count + 1) - } else { - prefixCounts[prefix] = (contact.displayName, 1) - } - } + for contact in contacts { + guard contact.publicKey.count >= prefixLength else { continue } + let prefix = contact.publicKey.prefix(prefixLength) - for (prefix, entry) in prefixCounts where entry.count == 1 { - map[prefix] = entry.name - } + if let existing = prefixCounts[prefix] { + prefixCounts[prefix] = (existing.name, existing.count + 1) + } else { + prefixCounts[prefix] = (contact.displayName, 1) } + } - return map + for (prefix, entry) in prefixCounts where entry.count == 1 { + map[prefix] = entry.name + } } + + return map + } } diff --git a/MC1/Views/Tools/ToolDestinationView.swift b/MC1/Views/Tools/ToolDestinationView.swift index 791a0506..ce20ac85 100644 --- a/MC1/Views/Tools/ToolDestinationView.swift +++ b/MC1/Views/Tools/ToolDestinationView.swift @@ -5,17 +5,17 @@ import SwiftUI /// between the two — the compact stack shows the combined map-with-sheet layout from a fresh view /// model, while the split shows only the map driven by the shared one — so its view is injected. struct ToolDestinationView: View { - let tool: ToolSelection - @ViewBuilder let lineOfSight: () -> LineOfSight + let tool: ToolSelection + @ViewBuilder let lineOfSight: () -> LineOfSight - var body: some View { - switch tool { - case .tracePath: TracePathView() - case .lineOfSight: lineOfSight() - case .rxLog: RxLogView() - case .noiseFloor: NoiseFloorView() - case .nodeDiscovery: NodeDiscoveryView() - case .cli: CLIToolView() - } + var body: some View { + switch tool { + case .tracePath: TracePathView() + case .lineOfSight: lineOfSight() + case .rxLog: RxLogView() + case .noiseFloor: NoiseFloorView() + case .nodeDiscovery: NodeDiscoveryView() + case .cli: CLIToolView() } + } } diff --git a/MC1/Views/Tools/ToolSelection.swift b/MC1/Views/Tools/ToolSelection.swift index 4f723bc1..fd036738 100644 --- a/MC1/Views/Tools/ToolSelection.swift +++ b/MC1/Views/Tools/ToolSelection.swift @@ -4,45 +4,45 @@ import SwiftUI /// `NavigationLink`) and the iPad split columns (`ToolsContentColumn` list selection + /// `ToolsDetailColumn` detail), and persisted as the active selection on `NavigationCoordinator`. enum ToolSelection: Hashable, CaseIterable { - case tracePath - case lineOfSight - case rxLog - case noiseFloor - case nodeDiscovery - case cli + case tracePath + case lineOfSight + case rxLog + case noiseFloor + case nodeDiscovery + case cli - var title: String { - switch self { - case .tracePath: L10n.Tools.Tools.tracePath - case .lineOfSight: L10n.Tools.Tools.lineOfSight - case .rxLog: L10n.Tools.Tools.rxLog - case .noiseFloor: L10n.Tools.Tools.noiseFloor - case .nodeDiscovery: L10n.Tools.Tools.nodeDiscovery - case .cli: L10n.Tools.Tools.cli - } + var title: String { + switch self { + case .tracePath: L10n.Tools.Tools.tracePath + case .lineOfSight: L10n.Tools.Tools.lineOfSight + case .rxLog: L10n.Tools.Tools.rxLog + case .noiseFloor: L10n.Tools.Tools.noiseFloor + case .nodeDiscovery: L10n.Tools.Tools.nodeDiscovery + case .cli: L10n.Tools.Tools.cli } + } - var systemImage: String { - switch self { - case .tracePath: "point.3.connected.trianglepath.dotted" - case .lineOfSight: "eye" - case .rxLog: "waveform.badge.magnifyingglass" - case .noiseFloor: "waveform" - case .nodeDiscovery: "dot.radiowaves.left.and.right" - case .cli: "terminal" - } + var systemImage: String { + switch self { + case .tracePath: "point.3.connected.trianglepath.dotted" + case .lineOfSight: "eye" + case .rxLog: "waveform.badge.magnifyingglass" + case .noiseFloor: "waveform" + case .nodeDiscovery: "dot.radiowaves.left.and.right" + case .cli: "terminal" } + } - /// Line of Sight runs its analysis offline; every other tool needs a connected radio. - var requiresRadio: Bool { - self != .lineOfSight - } + /// Line of Sight runs its analysis offline; every other tool needs a connected radio. + var requiresRadio: Bool { + self != .lineOfSight + } - /// Tools that collapse the iPad section's sidebar when open, reclaiming its width. Line of Sight - /// swaps the content column for its analysis panel beside the detail map; Trace Path keeps the tool - /// list in the content column and gives its own list/map view the freed width in the detail column. - /// Other tools keep the sidebar's normal width-driven behavior. - var prefersCollapsedSidebar: Bool { - self == .lineOfSight || self == .tracePath - } + /// Tools that collapse the iPad section's sidebar when open, reclaiming its width. Line of Sight + /// swaps the content column for its analysis panel beside the detail map; Trace Path keeps the tool + /// list in the content column and gives its own list/map view the freed width in the detail column. + /// Other tools keep the sidebar's normal width-driven behavior. + var prefersCollapsedSidebar: Bool { + self == .lineOfSight || self == .tracePath + } } diff --git a/MC1/Views/Tools/ToolsContentColumn.swift b/MC1/Views/Tools/ToolsContentColumn.swift index 3119496e..6dd41b5d 100644 --- a/MC1/Views/Tools/ToolsContentColumn.swift +++ b/MC1/Views/Tools/ToolsContentColumn.swift @@ -6,72 +6,72 @@ import SwiftUI /// with a back control returning to the list. The panel carries readable RF figures, so it keeps /// an opaque background — the floating-glass background extension is applied to the list only. struct ToolsContentColumn: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme - let lineOfSightViewModel: LineOfSightViewModel + let lineOfSightViewModel: LineOfSightViewModel - private var selection: Binding { - Binding( - get: { appState.navigation.selectedTool }, - set: { setSelectedTool($0) } - ) - } + private var selection: Binding { + Binding( + get: { appState.navigation.selectedTool }, + set: { setSelectedTool($0) } + ) + } - /// Selecting or leaving Line of Sight swaps this column's `NavigationStack` root, which animates - /// the navigation bar; suppressing the animation on that transition stops the radio control from - /// leaving a stuck ghost over the title while the leading slot reconfigures to the back button. - /// Selections that only drive the detail column (every other tool) keep their default animation. - private func setSelectedTool(_ tool: ToolSelection?) { - let togglesPanel = tool == .lineOfSight || appState.navigation.selectedTool == .lineOfSight - guard togglesPanel else { - appState.navigation.selectedTool = tool - return - } - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { appState.navigation.selectedTool = tool } + /// Selecting or leaving Line of Sight swaps this column's `NavigationStack` root, which animates + /// the navigation bar; suppressing the animation on that transition stops the radio control from + /// leaving a stuck ghost over the title while the leading slot reconfigures to the back button. + /// Selections that only drive the detail column (every other tool) keep their default animation. + private func setSelectedTool(_ tool: ToolSelection?) { + let togglesPanel = tool == .lineOfSight || appState.navigation.selectedTool == .lineOfSight + guard togglesPanel else { + appState.navigation.selectedTool = tool + return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { appState.navigation.selectedTool = tool } + } - var body: some View { - NavigationStack { - if appState.navigation.selectedTool == .lineOfSight { - lineOfSightPanel - } else { - toolList - } - } + var body: some View { + NavigationStack { + if appState.navigation.selectedTool == .lineOfSight { + lineOfSightPanel + } else { + toolList + } } + } - private var toolList: some View { - List(selection: selection) { - ForEach(ToolSelection.allCases, id: \.self) { tool in - Label(tool.title, systemImage: tool.systemImage) - .tag(Optional(tool)) - } - } - .listStyle(.sidebar) - .navigationTitle(L10n.Tools.Tools.title) - .modifier(SidebarContentColumnBackground(theme: theme)) - .toolbar { - bleStatusToolbarItem() - } + private var toolList: some View { + List(selection: selection) { + ForEach(ToolSelection.allCases, id: \.self) { tool in + Label(tool.title, systemImage: tool.systemImage) + .tag(Optional(tool)) + } } - - private var lineOfSightPanel: some View { - LineOfSightView(viewModel: lineOfSightViewModel, layoutMode: .panel) - .navigationTitle(L10n.Tools.Tools.lineOfSight) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - setSelectedTool(nil) - } label: { - Label(L10n.Tools.Tools.title, systemImage: "chevron.backward") - } - } - // The back button owns the leading slot here, so the radio moves to the trailing edge. - bleStatusToolbarItem(placement: .topBarTrailing) - } + .listStyle(.sidebar) + .navigationTitle(L10n.Tools.Tools.title) + .modifier(SidebarContentColumnBackground(theme: theme)) + .toolbar { + bleStatusToolbarItem() } + } + + private var lineOfSightPanel: some View { + LineOfSightView(viewModel: lineOfSightViewModel, layoutMode: .panel) + .navigationTitle(L10n.Tools.Tools.lineOfSight) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + setSelectedTool(nil) + } label: { + Label(L10n.Tools.Tools.title, systemImage: "chevron.backward") + } + } + // The back button owns the leading slot here, so the radio moves to the trailing edge. + bleStatusToolbarItem(placement: .topBarTrailing) + } + } } diff --git a/MC1/Views/Tools/ToolsDetailColumn.swift b/MC1/Views/Tools/ToolsDetailColumn.swift index 37e4ff89..e6293fc2 100644 --- a/MC1/Views/Tools/ToolsDetailColumn.swift +++ b/MC1/Views/Tools/ToolsDetailColumn.swift @@ -6,38 +6,38 @@ import SwiftUI /// The map self-manages its safe area, so no blanket `ignoresSafeArea` is applied here (that would /// push the other tools' titles under the status bar). struct ToolsDetailColumn: View { - @Environment(\.appState) private var appState + @Environment(\.appState) private var appState - let lineOfSightViewModel: LineOfSightViewModel + let lineOfSightViewModel: LineOfSightViewModel - private var selectedTool: ToolSelection? { - appState.navigation.selectedTool - } + private var selectedTool: ToolSelection? { + appState.navigation.selectedTool + } - var body: some View { - NavigationStack { - detail - .navigationTitle(navigationTitle) - .navigationBarTitleDisplayMode(.inline) - } - .id(selectedTool) - .liquidGlassToolbarBackground() + var body: some View { + NavigationStack { + detail + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) } + .id(selectedTool) + .liquidGlassToolbarBackground() + } - @ViewBuilder - private var detail: some View { - if let selectedTool { - ToolDestinationView(tool: selectedTool) { - LineOfSightView(viewModel: lineOfSightViewModel, layoutMode: .map) - } - } else { - ContentUnavailableView(L10n.Tools.Tools.selectTool, systemImage: "wrench.and.screwdriver") - } + @ViewBuilder + private var detail: some View { + if let selectedTool { + ToolDestinationView(tool: selectedTool) { + LineOfSightView(viewModel: lineOfSightViewModel, layoutMode: .map) + } + } else { + ContentUnavailableView(L10n.Tools.Tools.selectTool, systemImage: "wrench.and.screwdriver") } + } - /// Line of Sight owns its own inline chrome over the map, so it renders titleless. - private var navigationTitle: String { - guard let selectedTool, selectedTool != .lineOfSight else { return "" } - return selectedTool.title - } + /// Line of Sight owns its own inline chrome over the map, so it renders titleless. + private var navigationTitle: String { + guard let selectedTool, selectedTool != .lineOfSight else { return "" } + return selectedTool.title + } } diff --git a/MC1/Views/Tools/ToolsView.swift b/MC1/Views/Tools/ToolsView.swift index 4642de73..b36d9b36 100644 --- a/MC1/Views/Tools/ToolsView.swift +++ b/MC1/Views/Tools/ToolsView.swift @@ -4,31 +4,31 @@ import SwiftUI /// regular-width layout routes Tools through `MainSidebarView`'s split (`ToolsContentColumn` + /// `ToolsDetailColumn`) instead, so this view is only reached in compact width. struct ToolsView: View { - @Environment(\.appTheme) private var theme + @Environment(\.appTheme) private var theme - var body: some View { - NavigationStack { - List { - ForEach(ToolSelection.allCases, id: \.self) { tool in - NavigationLink(value: tool) { - Label(tool.title, systemImage: tool.systemImage) - } - } - .themedRowBackground(theme) - } - .navigationDestination(for: ToolSelection.self) { tool in - ToolDestinationView(tool: tool) { LineOfSightView() } - } - .themedCanvas(theme) - .navigationTitle(L10n.Tools.Tools.title) - .toolbar { - bleStatusToolbarItem() - } + var body: some View { + NavigationStack { + List { + ForEach(ToolSelection.allCases, id: \.self) { tool in + NavigationLink(value: tool) { + Label(tool.title, systemImage: tool.systemImage) + } } + .themedRowBackground(theme) + } + .navigationDestination(for: ToolSelection.self) { tool in + ToolDestinationView(tool: tool) { LineOfSightView() } + } + .themedCanvas(theme) + .navigationTitle(L10n.Tools.Tools.title) + .toolbar { + bleStatusToolbarItem() + } } + } } #Preview { - ToolsView() - .environment(\.appState, AppState()) + ToolsView() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Tools/TracePath/BatchRTTRow.swift b/MC1/Views/Tools/TracePath/BatchRTTRow.swift index b20c5d85..107645f2 100644 --- a/MC1/Views/Tools/TracePath/BatchRTTRow.swift +++ b/MC1/Views/Tools/TracePath/BatchRTTRow.swift @@ -1,28 +1,28 @@ -import SwiftUI import MC1Services +import SwiftUI /// Row displaying batch RTT statistics (average, min, max) struct BatchRTTRow: View { - @Bindable var viewModel: TracePathViewModel + @Bindable var viewModel: TracePathViewModel - var body: some View { - if let avg = viewModel.averageRTT, - let min = viewModel.minRTT, - let max = viewModel.maxRTT { - HStack { - Text(L10n.Contacts.Contacts.Results.avgRoundTrip) - .foregroundStyle(.secondary) - Spacer() - VStack(alignment: .trailing) { - Text("\(avg) ms") - .font(.body.monospacedDigit()) - Text("(\(min) – \(max))") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Contacts.Contacts.Results.avgRTTLabel(avg, min, max)) + var body: some View { + if let avg = viewModel.averageRTT, + let min = viewModel.minRTT, + let max = viewModel.maxRTT { + HStack { + Text(L10n.Contacts.Contacts.Results.avgRoundTrip) + .foregroundStyle(.secondary) + Spacer() + VStack(alignment: .trailing) { + Text("\(avg) ms") + .font(.body.monospacedDigit()) + Text("(\(min) – \(max))") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Contacts.Contacts.Results.avgRTTLabel(avg, min, max)) } + } } diff --git a/MC1/Views/Tools/TracePath/BatchSizeChip.swift b/MC1/Views/Tools/TracePath/BatchSizeChip.swift index 4694085d..4727a898 100644 --- a/MC1/Views/Tools/TracePath/BatchSizeChip.swift +++ b/MC1/Views/Tools/TracePath/BatchSizeChip.swift @@ -1,22 +1,24 @@ import SwiftUI struct BatchSizeChip: View { - let size: Int - @Binding var selectedSize: Int + let size: Int + @Binding var selectedSize: Int - private var isSelected: Bool { selectedSize == size } + private var isSelected: Bool { + selectedSize == size + } - var body: some View { - Button { - selectedSize = size - } label: { - Text("\(size)×") - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(isSelected ? Color.accentColor : Color.secondary.opacity(0.2), in: .capsule) - .foregroundStyle(isSelected ? .white : .primary) - } - .buttonStyle(.plain) + var body: some View { + Button { + selectedSize = size + } label: { + Text("\(size)×") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? Color.accentColor : Color.secondary.opacity(0.2), in: .capsule) + .foregroundStyle(isSelected ? .white : .primary) } + .buttonStyle(.plain) + } } diff --git a/MC1/Views/Tools/TracePath/ComparisonRowView.swift b/MC1/Views/Tools/TracePath/ComparisonRowView.swift index 76d334a4..dd6db894 100644 --- a/MC1/Views/Tools/TracePath/ComparisonRowView.swift +++ b/MC1/Views/Tools/TracePath/ComparisonRowView.swift @@ -1,59 +1,59 @@ -import SwiftUI import MC1Services +import SwiftUI /// Row comparing current trace RTT with a previous saved path run struct ComparisonRowView: View { - let currentMs: Int - let previousRun: TracePathRunDTO - @Bindable var viewModel: TracePathViewModel - - var body: some View { - let diff = currentMs - previousRun.roundTripMs - let percentChange = previousRun.roundTripMs > 0 - ? Double(diff) / Double(previousRun.roundTripMs) * 100 - : 0 - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(L10n.Contacts.Contacts.PathDetail.roundTrip) - .foregroundStyle(.secondary) - Spacer() - Text("\(currentMs) ms") - .font(.body.monospacedDigit()) - - // Change indicator - if diff != 0 { - Text(diff > 0 ? "\u{25B2}" : "\u{25BC}") - .foregroundStyle(diff > 0 ? .red : .green) - .accessibilityLabel(diff > 0 - ? L10n.Contacts.Contacts.Results.Comparison.increased - : L10n.Contacts.Contacts.Results.Comparison.decreased) - Text(abs(percentChange), format: .number.precision(.fractionLength(0))) - .font(.caption.monospacedDigit()) - + Text("%") - .font(.caption) - } - } - - Text(L10n.Contacts.Contacts.Results.comparison(previousRun.roundTripMs, previousRun.date.formatted(date: .abbreviated, time: .omitted))) - .font(.caption) - .foregroundStyle(.secondary) + let currentMs: Int + let previousRun: TracePathRunDTO + @Bindable var viewModel: TracePathViewModel + + var body: some View { + let diff = currentMs - previousRun.roundTripMs + let percentChange = previousRun.roundTripMs > 0 + ? Double(diff) / Double(previousRun.roundTripMs) * 100 + : 0 + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(L10n.Contacts.Contacts.PathDetail.roundTrip) + .foregroundStyle(.secondary) + Spacer() + Text("\(currentMs) ms") + .font(.body.monospacedDigit()) + + // Change indicator + if diff != 0 { + Text(diff > 0 ? "\u{25B2}" : "\u{25BC}") + .foregroundStyle(diff > 0 ? .red : .green) + .accessibilityLabel(diff > 0 + ? L10n.Contacts.Contacts.Results.Comparison.increased + : L10n.Contacts.Contacts.Results.Comparison.decreased) + Text(abs(percentChange), format: .number.precision(.fractionLength(0))) + .font(.caption.monospacedDigit()) + + Text("%") + .font(.caption) } + } + + Text(L10n.Contacts.Contacts.Results.comparison(previousRun.roundTripMs, previousRun.date.formatted(date: .abbreviated, time: .omitted))) + .font(.caption) + .foregroundStyle(.secondary) + } - // Sparkline with history link - if let savedPath = viewModel.activeSavedPath, !savedPath.recentRTTs.isEmpty { - HStack { - MiniSparkline(values: savedPath.recentRTTs) - .frame(height: 20) + // Sparkline with history link + if let savedPath = viewModel.activeSavedPath, !savedPath.recentRTTs.isEmpty { + HStack { + MiniSparkline(values: savedPath.recentRTTs) + .frame(height: 20) - Spacer() + Spacer() - NavigationLink(value: TracePathRoute.savedPathDetail(savedPath)) { - Text(L10n.Contacts.Contacts.Results.viewRuns(savedPath.runCount)) - .font(.caption) - } - } - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + NavigationLink(value: TracePathRoute.savedPathDetail(savedPath)) { + Text(L10n.Contacts.Contacts.Results.viewRuns(savedPath.runCount)) + .font(.caption) } + } + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } } + } } diff --git a/MC1/Views/Tools/TracePath/DistanceInfoSheetView.swift b/MC1/Views/Tools/TracePath/DistanceInfoSheetView.swift index d829f80a..eb07001c 100644 --- a/MC1/Views/Tools/TracePath/DistanceInfoSheetView.swift +++ b/MC1/Views/Tools/TracePath/DistanceInfoSheetView.swift @@ -1,62 +1,62 @@ -import SwiftUI import MC1Services +import SwiftUI /// Sheet explaining distance calculation details and limitations struct DistanceInfoSheetView: View { - @Environment(\.appTheme) private var theme - let result: TraceResult - @Bindable var viewModel: TracePathViewModel - @Binding var showingDistanceInfo: Bool + @Environment(\.appTheme) private var theme + let result: TraceResult + @Bindable var viewModel: TracePathViewModel + @Binding var showingDistanceInfo: Bool - var body: some View { - NavigationStack { - List { - if viewModel.isDistanceUsingFallback { - Section { - Text(L10n.Contacts.Contacts.Results.partialDistanceExplanation) - } header: { - Label(L10n.Contacts.Contacts.Results.partialDistanceHeader, systemImage: "location.slash") - } - .themedRowBackground(theme) - Section { - Text(L10n.Contacts.Contacts.Results.fullPathTip) - } header: { - Label(L10n.Contacts.Contacts.Results.fullPathHeader, systemImage: "lightbulb") - } - .themedRowBackground(theme) - } else if result.hops.filter({ !$0.isStartNode && !$0.isEndNode }).count < 2 { - Section { - Text(L10n.Contacts.Contacts.Results.needsRepeaters) - } - .themedRowBackground(theme) - } else if viewModel.repeatersWithoutLocation.isEmpty { - Section { - Text(L10n.Contacts.Contacts.Results.distanceError) - } - .themedRowBackground(theme) - } else { - Section { - Text(L10n.Contacts.Contacts.Results.missingLocations) - } - .themedRowBackground(theme) - Section(L10n.Contacts.Contacts.Results.repeatersWithoutLocations) { - ForEach(viewModel.repeatersWithoutLocation, id: \.self) { name in - Text(name) - } - } - .themedRowBackground(theme) - } - } - .themedCanvas(theme) - .navigationTitle(viewModel.isDistanceUsingFallback ? L10n.Contacts.Contacts.Results.distanceInfoTitlePartial : L10n.Contacts.Contacts.Results.distanceInfoTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(L10n.Contacts.Contacts.Common.done) { - showingDistanceInfo = false - } - } + var body: some View { + NavigationStack { + List { + if viewModel.isDistanceUsingFallback { + Section { + Text(L10n.Contacts.Contacts.Results.partialDistanceExplanation) + } header: { + Label(L10n.Contacts.Contacts.Results.partialDistanceHeader, systemImage: "location.slash") + } + .themedRowBackground(theme) + Section { + Text(L10n.Contacts.Contacts.Results.fullPathTip) + } header: { + Label(L10n.Contacts.Contacts.Results.fullPathHeader, systemImage: "lightbulb") + } + .themedRowBackground(theme) + } else if result.hops.count(where: { !$0.isStartNode && !$0.isEndNode }) < 2 { + Section { + Text(L10n.Contacts.Contacts.Results.needsRepeaters) + } + .themedRowBackground(theme) + } else if viewModel.repeatersWithoutLocation.isEmpty { + Section { + Text(L10n.Contacts.Contacts.Results.distanceError) + } + .themedRowBackground(theme) + } else { + Section { + Text(L10n.Contacts.Contacts.Results.missingLocations) + } + .themedRowBackground(theme) + Section(L10n.Contacts.Contacts.Results.repeatersWithoutLocations) { + ForEach(viewModel.repeatersWithoutLocation, id: \.self) { name in + Text(name) } + } + .themedRowBackground(theme) + } + } + .themedCanvas(theme) + .navigationTitle(viewModel.isDistanceUsingFallback ? L10n.Contacts.Contacts.Results.distanceInfoTitlePartial : L10n.Contacts.Contacts.Results.distanceInfoTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(L10n.Contacts.Contacts.Common.done) { + showingDistanceInfo = false + } } + } } + } } diff --git a/MC1/Views/Tools/TracePath/Map/PathActionsSectionView.swift b/MC1/Views/Tools/TracePath/Map/PathActionsSectionView.swift index ce0f8d28..9faa1591 100644 --- a/MC1/Views/Tools/TracePath/Map/PathActionsSectionView.swift +++ b/MC1/Views/Tools/TracePath/Map/PathActionsSectionView.swift @@ -1,91 +1,91 @@ -import SwiftUI import MC1Services +import SwiftUI /// Section with path configuration toggles, copy, and clear actions struct PathActionsSectionView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Bindable var viewModel: TracePathViewModel - @Binding var showingClearConfirmation: Bool - @Binding var copyHapticTrigger: Int + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Bindable var viewModel: TracePathViewModel + @Binding var showingClearConfirmation: Bool + @Binding var copyHapticTrigger: Int - var body: some View { - Section { - if !viewModel.outboundPath.isEmpty { - Toggle(isOn: $viewModel.autoReturnPath) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Contacts.Contacts.Trace.List.autoReturn) - Text(L10n.Contacts.Contacts.Trace.List.autoReturnDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } + var body: some View { + Section { + if !viewModel.outboundPath.isEmpty { + Toggle(isOn: $viewModel.autoReturnPath) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Contacts.Contacts.Trace.List.autoReturn) + Text(L10n.Contacts.Contacts.Trace.List.autoReturnDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } - Toggle(isOn: $viewModel.batchEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Contacts.Contacts.Trace.List.batchTrace) - Text(L10n.Contacts.Contacts.Trace.List.batchTraceDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } + Toggle(isOn: $viewModel.batchEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Contacts.Contacts.Trace.List.batchTrace) + Text(L10n.Contacts.Contacts.Trace.List.batchTraceDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } - if viewModel.batchEnabled { - HStack(spacing: 12) { - Text(L10n.Contacts.Contacts.Trace.List.traces) - .foregroundStyle(.secondary) - Spacer() - BatchSizeChip(size: 3, selectedSize: $viewModel.batchSize) - BatchSizeChip(size: 5, selectedSize: $viewModel.batchSize) - BatchSizeChip(size: 10, selectedSize: $viewModel.batchSize) - } - } + if viewModel.batchEnabled { + HStack(spacing: 12) { + Text(L10n.Contacts.Contacts.Trace.List.traces) + .foregroundStyle(.secondary) + Spacer() + BatchSizeChip(size: 3, selectedSize: $viewModel.batchSize) + BatchSizeChip(size: 5, selectedSize: $viewModel.batchSize) + BatchSizeChip(size: 10, selectedSize: $viewModel.batchSize) + } + } - if appState.connectedDevice?.supportsTraceHashSizeOverride == true { - VStack(alignment: .leading, spacing: 4) { - Picker(L10n.Contacts.Contacts.Trace.List.hashSize, selection: Binding( - get: { viewModel.effectiveTraceMode }, - set: { viewModel.setTraceHashMode($0) } - )) { - Text(L10n.Contacts.Contacts.Trace.List.hashSizeOneByte).tag(UInt8(0)) - Text(L10n.Contacts.Contacts.Trace.List.hashSizeTwoBytes).tag(UInt8(1)) - Text(L10n.Contacts.Contacts.Trace.List.hashSizeFourBytes).tag(UInt8(2)) - } - .pickerStyle(.menu) - .tint(.primary) + if appState.connectedDevice?.supportsTraceHashSizeOverride == true { + VStack(alignment: .leading, spacing: 4) { + Picker(L10n.Contacts.Contacts.Trace.List.hashSize, selection: Binding( + get: { viewModel.effectiveTraceMode }, + set: { viewModel.setTraceHashMode($0) } + )) { + Text(L10n.Contacts.Contacts.Trace.List.hashSizeOneByte).tag(UInt8(0)) + Text(L10n.Contacts.Contacts.Trace.List.hashSizeTwoBytes).tag(UInt8(1)) + Text(L10n.Contacts.Contacts.Trace.List.hashSizeFourBytes).tag(UInt8(2)) + } + .pickerStyle(.menu) + .tint(.primary) - if viewModel.effectiveTraceMode > 0 { - Text(L10n.Contacts.Contacts.Trace.List.hashSizeFooter) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } + if viewModel.effectiveTraceMode > 0 { + Text(L10n.Contacts.Contacts.Trace.List.hashSizeFooter) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } - HStack { - Text(viewModel.fullPathString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + HStack { + Text(viewModel.fullPathString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - viewModel.copyPathToClipboard() - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - } + Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + viewModel.copyPathToClipboard() + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } - Button(L10n.Contacts.Contacts.Trace.clearPath, systemImage: "trash", role: .destructive) { - showingClearConfirmation = true - } - } - } footer: { - if !viewModel.outboundPath.isEmpty { - Text(L10n.Contacts.Contacts.Trace.List.rangeWarning) - } + Button(L10n.Contacts.Contacts.Trace.clearPath, systemImage: "trash", role: .destructive) { + showingClearConfirmation = true } - .themedRowBackground(theme) + } + } footer: { + if !viewModel.outboundPath.isEmpty { + Text(L10n.Contacts.Contacts.Trace.List.rangeWarning) + } } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Tools/TracePath/Map/RunTraceSectionView.swift b/MC1/Views/Tools/TracePath/Map/RunTraceSectionView.swift index 448c93e6..92b7d3ce 100644 --- a/MC1/Views/Tools/TracePath/Map/RunTraceSectionView.swift +++ b/MC1/Views/Tools/TracePath/Map/RunTraceSectionView.swift @@ -1,75 +1,75 @@ -import SwiftUI import MC1Services +import SwiftUI /// Section with the run trace button and running state indicator struct RunTraceSectionView: View { - @Environment(\.appState) private var appState - var viewModel: TracePathViewModel - @Binding var showJumpToPath: Bool + @Environment(\.appState) private var appState + var viewModel: TracePathViewModel + @Binding var showJumpToPath: Bool - var body: some View { - Section { - HStack { - Spacer() - if viewModel.isRunning { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - if viewModel.batchEnabled { - Text(L10n.Contacts.Contacts.Trace.List.runningBatch(viewModel.currentTraceIndex, viewModel.batchSize)) - } else { - Text(L10n.Contacts.Contacts.Trace.List.runningTrace) - } - } - .frame(minWidth: 160) - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(.regularMaterial, in: .capsule) - .overlay { - Capsule() - .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) - } - .accessibilityLabel(viewModel.batchEnabled - ? L10n.Contacts.Contacts.Trace.List.runningBatchLabel(viewModel.currentTraceIndex, viewModel.batchSize) - : L10n.Contacts.Contacts.Trace.List.runningLabel) - .accessibilityHint(L10n.Contacts.Contacts.Trace.List.runningHint) - } else { - Button { - Task { - if viewModel.batchEnabled { - await viewModel.runBatchTrace() - } else { - await viewModel.runTrace() - } - } - } label: { - Text(L10n.Contacts.Contacts.Trace.List.runTrace) - .frame(minWidth: 160) - .padding(.vertical, 4) - } - .liquidGlassProminentButtonStyle() - .radioDisabled(for: appState.connectionState, or: !viewModel.canRunTraceWhenConnected) - .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.runTraceLabel) - .accessibilityHint(viewModel.batchEnabled - ? L10n.Contacts.Contacts.Trace.List.batchHint(viewModel.batchSize) - : L10n.Contacts.Contacts.Trace.List.singleHint) - } - Spacer() - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .id("runTrace") - .onAppear { - withAnimation(.easeInOut(duration: 0.2)) { - showJumpToPath = false - } + var body: some View { + Section { + HStack { + Spacer() + if viewModel.isRunning { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + if viewModel.batchEnabled { + Text(L10n.Contacts.Contacts.Trace.List.runningBatch(viewModel.currentTraceIndex, viewModel.batchSize)) + } else { + Text(L10n.Contacts.Contacts.Trace.List.runningTrace) } - .onDisappear { - withAnimation(.easeInOut(duration: 0.2)) { - showJumpToPath = true - } + } + .frame(minWidth: 160) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(.regularMaterial, in: .capsule) + .overlay { + Capsule() + .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) + } + .accessibilityLabel(viewModel.batchEnabled + ? L10n.Contacts.Contacts.Trace.List.runningBatchLabel(viewModel.currentTraceIndex, viewModel.batchSize) + : L10n.Contacts.Contacts.Trace.List.runningLabel) + .accessibilityHint(L10n.Contacts.Contacts.Trace.List.runningHint) + } else { + Button { + Task { + if viewModel.batchEnabled { + await viewModel.runBatchTrace() + } else { + await viewModel.runTrace() + } } + } label: { + Text(L10n.Contacts.Contacts.Trace.List.runTrace) + .frame(minWidth: 160) + .padding(.vertical, 4) + } + .liquidGlassProminentButtonStyle() + .radioDisabled(for: appState.connectionState, or: !viewModel.canRunTraceWhenConnected) + .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.runTraceLabel) + .accessibilityHint(viewModel.batchEnabled + ? L10n.Contacts.Contacts.Trace.List.batchHint(viewModel.batchSize) + : L10n.Contacts.Contacts.Trace.List.singleHint) + } + Spacer() + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .id("runTrace") + .onAppear { + withAnimation(.easeInOut(duration: 0.2)) { + showJumpToPath = false + } + } + .onDisappear { + withAnimation(.easeInOut(duration: 0.2)) { + showJumpToPath = true } - .listSectionSeparator(.hidden) + } } + .listSectionSeparator(.hidden) + } } diff --git a/MC1/Views/Tools/TracePath/Map/TracePathFloatingButtonsView.swift b/MC1/Views/Tools/TracePath/Map/TracePathFloatingButtonsView.swift index fcfcfb5f..b22df832 100644 --- a/MC1/Views/Tools/TracePath/Map/TracePathFloatingButtonsView.swift +++ b/MC1/Views/Tools/TracePath/Map/TracePathFloatingButtonsView.swift @@ -1,76 +1,76 @@ -import SwiftUI import MC1Services +import SwiftUI /// Floating action buttons for trace path map (clear, run trace, view results) struct TracePathFloatingButtonsView: View { - var mapViewModel: TracePathMapViewModel - @Binding var showingClearConfirmation: Bool - @Binding var presentedResult: TraceResult? - var buttonNamespace: Namespace.ID + var mapViewModel: TracePathMapViewModel + @Binding var showingClearConfirmation: Bool + @Binding var presentedResult: TraceResult? + var buttonNamespace: Namespace.ID - var body: some View { - VStack { - Spacer() + var body: some View { + VStack { + Spacer() - LiquidGlassContainer(spacing: 12) { - HStack(spacing: 12) { - if mapViewModel.hasPath { - // Clear button - Button { - showingClearConfirmation = true - } label: { - Text(L10n.Contacts.Contacts.Trace.Map.clear) - } - .liquidGlassButtonStyle() - .liquidGlassID("clear", in: buttonNamespace) - .confirmationDialog( - L10n.Contacts.Contacts.Trace.clearPath, - isPresented: $showingClearConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { - mapViewModel.clearPath() - } - } message: { - Text(L10n.Contacts.Contacts.Trace.clearPathMessage) - } - - // Run Trace button - Button { - Task { - await mapViewModel.runTrace() - } - } label: { - if mapViewModel.isRunning { - HStack { - ProgressView() - .controlSize(.small) - Text(L10n.Contacts.Contacts.Trace.List.runningTrace) - } - } else { - Text(L10n.Contacts.Contacts.Trace.List.runTrace) - } - } - .liquidGlassProminentButtonStyle() - .liquidGlassID("trace", in: buttonNamespace) - .disabled(!mapViewModel.canRunTrace) + LiquidGlassContainer(spacing: 12) { + HStack(spacing: 12) { + if mapViewModel.hasPath { + // Clear button + Button { + showingClearConfirmation = true + } label: { + Text(L10n.Contacts.Contacts.Trace.Map.clear) + } + .liquidGlassButtonStyle() + .liquidGlassID("clear", in: buttonNamespace) + .confirmationDialog( + L10n.Contacts.Contacts.Trace.clearPath, + isPresented: $showingClearConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { + mapViewModel.clearPath() + } + } message: { + Text(L10n.Contacts.Contacts.Trace.clearPathMessage) + } - // View Results button - if let result = mapViewModel.result, result.success { - Button { - presentedResult = result - } label: { - Text(L10n.Contacts.Contacts.Trace.Map.viewResults) - } - .liquidGlassButtonStyle() - .liquidGlassID("viewResults", in: buttonNamespace) - } - } + // Run Trace button + Button { + Task { + await mapViewModel.runTrace() + } + } label: { + if mapViewModel.isRunning { + HStack { + ProgressView() + .controlSize(.small) + Text(L10n.Contacts.Contacts.Trace.List.runningTrace) } + } else { + Text(L10n.Contacts.Contacts.Trace.List.runTrace) + } + } + .liquidGlassProminentButtonStyle() + .liquidGlassID("trace", in: buttonNamespace) + .disabled(!mapViewModel.canRunTrace) + + // View Results button + if let result = mapViewModel.result, result.success { + Button { + presentedResult = result + } label: { + Text(L10n.Contacts.Contacts.Trace.Map.viewResults) + } + .liquidGlassButtonStyle() + .liquidGlassID("viewResults", in: buttonNamespace) } - .animation(.spring(response: 0.3), value: mapViewModel.hasPath) - .animation(.spring(response: 0.3), value: mapViewModel.result?.id) - .padding(.bottom, 24) + } } + } + .animation(.spring(response: 0.3), value: mapViewModel.hasPath) + .animation(.spring(response: 0.3), value: mapViewModel.result?.id) + .padding(.bottom, 24) } + } } diff --git a/MC1/Views/Tools/TracePath/Map/TracePathMapToolbarView.swift b/MC1/Views/Tools/TracePath/Map/TracePathMapToolbarView.swift index 02b51dba..14f1c827 100644 --- a/MC1/Views/Tools/TracePath/Map/TracePathMapToolbarView.swift +++ b/MC1/Views/Tools/TracePath/Map/TracePathMapToolbarView.swift @@ -1,57 +1,57 @@ import MapKit import MapLibre -import SwiftUI import MC1Services +import SwiftUI /// Map controls toolbar for trace path map view (location, labels, layers) struct TracePathMapToolbarView: View { - @Environment(\.appState) private var appState - @Bindable var mapViewModel: TracePathMapViewModel - @Binding var mapStyleSelection: MapStyleSelection - @Binding var showLabels: Bool + @Environment(\.appState) private var appState + @Bindable var mapViewModel: TracePathMapViewModel + @Binding var mapStyleSelection: MapStyleSelection + @Binding var showLabels: Bool - var body: some View { - VStack { - Spacer() - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: { - if let location = appState.bestAvailableLocation { - mapViewModel.setCameraRegion(MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - )) - } else { - appState.locationService.requestLocation() - } - }, - isNorthLocked: $mapViewModel.isNorthLocked, - showLabels: $showLabels, - showingLayersMenu: $mapViewModel.showingLayersMenu - ) { - // Center on path - if mapViewModel.hasPath { - Button(L10n.Contacts.Contacts.Trace.Map.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { - mapViewModel.centerOnPath() - } - .mapControlButton(tint: .primary) - } - } + var body: some View { + VStack { + Spacer() + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: { + if let location = appState.bestAvailableLocation { + mapViewModel.setCameraRegion(MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + )) + } else { + appState.locationService.requestLocation() } - } - .overlay(alignment: .bottomTrailing) { - if mapViewModel.showingLayersMenu { - LayersMenu( - selection: $mapStyleSelection, - isPresented: $mapViewModel.showingLayersMenu, - viewportBounds: mapViewModel.cameraRegion?.toMLNCoordinateBounds() - ) - .padding(.trailing, 16) - .padding(.bottom, 160) - .transition(.scale.combined(with: .opacity)) + }, + isNorthLocked: $mapViewModel.isNorthLocked, + showLabels: $showLabels, + showingLayersMenu: $mapViewModel.showingLayersMenu + ) { + // Center on path + if mapViewModel.hasPath { + Button(L10n.Contacts.Contacts.Trace.Map.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { + mapViewModel.centerOnPath() } + .mapControlButton(tint: .primary) + } } - .animation(.spring(response: 0.3), value: mapViewModel.showingLayersMenu) + } + } + .overlay(alignment: .bottomTrailing) { + if mapViewModel.showingLayersMenu { + LayersMenu( + selection: $mapStyleSelection, + isPresented: $mapViewModel.showingLayersMenu, + viewportBounds: mapViewModel.cameraRegion?.toMLNCoordinateBounds() + ) + .padding(.trailing, 16) + .padding(.bottom, 160) + .transition(.scale.combined(with: .opacity)) + } } + .animation(.spring(response: 0.3), value: mapViewModel.showingLayersMenu) + } } diff --git a/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift b/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift index f023d8ce..6238d005 100644 --- a/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift +++ b/MC1/Views/Tools/TracePath/Map/TracePathMapView.swift @@ -1,145 +1,144 @@ import MapKit -import SwiftUI import MC1Services import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "TracePathMapView") /// Map-based view for building and visualizing trace paths struct TracePathMapView: View { - @Environment(\.appState) private var appState - @Environment(\.colorScheme) private var colorScheme - @Bindable var traceViewModel: TracePathViewModel - @Binding var presentedResult: TraceResult? - @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard - @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels - @State private var mapViewModel = TracePathMapViewModel() + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + @Bindable var traceViewModel: TracePathViewModel + @Binding var presentedResult: TraceResult? + @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels + @State private var mapViewModel = TracePathMapViewModel() - @State private var showingSavePrompt = false - @State private var saveName = "" - @State private var showingClearConfirmation = false - @State private var showingSaveSuccess = false - @State private var errorMessage: String? - @State private var pinTapHaptic = 0 - @State private var rejectedTapHaptic = 0 + @State private var showingSavePrompt = false + @State private var saveName = "" + @State private var showingClearConfirmation = false + @State private var showingSaveSuccess = false + @State private var errorMessage: String? + @State private var pinTapHaptic = 0 + @State private var rejectedTapHaptic = 0 - @Namespace private var buttonNamespace + @Namespace private var buttonNamespace - var body: some View { - ZStack { - mapContent + var body: some View { + ZStack { + mapContent - // Results banner at top - if let result = mapViewModel.result, result.success { - PathDistanceBanner( - hopCount: result.hops.count - 2, - totalPathDistance: traceViewModel.totalPathDistance - ) - } + // Results banner at top + if let result = mapViewModel.result, result.success { + PathDistanceBanner( + hopCount: result.hops.count - 2, + totalPathDistance: traceViewModel.totalPathDistance + ) + } - // Floating buttons - TracePathFloatingButtonsView( - mapViewModel: mapViewModel, - showingClearConfirmation: $showingClearConfirmation, - presentedResult: $presentedResult, - buttonNamespace: buttonNamespace - ) + // Floating buttons + TracePathFloatingButtonsView( + mapViewModel: mapViewModel, + showingClearConfirmation: $showingClearConfirmation, + presentedResult: $presentedResult, + buttonNamespace: buttonNamespace + ) - // Map controls toolbar - TracePathMapToolbarView( - mapViewModel: mapViewModel, - mapStyleSelection: $mapStyleSelection, - showLabels: $showLabels - ) - } - .onAppear { - mapViewModel.configure( - traceViewModel: traceViewModel, - userLocation: appState.bestAvailableLocation - ) - mapViewModel.showLabels = showLabels - mapViewModel.rebuildOverlays() - mapViewModel.performInitialCentering() - } - .onChange(of: showLabels) { _, newValue in - mapViewModel.showLabels = newValue - } - .onChange(of: appState.bestAvailableLocation) { old, new in - guard old?.coordinate.latitude != new?.coordinate.latitude - || old?.coordinate.longitude != new?.coordinate.longitude else { return } - mapViewModel.updateUserLocation(new) - } - .onChange(of: traceViewModel.availableNodes) { _, _ in - mapViewModel.rebuildPathState() - if !mapViewModel.hasInitiallyCenteredOnRepeaters && !mapViewModel.repeatersWithLocation.isEmpty { - mapViewModel.performInitialCentering() - } - } - .onChange(of: traceViewModel.resultID) { _, _ in - mapViewModel.updateOverlaysWithResults() - } - .alert(L10n.Contacts.Contacts.Trace.Map.saveTitle, isPresented: $showingSavePrompt) { - TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $saveName) - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { - saveName = "" - } - Button(L10n.Contacts.Contacts.Common.save) { - Task { - let success = await mapViewModel.savePath(name: saveName) - saveName = "" - if success { - showingSaveSuccess = true - } else { - errorMessage = L10n.Contacts.Contacts.Trace.Map.saveFailedMessage - } - } - } - } message: { - Text(L10n.Contacts.Contacts.Trace.Map.saveMessage) - } - .sensoryFeedback(.impact(weight: .light), trigger: pinTapHaptic) - .sensoryFeedback(.warning, trigger: rejectedTapHaptic) - .alert(L10n.Contacts.Contacts.Trace.Map.savedTitle, isPresented: $showingSaveSuccess) { - Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) {} - } message: { - Text(L10n.Contacts.Contacts.Trace.Map.savedMessage) + // Map controls toolbar + TracePathMapToolbarView( + mapViewModel: mapViewModel, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels + ) + } + .onAppear { + mapViewModel.configure( + traceViewModel: traceViewModel, + userLocation: appState.bestAvailableLocation + ) + mapViewModel.showLabels = showLabels + mapViewModel.rebuildOverlays() + mapViewModel.performInitialCentering() + } + .onChange(of: showLabels) { _, newValue in + mapViewModel.showLabels = newValue + } + .onChange(of: appState.bestAvailableLocation) { old, new in + guard old?.coordinate.latitude != new?.coordinate.latitude + || old?.coordinate.longitude != new?.coordinate.longitude else { return } + mapViewModel.updateUserLocation(new) + } + .onChange(of: traceViewModel.availableNodes) { _, _ in + mapViewModel.rebuildPathState() + if !mapViewModel.hasInitiallyCenteredOnRepeaters, !mapViewModel.repeatersWithLocation.isEmpty { + mapViewModel.performInitialCentering() + } + } + .onChange(of: traceViewModel.resultID) { _, _ in + mapViewModel.updateOverlaysWithResults() + } + .alert(L10n.Contacts.Contacts.Trace.Map.saveTitle, isPresented: $showingSavePrompt) { + TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $saveName) + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { + saveName = "" + } + Button(L10n.Contacts.Contacts.Common.save) { + Task { + let success = await mapViewModel.savePath(name: saveName) + saveName = "" + if success { + showingSaveSuccess = true + } else { + errorMessage = L10n.Contacts.Contacts.Trace.Map.saveFailedMessage + } } - .errorAlert($errorMessage, title: L10n.Contacts.Contacts.Trace.Map.saveFailedTitle) + } + } message: { + Text(L10n.Contacts.Contacts.Trace.Map.saveMessage) } - - // MARK: - Map Content - - private var mapContent: some View { - MC1MapView( - points: mapViewModel.mapPoints, - lines: mapViewModel.mapLines, - mapStyle: mapStyleSelection, - isDarkMode: colorScheme == .dark, - isOffline: !appState.offlineMapService.isNetworkAvailable, - showLabels: showLabels, - showsUserLocation: true, - isInteractive: true, - showsScale: true, - isNorthLocked: mapViewModel.isNorthLocked, - cameraRegion: $mapViewModel.cameraRegion, - cameraRegionVersion: mapViewModel.cameraRegionVersion, - cameraBottomSheetFraction: 0, - onPointTap: { point, _ in - if let repeater = mapViewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { - let result = mapViewModel.handleRepeaterTap(repeater) - if result == .rejectedMiddleHop { - rejectedTapHaptic += 1 - } else { - pinTapHaptic += 1 - } - } - }, - onMapTap: nil, - onCameraRegionChange: { region in - mapViewModel.cameraRegion = region - }, - ) - .ignoresSafeArea() + .sensoryFeedback(.impact(weight: .light), trigger: pinTapHaptic) + .sensoryFeedback(.warning, trigger: rejectedTapHaptic) + .alert(L10n.Contacts.Contacts.Trace.Map.savedTitle, isPresented: $showingSaveSuccess) { + Button(L10n.Contacts.Contacts.Common.ok, role: .cancel) {} + } message: { + Text(L10n.Contacts.Contacts.Trace.Map.savedMessage) } + .errorAlert($errorMessage, title: L10n.Contacts.Contacts.Trace.Map.saveFailedTitle) + } + // MARK: - Map Content + + private var mapContent: some View { + MC1MapView( + points: mapViewModel.mapPoints, + lines: mapViewModel.mapLines, + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: mapViewModel.isNorthLocked, + cameraRegion: $mapViewModel.cameraRegion, + cameraRegionVersion: mapViewModel.cameraRegionVersion, + cameraBottomSheetFraction: 0, + onPointTap: { point, _ in + if let repeater = mapViewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { + let result = mapViewModel.handleRepeaterTap(repeater) + if result == .rejectedMiddleHop { + rejectedTapHaptic += 1 + } else { + pinTapHaptic += 1 + } + } + }, + onMapTap: nil, + onCameraRegionChange: { region in + mapViewModel.cameraRegion = region + }, + ) + .ignoresSafeArea() + } } diff --git a/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift b/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift index 8eb9355c..5789fe83 100644 --- a/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift +++ b/MC1/Views/Tools/TracePath/Map/TracePathMapViewModel.swift @@ -1,8 +1,8 @@ import CoreLocation import MapKit -import SwiftUI import MC1Services import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "TracePathMap") @@ -10,384 +10,384 @@ private let logger = Logger(subsystem: "com.mc1", category: "TracePathMap") @Observable @MainActor final class TracePathMapViewModel { + // MARK: - Map State - // MARK: - Map State - - var cameraRegion: MKCoordinateRegion? - /// Incremented when code intentionally moves the camera (not from user gesture sync) - private(set) var cameraRegionVersion = 0 - var showLabels: Bool = true { - didSet { rebuildMapPoints() } - } - var isNorthLocked = false - var showingLayersMenu: Bool = false + var cameraRegion: MKCoordinateRegion? + /// Incremented when code intentionally moves the camera (not from user gesture sync) + private(set) var cameraRegionVersion = 0 + var showLabels: Bool = true { + didSet { rebuildMapPoints() } + } - /// Tracks whether initial centering on repeaters has been performed - private(set) var hasInitiallyCenteredOnRepeaters = false + var isNorthLocked = false + var showingLayersMenu: Bool = false - // MARK: - Path Overlays + /// Tracks whether initial centering on repeaters has been performed + private(set) var hasInitiallyCenteredOnRepeaters = false - private(set) var mapLines: [MapLine] = [] - private(set) var badgePoints: [MapPoint] = [] - private(set) var mapPoints: [MapPoint] = [] + // MARK: - Path Overlays - // MARK: - Dependencies + private(set) var mapLines: [MapLine] = [] + private(set) var badgePoints: [MapPoint] = [] + private(set) var mapPoints: [MapPoint] = [] - private weak var traceViewModel: TracePathViewModel? - private var userLocation: CLLocation? - private var lastRebuildLocation: CLLocation? + // MARK: - Dependencies - // MARK: - Path State + private weak var traceViewModel: TracePathViewModel? + private var userLocation: CLLocation? + private var lastRebuildLocation: CLLocation? - struct RepeaterPathInfo { - let inPath: Bool - let hopIndex: Int? - let isLastHop: Bool - } + // MARK: - Path State - /// Pre-computed path membership for all repeaters, keyed by repeater ID. - /// Stored to avoid reallocation on every body eval. Rebuilt via `rebuildPathState()`. - private(set) var pathState: [UUID: RepeaterPathInfo] = [:] + struct RepeaterPathInfo { + let inPath: Bool + let hopIndex: Int? + let isLastHop: Bool + } - // MARK: - Computed Properties + /// Pre-computed path membership for all repeaters, keyed by repeater ID. + /// Stored to avoid reallocation on every body eval. Rebuilt via `rebuildPathState()`. + private(set) var pathState: [UUID: RepeaterPathInfo] = [:] - /// Repeaters and rooms to display on map - var repeatersWithLocation: [ContactDTO] { - traceViewModel?.availableNodes.filter { $0.hasLocation } ?? [] - } + // MARK: - Computed Properties - /// Whether a path has been built (at least one hop) - var hasPath: Bool { - !(traceViewModel?.outboundPath.isEmpty ?? true) - } + /// Repeaters and rooms to display on map + var repeatersWithLocation: [ContactDTO] { + traceViewModel?.availableNodes.filter(\.hasLocation) ?? [] + } - /// Whether trace can be run (when connected) - var canRunTrace: Bool { - traceViewModel?.canRunTraceWhenConnected ?? false - } + /// Whether a path has been built (at least one hop) + var hasPath: Bool { + !(traceViewModel?.outboundPath.isEmpty ?? true) + } - /// Whether trace is currently running - var isRunning: Bool { - traceViewModel?.isRunning ?? false - } + /// Whether trace can be run (when connected) + var canRunTrace: Bool { + traceViewModel?.canRunTraceWhenConnected ?? false + } - /// Whether a successful result exists that can be saved - var canSave: Bool { - traceViewModel?.canSavePath ?? false - } + /// Whether trace is currently running + var isRunning: Bool { + traceViewModel?.isRunning ?? false + } - /// Current trace result - var result: TraceResult? { - traceViewModel?.result - } + /// Whether a successful result exists that can be saved + var canSave: Bool { + traceViewModel?.canSavePath ?? false + } - // MARK: - Configuration + /// Current trace result + var result: TraceResult? { + traceViewModel?.result + } - func configure(traceViewModel: TracePathViewModel, userLocation: CLLocation?) { - self.traceViewModel = traceViewModel - self.userLocation = userLocation - } + // MARK: - Configuration - func updateUserLocation(_ location: CLLocation?) { - self.userLocation = location + func configure(traceViewModel: TracePathViewModel, userLocation: CLLocation?) { + self.traceViewModel = traceViewModel + self.userLocation = userLocation + } - // Only rebuild if the path is non-empty and user moved meaningfully - guard traceViewModel?.outboundPath.isEmpty == false else { return } - if let location, let last = lastRebuildLocation, location.distance(from: last) < 10 { return } - lastRebuildLocation = location - rebuildOverlays() - } + func updateUserLocation(_ location: CLLocation?) { + userLocation = location - // MARK: - Path State Rebuild + // Only rebuild if the path is non-empty and user moved meaningfully + guard traceViewModel?.outboundPath.isEmpty == false else { return } + if let location, let last = lastRebuildLocation, location.distance(from: last) < 10 { return } + lastRebuildLocation = location + rebuildOverlays() + } - /// Rebuilds stored `pathState` and `mapPoints`. Call when path, available nodes, or user location changes. - func rebuildPathState() { - let repeaters = repeatersWithLocation + // MARK: - Path State Rebuild - var pathLookup: [UUID: (index: Int, isLast: Bool)] = [:] - if let path = traceViewModel?.outboundPath { - for (index, hop) in path.enumerated() { - if let repeater = findRepeater(for: hop) { - pathLookup[repeater.id] = (index: index + 1, isLast: index == path.count - 1) - } - } - } + /// Rebuilds stored `pathState` and `mapPoints`. Call when path, available nodes, or user location changes. + func rebuildPathState() { + let repeaters = repeatersWithLocation - var state: [UUID: RepeaterPathInfo] = [:] - state.reserveCapacity(repeaters.count) - for repeater in repeaters { - if let info = pathLookup[repeater.id] { - state[repeater.id] = RepeaterPathInfo(inPath: true, hopIndex: info.index, isLastHop: info.isLast) - } else { - state[repeater.id] = RepeaterPathInfo(inPath: false, hopIndex: nil, isLastHop: false) - } + var pathLookup: [UUID: (index: Int, isLast: Bool)] = [:] + if let path = traceViewModel?.outboundPath { + for (index, hop) in path.enumerated() { + if let repeater = findRepeater(for: hop) { + pathLookup[repeater.id] = (index: index + 1, isLast: index == path.count - 1) } - pathState = state - rebuildMapPoints(repeaters: repeaters) + } } - private func rebuildMapPoints(repeaters: [ContactDTO]? = nil) { - let nodes = repeaters ?? repeatersWithLocation - var points: [MapPoint] = [] - for repeater in nodes { - let info = pathState[repeater.id] - let inPath = info?.inPath ?? false - points.append(MapPoint( - id: repeater.id, - coordinate: repeater.coordinate, - pinStyle: inPath ? .repeaterRingWhite : .repeater, - label: showLabels ? repeater.displayName : nil, - isClusterable: false, - hopIndex: info?.hopIndex, - badgeText: nil - )) - } - points.append(contentsOf: badgePoints) - mapPoints = points + var state: [UUID: RepeaterPathInfo] = [:] + state.reserveCapacity(repeaters.count) + for repeater in repeaters { + if let info = pathLookup[repeater.id] { + state[repeater.id] = RepeaterPathInfo(inPath: true, hopIndex: info.index, isLastHop: info.isLast) + } else { + state[repeater.id] = RepeaterPathInfo(inPath: false, hopIndex: nil, isLastHop: false) + } } - - // MARK: - Path Building - - /// Find the repeater or room for a hop using full public key or RepeaterResolver fallback. - private func findRepeater(for hop: PathHop) -> ContactDTO? { - RepeaterResolver.bestMatch(for: hop, in: traceViewModel?.availableNodes ?? [], userLocation: userLocation) + pathState = state + rebuildMapPoints(repeaters: repeaters) + } + + private func rebuildMapPoints(repeaters: [ContactDTO]? = nil) { + let nodes = repeaters ?? repeatersWithLocation + var points: [MapPoint] = [] + for repeater in nodes { + let info = pathState[repeater.id] + let inPath = info?.inPath ?? false + points.append(MapPoint( + id: repeater.id, + coordinate: repeater.coordinate, + pinStyle: inPath ? .repeaterRingWhite : .repeater, + label: showLabels ? repeater.displayName : nil, + isClusterable: false, + hopIndex: info?.hopIndex, + badgeText: nil + )) } - - enum RepeaterTapResult { - case added - case removed - case rejectedMiddleHop - case ignored + points.append(contentsOf: badgePoints) + mapPoints = points + } + + // MARK: - Path Building + + /// Find the repeater or room for a hop using full public key or RepeaterResolver fallback. + private func findRepeater(for hop: PathHop) -> ContactDTO? { + RepeaterResolver.bestMatch(for: hop, in: traceViewModel?.availableNodes ?? [], userLocation: userLocation) + } + + enum RepeaterTapResult { + case added + case removed + case rejectedMiddleHop + case ignored + } + + /// Handle tap on a repeater, returns the result of the tap action + @discardableResult + func handleRepeaterTap(_ repeater: ContactDTO) -> RepeaterTapResult { + guard let traceViewModel else { return .ignored } + + let info = pathState[repeater.id] + let result: RepeaterTapResult + if info?.isLastHop == true { + if let lastIndex = traceViewModel.outboundPath.indices.last { + traceViewModel.removeRepeater(at: lastIndex) + } + result = .removed + } else if info?.inPath != true { + traceViewModel.addNode(repeater) + result = .added + } else { + result = .rejectedMiddleHop } - /// Handle tap on a repeater, returns the result of the tap action - @discardableResult - func handleRepeaterTap(_ repeater: ContactDTO) -> RepeaterTapResult { - guard let traceViewModel else { return .ignored } - - let info = pathState[repeater.id] - let result: RepeaterTapResult - if info?.isLastHop == true { - if let lastIndex = traceViewModel.outboundPath.indices.last { - traceViewModel.removeRepeater(at: lastIndex) - } - result = .removed - } else if info?.inPath != true { - traceViewModel.addNode(repeater) - result = .added - } else { - result = .rejectedMiddleHop - } - - rebuildOverlays() - return result - } + rebuildOverlays() + return result + } - /// Clear the path - func clearPath() { - traceViewModel?.clearPath() - clearOverlays() - rebuildPathState() - } + /// Clear the path + func clearPath() { + traceViewModel?.clearPath() + clearOverlays() + rebuildPathState() + } - // MARK: - Trace Execution + // MARK: - Trace Execution - func runTrace() async { - centerOnPath() - traceViewModel?.batchEnabled = false - await traceViewModel?.runTrace() - } + func runTrace() async { + centerOnPath() + traceViewModel?.batchEnabled = false + await traceViewModel?.runTrace() + } - func savePath(name: String) async -> Bool { - await traceViewModel?.savePath(name: name) ?? false - } + func savePath(name: String) async -> Bool { + await traceViewModel?.savePath(name: name) ?? false + } - func generatePathName() -> String { - traceViewModel?.generatePathName() ?? L10n.Contacts.Contacts.Trace.Map.defaultPathName - } + func generatePathName() -> String { + traceViewModel?.generatePathName() ?? L10n.Contacts.Contacts.Trace.Map.defaultPathName + } - // MARK: - Overlay Management + // MARK: - Overlay Management - /// Rebuild map lines based on current path - func rebuildOverlays() { - clearOverlays() - rebuildPathState() + /// Rebuild map lines based on current path + func rebuildOverlays() { + clearOverlays() + rebuildPathState() - guard let traceViewModel, - !traceViewModel.outboundPath.isEmpty else { return } + guard let traceViewModel, + !traceViewModel.outboundPath.isEmpty else { return } - var previousCoordinate: CLLocationCoordinate2D? - if let userLocation { - previousCoordinate = userLocation.coordinate - } + var previousCoordinate: CLLocationCoordinate2D? + if let userLocation { + previousCoordinate = userLocation.coordinate + } - for (index, hop) in traceViewModel.outboundPath.enumerated() { - guard let repeater = findRepeater(for: hop), - repeater.hasLocation else { continue } + for (index, hop) in traceViewModel.outboundPath.enumerated() { + guard let repeater = findRepeater(for: hop), + repeater.hasLocation else { continue } - let hopCoordinate = CLLocationCoordinate2D( - latitude: repeater.latitude, - longitude: repeater.longitude - ) + let hopCoordinate = CLLocationCoordinate2D( + latitude: repeater.latitude, + longitude: repeater.longitude + ) - guard CLLocationCoordinate2DIsValid(hopCoordinate) else { continue } + guard CLLocationCoordinate2DIsValid(hopCoordinate) else { continue } - if let prevCoord = previousCoordinate, CLLocationCoordinate2DIsValid(prevCoord) { - mapLines.append(MapLine( - id: "trace-\(index)", - coordinates: [prevCoord, hopCoordinate], - style: .traceUntraced, - opacity: 1.0, - pathIndex: index - )) - } + if let prevCoord = previousCoordinate, CLLocationCoordinate2DIsValid(prevCoord) { + mapLines.append(MapLine( + id: "trace-\(index)", + coordinates: [prevCoord, hopCoordinate], + style: .traceUntraced, + opacity: 1.0, + pathIndex: index + )) + } - previousCoordinate = hopCoordinate - } + previousCoordinate = hopCoordinate } - - /// Update lines with trace results and add badge points at segment midpoints - func updateOverlaysWithResults() { - guard let result = traceViewModel?.result, result.success else { return } - - badgePoints.removeAll() - - var updatedLines: [MapLine] = [] - for line in mapLines { - guard let pathIndex = line.pathIndex else { - updatedLines.append(line) - continue - } - let hopIndex = pathIndex + 1 - if hopIndex < result.hops.count { - let hop = result.hops[hopIndex] - let style = MapLine.LineStyle.forSNR(hop.snr) - - updatedLines.append(MapLine( - id: line.id, - coordinates: line.coordinates, - style: style, - opacity: 1.0, - pathIndex: pathIndex - )) - - // Badge at midpoint - if line.coordinates.count >= 2 { - badgePoints.append(MapLine.snrBadge( - id: UUID(hopIndex: hopIndex), - from: line.coordinates[0], - to: line.coordinates[1], - snr: hop.snr - )) - } - } else { - updatedLines.append(line) - } + } + + /// Update lines with trace results and add badge points at segment midpoints + func updateOverlaysWithResults() { + guard let result = traceViewModel?.result, result.success else { return } + + badgePoints.removeAll() + + var updatedLines: [MapLine] = [] + for line in mapLines { + guard let pathIndex = line.pathIndex else { + updatedLines.append(line) + continue + } + let hopIndex = pathIndex + 1 + if hopIndex < result.hops.count { + let hop = result.hops[hopIndex] + let style = MapLine.LineStyle.forSNR(hop.snr) + + updatedLines.append(MapLine( + id: line.id, + coordinates: line.coordinates, + style: style, + opacity: 1.0, + pathIndex: pathIndex + )) + + // Badge at midpoint + if line.coordinates.count >= 2 { + badgePoints.append(MapLine.snrBadge( + id: UUID(hopIndex: hopIndex), + from: line.coordinates[0], + to: line.coordinates[1], + snr: hop.snr + )) } - - mapLines = updatedLines - rebuildMapPoints() + } else { + updatedLines.append(line) + } } - /// Clear all overlays - func clearOverlays() { - mapLines.removeAll() - badgePoints.removeAll() - } + mapLines = updatedLines + rebuildMapPoints() + } - // MARK: - Camera + /// Clear all overlays + func clearOverlays() { + mapLines.removeAll() + badgePoints.removeAll() + } - /// Center map on all path points - func centerOnPath() { - var coordinates: [CLLocationCoordinate2D] = [] + // MARK: - Camera - if let userLocation { - coordinates.append(userLocation.coordinate) - } + /// Center map on all path points + func centerOnPath() { + var coordinates: [CLLocationCoordinate2D] = [] - for line in mapLines { - coordinates.append(contentsOf: line.coordinates) - } + if let userLocation { + coordinates.append(userLocation.coordinate) + } - setCameraRegion(fitting: coordinates) + for line in mapLines { + coordinates.append(contentsOf: line.coordinates) } - /// Center map to show all repeaters - func centerOnAllRepeaters() { - let repeaters = repeatersWithLocation - guard !repeaters.isEmpty else { - cameraRegion = nil - return - } + setCameraRegion(fitting: coordinates) + } - let coordinates = repeaters.map(\.coordinate) - setCameraRegion(fitting: coordinates) - hasInitiallyCenteredOnRepeaters = true + /// Center map to show all repeaters + func centerOnAllRepeaters() { + let repeaters = repeatersWithLocation + guard !repeaters.isEmpty else { + cameraRegion = nil + return } - /// Perform initial centering based on current state - /// Centers on path if one exists, otherwise centers on all repeaters - func performInitialCentering() { - if hasPath { - centerOnPathRepeaters() - } else { - centerOnAllRepeaters() - } + let coordinates = repeaters.map(\.coordinate) + setCameraRegion(fitting: coordinates) + hasInitiallyCenteredOnRepeaters = true + } + + /// Perform initial centering based on current state + /// Centers on path if one exists, otherwise centers on all repeaters + func performInitialCentering() { + if hasPath { + centerOnPathRepeaters() + } else { + centerOnAllRepeaters() } + } - /// Center map on path repeaters directly (doesn't depend on overlays) - private func centerOnPathRepeaters() { - guard let traceViewModel else { - centerOnAllRepeaters() - return - } - - var coordinates: [CLLocationCoordinate2D] = [] - - if let userLocation { - coordinates.append(userLocation.coordinate) - } - - for hop in traceViewModel.outboundPath { - guard let repeater = findRepeater(for: hop), - repeater.hasLocation else { - continue - } - - let coord = CLLocationCoordinate2D( - latitude: repeater.latitude, - longitude: repeater.longitude - ) - if CLLocationCoordinate2DIsValid(coord) { - coordinates.append(coord) - } - } + /// Center map on path repeaters directly (doesn't depend on overlays) + private func centerOnPathRepeaters() { + guard let traceViewModel else { + centerOnAllRepeaters() + return + } - guard !coordinates.isEmpty else { - centerOnAllRepeaters() - return - } + var coordinates: [CLLocationCoordinate2D] = [] - setCameraRegion(fitting: coordinates) - hasInitiallyCenteredOnRepeaters = true + if let userLocation { + coordinates.append(userLocation.coordinate) } - func setCameraRegion(_ region: MKCoordinateRegion) { - cameraRegion = region - cameraRegionVersion += 1 + for hop in traceViewModel.outboundPath { + guard let repeater = findRepeater(for: hop), + repeater.hasLocation else { + continue + } + + let coord = CLLocationCoordinate2D( + latitude: repeater.latitude, + longitude: repeater.longitude + ) + if CLLocationCoordinate2DIsValid(coord) { + coordinates.append(coord) + } } - private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { - guard let region = coordinates.boundingRegion() else { return } - setCameraRegion(region) + guard !coordinates.isEmpty else { + centerOnAllRepeaters() + return } + + setCameraRegion(fitting: coordinates) + hasInitiallyCenteredOnRepeaters = true + } + + func setCameraRegion(_ region: MKCoordinateRegion) { + cameraRegion = region + cameraRegionVersion += 1 + } + + private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { + guard let region = coordinates.boundingRegion() else { return } + setCameraRegion(region) + } } private extension UUID { - /// Deterministic UUID for badge points keyed by hop index. - init(hopIndex: Int) { - let hex = String(hopIndex, radix: 16) - let padded = String(repeating: "0", count: max(0, 12 - hex.count)) + hex - self = UUID(uuidString: "00000000-0000-0000-0000-\(padded)") ?? UUID() - } + /// Deterministic UUID for badge points keyed by hop index. + init(hopIndex: Int) { + let hex = String(hopIndex, radix: 16) + let padded = String(repeating: "0", count: max(0, 12 - hex.count)) + hex + self = UUID(uuidString: "00000000-0000-0000-0000-\(padded)") ?? UUID() + } } diff --git a/MC1/Views/Tools/TracePath/MiniSparkline.swift b/MC1/Views/Tools/TracePath/MiniSparkline.swift index 34d423a8..97cc426f 100644 --- a/MC1/Views/Tools/TracePath/MiniSparkline.swift +++ b/MC1/Views/Tools/TracePath/MiniSparkline.swift @@ -1,35 +1,35 @@ import SwiftUI struct MiniSparkline: View { - let values: [Int] + let values: [Int] - var body: some View { - GeometryReader { geometry in - if let minVal = values.min(), let maxVal = values.max(), maxVal > minVal { - Path { path in - let range = Double(maxVal - minVal) - let stepX = geometry.size.width / Double(max(values.count - 1, 1)) + var body: some View { + GeometryReader { geometry in + if let minVal = values.min(), let maxVal = values.max(), maxVal > minVal { + Path { path in + let range = Double(maxVal - minVal) + let stepX = geometry.size.width / Double(max(values.count - 1, 1)) - for (index, value) in values.enumerated() { - let x = Double(index) * stepX - let y = geometry.size.height - (Double(value - minVal) / range * geometry.size.height) + for (index, value) in values.enumerated() { + let x = Double(index) * stepX + let y = geometry.size.height - (Double(value - minVal) / range * geometry.size.height) - if index == 0 { - path.move(to: CGPoint(x: x, y: y)) - } else { - path.addLine(to: CGPoint(x: x, y: y)) - } - } - } - .stroke(Color.accentColor, lineWidth: 1.5) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) } else { - // Flat line for constant values - Path { path in - path.move(to: CGPoint(x: 0, y: geometry.size.height / 2)) - path.addLine(to: CGPoint(x: geometry.size.width, y: geometry.size.height / 2)) - } - .stroke(Color.accentColor, lineWidth: 1.5) + path.addLine(to: CGPoint(x: x, y: y)) } + } } + .stroke(Color.accentColor, lineWidth: 1.5) + } else { + // Flat line for constant values + Path { path in + path.move(to: CGPoint(x: 0, y: geometry.size.height / 2)) + path.addLine(to: CGPoint(x: geometry.size.width, y: geometry.size.height / 2)) + } + .stroke(Color.accentColor, lineWidth: 1.5) + } } + } } diff --git a/MC1/Views/Tools/TracePath/SavePathRowView.swift b/MC1/Views/Tools/TracePath/SavePathRowView.swift index 52590a3a..c5a0d91b 100644 --- a/MC1/Views/Tools/TracePath/SavePathRowView.swift +++ b/MC1/Views/Tools/TracePath/SavePathRowView.swift @@ -1,58 +1,58 @@ -import SwiftUI import MC1Services +import SwiftUI /// Row allowing the user to save the current trace path struct SavePathRowView: View { - @Bindable var viewModel: TracePathViewModel - @Binding var saveHapticTrigger: Int + @Bindable var viewModel: TracePathViewModel + @Binding var saveHapticTrigger: Int - @State private var showingSaveDialog = false - @State private var savePathName = "" + @State private var showingSaveDialog = false + @State private var savePathName = "" - var body: some View { - if showingSaveDialog { - VStack(alignment: .leading, spacing: 8) { - TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $savePathName) - .textFieldStyle(.roundedBorder) + var body: some View { + if showingSaveDialog { + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $savePathName) + .textFieldStyle(.roundedBorder) - HStack { - Button(L10n.Contacts.Contacts.Common.cancel) { - showingSaveDialog = false - savePathName = "" - } - .buttonStyle(.bordered) + HStack { + Button(L10n.Contacts.Contacts.Common.cancel) { + showingSaveDialog = false + savePathName = "" + } + .buttonStyle(.bordered) - Spacer() + Spacer() - Button(L10n.Contacts.Contacts.Common.save) { - Task { - let success = await viewModel.savePath(name: savePathName) - if success { - saveHapticTrigger += 1 - } - showingSaveDialog = false - savePathName = "" - } - } - .buttonStyle(.borderedProminent) - .disabled(savePathName.trimmingCharacters(in: .whitespaces).isEmpty || !viewModel.canSavePath) - } + Button(L10n.Contacts.Contacts.Common.save) { + Task { + let success = await viewModel.savePath(name: savePathName) + if success { + saveHapticTrigger += 1 + } + showingSaveDialog = false + savePathName = "" } - .padding(.vertical, 4) - } else { - Button { - savePathName = viewModel.generatePathName() - showingSaveDialog = true - } label: { - HStack { - Label(L10n.Contacts.Contacts.Results.savePath, systemImage: "bookmark") - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(.secondary) - } - } - .foregroundStyle(.primary) - .disabled(!viewModel.canSavePath) + } + .buttonStyle(.borderedProminent) + .disabled(savePathName.trimmingCharacters(in: .whitespaces).isEmpty || !viewModel.canSavePath) + } + } + .padding(.vertical, 4) + } else { + Button { + savePathName = viewModel.generatePathName() + showingSaveDialog = true + } label: { + HStack { + Label(L10n.Contacts.Contacts.Results.savePath, systemImage: "bookmark") + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) } + } + .foregroundStyle(.primary) + .disabled(!viewModel.canSavePath) } + } } diff --git a/MC1/Views/Tools/TracePath/SavedPathDetailView.swift b/MC1/Views/Tools/TracePath/SavedPathDetailView.swift index abd5066d..91d13675 100644 --- a/MC1/Views/Tools/TracePath/SavedPathDetailView.swift +++ b/MC1/Views/Tools/TracePath/SavedPathDetailView.swift @@ -1,193 +1,193 @@ -import SwiftUI import Charts import MC1Services +import SwiftUI struct SavedPathDetailView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @State private var viewModel: SavedPathDetailViewModel - - init(savedPath: SavedTracePathDTO) { - _viewModel = State(initialValue: SavedPathDetailViewModel(savedPath: savedPath)) + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @State private var viewModel: SavedPathDetailViewModel + + init(savedPath: SavedTracePathDTO) { + _viewModel = State(initialValue: SavedPathDetailViewModel(savedPath: savedPath)) + } + + var body: some View { + List { + pathSection + .themedRowBackground(theme) + performanceSection + .themedRowBackground(theme) + historySection + .themedRowBackground(theme) } - - var body: some View { - List { - pathSection - .themedRowBackground(theme) - performanceSection - .themedRowBackground(theme) - historySection - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationDestination(for: TracePathRoute.RunDetail.self) { route in - RunDetailView(run: route.run) - } - .navigationTitle(viewModel.savedPath.name) - .navigationBarTitleDisplayMode(.inline) - .task { - viewModel.configure(dataStore: { appState.services?.dataStore }) - } - .refreshable { - await viewModel.refresh() - } + .themedCanvas(theme) + .navigationDestination(for: TracePathRoute.RunDetail.self) { route in + RunDetailView(run: route.run) + } + .navigationTitle(viewModel.savedPath.name) + .navigationBarTitleDisplayMode(.inline) + .task { + viewModel.configure(dataStore: { appState.services?.dataStore }) } + .refreshable { + await viewModel.refresh() + } + } - // MARK: - Path Section + // MARK: - Path Section - private var pathSection: some View { - Section(L10n.Contacts.Contacts.PathDetail.path) { - PathChipsView(pathData: viewModel.savedPath.pathBytes, hashSize: viewModel.hashSize) + private var pathSection: some View { + Section(L10n.Contacts.Contacts.PathDetail.path) { + PathChipsView(pathData: viewModel.savedPath.pathBytes, hashSize: viewModel.hashSize) + } + } + + // MARK: - Performance Section + + private var performanceSection: some View { + Section(L10n.Contacts.Contacts.PathDetail.performance) { + // Chart + if viewModel.successfulRuns.count >= 2 { + Chart(viewModel.successfulRuns) { run in + LineMark( + x: .value("Date", run.date), + y: .value("RTT", run.roundTripMs) + ) + .foregroundStyle(.blue) + + PointMark( + x: .value("Date", run.date), + y: .value("RTT", run.roundTripMs) + ) + .foregroundStyle(.blue) } + .frame(height: 150) + .chartYAxisLabel(L10n.Contacts.Contacts.PathDetail.roundTripMs) + } + + // Summary stats + HStack { + StatView(label: L10n.Contacts.Contacts.PathDetail.avg, value: viewModel.averageRoundTrip.map { "\($0) ms" } ?? "-") + Divider() + StatView(label: L10n.Contacts.Contacts.PathDetail.best, value: viewModel.bestRoundTrip.map { "\($0) ms" } ?? "-") + Divider() + StatView(label: L10n.Contacts.Contacts.PathDetail.success, value: viewModel.successRateText) + } + .frame(height: 50) } - - // MARK: - Performance Section - - private var performanceSection: some View { - Section(L10n.Contacts.Contacts.PathDetail.performance) { - // Chart - if viewModel.successfulRuns.count >= 2 { - Chart(viewModel.successfulRuns) { run in - LineMark( - x: .value("Date", run.date), - y: .value("RTT", run.roundTripMs) - ) - .foregroundStyle(.blue) - - PointMark( - x: .value("Date", run.date), - y: .value("RTT", run.roundTripMs) - ) - .foregroundStyle(.blue) - } - .frame(height: 150) - .chartYAxisLabel(L10n.Contacts.Contacts.PathDetail.roundTripMs) + } + + // MARK: - History Section + + private var historySection: some View { + Section(L10n.Contacts.Contacts.PathDetail.history) { + ForEach(viewModel.sortedRuns) { run in + NavigationLink(value: TracePathRoute.RunDetail(run: run)) { + HStack { + VStack(alignment: .leading) { + Text(run.date.formatted(date: .abbreviated, time: .shortened)) + if run.success { + Text("\(run.roundTripMs) ms") + .font(.caption) + .foregroundStyle(.secondary) + } } - // Summary stats - HStack { - StatView(label: L10n.Contacts.Contacts.PathDetail.avg, value: viewModel.averageRoundTrip.map { "\($0) ms" } ?? "-") - Divider() - StatView(label: L10n.Contacts.Contacts.PathDetail.best, value: viewModel.bestRoundTrip.map { "\($0) ms" } ?? "-") - Divider() - StatView(label: L10n.Contacts.Contacts.PathDetail.success, value: viewModel.successRateText) - } - .frame(height: 50) - } - } + Spacer() - // MARK: - History Section - - private var historySection: some View { - Section(L10n.Contacts.Contacts.PathDetail.history) { - ForEach(viewModel.sortedRuns) { run in - NavigationLink(value: TracePathRoute.RunDetail(run: run)) { - HStack { - VStack(alignment: .leading) { - Text(run.date.formatted(date: .abbreviated, time: .shortened)) - if run.success { - Text("\(run.roundTripMs) ms") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - if !run.success { - Text(L10n.Contacts.Contacts.PathDetail.failed) - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.red.opacity(0.2), in: .capsule) - .foregroundStyle(.red) - } - } - } + if !run.success { + Text(L10n.Contacts.Contacts.PathDetail.failed) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.red.opacity(0.2), in: .capsule) + .foregroundStyle(.red) } + } } + } } + } } // MARK: - Supporting Views private struct PathChipsView: View { - let pathData: Data - let hashSize: Int + let pathData: Data + let hashSize: Int - private var hopHexStrings: [String] { - stride(from: 0, to: pathData.count, by: hashSize).map { start in - let end = min(start + hashSize, pathData.count) - return pathData[start.. 0 { - Image(systemName: "arrow.right") - .font(.caption2) - .foregroundStyle(.secondary) - } - - Text(hex) - .font(.caption.monospaced()) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.fill.tertiary, in: .capsule) - } - } + } + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 4) { + ForEach(Array(hopHexStrings.enumerated()), id: \.offset) { index, hex in + if index > 0 { + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Text(hex) + .font(.caption.monospaced()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.fill.tertiary, in: .capsule) } - .scrollIndicators(.hidden) + } } + .scrollIndicators(.hidden) + } } private struct StatView: View { - let label: String - let value: String - - var body: some View { - VStack { - Text(value) - .font(.headline.monospacedDigit()) - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) + let label: String + let value: String + + var body: some View { + VStack { + Text(value) + .font(.headline.monospacedDigit()) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity) + } } private struct RunDetailView: View { - @Environment(\.appTheme) private var theme - let run: TracePathRunDTO - - var body: some View { - List { - Section(L10n.Contacts.Contacts.PathDetail.overview) { - LabeledContent(L10n.Contacts.Contacts.PathDetail.date, value: run.date.formatted()) - LabeledContent(L10n.Contacts.Contacts.PathDetail.roundTrip, value: "\(run.roundTripMs) ms") - LabeledContent(L10n.Contacts.Contacts.PathDetail.status, value: run.success ? L10n.Contacts.Contacts.PathDetail.success : L10n.Contacts.Contacts.PathDetail.failed) - } - .themedRowBackground(theme) - - if run.success && !run.hopsSNR.isEmpty { - Section(L10n.Contacts.Contacts.PathDetail.perHopSNR) { - ForEach(Array(run.hopsSNR.enumerated()), id: \.offset) { index, snr in - LabeledContent(L10n.Contacts.Contacts.PathDetail.hop(index + 1)) { - Text(snr, format: .number.precision(.fractionLength(2))) - + Text(" dB") - } - } - } - .themedRowBackground(theme) + @Environment(\.appTheme) private var theme + let run: TracePathRunDTO + + var body: some View { + List { + Section(L10n.Contacts.Contacts.PathDetail.overview) { + LabeledContent(L10n.Contacts.Contacts.PathDetail.date, value: run.date.formatted()) + LabeledContent(L10n.Contacts.Contacts.PathDetail.roundTrip, value: "\(run.roundTripMs) ms") + LabeledContent(L10n.Contacts.Contacts.PathDetail.status, value: run.success ? L10n.Contacts.Contacts.PathDetail.success : L10n.Contacts.Contacts.PathDetail.failed) + } + .themedRowBackground(theme) + + if run.success, !run.hopsSNR.isEmpty { + Section(L10n.Contacts.Contacts.PathDetail.perHopSNR) { + ForEach(Array(run.hopsSNR.enumerated()), id: \.offset) { index, snr in + LabeledContent(L10n.Contacts.Contacts.PathDetail.hop(index + 1)) { + Text(snr, format: .number.precision(.fractionLength(2))) + + Text(" dB") } + } } - .themedCanvas(theme) - .navigationTitle(L10n.Contacts.Contacts.PathDetail.runDetails) - .navigationBarTitleDisplayMode(.inline) + .themedRowBackground(theme) + } } + .themedCanvas(theme) + .navigationTitle(L10n.Contacts.Contacts.PathDetail.runDetails) + .navigationBarTitleDisplayMode(.inline) + } } diff --git a/MC1/Views/Tools/TracePath/SavedPathDetailViewModel.swift b/MC1/Views/Tools/TracePath/SavedPathDetailViewModel.swift index 40e5695f..d865551c 100644 --- a/MC1/Views/Tools/TracePath/SavedPathDetailViewModel.swift +++ b/MC1/Views/Tools/TracePath/SavedPathDetailViewModel.swift @@ -1,74 +1,73 @@ -import SwiftUI import MC1Services import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "SavedPathDetail") @Observable @MainActor final class SavedPathDetailViewModel { + // MARK: - State - // MARK: - State + var savedPath: SavedTracePathDTO + var isLoading = false - var savedPath: SavedTracePathDTO - var isLoading = false + // MARK: - Computed - // MARK: - Computed + var sortedRuns: [TracePathRunDTO] { + savedPath.runs.sorted { $0.date > $1.date } + } - var sortedRuns: [TracePathRunDTO] { - savedPath.runs.sorted { $0.date > $1.date } - } + var successfulRuns: [TracePathRunDTO] { + sortedRuns.filter(\.success) + } - var successfulRuns: [TracePathRunDTO] { - sortedRuns.filter { $0.success } - } + var bestRoundTrip: Int? { + successfulRuns.map(\.roundTripMs).min() + } - var bestRoundTrip: Int? { - successfulRuns.map(\.roundTripMs).min() - } + var averageRoundTrip: Int? { + savedPath.averageRoundTripMs + } - var averageRoundTrip: Int? { - savedPath.averageRoundTripMs - } + var successRateText: String { + "\(savedPath.successRate)%" + } - var successRateText: String { - "\(savedPath.successRate)%" - } + // MARK: - Initialization - // MARK: - Initialization + init(savedPath: SavedTracePathDTO) { + self.savedPath = savedPath + } - init(savedPath: SavedTracePathDTO) { - self.savedPath = savedPath - } + // MARK: - Dependencies - // MARK: - Dependencies + private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } - private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } + /// Hash size per hop from when the path was saved (1, 2, or 4 bytes) + var hashSize: Int { + savedPath.hashSize + } - /// Hash size per hop from when the path was saved (1, 2, or 4 bytes) - var hashSize: Int { - savedPath.hashSize - } - - /// The provider is read live at its point of use; a provider returning - /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. - func configure(dataStore: @escaping @MainActor () -> PersistenceStore?) { - dataStoreProvider = dataStore - } + /// The provider is read live at its point of use; a provider returning + /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. + func configure(dataStore: @escaping @MainActor () -> PersistenceStore?) { + dataStoreProvider = dataStore + } - // MARK: - Actions + // MARK: - Actions - func refresh() async { - guard let dataStore = dataStoreProvider() else { return } + func refresh() async { + guard let dataStore = dataStoreProvider() else { return } - isLoading = true - do { - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - savedPath = updated - } - } catch { - logger.error("Failed to refresh: \(error.localizedDescription)") - } - isLoading = false + isLoading = true + do { + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + savedPath = updated + } + } catch { + logger.error("Failed to refresh: \(error.localizedDescription)") } + isLoading = false + } } diff --git a/MC1/Views/Tools/TracePath/SavedPathRow.swift b/MC1/Views/Tools/TracePath/SavedPathRow.swift index 8a2f3f12..3ba28e36 100644 --- a/MC1/Views/Tools/TracePath/SavedPathRow.swift +++ b/MC1/Views/Tools/TracePath/SavedPathRow.swift @@ -1,86 +1,86 @@ -import SwiftUI import MC1Services +import SwiftUI struct SavedPathRow: View { - let path: SavedTracePathDTO + let path: SavedTracePathDTO - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(path.name) - .font(.body) + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(path.name) + .font(.body) - HStack(spacing: 8) { - // Run count and recency - Text(subtitleText) - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: 8) { + // Run count and recency + Text(subtitleText) + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer() - // Health indicator - healthDot + // Health indicator + healthDot - // Mini sparkline - if !path.recentRTTs.isEmpty { - MiniSparkline(values: path.recentRTTs) - .frame(width: 50, height: 16) - .accessibilityLabel(sparklineAccessibilityLabel) - } - } + // Mini sparkline + if !path.recentRTTs.isEmpty { + MiniSparkline(values: path.recentRTTs) + .frame(width: 50, height: 16) + .accessibilityLabel(sparklineAccessibilityLabel) } - .padding(.vertical, 4) + } } + .padding(.vertical, 4) + } - private var subtitleText: String { - var parts: [String] = [] + private var subtitleText: String { + var parts: [String] = [] - // Run count - let runText = path.runCount == 1 - ? L10n.Contacts.Contacts.SavedPaths.Runs.singular - : L10n.Contacts.Contacts.SavedPaths.Runs.plural(path.runCount) - parts.append(runText) - - // Last run - if let lastDate = path.lastRunDate { - parts.append(L10n.Contacts.Contacts.SavedPaths.lastRun(lastDate.relativeFormatted)) - } + // Run count + let runText = path.runCount == 1 + ? L10n.Contacts.Contacts.SavedPaths.Runs.singular + : L10n.Contacts.Contacts.SavedPaths.Runs.plural(path.runCount) + parts.append(runText) - return parts.joined(separator: " · ") + // Last run + if let lastDate = path.lastRunDate { + parts.append(L10n.Contacts.Contacts.SavedPaths.lastRun(lastDate.relativeFormatted)) } - @ViewBuilder - private var healthDot: some View { - let rate = path.successRate - let healthDescription = rate >= 90 - ? L10n.Contacts.Contacts.SavedPaths.Health.healthy - : rate >= 50 - ? L10n.Contacts.Contacts.SavedPaths.Health.degraded - : L10n.Contacts.Contacts.SavedPaths.Health.poor - Circle() - .fill(rate >= 90 ? .green : rate >= 50 ? .yellow : .red) - .frame(width: 8, height: 8) - .accessibilityLabel(L10n.Contacts.Contacts.SavedPaths.healthLabel(healthDescription, rate)) - } + return parts.joined(separator: " · ") + } - private var sparklineAccessibilityLabel: String { - let rtts = path.recentRTTs - guard !rtts.isEmpty else { return L10n.Contacts.Contacts.SavedPaths.noResponseData } + @ViewBuilder + private var healthDot: some View { + let rate = path.successRate + let healthDescription = rate >= 90 + ? L10n.Contacts.Contacts.SavedPaths.Health.healthy + : rate >= 50 + ? L10n.Contacts.Contacts.SavedPaths.Health.degraded + : L10n.Contacts.Contacts.SavedPaths.Health.poor + Circle() + .fill(rate >= 90 ? .green : rate >= 50 ? .yellow : .red) + .frame(width: 8, height: 8) + .accessibilityLabel(L10n.Contacts.Contacts.SavedPaths.healthLabel(healthDescription, rate)) + } - let avgRTT = rtts.reduce(0, +) / rtts.count - let trend: String - if rtts.count >= 2 { - let firstHalf = rtts.prefix(rtts.count / 2).reduce(0, +) / max(1, rtts.count / 2) - let secondHalf = rtts.suffix(rtts.count / 2).reduce(0, +) / max(1, rtts.count / 2) - if secondHalf > firstHalf + 50 { - trend = L10n.Contacts.Contacts.SavedPaths.Trend.increasing - } else if secondHalf < firstHalf - 50 { - trend = L10n.Contacts.Contacts.SavedPaths.Trend.decreasing - } else { - trend = L10n.Contacts.Contacts.SavedPaths.Trend.stable - } - } else { - trend = L10n.Contacts.Contacts.SavedPaths.Trend.stable - } - return L10n.Contacts.Contacts.SavedPaths.responseTimes(avgRTT, trend) + private var sparklineAccessibilityLabel: String { + let rtts = path.recentRTTs + guard !rtts.isEmpty else { return L10n.Contacts.Contacts.SavedPaths.noResponseData } + + let avgRTT = rtts.reduce(0, +) / rtts.count + let trend: String + if rtts.count >= 2 { + let firstHalf = rtts.prefix(rtts.count / 2).reduce(0, +) / max(1, rtts.count / 2) + let secondHalf = rtts.suffix(rtts.count / 2).reduce(0, +) / max(1, rtts.count / 2) + if secondHalf > firstHalf + 50 { + trend = L10n.Contacts.Contacts.SavedPaths.Trend.increasing + } else if secondHalf < firstHalf - 50 { + trend = L10n.Contacts.Contacts.SavedPaths.Trend.decreasing + } else { + trend = L10n.Contacts.Contacts.SavedPaths.Trend.stable + } + } else { + trend = L10n.Contacts.Contacts.SavedPaths.Trend.stable } + return L10n.Contacts.Contacts.SavedPaths.responseTimes(avgRTT, trend) + } } diff --git a/MC1/Views/Tools/TracePath/SavedPathsSheet.swift b/MC1/Views/Tools/TracePath/SavedPathsSheet.swift index 09d7d1ef..f475fac2 100644 --- a/MC1/Views/Tools/TracePath/SavedPathsSheet.swift +++ b/MC1/Views/Tools/TracePath/SavedPathsSheet.swift @@ -1,134 +1,134 @@ -import SwiftUI import MC1Services +import SwiftUI /// Sheet displaying saved trace paths for selection struct SavedPathsSheet: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss - @State private var viewModel = SavedPathsViewModel() + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss + @State private var viewModel = SavedPathsViewModel() - /// Callback when a path is selected - var onSelect: (SavedTracePathDTO) -> Void - /// Callback when a path is deleted - var onDelete: ((UUID) -> Void)? + /// Callback when a path is selected + var onSelect: (SavedTracePathDTO) -> Void + /// Callback when a path is deleted + var onDelete: ((UUID) -> Void)? - @State private var pathToDelete: SavedTracePathDTO? - @State private var pathToRename: SavedTracePathDTO? - @State private var renameText = "" + @State private var pathToDelete: SavedTracePathDTO? + @State private var pathToRename: SavedTracePathDTO? + @State private var renameText = "" - var body: some View { - NavigationStack { - Group { - if viewModel.savedPaths.isEmpty && !viewModel.isLoading { - emptyState - } else { - pathsList - } - } - .navigationTitle(L10n.Contacts.Contacts.SavedPaths.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Contacts.Contacts.Common.done) { dismiss() } - } - } - .task { - viewModel.configure( - dataStore: { appState.services?.dataStore }, - connectedDevice: { appState.connectedDevice } - ) - await viewModel.loadSavedPaths() - } - .errorAlert($viewModel.errorMessage) - .confirmationDialog( - L10n.Contacts.Contacts.SavedPaths.deleteTitle, - isPresented: .init( - get: { pathToDelete != nil }, - set: { if !$0 { pathToDelete = nil } } - ), - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { - if let path = pathToDelete { - let pathId = path.id - Task { - await viewModel.deletePath(path) - onDelete?(pathId) - } - } - } - } message: { - if let path = pathToDelete { - Text(L10n.Contacts.Contacts.SavedPaths.deleteMessage(path.name)) - } - } - .alert(L10n.Contacts.Contacts.SavedPaths.renameTitle, isPresented: .init( - get: { pathToRename != nil }, - set: { if !$0 { pathToRename = nil } } - )) { - TextField(L10n.Contacts.Contacts.Detail.name, text: $renameText) - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) { } - Button(L10n.Contacts.Contacts.Common.save) { - if let path = pathToRename { - Task { await viewModel.renamePath(path, to: renameText) } - } - } + var body: some View { + NavigationStack { + Group { + if viewModel.savedPaths.isEmpty, !viewModel.isLoading { + emptyState + } else { + pathsList + } + } + .navigationTitle(L10n.Contacts.Contacts.SavedPaths.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Common.done) { dismiss() } + } + } + .task { + viewModel.configure( + dataStore: { appState.services?.dataStore }, + connectedDevice: { appState.connectedDevice } + ) + await viewModel.loadSavedPaths() + } + .errorAlert($viewModel.errorMessage) + .confirmationDialog( + L10n.Contacts.Contacts.SavedPaths.deleteTitle, + isPresented: .init( + get: { pathToDelete != nil }, + set: { if !$0 { pathToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Common.delete, role: .destructive) { + if let path = pathToDelete { + let pathId = path.id + Task { + await viewModel.deletePath(path) + onDelete?(pathId) } + } + } + } message: { + if let path = pathToDelete { + Text(L10n.Contacts.Contacts.SavedPaths.deleteMessage(path.name)) + } + } + .alert(L10n.Contacts.Contacts.SavedPaths.renameTitle, isPresented: .init( + get: { pathToRename != nil }, + set: { if !$0 { pathToRename = nil } } + )) { + TextField(L10n.Contacts.Contacts.Detail.name, text: $renameText) + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + Button(L10n.Contacts.Contacts.Common.save) { + if let path = pathToRename { + Task { await viewModel.renamePath(path, to: renameText) } + } } - .presentationDetents([.medium, .large]) + } } + .presentationDetents([.medium, .large]) + } - // MARK: - Empty State + // MARK: - Empty State - private var emptyState: some View { - ContentUnavailableView { - Label(L10n.Contacts.Contacts.SavedPaths.Empty.title, systemImage: "bookmark") - } description: { - Text(L10n.Contacts.Contacts.SavedPaths.Empty.description) - } + private var emptyState: some View { + ContentUnavailableView { + Label(L10n.Contacts.Contacts.SavedPaths.Empty.title, systemImage: "bookmark") + } description: { + Text(L10n.Contacts.Contacts.SavedPaths.Empty.description) } + } - // MARK: - Paths List + // MARK: - Paths List - /// `SavedPathRow` has no avatar, so the row text and the inter-row divider share this inset. - private static let rowHorizontalPadding: CGFloat = 16 + /// `SavedPathRow` has no avatar, so the row text and the inter-row divider share this inset. + private static let rowHorizontalPadding: CGFloat = 16 - private var pathsList: some View { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(viewModel.savedPaths.enumerated()), id: \.element.id) { index, path in - Button { - onSelect(path) - dismiss() - } label: { - SavedPathRow(path: path) - .padding(.horizontal, Self.rowHorizontalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } - .buttonStyle(.plain) - .contextMenu { - Button(L10n.Contacts.Contacts.SavedPaths.rename, systemImage: "pencil") { - renameText = path.name - pathToRename = path - } - Button(L10n.Contacts.Contacts.Common.delete, systemImage: "trash", role: .destructive) { - pathToDelete = path - } - } - .transition(.opacity) - if index < viewModel.savedPaths.count - 1 { - Divider().padding(.leading, Self.rowHorizontalPadding) - } - } + private var pathsList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(viewModel.savedPaths.enumerated()), id: \.element.id) { index, path in + Button { + onSelect(path) + dismiss() + } label: { + SavedPathRow(path: path) + .padding(.horizontal, Self.rowHorizontalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .contextMenu { + Button(L10n.Contacts.Contacts.SavedPaths.rename, systemImage: "pencil") { + renameText = path.name + pathToRename = path } - } - .themedCanvas(theme) - .overlay { - if viewModel.isLoading { - ProgressView() + Button(L10n.Contacts.Contacts.Common.delete, systemImage: "trash", role: .destructive) { + pathToDelete = path } + } + .transition(.opacity) + if index < viewModel.savedPaths.count - 1 { + Divider().padding(.leading, Self.rowHorizontalPadding) + } } + } + } + .themedCanvas(theme) + .overlay { + if viewModel.isLoading { + ProgressView() + } } + } } diff --git a/MC1/Views/Tools/TracePath/SavedPathsViewModel.swift b/MC1/Views/Tools/TracePath/SavedPathsViewModel.swift index 688fa03d..4de6c59f 100644 --- a/MC1/Views/Tools/TracePath/SavedPathsViewModel.swift +++ b/MC1/Views/Tools/TracePath/SavedPathsViewModel.swift @@ -1,80 +1,79 @@ -import SwiftUI import MC1Services import os.log +import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "SavedPaths") @Observable @MainActor final class SavedPathsViewModel { + // MARK: - State - // MARK: - State + var savedPaths: [SavedTracePathDTO] = [] + var isLoading = false + var errorMessage: String? - var savedPaths: [SavedTracePathDTO] = [] - var isLoading = false - var errorMessage: String? + // MARK: - Dependencies - // MARK: - Dependencies + private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } + private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } - private var dataStoreProvider: @MainActor () -> PersistenceStore? = { nil } - private var connectedDeviceProvider: @MainActor () -> DeviceDTO? = { nil } + // MARK: - Configuration - // MARK: - Configuration - - /// Each provider is read live at its point of use; a provider returning - /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. - func configure( - dataStore: @escaping @MainActor () -> PersistenceStore?, - connectedDevice: @escaping @MainActor () -> DeviceDTO? - ) { - dataStoreProvider = dataStore - connectedDeviceProvider = connectedDevice - } + /// Each provider is read live at its point of use; a provider returning + /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. + func configure( + dataStore: @escaping @MainActor () -> PersistenceStore?, + connectedDevice: @escaping @MainActor () -> DeviceDTO? + ) { + dataStoreProvider = dataStore + connectedDeviceProvider = connectedDevice + } - // MARK: - Data Loading + // MARK: - Data Loading - func loadSavedPaths() async { - guard let radioID = connectedDeviceProvider()?.radioID, - let dataStore = dataStoreProvider() else { return } + func loadSavedPaths() async { + guard let radioID = connectedDeviceProvider()?.radioID, + let dataStore = dataStoreProvider() else { return } - isLoading = true - errorMessage = nil + isLoading = true + errorMessage = nil - do { - savedPaths = try await dataStore.fetchSavedTracePaths(radioID: radioID) - logger.info("Loaded \(self.savedPaths.count) saved paths") - } catch { - logger.error("Failed to load saved paths: \(error.localizedDescription)") - errorMessage = L10n.Tools.Tools.SavedPaths.loadFailed - } - - isLoading = false + do { + savedPaths = try await dataStore.fetchSavedTracePaths(radioID: radioID) + logger.info("Loaded \(savedPaths.count) saved paths") + } catch { + logger.error("Failed to load saved paths: \(error.localizedDescription)") + errorMessage = L10n.Tools.Tools.SavedPaths.loadFailed } - // MARK: - Actions + isLoading = false + } - func renamePath(_ path: SavedTracePathDTO, to newName: String) async { - guard let dataStore = dataStoreProvider() else { return } + // MARK: - Actions - do { - try await dataStore.updateSavedTracePathName(id: path.id, name: newName) - await loadSavedPaths() - } catch { - logger.error("Failed to rename path: \(error.localizedDescription)") - errorMessage = L10n.Tools.Tools.SavedPaths.renameFailed - } - } + func renamePath(_ path: SavedTracePathDTO, to newName: String) async { + guard let dataStore = dataStoreProvider() else { return } - func deletePath(_ path: SavedTracePathDTO) async { - guard let dataStore = dataStoreProvider() else { return } - - do { - try await dataStore.deleteSavedTracePath(id: path.id) - savedPaths.removeAll { $0.id == path.id } - logger.info("Deleted saved path: \(path.name)") - } catch { - logger.error("Failed to delete path: \(error.localizedDescription)") - errorMessage = L10n.Tools.Tools.SavedPaths.deleteFailed - } + do { + try await dataStore.updateSavedTracePathName(id: path.id, name: newName) + await loadSavedPaths() + } catch { + logger.error("Failed to rename path: \(error.localizedDescription)") + errorMessage = L10n.Tools.Tools.SavedPaths.renameFailed + } + } + + func deletePath(_ path: SavedTracePathDTO) async { + guard let dataStore = dataStoreProvider() else { return } + + do { + try await dataStore.deleteSavedTracePath(id: path.id) + savedPaths.removeAll { $0.id == path.id } + logger.info("Deleted saved path: \(path.name)") + } catch { + logger.error("Failed to delete path: \(error.localizedDescription)") + errorMessage = L10n.Tools.Tools.SavedPaths.deleteFailed } + } } diff --git a/MC1/Views/Tools/TracePath/TotalDistanceRow.swift b/MC1/Views/Tools/TracePath/TotalDistanceRow.swift index ddaf7719..dea2e5de 100644 --- a/MC1/Views/Tools/TracePath/TotalDistanceRow.swift +++ b/MC1/Views/Tools/TracePath/TotalDistanceRow.swift @@ -1,57 +1,57 @@ -import SwiftUI import MC1Services +import SwiftUI /// Row displaying total path distance with optional info sheet struct TotalDistanceRow: View { - @Bindable var viewModel: TracePathViewModel - let result: TraceResult - @Binding var showingDistanceInfo: Bool + @Bindable var viewModel: TracePathViewModel + let result: TraceResult + @Binding var showingDistanceInfo: Bool - var body: some View { - HStack { - Text(L10n.Contacts.Contacts.Results.totalDistance) - .foregroundStyle(.secondary) - Spacer() + var body: some View { + HStack { + Text(L10n.Contacts.Contacts.Results.totalDistance) + .foregroundStyle(.secondary) + Spacer() - if let distance = viewModel.totalPathDistance { - HStack { - Text(formatDistance(distance)) - .font(.body.monospacedDigit()) - if viewModel.isDistanceUsingFallback { - Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { - showingDistanceInfo = true - } - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.partialDistanceLabel) - .accessibilityHint(L10n.Contacts.Contacts.Results.partialDistanceHint) - } - } - } else { - HStack { - Text(L10n.Contacts.Contacts.Results.unavailable) - .foregroundStyle(.secondary) - Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { - showingDistanceInfo = true - } - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.distanceUnavailableLabel) - .accessibilityHint(L10n.Contacts.Contacts.Results.distanceInfoHint) - } + if let distance = viewModel.totalPathDistance { + HStack { + Text(formatDistance(distance)) + .font(.body.monospacedDigit()) + if viewModel.isDistanceUsingFallback { + Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { + showingDistanceInfo = true } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.partialDistanceLabel) + .accessibilityHint(L10n.Contacts.Contacts.Results.partialDistanceHint) + } } - .sheet(isPresented: $showingDistanceInfo) { - DistanceInfoSheetView(result: result, viewModel: viewModel, showingDistanceInfo: $showingDistanceInfo) - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) + } else { + HStack { + Text(L10n.Contacts.Contacts.Results.unavailable) + .foregroundStyle(.secondary) + Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { + showingDistanceInfo = true + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.distanceUnavailableLabel) + .accessibilityHint(L10n.Contacts.Contacts.Results.distanceInfoHint) } + } } - - private func formatDistance(_ meters: Double) -> String { - let measurement = Measurement(value: meters, unit: UnitLength.meters) - return measurement.formatted(.measurement(width: .abbreviated, usage: .road)) + .sheet(isPresented: $showingDistanceInfo) { + DistanceInfoSheetView(result: result, viewModel: viewModel, showingDistanceInfo: $showingDistanceInfo) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) } + } + + private func formatDistance(_ meters: Double) -> String { + let measurement = Measurement(value: meters, unit: UnitLength.meters) + return measurement.formatted(.measurement(width: .abbreviated, usage: .road)) + } } diff --git a/MC1/Views/Tools/TracePath/TraceHop.swift b/MC1/Views/Tools/TracePath/TraceHop.swift index 5705bc21..c1469003 100644 --- a/MC1/Views/Tools/TracePath/TraceHop.swift +++ b/MC1/Views/Tools/TracePath/TraceHop.swift @@ -3,27 +3,29 @@ import MC1Services /// Represents a single hop in a trace result struct TraceHop: Identifiable { - let id = UUID() - let hashBytes: Data? // nil for start/end node (local device) - let resolvedName: String? // From contacts lookup - let snr: Double - let isStartNode: Bool - let isEndNode: Bool - let latitude: Double? - let longitude: Double? + let id = UUID() + let hashBytes: Data? // nil for start/end node (local device) + let resolvedName: String? // From contacts lookup + let snr: Double + let isStartNode: Bool + let isEndNode: Bool + let latitude: Double? + let longitude: Double? - /// Display string for hash (shows all bytes) - var hashDisplayString: String? { - hashBytes?.map { $0.hexString }.joined() - } + /// Display string for hash (shows all bytes) + var hashDisplayString: String? { + hashBytes?.map(\.hexString).joined() + } - /// Whether this hop has a valid (non-zero) location. - /// Uses OR logic to match ContactDTO.hasLocation - if either coordinate is non-zero, - /// we have some location data. (0,0) is "Null Island" and extremely unlikely. - var hasLocation: Bool { - guard let lat = latitude, let lon = longitude else { return false } - return lat != 0 || lon != 0 - } + /// Whether this hop has a valid (non-zero) location. + /// Uses OR logic to match ContactDTO.hasLocation - if either coordinate is non-zero, + /// we have some location data. (0,0) is "Null Island" and extremely unlikely. + var hasLocation: Bool { + guard let lat = latitude, let lon = longitude else { return false } + return lat != 0 || lon != 0 + } - var snrQuality: SNRQuality { SNRQuality(snr: snr) } + var snrQuality: SNRQuality { + SNRQuality(snr: snr) + } } diff --git a/MC1/Views/Tools/TracePath/TracePathHopRow.swift b/MC1/Views/Tools/TracePath/TracePathHopRow.swift index da42611e..9a857173 100644 --- a/MC1/Views/Tools/TracePath/TracePathHopRow.swift +++ b/MC1/Views/Tools/TracePath/TracePathHopRow.swift @@ -1,24 +1,24 @@ import SwiftUI struct TracePathHopRow: View { - let hop: PathHop - let hopNumber: Int + let hop: PathHop + let hopNumber: Int - var body: some View { - VStack(alignment: .leading) { - if let name = hop.resolvedName { - Text(name) - Text(hop.hashHex) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } else { - Text(hop.hashHex) - .font(.body.monospaced()) - } - } - .frame(minHeight: 44) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.hopLabel(hopNumber, hop.resolvedName ?? hop.hashHex)) - .accessibilityHint(L10n.Contacts.Contacts.Trace.List.hopHint) + var body: some View { + VStack(alignment: .leading) { + if let name = hop.resolvedName { + Text(name) + Text(hop.hashHex) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } else { + Text(hop.hashHex) + .font(.body.monospaced()) + } } + .frame(minHeight: 44) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.hopLabel(hopNumber, hop.resolvedName ?? hop.hashHex)) + .accessibilityHint(L10n.Contacts.Contacts.Trace.List.hopHint) + } } diff --git a/MC1/Views/Tools/TracePath/TracePathListView.swift b/MC1/Views/Tools/TracePath/TracePathListView.swift index fde17bb5..1f94f81c 100644 --- a/MC1/Views/Tools/TracePath/TracePathListView.swift +++ b/MC1/Views/Tools/TracePath/TracePathListView.swift @@ -1,120 +1,120 @@ -import SwiftUI import MC1Services +import SwiftUI /// List-based view for building trace paths. Hops are added through the shared /// `AddHopPickerView`, pushed onto the Tools navigation stack via the Add Hop CTA. struct TracePathListView: View { - @Environment(\.appState) private var appState - @Environment(\.appTheme) private var theme - @Bindable var viewModel: TracePathViewModel + @Environment(\.appState) private var appState + @Environment(\.appTheme) private var theme + @Bindable var viewModel: TracePathViewModel - @Binding var dragHapticTrigger: Int - @Binding var copyHapticTrigger: Int - @Binding var showingClearConfirmation: Bool - @Binding var presentedResult: TraceResult? - @Binding var showJumpToPath: Bool + @Binding var dragHapticTrigger: Int + @Binding var copyHapticTrigger: Int + @Binding var showingClearConfirmation: Bool + @Binding var presentedResult: TraceResult? + @Binding var showJumpToPath: Bool - /// Drives the pushed Add Hop picker via `.navigationDestination(item:)`. - @State private var insertionIntent: AddHopIntent? + /// Drives the pushed Add Hop picker via `.navigationDestination(item:)`. + @State private var insertionIntent: AddHopIntent? - var body: some View { - List { - if viewModel.outboundPath.isEmpty { - emptyStateSection - } else { - outboundPathSection - .themedRowBackground(theme) - addHopCtaSection - } - PathActionsSectionView( - viewModel: viewModel, - showingClearConfirmation: $showingClearConfirmation, - copyHapticTrigger: $copyHapticTrigger - ) - RunTraceSectionView( - viewModel: viewModel, - showJumpToPath: $showJumpToPath - ) + var body: some View { + List { + if viewModel.outboundPath.isEmpty { + emptyStateSection + } else { + outboundPathSection + .themedRowBackground(theme) + addHopCtaSection + } + PathActionsSectionView( + viewModel: viewModel, + showingClearConfirmation: $showingClearConfirmation, + copyHapticTrigger: $copyHapticTrigger + ) + RunTraceSectionView( + viewModel: viewModel, + showJumpToPath: $showJumpToPath + ) - Color.clear - .frame(height: 1) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets()) - .id("bottom") - } - .themedCanvas(theme) - .environment(\.editMode, .constant(.active)) - .addHopPicker(for: $insertionIntent, source: viewModel, inDetailColumn: true) + Color.clear + .frame(height: 1) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + .id("bottom") } + .themedCanvas(theme) + .environment(\.editMode, .constant(.active)) + .addHopPicker(for: $insertionIntent, source: viewModel, inDetailColumn: true) + } - // MARK: - Empty state + // MARK: - Empty state - @ViewBuilder - private var emptyStateSection: some View { - Section { - ContentUnavailableView { - Label( - L10n.Contacts.Contacts.PathEdit.Empty.title, - systemImage: "antenna.radiowaves.left.and.right.slash" - ) - } description: { - Text(L10n.Contacts.Contacts.Trace.List.emptyPath) - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - } + @ViewBuilder + private var emptyStateSection: some View { + Section { + ContentUnavailableView { + Label( + L10n.Contacts.Contacts.PathEdit.Empty.title, + systemImage: "antenna.radiowaves.left.and.right.slash" + ) + } description: { + Text(L10n.Contacts.Contacts.Trace.List.emptyPath) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } - Section { - addHopButton - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } + Section { + addHopButton + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } + } - // MARK: - Add Hop CTA + // MARK: - Add Hop CTA - private var addHopCtaSection: some View { - Section { - addHopButton - .listRowInsets(PathEditMetrics.ctaRowInsets) - .listRowBackground(Color.clear) - } + private var addHopCtaSection: some View { + Section { + addHopButton + .listRowInsets(PathEditMetrics.ctaRowInsets) + .listRowBackground(Color.clear) } + } - private var addHopButton: some View { - Button { - insertionIntent = .append - } label: { - PathEditCTALabel( - title: L10n.Contacts.Contacts.PathEdit.addHop, - systemImage: "plus.circle.fill" - ) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) + private var addHopButton: some View { + Button { + insertionIntent = .append + } label: { + PathEditCTALabel( + title: L10n.Contacts.Contacts.PathEdit.addHop, + systemImage: "plus.circle.fill" + ) } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } - // MARK: - Outbound Path Section + // MARK: - Outbound Path Section - private var outboundPathSection: some View { - Section { - ForEach(Array(viewModel.outboundPath.enumerated()), id: \.element.id) { index, hop in - TracePathHopRow(hop: hop, hopNumber: index + 1) - } - .onMove { source, destination in - dragHapticTrigger += 1 - viewModel.moveRepeater(from: source, to: destination) - } - .onDelete { indexSet in - for index in indexSet.sorted().reversed() { - viewModel.removeRepeater(at: index) - } - } - } header: { - Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) + private var outboundPathSection: some View { + Section { + ForEach(Array(viewModel.outboundPath.enumerated()), id: \.element.id) { index, hop in + TracePathHopRow(hop: hop, hopNumber: index + 1) + } + .onMove { source, destination in + dragHapticTrigger += 1 + viewModel.moveRepeater(from: source, to: destination) + } + .onDelete { indexSet in + for index in indexSet.sorted().reversed() { + viewModel.removeRepeater(at: index) } + } + } header: { + Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) } + } } diff --git a/MC1/Views/Tools/TracePath/TracePathRoute.swift b/MC1/Views/Tools/TracePath/TracePathRoute.swift index 02123365..dc5b2af9 100644 --- a/MC1/Views/Tools/TracePath/TracePathRoute.swift +++ b/MC1/Views/Tools/TracePath/TracePathRoute.swift @@ -5,11 +5,11 @@ import MC1Services /// comparison row can drill into a saved path's history. Value-based so each push rebuilds the /// destination instead of reusing stale `@State` from a prior visit. enum TracePathRoute: Hashable { - case savedPathDetail(SavedTracePathDTO) + case savedPathDetail(SavedTracePathDTO) - /// Run drill-down push destination. `SavedPathDetailView` registers and pushes this itself - /// so the destination travels with the view regardless of which stack hosts it. - struct RunDetail: Hashable { - let run: TracePathRunDTO - } + /// Run drill-down push destination. `SavedPathDetailView` registers and pushes this itself + /// so the destination travels with the view regardless of which stack hosts it. + struct RunDetail: Hashable { + let run: TracePathRunDTO + } } diff --git a/MC1/Views/Tools/TracePath/TracePathView.swift b/MC1/Views/Tools/TracePath/TracePathView.swift index 913b373c..25cfedaf 100644 --- a/MC1/Views/Tools/TracePath/TracePathView.swift +++ b/MC1/Views/Tools/TracePath/TracePathView.swift @@ -1,204 +1,202 @@ -import SwiftUI import MC1Services +import SwiftUI /// View mode for trace path building enum TracePathViewMode: String, CaseIterable { - case list - case map + case list + case map - var label: String { - switch self { - case .list: L10n.Contacts.Contacts.Trace.Mode.list - case .map: L10n.Contacts.Contacts.Trace.Mode.map - } + var label: String { + switch self { + case .list: L10n.Contacts.Contacts.Trace.Mode.list + case .map: L10n.Contacts.Contacts.Trace.Mode.map } + } } /// View for building and executing network path traces struct TracePathView: View { - @Environment(\.appState) private var appState - @State private var viewModel = TracePathViewModel() + @Environment(\.appState) private var appState + @State private var viewModel = TracePathViewModel() - // Haptic feedback triggers - @State private var dragHapticTrigger = 0 - @State private var copyHapticTrigger = 0 - @State private var jumpHapticTrigger = 0 + // Haptic feedback triggers + @State private var dragHapticTrigger = 0 + @State private var copyHapticTrigger = 0 + @State private var jumpHapticTrigger = 0 - @State private var showingSavedPaths = false - @State private var presentedResult: TraceResult? - @State private var showingClearConfirmation = false + @State private var showingSavedPaths = false + @State private var presentedResult: TraceResult? + @State private var showingClearConfirmation = false - @State private var showJumpToPath = true - @State private var pathLoadedFromSheet = false - @AppStorage(AppStorageKey.tracePathViewMode.rawValue) private var viewMode: TracePathViewMode = .list + @State private var showJumpToPath = true + @State private var pathLoadedFromSheet = false + @AppStorage(AppStorageKey.tracePathViewMode.rawValue) private var viewMode: TracePathViewMode = .list - var body: some View { - ZStack { - switch viewMode { - case .list: - listView - case .map: - TracePathMapView(traceViewModel: viewModel, presentedResult: $presentedResult) - } - } - .animation(nil, value: viewMode) - .navigationTitle(L10n.Contacts.Contacts.Trace.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - Picker(L10n.Contacts.Contacts.Trace.viewMode, selection: $viewMode) { - ForEach(TracePathViewMode.allCases, id: \.self) { mode in - Text(mode.label).tag(mode) - } - } - .pickerStyle(.segmented) - .fixedSize() - } - ToolbarItem(placement: .primaryAction) { - Button(L10n.Contacts.Contacts.Trace.saved, systemImage: "bookmark") { - showingSavedPaths = true - } - } - } - .sensoryFeedback(.impact(weight: .light), trigger: dragHapticTrigger) - .sensoryFeedback(.success, trigger: copyHapticTrigger) - .sensoryFeedback(.error, trigger: viewModel.errorHapticTrigger) - .sheet(isPresented: $showingSavedPaths) { - SavedPathsSheet { selectedPath in - viewModel.loadSavedPath(selectedPath) - pathLoadedFromSheet = true - } onDelete: { deletedPathId in - viewModel.handleSavedPathDeleted(id: deletedPathId) - } - } - .onChange(of: viewModel.resultID) { _, newID in - guard newID != nil else { return } - if let result = viewModel.result, result.success { - if viewMode == .list { - presentedResult = result - } - } - } - .sheet(item: $presentedResult, onDismiss: { - if viewModel.isBatchInProgress { - viewModel.cancelBatchTrace() - } - }) { result in - TraceResultsSheet(result: result, viewModel: viewModel) - .presentationDetents([.large]) - .presentationDragIndicator(.visible) - } - .confirmationDialog( - L10n.Contacts.Contacts.Trace.clearPath, - isPresented: $showingClearConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { - viewModel.clearPath() - } - } message: { - Text(L10n.Contacts.Contacts.Trace.clearPathMessage) - } - .alert(L10n.Contacts.Contacts.Trace.failed, isPresented: Binding( - get: { viewModel.errorMessage != nil }, - set: { if !$0 { viewModel.clearError() } } - )) { - Button(L10n.Contacts.Contacts.Common.ok) { - viewModel.clearError() - } - } message: { - if let error = viewModel.errorMessage { - Text(error) - } + var body: some View { + ZStack { + switch viewMode { + case .list: + listView + case .map: + TracePathMapView(traceViewModel: viewModel, presentedResult: $presentedResult) + } + } + .animation(nil, value: viewMode) + .navigationTitle(L10n.Contacts.Contacts.Trace.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Picker(L10n.Contacts.Contacts.Trace.viewMode, selection: $viewMode) { + ForEach(TracePathViewMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } } - .task(id: appState.servicesVersion) { - // Keyed on servicesVersion: a late connect or reconnect rebuilds the - // ServiceContainer, so the listener must re-subscribe to the fresh - // AdvertisementService or trace responses are silently dropped. - viewModel.configure(dependencies: TracePathViewModel.Dependencies( - dataStore: { appState.services?.dataStore }, - session: { appState.services?.session }, - advertisementService: { appState.services?.advertisementService }, - connectedDevice: { appState.connectedDevice }, - bestAvailableLocation: { appState.bestAvailableLocation } - )) - viewModel.startListening() - if let radioID = appState.connectedDevice?.radioID { - await viewModel.loadContacts(radioID: radioID) - } + .pickerStyle(.segmented) + .fixedSize() + } + ToolbarItem(placement: .primaryAction) { + Button(L10n.Contacts.Contacts.Trace.saved, systemImage: "bookmark") { + showingSavedPaths = true } - .onDisappear { - viewModel.stopListening() + } + } + .sensoryFeedback(.impact(weight: .light), trigger: dragHapticTrigger) + .sensoryFeedback(.success, trigger: copyHapticTrigger) + .sensoryFeedback(.error, trigger: viewModel.errorHapticTrigger) + .sheet(isPresented: $showingSavedPaths) { + SavedPathsSheet { selectedPath in + viewModel.loadSavedPath(selectedPath) + pathLoadedFromSheet = true + } onDelete: { deletedPathId in + viewModel.handleSavedPathDeleted(id: deletedPathId) + } + } + .onChange(of: viewModel.resultID) { _, newID in + guard newID != nil else { return } + if let result = viewModel.result, result.success { + if viewMode == .list { + presentedResult = result } + } + } + .sheet(item: $presentedResult, onDismiss: { + if viewModel.isBatchInProgress { + viewModel.cancelBatchTrace() + } + }) { result in + TraceResultsSheet(result: result, viewModel: viewModel) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .confirmationDialog( + L10n.Contacts.Contacts.Trace.clearPath, + isPresented: $showingClearConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { + viewModel.clearPath() + } + } message: { + Text(L10n.Contacts.Contacts.Trace.clearPathMessage) + } + .alert(L10n.Contacts.Contacts.Trace.failed, isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.clearError() } } + )) { + Button(L10n.Contacts.Contacts.Common.ok) { + viewModel.clearError() + } + } message: { + if let error = viewModel.errorMessage { + Text(error) + } } + .task(id: appState.servicesVersion) { + // Keyed on servicesVersion: a late connect or reconnect rebuilds the + // ServiceContainer, so the listener must re-subscribe to the fresh + // AdvertisementService or trace responses are silently dropped. + viewModel.configure(dependencies: TracePathViewModel.Dependencies( + dataStore: { appState.services?.dataStore }, + session: { appState.services?.session }, + advertisementService: { appState.services?.advertisementService }, + connectedDevice: { appState.connectedDevice }, + bestAvailableLocation: { appState.bestAvailableLocation } + )) + viewModel.startListening() + if let radioID = appState.connectedDevice?.radioID { + await viewModel.loadContacts(radioID: radioID) + } + } + .onDisappear { + viewModel.stopListening() + } + } - @ViewBuilder - private var listView: some View { - ScrollViewReader { proxy in - TracePathListView( - viewModel: viewModel, - dragHapticTrigger: $dragHapticTrigger, - copyHapticTrigger: $copyHapticTrigger, - showingClearConfirmation: $showingClearConfirmation, - presentedResult: $presentedResult, - showJumpToPath: $showJumpToPath - ) - .scrollDismissesKeyboard(.interactively) - .overlay(alignment: .bottom) { - jumpToPathButton(proxy: proxy) - } - .onChange(of: showingSavedPaths) { wasShowing, isShowing in - if wasShowing && !isShowing && pathLoadedFromSheet { - pathLoadedFromSheet = false - withAnimation { - proxy.scrollTo("runTrace", anchor: .bottom) - } - } - } + private var listView: some View { + ScrollViewReader { proxy in + TracePathListView( + viewModel: viewModel, + dragHapticTrigger: $dragHapticTrigger, + copyHapticTrigger: $copyHapticTrigger, + showingClearConfirmation: $showingClearConfirmation, + presentedResult: $presentedResult, + showJumpToPath: $showJumpToPath + ) + .scrollDismissesKeyboard(.interactively) + .overlay(alignment: .bottom) { + jumpToPathButton(proxy: proxy) + } + .onChange(of: showingSavedPaths) { wasShowing, isShowing in + if wasShowing, !isShowing, pathLoadedFromSheet { + pathLoadedFromSheet = false + withAnimation { + proxy.scrollTo("runTrace", anchor: .bottom) + } } + } } + } - // MARK: - Jump to Path Button + // MARK: - Jump to Path Button - @ViewBuilder - private func jumpToPathButton(proxy: ScrollViewProxy) -> some View { - JumpToPathButton(isVisible: showJumpToPath) { - jumpHapticTrigger += 1 - withAnimation { - showJumpToPath = false - proxy.scrollTo("runTrace", anchor: .bottom) - } - } - .padding(.bottom) - .sensoryFeedback(.selection, trigger: jumpHapticTrigger) + private func jumpToPathButton(proxy: ScrollViewProxy) -> some View { + JumpToPathButton(isVisible: showJumpToPath) { + jumpHapticTrigger += 1 + withAnimation { + showJumpToPath = false + proxy.scrollTo("runTrace", anchor: .bottom) + } } + .padding(.bottom) + .sensoryFeedback(.selection, trigger: jumpHapticTrigger) + } } // MARK: - Jump to Path Button /// Floating pill button to scroll to the Run Trace button private struct JumpToPathButton: View { - let isVisible: Bool - let onTap: () -> Void + let isVisible: Bool + let onTap: () -> Void - var body: some View { - Button(action: onTap) { - Label(L10n.Contacts.Contacts.Trace.runBelow, systemImage: "arrow.down") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color.accentColor, in: .capsule) - .liquidGlass(in: .capsule) - .shadow(color: .black.opacity(0.2), radius: 8, y: 4) - } - .buttonStyle(.plain) - .opacity(isVisible ? 1 : 0) - .scaleEffect(isVisible ? 1 : 0.8) - .allowsHitTesting(isVisible) - .animation(.snappy(duration: 0.2), value: isVisible) - .accessibilityLabel(L10n.Contacts.Contacts.Trace.jumpLabel) - .accessibilityHint(L10n.Contacts.Contacts.Trace.jumpHint) - .accessibilityHidden(!isVisible) + var body: some View { + Button(action: onTap) { + Label(L10n.Contacts.Contacts.Trace.runBelow, systemImage: "arrow.down") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.accentColor, in: .capsule) + .liquidGlass(in: .capsule) + .shadow(color: .black.opacity(0.2), radius: 8, y: 4) } + .buttonStyle(.plain) + .opacity(isVisible ? 1 : 0) + .scaleEffect(isVisible ? 1 : 0.8) + .allowsHitTesting(isVisible) + .animation(.snappy(duration: 0.2), value: isVisible) + .accessibilityLabel(L10n.Contacts.Contacts.Trace.jumpLabel) + .accessibilityHint(L10n.Contacts.Contacts.Trace.jumpHint) + .accessibilityHidden(!isVisible) + } } diff --git a/MC1/Views/Tools/TracePath/TracePathViewModel.swift b/MC1/Views/Tools/TracePath/TracePathViewModel.swift index 5039222f..d5fab39a 100644 --- a/MC1/Views/Tools/TracePath/TracePathViewModel.swift +++ b/MC1/Views/Tools/TracePath/TracePathViewModel.swift @@ -1,1256 +1,1278 @@ import CoreLocation -import SwiftUI -import UIKit -import MeshCore import MC1Services +import MeshCore import os.log +import SwiftUI +import UIKit private let logger = Logger(subsystem: "com.mc1", category: "TracePath") @Observable @MainActor final class TracePathViewModel { - - // MARK: - Path Building State - - var outboundPath: [PathHop] = [] - var availableRepeaters: [ContactDTO] = [] - var availableRooms: [ContactDTO] = [] - var autoReturnPath = true - private var allContacts: [ContactDTO] = [] - var discoveredRepeaters: [DiscoveredNodeDTO] = [] - - /// Recently added hop public keys, newest first. Source for the shared - /// picker's "Recent" section; persisted per radio via ``RecentHopsStore``. - var recentPublicKeys: [Data] = [] - private let recents: RecentHopsStore - private var currentRadioID: UUID? - - init(defaults: UserDefaults = .standard) { - self.recents = RecentHopsStore(defaults: defaults) - } - - /// Combined repeaters and rooms for resolution (hex codes, map pins, etc.) - var availableNodes: [ContactDTO] { - availableRepeaters + availableRooms - } - - // MARK: - Execution State - - var isRunning = false - var result: TraceResult? - var resultID: UUID? // Set to new UUID only on successful trace - var errorMessage: String? - var errorAutoClearDelay: Duration = .seconds(4) - private var errorAutoClearTask: Task? - var errorHapticTrigger = 0 // Incremented on each error for haptic feedback - - /// Buffer between consecutive batch traces to avoid network flooding. - private static let interTraceBufferMs = 500 - - // MARK: - Batch Trace State - - var batchEnabled = false { - didSet { - if !batchEnabled { - clearBatchState() - } - } - } - var batchSize = 5 - var currentTraceIndex = 0 - var completedResults: [TraceResult] = [] - - /// Flag to signal batch loop should stop (since we await in the calling context) - private var batchCancelled = false - - /// Continuation for awaiting trace response in batch mode - private var traceContinuation: CheckedContinuation? - - var isBatchInProgress: Bool { - batchEnabled && currentTraceIndex > 0 && currentTraceIndex <= batchSize + // MARK: - Path Building State + + var outboundPath: [PathHop] = [] + var availableRepeaters: [ContactDTO] = [] + var availableRooms: [ContactDTO] = [] + var autoReturnPath = true + private var allContacts: [ContactDTO] = [] + var discoveredRepeaters: [DiscoveredNodeDTO] = [] + + /// Recently added hop public keys, newest first. Source for the shared + /// picker's "Recent" section; persisted per radio via ``RecentHopsStore``. + var recentPublicKeys: [Data] = [] + private let recents: RecentHopsStore + private var currentRadioID: UUID? + + init(defaults: UserDefaults = .standard) { + recents = RecentHopsStore(defaults: defaults) + } + + /// Combined repeaters and rooms for resolution (hex codes, map pins, etc.) + var availableNodes: [ContactDTO] { + availableRepeaters + availableRooms + } + + // MARK: - Execution State + + var isRunning = false + var result: TraceResult? + var resultID: UUID? // Set to new UUID only on successful trace + var errorMessage: String? + var errorAutoClearDelay: Duration = .seconds(4) + private var errorAutoClearTask: Task? + var errorHapticTrigger = 0 // Incremented on each error for haptic feedback + + /// Buffer between consecutive batch traces to avoid network flooding. + private static let interTraceBufferMs = 500 + + // MARK: - Batch Trace State + + var batchEnabled = false { + didSet { + if !batchEnabled { + clearBatchState() + } } - - var isBatchComplete: Bool { - batchEnabled && completedResults.count == batchSize + } + + var batchSize = 5 + var currentTraceIndex = 0 + var completedResults: [TraceResult] = [] + + /// Flag to signal batch loop should stop (since we await in the calling context) + private var batchCancelled = false + + /// Continuation for awaiting trace response in batch mode + private var traceContinuation: CheckedContinuation? + + var isBatchInProgress: Bool { + batchEnabled && currentTraceIndex > 0 && currentTraceIndex <= batchSize + } + + var isBatchComplete: Bool { + batchEnabled && completedResults.count == batchSize + } + + var successfulResults: [TraceResult] { + completedResults.filter(\.success) + } + + var successCount: Int { + successfulResults.count + } + + /// Clear batch execution state + func clearBatchState() { + currentTraceIndex = 0 + completedResults = [] + } + + // MARK: - Batch Aggregates + + var averageRTT: Int? { + let rtts = successfulResults.map(\.durationMs) + guard !rtts.isEmpty else { return nil } + return rtts.reduce(0, +) / rtts.count + } + + var minRTT: Int? { + successfulResults.map(\.durationMs).min() + } + + var maxRTT: Int? { + successfulResults.map(\.durationMs).max() + } + + /// Returns aggregate stats for a hop at the given index (0 = start node, 1+ = intermediate/end) + /// Returns nil for start node (index 0) as it has no received SNR + func hopStats(at index: Int) -> (avg: Double, min: Double, max: Double)? { + guard index > 0 else { return nil } // Start node has no SNR + + let snrValues = successfulResults.compactMap { result -> Double? in + guard index < result.hops.count else { return nil } + let hop = result.hops[index] + guard !hop.isStartNode else { return nil } + return hop.snr } - var successfulResults: [TraceResult] { - completedResults.filter { $0.success } + guard !snrValues.isEmpty else { return nil } + + let avg = snrValues.reduce(0, +) / Double(snrValues.count) + let min = snrValues.min() ?? 0 + let max = snrValues.max() ?? 0 + + return (avg, min, max) + } + + /// Returns the SNR for a hop from the most recent successful result + func latestHopSNR(at index: Int) -> Double? { + guard let latest = successfulResults.last, + index < latest.hops.count else { return nil } + return latest.hops[index].snr + } + + // MARK: - Saved Path State + + var activeSavedPath: SavedTracePathDTO? + var isRunningSavedPath: Bool { + activeSavedPath != nil + } + + /// Returns the second-most-recent successful run for comparison display + var previousRun: TracePathRunDTO? { + let successfulRuns = activeSavedPath?.runs + .filter(\.success) + .sorted(by: { $0.date > $1.date }) ?? [] + return successfulRuns.count >= 2 ? successfulRuns[1] : nil + } + + // MARK: - Trace Correlation + + private var pendingTag: UInt32? + private var pendingDeviceID: UUID? // Track which device initiated trace + private var traceStartTime: Date? + private var traceTask: Task? + + // MARK: - Path Hash Tracking (for save validation) + + private var pendingPathHash: [UInt8]? + // resultPathHash removed - now derived from result.tracedPathBytes + + // MARK: - Event Subscription + + private var traceEventsTask: Task? + + /// Start listening for trace responses on the current connection's + /// `AdvertisementService`. Requires `configure` first. The owning + /// `ServiceContainer` is rebuilt on every connection and finishes + /// its event stream on teardown, so the hosting view re-invokes this per + /// container (keyed on `AppState.servicesVersion`); while disconnected + /// there is no service yet and the call is a no-op until then. + func startListening() { + traceEventsTask?.cancel() + guard let advertisementService else { return } + let events = advertisementService.events() + traceEventsTask = Task { [weak self] in + for await event in events { + guard case let .traceResponse(traceInfo, radioID) = event else { continue } + guard let self else { return } + handleTraceResponse(traceInfo, radioID: radioID) + } } - - var successCount: Int { - successfulResults.count + } + + /// Stop listening for trace responses + func stopListening() { + traceEventsTask?.cancel() + traceEventsTask = nil + } + + // MARK: - Dependencies + + struct Dependencies { + // Provider closures are re-evaluated at every use so a disconnect (or a + // container rebuild) mid-trace is observed live, never a stale snapshot. + var dataStore: @MainActor () -> PersistenceStore? + var session: @MainActor () -> MeshCoreSession? + var advertisementService: @MainActor () -> AdvertisementService? + var connectedDevice: @MainActor () -> DeviceDTO? + var bestAvailableLocation: @MainActor () -> CLLocation? + } + + private var deps = Dependencies( + dataStore: { nil }, + session: { nil }, + advertisementService: { nil }, + connectedDevice: { nil }, + bestAvailableLocation: { nil } + ) + + private var dataStore: PersistenceStore? { + deps.dataStore() + } + + private var session: MeshCoreSession? { + deps.session() + } + + private var advertisementService: AdvertisementService? { + deps.advertisementService() + } + + private var connectedDevice: DeviceDTO? { + deps.connectedDevice() + } + + private var bestAvailableLocation: CLLocation? { + deps.bestAvailableLocation() + } + + // MARK: - Computed Properties + + /// Full path data: outbound + optional mirrored return (minus last hop to avoid duplicate) + var fullPathData: Data { + let outbound = outboundPath.map(\.hashBytes) + guard !outbound.isEmpty else { return Data() } + + if autoReturnPath { + let returnPath = outbound.reversed().dropFirst() + return Data((outbound + returnPath).flatMap(\.self)) + } else { + return Data(outbound.flatMap(\.self)) } - - /// Clear batch execution state - func clearBatchState() { - currentTraceIndex = 0 - completedResults = [] + } + + /// Full path as byte array for backward compatibility + var fullPathBytes: [UInt8] { + Array(fullPathData) + } + + /// Per-trace hash size override (path_sz code 0/1/2). `nil` follows the + /// radio's configured `pathHashMode`. Honored by firmware v1.11+. + var traceHashMode: UInt8? + + /// Path_sz code used for this trace's flags byte and hop widths: the + /// override when set, otherwise the radio's configured `pathHashMode`. + var effectiveTraceMode: UInt8 { + traceHashMode ?? (connectedDevice?.pathHashMode ?? 0) + } + + /// Trace hash size in bytes per hop (1, 2, or 4), derived from the effective + /// mode. Trace uses power-of-2 encoding (`1 << mode`), unlike the linear + /// 1/2/3-byte routing hash size. + var hashSize: Int { + 1 << Int(effectiveTraceMode) + } + + /// Comma-separated path string for display/copy, chunked by hash size + var fullPathString: String { + let data = fullPathData + let size = outboundPath.first?.hashBytes.count ?? hashSize + return stride(from: 0, to: data.count, by: size).map { start in + let end = min(start + size, data.count) + return data[start..= 2 else { return nil } - var minRTT: Int? { - successfulResults.map(\.durationMs).min() + // Priority 1: Full path including device legs + if let fullDistance = calculateDistance(for: result.hops) { + return fullDistance } - var maxRTT: Int? { - successfulResults.map(\.durationMs).max() - } + // Priority 2: Intermediate repeaters only (device has no location) + let repeaters = result.hops.filter { !$0.isStartNode && !$0.isEndNode } + return calculateDistance(for: repeaters) + } - /// Returns aggregate stats for a hop at the given index (0 = start node, 1+ = intermediate/end) - /// Returns nil for start node (index 0) as it has no received SNR - func hopStats(at index: Int) -> (avg: Double, min: Double, max: Double)? { - guard index > 0 else { return nil } // Start node has no SNR + /// Calculate total distance for a sequence of hops, or nil if any lacks location + private func calculateDistance(for hops: [TraceHop]) -> Double? { + guard hops.count >= 2 else { return nil } - let snrValues = successfulResults.compactMap { result -> Double? in - guard index < result.hops.count else { return nil } - let hop = result.hops[index] - guard !hop.isStartNode else { return nil } - return hop.snr - } + var totalMeters: Double = 0 - guard !snrValues.isEmpty else { return nil } + for index in 0..<(hops.count - 1) { + let current = hops[index] + let next = hops[index + 1] - let avg = snrValues.reduce(0, +) / Double(snrValues.count) - let min = snrValues.min() ?? 0 - let max = snrValues.max() ?? 0 + guard current.hasLocation, next.hasLocation, + let curLat = current.latitude, let curLon = current.longitude, + let nextLat = next.latitude, let nextLon = next.longitude else { + return nil + } - return (avg, min, max) + let from = CLLocation(latitude: curLat, longitude: curLon) + let to = CLLocation(latitude: nextLat, longitude: nextLon) + totalMeters += from.distance(from: to) } - /// Returns the SNR for a hop from the most recent successful result - func latestHopSNR(at index: Int) -> Double? { - guard let latest = successfulResults.last, - index < latest.hops.count else { return nil } - return latest.hops[index].snr - } + return totalMeters + } - // MARK: - Saved Path State + /// Names of intermediate repeaters that lack location data + var repeatersWithoutLocation: [String] { + guard let result else { return [] } - var activeSavedPath: SavedTracePathDTO? - var isRunningSavedPath: Bool { activeSavedPath != nil } - /// Returns the second-most-recent successful run for comparison display - var previousRun: TracePathRunDTO? { - let successfulRuns = activeSavedPath?.runs - .filter { $0.success } - .sorted(by: { $0.date > $1.date }) ?? [] - return successfulRuns.count >= 2 ? successfulRuns[1] : nil - } + return result.hops + .filter { !$0.isStartNode && !$0.isEndNode && !$0.hasLocation } + .map { $0.resolvedName ?? $0.hashDisplayString ?? "Unknown" } + } - // MARK: - Trace Correlation - - private var pendingTag: UInt32? - private var pendingDeviceID: UUID? // Track which device initiated trace - private var traceStartTime: Date? - private var traceTask: Task? - - // MARK: - Path Hash Tracking (for save validation) - - private var pendingPathHash: [UInt8]? - // resultPathHash removed - now derived from result.tracedPathBytes - - // MARK: - Event Subscription - - private var traceEventsTask: Task? - - /// Start listening for trace responses on the current connection's - /// `AdvertisementService`. Requires `configure` first. The owning - /// `ServiceContainer` is rebuilt on every connection and finishes - /// its event stream on teardown, so the hosting view re-invokes this per - /// container (keyed on `AppState.servicesVersion`); while disconnected - /// there is no service yet and the call is a no-op until then. - func startListening() { - traceEventsTask?.cancel() - guard let advertisementService else { return } - let events = advertisementService.events() - traceEventsTask = Task { [weak self] in - for await event in events { - guard case .traceResponse(let traceInfo, let radioID) = event else { continue } - guard let self else { return } - self.handleTraceResponse(traceInfo, radioID: radioID) - } - } - } + /// Whether the distance calculation used intermediate-only fallback (device has no location) + var isDistanceUsingFallback: Bool { + guard let result, result.success, totalPathDistance != nil else { return false } - /// Stop listening for trace responses - func stopListening() { - traceEventsTask?.cancel() - traceEventsTask = nil - } + guard let startNode = result.hops.first, let endNode = result.hops.last else { return false } - // MARK: - Dependencies + // If device nodes lack location, we used the intermediate-only fallback + return !startNode.hasLocation || !endNode.hasLocation + } - struct Dependencies { - // Provider closures are re-evaluated at every use so a disconnect (or a - // container rebuild) mid-trace is observed live, never a stale snapshot. - var dataStore: @MainActor () -> PersistenceStore? - var session: @MainActor () -> MeshCoreSession? - var advertisementService: @MainActor () -> AdvertisementService? - var connectedDevice: @MainActor () -> DeviceDTO? - var bestAvailableLocation: @MainActor () -> CLLocation? - } + // MARK: - Configuration - private var deps = Dependencies( - dataStore: { nil }, - session: { nil }, - advertisementService: { nil }, - connectedDevice: { nil }, - bestAvailableLocation: { nil } - ) + /// Each provider is read live at its point of use; a provider returning + /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. + func configure(dependencies: Dependencies) { + deps = dependencies + } - private var dataStore: PersistenceStore? { deps.dataStore() } - private var session: MeshCoreSession? { deps.session() } - private var advertisementService: AdvertisementService? { deps.advertisementService() } - private var connectedDevice: DeviceDTO? { deps.connectedDevice() } - private var bestAvailableLocation: CLLocation? { deps.bestAvailableLocation() } + // MARK: - Error Handling - // MARK: - Computed Properties + func setError(_ message: String) { + errorAutoClearTask?.cancel() - /// Full path data: outbound + optional mirrored return (minus last hop to avoid duplicate) - var fullPathData: Data { - let outbound = outboundPath.map { $0.hashBytes } - guard !outbound.isEmpty else { return Data() } + errorMessage = message + errorHapticTrigger += 1 - if autoReturnPath { - let returnPath = outbound.reversed().dropFirst() - return Data((outbound + returnPath).flatMap { $0 }) - } else { - return Data(outbound.flatMap { $0 }) - } + errorAutoClearTask = Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(for: errorAutoClearDelay) + guard !Task.isCancelled else { return } + errorMessage = nil } + } - /// Full path as byte array for backward compatibility - var fullPathBytes: [UInt8] { - Array(fullPathData) - } + func clearError() { + errorAutoClearTask?.cancel() + errorAutoClearTask = nil + errorMessage = nil + } - /// Per-trace hash size override (path_sz code 0/1/2). `nil` follows the - /// radio's configured `pathHashMode`. Honored by firmware v1.11+. - var traceHashMode: UInt8? + // MARK: - Hash Resolution - /// Path_sz code used for this trace's flags byte and hop widths: the - /// override when set, otherwise the radio's configured `pathHashMode`. - var effectiveTraceMode: UInt8 { - traceHashMode ?? (connectedDevice?.pathHashMode ?? 0) - } + /// Resolve hash bytes to the best matching node name (contacts first, then discovered) + func resolveHashToName(_ hashBytes: Data) -> String? { + resolveNode(for: hashBytes)?.resolvableName + } - /// Trace hash size in bytes per hop (1, 2, or 4), derived from the effective - /// mode. Trace uses power-of-2 encoding (`1 << mode`), unlike the linear - /// 1/2/3-byte routing hash size. - var hashSize: Int { 1 << Int(effectiveTraceMode) } - - /// Comma-separated path string for display/copy, chunked by hash size - var fullPathString: String { - let data = fullPathData - let size = outboundPath.first?.hashBytes.count ?? hashSize - return stride(from: 0, to: data.count, by: size).map { start in - let end = min(start + size, data.count) - return data[start.. (any RepeaterResolvable)? { + if let contact = RepeaterResolver.bestMatch(for: hashBytes, in: availableNodes, userLocation: bestAvailableLocation) { + return contact } + return RepeaterResolver.bestMatch(for: hashBytes, in: discoveredRepeaters, userLocation: bestAvailableLocation) + } - /// Can run trace if path has at least one hop and not currently running - var canRunTraceWhenConnected: Bool { - !outboundPath.isEmpty && !isRunning + /// Try contacts first, then discovered nodes, using full PathHop for exact key match. + private func resolveNode(for hop: PathHop) -> (any RepeaterResolvable)? { + if let contact = RepeaterResolver.bestMatch(for: hop, in: availableNodes, userLocation: bestAvailableLocation) { + return contact } - - /// Can save path if result is successful and path hasn't changed since trace ran - var canSavePath: Bool { - if batchEnabled { - guard !completedResults.isEmpty else { return false } - guard let firstSuccess = successfulResults.first else { return false } - return fullPathBytes == firstSuccess.tracedPathBytes - } else { - guard let result, result.success else { return false } - return fullPathBytes == result.tracedPathBytes - } + return RepeaterResolver.bestMatch(for: hop, in: discoveredRepeaters, userLocation: bestAvailableLocation) + } + + // MARK: - Data Loading + + /// Load contacts for name resolution and available repeaters + func loadContacts(radioID: UUID) async { + currentRadioID = radioID + recentPublicKeys = recents.load(for: radioID) + // Drop an override carried from a prior radio the current one can't + // honor, so the trace falls back to its pathHashMode. + if connectedDevice?.supportsTraceHashSizeOverride != true { + traceHashMode = nil } - - // MARK: - Distance Calculation - - /// Total path distance in meters, using a priority cascade: - /// 1. Full path (including device legs) if device has location - /// 2. Intermediate repeaters only if device lacks location - /// 3. Nil if fewer than 2 hops with valid location - var totalPathDistance: Double? { - guard let result, result.success else { return nil } - guard result.hops.count >= 2 else { return nil } - - // Priority 1: Full path including device legs - if let fullDistance = calculateDistance(for: result.hops) { - return fullDistance - } - - // Priority 2: Intermediate repeaters only (device has no location) - let repeaters = result.hops.filter { !$0.isStartNode && !$0.isEndNode } - return calculateDistance(for: repeaters) + guard let dataStore else { return } + do { + let contacts = try await dataStore.fetchContacts(radioID: radioID) + allContacts = contacts + availableRepeaters = contacts.filter { $0.type == .repeater } + availableRooms = contacts.filter { $0.type == .room } + let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) + discoveredRepeaters = nodes.filter { $0.nodeType == .repeater } + } catch { + logger.error("Failed to load contacts: \(error.localizedDescription)") + allContacts = [] + availableRepeaters = [] + availableRooms = [] + discoveredRepeaters = [] } - - /// Calculate total distance for a sequence of hops, or nil if any lacks location - private func calculateDistance(for hops: [TraceHop]) -> Double? { - guard hops.count >= 2 else { return nil } - - var totalMeters: Double = 0 - - for index in 0..<(hops.count - 1) { - let current = hops[index] - let next = hops[index + 1] - - guard current.hasLocation, next.hasLocation, - let curLat = current.latitude, let curLon = current.longitude, - let nextLat = next.latitude, let nextLon = next.longitude else { - return nil - } - - let from = CLLocation(latitude: curLat, longitude: curLon) - let to = CLLocation(latitude: nextLat, longitude: nextLon) - totalMeters += from.distance(from: to) - } - - return totalMeters + } + + // MARK: - Path Manipulation + + /// Add a node to the outbound path + func addNode(_ node: some RepeaterResolvable) { + clearError() + let hashBytes = Data(node.publicKey.prefix(hashSize)) + let hop = PathHop(hashBytes: hashBytes, publicKey: node.publicKey, resolvedName: node.resolvableName) + outboundPath.append(hop) + activeSavedPath = nil + pendingPathHash = nil + result = nil + } + + /// Apply a per-trace hash size override and rebuild every hop to a uniform + /// width. Hops with a public key re-prefix exactly; key-less hops (from a + /// saved path with an unresolved hop) have nothing to widen from and are + /// zero-padded. The width must be uniform because the trace flags byte + /// declares a single hash size for the whole path. + func setTraceHashMode(_ mode: UInt8) { + clearError() + traceHashMode = mode + let size = hashSize + outboundPath = outboundPath.map { hop in + var hop = hop + let source = hop.publicKey ?? hop.hashBytes + var bytes = Data(source.prefix(size)) + if bytes.count < size { + bytes.append(Data(repeating: 0, count: size - bytes.count)) + } + hop.hashBytes = bytes + return hop } - - /// Names of intermediate repeaters that lack location data - var repeatersWithoutLocation: [String] { - guard let result else { return [] } - - return result.hops - .filter { !$0.isStartNode && !$0.isEndNode && !$0.hasLocation } - .map { $0.resolvedName ?? $0.hashDisplayString ?? "Unknown" } + activeSavedPath = nil + pendingPathHash = nil + result = nil + } + + /// Parse comma-separated hex codes and add matching repeaters to the path. + /// Shares ``HopCodeParser`` with the bulk-add preview so the two never diverge. + @discardableResult + func addRepeatersFromCodes(_ input: String) -> CodeInputResult { + var result = CodeInputResult() + for entry in classifyCodes(input) { + switch entry.status { + case let .willAdd(hop): + outboundPath.append(hop) + if let publicKey = hop.publicKey { recordRecent(publicKey) } + result.added.append(entry.code) + case .alreadyInPath: result.alreadyInPath.append(entry.code) + case .notFound: result.notFound.append(entry.code) + case .invalidFormat: result.invalidFormat.append(entry.code) + case .pathFull: assertionFailure("trace paths are uncapped, so classifyCodes never yields .pathFull") + } } - /// Whether the distance calculation used intermediate-only fallback (device has no location) - var isDistanceUsingFallback: Bool { - guard let result, result.success, totalPathDistance != nil else { return false } - - guard let startNode = result.hops.first, let endNode = result.hops.last else { return false } - - // If device nodes lack location, we used the intermediate-only fallback - return !startNode.hasLocation || !endNode.hasLocation + // Clear saved path reference if we added anything + if !result.added.isEmpty { + activeSavedPath = nil + pendingPathHash = nil + self.result = nil + clearError() } - // MARK: - Configuration - - /// Each provider is read live at its point of use; a provider returning - /// `nil` mirrors a disconnected state, so unconfigured calls are no-ops. - func configure(dependencies: Dependencies) { - deps = dependencies + return result + } + + /// Remove a repeater from the path + func removeRepeater(at index: Int) { + clearError() + guard outboundPath.indices.contains(index) else { return } + outboundPath.remove(at: index) + activeSavedPath = nil + pendingPathHash = nil + result = nil + } + + /// Move a repeater within the path + func moveRepeater(from source: IndexSet, to destination: Int) { + clearError() + outboundPath.move(fromOffsets: source, toOffset: destination) + activeSavedPath = nil + pendingPathHash = nil + result = nil + } + + /// Copy full path string to clipboard + func copyPathToClipboard() { + UIPasteboard.general.string = fullPathString + } + + /// Generate a default name from the path (e.g., "Tower → ... → Ridge") + func generatePathName() -> String { + let names = outboundPath.compactMap(\.resolvedName) + switch names.count { + case 0: + return L10n.Contacts.Contacts.PathName.prefix(String(fullPathString.prefix(8))) + case 1: + return names[0] + case 2: + return L10n.Contacts.Contacts.PathName.twoEndpoints(names[0], names[1]) + default: + return L10n.Contacts.Contacts.PathName.multipleEndpoints(names[0], names[names.count - 1]) } + } + + /// Extract SNR values from intermediate hops (excluding start and end nodes) + private func extractHopsSNR(from result: TraceResult) -> [Double] { + result.hops + .filter { !$0.isStartNode && !$0.isEndNode } + .map(\.snr) + } + + /// Save the current path with the given name + /// - Returns: `true` if save succeeded, `false` otherwise + @discardableResult + func savePath(name: String) async -> Bool { + guard let radioID = connectedDevice?.radioID, + let dataStore else { return false } + + // For batch mode, save all completed results + if batchEnabled, !completedResults.isEmpty { + guard let firstSuccess = successfulResults.first else { return false } + + // Create initial run from first successful result + let initialRun = TracePathRunDTO( + id: UUID(), + date: Date(), + success: true, + roundTripMs: firstSuccess.durationMs, + hopsSNR: extractHopsSNR(from: firstSuccess) + ) + + do { + let savedPath = try await dataStore.createSavedTracePath( + radioID: radioID, + name: name, + pathBytes: Data(firstSuccess.tracedPathBytes), + hashSize: hashSize, + initialRun: initialRun + ) - // MARK: - Error Handling - - func setError(_ message: String) { - errorAutoClearTask?.cancel() - - errorMessage = message - errorHapticTrigger += 1 + // Append remaining results as additional runs + for (index, batchResult) in completedResults.enumerated() { + // Skip the first successful result (already saved as initial) + if batchResult.id == firstSuccess.id { continue } - errorAutoClearTask = Task { @MainActor [weak self] in - guard let self else { return } - try? await Task.sleep(for: errorAutoClearDelay) - guard !Task.isCancelled else { return } - errorMessage = nil + let run = TracePathRunDTO( + id: UUID(), + date: Date().addingTimeInterval(Double(index)), + success: batchResult.success, + roundTripMs: batchResult.durationMs, + hopsSNR: extractHopsSNR(from: batchResult) + ) + try await dataStore.appendTracePathRun(pathID: savedPath.id, run: run) } - } - - func clearError() { - errorAutoClearTask?.cancel() - errorAutoClearTask = nil - errorMessage = nil - } - - // MARK: - Hash Resolution - - /// Resolve hash bytes to the best matching node name (contacts first, then discovered) - func resolveHashToName(_ hashBytes: Data) -> String? { - resolveNode(for: hashBytes)?.resolvableName - } - /// Try contacts first, then discovered nodes. Returns the best match from either source. - private func resolveNode(for hashBytes: Data) -> (any RepeaterResolvable)? { - if let contact = RepeaterResolver.bestMatch(for: hashBytes, in: availableNodes, userLocation: bestAvailableLocation) { - return contact + // Refresh to get all runs + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + activeSavedPath = updated } - return RepeaterResolver.bestMatch(for: hashBytes, in: discoveredRepeaters, userLocation: bestAvailableLocation) + logger.info("Saved batch path: \(name) with \(completedResults.count) runs") + return true + } catch { + logger.error("Failed to save batch path: \(error.localizedDescription)") + return false + } } - /// Try contacts first, then discovered nodes, using full PathHop for exact key match. - private func resolveNode(for hop: PathHop) -> (any RepeaterResolvable)? { - if let contact = RepeaterResolver.bestMatch(for: hop, in: availableNodes, userLocation: bestAvailableLocation) { - return contact - } - return RepeaterResolver.bestMatch(for: hop, in: discoveredRepeaters, userLocation: bestAvailableLocation) - } + // Single trace mode (original behavior) + guard let result, result.success else { return false } - // MARK: - Data Loading + let initialRun = TracePathRunDTO( + id: UUID(), + date: Date(), + success: true, + roundTripMs: result.durationMs, + hopsSNR: extractHopsSNR(from: result) + ) - /// Load contacts for name resolution and available repeaters - func loadContacts(radioID: UUID) async { - currentRadioID = radioID - recentPublicKeys = recents.load(for: radioID) - // Drop an override carried from a prior radio the current one can't - // honor, so the trace falls back to its pathHashMode. - if connectedDevice?.supportsTraceHashSizeOverride != true { - traceHashMode = nil - } - guard let dataStore else { return } - do { - let contacts = try await dataStore.fetchContacts(radioID: radioID) - allContacts = contacts - availableRepeaters = contacts.filter { $0.type == .repeater } - availableRooms = contacts.filter { $0.type == .room } - let nodes = try await dataStore.fetchDiscoveredNodes(radioID: radioID) - discoveredRepeaters = nodes.filter { $0.nodeType == .repeater } - } catch { - logger.error("Failed to load contacts: \(error.localizedDescription)") - allContacts = [] - availableRepeaters = [] - availableRooms = [] - discoveredRepeaters = [] - } + do { + let savedPath = try await dataStore.createSavedTracePath( + radioID: radioID, + name: name, + pathBytes: Data(result.tracedPathBytes), + hashSize: hashSize, + initialRun: initialRun + ) + activeSavedPath = savedPath + logger.info("Saved path: \(name)") + return true + } catch { + logger.error("Failed to save path: \(error.localizedDescription)") + return false } - - // MARK: - Path Manipulation - - /// Add a node to the outbound path - func addNode(_ node: some RepeaterResolvable) { - clearError() - let hashBytes = Data(node.publicKey.prefix(hashSize)) - let hop = PathHop(hashBytes: hashBytes, publicKey: node.publicKey, resolvedName: node.resolvableName) - outboundPath.append(hop) - activeSavedPath = nil - pendingPathHash = nil - result = nil + } + + /// Load a saved path into the builder + func loadSavedPath(_ savedPath: SavedTracePathDTO) { + // Clear existing path + outboundPath.removeAll() + result = nil + pendingPathHash = nil + + // Reconstruct outbound path from saved bytes + // The saved pathBytes contains full path (outbound + return) + // We need to extract just the outbound portion + let fullPath = savedPath.pathBytes + let size = savedPath.hashSize + guard !fullPath.isEmpty else { return } + + // Match the trace hash mode to the saved width (size is 1/2/4 -> code + // 0/1/2), but only on a radio that honors the per-trace override; + // otherwise the trace follows the configured pathHashMode. + if connectedDevice?.supportsTraceHashSizeOverride == true { + traceHashMode = UInt8(size.trailingZeroBitCount) + } else { + traceHashMode = nil } - /// Apply a per-trace hash size override and rebuild every hop to a uniform - /// width. Hops with a public key re-prefix exactly; key-less hops (from a - /// saved path with an unresolved hop) have nothing to widen from and are - /// zero-padded. The width must be uniform because the trace flags byte - /// declares a single hash size for the whole path. - func setTraceHashMode(_ mode: UInt8) { - clearError() - traceHashMode = mode - let size = hashSize - outboundPath = outboundPath.map { hop in - var hop = hop - let source = hop.publicKey ?? hop.hashBytes - var bytes = Data(source.prefix(size)) - if bytes.count < size { - bytes.append(Data(repeating: 0, count: size - bytes.count)) - } - hop.hashBytes = bytes - return hop - } - activeSavedPath = nil - pendingPathHash = nil - result = nil + // Calculate total hops, then outbound is first half (rounded up) + let totalHops = fullPath.count / size + let outboundHopCount = (totalHops + 1) / 2 + let outboundByteCount = outboundHopCount * size + + for start in stride(from: 0, to: min(outboundByteCount, fullPath.count), by: size) { + let end = min(start + size, fullPath.count) + let hashBytes = Data(fullPath[start.. CodeInputResult { - var result = CodeInputResult() - for entry in classifyCodes(input) { - switch entry.status { - case .willAdd(let hop): - outboundPath.append(hop) - if let publicKey = hop.publicKey { recordRecent(publicKey) } - result.added.append(entry.code) - case .alreadyInPath: result.alreadyInPath.append(entry.code) - case .notFound: result.notFound.append(entry.code) - case .invalidFormat: result.invalidFormat.append(entry.code) - case .pathFull: assertionFailure("trace paths are uncapped, so classifyCodes never yields .pathFull") - } - } + activeSavedPath = savedPath + logger.info("Loaded saved path: \(savedPath.name) with \(outboundPath.count) hops") + } + + /// Clear the path (resets to empty state) + func clearPath() { + clearError() + activeSavedPath = nil + outboundPath.removeAll() + result = nil + pendingPathHash = nil + traceHashMode = nil + } + + /// Clear active saved path reference if it matches the deleted path + func handleSavedPathDeleted(id: UUID) { + guard activeSavedPath?.id == id else { return } + activeSavedPath = nil + logger.info("Cleared active saved path reference after deletion") + } + + /// Find a saved path matching the current path bytes + /// Returns the most recently used match if multiple exist + private func findMatchingSavedPath() async -> SavedTracePathDTO? { + guard let radioID = connectedDevice?.radioID, + let dataStore else { return nil } + + let pathBytes = fullPathBytes + guard !pathBytes.isEmpty else { return nil } + + do { + let savedPaths = try await dataStore.fetchSavedTracePaths(radioID: radioID) + let matches = savedPaths.filter { $0.pathHashBytes == pathBytes } + + // Return most recently used (by latest run date) + return matches.max { path1, path2 in + let date1 = path1.runs.map(\.date).max() ?? .distantPast + let date2 = path2.runs.map(\.date).max() ?? .distantPast + return date1 < date2 + } + } catch { + logger.error("Failed to fetch saved paths for matching: \(error.localizedDescription)") + return nil + } + } - // Clear saved path reference if we added anything - if !result.added.isEmpty { - activeSavedPath = nil - pendingPathHash = nil - self.result = nil - clearError() - } + // MARK: - Trace Execution - return result - } + /// Execute the trace and wait for response + func runTrace() async { + guard let session, + !outboundPath.isEmpty else { return } - /// Remove a repeater from the path - func removeRepeater(at index: Int) { - clearError() - guard outboundPath.indices.contains(index) else { return } - outboundPath.remove(at: index) - activeSavedPath = nil - pendingPathHash = nil - result = nil - } + // Cancel any pending trace + traceTask?.cancel() - /// Move a repeater within the path - func moveRepeater(from source: IndexSet, to destination: Int) { - clearError() - outboundPath.move(fromOffsets: source, toOffset: destination) - activeSavedPath = nil - pendingPathHash = nil - result = nil - } + // Clear previous results and errors + resultID = nil + clearError() - /// Copy full path string to clipboard - func copyPathToClipboard() { - UIPasteboard.general.string = fullPathString + // Match to saved path if not already running one + if activeSavedPath == nil { + if let matchedPath = await findMatchingSavedPath() { + activeSavedPath = matchedPath + logger.info("Matched path to saved path: \(matchedPath.name)") + } } - /// Generate a default name from the path (e.g., "Tower → ... → Ridge") - func generatePathName() -> String { - let names = outboundPath.compactMap { $0.resolvedName } - switch names.count { - case 0: - return L10n.Contacts.Contacts.PathName.prefix(String(fullPathString.prefix(8))) - case 1: - return names[0] - case 2: - return L10n.Contacts.Contacts.PathName.twoEndpoints(names[0], names[1]) - default: - return L10n.Contacts.Contacts.PathName.multipleEndpoints(names[0], names[names.count - 1]) + isRunning = true + result = nil + pendingPathHash = fullPathBytes + + // Generate random tag for correlation + let tag = UInt32.random(in: 0...UInt32.max) + pendingTag = tag + pendingDeviceID = connectedDevice?.radioID // Capture device + traceStartTime = Date() + + // Build path data + let pathData = Data(fullPathBytes) + + // Send trace command + let timeoutSeconds: Double + do { + let sentInfo = try await session.sendTrace( + tag: tag, + authCode: 0, // Not used for basic trace + flags: effectiveTraceMode, + path: pathData + ) + timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentInfo.suggestedTimeoutMs) + logger.info("Sent trace with tag \(tag), path: \(fullPathString), timeout: \(timeoutSeconds)s") + } catch { + logger.error("Failed to send trace: \(error.localizedDescription)") + setError(L10n.Contacts.Contacts.Trace.Error.sendFailed) + pendingPathHash = nil + + // Record failed run for saved paths + if let savedPath = activeSavedPath, + let dataStore { + let failedRun = TracePathRunDTO( + id: UUID(), + date: Date(), + success: false, + roundTripMs: 0, + hopsSNR: [] + ) + Task { @MainActor [weak self] in + do { + try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + self?.activeSavedPath = updated + } + } catch { + logger.error("Failed to record send failure: \(error.localizedDescription)") + } } - } + } - /// Extract SNR values from intermediate hops (excluding start and end nodes) - private func extractHopsSNR(from result: TraceResult) -> [Double] { - result.hops - .filter { !$0.isStartNode && !$0.isEndNode } - .map { $0.snr } + isRunning = false + pendingTag = nil + pendingDeviceID = nil + return } - /// Save the current path with the given name - /// - Returns: `true` if save succeeded, `false` otherwise - @discardableResult - func savePath(name: String) async -> Bool { - guard let radioID = connectedDevice?.radioID, - let dataStore else { return false } - - // For batch mode, save all completed results - if batchEnabled && !completedResults.isEmpty { - guard let firstSuccess = successfulResults.first else { return false } - - // Create initial run from first successful result - let initialRun = TracePathRunDTO( - id: UUID(), - date: Date(), - success: true, - roundTripMs: firstSuccess.durationMs, - hopsSNR: extractHopsSNR(from: firstSuccess) + // Wait for response with timeout + traceTask = Task { @MainActor in + do { + try await Task.sleep(for: .seconds(timeoutSeconds)) + + // Timeout - no response received + if !Task.isCancelled, pendingTag == tag { + logger.warning("Trace timeout for tag \(tag) after \(timeoutSeconds)s") + setError(L10n.Contacts.Contacts.Trace.Error.noResponse) + pendingPathHash = nil + + // Record failed run for saved paths + if let savedPath = activeSavedPath, + let dataStore { + let failedRun = TracePathRunDTO( + id: UUID(), + date: Date(), + success: false, + roundTripMs: 0, + hopsSNR: [] ) - do { - let savedPath = try await dataStore.createSavedTracePath( - radioID: radioID, - name: name, - pathBytes: Data(firstSuccess.tracedPathBytes), - hashSize: hashSize, - initialRun: initialRun - ) - - // Append remaining results as additional runs - for (index, batchResult) in completedResults.enumerated() { - // Skip the first successful result (already saved as initial) - if batchResult.id == firstSuccess.id { continue } - - let run = TracePathRunDTO( - id: UUID(), - date: Date().addingTimeInterval(Double(index)), - success: batchResult.success, - roundTripMs: batchResult.durationMs, - hopsSNR: extractHopsSNR(from: batchResult) - ) - try await dataStore.appendTracePathRun(pathID: savedPath.id, run: run) - } - - // Refresh to get all runs - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - activeSavedPath = updated - } - logger.info("Saved batch path: \(name) with \(self.completedResults.count) runs") - return true + try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + activeSavedPath = updated + } } catch { - logger.error("Failed to save batch path: \(error.localizedDescription)") - return false + logger.error("Failed to record timeout: \(error.localizedDescription)") } - } - - // Single trace mode (original behavior) - guard let result, result.success else { return false } - - let initialRun = TracePathRunDTO( - id: UUID(), - date: Date(), - success: true, - roundTripMs: result.durationMs, - hopsSNR: extractHopsSNR(from: result) - ) + } - do { - let savedPath = try await dataStore.createSavedTracePath( - radioID: radioID, - name: name, - pathBytes: Data(result.tracedPathBytes), - hashSize: hashSize, - initialRun: initialRun - ) - activeSavedPath = savedPath - logger.info("Saved path: \(name)") - return true - } catch { - logger.error("Failed to save path: \(error.localizedDescription)") - return false + isRunning = false + pendingTag = nil + pendingDeviceID = nil } + } catch { + // Task cancelled (response received) + } } + } - /// Load a saved path into the builder - func loadSavedPath(_ savedPath: SavedTracePathDTO) { - // Clear existing path - outboundPath.removeAll() - result = nil - pendingPathHash = nil - - // Reconstruct outbound path from saved bytes - // The saved pathBytes contains full path (outbound + return) - // We need to extract just the outbound portion - let fullPath = savedPath.pathBytes - let size = savedPath.hashSize - guard !fullPath.isEmpty else { return } - - // Match the trace hash mode to the saved width (size is 1/2/4 -> code - // 0/1/2), but only on a radio that honors the per-trace override; - // otherwise the trace follows the configured pathHashMode. - if connectedDevice?.supportsTraceHashSizeOverride == true { - traceHashMode = UInt8(size.trailingZeroBitCount) - } else { - traceHashMode = nil - } - - // Calculate total hops, then outbound is first half (rounded up) - let totalHops = fullPath.count / size - let outboundHopCount = (totalHops + 1) / 2 - let outboundByteCount = outboundHopCount * size - - for start in stride(from: 0, to: min(outboundByteCount, fullPath.count), by: size) { - let end = min(start + size, fullPath.count) - let hashBytes = Data(fullPath[start.. SavedTracePathDTO? { - guard let radioID = connectedDevice?.radioID, - let dataStore else { return nil } - - let pathBytes = fullPathBytes - guard !pathBytes.isEmpty else { return nil } + isRunning = true + result = nil - do { - let savedPaths = try await dataStore.fetchSavedTracePaths(radioID: radioID) - let matches = savedPaths.filter { $0.pathHashBytes == pathBytes } - - // Return most recently used (by latest run date) - return matches.max { path1, path2 in - let date1 = path1.runs.map(\.date).max() ?? .distantPast - let date2 = path2.runs.map(\.date).max() ?? .distantPast - return date1 < date2 - } - } catch { - logger.error("Failed to fetch saved paths for matching: \(error.localizedDescription)") - return nil - } - } + // Execute traces sequentially + for traceIndex in 1...batchSize { + // Check cancellation before starting the next trace + if batchCancelled { break } - // MARK: - Trace Execution + currentTraceIndex = traceIndex - /// Execute the trace and wait for response - func runTrace() async { - guard let session, - !outboundPath.isEmpty else { return } - - // Cancel any pending trace - traceTask?.cancel() - - // Clear previous results and errors - resultID = nil - clearError() - - // Match to saved path if not already running one - if activeSavedPath == nil { - if let matchedPath = await findMatchingSavedPath() { - activeSavedPath = matchedPath - logger.info("Matched path to saved path: \(matchedPath.name)") - } - } - - isRunning = true - result = nil - pendingPathHash = fullPathBytes - - // Generate random tag for correlation - let tag = UInt32.random(in: 0...UInt32.max) - pendingTag = tag - pendingDeviceID = connectedDevice?.radioID // Capture device - traceStartTime = Date() - - // Build path data - let pathData = Data(fullPathBytes) - - // Send trace command - let timeoutSeconds: Double - do { - let sentInfo = try await session.sendTrace( - tag: tag, - authCode: 0, // Not used for basic trace - flags: effectiveTraceMode, - path: pathData - ) - timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentInfo.suggestedTimeoutMs) - logger.info("Sent trace with tag \(tag), path: \(self.fullPathString), timeout: \(timeoutSeconds)s") - } catch { - logger.error("Failed to send trace: \(error.localizedDescription)") - setError(L10n.Contacts.Contacts.Trace.Error.sendFailed) - pendingPathHash = nil + // Run single trace and wait for result + await executeSingleTrace(session: session) - // Record failed run for saved paths - if let savedPath = activeSavedPath, - let dataStore { - let failedRun = TracePathRunDTO( - id: UUID(), - date: Date(), - success: false, - roundTripMs: 0, - hopsSNR: [] - ) - Task { @MainActor [weak self] in - do { - try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - self?.activeSavedPath = updated - } - } catch { - logger.error("Failed to record send failure: \(error.localizedDescription)") - } - } - } - - isRunning = false - pendingTag = nil - pendingDeviceID = nil - return - } - - // Wait for response with timeout - traceTask = Task { @MainActor in - do { - try await Task.sleep(for: .seconds(timeoutSeconds)) - - // Timeout - no response received - if !Task.isCancelled && pendingTag == tag { - logger.warning("Trace timeout for tag \(tag) after \(timeoutSeconds)s") - setError(L10n.Contacts.Contacts.Trace.Error.noResponse) - pendingPathHash = nil - - // Record failed run for saved paths - if let savedPath = activeSavedPath, - let dataStore { - let failedRun = TracePathRunDTO( - id: UUID(), - date: Date(), - success: false, - roundTripMs: 0, - hopsSNR: [] - ) - do { - try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - activeSavedPath = updated - } - } catch { - logger.error("Failed to record timeout: \(error.localizedDescription)") - } - } - - isRunning = false - pendingTag = nil - pendingDeviceID = nil - } - } catch { - // Task cancelled (response received) - } + // Check if we got a successful result to show the sheet + if let latestResult = completedResults.last, latestResult.success { + // First successful result triggers sheet presentation + if successCount == 1 { + result = latestResult + resultID = UUID() + } else { + // Update result for subsequent successful traces + result = latestResult } + } + + // Small buffer between traces (unless this is the last one) + if traceIndex < batchSize { + // Check cancellation before sleeping + if batchCancelled { break } + try? await Task.sleep(for: .milliseconds(Self.interTraceBufferMs)) + // Check cancellation after sleeping + if batchCancelled { break } + } } - // MARK: - Batch Trace Execution - - /// Execute multiple traces in batch mode - func runBatchTrace() async { - guard batchEnabled else { - await runTrace() - return - } - - // Reset batch state before any early returns - clearBatchState() - batchCancelled = false - resultID = nil - clearError() - - guard let session, - !outboundPath.isEmpty else { return } - - // Match to saved path if not already running one - if activeSavedPath == nil { - if let matchedPath = await findMatchingSavedPath() { - activeSavedPath = matchedPath - logger.info("Matched path to saved path: \(matchedPath.name)") - } - } - - isRunning = true - result = nil - - // Execute traces sequentially - for traceIndex in 1...batchSize { - // Check cancellation before starting the next trace - if batchCancelled { break } - - currentTraceIndex = traceIndex - - // Run single trace and wait for result - await executeSingleTrace(session: session) - - // Check if we got a successful result to show the sheet - if let latestResult = completedResults.last, latestResult.success { - // First successful result triggers sheet presentation - if successCount == 1 { - result = latestResult - resultID = UUID() - } else { - // Update result for subsequent successful traces - result = latestResult - } - } - - // Small buffer between traces (unless this is the last one) - if traceIndex < batchSize { - // Check cancellation before sleeping - if batchCancelled { break } - try? await Task.sleep(for: .milliseconds(Self.interTraceBufferMs)) - // Check cancellation after sleeping - if batchCancelled { break } - } - } + isRunning = false + currentTraceIndex = 0 - isRunning = false - currentTraceIndex = 0 - - // If batch completed but all traces failed, show error - if isBatchComplete && successCount == 0 { - setError(L10n.Contacts.Contacts.Trace.Error.allFailed(batchSize)) - } + // If batch completed but all traces failed, show error + if isBatchComplete, successCount == 0 { + setError(L10n.Contacts.Contacts.Trace.Error.allFailed(batchSize)) + } + } + + /// Execute a single trace within a batch, storing result in completedResults + private func executeSingleTrace(session: MeshCoreSession) async { + pendingPathHash = fullPathBytes + + // Generate random tag for correlation + let tag = UInt32.random(in: 0...UInt32.max) + pendingTag = tag + pendingDeviceID = connectedDevice?.radioID + traceStartTime = Date() + + let pathData = Data(fullPathBytes) + + let timeoutSeconds: Double + do { + let sentInfo = try await session.sendTrace( + tag: tag, + authCode: 0, + flags: effectiveTraceMode, + path: pathData + ) + timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentInfo.suggestedTimeoutMs) + logger.info("Sent batch trace \(currentTraceIndex)/\(batchSize) with tag \(tag), timeout: \(timeoutSeconds)s") + } catch { + logger.error("Failed to send trace: \(error.localizedDescription)") + let failedResult = TraceResult.sendFailed( + L10n.Contacts.Contacts.Trace.Error.sendFailed, + attemptedPath: pendingPathHash ?? [], + hashSize: hashSize + ) + completedResults.append(failedResult) + recordFailedRun() + pendingPathHash = nil + pendingTag = nil + return } - /// Execute a single trace within a batch, storing result in completedResults - private func executeSingleTrace(session: MeshCoreSession) async { - pendingPathHash = fullPathBytes - - // Generate random tag for correlation - let tag = UInt32.random(in: 0...UInt32.max) - pendingTag = tag - pendingDeviceID = connectedDevice?.radioID - traceStartTime = Date() - - let pathData = Data(fullPathBytes) + // Wait for response with timeout using continuation + // Store continuation so handleTraceResponse can resume it immediately + await withCheckedContinuation { (continuation: CheckedContinuation) in + traceContinuation = continuation - let timeoutSeconds: Double + traceTask = Task { @MainActor in do { - let sentInfo = try await session.sendTrace( - tag: tag, - authCode: 0, - flags: effectiveTraceMode, - path: pathData - ) - timeoutSeconds = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: sentInfo.suggestedTimeoutMs) - logger.info("Sent batch trace \(self.currentTraceIndex)/\(self.batchSize) with tag \(tag), timeout: \(timeoutSeconds)s") - } catch { - logger.error("Failed to send trace: \(error.localizedDescription)") - let failedResult = TraceResult.sendFailed( - L10n.Contacts.Contacts.Trace.Error.sendFailed, - attemptedPath: pendingPathHash ?? [], - hashSize: hashSize - ) - completedResults.append(failedResult) + try await Task.sleep(for: .seconds(timeoutSeconds)) + + // Timeout - resume continuation if still waiting + if traceContinuation != nil, pendingTag == tag { + logger.warning("Batch trace timeout for tag \(tag) after \(timeoutSeconds)s") + let timeoutResult = TraceResult.timeout(attemptedPath: pendingPathHash ?? [], hashSize: hashSize) + completedResults.append(timeoutResult) recordFailedRun() pendingPathHash = nil pendingTag = nil - return - } - // Wait for response with timeout using continuation - // Store continuation so handleTraceResponse can resume it immediately - await withCheckedContinuation { (continuation: CheckedContinuation) in - traceContinuation = continuation - - traceTask = Task { @MainActor in - do { - try await Task.sleep(for: .seconds(timeoutSeconds)) - - // Timeout - resume continuation if still waiting - if traceContinuation != nil && pendingTag == tag { - logger.warning("Batch trace timeout for tag \(tag) after \(timeoutSeconds)s") - let timeoutResult = TraceResult.timeout(attemptedPath: pendingPathHash ?? [], hashSize: hashSize) - completedResults.append(timeoutResult) - recordFailedRun() - pendingPathHash = nil - pendingTag = nil - - // Resume continuation atomically (only if not already resumed by handleTraceResponse) - if let continuation = traceContinuation { - traceContinuation = nil - continuation.resume() - } - } - } catch { - // Cancelled - handleTraceResponse already resumed continuation - } + // Resume continuation atomically (only if not already resumed by handleTraceResponse) + if let continuation = traceContinuation { + traceContinuation = nil + continuation.resume() } + } + } catch { + // Cancelled - handleTraceResponse already resumed continuation } + } } + } + + /// Record a failed run for saved paths + private func recordFailedRun() { + guard let savedPath = activeSavedPath, + let dataStore else { return } + + let failedRun = TracePathRunDTO( + id: UUID(), + date: Date(), + success: false, + roundTripMs: 0, + hopsSNR: [] + ) - /// Record a failed run for saved paths - private func recordFailedRun() { - guard let savedPath = activeSavedPath, - let dataStore else { return } - - let failedRun = TracePathRunDTO( - id: UUID(), - date: Date(), - success: false, - roundTripMs: 0, - hopsSNR: [] - ) - - Task { @MainActor [weak self] in - do { - try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - self?.activeSavedPath = updated - } - } catch { - logger.error("Failed to record run: \(error.localizedDescription)") - } + Task { @MainActor [weak self] in + do { + try await dataStore.appendTracePathRun(pathID: savedPath.id, run: failedRun) + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + self?.activeSavedPath = updated } + } catch { + logger.error("Failed to record run: \(error.localizedDescription)") + } } + } - /// Cancel any running batch trace - func cancelBatchTrace() { - // Set cancel flag for batch loop - batchCancelled = true + /// Cancel any running batch trace + func cancelBatchTrace() { + // Set cancel flag for batch loop + batchCancelled = true - // Cancel current trace timeout task - traceTask?.cancel() - traceTask = nil + // Cancel current trace timeout task + traceTask?.cancel() + traceTask = nil - // Clear continuation if waiting (prevent leaked continuation) - if let continuation = traceContinuation { - traceContinuation = nil - continuation.resume() - } + // Clear continuation if waiting (prevent leaked continuation) + if let continuation = traceContinuation { + traceContinuation = nil + continuation.resume() + } - isRunning = false - currentTraceIndex = 0 - pendingTag = nil - pendingDeviceID = nil - pendingPathHash = nil + isRunning = false + currentTraceIndex = 0 + pendingTag = nil + pendingDeviceID = nil + pendingPathHash = nil + } + + /// Handle trace response from event stream + func handleTraceResponse(_ traceInfo: TraceInfo, radioID: UUID?) { + guard traceInfo.tag == pendingTag else { + logger.debug("Ignoring trace response with non-matching tag \(traceInfo.tag)") + return } - /// Handle trace response from event stream - func handleTraceResponse(_ traceInfo: TraceInfo, radioID: UUID?) { - guard traceInfo.tag == pendingTag else { - logger.debug("Ignoring trace response with non-matching tag \(traceInfo.tag)") - return - } + // Validate device ID if both are available; skip if either is nil + if let pending = pendingDeviceID, let received = radioID, pending != received { + logger.warning("Ignoring trace response from different device") + return + } - // Validate device ID if both are available; skip if either is nil - if let pending = pendingDeviceID, let received = radioID, pending != received { - logger.warning("Ignoring trace response from different device") - return - } + // Cancel timeout + traceTask?.cancel() + traceTask = nil - // Cancel timeout - traceTask?.cancel() - traceTask = nil + // Calculate duration + let durationMs = if let startTime = traceStartTime { + Int(Date().timeIntervalSince(startTime) * 1000) + } else { + 0 + } - // Calculate duration - let durationMs: Int - if let startTime = traceStartTime { - durationMs = Int(Date().timeIntervalSince(startTime) * 1000) + // Build hops from response using receiver attribution model: + // Each node's SNR shows what it measured when receiving. + // This answers "how well did this node receive the signal?" + var hops: [TraceHop] = [] + let deviceName = connectedDevice?.nodeName ?? L10n.Contacts.Contacts.Results.Hop.myDevice + let path = traceInfo.path + + let deviceLocation = bestAvailableLocation + let deviceLat = deviceLocation?.coordinate.latitude + let deviceLon = deviceLocation?.coordinate.longitude + + // Start node has no SNR (it transmitted first, didn't receive anything) + hops.append(TraceHop( + hashBytes: nil, + resolvedName: deviceName, + snr: 0, + isStartNode: true, + isEndNode: false, + latitude: deviceLat, + longitude: deviceLon + )) + + // Intermediate hops - each shows SNR it measured when receiving + for node in path where node.hashBytes != nil { + let resolvedName: String? + var latitude: Double? + var longitude: Double? + + if let bytes = node.hashBytes { + let matchingHop = outboundPath.first(where: { $0.hashBytes == bytes }) + let match = matchingHop.flatMap { resolveNode(for: $0) } ?? resolveNode(for: bytes) + + if let match { + resolvedName = match.resolvableName + if match.hasLocation { + latitude = match.latitude + longitude = match.longitude + } } else { - durationMs = 0 + resolvedName = matchingHop?.resolvedName } + } else { + resolvedName = nil + } + + hops.append(TraceHop( + hashBytes: node.hashBytes, + resolvedName: resolvedName, + snr: node.snr, + isStartNode: false, + isEndNode: false, + latitude: latitude, + longitude: longitude + )) + } - // Build hops from response using receiver attribution model: - // Each node's SNR shows what it measured when receiving. - // This answers "how well did this node receive the signal?" - var hops: [TraceHop] = [] - let deviceName = connectedDevice?.nodeName ?? L10n.Contacts.Contacts.Results.Hop.myDevice - let path = traceInfo.path - - let deviceLocation = bestAvailableLocation - let deviceLat = deviceLocation?.coordinate.latitude - let deviceLon = deviceLocation?.coordinate.longitude - - // Start node has no SNR (it transmitted first, didn't receive anything) - hops.append(TraceHop( - hashBytes: nil, - resolvedName: deviceName, - snr: 0, - isStartNode: true, - isEndNode: false, - latitude: deviceLat, - longitude: deviceLon - )) - - // Intermediate hops - each shows SNR it measured when receiving - for node in path where node.hashBytes != nil { - let resolvedName: String? - var latitude: Double? - var longitude: Double? - - if let bytes = node.hashBytes { - let matchingHop = outboundPath.first(where: { $0.hashBytes == bytes }) - let match = matchingHop.flatMap({ resolveNode(for: $0) }) ?? resolveNode(for: bytes) - - if let match { - resolvedName = match.resolvableName - if match.hasLocation { - latitude = match.latitude - longitude = match.longitude - } - } else { - resolvedName = matchingHop?.resolvedName - } - } else { - resolvedName = nil - } - - hops.append(TraceHop( - hashBytes: node.hashBytes, - resolvedName: resolvedName, - snr: node.snr, - isStartNode: false, - isEndNode: false, - latitude: latitude, - longitude: longitude - )) - } - - // End node shows SNR it measured when receiving - let endSnr = path.last?.snr ?? 0 - hops.append(TraceHop( - hashBytes: nil, - resolvedName: deviceName, - snr: endSnr, - isStartNode: false, - isEndNode: true, - latitude: deviceLat, - longitude: deviceLon - )) - - result = TraceResult( - hops: hops, - durationMs: durationMs, - success: true, - errorMessage: nil, - tracedPathBytes: pendingPathHash ?? [], - hashSize: hashSize - ) - - // In batch mode, store result and resume continuation - if batchEnabled, let result { - completedResults.append(result) - } else { - resultID = UUID() - isRunning = false - } + // End node shows SNR it measured when receiving + let endSnr = path.last?.snr ?? 0 + hops.append(TraceHop( + hashBytes: nil, + resolvedName: deviceName, + snr: endSnr, + isStartNode: false, + isEndNode: true, + latitude: deviceLat, + longitude: deviceLon + )) + + result = TraceResult( + hops: hops, + durationMs: durationMs, + success: true, + errorMessage: nil, + tracedPathBytes: pendingPathHash ?? [], + hashSize: hashSize + ) - // Resume continuation if waiting (enables immediate batch progression) - if let continuation = traceContinuation { - traceContinuation = nil - traceTask?.cancel() - continuation.resume() - } + // In batch mode, store result and resume continuation + if batchEnabled, let result { + completedResults.append(result) + } else { + resultID = UUID() + isRunning = false + } - pendingPathHash = nil - pendingTag = nil - pendingDeviceID = nil - traceStartTime = nil - - // Auto-append run if this is a saved path - if let savedPath = activeSavedPath, - let dataStore { - let hopsSNR = hops - .filter { !$0.isStartNode && !$0.isEndNode } - .map { $0.snr } - let runDTO = TracePathRunDTO( - id: UUID(), - date: Date(), - success: true, - roundTripMs: durationMs, - hopsSNR: hopsSNR - ) + // Resume continuation if waiting (enables immediate batch progression) + if let continuation = traceContinuation { + traceContinuation = nil + traceTask?.cancel() + continuation.resume() + } - Task { @MainActor [weak self] in - do { - try await dataStore.appendTracePathRun(pathID: savedPath.id, run: runDTO) - // Refresh saved path to get updated runs - if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { - self?.activeSavedPath = updated - } - logger.info("Appended run to saved path") - } catch { - logger.error("Failed to append run: \(error.localizedDescription)") - } - } + pendingPathHash = nil + pendingTag = nil + pendingDeviceID = nil + traceStartTime = nil + + // Auto-append run if this is a saved path + if let savedPath = activeSavedPath, + let dataStore { + let hopsSNR = hops + .filter { !$0.isStartNode && !$0.isEndNode } + .map(\.snr) + let runDTO = TracePathRunDTO( + id: UUID(), + date: Date(), + success: true, + roundTripMs: durationMs, + hopsSNR: hopsSNR + ) + + Task { @MainActor [weak self] in + do { + try await dataStore.appendTracePathRun(pathID: savedPath.id, run: runDTO) + // Refresh saved path to get updated runs + if let updated = try await dataStore.fetchSavedTracePath(id: savedPath.id) { + self?.activeSavedPath = updated + } + logger.info("Appended run to saved path") + } catch { + logger.error("Failed to append run: \(error.localizedDescription)") } - - logger.info("Trace completed: \(hops.count) hops, \(durationMs)ms") + } } - // MARK: - Testing Support + logger.info("Trace completed: \(hops.count) hops, \(durationMs)ms") + } + + // MARK: - Testing Support - #if DEBUG + #if DEBUG /// Test helper to set pending tag without running a full trace func setPendingTagForTesting(_ tag: UInt32) { - pendingTag = tag + pendingTag = tag } /// Test helper to set pending device ID func setPendingDeviceIDForTesting(_ radioID: UUID?) { - pendingDeviceID = radioID + pendingDeviceID = radioID } /// Test helper to set pending path hash func setPendingPathHashForTesting(_ pathHash: [UInt8]?) { - pendingPathHash = pathHash + pendingPathHash = pathHash } /// Test helper to set contacts for hash resolution func setContactsForTesting(_ contacts: [ContactDTO]) { - allContacts = contacts - availableRepeaters = contacts.filter { $0.type == .repeater } - availableRooms = contacts.filter { $0.type == .room } + allContacts = contacts + availableRepeaters = contacts.filter { $0.type == .repeater } + availableRooms = contacts.filter { $0.type == .room } } - #endif + #endif } // MARK: - HopPickerSource extension TracePathViewModel: HopPickerSource { - var currentHopCount: Int { outboundPath.count } - - /// Trace paths are uncapped; `nil` tells the shared picker not to gate adds - /// (the `HopPickerSource` default then makes `isPathFull` always `false`). - var hopLimit: Int? { nil } - - func appendHop(_ node: some RepeaterResolvable) { - addNode(node) - recordRecent(node.publicKey) - } - - func addCodes(_ input: String) -> CodeInputResult { - addRepeatersFromCodes(input) - } - - func classifyCodes(_ input: String) -> [HopCodeClassification] { - let existing = Set(outboundPath.map(\.hashBytes)) - return HopCodeParser.classify( - input: input, - hashSize: hashSize, - existingHashes: existing, - remainingCapacity: nil - ) { hash in - guard let match = resolveNode(for: hash) else { return nil } - return (match.publicKey, match.resolvableName) - } + var currentHopCount: Int { + outboundPath.count + } + + /// Trace paths are uncapped; `nil` tells the shared picker not to gate adds + /// (the `HopPickerSource` default then makes `isPathFull` always `false`). + var hopLimit: Int? { + nil + } + + func appendHop(_ node: some RepeaterResolvable) { + addNode(node) + recordRecent(node.publicKey) + } + + func addCodes(_ input: String) -> CodeInputResult { + addRepeatersFromCodes(input) + } + + func classifyCodes(_ input: String) -> [HopCodeClassification] { + let existing = Set(outboundPath.map(\.hashBytes)) + return HopCodeParser.classify( + input: input, + hashSize: hashSize, + existingHashes: existing, + remainingCapacity: nil + ) { hash in + guard let match = resolveNode(for: hash) else { return nil } + return (match.publicKey, match.resolvableName) } - - /// Trace hop widths the firmware accepts, in bytes (power-of-2 encoding). - private static let validTraceHashSizes = [1, 2, 4] - - /// When a pasted bulk entry's codes all share one valid trace width that the - /// radio can honor and differs from the active width, switch to it so the - /// codes parse instead of failing as invalid. Skips mixed-width, malformed, - /// or already-matching input so it never wipes a result redundantly. - func adoptHashSize(forPastedCodes input: String) { - guard connectedDevice?.supportsTraceHashSizeOverride == true, - let mode = Self.inferredTraceHashMode(from: input), - mode != effectiveTraceMode else { return } - setTraceHashMode(mode) - } - - /// The single trace hash mode (0/1/2 for 1/2/4 bytes) implied by a - /// comma-separated bulk paste, or `nil` when the codes are empty, non-hex, - /// odd-length, mixed-width, or not a valid power-of-2 trace width. - static func inferredTraceHashMode(from input: String) -> UInt8? { - let tokens = input - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - guard !tokens.isEmpty else { return nil } - - var width: Int? - for token in tokens { - guard token.count.isMultiple(of: 2), token.allSatisfy(\.isHexDigit) else { return nil } - let bytes = token.count / 2 - if let width, width != bytes { return nil } - width = bytes - } - guard let width, validTraceHashSizes.contains(width) else { return nil } - return UInt8(width.trailingZeroBitCount) - } - - func recordRecent(_ publicKey: Data) { - guard let radioID = currentRadioID else { return } - recentPublicKeys = recents.record(publicKey, into: recentPublicKeys, for: radioID) + } + + /// Trace hop widths the firmware accepts, in bytes (power-of-2 encoding). + private static let validTraceHashSizes = [1, 2, 4] + + /// When a pasted bulk entry's codes all share one valid trace width that the + /// radio can honor and differs from the active width, switch to it so the + /// codes parse instead of failing as invalid. Skips mixed-width, malformed, + /// or already-matching input so it never wipes a result redundantly. + func adoptHashSize(forPastedCodes input: String) { + guard connectedDevice?.supportsTraceHashSizeOverride == true, + let mode = Self.inferredTraceHashMode(from: input), + mode != effectiveTraceMode else { return } + setTraceHashMode(mode) + } + + /// The single trace hash mode (0/1/2 for 1/2/4 bytes) implied by a + /// comma-separated bulk paste, or `nil` when the codes are empty, non-hex, + /// odd-length, mixed-width, or not a valid power-of-2 trace width. + static func inferredTraceHashMode(from input: String) -> UInt8? { + let tokens = input + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !tokens.isEmpty else { return nil } + + var width: Int? + for token in tokens { + guard token.count.isMultiple(of: 2), token.allSatisfy(\.isHexDigit) else { return nil } + let bytes = token.count / 2 + if let width, width != bytes { return nil } + width = bytes } + guard let width, validTraceHashSizes.contains(width) else { return nil } + return UInt8(width.trailingZeroBitCount) + } + + func recordRecent(_ publicKey: Data) { + guard let radioID = currentRadioID else { return } + recentPublicKeys = recents.record(publicKey, into: recentPublicKeys, for: radioID) + } } diff --git a/MC1/Views/Tools/TracePath/TraceResult.swift b/MC1/Views/Tools/TracePath/TraceResult.swift index e23f1e75..6a8ae3b5 100644 --- a/MC1/Views/Tools/TracePath/TraceResult.swift +++ b/MC1/Views/Tools/TracePath/TraceResult.swift @@ -2,30 +2,30 @@ import Foundation /// Result of a trace operation struct TraceResult: Identifiable { - let id = UUID() - let hops: [TraceHop] - let durationMs: Int - let success: Bool - let errorMessage: String? - let tracedPathBytes: [UInt8] // Path that was actually traced - let hashSize: Int // Bytes per hop (1, 2, or 4) + let id = UUID() + let hops: [TraceHop] + let durationMs: Int + let success: Bool + let errorMessage: String? + let tracedPathBytes: [UInt8] // Path that was actually traced + let hashSize: Int // Bytes per hop (1, 2, or 4) - /// Comma-separated path string for display/copy, chunked by hash size - var tracedPathString: String { - let data = Data(tracedPathBytes) - return stride(from: 0, to: data.count, by: hashSize).map { start in - let end = min(start + hashSize, data.count) - return data[start.. TraceResult { - TraceResult(hops: [], durationMs: 0, success: false, - errorMessage: L10n.Contacts.Contacts.Trace.Error.noResponse, tracedPathBytes: attemptedPath, hashSize: hashSize) - } + static func timeout(attemptedPath: [UInt8], hashSize: Int) -> TraceResult { + TraceResult(hops: [], durationMs: 0, success: false, + errorMessage: L10n.Contacts.Contacts.Trace.Error.noResponse, tracedPathBytes: attemptedPath, hashSize: hashSize) + } - static func sendFailed(_ message: String, attemptedPath: [UInt8], hashSize: Int) -> TraceResult { - TraceResult(hops: [], durationMs: 0, success: false, - errorMessage: message, tracedPathBytes: attemptedPath, hashSize: hashSize) - } + static func sendFailed(_ message: String, attemptedPath: [UInt8], hashSize: Int) -> TraceResult { + TraceResult(hops: [], durationMs: 0, success: false, + errorMessage: message, tracedPathBytes: attemptedPath, hashSize: hashSize) + } } diff --git a/MC1/Views/Tools/TracePath/TraceResultHopRow.swift b/MC1/Views/Tools/TracePath/TraceResultHopRow.swift index 4667bd4f..d8117b00 100644 --- a/MC1/Views/Tools/TracePath/TraceResultHopRow.swift +++ b/MC1/Views/Tools/TracePath/TraceResultHopRow.swift @@ -1,95 +1,101 @@ -import SwiftUI import MC1Services +import SwiftUI // MARK: - Result Hop Row /// Row for displaying a hop in the trace results struct TraceResultHopRow: View { - let hop: TraceHop - let hopIndex: Int - var batchStats: (avg: Double, min: Double, max: Double)? - var latestSNR: Double? - var isBatchInProgress: Bool = false + let hop: TraceHop + let hopIndex: Int + var batchStats: (avg: Double, min: Double, max: Double)? + var latestSNR: Double? + var isBatchInProgress: Bool = false - /// SNR value to use for signal bars (latest during progress, average when complete) - private var displaySNR: Double { - if isBatchInProgress { - return latestSNR ?? hop.snr - } else if let stats = batchStats { - return stats.avg - } else { - return hop.snr - } + /// SNR value to use for signal bars (latest during progress, average when complete) + private var displaySNR: Double { + if isBatchInProgress { + latestSNR ?? hop.snr + } else if let stats = batchStats { + stats.avg + } else { + hop.snr } + } - private var snrQuality: SNRQuality { SNRQuality(snr: displaySNR) } + private var snrQuality: SNRQuality { + SNRQuality(snr: displaySNR) + } - private var signalLevel: Double { snrQuality.barLevel } + private var signalLevel: Double { + snrQuality.barLevel + } - private var signalColor: Color { snrQuality.color } + private var signalColor: Color { + snrQuality.color + } - var body: some View { - HStack { - VStack(alignment: .leading) { - // Node identifier - if hop.isStartNode { - Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) - Text(L10n.Contacts.Contacts.Results.Hop.started) - .font(.caption) - .foregroundStyle(.secondary) - } else if hop.isEndNode { - Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) - .foregroundStyle(.green) - Text(L10n.Contacts.Contacts.Results.Hop.received) - .font(.caption) - .foregroundStyle(.secondary) - } else if let hashDisplay = hop.hashDisplayString { - HStack { - Text(hashDisplay) - .font(.body.monospaced()) - .foregroundStyle(.secondary) - if let name = hop.resolvedName { - Text(name) - } - } - Text(L10n.Contacts.Contacts.Results.Hop.repeated) - .font(.caption) - .foregroundStyle(.secondary) - } - - // SNR display - batch mode shows avg with range, single shows plain SNR - if !hop.isStartNode { - if let stats = batchStats { - let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(1)) - Text(L10n.Contacts.Contacts.Results.Hop.avgSNR( - stats.avg.formatted(snrFormat), - stats.min.formatted(snrFormat), - stats.max.formatted(snrFormat) - )) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.Hop.avgSNRLabel( - stats.avg.formatted(snrFormat), - stats.min.formatted(snrFormat), - stats.max.formatted(snrFormat) - )) - } else { - Text(L10n.Contacts.Contacts.Results.Hop.snr(hop.snr.formatted(.number.precision(.fractionLength(2))))) - .font(.caption) - .foregroundStyle(.secondary) - } - } + var body: some View { + HStack { + VStack(alignment: .leading) { + // Node identifier + if hop.isStartNode { + Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) + Text(L10n.Contacts.Contacts.Results.Hop.started) + .font(.caption) + .foregroundStyle(.secondary) + } else if hop.isEndNode { + Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) + .foregroundStyle(.green) + Text(L10n.Contacts.Contacts.Results.Hop.received) + .font(.caption) + .foregroundStyle(.secondary) + } else if let hashDisplay = hop.hashDisplayString { + HStack { + Text(hashDisplay) + .font(.body.monospaced()) + .foregroundStyle(.secondary) + if let name = hop.resolvedName { + Text(name) } + } + Text(L10n.Contacts.Contacts.Results.Hop.repeated) + .font(.caption) + .foregroundStyle(.secondary) + } - Spacer() - - // Signal strength indicator (not for start node - it didn't receive) - if !hop.isStartNode { - Image(systemName: "cellularbars", variableValue: signalLevel) - .foregroundStyle(signalColor) - .font(.title2) - } + // SNR display - batch mode shows avg with range, single shows plain SNR + if !hop.isStartNode { + if let stats = batchStats { + let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(1)) + Text(L10n.Contacts.Contacts.Results.Hop.avgSNR( + stats.avg.formatted(snrFormat), + stats.min.formatted(snrFormat), + stats.max.formatted(snrFormat) + )) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.Hop.avgSNRLabel( + stats.avg.formatted(snrFormat), + stats.min.formatted(snrFormat), + stats.max.formatted(snrFormat) + )) + } else { + Text(L10n.Contacts.Contacts.Results.Hop.snr(hop.snr.formatted(.number.precision(.fractionLength(2))))) + .font(.caption) + .foregroundStyle(.secondary) + } } - .padding(.vertical, 4) + } + + Spacer() + + // Signal strength indicator (not for start node - it didn't receive) + if !hop.isStartNode { + Image(systemName: "cellularbars", variableValue: signalLevel) + .foregroundStyle(signalColor) + .font(.title2) + } } + .padding(.vertical, 4) + } } diff --git a/MC1/Views/Tools/TracePath/TraceResultsSectionView.swift b/MC1/Views/Tools/TracePath/TraceResultsSectionView.swift index 639b6fca..c5320177 100644 --- a/MC1/Views/Tools/TracePath/TraceResultsSectionView.swift +++ b/MC1/Views/Tools/TracePath/TraceResultsSectionView.swift @@ -1,80 +1,80 @@ -import SwiftUI import MC1Services +import SwiftUI /// Section displaying trace result hops, RTT info, distance, and save action struct TraceResultsSectionView: View { - @Environment(\.appTheme) private var theme - let result: TraceResult - @Bindable var viewModel: TracePathViewModel - @Binding var saveHapticTrigger: Int - @Binding var showingDistanceInfo: Bool + @Environment(\.appTheme) private var theme + let result: TraceResult + @Bindable var viewModel: TracePathViewModel + @Binding var saveHapticTrigger: Int + @Binding var showingDistanceInfo: Bool - var body: some View { - Section { - if result.success { - ForEach(Array(result.hops.enumerated()), id: \.element.id) { index, hop in - TraceResultHopRow( - hop: hop, - hopIndex: index, - batchStats: viewModel.batchEnabled ? viewModel.hopStats(at: index) : nil, - latestSNR: viewModel.batchEnabled ? viewModel.latestHopSNR(at: index) : nil, - isBatchInProgress: viewModel.isBatchInProgress - ) - } + var body: some View { + Section { + if result.success { + ForEach(Array(result.hops.enumerated()), id: \.element.id) { index, hop in + TraceResultHopRow( + hop: hop, + hopIndex: index, + batchStats: viewModel.batchEnabled ? viewModel.hopStats(at: index) : nil, + latestSNR: viewModel.batchEnabled ? viewModel.latestHopSNR(at: index) : nil, + isBatchInProgress: viewModel.isBatchInProgress + ) + } - // Batch status row (progress or completion) - if viewModel.batchEnabled && (viewModel.isBatchInProgress || viewModel.isBatchComplete) { - HStack { - if viewModel.isBatchComplete { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text(L10n.Contacts.Contacts.Results.batchSuccess(viewModel.successCount, viewModel.batchSize)) - .foregroundStyle(.secondary) - } else { - ProgressView() - .controlSize(.small) - Text(L10n.Contacts.Contacts.Results.batchProgress(viewModel.currentTraceIndex, viewModel.batchSize)) - .foregroundStyle(.secondary) - } - Spacer() - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel( - viewModel.isBatchComplete - ? L10n.Contacts.Contacts.Results.batchCompleteLabel(viewModel.successCount, viewModel.batchSize) - : L10n.Contacts.Contacts.Results.batchProgressLabel(viewModel.currentTraceIndex, viewModel.batchSize) - ) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - } + // Batch status row (progress or completion) + if viewModel.batchEnabled, viewModel.isBatchInProgress || viewModel.isBatchComplete { + HStack { + if viewModel.isBatchComplete { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(L10n.Contacts.Contacts.Results.batchSuccess(viewModel.successCount, viewModel.batchSize)) + .foregroundStyle(.secondary) + } else { + ProgressView() + .controlSize(.small) + Text(L10n.Contacts.Contacts.Results.batchProgress(viewModel.currentTraceIndex, viewModel.batchSize)) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel( + viewModel.isBatchComplete + ? L10n.Contacts.Contacts.Results.batchCompleteLabel(viewModel.successCount, viewModel.batchSize) + : L10n.Contacts.Contacts.Results.batchProgressLabel(viewModel.currentTraceIndex, viewModel.batchSize) + ) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + } - // Duration row with batch or single display - if viewModel.batchEnabled && viewModel.successCount > 0 { - BatchRTTRow(viewModel: viewModel) - } else if viewModel.isRunningSavedPath, let previous = viewModel.previousRun { - ComparisonRowView(currentMs: result.durationMs, previousRun: previous, viewModel: viewModel) - } else { - HStack { - Text(L10n.Contacts.Contacts.PathDetail.roundTrip) - .foregroundStyle(.secondary) - Spacer() - Text("\(result.durationMs) ms") - .font(.body.monospacedDigit()) - } - } + // Duration row with batch or single display + if viewModel.batchEnabled, viewModel.successCount > 0 { + BatchRTTRow(viewModel: viewModel) + } else if viewModel.isRunningSavedPath, let previous = viewModel.previousRun { + ComparisonRowView(currentMs: result.durationMs, previousRun: previous, viewModel: viewModel) + } else { + HStack { + Text(L10n.Contacts.Contacts.PathDetail.roundTrip) + .foregroundStyle(.secondary) + Spacer() + Text("\(result.durationMs) ms") + .font(.body.monospacedDigit()) + } + } - // Total distance row - TotalDistanceRow(viewModel: viewModel, result: result, showingDistanceInfo: $showingDistanceInfo) + // Total distance row + TotalDistanceRow(viewModel: viewModel, result: result, showingDistanceInfo: $showingDistanceInfo) - // Save path action (only for successful traces when not running a saved path) - if !viewModel.isRunningSavedPath { - SavePathRowView(viewModel: viewModel, saveHapticTrigger: $saveHapticTrigger) - } - } else if let error = result.errorMessage { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) - } + // Save path action (only for successful traces when not running a saved path) + if !viewModel.isRunningSavedPath { + SavePathRowView(viewModel: viewModel, saveHapticTrigger: $saveHapticTrigger) } - .themedRowBackground(theme) + } else if let error = result.errorMessage { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } } + .themedRowBackground(theme) + } } diff --git a/MC1/Views/Tools/TracePath/TraceResultsSheet.swift b/MC1/Views/Tools/TracePath/TraceResultsSheet.swift index f48580b4..a3b5e311 100644 --- a/MC1/Views/Tools/TracePath/TraceResultsSheet.swift +++ b/MC1/Views/Tools/TracePath/TraceResultsSheet.swift @@ -1,71 +1,71 @@ -import SwiftUI import MC1Services +import SwiftUI /// Full-screen sheet displaying trace results struct TraceResultsSheet: View { - let result: TraceResult - @Bindable var viewModel: TracePathViewModel - @Environment(\.appTheme) private var theme - @Environment(\.dismiss) private var dismiss + let result: TraceResult + @Bindable var viewModel: TracePathViewModel + @Environment(\.appTheme) private var theme + @Environment(\.dismiss) private var dismiss - // Save dialog state - @State private var saveHapticTrigger = 0 - @State private var copyHapticTrigger = 0 - @State private var showingDistanceInfo = false + // Save dialog state + @State private var saveHapticTrigger = 0 + @State private var copyHapticTrigger = 0 + @State private var showingDistanceInfo = false - var body: some View { - NavigationStack { - List { - TraceResultsSectionView( - result: result, - viewModel: viewModel, - saveHapticTrigger: $saveHapticTrigger, - showingDistanceInfo: $showingDistanceInfo - ) - roundTripPathSection - .themedRowBackground(theme) - } - .themedCanvas(theme) - .navigationDestination(for: TracePathRoute.self) { route in - switch route { - case .savedPathDetail(let savedPath): - SavedPathDetailView(savedPath: savedPath) - } - } - .navigationTitle(L10n.Contacts.Contacts.Results.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(L10n.Contacts.Contacts.Results.dismiss, systemImage: "xmark") { - dismiss() - } - } - } - .sensoryFeedback(.success, trigger: saveHapticTrigger) - .sensoryFeedback(.success, trigger: copyHapticTrigger) + var body: some View { + NavigationStack { + List { + TraceResultsSectionView( + result: result, + viewModel: viewModel, + saveHapticTrigger: $saveHapticTrigger, + showingDistanceInfo: $showingDistanceInfo + ) + roundTripPathSection + .themedRowBackground(theme) + } + .themedCanvas(theme) + .navigationDestination(for: TracePathRoute.self) { route in + switch route { + case let .savedPathDetail(savedPath): + SavedPathDetailView(savedPath: savedPath) + } + } + .navigationTitle(L10n.Contacts.Contacts.Results.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(L10n.Contacts.Contacts.Results.dismiss, systemImage: "xmark") { + dismiss() + } } + } + .sensoryFeedback(.success, trigger: saveHapticTrigger) + .sensoryFeedback(.success, trigger: copyHapticTrigger) } + } - // MARK: - Outbound Path Section + // MARK: - Outbound Path Section - private var roundTripPathSection: some View { - Section { - HStack { - Text(result.tracedPathString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + private var roundTripPathSection: some View { + Section { + HStack { + Text(result.tracedPathString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) - Spacer() + Spacer() - Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - UIPasteboard.general.string = result.tracedPathString - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - } - } header: { - Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) + Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = result.tracedPathString } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + } header: { + Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) } + } } diff --git a/MC1/Views/WhatsNew/WhatsNewSheet.swift b/MC1/Views/WhatsNew/WhatsNewSheet.swift index 88e5113d..5215a549 100644 --- a/MC1/Views/WhatsNew/WhatsNewSheet.swift +++ b/MC1/Views/WhatsNew/WhatsNewSheet.swift @@ -3,105 +3,105 @@ import SwiftUI /// The "What's New" sheet. Non-safety chrome, so glass on the Continue button is /// allowed; Continue and swipe-to-dismiss share one dismiss path that persists the baseline. struct WhatsNewSheet: View { - let release: WhatsNewRelease + let release: WhatsNewRelease - @Environment(\.dismiss) private var dismiss + @Environment(\.dismiss) private var dismiss - fileprivate enum Metrics { - static let titleTopPadding: CGFloat = 32 - static let rowSpacing: CGFloat = 28 - static let titleToRowsSpacing: CGFloat = 36 - static let symbolColumnWidth: CGFloat = 44 - static let symbolToTextSpacing: CGFloat = 16 - static let titleToBodySpacing: CGFloat = 4 - } + fileprivate enum Metrics { + static let titleTopPadding: CGFloat = 32 + static let rowSpacing: CGFloat = 28 + static let titleToRowsSpacing: CGFloat = 36 + static let symbolColumnWidth: CGFloat = 44 + static let symbolToTextSpacing: CGFloat = 16 + static let titleToBodySpacing: CGFloat = 4 + } - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: Metrics.titleToRowsSpacing) { - Text(L10n.WhatsNew.WhatsNew.title) - .font(.largeTitle.bold()) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .center) - .accessibilityHeading(.h1) - .padding(.top, Metrics.titleTopPadding) + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Metrics.titleToRowsSpacing) { + Text(L10n.WhatsNew.WhatsNew.title) + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .accessibilityHeading(.h1) + .padding(.top, Metrics.titleTopPadding) - VStack(alignment: .leading, spacing: Metrics.rowSpacing) { - ForEach(release.items) { item in - WhatsNewRow(item: item) - } - } - } - .padding() - } - .safeAreaInset(edge: .bottom) { - Button { - dismiss() - } label: { - Text(L10n.WhatsNew.WhatsNew.continueButton) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .liquidGlassProminentButtonStyle() - .padding() + VStack(alignment: .leading, spacing: Metrics.rowSpacing) { + ForEach(release.items) { item in + WhatsNewRow(item: item) + } } - .presentationDragIndicator(.hidden) + } + .padding() + } + .safeAreaInset(edge: .bottom) { + Button { + dismiss() + } label: { + Text(L10n.WhatsNew.WhatsNew.continueButton) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .padding() } + .presentationDragIndicator(.hidden) + } } /// A decorative SF Symbol plus the feature's title and description; VoiceOver reads /// the pair as one element. private struct WhatsNewRow: View { - let item: WhatsNewItem + let item: WhatsNewItem - var body: some View { - HStack(alignment: .top, spacing: WhatsNewSheet.Metrics.symbolToTextSpacing) { - Image(systemName: item.symbol) - .font(.title) - .foregroundStyle(.tint) - .frame(width: WhatsNewSheet.Metrics.symbolColumnWidth) - .accessibilityHidden(true) + var body: some View { + HStack(alignment: .top, spacing: WhatsNewSheet.Metrics.symbolToTextSpacing) { + Image(systemName: item.symbol) + .font(.title) + .foregroundStyle(.tint) + .frame(width: WhatsNewSheet.Metrics.symbolColumnWidth) + .accessibilityHidden(true) - VStack(alignment: .leading, spacing: WhatsNewSheet.Metrics.titleToBodySpacing) { - Text(item.title) - .font(.headline) - Text(item.description) - .font(.subheadline) - .foregroundStyle(.secondary) - } + VStack(alignment: .leading, spacing: WhatsNewSheet.Metrics.titleToBodySpacing) { + Text(item.title) + .font(.headline) + Text(item.description) + .font(.subheadline) + .foregroundStyle(.secondary) + } - Spacer(minLength: 0) - } - .accessibilityElement(children: .combine) + Spacer(minLength: 0) } + .accessibilityElement(children: .combine) + } } #Preview { - Color.clear.sheet(isPresented: .constant(true)) { - WhatsNewSheet(release: .preview) - } + Color.clear.sheet(isPresented: .constant(true)) { + WhatsNewSheet(release: .preview) + } } private extension WhatsNewRelease { - static let preview = WhatsNewRelease( - version: WhatsNewVersion(major: 1, minor: 1), - items: [ - WhatsNewItem( - symbol: "sparkles", - title: "Faster Messaging", - description: "Messages now send and sync more quickly across your mesh." - ), - WhatsNewItem( - symbol: "antenna.radiowaves.left.and.right", - title: "Stronger Multi-Hop Routing", - description: "Improved route handling keeps distant nodes connected." - ), - WhatsNewItem( - symbol: "lock.shield", - title: "Private by Default", - description: "Your messages stay encrypted end to end, on device." - ) - ] - ) + static let preview = WhatsNewRelease( + version: WhatsNewVersion(major: 1, minor: 1), + items: [ + WhatsNewItem( + symbol: "sparkles", + title: "Faster Messaging", + description: "Messages now send and sync more quickly across your mesh." + ), + WhatsNewItem( + symbol: "antenna.radiowaves.left.and.right", + title: "Stronger Multi-Hop Routing", + description: "Improved route handling keeps distant nodes connected." + ), + WhatsNewItem( + symbol: "lock.shield", + title: "Private by Default", + description: "Your messages stay encrypted end to end, on device." + ) + ] + ) } diff --git a/MC1Services/Package.swift b/MC1Services/Package.swift index bb3070bd..c2978fe8 100644 --- a/MC1Services/Package.swift +++ b/MC1Services/Package.swift @@ -2,25 +2,25 @@ import PackageDescription let package = Package( - name: "MC1Services", - platforms: [.iOS(.v18), .macOS(.v15)], - products: [ - .library(name: "MC1Services", targets: ["MC1Services"]) - ], - dependencies: [ - .package(path: "../MeshCore") - ], - targets: [ - .target( - name: "MC1Services", - dependencies: ["MeshCore"] - ), - .testTarget( - name: "MC1ServicesTests", - dependencies: [ - "MC1Services", - .product(name: "MeshCoreTestSupport", package: "MeshCore") - ] - ) - ] + name: "MC1Services", + platforms: [.iOS(.v18), .macOS(.v15)], + products: [ + .library(name: "MC1Services", targets: ["MC1Services"]) + ], + dependencies: [ + .package(path: "../MeshCore") + ], + targets: [ + .target( + name: "MC1Services", + dependencies: ["MeshCore"] + ), + .testTarget( + name: "MC1ServicesTests", + dependencies: [ + "MC1Services", + .product(name: "MeshCoreTestSupport", package: "MeshCore") + ] + ) + ] ) diff --git a/MC1Services/Sources/MC1Services/Connection/BLEReconnectionCoordinator.swift b/MC1Services/Sources/MC1Services/Connection/BLEReconnectionCoordinator.swift index 34322df4..57f296c8 100644 --- a/MC1Services/Sources/MC1Services/Connection/BLEReconnectionCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Connection/BLEReconnectionCoordinator.swift @@ -9,270 +9,269 @@ import OSLog /// from session rebuild logic. @MainActor final class BLEReconnectionCoordinator { + private let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEReconnectionCoordinator") - private let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEReconnectionCoordinator") + weak var delegate: BLEReconnectionDelegate? - weak var delegate: BLEReconnectionDelegate? + /// The device ID for the reconnect cycle currently claimed by + /// `handleEnteringAutoReconnect`. Completions are accepted only when this matches + /// the completing device. A nil value (entry suppressed, or manually superseded) + /// rejects late completions to prevent them from racing a new flow. + var reconnectingDeviceID: UUID? { + activeCycle?.deviceID + } - /// The device ID for the reconnect cycle currently claimed by - /// `handleEnteringAutoReconnect`. Completions are accepted only when this matches - /// the completing device. A nil value (entry suppressed, or manually superseded) - /// rejects late completions to prevent them from racing a new flow. - var reconnectingDeviceID: UUID? { - activeCycle?.deviceID - } + private struct ReconnectCycle { + let deviceID: UUID + let generation: Int + var uiTimedOut: Bool + } - private struct ReconnectCycle { - let deviceID: UUID - let generation: Int - var uiTimedOut: Bool - } + private var activeCycle: ReconnectCycle? - private var activeCycle: ReconnectCycle? + private var timeoutTask: Task? - private var timeoutTask: Task? + /// Incremented each time a reconnection cycle starts, used to detect stale rebuilds and retries. + private(set) var reconnectGeneration = 0 - /// Incremented each time a reconnection cycle starts, used to detect stale rebuilds and retries. - private(set) var reconnectGeneration = 0 + /// UI timeout duration before transitioning from "connecting" to "disconnected". + /// iOS auto-reconnect continues in the background even after this fires. + private let uiTimeoutDuration: TimeInterval - /// UI timeout duration before transitioning from "connecting" to "disconnected". - /// iOS auto-reconnect continues in the background even after this fires. - private let uiTimeoutDuration: TimeInterval + /// When the current reconnect UI window started. Used to bound re-arms. + private var reconnectUIWindowStart: Date? - /// When the current reconnect UI window started. Used to bound re-arms. - private var reconnectUIWindowStart: Date? + /// Maximum time the UI can stay in `.connecting` state, even if BLE is still + /// auto-reconnecting. Prevents indefinite connecting UI when transport is stuck. + private let maxConnectingUIWindow: TimeInterval - /// Maximum time the UI can stay in `.connecting` state, even if BLE is still - /// auto-reconnecting. Prevents indefinite connecting UI when transport is stuck. - private let maxConnectingUIWindow: TimeInterval + init(uiTimeoutDuration: TimeInterval = 15, maxConnectingUIWindow: TimeInterval = 60) { + self.uiTimeoutDuration = uiTimeoutDuration + self.maxConnectingUIWindow = maxConnectingUIWindow + } - init(uiTimeoutDuration: TimeInterval = 15, maxConnectingUIWindow: TimeInterval = 60) { - self.uiTimeoutDuration = uiTimeoutDuration - self.maxConnectingUIWindow = maxConnectingUIWindow + /// Handles the device entering iOS auto-reconnect phase. + /// Tears down session layer and starts a UI timeout. + func handleEnteringAutoReconnect(deviceID: UUID) async { + guard let delegate else { return } + logger.info("[BLE] handleEnteringAutoReconnect: device=\(deviceID.uuidString.prefix(8)), connectionState=\(String(describing: delegate.connectionState))") + + guard delegate.connectionIntent.wantsConnection else { + logger.info("Ignoring auto-reconnect: user disconnected") + await delegate.disconnectTransport() + return } - /// Handles the device entering iOS auto-reconnect phase. - /// Tears down session layer and starts a UI timeout. - func handleEnteringAutoReconnect(deviceID: UUID) async { - guard let delegate else { return } - logger.info("[BLE] handleEnteringAutoReconnect: device=\(deviceID.uuidString.prefix(8)), connectionState=\(String(describing: delegate.connectionState))") - - guard delegate.connectionIntent.wantsConnection else { - logger.info("Ignoring auto-reconnect: user disconnected") - await delegate.disconnectTransport() - return - } - - // C3 fix: set connecting state BEFORE awaiting teardown so that - // handleReconnectionComplete() sees .connecting even if it runs - // during the teardown await. - delegate.setConnectionState(.connecting) - reconnectGeneration += 1 - activeCycle = ReconnectCycle(deviceID: deviceID, generation: reconnectGeneration, uiTimedOut: false) - reconnectUIWindowStart = Date() - - // Tear down session layer (it's invalid now) - await delegate.teardownSessionForReconnect() - - // Start UI timeout - logger.info("[BLE] Arming UI timeout: \(uiTimeoutDuration)s for device \(deviceID.uuidString.prefix(8))") - armTimeout(deviceID: deviceID, generation: reconnectGeneration) + // C3 fix: set connecting state BEFORE awaiting teardown so that + // handleReconnectionComplete() sees .connecting even if it runs + // during the teardown await. + delegate.setConnectionState(.connecting) + reconnectGeneration += 1 + activeCycle = ReconnectCycle(deviceID: deviceID, generation: reconnectGeneration, uiTimedOut: false) + reconnectUIWindowStart = Date() + + // Tear down session layer (it's invalid now) + await delegate.teardownSessionForReconnect() + + // Start UI timeout + logger.info("[BLE] Arming UI timeout: \(uiTimeoutDuration)s for device \(deviceID.uuidString.prefix(8))") + armTimeout(deviceID: deviceID, generation: reconnectGeneration) + } + + /// Handles iOS auto-reconnect completion. Cancels the UI timeout + /// and delegates session rebuild to ConnectionManager. + func handleReconnectionComplete(deviceID: UUID) async { + guard let delegate else { return } + logger.info("[BLE] handleReconnectionComplete: device=\(deviceID.uuidString.prefix(8))") + + guard delegate.connectionIntent.wantsConnection else { + cancelTimeout() + logger.info("Ignoring reconnection: user disconnected") + clearActiveCycle(invalidateGeneration: true) + await delegate.disconnectTransport() + return } - /// Handles iOS auto-reconnect completion. Cancels the UI timeout - /// and delegates session rebuild to ConnectionManager. - func handleReconnectionComplete(deviceID: UUID) async { - guard let delegate else { return } - logger.info("[BLE] handleReconnectionComplete: device=\(deviceID.uuidString.prefix(8))") - - guard delegate.connectionIntent.wantsConnection else { - cancelTimeout() - logger.info("Ignoring reconnection: user disconnected") - clearActiveCycle(invalidateGeneration: true) - await delegate.disconnectTransport() - return - } - - // Reject completions for cycles we didn't claim — an orphaned completion - // would race the new flow. Don't cancel the active timeout — the current - // reconnect retains its fallback. - guard activeCycle?.deviceID == deviceID else { - let claim = reconnectingDeviceID?.uuidString.prefix(8) ?? "no claim" - logger.warning("[BLE] Ignoring auto-reconnect completion for \(deviceID.uuidString.prefix(8)): \(claim)") - return - } - - let completedAfterUITimeout = activeCycle?.uiTimedOut == true - if completedAfterUITimeout { - logger.info("[BLE] Accepting auto-reconnect completion after UI timeout for \(deviceID.uuidString.prefix(8))") - } - - // This completion is for our device — safe to cancel timeout - cancelTimeout() - clearActiveCycle(invalidateGeneration: false) - - // Accept both disconnected (normal) and connecting (auto-reconnect in progress) - let state = delegate.connectionState - guard state == .disconnected || state == .connecting else { - logger.info("Ignoring reconnection: already \(String(describing: state))") - return - } - - reconnectGeneration += 1 - let expectedGeneration = reconnectGeneration - - delegate.setConnectionState(.connecting) - - do { - try await delegate.rebuildSession(deviceID: deviceID) - } catch { - logger.warning("[BLE] Auto-reconnect session rebuild failed: \(error.localizedDescription) - retrying in 2s") - await retryRebuild(deviceID: deviceID, expectedGeneration: expectedGeneration) - } + // Reject completions for cycles we didn't claim — an orphaned completion + // would race the new flow. Don't cancel the active timeout — the current + // reconnect retains its fallback. + guard activeCycle?.deviceID == deviceID else { + let claim = reconnectingDeviceID?.uuidString.prefix(8) ?? "no claim" + logger.warning("[BLE] Ignoring auto-reconnect completion for \(deviceID.uuidString.prefix(8)): \(claim)") + return } - /// Restarts the UI timeout without tearing down the session. - /// Used when user taps Connect while iOS auto-reconnect is already in progress. - func restartTimeout(deviceID: UUID) { - if activeCycle?.deviceID != deviceID { - reconnectGeneration += 1 - activeCycle = ReconnectCycle(deviceID: deviceID, generation: reconnectGeneration, uiTimedOut: false) - } else { - activeCycle?.uiTimedOut = false - } - reconnectUIWindowStart = Date() - guard let generation = activeCycle?.generation else { return } - armTimeout(deviceID: deviceID, generation: generation) + let completedAfterUITimeout = activeCycle?.uiTimedOut == true + if completedAfterUITimeout { + logger.info("[BLE] Accepting auto-reconnect completion after UI timeout for \(deviceID.uuidString.prefix(8))") } - private func armTimeout(deviceID: UUID, generation: Int) { - cancelTimeout() - timeoutTask = Task { [weak self, uiTimeoutDuration] in - try? await Task.sleep(for: .seconds(uiTimeoutDuration)) - guard !Task.isCancelled, let self else { return } - await self.handleUITimeout(deviceID: deviceID, generation: generation) - } + // This completion is for our device — safe to cancel timeout + cancelTimeout() + clearActiveCycle(invalidateGeneration: false) + + // Accept both disconnected (normal) and connecting (auto-reconnect in progress) + let state = delegate.connectionState + guard state == .disconnected || state == .connecting else { + logger.info("Ignoring reconnection: already \(String(describing: state))") + return } - /// Cancels the UI timeout timer. - func cancelTimeout() { - if timeoutTask != nil { - logger.debug("[BLE] Cancelling UI timeout") - } - timeoutTask?.cancel() - timeoutTask = nil + reconnectGeneration += 1 + let expectedGeneration = reconnectGeneration + + delegate.setConnectionState(.connecting) + + do { + try await delegate.rebuildSession(deviceID: deviceID) + } catch { + logger.warning("[BLE] Auto-reconnect session rebuild failed: \(error.localizedDescription) - retrying in 2s") + await retryRebuild(deviceID: deviceID, expectedGeneration: expectedGeneration) + } + } + + /// Restarts the UI timeout without tearing down the session. + /// Used when user taps Connect while iOS auto-reconnect is already in progress. + func restartTimeout(deviceID: UUID) { + if activeCycle?.deviceID != deviceID { + reconnectGeneration += 1 + activeCycle = ReconnectCycle(deviceID: deviceID, generation: reconnectGeneration, uiTimedOut: false) + } else { + activeCycle?.uiTimedOut = false } + reconnectUIWindowStart = Date() + guard let generation = activeCycle?.generation else { return } + armTimeout(deviceID: deviceID, generation: generation) + } + + private func armTimeout(deviceID: UUID, generation: Int) { + cancelTimeout() + timeoutTask = Task { [weak self, uiTimeoutDuration] in + try? await Task.sleep(for: .seconds(uiTimeoutDuration)) + guard !Task.isCancelled, let self else { return } + await handleUITimeout(deviceID: deviceID, generation: generation) + } + } - /// Clears the reconnecting device ID, used when manual connect supersedes auto-reconnect. - func clearReconnectingDevice() { - clearActiveCycle(invalidateGeneration: true) + /// Cancels the UI timeout timer. + func cancelTimeout() { + if timeoutTask != nil { + logger.debug("[BLE] Cancelling UI timeout") + } + timeoutTask?.cancel() + timeoutTask = nil + } + + /// Clears the reconnecting device ID, used when manual connect supersedes auto-reconnect. + func clearReconnectingDevice() { + clearActiveCycle(invalidateGeneration: true) + } + + private func clearActiveCycle(invalidateGeneration: Bool) { + guard activeCycle != nil else { return } + activeCycle = nil + reconnectUIWindowStart = nil + if invalidateGeneration { + reconnectGeneration += 1 } + } + + /// Retries a failed session rebuild after a short delay, aborting if the reconnect + /// generation has changed or the user disconnected during the wait. + private func retryRebuild(deviceID: UUID, expectedGeneration: Int) async { + guard let delegate else { return } - private func clearActiveCycle(invalidateGeneration: Bool) { - guard activeCycle != nil else { return } - activeCycle = nil - reconnectUIWindowStart = nil - if invalidateGeneration { - reconnectGeneration += 1 - } + try? await Task.sleep(for: .seconds(2)) + + guard expectedGeneration == reconnectGeneration else { + logger.info("New reconnect cycle started during rebuild retry delay - aborting stale retry") + return + } + guard delegate.connectionIntent.wantsConnection else { + logger.info("User disconnected during rebuild retry delay") + await delegate.handleReconnectionFailure() + return } - /// Retries a failed session rebuild after a short delay, aborting if the reconnect - /// generation has changed or the user disconnected during the wait. - private func retryRebuild(deviceID: UUID, expectedGeneration: Int) async { - guard let delegate else { return } - - try? await Task.sleep(for: .seconds(2)) - - guard expectedGeneration == reconnectGeneration else { - logger.info("New reconnect cycle started during rebuild retry delay - aborting stale retry") - return - } - guard delegate.connectionIntent.wantsConnection else { - logger.info("User disconnected during rebuild retry delay") - await delegate.handleReconnectionFailure() - return - } - - do { - try await delegate.rebuildSession(deviceID: deviceID) - logger.info("[BLE] Auto-reconnect session rebuild succeeded on retry") - } catch { - logger.error("[BLE] Auto-reconnect session rebuild failed on retry: \(error.localizedDescription)") - await delegate.handleReconnectionFailure() - } + do { + try await delegate.rebuildSession(deviceID: deviceID) + logger.info("[BLE] Auto-reconnect session rebuild succeeded on retry") + } catch { + logger.error("[BLE] Auto-reconnect session rebuild failed on retry: \(error.localizedDescription)") + await delegate.handleReconnectionFailure() + } + } + + private func handleUITimeout(deviceID: UUID, generation: Int) async { + guard let delegate, delegate.connectionState == .connecting else { return } + guard let activeCycle, + activeCycle.deviceID == deviceID, + activeCycle.generation == generation else { return } + let elapsed = Date().timeIntervalSince(reconnectUIWindowStart ?? Date()) + logger.info("[BLE] handleUITimeout: device=\(deviceID.uuidString.prefix(8)), elapsed=\(elapsed.formatted(.number.precision(.fractionLength(1))))s") + + // If BLE transport is still actively auto-reconnecting and we haven't + // exceeded the max connecting window, re-arm the timeout instead of + // forcing disconnected state. This handles the case where the timeout + // was armed before suspension and fires immediately on resume. + let transportAutoReconnecting = await delegate.isTransportAutoReconnecting() + + // Re-validate after the await: handleReconnectionComplete can run to + // completion during the suspension, and this timeout must not force + // a freshly rebuilt session back to disconnected. + guard delegate.connectionState == .connecting, + let currentCycle = self.activeCycle, + currentCycle.deviceID == deviceID, + currentCycle.generation == generation else { return } + + if transportAutoReconnecting, elapsed < maxConnectingUIWindow { + logger.info("[BLE] UI timeout fired but BLE still auto-reconnecting, re-arming (elapsed: \(elapsed.formatted(.number.precision(.fractionLength(1))))s)") + armTimeout(deviceID: deviceID, generation: generation) + return } - private func handleUITimeout(deviceID: UUID, generation: Int) async { - guard let delegate, delegate.connectionState == .connecting else { return } - guard let activeCycle, - activeCycle.deviceID == deviceID, - activeCycle.generation == generation else { return } - let elapsed = Date().timeIntervalSince(reconnectUIWindowStart ?? Date()) - logger.info("[BLE] handleUITimeout: device=\(deviceID.uuidString.prefix(8)), elapsed=\(elapsed.formatted(.number.precision(.fractionLength(1))))s") - - // If BLE transport is still actively auto-reconnecting and we haven't - // exceeded the max connecting window, re-arm the timeout instead of - // forcing disconnected state. This handles the case where the timeout - // was armed before suspension and fires immediately on resume. - let transportAutoReconnecting = await delegate.isTransportAutoReconnecting() - - // Re-validate after the await: handleReconnectionComplete can run to - // completion during the suspension, and this timeout must not force - // a freshly rebuilt session back to disconnected. - guard delegate.connectionState == .connecting, - let currentCycle = self.activeCycle, - currentCycle.deviceID == deviceID, - currentCycle.generation == generation else { return } - - if transportAutoReconnecting, elapsed < maxConnectingUIWindow { - logger.info("[BLE] UI timeout fired but BLE still auto-reconnecting, re-arming (elapsed: \(elapsed.formatted(.number.precision(.fractionLength(1))))s)") - armTimeout(deviceID: deviceID, generation: generation) - return - } - - if transportAutoReconnecting { - self.activeCycle?.uiTimedOut = true - } else { - clearActiveCycle(invalidateGeneration: true) - } - logger.warning( - "[BLE] Auto-reconnect UI timeout (\(uiTimeoutDuration)s) fired - transitioning UI to disconnected (iOS reconnect continues in background)" - ) - delegate.setConnectionState(.disconnected) - delegate.setConnectedDevice(nil) - await delegate.notifyConnectionLost() + if transportAutoReconnecting { + self.activeCycle?.uiTimedOut = true + } else { + clearActiveCycle(invalidateGeneration: true) } + logger.warning( + "[BLE] Auto-reconnect UI timeout (\(uiTimeoutDuration)s) fired - transitioning UI to disconnected (iOS reconnect continues in background)" + ) + delegate.setConnectionState(.disconnected) + delegate.setConnectedDevice(nil) + await delegate.notifyConnectionLost() + } } /// Delegate protocol for BLEReconnectionCoordinator. /// ConnectionManager implements this to provide session management. @MainActor protocol BLEReconnectionDelegate: AnyObject { - var connectionIntent: ConnectionIntent { get } - var connectionState: DeviceConnectionState { get } + var connectionIntent: ConnectionIntent { get } + var connectionState: DeviceConnectionState { get } - /// Sets the connection state (used by coordinator for state transitions). - func setConnectionState(_ state: DeviceConnectionState) + /// Sets the connection state (used by coordinator for state transitions). + func setConnectionState(_ state: DeviceConnectionState) - /// Sets the connected device (used by coordinator to clear on timeout). - func setConnectedDevice(_ device: DeviceDTO?) + /// Sets the connected device (used by coordinator to clear on timeout). + func setConnectedDevice(_ device: DeviceDTO?) - /// Tears down the current session and services for reconnection. - func teardownSessionForReconnect() async + /// Tears down the current session and services for reconnection. + func teardownSessionForReconnect() async - /// Rebuilds the session after iOS auto-reconnect completes. - func rebuildSession(deviceID: UUID) async throws + /// Rebuilds the session after iOS auto-reconnect completes. + func rebuildSession(deviceID: UUID) async throws - /// Disconnects the BLE transport (used when user disconnected during reconnect). - func disconnectTransport() async + /// Disconnects the BLE transport (used when user disconnected during reconnect). + func disconnectTransport() async - /// Notifies the UI layer of connection loss. - func notifyConnectionLost() async + /// Notifies the UI layer of connection loss. + func notifyConnectionLost() async - /// Handles reconnection failure (cleanup session, disconnect transport). - func handleReconnectionFailure() async + /// Handles reconnection failure (cleanup session, disconnect transport). + func handleReconnectionFailure() async - /// Returns whether the BLE transport is currently in auto-reconnecting phase. - func isTransportAutoReconnecting() async -> Bool + /// Returns whether the BLE transport is currently in auto-reconnecting phase. + func isTransportAutoReconnecting() async -> Bool } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionIntent.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionIntent.swift index 8de791d6..a359ec0e 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionIntent.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionIntent.swift @@ -4,48 +4,47 @@ import Foundation /// Represents the user's connection intent, replacing three separate flags: /// `shouldBeConnected`, `userExplicitlyDisconnected`, and `pendingForceFullSync`. -enum ConnectionIntent: Sendable, Equatable { +enum ConnectionIntent: Equatable { + /// No active connection intent (initial state) + case none - /// No active connection intent (initial state) - case none + /// User explicitly disconnected — suppress auto-reconnect and disconnected pill + case userDisconnected - /// User explicitly disconnected — suppress auto-reconnect and disconnected pill - case userDisconnected + /// User wants to be connected + case wantsConnection(forceFullSync: Bool = false) - /// User wants to be connected - case wantsConnection(forceFullSync: Bool = false) + // MARK: - Persistence - // MARK: - Persistence + private static let persistenceKey = PersistenceKeys.userExplicitlyDisconnected - private static let persistenceKey = PersistenceKeys.userExplicitlyDisconnected - - /// Persists the `userDisconnected` state to UserDefaults. - /// Only `.userDisconnected` is persisted; other states are transient. - func persist(to defaults: UserDefaults = .standard) { - switch self { - case .userDisconnected: - defaults.set(true, forKey: Self.persistenceKey) - case .none, .wantsConnection: - defaults.removeObject(forKey: Self.persistenceKey) - } - } - - /// Restores intent from UserDefaults on launch. - /// Returns `.userDisconnected` if persisted, otherwise `.none`. - static func restored(from defaults: UserDefaults = .standard) -> ConnectionIntent { - defaults.bool(forKey: persistenceKey) ? .userDisconnected : .none - } - - // MARK: - Convenience - - /// Whether the user wants to be connected - var wantsConnection: Bool { - if case .wantsConnection = self { return true } - return false - } - - /// Whether the user explicitly disconnected - var isUserDisconnected: Bool { - self == .userDisconnected + /// Persists the `userDisconnected` state to UserDefaults. + /// Only `.userDisconnected` is persisted; other states are transient. + func persist(to defaults: UserDefaults = .standard) { + switch self { + case .userDisconnected: + defaults.set(true, forKey: Self.persistenceKey) + case .none, .wantsConnection: + defaults.removeObject(forKey: Self.persistenceKey) } + } + + /// Restores intent from UserDefaults on launch. + /// Returns `.userDisconnected` if persisted, otherwise `.none`. + static func restored(from defaults: UserDefaults = .standard) -> ConnectionIntent { + defaults.bool(forKey: persistenceKey) ? .userDisconnected : .none + } + + // MARK: - Convenience + + /// Whether the user wants to be connected + var wantsConnection: Bool { + if case .wantsConnection = self { return true } + return false + } + + /// Whether the user explicitly disconnected + var isUserDisconnected: Bool { + self == .userDisconnected + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLE.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLE.swift index df37144e..dd8982c1 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLE.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLE.swift @@ -2,693 +2,690 @@ import MeshCore extension ConnectionManager { - - /// The connection methods recorded for a freshly connected BLE device. Centralized so the - /// BLE connect and device-switch paths persist an identical `.bluetooth` descriptor rather - /// than repeating the literal at each call site. - static func bleConnectionMethods(for deviceID: UUID) -> [ConnectionMethod] { - [.bluetooth(peripheralUUID: deviceID, displayName: nil)] + /// The connection methods recorded for a freshly connected BLE device. Centralized so the + /// BLE connect and device-switch paths persist an identical `.bluetooth` descriptor rather + /// than repeating the literal at each call site. + static func bleConnectionMethods(for deviceID: UUID) -> [ConnectionMethod] { + [.bluetooth(peripheralUUID: deviceID, displayName: nil)] + } + + // MARK: - BLE Diagnostics + + /// Returns a best-effort snapshot of the BLE state machine for debug exports. + public func currentBLEDiagnosticsSummary() async -> String { + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + let isConnected = await stateMachine.isConnected + let isAutoReconnecting = await stateMachine.isAutoReconnecting + let connectedDeviceShort = await stateMachine.connectedDeviceID?.uuidString.prefix(8) ?? "none" + let sessionPresent = session != nil + let servicesPresent = services != nil + let eventMonitoringActive = services?.isEventMonitoringActive ?? false + let autoFetchActive: Bool = if let services { + await services.messagePollingService.isAutoFetching + } else { + false } - - // MARK: - BLE Diagnostics - - /// Returns a best-effort snapshot of the BLE state machine for debug exports. - public func currentBLEDiagnosticsSummary() async -> String { - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - let isConnected = await stateMachine.isConnected - let isAutoReconnecting = await stateMachine.isAutoReconnecting - let connectedDeviceShort = await stateMachine.connectedDeviceID?.uuidString.prefix(8) ?? "none" - let sessionPresent = session != nil - let servicesPresent = services != nil - let eventMonitoringActive = services?.isEventMonitoringActive ?? false - let autoFetchActive: Bool - if let services { - autoFetchActive = await services.messagePollingService.isAutoFetching - } else { - autoFetchActive = false - } - let activeReconnectDeviceShort = reconnectionCoordinator.reconnectingDeviceID?.uuidString.prefix(8) ?? "none" - let sessionRebuildDeviceShort = sessionRebuildDeviceID?.uuidString.prefix(8) ?? "none" - return - "BLE: state=\(bleState), " + - "phase=\(blePhase), " + - "peripheralState=\(blePeripheralState), " + - "isConnected=\(isConnected), " + - "isAutoReconnecting=\(isAutoReconnecting), " + - "connectedDevice=\(connectedDeviceShort), " + - "sessionPresent=\(sessionPresent), " + - "servicesPresent=\(servicesPresent), " + - "eventMonitoringActive=\(eventMonitoringActive), " + - "autoFetchActive=\(autoFetchActive), " + - "activeReconnectDevice=\(activeReconnectDeviceShort), " + - "sessionRebuildDevice=\(sessionRebuildDeviceShort)" + let activeReconnectDeviceShort = reconnectionCoordinator.reconnectingDeviceID?.uuidString.prefix(8) ?? "none" + let sessionRebuildDeviceShort = sessionRebuildDeviceID?.uuidString.prefix(8) ?? "none" + return + "BLE: state=\(bleState), " + + "phase=\(blePhase), " + + "peripheralState=\(blePeripheralState), " + + "isConnected=\(isConnected), " + + "isAutoReconnecting=\(isAutoReconnecting), " + + "connectedDevice=\(connectedDeviceShort), " + + "sessionPresent=\(sessionPresent), " + + "servicesPresent=\(servicesPresent), " + + "eventMonitoringActive=\(eventMonitoringActive), " + + "autoFetchActive=\(autoFetchActive), " + + "activeReconnectDevice=\(activeReconnectDeviceShort), " + + "sessionRebuildDevice=\(sessionRebuildDeviceShort)" + } + + // MARK: - BLE Device Checks + + /// Checks if a device is connected to the system by another app. + /// Returns false during auto-reconnect or when the device is already connected by us. + /// - Parameter deviceID: The UUID of the device to check + /// - Returns: `true` if device appears connected to another app + public func isDeviceConnectedToOtherApp(_ deviceID: UUID) async -> Bool { + let isAutoReconnecting = await stateMachine.isAutoReconnecting + let smIsConnected = await stateMachine.isConnected + let smConnectedDeviceID = await stateMachine.connectedDeviceID + let systemConnected = await stateMachine.isDeviceConnectedToSystem(deviceID) + let allSystemConnected = await stateMachine.systemConnectedPeripheralIDs() + + logger.info( + "[OtherAppCheck] device=\(deviceID.uuidString.prefix(8)), " + + "connectionState=\(String(describing: connectionState)), " + + "isAutoReconnecting=\(isAutoReconnecting), " + + "smIsConnected=\(smIsConnected), " + + "smConnectedDevice=\(smConnectedDeviceID?.uuidString.prefix(8) ?? "nil"), " + + "isDeviceConnectedToSystem=\(systemConnected), " + + "allSystemConnectedCount=\(allSystemConnected.count), " + + "allSystemConnected=\(allSystemConnected.map { String($0.uuidString.prefix(8)) })" + ) + + // Don't check during auto-reconnect - that's our own connection + guard !isAutoReconnecting else { return false } + + // Don't check if we're already connected (switching devices) + guard connectionState == .disconnected else { return false } + + // Don't report our own connection as "another app" (state restoration may have completed) + if smIsConnected, smConnectedDeviceID == deviceID { + return false } - // MARK: - BLE Device Checks - - /// Checks if a device is connected to the system by another app. - /// Returns false during auto-reconnect or when the device is already connected by us. - /// - Parameter deviceID: The UUID of the device to check - /// - Returns: `true` if device appears connected to another app - public func isDeviceConnectedToOtherApp(_ deviceID: UUID) async -> Bool { - let isAutoReconnecting = await stateMachine.isAutoReconnecting - let smIsConnected = await stateMachine.isConnected - let smConnectedDeviceID = await stateMachine.connectedDeviceID - let systemConnected = await stateMachine.isDeviceConnectedToSystem(deviceID) - let allSystemConnected = await stateMachine.systemConnectedPeripheralIDs() - - logger.info( - "[OtherAppCheck] device=\(deviceID.uuidString.prefix(8)), " + - "connectionState=\(String(describing: self.connectionState)), " + - "isAutoReconnecting=\(isAutoReconnecting), " + - "smIsConnected=\(smIsConnected), " + - "smConnectedDevice=\(smConnectedDeviceID?.uuidString.prefix(8) ?? "nil"), " + - "isDeviceConnectedToSystem=\(systemConnected), " + - "allSystemConnectedCount=\(allSystemConnected.count), " + - "allSystemConnected=\(allSystemConnected.map { String($0.uuidString.prefix(8)) })" - ) - - // Don't check during auto-reconnect - that's our own connection - guard !isAutoReconnecting else { return false } - - // Don't check if we're already connected (switching devices) - guard connectionState == .disconnected else { return false } - - // Don't report our own connection as "another app" (state restoration may have completed) - if smIsConnected, smConnectedDeviceID == deviceID { - return false - } - - return systemConnected + return systemConnected + } + + /// Attempts to adopt a system-connected BLE link for the *last connected* device. + /// + /// iOS can keep a BLE link alive across app termination (notably after app updates) while state + /// restoration does not fire for the new process. In that case, CoreBluetooth may report the + /// radio as system-connected even though our state machine is `.idle`. + /// + /// Rather than treating this as "connected elsewhere" and blocking reconnect, we can adopt the + /// existing link by running the restoration discovery chain against the connected peripheral. + /// + /// - Returns: `true` if an adoption attempt was started. + func startAdoptingLastSystemConnectedPeripheralIfAvailable( + deviceID: UUID, + context: String + ) async -> Bool { + guard deviceID == lastConnectedDeviceID else { return false } + guard currentTransportType == nil || currentTransportType == .bluetooth else { return false } + guard connectionState == .disconnected else { return false } + guard connectionIntent.wantsConnection else { return false } + + // Don't interfere with iOS auto-reconnect or an active BLE connection. + guard await !(stateMachine.isAutoReconnecting) else { return false } + if await stateMachine.isConnected, await stateMachine.connectedDeviceID == deviceID { + return false } - /// Attempts to adopt a system-connected BLE link for the *last connected* device. - /// - /// iOS can keep a BLE link alive across app termination (notably after app updates) while state - /// restoration does not fire for the new process. In that case, CoreBluetooth may report the - /// radio as system-connected even though our state machine is `.idle`. - /// - /// Rather than treating this as "connected elsewhere" and blocking reconnect, we can adopt the - /// existing link by running the restoration discovery chain against the connected peripheral. - /// - /// - Returns: `true` if an adoption attempt was started. - func startAdoptingLastSystemConnectedPeripheralIfAvailable( - deviceID: UUID, - context: String - ) async -> Bool { - guard deviceID == lastConnectedDeviceID else { return false } - guard currentTransportType == nil || currentTransportType == .bluetooth else { return false } - guard connectionState == .disconnected else { return false } - guard connectionIntent.wantsConnection else { return false } - - // Don't interfere with iOS auto-reconnect or an active BLE connection. - guard !(await stateMachine.isAutoReconnecting) else { return false } - if await stateMachine.isConnected, await stateMachine.connectedDeviceID == deviceID { - return false - } - - // Adoption is only valid from an idle BLE state machine. If restoration or another - // discovery chain is already in progress, let that flow own the reconnect. - let blePhase = await stateMachine.currentPhaseName - guard blePhase == "idle" else { return false } - - // Avoid doing teardown/UI transitions when there is no system-level link. - guard await stateMachine.isDeviceConnectedToSystem(deviceID) else { return false } - - let bleState = await stateMachine.centralManagerStateName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - logger.warning( - "[BLE] \(context): device appears system-connected while disconnected; attempting adoption - " + - "device=\(deviceID.uuidString.prefix(8)), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState)" - ) - - // Prepare session layer + UI timeout window before starting adoption. - await reconnectionCoordinator.handleEnteringAutoReconnect(deviceID: deviceID) - - let started = await stateMachine.startAdoptingSystemConnectedPeripheral(deviceID) - guard started else { - logger.warning("[BLE] \(context): startAdoptingSystemConnectedPeripheral returned false after system-connected preflight") - // Undo UI timeout + state changes so downstream health checks remain accurate. - reconnectionCoordinator.cancelTimeout() - reconnectionCoordinator.clearReconnectingDevice() - if connectionState == .connecting { - connectionState = .disconnected - } - return false - } - - persistDisconnectDiagnostic( - "source=\(context).adoptSystemConnectedPeripheral, " + - "device=\(deviceID.uuidString.prefix(8)), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState), " + - "intent=\(connectionIntent)" - ) - return true + // Adoption is only valid from an idle BLE state machine. If restoration or another + // discovery chain is already in progress, let that flow own the reconnect. + let blePhase = await stateMachine.currentPhaseName + guard blePhase == "idle" else { return false } + + // Avoid doing teardown/UI transitions when there is no system-level link. + guard await stateMachine.isDeviceConnectedToSystem(deviceID) else { return false } + + let bleState = await stateMachine.centralManagerStateName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + logger.warning( + "[BLE] \(context): device appears system-connected while disconnected; attempting adoption - " + + "device=\(deviceID.uuidString.prefix(8)), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState)" + ) + + // Prepare session layer + UI timeout window before starting adoption. + await reconnectionCoordinator.handleEnteringAutoReconnect(deviceID: deviceID) + + let started = await stateMachine.startAdoptingSystemConnectedPeripheral(deviceID) + guard started else { + logger.warning("[BLE] \(context): startAdoptingSystemConnectedPeripheral returned false after system-connected preflight") + // Undo UI timeout + state changes so downstream health checks remain accurate. + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() + if connectionState == .connecting { + connectionState = .disconnected + } + return false } - // MARK: - BLE Scanning - - /// Starts scanning for nearby BLE devices and returns an AsyncStream of `DiscoveredDevice`. - /// Scanning is orthogonal to the connection lifecycle — works while connected. - /// Cancel the consuming task to stop scanning automatically. - public func startBLEScanning() -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: DiscoveredDevice.self) - bleScanTask?.cancel() - bleScanRequestID &+= 1 - let requestID = bleScanRequestID - - bleScanTask = Task { @MainActor [weak self] in - guard let self else { return } - guard !Task.isCancelled, requestID == self.bleScanRequestID else { return } - - await self.stateMachine.setDeviceDiscoveredHandler { @Sendable deviceID, name, rssi in - _ = continuation.yield(DiscoveredDevice(id: deviceID, name: name, rssi: rssi)) - } - - guard !Task.isCancelled, requestID == self.bleScanRequestID else { return } - await self.stateMachine.startScanning() - } - - continuation.onTermination = { [weak self] _ in - Task { @MainActor in - guard let self else { return } - guard self.bleScanRequestID == requestID else { return } - self.bleScanRequestID &+= 1 - self.bleScanTask?.cancel() - self.bleScanTask = nil - await self.stateMachine.setDeviceDiscoveredHandler { _, _, _ in } - await self.stateMachine.stopScanning() - } - } - - return stream + persistDisconnectDiagnostic( + "source=\(context).adoptSystemConnectedPeripheral, " + + "device=\(deviceID.uuidString.prefix(8)), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState), " + + "intent=\(connectionIntent)" + ) + return true + } + + // MARK: - BLE Scanning + + /// Starts scanning for nearby BLE devices and returns an AsyncStream of `DiscoveredDevice`. + /// Scanning is orthogonal to the connection lifecycle — works while connected. + /// Cancel the consuming task to stop scanning automatically. + public func startBLEScanning() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: DiscoveredDevice.self) + bleScanTask?.cancel() + bleScanRequestID &+= 1 + let requestID = bleScanRequestID + + bleScanTask = Task { @MainActor [weak self] in + guard let self else { return } + guard !Task.isCancelled, requestID == bleScanRequestID else { return } + + await stateMachine.setDeviceDiscoveredHandler { @Sendable deviceID, name, rssi in + _ = continuation.yield(DiscoveredDevice(id: deviceID, name: name, rssi: rssi)) + } + + guard !Task.isCancelled, requestID == bleScanRequestID else { return } + await stateMachine.startScanning() } - /// Manually stops BLE scanning. - public func stopBLEScanning() async { - bleScanRequestID &+= 1 - bleScanTask?.cancel() - bleScanTask = nil - await stateMachine.setDeviceDiscoveredHandler { _, _, _ in } - await stateMachine.stopScanning() + continuation.onTermination = { [weak self] _ in + Task { @MainActor in + guard let self else { return } + guard self.bleScanRequestID == requestID else { return } + self.bleScanRequestID &+= 1 + self.bleScanTask?.cancel() + self.bleScanTask = nil + await self.stateMachine.setDeviceDiscoveredHandler { _, _, _ in } + await self.stateMachine.stopScanning() + } } - // MARK: - Reconnection Watchdog - - /// Starts a watchdog that periodically retries connection when the user wants to be - /// connected but the device is stuck in disconnected state (e.g., after auto-reconnect failure). - /// Uses exponential backoff: 30s → 60s → 120s (capped). - func startReconnectionWatchdog() { - stopReconnectionWatchdog() - - reconnectionWatchdogTask = Task { - var delay: Duration = .seconds(30) - let maxDelay: Duration = .seconds(120) - - while !Task.isCancelled { - try? await Task.sleep(for: delay) - guard !Task.isCancelled else { return } - - guard connectionIntent.wantsConnection, - connectionState == .disconnected else { - logger.info("[BLE] Watchdog exiting: intent or state changed") - return - } - - if await stateMachine.isBluetoothPoweredOff { - logger.info("[BLE] Watchdog skipping: Bluetooth powered off") - delay = min(delay * 2, maxDelay) - continue - } - - if await stateMachine.isAutoReconnecting { - logger.info("[BLE] Watchdog skipping: iOS auto-reconnect in progress") - delay = min(delay * 2, maxDelay) - continue - } - - logger.info("[BLE] Watchdog attempting reconnection (delay was \(delay))") - await checkBLEConnectionHealth() - - delay = min(delay * 2, maxDelay) - } - } - } + return stream + } - /// Stops the reconnection watchdog - func stopReconnectionWatchdog() { - reconnectionWatchdogTask?.cancel() - reconnectionWatchdogTask = nil - } + /// Manually stops BLE scanning. + public func stopBLEScanning() async { + bleScanRequestID &+= 1 + bleScanTask?.cancel() + bleScanTask = nil + await stateMachine.setDeviceDiscoveredHandler { _, _, _ in } + await stateMachine.stopScanning() + } - // MARK: - BLE Connection Health - - /// Attempts BLE reconnection if user expects to be connected but iOS auto-reconnect gave up. - /// Called when the app returns to foreground (`appDidBecomeActive`) and from the reconnection - /// watchdog loop. - /// - /// The guards below run as an ordered priority ladder, each ending in an early return so the - /// first matching situation wins. Order is load-bearing: cheap stand-down checks (wrong - /// transport, pairing, no intent, reconnect already in flight) come first so the expensive - /// state-machine queries below them only run when a reconnect is actually warranted; the live - /// transport check precedes the disconnected paths so an already-healthy link is reconciled - /// rather than torn down; stale-state cleanup runs before the adopt/other-app/reconnect - /// strategies so they start from a clean `connectionState`. Reordering can either tear down a - /// good connection or attempt a redundant reconnect. - public func checkBLEConnectionHealth() async { - // Only check BLE connections - guard currentTransportType == nil || currentTransportType == .bluetooth else { return } - - if shouldDeferOpportunisticReconnect { - logger.info("[BLE] Foreground health check standing down: pairing in progress") - return - } + // MARK: - Reconnection Watchdog - let deviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - logger.info(""" - [BLE] Foreground health check - \ - connectionIntent: \(connectionIntent), \ - lastDevice: \(deviceShort), \ - connectionState: \(String(describing: connectionState)), \ - bleState: \(bleState), \ - blePhase: \(blePhase) - """) - - // Check if user expects to be connected - guard connectionIntent.wantsConnection, - let deviceID = lastConnectedDeviceID else { return } + /// Starts a watchdog that periodically retries connection when the user wants to be + /// connected but the device is stuck in disconnected state (e.g., after auto-reconnect failure). + /// Uses exponential backoff: 30s → 60s → 120s (capped). + func startReconnectionWatchdog() { + stopReconnectionWatchdog() - if activeReconnectDeviceID == deviceID { - logger.info("[BLE] Skipping foreground reconnect: reconnect/session rebuild already in progress for \(deviceID.uuidString.prefix(8))") - return - } + reconnectionWatchdogTask = Task { + var delay: Duration = .seconds(30) + let maxDelay: Duration = .seconds(120) - // Check actual BLE state. A live BLE transport is only healthy if the - // MC1 session, services, and listeners are alive too. - let bleConnected = await stateMachine.isConnected - if bleConnected { - await reconcileConnectedBLEAppStack(deviceID: deviceID) - return - } + while !Task.isCancelled { + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } - // Don't interfere if iOS auto-reconnect is still in progress - if await stateMachine.isAutoReconnecting { - logger.info("[BLE] Skipping foreground reconnect: iOS auto-reconnect still in progress") - return + guard connectionIntent.wantsConnection, + connectionState == .disconnected else { + logger.info("[BLE] Watchdog exiting: intent or state changed") + return } - // Don't attempt reconnection when Bluetooth is off if await stateMachine.isBluetoothPoweredOff { - logger.info("[BLE] Skipping foreground reconnect: Bluetooth is powered off") - return + logger.info("[BLE] Watchdog skipping: Bluetooth powered off") + delay = min(delay * 2, maxDelay) + continue } - // Detect stale connection state: app thinks connected but BLE is actually disconnected - // This happens when iOS terminates the BLE connection while app is suspended - if connectionState.isConnected { - logger.warning("[BLE] Detected stale connection state on foreground: connectionState=\(String(describing: connectionState)) but BLE disconnected, triggering cleanup") - await handleConnectionLoss(deviceID: deviceID, error: nil) + if await stateMachine.isAutoReconnecting { + logger.info("[BLE] Watchdog skipping: iOS auto-reconnect in progress") + delay = min(delay * 2, maxDelay) + continue } - // If iOS kept a system-level BLE link alive (common across app updates) but our state machine is idle, - // adopt the existing connection rather than treating it as "connected elsewhere". - if await startAdoptingLastSystemConnectedPeripheralIfAvailable( - deviceID: deviceID, - context: "checkBLEConnectionHealth" - ) { - return - } + logger.info("[BLE] Watchdog attempting reconnection (delay was \(delay))") + await checkBLEConnectionHealth() - // Don't reconnect if device is connected to another app - if await isDeviceConnectedToOtherApp(deviceID) { - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - persistDisconnectDiagnostic( - "source=checkBLEConnectionHealth.otherAppConnected, " + - "device=\(deviceID.uuidString.prefix(8)), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState), " + - "intent=\(connectionIntent)" - ) - - // Ensure retries continue even when this method is called directly - // (outside appDidBecomeActive's watchdog re-arm path). - if reconnectionWatchdogTask == nil { - startReconnectionWatchdog() - } - logger.info("[BLE] Skipping foreground reconnect: device connected to another app") - return - } - - logger.info("[BLE] Attempting foreground reconnection to \(deviceID.uuidString.prefix(8))") - await attemptOpportunisticReconnect(deviceID: deviceID, reason: "foreground health check") + delay = min(delay * 2, maxDelay) + } + } + } + + /// Stops the reconnection watchdog + func stopReconnectionWatchdog() { + reconnectionWatchdogTask?.cancel() + reconnectionWatchdogTask = nil + } + + // MARK: - BLE Connection Health + + /// Attempts BLE reconnection if user expects to be connected but iOS auto-reconnect gave up. + /// Called when the app returns to foreground (`appDidBecomeActive`) and from the reconnection + /// watchdog loop. + /// + /// The guards below run as an ordered priority ladder, each ending in an early return so the + /// first matching situation wins. Order is load-bearing: cheap stand-down checks (wrong + /// transport, pairing, no intent, reconnect already in flight) come first so the expensive + /// state-machine queries below them only run when a reconnect is actually warranted; the live + /// transport check precedes the disconnected paths so an already-healthy link is reconciled + /// rather than torn down; stale-state cleanup runs before the adopt/other-app/reconnect + /// strategies so they start from a clean `connectionState`. Reordering can either tear down a + /// good connection or attempt a redundant reconnect. + public func checkBLEConnectionHealth() async { + // Only check BLE connections + guard currentTransportType == nil || currentTransportType == .bluetooth else { return } + + if shouldDeferOpportunisticReconnect { + logger.info("[BLE] Foreground health check standing down: pairing in progress") + return } - private func reconcileConnectedBLEAppStack(deviceID: UUID) async { - if await stateMachine.isAutoReconnecting { - logger.info("[BLE] Skipping connected-transport recovery: iOS auto-reconnect still in progress") - return - } + let deviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + logger.info(""" + [BLE] Foreground health check - \ + connectionIntent: \(connectionIntent), \ + lastDevice: \(deviceShort), \ + connectionState: \(String(describing: connectionState)), \ + bleState: \(bleState), \ + blePhase: \(blePhase) + """) + + // Check if user expects to be connected + guard connectionIntent.wantsConnection, + let deviceID = lastConnectedDeviceID else { return } + + if activeReconnectDeviceID == deviceID { + logger.info("[BLE] Skipping foreground reconnect: reconnect/session rebuild already in progress for \(deviceID.uuidString.prefix(8))") + return + } - let bleConnectedDeviceID = await stateMachine.connectedDeviceID - if let bleConnectedDeviceID, bleConnectedDeviceID != deviceID { - logger.warning( - "[BLE] Connected transport belongs to \(bleConnectedDeviceID.uuidString.prefix(8)); expected \(deviceID.uuidString.prefix(8))" - ) - return - } + // Check actual BLE state. A live BLE transport is only healthy if the + // MC1 session, services, and listeners are alive too. + let bleConnected = await stateMachine.isConnected + if bleConnected { + await reconcileConnectedBLEAppStack(deviceID: deviceID) + return + } - guard let services, - session != nil, - let connectedDevice, - connectedDevice.id == deviceID else { - await rebuildConnectedBLEAppStack(deviceID: deviceID) - return - } + // Don't interfere if iOS auto-reconnect is still in progress + if await stateMachine.isAutoReconnecting { + logger.info("[BLE] Skipping foreground reconnect: iOS auto-reconnect still in progress") + return + } - guard connectionState.isOperational else { - logger.info( - "[BLE] Connected transport app stack present while state is \(String(describing: connectionState)); leaving active connection flow in place" - ) - return - } + // Don't attempt reconnection when Bluetooth is off + if await stateMachine.isBluetoothPoweredOff { + logger.info("[BLE] Skipping foreground reconnect: Bluetooth is powered off") + return + } - await reconcileConnectedBLEListeners( - services: services, - radioID: connectedDevice.radioID - ) + // Detect stale connection state: app thinks connected but BLE is actually disconnected + // This happens when iOS terminates the BLE connection while app is suspended + if connectionState.isConnected { + logger.warning("[BLE] Detected stale connection state on foreground: connectionState=\(String(describing: connectionState)) but BLE disconnected, triggering cleanup") + await handleConnectionLoss(deviceID: deviceID, error: nil) } - private func rebuildConnectedBLEAppStack(deviceID: UUID) async { - logger.warning( - "[BLE] Connected BLE transport has missing app stack; rebuilding session for \(deviceID.uuidString.prefix(8))" - ) - connectionState = .connecting + // If iOS kept a system-level BLE link alive (common across app updates) but our state machine is idle, + // adopt the existing connection rather than treating it as "connected elsewhere". + if await startAdoptingLastSystemConnectedPeripheralIfAvailable( + deviceID: deviceID, + context: "checkBLEConnectionHealth" + ) { + return + } - do { - try await rebuildSessionForHealthCheck(deviceID: deviceID) - } catch { - logger.warning("[BLE] Connected-transport session rebuild failed: \(error.localizedDescription)") - await handleReconnectionFailure() - } + // Don't reconnect if device is connected to another app + if await isDeviceConnectedToOtherApp(deviceID) { + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + persistDisconnectDiagnostic( + "source=checkBLEConnectionHealth.otherAppConnected, " + + "device=\(deviceID.uuidString.prefix(8)), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState), " + + "intent=\(connectionIntent)" + ) + + // Ensure retries continue even when this method is called directly + // (outside appDidBecomeActive's watchdog re-arm path). + if reconnectionWatchdogTask == nil { + startReconnectionWatchdog() + } + logger.info("[BLE] Skipping foreground reconnect: device connected to another app") + return } - private func rebuildSessionForHealthCheck(deviceID: UUID) async throws { - #if DEBUG - if let rebuildSessionForHealthCheckOverride { - try await rebuildSessionForHealthCheckOverride(deviceID) - return - } - #endif + logger.info("[BLE] Attempting foreground reconnection to \(deviceID.uuidString.prefix(8))") + await attemptOpportunisticReconnect(deviceID: deviceID, reason: "foreground health check") + } - try await rebuildSession(deviceID: deviceID) + private func reconcileConnectedBLEAppStack(deviceID: UUID) async { + if await stateMachine.isAutoReconnecting { + logger.info("[BLE] Skipping connected-transport recovery: iOS auto-reconnect still in progress") + return } - private func reconcileConnectedBLEListeners( - services: ServiceContainer, - radioID: UUID - ) async { - let eventMonitoringActive = services.isEventMonitoringActive - let autoFetchActive = await services.messagePollingService.isAutoFetching + let bleConnectedDeviceID = await stateMachine.connectedDeviceID + if let bleConnectedDeviceID, bleConnectedDeviceID != deviceID { + logger.warning( + "[BLE] Connected transport belongs to \(bleConnectedDeviceID.uuidString.prefix(8)); expected \(deviceID.uuidString.prefix(8))" + ) + return + } - guard !eventMonitoringActive || !autoFetchActive else { return } + guard let services, + session != nil, + let connectedDevice, + connectedDevice.id == deviceID else { + await rebuildConnectedBLEAppStack(deviceID: deviceID) + return + } - logger.warning( - "[BLE] Connected BLE app stack missing listeners; restarting event pipeline for \(radioID.uuidString.prefix(8))" - ) + guard connectionState.isOperational else { + logger.info( + "[BLE] Connected transport app stack present while state is \(String(describing: connectionState)); leaving active connection flow in place" + ) + return + } - if !eventMonitoringActive { - await services.startEventMonitoring(radioID: radioID) - } else { - await services.messagePollingService.startAutoFetch(radioID: radioID) - } + await reconcileConnectedBLEListeners( + services: services, + radioID: connectedDevice.radioID + ) + } + + private func rebuildConnectedBLEAppStack(deviceID: UUID) async { + logger.warning( + "[BLE] Connected BLE transport has missing app stack; rebuilding session for \(deviceID.uuidString.prefix(8))" + ) + connectionState = .connecting + + do { + try await rebuildSessionForHealthCheck(deviceID: deviceID) + } catch { + logger.warning("[BLE] Connected-transport session rebuild failed: \(error.localizedDescription)") + await handleReconnectionFailure() } + } - // MARK: - BLE Connection - - /// Connects with retry logic for reconnection scenarios - func connectWithRetry(deviceID: UUID, maxAttempts: Int) async throws { - var lastError: Error = ConnectionError.connectionFailed("Unknown error") - - for attempt in 1...maxAttempts { - guard connectingDeviceID == deviceID else { throw CancellationError() } - - do { - try await performConnection(deviceID: deviceID) - - recordConnectionSuccess() - if attempt > 1 { - logger.info("Reconnection succeeded on attempt \(attempt)") - } - return - - } catch { - lastError = error - guard connectingDeviceID == deviceID else { throw error } - - // BLE precondition failures won't resolve between retries. - // Exit without retrying or tripping the circuit breaker so that - // onBluetoothPoweredOn can reconnect cleanly when BLE comes back. - // Auth failures also bypass retry — the bond is bad, the user has to - // intervene; four sequential auth failures just delays the recovery alert. - if let bleError = error as? BLEError { - switch bleError { - case .bluetoothPoweredOff, .bluetoothUnavailable, .bluetoothUnauthorized: - throw error - case .authenticationFailed: - // The peripheral connected before auth failed, leaving the - // BLE state machine in a non-idle phase. Tear down before - // bypassing retries so subsequent connects start fresh. - await cleanupResources() - await transport.disconnect() - throw error - default: - break - } - } - - if isDeviceNotFoundError(error) { - await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "connectWithRetry attempt \(attempt)") - } - - // Diagnostic: Log BLE state on each failed attempt - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - let backoffDelay = attempt < maxAttempts ? 0.3 * pow(2.0, Double(attempt - 1)) : 0.0 - let backoffStr = backoffDelay.formatted(.number.precision(.fractionLength(2))) - logger.warning( - "[BLE] Reconnection attempt \(attempt)/\(maxAttempts) failed - error: \(error.localizedDescription), blePhase: \(blePhase), blePeripheralState: \(blePeripheralState), nextBackoff: \(backoffStr)s" - ) - - // Clean up resources but keep state as .connecting - await cleanupResources() - await transport.disconnect() - - if attempt < maxAttempts { - // Backoff delay - state remains .connecting - let baseDelay = 0.3 * pow(2.0, Double(attempt - 1)) - let jitter = Double.random(in: 0...0.1) * baseDelay - try await Task.sleep(for: .seconds(baseDelay + jitter)) - } - } - } + private func rebuildSessionForHealthCheck(deviceID: UUID) async throws { + #if DEBUG + if let rebuildSessionForHealthCheckOverride { + try await rebuildSessionForHealthCheckOverride(deviceID) + return + } + #endif - // All retries exhausted - trip circuit breaker, then throw - recordConnectionFailure() + try await rebuildSession(deviceID: deviceID) + } - // Diagnostic: Log final failure state - let finalBlePhase = await stateMachine.currentPhaseName - let finalBlePeripheralState = await stateMachine.currentPeripheralState ?? "none" - logger.error( - "[BLE] All \(maxAttempts) reconnection attempts exhausted - lastError: \(lastError.localizedDescription), blePhase: \(finalBlePhase), blePeripheralState: \(finalBlePeripheralState)" - ) + private func reconcileConnectedBLEListeners( + services: ServiceContainer, + radioID: UUID + ) async { + let eventMonitoringActive = services.isEventMonitoringActive + let autoFetchActive = await services.messagePollingService.isAutoFetching - throw lastError - } + guard !eventMonitoringActive || !autoFetchActive else { return } - /// Performs the actual connection to a device - func performConnection(deviceID: UUID) async throws { - // Note: connectionState is already .connecting (set by caller) + logger.warning( + "[BLE] Connected BLE app stack missing listeners; restarting event pipeline for \(radioID.uuidString.prefix(8))" + ) - // Stop any existing session to prevent multiple receive loops racing for transport data - await session?.stop() - session = nil + if !eventMonitoringActive { + await services.startEventMonitoring(radioID: radioID) + } else { + await services.messagePollingService.startAutoFetch(radioID: radioID) + } + } - // Set device ID and connect - await transport.setDeviceID(deviceID) - try await transport.connect() + // MARK: - BLE Connection - logger.info("[BLE] State → .connected (transport connected for device: \(deviceID.uuidString.prefix(8)))") - connectionState = .connected + /// Connects with retry logic for reconnection scenarios + func connectWithRetry(deviceID: UUID, maxAttempts: Int) async throws { + var lastError: Error = ConnectionError.connectionFailed("Unknown error") - // Create session - let newSession = MeshCoreSession(transport: transport) - self.session = newSession + for attempt in 1...maxAttempts { + guard connectingDeviceID == deviceID else { throw CancellationError() } - let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) + do { + try await performConnection(deviceID: deviceID) - // Configure BLE write pacing based on device platform - await configureBLEPacing(for: deviceCapabilities) + recordConnectionSuccess() + if attempt > 1 { + logger.info("Reconnection succeeded on attempt \(attempt)") + } + return - let (newServices, radioID) = try await buildServicesAndSaveDevice( - deviceID: deviceID, - session: newSession, - selfInfo: meshCoreSelfInfo, - capabilities: deviceCapabilities, - connectionMethods: Self.bleConnectionMethods(for: deviceID) + } catch { + lastError = error + guard connectingDeviceID == deviceID else { throw error } + + // BLE precondition failures won't resolve between retries. + // Exit without retrying or tripping the circuit breaker so that + // onBluetoothPoweredOn can reconnect cleanly when BLE comes back. + // Auth failures also bypass retry — the bond is bad, the user has to + // intervene; four sequential auth failures just delays the recovery alert. + if let bleError = error as? BLEError { + switch bleError { + case .bluetoothPoweredOff, .bluetoothUnavailable, .bluetoothUnauthorized: + throw error + case .authenticationFailed: + // The peripheral connected before auth failed, leaving the + // BLE state machine in a non-idle phase. Tear down before + // bypassing retries so subsequent connects start fresh. + await cleanupResources() + await transport.disconnect() + throw error + default: + break + } + } + + if isDeviceNotFoundError(error) { + await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "connectWithRetry attempt \(attempt)") + } + + // Diagnostic: Log BLE state on each failed attempt + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + let backoffDelay = attempt < maxAttempts ? 0.3 * pow(2.0, Double(attempt - 1)) : 0.0 + let backoffStr = backoffDelay.formatted(.number.precision(.fractionLength(2))) + logger.warning( + "[BLE] Reconnection attempt \(attempt)/\(maxAttempts) failed - error: \(error.localizedDescription), blePhase: \(blePhase), blePeripheralState: \(blePeripheralState), nextBackoff: \(backoffStr)s" ) - // Persist connection for auto-reconnect - persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) - - // Notify observers before sync starts so they can wire callbacks - // (e.g., AppState needs to set sync activity callbacks for the syncing pill) - await onConnectionReady?() - let shouldForceFullSync: Bool - if case .wantsConnection(let force) = connectionIntent { - shouldForceFullSync = force - if force { connectionIntent = .wantsConnection() } - } else { - shouldForceFullSync = false - } - let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, forceFullSync: shouldForceFullSync) + // Clean up resources but keep state as .connecting + await cleanupResources() + await transport.disconnect() - guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .bluetooth) else { - await newSession.stop() - return + if attempt < maxAttempts { + // Backoff delay - state remains .connecting + let baseDelay = 0.3 * pow(2.0, Double(attempt - 1)) + let jitter = Double.random(in: 0...0.1) * baseDelay + try await Task.sleep(for: .seconds(baseDelay + jitter)) } - - stopReconnectionWatchdog() - logger.info("Connection complete - device ready") + } } - // MARK: - BLE Diagnostics Helpers - - func logDeviceNotFoundDiagnostics(deviceID: UUID, context: String) async { - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - let lastDeviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" - let registeredDevices = pairing.registeredDeviceInfos() - let pairedSummary = registeredDevices.prefix(5).map { info in - "\(info.name)(\(info.id.uuidString.prefix(8)))" - } - let pairedSummaryText = pairedSummary.isEmpty ? "none" : pairedSummary.joined(separator: ", ") - - logger.warning( - // swiftlint:disable:next line_length - "[BLE] Device not found diagnostics (\(context)) - device: \(deviceID.uuidString.prefix(8)), lastDevice: \(lastDeviceShort), connectionIntent: \(connectionIntent), bleState: \(bleState), blePhase: \(blePhase), pairingSessionActive: \(pairing.isSessionActive), pairedCount: \(registeredDevices.count), paired: \(pairedSummaryText)" - ) + // All retries exhausted - trip circuit breaker, then throw + recordConnectionFailure() + + // Diagnostic: Log final failure state + let finalBlePhase = await stateMachine.currentPhaseName + let finalBlePeripheralState = await stateMachine.currentPeripheralState ?? "none" + logger.error( + "[BLE] All \(maxAttempts) reconnection attempts exhausted - lastError: \(lastError.localizedDescription), blePhase: \(finalBlePhase), blePeripheralState: \(finalBlePeripheralState)" + ) + + throw lastError + } + + /// Performs the actual connection to a device + func performConnection(deviceID: UUID) async throws { + // Note: connectionState is already .connecting (set by caller) + + // Stop any existing session to prevent multiple receive loops racing for transport data + await session?.stop() + session = nil + + // Set device ID and connect + await transport.setDeviceID(deviceID) + try await transport.connect() + + logger.info("[BLE] State → .connected (transport connected for device: \(deviceID.uuidString.prefix(8)))") + connectionState = .connected + + // Create session + let newSession = MeshCoreSession(transport: transport) + session = newSession + + let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) + + // Configure BLE write pacing based on device platform + await configureBLEPacing(for: deviceCapabilities) + + let (newServices, radioID) = try await buildServicesAndSaveDevice( + deviceID: deviceID, + session: newSession, + selfInfo: meshCoreSelfInfo, + capabilities: deviceCapabilities, + connectionMethods: Self.bleConnectionMethods(for: deviceID) + ) + + // Persist connection for auto-reconnect + persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) + + // Notify observers before sync starts so they can wire callbacks + // (e.g., AppState needs to set sync activity callbacks for the syncing pill) + await onConnectionReady?() + let shouldForceFullSync: Bool + if case let .wantsConnection(force) = connectionIntent { + shouldForceFullSync = force + if force { connectionIntent = .wantsConnection() } + } else { + shouldForceFullSync = false } + let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, forceFullSync: shouldForceFullSync) - func isDeviceNotFoundError(_ error: Error) -> Bool { - if case ConnectionError.deviceNotFound = error { return true } - if case BLEError.deviceNotFound = error { return true } - return false + guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .bluetooth) else { + await newSession.stop() + return } - // MARK: - Connection Loss Handling - - /// Handles unexpected connection loss - func handleConnectionLoss(deviceID: UUID, error: Error?) async { - // Don't clobber a newer connection attempt - if connectionState == .connecting { - let activeID = connectingDeviceID ?? reconnectionCoordinator.reconnectingDeviceID - if let activeID, activeID != deviceID { - logger.info("[BLE] Ignoring connection loss for \(deviceID.uuidString.prefix(8)) — connecting to \(activeID.uuidString.prefix(8))") - return - } - } - - let stateBeforeLoss = connectionState - var errorInfo = "none" - if let error = error as NSError? { - errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" - } - logger.warning("[BLE] Connection lost: \(deviceID.uuidString.prefix(8)), currentState: \(String(describing: connectionState)), error: \(errorInfo)") - - // Cancel any pending auto-reconnect timeout and clear device identity - reconnectionCoordinator.cancelTimeout() - reconnectionCoordinator.clearReconnectingDevice() + stopReconnectionWatchdog() + logger.info("Connection complete - device ready") + } - cancelResyncLoop() + // MARK: - BLE Diagnostics Helpers - // Capture and clear synchronously, mirroring teardownSessionForReconnect: - // a same-device rebuild can install a new container during the awaits - // below, and re-reading self.services would tear that new container down. - let oldServices = services - services = nil - session = nil + func logDeviceNotFoundDiagnostics(deviceID: UUID, context: String) async { + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + let lastDeviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" + let registeredDevices = pairing.registeredDeviceInfos() + let pairedSummary = registeredDevices.prefix(5).map { info in + "\(info.name)(\(info.id.uuidString.prefix(8)))" + } + let pairedSummaryText = pairedSummary.isEmpty ? "none" : pairedSummary.joined(separator: ", ") + + logger.warning( + // swiftlint:disable:next line_length + "[BLE] Device not found diagnostics (\(context)) - device: \(deviceID.uuidString.prefix(8)), lastDevice: \(lastDeviceShort), connectionIntent: \(connectionIntent), bleState: \(bleState), blePhase: \(blePhase), pairingSessionActive: \(pairing.isSessionActive), pairedCount: \(registeredDevices.count), paired: \(pairedSummaryText)" + ) + } + + func isDeviceNotFoundError(_ error: Error) -> Bool { + if case ConnectionError.deviceNotFound = error { return true } + if case BLEError.deviceNotFound = error { return true } + return false + } + + // MARK: - Connection Loss Handling + + /// Handles unexpected connection loss + func handleConnectionLoss(deviceID: UUID, error: Error?) async { + // Don't clobber a newer connection attempt + if connectionState == .connecting { + let activeID = connectingDeviceID ?? reconnectionCoordinator.reconnectingDeviceID + if let activeID, activeID != deviceID { + logger.info("[BLE] Ignoring connection loss for \(deviceID.uuidString.prefix(8)) — connecting to \(activeID.uuidString.prefix(8))") + return + } + } - logger.warning("[BLE] State → .disconnected (connection loss for device: \(deviceID.uuidString.prefix(8)))") - connectionState = .disconnected - if connectingDeviceID == deviceID { connectingDeviceID = nil } - connectedDevice = nil - allowedRepeatFreqRanges = [] - - if let oldServices { - // Mark room sessions disconnected before tearing down services - _ = await oldServices.remoteNodeService.handleBLEDisconnection() - await oldServices.tearDown() - // Reset sync state to prevent stuck "Syncing" pill - await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) - } + let stateBeforeLoss = connectionState + var errorInfo = "none" + if let error = error as NSError? { + errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" + } + logger.warning("[BLE] Connection lost: \(deviceID.uuidString.prefix(8)), currentState: \(String(describing: connectionState)), error: \(errorInfo)") + + // Cancel any pending auto-reconnect timeout and clear device identity + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() + + cancelResyncLoop() + + // Capture and clear synchronously, mirroring teardownSessionForReconnect: + // a same-device rebuild can install a new container during the awaits + // below, and re-reading self.services would tear that new container down. + let oldServices = services + services = nil + session = nil + + logger.warning("[BLE] State → .disconnected (connection loss for device: \(deviceID.uuidString.prefix(8)))") + connectionState = .disconnected + if connectingDeviceID == deviceID { connectingDeviceID = nil } + connectedDevice = nil + allowedRepeatFreqRanges = [] + + if let oldServices { + // Mark room sessions disconnected before tearing down services + _ = await oldServices.remoteNodeService.handleBLEDisconnection() + await oldServices.tearDown() + // Reset sync state to prevent stuck "Syncing" pill + await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) + } - persistDisconnectDiagnostic( - "source=handleConnectionLoss, " + - "device=\(deviceID.uuidString.prefix(8)), " + - "stateBefore=\(String(describing: stateBeforeLoss)), " + - "error=\(errorInfo), " + - "intent=\(connectionIntent)" - ) + persistDisconnectDiagnostic( + "source=handleConnectionLoss, " + + "device=\(deviceID.uuidString.prefix(8)), " + + "stateBefore=\(String(describing: stateBeforeLoss)), " + + "error=\(errorInfo), " + + "intent=\(connectionIntent)" + ) - // Keep transport reference for iOS auto-reconnect to use + // Keep transport reference for iOS auto-reconnect to use - // Notify UI layer of connection loss - await onConnectionLost?() + // Notify UI layer of connection loss + await onConnectionLost?() - // iOS auto-reconnect handles normal disconnects via reconnectionCoordinator - // Bluetooth power-cycle handled via onBluetoothPoweredOn callback - // Watchdog provides fallback retry if both fail - if connectionIntent.wantsConnection { - startReconnectionWatchdog() - } + // iOS auto-reconnect handles normal disconnects via reconnectionCoordinator + // Bluetooth power-cycle handled via onBluetoothPoweredOn callback + // Watchdog provides fallback retry if both fail + if connectionIntent.wantsConnection { + startReconnectionWatchdog() } - - /// Publishes user-actionable Bluetooth availability for the pickers and logs the change. - /// Disconnect logic is not duplicated here — BLEStateMachine already handles - /// `.poweredOff` via `cancelCurrentOperation` which fires `onDisconnection`. - func handleBluetoothStateChange(_ state: CBManagerState) { - let stateName: String - switch state { - case .unknown: stateName = "unknown" - case .resetting: stateName = "resetting" - case .unsupported: stateName = "unsupported" - case .unauthorized: stateName = "unauthorized" - case .poweredOff: stateName = "poweredOff" - case .poweredOn: stateName = "poweredOn" - @unknown default: stateName = "unknown(\(state.rawValue))" - } - bluetoothAvailability = Self.bluetoothAvailability(for: state) - logger.info("[BLE] Bluetooth state changed: \(stateName), connectionState: \(String(describing: self.connectionState)), connectionIntent: \(self.connectionIntent)") + } + + /// Publishes user-actionable Bluetooth availability for the pickers and logs the change. + /// Disconnect logic is not duplicated here — BLEStateMachine already handles + /// `.poweredOff` via `cancelCurrentOperation` which fires `onDisconnection`. + func handleBluetoothStateChange(_ state: CBManagerState) { + let stateName = switch state { + case .unknown: "unknown" + case .resetting: "resetting" + case .unsupported: "unsupported" + case .unauthorized: "unauthorized" + case .poweredOff: "poweredOff" + case .poweredOn: "poweredOn" + @unknown default: "unknown(\(state.rawValue))" } - - /// Maps a raw `CBManagerState` to the user-actionable availability the pickers display. Only the - /// two states a person can resolve get a distinct case; transient and unsupported states stay - /// `.ready` so the picker keeps scanning rather than offering a remedy that does nothing. - private static func bluetoothAvailability(for state: CBManagerState) -> BluetoothAvailability { - switch state { - case .poweredOff: .poweredOff - case .unauthorized: .unauthorized - default: .ready - } + bluetoothAvailability = Self.bluetoothAvailability(for: state) + logger.info("[BLE] Bluetooth state changed: \(stateName), connectionState: \(String(describing: connectionState)), connectionIntent: \(connectionIntent)") + } + + /// Maps a raw `CBManagerState` to the user-actionable availability the pickers display. Only the + /// two states a person can resolve get a distinct case; transient and unsupported states stay + /// `.ready` so the picker keeps scanning rather than offering a remedy that does nothing. + private static func bluetoothAvailability(for state: CBManagerState) -> BluetoothAvailability { + switch state { + case .poweredOff: .poweredOff + case .unauthorized: .unauthorized + default: .ready } + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLEReconnectionDelegate.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLEReconnectionDelegate.swift index d0103a08..dc98175f 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLEReconnectionDelegate.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+BLEReconnectionDelegate.swift @@ -4,253 +4,252 @@ import MeshCore // MARK: - BLEReconnectionDelegate extension ConnectionManager: BLEReconnectionDelegate { - - func setConnectionState(_ state: DeviceConnectionState) { - let previousState = connectionState - connectionState = state - if state == .disconnected, previousState != .disconnected { - let transportName = switch currentTransportType { - case .bluetooth: "bluetooth" - case .wifi: "wifi" - case nil: "none" - } - persistDisconnectDiagnostic( - "source=reconnectionCoordinator.setConnectionState, " + - "previousState=\(String(describing: previousState)), " + - "transport=\(transportName), " + - "intent=\(connectionIntent)" - ) - } + func setConnectionState(_ state: DeviceConnectionState) { + let previousState = connectionState + connectionState = state + if state == .disconnected, previousState != .disconnected { + let transportName = switch currentTransportType { + case .bluetooth: "bluetooth" + case .wifi: "wifi" + case nil: "none" + } + persistDisconnectDiagnostic( + "source=reconnectionCoordinator.setConnectionState, " + + "previousState=\(String(describing: previousState)), " + + "transport=\(transportName), " + + "intent=\(connectionIntent)" + ) } - - func setConnectedDevice(_ device: DeviceDTO?) { - connectedDevice = device + } + + func setConnectedDevice(_ device: DeviceDTO?) { + connectedDevice = device + } + + func teardownSessionForReconnect() async { + // Capture and clear synchronously so a concurrent rebuildSession can + // assume nil-and-rebuild without racing the terminal writes that used + // to land at the end of this method. Subsequent awaits operate on the + // captured local — they no longer touch self.services / self.session. + let oldServices = services + services = nil + session = nil + + if let oldServices { + sessionsAwaitingReauth = await oldServices.remoteNodeService.handleBLEDisconnection() + await oldServices.tearDown() } + cancelResyncLoop() - func teardownSessionForReconnect() async { - // Capture and clear synchronously so a concurrent rebuildSession can - // assume nil-and-rebuild without racing the terminal writes that used - // to land at the end of this method. Subsequent awaits operate on the - // captured local — they no longer touch self.services / self.session. - let oldServices = services - services = nil - session = nil - - if let oldServices { - sessionsAwaitingReauth = await oldServices.remoteNodeService.handleBLEDisconnection() - await oldServices.tearDown() - } - cancelResyncLoop() - - // Reset sync state on the captured services to prevent stuck "Syncing" pill - if let oldServices { - await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) - } + // Reset sync state on the captured services to prevent stuck "Syncing" pill + if let oldServices { + await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) + } + } + + /// Background execution note: iOS provides ~10s of background execution time. + /// Session rebuild (transport + session.start) should complete within this window. + /// Full sync is deferred until performInitialSync returns to foreground via onConnectionEstablished. + func rebuildSession(deviceID: UUID) async throws { + logger.info("[BLE] Rebuilding session for auto-reconnect: \(deviceID.uuidString.prefix(8))") + let expectedGeneration = reconnectionCoordinator.reconnectGeneration + sessionRebuildDeviceID = deviceID + defer { + if sessionRebuildDeviceID == deviceID { + sessionRebuildDeviceID = nil + } } - // Background execution note: iOS provides ~10s of background execution time. - // Session rebuild (transport + session.start) should complete within this window. - // Full sync is deferred until performInitialSync returns to foreground via onConnectionEstablished. - func rebuildSession(deviceID: UUID) async throws { - logger.info("[BLE] Rebuilding session for auto-reconnect: \(deviceID.uuidString.prefix(8))") - let expectedGeneration = reconnectionCoordinator.reconnectGeneration - sessionRebuildDeviceID = deviceID - defer { - if sessionRebuildDeviceID == deviceID { - sessionRebuildDeviceID = nil - } - } - - // Stop any existing session to prevent multiple receive loops racing for transport data - await session?.stop() - session = nil - - let newSession = MeshCoreSession(transport: transport) - self.session = newSession - - do { - try await newSession.start(reconnectingAttempt: 1) - } catch { - logger.warning("[BLE] rebuildSession: session.start() failed: \(error.localizedDescription)") - throw error - } - - // Check after await — user may have disconnected or a new reconnect cycle may have started - guard connectionIntent.wantsConnection else { - logger.info("User disconnected during session setup") - await newSession.stop() - connectionState = .disconnected - return - } - guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { - logger.info("[BLE] rebuildSession superseded by new reconnect cycle during session setup") - await newSession.stop() - return - } - - guard let selfInfo = await newSession.currentSelfInfo else { - logger.warning("[BLE] rebuildSession: selfInfo is nil after start()") - throw ConnectionError.initializationFailed("No self info") - } - let capabilities: DeviceCapabilities - do { - capabilities = try await newSession.queryDevice() - } catch { - logger.warning("[BLE] rebuildSession: queryDevice() failed: \(error.localizedDescription)") - throw error - } - - // Configure BLE write pacing based on device platform - await configureBLEPacing(for: capabilities) - - // Check after await - guard connectionIntent.wantsConnection else { - logger.info("User disconnected during device query") - await newSession.stop() - connectionState = .disconnected - return - } - guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { - logger.info("[BLE] rebuildSession superseded by new reconnect cycle during device query") - await newSession.stop() - return - } - - let (newServices, radioID) = try await buildServicesAndSaveDevice( - deviceID: deviceID, - session: newSession, - selfInfo: selfInfo, - capabilities: capabilities - ) - - // Check after await — user may have disconnected or new reconnect cycle started - guard connectionIntent.wantsConnection else { - logger.info("User disconnected during service wiring") - await newSession.stop() - await newServices.tearDown() - services = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - connectionState = .disconnected - return - } - guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { - logger.info("[BLE] rebuildSession superseded by new reconnect cycle during service wiring") - await newSession.stop() - await newServices.tearDown() - services = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - return - } - - // Notify observers before sync starts so they can wire callbacks - await onConnectionReady?() - // onConnectionReady can suspend; a reentrant main-actor disconnect or - // reconnect-UI timeout may clear connectedDevice or replace services during - // that await. Recheck so an aborted reconnect bails here instead of syncing - // a torn-down session. - guard connectionIntent.wantsConnection, - reconnectionCoordinator.reconnectGeneration == expectedGeneration, - self.services === newServices, - connectedDevice != nil - else { - logger.info("[BLE] rebuildSession aborted after onConnectionReady: reconnect state changed") - await newSession.stop() - await newServices.tearDown() - services = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - return - } - let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, context: "[BLE] iOS auto-reconnect") + // Stop any existing session to prevent multiple receive loops racing for transport data + await session?.stop() + session = nil - // Caller-specific guard: generation check for superseded reconnects - guard connectionIntent.wantsConnection, - reconnectionCoordinator.reconnectGeneration == expectedGeneration, - self.services === newServices - else { - await newSession.stop() - return - } + let newSession = MeshCoreSession(transport: transport) + session = newSession - if syncSucceeded { - // Re-authenticate room sessions (sends BLE commands — skip on failure path). - let sessionIDs = sessionsAwaitingReauth - await newServices.remoteNodeService.handleBLEReconnection(sessionIDs: sessionIDs) + do { + try await newSession.start(reconnectingAttempt: 1) + } catch { + logger.warning("[BLE] rebuildSession: session.start() failed: \(error.localizedDescription)") + throw error + } - guard connectionIntent.wantsConnection, - reconnectionCoordinator.reconnectGeneration == expectedGeneration, - self.services === newServices - else { - // IDs preserved for next reconnect cycle — new IDs may have - // arrived during handleBLEReconnection if BLE dropped mid-reauth. - await newSession.stop() - return - } + // Check after await — user may have disconnected or a new reconnect cycle may have started + guard connectionIntent.wantsConnection else { + logger.info("User disconnected during session setup") + await newSession.stop() + connectionState = .disconnected + return + } + guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { + logger.info("[BLE] rebuildSession superseded by new reconnect cycle during session setup") + await newSession.stop() + return + } - // Only clear consumed IDs after confirming this cycle is still authoritative. - // Any IDs appended during the await (via teardownSessionForReconnect) survive. - sessionsAwaitingReauth.subtract(sessionIDs) - } + guard let selfInfo = await newSession.currentSelfInfo else { + logger.warning("[BLE] rebuildSession: selfInfo is nil after start()") + throw ConnectionError.initializationFailed("No self info") + } + let capabilities: DeviceCapabilities + do { + capabilities = try await newSession.queryDevice() + } catch { + logger.warning("[BLE] rebuildSession: queryDevice() failed: \(error.localizedDescription)") + throw error + } - guard await promoteToReady( - syncSucceeded: syncSucceeded, - expectedServices: newServices, - transportType: .bluetooth, - additionalGuard: { [reconnectionCoordinator] in - reconnectionCoordinator.reconnectGeneration == expectedGeneration - } - ) else { - await newSession.stop() - return - } + // Configure BLE write pacing based on device platform + await configureBLEPacing(for: capabilities) - recordConnectionSuccess() - stopReconnectionWatchdog() - logger.info("[BLE] iOS auto-reconnect: session ready, device: \(deviceID.uuidString.prefix(8))") + // Check after await + guard connectionIntent.wantsConnection else { + logger.info("User disconnected during device query") + await newSession.stop() + connectionState = .disconnected + return } - - func disconnectTransport() async { - await transport.disconnect() + guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { + logger.info("[BLE] rebuildSession superseded by new reconnect cycle during device query") + await newSession.stop() + return } - func notifyConnectionLost() async { - await onConnectionLost?() + let (newServices, radioID) = try await buildServicesAndSaveDevice( + deviceID: deviceID, + session: newSession, + selfInfo: selfInfo, + capabilities: capabilities + ) + + // Check after await — user may have disconnected or new reconnect cycle started + guard connectionIntent.wantsConnection else { + logger.info("User disconnected during service wiring") + await newSession.stop() + await newServices.tearDown() + services = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + connectionState = .disconnected + return } - - func isTransportAutoReconnecting() async -> Bool { - await stateMachine.isAutoReconnecting + guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { + logger.info("[BLE] rebuildSession superseded by new reconnect cycle during service wiring") + await newSession.stop() + await newServices.tearDown() + services = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + return } - func handleReconnectionFailure() async { - logger.error("[BLE] Auto-reconnect session rebuild failed") - - // Capture and clear synchronously, mirroring teardownSessionForReconnect: - // a concurrent rebuild can install a new session and container during the - // awaits below, and re-reading self.session / self.services would tear - // the replacements down. - let oldSession = session - let oldServices = services - session = nil - services = nil - connectionState = .disconnected - connectedDevice = nil - allowedRepeatFreqRanges = [] + // Notify observers before sync starts so they can wire callbacks + await onConnectionReady?() + // onConnectionReady can suspend; a reentrant main-actor disconnect or + // reconnect-UI timeout may clear connectedDevice or replace services during + // that await. Recheck so an aborted reconnect bails here instead of syncing + // a torn-down session. + guard connectionIntent.wantsConnection, + reconnectionCoordinator.reconnectGeneration == expectedGeneration, + services === newServices, + connectedDevice != nil + else { + logger.info("[BLE] rebuildSession aborted after onConnectionReady: reconnect state changed") + await newSession.stop() + await newServices.tearDown() + services = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + return + } + let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, context: "[BLE] iOS auto-reconnect") + + // Caller-specific guard: generation check for superseded reconnects + guard connectionIntent.wantsConnection, + reconnectionCoordinator.reconnectGeneration == expectedGeneration, + services === newServices + else { + await newSession.stop() + return + } - await oldSession?.stop() - await oldServices?.tearDown() - await transport.disconnect() + if syncSucceeded { + // Re-authenticate room sessions (sends BLE commands — skip on failure path). + let sessionIDs = sessionsAwaitingReauth + await newServices.remoteNodeService.handleBLEReconnection(sessionIDs: sessionIDs) + + guard connectionIntent.wantsConnection, + reconnectionCoordinator.reconnectGeneration == expectedGeneration, + services === newServices + else { + // IDs preserved for next reconnect cycle — new IDs may have + // arrived during handleBLEReconnection if BLE dropped mid-reauth. + await newSession.stop() + return + } + + // Only clear consumed IDs after confirming this cycle is still authoritative. + // Any IDs appended during the await (via teardownSessionForReconnect) survive. + sessionsAwaitingReauth.subtract(sessionIDs) + } - // Same callback contract as handleConnectionLoss and the UI-timeout - // path in BLEReconnectionCoordinator: route through notifyConnectionLost() - // so AppState tears down its observers and the Live Activity transitions - // to disconnected. Without this the LA stays in stale "connected" state. - await notifyConnectionLost() + guard await promoteToReady( + syncSucceeded: syncSucceeded, + expectedServices: newServices, + transportType: .bluetooth, + additionalGuard: { [reconnectionCoordinator] in + reconnectionCoordinator.reconnectGeneration == expectedGeneration + } + ) else { + await newSession.stop() + return + } - // Start watchdog to periodically retry if user still wants connection - if connectionIntent.wantsConnection { - startReconnectionWatchdog() - } + recordConnectionSuccess() + stopReconnectionWatchdog() + logger.info("[BLE] iOS auto-reconnect: session ready, device: \(deviceID.uuidString.prefix(8))") + } + + func disconnectTransport() async { + await transport.disconnect() + } + + func notifyConnectionLost() async { + await onConnectionLost?() + } + + func isTransportAutoReconnecting() async -> Bool { + await stateMachine.isAutoReconnecting + } + + func handleReconnectionFailure() async { + logger.error("[BLE] Auto-reconnect session rebuild failed") + + // Capture and clear synchronously, mirroring teardownSessionForReconnect: + // a concurrent rebuild can install a new session and container during the + // awaits below, and re-reading self.session / self.services would tear + // the replacements down. + let oldSession = session + let oldServices = services + session = nil + services = nil + connectionState = .disconnected + connectedDevice = nil + allowedRepeatFreqRanges = [] + + await oldSession?.stop() + await oldServices?.tearDown() + await transport.disconnect() + + // Same callback contract as handleConnectionLoss and the UI-timeout + // path in BLEReconnectionCoordinator: route through notifyConnectionLost() + // so AppState tears down its observers and the Live Activity transitions + // to disconnected. Without this the LA stays in stale "connected" state. + await notifyConnectionLost() + + // Start watchdog to periodically retry if user still wants connection + if connectionIntent.wantsConnection { + startReconnectionWatchdog() } + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift index 2e1f3ce5..ac777931 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift @@ -1,771 +1,771 @@ import Foundation import MeshCore -extension ConnectionManager { - var activeConnectionAttemptDeviceID: UUID? { - connectingDeviceID ?? sessionRebuildDeviceID ?? reconnectionCoordinator.reconnectingDeviceID +public extension ConnectionManager { + internal var activeConnectionAttemptDeviceID: UUID? { + connectingDeviceID ?? sessionRebuildDeviceID ?? reconnectionCoordinator.reconnectingDeviceID + } + + internal var activeReconnectDeviceID: UUID? { + sessionRebuildDeviceID ?? reconnectionCoordinator.reconnectingDeviceID + } + + // MARK: - App Lifecycle + + /// Called when the app enters background. Pauses foreground-only BLE operations. + func appDidEnterBackground() async { + let transportName = switch currentTransportType { + case .bluetooth: "bluetooth" + case .wifi: "wifi" + case nil: "none" } - - var activeReconnectDeviceID: UUID? { - sessionRebuildDeviceID ?? reconnectionCoordinator.reconnectingDeviceID + logger.info( + "[BLE] Lifecycle transition: entering background, " + + "transport: \(transportName), " + + "connectionIntent: \(connectionIntent), " + + "connectionState: \(String(describing: connectionState))" + ) + await stateMachine.appDidEnterBackground() + stopReconnectionWatchdog() + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + logger.info( + "[BLE] Lifecycle transition complete: backgrounded, " + + "bleState: \(bleState), " + + "blePhase: \(blePhase)" + ) + } + + /// Called when the app becomes active. Reconciles BLE state and restarts + /// foreground operations. + func appDidBecomeActive() async { + let transportName = switch currentTransportType { + case .bluetooth: "bluetooth" + case .wifi: "wifi" + case nil: "none" } - - // MARK: - App Lifecycle - - /// Called when the app enters background. Pauses foreground-only BLE operations. - public func appDidEnterBackground() async { - let transportName = switch currentTransportType { - case .bluetooth: "bluetooth" - case .wifi: "wifi" - case nil: "none" - } - logger.info( - "[BLE] Lifecycle transition: entering background, " + - "transport: \(transportName), " + - "connectionIntent: \(connectionIntent), " + - "connectionState: \(String(describing: connectionState))" - ) - await stateMachine.appDidEnterBackground() - stopReconnectionWatchdog() - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - logger.info( - "[BLE] Lifecycle transition complete: backgrounded, " + - "bleState: \(bleState), " + - "blePhase: \(blePhase)" - ) + logger.info( + "[BLE] Lifecycle transition: becoming active, " + + "transport: \(transportName), " + + "connectionIntent: \(connectionIntent), " + + "connectionState: \(String(describing: connectionState))" + ) + await stateMachine.appDidBecomeActive() + await checkBLEConnectionHealth() + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + logger.info( + "[BLE] Lifecycle transition complete: active health check finished, " + + "connectionState: \(String(describing: connectionState)), " + + "bleState: \(bleState), " + + "blePhase: \(blePhase)" + ) + + guard currentTransportType == nil || currentTransportType == .bluetooth else { return } + guard connectionIntent.wantsConnection, connectionState == .disconnected else { return } + + if shouldDeferOpportunisticReconnect { + logger.info("[BLE] ConnectionManager: not re-arming watchdog on foreground (pairing in progress)") + return } - /// Called when the app becomes active. Reconciles BLE state and restarts - /// foreground operations. - public func appDidBecomeActive() async { - let transportName = switch currentTransportType { - case .bluetooth: "bluetooth" - case .wifi: "wifi" - case nil: "none" - } - logger.info( - "[BLE] Lifecycle transition: becoming active, " + - "transport: \(transportName), " + - "connectionIntent: \(connectionIntent), " + - "connectionState: \(String(describing: connectionState))" - ) - await stateMachine.appDidBecomeActive() - await checkBLEConnectionHealth() - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - logger.info( - "[BLE] Lifecycle transition complete: active health check finished, " + - "connectionState: \(String(describing: connectionState)), " + - "bleState: \(bleState), " + - "blePhase: \(blePhase)" - ) - - guard currentTransportType == nil || currentTransportType == .bluetooth else { return } - guard connectionIntent.wantsConnection, connectionState == .disconnected else { return } - - if shouldDeferOpportunisticReconnect { - logger.info("[BLE] ConnectionManager: not re-arming watchdog on foreground (pairing in progress)") - return - } - - if await stateMachine.isAutoReconnecting { - logger.info("[BLE] ConnectionManager: not re-arming watchdog on foreground (iOS auto-reconnect in progress)") - return - } - - startReconnectionWatchdog() - logger.info("[BLE] ConnectionManager: re-armed watchdog on foreground while disconnected") + if await stateMachine.isAutoReconnecting { + logger.info("[BLE] ConnectionManager: not re-arming watchdog on foreground (iOS auto-reconnect in progress)") + return } - // MARK: - Sync Health + startReconnectionWatchdog() + logger.info("[BLE] ConnectionManager: re-armed watchdog on foreground while disconnected") + } - /// Triggers resync if connected but sync state is failed. - /// Called when app returns to foreground. - public func checkSyncHealth() async { - guard connectionState.isOperational, - connectionIntent.wantsConnection, - let services, - let radioID = connectedDevice?.radioID else { return } + // MARK: - Sync Health - let syncCoordinator = services.syncCoordinator - let syncState = syncCoordinator.state - guard case .failed = syncState else { return } + /// Triggers resync if connected but sync state is failed. + /// Called when app returns to foreground. + func checkSyncHealth() async { + guard connectionState.isOperational, + connectionIntent.wantsConnection, + let services, + let radioID = connectedDevice?.radioID else { return } - guard resyncTask == nil else { - logger.info("Resync loop already running, skipping foreground trigger") - return - } + let syncCoordinator = services.syncCoordinator + let syncState = syncCoordinator.state + guard case .failed = syncState else { return } - logger.info("Foreground return: sync state is failed, starting resync loop") - startResyncLoop(radioID: radioID, services: services, transportType: currentTransportType ?? .bluetooth) + guard resyncTask == nil else { + logger.info("Resync loop already running, skipping foreground trigger") + return } - // MARK: - Activation - - /// Activates the connection manager on app launch. - /// Call this once during app initialization. - public func activate() async { - // Ensure lifecycle handlers are installed before anything below can - // construct the CBCentralManager; creating the central can fire - // restoration callbacks synchronously, and a missed handler would - // strand the restored link without a session rebuild. - await wireTransportHandlers() - - let lastDeviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" - let bleState = await stateMachine.centralManagerStateName - logger.info(""" - Activating ConnectionManager - \ - connectionIntent: \(connectionIntent), \ - lastConnectedDeviceID: \(lastDeviceShort), \ - connectionState: \(String(describing: connectionState)), \ - bleState: \(bleState) - """) - - // Reset stale room session connections from previous app launch - let resetStore = createStandalonePersistenceStore() - try? await resetStore.resetAllRemoteNodeSessionConnections() - - // Populate radioID on existing devices and backfill deduplication keys (one-time migration) - do { - try await resetStore.performRadioIDMigration() - } catch { - logger.error("radioID migration failed: \(error)") - } + logger.info("Foreground return: sync state is failed, starting resync loop") + startResyncLoop(radioID: radioID, services: services, transportType: currentTransportType ?? .bluetooth) + } + + // MARK: - Activation + + /// Activates the connection manager on app launch. + /// Call this once during app initialization. + func activate() async { + // Ensure lifecycle handlers are installed before anything below can + // construct the CBCentralManager; creating the central can fire + // restoration callbacks synchronously, and a missed handler would + // strand the restored link without a session rebuild. + await wireTransportHandlers() + + let lastDeviceShort = lastConnectedDeviceID?.uuidString.prefix(8) ?? "none" + let bleState = await stateMachine.centralManagerStateName + logger.info(""" + Activating ConnectionManager - \ + connectionIntent: \(connectionIntent), \ + lastConnectedDeviceID: \(lastDeviceShort), \ + connectionState: \(String(describing: connectionState)), \ + bleState: \(bleState) + """) + + // Reset stale room session connections from previous app launch + let resetStore = createStandalonePersistenceStore() + try? await resetStore.resetAllRemoteNodeSessionConnections() + + // Populate radioID on existing devices and backfill deduplication keys (one-time migration) + do { + try await resetStore.performRadioIDMigration() + } catch { + logger.error("radioID migration failed: \(error)") + } - // Promote legacy per-channel region overrides to `.specific` mode so the - // corrective flood-scope semantics don't reinterpret them as `.inherit`. - do { - try await resetStore.performChannelFloodScopeMigration() - } catch { - logger.error("channel flood-scope migration failed: \(error)") - } + // Promote legacy per-channel region overrides to `.specific` mode so the + // corrective flood-scope semantics don't reinterpret them as `.inherit`. + do { + try await resetStore.performChannelFloodScopeMigration() + } catch { + logger.error("channel flood-scope migration failed: \(error)") + } - // Zero accumulated unread counts on repeater-type contacts and repeater-role - // sessions so the badge stops including invisible records. - do { - try await resetStore.performRepeaterUnreadCountMigration() - } catch { - logger.error("repeater unread-count migration failed: \(error)") - } + // Zero accumulated unread counts on repeater-type contacts and repeater-role + // sessions so the badge stops including invisible records. + do { + try await resetStore.performRepeaterUnreadCountMigration() + } catch { + logger.error("repeater unread-count migration failed: \(error)") + } - // Backfill sortDate from createdAt on pre-existing messages so date-header - // grouping keeps their current display order. - do { - try await resetStore.performSortDateBackfillMigration() - } catch { - logger.error("sortDate backfill migration failed: \(error)") - } + // Backfill sortDate from createdAt on pre-existing messages so date-header + // grouping keeps their current display order. + do { + try await resetStore.performSortDateBackfillMigration() + } catch { + logger.error("sortDate backfill migration failed: \(error)") + } - // Re-normalize sortDate to createdAt for installs whose backfill already ran - // before the block-at-reconnect change, un-burying any rows an interim build - // sorted by send time. Must run before stateMachine.activate() so no - // restoration-driven sync writes a fresh anchor before this resets the baseline. - do { - try await resetStore.performSortDateResetMigration() - } catch { - logger.error("sortDate reset migration failed: \(error)") - } + // Re-normalize sortDate to createdAt for installs whose backfill already ran + // before the block-at-reconnect change, un-burying any rows an interim build + // sorted by send time. Must run before stateMachine.activate() so no + // restoration-driven sync writes a fresh anchor before this resets the baseline. + do { + try await resetStore.performSortDateResetMigration() + } catch { + logger.error("sortDate reset migration failed: \(error)") + } - #if targetEnvironment(simulator) - // Skip auto-reconnect if user explicitly disconnected - if connectionIntent.isUserDisconnected { - logger.info("Simulator: skipping auto-reconnect - user previously disconnected") - return - } - // On simulator, skip ASK entirely and auto-reconnect to simulator device - if let lastDeviceID = lastConnectedDeviceID, - lastDeviceID == MockDataProvider.simulatorDeviceID { - logger.info("Simulator: auto-reconnecting to mock device") - connectionIntent = .wantsConnection() - do { - try await simulatorConnect() - } catch { - logger.warning("Simulator auto-reconnect failed: \(error.localizedDescription)") - } - return - } - // Simulator doesn't support real BLE devices - show connection UI for simulator pairing + #if targetEnvironment(simulator) + // Skip auto-reconnect if user explicitly disconnected + if connectionIntent.isUserDisconnected { + logger.info("Simulator: skipping auto-reconnect - user previously disconnected") return - #else - // Activate the pairing session early; on iOS this is AccessorySetupKit, required for - // ASK events and iOS 26 state restoration. On macOS this is a no-op. + } + // On simulator, skip ASK entirely and auto-reconnect to simulator device + if let lastDeviceID = lastConnectedDeviceID, + lastDeviceID == MockDataProvider.simulatorDeviceID { + logger.info("Simulator: auto-reconnecting to mock device") + connectionIntent = .wantsConnection() do { - try await pairing.activate() + try await simulatorConnect() } catch { - logger.error("Failed to activate pairing session: \(error.localizedDescription)") - // Don't return - WiFi doesn't need the pairing session - } - - // Skip auto-reconnect if user explicitly disconnected - if connectionIntent.isUserDisconnected { - logger.info("Skipping auto-reconnect: user previously disconnected") - return + logger.warning("Simulator auto-reconnect failed: \(error.localizedDescription)") } + return + } + // Simulator doesn't support real BLE devices - show connection UI for simulator pairing + return + #else + // Activate the pairing session early; on iOS this is AccessorySetupKit, required for + // ASK events and iOS 26 state restoration. On macOS this is a no-op. + do { + try await pairing.activate() + } catch { + logger.error("Failed to activate pairing session: \(error.localizedDescription)") + // Don't return - WiFi doesn't need the pairing session + } + + // Skip auto-reconnect if user explicitly disconnected + if connectionIntent.isUserDisconnected { + logger.info("Skipping auto-reconnect: user previously disconnected") + return + } - // Auto-reconnect to last device if available - if let lastDeviceID = lastConnectedDeviceID { - logger.info("Attempting auto-reconnect to last device: \(lastDeviceID)") - - // Set intent before checking state - connectionIntent = .wantsConnection() - - // Check if last device was WiFi - try WiFi first - let dataStore = PersistenceStore(modelContainer: modelContainer) - if let device = try? await dataStore.fetchDevice(id: lastDeviceID), - let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { - if case .wifi(let host, let port, _) = wifiMethod { - logger.info("Auto-reconnecting via WiFi to \(host):\(port)") - do { - try await connectViaWiFi(host: host, port: port) - return - } catch { - logger.warning("WiFi auto-reconnect failed: \(error.localizedDescription)") - // Fall through to try BLE - } - } - } - - // Activate BLE state machine before checking BLE state restoration status. - // Must be after: ASK activation (line 700), explicit disconnect guard (line 709). - // Must be before: isAutoReconnecting check, isDeviceConnectedToSystem. - await stateMachine.activate() - - // If state machine is already auto-reconnecting (from state restoration), - // let it complete rather than fighting with it - if await stateMachine.isAutoReconnecting { - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - logger.info( - "State restoration in progress - blePhase: \(blePhase), blePeripheralState: \(blePeripheralState), waiting for auto-reconnect" - ) - return - } - - if await stateMachine.isConnected, await stateMachine.connectedDeviceID == lastDeviceID { - logger.info("State restoration complete - device already connected, waiting for session setup") - return - } - - // If iOS kept the BLE link alive (common across app updates) but restoration didn't fire, - // adopt the system-connected peripheral rather than treating it as "connected elsewhere". - if await startAdoptingLastSystemConnectedPeripheralIfAvailable( - deviceID: lastDeviceID, - context: "activate" - ) { - return - } + // Auto-reconnect to last device if available + if let lastDeviceID = lastConnectedDeviceID { + logger.info("Attempting auto-reconnect to last device: \(lastDeviceID)") - // Check if device is connected to another app before auto-reconnect - // Silently skip per HIG: minimize interruptions on app launch - if await isDeviceConnectedToOtherApp(lastDeviceID) { - logger.info("Auto-reconnect skipped: device connected to another app") - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - persistDisconnectDiagnostic( - "source=activate.autoReconnectSkippedOtherApp, " + - "device=\(lastDeviceID.uuidString.prefix(8)), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState), " + - "intent=\(connectionIntent)" - ) - - // Keep intent so we can retry on foreground/watchdog, but avoid - // fighting another app's connection on launch. - startReconnectionWatchdog() - return - } + // Set intent before checking state + connectionIntent = .wantsConnection() + // Check if last device was WiFi - try WiFi first + let dataStore = PersistenceStore(modelContainer: modelContainer) + if let device = try? await dataStore.fetchDevice(id: lastDeviceID), + let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { + if case let .wifi(host, port, _) = wifiMethod { + logger.info("Auto-reconnecting via WiFi to \(host):\(port)") do { - try await connect(to: lastDeviceID) + try await connectViaWiFi(host: host, port: port) + return } catch { - logger.warning("Auto-reconnect failed: \(error.localizedDescription)") - let bleState = await stateMachine.centralManagerStateName - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - persistDisconnectDiagnostic( - "source=activate.autoReconnectFailed, " + - "device=\(lastDeviceID.uuidString.prefix(8)), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState), " + - "error=\(error.localizedDescription), " + - "intent=\(connectionIntent)" - ) - startReconnectionWatchdog() - // Don't propagate - auto-reconnect failure is not fatal + logger.warning("WiFi auto-reconnect failed: \(error.localizedDescription)") + // Fall through to try BLE } - } else { - logger.info("No last connected device - skipping auto-reconnect") - } - #endif - } - - // MARK: - Connection - - /// Connects to a previously paired device. - /// - /// This method handles all connection scenarios: - /// - If disconnected: connects to the device - /// - If already connected to this device: no-op - /// - If connected to a different device: switches to the new device - /// - /// - Parameters: - /// - deviceID: The UUID of the device to connect to - /// - forceFullSync: Whether to force a full sync instead of incremental - /// - forceReconnect: When `true`, marks the connect as user-initiated: it bypasses the - /// circuit breaker, and on a platform without a system pairing registry (macOS) it also - /// bounds the retry budget to `unverifiedConnectAttempts`, so a tap on an out-of-range - /// radio fails fast instead of hanging the full budget. Background reconnects pass - /// `false` to keep the full budget for unattended recovery. - /// - Throws: Connection errors - public func connect(to deviceID: UUID, forceFullSync: Bool = false, forceReconnect: Bool = false) async throws { - // Honor cancellation before any state mutation. Without this checkpoint - // a cancelled `pairNewDevice` task whose preceding awaits all swallowed - // cancellation would still drive a real BLE connect to completion. - try Task.checkCancellation() - - // Circuit breaker: prevent rapid reconnection loops after repeated failures - guard shouldAllowConnection(force: forceReconnect) else { - logger.info("[BLE] Circuit breaker open, rejecting connection to \(deviceID.uuidString.prefix(8))") - throw BLEError.connectionFailed("Connection blocked by circuit breaker (cooling down)") + } } - if activeReconnectDeviceID == deviceID { - connectionIntent = .wantsConnection(forceFullSync: forceFullSync) - persistIntent() - if sessionRebuildDeviceID != deviceID { - reconnectionCoordinator.restartTimeout(deviceID: deviceID) - } - logger.info("[BLE] Reconnect already in progress for \(deviceID.uuidString.prefix(8)), deferring duplicate connect request") - return - } - - // Prevent concurrent connection attempts - if connectionState == .connecting { - let currentDeviceID = activeConnectionAttemptDeviceID - - if currentDeviceID == deviceID { - if connectingDeviceID == nil { - // Auto-reconnect same device — refresh UI timeout - connectionIntent = .wantsConnection(forceFullSync: forceFullSync) - persistIntent() - reconnectionCoordinator.restartTimeout(deviceID: deviceID) - } - logger.info("Connection already in progress for \(deviceID.uuidString.prefix(8)), ignoring") - return - } + // Activate BLE state machine before checking BLE state restoration status. + // Must be after: ASK activation (line 700), explicit disconnect guard (line 709). + // Must be before: isAutoReconnecting check, isDeviceConnectedToSystem. + await stateMachine.activate() - // Different device — cancel current and fall through - logger.info("Cancelling connection to \(currentDeviceID?.uuidString.prefix(8) ?? "unknown") to connect to \(deviceID.uuidString.prefix(8))") - connectingDeviceID = nil - reconnectionCoordinator.cancelTimeout() - reconnectionCoordinator.clearReconnectingDevice() - cancelResyncLoop() - stopReconnectionWatchdog() - await cleanupResources() - await transport.disconnect() - connectionState = .disconnected - } - - // Handle already-connected cases - if connectionState != .disconnected { - if connectedDevice?.id == deviceID { - logger.info("Already connected to device: \(deviceID)") - return - } - // Connected to different device - switch to new one - logger.info("Switching from current device to: \(deviceID)") - try await switchDevice(to: deviceID) - return - } - - // Claim the attempt before the pre-connect awaits below. Two connect - // calls interleaving at those suspension points would otherwise both - // pass the guards above and build duplicate sessions over one - // transport. The newest claim wins; a superseded call observes the - // mismatch after each await and bails. - if connectingDeviceID == deviceID { - logger.info("Connection already claimed for \(deviceID.uuidString.prefix(8)), ignoring") - return + // If state machine is already auto-reconnecting (from state restoration), + // let it complete rather than fighting with it + if await stateMachine.isAutoReconnecting { + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + logger.info( + "State restoration in progress - blePhase: \(blePhase), blePeripheralState: \(blePeripheralState), waiting for auto-reconnect" + ) + return } - connectingDeviceID = deviceID - // Handle state restoration auto-reconnect - let transportAutoReconnecting = await stateMachine.isAutoReconnecting - guard connectingDeviceID == deviceID else { throw CancellationError() } - if transportAutoReconnecting { - let restoringDeviceID = await stateMachine.connectedDeviceID - let blePhase = await stateMachine.currentPhaseName - let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" - guard connectingDeviceID == deviceID else { throw CancellationError() } - - if restoringDeviceID != deviceID { - logger.info("Cancelling state restoration auto-reconnect to \(restoringDeviceID?.uuidString ?? "unknown") to connect to \(deviceID)") - await transport.disconnect() - guard connectingDeviceID == deviceID else { throw CancellationError() } - } else { - // Same device - let auto-reconnect complete instead of racing with it. - // The reconnection handler will create the session when auto-reconnect succeeds. - // Release the claim: the reconnect cycle owns identity tracking from here. - connectingDeviceID = nil - // Preserve user intent so the watchdog can retry if auto-reconnect fails. - connectionIntent = .wantsConnection(forceFullSync: forceFullSync) - persistIntent() - // Show connecting UI so the user sees their tap did something - if connectionState != .connecting { - connectionState = .connecting - } - // Re-arm timeout in case the previous one already fired - reconnectionCoordinator.restartTimeout(deviceID: deviceID) - logger.warning( - "[BLE] Deferring to iOS auto-reconnect for device \(deviceID.uuidString.prefix(8)) - connectionState: \(String(describing: connectionState)), blePhase: \(blePhase), blePeripheralState: \(blePeripheralState)" - ) - return - } + if await stateMachine.isConnected, await stateMachine.connectedDeviceID == lastDeviceID { + logger.info("State restoration complete - device already connected, waiting for session setup") + return } - // If the user is reconnecting to the last radio and iOS still has a system-level BLE link - // (common after app updates), adopt the existing link rather than blocking as "connected elsewhere". - if deviceID == lastConnectedDeviceID { - connectionIntent = .wantsConnection(forceFullSync: forceFullSync) - persistIntent() - - if await startAdoptingLastSystemConnectedPeripheralIfAvailable( - deviceID: deviceID, - context: "connect(to:)" - ) { - // Adoption owns the reconnect cycle now; release the claim. - if connectingDeviceID == deviceID { connectingDeviceID = nil } - return - } - guard connectingDeviceID == deviceID else { throw CancellationError() } + // If iOS kept the BLE link alive (common across app updates) but restoration didn't fire, + // adopt the system-connected peripheral rather than treating it as "connected elsewhere". + if await startAdoptingLastSystemConnectedPeripheralIfAvailable( + deviceID: lastDeviceID, + context: "activate" + ) { + return } - // Check for other app connection before changing state - if await isDeviceConnectedToOtherApp(deviceID) { - if connectingDeviceID == deviceID { connectingDeviceID = nil } - throw BLEError.deviceConnectedToOtherApp + // Check if device is connected to another app before auto-reconnect + // Silently skip per HIG: minimize interruptions on app launch + if await isDeviceConnectedToOtherApp(lastDeviceID) { + logger.info("Auto-reconnect skipped: device connected to another app") + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + persistDisconnectDiagnostic( + "source=activate.autoReconnectSkippedOtherApp, " + + "device=\(lastDeviceID.uuidString.prefix(8)), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState), " + + "intent=\(connectionIntent)" + ) + + // Keep intent so we can retry on foreground/watchdog, but avoid + // fighting another app's connection on launch. + startReconnectionWatchdog() + return } - guard connectingDeviceID == deviceID else { throw CancellationError() } - - // Clear intentional disconnect flag before changing state, - // so the didSet invariant check sees consistent state - connectionIntent = .wantsConnection(forceFullSync: forceFullSync) - persistIntent() - - // Set connecting state for immediate UI feedback - // (connectingDeviceID was already claimed above) - connectionState = .connecting - - logger.info("Connecting to device: \(deviceID)") - - // Cancel any pending auto-reconnect timeout and clear device identity - reconnectionCoordinator.cancelTimeout() - reconnectionCoordinator.clearReconnectingDevice() do { - // Validate device is still registered with the pairing registry. macOS reports - // no active session, so this guard is skipped and CoreBluetooth resolves the device. - if pairing.isSessionActive { - let isConnectable = pairing.isDeviceConnectable(deviceID) - - if !isConnectable { - await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "connect(to:) paired accessories mismatch") - throw ConnectionError.deviceNotFound - } - } - - // Attempt connection with retry. Without a system pairing registry (macOS), - // CoreBluetooth cannot pre-reject an absent saved peripheral, so a user-initiated - // tap on an out-of-range radio would hang the full retry budget. Bound user taps - // there; background reconnects (forceReconnect == false) keep the full budget so - // unattended recovery still gets every attempt. - let maxAttempts = (forceReconnect && !pairing.hasSystemPairingRegistry) - ? Self.unverifiedConnectAttempts - : Self.defaultConnectAttempts - try await connectWithRetry(deviceID: deviceID, maxAttempts: maxAttempts) + try await connect(to: lastDeviceID) } catch { - guard connectingDeviceID == deviceID else { - logger.info("Connection to \(deviceID.uuidString.prefix(8)) superseded") - throw error - } - if error is CancellationError { - logger.info("Connection cancelled") - } else { - logger.warning("Connection failed: \(error.localizedDescription)") - } - connectingDeviceID = nil - connectionState = .disconnected - throw error + logger.warning("Auto-reconnect failed: \(error.localizedDescription)") + let bleState = await stateMachine.centralManagerStateName + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + persistDisconnectDiagnostic( + "source=activate.autoReconnectFailed, " + + "device=\(lastDeviceID.uuidString.prefix(8)), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState), " + + "error=\(error.localizedDescription), " + + "intent=\(connectionIntent)" + ) + startReconnectionWatchdog() + // Don't propagate - auto-reconnect failure is not fatal } - connectingDeviceID = nil + } else { + logger.info("No last connected device - skipping auto-reconnect") + } + #endif + } + + // MARK: - Connection + + /// Connects to a previously paired device. + /// + /// This method handles all connection scenarios: + /// - If disconnected: connects to the device + /// - If already connected to this device: no-op + /// - If connected to a different device: switches to the new device + /// + /// - Parameters: + /// - deviceID: The UUID of the device to connect to + /// - forceFullSync: Whether to force a full sync instead of incremental + /// - forceReconnect: When `true`, marks the connect as user-initiated: it bypasses the + /// circuit breaker, and on a platform without a system pairing registry (macOS) it also + /// bounds the retry budget to `unverifiedConnectAttempts`, so a tap on an out-of-range + /// radio fails fast instead of hanging the full budget. Background reconnects pass + /// `false` to keep the full budget for unattended recovery. + /// - Throws: Connection errors + func connect(to deviceID: UUID, forceFullSync: Bool = false, forceReconnect: Bool = false) async throws { + // Honor cancellation before any state mutation. Without this checkpoint + // a cancelled `pairNewDevice` task whose preceding awaits all swallowed + // cancellation would still drive a real BLE connect to completion. + try Task.checkCancellation() + + // Circuit breaker: prevent rapid reconnection loops after repeated failures + guard shouldAllowConnection(force: forceReconnect) else { + logger.info("[BLE] Circuit breaker open, rejecting connection to \(deviceID.uuidString.prefix(8))") + throw BLEError.connectionFailed("Connection blocked by circuit breaker (cooling down)") } - /// Disconnects from the current device. - /// - Parameter reason: The reason for disconnecting (for debugging) - public func disconnect(reason: DisconnectReason = .userInitiated) async { - let initialState = String(describing: connectionState) - let transportName = switch currentTransportType { - case .bluetooth: "bluetooth" - case .wifi: "wifi" - case nil: "none" - } - let activeDevice = connectedDevice?.id.uuidString.prefix(8) ?? "none" - - logger.info( - "Disconnecting from device (" + - "reason: \(reason.rawValue), " + - "transport: \(transportName), " + - "device: \(activeDevice), " + - "initialState: \(initialState), " + - "intent: \(connectionIntent)" + - ")" - ) - - // Cancel any pending auto-reconnect timeout and clear device identity - reconnectionCoordinator.cancelTimeout() - reconnectionCoordinator.clearReconnectingDevice() - connectingDeviceID = nil + if activeReconnectDeviceID == deviceID { + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() + if sessionRebuildDeviceID != deviceID { + reconnectionCoordinator.restartTimeout(deviceID: deviceID) + } + logger.info("[BLE] Reconnect already in progress for \(deviceID.uuidString.prefix(8)), deferring duplicate connect request") + return + } - // Cancel any WiFi reconnection in progress - cancelWiFiReconnection() - - // Stop WiFi heartbeat - stopWiFiHeartbeat() - - // Stop reconnection watchdog - stopReconnectionWatchdog() - - cancelResyncLoop() - cancelChannelRetry() - - // Only clear user intent and clean-channel state for explicit disconnects. - // Transient reasons preserve both so the next reconnect can skip redundant channel sync. - switch reason { - case .userInitiated, .statusMenuDisconnectTap, .forgetDevice, .deviceRemovedFromSettings, .factoryReset, .switchingDevice: - connectionIntent = .userDisconnected - persistIntent() - lastCleanChannelSync = nil - lastAttemptedChannelSync = nil - case .resyncFailed, .wifiAddressChange, .wifiReconnectPrep, .pairingFailed: - // Preserve .wantsConnection so health check can retry - break - } + // Prevent concurrent connection attempts + if connectionState == .connecting { + let currentDeviceID = activeConnectionAttemptDeviceID - // Capture and clear synchronously, mirroring teardownSessionForReconnect: - // a racing connect can install a new container during the awaits below, - // and re-reading self.services would tear that new container down. - let oldServices = services - let oldSession = session - services = nil - session = nil - logger.info("[BLE] disconnect: state → .disconnected") - connectionState = .disconnected - connectingDeviceID = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - - if let oldServices { - // Mark room sessions disconnected before tearing down services - _ = await oldServices.remoteNodeService.handleBLEDisconnection() - sessionsAwaitingReauth = [] - await oldServices.tearDown() - // Reset sync state and clear notification suppression (safety net) - await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) + if currentDeviceID == deviceID { + if connectingDeviceID == nil { + // Auto-reconnect same device — refresh UI timeout + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() + reconnectionCoordinator.restartTimeout(deviceID: deviceID) } + logger.info("Connection already in progress for \(deviceID.uuidString.prefix(8)), ignoring") + return + } + + // Different device — cancel current and fall through + logger.info("Cancelling connection to \(currentDeviceID?.uuidString.prefix(8) ?? "unknown") to connect to \(deviceID.uuidString.prefix(8))") + connectingDeviceID = nil + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() + cancelResyncLoop() + stopReconnectionWatchdog() + await cleanupResources() + await transport.disconnect() + connectionState = .disconnected + } - // Stop session - await oldSession?.stop() + // Handle already-connected cases + if connectionState != .disconnected { + if connectedDevice?.id == deviceID { + logger.info("Already connected to device: \(deviceID)") + return + } + // Connected to different device - switch to new one + logger.info("Switching from current device to: \(deviceID)") + try await switchDevice(to: deviceID) + return + } - // Disconnect appropriate transport based on current type - if let wifiTransport { - await wifiTransport.disconnect() - self.wifiTransport = nil - } else { - await transport.disconnect() + // Claim the attempt before the pre-connect awaits below. Two connect + // calls interleaving at those suspension points would otherwise both + // pass the guards above and build duplicate sessions over one + // transport. The newest claim wins; a superseded call observes the + // mismatch after each await and bails. + if connectingDeviceID == deviceID { + logger.info("Connection already claimed for \(deviceID.uuidString.prefix(8)), ignoring") + return + } + connectingDeviceID = deviceID + + // Handle state restoration auto-reconnect + let transportAutoReconnecting = await stateMachine.isAutoReconnecting + guard connectingDeviceID == deviceID else { throw CancellationError() } + if transportAutoReconnecting { + let restoringDeviceID = await stateMachine.connectedDeviceID + let blePhase = await stateMachine.currentPhaseName + let blePeripheralState = await stateMachine.currentPeripheralState ?? "none" + guard connectingDeviceID == deviceID else { throw CancellationError() } + + if restoringDeviceID != deviceID { + logger.info("Cancelling state restoration auto-reconnect to \(restoringDeviceID?.uuidString ?? "unknown") to connect to \(deviceID)") + await transport.disconnect() + guard connectingDeviceID == deviceID else { throw CancellationError() } + } else { + // Same device - let auto-reconnect complete instead of racing with it. + // The reconnection handler will create the session when auto-reconnect succeeds. + // Release the claim: the reconnect cycle owns identity tracking from here. + connectingDeviceID = nil + // Preserve user intent so the watchdog can retry if auto-reconnect fails. + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() + // Show connecting UI so the user sees their tap did something + if connectionState != .connecting { + connectionState = .connecting } - - // Clear transport type - currentTransportType = nil - - persistDisconnectDiagnostic( - "source=disconnect(reason), " + - "reason=\(reason.rawValue), " + - "transport=\(transportName), " + - "device=\(activeDevice), " + - "initialState=\(initialState), " + - "finalState=\(String(describing: connectionState)), " + - "intent=\(connectionIntent)" + // Re-arm timeout in case the previous one already fired + reconnectionCoordinator.restartTimeout(deviceID: deviceID) + logger.warning( + "[BLE] Deferring to iOS auto-reconnect for device \(deviceID.uuidString.prefix(8)) - connectionState: \(String(describing: connectionState)), blePhase: \(blePhase), blePeripheralState: \(blePeripheralState)" ) + return + } + } - logger.info( - "Disconnected (" + - "reason: \(reason.rawValue), " + - "transport: \(transportName), " + - "device: \(activeDevice), " + - "initialState: \(initialState), " + - "finalState: \(String(describing: connectionState)), " + - "intent: \(connectionIntent)" + - ")" - ) + // If the user is reconnecting to the last radio and iOS still has a system-level BLE link + // (common after app updates), adopt the existing link rather than blocking as "connected elsewhere". + if deviceID == lastConnectedDeviceID { + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() + + if await startAdoptingLastSystemConnectedPeripheralIfAvailable( + deviceID: deviceID, + context: "connect(to:)" + ) { + // Adoption owns the reconnect cycle now; release the claim. + if connectingDeviceID == deviceID { connectingDeviceID = nil } + return + } + guard connectingDeviceID == deviceID else { throw CancellationError() } } - // MARK: - Simulator + // Check for other app connection before changing state + if await isDeviceConnectedToOtherApp(deviceID) { + if connectingDeviceID == deviceID { connectingDeviceID = nil } + throw BLEError.deviceConnectedToOtherApp + } + guard connectingDeviceID == deviceID else { throw CancellationError() } - /// Connects to the simulator device with mock data. - /// Used for simulator builds and demo mode on device. - public func simulatorConnect() async throws { - logger.info("Starting simulator connection") + // Clear intentional disconnect flag before changing state, + // so the didSet invariant check sees consistent state + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() - connectionIntent = .wantsConnection() - persistIntent() - connectingDeviceID = MockDataProvider.simulatorDeviceID - connectionState = .connecting + // Set connecting state for immediate UI feedback + // (connectingDeviceID was already claimed above) + connectionState = .connecting - do { - // Connect simulator mode - await simulatorMode.connect() - - // Create services with a placeholder session - // Note: We need a MeshCoreSession but won't actually use it for communication - // The mock data is seeded directly into the persistence store - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - self.session = session - - // Create services - let newServices = ServiceContainer( - session: session, - modelContainer: modelContainer, - radioID: MockDataProvider.simulatorDeviceID, - appStateProvider: appStateProvider, - connectionStateEvents: connectionStateEvents, - initialConnectionState: connectionState - ) - await wireCleanChannelSyncCallback(on: newServices) - await newServices.chatSendQueueService.hydrate() - self.services = newServices - - // Seed mock data - try await simulatorMode.seedDataStore(newServices.dataStore) - - // Set connected device - self.connectedDevice = MockDataProvider.simulatorDevice - - // Persist for auto-reconnect - persistConnection( - deviceID: MockDataProvider.simulatorDeviceID, - radioID: MockDataProvider.simulatorDeviceID, - deviceName: "MeshCore One Sim" - ) - - // Notify observers - await onConnectionReady?() - - connectingDeviceID = nil - connectionState = .ready - await onDeviceSynced?() - logger.info("Simulator connection complete") - } catch { - connectingDeviceID = nil - await cleanupConnection() - throw error - } - } + logger.info("Connecting to device: \(deviceID)") - // MARK: - Device Switching + // Cancel any pending auto-reconnect timeout and clear device identity + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() - /// Switches to a different device. - /// - /// - Parameter deviceID: UUID of the new device to connect to - public func switchDevice(to deviceID: UUID) async throws { - logger.info("Switching to device: \(deviceID)") - lastCleanChannelSync = nil - lastAttemptedChannelSync = nil + do { + // Validate device is still registered with the pairing registry. macOS reports + // no active session, so this guard is skipped and CoreBluetooth resolves the device. + if pairing.isSessionActive { + let isConnectable = pairing.isDeviceConnectable(deviceID) - // Update intent - connectionIntent = .wantsConnection() - persistIntent() + if !isConnectable { + await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "connect(to:) paired accessories mismatch") + throw ConnectionError.deviceNotFound + } + } + + // Attempt connection with retry. Without a system pairing registry (macOS), + // CoreBluetooth cannot pre-reject an absent saved peripheral, so a user-initiated + // tap on an out-of-range radio would hang the full retry budget. Bound user taps + // there; background reconnects (forceReconnect == false) keep the full budget so + // unattended recovery still gets every attempt. + let maxAttempts = (forceReconnect && !pairing.hasSystemPairingRegistry) + ? Self.unverifiedConnectAttempts + : Self.defaultConnectAttempts + try await connectWithRetry(deviceID: deviceID, maxAttempts: maxAttempts) + } catch { + guard connectingDeviceID == deviceID else { + logger.info("Connection to \(deviceID.uuidString.prefix(8)) superseded") + throw error + } + if error is CancellationError { + logger.info("Connection cancelled") + } else { + logger.warning("Connection failed: \(error.localizedDescription)") + } + connectingDeviceID = nil + connectionState = .disconnected + throw error + } + connectingDeviceID = nil + } + + /// Disconnects from the current device. + /// - Parameter reason: The reason for disconnecting (for debugging) + func disconnect(reason: DisconnectReason = .userInitiated) async { + let initialState = String(describing: connectionState) + let transportName = switch currentTransportType { + case .bluetooth: "bluetooth" + case .wifi: "wifi" + case nil: "none" + } + let activeDevice = connectedDevice?.id.uuidString.prefix(8) ?? "none" + + logger.info( + "Disconnecting from device (" + + "reason: \(reason.rawValue), " + + "transport: \(transportName), " + + "device: \(activeDevice), " + + "initialState: \(initialState), " + + "intent: \(connectionIntent)" + + ")" + ) + + // Cancel any pending auto-reconnect timeout and clear device identity + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() + connectingDeviceID = nil + + // Cancel any WiFi reconnection in progress + cancelWiFiReconnection() + + // Stop WiFi heartbeat + stopWiFiHeartbeat() + + // Stop reconnection watchdog + stopReconnectionWatchdog() + + cancelResyncLoop() + cancelChannelRetry() + + // Only clear user intent and clean-channel state for explicit disconnects. + // Transient reasons preserve both so the next reconnect can skip redundant channel sync. + switch reason { + case .userInitiated, .statusMenuDisconnectTap, .forgetDevice, .deviceRemovedFromSettings, .factoryReset, .switchingDevice: + connectionIntent = .userDisconnected + persistIntent() + lastCleanChannelSync = nil + lastAttemptedChannelSync = nil + case .resyncFailed, .wifiAddressChange, .wifiReconnectPrep, .pairingFailed: + // Preserve .wantsConnection so health check can retry + break + } - do { - // Validate device is registered with the pairing registry. Skipped on macOS, - // which has no active session; CoreBluetooth resolves the device on connect. - if pairing.isSessionActive { - let isConnectable = pairing.isDeviceConnectable(deviceID) - if !isConnectable { - await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "switchDevice paired accessories mismatch") - throw ConnectionError.deviceNotFound - } - } + // Capture and clear synchronously, mirroring teardownSessionForReconnect: + // a racing connect can install a new container during the awaits below, + // and re-reading self.services would tear that new container down. + let oldServices = services + let oldSession = session + services = nil + session = nil + logger.info("[BLE] disconnect: state → .disconnected") + connectionState = .disconnected + connectingDeviceID = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + + if let oldServices { + // Mark room sessions disconnected before tearing down services + _ = await oldServices.remoteNodeService.handleBLEDisconnection() + sessionsAwaitingReauth = [] + await oldServices.tearDown() + // Reset sync state and clear notification suppression (safety net) + await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) + } - // Cancel any resync loop from the old device before teardown - cancelResyncLoop() + // Stop session + await oldSession?.stop() - // Reset sync state before destroying services to prevent stuck "Syncing" pill - if let services { - await services.syncCoordinator.onDisconnected(notificationService: services.notificationService) - } + // Disconnect appropriate transport based on current type + if let wifiTransport { + await wifiTransport.disconnect() + self.wifiTransport = nil + } else { + await transport.disconnect() + } - // Stop current services - await services?.tearDown() - // tearDown above cancelled the old container's connection-state - // subscription; nilling drops the dead reference before the - // connect ceremony installs its replacement. - self.services = nil - await session?.stop() - - // Switch transport - logger.info("[BLE] switchDevice: state → .connecting for device: \(deviceID.uuidString.prefix(8))") - connectionState = .connecting - try await transport.switchDevice(to: deviceID) - logger.info("[BLE] switchDevice: state → .connected for device: \(deviceID.uuidString.prefix(8))") - connectionState = .connected - - // Re-create session with existing transport - let newSession = MeshCoreSession(transport: transport) - self.session = newSession - - let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) - - // Configure BLE write pacing based on device platform - await configureBLEPacing(for: deviceCapabilities) - - let (newServices, radioID) = try await buildServicesAndSaveDevice( - deviceID: deviceID, - session: newSession, - selfInfo: meshCoreSelfInfo, - capabilities: deviceCapabilities, - connectionMethods: Self.bleConnectionMethods(for: deviceID) - ) - - // Persist connection for auto-reconnect - persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) - - // Notify observers before sync starts so they can wire callbacks - await onConnectionReady?() - let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, context: "Device switch", forceFullSync: true) - - guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .bluetooth) else { return } - - stopReconnectionWatchdog() - logger.info("Device switch complete - device ready") - } catch { - // Without this, a partial switch would leave services, session, and - // connectedDevice pointing at the old device with state stuck on - // .connecting or .connected. pairNewDevice routes through here when - // an old radio is still connected, so the failure alert's recovery - // actions need clean state. - await cleanupConnection() - await transport.disconnect() - // cleanupConnection tore down the session without firing onConnectionLost, - // so observers (and the radio's Live Activity) would otherwise be stranded - // on the old device's "connected" state. Route through the same callback the - // transport-loss and reconnect-failure paths use. - await onConnectionLost?() - throw error + // Clear transport type + currentTransportType = nil + + persistDisconnectDiagnostic( + "source=disconnect(reason), " + + "reason=\(reason.rawValue), " + + "transport=\(transportName), " + + "device=\(activeDevice), " + + "initialState=\(initialState), " + + "finalState=\(String(describing: connectionState)), " + + "intent=\(connectionIntent)" + ) + + logger.info( + "Disconnected (" + + "reason: \(reason.rawValue), " + + "transport: \(transportName), " + + "device: \(activeDevice), " + + "initialState: \(initialState), " + + "finalState: \(String(describing: connectionState)), " + + "intent: \(connectionIntent)" + + ")" + ) + } + + // MARK: - Simulator + + /// Connects to the simulator device with mock data. + /// Used for simulator builds and demo mode on device. + func simulatorConnect() async throws { + logger.info("Starting simulator connection") + + connectionIntent = .wantsConnection() + persistIntent() + connectingDeviceID = MockDataProvider.simulatorDeviceID + connectionState = .connecting + + do { + // Connect simulator mode + await simulatorMode.connect() + + // Create services with a placeholder session + // Note: We need a MeshCoreSession but won't actually use it for communication + // The mock data is seeded directly into the persistence store + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + self.session = session + + // Create services + let newServices = ServiceContainer( + session: session, + modelContainer: modelContainer, + radioID: MockDataProvider.simulatorDeviceID, + appStateProvider: appStateProvider, + connectionStateEvents: connectionStateEvents, + initialConnectionState: connectionState + ) + await wireCleanChannelSyncCallback(on: newServices) + await newServices.chatSendQueueService.hydrate() + services = newServices + + // Seed mock data + try await simulatorMode.seedDataStore(newServices.dataStore) + + // Set connected device + connectedDevice = MockDataProvider.simulatorDevice + + // Persist for auto-reconnect + persistConnection( + deviceID: MockDataProvider.simulatorDeviceID, + radioID: MockDataProvider.simulatorDeviceID, + deviceName: "MeshCore One Sim" + ) + + // Notify observers + await onConnectionReady?() + + connectingDeviceID = nil + connectionState = .ready + await onDeviceSynced?() + logger.info("Simulator connection complete") + } catch { + connectingDeviceID = nil + await cleanupConnection() + throw error + } + } + + // MARK: - Device Switching + + /// Switches to a different device. + /// + /// - Parameter deviceID: UUID of the new device to connect to + func switchDevice(to deviceID: UUID) async throws { + logger.info("Switching to device: \(deviceID)") + lastCleanChannelSync = nil + lastAttemptedChannelSync = nil + + // Update intent + connectionIntent = .wantsConnection() + persistIntent() + + do { + // Validate device is registered with the pairing registry. Skipped on macOS, + // which has no active session; CoreBluetooth resolves the device on connect. + if pairing.isSessionActive { + let isConnectable = pairing.isDeviceConnectable(deviceID) + if !isConnectable { + await logDeviceNotFoundDiagnostics(deviceID: deviceID, context: "switchDevice paired accessories mismatch") + throw ConnectionError.deviceNotFound } + } + + // Cancel any resync loop from the old device before teardown + cancelResyncLoop() + + // Reset sync state before destroying services to prevent stuck "Syncing" pill + if let services { + await services.syncCoordinator.onDisconnected(notificationService: services.notificationService) + } + + // Stop current services + await services?.tearDown() + // tearDown above cancelled the old container's connection-state + // subscription; nilling drops the dead reference before the + // connect ceremony installs its replacement. + services = nil + await session?.stop() + + // Switch transport + logger.info("[BLE] switchDevice: state → .connecting for device: \(deviceID.uuidString.prefix(8))") + connectionState = .connecting + try await transport.switchDevice(to: deviceID) + logger.info("[BLE] switchDevice: state → .connected for device: \(deviceID.uuidString.prefix(8))") + connectionState = .connected + + // Re-create session with existing transport + let newSession = MeshCoreSession(transport: transport) + session = newSession + + let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) + + // Configure BLE write pacing based on device platform + await configureBLEPacing(for: deviceCapabilities) + + let (newServices, radioID) = try await buildServicesAndSaveDevice( + deviceID: deviceID, + session: newSession, + selfInfo: meshCoreSelfInfo, + capabilities: deviceCapabilities, + connectionMethods: Self.bleConnectionMethods(for: deviceID) + ) + + // Persist connection for auto-reconnect + persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) + + // Notify observers before sync starts so they can wire callbacks + await onConnectionReady?() + let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, context: "Device switch", forceFullSync: true) + + guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .bluetooth) else { return } + + stopReconnectionWatchdog() + logger.info("Device switch complete - device ready") + } catch { + // Without this, a partial switch would leave services, session, and + // connectedDevice pointing at the old device with state stuck on + // .connecting or .connected. pairNewDevice routes through here when + // an old radio is still connected, so the failure alert's recovery + // actions need clean state. + await cleanupConnection() + await transport.disconnect() + // cleanupConnection tore down the session without firing onConnectionLost, + // so observers (and the radio's Live Activity) would otherwise be stranded + // on the old device's "connected" state. Route through the same callback the + // transport-loss and reconnect-failure paths use. + await onConnectionLost?() + throw error } + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift index 7d2e1ae1..174cef78 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift @@ -3,586 +3,585 @@ import MeshCore // MARK: - Pairing -extension ConnectionManager { - - /// Discovers a new device through the platform pairing service (AccessorySetupKit on - /// iOS, an in-app CoreBluetooth scan picker on macOS), then connects through the shared - /// `connect(to:)` ceremony. The connect path coordinates with in-flight auto-reconnects, - /// switch-device handling, and the circuit breaker, which `connectAfterPairing`'s direct - /// `performConnection` call used to bypass. - /// - /// **Cancellation behavior** (per `await`): - /// - `pairing.discoverDevice()` — `withTaskCancellationHandler` resumes the - /// continuation with `CancellationError`. On iOS the system picker may stay visible - /// (no public ASK API to dismiss programmatically without invalidating the - /// session); if the user completes pairing in the orphaned picker, - /// `accessoryAdded` removes the bond immediately. The device is not registered - /// when this point is reached, so no cleanup needed here. - /// - `waitForOtherAppReconnection` — checks `Task.isCancelled` at the top of - /// each iteration and short-circuits to `false`. The subsequent - /// `try await connect(to:)` then surfaces the cancellation via its - /// entry-point `Task.checkCancellation()`. - /// - `connect(to:)` — runs `Task.checkCancellation()` before any state mutation - /// so a cancelled task cannot drive a real BLE connect through to success. - /// Propagates `CancellationError` normally; we catch it explicitly and - /// re-throw without re-wrapping so the UI alert path stays quiet. - /// - /// Hard quit: process death; defer doesn't fire; the in-memory flag resets to - /// `false` on next launch. No persistent state corruption. - /// - /// - Throws: - /// - `DevicePairingError.alreadyInProgress` on re-entry. - /// - `PairingError.deviceConnectedToOtherApp` when another app holds the radio. - /// - `PairingError.connectionFailed` for any other connection failure (auth, - /// timeout, transport error). The wrapped `underlying` is checked by - /// `PairingError.isAuthenticationFailure` so the auth alert path keeps working. - /// - `CancellationError` if the surrounding task is cancelled mid-flight. - public func pairNewDevice() async throws { - logger.info("Starting device pairing") - guard !isPairingInProgress else { - throw DevicePairingError.alreadyInProgress - } - isPairingInProgress = true - defer { isPairingInProgress = false } - - connectionIntent = .wantsConnection() - persistIntent() +public extension ConnectionManager { + /// Discovers a new device through the platform pairing service (AccessorySetupKit on + /// iOS, an in-app CoreBluetooth scan picker on macOS), then connects through the shared + /// `connect(to:)` ceremony. The connect path coordinates with in-flight auto-reconnects, + /// switch-device handling, and the circuit breaker, which `connectAfterPairing`'s direct + /// `performConnection` call used to bypass. + /// + /// **Cancellation behavior** (per `await`): + /// - `pairing.discoverDevice()` — `withTaskCancellationHandler` resumes the + /// continuation with `CancellationError`. On iOS the system picker may stay visible + /// (no public ASK API to dismiss programmatically without invalidating the + /// session); if the user completes pairing in the orphaned picker, + /// `accessoryAdded` removes the bond immediately. The device is not registered + /// when this point is reached, so no cleanup needed here. + /// - `waitForOtherAppReconnection` — checks `Task.isCancelled` at the top of + /// each iteration and short-circuits to `false`. The subsequent + /// `try await connect(to:)` then surfaces the cancellation via its + /// entry-point `Task.checkCancellation()`. + /// - `connect(to:)` — runs `Task.checkCancellation()` before any state mutation + /// so a cancelled task cannot drive a real BLE connect through to success. + /// Propagates `CancellationError` normally; we catch it explicitly and + /// re-throw without re-wrapping so the UI alert path stays quiet. + /// + /// Hard quit: process death; defer doesn't fire; the in-memory flag resets to + /// `false` on next launch. No persistent state corruption. + /// + /// - Throws: + /// - `DevicePairingError.alreadyInProgress` on re-entry. + /// - `PairingError.deviceConnectedToOtherApp` when another app holds the radio. + /// - `PairingError.connectionFailed` for any other connection failure (auth, + /// timeout, transport error). The wrapped `underlying` is checked by + /// `PairingError.isAuthenticationFailure` so the auth alert path keeps working. + /// - `CancellationError` if the surrounding task is cancelled mid-flight. + func pairNewDevice() async throws { + logger.info("Starting device pairing") + guard !isPairingInProgress else { + throw DevicePairingError.alreadyInProgress + } + isPairingInProgress = true + defer { isPairingInProgress = false } - await stopBLEScanning() + connectionIntent = .wantsConnection() + persistIntent() - let deviceID = try await pairing.discoverDevice() + await stopBLEScanning() - if await waitForOtherAppReconnection(deviceID) { - throw PairingError.deviceConnectedToOtherApp(deviceID: deviceID) - } + let deviceID = try await pairing.discoverDevice() - do { - try await connect(to: deviceID, forceFullSync: true, forceReconnect: true) - } catch BLEError.deviceConnectedToOtherApp { - // No `cleanupPartialPairing` here — the bond is good; the user retries - // after dismissing the other app via the otherAppWarningDeviceID alert. - // Removing the bond would force a fresh pair instead. - throw PairingError.deviceConnectedToOtherApp(deviceID: deviceID) - } catch is CancellationError { - await cleanupPartialPairing(deviceID: deviceID) - throw CancellationError() - } catch { - // Edge case: a domain error bubbled up while the surrounding task was - // also cancelled. Without this guard the user sees "Couldn't connect" - // instead of silent cancellation. Re-throw as CancellationError so the - // alert path doesn't fire. - if Task.isCancelled { - await cleanupPartialPairing(deviceID: deviceID) - throw CancellationError() - } - logger.error("Connection after pairing failed: \(error.localizedDescription)") - throw PairingError.connectionFailed(deviceID: deviceID, underlying: error) - } + if await waitForOtherAppReconnection(deviceID) { + throw PairingError.deviceConnectedToOtherApp(deviceID: deviceID) } - /// Removes a partially-paired device from the system registry after `connect(to:)` was - /// cancelled mid-flight. On iOS the system has the device; we don't. Without this cleanup, - /// iOS retains a paired bond with no app-level state, surfacing as a phantom device in - /// Settings → Bluetooth that won't show up in the picker again. No-op on macOS. - private func cleanupPartialPairing(deviceID: UUID) async { - logger.info("Pairing cancelled — removing device \(deviceID.uuidString.prefix(8)) from pairing registry") - try? await pairing.removeDevice(deviceID) - // Defensive backstop — connectWithRetry and switchDevice both reset state - // on throw, so this is normally a no-op. Kept so a future cancellation - // path that lands here without doing so still leaves a clean UI. - connectionState = .disconnected + do { + try await connect(to: deviceID, forceFullSync: true, forceReconnect: true) + } catch BLEError.deviceConnectedToOtherApp { + // No `cleanupPartialPairing` here — the bond is good; the user retries + // after dismissing the other app via the otherAppWarningDeviceID alert. + // Removing the bond would force a fresh pair instead. + throw PairingError.deviceConnectedToOtherApp(deviceID: deviceID) + } catch is CancellationError { + await cleanupPartialPairing(deviceID: deviceID) + throw CancellationError() + } catch { + // Edge case: a domain error bubbled up while the surrounding task was + // also cancelled. Without this guard the user sees "Couldn't connect" + // instead of silent cancellation. Re-throw as CancellationError so the + // alert path doesn't fire. + if Task.isCancelled { + await cleanupPartialPairing(deviceID: deviceID) + throw CancellationError() + } + logger.error("Connection after pairing failed: \(error.localizedDescription)") + throw PairingError.connectionFailed(deviceID: deviceID, underlying: error) + } + } + + /// Removes a partially-paired device from the system registry after `connect(to:)` was + /// cancelled mid-flight. On iOS the system has the device; we don't. Without this cleanup, + /// iOS retains a paired bond with no app-level state, surfacing as a phantom device in + /// Settings → Bluetooth that won't show up in the picker again. No-op on macOS. + private func cleanupPartialPairing(deviceID: UUID) async { + logger.info("Pairing cancelled — removing device \(deviceID.uuidString.prefix(8)) from pairing registry") + try? await pairing.removeDevice(deviceID) + // Defensive backstop — connectWithRetry and switchDevice both reset state + // on throw, so this is normally a no-op. Kept so a future cancellation + // path that lands here without doing so still leaves a clean UI. + connectionState = .disconnected + } + + /// Removes a device that failed to connect after pairing. + /// Call this when user explicitly chooses to remove and retry. + /// No data cascade — fresh pairings have no associated data. + /// - Parameter deviceID: The device ID from `PairingError.connectionFailed` + func removeFailedPairing(deviceID: UUID) async { + logger.info("Removing failed pairing for device: \(deviceID)") + + await transport.disconnect() + + do { + try await pairing.removeDevice(deviceID) + } catch { + logger.warning("Failed to remove from pairing registry: \(error.localizedDescription)") } - /// Removes a device that failed to connect after pairing. - /// Call this when user explicitly chooses to remove and retry. - /// No data cascade — fresh pairings have no associated data. - /// - Parameter deviceID: The device ID from `PairingError.connectionFailed` - public func removeFailedPairing(deviceID: UUID) async { - logger.info("Removing failed pairing for device: \(deviceID)") - - await transport.disconnect() - - do { - try await pairing.removeDevice(deviceID) - } catch { - logger.warning("Failed to remove from pairing registry: \(error.localizedDescription)") - } - - let dataStore = PersistenceStore(modelContainer: modelContainer) - try? await dataStore.deleteDevice(id: deviceID) + let dataStore = PersistenceStore(modelContainer: modelContainer) + try? await dataStore.deleteDevice(id: deviceID) - if lastConnectedDeviceID == deviceID { - clearPersistedConnection() - } + if lastConnectedDeviceID == deviceID { + clearPersistedConnection() } + } + + // MARK: - Other-App Detection + + /// Polls for other-app reconnection after ASK pairing disrupts existing BLE connections. + /// ASK pairing severs the other app's BLE link; it auto-reconnects seconds later via + /// `CBConnectPeripheralOptionEnableAutoReconnect`. This method gives it time to reappear. + /// - Parameter deviceID: The UUID of the newly paired device + /// - Returns: `true` if the device was detected as connected to another app + internal func waitForOtherAppReconnection(_ deviceID: UUID) async -> Bool { + #if DEBUG + if let strategy = otherAppWaitStrategyOverride { + return await strategy(deviceID) + } + #endif + return await defaultWaitForOtherAppReconnection(deviceID) + } + + private func defaultWaitForOtherAppReconnection(_ deviceID: UUID) async -> Bool { + let maxChecks = 6 + let interval: Duration = .milliseconds(400) + + for check in 1...maxChecks { + // Short-circuit on cancellation so the caller's `connect(to:)` checkpoint + // surfaces the CancellationError without grinding through every iteration. + if Task.isCancelled { + logger.info("[OtherAppCheck] Cancelled at check \(check)/\(maxChecks)") + return false + } - // MARK: - Other-App Detection - - /// Polls for other-app reconnection after ASK pairing disrupts existing BLE connections. - /// ASK pairing severs the other app's BLE link; it auto-reconnects seconds later via - /// `CBConnectPeripheralOptionEnableAutoReconnect`. This method gives it time to reappear. - /// - Parameter deviceID: The UUID of the newly paired device - /// - Returns: `true` if the device was detected as connected to another app - func waitForOtherAppReconnection(_ deviceID: UUID) async -> Bool { - #if DEBUG - if let strategy = otherAppWaitStrategyOverride { - return await strategy(deviceID) - } - #endif - return await defaultWaitForOtherAppReconnection(deviceID) - } - - private func defaultWaitForOtherAppReconnection(_ deviceID: UUID) async -> Bool { - let maxChecks = 6 - let interval: Duration = .milliseconds(400) - - for check in 1...maxChecks { - // Short-circuit on cancellation so the caller's `connect(to:)` checkpoint - // surfaces the CancellationError without grinding through every iteration. - if Task.isCancelled { - logger.info("[OtherAppCheck] Cancelled at check \(check)/\(maxChecks)") - return false - } - - let connected = await stateMachine.isDeviceConnectedToSystem(deviceID) - if connected { - logger.info("[OtherAppCheck] Detected other-app connection on check \(check)/\(maxChecks)") - return true - } - - if check < maxChecks { - try? await Task.sleep(for: interval) - } - } + let connected = await stateMachine.isDeviceConnectedToSystem(deviceID) + if connected { + logger.info("[OtherAppCheck] Detected other-app connection on check \(check)/\(maxChecks)") + return true + } - logger.info("[OtherAppCheck] No other-app connection detected after \(maxChecks) checks") - return false + if check < maxChecks { + try? await Task.sleep(for: interval) + } } - // MARK: - Forget Device + logger.info("[OtherAppCheck] No other-app connection detected after \(maxChecks) checks") + return false + } - /// Forgets the device, removing it from paired accessories and local storage. - /// - Parameter deleteData: If `true`, also deletes all associated data (contacts, messages, channels, trace paths). - /// - Throws: `ConnectionError.notConnected` if no device is connected - public func forgetDevice(deleteData: Bool) async throws { - guard let deviceID = connectedDevice?.id else { - throw ConnectionError.notConnected - } + // MARK: - Forget Device - guard pairing.isDeviceConnectable(deviceID) else { - throw ConnectionError.deviceNotFound - } + /// Forgets the device, removing it from paired accessories and local storage. + /// - Parameter deleteData: If `true`, also deletes all associated data (contacts, messages, channels, trace paths). + /// - Throws: `ConnectionError.notConnected` if no device is connected + func forgetDevice(deleteData: Bool) async throws { + guard let deviceID = connectedDevice?.id else { + throw ConnectionError.notConnected + } - logger.info("Forgetting device: \(deviceID), deleteData: \(deleteData)") + guard pairing.isDeviceConnectable(deviceID) else { + throw ConnectionError.deviceNotFound + } - await disconnect(reason: .forgetDevice) - try await pairing.removeDevice(deviceID) + logger.info("Forgetting device: \(deviceID), deleteData: \(deleteData)") - let dataStore = PersistenceStore(modelContainer: modelContainer) - do { - if deleteData { - try await dataStore.deleteDeviceAndData(id: deviceID) - } else { - try await dataStore.demoteDeviceToGhost(id: deviceID) - } - } catch { - logger.warning("Failed to demote device in SwiftData: \(error.localizedDescription)") - } + await disconnect(reason: .forgetDevice) + try await pairing.removeDevice(deviceID) - clearPersistedConnection() - logger.info("Device forgotten") + let dataStore = PersistenceStore(modelContainer: modelContainer) + do { + if deleteData { + try await dataStore.deleteDeviceAndData(id: deviceID) + } else { + try await dataStore.demoteDeviceToGhost(id: deviceID) + } + } catch { + logger.warning("Failed to demote device in SwiftData: \(error.localizedDescription)") } - /// Forgets a device by ID, removing it from paired accessories and local storage. - /// Deletes both the device record and all associated data (factory reset path). - /// Best-effort cleanup — does not throw. - public func forgetDevice(id: UUID) async { - logger.info("Forgetting device by ID: \(id)") + clearPersistedConnection() + logger.info("Device forgotten") + } - await disconnect(reason: .factoryReset) + /// Forgets a device by ID, removing it from paired accessories and local storage. + /// Deletes both the device record and all associated data (factory reset path). + /// Best-effort cleanup — does not throw. + func forgetDevice(id: UUID) async { + logger.info("Forgetting device by ID: \(id)") - do { - try await pairing.removeDevice(id) - } catch { - logger.warning("Failed to remove device from pairing registry: \(error.localizedDescription)") - } + await disconnect(reason: .factoryReset) - let dataStore = PersistenceStore(modelContainer: modelContainer) - do { - try await dataStore.deleteDeviceAndData(id: id) - } catch { - logger.warning("Failed to delete device data from SwiftData: \(error.localizedDescription)") - } + do { + try await pairing.removeDevice(id) + } catch { + logger.warning("Failed to remove device from pairing registry: \(error.localizedDescription)") + } - // Drop the auto-reconnect / resume signal if we just forgot the device this install - // last connected to, so it can't trigger a phantom reconnect or onboarding resume. - if lastConnectedDeviceID == id { - clearPersistedConnection() - } + let dataStore = PersistenceStore(modelContainer: modelContainer) + do { + try await dataStore.deleteDeviceAndData(id: id) + } catch { + logger.warning("Failed to delete device data from SwiftData: \(error.localizedDescription)") + } - logger.info("Device forgotten by ID: \(id)") + // Drop the auto-reconnect / resume signal if we just forgot the device this install + // last connected to, so it can't trigger a phantom reconnect or onboarding resume. + if lastConnectedDeviceID == id { + clearPersistedConnection() } - // MARK: - Node Management + logger.info("Device forgotten by ID: \(id)") + } - /// Returns the number of non-favorite contacts for the current device. - public func unfavoritedNodeCount() async throws -> Int { - guard let radioID = connectedDevice?.radioID else { - throw ConnectionError.notConnected - } + // MARK: - Node Management - let dataStore = PersistenceStore(modelContainer: modelContainer) - let allContacts = try await dataStore.fetchContacts(radioID: radioID) - return allContacts.filter { !$0.isFavorite }.count + /// Returns the number of non-favorite contacts for the current device. + func unfavoritedNodeCount() async throws -> Int { + guard let radioID = connectedDevice?.radioID else { + throw ConnectionError.notConnected } - /// Removes all non-favorite contacts from the device and app, along with their messages. - /// - Returns: Count of removed vs total non-favorite contacts - /// - Throws: `ConnectionError.notConnected` if no device is connected - public func removeUnfavoritedNodes() async throws -> RemoveUnfavoritedResult { - try await removeContacts(matching: { !$0.isFavorite }) + let dataStore = PersistenceStore(modelContainer: modelContainer) + let allContacts = try await dataStore.fetchContacts(radioID: radioID) + return allContacts.count(where: { !$0.isFavorite }) + } + + /// Removes all non-favorite contacts from the device and app, along with their messages. + /// - Returns: Count of removed vs total non-favorite contacts + /// - Throws: `ConnectionError.notConnected` if no device is connected + func removeUnfavoritedNodes() async throws -> RemoveUnfavoritedResult { + try await removeContacts(matching: { !$0.isFavorite }) + } + + /// Removes non-favorite contacts whose `lastModified` timestamp is older than the given threshold. + /// - Parameter days: Number of days. Contacts not heard from in this many days are removed. + /// - Returns: Count of removed vs total stale contacts + /// - Throws: `ConnectionError.notConnected` if no device is connected + func removeStaleNodes(olderThanDays days: Int) async throws -> RemoveUnfavoritedResult { + let cutoff = UInt32(Date().addingTimeInterval(-Double(days) * 86400).timeIntervalSince1970) + return try await removeContacts(matching: { !$0.isFavorite && $0.lastModified < cutoff }) { contact in + let ageDays = (Int(Date().timeIntervalSince1970) - Int(contact.lastModified)) / 86400 + let keyPrefix = contact.publicKeyHex.prefix(8) + self.logger.info("Auto-removed stale node '\(contact.name)' [\(keyPrefix)] (last heard \(ageDays)d ago)") + } + } + + /// Shared implementation for removing contacts matching a predicate. + /// - Parameters: + /// - predicate: Filter applied to all contacts to determine which to remove. + /// - onRemove: Optional callback invoked after each successful removal (for per-contact logging). + /// - Returns: Count of removed vs total matching contacts + private func removeContacts( + matching predicate: (ContactDTO) -> Bool, + onRemove: ((_ contact: ContactDTO) -> Void)? = nil + ) async throws -> RemoveUnfavoritedResult { + guard let radioID = connectedDevice?.radioID else { + throw ConnectionError.notConnected } - /// Removes non-favorite contacts whose `lastModified` timestamp is older than the given threshold. - /// - Parameter days: Number of days. Contacts not heard from in this many days are removed. - /// - Returns: Count of removed vs total stale contacts - /// - Throws: `ConnectionError.notConnected` if no device is connected - public func removeStaleNodes(olderThanDays days: Int) async throws -> RemoveUnfavoritedResult { - let cutoff = UInt32(Date().addingTimeInterval(-Double(days) * 86400).timeIntervalSince1970) - return try await removeContacts(matching: { !$0.isFavorite && $0.lastModified < cutoff }) { contact in - let ageDays = (Int(Date().timeIntervalSince1970) - Int(contact.lastModified)) / 86400 - let keyPrefix = contact.publicKeyHex.prefix(8) - self.logger.info("Auto-removed stale node '\(contact.name)' [\(keyPrefix)] (last heard \(ageDays)d ago)") - } + guard let services else { + throw ConnectionError.notConnected } - /// Shared implementation for removing contacts matching a predicate. - /// - Parameters: - /// - predicate: Filter applied to all contacts to determine which to remove. - /// - onRemove: Optional callback invoked after each successful removal (for per-contact logging). - /// - Returns: Count of removed vs total matching contacts - private func removeContacts( - matching predicate: (ContactDTO) -> Bool, - onRemove: ((_ contact: ContactDTO) -> Void)? = nil - ) async throws -> RemoveUnfavoritedResult { - guard let radioID = connectedDevice?.radioID else { - throw ConnectionError.notConnected - } + let dataStore = PersistenceStore(modelContainer: modelContainer) + let allContacts = try await dataStore.fetchContacts(radioID: radioID) + let targets = allContacts.filter(predicate) - guard let services else { - throw ConnectionError.notConnected - } + if targets.isEmpty { + return RemoveUnfavoritedResult(removed: 0, total: 0) + } - let dataStore = PersistenceStore(modelContainer: modelContainer) - let allContacts = try await dataStore.fetchContacts(radioID: radioID) - let targets = allContacts.filter(predicate) + var removedCount = 0 - if targets.isEmpty { - return RemoveUnfavoritedResult(removed: 0, total: 0) - } + for contact in targets { + try Task.checkCancellation() - var removedCount = 0 - - for contact in targets { - try Task.checkCancellation() - - do { - try await services.contactService.removeContact( - radioID: radioID, - publicKey: contact.publicKey - ) - removedCount += 1 - onRemove?(contact) - } catch ContactServiceError.contactNotFound { - do { - try await services.contactService.removeLocalContact( - contactID: contact.id, - publicKey: contact.publicKey - ) - removedCount += 1 - logger.info("Contact not found on device, cleaned up locally: \(contact.name)") - } catch is CancellationError { - throw CancellationError() - } catch { - logger.warning("Failed to clean up local data for \(contact.name): \(error.localizedDescription)") - } - } catch is CancellationError { - throw CancellationError() - } catch { - logger.warning("Failed to remove contact \(contact.name): \(error.localizedDescription)") - return RemoveUnfavoritedResult(removed: removedCount, total: targets.count) - } + do { + try await services.contactService.removeContact( + radioID: radioID, + publicKey: contact.publicKey + ) + removedCount += 1 + onRemove?(contact) + } catch ContactServiceError.contactNotFound { + do { + try await services.contactService.removeLocalContact( + contactID: contact.id, + publicKey: contact.publicKey + ) + removedCount += 1 + logger.info("Contact not found on device, cleaned up locally: \(contact.name)") + } catch is CancellationError { + throw CancellationError() + } catch { + logger.warning("Failed to clean up local data for \(contact.name): \(error.localizedDescription)") } - + } catch is CancellationError { + throw CancellationError() + } catch { + logger.warning("Failed to remove contact \(contact.name): \(error.localizedDescription)") return RemoveUnfavoritedResult(removed: removedCount, total: targets.count) + } } - // MARK: - Stale Pairings + return RemoveUnfavoritedResult(removed: removedCount, total: targets.count) + } - /// Clears all stale pairings from the system registry (AccessorySetupKit on iOS). - /// Use when a device has been factory-reset but iOS still has the old pairing. No-op on macOS. - public func clearStalePairings() async { - logger.info("Clearing stale pairings") - await pairing.clearStaleRegistrations() - logger.info("Stale pairings cleared") - } + // MARK: - Stale Pairings - // MARK: - Device Updates + /// Clears all stale pairings from the system registry (AccessorySetupKit on iOS). + /// Use when a device has been factory-reset but iOS still has the old pairing. No-op on macOS. + func clearStalePairings() async { + logger.info("Clearing stale pairings") + await pairing.clearStaleRegistrations() + logger.info("Stale pairings cleared") + } - /// Updates the connected device with new settings from SelfInfo. - /// Called by SettingsService after device settings are successfully changed. - /// Also persists to SwiftData so changes appear in Connect Device sheet. - public func updateDevice(from selfInfo: MeshCore.SelfInfo) { - guard let device = connectedDevice else { return } - let updated = device.updating(from: selfInfo) - connectedDevice = updated + // MARK: - Device Updates - // Persist to SwiftData - Task { - try? await services?.dataStore.saveDevice(updated) - } - } + /// Updates the connected device with new settings from SelfInfo. + /// Called by SettingsService after device settings are successfully changed. + /// Also persists to SwiftData so changes appear in Connect Device sheet. + func updateDevice(from selfInfo: MeshCore.SelfInfo) { + guard let device = connectedDevice else { return } + let updated = device.updating(from: selfInfo) + connectedDevice = updated - /// Updates the connected device with a new DeviceDTO. - /// Called by DeviceService after local device settings are successfully changed. - public func updateDevice(with deviceDTO: DeviceDTO) { - connectedDevice = deviceDTO + // Persist to SwiftData + Task { + try? await services?.dataStore.saveDevice(updated) } + } + + /// Updates the connected device with a new DeviceDTO. + /// Called by DeviceService after local device settings are successfully changed. + func updateDevice(with deviceDTO: DeviceDTO) { + connectedDevice = deviceDTO + } + + /// Updates the connected device's auto-add config. + /// Called by SettingsService after auto-add config is successfully changed. + func updateAutoAddConfig(_ config: MeshCore.AutoAddConfig) { + guard let device = connectedDevice else { return } + let updated = device.copy { + $0.autoAddConfig = config.bitmask + $0.autoAddMaxHops = config.maxHops + } + connectedDevice = updated - /// Updates the connected device's auto-add config. - /// Called by SettingsService after auto-add config is successfully changed. - public func updateAutoAddConfig(_ config: MeshCore.AutoAddConfig) { - guard let device = connectedDevice else { return } - let updated = device.copy { - $0.autoAddConfig = config.bitmask - $0.autoAddMaxHops = config.maxHops - } - connectedDevice = updated - - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist auto-add config: \(error)") } - } + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist auto-add config: \(error)") } } + } - /// Updates the connected device's client repeat state. - /// Called by SettingsService after client repeat is successfully changed. - public func updateClientRepeat(_ enabled: Bool) { - guard let device = connectedDevice else { return } - let updated = device.copy { $0.clientRepeat = enabled } - connectedDevice = updated + /// Updates the connected device's client repeat state. + /// Called by SettingsService after client repeat is successfully changed. + func updateClientRepeat(_ enabled: Bool) { + guard let device = connectedDevice else { return } + let updated = device.copy { $0.clientRepeat = enabled } + connectedDevice = updated - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist client repeat state: \(error)") } - } + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist client repeat state: \(error)") } } + } - /// Updates the connected device's path hash mode. - /// Called by SettingsService after path hash mode is successfully changed. - public func updatePathHashMode(_ mode: UInt8) { - guard let device = connectedDevice else { return } - let updated = device.copy { $0.pathHashMode = mode } - connectedDevice = updated + /// Updates the connected device's path hash mode. + /// Called by SettingsService after path hash mode is successfully changed. + func updatePathHashMode(_ mode: UInt8) { + guard let device = connectedDevice else { return } + let updated = device.copy { $0.pathHashMode = mode } + connectedDevice = updated - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist path hash mode: \(error)") } - } + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist path hash mode: \(error)") } } + } - /// Updates the connected device's cached default flood scope name. - /// Called by SettingsService after a `getDefaultFloodScope` read or a successful write. - public func updateDefaultFloodScopeName(_ name: String?) { - guard let device = connectedDevice else { return } - let updated = device.copy { $0.defaultFloodScopeName = name } - connectedDevice = updated + /// Updates the connected device's cached default flood scope name. + /// Called by SettingsService after a `getDefaultFloodScope` read or a successful write. + func updateDefaultFloodScopeName(_ name: String?) { + guard let device = connectedDevice else { return } + let updated = device.copy { $0.defaultFloodScopeName = name } + connectedDevice = updated - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist default flood scope: \(error)") } - } + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist default flood scope: \(error)") } } - - /// Appends a region to the connected device's known-regions list and persists. - /// No-ops if the region is already present. - public func addKnownRegion(_ region: String) { - guard let device = connectedDevice, - !device.knownRegions.contains(region) else { return } - let updated = device.copy { $0.knownRegions.append(region) } - connectedDevice = updated - Task { - do { try await services?.dataStore.addDeviceKnownRegion(radioID: updated.radioID, region: region) } catch { logger.error("Failed to add known region: \(error)") } - await services?.rxLogService.updateKnownRegions(updated.knownRegions) - } + } + + /// Appends a region to the connected device's known-regions list and persists. + /// No-ops if the region is already present. + func addKnownRegion(_ region: String) { + guard let device = connectedDevice, + !device.knownRegions.contains(region) else { return } + let updated = device.copy { $0.knownRegions.append(region) } + connectedDevice = updated + Task { + do { try await services?.dataStore.addDeviceKnownRegion(radioID: updated.radioID, region: region) } catch { logger.error("Failed to add known region: \(error)") } + await services?.rxLogService.updateKnownRegions(updated.knownRegions) } - - /// Removes a region from the connected device's known-regions list and persists. - /// If the removed region is the device's current default flood scope, also clears - /// the scope on the radio so firmware state doesn't dangle on a deleted name. - public func removeKnownRegion(_ region: String) { - guard let device = connectedDevice else { return } - let wasDefaultFloodScope = device.defaultFloodScopeName == region - let updated = device.copy { - $0.knownRegions.removeAll { $0 == region } - if wasDefaultFloodScope { - $0.defaultFloodScopeName = nil - } - } - connectedDevice = updated - Task { - do { - try await services?.dataStore.removeDeviceKnownRegion(radioID: updated.radioID, region: region) - } catch { - logger.error("Failed to remove known region: \(error)") - } - - if wasDefaultFloodScope, let settingsService = services?.settingsService { - do { - _ = try await settingsService.setDefaultFloodScopeVerified(name: nil) - } catch { - logger.error("Failed to clear default flood scope after region removal: \(error)") - } - } - - await services?.rxLogService.updateKnownRegions(updated.knownRegions) - } + } + + /// Removes a region from the connected device's known-regions list and persists. + /// If the removed region is the device's current default flood scope, also clears + /// the scope on the radio so firmware state doesn't dangle on a deleted name. + func removeKnownRegion(_ region: String) { + guard let device = connectedDevice else { return } + let wasDefaultFloodScope = device.defaultFloodScopeName == region + let updated = device.copy { + $0.knownRegions.removeAll { $0 == region } + if wasDefaultFloodScope { + $0.defaultFloodScopeName = nil + } } - - /// Saves the connected device's current radio settings as pre-repeat settings. - /// Called before enabling repeat mode so settings can be restored later. - public func savePreRepeatSettings() { - guard let device = connectedDevice else { return } - let updated = device.savingPreRepeatSettings() - connectedDevice = updated - - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist pre-repeat settings: \(error)") } + connectedDevice = updated + Task { + do { + try await services?.dataStore.removeDeviceKnownRegion(radioID: updated.radioID, region: region) + } catch { + logger.error("Failed to remove known region: \(error)") + } + + if wasDefaultFloodScope, let settingsService = services?.settingsService { + do { + _ = try await settingsService.setDefaultFloodScopeVerified(name: nil) + } catch { + logger.error("Failed to clear default flood scope after region removal: \(error)") } + } + + await services?.rxLogService.updateKnownRegions(updated.knownRegions) } + } - /// Clears the connected device's pre-repeat settings after restoration. - public func clearPreRepeatSettings() { - guard let device = connectedDevice else { return } - let updated = device.clearingPreRepeatSettings() - connectedDevice = updated + /// Saves the connected device's current radio settings as pre-repeat settings. + /// Called before enabling repeat mode so settings can be restored later. + func savePreRepeatSettings() { + guard let device = connectedDevice else { return } + let updated = device.savingPreRepeatSettings() + connectedDevice = updated - Task { - do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist cleared pre-repeat settings: \(error)") } - } + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist pre-repeat settings: \(error)") } } + } - // MARK: - Accessory Management + /// Clears the connected device's pre-repeat settings after restoration. + func clearPreRepeatSettings() { + guard let device = connectedDevice else { return } + let updated = device.clearingPreRepeatSettings() + connectedDevice = updated - /// Checks if a device is registered with the system pairing registry. - /// - Parameter deviceID: The Bluetooth UUID of the device - /// - Returns: `true` if the device is available for connection (always `true` on macOS). - public func hasAccessory(for deviceID: UUID) -> Bool { - pairing.isDeviceConnectable(deviceID) + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist cleared pre-repeat settings: \(error)") } } - - /// Fetches all previously paired devices from storage. - /// Available even when disconnected, for device selection UI. - public func fetchSavedDevices() async throws -> [DeviceDTO] { - logger.info("fetchSavedDevices called, connectionState: \(String(describing: self.connectionState))") - let dataStore = PersistenceStore(modelContainer: modelContainer) - let devices = try await dataStore.fetchDevices() - logger.info("fetchSavedDevices returning \(devices.count) devices") - return devices + } + + // MARK: - Accessory Management + + /// Checks if a device is registered with the system pairing registry. + /// - Parameter deviceID: The Bluetooth UUID of the device + /// - Returns: `true` if the device is available for connection (always `true` on macOS). + func hasAccessory(for deviceID: UUID) -> Bool { + pairing.isDeviceConnectable(deviceID) + } + + /// Fetches all previously paired devices from storage. + /// Available even when disconnected, for device selection UI. + func fetchSavedDevices() async throws -> [DeviceDTO] { + logger.info("fetchSavedDevices called, connectionState: \(String(describing: connectionState))") + let dataStore = PersistenceStore(modelContainer: modelContainer) + let devices = try await dataStore.fetchDevices() + logger.info("fetchSavedDevices returning \(devices.count) devices") + return devices + } + + /// Deletes a previously paired device record from storage. + /// Demotes to ghost record — preserves publicKey ↔ radioID bridge for data recovery on re-pair. + /// - Parameter id: The device UUID to demote + func deleteDevice(id: UUID) async throws { + logger.info("deleteDevice called for device: \(id)") + let dataStore = PersistenceStore(modelContainer: modelContainer) + try await dataStore.demoteDeviceToGhost(id: id) + + // Drop the auto-reconnect / onboarding-resume signal when the deleted row is the one + // this install last connected to, mirroring the sibling forget paths. Without this the + // macOS no-validation connect path grinds the full retry budget toward a device the + // user removed, and `OnboardingState.suggestedStartingPath` still resumes onto it. + if lastConnectedDeviceID == id { + clearPersistedConnection() } - /// Deletes a previously paired device record from storage. - /// Demotes to ghost record — preserves publicKey ↔ radioID bridge for data recovery on re-pair. - /// - Parameter id: The device UUID to demote - public func deleteDevice(id: UUID) async throws { - logger.info("deleteDevice called for device: \(id)") - let dataStore = PersistenceStore(modelContainer: modelContainer) - try await dataStore.demoteDeviceToGhost(id: id) - - // Drop the auto-reconnect / onboarding-resume signal when the deleted row is the one - // this install last connected to, mirroring the sibling forget paths. Without this the - // macOS no-validation connect path grinds the full retry budget toward a device the - // user removed, and `OnboardingState.suggestedStartingPath` still resumes onto it. - if lastConnectedDeviceID == id { - clearPersistedConnection() - } - - logger.info("deleteDevice completed for device: \(id)") + logger.info("deleteDevice completed for device: \(id)") + } + + /// Returns devices registered with the system pairing registry (AccessorySetupKit on iOS). + /// Use as fallback when SwiftData has no device records. Empty on macOS. + var pairedAccessoryInfos: [(id: UUID, name: String)] { + pairing.registeredDeviceInfos() + } + + /// Renames the currently connected device via the system rename UI (AccessorySetupKit on iOS). + /// No-op on macOS, where there is no system rename surface. + /// - Throws: `ConnectionError.notConnected` if no device is connected + func renameCurrentDevice() async throws { + guard let deviceID = connectedDevice?.id else { + throw ConnectionError.notConnected } - /// Returns devices registered with the system pairing registry (AccessorySetupKit on iOS). - /// Use as fallback when SwiftData has no device records. Empty on macOS. - public var pairedAccessoryInfos: [(id: UUID, name: String)] { - pairing.registeredDeviceInfos() + guard pairing.isDeviceConnectable(deviceID) else { + throw ConnectionError.deviceNotFound } - /// Renames the currently connected device via the system rename UI (AccessorySetupKit on iOS). - /// No-op on macOS, where there is no system rename surface. - /// - Throws: `ConnectionError.notConnected` if no device is connected - public func renameCurrentDevice() async throws { - guard let deviceID = connectedDevice?.id else { - throw ConnectionError.notConnected - } - - guard pairing.isDeviceConnectable(deviceID) else { - throw ConnectionError.deviceNotFound - } - - try await pairing.renameDevice(deviceID) - } + try await pairing.renameDevice(deviceID) + } } // MARK: - DevicePairingDelegate extension ConnectionManager: DevicePairingDelegate { - public func devicePairing( - _ service: any DevicePairingService, - didRemoveDeviceWithID bluetoothID: UUID - ) { - logger.info("Device removed from pairing registry: \(bluetoothID)") - - Task { - if connectedDevice?.id == bluetoothID { - await disconnect(reason: .deviceRemovedFromSettings) - } - - // Demote to ghost — preserve publicKey ↔ radioID bridge - let dataStore = PersistenceStore(modelContainer: modelContainer) - do { - try await dataStore.demoteDeviceToGhost(id: bluetoothID) - } catch { - logger.warning("Failed to demote device in SwiftData: \(error.localizedDescription)") - } - } - - // Clear persisted connection if it was this device - if lastConnectedDeviceID == bluetoothID { - clearPersistedConnection() - } + public func devicePairing( + _ service: any DevicePairingService, + didRemoveDeviceWithID bluetoothID: UUID + ) { + logger.info("Device removed from pairing registry: \(bluetoothID)") + + Task { + if connectedDevice?.id == bluetoothID { + await disconnect(reason: .deviceRemovedFromSettings) + } + + // Demote to ghost — preserve publicKey ↔ radioID bridge + let dataStore = PersistenceStore(modelContainer: modelContainer) + do { + try await dataStore.demoteDeviceToGhost(id: bluetoothID) + } catch { + logger.warning("Failed to demote device in SwiftData: \(error.localizedDescription)") + } } - public func devicePairing( - _ service: any DevicePairingService, - didFailPairingForDeviceWithID bluetoothID: UUID - ) { - // Clean up device record so the device can appear in picker again. - // No data cascade — failed pairings have no associated data. - logger.info("Pairing failed for device: \(bluetoothID)") - - Task { - if connectedDevice?.id == bluetoothID { - await disconnect(reason: .pairingFailed) - } - - // Delete device record only — no data exists for a failed pairing - let dataStore = PersistenceStore(modelContainer: modelContainer) - do { - try await dataStore.deleteDevice(id: bluetoothID) - logger.info("Deleted device record after failed pairing") - } catch { - logger.info("No device record to delete: \(error.localizedDescription)") - } - } + // Clear persisted connection if it was this device + if lastConnectedDeviceID == bluetoothID { + clearPersistedConnection() + } + } + + public func devicePairing( + _ service: any DevicePairingService, + didFailPairingForDeviceWithID bluetoothID: UUID + ) { + // Clean up device record so the device can appear in picker again. + // No data cascade — failed pairings have no associated data. + logger.info("Pairing failed for device: \(bluetoothID)") + + Task { + if connectedDevice?.id == bluetoothID { + await disconnect(reason: .pairingFailed) + } + + // Delete device record only — no data exists for a failed pairing + let dataStore = PersistenceStore(modelContainer: modelContainer) + do { + try await dataStore.deleteDevice(id: bluetoothID) + logger.info("Deleted device record after failed pairing") + } catch { + logger.info("No device record to delete: \(error.localizedDescription)") + } + } - // Clear persisted connection if it was this device - if lastConnectedDeviceID == bluetoothID { - clearPersistedConnection() - } + // Clear persisted connection if it was this device + if lastConnectedDeviceID == bluetoothID { + clearPersistedConnection() } + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift index 1acc13fd..e27bafbc 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift @@ -2,388 +2,387 @@ import Foundation import MeshCore extension ConnectionManager { + // MARK: - WiFi Heartbeat - // MARK: - WiFi Heartbeat - - /// Starts periodic heartbeat to detect dead WiFi connections. - /// ESP32's TCP stack doesn't respond to TCP keepalives, so we use application-level probes. - private func startWiFiHeartbeat() { - stopWiFiHeartbeat() - - wifiHeartbeatTask = Task { [weak self] in - while !Task.isCancelled { - do { - try await Task.sleep(for: Self.wifiHeartbeatInterval) - } catch { - break - } - - guard let self, - self.currentTransportType == .wifi, - self.connectionState.isOperational, - let session = self.session else { break } - - if self.shouldPauseWiFiHeartbeatProbe { - self.logger.info("WiFi heartbeat skipped while sync activity is active") - continue - } - - // Probe connection with lightweight command - do { - _ = try await session.getTime() - } catch { - if self.shouldPauseWiFiHeartbeatProbe { - self.logger.info("WiFi heartbeat timeout during sync activity treated as inconclusive") - continue - } - self.logger.warning("WiFi heartbeat failed: \(error.localizedDescription)") - await self.handleWiFiDisconnection(error: error) - break - } - } + /// Starts periodic heartbeat to detect dead WiFi connections. + /// ESP32's TCP stack doesn't respond to TCP keepalives, so we use application-level probes. + private func startWiFiHeartbeat() { + stopWiFiHeartbeat() + + wifiHeartbeatTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: Self.wifiHeartbeatInterval) + } catch { + break } - } - var shouldPauseWiFiHeartbeatProbe: Bool { - connectionState == .syncing || resyncTask != nil || channelRetryTask != nil - } + guard let self, + currentTransportType == .wifi, + connectionState.isOperational, + let session else { break } - /// Stops the WiFi heartbeat loop - func stopWiFiHeartbeat() { - wifiHeartbeatTask?.cancel() - wifiHeartbeatTask = nil - } + if shouldPauseWiFiHeartbeatProbe { + logger.info("WiFi heartbeat skipped while sync activity is active") + continue + } - // MARK: - WiFi Disconnection Handling + // Probe connection with lightweight command + do { + _ = try await session.getTime() + } catch { + if shouldPauseWiFiHeartbeatProbe { + logger.info("WiFi heartbeat timeout during sync activity treated as inconclusive") + continue + } + logger.warning("WiFi heartbeat failed: \(error.localizedDescription)") + await handleWiFiDisconnection(error: error) + break + } + } + } + } + + var shouldPauseWiFiHeartbeatProbe: Bool { + connectionState == .syncing || resyncTask != nil || channelRetryTask != nil + } + + /// Stops the WiFi heartbeat loop + func stopWiFiHeartbeat() { + wifiHeartbeatTask?.cancel() + wifiHeartbeatTask = nil + } + + // MARK: - WiFi Disconnection Handling + + /// Handles unexpected WiFi connection loss + private func handleWiFiDisconnection(error: Error?) async { + // User-initiated disconnect - don't reconnect + guard connectionIntent.wantsConnection else { return } + + // Only handle WiFi disconnections + guard currentTransportType == .wifi else { return } + + // Prevent re-entrant calls: multiple disconnection callbacks can fire + // simultaneously from the transport handler and heartbeat. The flag + // covers the window between entry and startWiFiReconnection() where + // await suspension points could allow interleaving on @MainActor. + guard !isHandlingWiFiDisconnection, wifiReconnectTask == nil else { + logger.info("WiFi disconnection already being handled, ignoring duplicate") + return + } + isHandlingWiFiDisconnection = true + defer { isHandlingWiFiDisconnection = false } + + logger.warning("WiFi connection lost: \(error?.localizedDescription ?? "unknown")") + + // Stop heartbeat before teardown + stopWiFiHeartbeat() + + cancelResyncLoop() + cancelChannelRetry() + + // Capture and clear synchronously, mirroring teardownSessionForReconnect: + // a reconnect can install a new container during the awaits below, and + // re-reading self.services would tear that new container down. + let oldServices = services + services = nil + session = nil + + if let oldServices { + // Mark room sessions disconnected before tearing down services + _ = await oldServices.remoteNodeService.handleBLEDisconnection() + // Reset sync state before destroying services to prevent stuck "Syncing" pill + await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) + await oldServices.tearDown() + } - /// Handles unexpected WiFi connection loss - private func handleWiFiDisconnection(error: Error?) async { - // User-initiated disconnect - don't reconnect - guard connectionIntent.wantsConnection else { return } + // Show connecting state (pulsing indicator) + logger.info("[WiFi] State → .connecting (WiFi disconnection, starting reconnection)") + connectionState = .connecting - // Only handle WiFi disconnections - guard currentTransportType == .wifi else { return } + // Start reconnection attempts + startWiFiReconnection() + } - // Prevent re-entrant calls: multiple disconnection callbacks can fire - // simultaneously from the transport handler and heartbeat. The flag - // covers the window between entry and startWiFiReconnection() where - // await suspension points could allow interleaving on @MainActor. - guard !isHandlingWiFiDisconnection, wifiReconnectTask == nil else { - logger.info("WiFi disconnection already being handled, ignoring duplicate") - return - } - isHandlingWiFiDisconnection = true - defer { isHandlingWiFiDisconnection = false } - - logger.warning("WiFi connection lost: \(error?.localizedDescription ?? "unknown")") - - // Stop heartbeat before teardown - stopWiFiHeartbeat() - - cancelResyncLoop() - cancelChannelRetry() - - // Capture and clear synchronously, mirroring teardownSessionForReconnect: - // a reconnect can install a new container during the awaits below, and - // re-reading self.services would tear that new container down. - let oldServices = services - services = nil - session = nil - - if let oldServices { - // Mark room sessions disconnected before tearing down services - _ = await oldServices.remoteNodeService.handleBLEDisconnection() - // Reset sync state before destroying services to prevent stuck "Syncing" pill - await oldServices.syncCoordinator.onDisconnected(notificationService: oldServices.notificationService) - await oldServices.tearDown() - } + // MARK: - WiFi Reconnection - // Show connecting state (pulsing indicator) - logger.info("[WiFi] State → .connecting (WiFi disconnection, starting reconnection)") - connectionState = .connecting + /// Starts the WiFi reconnection retry loop + private func startWiFiReconnection() { + // If a reconnect task is already running, don't start another + if wifiReconnectTask != nil { + logger.info("WiFi reconnection already in progress, skipping") + return + } - // Start reconnection attempts - startWiFiReconnection() + // Rate limiting: prevent rapid reconnection attempts + if let lastStart = lastWiFiReconnectStartTime, + Date().timeIntervalSince(lastStart) < Self.wifiReconnectCooldown { + logger.warning("Suppressing WiFi reconnection: too soon after last attempt") + Task { await cleanupConnection() } + return } + lastWiFiReconnectStartTime = Date() + + wifiReconnectAttempt = 0 + wifiReconnectTask?.cancel() + + wifiReconnectTask = Task { + // Only self-nil when not cancelled: startWiFiReconnection cancels the + // old task before assigning a new one, and an unconditional defer in + // the cancelled old task would destroy the replacement's handle (the + // same pattern resyncTask and channelRetryTask document). + defer { + if !Task.isCancelled { + wifiReconnectTask = nil + wifiReconnectAttempt = 0 + } + } - // MARK: - WiFi Reconnection + let startTime = ContinuousClock.now - /// Starts the WiFi reconnection retry loop - private func startWiFiReconnection() { - // If a reconnect task is already running, don't start another - if wifiReconnectTask != nil { - logger.info("WiFi reconnection already in progress, skipping") - return + while !Task.isCancelled, connectionIntent.wantsConnection { + // Check if we've exceeded 30 second window + let elapsed = ContinuousClock.now - startTime + if elapsed > Self.wifiMaxReconnectDuration { + logger.info("WiFi reconnection timeout after 30s") + await cleanupConnection() + return } - // Rate limiting: prevent rapid reconnection attempts - if let lastStart = lastWiFiReconnectStartTime, - Date().timeIntervalSince(lastStart) < Self.wifiReconnectCooldown { - logger.warning("Suppressing WiFi reconnection: too soon after last attempt") - Task { await cleanupConnection() } - return - } - lastWiFiReconnectStartTime = Date() - - wifiReconnectAttempt = 0 - wifiReconnectTask?.cancel() - - wifiReconnectTask = Task { - // Only self-nil when not cancelled: startWiFiReconnection cancels the - // old task before assigning a new one, and an unconditional defer in - // the cancelled old task would destroy the replacement's handle (the - // same pattern resyncTask and channelRetryTask document). - defer { - if !Task.isCancelled { - wifiReconnectTask = nil - wifiReconnectAttempt = 0 - } - } - - let startTime = ContinuousClock.now - - while !Task.isCancelled && connectionIntent.wantsConnection { - // Check if we've exceeded 30 second window - let elapsed = ContinuousClock.now - startTime - if elapsed > Self.wifiMaxReconnectDuration { - logger.info("WiFi reconnection timeout after 30s") - await cleanupConnection() - return - } - - wifiReconnectAttempt += 1 - logger.info("WiFi reconnect attempt \(self.wifiReconnectAttempt)") - - do { - try await reconnectWiFi() - logger.info("WiFi reconnection succeeded") - return - } catch { - logger.warning("WiFi reconnect failed: \(error.localizedDescription)") - } - - // Exponential backoff: 0.5s, 1s, 2s, 4s (capped) - let delay = min(0.5 * pow(2.0, Double(wifiReconnectAttempt - 1)), 4.0) - try? await Task.sleep(for: .seconds(delay)) - } - } - } + wifiReconnectAttempt += 1 + logger.info("WiFi reconnect attempt \(self.wifiReconnectAttempt)") - /// Attempts to reconnect to the WiFi device using stored connection info - private func reconnectWiFi() async throws { - guard let wifiTransport, - let (host, port) = await wifiTransport.connectionInfo else { - throw ConnectionError.connectionFailed("No WiFi connection info") + do { + try await reconnectWiFi() + logger.info("WiFi reconnection succeeded") + return + } catch { + logger.warning("WiFi reconnect failed: \(error.localizedDescription)") } - // Stop any existing session to prevent receive loops racing for transport data - await session?.stop() - session = nil + // Exponential backoff: 0.5s, 1s, 2s, 4s (capped) + let delay = min(0.5 * pow(2.0, Double(wifiReconnectAttempt - 1)), 4.0) + try? await Task.sleep(for: .seconds(delay)) + } + } + } + + /// Attempts to reconnect to the WiFi device using stored connection info + private func reconnectWiFi() async throws { + guard let wifiTransport, + let (host, port) = await wifiTransport.connectionInfo else { + throw ConnectionError.connectionFailed("No WiFi connection info") + } - // Disconnect old transport cleanly - await wifiTransport.disconnect() + // Stop any existing session to prevent receive loops racing for transport data + await session?.stop() + session = nil - // Create fresh transport with same connection info - let newTransport = WiFiTransport() - await newTransport.setConnectionInfo(host: host, port: port) - self.wifiTransport = newTransport + // Disconnect old transport cleanly + await wifiTransport.disconnect() - // Connect - try await newTransport.connect() - connectionState = .connected + // Create fresh transport with same connection info + let newTransport = WiFiTransport() + await newTransport.setConnectionInfo(host: host, port: port) + self.wifiTransport = newTransport - // Re-establish session - let newSession = MeshCoreSession(transport: newTransport) - self.session = newSession - try await newSession.start() + // Connect + try await newTransport.connect() + connectionState = .connected - guard let selfInfo = await newSession.currentSelfInfo else { - throw ConnectionError.initializationFailed("No self info") - } + // Re-establish session + let newSession = MeshCoreSession(transport: newTransport) + session = newSession + try await newSession.start() - let deviceID = DeviceIdentity.deriveUUID(from: selfInfo.publicKey) - try await completeWiFiReconnection( - session: newSession, - transport: newTransport, - deviceID: deviceID - ) + guard let selfInfo = await newSession.currentSelfInfo else { + throw ConnectionError.initializationFailed("No self info") } - /// Completes WiFi reconnection by re-establishing services - private func completeWiFiReconnection( - session: MeshCoreSession, - transport: WiFiTransport, - deviceID: UUID - ) async throws { - let capabilities = try await session.queryDevice() - detectAndStorePlatform(model: capabilities.model, transportType: .wifi) - guard let selfInfo = await session.currentSelfInfo else { - throw ConnectionError.initializationFailed("No self info") - } + let deviceID = DeviceIdentity.deriveUUID(from: selfInfo.publicKey) + try await completeWiFiReconnection( + session: newSession, + transport: newTransport, + deviceID: deviceID + ) + } + + /// Completes WiFi reconnection by re-establishing services + private func completeWiFiReconnection( + session: MeshCoreSession, + transport: WiFiTransport, + deviceID: UUID + ) async throws { + let capabilities = try await session.queryDevice() + detectAndStorePlatform(model: capabilities.model, transportType: .wifi) + guard let selfInfo = await session.currentSelfInfo else { + throw ConnectionError.initializationFailed("No self info") + } - let (newServices, radioID) = try await buildServicesAndSaveDevice( - deviceID: deviceID, - session: session, - selfInfo: selfInfo, - capabilities: capabilities - ) - - // Wire disconnection handler on new transport - await transport.setDisconnectionHandler { [weak self] error in - Task { @MainActor in - await self?.handleWiFiDisconnection(error: error) - } - } + let (newServices, radioID) = try await buildServicesAndSaveDevice( + deviceID: deviceID, + session: session, + selfInfo: selfInfo, + capabilities: capabilities + ) + + // Wire disconnection handler on new transport + await transport.setDisconnectionHandler { [weak self] error in + Task { @MainActor in + await self?.handleWiFiDisconnection(error: error) + } + } - await onConnectionReady?() - // onConnectionReady can suspend; a reentrant main-actor WiFi disconnect may - // clear connectedDevice or replace services during that await. Recheck so an - // aborted reconnect bails here instead of syncing a torn-down session. - guard connectionIntent.wantsConnection, - self.services === newServices, - connectedDevice != nil - else { - logger.info("[WiFi] reconnect aborted after onConnectionReady: connection state changed") - await session.stop() - await newServices.tearDown() - services = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - return - } - let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, transportType: .wifi, context: "WiFi reconnect") + await onConnectionReady?() + // onConnectionReady can suspend; a reentrant main-actor WiFi disconnect may + // clear connectedDevice or replace services during that await. Recheck so an + // aborted reconnect bails here instead of syncing a torn-down session. + guard connectionIntent.wantsConnection, + services === newServices, + connectedDevice != nil + else { + logger.info("[WiFi] reconnect aborted after onConnectionReady: connection state changed") + await session.stop() + await newServices.tearDown() + services = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + return + } + let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, transportType: .wifi, context: "WiFi reconnect") - guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .wifi) else { return } + guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .wifi) else { return } - stopReconnectionWatchdog() - if connectionState == .ready { - startWiFiHeartbeat() - } + stopReconnectionWatchdog() + if connectionState == .ready { + startWiFiHeartbeat() } + } - // MARK: - WiFi Connection Health + // MARK: - WiFi Connection Health - /// Checks if the WiFi connection is still alive (call on app foreground) - public func checkWiFiConnectionHealth() async { - // If a reconnect task is already running, let it finish - if wifiReconnectTask != nil { - logger.info("WiFi reconnection already in progress on foreground") - return - } + /// Checks if the WiFi connection is still alive (call on app foreground) + public func checkWiFiConnectionHealth() async { + // If a reconnect task is already running, let it finish + if wifiReconnectTask != nil { + logger.info("WiFi reconnection already in progress on foreground") + return + } - // Case 1: We think we're connected but the transport died while backgrounded - if currentTransportType == .wifi, - connectionState.isOperational, - let wifiTransport { - let isConnected = await wifiTransport.isConnected - if !isConnected { - logger.info("WiFi connection died while backgrounded") - await handleWiFiDisconnection(error: nil) - return - } - } + // Case 1: We think we're connected but the transport died while backgrounded + if currentTransportType == .wifi, + connectionState.isOperational, + let wifiTransport { + let isConnected = await wifiTransport.isConnected + if !isConnected { + logger.info("WiFi connection died while backgrounded") + await handleWiFiDisconnection(error: nil) + return + } + } - // Case 2: Connection was lost and cleanup already ran while backgrounded, - // but user still wants to be connected — attempt fresh reconnection - if connectionState == .disconnected, - connectionIntent.wantsConnection, - let lastDeviceID = lastConnectedDeviceID { - let dataStore = PersistenceStore(modelContainer: modelContainer) - if let device = try? await dataStore.fetchDevice(id: lastDeviceID), - let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { - if case .wifi(let host, let port, _) = wifiMethod { - logger.info("WiFi foreground reconnect to \(host):\(port)") - do { - try await connectViaWiFi(host: host, port: port) - } catch { - logger.warning("WiFi foreground reconnect failed: \(error.localizedDescription)") - } - } - } + // Case 2: Connection was lost and cleanup already ran while backgrounded, + // but user still wants to be connected — attempt fresh reconnection + if connectionState == .disconnected, + connectionIntent.wantsConnection, + let lastDeviceID = lastConnectedDeviceID { + let dataStore = PersistenceStore(modelContainer: modelContainer) + if let device = try? await dataStore.fetchDevice(id: lastDeviceID), + let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { + if case let .wifi(host, port, _) = wifiMethod { + logger.info("WiFi foreground reconnect to \(host):\(port)") + do { + try await connectViaWiFi(host: host, port: port) + } catch { + logger.warning("WiFi foreground reconnect failed: \(error.localizedDescription)") + } } + } + } + } + + // MARK: - WiFi Connection + + /// Connects to a device via WiFi/TCP. + /// + /// - Parameters: + /// - host: The hostname or IP address of the device + /// - port: The TCP port to connect to + /// - forceFullSync: When true, performs a complete sync ignoring cached timestamps + /// - Throws: Connection or session errors + public func connectViaWiFi(host: String, port: UInt16, forceFullSync: Bool = false) async throws { + logger.info("Connecting via WiFi to \(host):\(port)") + + // Disconnect existing connection if any + if connectionState != .disconnected { + await disconnect(reason: .wifiReconnectPrep) } - // MARK: - WiFi Connection - - /// Connects to a device via WiFi/TCP. - /// - /// - Parameters: - /// - host: The hostname or IP address of the device - /// - port: The TCP port to connect to - /// - forceFullSync: When true, performs a complete sync ignoring cached timestamps - /// - Throws: Connection or session errors - public func connectViaWiFi(host: String, port: UInt16, forceFullSync: Bool = false) async throws { - logger.info("Connecting via WiFi to \(host):\(port)") - - // Disconnect existing connection if any - if connectionState != .disconnected { - await disconnect(reason: .wifiReconnectPrep) - } + connectionIntent = .wantsConnection() + persistIntent() + connectionState = .connecting - connectionIntent = .wantsConnection() - persistIntent() - connectionState = .connecting + do { + // Create and configure WiFi transport + let newWiFiTransport = WiFiTransport() + await newWiFiTransport.setConnectionInfo(host: host, port: port) + wifiTransport = newWiFiTransport - do { - // Create and configure WiFi transport - let newWiFiTransport = WiFiTransport() - await newWiFiTransport.setConnectionInfo(host: host, port: port) - wifiTransport = newWiFiTransport + // Connect the transport + try await newWiFiTransport.connect() - // Connect the transport - try await newWiFiTransport.connect() + connectionState = .connected - connectionState = .connected + // Create session (same as BLE) + let newSession = MeshCoreSession(transport: newWiFiTransport) + session = newSession - // Create session (same as BLE) - let newSession = MeshCoreSession(transport: newWiFiTransport) - self.session = newSession + let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) + detectAndStorePlatform(model: deviceCapabilities.model, transportType: .wifi) - let (meshCoreSelfInfo, deviceCapabilities) = try await initializeSession(newSession) - detectAndStorePlatform(model: deviceCapabilities.model, transportType: .wifi) + // Derive device ID from public key (WiFi devices don't have Bluetooth UUIDs) + let deviceID = DeviceIdentity.deriveUUID(from: meshCoreSelfInfo.publicKey) - // Derive device ID from public key (WiFi devices don't have Bluetooth UUIDs) - let deviceID = DeviceIdentity.deriveUUID(from: meshCoreSelfInfo.publicKey) + let wifiMethod = ConnectionMethod.wifi(host: host, port: port, displayName: nil) + let (newServices, radioID) = try await buildServicesAndSaveDevice( + deviceID: deviceID, + session: newSession, + selfInfo: meshCoreSelfInfo, + capabilities: deviceCapabilities, + connectionMethods: [wifiMethod] + ) - let wifiMethod = ConnectionMethod.wifi(host: host, port: port, displayName: nil) - let (newServices, radioID) = try await buildServicesAndSaveDevice( - deviceID: deviceID, - session: newSession, - selfInfo: meshCoreSelfInfo, - capabilities: deviceCapabilities, - connectionMethods: [wifiMethod] - ) + // Persist connection for potential future use + persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) - // Persist connection for potential future use - persistConnection(deviceID: deviceID, radioID: radioID, deviceName: meshCoreSelfInfo.name) + await onConnectionReady?() + let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, transportType: .wifi, forceFullSync: forceFullSync) - await onConnectionReady?() - let syncSucceeded = await performInitialSync(radioID: radioID, services: newServices, transportType: .wifi, forceFullSync: forceFullSync) - - // Wire disconnection handler before promotion — needed even if promotion fails - await newWiFiTransport.setDisconnectionHandler { [weak self] error in - Task { @MainActor in - await self?.handleWiFiDisconnection(error: error) - } - } + // Wire disconnection handler before promotion — needed even if promotion fails + await newWiFiTransport.setDisconnectionHandler { [weak self] error in + Task { @MainActor in + await self?.handleWiFiDisconnection(error: error) + } + } - guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .wifi) else { return } + guard await promoteToReady(syncSucceeded: syncSucceeded, expectedServices: newServices, transportType: .wifi) else { return } - stopReconnectionWatchdog() - if connectionState == .ready { - startWiFiHeartbeat() - } - logger.info("WiFi connection complete - device ready") + stopReconnectionWatchdog() + if connectionState == .ready { + startWiFiHeartbeat() + } + logger.info("WiFi connection complete - device ready") - } catch { - // Cleanup on failure - if let wifiTransport { - await wifiTransport.disconnect() - self.wifiTransport = nil - } - currentTransportType = nil - await cleanupConnection() - throw error - } + } catch { + // Cleanup on failure + if let wifiTransport { + await wifiTransport.disconnect() + self.wifiTransport = nil + } + currentTransportType = nil + await cleanupConnection() + throw error } + } } diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift index f20fbd34..f3f220f1 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift @@ -1,8 +1,8 @@ @preconcurrency import CoreBluetooth import Foundation -import SwiftData import MeshCore import OSLog +import SwiftData /// Manages the connection lifecycle for mesh devices. /// @@ -44,323 +44,326 @@ import OSLog @Observable @MainActor public final class ConnectionManager { - - // MARK: - Logging - - let logger = PersistentLogger(subsystem: "com.mc1", category: "ConnectionManager") - - // MARK: - Observable State - - /// Broadcasts every `connectionState` change. Per-connection consumers - /// (`ServiceContainer`) subscribe at construction and end their - /// subscription by task cancellation in `tearDown()`. `ConnectionManager` - /// never calls `finish()`: it lives for the app lifetime, and the next - /// connection's container subscribes to this same broadcaster. - let connectionStateEvents = EventBroadcaster() - - /// Current connection state - public internal(set) var connectionState: DeviceConnectionState = .disconnected { - didSet { - connectionStateEvents.yield(connectionState) - #if DEBUG - assertStateInvariants() - #endif - } + // MARK: - Logging + + let logger = PersistentLogger(subsystem: "com.mc1", category: "ConnectionManager") + + // MARK: - Observable State + + /// Broadcasts every `connectionState` change. Per-connection consumers + /// (`ServiceContainer`) subscribe at construction and end their + /// subscription by task cancellation in `tearDown()`. `ConnectionManager` + /// never calls `finish()`: it lives for the app lifetime, and the next + /// connection's container subscribes to this same broadcaster. + let connectionStateEvents = EventBroadcaster() + + /// Current connection state + public internal(set) var connectionState: DeviceConnectionState = .disconnected { + didSet { + connectionStateEvents.yield(connectionState) + #if DEBUG + assertStateInvariants() + #endif } - - /// Connected device info (nil when disconnected) - public internal(set) var connectedDevice: DeviceDTO? - - /// Allowed repeat frequency ranges from connected device (empty when disconnected or unsupported) - public var allowedRepeatFreqRanges: [MeshCore.FrequencyRange] = [] - - /// Services container (nil when disconnected) - public internal(set) var services: ServiceContainer? - - /// Current transport type (bluetooth or wifi) - public internal(set) var currentTransportType: TransportType? - - /// Current user-actionable Bluetooth availability, updated as `CBCentralManager` reports state - /// changes. The macOS scan picker reads this to swap its scanning state for a remedy when - /// Bluetooth is off or unauthorized; iOS surfaces the same conditions through its system picker. - public internal(set) var bluetoothAvailability: BluetoothAvailability = .ready - - /// Detected device platform, used for sync throttling config. - /// Survives reconnects (lives on ConnectionManager, not ServiceContainer). - private(set) var detectedPlatform: DevicePlatform = .unknown - - /// Records the last fully-clean channel sync, keyed by device. - /// Only set when channel sync completes with zero errors (including retries). - /// Survives transient disconnects; cleared on explicit disconnect or device change. - var lastCleanChannelSync: (radioID: UUID, completedAt: Date)? - - /// Records the last attempted channel sync, including partial/failed attempts. - /// Used to cool down immediate channel-only retry loops. - var lastAttemptedChannelSync: (radioID: UUID, attemptedAt: Date)? - - /// The user's connection intent. Replaces shouldBeConnected, userExplicitlyDisconnected, and pendingForceFullSync. - var connectionIntent: ConnectionIntent = .none - - /// The device being actively connected via connect(to:). - /// Nil during auto-reconnect (tracked by reconnectionCoordinator.reconnectingDeviceID instead). - var connectingDeviceID: UUID? - - /// The device whose MeshCore session is currently being rebuilt after a BLE auto-reconnect. - /// Used to suppress duplicate reconnect attempts while session startup is still in flight. - var sessionRebuildDeviceID: UUID? - - /// True for the duration of pairNewDevice() — suppresses opportunistic - /// reconnect paths so they can't race the pairing's `connect(to:)` for the - /// BLE state machine's single in-flight connect slot. - var isPairingInProgress = false - - /// Single source of truth for "stand down, an explicit connect flow is running." - /// Opportunistic reconnect call sites consult this; or new conditions in here - /// when the next contention class shows up, so every site picks them up. - var shouldDeferOpportunisticReconnect: Bool { - isPairingInProgress + } + + /// Connected device info (nil when disconnected) + public internal(set) var connectedDevice: DeviceDTO? + + /// Allowed repeat frequency ranges from connected device (empty when disconnected or unsupported) + public var allowedRepeatFreqRanges: [MeshCore.FrequencyRange] = [] + + /// Services container (nil when disconnected) + public internal(set) var services: ServiceContainer? + + /// Current transport type (bluetooth or wifi) + public internal(set) var currentTransportType: TransportType? + + /// Current user-actionable Bluetooth availability, updated as `CBCentralManager` reports state + /// changes. The macOS scan picker reads this to swap its scanning state for a remedy when + /// Bluetooth is off or unauthorized; iOS surfaces the same conditions through its system picker. + public internal(set) var bluetoothAvailability: BluetoothAvailability = .ready + + /// Detected device platform, used for sync throttling config. + /// Survives reconnects (lives on ConnectionManager, not ServiceContainer). + private(set) var detectedPlatform: DevicePlatform = .unknown + + /// Records the last fully-clean channel sync, keyed by device. + /// Only set when channel sync completes with zero errors (including retries). + /// Survives transient disconnects; cleared on explicit disconnect or device change. + var lastCleanChannelSync: (radioID: UUID, completedAt: Date)? + + /// Records the last attempted channel sync, including partial/failed attempts. + /// Used to cool down immediate channel-only retry loops. + var lastAttemptedChannelSync: (radioID: UUID, attemptedAt: Date)? + + /// The user's connection intent. Replaces shouldBeConnected, userExplicitlyDisconnected, and pendingForceFullSync. + var connectionIntent: ConnectionIntent = .none + + /// The device being actively connected via connect(to:). + /// Nil during auto-reconnect (tracked by reconnectionCoordinator.reconnectingDeviceID instead). + var connectingDeviceID: UUID? + + /// The device whose MeshCore session is currently being rebuilt after a BLE auto-reconnect. + /// Used to suppress duplicate reconnect attempts while session startup is still in flight. + var sessionRebuildDeviceID: UUID? + + /// True for the duration of pairNewDevice() — suppresses opportunistic + /// reconnect paths so they can't race the pairing's `connect(to:)` for the + /// BLE state machine's single in-flight connect slot. + var isPairingInProgress = false + + /// Single source of truth for "stand down, an explicit connect flow is running." + /// Opportunistic reconnect call sites consult this; or new conditions in here + /// when the next contention class shows up, so every site picks them up. + var shouldDeferOpportunisticReconnect: Bool { + isPairingInProgress + } + + /// Single chokepoint for opportunistic reconnect attempts. Consults the defer + /// predicate and dispatches to `connect(to:)`. New opportunistic-reconnect + /// sites must route through this helper rather than duplicating the gate logic. + /// The auto-reconnect-handler closure (`setAutoReconnectingHandler`) operates + /// on `.autoReconnecting` phases rather than initiating a `.connecting`, so it + /// applies the same gate predicate at its own call site instead. + /// - Parameters: + /// - deviceID: device to reconnect to. The caller is responsible for + /// vetting that the user wants this device connected before invoking. + /// - reason: short string for log correlation across call sites. + func attemptOpportunisticReconnect(deviceID: UUID, reason: String) async { + guard !shouldDeferOpportunisticReconnect else { + logger.info("[BLE] Opportunistic reconnect skipped (\(reason)): pairing in progress") + return } - - /// Single chokepoint for opportunistic reconnect attempts. Consults the defer - /// predicate and dispatches to `connect(to:)`. New opportunistic-reconnect - /// sites must route through this helper rather than duplicating the gate logic. - /// The auto-reconnect-handler closure (`setAutoReconnectingHandler`) operates - /// on `.autoReconnecting` phases rather than initiating a `.connecting`, so it - /// applies the same gate predicate at its own call site instead. - /// - Parameters: - /// - deviceID: device to reconnect to. The caller is responsible for - /// vetting that the user wants this device connected before invoking. - /// - reason: short string for log correlation across call sites. - func attemptOpportunisticReconnect(deviceID: UUID, reason: String) async { - guard !shouldDeferOpportunisticReconnect else { - logger.info("[BLE] Opportunistic reconnect skipped (\(reason)): pairing in progress") - return - } - do { - try await connect(to: deviceID) - } catch { - logger.warning("[BLE] Opportunistic reconnect (\(reason)) failed: \(error.localizedDescription)") - } + do { + try await connect(to: deviceID) + } catch { + logger.warning("[BLE] Opportunistic reconnect (\(reason)) failed: \(error.localizedDescription)") } + } - // MARK: - Callbacks + // MARK: - Callbacks + + /// Called when connection is ready and services are available. + /// Use this to wire up UI observation of services. + /// Installed by `AppState` during initialization. + public var onConnectionReady: (() async -> Void)? - /// Called when connection is ready and services are available. - /// Use this to wire up UI observation of services. - /// Installed by `AppState` during initialization. - public var onConnectionReady: (() async -> Void)? + /// Called when connection is lost (disconnection, BLE power off, etc). + /// Use this to update UI state when services become unavailable. + /// Installed by `AppState` during initialization. + public var onConnectionLost: (() async -> Void)? - /// Called when connection is lost (disconnection, BLE power off, etc). - /// Use this to update UI state when services become unavailable. - /// Installed by `AppState` during initialization. - public var onConnectionLost: (() async -> Void)? + /// Called after initial sync completes and connectionState becomes `.ready`. + /// Use this for work that depends on up-to-date synced data (e.g. stale node cleanup). + /// Installed by `AppState` during initialization. + public var onDeviceSynced: (() async -> Void)? - /// Called after initial sync completes and connectionState becomes `.ready`. - /// Use this for work that depends on up-to-date synced data (e.g. stale node cleanup). - /// Installed by `AppState` during initialization. - public var onDeviceSynced: (() async -> Void)? + /// Provider for app foreground/background state detection. + /// Installed by `AppState` during initialization. + public var appStateProvider: AppStateProvider? - /// Provider for app foreground/background state detection. - /// Installed by `AppState` during initialization. - public var appStateProvider: AppStateProvider? + /// Number of devices registered with the system pairing registry (for troubleshooting UI). + /// iOS reports AccessorySetupKit accessories; macOS reports 0 (no system registry). + public var pairedAccessoriesCount: Int { + pairing.registeredDeviceCount + } - /// Number of devices registered with the system pairing registry (for troubleshooting UI). - /// iOS reports AccessorySetupKit accessories; macOS reports 0 (no system registry). - public var pairedAccessoriesCount: Int { - pairing.registeredDeviceCount - } + /// Whether the connected device can be renamed through a system rename surface. + /// `true` on iOS (AccessorySetupKit rename sheet); `false` on macOS, where the UI must + /// hide the rename action rather than offer a control that silently does nothing. + public var supportsDeviceRename: Bool { + pairing.supportsSystemRename + } - /// Whether the connected device can be renamed through a system rename surface. - /// `true` on iOS (AccessorySetupKit rename sheet); `false` on macOS, where the UI must - /// hide the rename action rather than offer a control that silently does nothing. - public var supportsDeviceRename: Bool { - pairing.supportsSystemRename - } + /// Whether this platform has an app-visible system pairing registry (AccessorySetupKit). + /// `true` on iOS; `false` on macOS "Designed for iPad". The device picker uses this to + /// decide whether registry membership or the stored connection method is the reachability + /// signal, and the connect path uses it to bound a user-initiated connect's retry budget. + public var hasSystemPairingRegistry: Bool { + pairing.hasSystemPairingRegistry + } + + /// Creates a standalone persistence store for operations that don't require services + public func createStandalonePersistenceStore() -> PersistenceStore { + PersistenceStore(modelContainer: modelContainer) + } + + // MARK: - Internal Components - /// Whether this platform has an app-visible system pairing registry (AccessorySetupKit). - /// `true` on iOS; `false` on macOS "Designed for iPad". The device picker uses this to - /// decide whether registry membership or the stored connection method is the reachability - /// signal, and the connect path uses it to bound a user-initiated connect's retry budget. - public var hasSystemPairingRegistry: Bool { - pairing.hasSystemPairingRegistry - } + let modelContainer: ModelContainer + private let defaults: UserDefaults + private let lastConnectionStore: LastConnectionStore + let transport: any iOSMeshTransport + var wifiTransport: WiFiTransport? + var session: MeshCoreSession? + /// Device discovery + system pairing-registry seam. Resolves to AccessorySetupKit on + /// iOS, or an in-app CoreBluetooth scan picker on macOS "Designed for iPad". + let pairing: any DevicePairingService + + /// Non-nil only on macOS, where the scan picker UI must be presented by the view layer. + /// nil on iOS, where AccessorySetupKit presents its own system picker. + public var bluetoothScanPicker: BluetoothScanPairingService? { + pairing as? BluetoothScanPairingService + } + + /// Shared BLE state machine to manage connection lifecycle. + /// This prevents state restoration race conditions that cause "API MISUSE" errors. + let stateMachine: any BLEStateMachineProtocol + + /// Coordinates iOS auto-reconnect lifecycle (timeouts, teardown, rebuild). + let reconnectionCoordinator = BLEReconnectionCoordinator() + + // MARK: - WiFi Reconnection + + /// Task handling WiFi reconnection attempts + var wifiReconnectTask: Task? + + /// Current reconnection attempt number + var wifiReconnectAttempt = 0 + + /// Maximum duration for WiFi reconnection attempts (30 seconds) + static let wifiMaxReconnectDuration: Duration = .seconds(30) + + /// Last reconnection start time (for rate limiting rapid disconnects) + var lastWiFiReconnectStartTime: Date? + + /// Minimum interval between reconnection attempts (prevents flapping) + static let wifiReconnectCooldown: TimeInterval = 35 + + // MARK: - WiFi Heartbeat + + /// Task for periodic WiFi connection health checks + var wifiHeartbeatTask: Task? + + /// Interval between WiFi heartbeat probes (seconds) + static let wifiHeartbeatInterval: Duration = .seconds(30) + + /// Task coordinating BLE scan startup to avoid start/stop races with stream termination. + var bleScanTask: Task? + + /// Monotonic token used to invalidate stale BLE scan requests. + var bleScanRequestID: UInt64 = 0 + + // MARK: - Resync State + + // Stored state for the sync retry loops; the loops themselves live in the + // SyncRetry extension, which cannot add instance storage. + + /// Current resync attempt count (reset on success or disconnect) + var resyncAttemptCount = 0 + + /// Task managing the resync retry loop + var resyncTask: Task? + + /// Task managing delayed channel-only retry after a partial channel phase. + var channelRetryTask: Task? + + /// Callback when resync fails after all attempts (triggers "Sync Failed" pill) + /// Note: @Sendable @MainActor ensures safe cross-isolation callback + /// Installed by `ConnectionUIState.wireCallbacks`. + public var onResyncFailed: (@Sendable @MainActor () -> Void)? + + // MARK: - Circuit Breaker + + /// Prevents rapid reconnection loops after repeated failures. + /// Closed → Open (30s cooldown) → Half-Open (single probe). + private enum CircuitBreakerState { + case closed + case open(since: Date) + case halfOpen + } + + private var circuitBreaker: CircuitBreakerState = .closed + private static let circuitBreakerCooldown: TimeInterval = 30 + + /// Connect-retry budget for a single `connect(to:)` call. + /// `default` applies to every attempt on a platform with a system pairing registry, and to + /// all background reconnects. `unverified` bounds a *user-initiated* connect on a platform + /// without a registry (macOS), where CoreBluetooth cannot pre-reject an absent cached + /// peripheral, so the full budget would otherwise leave the user staring at a ~40s spinner. + static let defaultConnectAttempts = 4 + static let unverifiedConnectAttempts = 2 - /// Creates a standalone persistence store for operations that don't require services - public func createStandalonePersistenceStore() -> PersistenceStore { - PersistenceStore(modelContainer: modelContainer) - } + /// Checks whether a connection attempt should proceed. + /// Returns `true` if the circuit breaker allows it. + /// - Parameter force: When `true`, bypasses the circuit breaker (user-initiated reconnect) + func shouldAllowConnection(force: Bool) -> Bool { + if force { return true } - // MARK: - Internal Components - - let modelContainer: ModelContainer - private let defaults: UserDefaults - private let lastConnectionStore: LastConnectionStore - let transport: any iOSMeshTransport - var wifiTransport: WiFiTransport? - var session: MeshCoreSession? - /// Device discovery + system pairing-registry seam. Resolves to AccessorySetupKit on - /// iOS, or an in-app CoreBluetooth scan picker on macOS "Designed for iPad". - let pairing: any DevicePairingService - - /// Non-nil only on macOS, where the scan picker UI must be presented by the view layer. - /// nil on iOS, where AccessorySetupKit presents its own system picker. - public var bluetoothScanPicker: BluetoothScanPairingService? { - pairing as? BluetoothScanPairingService + switch circuitBreaker { + case .closed: + return true + case let .open(since): + if Date().timeIntervalSince(since) >= Self.circuitBreakerCooldown { + circuitBreaker = .halfOpen + logger.info("[BLE] Circuit breaker: open → halfOpen (cooldown elapsed)") + return true + } + return false + case .halfOpen: + // Half-open: allow a single probe attempt to determine if the connection can be restored. + return true } + } + + /// Records a connection failure for circuit breaker tracking. + /// Trips the breaker to `.open` when called after all retries are exhausted. + func recordConnectionFailure() { + switch circuitBreaker { + case .closed: + circuitBreaker = .open(since: Date()) + logger.warning("[BLE] Circuit breaker: closed → open (retries exhausted)") + case .halfOpen: + circuitBreaker = .open(since: Date()) + logger.warning("[BLE] Circuit breaker: halfOpen → open (probe failed)") + case .open: + break + } + } - /// Shared BLE state machine to manage connection lifecycle. - /// This prevents state restoration race conditions that cause "API MISUSE" errors. - let stateMachine: any BLEStateMachineProtocol - - /// Coordinates iOS auto-reconnect lifecycle (timeouts, teardown, rebuild). - let reconnectionCoordinator = BLEReconnectionCoordinator() - - // MARK: - WiFi Reconnection - - /// Task handling WiFi reconnection attempts - var wifiReconnectTask: Task? - - /// Current reconnection attempt number - var wifiReconnectAttempt = 0 - - /// Maximum duration for WiFi reconnection attempts (30 seconds) - static let wifiMaxReconnectDuration: Duration = .seconds(30) - - /// Last reconnection start time (for rate limiting rapid disconnects) - var lastWiFiReconnectStartTime: Date? - - /// Minimum interval between reconnection attempts (prevents flapping) - static let wifiReconnectCooldown: TimeInterval = 35 - - // MARK: - WiFi Heartbeat - - /// Task for periodic WiFi connection health checks - var wifiHeartbeatTask: Task? - - /// Interval between WiFi heartbeat probes (seconds) - static let wifiHeartbeatInterval: Duration = .seconds(30) - - /// Task coordinating BLE scan startup to avoid start/stop races with stream termination. - var bleScanTask: Task? - - /// Monotonic token used to invalidate stale BLE scan requests. - var bleScanRequestID: UInt64 = 0 - - // MARK: - Resync State - - // Stored state for the sync retry loops; the loops themselves live in the - // SyncRetry extension, which cannot add instance storage. - - /// Current resync attempt count (reset on success or disconnect) - var resyncAttemptCount = 0 + /// Records a successful connection, resetting the circuit breaker. + func recordConnectionSuccess() { + if case .closed = circuitBreaker { return } + circuitBreaker = .closed + logger.info("[BLE] Circuit breaker: → closed (connection succeeded)") + } - /// Task managing the resync retry loop - var resyncTask: Task? + // MARK: - Reconnection Watchdog - /// Task managing delayed channel-only retry after a partial channel phase. - var channelRetryTask: Task? + /// Task managing the reconnection watchdog (retries when stuck disconnected) + var reconnectionWatchdogTask: Task? - /// Callback when resync fails after all attempts (triggers "Sync Failed" pill) - /// Note: @Sendable @MainActor ensures safe cross-isolation callback - /// Installed by `ConnectionUIState.wireCallbacks`. - public var onResyncFailed: (@Sendable @MainActor () -> Void)? + /// Session IDs that need re-authentication after BLE reconnect. + /// Populated by `handleBLEDisconnection()`, consumed by `rebuildSession()`. + /// Empty after app restart, so rooms show "Tap to reconnect" instead of auto-connecting. + var sessionsAwaitingReauth: Set = [] - // MARK: - Circuit Breaker + // MARK: - Simulator Support - /// Prevents rapid reconnection loops after repeated failures. - /// Closed → Open (30s cooldown) → Half-Open (single probe). - private enum CircuitBreakerState { - case closed - case open(since: Date) - case halfOpen - } + /// Simulator connection mode (used for demo mode on device) + let simulatorMode = SimulatorConnectionMode() - private var circuitBreaker: CircuitBreakerState = .closed - private static let circuitBreakerCooldown: TimeInterval = 30 - - /// Connect-retry budget for a single `connect(to:)` call. - /// `default` applies to every attempt on a platform with a system pairing registry, and to - /// all background reconnects. `unverified` bounds a *user-initiated* connect on a platform - /// without a registry (macOS), where CoreBluetooth cannot pre-reject an absent cached - /// peripheral, so the full budget would otherwise leave the user staring at a ~40s spinner. - static let defaultConnectAttempts = 4 - static let unverifiedConnectAttempts = 2 - - /// Checks whether a connection attempt should proceed. - /// Returns `true` if the circuit breaker allows it. - /// - Parameter force: When `true`, bypasses the circuit breaker (user-initiated reconnect) - func shouldAllowConnection(force: Bool) -> Bool { - if force { return true } - - switch circuitBreaker { - case .closed: - return true - case .open(let since): - if Date().timeIntervalSince(since) >= Self.circuitBreakerCooldown { - circuitBreaker = .halfOpen - logger.info("[BLE] Circuit breaker: open → halfOpen (cooldown elapsed)") - return true - } - return false - case .halfOpen: - // Half-open: allow a single probe attempt to determine if the connection can be restored. - return true - } + // Whether running in simulator mode + #if targetEnvironment(simulator) + public var isSimulatorMode: Bool { + true } - - /// Records a connection failure for circuit breaker tracking. - /// Trips the breaker to `.open` when called after all retries are exhausted. - func recordConnectionFailure() { - switch circuitBreaker { - case .closed: - circuitBreaker = .open(since: Date()) - logger.warning("[BLE] Circuit breaker: closed → open (retries exhausted)") - case .halfOpen: - circuitBreaker = .open(since: Date()) - logger.warning("[BLE] Circuit breaker: halfOpen → open (probe failed)") - case .open: - break - } + #else + public var isSimulatorMode: Bool { + false } + #endif - /// Records a successful connection, resetting the circuit breaker. - func recordConnectionSuccess() { - if case .closed = circuitBreaker { return } - circuitBreaker = .closed - logger.info("[BLE] Circuit breaker: → closed (connection succeeded)") - } - - // MARK: - Reconnection Watchdog - - /// Task managing the reconnection watchdog (retries when stuck disconnected) - var reconnectionWatchdogTask: Task? - - /// Session IDs that need re-authentication after BLE reconnect. - /// Populated by `handleBLEDisconnection()`, consumed by `rebuildSession()`. - /// Empty after app restart, so rooms show "Tap to reconnect" instead of auto-connecting. - var sessionsAwaitingReauth: Set = [] - - // MARK: - Simulator Support - - /// Simulator connection mode (used for demo mode on device) - let simulatorMode = SimulatorConnectionMode() + // MARK: - Last Device Persistence - /// Whether running in simulator mode - #if targetEnvironment(simulator) - public var isSimulatorMode: Bool { true } - #else - public var isSimulatorMode: Bool { false } - #endif - - // MARK: - Last Device Persistence - - #if DEBUG + #if DEBUG /// Test override for lastConnectedDeviceID - internal var testLastConnectedDeviceID: UUID? + var testLastConnectedDeviceID: UUID? /// True when the BLE reconnection watchdog task is active. - internal var isReconnectionWatchdogRunning: Bool { - reconnectionWatchdogTask != nil + var isReconnectionWatchdogRunning: Bool { + reconnectionWatchdogTask != nil } /// Strategy injection for `waitForOtherAppReconnection`. Tests use this to @@ -368,799 +371,798 @@ public final class ConnectionManager { /// deterministically before releasing the wait. typealias OtherAppWaitStrategy = @Sendable (UUID) async -> Bool - internal var otherAppWaitStrategyOverride: OtherAppWaitStrategy? + var otherAppWaitStrategyOverride: OtherAppWaitStrategy? typealias HealthCheckSessionRebuildOverride = @MainActor (UUID) async throws -> Void - internal var rebuildSessionForHealthCheckOverride: HealthCheckSessionRebuildOverride? - #endif - - /// The last connected device ID (for auto-reconnect) - public var lastConnectedDeviceID: UUID? { - #if DEBUG - if let testID = testLastConnectedDeviceID { - return testID - } - #endif - return lastConnectionStore.deviceID - } - - /// The last connected radio ID (for offline data scoping) - public var lastConnectedRadioID: UUID? { - lastConnectionStore.radioID - } - - /// The last connected device name (for offline display when disconnected) - public var lastConnectedDeviceName: String? { - lastConnectionStore.deviceName - } - - /// Records a successful connection for future restoration - func persistConnection(deviceID: UUID, radioID: UUID, deviceName: String) { - lastConnectionStore.persist(deviceID: deviceID, radioID: radioID, deviceName: deviceName) - } + var rebuildSessionForHealthCheckOverride: HealthCheckSessionRebuildOverride? + #endif - /// Clears the persisted connection - func clearPersistedConnection() { - lastConnectionStore.clear() + /// The last connected device ID (for auto-reconnect) + public var lastConnectedDeviceID: UUID? { + #if DEBUG + if let testID = testLastConnectedDeviceID { + return testID + } + #endif + return lastConnectionStore.deviceID + } + + /// The last connected radio ID (for offline data scoping) + public var lastConnectedRadioID: UUID? { + lastConnectionStore.radioID + } + + /// The last connected device name (for offline display when disconnected) + public var lastConnectedDeviceName: String? { + lastConnectionStore.deviceName + } + + /// Records a successful connection for future restoration + func persistConnection(deviceID: UUID, radioID: UUID, deviceName: String) { + lastConnectionStore.persist(deviceID: deviceID, radioID: radioID, deviceName: deviceName) + } + + /// Clears the persisted connection + func clearPersistedConnection() { + lastConnectionStore.clear() + } + + /// Whether the disconnected pill should be suppressed (user explicitly disconnected) + public var shouldSuppressDisconnectedPill: Bool { + connectionIntent.isUserDisconnected + } + + /// Most recent disconnect diagnostic summary persisted across app launches. + public var lastDisconnectDiagnostic: String? { + lastConnectionStore.disconnectDiagnostic + } + + /// Current high-level connection intent, exported for diagnostics. + public var connectionIntentSummary: String { + switch connectionIntent { + case .none: + "none" + case .userDisconnected: + "userDisconnected" + case let .wantsConnection(forceFullSync): + forceFullSync ? "wantsConnection(forceFullSync: true)" : "wantsConnection" } - - /// Whether the disconnected pill should be suppressed (user explicitly disconnected) - public var shouldSuppressDisconnectedPill: Bool { - connectionIntent.isUserDisconnected + } + + /// Whether the last connection was a simulator connection + public var wasSimulatorConnection: Bool { + lastConnectedDeviceID == MockDataProvider.simulatorDeviceID + } + + /// Whether a WiFi disconnection is currently being handled (prevents interleaving + /// across await suspension points before wifiReconnectTask is set). + var isHandlingWiFiDisconnection = false + + // MARK: - Cancellation Helpers + + /// Cancels any in-progress WiFi reconnection attempts + func cancelWiFiReconnection() { + wifiReconnectTask?.cancel() + wifiReconnectTask = nil + wifiReconnectAttempt = 0 + } + + // MARK: - Initialization + + /// Creates a new connection manager. + /// - Parameters: + /// - modelContainer: The SwiftData model container for persistence + /// - stateMachine: Optional BLE state machine for testing. If nil, creates a real BLEStateMachine. + /// - transport: Optional iOS mesh transport for testing. If nil, creates an `iOSBLETransport` against the chosen state machine. + /// - pairing: Optional pairing service for testing. If nil, `DevicePairingFactory` selects the platform implementation. + public init( + modelContainer: ModelContainer, + defaults: UserDefaults = .standard, + stateMachine: (any BLEStateMachineProtocol)? = nil, + transport: (any iOSMeshTransport)? = nil, + pairing: (any DevicePairingService)? = nil + ) { + self.modelContainer = modelContainer + self.defaults = defaults + lastConnectionStore = LastConnectionStore(defaults: defaults) + connectionIntent = .restored(from: defaults) + + // Use provided state machine or create default + let bleStateMachine = stateMachine ?? BLEStateMachine() + self.stateMachine = bleStateMachine + + if let injected = transport { + self.transport = injected + } else if let concrete = bleStateMachine as? BLEStateMachine { + self.transport = iOSBLETransport(stateMachine: concrete) + } else { + // Test mode without an injected transport: create a dummy (unused when mocking BLE) + self.transport = iOSBLETransport(stateMachine: BLEStateMachine()) } - /// Most recent disconnect diagnostic summary persisted across app launches. - public var lastDisconnectDiagnostic: String? { - lastConnectionStore.disconnectDiagnostic + self.pairing = pairing ?? DevicePairingFactory.make() + + self.pairing.delegate = self + reconnectionCoordinator.delegate = self + + // Best-effort early wiring. activate() awaits the same idempotent + // method before constructing the CBCentralManager, which is the + // ordering guarantee; this Task just installs handlers promptly for + // flows that touch the state machine before activate() runs. + Task { await self.wireTransportHandlers() } + } + + /// Wires the transport and state-machine lifecycle handlers. + /// + /// Must complete before anything constructs the CBCentralManager: + /// creating the central can synchronously fire state-restoration + /// callbacks, and a missed auto-reconnect or reconnection handler at + /// launch leaves the restored link without a session rebuild. Idempotent; + /// re-installing the same handlers is harmless. + func wireTransportHandlers() async { + let stateMachine = stateMachine + let transport = transport + + // Handle disconnection events + await transport.setDisconnectionHandler { [weak self] deviceID, error in + Task { @MainActor in + guard let self else { return } + await self.handleConnectionLoss(deviceID: deviceID, error: error) + } } - /// Current high-level connection intent, exported for diagnostics. - public var connectionIntentSummary: String { - switch connectionIntent { - case .none: - return "none" - case .userDisconnected: - return "userDisconnected" - case .wantsConnection(let forceFullSync): - return forceFullSync ? "wantsConnection(forceFullSync: true)" : "wantsConnection" + // Handle entering auto-reconnecting phase + await stateMachine.setAutoReconnectingHandler { [weak self] (deviceID: UUID, errorInfo: String) in + Task { @MainActor in + guard let self else { return } + + if self.shouldDeferOpportunisticReconnect { + self.logger.info( + "[BLE] Auto-reconnect entry suppressed for \(deviceID.uuidString.prefix(8)) (pairing in progress) — tearing down stale session" + ) + // Skip the reconnect-cycle claim and UI timeout (pairing's + // connect(to:) ceremony owns the next state transitions), + // but tear down the prior session so a pairing early-exit + // path doesn't strand the UI on stale `.ready` state. + await self.handleConnectionLoss(deviceID: deviceID, error: nil) + return } - } - - /// Whether the last connection was a simulator connection - public var wasSimulatorConnection: Bool { - lastConnectedDeviceID == MockDataProvider.simulatorDeviceID - } - - /// Whether a WiFi disconnection is currently being handled (prevents interleaving - /// across await suspension points before wifiReconnectTask is set). - var isHandlingWiFiDisconnection = false - - // MARK: - Cancellation Helpers - /// Cancels any in-progress WiFi reconnection attempts - func cancelWiFiReconnection() { - wifiReconnectTask?.cancel() - wifiReconnectTask = nil - wifiReconnectAttempt = 0 - } - - // MARK: - Initialization - - /// Creates a new connection manager. - /// - Parameters: - /// - modelContainer: The SwiftData model container for persistence - /// - stateMachine: Optional BLE state machine for testing. If nil, creates a real BLEStateMachine. - /// - transport: Optional iOS mesh transport for testing. If nil, creates an `iOSBLETransport` against the chosen state machine. - /// - pairing: Optional pairing service for testing. If nil, `DevicePairingFactory` selects the platform implementation. - public init( - modelContainer: ModelContainer, - defaults: UserDefaults = .standard, - stateMachine: (any BLEStateMachineProtocol)? = nil, - transport: (any iOSMeshTransport)? = nil, - pairing: (any DevicePairingService)? = nil - ) { - self.modelContainer = modelContainer - self.defaults = defaults - self.lastConnectionStore = LastConnectionStore(defaults: defaults) - self.connectionIntent = .restored(from: defaults) - - // Use provided state machine or create default - let bleStateMachine = stateMachine ?? BLEStateMachine() - self.stateMachine = bleStateMachine - - if let injected = transport { - self.transport = injected - } else if let concrete = bleStateMachine as? BLEStateMachine { - self.transport = iOSBLETransport(stateMachine: concrete) - } else { - // Test mode without an injected transport: create a dummy (unused when mocking BLE) - self.transport = iOSBLETransport(stateMachine: BLEStateMachine()) + // Snapshot pre-claim state before entering — handleEnteringAutoReconnect + // mutates connectionState to .connecting before its first await. + let initialState = String(describing: self.connectionState) + let transportName = switch self.currentTransportType { + case .bluetooth: "bluetooth" + case .wifi: "wifi" + case nil: "none" } - self.pairing = pairing ?? DevicePairingFactory.make() - - self.pairing.delegate = self - reconnectionCoordinator.delegate = self - - // Best-effort early wiring. activate() awaits the same idempotent - // method before constructing the CBCentralManager, which is the - // ordering guarantee; this Task just installs handlers promptly for - // flows that touch the state machine before activate() runs. - Task { await self.wireTransportHandlers() } + // Claim before the state-machine queries below. Without this, + // a state-restoration adoption where iOS callbacks land within + // microseconds of each other can fire onReconnection while these + // awaits are still queued, and the strict completion guard would + // drop the completion since reconnectingDeviceID is still nil. + await self.reconnectionCoordinator.handleEnteringAutoReconnect(deviceID: deviceID) + + let bleState = await self.stateMachine.centralManagerStateName + let blePhase = await self.stateMachine.currentPhaseName + let blePeripheralState = await self.stateMachine.currentPeripheralState ?? "none" + + self.persistDisconnectDiagnostic( + "source=bleStateMachine.autoReconnectingHandler, " + + "device=\(deviceID.uuidString.prefix(8)), " + + "transport=\(transportName), " + + "initialState=\(initialState), " + + "bleState=\(bleState), " + + "blePhase=\(blePhase), " + + "blePeripheralState=\(blePeripheralState), " + + "error=\(errorInfo), " + + "intent=\(self.connectionIntent)" + ) + } } - /// Wires the transport and state-machine lifecycle handlers. - /// - /// Must complete before anything constructs the CBCentralManager: - /// creating the central can synchronously fire state-restoration - /// callbacks, and a missed auto-reconnect or reconnection handler at - /// launch leaves the restored link without a session rebuild. Idempotent; - /// re-installing the same handlers is harmless. - func wireTransportHandlers() async { - let stateMachine = self.stateMachine - let transport = self.transport - - // Handle disconnection events - await transport.setDisconnectionHandler { [weak self] deviceID, error in - Task { @MainActor in - guard let self else { return } - await self.handleConnectionLoss(deviceID: deviceID, error: error) - } - } + // Handle iOS auto-reconnect completion + // Using transport.setReconnectionHandler ensures the transport captures + // the data stream internally before calling our handler + await transport.setReconnectionHandler { [weak self] deviceID in + Task { @MainActor in + guard let self else { return } + await self.reconnectionCoordinator.handleReconnectionComplete(deviceID: deviceID) + } + } - // Handle entering auto-reconnecting phase - await stateMachine.setAutoReconnectingHandler { [weak self] (deviceID: UUID, errorInfo: String) in - Task { @MainActor in - guard let self else { return } - - if self.shouldDeferOpportunisticReconnect { - self.logger.info( - "[BLE] Auto-reconnect entry suppressed for \(deviceID.uuidString.prefix(8)) (pairing in progress) — tearing down stale session" - ) - // Skip the reconnect-cycle claim and UI timeout (pairing's - // connect(to:) ceremony owns the next state transitions), - // but tear down the prior session so a pairing early-exit - // path doesn't strand the UI on stale `.ready` state. - await self.handleConnectionLoss(deviceID: deviceID, error: nil) - return - } - - // Snapshot pre-claim state before entering — handleEnteringAutoReconnect - // mutates connectionState to .connecting before its first await. - let initialState = String(describing: self.connectionState) - let transportName = switch self.currentTransportType { - case .bluetooth: "bluetooth" - case .wifi: "wifi" - case nil: "none" - } - - // Claim before the state-machine queries below. Without this, - // a state-restoration adoption where iOS callbacks land within - // microseconds of each other can fire onReconnection while these - // awaits are still queued, and the strict completion guard would - // drop the completion since reconnectingDeviceID is still nil. - await self.reconnectionCoordinator.handleEnteringAutoReconnect(deviceID: deviceID) - - let bleState = await self.stateMachine.centralManagerStateName - let blePhase = await self.stateMachine.currentPhaseName - let blePeripheralState = await self.stateMachine.currentPeripheralState ?? "none" - - self.persistDisconnectDiagnostic( - "source=bleStateMachine.autoReconnectingHandler, " + - "device=\(deviceID.uuidString.prefix(8)), " + - "transport=\(transportName), " + - "initialState=\(initialState), " + - "bleState=\(bleState), " + - "blePhase=\(blePhase), " + - "blePeripheralState=\(blePeripheralState), " + - "error=\(errorInfo), " + - "intent=\(self.connectionIntent)" - ) - } + // Handle Bluetooth power-cycle recovery + await stateMachine.setBluetoothPoweredOnHandler { [weak self] in + Task { @MainActor in + guard let self, + self.connectionIntent.wantsConnection, + self.connectionState == .disconnected, + let deviceID = self.lastConnectedDeviceID else { return } + + if self.shouldDeferOpportunisticReconnect { + self.logger.info("[BLE] Bluetooth powered on: standing down (pairing in progress)") + return } - // Handle iOS auto-reconnect completion - // Using transport.setReconnectionHandler ensures the transport captures - // the data stream internally before calling our handler - await transport.setReconnectionHandler { [weak self] deviceID in - Task { @MainActor in - guard let self else { return } - await self.reconnectionCoordinator.handleReconnectionComplete(deviceID: deviceID) - } + let blePhase = await self.stateMachine.currentPhaseName + let bleConnectedDeviceID = await self.stateMachine.connectedDeviceID + if blePhase != "idle" || bleConnectedDeviceID == deviceID { + self.logger.info( + "[BLE] Bluetooth powered on: BLE already owns reconnect flow for \(deviceID.uuidString.prefix(8)) " + + "(phase: \(blePhase), bleConnectedDevice: \(bleConnectedDeviceID?.uuidString.prefix(8) ?? "none"))" + ) + return } - // Handle Bluetooth power-cycle recovery - await stateMachine.setBluetoothPoweredOnHandler { [weak self] in - Task { @MainActor in - guard let self, - self.connectionIntent.wantsConnection, - self.connectionState == .disconnected, - let deviceID = self.lastConnectedDeviceID else { return } - - if self.shouldDeferOpportunisticReconnect { - self.logger.info("[BLE] Bluetooth powered on: standing down (pairing in progress)") - return - } - - let blePhase = await self.stateMachine.currentPhaseName - let bleConnectedDeviceID = await self.stateMachine.connectedDeviceID - if blePhase != "idle" || bleConnectedDeviceID == deviceID { - self.logger.info( - "[BLE] Bluetooth powered on: BLE already owns reconnect flow for \(deviceID.uuidString.prefix(8)) " + - "(phase: \(blePhase), bleConnectedDevice: \(bleConnectedDeviceID?.uuidString.prefix(8) ?? "none"))" - ) - return - } - - if self.activeReconnectDeviceID == deviceID { - self.logger.info("[BLE] Bluetooth powered on: reconnect/session rebuild already in progress for \(deviceID.uuidString.prefix(8))") - return - } - - self.logger.info("[BLE] Bluetooth powered on: attempting reconnection to \(deviceID.uuidString.prefix(8))") - await self.attemptOpportunisticReconnect(deviceID: deviceID, reason: "Bluetooth powered on") - } + if self.activeReconnectDeviceID == deviceID { + self.logger.info("[BLE] Bluetooth powered on: reconnect/session rebuild already in progress for \(deviceID.uuidString.prefix(8))") + return } - // Handle Bluetooth state changes for diagnostics - await stateMachine.setBluetoothStateChangeHandler { [weak self] state in - Task { @MainActor in - guard let self else { return } - self.handleBluetoothStateChange(state) - } - } + self.logger.info("[BLE] Bluetooth powered on: attempting reconnection to \(deviceID.uuidString.prefix(8))") + await self.attemptOpportunisticReconnect(deviceID: deviceID, reason: "Bluetooth powered on") + } } - // MARK: - Session Helpers - - /// Starts a session and queries device capabilities. - func initializeSession( - _ session: MeshCoreSession - ) async throws -> (SelfInfo, DeviceCapabilities) { - do { - try await withTimeout(.seconds(10), operationName: "session.start") { - try await session.start() - } - } catch { - logger.warning("[BLE] session.start() timed out or failed: \(error.localizedDescription)") - throw error - } - - guard let selfInfo = await session.currentSelfInfo else { - logger.warning("[BLE] selfInfo is nil after session.start()") - throw ConnectionError.initializationFailed("Failed to get device self info") - } - do { - let capabilities = try await withTimeout(.seconds(10), operationName: "queryDevice") { - try await session.queryDevice() - } - return (selfInfo, capabilities) - } catch { - logger.warning("[BLE] queryDevice() timed out or failed: \(error.localizedDescription)") - throw error - } + // Handle Bluetooth state changes for diagnostics + await stateMachine.setBluetoothStateChangeHandler { [weak self] state in + Task { @MainActor in + guard let self else { return } + self.handleBluetoothStateChange(state) + } } - - // MARK: - Service Wiring Helpers - - /// Wires the clean-channel-sync callback on a new ServiceContainer so that - /// `lastCleanChannelSync` is updated when a channel phase completes without errors. - /// Called from every path that creates a new ServiceContainer. - func wireCleanChannelSyncCallback(on services: ServiceContainer) async { - await services.syncCoordinator.setCleanChannelSyncCallback { [weak self] radioID in - await MainActor.run { - self?.lastCleanChannelSync = (radioID: radioID, completedAt: Date()) - } - } - await services.syncCoordinator.setChannelSyncAttemptedCallback { [weak self] radioID in - await MainActor.run { - self?.lastAttemptedChannelSync = (radioID: radioID, attemptedAt: Date()) - } - } + } + + // MARK: - Session Helpers + + /// Starts a session and queries device capabilities. + func initializeSession( + _ session: MeshCoreSession + ) async throws -> (SelfInfo, DeviceCapabilities) { + do { + try await withTimeout(.seconds(10), operationName: "session.start") { + try await session.start() + } + } catch { + logger.warning("[BLE] session.start() timed out or failed: \(error.localizedDescription)") + throw error } - // MARK: - Connection Ceremony - - /// Builds a fresh ServiceContainer, fetches device configuration from the radio - /// and database, builds and persists the device record, and updates self. - /// - /// Each connection path (BLE, WiFi, reconnect, device switch) calls this after - /// its transport and session are established. Post-ceremony work (sync, promote, - /// cleanup) remains in each caller since it genuinely varies. - /// - /// - Returns: The wired `ServiceContainer` for the caller's sync phase. - func buildServicesAndSaveDevice( - deviceID: UUID, - session: MeshCoreSession, - selfInfo: SelfInfo, - capabilities: DeviceCapabilities, - connectionMethods: [ConnectionMethod] = [] - ) async throws -> (services: ServiceContainer, radioID: UUID) { - // Kick off `getAutoAddConfig` up-front so the BLE roundtrip overlaps - // with the local DB fetches and container wiring below. - async let autoAddConfigResult = session.getAutoAddConfig() - - // Resolve the radio before constructing the container so the - // chat send queue can scope its `PendingSend` hydration to the - // right radio from frame zero. Falls back to publicKey lookup - // (backup import) and finally to a fresh UUID for first-time - // pairings. - let standaloneStore = PersistenceStore(modelContainer: modelContainer) - let existingDevice = try? await standaloneStore.fetchDevice(id: deviceID) - let deviceByPublicKey: DeviceDTO? - if existingDevice == nil { - deviceByPublicKey = try? await standaloneStore.fetchDevice(publicKey: selfInfo.publicKey) - } else { - deviceByPublicKey = nil - } - let effectiveExisting = existingDevice ?? deviceByPublicKey - let resolvedRadioID = effectiveExisting?.radioID ?? UUID() - - let newServices = ServiceContainer( - session: session, - modelContainer: modelContainer, - radioID: resolvedRadioID, - appStateProvider: appStateProvider, - connectionStateEvents: connectionStateEvents, - initialConnectionState: connectionState - ) - await wireCleanChannelSyncCallback(on: newServices) - await newServices.nodeConfigService.setOnPostIdentityImport { [weak self, weak newServices] in - guard let self, let services = newServices else { return nil } - return try await self.reconcileIdentity(expectedServices: services, deviceID: deviceID) - } - - let autoAddConfig = (try? await autoAddConfigResult) ?? MeshCore.AutoAddConfig(bitmask: 0) - - let repeatFreqRanges: [MeshCore.FrequencyRange] = capabilities.clientRepeat - ? (try? await session.getRepeatFreq()) ?? [] - : [] - - let device = createDevice( - deviceID: deviceID, - radioID: resolvedRadioID, - selfInfo: selfInfo, - capabilities: capabilities, - autoAddConfig: autoAddConfig, - existingDevice: effectiveExisting, - connectionMethods: connectionMethods - ) - - let deviceDTO = DeviceDTO(from: device) - // Persist before warmUp so purgeOrphanPendingSends sees the in-progress - // radio's Device row and does not classify its PendingSends as orphans. - try await newServices.dataStore.saveDevice(deviceDTO) - - // Clean up orphaned Device row from backup import - if let oldDevice = deviceByPublicKey, oldDevice.id != deviceID { - try? await newServices.dataStore.deleteDevice(id: oldDevice.id) - } - - // Run startup-time DB hygiene before hydrating the send queue. - // warmUp's inner operations (purgeOrphanPendingSends and - // purgeLegacyAttemptCountRows) are best-effort; failure is non-fatal. - do { - try await newServices.warmUp() - } catch { - logger.warning("ServiceContainer.warmUp failed: \(error.localizedDescription)") - } - - // Hydrate the chat send queue before exposing the container so - // view-model `configure` calls see a hydrated service from the - // first read. - await newServices.chatSendQueueService.hydrate() - - // Restore `self.session` together with `self.services`: an interleaved - // `handleConnectionLoss` nils both atomically, and `promoteToReady`'s - // state invariants require both. - self.session = session - self.services = newServices - - self.connectedDevice = deviceDTO - self.allowedRepeatFreqRanges = repeatFreqRanges - - return (newServices, deviceDTO.radioID) + guard let selfInfo = await session.currentSelfInfo else { + logger.warning("[BLE] selfInfo is nil after session.start()") + throw ConnectionError.initializationFailed("Failed to get device self info") } - - // MARK: - Ready Promotion - - /// Promotes connection to `.ready` if the connection is still alive and owned by the expected services. - /// Skips post-sync work (time sync, onDeviceSynced) when sync failed to avoid BLE pressure. - /// Returns `true` if `.ready` was set, `false` if promotion was suppressed. - /// - /// - Parameter additionalGuard: Caller-specific invariant checked at every guard point, - /// including after async operations like `syncDeviceTimeIfNeeded()`. This cannot be an - /// inline check at the call site because the invariant must hold both before and after - /// the internal awaits — a competing reconnect cycle could start during time sync, - /// and promoting a stale session to `.ready` would shadow the new one. - /// Currently only `rebuildSession` uses this (reconnect-generation check). - @discardableResult - func promoteToReady( - syncSucceeded: Bool, - expectedServices: ServiceContainer, - transportType: TransportType, - additionalGuard: (() -> Bool)? = nil - ) async -> Bool { - guard connectionIntent.wantsConnection else { - logger.warning("Promotion suppressed: user disconnected") - return false - } - guard self.services === expectedServices else { - logger.warning("Promotion suppressed: services replaced or nil") - return false - } - guard additionalGuard?() ?? true else { - logger.warning("Promotion suppressed: caller guard failed (e.g. reconnect generation)") - return false - } - - currentTransportType = transportType - connectionState = syncSucceeded ? .ready : .syncing - - // Skip time sync on BLE failure to avoid pressure on a saturated link. - // WiFi/TCP has no such constraint, so always correct the clock there. - if syncSucceeded || transportType == .wifi { - await syncDeviceTimeIfNeeded() - guard connectionIntent.wantsConnection else { - logger.warning("Promotion suppressed after time sync: user disconnected") - return false - } - guard self.services === expectedServices else { - logger.warning("Promotion suppressed after time sync: services replaced or nil") - return false - } - guard additionalGuard?() ?? true else { - logger.warning("Promotion suppressed after time sync: caller guard failed (e.g. reconnect generation)") - return false - } - } - - if syncSucceeded { await onDeviceSynced?() } - return true + do { + let capabilities = try await withTimeout(.seconds(10), operationName: "queryDevice") { + try await session.queryDevice() + } + return (selfInfo, capabilities) + } catch { + logger.warning("[BLE] queryDevice() timed out or failed: \(error.localizedDescription)") + throw error + } + } + + // MARK: - Service Wiring Helpers + + /// Wires the clean-channel-sync callback on a new ServiceContainer so that + /// `lastCleanChannelSync` is updated when a channel phase completes without errors. + /// Called from every path that creates a new ServiceContainer. + func wireCleanChannelSyncCallback(on services: ServiceContainer) async { + await services.syncCoordinator.setCleanChannelSyncCallback { [weak self] radioID in + await MainActor.run { + self?.lastCleanChannelSync = (radioID: radioID, completedAt: Date()) + } + } + await services.syncCoordinator.setChannelSyncAttemptedCallback { [weak self] radioID in + await MainActor.run { + self?.lastAttemptedChannelSync = (radioID: radioID, attemptedAt: Date()) + } + } + } + + // MARK: - Connection Ceremony + + /// Builds a fresh ServiceContainer, fetches device configuration from the radio + /// and database, builds and persists the device record, and updates self. + /// + /// Each connection path (BLE, WiFi, reconnect, device switch) calls this after + /// its transport and session are established. Post-ceremony work (sync, promote, + /// cleanup) remains in each caller since it genuinely varies. + /// + /// - Returns: The wired `ServiceContainer` for the caller's sync phase. + func buildServicesAndSaveDevice( + deviceID: UUID, + session: MeshCoreSession, + selfInfo: SelfInfo, + capabilities: DeviceCapabilities, + connectionMethods: [ConnectionMethod] = [] + ) async throws -> (services: ServiceContainer, radioID: UUID) { + // Kick off `getAutoAddConfig` up-front so the BLE roundtrip overlaps + // with the local DB fetches and container wiring below. + async let autoAddConfigResult = session.getAutoAddConfig() + + // Resolve the radio before constructing the container so the + // chat send queue can scope its `PendingSend` hydration to the + // right radio from frame zero. Falls back to publicKey lookup + // (backup import) and finally to a fresh UUID for first-time + // pairings. + let standaloneStore = PersistenceStore(modelContainer: modelContainer) + let existingDevice = try? await standaloneStore.fetchDevice(id: deviceID) + let deviceByPublicKey: DeviceDTO? = if existingDevice == nil { + try? await standaloneStore.fetchDevice(publicKey: selfInfo.publicKey) + } else { + nil + } + let effectiveExisting = existingDevice ?? deviceByPublicKey + let resolvedRadioID = effectiveExisting?.radioID ?? UUID() + + let newServices = ServiceContainer( + session: session, + modelContainer: modelContainer, + radioID: resolvedRadioID, + appStateProvider: appStateProvider, + connectionStateEvents: connectionStateEvents, + initialConnectionState: connectionState + ) + await wireCleanChannelSyncCallback(on: newServices) + await newServices.nodeConfigService.setOnPostIdentityImport { [weak self, weak newServices] in + guard let self, let services = newServices else { return nil } + return try await reconcileIdentity(expectedServices: services, deviceID: deviceID) } - /// Re-evaluates the connected device's identity after `NodeConfigService.importIdentity` - /// has restored a privateKey on the radio. If the radio is now reporting a different - /// `publicKey` than the local Device row, attempts to reconcile against any ghost - /// carrying that publicKey (left by a prior "remove from MC1"). - /// - /// Mirrors the `promoteToReady` lifecycle pattern: takes the `ServiceContainer` - /// the caller expects to still own, plus the `deviceID` it expects to still be - /// connected to. Bails out (returns `nil`) if either invariant fails before or - /// after the `currentSelfInfo` await — a competing reconnect cycle could otherwise - /// reconcile state onto the wrong connection. - /// - /// - Parameters: - /// - expectedServices: The `ServiceContainer` captured at the start of the - /// config-import operation. If `self.services` no longer points to it, the - /// reconcile is silently skipped. - /// - deviceID: The BLE peripheral UUID the import was started against. - /// - Returns: The new `radioID` if reconciliation reassigned the device, - /// otherwise `nil` (no publicKey change, no matching ghost, or a guard tripped). - @discardableResult - public func reconcileIdentity( - expectedServices: ServiceContainer, - deviceID: UUID - ) async throws -> UUID? { - guard self.services === expectedServices else { - logger.info("reconcileIdentity skipped: services replaced before currentSelfInfo") - return nil - } - guard let preDevice = self.connectedDevice, preDevice.id == deviceID else { - logger.info("reconcileIdentity skipped: connectedDevice changed before currentSelfInfo") - return nil - } - - let selfInfo: MeshCore.SelfInfo - do { - guard let info = await expectedServices.session.currentSelfInfo else { - logger.warning("reconcileIdentity failed: session.currentSelfInfo is nil") - return nil - } - selfInfo = info - } - - guard self.services === expectedServices else { - logger.info("reconcileIdentity skipped: services replaced after currentSelfInfo") - return nil - } - guard self.connectedDevice?.id == deviceID else { - logger.info("reconcileIdentity skipped: connectedDevice changed after currentSelfInfo") - return nil - } - - // No publicKey-equality short-circuit: after a partial-import + app restart, - // `buildServicesAndSaveDevice` finds the Device by BLE UUID and overwrites - // `Device.publicKey` with the restored key via `Device.apply(dto:)`. On the - // user's retry, `selfInfo.publicKey == connectedDevice.publicKey` even though - // `radioID` is still stale. Always ask `reconcileGhostIdentity` — its - // predicate handles the no-ghost case by returning nil, so the cost of an - // unconditional query is one cheap DB lookup per import. - - let newRadioID: UUID? - do { - newRadioID = try await expectedServices.dataStore.reconcileGhostIdentity( - currentDeviceID: deviceID, - newPublicKey: selfInfo.publicKey - ) - } catch { - logger.warning("reconcileIdentity: reconcileGhostIdentity failed: \(error.localizedDescription)") - return nil - } + let autoAddConfig = await (try? autoAddConfigResult) ?? MeshCore.AutoAddConfig(bitmask: 0) + + let repeatFreqRanges: [MeshCore.FrequencyRange] = await capabilities.clientRepeat + ? (try? session.getRepeatFreq()) ?? [] + : [] + + let device = createDevice( + deviceID: deviceID, + radioID: resolvedRadioID, + selfInfo: selfInfo, + capabilities: capabilities, + autoAddConfig: autoAddConfig, + existingDevice: effectiveExisting, + connectionMethods: connectionMethods + ) + + let deviceDTO = DeviceDTO(from: device) + // Persist before warmUp so purgeOrphanPendingSends sees the in-progress + // radio's Device row and does not classify its PendingSends as orphans. + try await newServices.dataStore.saveDevice(deviceDTO) + + // Clean up orphaned Device row from backup import + if let oldDevice = deviceByPublicKey, oldDevice.id != deviceID { + try? await newServices.dataStore.deleteDevice(id: oldDevice.id) + } - guard let newRadioID else { - logger.info("reconcileIdentity: publicKey changed but no ghost matched") - return nil - } + // Run startup-time DB hygiene before hydrating the send queue. + // warmUp's inner operations (purgeOrphanPendingSends and + // purgeLegacyAttemptCountRows) are best-effort; failure is non-fatal. + do { + try await newServices.warmUp() + } catch { + logger.warning("ServiceContainer.warmUp failed: \(error.localizedDescription)") + } - // Final guard: the DB save just ran on a background actor; the connection - // could still have churned. Only mutate live state if the captured container - // is still authoritative. - guard self.services === expectedServices, self.connectedDevice?.id == deviceID else { - logger.info("reconcileIdentity: state churned after DB save; skipping in-memory refresh") - return newRadioID - } - if let refreshed = try? await expectedServices.dataStore.fetchDevice(id: deviceID), - self.services === expectedServices, - self.connectedDevice?.id == deviceID { - self.connectedDevice = refreshed - persistConnection( - deviceID: refreshed.id, - radioID: refreshed.radioID, - deviceName: refreshed.nodeName - ) - } + // Hydrate the chat send queue before exposing the container so + // view-model `configure` calls see a hydrated service from the + // first read. + await newServices.chatSendQueueService.hydrate() + + // Restore `self.session` together with `self.services`: an interleaved + // `handleConnectionLoss` nils both atomically, and `promoteToReady`'s + // state invariants require both. + self.session = session + services = newServices + + connectedDevice = deviceDTO + allowedRepeatFreqRanges = repeatFreqRanges + + return (newServices, deviceDTO.radioID) + } + + // MARK: - Ready Promotion + + /// Promotes connection to `.ready` if the connection is still alive and owned by the expected services. + /// Skips post-sync work (time sync, onDeviceSynced) when sync failed to avoid BLE pressure. + /// Returns `true` if `.ready` was set, `false` if promotion was suppressed. + /// + /// - Parameter additionalGuard: Caller-specific invariant checked at every guard point, + /// including after async operations like `syncDeviceTimeIfNeeded()`. This cannot be an + /// inline check at the call site because the invariant must hold both before and after + /// the internal awaits — a competing reconnect cycle could start during time sync, + /// and promoting a stale session to `.ready` would shadow the new one. + /// Currently only `rebuildSession` uses this (reconnect-generation check). + @discardableResult + func promoteToReady( + syncSucceeded: Bool, + expectedServices: ServiceContainer, + transportType: TransportType, + additionalGuard: (() -> Bool)? = nil + ) async -> Bool { + guard connectionIntent.wantsConnection else { + logger.warning("Promotion suppressed: user disconnected") + return false + } + guard services === expectedServices else { + logger.warning("Promotion suppressed: services replaced or nil") + return false + } + guard additionalGuard?() ?? true else { + logger.warning("Promotion suppressed: caller guard failed (e.g. reconnect generation)") + return false + } - logger.info("Reconciled identity to ghost radioID after key import: \(newRadioID)") - return newRadioID + currentTransportType = transportType + connectionState = syncSucceeded ? .ready : .syncing + + // Skip time sync on BLE failure to avoid pressure on a saturated link. + // WiFi/TCP has no such constraint, so always correct the clock there. + if syncSucceeded || transportType == .wifi { + await syncDeviceTimeIfNeeded() + guard connectionIntent.wantsConnection else { + logger.warning("Promotion suppressed after time sync: user disconnected") + return false + } + guard services === expectedServices else { + logger.warning("Promotion suppressed after time sync: services replaced or nil") + return false + } + guard additionalGuard?() ?? true else { + logger.warning("Promotion suppressed after time sync: caller guard failed (e.g. reconnect generation)") + return false + } } - /// Syncs the device clock if it drifts more than 60 seconds from the phone. - /// Safe to call after sync — only affects future device-originated timestamps. - func syncDeviceTimeIfNeeded() async { - guard let session else { return } - do { - let deviceTime = try await withTimeout(.seconds(5), operationName: "getTime") { - try await session.getTime() - } - let timeDifference = abs(deviceTime.timeIntervalSinceNow) - if timeDifference > 60 { - try await withTimeout(.seconds(5), operationName: "setTime") { - try await session.setTime(Date()) - } - logger.info("Synced device time (was off by \(Int(timeDifference))s)") - } else { - logger.info("Device time in sync (drift: \(Int(timeDifference))s)") - } - } catch { - logger.warning("Failed to sync device time: \(error.localizedDescription)") - } + if syncSucceeded { await onDeviceSynced?() } + return true + } + + /// Re-evaluates the connected device's identity after `NodeConfigService.importIdentity` + /// has restored a privateKey on the radio. If the radio is now reporting a different + /// `publicKey` than the local Device row, attempts to reconcile against any ghost + /// carrying that publicKey (left by a prior "remove from MC1"). + /// + /// Mirrors the `promoteToReady` lifecycle pattern: takes the `ServiceContainer` + /// the caller expects to still own, plus the `deviceID` it expects to still be + /// connected to. Bails out (returns `nil`) if either invariant fails before or + /// after the `currentSelfInfo` await — a competing reconnect cycle could otherwise + /// reconcile state onto the wrong connection. + /// + /// - Parameters: + /// - expectedServices: The `ServiceContainer` captured at the start of the + /// config-import operation. If `self.services` no longer points to it, the + /// reconcile is silently skipped. + /// - deviceID: The BLE peripheral UUID the import was started against. + /// - Returns: The new `radioID` if reconciliation reassigned the device, + /// otherwise `nil` (no publicKey change, no matching ghost, or a guard tripped). + @discardableResult + public func reconcileIdentity( + expectedServices: ServiceContainer, + deviceID: UUID + ) async throws -> UUID? { + guard services === expectedServices else { + logger.info("reconcileIdentity skipped: services replaced before currentSelfInfo") + return nil + } + guard let preDevice = connectedDevice, preDevice.id == deviceID else { + logger.info("reconcileIdentity skipped: connectedDevice changed before currentSelfInfo") + return nil } - /// Creates a Device from MeshCore types - /// - /// `radioID` is the resolved partition UUID for this device. Callers in - /// the connect path must pass the same UUID they used to construct - /// `ServiceContainer(radioID:)` so the chat send queue's `PendingSend` - /// scope and the persisted `Device.radioID` cannot diverge on first pair. - /// A bare `?? UUID()` fallback here would mint a second UUID that the - /// container would never see. - func createDevice( - deviceID: UUID, - radioID: UUID, - selfInfo: MeshCore.SelfInfo, - capabilities: MeshCore.DeviceCapabilities, - autoAddConfig: MeshCore.AutoAddConfig, - existingDevice: DeviceDTO? = nil, - connectionMethods: [ConnectionMethod] = [] - ) -> Device { - // Merge new connection methods with existing ones, replacing by transport type - var mergedMethods = existingDevice?.connectionMethods ?? [] - for method in connectionMethods { - if method.isWiFi { - mergedMethods.removeAll { $0.isWiFi } - } else if method.isBluetooth { - mergedMethods.removeAll { $0.isBluetooth } - } - mergedMethods.append(method) - } + let selfInfo: MeshCore.SelfInfo + do { + guard let info = await expectedServices.session.currentSelfInfo else { + logger.warning("reconcileIdentity failed: session.currentSelfInfo is nil") + return nil + } + selfInfo = info + } - let device = Device( - id: deviceID, - radioID: radioID, - publicKey: selfInfo.publicKey, - nodeName: selfInfo.name, - firmwareVersion: capabilities.firmwareVersion, - firmwareVersionString: capabilities.version, - manufacturerName: capabilities.model, - buildDate: capabilities.firmwareBuild, - maxContacts: UInt16(capabilities.maxContacts), - maxChannels: UInt8(min(capabilities.maxChannels, 255)), - frequency: UInt32(selfInfo.radioFrequency * 1000), // Convert MHz to kHz - bandwidth: UInt32(selfInfo.radioBandwidth * 1000), // Convert kHz to Hz - spreadingFactor: selfInfo.radioSpreadingFactor, - codingRate: selfInfo.radioCodingRate, - txPower: selfInfo.txPower, - maxTxPower: selfInfo.maxTxPower, - latitude: selfInfo.latitude, - longitude: selfInfo.longitude, - blePin: capabilities.blePin, - clientRepeat: capabilities.clientRepeat, - pathHashMode: capabilities.pathHashMode, - defaultFloodScopeName: existingDevice?.defaultFloodScopeName, - preRepeatFrequency: existingDevice?.preRepeatFrequency, - preRepeatBandwidth: existingDevice?.preRepeatBandwidth, - preRepeatSpreadingFactor: existingDevice?.preRepeatSpreadingFactor, - preRepeatCodingRate: existingDevice?.preRepeatCodingRate, - manualAddContacts: selfInfo.manualAddContacts, - autoAddConfig: autoAddConfig.bitmask, - autoAddMaxHops: autoAddConfig.maxHops, - multiAcks: selfInfo.multiAcks, - telemetryModeBase: selfInfo.telemetryModeBase, - telemetryModeLoc: selfInfo.telemetryModeLocation, - telemetryModeEnv: selfInfo.telemetryModeEnvironment, - advertLocationPolicy: selfInfo.advertisementLocationPolicy, - lastConnected: Date(), - lastContactSync: existingDevice?.lastContactSync ?? 0, - isActive: true, - ocvPreset: existingDevice?.ocvPreset - ?? OCVPreset.preset(forManufacturer: capabilities.model)?.rawValue, - customOCVArrayString: existingDevice?.customOCVArrayString, - connectionMethods: mergedMethods, - knownRegions: existingDevice?.knownRegions ?? [] - ) + guard services === expectedServices else { + logger.info("reconcileIdentity skipped: services replaced after currentSelfInfo") + return nil + } + guard connectedDevice?.id == deviceID else { + logger.info("reconcileIdentity skipped: connectedDevice changed after currentSelfInfo") + return nil + } - // If repeat mode was disabled externally, clear orphaned pre-repeat settings - if !capabilities.clientRepeat && existingDevice?.preRepeatFrequency != nil { - device.preRepeatFrequency = nil - device.preRepeatBandwidth = nil - device.preRepeatSpreadingFactor = nil - device.preRepeatCodingRate = nil - } + // No publicKey-equality short-circuit: after a partial-import + app restart, + // `buildServicesAndSaveDevice` finds the Device by BLE UUID and overwrites + // `Device.publicKey` with the restored key via `Device.apply(dto:)`. On the + // user's retry, `selfInfo.publicKey == connectedDevice.publicKey` even though + // `radioID` is still stale. Always ask `reconcileGhostIdentity` — its + // predicate handles the no-ghost case by returning nil, so the cost of an + // unconditional query is one cheap DB lookup per import. + + let newRadioID: UUID? + do { + newRadioID = try await expectedServices.dataStore.reconcileGhostIdentity( + currentDeviceID: deviceID, + newPublicKey: selfInfo.publicKey + ) + } catch { + logger.warning("reconcileIdentity: reconcileGhostIdentity failed: \(error.localizedDescription)") + return nil + } - return device + guard let newRadioID else { + logger.info("reconcileIdentity: publicKey changed but no ghost matched") + return nil } - /// Configures BLE write pacing based on detected device platform. - /// - Parameter capabilities: The device capabilities from queryDevice() - func configureBLEPacing(for capabilities: MeshCore.DeviceCapabilities) async { - detectAndStorePlatform(model: capabilities.model, transportType: .bluetooth) - let pacing = detectedPlatform.recommendedWritePacing - await stateMachine.setWritePacingDelay(pacing) - if pacing > 0 { - logger.info("[BLE] Platform detected: \(capabilities.model) -> \(detectedPlatform), write pacing: \(pacing)s") - } + // Final guard: the DB save just ran on a background actor; the connection + // could still have churned. Only mutate live state if the captured container + // is still authoritative. + guard services === expectedServices, connectedDevice?.id == deviceID else { + logger.info("reconcileIdentity: state churned after DB save; skipping in-memory refresh") + return newRadioID + } + if let refreshed = try? await expectedServices.dataStore.fetchDevice(id: deviceID), + services === expectedServices, + connectedDevice?.id == deviceID { + connectedDevice = refreshed + persistConnection( + deviceID: refreshed.id, + radioID: refreshed.radioID, + deviceName: refreshed.nodeName + ) } - /// Detects and stores the device platform from its model string, used for - /// channel-sync throttling and (over BLE) write pacing. - /// - /// A WiFi radio is always ESP32-class — no nRF52 part ships a WiFi radio, and the - /// WiFi companion firmware is ESP32-only — so an unrecognized model on WiFi resolves - /// to `.esp32` rather than `.unknown`. Without this, WiFi radios would keep the - /// no-skip `.unknown` config and re-read all channels on every sync. BLE keeps the - /// conservative `.unknown` fallback, which drives its ESP32-safe write pacing. - /// - /// Lives here rather than in the BLE/WiFi extensions because `detectedPlatform` has a - /// `private(set)` setter that only this file can write. - func detectAndStorePlatform(model: String, transportType: TransportType) { - var platform = DevicePlatform.detect(from: model) - if transportType == .wifi, platform == .unknown { - platform = .esp32 - } - detectedPlatform = platform - if transportType == .wifi { - logger.info("[WiFi] Platform detected: \(model) -> \(platform)") + logger.info("Reconciled identity to ghost radioID after key import: \(newRadioID)") + return newRadioID + } + + /// Syncs the device clock if it drifts more than 60 seconds from the phone. + /// Safe to call after sync — only affects future device-originated timestamps. + func syncDeviceTimeIfNeeded() async { + guard let session else { return } + do { + let deviceTime = try await withTimeout(.seconds(5), operationName: "getTime") { + try await session.getTime() + } + let timeDifference = abs(deviceTime.timeIntervalSinceNow) + if timeDifference > 60 { + try await withTimeout(.seconds(5), operationName: "setTime") { + try await session.setTime(Date()) } + logger.info("Synced device time (was off by \(Int(timeDifference))s)") + } else { + logger.info("Device time in sync (drift: \(Int(timeDifference))s)") + } + } catch { + logger.warning("Failed to sync device time: \(error.localizedDescription)") } - - // MARK: - Cleanup - - /// Cleans up session and services without changing connection state (used during retries) - func cleanupResources() async { - await session?.stop() - await services?.tearDown() - session = nil - services = nil + } + + /// Creates a Device from MeshCore types + /// + /// `radioID` is the resolved partition UUID for this device. Callers in + /// the connect path must pass the same UUID they used to construct + /// `ServiceContainer(radioID:)` so the chat send queue's `PendingSend` + /// scope and the persisted `Device.radioID` cannot diverge on first pair. + /// A bare `?? UUID()` fallback here would mint a second UUID that the + /// container would never see. + func createDevice( + deviceID: UUID, + radioID: UUID, + selfInfo: MeshCore.SelfInfo, + capabilities: MeshCore.DeviceCapabilities, + autoAddConfig: MeshCore.AutoAddConfig, + existingDevice: DeviceDTO? = nil, + connectionMethods: [ConnectionMethod] = [] + ) -> Device { + // Merge new connection methods with existing ones, replacing by transport type + var mergedMethods = existingDevice?.connectionMethods ?? [] + for method in connectionMethods { + if method.isWiFi { + mergedMethods.removeAll { $0.isWiFi } + } else if method.isBluetooth { + mergedMethods.removeAll { $0.isBluetooth } + } + mergedMethods.append(method) } - /// Full cleanup including state reset (used on explicit disconnect) - func cleanupConnection() async { - logger.info("[BLE] cleanupConnection: state → .disconnected") - connectionState = .disconnected - connectingDeviceID = nil - connectedDevice = nil - allowedRepeatFreqRanges = [] - await cleanupResources() + let device = Device( + id: deviceID, + radioID: radioID, + publicKey: selfInfo.publicKey, + nodeName: selfInfo.name, + firmwareVersion: capabilities.firmwareVersion, + firmwareVersionString: capabilities.version, + manufacturerName: capabilities.model, + buildDate: capabilities.firmwareBuild, + maxContacts: UInt16(capabilities.maxContacts), + maxChannels: UInt8(min(capabilities.maxChannels, 255)), + frequency: UInt32(selfInfo.radioFrequency * 1000), // Convert MHz to kHz + bandwidth: UInt32(selfInfo.radioBandwidth * 1000), // Convert kHz to Hz + spreadingFactor: selfInfo.radioSpreadingFactor, + codingRate: selfInfo.radioCodingRate, + txPower: selfInfo.txPower, + maxTxPower: selfInfo.maxTxPower, + latitude: selfInfo.latitude, + longitude: selfInfo.longitude, + blePin: capabilities.blePin, + clientRepeat: capabilities.clientRepeat, + pathHashMode: capabilities.pathHashMode, + defaultFloodScopeName: existingDevice?.defaultFloodScopeName, + preRepeatFrequency: existingDevice?.preRepeatFrequency, + preRepeatBandwidth: existingDevice?.preRepeatBandwidth, + preRepeatSpreadingFactor: existingDevice?.preRepeatSpreadingFactor, + preRepeatCodingRate: existingDevice?.preRepeatCodingRate, + manualAddContacts: selfInfo.manualAddContacts, + autoAddConfig: autoAddConfig.bitmask, + autoAddMaxHops: autoAddConfig.maxHops, + multiAcks: selfInfo.multiAcks, + telemetryModeBase: selfInfo.telemetryModeBase, + telemetryModeLoc: selfInfo.telemetryModeLocation, + telemetryModeEnv: selfInfo.telemetryModeEnvironment, + advertLocationPolicy: selfInfo.advertisementLocationPolicy, + lastConnected: Date(), + lastContactSync: existingDevice?.lastContactSync ?? 0, + isActive: true, + ocvPreset: existingDevice?.ocvPreset + ?? OCVPreset.preset(forManufacturer: capabilities.model)?.rawValue, + customOCVArrayString: existingDevice?.customOCVArrayString, + connectionMethods: mergedMethods, + knownRegions: existingDevice?.knownRegions ?? [] + ) + + // If repeat mode was disabled externally, clear orphaned pre-repeat settings + if !capabilities.clientRepeat, existingDevice?.preRepeatFrequency != nil { + device.preRepeatFrequency = nil + device.preRepeatBandwidth = nil + device.preRepeatSpreadingFactor = nil + device.preRepeatCodingRate = nil } - func persistDisconnectDiagnostic(_ summary: String) { - lastConnectionStore.persistDisconnectDiagnostic(summary) + return device + } + + /// Configures BLE write pacing based on detected device platform. + /// - Parameter capabilities: The device capabilities from queryDevice() + func configureBLEPacing(for capabilities: MeshCore.DeviceCapabilities) async { + detectAndStorePlatform(model: capabilities.model, transportType: .bluetooth) + let pacing = detectedPlatform.recommendedWritePacing + await stateMachine.setWritePacingDelay(pacing) + if pacing > 0 { + logger.info("[BLE] Platform detected: \(capabilities.model) -> \(detectedPlatform), write pacing: \(pacing)s") } - - func persistIntent() { - connectionIntent.persist(to: defaults) + } + + /// Detects and stores the device platform from its model string, used for + /// channel-sync throttling and (over BLE) write pacing. + /// + /// A WiFi radio is always ESP32-class — no nRF52 part ships a WiFi radio, and the + /// WiFi companion firmware is ESP32-only — so an unrecognized model on WiFi resolves + /// to `.esp32` rather than `.unknown`. Without this, WiFi radios would keep the + /// no-skip `.unknown` config and re-read all channels on every sync. BLE keeps the + /// conservative `.unknown` fallback, which drives its ESP32-safe write pacing. + /// + /// Lives here rather than in the BLE/WiFi extensions because `detectedPlatform` has a + /// `private(set)` setter that only this file can write. + func detectAndStorePlatform(model: String, transportType: TransportType) { + var platform = DevicePlatform.detect(from: model) + if transportType == .wifi, platform == .unknown { + platform = .esp32 } - - // MARK: - State Invariants - - #if DEBUG + detectedPlatform = platform + if transportType == .wifi { + logger.info("[WiFi] Platform detected: \(model) -> \(platform)") + } + } + + // MARK: - Cleanup + + /// Cleans up session and services without changing connection state (used during retries) + func cleanupResources() async { + await session?.stop() + await services?.tearDown() + session = nil + services = nil + } + + /// Full cleanup including state reset (used on explicit disconnect) + func cleanupConnection() async { + logger.info("[BLE] cleanupConnection: state → .disconnected") + connectionState = .disconnected + connectingDeviceID = nil + connectedDevice = nil + allowedRepeatFreqRanges = [] + await cleanupResources() + } + + func persistDisconnectDiagnostic(_ summary: String) { + lastConnectionStore.persistDisconnectDiagnostic(summary) + } + + func persistIntent() { + connectionIntent.persist(to: defaults) + } + + // MARK: - State Invariants + + #if DEBUG private var suppressInvariantChecks = false private func assertStateInvariants() { - guard !suppressInvariantChecks else { return } - switch connectionState { - case .ready, .syncing: - assert(services != nil, "Invariant: \(connectionState) requires services") - assert(session != nil, "Invariant: \(connectionState) requires session") - assert(connectedDevice != nil, "Invariant: \(connectionState) requires connectedDevice") - case .connected, .disconnected, .connecting: - break - } - if connectionIntent.isUserDisconnected { - assert(connectionState == .disconnected, "Invariant: .userDisconnected requires .disconnected state") - } + guard !suppressInvariantChecks else { return } + switch connectionState { + case .ready, .syncing: + assert(services != nil, "Invariant: \(connectionState) requires services") + assert(session != nil, "Invariant: \(connectionState) requires session") + assert(connectedDevice != nil, "Invariant: \(connectionState) requires connectedDevice") + case .connected, .disconnected, .connecting: + break + } + if connectionIntent.isUserDisconnected { + assert(connectionState == .disconnected, "Invariant: .userDisconnected requires .disconnected state") + } } - #endif + #endif - // MARK: - Test Helpers + // MARK: - Test Helpers - #if DEBUG + #if DEBUG /// Sets internal state for testing. Only available in DEBUG builds. - internal func setTestState( - connectionState: DeviceConnectionState? = nil, - services: ServiceContainer?? = nil, - session: MeshCoreSession?? = nil, - connectedDevice: DeviceDTO?? = nil, - currentTransportType: TransportType?? = nil, - connectionIntent: ConnectionIntent? = nil, - connectingDeviceID: UUID?? = nil, - sessionRebuildDeviceID: UUID?? = nil, - isPairingInProgress: Bool? = nil, - detectedPlatform: DevicePlatform? = nil, - lastCleanChannelSync: (radioID: UUID, completedAt: Date)?? = nil, - lastAttemptedChannelSync: (radioID: UUID, attemptedAt: Date)?? = nil + func setTestState( + connectionState: DeviceConnectionState? = nil, + services: ServiceContainer?? = nil, + session: MeshCoreSession?? = nil, + connectedDevice: DeviceDTO?? = nil, + currentTransportType: TransportType?? = nil, + connectionIntent: ConnectionIntent? = nil, + connectingDeviceID: UUID?? = nil, + sessionRebuildDeviceID: UUID?? = nil, + isPairingInProgress: Bool? = nil, + detectedPlatform: DevicePlatform? = nil, + lastCleanChannelSync: (radioID: UUID, completedAt: Date)?? = nil, + lastAttemptedChannelSync: (radioID: UUID, attemptedAt: Date)?? = nil ) { - suppressInvariantChecks = true - defer { suppressInvariantChecks = false } - - if let state = connectionState { - self.connectionState = state - } - if let svc = services { - self.services = svc - } - if let sess = session { - self.session = sess - } - if let device = connectedDevice { - self.connectedDevice = device - } - if let transport = currentTransportType { - self.currentTransportType = transport - } - if let intent = connectionIntent { - self.connectionIntent = intent - } - if let deviceID = connectingDeviceID { - self.connectingDeviceID = deviceID - } - if let deviceID = sessionRebuildDeviceID { - self.sessionRebuildDeviceID = deviceID - } - if let pairing = isPairingInProgress { - self.isPairingInProgress = pairing - } - if let platform = detectedPlatform { - self.detectedPlatform = platform - } - if let cleanSync = lastCleanChannelSync { - self.lastCleanChannelSync = cleanSync - } - if let attemptedSync = lastAttemptedChannelSync { - self.lastAttemptedChannelSync = attemptedSync - } + suppressInvariantChecks = true + defer { suppressInvariantChecks = false } + + if let state = connectionState { + self.connectionState = state + } + if let svc = services { + self.services = svc + } + if let sess = session { + self.session = sess + } + if let device = connectedDevice { + self.connectedDevice = device + } + if let transport = currentTransportType { + self.currentTransportType = transport + } + if let intent = connectionIntent { + self.connectionIntent = intent + } + if let deviceID = connectingDeviceID { + self.connectingDeviceID = deviceID + } + if let deviceID = sessionRebuildDeviceID { + self.sessionRebuildDeviceID = deviceID + } + if let pairing = isPairingInProgress { + self.isPairingInProgress = pairing + } + if let platform = detectedPlatform { + self.detectedPlatform = platform + } + if let cleanSync = lastCleanChannelSync { + self.lastCleanChannelSync = cleanSync + } + if let attemptedSync = lastAttemptedChannelSync { + self.lastAttemptedChannelSync = attemptedSync + } } - #endif + #endif } diff --git a/MC1Services/Sources/MC1Services/Connection/DeviceConnectionState.swift b/MC1Services/Sources/MC1Services/Connection/DeviceConnectionState.swift index a2f8abe5..7cbcf5ba 100644 --- a/MC1Services/Sources/MC1Services/Connection/DeviceConnectionState.swift +++ b/MC1Services/Sources/MC1Services/Connection/DeviceConnectionState.swift @@ -5,31 +5,31 @@ /// transport-link state the session publishes; `ConnectionManager` translates /// transport events into the rung modeled here. public enum DeviceConnectionState: Sendable { - case disconnected - case connecting - case connected - case syncing - case ready + case disconnected + case connecting + case connected + case syncing + case ready - /// True when session and services are available and the transport is alive. - /// Used by internal infrastructure (resync loop, health checks, heartbeat). - /// UI code should check `== .ready` to gate user interactions. - public var isOperational: Bool { - self == .syncing || self == .ready - } + /// True when session and services are available and the transport is alive. + /// Used by internal infrastructure (resync loop, health checks, heartbeat). + /// UI code should check `== .ready` to gate user interactions. + public var isOperational: Bool { + self == .syncing || self == .ready + } - /// True when a transport link is established (session may or may not be synced). - public var isConnected: Bool { - switch self { - case .connected, .syncing, .ready: true - case .disconnected, .connecting: false - } + /// True when a transport link is established (session may or may not be synced). + public var isConnected: Bool { + switch self { + case .connected, .syncing, .ready: true + case .disconnected, .connecting: false } + } - /// True only once initial sync has cleared, so the chat send queue may drain without - /// contending with sync's contact/channel/message reads on the radio's link. `.connected` - /// (link up, sync not yet run) deliberately excludes the queue from the vulnerable window. - public var canDrainSendQueue: Bool { - self == .ready - } + /// True only once initial sync has cleared, so the chat send queue may drain without + /// contending with sync's contact/channel/message reads on the radio's link. `.connected` + /// (link up, sync not yet run) deliberately excludes the queue from the vulnerable window. + public var canDrainSendQueue: Bool { + self == .ready + } } diff --git a/MC1Services/Sources/MC1Services/Connection/DevicePlatform.swift b/MC1Services/Sources/MC1Services/Connection/DevicePlatform.swift index 38ebc3e6..6e3c9876 100644 --- a/MC1Services/Sources/MC1Services/Connection/DevicePlatform.swift +++ b/MC1Services/Sources/MC1Services/Connection/DevicePlatform.swift @@ -1,94 +1,94 @@ import Foundation /// Device platform type for BLE write pacing configuration -enum DevicePlatform: Sendable { - case esp32 - case nrf52 - case unknown +enum DevicePlatform { + case esp32 + case nrf52 + case unknown - /// Recommended write pacing delay for this platform - var recommendedWritePacing: TimeInterval { - switch self { - case .esp32: return 0.060 // 60ms required by ESP32 BLE stack - case .nrf52: return 0.025 // Light pacing to avoid RX queue pressure - case .unknown: return 0.060 // Conservative ESP32-safe default for unrecognized devices - } + /// Recommended write pacing delay for this platform + var recommendedWritePacing: TimeInterval { + switch self { + case .esp32: 0.060 // 60ms required by ESP32 BLE stack + case .nrf52: 0.025 // Light pacing to avoid RX queue pressure + case .unknown: 0.060 // Conservative ESP32-safe default for unrecognized devices } + } - /// Detects the device platform from the model string for BLE write pacing. - /// - /// Uses specific model substrings rather than vendor prefixes, because vendors like - /// Heltec, RAK, Seeed, and Elecrow ship devices on multiple chip families. - /// Unrecognized devices fall to `.unknown` (conservative 60ms pacing). - static func detect(from model: String) -> DevicePlatform { - for rule in platformRules { - if model.localizedStandardContains(rule.substring) { - return rule.platform - } - } - return .unknown + /// Detects the device platform from the model string for BLE write pacing. + /// + /// Uses specific model substrings rather than vendor prefixes, because vendors like + /// Heltec, RAK, Seeed, and Elecrow ship devices on multiple chip families. + /// Unrecognized devices fall to `.unknown` (conservative 60ms pacing). + static func detect(from model: String) -> DevicePlatform { + for rule in platformRules { + if model.localizedStandardContains(rule.substring) { + return rule.platform + } } + return .unknown + } - // Ordering matters: first match wins in detect(from:). More specific patterns must precede general ones within each platform group. - private static let platformRules: [(substring: String, platform: DevicePlatform)] = [ - // ESP32 — Heltec - ("Heltec V2", .esp32), - ("Heltec V3", .esp32), - ("Heltec V4", .esp32), - ("Heltec Tracker", .esp32), - ("Heltec E290", .esp32), - ("Heltec E213", .esp32), - ("Heltec T190", .esp32), - ("Heltec CT62", .esp32), - // ESP32 — LilyGo - ("T-Beam", .esp32), - ("T-Deck", .esp32), - ("T-LoRa", .esp32), - ("TLora", .esp32), - // ESP32 — Seeed - ("Xiao S3 WIO", .esp32), - ("Xiao C3", .esp32), - ("Xiao C6", .esp32), - // ESP32 — RAK - ("RAK 3112", .esp32), - // ESP32 — M5Stack - ("Unit C6L", .esp32), - // ESP32 — Other - ("Station G2", .esp32), - ("Meshadventurer", .esp32), - ("Generic ESP32", .esp32), - ("ThinkNode M2", .esp32), - ("ThinkNode M5", .esp32), - // nRF52 — Heltec - ("MeshPocket", .nrf52), - ("Mesh Pocket", .nrf52), - ("T114", .nrf52), - ("Mesh Solar", .nrf52), - // nRF52 — Seeed - ("Xiao-nrf52", .nrf52), - ("Xiao_nrf52", .nrf52), - ("WM1110", .nrf52), - ("Wio Tracker", .nrf52), - ("T1000-E", .nrf52), - ("SenseCap Solar", .nrf52), - // nRF52 — RAK - ("WisMesh Tag", .nrf52), - ("RAK 4631", .nrf52), - ("RAK 3401", .nrf52), - // nRF52 — LilyGo - ("T-Echo", .nrf52), - // nRF52 — Elecrow - ("ThinkNode-M1", .nrf52), - ("ThinkNode M3", .nrf52), - ("ThinkNode-M6", .nrf52), - // nRF52 — GAT562 - ("GAT562", .nrf52), - // nRF52 — Other - ("Ikoka", .nrf52), - ("ProMicro", .nrf52), - ("Minewsemi", .nrf52), - ("Meshtiny", .nrf52), - ("Keepteen", .nrf52), - ("Nano G2 Ultra", .nrf52), - ] + /// Ordering matters: first match wins in detect(from:). More specific patterns must precede general ones within each platform group. + private static let platformRules: [(substring: String, platform: DevicePlatform)] = [ + // ESP32 — Heltec + ("Heltec V2", .esp32), + ("Heltec V3", .esp32), + ("Heltec V4", .esp32), + ("Heltec Tracker", .esp32), + ("Heltec E290", .esp32), + ("Heltec E213", .esp32), + ("Heltec T190", .esp32), + ("Heltec CT62", .esp32), + // ESP32 — LilyGo + ("T-Beam", .esp32), + ("T-Deck", .esp32), + ("T-LoRa", .esp32), + ("TLora", .esp32), + // ESP32 — Seeed + ("Xiao S3 WIO", .esp32), + ("Xiao C3", .esp32), + ("Xiao C6", .esp32), + // ESP32 — RAK + ("RAK 3112", .esp32), + // ESP32 — M5Stack + ("Unit C6L", .esp32), + // ESP32 — Other + ("Station G2", .esp32), + ("Meshadventurer", .esp32), + ("Generic ESP32", .esp32), + ("ThinkNode M2", .esp32), + ("ThinkNode M5", .esp32), + // nRF52 — Heltec + ("MeshPocket", .nrf52), + ("Mesh Pocket", .nrf52), + ("T114", .nrf52), + ("Mesh Solar", .nrf52), + // nRF52 — Seeed + ("Xiao-nrf52", .nrf52), + ("Xiao_nrf52", .nrf52), + ("WM1110", .nrf52), + ("Wio Tracker", .nrf52), + ("T1000-E", .nrf52), + ("SenseCap Solar", .nrf52), + // nRF52 — RAK + ("WisMesh Tag", .nrf52), + ("RAK 4631", .nrf52), + ("RAK 3401", .nrf52), + // nRF52 — LilyGo + ("T-Echo", .nrf52), + // nRF52 — Elecrow + ("ThinkNode-M1", .nrf52), + ("ThinkNode M3", .nrf52), + ("ThinkNode-M6", .nrf52), + // nRF52 — GAT562 + ("GAT562", .nrf52), + // nRF52 — Other + ("Ikoka", .nrf52), + ("ProMicro", .nrf52), + ("Minewsemi", .nrf52), + ("Meshtiny", .nrf52), + ("Keepteen", .nrf52), + ("Nano G2 Ultra", .nrf52), + ] } diff --git a/MC1Services/Sources/MC1Services/Connection/DisconnectReason.swift b/MC1Services/Sources/MC1Services/Connection/DisconnectReason.swift index d0a6e747..53a758e9 100644 --- a/MC1Services/Sources/MC1Services/Connection/DisconnectReason.swift +++ b/MC1Services/Sources/MC1Services/Connection/DisconnectReason.swift @@ -2,14 +2,14 @@ import Foundation /// Reasons for disconnecting from a device (for debugging) public enum DisconnectReason: String, Sendable { - case userInitiated = "user initiated disconnect" - case statusMenuDisconnectTap = "status menu disconnect tapped" - case switchingDevice = "switching to new device" - case factoryReset = "device factory reset" - case wifiAddressChange = "WiFi address changed" - case resyncFailed = "resync failed after 3 attempts" - case forgetDevice = "user forgot device" - case deviceRemovedFromSettings = "device removed from iOS Settings" - case pairingFailed = "device pairing failed" - case wifiReconnectPrep = "preparing for WiFi reconnect" + case userInitiated = "user initiated disconnect" + case statusMenuDisconnectTap = "status menu disconnect tapped" + case switchingDevice = "switching to new device" + case factoryReset = "device factory reset" + case wifiAddressChange = "WiFi address changed" + case resyncFailed = "resync failed after 3 attempts" + case forgetDevice = "user forgot device" + case deviceRemovedFromSettings = "device removed from iOS Settings" + case pairingFailed = "device pairing failed" + case wifiReconnectPrep = "preparing for WiFi reconnect" } diff --git a/MC1Services/Sources/MC1Services/Connection/LastConnectionStore.swift b/MC1Services/Sources/MC1Services/Connection/LastConnectionStore.swift index 8f0971fd..eb377c50 100644 --- a/MC1Services/Sources/MC1Services/Connection/LastConnectionStore.swift +++ b/MC1Services/Sources/MC1Services/Connection/LastConnectionStore.swift @@ -9,56 +9,55 @@ import Foundation /// `ConnectionIntent` persistence is deliberately separate: intent is the /// "does the user want to be connected" axis, not last-device state. struct LastConnectionStore { - - private let defaults: UserDefaults - - init(defaults: UserDefaults) { - self.defaults = defaults - } - - /// The last connected device ID (for auto-reconnect). - var deviceID: UUID? { - guard let uuidString = defaults.string(forKey: PersistenceKeys.lastConnectedDeviceID) else { - return nil - } - return UUID(uuidString: uuidString) - } - - /// The last connected radio ID (for offline data scoping). - var radioID: UUID? { - guard let uuidString = defaults.string(forKey: PersistenceKeys.lastConnectedRadioID) else { - return nil - } - return UUID(uuidString: uuidString) - } - - /// The last connected device name (for offline display when disconnected). - var deviceName: String? { - defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) - } - - /// Records a successful connection for future restoration. - func persist(deviceID: UUID, radioID: UUID, deviceName: String) { - defaults.set(deviceID.uuidString, forKey: PersistenceKeys.lastConnectedDeviceID) - defaults.set(radioID.uuidString, forKey: PersistenceKeys.lastConnectedRadioID) - defaults.set(deviceName, forKey: PersistenceKeys.lastConnectedDeviceName) - } - - /// Clears the persisted connection record. - func clear() { - defaults.removeObject(forKey: PersistenceKeys.lastConnectedDeviceID) - defaults.removeObject(forKey: PersistenceKeys.lastConnectedRadioID) - defaults.removeObject(forKey: PersistenceKeys.lastConnectedDeviceName) - } - - /// Most recent disconnect diagnostic summary persisted across app launches. - var disconnectDiagnostic: String? { - defaults.string(forKey: PersistenceKeys.lastDisconnectDiagnostic) - } - - /// Persists a disconnect diagnostic prefixed with the current ISO8601 timestamp. - func persistDisconnectDiagnostic(_ summary: String) { - let timestamp = Date().ISO8601Format() - defaults.set("\(timestamp) \(summary)", forKey: PersistenceKeys.lastDisconnectDiagnostic) - } + private let defaults: UserDefaults + + init(defaults: UserDefaults) { + self.defaults = defaults + } + + /// The last connected device ID (for auto-reconnect). + var deviceID: UUID? { + guard let uuidString = defaults.string(forKey: PersistenceKeys.lastConnectedDeviceID) else { + return nil + } + return UUID(uuidString: uuidString) + } + + /// The last connected radio ID (for offline data scoping). + var radioID: UUID? { + guard let uuidString = defaults.string(forKey: PersistenceKeys.lastConnectedRadioID) else { + return nil + } + return UUID(uuidString: uuidString) + } + + /// The last connected device name (for offline display when disconnected). + var deviceName: String? { + defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) + } + + /// Records a successful connection for future restoration. + func persist(deviceID: UUID, radioID: UUID, deviceName: String) { + defaults.set(deviceID.uuidString, forKey: PersistenceKeys.lastConnectedDeviceID) + defaults.set(radioID.uuidString, forKey: PersistenceKeys.lastConnectedRadioID) + defaults.set(deviceName, forKey: PersistenceKeys.lastConnectedDeviceName) + } + + /// Clears the persisted connection record. + func clear() { + defaults.removeObject(forKey: PersistenceKeys.lastConnectedDeviceID) + defaults.removeObject(forKey: PersistenceKeys.lastConnectedRadioID) + defaults.removeObject(forKey: PersistenceKeys.lastConnectedDeviceName) + } + + /// Most recent disconnect diagnostic summary persisted across app launches. + var disconnectDiagnostic: String? { + defaults.string(forKey: PersistenceKeys.lastDisconnectDiagnostic) + } + + /// Persists a disconnect diagnostic prefixed with the current ISO8601 timestamp. + func persistDisconnectDiagnostic(_ summary: String) { + let timestamp = Date().ISO8601Format() + defaults.set("\(timestamp) \(summary)", forKey: PersistenceKeys.lastDisconnectDiagnostic) + } } diff --git a/MC1Services/Sources/MC1Services/Connection/RemoveUnfavoritedResult.swift b/MC1Services/Sources/MC1Services/Connection/RemoveUnfavoritedResult.swift index d57347bf..5e1e79ae 100644 --- a/MC1Services/Sources/MC1Services/Connection/RemoveUnfavoritedResult.swift +++ b/MC1Services/Sources/MC1Services/Connection/RemoveUnfavoritedResult.swift @@ -2,6 +2,6 @@ import Foundation /// Result of removing unfavorited nodes from the device public struct RemoveUnfavoritedResult: Sendable { - public let removed: Int - public let total: Int + public let removed: Int + public let total: Int } diff --git a/MC1Services/Sources/MC1Services/Connection/TransportType.swift b/MC1Services/Sources/MC1Services/Connection/TransportType.swift index 28acc899..c3544bf1 100644 --- a/MC1Services/Sources/MC1Services/Connection/TransportType.swift +++ b/MC1Services/Sources/MC1Services/Connection/TransportType.swift @@ -2,6 +2,6 @@ import Foundation /// Transport type for the mesh connection public enum TransportType: Sendable { - case bluetooth - case wifi + case bluetooth + case wifi } diff --git a/MC1Services/Sources/MC1Services/DTOs/LinkPreviewDataDTO.swift b/MC1Services/Sources/MC1Services/DTOs/LinkPreviewDataDTO.swift index 069f2101..6ec957cf 100644 --- a/MC1Services/Sources/MC1Services/DTOs/LinkPreviewDataDTO.swift +++ b/MC1Services/Sources/MC1Services/DTOs/LinkPreviewDataDTO.swift @@ -2,43 +2,43 @@ import Foundation /// Data transfer object for link preview data public struct LinkPreviewDataDTO: Identifiable, Sendable, Hashable { - /// URL is the ID (unique key) - public let id: String - public let url: String - public let title: String? - public let imageData: Data? - public let iconData: Data? - public let imageWidth: Int? - public let imageHeight: Int? - public let fetchedAt: Date + /// URL is the ID (unique key) + public let id: String + public let url: String + public let title: String? + public let imageData: Data? + public let iconData: Data? + public let imageWidth: Int? + public let imageHeight: Int? + public let fetchedAt: Date - public init( - url: String, - title: String? = nil, - imageData: Data? = nil, - iconData: Data? = nil, - imageWidth: Int? = nil, - imageHeight: Int? = nil, - fetchedAt: Date = Date() - ) { - self.id = url - self.url = url - self.title = title - self.imageData = imageData - self.iconData = iconData - self.imageWidth = imageWidth - self.imageHeight = imageHeight - self.fetchedAt = fetchedAt - } + public init( + url: String, + title: String? = nil, + imageData: Data? = nil, + iconData: Data? = nil, + imageWidth: Int? = nil, + imageHeight: Int? = nil, + fetchedAt: Date = Date() + ) { + id = url + self.url = url + self.title = title + self.imageData = imageData + self.iconData = iconData + self.imageWidth = imageWidth + self.imageHeight = imageHeight + self.fetchedAt = fetchedAt + } - init(from model: LinkPreviewData) { - self.id = model.url - self.url = model.url - self.title = model.title - self.imageData = model.imageData - self.iconData = model.iconData - self.imageWidth = model.imageWidth - self.imageHeight = model.imageHeight - self.fetchedAt = model.fetchedAt - } + init(from model: LinkPreviewData) { + id = model.url + url = model.url + title = model.title + imageData = model.imageData + iconData = model.iconData + imageWidth = model.imageWidth + imageHeight = model.imageHeight + fetchedAt = model.fetchedAt + } } diff --git a/MC1Services/Sources/MC1Services/Errors/AppBackupError.swift b/MC1Services/Sources/MC1Services/Errors/AppBackupError.swift index 91836b13..e9bec2a1 100644 --- a/MC1Services/Sources/MC1Services/Errors/AppBackupError.swift +++ b/MC1Services/Sources/MC1Services/Errors/AppBackupError.swift @@ -2,30 +2,30 @@ import Foundation /// Errors that can occur during app backup export or import. public enum AppBackupError: Error, LocalizedError, Sendable { - case invalidFile - case fileTooLarge(actualBytes: Int, maxBytes: Int) - case decompressedTooLarge(maxBytes: Int) - case unsupportedVersion(found: Int, maxSupported: Int) - case corruptedManifest - case exportFailed(underlying: any Error) - case importFailed(underlying: any Error) + case invalidFile + case fileTooLarge(actualBytes: Int, maxBytes: Int) + case decompressedTooLarge(maxBytes: Int) + case unsupportedVersion(found: Int, maxSupported: Int) + case corruptedManifest + case exportFailed(underlying: any Error) + case importFailed(underlying: any Error) - public var errorDescription: String? { - switch self { - case .invalidFile: - "The backup file is invalid or could not be read." - case .fileTooLarge(let actualBytes, let maxBytes): - "The backup file is too large to import (\(actualBytes / 1_048_576) MB; limit is \(maxBytes / 1_048_576) MB)." - case .decompressedTooLarge(let maxBytes): - "The backup file expands past the safe size limit (\(maxBytes / 1_048_576) MB uncompressed)." - case .unsupportedVersion(let found, let maxSupported): - "This backup was created with a newer format (version \(found)). This app supports up to version \(maxSupported). Please update the app and try again." - case .corruptedManifest: - "The backup file appears to be corrupted. The declared item counts do not match the actual data." - case .exportFailed(let underlying): - "Failed to create backup: \(underlying.localizedDescription)" - case .importFailed(let underlying): - "Failed to import backup: \(underlying.localizedDescription)" - } + public var errorDescription: String? { + switch self { + case .invalidFile: + "The backup file is invalid or could not be read." + case let .fileTooLarge(actualBytes, maxBytes): + "The backup file is too large to import (\(actualBytes / 1_048_576) MB; limit is \(maxBytes / 1_048_576) MB)." + case let .decompressedTooLarge(maxBytes): + "The backup file expands past the safe size limit (\(maxBytes / 1_048_576) MB uncompressed)." + case let .unsupportedVersion(found, maxSupported): + "This backup was created with a newer format (version \(found)). This app supports up to version \(maxSupported). Please update the app and try again." + case .corruptedManifest: + "The backup file appears to be corrupted. The declared item counts do not match the actual data." + case let .exportFailed(underlying): + "Failed to create backup: \(underlying.localizedDescription)" + case let .importFailed(underlying): + "Failed to import backup: \(underlying.localizedDescription)" } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/BLEError.swift b/MC1Services/Sources/MC1Services/Errors/BLEError.swift index 7e2ebb84..b89ec73c 100644 --- a/MC1Services/Sources/MC1Services/Errors/BLEError.swift +++ b/MC1Services/Sources/MC1Services/Errors/BLEError.swift @@ -4,55 +4,55 @@ import Foundation /// Errors that can occur during BLE operations public enum BLEError: Error, Sendable { - case bluetoothUnavailable - case bluetoothUnauthorized - case bluetoothPoweredOff - case deviceNotFound - case connectionFailed(String) - case connectionTimeout - case notConnected - case characteristicNotFound - case writeError(String) - case invalidResponse - case operationTimeout - case authenticationFailed - case pairingFailed(String) - case deviceConnectedToOtherApp + case bluetoothUnavailable + case bluetoothUnauthorized + case bluetoothPoweredOff + case deviceNotFound + case connectionFailed(String) + case connectionTimeout + case notConnected + case characteristicNotFound + case writeError(String) + case invalidResponse + case operationTimeout + case authenticationFailed + case pairingFailed(String) + case deviceConnectedToOtherApp } // MARK: - BLEError LocalizedError Conformance extension BLEError: LocalizedError { - public var errorDescription: String? { - switch self { - case .bluetoothUnavailable: - return "Bluetooth is not available on this device." - case .bluetoothUnauthorized: - return "Bluetooth permission is required. Please enable it in Settings." - case .bluetoothPoweredOff: - return "Bluetooth is turned off. Please enable Bluetooth to connect." - case .deviceNotFound: - return "Device not found. Please make sure it's powered on and nearby." - case .connectionFailed(let message): - return "Connection failed: \(message)" - case .connectionTimeout: - return "Connection timed out. Please try again." - case .notConnected: - return "Not connected to a device." - case .characteristicNotFound: - return "Unable to communicate with device. Please try reconnecting." - case .writeError(let message): - return "Failed to send data: \(message)" - case .invalidResponse: - return "Invalid response from device. Please try again." - case .operationTimeout: - return "Operation timed out. Please try again." - case .authenticationFailed: - return "Authentication failed. Please check your device's PIN." - case .pairingFailed(let reason): - return "Bluetooth pairing failed: \(reason)" - case .deviceConnectedToOtherApp: - return "This device is connected to another app. Only one app can use a mesh radio at a time to prevent communication issues." - } + public var errorDescription: String? { + switch self { + case .bluetoothUnavailable: + "Bluetooth is not available on this device." + case .bluetoothUnauthorized: + "Bluetooth permission is required. Please enable it in Settings." + case .bluetoothPoweredOff: + "Bluetooth is turned off. Please enable Bluetooth to connect." + case .deviceNotFound: + "Device not found. Please make sure it's powered on and nearby." + case let .connectionFailed(message): + "Connection failed: \(message)" + case .connectionTimeout: + "Connection timed out. Please try again." + case .notConnected: + "Not connected to a device." + case .characteristicNotFound: + "Unable to communicate with device. Please try reconnecting." + case let .writeError(message): + "Failed to send data: \(message)" + case .invalidResponse: + "Invalid response from device. Please try again." + case .operationTimeout: + "Operation timed out. Please try again." + case .authenticationFailed: + "Authentication failed. Please check your device's PIN." + case let .pairingFailed(reason): + "Bluetooth pairing failed: \(reason)" + case .deviceConnectedToOtherApp: + "This device is connected to another app. Only one app can use a mesh radio at a time to prevent communication issues." } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/ConnectionError.swift b/MC1Services/Sources/MC1Services/Errors/ConnectionError.swift index 75c4b359..4bdd1fc0 100644 --- a/MC1Services/Sources/MC1Services/Errors/ConnectionError.swift +++ b/MC1Services/Sources/MC1Services/Errors/ConnectionError.swift @@ -2,21 +2,21 @@ import Foundation /// Errors that can occur during connection operations public enum ConnectionError: LocalizedError { - case connectionFailed(String) - case deviceNotFound - case notConnected - case initializationFailed(String) + case connectionFailed(String) + case deviceNotFound + case notConnected + case initializationFailed(String) - public var errorDescription: String? { - switch self { - case .connectionFailed(let reason): - return "Connection failed: \(reason)" - case .deviceNotFound: - return "Device not found" - case .notConnected: - return "Not connected to device" - case .initializationFailed(let reason): - return "Device initialization failed: \(reason)" - } + public var errorDescription: String? { + switch self { + case let .connectionFailed(reason): + "Connection failed: \(reason)" + case .deviceNotFound: + "Device not found" + case .notConnected: + "Not connected to device" + case let .initializationFailed(reason): + "Device initialization failed: \(reason)" } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/MeshCoreError+LocalizedError.swift b/MC1Services/Sources/MC1Services/Errors/MeshCoreError+LocalizedError.swift index 8e61d5e1..90fae061 100644 --- a/MC1Services/Sources/MC1Services/Errors/MeshCoreError+LocalizedError.swift +++ b/MC1Services/Sources/MC1Services/Errors/MeshCoreError+LocalizedError.swift @@ -4,58 +4,58 @@ import MeshCore // MARK: - MeshCoreError LocalizedError Conformance extension MeshCoreError: @retroactive LocalizedError { - public var errorDescription: String? { - switch self { - case .timeout: - "The operation timed out. Please try again." - case .deviceError(let code): - Self.deviceErrorDescription(code: code) - case .parseError(let detail): - "Failed to parse device response: \(detail)" - case .notConnected: - "Not connected to device." - case .commandFailed(_, let reason): - "Command failed: \(reason)" - case .invalidResponse(let expected, let got): - "Unexpected response from device (expected \(expected), got \(got))." - case .contactNotFound: - "Contact not found on device." - case .dataTooLarge(let maxSize, let actualSize): - "Data too large (\(actualSize) bytes, maximum is \(maxSize))." - case .signingFailed(let reason): - "Signing failed: \(reason)" - case .invalidInput(let detail): - "Invalid input: \(detail)" - case .unknown(let detail): - "An unknown error occurred: \(detail)" - case .bluetoothUnavailable: - "Bluetooth is not available on this device." - case .bluetoothUnauthorized: - "Bluetooth permission is required. Please enable it in Settings." - case .bluetoothPoweredOff: - "Bluetooth is turned off. Please enable Bluetooth to connect." - case .connectionLost(let underlying): - if let underlying { - "Connection to device was lost: \(underlying.localizedDescription)" - } else { - "Connection to device was lost." - } - case .sessionNotStarted: - "Session has not been started." - case .featureDisabled: - "This feature is disabled on the device." - } + public var errorDescription: String? { + switch self { + case .timeout: + "The operation timed out. Please try again." + case let .deviceError(code): + Self.deviceErrorDescription(code: code) + case let .parseError(detail): + "Failed to parse device response: \(detail)" + case .notConnected: + "Not connected to device." + case let .commandFailed(_, reason): + "Command failed: \(reason)" + case let .invalidResponse(expected, got): + "Unexpected response from device (expected \(expected), got \(got))." + case .contactNotFound: + "Contact not found on device." + case let .dataTooLarge(maxSize, actualSize): + "Data too large (\(actualSize) bytes, maximum is \(maxSize))." + case let .signingFailed(reason): + "Signing failed: \(reason)" + case let .invalidInput(detail): + "Invalid input: \(detail)" + case let .unknown(detail): + "An unknown error occurred: \(detail)" + case .bluetoothUnavailable: + "Bluetooth is not available on this device." + case .bluetoothUnauthorized: + "Bluetooth permission is required. Please enable it in Settings." + case .bluetoothPoweredOff: + "Bluetooth is turned off. Please enable Bluetooth to connect." + case let .connectionLost(underlying): + if let underlying { + "Connection to device was lost: \(underlying.localizedDescription)" + } else { + "Connection to device was lost." + } + case .sessionNotStarted: + "Session has not been started." + case .featureDisabled: + "This feature is disabled on the device." } + } - private static func deviceErrorDescription(code: UInt8) -> String { - switch code { - case 0x01: "Command not supported by device firmware." - case 0x02: "Item not found on device." - case 0x03: "Device storage is full." - case 0x04: "Device is in an invalid state for this operation." - case 0x05: "Device file system error." - case 0x06: "Invalid parameter sent to device." - default: "Device error (code \(code))." - } + private static func deviceErrorDescription(code: UInt8) -> String { + switch code { + case 0x01: "Command not supported by device firmware." + case 0x02: "Item not found on device." + case 0x03: "Device storage is full." + case 0x04: "Device is in an invalid state for this operation." + case 0x05: "Device file system error." + case 0x06: "Invalid parameter sent to device." + default: "Device error (code \(code))." } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/PairingError.swift b/MC1Services/Sources/MC1Services/Errors/PairingError.swift index 99e0d969..2126be1a 100644 --- a/MC1Services/Sources/MC1Services/Errors/PairingError.swift +++ b/MC1Services/Sources/MC1Services/Errors/PairingError.swift @@ -2,37 +2,37 @@ import Foundation /// Errors that can occur during device pairing public enum PairingError: LocalizedError { - /// ASK pairing succeeded but BLE connection failed (e.g., wrong PIN) - case connectionFailed(deviceID: UUID, underlying: Error) - /// ASK pairing succeeded but device is connected to another app - case deviceConnectedToOtherApp(deviceID: UUID) + /// ASK pairing succeeded but BLE connection failed (e.g., wrong PIN) + case connectionFailed(deviceID: UUID, underlying: Error) + /// ASK pairing succeeded but device is connected to another app + case deviceConnectedToOtherApp(deviceID: UUID) - public var errorDescription: String? { - switch self { - case .connectionFailed(_, let underlying): - return "Connection failed: \(underlying.localizedDescription)" - case .deviceConnectedToOtherApp: - return "Device is connected to another app." - } + public var errorDescription: String? { + switch self { + case let .connectionFailed(_, underlying): + "Connection failed: \(underlying.localizedDescription)" + case .deviceConnectedToOtherApp: + "Device is connected to another app." } + } - /// The device ID that failed to connect (for recovery UI) - public var deviceID: UUID? { - switch self { - case .connectionFailed(let deviceID, _): - return deviceID - case .deviceConnectedToOtherApp(let deviceID): - return deviceID - } + /// The device ID that failed to connect (for recovery UI) + public var deviceID: UUID? { + switch self { + case let .connectionFailed(deviceID, _): + deviceID + case let .deviceConnectedToOtherApp(deviceID): + deviceID } + } - /// True when the underlying BLE failure is an auth/encryption error. - /// Detection is locale-independent: BLEStateMachine maps CBATTError auth - /// codes (5/8/12/15) and CBError.encryptionTimedOut to BLEError.authenticationFailed - /// at the throw site. - public var isAuthenticationFailure: Bool { - guard case .connectionFailed(_, let underlying) = self else { return false } - if case BLEError.authenticationFailed = underlying { return true } - return false - } + /// True when the underlying BLE failure is an auth/encryption error. + /// Detection is locale-independent: BLEStateMachine maps CBATTError auth + /// codes (5/8/12/15) and CBError.encryptionTimedOut to BLEError.authenticationFailed + /// at the throw site. + public var isAuthenticationFailure: Bool { + guard case let .connectionFailed(_, underlying) = self else { return false } + if case BLEError.authenticationFailed = underlying { return true } + return false + } } diff --git a/MC1Services/Sources/MC1Services/Errors/ProtocolError.swift b/MC1Services/Sources/MC1Services/Errors/ProtocolError.swift index b8fdf32f..4562bb47 100644 --- a/MC1Services/Sources/MC1Services/Errors/ProtocolError.swift +++ b/MC1Services/Sources/MC1Services/Errors/ProtocolError.swift @@ -2,23 +2,23 @@ import Foundation /// Error codes returned by the device public enum ProtocolError: UInt8, Sendable, Error { - case unsupportedCommand = 0x01 - case notFound = 0x02 - case tableFull = 0x03 - case badState = 0x04 - case fileIOError = 0x05 - case illegalArgument = 0x06 + case unsupportedCommand = 0x01 + case notFound = 0x02 + case tableFull = 0x03 + case badState = 0x04 + case fileIOError = 0x05 + case illegalArgument = 0x06 } extension ProtocolError: LocalizedError { - public var errorDescription: String? { - switch self { - case .unsupportedCommand: "Command not supported by device firmware." - case .notFound: "Item not found on device." - case .tableFull: "Device storage is full." - case .badState: "Device is in an invalid state for this operation." - case .fileIOError: "Device file system error." - case .illegalArgument: "Invalid parameter sent to device." - } + public var errorDescription: String? { + switch self { + case .unsupportedCommand: "Command not supported by device firmware." + case .notFound: "Item not found on device." + case .tableFull: "Device storage is full." + case .badState: "Device is in an invalid state for this operation." + case .fileIOError: "Device file system error." + case .illegalArgument: "Invalid parameter sent to device." } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift b/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift index d54766a2..ace4cc16 100644 --- a/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift +++ b/MC1Services/Sources/MC1Services/Errors/RemoteNodeError.swift @@ -2,60 +2,60 @@ import Foundation import MeshCore public enum RemoteNodeError: Error, LocalizedError, Sendable { - case notConnected - case loginFailed(String) - case sendFailed(String) - case invalidResponse - case permissionDenied - case timeout - case sessionNotFound - case passwordNotFound - case floodRouted // Keep-alive requires direct path - case pathDiscoveryFailed - case contactNotFound - case radioContactsFull // Radio's contact table is full; cannot auto-add a missing node - case cancelled // Login cancelled due to duplicate attempt or shutdown - case sessionError(MeshCoreError) + case notConnected + case loginFailed(String) + case sendFailed(String) + case invalidResponse + case permissionDenied + case timeout + case sessionNotFound + case passwordNotFound + case floodRouted // Keep-alive requires direct path + case pathDiscoveryFailed + case contactNotFound + case radioContactsFull // Radio's contact table is full; cannot auto-add a missing node + case cancelled // Login cancelled due to duplicate attempt or shutdown + case sessionError(MeshCoreError) - public var errorDescription: String? { - switch self { - case .notConnected: - return "Not connected to mesh device" - case .loginFailed(let reason): - return "Login failed: \(reason)" - case .sendFailed(let reason): - return "Failed to send: \(reason)" - case .invalidResponse: - return "Invalid response from remote node" - case .permissionDenied: - return "Permission denied" - case .timeout: - return "Request timed out" - case .sessionNotFound: - return "Remote node session not found" - case .passwordNotFound: - return "Password not found in keychain" - case .floodRouted: - return "Keep-alive requires direct routing path" - case .pathDiscoveryFailed: - return "Failed to establish direct path" - case .contactNotFound: - return "Contact not found in database" - case .radioContactsFull: - return "Radio contact list is full" - case .cancelled: - return "Login cancelled" - case .sessionError(let error): - return error.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: + "Not connected to mesh device" + case let .loginFailed(reason): + "Login failed: \(reason)" + case let .sendFailed(reason): + "Failed to send: \(reason)" + case .invalidResponse: + "Invalid response from remote node" + case .permissionDenied: + "Permission denied" + case .timeout: + "Request timed out" + case .sessionNotFound: + "Remote node session not found" + case .passwordNotFound: + "Password not found in keychain" + case .floodRouted: + "Keep-alive requires direct routing path" + case .pathDiscoveryFailed: + "Failed to establish direct path" + case .contactNotFound: + "Contact not found in database" + case .radioContactsFull: + "Radio contact list is full" + case .cancelled: + "Login cancelled" + case let .sessionError(error): + error.localizedDescription } + } - public var isRetryable: Bool { - switch self { - case .timeout, .notConnected, .floodRouted: - return true - default: - return false - } + public var isRetryable: Bool { + switch self { + case .timeout, .notConnected, .floodRouted: + true + default: + false } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/SettingsServiceError.swift b/MC1Services/Sources/MC1Services/Errors/SettingsServiceError.swift index b7c8c7d8..279a48fd 100644 --- a/MC1Services/Sources/MC1Services/Errors/SettingsServiceError.swift +++ b/MC1Services/Sources/MC1Services/Errors/SettingsServiceError.swift @@ -2,38 +2,38 @@ import Foundation import MeshCore public enum SettingsServiceError: Error, LocalizedError, Sendable { - case notConnected - case sendFailed - case invalidResponse - case sessionError(MeshCoreError) - case verificationFailed(expected: String, actual: String) - case deviceGPSVerificationFailed(expectedEnabled: Bool, actualEnabled: Bool) + case notConnected + case sendFailed + case invalidResponse + case sessionError(MeshCoreError) + case verificationFailed(expected: String, actual: String) + case deviceGPSVerificationFailed(expectedEnabled: Bool, actualEnabled: Bool) - public var errorDescription: String? { - switch self { - case .notConnected: return "Device not connected" - case .sendFailed: return "Failed to send command" - case .invalidResponse: return "Invalid response from device" - case .sessionError(let error): return error.localizedDescription - case .verificationFailed(let expected, let actual): - return "Setting was not saved. Expected '\(expected)' but device reports '\(actual)'." - case .deviceGPSVerificationFailed(let expectedEnabled, let actualEnabled): - let expected = expectedEnabled ? "On" : "Off" - let actual = actualEnabled ? "On" : "Off" - return "Device GPS setting was not saved. Expected '\(expected)' but device reports '\(actual)'." - } + public var errorDescription: String? { + switch self { + case .notConnected: return "Device not connected" + case .sendFailed: return "Failed to send command" + case .invalidResponse: return "Invalid response from device" + case let .sessionError(error): return error.localizedDescription + case let .verificationFailed(expected, actual): + return "Setting was not saved. Expected '\(expected)' but device reports '\(actual)'." + case let .deviceGPSVerificationFailed(expectedEnabled, actualEnabled): + let expected = expectedEnabled ? "On" : "Off" + let actual = actualEnabled ? "On" : "Off" + return "Device GPS setting was not saved. Expected '\(expected)' but device reports '\(actual)'." } + } - /// Whether this error suggests a connection issue that might be resolved by retrying - public var isRetryable: Bool { - switch self { - case .sendFailed, .notConnected: - return true - case .sessionError(let error): - if case .timeout = error { return true } - return false - default: - return false - } + /// Whether this error suggests a connection issue that might be resolved by retrying + public var isRetryable: Bool { + switch self { + case .sendFailed, .notConnected: + return true + case let .sessionError(error): + if case .timeout = error { return true } + return false + default: + return false } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/StoreServiceError.swift b/MC1Services/Sources/MC1Services/Errors/StoreServiceError.swift index 51877a70..433707ed 100644 --- a/MC1Services/Sources/MC1Services/Errors/StoreServiceError.swift +++ b/MC1Services/Sources/MC1Services/Errors/StoreServiceError.swift @@ -5,44 +5,44 @@ import StoreKit /// MC1Services error type (L10n is not visible from this package); the MC1 view-model /// layer maps these to localized copy. public enum StoreServiceError: LocalizedError, Sendable, Equatable { - case productsNotLoaded - case productNotFound(productID: String) - case purchaseFailed(reason: String) - case verificationFailed - case notEntitled - case networkUnavailable - case storefrontUnavailable - case unsupported + case productsNotLoaded + case productNotFound(productID: String) + case purchaseFailed(reason: String) + case verificationFailed + case notEntitled + case networkUnavailable + case storefrontUnavailable + case unsupported - public var errorDescription: String? { - switch self { - case .productsNotLoaded: "Products could not be loaded." - case .productNotFound: "Product not found." - case .purchaseFailed(let r): "Purchase failed: \(r)" - case .verificationFailed: "Could not verify purchase." - case .notEntitled: "Purchases are restricted on this device." - case .networkUnavailable: "Network unavailable." - case .storefrontUnavailable: "This product is not available in your region." - case .unsupported: "In-app purchases are not supported on this device." - } + public var errorDescription: String? { + switch self { + case .productsNotLoaded: "Products could not be loaded." + case .productNotFound: "Product not found." + case let .purchaseFailed(r): "Purchase failed: \(r)" + case .verificationFailed: "Could not verify purchase." + case .notEntitled: "Purchases are restricted on this device." + case .networkUnavailable: "Network unavailable." + case .storefrontUnavailable: "This product is not available in your region." + case .unsupported: "In-app purchases are not supported on this device." } + } - /// Maps a `StoreKitError` to a `StoreServiceError`, or `nil` when the error represents - /// user cancellation — which callers surface as `StorePurchaseOutcome.userCancelled`, - /// not as an alert. - public static func from(_ error: StoreKitError) -> StoreServiceError? { - if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *), - case .unsupported = error { - return .unsupported - } - switch error { - case .userCancelled: return nil - case .networkError: return .networkUnavailable - case .notAvailableInStorefront: return .storefrontUnavailable - case .notEntitled: return .notEntitled - case .systemError(let underlying): return .purchaseFailed(reason: String(describing: underlying)) - case .unknown: return .purchaseFailed(reason: "Unknown StoreKit error") - default: return .purchaseFailed(reason: "Unhandled StoreKit error") - } + /// Maps a `StoreKitError` to a `StoreServiceError`, or `nil` when the error represents + /// user cancellation — which callers surface as `StorePurchaseOutcome.userCancelled`, + /// not as an alert. + public static func from(_ error: StoreKitError) -> StoreServiceError? { + if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *), + case .unsupported = error { + return .unsupported } + switch error { + case .userCancelled: return nil + case .networkError: return .networkUnavailable + case .notAvailableInStorefront: return .storefrontUnavailable + case .notEntitled: return .notEntitled + case let .systemError(underlying): return .purchaseFailed(reason: String(describing: underlying)) + case .unknown: return .purchaseFailed(reason: "Unknown StoreKit error") + default: return .purchaseFailed(reason: "Unhandled StoreKit error") + } + } } diff --git a/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift b/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift index 12716965..1d001be6 100644 --- a/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift +++ b/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift @@ -4,24 +4,24 @@ import MeshCore // MARK: - WiFiTransportError LocalizedError Conformance extension WiFiTransportError: @retroactive LocalizedError { - public var errorDescription: String? { - switch self { - case .connectionFailed(let reason): - "Connection failed: \(reason)" - case .connectionTimeout: - "Connection timed out. Check the IP address and ensure the device is on the same network." - case .notConnected: - "Not connected to device." - case .sendFailed(let reason): - "Failed to send data: \(reason)" - case .sendTimeout: - "Send operation timed out." - case .invalidHost: - "Invalid IP address." - case .invalidPort: - "Invalid port number." - case .notConfigured: - "Connection not configured." - } + public var errorDescription: String? { + switch self { + case let .connectionFailed(reason): + "Connection failed: \(reason)" + case .connectionTimeout: + "Connection timed out. Check the IP address and ensure the device is on the same network." + case .notConnected: + "Not connected to device." + case let .sendFailed(reason): + "Failed to send data: \(reason)" + case .sendTimeout: + "Send operation timed out." + case .invalidHost: + "Invalid IP address." + case .invalidPort: + "Invalid port number." + case .notConfigured: + "Connection not configured." } + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/Bundle+App.swift b/MC1Services/Sources/MC1Services/Extensions/Bundle+App.swift index 2696231a..149654cb 100644 --- a/MC1Services/Sources/MC1Services/Extensions/Bundle+App.swift +++ b/MC1Services/Sources/MC1Services/Extensions/Bundle+App.swift @@ -1,13 +1,13 @@ import Foundation public extension Bundle { - /// `CFBundleShortVersionString` from the bundle's Info.plist, or `"unknown"` if absent. - var appVersion: String { - infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" - } + /// `CFBundleShortVersionString` from the bundle's Info.plist, or `"unknown"` if absent. + var appVersion: String { + infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + } - /// `CFBundleVersion` from the bundle's Info.plist, or `"unknown"` if absent. - var appBuild: String { - infoDictionary?["CFBundleVersion"] as? String ?? "unknown" - } + /// `CFBundleVersion` from the bundle's Info.plist, or `"unknown"` if absent. + var appBuild: String { + infoDictionary?["CFBundleVersion"] as? String ?? "unknown" + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/Character+EmojiDetection.swift b/MC1Services/Sources/MC1Services/Extensions/Character+EmojiDetection.swift index 6e5ac931..d5cd7147 100644 --- a/MC1Services/Sources/MC1Services/Extensions/Character+EmojiDetection.swift +++ b/MC1Services/Sources/MC1Services/Extensions/Character+EmojiDetection.swift @@ -1,8 +1,8 @@ import Foundation public extension Character { - var isEmoji: Bool { - guard let scalar = unicodeScalars.first else { return false } - return scalar.properties.isEmoji && (scalar.value > 0x238C || unicodeScalars.count > 1) - } + var isEmoji: Bool { + guard let scalar = unicodeScalars.first else { return false } + return scalar.properties.isEmoji && (scalar.value > 0x238C || unicodeScalars.count > 1) + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/Data+Extensions.swift b/MC1Services/Sources/MC1Services/Extensions/Data+Extensions.swift index 7cdae511..8a3e9897 100644 --- a/MC1Services/Sources/MC1Services/Extensions/Data+Extensions.swift +++ b/MC1Services/Sources/MC1Services/Extensions/Data+Extensions.swift @@ -7,85 +7,85 @@ import Foundation private let zlibDecompressReadChunkSize = 64 * 1024 public extension Data { - /// Converts data to an uppercase hex string with an optional separator between bytes. - /// Display-only formatting; identity comparisons and serialization use the - /// lowercase `hexString` property from MeshCore. - /// - Parameter separator: String to insert between each byte (default: none) - /// - Returns: Uppercase hex string representation - func uppercaseHexString(separator: String = "") -> String { - map { String(format: "%02X", $0) }.joined(separator: separator) - } + /// Converts data to an uppercase hex string with an optional separator between bytes. + /// Display-only formatting; identity comparisons and serialization use the + /// lowercase `hexString` property from MeshCore. + /// - Parameter separator: String to insert between each byte (default: none) + /// - Returns: Uppercase hex string representation + func uppercaseHexString(separator: String = "") -> String { + map { String(format: "%02X", $0) }.joined(separator: separator) + } - /// Splits a path's hop bytes into per-hop `(rawBytes, uppercaseHex)` pairs, chunking by - /// `hashSize`; callers pass only the valid slice. A non-positive `hashSize` returns no hops, - /// avoiding a `stride(by:)` trap, and each chunk is re-based to its own `Data` so a slice - /// with a non-zero `startIndex` indexes correctly. - internal func pathHops(hashSize: Int) -> [(data: Data, hex: String)] { - guard hashSize > 0 else { return [] } - return stride(from: 0, to: count, by: hashSize).map { offset in - let start = index(startIndex, offsetBy: offset) - let end = index(start, offsetBy: Swift.min(hashSize, count - offset)) - let chunk = Data(self[start.. [(data: Data, hex: String)] { + guard hashSize > 0 else { return [] } + return stride(from: 0, to: count, by: hashSize).map { offset in + let start = index(startIndex, offsetBy: offset) + let end = index(start, offsetBy: Swift.min(hashSize, count - offset)) + let chunk = Data(self[start..= 4 else { return 0 } - return prefix(4).withUnsafeBytes { - $0.load(as: UInt32.self).littleEndian - } - } + self = data + } - /// zlib-compress this data. Wraps Foundation's NSData bridge. - internal func zlibCompressed() throws -> Data { - try (self as NSData).compressed(using: .zlib) as Data + /// Convert first 4 bytes to UInt32 ACK code (little-endian) + /// Returns 0 if data has fewer than 4 bytes + internal var ackCodeUInt32: UInt32 { + guard count >= 4 else { return 0 } + return prefix(4).withUnsafeBytes { + $0.load(as: UInt32.self).littleEndian } + } - /// Stream-decompress a zlib payload, aborting once the output crosses - /// `maxUncompressedBytes`. Throws `AppBackupError.decompressedTooLarge` - /// on cap overflow so callers can surface a specific user-facing reason - /// instead of a generic invalid-file error. - internal func zlibDecompressed(maxUncompressedBytes: Int) throws -> Data { - var offset = 0 - let source = self - let inputFilter = try InputFilter(.decompress, using: .zlib) { length -> Data? in - let remaining = source.count - offset - guard remaining > 0 else { return nil } - let take = Swift.min(length, remaining) - let chunk = source.subdata(in: offset..<(offset + take)) - offset += take - return chunk - } + /// zlib-compress this data. Wraps Foundation's NSData bridge. + internal func zlibCompressed() throws -> Data { + try (self as NSData).compressed(using: .zlib) as Data + } + + /// Stream-decompress a zlib payload, aborting once the output crosses + /// `maxUncompressedBytes`. Throws `AppBackupError.decompressedTooLarge` + /// on cap overflow so callers can surface a specific user-facing reason + /// instead of a generic invalid-file error. + internal func zlibDecompressed(maxUncompressedBytes: Int) throws -> Data { + var offset = 0 + let source = self + let inputFilter = try InputFilter(.decompress, using: .zlib) { length -> Data? in + let remaining = source.count - offset + guard remaining > 0 else { return nil } + let take = Swift.min(length, remaining) + let chunk = source.subdata(in: offset..<(offset + take)) + offset += take + return chunk + } - var output = Data() - while let chunk = try inputFilter.readData(ofLength: zlibDecompressReadChunkSize), !chunk.isEmpty { - if output.count + chunk.count > maxUncompressedBytes { - throw AppBackupError.decompressedTooLarge(maxBytes: maxUncompressedBytes) - } - output.append(chunk) - } - return output + var output = Data() + while let chunk = try inputFilter.readData(ofLength: zlibDecompressReadChunkSize), !chunk.isEmpty { + if output.count + chunk.count > maxUncompressedBytes { + throw AppBackupError.decompressedTooLarge(maxBytes: maxUncompressedBytes) + } + output.append(chunk) } + return output + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/LPPDataPoint+Display.swift b/MC1Services/Sources/MC1Services/Extensions/LPPDataPoint+Display.swift index 4969b343..cb11a5ce 100644 --- a/MC1Services/Sources/MC1Services/Extensions/LPPDataPoint+Display.swift +++ b/MC1Services/Sources/MC1Services/Extensions/LPPDataPoint+Display.swift @@ -1,60 +1,60 @@ import Foundation import MeshCore -extension LPPDataPoint { - /// Human-readable type name for the sensor channel - public var typeName: String { - type.name - } +public extension LPPDataPoint { + /// Human-readable type name for the sensor channel + var typeName: String { + type.name + } - /// Formatted value with appropriate unit suffix - public var formattedValue: String { - switch (type, value) { - case (.voltage, .float(let v)): return "\(v.formatted(.number.precision(.fractionLength(3)))) V" - case (.temperature, .float(let t)): - let v = type.convertedValue(t) - return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" - case (.humidity, .float(let h)): return "\(h.formatted(.number.precision(.fractionLength(1))))%" - case (.barometer, .float(let p)): - let v = type.convertedValue(p) - return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" - case (.illuminance, .integer(let i)): return "\(i) lux" - case (.percentage, .integer(let p)): return "\(p)%" - case (.current, .float(let c)): return "\(c.formatted(.number.precision(.fractionLength(3)))) A" - case (.power, .float(let p)): return "\(p.formatted(.number.precision(.fractionLength(1)))) W" - case (.frequency, .float(let f)): return "\(f.formatted(.number.precision(.fractionLength(1)))) Hz" - case (.altitude, .float(let a)): - let v = type.convertedValue(a) - return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" - case (.distance, .float(let d)): - let v = type.convertedValue(d) - return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" - case (.energy, .float(let e)): return "\(e.formatted(.number.precision(.fractionLength(3)))) kWh" - case (.direction, .float(let d)): return "\(d.formatted(.number.precision(.fractionLength(0))))\u{00B0}" - case (_, .digital(let b)): return b ? "On" : "Off" - case (_, .integer(let i)): return "\(i)" - case (_, .float(let f)): return f.formatted(.number.precision(.fractionLength(3))) - case (_, .vector3(let x, let y, let z)): - return "(\(x.formatted(.number.precision(.fractionLength(2)))), \(y.formatted(.number.precision(.fractionLength(2)))), \(z.formatted(.number.precision(.fractionLength(2)))))" - case (_, .gps(let lat, let lon, let alt)): - return "\(lat.formatted(.number.precision(.fractionLength(5)))), \(lon.formatted(.number.precision(.fractionLength(5)))) @ \(alt.formatted(.number.precision(.fractionLength(1))))m" - case (_, .rgb(let r, let g, let b)): - return "RGB(\(r), \(g), \(b))" - case (_, .timestamp(let date)): - return date.formatted(date: .abbreviated, time: .shortened) - } + /// Formatted value with appropriate unit suffix + var formattedValue: String { + switch (type, value) { + case let (.voltage, .float(v)): return "\(v.formatted(.number.precision(.fractionLength(3)))) V" + case let (.temperature, .float(t)): + let v = type.convertedValue(t) + return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" + case let (.humidity, .float(h)): return "\(h.formatted(.number.precision(.fractionLength(1))))%" + case let (.barometer, .float(p)): + let v = type.convertedValue(p) + return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" + case let (.illuminance, .integer(i)): return "\(i) lux" + case let (.percentage, .integer(p)): return "\(p)%" + case let (.current, .float(c)): return "\(c.formatted(.number.precision(.fractionLength(3)))) A" + case let (.power, .float(p)): return "\(p.formatted(.number.precision(.fractionLength(1)))) W" + case let (.frequency, .float(f)): return "\(f.formatted(.number.precision(.fractionLength(1)))) Hz" + case let (.altitude, .float(a)): + let v = type.convertedValue(a) + return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" + case let (.distance, .float(d)): + let v = type.convertedValue(d) + return "\(v.formatted(.number.precision(.fractionLength(type.convertedFractionLength)))) \(type.localizedUnitSymbol)" + case let (.energy, .float(e)): return "\(e.formatted(.number.precision(.fractionLength(3)))) kWh" + case let (.direction, .float(d)): return "\(d.formatted(.number.precision(.fractionLength(0))))\u{00B0}" + case let (_, .digital(b)): return b ? "On" : "Off" + case let (_, .integer(i)): return "\(i)" + case let (_, .float(f)): return f.formatted(.number.precision(.fractionLength(3))) + case let (_, .vector3(x, y, z)): + return "(\(x.formatted(.number.precision(.fractionLength(2)))), \(y.formatted(.number.precision(.fractionLength(2)))), \(z.formatted(.number.precision(.fractionLength(2)))))" + case let (_, .gps(lat, lon, alt)): + return "\(lat.formatted(.number.precision(.fractionLength(5)))), \(lon.formatted(.number.precision(.fractionLength(5)))) @ \(alt.formatted(.number.precision(.fractionLength(1))))m" + case let (_, .rgb(r, g, b)): + return "RGB(\(r), \(g), \(b))" + case let (_, .timestamp(date)): + return date.formatted(date: .abbreviated, time: .shortened) } + } - /// Estimated battery percentage based on voltage (3.0V=0%, 4.2V=100%) - /// Returns nil for non-voltage types or non-float values - public var batteryPercentage: Int? { - guard type == .voltage, case .float(let voltage) = value else { - return nil - } - - let minVoltage: Double = 3.0 - let maxVoltage: Double = 4.2 - let percentage = (voltage - minVoltage) / (maxVoltage - minVoltage) * 100 - return max(0, min(100, Int(percentage.rounded()))) + /// Estimated battery percentage based on voltage (3.0V=0%, 4.2V=100%) + /// Returns nil for non-voltage types or non-float values + var batteryPercentage: Int? { + guard type == .voltage, case let .float(voltage) = value else { + return nil } + + let minVoltage = 3.0 + let maxVoltage = 4.2 + let percentage = (voltage - minVoltage) / (maxVoltage - minVoltage) * 100 + return max(0, min(100, Int(percentage.rounded()))) + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/LPPSensorType+LocaleUnits.swift b/MC1Services/Sources/MC1Services/Extensions/LPPSensorType+LocaleUnits.swift index c7650a04..002a071f 100644 --- a/MC1Services/Sources/MC1Services/Extensions/LPPSensorType+LocaleUnits.swift +++ b/MC1Services/Sources/MC1Services/Extensions/LPPSensorType+LocaleUnits.swift @@ -1,66 +1,67 @@ import Foundation import MeshCore -extension LPPSensorType { +public extension LPPSensorType { + /// Converts an SI telemetry value to the user's locale-preferred unit. + /// Non-locale-sensitive types return the input unchanged. + func convertedValue(_ siValue: Double) -> Double { + guard let target = preferredUnit, let si = siDimension else { return siValue } + return Measurement(value: siValue, unit: si).converted(to: target).value + } - /// Converts an SI telemetry value to the user's locale-preferred unit. - /// Non-locale-sensitive types return the input unchanged. - public func convertedValue(_ siValue: Double) -> Double { - guard let target = preferredUnit, let si = siDimension else { return siValue } - return Measurement(value: siValue, unit: si).converted(to: target).value - } - - /// The locale-appropriate unit symbol (e.g. "°F" on US, "°C" on metric). - /// Falls back to the SI `unit` property for non-locale-sensitive types. - public var localizedUnitSymbol: String { - guard let target = preferredUnit else { return unit } - return target.symbol - } + /// The locale-appropriate unit symbol (e.g. "°F" on US, "°C" on metric). + /// Falls back to the SI `unit` property for non-locale-sensitive types. + var localizedUnitSymbol: String { + guard let target = preferredUnit else { return unit } + return target.symbol + } - /// Whether this type's value will be converted for the current locale. - public var isConverted: Bool { preferredUnit != nil } + /// Whether this type's value will be converted for the current locale. + var isConverted: Bool { + preferredUnit != nil + } - /// The fraction length appropriate for the converted value. - public var convertedFractionLength: Int { - guard isConverted else { - switch self { - case .temperature, .barometer, .altitude: return 1 - case .distance: return 3 - default: return 1 - } - } - switch self { - case .temperature: return 1 - case .barometer: return 2 // 29.92 inHg - case .altitude: return 0 // whole feet - case .distance: return 1 - default: return 1 - } + /// The fraction length appropriate for the converted value. + var convertedFractionLength: Int { + guard isConverted else { + switch self { + case .temperature, .barometer, .altitude: return 1 + case .distance: return 3 + default: return 1 + } + } + switch self { + case .temperature: return 1 + case .barometer: return 2 // 29.92 inHg + case .altitude: return 0 // whole feet + case .distance: return 1 + default: return 1 } + } - // MARK: - Private + // MARK: - Private - /// The locale-preferred Dimension for this sensor type, or nil if metric (no conversion needed). - private var preferredUnit: Dimension? { - guard siDimension != nil, - Locale.current.measurementSystem != .metric else { return nil } - switch self { - case .temperature: return UnitTemperature.fahrenheit - case .barometer: return UnitPressure.inchesOfMercury - case .altitude: return UnitLength.feet - case .distance: return UnitLength.feet - default: return nil - } + /// The locale-preferred Dimension for this sensor type, or nil if metric (no conversion needed). + private var preferredUnit: Dimension? { + guard siDimension != nil, + Locale.current.measurementSystem != .metric else { return nil } + switch self { + case .temperature: return UnitTemperature.fahrenheit + case .barometer: return UnitPressure.inchesOfMercury + case .altitude: return UnitLength.feet + case .distance: return UnitLength.feet + default: return nil } + } - /// Maps locale-sensitive sensor types to their Foundation SI Dimension. - private var siDimension: Dimension? { - switch self { - case .temperature: UnitTemperature.celsius - case .barometer: UnitPressure.hectopascals - case .altitude: UnitLength.meters - case .distance: UnitLength.meters - default: nil - } + /// Maps locale-sensitive sensor types to their Foundation SI Dimension. + private var siDimension: Dimension? { + switch self { + case .temperature: UnitTemperature.celsius + case .barometer: UnitPressure.hectopascals + case .altitude: UnitLength.meters + case .distance: UnitLength.meters + default: nil } + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/Locale+POSIX.swift b/MC1Services/Sources/MC1Services/Extensions/Locale+POSIX.swift index 9481b46e..d7eff896 100644 --- a/MC1Services/Sources/MC1Services/Extensions/Locale+POSIX.swift +++ b/MC1Services/Sources/MC1Services/Extensions/Locale+POSIX.swift @@ -1,7 +1,7 @@ import Foundation -extension Locale { - /// POSIX locale — always uses period as decimal separator. - /// Use for technical values like radio frequency and bandwidth. - public static let posix = Locale(identifier: "en_US_POSIX") +public extension Locale { + /// POSIX locale — always uses period as decimal separator. + /// Use for technical values like radio frequency and bandwidth. + static let posix = Locale(identifier: "en_US_POSIX") } diff --git a/MC1Services/Sources/MC1Services/Extensions/StatusResponse+Compatibility.swift b/MC1Services/Sources/MC1Services/Extensions/StatusResponse+Compatibility.swift index bd144780..4781c0de 100644 --- a/MC1Services/Sources/MC1Services/Extensions/StatusResponse+Compatibility.swift +++ b/MC1Services/Sources/MC1Services/Extensions/StatusResponse+Compatibility.swift @@ -1,18 +1,28 @@ import MeshCore -extension StatusResponse { - /// Uptime in seconds (compatibility alias) - public var uptimeSeconds: UInt32 { uptime } +public extension StatusResponse { + /// Uptime in seconds (compatibility alias) + var uptimeSeconds: UInt32 { + uptime + } - /// Battery level in millivolts (compatibility conversion) - public var batteryMillivolts: UInt16 { UInt16(clamping: battery) } + /// Battery level in millivolts (compatibility conversion) + var batteryMillivolts: UInt16 { + UInt16(clamping: battery) + } - /// Last RSSI value (compatibility alias) - public var lastRssi: Int16 { Int16(clamping: lastRSSI) } + /// Last RSSI value (compatibility alias) + var lastRssi: Int16 { + Int16(clamping: lastRSSI) + } - /// Last SNR value (compatibility conversion) - public var lastSnr: Float { Float(lastSNR) } + /// Last SNR value (compatibility conversion) + var lastSnr: Float { + Float(lastSNR) + } - /// Repeater RX airtime in seconds (compatibility alias) - public var repeaterRxAirtimeSeconds: UInt32 { rxAirtime } + /// Repeater RX airtime in seconds (compatibility alias) + var repeaterRxAirtimeSeconds: UInt32 { + rxAirtime + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/String+StableUUID.swift b/MC1Services/Sources/MC1Services/Extensions/String+StableUUID.swift index a9d6b7fe..286748ca 100644 --- a/MC1Services/Sources/MC1Services/Extensions/String+StableUUID.swift +++ b/MC1Services/Sources/MC1Services/Extensions/String+StableUUID.swift @@ -1,39 +1,39 @@ import Foundation public extension String { - /// Generates a deterministic UUID from this string using djb2 hash (non-cryptographic). - /// - /// Uses two independent hash streams (even/odd bytes) to generate 16 bytes for UUID construction. - /// The same input string always produces the same UUID output. - var stableUUID: UUID { - var hash1: UInt64 = 5381 - var hash2: UInt64 = 5381 + /// Generates a deterministic UUID from this string using djb2 hash (non-cryptographic). + /// + /// Uses two independent hash streams (even/odd bytes) to generate 16 bytes for UUID construction. + /// The same input string always produces the same UUID output. + var stableUUID: UUID { + var hash1: UInt64 = 5381 + var hash2: UInt64 = 5381 - for (index, byte) in self.utf8.enumerated() { - if index.isMultiple(of: 2) { - hash1 = ((hash1 << 5) &+ hash1) &+ UInt64(byte) - } else { - hash2 = ((hash2 << 5) &+ hash2) &+ UInt64(byte) - } - } - - return UUID(uuid: ( - UInt8(truncatingIfNeeded: hash1), - UInt8(truncatingIfNeeded: hash1 >> 8), - UInt8(truncatingIfNeeded: hash1 >> 16), - UInt8(truncatingIfNeeded: hash1 >> 24), - UInt8(truncatingIfNeeded: hash1 >> 32), - UInt8(truncatingIfNeeded: hash1 >> 40), - UInt8(truncatingIfNeeded: hash1 >> 48), - UInt8(truncatingIfNeeded: hash1 >> 56), - UInt8(truncatingIfNeeded: hash2), - UInt8(truncatingIfNeeded: hash2 >> 8), - UInt8(truncatingIfNeeded: hash2 >> 16), - UInt8(truncatingIfNeeded: hash2 >> 24), - UInt8(truncatingIfNeeded: hash2 >> 32), - UInt8(truncatingIfNeeded: hash2 >> 40), - UInt8(truncatingIfNeeded: hash2 >> 48), - UInt8(truncatingIfNeeded: hash2 >> 56) - )) + for (index, byte) in utf8.enumerated() { + if index.isMultiple(of: 2) { + hash1 = ((hash1 << 5) &+ hash1) &+ UInt64(byte) + } else { + hash2 = ((hash2 << 5) &+ hash2) &+ UInt64(byte) + } } + + return UUID(uuid: ( + UInt8(truncatingIfNeeded: hash1), + UInt8(truncatingIfNeeded: hash1 >> 8), + UInt8(truncatingIfNeeded: hash1 >> 16), + UInt8(truncatingIfNeeded: hash1 >> 24), + UInt8(truncatingIfNeeded: hash1 >> 32), + UInt8(truncatingIfNeeded: hash1 >> 40), + UInt8(truncatingIfNeeded: hash1 >> 48), + UInt8(truncatingIfNeeded: hash1 >> 56), + UInt8(truncatingIfNeeded: hash2), + UInt8(truncatingIfNeeded: hash2 >> 8), + UInt8(truncatingIfNeeded: hash2 >> 16), + UInt8(truncatingIfNeeded: hash2 >> 24), + UInt8(truncatingIfNeeded: hash2 >> 32), + UInt8(truncatingIfNeeded: hash2 >> 40), + UInt8(truncatingIfNeeded: hash2 >> 48), + UInt8(truncatingIfNeeded: hash2 >> 56) + )) + } } diff --git a/MC1Services/Sources/MC1Services/Extensions/TelemetryResponse+DataPoints.swift b/MC1Services/Sources/MC1Services/Extensions/TelemetryResponse+DataPoints.swift index 3626e73d..cd3191cb 100644 --- a/MC1Services/Sources/MC1Services/Extensions/TelemetryResponse+DataPoints.swift +++ b/MC1Services/Sources/MC1Services/Extensions/TelemetryResponse+DataPoints.swift @@ -1,9 +1,9 @@ import MeshCore -extension TelemetryResponse { - /// Decoded LPP data points from the raw telemetry data. - /// Uses MeshCore's LPPDecoder to parse the raw bytes into structured sensor values. - public var dataPoints: [LPPDataPoint] { - LPPDecoder.decode(rawData) - } +public extension TelemetryResponse { + /// Decoded LPP data points from the raw telemetry data. + /// Uses MeshCore's LPPDecoder to parse the raw bytes into structured sensor values. + var dataPoints: [LPPDataPoint] { + LPPDecoder.decode(rawData) + } } diff --git a/MC1Services/Sources/MC1Services/MC1Services.swift b/MC1Services/Sources/MC1Services/MC1Services.swift index a8ada12d..c9651f21 100644 --- a/MC1Services/Sources/MC1Services/MC1Services.swift +++ b/MC1Services/Sources/MC1Services/MC1Services.swift @@ -10,7 +10,7 @@ /// - Notification, Keychain, and AccessorySetupKit services /// - High-level service layer (ContactService, MessageService, ChannelService) enum MC1ServicesVersion { - static let version = "0.1.0" + static let version = "0.1.0" } // MARK: - Type Aliases diff --git a/MC1Services/Sources/MC1Services/Models/AutoAddMode.swift b/MC1Services/Sources/MC1Services/Models/AutoAddMode.swift index a40f6493..d10b7b63 100644 --- a/MC1Services/Sources/MC1Services/Models/AutoAddMode.swift +++ b/MC1Services/Sources/MC1Services/Models/AutoAddMode.swift @@ -3,37 +3,37 @@ import MeshCore /// Represents the auto-add behavior for discovered nodes. public enum AutoAddMode: String, Codable, Sendable, CaseIterable { - /// Review all nodes in Discover before adding. - case manual - /// Auto-add only the types enabled in settings. - case selectedTypes - /// Auto-add every discovered node. - case all + /// Review all nodes in Discover before adding. + case manual + /// Auto-add only the types enabled in settings. + case selectedTypes + /// Auto-add every discovered node. + case all - /// Bitmask covering all auto-add type bits. - private static let typeBitsMask: UInt8 = AutoAddConfig.contactsBit - | AutoAddConfig.repeatersBit - | AutoAddConfig.roomServersBit - | AutoAddConfig.sensorsBit + /// Bitmask covering all auto-add type bits. + private static let typeBitsMask: UInt8 = AutoAddConfig.contactsBit + | AutoAddConfig.repeatersBit + | AutoAddConfig.roomServersBit + | AutoAddConfig.sensorsBit - /// Computes the auto-add mode from device settings. - /// - /// Protocol mapping: - /// - `manualAddContacts=false` → `.all` (firmware auto-adds everything) - /// - `manualAddContacts=true` + no type bits → `.manual` (user reviews all in Discover) - /// - `manualAddContacts=true` + type bits set → `.selectedTypes` (firmware auto-adds selected types) - /// - /// - Parameters: - /// - manualAddContacts: Whether manual add mode is enabled on the device - /// - autoAddConfig: The bitmask of enabled auto-add types - /// - Returns: The computed auto-add mode - public static func mode(manualAddContacts: Bool, autoAddConfig: UInt8) -> AutoAddMode { - if !manualAddContacts { - return .all // Firmware auto-adds everything - } else if autoAddConfig & typeBitsMask == 0 { - return .manual // No type bits = review all in Discover - } else { - return .selectedTypes // Type bits set = auto-add those types - } + /// Computes the auto-add mode from device settings. + /// + /// Protocol mapping: + /// - `manualAddContacts=false` → `.all` (firmware auto-adds everything) + /// - `manualAddContacts=true` + no type bits → `.manual` (user reviews all in Discover) + /// - `manualAddContacts=true` + type bits set → `.selectedTypes` (firmware auto-adds selected types) + /// + /// - Parameters: + /// - manualAddContacts: Whether manual add mode is enabled on the device + /// - autoAddConfig: The bitmask of enabled auto-add types + /// - Returns: The computed auto-add mode + public static func mode(manualAddContacts: Bool, autoAddConfig: UInt8) -> AutoAddMode { + if !manualAddContacts { + .all // Firmware auto-adds everything + } else if autoAddConfig & typeBitsMask == 0 { + .manual // No type bits = review all in Discover + } else { + .selectedTypes // Type bits set = auto-add those types } + } } diff --git a/MC1Services/Sources/MC1Services/Models/BlockedChannelSender.swift b/MC1Services/Sources/MC1Services/Models/BlockedChannelSender.swift index b1465d66..cbc20e94 100644 --- a/MC1Services/Sources/MC1Services/Models/BlockedChannelSender.swift +++ b/MC1Services/Sources/MC1Services/Models/BlockedChannelSender.swift @@ -5,64 +5,64 @@ import SwiftData /// Channel messages don't include sender keys, so blocking is name-based only. @Model public final class BlockedChannelSender { - #Index([\.radioID, \.name]) + #Index([\.radioID, \.name]) - @Attribute(.unique) - public var id: UUID + @Attribute(.unique) + public var id: UUID - /// The sender name to block (matched exactly as stored) - public var name: String + /// The sender name to block (matched exactly as stored) + public var name: String - /// Which device this block applies to - @Attribute(originalName: "deviceID") - public var radioID: UUID + /// Which device this block applies to + @Attribute(originalName: "deviceID") + public var radioID: UUID - /// When the user blocked this name - public var dateBlocked: Date + /// When the user blocked this name + public var dateBlocked: Date - public init( - id: UUID = UUID(), - name: String, - radioID: UUID, - dateBlocked: Date = .now - ) { - self.id = id - self.name = name - self.radioID = radioID - self.dateBlocked = dateBlocked - } + public init( + id: UUID = UUID(), + name: String, + radioID: UUID, + dateBlocked: Date = .now + ) { + self.id = id + self.name = name + self.radioID = radioID + self.dateBlocked = dateBlocked + } - /// Builds a model instance directly from a DTO. - public convenience init(dto: BlockedChannelSenderDTO) { - self.init(id: dto.id, name: dto.name, radioID: dto.radioID, dateBlocked: dto.dateBlocked) - } + /// Builds a model instance directly from a DTO. + public convenience init(dto: BlockedChannelSenderDTO) { + self.init(id: dto.id, name: dto.name, radioID: dto.radioID, dateBlocked: dto.dateBlocked) + } } // MARK: - Sendable DTO /// A sendable snapshot of BlockedChannelSender for cross-actor transfers. public struct BlockedChannelSenderDTO: Sendable, Equatable, Identifiable, Codable { - public let id: UUID - public let name: String - public var radioID: UUID - public let dateBlocked: Date + public let id: UUID + public let name: String + public var radioID: UUID + public let dateBlocked: Date - public init( - id: UUID = UUID(), - name: String, - radioID: UUID, - dateBlocked: Date = .now - ) { - self.id = id - self.name = name - self.radioID = radioID - self.dateBlocked = dateBlocked - } + public init( + id: UUID = UUID(), + name: String, + radioID: UUID, + dateBlocked: Date = .now + ) { + self.id = id + self.name = name + self.radioID = radioID + self.dateBlocked = dateBlocked + } - public init(from model: BlockedChannelSender) { - self.id = model.id - self.name = model.name - self.radioID = model.radioID - self.dateBlocked = model.dateBlocked - } + public init(from model: BlockedChannelSender) { + id = model.id + name = model.name + radioID = model.radioID + dateBlocked = model.dateBlocked + } } diff --git a/MC1Services/Sources/MC1Services/Models/Channel.swift b/MC1Services/Sources/MC1Services/Models/Channel.swift index 747c4134..3cca331e 100644 --- a/MC1Services/Sources/MC1Services/Models/Channel.swift +++ b/MC1Services/Sources/MC1Services/Models/Channel.swift @@ -5,481 +5,483 @@ import SwiftData /// Max number of channels depends on the device, with slot 0 being the public channel. @Model public final class Channel { - #Index( - [\.radioID], - [\.radioID, \.index] - ) - - /// Unique identifier - @Attribute(.unique) - public var id: UUID - - /// The device this channel belongs to - @Attribute(originalName: "deviceID") - public var radioID: UUID - - /// Channel slot index - public var index: UInt8 - - /// Channel name - public var name: String - - /// Channel secret (16 bytes, SHA-256 hashed from passphrase) - public var secret: Data - - /// Whether this channel is enabled/active - public var isEnabled: Bool - - /// Last message timestamp for this channel - public var lastMessageDate: Date? - - /// Unread message count - public var unreadCount: Int - - /// Unread mention count (mentions of current user not yet seen) - public var unreadMentionCount: Int = 0 - - /// Notification level for this channel (stored as raw value for SwiftData). - /// Default is -1 (unmigrated) to enable migration from legacy isMuted property. - public var notificationLevelRawValue: Int = -1 - - /// Legacy isMuted property from V1 schema (maps to old "isMuted" column). - /// Used for one-time migration to notificationLevelRawValue. - @Attribute(originalName: "isMuted") - public var legacyIsMuted: Bool? - - /// Notification level computed property with automatic migration from legacy isMuted - public var notificationLevel: NotificationLevel { - get { - // Check if migration is needed - if notificationLevelRawValue == -1 { - // Migrate from legacy isMuted - let migratedLevel: NotificationLevel = (legacyIsMuted == true) ? .muted : .all - notificationLevelRawValue = migratedLevel.rawValue - return migratedLevel - } - return NotificationLevel(rawValue: notificationLevelRawValue) ?? .all - } - set { notificationLevelRawValue = newValue.rawValue } - } - - /// Whether this channel is marked as favorite - public var isFavorite: Bool = false - - /// The named region override for this channel, when `floodScopeModeRawValue == .specific`. - /// Meaningful only in `.specific` mode; otherwise should be nil. - public var regionScope: String? - - /// Raw flood-scope-mode value ("inherit" / "allRegions" / "specific"). - /// Default "inherit" means: apply the device-level default flood scope at send time. - /// Access through ``floodScope`` for type-safe reads/writes. - public var floodScopeModeRawValue: String = ChannelFloodScopeStorage.Mode.inherit.rawValue - - private init( - id: UUID, - radioID: UUID, - index: UInt8, - name: String, - secret: Data, - isEnabled: Bool, - lastMessageDate: Date?, - unreadCount: Int, - unreadMentionCount: Int, - notificationLevelRawValue: Int, - isFavorite: Bool, - floodScopeModeRawValue: String, - regionScope: String? - ) { - self.id = id - self.radioID = radioID - self.index = index - self.name = name - self.secret = secret - self.isEnabled = isEnabled - self.lastMessageDate = lastMessageDate - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - self.notificationLevelRawValue = notificationLevelRawValue - self.isFavorite = isFavorite - self.floodScopeModeRawValue = floodScopeModeRawValue - self.regionScope = regionScope - } - - public convenience init( - id: UUID = UUID(), - radioID: UUID, - index: UInt8, - name: String, - secret: Data = Data(repeating: 0, count: 16), - isEnabled: Bool = true, - lastMessageDate: Date? = nil, - unreadCount: Int = 0, - unreadMentionCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - floodScope: ChannelFloodScope = .inherit - ) { - let storage = ChannelFloodScopeStorage.decompose(floodScope) - self.init( - id: id, - radioID: radioID, - index: index, - name: name, - secret: secret, - isEnabled: isEnabled, - lastMessageDate: lastMessageDate, - unreadCount: unreadCount, - unreadMentionCount: unreadMentionCount, - notificationLevelRawValue: notificationLevel.rawValue, - isFavorite: isFavorite, - floodScopeModeRawValue: storage.mode.rawValue, - regionScope: storage.regionName - ) - } - - /// Builds a model instance directly from a DTO. Shared by `saveChannel` and - /// backup batch-insert paths so they can't drift on field coverage. - /// Forwards raw flood-scope storage fields verbatim so pre-migration rows - /// survive without passing through the normalizing ``ChannelFloodScope`` accessor. - public convenience init(dto: ChannelDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - index: dto.index, - name: dto.name, - secret: dto.secret, - isEnabled: dto.isEnabled, - lastMessageDate: dto.lastMessageDate, - unreadCount: dto.unreadCount, - unreadMentionCount: dto.unreadMentionCount, - notificationLevelRawValue: dto.notificationLevel.rawValue, - isFavorite: dto.isFavorite, - floodScopeModeRawValue: dto.floodScopeModeRawValue, - regionScope: dto.regionScope - ) - } - - /// Applies all mutable fields from a DTO to this model instance. - /// Copies raw flood-scope storage fields verbatim; see ``init(dto:)``. - /// `radioID` and `index` are identity and stay frozen: this runs in the - /// id-matched `saveChannel(_:)` upsert, which owns app-side metadata. Slot - /// changes come from the radio keyed by `(radioID, index)`, and backup - /// relocation inserts a fresh row via ``init(dto:)``. - func apply(_ dto: ChannelDTO) { - name = dto.name - secret = dto.secret - isEnabled = dto.isEnabled - lastMessageDate = dto.lastMessageDate - unreadCount = dto.unreadCount - unreadMentionCount = dto.unreadMentionCount - notificationLevel = dto.notificationLevel - isFavorite = dto.isFavorite - floodScopeModeRawValue = dto.floodScopeModeRawValue - regionScope = dto.regionScope - } - - /// Creates a Channel from a protocol ChannelInfo - public convenience init(radioID: UUID, from info: ChannelInfo) { - self.init( - radioID: radioID, - index: info.index, - name: info.name, - secret: info.secret - ) + #Index( + [\.radioID], + [\.radioID, \.index] + ) + + /// Unique identifier + @Attribute(.unique) + public var id: UUID + + /// The device this channel belongs to + @Attribute(originalName: "deviceID") + public var radioID: UUID + + /// Channel slot index + public var index: UInt8 + + /// Channel name + public var name: String + + /// Channel secret (16 bytes, SHA-256 hashed from passphrase) + public var secret: Data + + /// Whether this channel is enabled/active + public var isEnabled: Bool + + /// Last message timestamp for this channel + public var lastMessageDate: Date? + + /// Unread message count + public var unreadCount: Int + + /// Unread mention count (mentions of current user not yet seen) + public var unreadMentionCount: Int = 0 + + /// Notification level for this channel (stored as raw value for SwiftData). + /// Default is -1 (unmigrated) to enable migration from legacy isMuted property. + public var notificationLevelRawValue: Int = -1 + + /// Legacy isMuted property from V1 schema (maps to old "isMuted" column). + /// Used for one-time migration to notificationLevelRawValue. + @Attribute(originalName: "isMuted") + public var legacyIsMuted: Bool? + + /// Notification level computed property with automatic migration from legacy isMuted + public var notificationLevel: NotificationLevel { + get { + // Check if migration is needed + if notificationLevelRawValue == -1 { + // Migrate from legacy isMuted + let migratedLevel: NotificationLevel = (legacyIsMuted == true) ? .muted : .all + notificationLevelRawValue = migratedLevel.rawValue + return migratedLevel + } + return NotificationLevel(rawValue: notificationLevelRawValue) ?? .all } + set { notificationLevelRawValue = newValue.rawValue } + } + + /// Whether this channel is marked as favorite + public var isFavorite: Bool = false + + /// The named region override for this channel, when `floodScopeModeRawValue == .specific`. + /// Meaningful only in `.specific` mode; otherwise should be nil. + public var regionScope: String? + + /// Raw flood-scope-mode value ("inherit" / "allRegions" / "specific"). + /// Default "inherit" means: apply the device-level default flood scope at send time. + /// Access through ``floodScope`` for type-safe reads/writes. + public var floodScopeModeRawValue: String = ChannelFloodScopeStorage.Mode.inherit.rawValue + + private init( + id: UUID, + radioID: UUID, + index: UInt8, + name: String, + secret: Data, + isEnabled: Bool, + lastMessageDate: Date?, + unreadCount: Int, + unreadMentionCount: Int, + notificationLevelRawValue: Int, + isFavorite: Bool, + floodScopeModeRawValue: String, + regionScope: String? + ) { + self.id = id + self.radioID = radioID + self.index = index + self.name = name + self.secret = secret + self.isEnabled = isEnabled + self.lastMessageDate = lastMessageDate + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.notificationLevelRawValue = notificationLevelRawValue + self.isFavorite = isFavorite + self.floodScopeModeRawValue = floodScopeModeRawValue + self.regionScope = regionScope + } + + public convenience init( + id: UUID = UUID(), + radioID: UUID, + index: UInt8, + name: String, + secret: Data = Data(repeating: 0, count: 16), + isEnabled: Bool = true, + lastMessageDate: Date? = nil, + unreadCount: Int = 0, + unreadMentionCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + floodScope: ChannelFloodScope = .inherit + ) { + let storage = ChannelFloodScopeStorage.decompose(floodScope) + self.init( + id: id, + radioID: radioID, + index: index, + name: name, + secret: secret, + isEnabled: isEnabled, + lastMessageDate: lastMessageDate, + unreadCount: unreadCount, + unreadMentionCount: unreadMentionCount, + notificationLevelRawValue: notificationLevel.rawValue, + isFavorite: isFavorite, + floodScopeModeRawValue: storage.mode.rawValue, + regionScope: storage.regionName + ) + } + + /// Builds a model instance directly from a DTO. Shared by `saveChannel` and + /// backup batch-insert paths so they can't drift on field coverage. + /// Forwards raw flood-scope storage fields verbatim so pre-migration rows + /// survive without passing through the normalizing ``ChannelFloodScope`` accessor. + public convenience init(dto: ChannelDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + index: dto.index, + name: dto.name, + secret: dto.secret, + isEnabled: dto.isEnabled, + lastMessageDate: dto.lastMessageDate, + unreadCount: dto.unreadCount, + unreadMentionCount: dto.unreadMentionCount, + notificationLevelRawValue: dto.notificationLevel.rawValue, + isFavorite: dto.isFavorite, + floodScopeModeRawValue: dto.floodScopeModeRawValue, + regionScope: dto.regionScope + ) + } + + /// Applies all mutable fields from a DTO to this model instance. + /// Copies raw flood-scope storage fields verbatim; see ``init(dto:)``. + /// `radioID` and `index` are identity and stay frozen: this runs in the + /// id-matched `saveChannel(_:)` upsert, which owns app-side metadata. Slot + /// changes come from the radio keyed by `(radioID, index)`, and backup + /// relocation inserts a fresh row via ``init(dto:)``. + func apply(_ dto: ChannelDTO) { + name = dto.name + secret = dto.secret + isEnabled = dto.isEnabled + lastMessageDate = dto.lastMessageDate + unreadCount = dto.unreadCount + unreadMentionCount = dto.unreadMentionCount + notificationLevel = dto.notificationLevel + isFavorite = dto.isFavorite + floodScopeModeRawValue = dto.floodScopeModeRawValue + regionScope = dto.regionScope + } + + /// Creates a Channel from a protocol ChannelInfo + public convenience init(radioID: UUID, from info: ChannelInfo) { + self.init( + radioID: radioID, + index: info.index, + name: info.name, + secret: info.secret + ) + } } // MARK: - Computed Properties public extension Channel { - /// Type-safe accessor for the per-channel flood scope preference. - var floodScope: ChannelFloodScope { - get { ChannelFloodScopeStorage.recompose(modeRawValue: floodScopeModeRawValue, regionName: regionScope) } - set { - let storage = ChannelFloodScopeStorage.decompose(newValue) - floodScopeModeRawValue = storage.mode.rawValue - regionScope = storage.regionName - } - } - - /// Whether this is the public channel (slot 0) - var isPublicChannel: Bool { - index == 0 - } - - /// Whether this channel has a non-empty secret - var hasSecret: Bool { - !secret.allSatisfy { $0 == 0 } - } - - /// Whether this channel uses meaningful encryption (private channels only). - /// Public channels (index 0) and hashtag channels use publicly-derivable keys. - var isEncryptedChannel: Bool { - !isPublicChannel && !name.hasPrefix("#") - } - - /// Updates from a protocol ChannelInfo - func update(from info: ChannelInfo) { - self.name = info.name - self.secret = info.secret - } - - /// Converts to a protocol ChannelInfo - func toChannelInfo() -> ChannelInfo { - ChannelInfo(index: index, name: name, secret: secret) + /// Type-safe accessor for the per-channel flood scope preference. + var floodScope: ChannelFloodScope { + get { ChannelFloodScopeStorage.recompose(modeRawValue: floodScopeModeRawValue, regionName: regionScope) } + set { + let storage = ChannelFloodScopeStorage.decompose(newValue) + floodScopeModeRawValue = storage.mode.rawValue + regionScope = storage.regionName } + } + + /// Whether this is the public channel (slot 0) + var isPublicChannel: Bool { + index == 0 + } + + /// Whether this channel has a non-empty secret + var hasSecret: Bool { + !secret.allSatisfy { $0 == 0 } + } + + /// Whether this channel uses meaningful encryption (private channels only). + /// Public channels (index 0) and hashtag channels use publicly-derivable keys. + var isEncryptedChannel: Bool { + !isPublicChannel && !name.hasPrefix("#") + } + + /// Updates from a protocol ChannelInfo + func update(from info: ChannelInfo) { + name = info.name + secret = info.secret + } + + /// Converts to a protocol ChannelInfo + func toChannelInfo() -> ChannelInfo { + ChannelInfo(index: index, name: name, secret: secret) + } } // MARK: - Sendable DTO /// A sendable snapshot of Channel for cross-actor transfers public struct ChannelDTO: Sendable, Equatable, Identifiable, Hashable, Codable { - public let id: UUID - public var radioID: UUID - public let index: UInt8 - public let name: String - public let secret: Data - public let isEnabled: Bool - public let lastMessageDate: Date? - public let unreadCount: Int - public let unreadMentionCount: Int - public let notificationLevel: NotificationLevel - public let isFavorite: Bool - public let floodScopeModeRawValue: String - public let regionScope: String? - - /// Convenience property for checking if muted - public var isMuted: Bool { notificationLevel == .muted } - - /// Type-safe accessor for the per-channel flood scope preference. - public var floodScope: ChannelFloodScope { - ChannelFloodScopeStorage.recompose(modeRawValue: floodScopeModeRawValue, regionName: regionScope) - } - - // Explicit Codable so backups predating ``floodScopeModeRawValue`` decode cleanly. - // Legacy envelopes: missing key + non-nil `regionScope` → `.specific`; missing key + - // nil `regionScope` → `.inherit` (matches the corrective post-migration semantics). - private enum CodingKeys: String, CodingKey { - case id, radioID, index, name, secret, isEnabled, lastMessageDate, - unreadCount, unreadMentionCount, notificationLevel, isFavorite, - floodScopeModeRawValue, regionScope - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(UUID.self, forKey: .id) - self.radioID = try container.decode(UUID.self, forKey: .radioID) - self.index = try container.decode(UInt8.self, forKey: .index) - self.name = try container.decode(String.self, forKey: .name) - self.secret = try container.decode(Data.self, forKey: .secret) - self.isEnabled = try container.decode(Bool.self, forKey: .isEnabled) - self.lastMessageDate = try container.decodeIfPresent(Date.self, forKey: .lastMessageDate) - self.unreadCount = try container.decode(Int.self, forKey: .unreadCount) - self.unreadMentionCount = try container.decodeIfPresent(Int.self, forKey: .unreadMentionCount) ?? 0 - self.notificationLevel = try container.decodeIfPresent(NotificationLevel.self, forKey: .notificationLevel) ?? .all - self.isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false - let region = try container.decodeIfPresent(String.self, forKey: .regionScope) - self.regionScope = region - if let raw = try container.decodeIfPresent(String.self, forKey: .floodScopeModeRawValue) { - self.floodScopeModeRawValue = raw - } else { - let mode: ChannelFloodScopeStorage.Mode = (region != nil) ? .specific : .inherit - self.floodScopeModeRawValue = mode.rawValue - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(radioID, forKey: .radioID) - try container.encode(index, forKey: .index) - try container.encode(name, forKey: .name) - try container.encode(secret, forKey: .secret) - try container.encode(isEnabled, forKey: .isEnabled) - try container.encodeIfPresent(lastMessageDate, forKey: .lastMessageDate) - try container.encode(unreadCount, forKey: .unreadCount) - try container.encode(unreadMentionCount, forKey: .unreadMentionCount) - try container.encode(notificationLevel, forKey: .notificationLevel) - try container.encode(isFavorite, forKey: .isFavorite) - try container.encode(floodScopeModeRawValue, forKey: .floodScopeModeRawValue) - try container.encodeIfPresent(regionScope, forKey: .regionScope) - } - - public init(from channel: Channel) { - self.id = channel.id - self.radioID = channel.radioID - self.index = channel.index - self.name = channel.name - self.secret = channel.secret - self.isEnabled = channel.isEnabled - self.lastMessageDate = channel.lastMessageDate - self.unreadCount = channel.unreadCount - self.unreadMentionCount = channel.unreadMentionCount - // Decode the level without invoking the migrating getter's in-memory write-back, keeping - // export a pure read. An unmigrated -1 sentinel maps to its migrated value (muted if the - // legacy isMuted flag was set, else all) exactly as the getter would — but without - // dirtying the live row. The -1 sentinel itself is never put on the wire (the DTO carries - // a NotificationLevel, not the raw column), which is intended. - self.notificationLevel = NotificationLevel(rawValue: channel.notificationLevelRawValue) - ?? ((channel.legacyIsMuted == true) ? .muted : .all) - self.isFavorite = channel.isFavorite - self.floodScopeModeRawValue = channel.floodScopeModeRawValue - self.regionScope = channel.regionScope - } - - /// Memberwise initializer for creating DTOs directly - public init( - id: UUID, - radioID: UUID, - index: UInt8, - name: String, - secret: Data, - isEnabled: Bool, - lastMessageDate: Date?, - unreadCount: Int, - unreadMentionCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - floodScope: ChannelFloodScope = .inherit - ) { - self.id = id - self.radioID = radioID - self.index = index - self.name = name - self.secret = secret - self.isEnabled = isEnabled - self.lastMessageDate = lastMessageDate - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - self.notificationLevel = notificationLevel - self.isFavorite = isFavorite - let storage = ChannelFloodScopeStorage.decompose(floodScope) - self.floodScopeModeRawValue = storage.mode.rawValue - self.regionScope = storage.regionName - } - - /// Returns a copy with only `notificationLevel` changed. - public func with(notificationLevel: NotificationLevel) -> ChannelDTO { - ChannelDTO( - id: id, radioID: radioID, index: index, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScope: floodScope - ) - } - - /// Returns a copy with only `isFavorite` changed. - public func with(isFavorite: Bool) -> ChannelDTO { - ChannelDTO( - id: id, radioID: radioID, index: index, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScope: floodScope - ) - } - - /// Returns a copy with only the flood-scope preference changed. - public func with(floodScope: ChannelFloodScope) -> ChannelDTO { - ChannelDTO( - id: id, radioID: radioID, index: index, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScope: floodScope - ) - } - - /// Returns a copy placed at a different slot `index`, forwarding every other raw - /// storage field verbatim. Used by backup import to relocate a channel whose secret - /// has no local match onto a free slot without routing through the normalizing - /// ``ChannelFloodScope`` accessor (preserving pre-migration flood-scope rows). - func with(index newIndex: UInt8) -> ChannelDTO { - ChannelDTO( - id: id, radioID: radioID, index: newIndex, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScopeModeRawValue: floodScopeModeRawValue, - regionScope: regionScope - ) - } - - /// Returns a copy with a fresh surrogate `id`, forwarding every other raw storage - /// field verbatim. Used by backup import so a relocated/non-matching channel can never - /// upsert a live local channel that happens to share the backup's `@Attribute(.unique)` - /// id. Channel has no inbound foreign key, so re-issuing the id breaks no linkage; it - /// uses the raw initializer to preserve pre-migration flood-scope rows (see ``init(dto:)``). - func with(id newID: UUID) -> ChannelDTO { - ChannelDTO( - id: newID, radioID: radioID, index: index, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScopeModeRawValue: floodScopeModeRawValue, - regionScope: regionScope - ) - } - - /// Returns a copy with only the raw `regionScope` column changed. This is the - /// low-level bypass used for tests that must simulate malformed on-disk state; - /// production code should go through ``with(floodScope:)``. - func with(regionScope: String?) -> ChannelDTO { - ChannelDTO( - id: id, radioID: radioID, index: index, name: name, - secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite, - floodScopeModeRawValue: floodScopeModeRawValue, - regionScope: regionScope - ) - } - - /// Low-level init that sets both raw storage fields directly. Kept `internal` - /// to prevent callers from constructing invalid combinations; used by - /// ``with(regionScope:)`` and migration tests. - init( - id: UUID, - radioID: UUID, - index: UInt8, - name: String, - secret: Data, - isEnabled: Bool, - lastMessageDate: Date?, - unreadCount: Int, - unreadMentionCount: Int, - notificationLevel: NotificationLevel, - isFavorite: Bool, - floodScopeModeRawValue: String, - regionScope: String? - ) { - self.id = id - self.radioID = radioID - self.index = index - self.name = name - self.secret = secret - self.isEnabled = isEnabled - self.lastMessageDate = lastMessageDate - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - self.notificationLevel = notificationLevel - self.isFavorite = isFavorite - self.floodScopeModeRawValue = floodScopeModeRawValue - self.regionScope = regionScope - } - - public var isPublicChannel: Bool { - index == 0 - } - - public var hasSecret: Bool { - !secret.allSatisfy { $0 == 0 } - } - - /// Whether this channel uses meaningful encryption (private channels only). - /// Public channels (index 0) and hashtag channels use publicly-derivable keys. - public var isEncryptedChannel: Bool { - !isPublicChannel && !name.hasPrefix("#") + public let id: UUID + public var radioID: UUID + public let index: UInt8 + public let name: String + public let secret: Data + public let isEnabled: Bool + public let lastMessageDate: Date? + public let unreadCount: Int + public let unreadMentionCount: Int + public let notificationLevel: NotificationLevel + public let isFavorite: Bool + public let floodScopeModeRawValue: String + public let regionScope: String? + + /// Convenience property for checking if muted + public var isMuted: Bool { + notificationLevel == .muted + } + + /// Type-safe accessor for the per-channel flood scope preference. + public var floodScope: ChannelFloodScope { + ChannelFloodScopeStorage.recompose(modeRawValue: floodScopeModeRawValue, regionName: regionScope) + } + + /// Explicit Codable so backups predating ``floodScopeModeRawValue`` decode cleanly. + /// Legacy envelopes: missing key + non-nil `regionScope` → `.specific`; missing key + + /// nil `regionScope` → `.inherit` (matches the corrective post-migration semantics). + private enum CodingKeys: String, CodingKey { + case id, radioID, index, name, secret, isEnabled, lastMessageDate, + unreadCount, unreadMentionCount, notificationLevel, isFavorite, + floodScopeModeRawValue, regionScope + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + radioID = try container.decode(UUID.self, forKey: .radioID) + index = try container.decode(UInt8.self, forKey: .index) + name = try container.decode(String.self, forKey: .name) + secret = try container.decode(Data.self, forKey: .secret) + isEnabled = try container.decode(Bool.self, forKey: .isEnabled) + lastMessageDate = try container.decodeIfPresent(Date.self, forKey: .lastMessageDate) + unreadCount = try container.decode(Int.self, forKey: .unreadCount) + unreadMentionCount = try container.decodeIfPresent(Int.self, forKey: .unreadMentionCount) ?? 0 + notificationLevel = try container.decodeIfPresent(NotificationLevel.self, forKey: .notificationLevel) ?? .all + isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false + let region = try container.decodeIfPresent(String.self, forKey: .regionScope) + regionScope = region + if let raw = try container.decodeIfPresent(String.self, forKey: .floodScopeModeRawValue) { + floodScopeModeRawValue = raw + } else { + let mode: ChannelFloodScopeStorage.Mode = (region != nil) ? .specific : .inherit + floodScopeModeRawValue = mode.rawValue } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(radioID, forKey: .radioID) + try container.encode(index, forKey: .index) + try container.encode(name, forKey: .name) + try container.encode(secret, forKey: .secret) + try container.encode(isEnabled, forKey: .isEnabled) + try container.encodeIfPresent(lastMessageDate, forKey: .lastMessageDate) + try container.encode(unreadCount, forKey: .unreadCount) + try container.encode(unreadMentionCount, forKey: .unreadMentionCount) + try container.encode(notificationLevel, forKey: .notificationLevel) + try container.encode(isFavorite, forKey: .isFavorite) + try container.encode(floodScopeModeRawValue, forKey: .floodScopeModeRawValue) + try container.encodeIfPresent(regionScope, forKey: .regionScope) + } + + public init(from channel: Channel) { + id = channel.id + radioID = channel.radioID + index = channel.index + name = channel.name + secret = channel.secret + isEnabled = channel.isEnabled + lastMessageDate = channel.lastMessageDate + unreadCount = channel.unreadCount + unreadMentionCount = channel.unreadMentionCount + // Decode the level without invoking the migrating getter's in-memory write-back, keeping + // export a pure read. An unmigrated -1 sentinel maps to its migrated value (muted if the + // legacy isMuted flag was set, else all) exactly as the getter would — but without + // dirtying the live row. The -1 sentinel itself is never put on the wire (the DTO carries + // a NotificationLevel, not the raw column), which is intended. + notificationLevel = NotificationLevel(rawValue: channel.notificationLevelRawValue) + ?? ((channel.legacyIsMuted == true) ? .muted : .all) + isFavorite = channel.isFavorite + floodScopeModeRawValue = channel.floodScopeModeRawValue + regionScope = channel.regionScope + } + + /// Memberwise initializer for creating DTOs directly + public init( + id: UUID, + radioID: UUID, + index: UInt8, + name: String, + secret: Data, + isEnabled: Bool, + lastMessageDate: Date?, + unreadCount: Int, + unreadMentionCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + floodScope: ChannelFloodScope = .inherit + ) { + self.id = id + self.radioID = radioID + self.index = index + self.name = name + self.secret = secret + self.isEnabled = isEnabled + self.lastMessageDate = lastMessageDate + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.notificationLevel = notificationLevel + self.isFavorite = isFavorite + let storage = ChannelFloodScopeStorage.decompose(floodScope) + floodScopeModeRawValue = storage.mode.rawValue + regionScope = storage.regionName + } + + /// Returns a copy with only `notificationLevel` changed. + public func with(notificationLevel: NotificationLevel) -> ChannelDTO { + ChannelDTO( + id: id, radioID: radioID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScope: floodScope + ) + } + + /// Returns a copy with only `isFavorite` changed. + public func with(isFavorite: Bool) -> ChannelDTO { + ChannelDTO( + id: id, radioID: radioID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScope: floodScope + ) + } + + /// Returns a copy with only the flood-scope preference changed. + public func with(floodScope: ChannelFloodScope) -> ChannelDTO { + ChannelDTO( + id: id, radioID: radioID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScope: floodScope + ) + } + + /// Returns a copy placed at a different slot `index`, forwarding every other raw + /// storage field verbatim. Used by backup import to relocate a channel whose secret + /// has no local match onto a free slot without routing through the normalizing + /// ``ChannelFloodScope`` accessor (preserving pre-migration flood-scope rows). + func with(index newIndex: UInt8) -> ChannelDTO { + ChannelDTO( + id: id, radioID: radioID, index: newIndex, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScopeModeRawValue: floodScopeModeRawValue, + regionScope: regionScope + ) + } + + /// Returns a copy with a fresh surrogate `id`, forwarding every other raw storage + /// field verbatim. Used by backup import so a relocated/non-matching channel can never + /// upsert a live local channel that happens to share the backup's `@Attribute(.unique)` + /// id. Channel has no inbound foreign key, so re-issuing the id breaks no linkage; it + /// uses the raw initializer to preserve pre-migration flood-scope rows (see ``init(dto:)``). + func with(id newID: UUID) -> ChannelDTO { + ChannelDTO( + id: newID, radioID: radioID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScopeModeRawValue: floodScopeModeRawValue, + regionScope: regionScope + ) + } + + /// Returns a copy with only the raw `regionScope` column changed. This is the + /// low-level bypass used for tests that must simulate malformed on-disk state; + /// production code should go through ``with(floodScope:)``. + func with(regionScope: String?) -> ChannelDTO { + ChannelDTO( + id: id, radioID: radioID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + floodScopeModeRawValue: floodScopeModeRawValue, + regionScope: regionScope + ) + } + + /// Low-level init that sets both raw storage fields directly. Kept `internal` + /// to prevent callers from constructing invalid combinations; used by + /// ``with(regionScope:)`` and migration tests. + init( + id: UUID, + radioID: UUID, + index: UInt8, + name: String, + secret: Data, + isEnabled: Bool, + lastMessageDate: Date?, + unreadCount: Int, + unreadMentionCount: Int, + notificationLevel: NotificationLevel, + isFavorite: Bool, + floodScopeModeRawValue: String, + regionScope: String? + ) { + self.id = id + self.radioID = radioID + self.index = index + self.name = name + self.secret = secret + self.isEnabled = isEnabled + self.lastMessageDate = lastMessageDate + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.notificationLevel = notificationLevel + self.isFavorite = isFavorite + self.floodScopeModeRawValue = floodScopeModeRawValue + self.regionScope = regionScope + } + + public var isPublicChannel: Bool { + index == 0 + } + + public var hasSecret: Bool { + !secret.allSatisfy { $0 == 0 } + } + + /// Whether this channel uses meaningful encryption (private channels only). + /// Public channels (index 0) and hashtag channels use publicly-derivable keys. + public var isEncryptedChannel: Bool { + !isPublicChannel && !name.hasPrefix("#") + } } diff --git a/MC1Services/Sources/MC1Services/Models/ChannelFloodScope.swift b/MC1Services/Sources/MC1Services/Models/ChannelFloodScope.swift index 6e32a271..548eb8fe 100644 --- a/MC1Services/Sources/MC1Services/Models/ChannelFloodScope.swift +++ b/MC1Services/Sources/MC1Services/Models/ChannelFloodScope.swift @@ -5,9 +5,9 @@ import Foundation /// `allRegions` means "override the device default and broadcast to all regions"; /// `region(name)` is a per-channel override to a specific named region. public enum ChannelFloodScope: Sendable, Equatable, Hashable { - case inherit - case allRegions - case region(String) + case inherit + case allRegions + case region(String) } /// Bridges the two-field on-disk representation (`floodScopeModeRawValue: String` + @@ -15,31 +15,31 @@ public enum ChannelFloodScope: Sendable, Equatable, Hashable { /// combinations are not representable externally: if `.specific` mode is set but /// `regionName` is nil, recompose yields `.inherit` defensively. enum ChannelFloodScopeStorage { - enum Mode: String { - // Raw values are pinned so a case rename can't silently change what's - // persisted to SwiftData, written into backups, or matched by the migration predicate. - // swiftlint:disable redundant_string_enum_value - case inherit = "inherit" - case allRegions = "allRegions" - case specific = "specific" - // swiftlint:enable redundant_string_enum_value - } + enum Mode: String { + // Raw values are pinned so a case rename can't silently change what's + // persisted to SwiftData, written into backups, or matched by the migration predicate. + // swiftlint:disable redundant_string_enum_value + case inherit + case allRegions + case specific + // swiftlint:enable redundant_string_enum_value + } - static func decompose(_ scope: ChannelFloodScope) -> (mode: Mode, regionName: String?) { - switch scope { - case .inherit: return (.inherit, nil) - case .allRegions: return (.allRegions, nil) - case .region(let name): return (.specific, name) - } + static func decompose(_ scope: ChannelFloodScope) -> (mode: Mode, regionName: String?) { + switch scope { + case .inherit: (.inherit, nil) + case .allRegions: (.allRegions, nil) + case let .region(name): (.specific, name) } + } - static func recompose(modeRawValue: String, regionName: String?) -> ChannelFloodScope { - switch Mode(rawValue: modeRawValue) ?? .inherit { - case .inherit: return .inherit - case .allRegions: return .allRegions - case .specific: - guard let name = regionName, !name.isEmpty else { return .inherit } - return .region(name) - } + static func recompose(modeRawValue: String, regionName: String?) -> ChannelFloodScope { + switch Mode(rawValue: modeRawValue) ?? .inherit { + case .inherit: return .inherit + case .allRegions: return .allRegions + case .specific: + guard let name = regionName, !name.isEmpty else { return .inherit } + return .region(name) } + } } diff --git a/MC1Services/Sources/MC1Services/Models/ChannelMessageEnvelope.swift b/MC1Services/Sources/MC1Services/Models/ChannelMessageEnvelope.swift index 1ee16827..11838a76 100644 --- a/MC1Services/Sources/MC1Services/Models/ChannelMessageEnvelope.swift +++ b/MC1Services/Sources/MC1Services/Models/ChannelMessageEnvelope.swift @@ -16,26 +16,26 @@ import Foundation /// window risks tagging the message with stale identity, or indexing /// against the wrong message entirely. public struct ChannelMessageEnvelope: Sendable { - public let messageID: UUID - public let channelIndex: UInt8 - public let isResend: Bool - public let messageText: String - public let messageTimestamp: UInt32 - public let localNodeName: String? + public let messageID: UUID + public let channelIndex: UInt8 + public let isResend: Bool + public let messageText: String + public let messageTimestamp: UInt32 + public let localNodeName: String? - public init( - messageID: UUID, - channelIndex: UInt8, - isResend: Bool, - messageText: String, - messageTimestamp: UInt32, - localNodeName: String? - ) { - self.messageID = messageID - self.channelIndex = channelIndex - self.isResend = isResend - self.messageText = messageText - self.messageTimestamp = messageTimestamp - self.localNodeName = localNodeName - } + public init( + messageID: UUID, + channelIndex: UInt8, + isResend: Bool, + messageText: String, + messageTimestamp: UInt32, + localNodeName: String? + ) { + self.messageID = messageID + self.channelIndex = channelIndex + self.isResend = isResend + self.messageText = messageText + self.messageTimestamp = messageTimestamp + self.localNodeName = localNodeName + } } diff --git a/MC1Services/Sources/MC1Services/Models/ChatConversationID.swift b/MC1Services/Sources/MC1Services/Models/ChatConversationID.swift index 343b58ef..3145e83a 100644 --- a/MC1Services/Sources/MC1Services/Models/ChatConversationID.swift +++ b/MC1Services/Sources/MC1Services/Models/ChatConversationID.swift @@ -6,45 +6,45 @@ import Foundation /// transitions. The `ChatCoordinatorRegistry` keys its dictionary on /// this value. public struct ChatConversationID: Hashable, Sendable { - public let radioID: UUID - let conversation: ConversationKey + public let radioID: UUID + let conversation: ConversationKey - enum ConversationKey: Hashable, Sendable { - case dm(contactID: UUID) - case channel(channelIndex: UInt8) - } + enum ConversationKey: Hashable { + case dm(contactID: UUID) + case channel(channelIndex: UInt8) + } } public extension ChatConversationID { - static func dm(radioID: UUID, contactID: UUID) -> ChatConversationID { - ChatConversationID(radioID: radioID, conversation: .dm(contactID: contactID)) - } + static func dm(radioID: UUID, contactID: UUID) -> ChatConversationID { + ChatConversationID(radioID: radioID, conversation: .dm(contactID: contactID)) + } - static func channel(radioID: UUID, channelIndex: UInt8) -> ChatConversationID { - ChatConversationID(radioID: radioID, conversation: .channel(channelIndex: channelIndex)) - } + static func channel(radioID: UUID, channelIndex: UInt8) -> ChatConversationID { + ChatConversationID(radioID: radioID, conversation: .channel(channelIndex: channelIndex)) + } } public extension ChatConversationID { - /// Separator between the segments of a draft storage key. - private static let keySeparator = "|" - /// Segment marking a direct-message draft key. - private static let dmKeySegment = "dm" - /// Segment marking a channel draft key. - private static let channelKeySegment = "ch" + /// Separator between the segments of a draft storage key. + private static let keySeparator = "|" + /// Segment marking a direct-message draft key. + private static let dmKeySegment = "dm" + /// Segment marking a channel draft key. + private static let channelKeySegment = "ch" - /// Stable, pinned string encoding used as the `DraftStore` dictionary key. - /// Always namespaced by `radioID` so drafts never leak across radios; DMs key - /// on the contact UUID, channels on the slot index. The format is frozen by a - /// unit test — changing it silently orphans every previously persisted draft. - var draftStorageKey: String { - switch conversation { - case .dm(let contactID): - return [radioID.uuidString, Self.dmKeySegment, contactID.uuidString] - .joined(separator: Self.keySeparator) - case .channel(let channelIndex): - return [radioID.uuidString, Self.channelKeySegment, String(channelIndex)] - .joined(separator: Self.keySeparator) - } + /// Stable, pinned string encoding used as the `DraftStore` dictionary key. + /// Always namespaced by `radioID` so drafts never leak across radios; DMs key + /// on the contact UUID, channels on the slot index. The format is frozen by a + /// unit test — changing it silently orphans every previously persisted draft. + var draftStorageKey: String { + switch conversation { + case let .dm(contactID): + [radioID.uuidString, Self.dmKeySegment, contactID.uuidString] + .joined(separator: Self.keySeparator) + case let .channel(channelIndex): + [radioID.uuidString, Self.channelKeySegment, String(channelIndex)] + .joined(separator: Self.keySeparator) } + } } diff --git a/MC1Services/Sources/MC1Services/Models/ConnectionMethod.swift b/MC1Services/Sources/MC1Services/Models/ConnectionMethod.swift index ee5ce8e0..5f6983c5 100644 --- a/MC1Services/Sources/MC1Services/Models/ConnectionMethod.swift +++ b/MC1Services/Sources/MC1Services/Models/ConnectionMethod.swift @@ -2,56 +2,55 @@ import Foundation /// Represents a method for connecting to a MeshCore device. public enum ConnectionMethod: Codable, Sendable, Identifiable, Hashable { + /// Bluetooth Low Energy connection + case bluetooth(peripheralUUID: UUID, displayName: String?) - /// Bluetooth Low Energy connection - case bluetooth(peripheralUUID: UUID, displayName: String?) + /// WiFi TCP connection + case wifi(host: String, port: UInt16, displayName: String?) - /// WiFi TCP connection - case wifi(host: String, port: UInt16, displayName: String?) + // MARK: - Identifiable - // MARK: - Identifiable - - public var id: String { - switch self { - case .bluetooth(let uuid, _): - return "ble:\(uuid.uuidString)" - case .wifi(let host, let port, _): - return "wifi:\(host):\(port)" - } - } - - // MARK: - Convenience - - /// User-assigned display name, if any. - public var displayName: String? { - switch self { - case .bluetooth(_, let name), .wifi(_, _, let name): - return name - } + public var id: String { + switch self { + case let .bluetooth(uuid, _): + "ble:\(uuid.uuidString)" + case let .wifi(host, port, _): + "wifi:\(host):\(port)" } + } - /// Whether this is a Bluetooth connection. - public var isBluetooth: Bool { - if case .bluetooth = self { return true } - return false - } + // MARK: - Convenience - /// Whether this is a WiFi connection. - public var isWiFi: Bool { - if case .wifi = self { return true } - return false + /// User-assigned display name, if any. + public var displayName: String? { + switch self { + case let .bluetooth(_, name), let .wifi(_, _, name): + name } - - /// Short description for display in lists. - public var shortDescription: String { - switch self { - case .bluetooth: - return "Bluetooth" - case .wifi(let host, let port, _): - if port == 5000 { - return host - } - return "\(host):\(port)" - } + } + + /// Whether this is a Bluetooth connection. + public var isBluetooth: Bool { + if case .bluetooth = self { return true } + return false + } + + /// Whether this is a WiFi connection. + public var isWiFi: Bool { + if case .wifi = self { return true } + return false + } + + /// Short description for display in lists. + public var shortDescription: String { + switch self { + case .bluetooth: + return "Bluetooth" + case let .wifi(host, port, _): + if port == 5000 { + return host + } + return "\(host):\(port)" } + } } diff --git a/MC1Services/Sources/MC1Services/Models/Contact.swift b/MC1Services/Sources/MC1Services/Models/Contact.swift index f2ce7724..35f43559 100644 --- a/MC1Services/Sources/MC1Services/Models/Contact.swift +++ b/MC1Services/Sources/MC1Services/Models/Contact.swift @@ -7,521 +7,523 @@ import SwiftData /// Contacts are stored per-device and synced from the device's contact table. @Model public final class Contact { - #Index( - [\.radioID], - [\.radioID, \.publicKey] + #Index( + [\.radioID], + [\.radioID, \.publicKey] + ) + + /// Unique identifier (derived from public key hash) + @Attribute(.unique) + public var id: UUID + + /// The device this contact belongs to + @Attribute(originalName: "deviceID") + public var radioID: UUID + + /// The 32-byte public key of the contact + public var publicKey: Data + + /// Human-readable name + public var name: String + + /// Contact type (chat, repeater, room) + public var typeRawValue: UInt8 + + /// Permission flags + public var flags: UInt8 + + /// Encoded outbound path length (0xFF = flood; upper 2 bits = hash mode, lower 6 bits = hop count) + public var outPathLength: UInt8 + + /// Outgoing routing path (up to 64 bytes) + public var outPath: Data + + /// Last advertisement timestamp (device time) + public var lastAdvertTimestamp: UInt32 + + /// Contact latitude + public var latitude: Double + + /// Contact longitude + public var longitude: Double + + /// Last modification timestamp (for sync watermarking) + public var lastModified: UInt32 + + /// Local nickname override (optional) + public var nickname: String? + + /// Whether this contact is blocked + public var isBlocked: Bool + + /// Whether this contact's notifications are muted + public var isMuted: Bool = false + + /// Whether this contact is a favorite/pinned + public var isFavorite: Bool + + /// Last message timestamp (for sorting conversations) + public var lastMessageDate: Date? + + /// Unread message count + public var unreadCount: Int + + /// Unread mention count (mentions of current user not yet seen) + public var unreadMentionCount: Int = 0 + + /// Selected OCV preset name (nil = liIon default) + public var ocvPreset: String? + + /// Custom OCV array as comma-separated string (e.g., "4240,4112,4029,...") + public var customOCVArrayString: String? + + public init( + id: UUID = UUID(), + radioID: UUID, + publicKey: Data, + name: String, + typeRawValue: UInt8 = 0, + flags: UInt8 = 0, + outPathLength: UInt8 = PacketBuilder.floodPathSentinel, + outPath: Data = Data(), + lastAdvertTimestamp: UInt32 = 0, + latitude: Double = 0, + longitude: Double = 0, + lastModified: UInt32 = 0, + nickname: String? = nil, + isBlocked: Bool = false, + isMuted: Bool = false, + isFavorite: Bool = false, + lastMessageDate: Date? = nil, + unreadCount: Int = 0, + unreadMentionCount: Int = 0, + ocvPreset: String? = nil, + customOCVArrayString: String? = nil + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + self.typeRawValue = typeRawValue + self.flags = flags + self.outPathLength = outPathLength + self.outPath = outPath + self.lastAdvertTimestamp = lastAdvertTimestamp + self.latitude = latitude + self.longitude = longitude + self.lastModified = lastModified + self.nickname = nickname + self.isBlocked = isBlocked + self.isMuted = isMuted + self.isFavorite = isFavorite + self.lastMessageDate = lastMessageDate + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.ocvPreset = ocvPreset + self.customOCVArrayString = customOCVArrayString + } + + /// Builds a model instance directly from a DTO. Shared by `saveContact` and + /// backup batch-insert paths so they can't drift on field coverage. + public convenience init(dto: ContactDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + publicKey: dto.publicKey, + name: dto.name, + typeRawValue: dto.typeRawValue, + flags: dto.flags, + outPathLength: dto.outPathLength, + outPath: dto.outPath, + lastAdvertTimestamp: dto.lastAdvertTimestamp, + latitude: dto.latitude, + longitude: dto.longitude, + lastModified: dto.lastModified, + nickname: dto.nickname, + isBlocked: dto.isBlocked, + isMuted: dto.isMuted, + isFavorite: dto.isFavorite, + lastMessageDate: dto.lastMessageDate, + unreadCount: dto.unreadCount, + unreadMentionCount: dto.unreadMentionCount, + ocvPreset: dto.ocvPreset, + customOCVArrayString: dto.customOCVArrayString ) - - /// Unique identifier (derived from public key hash) - @Attribute(.unique) - public var id: UUID - - /// The device this contact belongs to - @Attribute(originalName: "deviceID") - public var radioID: UUID - - /// The 32-byte public key of the contact - public var publicKey: Data - - /// Human-readable name - public var name: String - - /// Contact type (chat, repeater, room) - public var typeRawValue: UInt8 - - /// Permission flags - public var flags: UInt8 - - /// Encoded outbound path length (0xFF = flood; upper 2 bits = hash mode, lower 6 bits = hop count) - public var outPathLength: UInt8 - - /// Outgoing routing path (up to 64 bytes) - public var outPath: Data - - /// Last advertisement timestamp (device time) - public var lastAdvertTimestamp: UInt32 - - /// Contact latitude - public var latitude: Double - - /// Contact longitude - public var longitude: Double - - /// Last modification timestamp (for sync watermarking) - public var lastModified: UInt32 - - /// Local nickname override (optional) - public var nickname: String? - - /// Whether this contact is blocked - public var isBlocked: Bool - - /// Whether this contact's notifications are muted - public var isMuted: Bool = false - - /// Whether this contact is a favorite/pinned - public var isFavorite: Bool - - /// Last message timestamp (for sorting conversations) - public var lastMessageDate: Date? - - /// Unread message count - public var unreadCount: Int - - /// Unread mention count (mentions of current user not yet seen) - public var unreadMentionCount: Int = 0 - - /// Selected OCV preset name (nil = liIon default) - public var ocvPreset: String? - - /// Custom OCV array as comma-separated string (e.g., "4240,4112,4029,...") - public var customOCVArrayString: String? - - public init( - id: UUID = UUID(), - radioID: UUID, - publicKey: Data, - name: String, - typeRawValue: UInt8 = 0, - flags: UInt8 = 0, - outPathLength: UInt8 = PacketBuilder.floodPathSentinel, - outPath: Data = Data(), - lastAdvertTimestamp: UInt32 = 0, - latitude: Double = 0, - longitude: Double = 0, - lastModified: UInt32 = 0, - nickname: String? = nil, - isBlocked: Bool = false, - isMuted: Bool = false, - isFavorite: Bool = false, - lastMessageDate: Date? = nil, - unreadCount: Int = 0, - unreadMentionCount: Int = 0, - ocvPreset: String? = nil, - customOCVArrayString: String? = nil - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.typeRawValue = typeRawValue - self.flags = flags - self.outPathLength = outPathLength - self.outPath = outPath - self.lastAdvertTimestamp = lastAdvertTimestamp - self.latitude = latitude - self.longitude = longitude - self.lastModified = lastModified - self.nickname = nickname - self.isBlocked = isBlocked - self.isMuted = isMuted - self.isFavorite = isFavorite - self.lastMessageDate = lastMessageDate - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - self.ocvPreset = ocvPreset - self.customOCVArrayString = customOCVArrayString - } - - /// Builds a model instance directly from a DTO. Shared by `saveContact` and - /// backup batch-insert paths so they can't drift on field coverage. - public convenience init(dto: ContactDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - publicKey: dto.publicKey, - name: dto.name, - typeRawValue: dto.typeRawValue, - flags: dto.flags, - outPathLength: dto.outPathLength, - outPath: dto.outPath, - lastAdvertTimestamp: dto.lastAdvertTimestamp, - latitude: dto.latitude, - longitude: dto.longitude, - lastModified: dto.lastModified, - nickname: dto.nickname, - isBlocked: dto.isBlocked, - isMuted: dto.isMuted, - isFavorite: dto.isFavorite, - lastMessageDate: dto.lastMessageDate, - unreadCount: dto.unreadCount, - unreadMentionCount: dto.unreadMentionCount, - ocvPreset: dto.ocvPreset, - customOCVArrayString: dto.customOCVArrayString - ) - } - - /// Applies all mutable fields from a DTO to this model instance. - /// `radioID` and `publicKey` are identity and stay frozen: this runs in the - /// id-matched `saveContact(_:)` upsert, which owns app-side metadata. Key - /// changes arrive from the radio as `ContactFrame`s keyed by - /// `(radioID, publicKey)`, where a new key is a new contact row. - func apply(_ dto: ContactDTO) { - name = dto.name - typeRawValue = dto.typeRawValue - flags = dto.flags - outPathLength = dto.outPathLength - outPath = dto.outPath - lastAdvertTimestamp = dto.lastAdvertTimestamp - latitude = dto.latitude - longitude = dto.longitude - lastModified = dto.lastModified - nickname = dto.nickname - isBlocked = dto.isBlocked - isMuted = dto.isMuted - isFavorite = dto.isFavorite - lastMessageDate = dto.lastMessageDate - unreadCount = dto.unreadCount - unreadMentionCount = dto.unreadMentionCount - ocvPreset = dto.ocvPreset - customOCVArrayString = dto.customOCVArrayString - } - - /// Creates a Contact from a protocol ContactFrame - public convenience init(radioID: UUID, from frame: ContactFrame) { - self.init( - radioID: radioID, - publicKey: frame.publicKey, - name: frame.name, - typeRawValue: frame.typeRawValue, - flags: frame.flags, - outPathLength: frame.outPathLength, - outPath: frame.outPath, - lastAdvertTimestamp: frame.lastAdvertTimestamp, - latitude: frame.latitude, - longitude: frame.longitude, - lastModified: frame.lastModified, - isFavorite: (frame.flags & 0x01) != 0 - ) - } + } + + /// Applies all mutable fields from a DTO to this model instance. + /// `radioID` and `publicKey` are identity and stay frozen: this runs in the + /// id-matched `saveContact(_:)` upsert, which owns app-side metadata. Key + /// changes arrive from the radio as `ContactFrame`s keyed by + /// `(radioID, publicKey)`, where a new key is a new contact row. + func apply(_ dto: ContactDTO) { + name = dto.name + typeRawValue = dto.typeRawValue + flags = dto.flags + outPathLength = dto.outPathLength + outPath = dto.outPath + lastAdvertTimestamp = dto.lastAdvertTimestamp + latitude = dto.latitude + longitude = dto.longitude + lastModified = dto.lastModified + nickname = dto.nickname + isBlocked = dto.isBlocked + isMuted = dto.isMuted + isFavorite = dto.isFavorite + lastMessageDate = dto.lastMessageDate + unreadCount = dto.unreadCount + unreadMentionCount = dto.unreadMentionCount + ocvPreset = dto.ocvPreset + customOCVArrayString = dto.customOCVArrayString + } + + /// Creates a Contact from a protocol ContactFrame + public convenience init(radioID: UUID, from frame: ContactFrame) { + self.init( + radioID: radioID, + publicKey: frame.publicKey, + name: frame.name, + typeRawValue: frame.typeRawValue, + flags: frame.flags, + outPathLength: frame.outPathLength, + outPath: frame.outPath, + lastAdvertTimestamp: frame.lastAdvertTimestamp, + latitude: frame.latitude, + longitude: frame.longitude, + lastModified: frame.lastModified, + isFavorite: (frame.flags & 0x01) != 0 + ) + } } // MARK: - Computed Properties public extension Contact { - /// The contact type enum - var type: ContactType { - ContactType(rawValue: typeRawValue) ?? .chat - } - - /// Display name (nickname if set, otherwise name) - var displayName: String { - nickname ?? name - } - - /// The 6-byte public key prefix for message addressing - var publicKeyPrefix: Data { - publicKey.prefix(6) - } - - /// Whether this contact uses flood routing - var isFloodRouted: Bool { - outPathLength == PacketBuilder.floodPathSentinel - } - - /// Whether this contact has a known, valid location - var hasLocation: Bool { - let hasNonZero = latitude != 0 || longitude != 0 - guard hasNonZero else { return false } - return CLLocationCoordinate2DIsValid( - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - ) - } - - /// Whether this contact is a repeater - var isRepeater: Bool { - type == .repeater - } - - /// Whether this contact is a room - var isRoom: Bool { - type == .room - } - - /// Updates from a protocol ContactFrame - func update(from frame: ContactFrame) { - self.name = frame.name - self.typeRawValue = frame.typeRawValue - // Preserve bit 0 (favorite) from existing flags, take bits 1-7 from frame - self.flags = (self.flags & 0x01) | (frame.flags & ~0x01) - self.outPathLength = frame.outPathLength - self.outPath = frame.outPath - self.lastAdvertTimestamp = frame.lastAdvertTimestamp - self.latitude = frame.latitude - self.longitude = frame.longitude - self.lastModified = frame.lastModified - } - - /// Converts to a protocol ContactFrame for sending to device - func toContactFrame() -> ContactFrame { - ContactFrame( - publicKey: publicKey, - type: type, - typeRawValue: typeRawValue, - flags: flags, - outPathLength: outPathLength, - outPath: outPath, - name: name, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: lastModified - ) - } + /// The contact type enum + var type: ContactType { + ContactType(rawValue: typeRawValue) ?? .chat + } + + /// Display name (nickname if set, otherwise name) + var displayName: String { + nickname ?? name + } + + /// The 6-byte public key prefix for message addressing + var publicKeyPrefix: Data { + publicKey.prefix(6) + } + + /// Whether this contact uses flood routing + var isFloodRouted: Bool { + outPathLength == PacketBuilder.floodPathSentinel + } + + /// Whether this contact has a known, valid location + var hasLocation: Bool { + let hasNonZero = latitude != 0 || longitude != 0 + guard hasNonZero else { return false } + return CLLocationCoordinate2DIsValid( + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + } + + /// Whether this contact is a repeater + var isRepeater: Bool { + type == .repeater + } + + /// Whether this contact is a room + var isRoom: Bool { + type == .room + } + + /// Updates from a protocol ContactFrame + func update(from frame: ContactFrame) { + name = frame.name + typeRawValue = frame.typeRawValue + // Preserve bit 0 (favorite) from existing flags, take bits 1-7 from frame + flags = (flags & 0x01) | (frame.flags & ~0x01) + outPathLength = frame.outPathLength + outPath = frame.outPath + lastAdvertTimestamp = frame.lastAdvertTimestamp + latitude = frame.latitude + longitude = frame.longitude + lastModified = frame.lastModified + } + + /// Converts to a protocol ContactFrame for sending to device + func toContactFrame() -> ContactFrame { + ContactFrame( + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: flags, + outPathLength: outPathLength, + outPath: outPath, + name: name, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified + ) + } } // MARK: - Sendable DTO /// A sendable snapshot of Contact for cross-actor transfers public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, RepeaterResolvable { - public let id: UUID - public var radioID: UUID - public let publicKey: Data - public let name: String - public let typeRawValue: UInt8 - public let flags: UInt8 - public let outPathLength: UInt8 - public let outPath: Data - public let lastAdvertTimestamp: UInt32 - public let latitude: Double - public let longitude: Double - public let lastModified: UInt32 - public let nickname: String? - public let isBlocked: Bool - public let isMuted: Bool - public let isFavorite: Bool - public let lastMessageDate: Date? - public let unreadCount: Int - public let unreadMentionCount: Int - public let ocvPreset: String? - public let customOCVArrayString: String? - - public init(from contact: Contact) { - self.id = contact.id - self.radioID = contact.radioID - self.publicKey = contact.publicKey - self.name = contact.name - self.typeRawValue = contact.typeRawValue - self.flags = contact.flags - self.outPathLength = contact.outPathLength - self.outPath = contact.outPath - self.lastAdvertTimestamp = contact.lastAdvertTimestamp - self.latitude = contact.latitude - self.longitude = contact.longitude - self.lastModified = contact.lastModified - self.nickname = contact.nickname - self.isBlocked = contact.isBlocked - self.isMuted = contact.isMuted - self.isFavorite = contact.isFavorite - self.lastMessageDate = contact.lastMessageDate - self.unreadCount = contact.unreadCount - self.unreadMentionCount = contact.unreadMentionCount - self.ocvPreset = contact.ocvPreset - self.customOCVArrayString = contact.customOCVArrayString - } - - /// Memberwise initializer for creating DTOs directly - public init( - id: UUID, - radioID: UUID, - publicKey: Data, - name: String, - typeRawValue: UInt8, - flags: UInt8, - outPathLength: UInt8, - outPath: Data, - lastAdvertTimestamp: UInt32, - latitude: Double, - longitude: Double, - lastModified: UInt32, - nickname: String?, - isBlocked: Bool, - isMuted: Bool, - isFavorite: Bool, - lastMessageDate: Date?, - unreadCount: Int, - unreadMentionCount: Int = 0, - ocvPreset: String? = nil, - customOCVArrayString: String? = nil - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.typeRawValue = typeRawValue - self.flags = flags - self.outPathLength = outPathLength - self.outPath = outPath - self.lastAdvertTimestamp = lastAdvertTimestamp - self.latitude = latitude - self.longitude = longitude - self.lastModified = lastModified - self.nickname = nickname - self.isBlocked = isBlocked - self.isMuted = isMuted - self.isFavorite = isFavorite - self.lastMessageDate = lastMessageDate - self.unreadCount = unreadCount - self.unreadMentionCount = unreadMentionCount - self.ocvPreset = ocvPreset - self.customOCVArrayString = customOCVArrayString - } - - public var type: ContactType { - ContactType(rawValue: typeRawValue) ?? .chat - } - - public var displayName: String { - nickname ?? name - } - - public var publicKeyPrefix: Data { - publicKey.prefix(6) - } - - public var isFloodRouted: Bool { - outPathLength == PacketBuilder.floodPathSentinel - } - - /// The hash size per hop in bytes (1, 2, or 3), derived from the upper 2 bits of ``outPathLength``. - public var pathHashSize: Int { - decodePathLen(outPathLength)?.hashSize ?? 1 - } - - /// The number of hops in the path, derived from the lower 6 bits of ``outPathLength``. - public var pathHopCount: Int { - decodePathLen(outPathLength)?.hopCount ?? 0 - } - - /// The hop count to surface in the UI: the deliberately-set out-path hops when a route exists, - /// otherwise the passively-heard inbound advert hops, which a contact does not store and the - /// caller looks up live from the discovered-node list. nil when flood-routed and none is known. - public func displayedHopCount(inboundHopCount: Int?) -> Int? { - isFloodRouted ? inboundHopCount : pathHopCount - } - - /// The total byte length of the path data (`pathHopCount * pathHashSize`). - public var pathByteLength: Int { - decodePathLen(outPathLength)?.byteLength ?? 0 - } - - /// Each hop as its raw hash bytes plus uppercase hex, e.g. `[(0xA3, "A3"), (0x7F, "7F")]`. - /// The raw bytes are needed to match a hop against a repeater's public-key prefix. - public var pathHops: [(data: Data, hex: String)] { - outPath.prefix(pathByteLength).pathHops(hashSize: pathHashSize) - } - - /// Each hop's hash as a hex string, e.g. `["A3", "7F", "42"]`. - public var pathNodesHex: [String] { - pathHops.map(\.hex) - } - - /// Human-readable path string with arrow separators, e.g. `"A3 → 7F → 42"`. - public var pathString: String { - pathNodesHex.joined(separator: " \u{2192} ") - } - - public var hasLocation: Bool { - let hasNonZero = latitude != 0 || longitude != 0 - guard hasNonZero else { return false } - return CLLocationCoordinate2DIsValid( - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - ) - } - - public var publicKeyHex: String { - publicKey.uppercaseHexString() - } - - /// Returns a copy with only `isMuted` changed. - public func with(isMuted: Bool) -> ContactDTO { - ContactDTO( - id: id, radioID: radioID, publicKey: publicKey, name: name, - typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, - outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, longitude: longitude, lastModified: lastModified, - nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, - isFavorite: isFavorite, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - ocvPreset: ocvPreset, customOCVArrayString: customOCVArrayString - ) - } - - /// Returns a copy with only `isFavorite` changed. - public func with(isFavorite: Bool) -> ContactDTO { - ContactDTO( - id: id, radioID: radioID, publicKey: publicKey, name: name, - typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, - outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, longitude: longitude, lastModified: lastModified, - nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, - isFavorite: isFavorite, lastMessageDate: lastMessageDate, - unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - ocvPreset: ocvPreset, customOCVArrayString: customOCVArrayString - ) - } - - /// The active OCV array for this contact (preset or custom) - public var activeOCVArray: [Int] { - // If custom preset with valid custom string, parse it - if ocvPreset == OCVPreset.custom.rawValue, let customString = customOCVArrayString { - let parsed = customString.split(separator: ",") - .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } - if parsed.count == 11 { - return parsed - } - } - - // Use preset if set - if let presetName = ocvPreset, let preset = OCVPreset(rawValue: presetName) { - return preset.ocvArray - } - - // Default to Li-Ion - return OCVPreset.liIon.ocvArray - } - - // MARK: - RepeaterResolvable - - public var recencyDate: Date { - Date(timeIntervalSince1970: Double(lastModified)) - } - - public var resolvableName: String { displayName } - - /// Converts to a protocol ContactFrame for sending to device - public func toContactFrame() -> ContactFrame { - ContactFrame( - publicKey: publicKey, - type: type, - typeRawValue: typeRawValue, - flags: flags, - outPathLength: outPathLength, - outPath: outPath, - name: name, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: lastModified - ) - } - - /// A frame for this contact with the path reset to flood routing. A contact the radio doesn't - /// know has no valid stored path, so flooding is the only route that can reach it; the firmware - /// rediscovers the direct path from the first response. - public func floodedContactFrame(asOf now: UInt32) -> ContactFrame { - ContactFrame( - publicKey: publicKey, - type: type, - typeRawValue: typeRawValue, - flags: flags, - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data(), - name: name, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: now - ) - } + public let id: UUID + public var radioID: UUID + public let publicKey: Data + public let name: String + public let typeRawValue: UInt8 + public let flags: UInt8 + public let outPathLength: UInt8 + public let outPath: Data + public let lastAdvertTimestamp: UInt32 + public let latitude: Double + public let longitude: Double + public let lastModified: UInt32 + public let nickname: String? + public let isBlocked: Bool + public let isMuted: Bool + public let isFavorite: Bool + public let lastMessageDate: Date? + public let unreadCount: Int + public let unreadMentionCount: Int + public let ocvPreset: String? + public let customOCVArrayString: String? + + public init(from contact: Contact) { + id = contact.id + radioID = contact.radioID + publicKey = contact.publicKey + name = contact.name + typeRawValue = contact.typeRawValue + flags = contact.flags + outPathLength = contact.outPathLength + outPath = contact.outPath + lastAdvertTimestamp = contact.lastAdvertTimestamp + latitude = contact.latitude + longitude = contact.longitude + lastModified = contact.lastModified + nickname = contact.nickname + isBlocked = contact.isBlocked + isMuted = contact.isMuted + isFavorite = contact.isFavorite + lastMessageDate = contact.lastMessageDate + unreadCount = contact.unreadCount + unreadMentionCount = contact.unreadMentionCount + ocvPreset = contact.ocvPreset + customOCVArrayString = contact.customOCVArrayString + } + + /// Memberwise initializer for creating DTOs directly + public init( + id: UUID, + radioID: UUID, + publicKey: Data, + name: String, + typeRawValue: UInt8, + flags: UInt8, + outPathLength: UInt8, + outPath: Data, + lastAdvertTimestamp: UInt32, + latitude: Double, + longitude: Double, + lastModified: UInt32, + nickname: String?, + isBlocked: Bool, + isMuted: Bool, + isFavorite: Bool, + lastMessageDate: Date?, + unreadCount: Int, + unreadMentionCount: Int = 0, + ocvPreset: String? = nil, + customOCVArrayString: String? = nil + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + self.typeRawValue = typeRawValue + self.flags = flags + self.outPathLength = outPathLength + self.outPath = outPath + self.lastAdvertTimestamp = lastAdvertTimestamp + self.latitude = latitude + self.longitude = longitude + self.lastModified = lastModified + self.nickname = nickname + self.isBlocked = isBlocked + self.isMuted = isMuted + self.isFavorite = isFavorite + self.lastMessageDate = lastMessageDate + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.ocvPreset = ocvPreset + self.customOCVArrayString = customOCVArrayString + } + + public var type: ContactType { + ContactType(rawValue: typeRawValue) ?? .chat + } + + public var displayName: String { + nickname ?? name + } + + public var publicKeyPrefix: Data { + publicKey.prefix(6) + } + + public var isFloodRouted: Bool { + outPathLength == PacketBuilder.floodPathSentinel + } + + /// The hash size per hop in bytes (1, 2, or 3), derived from the upper 2 bits of ``outPathLength``. + public var pathHashSize: Int { + decodePathLen(outPathLength)?.hashSize ?? 1 + } + + /// The number of hops in the path, derived from the lower 6 bits of ``outPathLength``. + public var pathHopCount: Int { + decodePathLen(outPathLength)?.hopCount ?? 0 + } + + /// The hop count to surface in the UI: the deliberately-set out-path hops when a route exists, + /// otherwise the passively-heard inbound advert hops, which a contact does not store and the + /// caller looks up live from the discovered-node list. nil when flood-routed and none is known. + public func displayedHopCount(inboundHopCount: Int?) -> Int? { + isFloodRouted ? inboundHopCount : pathHopCount + } + + /// The total byte length of the path data (`pathHopCount * pathHashSize`). + public var pathByteLength: Int { + decodePathLen(outPathLength)?.byteLength ?? 0 + } + + /// Each hop as its raw hash bytes plus uppercase hex, e.g. `[(0xA3, "A3"), (0x7F, "7F")]`. + /// The raw bytes are needed to match a hop against a repeater's public-key prefix. + public var pathHops: [(data: Data, hex: String)] { + outPath.prefix(pathByteLength).pathHops(hashSize: pathHashSize) + } + + /// Each hop's hash as a hex string, e.g. `["A3", "7F", "42"]`. + public var pathNodesHex: [String] { + pathHops.map(\.hex) + } + + /// Human-readable path string with arrow separators, e.g. `"A3 → 7F → 42"`. + public var pathString: String { + pathNodesHex.joined(separator: " \u{2192} ") + } + + public var hasLocation: Bool { + let hasNonZero = latitude != 0 || longitude != 0 + guard hasNonZero else { return false } + return CLLocationCoordinate2DIsValid( + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + } + + public var publicKeyHex: String { + publicKey.uppercaseHexString() + } + + /// Returns a copy with only `isMuted` changed. + public func with(isMuted: Bool) -> ContactDTO { + ContactDTO( + id: id, radioID: radioID, publicKey: publicKey, name: name, + typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, + outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, longitude: longitude, lastModified: lastModified, + nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, + isFavorite: isFavorite, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + ocvPreset: ocvPreset, customOCVArrayString: customOCVArrayString + ) + } + + /// Returns a copy with only `isFavorite` changed. + public func with(isFavorite: Bool) -> ContactDTO { + ContactDTO( + id: id, radioID: radioID, publicKey: publicKey, name: name, + typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, + outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, longitude: longitude, lastModified: lastModified, + nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, + isFavorite: isFavorite, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + ocvPreset: ocvPreset, customOCVArrayString: customOCVArrayString + ) + } + + /// The active OCV array for this contact (preset or custom) + public var activeOCVArray: [Int] { + // If custom preset with valid custom string, parse it + if ocvPreset == OCVPreset.custom.rawValue, let customString = customOCVArrayString { + let parsed = customString.split(separator: ",") + .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if parsed.count == 11 { + return parsed + } + } + + // Use preset if set + if let presetName = ocvPreset, let preset = OCVPreset(rawValue: presetName) { + return preset.ocvArray + } + + // Default to Li-Ion + return OCVPreset.liIon.ocvArray + } + + // MARK: - RepeaterResolvable + + public var recencyDate: Date { + Date(timeIntervalSince1970: Double(lastModified)) + } + + public var resolvableName: String { + displayName + } + + /// Converts to a protocol ContactFrame for sending to device + public func toContactFrame() -> ContactFrame { + ContactFrame( + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: flags, + outPathLength: outPathLength, + outPath: outPath, + name: name, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified + ) + } + + /// A frame for this contact with the path reset to flood routing. A contact the radio doesn't + /// know has no valid stored path, so flooding is the only route that can reach it; the firmware + /// rediscovers the direct path from the first response. + public func floodedContactFrame(asOf now: UInt32) -> ContactFrame { + ContactFrame( + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: flags, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: name, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: now + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/ContactFrame.swift b/MC1Services/Sources/MC1Services/Models/ContactFrame.swift index 2fbdf10c..8b28b682 100644 --- a/MC1Services/Sources/MC1Services/Models/ContactFrame.swift +++ b/MC1Services/Sources/MC1Services/Models/ContactFrame.swift @@ -3,44 +3,44 @@ import MeshCore /// Contact information frame from device public struct ContactFrame: Sendable, Equatable { - public let publicKey: Data - public let type: ContactType - /// The raw 1-byte type value as it appears on the wire. Normally equal to `type.rawValue`; - /// preserved separately so a contact carrying a type byte not modeled by ``ContactType`` - /// round-trips to the device verbatim instead of being coerced. - public let typeRawValue: UInt8 - public let flags: UInt8 - public let outPathLength: UInt8 - public let outPath: Data - public let name: String - public let lastAdvertTimestamp: UInt32 - public let latitude: Double - public let longitude: Double - public let lastModified: UInt32 + public let publicKey: Data + public let type: ContactType + /// The raw 1-byte type value as it appears on the wire. Normally equal to `type.rawValue`; + /// preserved separately so a contact carrying a type byte not modeled by ``ContactType`` + /// round-trips to the device verbatim instead of being coerced. + public let typeRawValue: UInt8 + public let flags: UInt8 + public let outPathLength: UInt8 + public let outPath: Data + public let name: String + public let lastAdvertTimestamp: UInt32 + public let latitude: Double + public let longitude: Double + public let lastModified: UInt32 - public init( - publicKey: Data, - type: ContactType, - typeRawValue: UInt8? = nil, - flags: UInt8, - outPathLength: UInt8, - outPath: Data, - name: String, - lastAdvertTimestamp: UInt32, - latitude: Double, - longitude: Double, - lastModified: UInt32 - ) { - self.publicKey = publicKey - self.type = type - self.typeRawValue = typeRawValue ?? type.rawValue - self.flags = flags - self.outPathLength = outPathLength - self.outPath = outPath - self.name = name - self.lastAdvertTimestamp = lastAdvertTimestamp - self.latitude = latitude - self.longitude = longitude - self.lastModified = lastModified - } + public init( + publicKey: Data, + type: ContactType, + typeRawValue: UInt8? = nil, + flags: UInt8, + outPathLength: UInt8, + outPath: Data, + name: String, + lastAdvertTimestamp: UInt32, + latitude: Double, + longitude: Double, + lastModified: UInt32 + ) { + self.publicKey = publicKey + self.type = type + self.typeRawValue = typeRawValue ?? type.rawValue + self.flags = flags + self.outPathLength = outPathLength + self.outPath = outPath + self.name = name + self.lastAdvertTimestamp = lastAdvertTimestamp + self.latitude = latitude + self.longitude = longitude + self.lastModified = lastModified + } } diff --git a/MC1Services/Sources/MC1Services/Models/DebugLogEntry.swift b/MC1Services/Sources/MC1Services/Models/DebugLogEntry.swift index 761a4803..f7fbae82 100644 --- a/MC1Services/Sources/MC1Services/Models/DebugLogEntry.swift +++ b/MC1Services/Sources/MC1Services/Models/DebugLogEntry.swift @@ -4,67 +4,67 @@ import SwiftData /// SwiftData model for persisted debug log entries. @Model final class DebugLogEntry { - #Index([\.timestamp]) + #Index([\.timestamp]) - @Attribute(.unique) - var id: UUID + @Attribute(.unique) + var id: UUID - var timestamp: Date - var level: Int - var subsystem: String - var category: String - var message: String + var timestamp: Date + var level: Int + var subsystem: String + var category: String + var message: String - init( - id: UUID = UUID(), - timestamp: Date = Date(), - level: Int, - subsystem: String, - category: String, - message: String - ) { - self.id = id - self.timestamp = timestamp - self.level = level - self.subsystem = subsystem - self.category = category - self.message = message - } + init( + id: UUID = UUID(), + timestamp: Date = Date(), + level: Int, + subsystem: String, + category: String, + message: String + ) { + self.id = id + self.timestamp = timestamp + self.level = level + self.subsystem = subsystem + self.category = category + self.message = message + } } /// Sendable DTO for cross-actor transfer of DebugLogEntry data. public struct DebugLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { - public let id: UUID - public let timestamp: Date - public let level: DebugLogLevel - public let subsystem: String - public let category: String - public let message: String + public let id: UUID + public let timestamp: Date + public let level: DebugLogLevel + public let subsystem: String + public let category: String + public let message: String - public init( - id: UUID = UUID(), - timestamp: Date = Date(), - level: DebugLogLevel, - subsystem: String, - category: String, - message: String - ) { - self.id = id - self.timestamp = timestamp - self.level = level - self.subsystem = subsystem - self.category = category - // Truncate message to prevent memory issues - self.message = String(message.prefix(4000)) - } + public init( + id: UUID = UUID(), + timestamp: Date = Date(), + level: DebugLogLevel, + subsystem: String, + category: String, + message: String + ) { + self.id = id + self.timestamp = timestamp + self.level = level + self.subsystem = subsystem + self.category = category + // Truncate message to prevent memory issues + self.message = String(message.prefix(4000)) + } - /// Initialize from SwiftData model. - init(from model: DebugLogEntry) { - self.id = model.id - self.timestamp = model.timestamp - self.level = DebugLogLevel(rawValue: model.level) ?? .info - self.subsystem = model.subsystem - self.category = model.category - self.message = model.message - } + /// Initialize from SwiftData model. + init(from model: DebugLogEntry) { + id = model.id + timestamp = model.timestamp + level = DebugLogLevel(rawValue: model.level) ?? .info + subsystem = model.subsystem + category = model.category + message = model.message + } } diff --git a/MC1Services/Sources/MC1Services/Models/DebugLogLevel.swift b/MC1Services/Sources/MC1Services/Models/DebugLogLevel.swift index 2930628d..2920389c 100644 --- a/MC1Services/Sources/MC1Services/Models/DebugLogLevel.swift +++ b/MC1Services/Sources/MC1Services/Models/DebugLogLevel.swift @@ -1,21 +1,21 @@ import Foundation public enum DebugLogLevel: Int, Sendable, CaseIterable { - case debug = 0 - case info = 1 - case notice = 2 - case warning = 3 - case error = 4 - case fault = 5 + case debug = 0 + case info = 1 + case notice = 2 + case warning = 3 + case error = 4 + case fault = 5 - public var label: String { - switch self { - case .debug: return "DEBUG" - case .info: return "INFO" - case .notice: return "NOTICE" - case .warning: return "WARNING" - case .error: return "ERROR" - case .fault: return "FAULT" - } + public var label: String { + switch self { + case .debug: "DEBUG" + case .info: "INFO" + case .notice: "NOTICE" + case .warning: "WARNING" + case .error: "ERROR" + case .fault: "FAULT" } + } } diff --git a/MC1Services/Sources/MC1Services/Models/DecryptStatus.swift b/MC1Services/Sources/MC1Services/Models/DecryptStatus.swift index 12565ab9..edb98ac8 100644 --- a/MC1Services/Sources/MC1Services/Models/DecryptStatus.swift +++ b/MC1Services/Sources/MC1Services/Models/DecryptStatus.swift @@ -3,37 +3,37 @@ import Foundation /// Decryption outcome for channel and direct messages. public enum DecryptStatus: Int, Codable, Sendable, CaseIterable { - case notApplicable = 0 // Not a channel message (e.g., direct, advert) - case noMatchingKey = 1 // Channel: no stored channel matches - case hmacFailed = 2 // Key found but HMAC validation failed - case decryptFailed = 3 // HMAC passed but AES decrypt failed - case success = 4 // Decrypted successfully - case pending = 5 // Key found but decryption not yet implemented - case dmNoMatchingKey = 6 // DM: missing private key or contact public key + case notApplicable = 0 // Not a channel message (e.g., direct, advert) + case noMatchingKey = 1 // Channel: no stored channel matches + case hmacFailed = 2 // Key found but HMAC validation failed + case decryptFailed = 3 // HMAC passed but AES decrypt failed + case success = 4 // Decrypted successfully + case pending = 5 // Key found but decryption not yet implemented + case dmNoMatchingKey = 6 // DM: missing private key or contact public key - /// Developer-facing English description for logs; UI uses the app target's localized `DecryptStatus.localizedName`. - public var displayName: String { - switch self { - case .notApplicable: return "N/A" - case .noMatchingKey: return "No Key" - case .hmacFailed: return "HMAC Failed" - case .decryptFailed: return "Decrypt Failed" - case .success: return "Decrypted" - case .pending: return "Has Key" - case .dmNoMatchingKey: return "No DM Key" - } + /// Developer-facing English description for logs; UI uses the app target's localized `DecryptStatus.localizedName`. + public var displayName: String { + switch self { + case .notApplicable: "N/A" + case .noMatchingKey: "No Key" + case .hmacFailed: "HMAC Failed" + case .decryptFailed: "Decrypt Failed" + case .success: "Decrypted" + case .pending: "Has Key" + case .dmNoMatchingKey: "No DM Key" } + } - /// SF Symbol name for status indicator. - public var symbolName: String { - switch self { - case .notApplicable: return "minus.circle" - case .noMatchingKey: return "key.slash" - case .hmacFailed: return "exclamationmark.shield" - case .decryptFailed: return "lock.slash" - case .success: return "checkmark.seal" - case .pending: return "key" - case .dmNoMatchingKey: return "key.slash" - } + /// SF Symbol name for status indicator. + public var symbolName: String { + switch self { + case .notApplicable: "minus.circle" + case .noMatchingKey: "key.slash" + case .hmacFailed: "exclamationmark.shield" + case .decryptFailed: "lock.slash" + case .success: "checkmark.seal" + case .pending: "key" + case .dmNoMatchingKey: "key.slash" } + } } diff --git a/MC1Services/Sources/MC1Services/Models/DeliveryContext.swift b/MC1Services/Sources/MC1Services/Models/DeliveryContext.swift index 96fa471e..186aa29a 100644 --- a/MC1Services/Sources/MC1Services/Models/DeliveryContext.swift +++ b/MC1Services/Sources/MC1Services/Models/DeliveryContext.swift @@ -9,11 +9,11 @@ import Foundation /// - `initialSync` positions a drained backlog batch as one contiguous block at the drain /// time carried in `anchor`, so reconnect history lands together near the bottom rather /// than scattered through scrollback by send time. -enum DeliveryContext: Sendable { - /// Drained from the device's stored backlog during initial connect or resync. - /// `anchor` is captured once per drain so every message in the batch shares a sort - /// date and forms a contiguous block positioned at delivery time, send-ordered within. - case initialSync(anchor: Date) - /// Pushed in real time while connected. - case live +enum DeliveryContext { + /// Drained from the device's stored backlog during initial connect or resync. + /// `anchor` is captured once per drain so every message in the batch shares a sort + /// date and forms a contiguous block positioned at delivery time, send-ordered within. + case initialSync(anchor: Date) + /// Pushed in real time while connected. + case live } diff --git a/MC1Services/Sources/MC1Services/Models/Device.swift b/MC1Services/Sources/MC1Services/Models/Device.swift index 939abd2e..e1624ee3 100644 --- a/MC1Services/Sources/MC1Services/Models/Device.swift +++ b/MC1Services/Sources/MC1Services/Models/Device.swift @@ -7,779 +7,793 @@ import SwiftData /// Each device has its own isolated data store for contacts, messages, and channels. @Model public final class Device { - /// Unique identifier for the device (derived from BLE peripheral identifier) - @Attribute(.unique) - public var id: UUID + /// Unique identifier for the device (derived from BLE peripheral identifier) + @Attribute(.unique) + public var id: UUID - /// New column (not renamed from deviceID), so no @Attribute(originalName:) unlike child models. - public var radioID: UUID = UUID() + /// New column (not renamed from deviceID), so no @Attribute(originalName:) unlike child models. + public var radioID: UUID = UUID() - /// The 32-byte public key of the device - public var publicKey: Data + /// The 32-byte public key of the device + public var publicKey: Data - /// Human-readable name of the node - public var nodeName: String + /// Human-readable name of the node + public var nodeName: String - /// Firmware version code (e.g., 8) - public var firmwareVersion: UInt8 + /// Firmware version code (e.g., 8) + public var firmwareVersion: UInt8 - /// Firmware version string (e.g., "v1.11.0") - public var firmwareVersionString: String + /// Firmware version string (e.g., "v1.11.0") + public var firmwareVersionString: String - /// Manufacturer name - public var manufacturerName: String + /// Manufacturer name + public var manufacturerName: String - /// Build date string - public var buildDate: String + /// Build date string + public var buildDate: String - /// Maximum number of contacts supported - public var maxContacts: UInt16 + /// Maximum number of contacts supported + public var maxContacts: UInt16 - /// Maximum number of channels supported - public var maxChannels: UInt8 + /// Maximum number of channels supported + public var maxChannels: UInt8 - /// Radio frequency in kHz - public var frequency: UInt32 + /// Radio frequency in kHz + public var frequency: UInt32 - /// Radio bandwidth in kHz - public var bandwidth: UInt32 + /// Radio bandwidth in kHz + public var bandwidth: UInt32 - /// LoRa spreading factor (5-12) - public var spreadingFactor: UInt8 + /// LoRa spreading factor (5-12) + public var spreadingFactor: UInt8 - /// LoRa coding rate (5-8) - public var codingRate: UInt8 + /// LoRa coding rate (5-8) + public var codingRate: UInt8 - /// Transmit power in dBm (may be negative) - public var txPower: Int8 + /// Transmit power in dBm (may be negative) + public var txPower: Int8 - /// Maximum transmit power in dBm - public var maxTxPower: Int8 + /// Maximum transmit power in dBm + public var maxTxPower: Int8 - /// Node latitude (scaled by 1e6) - public var latitude: Double + /// Node latitude (scaled by 1e6) + public var latitude: Double - /// Node longitude (scaled by 1e6) - public var longitude: Double + /// Node longitude (scaled by 1e6) + public var longitude: Double - /// BLE PIN (0 = disabled, 100000-999999 = enabled) - public var blePin: UInt32 - - /// Whether client repeat mode is enabled (v9+ firmware) - public var clientRepeat: Bool = false - - /// Path hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). Firmware v10+. - public var pathHashMode: UInt8 = 0 - - /// Name of the device's persisted default flood scope, or `nil` when cleared. Firmware v11+. - /// The scope key is derived from this name on-demand via ``MeshCore/FloodScope/region(_:)``; - /// only the name is cached here because the user-facing flow selects regions by name. - public var defaultFloodScopeName: String? - - /// Cached radio settings from before repeat mode was enabled, for restoration on disable. - /// All 4 fields are set together when enabling repeat mode, and cleared together when disabling. - public var preRepeatFrequency: UInt32? - public var preRepeatBandwidth: UInt32? - public var preRepeatSpreadingFactor: UInt8? - public var preRepeatCodingRate: UInt8? - - /// Manual add contacts mode - public var manualAddContacts: Bool - - /// Auto-add configuration bitmask from device - public var autoAddConfig: UInt8 = 0 - - /// Maximum hops for auto-add filtering. 0 = no limit, 1 = direct only, N = up to N-1 hops. - public var autoAddMaxHops: UInt8 = 0 - - /// Number of acknowledgments to send for direct messages (0=disabled, 1-2 typical) - public var multiAcks: UInt8 - - /// Telemetry mode for base data - public var telemetryModeBase: UInt8 - - /// Telemetry mode for location data - public var telemetryModeLoc: UInt8 - - /// Telemetry mode for environment data - public var telemetryModeEnv: UInt8 - - /// Advertisement location policy - public var advertLocationPolicy: UInt8 - - /// Last time the device was connected - public var lastConnected: Date - - /// Last sync timestamp for contacts (watermark for incremental sync) - public var lastContactSync: UInt32 - - /// Whether this is the currently active device - public var isActive: Bool - - /// Selected OCV preset name (nil = liIon default) - public var ocvPreset: String? - - /// Custom OCV array as comma-separated string (e.g., "4240,4112,4029,...") - public var customOCVArrayString: String? - - /// Connection methods available for this device (BLE, WiFi, etc.) - public var connectionMethods: [ConnectionMethod] = [] - - /// Region codes known to this device - public var knownRegions: [String] = [] - - public init( - id: UUID = UUID(), - radioID: UUID = UUID(), - publicKey: Data, - nodeName: String, - firmwareVersion: UInt8 = 0, - firmwareVersionString: String = "", - manufacturerName: String = "", - buildDate: String = "", - maxContacts: UInt16 = 100, - maxChannels: UInt8 = 8, - frequency: UInt32 = Device.Defaults.frequency, - bandwidth: UInt32 = Device.Defaults.bandwidth, - spreadingFactor: UInt8 = Device.Defaults.spreadingFactor, - codingRate: UInt8 = Device.Defaults.codingRate, - txPower: Int8 = Device.Defaults.txPower, - maxTxPower: Int8 = Device.Defaults.maxTxPower, - latitude: Double = Device.Defaults.latitude, - longitude: Double = Device.Defaults.longitude, - blePin: UInt32 = Device.Defaults.blePin, - clientRepeat: Bool = Device.Defaults.clientRepeat, - pathHashMode: UInt8 = Device.Defaults.pathHashMode, - defaultFloodScopeName: String? = Device.Defaults.defaultFloodScopeName, - preRepeatFrequency: UInt32? = Device.Defaults.preRepeatFrequency, - preRepeatBandwidth: UInt32? = Device.Defaults.preRepeatBandwidth, - preRepeatSpreadingFactor: UInt8? = Device.Defaults.preRepeatSpreadingFactor, - preRepeatCodingRate: UInt8? = Device.Defaults.preRepeatCodingRate, - manualAddContacts: Bool = Device.Defaults.manualAddContacts, - autoAddConfig: UInt8 = Device.Defaults.autoAddConfig, - autoAddMaxHops: UInt8 = Device.Defaults.autoAddMaxHops, - multiAcks: UInt8 = Device.Defaults.multiAcks, - telemetryModeBase: UInt8 = Device.Defaults.telemetryModeBase, - telemetryModeLoc: UInt8 = Device.Defaults.telemetryModeLoc, - telemetryModeEnv: UInt8 = Device.Defaults.telemetryModeEnv, - advertLocationPolicy: UInt8 = Device.Defaults.advertLocationPolicy, - lastConnected: Date = Date(), - lastContactSync: UInt32 = 0, - isActive: Bool = false, - ocvPreset: String? = nil, - customOCVArrayString: String? = nil, - connectionMethods: [ConnectionMethod] = [], - knownRegions: [String] = [] - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.nodeName = nodeName - self.firmwareVersion = firmwareVersion - self.firmwareVersionString = firmwareVersionString - self.manufacturerName = manufacturerName - self.buildDate = buildDate - self.maxContacts = maxContacts - self.maxChannels = maxChannels - self.frequency = frequency - self.bandwidth = bandwidth - self.spreadingFactor = spreadingFactor - self.codingRate = codingRate - self.txPower = txPower - self.maxTxPower = maxTxPower - self.latitude = latitude - self.longitude = longitude - self.blePin = blePin - self.clientRepeat = clientRepeat - self.pathHashMode = pathHashMode - self.defaultFloodScopeName = defaultFloodScopeName - self.preRepeatFrequency = preRepeatFrequency - self.preRepeatBandwidth = preRepeatBandwidth - self.preRepeatSpreadingFactor = preRepeatSpreadingFactor - self.preRepeatCodingRate = preRepeatCodingRate - self.manualAddContacts = manualAddContacts - self.autoAddConfig = autoAddConfig - self.autoAddMaxHops = autoAddMaxHops - self.multiAcks = multiAcks - self.telemetryModeBase = telemetryModeBase - self.telemetryModeLoc = telemetryModeLoc - self.telemetryModeEnv = telemetryModeEnv - self.advertLocationPolicy = advertLocationPolicy - self.lastConnected = lastConnected - self.lastContactSync = lastContactSync - self.isActive = isActive - self.ocvPreset = ocvPreset - self.customOCVArrayString = customOCVArrayString - self.connectionMethods = connectionMethods - self.knownRegions = knownRegions - } - - /// Builds a model instance directly from a DTO. Shared by sync and backup - /// batch-insert paths so they can't drift on field coverage. - public convenience init(dto: DeviceDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - publicKey: dto.publicKey, - nodeName: dto.nodeName, - firmwareVersion: dto.firmwareVersion, - firmwareVersionString: dto.firmwareVersionString, - manufacturerName: dto.manufacturerName, - buildDate: dto.buildDate, - maxContacts: dto.maxContacts, - maxChannels: dto.maxChannels, - frequency: dto.frequency, - bandwidth: dto.bandwidth, - spreadingFactor: dto.spreadingFactor, - codingRate: dto.codingRate, - txPower: dto.txPower, - maxTxPower: dto.maxTxPower, - latitude: dto.latitude, - longitude: dto.longitude, - blePin: dto.blePin, - clientRepeat: dto.clientRepeat, - pathHashMode: dto.pathHashMode, - defaultFloodScopeName: dto.defaultFloodScopeName, - preRepeatFrequency: dto.preRepeatFrequency, - preRepeatBandwidth: dto.preRepeatBandwidth, - preRepeatSpreadingFactor: dto.preRepeatSpreadingFactor, - preRepeatCodingRate: dto.preRepeatCodingRate, - manualAddContacts: dto.manualAddContacts, - autoAddConfig: dto.autoAddConfig, - autoAddMaxHops: dto.autoAddMaxHops, - multiAcks: dto.multiAcks, - telemetryModeBase: dto.telemetryModeBase, - telemetryModeLoc: dto.telemetryModeLoc, - telemetryModeEnv: dto.telemetryModeEnv, - advertLocationPolicy: dto.advertLocationPolicy, - lastConnected: dto.lastConnected, - lastContactSync: dto.lastContactSync, - isActive: dto.isActive, - ocvPreset: dto.ocvPreset, - customOCVArrayString: dto.customOCVArrayString, - connectionMethods: dto.connectionMethods, - knownRegions: dto.knownRegions - ) - } - - /// Applies all mutable fields from a DTO to this model instance. - /// Unlike `Channel.apply` and `Contact.apply`, the identity fields - /// `publicKey` and `radioID` are deliberately rewritten: rows are matched - /// by the stable `id`, runtime key rotation must persist the new key, and - /// ghost reconciliation after a partial backup import depends on the - /// overwrite (see `ConnectionManager.reconcileIdentity`). - func apply(_ dto: DeviceDTO) { - radioID = dto.radioID - publicKey = dto.publicKey - nodeName = dto.nodeName - firmwareVersion = dto.firmwareVersion - firmwareVersionString = dto.firmwareVersionString - manufacturerName = dto.manufacturerName - buildDate = dto.buildDate - maxContacts = dto.maxContacts - maxChannels = dto.maxChannels - frequency = dto.frequency - bandwidth = dto.bandwidth - spreadingFactor = dto.spreadingFactor - codingRate = dto.codingRate - txPower = dto.txPower - maxTxPower = dto.maxTxPower - latitude = dto.latitude - longitude = dto.longitude - blePin = dto.blePin - clientRepeat = dto.clientRepeat - pathHashMode = dto.pathHashMode - defaultFloodScopeName = dto.defaultFloodScopeName - preRepeatFrequency = dto.preRepeatFrequency - preRepeatBandwidth = dto.preRepeatBandwidth - preRepeatSpreadingFactor = dto.preRepeatSpreadingFactor - preRepeatCodingRate = dto.preRepeatCodingRate - manualAddContacts = dto.manualAddContacts - autoAddConfig = dto.autoAddConfig - autoAddMaxHops = dto.autoAddMaxHops - multiAcks = dto.multiAcks - telemetryModeBase = dto.telemetryModeBase - telemetryModeLoc = dto.telemetryModeLoc - telemetryModeEnv = dto.telemetryModeEnv - advertLocationPolicy = dto.advertLocationPolicy - lastConnected = dto.lastConnected - lastContactSync = dto.lastContactSync - isActive = dto.isActive - ocvPreset = dto.ocvPreset - customOCVArrayString = dto.customOCVArrayString - connectionMethods = dto.connectionMethods - // knownRegions is app-only state owned solely by addDeviceKnownRegion/ - // removeDeviceKnownRegion. It is excluded from this full overwrite so a - // stale or empty DTO from a read-snapshot-then-save caller cannot erase - // regions the targeted path committed. First write is Device(dto:) at insert. - } + /// BLE PIN (0 = disabled, 100000-999999 = enabled) + public var blePin: UInt32 + + /// Whether client repeat mode is enabled (v9+ firmware) + public var clientRepeat: Bool = false + + /// Path hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). Firmware v10+. + public var pathHashMode: UInt8 = 0 + + /// Name of the device's persisted default flood scope, or `nil` when cleared. Firmware v11+. + /// The scope key is derived from this name on-demand via ``MeshCore/FloodScope/region(_:)``; + /// only the name is cached here because the user-facing flow selects regions by name. + public var defaultFloodScopeName: String? + + /// Cached radio settings from before repeat mode was enabled, for restoration on disable. + /// All 4 fields are set together when enabling repeat mode, and cleared together when disabling. + public var preRepeatFrequency: UInt32? + public var preRepeatBandwidth: UInt32? + public var preRepeatSpreadingFactor: UInt8? + public var preRepeatCodingRate: UInt8? + + /// Manual add contacts mode + public var manualAddContacts: Bool + + /// Auto-add configuration bitmask from device + public var autoAddConfig: UInt8 = 0 + + /// Maximum hops for auto-add filtering. 0 = no limit, 1 = direct only, N = up to N-1 hops. + public var autoAddMaxHops: UInt8 = 0 + + /// Number of acknowledgments to send for direct messages (0=disabled, 1-2 typical) + public var multiAcks: UInt8 + + /// Telemetry mode for base data + public var telemetryModeBase: UInt8 + + /// Telemetry mode for location data + public var telemetryModeLoc: UInt8 + + /// Telemetry mode for environment data + public var telemetryModeEnv: UInt8 + + /// Advertisement location policy + public var advertLocationPolicy: UInt8 + + /// Last time the device was connected + public var lastConnected: Date + + /// Last sync timestamp for contacts (watermark for incremental sync) + public var lastContactSync: UInt32 + + /// Whether this is the currently active device + public var isActive: Bool + + /// Selected OCV preset name (nil = liIon default) + public var ocvPreset: String? + + /// Custom OCV array as comma-separated string (e.g., "4240,4112,4029,...") + public var customOCVArrayString: String? + + /// Connection methods available for this device (BLE, WiFi, etc.) + public var connectionMethods: [ConnectionMethod] = [] + + /// Region codes known to this device + public var knownRegions: [String] = [] + + public init( + id: UUID = UUID(), + radioID: UUID = UUID(), + publicKey: Data, + nodeName: String, + firmwareVersion: UInt8 = 0, + firmwareVersionString: String = "", + manufacturerName: String = "", + buildDate: String = "", + maxContacts: UInt16 = 100, + maxChannels: UInt8 = 8, + frequency: UInt32 = Device.Defaults.frequency, + bandwidth: UInt32 = Device.Defaults.bandwidth, + spreadingFactor: UInt8 = Device.Defaults.spreadingFactor, + codingRate: UInt8 = Device.Defaults.codingRate, + txPower: Int8 = Device.Defaults.txPower, + maxTxPower: Int8 = Device.Defaults.maxTxPower, + latitude: Double = Device.Defaults.latitude, + longitude: Double = Device.Defaults.longitude, + blePin: UInt32 = Device.Defaults.blePin, + clientRepeat: Bool = Device.Defaults.clientRepeat, + pathHashMode: UInt8 = Device.Defaults.pathHashMode, + defaultFloodScopeName: String? = Device.Defaults.defaultFloodScopeName, + preRepeatFrequency: UInt32? = Device.Defaults.preRepeatFrequency, + preRepeatBandwidth: UInt32? = Device.Defaults.preRepeatBandwidth, + preRepeatSpreadingFactor: UInt8? = Device.Defaults.preRepeatSpreadingFactor, + preRepeatCodingRate: UInt8? = Device.Defaults.preRepeatCodingRate, + manualAddContacts: Bool = Device.Defaults.manualAddContacts, + autoAddConfig: UInt8 = Device.Defaults.autoAddConfig, + autoAddMaxHops: UInt8 = Device.Defaults.autoAddMaxHops, + multiAcks: UInt8 = Device.Defaults.multiAcks, + telemetryModeBase: UInt8 = Device.Defaults.telemetryModeBase, + telemetryModeLoc: UInt8 = Device.Defaults.telemetryModeLoc, + telemetryModeEnv: UInt8 = Device.Defaults.telemetryModeEnv, + advertLocationPolicy: UInt8 = Device.Defaults.advertLocationPolicy, + lastConnected: Date = Date(), + lastContactSync: UInt32 = 0, + isActive: Bool = false, + ocvPreset: String? = nil, + customOCVArrayString: String? = nil, + connectionMethods: [ConnectionMethod] = [], + knownRegions: [String] = [] + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.nodeName = nodeName + self.firmwareVersion = firmwareVersion + self.firmwareVersionString = firmwareVersionString + self.manufacturerName = manufacturerName + self.buildDate = buildDate + self.maxContacts = maxContacts + self.maxChannels = maxChannels + self.frequency = frequency + self.bandwidth = bandwidth + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + self.txPower = txPower + self.maxTxPower = maxTxPower + self.latitude = latitude + self.longitude = longitude + self.blePin = blePin + self.clientRepeat = clientRepeat + self.pathHashMode = pathHashMode + self.defaultFloodScopeName = defaultFloodScopeName + self.preRepeatFrequency = preRepeatFrequency + self.preRepeatBandwidth = preRepeatBandwidth + self.preRepeatSpreadingFactor = preRepeatSpreadingFactor + self.preRepeatCodingRate = preRepeatCodingRate + self.manualAddContacts = manualAddContacts + self.autoAddConfig = autoAddConfig + self.autoAddMaxHops = autoAddMaxHops + self.multiAcks = multiAcks + self.telemetryModeBase = telemetryModeBase + self.telemetryModeLoc = telemetryModeLoc + self.telemetryModeEnv = telemetryModeEnv + self.advertLocationPolicy = advertLocationPolicy + self.lastConnected = lastConnected + self.lastContactSync = lastContactSync + self.isActive = isActive + self.ocvPreset = ocvPreset + self.customOCVArrayString = customOCVArrayString + self.connectionMethods = connectionMethods + self.knownRegions = knownRegions + } + + /// Builds a model instance directly from a DTO. Shared by sync and backup + /// batch-insert paths so they can't drift on field coverage. + public convenience init(dto: DeviceDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + publicKey: dto.publicKey, + nodeName: dto.nodeName, + firmwareVersion: dto.firmwareVersion, + firmwareVersionString: dto.firmwareVersionString, + manufacturerName: dto.manufacturerName, + buildDate: dto.buildDate, + maxContacts: dto.maxContacts, + maxChannels: dto.maxChannels, + frequency: dto.frequency, + bandwidth: dto.bandwidth, + spreadingFactor: dto.spreadingFactor, + codingRate: dto.codingRate, + txPower: dto.txPower, + maxTxPower: dto.maxTxPower, + latitude: dto.latitude, + longitude: dto.longitude, + blePin: dto.blePin, + clientRepeat: dto.clientRepeat, + pathHashMode: dto.pathHashMode, + defaultFloodScopeName: dto.defaultFloodScopeName, + preRepeatFrequency: dto.preRepeatFrequency, + preRepeatBandwidth: dto.preRepeatBandwidth, + preRepeatSpreadingFactor: dto.preRepeatSpreadingFactor, + preRepeatCodingRate: dto.preRepeatCodingRate, + manualAddContacts: dto.manualAddContacts, + autoAddConfig: dto.autoAddConfig, + autoAddMaxHops: dto.autoAddMaxHops, + multiAcks: dto.multiAcks, + telemetryModeBase: dto.telemetryModeBase, + telemetryModeLoc: dto.telemetryModeLoc, + telemetryModeEnv: dto.telemetryModeEnv, + advertLocationPolicy: dto.advertLocationPolicy, + lastConnected: dto.lastConnected, + lastContactSync: dto.lastContactSync, + isActive: dto.isActive, + ocvPreset: dto.ocvPreset, + customOCVArrayString: dto.customOCVArrayString, + connectionMethods: dto.connectionMethods, + knownRegions: dto.knownRegions + ) + } + + /// Applies all mutable fields from a DTO to this model instance. + /// Unlike `Channel.apply` and `Contact.apply`, the identity fields + /// `publicKey` and `radioID` are deliberately rewritten: rows are matched + /// by the stable `id`, runtime key rotation must persist the new key, and + /// ghost reconciliation after a partial backup import depends on the + /// overwrite (see `ConnectionManager.reconcileIdentity`). + func apply(_ dto: DeviceDTO) { + radioID = dto.radioID + publicKey = dto.publicKey + nodeName = dto.nodeName + firmwareVersion = dto.firmwareVersion + firmwareVersionString = dto.firmwareVersionString + manufacturerName = dto.manufacturerName + buildDate = dto.buildDate + maxContacts = dto.maxContacts + maxChannels = dto.maxChannels + frequency = dto.frequency + bandwidth = dto.bandwidth + spreadingFactor = dto.spreadingFactor + codingRate = dto.codingRate + txPower = dto.txPower + maxTxPower = dto.maxTxPower + latitude = dto.latitude + longitude = dto.longitude + blePin = dto.blePin + clientRepeat = dto.clientRepeat + pathHashMode = dto.pathHashMode + defaultFloodScopeName = dto.defaultFloodScopeName + preRepeatFrequency = dto.preRepeatFrequency + preRepeatBandwidth = dto.preRepeatBandwidth + preRepeatSpreadingFactor = dto.preRepeatSpreadingFactor + preRepeatCodingRate = dto.preRepeatCodingRate + manualAddContacts = dto.manualAddContacts + autoAddConfig = dto.autoAddConfig + autoAddMaxHops = dto.autoAddMaxHops + multiAcks = dto.multiAcks + telemetryModeBase = dto.telemetryModeBase + telemetryModeLoc = dto.telemetryModeLoc + telemetryModeEnv = dto.telemetryModeEnv + advertLocationPolicy = dto.advertLocationPolicy + lastConnected = dto.lastConnected + lastContactSync = dto.lastContactSync + isActive = dto.isActive + ocvPreset = dto.ocvPreset + customOCVArrayString = dto.customOCVArrayString + connectionMethods = dto.connectionMethods + // knownRegions is app-only state owned solely by addDeviceKnownRegion/ + // removeDeviceKnownRegion. It is excluded from this full overwrite so a + // stale or empty DTO from a read-snapshot-then-save caller cannot erase + // regions the targeted path committed. First write is Device(dto:) at insert. + } } // MARK: - Sendable DTO /// A sendable snapshot of Device for cross-actor transfers public struct DeviceDTO: Sendable, Equatable, Identifiable, Codable { - public var id: UUID - public var radioID: UUID - public var publicKey: Data - public var nodeName: String - public var firmwareVersion: UInt8 - public var firmwareVersionString: String - public var manufacturerName: String - public var buildDate: String - public var maxContacts: UInt16 - public var maxChannels: UInt8 - public var frequency: UInt32 - public var bandwidth: UInt32 - public var spreadingFactor: UInt8 - public var codingRate: UInt8 - public var txPower: Int8 - public var maxTxPower: Int8 - public var latitude: Double - public var longitude: Double - public var blePin: UInt32 - public var clientRepeat: Bool - public var pathHashMode: UInt8 - - /// Name of the device's persisted default flood scope, or `nil` when cleared. Firmware v11+. - public var defaultFloodScopeName: String? - - /// The hash size per hop in bytes (1, 2, or 3), derived from ``pathHashMode``. - public var hashSize: Int { Int(pathHashMode) + 1 } - - /// Hash size per hop in trace packets (1, 2, or 4 bytes), derived from ``pathHashMode``. - /// Trace protocol uses power-of-2 encoding: `1 << pathHashMode`. - public var traceHashSize: Int { 1 << Int(pathHashMode) } - - public var hasLocation: Bool { - let hasNonZero = latitude != 0 || longitude != 0 - guard hasNonZero else { return false } - return CLLocationCoordinate2DIsValid( - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - ) - } - - public var preRepeatFrequency: UInt32? - public var preRepeatBandwidth: UInt32? - public var preRepeatSpreadingFactor: UInt8? - public var preRepeatCodingRate: UInt8? - public var manualAddContacts: Bool - public var autoAddConfig: UInt8 - public var autoAddMaxHops: UInt8 - public var multiAcks: UInt8 - public var telemetryModeBase: UInt8 - public var telemetryModeLoc: UInt8 - public var telemetryModeEnv: UInt8 - public var advertLocationPolicy: UInt8 - public var lastConnected: Date - public var lastContactSync: UInt32 - public var isActive: Bool - public var ocvPreset: String? - public var customOCVArrayString: String? - public var connectionMethods: [ConnectionMethod] - public var knownRegions: [String] - - /// Computed auto-add mode based on manualAddContacts and autoAddConfig - public var autoAddMode: AutoAddMode { - AutoAddMode.mode(manualAddContacts: manualAddContacts, autoAddConfig: autoAddConfig) - } - - /// Whether to auto-add Contact type nodes - public var autoAddContacts: Bool { - autoAddConfig & AutoAddConfig.contactsBit != 0 - } - - /// Whether to auto-add Repeater type nodes - public var autoAddRepeaters: Bool { - autoAddConfig & AutoAddConfig.repeatersBit != 0 - } - - /// Whether to auto-add Room Server type nodes - public var autoAddRoomServers: Bool { - autoAddConfig & AutoAddConfig.roomServersBit != 0 - } - - /// Whether to overwrite oldest non-favorite when storage is full - public var overwriteOldest: Bool { - autoAddConfig & AutoAddConfig.overwriteOldestBit != 0 + public var id: UUID + public var radioID: UUID + public var publicKey: Data + public var nodeName: String + public var firmwareVersion: UInt8 + public var firmwareVersionString: String + public var manufacturerName: String + public var buildDate: String + public var maxContacts: UInt16 + public var maxChannels: UInt8 + public var frequency: UInt32 + public var bandwidth: UInt32 + public var spreadingFactor: UInt8 + public var codingRate: UInt8 + public var txPower: Int8 + public var maxTxPower: Int8 + public var latitude: Double + public var longitude: Double + public var blePin: UInt32 + public var clientRepeat: Bool + public var pathHashMode: UInt8 + + /// Name of the device's persisted default flood scope, or `nil` when cleared. Firmware v11+. + public var defaultFloodScopeName: String? + + /// The hash size per hop in bytes (1, 2, or 3), derived from ``pathHashMode``. + public var hashSize: Int { + Int(pathHashMode) + 1 + } + + /// Hash size per hop in trace packets (1, 2, or 4 bytes), derived from ``pathHashMode``. + /// Trace protocol uses power-of-2 encoding: `1 << pathHashMode`. + public var traceHashSize: Int { + 1 << Int(pathHashMode) + } + + public var hasLocation: Bool { + let hasNonZero = latitude != 0 || longitude != 0 + guard hasNonZero else { return false } + return CLLocationCoordinate2DIsValid( + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + } + + public var preRepeatFrequency: UInt32? + public var preRepeatBandwidth: UInt32? + public var preRepeatSpreadingFactor: UInt8? + public var preRepeatCodingRate: UInt8? + public var manualAddContacts: Bool + public var autoAddConfig: UInt8 + public var autoAddMaxHops: UInt8 + public var multiAcks: UInt8 + public var telemetryModeBase: UInt8 + public var telemetryModeLoc: UInt8 + public var telemetryModeEnv: UInt8 + public var advertLocationPolicy: UInt8 + public var lastConnected: Date + public var lastContactSync: UInt32 + public var isActive: Bool + public var ocvPreset: String? + public var customOCVArrayString: String? + public var connectionMethods: [ConnectionMethod] + public var knownRegions: [String] + + /// Computed auto-add mode based on manualAddContacts and autoAddConfig + public var autoAddMode: AutoAddMode { + AutoAddMode.mode(manualAddContacts: manualAddContacts, autoAddConfig: autoAddConfig) + } + + /// Whether to auto-add Contact type nodes + public var autoAddContacts: Bool { + autoAddConfig & AutoAddConfig.contactsBit != 0 + } + + /// Whether to auto-add Repeater type nodes + public var autoAddRepeaters: Bool { + autoAddConfig & AutoAddConfig.repeatersBit != 0 + } + + /// Whether to auto-add Room Server type nodes + public var autoAddRoomServers: Bool { + autoAddConfig & AutoAddConfig.roomServersBit != 0 + } + + /// Whether to overwrite oldest non-favorite when storage is full + public var overwriteOldest: Bool { + autoAddConfig & AutoAddConfig.overwriteOldestBit != 0 + } + + /// Whether the device supports auto-add configuration (v1.12+) + /// Devices with older firmware only support manualAddContacts toggle + public var supportsAutoAddConfig: Bool { + firmwareVersionString.isAtLeast(major: 1, minor: 12) + } + + /// Whether the device supports auto-add max hops (v1.14+) + public var supportsAutoAddMaxHops: Bool { + firmwareVersionString.isAtLeast(major: 1, minor: 14) + } + + /// Whether the trace command honors a per-trace hash size in its flags byte, + /// letting a trace use a hash size different from `pathHashMode`. Added in + /// firmware v1.11.0; FIRMWARE_VER_CODE stayed 8 from v1.10.0 through v1.12.0, + /// so the version string disambiguates the v1.11/v1.12 window while + /// firmwareVersion >= 9 (v1.13+) still holds if a fork rewrites the string. + public var supportsTraceHashSizeOverride: Bool { + firmwareVersion >= 9 || firmwareVersionString.isAtLeast(major: 1, minor: 11) + } + + /// Whether this device supports client repeat mode (firmware v9+) + public var supportsClientRepeat: Bool { + firmwareVersion >= 9 + } + + /// Whether this device supports path hash mode configuration (firmware v10+) + public var supportsPathHashMode: Bool { + firmwareVersion >= 10 + } + + /// Whether this device supports the persisted default flood scope (firmware v11+). + public var supportsDefaultFloodScope: Bool { + firmwareVersion >= 11 + } + + /// Whether this device supports forcing un-scoped flood broadcasts that override + /// the persisted default flood scope (firmware v12+). + public var supportsUnscopedFloodSend: Bool { + firmwareVersion >= 12 + } + + /// Whether the radio honors an anonymous request to a non-contact public key, + /// auto-adding a temporary zero-hop entry so the app can query a nearby repeater + /// (e.g. for region discovery) without first adding it. Added with + /// FIRMWARE_VER_CODE 13; the version string covers a fork that leaves it unbumped. + public var supportsAdHocRepeaterRequest: Bool { + firmwareVersion >= 13 || firmwareVersionString.isAtLeast(major: 1, minor: 16) + } + + /// Advertisement location policy interpreted from raw value. + public var advertLocationPolicyMode: AdvertLocationPolicy { + AdvertLocationPolicy(rawValue: advertLocationPolicy) ?? .none + } + + /// Telemetry modes constructed from raw base/location/environment values. + public var telemetryModes: TelemetryModes { + TelemetryModes(base: telemetryModeBase, location: telemetryModeLoc, environment: telemetryModeEnv) + } + + /// Whether location is shared publicly in advertisements. + public var sharesLocationPublicly: Bool { + advertLocationPolicy > 0 + } + + /// Whether pre-repeat radio settings are saved for restoration. + public var hasPreRepeatSettings: Bool { + preRepeatFrequency != nil && preRepeatBandwidth != nil && + preRepeatSpreadingFactor != nil && preRepeatCodingRate != nil + } + + public init( + id: UUID, + radioID: UUID = UUID(), + publicKey: Data, + nodeName: String, + firmwareVersion: UInt8, + firmwareVersionString: String, + manufacturerName: String, + buildDate: String, + maxContacts: UInt16, + maxChannels: UInt8, + frequency: UInt32, + bandwidth: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8, + txPower: Int8, + maxTxPower: Int8, + latitude: Double, + longitude: Double, + blePin: UInt32, + clientRepeat: Bool = false, + pathHashMode: UInt8 = 0, + defaultFloodScopeName: String? = nil, + preRepeatFrequency: UInt32? = nil, + preRepeatBandwidth: UInt32? = nil, + preRepeatSpreadingFactor: UInt8? = nil, + preRepeatCodingRate: UInt8? = nil, + manualAddContacts: Bool, + autoAddConfig: UInt8 = 0, + autoAddMaxHops: UInt8 = 0, + multiAcks: UInt8, + telemetryModeBase: UInt8, + telemetryModeLoc: UInt8, + telemetryModeEnv: UInt8, + advertLocationPolicy: UInt8, + lastConnected: Date, + lastContactSync: UInt32, + isActive: Bool, + ocvPreset: String?, + customOCVArrayString: String?, + connectionMethods: [ConnectionMethod] = [], + knownRegions: [String] = [] + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.nodeName = nodeName + self.firmwareVersion = firmwareVersion + self.firmwareVersionString = firmwareVersionString + self.manufacturerName = manufacturerName + self.buildDate = buildDate + self.maxContacts = maxContacts + self.maxChannels = maxChannels + self.frequency = frequency + self.bandwidth = bandwidth + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + self.txPower = txPower + self.maxTxPower = maxTxPower + self.latitude = latitude + self.longitude = longitude + self.blePin = blePin + self.clientRepeat = clientRepeat + self.pathHashMode = pathHashMode + self.defaultFloodScopeName = defaultFloodScopeName + self.preRepeatFrequency = preRepeatFrequency + self.preRepeatBandwidth = preRepeatBandwidth + self.preRepeatSpreadingFactor = preRepeatSpreadingFactor + self.preRepeatCodingRate = preRepeatCodingRate + self.manualAddContacts = manualAddContacts + self.autoAddConfig = autoAddConfig + self.autoAddMaxHops = autoAddMaxHops + self.multiAcks = multiAcks + self.telemetryModeBase = telemetryModeBase + self.telemetryModeLoc = telemetryModeLoc + self.telemetryModeEnv = telemetryModeEnv + self.advertLocationPolicy = advertLocationPolicy + self.lastConnected = lastConnected + self.lastContactSync = lastContactSync + self.isActive = isActive + self.ocvPreset = ocvPreset + self.customOCVArrayString = customOCVArrayString + self.connectionMethods = connectionMethods + self.knownRegions = knownRegions + } + + public init(from device: Device) { + id = device.id + radioID = device.radioID + publicKey = device.publicKey + nodeName = device.nodeName + firmwareVersion = device.firmwareVersion + firmwareVersionString = device.firmwareVersionString + manufacturerName = device.manufacturerName + buildDate = device.buildDate + maxContacts = device.maxContacts + maxChannels = device.maxChannels + frequency = device.frequency + bandwidth = device.bandwidth + spreadingFactor = device.spreadingFactor + codingRate = device.codingRate + txPower = device.txPower + maxTxPower = device.maxTxPower + latitude = device.latitude + longitude = device.longitude + blePin = device.blePin + clientRepeat = device.clientRepeat + pathHashMode = device.pathHashMode + defaultFloodScopeName = device.defaultFloodScopeName + preRepeatFrequency = device.preRepeatFrequency + preRepeatBandwidth = device.preRepeatBandwidth + preRepeatSpreadingFactor = device.preRepeatSpreadingFactor + preRepeatCodingRate = device.preRepeatCodingRate + manualAddContacts = device.manualAddContacts + autoAddConfig = device.autoAddConfig + autoAddMaxHops = device.autoAddMaxHops + multiAcks = device.multiAcks + telemetryModeBase = device.telemetryModeBase + telemetryModeLoc = device.telemetryModeLoc + telemetryModeEnv = device.telemetryModeEnv + advertLocationPolicy = device.advertLocationPolicy + lastConnected = device.lastConnected + lastContactSync = device.lastContactSync + isActive = device.isActive + ocvPreset = device.ocvPreset + customOCVArrayString = device.customOCVArrayString + connectionMethods = device.connectionMethods + knownRegions = device.knownRegions + } + + /// The 6-byte public key prefix used for identifying messages + public var publicKeyPrefix: Data { + publicKey.prefix(6) + } + + /// Returns a new DeviceDTO with the given mutations applied. + public func copy(_ mutations: (inout DeviceDTO) -> Void) -> DeviceDTO { + var copy = self + mutations(©) + return copy + } + + /// The active OCV array for this device (preset or custom) + public var activeOCVArray: [Int] { + // If custom preset with valid custom string, parse it + if ocvPreset == OCVPreset.custom.rawValue, let customString = customOCVArrayString { + let parsed = customString.split(separator: ",") + .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if parsed.count == 11 { + return parsed + } } - /// Whether the device supports auto-add configuration (v1.12+) - /// Devices with older firmware only support manualAddContacts toggle - public var supportsAutoAddConfig: Bool { - firmwareVersionString.isAtLeast(major: 1, minor: 12) + // Use preset if set + if let presetName = ocvPreset, let preset = OCVPreset(rawValue: presetName) { + return preset.ocvArray } - /// Whether the device supports auto-add max hops (v1.14+) - public var supportsAutoAddMaxHops: Bool { - firmwareVersionString.isAtLeast(major: 1, minor: 14) + // Default to Li-Ion + return OCVPreset.liIon.ocvArray + } + + /// Returns a new DeviceDTO with settings updated from SelfInfo. + /// Used after device settings are changed via SettingsService. + public func updating(from selfInfo: MeshCore.SelfInfo) -> DeviceDTO { + copy { + $0.publicKey = selfInfo.publicKey + $0.nodeName = selfInfo.name + $0.frequency = UInt32(selfInfo.radioFrequency * 1000) + $0.bandwidth = UInt32(selfInfo.radioBandwidth * 1000) + $0.spreadingFactor = selfInfo.radioSpreadingFactor + $0.codingRate = selfInfo.radioCodingRate + $0.txPower = selfInfo.txPower + $0.latitude = selfInfo.latitude + $0.longitude = selfInfo.longitude + $0.manualAddContacts = selfInfo.manualAddContacts + $0.multiAcks = selfInfo.multiAcks + $0.telemetryModeBase = selfInfo.telemetryModeBase + $0.telemetryModeLoc = selfInfo.telemetryModeLocation + $0.telemetryModeEnv = selfInfo.telemetryModeEnvironment + $0.advertLocationPolicy = selfInfo.advertisementLocationPolicy } - - /// Whether the trace command honors a per-trace hash size in its flags byte, - /// letting a trace use a hash size different from `pathHashMode`. Added in - /// firmware v1.11.0; FIRMWARE_VER_CODE stayed 8 from v1.10.0 through v1.12.0, - /// so the version string disambiguates the v1.11/v1.12 window while - /// firmwareVersion >= 9 (v1.13+) still holds if a fork rewrites the string. - public var supportsTraceHashSizeOverride: Bool { - firmwareVersion >= 9 || firmwareVersionString.isAtLeast(major: 1, minor: 11) + } + + /// Returns a new DeviceDTO with current radio settings saved as pre-repeat settings. + public func savingPreRepeatSettings() -> DeviceDTO { + copy { + $0.preRepeatFrequency = frequency + $0.preRepeatBandwidth = bandwidth + $0.preRepeatSpreadingFactor = spreadingFactor + $0.preRepeatCodingRate = codingRate } - - /// Whether this device supports client repeat mode (firmware v9+) - public var supportsClientRepeat: Bool { firmwareVersion >= 9 } - - /// Whether this device supports path hash mode configuration (firmware v10+) - public var supportsPathHashMode: Bool { firmwareVersion >= 10 } - - /// Whether this device supports the persisted default flood scope (firmware v11+). - public var supportsDefaultFloodScope: Bool { firmwareVersion >= 11 } - - /// Whether this device supports forcing un-scoped flood broadcasts that override - /// the persisted default flood scope (firmware v12+). - public var supportsUnscopedFloodSend: Bool { firmwareVersion >= 12 } - - /// Whether the radio honors an anonymous request to a non-contact public key, - /// auto-adding a temporary zero-hop entry so the app can query a nearby repeater - /// (e.g. for region discovery) without first adding it. Added with - /// FIRMWARE_VER_CODE 13; the version string covers a fork that leaves it unbumped. - public var supportsAdHocRepeaterRequest: Bool { - firmwareVersion >= 13 || firmwareVersionString.isAtLeast(major: 1, minor: 16) + } + + /// Returns a new DeviceDTO with pre-repeat settings cleared. + public func clearingPreRepeatSettings() -> DeviceDTO { + copy { + $0.preRepeatFrequency = nil + $0.preRepeatBandwidth = nil + $0.preRepeatSpreadingFactor = nil + $0.preRepeatCodingRate = nil } - - /// Advertisement location policy interpreted from raw value. - public var advertLocationPolicyMode: AdvertLocationPolicy { - AdvertLocationPolicy(rawValue: advertLocationPolicy) ?? .none + } + + /// Returns a copy with `isActive` cleared and any Bluetooth connection + /// methods stripped. Used on backup import so a restored device isn't + /// mistaken for the currently-connected radio, and so legacy backups + /// that shipped stale `CBPeripheral.identifier` values don't produce a + /// permanently-disabled row in `DeviceSelectionSheet`. + public func cleanedForImport() -> DeviceDTO { + copy { + $0.isActive = false + $0.connectionMethods = connectionMethods.filter { !$0.isBluetooth } } - - /// Telemetry modes constructed from raw base/location/environment values. - public var telemetryModes: TelemetryModes { - TelemetryModes(base: telemetryModeBase, location: telemetryModeLoc, environment: telemetryModeEnv) - } - - /// Whether location is shared publicly in advertisements. - public var sharesLocationPublicly: Bool { advertLocationPolicy > 0 } - - /// Whether pre-repeat radio settings are saved for restoration. - public var hasPreRepeatSettings: Bool { - preRepeatFrequency != nil && preRepeatBandwidth != nil && - preRepeatSpreadingFactor != nil && preRepeatCodingRate != nil - } - - public init( - id: UUID, - radioID: UUID = UUID(), - publicKey: Data, - nodeName: String, - firmwareVersion: UInt8, - firmwareVersionString: String, - manufacturerName: String, - buildDate: String, - maxContacts: UInt16, - maxChannels: UInt8, - frequency: UInt32, - bandwidth: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8, - txPower: Int8, - maxTxPower: Int8, - latitude: Double, - longitude: Double, - blePin: UInt32, - clientRepeat: Bool = false, - pathHashMode: UInt8 = 0, - defaultFloodScopeName: String? = nil, - preRepeatFrequency: UInt32? = nil, - preRepeatBandwidth: UInt32? = nil, - preRepeatSpreadingFactor: UInt8? = nil, - preRepeatCodingRate: UInt8? = nil, - manualAddContacts: Bool, - autoAddConfig: UInt8 = 0, - autoAddMaxHops: UInt8 = 0, - multiAcks: UInt8, - telemetryModeBase: UInt8, - telemetryModeLoc: UInt8, - telemetryModeEnv: UInt8, - advertLocationPolicy: UInt8, - lastConnected: Date, - lastContactSync: UInt32, - isActive: Bool, - ocvPreset: String?, - customOCVArrayString: String?, - connectionMethods: [ConnectionMethod] = [], - knownRegions: [String] = [] - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.nodeName = nodeName - self.firmwareVersion = firmwareVersion - self.firmwareVersionString = firmwareVersionString - self.manufacturerName = manufacturerName - self.buildDate = buildDate - self.maxContacts = maxContacts - self.maxChannels = maxChannels - self.frequency = frequency - self.bandwidth = bandwidth - self.spreadingFactor = spreadingFactor - self.codingRate = codingRate - self.txPower = txPower - self.maxTxPower = maxTxPower - self.latitude = latitude - self.longitude = longitude - self.blePin = blePin - self.clientRepeat = clientRepeat - self.pathHashMode = pathHashMode - self.defaultFloodScopeName = defaultFloodScopeName - self.preRepeatFrequency = preRepeatFrequency - self.preRepeatBandwidth = preRepeatBandwidth - self.preRepeatSpreadingFactor = preRepeatSpreadingFactor - self.preRepeatCodingRate = preRepeatCodingRate - self.manualAddContacts = manualAddContacts - self.autoAddConfig = autoAddConfig - self.autoAddMaxHops = autoAddMaxHops - self.multiAcks = multiAcks - self.telemetryModeBase = telemetryModeBase - self.telemetryModeLoc = telemetryModeLoc - self.telemetryModeEnv = telemetryModeEnv - self.advertLocationPolicy = advertLocationPolicy - self.lastConnected = lastConnected - self.lastContactSync = lastContactSync - self.isActive = isActive - self.ocvPreset = ocvPreset - self.customOCVArrayString = customOCVArrayString - self.connectionMethods = connectionMethods - self.knownRegions = knownRegions - } - - public init(from device: Device) { - self.id = device.id - self.radioID = device.radioID - self.publicKey = device.publicKey - self.nodeName = device.nodeName - self.firmwareVersion = device.firmwareVersion - self.firmwareVersionString = device.firmwareVersionString - self.manufacturerName = device.manufacturerName - self.buildDate = device.buildDate - self.maxContacts = device.maxContacts - self.maxChannels = device.maxChannels - self.frequency = device.frequency - self.bandwidth = device.bandwidth - self.spreadingFactor = device.spreadingFactor - self.codingRate = device.codingRate - self.txPower = device.txPower - self.maxTxPower = device.maxTxPower - self.latitude = device.latitude - self.longitude = device.longitude - self.blePin = device.blePin - self.clientRepeat = device.clientRepeat - self.pathHashMode = device.pathHashMode - self.defaultFloodScopeName = device.defaultFloodScopeName - self.preRepeatFrequency = device.preRepeatFrequency - self.preRepeatBandwidth = device.preRepeatBandwidth - self.preRepeatSpreadingFactor = device.preRepeatSpreadingFactor - self.preRepeatCodingRate = device.preRepeatCodingRate - self.manualAddContacts = device.manualAddContacts - self.autoAddConfig = device.autoAddConfig - self.autoAddMaxHops = device.autoAddMaxHops - self.multiAcks = device.multiAcks - self.telemetryModeBase = device.telemetryModeBase - self.telemetryModeLoc = device.telemetryModeLoc - self.telemetryModeEnv = device.telemetryModeEnv - self.advertLocationPolicy = device.advertLocationPolicy - self.lastConnected = device.lastConnected - self.lastContactSync = device.lastContactSync - self.isActive = device.isActive - self.ocvPreset = device.ocvPreset - self.customOCVArrayString = device.customOCVArrayString - self.connectionMethods = device.connectionMethods - self.knownRegions = device.knownRegions - } - - /// The 6-byte public key prefix used for identifying messages - public var publicKeyPrefix: Data { - publicKey.prefix(6) - } - - /// Returns a new DeviceDTO with the given mutations applied. - public func copy(_ mutations: (inout DeviceDTO) -> Void) -> DeviceDTO { - var copy = self - mutations(©) - return copy - } - - /// The active OCV array for this device (preset or custom) - public var activeOCVArray: [Int] { - // If custom preset with valid custom string, parse it - if ocvPreset == OCVPreset.custom.rawValue, let customString = customOCVArrayString { - let parsed = customString.split(separator: ",") - .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } - if parsed.count == 11 { - return parsed - } - } - - // Use preset if set - if let presetName = ocvPreset, let preset = OCVPreset(rawValue: presetName) { - return preset.ocvArray - } - - // Default to Li-Ion - return OCVPreset.liIon.ocvArray - } - - /// Returns a new DeviceDTO with settings updated from SelfInfo. - /// Used after device settings are changed via SettingsService. - public func updating(from selfInfo: MeshCore.SelfInfo) -> DeviceDTO { - copy { - $0.publicKey = selfInfo.publicKey - $0.nodeName = selfInfo.name - $0.frequency = UInt32(selfInfo.radioFrequency * 1000) - $0.bandwidth = UInt32(selfInfo.radioBandwidth * 1000) - $0.spreadingFactor = selfInfo.radioSpreadingFactor - $0.codingRate = selfInfo.radioCodingRate - $0.txPower = selfInfo.txPower - $0.latitude = selfInfo.latitude - $0.longitude = selfInfo.longitude - $0.manualAddContacts = selfInfo.manualAddContacts - $0.multiAcks = selfInfo.multiAcks - $0.telemetryModeBase = selfInfo.telemetryModeBase - $0.telemetryModeLoc = selfInfo.telemetryModeLocation - $0.telemetryModeEnv = selfInfo.telemetryModeEnvironment - $0.advertLocationPolicy = selfInfo.advertisementLocationPolicy - } - } - - /// Returns a new DeviceDTO with current radio settings saved as pre-repeat settings. - public func savingPreRepeatSettings() -> DeviceDTO { - copy { - $0.preRepeatFrequency = frequency - $0.preRepeatBandwidth = bandwidth - $0.preRepeatSpreadingFactor = spreadingFactor - $0.preRepeatCodingRate = codingRate - } - } - - /// Returns a new DeviceDTO with pre-repeat settings cleared. - public func clearingPreRepeatSettings() -> DeviceDTO { - copy { - $0.preRepeatFrequency = nil - $0.preRepeatBandwidth = nil - $0.preRepeatSpreadingFactor = nil - $0.preRepeatCodingRate = nil - } - } - - /// Returns a copy with `isActive` cleared and any Bluetooth connection - /// methods stripped. Used on backup import so a restored device isn't - /// mistaken for the currently-connected radio, and so legacy backups - /// that shipped stale `CBPeripheral.identifier` values don't produce a - /// permanently-disabled row in `DeviceSelectionSheet`. - public func cleanedForImport() -> DeviceDTO { - copy { - $0.isActive = false - $0.connectionMethods = connectionMethods.filter { !$0.isBluetooth } - } - } - - /// Returns a copy with radio configuration fields reset to safe defaults, - /// the surrogate `id` reset to a fresh UUID, and any Bluetooth connection - /// methods removed. Used during backup export to avoid exposing BLE PIN, - /// frequency, or the source phone's `CBPeripheral.identifier` (the BLE - /// `Device.id`) in the .mc1backup file — peripheral UUIDs are only - /// meaningful on the phone that paired the radio and are not portable - /// across installs. WiFi connection methods are intentionally retained - /// (restore-then-reconnect reads them) and are disclosed in the pre-export - /// Security Notice. `publicKey`/`radioID` are preserved as the - /// reconciliation/partition keys; import re-mints `id` again on insert. - public func redactedForBackup() -> DeviceDTO { - copy { - // Reset the surrogate id so the source phone's CBPeripheral UUID (Device.id for BLE - // radios) never travels in a shareable backup. publicKey/radioID are preserved as the - // reconciliation/partition keys; import re-mints id again on insert. For WiFi radios - // Device.id is DeviceIdentity.deriveUUID(from: publicKey), re-derivable from the - // retained publicKey, so the reset is doc-honesty plus BLE-identifier removal. - $0.id = UUID() - $0.frequency = Device.Defaults.frequency - $0.bandwidth = Device.Defaults.bandwidth - $0.spreadingFactor = Device.Defaults.spreadingFactor - $0.codingRate = Device.Defaults.codingRate - $0.txPower = Device.Defaults.txPower - $0.maxTxPower = Device.Defaults.maxTxPower - $0.latitude = Device.Defaults.latitude - $0.longitude = Device.Defaults.longitude - $0.blePin = Device.Defaults.blePin - $0.clientRepeat = Device.Defaults.clientRepeat - $0.pathHashMode = Device.Defaults.pathHashMode - $0.preRepeatFrequency = Device.Defaults.preRepeatFrequency - $0.preRepeatBandwidth = Device.Defaults.preRepeatBandwidth - $0.preRepeatSpreadingFactor = Device.Defaults.preRepeatSpreadingFactor - $0.preRepeatCodingRate = Device.Defaults.preRepeatCodingRate - $0.manualAddContacts = Device.Defaults.manualAddContacts - $0.autoAddConfig = Device.Defaults.autoAddConfig - $0.autoAddMaxHops = Device.Defaults.autoAddMaxHops - $0.multiAcks = Device.Defaults.multiAcks - $0.telemetryModeBase = Device.Defaults.telemetryModeBase - $0.telemetryModeLoc = Device.Defaults.telemetryModeLoc - $0.telemetryModeEnv = Device.Defaults.telemetryModeEnv - $0.advertLocationPolicy = Device.Defaults.advertLocationPolicy - $0.connectionMethods = connectionMethods.filter { !$0.isBluetooth } - } + } + + /// Returns a copy with radio configuration fields reset to safe defaults, + /// the surrogate `id` reset to a fresh UUID, and any Bluetooth connection + /// methods removed. Used during backup export to avoid exposing BLE PIN, + /// frequency, or the source phone's `CBPeripheral.identifier` (the BLE + /// `Device.id`) in the .mc1backup file — peripheral UUIDs are only + /// meaningful on the phone that paired the radio and are not portable + /// across installs. WiFi connection methods are intentionally retained + /// (restore-then-reconnect reads them) and are disclosed in the pre-export + /// Security Notice. `publicKey`/`radioID` are preserved as the + /// reconciliation/partition keys; import re-mints `id` again on insert. + public func redactedForBackup() -> DeviceDTO { + copy { + // Reset the surrogate id so the source phone's CBPeripheral UUID (Device.id for BLE + // radios) never travels in a shareable backup. publicKey/radioID are preserved as the + // reconciliation/partition keys; import re-mints id again on insert. For WiFi radios + // Device.id is DeviceIdentity.deriveUUID(from: publicKey), re-derivable from the + // retained publicKey, so the reset is doc-honesty plus BLE-identifier removal. + $0.id = UUID() + $0.frequency = Device.Defaults.frequency + $0.bandwidth = Device.Defaults.bandwidth + $0.spreadingFactor = Device.Defaults.spreadingFactor + $0.codingRate = Device.Defaults.codingRate + $0.txPower = Device.Defaults.txPower + $0.maxTxPower = Device.Defaults.maxTxPower + $0.latitude = Device.Defaults.latitude + $0.longitude = Device.Defaults.longitude + $0.blePin = Device.Defaults.blePin + $0.clientRepeat = Device.Defaults.clientRepeat + $0.pathHashMode = Device.Defaults.pathHashMode + $0.preRepeatFrequency = Device.Defaults.preRepeatFrequency + $0.preRepeatBandwidth = Device.Defaults.preRepeatBandwidth + $0.preRepeatSpreadingFactor = Device.Defaults.preRepeatSpreadingFactor + $0.preRepeatCodingRate = Device.Defaults.preRepeatCodingRate + $0.manualAddContacts = Device.Defaults.manualAddContacts + $0.autoAddConfig = Device.Defaults.autoAddConfig + $0.autoAddMaxHops = Device.Defaults.autoAddMaxHops + $0.multiAcks = Device.Defaults.multiAcks + $0.telemetryModeBase = Device.Defaults.telemetryModeBase + $0.telemetryModeLoc = Device.Defaults.telemetryModeLoc + $0.telemetryModeEnv = Device.Defaults.telemetryModeEnv + $0.advertLocationPolicy = Device.Defaults.advertLocationPolicy + $0.connectionMethods = connectionMethods.filter { !$0.isBluetooth } } + } } // MARK: - Shared Defaults -extension Device { - /// Radio and location defaults used by `Device.init` and by - /// `DeviceDTO.redactedForBackup()`. One source of truth keeps the two - /// paths from drifting — a future default change will flow through - /// exported backups automatically. - public enum Defaults { - public static let frequency: UInt32 = 915_000 - public static let bandwidth: UInt32 = 250_000 - public static let spreadingFactor: UInt8 = 10 - public static let codingRate: UInt8 = 5 - public static let txPower: Int8 = 20 - public static let maxTxPower: Int8 = 20 - public static let latitude: Double = 0 - public static let longitude: Double = 0 - public static let blePin: UInt32 = 0 - public static let clientRepeat: Bool = false - public static let pathHashMode: UInt8 = 0 - public static let defaultFloodScopeName: String? = nil - public static let preRepeatFrequency: UInt32? = nil - public static let preRepeatBandwidth: UInt32? = nil - public static let preRepeatSpreadingFactor: UInt8? = nil - public static let preRepeatCodingRate: UInt8? = nil - public static let manualAddContacts: Bool = false - public static let autoAddConfig: UInt8 = 0 - public static let autoAddMaxHops: UInt8 = 0 - public static let multiAcks: UInt8 = 2 - public static let telemetryModeBase: UInt8 = 2 - public static let telemetryModeLoc: UInt8 = 0 - public static let telemetryModeEnv: UInt8 = 0 - public static let advertLocationPolicy: UInt8 = 0 - } +public extension Device { + /// Radio and location defaults used by `Device.init` and by + /// `DeviceDTO.redactedForBackup()`. One source of truth keeps the two + /// paths from drifting — a future default change will flow through + /// exported backups automatically. + enum Defaults { + public static let frequency: UInt32 = 915_000 + public static let bandwidth: UInt32 = 250_000 + public static let spreadingFactor: UInt8 = 10 + public static let codingRate: UInt8 = 5 + public static let txPower: Int8 = 20 + public static let maxTxPower: Int8 = 20 + public static let latitude: Double = 0 + public static let longitude: Double = 0 + public static let blePin: UInt32 = 0 + public static let clientRepeat: Bool = false + public static let pathHashMode: UInt8 = 0 + public static let defaultFloodScopeName: String? = nil + public static let preRepeatFrequency: UInt32? = nil + public static let preRepeatBandwidth: UInt32? = nil + public static let preRepeatSpreadingFactor: UInt8? = nil + public static let preRepeatCodingRate: UInt8? = nil + public static let manualAddContacts: Bool = false + public static let autoAddConfig: UInt8 = 0 + public static let autoAddMaxHops: UInt8 = 0 + public static let multiAcks: UInt8 = 2 + public static let telemetryModeBase: UInt8 = 2 + public static let telemetryModeLoc: UInt8 = 0 + public static let telemetryModeEnv: UInt8 = 0 + public static let advertLocationPolicy: UInt8 = 0 + } } // MARK: - Version String Comparison extension String { - /// Checks if this version string is at least the specified version. - /// Handles formats like "v1.12.0", "1.12", "v1.12" - /// - Parameters: - /// - major: Required major version - /// - minor: Required minor version - /// - Returns: true if this version >= major.minor - func isAtLeast(major requiredMajor: Int, minor requiredMinor: Int) -> Bool { - let cleaned = trimmingCharacters(in: CharacterSet(charactersIn: "v")) - let components = cleaned.split(separator: ".") - guard components.count >= 2, - let major = Int(components[0]), - let minor = Int(components[1]) else { - return false - } - if major > requiredMajor { return true } - if major < requiredMajor { return false } - return minor >= requiredMinor + /// Checks if this version string is at least the specified version. + /// Handles formats like "v1.12.0", "1.12", "v1.12" + /// - Parameters: + /// - major: Required major version + /// - minor: Required minor version + /// - Returns: true if this version >= major.minor + func isAtLeast(major requiredMajor: Int, minor requiredMinor: Int) -> Bool { + let cleaned = trimmingCharacters(in: CharacterSet(charactersIn: "v")) + let components = cleaned.split(separator: ".") + guard components.count >= 2, + let major = Int(components[0]), + let minor = Int(components[1]) else { + return false } + if major > requiredMajor { return true } + if major < requiredMajor { return false } + return minor >= requiredMinor + } } diff --git a/MC1Services/Sources/MC1Services/Models/DirectMessageEnvelope.swift b/MC1Services/Sources/MC1Services/Models/DirectMessageEnvelope.swift index 695a1e12..9f71b9ff 100644 --- a/MC1Services/Sources/MC1Services/Models/DirectMessageEnvelope.swift +++ b/MC1Services/Sources/MC1Services/Models/DirectMessageEnvelope.swift @@ -11,13 +11,13 @@ import Foundation /// `sendCount` bump) and `resendDirectMessage` (bumps `sendCount`, /// broadcasts `MessageStatusEvent.resent`). public struct DirectMessageEnvelope: Sendable { - public let messageID: UUID - public let contactID: UUID - public let isResend: Bool + public let messageID: UUID + public let contactID: UUID + public let isResend: Bool - public init(messageID: UUID, contactID: UUID, isResend: Bool = false) { - self.messageID = messageID - self.contactID = contactID - self.isResend = isResend - } + public init(messageID: UUID, contactID: UUID, isResend: Bool = false) { + self.messageID = messageID + self.contactID = contactID + self.isResend = isResend + } } diff --git a/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift b/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift index 80833612..024820fe 100644 --- a/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift +++ b/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift @@ -6,185 +6,190 @@ import SwiftData /// Separate from Contact - this is ephemeral, app-only, capped at 1000 per device. @Model final class DiscoveredNode { - #Index( - [\.radioID, \.publicKey], - [\.radioID, \.lastHeard] - ) - - @Attribute(.unique) - var id: UUID - - /// Parent device ID - @Attribute(originalName: "deviceID") - var radioID: UUID - - /// 32-byte public key identifier - var publicKey: Data - - /// Advertised node name - var name: String - - /// Node type (1=chat, 2=repeater, 3=room) - var typeRawValue: UInt8 - - /// When we last received an advertisement from this node - var lastHeard: Date - - /// Firmware advertisement timestamp - var lastAdvertTimestamp: UInt32 - - /// Node latitude - var latitude: Double - - /// Node longitude - var longitude: Double - - /// Encoded routing path length (0xFF = flood) - var outPathLength: UInt8 - - /// Routing path data (up to 64 bytes) - var outPath: Data - - /// Hops the advert traversed to reach this phone (inbound), decoded from the heard - /// RX-log packet's path length. nil = never heard via an advert RX-log entry; 0 = heard - /// directly. Distinct from outPath/outPathLength, the route used to send to this node. - var inboundHopCount: Int? - - /// The firmware advert timestamp that was current when inboundHopCount was last written. - /// Paired with inboundHopCount to implement latest-advert semantics: a newer timestamp - /// always replaces the stored count; equal timestamps keep the closest copy of that broadcast. - var inboundHopAdvertTimestamp: UInt32? - - init( - id: UUID = UUID(), - radioID: UUID, - publicKey: Data, - name: String, - typeRawValue: UInt8, - lastHeard: Date = Date(), - lastAdvertTimestamp: UInt32, - latitude: Double = 0, - longitude: Double = 0, - outPathLength: UInt8 = PacketBuilder.floodPathSentinel, - outPath: Data = Data(), - inboundHopCount: Int? = nil, - inboundHopAdvertTimestamp: UInt32? = nil - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.typeRawValue = typeRawValue - self.lastHeard = lastHeard - self.lastAdvertTimestamp = lastAdvertTimestamp - self.latitude = latitude - self.longitude = longitude - self.outPathLength = outPathLength - self.outPath = outPath - self.inboundHopCount = inboundHopCount - self.inboundHopAdvertTimestamp = inboundHopAdvertTimestamp - } + #Index( + [\.radioID, \.publicKey], + [\.radioID, \.lastHeard] + ) + + @Attribute(.unique) + var id: UUID + + /// Parent device ID + @Attribute(originalName: "deviceID") + var radioID: UUID + + /// 32-byte public key identifier + var publicKey: Data + + /// Advertised node name + var name: String + + /// Node type (1=chat, 2=repeater, 3=room) + var typeRawValue: UInt8 + + /// When we last received an advertisement from this node + var lastHeard: Date + + /// Firmware advertisement timestamp + var lastAdvertTimestamp: UInt32 + + /// Node latitude + var latitude: Double + + /// Node longitude + var longitude: Double + + /// Encoded routing path length (0xFF = flood) + var outPathLength: UInt8 + + /// Routing path data (up to 64 bytes) + var outPath: Data + + /// Hops the advert traversed to reach this phone (inbound), decoded from the heard + /// RX-log packet's path length. nil = never heard via an advert RX-log entry; 0 = heard + /// directly. Distinct from outPath/outPathLength, the route used to send to this node. + var inboundHopCount: Int? + + /// The firmware advert timestamp that was current when inboundHopCount was last written. + /// Paired with inboundHopCount to implement latest-advert semantics: a newer timestamp + /// always replaces the stored count; equal timestamps keep the closest copy of that broadcast. + var inboundHopAdvertTimestamp: UInt32? + + init( + id: UUID = UUID(), + radioID: UUID, + publicKey: Data, + name: String, + typeRawValue: UInt8, + lastHeard: Date = Date(), + lastAdvertTimestamp: UInt32, + latitude: Double = 0, + longitude: Double = 0, + outPathLength: UInt8 = PacketBuilder.floodPathSentinel, + outPath: Data = Data(), + inboundHopCount: Int? = nil, + inboundHopAdvertTimestamp: UInt32? = nil + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + self.typeRawValue = typeRawValue + self.lastHeard = lastHeard + self.lastAdvertTimestamp = lastAdvertTimestamp + self.latitude = latitude + self.longitude = longitude + self.outPathLength = outPathLength + self.outPath = outPath + self.inboundHopCount = inboundHopCount + self.inboundHopAdvertTimestamp = inboundHopAdvertTimestamp + } } // MARK: - Sendable DTO /// A sendable snapshot of DiscoveredNode for cross-actor transfers public struct DiscoveredNodeDTO: Sendable, Equatable, Identifiable, RepeaterResolvable { - public let id: UUID - public let radioID: UUID - public let publicKey: Data - public let name: String - public let typeRawValue: UInt8 - public let lastHeard: Date - public let lastAdvertTimestamp: UInt32 - public let latitude: Double - public let longitude: Double - public let outPathLength: UInt8 - public let outPath: Data - public let inboundHopCount: Int? - public let inboundHopAdvertTimestamp: UInt32? - - public var nodeType: ContactType { - ContactType(rawValue: typeRawValue) ?? .chat - } - - public var hasLocation: Bool { - latitude != 0 || longitude != 0 - } - - public var isFloodRouted: Bool { - outPathLength == PacketBuilder.floodPathSentinel - } - - public var pathHashSize: Int { - decodePathLen(outPathLength)?.hashSize ?? 1 - } - - public var pathHopCount: Int { - decodePathLen(outPathLength)?.hopCount ?? 0 - } - - /// The hop count to surface in the UI: the deliberately-set out-path hops when a route exists, - /// otherwise the passively-heard inbound advert hops stored on this row. nil when flood-routed - /// and no advert hop count is known. - public var displayedHopCount: Int? { - isFloodRouted ? inboundHopCount : pathHopCount - } - - public var pathByteLength: Int { - decodePathLen(outPathLength)?.byteLength ?? 0 - } - - public var pathNodesHex: [String] { - outPath.prefix(pathByteLength).pathHops(hashSize: pathHashSize).map(\.hex) - } - - public var recencyDate: Date { lastHeard } - public var resolvableName: String { name } - - public init( - id: UUID, - radioID: UUID, - publicKey: Data, - name: String, - typeRawValue: UInt8, - lastHeard: Date, - lastAdvertTimestamp: UInt32, - latitude: Double, - longitude: Double, - outPathLength: UInt8, - outPath: Data, - inboundHopCount: Int?, - inboundHopAdvertTimestamp: UInt32? - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.typeRawValue = typeRawValue - self.lastHeard = lastHeard - self.lastAdvertTimestamp = lastAdvertTimestamp - self.latitude = latitude - self.longitude = longitude - self.outPathLength = outPathLength - self.outPath = outPath - self.inboundHopCount = inboundHopCount - self.inboundHopAdvertTimestamp = inboundHopAdvertTimestamp - } - - init(from node: DiscoveredNode) { - self.id = node.id - self.radioID = node.radioID - self.publicKey = node.publicKey - self.name = node.name - self.typeRawValue = node.typeRawValue - self.lastHeard = node.lastHeard - self.lastAdvertTimestamp = node.lastAdvertTimestamp - self.latitude = node.latitude - self.longitude = node.longitude - self.outPathLength = node.outPathLength - self.outPath = node.outPath - self.inboundHopCount = node.inboundHopCount - self.inboundHopAdvertTimestamp = node.inboundHopAdvertTimestamp - } + public let id: UUID + public let radioID: UUID + public let publicKey: Data + public let name: String + public let typeRawValue: UInt8 + public let lastHeard: Date + public let lastAdvertTimestamp: UInt32 + public let latitude: Double + public let longitude: Double + public let outPathLength: UInt8 + public let outPath: Data + public let inboundHopCount: Int? + public let inboundHopAdvertTimestamp: UInt32? + + public var nodeType: ContactType { + ContactType(rawValue: typeRawValue) ?? .chat + } + + public var hasLocation: Bool { + latitude != 0 || longitude != 0 + } + + public var isFloodRouted: Bool { + outPathLength == PacketBuilder.floodPathSentinel + } + + public var pathHashSize: Int { + decodePathLen(outPathLength)?.hashSize ?? 1 + } + + public var pathHopCount: Int { + decodePathLen(outPathLength)?.hopCount ?? 0 + } + + /// The hop count to surface in the UI: the deliberately-set out-path hops when a route exists, + /// otherwise the passively-heard inbound advert hops stored on this row. nil when flood-routed + /// and no advert hop count is known. + public var displayedHopCount: Int? { + isFloodRouted ? inboundHopCount : pathHopCount + } + + public var pathByteLength: Int { + decodePathLen(outPathLength)?.byteLength ?? 0 + } + + public var pathNodesHex: [String] { + outPath.prefix(pathByteLength).pathHops(hashSize: pathHashSize).map(\.hex) + } + + public var recencyDate: Date { + lastHeard + } + + public var resolvableName: String { + name + } + + public init( + id: UUID, + radioID: UUID, + publicKey: Data, + name: String, + typeRawValue: UInt8, + lastHeard: Date, + lastAdvertTimestamp: UInt32, + latitude: Double, + longitude: Double, + outPathLength: UInt8, + outPath: Data, + inboundHopCount: Int?, + inboundHopAdvertTimestamp: UInt32? + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + self.typeRawValue = typeRawValue + self.lastHeard = lastHeard + self.lastAdvertTimestamp = lastAdvertTimestamp + self.latitude = latitude + self.longitude = longitude + self.outPathLength = outPathLength + self.outPath = outPath + self.inboundHopCount = inboundHopCount + self.inboundHopAdvertTimestamp = inboundHopAdvertTimestamp + } + + init(from node: DiscoveredNode) { + id = node.id + radioID = node.radioID + publicKey = node.publicKey + name = node.name + typeRawValue = node.typeRawValue + lastHeard = node.lastHeard + lastAdvertTimestamp = node.lastAdvertTimestamp + latitude = node.latitude + longitude = node.longitude + outPathLength = node.outPathLength + outPath = node.outPath + inboundHopCount = node.inboundHopCount + inboundHopAdvertTimestamp = node.inboundHopAdvertTimestamp + } } diff --git a/MC1Services/Sources/MC1Services/Models/LinkPreviewData.swift b/MC1Services/Sources/MC1Services/Models/LinkPreviewData.swift index e6a7a934..eec8bcbf 100644 --- a/MC1Services/Sources/MC1Services/Models/LinkPreviewData.swift +++ b/MC1Services/Sources/MC1Services/Models/LinkPreviewData.swift @@ -5,45 +5,45 @@ import SwiftData /// Multiple messages with the same URL reference a single LinkPreviewData row. @Model final class LinkPreviewData { - /// The URL this preview is for (unique key) - @Attribute(.unique) - var url: String - - /// Title from link metadata - var title: String? - - /// Preview image data (hero image) - @Attribute(.externalStorage) - var imageData: Data? - - /// Icon/favicon data - @Attribute(.externalStorage) - var iconData: Data? - - /// Hero image pixel width, recorded at fetch time - var imageWidth: Int? - - /// Hero image pixel height, recorded at fetch time - var imageHeight: Int? - - /// When this preview was fetched - var fetchedAt: Date - - init( - url: String, - title: String? = nil, - imageData: Data? = nil, - iconData: Data? = nil, - imageWidth: Int? = nil, - imageHeight: Int? = nil, - fetchedAt: Date = Date() - ) { - self.url = url - self.title = title - self.imageData = imageData - self.iconData = iconData - self.imageWidth = imageWidth - self.imageHeight = imageHeight - self.fetchedAt = fetchedAt - } + /// The URL this preview is for (unique key) + @Attribute(.unique) + var url: String + + /// Title from link metadata + var title: String? + + /// Preview image data (hero image) + @Attribute(.externalStorage) + var imageData: Data? + + /// Icon/favicon data + @Attribute(.externalStorage) + var iconData: Data? + + /// Hero image pixel width, recorded at fetch time + var imageWidth: Int? + + /// Hero image pixel height, recorded at fetch time + var imageHeight: Int? + + /// When this preview was fetched + var fetchedAt: Date + + init( + url: String, + title: String? = nil, + imageData: Data? = nil, + iconData: Data? = nil, + imageWidth: Int? = nil, + imageHeight: Int? = nil, + fetchedAt: Date = Date() + ) { + self.url = url + self.title = title + self.imageData = imageData + self.iconData = iconData + self.imageWidth = imageWidth + self.imageHeight = imageHeight + self.fetchedAt = fetchedAt + } } diff --git a/MC1Services/Sources/MC1Services/Models/Message.swift b/MC1Services/Sources/MC1Services/Models/Message.swift index 56adf2a1..135a85ac 100644 --- a/MC1Services/Sources/MC1Services/Models/Message.swift +++ b/MC1Services/Sources/MC1Services/Models/Message.swift @@ -4,711 +4,710 @@ import SwiftData /// Message delivery status public enum MessageStatus: Int, Sendable, Codable { - case pending = 0 - case sending = 1 - case sent = 2 - case delivered = 3 - case failed = 4 - case retrying = 5 + case pending = 0 + case sending = 1 + case sent = 2 + case delivered = 3 + case failed = 4 + case retrying = 5 } /// Message direction public enum MessageDirection: Int, Sendable, Codable { - case incoming = 0 - case outgoing = 1 + case incoming = 0 + case outgoing = 1 } /// Represents a message in a conversation. /// Messages are stored per-device and associated with a contact or channel. @Model public final class Message { - #Index( - [\.radioID, \.channelIndex, \.createdAt], - [\.radioID, \.channelIndex, \.sortDate], - [\.radioID, \.channelIndex, \.timestamp], - [\.contactID, \.createdAt], - [\.contactID, \.sortDate], - [\.contactID, \.containsSelfMention, \.mentionSeen], - [\.radioID, \.channelIndex, \.containsSelfMention, \.mentionSeen], - [\.deduplicationKey] - ) - - /// Unique message identifier - @Attribute(.unique) - public var id: UUID - - /// The device this message belongs to - @Attribute(originalName: "deviceID") - public var radioID: UUID + #Index( + [\.radioID, \.channelIndex, \.createdAt], + [\.radioID, \.channelIndex, \.sortDate], + [\.radioID, \.channelIndex, \.timestamp], + [\.contactID, \.createdAt], + [\.contactID, \.sortDate], + [\.contactID, \.containsSelfMention, \.mentionSeen], + [\.radioID, \.channelIndex, \.containsSelfMention, \.mentionSeen], + [\.deduplicationKey] + ) - /// Contact ID for direct messages (nil for channel messages) - public var contactID: UUID? + /// Unique message identifier + @Attribute(.unique) + public var id: UUID - /// Channel index for channel messages (nil for direct messages) - public var channelIndex: UInt8? + /// The device this message belongs to + @Attribute(originalName: "deviceID") + public var radioID: UUID - /// Message text content - public var text: String + /// Contact ID for direct messages (nil for channel messages) + public var contactID: UUID? - /// Message timestamp (device time) - public var timestamp: UInt32 + /// Channel index for channel messages (nil for direct messages) + public var channelIndex: UInt8? - /// Local creation date - public var createdAt: Date + /// Message text content + public var text: String - /// Date used for send-time ordering of synced backlog messages. - /// Defaults to `createdAt` for newly created rows. - public var sortDate: Date = Date.distantPast + /// Message timestamp (device time) + public var timestamp: UInt32 - /// Direction (incoming/outgoing) - public var directionRawValue: Int + /// Local creation date + public var createdAt: Date - /// Delivery status - public var statusRawValue: Int + /// Date used for send-time ordering of synced backlog messages. + /// Defaults to `createdAt` for newly created rows. + public var sortDate: Date = Date.distantPast - /// Text type (plain, signed, etc.) - public var textTypeRawValue: UInt8 + /// Direction (incoming/outgoing) + public var directionRawValue: Int - /// ACK code for tracking delivery (outgoing only) - public var ackCode: UInt32? + /// Delivery status + public var statusRawValue: Int - /// Path length when received - public var pathLength: UInt8 + /// Text type (plain, signed, etc.) + public var textTypeRawValue: UInt8 - /// Signal-to-noise ratio in dB - public var snr: Double? + /// ACK code for tracking delivery (outgoing only) + public var ackCode: UInt32? - /// Path nodes for incoming messages (1 byte per hop, from RxLogEntry correlation) - public var pathNodes: Data? + /// Path length when received + public var pathLength: UInt8 - /// Sender public key prefix (6 bytes, for incoming messages) - public var senderKeyPrefix: Data? + /// Signal-to-noise ratio in dB + public var snr: Double? - /// Sender node name (for channel messages, parsed from "NodeName: MessageText" format) - public var senderNodeName: String? + /// Path nodes for incoming messages (1 byte per hop, from RxLogEntry correlation) + public var pathNodes: Data? - /// Whether this message has been read locally - public var isRead: Bool - - /// Reply-to message ID (for threaded replies) - public var replyToID: UUID? - - /// Round-trip time in ms (when ACK received) - public var roundTripTime: UInt32? - - /// Count of mesh repeats heard for this message (outgoing only) - public var heardRepeats: Int = 0 - - /// Number of times this message has been sent (1 = original, 2+ = sent again) - public var sendCount: Int = 1 - - /// Current retry attempt (0 = first attempt, 1 = first retry, etc.) - public var retryAttempt: Int = 0 - - /// Maximum retry attempts configured for this message - public var maxRetryAttempts: Int = 0 - - /// Deduplication key for preventing duplicate incoming messages - public var deduplicationKey: String? - - /// Link preview URL that was detected (nil if no URL in message) - public var linkPreviewURL: String? - - /// Title from link metadata - public var linkPreviewTitle: String? - - /// Preview image data (hero image) - @Attribute(.externalStorage) - public var linkPreviewImageData: Data? - - /// Icon/favicon data - @Attribute(.externalStorage) - public var linkPreviewIconData: Data? - - /// Whether fetch has been attempted (true = done, false = not yet tried) - public var linkPreviewFetched: Bool = false - - /// Whether this incoming message contains a mention of the current user - public var containsSelfMention: Bool = false - - /// Whether the user has scrolled to see this mention (for tracking unread mentions) - public var mentionSeen: Bool = false - - /// Whether the timestamp was corrected due to sender clock being invalid - public var timestampCorrected: Bool = false - - /// Original sender timestamp from the wire (for incoming messages when corrected). - /// Used for reaction hash computation to ensure sender and receiver match. - /// Nil when timestamp was not corrected or for outgoing messages. - public var senderTimestamp: UInt32? - - /// Cached reaction summary for scroll performance - /// Format: "👍:3,❤️:2,😂:1" (emoji:count pairs, ordered by count desc) - public var reactionSummary: String? - - /// Route type from RxLog correlation (-1 = unknown/uncorrelated) - public var routeTypeRawValue: Int = -1 - - /// Resolved flood region the sender transmitted under, derived from - /// `transport_codes[0]` at receive time via the RxLog correlation. Incoming - /// messages only; nil when the sender's region was not in the local - /// known-regions list at receive time (back-filled by `updateKnownRegions`). - public var regionScope: String? - - /// Heard repeats for this message (cascade delete) - @Relationship(deleteRule: .cascade, inverse: \MessageRepeat.message) - var repeats: [MessageRepeat]? - - public init( - id: UUID = UUID(), - radioID: UUID, - contactID: UUID? = nil, - channelIndex: UInt8? = nil, - text: String, - timestamp: UInt32 = 0, - createdAt: Date = Date(), - sortDate: Date? = nil, - directionRawValue: Int = MessageDirection.outgoing.rawValue, - statusRawValue: Int = MessageStatus.pending.rawValue, - textTypeRawValue: UInt8 = TextType.plain.rawValue, - ackCode: UInt32? = nil, - pathLength: UInt8 = 0, - snr: Double? = nil, - pathNodes: Data? = nil, - senderKeyPrefix: Data? = nil, - senderNodeName: String? = nil, - isRead: Bool = false, - replyToID: UUID? = nil, - roundTripTime: UInt32? = nil, - heardRepeats: Int = 0, - sendCount: Int = 1, - retryAttempt: Int = 0, - maxRetryAttempts: Int = 0, - deduplicationKey: String? = nil, - linkPreviewURL: String? = nil, - linkPreviewTitle: String? = nil, - linkPreviewImageData: Data? = nil, - linkPreviewIconData: Data? = nil, - linkPreviewFetched: Bool = false, - containsSelfMention: Bool = false, - mentionSeen: Bool = false, - timestampCorrected: Bool = false, - senderTimestamp: UInt32? = nil, - reactionSummary: String? = nil, - routeTypeRawValue: Int = -1, - regionScope: String? = nil - ) { - self.id = id - self.radioID = radioID - self.contactID = contactID - self.channelIndex = channelIndex - self.text = text - self.timestamp = timestamp > 0 ? timestamp : UInt32(createdAt.timeIntervalSince1970) - self.createdAt = createdAt - self.sortDate = sortDate ?? createdAt - self.directionRawValue = directionRawValue - self.statusRawValue = statusRawValue - self.textTypeRawValue = textTypeRawValue - self.ackCode = ackCode - self.pathLength = pathLength - self.snr = snr - self.pathNodes = pathNodes - self.senderKeyPrefix = senderKeyPrefix - self.senderNodeName = senderNodeName - self.isRead = isRead - self.replyToID = replyToID - self.roundTripTime = roundTripTime - self.heardRepeats = heardRepeats - self.sendCount = sendCount - self.retryAttempt = retryAttempt - self.maxRetryAttempts = maxRetryAttempts - self.deduplicationKey = deduplicationKey - self.linkPreviewURL = linkPreviewURL - self.linkPreviewTitle = linkPreviewTitle - self.linkPreviewImageData = linkPreviewImageData - self.linkPreviewIconData = linkPreviewIconData - self.linkPreviewFetched = linkPreviewFetched - self.containsSelfMention = containsSelfMention - self.mentionSeen = mentionSeen - self.timestampCorrected = timestampCorrected - self.senderTimestamp = senderTimestamp - self.reactionSummary = reactionSummary - self.routeTypeRawValue = routeTypeRawValue - self.regionScope = regionScope - } - - /// Builds a model instance directly from a DTO. Shared by backup batch-insert - /// paths so schema changes don't require touching a 30-argument call site. - public convenience init(dto: MessageDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - contactID: dto.contactID, - channelIndex: dto.channelIndex, - text: dto.text, - timestamp: dto.timestamp, - createdAt: dto.createdAt, - sortDate: dto.sortDate, - directionRawValue: dto.direction.rawValue, - statusRawValue: dto.status.rawValue, - textTypeRawValue: dto.textType.rawValue, - ackCode: dto.ackCode, - pathLength: dto.pathLength, - snr: dto.snr, - pathNodes: dto.pathNodes, - senderKeyPrefix: dto.senderKeyPrefix, - senderNodeName: dto.senderNodeName, - isRead: dto.isRead, - replyToID: dto.replyToID, - roundTripTime: dto.roundTripTime, - heardRepeats: dto.heardRepeats, - sendCount: dto.sendCount, - retryAttempt: dto.retryAttempt, - maxRetryAttempts: dto.maxRetryAttempts, - deduplicationKey: dto.deduplicationKey, - linkPreviewURL: dto.linkPreviewURL, - linkPreviewTitle: dto.linkPreviewTitle, - // External-storage blobs stay nil and fetched stays false so the new - // row re-fetches its preview; updateMessageLinkPreview writes them. - linkPreviewImageData: nil, - linkPreviewIconData: nil, - linkPreviewFetched: false, - containsSelfMention: dto.containsSelfMention, - mentionSeen: dto.mentionSeen, - timestampCorrected: dto.timestampCorrected, - senderTimestamp: dto.senderTimestamp, - reactionSummary: dto.reactionSummary, - routeTypeRawValue: dto.routeType.map { Int($0.rawValue) } ?? -1, - regionScope: dto.regionScope - ) - } + /// Sender public key prefix (6 bytes, for incoming messages) + public var senderKeyPrefix: Data? + + /// Sender node name (for channel messages, parsed from "NodeName: MessageText" format) + public var senderNodeName: String? + + /// Whether this message has been read locally + public var isRead: Bool + + /// Reply-to message ID (for threaded replies) + public var replyToID: UUID? + + /// Round-trip time in ms (when ACK received) + public var roundTripTime: UInt32? + + /// Count of mesh repeats heard for this message (outgoing only) + public var heardRepeats: Int = 0 + + /// Number of times this message has been sent (1 = original, 2+ = sent again) + public var sendCount: Int = 1 + + /// Current retry attempt (0 = first attempt, 1 = first retry, etc.) + public var retryAttempt: Int = 0 + + /// Maximum retry attempts configured for this message + public var maxRetryAttempts: Int = 0 + + /// Deduplication key for preventing duplicate incoming messages + public var deduplicationKey: String? + + /// Link preview URL that was detected (nil if no URL in message) + public var linkPreviewURL: String? + + /// Title from link metadata + public var linkPreviewTitle: String? + + /// Preview image data (hero image) + @Attribute(.externalStorage) + public var linkPreviewImageData: Data? + + /// Icon/favicon data + @Attribute(.externalStorage) + public var linkPreviewIconData: Data? + + /// Whether fetch has been attempted (true = done, false = not yet tried) + public var linkPreviewFetched: Bool = false + + /// Whether this incoming message contains a mention of the current user + public var containsSelfMention: Bool = false + + /// Whether the user has scrolled to see this mention (for tracking unread mentions) + public var mentionSeen: Bool = false + + /// Whether the timestamp was corrected due to sender clock being invalid + public var timestampCorrected: Bool = false + + /// Original sender timestamp from the wire (for incoming messages when corrected). + /// Used for reaction hash computation to ensure sender and receiver match. + /// Nil when timestamp was not corrected or for outgoing messages. + public var senderTimestamp: UInt32? + + /// Cached reaction summary for scroll performance + /// Format: "👍:3,❤️:2,😂:1" (emoji:count pairs, ordered by count desc) + public var reactionSummary: String? + + /// Route type from RxLog correlation (-1 = unknown/uncorrelated) + public var routeTypeRawValue: Int = -1 + + /// Resolved flood region the sender transmitted under, derived from + /// `transport_codes[0]` at receive time via the RxLog correlation. Incoming + /// messages only; nil when the sender's region was not in the local + /// known-regions list at receive time (back-filled by `updateKnownRegions`). + public var regionScope: String? + + /// Heard repeats for this message (cascade delete) + @Relationship(deleteRule: .cascade, inverse: \MessageRepeat.message) + var repeats: [MessageRepeat]? + + public init( + id: UUID = UUID(), + radioID: UUID, + contactID: UUID? = nil, + channelIndex: UInt8? = nil, + text: String, + timestamp: UInt32 = 0, + createdAt: Date = Date(), + sortDate: Date? = nil, + directionRawValue: Int = MessageDirection.outgoing.rawValue, + statusRawValue: Int = MessageStatus.pending.rawValue, + textTypeRawValue: UInt8 = TextType.plain.rawValue, + ackCode: UInt32? = nil, + pathLength: UInt8 = 0, + snr: Double? = nil, + pathNodes: Data? = nil, + senderKeyPrefix: Data? = nil, + senderNodeName: String? = nil, + isRead: Bool = false, + replyToID: UUID? = nil, + roundTripTime: UInt32? = nil, + heardRepeats: Int = 0, + sendCount: Int = 1, + retryAttempt: Int = 0, + maxRetryAttempts: Int = 0, + deduplicationKey: String? = nil, + linkPreviewURL: String? = nil, + linkPreviewTitle: String? = nil, + linkPreviewImageData: Data? = nil, + linkPreviewIconData: Data? = nil, + linkPreviewFetched: Bool = false, + containsSelfMention: Bool = false, + mentionSeen: Bool = false, + timestampCorrected: Bool = false, + senderTimestamp: UInt32? = nil, + reactionSummary: String? = nil, + routeTypeRawValue: Int = -1, + regionScope: String? = nil + ) { + self.id = id + self.radioID = radioID + self.contactID = contactID + self.channelIndex = channelIndex + self.text = text + self.timestamp = timestamp > 0 ? timestamp : UInt32(createdAt.timeIntervalSince1970) + self.createdAt = createdAt + self.sortDate = sortDate ?? createdAt + self.directionRawValue = directionRawValue + self.statusRawValue = statusRawValue + self.textTypeRawValue = textTypeRawValue + self.ackCode = ackCode + self.pathLength = pathLength + self.snr = snr + self.pathNodes = pathNodes + self.senderKeyPrefix = senderKeyPrefix + self.senderNodeName = senderNodeName + self.isRead = isRead + self.replyToID = replyToID + self.roundTripTime = roundTripTime + self.heardRepeats = heardRepeats + self.sendCount = sendCount + self.retryAttempt = retryAttempt + self.maxRetryAttempts = maxRetryAttempts + self.deduplicationKey = deduplicationKey + self.linkPreviewURL = linkPreviewURL + self.linkPreviewTitle = linkPreviewTitle + self.linkPreviewImageData = linkPreviewImageData + self.linkPreviewIconData = linkPreviewIconData + self.linkPreviewFetched = linkPreviewFetched + self.containsSelfMention = containsSelfMention + self.mentionSeen = mentionSeen + self.timestampCorrected = timestampCorrected + self.senderTimestamp = senderTimestamp + self.reactionSummary = reactionSummary + self.routeTypeRawValue = routeTypeRawValue + self.regionScope = regionScope + } + + /// Builds a model instance directly from a DTO. Shared by backup batch-insert + /// paths so schema changes don't require touching a 30-argument call site. + public convenience init(dto: MessageDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + contactID: dto.contactID, + channelIndex: dto.channelIndex, + text: dto.text, + timestamp: dto.timestamp, + createdAt: dto.createdAt, + sortDate: dto.sortDate, + directionRawValue: dto.direction.rawValue, + statusRawValue: dto.status.rawValue, + textTypeRawValue: dto.textType.rawValue, + ackCode: dto.ackCode, + pathLength: dto.pathLength, + snr: dto.snr, + pathNodes: dto.pathNodes, + senderKeyPrefix: dto.senderKeyPrefix, + senderNodeName: dto.senderNodeName, + isRead: dto.isRead, + replyToID: dto.replyToID, + roundTripTime: dto.roundTripTime, + heardRepeats: dto.heardRepeats, + sendCount: dto.sendCount, + retryAttempt: dto.retryAttempt, + maxRetryAttempts: dto.maxRetryAttempts, + deduplicationKey: dto.deduplicationKey, + linkPreviewURL: dto.linkPreviewURL, + linkPreviewTitle: dto.linkPreviewTitle, + // External-storage blobs stay nil and fetched stays false so the new + // row re-fetches its preview; updateMessageLinkPreview writes them. + linkPreviewImageData: nil, + linkPreviewIconData: nil, + linkPreviewFetched: false, + containsSelfMention: dto.containsSelfMention, + mentionSeen: dto.mentionSeen, + timestampCorrected: dto.timestampCorrected, + senderTimestamp: dto.senderTimestamp, + reactionSummary: dto.reactionSummary, + routeTypeRawValue: dto.routeType.map { Int($0.rawValue) } ?? -1, + regionScope: dto.regionScope + ) + } } // MARK: - Computed Properties public extension Message { - /// Direction enum - var direction: MessageDirection { - MessageDirection(rawValue: directionRawValue) ?? .outgoing - } - - /// Status enum - var status: MessageStatus { - get { MessageStatus(rawValue: statusRawValue) ?? .pending } - set { statusRawValue = newValue.rawValue } - } - - /// Text type enum - var textType: TextType { - TextType(rawValue: textTypeRawValue) ?? .plain - } - - /// Whether this is an outgoing message - var isOutgoing: Bool { - direction == .outgoing - } - - /// Whether this is a channel message - var isChannelMessage: Bool { - channelIndex != nil - } - - /// Whether the message is still pending delivery - var isPending: Bool { - status == .pending || status == .sending - } - - /// Whether the message failed to send - var hasFailed: Bool { - status == .failed - } - + /// Direction enum + var direction: MessageDirection { + MessageDirection(rawValue: directionRawValue) ?? .outgoing + } + + /// Status enum + var status: MessageStatus { + get { MessageStatus(rawValue: statusRawValue) ?? .pending } + set { statusRawValue = newValue.rawValue } + } + + /// Text type enum + var textType: TextType { + TextType(rawValue: textTypeRawValue) ?? .plain + } + + /// Whether this is an outgoing message + var isOutgoing: Bool { + direction == .outgoing + } + + /// Whether this is a channel message + var isChannelMessage: Bool { + channelIndex != nil + } + + /// Whether the message is still pending delivery + var isPending: Bool { + status == .pending || status == .sending + } + + /// Whether the message failed to send + var hasFailed: Bool { + status == .failed + } } // MARK: - Sendable DTO /// A sendable snapshot of Message for cross-actor transfers public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { - public var id: UUID - public var radioID: UUID - public var contactID: UUID? - public var channelIndex: UInt8? - public var text: String - public var timestamp: UInt32 - public var createdAt: Date - public var sortDate: Date - public var direction: MessageDirection - public var status: MessageStatus - public var textType: TextType - public var ackCode: UInt32? - public var pathLength: UInt8 - public var snr: Double? - public var pathNodes: Data? - public var senderKeyPrefix: Data? - public var senderNodeName: String? - public var isRead: Bool - public var replyToID: UUID? - public var roundTripTime: UInt32? - public var heardRepeats: Int - public var sendCount: Int - public var retryAttempt: Int - public var maxRetryAttempts: Int - public var deduplicationKey: String? - public var linkPreviewURL: String? - public var linkPreviewTitle: String? - public var linkPreviewImageData: Data? - public var linkPreviewIconData: Data? - public var linkPreviewFetched: Bool - public var containsSelfMention: Bool - public var mentionSeen: Bool - public var timestampCorrected: Bool - public var senderTimestamp: UInt32? - public var reactionSummary: String? - public var routeType: RouteType? - public var regionScope: String? - - // Explicit Codable so backups predating ``sortDate`` decode cleanly. - // Legacy envelopes have no `sortDate` key; it falls back to `createdAt`, - // which must be decoded first. Every other field decodes exactly as the - // synthesized Codable did, preserving the existing wire format. - private enum CodingKeys: String, CodingKey { - case id, radioID, contactID, channelIndex, text, timestamp, createdAt, - sortDate, direction, status, textType, ackCode, pathLength, snr, - pathNodes, senderKeyPrefix, senderNodeName, isRead, replyToID, - roundTripTime, heardRepeats, sendCount, retryAttempt, maxRetryAttempts, - deduplicationKey, linkPreviewURL, linkPreviewTitle, linkPreviewImageData, - linkPreviewIconData, linkPreviewFetched, containsSelfMention, mentionSeen, - timestampCorrected, senderTimestamp, reactionSummary, routeType, regionScope - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(UUID.self, forKey: .id) - self.radioID = try container.decode(UUID.self, forKey: .radioID) - self.contactID = try container.decodeIfPresent(UUID.self, forKey: .contactID) - self.channelIndex = try container.decodeIfPresent(UInt8.self, forKey: .channelIndex) - self.text = try container.decode(String.self, forKey: .text) - self.timestamp = try container.decode(UInt32.self, forKey: .timestamp) - let createdAt = try container.decode(Date.self, forKey: .createdAt) - self.createdAt = createdAt - self.sortDate = try container.decodeIfPresent(Date.self, forKey: .sortDate) ?? createdAt - self.direction = try container.decode(MessageDirection.self, forKey: .direction) - self.status = try container.decode(MessageStatus.self, forKey: .status) - self.textType = try container.decode(TextType.self, forKey: .textType) - self.ackCode = try container.decodeIfPresent(UInt32.self, forKey: .ackCode) - self.pathLength = try container.decode(UInt8.self, forKey: .pathLength) - self.snr = try container.decodeIfPresent(Double.self, forKey: .snr) - self.pathNodes = try container.decodeIfPresent(Data.self, forKey: .pathNodes) - self.senderKeyPrefix = try container.decodeIfPresent(Data.self, forKey: .senderKeyPrefix) - self.senderNodeName = try container.decodeIfPresent(String.self, forKey: .senderNodeName) - self.isRead = try container.decode(Bool.self, forKey: .isRead) - self.replyToID = try container.decodeIfPresent(UUID.self, forKey: .replyToID) - self.roundTripTime = try container.decodeIfPresent(UInt32.self, forKey: .roundTripTime) - self.heardRepeats = try container.decode(Int.self, forKey: .heardRepeats) - self.sendCount = try container.decode(Int.self, forKey: .sendCount) - self.retryAttempt = try container.decode(Int.self, forKey: .retryAttempt) - self.maxRetryAttempts = try container.decode(Int.self, forKey: .maxRetryAttempts) - self.deduplicationKey = try container.decodeIfPresent(String.self, forKey: .deduplicationKey) - self.linkPreviewURL = try container.decodeIfPresent(String.self, forKey: .linkPreviewURL) - self.linkPreviewTitle = try container.decodeIfPresent(String.self, forKey: .linkPreviewTitle) - self.linkPreviewImageData = try container.decodeIfPresent(Data.self, forKey: .linkPreviewImageData) - self.linkPreviewIconData = try container.decodeIfPresent(Data.self, forKey: .linkPreviewIconData) - self.linkPreviewFetched = try container.decode(Bool.self, forKey: .linkPreviewFetched) - self.containsSelfMention = try container.decode(Bool.self, forKey: .containsSelfMention) - self.mentionSeen = try container.decode(Bool.self, forKey: .mentionSeen) - self.timestampCorrected = try container.decode(Bool.self, forKey: .timestampCorrected) - self.senderTimestamp = try container.decodeIfPresent(UInt32.self, forKey: .senderTimestamp) - self.reactionSummary = try container.decodeIfPresent(String.self, forKey: .reactionSummary) - self.routeType = try container.decodeIfPresent(RouteType.self, forKey: .routeType) - self.regionScope = try container.decodeIfPresent(String.self, forKey: .regionScope) - } - - public init(from message: Message, includeLinkPreviewBlobs: Bool = true) { - self.id = message.id - self.radioID = message.radioID - self.contactID = message.contactID - self.channelIndex = message.channelIndex - self.text = message.text - self.timestamp = message.timestamp - self.createdAt = message.createdAt - self.sortDate = message.sortDate - self.direction = message.direction - self.status = message.status - self.textType = message.textType - self.ackCode = message.ackCode - self.pathLength = message.pathLength - self.snr = message.snr - self.pathNodes = message.pathNodes - self.senderKeyPrefix = message.senderKeyPrefix - self.senderNodeName = message.senderNodeName - self.isRead = message.isRead - self.replyToID = message.replyToID - self.roundTripTime = message.roundTripTime - self.heardRepeats = message.heardRepeats - self.sendCount = message.sendCount - self.retryAttempt = message.retryAttempt - self.maxRetryAttempts = message.maxRetryAttempts - self.deduplicationKey = message.deduplicationKey - self.linkPreviewURL = message.linkPreviewURL - self.linkPreviewTitle = message.linkPreviewTitle - // External-storage blobs (linkPreviewImageData/IconData) are only faulted when - // accessed. Backup export skips them to avoid loading every preview image into - // memory just to discard it. - if includeLinkPreviewBlobs { - self.linkPreviewImageData = message.linkPreviewImageData - self.linkPreviewIconData = message.linkPreviewIconData - self.linkPreviewFetched = message.linkPreviewFetched - } else { - self.linkPreviewImageData = nil - self.linkPreviewIconData = nil - self.linkPreviewFetched = false + public var id: UUID + public var radioID: UUID + public var contactID: UUID? + public var channelIndex: UInt8? + public var text: String + public var timestamp: UInt32 + public var createdAt: Date + public var sortDate: Date + public var direction: MessageDirection + public var status: MessageStatus + public var textType: TextType + public var ackCode: UInt32? + public var pathLength: UInt8 + public var snr: Double? + public var pathNodes: Data? + public var senderKeyPrefix: Data? + public var senderNodeName: String? + public var isRead: Bool + public var replyToID: UUID? + public var roundTripTime: UInt32? + public var heardRepeats: Int + public var sendCount: Int + public var retryAttempt: Int + public var maxRetryAttempts: Int + public var deduplicationKey: String? + public var linkPreviewURL: String? + public var linkPreviewTitle: String? + public var linkPreviewImageData: Data? + public var linkPreviewIconData: Data? + public var linkPreviewFetched: Bool + public var containsSelfMention: Bool + public var mentionSeen: Bool + public var timestampCorrected: Bool + public var senderTimestamp: UInt32? + public var reactionSummary: String? + public var routeType: RouteType? + public var regionScope: String? + + /// Explicit Codable so backups predating ``sortDate`` decode cleanly. + /// Legacy envelopes have no `sortDate` key; it falls back to `createdAt`, + /// which must be decoded first. Every other field decodes exactly as the + /// synthesized Codable did, preserving the existing wire format. + private enum CodingKeys: String, CodingKey { + case id, radioID, contactID, channelIndex, text, timestamp, createdAt, + sortDate, direction, status, textType, ackCode, pathLength, snr, + pathNodes, senderKeyPrefix, senderNodeName, isRead, replyToID, + roundTripTime, heardRepeats, sendCount, retryAttempt, maxRetryAttempts, + deduplicationKey, linkPreviewURL, linkPreviewTitle, linkPreviewImageData, + linkPreviewIconData, linkPreviewFetched, containsSelfMention, mentionSeen, + timestampCorrected, senderTimestamp, reactionSummary, routeType, regionScope + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + radioID = try container.decode(UUID.self, forKey: .radioID) + contactID = try container.decodeIfPresent(UUID.self, forKey: .contactID) + channelIndex = try container.decodeIfPresent(UInt8.self, forKey: .channelIndex) + text = try container.decode(String.self, forKey: .text) + timestamp = try container.decode(UInt32.self, forKey: .timestamp) + let createdAt = try container.decode(Date.self, forKey: .createdAt) + self.createdAt = createdAt + sortDate = try container.decodeIfPresent(Date.self, forKey: .sortDate) ?? createdAt + direction = try container.decode(MessageDirection.self, forKey: .direction) + status = try container.decode(MessageStatus.self, forKey: .status) + textType = try container.decode(TextType.self, forKey: .textType) + ackCode = try container.decodeIfPresent(UInt32.self, forKey: .ackCode) + pathLength = try container.decode(UInt8.self, forKey: .pathLength) + snr = try container.decodeIfPresent(Double.self, forKey: .snr) + pathNodes = try container.decodeIfPresent(Data.self, forKey: .pathNodes) + senderKeyPrefix = try container.decodeIfPresent(Data.self, forKey: .senderKeyPrefix) + senderNodeName = try container.decodeIfPresent(String.self, forKey: .senderNodeName) + isRead = try container.decode(Bool.self, forKey: .isRead) + replyToID = try container.decodeIfPresent(UUID.self, forKey: .replyToID) + roundTripTime = try container.decodeIfPresent(UInt32.self, forKey: .roundTripTime) + heardRepeats = try container.decode(Int.self, forKey: .heardRepeats) + sendCount = try container.decode(Int.self, forKey: .sendCount) + retryAttempt = try container.decode(Int.self, forKey: .retryAttempt) + maxRetryAttempts = try container.decode(Int.self, forKey: .maxRetryAttempts) + deduplicationKey = try container.decodeIfPresent(String.self, forKey: .deduplicationKey) + linkPreviewURL = try container.decodeIfPresent(String.self, forKey: .linkPreviewURL) + linkPreviewTitle = try container.decodeIfPresent(String.self, forKey: .linkPreviewTitle) + linkPreviewImageData = try container.decodeIfPresent(Data.self, forKey: .linkPreviewImageData) + linkPreviewIconData = try container.decodeIfPresent(Data.self, forKey: .linkPreviewIconData) + linkPreviewFetched = try container.decode(Bool.self, forKey: .linkPreviewFetched) + containsSelfMention = try container.decode(Bool.self, forKey: .containsSelfMention) + mentionSeen = try container.decode(Bool.self, forKey: .mentionSeen) + timestampCorrected = try container.decode(Bool.self, forKey: .timestampCorrected) + senderTimestamp = try container.decodeIfPresent(UInt32.self, forKey: .senderTimestamp) + reactionSummary = try container.decodeIfPresent(String.self, forKey: .reactionSummary) + routeType = try container.decodeIfPresent(RouteType.self, forKey: .routeType) + regionScope = try container.decodeIfPresent(String.self, forKey: .regionScope) + } + + public init(from message: Message, includeLinkPreviewBlobs: Bool = true) { + id = message.id + radioID = message.radioID + contactID = message.contactID + channelIndex = message.channelIndex + text = message.text + timestamp = message.timestamp + createdAt = message.createdAt + sortDate = message.sortDate + direction = message.direction + status = message.status + textType = message.textType + ackCode = message.ackCode + pathLength = message.pathLength + snr = message.snr + pathNodes = message.pathNodes + senderKeyPrefix = message.senderKeyPrefix + senderNodeName = message.senderNodeName + isRead = message.isRead + replyToID = message.replyToID + roundTripTime = message.roundTripTime + heardRepeats = message.heardRepeats + sendCount = message.sendCount + retryAttempt = message.retryAttempt + maxRetryAttempts = message.maxRetryAttempts + deduplicationKey = message.deduplicationKey + linkPreviewURL = message.linkPreviewURL + linkPreviewTitle = message.linkPreviewTitle + // External-storage blobs (linkPreviewImageData/IconData) are only faulted when + // accessed. Backup export skips them to avoid loading every preview image into + // memory just to discard it. + if includeLinkPreviewBlobs { + linkPreviewImageData = message.linkPreviewImageData + linkPreviewIconData = message.linkPreviewIconData + linkPreviewFetched = message.linkPreviewFetched + } else { + linkPreviewImageData = nil + linkPreviewIconData = nil + linkPreviewFetched = false + } + containsSelfMention = message.containsSelfMention + mentionSeen = message.mentionSeen + timestampCorrected = message.timestampCorrected + senderTimestamp = message.senderTimestamp + reactionSummary = message.reactionSummary + routeType = UInt8(exactly: message.routeTypeRawValue) + .flatMap(RouteType.init(rawValue:)) + regionScope = message.regionScope + } + + /// Memberwise initializer for creating DTOs directly + public init( + id: UUID, + radioID: UUID, + contactID: UUID?, + channelIndex: UInt8?, + text: String, + timestamp: UInt32, + createdAt: Date, + sortDate: Date? = nil, + direction: MessageDirection, + status: MessageStatus, + textType: TextType, + ackCode: UInt32?, + pathLength: UInt8, + snr: Double?, + pathNodes: Data? = nil, + senderKeyPrefix: Data?, + senderNodeName: String?, + isRead: Bool, + replyToID: UUID?, + roundTripTime: UInt32?, + heardRepeats: Int, + sendCount: Int = 1, + retryAttempt: Int, + maxRetryAttempts: Int, + deduplicationKey: String? = nil, + linkPreviewURL: String? = nil, + linkPreviewTitle: String? = nil, + linkPreviewImageData: Data? = nil, + linkPreviewIconData: Data? = nil, + linkPreviewFetched: Bool = false, + containsSelfMention: Bool = false, + mentionSeen: Bool = false, + timestampCorrected: Bool = false, + senderTimestamp: UInt32? = nil, + reactionSummary: String? = nil, + routeType: RouteType? = nil, + regionScope: String? = nil + ) { + self.id = id + self.radioID = radioID + self.contactID = contactID + self.channelIndex = channelIndex + self.text = text + self.timestamp = timestamp + self.createdAt = createdAt + self.sortDate = sortDate ?? createdAt + self.direction = direction + self.status = status + self.textType = textType + self.ackCode = ackCode + self.pathLength = pathLength + self.snr = snr + self.pathNodes = pathNodes + self.senderKeyPrefix = senderKeyPrefix + self.senderNodeName = senderNodeName + self.isRead = isRead + self.replyToID = replyToID + self.roundTripTime = roundTripTime + self.heardRepeats = heardRepeats + self.sendCount = sendCount + self.retryAttempt = retryAttempt + self.maxRetryAttempts = maxRetryAttempts + self.deduplicationKey = deduplicationKey + self.linkPreviewURL = linkPreviewURL + self.linkPreviewTitle = linkPreviewTitle + self.linkPreviewImageData = linkPreviewImageData + self.linkPreviewIconData = linkPreviewIconData + self.linkPreviewFetched = linkPreviewFetched + self.containsSelfMention = containsSelfMention + self.mentionSeen = mentionSeen + self.timestampCorrected = timestampCorrected + self.senderTimestamp = senderTimestamp + self.reactionSummary = reactionSummary + self.routeType = routeType + self.regionScope = regionScope + } + + public var isOutgoing: Bool { + direction == .outgoing + } + + public var isChannelMessage: Bool { + channelIndex != nil + } + + /// Timestamp to use for reaction hash computation. + /// Uses original sender timestamp if available (for incoming messages with corrected timestamps), + /// otherwise uses the stored timestamp. + public var reactionTimestamp: UInt32 { + senderTimestamp ?? timestamp + } + + public var isPending: Bool { + status == .pending || status == .sending + } + + public var hasFailed: Bool { + status == .failed + } + + /// Returns a new MessageDTO with the given mutations applied. + public func copy(_ mutations: (inout MessageDTO) -> Void) -> MessageDTO { + var copy = self + mutations(©) + return copy + } + + /// Date used for display and sorting (local receive time) + public var date: Date { + createdAt + } + + /// Date derived from the sender's device clock (may differ from `date` if the sender's clock is skewed) + public var senderDate: Date { + Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + /// Raw, uncorrected send time the sender stamped on the wire. + /// Unlike `senderDate`, this is not clock-corrected, so a skewed + /// sender clock surfaces its literal value. + public var wireSentDate: Date { + Date(timeIntervalSince1970: TimeInterval(senderTimestamp ?? timestamp)) + } + + /// Hop count decoded from pathLength (lower 6 bits) + public var hopCount: Int { + decodePathLen(pathLength)?.hopCount ?? Int(pathLength & 63) + } + + /// Whether this message was flood-routed (broadcast). + /// Priority: channelIndex (channels are always flood) → routeType from RxLog → pathLength inference. + public var isFloodRouted: Bool { + if channelIndex != nil { return true } + if let routeType { return routeType == .flood || routeType == .tcFlood } + return pathLength != PacketBuilder.floodPathSentinel + } + + /// Whether this message was direct-routed (pre-built path, hops consumed in transit). + public var isDirectRouted: Bool { + !isFloodRouted + } + + /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits + public var pathHashSize: Int { + decodePathLen(pathLength)?.hashSize ?? 1 + } + + /// Hash size per hop in bytes (1, 2, or 3) when the path length byte encodes a + /// valid hash mode; nil for reserved modes or the no-path marker (0xFF). + public var pathHashSizeIfKnown: Int? { + decodePathLen(pathLength)?.hashSize + } + + /// Each hop as its raw hash bytes plus uppercase hex, e.g. `[(0xA3, "A3"), (0x7F, "7F")]`. + /// The raw bytes are needed to match a hop against a repeater's public-key prefix. + public var pathHops: [(data: Data, hex: String)] { + guard let pathNodes else { return [] } + return pathNodes.pathHops(hashSize: pathHashSize) + } + + /// Path nodes as hex strings for display, chunked by hash size + public var pathNodesHex: [String] { + pathHops.map(\.hex) + } + + /// Path as arrow-separated string (e.g., "A3 → 7F → 42") + public var pathString: String { + pathNodesHex.joined(separator: " → ") + } + + /// Path as comma-separated string for clipboard (e.g., "A3,7F,42") + public var pathStringForClipboard: String { + pathNodesHex.joined(separator: ",") + } + + // MARK: - Same-Sender Reordering + + /// Maximum time window (in seconds) within which consecutive messages from the same sender + /// are re-sorted by sender timestamp to preserve intended send order. + private static let sameSenderReorderWindow: TimeInterval = 5 + + /// Reorders messages within narrow same-sender clusters by sender timestamp. + /// + /// Expects input already sorted by `sortDate` (the display sort key). When multiple + /// messages from the same sender fall within a short window, mesh relay may deliver them + /// out of order. This function detects those clusters and re-sorts them by the sender's + /// claimed timestamp to restore the intended conversation order. The cluster window is + /// measured on `sortDate` — the same key the input is sorted by — so it stays non-negative + /// and cannot pull together rows that are far apart on the display axis. + public static func reorderSameSenderClusters(_ messages: [MessageDTO]) -> [MessageDTO] { + guard messages.count > 1 else { return messages } + + var result = messages + var clusterStart = 0 + + while clusterStart < result.count { + var clusterEnd = clusterStart + 1 + + // Extend the cluster while consecutive messages match the same sender/direction + // and fall within the reorder window + while clusterEnd < result.count { + let gap = result[clusterEnd].sortDate.timeIntervalSince(result[clusterEnd - 1].sortDate) + guard isSameSender(result[clusterEnd], result[clusterEnd - 1]), + gap <= sameSenderReorderWindow else { break } + clusterEnd += 1 + } + + // Sort the cluster by sender timestamp if it contains more than one message + if clusterEnd - clusterStart > 1 { + let sorted = result[clusterStart.. Bool { + guard a.direction == b.direction else { return false } + guard a.isChannelMessage == b.isChannelMessage else { return false } - public var hasFailed: Bool { - status == .failed + // For channel messages, compare sender node name (nil = unknown, treat as different). + // senderNodeName isn't unique — two users with the same name may be falsely clustered. + if a.isChannelMessage { + guard let nameA = a.senderNodeName, let nameB = b.senderNodeName else { return false } + return nameA == nameB } - /// Returns a new MessageDTO with the given mutations applied. - public func copy(_ mutations: (inout MessageDTO) -> Void) -> MessageDTO { - var copy = self - mutations(©) - return copy - } - - /// Date used for display and sorting (local receive time) - public var date: Date { - createdAt - } - - /// Date derived from the sender's device clock (may differ from `date` if the sender's clock is skewed) - public var senderDate: Date { - Date(timeIntervalSince1970: TimeInterval(timestamp)) - } - - /// Raw, uncorrected send time the sender stamped on the wire. - /// Unlike `senderDate`, this is not clock-corrected, so a skewed - /// sender clock surfaces its literal value. - public var wireSentDate: Date { - Date(timeIntervalSince1970: TimeInterval(senderTimestamp ?? timestamp)) - } - - /// Hop count decoded from pathLength (lower 6 bits) - public var hopCount: Int { - decodePathLen(pathLength)?.hopCount ?? Int(pathLength & 63) - } - - /// Whether this message was flood-routed (broadcast). - /// Priority: channelIndex (channels are always flood) → routeType from RxLog → pathLength inference. - public var isFloodRouted: Bool { - if channelIndex != nil { return true } - if let routeType { return routeType == .flood || routeType == .tcFlood } - return pathLength != PacketBuilder.floodPathSentinel - } - - /// Whether this message was direct-routed (pre-built path, hops consumed in transit). - public var isDirectRouted: Bool { - !isFloodRouted - } - - /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits - public var pathHashSize: Int { - decodePathLen(pathLength)?.hashSize ?? 1 - } - - /// Hash size per hop in bytes (1, 2, or 3) when the path length byte encodes a - /// valid hash mode; nil for reserved modes or the no-path marker (0xFF). - public var pathHashSizeIfKnown: Int? { - decodePathLen(pathLength)?.hashSize - } - - /// Each hop as its raw hash bytes plus uppercase hex, e.g. `[(0xA3, "A3"), (0x7F, "7F")]`. - /// The raw bytes are needed to match a hop against a repeater's public-key prefix. - public var pathHops: [(data: Data, hex: String)] { - guard let pathNodes else { return [] } - return pathNodes.pathHops(hashSize: pathHashSize) - } - - /// Path nodes as hex strings for display, chunked by hash size - public var pathNodesHex: [String] { - pathHops.map(\.hex) - } - - /// Path as arrow-separated string (e.g., "A3 → 7F → 42") - public var pathString: String { - pathNodesHex.joined(separator: " → ") - } - - /// Path as comma-separated string for clipboard (e.g., "A3,7F,42") - public var pathStringForClipboard: String { - pathNodesHex.joined(separator: ",") - } - - // MARK: - Same-Sender Reordering - - /// Maximum time window (in seconds) within which consecutive messages from the same sender - /// are re-sorted by sender timestamp to preserve intended send order. - private static let sameSenderReorderWindow: TimeInterval = 5 - - /// Reorders messages within narrow same-sender clusters by sender timestamp. - /// - /// Expects input already sorted by `sortDate` (the display sort key). When multiple - /// messages from the same sender fall within a short window, mesh relay may deliver them - /// out of order. This function detects those clusters and re-sorts them by the sender's - /// claimed timestamp to restore the intended conversation order. The cluster window is - /// measured on `sortDate` — the same key the input is sorted by — so it stays non-negative - /// and cannot pull together rows that are far apart on the display axis. - public static func reorderSameSenderClusters(_ messages: [MessageDTO]) -> [MessageDTO] { - guard messages.count > 1 else { return messages } - - var result = messages - var clusterStart = 0 - - while clusterStart < result.count { - var clusterEnd = clusterStart + 1 - - // Extend the cluster while consecutive messages match the same sender/direction - // and fall within the reorder window - while clusterEnd < result.count { - let gap = result[clusterEnd].sortDate.timeIntervalSince(result[clusterEnd - 1].sortDate) - guard isSameSender(result[clusterEnd], result[clusterEnd - 1]), - gap <= sameSenderReorderWindow else { break } - clusterEnd += 1 - } - - // Sort the cluster by sender timestamp if it contains more than one message - if clusterEnd - clusterStart > 1 { - let sorted = result[clusterStart.. Bool { - guard a.direction == b.direction else { return false } - guard a.isChannelMessage == b.isChannelMessage else { return false } - - // For channel messages, compare sender node name (nil = unknown, treat as different). - // senderNodeName isn't unique — two users with the same name may be falsely clustered. - if a.isChannelMessage { - guard let nameA = a.senderNodeName, let nameB = b.senderNodeName else { return false } - return nameA == nameB - } - - // For DMs, same-direction messages share the same sender - return true - } + // For DMs, same-direction messages share the same sender + return true + } } diff --git a/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift b/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift index c74f7141..00671ccb 100644 --- a/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift +++ b/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift @@ -6,51 +6,51 @@ import Foundation /// `hasFailed` is true only for `MessageStatus.failed`. Transitional statuses /// (`.pending`, `.sending`, `.retrying`) do not set this flag. public struct MessageEnvelope: Sendable, Hashable { - public let messageID: UUID - public let isOutgoing: Bool - public let senderName: String - public let senderResolution: NodeNameResolution - public let status: MessageStatus - public let date: Date - public let hasFailed: Bool - public let containsSelfMention: Bool - public let mentionSeen: Bool + public let messageID: UUID + public let isOutgoing: Bool + public let senderName: String + public let senderResolution: NodeNameResolution + public let status: MessageStatus + public let date: Date + public let hasFailed: Bool + public let containsSelfMention: Bool + public let mentionSeen: Bool - public init( - messageID: UUID, - isOutgoing: Bool, - senderName: String, - senderResolution: NodeNameResolution, - status: MessageStatus, - date: Date, - hasFailed: Bool, - containsSelfMention: Bool, - mentionSeen: Bool - ) { - self.messageID = messageID - self.isOutgoing = isOutgoing - self.senderName = senderName - self.senderResolution = senderResolution - self.status = status - self.date = date - self.hasFailed = hasFailed - self.containsSelfMention = containsSelfMention - self.mentionSeen = mentionSeen - } + public init( + messageID: UUID, + isOutgoing: Bool, + senderName: String, + senderResolution: NodeNameResolution, + status: MessageStatus, + date: Date, + hasFailed: Bool, + containsSelfMention: Bool, + mentionSeen: Bool + ) { + self.messageID = messageID + self.isOutgoing = isOutgoing + self.senderName = senderName + self.senderResolution = senderResolution + self.status = status + self.date = date + self.hasFailed = hasFailed + self.containsSelfMention = containsSelfMention + self.mentionSeen = mentionSeen + } - /// Returns a new envelope with `status` (and the derived `hasFailed`) - /// overridden. Eliminates the 9-field rebuild at status-flip sites. - public func with(status: MessageStatus) -> MessageEnvelope { - MessageEnvelope( - messageID: messageID, - isOutgoing: isOutgoing, - senderName: senderName, - senderResolution: senderResolution, - status: status, - date: date, - hasFailed: status == .failed, - containsSelfMention: containsSelfMention, - mentionSeen: mentionSeen - ) - } + /// Returns a new envelope with `status` (and the derived `hasFailed`) + /// overridden. Eliminates the 9-field rebuild at status-flip sites. + public func with(status: MessageStatus) -> MessageEnvelope { + MessageEnvelope( + messageID: messageID, + isOutgoing: isOutgoing, + senderName: senderName, + senderResolution: senderResolution, + status: status, + date: date, + hasFailed: status == .failed, + containsSelfMention: containsSelfMention, + mentionSeen: mentionSeen + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/MessageRepeat.swift b/MC1Services/Sources/MC1Services/Models/MessageRepeat.swift index 666b6235..55cb87e4 100644 --- a/MC1Services/Sources/MC1Services/Models/MessageRepeat.swift +++ b/MC1Services/Sources/MC1Services/Models/MessageRepeat.swift @@ -6,170 +6,174 @@ import SwiftData /// Each repeat is an observation of the message being re-broadcast by a repeater. @Model final class MessageRepeat { - #Index( - [\.messageID, \.receivedAt], - [\.rxLogEntryID] + #Index( + [\.messageID, \.receivedAt], + [\.rxLogEntryID] + ) + + @Attribute(.unique) + var id: UUID + + /// The parent message (cascade delete when message is deleted) + var message: Message? + + /// The message ID (kept for queries, matches message.id) + var messageID: UUID + + /// When this repeat was received by the companion radio + var receivedAt: Date + + /// Repeater public key prefixes (1–3 bytes per hop depending on hash mode) + var pathNodes: Data + + /// Encoded path length byte (upper 2 bits = hash mode, lower 6 bits = hop count) + var pathLength: UInt8 = 0 + + /// Signal-to-noise ratio in dB + var snr: Double? + + /// Received signal strength indicator in dBm + var rssi: Int? + + /// Link to RxLogEntry for raw packet details + var rxLogEntryID: UUID? + + init( + id: UUID = UUID(), + message: Message? = nil, + messageID: UUID, + receivedAt: Date = Date(), + pathNodes: Data, + pathLength: UInt8 = 0, + snr: Double? = nil, + rssi: Int? = nil, + rxLogEntryID: UUID? = nil + ) { + self.id = id + self.message = message + self.messageID = messageID + self.receivedAt = receivedAt + self.pathNodes = pathNodes + self.pathLength = pathLength + self.snr = snr + self.rssi = rssi + self.rxLogEntryID = rxLogEntryID + } + + /// Builds a model instance directly from a DTO. The parent `message` + /// relationship is passed separately because the caller looks it up in + /// bulk before iterating. + convenience init(dto: MessageRepeatDTO, message: Message?) { + self.init( + id: dto.id, + message: message, + messageID: dto.messageID, + receivedAt: dto.receivedAt, + pathNodes: dto.pathNodes, + pathLength: dto.pathLength, + snr: dto.snr, + rssi: dto.rssi, + rxLogEntryID: dto.rxLogEntryID ) - - @Attribute(.unique) - var id: UUID - - /// The parent message (cascade delete when message is deleted) - var message: Message? - - /// The message ID (kept for queries, matches message.id) - var messageID: UUID - - /// When this repeat was received by the companion radio - var receivedAt: Date - - /// Repeater public key prefixes (1–3 bytes per hop depending on hash mode) - var pathNodes: Data - - /// Encoded path length byte (upper 2 bits = hash mode, lower 6 bits = hop count) - var pathLength: UInt8 = 0 - - /// Signal-to-noise ratio in dB - var snr: Double? - - /// Received signal strength indicator in dBm - var rssi: Int? - - /// Link to RxLogEntry for raw packet details - var rxLogEntryID: UUID? - - init( - id: UUID = UUID(), - message: Message? = nil, - messageID: UUID, - receivedAt: Date = Date(), - pathNodes: Data, - pathLength: UInt8 = 0, - snr: Double? = nil, - rssi: Int? = nil, - rxLogEntryID: UUID? = nil - ) { - self.id = id - self.message = message - self.messageID = messageID - self.receivedAt = receivedAt - self.pathNodes = pathNodes - self.pathLength = pathLength - self.snr = snr - self.rssi = rssi - self.rxLogEntryID = rxLogEntryID - } - - /// Builds a model instance directly from a DTO. The parent `message` - /// relationship is passed separately because the caller looks it up in - /// bulk before iterating. - convenience init(dto: MessageRepeatDTO, message: Message?) { - self.init( - id: dto.id, - message: message, - messageID: dto.messageID, - receivedAt: dto.receivedAt, - pathNodes: dto.pathNodes, - pathLength: dto.pathLength, - snr: dto.snr, - rssi: dto.rssi, - rxLogEntryID: dto.rxLogEntryID - ) - } + } } // MARK: - DTO /// Sendable DTO for cross-actor transfer of MessageRepeat data. public struct MessageRepeatDTO: Sendable, Identifiable, Equatable, Hashable, Codable { - public let id: UUID - public var messageID: UUID - public let receivedAt: Date - public let pathNodes: Data - public let pathLength: UInt8 - public let snr: Double? - public let rssi: Int? - public let rxLogEntryID: UUID? - - init(from model: MessageRepeat) { - self.id = model.id - self.messageID = model.messageID - self.receivedAt = model.receivedAt - self.pathNodes = model.pathNodes - self.pathLength = model.pathLength - self.snr = model.snr - self.rssi = model.rssi - self.rxLogEntryID = model.rxLogEntryID - } - - public init( - id: UUID = UUID(), - messageID: UUID, - receivedAt: Date, - pathNodes: Data, - pathLength: UInt8 = 0, - snr: Double?, - rssi: Int?, - rxLogEntryID: UUID? - ) { - self.id = id - self.messageID = messageID - self.receivedAt = receivedAt - self.pathNodes = pathNodes - self.pathLength = pathLength - self.snr = snr - self.rssi = rssi - self.rxLogEntryID = rxLogEntryID - } - - // MARK: - Computed Properties - - /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits - public var hashSize: Int { - decodePathLen(pathLength)?.hashSize ?? 1 - } - - /// Last repeater's public key prefix bytes (the node we heard from), or nil if direct - public var repeaterHash: Data? { - guard !pathNodes.isEmpty else { return nil } - let size = hashSize - guard pathNodes.count >= size else { return pathNodes } - return pathNodes.suffix(size) - } - - /// Number of hops in the path (1 = direct from repeater, 2+ = multi-hop) - public var hopCount: Int { - let size = hashSize - guard size > 0 else { return pathNodes.count } - return pathNodes.count / size - } - - /// Repeater hash formatted as hex (e.g., "31" for 1-byte, "31A7" for 2-byte) - public var repeaterHashFormatted: String { - guard let hash = repeaterHash else { return "00" } - return hash.uppercaseHexString() - } - - /// Path nodes as hex strings for display, chunked by hash size - public var pathNodesHex: [String] { - pathNodes.pathHops(hashSize: hashSize).map(\.hex) - } - - /// Classified signal quality based on SNR thresholds. - public var snrQuality: SNRQuality { SNRQuality(snr: snr) } - - /// SNR mapped to 0-1 for signal bars variableValue. - public var snrLevel: Double { snrQuality.barLevel } - - /// RSSI formatted for display - public var rssiFormatted: String { - guard let rssi = rssi else { return "—" } - return "\(rssi) dBm" - } - - /// SNR formatted for display - public var snrFormatted: String { - guard let snr = snr else { return "—" } - return snr.formatted(.number.precision(.fractionLength(1))) + " dB" - } + public let id: UUID + public var messageID: UUID + public let receivedAt: Date + public let pathNodes: Data + public let pathLength: UInt8 + public let snr: Double? + public let rssi: Int? + public let rxLogEntryID: UUID? + + init(from model: MessageRepeat) { + id = model.id + messageID = model.messageID + receivedAt = model.receivedAt + pathNodes = model.pathNodes + pathLength = model.pathLength + snr = model.snr + rssi = model.rssi + rxLogEntryID = model.rxLogEntryID + } + + public init( + id: UUID = UUID(), + messageID: UUID, + receivedAt: Date, + pathNodes: Data, + pathLength: UInt8 = 0, + snr: Double?, + rssi: Int?, + rxLogEntryID: UUID? + ) { + self.id = id + self.messageID = messageID + self.receivedAt = receivedAt + self.pathNodes = pathNodes + self.pathLength = pathLength + self.snr = snr + self.rssi = rssi + self.rxLogEntryID = rxLogEntryID + } + + // MARK: - Computed Properties + + /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits + public var hashSize: Int { + decodePathLen(pathLength)?.hashSize ?? 1 + } + + /// Last repeater's public key prefix bytes (the node we heard from), or nil if direct + public var repeaterHash: Data? { + guard !pathNodes.isEmpty else { return nil } + let size = hashSize + guard pathNodes.count >= size else { return pathNodes } + return pathNodes.suffix(size) + } + + /// Number of hops in the path (1 = direct from repeater, 2+ = multi-hop) + public var hopCount: Int { + let size = hashSize + guard size > 0 else { return pathNodes.count } + return pathNodes.count / size + } + + /// Repeater hash formatted as hex (e.g., "31" for 1-byte, "31A7" for 2-byte) + public var repeaterHashFormatted: String { + guard let hash = repeaterHash else { return "00" } + return hash.uppercaseHexString() + } + + /// Path nodes as hex strings for display, chunked by hash size + public var pathNodesHex: [String] { + pathNodes.pathHops(hashSize: hashSize).map(\.hex) + } + + /// Classified signal quality based on SNR thresholds. + public var snrQuality: SNRQuality { + SNRQuality(snr: snr) + } + + /// SNR mapped to 0-1 for signal bars variableValue. + public var snrLevel: Double { + snrQuality.barLevel + } + + /// RSSI formatted for display + public var rssiFormatted: String { + guard let rssi else { return "—" } + return "\(rssi) dBm" + } + + /// SNR formatted for display + public var snrFormatted: String { + guard let snr else { return "—" } + return snr.formatted(.number.precision(.fractionLength(1))) + " dB" + } } diff --git a/MC1Services/Sources/MC1Services/Models/NodeConfig.swift b/MC1Services/Sources/MC1Services/Models/NodeConfig.swift index 489d9635..033cc412 100644 --- a/MC1Services/Sources/MC1Services/Models/NodeConfig.swift +++ b/MC1Services/Sources/MC1Services/Models/NodeConfig.swift @@ -2,294 +2,294 @@ import Foundation /// JSON-serializable node configuration, compatible with MeshCore companion app format. public struct MeshCoreNodeConfig: Codable, Sendable, Equatable { - public var name: String? - public var publicKey: String? - public var privateKey: String? - public var radioSettings: RadioSettings? - public var positionSettings: PositionSettings? - public var otherSettings: OtherSettings? - public var channels: [ChannelConfig]? - public var contacts: [ContactConfig]? + public var name: String? + public var publicKey: String? + public var privateKey: String? + public var radioSettings: RadioSettings? + public var positionSettings: PositionSettings? + public var otherSettings: OtherSettings? + public var channels: [ChannelConfig]? + public var contacts: [ContactConfig]? - public init( - name: String? = nil, - publicKey: String? = nil, - privateKey: String? = nil, - radioSettings: RadioSettings? = nil, - positionSettings: PositionSettings? = nil, - otherSettings: OtherSettings? = nil, - channels: [ChannelConfig]? = nil, - contacts: [ContactConfig]? = nil - ) { - self.name = name - self.publicKey = publicKey - self.privateKey = privateKey - self.radioSettings = radioSettings - self.positionSettings = positionSettings - self.otherSettings = otherSettings - self.channels = channels - self.contacts = contacts - } + public init( + name: String? = nil, + publicKey: String? = nil, + privateKey: String? = nil, + radioSettings: RadioSettings? = nil, + positionSettings: PositionSettings? = nil, + otherSettings: OtherSettings? = nil, + channels: [ChannelConfig]? = nil, + contacts: [ContactConfig]? = nil + ) { + self.name = name + self.publicKey = publicKey + self.privateKey = privateKey + self.radioSettings = radioSettings + self.positionSettings = positionSettings + self.otherSettings = otherSettings + self.channels = channels + self.contacts = contacts + } - enum CodingKeys: String, CodingKey { - case name - case publicKey = "public_key" - case privateKey = "private_key" - case radioSettings = "radio_settings" - case positionSettings = "position_settings" - case otherSettings = "other_settings" - case channels - case contacts - } + enum CodingKeys: String, CodingKey { + case name + case publicKey = "public_key" + case privateKey = "private_key" + case radioSettings = "radio_settings" + case positionSettings = "position_settings" + case otherSettings = "other_settings" + case channels + case contacts + } } // MARK: - Radio Settings -extension MeshCoreNodeConfig { - public struct RadioSettings: Codable, Sendable, Equatable { - /// Frequency in kHz (e.g. 910525 = 910.525 MHz) - public var frequency: UInt32 - /// Bandwidth in Hz (e.g. 62500 = 62.5 kHz) - public var bandwidth: UInt32 - public var spreadingFactor: UInt8 - public var codingRate: UInt8 - /// Transmit power in dBm (may be negative) - public var txPower: Int8 +public extension MeshCoreNodeConfig { + struct RadioSettings: Codable, Sendable, Equatable { + /// Frequency in kHz (e.g. 910525 = 910.525 MHz) + public var frequency: UInt32 + /// Bandwidth in Hz (e.g. 62500 = 62.5 kHz) + public var bandwidth: UInt32 + public var spreadingFactor: UInt8 + public var codingRate: UInt8 + /// Transmit power in dBm (may be negative) + public var txPower: Int8 - public init( - frequency: UInt32, - bandwidth: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8, - txPower: Int8 - ) { - self.frequency = frequency - self.bandwidth = bandwidth - self.spreadingFactor = spreadingFactor - self.codingRate = codingRate - self.txPower = txPower - } + public init( + frequency: UInt32, + bandwidth: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8, + txPower: Int8 + ) { + self.frequency = frequency + self.bandwidth = bandwidth + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + self.txPower = txPower + } - // swiftlint:disable:next nesting - enum CodingKeys: String, CodingKey { - case frequency, bandwidth - case spreadingFactor = "spreading_factor" - case codingRate = "coding_rate" - case txPower = "tx_power" - } + // swiftlint:disable:next nesting + enum CodingKeys: String, CodingKey { + case frequency, bandwidth + case spreadingFactor = "spreading_factor" + case codingRate = "coding_rate" + case txPower = "tx_power" } + } } // MARK: - Position Settings -extension MeshCoreNodeConfig { - public struct PositionSettings: Codable, Sendable, Equatable { - public var latitude: String - public var longitude: String +public extension MeshCoreNodeConfig { + struct PositionSettings: Codable, Sendable, Equatable { + public var latitude: String + public var longitude: String - public init(latitude: String, longitude: String) { - self.latitude = latitude - self.longitude = longitude - } + public init(latitude: String, longitude: String) { + self.latitude = latitude + self.longitude = longitude + } - /// Both lat and lon are zero (likely unset). - public var isZero: Bool { - (Double(latitude) ?? 0) == 0 && (Double(longitude) ?? 0) == 0 - } + /// Both lat and lon are zero (likely unset). + public var isZero: Bool { + (Double(latitude) ?? 0) == 0 && (Double(longitude) ?? 0) == 0 } + } } // MARK: - Other Settings -extension MeshCoreNodeConfig { - /// Other device parameters. Only `manual_add_contacts` and `advert_location_policy` - /// are exported, matching the official companion app format. All 7 fields are decoded - /// on import for forward compatibility. - public struct OtherSettings: Codable, Sendable, Equatable { - public var manualAddContacts: UInt8? - public var advertLocationPolicy: UInt8? - public var telemetryModeBase: UInt8? - public var telemetryModeLocation: UInt8? - public var telemetryModeEnvironment: UInt8? - public var multiAcks: UInt8? - public var advertisementType: UInt8? +public extension MeshCoreNodeConfig { + /// Other device parameters. Only `manual_add_contacts` and `advert_location_policy` + /// are exported, matching the official companion app format. All 7 fields are decoded + /// on import for forward compatibility. + struct OtherSettings: Codable, Sendable, Equatable { + public var manualAddContacts: UInt8? + public var advertLocationPolicy: UInt8? + public var telemetryModeBase: UInt8? + public var telemetryModeLocation: UInt8? + public var telemetryModeEnvironment: UInt8? + public var multiAcks: UInt8? + public var advertisementType: UInt8? - public init( - manualAddContacts: UInt8? = nil, - advertLocationPolicy: UInt8? = nil, - telemetryModeBase: UInt8? = nil, - telemetryModeLocation: UInt8? = nil, - telemetryModeEnvironment: UInt8? = nil, - multiAcks: UInt8? = nil, - advertisementType: UInt8? = nil - ) { - self.manualAddContacts = manualAddContacts - self.advertLocationPolicy = advertLocationPolicy - self.telemetryModeBase = telemetryModeBase - self.telemetryModeLocation = telemetryModeLocation - self.telemetryModeEnvironment = telemetryModeEnvironment - self.multiAcks = multiAcks - self.advertisementType = advertisementType - } + public init( + manualAddContacts: UInt8? = nil, + advertLocationPolicy: UInt8? = nil, + telemetryModeBase: UInt8? = nil, + telemetryModeLocation: UInt8? = nil, + telemetryModeEnvironment: UInt8? = nil, + multiAcks: UInt8? = nil, + advertisementType: UInt8? = nil + ) { + self.manualAddContacts = manualAddContacts + self.advertLocationPolicy = advertLocationPolicy + self.telemetryModeBase = telemetryModeBase + self.telemetryModeLocation = telemetryModeLocation + self.telemetryModeEnvironment = telemetryModeEnvironment + self.multiAcks = multiAcks + self.advertisementType = advertisementType + } - // swiftlint:disable:next nesting - enum CodingKeys: String, CodingKey { - case manualAddContacts = "manual_add_contacts" - case advertLocationPolicy = "advert_location_policy" - case telemetryModeBase = "telemetry_mode_base" - case telemetryModeLocation = "telemetry_mode_location" - case telemetryModeEnvironment = "telemetry_mode_environment" - case multiAcks = "multi_acks" - case advertisementType = "advertisement_type" - } + // swiftlint:disable:next nesting + enum CodingKeys: String, CodingKey { + case manualAddContacts = "manual_add_contacts" + case advertLocationPolicy = "advert_location_policy" + case telemetryModeBase = "telemetry_mode_base" + case telemetryModeLocation = "telemetry_mode_location" + case telemetryModeEnvironment = "telemetry_mode_environment" + case multiAcks = "multi_acks" + case advertisementType = "advertisement_type" } + } } // MARK: - Channel Config -extension MeshCoreNodeConfig { - public struct ChannelConfig: Codable, Sendable, Equatable { - public var name: String - /// Hex-encoded 16-byte secret (32 hex characters). - public var secret: String +public extension MeshCoreNodeConfig { + struct ChannelConfig: Codable, Sendable, Equatable { + public var name: String + /// Hex-encoded 16-byte secret (32 hex characters). + public var secret: String - public init(name: String, secret: String) { - self.name = name - self.secret = secret - } + public init(name: String, secret: String) { + self.name = name + self.secret = secret } + } } // MARK: - Contact Config -extension MeshCoreNodeConfig { - public struct ContactConfig: Codable, Sendable, Equatable { - public var type: UInt8 - public var name: String - /// App-local nickname (not part of wire protocol). - /// Decoded from JSON for companion app compatibility but not imported to firmware. - public var customName: String? - /// Hex-encoded 32-byte public key (64 hex characters) - public var publicKey: String - public var flags: UInt8 - public var latitude: String - public var longitude: String - public var lastAdvert: UInt32 - /// Read-only; firmware sets this value - public var lastModified: UInt32 - /// Hex-encoded path data, or nil for no path - public var outPath: String? - /// Path hash mode (0=1-byte, 1=2-byte, 2=3-byte). Nil in configs from older versions. - public var pathHashMode: UInt8? +public extension MeshCoreNodeConfig { + struct ContactConfig: Codable, Sendable, Equatable { + public var type: UInt8 + public var name: String + /// App-local nickname (not part of wire protocol). + /// Decoded from JSON for companion app compatibility but not imported to firmware. + public var customName: String? + /// Hex-encoded 32-byte public key (64 hex characters) + public var publicKey: String + public var flags: UInt8 + public var latitude: String + public var longitude: String + public var lastAdvert: UInt32 + /// Read-only; firmware sets this value + public var lastModified: UInt32 + /// Hex-encoded path data, or nil for no path + public var outPath: String? + /// Path hash mode (0=1-byte, 1=2-byte, 2=3-byte). Nil in configs from older versions. + public var pathHashMode: UInt8? - public init( - type: UInt8, - name: String, - customName: String? = nil, - publicKey: String, - flags: UInt8, - latitude: String, - longitude: String, - lastAdvert: UInt32, - lastModified: UInt32, - outPath: String? = nil, - pathHashMode: UInt8? = nil - ) { - self.type = type - self.name = name - self.customName = customName - self.publicKey = publicKey - self.flags = flags - self.latitude = latitude - self.longitude = longitude - self.lastAdvert = lastAdvert - self.lastModified = lastModified - self.outPath = outPath - self.pathHashMode = pathHashMode - } + public init( + type: UInt8, + name: String, + customName: String? = nil, + publicKey: String, + flags: UInt8, + latitude: String, + longitude: String, + lastAdvert: UInt32, + lastModified: UInt32, + outPath: String? = nil, + pathHashMode: UInt8? = nil + ) { + self.type = type + self.name = name + self.customName = customName + self.publicKey = publicKey + self.flags = flags + self.latitude = latitude + self.longitude = longitude + self.lastAdvert = lastAdvert + self.lastModified = lastModified + self.outPath = outPath + self.pathHashMode = pathHashMode + } - // swiftlint:disable:next nesting - enum CodingKeys: String, CodingKey { - case type, name, flags, latitude, longitude - case customName = "custom_name" - case publicKey = "public_key" - case lastAdvert = "last_advert" - case lastModified = "last_modified" - case outPath = "out_path" - case pathHashMode = "path_hash_mode" - } + // swiftlint:disable:next nesting + enum CodingKeys: String, CodingKey { + case type, name, flags, latitude, longitude + case customName = "custom_name" + case publicKey = "public_key" + case lastAdvert = "last_advert" + case lastModified = "last_modified" + case outPath = "out_path" + case pathHashMode = "path_hash_mode" + } - /// Encodes with explicit `null` for nil `customName` and `outPath`, - /// matching the official companion app JSON format. - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(type, forKey: .type) - try container.encode(name, forKey: .name) - try container.encode(customName, forKey: .customName) - try container.encode(publicKey, forKey: .publicKey) - try container.encode(flags, forKey: .flags) - try container.encode(latitude, forKey: .latitude) - try container.encode(longitude, forKey: .longitude) - try container.encode(lastAdvert, forKey: .lastAdvert) - try container.encode(lastModified, forKey: .lastModified) - try container.encode(outPath, forKey: .outPath) - try container.encodeIfPresent(pathHashMode, forKey: .pathHashMode) - } + /// Encodes with explicit `null` for nil `customName` and `outPath`, + /// matching the official companion app JSON format. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encode(name, forKey: .name) + try container.encode(customName, forKey: .customName) + try container.encode(publicKey, forKey: .publicKey) + try container.encode(flags, forKey: .flags) + try container.encode(latitude, forKey: .latitude) + try container.encode(longitude, forKey: .longitude) + try container.encode(lastAdvert, forKey: .lastAdvert) + try container.encode(lastModified, forKey: .lastModified) + try container.encode(outPath, forKey: .outPath) + try container.encodeIfPresent(pathHashMode, forKey: .pathHashMode) } + } } // MARK: - Section Selection /// Controls which config sections to include in export/import. public struct ConfigSections: Sendable, Equatable { - public var nodeIdentity: Bool - public var radioSettings: Bool - public var positionSettings: Bool - public var otherSettings: Bool - public var channels: Bool - public var contacts: Bool + public var nodeIdentity: Bool + public var radioSettings: Bool + public var positionSettings: Bool + public var otherSettings: Bool + public var channels: Bool + public var contacts: Bool - public init( - nodeIdentity: Bool = false, - radioSettings: Bool = false, - positionSettings: Bool = false, - otherSettings: Bool = false, - channels: Bool = false, - contacts: Bool = false - ) { - self.nodeIdentity = nodeIdentity - self.radioSettings = radioSettings - self.positionSettings = positionSettings - self.otherSettings = otherSettings - self.channels = channels - self.contacts = contacts - } + public init( + nodeIdentity: Bool = false, + radioSettings: Bool = false, + positionSettings: Bool = false, + otherSettings: Bool = false, + channels: Bool = false, + contacts: Bool = false + ) { + self.nodeIdentity = nodeIdentity + self.radioSettings = radioSettings + self.positionSettings = positionSettings + self.otherSettings = otherSettings + self.channels = channels + self.contacts = contacts + } - /// True when all sections are selected. - public var allSelected: Bool { - nodeIdentity && radioSettings && positionSettings && otherSettings && channels && contacts - } + /// True when all sections are selected. + public var allSelected: Bool { + nodeIdentity && radioSettings && positionSettings && otherSettings && channels && contacts + } - /// True when at least one section is selected. - public var anySectionSelected: Bool { - nodeIdentity || radioSettings || positionSettings || otherSettings || channels || contacts - } + /// True when at least one section is selected. + public var anySectionSelected: Bool { + nodeIdentity || radioSettings || positionSettings || otherSettings || channels || contacts + } - public mutating func selectAll() { - nodeIdentity = true - radioSettings = true - positionSettings = true - otherSettings = true - channels = true - contacts = true - } + public mutating func selectAll() { + nodeIdentity = true + radioSettings = true + positionSettings = true + otherSettings = true + channels = true + contacts = true + } - public mutating func deselectAll() { - nodeIdentity = false - radioSettings = false - positionSettings = false - otherSettings = false - channels = false - contacts = false - } + public mutating func deselectAll() { + nodeIdentity = false + radioSettings = false + positionSettings = false + otherSettings = false + channels = false + contacts = false + } } diff --git a/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift b/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift index 484a57bf..6fe8a66e 100644 --- a/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift +++ b/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift @@ -4,295 +4,296 @@ import SwiftData /// Capture-rate policy for node status snapshots. enum NodeSnapshotPolicy { - /// Minimum interval between persisted snapshots for the same node. - /// A status, telemetry, or neighbor capture arriving within this window of - /// the latest snapshot enriches that row rather than inserting a new one. - static let minimumInterval: TimeInterval = 15 * 60 + /// Minimum interval between persisted snapshots for the same node. + /// A status, telemetry, or neighbor capture arriving within this window of + /// the latest snapshot enriches that row rather than inserting a new one. + static let minimumInterval: TimeInterval = 15 * 60 } /// The radio/room status fields captured in a snapshot, clamped to their /// storage types. Bundling them into one value keeps the save and backfill /// paths in lockstep and performs the lossy `Int16` conversions exactly once. public struct NodeStatusMetrics: Sendable, Equatable { - public let batteryMillivolts: UInt16? - public let lastSNR: Double? - public let lastRSSI: Int16? - public let noiseFloor: Int16? - public let uptimeSeconds: UInt32? - public let rxAirtimeSeconds: UInt32? - public let packetsSent: UInt32? - public let packetsReceived: UInt32? - public let receiveErrors: UInt32? - public let postedCount: UInt16? - public let postPushCount: UInt16? + public let batteryMillivolts: UInt16? + public let lastSNR: Double? + public let lastRSSI: Int16? + public let noiseFloor: Int16? + public let uptimeSeconds: UInt32? + public let rxAirtimeSeconds: UInt32? + public let packetsSent: UInt32? + public let packetsReceived: UInt32? + public let receiveErrors: UInt32? + public let postedCount: UInt16? + public let postPushCount: UInt16? - public init( - batteryMillivolts: UInt16?, - lastSNR: Double?, - lastRSSI: Int16?, - noiseFloor: Int16?, - uptimeSeconds: UInt32?, - rxAirtimeSeconds: UInt32?, - packetsSent: UInt32?, - packetsReceived: UInt32?, - receiveErrors: UInt32?, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil - ) { - self.batteryMillivolts = batteryMillivolts - self.lastSNR = lastSNR - self.lastRSSI = lastRSSI - self.noiseFloor = noiseFloor - self.uptimeSeconds = uptimeSeconds - self.rxAirtimeSeconds = rxAirtimeSeconds - self.packetsSent = packetsSent - self.packetsReceived = packetsReceived - self.receiveErrors = receiveErrors - self.postedCount = postedCount - self.postPushCount = postPushCount - } + public init( + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) { + self.batteryMillivolts = batteryMillivolts + self.lastSNR = lastSNR + self.lastRSSI = lastRSSI + self.noiseFloor = noiseFloor + self.uptimeSeconds = uptimeSeconds + self.rxAirtimeSeconds = rxAirtimeSeconds + self.packetsSent = packetsSent + self.packetsReceived = packetsReceived + self.receiveErrors = receiveErrors + self.postedCount = postedCount + self.postPushCount = postPushCount + } - /// Build metrics from a status response. `lastRSSI` and `noiseFloor` saturate - /// into `Int16` so an out-of-range firmware value clamps to the axis bound. - /// Role-specific fields are supplied by the caller: repeaters pass - /// `rxAirtimeSeconds`/`receiveErrors`, rooms pass `postedCount`/`postPushCount`. - public init( - status: RemoteNodeStatus, - rxAirtimeSeconds: UInt32? = nil, - receiveErrors: UInt32? = nil, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil - ) { - self.init( - batteryMillivolts: status.batteryMillivolts, - lastSNR: status.lastSNR, - lastRSSI: Int16(clamping: status.lastRSSI), - noiseFloor: Int16(clamping: status.noiseFloor), - uptimeSeconds: status.uptimeSeconds, - rxAirtimeSeconds: rxAirtimeSeconds, - packetsSent: status.packetsSent, - packetsReceived: status.packetsReceived, - receiveErrors: receiveErrors, - postedCount: postedCount, - postPushCount: postPushCount - ) - } + /// Build metrics from a status response. `lastRSSI` and `noiseFloor` saturate + /// into `Int16` so an out-of-range firmware value clamps to the axis bound. + /// Role-specific fields are supplied by the caller: repeaters pass + /// `rxAirtimeSeconds`/`receiveErrors`, rooms pass `postedCount`/`postPushCount`. + public init( + status: RemoteNodeStatus, + rxAirtimeSeconds: UInt32? = nil, + receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) { + self.init( + batteryMillivolts: status.batteryMillivolts, + lastSNR: status.lastSNR, + lastRSSI: Int16(clamping: status.lastRSSI), + noiseFloor: Int16(clamping: status.noiseFloor), + uptimeSeconds: status.uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: status.packetsSent, + packetsReceived: status.packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + } } /// Codable entry representing a single neighbor's state at snapshot time. public struct NeighborSnapshotEntry: Codable, Sendable, Equatable { - public let publicKeyPrefix: Data - public let snr: Double - public let secondsAgo: Int + public let publicKeyPrefix: Data + public let snr: Double + public let secondsAgo: Int - public init(publicKeyPrefix: Data, snr: Double, secondsAgo: Int) { - self.publicKeyPrefix = publicKeyPrefix - self.snr = snr - self.secondsAgo = secondsAgo - } + public init(publicKeyPrefix: Data, snr: Double, secondsAgo: Int) { + self.publicKeyPrefix = publicKeyPrefix + self.snr = snr + self.secondsAgo = secondsAgo + } } /// Codable entry representing a single telemetry reading at snapshot time. public struct TelemetrySnapshotEntry: Codable, Sendable, Equatable { - public let channel: Int - public let type: String - public let value: Double + public let channel: Int + public let type: String + public let value: Double - public init(channel: Int, type: String, value: Double) { - self.channel = channel - self.type = type - self.value = value - } + public init(channel: Int, type: String, value: Double) { + self.channel = channel + self.type = type + self.value = value + } } /// Point-in-time snapshot of a remote node's status, captured when the user views it. @Model final class NodeStatusSnapshot { - #Index([\.nodePublicKey, \.timestamp]) + #Index([\.nodePublicKey, \.timestamp]) - @Attribute(.unique) - var id: UUID + @Attribute(.unique) + var id: UUID - /// When this snapshot was captured - var timestamp: Date + /// When this snapshot was captured + var timestamp: Date - /// The node's full public key (32 bytes) -- links to RemoteNodeSession - var nodePublicKey: Data + /// The node's full public key (32 bytes) -- links to RemoteNodeSession + var nodePublicKey: Data - // MARK: - Radio metrics - // Intentionally excluded: txQueueLength, airtime, sentFlood, sentDirect, - // receivedFlood, receivedDirect, fullEvents, directDuplicates, floodDuplicates + // MARK: - Radio metrics - var batteryMillivolts: UInt16? - var lastSNR: Double? - var lastRSSI: Int16? - var noiseFloor: Int16? - var uptimeSeconds: UInt32? - var rxAirtimeSeconds: UInt32? - var packetsSent: UInt32? - var packetsReceived: UInt32? - var receiveErrors: UInt32? + // Intentionally excluded: txQueueLength, airtime, sentFlood, sentDirect, + // receivedFlood, receivedDirect, fullEvents, directDuplicates, floodDuplicates - // MARK: - Room server metrics + var batteryMillivolts: UInt16? + var lastSNR: Double? + var lastRSSI: Int16? + var noiseFloor: Int16? + var uptimeSeconds: UInt32? + var rxAirtimeSeconds: UInt32? + var packetsSent: UInt32? + var packetsReceived: UInt32? + var receiveErrors: UInt32? - var postedCount: UInt16? - var postPushCount: UInt16? + // MARK: - Room server metrics - // MARK: - Optional neighbor/telemetry data + var postedCount: UInt16? + var postPushCount: UInt16? - /// Neighbor data, only populated if the user expanded the neighbors section. - var neighborSnapshots: [NeighborSnapshotEntry]? + // MARK: - Optional neighbor/telemetry data - /// Telemetry data, only populated if the user expanded the telemetry section. - var telemetryEntries: [TelemetrySnapshotEntry]? + /// Neighbor data, only populated if the user expanded the neighbors section. + var neighborSnapshots: [NeighborSnapshotEntry]? - init( - id: UUID = UUID(), - timestamp: Date = .now, - nodePublicKey: Data, - batteryMillivolts: UInt16? = nil, - lastSNR: Double? = nil, - lastRSSI: Int16? = nil, - noiseFloor: Int16? = nil, - uptimeSeconds: UInt32? = nil, - rxAirtimeSeconds: UInt32? = nil, - packetsSent: UInt32? = nil, - packetsReceived: UInt32? = nil, - receiveErrors: UInt32? = nil, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil, - neighborSnapshots: [NeighborSnapshotEntry]? = nil, - telemetryEntries: [TelemetrySnapshotEntry]? = nil - ) { - self.id = id - self.timestamp = timestamp - self.nodePublicKey = nodePublicKey - self.batteryMillivolts = batteryMillivolts - self.lastSNR = lastSNR - self.lastRSSI = lastRSSI - self.noiseFloor = noiseFloor - self.uptimeSeconds = uptimeSeconds - self.rxAirtimeSeconds = rxAirtimeSeconds - self.packetsSent = packetsSent - self.packetsReceived = packetsReceived - self.receiveErrors = receiveErrors - self.postedCount = postedCount - self.postPushCount = postPushCount - self.neighborSnapshots = neighborSnapshots - self.telemetryEntries = telemetryEntries - } + /// Telemetry data, only populated if the user expanded the telemetry section. + var telemetryEntries: [TelemetrySnapshotEntry]? - /// Apply captured status metrics onto this snapshot, leaving neighbor and - /// telemetry enrichment untouched. - func apply(_ metrics: NodeStatusMetrics) { - batteryMillivolts = metrics.batteryMillivolts - lastSNR = metrics.lastSNR - lastRSSI = metrics.lastRSSI - noiseFloor = metrics.noiseFloor - uptimeSeconds = metrics.uptimeSeconds - rxAirtimeSeconds = metrics.rxAirtimeSeconds - packetsSent = metrics.packetsSent - packetsReceived = metrics.packetsReceived - receiveErrors = metrics.receiveErrors - postedCount = metrics.postedCount - postPushCount = metrics.postPushCount - } + init( + id: UUID = UUID(), + timestamp: Date = .now, + nodePublicKey: Data, + batteryMillivolts: UInt16? = nil, + lastSNR: Double? = nil, + lastRSSI: Int16? = nil, + noiseFloor: Int16? = nil, + uptimeSeconds: UInt32? = nil, + rxAirtimeSeconds: UInt32? = nil, + packetsSent: UInt32? = nil, + packetsReceived: UInt32? = nil, + receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil, + neighborSnapshots: [NeighborSnapshotEntry]? = nil, + telemetryEntries: [TelemetrySnapshotEntry]? = nil + ) { + self.id = id + self.timestamp = timestamp + self.nodePublicKey = nodePublicKey + self.batteryMillivolts = batteryMillivolts + self.lastSNR = lastSNR + self.lastRSSI = lastRSSI + self.noiseFloor = noiseFloor + self.uptimeSeconds = uptimeSeconds + self.rxAirtimeSeconds = rxAirtimeSeconds + self.packetsSent = packetsSent + self.packetsReceived = packetsReceived + self.receiveErrors = receiveErrors + self.postedCount = postedCount + self.postPushCount = postPushCount + self.neighborSnapshots = neighborSnapshots + self.telemetryEntries = telemetryEntries + } - /// Builds a model instance directly from a DTO. - convenience init(dto: NodeStatusSnapshotDTO) { - self.init( - id: dto.id, - timestamp: dto.timestamp, - nodePublicKey: dto.nodePublicKey, - batteryMillivolts: dto.batteryMillivolts, - lastSNR: dto.lastSNR, - lastRSSI: dto.lastRSSI, - noiseFloor: dto.noiseFloor, - uptimeSeconds: dto.uptimeSeconds, - rxAirtimeSeconds: dto.rxAirtimeSeconds, - packetsSent: dto.packetsSent, - packetsReceived: dto.packetsReceived, - receiveErrors: dto.receiveErrors, - postedCount: dto.postedCount, - postPushCount: dto.postPushCount, - neighborSnapshots: dto.neighborSnapshots, - telemetryEntries: dto.telemetryEntries - ) - } + /// Apply captured status metrics onto this snapshot, leaving neighbor and + /// telemetry enrichment untouched. + func apply(_ metrics: NodeStatusMetrics) { + batteryMillivolts = metrics.batteryMillivolts + lastSNR = metrics.lastSNR + lastRSSI = metrics.lastRSSI + noiseFloor = metrics.noiseFloor + uptimeSeconds = metrics.uptimeSeconds + rxAirtimeSeconds = metrics.rxAirtimeSeconds + packetsSent = metrics.packetsSent + packetsReceived = metrics.packetsReceived + receiveErrors = metrics.receiveErrors + postedCount = metrics.postedCount + postPushCount = metrics.postPushCount + } + + /// Builds a model instance directly from a DTO. + convenience init(dto: NodeStatusSnapshotDTO) { + self.init( + id: dto.id, + timestamp: dto.timestamp, + nodePublicKey: dto.nodePublicKey, + batteryMillivolts: dto.batteryMillivolts, + lastSNR: dto.lastSNR, + lastRSSI: dto.lastRSSI, + noiseFloor: dto.noiseFloor, + uptimeSeconds: dto.uptimeSeconds, + rxAirtimeSeconds: dto.rxAirtimeSeconds, + packetsSent: dto.packetsSent, + packetsReceived: dto.packetsReceived, + receiveErrors: dto.receiveErrors, + postedCount: dto.postedCount, + postPushCount: dto.postPushCount, + neighborSnapshots: dto.neighborSnapshots, + telemetryEntries: dto.telemetryEntries + ) + } } // MARK: - Sendable DTO public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable, Codable { - public let id: UUID - public let timestamp: Date - public let nodePublicKey: Data - public let batteryMillivolts: UInt16? - public let lastSNR: Double? - public let lastRSSI: Int16? - public let noiseFloor: Int16? - public let uptimeSeconds: UInt32? - public let rxAirtimeSeconds: UInt32? - public let packetsSent: UInt32? - public let packetsReceived: UInt32? - public let receiveErrors: UInt32? - public let postedCount: UInt16? - public let postPushCount: UInt16? - public let neighborSnapshots: [NeighborSnapshotEntry]? - public let telemetryEntries: [TelemetrySnapshotEntry]? + public let id: UUID + public let timestamp: Date + public let nodePublicKey: Data + public let batteryMillivolts: UInt16? + public let lastSNR: Double? + public let lastRSSI: Int16? + public let noiseFloor: Int16? + public let uptimeSeconds: UInt32? + public let rxAirtimeSeconds: UInt32? + public let packetsSent: UInt32? + public let packetsReceived: UInt32? + public let receiveErrors: UInt32? + public let postedCount: UInt16? + public let postPushCount: UInt16? + public let neighborSnapshots: [NeighborSnapshotEntry]? + public let telemetryEntries: [TelemetrySnapshotEntry]? - init(from model: NodeStatusSnapshot) { - self.id = model.id - self.timestamp = model.timestamp - self.nodePublicKey = model.nodePublicKey - self.batteryMillivolts = model.batteryMillivolts - self.lastSNR = model.lastSNR - self.lastRSSI = model.lastRSSI - self.noiseFloor = model.noiseFloor - self.uptimeSeconds = model.uptimeSeconds - self.rxAirtimeSeconds = model.rxAirtimeSeconds - self.packetsSent = model.packetsSent - self.packetsReceived = model.packetsReceived - self.receiveErrors = model.receiveErrors - self.postedCount = model.postedCount - self.postPushCount = model.postPushCount - self.neighborSnapshots = model.neighborSnapshots - self.telemetryEntries = model.telemetryEntries - } + init(from model: NodeStatusSnapshot) { + id = model.id + timestamp = model.timestamp + nodePublicKey = model.nodePublicKey + batteryMillivolts = model.batteryMillivolts + lastSNR = model.lastSNR + lastRSSI = model.lastRSSI + noiseFloor = model.noiseFloor + uptimeSeconds = model.uptimeSeconds + rxAirtimeSeconds = model.rxAirtimeSeconds + packetsSent = model.packetsSent + packetsReceived = model.packetsReceived + receiveErrors = model.receiveErrors + postedCount = model.postedCount + postPushCount = model.postPushCount + neighborSnapshots = model.neighborSnapshots + telemetryEntries = model.telemetryEntries + } - public init( - id: UUID = UUID(), - timestamp: Date = .now, - nodePublicKey: Data, - batteryMillivolts: UInt16? = nil, - lastSNR: Double? = nil, - lastRSSI: Int16? = nil, - noiseFloor: Int16? = nil, - uptimeSeconds: UInt32? = nil, - rxAirtimeSeconds: UInt32? = nil, - packetsSent: UInt32? = nil, - packetsReceived: UInt32? = nil, - receiveErrors: UInt32? = nil, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil, - neighborSnapshots: [NeighborSnapshotEntry]? = nil, - telemetryEntries: [TelemetrySnapshotEntry]? = nil - ) { - self.id = id - self.timestamp = timestamp - self.nodePublicKey = nodePublicKey - self.batteryMillivolts = batteryMillivolts - self.lastSNR = lastSNR - self.lastRSSI = lastRSSI - self.noiseFloor = noiseFloor - self.uptimeSeconds = uptimeSeconds - self.rxAirtimeSeconds = rxAirtimeSeconds - self.packetsSent = packetsSent - self.packetsReceived = packetsReceived - self.receiveErrors = receiveErrors - self.postedCount = postedCount - self.postPushCount = postPushCount - self.neighborSnapshots = neighborSnapshots - self.telemetryEntries = telemetryEntries - } + public init( + id: UUID = UUID(), + timestamp: Date = .now, + nodePublicKey: Data, + batteryMillivolts: UInt16? = nil, + lastSNR: Double? = nil, + lastRSSI: Int16? = nil, + noiseFloor: Int16? = nil, + uptimeSeconds: UInt32? = nil, + rxAirtimeSeconds: UInt32? = nil, + packetsSent: UInt32? = nil, + packetsReceived: UInt32? = nil, + receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil, + neighborSnapshots: [NeighborSnapshotEntry]? = nil, + telemetryEntries: [TelemetrySnapshotEntry]? = nil + ) { + self.id = id + self.timestamp = timestamp + self.nodePublicKey = nodePublicKey + self.batteryMillivolts = batteryMillivolts + self.lastSNR = lastSNR + self.lastRSSI = lastRSSI + self.noiseFloor = noiseFloor + self.uptimeSeconds = uptimeSeconds + self.rxAirtimeSeconds = rxAirtimeSeconds + self.packetsSent = packetsSent + self.packetsReceived = packetsReceived + self.receiveErrors = receiveErrors + self.postedCount = postedCount + self.postPushCount = postPushCount + self.neighborSnapshots = neighborSnapshots + self.telemetryEntries = telemetryEntries + } } diff --git a/MC1Services/Sources/MC1Services/Models/NotificationLevel.swift b/MC1Services/Sources/MC1Services/Models/NotificationLevel.swift index 5f892cca..9e55e90c 100644 --- a/MC1Services/Sources/MC1Services/Models/NotificationLevel.swift +++ b/MC1Services/Sources/MC1Services/Models/NotificationLevel.swift @@ -2,40 +2,40 @@ import Foundation /// Notification level for conversations (channels, rooms, contacts) public enum NotificationLevel: Int, Sendable, Codable, CaseIterable { - case muted = 0 - case mentionsOnly = 1 - case all = 2 + case muted = 0 + case mentionsOnly = 1 + case all = 2 - /// SF Symbol name for this notification level - public var iconName: String { - switch self { - case .muted: "bell.slash" - case .mentionsOnly: "at" - case .all: "bell.fill" - } + /// SF Symbol name for this notification level + public var iconName: String { + switch self { + case .muted: "bell.slash" + case .mentionsOnly: "at" + case .all: "bell.fill" } + } - /// Developer-facing English label for logs; UI uses the app target's localized `NotificationLevel.localizedName`. - public var displayName: String { - switch self { - case .muted: "Muted" - case .mentionsOnly: "Mentions" - case .all: "All" - } + /// Developer-facing English label for logs; UI uses the app target's localized `NotificationLevel.localizedName`. + public var displayName: String { + switch self { + case .muted: "Muted" + case .mentionsOnly: "Mentions" + case .all: "All" } + } - /// Developer-facing English description; UI uses the app target's localized `NotificationLevel.localizedAccessibilityDescription`. - public var accessibilityDescription: String { - switch self { - case .muted: "Muted, no notifications" - case .mentionsOnly: "Mentions only" - case .all: "All notifications" - } + /// Developer-facing English description; UI uses the app target's localized `NotificationLevel.localizedAccessibilityDescription`. + public var accessibilityDescription: String { + switch self { + case .muted: "Muted, no notifications" + case .mentionsOnly: "Mentions only" + case .all: "All notifications" } + } - /// Levels available for channels (which support mention tracking) - public static let channelLevels: [NotificationLevel] = [.muted, .mentionsOnly, .all] + /// Levels available for channels (which support mention tracking) + public static let channelLevels: [NotificationLevel] = [.muted, .mentionsOnly, .all] - /// Levels available for rooms (no mention tracking infrastructure) - public static let roomLevels: [NotificationLevel] = [.muted, .all] + /// Levels available for rooms (no mention tracking infrastructure) + public static let roomLevels: [NotificationLevel] = [.muted, .all] } diff --git a/MC1Services/Sources/MC1Services/Models/NotificationPreferences.swift b/MC1Services/Sources/MC1Services/Models/NotificationPreferences.swift index 114c8508..fd8625ba 100644 --- a/MC1Services/Sources/MC1Services/Models/NotificationPreferences.swift +++ b/MC1Services/Sources/MC1Services/Models/NotificationPreferences.swift @@ -1,144 +1,144 @@ import Foundation /// Thread-safe notification preferences (read-only snapshot from UserDefaults) -struct NotificationPreferences: Sendable { - let contactMessagesEnabled: Bool - let channelMessagesEnabled: Bool - let roomMessagesEnabled: Bool - let newContactDiscoveredEnabled: Bool - let discoveryContactEnabled: Bool - let discoveryRepeaterEnabled: Bool - let discoveryRoomEnabled: Bool - let reactionNotificationsEnabled: Bool - let soundEnabled: Bool - let badgeEnabled: Bool - let lowBatteryEnabled: Bool - - init() { - let defaults = UserDefaults.standard - func enabled(_ key: AppStorageKey) -> Bool { - defaults.object(forKey: key.rawValue) as? Bool ?? AppStorageKey.defaultNotificationEnabled - } - self.contactMessagesEnabled = enabled(.notifyContactMessages) - self.channelMessagesEnabled = enabled(.notifyChannelMessages) - self.roomMessagesEnabled = enabled(.notifyRoomMessages) - self.newContactDiscoveredEnabled = enabled(.notifyNewContacts) - self.discoveryContactEnabled = enabled(.notifyNewContactsContact) - self.discoveryRepeaterEnabled = enabled(.notifyNewContactsRepeater) - self.discoveryRoomEnabled = enabled(.notifyNewContactsRoom) - self.reactionNotificationsEnabled = enabled(.notifyReactions) - self.soundEnabled = enabled(.notificationSoundEnabled) - self.badgeEnabled = enabled(.notificationBadgeEnabled) - self.lowBatteryEnabled = enabled(.notifyLowBattery) +struct NotificationPreferences { + let contactMessagesEnabled: Bool + let channelMessagesEnabled: Bool + let roomMessagesEnabled: Bool + let newContactDiscoveredEnabled: Bool + let discoveryContactEnabled: Bool + let discoveryRepeaterEnabled: Bool + let discoveryRoomEnabled: Bool + let reactionNotificationsEnabled: Bool + let soundEnabled: Bool + let badgeEnabled: Bool + let lowBatteryEnabled: Bool + + init() { + let defaults = UserDefaults.standard + func enabled(_ key: AppStorageKey) -> Bool { + defaults.object(forKey: key.rawValue) as? Bool ?? AppStorageKey.defaultNotificationEnabled } + contactMessagesEnabled = enabled(.notifyContactMessages) + channelMessagesEnabled = enabled(.notifyChannelMessages) + roomMessagesEnabled = enabled(.notifyRoomMessages) + newContactDiscoveredEnabled = enabled(.notifyNewContacts) + discoveryContactEnabled = enabled(.notifyNewContactsContact) + discoveryRepeaterEnabled = enabled(.notifyNewContactsRepeater) + discoveryRoomEnabled = enabled(.notifyNewContactsRoom) + reactionNotificationsEnabled = enabled(.notifyReactions) + soundEnabled = enabled(.notificationSoundEnabled) + badgeEnabled = enabled(.notificationBadgeEnabled) + lowBatteryEnabled = enabled(.notifyLowBattery) + } } /// Observable store for notification preferences (used by Settings UI for two-way binding) @Observable @MainActor public final class NotificationPreferencesStore { - private let defaults = UserDefaults.standard - - /// Observation anchor for the UserDefaults-backed computed properties: - /// `@Observable` only instruments stored properties, so every getter reads - /// this and every setter bumps it, making writes visible to SwiftUI. - private var revision = 0 - - private func isEnabled(_ key: AppStorageKey) -> Bool { - _ = revision - return defaults.object(forKey: key.rawValue) as? Bool ?? AppStorageKey.defaultNotificationEnabled - } - - private func setEnabled(_ newValue: Bool, for key: AppStorageKey) { - defaults.set(newValue, forKey: key.rawValue) - revision += 1 - } - - // MARK: - Message Notifications - - /// Enable notifications for contact (direct) messages - public var contactMessagesEnabled: Bool { - get { isEnabled(.notifyContactMessages) } - set { setEnabled(newValue, for: .notifyContactMessages) } - } - - /// Enable notifications for channel messages - public var channelMessagesEnabled: Bool { - get { isEnabled(.notifyChannelMessages) } - set { setEnabled(newValue, for: .notifyChannelMessages) } - } - - /// Enable notifications for room messages - public var roomMessagesEnabled: Bool { - get { isEnabled(.notifyRoomMessages) } - set { setEnabled(newValue, for: .notifyRoomMessages) } - } - - /// Enable notifications when new contacts are discovered - public var newContactDiscoveredEnabled: Bool { - get { isEnabled(.notifyNewContacts) } - set { - let wasEnabled = isEnabled(.notifyNewContacts) - setEnabled(newValue, for: .notifyNewContacts) - // Only auto-enable children on first activation (keys never written before) - if newValue && !wasEnabled { - let hasExistingChoices = defaults.object(forKey: AppStorageKey.notifyNewContactsContact.rawValue) != nil - || defaults.object(forKey: AppStorageKey.notifyNewContactsRepeater.rawValue) != nil - || defaults.object(forKey: AppStorageKey.notifyNewContactsRoom.rawValue) != nil - if !hasExistingChoices { - discoveryContactEnabled = true - discoveryRepeaterEnabled = true - discoveryRoomEnabled = true - } - } + private let defaults = UserDefaults.standard + + /// Observation anchor for the UserDefaults-backed computed properties: + /// `@Observable` only instruments stored properties, so every getter reads + /// this and every setter bumps it, making writes visible to SwiftUI. + private var revision = 0 + + private func isEnabled(_ key: AppStorageKey) -> Bool { + _ = revision + return defaults.object(forKey: key.rawValue) as? Bool ?? AppStorageKey.defaultNotificationEnabled + } + + private func setEnabled(_ newValue: Bool, for key: AppStorageKey) { + defaults.set(newValue, forKey: key.rawValue) + revision += 1 + } + + // MARK: - Message Notifications + + /// Enable notifications for contact (direct) messages + public var contactMessagesEnabled: Bool { + get { isEnabled(.notifyContactMessages) } + set { setEnabled(newValue, for: .notifyContactMessages) } + } + + /// Enable notifications for channel messages + public var channelMessagesEnabled: Bool { + get { isEnabled(.notifyChannelMessages) } + set { setEnabled(newValue, for: .notifyChannelMessages) } + } + + /// Enable notifications for room messages + public var roomMessagesEnabled: Bool { + get { isEnabled(.notifyRoomMessages) } + set { setEnabled(newValue, for: .notifyRoomMessages) } + } + + /// Enable notifications when new contacts are discovered + public var newContactDiscoveredEnabled: Bool { + get { isEnabled(.notifyNewContacts) } + set { + let wasEnabled = isEnabled(.notifyNewContacts) + setEnabled(newValue, for: .notifyNewContacts) + // Only auto-enable children on first activation (keys never written before) + if newValue, !wasEnabled { + let hasExistingChoices = defaults.object(forKey: AppStorageKey.notifyNewContactsContact.rawValue) != nil + || defaults.object(forKey: AppStorageKey.notifyNewContactsRepeater.rawValue) != nil + || defaults.object(forKey: AppStorageKey.notifyNewContactsRoom.rawValue) != nil + if !hasExistingChoices { + discoveryContactEnabled = true + discoveryRepeaterEnabled = true + discoveryRoomEnabled = true } + } } - - /// Enable discovery notifications for companion (chat) nodes - public var discoveryContactEnabled: Bool { - get { isEnabled(.notifyNewContactsContact) } - set { setEnabled(newValue, for: .notifyNewContactsContact) } - } - - /// Enable discovery notifications for repeater nodes - public var discoveryRepeaterEnabled: Bool { - get { isEnabled(.notifyNewContactsRepeater) } - set { setEnabled(newValue, for: .notifyNewContactsRepeater) } - } - - /// Enable discovery notifications for room nodes - public var discoveryRoomEnabled: Bool { - get { isEnabled(.notifyNewContactsRoom) } - set { setEnabled(newValue, for: .notifyNewContactsRoom) } - } - - /// Enable notifications when someone reacts to your messages - public var reactionNotificationsEnabled: Bool { - get { isEnabled(.notifyReactions) } - set { setEnabled(newValue, for: .notifyReactions) } - } - - // MARK: - Sound & Badge - - /// Enable notification sounds - public var soundEnabled: Bool { - get { isEnabled(.notificationSoundEnabled) } - set { setEnabled(newValue, for: .notificationSoundEnabled) } - } - - /// Enable badge count on app icon - public var badgeEnabled: Bool { - get { isEnabled(.notificationBadgeEnabled) } - set { setEnabled(newValue, for: .notificationBadgeEnabled) } - } - - // MARK: - Low Battery - - /// Enable low battery warning notifications - public var lowBatteryEnabled: Bool { - get { isEnabled(.notifyLowBattery) } - set { setEnabled(newValue, for: .notifyLowBattery) } - } - - public init() {} + } + + /// Enable discovery notifications for companion (chat) nodes + public var discoveryContactEnabled: Bool { + get { isEnabled(.notifyNewContactsContact) } + set { setEnabled(newValue, for: .notifyNewContactsContact) } + } + + /// Enable discovery notifications for repeater nodes + public var discoveryRepeaterEnabled: Bool { + get { isEnabled(.notifyNewContactsRepeater) } + set { setEnabled(newValue, for: .notifyNewContactsRepeater) } + } + + /// Enable discovery notifications for room nodes + public var discoveryRoomEnabled: Bool { + get { isEnabled(.notifyNewContactsRoom) } + set { setEnabled(newValue, for: .notifyNewContactsRoom) } + } + + /// Enable notifications when someone reacts to your messages + public var reactionNotificationsEnabled: Bool { + get { isEnabled(.notifyReactions) } + set { setEnabled(newValue, for: .notifyReactions) } + } + + // MARK: - Sound & Badge + + /// Enable notification sounds + public var soundEnabled: Bool { + get { isEnabled(.notificationSoundEnabled) } + set { setEnabled(newValue, for: .notificationSoundEnabled) } + } + + /// Enable badge count on app icon + public var badgeEnabled: Bool { + get { isEnabled(.notificationBadgeEnabled) } + set { setEnabled(newValue, for: .notificationBadgeEnabled) } + } + + // MARK: - Low Battery + + /// Enable low battery warning notifications + public var lowBatteryEnabled: Bool { + get { isEnabled(.notifyLowBattery) } + set { setEnabled(newValue, for: .notifyLowBattery) } + } + + public init() {} } diff --git a/MC1Services/Sources/MC1Services/Models/OCVPreset.swift b/MC1Services/Sources/MC1Services/Models/OCVPreset.swift index c17278c9..6a8d1566 100644 --- a/MC1Services/Sources/MC1Services/Models/OCVPreset.swift +++ b/MC1Services/Sources/MC1Services/Models/OCVPreset.swift @@ -1,11 +1,11 @@ import Foundation /// Categories for OCV presets -enum OCVPresetCategory: Sendable { - /// Generic battery chemistry (Li-Ion, LiFePO4, etc.) - case batteryChemistry - /// Specific commercial device - case deviceSpecific +enum OCVPresetCategory { + /// Generic battery chemistry (Li-Ion, LiFePO4, etc.) + case batteryChemistry + /// Specific commercial device + case deviceSpecific } /// Battery OCV (Open Circuit Voltage) presets for accurate percentage calculation. @@ -13,137 +13,137 @@ enum OCVPresetCategory: Sendable { /// /// Reference: https://github.com/meshtastic/firmware public enum OCVPreset: String, CaseIterable, Codable, Sendable { - case liIon - case liFePO4 - case leadAcid - case alkaline - case niMH - case lto - case trackerT1000E - case heltecPocket5000 - case heltecPocket10000 - case seeedWioTracker - case seeedSolarNode - case r1Neo - case wisMeshTag - case lilyGoTBeam1W - case thinkNodeM6 - case custom + case liIon + case liFePO4 + case leadAcid + case alkaline + case niMH + case lto + case trackerT1000E + case heltecPocket5000 + case heltecPocket10000 + case seeedWioTracker + case seeedSolarNode + case r1Neo + case wisMeshTag + case lilyGoTBeam1W + case thinkNodeM6 + case custom - /// Valid range for user-entered OCV voltage values (millivolts). - /// Upper bound accommodates multi-cell series packs - public static let validMillivoltRange: ClosedRange = 1000...99_999 + /// Valid range for user-entered OCV voltage values (millivolts). + /// Upper bound accommodates multi-cell series packs + public static let validMillivoltRange: ClosedRange = 1000...99999 - /// The 11-point OCV array in millivolts (100% to 0% in 10% steps) - public var ocvArray: [Int] { - switch self { - case .liIon: - [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] - case .liFePO4: - [3400, 3350, 3320, 3290, 3270, 3260, 3250, 3230, 3200, 3120, 3000] - case .leadAcid: - [2120, 2090, 2070, 2050, 2030, 2010, 1990, 1980, 1970, 1960, 1950] - case .alkaline: - [1580, 1400, 1350, 1300, 1280, 1250, 1230, 1190, 1150, 1100, 1000] - case .niMH: - [1400, 1300, 1280, 1270, 1260, 1250, 1240, 1230, 1210, 1150, 1000] - case .lto: - [2770, 2650, 2540, 2420, 2300, 2180, 2060, 1940, 1800, 1680, 1550] - case .trackerT1000E: - [4190, 4042, 3957, 3885, 3820, 3776, 3746, 3725, 3696, 3644, 3100] - case .heltecPocket5000: - [4300, 4240, 4120, 4000, 3888, 3800, 3740, 3698, 3655, 3580, 3400] - case .heltecPocket10000: - [4100, 4060, 3960, 3840, 3729, 3625, 3550, 3500, 3420, 3345, 3100] - case .seeedWioTracker: - [4200, 3876, 3826, 3763, 3713, 3660, 3573, 3485, 3422, 3359, 3300] - case .seeedSolarNode: - [4200, 3986, 3922, 3812, 3734, 3645, 3527, 3420, 3281, 3087, 2786] - case .r1Neo: - [4120, 4020, 4000, 3940, 3870, 3820, 3750, 3630, 3550, 3450, 3100] - case .wisMeshTag: - [4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990] - case .lilyGoTBeam1W: - [7950, 7850, 7750, 7580, 7440, 7310, 7150, 7005, 6860, 6685, 6000] - case .thinkNodeM6: - [4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3450] - case .custom: - OCVPreset.liIon.ocvArray // Fallback, actual custom values stored separately - } + /// The 11-point OCV array in millivolts (100% to 0% in 10% steps) + public var ocvArray: [Int] { + switch self { + case .liIon: + [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] + case .liFePO4: + [3400, 3350, 3320, 3290, 3270, 3260, 3250, 3230, 3200, 3120, 3000] + case .leadAcid: + [2120, 2090, 2070, 2050, 2030, 2010, 1990, 1980, 1970, 1960, 1950] + case .alkaline: + [1580, 1400, 1350, 1300, 1280, 1250, 1230, 1190, 1150, 1100, 1000] + case .niMH: + [1400, 1300, 1280, 1270, 1260, 1250, 1240, 1230, 1210, 1150, 1000] + case .lto: + [2770, 2650, 2540, 2420, 2300, 2180, 2060, 1940, 1800, 1680, 1550] + case .trackerT1000E: + [4190, 4042, 3957, 3885, 3820, 3776, 3746, 3725, 3696, 3644, 3100] + case .heltecPocket5000: + [4300, 4240, 4120, 4000, 3888, 3800, 3740, 3698, 3655, 3580, 3400] + case .heltecPocket10000: + [4100, 4060, 3960, 3840, 3729, 3625, 3550, 3500, 3420, 3345, 3100] + case .seeedWioTracker: + [4200, 3876, 3826, 3763, 3713, 3660, 3573, 3485, 3422, 3359, 3300] + case .seeedSolarNode: + [4200, 3986, 3922, 3812, 3734, 3645, 3527, 3420, 3281, 3087, 2786] + case .r1Neo: + [4120, 4020, 4000, 3940, 3870, 3820, 3750, 3630, 3550, 3450, 3100] + case .wisMeshTag: + [4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990] + case .lilyGoTBeam1W: + [7950, 7850, 7750, 7580, 7440, 7310, 7150, 7005, 6860, 6685, 6000] + case .thinkNodeM6: + [4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3450] + case .custom: + OCVPreset.liIon.ocvArray // Fallback, actual custom values stored separately } + } - /// Human-readable display name - public var displayName: String { - switch self { - case .liIon: "Li-Ion (Default)" - case .liFePO4: "LiFePO4" - case .leadAcid: "Lead Acid" - case .alkaline: "Alkaline" - case .niMH: "NiMH" - case .lto: "LTO" - case .trackerT1000E: "Tracker T1000-E" - case .heltecPocket5000: "Heltec Pocket 5000" - case .heltecPocket10000: "Heltec Pocket 10000" - case .seeedWioTracker: "Seeed WIO Tracker" - case .seeedSolarNode: "Seeed Solar Node" - case .r1Neo: "R1 Neo" - case .wisMeshTag: "WisMesh Tag" - case .lilyGoTBeam1W: "LilyGo T-Beam 1W" - case .thinkNodeM6: "ThinkNode M6" - case .custom: "Custom" - } + /// Human-readable display name + public var displayName: String { + switch self { + case .liIon: "Li-Ion (Default)" + case .liFePO4: "LiFePO4" + case .leadAcid: "Lead Acid" + case .alkaline: "Alkaline" + case .niMH: "NiMH" + case .lto: "LTO" + case .trackerT1000E: "Tracker T1000-E" + case .heltecPocket5000: "Heltec Pocket 5000" + case .heltecPocket10000: "Heltec Pocket 10000" + case .seeedWioTracker: "Seeed WIO Tracker" + case .seeedSolarNode: "Seeed Solar Node" + case .r1Neo: "R1 Neo" + case .wisMeshTag: "WisMesh Tag" + case .lilyGoTBeam1W: "LilyGo T-Beam 1W" + case .thinkNodeM6: "ThinkNode M6" + case .custom: "Custom" } + } - /// The category of this preset - var category: OCVPresetCategory { - switch self { - case .liIon, .liFePO4, .leadAcid, .alkaline, .niMH, .lto: - .batteryChemistry - case .trackerT1000E, .heltecPocket5000, .heltecPocket10000, - .seeedWioTracker, .seeedSolarNode, .r1Neo, .wisMeshTag, - .lilyGoTBeam1W, .thinkNodeM6, .custom: - .deviceSpecific - } + /// The category of this preset + var category: OCVPresetCategory { + switch self { + case .liIon, .liFePO4, .leadAcid, .alkaline, .niMH, .lto: + .batteryChemistry + case .trackerT1000E, .heltecPocket5000, .heltecPocket10000, + .seeedWioTracker, .seeedSolarNode, .r1Neo, .wisMeshTag, + .lilyGoTBeam1W, .thinkNodeM6, .custom: + .deviceSpecific } + } - /// All presets except custom (for picker display) - public static var selectablePresets: [OCVPreset] { - allCases.filter { $0 != .custom } - } + /// All presets except custom (for picker display) + public static var selectablePresets: [OCVPreset] { + allCases.filter { $0 != .custom } + } - /// Battery chemistry presets only (excludes device-specific and custom) - public static var batteryChemistryPresets: [OCVPreset] { - allCases.filter { $0.category == .batteryChemistry } - } + /// Battery chemistry presets only (excludes device-specific and custom) + public static var batteryChemistryPresets: [OCVPreset] { + allCases.filter { $0.category == .batteryChemistry } + } - /// Presets available for remote node configuration. - /// Includes battery chemistry types plus select device-specific presets. - public static var nodePresets: [OCVPreset] { - var presets = batteryChemistryPresets - presets.append(.seeedSolarNode) - return presets - } + /// Presets available for remote node configuration. + /// Includes battery chemistry types plus select device-specific presets. + public static var nodePresets: [OCVPreset] { + var presets = batteryChemistryPresets + presets.append(.seeedSolarNode) + return presets + } - private static let logger = PersistentLogger(subsystem: "com.mc1", category: "OCVPreset") + private static let logger = PersistentLogger(subsystem: "com.mc1", category: "OCVPreset") - /// Returns the OCV preset for a known manufacturer name, or nil if no match. - /// - /// Manufacturer names must exactly match the strings returned by `getManufacturerName()` - /// in the MeshCore firmware's device variant headers (`{device_variant}.h`). - /// See: https://github.com/meshcore-dev/MeshCore - public static func preset(forManufacturer name: String) -> OCVPreset? { - let preset: OCVPreset? = switch name { - case "Seeed Tracker T1000-e", "Seeed Tracker T1000-E": .trackerT1000E - case "Seeed Wio Tracker L1": .seeedWioTracker - case "Seeed SenseCap Solar": .seeedSolarNode - case "RAK WisMesh Tag": .wisMeshTag - case "LilyGo T-Beam 1W": .lilyGoTBeam1W - case "Elecrow ThinkNode M6": .thinkNodeM6 - default: nil - } - if preset == nil && !name.isEmpty { - logger.debug("No OCV preset for manufacturer: \(name)") - } - return preset + /// Returns the OCV preset for a known manufacturer name, or nil if no match. + /// + /// Manufacturer names must exactly match the strings returned by `getManufacturerName()` + /// in the MeshCore firmware's device variant headers (`{device_variant}.h`). + /// See: https://github.com/meshcore-dev/MeshCore + public static func preset(forManufacturer name: String) -> OCVPreset? { + let preset: OCVPreset? = switch name { + case "Seeed Tracker T1000-e", "Seeed Tracker T1000-E": .trackerT1000E + case "Seeed Wio Tracker L1": .seeedWioTracker + case "Seeed SenseCap Solar": .seeedSolarNode + case "RAK WisMesh Tag": .wisMeshTag + case "LilyGo T-Beam 1W": .lilyGoTBeam1W + case "Elecrow ThinkNode M6": .thinkNodeM6 + default: nil + } + if preset == nil, !name.isEmpty { + logger.debug("No OCV preset for manufacturer: \(name)") } + return preset + } } diff --git a/MC1Services/Sources/MC1Services/Models/PendingSend.swift b/MC1Services/Sources/MC1Services/Models/PendingSend.swift index f7bc3b03..26836068 100644 --- a/MC1Services/Sources/MC1Services/Models/PendingSend.swift +++ b/MC1Services/Sources/MC1Services/Models/PendingSend.swift @@ -25,97 +25,97 @@ import SwiftData /// future maintainers don't re-evaluate it from scratch. @Model public final class PendingSend { - /// Compound indexes targeting the two hot fetch paths: - /// - `(radioID, sequence)` powers `fetchPendingSends(radioID:)`'s - /// ordered scan during hydrate-on-configure. - /// - `messageID` alone powers `deletePendingSendsForMessage(...)`, - /// which runs on every successful send and every non-cancellation - /// error — i.e., the highest-frequency read path on this table. - /// Radio is intentionally not part of this index because the - /// drain-time delete must succeed regardless of which radio is - /// currently in scope. - /// The uniqueness on `id` already produces an implicit index for the - /// `deletePendingSend(id:)` path, so no third compound is needed. - #Index([\.radioID, \.sequence], [\.messageID]) + // Compound indexes targeting the two hot fetch paths: + // - `(radioID, sequence)` powers `fetchPendingSends(radioID:)`'s + // ordered scan during hydrate-on-configure. + // - `messageID` alone powers `deletePendingSendsForMessage(...)`, + // which runs on every successful send and every non-cancellation + // error — i.e., the highest-frequency read path on this table. + // Radio is intentionally not part of this index because the + // drain-time delete must succeed regardless of which radio is + // currently in scope. + // The uniqueness on `id` already produces an implicit index for the + // `deletePendingSend(id:)` path, so no third compound is needed. + #Index([\.radioID, \.sequence], [\.messageID]) - @Attribute(.unique) - public var id: UUID + @Attribute(.unique) + public var id: UUID - public var radioID: UUID - public var messageID: UUID + public var radioID: UUID + public var messageID: UUID - /// Discriminator: 0 = DM, 1 = channel. See `PendingSendKind`. - public var kindRawValue: Int + /// Discriminator: 0 = DM, 1 = channel. See `PendingSendKind`. + public var kindRawValue: Int - public var contactID: UUID? - public var channelIndex: UInt8? - public var isResend: Bool + public var contactID: UUID? + public var channelIndex: UInt8? + public var isResend: Bool - public var messageText: String - public var messageTimestamp: UInt32 - public var localNodeName: String? + public var messageText: String + public var messageTimestamp: UInt32 + public var localNodeName: String? - public var sequence: Int - public var enqueuedAt: Date + public var sequence: Int + public var enqueuedAt: Date - /// Number of drain attempts the send queue has progressed past the - /// `hasPendingSend` gate for this row. Bumped at the top of each drain - /// attempt, before any wire-affecting work. Three distinguishable states: - /// - /// - `nil` — pre-migration row, lightweight-migrated from a schema - /// version without this column. Drain history is ambiguous, - /// so `purgeLegacyAttemptCountRows` in - /// `PersistenceStore.warmUp()` deletes these on connect. - /// - `0` — row that has been persisted but has not yet progressed - /// past the top-of-drain bump (either fresh enqueue in - /// flight, or process death between persist and bump). - /// The recipient cannot have seen this packet, so the - /// next drain stamps a fresh wire timestamp - /// (`preserveTimestamp = false`). - /// - positive — at least one drain attempt has run. A wire send may - /// already have happened, so auto-retries must preserve the - /// original wire timestamp via `postBumpCount > 1`. - /// - /// PendingSendDTO is `Sendable, Hashable, Identifiable` only — intentionally - /// not Codable, intentionally excluded from AppBackupEnvelope — so there - /// is no on-disk wire format to defend against. The only "legacy" data is - /// live SwiftData rows persisted by a build that predates this field, - /// which lightweight migration maps to `nil` and warmUp purges on connect. - public var attemptCount: Int? + /// Number of drain attempts the send queue has progressed past the + /// `hasPendingSend` gate for this row. Bumped at the top of each drain + /// attempt, before any wire-affecting work. Three distinguishable states: + /// + /// - `nil` — pre-migration row, lightweight-migrated from a schema + /// version without this column. Drain history is ambiguous, + /// so `purgeLegacyAttemptCountRows` in + /// `PersistenceStore.warmUp()` deletes these on connect. + /// - `0` — row that has been persisted but has not yet progressed + /// past the top-of-drain bump (either fresh enqueue in + /// flight, or process death between persist and bump). + /// The recipient cannot have seen this packet, so the + /// next drain stamps a fresh wire timestamp + /// (`preserveTimestamp = false`). + /// - positive — at least one drain attempt has run. A wire send may + /// already have happened, so auto-retries must preserve the + /// original wire timestamp via `postBumpCount > 1`. + /// + /// PendingSendDTO is `Sendable, Hashable, Identifiable` only — intentionally + /// not Codable, intentionally excluded from AppBackupEnvelope — so there + /// is no on-disk wire format to defend against. The only "legacy" data is + /// live SwiftData rows persisted by a build that predates this field, + /// which lightweight migration maps to `nil` and warmUp purges on connect. + public var attemptCount: Int? - public init( - id: UUID, - radioID: UUID, - messageID: UUID, - kindRawValue: Int, - contactID: UUID?, - channelIndex: UInt8?, - isResend: Bool, - messageText: String, - messageTimestamp: UInt32, - localNodeName: String?, - sequence: Int, - enqueuedAt: Date, - attemptCount: Int? = nil - ) { - self.id = id - self.radioID = radioID - self.messageID = messageID - self.kindRawValue = kindRawValue - self.contactID = contactID - self.channelIndex = channelIndex - self.isResend = isResend - self.messageText = messageText - self.messageTimestamp = messageTimestamp - self.localNodeName = localNodeName - self.sequence = sequence - self.enqueuedAt = enqueuedAt - self.attemptCount = attemptCount - } + public init( + id: UUID, + radioID: UUID, + messageID: UUID, + kindRawValue: Int, + contactID: UUID?, + channelIndex: UInt8?, + isResend: Bool, + messageText: String, + messageTimestamp: UInt32, + localNodeName: String?, + sequence: Int, + enqueuedAt: Date, + attemptCount: Int? = nil + ) { + self.id = id + self.radioID = radioID + self.messageID = messageID + self.kindRawValue = kindRawValue + self.contactID = contactID + self.channelIndex = channelIndex + self.isResend = isResend + self.messageText = messageText + self.messageTimestamp = messageTimestamp + self.localNodeName = localNodeName + self.sequence = sequence + self.enqueuedAt = enqueuedAt + self.attemptCount = attemptCount + } - var kind: PendingSendKind { - PendingSendKind(rawValue: kindRawValue) ?? .dm - } + var kind: PendingSendKind { + PendingSendKind(rawValue: kindRawValue) ?? .dm + } } /// Discriminator for which `SendQueue` a row drains into. @@ -124,9 +124,9 @@ public final class PendingSend { /// even though `PendingSendDTO` is excluded from `AppBackupEnvelope`, the /// discriminator IS persisted via `kindRawValue` and a future case rename /// must not silently change the on-disk format. -enum PendingSendKind: Int, Sendable { - case dm = 0 - case channel = 1 +enum PendingSendKind: Int { + case dm = 0 + case channel = 1 } /// Sendable DTO mirror of `PendingSend`. Raw `@Model` instances never leave @@ -138,89 +138,89 @@ enum PendingSendKind: Int, Sendable { /// delivered on the radio, so the DTO does not flow through backup- /// envelope encoding paths. public struct PendingSendDTO: Sendable, Hashable, Identifiable { - public let id: UUID - public let radioID: UUID - public let messageID: UUID - let kind: PendingSendKind - public let contactID: UUID? - public let channelIndex: UInt8? - public let isResend: Bool - public let messageText: String - public let messageTimestamp: UInt32 - public let localNodeName: String? - public let sequence: Int - public let enqueuedAt: Date - /// See `PendingSend.attemptCount` for the three-state semantics. The DTO - /// memberwise init defaults this to `0` (current-build sentinel) so - /// new envelopes enter disk distinguishable from pre-migration rows that - /// lightweight-migrate to `nil`. - public let attemptCount: Int? + public let id: UUID + public let radioID: UUID + public let messageID: UUID + let kind: PendingSendKind + public let contactID: UUID? + public let channelIndex: UInt8? + public let isResend: Bool + public let messageText: String + public let messageTimestamp: UInt32 + public let localNodeName: String? + public let sequence: Int + public let enqueuedAt: Date + /// See `PendingSend.attemptCount` for the three-state semantics. The DTO + /// memberwise init defaults this to `0` (current-build sentinel) so + /// new envelopes enter disk distinguishable from pre-migration rows that + /// lightweight-migrate to `nil`. + public let attemptCount: Int? - init( - id: UUID, - radioID: UUID, - messageID: UUID, - kind: PendingSendKind, - contactID: UUID?, - channelIndex: UInt8?, - isResend: Bool, - messageText: String, - messageTimestamp: UInt32, - localNodeName: String?, - sequence: Int, - enqueuedAt: Date, - attemptCount: Int? = 0 - ) { - self.id = id - self.radioID = radioID - self.messageID = messageID - self.kind = kind - self.contactID = contactID - self.channelIndex = channelIndex - self.isResend = isResend - self.messageText = messageText - self.messageTimestamp = messageTimestamp - self.localNodeName = localNodeName - self.sequence = sequence - self.enqueuedAt = enqueuedAt - self.attemptCount = attemptCount - } + init( + id: UUID, + radioID: UUID, + messageID: UUID, + kind: PendingSendKind, + contactID: UUID?, + channelIndex: UInt8?, + isResend: Bool, + messageText: String, + messageTimestamp: UInt32, + localNodeName: String?, + sequence: Int, + enqueuedAt: Date, + attemptCount: Int? = 0 + ) { + self.id = id + self.radioID = radioID + self.messageID = messageID + self.kind = kind + self.contactID = contactID + self.channelIndex = channelIndex + self.isResend = isResend + self.messageText = messageText + self.messageTimestamp = messageTimestamp + self.localNodeName = localNodeName + self.sequence = sequence + self.enqueuedAt = enqueuedAt + self.attemptCount = attemptCount + } } public extension PendingSend { - convenience init(dto: PendingSendDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - messageID: dto.messageID, - kindRawValue: dto.kind.rawValue, - contactID: dto.contactID, - channelIndex: dto.channelIndex, - isResend: dto.isResend, - messageText: dto.messageText, - messageTimestamp: dto.messageTimestamp, - localNodeName: dto.localNodeName, - sequence: dto.sequence, - enqueuedAt: dto.enqueuedAt, - attemptCount: dto.attemptCount - ) - } + convenience init(dto: PendingSendDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + messageID: dto.messageID, + kindRawValue: dto.kind.rawValue, + contactID: dto.contactID, + channelIndex: dto.channelIndex, + isResend: dto.isResend, + messageText: dto.messageText, + messageTimestamp: dto.messageTimestamp, + localNodeName: dto.localNodeName, + sequence: dto.sequence, + enqueuedAt: dto.enqueuedAt, + attemptCount: dto.attemptCount + ) + } - func toDTO() -> PendingSendDTO { - PendingSendDTO( - id: id, - radioID: radioID, - messageID: messageID, - kind: kind, - contactID: contactID, - channelIndex: channelIndex, - isResend: isResend, - messageText: messageText, - messageTimestamp: messageTimestamp, - localNodeName: localNodeName, - sequence: sequence, - enqueuedAt: enqueuedAt, - attemptCount: attemptCount - ) - } + func toDTO() -> PendingSendDTO { + PendingSendDTO( + id: id, + radioID: radioID, + messageID: messageID, + kind: kind, + contactID: contactID, + channelIndex: channelIndex, + isResend: isResend, + messageText: messageText, + messageTimestamp: messageTimestamp, + localNodeName: localNodeName, + sequence: sequence, + enqueuedAt: enqueuedAt, + attemptCount: attemptCount + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/PendingSendEnvelope.swift b/MC1Services/Sources/MC1Services/Models/PendingSendEnvelope.swift index b9378d8c..c9035cee 100644 --- a/MC1Services/Sources/MC1Services/Models/PendingSendEnvelope.swift +++ b/MC1Services/Sources/MC1Services/Models/PendingSendEnvelope.swift @@ -5,23 +5,22 @@ import Foundation /// not match the requested envelope type — guards against mis-routing a /// channel row into the DM queue or vice versa. public extension PendingSendDTO { + func directMessageEnvelope() -> DirectMessageEnvelope? { + guard kind == .dm, let contactID else { return nil } + return DirectMessageEnvelope(messageID: messageID, contactID: contactID, isResend: isResend) + } - func directMessageEnvelope() -> DirectMessageEnvelope? { - guard kind == .dm, let contactID else { return nil } - return DirectMessageEnvelope(messageID: messageID, contactID: contactID, isResend: isResend) - } - - func channelMessageEnvelope() -> ChannelMessageEnvelope? { - guard kind == .channel, let channelIndex else { return nil } - return ChannelMessageEnvelope( - messageID: messageID, - channelIndex: channelIndex, - isResend: isResend, - messageText: messageText, - messageTimestamp: messageTimestamp, - localNodeName: localNodeName - ) - } + func channelMessageEnvelope() -> ChannelMessageEnvelope? { + guard kind == .channel, let channelIndex else { return nil } + return ChannelMessageEnvelope( + messageID: messageID, + channelIndex: channelIndex, + isResend: isResend, + messageText: messageText, + messageTimestamp: messageTimestamp, + localNodeName: localNodeName + ) + } } /// Construct a `PendingSendDTO` from a runtime envelope. Used by the @@ -34,65 +33,64 @@ public extension PendingSendDTO { /// to pin sequence values should call the full positional /// `PendingSendDTO.init(...)` directly. public extension PendingSendDTO { + init( + id: UUID = UUID(), + envelope: DirectMessageEnvelope, + radioID: UUID, + enqueuedAt: Date = Date() + ) { + // DM rows persist `isResend` so a process restart between enqueue + // and drain routes the row to the same send method it would have + // hit pre-restart (sendPendingDirectMessage vs. resendDirectMessage). + // `messageTimestamp` stays sentinel: the DM drain keys preserve- + // timestamp behaviour off `PendingSend.attemptCount` via the + // top-of-drain bump (`postBumpCount > 1` returns true on the 2nd+ + // drain attempt), so the wire timestamp does not round-trip + // through the DTO. Channel rows persist both explicitly because + // reaction indexing hashes off the post-send wire timestamp. + self.init( + id: id, + radioID: radioID, + messageID: envelope.messageID, + kind: .dm, + contactID: envelope.contactID, + channelIndex: nil, + isResend: envelope.isResend, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: 0, + enqueuedAt: enqueuedAt + ) + } - init( - id: UUID = UUID(), - envelope: DirectMessageEnvelope, - radioID: UUID, - enqueuedAt: Date = Date() - ) { - // DM rows persist `isResend` so a process restart between enqueue - // and drain routes the row to the same send method it would have - // hit pre-restart (sendPendingDirectMessage vs. resendDirectMessage). - // `messageTimestamp` stays sentinel: the DM drain keys preserve- - // timestamp behaviour off `PendingSend.attemptCount` via the - // top-of-drain bump (`postBumpCount > 1` returns true on the 2nd+ - // drain attempt), so the wire timestamp does not round-trip - // through the DTO. Channel rows persist both explicitly because - // reaction indexing hashes off the post-send wire timestamp. - self.init( - id: id, - radioID: radioID, - messageID: envelope.messageID, - kind: .dm, - contactID: envelope.contactID, - channelIndex: nil, - isResend: envelope.isResend, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: 0, - enqueuedAt: enqueuedAt - ) - } - - init( - id: UUID = UUID(), - envelope: ChannelMessageEnvelope, - radioID: UUID, - enqueuedAt: Date = Date() - ) { - // ChannelMessageEnvelope DTO factory captures `envelope.messageTimestamp` - // verbatim. For isResend=true rows the stored timestamp is overwritten - // before use — `resendChannelMessage(preserveTimestamp: postBumpCount > 1)` - // stamps a fresh wire timestamp when this is the first drain attempt - // (`postBumpCount == 1`), and reaction indexing keys off the - // post-resend value, not the persisted one. Kept non-zero on resend - // rows for symmetry with original-send rows; readers must not depend - // on the value for retry-path semantics. - self.init( - id: id, - radioID: radioID, - messageID: envelope.messageID, - kind: .channel, - contactID: nil, - channelIndex: envelope.channelIndex, - isResend: envelope.isResend, - messageText: envelope.messageText, - messageTimestamp: envelope.messageTimestamp, - localNodeName: envelope.localNodeName, - sequence: 0, - enqueuedAt: enqueuedAt - ) - } + init( + id: UUID = UUID(), + envelope: ChannelMessageEnvelope, + radioID: UUID, + enqueuedAt: Date = Date() + ) { + // ChannelMessageEnvelope DTO factory captures `envelope.messageTimestamp` + // verbatim. For isResend=true rows the stored timestamp is overwritten + // before use — `resendChannelMessage(preserveTimestamp: postBumpCount > 1)` + // stamps a fresh wire timestamp when this is the first drain attempt + // (`postBumpCount == 1`), and reaction indexing keys off the + // post-resend value, not the persisted one. Kept non-zero on resend + // rows for symmetry with original-send rows; readers must not depend + // on the value for retry-path semantics. + self.init( + id: id, + radioID: radioID, + messageID: envelope.messageID, + kind: .channel, + contactID: nil, + channelIndex: envelope.channelIndex, + isResend: envelope.isResend, + messageText: envelope.messageText, + messageTimestamp: envelope.messageTimestamp, + localNodeName: envelope.localNodeName, + sequence: 0, + enqueuedAt: enqueuedAt + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/ProtocolLimits.swift b/MC1Services/Sources/MC1Services/Models/ProtocolLimits.swift index 46164c90..a1855769 100644 --- a/MC1Services/Sources/MC1Services/Models/ProtocolLimits.swift +++ b/MC1Services/Sources/MC1Services/Models/ProtocolLimits.swift @@ -2,41 +2,41 @@ import Foundation /// Protocol size limits and constants public enum ProtocolLimits { - public static let publicKeySize = 32 - public static let advertTimestampSize = 4 - public static let privateKeySize = 64 - public static let maxPathSize = 64 - public static let maxFrameSize = 172 - public static let signatureSize = 64 - public static let maxPacketPayload = 184 - public static let cipherMacSize = 2 - public static let maxHashSize = 8 - public static let cipherKeySize = 16 - public static let maxContacts = 100 - public static let offlineQueueSize = 16 - public static let maxNameLength = 32 - public static let channelSecretSize = 16 - public static let maxMessageLength = 160 + public static let publicKeySize = 32 + public static let advertTimestampSize = 4 + public static let privateKeySize = 64 + public static let maxPathSize = 64 + public static let maxFrameSize = 172 + public static let signatureSize = 64 + public static let maxPacketPayload = 184 + public static let cipherMacSize = 2 + public static let maxHashSize = 8 + public static let cipherKeySize = 16 + public static let maxContacts = 100 + public static let offlineQueueSize = 16 + public static let maxNameLength = 32 + public static let channelSecretSize = 16 + public static let maxMessageLength = 160 - /// Maximum usable bytes for names (firmware char[32] minus null terminator) - public static let maxUsableNameBytes = 31 + /// Maximum usable bytes for names (firmware char[32] minus null terminator) + public static let maxUsableNameBytes = 31 - /// Maximum UTF-8 bytes for the default flood scope name field. The firmware field is 31 - /// bytes with zero padding and accepts `0 < strlen(name) < 31`, so the effective cap is - /// 30. Longer inputs must be truncated before send; otherwise the stored display and the - /// full-name-derived scope key would disagree. - public static let maxDefaultFloodScopeNameBytes = 30 + /// Maximum UTF-8 bytes for the default flood scope name field. The firmware field is 31 + /// bytes with zero padding and accepts `0 < strlen(name) < 31`, so the effective cap is + /// 30. Longer inputs must be truncated before send; otherwise the stored display and the + /// full-name-derived scope key would disagree. + public static let maxDefaultFloodScopeNameBytes = 30 - /// Maximum UTF-8 bytes for direct message text. App-enforced at 150, which - /// sits under the firmware's binding `MAX_TEXT_LEN` of 160 so the message - /// always fits the wire buffer. - public static let maxDirectMessageLength = 150 + /// Maximum UTF-8 bytes for direct message text. App-enforced at 150, which + /// sits under the firmware's binding `MAX_TEXT_LEN` of 160 so the message + /// always fits the wire buffer. + public static let maxDirectMessageLength = 150 - /// Total limit for channel messages including "NodeName: " prefix - public static let maxChannelMessageTotalLength = 147 + /// Total limit for channel messages including "NodeName: " prefix + public static let maxChannelMessageTotalLength = 147 - /// Max user text bytes for channel messages, accounting for node name prefix - public static func maxChannelMessageLength(nodeNameByteCount: Int) -> Int { - max(0, maxChannelMessageTotalLength - nodeNameByteCount - 2) - } + /// Max user text bytes for channel messages, accounting for node name prefix + public static func maxChannelMessageLength(nodeNameByteCount: Int) -> Int { + max(0, maxChannelMessageTotalLength - nodeNameByteCount - 2) + } } diff --git a/MC1Services/Sources/MC1Services/Models/ProtocolTypes.swift b/MC1Services/Sources/MC1Services/Models/ProtocolTypes.swift index 75d42489..085e2957 100644 --- a/MC1Services/Sources/MC1Services/Models/ProtocolTypes.swift +++ b/MC1Services/Sources/MC1Services/Models/ProtocolTypes.swift @@ -1,10 +1,10 @@ -/// Semantic protocol enums that add iOS-specific value over MeshCore -/// (TextType, RemoteNodeRole, RoomPermissionLevel) plus ContactType re-exports. -/// -/// Types that are direct duplicates of MeshCore types have been removed. -/// Use MeshCore types directly: SelfInfo, DeviceCapabilities, ChannelInfo, -/// ContactMessage, ChannelMessage, MessageSentInfo, BatteryInfo, -/// ContactType, ContactFlags. +// Semantic protocol enums that add iOS-specific value over MeshCore +// (TextType, RemoteNodeRole, RoomPermissionLevel) plus ContactType re-exports. +// +// Types that are direct duplicates of MeshCore types have been removed. +// Use MeshCore types directly: SelfInfo, DeviceCapabilities, ChannelInfo, +// ContactMessage, ChannelMessage, MessageSentInfo, BatteryInfo, +// ContactType, ContactFlags. import Foundation import MeshCore @@ -18,62 +18,67 @@ typealias ContactFlags = MeshCore.ContactFlags // MARK: - Contact Type UI Extensions -extension ContactType { - /// Developer-facing English name for logs; UI uses the app target's localized `ContactType.localizedName`. - public var displayName: String { - switch self { - case .chat: return "Contact" - case .repeater: return "Repeater" - case .room: return "Room" - } +public extension ContactType { + /// Developer-facing English name for logs; UI uses the app target's localized `ContactType.localizedName`. + var displayName: String { + switch self { + case .chat: "Contact" + case .repeater: "Repeater" + case .room: "Room" } + } } // MARK: - Text Types /// Message text type encoding public enum TextType: UInt8, Sendable, Codable { - case plain = 0x00 - case cliData = 0x01 - case signedPlain = 0x02 + case plain = 0x00 + case cliData = 0x01 + case signedPlain = 0x02 } // MARK: - Remote Node Types /// Discriminates between remote node types for role-specific handling public enum RemoteNodeRole: UInt8, Sendable, Codable { - case repeater = 0x02 - case roomServer = 0x03 + case repeater = 0x02 + case roomServer = 0x03 - /// Initialize from ContactType - public init?(contactType: ContactType) { - switch contactType { - case .repeater: self = .repeater - case .room: self = .roomServer - case .chat: return nil - } + /// Initialize from ContactType + public init?(contactType: ContactType) { + switch contactType { + case .repeater: self = .repeater + case .room: self = .roomServer + case .chat: return nil } + } } /// Permission levels for room server access public enum RoomPermissionLevel: UInt8, Sendable, Comparable, Codable { - case guest = 0x00 - case readWrite = 0x01 - case admin = 0x02 + case guest = 0x00 + case readWrite = 0x01 + case admin = 0x02 - public var canPost: Bool { self >= .readWrite } - public var isAdmin: Bool { self == .admin } + public var canPost: Bool { + self >= .readWrite + } - /// Developer-facing English name for logs; UI uses the app target's localized `RoomPermissionLevel.localizedName`. - public var displayName: String { - switch self { - case .guest: return "Guest" - case .readWrite: return "Member" - case .admin: return "Admin" - } - } + public var isAdmin: Bool { + self == .admin + } - public static func < (lhs: RoomPermissionLevel, rhs: RoomPermissionLevel) -> Bool { - lhs.rawValue < rhs.rawValue + /// Developer-facing English name for logs; UI uses the app target's localized `RoomPermissionLevel.localizedName`. + public var displayName: String { + switch self { + case .guest: "Guest" + case .readWrite: "Member" + case .admin: "Admin" } + } + + public static func < (lhs: RoomPermissionLevel, rhs: RoomPermissionLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } } diff --git a/MC1Services/Sources/MC1Services/Models/Reaction.swift b/MC1Services/Sources/MC1Services/Models/Reaction.swift index c1000307..fed90262 100644 --- a/MC1Services/Sources/MC1Services/Models/Reaction.swift +++ b/MC1Services/Sources/MC1Services/Models/Reaction.swift @@ -4,135 +4,134 @@ import SwiftData /// Represents an emoji reaction to a channel or DM message. @Model public final class Reaction { - #Index( - [\.messageID], - [\.radioID, \.contactID, \.messageID], - [\.messageID, \.senderName, \.emoji] + #Index( + [\.messageID], + [\.radioID, \.contactID, \.messageID], + [\.messageID, \.senderName, \.emoji] + ) + + @Attribute(.unique) + public var id: UUID + + /// Target message UUID + public var messageID: UUID + + /// The emoji used + public var emoji: String + + /// Sender's node name + public var senderName: String + + /// Message hash from wire format (8 hex chars) + public var messageHash: String + + /// Original raw text for fallback display + public var rawText: String + + /// When we received this reaction + public var receivedAt: Date + + /// Channel index where received (nil for DM reactions) + public var channelIndex: UInt8? + + /// Contact ID for DM reactions (nil for channel reactions) + public var contactID: UUID? + + /// Device ID this belongs to + @Attribute(originalName: "deviceID") + public var radioID: UUID + + public init( + id: UUID = UUID(), + messageID: UUID, + emoji: String, + senderName: String, + messageHash: String, + rawText: String, + receivedAt: Date = Date(), + channelIndex: UInt8? = nil, + contactID: UUID? = nil, + radioID: UUID + ) { + self.id = id + self.messageID = messageID + self.emoji = emoji + self.senderName = senderName + self.messageHash = messageHash + self.rawText = rawText + self.receivedAt = receivedAt + self.channelIndex = channelIndex + self.contactID = contactID + self.radioID = radioID + } + + /// Builds a model instance directly from a DTO. + public convenience init(dto: ReactionDTO) { + self.init( + id: dto.id, + messageID: dto.messageID, + emoji: dto.emoji, + senderName: dto.senderName, + messageHash: dto.messageHash, + rawText: dto.rawText, + receivedAt: dto.receivedAt, + channelIndex: dto.channelIndex, + contactID: dto.contactID, + radioID: dto.radioID ) - - @Attribute(.unique) - public var id: UUID - - /// Target message UUID - public var messageID: UUID - - /// The emoji used - public var emoji: String - - /// Sender's node name - public var senderName: String - - /// Message hash from wire format (8 hex chars) - public var messageHash: String - - /// Original raw text for fallback display - public var rawText: String - - /// When we received this reaction - public var receivedAt: Date - - /// Channel index where received (nil for DM reactions) - public var channelIndex: UInt8? - - /// Contact ID for DM reactions (nil for channel reactions) - public var contactID: UUID? - - /// Device ID this belongs to - @Attribute(originalName: "deviceID") - public var radioID: UUID - - public init( - id: UUID = UUID(), - messageID: UUID, - emoji: String, - senderName: String, - messageHash: String, - rawText: String, - receivedAt: Date = Date(), - channelIndex: UInt8? = nil, - contactID: UUID? = nil, - radioID: UUID - ) { - self.id = id - self.messageID = messageID - self.emoji = emoji - self.senderName = senderName - self.messageHash = messageHash - self.rawText = rawText - self.receivedAt = receivedAt - self.channelIndex = channelIndex - self.contactID = contactID - self.radioID = radioID - } - - /// Builds a model instance directly from a DTO. - public convenience init(dto: ReactionDTO) { - self.init( - id: dto.id, - messageID: dto.messageID, - emoji: dto.emoji, - senderName: dto.senderName, - messageHash: dto.messageHash, - rawText: dto.rawText, - receivedAt: dto.receivedAt, - channelIndex: dto.channelIndex, - contactID: dto.contactID, - radioID: dto.radioID - ) - } + } } // MARK: - Sendable DTO public struct ReactionDTO: Sendable, Equatable, Hashable, Identifiable, Codable { - public let id: UUID - public var messageID: UUID - public let emoji: String - public let senderName: String - public let messageHash: String - public let rawText: String - public let receivedAt: Date - /// Mutable so backup import can rewrite it in lockstep with the parent channel - /// message when a channel relocates to a different local slot. - public var channelIndex: UInt8? - public var contactID: UUID? - public var radioID: UUID - - public init(from reaction: Reaction) { - self.id = reaction.id - self.messageID = reaction.messageID - self.emoji = reaction.emoji - self.senderName = reaction.senderName - self.messageHash = reaction.messageHash - self.rawText = reaction.rawText - self.receivedAt = reaction.receivedAt - self.channelIndex = reaction.channelIndex - self.contactID = reaction.contactID - self.radioID = reaction.radioID - } - - public init( - id: UUID = UUID(), - messageID: UUID, - emoji: String, - senderName: String, - messageHash: String, - rawText: String, - receivedAt: Date = Date(), - channelIndex: UInt8? = nil, - contactID: UUID? = nil, - radioID: UUID - ) { - self.id = id - self.messageID = messageID - self.emoji = emoji - self.senderName = senderName - self.messageHash = messageHash - self.rawText = rawText - self.receivedAt = receivedAt - self.channelIndex = channelIndex - self.contactID = contactID - self.radioID = radioID - } - + public let id: UUID + public var messageID: UUID + public let emoji: String + public let senderName: String + public let messageHash: String + public let rawText: String + public let receivedAt: Date + /// Mutable so backup import can rewrite it in lockstep with the parent channel + /// message when a channel relocates to a different local slot. + public var channelIndex: UInt8? + public var contactID: UUID? + public var radioID: UUID + + public init(from reaction: Reaction) { + id = reaction.id + messageID = reaction.messageID + emoji = reaction.emoji + senderName = reaction.senderName + messageHash = reaction.messageHash + rawText = reaction.rawText + receivedAt = reaction.receivedAt + channelIndex = reaction.channelIndex + contactID = reaction.contactID + radioID = reaction.radioID + } + + public init( + id: UUID = UUID(), + messageID: UUID, + emoji: String, + senderName: String, + messageHash: String, + rawText: String, + receivedAt: Date = Date(), + channelIndex: UInt8? = nil, + contactID: UUID? = nil, + radioID: UUID + ) { + self.id = id + self.messageID = messageID + self.emoji = emoji + self.senderName = senderName + self.messageHash = messageHash + self.rawText = rawText + self.receivedAt = receivedAt + self.channelIndex = channelIndex + self.contactID = contactID + self.radioID = radioID + } } diff --git a/MC1Services/Sources/MC1Services/Models/RegionSelection.swift b/MC1Services/Sources/MC1Services/Models/RegionSelection.swift index d8942a2e..2cc27356 100644 --- a/MC1Services/Sources/MC1Services/Models/RegionSelection.swift +++ b/MC1Services/Sources/MC1Services/Models/RegionSelection.swift @@ -3,40 +3,40 @@ import Foundation /// User's geographic region, used to recommend community-curated radio presets. /// Distinct from the firmware-mesh-region concept in `RegionDiscoveryService`. public struct RegionSelection: Codable, Sendable, Equatable { - public let countryCode: String // ISO-3166 α-2 (e.g. "US") - public let administrativeAreaCode: String? // ISO 3166-2 (e.g. "US-CA") - public let countyKey: String? // normalized US county key (e.g. "los angeles") - public let source: Source + public let countryCode: String // ISO-3166 α-2 (e.g. "US") + public let administrativeAreaCode: String? // ISO 3166-2 (e.g. "US-CA") + public let countyKey: String? // normalized US county key (e.g. "los angeles") + public let source: Source - public enum Source: String, Codable, Sendable { - // swiftlint:disable redundant_string_enum_value - // Raw values pinned per backup contract: a future case rename must not silently change the on-disk format. - case location = "location" - case manual = "manual" - // swiftlint:enable redundant_string_enum_value - } + public enum Source: String, Codable, Sendable { + // swiftlint:disable redundant_string_enum_value + // Raw values pinned per backup contract: a future case rename must not silently change the on-disk format. + case location + case manual + // swiftlint:enable redundant_string_enum_value + } - public init( - countryCode: String, - administrativeAreaCode: String? = nil, - countyKey: String? = nil, - source: Source - ) { - self.countryCode = countryCode - self.administrativeAreaCode = administrativeAreaCode - self.countyKey = countyKey - self.source = source - } + public init( + countryCode: String, + administrativeAreaCode: String? = nil, + countyKey: String? = nil, + source: Source + ) { + self.countryCode = countryCode + self.administrativeAreaCode = administrativeAreaCode + self.countyKey = countyKey + self.source = source + } - private enum CodingKeys: String, CodingKey { - case countryCode, administrativeAreaCode, countyKey, source - } + private enum CodingKeys: String, CodingKey { + case countryCode, administrativeAreaCode, countyKey, source + } - public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - self.countryCode = try c.decode(String.self, forKey: .countryCode) - self.administrativeAreaCode = try c.decodeIfPresent(String.self, forKey: .administrativeAreaCode) - self.countyKey = try c.decodeIfPresent(String.self, forKey: .countyKey) - self.source = try c.decode(Source.self, forKey: .source) - } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + countryCode = try c.decode(String.self, forKey: .countryCode) + administrativeAreaCode = try c.decodeIfPresent(String.self, forKey: .administrativeAreaCode) + countyKey = try c.decodeIfPresent(String.self, forKey: .countyKey) + source = try c.decode(Source.self, forKey: .source) + } } diff --git a/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift b/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift index 1d2f67b4..eb906796 100644 --- a/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift +++ b/MC1Services/Sources/MC1Services/Models/RemoteNodeSession.swift @@ -6,388 +6,411 @@ import SwiftData /// Used for both room servers and repeater admin connections. @Model public final class RemoteNodeSession { - #Index([\.radioID]) + #Index([\.radioID]) - /// Unique session identifier - @Attribute(.unique) - public var id: UUID + /// Unique session identifier + @Attribute(.unique) + public var id: UUID - /// The companion radio used to access this node - @Attribute(originalName: "deviceID") - public var radioID: UUID + /// The companion radio used to access this node + @Attribute(originalName: "deviceID") + public var radioID: UUID - /// 32-byte remote node's public key - public var publicKey: Data + /// 32-byte remote node's public key + public var publicKey: Data - /// Human-readable node name - public var name: String + /// Human-readable node name + public var name: String - /// Raw value of RemoteNodeRole - public var roleRawValue: UInt8 + /// Raw value of RemoteNodeRole + public var roleRawValue: UInt8 - /// Node latitude (non-optional, consistent with Device) - public var latitude: Double + /// Node latitude (non-optional, consistent with Device) + public var latitude: Double - /// Node longitude - public var longitude: Double + /// Node longitude + public var longitude: Double - /// Whether currently connected/authenticated - public var isConnected: Bool + /// Whether currently connected/authenticated + public var isConnected: Bool - /// Permission level raw value (RoomPermissionLevel) - public var permissionLevelRawValue: UInt8 + /// Permission level raw value (RoomPermissionLevel) + public var permissionLevelRawValue: UInt8 - /// Last successful connection date - public var lastConnectedDate: Date? + /// Last successful connection date + public var lastConnectedDate: Date? - /// Cached battery level from last status - public var lastBatteryMillivolts: UInt16? + /// Cached battery level from last status + public var lastBatteryMillivolts: UInt16? - /// Cached uptime from last status - public var lastUptimeSeconds: UInt32? + /// Cached uptime from last status + public var lastUptimeSeconds: UInt32? - /// Cached noise floor from last status - public var lastNoiseFloor: Int16? + /// Cached noise floor from last status + public var lastNoiseFloor: Int16? - /// Unread message count (room-specific) - public var unreadCount: Int + /// Unread message count (room-specific) + public var unreadCount: Int - /// Notification level for this room (stored as raw value for SwiftData). - /// Default is -1 (unmigrated) to enable migration from legacy isMuted property. - public var notificationLevelRawValue: Int = -1 + /// Notification level for this room (stored as raw value for SwiftData). + /// Default is -1 (unmigrated) to enable migration from legacy isMuted property. + public var notificationLevelRawValue: Int = -1 - /// Legacy isMuted property from V1 schema (maps to old "isMuted" column). - /// Used for one-time migration to notificationLevelRawValue. - @Attribute(originalName: "isMuted") - public var legacyIsMuted: Bool? + /// Legacy isMuted property from V1 schema (maps to old "isMuted" column). + /// Used for one-time migration to notificationLevelRawValue. + @Attribute(originalName: "isMuted") + public var legacyIsMuted: Bool? - /// Notification level computed property with automatic migration from legacy isMuted - public var notificationLevel: NotificationLevel { - get { - // Check if migration is needed - if notificationLevelRawValue == -1 { - // Migrate from legacy isMuted - let migratedLevel: NotificationLevel = (legacyIsMuted == true) ? .muted : .all - notificationLevelRawValue = migratedLevel.rawValue - return migratedLevel - } - return NotificationLevel(rawValue: notificationLevelRawValue) ?? .all - } - set { notificationLevelRawValue = newValue.rawValue } - } - - /// Whether this session/node is marked as favorite - public var isFavorite: Bool = false - - /// Last RX airtime in seconds (repeater-specific) - public var lastRxAirtimeSeconds: UInt32? - - /// Number of neighbors (repeater-specific) - public var neighborCount: Int - - /// Timestamp of the last message received from this room. - /// Used to request only newer messages on reconnect. - /// Value of 0 means no messages synced yet (request all). - public var lastSyncTimestamp: UInt32 - - /// Device-local date of last message activity (send or receive). - /// Used for sorting in the chat list. Separate from lastSyncTimestamp - /// which tracks the sender's clock for sync purposes. - public var lastMessageDate: Date? - - public init( - id: UUID = UUID(), - radioID: UUID, - publicKey: Data, - name: String, - role: RemoteNodeRole, - latitude: Double = 0, - longitude: Double = 0, - isConnected: Bool = false, - permissionLevel: RoomPermissionLevel = .guest, - lastConnectedDate: Date? = nil, - lastBatteryMillivolts: UInt16? = nil, - lastUptimeSeconds: UInt32? = nil, - lastNoiseFloor: Int16? = nil, - unreadCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - lastRxAirtimeSeconds: UInt32? = nil, - neighborCount: Int = 0, - lastSyncTimestamp: UInt32 = 0, - lastMessageDate: Date? = nil - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.roleRawValue = role.rawValue - self.latitude = latitude - self.longitude = longitude - self.isConnected = isConnected - self.permissionLevelRawValue = permissionLevel.rawValue - self.lastConnectedDate = lastConnectedDate - self.lastBatteryMillivolts = lastBatteryMillivolts - self.lastUptimeSeconds = lastUptimeSeconds - self.lastNoiseFloor = lastNoiseFloor - self.unreadCount = unreadCount - self.notificationLevelRawValue = notificationLevel.rawValue - self.isFavorite = isFavorite - self.lastRxAirtimeSeconds = lastRxAirtimeSeconds - self.neighborCount = neighborCount - self.lastSyncTimestamp = lastSyncTimestamp - self.lastMessageDate = lastMessageDate - } - - /// Builds a model instance directly from a DTO. Shared by backup batch-insert - /// paths so model and DTO can't drift on field coverage. - public convenience init(dto: RemoteNodeSessionDTO) { - self.init( - id: dto.id, - radioID: dto.radioID, - publicKey: dto.publicKey, - name: dto.name, - role: dto.role, - latitude: dto.latitude, - longitude: dto.longitude, - isConnected: dto.isConnected, - permissionLevel: dto.permissionLevel, - lastConnectedDate: dto.lastConnectedDate, - lastBatteryMillivolts: dto.lastBatteryMillivolts, - lastUptimeSeconds: dto.lastUptimeSeconds, - lastNoiseFloor: dto.lastNoiseFloor, - unreadCount: dto.unreadCount, - notificationLevel: dto.notificationLevel, - isFavorite: dto.isFavorite, - lastRxAirtimeSeconds: dto.lastRxAirtimeSeconds, - neighborCount: dto.neighborCount, - lastSyncTimestamp: dto.lastSyncTimestamp, - lastMessageDate: dto.lastMessageDate - ) - } - - /// Applies all mutable fields from a DTO to this model instance. - func apply(_ dto: RemoteNodeSessionDTO) { - radioID = dto.radioID - publicKey = dto.publicKey - name = dto.name - roleRawValue = dto.role.rawValue - latitude = dto.latitude - longitude = dto.longitude - isConnected = dto.isConnected - permissionLevelRawValue = dto.permissionLevel.rawValue - lastConnectedDate = dto.lastConnectedDate - lastBatteryMillivolts = dto.lastBatteryMillivolts - lastUptimeSeconds = dto.lastUptimeSeconds - lastNoiseFloor = dto.lastNoiseFloor - unreadCount = dto.unreadCount - notificationLevel = dto.notificationLevel - isFavorite = dto.isFavorite - lastRxAirtimeSeconds = dto.lastRxAirtimeSeconds - neighborCount = dto.neighborCount - lastSyncTimestamp = dto.lastSyncTimestamp - lastMessageDate = dto.lastMessageDate + /// Notification level computed property with automatic migration from legacy isMuted + public var notificationLevel: NotificationLevel { + get { + // Check if migration is needed + if notificationLevelRawValue == -1 { + // Migrate from legacy isMuted + let migratedLevel: NotificationLevel = (legacyIsMuted == true) ? .muted : .all + notificationLevelRawValue = migratedLevel.rawValue + return migratedLevel + } + return NotificationLevel(rawValue: notificationLevelRawValue) ?? .all } + set { notificationLevelRawValue = newValue.rawValue } + } + + /// Whether this session/node is marked as favorite + public var isFavorite: Bool = false + + /// Last RX airtime in seconds (repeater-specific) + public var lastRxAirtimeSeconds: UInt32? + + /// Number of neighbors (repeater-specific) + public var neighborCount: Int + + /// Timestamp of the last message received from this room. + /// Used to request only newer messages on reconnect. + /// Value of 0 means no messages synced yet (request all). + public var lastSyncTimestamp: UInt32 + + /// Device-local date of last message activity (send or receive). + /// Used for sorting in the chat list. Separate from lastSyncTimestamp + /// which tracks the sender's clock for sync purposes. + public var lastMessageDate: Date? + + public init( + id: UUID = UUID(), + radioID: UUID, + publicKey: Data, + name: String, + role: RemoteNodeRole, + latitude: Double = 0, + longitude: Double = 0, + isConnected: Bool = false, + permissionLevel: RoomPermissionLevel = .guest, + lastConnectedDate: Date? = nil, + lastBatteryMillivolts: UInt16? = nil, + lastUptimeSeconds: UInt32? = nil, + lastNoiseFloor: Int16? = nil, + unreadCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + lastRxAirtimeSeconds: UInt32? = nil, + neighborCount: Int = 0, + lastSyncTimestamp: UInt32 = 0, + lastMessageDate: Date? = nil + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + roleRawValue = role.rawValue + self.latitude = latitude + self.longitude = longitude + self.isConnected = isConnected + permissionLevelRawValue = permissionLevel.rawValue + self.lastConnectedDate = lastConnectedDate + self.lastBatteryMillivolts = lastBatteryMillivolts + self.lastUptimeSeconds = lastUptimeSeconds + self.lastNoiseFloor = lastNoiseFloor + self.unreadCount = unreadCount + notificationLevelRawValue = notificationLevel.rawValue + self.isFavorite = isFavorite + self.lastRxAirtimeSeconds = lastRxAirtimeSeconds + self.neighborCount = neighborCount + self.lastSyncTimestamp = lastSyncTimestamp + self.lastMessageDate = lastMessageDate + } + + /// Builds a model instance directly from a DTO. Shared by backup batch-insert + /// paths so model and DTO can't drift on field coverage. + public convenience init(dto: RemoteNodeSessionDTO) { + self.init( + id: dto.id, + radioID: dto.radioID, + publicKey: dto.publicKey, + name: dto.name, + role: dto.role, + latitude: dto.latitude, + longitude: dto.longitude, + isConnected: dto.isConnected, + permissionLevel: dto.permissionLevel, + lastConnectedDate: dto.lastConnectedDate, + lastBatteryMillivolts: dto.lastBatteryMillivolts, + lastUptimeSeconds: dto.lastUptimeSeconds, + lastNoiseFloor: dto.lastNoiseFloor, + unreadCount: dto.unreadCount, + notificationLevel: dto.notificationLevel, + isFavorite: dto.isFavorite, + lastRxAirtimeSeconds: dto.lastRxAirtimeSeconds, + neighborCount: dto.neighborCount, + lastSyncTimestamp: dto.lastSyncTimestamp, + lastMessageDate: dto.lastMessageDate + ) + } + + /// Applies all mutable fields from a DTO to this model instance. + func apply(_ dto: RemoteNodeSessionDTO) { + radioID = dto.radioID + publicKey = dto.publicKey + name = dto.name + roleRawValue = dto.role.rawValue + latitude = dto.latitude + longitude = dto.longitude + isConnected = dto.isConnected + permissionLevelRawValue = dto.permissionLevel.rawValue + lastConnectedDate = dto.lastConnectedDate + lastBatteryMillivolts = dto.lastBatteryMillivolts + lastUptimeSeconds = dto.lastUptimeSeconds + lastNoiseFloor = dto.lastNoiseFloor + unreadCount = dto.unreadCount + notificationLevel = dto.notificationLevel + isFavorite = dto.isFavorite + lastRxAirtimeSeconds = dto.lastRxAirtimeSeconds + neighborCount = dto.neighborCount + lastSyncTimestamp = dto.lastSyncTimestamp + lastMessageDate = dto.lastMessageDate + } } // MARK: - Computed Properties public extension RemoteNodeSession { - /// The node role enum - var role: RemoteNodeRole { - RemoteNodeRole(rawValue: roleRawValue) ?? .repeater - } - - /// The permission level enum - var permissionLevel: RoomPermissionLevel { - get { RoomPermissionLevel(rawValue: permissionLevelRawValue) ?? .guest } - set { permissionLevelRawValue = newValue.rawValue } - } - - /// Whether this is a room server session - var isRoom: Bool { role == .roomServer } - - /// Whether this is a repeater session - var isRepeater: Bool { role == .repeater } - - /// 6-byte public key prefix for addressing - var publicKeyPrefix: Data { publicKey.prefix(6) } - - /// Hex string representation of full public key - var publicKeyHex: String { - publicKey.map { String(format: "%02X", $0) }.joined() - } - - /// Whether user can post messages (room-specific) - var canPost: Bool { isRoom && permissionLevel.canPost } - - /// Whether user has admin access - var isAdmin: Bool { permissionLevel.isAdmin } + /// The node role enum + var role: RemoteNodeRole { + RemoteNodeRole(rawValue: roleRawValue) ?? .repeater + } + + /// The permission level enum + var permissionLevel: RoomPermissionLevel { + get { RoomPermissionLevel(rawValue: permissionLevelRawValue) ?? .guest } + set { permissionLevelRawValue = newValue.rawValue } + } + + /// Whether this is a room server session + var isRoom: Bool { + role == .roomServer + } + + /// Whether this is a repeater session + var isRepeater: Bool { + role == .repeater + } + + /// 6-byte public key prefix for addressing + var publicKeyPrefix: Data { + publicKey.prefix(6) + } + + /// Hex string representation of full public key + var publicKeyHex: String { + publicKey.map { String(format: "%02X", $0) }.joined() + } + + /// Whether user can post messages (room-specific) + var canPost: Bool { + isRoom && permissionLevel.canPost + } + + /// Whether user has admin access + var isAdmin: Bool { + permissionLevel.isAdmin + } } // MARK: - Sendable DTO /// A sendable snapshot of RemoteNodeSession for cross-actor transfers public struct RemoteNodeSessionDTO: Sendable, Equatable, Identifiable, Hashable, Codable { - public let id: UUID - public var radioID: UUID - public let publicKey: Data - public let name: String - public let role: RemoteNodeRole - public let latitude: Double - public let longitude: Double - public let isConnected: Bool - public let permissionLevel: RoomPermissionLevel - public let lastConnectedDate: Date? - public let lastBatteryMillivolts: UInt16? - public let lastUptimeSeconds: UInt32? - public let lastNoiseFloor: Int16? - public let unreadCount: Int - public let notificationLevel: NotificationLevel - public let isFavorite: Bool - - /// Convenience property for checking if muted - public var isMuted: Bool { notificationLevel == .muted } - public let lastRxAirtimeSeconds: UInt32? - public let neighborCount: Int - public let lastSyncTimestamp: UInt32 - public let lastMessageDate: Date? - - public init(from model: RemoteNodeSession) { - self.id = model.id - self.radioID = model.radioID - self.publicKey = model.publicKey - self.name = model.name - self.role = model.role - self.latitude = model.latitude - self.longitude = model.longitude - self.isConnected = model.isConnected - self.permissionLevel = model.permissionLevel - self.lastConnectedDate = model.lastConnectedDate - self.lastBatteryMillivolts = model.lastBatteryMillivolts - self.lastUptimeSeconds = model.lastUptimeSeconds - self.lastNoiseFloor = model.lastNoiseFloor - self.unreadCount = model.unreadCount - // Decode the level without invoking the migrating getter's in-memory write-back, keeping - // export a pure read (mirrors `ChannelDTO.init(from:)`). An unmigrated -1 sentinel maps - // to its migrated value (muted if the legacy isMuted flag was set, else all) exactly as - // the getter would, but without dirtying the live row. - self.notificationLevel = NotificationLevel(rawValue: model.notificationLevelRawValue) - ?? ((model.legacyIsMuted == true) ? .muted : .all) - self.isFavorite = model.isFavorite - self.lastRxAirtimeSeconds = model.lastRxAirtimeSeconds - self.neighborCount = model.neighborCount - self.lastSyncTimestamp = model.lastSyncTimestamp - // Backward compat: fall back to sync timestamp for pre-migration data - self.lastMessageDate = model.lastMessageDate - ?? (model.lastSyncTimestamp > 0 - ? Date(timeIntervalSince1970: TimeInterval(model.lastSyncTimestamp)) - : nil) - } - - /// Memberwise initializer for creating DTOs directly - public init( - id: UUID = UUID(), - radioID: UUID, - publicKey: Data, - name: String, - role: RemoteNodeRole, - latitude: Double = 0, - longitude: Double = 0, - isConnected: Bool = false, - permissionLevel: RoomPermissionLevel = .guest, - lastConnectedDate: Date? = nil, - lastBatteryMillivolts: UInt16? = nil, - lastUptimeSeconds: UInt32? = nil, - lastNoiseFloor: Int16? = nil, - unreadCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - lastRxAirtimeSeconds: UInt32? = nil, - neighborCount: Int = 0, - lastSyncTimestamp: UInt32 = 0, - lastMessageDate: Date? = nil - ) { - self.id = id - self.radioID = radioID - self.publicKey = publicKey - self.name = name - self.role = role - self.latitude = latitude - self.longitude = longitude - self.isConnected = isConnected - self.permissionLevel = permissionLevel - self.lastConnectedDate = lastConnectedDate - self.lastBatteryMillivolts = lastBatteryMillivolts - self.lastUptimeSeconds = lastUptimeSeconds - self.lastNoiseFloor = lastNoiseFloor - self.unreadCount = unreadCount - self.notificationLevel = notificationLevel - self.isFavorite = isFavorite - self.lastRxAirtimeSeconds = lastRxAirtimeSeconds - self.neighborCount = neighborCount - self.lastSyncTimestamp = lastSyncTimestamp - self.lastMessageDate = lastMessageDate - } - - /// Returns a copy with only `notificationLevel` changed. - public func with(notificationLevel: NotificationLevel) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: id, radioID: radioID, publicKey: publicKey, name: name, - role: role, latitude: latitude, longitude: longitude, - isConnected: isConnected, permissionLevel: permissionLevel, - lastConnectedDate: lastConnectedDate, - lastBatteryMillivolts: lastBatteryMillivolts, - lastUptimeSeconds: lastUptimeSeconds, lastNoiseFloor: lastNoiseFloor, - unreadCount: unreadCount, notificationLevel: notificationLevel, - isFavorite: isFavorite, lastRxAirtimeSeconds: lastRxAirtimeSeconds, - neighborCount: neighborCount, lastSyncTimestamp: lastSyncTimestamp, - lastMessageDate: lastMessageDate - ) - } - - /// Returns a copy with only `isFavorite` changed. - public func with(isFavorite: Bool) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: id, radioID: radioID, publicKey: publicKey, name: name, - role: role, latitude: latitude, longitude: longitude, - isConnected: isConnected, permissionLevel: permissionLevel, - lastConnectedDate: lastConnectedDate, - lastBatteryMillivolts: lastBatteryMillivolts, - lastUptimeSeconds: lastUptimeSeconds, lastNoiseFloor: lastNoiseFloor, - unreadCount: unreadCount, notificationLevel: notificationLevel, - isFavorite: isFavorite, lastRxAirtimeSeconds: lastRxAirtimeSeconds, - neighborCount: neighborCount, lastSyncTimestamp: lastSyncTimestamp, - lastMessageDate: lastMessageDate - ) - } - - public var publicKeyPrefix: Data { publicKey.prefix(6) } - - public var publicKeyHex: String { - publicKey.map { String(format: "%02X", $0) }.joined() - } - - public var isRoom: Bool { role == .roomServer } - - public var isRepeater: Bool { role == .repeater } - - public var canPost: Bool { isRoom && permissionLevel.canPost } - - public var isAdmin: Bool { permissionLevel.isAdmin } - - /// Whether the node reported a usable location. Latitude/longitude default to (0,0) when GPS was - /// never shared, so the sentinel and validity check are guarded here, mirroring `ContactDTO`. - public var hasLocation: Bool { - let hasNonZero = latitude != 0 || longitude != 0 - guard hasNonZero else { return false } - return CLLocationCoordinate2DIsValid( - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - ) - } - - public var coordinate: CLLocationCoordinate2D? { - guard hasLocation else { return nil } - return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - } + public let id: UUID + public var radioID: UUID + public let publicKey: Data + public let name: String + public let role: RemoteNodeRole + public let latitude: Double + public let longitude: Double + public let isConnected: Bool + public let permissionLevel: RoomPermissionLevel + public let lastConnectedDate: Date? + public let lastBatteryMillivolts: UInt16? + public let lastUptimeSeconds: UInt32? + public let lastNoiseFloor: Int16? + public let unreadCount: Int + public let notificationLevel: NotificationLevel + public let isFavorite: Bool + + /// Convenience property for checking if muted + public var isMuted: Bool { + notificationLevel == .muted + } + + public let lastRxAirtimeSeconds: UInt32? + public let neighborCount: Int + public let lastSyncTimestamp: UInt32 + public let lastMessageDate: Date? + + public init(from model: RemoteNodeSession) { + id = model.id + radioID = model.radioID + publicKey = model.publicKey + name = model.name + role = model.role + latitude = model.latitude + longitude = model.longitude + isConnected = model.isConnected + permissionLevel = model.permissionLevel + lastConnectedDate = model.lastConnectedDate + lastBatteryMillivolts = model.lastBatteryMillivolts + lastUptimeSeconds = model.lastUptimeSeconds + lastNoiseFloor = model.lastNoiseFloor + unreadCount = model.unreadCount + // Decode the level without invoking the migrating getter's in-memory write-back, keeping + // export a pure read (mirrors `ChannelDTO.init(from:)`). An unmigrated -1 sentinel maps + // to its migrated value (muted if the legacy isMuted flag was set, else all) exactly as + // the getter would, but without dirtying the live row. + notificationLevel = NotificationLevel(rawValue: model.notificationLevelRawValue) + ?? ((model.legacyIsMuted == true) ? .muted : .all) + isFavorite = model.isFavorite + lastRxAirtimeSeconds = model.lastRxAirtimeSeconds + neighborCount = model.neighborCount + lastSyncTimestamp = model.lastSyncTimestamp + // Backward compat: fall back to sync timestamp for pre-migration data + lastMessageDate = model.lastMessageDate + ?? (model.lastSyncTimestamp > 0 + ? Date(timeIntervalSince1970: TimeInterval(model.lastSyncTimestamp)) + : nil) + } + + /// Memberwise initializer for creating DTOs directly + public init( + id: UUID = UUID(), + radioID: UUID, + publicKey: Data, + name: String, + role: RemoteNodeRole, + latitude: Double = 0, + longitude: Double = 0, + isConnected: Bool = false, + permissionLevel: RoomPermissionLevel = .guest, + lastConnectedDate: Date? = nil, + lastBatteryMillivolts: UInt16? = nil, + lastUptimeSeconds: UInt32? = nil, + lastNoiseFloor: Int16? = nil, + unreadCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + lastRxAirtimeSeconds: UInt32? = nil, + neighborCount: Int = 0, + lastSyncTimestamp: UInt32 = 0, + lastMessageDate: Date? = nil + ) { + self.id = id + self.radioID = radioID + self.publicKey = publicKey + self.name = name + self.role = role + self.latitude = latitude + self.longitude = longitude + self.isConnected = isConnected + self.permissionLevel = permissionLevel + self.lastConnectedDate = lastConnectedDate + self.lastBatteryMillivolts = lastBatteryMillivolts + self.lastUptimeSeconds = lastUptimeSeconds + self.lastNoiseFloor = lastNoiseFloor + self.unreadCount = unreadCount + self.notificationLevel = notificationLevel + self.isFavorite = isFavorite + self.lastRxAirtimeSeconds = lastRxAirtimeSeconds + self.neighborCount = neighborCount + self.lastSyncTimestamp = lastSyncTimestamp + self.lastMessageDate = lastMessageDate + } + + /// Returns a copy with only `notificationLevel` changed. + public func with(notificationLevel: NotificationLevel) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: id, radioID: radioID, publicKey: publicKey, name: name, + role: role, latitude: latitude, longitude: longitude, + isConnected: isConnected, permissionLevel: permissionLevel, + lastConnectedDate: lastConnectedDate, + lastBatteryMillivolts: lastBatteryMillivolts, + lastUptimeSeconds: lastUptimeSeconds, lastNoiseFloor: lastNoiseFloor, + unreadCount: unreadCount, notificationLevel: notificationLevel, + isFavorite: isFavorite, lastRxAirtimeSeconds: lastRxAirtimeSeconds, + neighborCount: neighborCount, lastSyncTimestamp: lastSyncTimestamp, + lastMessageDate: lastMessageDate + ) + } + + /// Returns a copy with only `isFavorite` changed. + public func with(isFavorite: Bool) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: id, radioID: radioID, publicKey: publicKey, name: name, + role: role, latitude: latitude, longitude: longitude, + isConnected: isConnected, permissionLevel: permissionLevel, + lastConnectedDate: lastConnectedDate, + lastBatteryMillivolts: lastBatteryMillivolts, + lastUptimeSeconds: lastUptimeSeconds, lastNoiseFloor: lastNoiseFloor, + unreadCount: unreadCount, notificationLevel: notificationLevel, + isFavorite: isFavorite, lastRxAirtimeSeconds: lastRxAirtimeSeconds, + neighborCount: neighborCount, lastSyncTimestamp: lastSyncTimestamp, + lastMessageDate: lastMessageDate + ) + } + + public var publicKeyPrefix: Data { + publicKey.prefix(6) + } + + public var publicKeyHex: String { + publicKey.map { String(format: "%02X", $0) }.joined() + } + + public var isRoom: Bool { + role == .roomServer + } + + public var isRepeater: Bool { + role == .repeater + } + + public var canPost: Bool { + isRoom && permissionLevel.canPost + } + + public var isAdmin: Bool { + permissionLevel.isAdmin + } + + /// Whether the node reported a usable location. Latitude/longitude default to (0,0) when GPS was + /// never shared, so the sentinel and validity check are guarded here, mirroring `ContactDTO`. + public var hasLocation: Bool { + let hasNonZero = latitude != 0 || longitude != 0 + guard hasNonZero else { return false } + return CLLocationCoordinate2DIsValid( + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + } + + public var coordinate: CLLocationCoordinate2D? { + guard hasLocation else { return nil } + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/BaseColorSlot.swift b/MC1Services/Sources/MC1Services/Models/Rendering/BaseColorSlot.swift index fedb5e34..4e81595c 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/BaseColorSlot.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/BaseColorSlot.swift @@ -7,6 +7,6 @@ import Foundation /// rest of the chat content model to live in MC1Services without a /// SwiftUI dependency. public enum BaseColorSlot: Sendable, Hashable { - case outgoing - case incoming + case outgoing + case incoming } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift b/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift index 021797e9..f76facc2 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/ChatRenderState.swift @@ -5,105 +5,104 @@ import Foundation /// `ChatViewModel` accessors that forward to the coordinator's current /// `renderState`. public struct ChatRenderState: Sendable, Equatable { + /// Tri-state load phase that distinguishes "we haven't loaded yet" from + /// "we loaded and the result was empty." Per-conversation views gate the + /// empty-state placeholder on `phase == .loaded && items.isEmpty`, so a + /// freshly-bound coordinator whose first fetch is still in-flight does + /// not flash "No messages" before the awaited fetch lands. + public enum LoadPhase: Sendable { + case uninitialized + case loading + case loaded + } - /// Tri-state load phase that distinguishes "we haven't loaded yet" from - /// "we loaded and the result was empty." Per-conversation views gate the - /// empty-state placeholder on `phase == .loaded && items.isEmpty`, so a - /// freshly-bound coordinator whose first fetch is still in-flight does - /// not flash "No messages" before the awaited fetch lands. - public enum LoadPhase: Sendable { - case uninitialized - case loading - case loaded - } + public let items: [MessageItem] + public let itemIndexByID: [UUID: Int] + public let hasMoreMessages: Bool + public let isLoadingOlder: Bool + public let totalFetchedCount: Int + public let phase: LoadPhase - public let items: [MessageItem] - public let itemIndexByID: [UUID: Int] - public let hasMoreMessages: Bool - public let isLoadingOlder: Bool - public let totalFetchedCount: Int - public let phase: LoadPhase + public init( + items: [MessageItem], + itemIndexByID: [UUID: Int], + hasMoreMessages: Bool, + isLoadingOlder: Bool, + totalFetchedCount: Int, + phase: LoadPhase = .uninitialized + ) { + self.items = items + self.itemIndexByID = itemIndexByID + self.hasMoreMessages = hasMoreMessages + self.isLoadingOlder = isLoadingOlder + self.totalFetchedCount = totalFetchedCount + self.phase = phase + } - public init( - items: [MessageItem], - itemIndexByID: [UUID: Int], - hasMoreMessages: Bool, - isLoadingOlder: Bool, - totalFetchedCount: Int, - phase: LoadPhase = .uninitialized - ) { - self.items = items - self.itemIndexByID = itemIndexByID - self.hasMoreMessages = hasMoreMessages - self.isLoadingOlder = isLoadingOlder - self.totalFetchedCount = totalFetchedCount - self.phase = phase - } + public static let empty = ChatRenderState( + items: [], + itemIndexByID: [:], + hasMoreMessages: true, + isLoadingOlder: false, + totalFetchedCount: 0, + phase: .uninitialized + ) - public static let empty = ChatRenderState( - items: [], - itemIndexByID: [:], - hasMoreMessages: true, - isLoadingOlder: false, - totalFetchedCount: 0, - phase: .uninitialized + /// Returns a new render state with the supplied fields overridden. + /// Use at every mutation site to enforce "rebuild, never mutate in place." + public func with( + items: [MessageItem]? = nil, + itemIndexByID: [UUID: Int]? = nil, + hasMoreMessages: Bool? = nil, + isLoadingOlder: Bool? = nil, + totalFetchedCount: Int? = nil, + phase: LoadPhase? = nil + ) -> ChatRenderState { + ChatRenderState( + items: items ?? self.items, + itemIndexByID: itemIndexByID ?? self.itemIndexByID, + hasMoreMessages: hasMoreMessages ?? self.hasMoreMessages, + isLoadingOlder: isLoadingOlder ?? self.isLoadingOlder, + totalFetchedCount: totalFetchedCount ?? self.totalFetchedCount, + phase: phase ?? self.phase ) + } - /// Returns a new render state with the supplied fields overridden. - /// Use at every mutation site to enforce "rebuild, never mutate in place." - public func with( - items: [MessageItem]? = nil, - itemIndexByID: [UUID: Int]? = nil, - hasMoreMessages: Bool? = nil, - isLoadingOlder: Bool? = nil, - totalFetchedCount: Int? = nil, - phase: LoadPhase? = nil - ) -> ChatRenderState { - ChatRenderState( - items: items ?? self.items, - itemIndexByID: itemIndexByID ?? self.itemIndexByID, - hasMoreMessages: hasMoreMessages ?? self.hasMoreMessages, - isLoadingOlder: isLoadingOlder ?? self.isLoadingOlder, - totalFetchedCount: totalFetchedCount ?? self.totalFetchedCount, - phase: phase ?? self.phase - ) - } + /// Replace a single item by message ID. No-op if the ID is not present + /// or if `transform` returns an equal item — returning `self` in those + /// cases preserves the underlying `items` buffer so the caller's + /// `newState != renderState` guard short-circuits on Array buffer + /// identity instead of walking every item. + public func updatingItem(id: UUID, _ transform: (MessageItem) -> MessageItem) -> ChatRenderState { + guard let index = itemIndexByID[id] else { return self } + let updated = transform(items[index]) + guard updated != items[index] else { return self } + var newItems = items + newItems[index] = updated + return with(items: newItems) + } - /// Replace a single item by message ID. No-op if the ID is not present - /// or if `transform` returns an equal item — returning `self` in those - /// cases preserves the underlying `items` buffer so the caller's - /// `newState != renderState` guard short-circuits on Array buffer - /// identity instead of walking every item. - public func updatingItem(id: UUID, _ transform: (MessageItem) -> MessageItem) -> ChatRenderState { - guard let index = itemIndexByID[id] else { return self } - let updated = transform(items[index]) - guard updated != items[index] else { return self } - var newItems = items - newItems[index] = updated - return self.with(items: newItems) - } + /// Remove a single item by message ID. Rebuilds `itemIndexByID` since + /// indices shift after removal. No-op if the ID is not present. + public func removingItem(id: UUID) -> ChatRenderState { + guard itemIndexByID[id] != nil else { return self } + var newItems = items + newItems.removeAll { $0.id == id } + return with(items: newItems, itemIndexByID: newItems.indexByID()) + } - /// Remove a single item by message ID. Rebuilds `itemIndexByID` since - /// indices shift after removal. No-op if the ID is not present. - public func removingItem(id: UUID) -> ChatRenderState { - guard itemIndexByID[id] != nil else { return self } - var newItems = items - newItems.removeAll { $0.id == id } - return self.with(items: newItems, itemIndexByID: newItems.indexByID()) - } - - /// Append a single item and update `itemIndexByID`. Caller is responsible - /// for ensuring the ID is not already present (typically via an upstream - /// `itemIndexByID[id] == nil` guard at the append site). - public func appendingItem(_ item: MessageItem) -> ChatRenderState { - var newItems = items - newItems.append(item) - var newIndex = itemIndexByID - newIndex[item.id] = newItems.count - 1 - return self.with( - items: newItems, - itemIndexByID: newIndex, - totalFetchedCount: totalFetchedCount + 1 - ) - } + /// Append a single item and update `itemIndexByID`. Caller is responsible + /// for ensuring the ID is not already present (typically via an upstream + /// `itemIndexByID[id] == nil` guard at the append site). + public func appendingItem(_ item: MessageItem) -> ChatRenderState { + var newItems = items + newItems.append(item) + var newIndex = itemIndexByID + newIndex[item.id] = newItems.count - 1 + return with( + items: newItems, + itemIndexByID: newIndex, + totalFetchedCount: totalFetchedCount + 1 + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift b/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift index 7c773e0a..b018e4c4 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift @@ -6,98 +6,98 @@ import Foundation /// and pushes it to `ChatViewModel.applyEnvInputs(_:)`; the view model /// rebuilds `MessageItem`s when the value changes. public struct EnvInputs: Sendable, Hashable { - public let showInlineImages: Bool - public let autoPlayGIFs: Bool - public let showIncomingPath: Bool - public let showIncomingHopCount: Bool - public let showIncomingRegion: Bool - public let showIncomingSendTime: Bool - public let previewsEnabled: Bool - public let isHighContrast: Bool - /// Light/dark appearance, sourced from `@Environment(\.colorScheme)` in - /// `ChatConversationView`. Threaded into `MapPreviewFragmentState` so the map - /// thumbnail renders against the matching style. Like `isHighContrast`, a - /// change forces a full `buildItems()` rebuild (rare, OS-driven). - public let isDark: Bool - /// User-controlled privacy gate. When false, `MessageFragmentBuilder` skips - /// the map-preview fragment entirely so `MapPreviewFragmentView.onAppear` - /// never fires the third-party tile request — the coordinate text in the - /// message body remains tappable. - public let showMapPreviews: Bool - /// True when `OfflineMapService.isNetworkAvailable` is false. Sourced in - /// `ChatConversationView` and threaded through `MapSnapshotRequest` so the - /// snapshotter routes to the offline-pack style URL and the cache key for - /// online and offline renders does not collide. - public let isOffline: Bool - public let currentUserName: String - /// Active theme identifier (`Theme.id`). A `Sendable, Hashable` token — never a SwiftUI - /// `Color`, which would pull SwiftUI into MC1Services and break `Hashable`. The MC1 side - /// resolves it back to a `Theme` to bake outgoing-text/hashtag colors into `MessageTextPayload`. - public let themeID: String + public let showInlineImages: Bool + public let autoPlayGIFs: Bool + public let showIncomingPath: Bool + public let showIncomingHopCount: Bool + public let showIncomingRegion: Bool + public let showIncomingSendTime: Bool + public let previewsEnabled: Bool + public let isHighContrast: Bool + /// Light/dark appearance, sourced from `@Environment(\.colorScheme)` in + /// `ChatConversationView`. Threaded into `MapPreviewFragmentState` so the map + /// thumbnail renders against the matching style. Like `isHighContrast`, a + /// change forces a full `buildItems()` rebuild (rare, OS-driven). + public let isDark: Bool + /// User-controlled privacy gate. When false, `MessageFragmentBuilder` skips + /// the map-preview fragment entirely so `MapPreviewFragmentView.onAppear` + /// never fires the third-party tile request — the coordinate text in the + /// message body remains tappable. + public let showMapPreviews: Bool + /// True when `OfflineMapService.isNetworkAvailable` is false. Sourced in + /// `ChatConversationView` and threaded through `MapSnapshotRequest` so the + /// snapshotter routes to the offline-pack style URL and the cache key for + /// online and offline renders does not collide. + public let isOffline: Bool + public let currentUserName: String + /// Active theme identifier (`Theme.id`). A `Sendable, Hashable` token — never a SwiftUI + /// `Color`, which would pull SwiftUI into MC1Services and break `Hashable`. The MC1 side + /// resolves it back to a `Theme` to bake outgoing-text/hashtag colors into `MessageTextPayload`. + public let themeID: String - /// Dynamic Type size fingerprint. A `Sendable, Hashable` token (a `DynamicTypeSize` case - /// name string supplied by the MC1 side, never the SwiftUI type itself) so a Dynamic Type - /// change bumps the `EnvInputs` equality fingerprint like `themeID` and `isHighContrast` do, - /// forcing a full `buildItems()` rebuild. It does not key the bubble text view's size cache: - /// that cache is keyed on the `dynamicTypeSize` the renderer reads directly from its own - /// `@Environment`. Reflow of already-visible cells is driven by the appearance reconfigure path. - public let contentSizeCategory: String + /// Dynamic Type size fingerprint. A `Sendable, Hashable` token (a `DynamicTypeSize` case + /// name string supplied by the MC1 side, never the SwiftUI type itself) so a Dynamic Type + /// change bumps the `EnvInputs` equality fingerprint like `themeID` and `isHighContrast` do, + /// forcing a full `buildItems()` rebuild. It does not key the bubble text view's size cache: + /// that cache is keyed on the `dynamicTypeSize` the renderer reads directly from its own + /// `@Environment`. Reflow of already-visible cells is driven by the appearance reconfigure path. + public let contentSizeCategory: String - public init( - showInlineImages: Bool, - autoPlayGIFs: Bool, - showIncomingPath: Bool, - showIncomingHopCount: Bool, - showIncomingRegion: Bool, - showIncomingSendTime: Bool, - previewsEnabled: Bool, - isHighContrast: Bool, - isDark: Bool, - showMapPreviews: Bool, - isOffline: Bool, - currentUserName: String, - themeID: String, - contentSizeCategory: String - ) { - self.showInlineImages = showInlineImages - self.autoPlayGIFs = autoPlayGIFs - self.showIncomingPath = showIncomingPath - self.showIncomingHopCount = showIncomingHopCount - self.showIncomingRegion = showIncomingRegion - self.showIncomingSendTime = showIncomingSendTime - self.previewsEnabled = previewsEnabled - self.isHighContrast = isHighContrast - self.isDark = isDark - self.showMapPreviews = showMapPreviews - self.isOffline = isOffline - self.currentUserName = currentUserName - self.themeID = themeID - self.contentSizeCategory = contentSizeCategory - } + public init( + showInlineImages: Bool, + autoPlayGIFs: Bool, + showIncomingPath: Bool, + showIncomingHopCount: Bool, + showIncomingRegion: Bool, + showIncomingSendTime: Bool, + previewsEnabled: Bool, + isHighContrast: Bool, + isDark: Bool, + showMapPreviews: Bool, + isOffline: Bool, + currentUserName: String, + themeID: String, + contentSizeCategory: String + ) { + self.showInlineImages = showInlineImages + self.autoPlayGIFs = autoPlayGIFs + self.showIncomingPath = showIncomingPath + self.showIncomingHopCount = showIncomingHopCount + self.showIncomingRegion = showIncomingRegion + self.showIncomingSendTime = showIncomingSendTime + self.previewsEnabled = previewsEnabled + self.isHighContrast = isHighContrast + self.isDark = isDark + self.showMapPreviews = showMapPreviews + self.isOffline = isOffline + self.currentUserName = currentUserName + self.themeID = themeID + self.contentSizeCategory = contentSizeCategory + } - /// Identifier of the built-in default theme. Shared so `EnvInputs.default` and `Theme.default.id` - /// (defined in the MC1 layer, which cannot see `Theme` from here) cannot drift apart. - public static let defaultThemeID = "default" + /// Identifier of the built-in default theme. Shared so `EnvInputs.default` and `Theme.default.id` + /// (defined in the MC1 layer, which cannot see `Theme` from here) cannot drift apart. + public static let defaultThemeID = "default" - /// The system's unscaled Dynamic Type baseline (`DynamicTypeSize.large`). Shared so - /// `EnvInputs.default` and the MC1-side token mapper agree on the baseline string and - /// cannot drift apart. - public static let defaultContentSizeCategory = "large" + /// The system's unscaled Dynamic Type baseline (`DynamicTypeSize.large`). Shared so + /// `EnvInputs.default` and the MC1-side token mapper agree on the baseline string and + /// cannot drift apart. + public static let defaultContentSizeCategory = "large" - public static let `default` = EnvInputs( - showInlineImages: AppStorageKey.defaultShowInlineImages, - autoPlayGIFs: AppStorageKey.defaultAutoPlayGIFs, - showIncomingPath: AppStorageKey.defaultShowIncomingPath, - showIncomingHopCount: AppStorageKey.defaultShowIncomingHopCount, - showIncomingRegion: AppStorageKey.defaultShowIncomingRegion, - showIncomingSendTime: AppStorageKey.defaultShowIncomingSendTime, - previewsEnabled: AppStorageKey.defaultLinkPreviewsEnabled, - isHighContrast: false, - isDark: false, - showMapPreviews: AppStorageKey.defaultShowMapPreviewThumbnails, - isOffline: false, - currentUserName: "", - themeID: defaultThemeID, - contentSizeCategory: defaultContentSizeCategory - ) + public static let `default` = EnvInputs( + showInlineImages: AppStorageKey.defaultShowInlineImages, + autoPlayGIFs: AppStorageKey.defaultAutoPlayGIFs, + showIncomingPath: AppStorageKey.defaultShowIncomingPath, + showIncomingHopCount: AppStorageKey.defaultShowIncomingHopCount, + showIncomingRegion: AppStorageKey.defaultShowIncomingRegion, + showIncomingSendTime: AppStorageKey.defaultShowIncomingSendTime, + previewsEnabled: AppStorageKey.defaultLinkPreviewsEnabled, + isHighContrast: false, + isDark: false, + showMapPreviews: AppStorageKey.defaultShowMapPreviewThumbnails, + isOffline: false, + currentUserName: "", + themeID: defaultThemeID, + contentSizeCategory: defaultContentSizeCategory + ) } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/GroupingFlags.swift b/MC1Services/Sources/MC1Services/Models/Rendering/GroupingFlags.swift index 74c4d950..0c614908 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/GroupingFlags.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/GroupingFlags.swift @@ -2,24 +2,24 @@ import Foundation /// Grouping signal — first-in-cluster, show timestamp, show divider. public struct GroupingFlags: Sendable, Hashable { - public let showTimestamp: Bool - public let showDirectionGap: Bool - public let showSenderName: Bool - public let showNewMessagesDivider: Bool - /// True for the first message of a new calendar day; drives the day separator. - public let showDayDivider: Bool + public let showTimestamp: Bool + public let showDirectionGap: Bool + public let showSenderName: Bool + public let showNewMessagesDivider: Bool + /// True for the first message of a new calendar day; drives the day separator. + public let showDayDivider: Bool - public init( - showTimestamp: Bool, - showDirectionGap: Bool, - showSenderName: Bool, - showNewMessagesDivider: Bool, - showDayDivider: Bool = false - ) { - self.showTimestamp = showTimestamp - self.showDirectionGap = showDirectionGap - self.showSenderName = showSenderName - self.showNewMessagesDivider = showNewMessagesDivider - self.showDayDivider = showDayDivider - } + public init( + showTimestamp: Bool, + showDirectionGap: Bool, + showSenderName: Bool, + showNewMessagesDivider: Bool, + showDayDivider: Bool = false + ) { + self.showTimestamp = showTimestamp + self.showDirectionGap = showDirectionGap + self.showSenderName = showSenderName + self.showNewMessagesDivider = showNewMessagesDivider + self.showDayDivider = showDayDivider + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/ImageReference.swift b/MC1Services/Sources/MC1Services/Models/Rendering/ImageReference.swift index 2b7ddc6c..67538a15 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/ImageReference.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/ImageReference.swift @@ -5,14 +5,14 @@ import Foundation /// it only carries a Hashable key. The bubble resolves the actual UIImage via /// an `imageResolver: (ImageReference) -> UIImage?` callback at render time. public struct ImageReference: Sendable, Hashable { - public let cacheKey: UUID - public let role: Role - public enum Role: Sendable, Hashable { - case inline, linkPreviewImage, linkPreviewIcon - } + public let cacheKey: UUID + public let role: Role + public enum Role: Sendable, Hashable { + case inline, linkPreviewImage, linkPreviewIcon + } - public init(cacheKey: UUID, role: Role) { - self.cacheKey = cacheKey - self.role = role - } + public init(cacheKey: UUID, role: Role) { + self.cacheKey = cacheKey + self.role = role + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/InlineImage.swift b/MC1Services/Sources/MC1Services/Models/Rendering/InlineImage.swift index 474efc39..e76baddf 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/InlineImage.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/InlineImage.swift @@ -1,22 +1,23 @@ import Foundation public struct InlineImage: Sendable, Hashable { - public enum LoadState: Sendable, Hashable { - case idle(URL) - case loading(URL) - case loaded(ImageReference, isGIF: Bool) - case failed(URL) - } - public let state: LoadState - public let autoPlayGIFs: Bool - /// Width-over-height ratio resolved from `InlineImageDimensionsStore` at - /// build time. `nil` means dimensions are unknown and the view layer - /// must fall back to its 16:9 reservation skeleton. - public let cachedAspect: Double? + public enum LoadState: Sendable, Hashable { + case idle(URL) + case loading(URL) + case loaded(ImageReference, isGIF: Bool) + case failed(URL) + } - public init(state: LoadState, autoPlayGIFs: Bool, cachedAspect: Double? = nil) { - self.state = state - self.autoPlayGIFs = autoPlayGIFs - self.cachedAspect = cachedAspect - } + public let state: LoadState + public let autoPlayGIFs: Bool + /// Width-over-height ratio resolved from `InlineImageDimensionsStore` at + /// build time. `nil` means dimensions are unknown and the view layer + /// must fall back to its 16:9 reservation skeleton. + public let cachedAspect: Double? + + public init(state: LoadState, autoPlayGIFs: Bool, cachedAspect: Double? = nil) { + self.state = state + self.autoPlayGIFs = autoPlayGIFs + self.cachedAspect = cachedAspect + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift b/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift index 3d8094ed..2ce0e2f1 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/LinkPreviewFragmentState.swift @@ -1,33 +1,34 @@ import Foundation public struct LinkPreviewFragmentState: Sendable, Hashable { - public enum Mode: Sendable, Hashable { - case idle - case loading(URL) - case loaded(LinkPreviewDataDTO, image: ImageReference?, icon: ImageReference?) - case noPreview - case disabled(URL) - case legacy(url: URL, title: String?, image: ImageReference?, icon: ImageReference?) - } - public let mode: Mode + public enum Mode: Sendable, Hashable { + case idle + case loading(URL) + case loaded(LinkPreviewDataDTO, image: ImageReference?, icon: ImageReference?) + case noPreview + case disabled(URL) + case legacy(url: URL, title: String?, image: ImageReference?, icon: ImageReference?) + } - public init(mode: Mode) { - self.mode = mode - } + public let mode: Mode + + public init(mode: Mode) { + self.mode = mode + } - /// The single openable URL the preview resolves to: the destination of a - /// loaded or legacy card. `nil` for modes that show no openable card - /// (`idle`, `loading`, `noPreview`, `disabled`). The preview card's tap path - /// and the bubble's accessibility "open link" action both read this so the - /// URL is parsed in one place rather than re-derived per call site. - public var primaryURL: URL? { - switch mode { - case .loaded(let preview, _, _): - return URL(string: preview.url) - case .legacy(let url, _, _, _): - return url - case .idle, .loading, .noPreview, .disabled: - return nil - } + /// The single openable URL the preview resolves to: the destination of a + /// loaded or legacy card. `nil` for modes that show no openable card + /// (`idle`, `loading`, `noPreview`, `disabled`). The preview card's tap path + /// and the bubble's accessibility "open link" action both read this so the + /// URL is parsed in one place rather than re-derived per call site. + public var primaryURL: URL? { + switch mode { + case let .loaded(preview, _, _): + URL(string: preview.url) + case let .legacy(url, _, _, _): + url + case .idle, .loading, .noPreview, .disabled: + nil } + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MapPreviewFragmentState.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MapPreviewFragmentState.swift index 81d42d1b..d2b3e871 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MapPreviewFragmentState.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MapPreviewFragmentState.swift @@ -13,21 +13,21 @@ import Foundation /// `(rounded lat/lon, isDark, isOffline)` is resolved (cached or failed), which /// changes this `Hashable` value and reloads the row. public struct MapPreviewFragmentState: Sendable, Hashable { - public let latitude: Double - public let longitude: Double - public let isDark: Bool - public let isOffline: Bool - public let isReady: Bool + public let latitude: Double + public let longitude: Double + public let isDark: Bool + public let isOffline: Bool + public let isReady: Bool - public init(latitude: Double, longitude: Double, isDark: Bool, isOffline: Bool, isReady: Bool) { - self.latitude = latitude - self.longitude = longitude - self.isDark = isDark - self.isOffline = isOffline - self.isReady = isReady - } + public init(latitude: Double, longitude: Double, isDark: Bool, isOffline: Bool, isReady: Bool) { + self.latitude = latitude + self.longitude = longitude + self.isDark = isDark + self.isOffline = isOffline + self.isReady = isReady + } - public var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - } + public var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift index b0131e8b..258e29c2 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift @@ -7,81 +7,81 @@ import Foundation /// `imageResolver` callback (UIImage is not Sendable). Env-derived flags /// live on `EnvInputs`, the builder's second parcel. public struct MessageBuildInputs: Sendable, Hashable { - public let messageID: UUID - public let previewState: PreviewLoadState - public let loadedPreview: LinkPreviewDataDTO? - public let cachedURL: URL? - public let hasInlineImageRef: Bool - public let hasPreviewImageRef: Bool - public let hasPreviewIconRef: Bool - public let imageIsGIF: Bool - /// Cached width-over-height ratio for `cachedURL` when it points to an - /// inline image. Resolved at build time so the bubble can reserve the - /// correct frame on first paint. `nil` for non-image URLs or when the - /// dimensions store has not yet seen this URL. - public let inlineImageAspect: Double? - /// Latitude of the first linkified coordinate in the message text, or nil. - /// Drives the `.mapPreview` fragment. Stored as `Double` for `Hashable`. - public let mapPreviewLatitude: Double? - public let mapPreviewLongitude: Double? - /// True once the snapshot for this coordinate's `(rounded lat/lon, isDark)` - /// request has resolved (cached or failed). Build-time value: a - /// `UIHostingConfiguration` cell only re-evaluates when its item changes. - public let isMapPreviewReady: Bool - public let formattedText: AttributedString? - public let baseColor: BaseColorSlot - public let formattedPath: String? - public let senderResolution: NodeNameResolution - public let showTimestamp: Bool - public let showDirectionGap: Bool - public let showSenderName: Bool - public let showNewMessagesDivider: Bool - /// True for the first message of a new calendar day; drives the day separator. - public let showDayDivider: Bool + public let messageID: UUID + public let previewState: PreviewLoadState + public let loadedPreview: LinkPreviewDataDTO? + public let cachedURL: URL? + public let hasInlineImageRef: Bool + public let hasPreviewImageRef: Bool + public let hasPreviewIconRef: Bool + public let imageIsGIF: Bool + /// Cached width-over-height ratio for `cachedURL` when it points to an + /// inline image. Resolved at build time so the bubble can reserve the + /// correct frame on first paint. `nil` for non-image URLs or when the + /// dimensions store has not yet seen this URL. + public let inlineImageAspect: Double? + /// Latitude of the first linkified coordinate in the message text, or nil. + /// Drives the `.mapPreview` fragment. Stored as `Double` for `Hashable`. + public let mapPreviewLatitude: Double? + public let mapPreviewLongitude: Double? + /// True once the snapshot for this coordinate's `(rounded lat/lon, isDark)` + /// request has resolved (cached or failed). Build-time value: a + /// `UIHostingConfiguration` cell only re-evaluates when its item changes. + public let isMapPreviewReady: Bool + public let formattedText: AttributedString? + public let baseColor: BaseColorSlot + public let formattedPath: String? + public let senderResolution: NodeNameResolution + public let showTimestamp: Bool + public let showDirectionGap: Bool + public let showSenderName: Bool + public let showNewMessagesDivider: Bool + /// True for the first message of a new calendar day; drives the day separator. + public let showDayDivider: Bool - public init( - messageID: UUID, - previewState: PreviewLoadState, - loadedPreview: LinkPreviewDataDTO?, - cachedURL: URL?, - hasInlineImageRef: Bool, - hasPreviewImageRef: Bool, - hasPreviewIconRef: Bool, - imageIsGIF: Bool, - inlineImageAspect: Double? = nil, - mapPreviewLatitude: Double? = nil, - mapPreviewLongitude: Double? = nil, - isMapPreviewReady: Bool = false, - formattedText: AttributedString?, - baseColor: BaseColorSlot, - formattedPath: String?, - senderResolution: NodeNameResolution, - showTimestamp: Bool, - showDirectionGap: Bool, - showSenderName: Bool, - showNewMessagesDivider: Bool, - showDayDivider: Bool = false - ) { - self.messageID = messageID - self.previewState = previewState - self.loadedPreview = loadedPreview - self.cachedURL = cachedURL - self.hasInlineImageRef = hasInlineImageRef - self.hasPreviewImageRef = hasPreviewImageRef - self.hasPreviewIconRef = hasPreviewIconRef - self.imageIsGIF = imageIsGIF - self.inlineImageAspect = inlineImageAspect - self.mapPreviewLatitude = mapPreviewLatitude - self.mapPreviewLongitude = mapPreviewLongitude - self.isMapPreviewReady = isMapPreviewReady - self.formattedText = formattedText - self.baseColor = baseColor - self.formattedPath = formattedPath - self.senderResolution = senderResolution - self.showTimestamp = showTimestamp - self.showDirectionGap = showDirectionGap - self.showSenderName = showSenderName - self.showNewMessagesDivider = showNewMessagesDivider - self.showDayDivider = showDayDivider - } + public init( + messageID: UUID, + previewState: PreviewLoadState, + loadedPreview: LinkPreviewDataDTO?, + cachedURL: URL?, + hasInlineImageRef: Bool, + hasPreviewImageRef: Bool, + hasPreviewIconRef: Bool, + imageIsGIF: Bool, + inlineImageAspect: Double? = nil, + mapPreviewLatitude: Double? = nil, + mapPreviewLongitude: Double? = nil, + isMapPreviewReady: Bool = false, + formattedText: AttributedString?, + baseColor: BaseColorSlot, + formattedPath: String?, + senderResolution: NodeNameResolution, + showTimestamp: Bool, + showDirectionGap: Bool, + showSenderName: Bool, + showNewMessagesDivider: Bool, + showDayDivider: Bool = false + ) { + self.messageID = messageID + self.previewState = previewState + self.loadedPreview = loadedPreview + self.cachedURL = cachedURL + self.hasInlineImageRef = hasInlineImageRef + self.hasPreviewImageRef = hasPreviewImageRef + self.hasPreviewIconRef = hasPreviewIconRef + self.imageIsGIF = imageIsGIF + self.inlineImageAspect = inlineImageAspect + self.mapPreviewLatitude = mapPreviewLatitude + self.mapPreviewLongitude = mapPreviewLongitude + self.isMapPreviewReady = isMapPreviewReady + self.formattedText = formattedText + self.baseColor = baseColor + self.formattedPath = formattedPath + self.senderResolution = senderResolution + self.showTimestamp = showTimestamp + self.showDirectionGap = showDirectionGap + self.showSenderName = showSenderName + self.showNewMessagesDivider = showNewMessagesDivider + self.showDayDivider = showDayDivider + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift index f92f17af..0cb47c37 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift @@ -11,80 +11,80 @@ import Foundation /// leave bubbles displaying stale strings until a rebuild. The view body /// resolves the localized text at render time. public struct MessageFooter: Sendable, Hashable { - public let showHop: Bool - public let hopCount: Int - public let formattedPath: String? - public let regionToShow: String? - /// Send time to display inside the bubble; nil means do not show it. Holds the - /// clock-corrected `senderDate`, so a skewed sender clock never surfaces a - /// misleading time here — `sendTimeWasCorrected` flags the substitution and the - /// raw wire value stays available in the message info sheet. Stored as a `Date`, - /// not a formatted string, so a mid-session 12h/24h or language change reformats - /// at render time without rebaking items — the same rule that keeps `status` a - /// raw enum here. - public let sendTimeToShow: Date? - /// Whether the app substituted a corrected `timestamp` because the sender's - /// wire clock was invalid. Only meaningful when `sendTimeToShow != nil`; - /// drives the warning badge next to the time. - public let sendTimeWasCorrected: Bool - public let showStatusRow: Bool - public let status: MessageStatus - /// Distinguishes a channel broadcast from a DM at render time. `BubbleStatusRow` - /// uses it so a DM's transient `.sent` reads as "Sending..." while a channel's - /// `.sent` stays terminal success. Render-only, never persisted. - public let isChannelMessage: Bool - public let heardRepeats: Int - public let retryAttempt: Int - public let maxRetryAttempts: Int - public let sendCount: Int + public let showHop: Bool + public let hopCount: Int + public let formattedPath: String? + public let regionToShow: String? + /// Send time to display inside the bubble; nil means do not show it. Holds the + /// clock-corrected `senderDate`, so a skewed sender clock never surfaces a + /// misleading time here — `sendTimeWasCorrected` flags the substitution and the + /// raw wire value stays available in the message info sheet. Stored as a `Date`, + /// not a formatted string, so a mid-session 12h/24h or language change reformats + /// at render time without rebaking items — the same rule that keeps `status` a + /// raw enum here. + public let sendTimeToShow: Date? + /// Whether the app substituted a corrected `timestamp` because the sender's + /// wire clock was invalid. Only meaningful when `sendTimeToShow != nil`; + /// drives the warning badge next to the time. + public let sendTimeWasCorrected: Bool + public let showStatusRow: Bool + public let status: MessageStatus + /// Distinguishes a channel broadcast from a DM at render time. `BubbleStatusRow` + /// uses it so a DM's transient `.sent` reads as "Sending..." while a channel's + /// `.sent` stays terminal success. Render-only, never persisted. + public let isChannelMessage: Bool + public let heardRepeats: Int + public let retryAttempt: Int + public let maxRetryAttempts: Int + public let sendCount: Int - public init( - showHop: Bool, - hopCount: Int, - formattedPath: String?, - regionToShow: String?, - sendTimeToShow: Date?, - sendTimeWasCorrected: Bool, - showStatusRow: Bool, - status: MessageStatus, - isChannelMessage: Bool, - heardRepeats: Int, - retryAttempt: Int, - maxRetryAttempts: Int, - sendCount: Int - ) { - self.showHop = showHop - self.hopCount = hopCount - self.formattedPath = formattedPath - self.regionToShow = regionToShow - self.sendTimeToShow = sendTimeToShow - self.sendTimeWasCorrected = sendTimeWasCorrected - self.showStatusRow = showStatusRow - self.status = status - self.isChannelMessage = isChannelMessage - self.heardRepeats = heardRepeats - self.retryAttempt = retryAttempt - self.maxRetryAttempts = maxRetryAttempts - self.sendCount = sendCount - } + public init( + showHop: Bool, + hopCount: Int, + formattedPath: String?, + regionToShow: String?, + sendTimeToShow: Date?, + sendTimeWasCorrected: Bool, + showStatusRow: Bool, + status: MessageStatus, + isChannelMessage: Bool, + heardRepeats: Int, + retryAttempt: Int, + maxRetryAttempts: Int, + sendCount: Int + ) { + self.showHop = showHop + self.hopCount = hopCount + self.formattedPath = formattedPath + self.regionToShow = regionToShow + self.sendTimeToShow = sendTimeToShow + self.sendTimeWasCorrected = sendTimeWasCorrected + self.showStatusRow = showStatusRow + self.status = status + self.isChannelMessage = isChannelMessage + self.heardRepeats = heardRepeats + self.retryAttempt = retryAttempt + self.maxRetryAttempts = maxRetryAttempts + self.sendCount = sendCount + } - /// Returns a new footer with `status` overridden. Eliminates the 10-field - /// rebuild at status-flip sites. - public func with(status: MessageStatus) -> MessageFooter { - MessageFooter( - showHop: showHop, - hopCount: hopCount, - formattedPath: formattedPath, - regionToShow: regionToShow, - sendTimeToShow: sendTimeToShow, - sendTimeWasCorrected: sendTimeWasCorrected, - showStatusRow: showStatusRow, - status: status, - isChannelMessage: isChannelMessage, - heardRepeats: heardRepeats, - retryAttempt: retryAttempt, - maxRetryAttempts: maxRetryAttempts, - sendCount: sendCount - ) - } + /// Returns a new footer with `status` overridden. Eliminates the 10-field + /// rebuild at status-flip sites. + public func with(status: MessageStatus) -> MessageFooter { + MessageFooter( + showHop: showHop, + hopCount: hopCount, + formattedPath: formattedPath, + regionToShow: regionToShow, + sendTimeToShow: sendTimeToShow, + sendTimeWasCorrected: sendTimeWasCorrected, + showStatusRow: showStatusRow, + status: status, + isChannelMessage: isChannelMessage, + heardRepeats: heardRepeats, + retryAttempt: retryAttempt, + maxRetryAttempts: maxRetryAttempts, + sendCount: sendCount + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFragment.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFragment.swift index af7c8f66..41bd4665 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFragment.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFragment.swift @@ -7,15 +7,15 @@ import Foundation /// `MessageText` because module `MC1` already exports a SwiftUI view named /// `MessageText`. public enum MessageFragment: Sendable, Hashable { - case text(MessageTextPayload) - case inlineImage(InlineImage) - case linkPreview(LinkPreviewFragmentState) - case mapPreview(MapPreviewFragmentState) - case malwareWarning(URL) - /// Carries the raw summary string (`"👍:3,❤️:2,😂:1"` format produced by - /// `ReactionParser`). The DTO field `reactionSummary` is `String?`; the - /// fragment is only emitted when the value is non-nil and non-empty, so - /// the payload is a non-optional `String`. Parsing back into per-emoji - /// counts happens at render time. - case reactionSummary(String) + case text(MessageTextPayload) + case inlineImage(InlineImage) + case linkPreview(LinkPreviewFragmentState) + case mapPreview(MapPreviewFragmentState) + case malwareWarning(URL) + /// Carries the raw summary string (`"👍:3,❤️:2,😂:1"` format produced by + /// `ReactionParser`). The DTO field `reactionSummary` is `String?`; the + /// fragment is only emitted when the value is non-nil and non-empty, so + /// the payload is a non-optional `String`. Parsing back into per-emoji + /// counts happens at render time. + case reactionSummary(String) } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift index f3c62e17..da261f3c 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift @@ -17,52 +17,52 @@ import Foundation /// return stale renders. Verify the rebuild path still writes the full set of /// inputs before changing this invariant. public struct MessageItem: Identifiable, Sendable, Hashable { - public let id: UUID - public let envelope: MessageEnvelope - public let content: [MessageFragment] - public let footer: MessageFooter - public let grouping: GroupingFlags - public let shouldRequestPreviewFetch: Bool + public let id: UUID + public let envelope: MessageEnvelope + public let content: [MessageFragment] + public let footer: MessageFooter + public let grouping: GroupingFlags + public let shouldRequestPreviewFetch: Bool - public init( - id: UUID, - envelope: MessageEnvelope, - content: [MessageFragment], - footer: MessageFooter, - grouping: GroupingFlags, - shouldRequestPreviewFetch: Bool - ) { - self.id = id - self.envelope = envelope - self.content = content - self.footer = footer - self.grouping = grouping - self.shouldRequestPreviewFetch = shouldRequestPreviewFetch - } + public init( + id: UUID, + envelope: MessageEnvelope, + content: [MessageFragment], + footer: MessageFooter, + grouping: GroupingFlags, + shouldRequestPreviewFetch: Bool + ) { + self.id = id + self.envelope = envelope + self.content = content + self.footer = footer + self.grouping = grouping + self.shouldRequestPreviewFetch = shouldRequestPreviewFetch + } - /// Message-scoped identity for the bubble's preview-fetch `.task(id:)`. Holds - /// the message id while a fetch is wanted, `nil` otherwise. The task keys on - /// this rather than the bare `shouldRequestPreviewFetch` flag because a reused - /// `UIHostingConfiguration` cell preserves the task's last id: two successive - /// fetch-wanting messages both keyed on `true` produce no id edge, so the - /// second message's fetch would never start. A per-message id forces the edge. - public var previewFetchTaskID: UUID? { - shouldRequestPreviewFetch ? id : nil - } + /// Message-scoped identity for the bubble's preview-fetch `.task(id:)`. Holds + /// the message id while a fetch is wanted, `nil` otherwise. The task keys on + /// this rather than the bare `shouldRequestPreviewFetch` flag because a reused + /// `UIHostingConfiguration` cell preserves the task's last id: two successive + /// fetch-wanting messages both keyed on `true` produce no id edge, so the + /// second message's fetch would never start. A per-message id forces the edge. + public var previewFetchTaskID: UUID? { + shouldRequestPreviewFetch ? id : nil + } - /// Returns a new item with the supplied envelope and/or footer overridden. - /// Eliminates the 6-field rebuild at single-row mutation sites. - public func with( - envelope: MessageEnvelope? = nil, - footer: MessageFooter? = nil - ) -> MessageItem { - MessageItem( - id: id, - envelope: envelope ?? self.envelope, - content: content, - footer: footer ?? self.footer, - grouping: grouping, - shouldRequestPreviewFetch: shouldRequestPreviewFetch - ) - } + /// Returns a new item with the supplied envelope and/or footer overridden. + /// Eliminates the 6-field rebuild at single-row mutation sites. + public func with( + envelope: MessageEnvelope? = nil, + footer: MessageFooter? = nil + ) -> MessageItem { + MessageItem( + id: id, + envelope: envelope ?? self.envelope, + content: content, + footer: footer ?? self.footer, + grouping: grouping, + shouldRequestPreviewFetch: shouldRequestPreviewFetch + ) + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift index 561a4c1c..b938b8f7 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift @@ -1,23 +1,23 @@ import Foundation public struct MessageTextPayload: Sendable, Hashable { - public let raw: String - public let formatted: AttributedString? - public let baseColor: BaseColorSlot - public let isOutgoing: Bool - public let currentUserName: String + public let raw: String + public let formatted: AttributedString? + public let baseColor: BaseColorSlot + public let isOutgoing: Bool + public let currentUserName: String - public init( - raw: String, - formatted: AttributedString?, - baseColor: BaseColorSlot, - isOutgoing: Bool, - currentUserName: String - ) { - self.raw = raw - self.formatted = formatted - self.baseColor = baseColor - self.isOutgoing = isOutgoing - self.currentUserName = currentUserName - } + public init( + raw: String, + formatted: AttributedString?, + baseColor: BaseColorSlot, + isOutgoing: Bool, + currentUserName: String + ) { + self.raw = raw + self.formatted = formatted + self.baseColor = baseColor + self.isOutgoing = isOutgoing + self.currentUserName = currentUserName + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/NodeNameResolution.swift b/MC1Services/Sources/MC1Services/Models/Rendering/NodeNameResolution.swift index cb82491d..c941a047 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/NodeNameResolution.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/NodeNameResolution.swift @@ -3,24 +3,24 @@ import Foundation /// Whether a name resolution corresponds to an exact public-key match, a /// proximity fallback within a hash-prefix collision set, or no match at all. public enum NodeNameMatchKind: Sendable, Equatable, Hashable { - case exact - case fallback - case unresolved + case exact + case fallback + case unresolved } /// Resolution result for a sender node name. Pairs the resolved display name /// with the confidence level (`matchKind`) so the caller can disambiguate /// exact identity from proximity-based guesses. public struct NodeNameResolution: Sendable, Equatable, Hashable { - public let displayName: String - public let matchKind: NodeNameMatchKind + public let displayName: String + public let matchKind: NodeNameMatchKind - public var isFallback: Bool { - matchKind == .fallback - } + public var isFallback: Bool { + matchKind == .fallback + } - public init(displayName: String, matchKind: NodeNameMatchKind) { - self.displayName = displayName - self.matchKind = matchKind - } + public init(displayName: String, matchKind: NodeNameMatchKind) { + self.displayName = displayName + self.matchKind = matchKind + } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/PreviewLoadState.swift b/MC1Services/Sources/MC1Services/Models/Rendering/PreviewLoadState.swift index 46314596..8145f631 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/PreviewLoadState.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/PreviewLoadState.swift @@ -2,10 +2,10 @@ import Foundation /// State of link preview loading for a message. public enum PreviewLoadState: Sendable, Hashable { - case idle - case loading - case loaded - case noPreview - case disabled - case malwareWarning + case idle + case loading + case loaded + case noPreview + case disabled + case malwareWarning } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/SNRQuality.swift b/MC1Services/Sources/MC1Services/Models/Rendering/SNRQuality.swift index be1fd9d9..7bfd48a1 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/SNRQuality.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/SNRQuality.swift @@ -2,39 +2,39 @@ /// /// Standard 4-tier scale for signal quality indicators across the app. public enum SNRQuality: Sendable, Equatable { - case excellent // SNR > +6 dB - case good // SNR > +0 dB - case fair // SNR > -6 dB - case poor // SNR <= -6 dB - case unknown // nil SNR + case excellent // SNR > +6 dB + case good // SNR > +0 dB + case fair // SNR > -6 dB + case poor // SNR <= -6 dB + case unknown // nil SNR - public init(snr: Double?) { - guard let snr else { - self = .unknown - return - } - if snr > 6 { self = .excellent } else if snr > 0 { self = .good } else if snr > -6 { self = .fair } else { self = .poor } + public init(snr: Double?) { + guard let snr else { + self = .unknown + return } + if snr > 6 { self = .excellent } else if snr > 0 { self = .good } else if snr > -6 { self = .fair } else { self = .poor } + } - /// Bar level for SF Symbol `cellularbars` variableValue (0–1). - public var barLevel: Double { - switch self { - case .excellent: 1.0 - case .good: 0.75 - case .fair: 0.5 - case .poor: 0.25 - case .unknown: 0 - } + /// Bar level for SF Symbol `cellularbars` variableValue (0–1). + public var barLevel: Double { + switch self { + case .excellent: 1.0 + case .good: 0.75 + case .fair: 0.5 + case .poor: 0.25 + case .unknown: 0 } + } - /// Developer-facing English label for logs; UI uses the app target's localized `SNRQuality.localizedLabel`. - public var qualityLabel: String { - switch self { - case .excellent: "Excellent" - case .good: "Good" - case .fair: "Fair" - case .poor: "Weak" - case .unknown: "Unknown" - } + /// Developer-facing English label for logs; UI uses the app target's localized `SNRQuality.localizedLabel`. + public var qualityLabel: String { + switch self { + case .excellent: "Excellent" + case .good: "Good" + case .fair: "Fair" + case .poor: "Weak" + case .unknown: "Unknown" } + } } diff --git a/MC1Services/Sources/MC1Services/Models/RoomMessage.swift b/MC1Services/Sources/MC1Services/Models/RoomMessage.swift index e9b42183..a385a11a 100644 --- a/MC1Services/Sources/MC1Services/Models/RoomMessage.swift +++ b/MC1Services/Sources/MC1Services/Models/RoomMessage.swift @@ -5,219 +5,219 @@ import SwiftData /// Represents a message in a room server conversation. @Model public final class RoomMessage { - #Index( - [\.sessionID, \.timestamp], - [\.sessionID, \.deduplicationKey] + #Index( + [\.sessionID, \.timestamp], + [\.sessionID, \.deduplicationKey] + ) + + /// Unique message identifier + @Attribute(.unique) + public var id: UUID + + /// References RemoteNodeSession.id + public var sessionID: UUID + + /// 4-byte original author's public key prefix from server push + public var authorKeyPrefix: Data + + /// Resolved author name (from contacts or nil) + public var authorName: String? + + /// Message text content + public var text: String + + /// Message timestamp (server time) + public var timestamp: UInt32 + + /// Local creation date + public var createdAt: Date + + /// Whether this message was posted by the current user + public var isFromSelf: Bool + + /// Deduplication key combining timestamp, author, and content hash + /// Format: "\(timestamp)-\(authorPrefixHex)-\(contentHashPrefix)" + public var deduplicationKey: String + + /// Message delivery status (uses MessageStatus enum) + public var statusRawValue: Int = MessageStatus.delivered.rawValue + + /// ACK code returned by MeshCore when message was sent + public var ackCode: UInt32? + + /// Round-trip time in milliseconds when ACK received + public var roundTripTime: UInt32? + + /// Current retry attempt number + public var retryAttempt: Int = 0 + + /// Maximum retry attempts configured + public var maxRetryAttempts: Int = 0 + + public init( + id: UUID = UUID(), + sessionID: UUID, + authorKeyPrefix: Data, + authorName: String? = nil, + text: String, + timestamp: UInt32, + isFromSelf: Bool = false, + status: MessageStatus = .delivered + ) { + self.id = id + self.sessionID = sessionID + self.authorKeyPrefix = authorKeyPrefix + self.authorName = authorName + self.text = text + self.timestamp = timestamp + createdAt = Date() + self.isFromSelf = isFromSelf + statusRawValue = status.rawValue + deduplicationKey = Self.generateDeduplicationKey( + timestamp: timestamp, + authorKeyPrefix: authorKeyPrefix, + text: text ) - - /// Unique message identifier - @Attribute(.unique) - public var id: UUID - - /// References RemoteNodeSession.id - public var sessionID: UUID - - /// 4-byte original author's public key prefix from server push - public var authorKeyPrefix: Data - - /// Resolved author name (from contacts or nil) - public var authorName: String? - - /// Message text content - public var text: String - - /// Message timestamp (server time) - public var timestamp: UInt32 - - /// Local creation date - public var createdAt: Date - - /// Whether this message was posted by the current user - public var isFromSelf: Bool - - /// Deduplication key combining timestamp, author, and content hash - /// Format: "\(timestamp)-\(authorPrefixHex)-\(contentHashPrefix)" - public var deduplicationKey: String - - /// Message delivery status (uses MessageStatus enum) - public var statusRawValue: Int = MessageStatus.delivered.rawValue - - /// ACK code returned by MeshCore when message was sent - public var ackCode: UInt32? - - /// Round-trip time in milliseconds when ACK received - public var roundTripTime: UInt32? - - /// Current retry attempt number - public var retryAttempt: Int = 0 - - /// Maximum retry attempts configured - public var maxRetryAttempts: Int = 0 - - public init( - id: UUID = UUID(), - sessionID: UUID, - authorKeyPrefix: Data, - authorName: String? = nil, - text: String, - timestamp: UInt32, - isFromSelf: Bool = false, - status: MessageStatus = .delivered - ) { - self.id = id - self.sessionID = sessionID - self.authorKeyPrefix = authorKeyPrefix - self.authorName = authorName - self.text = text - self.timestamp = timestamp - self.createdAt = Date() - self.isFromSelf = isFromSelf - self.statusRawValue = status.rawValue - self.deduplicationKey = Self.generateDeduplicationKey( - timestamp: timestamp, - authorKeyPrefix: authorKeyPrefix, - text: text - ) - } - - /// Builds a model instance directly from a DTO, preserving the exact - /// createdAt, deduplicationKey, and delivery metadata from the source. - public convenience init(dto: RoomMessageDTO) { - self.init( - id: dto.id, - sessionID: dto.sessionID, - authorKeyPrefix: dto.authorKeyPrefix, - authorName: dto.authorName, - text: dto.text, - timestamp: dto.timestamp, - isFromSelf: dto.isFromSelf, - status: dto.status - ) - createdAt = dto.createdAt - deduplicationKey = dto.deduplicationKey - ackCode = dto.ackCode - roundTripTime = dto.roundTripTime - retryAttempt = dto.retryAttempt - maxRetryAttempts = dto.maxRetryAttempts - } + } + + /// Builds a model instance directly from a DTO, preserving the exact + /// createdAt, deduplicationKey, and delivery metadata from the source. + public convenience init(dto: RoomMessageDTO) { + self.init( + id: dto.id, + sessionID: dto.sessionID, + authorKeyPrefix: dto.authorKeyPrefix, + authorName: dto.authorName, + text: dto.text, + timestamp: dto.timestamp, + isFromSelf: dto.isFromSelf, + status: dto.status + ) + createdAt = dto.createdAt + deduplicationKey = dto.deduplicationKey + ackCode = dto.ackCode + roundTripTime = dto.roundTripTime + retryAttempt = dto.retryAttempt + maxRetryAttempts = dto.maxRetryAttempts + } } // MARK: - Computed Properties public extension RoomMessage { - /// Display name for author (resolved name or hex prefix) - var authorDisplayName: String { - authorName ?? authorKeyPrefix.map { String(format: "%02X", $0) }.joined() - } - - /// Date representation of timestamp - var date: Date { - Date(timeIntervalSince1970: TimeInterval(timestamp)) - } - - /// Delivery status - var status: MessageStatus { - MessageStatus(rawValue: statusRawValue) ?? .delivered - } + /// Display name for author (resolved name or hex prefix) + var authorDisplayName: String { + authorName ?? authorKeyPrefix.map { String(format: "%02X", $0) }.joined() + } + + /// Date representation of timestamp + var date: Date { + Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + /// Delivery status + var status: MessageStatus { + MessageStatus(rawValue: statusRawValue) ?? .delivered + } } // MARK: - Deduplication public extension RoomMessage { - /// Generate a deduplication key for message uniqueness. - /// Uses timestamp + author prefix + first 8 chars of content hash. - static func generateDeduplicationKey( - timestamp: UInt32, - authorKeyPrefix: Data, - text: String - ) -> String { - let authorHex = authorKeyPrefix.map { String(format: "%02X", $0) }.joined() - let contentHash = SHA256.hash(data: Data(text.utf8)) - let hashPrefix = contentHash.prefix(4).map { String(format: "%02X", $0) }.joined() - return "\(timestamp)-\(authorHex)-\(hashPrefix)" - } + /// Generate a deduplication key for message uniqueness. + /// Uses timestamp + author prefix + first 8 chars of content hash. + static func generateDeduplicationKey( + timestamp: UInt32, + authorKeyPrefix: Data, + text: String + ) -> String { + let authorHex = authorKeyPrefix.map { String(format: "%02X", $0) }.joined() + let contentHash = SHA256.hash(data: Data(text.utf8)) + let hashPrefix = contentHash.prefix(4).map { String(format: "%02X", $0) }.joined() + return "\(timestamp)-\(authorHex)-\(hashPrefix)" + } } // MARK: - Sendable DTO /// A sendable snapshot of RoomMessage for cross-actor transfers public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codable { - public let id: UUID - public var sessionID: UUID - public let authorKeyPrefix: Data - public let authorName: String? - public let text: String - public let timestamp: UInt32 - public let createdAt: Date - public let isFromSelf: Bool - public let deduplicationKey: String - public let statusRawValue: Int - public let ackCode: UInt32? - public let roundTripTime: UInt32? - public let retryAttempt: Int - public let maxRetryAttempts: Int - - public init(from model: RoomMessage) { - self.id = model.id - self.sessionID = model.sessionID - self.authorKeyPrefix = model.authorKeyPrefix - self.authorName = model.authorName - self.text = model.text - self.timestamp = model.timestamp - self.createdAt = model.createdAt - self.isFromSelf = model.isFromSelf - self.deduplicationKey = model.deduplicationKey - self.statusRawValue = model.statusRawValue - self.ackCode = model.ackCode - self.roundTripTime = model.roundTripTime - self.retryAttempt = model.retryAttempt - self.maxRetryAttempts = model.maxRetryAttempts - } - - public init( - id: UUID = UUID(), - sessionID: UUID, - authorKeyPrefix: Data, - authorName: String? = nil, - text: String, - timestamp: UInt32, - createdAt: Date = Date(), - isFromSelf: Bool = false, - status: MessageStatus = .delivered, - ackCode: UInt32? = nil, - roundTripTime: UInt32? = nil, - retryAttempt: Int = 0, - maxRetryAttempts: Int = 0 - ) { - self.id = id - self.sessionID = sessionID - self.authorKeyPrefix = authorKeyPrefix - self.authorName = authorName - self.text = text - self.timestamp = timestamp - self.createdAt = createdAt - self.isFromSelf = isFromSelf - self.statusRawValue = status.rawValue - self.ackCode = ackCode - self.roundTripTime = roundTripTime - self.retryAttempt = retryAttempt - self.maxRetryAttempts = maxRetryAttempts - self.deduplicationKey = RoomMessage.generateDeduplicationKey( - timestamp: timestamp, - authorKeyPrefix: authorKeyPrefix, - text: text - ) - } - - public var authorDisplayName: String { - authorName ?? authorKeyPrefix.map { String(format: "%02X", $0) }.joined() - } - - public var date: Date { - Date(timeIntervalSince1970: TimeInterval(timestamp)) - } - - public var status: MessageStatus { - MessageStatus(rawValue: statusRawValue) ?? .delivered - } + public let id: UUID + public var sessionID: UUID + public let authorKeyPrefix: Data + public let authorName: String? + public let text: String + public let timestamp: UInt32 + public let createdAt: Date + public let isFromSelf: Bool + public let deduplicationKey: String + public let statusRawValue: Int + public let ackCode: UInt32? + public let roundTripTime: UInt32? + public let retryAttempt: Int + public let maxRetryAttempts: Int + + public init(from model: RoomMessage) { + id = model.id + sessionID = model.sessionID + authorKeyPrefix = model.authorKeyPrefix + authorName = model.authorName + text = model.text + timestamp = model.timestamp + createdAt = model.createdAt + isFromSelf = model.isFromSelf + deduplicationKey = model.deduplicationKey + statusRawValue = model.statusRawValue + ackCode = model.ackCode + roundTripTime = model.roundTripTime + retryAttempt = model.retryAttempt + maxRetryAttempts = model.maxRetryAttempts + } + + public init( + id: UUID = UUID(), + sessionID: UUID, + authorKeyPrefix: Data, + authorName: String? = nil, + text: String, + timestamp: UInt32, + createdAt: Date = Date(), + isFromSelf: Bool = false, + status: MessageStatus = .delivered, + ackCode: UInt32? = nil, + roundTripTime: UInt32? = nil, + retryAttempt: Int = 0, + maxRetryAttempts: Int = 0 + ) { + self.id = id + self.sessionID = sessionID + self.authorKeyPrefix = authorKeyPrefix + self.authorName = authorName + self.text = text + self.timestamp = timestamp + self.createdAt = createdAt + self.isFromSelf = isFromSelf + statusRawValue = status.rawValue + self.ackCode = ackCode + self.roundTripTime = roundTripTime + self.retryAttempt = retryAttempt + self.maxRetryAttempts = maxRetryAttempts + deduplicationKey = RoomMessage.generateDeduplicationKey( + timestamp: timestamp, + authorKeyPrefix: authorKeyPrefix, + text: text + ) + } + + public var authorDisplayName: String { + authorName ?? authorKeyPrefix.map { String(format: "%02X", $0) }.joined() + } + + public var date: Date { + Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + public var status: MessageStatus { + MessageStatus(rawValue: statusRawValue) ?? .delivered + } } diff --git a/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift b/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift index c8c9d902..f65e2226 100644 --- a/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift +++ b/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift @@ -6,290 +6,296 @@ import SwiftData /// SwiftData model for persisted RX log packets. @Model final class RxLogEntry { - #Index( - [\.channelIndex, \.senderTimestamp], - [\.radioID, \.receivedAt] - ) - - @Attribute(.unique) - var id: UUID - - @Attribute(originalName: "deviceID") - var radioID: UUID - - var receivedAt: Date - - // From MeshCore ParsedRxLogData - var snr: Double? - var rssi: Int? - var routeType: Int - var payloadType: Int - var payloadVersion: Int - var transportCode: Data? - var pathLength: Int - var pathNodes: Data // Raw bytes, 1 byte per hop - var packetPayload: Data - var rawPayload: Data - - // Correlation key for "heard repeats" - var packetHash: String - - // App-level decoding - @Attribute(originalName: "channelHash") - var channelIndex: Int? - var channelName: String? - var decryptStatus: Int - var fromContactName: String? - var toContactName: String? - - /// Sender's timestamp from decrypted payload (Unix epoch seconds). - /// Only available for successfully decrypted channel messages. - var senderTimestamp: Int? - - /// Resolved flood region the sender transmitted under, derived from - /// `transport_codes[0]` at receive time. Nil when no known region matches. - /// Local-only: not part of any backup envelope. - var regionScope: String? - - /// Raw 4-bit payload-type nibble from the wire header. Persisted so the - /// region resolver can replay the exact firmware HMAC input on back-fill, - /// even for header values that map to `PayloadType.unknown`. - var payloadTypeBits: Int = 0 - - // Privacy: Never persisted — decrypted on demand - @Transient - var decodedText: String? - - init( - id: UUID = UUID(), - radioID: UUID, - receivedAt: Date = Date(), - snr: Double? = nil, - rssi: Int? = nil, - routeType: Int, - payloadType: Int, - payloadVersion: Int, - transportCode: Data? = nil, - pathLength: Int, - pathNodes: Data, - packetPayload: Data, - rawPayload: Data, - packetHash: String, - channelIndex: Int? = nil, - channelName: String? = nil, - decryptStatus: Int = DecryptStatus.notApplicable.rawValue, - fromContactName: String? = nil, - toContactName: String? = nil, - senderTimestamp: Int? = nil, - regionScope: String? = nil, - payloadTypeBits: Int = 0 - ) { - self.id = id - self.radioID = radioID - self.receivedAt = receivedAt - self.snr = snr - self.rssi = rssi - self.routeType = routeType - self.payloadType = payloadType - self.payloadVersion = payloadVersion - self.transportCode = transportCode - self.pathLength = pathLength - self.pathNodes = pathNodes - self.packetPayload = packetPayload - self.rawPayload = rawPayload - self.packetHash = packetHash - self.channelIndex = channelIndex - self.channelName = channelName - self.decryptStatus = decryptStatus - self.fromContactName = fromContactName - self.toContactName = toContactName - self.senderTimestamp = senderTimestamp - self.regionScope = regionScope - self.payloadTypeBits = payloadTypeBits - } + #Index( + [\.channelIndex, \.senderTimestamp], + [\.radioID, \.receivedAt] + ) + + @Attribute(.unique) + var id: UUID + + @Attribute(originalName: "deviceID") + var radioID: UUID + + var receivedAt: Date + + // From MeshCore ParsedRxLogData + var snr: Double? + var rssi: Int? + var routeType: Int + var payloadType: Int + var payloadVersion: Int + var transportCode: Data? + var pathLength: Int + var pathNodes: Data // Raw bytes, 1 byte per hop + var packetPayload: Data + var rawPayload: Data + + /// Correlation key for "heard repeats" + var packetHash: String + + // App-level decoding + @Attribute(originalName: "channelHash") + var channelIndex: Int? + var channelName: String? + var decryptStatus: Int + var fromContactName: String? + var toContactName: String? + + /// Sender's timestamp from decrypted payload (Unix epoch seconds). + /// Only available for successfully decrypted channel messages. + var senderTimestamp: Int? + + /// Resolved flood region the sender transmitted under, derived from + /// `transport_codes[0]` at receive time. Nil when no known region matches. + /// Local-only: not part of any backup envelope. + var regionScope: String? + + /// Raw 4-bit payload-type nibble from the wire header. Persisted so the + /// region resolver can replay the exact firmware HMAC input on back-fill, + /// even for header values that map to `PayloadType.unknown`. + var payloadTypeBits: Int = 0 + + // Privacy: Never persisted — decrypted on demand + @Transient + var decodedText: String? + + init( + id: UUID = UUID(), + radioID: UUID, + receivedAt: Date = Date(), + snr: Double? = nil, + rssi: Int? = nil, + routeType: Int, + payloadType: Int, + payloadVersion: Int, + transportCode: Data? = nil, + pathLength: Int, + pathNodes: Data, + packetPayload: Data, + rawPayload: Data, + packetHash: String, + channelIndex: Int? = nil, + channelName: String? = nil, + decryptStatus: Int = DecryptStatus.notApplicable.rawValue, + fromContactName: String? = nil, + toContactName: String? = nil, + senderTimestamp: Int? = nil, + regionScope: String? = nil, + payloadTypeBits: Int = 0 + ) { + self.id = id + self.radioID = radioID + self.receivedAt = receivedAt + self.snr = snr + self.rssi = rssi + self.routeType = routeType + self.payloadType = payloadType + self.payloadVersion = payloadVersion + self.transportCode = transportCode + self.pathLength = pathLength + self.pathNodes = pathNodes + self.packetPayload = packetPayload + self.rawPayload = rawPayload + self.packetHash = packetHash + self.channelIndex = channelIndex + self.channelName = channelName + self.decryptStatus = decryptStatus + self.fromContactName = fromContactName + self.toContactName = toContactName + self.senderTimestamp = senderTimestamp + self.regionScope = regionScope + self.payloadTypeBits = payloadTypeBits + } } /// Sendable DTO for cross-actor transfer of RxLogEntry data. public struct RxLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { - public let id: UUID - public var radioID: UUID - public let receivedAt: Date - public let snr: Double? - public let rssi: Int? - public let routeType: RouteType - public let payloadType: PayloadType - public let payloadVersion: UInt8 - public let transportCode: Data? - public let pathLength: UInt8 - public let pathNodes: Data - public let packetPayload: Data - public let rawPayload: Data - public let packetHash: String - - /// Channel attribution. Mutable so re-decryption of older entries can - /// record which channel's secret matched. - public var channelIndex: UInt8? - public var channelName: String? - - public let decryptStatus: DecryptStatus - public let fromContactName: String? - public let toContactName: String? - - /// Sender's timestamp from decrypted payload (Unix epoch seconds). - /// Only available for successfully decrypted channel messages. - /// Mutable to allow updating during re-decryption of older entries. - public var senderTimestamp: UInt32? - - /// Resolved flood region the sender transmitted under. Local-only. - public let regionScope: String? - - /// Raw 4-bit payload-type nibble from the wire header (matches firmware - /// HMAC input). Persisted alongside `regionScope` for back-fill replay. - public let payloadTypeBits: UInt8 - - // Transient - set by UI layer after decryption - public var decodedText: String? - - /// Initialize from SwiftData model. - init(from model: RxLogEntry) { - self.id = model.id - self.radioID = model.radioID - self.receivedAt = model.receivedAt - self.snr = model.snr - self.rssi = model.rssi - // Narrow each stored Int through the failable initializer so a corrupt or - // out-of-range row falls back instead of trapping inside the ModelActor. - self.routeType = UInt8(exactly: model.routeType).flatMap(RouteType.init(rawValue:)) ?? .flood - self.payloadType = UInt8(exactly: model.payloadType).flatMap(PayloadType.init(rawValue:)) ?? .unknown - self.payloadVersion = UInt8(exactly: model.payloadVersion) ?? 0 - self.transportCode = model.transportCode - self.pathLength = UInt8(exactly: model.pathLength) ?? 0 - self.pathNodes = model.pathNodes - self.packetPayload = model.packetPayload - self.rawPayload = model.rawPayload - self.packetHash = model.packetHash - self.channelIndex = model.channelIndex.flatMap { UInt8(exactly: $0) } - self.channelName = model.channelName - self.decryptStatus = DecryptStatus(rawValue: model.decryptStatus) ?? .notApplicable - self.fromContactName = model.fromContactName - self.toContactName = model.toContactName - self.senderTimestamp = model.senderTimestamp.flatMap { UInt32(exactly: $0) } - self.regionScope = model.regionScope - self.payloadTypeBits = UInt8(model.payloadTypeBits & 0x0F) - self.decodedText = model.decodedText - } - - /// Initialize from ParsedRxLogData (for new entries). - public init( - id: UUID = UUID(), - radioID: UUID, - receivedAt: Date = Date(), - from parsed: ParsedRxLogData, - channelIndex: UInt8? = nil, - channelName: String? = nil, - decryptStatus: DecryptStatus = .notApplicable, - fromContactName: String? = nil, - toContactName: String? = nil, - senderTimestamp: UInt32? = nil, - regionScope: String? = nil, - decodedText: String? = nil - ) { - self.id = id - self.radioID = radioID - self.receivedAt = receivedAt - self.snr = parsed.snr - self.rssi = parsed.rssi - self.routeType = parsed.routeType - self.payloadType = parsed.payloadType - self.payloadVersion = parsed.payloadVersion - self.transportCode = parsed.transportCode - self.pathLength = parsed.pathLength - self.pathNodes = Data(parsed.pathNodes) - self.packetPayload = parsed.packetPayload - self.rawPayload = parsed.rawPayload - self.packetHash = parsed.packetHash - self.channelIndex = channelIndex - self.channelName = channelName - self.decryptStatus = decryptStatus - self.fromContactName = fromContactName - self.toContactName = toContactName - self.senderTimestamp = senderTimestamp - self.regionScope = regionScope - self.payloadTypeBits = parsed.payloadTypeBits - self.decodedText = decodedText - } - - // MARK: - Computed Properties - - /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits. - public var pathHashSize: Int { - return decodePathLen(pathLength)?.hashSize ?? 1 - } - - /// Hop count decoded from pathLength. - public var hopCount: Int { - return decodePathLen(pathLength)?.hopCount ?? 0 - } - - /// Target node hashes extracted from TRACE payload. - /// Layout: [tag:4][auth:4][flags:1][hashes...], hash size from flags lower 2 bits. - public var traceTargetHashes: [Data]? { - guard payloadType == .trace, packetPayload.count > 9 else { return nil } - let pathSz = Int(packetPayload[8] & 0x03) - let hashSize = 1 << pathSz - let hashBytes = packetPayload.dropFirst(9) - guard !hashBytes.isEmpty, hashBytes.count % hashSize == 0 else { return nil } - return stride(from: hashBytes.startIndex, to: hashBytes.endIndex, by: hashSize).map { start in - Data(hashBytes[start..= dmPrefixSize * 2 else { return nil } - return Data(packetPayload[dmPrefixSize..= dmPrefixSize * 2 else { return nil } - return Data(packetPayload[0.. 9 else { return nil } + let pathSz = Int(packetPayload[8] & 0x03) + let hashSize = 1 << pathSz + let hashBytes = packetPayload.dropFirst(9) + guard !hashBytes.isEmpty, hashBytes.count % hashSize == 0 else { return nil } + return stride(from: hashBytes.startIndex, to: hashBytes.endIndex, by: hashSize).map { start in + Data(hashBytes[start..= dmPrefixSize * 2 else { return nil } + return Data(packetPayload[dmPrefixSize..= dmPrefixSize * 2 else { return nil } + return Data(packetPayload[0.. $1.date } - .prefix(10) - .reversed() - .map { $0.roundTripMs } - } - - /// The path as array of hash bytes - public var pathHashBytes: [UInt8] { - Array(pathBytes) - } + public let id: UUID + public var radioID: UUID + public let name: String + public let pathBytes: Data + public let hashSize: Int + public let createdDate: Date + public let runs: [TracePathRunDTO] + + init(from model: SavedTracePath) { + id = model.id + radioID = model.radioID + name = model.name + pathBytes = model.pathBytes + hashSize = model.hashSize + createdDate = model.createdDate + runs = model.runs.map { TracePathRunDTO(from: $0) } + } + + public init( + id: UUID, + radioID: UUID, + name: String, + pathBytes: Data, + hashSize: Int = 1, + createdDate: Date, + runs: [TracePathRunDTO] + ) { + self.id = id + self.radioID = radioID + self.name = name + self.pathBytes = pathBytes + self.hashSize = hashSize + self.createdDate = createdDate + self.runs = runs + } + + public var runCount: Int { + runs.count + } + + public var lastRunDate: Date? { + runs.max(by: { $0.date < $1.date })?.date + } + + public var averageRoundTripMs: Int? { + let successful = runs.filter(\.success) + guard !successful.isEmpty else { return nil } + let total = successful.reduce(0) { $0 + $1.roundTripMs } + return total / successful.count + } + + public var successRate: Int { + guard !runs.isEmpty else { return 100 } + let successful = runs.count(where: { $0.success }) + return (successful * 100) / runs.count + } + + /// Recent RTT values for sparkline (most recent 10) + public var recentRTTs: [Int] { + runs.filter(\.success) + .sorted { $0.date > $1.date } + .prefix(10) + .reversed() + .map(\.roundTripMs) + } + + /// The path as array of hash bytes + public var pathHashBytes: [UInt8] { + Array(pathBytes) + } } /// Sendable snapshot of TracePathRun public struct TracePathRunDTO: Sendable, Identifiable, Equatable, Hashable, Codable { - public let id: UUID - public let date: Date - public let success: Bool - public let roundTripMs: Int - public let hopsSNR: [Double] - - init(from model: TracePathRun) { - self.id = model.id - self.date = model.date - self.success = model.success - self.roundTripMs = model.roundTripMs - self.hopsSNR = model.hopsSNR - } - - public init(id: UUID, date: Date, success: Bool, roundTripMs: Int, hopsSNR: [Double]) { - self.id = id - self.date = date - self.success = success - self.roundTripMs = roundTripMs - self.hopsSNR = hopsSNR - } + public let id: UUID + public let date: Date + public let success: Bool + public let roundTripMs: Int + public let hopsSNR: [Double] + + init(from model: TracePathRun) { + id = model.id + date = model.date + success = model.success + roundTripMs = model.roundTripMs + hopsSNR = model.hopsSNR + } + + public init(id: UUID, date: Date, success: Bool, roundTripMs: Int, hopsSNR: [Double]) { + self.id = id + self.date = date + self.success = success + self.roundTripMs = roundTripMs + self.hopsSNR = hopsSNR + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/AppStateProvider.swift b/MC1Services/Sources/MC1Services/Protocols/AppStateProvider.swift index 2dd07b61..d1eabe9c 100644 --- a/MC1Services/Sources/MC1Services/Protocols/AppStateProvider.swift +++ b/MC1Services/Sources/MC1Services/Protocols/AppStateProvider.swift @@ -5,7 +5,7 @@ import Foundation /// Injected into services to avoid UIKit dependency in the service layer. /// Property is async to support cross-actor access from non-MainActor contexts. public protocol AppStateProvider: Sendable { - /// Returns true if the app is currently in the foreground (active state). - /// Async to allow MainActor-isolated implementations to be called from other actors. - var isInForeground: Bool { get async } + /// Returns true if the app is currently in the foreground (active state). + /// Async to allow MainActor-isolated implementations to be called from other actors. + var isInForeground: Bool { get async } } diff --git a/MC1Services/Sources/MC1Services/Protocols/ChannelServiceProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/ChannelServiceProtocol.swift index f486ce01..e9aed9ca 100644 --- a/MC1Services/Sources/MC1Services/Protocols/ChannelServiceProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/ChannelServiceProtocol.swift @@ -19,31 +19,30 @@ import MeshCore /// } /// ``` protocol ChannelServiceProtocol: Actor { + // MARK: - Channel Sync - // MARK: - Channel Sync + /// Fetches all channels for a device from the remote device. + /// - Parameters: + /// - radioID: The device UUID + /// - maxChannels: Maximum number of channels to fetch (from device capacity) + /// - usePipelinedRead: When `true`, reads channels via the bounded-window pipeline + /// (nRF52 over BLE, ESP32 over WiFi); when `false`, uses the serial acknowledged path. + /// - Returns: Sync result with number of channels synced + func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult - /// Fetches all channels for a device from the remote device. - /// - Parameters: - /// - radioID: The device UUID - /// - maxChannels: Maximum number of channels to fetch (from device capacity) - /// - usePipelinedRead: When `true`, reads channels via the bounded-window pipeline - /// (nRF52 over BLE, ESP32 over WiFi); when `false`, uses the serial acknowledged path. - /// - Returns: Sync result with number of channels synced - func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult - - /// Retries syncing only the channels that previously failed. - /// - Parameters: - /// - radioID: The device UUID - /// - indices: Channel indices to retry - /// - Returns: Sync result for the retried channels - func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult + /// Retries syncing only the channels that previously failed. + /// - Parameters: + /// - radioID: The device UUID + /// - indices: Channel indices to retry + /// - Returns: Sync result for the retried channels + func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult } extension ChannelServiceProtocol { - /// Convenience that defaults to the serial acknowledged read path. A default argument on - /// the protocol requirement itself is illegal and ignored by witness matching, so the - /// 2-arg form lives here while conformers implement only the 3-arg requirement. - func syncChannels(radioID: UUID, maxChannels: UInt8) async throws -> ChannelSyncResult { - try await syncChannels(radioID: radioID, maxChannels: maxChannels, usePipelinedRead: false) - } + /// Convenience that defaults to the serial acknowledged read path. A default argument on + /// the protocol requirement itself is illegal and ignored by witness matching, so the + /// 2-arg form lives here while conformers implement only the 3-arg requirement. + func syncChannels(radioID: UUID, maxChannels: UInt8) async throws -> ChannelSyncResult { + try await syncChannels(radioID: radioID, maxChannels: maxChannels, usePipelinedRead: false) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/ContactServiceProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/ContactServiceProtocol.swift index 190a740a..d8b9a81d 100644 --- a/MC1Services/Sources/MC1Services/Protocols/ContactServiceProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/ContactServiceProtocol.swift @@ -19,13 +19,12 @@ import MeshCore /// } /// ``` protocol ContactServiceProtocol: Actor { + // MARK: - Contact Sync - // MARK: - Contact Sync - - /// Sync all contacts from device - /// - Parameters: - /// - radioID: The device to sync from - /// - since: Optional date for incremental sync (only contacts modified after this time) - /// - Returns: Sync result with count and timestamp - func syncContacts(radioID: UUID, since: Date?) async throws -> ContactSyncResult + /// Sync all contacts from device + /// - Parameters: + /// - radioID: The device to sync from + /// - since: Optional date for incremental sync (only contacts modified after this time) + /// - Returns: Sync result with count and timestamp + func syncContacts(radioID: UUID, since: Date?) async throws -> ContactSyncResult } diff --git a/MC1Services/Sources/MC1Services/Protocols/MessagePollingServiceProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/MessagePollingServiceProtocol.swift index 869faad2..9e67ee4e 100644 --- a/MC1Services/Sources/MC1Services/Protocols/MessagePollingServiceProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/MessagePollingServiceProtocol.swift @@ -19,39 +19,38 @@ import MeshCore /// } /// ``` protocol MessagePollingServiceProtocol: Actor { + // MARK: - Message Polling - // MARK: - Message Polling + /// Poll all waiting messages from the device. + /// - Returns: Count of messages retrieved + func pollAllMessages() async throws -> Int - /// Poll all waiting messages from the device. - /// - Returns: Count of messages retrieved - func pollAllMessages() async throws -> Int + /// Wait for all pending message handlers to complete. + /// Call this after pollAllMessages() to ensure all messages are fully processed. + func waitForPendingHandlers(timeout: Duration) async -> Bool - /// Wait for all pending message handlers to complete. - /// Call this after pollAllMessages() to ensure all messages are fully processed. - func waitForPendingHandlers(timeout: Duration) async -> Bool + // MARK: - Auto-Fetch Lifecycle - // MARK: - Auto-Fetch Lifecycle + /// Start periodic message auto-fetch for the connected radio. + func startAutoFetch(radioID: UUID) async - /// Start periodic message auto-fetch for the connected radio. - func startAutoFetch(radioID: UUID) async + /// Pause auto-fetch (e.g. while a sync owns the transport). + func pauseAutoFetch() async - /// Pause auto-fetch (e.g. while a sync owns the transport). - func pauseAutoFetch() async + /// Resume auto-fetch after a pause. + func resumeAutoFetch() async - /// Resume auto-fetch after a pause. - func resumeAutoFetch() async + // MARK: - Ingestion Handlers - // MARK: - Ingestion Handlers + /// Install the handler invoked for each incoming direct message. + func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) - /// Install the handler invoked for each incoming direct message. - func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) + /// Install the handler invoked for each incoming channel message. + func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) - /// Install the handler invoked for each incoming channel message. - func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) + /// Install the handler invoked for each incoming signed room message. + func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) - /// Install the handler invoked for each incoming signed room message. - func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) - - /// Install the handler invoked for each incoming CLI response. - func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) + /// Install the handler invoked for each incoming CLI response. + func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) } diff --git a/MC1Services/Sources/MC1Services/Protocols/NotificationStringProvider.swift b/MC1Services/Sources/MC1Services/Protocols/NotificationStringProvider.swift index 8abf0ae1..80d98cca 100644 --- a/MC1Services/Sources/MC1Services/Protocols/NotificationStringProvider.swift +++ b/MC1Services/Sources/MC1Services/Protocols/NotificationStringProvider.swift @@ -5,51 +5,51 @@ import Foundation /// This protocol allows the app layer to inject localized strings into /// MC1Services without the service layer depending on L10n directly. public protocol NotificationStringProvider: Sendable { - /// Returns the notification title for a discovered contact of the given type. - /// - Parameter type: The type of contact discovered - /// - Returns: Localized notification title (e.g., "New Repeater Discovered") - func discoveryNotificationTitle(for type: ContactType) -> String + /// Returns the notification title for a discovered contact of the given type. + /// - Parameter type: The type of contact discovered + /// - Returns: Localized notification title (e.g., "New Repeater Discovered") + func discoveryNotificationTitle(for type: ContactType) -> String - /// Returns the localized title for the "Reply" notification action. - var replyActionTitle: String { get } + /// Returns the localized title for the "Reply" notification action. + var replyActionTitle: String { get } - /// Returns the localized title for the "Send" button in notification quick reply. - var sendButtonTitle: String { get } + /// Returns the localized title for the "Send" button in notification quick reply. + var sendButtonTitle: String { get } - /// Returns the localized placeholder for the notification quick reply text input. - var messagePlaceholder: String { get } + /// Returns the localized placeholder for the notification quick reply text input. + var messagePlaceholder: String { get } - /// Returns the localized title for the "Mark as Read" notification action. - var markAsReadActionTitle: String { get } + /// Returns the localized title for the "Mark as Read" notification action. + var markAsReadActionTitle: String { get } - /// Returns the localized title for a low battery warning notification. - var lowBatteryTitle: String { get } + /// Returns the localized title for a low battery warning notification. + var lowBatteryTitle: String { get } - /// Returns the localized body for a low battery warning notification. - /// - Parameters: - /// - deviceName: The name of the device with low battery - /// - percentage: The current battery percentage - /// - Returns: Localized notification body - func lowBatteryBody(deviceName: String, percentage: Int) -> String + /// Returns the localized body for a low battery warning notification. + /// - Parameters: + /// - deviceName: The name of the device with low battery + /// - percentage: The current battery percentage + /// - Returns: Localized notification body + func lowBatteryBody(deviceName: String, percentage: Int) -> String - /// Returns the localized title for a failed quick-reply notification. - var quickReplyFailedTitle: String { get } + /// Returns the localized title for a failed quick-reply notification. + var quickReplyFailedTitle: String { get } - /// Returns the localized body for a failed quick-reply notification. - /// - Parameter conversationName: The contact or channel display name - /// - Returns: Localized notification body - func quickReplyFailedBody(conversationName: String) -> String + /// Returns the localized body for a failed quick-reply notification. + /// - Parameter conversationName: The contact or channel display name + /// - Returns: Localized notification body + func quickReplyFailedBody(conversationName: String) -> String - /// Returns the localized fallback display name for a discovered contact with no advertised name. - var unknownContactName: String { get } + /// Returns the localized fallback display name for a discovered contact with no advertised name. + var unknownContactName: String { get } - /// Returns the localized fallback display name for a channel with no stored name. - /// - Parameter index: The channel's slot index - func defaultChannelName(index: Int) -> String + /// Returns the localized fallback display name for a channel with no stored name. + /// - Parameter index: The channel's slot index + func defaultChannelName(index: Int) -> String - /// Returns the localized body for a reaction notification. - /// - Parameters: - /// - emoji: The reaction emoji - /// - messagePreview: Truncated preview of the reacted-to message - func reactionNotificationBody(emoji: String, messagePreview: String) -> String + /// Returns the localized body for a reaction notification. + /// - Parameters: + /// - emoji: The reaction emoji + /// - messagePreview: Truncated preview of the reacted-to message + func reactionNotificationBody(emoji: String, messagePreview: String) -> String } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/ChannelPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/ChannelPersisting.swift index 088ea70a..b36337be 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/ChannelPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/ChannelPersisting.swift @@ -3,103 +3,102 @@ import MeshCore /// Store operations for channel rows, channel unread state, and channel mention tracking. public protocol ChannelPersisting: Actor { + // MARK: - Channel Operations - // MARK: - Channel Operations + /// Fetch all channels for a device + func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] - /// Fetch all channels for a device - func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] + /// Fetch a channel by index + func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? - /// Fetch a channel by index - func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? + /// Fetch a channel by ID + func fetchChannel(id: UUID) async throws -> ChannelDTO? - /// Fetch a channel by ID - func fetchChannel(id: UUID) async throws -> ChannelDTO? + /// Save or update a channel from ChannelInfo + @discardableResult + func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID - /// Save or update a channel from ChannelInfo - @discardableResult - func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID + /// Save or update a channel from DTO + func saveChannel(_ dto: ChannelDTO) async throws - /// Save or update a channel from DTO - func saveChannel(_ dto: ChannelDTO) async throws + /// Persists a full channel-sync pass in a single transaction: upserts each configured + /// `ChannelInfo` (matched by `(radioID, index)`), deletes stale local rows at + /// `unconfiguredIndices`, and (when `pruneBeyond` is non-nil) deletes orphaned rows + /// whose index is `>= pruneBeyond`. Rows at indices that are neither configured nor + /// unconfigured (e.g. skipped by the circuit breaker) are left untouched. Returns all + /// channels for the radio after the write, sorted by index. + func batchSaveChannels( + radioID: UUID, + configured: [ChannelInfo], + unconfiguredIndices: [UInt8], + pruneBeyond maxChannels: UInt8? + ) async throws -> [ChannelDTO] - /// Persists a full channel-sync pass in a single transaction: upserts each configured - /// `ChannelInfo` (matched by `(radioID, index)`), deletes stale local rows at - /// `unconfiguredIndices`, and (when `pruneBeyond` is non-nil) deletes orphaned rows - /// whose index is `>= pruneBeyond`. Rows at indices that are neither configured nor - /// unconfigured (e.g. skipped by the circuit breaker) are left untouched. Returns all - /// channels for the radio after the write, sorted by index. - func batchSaveChannels( - radioID: UUID, - configured: [ChannelInfo], - unconfiguredIndices: [UInt8], - pruneBeyond maxChannels: UInt8? - ) async throws -> [ChannelDTO] + /// Delete a channel + func deleteChannel(id: UUID) async throws - /// Delete a channel - func deleteChannel(id: UUID) async throws + /// Delete all messages for a channel + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws - /// Delete all messages for a channel - func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws + /// Update channel's last message info (nil clears the date) + func updateChannelLastMessage(channelID: UUID, date: Date?) async throws - /// Update channel's last message info (nil clears the date) - func updateChannelLastMessage(channelID: UUID, date: Date?) async throws + /// Increment unread count for a channel + func incrementChannelUnreadCount(channelID: UUID) async throws - /// Increment unread count for a channel - func incrementChannelUnreadCount(channelID: UUID) async throws + /// Clear unread count for a channel + func clearChannelUnreadCount(channelID: UUID) async throws - /// Clear unread count for a channel - func clearChannelUnreadCount(channelID: UUID) async throws + /// Clear unread count for a channel by radioID and index + /// More efficient than fetching the full channel DTO when only clearing unread + func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws - /// Clear unread count for a channel by radioID and index - /// More efficient than fetching the full channel DTO when only clearing unread - func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws + /// Sets the notification level for a channel + func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws - /// Sets the notification level for a channel - func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws + /// Sets the notification level for a remote node session + func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws - /// Sets the notification level for a remote node session - func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws + // MARK: - Channel Mention Tracking - // MARK: - Channel Mention Tracking + /// Increment unread mention count for a channel + func incrementChannelUnreadMentionCount(channelID: UUID) async throws - /// Increment unread mention count for a channel - func incrementChannelUnreadMentionCount(channelID: UUID) async throws + /// Decrement unread mention count for a channel + func decrementChannelUnreadMentionCount(channelID: UUID) async throws - /// Decrement unread mention count for a channel - func decrementChannelUnreadMentionCount(channelID: UUID) async throws + /// Clear unread mention count for a channel + func clearChannelUnreadMentionCount(channelID: UUID) async throws - /// Clear unread mention count for a channel - func clearChannelUnreadMentionCount(channelID: UUID) async throws - - /// Fetch unseen mention message IDs for a channel, ordered oldest-first - func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] + /// Fetch unseen mention message IDs for a channel, ordered oldest-first + func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] } // MARK: - Default Parameter Values extension ChannelPersisting { - /// Default channel-sync persistence built from the per-item operations. The concrete - /// `PersistenceStore` overrides this with a single-transaction implementation; this - /// fallback keeps lightweight test stubs conforming without their own batch logic. - func batchSaveChannels( - radioID: UUID, - configured: [ChannelInfo], - unconfiguredIndices: [UInt8], - pruneBeyond maxChannels: UInt8? - ) async throws -> [ChannelDTO] { - for info in configured { - _ = try await saveChannel(radioID: radioID, from: info) - } - for index in unconfiguredIndices { - if let stale = try await fetchChannel(radioID: radioID, index: index) { - try await deleteChannel(id: stale.id) - } - } - if let maxChannels { - for channel in try await fetchChannels(radioID: radioID) where channel.index >= maxChannels { - try await deleteChannel(id: channel.id) - } - } - return try await fetchChannels(radioID: radioID) + /// Default channel-sync persistence built from the per-item operations. The concrete + /// `PersistenceStore` overrides this with a single-transaction implementation; this + /// fallback keeps lightweight test stubs conforming without their own batch logic. + func batchSaveChannels( + radioID: UUID, + configured: [ChannelInfo], + unconfiguredIndices: [UInt8], + pruneBeyond maxChannels: UInt8? + ) async throws -> [ChannelDTO] { + for info in configured { + _ = try await saveChannel(radioID: radioID, from: info) + } + for index in unconfiguredIndices { + if let stale = try await fetchChannel(radioID: radioID, index: index) { + try await deleteChannel(id: stale.id) + } + } + if let maxChannels { + for channel in try await fetchChannels(radioID: radioID) where channel.index >= maxChannels { + try await deleteChannel(id: channel.id) + } } + return try await fetchChannels(radioID: radioID) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift index a32e479c..edad7ccc 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift @@ -2,111 +2,110 @@ import Foundation /// Store operations for contact rows, contact mention tracking, and blocked senders. public protocol ContactPersisting: Actor { + // MARK: - Contact Operations - // MARK: - Contact Operations + /// Fetch all confirmed contacts for a device + func fetchContacts(radioID: UUID) async throws -> [ContactDTO] - /// Fetch all confirmed contacts for a device - func fetchContacts(radioID: UUID) async throws -> [ContactDTO] + /// Fetch contacts with recent messages + func fetchConversations(radioID: UUID) async throws -> [ContactDTO] - /// Fetch contacts with recent messages - func fetchConversations(radioID: UUID) async throws -> [ContactDTO] + /// Fetch a contact by ID + func fetchContact(id: UUID) async throws -> ContactDTO? - /// Fetch a contact by ID - func fetchContact(id: UUID) async throws -> ContactDTO? + /// Fetch a contact by public key + func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? - /// Fetch a contact by public key - func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? + /// Fetch a contact by public key prefix + func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? - /// Fetch a contact by public key prefix - func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? + /// Fetch all contacts with their public keys for crypto operations. + /// Returns dictionary mapping 1-byte public key prefix to array of full 32-byte public keys. + /// Multiple contacts may share the same prefix byte, so we store all of them. + func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] - /// Fetch all contacts with their public keys for crypto operations. - /// Returns dictionary mapping 1-byte public key prefix to array of full 32-byte public keys. - /// Multiple contacts may share the same prefix byte, so we store all of them. - func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] + /// Find contact display name by 4-byte or 6-byte public key prefix. + /// Searches across all devices, because room message authors may only be + /// known from a previously-connected radio's contact list. + func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? - /// Find contact display name by 4-byte or 6-byte public key prefix. - /// Searches across all devices, because room message authors may only be - /// known from a previously-connected radio's contact list. - func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? + /// Find contact by 32-byte public key. Searches across all devices, + /// for routing hints where the contact may exist under another device's ID. + func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? - /// Find contact by 32-byte public key. Searches across all devices, - /// for routing hints where the contact may exist under another device's ID. - func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? + /// Save or update a contact from a ContactFrame + @discardableResult + func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID - /// Save or update a contact from a ContactFrame - @discardableResult - func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID + /// Save or update a contact from DTO + func saveContact(_ dto: ContactDTO) async throws - /// Save or update a contact from DTO - func saveContact(_ dto: ContactDTO) async throws + /// Upsert contacts from frames in a single transaction, matching local rows by + /// `(radioID, publicKey)`. Returns the number of frames persisted. + @discardableResult + func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) async throws -> Int - /// Upsert contacts from frames in a single transaction, matching local rows by - /// `(radioID, publicKey)`. Returns the number of frames persisted. - @discardableResult - func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) async throws -> Int + /// Delete a contact + func deleteContact(id: UUID) async throws - /// Delete a contact - func deleteContact(id: UUID) async throws + /// Update contact's last message info (nil clears the date, removing from conversations list) + func updateContactLastMessage(contactID: UUID, date: Date?) async throws - /// Update contact's last message info (nil clears the date, removing from conversations list) - func updateContactLastMessage(contactID: UUID, date: Date?) async throws + /// Increment unread count for a contact + func incrementUnreadCount(contactID: UUID) async throws - /// Increment unread count for a contact - func incrementUnreadCount(contactID: UUID) async throws + /// Clear unread count for a contact + func clearUnreadCount(contactID: UUID) async throws - /// Clear unread count for a contact - func clearUnreadCount(contactID: UUID) async throws + // MARK: - Mention Tracking - // MARK: - Mention Tracking + /// Mark a mention as seen + func markMentionSeen(messageID: UUID) async throws - /// Mark a mention as seen - func markMentionSeen(messageID: UUID) async throws + /// Increment unread mention count for a contact + func incrementUnreadMentionCount(contactID: UUID) async throws - /// Increment unread mention count for a contact - func incrementUnreadMentionCount(contactID: UUID) async throws + /// Decrement unread mention count for a contact + func decrementUnreadMentionCount(contactID: UUID) async throws - /// Decrement unread mention count for a contact - func decrementUnreadMentionCount(contactID: UUID) async throws + /// Clear unread mention count for a contact + func clearUnreadMentionCount(contactID: UUID) async throws - /// Clear unread mention count for a contact - func clearUnreadMentionCount(contactID: UUID) async throws + /// Fetch unseen mention message IDs for a contact, ordered oldest-first + func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] - /// Fetch unseen mention message IDs for a contact, ordered oldest-first - func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] + /// Delete all messages for a contact + func deleteMessagesForContact(contactID: UUID) async throws - /// Delete all messages for a contact - func deleteMessagesForContact(contactID: UUID) async throws + /// Delete all channel messages from a specific sender for a device + func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws - /// Delete all channel messages from a specific sender for a device - func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws + /// Fetch blocked contacts for a device + func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] - /// Fetch blocked contacts for a device - func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] + // MARK: - Blocked Channel Senders - // MARK: - Blocked Channel Senders + /// Save a blocked channel sender name (upserts to prevent duplicates) + func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws - /// Save a blocked channel sender name (upserts to prevent duplicates) - func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws + /// Delete a blocked channel sender by device and name + func deleteBlockedChannelSender(radioID: UUID, name: String) async throws - /// Delete a blocked channel sender by device and name - func deleteBlockedChannelSender(radioID: UUID, name: String) async throws - - /// Fetch all blocked channel senders for a device - func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] + /// Fetch all blocked channel senders for a device + func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] } // MARK: - Default Parameter Values extension ContactPersisting { - /// Default batch upsert built from the per-item `saveContact` path. The concrete - /// `PersistenceStore` overrides this with a single-transaction implementation; this - /// fallback keeps lightweight test stubs conforming without their own batch logic. - @discardableResult - func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) async throws -> Int { - for frame in frames { - _ = try await saveContact(radioID: radioID, from: frame) - } - return frames.count + /// Default batch upsert built from the per-item `saveContact` path. The concrete + /// `PersistenceStore` overrides this with a single-transaction implementation; this + /// fallback keeps lightweight test stubs conforming without their own batch logic. + @discardableResult + func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) async throws -> Int { + for frame in frames { + _ = try await saveContact(radioID: radioID, from: frame) } + return frames.count + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/DebugLogPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/DebugLogPersisting.swift index 9d7a0096..d55b61d8 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/DebugLogPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/DebugLogPersisting.swift @@ -2,19 +2,18 @@ import Foundation /// Store operations for debug log entries. public protocol DebugLogPersisting: Actor { + /// Save a batch of debug log entries + func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws - /// Save a batch of debug log entries - func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws + /// Fetch debug log entries since a given date + func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] - /// Fetch debug log entries since a given date - func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] + /// Count all debug log entries + func countDebugLogEntries() async throws -> Int - /// Count all debug log entries - func countDebugLogEntries() async throws -> Int + /// Prune debug log entries, keeping only the most recent + func pruneDebugLogEntries(keepCount: Int) async throws - /// Prune debug log entries, keeping only the most recent - func pruneDebugLogEntries(keepCount: Int) async throws - - /// Clear all debug log entries - func clearDebugLogEntries() async throws + /// Clear all debug log entries + func clearDebugLogEntries() async throws } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/DevicePersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/DevicePersisting.swift index 29e13708..27aa1191 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/DevicePersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/DevicePersisting.swift @@ -2,17 +2,16 @@ import Foundation /// Store operations for device rows. public protocol DevicePersisting: Actor { + /// Fetch a device by ID + func fetchDevice(id: UUID) async throws -> DeviceDTO? - /// Fetch a device by ID - func fetchDevice(id: UUID) async throws -> DeviceDTO? + /// Fetch a device by radio ID + func fetchDevice(radioID: UUID) async throws -> DeviceDTO? - /// Fetch a device by radio ID - func fetchDevice(radioID: UUID) async throws -> DeviceDTO? + /// Update the lastContactSync timestamp for a device, used to track + /// incremental contact sync progress + func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws - /// Update the lastContactSync timestamp for a device, used to track - /// incremental contact sync progress - func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws - - /// Save or update a device - func saveDevice(_ dto: DeviceDTO) async throws + /// Save or update a device + func saveDevice(_ dto: DeviceDTO) async throws } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/DiscoveredNodePersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/DiscoveredNodePersisting.swift index 5f9f6e34..ed8cabed 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/DiscoveredNodePersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/DiscoveredNodePersisting.swift @@ -2,28 +2,27 @@ import Foundation /// Store operations for discovered (not yet added) mesh nodes. public protocol DiscoveredNodePersisting: Actor { + /// Insert or update a discovered node from an advertisement frame. + /// Updates lastHeard timestamp if node already exists. + /// - Returns: Tuple of (DiscoveredNodeDTO, isNew) where isNew is true only if node was newly created + func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) - /// Insert or update a discovered node from an advertisement frame. - /// Updates lastHeard timestamp if node already exists. - /// - Returns: Tuple of (DiscoveredNodeDTO, isNew) where isNew is true only if node was newly created - func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) + /// Stamp the inbound advert hop count onto an existing discovered node, keyed by public key. + /// Tracks the latest advert: a newer advertTimestamp always replaces the stored count; + /// equal timestamps keep the closest copy of that broadcast. No-op if no matching row exists; + /// the next advert upsert creates it. + func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws - /// Stamp the inbound advert hop count onto an existing discovered node, keyed by public key. - /// Tracks the latest advert: a newer advertTimestamp always replaces the stored count; - /// equal timestamps keep the closest copy of that broadcast. No-op if no matching row exists; - /// the next advert upsert creates it. - func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws + /// Fetch all discovered nodes for a device. + func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] - /// Fetch all discovered nodes for a device. - func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] + /// Delete a discovered node by ID. + func deleteDiscoveredNode(id: UUID) async throws - /// Delete a discovered node by ID. - func deleteDiscoveredNode(id: UUID) async throws + /// Clear all discovered nodes for a device. + func clearDiscoveredNodes(radioID: UUID) async throws - /// Clear all discovered nodes for a device. - func clearDiscoveredNodes(radioID: UUID) async throws - - /// Batch fetch all contact public keys for efficient "added" state lookup. - /// Returns public keys of confirmed (non-discovered) contacts only. - func fetchContactPublicKeys(radioID: UUID) async throws -> Set + /// Batch fetch all contact public keys for efficient "added" state lookup. + /// Returns public keys of confirmed (non-discovered) contacts only. + func fetchContactPublicKeys(radioID: UUID) async throws -> Set } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/HeardRepeatPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/HeardRepeatPersisting.swift index 996e61e7..b612888a 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/HeardRepeatPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/HeardRepeatPersisting.swift @@ -2,25 +2,24 @@ import Foundation /// Store operations for correlating sent channel messages with heard repeats. public protocol HeardRepeatPersisting: Actor { + /// Find a sent channel message matching criteria within a time window + func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? - /// Find a sent channel message matching criteria within a time window - func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? + /// Save a message repeat entry + func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws - /// Save a message repeat entry - func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws + /// Fetch all repeats for a message + func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] - /// Fetch all repeats for a message - func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] + /// Delete all repeats for a message + func deleteMessageRepeats(messageID: UUID) async throws - /// Delete all repeats for a message - func deleteMessageRepeats(messageID: UUID) async throws + /// Check if a repeat exists for the given RX log entry + func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool - /// Check if a repeat exists for the given RX log entry - func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool + /// Increment heard repeats count and return new count + func incrementMessageHeardRepeats(id: UUID) async throws -> Int - /// Increment heard repeats count and return new count - func incrementMessageHeardRepeats(id: UUID) async throws -> Int - - /// Increment send count and return new count - func incrementMessageSendCount(id: UUID) async throws -> Int + /// Increment send count and return new count + func incrementMessageSendCount(id: UUID) async throws -> Int } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/LinkPreviewPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/LinkPreviewPersisting.swift index d32c3b99..9ea1f9b4 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/LinkPreviewPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/LinkPreviewPersisting.swift @@ -2,10 +2,9 @@ import Foundation /// Store operations for cached link preview data. public protocol LinkPreviewPersisting: Actor { + /// Fetch link preview data by URL + func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? - /// Fetch link preview data by URL - func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? - - /// Save or update link preview data - func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws + /// Save or update link preview data + func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift index e591ea4b..a1d7e1f2 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift @@ -2,163 +2,162 @@ import Foundation /// Store operations for message rows and their pending-send queue entries. public protocol MessagePersisting: Actor { - - // MARK: - Message Operations - - /// Check if a message with this deduplication key already exists for the given radio. - /// - /// Dedup is scoped per-radio because the content-based key is radio-agnostic, and two - /// companion radios in the same area can receive the same over-the-air packet. Without - /// the `radioID` filter the second radio's sync would be suppressed, leaving nothing to - /// display when the user switches devices. - func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool - - /// Save a new message - func saveMessage(_ dto: MessageDTO) async throws - - /// Fetch a message by ID - func fetchMessage(id: UUID) async throws -> MessageDTO? - - /// Fetch messages for a contact - func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] - - /// Fetch messages for a channel - func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] - - /// Batch fetch last messages for multiple contacts in a single actor call. - /// Avoids N actor hops when loading message previews for the conversation list. - func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] - - /// Batch fetch last messages for multiple channels in a single actor call. - /// Each tuple contains (radioID, channelIndex, id) where id is used as the dictionary key. - func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] - - /// Finds a channel message matching a parsed reaction within a timestamp window - func findChannelMessageForReaction( - radioID: UUID, - channelIndex: UInt8, - parsedReaction: ParsedReaction, - localNodeName: String?, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> MessageDTO? - - /// Fetches channel message candidates for meshcore-open reaction matching - func fetchChannelMessageCandidates( - radioID: UUID, - channelIndex: UInt8, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> [MessageDTO] - - /// Fetches DM message candidates for meshcore-open reaction matching - func fetchDMMessageCandidates( - radioID: UUID, - contactID: UUID, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> [MessageDTO] - - /// Finds a DM message matching a reaction by hash within a timestamp window - func findDMMessageForReaction( - radioID: UUID, - contactID: UUID, - messageHash: String, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> MessageDTO? - - /// Update message status - func updateMessageStatus(id: UUID, status: MessageStatus) async throws - - /// Update message status unless delivery has already won the race. - /// - /// - Returns: `true` if the row's status was changed, `false` if no row was - /// updated (either the row is already `.delivered`, or no row exists for - /// the given `id`). Callers must gate failure side effects (e.g., the - /// `MessageStatusEvent.failed` broadcast, UI toasts) on the return value - /// so they do not surface a `.failed` event for a delivered or absent row. - func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool - - /// Clear a spent retry-loop status back to `.sent`, leaving terminal rows - /// untouched. - /// - /// Writes `.sent` only when the row is neither `.delivered` nor `.failed`, - /// so a late ACK can still upgrade the surviving `.sent` row to `.delivered` - /// while the expiry checker remains the single `.failed` writer. - /// - /// - Returns: `true` if the row moved to `.sent`, `false` if it was already - /// terminal (`.delivered`/`.failed`) or no row exists. Callers must gate - /// the `.sent` status broadcast on the return value. - func clearRetryingToSent(id: UUID) async throws -> Bool - - /// Read-only diagnostic: whether an outgoing direct-message row persists - /// `.sent` with this `ackCode`. Used to flag a stuck-`.sent` orphan (a DM - /// that lost its `pendingAcks` entry to a teardown) when a late ACK arrives - /// with no live pending entry. Observability only; never gates behavior. - func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool - - /// Update message ACK info - func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws - - /// Update message retry status - func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws - - /// Update message timestamp (for resending) - func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws - - /// Update heard repeats count - func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws - - /// Mark a message as read - func markMessageAsRead(id: UUID) async throws - - /// Update link preview data for a message - func updateMessageLinkPreview( - id: UUID, - url: String?, - title: String?, - imageData: Data?, - iconData: Data?, - fetched: Bool - ) throws - - // MARK: - Pending Sends - - /// Insert (or update if `dto.id` already exists) a pending send row using the sequence value from the DTO. - func upsertPendingSend(_ dto: PendingSendDTO) async throws - - /// Insert a new pending send row, atomically assigning the next sequence number for the row's radio. - /// Returns the assigned sequence number. - func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int - - /// Fetch all pending sends for a given radio, ordered by sequence ascending. - func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] - - /// Delete a pending send by row id. No-op if the id is not present. - func deletePendingSend(id: UUID) async throws - - /// Delete every pending send row whose `messageID` matches. No-op if no rows match. - /// `messageID` is globally unique across all radios, so scoping by radio would be - /// redundant and could miss stale rows from prior pairings. - func deletePendingSendsForMessage(messageID: UUID) async throws - - /// `messageID` is globally unique. - func hasPendingSend(messageID: UUID) async throws -> Bool - - /// Increments the `attemptCount` for the `PendingSend` row matching - /// `messageID`. Returns the new count, or `nil` if no row matched. - /// Throws on SwiftData read/write failure; callers must treat a thrown - /// error as transient (park + retry) and `nil` as terminal (deleted row). - @discardableResult - func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? + // MARK: - Message Operations + + /// Check if a message with this deduplication key already exists for the given radio. + /// + /// Dedup is scoped per-radio because the content-based key is radio-agnostic, and two + /// companion radios in the same area can receive the same over-the-air packet. Without + /// the `radioID` filter the second radio's sync would be suppressed, leaving nothing to + /// display when the user switches devices. + func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool + + /// Save a new message + func saveMessage(_ dto: MessageDTO) async throws + + /// Fetch a message by ID + func fetchMessage(id: UUID) async throws -> MessageDTO? + + /// Fetch messages for a contact + func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] + + /// Fetch messages for a channel + func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] + + /// Batch fetch last messages for multiple contacts in a single actor call. + /// Avoids N actor hops when loading message previews for the conversation list. + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] + + /// Batch fetch last messages for multiple channels in a single actor call. + /// Each tuple contains (radioID, channelIndex, id) where id is used as the dictionary key. + func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] + + /// Finds a channel message matching a parsed reaction within a timestamp window + func findChannelMessageForReaction( + radioID: UUID, + channelIndex: UInt8, + parsedReaction: ParsedReaction, + localNodeName: String?, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> MessageDTO? + + /// Fetches channel message candidates for meshcore-open reaction matching + func fetchChannelMessageCandidates( + radioID: UUID, + channelIndex: UInt8, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] + + /// Fetches DM message candidates for meshcore-open reaction matching + func fetchDMMessageCandidates( + radioID: UUID, + contactID: UUID, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] + + /// Finds a DM message matching a reaction by hash within a timestamp window + func findDMMessageForReaction( + radioID: UUID, + contactID: UUID, + messageHash: String, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> MessageDTO? + + /// Update message status + func updateMessageStatus(id: UUID, status: MessageStatus) async throws + + /// Update message status unless delivery has already won the race. + /// + /// - Returns: `true` if the row's status was changed, `false` if no row was + /// updated (either the row is already `.delivered`, or no row exists for + /// the given `id`). Callers must gate failure side effects (e.g., the + /// `MessageStatusEvent.failed` broadcast, UI toasts) on the return value + /// so they do not surface a `.failed` event for a delivered or absent row. + func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool + + /// Clear a spent retry-loop status back to `.sent`, leaving terminal rows + /// untouched. + /// + /// Writes `.sent` only when the row is neither `.delivered` nor `.failed`, + /// so a late ACK can still upgrade the surviving `.sent` row to `.delivered` + /// while the expiry checker remains the single `.failed` writer. + /// + /// - Returns: `true` if the row moved to `.sent`, `false` if it was already + /// terminal (`.delivered`/`.failed`) or no row exists. Callers must gate + /// the `.sent` status broadcast on the return value. + func clearRetryingToSent(id: UUID) async throws -> Bool + + /// Read-only diagnostic: whether an outgoing direct-message row persists + /// `.sent` with this `ackCode`. Used to flag a stuck-`.sent` orphan (a DM + /// that lost its `pendingAcks` entry to a teardown) when a late ACK arrives + /// with no live pending entry. Observability only; never gates behavior. + func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool + + /// Update message ACK info + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws + + /// Update message retry status + func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws + + /// Update message timestamp (for resending) + func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws + + /// Update heard repeats count + func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws + + /// Mark a message as read + func markMessageAsRead(id: UUID) async throws + + /// Update link preview data for a message + func updateMessageLinkPreview( + id: UUID, + url: String?, + title: String?, + imageData: Data?, + iconData: Data?, + fetched: Bool + ) throws + + // MARK: - Pending Sends + + /// Insert (or update if `dto.id` already exists) a pending send row using the sequence value from the DTO. + func upsertPendingSend(_ dto: PendingSendDTO) async throws + + /// Insert a new pending send row, atomically assigning the next sequence number for the row's radio. + /// Returns the assigned sequence number. + func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int + + /// Fetch all pending sends for a given radio, ordered by sequence ascending. + func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] + + /// Delete a pending send by row id. No-op if the id is not present. + func deletePendingSend(id: UUID) async throws + + /// Delete every pending send row whose `messageID` matches. No-op if no rows match. + /// `messageID` is globally unique across all radios, so scoping by radio would be + /// redundant and could miss stale rows from prior pairings. + func deletePendingSendsForMessage(messageID: UUID) async throws + + /// `messageID` is globally unique. + func hasPendingSend(messageID: UUID) async throws -> Bool + + /// Increments the `attemptCount` for the `PendingSend` row matching + /// `messageID`. Returns the new count, or `nil` if no row matched. + /// Throws on SwiftData read/write failure; callers must treat a thrown + /// error as transient (park + retry) and `nil` as terminal (deleted row). + @discardableResult + func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? } // MARK: - Default Parameter Values extension MessagePersisting { - /// Update message ACK info with no round-trip time - func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus) async throws { - try await updateMessageAck(id: id, ackCode: ackCode, status: status, roundTripTime: nil) - } + /// Update message ACK info with no round-trip time + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus) async throws { + try await updateMessageAck(id: id, ackCode: ackCode, status: status, roundTripTime: nil) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/NodeSnapshotPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/NodeSnapshotPersisting.swift index 4041062b..b10c6c10 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/NodeSnapshotPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/NodeSnapshotPersisting.swift @@ -2,86 +2,83 @@ import Foundation /// Store operations for node status snapshots. public protocol NodeSnapshotPersisting: Actor { + // swiftlint:disable function_parameter_count + /// Save a node status snapshot from primitive parameters. Returns the snapshot ID. + func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? + ) async throws -> UUID + // swiftlint:enable function_parameter_count - // swiftlint:disable function_parameter_count - /// Save a node status snapshot from primitive parameters. Returns the snapshot ID. - func saveNodeStatusSnapshot( - nodePublicKey: Data, - batteryMillivolts: UInt16?, - lastSNR: Double?, - lastRSSI: Int16?, - noiseFloor: Int16?, - uptimeSeconds: UInt32?, - rxAirtimeSeconds: UInt32?, - packetsSent: UInt32?, - packetsReceived: UInt32?, - receiveErrors: UInt32?, - postedCount: UInt16?, - postPushCount: UInt16? - ) async throws -> UUID - // swiftlint:enable function_parameter_count + /// Fetch the most recent snapshot for a node + func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? - /// Fetch the most recent snapshot for a node - func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? + /// Fetch snapshots for a node within a date range, sorted by timestamp ascending + func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] - /// Fetch snapshots for a node within a date range, sorted by timestamp ascending - func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] + /// Update neighbor data on an existing snapshot + func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws - /// Update neighbor data on an existing snapshot - func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws + /// Update telemetry data on an existing snapshot + func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws - /// Update telemetry data on an existing snapshot - func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws + /// Save a telemetry-only snapshot (no radio metrics). Returns the snapshot ID. + func saveTelemetryOnlySnapshot( + nodePublicKey: Data, + telemetryEntries: [TelemetrySnapshotEntry] + ) async throws -> UUID - /// Save a telemetry-only snapshot (no radio metrics). Returns the snapshot ID. - func saveTelemetryOnlySnapshot( - nodePublicKey: Data, - telemetryEntries: [TelemetrySnapshotEntry] - ) async throws -> UUID + /// Atomically capture a status, telemetry, and/or neighbor snapshot for a node, + /// enriching the latest in-window snapshot or inserting a new one. Returns the + /// snapshot ID. The concrete `PersistenceStore` performs the read-modify-write + /// in a single `@ModelActor` turn so concurrent captures cannot duplicate a row. + func recordNodeStatusSnapshot( + nodePublicKey: Data, + status: NodeStatusMetrics?, + telemetry: [TelemetrySnapshotEntry]?, + neighbors: [NeighborSnapshotEntry]? + ) async throws -> UUID - /// Atomically capture a status, telemetry, and/or neighbor snapshot for a node, - /// enriching the latest in-window snapshot or inserting a new one. Returns the - /// snapshot ID. The concrete `PersistenceStore` performs the read-modify-write - /// in a single `@ModelActor` turn so concurrent captures cannot duplicate a row. - func recordNodeStatusSnapshot( - nodePublicKey: Data, - status: NodeStatusMetrics?, - telemetry: [TelemetrySnapshotEntry]?, - neighbors: [NeighborSnapshotEntry]? - ) async throws -> UUID - - /// Delete snapshots older than the given date - func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws + /// Delete snapshots older than the given date + func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws } public extension NodeSnapshotPersisting { - - /// The most recent snapshot that actually carries neighbor data, for neighbor - /// delta display. Neighbor arrays are sparse (a snapshot holds them only when the - /// user expanded the neighbors section), so the generic previous snapshot - /// frequently has none; this skips status- or telemetry-only rows. The current - /// in-window capture is excluded so the reading being viewed is never diffed - /// against itself. - func fetchPreviousNeighborSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { - let all = try await fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) - let cutoff: Date - if let latest = all.last, - latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { - cutoff = latest.timestamp - } else { - cutoff = .now - } - return all.last { $0.timestamp < cutoff && $0.neighborSnapshots != nil } + /// The most recent snapshot that actually carries neighbor data, for neighbor + /// delta display. Neighbor arrays are sparse (a snapshot holds them only when the + /// user expanded the neighbors section), so the generic previous snapshot + /// frequently has none; this skips status- or telemetry-only rows. The current + /// in-window capture is excluded so the reading being viewed is never diffed + /// against itself. + func fetchPreviousNeighborSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { + let all = try await fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) + let cutoff: Date = if let latest = all.last, + latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { + latest.timestamp + } else { + .now } + return all.last { $0.timestamp < cutoff && $0.neighborSnapshots != nil } + } - /// The most recent snapshot carrying status fields before the given date, for the - /// status delta. A neighbor- or telemetry-only capture inserts a row with no - /// status; skipping those (the `uptimeSeconds` marker the in-window throttle uses) - /// keeps such a row from blanking the delta. The in-window capture is kept, unlike - /// the neighbor baseline: status is throttled and never overwrites itself, so an - /// early reading in the current window is still a valid baseline. - func fetchPreviousStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { - let all = try await fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) - return all.last { $0.timestamp < before && $0.uptimeSeconds != nil } - } + /// The most recent snapshot carrying status fields before the given date, for the + /// status delta. A neighbor- or telemetry-only capture inserts a row with no + /// status; skipping those (the `uptimeSeconds` marker the in-window throttle uses) + /// keeps such a row from blanking the delta. The in-window capture is kept, unlike + /// the neighbor baseline: status is throttled and never overwrites itself, so an + /// early reading in the current window is still a valid baseline. + func fetchPreviousStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { + let all = try await fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) + return all.last { $0.timestamp < before && $0.uptimeSeconds != nil } + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/ReactionPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/ReactionPersisting.swift index 74d5b6f1..f339705f 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/ReactionPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/ReactionPersisting.swift @@ -2,28 +2,27 @@ import Foundation /// Store operations for message reactions and their summary cache. public protocol ReactionPersisting: Actor { + /// Fetch reactions for a message, ordered by most recent first + func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] - /// Fetch reactions for a message, ordered by most recent first - func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] + /// Save a new reaction + func saveReaction(_ dto: ReactionDTO) async throws - /// Save a new reaction - func saveReaction(_ dto: ReactionDTO) async throws + /// Check if a reaction already exists (deduplication) + func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool - /// Check if a reaction already exists (deduplication) - func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool + /// Update a message's reaction summary cache + func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws - /// Update a message's reaction summary cache - func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws - - /// Delete all reactions for a message - func deleteReactionsForMessage(messageID: UUID) async throws + /// Delete all reactions for a message + func deleteReactionsForMessage(messageID: UUID) async throws } // MARK: - Default Parameter Values extension ReactionPersisting { - /// Fetch reactions with default limit of 100 - func fetchReactions(for messageID: UUID) async throws -> [ReactionDTO] { - try await fetchReactions(for: messageID, limit: 100) - } + /// Fetch reactions with default limit of 100 + func fetchReactions(for messageID: UUID) async throws -> [ReactionDTO] { + try await fetchReactions(for: messageID, limit: 100) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/RoomPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/RoomPersisting.swift index bf1ca92c..cacb3edb 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/RoomPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/RoomPersisting.swift @@ -2,90 +2,89 @@ import Foundation /// Store operations for remote node sessions and room messages. public protocol RoomPersisting: Actor { + // MARK: - Room Session State - // MARK: - Room Session State + /// Fetch a remote node session by ID + func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? - /// Fetch a remote node session by ID - func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? + /// Fetch a remote node session by 32-byte public key + func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? - /// Fetch a remote node session by 32-byte public key - func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? + /// Fetch a remote node session by 6-byte public key prefix + func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? - /// Fetch a remote node session by 6-byte public key prefix - func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? + /// Fetch all remote node sessions for a device + func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] - /// Fetch all remote node sessions for a device - func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] + /// Fetch all connected sessions for re-authentication after BLE reconnection + func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] - /// Fetch all connected sessions for re-authentication after BLE reconnection - func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] + /// Save or update a remote node session + func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws - /// Save or update a remote node session - func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws + /// Update session connection state + func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws - /// Update session connection state - func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws + /// Clean up duplicate remote node sessions with the same public key. + /// Keeps the session with the specified ID and deletes any others. + func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws - /// Clean up duplicate remote node sessions with the same public key. - /// Keeps the session with the specified ID and deletes any others. - func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws + /// Delete remote node session and all associated room messages + func deleteRemoteNodeSession(id: UUID) async throws - /// Delete remote node session and all associated room messages - func deleteRemoteNodeSession(id: UUID) async throws + /// Mark a session as disconnected without changing permission level. + /// Use for transient disconnections (BLE drop, keep-alive failure, re-auth failure). + func markSessionDisconnected(_ sessionID: UUID) async throws - /// Mark a session as disconnected without changing permission level. - /// Use for transient disconnections (BLE drop, keep-alive failure, re-auth failure). - func markSessionDisconnected(_ sessionID: UUID) async throws + /// Mark a room session as connected. Returns true if the state actually changed. + @discardableResult + func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool - /// Mark a room session as connected. Returns true if the state actually changed. - @discardableResult - func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool + /// Update room activity timestamps (sort date and optional sync bookmark). + func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws - /// Update room activity timestamps (sort date and optional sync bookmark). - func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws + // MARK: - Room Message Operations - // MARK: - Room Message Operations + /// Save a new room message + func saveRoomMessage(_ dto: RoomMessageDTO) async throws - /// Save a new room message - func saveRoomMessage(_ dto: RoomMessageDTO) async throws + /// Fetch a room message by ID + func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? - /// Fetch a room message by ID - func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? + /// Fetch room messages for a session + func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] - /// Fetch room messages for a session - func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] + /// Check for duplicate room message + func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool - /// Check for duplicate room message - func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool + /// Update room message status after send attempt + func updateRoomMessageStatus( + id: UUID, + status: MessageStatus, + ackCode: UInt32?, + roundTripTime: UInt32? + ) async throws - /// Update room message status after send attempt - func updateRoomMessageStatus( - id: UUID, - status: MessageStatus, - ackCode: UInt32?, - roundTripTime: UInt32? - ) async throws + /// Update room message retry status + func updateRoomMessageRetryStatus( + id: UUID, + status: MessageStatus, + retryAttempt: Int, + maxRetryAttempts: Int + ) async throws - /// Update room message retry status - func updateRoomMessageRetryStatus( - id: UUID, - status: MessageStatus, - retryAttempt: Int, - maxRetryAttempts: Int - ) async throws + /// Increment unread message count for a room session + func incrementRoomUnreadCount(_ sessionID: UUID) async throws - /// Increment unread message count for a room session - func incrementRoomUnreadCount(_ sessionID: UUID) async throws - - /// Reset unread count to zero (called when user views conversation) - func resetRoomUnreadCount(_ sessionID: UUID) async throws + /// Reset unread count to zero (called when user views conversation) + func resetRoomUnreadCount(_ sessionID: UUID) async throws } // MARK: - Default Parameter Values extension RoomPersisting { - /// Update room activity with nil sync timestamp (sort date only) - func updateRoomActivity(_ sessionID: UUID) async throws { - try await updateRoomActivity(sessionID, syncTimestamp: nil) - } + /// Update room activity with nil sync timestamp (sort date only) + func updateRoomActivity(_ sessionID: UUID) async throws { + try await updateRoomActivity(sessionID, syncTimestamp: nil) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift index aadd9628..94dbc3b3 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift @@ -2,82 +2,81 @@ import Foundation /// Store operations for RX log entries: persistence, lookup, and batch enrichment. public protocol RxLogPersisting: Actor { - - // MARK: - RxLogEntry Lookup - - /// Find RxLogEntry matching an incoming message for path correlation. - /// - /// For channel messages: Correlates by channel index and sender timestamp. - /// For direct messages: Correlates by sender timestamp and payload type. - func findRxLogEntry( - radioID: UUID, - channelIndex: UInt8?, - senderTimestamp: UInt32 - ) async throws -> RxLogEntryDTO? - - /// Find a DM RxLogEntry by matching the sender prefix byte in the packet payload. - /// Fallback for when the primary timestamp-based lookup fails. - func findRxLogEntryBySenderPrefix( - radioID: UUID, - senderPrefixByte: UInt8, - receivedSince: Date - ) async throws -> RxLogEntryDTO? - - // MARK: - RX Log - - /// Save a new RX log entry - func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws - - /// Fetch RX log entries for a device, most recent first - func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] - - /// Clear all RX log entries for a device - func clearRxLogEntries(radioID: UUID) async throws - - /// Delete oldest entries once the log materially exceeds the retention cap - func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws - - /// Fetch RX log entries that have a transport code but no resolved - /// region yet, the back-fill candidate set - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] - - /// Fetch recent RX log entries with a given decrypt status - func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] - - /// Batch update `regionScope` on RX log entries by id - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws - - /// Batch update RX log entries after successful decryption. - /// Note: decodedText is transient and not persisted. - func batchUpdateRxLogDecryption( - _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] - ) async throws - - /// Batch update `regionScope` on incoming channel `Message` rows - /// correlated by `(channelIndex, senderTimestamp)` - func batchUpdateChannelMessageRegion( - radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws - - /// Batch update `regionScope` on incoming DM `Message` rows - /// correlated by `(senderPrefixByte, senderTimestamp)` - func batchUpdateDMMessageRegion( - radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws + // MARK: - RxLogEntry Lookup + + /// Find RxLogEntry matching an incoming message for path correlation. + /// + /// For channel messages: Correlates by channel index and sender timestamp. + /// For direct messages: Correlates by sender timestamp and payload type. + func findRxLogEntry( + radioID: UUID, + channelIndex: UInt8?, + senderTimestamp: UInt32 + ) async throws -> RxLogEntryDTO? + + /// Find a DM RxLogEntry by matching the sender prefix byte in the packet payload. + /// Fallback for when the primary timestamp-based lookup fails. + func findRxLogEntryBySenderPrefix( + radioID: UUID, + senderPrefixByte: UInt8, + receivedSince: Date + ) async throws -> RxLogEntryDTO? + + // MARK: - RX Log + + /// Save a new RX log entry + func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws + + /// Fetch RX log entries for a device, most recent first + func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] + + /// Clear all RX log entries for a device + func clearRxLogEntries(radioID: UUID) async throws + + /// Delete oldest entries once the log materially exceeds the retention cap + func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws + + /// Fetch RX log entries that have a transport code but no resolved + /// region yet, the back-fill candidate set + func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] + + /// Fetch recent RX log entries with a given decrypt status + func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] + + /// Batch update `regionScope` on RX log entries by id + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws + + /// Batch update RX log entries after successful decryption. + /// Note: decodedText is transient and not persisted. + func batchUpdateRxLogDecryption( + _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] + ) async throws + + /// Batch update `regionScope` on incoming channel `Message` rows + /// correlated by `(channelIndex, senderTimestamp)` + func batchUpdateChannelMessageRegion( + radioID: UUID, + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) async throws + + /// Batch update `regionScope` on incoming DM `Message` rows + /// correlated by `(senderPrefixByte, senderTimestamp)` + func batchUpdateDMMessageRegion( + radioID: UUID, + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) async throws } // MARK: - Default Parameter Values extension RxLogPersisting { - /// Fetch RX log entries with the default limit of 500 - func fetchRxLogEntries(radioID: UUID) async throws -> [RxLogEntryDTO] { - try await fetchRxLogEntries(radioID: radioID, limit: 500) - } - - /// Prune RX log entries with the default retention cap of 1000 plus a 100-entry threshold - func pruneRxLogEntries(radioID: UUID) async throws { - try await pruneRxLogEntries(radioID: radioID, keepCount: 1000, pruneThreshold: 100) - } + /// Fetch RX log entries with the default limit of 500 + func fetchRxLogEntries(radioID: UUID) async throws -> [RxLogEntryDTO] { + try await fetchRxLogEntries(radioID: radioID, limit: 500) + } + + /// Prune RX log entries with the default retention cap of 1000 plus a 100-entry threshold + func pruneRxLogEntries(radioID: UUID) async throws { + try await pruneRxLogEntries(radioID: radioID, keepCount: 1000, pruneThreshold: 100) + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/TracePathPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/TracePathPersisting.swift index 0b6d90e5..218b854c 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/TracePathPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/TracePathPersisting.swift @@ -2,22 +2,21 @@ import Foundation /// Store operations for saved trace paths and their runs. public protocol TracePathPersisting: Actor { + /// Fetch all saved trace paths for a device + func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] - /// Fetch all saved trace paths for a device - func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] + /// Fetch a single saved trace path by ID + func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? - /// Fetch a single saved trace path by ID - func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? + /// Create a new saved trace path + func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO - /// Create a new saved trace path - func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO + /// Update a saved trace path's name + func updateSavedTracePathName(id: UUID, name: String) async throws - /// Update a saved trace path's name - func updateSavedTracePathName(id: UUID, name: String) async throws + /// Delete a saved trace path + func deleteSavedTracePath(id: UUID) async throws - /// Delete a saved trace path - func deleteSavedTracePath(id: UUID) async throws - - /// Append a run to a saved trace path - func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws + /// Append a run to a saved trace path + func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws } diff --git a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift index 99ca4a0d..e95476ab 100644 --- a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift @@ -21,16 +21,16 @@ /// } /// ``` public protocol PersistenceStoreProtocol: - MessagePersisting, - DevicePersisting, - ContactPersisting, - ChannelPersisting, - TracePathPersisting, - HeardRepeatPersisting, - DebugLogPersisting, - LinkPreviewPersisting, - RxLogPersisting, - RoomPersisting, - DiscoveredNodePersisting, - ReactionPersisting, - NodeSnapshotPersisting {} + MessagePersisting, + DevicePersisting, + ContactPersisting, + ChannelPersisting, + TracePathPersisting, + HeardRepeatPersisting, + DebugLogPersisting, + LinkPreviewPersisting, + RxLogPersisting, + RoomPersisting, + DiscoveredNodePersisting, + ReactionPersisting, + NodeSnapshotPersisting {} diff --git a/MC1Services/Sources/MC1Services/Protocols/RepeaterResolvable.swift b/MC1Services/Sources/MC1Services/Protocols/RepeaterResolvable.swift index 691b329c..d0089ae5 100644 --- a/MC1Services/Sources/MC1Services/Protocols/RepeaterResolvable.swift +++ b/MC1Services/Sources/MC1Services/Protocols/RepeaterResolvable.swift @@ -3,13 +3,13 @@ import Foundation /// Shared interface for types that can be matched by `RepeaterResolver`. /// Both `ContactDTO` and `DiscoveredNodeDTO` conform. public protocol RepeaterResolvable: Sendable { - var publicKey: Data { get } - var latitude: Double { get } - var longitude: Double { get } - var hasLocation: Bool { get } - var lastAdvertTimestamp: UInt32 { get } - /// Secondary recency tiebreaker (ContactDTO → lastModified, DiscoveredNodeDTO → lastHeard). - var recencyDate: Date { get } - /// Display name used for path hops and resolver tiebreaking. - var resolvableName: String { get } + var publicKey: Data { get } + var latitude: Double { get } + var longitude: Double { get } + var hasLocation: Bool { get } + var lastAdvertTimestamp: UInt32 { get } + /// Secondary recency tiebreaker (ContactDTO → lastModified, DiscoveredNodeDTO → lastHeard). + var recencyDate: Date { get } + /// Display name used for path hops and resolver tiebreaking. + var resolvableName: String { get } } diff --git a/MC1Services/Sources/MC1Services/RF/ClearanceStatus.swift b/MC1Services/Sources/MC1Services/RF/ClearanceStatus.swift index c1983806..27597575 100644 --- a/MC1Services/Sources/MC1Services/RF/ClearanceStatus.swift +++ b/MC1Services/Sources/MC1Services/RF/ClearanceStatus.swift @@ -2,8 +2,8 @@ import Foundation /// Clearance status at worst point along path public enum ClearanceStatus: String, Sendable { - case clear = "Clear" - case marginal = "Marginal" - case partialObstruction = "Partial obstruction" - case blocked = "Blocked" + case clear = "Clear" + case marginal = "Marginal" + case partialObstruction = "Partial obstruction" + case blocked = "Blocked" } diff --git a/MC1Services/Sources/MC1Services/RF/ElevationSample.swift b/MC1Services/Sources/MC1Services/RF/ElevationSample.swift index 7ba7f1ea..24ec3112 100644 --- a/MC1Services/Sources/MC1Services/RF/ElevationSample.swift +++ b/MC1Services/Sources/MC1Services/RF/ElevationSample.swift @@ -3,14 +3,14 @@ import Foundation /// Elevation sample along the path public struct ElevationSample: Identifiable, Sendable { - public let id = UUID() - public let coordinate: CLLocationCoordinate2D - public let elevation: Double // meters above sea level - public let distanceFromAMeters: Double + public let id = UUID() + public let coordinate: CLLocationCoordinate2D + public let elevation: Double // meters above sea level + public let distanceFromAMeters: Double - public init(coordinate: CLLocationCoordinate2D, elevation: Double, distanceFromAMeters: Double) { - self.coordinate = coordinate - self.elevation = elevation - self.distanceFromAMeters = distanceFromAMeters - } + public init(coordinate: CLLocationCoordinate2D, elevation: Double, distanceFromAMeters: Double) { + self.coordinate = coordinate + self.elevation = elevation + self.distanceFromAMeters = distanceFromAMeters + } } diff --git a/MC1Services/Sources/MC1Services/RF/ObstructionPoint.swift b/MC1Services/Sources/MC1Services/RF/ObstructionPoint.swift index b606ac02..203ee4cd 100644 --- a/MC1Services/Sources/MC1Services/RF/ObstructionPoint.swift +++ b/MC1Services/Sources/MC1Services/RF/ObstructionPoint.swift @@ -2,24 +2,24 @@ import Foundation /// Point where obstruction affects the path public struct ObstructionPoint: Identifiable, Equatable, Sendable { - public let id = UUID() - public let distanceFromAMeters: Double - public let obstructionHeightMeters: Double - public let fresnelClearancePercent: Double + public let id = UUID() + public let distanceFromAMeters: Double + public let obstructionHeightMeters: Double + public let fresnelClearancePercent: Double - public init( - distanceFromAMeters: Double, - obstructionHeightMeters: Double, - fresnelClearancePercent: Double - ) { - self.distanceFromAMeters = distanceFromAMeters - self.obstructionHeightMeters = obstructionHeightMeters - self.fresnelClearancePercent = fresnelClearancePercent - } + public init( + distanceFromAMeters: Double, + obstructionHeightMeters: Double, + fresnelClearancePercent: Double + ) { + self.distanceFromAMeters = distanceFromAMeters + self.obstructionHeightMeters = obstructionHeightMeters + self.fresnelClearancePercent = fresnelClearancePercent + } - public static func == (lhs: ObstructionPoint, rhs: ObstructionPoint) -> Bool { - lhs.distanceFromAMeters == rhs.distanceFromAMeters - && lhs.obstructionHeightMeters == rhs.obstructionHeightMeters - && lhs.fresnelClearancePercent == rhs.fresnelClearancePercent - } + public static func == (lhs: ObstructionPoint, rhs: ObstructionPoint) -> Bool { + lhs.distanceFromAMeters == rhs.distanceFromAMeters + && lhs.obstructionHeightMeters == rhs.obstructionHeightMeters + && lhs.fresnelClearancePercent == rhs.fresnelClearancePercent + } } diff --git a/MC1Services/Sources/MC1Services/RF/PathAnalysisResult.swift b/MC1Services/Sources/MC1Services/RF/PathAnalysisResult.swift index 97122625..1908ed08 100644 --- a/MC1Services/Sources/MC1Services/RF/PathAnalysisResult.swift +++ b/MC1Services/Sources/MC1Services/RF/PathAnalysisResult.swift @@ -2,78 +2,80 @@ import Foundation /// Complete analysis result for a path public struct PathAnalysisResult: Equatable, Sendable { - public let distanceMeters: Double - public let freeSpacePathLoss: Double - /// Peak diffraction loss from the single worst knife-edge obstruction (not cumulative) - public let peakDiffractionLoss: Double - public let totalPathLoss: Double - public let clearanceStatus: ClearanceStatus - public let worstClearancePercent: Double - public let obstructionPoints: [ObstructionPoint] - public let frequencyMHz: Double - public let refractionK: Double + public let distanceMeters: Double + public let freeSpacePathLoss: Double + /// Peak diffraction loss from the single worst knife-edge obstruction (not cumulative) + public let peakDiffractionLoss: Double + public let totalPathLoss: Double + public let clearanceStatus: ClearanceStatus + public let worstClearancePercent: Double + public let obstructionPoints: [ObstructionPoint] + public let frequencyMHz: Double + public let refractionK: Double - public init( - distanceMeters: Double, - freeSpacePathLoss: Double, - peakDiffractionLoss: Double, - totalPathLoss: Double, - clearanceStatus: ClearanceStatus, - worstClearancePercent: Double, - obstructionPoints: [ObstructionPoint], - frequencyMHz: Double, - refractionK: Double - ) { - self.distanceMeters = distanceMeters - self.freeSpacePathLoss = freeSpacePathLoss - self.peakDiffractionLoss = peakDiffractionLoss - self.totalPathLoss = totalPathLoss - self.clearanceStatus = clearanceStatus - self.worstClearancePercent = worstClearancePercent - self.obstructionPoints = obstructionPoints - self.frequencyMHz = frequencyMHz - self.refractionK = refractionK - } + public init( + distanceMeters: Double, + freeSpacePathLoss: Double, + peakDiffractionLoss: Double, + totalPathLoss: Double, + clearanceStatus: ClearanceStatus, + worstClearancePercent: Double, + obstructionPoints: [ObstructionPoint], + frequencyMHz: Double, + refractionK: Double + ) { + self.distanceMeters = distanceMeters + self.freeSpacePathLoss = freeSpacePathLoss + self.peakDiffractionLoss = peakDiffractionLoss + self.totalPathLoss = totalPathLoss + self.clearanceStatus = clearanceStatus + self.worstClearancePercent = worstClearancePercent + self.obstructionPoints = obstructionPoints + self.frequencyMHz = frequencyMHz + self.refractionK = refractionK + } - public var distanceKm: Double { distanceMeters / 1000 } + public var distanceKm: Double { + distanceMeters / 1000 + } - public var worstObstructionPoint: ObstructionPoint? { - obstructionPoints.min(by: { $0.fresnelClearancePercent < $1.fresnelClearancePercent }) - } + public 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. - public var peakObstructionPerRegion: [ObstructionPoint] { - guard obstructionPoints.count >= 2 else { return obstructionPoints } + /// 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. + public 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.. 0 && gap < minGap { minGap = gap } - } - guard minGap.isFinite else { return [obstructionPoints[0]] } + // Find the smallest gap between consecutive points (= one sample step) + var minGap = Double.infinity + for i in 1.. 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 + // 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] + var regions: [ObstructionPoint] = [] + var regionWorst = obstructionPoints[0] - for i in 1.. gapThreshold { - regions.append(regionWorst) - regionWorst = point - } else if point.fresnelClearancePercent < regionWorst.fresnelClearancePercent { - regionWorst = point - } - } + if gap > gapThreshold { regions.append(regionWorst) - - return regions + regionWorst = point + } else if point.fresnelClearancePercent < regionWorst.fresnelClearancePercent { + regionWorst = point + } } + regions.append(regionWorst) + + return regions + } } diff --git a/MC1Services/Sources/MC1Services/RF/RFCalculator.swift b/MC1Services/Sources/MC1Services/RF/RFCalculator.swift index fb62f7fa..4e902c8e 100644 --- a/MC1Services/Sources/MC1Services/RF/RFCalculator.swift +++ b/MC1Services/Sources/MC1Services/RF/RFCalculator.swift @@ -6,413 +6,411 @@ import Foundation /// Provides functions for calculating wavelength, Fresnel zones, earth bulge, /// path loss, and diffraction loss for radio frequency propagation analysis. public enum RFCalculator { - - // MARK: - Constants - - /// Speed of light in meters per second - public static let speedOfLight: Double = 299_792_458 - - /// Earth's radius in kilometers - public static let earthRadiusKm: Double = 6371 - - /// Minimum Fresnel zone clearance percentage for a "clear" path - public static let clearClearanceThreshold: Double = 80 - - /// Minimum Fresnel zone clearance percentage for a "marginal" path - public static let marginalClearanceThreshold: Double = 60 - - // MARK: - Wavelength - - /// Calculates the wavelength in meters for a given frequency. - /// - Parameter frequencyMHz: The frequency in megahertz. - /// - Returns: The wavelength in meters. - public static func wavelength(frequencyMHz: Double) -> Double { - guard frequencyMHz > 0 else { return 0 } - let frequencyHz = frequencyMHz * 1_000_000 - return speedOfLight / frequencyHz + // MARK: - Constants + + /// Speed of light in meters per second + public static let speedOfLight: Double = 299_792_458 + + /// Earth's radius in kilometers + public static let earthRadiusKm: Double = 6371 + + /// Minimum Fresnel zone clearance percentage for a "clear" path + public static let clearClearanceThreshold: Double = 80 + + /// Minimum Fresnel zone clearance percentage for a "marginal" path + public static let marginalClearanceThreshold: Double = 60 + + // MARK: - Wavelength + + /// Calculates the wavelength in meters for a given frequency. + /// - Parameter frequencyMHz: The frequency in megahertz. + /// - Returns: The wavelength in meters. + public static func wavelength(frequencyMHz: Double) -> Double { + guard frequencyMHz > 0 else { return 0 } + let frequencyHz = frequencyMHz * 1_000_000 + return speedOfLight / frequencyHz + } + + // MARK: - Fresnel Zone + + /// Calculates the first Fresnel zone radius at a point along the path. + /// + /// The Fresnel zone represents the ellipsoidal region around the direct + /// line-of-sight path where radio waves propagate. For best reception, + /// at least 60% of the first Fresnel zone should be clear of obstructions. + /// + /// - Parameters: + /// - frequencyMHz: The frequency in megahertz. + /// - distanceToAMeters: Distance from point A to the calculation point in meters. + /// - distanceToBMeters: Distance from the calculation point to point B in meters. + /// - Returns: The first Fresnel zone radius in meters. + public static func fresnelRadius( + frequencyMHz: Double, + distanceToAMeters: Double, + distanceToBMeters: Double + ) -> Double { + guard frequencyMHz > 0, distanceToAMeters > 0, distanceToBMeters > 0 else { return 0 } + + let lambda = wavelength(frequencyMHz: frequencyMHz) + let totalDistance = distanceToAMeters + distanceToBMeters + + // First Fresnel zone radius: r = sqrt((lambda * d1 * d2) / (d1 + d2)) + return sqrt((lambda * distanceToAMeters * distanceToBMeters) / totalDistance) + } + + // MARK: - Earth Bulge + + /// Calculates the earth bulge (curvature correction) at a point along the path. + /// + /// Earth bulge represents how much the curved surface of the Earth rises + /// above a straight line between two points. This is critical for long-distance + /// radio links where the curvature can obstruct the signal path. + /// + /// - Parameters: + /// - distanceToAMeters: Distance from point A to the calculation point in meters. + /// - distanceToBMeters: Distance from the calculation point to point B in meters. + /// - refractionK: The effective earth radius factor. Use 1.0 for no adjustment, + /// 1.33 (4/3) for standard atmosphere, or 4.0 for ducting conditions. + /// - Returns: The earth bulge in meters. + public static func earthBulge( + distanceToAMeters: Double, + distanceToBMeters: Double, + refractionK: Double + ) -> Double { + guard distanceToAMeters > 0, distanceToBMeters > 0, refractionK > 0 else { return 0 } + + let earthRadiusMeters = earthRadiusKm * 1000 + let effectiveEarthRadius = refractionK * earthRadiusMeters + + // Earth bulge: h = (d1 * d2) / (2 * Re_effective) + return (distanceToAMeters * distanceToBMeters) / (2 * effectiveEarthRadius) + } + + // MARK: - Path Loss + + /// Calculates the free-space path loss in decibels. + /// + /// Free-space path loss represents the attenuation of radio signal + /// as it travels through free space (vacuum). Real-world losses are + /// typically higher due to atmospheric absorption and other factors. + /// + /// - Parameters: + /// - distanceMeters: The distance in meters. + /// - frequencyMHz: The frequency in megahertz. + /// - Returns: The free-space path loss in dB. + public static func pathLoss(distanceMeters: Double, frequencyMHz: Double) -> Double { + guard distanceMeters > 0, frequencyMHz > 0 else { return 0 } + + // FSPL (dB) = 20*log10(d) + 20*log10(f) + 20*log10(4*pi/c) + // Simplified: FSPL = 20*log10(d_m) + 20*log10(f_MHz) + 20*log10(4*pi*1e6/c) + // The constant = 20*log10(4*pi*1e6/299792458) ≈ -27.55 + let distanceComponent = 20 * log10(distanceMeters) + let frequencyComponent = 20 * log10(frequencyMHz) + let constant = -27.55 + + return distanceComponent + frequencyComponent + constant + } + + // MARK: - Diffraction Loss + + /// Calculates the knife-edge diffraction loss for an obstruction. + /// + /// Uses the Fresnel-Kirchhoff diffraction parameter (v) to estimate + /// the loss caused by a single knife-edge obstruction in the path. + /// + /// - Parameters: + /// - obstructionHeightMeters: The height of the obstruction above the line-of-sight + /// (positive = blocked, negative = clearance). + /// - distanceToAMeters: Distance from point A to the obstruction in meters. + /// - distanceToBMeters: Distance from the obstruction to point B in meters. + /// - frequencyMHz: The frequency in megahertz. + /// - Returns: The diffraction loss in dB (positive value represents loss). + public static func diffractionLoss( + obstructionHeightMeters: Double, + distanceToAMeters: Double, + distanceToBMeters: Double, + frequencyMHz: Double + ) -> Double { + guard distanceToAMeters > 0, distanceToBMeters > 0, frequencyMHz > 0 else { return 0 } + + let lambda = wavelength(frequencyMHz: frequencyMHz) + let totalDistance = distanceToAMeters + distanceToBMeters + + // Fresnel-Kirchhoff diffraction parameter: + // v = h * sqrt(2 * (d1 + d2) / (lambda * d1 * d2)) + let vParam = obstructionHeightMeters * sqrt( + 2 * totalDistance / (lambda * distanceToAMeters * distanceToBMeters) + ) + + // Approximate diffraction loss based on v parameter + // Using ITU-R P.526 approximation + return diffractionLossFromV(vParam) + } + + /// Fresnel-Kirchhoff parameter at or below which the path has full clearance and no knife-edge loss. + private static let diffractionClearThresholdV: Double = -0.78 + + /// Calculates diffraction loss from the Fresnel-Kirchhoff v parameter. + /// + /// Uses the ITU-R P.526 single-equation knife-edge diffraction model. A single + /// expression keeps the loss continuous and monotonic across the whole obstruction + /// range, avoiding the branch-join drift of the piecewise polynomial approximations. + /// + /// - Parameter vParam: The Fresnel-Kirchhoff diffraction parameter. + /// - Returns: The diffraction loss in dB. + private static func diffractionLossFromV(_ vParam: Double) -> Double { + guard vParam > diffractionClearThresholdV else { return 0 } + let shifted = vParam - 0.1 + return 6.9 + 20 * log10(sqrt(shifted * shifted + 1) + shifted) + } + + // MARK: - Distance Calculation + + /// Calculates the great-circle distance between two coordinates using the Haversine formula. + /// + /// - Parameters: + /// - from: The starting coordinate. + /// - destination: The ending coordinate. + /// - Returns: The distance in meters. + public static func distance(from: CLLocationCoordinate2D, to destination: CLLocationCoordinate2D) -> Double { + let earthRadiusMeters = earthRadiusKm * 1000 + + let lat1 = from.latitude * .pi / 180 + let lat2 = destination.latitude * .pi / 180 + let deltaLat = (destination.latitude - from.latitude) * .pi / 180 + let deltaLon = (destination.longitude - from.longitude) * .pi / 180 + + // Haversine formula + let haversineA = sin(deltaLat / 2) * sin(deltaLat / 2) + + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) + let angularDistance = 2 * atan2(sqrt(haversineA), sqrt(1 - haversineA)) + + return earthRadiusMeters * angularDistance + } + + // MARK: - Path Analysis + + /// Analyze full path for clearance and signal propagation. + /// + /// This function evaluates an elevation profile between two points to determine: + /// - Free-space path loss (FSPL) + /// - Additional loss from diffraction over obstructions + /// - Fresnel zone clearance at each point + /// - Overall clearance status of the path + /// + /// - Parameters: + /// - elevationProfile: Array of elevation samples along the path from A to B. + /// - pointAHeightMeters: Antenna height at point A in meters above ground. + /// - pointBHeightMeters: Antenna height at point B in meters above ground. + /// - frequencyMHz: The operating frequency in megahertz. + /// - refractionK: The effective earth radius factor. Use 1.0 for no adjustment, + /// 1.33 (4/3) for standard atmosphere, or 4.0 for ducting conditions. + /// - Returns: A PathAnalysisResult containing loss calculations and clearance status. + public static func analyzePath( + elevationProfile: [ElevationSample], + pointAHeightMeters: Double, + pointBHeightMeters: Double, + frequencyMHz: Double, + refractionK: Double + ) -> PathAnalysisResult { + // Full-path distances are measured from A itself, so the origin is 0 + // even if the first sample carries a non-zero distance. + analyze( + elevationProfile: elevationProfile[...], + startHeightMeters: pointAHeightMeters, + endHeightMeters: pointBHeightMeters, + frequencyMHz: frequencyMHz, + refractionK: refractionK, + distanceOriginMeters: 0 + ) + } + + // MARK: - Segment Analysis + + /// Analyze a segment of the path for clearance and signal propagation. + /// Uses ArraySlice to avoid copying - critical for 60fps drag performance. + /// + /// - Parameters: + /// - elevationProfile: Slice of elevation samples for this segment. + /// - startHeightMeters: Antenna height at segment start in meters above ground. + /// - endHeightMeters: Antenna height at segment end in meters above ground. + /// - frequencyMHz: The operating frequency in megahertz. + /// - refractionK: The effective earth radius factor. + /// - Returns: A PathAnalysisResult for this segment. + public static func analyzePathSegment( + elevationProfile: ArraySlice, + startHeightMeters: Double, + endHeightMeters: Double, + frequencyMHz: Double, + refractionK: Double + ) -> PathAnalysisResult { + analyze( + elevationProfile: elevationProfile, + startHeightMeters: startHeightMeters, + endHeightMeters: endHeightMeters, + frequencyMHz: frequencyMHz, + refractionK: refractionK, + distanceOriginMeters: elevationProfile.first?.distanceFromAMeters ?? 0 + ) + } + + // MARK: - Shared Analysis Core + + /// Distance margin (meters) within which a sample counts as an endpoint and is skipped. + private static let endpointSkipMarginMeters: Double = 1 + + /// Shared core for `analyzePath` and `analyzePathSegment`. + /// + /// `distanceOriginMeters` anchors the local distance axis: 0 for a full + /// path, the first sample's `distanceFromAMeters` for a segment. Recorded + /// obstruction points always keep the sample's original `distanceFromAMeters` + /// so they stay in full-path coordinates for chart rendering. + private static func analyze( + elevationProfile: ArraySlice, + startHeightMeters: Double, + endHeightMeters: Double, + frequencyMHz: Double, + refractionK: Double, + distanceOriginMeters: Double + ) -> PathAnalysisResult { + guard elevationProfile.count >= 2, + let firstSample = elevationProfile.first, + let lastSample = elevationProfile.last else { + return emptyResult(frequencyMHz: frequencyMHz, refractionK: refractionK) } - // MARK: - Fresnel Zone - - /// Calculates the first Fresnel zone radius at a point along the path. - /// - /// The Fresnel zone represents the ellipsoidal region around the direct - /// line-of-sight path where radio waves propagate. For best reception, - /// at least 60% of the first Fresnel zone should be clear of obstructions. - /// - /// - Parameters: - /// - frequencyMHz: The frequency in megahertz. - /// - distanceToAMeters: Distance from point A to the calculation point in meters. - /// - distanceToBMeters: Distance from the calculation point to point B in meters. - /// - Returns: The first Fresnel zone radius in meters. - public static func fresnelRadius( - frequencyMHz: Double, - distanceToAMeters: Double, - distanceToBMeters: Double - ) -> Double { - guard frequencyMHz > 0, distanceToAMeters > 0, distanceToBMeters > 0 else { return 0 } - - let lambda = wavelength(frequencyMHz: frequencyMHz) - let totalDistance = distanceToAMeters + distanceToBMeters - - // First Fresnel zone radius: r = sqrt((lambda * d1 * d2) / (d1 + d2)) - return sqrt((lambda * distanceToAMeters * distanceToBMeters) / totalDistance) - } - - // MARK: - Earth Bulge - - /// Calculates the earth bulge (curvature correction) at a point along the path. - /// - /// Earth bulge represents how much the curved surface of the Earth rises - /// above a straight line between two points. This is critical for long-distance - /// radio links where the curvature can obstruct the signal path. - /// - /// - Parameters: - /// - distanceToAMeters: Distance from point A to the calculation point in meters. - /// - distanceToBMeters: Distance from the calculation point to point B in meters. - /// - refractionK: The effective earth radius factor. Use 1.0 for no adjustment, - /// 1.33 (4/3) for standard atmosphere, or 4.0 for ducting conditions. - /// - Returns: The earth bulge in meters. - public static func earthBulge( - distanceToAMeters: Double, - distanceToBMeters: Double, - refractionK: Double - ) -> Double { - guard distanceToAMeters > 0, distanceToBMeters > 0, refractionK > 0 else { return 0 } - - let earthRadiusMeters = earthRadiusKm * 1000 - let effectiveEarthRadius = refractionK * earthRadiusMeters - - // Earth bulge: h = (d1 * d2) / (2 * Re_effective) - return (distanceToAMeters * distanceToBMeters) / (2 * effectiveEarthRadius) - } + let lengthMeters = lastSample.distanceFromAMeters - distanceOriginMeters - // MARK: - Path Loss - - /// Calculates the free-space path loss in decibels. - /// - /// Free-space path loss represents the attenuation of radio signal - /// as it travels through free space (vacuum). Real-world losses are - /// typically higher due to atmospheric absorption and other factors. - /// - /// - Parameters: - /// - distanceMeters: The distance in meters. - /// - frequencyMHz: The frequency in megahertz. - /// - Returns: The free-space path loss in dB. - public static func pathLoss(distanceMeters: Double, frequencyMHz: Double) -> Double { - guard distanceMeters > 0, frequencyMHz > 0 else { return 0 } - - // FSPL (dB) = 20*log10(d) + 20*log10(f) + 20*log10(4*pi/c) - // Simplified: FSPL = 20*log10(d_m) + 20*log10(f_MHz) + 20*log10(4*pi*1e6/c) - // The constant = 20*log10(4*pi*1e6/299792458) ≈ -27.55 - let distanceComponent = 20 * log10(distanceMeters) - let frequencyComponent = 20 * log10(frequencyMHz) - let constant = -27.55 - - return distanceComponent + frequencyComponent + constant + guard lengthMeters > 0 else { + return emptyResult(frequencyMHz: frequencyMHz, refractionK: refractionK) } - // MARK: - Diffraction Loss - - /// Calculates the knife-edge diffraction loss for an obstruction. - /// - /// Uses the Fresnel-Kirchhoff diffraction parameter (v) to estimate - /// the loss caused by a single knife-edge obstruction in the path. - /// - /// - Parameters: - /// - obstructionHeightMeters: The height of the obstruction above the line-of-sight - /// (positive = blocked, negative = clearance). - /// - distanceToAMeters: Distance from point A to the obstruction in meters. - /// - distanceToBMeters: Distance from the obstruction to point B in meters. - /// - frequencyMHz: The frequency in megahertz. - /// - Returns: The diffraction loss in dB (positive value represents loss). - public static func diffractionLoss( - obstructionHeightMeters: Double, - distanceToAMeters: Double, - distanceToBMeters: Double, - frequencyMHz: Double - ) -> Double { - guard distanceToAMeters > 0, distanceToBMeters > 0, frequencyMHz > 0 else { return 0 } - - let lambda = wavelength(frequencyMHz: frequencyMHz) - let totalDistance = distanceToAMeters + distanceToBMeters - - // Fresnel-Kirchhoff diffraction parameter: - // v = h * sqrt(2 * (d1 + d2) / (lambda * d1 * d2)) - let vParam = obstructionHeightMeters * sqrt( - 2 * totalDistance / (lambda * distanceToAMeters * distanceToBMeters) + // Antenna heights above sea level + let antennaStartHeight = firstSample.elevation + startHeightMeters + let antennaEndHeight = lastSample.elevation + endHeightMeters + + // Calculate free-space path loss + let fspl = pathLoss(distanceMeters: lengthMeters, frequencyMHz: frequencyMHz) + + var worstClearancePercent = Double.infinity + var peakDiffractionLoss = 0.0 + var obstructionPoints: [ObstructionPoint] = [] + + // Analyze each intermediate sample point (skip endpoints) + for sample in elevationProfile { + let distanceFromStart = sample.distanceFromAMeters - distanceOriginMeters + let distanceToEnd = lengthMeters - distanceFromStart + + // Skip points at or very near the endpoints + guard distanceFromStart > endpointSkipMarginMeters, + distanceToEnd > endpointSkipMarginMeters else { continue } + + // Line of sight height at this point (linear interpolation) + let fraction = distanceFromStart / lengthMeters + let losHeight = antennaStartHeight + fraction * (antennaEndHeight - antennaStartHeight) + + // Effective terrain height including earth bulge + let bulge = earthBulge( + distanceToAMeters: distanceFromStart, + distanceToBMeters: distanceToEnd, + refractionK: refractionK + ) + let effectiveTerrainHeight = sample.elevation + bulge + + // Calculate Fresnel zone radius at this point + let fresnelZoneRadius = fresnelRadius( + frequencyMHz: frequencyMHz, + distanceToAMeters: distanceFromStart, + distanceToBMeters: distanceToEnd + ) + + // Clearance: distance from terrain to line of sight + let clearance = losHeight - effectiveTerrainHeight + + // Fresnel clearance percentage + // 100% = terrain clears full first Fresnel zone + // 0% = terrain touches line of sight + // <0% = terrain blocks line of sight + let clearancePercent: Double = if fresnelZoneRadius > 0 { + (clearance / fresnelZoneRadius) * 100 + } else { + clearance > 0 ? 100 : 0 + } + + // Track worst clearance + if clearancePercent < worstClearancePercent { + worstClearancePercent = clearancePercent + } + + // Calculate diffraction loss if there's an obstruction + // Obstruction height is negative clearance (positive = blocked) + let obstructionHeight = effectiveTerrainHeight - losHeight + if obstructionHeight > -fresnelZoneRadius { + let diffLoss = diffractionLoss( + obstructionHeightMeters: obstructionHeight, + distanceToAMeters: distanceFromStart, + distanceToBMeters: distanceToEnd, + frequencyMHz: frequencyMHz ) - - // Approximate diffraction loss based on v parameter - // Using ITU-R P.526 approximation - return diffractionLossFromV(vParam) - } - - /// Fresnel-Kirchhoff parameter at or below which the path has full clearance and no knife-edge loss. - private static let diffractionClearThresholdV: Double = -0.78 - - /// Calculates diffraction loss from the Fresnel-Kirchhoff v parameter. - /// - /// Uses the ITU-R P.526 single-equation knife-edge diffraction model. A single - /// expression keeps the loss continuous and monotonic across the whole obstruction - /// range, avoiding the branch-join drift of the piecewise polynomial approximations. - /// - /// - Parameter vParam: The Fresnel-Kirchhoff diffraction parameter. - /// - Returns: The diffraction loss in dB. - private static func diffractionLossFromV(_ vParam: Double) -> Double { - guard vParam > diffractionClearThresholdV else { return 0 } - let shifted = vParam - 0.1 - return 6.9 + 20 * log10(sqrt(shifted * shifted + 1) + shifted) - } - - // MARK: - Distance Calculation - - /// Calculates the great-circle distance between two coordinates using the Haversine formula. - /// - /// - Parameters: - /// - from: The starting coordinate. - /// - destination: The ending coordinate. - /// - Returns: The distance in meters. - public static func distance(from: CLLocationCoordinate2D, to destination: CLLocationCoordinate2D) -> Double { - let earthRadiusMeters = earthRadiusKm * 1000 - - let lat1 = from.latitude * .pi / 180 - let lat2 = destination.latitude * .pi / 180 - let deltaLat = (destination.latitude - from.latitude) * .pi / 180 - let deltaLon = (destination.longitude - from.longitude) * .pi / 180 - - // Haversine formula - let haversineA = sin(deltaLat / 2) * sin(deltaLat / 2) + - cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) - let angularDistance = 2 * atan2(sqrt(haversineA), sqrt(1 - haversineA)) - - return earthRadiusMeters * angularDistance - } - - // MARK: - Path Analysis - - /// Analyze full path for clearance and signal propagation. - /// - /// This function evaluates an elevation profile between two points to determine: - /// - Free-space path loss (FSPL) - /// - Additional loss from diffraction over obstructions - /// - Fresnel zone clearance at each point - /// - Overall clearance status of the path - /// - /// - Parameters: - /// - elevationProfile: Array of elevation samples along the path from A to B. - /// - pointAHeightMeters: Antenna height at point A in meters above ground. - /// - pointBHeightMeters: Antenna height at point B in meters above ground. - /// - frequencyMHz: The operating frequency in megahertz. - /// - refractionK: The effective earth radius factor. Use 1.0 for no adjustment, - /// 1.33 (4/3) for standard atmosphere, or 4.0 for ducting conditions. - /// - Returns: A PathAnalysisResult containing loss calculations and clearance status. - public static func analyzePath( - elevationProfile: [ElevationSample], - pointAHeightMeters: Double, - pointBHeightMeters: Double, - frequencyMHz: Double, - refractionK: Double - ) -> PathAnalysisResult { - // Full-path distances are measured from A itself, so the origin is 0 - // even if the first sample carries a non-zero distance. - analyze( - elevationProfile: elevationProfile[...], - startHeightMeters: pointAHeightMeters, - endHeightMeters: pointBHeightMeters, - frequencyMHz: frequencyMHz, - refractionK: refractionK, - distanceOriginMeters: 0 - ) - } - - // MARK: - Segment Analysis - - /// Analyze a segment of the path for clearance and signal propagation. - /// Uses ArraySlice to avoid copying - critical for 60fps drag performance. - /// - /// - Parameters: - /// - elevationProfile: Slice of elevation samples for this segment. - /// - startHeightMeters: Antenna height at segment start in meters above ground. - /// - endHeightMeters: Antenna height at segment end in meters above ground. - /// - frequencyMHz: The operating frequency in megahertz. - /// - refractionK: The effective earth radius factor. - /// - Returns: A PathAnalysisResult for this segment. - public static func analyzePathSegment( - elevationProfile: ArraySlice, - startHeightMeters: Double, - endHeightMeters: Double, - frequencyMHz: Double, - refractionK: Double - ) -> PathAnalysisResult { - analyze( - elevationProfile: elevationProfile, - startHeightMeters: startHeightMeters, - endHeightMeters: endHeightMeters, - frequencyMHz: frequencyMHz, - refractionK: refractionK, - distanceOriginMeters: elevationProfile.first?.distanceFromAMeters ?? 0 - ) - } - - // MARK: - Shared Analysis Core - - /// Distance margin (meters) within which a sample counts as an endpoint and is skipped. - private static let endpointSkipMarginMeters: Double = 1 - - /// Shared core for `analyzePath` and `analyzePathSegment`. - /// - /// `distanceOriginMeters` anchors the local distance axis: 0 for a full - /// path, the first sample's `distanceFromAMeters` for a segment. Recorded - /// obstruction points always keep the sample's original `distanceFromAMeters` - /// so they stay in full-path coordinates for chart rendering. - private static func analyze( - elevationProfile: ArraySlice, - startHeightMeters: Double, - endHeightMeters: Double, - frequencyMHz: Double, - refractionK: Double, - distanceOriginMeters: Double - ) -> PathAnalysisResult { - guard elevationProfile.count >= 2, - let firstSample = elevationProfile.first, - let lastSample = elevationProfile.last else { - return emptyResult(frequencyMHz: frequencyMHz, refractionK: refractionK) - } - - let lengthMeters = lastSample.distanceFromAMeters - distanceOriginMeters - - guard lengthMeters > 0 else { - return emptyResult(frequencyMHz: frequencyMHz, refractionK: refractionK) + if diffLoss > peakDiffractionLoss { + peakDiffractionLoss = diffLoss } - - // Antenna heights above sea level - let antennaStartHeight = firstSample.elevation + startHeightMeters - let antennaEndHeight = lastSample.elevation + endHeightMeters - - // Calculate free-space path loss - let fspl = pathLoss(distanceMeters: lengthMeters, frequencyMHz: frequencyMHz) - - var worstClearancePercent = Double.infinity - var peakDiffractionLoss = 0.0 - var obstructionPoints: [ObstructionPoint] = [] - - // Analyze each intermediate sample point (skip endpoints) - for sample in elevationProfile { - let distanceFromStart = sample.distanceFromAMeters - distanceOriginMeters - let distanceToEnd = lengthMeters - distanceFromStart - - // Skip points at or very near the endpoints - guard distanceFromStart > endpointSkipMarginMeters, - distanceToEnd > endpointSkipMarginMeters else { continue } - - // Line of sight height at this point (linear interpolation) - let fraction = distanceFromStart / lengthMeters - let losHeight = antennaStartHeight + fraction * (antennaEndHeight - antennaStartHeight) - - // Effective terrain height including earth bulge - let bulge = earthBulge( - distanceToAMeters: distanceFromStart, - distanceToBMeters: distanceToEnd, - refractionK: refractionK - ) - let effectiveTerrainHeight = sample.elevation + bulge - - // Calculate Fresnel zone radius at this point - let fresnelZoneRadius = fresnelRadius( - frequencyMHz: frequencyMHz, - distanceToAMeters: distanceFromStart, - distanceToBMeters: distanceToEnd - ) - - // Clearance: distance from terrain to line of sight - let clearance = losHeight - effectiveTerrainHeight - - // Fresnel clearance percentage - // 100% = terrain clears full first Fresnel zone - // 0% = terrain touches line of sight - // <0% = terrain blocks line of sight - let clearancePercent: Double - if fresnelZoneRadius > 0 { - clearancePercent = (clearance / fresnelZoneRadius) * 100 - } else { - clearancePercent = clearance > 0 ? 100 : 0 - } - - // Track worst clearance - if clearancePercent < worstClearancePercent { - worstClearancePercent = clearancePercent - } - - // Calculate diffraction loss if there's an obstruction - // Obstruction height is negative clearance (positive = blocked) - let obstructionHeight = effectiveTerrainHeight - losHeight - if obstructionHeight > -fresnelZoneRadius { - let diffLoss = diffractionLoss( - obstructionHeightMeters: obstructionHeight, - distanceToAMeters: distanceFromStart, - distanceToBMeters: distanceToEnd, - frequencyMHz: frequencyMHz - ) - if diffLoss > peakDiffractionLoss { - peakDiffractionLoss = diffLoss - } - } - - // Record obstruction points where clearance < marginal threshold - if clearancePercent < marginalClearanceThreshold { - let obstruction = ObstructionPoint( - distanceFromAMeters: sample.distanceFromAMeters, - obstructionHeightMeters: obstructionHeight, - fresnelClearancePercent: clearancePercent - ) - obstructionPoints.append(obstruction) - } - } - - // If no samples were analyzed, set default clearance - if worstClearancePercent == .infinity { - worstClearancePercent = 100 - } - - return PathAnalysisResult( - distanceMeters: lengthMeters, - freeSpacePathLoss: fspl, - peakDiffractionLoss: peakDiffractionLoss, - totalPathLoss: fspl + peakDiffractionLoss, - clearanceStatus: clearanceStatus(forWorstClearancePercent: worstClearancePercent), - worstClearancePercent: worstClearancePercent, - obstructionPoints: obstructionPoints, - frequencyMHz: frequencyMHz, - refractionK: refractionK + } + + // Record obstruction points where clearance < marginal threshold + if clearancePercent < marginalClearanceThreshold { + let obstruction = ObstructionPoint( + distanceFromAMeters: sample.distanceFromAMeters, + obstructionHeightMeters: obstructionHeight, + fresnelClearancePercent: clearancePercent ) + obstructionPoints.append(obstruction) + } } - /// The zeroed blocked result returned for degenerate profiles. - private static func emptyResult(frequencyMHz: Double, refractionK: Double) -> PathAnalysisResult { - PathAnalysisResult( - distanceMeters: 0, - freeSpacePathLoss: 0, - peakDiffractionLoss: 0, - totalPathLoss: 0, - clearanceStatus: .blocked, - worstClearancePercent: 0, - obstructionPoints: [], - frequencyMHz: frequencyMHz, - refractionK: refractionK - ) + // If no samples were analyzed, set default clearance + if worstClearancePercent == .infinity { + worstClearancePercent = 100 } - /// Maps a worst-case Fresnel clearance percentage to a clearance status. - private static func clearanceStatus(forWorstClearancePercent percent: Double) -> ClearanceStatus { - if percent >= clearClearanceThreshold { - return .clear - } else if percent >= marginalClearanceThreshold { - return .marginal - } else if percent >= 0 { - return .partialObstruction - } else { - return .blocked - } + return PathAnalysisResult( + distanceMeters: lengthMeters, + freeSpacePathLoss: fspl, + peakDiffractionLoss: peakDiffractionLoss, + totalPathLoss: fspl + peakDiffractionLoss, + clearanceStatus: clearanceStatus(forWorstClearancePercent: worstClearancePercent), + worstClearancePercent: worstClearancePercent, + obstructionPoints: obstructionPoints, + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + } + + /// The zeroed blocked result returned for degenerate profiles. + private static func emptyResult(frequencyMHz: Double, refractionK: Double) -> PathAnalysisResult { + PathAnalysisResult( + distanceMeters: 0, + freeSpacePathLoss: 0, + peakDiffractionLoss: 0, + totalPathLoss: 0, + clearanceStatus: .blocked, + worstClearancePercent: 0, + obstructionPoints: [], + frequencyMHz: frequencyMHz, + refractionK: refractionK + ) + } + + /// Maps a worst-case Fresnel clearance percentage to a clearance status. + private static func clearanceStatus(forWorstClearancePercent percent: Double) -> ClearanceStatus { + if percent >= clearClearanceThreshold { + .clear + } else if percent >= marginalClearanceThreshold { + .marginal + } else if percent >= 0 { + .partialObstruction + } else { + .blocked } + } } diff --git a/MC1Services/Sources/MC1Services/RF/RelayPathAnalysisResult.swift b/MC1Services/Sources/MC1Services/RF/RelayPathAnalysisResult.swift index 61310858..6d3466b8 100644 --- a/MC1Services/Sources/MC1Services/RF/RelayPathAnalysisResult.swift +++ b/MC1Services/Sources/MC1Services/RF/RelayPathAnalysisResult.swift @@ -2,25 +2,27 @@ import Foundation /// Combined result when analyzing a path via repeater public struct RelayPathAnalysisResult: Equatable, Sendable { - public let segmentAR: SegmentAnalysisResult - public let segmentRB: SegmentAnalysisResult + public let segmentAR: SegmentAnalysisResult + public let segmentRB: SegmentAnalysisResult - public init(segmentAR: SegmentAnalysisResult, segmentRB: SegmentAnalysisResult) { - self.segmentAR = segmentAR - self.segmentRB = segmentRB - } + public init(segmentAR: SegmentAnalysisResult, segmentRB: SegmentAnalysisResult) { + self.segmentAR = segmentAR + self.segmentRB = segmentRB + } - public var totalDistanceMeters: Double { - segmentAR.distanceMeters + segmentRB.distanceMeters - } + public var totalDistanceMeters: Double { + segmentAR.distanceMeters + segmentRB.distanceMeters + } - public var totalDistanceKm: Double { totalDistanceMeters / 1000 } + public var totalDistanceKm: Double { + totalDistanceMeters / 1000 + } - /// Overall status is the worst of the two segments - public var overallStatus: ClearanceStatus { - let statusOrder: [ClearanceStatus] = [.clear, .marginal, .partialObstruction, .blocked] - let arIndex = statusOrder.firstIndex(of: segmentAR.clearanceStatus) ?? 0 - let rbIndex = statusOrder.firstIndex(of: segmentRB.clearanceStatus) ?? 0 - return statusOrder[max(arIndex, rbIndex)] - } + /// Overall status is the worst of the two segments + public var overallStatus: ClearanceStatus { + let statusOrder: [ClearanceStatus] = [.clear, .marginal, .partialObstruction, .blocked] + let arIndex = statusOrder.firstIndex(of: segmentAR.clearanceStatus) ?? 0 + let rbIndex = statusOrder.firstIndex(of: segmentRB.clearanceStatus) ?? 0 + return statusOrder[max(arIndex, rbIndex)] + } } diff --git a/MC1Services/Sources/MC1Services/RF/SegmentAnalysisResult.swift b/MC1Services/Sources/MC1Services/RF/SegmentAnalysisResult.swift index 9680d889..9b57549a 100644 --- a/MC1Services/Sources/MC1Services/RF/SegmentAnalysisResult.swift +++ b/MC1Services/Sources/MC1Services/RF/SegmentAnalysisResult.swift @@ -2,25 +2,27 @@ import Foundation /// Result for a single path segment (A to R, or R to B) public struct SegmentAnalysisResult: Equatable, Sendable { - public let startLabel: String - public let endLabel: String - public let clearanceStatus: ClearanceStatus - public let distanceMeters: Double - public let worstClearancePercent: Double + public let startLabel: String + public let endLabel: String + public let clearanceStatus: ClearanceStatus + public let distanceMeters: Double + public let worstClearancePercent: Double - public init( - startLabel: String, - endLabel: String, - clearanceStatus: ClearanceStatus, - distanceMeters: Double, - worstClearancePercent: Double - ) { - self.startLabel = startLabel - self.endLabel = endLabel - self.clearanceStatus = clearanceStatus - self.distanceMeters = distanceMeters - self.worstClearancePercent = worstClearancePercent - } + public init( + startLabel: String, + endLabel: String, + clearanceStatus: ClearanceStatus, + distanceMeters: Double, + worstClearancePercent: Double + ) { + self.startLabel = startLabel + self.endLabel = endLabel + self.clearanceStatus = clearanceStatus + self.distanceMeters = distanceMeters + self.worstClearancePercent = worstClearancePercent + } - public var distanceKm: Double { distanceMeters / 1000 } + public var distanceKm: Double { + distanceMeters / 1000 + } } diff --git a/MC1Services/Sources/MC1Services/ServiceContainer.swift b/MC1Services/Sources/MC1Services/ServiceContainer.swift index 95ddd964..1d2e752a 100644 --- a/MC1Services/Sources/MC1Services/ServiceContainer.swift +++ b/MC1Services/Sources/MC1Services/ServiceContainer.swift @@ -1,7 +1,7 @@ import Foundation +import MeshCore import OSLog import SwiftData -import MeshCore /// Dependency injection container for MC1Services. /// @@ -66,437 +66,435 @@ import MeshCore @Observable @MainActor public final class ServiceContainer { + // MARK: - Core Infrastructure - // MARK: - Core Infrastructure - - /// The MeshCore session for device communication - public let session: MeshCoreSession - - /// The persistence store for SwiftData operations - public let dataStore: PersistenceStore - - /// Persists inline-image aspect ratios for chat link previews. Built early - /// because downstream caches and the prefetcher depend on it. - public let inlineImageDimensionsStore: InlineImageDimensionsStore - - // MARK: - Independent Services - - /// Keychain service for secure credential storage - let keychainService: KeychainService - - /// Notification service for local notifications - public let notificationService: NotificationService - - // MARK: - Core Services - - /// Service for managing contacts - public let contactService: ContactService + /// The MeshCore session for device communication + public let session: MeshCoreSession - /// Service for sending messages, retry logic, and ACK/delivery tracking. - /// It does not receive: inbound messages arrive through `MessagePollingService` - /// and the handlers `SyncCoordinator.wireMessageHandlers` installs there. - public let messageService: MessageService + /// The persistence store for SwiftData operations + public let dataStore: PersistenceStore - /// Service for managing channels (groups) - public let channelService: ChannelService + /// Persists inline-image aspect ratios for chat link previews. Built early + /// because downstream caches and the prefetcher depend on it. + public let inlineImageDimensionsStore: InlineImageDimensionsStore - /// Service for device settings management - public let settingsService: SettingsService + // MARK: - Independent Services - /// Service for device data persistence - public let deviceService: DeviceService + /// Keychain service for secure credential storage + let keychainService: KeychainService - /// Service for advertisements and path discovery - public let advertisementService: AdvertisementService + /// Notification service for local notifications + public let notificationService: NotificationService - /// Service for polling and routing messages - let messagePollingService: MessagePollingService + // MARK: - Core Services - /// Service for binary protocol operations (telemetry, status, etc.) - public let binaryProtocolService: BinaryProtocolService + /// Service for managing contacts + public let contactService: ContactService - /// Service for RX log packet capture - public let rxLogService: RxLogService + /// Service for sending messages, retry logic, and ACK/delivery tracking. + /// It does not receive: inbound messages arrive through `MessagePollingService` + /// and the handlers `SyncCoordinator.wireMessageHandlers` installs there. + public let messageService: MessageService - /// Service for tracking heard repeats of sent messages - public let heardRepeatsService: HeardRepeatsService + /// Service for managing channels (groups) + public let channelService: ChannelService - /// Buffer for batching debug log entries to persistence - public let debugLogBuffer: DebugLogBuffer + /// Service for device settings management + public let settingsService: SettingsService - /// Service for handling emoji reactions on channel messages - public let reactionService: ReactionService + /// Service for device data persistence + public let deviceService: DeviceService - /// Service for exporting/importing node configuration - public let nodeConfigService: NodeConfigService + /// Service for advertisements and path discovery + public let advertisementService: AdvertisementService - /// Service for node status history snapshots - public let nodeSnapshotService: NodeSnapshotService + /// Service for polling and routing messages + let messagePollingService: MessagePollingService - // MARK: - Remote Node Services + /// Service for binary protocol operations (telemetry, status, etc.) + public let binaryProtocolService: BinaryProtocolService - /// Service for remote node session management - public let remoteNodeService: RemoteNodeService + /// Service for RX log packet capture + public let rxLogService: RxLogService - /// Service for repeater administration - public let repeaterAdminService: RepeaterAdminService + /// Service for tracking heard repeats of sent messages + public let heardRepeatsService: HeardRepeatsService - /// Service for room server administration (telemetry, settings) - public let roomAdminService: RoomAdminService + /// Buffer for batching debug log entries to persistence + public let debugLogBuffer: DebugLogBuffer - /// Service for room server operations - public let roomServerService: RoomServerService + /// Service for handling emoji reactions on channel messages + public let reactionService: ReactionService - // MARK: - Sync Coordination + /// Service for exporting/importing node configuration + public let nodeConfigService: NodeConfigService - /// Sync coordinator for managing sync lifecycle - public let syncCoordinator: SyncCoordinator + /// Service for node status history snapshots + public let nodeSnapshotService: NodeSnapshotService - // MARK: - Chat Send Queue + // MARK: - Remote Node Services + + /// Service for remote node session management + public let remoteNodeService: RemoteNodeService - /// Service-layer outbound chat queue. Replaces the per-view-model - /// `dmSendQueue` / `channelSendQueue` instances. Hydrates from - /// `PendingSend` on construction; drain gated on `ConnectionManager` - /// transport state via `BLETransportOpenedSignal`. - /// - /// Constructed eagerly in `ServiceContainer.init` because `radioID` - /// is known at container-build time (`buildServicesAndSaveDevice` - /// resolves the device record before instantiating the container). - /// Eager construction closes the visibility window where the - /// container exists but the service is `nil`. - public let chatSendQueueService: ChatSendQueueService - - // MARK: - Notification Actions - - /// Executes the multi-service transactions behind notification actions - /// (quick reply, mark-as-read, reactions). `AppState` installs - /// `NotificationService` forwarders that delegate here and injects the - /// app-layer inputs via `configure(isConnectionReady:localNodeName:)`. - public let notificationActionHandler: NotificationActionHandler - - // MARK: - App State - - /// Provider for checking app foreground/background state - /// Used to determine sync behavior (full vs incremental) - let appStateProvider: AppStateProvider? - - // MARK: - State - - /// Event-monitoring lifecycle. Tri-state (not a Bool) so start and stop can - /// claim the transition synchronously before their first await; two callers - /// interleaving at those suspension points must not double-start or - /// double-stop every per-service monitor. - private enum EventMonitoringState { - case stopped - case starting - case active - case stopping + /// Service for repeater administration + public let repeaterAdminService: RepeaterAdminService + + /// Service for room server administration (telemetry, settings) + public let roomAdminService: RoomAdminService + + /// Service for room server operations + public let roomServerService: RoomServerService + + // MARK: - Sync Coordination + + /// Sync coordinator for managing sync lifecycle + public let syncCoordinator: SyncCoordinator + + // MARK: - Chat Send Queue + + /// Service-layer outbound chat queue. Replaces the per-view-model + /// `dmSendQueue` / `channelSendQueue` instances. Hydrates from + /// `PendingSend` on construction; drain gated on `ConnectionManager` + /// transport state via `BLETransportOpenedSignal`. + /// + /// Constructed eagerly in `ServiceContainer.init` because `radioID` + /// is known at container-build time (`buildServicesAndSaveDevice` + /// resolves the device record before instantiating the container). + /// Eager construction closes the visibility window where the + /// container exists but the service is `nil`. + public let chatSendQueueService: ChatSendQueueService + + // MARK: - Notification Actions + + /// Executes the multi-service transactions behind notification actions + /// (quick reply, mark-as-read, reactions). `AppState` installs + /// `NotificationService` forwarders that delegate here and injects the + /// app-layer inputs via `configure(isConnectionReady:localNodeName:)`. + public let notificationActionHandler: NotificationActionHandler + + // MARK: - App State + + /// Provider for checking app foreground/background state + /// Used to determine sync behavior (full vs incremental) + let appStateProvider: AppStateProvider? + + // MARK: - State + + /// Event-monitoring lifecycle. Tri-state (not a Bool) so start and stop can + /// claim the transition synchronously before their first await; two callers + /// interleaving at those suspension points must not double-start or + /// double-stop every per-service monitor. + private enum EventMonitoringState { + case stopped + case starting + case active + case stopping + } + + private var eventMonitoringState: EventMonitoringState = .stopped + + /// Whether service event listeners are active or currently starting. + var isEventMonitoringActive: Bool { + eventMonitoringState == .starting || eventMonitoringState == .active + } + + // MARK: - Initialization + + /// Creates a new service container. + /// + /// - Parameters: + /// - session: The MeshCoreSession for device communication + /// - modelContainer: The SwiftData model container for persistence + /// - radioID: The connected device's radio ID. Used to scope the + /// chat send queue's pending-send rows so two radios cannot share + /// drain state across reconnects. + /// - appStateProvider: Optional provider for app foreground/background state + /// - connectionStateEvents: Optional broadcaster of connection-state + /// changes. When provided, the chat send queue observes it to wake + /// parked drains on each disconnected-to-connected edge. + /// - initialConnectionState: The connection state at container build + /// time. Connect paths reach `.connected` before constructing the + /// container, so the queue's edge detection treats an + /// already-connected initial value as a fired edge. + init( + session: MeshCoreSession, + modelContainer: ModelContainer, + radioID: UUID, + appStateProvider: AppStateProvider? = nil, + connectionStateEvents: EventBroadcaster? = nil, + initialConnectionState: DeviceConnectionState = .disconnected + ) { + self.session = session + self.appStateProvider = appStateProvider + dataStore = PersistenceStore(modelContainer: modelContainer) + inlineImageDimensionsStore = InlineImageDimensionsStore() + + // Independent services (no dependencies) + keychainService = KeychainService() + notificationService = NotificationService() + syncCoordinator = SyncCoordinator() + + // Core services, constructed so every dependency exists before its consumer + heardRepeatsService = HeardRepeatsService(dataStore: dataStore) + rxLogService = RxLogService( + session: session, + dataStore: dataStore, + heardRepeatsService: heardRepeatsService + ) + remoteNodeService = RemoteNodeService( + session: session, + dataStore: dataStore, + keychainService: keychainService + ) + let cleanupCoordinator = ContactCleanupCoordinator( + dataStore: dataStore, + syncCoordinator: syncCoordinator, + notificationService: notificationService, + remoteNodeService: remoteNodeService + ) + contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: syncCoordinator, + cleanupCoordinator: cleanupCoordinator + ) + messageService = MessageService( + session: session, + dataStore: dataStore, + contactService: contactService + ) + channelService = ChannelService( + session: session, + dataStore: dataStore, + rxLogService: rxLogService + ) + settingsService = SettingsService(session: session) + deviceService = DeviceService(dataStore: dataStore) + advertisementService = AdvertisementService(session: session, dataStore: dataStore) + messagePollingService = MessagePollingService(session: session, dataStore: dataStore) + binaryProtocolService = BinaryProtocolService(session: session, dataStore: dataStore) + debugLogBuffer = DebugLogBuffer(dataStore: dataStore) + DebugLogBuffer.shared = debugLogBuffer + reactionService = ReactionService() + nodeConfigService = NodeConfigService( + session: session, + settingsService: settingsService, + channelService: channelService, + dataStore: dataStore, + syncCoordinator: syncCoordinator + ) + nodeSnapshotService = NodeSnapshotService(dataStore: dataStore) + + // Higher-level services (depend on other services) + repeaterAdminService = RepeaterAdminService( + session: session, + remoteNodeService: remoteNodeService, + dataStore: dataStore + ) + roomAdminService = RoomAdminService( + remoteNodeService: remoteNodeService, + dataStore: dataStore + ) + roomServerService = RoomServerService( + session: session, + remoteNodeService: remoteNodeService, + dataStore: dataStore + ) + + chatSendQueueService = ChatSendQueueService( + radioID: radioID, + dataStore: dataStore, + messageService: messageService, + channelService: channelService, + reactionService: reactionService + ) + notificationActionHandler = NotificationActionHandler( + dataStore: dataStore, + messageService: messageService, + notificationService: notificationService, + roomServerService: roomServerService, + syncCoordinator: syncCoordinator + ) + if let connectionStateEvents { + chatSendQueueService.observeConnectionState( + initial: initialConnectionState, + events: connectionStateEvents.subscribe() + ) } - - private var eventMonitoringState: EventMonitoringState = .stopped - - /// Whether service event listeners are active or currently starting. - var isEventMonitoringActive: Bool { - eventMonitoringState == .starting || eventMonitoringState == .active - } - - // MARK: - Initialization - - /// Creates a new service container. - /// - /// - Parameters: - /// - session: The MeshCoreSession for device communication - /// - modelContainer: The SwiftData model container for persistence - /// - radioID: The connected device's radio ID. Used to scope the - /// chat send queue's pending-send rows so two radios cannot share - /// drain state across reconnects. - /// - appStateProvider: Optional provider for app foreground/background state - /// - connectionStateEvents: Optional broadcaster of connection-state - /// changes. When provided, the chat send queue observes it to wake - /// parked drains on each disconnected-to-connected edge. - /// - initialConnectionState: The connection state at container build - /// time. Connect paths reach `.connected` before constructing the - /// container, so the queue's edge detection treats an - /// already-connected initial value as a fired edge. - init( - session: MeshCoreSession, - modelContainer: ModelContainer, - radioID: UUID, - appStateProvider: AppStateProvider? = nil, - connectionStateEvents: EventBroadcaster? = nil, - initialConnectionState: DeviceConnectionState = .disconnected - ) { - self.session = session - self.appStateProvider = appStateProvider - self.dataStore = PersistenceStore(modelContainer: modelContainer) - self.inlineImageDimensionsStore = InlineImageDimensionsStore() - - // Independent services (no dependencies) - self.keychainService = KeychainService() - self.notificationService = NotificationService() - self.syncCoordinator = SyncCoordinator() - - // Core services, constructed so every dependency exists before its consumer - self.heardRepeatsService = HeardRepeatsService(dataStore: dataStore) - self.rxLogService = RxLogService( - session: session, - dataStore: dataStore, - heardRepeatsService: heardRepeatsService + } + + // MARK: - Event Monitoring + + /// Starts event monitoring for all services. + /// + /// Call this after a device is connected to begin processing events + /// from the MeshCoreSession. + /// + /// - Parameters: + /// - radioID: The connected device's radio ID for data scoping + /// - enableAutoFetch: Whether to start message auto-fetch immediately (default true) + /// - enableAdvertisementMonitoring: Whether to start advertisement monitoring immediately (default true) + func startEventMonitoring( + radioID: UUID, + enableAutoFetch: Bool = true, + enableAdvertisementMonitoring: Bool = true + ) async { + // Claim synchronously before the awaits below so an overlapping caller + // (SyncCoordinator.onConnectionEstablished racing the foreground health + // check) cannot pass the guard and double-start every monitor. + guard eventMonitoringState == .stopped else { return } + eventMonitoringState = .starting + + let logger = Logger(subsystem: "com.mc1", category: "ServiceContainer") + + // Configure HeardRepeatsService with device info + do { + if let device = try await dataStore.fetchDevice(radioID: radioID) { + await heardRepeatsService.configure( + radioID: radioID, + localNodeName: device.nodeName ) - self.remoteNodeService = RemoteNodeService( - session: session, - dataStore: dataStore, - keychainService: keychainService - ) - let cleanupCoordinator = ContactCleanupCoordinator( - dataStore: dataStore, - syncCoordinator: syncCoordinator, - notificationService: notificationService, - remoteNodeService: remoteNodeService - ) - self.contactService = ContactService( - session: session, - dataStore: dataStore, - syncCoordinator: syncCoordinator, - cleanupCoordinator: cleanupCoordinator - ) - self.messageService = MessageService( - session: session, - dataStore: dataStore, - contactService: contactService - ) - self.channelService = ChannelService( - session: session, - dataStore: dataStore, - rxLogService: rxLogService - ) - self.settingsService = SettingsService(session: session) - self.deviceService = DeviceService(dataStore: dataStore) - self.advertisementService = AdvertisementService(session: session, dataStore: dataStore) - self.messagePollingService = MessagePollingService(session: session, dataStore: dataStore) - self.binaryProtocolService = BinaryProtocolService(session: session, dataStore: dataStore) - self.debugLogBuffer = DebugLogBuffer(dataStore: dataStore) - DebugLogBuffer.shared = debugLogBuffer - self.reactionService = ReactionService() - self.nodeConfigService = NodeConfigService( - session: session, - settingsService: settingsService, - channelService: channelService, - dataStore: dataStore, - syncCoordinator: syncCoordinator - ) - self.nodeSnapshotService = NodeSnapshotService(dataStore: dataStore) - - // Higher-level services (depend on other services) - self.repeaterAdminService = RepeaterAdminService( - session: session, - remoteNodeService: remoteNodeService, - dataStore: dataStore - ) - self.roomAdminService = RoomAdminService( - remoteNodeService: remoteNodeService, - dataStore: dataStore - ) - self.roomServerService = RoomServerService( - session: session, - remoteNodeService: remoteNodeService, - dataStore: dataStore - ) - - self.chatSendQueueService = ChatSendQueueService( - radioID: radioID, - dataStore: dataStore, - messageService: messageService, - channelService: channelService, - reactionService: reactionService - ) - self.notificationActionHandler = NotificationActionHandler( - dataStore: dataStore, - messageService: messageService, - notificationService: notificationService, - roomServerService: roomServerService, - syncCoordinator: syncCoordinator - ) - if let connectionStateEvents { - chatSendQueueService.observeConnectionState( - initial: initialConnectionState, - events: connectionStateEvents.subscribe() - ) - } + } else { + logger.warning("Device not found for HeardRepeatsService configuration") + } + } catch { + logger.warning("Failed to fetch device for HeardRepeatsService: \(error)") } - // MARK: - Event Monitoring - - /// Starts event monitoring for all services. - /// - /// Call this after a device is connected to begin processing events - /// from the MeshCoreSession. - /// - /// - Parameters: - /// - radioID: The connected device's radio ID for data scoping - /// - enableAutoFetch: Whether to start message auto-fetch immediately (default true) - /// - enableAdvertisementMonitoring: Whether to start advertisement monitoring immediately (default true) - func startEventMonitoring( - radioID: UUID, - enableAutoFetch: Bool = true, - enableAdvertisementMonitoring: Bool = true - ) async { - // Claim synchronously before the awaits below so an overlapping caller - // (SyncCoordinator.onConnectionEstablished racing the foreground health - // check) cannot pass the guard and double-start every monitor. - guard eventMonitoringState == .stopped else { return } - eventMonitoringState = .starting - - let logger = Logger(subsystem: "com.mc1", category: "ServiceContainer") - - // Configure HeardRepeatsService with device info - do { - if let device = try await dataStore.fetchDevice(radioID: radioID) { - await heardRepeatsService.configure( - radioID: radioID, - localNodeName: device.nodeName - ) - } else { - logger.warning("Device not found for HeardRepeatsService configuration") - } - } catch { - logger.warning("Failed to fetch device for HeardRepeatsService: \(error)") - } - - // Start event monitoring for services that need it - if enableAdvertisementMonitoring { - await advertisementService.startEventMonitoring(radioID: radioID) - } - await rxLogService.startEventMonitoring(radioID: radioID) - await messageService.startEventMonitoring() - await messageService.startAckExpiryChecking() - await remoteNodeService.startEventMonitoring() - - // Always start message event monitoring so handlers are ready for polled messages - await messagePollingService.startMessageEventMonitoring(radioID: radioID) - if enableAutoFetch { - await messagePollingService.startAutoFetch(radioID: radioID) - } - - // Prune debug logs on connection - Task { - try? await dataStore.pruneDebugLogEntries(keepCount: 1000) - } - - // Prune node status snapshots older than 1 year - Task { - let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: .now)! - await nodeSnapshotService.pruneOldSnapshots(olderThan: oneYearAgo) - } - - eventMonitoringState = .active + // Start event monitoring for services that need it + if enableAdvertisementMonitoring { + await advertisementService.startEventMonitoring(radioID: radioID) } - - /// Stops event monitoring for all services. - /// - /// Call this when disconnecting from a device. - func stopEventMonitoring() async { - // Claimed synchronously, mirroring startEventMonitoring, so two teardown - // paths interleaving at the awaits below cannot double-stop. - guard eventMonitoringState == .active else { return } - eventMonitoringState = .stopping - - await advertisementService.stopEventMonitoring() - await rxLogService.stopEventMonitoring() - await messageService.stopEventMonitoring() - // Do not fail in-flight DMs on disconnect. The firmware retains the - // expected ACK and re-emits the delivery confirmation whenever it - // returns, so a routine BLE cycle must not mark a delivered message - // `.failed`. Stop only the expiry checker; pending entries resolve on - // reconnect within the same session or expire via `ackGiveUpWindow`. - await messageService.stopAckExpiryChecking() - await messagePollingService.stopMessageEventMonitoring() - // RemoteNodeService event monitoring is per-session, handled internally - - // Flush debug log buffer - await debugLogBuffer.shutdown() - - eventMonitoringState = .stopped + await rxLogService.startEventMonitoring(radioID: radioID) + await messageService.startEventMonitoring() + await messageService.startAckExpiryChecking() + await remoteNodeService.startEventMonitoring() + + // Always start message event monitoring so handlers are ready for polled messages + await messagePollingService.startMessageEventMonitoring(radioID: radioID) + if enableAutoFetch { + await messagePollingService.startAutoFetch(radioID: radioID) } - /// Full container teardown. Must be awaited before nulling the container - /// so chat send queue drains and chat coordinator off-main builds release - /// the strong references they hold on `MessageService` and `dataStore`. - /// `stopEventMonitoring()` alone does not cover those. - func tearDown() async { - await stopEventMonitoring() - - // Break the retain cycles the wired handlers form. The message - // closures and the discovery event task capture this container's - // services strongly (via SyncDependencies), so without this the whole - // service graph leaks on every reconnect. Cleared after - // `stopEventMonitoring()` so the event tasks reading them are cancelled. - await messagePollingService.clearMessageHandlers() - await syncCoordinator.cancelDiscoveryEventMonitoring() - - // Finishing the event streams ends every consumer's for-await loop, - // releasing the strong service references those loops hold. - syncCoordinator.finishDataEvents() - advertisementService.finishEvents() - messageService.finishStatusEvents() - heardRepeatsService.finishEvents() - remoteNodeService.finishEvents() - roomServerService.finishEvents() - contactService.finishEvents() - rxLogService.finishEntryStream() - - // The action forwarders AppState installs capture notificationActionHandler - // strongly, and the handler strong-holds notificationService back, forming a - // cycle that outlives the container. Clear them so the per-connection service - // graph is released on teardown. - notificationService.onQuickReply = nil - notificationService.onChannelQuickReply = nil - notificationService.onMarkAsRead = nil - notificationService.onChannelMarkAsRead = nil - notificationService.onRoomMarkAsRead = nil - - await chatSendQueueService.shutdown() + // Prune debug logs on connection + Task { + try? await dataStore.pruneDebugLogEntries(keepCount: 1000) } - // MARK: - Convenience Methods - - /// Performs initial database warm-up. - /// - /// Call this early during app launch to avoid lazy initialization delays. - func warmUp() async throws { - try await dataStore.warmUp() + // Prune node status snapshots older than 1 year + Task { + let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: .now)! + await nodeSnapshotService.pruneOldSnapshots(olderThan: oneYearAgo) } - /// Resets all remote node session connections. - /// - /// Call this on app launch since connections don't persist across app restarts. - func resetRemoteNodeConnections() async throws { - try await dataStore.resetAllRemoteNodeSessionConnections() - } + eventMonitoringState = .active + } + + /// Stops event monitoring for all services. + /// + /// Call this when disconnecting from a device. + func stopEventMonitoring() async { + // Claimed synchronously, mirroring startEventMonitoring, so two teardown + // paths interleaving at the awaits below cannot double-stop. + guard eventMonitoringState == .active else { return } + eventMonitoringState = .stopping + + await advertisementService.stopEventMonitoring() + await rxLogService.stopEventMonitoring() + await messageService.stopEventMonitoring() + // Do not fail in-flight DMs on disconnect. The firmware retains the + // expected ACK and re-emits the delivery confirmation whenever it + // returns, so a routine BLE cycle must not mark a delivered message + // `.failed`. Stop only the expiry checker; pending entries resolve on + // reconnect within the same session or expire via `ackGiveUpWindow`. + await messageService.stopAckExpiryChecking() + await messagePollingService.stopMessageEventMonitoring() + // RemoteNodeService event monitoring is per-session, handled internally + + // Flush debug log buffer + await debugLogBuffer.shutdown() + + eventMonitoringState = .stopped + } + + /// Full container teardown. Must be awaited before nulling the container + /// so chat send queue drains and chat coordinator off-main builds release + /// the strong references they hold on `MessageService` and `dataStore`. + /// `stopEventMonitoring()` alone does not cover those. + func tearDown() async { + await stopEventMonitoring() + + // Break the retain cycles the wired handlers form. The message + // closures and the discovery event task capture this container's + // services strongly (via SyncDependencies), so without this the whole + // service graph leaks on every reconnect. Cleared after + // `stopEventMonitoring()` so the event tasks reading them are cancelled. + await messagePollingService.clearMessageHandlers() + await syncCoordinator.cancelDiscoveryEventMonitoring() + + // Finishing the event streams ends every consumer's for-await loop, + // releasing the strong service references those loops hold. + syncCoordinator.finishDataEvents() + advertisementService.finishEvents() + messageService.finishStatusEvents() + heardRepeatsService.finishEvents() + remoteNodeService.finishEvents() + roomServerService.finishEvents() + contactService.finishEvents() + rxLogService.finishEntryStream() + + // The action forwarders AppState installs capture notificationActionHandler + // strongly, and the handler strong-holds notificationService back, forming a + // cycle that outlives the container. Clear them so the per-connection service + // graph is released on teardown. + notificationService.onQuickReply = nil + notificationService.onChannelQuickReply = nil + notificationService.onMarkAsRead = nil + notificationService.onChannelMarkAsRead = nil + notificationService.onRoomMarkAsRead = nil + + await chatSendQueueService.shutdown() + } + + // MARK: - Convenience Methods + + /// Performs initial database warm-up. + /// + /// Call this early during app launch to avoid lazy initialization delays. + func warmUp() async throws { + try await dataStore.warmUp() + } + + /// Resets all remote node session connections. + /// + /// Call this on app launch since connections don't persist across app restarts. + func resetRemoteNodeConnections() async throws { + try await dataStore.resetAllRemoteNodeSessionConnections() + } } // MARK: - Factory Methods extension ServiceContainer { - - /// Creates a service container with a new in-memory model container. - /// - /// Useful for testing and previews. The container is fully wired by `init`, - /// matching production behavior. - /// - /// - Parameters: - /// - session: The MeshCoreSession for device communication - /// - radioID: Radio ID to scope the chat send queue (default: synthesized `UUID()`) - /// - Returns: A configured ServiceContainer with in-memory storage - static func forTesting( - session: MeshCoreSession, - radioID: UUID = UUID() - ) async throws -> ServiceContainer { - let container = try PersistenceStore.createContainer(inMemory: true) - return ServiceContainer( - session: session, - modelContainer: container, - radioID: radioID - ) - } + /// Creates a service container with a new in-memory model container. + /// + /// Useful for testing and previews. The container is fully wired by `init`, + /// matching production behavior. + /// + /// - Parameters: + /// - session: The MeshCoreSession for device communication + /// - radioID: Radio ID to scope the chat send queue (default: synthesized `UUID()`) + /// - Returns: A configured ServiceContainer with in-memory storage + static func forTesting( + session: MeshCoreSession, + radioID: UUID = UUID() + ) async throws -> ServiceContainer { + let container = try PersistenceStore.createContainer(inMemory: true) + return ServiceContainer( + session: session, + modelContainer: container, + radioID: radioID + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitDiscoveryCriteria.swift b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitDiscoveryCriteria.swift index 38445cf2..79a15016 100644 --- a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitDiscoveryCriteria.swift +++ b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitDiscoveryCriteria.swift @@ -1,41 +1,41 @@ import Foundation -struct AccessorySetupKitDiscoveryCriterion: Equatable, Sendable { - let bluetoothServiceUUID: String - let bluetoothNameSubstring: String +struct AccessorySetupKitDiscoveryCriterion: Equatable { + let bluetoothServiceUUID: String + let bluetoothNameSubstring: String } enum AccessorySetupKitDiscoveryCriteria { - /// Opt into AccessorySetupKit filtered discovery (iOS 26.1+) so the picker can relabel - /// each match with its advertised BLE name; older systems use the static picker label. - static let usesFilteredDiscovery = true + /// Opt into AccessorySetupKit filtered discovery (iOS 26.1+) so the picker can relabel + /// each match with its advertised BLE name; older systems use the static picker label. + static let usesFilteredDiscovery = true - static let bluetoothNameSubstrings = [ - "MeshCore-", - "Whisper-", - "WisCore", - "XIAO", - "elecrow", - "HT-n5262", - "Seeed", - "BQ", - "ProMicro", - "Keepteen", - "Meshtiny", - "T1000-E-BOOT", - "me25ls01-BOOT", - "NRF52 DK", - "T-Impulse", - ] + static let bluetoothNameSubstrings = [ + "MeshCore-", + "Whisper-", + "WisCore", + "XIAO", + "elecrow", + "HT-n5262", + "Seeed", + "BQ", + "ProMicro", + "Keepteen", + "Meshtiny", + "T1000-E-BOOT", + "me25ls01-BOOT", + "NRF52 DK", + "T-Impulse", + ] - static let supportedBluetoothCriteria = bluetoothNameSubstrings.map { - AccessorySetupKitDiscoveryCriterion( - bluetoothServiceUUID: BLEServiceUUID.nordicUART, - bluetoothNameSubstring: $0 - ) - } + static let supportedBluetoothCriteria = bluetoothNameSubstrings.map { + AccessorySetupKitDiscoveryCriterion( + bluetoothServiceUUID: BLEServiceUUID.nordicUART, + bluetoothNameSubstring: $0 + ) + } - static let diagnosticsSummary = supportedBluetoothCriteria - .map { "\($0.bluetoothNameSubstring)->\($0.bluetoothServiceUUID)" } - .joined(separator: ", ") + static let diagnosticsSummary = supportedBluetoothCriteria + .map { "\($0.bluetoothNameSubstring)->\($0.bluetoothServiceUUID)" } + .joined(separator: ", ") } diff --git a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitLogFormatter.swift b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitLogFormatter.swift index b36b1214..8f59de79 100644 --- a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitLogFormatter.swift +++ b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitLogFormatter.swift @@ -1,40 +1,40 @@ import Foundation enum AccessorySetupKitLogFormatter { - private static let criteriaPreviewCount = 4 + private static let criteriaPreviewCount = 4 - static func criteriaSummary(_ criteria: [AccessorySetupKitDiscoveryCriterion]) -> String { - let preview = criteria.prefix(criteriaPreviewCount).map(\.bluetoothNameSubstring) - let remainderCount = criteria.count - preview.count - let previewText = preview.joined(separator: ", ") + static func criteriaSummary(_ criteria: [AccessorySetupKitDiscoveryCriterion]) -> String { + let preview = criteria.prefix(criteriaPreviewCount).map(\.bluetoothNameSubstring) + let remainderCount = criteria.count - preview.count + let previewText = preview.joined(separator: ", ") - if remainderCount > 0 { - return "\(criteria.count) prefixes [\(previewText), +\(remainderCount) more]" - } - - return "\(criteria.count) prefixes [\(previewText)]" + if remainderCount > 0 { + return "\(criteria.count) prefixes [\(previewText), +\(remainderCount) more]" } - static func selectionMessage( - accessoryName: String, - bluetoothID: UUID?, - elapsed: TimeInterval? - ) -> String { - let identifier = bluetoothID?.uuidString ?? "none" - return "[ASK] Selected accessory '\(accessoryName)' (id: \(identifier)) after \(durationSummary(elapsed))" - } + return "\(criteria.count) prefixes [\(previewText)]" + } - static func dismissalMessage( - outcome: String, - pairedCount: Int, - elapsed: TimeInterval?, - filteredDiscovery: Bool - ) -> String { - "[ASK] Picker dismissed after \(durationSummary(elapsed)) (outcome: \(outcome), filteredDiscovery: \(filteredDiscovery), pairedCount: \(pairedCount))" - } + static func selectionMessage( + accessoryName: String, + bluetoothID: UUID?, + elapsed: TimeInterval? + ) -> String { + let identifier = bluetoothID?.uuidString ?? "none" + return "[ASK] Selected accessory '\(accessoryName)' (id: \(identifier)) after \(durationSummary(elapsed))" + } - private static func durationSummary(_ elapsed: TimeInterval?) -> String { - guard let elapsed else { return "unknown" } - return "\(elapsed.formatted(.number.precision(.fractionLength(1))))s" - } + static func dismissalMessage( + outcome: String, + pairedCount: Int, + elapsed: TimeInterval?, + filteredDiscovery: Bool + ) -> String { + "[ASK] Picker dismissed after \(durationSummary(elapsed)) (outcome: \(outcome), filteredDiscovery: \(filteredDiscovery), pairedCount: \(pairedCount))" + } + + private static func durationSummary(_ elapsed: TimeInterval?) -> String { + guard let elapsed else { return "unknown" } + return "\(elapsed.formatted(.number.precision(.fractionLength(1))))s" + } } diff --git a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitService.swift b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitService.swift index fe613371..57dcc49e 100644 --- a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitService.swift +++ b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitService.swift @@ -1,36 +1,36 @@ #if canImport(UIKit) -import AccessorySetupKit -import CoreBluetooth -import UIKit -import os - -/// Delegate protocol for accessory state changes -/// Must be @MainActor since implementations access main-actor-isolated state -@MainActor -protocol AccessorySetupKitServiceDelegate: AnyObject { + import AccessorySetupKit + import CoreBluetooth + import os + import UIKit + + /// Delegate protocol for accessory state changes + /// Must be @MainActor since implementations access main-actor-isolated state + @MainActor + protocol AccessorySetupKitServiceDelegate: AnyObject { /// Called when an accessory is removed from Settings > Accessories func accessorySetupKitService(_ service: AccessorySetupKitService, didRemoveAccessoryWithID bluetoothID: UUID) /// Called when pairing fails (e.g., wrong PIN). The device should be cleaned up from local storage. func accessorySetupKitService(_ service: AccessorySetupKitService, didFailPairingForAccessoryWithID bluetoothID: UUID) -} - -/// Manages AccessorySetupKit session for device discovery and pairing. -/// -/// AccessorySetupKit is Apple's modern framework for Bluetooth accessory setup. -/// Starting with iOS 26, only apps using AccessorySetupKit will be relaunched -/// for Bluetooth state restoration events. -/// -/// ## Usage -/// -/// ```swift -/// let accessoryService = AccessorySetupKitService() -/// try await accessoryService.activateSession() -/// let deviceUUID = try await accessoryService.showPicker() -/// ``` -@Observable -@MainActor -final class AccessorySetupKitService { + } + + /// Manages AccessorySetupKit session for device discovery and pairing. + /// + /// AccessorySetupKit is Apple's modern framework for Bluetooth accessory setup. + /// Starting with iOS 26, only apps using AccessorySetupKit will be relaunched + /// for Bluetooth state restoration events. + /// + /// ## Usage + /// + /// ```swift + /// let accessoryService = AccessorySetupKitService() + /// try await accessoryService.activateSession() + /// let deviceUUID = try await accessoryService.showPicker() + /// ``` + @Observable + @MainActor + final class AccessorySetupKitService { private let logger = PersistentLogger(subsystem: "com.mc1", category: "AccessorySetupKit") private var session: ASAccessorySession? @@ -82,14 +82,14 @@ final class AccessorySetupKitService { /// Safely resume the picker continuation exactly once /// Clears continuation BEFORE resuming to prevent double-resume race conditions private func resumePickerContinuation(with result: Result) { - guard let continuation = pickerContinuation else { return } - pickerContinuation = nil // Clear BEFORE resuming - switch result { - case .success(let uuid): - continuation.resume(returning: uuid) - case .failure(let error): - continuation.resume(throwing: error) - } + guard let continuation = pickerContinuation else { return } + pickerContinuation = nil // Clear BEFORE resuming + switch result { + case let .success(uuid): + continuation.resume(returning: uuid) + case let .failure(error): + continuation.resume(throwing: error) + } } // MARK: - Session Management @@ -97,263 +97,264 @@ final class AccessorySetupKitService { /// Activate the AccessorySetupKit session /// Uses Apple's recommended closure pattern to avoid AsyncStream issues func activateSession() async throws { - guard session == nil else { return } - - let newSession = ASAccessorySession() - session = newSession - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.activationContinuation = continuation - - newSession.activate(on: .main) { [weak self] event in - guard let self else { return } - self.handleEvent(event) - - // Resume activation continuation only once - if event.eventType == .activated { - self.isSessionActive = true - self.pairedAccessories = newSession.accessories - self.logger.info("ASAccessorySession activated with \(self.pairedAccessories.count) accessories") - self.activationContinuation?.resume() - self.activationContinuation = nil - } else if event.eventType == .invalidated { - self.activationContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) - self.activationContinuation = nil - } - } + guard session == nil else { return } + + let newSession = ASAccessorySession() + session = newSession + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.activationContinuation = continuation + + newSession.activate(on: .main) { [weak self] event in + guard let self else { return } + handleEvent(event) + + // Resume activation continuation only once + if event.eventType == .activated { + isSessionActive = true + pairedAccessories = newSession.accessories + logger.info("ASAccessorySession activated with \(pairedAccessories.count) accessories") + activationContinuation?.resume() + activationContinuation = nil + } else if event.eventType == .invalidated { + activationContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) + activationContinuation = nil + } } + } } /// Handle incoming ASK events /// Called directly from session's event handler closure private func handleEvent(_ event: ASAccessoryEvent) { - switch event.eventType { - case .activated: - // Handled in activateSession continuation - break - - case .invalidated: - logger.error("ASAccessorySession invalidated") - isSessionActive = false - pairedAccessories = [] - pickerContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) - pickerContinuation = nil - case .accessoryAdded: - if let accessory = event.accessory { - if pickerWasCancelled { - pickerWasCancelled = false - // Set to "orphanedAfterCancellation" so the next pickerDidDismiss - // log doesn't read "selected" — without this, the success branch - // below would mark outcome as "selected" even though the awaiting - // Task was already cancelled. - pickerOutcome = "orphanedAfterCancellation" - logger.info("[ASK] Removing orphaned accessory after picker cancellation: \(accessory.displayName)") - Task { [weak self] in - guard let self else { return } - do { - try await self.removeAccessory(accessory) - } catch { - self.logger.warning("[ASK] Failed to remove orphaned accessory: \(error.localizedDescription)") - } - } - // Skip the post-add `pairedAccessories` write — `removeAccessory`'s - // own write is the correct final state for the cancelled branch. - return - } + switch event.eventType { + case .activated: + // Handled in activateSession continuation + break - pairedAccessories = session?.accessories ?? [] - pickerOutcome = "selected" - logger.info("Accessory added: \(accessory.displayName)") - logger.info( - AccessorySetupKitLogFormatter.selectionMessage( - accessoryName: accessory.displayName, - bluetoothID: accessory.bluetoothIdentifier, - elapsed: pickerElapsedTime - ) - ) - - if let bluetoothID = accessory.bluetoothIdentifier { - resumePickerContinuation(with: .success(bluetoothID)) - } else { - resumePickerContinuation(with: .failure(AccessorySetupKitError.noBluetoothIdentifier)) - } - } + case .invalidated: + logger.error("ASAccessorySession invalidated") + isSessionActive = false + pairedAccessories = [] + pickerContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) + pickerContinuation = nil - case .accessoryRemoved: - if let accessory = event.accessory, - let bluetoothID = accessory.bluetoothIdentifier { - logger.info("Accessory removed: \(accessory.displayName)") - pairedAccessories = session?.accessories ?? [] - // Notify delegate to clear stored state - delegate?.accessorySetupKitService(self, didRemoveAccessoryWithID: bluetoothID) + case .accessoryAdded: + if let accessory = event.accessory { + if pickerWasCancelled { + pickerWasCancelled = false + // Set to "orphanedAfterCancellation" so the next pickerDidDismiss + // log doesn't read "selected" — without this, the success branch + // below would mark outcome as "selected" even though the awaiting + // Task was already cancelled. + pickerOutcome = "orphanedAfterCancellation" + logger.info("[ASK] Removing orphaned accessory after picker cancellation: \(accessory.displayName)") + Task { [weak self] in + guard let self else { return } + do { + try await removeAccessory(accessory) + } catch { + logger.warning("[ASK] Failed to remove orphaned accessory: \(error.localizedDescription)") + } } + // Skip the post-add `pairedAccessories` write — `removeAccessory`'s + // own write is the correct final state for the cancelled branch. + return + } + + pairedAccessories = session?.accessories ?? [] + pickerOutcome = "selected" + logger.info("Accessory added: \(accessory.displayName)") + logger.info( + AccessorySetupKitLogFormatter.selectionMessage( + accessoryName: accessory.displayName, + bluetoothID: accessory.bluetoothIdentifier, + elapsed: pickerElapsedTime + ) + ) - case .accessoryChanged: - pairedAccessories = session?.accessories ?? [] - logger.info("Accessory changed") + if let bluetoothID = accessory.bluetoothIdentifier { + resumePickerContinuation(with: .success(bluetoothID)) + } else { + resumePickerContinuation(with: .failure(AccessorySetupKitError.noBluetoothIdentifier)) + } + } - case .accessoryDiscovered: - // Under filtered discovery (iOS 26.1+) the picker hands each match to us so we can - // relabel it with the device's advertised name before it appears for selection. - if #available(iOS 26.1, *), usedFilteredDiscovery { - handleDiscoveredAccessory(event.accessory) - } + case .accessoryRemoved: + if let accessory = event.accessory, + let bluetoothID = accessory.bluetoothIdentifier { + logger.info("Accessory removed: \(accessory.displayName)") + pairedAccessories = session?.accessories ?? [] + // Notify delegate to clear stored state + delegate?.accessorySetupKitService(self, didRemoveAccessoryWithID: bluetoothID) + } - case .pickerDidPresent: - logger.info("Picker presented") - - case .pickerDidDismiss: - logger.info( - AccessorySetupKitLogFormatter.dismissalMessage( - outcome: pickerOutcome, - pairedCount: pairedAccessories.count, - elapsed: pickerElapsedTime, - filteredDiscovery: usedFilteredDiscovery - ) - ) - pickerPresentedAt = nil - pickerOutcome = "cancelled" - // Defense-in-depth: don't rely on `showPicker` to clear the flag. - pickerWasCancelled = false - resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerDismissed)) - - case .pickerSetupBridging: - logger.info("Picker bridging...") - - case .pickerSetupPairing: - logger.info("User entering PIN...") - - case .pickerSetupFailed: - if let error = event.error { - pickerOutcome = "pairingFailed" - logger.error("Pairing failed: \(error.localizedDescription)") - - if let accessory = event.accessory, - let bluetoothID = accessory.bluetoothIdentifier { - logger.info("Cleaning up failed pairing for \(accessory.displayName)") - - delegate?.accessorySetupKitService(self, didFailPairingForAccessoryWithID: bluetoothID) - - if pairedAccessories.contains(where: { $0.bluetoothIdentifier == bluetoothID }) { - Task { - do { - try await self.removeAccessory(accessory) - self.logger.info("Removed failed accessory from ASK") - } catch { - self.logger.warning("Failed to remove accessory from ASK: \(error.localizedDescription)") - } - } - } - } + case .accessoryChanged: + pairedAccessories = session?.accessories ?? [] + logger.info("Accessory changed") - resumePickerContinuation(with: .failure(AccessorySetupKitError.pairingFailed(error.localizedDescription))) - } + case .accessoryDiscovered: + // Under filtered discovery (iOS 26.1+) the picker hands each match to us so we can + // relabel it with the device's advertised name before it appears for selection. + if #available(iOS 26.1, *), usedFilteredDiscovery { + handleDiscoveredAccessory(event.accessory) + } + + case .pickerDidPresent: + logger.info("Picker presented") + + case .pickerDidDismiss: + logger.info( + AccessorySetupKitLogFormatter.dismissalMessage( + outcome: pickerOutcome, + pairedCount: pairedAccessories.count, + elapsed: pickerElapsedTime, + filteredDiscovery: usedFilteredDiscovery + ) + ) + pickerPresentedAt = nil + pickerOutcome = "cancelled" + // Defense-in-depth: don't rely on `showPicker` to clear the flag. + pickerWasCancelled = false + resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerDismissed)) + + case .pickerSetupBridging: + logger.info("Picker bridging...") + + case .pickerSetupPairing: + logger.info("User entering PIN...") - case .pickerSetupRename: - logger.info("Picker rename step") + case .pickerSetupFailed: + if let error = event.error { + pickerOutcome = "pairingFailed" + logger.error("Pairing failed: \(error.localizedDescription)") - case .migrationComplete: - logger.info("Migration complete") + if let accessory = event.accessory, + let bluetoothID = accessory.bluetoothIdentifier { + logger.info("Cleaning up failed pairing for \(accessory.displayName)") - case .unknown: - // Explicit handling per Apple sample code - logger.info("Received unknown event type") + delegate?.accessorySetupKitService(self, didFailPairingForAccessoryWithID: bluetoothID) + + if pairedAccessories.contains(where: { $0.bluetoothIdentifier == bluetoothID }) { + Task { + do { + try await self.removeAccessory(accessory) + self.logger.info("Removed failed accessory from ASK") + } catch { + self.logger.warning("Failed to remove accessory from ASK: \(error.localizedDescription)") + } + } + } + } - @unknown default: - // Reserve for future event types - logger.warning("Received future event type: \(String(describing: event.eventType))") + resumePickerContinuation(with: .failure(AccessorySetupKitError.pairingFailed(error.localizedDescription))) } + + case .pickerSetupRename: + logger.info("Picker rename step") + + case .migrationComplete: + logger.info("Migration complete") + + case .unknown: + // Explicit handling per Apple sample code + logger.info("Received unknown event type") + + @unknown default: + // Reserve for future event types + logger.warning("Received future event type: \(String(describing: event.eventType))") + } } /// Show the accessory picker for new device pairing /// - Returns: The Bluetooth identifier (UUID) for the paired device func showPicker() async throws -> UUID { - guard let session else { - throw AccessorySetupKitError.sessionNotActive - } - - guard pickerContinuation == nil else { - throw AccessorySetupKitError.pickerAlreadyActive - } - - discoveredDisplayItems.removeAll() - usedFilteredDiscovery = false - - if #available(iOS 26.1, *), AccessorySetupKitDiscoveryCriteria.usesFilteredDiscovery { - let settings = session.pickerDisplaySettings ?? ASPickerDisplaySettings() - settings.options.insert(.filterDiscoveryResults) - session.pickerDisplaySettings = settings - usedFilteredDiscovery = true - } else if #available(iOS 26.0, *) { - if session.pickerDisplaySettings == nil { - session.pickerDisplaySettings = ASPickerDisplaySettings() - } + guard let session else { + throw AccessorySetupKitError.sessionNotActive + } + + guard pickerContinuation == nil else { + throw AccessorySetupKitError.pickerAlreadyActive + } + + discoveredDisplayItems.removeAll() + usedFilteredDiscovery = false + + if #available(iOS 26.1, *), AccessorySetupKitDiscoveryCriteria.usesFilteredDiscovery { + let settings = session.pickerDisplaySettings ?? ASPickerDisplaySettings() + settings.options.insert(.filterDiscoveryResults) + session.pickerDisplaySettings = settings + usedFilteredDiscovery = true + } else if #available(iOS 26.0, *) { + if session.pickerDisplaySettings == nil { + session.pickerDisplaySettings = ASPickerDisplaySettings() } + } + + let productImage = createGenericProductImage() + currentProductImage = productImage + let displayItems = makePickerDisplayItems(productImage: productImage) + pickerPresentedAt = Date() + pickerOutcome = "presented" + pickerWasCancelled = false + logger.info( + """ + [ASK] Presenting picker on iOS \(currentOSVersion), \ + sessionActive: \(isSessionActive), \ + pairedCount: \(pairedAccessories.count), \ + displayItems: \(displayItems.count), \ + filteredDiscovery: \(usedFilteredDiscovery), \ + criteria: \(AccessorySetupKitLogFormatter.criteriaSummary(AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria)) + """ + ) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.pickerContinuation = continuation + + session.showPicker(for: displayItems) { [weak self] error in + guard let self else { return } - let productImage = createGenericProductImage() - currentProductImage = productImage - let displayItems = makePickerDisplayItems(productImage: productImage) - pickerPresentedAt = Date() - pickerOutcome = "presented" - pickerWasCancelled = false - logger.info( - """ - [ASK] Presenting picker on iOS \(currentOSVersion), \ - sessionActive: \(isSessionActive), \ - pairedCount: \(pairedAccessories.count), \ - displayItems: \(displayItems.count), \ - filteredDiscovery: \(usedFilteredDiscovery), \ - criteria: \(AccessorySetupKitLogFormatter.criteriaSummary(AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria)) - """ - ) - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.pickerContinuation = continuation - - session.showPicker(for: displayItems) { [weak self] error in - guard let self else { return } - - Task { @MainActor in - if let error = error as? ASError { - self.logger.error("Picker error: \(error.localizedDescription)") - - switch error.code { - case .pickerRestricted: - self.pickerOutcome = "pickerRestricted" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerRestricted)) - case .pickerAlreadyActive: - self.pickerOutcome = "pickerAlreadyActive" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerAlreadyActive)) - case .userCancelled: - self.pickerOutcome = "cancelled" - // User explicitly cancelled (error code 700) - not an error condition - // Will be handled by pickerDidDismiss event - return - case .discoveryTimeout: - self.pickerOutcome = "discoveryTimeout" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.discoveryTimeout)) - case .connectionFailed: - self.pickerOutcome = "connectionFailed" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.connectionFailed)) - default: - self.logger.error("Unexpected picker error code: \(error.code.rawValue)") - self.pickerOutcome = "connectionFailed" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.connectionFailed)) - } - } else if let error { - self.logger.error("Picker error: \(error.localizedDescription)") - self.pickerOutcome = "pairingFailed" - self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pairingFailed(error.localizedDescription))) - } - } - } - } - } onCancel: { [weak self] in Task { @MainActor in - self?.handlePickerCancellation() + if let error = error as? ASError { + self.logger.error("Picker error: \(error.localizedDescription)") + + switch error.code { + case .pickerRestricted: + self.pickerOutcome = "pickerRestricted" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerRestricted)) + case .pickerAlreadyActive: + self.pickerOutcome = "pickerAlreadyActive" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pickerAlreadyActive)) + case .userCancelled: + self.pickerOutcome = "cancelled" + // User explicitly cancelled (error code 700) - not an error condition + // Will be handled by pickerDidDismiss event + return + case .discoveryTimeout: + self.pickerOutcome = "discoveryTimeout" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.discoveryTimeout)) + case .connectionFailed: + self.pickerOutcome = "connectionFailed" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.connectionFailed)) + default: + self.logger.error("Unexpected picker error code: \(error.code.rawValue)") + self.pickerOutcome = "connectionFailed" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.connectionFailed)) + } + } else if let error { + self.logger.error("Picker error: \(error.localizedDescription)") + self.pickerOutcome = "pairingFailed" + self.resumePickerContinuation(with: .failure(AccessorySetupKitError.pairingFailed(error.localizedDescription))) + } } + } + } + } onCancel: { [weak self] in + Task { @MainActor in + self?.handlePickerCancellation() } + } } /// Surfaces task cancellation as a `CancellationError` on the awaiting `showPicker`. @@ -363,119 +364,119 @@ final class AccessorySetupKitService { /// observe `pickerWasCancelled` and remove the orphaned bond. @MainActor private func handlePickerCancellation() { - pickerWasCancelled = true - // In Race-B (accessoryAdded ran first, then cancellation), the success branch - // already set pickerOutcome to "selected". Refine it so the dismissal log - // reflects what actually happened: a user-completed selection that the - // awaiting Task no longer cared about. - if pickerOutcome == "selected" { - pickerOutcome = "cancelledAfterSelection" - } - resumePickerContinuation(with: .failure(CancellationError())) - logger.warning("[ASK] Picker cancelled programmatically; awaiting Task unwound") + pickerWasCancelled = true + // In Race-B (accessoryAdded ran first, then cancellation), the success branch + // already set pickerOutcome to "selected". Refine it so the dismissal log + // reflects what actually happened: a user-completed selection that the + // awaiting Task no longer cared about. + if pickerOutcome == "selected" { + pickerOutcome = "cancelledAfterSelection" + } + resumePickerContinuation(with: .failure(CancellationError())) + logger.warning("[ASK] Picker cancelled programmatically; awaiting Task unwound") } /// Remove an accessory from the system /// Note: iOS 26 shows a confirmation dialog to the user func removeAccessory(_ accessory: ASAccessory) async throws { - guard let session else { - throw AccessorySetupKitError.sessionNotActive - } + guard let session else { + throw AccessorySetupKitError.sessionNotActive + } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - session.removeAccessory(accessory) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.removeAccessory(accessory) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } } + } - pairedAccessories = session.accessories + pairedAccessories = session.accessories } /// Shows the system rename sheet for an accessory /// - Parameter accessory: The accessory to rename func renameAccessory(_ accessory: ASAccessory) async throws { - guard let session else { - throw AccessorySetupKitError.sessionNotActive - } + guard let session else { + throw AccessorySetupKitError.sessionNotActive + } - try await session.renameAccessory(accessory) - pairedAccessories = session.accessories + try await session.renameAccessory(accessory) + pairedAccessories = session.accessories } /// Find a paired accessory by its Bluetooth identifier func accessory(for bluetoothID: UUID) -> ASAccessory? { - pairedAccessories.first { $0.bluetoothIdentifier == bluetoothID } + pairedAccessories.first { $0.bluetoothIdentifier == bluetoothID } } /// Invalidate the session func invalidateSession() { - pickerContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) - pickerContinuation = nil - activationContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) - activationContinuation = nil - session?.invalidate() - session = nil - isSessionActive = false - pairedAccessories = [] + pickerContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) + pickerContinuation = nil + activationContinuation?.resume(throwing: AccessorySetupKitError.sessionInvalidated) + activationContinuation = nil + session?.invalidate() + session = nil + isSessionActive = false + pairedAccessories = [] } // MARK: - Private Helpers private var currentOSVersion: String { - let version = ProcessInfo.processInfo.operatingSystemVersion - return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + let version = ProcessInfo.processInfo.operatingSystemVersion + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" } private var pickerElapsedTime: TimeInterval? { - pickerPresentedAt.map { Date().timeIntervalSince($0) } + pickerPresentedAt.map { Date().timeIntervalSince($0) } } private func makePickerDisplayItems(productImage: UIImage) -> [ASPickerDisplayItem] { - AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria.map { criterion in - let descriptor = ASDiscoveryDescriptor() - descriptor.bluetoothServiceUUID = CBUUID(string: criterion.bluetoothServiceUUID) - descriptor.bluetoothNameSubstring = criterion.bluetoothNameSubstring - - return ASPickerDisplayItem( - name: Self.defaultAccessoryName, - productImage: productImage, - descriptor: descriptor - ) - } + AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria.map { criterion in + let descriptor = ASDiscoveryDescriptor() + descriptor.bluetoothServiceUUID = CBUUID(string: criterion.bluetoothServiceUUID) + descriptor.bluetoothNameSubstring = criterion.bluetoothNameSubstring + + return ASPickerDisplayItem( + name: Self.defaultAccessoryName, + productImage: productImage, + descriptor: descriptor + ) + } } /// Relabels a filtered-discovery match with its advertised BLE name, falling back to /// `defaultAccessoryName` when the advertisement carries no usable local name. @available(iOS 26.1, *) private func handleDiscoveredAccessory(_ accessory: ASAccessory?) { - guard let session, - let discovered = accessory as? ASDiscoveredAccessory else { return } - - let advertisedName = (discovered.bluetoothAdvertisementData?[CBAdvertisementDataLocalNameKey] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let name = advertisedName.flatMap { $0.isEmpty ? nil : $0 } ?? Self.defaultAccessoryName - let productImage = currentProductImage ?? createGenericProductImage() - - logger.info("[ASK] Discovered accessory '\(name)', RSSI: \(discovered.bluetoothRSSI.map(String.init) ?? "n/a")") - - discoveredDisplayItems[AnyHashable(discovered)] = ASDiscoveredDisplayItem( - name: name, - productImage: productImage, - accessory: discovered - ) - - let items = discoveredDisplayItems.values.compactMap { $0 as? ASDiscoveredDisplayItem } - logger.info("[ASK] updatePicker with \(items.count) discovered item(s)") - session.updatePicker(showing: items) { [weak self] error in - guard let error else { return } - Task { @MainActor in - self?.logger.warning("[ASK] updatePicker failed: \(error.localizedDescription)") - } + guard let session, + let discovered = accessory as? ASDiscoveredAccessory else { return } + + let advertisedName = (discovered.bluetoothAdvertisementData?[CBAdvertisementDataLocalNameKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let name = advertisedName.flatMap { $0.isEmpty ? nil : $0 } ?? Self.defaultAccessoryName + let productImage = currentProductImage ?? createGenericProductImage() + + logger.info("[ASK] Discovered accessory '\(name)', RSSI: \(discovered.bluetoothRSSI.map(String.init) ?? "n/a")") + + discoveredDisplayItems[AnyHashable(discovered)] = ASDiscoveredDisplayItem( + name: name, + productImage: productImage, + accessory: discovered + ) + + let items = discoveredDisplayItems.values.compactMap { $0 as? ASDiscoveredDisplayItem } + logger.info("[ASK] updatePicker with \(items.count) discovered item(s)") + session.updatePicker(showing: items) { [weak self] error in + guard let error else { return } + Task { @MainActor in + self?.logger.warning("[ASK] updatePicker failed: \(error.localizedDescription)") } + } } /// Creates a generic product image for the ASK picker @@ -487,33 +488,33 @@ final class AccessorySetupKitService { /// Alternative: Could use `.withRenderingMode(.alwaysTemplate)` with `.label` color /// for better automatic dark mode adaptation, but explicit blue tint is acceptable. private func createGenericProductImage() -> UIImage { - let size = CGSize(width: 180, height: 120) - let renderer = UIGraphicsImageRenderer(size: size) - - return renderer.image { context in - // Transparent background (required for light/dark mode) - UIColor.clear.setFill() - context.fill(CGRect(origin: .zero, size: size)) - - // Draw centered SF Symbol with padding for visual balance - // Smaller point size (50 vs 60) creates breathing room in carousel - let config = UIImage.SymbolConfiguration(pointSize: 50, weight: .regular) - guard let symbol = UIImage(systemName: "antenna.radiowaves.left.and.right", withConfiguration: config)? - .withTintColor(.systemBlue, renderingMode: .alwaysOriginal) else { return } - - let symbolSize = symbol.size - let origin = CGPoint( - x: (size.width - symbolSize.width) / 2, - y: (size.height - symbolSize.height) / 2 - ) - symbol.draw(at: origin) - } + let size = CGSize(width: 180, height: 120) + let renderer = UIGraphicsImageRenderer(size: size) + + return renderer.image { context in + // Transparent background (required for light/dark mode) + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // Draw centered SF Symbol with padding for visual balance + // Smaller point size (50 vs 60) creates breathing room in carousel + let config = UIImage.SymbolConfiguration(pointSize: 50, weight: .regular) + guard let symbol = UIImage(systemName: "antenna.radiowaves.left.and.right", withConfiguration: config)? + .withTintColor(.systemBlue, renderingMode: .alwaysOriginal) else { return } + + let symbolSize = symbol.size + let origin = CGPoint( + x: (size.width - symbolSize.width) / 2, + y: (size.height - symbolSize.height) / 2 + ) + symbol.draw(at: origin) + } } -} + } -// MARK: - Errors + // MARK: - Errors -public enum AccessorySetupKitError: LocalizedError, Sendable { + public enum AccessorySetupKitError: LocalizedError, Sendable { case sessionNotActive case sessionInvalidated case pickerDismissed @@ -525,51 +526,51 @@ public enum AccessorySetupKitError: LocalizedError, Sendable { case connectionFailed public var errorDescription: String? { - switch self { - case .sessionNotActive: - return "Bluetooth is not ready. Please ensure Bluetooth is enabled and try again." - case .sessionInvalidated: - return "Bluetooth session ended unexpectedly. Please restart the app." - case .pickerDismissed: - return "Device selection was cancelled." - case .pickerRestricted: - return "Cannot show device picker. Please check that Bluetooth is enabled, wait a moment, and try again." - case .pickerAlreadyActive: - return "Device picker is already showing." - case .pairingFailed(let reason): - return "Pairing failed: \(reason)" - case .noBluetoothIdentifier: - return "Selected device does not support Bluetooth connection." - case .discoveryTimeout: - return "No devices found. Make sure your device is powered on and nearby." - case .connectionFailed: - return "Could not connect to the device. Please try again." - } + switch self { + case .sessionNotActive: + "Bluetooth is not ready. Please ensure Bluetooth is enabled and try again." + case .sessionInvalidated: + "Bluetooth session ended unexpectedly. Please restart the app." + case .pickerDismissed: + "Device selection was cancelled." + case .pickerRestricted: + "Cannot show device picker. Please check that Bluetooth is enabled, wait a moment, and try again." + case .pickerAlreadyActive: + "Device picker is already showing." + case let .pairingFailed(reason): + "Pairing failed: \(reason)" + case .noBluetoothIdentifier: + "Selected device does not support Bluetooth connection." + case .discoveryTimeout: + "No devices found. Make sure your device is powered on and nearby." + case .connectionFailed: + "Could not connect to the device. Please try again." + } } -} + } #else -// macOS stubs for compilation -import Foundation + // macOS stubs for compilation + import Foundation -struct ASAccessory: Sendable { + struct ASAccessory { var bluetoothIdentifier: UUID? var displayName: String init(bluetoothIdentifier: UUID? = nil, displayName: String = "") { - self.bluetoothIdentifier = bluetoothIdentifier - self.displayName = displayName + self.bluetoothIdentifier = bluetoothIdentifier + self.displayName = displayName } -} + } -@MainActor -protocol AccessorySetupKitServiceDelegate: AnyObject { + @MainActor + protocol AccessorySetupKitServiceDelegate: AnyObject { func accessorySetupKitService(_ service: AccessorySetupKitService, didRemoveAccessoryWithID bluetoothID: UUID) func accessorySetupKitService(_ service: AccessorySetupKitService, didFailPairingForAccessoryWithID bluetoothID: UUID) -} + } -@Observable -@MainActor -final class AccessorySetupKitService { + @Observable + @MainActor + final class AccessorySetupKitService { private(set) var pairedAccessories: [ASAccessory] = [] private(set) var isSessionActive = false weak var delegate: AccessorySetupKitServiceDelegate? @@ -577,14 +578,20 @@ final class AccessorySetupKitService { init() {} func activateSession() async throws {} - func showPicker() async throws -> UUID { throw AccessorySetupKitError.sessionNotActive } + func showPicker() async throws -> UUID { + throw AccessorySetupKitError.sessionNotActive + } + func removeAccessory(_ accessory: ASAccessory) async throws {} func renameAccessory(_ accessory: ASAccessory) async throws {} - func accessory(for bluetoothID: UUID) -> ASAccessory? { nil } + func accessory(for bluetoothID: UUID) -> ASAccessory? { + nil + } + func invalidateSession() {} -} + } -public enum AccessorySetupKitError: LocalizedError, Sendable { + public enum AccessorySetupKitError: LocalizedError, Sendable { case sessionNotActive case sessionInvalidated case pickerDismissed @@ -596,26 +603,26 @@ public enum AccessorySetupKitError: LocalizedError, Sendable { case connectionFailed public var errorDescription: String? { - switch self { - case .sessionNotActive: - return "Bluetooth is not ready. Please ensure Bluetooth is enabled and try again." - case .sessionInvalidated: - return "Bluetooth session ended unexpectedly. Please restart the app." - case .pickerDismissed: - return "Device selection was cancelled." - case .pickerRestricted: - return "Cannot show device picker. Please check that Bluetooth is enabled, wait a moment, and try again." - case .pickerAlreadyActive: - return "Device picker is already showing." - case .pairingFailed(let reason): - return "Pairing failed: \(reason)" - case .noBluetoothIdentifier: - return "Selected device does not support Bluetooth connection." - case .discoveryTimeout: - return "No devices found. Make sure your device is powered on and nearby." - case .connectionFailed: - return "Could not connect to the device. Please try again." - } + switch self { + case .sessionNotActive: + "Bluetooth is not ready. Please ensure Bluetooth is enabled and try again." + case .sessionInvalidated: + "Bluetooth session ended unexpectedly. Please restart the app." + case .pickerDismissed: + "Device selection was cancelled." + case .pickerRestricted: + "Cannot show device picker. Please check that Bluetooth is enabled, wait a moment, and try again." + case .pickerAlreadyActive: + "Device picker is already showing." + case let .pairingFailed(reason): + "Pairing failed: \(reason)" + case .noBluetoothIdentifier: + "Selected device does not support Bluetooth connection." + case .discoveryTimeout: + "No devices found. Make sure your device is powered on and nearby." + case .connectionFailed: + "Could not connect to the device. Please try again." + } } -} + } #endif diff --git a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitServicing.swift b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitServicing.swift index 5f2eb615..a9884af4 100644 --- a/MC1Services/Sources/MC1Services/Services/AccessorySetupKitServicing.swift +++ b/MC1Services/Sources/MC1Services/Services/AccessorySetupKitServicing.swift @@ -1,5 +1,5 @@ #if canImport(UIKit) -import AccessorySetupKit + import AccessorySetupKit #endif import Foundation @@ -12,15 +12,15 @@ import Foundation /// a protocol. @MainActor protocol AccessorySetupKitServicing: AnyObject { - var pairedAccessories: [ASAccessory] { get } - var isSessionActive: Bool { get } - var delegate: AccessorySetupKitServiceDelegate? { get set } - func activateSession() async throws - func showPicker() async throws -> UUID - func removeAccessory(_ accessory: ASAccessory) async throws - func renameAccessory(_ accessory: ASAccessory) async throws - func accessory(for bluetoothID: UUID) -> ASAccessory? - func invalidateSession() + var pairedAccessories: [ASAccessory] { get } + var isSessionActive: Bool { get } + var delegate: AccessorySetupKitServiceDelegate? { get set } + func activateSession() async throws + func showPicker() async throws -> UUID + func removeAccessory(_ accessory: ASAccessory) async throws + func renameAccessory(_ accessory: ASAccessory) async throws + func accessory(for bluetoothID: UUID) -> ASAccessory? + func invalidateSession() } extension AccessorySetupKitService: AccessorySetupKitServicing {} diff --git a/MC1Services/Sources/MC1Services/Services/AccessorySetupPairingService.swift b/MC1Services/Sources/MC1Services/Services/AccessorySetupPairingService.swift index 069849d0..973d4aab 100644 --- a/MC1Services/Sources/MC1Services/Services/AccessorySetupPairingService.swift +++ b/MC1Services/Sources/MC1Services/Services/AccessorySetupPairingService.swift @@ -1,5 +1,5 @@ #if canImport(UIKit) -import AccessorySetupKit + import AccessorySetupKit #endif import Foundation import os @@ -12,78 +12,89 @@ import os /// this adapter is the one place that knows AccessorySetupKit exists. @MainActor final class AccessorySetupPairingService: DevicePairingService { - private let accessorySetupKit: any AccessorySetupKitServicing - private let logger = PersistentLogger(subsystem: "com.mc1", category: "AccessorySetupPairing") - weak var delegate: (any DevicePairingDelegate)? + private let accessorySetupKit: any AccessorySetupKitServicing + private let logger = PersistentLogger(subsystem: "com.mc1", category: "AccessorySetupPairing") + weak var delegate: (any DevicePairingDelegate)? - init(accessorySetupKit: any AccessorySetupKitServicing) { - self.accessorySetupKit = accessorySetupKit - self.accessorySetupKit.delegate = self - } + init(accessorySetupKit: any AccessorySetupKitServicing) { + self.accessorySetupKit = accessorySetupKit + self.accessorySetupKit.delegate = self + } - var isSessionActive: Bool { accessorySetupKit.isSessionActive } - var registeredDeviceCount: Int { accessorySetupKit.pairedAccessories.count } - var hasSystemPairingRegistry: Bool { true } - var supportsSystemRename: Bool { true } + var isSessionActive: Bool { + accessorySetupKit.isSessionActive + } - func activate() async throws { - try await accessorySetupKit.activateSession() - } + var registeredDeviceCount: Int { + accessorySetupKit.pairedAccessories.count + } - func discoverDevice() async throws -> UUID { - do { - return try await accessorySetupKit.showPicker() - } catch AccessorySetupKitError.pickerDismissed { - throw DevicePairingError.cancelled - } catch AccessorySetupKitError.pickerAlreadyActive { - throw DevicePairingError.alreadyInProgress - } - } + var hasSystemPairingRegistry: Bool { + true + } - func isDeviceConnectable(_ id: UUID) -> Bool { - accessorySetupKit.accessory(for: id) != nil - } + var supportsSystemRename: Bool { + true + } - func registeredDeviceInfos() -> [(id: UUID, name: String)] { - accessorySetupKit.pairedAccessories.compactMap { accessory in - guard let id = accessory.bluetoothIdentifier else { return nil } - return (id: id, name: accessory.displayName) - } - } + func activate() async throws { + try await accessorySetupKit.activateSession() + } - func removeDevice(_ id: UUID) async throws { - guard let accessory = accessorySetupKit.accessory(for: id) else { return } - try await accessorySetupKit.removeAccessory(accessory) + func discoverDevice() async throws -> UUID { + do { + return try await accessorySetupKit.showPicker() + } catch AccessorySetupKitError.pickerDismissed { + throw DevicePairingError.cancelled + } catch AccessorySetupKitError.pickerAlreadyActive { + throw DevicePairingError.alreadyInProgress } + } - func renameDevice(_ id: UUID) async throws { - guard let accessory = accessorySetupKit.accessory(for: id) else { return } - try await accessorySetupKit.renameAccessory(accessory) + func isDeviceConnectable(_ id: UUID) -> Bool { + accessorySetupKit.accessory(for: id) != nil + } + + func registeredDeviceInfos() -> [(id: UUID, name: String)] { + accessorySetupKit.pairedAccessories.compactMap { accessory in + guard let id = accessory.bluetoothIdentifier else { return nil } + return (id: id, name: accessory.displayName) } + } + + func removeDevice(_ id: UUID) async throws { + guard let accessory = accessorySetupKit.accessory(for: id) else { return } + try await accessorySetupKit.removeAccessory(accessory) + } + + func renameDevice(_ id: UUID) async throws { + guard let accessory = accessorySetupKit.accessory(for: id) else { return } + try await accessorySetupKit.renameAccessory(accessory) + } - func clearStaleRegistrations() async { - for accessory in accessorySetupKit.pairedAccessories { - do { - try await accessorySetupKit.removeAccessory(accessory) - } catch { - logger.warning("Failed to remove stale accessory \(accessory.displayName): \(error.localizedDescription)") - } - } + func clearStaleRegistrations() async { + for accessory in accessorySetupKit.pairedAccessories { + do { + try await accessorySetupKit.removeAccessory(accessory) + } catch { + logger.warning("Failed to remove stale accessory \(accessory.displayName): \(error.localizedDescription)") + } } + } } extension AccessorySetupPairingService: AccessorySetupKitServiceDelegate { - func accessorySetupKitService( - _ service: AccessorySetupKitService, - didRemoveAccessoryWithID bluetoothID: UUID - ) { - delegate?.devicePairing(self, didRemoveDeviceWithID: bluetoothID) - } + func accessorySetupKitService( + _ service: AccessorySetupKitService, + didRemoveAccessoryWithID bluetoothID: UUID + ) { + delegate?.devicePairing(self, didRemoveDeviceWithID: bluetoothID) + } - func accessorySetupKitService( - _ service: AccessorySetupKitService, - didFailPairingForAccessoryWithID bluetoothID: UUID - ) { - delegate?.devicePairing(self, didFailPairingForDeviceWithID: bluetoothID) - } + func accessorySetupKitService( + _ service: AccessorySetupKitService, + didFailPairingForAccessoryWithID bluetoothID: UUID + ) { + delegate?.devicePairing(self, didFailPairingForDeviceWithID: bluetoothID) + } } diff --git a/MC1Services/Sources/MC1Services/Services/AckCodeBuilder.swift b/MC1Services/Sources/MC1Services/Services/AckCodeBuilder.swift index 4007fb40..aff48310 100644 --- a/MC1Services/Sources/MC1Services/Services/AckCodeBuilder.swift +++ b/MC1Services/Sources/MC1Services/Services/AckCodeBuilder.swift @@ -37,22 +37,22 @@ private let ackCodeByteCount = 4 /// Cross-*message* collision is unaffected by the wrap and stays mitigated by /// the per-radioID `dmQueue` serialization. enum AckCodeBuilder { - static func expectedAck( - timestamp: UInt32, - attempt: UInt8, - text: String, - senderPublicKey: Data - ) -> Data { - precondition( - attempt < 5, - "MessageServiceConfig caps maxAttempts at 5 (4 direct + 1 flood); attempt \(attempt) exceeds the index range and would over-wrap the & 0x03 ACK mask" - ) - var input = Data() - var le = timestamp.littleEndian - withUnsafeBytes(of: &le) { input.append(contentsOf: $0) } - input.append(attempt & attemptMask) - input.append(contentsOf: text.utf8) - input.append(senderPublicKey) - return Data(SHA256.hash(data: input).prefix(ackCodeByteCount)) - } + static func expectedAck( + timestamp: UInt32, + attempt: UInt8, + text: String, + senderPublicKey: Data + ) -> Data { + precondition( + attempt < 5, + "MessageServiceConfig caps maxAttempts at 5 (4 direct + 1 flood); attempt \(attempt) exceeds the index range and would over-wrap the & 0x03 ACK mask" + ) + var input = Data() + var le = timestamp.littleEndian + withUnsafeBytes(of: &le) { input.append(contentsOf: $0) } + input.append(attempt & attemptMask) + input.append(contentsOf: text.utf8) + input.append(senderPublicKey) + return Data(SHA256.hash(data: input).prefix(ackCodeByteCount)) + } } diff --git a/MC1Services/Sources/MC1Services/Services/AdvertLocationPolicy.swift b/MC1Services/Sources/MC1Services/Services/AdvertLocationPolicy.swift index 040e2145..8c3c0a81 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertLocationPolicy.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertLocationPolicy.swift @@ -2,11 +2,11 @@ import Foundation /// Location inclusion policy for advertisements. public enum AdvertLocationPolicy: UInt8, Sendable, CaseIterable { - case none = 0 - case share = 1 - case prefs = 2 + case none = 0 + case share = 1 + case prefs = 2 - public var isEnabled: Bool { - self != .none - } + public var isEnabled: Bool { + self != .none + } } diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift index 180b9e84..e7907b47 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift @@ -9,25 +9,25 @@ import MeshCore /// bumps and deletion cleanup, `ConnectionUIState` for the storage-full flag, /// path-discovery views) never steal each other's events. public enum AdvertisementEvent: Sendable { - /// A contact or discovered node was created or updated; observers should - /// reload contact lists. - case contactUpdated - /// A new contact was discovered via advertisement. - case newContactDiscovered(name: String, contactID: UUID, contactType: ContactType) - /// An advert arrived for a contact unknown locally and it was fetched - /// from the device; observers should refresh contact lists. - case contactSyncRequested(radioID: UUID) - /// The device's node storage full state changed (true = full, false = has space). - case nodeStorageFullChanged(isFull: Bool) - /// The device auto-deleted a contact (overwrite oldest); observers clean - /// up its notifications and refresh the badge. - case contactDeletedCleanup(contactID: UUID, publicKey: Data) - /// A path discovery response arrived for a contact. - case pathDiscoveryResponse(PathInfo) - /// A trace response arrived; `traceInfo.tag` correlates it with the - /// trace that requested it. - case traceResponse(traceInfo: TraceInfo, radioID: UUID) - /// The RX log reported reception of a trace packet, carrying the SNR the - /// local radio measured and, when present, the far end's measured SNR. - case traceSnrObserved(tag: UInt32, localSnr: Double, remoteSnr: Double?, radioID: UUID) + /// A contact or discovered node was created or updated; observers should + /// reload contact lists. + case contactUpdated + /// A new contact was discovered via advertisement. + case newContactDiscovered(name: String, contactID: UUID, contactType: ContactType) + /// An advert arrived for a contact unknown locally and it was fetched + /// from the device; observers should refresh contact lists. + case contactSyncRequested(radioID: UUID) + /// The device's node storage full state changed (true = full, false = has space). + case nodeStorageFullChanged(isFull: Bool) + /// The device auto-deleted a contact (overwrite oldest); observers clean + /// up its notifications and refresh the badge. + case contactDeletedCleanup(contactID: UUID, publicKey: Data) + /// A path discovery response arrived for a contact. + case pathDiscoveryResponse(PathInfo) + /// A trace response arrived; `traceInfo.tag` correlates it with the + /// trace that requested it. + case traceResponse(traceInfo: TraceInfo, radioID: UUID) + /// The RX log reported reception of a trace packet, carrying the SNR the + /// local radio measured and, when present, the far end's measured SNR. + case traceSnrObserved(tag: UInt32, localSnr: Double, remoteSnr: Double?, radioID: UUID) } diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift index ebe5fdeb..15091a89 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift @@ -5,21 +5,21 @@ import os // MARK: - Advertisement Errors public enum AdvertisementError: Error, Sendable { - case notConnected - case sendFailed - case invalidResponse - case sessionError(MeshCoreError) + case notConnected + case sendFailed + case invalidResponse + case sessionError(MeshCoreError) } extension AdvertisementError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .sendFailed: "Failed to send advertisement." - case .invalidResponse: "Invalid response from device." - case .sessionError(let e): e.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .sendFailed: "Failed to send advertisement." + case .invalidResponse: "Invalid response from device." + case let .sessionError(e): e.localizedDescription } + } } // MARK: - Advertisement Service @@ -27,475 +27,474 @@ extension AdvertisementError: LocalizedError { /// Service for managing device advertisements and discovery. /// Handles sending self-advertisements and processing incoming adverts via MeshCore events. public actor AdvertisementService { + // MARK: - Properties - // MARK: - Properties + private let logger = PersistentLogger(subsystem: "com.mc1", category: "Advertisement") - private let logger = PersistentLogger(subsystem: "com.mc1", category: "Advertisement") + /// Temporary end-to-end Discover trace. Filter by category "discover-trace" + /// to follow a single advert from push receipt through persistence to the + /// view reload. Remove once the "no new nodes after clear" report is closed. + private let discoverTrace = PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - /// Temporary end-to-end Discover trace. Filter by category "discover-trace" - /// to follow a single advert from push receipt through persistence to the - /// view reload. Remove once the "no new nodes after clear" report is closed. - private let discoverTrace = PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + private let session: any AdvertisingSessionOps & SessionEventStreaming + private let dataStore: any PersistenceStoreProtocol - private let session: any AdvertisingSessionOps & SessionEventStreaming - private let dataStore: any PersistenceStoreProtocol + /// Task monitoring for events + private var eventMonitorTask: Task? + private var currentRadioID: UUID? - /// Task monitoring for events - private var eventMonitorTask: Task? - private var currentRadioID: UUID? + /// Whether contact fetches should be deferred (during sync) + private var isSyncingContacts = false + private var pendingUnknownContactKeys: Set = [] - /// Whether contact fetches should be deferred (during sync) - private var isSyncingContacts = false - private var pendingUnknownContactKeys: Set = [] + /// Tracks the last overwrite-oldest deletion for correlating with the replacement contact. + /// The device sends 0x8F (deleted) then shortly after an advert for the new contact. + private var lastOverwriteDeletion: (name: String, pubKeyHex: String, time: Date)? - /// Tracks the last overwrite-oldest deletion for correlating with the replacement contact. - /// The device sends 0x8F (deleted) then shortly after an advert for the new contact. - private var lastOverwriteDeletion: (name: String, pubKeyHex: String, time: Date)? + /// Multicast broadcaster for advertisement and discovery events. + /// Producers yield synchronously; consumers subscribe via `events()`. + nonisolated let eventBroadcaster = EventBroadcaster() - /// Multicast broadcaster for advertisement and discovery events. - /// Producers yield synchronously; consumers subscribe via `events()`. - nonisolated let eventBroadcaster = EventBroadcaster() + // MARK: - Initialization - // MARK: - Initialization + public init(session: any AdvertisingSessionOps & SessionEventStreaming, dataStore: any PersistenceStoreProtocol) { + self.session = session + self.dataStore = dataStore + } - public init(session: any AdvertisingSessionOps & SessionEventStreaming, dataStore: any PersistenceStoreProtocol) { - self.session = session - self.dataStore = dataStore - } + deinit { + eventMonitorTask?.cancel() + } - deinit { - eventMonitorTask?.cancel() - } + // MARK: - Events - // MARK: - Events + /// Returns a fresh stream of advertisement and discovery events. + /// Registration is synchronous, so events yielded after this call are + /// never dropped. Consumers must re-subscribe per connection because the + /// owning `ServiceContainer` is rebuilt on every connection. + public nonisolated func events() -> AsyncStream { + eventBroadcaster.subscribe() + } - /// Returns a fresh stream of advertisement and discovery events. - /// Registration is synchronous, so events yielded after this call are - /// never dropped. Consumers must re-subscribe per connection because the - /// owning `ServiceContainer` is rebuilt on every connection. - public nonisolated func events() -> AsyncStream { - eventBroadcaster.subscribe() - } + /// Ends every `events()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishEvents() { + eventBroadcaster.finish() + } - /// Ends every `events()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishEvents() { - eventBroadcaster.finish() - } + // MARK: - Event Monitoring - // MARK: - Event Monitoring - - /// Start monitoring MeshCore events for advertisement-related notifications - public func startEventMonitoring(radioID: UUID) { - eventMonitorTask?.cancel() - currentRadioID = radioID - - eventMonitorTask = Task { [weak self] in - guard let self else { return } - let filter = EventFilter { event in - switch event { - case .advertisement, .newContact, .pathUpdate, .pathResponse, - .traceData, .contactDeleted, .contactsFull: - return true - case .rxLogData(let log) where log.payloadType == .trace: - return true - default: - return false - } - } - let events = await session.events(filter: filter) + /// Start monitoring MeshCore events for advertisement-related notifications + public func startEventMonitoring(radioID: UUID) { + eventMonitorTask?.cancel() + currentRadioID = radioID - for await event in events { - guard !Task.isCancelled else { break } - await self.handleEvent(event, radioID: radioID) - } + eventMonitorTask = Task { [weak self] in + guard let self else { return } + let filter = EventFilter { event in + switch event { + case .advertisement, .newContact, .pathUpdate, .pathResponse, + .traceData, .contactDeleted, .contactsFull: + true + case let .rxLogData(log) where log.payloadType == .trace: + true + default: + false } - } + } + let events = await session.events(filter: filter) - /// Stop monitoring events - public func stopEventMonitoring() { - eventMonitorTask?.cancel() - eventMonitorTask = nil - currentRadioID = nil + for await event in events { + guard !Task.isCancelled else { break } + await handleEvent(event, radioID: radioID) + } } - - /// Toggle deferred contact fetching during sync. - public func setSyncingContacts(_ isSyncing: Bool) async { - isSyncingContacts = isSyncing - if !isSyncing { - await fetchPendingUnknownContacts() - } + } + + /// Stop monitoring events + public func stopEventMonitoring() { + eventMonitorTask?.cancel() + eventMonitorTask = nil + currentRadioID = nil + } + + /// Toggle deferred contact fetching during sync. + public func setSyncingContacts(_ isSyncing: Bool) async { + isSyncingContacts = isSyncing + if !isSyncing { + await fetchPendingUnknownContacts() } + } - /// Handle incoming MeshCore event - private func handleEvent(_ event: MeshEvent, radioID: UUID) async { - switch event { - case .advertisement(let publicKey): - await handleAdvertEvent(publicKey: publicKey, radioID: radioID) - - case .newContact(let contact): - await handleNewAdvertEvent(contact: contact, radioID: radioID) - - case .pathUpdate(let publicKey): - await handlePathUpdatedEvent(publicKey: publicKey, radioID: radioID) + /// Handle incoming MeshCore event + private func handleEvent(_ event: MeshEvent, radioID: UUID) async { + switch event { + case let .advertisement(publicKey): + await handleAdvertEvent(publicKey: publicKey, radioID: radioID) - case .pathResponse(let result): - await handlePathDiscoveryResponse(result: result, radioID: radioID) + case let .newContact(contact): + await handleNewAdvertEvent(contact: contact, radioID: radioID) - case .traceData(let traceInfo): - await handleTraceData(traceInfo: traceInfo, radioID: radioID) + case let .pathUpdate(publicKey): + await handlePathUpdatedEvent(publicKey: publicKey, radioID: radioID) - case .rxLogData(let logData) where logData.payloadType == .trace: - if logData.packetPayload.count >= 4, let snr = logData.snr { - let tag = logData.packetPayload.readUInt32LE(at: 0) - let remoteSnr: Double? = logData.pathNodes.last.map { - Double(Int8(bitPattern: $0)) / 4.0 - } - eventBroadcaster.yield(.traceSnrObserved(tag: tag, localSnr: snr, remoteSnr: remoteSnr, radioID: radioID)) - } - - case .contactDeleted(let publicKey): - await handleContactDeletedEvent(publicKey: publicKey, radioID: radioID) + case let .pathResponse(result): + await handlePathDiscoveryResponse(result: result, radioID: radioID) - case .contactsFull: - await handleContactsFullEvent() + case let .traceData(traceInfo): + await handleTraceData(traceInfo: traceInfo, radioID: radioID) - default: - break + case let .rxLogData(logData) where logData.payloadType == .trace: + if logData.packetPayload.count >= 4, let snr = logData.snr { + let tag = logData.packetPayload.readUInt32LE(at: 0) + let remoteSnr: Double? = logData.pathNodes.last.map { + Double(Int8(bitPattern: $0)) / 4.0 } - } - - // MARK: - Send Advertisement + eventBroadcaster.yield(.traceSnrObserved(tag: tag, localSnr: snr, remoteSnr: remoteSnr, radioID: radioID)) + } - /// Send self advertisement to the mesh network - /// - Parameter flood: If true, sends flood advertisement (reaches all nodes). - /// If false, sends zero-hop advertisement (direct only). - public func sendSelfAdvertisement(flood: Bool) async throws { - do { - try await session.sendAdvertisement(flood: flood) - } catch let error as MeshCoreError { - throw AdvertisementError.sessionError(error) - } - } + case let .contactDeleted(publicKey): + await handleContactDeletedEvent(publicKey: publicKey, radioID: radioID) - // MARK: - Update Node Name + case .contactsFull: + await handleContactsFullEvent() - /// Set the node's advertised name - /// - Parameter name: The name to advertise (max 31 characters) - public func setAdvertName(_ name: String) async throws { - do { - try await session.setName(name) - } catch let error as MeshCoreError { - throw AdvertisementError.sessionError(error) - } + default: + break } - - // MARK: - Update Location - - /// Set the node's advertised GPS coordinates - /// - Parameters: - /// - latitude: Latitude in degrees (-90 to 90) - /// - longitude: Longitude in degrees (-180 to 180) - public func setAdvertLocation(latitude: Double, longitude: Double) async throws { - do { - try await session.setCoordinates(latitude: latitude, longitude: longitude) - } catch let error as MeshCoreError { - throw AdvertisementError.sessionError(error) - } + } + + // MARK: - Send Advertisement + + /// Send self advertisement to the mesh network + /// - Parameter flood: If true, sends flood advertisement (reaches all nodes). + /// If false, sends zero-hop advertisement (direct only). + public func sendSelfAdvertisement(flood: Bool) async throws { + do { + try await session.sendAdvertisement(flood: flood) + } catch let error as MeshCoreError { + throw AdvertisementError.sessionError(error) } + } - // MARK: - Private Event Handlers - - /// Handle advertisement event - Existing contact updated - private func handleAdvertEvent(publicKey: Data, radioID: UUID) async { - let pubKeyHex = publicKey.map { String(format: "%02X", $0) }.joined() - logger.debug("Advert event for \(pubKeyHex)") - discoverTrace.info("B1 0x80 ADVERT received key=\(pubKeyHex)") - - let timestamp = UInt32(Date().timeIntervalSince1970) + // MARK: - Update Node Name - do { - if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { - // Create a modified version with updated timestamp - let frame = ContactFrame( - publicKey: contact.publicKey, - type: contact.type, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - name: contact.name, - lastAdvertTimestamp: timestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: UInt32(Date().timeIntervalSince1970) - ) - _ = try await dataStore.saveContact(radioID: radioID, from: frame) - - // Also track in DiscoveredNode for Discover page visibility - do { - let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) - discoverTrace.info("B2 0x80 known-contact upsert key=\(pubKeyHex) isNew=\(isNew)") - } catch { - discoverTrace.error("B2 0x80 known-contact upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") - } - - // Notify UI of contact update - eventBroadcaster.yield(.contactUpdated) - } else { - discoverTrace.info("B2 0x80 no local contact key=\(pubKeyHex) syncing=\(isSyncingContacts)") - if isSyncingContacts { - pendingUnknownContactKeys.insert(publicKey) - logger.info("ADVERT received for unknown contact during sync - deferring fetch") - } else { - // Unknown contact - device has it but we don't (auto-add mode) - // Fetch just this contact from device and notify - logger.info("ADVERT received for unknown contact - fetching from device") - do { - if let meshContact = try await session.getContact(publicKey: publicKey) { - let frame = meshContact.toContactFrame() - let contactID = try await dataStore.saveContact(radioID: radioID, from: frame) - - // Also track in DiscoveredNode for Discover page visibility - do { - let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) - discoverTrace.info("B2 0x80 getContact OK upsert key=\(pubKeyHex) isNew=\(isNew)") - } catch { - discoverTrace.error("B2 0x80 getContact-path upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") - } - - // Empty names pass through raw; NotificationService substitutes a localized fallback. - let contactName = meshContact.advertisedName - let contactType = meshContact.type - eventBroadcaster.yield(.newContactDiscovered(name: contactName, contactID: contactID, contactType: contactType)) - - // Correlate with recent overwrite-oldest deletion - logOverwriteReplacementIfRecent( - newContactName: contactName.isEmpty ? "Unknown Contact" : contactName, - newContactType: contactType - ) - } else { - discoverTrace.notice("B2 0x80 getContact returned nil key=\(pubKeyHex)") - } - } catch { - logger.error("Failed to fetch new contact: \(error.localizedDescription)") - discoverTrace.error("B2 0x80 getContact THREW key=\(pubKeyHex): \(error.localizedDescription)") - } - eventBroadcaster.yield(.contactSyncRequested(radioID: radioID)) - } - } - } catch { - logger.error("Error handling advert event: \(error.localizedDescription)") - } + /// Set the node's advertised name + /// - Parameter name: The name to advertise (max 31 characters) + public func setAdvertName(_ name: String) async throws { + do { + try await session.setName(name) + } catch let error as MeshCoreError { + throw AdvertisementError.sessionError(error) } - - private func fetchPendingUnknownContacts() async { - guard !pendingUnknownContactKeys.isEmpty else { return } - guard let radioID = currentRadioID else { - logger.warning("No device ID available to fetch pending contacts") - return - } - - let pendingKeys = pendingUnknownContactKeys - pendingUnknownContactKeys.removeAll() - - for publicKey in pendingKeys { - do { - let pubKeyHex = publicKey.map { String(format: "%02X", $0) }.joined() - if let meshContact = try await session.getContact(publicKey: publicKey) { - let frame = meshContact.toContactFrame() - let contactID = try await dataStore.saveContact(radioID: radioID, from: frame) - - // Also track in DiscoveredNode for Discover page visibility - do { - let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) - discoverTrace.info("B2 deferred-drain upsert key=\(pubKeyHex) isNew=\(isNew)") - } catch { - discoverTrace.error("B2 deferred-drain upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") - } - - // Empty names pass through raw; NotificationService substitutes a localized fallback. - let contactName = meshContact.advertisedName - let contactType = meshContact.type - eventBroadcaster.yield(.newContactDiscovered(name: contactName, contactID: contactID, contactType: contactType)) - eventBroadcaster.yield(.contactSyncRequested(radioID: radioID)) - } - } catch { - pendingUnknownContactKeys.insert(publicKey) - logger.error("Failed to fetch deferred contact: \(error.localizedDescription)") - } - } + } + + // MARK: - Update Location + + /// Set the node's advertised GPS coordinates + /// - Parameters: + /// - latitude: Latitude in degrees (-90 to 90) + /// - longitude: Longitude in degrees (-180 to 180) + public func setAdvertLocation(latitude: Double, longitude: Double) async throws { + do { + try await session.setCoordinates(latitude: latitude, longitude: longitude) + } catch let error as MeshCoreError { + throw AdvertisementError.sessionError(error) } - - /// Handle new advertisement event - New contact discovered (manual add mode) - private func handleNewAdvertEvent(contact: MeshContact, radioID: UUID) async { - let contactFrame = contact.toContactFrame() - let pubKeyHex = contactFrame.publicKey.map { String(format: "%02X", $0) }.joined() - discoverTrace.info("B1 0x8A NEW_ADVERT received key=\(pubKeyHex)") - + } + + // MARK: - Private Event Handlers + + /// Handle advertisement event - Existing contact updated + private func handleAdvertEvent(publicKey: Data, radioID: UUID) async { + let pubKeyHex = publicKey.map { String(format: "%02X", $0) }.joined() + logger.debug("Advert event for \(pubKeyHex)") + discoverTrace.info("B1 0x80 ADVERT received key=\(pubKeyHex)") + + let timestamp = UInt32(Date().timeIntervalSince1970) + + do { + if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { + // Create a modified version with updated timestamp + let frame = ContactFrame( + publicKey: contact.publicKey, + type: contact.type, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + name: contact.name, + lastAdvertTimestamp: timestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: UInt32(Date().timeIntervalSince1970) + ) + _ = try await dataStore.saveContact(radioID: radioID, from: frame) + + // Also track in DiscoveredNode for Discover page visibility do { - let (node, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: contactFrame) - discoverTrace.info("B2 0x8A upsert key=\(pubKeyHex) isNew=\(isNew)") - - // Notify UI of discovered node update - eventBroadcaster.yield(.contactUpdated) - - // Only post notification for NEW discoveries (not repeat adverts from same contact) - if isNew { - let contactName = node.name - let contactType = node.nodeType - eventBroadcaster.yield(.newContactDiscovered(name: contactName, contactID: node.id, contactType: contactType)) - - // Correlate with recent overwrite-oldest deletion - logOverwriteReplacementIfRecent(newContactName: contactName, newContactType: contactType) - } + let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) + discoverTrace.info("B2 0x80 known-contact upsert key=\(pubKeyHex) isNew=\(isNew)") } catch { - logger.error("Error handling new advert event: \(error.localizedDescription)") - discoverTrace.error("B2 0x8A upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") + discoverTrace.error("B2 0x80 known-contact upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") } - } - - /// Handle path updated event - Contact path changed - private func handlePathUpdatedEvent(publicKey: Data, radioID: UUID) async { - let pubKeyHex = publicKey.map { String(format: "%02X", $0) }.joined() - logger.debug("Path updated event for \(pubKeyHex)") - do { - // Fetch fresh contact from device (includes updated path) - guard let meshContact = try await session.getContact(publicKey: publicKey) else { - logger.warning("Contact not found on device for public key \(pubKeyHex)") - return + // Notify UI of contact update + eventBroadcaster.yield(.contactUpdated) + } else { + discoverTrace.info("B2 0x80 no local contact key=\(pubKeyHex) syncing=\(isSyncingContacts)") + if isSyncingContacts { + pendingUnknownContactKeys.insert(publicKey) + logger.info("ADVERT received for unknown contact during sync - deferring fetch") + } else { + // Unknown contact - device has it but we don't (auto-add mode) + // Fetch just this contact from device and notify + logger.info("ADVERT received for unknown contact - fetching from device") + do { + if let meshContact = try await session.getContact(publicKey: publicKey) { + let frame = meshContact.toContactFrame() + let contactID = try await dataStore.saveContact(radioID: radioID, from: frame) + + // Also track in DiscoveredNode for Discover page visibility + do { + let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) + discoverTrace.info("B2 0x80 getContact OK upsert key=\(pubKeyHex) isNew=\(isNew)") + } catch { + discoverTrace.error("B2 0x80 getContact-path upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") + } + + // Empty names pass through raw; NotificationService substitutes a localized fallback. + let contactName = meshContact.advertisedName + let contactType = meshContact.type + eventBroadcaster.yield(.newContactDiscovered(name: contactName, contactID: contactID, contactType: contactType)) + + // Correlate with recent overwrite-oldest deletion + logOverwriteReplacementIfRecent( + newContactName: contactName.isEmpty ? "Unknown Contact" : contactName, + newContactType: contactType + ) + } else { + discoverTrace.notice("B2 0x80 getContact returned nil key=\(pubKeyHex)") } - - // Persist updated routing info - let frame = meshContact.toContactFrame() - _ = try await dataStore.saveContact(radioID: radioID, from: frame) - - logger.debug("Refreshed contact path: \(meshContact.advertisedName.isEmpty ? "unnamed" : meshContact.advertisedName)") - - // Notify UI of contact update - eventBroadcaster.yield(.contactUpdated) - - } catch { - logger.error("Error refreshing contact path: \(error.localizedDescription)") + } catch { + logger.error("Failed to fetch new contact: \(error.localizedDescription)") + discoverTrace.error("B2 0x80 getContact THREW key=\(pubKeyHex): \(error.localizedDescription)") + } + eventBroadcaster.yield(.contactSyncRequested(radioID: radioID)) } + } + } catch { + logger.error("Error handling advert event: \(error.localizedDescription)") } + } - /// Handle path discovery response event - private func handlePathDiscoveryResponse(result: PathInfo, radioID: UUID) async { - // Chunk debug output using the hash size each direction declares on - // the wire so mode-skew between firmware and the cached device record - // can't smear hop boundaries in the log. - let outHashSize = decodePathLen(result.outPathLength)?.hashSize ?? 1 - let inHashSize = decodePathLen(result.inPathLength)?.hashSize ?? 1 - let outHops = stride(from: 0, to: result.outPath.count, by: outHashSize).map { start in - result.outPath[start.. Int { - switch kind { - case .messages: messageCount - case .contacts: contactCount - case .channels: channelCount - case .devices: deviceCount - case .roomMessages: roomMessageCount - case .reactions: reactionCount - case .messageRepeats: messageRepeatCount - case .savedTracePaths: savedTracePathCount - case .remoteNodeSessions: remoteNodeSessionCount - case .blockedChannelSenders: blockedChannelSenderCount - case .nodeStatusSnapshots: nodeStatusSnapshotCount - } + public var deviceCount: Int + public var contactCount: Int + public var channelCount: Int + public var messageCount: Int + public var messageRepeatCount: Int + public var reactionCount: Int + public var roomMessageCount: Int + public var remoteNodeSessionCount: Int + public var savedTracePathCount: Int + public var blockedChannelSenderCount: Int + public var nodeStatusSnapshotCount: Int + + public init( + deviceCount: Int = 0, + contactCount: Int = 0, + channelCount: Int = 0, + messageCount: Int = 0, + messageRepeatCount: Int = 0, + reactionCount: Int = 0, + roomMessageCount: Int = 0, + remoteNodeSessionCount: Int = 0, + savedTracePathCount: Int = 0, + blockedChannelSenderCount: Int = 0, + nodeStatusSnapshotCount: Int = 0 + ) { + self.deviceCount = deviceCount + self.contactCount = contactCount + self.channelCount = channelCount + self.messageCount = messageCount + self.messageRepeatCount = messageRepeatCount + self.reactionCount = reactionCount + self.roomMessageCount = roomMessageCount + self.remoteNodeSessionCount = remoteNodeSessionCount + self.savedTracePathCount = savedTracePathCount + self.blockedChannelSenderCount = blockedChannelSenderCount + self.nodeStatusSnapshotCount = nodeStatusSnapshotCount + } + + /// Build a manifest from an envelope's actual array counts. + public init(from envelope: AppBackupEnvelope) { + deviceCount = envelope.devices.count + contactCount = envelope.contacts.count + channelCount = envelope.channels.count + messageCount = envelope.messages.count + messageRepeatCount = envelope.messageRepeats.count + reactionCount = envelope.reactions.count + roomMessageCount = envelope.roomMessages.count + remoteNodeSessionCount = envelope.remoteNodeSessions.count + savedTracePathCount = envelope.savedTracePaths.count + blockedChannelSenderCount = envelope.blockedChannelSenders.count + nodeStatusSnapshotCount = envelope.nodeStatusSnapshots.count + } + + /// Returns the declared count for `kind`. Backs `BackupModelKind`-driven UI iteration. + public func count(for kind: BackupModelKind) -> Int { + switch kind { + case .messages: messageCount + case .contacts: contactCount + case .channels: channelCount + case .devices: deviceCount + case .roomMessages: roomMessageCount + case .reactions: reactionCount + case .messageRepeats: messageRepeatCount + case .savedTracePaths: savedTracePathCount + case .remoteNodeSessions: remoteNodeSessionCount + case .blockedChannelSenders: blockedChannelSenderCount + case .nodeStatusSnapshots: nodeStatusSnapshotCount } + } - /// Returns `true` if the manifest counts match the actual array counts in the envelope. - public func validate(against envelope: AppBackupEnvelope) -> Bool { - self == BackupManifest(from: envelope) - } + /// Returns `true` if the manifest counts match the actual array counts in the envelope. + public func validate(against envelope: AppBackupEnvelope) -> Bool { + self == BackupManifest(from: envelope) + } } // MARK: - ImportResult @@ -196,63 +195,76 @@ public struct BackupManifest: Codable, Sendable, Equatable { /// Storage is a `BackupModelKind`-keyed dict so totals and UI row iteration /// stay consistent when a new model type is added. public struct ImportResult: Sendable, Equatable { - public var counts: [BackupModelKind: PerTypeCounts] - public var userDefaultsRestored: Bool = false - - /// Local channel slots, keyed by radioID, whose occupancy changed during - /// import (relocated, merged at a different slot, or dropped). The main-actor - /// caller clears chat drafts for these slots so a draft typed against one - /// channel can't silently follow a slot to a different channel. - public var channelSlotsAffectedByImport: [UUID: Set] = [:] - - public init() { - self.counts = Dictionary( - uniqueKeysWithValues: BackupModelKind.allCases.map { ($0, .zero) } - ) - } - - /// Adds to the counts for `kind`. All import phases funnel through this - /// single mutator so totals and per-kind buckets can't drift. - public mutating func record( - _ kind: BackupModelKind, - inserted: Int = 0, - merged: Int = 0, - skipped: Int = 0, - dropped: Int = 0 - ) { - var current = counts[kind, default: .zero] - current.inserted += inserted - current.merged += merged - current.skipped += skipped - current.dropped += dropped - counts[kind] = current - } - - public var totalInserted: Int { counts.values.reduce(0) { $0 + $1.inserted } } - public var totalMerged: Int { counts.values.reduce(0) { $0 + $1.merged } } - public var totalSkipped: Int { counts.values.reduce(0) { $0 + $1.skipped } } - public var totalDropped: Int { counts.values.reduce(0) { $0 + $1.dropped } } - - public var totalRestoredRecordCount: Int { totalInserted + totalMerged } - - public var hasRestoredChanges: Bool { - totalRestoredRecordCount > 0 || userDefaultsRestored - } + public var counts: [BackupModelKind: PerTypeCounts] + public var userDefaultsRestored: Bool = false + + /// Local channel slots, keyed by radioID, whose occupancy changed during + /// import (relocated, merged at a different slot, or dropped). The main-actor + /// caller clears chat drafts for these slots so a draft typed against one + /// channel can't silently follow a slot to a different channel. + public var channelSlotsAffectedByImport: [UUID: Set] = [:] + + public init() { + counts = Dictionary( + uniqueKeysWithValues: BackupModelKind.allCases.map { ($0, .zero) } + ) + } + + /// Adds to the counts for `kind`. All import phases funnel through this + /// single mutator so totals and per-kind buckets can't drift. + public mutating func record( + _ kind: BackupModelKind, + inserted: Int = 0, + merged: Int = 0, + skipped: Int = 0, + dropped: Int = 0 + ) { + var current = counts[kind, default: .zero] + current.inserted += inserted + current.merged += merged + current.skipped += skipped + current.dropped += dropped + counts[kind] = current + } + + public var totalInserted: Int { + counts.values.reduce(0) { $0 + $1.inserted } + } + + public var totalMerged: Int { + counts.values.reduce(0) { $0 + $1.merged } + } + + public var totalSkipped: Int { + counts.values.reduce(0) { $0 + $1.skipped } + } + + public var totalDropped: Int { + counts.values.reduce(0) { $0 + $1.dropped } + } + + public var totalRestoredRecordCount: Int { + totalInserted + totalMerged + } + + public var hasRestoredChanges: Bool { + totalRestoredRecordCount > 0 || userDefaultsRestored + } } func makeBackupJSONEncoder() -> JSONEncoder { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .secondsSince1970 - // Deterministic key order gives zlib longer match windows on the - // DTO-array-heavy payload — measurable ratio win at zero runtime cost. - encoder.outputFormatting = .sortedKeys - return encoder + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + // Deterministic key order gives zlib longer match windows on the + // DTO-array-heavy payload — measurable ratio win at zero runtime cost. + encoder.outputFormatting = .sortedKeys + return encoder } func makeBackupJSONDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .secondsSince1970 - return decoder + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + return decoder } // MARK: - parseBackup @@ -277,43 +289,43 @@ public let maxBackupUncompressedBytes = 512 * 1_048_576 /// - Returns: A validated `AppBackupEnvelope`. /// - Throws: `AppBackupError` on failure. public func parseBackup( - data: Data, - maxUncompressedBytes: Int = maxBackupUncompressedBytes + data: Data, + maxUncompressedBytes: Int = maxBackupUncompressedBytes ) throws -> AppBackupEnvelope { - guard data.count <= maxBackupCompressedBytes else { - throw AppBackupError.fileTooLarge( - actualBytes: data.count, - maxBytes: maxBackupCompressedBytes - ) - } - - let decompressed: Data - do { - decompressed = try data.zlibDecompressed(maxUncompressedBytes: maxUncompressedBytes) - } catch let error as AppBackupError { - throw error - } catch { - throw AppBackupError.invalidFile - } - - let envelope: AppBackupEnvelope - do { - let decoder = makeBackupJSONDecoder() - envelope = try decoder.decode(AppBackupEnvelope.self, from: decompressed) - } catch { - throw AppBackupError.invalidFile - } - - guard envelope.version <= AppBackupEnvelope.currentVersion else { - throw AppBackupError.unsupportedVersion( - found: envelope.version, - maxSupported: AppBackupEnvelope.currentVersion - ) - } - - guard envelope.manifest.validate(against: envelope) else { - throw AppBackupError.corruptedManifest - } - - return envelope + guard data.count <= maxBackupCompressedBytes else { + throw AppBackupError.fileTooLarge( + actualBytes: data.count, + maxBytes: maxBackupCompressedBytes + ) + } + + let decompressed: Data + do { + decompressed = try data.zlibDecompressed(maxUncompressedBytes: maxUncompressedBytes) + } catch let error as AppBackupError { + throw error + } catch { + throw AppBackupError.invalidFile + } + + let envelope: AppBackupEnvelope + do { + let decoder = makeBackupJSONDecoder() + envelope = try decoder.decode(AppBackupEnvelope.self, from: decompressed) + } catch { + throw AppBackupError.invalidFile + } + + guard envelope.version <= AppBackupEnvelope.currentVersion else { + throw AppBackupError.unsupportedVersion( + found: envelope.version, + maxSupported: AppBackupEnvelope.currentVersion + ) + } + + guard envelope.manifest.validate(against: envelope) else { + throw AppBackupError.corruptedManifest + } + + return envelope } diff --git a/MC1Services/Sources/MC1Services/Services/AppBackupService.swift b/MC1Services/Sources/MC1Services/Services/AppBackupService.swift index 1cabe6bf..0ddd5423 100644 --- a/MC1Services/Sources/MC1Services/Services/AppBackupService.swift +++ b/MC1Services/Sources/MC1Services/Services/AppBackupService.swift @@ -2,116 +2,115 @@ import Foundation /// Result of a successful backup export. public struct ExportResult: Sendable { - public let data: Data - public let manifest: BackupManifest + public let data: Data + public let manifest: BackupManifest - public init(data: Data, manifest: BackupManifest) { - self.data = data - self.manifest = manifest - } + public init(data: Data, manifest: BackupManifest) { + self.data = data + self.manifest = manifest + } } /// Exports all app data to a compressed backup file. public actor AppBackupService { - - private let logger = PersistentLogger(subsystem: "com.mc1", category: "AppBackupService") - - public init() {} - - // MARK: - Export - - /// Export all app data to a compressed backup. - /// - Parameter persistenceStore: The store to fetch records from. - /// - Returns: An `ExportResult` containing the compressed data and manifest. - /// - Throws: `AppBackupError.exportFailed` on failure. - public func export( - persistenceStore: PersistenceStore - ) async throws -> ExportResult { - do { - let snapshot = try await persistenceStore.fetchBackupExportSnapshot() - - let userDefaultsSnapshot = BackupUserDefaults.snapshot(from: .standard) - - let appVersion = Bundle.main.appVersion - let appBuild = Bundle.main.appBuild - - var envelope = AppBackupEnvelope( - exportDate: .now, - appVersion: appVersion, - appBuild: appBuild, - devices: snapshot.devices, - contacts: snapshot.contacts, - channels: snapshot.channels, - messages: snapshot.messages, - messageRepeats: snapshot.messageRepeats, - reactions: snapshot.reactions, - roomMessages: snapshot.roomMessages, - remoteNodeSessions: snapshot.remoteNodeSessions, - savedTracePaths: snapshot.savedTracePaths, - blockedChannelSenders: snapshot.blockedChannelSenders, - nodeStatusSnapshots: snapshot.nodeStatusSnapshots, - userDefaults: userDefaultsSnapshot - ) - envelope.manifest = BackupManifest(from: envelope) - - let encoder = makeBackupJSONEncoder() - let jsonData = try encoder.encode(envelope) - - let compressed = try jsonData.zlibCompressed() - logger.info("Backup exported: \(jsonData.count) bytes JSON → \(compressed.count) bytes compressed") - return ExportResult(data: compressed, manifest: envelope.manifest) - - } catch let error as AppBackupError { - throw error - } catch { - throw AppBackupError.exportFailed(underlying: error) - } + private let logger = PersistentLogger(subsystem: "com.mc1", category: "AppBackupService") + + public init() {} + + // MARK: - Export + + /// Export all app data to a compressed backup. + /// - Parameter persistenceStore: The store to fetch records from. + /// - Returns: An `ExportResult` containing the compressed data and manifest. + /// - Throws: `AppBackupError.exportFailed` on failure. + public func export( + persistenceStore: PersistenceStore + ) async throws -> ExportResult { + do { + let snapshot = try await persistenceStore.fetchBackupExportSnapshot() + + let userDefaultsSnapshot = BackupUserDefaults.snapshot(from: .standard) + + let appVersion = Bundle.main.appVersion + let appBuild = Bundle.main.appBuild + + var envelope = AppBackupEnvelope( + exportDate: .now, + appVersion: appVersion, + appBuild: appBuild, + devices: snapshot.devices, + contacts: snapshot.contacts, + channels: snapshot.channels, + messages: snapshot.messages, + messageRepeats: snapshot.messageRepeats, + reactions: snapshot.reactions, + roomMessages: snapshot.roomMessages, + remoteNodeSessions: snapshot.remoteNodeSessions, + savedTracePaths: snapshot.savedTracePaths, + blockedChannelSenders: snapshot.blockedChannelSenders, + nodeStatusSnapshots: snapshot.nodeStatusSnapshots, + userDefaults: userDefaultsSnapshot + ) + envelope.manifest = BackupManifest(from: envelope) + + let encoder = makeBackupJSONEncoder() + let jsonData = try encoder.encode(envelope) + + let compressed = try jsonData.zlibCompressed() + logger.info("Backup exported: \(jsonData.count) bytes JSON → \(compressed.count) bytes compressed") + return ExportResult(data: compressed, manifest: envelope.manifest) + + } catch let error as AppBackupError { + throw error + } catch { + throw AppBackupError.exportFailed(underlying: error) } - - // MARK: - Import - - /// Import backup data into the local store with radioID remapping. - /// - /// Devices are matched by `publicKey`. When a backup device matches a local device, - /// all child records are remapped to use the local device's `radioID`. Unmatched - /// devices are inserted as-is. Records are inserted in parent-before-child order - /// with a single `modelContext.save()` at the end. - /// - /// - Parameters: - /// - envelope: The decoded backup envelope. - /// - persistenceStore: The store to insert records into. - /// - defaults: UserDefaults instance for preference restore (defaults to `.standard`). - /// - Returns: An `ImportResult` with per-model inserted/skipped counts. - /// - Throws: `AppBackupError.importFailed` on failure. - @discardableResult - public func importBackup( - envelope: AppBackupEnvelope, - into persistenceStore: PersistenceStore, - defaults: UserDefaults = .standard - ) async throws -> ImportResult { - do { - var result = try await persistenceStore.importBackupDatabase(envelope) - - // Cancellation past this point is ignored. `importBackupDatabase` has - // already committed, and `BackupUserDefaults.restore(to:)` is a - // write-if-missing no-op on re-run, so finishing the side effect is - // safer than reporting cancellation while the DB is persisted. - if let userDefaultsSnapshot = envelope.userDefaults { - let addedKeys = userDefaultsSnapshot.restore(to: defaults) - result.userDefaultsRestored = !addedKeys.isEmpty - } - - logger.info( - "Import complete: \(result.totalInserted) inserted, \(result.totalMerged) merged, \(result.totalSkipped) skipped" - ) - return result - - } catch is CancellationError { - throw CancellationError() - } catch let error as AppBackupError { - throw error - } catch { - throw AppBackupError.importFailed(underlying: error) - } + } + + // MARK: - Import + + /// Import backup data into the local store with radioID remapping. + /// + /// Devices are matched by `publicKey`. When a backup device matches a local device, + /// all child records are remapped to use the local device's `radioID`. Unmatched + /// devices are inserted as-is. Records are inserted in parent-before-child order + /// with a single `modelContext.save()` at the end. + /// + /// - Parameters: + /// - envelope: The decoded backup envelope. + /// - persistenceStore: The store to insert records into. + /// - defaults: UserDefaults instance for preference restore (defaults to `.standard`). + /// - Returns: An `ImportResult` with per-model inserted/skipped counts. + /// - Throws: `AppBackupError.importFailed` on failure. + @discardableResult + public func importBackup( + envelope: AppBackupEnvelope, + into persistenceStore: PersistenceStore, + defaults: UserDefaults = .standard + ) async throws -> ImportResult { + do { + var result = try await persistenceStore.importBackupDatabase(envelope) + + // Cancellation past this point is ignored. `importBackupDatabase` has + // already committed, and `BackupUserDefaults.restore(to:)` is a + // write-if-missing no-op on re-run, so finishing the side effect is + // safer than reporting cancellation while the DB is persisted. + if let userDefaultsSnapshot = envelope.userDefaults { + let addedKeys = userDefaultsSnapshot.restore(to: defaults) + result.userDefaultsRestored = !addedKeys.isEmpty + } + + logger.info( + "Import complete: \(result.totalInserted) inserted, \(result.totalMerged) merged, \(result.totalSkipped) skipped" + ) + return result + + } catch is CancellationError { + throw CancellationError() + } catch let error as AppBackupError { + throw error + } catch { + throw AppBackupError.importFailed(underlying: error) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/AppStorageKey.swift b/MC1Services/Sources/MC1Services/Services/AppStorageKey.swift index fa24da6e..0cce51e0 100644 --- a/MC1Services/Sources/MC1Services/Services/AppStorageKey.swift +++ b/MC1Services/Sources/MC1Services/Services/AppStorageKey.swift @@ -13,70 +13,70 @@ import Foundation /// it in `BackupUserDefaults` (a mapping row or a special-cased branch) /// unless the value is intentionally device-local. public enum AppStorageKey: String { - // swiftlint:disable redundant_string_enum_value - case hasCompletedOnboarding = "hasCompletedOnboarding" - case liveActivityEnabled = "liveActivityEnabled" - case showIncomingPath = "showIncomingPath" - case showIncomingHopCount = "showIncomingHopCount" - case showIncomingRegion = "showIncomingRegion" - case showIncomingSendTime = "showIncomingSendTime" - case linkPreviewsEnabled = "linkPreviewsEnabled" - case linkPreviewsAutoResolveDM = "linkPreviewsAutoResolveDM" - case linkPreviewsAutoResolveChannels = "linkPreviewsAutoResolveChannels" - case showInlineImages = "showInlineImages" - case autoPlayGIFs = "autoPlayGIFs" - case replyWithQuote = "replyWithQuote" - case showMapPreviewThumbnails = "showMapPreviewThumbnails" - case nodesSortOrder = "nodesSortOrder" - case discoverySortOrder = "discoverySortOrder" - case tracePathViewMode = "tracePathViewMode" - case mapStyleSelection = "mapStyleSelection" - case mapShowLabels = "mapShowLabels" - case hasSeenRepeaterDragHint = "hasSeenRepeaterDragHint" - case autoDeleteStaleNodesDays = "autoDeleteStaleNodesDays" - case lastStaleCleanupDate = "lastStaleCleanupDate" - case frequentEmojis = "frequentEmojis" - case recentReactionEmojis = "recentReactionEmojis" - case isDemoModeUnlocked = "isDemoModeUnlocked" - case isDemoModeEnabled = "isDemoModeEnabled" - // Intentionally device-local: not registered in BackupUserDefaults so restored - // hardware still shows the current release's notes once. Registering it would - // suppress the sheet on a genuinely new device. - case lastShownWhatsNewVersion = "lastShownWhatsNewVersion" + // swiftlint:disable redundant_string_enum_value + case hasCompletedOnboarding + case liveActivityEnabled + case showIncomingPath + case showIncomingHopCount + case showIncomingRegion + case showIncomingSendTime + case linkPreviewsEnabled + case linkPreviewsAutoResolveDM + case linkPreviewsAutoResolveChannels + case showInlineImages + case autoPlayGIFs + case replyWithQuote + case showMapPreviewThumbnails + case nodesSortOrder + case discoverySortOrder + case tracePathViewMode + case mapStyleSelection + case mapShowLabels + case hasSeenRepeaterDragHint + case autoDeleteStaleNodesDays + case lastStaleCleanupDate + case frequentEmojis + case recentReactionEmojis + case isDemoModeUnlocked + case isDemoModeEnabled + /// Intentionally device-local: not registered in BackupUserDefaults so restored + /// hardware still shows the current release's notes once. Registering it would + /// suppress the sheet on a genuinely new device. + case lastShownWhatsNewVersion - // Notification toggles read by NotificationPreferences and - // NotificationPreferencesStore; all share defaultNotificationEnabled. - case notifyContactMessages = "notifyContactMessages" - case notifyChannelMessages = "notifyChannelMessages" - case notifyRoomMessages = "notifyRoomMessages" - case notifyNewContacts = "notifyNewContacts" - case notifyNewContactsContact = "notifyNewContactsContact" - case notifyNewContactsRepeater = "notifyNewContactsRepeater" - case notifyNewContactsRoom = "notifyNewContactsRoom" - case notifyReactions = "notifyReactions" - case notificationSoundEnabled = "notificationSoundEnabled" - case notificationBadgeEnabled = "notificationBadgeEnabled" - case notifyLowBattery = "notifyLowBattery" - // swiftlint:enable redundant_string_enum_value + // Notification toggles read by NotificationPreferences and + // NotificationPreferencesStore; all share defaultNotificationEnabled. + case notifyContactMessages + case notifyChannelMessages + case notifyRoomMessages + case notifyNewContacts + case notifyNewContactsContact + case notifyNewContactsRepeater + case notifyNewContactsRoom + case notifyReactions + case notificationSoundEnabled + case notificationBadgeEnabled + case notifyLowBattery + // swiftlint:enable redundant_string_enum_value - public static let defaultShowIncomingPath: Bool = false - public static let defaultShowIncomingHopCount: Bool = false - public static let defaultShowIncomingRegion: Bool = false - public static let defaultShowIncomingSendTime: Bool = false - public static let defaultLinkPreviewsEnabled: Bool = false - public static let defaultLinkPreviewsAutoResolveDM: Bool = true - public static let defaultLinkPreviewsAutoResolveChannels: Bool = true - public static let defaultShowInlineImages: Bool = true - public static let defaultAutoPlayGIFs: Bool = true - public static let defaultReplyWithQuote: Bool = false - public static let defaultShowMapPreviewThumbnails: Bool = true - public static let defaultMapShowLabels: Bool = true - public static let defaultHasSeenRepeaterDragHint: Bool = false - public static let defaultLiveActivityEnabled: Bool = true - /// Days before a non-favorite node is auto-deleted; 0 disables cleanup. - public static let defaultAutoDeleteStaleNodesDays: Int = 0 - /// `timeIntervalSinceReferenceDate` of the last cleanup; 0 means never ran. - public static let defaultLastStaleCleanupDate: Double = 0 - /// Shared default for every notification toggle case. - public static let defaultNotificationEnabled: Bool = true + public static let defaultShowIncomingPath: Bool = false + public static let defaultShowIncomingHopCount: Bool = false + public static let defaultShowIncomingRegion: Bool = false + public static let defaultShowIncomingSendTime: Bool = false + public static let defaultLinkPreviewsEnabled: Bool = false + public static let defaultLinkPreviewsAutoResolveDM: Bool = true + public static let defaultLinkPreviewsAutoResolveChannels: Bool = true + public static let defaultShowInlineImages: Bool = true + public static let defaultAutoPlayGIFs: Bool = true + public static let defaultReplyWithQuote: Bool = false + public static let defaultShowMapPreviewThumbnails: Bool = true + public static let defaultMapShowLabels: Bool = true + public static let defaultHasSeenRepeaterDragHint: Bool = false + public static let defaultLiveActivityEnabled: Bool = true + /// Days before a non-favorite node is auto-deleted; 0 disables cleanup. + public static let defaultAutoDeleteStaleNodesDays: Int = 0 + /// `timeIntervalSinceReferenceDate` of the last cleanup; 0 means never ran. + public static let defaultLastStaleCleanupDate: Double = 0 + /// Shared default for every notification toggle case. + public static let defaultNotificationEnabled: Bool = true } diff --git a/MC1Services/Sources/MC1Services/Services/BLETransportOpenedSignal.swift b/MC1Services/Sources/MC1Services/Services/BLETransportOpenedSignal.swift index 9c1622a3..1f5a8b2b 100644 --- a/MC1Services/Sources/MC1Services/Services/BLETransportOpenedSignal.swift +++ b/MC1Services/Sources/MC1Services/Services/BLETransportOpenedSignal.swift @@ -6,69 +6,68 @@ import Foundation /// cancelled before `fire()` lands; callers handle the throw with the /// same logic as a timeout (park the envelope, requeue). actor BLETransportOpenedSignal { + private struct Waiter { + let id: UInt64 + let continuation: CheckedContinuation + } - private struct Waiter { - let id: UInt64 - let continuation: CheckedContinuation - } - - private var armed: Bool = false - private var waiters: [Waiter] = [] - private var nextWaiterID: UInt64 = 0 + private var armed: Bool = false + private var waiters: [Waiter] = [] + private var nextWaiterID: UInt64 = 0 - init() {} + init() {} - /// Suspend until `fire()` lands. If the signal is already armed at - /// call time, the call returns immediately and consumes the armed - /// flag. Throws `CancellationError` if the calling task is cancelled - /// before the signal fires. - func wait() async throws { - if armed { - armed = false - return - } - let waiterID = nextWaiterID - nextWaiterID &+= 1 - try await withTaskCancellationHandler { - let _: Void = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - if Task.isCancelled { - continuation.resume(throwing: CancellationError()) - return - } - waiters.append(Waiter(id: waiterID, continuation: continuation)) - } - } onCancel: { - Task { [weak self] in - await self?.handleCancellation(id: waiterID) - } - } + /// Suspend until `fire()` lands. If the signal is already armed at + /// call time, the call returns immediately and consumes the armed + /// flag. Throws `CancellationError` if the calling task is cancelled + /// before the signal fires. + func wait() async throws { + if armed { + armed = false + return } - - /// Mark the signal as fired. Wakes every waiter; arms the flag for - /// the next `wait()` call if no waiters are currently suspended. - func fire() { - var anyResumed = false - for waiter in waiters { - waiter.continuation.resume() - anyResumed = true - } - waiters.removeAll() - if !anyResumed { - armed = true + let waiterID = nextWaiterID + nextWaiterID &+= 1 + try await withTaskCancellationHandler { + let _: Void = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + return } + waiters.append(Waiter(id: waiterID, continuation: continuation)) + } + } onCancel: { + Task { [weak self] in + await self?.handleCancellation(id: waiterID) + } } + } - /// Drop any armed-pending state. Call sites: only after a successful - /// send, not before each attempt. The consume-on-wait semantic in - /// `wait()` already handles "fire landed during the previous attempt" - /// cleanly. - func clear() { - armed = false + /// Mark the signal as fired. Wakes every waiter; arms the flag for + /// the next `wait()` call if no waiters are currently suspended. + func fire() { + var anyResumed = false + for waiter in waiters { + waiter.continuation.resume() + anyResumed = true } - - private func handleCancellation(id: UInt64) { - guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } - let waiter = waiters.remove(at: index) - waiter.continuation.resume(throwing: CancellationError()) + waiters.removeAll() + if !anyResumed { + armed = true } + } + + /// Drop any armed-pending state. Call sites: only after a successful + /// send, not before each attempt. The consume-on-wait semantic in + /// `wait()` already handles "fire landed during the previous attempt" + /// cleanly. + func clear() { + armed = false + } + + private func handleCancellation(id: UInt64) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } + let waiter = waiters.remove(at: index) + waiter.continuation.resume(throwing: CancellationError()) + } } diff --git a/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift b/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift index 1f3b4d5e..3195a1af 100644 --- a/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift +++ b/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift @@ -3,241 +3,240 @@ import Foundation /// Snapshot of user preferences stored in UserDefaults, used for backup/restore. /// Each property is optional — `nil` means the key was not set at export time. public struct BackupUserDefaults: Codable, Sendable, Equatable { - - // MARK: - App preferences - - public var hasCompletedOnboarding: Bool? - public var liveActivityEnabled: Bool? - public var mapStyleSelection: String? - public var selectedThemeID: String? - public var appColorSchemePreference: String? - public var mapShowLabels: Bool? - public var replyWithQuote: Bool? - public var showInlineImages: Bool? - public var autoPlayGIFs: Bool? - public var showIncomingPath: Bool? - public var showIncomingHopCount: Bool? - public var showIncomingRegion: Bool? - public var showIncomingSendTime: Bool? - public var autoDeleteStaleNodesDays: Int? - public var discoverySortOrder: String? - public var nodesSortOrder: String? - public var tracePathViewMode: String? - public var linkPreviewsEnabled: Bool? - public var linkPreviewsAutoResolveDM: Bool? - public var linkPreviewsAutoResolveChannels: Bool? - public var showMapPreviewThumbnails: Bool? - public var frequentEmojis: [String]? - public var recentEmojis: [String]? - public var hasSeenRepeaterDragHint: Bool? - public var regionSelection: RegionSelection? - - // MARK: - Notification preferences - - public var notifyContactMessages: Bool? - public var notifyChannelMessages: Bool? - public var notifyRoomMessages: Bool? - public var notifyNewContacts: Bool? - public var notifyNewContactsContact: Bool? - public var notifyNewContactsRepeater: Bool? - public var notifyNewContactsRoom: Bool? - public var notifyReactions: Bool? - public var notificationSoundEnabled: Bool? - public var notificationBadgeEnabled: Bool? - public var notifyLowBattery: Bool? - - public init() {} - - // MARK: - UserDefaults keys for special-cased (non-Bool/String) properties - - private static let autoDeleteStaleNodesDaysKey = AppStorageKey.autoDeleteStaleNodesDays.rawValue - private static let frequentEmojisKey = AppStorageKey.frequentEmojis.rawValue - private static let recentReactionEmojisKey = AppStorageKey.recentReactionEmojis.rawValue - /// Public so `AppState` (and tests) can persist via the same key without a duplicated literal. - public static let regionSelectionKey = "userPrefs.region" - - /// Property names handled by hand-rolled branches in `snapshot`/`restore` - /// rather than the bool/string mappings. Internal solely for testability — - /// do not consume from non-test code. - internal static let specialCasedPropertyNames: Set = [ - "autoDeleteStaleNodesDays", - "frequentEmojis", - "recentEmojis", - "regionSelection" - ] - - // MARK: - Region selection persistence - - /// Single source of truth for the encoder/decoder used by `regionSelection`. - /// Both `BackupUserDefaults.snapshot`/`restore` and `AppState`'s live persistence - /// path go through these helpers, so the on-disk format cannot drift. - public static func loadRegionSelection(from defaults: UserDefaults = .standard) -> RegionSelection? { - guard let data = defaults.data(forKey: regionSelectionKey) else { return nil } - return try? JSONDecoder().decode(RegionSelection.self, from: data) + // MARK: - App preferences + + public var hasCompletedOnboarding: Bool? + public var liveActivityEnabled: Bool? + public var mapStyleSelection: String? + public var selectedThemeID: String? + public var appColorSchemePreference: String? + public var mapShowLabels: Bool? + public var replyWithQuote: Bool? + public var showInlineImages: Bool? + public var autoPlayGIFs: Bool? + public var showIncomingPath: Bool? + public var showIncomingHopCount: Bool? + public var showIncomingRegion: Bool? + public var showIncomingSendTime: Bool? + public var autoDeleteStaleNodesDays: Int? + public var discoverySortOrder: String? + public var nodesSortOrder: String? + public var tracePathViewMode: String? + public var linkPreviewsEnabled: Bool? + public var linkPreviewsAutoResolveDM: Bool? + public var linkPreviewsAutoResolveChannels: Bool? + public var showMapPreviewThumbnails: Bool? + public var frequentEmojis: [String]? + public var recentEmojis: [String]? + public var hasSeenRepeaterDragHint: Bool? + public var regionSelection: RegionSelection? + + // MARK: - Notification preferences + + public var notifyContactMessages: Bool? + public var notifyChannelMessages: Bool? + public var notifyRoomMessages: Bool? + public var notifyNewContacts: Bool? + public var notifyNewContactsContact: Bool? + public var notifyNewContactsRepeater: Bool? + public var notifyNewContactsRoom: Bool? + public var notifyReactions: Bool? + public var notificationSoundEnabled: Bool? + public var notificationBadgeEnabled: Bool? + public var notifyLowBattery: Bool? + + public init() {} + + // MARK: - UserDefaults keys for special-cased (non-Bool/String) properties + + private static let autoDeleteStaleNodesDaysKey = AppStorageKey.autoDeleteStaleNodesDays.rawValue + private static let frequentEmojisKey = AppStorageKey.frequentEmojis.rawValue + private static let recentReactionEmojisKey = AppStorageKey.recentReactionEmojis.rawValue + /// Public so `AppState` (and tests) can persist via the same key without a duplicated literal. + public static let regionSelectionKey = "userPrefs.region" + + /// Property names handled by hand-rolled branches in `snapshot`/`restore` + /// rather than the bool/string mappings. Internal solely for testability — + /// do not consume from non-test code. + static let specialCasedPropertyNames: Set = [ + "autoDeleteStaleNodesDays", + "frequentEmojis", + "recentEmojis", + "regionSelection" + ] + + // MARK: - Region selection persistence + + /// Single source of truth for the encoder/decoder used by `regionSelection`. + /// Both `BackupUserDefaults.snapshot`/`restore` and `AppState`'s live persistence + /// path go through these helpers, so the on-disk format cannot drift. + public static func loadRegionSelection(from defaults: UserDefaults = .standard) -> RegionSelection? { + guard let data = defaults.data(forKey: regionSelectionKey) else { return nil } + return try? JSONDecoder().decode(RegionSelection.self, from: data) + } + + public static func persistRegionSelection( + _ region: RegionSelection?, + to defaults: UserDefaults = .standard + ) { + if let region, let data = try? JSONEncoder().encode(region) { + defaults.set(data, forKey: regionSelectionKey) + } else { + defaults.removeObject(forKey: regionSelectionKey) + } + } + + // MARK: - UserDefaults key mapping + + /// Mapping from struct keyPaths to their UserDefaults key strings. + /// `frequentEmojis` is stored as encoded `Data` in the app (via @AppStorage), + /// but we export/import the decoded `[String]` array directly. + /// + /// Marked `nonisolated(unsafe)` because `WritableKeyPath` is not `Sendable`. + /// Safe here: the array is a `let` initialised once at module load and only + /// read afterwards (never mutated); no cross-actor write race can occur. + private nonisolated(unsafe) static let boolMappings: [(WritableKeyPath, String)] = [ + (\.hasCompletedOnboarding, AppStorageKey.hasCompletedOnboarding.rawValue), + (\.liveActivityEnabled, AppStorageKey.liveActivityEnabled.rawValue), + (\.mapShowLabels, AppStorageKey.mapShowLabels.rawValue), + (\.replyWithQuote, AppStorageKey.replyWithQuote.rawValue), + (\.showInlineImages, AppStorageKey.showInlineImages.rawValue), + (\.autoPlayGIFs, AppStorageKey.autoPlayGIFs.rawValue), + (\.showIncomingPath, AppStorageKey.showIncomingPath.rawValue), + (\.showIncomingHopCount, AppStorageKey.showIncomingHopCount.rawValue), + (\.showIncomingRegion, AppStorageKey.showIncomingRegion.rawValue), + (\.showIncomingSendTime, AppStorageKey.showIncomingSendTime.rawValue), + (\.linkPreviewsEnabled, AppStorageKey.linkPreviewsEnabled.rawValue), + (\.linkPreviewsAutoResolveDM, AppStorageKey.linkPreviewsAutoResolveDM.rawValue), + (\.linkPreviewsAutoResolveChannels, AppStorageKey.linkPreviewsAutoResolveChannels.rawValue), + (\.showMapPreviewThumbnails, AppStorageKey.showMapPreviewThumbnails.rawValue), + (\.hasSeenRepeaterDragHint, AppStorageKey.hasSeenRepeaterDragHint.rawValue), + (\.notifyContactMessages, AppStorageKey.notifyContactMessages.rawValue), + (\.notifyChannelMessages, AppStorageKey.notifyChannelMessages.rawValue), + (\.notifyRoomMessages, AppStorageKey.notifyRoomMessages.rawValue), + (\.notifyNewContacts, AppStorageKey.notifyNewContacts.rawValue), + (\.notifyNewContactsContact, AppStorageKey.notifyNewContactsContact.rawValue), + (\.notifyNewContactsRepeater, AppStorageKey.notifyNewContactsRepeater.rawValue), + (\.notifyNewContactsRoom, AppStorageKey.notifyNewContactsRoom.rawValue), + (\.notifyReactions, AppStorageKey.notifyReactions.rawValue), + (\.notificationSoundEnabled, AppStorageKey.notificationSoundEnabled.rawValue), + (\.notificationBadgeEnabled, AppStorageKey.notificationBadgeEnabled.rawValue), + (\.notifyLowBattery, AppStorageKey.notifyLowBattery.rawValue), + ] + + /// Key strings used by `boolMappings`. Internal solely for testability — + /// do not consume from non-test code. + static var boolMappingKeys: Set { + Set(boolMappings.map(\.1)) + } + + /// See `boolMappings` for the `nonisolated(unsafe)` rationale. + private nonisolated(unsafe) static let stringMappings: [(WritableKeyPath, String)] = [ + (\.mapStyleSelection, AppStorageKey.mapStyleSelection.rawValue), + (\.discoverySortOrder, AppStorageKey.discoverySortOrder.rawValue), + (\.nodesSortOrder, AppStorageKey.nodesSortOrder.rawValue), + (\.tracePathViewMode, AppStorageKey.tracePathViewMode.rawValue), + (\.selectedThemeID, PersistenceKeys.selectedThemeID), + (\.appColorSchemePreference, PersistenceKeys.appColorSchemePreference), + ] + + /// Key strings used by `stringMappings`. Internal solely for testability — + /// do not consume from non-test code. + static var stringMappingKeys: Set { + Set(stringMappings.map(\.1)) + } + + // MARK: - Read from UserDefaults + + /// Creates a snapshot by reading all known keys from UserDefaults. + /// - Parameter defaults: The UserDefaults instance to read from. + public static func snapshot(from defaults: UserDefaults = .standard) -> BackupUserDefaults { + var result = BackupUserDefaults() + + for (keyPath, key) in boolMappings { + if defaults.object(forKey: key) != nil { + result[keyPath: keyPath] = defaults.bool(forKey: key) + } } - public static func persistRegionSelection( - _ region: RegionSelection?, - to defaults: UserDefaults = .standard - ) { - if let region, let data = try? JSONEncoder().encode(region) { - defaults.set(data, forKey: regionSelectionKey) - } else { - defaults.removeObject(forKey: regionSelectionKey) - } + for (keyPath, key) in stringMappings { + if defaults.object(forKey: key) != nil { + result[keyPath: keyPath] = defaults.string(forKey: key) + } } - // MARK: - UserDefaults key mapping - - /// Mapping from struct keyPaths to their UserDefaults key strings. - /// `frequentEmojis` is stored as encoded `Data` in the app (via @AppStorage), - /// but we export/import the decoded `[String]` array directly. - /// - /// Marked `nonisolated(unsafe)` because `WritableKeyPath` is not `Sendable`. - /// Safe here: the array is a `let` initialised once at module load and only - /// read afterwards (never mutated); no cross-actor write race can occur. - nonisolated(unsafe) private static let boolMappings: [(WritableKeyPath, String)] = [ - (\.hasCompletedOnboarding, AppStorageKey.hasCompletedOnboarding.rawValue), - (\.liveActivityEnabled, AppStorageKey.liveActivityEnabled.rawValue), - (\.mapShowLabels, AppStorageKey.mapShowLabels.rawValue), - (\.replyWithQuote, AppStorageKey.replyWithQuote.rawValue), - (\.showInlineImages, AppStorageKey.showInlineImages.rawValue), - (\.autoPlayGIFs, AppStorageKey.autoPlayGIFs.rawValue), - (\.showIncomingPath, AppStorageKey.showIncomingPath.rawValue), - (\.showIncomingHopCount, AppStorageKey.showIncomingHopCount.rawValue), - (\.showIncomingRegion, AppStorageKey.showIncomingRegion.rawValue), - (\.showIncomingSendTime, AppStorageKey.showIncomingSendTime.rawValue), - (\.linkPreviewsEnabled, AppStorageKey.linkPreviewsEnabled.rawValue), - (\.linkPreviewsAutoResolveDM, AppStorageKey.linkPreviewsAutoResolveDM.rawValue), - (\.linkPreviewsAutoResolveChannels, AppStorageKey.linkPreviewsAutoResolveChannels.rawValue), - (\.showMapPreviewThumbnails, AppStorageKey.showMapPreviewThumbnails.rawValue), - (\.hasSeenRepeaterDragHint, AppStorageKey.hasSeenRepeaterDragHint.rawValue), - (\.notifyContactMessages, AppStorageKey.notifyContactMessages.rawValue), - (\.notifyChannelMessages, AppStorageKey.notifyChannelMessages.rawValue), - (\.notifyRoomMessages, AppStorageKey.notifyRoomMessages.rawValue), - (\.notifyNewContacts, AppStorageKey.notifyNewContacts.rawValue), - (\.notifyNewContactsContact, AppStorageKey.notifyNewContactsContact.rawValue), - (\.notifyNewContactsRepeater, AppStorageKey.notifyNewContactsRepeater.rawValue), - (\.notifyNewContactsRoom, AppStorageKey.notifyNewContactsRoom.rawValue), - (\.notifyReactions, AppStorageKey.notifyReactions.rawValue), - (\.notificationSoundEnabled, AppStorageKey.notificationSoundEnabled.rawValue), - (\.notificationBadgeEnabled, AppStorageKey.notificationBadgeEnabled.rawValue), - (\.notifyLowBattery, AppStorageKey.notifyLowBattery.rawValue), - ] - - /// Key strings used by `boolMappings`. Internal solely for testability — - /// do not consume from non-test code. - internal static var boolMappingKeys: Set { - Set(boolMappings.map { $0.1 }) + if defaults.object(forKey: Self.autoDeleteStaleNodesDaysKey) != nil { + result.autoDeleteStaleNodesDays = defaults.integer(forKey: Self.autoDeleteStaleNodesDaysKey) } - /// See `boolMappings` for the `nonisolated(unsafe)` rationale. - nonisolated(unsafe) private static let stringMappings: [(WritableKeyPath, String)] = [ - (\.mapStyleSelection, AppStorageKey.mapStyleSelection.rawValue), - (\.discoverySortOrder, AppStorageKey.discoverySortOrder.rawValue), - (\.nodesSortOrder, AppStorageKey.nodesSortOrder.rawValue), - (\.tracePathViewMode, AppStorageKey.tracePathViewMode.rawValue), - (\.selectedThemeID, PersistenceKeys.selectedThemeID), - (\.appColorSchemePreference, PersistenceKeys.appColorSchemePreference), - ] - - /// Key strings used by `stringMappings`. Internal solely for testability — - /// do not consume from non-test code. - internal static var stringMappingKeys: Set { - Set(stringMappings.map { $0.1 }) + // frequentEmojis is stored as JSON-encoded [String] via @AppStorage Data binding + if let data = defaults.data(forKey: Self.frequentEmojisKey), + let decoded = try? JSONDecoder().decode([String].self, from: data) { + result.frequentEmojis = decoded } - // MARK: - Read from UserDefaults + result.recentEmojis = defaults.stringArray(forKey: Self.recentReactionEmojisKey) - /// Creates a snapshot by reading all known keys from UserDefaults. - /// - Parameter defaults: The UserDefaults instance to read from. - public static func snapshot(from defaults: UserDefaults = .standard) -> BackupUserDefaults { - var result = BackupUserDefaults() + result.regionSelection = Self.loadRegionSelection(from: defaults) - for (keyPath, key) in boolMappings { - if defaults.object(forKey: key) != nil { - result[keyPath: keyPath] = defaults.bool(forKey: key) - } - } + return result + } - for (keyPath, key) in stringMappings { - if defaults.object(forKey: key) != nil { - result[keyPath: keyPath] = defaults.string(forKey: key) - } - } + // MARK: - Write to UserDefaults (write-if-missing) - if defaults.object(forKey: Self.autoDeleteStaleNodesDaysKey) != nil { - result.autoDeleteStaleNodesDays = defaults.integer(forKey: Self.autoDeleteStaleNodesDaysKey) - } + /// Restores preferences to UserDefaults, only writing keys that are not already set. + /// - Parameter defaults: The UserDefaults instance to write to. + /// - Returns: Keys that were newly set, in insertion order. Callers can undo a + /// partial restore by passing this list to `removeKeys(_:from:)`. + @discardableResult + public func restore(to defaults: UserDefaults = .standard) -> [String] { + var setKeys: [String] = [] + + for (keyPath, key) in Self.boolMappings { + if let value = self[keyPath: keyPath], defaults.object(forKey: key) == nil { + defaults.set(value, forKey: key) + setKeys.append(key) + } + } - // frequentEmojis is stored as JSON-encoded [String] via @AppStorage Data binding - if let data = defaults.data(forKey: Self.frequentEmojisKey), - let decoded = try? JSONDecoder().decode([String].self, from: data) { - result.frequentEmojis = decoded - } + for (keyPath, key) in Self.stringMappings { + if let value = self[keyPath: keyPath], defaults.object(forKey: key) == nil { + defaults.set(value, forKey: key) + setKeys.append(key) + } + } - result.recentEmojis = defaults.stringArray(forKey: Self.recentReactionEmojisKey) + if let value = autoDeleteStaleNodesDays, + defaults.object(forKey: Self.autoDeleteStaleNodesDaysKey) == nil { + defaults.set(value, forKey: Self.autoDeleteStaleNodesDaysKey) + setKeys.append(Self.autoDeleteStaleNodesDaysKey) + } - result.regionSelection = Self.loadRegionSelection(from: defaults) + if let emojis = frequentEmojis, defaults.object(forKey: Self.frequentEmojisKey) == nil { + if let data = try? JSONEncoder().encode(emojis) { + defaults.set(data, forKey: Self.frequentEmojisKey) + setKeys.append(Self.frequentEmojisKey) + } + } - return result + if let emojis = recentEmojis, defaults.object(forKey: Self.recentReactionEmojisKey) == nil { + defaults.set(emojis, forKey: Self.recentReactionEmojisKey) + setKeys.append(Self.recentReactionEmojisKey) } - // MARK: - Write to UserDefaults (write-if-missing) - - /// Restores preferences to UserDefaults, only writing keys that are not already set. - /// - Parameter defaults: The UserDefaults instance to write to. - /// - Returns: Keys that were newly set, in insertion order. Callers can undo a - /// partial restore by passing this list to `removeKeys(_:from:)`. - @discardableResult - public func restore(to defaults: UserDefaults = .standard) -> [String] { - var setKeys: [String] = [] - - for (keyPath, key) in Self.boolMappings { - if let value = self[keyPath: keyPath], defaults.object(forKey: key) == nil { - defaults.set(value, forKey: key) - setKeys.append(key) - } - } - - for (keyPath, key) in Self.stringMappings { - if let value = self[keyPath: keyPath], defaults.object(forKey: key) == nil { - defaults.set(value, forKey: key) - setKeys.append(key) - } - } - - if let value = autoDeleteStaleNodesDays, - defaults.object(forKey: Self.autoDeleteStaleNodesDaysKey) == nil { - defaults.set(value, forKey: Self.autoDeleteStaleNodesDaysKey) - setKeys.append(Self.autoDeleteStaleNodesDaysKey) - } - - if let emojis = frequentEmojis, defaults.object(forKey: Self.frequentEmojisKey) == nil { - if let data = try? JSONEncoder().encode(emojis) { - defaults.set(data, forKey: Self.frequentEmojisKey) - setKeys.append(Self.frequentEmojisKey) - } - } - - if let emojis = recentEmojis, defaults.object(forKey: Self.recentReactionEmojisKey) == nil { - defaults.set(emojis, forKey: Self.recentReactionEmojisKey) - setKeys.append(Self.recentReactionEmojisKey) - } - - if let region = regionSelection, - defaults.object(forKey: Self.regionSelectionKey) == nil { - Self.persistRegionSelection(region, to: defaults) - setKeys.append(Self.regionSelectionKey) - } - - return setKeys + if let region = regionSelection, + defaults.object(forKey: Self.regionSelectionKey) == nil { + Self.persistRegionSelection(region, to: defaults) + setKeys.append(Self.regionSelectionKey) } - /// Undoes a `restore(to:)` partial write by removing the specified keys. - public static func removeKeys(_ keys: [String], from defaults: UserDefaults) { - for key in keys { - defaults.removeObject(forKey: key) - } + return setKeys + } + + /// Undoes a `restore(to:)` partial write by removing the specified keys. + public static func removeKeys(_ keys: [String], from defaults: UserDefaults) { + for key in keys { + defaults.removeObject(forKey: key) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift b/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift index b96b516f..4bd562eb 100644 --- a/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift +++ b/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift @@ -5,23 +5,23 @@ import os // MARK: - Binary Protocol Errors public enum BinaryProtocolError: Error, Sendable { - case notConnected - case sendFailed - case timeout - case invalidResponse - case sessionError(MeshCoreError) + case notConnected + case sendFailed + case timeout + case invalidResponse + case sessionError(MeshCoreError) } extension BinaryProtocolError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .sendFailed: "Failed to send request." - case .timeout: "Request timed out." - case .invalidResponse: "Invalid response from device." - case .sessionError(let e): e.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .sendFailed: "Failed to send request." + case .timeout: "Request timed out." + case .invalidResponse: "Invalid response from device." + case let .sessionError(e): e.localizedDescription } + } } // MARK: - Binary Protocol Service @@ -29,268 +29,267 @@ extension BinaryProtocolError: LocalizedError { /// Service for binary protocol operations with remote mesh nodes. /// Handles status, telemetry, neighbours, and ACL requests via MeshCore session. public actor BinaryProtocolService { + // MARK: - Properties - // MARK: - Properties + private let session: any DiagnosticsSessionOps & SessionEventStreaming + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "BinaryProtocol") - private let session: any DiagnosticsSessionOps & SessionEventStreaming - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "BinaryProtocol") + /// Handler for status responses (from push notifications) + private var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? - /// Handler for status responses (from push notifications) - private var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? + /// Handler for telemetry responses (from push notifications) + private var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? - /// Handler for telemetry responses (from push notifications) - private var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? + /// Handler for neighbours responses (from push notifications) + private var neighboursResponseHandler: (@Sendable (NeighboursResponse) async -> Void)? - /// Handler for neighbours responses (from push notifications) - private var neighboursResponseHandler: (@Sendable (NeighboursResponse) async -> Void)? + /// Event monitoring task + private var eventMonitorTask: Task? - /// Event monitoring task - private var eventMonitorTask: Task? + // MARK: - Initialization - // MARK: - Initialization + public init(session: any DiagnosticsSessionOps & SessionEventStreaming, dataStore: any PersistenceStoreProtocol) { + self.session = session + self.dataStore = dataStore + } - public init(session: any DiagnosticsSessionOps & SessionEventStreaming, dataStore: any PersistenceStoreProtocol) { - self.session = session - self.dataStore = dataStore - } - - deinit { - eventMonitorTask?.cancel() - } + deinit { + eventMonitorTask?.cancel() + } - // MARK: - Event Handlers + // MARK: - Event Handlers - /// Set handler for status responses - public func setStatusResponseHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { - statusResponseHandler = handler - } + /// Set handler for status responses + public func setStatusResponseHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { + statusResponseHandler = handler + } - /// Set handler for telemetry responses - public func setTelemetryResponseHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { - telemetryResponseHandler = handler - } + /// Set handler for telemetry responses + public func setTelemetryResponseHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { + telemetryResponseHandler = handler + } - /// Set handler for neighbours responses - public func setNeighboursResponseHandler(_ handler: @escaping @Sendable (NeighboursResponse) async -> Void) { - neighboursResponseHandler = handler - } + /// Set handler for neighbours responses + public func setNeighboursResponseHandler(_ handler: @escaping @Sendable (NeighboursResponse) async -> Void) { + neighboursResponseHandler = handler + } - // MARK: - Event Monitoring + // MARK: - Event Monitoring - /// Start monitoring MeshCore events for binary protocol responses - public func startEventMonitoring() { - eventMonitorTask?.cancel() + /// Start monitoring MeshCore events for binary protocol responses + public func startEventMonitoring() { + eventMonitorTask?.cancel() - eventMonitorTask = Task { [weak self] in - guard let self else { return } - let events = await session.events() + eventMonitorTask = Task { [weak self] in + guard let self else { return } + let events = await session.events() - for await event in events { - guard !Task.isCancelled else { break } - await self.handleEvent(event) - } - } + for await event in events { + guard !Task.isCancelled else { break } + await handleEvent(event) + } } + } - /// Stop monitoring events - public func stopEventMonitoring() { - eventMonitorTask?.cancel() - eventMonitorTask = nil - } + /// Stop monitoring events + public func stopEventMonitoring() { + eventMonitorTask?.cancel() + eventMonitorTask = nil + } - /// Handle incoming MeshCore event - private func handleEvent(_ event: MeshEvent) async { - switch event { - case .statusResponse(let response): - await statusResponseHandler?(response) + /// Handle incoming MeshCore event + private func handleEvent(_ event: MeshEvent) async { + switch event { + case let .statusResponse(response): + await statusResponseHandler?(response) - case .telemetryResponse(let response): - await telemetryResponseHandler?(response) + case let .telemetryResponse(response): + await telemetryResponseHandler?(response) - case .neighboursResponse(let response): - await neighboursResponseHandler?(response) + case let .neighboursResponse(response): + await neighboursResponseHandler?(response) - default: - break - } + default: + break } - - // MARK: - Status Request - - /// Request status from a remote node (blocking, waits for response) - /// - Parameter publicKey: The remote node's full 32-byte public key - /// - Returns: StatusResponse with device stats - public func requestStatus(from publicKey: Data) async throws -> StatusResponse { - do { - return try await session.requestStatus(from: publicKey) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - Status Request + + /// Request status from a remote node (blocking, waits for response) + /// - Parameter publicKey: The remote node's full 32-byte public key + /// - Returns: StatusResponse with device stats + public func requestStatus(from publicKey: Data) async throws -> StatusResponse { + do { + return try await session.requestStatus(from: publicKey) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - /// Request status from a remote node (blocking, waits for response) - /// - Parameters: - /// - publicKey: The remote node's full 32-byte public key - /// - type: The target node type used to select the correct firmware status layout - /// - Returns: StatusResponse with device stats - public func requestStatus( - from publicKey: Data, - type: ContactType - ) async throws -> StatusResponse { - do { - return try await session.requestStatus(from: publicKey, type: type) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + /// Request status from a remote node (blocking, waits for response) + /// - Parameters: + /// - publicKey: The remote node's full 32-byte public key + /// - type: The target node type used to select the correct firmware status layout + /// - Returns: StatusResponse with device stats + public func requestStatus( + from publicKey: Data, + type: ContactType + ) async throws -> StatusResponse { + do { + return try await session.requestStatus(from: publicKey, type: type) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - Telemetry Request - - /// Request telemetry from a remote node (blocking, waits for response) - /// - Parameter publicKey: The remote node's public key (full or prefix) - /// - Returns: TelemetryResponse with sensor data - public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { - do { - return try await session.requestTelemetry(from: publicKey) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - Telemetry Request + + /// Request telemetry from a remote node (blocking, waits for response) + /// - Parameter publicKey: The remote node's public key (full or prefix) + /// - Returns: TelemetryResponse with sensor data + public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { + do { + return try await session.requestTelemetry(from: publicKey) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - Neighbours Request - - /// Default pubkey prefix length for neighbour queries. - /// Stored to ensure response parsing uses matching length. - public static let defaultPubkeyPrefixLength: UInt8 = 6 - - /// Request neighbours list from a remote node (blocking, waits for response) - /// - Parameters: - /// - publicKey: The remote node's public key - /// - count: Maximum number of neighbours to return (default 255 = all) - /// - offset: Pagination offset - /// - orderBy: Sort order for results (0 = newest first) - /// - pubkeyPrefixLength: Length of public key prefix in response - /// - Returns: NeighboursResponse with neighbour list - public func requestNeighbours( - from publicKey: Data, - count: UInt8 = 255, - offset: UInt16 = 0, - orderBy: UInt8 = 0, - pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength - ) async throws -> NeighboursResponse { - do { - return try await session.requestNeighbours( - from: publicKey, - count: count, - offset: offset, - orderBy: orderBy, - pubkeyPrefixLength: pubkeyPrefixLength - ) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - Neighbours Request + + /// Default pubkey prefix length for neighbour queries. + /// Stored to ensure response parsing uses matching length. + public static let defaultPubkeyPrefixLength: UInt8 = 6 + + /// Request neighbours list from a remote node (blocking, waits for response) + /// - Parameters: + /// - publicKey: The remote node's public key + /// - count: Maximum number of neighbours to return (default 255 = all) + /// - offset: Pagination offset + /// - orderBy: Sort order for results (0 = newest first) + /// - pubkeyPrefixLength: Length of public key prefix in response + /// - Returns: NeighboursResponse with neighbour list + public func requestNeighbours( + from publicKey: Data, + count: UInt8 = 255, + offset: UInt16 = 0, + orderBy: UInt8 = 0, + pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength + ) async throws -> NeighboursResponse { + do { + return try await session.requestNeighbours( + from: publicKey, + count: count, + offset: offset, + orderBy: orderBy, + pubkeyPrefixLength: pubkeyPrefixLength + ) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - /// Fetch all neighbours from a remote node with automatic pagination - /// - Parameters: - /// - publicKey: The remote node's public key - /// - orderBy: Sort order for results - /// - pubkeyPrefixLength: Length of public key prefix in response - /// - Returns: NeighboursResponse with complete neighbour list - public func fetchAllNeighbours( - from publicKey: Data, - orderBy: UInt8 = 0, - pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength - ) async throws -> NeighboursResponse { - do { - return try await session.fetchAllNeighbours( - from: publicKey, - orderBy: orderBy, - pubkeyPrefixLength: pubkeyPrefixLength - ) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + /// Fetch all neighbours from a remote node with automatic pagination + /// - Parameters: + /// - publicKey: The remote node's public key + /// - orderBy: Sort order for results + /// - pubkeyPrefixLength: Length of public key prefix in response + /// - Returns: NeighboursResponse with complete neighbour list + public func fetchAllNeighbours( + from publicKey: Data, + orderBy: UInt8 = 0, + pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength + ) async throws -> NeighboursResponse { + do { + return try await session.fetchAllNeighbours( + from: publicKey, + orderBy: orderBy, + pubkeyPrefixLength: pubkeyPrefixLength + ) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - MMA Request - - /// Request min/max/average telemetry data from a remote node - /// - Parameters: - /// - publicKey: The remote node's public key - /// - start: Start of time range - /// - end: End of time range - /// - Returns: MMAResponse with aggregated telemetry - public func requestMMA( - from publicKey: Data, - start: Date, - end: Date - ) async throws -> MMAResponse { - do { - return try await session.requestMMA(from: publicKey, start: start, end: end) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - MMA Request + + /// Request min/max/average telemetry data from a remote node + /// - Parameters: + /// - publicKey: The remote node's public key + /// - start: Start of time range + /// - end: End of time range + /// - Returns: MMAResponse with aggregated telemetry + public func requestMMA( + from publicKey: Data, + start: Date, + end: Date + ) async throws -> MMAResponse { + do { + return try await session.requestMMA(from: publicKey, start: start, end: end) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - ACL Request - - /// Request access control list from a remote node - /// - Parameter publicKey: The remote node's public key - /// - Returns: ACLResponse with permission entries - public func requestACL(from publicKey: Data) async throws -> ACLResponse { - do { - return try await session.requestACL(from: publicKey) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - ACL Request + + /// Request access control list from a remote node + /// - Parameter publicKey: The remote node's public key + /// - Returns: ACLResponse with permission entries + public func requestACL(from publicKey: Data) async throws -> ACLResponse { + do { + return try await session.requestACL(from: publicKey) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } + } - // MARK: - Self Telemetry + // MARK: - Self Telemetry - /// Get telemetry from the local device - /// - Returns: TelemetryResponse with local sensor data - public func getSelfTelemetry() async throws -> TelemetryResponse { - do { - return try await session.getSelfTelemetry() - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + /// Get telemetry from the local device + /// - Returns: TelemetryResponse with local sensor data + public func getSelfTelemetry() async throws -> TelemetryResponse { + do { + return try await session.getSelfTelemetry() + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - Path Discovery - - /// Send path discovery request to a contact - /// - Parameter publicKey: The contact's public key - /// - Returns: MessageSentInfo with expected ACK code - public func sendPathDiscovery(to publicKey: Data) async throws -> MessageSentInfo { - do { - return try await session.sendPathDiscovery(to: publicKey) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - Path Discovery + + /// Send path discovery request to a contact + /// - Parameter publicKey: The contact's public key + /// - Returns: MessageSentInfo with expected ACK code + public func sendPathDiscovery(to publicKey: Data) async throws -> MessageSentInfo { + do { + return try await session.sendPathDiscovery(to: publicKey) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } - - // MARK: - Trace Route - - /// Send a trace route request - /// - Parameters: - /// - tag: Optional trace tag (random if nil) - /// - authCode: Optional auth code (random if nil) - /// - flags: Trace flags - /// - path: Optional fixed path to trace - /// - Returns: MessageSentInfo with expected ACK code - public func sendTrace( - tag: UInt32? = nil, - authCode: UInt32? = nil, - flags: UInt8 = 0, - path: Data? = nil - ) async throws -> MessageSentInfo { - do { - return try await session.sendTrace(tag: tag, authCode: authCode, flags: flags, path: path) - } catch let error as MeshCoreError { - throw BinaryProtocolError.sessionError(error) - } + } + + // MARK: - Trace Route + + /// Send a trace route request + /// - Parameters: + /// - tag: Optional trace tag (random if nil) + /// - authCode: Optional auth code (random if nil) + /// - flags: Trace flags + /// - path: Optional fixed path to trace + /// - Returns: MessageSentInfo with expected ACK code + public func sendTrace( + tag: UInt32? = nil, + authCode: UInt32? = nil, + flags: UInt8 = 0, + path: Data? = nil + ) async throws -> MessageSentInfo { + do { + return try await session.sendTrace(tag: tag, authCode: authCode, flags: flags, path: path) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/BluetoothScanPairingService.swift b/MC1Services/Sources/MC1Services/Services/BluetoothScanPairingService.swift index bab225eb..01aaaf2d 100644 --- a/MC1Services/Sources/MC1Services/Services/BluetoothScanPairingService.swift +++ b/MC1Services/Sources/MC1Services/Services/BluetoothScanPairingService.swift @@ -16,91 +16,108 @@ import os @Observable @MainActor public final class BluetoothScanPairingService: DevicePairingService { - public weak var delegate: (any DevicePairingDelegate)? - - /// `true` while the in-app scan picker should be presented. Observed by the view layer. - public private(set) var isPresenting = false - - private var discoveryContinuation: CheckedContinuation? - - /// Set synchronously by the `onCancel` handler the instant the discovery task is cancelled, - /// before its `Task { @MainActor ... }` hop to `cancel()` is scheduled, and read on the - /// resolution path so a `select(_:)` that lands on the MainActor inside that hop window - /// resolves the continuation with `.cancelled` rather than a stale selection. Lives behind a - /// lock because `onCancel` may run off the MainActor. - private let cancellationRequested = OSAllocatedUnfairLock(initialState: false) - - public init() {} - - public var isSessionActive: Bool { false } - public var registeredDeviceCount: Int { 0 } - public var hasSystemPairingRegistry: Bool { false } - public var supportsSystemRename: Bool { false } - - public func activate() async throws {} - - /// Single-flight invariant: the only production caller is `ConnectionManager.pairNewDevice()`, - /// which is serialized by its `isPairingInProgress` gate, so two discoveries never overlap at - /// runtime — the prior continuation has always resolved before the next call begins. The - /// stranded-discovery resolution below, the `onCancel` handler, and the `cancellationRequested` - /// flag are defensive: they keep a single continuation consistent against cancellation, not - /// against concurrent callers. If a second concurrent caller of this method is ever added, add a - /// per-discovery token (mirroring `ConnectionManager.bleScanRequestID`) so a stale cancel cannot - /// resolve a newer continuation. - public func discoverDevice() async throws -> UUID { - // Clear any prior cancellation and resolve any stranded prior discovery before starting one. - cancellationRequested.withLock { $0 = false } - resolveDiscovery(with: .failure(DevicePairingError.cancelled)) - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - // If the task was already cancelled before the continuation is installed, - // `onCancel` has already run and found nothing to resolve. Resume here rather - // than installing a continuation that nothing will ever resume (which would - // leak it and strand `isPresenting`). - guard !Task.isCancelled else { - continuation.resume(throwing: DevicePairingError.cancelled) - return - } - self.discoveryContinuation = continuation - self.isPresenting = true - } - } onCancel: { - // Set synchronously so a `select(_:)` racing this cancellation on the MainActor can't - // resolve with a stale selection before the hop below runs `cancel()`. - self.cancellationRequested.withLock { $0 = true } - Task { @MainActor in self.cancel() } + public weak var delegate: (any DevicePairingDelegate)? + + /// `true` while the in-app scan picker should be presented. Observed by the view layer. + public private(set) var isPresenting = false + + private var discoveryContinuation: CheckedContinuation? + + /// Set synchronously by the `onCancel` handler the instant the discovery task is cancelled, + /// before its `Task { @MainActor ... }` hop to `cancel()` is scheduled, and read on the + /// resolution path so a `select(_:)` that lands on the MainActor inside that hop window + /// resolves the continuation with `.cancelled` rather than a stale selection. Lives behind a + /// lock because `onCancel` may run off the MainActor. + private let cancellationRequested = OSAllocatedUnfairLock(initialState: false) + + public init() {} + + public var isSessionActive: Bool { + false + } + + public var registeredDeviceCount: Int { + 0 + } + + public var hasSystemPairingRegistry: Bool { + false + } + + public var supportsSystemRename: Bool { + false + } + + public func activate() async throws {} + + /// Single-flight invariant: the only production caller is `ConnectionManager.pairNewDevice()`, + /// which is serialized by its `isPairingInProgress` gate, so two discoveries never overlap at + /// runtime — the prior continuation has always resolved before the next call begins. The + /// stranded-discovery resolution below, the `onCancel` handler, and the `cancellationRequested` + /// flag are defensive: they keep a single continuation consistent against cancellation, not + /// against concurrent callers. If a second concurrent caller of this method is ever added, add a + /// per-discovery token (mirroring `ConnectionManager.bleScanRequestID`) so a stale cancel cannot + /// resolve a newer continuation. + public func discoverDevice() async throws -> UUID { + // Clear any prior cancellation and resolve any stranded prior discovery before starting one. + cancellationRequested.withLock { $0 = false } + resolveDiscovery(with: .failure(DevicePairingError.cancelled)) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + // If the task was already cancelled before the continuation is installed, + // `onCancel` has already run and found nothing to resolve. Resume here rather + // than installing a continuation that nothing will ever resume (which would + // leak it and strand `isPresenting`). + guard !Task.isCancelled else { + continuation.resume(throwing: DevicePairingError.cancelled) + return } + self.discoveryContinuation = continuation + self.isPresenting = true + } + } onCancel: { + // Set synchronously so a `select(_:)` racing this cancellation on the MainActor can't + // resolve with a stale selection before the hop below runs `cancel()`. + self.cancellationRequested.withLock { $0 = true } + Task { @MainActor in self.cancel() } } + } - public func isDeviceConnectable(_ id: UUID) -> Bool { true } - public func registeredDeviceInfos() -> [(id: UUID, name: String)] { [] } - public func removeDevice(_ id: UUID) async throws {} - public func renameDevice(_ id: UUID) async throws {} - public func clearStaleRegistrations() async {} + public func isDeviceConnectable(_ id: UUID) -> Bool { + true + } - /// Called by the scan picker when the user selects a device. - public func select(_ id: UUID) { - resolveDiscovery(with: .success(id)) - } + public func registeredDeviceInfos() -> [(id: UUID, name: String)] { + [] + } - /// Called when the user cancels or dismisses the scan picker. - /// Surfaces as `DevicePairingError.cancelled` so call sites reuse one cancellation path - /// across both platforms. - public func cancel() { - resolveDiscovery(with: .failure(DevicePairingError.cancelled)) - } + public func removeDevice(_ id: UUID) async throws {} + public func renameDevice(_ id: UUID) async throws {} + public func clearStaleRegistrations() async {} - private func resolveDiscovery(with result: Result) { - isPresenting = false - guard let continuation = discoveryContinuation else { return } - discoveryContinuation = nil - // A selection that lands after cancellation has been signalled must not win the race: - // surface the cancellation instead of the stale selection. - if case .success = result, cancellationRequested.withLock({ $0 }) { - continuation.resume(throwing: DevicePairingError.cancelled) - return - } - continuation.resume(with: result) + /// Called by the scan picker when the user selects a device. + public func select(_ id: UUID) { + resolveDiscovery(with: .success(id)) + } + + /// Called when the user cancels or dismisses the scan picker. + /// Surfaces as `DevicePairingError.cancelled` so call sites reuse one cancellation path + /// across both platforms. + public func cancel() { + resolveDiscovery(with: .failure(DevicePairingError.cancelled)) + } + + private func resolveDiscovery(with result: Result) { + isPresenting = false + guard let continuation = discoveryContinuation else { return } + discoveryContinuation = nil + // A selection that lands after cancellation has been signalled must not win the race: + // surface the cancellation instead of the stale selection. + if case .success = result, cancellationRequested.withLock({ $0 }) { + continuation.resume(throwing: DevicePairingError.cancelled) + return } + continuation.resume(with: result) + } } diff --git a/MC1Services/Sources/MC1Services/Services/CLIResponse.swift b/MC1Services/Sources/MC1Services/Services/CLIResponse.swift index 644caa1b..12d42d01 100644 --- a/MC1Services/Sources/MC1Services/Services/CLIResponse.swift +++ b/MC1Services/Sources/MC1Services/Services/CLIResponse.swift @@ -2,128 +2,128 @@ import Foundation /// Parsed CLI response from repeater public enum CLIResponse: Sendable, Equatable { - case ok - case error(String) - case unknownCommand(String) // Specific case for "Error: unknown command" - case version(String) - case deviceTime(String) - case name(String) - case radio(frequency: Double, bandwidth: Double, spreadingFactor: Int, codingRate: Int) - case txPower(Int) - case repeatMode(Bool) - case advertInterval(Int) - case floodAdvertInterval(Int) // Value is in hours, not minutes - case floodMax(Int) - case latitude(Double) - case longitude(Double) - case ownerInfo(String) - case raw(String) - - /// Parse a CLI response text into a structured type - /// Note: Response correlation must be handled by the caller based on pending query tracking - public static func parse(_ text: String, forQuery query: String? = nil) -> CLIResponse { - var trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - - // Strip MeshCore CLI prompt prefix if present - // Firmware prepends "> " to all CLI command responses - if trimmed.hasPrefix("> ") { - trimmed = String(trimmed.dropFirst(2)) - } else if trimmed == ">" { - trimmed = "" - } - - // Success responses: "OK" or "OK - clock set: ..." etc. - if trimmed == "OK" || trimmed.hasPrefix("OK - ") { - return .ok - } - - if trimmed.lowercased().hasPrefix("error") || trimmed.hasPrefix("ERR:") { - // Check for "unknown command" specifically for defensive handling - if trimmed.lowercased().contains("unknown command") { - return .unknownCommand(trimmed) - } - return .error(trimmed) - } - - // Firmware version: "MeshCore v1.10.0 (2025-04-18)" or "v1.11.0 (2025-04-18)" - // Some firmware builds omit "MeshCore " prefix - if trimmed.hasPrefix("MeshCore v") || (trimmed.hasPrefix("v") && trimmed.contains("(")) { - return .version(trimmed) - } - - // Use query hint to match version responses that don't have standard prefix - if query == "ver" { - return .version(trimmed) - } - - // Freeform text fields: query hint takes priority over broad content heuristics - // below (e.g. deviceTime matches any string with ":" and "/", which is common - // in repeater names and contact info like "Contact: KD7ABC / 145.230") - if query == "get name" { - return .name(trimmed) - } - - if query == "get owner.info" { - return .ownerInfo(trimmed) - } - - // Clock response: "06:40 - 18/4/2025 UTC" or contains time-like patterns - if trimmed.contains("UTC") || (trimmed.contains(":") && trimmed.contains("/")) { - return .deviceTime(trimmed) - } - - // Radio params: "915.000,250.0,10,5" (freq,bw,sf,cr) - // Use query hint to disambiguate from other comma-separated values - if query == "get radio" { - let parts = trimmed.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - if parts.count >= 4, - let freq = Double(parts[0]), - let bw = Double(parts[1]), - let sf = Int(parts[2]), - let cr = Int(parts[3]) { - return .radio(frequency: freq, bandwidth: bw, spreadingFactor: sf, codingRate: cr) - } - } - - // TX power: integer dBm value - if query == "get tx", let power = Int(trimmed) { - return .txPower(power) - } - - // Repeat mode: "on" or "off" - if query == "get repeat" { - if trimmed.lowercased() == "on" { - return .repeatMode(true) - } else if trimmed.lowercased() == "off" { - return .repeatMode(false) - } - } - - // Advert interval: integer minutes - if query == "get advert.interval", let interval = Int(trimmed) { - return .advertInterval(interval) - } - - // Flood advert interval: integer hours - if query == "get flood.advert.interval", let interval = Int(trimmed) { - return .floodAdvertInterval(interval) - } - - // Flood max: integer hops - if query == "get flood.max", let maxHops = Int(trimmed) { - return .floodMax(maxHops) - } - - // Latitude: decimal degrees - if query == "get lat", let lat = Double(trimmed) { - return .latitude(lat) - } - - // Longitude: decimal degrees - if query == "get lon", let lon = Double(trimmed) { - return .longitude(lon) - } - - return .raw(trimmed) + case ok + case error(String) + case unknownCommand(String) // Specific case for "Error: unknown command" + case version(String) + case deviceTime(String) + case name(String) + case radio(frequency: Double, bandwidth: Double, spreadingFactor: Int, codingRate: Int) + case txPower(Int) + case repeatMode(Bool) + case advertInterval(Int) + case floodAdvertInterval(Int) // Value is in hours, not minutes + case floodMax(Int) + case latitude(Double) + case longitude(Double) + case ownerInfo(String) + case raw(String) + + /// Parse a CLI response text into a structured type + /// Note: Response correlation must be handled by the caller based on pending query tracking + public static func parse(_ text: String, forQuery query: String? = nil) -> CLIResponse { + var trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + + // Strip MeshCore CLI prompt prefix if present + // Firmware prepends "> " to all CLI command responses + if trimmed.hasPrefix("> ") { + trimmed = String(trimmed.dropFirst(2)) + } else if trimmed == ">" { + trimmed = "" } + + // Success responses: "OK" or "OK - clock set: ..." etc. + if trimmed == "OK" || trimmed.hasPrefix("OK - ") { + return .ok + } + + if trimmed.lowercased().hasPrefix("error") || trimmed.hasPrefix("ERR:") { + // Check for "unknown command" specifically for defensive handling + if trimmed.lowercased().contains("unknown command") { + return .unknownCommand(trimmed) + } + return .error(trimmed) + } + + // Firmware version: "MeshCore v1.10.0 (2025-04-18)" or "v1.11.0 (2025-04-18)" + // Some firmware builds omit "MeshCore " prefix + if trimmed.hasPrefix("MeshCore v") || (trimmed.hasPrefix("v") && trimmed.contains("(")) { + return .version(trimmed) + } + + // Use query hint to match version responses that don't have standard prefix + if query == "ver" { + return .version(trimmed) + } + + // Freeform text fields: query hint takes priority over broad content heuristics + // below (e.g. deviceTime matches any string with ":" and "/", which is common + // in repeater names and contact info like "Contact: KD7ABC / 145.230") + if query == "get name" { + return .name(trimmed) + } + + if query == "get owner.info" { + return .ownerInfo(trimmed) + } + + // Clock response: "06:40 - 18/4/2025 UTC" or contains time-like patterns + if trimmed.contains("UTC") || (trimmed.contains(":") && trimmed.contains("/")) { + return .deviceTime(trimmed) + } + + // Radio params: "915.000,250.0,10,5" (freq,bw,sf,cr) + // Use query hint to disambiguate from other comma-separated values + if query == "get radio" { + let parts = trimmed.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + if parts.count >= 4, + let freq = Double(parts[0]), + let bw = Double(parts[1]), + let sf = Int(parts[2]), + let cr = Int(parts[3]) { + return .radio(frequency: freq, bandwidth: bw, spreadingFactor: sf, codingRate: cr) + } + } + + // TX power: integer dBm value + if query == "get tx", let power = Int(trimmed) { + return .txPower(power) + } + + // Repeat mode: "on" or "off" + if query == "get repeat" { + if trimmed.lowercased() == "on" { + return .repeatMode(true) + } else if trimmed.lowercased() == "off" { + return .repeatMode(false) + } + } + + // Advert interval: integer minutes + if query == "get advert.interval", let interval = Int(trimmed) { + return .advertInterval(interval) + } + + // Flood advert interval: integer hours + if query == "get flood.advert.interval", let interval = Int(trimmed) { + return .floodAdvertInterval(interval) + } + + // Flood max: integer hops + if query == "get flood.max", let maxHops = Int(trimmed) { + return .floodMax(maxHops) + } + + // Latitude: decimal degrees + if query == "get lat", let lat = Double(trimmed) { + return .latitude(lat) + } + + // Longitude: decimal degrees + if query == "get lon", let lon = Double(trimmed) { + return .longitude(lon) + } + + return .raw(trimmed) + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChannelFloodScopeResolver.swift b/MC1Services/Sources/MC1Services/Services/ChannelFloodScopeResolver.swift index a0854bc8..9f89e45a 100644 --- a/MC1Services/Sources/MC1Services/Services/ChannelFloodScopeResolver.swift +++ b/MC1Services/Sources/MC1Services/Services/ChannelFloodScopeResolver.swift @@ -14,21 +14,21 @@ import MeshCore /// - `.inherit` + no device default → `.scope(.disabled)` (no scope filter) /// - `.region(name)` → `.scope(.region(name))` (explicit per-channel override) public enum ChannelFloodScopeResolver { - public static func resolve( - channelFloodScope: ChannelFloodScope, - deviceDefaultFloodScopeName: String?, - supportsUnscopedFloodSend: Bool - ) -> ResolvedFloodScope { - switch channelFloodScope { - case .inherit: - if let name = deviceDefaultFloodScopeName, !name.isEmpty { - return .scope(.region(name)) - } - return .scope(.disabled) - case .allRegions: - return supportsUnscopedFloodSend ? .unscoped : .scope(.disabled) - case .region(let name): - return .scope(.region(name)) - } + public static func resolve( + channelFloodScope: ChannelFloodScope, + deviceDefaultFloodScopeName: String?, + supportsUnscopedFloodSend: Bool + ) -> ResolvedFloodScope { + switch channelFloodScope { + case .inherit: + if let name = deviceDefaultFloodScopeName, !name.isEmpty { + return .scope(.region(name)) + } + return .scope(.disabled) + case .allRegions: + return supportsUnscopedFloodSend ? .unscoped : .scope(.disabled) + case let .region(name): + return .scope(.region(name)) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChannelService.swift b/MC1Services/Sources/MC1Services/Services/ChannelService.swift index e8c69774..05a9def9 100644 --- a/MC1Services/Sources/MC1Services/Services/ChannelService.swift +++ b/MC1Services/Sources/MC1Services/Services/ChannelService.swift @@ -1,106 +1,108 @@ -import Foundation import CryptoKit +import Foundation import MeshCore import os // MARK: - Channel Service Errors public enum ChannelServiceError: Error, Sendable { - case notConnected - case channelNotFound - case invalidChannelIndex - case secretHashingFailed - case saveFailed(String) - case sendFailed(String) - case sessionError(MeshCoreError) - case syncAlreadyInProgress - case circuitBreakerOpen(consecutiveFailures: Int) + case notConnected + case channelNotFound + case invalidChannelIndex + case secretHashingFailed + case saveFailed(String) + case sendFailed(String) + case sessionError(MeshCoreError) + case syncAlreadyInProgress + case circuitBreakerOpen(consecutiveFailures: Int) } extension ChannelServiceError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .channelNotFound: "Channel not found." - case .invalidChannelIndex: "Invalid channel index." - case .secretHashingFailed: "Failed to hash channel secret." - case .saveFailed(let msg): "Failed to save channel: \(msg)" - case .sendFailed(let msg): "Send failed: \(msg)" - case .sessionError(let e): e.localizedDescription - case .syncAlreadyInProgress: "Channel sync is already in progress." - case .circuitBreakerOpen(let n): "Channel sync suspended after \(n) consecutive failures." - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .channelNotFound: "Channel not found." + case .invalidChannelIndex: "Invalid channel index." + case .secretHashingFailed: "Failed to hash channel secret." + case let .saveFailed(msg): "Failed to save channel: \(msg)" + case let .sendFailed(msg): "Send failed: \(msg)" + case let .sessionError(e): e.localizedDescription + case .syncAlreadyInProgress: "Channel sync is already in progress." + case let .circuitBreakerOpen(n): "Channel sync suspended after \(n) consecutive failures." } + } } // MARK: - Channel Sync Error Details /// Detailed error information for a failed channel sync -struct ChannelSyncError: Sendable, Equatable { - let index: UInt8 - let errorType: ErrorType - let description: String - - enum ErrorType: Sendable, Equatable { - case timeout - case sendTimeout - case transportError - case circuitBreaker - case deviceError(code: UInt8) - case databaseError - case unknown +struct ChannelSyncError: Equatable { + let index: UInt8 + let errorType: ErrorType + let description: String + + enum ErrorType: Equatable { + case timeout + case sendTimeout + case transportError + case circuitBreaker + case deviceError(code: UInt8) + case databaseError + case unknown + } + + /// Whether this error type is potentially recoverable with retry + var isRetryable: Bool { + switch errorType { + case .timeout, .sendTimeout: + true + case .transportError, .circuitBreaker, .deviceError, .databaseError, .unknown: + false } - - /// Whether this error type is potentially recoverable with retry - var isRetryable: Bool { - switch errorType { - case .timeout, .sendTimeout: - return true - case .transportError, .circuitBreaker, .deviceError, .databaseError, .unknown: - return false - } - } - - var countsTowardCircuitBreaker: Bool { - switch errorType { - case .timeout, .sendTimeout, .transportError: - return true - case .circuitBreaker, .deviceError, .databaseError, .unknown: - return false - } + } + + var countsTowardCircuitBreaker: Bool { + switch errorType { + case .timeout, .sendTimeout, .transportError: + true + case .circuitBreaker, .deviceError, .databaseError, .unknown: + false } + } } // MARK: - Channel Sync Result -struct ChannelSyncResult: Sendable, Equatable { - let channelsSynced: Int - let errors: [ChannelSyncError] +struct ChannelSyncResult: Equatable { + let channelsSynced: Int + let errors: [ChannelSyncError] - /// Whether sync completed without errors - var isComplete: Bool { errors.isEmpty } + /// Whether sync completed without errors + var isComplete: Bool { + errors.isEmpty + } - var requestTimeoutCount: Int { - errors.filter { $0.errorType == .timeout }.count - } + var requestTimeoutCount: Int { + errors.count(where: { $0.errorType == .timeout }) + } - var sendTimeoutCount: Int { - errors.filter { $0.errorType == .sendTimeout }.count - } + var sendTimeoutCount: Int { + errors.count(where: { $0.errorType == .sendTimeout }) + } - var circuitBreakerAborted: Bool { - errors.contains { $0.errorType == .circuitBreaker } - } + var circuitBreakerAborted: Bool { + errors.contains { $0.errorType == .circuitBreaker } + } - /// Indices of channels that failed with retryable errors - var retryableIndices: [UInt8] { - errors.filter { $0.isRetryable }.map { $0.index } - } + /// Indices of channels that failed with retryable errors + var retryableIndices: [UInt8] { + errors.filter(\.isRetryable).map(\.index) + } - init(channelsSynced: Int, errors: [ChannelSyncError] = []) { - self.channelsSynced = channelsSynced - self.errors = errors - } + init(channelsSynced: Int, errors: [ChannelSyncError] = []) { + self.channelsSynced = channelsSynced + self.errors = errors + } } // MARK: - Channel Service Actor @@ -108,753 +110,753 @@ struct ChannelSyncResult: Sendable, Equatable { /// Actor-isolated service for channel (group) management. /// Handles channel CRUD operations, secret hashing, and broadcast messaging. public actor ChannelService { - - // MARK: - Properties - - private let session: any MeshCoreSessionProtocol - private let dataStore: any ChannelPersisting - private let logger = PersistentLogger(subsystem: "com.mc1", category: "ChannelService") - - /// Decryption cache consumer: channel writes and syncs forward the fresh - /// channel list so `RxLogService` can decode captured packets. - /// Injected by `ServiceContainer` at construction. - private let rxLogService: RxLogService? - - /// Callback invoked with the channel slots vacated by a delete or sync prune, - /// so the main-actor `DraftStore` can drop any draft keyed to a now-free slot - /// before that slot is reused by a different channel. - /// Installed by `AppState.wireServicesIfConnected`. - private var draftClearHandler: (@Sendable (UUID, Set) async -> Void)? - - /// Tracks whether a sync operation is in progress - private var isSyncing = false - - // MARK: - Initialization - - init( - session: any MeshCoreSessionProtocol, - dataStore: any ChannelPersisting, - rxLogService: RxLogService? - ) { - self.session = session - self.dataStore = dataStore - self.rxLogService = rxLogService + // MARK: - Properties + + private let session: any MeshCoreSessionProtocol + private let dataStore: any ChannelPersisting + private let logger = PersistentLogger(subsystem: "com.mc1", category: "ChannelService") + + /// Decryption cache consumer: channel writes and syncs forward the fresh + /// channel list so `RxLogService` can decode captured packets. + /// Injected by `ServiceContainer` at construction. + private let rxLogService: RxLogService? + + /// Callback invoked with the channel slots vacated by a delete or sync prune, + /// so the main-actor `DraftStore` can drop any draft keyed to a now-free slot + /// before that slot is reused by a different channel. + /// Installed by `AppState.wireServicesIfConnected`. + private var draftClearHandler: (@Sendable (UUID, Set) async -> Void)? + + /// Tracks whether a sync operation is in progress + private var isSyncing = false + + // MARK: - Initialization + + init( + session: any MeshCoreSessionProtocol, + dataStore: any ChannelPersisting, + rxLogService: RxLogService? + ) { + self.session = session + self.dataStore = dataStore + self.rxLogService = rxLogService + } + + /// Whether an RX log service was injected at construction. + var hasRxLogServiceWired: Bool { + rxLogService != nil + } + + // MARK: - Secret Hashing + + /// Hashes a passphrase into a 16-byte channel secret using SHA-256. + /// The firmware uses the first 16 bytes of the SHA-256 hash. + /// - Parameter passphrase: The passphrase to hash + /// - Returns: 16-byte secret data + public static func hashSecret(_ passphrase: String) -> Data { + guard !passphrase.isEmpty else { + return Data(repeating: 0, count: ProtocolLimits.channelSecretSize) } - /// Whether an RX log service was injected at construction. - var hasRxLogServiceWired: Bool { rxLogService != nil } - - // MARK: - Secret Hashing - - /// Hashes a passphrase into a 16-byte channel secret using SHA-256. - /// The firmware uses the first 16 bytes of the SHA-256 hash. - /// - Parameter passphrase: The passphrase to hash - /// - Returns: 16-byte secret data - public static func hashSecret(_ passphrase: String) -> Data { - guard !passphrase.isEmpty else { - return Data(repeating: 0, count: ProtocolLimits.channelSecretSize) - } - - let data = passphrase.data(using: .utf8) ?? Data() - let hash = SHA256.hash(data: data) - return Data(hash.prefix(ProtocolLimits.channelSecretSize)) + let data = passphrase.data(using: .utf8) ?? Data() + let hash = SHA256.hash(data: data) + return Data(hash.prefix(ProtocolLimits.channelSecretSize)) + } + + /// Validates that a secret has the correct size + public static func validateSecret(_ secret: Data) -> Bool { + secret.count == ProtocolLimits.channelSecretSize + } + + /// Determines if a channel slot should be treated as configured. + /// A slot is unconfigured only when both the name is empty and the secret is all zeros. + static func isChannelConfigured(name: String, secret: Data) -> Bool { + !name.isEmpty || !isZeroSecret(secret) + } + + private static func isZeroSecret(_ secret: Data) -> Bool { + secret.allSatisfy { $0 == 0 } + } + + // MARK: - Channel CRUD Operations + + /// Fetches all channels for a device from the remote device. + /// - Parameters: + /// - radioID: The device UUID + /// - maxChannels: Maximum number of channels to fetch (from device capacity) + /// - Returns: Sync result with number of channels synced + /// - Throws: `syncAlreadyInProgress` if another sync is running, + /// `circuitBreakerOpen` if too many consecutive timeouts + func syncChannels( + radioID: UUID, + maxChannels: UInt8, + usePipelinedRead: Bool + ) async throws -> ChannelSyncResult { + // Concurrency guard + guard !isSyncing else { + logger.warning("Channel sync already in progress, rejecting concurrent request") + throw ChannelServiceError.syncAlreadyInProgress } - /// Validates that a secret has the correct size - public static func validateSecret(_ secret: Data) -> Bool { - secret.count == ProtocolLimits.channelSecretSize - } + isSyncing = true + defer { isSyncing = false } - /// Determines if a channel slot should be treated as configured. - /// A slot is unconfigured only when both the name is empty and the secret is all zeros. - static func isChannelConfigured(name: String, secret: Data) -> Bool { - !name.isEmpty || !isZeroSecret(secret) + if usePipelinedRead { + return try await syncChannelsPipelined(radioID: radioID, maxChannels: maxChannels) } - private static func isZeroSecret(_ secret: Data) -> Bool { - secret.allSatisfy { $0 == 0 } - } - - // MARK: - Channel CRUD Operations - - /// Fetches all channels for a device from the remote device. - /// - Parameters: - /// - radioID: The device UUID - /// - maxChannels: Maximum number of channels to fetch (from device capacity) - /// - Returns: Sync result with number of channels synced - /// - Throws: `syncAlreadyInProgress` if another sync is running, - /// `circuitBreakerOpen` if too many consecutive timeouts - func syncChannels( - radioID: UUID, - maxChannels: UInt8, - usePipelinedRead: Bool - ) async throws -> ChannelSyncResult { - // Concurrency guard - guard !isSyncing else { - logger.warning("Channel sync already in progress, rejecting concurrent request") - throw ChannelServiceError.syncAlreadyInProgress - } - - isSyncing = true - defer { isSyncing = false } - - if usePipelinedRead { - return try await syncChannelsPipelined(radioID: radioID, maxChannels: maxChannels) + var syncErrors: [ChannelSyncError] = [] + var configured: [ChannelInfo] = [] + var unconfiguredIndices: [UInt8] = [] + var emptyNameWithSecretIndices: [UInt8] = [] + + // Circuit breaker state + var consecutiveTimeouts = 0 + let circuitBreakerThreshold = 3 + + for index: UInt8 in 0..= circuitBreakerThreshold { + logger.error("Circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, aborting sync") + // Mark remaining channels as failed + for remaining in index..= circuitBreakerThreshold { - logger.error("Circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, aborting sync") - // Mark remaining channels as failed - for remaining in index.. ChannelSyncResult { - var syncErrors: [ChannelSyncError] = [] - var configured: [ChannelInfo] = [] - var unconfiguredIndices: [UInt8] = [] - var emptyNameWithSecretIndices: [UInt8] = [] - - // A hard send failure (e.g. disconnect mid-send) throws here, aborting the round with - // nothing persisted; an idle stall returns a partial set to reconcile rather than throwing. - let (received, missing) = try await session.getChannels(indices: Array(0..= circuitBreakerThreshold { - logger.error("Reconcile circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, stopping reconcile") - for remaining in missing.drop(while: { $0 != index }) { - syncErrors.append(ChannelSyncError( - index: remaining, - errorType: .circuitBreaker, - description: "Skipped due to circuit breaker" - )) - } - break - } - - do { - if let channelInfo = try await fetchChannel(index: index) { - configured.append(channelInfo) - consecutiveTimeouts = 0 - if channelInfo.name.isEmpty { - emptyNameWithSecretIndices.append(index) - } - } else { - consecutiveTimeouts = 0 - unconfiguredIndices.append(index) - } - } catch { - let syncError = classifyError(error, forIndex: index) - consecutiveTimeouts = nextConsecutiveFailureCount( - after: syncError, - currentCount: consecutiveTimeouts - ) - logger.warning("Reconcile failed for channel \(index): \(syncError.description)") - syncErrors.append(syncError) - } + // Persist the whole pass in a single transaction. Indices skipped by the circuit breaker + // are left in neither list, so they are untouched. + return await finalizeChannelSync( + radioID: radioID, + maxChannels: maxChannels, + configured: configured, + unconfiguredIndices: unconfiguredIndices, + emptyNameWithSecretIndices: emptyNameWithSecretIndices, + syncErrors: syncErrors, + pipelined: false + ) + } + + /// Pipelined channel read for transports that support it (nRF52 over BLE, ESP32 over WiFi): + /// one bounded-window `getChannels` exchange in place of N serial round-trips, then + /// acknowledged reconciliation of any dropped requests. Classification and the + /// single-transaction persist match the serial path so an index that could not be read lands + /// in neither the configured nor the unconfigured list and is therefore never deleted. + private func syncChannelsPipelined(radioID: UUID, maxChannels: UInt8) async throws -> ChannelSyncResult { + var syncErrors: [ChannelSyncError] = [] + var configured: [ChannelInfo] = [] + var unconfiguredIndices: [UInt8] = [] + var emptyNameWithSecretIndices: [UInt8] = [] + + // A hard send failure (e.g. disconnect mid-send) throws here, aborting the round with + // nothing persisted; an idle stall returns a partial set to reconcile rather than throwing. + let (received, missing) = try await session.getChannels(indices: Array(0.. ChannelSyncResult { - var syncErrors = syncErrors - - // Snapshot occupied slots before the persist so vacated slots (unconfigured on the - // radio, or beyond capacity) can be diffed from the survivors and have their drafts - // dropped. A failed fetch yields an empty baseline that skips clearing, so log it. - let priorChannels = try? await dataStore.fetchChannels(radioID: radioID) - if priorChannels == nil { - logger.warning("Pre-sync channel snapshot failed for radio \(radioID.uuidString); skipping draft clear for any slots this prune vacates") + // Reconcile dropped Write Commands with acknowledged reads. "Consecutive" failures are + // meaningful again on this serial sub-loop, so the circuit breaker applies here. An index + // still unread after reconcile stays in neither list, so its row is never deleted. + var consecutiveTimeouts = 0 + let circuitBreakerThreshold = 3 + for index in missing { + if consecutiveTimeouts >= circuitBreakerThreshold { + logger.error("Reconcile circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, stopping reconcile") + for remaining in missing.drop(while: { $0 != index }) { + syncErrors.append(ChannelSyncError( + index: remaining, + errorType: .circuitBreaker, + description: "Skipped due to circuit breaker" + )) } - let occupiedBeforeSync = Set(priorChannels?.map(\.index) ?? []) - - let channels: [ChannelDTO] - do { - channels = try await dataStore.batchSaveChannels( - radioID: radioID, - configured: configured, - unconfiguredIndices: unconfiguredIndices, - pruneBeyond: maxChannels - ) - } catch { - logger.error("Channel batch persist failed: \(error.localizedDescription)") - for info in configured { - syncErrors.append(ChannelSyncError( - index: info.index, - errorType: .databaseError, - description: "Batch persist failed: \(error.localizedDescription)" - )) - } - return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) + break + } + + do { + if let channelInfo = try await fetchChannel(index: index) { + configured.append(channelInfo) + consecutiveTimeouts = 0 + if channelInfo.name.isEmpty { + emptyNameWithSecretIndices.append(index) + } + } else { + consecutiveTimeouts = 0 + unconfiguredIndices.append(index) } - - let diagnosticsLabel = pipelined ? "Channel sync diagnostics (pipelined)" : "Channel sync diagnostics" - logger.info( - "\(diagnosticsLabel): synced=\(configured.count), unconfigured=\(unconfiguredIndices.count), emptyNameWithSecret=\(emptyNameWithSecretIndices.count), errors=\(syncErrors.count)" + } catch { + let syncError = classifyError(error, forIndex: index) + consecutiveTimeouts = nextConsecutiveFailureCount( + after: syncError, + currentCount: consecutiveTimeouts ) - if !emptyNameWithSecretIndices.isEmpty { - logger.warning( - "Channel sync detected empty-name channels with non-zero secrets at indices: \(emptyNameWithSecretIndices)" - ) - } - - let vacatedSlots = occupiedBeforeSync.subtracting(channels.map(\.index)) - if !vacatedSlots.isEmpty { - await draftClearHandler?(radioID, vacatedSlots) - } - - await rxLogService?.updateChannels(from: channels) + logger.warning("Reconcile failed for channel \(index): \(syncError.description)") + syncErrors.append(syncError) + } + } - return ChannelSyncResult(channelsSynced: configured.count, errors: syncErrors) + return await finalizeChannelSync( + radioID: radioID, + maxChannels: maxChannels, + configured: configured, + unconfiguredIndices: unconfiguredIndices, + emptyNameWithSecretIndices: emptyNameWithSecretIndices, + syncErrors: syncErrors, + pipelined: true + ) + } + + /// Persists a completed channel-read pass in a single transaction and reports the result. + /// Shared by the serial and pipelined paths so their classification-to-persist tail cannot + /// drift: it upserts configured channels, deletes stale rows at unconfigured slots, prunes + /// orphans beyond capacity, and never touches an index that landed in neither list. + private func finalizeChannelSync( + radioID: UUID, + maxChannels: UInt8, + configured: [ChannelInfo], + unconfiguredIndices: [UInt8], + emptyNameWithSecretIndices: [UInt8], + syncErrors: [ChannelSyncError], + pipelined: Bool + ) async -> ChannelSyncResult { + var syncErrors = syncErrors + + // Snapshot occupied slots before the persist so vacated slots (unconfigured on the + // radio, or beyond capacity) can be diffed from the survivors and have their drafts + // dropped. A failed fetch yields an empty baseline that skips clearing, so log it. + let priorChannels = try? await dataStore.fetchChannels(radioID: radioID) + if priorChannels == nil { + logger.warning("Pre-sync channel snapshot failed for radio \(radioID.uuidString); skipping draft clear for any slots this prune vacates") + } + let occupiedBeforeSync = Set(priorChannels?.map(\.index) ?? []) + + let channels: [ChannelDTO] + do { + channels = try await dataStore.batchSaveChannels( + radioID: radioID, + configured: configured, + unconfiguredIndices: unconfiguredIndices, + pruneBeyond: maxChannels + ) + } catch { + logger.error("Channel batch persist failed: \(error.localizedDescription)") + for info in configured { + syncErrors.append(ChannelSyncError( + index: info.index, + errorType: .databaseError, + description: "Batch persist failed: \(error.localizedDescription)" + )) + } + return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) } - /// Retries syncing only the channels that previously failed. - /// - Parameters: - /// - radioID: The device UUID - /// - indices: Channel indices to retry - /// - Returns: Sync result for the retried channels - func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { - guard !isSyncing else { - throw ChannelServiceError.syncAlreadyInProgress - } + let diagnosticsLabel = pipelined ? "Channel sync diagnostics (pipelined)" : "Channel sync diagnostics" + logger.info( + "\(diagnosticsLabel): synced=\(configured.count), unconfigured=\(unconfiguredIndices.count), emptyNameWithSecret=\(emptyNameWithSecretIndices.count), errors=\(syncErrors.count)" + ) + if !emptyNameWithSecretIndices.isEmpty { + logger.warning( + "Channel sync detected empty-name channels with non-zero secrets at indices: \(emptyNameWithSecretIndices)" + ) + } - guard !indices.isEmpty else { - return ChannelSyncResult(channelsSynced: 0, errors: []) - } + let vacatedSlots = occupiedBeforeSync.subtracting(channels.map(\.index)) + if !vacatedSlots.isEmpty { + await draftClearHandler?(radioID, vacatedSlots) + } - isSyncing = true - defer { isSyncing = false } - - logger.info("Retrying \(indices.count) failed channels: \(indices)") - - // Brief delay before retry to allow transient issues to resolve - try await Task.sleep(for: .milliseconds(500)) - - var syncErrors: [ChannelSyncError] = [] - var configured: [ChannelInfo] = [] - - // Circuit breaker for retry (stricter threshold than initial sync) - var consecutiveTimeouts = 0 - let circuitBreakerThreshold = 2 - - for index in indices { - // Circuit breaker: stop retrying if connection is clearly broken - if consecutiveTimeouts >= circuitBreakerThreshold { - logger.warning("Retry circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, stopping retry") - // Mark remaining channels as failed - let remainingIndices = indices.drop(while: { $0 != index }) - for remaining in remainingIndices { - syncErrors.append(ChannelSyncError( - index: remaining, - errorType: .circuitBreaker, - description: "Skipped due to retry circuit breaker" - )) - } - break - } - - do { - if let channelInfo = try await fetchChannel(index: index) { - configured.append(channelInfo) - consecutiveTimeouts = 0 // Reset on success - logger.info("Retry succeeded for channel \(index)") - } else { - consecutiveTimeouts = 0 // Not-found is not a timeout - } - } catch { - let syncError = classifyError(error, forIndex: index) - consecutiveTimeouts = nextConsecutiveFailureCount( - after: syncError, - currentCount: consecutiveTimeouts - ) - logger.warning("Retry failed for channel \(index): \(syncError.description)") - syncErrors.append(syncError) - } - } + await rxLogService?.updateChannels(from: channels) - // Nothing recovered: skip the persist round-trip and the handler notification. - guard !configured.isEmpty else { - return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) - } + return ChannelSyncResult(channelsSynced: configured.count, errors: syncErrors) + } - // Upsert the recovered channels in one transaction. Retry only re-fetches previously - // failed slots, so it never deletes unconfigured slots or prunes by capacity. - do { - let allChannels = try await dataStore.batchSaveChannels( - radioID: radioID, - configured: configured, - unconfiguredIndices: [], - pruneBeyond: nil - ) - await rxLogService?.updateChannels(from: allChannels) - } catch { - logger.error("Retry batch persist failed: \(error.localizedDescription)") - for info in configured { - syncErrors.append(ChannelSyncError( - index: info.index, - errorType: .databaseError, - description: "Retry persist failed: \(error.localizedDescription)" - )) - } - return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) - } + /// Retries syncing only the channels that previously failed. + /// - Parameters: + /// - radioID: The device UUID + /// - indices: Channel indices to retry + /// - Returns: Sync result for the retried channels + func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { + guard !isSyncing else { + throw ChannelServiceError.syncAlreadyInProgress + } - return ChannelSyncResult(channelsSynced: configured.count, errors: syncErrors) + guard !indices.isEmpty else { + return ChannelSyncResult(channelsSynced: 0, errors: []) } - /// Fetches a single channel from the device with retry logic for transient BLE failures. - /// - Parameter index: The channel index - /// - Returns: Channel info if found, nil if not configured - public func fetchChannel(index: UInt8) async throws -> ChannelInfo? { - // BLE operations can fail transiently due to RF interference or timing. - // Retry with exponential backoff per industry best practices (BLE spec recommends 30s timeout, - // but shorter retries with backoff are more responsive). - let maxAttempts = 3 - var lastError: MeshCoreError = .timeout - - for attempt in 1...maxAttempts { - do { - let meshChannelInfo = try await session.getChannel(index: index) - - // Validate returned index matches requested - guard meshChannelInfo.index == index else { - logger.error("Channel index mismatch: requested \(index), received \(meshChannelInfo.index)") - throw ChannelServiceError.invalidChannelIndex - } - - // Treat channel as unconfigured only when both name and secret are empty. - guard Self.isChannelConfigured(name: meshChannelInfo.name, secret: meshChannelInfo.secret) else { - return nil - } - if meshChannelInfo.name.isEmpty { - logger.warning( - "Channel \(index) has empty name with non-zero secret; treating as configured" - ) - } - - // Convert MeshCore.ChannelInfo to MC1Services.ChannelInfo - return ChannelInfo( - index: meshChannelInfo.index, - name: meshChannelInfo.name, - secret: meshChannelInfo.secret - ) - } catch let error as MeshCoreError { - // Non-retryable: channel not found on device (permanent error) - if case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue { - return nil - } - - // Retryable: timeout errors are transient BLE issues - if case .timeout = error { - lastError = error - if attempt < maxAttempts { - // Exponential backoff: 500ms, 1000ms, 2000ms with jitter - let baseDelayMs = 500 * (1 << (attempt - 1)) - let jitterMs = Int.random(in: -100...100) - let delayMs = baseDelayMs + jitterMs - logger.info("Channel \(index) fetch timeout, retry \(attempt)/\(maxAttempts) in \(delayMs)ms") - try await Task.sleep(for: .milliseconds(delayMs)) - continue - } - } - - // Non-retryable: other MeshCore errors (device errors, parse errors, etc.) - throw ChannelServiceError.sessionError(error) - } + isSyncing = true + defer { isSyncing = false } + + logger.info("Retrying \(indices.count) failed channels: \(indices)") + + // Brief delay before retry to allow transient issues to resolve + try await Task.sleep(for: .milliseconds(500)) + + var syncErrors: [ChannelSyncError] = [] + var configured: [ChannelInfo] = [] + + // Circuit breaker for retry (stricter threshold than initial sync) + var consecutiveTimeouts = 0 + let circuitBreakerThreshold = 2 + + for index in indices { + // Circuit breaker: stop retrying if connection is clearly broken + if consecutiveTimeouts >= circuitBreakerThreshold { + logger.warning("Retry circuit breaker open: \(consecutiveTimeouts) consecutive timeouts, stopping retry") + // Mark remaining channels as failed + let remainingIndices = indices.drop(while: { $0 != index }) + for remaining in remainingIndices { + syncErrors.append(ChannelSyncError( + index: remaining, + errorType: .circuitBreaker, + description: "Skipped due to retry circuit breaker" + )) + } + break + } + + do { + if let channelInfo = try await fetchChannel(index: index) { + configured.append(channelInfo) + consecutiveTimeouts = 0 // Reset on success + logger.info("Retry succeeded for channel \(index)") + } else { + consecutiveTimeouts = 0 // Not-found is not a timeout } + } catch { + let syncError = classifyError(error, forIndex: index) + consecutiveTimeouts = nextConsecutiveFailureCount( + after: syncError, + currentCount: consecutiveTimeouts + ) + logger.warning("Retry failed for channel \(index): \(syncError.description)") + syncErrors.append(syncError) + } + } - // All retries exhausted - throw ChannelServiceError.sessionError(lastError) + // Nothing recovered: skip the persist round-trip and the handler notification. + guard !configured.isEmpty else { + return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) } - /// Sets (creates or updates) a channel on the device. - /// - Parameters: - /// - radioID: The device UUID - /// - index: The channel index - /// - name: The channel name - /// - passphrase: The passphrase to hash into a secret - public func setChannel( - radioID: UUID, - index: UInt8, - name: String, - passphrase: String - ) async throws { - let secret = Self.hashSecret(passphrase) - let truncatedName = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - - do { - try await session.setChannel(index: index, name: truncatedName, secret: secret) - - // Save to local database - let channelInfo = ChannelInfo(index: index, name: truncatedName, secret: secret) - _ = try await dataStore.saveChannel(radioID: radioID, from: channelInfo) - - // Refresh the decryption cache with the updated channel list - let channels = try await dataStore.fetchChannels(radioID: radioID) - await rxLogService?.updateChannels(from: channels) - } catch let error as MeshCoreError { - throw ChannelServiceError.sessionError(error) - } + // Upsert the recovered channels in one transaction. Retry only re-fetches previously + // failed slots, so it never deletes unconfigured slots or prunes by capacity. + do { + let allChannels = try await dataStore.batchSaveChannels( + radioID: radioID, + configured: configured, + unconfiguredIndices: [], + pruneBeyond: nil + ) + await rxLogService?.updateChannels(from: allChannels) + } catch { + logger.error("Retry batch persist failed: \(error.localizedDescription)") + for info in configured { + syncErrors.append(ChannelSyncError( + index: info.index, + errorType: .databaseError, + description: "Retry persist failed: \(error.localizedDescription)" + )) + } + return ChannelSyncResult(channelsSynced: 0, errors: syncErrors) } - /// Sets a channel with a pre-computed secret (for advanced use cases). - /// - Parameters: - /// - radioID: The device UUID - /// - index: The channel index - /// - name: The channel name - /// - secret: The 16-byte secret (must be exactly 16 bytes) - public func setChannelWithSecret( - radioID: UUID, - index: UInt8, - name: String, - secret: Data - ) async throws { - guard Self.validateSecret(secret) else { - throw ChannelServiceError.secretHashingFailed + return ChannelSyncResult(channelsSynced: configured.count, errors: syncErrors) + } + + /// Fetches a single channel from the device with retry logic for transient BLE failures. + /// - Parameter index: The channel index + /// - Returns: Channel info if found, nil if not configured + public func fetchChannel(index: UInt8) async throws -> ChannelInfo? { + // BLE operations can fail transiently due to RF interference or timing. + // Retry with exponential backoff per industry best practices (BLE spec recommends 30s timeout, + // but shorter retries with backoff are more responsive). + let maxAttempts = 3 + var lastError: MeshCoreError = .timeout + + for attempt in 1...maxAttempts { + do { + let meshChannelInfo = try await session.getChannel(index: index) + + // Validate returned index matches requested + guard meshChannelInfo.index == index else { + logger.error("Channel index mismatch: requested \(index), received \(meshChannelInfo.index)") + throw ChannelServiceError.invalidChannelIndex } - let truncatedName = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - - do { - try await session.setChannel(index: index, name: truncatedName, secret: secret) - - // Save to local database - let channelInfo = ChannelInfo(index: index, name: truncatedName, secret: secret) - _ = try await dataStore.saveChannel(radioID: radioID, from: channelInfo) - - // Refresh the decryption cache with the updated channel list - let channels = try await dataStore.fetchChannels(radioID: radioID) - await rxLogService?.updateChannels(from: channels) - } catch let error as MeshCoreError { - throw ChannelServiceError.sessionError(error) + // Treat channel as unconfigured only when both name and secret are empty. + guard Self.isChannelConfigured(name: meshChannelInfo.name, secret: meshChannelInfo.secret) else { + return nil } - } - - /// Clears a channel by setting it to empty name and zero secret. - /// - Parameters: - /// - radioID: The device UUID - /// - index: The channel index - public func clearChannel(radioID: UUID, index: UInt8) async throws { - // Get channel ID before clearing, so we can reliably delete it - // (fetching after setChannelWithSecret may not find the empty-named channel) - let channelToDelete = try await dataStore.fetchChannel(radioID: radioID, index: index) - - // Set empty name and zero secret to clear on device - do { - try await session.setChannel( - index: index, - name: "", - secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) - ) - } catch let error as MeshCoreError { - throw ChannelServiceError.sessionError(error) + if meshChannelInfo.name.isEmpty { + logger.warning( + "Channel \(index) has empty name with non-zero secret; treating as configured" + ) } - // Delete messages for this channel first - try await dataStore.deleteMessagesForChannel(radioID: radioID, channelIndex: index) - - // Delete channel from local database using the ID we captured earlier - if let channel = channelToDelete { - try await dataStore.deleteChannel(id: channel.id) + // Convert MeshCore.ChannelInfo to MC1Services.ChannelInfo + return ChannelInfo( + index: meshChannelInfo.index, + name: meshChannelInfo.name, + secret: meshChannelInfo.secret + ) + } catch let error as MeshCoreError { + // Non-retryable: channel not found on device (permanent error) + if case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue { + return nil } - // Drop any draft keyed to the freed slot so a channel later created at the - // same index can't surface this channel's stale draft. - await draftClearHandler?(radioID, [index]) - - // Refresh the decryption cache with the updated channel list - let channels = try await dataStore.fetchChannels(radioID: radioID) - await rxLogService?.updateChannels(from: channels) - } - - /// Clears all messages for a channel without deleting the channel itself. - /// Use this for a "Clear Messages" feature that keeps the channel active. - /// - Parameters: - /// - radioID: The device UUID - /// - channelIndex: The channel index (0-7) - public func clearChannelMessages(radioID: UUID, channelIndex: UInt8) async throws { - try await dataStore.deleteMessagesForChannel(radioID: radioID, channelIndex: channelIndex) - - // Clear the last message date so the channel doesn't show a preview, and zero - // both unread counters — leaving them set would inflate the badge for a channel - // the user just emptied. - if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { - try await dataStore.updateChannelLastMessage(channelID: channel.id, date: nil) - try await dataStore.clearChannelUnreadCount(channelID: channel.id) - try await dataStore.clearChannelUnreadMentionCount(channelID: channel.id) + // Retryable: timeout errors are transient BLE issues + if case .timeout = error { + lastError = error + if attempt < maxAttempts { + // Exponential backoff: 500ms, 1000ms, 2000ms with jitter + let baseDelayMs = 500 * (1 << (attempt - 1)) + let jitterMs = Int.random(in: -100...100) + let delayMs = baseDelayMs + jitterMs + logger.info("Channel \(index) fetch timeout, retry \(attempt)/\(maxAttempts) in \(delayMs)ms") + try await Task.sleep(for: .milliseconds(delayMs)) + continue + } } - } - - // MARK: - Local Database Operations - /// Gets all channels from local database for a device. - /// - Parameter radioID: The device UUID - /// - Returns: Array of channel DTOs - public func getChannels(radioID: UUID) async throws -> [ChannelDTO] { - try await dataStore.fetchChannels(radioID: radioID) + // Non-retryable: other MeshCore errors (device errors, parse errors, etc.) + throw ChannelServiceError.sessionError(error) + } } - /// Gets a specific channel from local database. - /// - Parameters: - /// - radioID: The device UUID - /// - index: The channel index - /// - Returns: Channel DTO if found - public func getChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { - try await dataStore.fetchChannel(radioID: radioID, index: index) + // All retries exhausted + throw ChannelServiceError.sessionError(lastError) + } + + /// Sets (creates or updates) a channel on the device. + /// - Parameters: + /// - radioID: The device UUID + /// - index: The channel index + /// - name: The channel name + /// - passphrase: The passphrase to hash into a secret + public func setChannel( + radioID: UUID, + index: UInt8, + name: String, + passphrase: String + ) async throws { + let secret = Self.hashSecret(passphrase) + let truncatedName = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + + do { + try await session.setChannel(index: index, name: truncatedName, secret: secret) + + // Save to local database + let channelInfo = ChannelInfo(index: index, name: truncatedName, secret: secret) + _ = try await dataStore.saveChannel(radioID: radioID, from: channelInfo) + + // Refresh the decryption cache with the updated channel list + let channels = try await dataStore.fetchChannels(radioID: radioID) + await rxLogService?.updateChannels(from: channels) + } catch let error as MeshCoreError { + throw ChannelServiceError.sessionError(error) } - - /// Gets channels that have messages (for chat list). - /// - Parameter radioID: The device UUID - /// - Returns: Array of channel DTOs with lastMessageDate set - public func getActiveChannels(radioID: UUID) async throws -> [ChannelDTO] { - let channels = try await dataStore.fetchChannels(radioID: radioID) - return channels.filter { $0.lastMessageDate != nil } + } + + /// Sets a channel with a pre-computed secret (for advanced use cases). + /// - Parameters: + /// - radioID: The device UUID + /// - index: The channel index + /// - name: The channel name + /// - secret: The 16-byte secret (must be exactly 16 bytes) + public func setChannelWithSecret( + radioID: UUID, + index: UInt8, + name: String, + secret: Data + ) async throws { + guard Self.validateSecret(secret) else { + throw ChannelServiceError.secretHashingFailed } - // MARK: - Public Channel (Slot 0) - - private static let publicChannelSecret = Data([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72 - ]) - - /// Creates or resets the public channel (slot 0). - /// - Parameter radioID: The device UUID - public func setupPublicChannel(radioID: UUID) async throws { - try await setChannelWithSecret( - radioID: radioID, - index: 0, - name: "Public", - secret: Self.publicChannelSecret - ) - } + let truncatedName = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - /// Checks if the public channel exists locally. - /// - Parameter radioID: The device UUID - /// - Returns: True if public channel exists - public func hasPublicChannel(radioID: UUID) async throws -> Bool { - let channel = try await dataStore.fetchChannel(radioID: radioID, index: 0) - return channel != nil - } + do { + try await session.setChannel(index: index, name: truncatedName, secret: secret) - // MARK: - Handlers + // Save to local database + let channelInfo = ChannelInfo(index: index, name: truncatedName, secret: secret) + _ = try await dataStore.saveChannel(radioID: radioID, from: channelInfo) - /// Sets a callback that receives the channel slots vacated by `clearChannel` - /// and by the sync prune in `finalizeChannelSync`, so the `DraftStore` can - /// clear drafts keyed to a freed slot. - public func setDraftClearHandler(_ handler: @escaping @Sendable (UUID, Set) async -> Void) { - draftClearHandler = handler + // Refresh the decryption cache with the updated channel list + let channels = try await dataStore.fetchChannels(radioID: radioID) + await rxLogService?.updateChannels(from: channels) + } catch let error as MeshCoreError { + throw ChannelServiceError.sessionError(error) + } + } + + /// Clears a channel by setting it to empty name and zero secret. + /// - Parameters: + /// - radioID: The device UUID + /// - index: The channel index + public func clearChannel(radioID: UUID, index: UInt8) async throws { + // Get channel ID before clearing, so we can reliably delete it + // (fetching after setChannelWithSecret may not find the empty-named channel) + let channelToDelete = try await dataStore.fetchChannel(radioID: radioID, index: index) + + // Set empty name and zero secret to clear on device + do { + try await session.setChannel( + index: index, + name: "", + secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) + ) + } catch let error as MeshCoreError { + throw ChannelServiceError.sessionError(error) } - // MARK: - Private Helpers - - /// Classifies an error into a ChannelSyncError for the given index - private func classifyError(_ error: Error, forIndex index: UInt8) -> ChannelSyncError { - if let channelError = error as? ChannelServiceError { - switch channelError { - case .circuitBreakerOpen: - return ChannelSyncError( - index: index, - errorType: .circuitBreaker, - description: channelError.localizedDescription - ) - case .sessionError(let meshError): - switch meshError { - case .timeout: - return ChannelSyncError( - index: index, - errorType: .timeout, - description: "Request timed out" - ) - case .deviceError(let code): - return ChannelSyncError( - index: index, - errorType: .deviceError(code: code), - description: meshError.localizedDescription - ) - default: - return ChannelSyncError( - index: index, - errorType: .unknown, - description: meshError.localizedDescription - ) - } - case .saveFailed(let reason): - return ChannelSyncError( - index: index, - errorType: .databaseError, - description: "Save failed: \(reason)" - ) - default: - return ChannelSyncError( - index: index, - errorType: .unknown, - description: channelError.localizedDescription - ) - } - } - - if let transportError = error as? WiFiTransportError { - switch transportError { - case .sendTimeout: - return ChannelSyncError( - index: index, - errorType: .sendTimeout, - description: "Send timed out" - ) - case .notConnected, .connectionFailed, .connectionTimeout, .sendFailed: - return ChannelSyncError( - index: index, - errorType: .transportError, - description: transportError.localizedDescription - ) - case .invalidHost, .invalidPort, .notConfigured: - return ChannelSyncError( - index: index, - errorType: .unknown, - description: transportError.localizedDescription - ) - } - } + // Delete messages for this channel first + try await dataStore.deleteMessagesForChannel(radioID: radioID, channelIndex: index) - if let transportError = error as? MeshTransportError { - switch transportError { - case .sendFailed: - return ChannelSyncError( - index: index, - errorType: .transportError, - description: transportError.localizedDescription - ) - case .notConnected, .connectionFailed, .deviceNotFound, .serviceNotFound, .characteristicNotFound: - return ChannelSyncError( - index: index, - errorType: .transportError, - description: transportError.localizedDescription - ) - } - } + // Delete channel from local database using the ID we captured earlier + if let channel = channelToDelete { + try await dataStore.deleteChannel(id: channel.id) + } + // Drop any draft keyed to the freed slot so a channel later created at the + // same index can't surface this channel's stale draft. + await draftClearHandler?(radioID, [index]) + + // Refresh the decryption cache with the updated channel list + let channels = try await dataStore.fetchChannels(radioID: radioID) + await rxLogService?.updateChannels(from: channels) + } + + /// Clears all messages for a channel without deleting the channel itself. + /// Use this for a "Clear Messages" feature that keeps the channel active. + /// - Parameters: + /// - radioID: The device UUID + /// - channelIndex: The channel index (0-7) + public func clearChannelMessages(radioID: UUID, channelIndex: UInt8) async throws { + try await dataStore.deleteMessagesForChannel(radioID: radioID, channelIndex: channelIndex) + + // Clear the last message date so the channel doesn't show a preview, and zero + // both unread counters — leaving them set would inflate the badge for a channel + // the user just emptied. + if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { + try await dataStore.updateChannelLastMessage(channelID: channel.id, date: nil) + try await dataStore.clearChannelUnreadCount(channelID: channel.id) + try await dataStore.clearChannelUnreadMentionCount(channelID: channel.id) + } + } + + // MARK: - Local Database Operations + + /// Gets all channels from local database for a device. + /// - Parameter radioID: The device UUID + /// - Returns: Array of channel DTOs + public func getChannels(radioID: UUID) async throws -> [ChannelDTO] { + try await dataStore.fetchChannels(radioID: radioID) + } + + /// Gets a specific channel from local database. + /// - Parameters: + /// - radioID: The device UUID + /// - index: The channel index + /// - Returns: Channel DTO if found + public func getChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { + try await dataStore.fetchChannel(radioID: radioID, index: index) + } + + /// Gets channels that have messages (for chat list). + /// - Parameter radioID: The device UUID + /// - Returns: Array of channel DTOs with lastMessageDate set + public func getActiveChannels(radioID: UUID) async throws -> [ChannelDTO] { + let channels = try await dataStore.fetchChannels(radioID: radioID) + return channels.filter { $0.lastMessageDate != nil } + } + + // MARK: - Public Channel (Slot 0) + + private static let publicChannelSecret = Data([ + 0x8B, 0x33, 0x87, 0xE9, 0xC5, 0xCD, 0xEA, 0x6A, + 0xC9, 0xE5, 0xED, 0xBA, 0xA1, 0x15, 0xCD, 0x72 + ]) + + /// Creates or resets the public channel (slot 0). + /// - Parameter radioID: The device UUID + public func setupPublicChannel(radioID: UUID) async throws { + try await setChannelWithSecret( + radioID: radioID, + index: 0, + name: "Public", + secret: Self.publicChannelSecret + ) + } + + /// Checks if the public channel exists locally. + /// - Parameter radioID: The device UUID + /// - Returns: True if public channel exists + public func hasPublicChannel(radioID: UUID) async throws -> Bool { + let channel = try await dataStore.fetchChannel(radioID: radioID, index: 0) + return channel != nil + } + + // MARK: - Handlers + + /// Sets a callback that receives the channel slots vacated by `clearChannel` + /// and by the sync prune in `finalizeChannelSync`, so the `DraftStore` can + /// clear drafts keyed to a freed slot. + public func setDraftClearHandler(_ handler: @escaping @Sendable (UUID, Set) async -> Void) { + draftClearHandler = handler + } + + // MARK: - Private Helpers + + /// Classifies an error into a ChannelSyncError for the given index + private func classifyError(_ error: Error, forIndex index: UInt8) -> ChannelSyncError { + if let channelError = error as? ChannelServiceError { + switch channelError { + case .circuitBreakerOpen: return ChannelSyncError( + index: index, + errorType: .circuitBreaker, + description: channelError.localizedDescription + ) + case let .sessionError(meshError): + switch meshError { + case .timeout: + return ChannelSyncError( + index: index, + errorType: .timeout, + description: "Request timed out" + ) + case let .deviceError(code): + return ChannelSyncError( + index: index, + errorType: .deviceError(code: code), + description: meshError.localizedDescription + ) + default: + return ChannelSyncError( index: index, errorType: .unknown, - description: error.localizedDescription + description: meshError.localizedDescription + ) + } + case let .saveFailed(reason): + return ChannelSyncError( + index: index, + errorType: .databaseError, + description: "Save failed: \(reason)" + ) + default: + return ChannelSyncError( + index: index, + errorType: .unknown, + description: channelError.localizedDescription ) + } } - private func nextConsecutiveFailureCount( - after error: ChannelSyncError, - currentCount: Int - ) -> Int { - error.countsTowardCircuitBreaker ? currentCount + 1 : 0 + if let transportError = error as? WiFiTransportError { + switch transportError { + case .sendTimeout: + return ChannelSyncError( + index: index, + errorType: .sendTimeout, + description: "Send timed out" + ) + case .notConnected, .connectionFailed, .connectionTimeout, .sendFailed: + return ChannelSyncError( + index: index, + errorType: .transportError, + description: transportError.localizedDescription + ) + case .invalidHost, .invalidPort, .notConfigured: + return ChannelSyncError( + index: index, + errorType: .unknown, + description: transportError.localizedDescription + ) + } + } + + if let transportError = error as? MeshTransportError { + switch transportError { + case .sendFailed: + return ChannelSyncError( + index: index, + errorType: .transportError, + description: transportError.localizedDescription + ) + case .notConnected, .connectionFailed, .deviceNotFound, .serviceNotFound, .characteristicNotFound: + return ChannelSyncError( + index: index, + errorType: .transportError, + description: transportError.localizedDescription + ) + } } + return ChannelSyncError( + index: index, + errorType: .unknown, + description: error.localizedDescription + ) + } + + private func nextConsecutiveFailureCount( + after error: ChannelSyncError, + currentCount: Int + ) -> Int { + error.countsTowardCircuitBreaker ? currentCount + 1 : 0 + } } // MARK: - ChannelServiceProtocol Conformance extension ChannelService: ChannelServiceProtocol { - // Already implements syncChannels(radioID:maxChannels:) -> ChannelSyncResult + // Already implements syncChannels(radioID:maxChannels:) -> ChannelSyncResult } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift index 29957bf2..bcb6ac7e 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift @@ -1,188 +1,187 @@ import Foundation public extension ChatCoordinator { - - /// Replace the entire canonical messages list. Rebuilds the lookup - /// dictionary and bumps `renderStateID` so any in-flight off-main - /// build discards on apply. Settles `renderState.phase` to `.loaded`, - /// which is the load-completion seam for both initial loads and - /// `hardReset` refetches. - func replaceAll(_ newMessages: [MessageDTO]) { - messages = newMessages - messagesByID = Dictionary(newMessages.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) - if renderState.phase != .loaded { - renderState = renderState.with(phase: .loaded) - } - renderStateID &+= 1 + /// Replace the entire canonical messages list. Rebuilds the lookup + /// dictionary and bumps `renderStateID` so any in-flight off-main + /// build discards on apply. Settles `renderState.phase` to `.loaded`, + /// which is the load-completion seam for both initial loads and + /// `hardReset` refetches. + func replaceAll(_ newMessages: [MessageDTO]) { + messages = newMessages + messagesByID = Dictionary(newMessages.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) + if renderState.phase != .loaded { + renderState = renderState.with(phase: .loaded) } + renderStateID &+= 1 + } - /// Transition `renderState.phase` to `.loading` only when still - /// `.uninitialized`. Called at the entry of `loadMessages` / - /// `loadChannelMessages` so the per-conversation view's empty-state - /// gate stays closed while the awaited fetch is in flight. A no-op - /// once the coordinator has reached `.loading` or `.loaded` — a - /// subsequent refresh of an already-populated timeline must not - /// regress to a placeholder. - func beginLoading() { - guard renderState.phase == .uninitialized else { return } - renderState = renderState.with(phase: .loading) - renderStateID &+= 1 - } + /// Transition `renderState.phase` to `.loading` only when still + /// `.uninitialized`. Called at the entry of `loadMessages` / + /// `loadChannelMessages` so the per-conversation view's empty-state + /// gate stays closed while the awaited fetch is in flight. A no-op + /// once the coordinator has reached `.loading` or `.loaded` — a + /// subsequent refresh of an already-populated timeline must not + /// regress to a placeholder. + func beginLoading() { + guard renderState.phase == .uninitialized else { return } + renderState = renderState.with(phase: .loading) + renderStateID &+= 1 + } - /// Force the phase to `.loaded`. Used by the load paths to dismiss the - /// placeholder on error and on the nil-`dataStore` early-return, where - /// `replaceAll` is never reached. Idempotent when already `.loaded`. - func markLoaded() { - guard renderState.phase != .loaded else { return } - renderState = renderState.with(phase: .loaded) - renderStateID &+= 1 - } + /// Force the phase to `.loaded`. Used by the load paths to dismiss the + /// placeholder on error and on the nil-`dataStore` early-return, where + /// `replaceAll` is never reached. Idempotent when already `.loaded`. + func markLoaded() { + guard renderState.phase != .loaded else { return } + renderState = renderState.with(phase: .loaded) + renderStateID &+= 1 + } - /// Insert `older` at the head of the canonical messages list. Used by - /// pagination to prepend a fresh page above the current timeline. - /// Skips IDs already present so concurrent appends do not produce - /// duplicate-key crashes during reordering. - func prepend(_ older: [MessageDTO]) { - guard !older.isEmpty else { return } - let filtered = older.filter { messagesByID[$0.id] == nil } - guard !filtered.isEmpty else { return } - messages.insert(contentsOf: filtered, at: 0) - for dto in filtered { - messagesByID[dto.id] = dto - } - renderStateID &+= 1 + /// Insert `older` at the head of the canonical messages list. Used by + /// pagination to prepend a fresh page above the current timeline. + /// Skips IDs already present so concurrent appends do not produce + /// duplicate-key crashes during reordering. + func prepend(_ older: [MessageDTO]) { + guard !older.isEmpty else { return } + let filtered = older.filter { messagesByID[$0.id] == nil } + guard !filtered.isEmpty else { return } + messages.insert(contentsOf: filtered, at: 0) + for dto in filtered { + messagesByID[dto.id] = dto } + renderStateID &+= 1 + } - /// Append a single message if its ID is not already known. The guard - /// reads `messagesByID` (O(1) lookup) so events landing during an - /// off-main build are still deduplicated correctly. - @discardableResult - func append(_ message: MessageDTO) -> Bool { - guard messagesByID[message.id] == nil else { return false } - messages.append(message) - messagesByID[message.id] = message - renderStateID &+= 1 - return true - } + /// Append a single message if its ID is not already known. The guard + /// reads `messagesByID` (O(1) lookup) so events landing during an + /// off-main build are still deduplicated correctly. + @discardableResult + func append(_ message: MessageDTO) -> Bool { + guard messagesByID[message.id] == nil else { return false } + messages.append(message) + messagesByID[message.id] = message + renderStateID &+= 1 + return true + } - /// Apply an in-place mutation to a message identified by ID. No-op on - /// missing ID. Bumps `renderStateID` so stale off-main builds discard. - func update(messageID: UUID, _ transform: (inout MessageDTO) -> Void) { - guard let index = messages.firstIndex(where: { $0.id == messageID }) else { return } - transform(&messages[index]) - messagesByID[messageID] = messages[index] - renderStateID &+= 1 - } + /// Apply an in-place mutation to a message identified by ID. No-op on + /// missing ID. Bumps `renderStateID` so stale off-main builds discard. + func update(messageID: UUID, _ transform: (inout MessageDTO) -> Void) { + guard let index = messages.firstIndex(where: { $0.id == messageID }) else { return } + transform(&messages[index]) + messagesByID[messageID] = messages[index] + renderStateID &+= 1 + } - /// Remove a message by ID. No-op if absent. - func remove(messageID: UUID) { - guard messagesByID[messageID] != nil else { return } - messages.removeAll { $0.id == messageID } - messagesByID[messageID] = nil - renderStateID &+= 1 - } + /// Remove a message by ID. No-op if absent. + func remove(messageID: UUID) { + guard messagesByID[messageID] != nil else { return } + messages.removeAll { $0.id == messageID } + messagesByID[messageID] = nil + renderStateID &+= 1 + } - /// Reassign the entire `messages` array in place after a same-sender - /// reordering pass. Rebuilds `messagesByID` to match. Bumps - /// `renderStateID`. - func replaceMessagesPreservingByID(_ reordered: [MessageDTO]) { - messages = reordered - messagesByID = Dictionary(reordered.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) - renderStateID &+= 1 - } + /// Reassign the entire `messages` array in place after a same-sender + /// reordering pass. Rebuilds `messagesByID` to match. Bumps + /// `renderStateID`. + func replaceMessagesPreservingByID(_ reordered: [MessageDTO]) { + messages = reordered + messagesByID = Dictionary(reordered.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) + renderStateID &+= 1 + } - /// Single seam through which off-main builds apply their rendered - /// timeline. Returns `false` if the captured `capturedID` does not - /// match the current `renderStateID` — i.e., the build is stale and - /// the caller should re-run. - @discardableResult - func setRenderState(_ new: ChatRenderState, capturedID: UInt64) -> Bool { - guard capturedID == renderStateID else { return false } - renderState = new - return true - } + /// Single seam through which off-main builds apply their rendered + /// timeline. Returns `false` if the captured `capturedID` does not + /// match the current `renderStateID` — i.e., the build is stale and + /// the caller should re-run. + @discardableResult + func setRenderState(_ new: ChatRenderState, capturedID: UInt64) -> Bool { + guard capturedID == renderStateID else { return false } + renderState = new + return true + } - /// Direct render-state assignment for single-row updates that already - /// hold the canonical messages array consistent. Used by load paths - /// that reset pagination fields. Bumps `renderStateID` because the - /// new render state may displace an in-flight off-main build. - func updateRenderState(_ transform: (ChatRenderState) -> ChatRenderState) { - renderState = transform(renderState) - renderStateID &+= 1 - } + /// Direct render-state assignment for single-row updates that already + /// hold the canonical messages array consistent. Used by load paths + /// that reset pagination fields. Bumps `renderStateID` because the + /// new render state may displace an in-flight off-main build. + func updateRenderState(_ transform: (ChatRenderState) -> ChatRenderState) { + renderState = transform(renderState) + renderStateID &+= 1 + } - /// Append a fully built `MessageItem` to the render state. Used by the - /// single-row append path so the new bubble is visible immediately - /// without waiting for the next off-main build. - func appendRenderItem(_ item: MessageItem) { - renderState = renderState.appendingItem(item) - renderStateID &+= 1 - } + /// Append a fully built `MessageItem` to the render state. Used by the + /// single-row append path so the new bubble is visible immediately + /// without waiting for the next off-main build. + func appendRenderItem(_ item: MessageItem) { + renderState = renderState.appendingItem(item) + renderStateID &+= 1 + } - /// Replace a single render-state item by ID via `transform`. No-op on - /// missing ID. Bumps `renderStateID`. - func updateRenderItem(id: UUID, _ transform: (MessageItem) -> MessageItem) { - let newState = renderState.updatingItem(id: id, transform) - guard newState != renderState else { return } - renderState = newState - renderStateID &+= 1 - } + /// Replace a single render-state item by ID via `transform`. No-op on + /// missing ID. Bumps `renderStateID`. + func updateRenderItem(id: UUID, _ transform: (MessageItem) -> MessageItem) { + let newState = renderState.updatingItem(id: id, transform) + guard newState != renderState else { return } + renderState = newState + renderStateID &+= 1 + } - /// Remove a single render-state item by ID. Bumps `renderStateID`. - func removeRenderItem(id: UUID) { - let newState = renderState.removingItem(id: id) - guard newState != renderState else { return } - renderState = newState - renderStateID &+= 1 - } + /// Remove a single render-state item by ID. Bumps `renderStateID`. + func removeRenderItem(id: UUID) { + let newState = renderState.removingItem(id: id) + guard newState != renderState else { return } + renderState = newState + renderStateID &+= 1 + } - /// Apply a status-only update to a message in place. Mutates the canonical - /// `MessageDTO` (so `BubbleStatusRow` re-reads the new label) and the - /// rendered `MessageItem`'s envelope and footer (so `bubbleColor` and - /// `accessibilityMessageLabel` reflect the new status). No DB read. - /// - /// `roundTripTime` is preserved when nil so .sent transitions don't clobber - /// an existing RTT recorded by a prior .delivered event. - /// - /// Mirrors the monotonic guard in `PersistenceStore.updateMessageAck`: once - /// the DTO has reached `.delivered`, a later `.sent` write from the - /// send-return path is skipped so the authoritative delivery state is - /// preserved. Without this guard, the DM-send-return then ACK-listener race - /// produces a visible `.delivered` then `.sent` downgrade in the UI when - /// the listener wins. - /// - /// A second guard blocks `.failed` from being downgraded to `.pending` by - /// a non-user-initiated path. Legitimate user-initiated retry sites set - /// `userInitiated: true` so the flip lands; event-stream `applyStatusUpdate` - /// callers (ack / send confirmation) never carry `.pending`, so the - /// default avoids the visible `.failed` then `.pending` flicker when a - /// stale event lands after the queue marked the row terminal. - /// - /// The `.delivered` downgrade guard is also relaxed for `userInitiated` - /// transitions so a user-initiated resend can visibly flip a delivered - /// row back to `.pending` while the queue retransmits; event-stream - /// callers still cannot downgrade a delivered row. - func applyStatusUpdate( - messageID: UUID, - status: MessageStatus, - roundTripTime: UInt32? = nil, - userInitiated: Bool = false - ) { - if let current = messagesByID[messageID] { - if current.status == .delivered, status != .delivered, !userInitiated { return } - if current.status == .failed, status == .pending, !userInitiated { return } - } - update(messageID: messageID) { dto in - dto.status = status - if let roundTripTime { - dto.roundTripTime = roundTripTime - } - } - updateRenderItem(id: messageID) { item in - item.with( - envelope: item.envelope.with(status: status), - footer: item.footer.with(status: status) - ) - } + /// Apply a status-only update to a message in place. Mutates the canonical + /// `MessageDTO` (so `BubbleStatusRow` re-reads the new label) and the + /// rendered `MessageItem`'s envelope and footer (so `bubbleColor` and + /// `accessibilityMessageLabel` reflect the new status). No DB read. + /// + /// `roundTripTime` is preserved when nil so .sent transitions don't clobber + /// an existing RTT recorded by a prior .delivered event. + /// + /// Mirrors the monotonic guard in `PersistenceStore.updateMessageAck`: once + /// the DTO has reached `.delivered`, a later `.sent` write from the + /// send-return path is skipped so the authoritative delivery state is + /// preserved. Without this guard, the DM-send-return then ACK-listener race + /// produces a visible `.delivered` then `.sent` downgrade in the UI when + /// the listener wins. + /// + /// A second guard blocks `.failed` from being downgraded to `.pending` by + /// a non-user-initiated path. Legitimate user-initiated retry sites set + /// `userInitiated: true` so the flip lands; event-stream `applyStatusUpdate` + /// callers (ack / send confirmation) never carry `.pending`, so the + /// default avoids the visible `.failed` then `.pending` flicker when a + /// stale event lands after the queue marked the row terminal. + /// + /// The `.delivered` downgrade guard is also relaxed for `userInitiated` + /// transitions so a user-initiated resend can visibly flip a delivered + /// row back to `.pending` while the queue retransmits; event-stream + /// callers still cannot downgrade a delivered row. + func applyStatusUpdate( + messageID: UUID, + status: MessageStatus, + roundTripTime: UInt32? = nil, + userInitiated: Bool = false + ) { + if let current = messagesByID[messageID] { + if current.status == .delivered, status != .delivered, !userInitiated { return } + if current.status == .failed, status == .pending, !userInitiated { return } + } + update(messageID: messageID) { dto in + dto.status = status + if let roundTripTime { + dto.roundTripTime = roundTripTime + } + } + updateRenderItem(id: messageID) { item in + item.with( + envelope: item.envelope.with(status: status), + footer: item.footer.with(status: status) + ) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift index 41dad791..5ff6562f 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Rebuild.swift @@ -1,73 +1,72 @@ import Foundation public extension ChatCoordinator { + /// Rebuild `renderState` from a snapshot of inputs already assembled on + /// the main actor. Runs the per-message builder loop off the main actor + /// inside a `Task { @concurrent }` hop, then applies the result on main + /// when the captured `renderStateID` still matches. + /// + /// The caller supplies the `(MessageDTO, MessageBuildInputs)` pairs so + /// per-message inputs that depend on `ChatViewModel` state (preview + /// states, cached URLs, image decode results) can be assembled on the + /// main actor where that state lives. `MessageFragmentBuilder.makeItem` + /// is pure over `Sendable` inputs, so the off-actor portion captures + /// only `Sendable` values. + /// + /// `postApply` runs on the main actor after a successful apply. The + /// view model uses it to kick off URL detection and legacy preview + /// decoding without needing to know about the off-main hop. + func rebuildItems( + inputs: [(MessageDTO, MessageBuildInputs)], + envInputs: EnvInputs, + postApply: (@MainActor () -> Void)? = nil + ) { + // Bump the generation before capturing it so two back-to-back + // rebuilds (link-preview load + URL detection, for example) cannot + // share the same `capturedID`. Without this, an older build that + // finished after a newer one would still pass `setRenderState`'s + // guard and clobber state. Last-scheduled-wins. + renderStateID &+= 1 + let capturedID = renderStateID + let snapshot = inputs - /// Rebuild `renderState` from a snapshot of inputs already assembled on - /// the main actor. Runs the per-message builder loop off the main actor - /// inside a `Task { @concurrent }` hop, then applies the result on main - /// when the captured `renderStateID` still matches. - /// - /// The caller supplies the `(MessageDTO, MessageBuildInputs)` pairs so - /// per-message inputs that depend on `ChatViewModel` state (preview - /// states, cached URLs, image decode results) can be assembled on the - /// main actor where that state lives. `MessageFragmentBuilder.makeItem` - /// is pure over `Sendable` inputs, so the off-actor portion captures - /// only `Sendable` values. - /// - /// `postApply` runs on the main actor after a successful apply. The - /// view model uses it to kick off URL detection and legacy preview - /// decoding without needing to know about the off-main hop. - func rebuildItems( - inputs: [(MessageDTO, MessageBuildInputs)], - envInputs: EnvInputs, - postApply: (@MainActor () -> Void)? = nil - ) { - // Bump the generation before capturing it so two back-to-back - // rebuilds (link-preview load + URL detection, for example) cannot - // share the same `capturedID`. Without this, an older build that - // finished after a newer one would still pass `setRenderState`'s - // guard and clobber state. Last-scheduled-wins. - renderStateID &+= 1 - let capturedID = renderStateID - let snapshot = inputs - - // Run off the MainActor; `MessageFragmentBuilder` is pure over - // Sendable inputs so the snapshot is safe to consume from a - // detached task. - buildItemsTask?.cancel() - buildItemsTask = Task(priority: .userInitiated) { @concurrent [weak self] in - var built: [MessageItem] = [] - built.reserveCapacity(snapshot.count) - for (message, perMessageInputs) in snapshot { - if Task.isCancelled { return } - built.append(MessageFragmentBuilder.makeItem( - for: message, - inputs: perMessageInputs, - envInputs: envInputs - )) - } - await self?.applyRebuiltItems(built, capturedID: capturedID, postApply: postApply) - } + // Run off the MainActor; `MessageFragmentBuilder` is pure over + // Sendable inputs so the snapshot is safe to consume from a + // detached task. + buildItemsTask?.cancel() + buildItemsTask = Task(priority: .userInitiated) { @concurrent [weak self] in + var built: [MessageItem] = [] + built.reserveCapacity(snapshot.count) + for (message, perMessageInputs) in snapshot { + if Task.isCancelled { return } + built.append(MessageFragmentBuilder.makeItem( + for: message, + inputs: perMessageInputs, + envInputs: envInputs + )) + } + await self?.applyRebuiltItems(built, capturedID: capturedID, postApply: postApply) } + } - /// Reached via `await self?.…` from the `@concurrent` builder task. - /// Inlining as `MainActor.run { guard let self else … }` trips - /// Swift 6 region isolation on the `weak self` transfer. - private func applyRebuiltItems( - _ built: [MessageItem], - capturedID: UInt64, - postApply: (@MainActor () -> Void)? - ) { - guard !Task.isCancelled else { return } - let new = renderState.with(items: built, itemIndexByID: built.indexByID()) - let applied = setRenderState(new, capturedID: capturedID) - if !applied { - // A fresher mutation landed mid-flight. Notify the bound - // view model so it can reassemble per-message inputs and - // call rebuildItems again. - renderStateInvalidated?() - return - } - postApply?() + /// Reached via `await self?.…` from the `@concurrent` builder task. + /// Inlining as `MainActor.run { guard let self else … }` trips + /// Swift 6 region isolation on the `weak self` transfer. + private func applyRebuiltItems( + _ built: [MessageItem], + capturedID: UInt64, + postApply: (@MainActor () -> Void)? + ) { + guard !Task.isCancelled else { return } + let new = renderState.with(items: built, itemIndexByID: built.indexByID()) + let applied = setRenderState(new, capturedID: capturedID) + if !applied { + // A fresher mutation landed mid-flight. Notify the bound + // view model so it can reassemble per-message inputs and + // call rebuildItems again. + renderStateInvalidated?() + return } + postApply?() + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift index 2f9ca3a8..3fb24be0 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift @@ -1,150 +1,148 @@ import Foundation public extension ChatCoordinator { + /// Single chokepoint for ack / retry / fail / heard-repeat / reaction + /// events. Unions IDs into `pendingReloadIDs` and schedules a coalesced + /// load if one is not already in flight. The load takes an atomic + /// snapshot of the buffer, clears it, fetches fresh DTOs from the + /// store, and applies. No event is ever dropped because no event asks + /// "is this in render state?". + func enqueueReload(updatedMessageIDs: Set) { + pendingReloadIDs.formUnion(updatedMessageIDs) + scheduleCoalescedReload() + } - /// Single chokepoint for ack / retry / fail / heard-repeat / reaction - /// events. Unions IDs into `pendingReloadIDs` and schedules a coalesced - /// load if one is not already in flight. The load takes an atomic - /// snapshot of the buffer, clears it, fetches fresh DTOs from the - /// store, and applies. No event is ever dropped because no event asks - /// "is this in render state?". - func enqueueReload(updatedMessageIDs: Set) { - pendingReloadIDs.formUnion(updatedMessageIDs) - scheduleCoalescedReload() - } + /// Convenience for the single-ID hot path. + func enqueueReload(messageID: UUID) { + enqueueReload(updatedMessageIDs: [messageID]) + } - /// Convenience for the single-ID hot path. - func enqueueReload(messageID: UUID) { - enqueueReload(updatedMessageIDs: [messageID]) + private func scheduleCoalescedReload() { + guard !reloadInFlight, !hardResetInFlight else { return } + reloadInFlight = true + coalescedReloadTask = Task { [weak self] in + await self?.coalescedReload() } + } - private func scheduleCoalescedReload() { - guard !reloadInFlight, !hardResetInFlight else { return } - reloadInFlight = true - coalescedReloadTask = Task { [weak self] in - await self?.coalescedReload() - } + /// Drain `pendingReloadIDs` until empty: snapshot, clear, fetch, apply. + /// Re-checks at the top of the loop so events landing during a fetch + /// are reconciled in the next iteration. The `hardResetInFlight` break + /// stops a mid-flight drain from stomping post-hardReset state with + /// stale per-ID updates; the scheduler guard above prevents new + /// Tasks, the break stops the running one. + private func coalescedReload() async { + while !pendingReloadIDs.isEmpty { + if hardResetInFlight { break } + let snapshot = pendingReloadIDs + pendingReloadIDs.removeAll(keepingCapacity: true) + await applyReloadedIDs(snapshot) } + reloadInFlight = false + } - /// Drain `pendingReloadIDs` until empty: snapshot, clear, fetch, apply. - /// Re-checks at the top of the loop so events landing during a fetch - /// are reconciled in the next iteration. The `hardResetInFlight` break - /// stops a mid-flight drain from stomping post-hardReset state with - /// stale per-ID updates; the scheduler guard above prevents new - /// Tasks, the break stops the running one. - private func coalescedReload() async { - while !pendingReloadIDs.isEmpty { - if hardResetInFlight { break } - let snapshot = pendingReloadIDs - pendingReloadIDs.removeAll(keepingCapacity: true) - await applyReloadedIDs(snapshot) + /// Per-ID fetch + in-place update. Routes through `dataStore` bound at + /// construction. A fetch returning nil for an ID that the coordinator + /// still holds is treated as inconsistency and triggers `hardReset`. + /// After each successful refresh, `renderItemRebuilder` rebuilds the + /// corresponding `MessageItem` so ack / retry / fail / heard-repeat / + /// reaction events refresh the rendered bubble without a full + /// `buildItems()` cycle. + private func applyReloadedIDs(_ ids: Set) async { + var inconsistencyDetected = false + var refreshedIDs: [UUID] = [] + for id in ids { + do { + if let fetched = try await dataStore.fetchMessage(id: id) { + guard messagesByID[id] != nil else { continue } + update(messageID: id) { dto in + dto = fetched + } + refreshedIDs.append(id) + } else if messagesByID[id] != nil { + inconsistencyDetected = true + logger.warning("applyReloadedIDs: fetch returned nil for known id \(id, privacy: .public)") } - reloadInFlight = false + } catch { + logger.warning("applyReloadedIDs fetch failed for \(id, privacy: .public): \(String(describing: error))") + } } - - /// Per-ID fetch + in-place update. Routes through `dataStore` bound at - /// construction. A fetch returning nil for an ID that the coordinator - /// still holds is treated as inconsistency and triggers `hardReset`. - /// After each successful refresh, `renderItemRebuilder` rebuilds the - /// corresponding `MessageItem` so ack / retry / fail / heard-repeat / - /// reaction events refresh the rendered bubble without a full - /// `buildItems()` cycle. - private func applyReloadedIDs(_ ids: Set) async { - var inconsistencyDetected = false - var refreshedIDs: [UUID] = [] - for id in ids { - do { - if let fetched = try await dataStore.fetchMessage(id: id) { - guard messagesByID[id] != nil else { continue } - update(messageID: id) { dto in - dto = fetched - } - refreshedIDs.append(id) - } else if messagesByID[id] != nil { - inconsistencyDetected = true - logger.warning("applyReloadedIDs: fetch returned nil for known id \(id, privacy: .public)") - } - } catch { - logger.warning("applyReloadedIDs fetch failed for \(id, privacy: .public): \(String(describing: error))") - } - } - if let rebuilder = renderItemRebuilder { - for id in refreshedIDs { - rebuilder(id) - } - } - if inconsistencyDetected { - hardReset(reason: "fetch returned nil for in-memory message") - } + if let rebuilder = renderItemRebuilder { + for id in refreshedIDs { + rebuilder(id) + } + } + if inconsistencyDetected { + hardReset(reason: "fetch returned nil for in-memory message") } + } - /// Fail-safe: drop all state and re-fetch from the data store. - /// Triggered when an internal invariant trips — currently only from - /// `applyReloadedIDs` when an expected fetch returns nil for a message - /// the coordinator still holds. - /// - /// Call-site invariant: `hardReset` is intended to be invoked from - /// `applyReloadedIDs`, which is itself running inside the in-flight - /// `coalescedReload` Task. The `hardResetInFlight` flag plus the - /// `coalescedReload` while-loop break ensure that the calling Task - /// exits without draining `pendingReloadIDs` after the refetch. A - /// future caller invoking `hardReset` from outside the coalescedReload - /// loop (a button, a remote-reset event, etc.) must either route - /// through the same `applyReloadedIDs` chokepoint or await the active - /// reload Task first — otherwise an in-flight `applyReloadedIDs` can - /// resume after `replaceAll(fresh)` and stomp the freshly-loaded - /// state with stale per-ID `update(messageID:)` writes. - func hardReset(reason: String) { - logger.warning("ChatCoordinator hardReset: \(reason, privacy: .public)") - hardResetInFlight = true - let id = conversationID - let dataStore = self.dataStore - hardResetTask = Task { [weak self] in - defer { - // Single cleanup site converges success and error paths. - // Buffered IDs from the hardReset window are guaranteed to - // drain. The Task body is @MainActor-isolated by - // ChatCoordinator's @MainActor attribute, so defer fires on - // the main actor — no nested Task hop needed. - self?.hardResetInFlight = false - if let self, !self.pendingReloadIDs.isEmpty { - self.scheduleCoalescedReload() - } - } - do { - let fresh: [MessageDTO] - switch id.conversation { - case .dm(let contactID): - fresh = try await dataStore.fetchMessages( - contactID: contactID, - limit: Self.pageSize, - offset: 0 - ) - case .channel(let channelIndex): - fresh = try await dataStore.fetchMessages( - radioID: id.radioID, - channelIndex: channelIndex, - limit: Self.pageSize, - offset: 0 - ) - } - guard let self else { return } - self.replaceAll(fresh) - self.renderStateInvalidated?() - } catch { - self?.logger.error("hardReset refetch failed: \(String(describing: error))") - } + /// Fail-safe: drop all state and re-fetch from the data store. + /// Triggered when an internal invariant trips — currently only from + /// `applyReloadedIDs` when an expected fetch returns nil for a message + /// the coordinator still holds. + /// + /// Call-site invariant: `hardReset` is intended to be invoked from + /// `applyReloadedIDs`, which is itself running inside the in-flight + /// `coalescedReload` Task. The `hardResetInFlight` flag plus the + /// `coalescedReload` while-loop break ensure that the calling Task + /// exits without draining `pendingReloadIDs` after the refetch. A + /// future caller invoking `hardReset` from outside the coalescedReload + /// loop (a button, a remote-reset event, etc.) must either route + /// through the same `applyReloadedIDs` chokepoint or await the active + /// reload Task first — otherwise an in-flight `applyReloadedIDs` can + /// resume after `replaceAll(fresh)` and stomp the freshly-loaded + /// state with stale per-ID `update(messageID:)` writes. + func hardReset(reason: String) { + logger.warning("ChatCoordinator hardReset: \(reason, privacy: .public)") + hardResetInFlight = true + let id = conversationID + let dataStore = dataStore + hardResetTask = Task { [weak self] in + defer { + // Single cleanup site converges success and error paths. + // Buffered IDs from the hardReset window are guaranteed to + // drain. The Task body is @MainActor-isolated by + // ChatCoordinator's @MainActor attribute, so defer fires on + // the main actor — no nested Task hop needed. + self?.hardResetInFlight = false + if let self, !self.pendingReloadIDs.isEmpty { + self.scheduleCoalescedReload() } + } + do { + let fresh: [MessageDTO] = switch id.conversation { + case let .dm(contactID): + try await dataStore.fetchMessages( + contactID: contactID, + limit: Self.pageSize, + offset: 0 + ) + case let .channel(channelIndex): + try await dataStore.fetchMessages( + radioID: id.radioID, + channelIndex: channelIndex, + limit: Self.pageSize, + offset: 0 + ) + } + guard let self else { return } + replaceAll(fresh) + renderStateInvalidated?() + } catch { + self?.logger.error("hardReset refetch failed: \(String(describing: error))") + } } + } - /// Cancel any in-flight maintenance Tasks owned by this coordinator. - /// Called from `ChatCoordinatorRegistry.tearDown` before the registry - /// drops its strong references so suspended drain loops do not keep - /// the coordinator (and its captured `dataStore`) alive past the - /// container's lifetime. - func cancelInFlight() { - buildItemsTask?.cancel() - coalescedReloadTask?.cancel() - hardResetTask?.cancel() - } + /// Cancel any in-flight maintenance Tasks owned by this coordinator. + /// Called from `ChatCoordinatorRegistry.tearDown` before the registry + /// drops its strong references so suspended drain loops do not keep + /// the coordinator (and its captured `dataStore`) alive past the + /// container's lifetime. + func cancelInFlight() { + buildItemsTask?.cancel() + coalescedReloadTask?.cancel() + hardResetTask?.cancel() + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift index 7bba7b3e..3dfe25a3 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift @@ -14,139 +14,138 @@ import OSLog @Observable @MainActor public final class ChatCoordinator { - - /// Number of messages fetched per pagination page. Used by `hardReset` - /// to refetch the most recent slice; consumed by `ChatViewModel` for - /// initial-load sizing so post-reset renders match the normal load. - public static let pageSize: Int = 50 - - public let conversationID: ChatConversationID - - @ObservationIgnored - let logger = Logger(subsystem: "com.mc1", category: "ChatCoordinator") - - /// Canonical loaded-messages list. Mutated only inside this class; - /// every reader either reads `messagesByID` (O(1) lookup) or - /// `renderState.items` (the rendered timeline). - public internal(set) var messages: [MessageDTO] = [] - - /// O(1) lookup keyed by message ID. The guard at every append / - /// update / event-handler site reads this, never `renderState`. - /// - /// Pairing invariant: every `messagesByID` mutation must trigger a - /// downstream `renderState.items` change — typically via the - /// `renderStateID` bump + off-main `rebuildItems` → `setRenderState` - /// apply chain on `ChatCoordinator`. View-body callers reach this map - /// only transitively through observation-tracked `renderState.items`; - /// cells re-render when items change. Any mutation that bypasses the - /// standard mutate-then-renderStateID-bump flow must manually - /// invalidate `renderState.items`, or drop `@ObservationIgnored` here. - @ObservationIgnored - public internal(set) var messagesByID: [UUID: MessageDTO] = [:] - - /// Immutable timeline snapshot rendered by the chat table. Rebuilt - /// from `messages` by the off-main builder; assigned on main only. - public internal(set) var renderState: ChatRenderState = .empty - - /// Monotonic counter incremented on every mutation of `messages` or - /// every assignment to `renderState`. The off-main build captures - /// this at start and discards its result if the counter has advanced - /// when it returns to main. Companion `urlDetectionGeneration` on the - /// view model gates `cachedURLs` writes from the URL-detection writer. - /// Consumed by the off-main builder and internal mutation tracking - /// only — no view body reads it. - @ObservationIgnored - public internal(set) var renderStateID: UInt64 = 0 - - /// IDs accumulated since the last load cycle. The next coalesced load - /// drains this set atomically. `enqueueReload(updatedMessageIDs:)` is - /// the single chokepoint for ack / retry / fail / heard-repeat / - /// reaction events. `@ObservationIgnored` because no view reads this - /// set directly — readers consume `renderState` after the load cycle - /// applies — and the registrar bookkeeping on every burst-event union - /// would be wasted work. - @ObservationIgnored - var pendingReloadIDs: Set = [] - - /// Whether a load cycle is currently in flight. The chase-the-counter - /// pattern in `coalescedReload` consults this. Also - /// `@ObservationIgnored` for the same reason as `pendingReloadIDs`. - @ObservationIgnored - var reloadInFlight = false - - /// Gates concurrent loads while a `hardReset` is mid-flight. The - /// scheduler guard prevents new `coalescedReload` Tasks from starting - /// after a hardReset begins; the loop-top check in `coalescedReload` - /// stops the already-running Task from draining `pendingReloadIDs` and - /// stomping the freshly-refetched post-hardReset state with stale - /// per-ID `update(messageID:)` writes. Cleared on hardReset completion - /// via a `defer`-driven cleanup that also schedules any buffered IDs. - @ObservationIgnored - var hardResetInFlight = false - - /// In-flight off-main batch build. Cancelled before each new - /// `rebuildItems` call so successive rebuilds do not pile up concurrent - /// work. Mirrors the cancel-and-reassign pattern used by URL detection. - @ObservationIgnored - public internal(set) var buildItemsTask: Task? - - /// In-flight coalesced-reload drain Task. Stored so the registry can - /// cancel it on `tearDown`, releasing the coordinator and any captured - /// services in flight. The `reloadInFlight` flag still serves a separate - /// concurrency purpose (break-the-running-loop semantics inside - /// `coalescedReload`); the Task handle is purely for teardown. - @ObservationIgnored - public internal(set) var coalescedReloadTask: Task? - - /// In-flight hardReset refetch Task. See `coalescedReloadTask` for the - /// teardown rationale. - @ObservationIgnored - public internal(set) var hardResetTask: Task? - - /// Data store used by `applyReloadedIDs` for per-ID fetches. Bound at - /// construction by the registry. `@ObservationIgnored` — never read - /// from a view body. - @ObservationIgnored - let dataStore: PersistenceStore - - /// Per-ID render-item rebuild hook invoked by `applyReloadedIDs` after a - /// successful DTO refresh. The bound `ChatViewModel` rebuilds the - /// corresponding `MessageItem` using its main-actor-only inputs - /// (preview state, cached URLs, decoded images). Stays `nil` when no - /// view model is bound — `applyReloadedIDs` then refreshes DTOs without - /// rebuilding render items, which matches headless and test usage. - /// `@ObservationIgnored` because no view body reads this closure. - @ObservationIgnored - public var renderItemRebuilder: (@MainActor (UUID) -> Void)? - - /// Fires when the coordinator's `renderState.items` no longer reflects - /// canonical `messages` and the bound view model must reassemble per-message - /// inputs (preview state, cached URLs, decoded images live on main and are - /// owned by the VM) before `rebuildItems` can be called again. Triggered - /// when a fresher mutation lands mid-flight and `setRenderState` rejects - /// the stale rebuild, and after `hardReset`'s `replaceAll`. - @ObservationIgnored - public var renderStateInvalidated: (@MainActor () -> Void)? - - init( - conversationID: ChatConversationID, - dataStore: PersistenceStore - ) { - self.conversationID = conversationID - self.dataStore = dataStore - } - - #if DEBUG + /// Number of messages fetched per pagination page. Used by `hardReset` + /// to refetch the most recent slice; consumed by `ChatViewModel` for + /// initial-load sizing so post-reset renders match the normal load. + public static let pageSize: Int = 50 + + public let conversationID: ChatConversationID + + @ObservationIgnored + let logger = Logger(subsystem: "com.mc1", category: "ChatCoordinator") + + /// Canonical loaded-messages list. Mutated only inside this class; + /// every reader either reads `messagesByID` (O(1) lookup) or + /// `renderState.items` (the rendered timeline). + public internal(set) var messages: [MessageDTO] = [] + + /// O(1) lookup keyed by message ID. The guard at every append / + /// update / event-handler site reads this, never `renderState`. + /// + /// Pairing invariant: every `messagesByID` mutation must trigger a + /// downstream `renderState.items` change — typically via the + /// `renderStateID` bump + off-main `rebuildItems` → `setRenderState` + /// apply chain on `ChatCoordinator`. View-body callers reach this map + /// only transitively through observation-tracked `renderState.items`; + /// cells re-render when items change. Any mutation that bypasses the + /// standard mutate-then-renderStateID-bump flow must manually + /// invalidate `renderState.items`, or drop `@ObservationIgnored` here. + @ObservationIgnored + public internal(set) var messagesByID: [UUID: MessageDTO] = [:] + + /// Immutable timeline snapshot rendered by the chat table. Rebuilt + /// from `messages` by the off-main builder; assigned on main only. + public internal(set) var renderState: ChatRenderState = .empty + + /// Monotonic counter incremented on every mutation of `messages` or + /// every assignment to `renderState`. The off-main build captures + /// this at start and discards its result if the counter has advanced + /// when it returns to main. Companion `urlDetectionGeneration` on the + /// view model gates `cachedURLs` writes from the URL-detection writer. + /// Consumed by the off-main builder and internal mutation tracking + /// only — no view body reads it. + @ObservationIgnored + public internal(set) var renderStateID: UInt64 = 0 + + /// IDs accumulated since the last load cycle. The next coalesced load + /// drains this set atomically. `enqueueReload(updatedMessageIDs:)` is + /// the single chokepoint for ack / retry / fail / heard-repeat / + /// reaction events. `@ObservationIgnored` because no view reads this + /// set directly — readers consume `renderState` after the load cycle + /// applies — and the registrar bookkeeping on every burst-event union + /// would be wasted work. + @ObservationIgnored + var pendingReloadIDs: Set = [] + + /// Whether a load cycle is currently in flight. The chase-the-counter + /// pattern in `coalescedReload` consults this. Also + /// `@ObservationIgnored` for the same reason as `pendingReloadIDs`. + @ObservationIgnored + var reloadInFlight = false + + /// Gates concurrent loads while a `hardReset` is mid-flight. The + /// scheduler guard prevents new `coalescedReload` Tasks from starting + /// after a hardReset begins; the loop-top check in `coalescedReload` + /// stops the already-running Task from draining `pendingReloadIDs` and + /// stomping the freshly-refetched post-hardReset state with stale + /// per-ID `update(messageID:)` writes. Cleared on hardReset completion + /// via a `defer`-driven cleanup that also schedules any buffered IDs. + @ObservationIgnored + var hardResetInFlight = false + + /// In-flight off-main batch build. Cancelled before each new + /// `rebuildItems` call so successive rebuilds do not pile up concurrent + /// work. Mirrors the cancel-and-reassign pattern used by URL detection. + @ObservationIgnored + public internal(set) var buildItemsTask: Task? + + /// In-flight coalesced-reload drain Task. Stored so the registry can + /// cancel it on `tearDown`, releasing the coordinator and any captured + /// services in flight. The `reloadInFlight` flag still serves a separate + /// concurrency purpose (break-the-running-loop semantics inside + /// `coalescedReload`); the Task handle is purely for teardown. + @ObservationIgnored + public internal(set) var coalescedReloadTask: Task? + + /// In-flight hardReset refetch Task. See `coalescedReloadTask` for the + /// teardown rationale. + @ObservationIgnored + public internal(set) var hardResetTask: Task? + + /// Data store used by `applyReloadedIDs` for per-ID fetches. Bound at + /// construction by the registry. `@ObservationIgnored` — never read + /// from a view body. + @ObservationIgnored + let dataStore: PersistenceStore + + /// Per-ID render-item rebuild hook invoked by `applyReloadedIDs` after a + /// successful DTO refresh. The bound `ChatViewModel` rebuilds the + /// corresponding `MessageItem` using its main-actor-only inputs + /// (preview state, cached URLs, decoded images). Stays `nil` when no + /// view model is bound — `applyReloadedIDs` then refreshes DTOs without + /// rebuilding render items, which matches headless and test usage. + /// `@ObservationIgnored` because no view body reads this closure. + @ObservationIgnored + public var renderItemRebuilder: (@MainActor (UUID) -> Void)? + + /// Fires when the coordinator's `renderState.items` no longer reflects + /// canonical `messages` and the bound view model must reassemble per-message + /// inputs (preview state, cached URLs, decoded images live on main and are + /// owned by the VM) before `rebuildItems` can be called again. Triggered + /// when a fresher mutation lands mid-flight and `setRenderState` rejects + /// the stale rebuild, and after `hardReset`'s `replaceAll`. + @ObservationIgnored + public var renderStateInvalidated: (@MainActor () -> Void)? + + init( + conversationID: ChatConversationID, + dataStore: PersistenceStore + ) { + self.conversationID = conversationID + self.dataStore = dataStore + } + + #if DEBUG /// Test-only factory that builds a standalone coordinator backed by /// an in-memory `PersistenceStore`. Lets unit tests exercise mutation /// behaviour without bringing up a `ServiceContainer`. public static func makeForTesting( - conversationID: ChatConversationID = .dm(radioID: UUID(), contactID: UUID()) + conversationID: ChatConversationID = .dm(radioID: UUID(), contactID: UUID()) ) -> ChatCoordinator { - // swiftlint:disable:next force_try - let container = try! PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - return ChatCoordinator(conversationID: conversationID, dataStore: store) + // swiftlint:disable:next force_try + let container = try! PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + return ChatCoordinator(conversationID: conversationID, dataStore: store) } - #endif + #endif } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift index 1c6ecfdc..b86281df 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift @@ -15,53 +15,52 @@ import Foundation /// no view should observe its internal entries. @MainActor public final class ChatCoordinatorRegistry { + public static let defaultCapacity = 16 - public static let defaultCapacity = 16 + private var entries: [(id: ChatConversationID, coordinator: ChatCoordinator)] = [] + private let capacity: Int + private(set) var dataStore: PersistenceStore - private var entries: [(id: ChatConversationID, coordinator: ChatCoordinator)] = [] - private let capacity: Int - private(set) var dataStore: PersistenceStore + public init( + dataStore: PersistenceStore, + capacity: Int = ChatCoordinatorRegistry.defaultCapacity + ) { + self.dataStore = dataStore + self.capacity = capacity + } - public init( - dataStore: PersistenceStore, - capacity: Int = ChatCoordinatorRegistry.defaultCapacity - ) { - self.dataStore = dataStore - self.capacity = capacity + /// Returns the coordinator for the given conversation, creating one on + /// first request. Repeat reads promote the entry to most-recently-used. + /// Two view models pointing at the same conversation share one coordinator. + public func coordinator(for id: ChatConversationID) -> ChatCoordinator { + if let index = entries.firstIndex(where: { $0.id == id }) { + let entry = entries.remove(at: index) + entries.append(entry) + return entry.coordinator } - - /// Returns the coordinator for the given conversation, creating one on - /// first request. Repeat reads promote the entry to most-recently-used. - /// Two view models pointing at the same conversation share one coordinator. - public func coordinator(for id: ChatConversationID) -> ChatCoordinator { - if let index = entries.firstIndex(where: { $0.id == id }) { - let entry = entries.remove(at: index) - entries.append(entry) - return entry.coordinator - } - let coordinator = ChatCoordinator( - conversationID: id, - dataStore: dataStore - ) - entries.append((id, coordinator)) - while entries.count > capacity { - let evicted = entries.removeFirst() - evicted.coordinator.cancelInFlight() - } - return coordinator + let coordinator = ChatCoordinator( + conversationID: id, + dataStore: dataStore + ) + entries.append((id, coordinator)) + while entries.count > capacity { + let evicted = entries.removeFirst() + evicted.coordinator.cancelInFlight() } + return coordinator + } - public func rebind(dataStore: PersistenceStore) { - tearDown() - self.dataStore = dataStore - } + public func rebind(dataStore: PersistenceStore) { + tearDown() + self.dataStore = dataStore + } - /// Cancel in-flight builds and drain Tasks on every coordinator and - /// drop all entries. - public func tearDown() { - for entry in entries { - entry.coordinator.cancelInFlight() - } - entries.removeAll() + /// Cancel in-flight builds and drain Tasks on every coordinator and + /// drop all entries. + public func tearDown() { + for entry in entries { + entry.coordinator.cancelInFlight() } + entries.removeAll() + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChatSendQueueService.swift b/MC1Services/Sources/MC1Services/Services/ChatSendQueueService.swift index bd64b117..1fdb6cbe 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatSendQueueService.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatSendQueueService.swift @@ -4,46 +4,46 @@ import os // MARK: - Chat Send Queue Service Errors public enum ChatSendQueueServiceError: Error, Sendable, LocalizedError { - case persistFailed(underlying: Error) - case notConnected - - public var errorDescription: String? { - switch self { - case .persistFailed(let underlying): - return "Failed to queue message for sending: \(underlying.localizedDescription)" - case .notConnected: - return "Not connected to device." - } + case persistFailed(underlying: Error) + case notConnected + + public var errorDescription: String? { + switch self { + case let .persistFailed(underlying): + "Failed to queue message for sending: \(underlying.localizedDescription)" + case .notConnected: + "Not connected to device." } + } } /// Retry timing for `ChatSendQueueService`. -struct ChatSendQueueConfig: Sendable { - /// Maximum time to wait for a transport-open trigger before - /// silently re-attempting the send. - let transportWaitTimeout: TimeInterval - - /// Number of channel-drain attempts before the queue spends a BLE - /// round-trip to disambiguate transient NOT_FOUND (pool exhaustion) - /// from terminal NOT_FOUND (channel deleted on the device). - let disambiguateAfterAttempts: Int - - /// Backstop cap on consecutive `fetchChannel` throws. After this many - /// the channel drain treats NOT_FOUND as terminal so the user sees - /// `.failed` and can manually retry. - let maxConsecutiveFetchChannelFailures: Int - - init( - transportWaitTimeout: TimeInterval = 30, - disambiguateAfterAttempts: Int = 3, - maxConsecutiveFetchChannelFailures: Int = 16 - ) { - self.transportWaitTimeout = transportWaitTimeout - self.disambiguateAfterAttempts = disambiguateAfterAttempts - self.maxConsecutiveFetchChannelFailures = maxConsecutiveFetchChannelFailures - } - - static let `default` = ChatSendQueueConfig() +struct ChatSendQueueConfig { + /// Maximum time to wait for a transport-open trigger before + /// silently re-attempting the send. + let transportWaitTimeout: TimeInterval + + /// Number of channel-drain attempts before the queue spends a BLE + /// round-trip to disambiguate transient NOT_FOUND (pool exhaustion) + /// from terminal NOT_FOUND (channel deleted on the device). + let disambiguateAfterAttempts: Int + + /// Backstop cap on consecutive `fetchChannel` throws. After this many + /// the channel drain treats NOT_FOUND as terminal so the user sees + /// `.failed` and can manually retry. + let maxConsecutiveFetchChannelFailures: Int + + init( + transportWaitTimeout: TimeInterval = 30, + disambiguateAfterAttempts: Int = 3, + maxConsecutiveFetchChannelFailures: Int = 16 + ) { + self.transportWaitTimeout = transportWaitTimeout + self.disambiguateAfterAttempts = disambiguateAfterAttempts + self.maxConsecutiveFetchChannelFailures = maxConsecutiveFetchChannelFailures + } + + static let `default` = ChatSendQueueConfig() } /// Owns the DM and channel send queues for a connection. Replaces the @@ -67,658 +67,657 @@ struct ChatSendQueueConfig: Sendable { /// failed send needs it. @MainActor public final class ChatSendQueueService { - - let radioID: UUID - private let config: ChatSendQueueConfig - private let dataStore: any MessagePersisting & ContactPersisting - private let messageService: MessageService - private let channelService: ChannelService - private let reactionService: ReactionService - private let triggers: BLETransportOpenedSignal - private let channelFetchFailureCounter = FailureCounter() - private let logger = PersistentLogger(subsystem: "com.mc1", category: "ChatSendQueueService") - private let osLogger = Logger(subsystem: "com.mc1", category: "ChatSendQueueService") - - private let dmQueue: SendQueue - private let channelQueue: SendQueue - - private var hasHydrated = false - - /// Task consuming the connection-state stream installed by - /// `observeConnectionState`. Cancelled in `shutdown()`. - private var connectionStateTask: Task? - - // swiftlint:disable:next function_body_length - init( - radioID: UUID, - dataStore: any MessagePersisting & ContactPersisting, - messageService: MessageService, - channelService: ChannelService, - reactionService: ReactionService, - config: ChatSendQueueConfig = .default - ) { - self.radioID = radioID - self.config = config - self.dataStore = dataStore - self.messageService = messageService - self.channelService = channelService - self.reactionService = reactionService - self.triggers = BLETransportOpenedSignal() - - // Capture by value into closures; queues outlive references but - // are torn down with the service. - let triggers = self.triggers - let dataStoreRef = dataStore - let messageServiceRef = messageService - let channelServiceRef = channelService - let reactionServiceRef = reactionService - let loggerRef = logger - let osLoggerRef = osLogger - let failureCounter = channelFetchFailureCounter - let configRef = config - - self.dmQueue = SendQueue( - send: { envelope in - // Outer catch: the queue-routed catch sites in MessageService - // do not broadcast `.failed` themselves; the helper - // `failMessageAndRethrow` only writes the DB state and - // rethrows. Any non-`CancellationError` escape from this - // closure is a terminal failure for the envelope, so the - // queue calls `notifyMessageFailed` exactly once before - // letting `SendQueue.drain` propagate the error to `onError`. - // Park-and-requeue branches throw `CancellationError`, hit - // the inner re-throw, and bypass the broadcast so a - // transient error does not produce a `.failed`-then-`.pending` - // flicker on the UI. - do { - let contact: ContactDTO - switch await ChatSendQueueService.classifyRead({ try await dataStoreRef.fetchContact(id: envelope.contactID) }) { - case .found(let value): - contact = value - case .missing: - loggerRef.info("DM send queue: contact \(envelope.contactID) was deleted; dropping envelope") - try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) - try? await dataStoreRef.updateMessageStatus(id: envelope.messageID, status: .failed) - return - case .transient(let error): - loggerRef.warning("DM drain fetchContact transient error: \(String(describing: error)); parking envelope") - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "DM", - timeout: configRef.transportWaitTimeout - ) - } - - // Pre-send gate + attemptCount bump. Persisted attemptCount - // becomes the source of truth for preserveTimestamp. Bump - // completes (and modelContext.save() commits) before any - // wire-affecting work. - let preserveTimestamp: Bool - do { - guard let result = try await ChatSendQueueService.preflightAndBump( - dataStore: dataStoreRef, - messageID: envelope.messageID, - kind: "DM", - logger: loggerRef, - osLogger: osLoggerRef - ) else { return } - preserveTimestamp = result.preserveTimestamp - } catch { - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "DM", - timeout: configRef.transportWaitTimeout - ) - } - - do { - if envelope.isResend { - _ = try await messageServiceRef.resendDirectMessage( - messageID: envelope.messageID, - to: contact, - preserveTimestamp: preserveTimestamp - ) - } else { - _ = try await messageServiceRef.sendPendingDirectMessage( - messageID: envelope.messageID, - to: contact, - preserveTimestamp: preserveTimestamp - ) - } - try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) - osLoggerRef.debug("DM drain success messageID=\(envelope.messageID)") - // Success: clear any armed trigger now that the row - // is gone. A subsequent failed send for a different - // envelope re-arms on its own when the next - // transport-open fires. - await triggers.clear() - } catch is CancellationError { - throw CancellationError() - } catch { - guard ChatSendQueueService.isTransientDirectMessageError(error) else { - loggerRef.info("DM drain terminal messageID=\(envelope.messageID) error=\(String(describing: error))") - throw error - } - loggerRef.info("DM drain transient messageID=\(envelope.messageID) error=\(String(describing: error))") - // Remap the .failed write that failMessageAndRethrow just - // made back to .pending so the bubble doesn't show - // "Failed" while the queue is silently parked. Duplicate - // prevention on the next pass is via preserveTimestamp, - // not via any boundary check. - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "DM", - timeout: configRef.transportWaitTimeout - ) - } - } catch let cancellation as CancellationError { - throw cancellation - } catch { - await messageServiceRef.notifyMessageFailed(messageID: envelope.messageID) - throw error - } - }, - onError: { _, envelope in - // Permanent error path only — transient errors take the - // wait-and-retry branch above and never reach onError. - try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) - }, - onDrain: { lastError in - if let lastError { - loggerRef.error("DM queue drained with error: \(String(describing: lastError))") - } + let radioID: UUID + private let config: ChatSendQueueConfig + private let dataStore: any MessagePersisting & ContactPersisting + private let messageService: MessageService + private let channelService: ChannelService + private let reactionService: ReactionService + private let triggers: BLETransportOpenedSignal + private let channelFetchFailureCounter = FailureCounter() + private let logger = PersistentLogger(subsystem: "com.mc1", category: "ChatSendQueueService") + private let osLogger = Logger(subsystem: "com.mc1", category: "ChatSendQueueService") + + private let dmQueue: SendQueue + private let channelQueue: SendQueue + + private var hasHydrated = false + + /// Task consuming the connection-state stream installed by + /// `observeConnectionState`. Cancelled in `shutdown()`. + private var connectionStateTask: Task? + + // swiftlint:disable:next function_body_length + init( + radioID: UUID, + dataStore: any MessagePersisting & ContactPersisting, + messageService: MessageService, + channelService: ChannelService, + reactionService: ReactionService, + config: ChatSendQueueConfig = .default + ) { + self.radioID = radioID + self.config = config + self.dataStore = dataStore + self.messageService = messageService + self.channelService = channelService + self.reactionService = reactionService + self.triggers = BLETransportOpenedSignal() + + // Capture by value into closures; queues outlive references but + // are torn down with the service. + let triggers = triggers + let dataStoreRef = dataStore + let messageServiceRef = messageService + let channelServiceRef = channelService + let reactionServiceRef = reactionService + let loggerRef = logger + let osLoggerRef = osLogger + let failureCounter = channelFetchFailureCounter + let configRef = config + + dmQueue = SendQueue( + send: { envelope in + // Outer catch: the queue-routed catch sites in MessageService + // do not broadcast `.failed` themselves; the helper + // `failMessageAndRethrow` only writes the DB state and + // rethrows. Any non-`CancellationError` escape from this + // closure is a terminal failure for the envelope, so the + // queue calls `notifyMessageFailed` exactly once before + // letting `SendQueue.drain` propagate the error to `onError`. + // Park-and-requeue branches throw `CancellationError`, hit + // the inner re-throw, and bypass the broadcast so a + // transient error does not produce a `.failed`-then-`.pending` + // flicker on the UI. + do { + let contact: ContactDTO + switch await ChatSendQueueService.classifyRead({ try await dataStoreRef.fetchContact(id: envelope.contactID) }) { + case let .found(value): + contact = value + case .missing: + loggerRef.info("DM send queue: contact \(envelope.contactID) was deleted; dropping envelope") + try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) + try? await dataStoreRef.updateMessageStatus(id: envelope.messageID, status: .failed) + return + case let .transient(error): + loggerRef.warning("DM drain fetchContact transient error: \(String(describing: error)); parking envelope") + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "DM", + timeout: configRef.transportWaitTimeout + ) + } + + // Pre-send gate + attemptCount bump. Persisted attemptCount + // becomes the source of truth for preserveTimestamp. Bump + // completes (and modelContext.save() commits) before any + // wire-affecting work. + let preserveTimestamp: Bool + do { + guard let result = try await ChatSendQueueService.preflightAndBump( + dataStore: dataStoreRef, + messageID: envelope.messageID, + kind: "DM", + logger: loggerRef, + osLogger: osLoggerRef + ) else { return } + preserveTimestamp = result.preserveTimestamp + } catch { + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "DM", + timeout: configRef.transportWaitTimeout + ) + } + + do { + if envelope.isResend { + _ = try await messageServiceRef.resendDirectMessage( + messageID: envelope.messageID, + to: contact, + preserveTimestamp: preserveTimestamp + ) + } else { + _ = try await messageServiceRef.sendPendingDirectMessage( + messageID: envelope.messageID, + to: contact, + preserveTimestamp: preserveTimestamp + ) } - ) - - self.channelQueue = SendQueue( - send: { envelope in - // Outer catch: see DM closure for rationale. Park-and-requeue - // branches throw `CancellationError`; any other escape is a - // terminal failure for the envelope and calls - // `notifyMessageFailed` exactly once. - do { - // Pre-send gate + attemptCount bump; see DM closure for rationale. - let postBumpCount: Int - let preserveTimestamp: Bool - do { - guard let result = try await ChatSendQueueService.preflightAndBump( - dataStore: dataStoreRef, - messageID: envelope.messageID, - kind: "channel", - logger: loggerRef, - osLogger: osLoggerRef - ) else { return } - postBumpCount = result.postBumpCount - preserveTimestamp = result.preserveTimestamp - } catch { - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "channel", - timeout: configRef.transportWaitTimeout - ) - } - - do { - // resendChannelMessage stamps a fresh wire timestamp so the - // mesh dedup ring treats the retry as a new broadcast. - // Reactions hash off that exact timestamp via - // SHA256(text || timestamp.littleEndian), so the resent - // packet must be indexed under the post-resend value, not - // the pre-resend value captured at enqueue time. - let indexTimestamp: UInt32 - if envelope.isResend { - indexTimestamp = try await messageServiceRef.resendChannelMessage( - messageID: envelope.messageID, - preserveTimestamp: preserveTimestamp - ) - } else { - try await messageServiceRef.sendPendingChannelMessage(messageID: envelope.messageID) - indexTimestamp = envelope.messageTimestamp - } - if let nodeName = envelope.localNodeName { - _ = await reactionServiceRef.indexMessage( - id: envelope.messageID, - channelIndex: envelope.channelIndex, - senderName: nodeName, - text: envelope.messageText, - timestamp: indexTimestamp - ) - } - try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) - osLoggerRef.debug("channel drain success messageID=\(envelope.messageID)") - await failureCounter.reset(for: envelope.messageID) - await triggers.clear() - } catch is CancellationError { - throw CancellationError() - } catch { - guard ChatSendQueueService.isTransientChannelMessageError(error) else { - loggerRef.info("channel drain terminal messageID=\(envelope.messageID) error=\(String(describing: error))") - throw error - } - if ChatSendQueueService.isChannelMessageNotFound(error) { - // Disambiguate pool exhaustion (transient) from a stale - // channel index (terminal) by refreshing the radio's - // view of the channel. `ChannelService.fetchChannel(index:)` - // reads from the device, so a nil result means the radio - // agrees the channel is gone (terminal). - // - // Gate on disambiguateAfterAttempts so the common - // pool-exhaustion burst (1-2 NOT_FOUNDs in a row) parks - // without an extra BLE round-trip; only persistent - // NOT_FOUND warrants the fetchChannel cost. - if postBumpCount < configRef.disambiguateAfterAttempts { - loggerRef.info("channel drain NOT_FOUND below disambiguate threshold messageID=\(envelope.messageID) postBumpCount=\(postBumpCount); parking envelope") - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "channel", - timeout: configRef.transportWaitTimeout - ) - } - - let stillExists: Bool - do { - stillExists = try await channelServiceRef.fetchChannel(index: envelope.channelIndex) != nil - await failureCounter.reset(for: envelope.messageID) - } catch { - let failures = await failureCounter.increment(for: envelope.messageID) - if failures >= configRef.maxConsecutiveFetchChannelFailures { - loggerRef.warning("Channel drain: fetchChannel persistently failing (\(failures) consecutive) for messageID=\(envelope.messageID); marking terminal") - await failureCounter.reset(for: envelope.messageID) - throw error - } - loggerRef.info("channel drain NOT_FOUND followup fetchChannel failed: \(String(describing: error)); parking envelope (\(failures)/\(configRef.maxConsecutiveFetchChannelFailures))") - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "channel", - timeout: configRef.transportWaitTimeout - ) - } - if !stillExists { - loggerRef.warning("Channel drain: NOT_FOUND confirmed by fetchChannel for messageID=\(envelope.messageID); treating as terminal (channel deleted)") - await failureCounter.reset(for: envelope.messageID) - throw error - } - // Channel still exists per device — NOT_FOUND was pool - // exhaustion. Park on the transport-open trigger. The - // effective per-attempt cadence under sustained - // exhaustion is ~37s (3.5s withPoolBackoff in-loop + - // 3.5s fetchChannel disambiguation + 30s - // transportWaitTimeout park). Transport-open only fires - // on entering `.ready`, so under a healthy connection - // the 30s park is what bounds the retry rate. - } - loggerRef.info("channel drain transient messageID=\(envelope.messageID) error=\(String(describing: error))") - _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) - try await ChatSendQueueService.parkAndCancel( - triggers: triggers, - logger: loggerRef, - messageID: envelope.messageID, - kind: "channel", - timeout: configRef.transportWaitTimeout - ) - } - } catch let cancellation as CancellationError { - throw cancellation - } catch { - await messageServiceRef.notifyMessageFailed(messageID: envelope.messageID) - throw error - } - }, - onError: { _, envelope in - try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) - }, - onDrain: { lastError in - if let lastError { - loggerRef.error("Channel queue drained with error: \(String(describing: lastError))") - } + try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) + osLoggerRef.debug("DM drain success messageID=\(envelope.messageID)") + // Success: clear any armed trigger now that the row + // is gone. A subsequent failed send for a different + // envelope re-arms on its own when the next + // transport-open fires. + await triggers.clear() + } catch is CancellationError { + throw CancellationError() + } catch { + guard ChatSendQueueService.isTransientDirectMessageError(error) else { + loggerRef.info("DM drain terminal messageID=\(envelope.messageID) error=\(String(describing: error))") + throw error } - ) - } - - /// Enqueue a DM envelope. Persists a `PendingSend` row first; the - /// queue's drain reads it back on the next send attempt. Throws - /// `ChatSendQueueServiceError.persistFailed` if the SwiftData write - /// fails so the caller can surface the failure instead of silently - /// dropping the queued send. - public func enqueueDM(_ envelope: DirectMessageEnvelope) async throws { - try await persist(PendingSendDTO(envelope: envelope, radioID: radioID)) - await dmQueue.enqueue(envelope) - } - - public func enqueueChannel(_ envelope: ChannelMessageEnvelope) async throws { - try await persist(PendingSendDTO(envelope: envelope, radioID: radioID)) - await channelQueue.enqueue(envelope) - } - - /// Signals the DM queue that a `PendingSend` row already exists for - /// this envelope. Used by the manual retry path where - /// `PersistenceStore.replacePendingSendForRetry` has already written - /// the row in one transaction; calling `enqueueDM` here would - /// double-persist. The drain still reads the row back via - /// `hasPendingSend` on the next send attempt, so the in-memory - /// enqueue is the only step left. - public func signalDMEnqueued(_ envelope: DirectMessageEnvelope) async { - await dmQueue.enqueue(envelope) - } - - /// Starts observing the connection-state stream and fires the - /// transport-open trigger exactly once each time the connection enters - /// `.ready`. Gating on `.ready` (not the first `isConnected` edge) keeps - /// hydrated sends parked through the initial-sync window, so they do not - /// contend with sync's reads on the radio's link. In production the container - /// is built while still `.connected`, so the `.ready` wake arrives on the - /// event stream; the initial-value check only fires when the observation is - /// (re)started against an already-`.ready` connection, waking drains parked in - /// `withCooperativeTimeout` instead of leaving them to wait out - /// `transportWaitTimeout`. Firing on "entered `.ready`" covers both the success - /// edge (`.connected → .ready`) and the failure-recovery edge - /// (`.syncing → .ready`). Calling again replaces the previous observation. - func observeConnectionState( - initial: DeviceConnectionState, - events: AsyncStream - ) { - connectionStateTask?.cancel() - if initial.canDrainSendQueue { - transportDidOpen() + loggerRef.info("DM drain transient messageID=\(envelope.messageID) error=\(String(describing: error))") + // Remap the .failed write that failMessageAndRethrow just + // made back to .pending so the bubble doesn't show + // "Failed" while the queue is silently parked. Duplicate + // prevention on the next pass is via preserveTimestamp, + // not via any boundary check. + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "DM", + timeout: configRef.transportWaitTimeout + ) + } + } catch let cancellation as CancellationError { + throw cancellation + } catch { + await messageServiceRef.notifyMessageFailed(messageID: envelope.messageID) + throw error } - connectionStateTask = Task { [weak self] in - var previous = initial - for await state in events { - guard let self else { return } - if !previous.canDrainSendQueue && state.canDrainSendQueue { - self.transportDidOpen() - } - previous = state - } + }, + onError: { _, envelope in + // Permanent error path only — transient errors take the + // wait-and-retry branch above and never reach onError. + try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) + }, + onDrain: { lastError in + if let lastError { + loggerRef.error("DM queue drained with error: \(String(describing: lastError))") } - } - - /// Fired by the connection-state observation started by - /// `observeConnectionState` each time the connection enters `.ready`. - /// Fires the trigger that wakes any drain attempt suspended in - /// `withCooperativeTimeout`. - /// - /// Fire-and-forget Task is intentional: `triggers.fire` is idempotent - /// (arming an already-armed bit is a no-op), so a tight reconnect cycle - /// (`.connected → .connecting → .connected` within a frame) collapses - /// to a single armed trigger on the actor. Do not extend this function - /// with per-call state — the fire-and-forget shape would lose ordering. - func transportDidOpen() { - osLogger.debug("transportDidOpen firing trigger") - Task { [triggers] in - await triggers.fire() - } - } - - /// Park the drain on the transport-open trigger up to `timeout` seconds. - /// Returns true if the signal fired, false on timeout or - /// cancelled-mid-wait (caller treats both as "requeue"). `nonisolated` - /// so the off-main send closures can call it. - nonisolated static func waitForTransportOpen( - triggers: BLETransportOpenedSignal, - logger: PersistentLogger, - messageID: UUID, - kind: String, - timeout: TimeInterval - ) async throws -> Bool { + } + ) + + channelQueue = SendQueue( + send: { envelope in + // Outer catch: see DM closure for rationale. Park-and-requeue + // branches throw `CancellationError`; any other escape is a + // terminal failure for the envelope and calls + // `notifyMessageFailed` exactly once. do { - try await withCooperativeTimeout(seconds: timeout) { - try await triggers.wait() - } - return true - } catch is CancellationError { - if Task.isCancelled { - logger.info("\(kind) drain cancelled mid-wait for \(messageID); requeueing") + // Pre-send gate + attemptCount bump; see DM closure for rationale. + let postBumpCount: Int + let preserveTimestamp: Bool + do { + guard let result = try await ChatSendQueueService.preflightAndBump( + dataStore: dataStoreRef, + messageID: envelope.messageID, + kind: "channel", + logger: loggerRef, + osLogger: osLoggerRef + ) else { return } + postBumpCount = result.postBumpCount + preserveTimestamp = result.preserveTimestamp + } catch { + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "channel", + timeout: configRef.transportWaitTimeout + ) + } + + do { + // resendChannelMessage stamps a fresh wire timestamp so the + // mesh dedup ring treats the retry as a new broadcast. + // Reactions hash off that exact timestamp via + // SHA256(text || timestamp.littleEndian), so the resent + // packet must be indexed under the post-resend value, not + // the pre-resend value captured at enqueue time. + let indexTimestamp: UInt32 + if envelope.isResend { + indexTimestamp = try await messageServiceRef.resendChannelMessage( + messageID: envelope.messageID, + preserveTimestamp: preserveTimestamp + ) } else { - logger.info("\(kind) drain timeout-without-fire after \(Int(timeout))s for \(messageID); requeueing") + try await messageServiceRef.sendPendingChannelMessage(messageID: envelope.messageID) + indexTimestamp = envelope.messageTimestamp } - return false - } - } - - /// Combines the `hasPendingSend` gate, the top-of-drain `attemptCount` bump, - /// and the `preserveTimestamp` computation that every DM and channel drain - /// runs before any wire-affecting work. Returns nil if the row is gone - /// (terminal — abandon envelope). Throws transient errors so the caller can - /// park and retry; throws nothing on the gate-missing terminal path. - nonisolated static func preflightAndBump( - dataStore: any MessagePersisting, - messageID: UUID, - kind: String, - logger: PersistentLogger, - osLogger: os.Logger - ) async throws -> (postBumpCount: Int, preserveTimestamp: Bool)? { - switch await ChatSendQueueService.classifyRead({ () async throws -> Bool? in - try await dataStore.hasPendingSend(messageID: messageID) ? true : nil - }) { - case .found: - break - case .missing: - logger.info("\(kind) drain abandoned messageID=\(messageID) reason=PendingSendGone") - return nil - case .transient(let error): - logger.warning("\(kind) drain hasPendingSend transient error: \(String(describing: error)); parking envelope") - throw error - } - - let postBumpCount: Int - do { - guard let bumped = try await dataStore.incrementPendingSendAttemptCount(messageID: messageID) else { - logger.info("\(kind) drain: PendingSend row gone for messageID=\(messageID); treating as terminal") - return nil + if let nodeName = envelope.localNodeName { + _ = await reactionServiceRef.indexMessage( + id: envelope.messageID, + channelIndex: envelope.channelIndex, + senderName: nodeName, + text: envelope.messageText, + timestamp: indexTimestamp + ) + } + try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) + osLoggerRef.debug("channel drain success messageID=\(envelope.messageID)") + await failureCounter.reset(for: envelope.messageID) + await triggers.clear() + } catch is CancellationError { + throw CancellationError() + } catch { + guard ChatSendQueueService.isTransientChannelMessageError(error) else { + loggerRef.info("channel drain terminal messageID=\(envelope.messageID) error=\(String(describing: error))") + throw error + } + if ChatSendQueueService.isChannelMessageNotFound(error) { + // Disambiguate pool exhaustion (transient) from a stale + // channel index (terminal) by refreshing the radio's + // view of the channel. `ChannelService.fetchChannel(index:)` + // reads from the device, so a nil result means the radio + // agrees the channel is gone (terminal). + // + // Gate on disambiguateAfterAttempts so the common + // pool-exhaustion burst (1-2 NOT_FOUNDs in a row) parks + // without an extra BLE round-trip; only persistent + // NOT_FOUND warrants the fetchChannel cost. + if postBumpCount < configRef.disambiguateAfterAttempts { + loggerRef.info("channel drain NOT_FOUND below disambiguate threshold messageID=\(envelope.messageID) postBumpCount=\(postBumpCount); parking envelope") + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "channel", + timeout: configRef.transportWaitTimeout + ) + } + + let stillExists: Bool + do { + stillExists = try await channelServiceRef.fetchChannel(index: envelope.channelIndex) != nil + await failureCounter.reset(for: envelope.messageID) + } catch { + let failures = await failureCounter.increment(for: envelope.messageID) + if failures >= configRef.maxConsecutiveFetchChannelFailures { + loggerRef.warning("Channel drain: fetchChannel persistently failing (\(failures) consecutive) for messageID=\(envelope.messageID); marking terminal") + await failureCounter.reset(for: envelope.messageID) + throw error + } + loggerRef.info("channel drain NOT_FOUND followup fetchChannel failed: \(String(describing: error)); parking envelope (\(failures)/\(configRef.maxConsecutiveFetchChannelFailures))") + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "channel", + timeout: configRef.transportWaitTimeout + ) + } + if !stillExists { + loggerRef.warning("Channel drain: NOT_FOUND confirmed by fetchChannel for messageID=\(envelope.messageID); treating as terminal (channel deleted)") + await failureCounter.reset(for: envelope.messageID) + throw error + } + // Channel still exists per device — NOT_FOUND was pool + // exhaustion. Park on the transport-open trigger. The + // effective per-attempt cadence under sustained + // exhaustion is ~37s (3.5s withPoolBackoff in-loop + + // 3.5s fetchChannel disambiguation + 30s + // transportWaitTimeout park). Transport-open only fires + // on entering `.ready`, so under a healthy connection + // the 30s park is what bounds the retry rate. } - postBumpCount = bumped + loggerRef.info("channel drain transient messageID=\(envelope.messageID) error=\(String(describing: error))") + _ = try? await dataStoreRef.updateMessageStatusUnlessDelivered(id: envelope.messageID, status: .pending) + try await ChatSendQueueService.parkAndCancel( + triggers: triggers, + logger: loggerRef, + messageID: envelope.messageID, + kind: "channel", + timeout: configRef.transportWaitTimeout + ) + } + } catch let cancellation as CancellationError { + throw cancellation } catch { - logger.warning("incrementPendingSendAttemptCount failed: \(String(describing: error)); parking envelope for next transport-open") - throw error + await messageServiceRef.notifyMessageFailed(messageID: envelope.messageID) + throw error } - - let preserveTimestamp = postBumpCount > 1 - osLogger.debug("\(kind) drain begin messageID=\(messageID) postBumpCount=\(postBumpCount) preserveTimestamp=\(preserveTimestamp)") - return (postBumpCount: postBumpCount, preserveTimestamp: preserveTimestamp) - } - - /// Awaits a transport-open trigger up to the bounded timeout, then throws - /// `CancellationError` to drive `SendQueue.drain`'s requeue protocol. - /// Sites that need to revert message status to `.pending` before parking - /// must call `updateMessageStatusUnlessDelivered` themselves — this helper - /// does not write status. - nonisolated static func parkAndCancel( - triggers: BLETransportOpenedSignal, - logger: PersistentLogger, - messageID: UUID, - kind: String, - timeout: TimeInterval - ) async throws -> Never { - _ = try await ChatSendQueueService.waitForTransportOpen( - triggers: triggers, - logger: logger, - messageID: messageID, - kind: kind, - timeout: timeout - ) - throw CancellationError() - } - - /// Classify a send error as transient (park the envelope and wait on a - /// transport-open trigger) or terminal (drop the row). The transient - /// `deviceError` code differs between DM and channel paths — see - /// `FirmwareDeviceErrorCode`. - nonisolated static func isTransientError(_ error: Error, deviceCode: UInt8) -> Bool { - if let serviceError = error as? MessageServiceError { - switch serviceError { - case .sessionError(let underlying): - return isTransientError(underlying, deviceCode: deviceCode) - case .notConnected: - return true - case .contactNotFound, .channelNotFound, .sendFailed, - .invalidRecipient, .messageTooLong: - return false - } + }, + onError: { _, envelope in + try? await dataStoreRef.deletePendingSendsForMessage(messageID: envelope.messageID) + }, + onDrain: { lastError in + if let lastError { + loggerRef.error("Channel queue drained with error: \(String(describing: lastError))") } - guard let meshError = error as? MeshCoreError else { return false } - switch meshError { - case .timeout, .notConnected, .connectionLost, - .bluetoothPoweredOff, .sessionNotStarted: - return true - case .deviceError(let code): - return code == deviceCode - case .parseError, .commandFailed, .invalidResponse, - .contactNotFound, .dataTooLarge, .signingFailed, .invalidInput, - .unknown, .bluetoothUnavailable, .bluetoothUnauthorized, - .featureDisabled: - return false + } + ) + } + + /// Enqueue a DM envelope. Persists a `PendingSend` row first; the + /// queue's drain reads it back on the next send attempt. Throws + /// `ChatSendQueueServiceError.persistFailed` if the SwiftData write + /// fails so the caller can surface the failure instead of silently + /// dropping the queued send. + public func enqueueDM(_ envelope: DirectMessageEnvelope) async throws { + try await persist(PendingSendDTO(envelope: envelope, radioID: radioID)) + await dmQueue.enqueue(envelope) + } + + public func enqueueChannel(_ envelope: ChannelMessageEnvelope) async throws { + try await persist(PendingSendDTO(envelope: envelope, radioID: radioID)) + await channelQueue.enqueue(envelope) + } + + /// Signals the DM queue that a `PendingSend` row already exists for + /// this envelope. Used by the manual retry path where + /// `PersistenceStore.replacePendingSendForRetry` has already written + /// the row in one transaction; calling `enqueueDM` here would + /// double-persist. The drain still reads the row back via + /// `hasPendingSend` on the next send attempt, so the in-memory + /// enqueue is the only step left. + public func signalDMEnqueued(_ envelope: DirectMessageEnvelope) async { + await dmQueue.enqueue(envelope) + } + + /// Starts observing the connection-state stream and fires the + /// transport-open trigger exactly once each time the connection enters + /// `.ready`. Gating on `.ready` (not the first `isConnected` edge) keeps + /// hydrated sends parked through the initial-sync window, so they do not + /// contend with sync's reads on the radio's link. In production the container + /// is built while still `.connected`, so the `.ready` wake arrives on the + /// event stream; the initial-value check only fires when the observation is + /// (re)started against an already-`.ready` connection, waking drains parked in + /// `withCooperativeTimeout` instead of leaving them to wait out + /// `transportWaitTimeout`. Firing on "entered `.ready`" covers both the success + /// edge (`.connected → .ready`) and the failure-recovery edge + /// (`.syncing → .ready`). Calling again replaces the previous observation. + func observeConnectionState( + initial: DeviceConnectionState, + events: AsyncStream + ) { + connectionStateTask?.cancel() + if initial.canDrainSendQueue { + transportDidOpen() + } + connectionStateTask = Task { [weak self] in + var previous = initial + for await state in events { + guard let self else { return } + if !previous.canDrainSendQueue, state.canDrainSendQueue { + transportDidOpen() } + previous = state + } } - - // MARK: - Store-read classifier - - nonisolated private enum DrainStoreReadOutcome: Sendable { - /// Read succeeded with a value. - case found(Value) - /// Read succeeded but the row is gone — terminal, drop the envelope. - case missing - /// Read threw — transient; park and retry on next transport open. - case transient(Error) + } + + /// Fired by the connection-state observation started by + /// `observeConnectionState` each time the connection enters `.ready`. + /// Fires the trigger that wakes any drain attempt suspended in + /// `withCooperativeTimeout`. + /// + /// Fire-and-forget Task is intentional: `triggers.fire` is idempotent + /// (arming an already-armed bit is a no-op), so a tight reconnect cycle + /// (`.connected → .connecting → .connected` within a frame) collapses + /// to a single armed trigger on the actor. Do not extend this function + /// with per-call state — the fire-and-forget shape would lose ordering. + func transportDidOpen() { + osLogger.debug("transportDidOpen firing trigger") + Task { [triggers] in + await triggers.fire() } - - /// Wraps a `throws -> T?` store read into a tri-state outcome so the drain - /// can distinguish "row deleted" (terminal) from "store fault" (transient). - nonisolated private static func classifyRead( - _ work: @Sendable () async throws -> Value? - ) async -> DrainStoreReadOutcome { - do { - if let value = try await work() { - return .found(value) - } - return .missing - } catch { - return .transient(error) - } + } + + /// Park the drain on the transport-open trigger up to `timeout` seconds. + /// Returns true if the signal fired, false on timeout or + /// cancelled-mid-wait (caller treats both as "requeue"). `nonisolated` + /// so the off-main send closures can call it. + nonisolated static func waitForTransportOpen( + triggers: BLETransportOpenedSignal, + logger: PersistentLogger, + messageID: UUID, + kind: String, + timeout: TimeInterval + ) async throws -> Bool { + do { + try await withCooperativeTimeout(seconds: timeout) { + try await triggers.wait() + } + return true + } catch is CancellationError { + if Task.isCancelled { + logger.info("\(kind) drain cancelled mid-wait for \(messageID); requeueing") + } else { + logger.info("\(kind) drain timeout-without-fire after \(Int(timeout))s for \(messageID); requeueing") + } + return false } - - nonisolated static func isTransientDirectMessageError(_ error: Error) -> Bool { - isTransientError(error, deviceCode: FirmwareDeviceErrorCode.directMessageTableFull) + } + + /// Combines the `hasPendingSend` gate, the top-of-drain `attemptCount` bump, + /// and the `preserveTimestamp` computation that every DM and channel drain + /// runs before any wire-affecting work. Returns nil if the row is gone + /// (terminal — abandon envelope). Throws transient errors so the caller can + /// park and retry; throws nothing on the gate-missing terminal path. + nonisolated static func preflightAndBump( + dataStore: any MessagePersisting, + messageID: UUID, + kind: String, + logger: PersistentLogger, + osLogger: os.Logger + ) async throws -> (postBumpCount: Int, preserveTimestamp: Bool)? { + switch await ChatSendQueueService.classifyRead({ () async throws -> Bool? in + try await dataStore.hasPendingSend(messageID: messageID) ? true : nil + }) { + case .found: + break + case .missing: + logger.info("\(kind) drain abandoned messageID=\(messageID) reason=PendingSendGone") + return nil + case let .transient(error): + logger.warning("\(kind) drain hasPendingSend transient error: \(String(describing: error)); parking envelope") + throw error } - nonisolated static func isTransientChannelMessageError(_ error: Error) -> Bool { - isTransientError(error, deviceCode: FirmwareDeviceErrorCode.channelMessageNotFound) + let postBumpCount: Int + do { + guard let bumped = try await dataStore.incrementPendingSendAttemptCount(messageID: messageID) else { + logger.info("\(kind) drain: PendingSend row gone for messageID=\(messageID); treating as terminal") + return nil + } + postBumpCount = bumped + } catch { + logger.warning("incrementPendingSendAttemptCount failed: \(String(describing: error)); parking envelope for next transport-open") + throw error } - /// Returns true if `error` is `MeshCoreError.deviceError(channelMessageNotFound)` - /// or `MessageServiceError.sessionError` wrapping the same. Used by the channel - /// drain catch to recognise the firmware NOT_FOUND signal regardless of which - /// MessageService entrypoint surfaced it. - /// - /// `MessageServiceError.sessionError` wraps `MeshCoreError` (a leaf — has no - /// `.sessionError` case), so the unwrap is single-level by construction. Two - /// pattern-matches cover both shapes; no recursion needed. - nonisolated static func isChannelMessageNotFound(_ error: Error) -> Bool { - if case MeshCoreError.deviceError(let code) = error { - return code == FirmwareDeviceErrorCode.channelMessageNotFound - } - if case MessageServiceError.sessionError(let underlying) = error, - case MeshCoreError.deviceError(let code) = underlying { - return code == FirmwareDeviceErrorCode.channelMessageNotFound - } + let preserveTimestamp = postBumpCount > 1 + osLogger.debug("\(kind) drain begin messageID=\(messageID) postBumpCount=\(postBumpCount) preserveTimestamp=\(preserveTimestamp)") + return (postBumpCount: postBumpCount, preserveTimestamp: preserveTimestamp) + } + + /// Awaits a transport-open trigger up to the bounded timeout, then throws + /// `CancellationError` to drive `SendQueue.drain`'s requeue protocol. + /// Sites that need to revert message status to `.pending` before parking + /// must call `updateMessageStatusUnlessDelivered` themselves — this helper + /// does not write status. + nonisolated static func parkAndCancel( + triggers: BLETransportOpenedSignal, + logger: PersistentLogger, + messageID: UUID, + kind: String, + timeout: TimeInterval + ) async throws -> Never { + _ = try await ChatSendQueueService.waitForTransportOpen( + triggers: triggers, + logger: logger, + messageID: messageID, + kind: kind, + timeout: timeout + ) + throw CancellationError() + } + + /// Classify a send error as transient (park the envelope and wait on a + /// transport-open trigger) or terminal (drop the row). The transient + /// `deviceError` code differs between DM and channel paths — see + /// `FirmwareDeviceErrorCode`. + nonisolated static func isTransientError(_ error: Error, deviceCode: UInt8) -> Bool { + if let serviceError = error as? MessageServiceError { + switch serviceError { + case let .sessionError(underlying): + return isTransientError(underlying, deviceCode: deviceCode) + case .notConnected: + return true + case .contactNotFound, .channelNotFound, .sendFailed, + .invalidRecipient, .messageTooLong: return false + } } + guard let meshError = error as? MeshCoreError else { return false } + switch meshError { + case .timeout, .notConnected, .connectionLost, + .bluetoothPoweredOff, .sessionNotStarted: + return true + case let .deviceError(code): + return code == deviceCode + case .parseError, .commandFailed, .invalidResponse, + .contactNotFound, .dataTooLarge, .signingFailed, .invalidInput, + .unknown, .bluetoothUnavailable, .bluetoothUnauthorized, + .featureDisabled: + return false + } + } + + // MARK: - Store-read classifier + + private nonisolated enum DrainStoreReadOutcome { + /// Read succeeded with a value. + case found(Value) + /// Read succeeded but the row is gone — terminal, drop the envelope. + case missing + /// Read threw — transient; park and retry on next transport open. + case transient(Error) + } + + /// Wraps a `throws -> T?` store read into a tri-state outcome so the drain + /// can distinguish "row deleted" (terminal) from "store fault" (transient). + private nonisolated static func classifyRead( + _ work: @Sendable () async throws -> Value? + ) async -> DrainStoreReadOutcome { + do { + if let value = try await work() { + return .found(value) + } + return .missing + } catch { + return .transient(error) + } + } + + nonisolated static func isTransientDirectMessageError(_ error: Error) -> Bool { + isTransientError(error, deviceCode: FirmwareDeviceErrorCode.directMessageTableFull) + } + + nonisolated static func isTransientChannelMessageError(_ error: Error) -> Bool { + isTransientError(error, deviceCode: FirmwareDeviceErrorCode.channelMessageNotFound) + } + + /// Returns true if `error` is `MeshCoreError.deviceError(channelMessageNotFound)` + /// or `MessageServiceError.sessionError` wrapping the same. Used by the channel + /// drain catch to recognise the firmware NOT_FOUND signal regardless of which + /// MessageService entrypoint surfaced it. + /// + /// `MessageServiceError.sessionError` wraps `MeshCoreError` (a leaf — has no + /// `.sessionError` case), so the unwrap is single-level by construction. Two + /// pattern-matches cover both shapes; no recursion needed. + nonisolated static func isChannelMessageNotFound(_ error: Error) -> Bool { + if case let MeshCoreError.deviceError(code) = error { + return code == FirmwareDeviceErrorCode.channelMessageNotFound + } + if case let MessageServiceError.sessionError(underlying) = error, + case let MeshCoreError.deviceError(code) = underlying { + return code == FirmwareDeviceErrorCode.channelMessageNotFound + } + return false + } - // MARK: - Persistence + // MARK: - Persistence - private func persist(_ dto: PendingSendDTO) async throws { - do { - _ = try await dataStore.insertPendingSendAssigningSequence(dto) - } catch { - logger.error("Persisting envelope failed: \(String(describing: error))") - throw ChatSendQueueServiceError.persistFailed(underlying: error) - } + private func persist(_ dto: PendingSendDTO) async throws { + do { + _ = try await dataStore.insertPendingSendAssigningSequence(dto) + } catch { + logger.error("Persisting envelope failed: \(String(describing: error))") + throw ChatSendQueueServiceError.persistFailed(underlying: error) } - - // MARK: - Hydration - - /// Called once by `ServiceContainer` after construction. Reads - /// every `PendingSend` row for this service's `radioID` and - /// enqueues each envelope. Subsequent calls are no-ops — the - /// service's hydration latch is per-instance, and one instance - /// lives per `ServiceContainer`, so two view models on the same - /// connection cannot trigger duplicate replay. - /// - /// The nullable-attemptCount scheme stores legacy-vs-current-build - /// distinction in the column itself; `PersistenceStore.warmUp()` runs - /// `purgeLegacyAttemptCountRows` before hydrate (wired in - /// `ConnectionManager.buildServicesAndSaveDevice`) so pre-migration `nil` - /// rows are deleted rather than promoted. Race-window rows (persist - /// succeeded, drain bump didn't run) sit at `attemptCount = 0`, bump to - /// `1`, and `preserveTimestamp` = false — correct, because the recipient - /// never saw the packet. - func hydrate() async { - guard !hasHydrated else { return } - hasHydrated = true - logger.info("hydrate begin radio=\(radioID)") - do { - let rows = try await dataStore.fetchPendingSends(radioID: radioID) - for dto in rows { - osLogger.debug("hydrate enqueue messageID=\(dto.messageID) kind=\(String(describing: dto.kind)) isResend=\(dto.isResend) attemptCount=\(String(describing: dto.attemptCount))") - switch dto.kind { - case .dm: - if let envelope = dto.directMessageEnvelope() { - await dmQueue.enqueue(envelope) - } - case .channel: - if let envelope = dto.channelMessageEnvelope() { - await channelQueue.enqueue(envelope) - } - } - } - logger.info("hydrate complete count=\(rows.count) radio=\(radioID)") - } catch { - logger.error("Hydration failed: \(String(describing: error))") + } + + // MARK: - Hydration + + /// Called once by `ServiceContainer` after construction. Reads + /// every `PendingSend` row for this service's `radioID` and + /// enqueues each envelope. Subsequent calls are no-ops — the + /// service's hydration latch is per-instance, and one instance + /// lives per `ServiceContainer`, so two view models on the same + /// connection cannot trigger duplicate replay. + /// + /// The nullable-attemptCount scheme stores legacy-vs-current-build + /// distinction in the column itself; `PersistenceStore.warmUp()` runs + /// `purgeLegacyAttemptCountRows` before hydrate (wired in + /// `ConnectionManager.buildServicesAndSaveDevice`) so pre-migration `nil` + /// rows are deleted rather than promoted. Race-window rows (persist + /// succeeded, drain bump didn't run) sit at `attemptCount = 0`, bump to + /// `1`, and `preserveTimestamp` = false — correct, because the recipient + /// never saw the packet. + func hydrate() async { + guard !hasHydrated else { return } + hasHydrated = true + logger.info("hydrate begin radio=\(radioID)") + do { + let rows = try await dataStore.fetchPendingSends(radioID: radioID) + for dto in rows { + osLogger.debug("hydrate enqueue messageID=\(dto.messageID) kind=\(String(describing: dto.kind)) isResend=\(dto.isResend) attemptCount=\(String(describing: dto.attemptCount))") + switch dto.kind { + case .dm: + if let envelope = dto.directMessageEnvelope() { + await dmQueue.enqueue(envelope) + } + case .channel: + if let envelope = dto.channelMessageEnvelope() { + await channelQueue.enqueue(envelope) + } } + } + logger.info("hydrate complete count=\(rows.count) radio=\(radioID)") + } catch { + logger.error("Hydration failed: \(String(describing: error))") } + } - #if DEBUG + #if DEBUG /// Test-only: drain readiness for synchronizing tests. func awaitDrainCompletion() async { - await dmQueue.awaitDrainCompletion() - await channelQueue.awaitDrainCompletion() - } - #endif - - /// Cancel both queues' drains so the underlying actors can deinit. - /// - /// Without this, send closures suspended in `withCooperativeTimeout` - /// keep the queue actor alive (and its captured `MessageService` / - /// `triggers`), respawning on every `.notConnected` requeue. `PendingSend` - /// rows survive in SwiftData, so the next container's `hydrate()` replays - /// them on reconnect. - /// - /// Cancelling the connection-state observation unregisters its - /// `EventBroadcaster` subscription so a torn-down container never - /// receives the next connection's edge. - func shutdown() async { - connectionStateTask?.cancel() - connectionStateTask = nil - await dmQueue.cancelDrain() - await channelQueue.cancelDrain() + await dmQueue.awaitDrainCompletion() + await channelQueue.awaitDrainCompletion() } + #endif + + /// Cancel both queues' drains so the underlying actors can deinit. + /// + /// Without this, send closures suspended in `withCooperativeTimeout` + /// keep the queue actor alive (and its captured `MessageService` / + /// `triggers`), respawning on every `.notConnected` requeue. `PendingSend` + /// rows survive in SwiftData, so the next container's `hydrate()` replays + /// them on reconnect. + /// + /// Cancelling the connection-state observation unregisters its + /// `EventBroadcaster` subscription so a torn-down container never + /// receives the next connection's edge. + func shutdown() async { + connectionStateTask?.cancel() + connectionStateTask = nil + await dmQueue.cancelDrain() + await channelQueue.cancelDrain() + } } /// Tracks consecutive `fetchChannel` throws inside the channel-drain @@ -729,16 +728,16 @@ public final class ChatSendQueueService { /// cleaned up on success and on terminal-fail so it does not grow /// unboundedly. actor FailureCounter { - private var counts: [UUID: Int] = [:] - - @discardableResult - func increment(for messageID: UUID) -> Int { - let next = (counts[messageID] ?? 0) + 1 - counts[messageID] = next - return next - } - - func reset(for messageID: UUID) { - counts.removeValue(forKey: messageID) - } + private var counts: [UUID: Int] = [:] + + @discardableResult + func increment(for messageID: UUID) -> Int { + let next = (counts[messageID] ?? 0) + 1 + counts[messageID] = next + return next + } + + func reset(for messageID: UUID) { + counts.removeValue(forKey: messageID) + } } diff --git a/MC1Services/Sources/MC1Services/Services/CommandAuditLogger.swift b/MC1Services/Sources/MC1Services/Services/CommandAuditLogger.swift index 212195a8..9d60331e 100644 --- a/MC1Services/Sources/MC1Services/Services/CommandAuditLogger.swift +++ b/MC1Services/Sources/MC1Services/Services/CommandAuditLogger.swift @@ -3,135 +3,134 @@ import Foundation /// Dedicated logger for command audit trails. /// Logs all repeater and room commands with consistent formatting. actor CommandAuditLogger { - - // MARK: - Types - - /// Direction of the command - enum Direction: String, Sendable { - case out = "->" - case `in` = "<-" - } - - /// Target type (repeater or room) - enum Target: String, Sendable { - case repeater = "REPEATER" - case room = "ROOM" - } - - // MARK: - Properties - - private let logger = PersistentLogger(subsystem: "com.mc1", category: "CommandAudit") - private let prefix = "[CMD]" - - // MARK: - Initialization - - init() {} - - // MARK: - Login/Logout - - /// Log a login request being sent - func logLoginRequest(target: Target, publicKey: Data, pathLength: UInt8) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) LOGIN to=\(keyHex) pathLen=\(pathLength)") - } - - /// Log a successful login response - func logLoginSuccess(target: Target, publicKey: Data, isAdmin: Bool) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) LOGIN_OK from=\(keyHex) admin=\(isAdmin)") - } - - /// Log a failed login response - func logLoginFailed(target: Target, publicKey: Data, reason: String) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.warning("\(prefix) \(Direction.in.rawValue) \(target.rawValue) LOGIN_FAIL from=\(keyHex) reason=\(reason)") - } - - /// Log a logout request being sent - func logLogout(target: Target, publicKey: Data) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) LOGOUT to=\(keyHex)") - } - - // MARK: - Status/Telemetry - - /// Log a status request being sent - func logStatusRequest(target: Target, publicKey: Data) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) STATUS_REQ to=\(keyHex)") - } - - /// Log a status response received - func logStatusResponse(target: Target, publicKey: Data, batteryMv: UInt16?, uptimeSec: UInt32?) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - let battery = batteryMv.map { "\($0)mV" } ?? "n/a" - let uptime = uptimeSec.map { "\($0)s" } ?? "n/a" - logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) STATUS from=\(keyHex) battery=\(battery) uptime=\(uptime)") - } - - /// Log a telemetry request being sent - func logTelemetryRequest(target: Target, publicKey: Data) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) TELEM_REQ to=\(keyHex)") - } - - /// Log a telemetry response received - func logTelemetryResponse(target: Target, publicKey: Data, pointCount: Int) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) TELEM from=\(keyHex) points=\(pointCount)") - } - - // MARK: - CLI Commands (Repeater) - - /// Log a CLI command being sent (with password redaction) - func logCLICommand(publicKey: Data, command: String) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - let redactedCmd = LogRedaction.cliCommand(command) - logger.info("\(prefix) \(Direction.out.rawValue) REPEATER CLI to=\(keyHex) cmd=\"\(redactedCmd)\"") - } - - /// Log a CLI response received (full content logged) - func logCLIResponse(publicKey: Data, response: String) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - // Truncate very long responses for readability - let truncated = response.count <= 100 ? response : String(response.prefix(100)) + "..." - logger.info("\(prefix) \(Direction.in.rawValue) REPEATER CLI_RESP from=\(keyHex) resp=\"\(truncated)\"") - } - - // MARK: - Neighbors (Repeater) - - /// Log a neighbors request being sent - func logNeighborsRequest(publicKey: Data, count: UInt8, offset: UInt16) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) REPEATER NEIGHBORS_REQ to=\(keyHex) count=\(count) offset=\(offset)") - } - - /// Log a neighbors response received - func logNeighborsResponse(publicKey: Data, totalCount: Int, returnedCount: Int) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.in.rawValue) REPEATER NEIGHBORS from=\(keyHex) total=\(totalCount) returned=\(returnedCount)") - } - - // MARK: - Room Messages (metadata only) - - /// Log a room message being posted (no content, only length) - func logRoomMessagePosted(publicKey: Data, messageLength: Int) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) ROOM MSG to=\(keyHex) len=\(messageLength)") - } - - /// Log a room message received (no content, only metadata) - func logRoomMessageReceived(roomPublicKey: Data, authorPrefix: Data, messageLength: Int) { - let roomHex = LogRedaction.publicKeyHex(roomPublicKey) - let authorHex = authorPrefix.map { String(format: "%02x", $0) }.joined() - logger.info("\(prefix) \(Direction.in.rawValue) ROOM MSG from=\(roomHex) author=\(authorHex) len=\(messageLength)") - } - - // MARK: - Keep-alive - - /// Log a keep-alive request being sent - func logKeepAlive(target: Target, publicKey: Data) { - let keyHex = LogRedaction.publicKeyHex(publicKey) - logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) KEEPALIVE to=\(keyHex)") - } + // MARK: - Types + + /// Direction of the command + enum Direction: String { + case out = "->" + case `in` = "<-" + } + + /// Target type (repeater or room) + enum Target: String { + case repeater = "REPEATER" + case room = "ROOM" + } + + // MARK: - Properties + + private let logger = PersistentLogger(subsystem: "com.mc1", category: "CommandAudit") + private let prefix = "[CMD]" + + // MARK: - Initialization + + init() {} + + // MARK: - Login/Logout + + /// Log a login request being sent + func logLoginRequest(target: Target, publicKey: Data, pathLength: UInt8) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) LOGIN to=\(keyHex) pathLen=\(pathLength)") + } + + /// Log a successful login response + func logLoginSuccess(target: Target, publicKey: Data, isAdmin: Bool) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) LOGIN_OK from=\(keyHex) admin=\(isAdmin)") + } + + /// Log a failed login response + func logLoginFailed(target: Target, publicKey: Data, reason: String) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.warning("\(prefix) \(Direction.in.rawValue) \(target.rawValue) LOGIN_FAIL from=\(keyHex) reason=\(reason)") + } + + /// Log a logout request being sent + func logLogout(target: Target, publicKey: Data) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) LOGOUT to=\(keyHex)") + } + + // MARK: - Status/Telemetry + + /// Log a status request being sent + func logStatusRequest(target: Target, publicKey: Data) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) STATUS_REQ to=\(keyHex)") + } + + /// Log a status response received + func logStatusResponse(target: Target, publicKey: Data, batteryMv: UInt16?, uptimeSec: UInt32?) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + let battery = batteryMv.map { "\($0)mV" } ?? "n/a" + let uptime = uptimeSec.map { "\($0)s" } ?? "n/a" + logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) STATUS from=\(keyHex) battery=\(battery) uptime=\(uptime)") + } + + /// Log a telemetry request being sent + func logTelemetryRequest(target: Target, publicKey: Data) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) TELEM_REQ to=\(keyHex)") + } + + /// Log a telemetry response received + func logTelemetryResponse(target: Target, publicKey: Data, pointCount: Int) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.in.rawValue) \(target.rawValue) TELEM from=\(keyHex) points=\(pointCount)") + } + + // MARK: - CLI Commands (Repeater) + + /// Log a CLI command being sent (with password redaction) + func logCLICommand(publicKey: Data, command: String) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + let redactedCmd = LogRedaction.cliCommand(command) + logger.info("\(prefix) \(Direction.out.rawValue) REPEATER CLI to=\(keyHex) cmd=\"\(redactedCmd)\"") + } + + /// Log a CLI response received (full content logged) + func logCLIResponse(publicKey: Data, response: String) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + // Truncate very long responses for readability + let truncated = response.count <= 100 ? response : String(response.prefix(100)) + "..." + logger.info("\(prefix) \(Direction.in.rawValue) REPEATER CLI_RESP from=\(keyHex) resp=\"\(truncated)\"") + } + + // MARK: - Neighbors (Repeater) + + /// Log a neighbors request being sent + func logNeighborsRequest(publicKey: Data, count: UInt8, offset: UInt16) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) REPEATER NEIGHBORS_REQ to=\(keyHex) count=\(count) offset=\(offset)") + } + + /// Log a neighbors response received + func logNeighborsResponse(publicKey: Data, totalCount: Int, returnedCount: Int) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.in.rawValue) REPEATER NEIGHBORS from=\(keyHex) total=\(totalCount) returned=\(returnedCount)") + } + + // MARK: - Room Messages (metadata only) + + /// Log a room message being posted (no content, only length) + func logRoomMessagePosted(publicKey: Data, messageLength: Int) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) ROOM MSG to=\(keyHex) len=\(messageLength)") + } + + /// Log a room message received (no content, only metadata) + func logRoomMessageReceived(roomPublicKey: Data, authorPrefix: Data, messageLength: Int) { + let roomHex = LogRedaction.publicKeyHex(roomPublicKey) + let authorHex = authorPrefix.map { String(format: "%02x", $0) }.joined() + logger.info("\(prefix) \(Direction.in.rawValue) ROOM MSG from=\(roomHex) author=\(authorHex) len=\(messageLength)") + } + + // MARK: - Keep-alive + + /// Log a keep-alive request being sent + func logKeepAlive(target: Target, publicKey: Data) { + let keyHex = LogRedaction.publicKeyHex(publicKey) + logger.info("\(prefix) \(Direction.out.rawValue) \(target.rawValue) KEEPALIVE to=\(keyHex)") + } } diff --git a/MC1Services/Sources/MC1Services/Services/ContactCleanupCoordinator.swift b/MC1Services/Sources/MC1Services/Services/ContactCleanupCoordinator.swift index f6aa632d..f0108ba5 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactCleanupCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactCleanupCoordinator.swift @@ -8,54 +8,53 @@ import Foundation /// Holds the collaborating services directly, never the `ServiceContainer`, /// so a torn-down container cannot be kept alive through this coordinator. struct ContactCleanupCoordinator: ContactCleanupHandling { + private let dataStore: any ContactPersisting & RoomPersisting + private let syncCoordinator: SyncCoordinator + private let notificationService: NotificationService + private let remoteNodeService: RemoteNodeService - private let dataStore: any ContactPersisting & RoomPersisting - private let syncCoordinator: SyncCoordinator - private let notificationService: NotificationService - private let remoteNodeService: RemoteNodeService + init( + dataStore: any ContactPersisting & RoomPersisting, + syncCoordinator: SyncCoordinator, + notificationService: NotificationService, + remoteNodeService: RemoteNodeService + ) { + self.dataStore = dataStore + self.syncCoordinator = syncCoordinator + self.notificationService = notificationService + self.remoteNodeService = remoteNodeService + } - init( - dataStore: any ContactPersisting & RoomPersisting, - syncCoordinator: SyncCoordinator, - notificationService: NotificationService, - remoteNodeService: RemoteNodeService - ) { - self.dataStore = dataStore - self.syncCoordinator = syncCoordinator - self.notificationService = notificationService - self.remoteNodeService = remoteNodeService - } - - func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async { - // Refresh blocked names cache and delete channel messages on block - if reason == .blocked || reason == .unblocked { - if let contact = try? await dataStore.fetchContact(id: contactID) { - if reason == .blocked { - try? await dataStore.deleteChannelMessages( - fromSender: contact.name, radioID: contact.radioID - ) - } - await syncCoordinator.refreshBlockedContactsCache( - radioID: contact.radioID, dataStore: dataStore - ) - await syncCoordinator.notifyConversationsChanged() - } + func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async { + // Refresh blocked names cache and delete channel messages on block + if reason == .blocked || reason == .unblocked { + if let contact = try? await dataStore.fetchContact(id: contactID) { + if reason == .blocked { + try? await dataStore.deleteChannelMessages( + fromSender: contact.name, radioID: contact.radioID + ) } + await syncCoordinator.refreshBlockedContactsCache( + radioID: contact.radioID, dataStore: dataStore + ) + await syncCoordinator.notifyConversationsChanged() + } + } - // Remove delivered notifications for this contact (only on block/delete) - if reason == .blocked || reason == .deleted { - await notificationService.removeDeliveredNotifications(forContactID: contactID) - } + // Remove delivered notifications for this contact (only on block/delete) + if reason == .blocked || reason == .deleted { + await notificationService.removeDeliveredNotifications(forContactID: contactID) + } - // Update badge count - await notificationService.updateBadgeCount() + // Update badge count + await notificationService.updateBadgeCount() - // Clean up any associated remote node session on delete - if reason == .deleted { - if let session = try? await dataStore.fetchRemoteNodeSession(publicKey: publicKey) { - try? await remoteNodeService.removeSession(id: session.id, publicKey: publicKey) - } - await syncCoordinator.notifyConversationsChanged() - } + // Clean up any associated remote node session on delete + if reason == .deleted { + if let session = try? await dataStore.fetchRemoteNodeSession(publicKey: publicKey) { + try? await remoteNodeService.removeSession(id: session.id, publicKey: publicKey) + } + await syncCoordinator.notifyConversationsChanged() } + } } diff --git a/MC1Services/Sources/MC1Services/Services/ContactCleanupHandling.swift b/MC1Services/Sources/MC1Services/Services/ContactCleanupHandling.swift index 32a32fcf..c00918f2 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactCleanupHandling.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactCleanupHandling.swift @@ -4,12 +4,11 @@ import Foundation /// delete). `ContactService` invokes this after its own database writes; /// `ContactCleanupCoordinator` is the production implementation. protocol ContactCleanupHandling: Sendable { - - /// Runs the cleanup chain for one contact. - /// - Parameters: - /// - contactID: The affected contact's local ID. - /// - reason: Which lifecycle change triggered the cleanup. - /// - publicKey: The contact's public key, used to locate any associated - /// remote node session. - func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async + /// Runs the cleanup chain for one contact. + /// - Parameters: + /// - contactID: The affected contact's local ID. + /// - reason: Which lifecycle change triggered the cleanup. + /// - publicKey: The contact's public key, used to locate any associated + /// remote node session. + func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async } diff --git a/MC1Services/Sources/MC1Services/Services/ContactService.swift b/MC1Services/Sources/MC1Services/Services/ContactService.swift index 5ec928dc..dd1980b6 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactService.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactService.swift @@ -5,57 +5,57 @@ import os // MARK: - Contact Service Errors public enum ContactServiceError: Error, Sendable, LocalizedError { - case notConnected - case sendFailed - case invalidResponse - case syncInterrupted - case contactNotFound - case contactTableFull - case shareContactUnavailable - case sessionError(MeshCoreError) - - public var errorDescription: String? { - switch self { - case .notConnected: - return "Not connected to radio" - case .sendFailed: - return "Failed to send message" - case .invalidResponse: - return "Invalid response from device" - case .syncInterrupted: - return "Sync was interrupted" - case .contactNotFound: - return "Contact not found on device" - case .contactTableFull: - return "Device node list is full" - case .shareContactUnavailable: - return "Unable to share node. The node's advertisement may be missing or too old." - case .sessionError(let error): - return error.localizedDescription - } + case notConnected + case sendFailed + case invalidResponse + case syncInterrupted + case contactNotFound + case contactTableFull + case shareContactUnavailable + case sessionError(MeshCoreError) + + public var errorDescription: String? { + switch self { + case .notConnected: + "Not connected to radio" + case .sendFailed: + "Failed to send message" + case .invalidResponse: + "Invalid response from device" + case .syncInterrupted: + "Sync was interrupted" + case .contactNotFound: + "Contact not found on device" + case .contactTableFull: + "Device node list is full" + case .shareContactUnavailable: + "Unable to share node. The node's advertisement may be missing or too old." + case let .sessionError(error): + error.localizedDescription } + } } /// Reason for contact cleanup (deletion or blocking) -enum ContactCleanupReason: Sendable { - case deleted - case blocked - case unblocked +enum ContactCleanupReason { + case deleted + case blocked + case unblocked } // MARK: - Sync Result /// Result of a contact sync operation public struct ContactSyncResult: Sendable { - public let contactsReceived: Int - public let lastSyncTimestamp: UInt32 - public let isIncremental: Bool - - public init(contactsReceived: Int, lastSyncTimestamp: UInt32, isIncremental: Bool) { - self.contactsReceived = contactsReceived - self.lastSyncTimestamp = lastSyncTimestamp - self.isIncremental = isIncremental - } + public let contactsReceived: Int + public let lastSyncTimestamp: UInt32 + public let isIncremental: Bool + + public init(contactsReceived: Int, lastSyncTimestamp: UInt32, isIncremental: Bool) { + self.contactsReceived = contactsReceived + self.lastSyncTimestamp = lastSyncTimestamp + self.isIncremental = isIncremental + } } // MARK: - Contact Service @@ -63,713 +63,716 @@ public struct ContactSyncResult: Sendable { /// Service for managing mesh network contacts. /// Handles contact discovery, sync, add/update/remove operations. public actor ContactService { - - // MARK: - Properties - - private let session: any MeshCoreSessionProtocol - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "ContactService") - - /// Sync coordinator for UI refresh notifications. - /// Injected by `ServiceContainer` at construction. - private weak var syncCoordinator: SyncCoordinator? - - /// Runs the cross-service cleanup chain when a contact is deleted, blocked, or unblocked. - /// Injected by `ServiceContainer` at construction. - private let cleanupCoordinator: (any ContactCleanupHandling)? - - /// Multicast broadcaster for sync progress and node-deletion events. - /// Consumers subscribe via `events()`; finished by `ServiceContainer.tearDown()`. - private nonisolated let eventBroadcaster = EventBroadcaster() - - // MARK: - Initialization - - init( - session: any MeshCoreSessionProtocol, - dataStore: any PersistenceStoreProtocol, - syncCoordinator: SyncCoordinator?, - cleanupCoordinator: (any ContactCleanupHandling)? - ) { - self.session = session - self.dataStore = dataStore - self.syncCoordinator = syncCoordinator - self.cleanupCoordinator = cleanupCoordinator - } - - // MARK: - Events - - /// Returns a fresh stream of contact service events. Registration is - /// synchronous, so events yielded after this call returns are never - /// dropped. Consumers re-subscribe per connection because the owning - /// `ServiceContainer` is rebuilt. - public nonisolated func events() -> AsyncStream { - eventBroadcaster.subscribe() - } - - /// Ends every `events()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release their service - /// references. - nonisolated func finishEvents() { - eventBroadcaster.finish() - } - - // MARK: - Configuration - - /// Whether a sync coordinator was injected at construction. - var hasSyncCoordinatorWired: Bool { syncCoordinator != nil } - - /// Whether a cleanup coordinator was injected at construction. - var hasCleanupCoordinatorWired: Bool { cleanupCoordinator != nil } - - // MARK: - Contact Sync - - /// Sync all contacts from device - /// - Parameters: - /// - radioID: The device to sync from - /// - since: Optional date for incremental sync (only contacts modified after this time) - /// - Returns: Sync result with count and timestamp - public func syncContacts(radioID: UUID, since: Date? = nil) async throws -> ContactSyncResult { - do { - let meshContacts = try await session.getContacts(since: since) - - eventBroadcaster.yield(.syncProgress(received: 0, total: meshContacts.count)) - - // Build set of public keys from device for cleanup - let devicePublicKeys = Set(meshContacts.map(\.publicKey)) - - // Persist every received contact in a single transaction rather than one - // commit per contact; the per-item save was redundant overhead on every sync. - let frames = meshContacts.map { $0.toContactFrame() } - let receivedCount = try await dataStore.batchSaveContacts(radioID: radioID, from: frames) - - let lastTimestamp = meshContacts - .map { UInt32($0.lastModified.timeIntervalSince1970) } - .max() ?? 0 - - eventBroadcaster.yield(.syncProgress(received: receivedCount, total: meshContacts.count)) - - // On full sync, remove local contacts that no longer exist on device - if since == nil { - let localContacts = try await dataStore.fetchContacts(radioID: radioID) - let orphans = localContacts.filter { !devicePublicKeys.contains($0.publicKey) } - if !orphans.isEmpty { - logger.notice("Full sync prune: \(orphans.count) local contact(s) not found on device (device has \(devicePublicKeys.count), local has \(localContacts.count))") - } - for localContact in orphans { - let keyPrefix = localContact.publicKey.prefix(4).map { String(format: "%02x", $0) }.joined() - logger.notice("Full sync prune: deleting '\(localContact.name)' [\(keyPrefix)…] (favorite=\(localContact.isFavorite), type=\(localContact.typeRawValue), lastModified=\(localContact.lastModified))") - try await dataStore.deleteMessagesForContact(contactID: localContact.id) - try await dataStore.deleteContact(id: localContact.id) - await cleanupCoordinator?.handleCleanup( - contactID: localContact.id, reason: .deleted, publicKey: localContact.publicKey - ) - } - } - - return ContactSyncResult( - contactsReceived: receivedCount, - lastSyncTimestamp: lastTimestamp, - isIncremental: since != nil - ) - } catch let error as MeshCoreError { - throw ContactServiceError.sessionError(error) + // MARK: - Properties + + private let session: any MeshCoreSessionProtocol + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "ContactService") + + /// Sync coordinator for UI refresh notifications. + /// Injected by `ServiceContainer` at construction. + private weak var syncCoordinator: SyncCoordinator? + + /// Runs the cross-service cleanup chain when a contact is deleted, blocked, or unblocked. + /// Injected by `ServiceContainer` at construction. + private let cleanupCoordinator: (any ContactCleanupHandling)? + + /// Multicast broadcaster for sync progress and node-deletion events. + /// Consumers subscribe via `events()`; finished by `ServiceContainer.tearDown()`. + private nonisolated let eventBroadcaster = EventBroadcaster() + + // MARK: - Initialization + + init( + session: any MeshCoreSessionProtocol, + dataStore: any PersistenceStoreProtocol, + syncCoordinator: SyncCoordinator?, + cleanupCoordinator: (any ContactCleanupHandling)? + ) { + self.session = session + self.dataStore = dataStore + self.syncCoordinator = syncCoordinator + self.cleanupCoordinator = cleanupCoordinator + } + + // MARK: - Events + + /// Returns a fresh stream of contact service events. Registration is + /// synchronous, so events yielded after this call returns are never + /// dropped. Consumers re-subscribe per connection because the owning + /// `ServiceContainer` is rebuilt. + public nonisolated func events() -> AsyncStream { + eventBroadcaster.subscribe() + } + + /// Ends every `events()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release their service + /// references. + nonisolated func finishEvents() { + eventBroadcaster.finish() + } + + // MARK: - Configuration + + /// Whether a sync coordinator was injected at construction. + var hasSyncCoordinatorWired: Bool { + syncCoordinator != nil + } + + /// Whether a cleanup coordinator was injected at construction. + var hasCleanupCoordinatorWired: Bool { + cleanupCoordinator != nil + } + + // MARK: - Contact Sync + + /// Sync all contacts from device + /// - Parameters: + /// - radioID: The device to sync from + /// - since: Optional date for incremental sync (only contacts modified after this time) + /// - Returns: Sync result with count and timestamp + public func syncContacts(radioID: UUID, since: Date? = nil) async throws -> ContactSyncResult { + do { + let meshContacts = try await session.getContacts(since: since) + + eventBroadcaster.yield(.syncProgress(received: 0, total: meshContacts.count)) + + // Build set of public keys from device for cleanup + let devicePublicKeys = Set(meshContacts.map(\.publicKey)) + + // Persist every received contact in a single transaction rather than one + // commit per contact; the per-item save was redundant overhead on every sync. + let frames = meshContacts.map { $0.toContactFrame() } + let receivedCount = try await dataStore.batchSaveContacts(radioID: radioID, from: frames) + + let lastTimestamp = meshContacts + .map { UInt32($0.lastModified.timeIntervalSince1970) } + .max() ?? 0 + + eventBroadcaster.yield(.syncProgress(received: receivedCount, total: meshContacts.count)) + + // On full sync, remove local contacts that no longer exist on device + if since == nil { + let localContacts = try await dataStore.fetchContacts(radioID: radioID) + let orphans = localContacts.filter { !devicePublicKeys.contains($0.publicKey) } + if !orphans.isEmpty { + logger.notice("Full sync prune: \(orphans.count) local contact(s) not found on device (device has \(devicePublicKeys.count), local has \(localContacts.count))") } - } - - // MARK: - Get Contact - - /// Get a specific contact by public key from local database - /// - Parameters: - /// - radioID: The device ID - /// - publicKey: The contact's 32-byte public key - /// - Returns: The contact if found - public func getContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { - try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) - } - - // MARK: - Add/Update Contact - - /// Add or update a contact on the device - /// - Parameters: - /// - radioID: The device ID - /// - contact: The contact to add/update - public func addOrUpdateContact(radioID: UUID, contact: ContactFrame) async throws { - do { - let meshContact = contact.toMeshContact() - try await session.addContact(meshContact) - - // Save to local database - _ = try await dataStore.saveContact(radioID: radioID, from: contact) - - // Notify UI to refresh contacts list - await syncCoordinator?.notifyContactsChanged() - } catch let error as MeshCoreError { - if case .deviceError(let code) = error, code == ProtocolError.tableFull.rawValue { - throw ContactServiceError.contactTableFull - } - throw ContactServiceError.sessionError(error) + for localContact in orphans { + let keyPrefix = localContact.publicKey.prefix(4).map { String(format: "%02x", $0) }.joined() + logger.notice("Full sync prune: deleting '\(localContact.name)' [\(keyPrefix)…] (favorite=\(localContact.isFavorite), type=\(localContact.typeRawValue), lastModified=\(localContact.lastModified))") + try await dataStore.deleteMessagesForContact(contactID: localContact.id) + try await dataStore.deleteContact(id: localContact.id) + await cleanupCoordinator?.handleCleanup( + contactID: localContact.id, reason: .deleted, publicKey: localContact.publicKey + ) } + } + + return ContactSyncResult( + contactsReceived: receivedCount, + lastSyncTimestamp: lastTimestamp, + isIncremental: since != nil + ) + } catch let error as MeshCoreError { + throw ContactServiceError.sessionError(error) } + } + + // MARK: - Get Contact + + /// Get a specific contact by public key from local database + /// - Parameters: + /// - radioID: The device ID + /// - publicKey: The contact's 32-byte public key + /// - Returns: The contact if found + public func getContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { + try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) + } + + // MARK: - Add/Update Contact + + /// Add or update a contact on the device + /// - Parameters: + /// - radioID: The device ID + /// - contact: The contact to add/update + public func addOrUpdateContact(radioID: UUID, contact: ContactFrame) async throws { + do { + let meshContact = contact.toMeshContact() + try await session.addContact(meshContact) + + // Save to local database + _ = try await dataStore.saveContact(radioID: radioID, from: contact) + + // Notify UI to refresh contacts list + await syncCoordinator?.notifyContactsChanged() + } catch let error as MeshCoreError { + if case let .deviceError(code) = error, code == ProtocolError.tableFull.rawValue { + throw ContactServiceError.contactTableFull + } + throw ContactServiceError.sessionError(error) + } + } - // MARK: - Remove Contact - - /// Remove a contact from the device - /// - Parameters: - /// - radioID: The device ID - /// - publicKey: The contact's 32-byte public key - public func removeContact(radioID: UUID, publicKey: Data) async throws { - do { - try await session.removeContact(publicKey: publicKey) - - // Remove from local database - if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { - let contactID = contact.id - - // Delete associated messages first - try await dataStore.deleteMessagesForContact(contactID: contactID) - - // Delete the contact - try await dataStore.deleteContact(id: contactID) - - // Trigger cleanup (notifications, badge, session) - await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .deleted, publicKey: publicKey) - } + // MARK: - Remove Contact - // Notify that a node was deleted (for clearing storage full flag) - eventBroadcaster.yield(.nodeDeleted) + /// Remove a contact from the device + /// - Parameters: + /// - radioID: The device ID + /// - publicKey: The contact's 32-byte public key + public func removeContact(radioID: UUID, publicKey: Data) async throws { + do { + try await session.removeContact(publicKey: publicKey) - // Notify UI to refresh contacts list - await syncCoordinator?.notifyContactsChanged() - } catch let error as MeshCoreError { - if case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue { - throw ContactServiceError.contactNotFound - } - throw ContactServiceError.sessionError(error) - } - } + // Remove from local database + if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { + let contactID = contact.id - /// Remove a contact's local data and run the full cleanup chain without contacting the device. - /// Use when the device reports the contact doesn't exist but local data remains. - public func removeLocalContact(contactID: UUID, publicKey: Data) async throws { + // Delete associated messages first try await dataStore.deleteMessagesForContact(contactID: contactID) + + // Delete the contact try await dataStore.deleteContact(id: contactID) + + // Trigger cleanup (notifications, badge, session) await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .deleted, publicKey: publicKey) - eventBroadcaster.yield(.nodeDeleted) - await syncCoordinator?.notifyContactsChanged() + } + + // Notify that a node was deleted (for clearing storage full flag) + eventBroadcaster.yield(.nodeDeleted) + + // Notify UI to refresh contacts list + await syncCoordinator?.notifyContactsChanged() + } catch let error as MeshCoreError { + if case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue { + throw ContactServiceError.contactNotFound + } + throw ContactServiceError.sessionError(error) } - - /// Clears all messages for a direct conversation without deleting the contact. - /// Preserves `lastMessageDate` so the now-empty conversation stays in the chats list - /// (showing "No messages"), unlike delete-conversation which nils the date. Also clears - /// both unread counters and notifies observers so no stale badge or preview survives. - public func clearContactMessages(contactID: UUID) async throws { - try await dataStore.deleteMessagesForContact(contactID: contactID) - try await dataStore.clearUnreadCount(contactID: contactID) - try await dataStore.clearUnreadMentionCount(contactID: contactID) - await syncCoordinator?.notifyConversationsChanged() + } + + /// Remove a contact's local data and run the full cleanup chain without contacting the device. + /// Use when the device reports the contact doesn't exist but local data remains. + public func removeLocalContact(contactID: UUID, publicKey: Data) async throws { + try await dataStore.deleteMessagesForContact(contactID: contactID) + try await dataStore.deleteContact(id: contactID) + await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .deleted, publicKey: publicKey) + eventBroadcaster.yield(.nodeDeleted) + await syncCoordinator?.notifyContactsChanged() + } + + /// Clears all messages for a direct conversation without deleting the contact. + /// Preserves `lastMessageDate` so the now-empty conversation stays in the chats list + /// (showing "No messages"), unlike delete-conversation which nils the date. Also clears + /// both unread counters and notifies observers so no stale badge or preview survives. + public func clearContactMessages(contactID: UUID) async throws { + try await dataStore.deleteMessagesForContact(contactID: contactID) + try await dataStore.clearUnreadCount(contactID: contactID) + try await dataStore.clearUnreadMentionCount(contactID: contactID) + await syncCoordinator?.notifyConversationsChanged() + } + + // MARK: - Reset Path + + /// Reset the path for a contact (force rediscovery) + /// - Parameters: + /// - radioID: The device ID + /// - publicKey: The contact's 32-byte public key + public func resetPath(radioID: UUID, publicKey: Data) async throws { + do { + try await session.resetPath(publicKey: publicKey) + + // Update local contact to show flood routing + if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { + let frame = contact.floodedContactFrame(asOf: UInt32(Date().timeIntervalSince1970)) + _ = try await dataStore.saveContact(radioID: radioID, from: frame) + } + } catch let error as MeshCoreError { + if case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue { + throw ContactServiceError.contactNotFound + } + throw ContactServiceError.sessionError(error) } - - // MARK: - Reset Path - - /// Reset the path for a contact (force rediscovery) - /// - Parameters: - /// - radioID: The device ID - /// - publicKey: The contact's 32-byte public key - public func resetPath(radioID: UUID, publicKey: Data) async throws { - do { - try await session.resetPath(publicKey: publicKey) - - // Update local contact to show flood routing - if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { - let frame = contact.floodedContactFrame(asOf: UInt32(Date().timeIntervalSince1970)) - _ = try await dataStore.saveContact(radioID: radioID, from: frame) - } - } catch let error as MeshCoreError { - if case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue { - throw ContactServiceError.contactNotFound - } - throw ContactServiceError.sessionError(error) - } + } + + // MARK: - Path Discovery + + /// Send a path discovery request to find optimal route to contact + /// - Parameters: + /// - radioID: The device ID + /// - publicKey: The contact's 32-byte public key + /// - Returns: MessageSentInfo containing the estimated timeout from firmware + public func sendPathDiscovery(radioID: UUID, publicKey: Data) async throws -> MessageSentInfo { + do { + return try await session.sendPathDiscovery(to: publicKey) + } catch let error as MeshCoreError { + if case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue { + throw ContactServiceError.contactNotFound + } + throw ContactServiceError.sessionError(error) } - - // MARK: - Path Discovery - - /// Send a path discovery request to find optimal route to contact - /// - Parameters: - /// - radioID: The device ID - /// - publicKey: The contact's 32-byte public key - /// - Returns: MessageSentInfo containing the estimated timeout from firmware - public func sendPathDiscovery(radioID: UUID, publicKey: Data) async throws -> MessageSentInfo { - do { - return try await session.sendPathDiscovery(to: publicKey) - } catch let error as MeshCoreError { - if case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue { - throw ContactServiceError.contactNotFound - } - throw ContactServiceError.sessionError(error) - } + } + + // MARK: - Set Path + + /// Set a specific path for a contact + /// - Parameters: + /// - radioID: The device ID + /// - publicKey: The contact's 32-byte public key + /// - path: The path data (repeater hashes) + /// - pathLength: Encoded path length byte (0xFF for flood, 0 for direct, >0 for routed) + public func setPath(radioID: UUID, publicKey: Data, path: Data, pathLength: UInt8) async throws { + // Get current contact to preserve other fields + guard let existingContact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) else { + throw ContactServiceError.contactNotFound } - // MARK: - Set Path - - /// Set a specific path for a contact - /// - Parameters: - /// - radioID: The device ID - /// - publicKey: The contact's 32-byte public key - /// - path: The path data (repeater hashes) - /// - pathLength: Encoded path length byte (0xFF for flood, 0 for direct, >0 for routed) - public func setPath(radioID: UUID, publicKey: Data, path: Data, pathLength: UInt8) async throws { - // Get current contact to preserve other fields - guard let existingContact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) else { - throw ContactServiceError.contactNotFound - } - - // Create updated contact frame with new path - let updatedFrame = ContactFrame( - publicKey: existingContact.publicKey, - type: existingContact.type, - flags: existingContact.flags, - outPathLength: pathLength, - outPath: path, - name: existingContact.name, - lastAdvertTimestamp: existingContact.lastAdvertTimestamp, - latitude: existingContact.latitude, - longitude: existingContact.longitude, - lastModified: UInt32(Date().timeIntervalSince1970) - ) - - // Send update to device - try await addOrUpdateContact(radioID: radioID, contact: updatedFrame) + // Create updated contact frame with new path + let updatedFrame = ContactFrame( + publicKey: existingContact.publicKey, + type: existingContact.type, + flags: existingContact.flags, + outPathLength: pathLength, + outPath: path, + name: existingContact.name, + lastAdvertTimestamp: existingContact.lastAdvertTimestamp, + latitude: existingContact.latitude, + longitude: existingContact.longitude, + lastModified: UInt32(Date().timeIntervalSince1970) + ) + + // Send update to device + try await addOrUpdateContact(radioID: radioID, contact: updatedFrame) + } + + // MARK: - Share Contact + + /// Share a contact via zero-hop broadcast + /// - Parameter publicKey: The contact's 32-byte public key to share + public func shareContact(publicKey: Data) async throws { + do { + try await session.shareContact(publicKey: publicKey) + } catch let error as MeshCoreError { + if case let .deviceError(code) = error, code == ProtocolError.tableFull.rawValue { + throw ContactServiceError.shareContactUnavailable + } + if case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue { + throw ContactServiceError.contactNotFound + } + throw ContactServiceError.sessionError(error) } - - // MARK: - Share Contact - - /// Share a contact via zero-hop broadcast - /// - Parameter publicKey: The contact's 32-byte public key to share - public func shareContact(publicKey: Data) async throws { - do { - try await session.shareContact(publicKey: publicKey) - } catch let error as MeshCoreError { - if case .deviceError(let code) = error, code == ProtocolError.tableFull.rawValue { - throw ContactServiceError.shareContactUnavailable - } - if case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue { - throw ContactServiceError.contactNotFound - } - throw ContactServiceError.sessionError(error) - } + } + + // MARK: - Export/Import Contact + + /// Export a contact to a shareable URI (legacy firmware call) + /// - Parameter publicKey: The contact's 32-byte public key (nil for self) + /// - Returns: Contact URI string + @available(*, deprecated, message: "Use exportContactURI(name:publicKey:type:) instead") + public func exportContact(publicKey: Data? = nil) async throws -> String { + do { + return try await session.exportContact(publicKey: publicKey) + } catch let error as MeshCoreError { + throw ContactServiceError.sessionError(error) } - - // MARK: - Export/Import Contact - - /// Export a contact to a shareable URI (legacy firmware call) - /// - Parameter publicKey: The contact's 32-byte public key (nil for self) - /// - Returns: Contact URI string - @available(*, deprecated, message: "Use exportContactURI(name:publicKey:type:) instead") - public func exportContact(publicKey: Data? = nil) async throws -> String { - do { - return try await session.exportContact(publicKey: publicKey) - } catch let error as MeshCoreError { - throw ContactServiceError.sessionError(error) - } + } + + private static let contactURIScheme = "meshcore" + private static let contactURIHost = "contact" + private static let contactURIPath = "/add" + private static let contactURINameKey = "name" + private static let contactURIPublicKeyKey = "public_key" + private static let contactURITypeKey = "type" + + /// Build a shareable contact URI from contact information + /// - Parameters: + /// - name: The contact's advertised name + /// - publicKey: The contact's 32-byte public key + /// - type: The contact type (chat, repeater, room) + /// - Returns: Contact URI string in format: meshcore://contact/add?name=...&public_key=...&type=... + public static func exportContactURI(name: String, publicKey: Data, type: ContactType) -> String { + // Build via URLComponents so reserved characters in the name (`&`, `=`, `+`, `?`) are + // percent-encoded per query item. String interpolation would let a crafted name inject + // its own public_key/type and spoof the parsed contact identity. + var components = URLComponents() + components.scheme = contactURIScheme + components.host = contactURIHost + components.path = contactURIPath + components.queryItems = [ + URLQueryItem(name: contactURINameKey, value: name), + URLQueryItem(name: contactURIPublicKeyKey, value: publicKey.uppercaseHexString()), + URLQueryItem(name: contactURITypeKey, value: String(type.rawValue)) + ] + return components.url?.absoluteString ?? "" + } + + /// Import a contact from card data + /// - Parameter cardData: The contact card data + public func importContact(cardData: Data) async throws { + do { + try await session.importContact(cardData: cardData) + } catch let error as MeshCoreError { + throw ContactServiceError.sessionError(error) } - - private static let contactURIScheme = "meshcore" - private static let contactURIHost = "contact" - private static let contactURIPath = "/add" - private static let contactURINameKey = "name" - private static let contactURIPublicKeyKey = "public_key" - private static let contactURITypeKey = "type" - - /// Build a shareable contact URI from contact information - /// - Parameters: - /// - name: The contact's advertised name - /// - publicKey: The contact's 32-byte public key - /// - type: The contact type (chat, repeater, room) - /// - Returns: Contact URI string in format: meshcore://contact/add?name=...&public_key=...&type=... - public static func exportContactURI(name: String, publicKey: Data, type: ContactType) -> String { - // Build via URLComponents so reserved characters in the name (`&`, `=`, `+`, `?`) are - // percent-encoded per query item. String interpolation would let a crafted name inject - // its own public_key/type and spoof the parsed contact identity. - var components = URLComponents() - components.scheme = contactURIScheme - components.host = contactURIHost - components.path = contactURIPath - components.queryItems = [ - URLQueryItem(name: contactURINameKey, value: name), - URLQueryItem(name: contactURIPublicKeyKey, value: publicKey.uppercaseHexString()), - URLQueryItem(name: contactURITypeKey, value: String(type.rawValue)) - ] - return components.url?.absoluteString ?? "" + } + + // MARK: - Local Database Operations + + /// Get all contacts for a device from local database + public func getContacts(radioID: UUID) async throws -> [ContactDTO] { + try await dataStore.fetchContacts(radioID: radioID) + } + + /// Get conversations (contacts with messages) from local database + public func getConversations(radioID: UUID) async throws -> [ContactDTO] { + try await dataStore.fetchConversations(radioID: radioID) + } + + /// Get a contact by ID from local database + public func getContactByID(_ id: UUID) async throws -> ContactDTO? { + try await dataStore.fetchContact(id: id) + } + + /// Update local contact preferences (nickname, blocked, favorite) + public func updateContactPreferences( + contactID: UUID, + nickname: String? = nil, + isBlocked: Bool? = nil, + isFavorite: Bool? = nil + ) async throws { + guard let existing = try await dataStore.fetchContact(id: contactID) else { + throw ContactServiceError.contactNotFound } - /// Import a contact from card data - /// - Parameter cardData: The contact card data - public func importContact(cardData: Data) async throws { - do { - try await session.importContact(cardData: cardData) - } catch let error as MeshCoreError { - throw ContactServiceError.sessionError(error) - } + // Check blocking state transitions + let isBeingBlocked = isBlocked == true && !existing.isBlocked + let isBeingUnblocked = isBlocked == false && existing.isBlocked + + // Create updated DTO preserving existing values + let updated = ContactDTO( + from: Contact( + id: existing.id, + radioID: existing.radioID, + publicKey: existing.publicKey, + name: existing.name, + typeRawValue: existing.typeRawValue, + flags: existing.flags, + outPathLength: existing.outPathLength, + outPath: existing.outPath, + lastAdvertTimestamp: existing.lastAdvertTimestamp, + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: existing.lastModified, + nickname: nickname ?? existing.nickname, + isBlocked: isBlocked ?? existing.isBlocked, + isFavorite: isFavorite ?? existing.isFavorite, + lastMessageDate: existing.lastMessageDate, + unreadCount: isBeingBlocked ? 0 : existing.unreadCount, + ocvPreset: existing.ocvPreset, + customOCVArrayString: existing.customOCVArrayString + ) + ) + + try await dataStore.saveContact(updated) + + // Trigger cleanup for blocking state changes + if isBeingBlocked { + await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .blocked, publicKey: existing.publicKey) + } else if isBeingUnblocked { + await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .unblocked, publicKey: existing.publicKey) } - - // MARK: - Local Database Operations - - /// Get all contacts for a device from local database - public func getContacts(radioID: UUID) async throws -> [ContactDTO] { - try await dataStore.fetchContacts(radioID: radioID) + } + + /// Updates OCV settings for a contact + /// - Parameters: + /// - contactID: The contact's ID + /// - preset: The OCV preset name + /// - customArray: Custom OCV array string (for custom preset) + public func updateContactOCVSettings( + contactID: UUID, + preset: String, + customArray: String? + ) async throws { + guard let existing = try await dataStore.fetchContact(id: contactID) else { + throw ContactServiceError.contactNotFound } - /// Get conversations (contacts with messages) from local database - public func getConversations(radioID: UUID) async throws -> [ContactDTO] { - try await dataStore.fetchConversations(radioID: radioID) + let updated = ContactDTO( + from: Contact( + id: existing.id, + radioID: existing.radioID, + publicKey: existing.publicKey, + name: existing.name, + typeRawValue: existing.typeRawValue, + flags: existing.flags, + outPathLength: existing.outPathLength, + outPath: existing.outPath, + lastAdvertTimestamp: existing.lastAdvertTimestamp, + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: existing.lastModified, + nickname: existing.nickname, + isBlocked: existing.isBlocked, + isFavorite: existing.isFavorite, + lastMessageDate: existing.lastMessageDate, + unreadCount: existing.unreadCount, + ocvPreset: preset, + customOCVArrayString: customArray + ) + ) + + try await dataStore.saveContact(updated) + } + + // MARK: - Device Favorite Sync + + /// Sets a contact's favorite status on the device and updates local storage. + /// + /// This method updates the device's contact flags (bit 0 = favorite), waits for + /// confirmation, then updates the local SwiftData contact. + /// + /// - Parameters: + /// - contactID: The contact's UUID. + /// - isFavorite: Whether to mark the contact as a favorite. + /// - Throws: `ContactServiceError` if the device update fails. + public func setContactFavorite(_ contactID: UUID, isFavorite: Bool) async throws { + guard let existing = try await dataStore.fetchContact(id: contactID) else { + throw ContactServiceError.contactNotFound } - /// Get a contact by ID from local database - public func getContactByID(_ id: UUID) async throws -> ContactDTO? { - try await dataStore.fetchContact(id: id) + // Calculate new flags: set or clear bit 0 + let newFlags: UInt8 = isFavorite + ? existing.flags | 0x01 + : existing.flags & ~0x01 + + // Build MeshContact for device update + let meshContact = MeshContact( + id: existing.publicKey.uppercaseHexString(), + publicKey: existing.publicKey, + type: ContactType(rawValue: existing.typeRawValue) ?? .chat, + flags: ContactFlags(rawValue: existing.flags), + outPathLength: existing.outPathLength, + outPath: existing.outPath, + advertisedName: existing.name, + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(existing.lastAdvertTimestamp)), + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: Date(timeIntervalSince1970: TimeInterval(existing.lastModified)) + ) + + // Push to device and wait for confirmation + do { + try await session.changeContactFlags(meshContact, flags: ContactFlags(rawValue: newFlags)) + } catch let error as MeshCoreError { + throw ContactServiceError.sessionError(error) } - /// Update local contact preferences (nickname, blocked, favorite) - public func updateContactPreferences( - contactID: UUID, - nickname: String? = nil, - isBlocked: Bool? = nil, - isFavorite: Bool? = nil - ) async throws { - guard let existing = try await dataStore.fetchContact(id: contactID) else { - throw ContactServiceError.contactNotFound - } - - // Check blocking state transitions - let isBeingBlocked = isBlocked == true && !existing.isBlocked - let isBeingUnblocked = isBlocked == false && existing.isBlocked - - // Create updated DTO preserving existing values - let updated = ContactDTO( - from: Contact( - id: existing.id, - radioID: existing.radioID, - publicKey: existing.publicKey, - name: existing.name, - typeRawValue: existing.typeRawValue, - flags: existing.flags, - outPathLength: existing.outPathLength, - outPath: existing.outPath, - lastAdvertTimestamp: existing.lastAdvertTimestamp, - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: existing.lastModified, - nickname: nickname ?? existing.nickname, - isBlocked: isBlocked ?? existing.isBlocked, - isFavorite: isFavorite ?? existing.isFavorite, - lastMessageDate: existing.lastMessageDate, - unreadCount: isBeingBlocked ? 0 : existing.unreadCount, - ocvPreset: existing.ocvPreset, - customOCVArrayString: existing.customOCVArrayString - ) - ) - - try await dataStore.saveContact(updated) - - // Trigger cleanup for blocking state changes - if isBeingBlocked { - await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .blocked, publicKey: existing.publicKey) - } else if isBeingUnblocked { - await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .unblocked, publicKey: existing.publicKey) - } + // Device confirmed - update local storage + let updated = ContactDTO( + from: Contact( + id: existing.id, + radioID: existing.radioID, + publicKey: existing.publicKey, + name: existing.name, + typeRawValue: existing.typeRawValue, + flags: newFlags, + outPathLength: existing.outPathLength, + outPath: existing.outPath, + lastAdvertTimestamp: existing.lastAdvertTimestamp, + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: existing.lastModified, + nickname: existing.nickname, + isBlocked: existing.isBlocked, + isFavorite: isFavorite, + lastMessageDate: existing.lastMessageDate, + unreadCount: existing.unreadCount, + ocvPreset: existing.ocvPreset, + customOCVArrayString: existing.customOCVArrayString + ) + ) + + try await dataStore.saveContact(updated) + } + + // MARK: - Telemetry Permissions + + /// Whether a contact has telemetry permission flags set (bits 1-3). + public static func hasTelemetryPermissions(flags: UInt8) -> Bool { + (flags & 0x0E) != 0 + } + + /// Sets telemetry permission flags on a contact's device record. + /// Bits 1-3 of contact.flags control base/location/environment permissions. + /// Bit 0 (favourite) is preserved. + /// + /// - Parameters: + /// - contactID: The contact's UUID. + /// - granted: Whether to grant telemetry permissions. + /// - Throws: `ContactServiceError` if the device update fails. + public func setTelemetryPermissions(_ contactID: UUID, granted: Bool) async throws { + guard let existing = try await dataStore.fetchContact(id: contactID) else { + throw ContactServiceError.contactNotFound } - /// Updates OCV settings for a contact - /// - Parameters: - /// - contactID: The contact's ID - /// - preset: The OCV preset name - /// - customArray: Custom OCV array string (for custom preset) - public func updateContactOCVSettings( - contactID: UUID, - preset: String, - customArray: String? - ) async throws { - guard let existing = try await dataStore.fetchContact(id: contactID) else { - throw ContactServiceError.contactNotFound - } - - let updated = ContactDTO( - from: Contact( - id: existing.id, - radioID: existing.radioID, - publicKey: existing.publicKey, - name: existing.name, - typeRawValue: existing.typeRawValue, - flags: existing.flags, - outPathLength: existing.outPathLength, - outPath: existing.outPath, - lastAdvertTimestamp: existing.lastAdvertTimestamp, - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: existing.lastModified, - nickname: existing.nickname, - isBlocked: existing.isBlocked, - isFavorite: existing.isFavorite, - lastMessageDate: existing.lastMessageDate, - unreadCount: existing.unreadCount, - ocvPreset: preset, - customOCVArrayString: customArray - ) - ) - - try await dataStore.saveContact(updated) + // Set or clear bits 1-3, preserving bit 0 (favourite) + let newFlags: UInt8 = granted + ? existing.flags | 0x0E + : existing.flags & ~0x0E + + // Build MeshContact for device update + let meshContact = MeshContact( + id: existing.publicKey.uppercaseHexString(), + publicKey: existing.publicKey, + type: ContactType(rawValue: existing.typeRawValue) ?? .chat, + flags: ContactFlags(rawValue: existing.flags), + outPathLength: existing.outPathLength, + outPath: existing.outPath, + advertisedName: existing.name, + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(existing.lastAdvertTimestamp)), + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: Date(timeIntervalSince1970: TimeInterval(existing.lastModified)) + ) + + // Push to device and wait for confirmation + do { + try await session.changeContactFlags(meshContact, flags: ContactFlags(rawValue: newFlags)) + } catch let error as MeshCoreError { + throw ContactServiceError.sessionError(error) } - // MARK: - Device Favorite Sync - - /// Sets a contact's favorite status on the device and updates local storage. - /// - /// This method updates the device's contact flags (bit 0 = favorite), waits for - /// confirmation, then updates the local SwiftData contact. - /// - /// - Parameters: - /// - contactID: The contact's UUID. - /// - isFavorite: Whether to mark the contact as a favorite. - /// - Throws: `ContactServiceError` if the device update fails. - public func setContactFavorite(_ contactID: UUID, isFavorite: Bool) async throws { - guard let existing = try await dataStore.fetchContact(id: contactID) else { - throw ContactServiceError.contactNotFound - } - - // Calculate new flags: set or clear bit 0 - let newFlags: UInt8 = isFavorite - ? existing.flags | 0x01 - : existing.flags & ~0x01 - - // Build MeshContact for device update - let meshContact = MeshContact( - id: existing.publicKey.uppercaseHexString(), - publicKey: existing.publicKey, - type: ContactType(rawValue: existing.typeRawValue) ?? .chat, - flags: ContactFlags(rawValue: existing.flags), - outPathLength: existing.outPathLength, - outPath: existing.outPath, - advertisedName: existing.name, - lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(existing.lastAdvertTimestamp)), - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: Date(timeIntervalSince1970: TimeInterval(existing.lastModified)) - ) - - // Push to device and wait for confirmation - do { - try await session.changeContactFlags(meshContact, flags: ContactFlags(rawValue: newFlags)) - } catch let error as MeshCoreError { - throw ContactServiceError.sessionError(error) - } - - // Device confirmed - update local storage - let updated = ContactDTO( - from: Contact( - id: existing.id, - radioID: existing.radioID, - publicKey: existing.publicKey, - name: existing.name, - typeRawValue: existing.typeRawValue, - flags: newFlags, - outPathLength: existing.outPathLength, - outPath: existing.outPath, - lastAdvertTimestamp: existing.lastAdvertTimestamp, - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: existing.lastModified, - nickname: existing.nickname, - isBlocked: existing.isBlocked, - isFavorite: isFavorite, - lastMessageDate: existing.lastMessageDate, - unreadCount: existing.unreadCount, - ocvPreset: existing.ocvPreset, - customOCVArrayString: existing.customOCVArrayString - ) - ) - - try await dataStore.saveContact(updated) + // Device confirmed - update local storage + let updated = ContactDTO( + from: Contact( + id: existing.id, + radioID: existing.radioID, + publicKey: existing.publicKey, + name: existing.name, + typeRawValue: existing.typeRawValue, + flags: newFlags, + outPathLength: existing.outPathLength, + outPath: existing.outPath, + lastAdvertTimestamp: existing.lastAdvertTimestamp, + latitude: existing.latitude, + longitude: existing.longitude, + lastModified: existing.lastModified, + nickname: existing.nickname, + isBlocked: existing.isBlocked, + isFavorite: existing.isFavorite, + lastMessageDate: existing.lastMessageDate, + unreadCount: existing.unreadCount, + ocvPreset: existing.ocvPreset, + customOCVArrayString: existing.customOCVArrayString + ) + ) + + try await dataStore.saveContact(updated) + } + + // MARK: - Favorites Migration + + private static let favoritesMigrationKey = "hasMigratedContactFavorites" + + /// Migrates existing app favorites to device flags (one-time operation). + /// + /// On first run after upgrade, this merges app favorites with device favorites: + /// any contact that is favorite in either location becomes favorite in both. + /// After migration, device wins for future syncs. + /// + /// - Parameter radioID: The connected device's UUID. + /// - Returns: Number of contacts migrated to device. + @discardableResult + public func migrateAppFavoritesToDevice(radioID: UUID) async throws -> Int { + // Check if already migrated + if UserDefaults.standard.bool(forKey: Self.favoritesMigrationKey) { + return 0 } - // MARK: - Telemetry Permissions + logger.info("Starting favorites migration to device") - /// Whether a contact has telemetry permission flags set (bits 1-3). - public static func hasTelemetryPermissions(flags: UInt8) -> Bool { - (flags & 0x0E) != 0 + // Find contacts that are favorite in app but not on device + let contacts = try await dataStore.fetchContacts(radioID: radioID) + let toMigrate = contacts.filter { contact in + contact.isFavorite && (contact.flags & 0x01) == 0 } - /// Sets telemetry permission flags on a contact's device record. - /// Bits 1-3 of contact.flags control base/location/environment permissions. - /// Bit 0 (favourite) is preserved. - /// - /// - Parameters: - /// - contactID: The contact's UUID. - /// - granted: Whether to grant telemetry permissions. - /// - Throws: `ContactServiceError` if the device update fails. - public func setTelemetryPermissions(_ contactID: UUID, granted: Bool) async throws { - guard let existing = try await dataStore.fetchContact(id: contactID) else { - throw ContactServiceError.contactNotFound - } - - // Set or clear bits 1-3, preserving bit 0 (favourite) - let newFlags: UInt8 = granted - ? existing.flags | 0x0E - : existing.flags & ~0x0E - - // Build MeshContact for device update - let meshContact = MeshContact( - id: existing.publicKey.uppercaseHexString(), - publicKey: existing.publicKey, - type: ContactType(rawValue: existing.typeRawValue) ?? .chat, - flags: ContactFlags(rawValue: existing.flags), - outPathLength: existing.outPathLength, - outPath: existing.outPath, - advertisedName: existing.name, - lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(existing.lastAdvertTimestamp)), - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: Date(timeIntervalSince1970: TimeInterval(existing.lastModified)) - ) - - // Push to device and wait for confirmation - do { - try await session.changeContactFlags(meshContact, flags: ContactFlags(rawValue: newFlags)) - } catch let error as MeshCoreError { - throw ContactServiceError.sessionError(error) - } - - // Device confirmed - update local storage - let updated = ContactDTO( - from: Contact( - id: existing.id, - radioID: existing.radioID, - publicKey: existing.publicKey, - name: existing.name, - typeRawValue: existing.typeRawValue, - flags: newFlags, - outPathLength: existing.outPathLength, - outPath: existing.outPath, - lastAdvertTimestamp: existing.lastAdvertTimestamp, - latitude: existing.latitude, - longitude: existing.longitude, - lastModified: existing.lastModified, - nickname: existing.nickname, - isBlocked: existing.isBlocked, - isFavorite: existing.isFavorite, - lastMessageDate: existing.lastMessageDate, - unreadCount: existing.unreadCount, - ocvPreset: existing.ocvPreset, - customOCVArrayString: existing.customOCVArrayString - ) - ) - - try await dataStore.saveContact(updated) + if toMigrate.isEmpty { + logger.info("No favorites to migrate, marking complete") + UserDefaults.standard.set(true, forKey: Self.favoritesMigrationKey) + return 0 } - // MARK: - Favorites Migration - - private static let favoritesMigrationKey = "hasMigratedContactFavorites" - - /// Migrates existing app favorites to device flags (one-time operation). - /// - /// On first run after upgrade, this merges app favorites with device favorites: - /// any contact that is favorite in either location becomes favorite in both. - /// After migration, device wins for future syncs. - /// - /// - Parameter radioID: The connected device's UUID. - /// - Returns: Number of contacts migrated to device. - @discardableResult - public func migrateAppFavoritesToDevice(radioID: UUID) async throws -> Int { - // Check if already migrated - if UserDefaults.standard.bool(forKey: Self.favoritesMigrationKey) { - return 0 - } - - logger.info("Starting favorites migration to device") - - // Find contacts that are favorite in app but not on device - let contacts = try await dataStore.fetchContacts(radioID: radioID) - let toMigrate = contacts.filter { contact in - contact.isFavorite && (contact.flags & 0x01) == 0 - } - - if toMigrate.isEmpty { - logger.info("No favorites to migrate, marking complete") - UserDefaults.standard.set(true, forKey: Self.favoritesMigrationKey) - return 0 - } - - logger.info("Migrating \(toMigrate.count) favorites to device") - - var migratedCount = 0 - for contact in toMigrate { - do { - try await setContactFavorite(contact.id, isFavorite: true) - migratedCount += 1 - } catch { - logger.warning("Failed to migrate favorite for \(contact.name): \(error)") - // Continue with other contacts, will retry on next connect - } - } - - // Only mark complete if all were migrated - if migratedCount == toMigrate.count { - UserDefaults.standard.set(true, forKey: Self.favoritesMigrationKey) - logger.info("Favorites migration complete: \(migratedCount) contacts") - } else { - logger.warning("Partial migration: \(migratedCount)/\(toMigrate.count), will retry") - } + logger.info("Migrating \(toMigrate.count) favorites to device") + + var migratedCount = 0 + for contact in toMigrate { + do { + try await setContactFavorite(contact.id, isFavorite: true) + migratedCount += 1 + } catch { + logger.warning("Failed to migrate favorite for \(contact.name): \(error)") + // Continue with other contacts, will retry on next connect + } + } - return migratedCount + // Only mark complete if all were migrated + if migratedCount == toMigrate.count { + UserDefaults.standard.set(true, forKey: Self.favoritesMigrationKey) + logger.info("Favorites migration complete: \(migratedCount) contacts") + } else { + logger.warning("Partial migration: \(migratedCount)/\(toMigrate.count), will retry") } + + return migratedCount + } } // MARK: - ContactServiceProtocol Conformance extension ContactService: ContactServiceProtocol { - // Already implements syncContacts(radioID:since:) -> ContactSyncResult + // Already implements syncContacts(radioID:since:) -> ContactSyncResult } // MARK: - MeshContact Extensions extension MeshContact { - /// Converts a MeshContact to a ContactFrame for persistence - func toContactFrame() -> ContactFrame { - ContactFrame( - publicKey: publicKey, - type: type, - typeRawValue: typeRawValue, - flags: flags.rawValue, - outPathLength: outPathLength, - outPath: outPath, - name: advertisedName, - lastAdvertTimestamp: UInt32(lastAdvertisement.timeIntervalSince1970), - latitude: latitude, - longitude: longitude, - lastModified: UInt32(lastModified.timeIntervalSince1970) - ) - } + /// Converts a MeshContact to a ContactFrame for persistence + func toContactFrame() -> ContactFrame { + ContactFrame( + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: flags.rawValue, + outPathLength: outPathLength, + outPath: outPath, + name: advertisedName, + lastAdvertTimestamp: UInt32(lastAdvertisement.timeIntervalSince1970), + latitude: latitude, + longitude: longitude, + lastModified: UInt32(lastModified.timeIntervalSince1970) + ) + } } // MARK: - ContactFrame Extensions -extension ContactFrame { - /// Converts a ContactFrame to a MeshContact for session operations - public func toMeshContact() -> MeshContact { - MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: type, - typeRawValue: typeRawValue, - flags: ContactFlags(rawValue: flags), - outPathLength: outPathLength, - outPath: outPath, - advertisedName: name, - lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(lastAdvertTimestamp)), - latitude: latitude, - longitude: longitude, - lastModified: Date(timeIntervalSince1970: TimeInterval(lastModified)) - ) - } +public extension ContactFrame { + /// Converts a ContactFrame to a MeshContact for session operations + func toMeshContact() -> MeshContact { + MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: type, + typeRawValue: typeRawValue, + flags: ContactFlags(rawValue: flags), + outPathLength: outPathLength, + outPath: outPath, + advertisedName: name, + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(lastAdvertTimestamp)), + latitude: latitude, + longitude: longitude, + lastModified: Date(timeIntervalSince1970: TimeInterval(lastModified)) + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/ContactServiceEvent.swift b/MC1Services/Sources/MC1Services/Services/ContactServiceEvent.swift index 1ef45f11..1fa213d1 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactServiceEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactServiceEvent.swift @@ -3,9 +3,9 @@ import Foundation /// One-to-many notifications broadcast by `ContactService` to its /// `events()` subscribers. public enum ContactServiceEvent: Sendable { - /// Progress during a contact sync: `received` of `total` contacts persisted. - case syncProgress(received: Int, total: Int) + /// Progress during a contact sync: `received` of `total` contacts persisted. + case syncProgress(received: Int, total: Int) - /// A contact was removed from the device, so node storage is no longer full. - case nodeDeleted + /// A contact was removed from the device, so node storage is no longer full. + case nodeDeleted } diff --git a/MC1Services/Sources/MC1Services/Services/DebugLogBuffer.swift b/MC1Services/Sources/MC1Services/Services/DebugLogBuffer.swift index ce154c7d..ab9db343 100644 --- a/MC1Services/Sources/MC1Services/Services/DebugLogBuffer.swift +++ b/MC1Services/Sources/MC1Services/Services/DebugLogBuffer.swift @@ -4,88 +4,88 @@ import os /// Actor for buffering debug log entries and flushing to persistence. /// Provides batched saves for performance and backpressure handling. public actor DebugLogBuffer { - /// Backing storage for the shared buffer. Reassigned on every connection from - /// `ServiceContainer.init` while `PersistentLogger` reads it from arbitrary - /// actors, so the write and the reads must be synchronized. - private static let sharedStorage = OSAllocatedUnfairLock(initialState: nil) + /// Backing storage for the shared buffer. Reassigned on every connection from + /// `ServiceContainer.init` while `PersistentLogger` reads it from arbitrary + /// actors, so the write and the reads must be synchronized. + private static let sharedStorage = OSAllocatedUnfairLock(initialState: nil) - /// Shared buffer instance for app-wide logging. - public static var shared: DebugLogBuffer? { - get { sharedStorage.withLock { $0 } } - set { sharedStorage.withLock { $0 = newValue } } - } + /// Shared buffer instance for app-wide logging. + public static var shared: DebugLogBuffer? { + get { sharedStorage.withLock { $0 } } + set { sharedStorage.withLock { $0 = newValue } } + } - private let dataStore: any DebugLogPersisting - private var buffer: [DebugLogEntryDTO] = [] - private var flushTask: Task? - private var isFlushScheduled = false - private let flushInterval: Duration = .seconds(5) - private let maxBufferSize = 50 + private let dataStore: any DebugLogPersisting + private var buffer: [DebugLogEntryDTO] = [] + private var flushTask: Task? + private var isFlushScheduled = false + private let flushInterval: Duration = .seconds(5) + private let maxBufferSize = 50 - private static let logger = Logger(subsystem: "com.mc1", category: "DebugLogBuffer") + private static let logger = Logger(subsystem: "com.mc1", category: "DebugLogBuffer") - public init(dataStore: any DebugLogPersisting) { - self.dataStore = dataStore - } + public init(dataStore: any DebugLogPersisting) { + self.dataStore = dataStore + } - public func append(_ entry: DebugLogEntryDTO) { - buffer.append(entry) + public func append(_ entry: DebugLogEntryDTO) { + buffer.append(entry) - if buffer.count >= maxBufferSize { - flushNow() - } else { - scheduleFlush() - } + if buffer.count >= maxBufferSize { + flushNow() + } else { + scheduleFlush() } + } - public func flush() async { - flushTask?.cancel() - flushTask = nil - isFlushScheduled = false - await flushBuffer() - } + public func flush() async { + flushTask?.cancel() + flushTask = nil + isFlushScheduled = false + await flushBuffer() + } - public func shutdown() async { - flushTask?.cancel() - flushTask = nil - isFlushScheduled = false - await flushBuffer() - } + public func shutdown() async { + flushTask?.cancel() + flushTask = nil + isFlushScheduled = false + await flushBuffer() + } - private func scheduleFlush() { - guard !isFlushScheduled else { return } - isFlushScheduled = true + private func scheduleFlush() { + guard !isFlushScheduled else { return } + isFlushScheduled = true - flushTask = Task { - try? await Task.sleep(for: flushInterval) - guard !Task.isCancelled else { return } - isFlushScheduled = false - await flushBuffer() - } + flushTask = Task { + try? await Task.sleep(for: flushInterval) + guard !Task.isCancelled else { return } + isFlushScheduled = false + await flushBuffer() } + } - private func flushNow() { - flushTask?.cancel() - flushTask = nil - isFlushScheduled = false - Task { await flushBuffer() } - } + private func flushNow() { + flushTask?.cancel() + flushTask = nil + isFlushScheduled = false + Task { await flushBuffer() } + } - private func flushBuffer() async { - guard !buffer.isEmpty else { return } - let entries = buffer - buffer = [] + private func flushBuffer() async { + guard !buffer.isEmpty else { return } + let entries = buffer + buffer = [] - do { - try await dataStore.saveDebugLogEntries(entries) - } catch { - Self.logger.error("Failed to save debug logs: \(error.localizedDescription)") + do { + try await dataStore.saveDebugLogEntries(entries) + } catch { + Self.logger.error("Failed to save debug logs: \(error.localizedDescription)") - // Backpressure: only re-queue if total won't exceed limit - let entriesToRequeue = Array(entries.prefix(maxBufferSize)) - if buffer.count + entriesToRequeue.count < maxBufferSize * 2 { - buffer.insert(contentsOf: entriesToRequeue, at: 0) - } - } + // Backpressure: only re-queue if total won't exceed limit + let entriesToRequeue = Array(entries.prefix(maxBufferSize)) + if buffer.count + entriesToRequeue.count < maxBufferSize * 2 { + buffer.insert(contentsOf: entriesToRequeue, at: 0) + } } + } } diff --git a/MC1Services/Sources/MC1Services/Services/DeviceGPSState.swift b/MC1Services/Sources/MC1Services/Services/DeviceGPSState.swift index ae515618..a8ba195b 100644 --- a/MC1Services/Sources/MC1Services/Services/DeviceGPSState.swift +++ b/MC1Services/Sources/MC1Services/Services/DeviceGPSState.swift @@ -1,11 +1,11 @@ import Foundation public struct DeviceGPSState: Sendable, Equatable { - public let isSupported: Bool - public let isEnabled: Bool + public let isSupported: Bool + public let isEnabled: Bool - public init(isSupported: Bool, isEnabled: Bool) { - self.isSupported = isSupported - self.isEnabled = isEnabled - } + public init(isSupported: Bool, isEnabled: Bool) { + self.isSupported = isSupported + self.isEnabled = isEnabled + } } diff --git a/MC1Services/Sources/MC1Services/Services/DevicePairingDelegate.swift b/MC1Services/Sources/MC1Services/Services/DevicePairingDelegate.swift index 17d51f45..33d4dfdb 100644 --- a/MC1Services/Sources/MC1Services/Services/DevicePairingDelegate.swift +++ b/MC1Services/Sources/MC1Services/Services/DevicePairingDelegate.swift @@ -8,10 +8,10 @@ import Foundation /// without app-visible events. @MainActor public protocol DevicePairingDelegate: AnyObject { - /// The user removed a device from the system pairing registry - /// (iOS: Settings → Privacy & Security → Accessories). - func devicePairing(_ service: any DevicePairingService, didRemoveDeviceWithID id: UUID) + /// The user removed a device from the system pairing registry + /// (iOS: Settings → Privacy & Security → Accessories). + func devicePairing(_ service: any DevicePairingService, didRemoveDeviceWithID id: UUID) - /// Pairing failed for a device (iOS: wrong PIN). The local record should be cleaned up. - func devicePairing(_ service: any DevicePairingService, didFailPairingForDeviceWithID id: UUID) + /// Pairing failed for a device (iOS: wrong PIN). The local record should be cleaned up. + func devicePairing(_ service: any DevicePairingService, didFailPairingForDeviceWithID id: UUID) } diff --git a/MC1Services/Sources/MC1Services/Services/DevicePairingError.swift b/MC1Services/Sources/MC1Services/Services/DevicePairingError.swift index 37c4f859..c58b2773 100644 --- a/MC1Services/Sources/MC1Services/Services/DevicePairingError.swift +++ b/MC1Services/Sources/MC1Services/Services/DevicePairingError.swift @@ -8,19 +8,19 @@ import Foundation /// `AccessorySetupKitError` cases into these at the seam boundary; the macOS scan picker /// throws them directly. public enum DevicePairingError: LocalizedError, Sendable { - /// The user dismissed the discovery picker (the AccessorySetupKit system picker on iOS, - /// the in-app scan sheet on macOS). A benign cancellation, not a failure. - case cancelled + /// The user dismissed the discovery picker (the AccessorySetupKit system picker on iOS, + /// the in-app scan sheet on macOS). A benign cancellation, not a failure. + case cancelled - /// A pairing flow is already running; the re-entrant request was ignored. - case alreadyInProgress + /// A pairing flow is already running; the re-entrant request was ignored. + case alreadyInProgress - public var errorDescription: String? { - switch self { - case .cancelled: - return "Device selection was cancelled." - case .alreadyInProgress: - return "Device pairing is already in progress." - } + public var errorDescription: String? { + switch self { + case .cancelled: + "Device selection was cancelled." + case .alreadyInProgress: + "Device pairing is already in progress." } + } } diff --git a/MC1Services/Sources/MC1Services/Services/DevicePairingFactory.swift b/MC1Services/Sources/MC1Services/Services/DevicePairingFactory.swift index 6a46c61d..4551b53c 100644 --- a/MC1Services/Sources/MC1Services/Services/DevicePairingFactory.swift +++ b/MC1Services/Sources/MC1Services/Services/DevicePairingFactory.swift @@ -8,12 +8,12 @@ import Foundation /// where AccessorySetupKit is present but non-functional. Keeping the branch here means no /// `#if os(...)` or `isiOSAppOnMac` check leaks into `ConnectionManager` or the views. enum DevicePairingFactory { - /// Builds the pairing service appropriate for the current platform. - @MainActor - static func make() -> any DevicePairingService { - if ProcessInfo.processInfo.isiOSAppOnMac { - return BluetoothScanPairingService() - } - return AccessorySetupPairingService(accessorySetupKit: AccessorySetupKitService()) + /// Builds the pairing service appropriate for the current platform. + @MainActor + static func make() -> any DevicePairingService { + if ProcessInfo.processInfo.isiOSAppOnMac { + return BluetoothScanPairingService() } + return AccessorySetupPairingService(accessorySetupKit: AccessorySetupKitService()) + } } diff --git a/MC1Services/Sources/MC1Services/Services/DevicePairingService.swift b/MC1Services/Sources/MC1Services/Services/DevicePairingService.swift index f4f59365..37741cd7 100644 --- a/MC1Services/Sources/MC1Services/Services/DevicePairingService.swift +++ b/MC1Services/Sources/MC1Services/Services/DevicePairingService.swift @@ -16,67 +16,67 @@ import Foundation /// nothing to AccessorySetupKit's types. @MainActor public protocol DevicePairingService: AnyObject { - /// Receives system pairing events. Only fires on iOS. - var delegate: (any DevicePairingDelegate)? { get set } + /// Receives system pairing events. Only fires on iOS. + var delegate: (any DevicePairingDelegate)? { get set } - /// Whether a system pairing session is active. - /// - /// iOS: reflects the AccessorySetupKit session. macOS: always `false` — there is no - /// session, and a `false` value makes `ConnectionManager` skip its accessory-registration - /// guard so connects proceed purely over CoreBluetooth. - var isSessionActive: Bool { get } + /// Whether a system pairing session is active. + /// + /// iOS: reflects the AccessorySetupKit session. macOS: always `false` — there is no + /// session, and a `false` value makes `ConnectionManager` skip its accessory-registration + /// guard so connects proceed purely over CoreBluetooth. + var isSessionActive: Bool { get } - /// Number of devices registered with the system pairing registry. - /// - /// iOS: AccessorySetupKit accessory count. macOS: `0` — the in-app device list is - /// sourced from SwiftData, not from a system registry. - var registeredDeviceCount: Int { get } + /// Number of devices registered with the system pairing registry. + /// + /// iOS: AccessorySetupKit accessory count. macOS: `0` — the in-app device list is + /// sourced from SwiftData, not from a system registry. + var registeredDeviceCount: Int { get } - /// Whether this platform exposes an app-visible system pairing registry (AccessorySetupKit). - /// - /// A static platform capability, not a session state — distinct from `isSessionActive`. - /// iOS: `true`. macOS "Designed for iPad": `false` — there is no registry, so the device - /// picker filters on the stored connection method rather than registry membership, and a - /// user-initiated connect to an absent cached peripheral fails fast rather than exhausting - /// the multi-attempt retry budget (CoreBluetooth cannot pre-reject it without a registry). - var hasSystemPairingRegistry: Bool { get } + /// Whether this platform exposes an app-visible system pairing registry (AccessorySetupKit). + /// + /// A static platform capability, not a session state — distinct from `isSessionActive`. + /// iOS: `true`. macOS "Designed for iPad": `false` — there is no registry, so the device + /// picker filters on the stored connection method rather than registry membership, and a + /// user-initiated connect to an absent cached peripheral fails fast rather than exhausting + /// the multi-attempt retry budget (CoreBluetooth cannot pre-reject it without a registry). + var hasSystemPairingRegistry: Bool { get } - /// Whether `renameDevice(_:)` presents a real rename surface. - /// - /// iOS: `true` — AccessorySetupKit shows its system rename sheet. macOS: `false` — there is - /// no system rename UI, so `renameDevice(_:)` is a no-op. The UI must hide the rename action - /// when this is `false` rather than offer a control that silently does nothing. - var supportsSystemRename: Bool { get } + /// Whether `renameDevice(_:)` presents a real rename surface. + /// + /// iOS: `true` — AccessorySetupKit shows its system rename sheet. macOS: `false` — there is + /// no system rename UI, so `renameDevice(_:)` is a no-op. The UI must hide the rename action + /// when this is `false` rather than offer a control that silently does nothing. + var supportsSystemRename: Bool { get } - /// Activate the system pairing session. iOS: `ASAccessorySession.activate`. macOS: no-op. - func activate() async throws + /// Activate the system pairing session. iOS: `ASAccessorySession.activate`. macOS: no-op. + func activate() async throws - /// Present device-discovery UI and return the selected peripheral's CoreBluetooth UUID. - /// - /// iOS: the AccessorySetupKit system picker. macOS: an in-app scan picker driven by - /// `BluetoothScanPairingService`. Throws `DevicePairingError.cancelled` when - /// the user cancels, on both platforms, so call sites share one cancellation path. - func discoverDevice() async throws -> UUID + /// Present device-discovery UI and return the selected peripheral's CoreBluetooth UUID. + /// + /// iOS: the AccessorySetupKit system picker. macOS: an in-app scan picker driven by + /// `BluetoothScanPairingService`. Throws `DevicePairingError.cancelled` when + /// the user cancels, on both platforms, so call sites share one cancellation path. + func discoverDevice() async throws -> UUID - /// Whether a connect attempt to this device is permitted by the platform. - /// - /// iOS: an AccessorySetupKit accessory exists for this id (registry membership gates the - /// connect). macOS: always `true` — there is no registry, so CoreBluetooth decides - /// reachability at connect time. - func isDeviceConnectable(_ id: UUID) -> Bool + /// Whether a connect attempt to this device is permitted by the platform. + /// + /// iOS: an AccessorySetupKit accessory exists for this id (registry membership gates the + /// connect). macOS: always `true` — there is no registry, so CoreBluetooth decides + /// reachability at connect time. + func isDeviceConnectable(_ id: UUID) -> Bool - /// Registered devices as `(id, name)`, for device-list fallbacks when SwiftData is empty. - /// iOS: AccessorySetupKit accessories. macOS: `[]`. - func registeredDeviceInfos() -> [(id: UUID, name: String)] + /// Registered devices as `(id, name)`, for device-list fallbacks when SwiftData is empty. + /// iOS: AccessorySetupKit accessories. macOS: `[]`. + func registeredDeviceInfos() -> [(id: UUID, name: String)] - /// Remove a device from the system pairing registry. Best-effort; a no-op when the device - /// is not registered. iOS: `removeAccessory`. macOS: no-op (no app-managed bond). - func removeDevice(_ id: UUID) async throws + /// Remove a device from the system pairing registry. Best-effort; a no-op when the device + /// is not registered. iOS: `removeAccessory`. macOS: no-op (no app-managed bond). + func removeDevice(_ id: UUID) async throws - /// Present the system rename UI for a device. iOS: AccessorySetupKit rename sheet. macOS: no-op. - func renameDevice(_ id: UUID) async throws + /// Present the system rename UI for a device. iOS: AccessorySetupKit rename sheet. macOS: no-op. + func renameDevice(_ id: UUID) async throws - /// Remove every device from the system pairing registry. iOS: clears stale AccessorySetupKit - /// bonds left by a factory-reset radio. macOS: no-op. - func clearStaleRegistrations() async + /// Remove every device from the system pairing registry. iOS: clears stale AccessorySetupKit + /// bonds left by a factory-reset radio. macOS: no-op. + func clearStaleRegistrations() async } diff --git a/MC1Services/Sources/MC1Services/Services/DeviceService.swift b/MC1Services/Sources/MC1Services/Services/DeviceService.swift index a7d89da3..d176ba60 100644 --- a/MC1Services/Sources/MC1Services/Services/DeviceService.swift +++ b/MC1Services/Sources/MC1Services/Services/DeviceService.swift @@ -5,17 +5,17 @@ import OSLog // MARK: - Device Service Errors public enum DeviceServiceError: Error, LocalizedError, Sendable { - case deviceNotFound - case persistenceFailed(String) + case deviceNotFound + case persistenceFailed(String) - public var errorDescription: String? { - switch self { - case .deviceNotFound: - return "Device not found" - case .persistenceFailed(let reason): - return "Failed to save device settings: \(reason)" - } + public var errorDescription: String? { + switch self { + case .deviceNotFound: + "Device not found" + case let .persistenceFailed(reason): + "Failed to save device settings: \(reason)" } + } } // MARK: - Device Service @@ -23,63 +23,63 @@ public enum DeviceServiceError: Error, LocalizedError, Sendable { /// Service for managing device-level data and settings persistence. /// Handles local device configuration that doesn't require MeshCore communication. public actor DeviceService { - private let dataStore: any DevicePersisting - private let logger = PersistentLogger(subsystem: "com.mc1", category: "DeviceService") + private let dataStore: any DevicePersisting + private let logger = PersistentLogger(subsystem: "com.mc1", category: "DeviceService") - /// Callback invoked when device data is successfully updated. - /// Used to refresh ConnectionManager.connectedDevice for UI updates. - /// Installed by `AppState.wireDeviceUpdateCallbacks`. - private var onDeviceUpdated: (@Sendable (DeviceDTO) async -> Void)? + /// Callback invoked when device data is successfully updated. + /// Used to refresh ConnectionManager.connectedDevice for UI updates. + /// Installed by `AppState.wireDeviceUpdateCallbacks`. + private var onDeviceUpdated: (@Sendable (DeviceDTO) async -> Void)? - init(dataStore: any DevicePersisting) { - self.dataStore = dataStore - } - - /// Sets the callback for device updates. - public func setDeviceUpdateCallback( - _ callback: @escaping @Sendable (DeviceDTO) async -> Void - ) { - onDeviceUpdated = callback - } + init(dataStore: any DevicePersisting) { + self.dataStore = dataStore + } - // MARK: - OCV Settings + /// Sets the callback for device updates. + public func setDeviceUpdateCallback( + _ callback: @escaping @Sendable (DeviceDTO) async -> Void + ) { + onDeviceUpdated = callback + } - /// Update OCV settings for the connected device. - /// - /// - Parameters: - /// - deviceID: The UUID of the device to update - /// - preset: The OCV preset name (e.g., "liIon", "liPo", "custom") - /// - customArray: Custom OCV array as comma-separated string (required if preset is "custom") - /// - Throws: DeviceServiceError if device not found or persistence fails - public func updateOCVSettings( - deviceID: UUID, - preset: String, - customArray: String? - ) async throws { - logger.info("Updating OCV settings for device \(deviceID): preset=\(preset)") + // MARK: - OCV Settings - // Fetch current device - guard var device = try await dataStore.fetchDevice(id: deviceID) else { - logger.error("Device not found: \(deviceID)") - throw DeviceServiceError.deviceNotFound - } + /// Update OCV settings for the connected device. + /// + /// - Parameters: + /// - deviceID: The UUID of the device to update + /// - preset: The OCV preset name (e.g., "liIon", "liPo", "custom") + /// - customArray: Custom OCV array as comma-separated string (required if preset is "custom") + /// - Throws: DeviceServiceError if device not found or persistence fails + public func updateOCVSettings( + deviceID: UUID, + preset: String, + customArray: String? + ) async throws { + logger.info("Updating OCV settings for device \(deviceID): preset=\(preset)") - // Update OCV fields - device = device.copy { - $0.ocvPreset = preset - $0.customOCVArrayString = customArray - } + // Fetch current device + guard var device = try await dataStore.fetchDevice(id: deviceID) else { + logger.error("Device not found: \(deviceID)") + throw DeviceServiceError.deviceNotFound + } - // Save to persistence - do { - try await dataStore.saveDevice(device) - logger.info("OCV settings saved successfully") - } catch { - logger.error("Failed to save OCV settings: \(error.localizedDescription)") - throw DeviceServiceError.persistenceFailed(error.localizedDescription) - } + // Update OCV fields + device = device.copy { + $0.ocvPreset = preset + $0.customOCVArrayString = customArray + } - // Notify callback for UI refresh - await onDeviceUpdated?(device) + // Save to persistence + do { + try await dataStore.saveDevice(device) + logger.info("OCV settings saved successfully") + } catch { + logger.error("Failed to save OCV settings: \(error.localizedDescription)") + throw DeviceServiceError.persistenceFailed(error.localizedDescription) } + + // Notify callback for UI refresh + await onDeviceUpdated?(device) + } } diff --git a/MC1Services/Sources/MC1Services/Services/DraftStore.swift b/MC1Services/Sources/MC1Services/Services/DraftStore.swift index 91b0f2c6..17955654 100644 --- a/MC1Services/Sources/MC1Services/Services/DraftStore.swift +++ b/MC1Services/Sources/MC1Services/Services/DraftStore.swift @@ -22,76 +22,75 @@ import Foundation /// Do not add a `BackupUserDefaults` mapping for ``storageKey``. @MainActor public final class DraftStore { + /// Top-level `UserDefaults` key holding the `[draftStorageKey: text]` map. + public static let storageKey = "chat.drafts.v1" - /// Top-level `UserDefaults` key holding the `[draftStorageKey: text]` map. - public static let storageKey = "chat.drafts.v1" + private let defaults: UserDefaults + private var cache: [String: String] - private let defaults: UserDefaults - private var cache: [String: String] + /// - Parameter defaults: Injectable for tests; production uses `.standard`. + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + cache = (defaults.dictionary(forKey: Self.storageKey) as? [String: String]) ?? [:] + } - /// - Parameter defaults: Injectable for tests; production uses `.standard`. - public init(defaults: UserDefaults = .standard) { - self.defaults = defaults - self.cache = (defaults.dictionary(forKey: Self.storageKey) as? [String: String]) ?? [:] - } + /// The stored draft for a conversation, or `nil` if none. + public func draft(for id: ChatConversationID) -> String? { + cache[id.draftStorageKey] + } - /// The stored draft for a conversation, or `nil` if none. - public func draft(for id: ChatConversationID) -> String? { - cache[id.draftStorageKey] + /// Stores `text` as the draft for a conversation, or removes the entry when + /// `text` is empty or whitespace/newline-only. Trimming is used only for the + /// emptiness test; a non-empty draft (e.g. with a trailing newline) is stored + /// verbatim. + public func setDraft(_ text: String, for id: ChatConversationID) { + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + clearDraft(for: id) + } else { + cache[id.draftStorageKey] = text + persist() } + } - /// Stores `text` as the draft for a conversation, or removes the entry when - /// `text` is empty or whitespace/newline-only. Trimming is used only for the - /// emptiness test; a non-empty draft (e.g. with a trailing newline) is stored - /// verbatim. - public func setDraft(_ text: String, for id: ChatConversationID) { - if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - clearDraft(for: id) - } else { - cache[id.draftStorageKey] = text - persist() - } - } - - /// Removes the draft for a conversation, if present. - public func clearDraft(for id: ChatConversationID) { - guard cache.removeValue(forKey: id.draftStorageKey) != nil else { return } - persist() - } + /// Removes the draft for a conversation, if present. + public func clearDraft(for id: ChatConversationID) { + guard cache.removeValue(forKey: id.draftStorageKey) != nil else { return } + persist() + } - /// Removes the drafts for the given channel slots on `radioID`, persisting once for - /// the whole batch. Used by the slot-vacate paths (channel delete, sync prune) so a - /// channel later reusing a freed slot can't surface the prior channel's draft. - public func clearChannelDrafts(radioID: UUID, indices: Set) { - clearChannelDrafts(slotsByRadio: [radioID: indices]) - } + /// Removes the drafts for the given channel slots on `radioID`, persisting once for + /// the whole batch. Used by the slot-vacate paths (channel delete, sync prune) so a + /// channel later reusing a freed slot can't surface the prior channel's draft. + public func clearChannelDrafts(radioID: UUID, indices: Set) { + clearChannelDrafts(slotsByRadio: [radioID: indices]) + } - /// Removes the drafts for the given channel slots grouped by radio, persisting once for - /// the whole batch. Used by the backup-import path, where affected slots arrive per radio. - public func clearChannelDrafts(slotsByRadio: [UUID: Set]) { - var didChange = false - for (radioID, indices) in slotsByRadio { - for index in indices { - let key = ChatConversationID.channel(radioID: radioID, channelIndex: index).draftStorageKey - if cache.removeValue(forKey: key) != nil { - didChange = true - } - } - } - if didChange { - persist() + /// Removes the drafts for the given channel slots grouped by radio, persisting once for + /// the whole batch. Used by the backup-import path, where affected slots arrive per radio. + public func clearChannelDrafts(slotsByRadio: [UUID: Set]) { + var didChange = false + for (radioID, indices) in slotsByRadio { + for index in indices { + let key = ChatConversationID.channel(radioID: radioID, channelIndex: index).draftStorageKey + if cache.removeValue(forKey: key) != nil { + didChange = true } + } } - - /// Pure restore decision: returns the saved draft only when the composer is - /// empty, otherwise `nil`. Keeps a reconnect-driven re-restore (or a double - /// initial load) from clobbering text the user is already typing, and isolates - /// that guard so it is unit-testable independent of the view. - public func draftToApply(over currentText: String, for id: ChatConversationID) -> String? { - currentText.isEmpty ? draft(for: id) : nil + if didChange { + persist() } + } - private func persist() { - defaults.set(cache, forKey: Self.storageKey) - } + /// Pure restore decision: returns the saved draft only when the composer is + /// empty, otherwise `nil`. Keeps a reconnect-driven re-restore (or a double + /// initial load) from clobbering text the user is already typing, and isolates + /// that guard so it is unit-testable independent of the view. + public func draftToApply(over currentText: String, for id: ChatConversationID) -> String? { + currentText.isEmpty ? draft(for: id) : nil + } + + private func persist() { + defaults.set(cache, forKey: Self.storageKey) + } } diff --git a/MC1Services/Sources/MC1Services/Services/FirmwareDeviceErrorCode.swift b/MC1Services/Sources/MC1Services/Services/FirmwareDeviceErrorCode.swift index 1ca195ef..17e6e04c 100644 --- a/MC1Services/Sources/MC1Services/Services/FirmwareDeviceErrorCode.swift +++ b/MC1Services/Sources/MC1Services/Services/FirmwareDeviceErrorCode.swift @@ -7,24 +7,24 @@ import Foundation /// transient-vs-terminal taxonomy is an MC1 send-queue policy, not a /// protocol-level constant. public enum FirmwareDeviceErrorCode { - /// `TABLE_FULL` on the direct-message path. The radio's outbound DM pool - /// is briefly exhausted; the send queue parks and retries. - public static let directMessageTableFull: UInt8 = 3 + /// `TABLE_FULL` on the direct-message path. The radio's outbound DM pool + /// is briefly exhausted; the send queue parks and retries. + public static let directMessageTableFull: UInt8 = 3 - /// `NOT_FOUND` on the channel broadcast path. Firmware emits this for two - /// distinct failures that share a wire code: - /// - **Pool exhaustion** (transient): `createGroupDatagram` returned NULL because - /// the radio's packet pool is briefly full. The send queue parks and retries. - /// - **Stale channel index** (terminal): the user deleted the channel between - /// enqueue and drain. `ChatSendQueueService` disambiguates by calling - /// `ChannelService.fetchChannel(index:)`; if the device confirms the - /// channel is gone the envelope is dropped and the message lands in - /// `.failed` so the user can resend into a different channel. - public static let channelMessageNotFound: UInt8 = 2 + /// `NOT_FOUND` on the channel broadcast path. Firmware emits this for two + /// distinct failures that share a wire code: + /// - **Pool exhaustion** (transient): `createGroupDatagram` returned NULL because + /// the radio's packet pool is briefly full. The send queue parks and retries. + /// - **Stale channel index** (terminal): the user deleted the channel between + /// enqueue and drain. `ChatSendQueueService` disambiguates by calling + /// `ChannelService.fetchChannel(index:)`; if the device confirms the + /// channel is gone the envelope is dropped and the message lands in + /// `.failed` so the user can resend into a different channel. + public static let channelMessageNotFound: UInt8 = 2 - /// `RESP_CODE_NO_MORE_MESSAGES` surfaced on the remote-node section path. The - /// companion radio's offline-message queue is empty, meaning the awaited - /// repeater/room reply hasn't arrived yet; the section request backs off and - /// retries within its shared timeout budget. - public static let remoteNodeNoResponseYet: UInt8 = 10 + /// `RESP_CODE_NO_MORE_MESSAGES` surfaced on the remote-node section path. The + /// companion radio's offline-message queue is empty, meaning the awaited + /// repeater/room reply hasn't arrived yet; the section request backs off and + /// retries within its shared timeout budget. + public static let remoteNodeNoResponseYet: UInt8 = 10 } diff --git a/MC1Services/Sources/MC1Services/Services/HeardRepeatEvent.swift b/MC1Services/Sources/MC1Services/Services/HeardRepeatEvent.swift index 34410766..db5c06ce 100644 --- a/MC1Services/Sources/MC1Services/Services/HeardRepeatEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/HeardRepeatEvent.swift @@ -5,8 +5,8 @@ import Foundation /// Broadcast by `HeardRepeatsService.events()`. The stream is multicast: /// every subscriber receives every event. public struct HeardRepeatEvent: Sendable { - /// The sent message the repeat was correlated to. - public let messageID: UUID - /// The message's updated heard-repeat count. - public let count: Int + /// The sent message the repeat was correlated to. + public let messageID: UUID + /// The message's updated heard-repeat count. + public let count: Int } diff --git a/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift b/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift index 8540ccf0..de99f390 100644 --- a/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift +++ b/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift @@ -5,144 +5,144 @@ import OSLog /// Service for correlating RX log entries to sent channel messages /// and tracking "heard repeats" - evidence of message propagation through the mesh. public actor HeardRepeatsService { - private let dataStore: any HeardRepeatPersisting - private let logger = PersistentLogger(subsystem: "com.mc1", category: "HeardRepeatsService") - - /// Device ID for the current session - private var radioID: UUID? - - /// Local node name for matching sender in decrypted messages - private var localNodeName: String? - - /// Multicast broadcaster for heard-repeat events. - private nonisolated let eventBroadcaster = EventBroadcaster() - - init(dataStore: any HeardRepeatPersisting) { - self.dataStore = dataStore + private let dataStore: any HeardRepeatPersisting + private let logger = PersistentLogger(subsystem: "com.mc1", category: "HeardRepeatsService") + + /// Device ID for the current session + private var radioID: UUID? + + /// Local node name for matching sender in decrypted messages + private var localNodeName: String? + + /// Multicast broadcaster for heard-repeat events. + private nonisolated let eventBroadcaster = EventBroadcaster() + + init(dataStore: any HeardRepeatPersisting) { + self.dataStore = dataStore + } + + /// Returns a fresh stream of heard-repeat events. Registration is + /// synchronous, so events yielded after this call are never dropped. + /// Consumers must re-subscribe per connection because the owning + /// `ServiceContainer` is rebuilt on every connection. + public nonisolated func events() -> AsyncStream { + eventBroadcaster.subscribe() + } + + /// Ends every `events()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishEvents() { + eventBroadcaster.finish() + } + + /// Configure the service with device context. + /// Must be called once before processing any RX log entries. + /// Thread-safe due to actor isolation. + public func configure(radioID: UUID, localNodeName: String) { + self.radioID = radioID + self.localNodeName = localNodeName + logger.info("Configured with radioID: \(radioID), nodeName: \(localNodeName)") + } + + /// Checks if a repeat has already been recorded for this RX log entry. + private func isDuplicateRepeat(_ entryID: UUID) async -> Bool { + do { + return try await dataStore.messageRepeatExists(rxLogEntryID: entryID) + } catch { + logger.error("Failed to check for existing repeat: \(error.localizedDescription)") + return true // Assume duplicate on error to prevent potential duplicates } - - /// Returns a fresh stream of heard-repeat events. Registration is - /// synchronous, so events yielded after this call are never dropped. - /// Consumers must re-subscribe per connection because the owning - /// `ServiceContainer` is rebuilt on every connection. - public nonisolated func events() -> AsyncStream { - eventBroadcaster.subscribe() + } + + /// Process an RX log entry to check if it's a repeat of a sent message. + /// + /// Called by RxLogService for each new entry. Only processes successfully + /// decrypted channel messages within the 10-second matching window. + /// + /// - Parameter entry: The RX log entry to process + /// - Returns: The updated heardRepeats count if a match was found, nil otherwise + @discardableResult + public func processForRepeats(_ entry: RxLogEntryDTO) async -> Int? { + // Only process successfully decrypted channel messages + guard entry.payloadType == .groupText else { return nil } + guard entry.decryptStatus == .success else { return nil } + guard let decodedText = entry.decodedText else { return nil } + guard let channelIndex = entry.channelIndex else { return nil } + guard let senderTimestamp = entry.senderTimestamp else { return nil } + guard let radioID else { return nil } + guard let localNodeName else { return nil } + + // Parse "NodeName: MessageText" format using shared utility + guard let (senderName, messageText) = ChannelMessageFormat.parse(decodedText) else { + logger.info("Failed to parse channel message text: \(decodedText.prefix(50))") + return nil } - /// Ends every `events()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishEvents() { - eventBroadcaster.finish() - } - - /// Configure the service with device context. - /// Must be called once before processing any RX log entries. - /// Thread-safe due to actor isolation. - public func configure(radioID: UUID, localNodeName: String) { - self.radioID = radioID - self.localNodeName = localNodeName - logger.info("Configured with radioID: \(radioID), nodeName: \(localNodeName)") - } + // Only match messages from our own node + guard senderName == localNodeName else { return nil } - /// Checks if a repeat has already been recorded for this RX log entry. - private func isDuplicateRepeat(_ entryID: UUID) async -> Bool { - do { - return try await dataStore.messageRepeatExists(rxLogEntryID: entryID) - } catch { - logger.error("Failed to check for existing repeat: \(error.localizedDescription)") - return true // Assume duplicate on error to prevent potential duplicates - } + // Check for duplicate (already processed this RX entry) + if await isDuplicateRepeat(entry.id) { + logger.info("Repeat already recorded for RX entry: \(entry.id)") + return nil } - /// Process an RX log entry to check if it's a repeat of a sent message. - /// - /// Called by RxLogService for each new entry. Only processes successfully - /// decrypted channel messages within the 10-second matching window. - /// - /// - Parameter entry: The RX log entry to process - /// - Returns: The updated heardRepeats count if a match was found, nil otherwise - @discardableResult - public func processForRepeats(_ entry: RxLogEntryDTO) async -> Int? { - // Only process successfully decrypted channel messages - guard entry.payloadType == .groupText else { return nil } - guard entry.decryptStatus == .success else { return nil } - guard let decodedText = entry.decodedText else { return nil } - guard let channelIndex = entry.channelIndex else { return nil } - guard let senderTimestamp = entry.senderTimestamp else { return nil } - guard let radioID = self.radioID else { return nil } - guard let localNodeName = self.localNodeName else { return nil } - - // Parse "NodeName: MessageText" format using shared utility - guard let (senderName, messageText) = ChannelMessageFormat.parse(decodedText) else { - logger.info("Failed to parse channel message text: \(decodedText.prefix(50))") - return nil - } - - // Only match messages from our own node - guard senderName == localNodeName else { return nil } - - // Check for duplicate (already processed this RX entry) - if await isDuplicateRepeat(entry.id) { - logger.info("Repeat already recorded for RX entry: \(entry.id)") - return nil - } - - // Find matching sent message - do { - guard let message = try await dataStore.findSentChannelMessage( - radioID: radioID, - channelIndex: channelIndex, - timestamp: senderTimestamp, - text: messageText, - withinSeconds: 10 - ) else { - return nil - } - - // Create repeat entry - let repeatDTO = MessageRepeatDTO( - messageID: message.id, - receivedAt: entry.receivedAt, - pathNodes: entry.pathNodes, - pathLength: entry.pathLength, - snr: entry.snr, - rssi: entry.rssi, - rxLogEntryID: entry.id - ) - - try await dataStore.saveMessageRepeat(repeatDTO) - - // Increment and return new count - let newCount = try await dataStore.incrementMessageHeardRepeats(id: message.id) - - logger.info("Recorded repeat #\(newCount) for message \(message.id)") - - eventBroadcaster.yield(HeardRepeatEvent(messageID: message.id, count: newCount)) - - return newCount - - } catch { - logger.error("Failed to process repeat: \(error.localizedDescription)") - return nil - } + // Find matching sent message + do { + guard let message = try await dataStore.findSentChannelMessage( + radioID: radioID, + channelIndex: channelIndex, + timestamp: senderTimestamp, + text: messageText, + withinSeconds: 10 + ) else { + return nil + } + + // Create repeat entry + let repeatDTO = MessageRepeatDTO( + messageID: message.id, + receivedAt: entry.receivedAt, + pathNodes: entry.pathNodes, + pathLength: entry.pathLength, + snr: entry.snr, + rssi: entry.rssi, + rxLogEntryID: entry.id + ) + + try await dataStore.saveMessageRepeat(repeatDTO) + + // Increment and return new count + let newCount = try await dataStore.incrementMessageHeardRepeats(id: message.id) + + logger.info("Recorded repeat #\(newCount) for message \(message.id)") + + eventBroadcaster.yield(HeardRepeatEvent(messageID: message.id, count: newCount)) + + return newCount + + } catch { + logger.error("Failed to process repeat: \(error.localizedDescription)") + return nil } - - /// Refresh repeats for a specific message by querying the RX log. - /// Used when opening the Repeat Details sheet to catch any missed repeats. - /// - /// - Parameter messageID: The message to refresh repeats for - /// - Returns: Array of repeat DTOs sorted by receivedAt - public func refreshRepeats(for messageID: UUID) async -> [MessageRepeatDTO] { - // Return existing repeats from database - logger.info("refreshRepeats called for messageID: \(messageID)") - do { - let results = try await dataStore.fetchMessageRepeats(messageID: messageID) - logger.info("refreshRepeats returning \(results.count) repeats") - return results - } catch { - logger.error("Failed to fetch repeats: \(error.localizedDescription)") - return [] - } + } + + /// Refresh repeats for a specific message by querying the RX log. + /// Used when opening the Repeat Details sheet to catch any missed repeats. + /// + /// - Parameter messageID: The message to refresh repeats for + /// - Returns: Array of repeat DTOs sorted by receivedAt + public func refreshRepeats(for messageID: UUID) async -> [MessageRepeatDTO] { + // Return existing repeats from database + logger.info("refreshRepeats called for messageID: \(messageID)") + do { + let results = try await dataStore.fetchMessageRepeats(messageID: messageID) + logger.info("refreshRepeats returning \(results.count) repeats") + return results + } catch { + logger.error("Failed to fetch repeats: \(error.localizedDescription)") + return [] } + } } diff --git a/MC1Services/Sources/MC1Services/Services/InboundHopAdoption.swift b/MC1Services/Sources/MC1Services/Services/InboundHopAdoption.swift index 3fe21b3b..c3a7b103 100644 --- a/MC1Services/Sources/MC1Services/Services/InboundHopAdoption.swift +++ b/MC1Services/Sources/MC1Services/Services/InboundHopAdoption.swift @@ -20,22 +20,22 @@ import Foundation /// freezing the stored count until the row ages out of the discovered-node cap. Reset detection is /// out of scope. func adoptInboundHop( - storedHops: Int?, - storedTimestamp: UInt32?, - incomingHops: Int, - incomingTimestamp: UInt32? + storedHops: Int?, + storedTimestamp: UInt32?, + incomingHops: Int, + incomingTimestamp: UInt32? ) -> (hopCount: Int, advertTimestamp: UInt32?)? { - guard let storedTimestamp else { - return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) - } - guard let incomingTimestamp else { - return nil - } - if incomingTimestamp > storedTimestamp { - return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) - } - if incomingTimestamp == storedTimestamp, incomingHops < (storedHops ?? Int.max) { - return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) - } + guard let storedTimestamp else { + return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) + } + guard let incomingTimestamp else { return nil + } + if incomingTimestamp > storedTimestamp { + return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) + } + if incomingTimestamp == storedTimestamp, incomingHops < (storedHops ?? Int.max) { + return (hopCount: incomingHops, advertTimestamp: incomingTimestamp) + } + return nil } diff --git a/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift b/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift index 00be3708..183b82b7 100644 --- a/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift +++ b/MC1Services/Sources/MC1Services/Services/InlineImageDimensionsStore.swift @@ -1,7 +1,7 @@ -import Foundation import CoreGraphics -import OSLog +import Foundation import os +import OSLog /// Persists inline-image aspect ratios to a flat JSON file in Application Support. /// @@ -13,111 +13,110 @@ import os /// `OSAllocatedUnfairLock` mirror so SwiftUI view bodies can resolve frame /// heights without awaiting an actor hop. public actor InlineImageDimensionsStore { - - private struct Entry: Codable, Sendable { - let aspect: Double - let fetchedAt: Date + private struct Entry: Codable { + let aspect: Double + let fetchedAt: Date + } + + private static let storeFilename = "InlineImageDimensions.json" + private static let resolutionStreamBufferDepth = 64 + + private let logger = Logger(subsystem: "com.mc1", category: "InlineImageDimensionsStore") + + private let fileURL: URL + private var entries: [String: Entry] = [:] + private let aspectMirror: OSAllocatedUnfairLock<[String: Double]> + private let streamContinuation: AsyncStream.Continuation + private let stream: AsyncStream + + /// Production initializer using the default Application Support path. + public init() { + self.init(fileURL: nil) + } + + /// Designated initializer. Pass a custom `fileURL` for tests; production callers + /// should use `init()` which resolves the Application Support path. + public init(fileURL: URL?) { + let resolvedURL = fileURL ?? Self.defaultFileURL() + self.fileURL = resolvedURL + + let (stream, continuation) = AsyncStream.makeStream( + of: URL.self, + bufferingPolicy: .bufferingOldest(Self.resolutionStreamBufferDepth) + ) + self.stream = stream + streamContinuation = continuation + + let directory = resolvedURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + logger.info("Could not create directory for inline image dimensions store: \(error.localizedDescription, privacy: .public)") } - private static let storeFilename = "InlineImageDimensions.json" - private static let resolutionStreamBufferDepth = 64 - - private let logger = Logger(subsystem: "com.mc1", category: "InlineImageDimensionsStore") - - private let fileURL: URL - private var entries: [String: Entry] = [:] - private let aspectMirror: OSAllocatedUnfairLock<[String: Double]> - private let streamContinuation: AsyncStream.Continuation - private let stream: AsyncStream - - /// Production initializer using the default Application Support path. - public init() { - self.init(fileURL: nil) + var loaded: [String: Entry] = [:] + if let data = try? Data(contentsOf: resolvedURL) { + if let decoded = try? JSONDecoder().decode([String: Entry].self, from: data) { + loaded = decoded + } else { + logger.info("Inline image dimensions file present but undecodable; starting empty") + } } - - /// Designated initializer. Pass a custom `fileURL` for tests; production callers - /// should use `init()` which resolves the Application Support path. - public init(fileURL: URL?) { - let resolvedURL = fileURL ?? Self.defaultFileURL() - self.fileURL = resolvedURL - - let (stream, continuation) = AsyncStream.makeStream( - of: URL.self, - bufferingPolicy: .bufferingOldest(Self.resolutionStreamBufferDepth) - ) - self.stream = stream - self.streamContinuation = continuation - - let directory = resolvedURL.deletingLastPathComponent() - do { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - } catch { - logger.info("Could not create directory for inline image dimensions store: \(error.localizedDescription, privacy: .public)") - } - - var loaded: [String: Entry] = [:] - if let data = try? Data(contentsOf: resolvedURL) { - if let decoded = try? JSONDecoder().decode([String: Entry].self, from: data) { - loaded = decoded - } else { - logger.info("Inline image dimensions file present but undecodable; starting empty") - } - } - self.entries = loaded - - let aspects = loaded.mapValues(\.aspect) - self.aspectMirror = OSAllocatedUnfairLock(initialState: aspects) + entries = loaded + + let aspects = loaded.mapValues(\.aspect) + aspectMirror = OSAllocatedUnfairLock(initialState: aspects) + } + + /// Upsert an aspect ratio for a URL. Saves with `width <= 0` or `height <= 0` + /// are rejected silently and do not emit on the stream. + public func save(url: URL, size: CGSize) async { + guard size.width > 0, size.height > 0 else { return } + + let aspect = Double(size.width) / Double(size.height) + let key = url.absoluteString + let entry = Entry(aspect: aspect, fetchedAt: Date()) + + entries[key] = entry + aspectMirror.withLock { $0[key] = aspect } + streamContinuation.yield(url) + + persist() + } + + /// Nonisolated wait-free aspect lookup. Safe to call from SwiftUI view bodies. + public nonisolated func aspect(for url: URL) -> Double? { + aspectMirror.withLock { $0[url.absoluteString] } + } + + /// Broadcast stream of URLs whose aspect was just (re)saved. Emits on every + /// save call, including idempotent re-saves where the aspect did not change. + /// + /// Single-consumer by design; `ChatViewModel` owns this in non-split-view + /// contexts. In iPad split view (multiple `ChatViewModel`s alive at once), + /// events are delivered to whichever subscriber happens to be iterating + /// first; affected bubbles still rebuild via other triggers (visible-cell + /// reload, retry, manual `rebuildDisplayItem`). + public nonisolated var resolutionStream: AsyncStream { + stream + } + + private func persist() { + guard let data = try? JSONEncoder().encode(entries) else { + logger.error("Failed to encode inline image dimensions snapshot") + return } - - /// Upsert an aspect ratio for a URL. Saves with `width <= 0` or `height <= 0` - /// are rejected silently and do not emit on the stream. - public func save(url: URL, size: CGSize) async { - guard size.width > 0, size.height > 0 else { return } - - let aspect = Double(size.width) / Double(size.height) - let key = url.absoluteString - let entry = Entry(aspect: aspect, fetchedAt: Date()) - - entries[key] = entry - aspectMirror.withLock { $0[key] = aspect } - streamContinuation.yield(url) - - persist() - } - - /// Nonisolated wait-free aspect lookup. Safe to call from SwiftUI view bodies. - public nonisolated func aspect(for url: URL) -> Double? { - aspectMirror.withLock { $0[url.absoluteString] } - } - - /// Broadcast stream of URLs whose aspect was just (re)saved. Emits on every - /// save call, including idempotent re-saves where the aspect did not change. - /// - /// Single-consumer by design; `ChatViewModel` owns this in non-split-view - /// contexts. In iPad split view (multiple `ChatViewModel`s alive at once), - /// events are delivered to whichever subscriber happens to be iterating - /// first; affected bubbles still rebuild via other triggers (visible-cell - /// reload, retry, manual `rebuildDisplayItem`). - public nonisolated var resolutionStream: AsyncStream { - stream - } - - private func persist() { - guard let data = try? JSONEncoder().encode(entries) else { - logger.error("Failed to encode inline image dimensions snapshot") - return - } - do { - try data.write(to: fileURL, options: .atomic) - } catch { - logger.error("Failed to write inline image dimensions store: \(error.localizedDescription, privacy: .public)") - } - } - - private static func defaultFileURL() -> URL { - let appSupport = FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first! - return appSupport.appending(path: storeFilename) + do { + try data.write(to: fileURL, options: .atomic) + } catch { + logger.error("Failed to write inline image dimensions store: \(error.localizedDescription, privacy: .public)") } + } + + private static func defaultFileURL() -> URL { + let appSupport = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first! + return appSupport.appending(path: storeFilename) + } } diff --git a/MC1Services/Sources/MC1Services/Services/KeepAliveRetryPolicy.swift b/MC1Services/Sources/MC1Services/Services/KeepAliveRetryPolicy.swift index 80a58742..c330886d 100644 --- a/MC1Services/Sources/MC1Services/Services/KeepAliveRetryPolicy.swift +++ b/MC1Services/Sources/MC1Services/Services/KeepAliveRetryPolicy.swift @@ -8,75 +8,75 @@ import MeshCore /// - **Skip** (floodRouted): not a failure, continue the loop /// - **Stop** (CancellationError, cancelled): task was cancelled, exit quietly enum KeepAliveRetryPolicy { - enum Action: Equatable { - /// Transient failure, try again next interval - case retryNextInterval - /// Consecutive transient failures exceeded threshold - case disconnect - /// Terminal local-state error, disconnect immediately - case disconnectNow - /// Flood-routed session, skip this iteration - case skip - /// Task cancelled, exit loop quietly - case stop + enum Action: Equatable { + /// Transient failure, try again next interval + case retryNextInterval + /// Consecutive transient failures exceeded threshold + case disconnect + /// Terminal local-state error, disconnect immediately + case disconnectNow + /// Flood-routed session, skip this iteration + case skip + /// Task cancelled, exit loop quietly + case stop - var shouldExitLoop: Bool { - switch self { - case .stop, .disconnect, .disconnectNow: true - case .retryNextInterval, .skip: false - } - } + var shouldExitLoop: Bool { + switch self { + case .stop, .disconnect, .disconnectNow: true + case .retryNextInterval, .skip: false + } } + } - /// The number of consecutive transient failures required before disconnecting. - static let maxConsecutiveFailures = 2 + /// The number of consecutive transient failures required before disconnecting. + static let maxConsecutiveFailures = 2 - /// Evaluates a keep-alive error and returns the appropriate action. - static func evaluate( - error: Error, - consecutiveFailures: inout Int - ) -> Action { - if error is CancellationError { - return .stop - } - - guard let nodeError = error as? RemoteNodeError else { - return .disconnectNow - } + /// Evaluates a keep-alive error and returns the appropriate action. + static func evaluate( + error: Error, + consecutiveFailures: inout Int + ) -> Action { + if error is CancellationError { + return .stop + } - switch nodeError { - case .cancelled: - return .stop - case .floodRouted: - return .skip - case .sessionNotFound, .contactNotFound: - return .disconnectNow - default: - consecutiveFailures += 1 - return consecutiveFailures >= maxConsecutiveFailures ? .disconnect : .retryNextInterval - } + guard let nodeError = error as? RemoteNodeError else { + return .disconnectNow } - /// Records a successful keep-alive by resetting the failure counter. - static func recordSuccess(consecutiveFailures: inout Int) { - consecutiveFailures = 0 + switch nodeError { + case .cancelled: + return .stop + case .floodRouted: + return .skip + case .sessionNotFound, .contactNotFound: + return .disconnectNow + default: + consecutiveFailures += 1 + return consecutiveFailures >= maxConsecutiveFailures ? .disconnect : .retryNextInterval } + } + + /// Records a successful keep-alive by resetting the failure counter. + static func recordSuccess(consecutiveFailures: inout Int) { + consecutiveFailures = 0 + } - /// Returns a human-readable reason for a keep-alive failure. - static func failureReason(for error: Error) -> String { - switch error { - case RemoteNodeError.sessionError(.timeout): - return "timeout" - case RemoteNodeError.sessionError(.deviceError(let code)): - return "device error (code: \(code))" - case RemoteNodeError.sessionError(.notConnected): - return "transport not connected" - case RemoteNodeError.sessionNotFound: - return "session not found" - case RemoteNodeError.contactNotFound: - return "contact not found" - default: - return "\(error)" - } + /// Returns a human-readable reason for a keep-alive failure. + static func failureReason(for error: Error) -> String { + switch error { + case RemoteNodeError.sessionError(.timeout): + "timeout" + case let RemoteNodeError.sessionError(.deviceError(code)): + "device error (code: \(code))" + case RemoteNodeError.sessionError(.notConnected): + "transport not connected" + case RemoteNodeError.sessionNotFound: + "session not found" + case RemoteNodeError.contactNotFound: + "contact not found" + default: + "\(error)" } + } } diff --git a/MC1Services/Sources/MC1Services/Services/KeyGenerationService.swift b/MC1Services/Sources/MC1Services/Services/KeyGenerationService.swift index 0f18c3e6..01931a5f 100644 --- a/MC1Services/Sources/MC1Services/Services/KeyGenerationService.swift +++ b/MC1Services/Sources/MC1Services/Services/KeyGenerationService.swift @@ -3,30 +3,30 @@ import Foundation /// Errors that can occur during key generation. public enum KeyGenerationError: Error, LocalizedError, Sendable { - /// Generation loop exceeded the maximum number of attempts without finding a matching key. - case maxAttemptsExceeded - - /// The requested prefix byte is reserved by firmware and cannot be used. - case reservedPrefix - - /// The system random number generator failed to produce bytes. - case randomGenerationFailed - - /// The provided key data is not a valid Ed25519 expanded private key. - case invalidKey - - public var errorDescription: String? { - switch self { - case .maxAttemptsExceeded: - "Could not generate a key with that prefix. Try a different one." - case .reservedPrefix: - "That prefix is reserved and cannot be used." - case .randomGenerationFailed: - "Secure random number generation failed. Please try again." - case .invalidKey: - "The key is not a valid Ed25519 private key." - } + /// Generation loop exceeded the maximum number of attempts without finding a matching key. + case maxAttemptsExceeded + + /// The requested prefix byte is reserved by firmware and cannot be used. + case reservedPrefix + + /// The system random number generator failed to produce bytes. + case randomGenerationFailed + + /// The provided key data is not a valid Ed25519 expanded private key. + case invalidKey + + public var errorDescription: String? { + switch self { + case .maxAttemptsExceeded: + "Could not generate a key with that prefix. Try a different one." + case .reservedPrefix: + "That prefix is reserved and cannot be used." + case .randomGenerationFailed: + "Secure random number generation failed. Please try again." + case .invalidKey: + "The key is not a valid Ed25519 private key." } + } } /// Generates Ed25519 keypairs compatible with MeshCore's 64-byte expanded format. @@ -34,99 +34,98 @@ public enum KeyGenerationError: Error, LocalizedError, Sendable { /// Key generation uses CryptoKit's `Curve25519.Signing.PrivateKey` and expands the /// 32-byte seed to MeshCore's 64-byte format using SHA-512 with RFC 8032 bit clamping. public enum KeyGenerationService: Sendable { + /// Generates a new Ed25519 identity, optionally matching a vanity hex prefix. + /// + /// - Parameter hexPrefix: Optional 1–4 uppercase hex characters the public key should start with. + /// Pass `nil` to accept any valid key. Prefixes of 2+ characters starting with `"00"` or `"FF"` + /// are rejected as reserved by firmware. + /// - Returns: A tuple of the 64-byte expanded private key and 32-byte public key. + /// - Throws: ``KeyGenerationError/reservedPrefix`` if the prefix maps to a reserved byte, + /// ``KeyGenerationError/maxAttemptsExceeded`` if no matching key is found, + /// or `CancellationError` if the task is cancelled. + public static func generateIdentity( + hexPrefix: String? + ) async throws -> (expandedPrivateKey: Data, publicKey: Data) { + if let hexPrefix, hexPrefix.count >= 2, + hexPrefix.hasPrefix("00") || hexPrefix.hasPrefix("FF") { + throw KeyGenerationError.reservedPrefix + } - /// Generates a new Ed25519 identity, optionally matching a vanity hex prefix. - /// - /// - Parameter hexPrefix: Optional 1–4 uppercase hex characters the public key should start with. - /// Pass `nil` to accept any valid key. Prefixes of 2+ characters starting with `"00"` or `"FF"` - /// are rejected as reserved by firmware. - /// - Returns: A tuple of the 64-byte expanded private key and 32-byte public key. - /// - Throws: ``KeyGenerationError/reservedPrefix`` if the prefix maps to a reserved byte, - /// ``KeyGenerationError/maxAttemptsExceeded`` if no matching key is found, - /// or `CancellationError` if the task is cancelled. - public static func generateIdentity( - hexPrefix: String? - ) async throws -> (expandedPrivateKey: Data, publicKey: Data) { - if let hexPrefix, hexPrefix.count >= 2, - hexPrefix.hasPrefix("00") || hexPrefix.hasPrefix("FF") { - throw KeyGenerationError.reservedPrefix - } - - let attempts = maxAttempts(forPrefixLength: hexPrefix?.count ?? 0) - - for _ in 0.. Int { - guard length > 0 else { return 10_000 } - let expected = 1 << (length * 4) // 16^length - return max(10_000, expected * 20) + throw KeyGenerationError.maxAttemptsExceeded + } + + /// Scales max attempts based on prefix length (~20x the expected attempts needed). + private static func maxAttempts(forPrefixLength length: Int) -> Int { + guard length > 0 else { return 10000 } + let expected = 1 << (length * 4) // 16^length + return max(10000, expected * 20) + } + + /// Validates that the given data is a properly clamped 64-byte Ed25519 expanded private key. + /// + /// Checks RFC 8032 bit clamping on the scalar half (first 32 bytes): + /// - Byte 0: lowest 3 bits must be clear + /// - Byte 31: highest bit must be clear, second-highest bit must be set + /// + /// - Parameter data: The key data to validate. + /// - Throws: ``KeyGenerationError/invalidKey`` if the data is not a valid expanded key. + public static func validateExpandedKey(_ data: Data) throws { + guard data.count == ProtocolLimits.privateKeySize else { + throw KeyGenerationError.invalidKey } - - /// Validates that the given data is a properly clamped 64-byte Ed25519 expanded private key. - /// - /// Checks RFC 8032 bit clamping on the scalar half (first 32 bytes): - /// - Byte 0: lowest 3 bits must be clear - /// - Byte 31: highest bit must be clear, second-highest bit must be set - /// - /// - Parameter data: The key data to validate. - /// - Throws: ``KeyGenerationError/invalidKey`` if the data is not a valid expanded key. - public static func validateExpandedKey(_ data: Data) throws { - guard data.count == ProtocolLimits.privateKeySize else { - throw KeyGenerationError.invalidKey - } - // Index relative to startIndex so a non-zero-based slice validates the right bytes. - let scalarFirst = data[data.startIndex] - let scalarLast = data[data.index(data.startIndex, offsetBy: 31)] - // RFC 8032 clamping: lowest 3 bits of byte 0 must be clear - guard scalarFirst & 0x07 == 0 else { - throw KeyGenerationError.invalidKey - } - // Byte 31: highest bit clear, second-highest set - guard scalarLast & 0x80 == 0, scalarLast & 0x40 == 0x40 else { - throw KeyGenerationError.invalidKey - } + // Index relative to startIndex so a non-zero-based slice validates the right bytes. + let scalarFirst = data[data.startIndex] + let scalarLast = data[data.index(data.startIndex, offsetBy: 31)] + // RFC 8032 clamping: lowest 3 bits of byte 0 must be clear + guard scalarFirst & 0x07 == 0 else { + throw KeyGenerationError.invalidKey } - - /// Expands a 32-byte seed to MeshCore's 64-byte format using SHA-512 with RFC 8032 bit clamping. - private static func expandSeed(_ seed: Data) -> Data { - var expanded = Data(SHA512.hash(data: seed)) - expanded[0] &= 0xF8 - expanded[31] &= 0x7F - expanded[31] |= 0x40 - return expanded + // Byte 31: highest bit clear, second-highest set + guard scalarLast & 0x80 == 0, scalarLast & 0x40 == 0x40 else { + throw KeyGenerationError.invalidKey } + } + + /// Expands a 32-byte seed to MeshCore's 64-byte format using SHA-512 with RFC 8032 bit clamping. + private static func expandSeed(_ seed: Data) -> Data { + var expanded = Data(SHA512.hash(data: seed)) + expanded[0] &= 0xF8 + expanded[31] &= 0x7F + expanded[31] |= 0x40 + return expanded + } } diff --git a/MC1Services/Sources/MC1Services/Services/KeychainService.swift b/MC1Services/Sources/MC1Services/Services/KeychainService.swift index 68013743..ff4d926c 100644 --- a/MC1Services/Sources/MC1Services/Services/KeychainService.swift +++ b/MC1Services/Sources/MC1Services/Services/KeychainService.swift @@ -7,139 +7,139 @@ import Security /// Secure password storage for remote node authentication. /// Passwords are stored device-only (not synced to iCloud). actor KeychainService { - static let shared = KeychainService() - - private let service = "com.pocketmesh.nodepasswords" - private let logger = PersistentLogger(subsystem: "com.mc1", category: "Keychain") - private let maxRetries = 3 - private let retryDelay: Duration = .milliseconds(100) - - init() {} - - /// Store a password for a remote node. - /// - Parameters: - /// - password: The password to store - /// - publicKey: The 32-byte public key of the remote node - func storePassword(_ password: String, forNodeKey publicKey: Data) async throws { - let account = publicKey.base64EncodedString() - guard let passwordData = password.data(using: .utf8) else { - throw KeychainError.encodingFailed - } - - // Delete existing entry first - let deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - SecItemDelete(deleteQuery as CFDictionary) - - // Add new entry - let addQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecValueData as String: passwordData, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly - ] - - var lastStatus: OSStatus = errSecSuccess - for attempt in 1...maxRetries { - lastStatus = SecItemAdd(addQuery as CFDictionary, nil) - if lastStatus == errSecSuccess { - logger.debug("Password stored for node") - return - } - if attempt < maxRetries { - try await Task.sleep(for: retryDelay) - } - } - - throw KeychainError.storageFailed(lastStatus) + static let shared = KeychainService() + + private let service = "com.pocketmesh.nodepasswords" + private let logger = PersistentLogger(subsystem: "com.mc1", category: "Keychain") + private let maxRetries = 3 + private let retryDelay: Duration = .milliseconds(100) + + init() {} + + /// Store a password for a remote node. + /// - Parameters: + /// - password: The password to store + /// - publicKey: The 32-byte public key of the remote node + func storePassword(_ password: String, forNodeKey publicKey: Data) async throws { + let account = publicKey.base64EncodedString() + guard let passwordData = password.data(using: .utf8) else { + throw KeychainError.encodingFailed } - /// Retrieve a stored password for a remote node. - /// - Parameter publicKey: The 32-byte public key of the remote node - /// - Returns: The stored password, or nil if not found - func retrievePassword(forNodeKey publicKey: Data) async throws -> String? { - let account = publicKey.base64EncodedString() - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - if status == errSecItemNotFound { - return nil - } - - guard status == errSecSuccess, - let data = result as? Data, - let password = String(data: data, encoding: .utf8) else { - throw KeychainError.retrievalFailed(status) - } - - return password + // Delete existing entry first + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(deleteQuery as CFDictionary) + + // Add new entry + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: passwordData, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + var lastStatus: OSStatus = errSecSuccess + for attempt in 1...maxRetries { + lastStatus = SecItemAdd(addQuery as CFDictionary, nil) + if lastStatus == errSecSuccess { + logger.debug("Password stored for node") + return + } + if attempt < maxRetries { + try await Task.sleep(for: retryDelay) + } } - /// Delete a stored password for a remote node. - /// - Parameter publicKey: The 32-byte public key of the remote node - func deletePassword(forNodeKey publicKey: Data) async throws { - let account = publicKey.base64EncodedString() - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - - let status = SecItemDelete(query as CFDictionary) - if status != errSecSuccess && status != errSecItemNotFound { - throw KeychainError.deletionFailed(status) - } + throw KeychainError.storageFailed(lastStatus) + } + + /// Retrieve a stored password for a remote node. + /// - Parameter publicKey: The 32-byte public key of the remote node + /// - Returns: The stored password, or nil if not found + func retrievePassword(forNodeKey publicKey: Data) async throws -> String? { + let account = publicKey.base64EncodedString() + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return nil } - /// Check if a password exists for a remote node. - /// - Parameter publicKey: The 32-byte public key of the remote node - /// - Returns: True if a password is stored - func hasPassword(forNodeKey publicKey: Data) async -> Bool { - let account = publicKey.base64EncodedString() + guard status == errSecSuccess, + let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + throw KeychainError.retrievalFailed(status) + } + + return password + } + + /// Delete a stored password for a remote node. + /// - Parameter publicKey: The 32-byte public key of the remote node + func deletePassword(forNodeKey publicKey: Data) async throws { + let account = publicKey.base64EncodedString() - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: false - ] + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] - return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess, status != errSecItemNotFound { + throw KeychainError.deletionFailed(status) } + } + + /// Check if a password exists for a remote node. + /// - Parameter publicKey: The 32-byte public key of the remote node + /// - Returns: True if a password is stored + func hasPassword(forNodeKey publicKey: Data) async -> Bool { + let account = publicKey.base64EncodedString() + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: false + ] + + return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess + } } // MARK: - KeychainError public enum KeychainError: Error, LocalizedError, Sendable { - case encodingFailed - case storageFailed(OSStatus) - case retrievalFailed(OSStatus) - case deletionFailed(OSStatus) - - public var errorDescription: String? { - switch self { - case .encodingFailed: - return "Failed to encode password" - case .storageFailed(let status): - return "Failed to store password (error \(status))" - case .retrievalFailed(let status): - return "Failed to retrieve password (error \(status))" - case .deletionFailed(let status): - return "Failed to delete password (error \(status))" - } + case encodingFailed + case storageFailed(OSStatus) + case retrievalFailed(OSStatus) + case deletionFailed(OSStatus) + + public var errorDescription: String? { + switch self { + case .encodingFailed: + "Failed to encode password" + case let .storageFailed(status): + "Failed to store password (error \(status))" + case let .retrievalFailed(status): + "Failed to retrieve password (error \(status))" + case let .deletionFailed(status): + "Failed to delete password (error \(status))" } + } } diff --git a/MC1Services/Sources/MC1Services/Services/LoginResult.swift b/MC1Services/Sources/MC1Services/Services/LoginResult.swift index 05f4c552..d56f27fa 100644 --- a/MC1Services/Sources/MC1Services/Services/LoginResult.swift +++ b/MC1Services/Sources/MC1Services/Services/LoginResult.swift @@ -1,19 +1,19 @@ import Foundation public struct LoginResult: Sendable { - public let success: Bool - public let isAdmin: Bool - public let aclPermissions: UInt8? - public let publicKeyPrefix: Data + public let success: Bool + public let isAdmin: Bool + public let aclPermissions: UInt8? + public let publicKeyPrefix: Data - public init(success: Bool, isAdmin: Bool, aclPermissions: UInt8?, publicKeyPrefix: Data) { - self.success = success - self.isAdmin = isAdmin - self.aclPermissions = aclPermissions - self.publicKeyPrefix = publicKeyPrefix - } + public init(success: Bool, isAdmin: Bool, aclPermissions: UInt8?, publicKeyPrefix: Data) { + self.success = success + self.isAdmin = isAdmin + self.aclPermissions = aclPermissions + self.publicKeyPrefix = publicKeyPrefix + } - public var permissionLevel: RoomPermissionLevel { - isAdmin ? .admin : (RoomPermissionLevel(rawValue: aclPermissions ?? 0) ?? .guest) - } + public var permissionLevel: RoomPermissionLevel { + isAdmin ? .admin : (RoomPermissionLevel(rawValue: aclPermissions ?? 0) ?? .guest) + } } diff --git a/MC1Services/Sources/MC1Services/Services/LoginTimeoutConfig.swift b/MC1Services/Sources/MC1Services/Services/LoginTimeoutConfig.swift index f46f2cea..c60fcf80 100644 --- a/MC1Services/Sources/MC1Services/Services/LoginTimeoutConfig.swift +++ b/MC1Services/Sources/MC1Services/Services/LoginTimeoutConfig.swift @@ -3,21 +3,21 @@ import MeshCore /// Configuration for login timeout based on path length enum LoginTimeoutConfig { - /// Base timeout for direct (0-hop) connections - static let directTimeout: Duration = .seconds(5) + /// Base timeout for direct (0-hop) connections + static let directTimeout: Duration = .seconds(5) - /// Additional timeout per hop in the path - static let perHopTimeout: Duration = .seconds(10) + /// Additional timeout per hop in the path + static let perHopTimeout: Duration = .seconds(10) - /// Maximum timeout regardless of path length - static let maximumTimeout: Duration = .seconds(60) + /// Maximum timeout regardless of path length + static let maximumTimeout: Duration = .seconds(60) - /// Calculate appropriate timeout based on path length - static func timeout(forPathLength pathLength: UInt8) -> Duration { - let base = directTimeout - let hopCount = decodePathLen(pathLength)?.hopCount ?? 0 - let additional = perHopTimeout * hopCount - let total = base + additional - return min(total, maximumTimeout) - } + /// Calculate appropriate timeout based on path length + static func timeout(forPathLength pathLength: UInt8) -> Duration { + let base = directTimeout + let hopCount = decodePathLen(pathLength)?.hopCount ?? 0 + let additional = perHopTimeout * hopCount + let total = base + additional + return min(total, maximumTimeout) + } } diff --git a/MC1Services/Sources/MC1Services/Services/MeshCoreOpenReactionParser.swift b/MC1Services/Sources/MC1Services/Services/MeshCoreOpenReactionParser.swift index 59e54bc5..9a3f3d1b 100644 --- a/MC1Services/Sources/MC1Services/Services/MeshCoreOpenReactionParser.swift +++ b/MC1Services/Sources/MC1Services/Services/MeshCoreOpenReactionParser.swift @@ -1,9 +1,9 @@ import Foundation /// Parsed meshcore-open v3 reaction data -struct ParsedMCOReaction: Sendable, Equatable { - let emoji: String - let dartHash: String // 4 lowercase hex chars +struct ParsedMCOReaction: Equatable { + let emoji: String + let dartHash: String // 4 lowercase hex chars } /// Parsed meshcore-open v1 reaction data (pre-Jan 2026 clients) @@ -11,16 +11,16 @@ struct ParsedMCOReaction: Sendable, Equatable { /// The `senderNameHash` and `textHash` are full Dart VM `String.hashCode` values /// (30-bit, decimal-encoded on the wire). For DM reactions, `senderNameHash` is /// not verified during matching since DMs have implicit sender context. -struct ParsedMCOReactionV1: Sendable, Equatable { - let emoji: String - let timestampSeconds: UInt32 - let senderNameHash: UInt32 - let textHash: UInt32 - - /// Reconstructs the original v1 messageId, used as the opaque reaction hash for dedup. - var messageIdHash: String { - "\(timestampSeconds)_\(senderNameHash)_\(textHash)" - } +struct ParsedMCOReactionV1: Equatable { + let emoji: String + let timestampSeconds: UInt32 + let senderNameHash: UInt32 + let textHash: UInt32 + + /// Reconstructs the original v1 messageId, used as the opaque reaction hash for dedup. + var messageIdHash: String { + "\(timestampSeconds)_\(senderNameHash)_\(textHash)" + } } /// Parses meshcore-open reaction wire format (receive-only). @@ -29,199 +29,198 @@ struct ParsedMCOReactionV1: Sendable, Equatable { /// The hash is computed using the Dart VM's `String.hashCode` algorithm /// masked to 16 bits. enum MeshCoreOpenReactionParser { - - // MARK: - Parsing - - /// Parses a meshcore-open reaction string. - /// - /// Format: `r:{4-char-hex-hash}:{2-char-hex-emoji-index}` - /// - Returns: Parsed emoji and dart hash, or nil if format doesn't match. - static func parse(_ text: String) -> ParsedMCOReaction? { - guard text.count == 9, - text.hasPrefix("r:"), - text[text.index(text.startIndex, offsetBy: 6)] == ":" else { - return nil - } - - let hashStart = text.index(text.startIndex, offsetBy: 2) - let hashEnd = text.index(text.startIndex, offsetBy: 6) - let indexStart = text.index(text.startIndex, offsetBy: 7) - - let hashStr = String(text[hashStart.. ParsedMCOReaction? { + guard text.count == 9, + text.hasPrefix("r:"), + text[text.index(text.startIndex, offsetBy: 6)] == ":" else { + return nil } - /// Parses a meshcore-open v1 reaction string. - /// - /// Format: `r:{millis}_{senderNameHash}_{textHash}:{emoji}` - /// Used by pre-Jan 2026 meshcore-open clients. The hashes are full Dart - /// `String.hashCode` values (30-bit, decimal-encoded). - static func parseV1(_ text: String) -> ParsedMCOReactionV1? { - guard text.hasPrefix("r:") else { return nil } - - // Split on last ":" to separate messageId from emoji - guard let lastColon = text.lastIndex(of: ":"), - lastColon > text.index(text.startIndex, offsetBy: 2) else { - return nil - } - - let messageId = String(text[text.index(text.startIndex, offsetBy: 2)..> 6 - /// finalize: - /// hash += hash << 3 - /// hash ^= hash >> 11 - /// hash += hash << 15 - /// hash &= (1 << 30) - 1 - /// if hash == 0: hash = 1 - /// ``` - /// Convenience overload that hashes a String's UTF-16 code units directly. - static func dartStringHash(_ string: String) -> UInt32 { - dartStringHash(Array(string.utf16)) + // Validate both are lowercase hex + guard isLowercaseHex(hashStr), isLowercaseHex(indexStr) else { + return nil } - static func dartStringHash(_ codeUnits: [UInt16]) -> UInt32 { - var hash: UInt32 = 0 + guard let emojiIndex = UInt8(indexStr, radix: 16), + Int(emojiIndex) < emojiTable.count else { + return nil + } - for unit in codeUnits { - hash = hash &+ UInt32(unit) - hash = hash &+ (hash &<< 10) - hash ^= (hash >> 6) - } + return ParsedMCOReaction( + emoji: emojiTable[Int(emojiIndex)], + dartHash: hashStr + ) + } + + /// Parses a meshcore-open v1 reaction string. + /// + /// Format: `r:{millis}_{senderNameHash}_{textHash}:{emoji}` + /// Used by pre-Jan 2026 meshcore-open clients. The hashes are full Dart + /// `String.hashCode` values (30-bit, decimal-encoded). + static func parseV1(_ text: String) -> ParsedMCOReactionV1? { + guard text.hasPrefix("r:") else { return nil } + + // Split on last ":" to separate messageId from emoji + guard let lastColon = text.lastIndex(of: ":"), + lastColon > text.index(text.startIndex, offsetBy: 2) else { + return nil + } - // Finalize - hash = hash &+ (hash &<< 3) - hash ^= (hash >> 11) - hash = hash &+ (hash &<< 15) + let messageId = String(text[text.index(text.startIndex, offsetBy: 2).. String { - var codeUnits: [UInt16] = [] - - // Append timestamp as string - codeUnits.append(contentsOf: String(timestamp).utf16) - - // Append sender name (channel only) - if let senderName { - codeUnits.append(contentsOf: senderName.utf16) - } - - // Append first 5 UTF-16 code units of text - codeUnits.append(contentsOf: text.utf16.prefix(5)) - - let hash = dartStringHash(codeUnits) & 0xFFFF - return String(format: "%04x", hash) + let seconds = timestampMillis / 1000 + guard seconds <= UInt64(UInt32.max) else { return nil } + + return ParsedMCOReactionV1( + emoji: emoji, + timestampSeconds: UInt32(seconds), + senderNameHash: senderNameHash, + textHash: textHash + ) + } + + // MARK: - Hash Computation + + /// Reimplements the Dart VM's `String.hashCode` algorithm. + /// + /// Operates on UTF-16 code units with 30-bit result: + /// ``` + /// hash = 0 + /// for each code_unit: + /// hash += code_unit + /// hash += hash << 10 + /// hash ^= hash >> 6 + /// finalize: + /// hash += hash << 3 + /// hash ^= hash >> 11 + /// hash += hash << 15 + /// hash &= (1 << 30) - 1 + /// if hash == 0: hash = 1 + /// ``` + /// Convenience overload that hashes a String's UTF-16 code units directly. + static func dartStringHash(_ string: String) -> UInt32 { + dartStringHash(Array(string.utf16)) + } + + static func dartStringHash(_ codeUnits: [UInt16]) -> UInt32 { + var hash: UInt32 = 0 + + for unit in codeUnits { + hash = hash &+ UInt32(unit) + hash = hash &+ (hash &<< 10) + hash ^= (hash >> 6) } - // MARK: - Emoji Table - - /// The 184-emoji lookup table from meshcore-open's emoji_picker.dart. - /// Concatenated in order: quickEmojis + smileys + gestures + hearts + objects. - static let emojiTable: [String] = [ - // quickEmojis (0x00–0x05) - "👍", "❤️", "😂", "🎉", "👏", "🔥", - // smileys (0x06–0x45) - "😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊", - "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", - "😗", "😙", "😚", "😋", "😛", "😝", "😜", "🤪", - "🤨", "🧐", "🤓", "😎", "🥸", "🤩", "🥳", "😏", - "😒", "😞", "😔", "😟", "😕", "🙁", "😣", "😖", - "😫", "😩", "🥺", "😢", "😭", "😤", "😠", "😡", - "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰", - "😥", "😓", "🤗", "🤔", "🤭", "🤫", "🤥", "😶", - // gestures (0x46–0x66) - "👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️", - "🤟", "🤘", "👌", "🤌", "🤏", "👈", "👉", "👆", - "👇", "☝️", "👋", "🤚", "🖐️", "✋", "🖖", "👏", - "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", - "💪", - // hearts (0x67–0x86) - "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", - "🤎", "💔", "❤️‍🔥", "❤️‍🩹", "💕", "💞", "💓", "💗", - "💖", "💘", "💝", "💟", "💌", "💢", "💥", "💫", - "💦", "💨", "🕳️", "💬", "👁️‍🗨️", "🗨️", "🗯️", "💭", - // objects (0x87–0xB7) - "🎉", "🎊", "🎈", "🎁", "🎀", "🪅", "🪆", "🏆", - "🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐", - "🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", "🏒", - "🥍", "🏓", "🏸", "🥊", "🥋", "🥅", "⛳", "🔥", - "⭐", "🌟", "✨", "⚡", "💡", "🔦", "🏮", "🪔", - "📱", "💻", "⌚", "📷", "📺", "📻", "🎵", "🎶", - "🚀", - ] - - // MARK: - Private Helpers - - private static func isLowercaseHex(_ string: String) -> Bool { - string.allSatisfy { $0.isHexDigit && !$0.isUppercase } + // Finalize + hash = hash &+ (hash &<< 3) + hash ^= (hash >> 11) + hash = hash &+ (hash &<< 15) + + // Mask to 30 bits + let kHashBitMask: UInt32 = (1 << 30) - 1 + hash &= kHashBitMask + + if hash == 0 { hash = 1 } + return hash + } + + /// Computes the reaction hash used by meshcore-open. + /// + /// Builds UTF-16 code units from: `"\(timestamp)" + senderName + first5UTF16(text)` + /// Then runs `dartStringHash`, masks to 16 bits, and formats as 4 lowercase hex chars. + /// + /// - Parameters: + /// - timestamp: Message timestamp as UInt32 + /// - senderName: Sender node name (nil for DM reactions) + /// - text: Message text content + /// - Returns: 4-character lowercase hex hash string + static func computeReactionHash( + timestamp: UInt32, + senderName: String?, + text: String + ) -> String { + var codeUnits: [UInt16] = [] + + // Append timestamp as string + codeUnits.append(contentsOf: String(timestamp).utf16) + + // Append sender name (channel only) + if let senderName { + codeUnits.append(contentsOf: senderName.utf16) } + + // Append first 5 UTF-16 code units of text + codeUnits.append(contentsOf: text.utf16.prefix(5)) + + let hash = dartStringHash(codeUnits) & 0xFFFF + return String(format: "%04x", hash) + } + + // MARK: - Emoji Table + + /// The 184-emoji lookup table from meshcore-open's emoji_picker.dart. + /// Concatenated in order: quickEmojis + smileys + gestures + hearts + objects. + static let emojiTable: [String] = [ + // quickEmojis (0x00–0x05) + "👍", "❤️", "😂", "🎉", "👏", "🔥", + // smileys (0x06–0x45) + "😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊", + "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", + "😗", "😙", "😚", "😋", "😛", "😝", "😜", "🤪", + "🤨", "🧐", "🤓", "😎", "🥸", "🤩", "🥳", "😏", + "😒", "😞", "😔", "😟", "😕", "🙁", "😣", "😖", + "😫", "😩", "🥺", "😢", "😭", "😤", "😠", "😡", + "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰", + "😥", "😓", "🤗", "🤔", "🤭", "🤫", "🤥", "😶", + // gestures (0x46–0x66) + "👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️", + "🤟", "🤘", "👌", "🤌", "🤏", "👈", "👉", "👆", + "👇", "☝️", "👋", "🤚", "🖐️", "✋", "🖖", "👏", + "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", + "💪", + // hearts (0x67–0x86) + "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", + "🤎", "💔", "❤️‍🔥", "❤️‍🩹", "💕", "💞", "💓", "💗", + "💖", "💘", "💝", "💟", "💌", "💢", "💥", "💫", + "💦", "💨", "🕳️", "💬", "👁️‍🗨️", "🗨️", "🗯️", "💭", + // objects (0x87–0xB7) + "🎉", "🎊", "🎈", "🎁", "🎀", "🪅", "🪆", "🏆", + "🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐", + "🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", "🏒", + "🥍", "🏓", "🏸", "🥊", "🥋", "🥅", "⛳", "🔥", + "⭐", "🌟", "✨", "⚡", "💡", "🔦", "🏮", "🪔", + "📱", "💻", "⌚", "📷", "📺", "📻", "🎵", "🎶", + "🚀", + ] + + // MARK: - Private Helpers + + private static func isLowercaseHex(_ string: String) -> Bool { + string.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift index d4aa4971..b16845e6 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift @@ -4,260 +4,256 @@ import Foundation /// `MessageBuildInputs` snapshot. Pure function — no I/O, no async, no actor /// state, no side effects. Unit-tested in isolation. public enum MessageFragmentBuilder { + /// Sentinel URL used when an inline image fragment falls into a `.failed` + /// state without a real source URL. `about:blank` is RFC-defined and parses + /// reliably, so the force-unwrap is safe; the bubble renders this as the + /// generic failure placeholder. + private static let blankURL = URL(string: "about:blank")! - /// Sentinel URL used when an inline image fragment falls into a `.failed` - /// state without a real source URL. `about:blank` is RFC-defined and parses - /// reliably, so the force-unwrap is safe; the bubble renders this as the - /// generic failure placeholder. - private static let blankURL = URL(string: "about:blank")! + public static func makeItem( + for message: MessageDTO, + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) -> MessageItem { + MessageItem( + id: message.id, + envelope: makeEnvelope(for: message, inputs: inputs), + content: makeFragments(for: message, inputs: inputs, envInputs: envInputs), + footer: makeFooter(for: message, inputs: inputs, envInputs: envInputs), + grouping: makeGrouping(inputs: inputs), + shouldRequestPreviewFetch: shouldRequestPreviewFetch( + inputs: inputs, + message: message + ) + ) + } - public static func makeItem( - for message: MessageDTO, - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) -> MessageItem { - MessageItem( - id: message.id, - envelope: makeEnvelope(for: message, inputs: inputs), - content: makeFragments(for: message, inputs: inputs, envInputs: envInputs), - footer: makeFooter(for: message, inputs: inputs, envInputs: envInputs), - grouping: makeGrouping(inputs: inputs), - shouldRequestPreviewFetch: shouldRequestPreviewFetch( - inputs: inputs, - message: message - ) - ) - } - - public static func makeFragments( - for message: MessageDTO, - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) -> [MessageFragment] { - var fragments: [MessageFragment] = [] - - fragments.append(.text(makeText(message: message, inputs: inputs, envInputs: envInputs))) - - if let summary = message.reactionSummary, !summary.isEmpty { - fragments.append(.reactionSummary(summary)) - } + public static func makeFragments( + for message: MessageDTO, + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) -> [MessageFragment] { + var fragments: [MessageFragment] = [] - let url = inputs.cachedURL - let isImageURL = url.map(ImageURLClassifier.isImageURL) ?? false + fragments.append(.text(makeText(message: message, inputs: inputs, envInputs: envInputs))) - if inputs.previewState == .malwareWarning, let url { - fragments.append(.malwareWarning(url)) - appendMapPreviewIfPresent(&fragments, inputs: inputs, envInputs: envInputs) - return fragments - } - - if isImageURL && envInputs.showInlineImages { - fragments.append(.inlineImage( - makeInlineImage(url: url, inputs: inputs, envInputs: envInputs) - )) - } else if envInputs.previewsEnabled { - fragments.append(.linkPreview( - makeLinkPreview(message: message, url: url, inputs: inputs) - )) - } - - appendMapPreviewIfPresent(&fragments, inputs: inputs, envInputs: envInputs) - return fragments + if let summary = message.reactionSummary, !summary.isEmpty { + fragments.append(.reactionSummary(summary)) } - /// Appends a `.mapPreview` for the first linkified coordinate, if any. Sibling - /// order is deterministic (array order), so it sits after a link preview. The - /// card is independent of any suspicious URL, so it is shown on malware - /// messages too (the location is not the link). - private static func appendMapPreviewIfPresent( - _ fragments: inout [MessageFragment], - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) { - // Privacy gate: when the user has disabled chat map thumbnails, skip the - // fragment entirely so `MapPreviewFragmentView.onAppear` never fires the - // third-party tile request. The coordinate text inside the message body - // remains tappable through the formatted-text link path. - guard envInputs.showMapPreviews else { return } - guard let latitude = inputs.mapPreviewLatitude, - let longitude = inputs.mapPreviewLongitude else { return } - fragments.append(.mapPreview(MapPreviewFragmentState( - latitude: latitude, - longitude: longitude, - isDark: envInputs.isDark, - isOffline: envInputs.isOffline, - isReady: inputs.isMapPreviewReady - ))) - } + let url = inputs.cachedURL + let isImageURL = url.map(ImageURLClassifier.isImageURL) ?? false - private static func makeText( - message: MessageDTO, - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) -> MessageTextPayload { - MessageTextPayload( - raw: message.text, - formatted: inputs.formattedText, - baseColor: inputs.baseColor, - isOutgoing: message.isOutgoing, - currentUserName: envInputs.currentUserName - ) + if inputs.previewState == .malwareWarning, let url { + fragments.append(.malwareWarning(url)) + appendMapPreviewIfPresent(&fragments, inputs: inputs, envInputs: envInputs) + return fragments } - private static func makeInlineImage( - url: URL?, - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) -> InlineImage { - let loadState: InlineImage.LoadState - switch inputs.previewState { - case .loaded: - if inputs.hasInlineImageRef { - loadState = .loaded( - ImageReference(cacheKey: inputs.messageID, role: .inline), - isGIF: inputs.imageIsGIF - ) - } else if let url { - loadState = .loading(url) - } else { - loadState = .failed(Self.blankURL) - } - case .loading: - loadState = url.map { .loading($0) } ?? .failed(Self.blankURL) - case .noPreview: - loadState = url.map { .failed($0) } ?? .failed(Self.blankURL) - case .idle, .disabled, .malwareWarning: - loadState = url.map { .idle($0) } ?? .failed(Self.blankURL) - } - return InlineImage( - state: loadState, - autoPlayGIFs: envInputs.autoPlayGIFs, - cachedAspect: inputs.inlineImageAspect - ) + if isImageURL, envInputs.showInlineImages { + fragments.append(.inlineImage( + makeInlineImage(url: url, inputs: inputs, envInputs: envInputs) + )) + } else if envInputs.previewsEnabled { + fragments.append(.linkPreview( + makeLinkPreview(message: message, url: url, inputs: inputs) + )) } - private static func makeLinkPreview( - message: MessageDTO, - url: URL?, - inputs: MessageBuildInputs - ) -> LinkPreviewFragmentState { - let imageRef = inputs.hasPreviewImageRef - ? ImageReference(cacheKey: inputs.messageID, role: .linkPreviewImage) - : nil - let iconRef = inputs.hasPreviewIconRef - ? ImageReference(cacheKey: inputs.messageID, role: .linkPreviewIcon) - : nil + appendMapPreviewIfPresent(&fragments, inputs: inputs, envInputs: envInputs) + return fragments + } - let mode: LinkPreviewFragmentState.Mode - switch inputs.previewState { - case .loaded: - if let preview = inputs.loadedPreview { - mode = .loaded(preview, image: imageRef, icon: iconRef) - } else { - mode = .noPreview - } - case .loading: - mode = url.map { .loading($0) } ?? .idle - case .noPreview: - mode = .noPreview - case .disabled: - mode = url.map { .disabled($0) } ?? .idle - case .idle: - if let urlString = message.linkPreviewURL, - let legacyURL = URL(string: urlString) { - mode = .legacy( - url: legacyURL, - title: message.linkPreviewTitle, - image: imageRef, - icon: iconRef - ) - } else if let url { - mode = .loading(url) - } else { - mode = .idle - } - case .malwareWarning: - mode = .idle - } - return LinkPreviewFragmentState(mode: mode) - } + /// Appends a `.mapPreview` for the first linkified coordinate, if any. Sibling + /// order is deterministic (array order), so it sits after a link preview. The + /// card is independent of any suspicious URL, so it is shown on malware + /// messages too (the location is not the link). + private static func appendMapPreviewIfPresent( + _ fragments: inout [MessageFragment], + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) { + // Privacy gate: when the user has disabled chat map thumbnails, skip the + // fragment entirely so `MapPreviewFragmentView.onAppear` never fires the + // third-party tile request. The coordinate text inside the message body + // remains tappable through the formatted-text link path. + guard envInputs.showMapPreviews else { return } + guard let latitude = inputs.mapPreviewLatitude, + let longitude = inputs.mapPreviewLongitude else { return } + fragments.append(.mapPreview(MapPreviewFragmentState( + latitude: latitude, + longitude: longitude, + isDark: envInputs.isDark, + isOffline: envInputs.isOffline, + isReady: inputs.isMapPreviewReady + ))) + } - private static func makeEnvelope( - for message: MessageDTO, - inputs: MessageBuildInputs - ) -> MessageEnvelope { - MessageEnvelope( - messageID: message.id, - isOutgoing: message.isOutgoing, - senderName: inputs.senderResolution.displayName, - senderResolution: inputs.senderResolution, - status: message.status, - // Send time, not drain time: the centered divider is the sole time surface, - // so a days-old drained message must not be relabeled at its delivery time. - date: message.senderDate, - hasFailed: message.hasFailed, - containsSelfMention: message.containsSelfMention, - mentionSeen: message.mentionSeen - ) - } + private static func makeText( + message: MessageDTO, + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) -> MessageTextPayload { + MessageTextPayload( + raw: message.text, + formatted: inputs.formattedText, + baseColor: inputs.baseColor, + isOutgoing: message.isOutgoing, + currentUserName: envInputs.currentUserName + ) + } - private static func makeFooter( - for message: MessageDTO, - inputs: MessageBuildInputs, - envInputs: EnvInputs - ) -> MessageFooter { - let showHop = envInputs.showIncomingHopCount && message.isFloodRouted - let region: String? - if envInputs.showIncomingRegion, message.isFloodRouted { - region = message.regionScope - } else { - region = nil - } - // Send time shows on every incoming message (DM and channel) — unlike hop - // and region, it is not gated on `isFloodRouted`. Shows the clock-corrected - // `senderDate`, not the raw wire value, so a skewed sender clock doesn't put a - // misleading timestamp in the bubble; the badge flags the substitution and the - // raw value is available in the message info sheet. - let sendTimeToShow: Date? = - (envInputs.showIncomingSendTime && !message.isOutgoing) - ? message.senderDate : nil - return MessageFooter( - showHop: showHop, - hopCount: message.hopCount, - formattedPath: inputs.formattedPath, - regionToShow: region, - sendTimeToShow: sendTimeToShow, - sendTimeWasCorrected: message.timestampCorrected, - showStatusRow: message.isOutgoing, - status: message.status, - isChannelMessage: message.isChannelMessage, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts, - sendCount: message.sendCount + private static func makeInlineImage( + url: URL?, + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) -> InlineImage { + let loadState: InlineImage.LoadState = switch inputs.previewState { + case .loaded: + if inputs.hasInlineImageRef { + .loaded( + ImageReference(cacheKey: inputs.messageID, role: .inline), + isGIF: inputs.imageIsGIF ) + } else if let url { + .loading(url) + } else { + .failed(Self.blankURL) + } + case .loading: + url.map { .loading($0) } ?? .failed(Self.blankURL) + case .noPreview: + url.map { .failed($0) } ?? .failed(Self.blankURL) + case .idle, .disabled, .malwareWarning: + url.map { .idle($0) } ?? .failed(Self.blankURL) } + return InlineImage( + state: loadState, + autoPlayGIFs: envInputs.autoPlayGIFs, + cachedAspect: inputs.inlineImageAspect + ) + } - private static func makeGrouping(inputs: MessageBuildInputs) -> GroupingFlags { - GroupingFlags( - showTimestamp: inputs.showTimestamp, - showDirectionGap: inputs.showDirectionGap, - showSenderName: inputs.showSenderName, - showNewMessagesDivider: inputs.showNewMessagesDivider, - showDayDivider: inputs.showDayDivider + private static func makeLinkPreview( + message: MessageDTO, + url: URL?, + inputs: MessageBuildInputs + ) -> LinkPreviewFragmentState { + let imageRef = inputs.hasPreviewImageRef + ? ImageReference(cacheKey: inputs.messageID, role: .linkPreviewImage) + : nil + let iconRef = inputs.hasPreviewIconRef + ? ImageReference(cacheKey: inputs.messageID, role: .linkPreviewIcon) + : nil + + let mode: LinkPreviewFragmentState.Mode = switch inputs.previewState { + case .loaded: + if let preview = inputs.loadedPreview { + .loaded(preview, image: imageRef, icon: iconRef) + } else { + .noPreview + } + case .loading: + url.map { .loading($0) } ?? .idle + case .noPreview: + .noPreview + case .disabled: + url.map { .disabled($0) } ?? .idle + case .idle: + if let urlString = message.linkPreviewURL, + let legacyURL = URL(string: urlString) { + .legacy( + url: legacyURL, + title: message.linkPreviewTitle, + image: imageRef, + icon: iconRef ) + } else if let url { + .loading(url) + } else { + .idle + } + case .malwareWarning: + .idle } + return LinkPreviewFragmentState(mode: mode) + } + + private static func makeEnvelope( + for message: MessageDTO, + inputs: MessageBuildInputs + ) -> MessageEnvelope { + MessageEnvelope( + messageID: message.id, + isOutgoing: message.isOutgoing, + senderName: inputs.senderResolution.displayName, + senderResolution: inputs.senderResolution, + status: message.status, + // Send time, not drain time: the centered divider is the sole time surface, + // so a days-old drained message must not be relabeled at its delivery time. + date: message.senderDate, + hasFailed: message.hasFailed, + containsSelfMention: message.containsSelfMention, + mentionSeen: message.mentionSeen + ) + } - /// Mirrors the predicate the bubble's `.onAppear` evaluated as - /// `previewState == .idle && detectedURL != nil && message.linkPreviewURL == nil`. - /// Pre-computed on the builder so the bubble body does not read view-model - /// state during render. - private static func shouldRequestPreviewFetch( - inputs: MessageBuildInputs, - message: MessageDTO - ) -> Bool { - inputs.previewState == .idle - && inputs.cachedURL != nil - && message.linkPreviewURL == nil + private static func makeFooter( + for message: MessageDTO, + inputs: MessageBuildInputs, + envInputs: EnvInputs + ) -> MessageFooter { + let showHop = envInputs.showIncomingHopCount && message.isFloodRouted + let region: String? = if envInputs.showIncomingRegion, message.isFloodRouted { + message.regionScope + } else { + nil } + // Send time shows on every incoming message (DM and channel) — unlike hop + // and region, it is not gated on `isFloodRouted`. Shows the clock-corrected + // `senderDate`, not the raw wire value, so a skewed sender clock doesn't put a + // misleading timestamp in the bubble; the badge flags the substitution and the + // raw value is available in the message info sheet. + let sendTimeToShow: Date? = + (envInputs.showIncomingSendTime && !message.isOutgoing) + ? message.senderDate : nil + return MessageFooter( + showHop: showHop, + hopCount: message.hopCount, + formattedPath: inputs.formattedPath, + regionToShow: region, + sendTimeToShow: sendTimeToShow, + sendTimeWasCorrected: message.timestampCorrected, + showStatusRow: message.isOutgoing, + status: message.status, + isChannelMessage: message.isChannelMessage, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts, + sendCount: message.sendCount + ) + } + + private static func makeGrouping(inputs: MessageBuildInputs) -> GroupingFlags { + GroupingFlags( + showTimestamp: inputs.showTimestamp, + showDirectionGap: inputs.showDirectionGap, + showSenderName: inputs.showSenderName, + showNewMessagesDivider: inputs.showNewMessagesDivider, + showDayDivider: inputs.showDayDivider + ) + } + + /// Mirrors the predicate the bubble's `.onAppear` evaluated as + /// `previewState == .idle && detectedURL != nil && message.linkPreviewURL == nil`. + /// Pre-computed on the builder so the bubble body does not read view-model + /// state during render. + private static func shouldRequestPreviewFetch( + inputs: MessageBuildInputs, + message: MessageDTO + ) -> Bool { + inputs.previewState == .idle + && inputs.cachedURL != nil + && message.linkPreviewURL == nil + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageLRUCache.swift b/MC1Services/Sources/MC1Services/Services/MessageLRUCache.swift index 8ff3e747..e1fb7bf6 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageLRUCache.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageLRUCache.swift @@ -1,136 +1,136 @@ import Foundation /// Key for message lookup in LRU cache -struct MessageCacheKey: Hashable, Sendable { - let channelIndex: UInt8 - let senderName: String - let messageHash: String +struct MessageCacheKey: Hashable { + let channelIndex: UInt8 + let senderName: String + let messageHash: String } /// Key for DM message lookup in LRU cache -struct DirectMessageCacheKey: Hashable, Sendable { - let contactID: UUID - let messageHash: String +struct DirectMessageCacheKey: Hashable { + let contactID: UUID + let messageHash: String } /// Candidate message for reaction matching -struct MessageCandidate: Sendable, Equatable { - let messageID: UUID - let text: String - let timestamp: UInt32 - let indexedAt: Date - - init(messageID: UUID, text: String, timestamp: UInt32, indexedAt: Date = Date()) { - self.messageID = messageID - self.text = text - self.timestamp = timestamp - self.indexedAt = indexedAt - } +struct MessageCandidate: Equatable { + let messageID: UUID + let text: String + let timestamp: UInt32 + let indexedAt: Date + + init(messageID: UUID, text: String, timestamp: UInt32, indexedAt: Date = Date()) { + self.messageID = messageID + self.text = text + self.timestamp = timestamp + self.indexedAt = indexedAt + } } /// LRU cache for recent channel messages to enable reaction matching with collision resolution actor MessageLRUCache { - private var cache: [MessageCacheKey: [MessageCandidate]] = [:] - private var order: [MessageCacheKey] = [] - - private var dmCache: [DirectMessageCacheKey: [MessageCandidate]] = [:] - private var dmOrder: [DirectMessageCacheKey] = [] + private var cache: [MessageCacheKey: [MessageCandidate]] = [:] + private var order: [MessageCacheKey] = [] - private let capacity: Int - private let maxCandidatesPerKey: Int + private var dmCache: [DirectMessageCacheKey: [MessageCandidate]] = [:] + private var dmOrder: [DirectMessageCacheKey] = [] - init(capacity: Int = 500, maxCandidatesPerKey: Int = 5) { - self.capacity = capacity - self.maxCandidatesPerKey = maxCandidatesPerKey - } + private let capacity: Int + private let maxCandidatesPerKey: Int - /// Indexes a message for later lookup - func index(messageID: UUID, channelIndex: UInt8, senderName: String, text: String, timestamp: UInt32) { - let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) - let key = MessageCacheKey(channelIndex: channelIndex, senderName: senderName, messageHash: hash) - let candidate = MessageCandidate(messageID: messageID, text: text, timestamp: timestamp) + init(capacity: Int = 500, maxCandidatesPerKey: Int = 5) { + self.capacity = capacity + self.maxCandidatesPerKey = maxCandidatesPerKey + } - // Update LRU order - if let existingIndex = order.firstIndex(of: key) { - order.remove(at: existingIndex) - } - order.append(key) + /// Indexes a message for later lookup + func index(messageID: UUID, channelIndex: UInt8, senderName: String, text: String, timestamp: UInt32) { + let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) + let key = MessageCacheKey(channelIndex: channelIndex, senderName: senderName, messageHash: hash) + let candidate = MessageCandidate(messageID: messageID, text: text, timestamp: timestamp) - // Evict oldest key if at capacity - if order.count > capacity, let oldest = order.first { - order.removeFirst() - cache.removeValue(forKey: oldest) - } + // Update LRU order + if let existingIndex = order.firstIndex(of: key) { + order.remove(at: existingIndex) + } + order.append(key) - // Add candidate to key's list - var candidates = cache[key] ?? [] + // Evict oldest key if at capacity + if order.count > capacity, let oldest = order.first { + order.removeFirst() + cache.removeValue(forKey: oldest) + } - // Remove existing candidate with same messageID (re-indexing) - candidates.removeAll { $0.messageID == messageID } + // Add candidate to key's list + var candidates = cache[key] ?? [] - // Append new candidate - candidates.append(candidate) + // Remove existing candidate with same messageID (re-indexing) + candidates.removeAll { $0.messageID == messageID } - // Prune to max candidates (keep most recent) - if candidates.count > maxCandidatesPerKey { - candidates = Array(candidates.suffix(maxCandidatesPerKey)) - } + // Append new candidate + candidates.append(candidate) - cache[key] = candidates + // Prune to max candidates (keep most recent) + if candidates.count > maxCandidatesPerKey { + candidates = Array(candidates.suffix(maxCandidatesPerKey)) } - /// Looks up candidates by cache key - func lookup(channelIndex: UInt8, senderName: String, messageHash: String) -> [MessageCandidate] { - let key = MessageCacheKey(channelIndex: channelIndex, senderName: senderName, messageHash: messageHash) - return cache[key] ?? [] - } + cache[key] = candidates + } - /// Indexes a DM message for later lookup - func indexDM(messageID: UUID, contactID: UUID, text: String, timestamp: UInt32) { - let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) - let key = DirectMessageCacheKey(contactID: contactID, messageHash: hash) - let candidate = MessageCandidate(messageID: messageID, text: text, timestamp: timestamp) + /// Looks up candidates by cache key + func lookup(channelIndex: UInt8, senderName: String, messageHash: String) -> [MessageCandidate] { + let key = MessageCacheKey(channelIndex: channelIndex, senderName: senderName, messageHash: messageHash) + return cache[key] ?? [] + } - // Update LRU order - if let existingIndex = dmOrder.firstIndex(of: key) { - dmOrder.remove(at: existingIndex) - } - dmOrder.append(key) + /// Indexes a DM message for later lookup + func indexDM(messageID: UUID, contactID: UUID, text: String, timestamp: UInt32) { + let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) + let key = DirectMessageCacheKey(contactID: contactID, messageHash: hash) + let candidate = MessageCandidate(messageID: messageID, text: text, timestamp: timestamp) - // Evict oldest key if at capacity - if dmOrder.count > capacity, let oldest = dmOrder.first { - dmOrder.removeFirst() - dmCache.removeValue(forKey: oldest) - } + // Update LRU order + if let existingIndex = dmOrder.firstIndex(of: key) { + dmOrder.remove(at: existingIndex) + } + dmOrder.append(key) - // Add candidate to key's list - var candidates = dmCache[key] ?? [] + // Evict oldest key if at capacity + if dmOrder.count > capacity, let oldest = dmOrder.first { + dmOrder.removeFirst() + dmCache.removeValue(forKey: oldest) + } - // Remove existing candidate with same messageID (re-indexing) - candidates.removeAll { $0.messageID == messageID } + // Add candidate to key's list + var candidates = dmCache[key] ?? [] - // Append new candidate - candidates.append(candidate) + // Remove existing candidate with same messageID (re-indexing) + candidates.removeAll { $0.messageID == messageID } - // Prune to max candidates (keep most recent) - if candidates.count > maxCandidatesPerKey { - candidates = Array(candidates.suffix(maxCandidatesPerKey)) - } + // Append new candidate + candidates.append(candidate) - dmCache[key] = candidates + // Prune to max candidates (keep most recent) + if candidates.count > maxCandidatesPerKey { + candidates = Array(candidates.suffix(maxCandidatesPerKey)) } - /// Looks up DM candidates by cache key - func lookupDM(contactID: UUID, messageHash: String) -> [MessageCandidate] { - let key = DirectMessageCacheKey(contactID: contactID, messageHash: messageHash) - return dmCache[key] ?? [] - } - - /// Clears the cache - func clear() { - cache.removeAll() - order.removeAll() - dmCache.removeAll() - dmOrder.removeAll() - } + dmCache[key] = candidates + } + + /// Looks up DM candidates by cache key + func lookupDM(contactID: UUID, messageHash: String) -> [MessageCandidate] { + let key = DirectMessageCacheKey(contactID: contactID, messageHash: messageHash) + return dmCache[key] ?? [] + } + + /// Clears the cache + func clear() { + cache.removeAll() + order.removeAll() + dmCache.removeAll() + dmOrder.removeAll() + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessagePollingService.swift b/MC1Services/Sources/MC1Services/Services/MessagePollingService.swift index bc1a86db..05fdb7be 100644 --- a/MC1Services/Sources/MC1Services/Services/MessagePollingService.swift +++ b/MC1Services/Sources/MC1Services/Services/MessagePollingService.swift @@ -5,19 +5,19 @@ import os // MARK: - Message Polling Errors public enum MessagePollingError: Error, Sendable { - case notConnected - case pollingFailed - case sessionError(MeshCoreError) + case notConnected + case pollingFailed + case sessionError(MeshCoreError) } extension MessagePollingError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .pollingFailed: "Message polling failed." - case .sessionError(let e): e.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .pollingFailed: "Message polling failed." + case let .sessionError(e): e.localizedDescription } + } } // MARK: - Message Polling Service @@ -25,330 +25,329 @@ extension MessagePollingError: LocalizedError { /// Service for polling messages from the mesh device. /// Handles automatic message fetching and contact message routing. actor MessagePollingService { - - // MARK: - Properties - - private let session: any MeshCoreSessionProtocol - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "MessagePolling") - - /// Handler for incoming contact messages. - /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. - private var contactMessageHandler: (@Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void)? - - /// Handler for incoming channel messages. - /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. - private var channelMessageHandler: (@Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void)? - - /// Handler for signed messages (from room servers). - /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. - private var signedMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? - - /// Handler for CLI responses (textType = 0x01). - /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. - private var cliMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? - - /// Event monitoring task - private var eventMonitorTask: Task? - - /// Whether auto-fetch is currently enabled - private var isAutoFetchEnabled = false - - /// Device ID for contact lookups - private var currentRadioID: UUID? - - /// Count of message handlers currently executing - /// Used to wait for sync-time handlers to complete before resuming notifications - private var pendingHandlerCount: Int = 0 - - /// Whether pollAllMessages() is actively polling and processing messages directly. - /// When true, the event monitor skips message events to avoid double-processing. - private var isPolling = false - - // MARK: - Initialization - - init( - session: any MeshCoreSessionProtocol, - dataStore: any PersistenceStoreProtocol - ) { - self.session = session - self.dataStore = dataStore + // MARK: - Properties + + private let session: any MeshCoreSessionProtocol + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "MessagePolling") + + /// Handler for incoming contact messages. + /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. + private var contactMessageHandler: (@Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void)? + + /// Handler for incoming channel messages. + /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. + private var channelMessageHandler: (@Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void)? + + /// Handler for signed messages (from room servers). + /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. + private var signedMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? + + /// Handler for CLI responses (textType = 0x01). + /// Installed by `SyncCoordinator.wireMessageHandlers`; cleared by `clearMessageHandlers`. + private var cliMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? + + /// Event monitoring task + private var eventMonitorTask: Task? + + /// Whether auto-fetch is currently enabled + private var isAutoFetchEnabled = false + + /// Device ID for contact lookups + private var currentRadioID: UUID? + + /// Count of message handlers currently executing + /// Used to wait for sync-time handlers to complete before resuming notifications + private var pendingHandlerCount: Int = 0 + + /// Whether pollAllMessages() is actively polling and processing messages directly. + /// When true, the event monitor skips message events to avoid double-processing. + private var isPolling = false + + // MARK: - Initialization + + init( + session: any MeshCoreSessionProtocol, + dataStore: any PersistenceStoreProtocol + ) { + self.session = session + self.dataStore = dataStore + } + + deinit { + eventMonitorTask?.cancel() + } + + // MARK: - Event Handlers + + /// Set handler for incoming contact messages + func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) { + contactMessageHandler = handler + } + + /// Set handler for incoming channel messages + func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) { + channelMessageHandler = handler + } + + /// Set handler for signed messages (from room servers) + func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { + signedMessageHandler = handler + } + + /// Set handler for CLI responses (textType = 0x01) + func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { + cliMessageHandler = handler + } + + /// Clears all message handlers. The wired handlers capture the owning + /// `ServiceContainer` strongly, so leaving them in place keeps the whole + /// service graph alive after the container is torn down on disconnect. + func clearMessageHandlers() { + contactMessageHandler = nil + channelMessageHandler = nil + signedMessageHandler = nil + cliMessageHandler = nil + } + + /// Whether any message handler is currently wired. + var hasMessageHandlersWired: Bool { + contactMessageHandler != nil || channelMessageHandler != nil + || signedMessageHandler != nil || cliMessageHandler != nil + } + + // MARK: - Event Monitoring + + /// Start event monitoring for message handlers without enabling auto-fetch. + /// Call this before sync to ensure handlers are ready for polled messages. + /// - Parameter radioID: The radio ID for contact lookups + func startMessageEventMonitoring(radioID: UUID) { + currentRadioID = radioID + startEventMonitoring() + logger.info("Message event monitoring started for radio \(radioID)") + } + + /// Stop event monitoring (also stops auto-fetch if running) + func stopMessageEventMonitoring() async { + if isAutoFetchEnabled { + await stopAutoFetch() + } else { + stopEventMonitoring() + currentRadioID = nil } + } - deinit { - eventMonitorTask?.cancel() - } + // MARK: - Auto-Fetch Control - // MARK: - Event Handlers + /// Start automatic message fetching for a device. + /// This enables the session's auto-fetch feature and monitors for incoming messages. + /// - Parameter radioID: The radio ID for contact lookups + func startAutoFetch(radioID: UUID) async { + guard !isAutoFetchEnabled else { return } - /// Set handler for incoming contact messages - func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) { - contactMessageHandler = handler - } + currentRadioID = radioID + isAutoFetchEnabled = true - /// Set handler for incoming channel messages - func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) { - channelMessageHandler = handler + // Start event monitoring if not already running + if eventMonitorTask == nil { + startEventMonitoring() } + await session.startAutoMessageFetching() - /// Set handler for signed messages (from room servers) - func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { - signedMessageHandler = handler - } + logger.info("Auto-fetch started for radio \(radioID)") + } - /// Set handler for CLI responses (textType = 0x01) - func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { - cliMessageHandler = handler - } + /// Stop automatic message fetching + func stopAutoFetch() async { + guard isAutoFetchEnabled else { return } - /// Clears all message handlers. The wired handlers capture the owning - /// `ServiceContainer` strongly, so leaving them in place keeps the whole - /// service graph alive after the container is torn down on disconnect. - func clearMessageHandlers() { - contactMessageHandler = nil - channelMessageHandler = nil - signedMessageHandler = nil - cliMessageHandler = nil - } + isAutoFetchEnabled = false - /// Whether any message handler is currently wired. - var hasMessageHandlersWired: Bool { - contactMessageHandler != nil || channelMessageHandler != nil - || signedMessageHandler != nil || cliMessageHandler != nil - } + // Stop session-level auto-fetch + await session.stopAutoMessageFetching() - // MARK: - Event Monitoring + // Stop event monitoring + stopEventMonitoring() - /// Start event monitoring for message handlers without enabling auto-fetch. - /// Call this before sync to ensure handlers are ready for polled messages. - /// - Parameter radioID: The radio ID for contact lookups - func startMessageEventMonitoring(radioID: UUID) { - currentRadioID = radioID - startEventMonitoring() - logger.info("Message event monitoring started for radio \(radioID)") - } + logger.info("Auto-fetch stopped") + } - /// Stop event monitoring (also stops auto-fetch if running) - func stopMessageEventMonitoring() async { - if isAutoFetchEnabled { - await stopAutoFetch() - } else { - stopEventMonitoring() - currentRadioID = nil - } - } - - // MARK: - Auto-Fetch Control + /// Pause session-level auto-fetching without stopping event monitoring. + /// Used during resync to prevent auto-fetch events from inflating handler counts. + func pauseAutoFetch() async { + guard isAutoFetchEnabled else { return } + await session.stopAutoMessageFetching() + } - /// Start automatic message fetching for a device. - /// This enables the session's auto-fetch feature and monitors for incoming messages. - /// - Parameter radioID: The radio ID for contact lookups - func startAutoFetch(radioID: UUID) async { - guard !isAutoFetchEnabled else { return } + /// Resume session-level auto-fetching after a pause. + func resumeAutoFetch() async { + guard isAutoFetchEnabled else { return } + await session.startAutoMessageFetching() + } - currentRadioID = radioID - isAutoFetchEnabled = true + /// Check if auto-fetch is currently enabled + var isAutoFetching: Bool { + isAutoFetchEnabled + } - // Start event monitoring if not already running - if eventMonitorTask == nil { - startEventMonitoring() - } - await session.startAutoMessageFetching() + // MARK: - Manual Polling - logger.info("Auto-fetch started for radio \(radioID)") + /// Manually poll for one message from the device. + /// - Returns: The message result (contact message, channel message, or no more messages) + func pollMessage() async throws -> MessageResult { + do { + return try await session.getMessage() + } catch let error as MeshCoreError { + throw MessagePollingError.sessionError(error) } - - /// Stop automatic message fetching - func stopAutoFetch() async { - guard isAutoFetchEnabled else { return } - - isAutoFetchEnabled = false - - // Stop session-level auto-fetch - await session.stopAutoMessageFetching() - - // Stop event monitoring - stopEventMonitoring() - - logger.info("Auto-fetch stopped") + } + + /// Poll all waiting messages from the device. + /// - Returns: Count of messages retrieved + func pollAllMessages() async throws -> Int { + isPolling = true + defer { isPolling = false } + var count = 0 + + // One anchor for the whole drain so every backlog message shares a sort date + // and forms a single contiguous block positioned at delivery time. + let blockAnchor = Date() + + while true { + let result = try await pollMessage() + switch result { + case let .contactMessage(msg): + count += 1 + await handleContactMessage(msg, context: .initialSync(anchor: blockAnchor)) + case let .channelMessage(msg): + count += 1 + await handleChannelMessage(msg, context: .initialSync(anchor: blockAnchor)) + case .channelDatagram: + // Datagrams are not user-visible messages; drain queue without counting. + // A follow-up plan will add a dedicated MC1Services listener for them. + break + case .noMoreMessages: + return count + } } - - /// Pause session-level auto-fetching without stopping event monitoring. - /// Used during resync to prevent auto-fetch events from inflating handler counts. - func pauseAutoFetch() async { - guard isAutoFetchEnabled else { return } - await session.stopAutoMessageFetching() + } + + /// Wait for all pending message handlers to complete. + /// Call this after pollAllMessages() to ensure all messages are fully processed + /// before performing actions that depend on completion (like resuming notifications). + func waitForPendingHandlers(timeout: Duration) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while pendingHandlerCount > 0 { + if clock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) } - /// Resume session-level auto-fetching after a pause. - func resumeAutoFetch() async { - guard isAutoFetchEnabled else { return } - await session.startAutoMessageFetching() - } + return true + } - /// Check if auto-fetch is currently enabled - var isAutoFetching: Bool { - isAutoFetchEnabled - } + // MARK: - Event Monitoring - // MARK: - Manual Polling + /// Start monitoring MeshCore events for messages + private func startEventMonitoring() { + eventMonitorTask?.cancel() - /// Manually poll for one message from the device. - /// - Returns: The message result (contact message, channel message, or no more messages) - func pollMessage() async throws -> MessageResult { - do { - return try await session.getMessage() - } catch let error as MeshCoreError { - throw MessagePollingError.sessionError(error) - } - } + eventMonitorTask = Task { [weak self] in + guard let self else { return } + let events = await session.events(filter: EventFilter.anyContactMessage.or(.anyChannelMessage)) - /// Poll all waiting messages from the device. - /// - Returns: Count of messages retrieved - func pollAllMessages() async throws -> Int { - isPolling = true - defer { isPolling = false } - var count = 0 - - // One anchor for the whole drain so every backlog message shares a sort date - // and forms a single contiguous block positioned at delivery time. - let blockAnchor = Date() - - while true { - let result = try await pollMessage() - switch result { - case .contactMessage(let msg): - count += 1 - await handleContactMessage(msg, context: .initialSync(anchor: blockAnchor)) - case .channelMessage(let msg): - count += 1 - await handleChannelMessage(msg, context: .initialSync(anchor: blockAnchor)) - case .channelDatagram: - // Datagrams are not user-visible messages; drain queue without counting. - // A follow-up plan will add a dedicated MC1Services listener for them. - break - case .noMoreMessages: - return count - } - } + for await event in events { + guard !Task.isCancelled else { break } + await handleEvent(event) + } } - - /// Wait for all pending message handlers to complete. - /// Call this after pollAllMessages() to ensure all messages are fully processed - /// before performing actions that depend on completion (like resuming notifications). - func waitForPendingHandlers(timeout: Duration) async -> Bool { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - - while pendingHandlerCount > 0 { - if clock.now >= deadline { - return false - } - try? await Task.sleep(for: .milliseconds(10)) - } - - return true + } + + /// Stop monitoring events + private func stopEventMonitoring() { + eventMonitorTask?.cancel() + eventMonitorTask = nil + } + + /// Handle incoming MeshCore event + private func handleEvent(_ event: MeshEvent) async { + switch event { + case let .contactMessageReceived(message): + guard !isPolling else { return } + pendingHandlerCount += 1 + defer { pendingHandlerCount -= 1 } + await handleContactMessage(message, context: .live) + + case let .channelMessageReceived(message): + guard !isPolling else { return } + pendingHandlerCount += 1 + defer { pendingHandlerCount -= 1 } + await handleChannelMessage(message, context: .live) + + default: + break } + } - // MARK: - Event Monitoring - - /// Start monitoring MeshCore events for messages - private func startEventMonitoring() { - eventMonitorTask?.cancel() + // MARK: - Private Message Handlers - eventMonitorTask = Task { [weak self] in - guard let self else { return } - let events = await session.events(filter: EventFilter.anyContactMessage.or(.anyChannelMessage)) - - for await event in events { - guard !Task.isCancelled else { break } - await self.handleEvent(event) - } - } + /// Handle incoming contact message + private func handleContactMessage(_ message: ContactMessage, context: DeliveryContext) async { + guard let radioID = currentRadioID else { + logger.warning("Received message but no radio ID set") + await contactMessageHandler?(message, nil, context) + return } - /// Stop monitoring events - private func stopEventMonitoring() { - eventMonitorTask?.cancel() - eventMonitorTask = nil + // Look up the sender contact + let contact = try? await dataStore.fetchContact( + radioID: radioID, + publicKeyPrefix: message.senderPublicKeyPrefix + ) + + // Route based on text type + switch message.textType { + case MeshTextType.cliData.rawValue: + // CLI responses from repeaters (textType = 0x01) + await cliMessageHandler?(message, contact) + case MeshTextType.signedPlain.rawValue: + // Signed messages from room servers (textType = 0x02) + await signedMessageHandler?(message, contact) + default: + // Regular contact messages (textType = 0x00 or unknown) + await contactMessageHandler?(message, contact, context) } - - /// Handle incoming MeshCore event - private func handleEvent(_ event: MeshEvent) async { - switch event { - case .contactMessageReceived(let message): - guard !isPolling else { return } - pendingHandlerCount += 1 - defer { pendingHandlerCount -= 1 } - await handleContactMessage(message, context: .live) - - case .channelMessageReceived(let message): - guard !isPolling else { return } - pendingHandlerCount += 1 - defer { pendingHandlerCount -= 1 } - await handleChannelMessage(message, context: .live) - - default: - break - } + } + + /// Handle incoming channel message + private func handleChannelMessage(_ message: ChannelMessage, context: DeliveryContext) async { + guard let radioID = currentRadioID else { + logger.warning("Received channel message but no radio ID set") + await channelMessageHandler?(message, nil, context) + return } - // MARK: - Private Message Handlers - - /// Handle incoming contact message - private func handleContactMessage(_ message: ContactMessage, context: DeliveryContext) async { - guard let radioID = currentRadioID else { - logger.warning("Received message but no radio ID set") - await contactMessageHandler?(message, nil, context) - return - } - - // Look up the sender contact - let contact = try? await dataStore.fetchContact( - radioID: radioID, - publicKeyPrefix: message.senderPublicKeyPrefix - ) - - // Route based on text type - switch message.textType { - case MeshTextType.cliData.rawValue: - // CLI responses from repeaters (textType = 0x01) - await cliMessageHandler?(message, contact) - case MeshTextType.signedPlain.rawValue: - // Signed messages from room servers (textType = 0x02) - await signedMessageHandler?(message, contact) - default: - // Regular contact messages (textType = 0x00 or unknown) - await contactMessageHandler?(message, contact, context) - } - } - - /// Handle incoming channel message - private func handleChannelMessage(_ message: ChannelMessage, context: DeliveryContext) async { - guard let radioID = currentRadioID else { - logger.warning("Received channel message but no radio ID set") - await channelMessageHandler?(message, nil, context) - return - } + // Look up the channel + let channel = try? await dataStore.fetchChannel(radioID: radioID, index: message.channelIndex) - // Look up the channel - let channel = try? await dataStore.fetchChannel(radioID: radioID, index: message.channelIndex) - - await channelMessageHandler?(message, channel, context) - } + await channelMessageHandler?(message, channel, context) + } } // MARK: - MessagePollingServiceProtocol Conformance extension MessagePollingService: MessagePollingServiceProtocol { - // Already implements pollAllMessages() -> Int + // Already implements pollAllMessages() -> Int } // MARK: - Message Text Type Constants /// Text type identifiers for mesh messages enum MeshTextType: UInt8 { - case plain = 0x00 - case cliData = 0x01 - case signedPlain = 0x02 + case plain = 0x00 + case cliData = 0x01 + case signedPlain = 0x02 } diff --git a/MC1Services/Sources/MC1Services/Services/MessageService+ACK.swift b/MC1Services/Sources/MC1Services/Services/MessageService+ACK.swift index ec17a325..a6443388 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageService+ACK.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageService+ACK.swift @@ -2,132 +2,131 @@ import Foundation // MARK: - Periodic ACK Checking -extension MessageService { - - /// Starts periodic checking for expired ACKs. - /// - /// Runs a background task that periodically fails a DM awaiting an ACK once - /// its per-entry give-up deadline elapses; `checkExpiredAcks` defines that - /// deadline. - /// - /// - Parameter interval: How often to check for expired ACKs (defaults to 5 seconds) - /// - /// # Lifecycle scope - /// - /// Independent from `startEventMonitoring()`. Counterparts are - /// `stopAckExpiryChecking()` (stop the checker only) and - /// `stopAndFailAllPending()` (stop the checker and fail every in-flight - /// DM — the explicit full-teardown variant). `stopEventMonitoring()` does - /// **not** stop this task. - public func startAckExpiryChecking(interval: TimeInterval = 5.0) { - self.checkInterval = interval - ackCheckTask?.cancel() - - ackCheckTask = Task { [weak self] in - guard let self else { return } - - while !Task.isCancelled { - do { - try await Task.sleep(for: .seconds(self.checkInterval)) - } catch { - break - } - - guard !Task.isCancelled else { break } - - do { - try await self.checkExpiredAcks() - } catch { - self.logger.error("ACK expiry check failed: \(error.localizedDescription)") - } - } - } - } - - /// Stops the periodic ACK expiry checking. - /// - /// Cancels `ackCheckTask` only. Does not stop the session event listener - /// (`stopEventMonitoring()`) and does not fail in-flight DMs - /// (`stopAndFailAllPending()` does both). This is the stop variant used on - /// a routine disconnect: in-flight DMs stay `.sent` so a reconnect within - /// the same session can still receive the ACK. - public func stopAckExpiryChecking() { - ackCheckTask?.cancel() - ackCheckTask = nil - } - - /// Checks for expired ACKs and advances their delivery state. - /// - /// Called automatically by the periodic checker, or manually for an - /// immediate check. The give-up deadline for each entry is - /// `max(ackGiveUpWindow, tracking.timeout)`: the window acts as a floor - /// and post-loop grace on fast presets, while on slow high-spreading-factor - /// presets the deadline follows the attempt's own ACK wait so the checker - /// never fires mid-attempt while the retry loop is still legitimately - /// parked in `waitForEvent`. - /// - /// - Throws: Database errors when updating message status - public func checkExpiredAcks() async throws { - let now = Date() - let window = config.ackGiveUpWindow - - let expiredEntries = pendingAcks.filter { _, tracking in - !tracking.isDelivered && - now.timeIntervalSince(tracking.sentAt) > max(window, tracking.timeout) +public extension MessageService { + /// Starts periodic checking for expired ACKs. + /// + /// Runs a background task that periodically fails a DM awaiting an ACK once + /// its per-entry give-up deadline elapses; `checkExpiredAcks` defines that + /// deadline. + /// + /// - Parameter interval: How often to check for expired ACKs (defaults to 5 seconds) + /// + /// # Lifecycle scope + /// + /// Independent from `startEventMonitoring()`. Counterparts are + /// `stopAckExpiryChecking()` (stop the checker only) and + /// `stopAndFailAllPending()` (stop the checker and fail every in-flight + /// DM — the explicit full-teardown variant). `stopEventMonitoring()` does + /// **not** stop this task. + func startAckExpiryChecking(interval: TimeInterval = 5.0) { + checkInterval = interval + ackCheckTask?.cancel() + + ackCheckTask = Task { [weak self] in + guard let self else { return } + + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(checkInterval)) + } catch { + break } - for (messageID, _) in expiredEntries { - let didFail = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) - guard let removed = pendingAcks.removeValue(forKey: messageID), - !removed.isDelivered, didFail else { continue } + guard !Task.isCancelled else { break } - let deadline = max(window, removed.timeout) - logger.warning("[ack-diag] give-up: failed after \(String(format: "%.1f", now.timeIntervalSince(removed.sentAt)))s window=\(window)s deadline=\(String(format: "%.1f", deadline))s livePending=\(pendingAcks.count)") - statusEventBroadcaster.yield(.failed(messageID: messageID)) + do { + try await checkExpiredAcks() + } catch { + logger.error("ACK expiry check failed: \(error.localizedDescription)") } + } } - - /// Fails all pending messages that are awaiting ACK. - /// - /// Use this when disconnecting from the device to mark all in-flight messages as failed. - /// - /// - Throws: Database errors when updating message status - public func failAllPendingMessages() async throws { - let pending = pendingAcks.filter { !$0.value.isDelivered } - pendingAcks.removeAll() - - for (messageID, _) in pending { - let didFail = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) - if didFail { - statusEventBroadcaster.yield(.failed(messageID: messageID)) - } - } + } + + /// Stops the periodic ACK expiry checking. + /// + /// Cancels `ackCheckTask` only. Does not stop the session event listener + /// (`stopEventMonitoring()`) and does not fail in-flight DMs + /// (`stopAndFailAllPending()` does both). This is the stop variant used on + /// a routine disconnect: in-flight DMs stay `.sent` so a reconnect within + /// the same session can still receive the ACK. + func stopAckExpiryChecking() { + ackCheckTask?.cancel() + ackCheckTask = nil + } + + /// Checks for expired ACKs and advances their delivery state. + /// + /// Called automatically by the periodic checker, or manually for an + /// immediate check. The give-up deadline for each entry is + /// `max(ackGiveUpWindow, tracking.timeout)`: the window acts as a floor + /// and post-loop grace on fast presets, while on slow high-spreading-factor + /// presets the deadline follows the attempt's own ACK wait so the checker + /// never fires mid-attempt while the retry loop is still legitimately + /// parked in `waitForEvent`. + /// + /// - Throws: Database errors when updating message status + func checkExpiredAcks() async throws { + let now = Date() + let window = config.ackGiveUpWindow + + let expiredEntries = pendingAcks.filter { _, tracking in + !tracking.isDelivered && + now.timeIntervalSince(tracking.sentAt) > max(window, tracking.timeout) } - /// Stops ACK checking and fails all pending messages atomically. - /// - /// Use this only for an explicit full teardown where in-flight DMs should - /// be terminated. A routine disconnect uses `stopAckExpiryChecking()` - /// instead, which leaves in-flight DMs `.sent` so a reconnect can still - /// receive their ACKs. - /// - /// - Throws: Database errors when updating message status - public func stopAndFailAllPending() async throws { - ackCheckTask?.cancel() - ackCheckTask = nil - - try await failAllPendingMessages() - } + for (messageID, _) in expiredEntries { + let didFail = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) + guard let removed = pendingAcks.removeValue(forKey: messageID), + !removed.isDelivered, didFail else { continue } - /// The current number of pending ACKs being tracked. - /// - /// Includes undelivered messages still inside the `ackGiveUpWindow`. - public var pendingAckCount: Int { - pendingAcks.count + let deadline = max(window, removed.timeout) + logger.warning("[ack-diag] give-up: failed after \(String(format: "%.1f", now.timeIntervalSince(removed.sentAt)))s window=\(window)s deadline=\(String(format: "%.1f", deadline))s livePending=\(pendingAcks.count)") + statusEventBroadcaster.yield(.failed(messageID: messageID)) } - - /// Whether ACK expiry checking is currently active. - public var isAckExpiryCheckingActive: Bool { - ackCheckTask != nil + } + + /// Fails all pending messages that are awaiting ACK. + /// + /// Use this when disconnecting from the device to mark all in-flight messages as failed. + /// + /// - Throws: Database errors when updating message status + func failAllPendingMessages() async throws { + let pending = pendingAcks.filter { !$0.value.isDelivered } + pendingAcks.removeAll() + + for (messageID, _) in pending { + let didFail = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) + if didFail { + statusEventBroadcaster.yield(.failed(messageID: messageID)) + } } + } + + /// Stops ACK checking and fails all pending messages atomically. + /// + /// Use this only for an explicit full teardown where in-flight DMs should + /// be terminated. A routine disconnect uses `stopAckExpiryChecking()` + /// instead, which leaves in-flight DMs `.sent` so a reconnect can still + /// receive their ACKs. + /// + /// - Throws: Database errors when updating message status + func stopAndFailAllPending() async throws { + ackCheckTask?.cancel() + ackCheckTask = nil + + try await failAllPendingMessages() + } + + /// The current number of pending ACKs being tracked. + /// + /// Includes undelivered messages still inside the `ackGiveUpWindow`. + var pendingAckCount: Int { + pendingAcks.count + } + + /// Whether ACK expiry checking is currently active. + var isAckExpiryCheckingActive: Bool { + ackCheckTask != nil + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageService+SendChannel.swift b/MC1Services/Sources/MC1Services/Services/MessageService+SendChannel.swift index addbe481..3369c92a 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageService+SendChannel.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageService+SendChannel.swift @@ -1,293 +1,292 @@ import Foundation import MeshCore -extension MessageService { +public extension MessageService { + // MARK: - Send Channel Message - // MARK: - Send Channel Message + /// Sends a broadcast message to a channel. + /// + /// Channel messages are broadcast to all devices listening on the specified channel. + /// No acknowledgement is expected or tracked for channel messages. + /// + /// - Parameters: + /// - text: The message text to broadcast. Total payload, including the local + /// node-name header used by repeaters, is bounded by + /// `ProtocolLimits.maxChannelMessageTotalLength` (147 UTF-8 bytes). + /// - channelIndex: The channel index (0-7) + /// - radioID: The local device ID + /// - textType: The text encoding type (defaults to `.plain`) + /// + /// - Returns: The ID of the created message + /// + /// - Throws: + /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxChannelMessageTotalLength` + /// - `MessageServiceError.channelNotFound` if channel index is invalid + /// - `MessageServiceError.sessionError` if MeshCore send fails + /// + /// # Example + /// + /// ```swift + /// let messageID = try await messageService.sendChannelMessage( + /// text: "Hello channel!", + /// channelIndex: 0, + /// radioID: device.id + /// ) + /// ``` + func sendChannelMessage( + text: String, + channelIndex: UInt8, + radioID: UUID, + textType: TextType = .plain + ) async throws -> (id: UUID, timestamp: UInt32) { + // Validate message length (byte count matches firmware buffer limits) + guard text.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength else { + throw MessageServiceError.messageTooLong + } - /// Sends a broadcast message to a channel. - /// - /// Channel messages are broadcast to all devices listening on the specified channel. - /// No acknowledgement is expected or tracked for channel messages. - /// - /// - Parameters: - /// - text: The message text to broadcast. Total payload, including the local - /// node-name header used by repeaters, is bounded by - /// `ProtocolLimits.maxChannelMessageTotalLength` (147 UTF-8 bytes). - /// - channelIndex: The channel index (0-7) - /// - radioID: The local device ID - /// - textType: The text encoding type (defaults to `.plain`) - /// - /// - Returns: The ID of the created message - /// - /// - Throws: - /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxChannelMessageTotalLength` - /// - `MessageServiceError.channelNotFound` if channel index is invalid - /// - `MessageServiceError.sessionError` if MeshCore send fails - /// - /// # Example - /// - /// ```swift - /// let messageID = try await messageService.sendChannelMessage( - /// text: "Hello channel!", - /// channelIndex: 0, - /// radioID: device.id - /// ) - /// ``` - public func sendChannelMessage( - text: String, - channelIndex: UInt8, - radioID: UUID, - textType: TextType = .plain - ) async throws -> (id: UUID, timestamp: UInt32) { - // Validate message length (byte count matches firmware buffer limits) - guard text.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength else { - throw MessageServiceError.messageTooLong - } + let messageID = UUID() + let timestamp = UInt32(Date().timeIntervalSince1970) - let messageID = UUID() - let timestamp = UInt32(Date().timeIntervalSince1970) + // Save message to store as pending first + let messageDTO = createOutgoingChannelMessage( + id: messageID, + radioID: radioID, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + textType: textType + ) + try await dataStore.saveMessage(messageDTO) - // Save message to store as pending first - let messageDTO = createOutgoingChannelMessage( - id: messageID, - radioID: radioID, - channelIndex: channelIndex, - text: text, - timestamp: timestamp, - textType: textType + do { + try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { + try await session.sendChannelMessage( + channel: channelIndex, + text: text, + timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)) ) - try await dataStore.saveMessage(messageDTO) - - do { - try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { - try await session.sendChannelMessage( - channel: channelIndex, - text: text, - timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)) - ) - } - } catch { - statusEventBroadcaster.yield(.failed(messageID: messageID)) - try await failMessageAndRethrow(error, messageID: messageID) - } - - // Post-broadcast bookkeeping. The broadcast already left the radio, - // so a throw here must not mark `.failed` — that would lie about - // delivery. Log and continue; the message stays `.sent` (or sticks - // `.pending` if `updateMessageStatus` itself threw, which a subsequent - // ack or user retry can reconverge). No queue row exists on this - // inline path, so the "retry button hidden via missing PendingSend" - // pathology from the queue-routed path does not apply. - do { - try await dataStore.updateMessageStatus(id: messageID, status: .sent) - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) - if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { - try await dataStore.updateChannelLastMessage(channelID: channel.id, date: Date()) - } - } catch { - logger.warning("Channel post-broadcast bookkeeping failed (inline path) messageID=\(messageID) broadcast already out: \(String(describing: error))") - } + } + } catch { + statusEventBroadcaster.yield(.failed(messageID: messageID)) + try await failMessageAndRethrow(error, messageID: messageID) + } - return (id: messageID, timestamp: timestamp) + // Post-broadcast bookkeeping. The broadcast already left the radio, + // so a throw here must not mark `.failed` — that would lie about + // delivery. Log and continue; the message stays `.sent` (or sticks + // `.pending` if `updateMessageStatus` itself threw, which a subsequent + // ack or user retry can reconverge). No queue row exists on this + // inline path, so the "retry button hidden via missing PendingSend" + // pathology from the queue-routed path does not apply. + do { + try await dataStore.updateMessageStatus(id: messageID, status: .sent) + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) + if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { + try await dataStore.updateChannelLastMessage(channelID: channel.id, date: Date()) + } + } catch { + logger.warning("Channel post-broadcast bookkeeping failed (inline path) messageID=\(messageID) broadcast already out: \(String(describing: error))") } - /// Creates a pending channel message without sending it. - /// - /// Use this for optimistic UI — the message is saved immediately and can be - /// displayed in the conversation while the actual send happens in the background - /// via ``sendPendingChannelMessage(messageID:channelIndex:radioID:)``. - /// - /// - Parameters: - /// - text: The message text - /// - channelIndex: The channel index to send on - /// - radioID: The device ID - /// - textType: The text type (defaults to `.plain`) - /// - /// - Returns: The created message DTO with pending status - public func createPendingChannelMessage( - text: String, - channelIndex: UInt8, - radioID: UUID, - textType: TextType = .plain - ) async throws -> MessageDTO { - guard text.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength else { - throw MessageServiceError.messageTooLong - } + return (id: messageID, timestamp: timestamp) + } - let messageID = UUID() - let timestamp = UInt32(Date().timeIntervalSince1970) + /// Creates a pending channel message without sending it. + /// + /// Use this for optimistic UI — the message is saved immediately and can be + /// displayed in the conversation while the actual send happens in the background + /// via ``sendPendingChannelMessage(messageID:channelIndex:radioID:)``. + /// + /// - Parameters: + /// - text: The message text + /// - channelIndex: The channel index to send on + /// - radioID: The device ID + /// - textType: The text type (defaults to `.plain`) + /// + /// - Returns: The created message DTO with pending status + func createPendingChannelMessage( + text: String, + channelIndex: UInt8, + radioID: UUID, + textType: TextType = .plain + ) async throws -> MessageDTO { + guard text.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength else { + throw MessageServiceError.messageTooLong + } - let messageDTO = createOutgoingChannelMessage( - id: messageID, - radioID: radioID, - channelIndex: channelIndex, - text: text, - timestamp: timestamp, - textType: textType - ) - try await dataStore.saveMessage(messageDTO) + let messageID = UUID() + let timestamp = UInt32(Date().timeIntervalSince1970) - return messageDTO - } + let messageDTO = createOutgoingChannelMessage( + id: messageID, + radioID: radioID, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + textType: textType + ) + try await dataStore.saveMessage(messageDTO) - /// Sends an already-created pending channel message. - /// - /// Use this after ``createPendingChannelMessage(text:channelIndex:radioID:textType:)`` - /// to transmit the message over the mesh. Updates the message status to `.sent` on - /// success or `.failed` on error. - /// - /// - Parameter messageID: The ID of the pending message to send - public func sendPendingChannelMessage(messageID: UUID) async throws { - // Catch 1: validation guards + send + status flip. Validation lives - // inside the do/catch so a missing message or missing channel index - // consistently writes `.failed` to the DB before propagating — paired - // with the queue's outer catch, which fires `notifyMessageFailed`. - // A failure before .sent is committed means the user should see - // .failed (retry button visible). - let radioID: UUID - let channelIndex: UInt8 - do { - guard let message = try await dataStore.fetchMessage(id: messageID) else { - throw MessageServiceError.sendFailed("Message not found") - } - guard let idx = message.channelIndex else { - throw MessageServiceError.sendFailed("Not a channel message") - } - radioID = message.radioID - channelIndex = idx + return messageDTO + } - try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { - try await session.sendChannelMessage( - channel: channelIndex, - text: message.text, - timestamp: Date(timeIntervalSince1970: TimeInterval(message.timestamp)) - ) - } - try await dataStore.updateMessageStatus(id: messageID, status: .sent) - } catch { - try await failMessageAndRethrow(error, messageID: messageID) - } + /// Sends an already-created pending channel message. + /// + /// Use this after ``createPendingChannelMessage(text:channelIndex:radioID:textType:)`` + /// to transmit the message over the mesh. Updates the message status to `.sent` on + /// success or `.failed` on error. + /// + /// - Parameter messageID: The ID of the pending message to send + func sendPendingChannelMessage(messageID: UUID) async throws { + // Catch 1: validation guards + send + status flip. Validation lives + // inside the do/catch so a missing message or missing channel index + // consistently writes `.failed` to the DB before propagating — paired + // with the queue's outer catch, which fires `notifyMessageFailed`. + // A failure before .sent is committed means the user should see + // .failed (retry button visible). + let radioID: UUID + let channelIndex: UInt8 + do { + guard let message = try await dataStore.fetchMessage(id: messageID) else { + throw MessageServiceError.sendFailed("Message not found") + } + guard let idx = message.channelIndex else { + throw MessageServiceError.sendFailed("Not a channel message") + } + radioID = message.radioID + channelIndex = idx - // Catch 2: post-status bookkeeping. Status is already .sent in DB; the - // radio broadcast happened. A failure here is logged but does not mark - // .failed — that would corrupt the user-visible delivery state. The - // bookkeeping (channel last-message timestamp, sent event) is - // best-effort metadata that next-load or next-ack will reconverge. - do { - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) - if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { - try await dataStore.updateChannelLastMessage(channelID: channel.id, date: Date()) - } - } catch { - logger.warning("Channel post-status bookkeeping failed messageID=\(messageID) status=.sent already committed: \(String(describing: error))") - } + try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { + try await session.sendChannelMessage( + channel: channelIndex, + text: message.text, + timestamp: Date(timeIntervalSince1970: TimeInterval(message.timestamp)) + ) + } + try await dataStore.updateMessageStatus(id: messageID, status: .sent) + } catch { + try await failMessageAndRethrow(error, messageID: messageID) } - /// Resend an existing channel message, incrementing its send count. - /// - /// This is used for "Send Again" - it re-transmits the same message - /// rather than creating a duplicate. Uses a new timestamp so the mesh - /// treats it as a fresh broadcast. - /// - /// - Returns: The new wire timestamp written to the message row. - /// Reaction indexing keys off this value via - /// `SHA256(text || timestamp.littleEndian)`; callers that index reactions - /// for the resent packet must pass this value rather than the pre-resend - /// timestamp from the queued envelope. - @discardableResult - public func resendChannelMessage( - messageID: UUID, - preserveTimestamp: Bool = false - ) async throws -> UInt32 { - // Broadcast .resent only after .sent is committed. The sentCommitted - // flag suppresses a spurious broadcast if catch 1 rethrows; - // failMessageAndRethrow always re-throws, so a catch-1 failure exits - // the function before the broadcast site is reached and the guard is - // belt-and-braces. The event still fires when catch 2 bookkeeping - // throws, since sentCommitted is set before catch 2 runs. Yielding - // after catch 2 and before return preserves the committed-before- - // broadcast ordering the resend tests assert. - var sentCommitted = false - - // Catch 1: validation guards + timestamp update + send + status flip. - // Validation lives inside the do/catch so a missing message or missing - // channel index consistently writes `.failed` before propagating — - // paired with the queue's outer catch. Once `.sent` is committed, the - // per-attempt bookkeeping below cannot retroactively flip it. - let wireTimestamp: UInt32 - do { - guard let message = try await dataStore.fetchMessage(id: messageID) else { - throw MessageServiceError.sendFailed("Message not found") - } - guard let channelIndex = message.channelIndex else { - throw MessageServiceError.sendFailed("Not a channel message") - } + // Catch 2: post-status bookkeeping. Status is already .sent in DB; the + // radio broadcast happened. A failure here is logged but does not mark + // .failed — that would corrupt the user-visible delivery state. The + // bookkeeping (channel last-message timestamp, sent event) is + // best-effort metadata that next-load or next-ack will reconverge. + do { + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) + if let channel = try await dataStore.fetchChannel(radioID: radioID, index: channelIndex) { + try await dataStore.updateChannelLastMessage(channelID: channel.id, date: Date()) + } + } catch { + logger.warning("Channel post-status bookkeeping failed messageID=\(messageID) status=.sent already committed: \(String(describing: error))") + } + } - let wireDate: Date - if preserveTimestamp { - wireTimestamp = message.timestamp - wireDate = Date(timeIntervalSince1970: TimeInterval(wireTimestamp)) - } else { - let now = Date() - wireTimestamp = UInt32(now.timeIntervalSince1970) - wireDate = now - } + /// Resend an existing channel message, incrementing its send count. + /// + /// This is used for "Send Again" - it re-transmits the same message + /// rather than creating a duplicate. Uses a new timestamp so the mesh + /// treats it as a fresh broadcast. + /// + /// - Returns: The new wire timestamp written to the message row. + /// Reaction indexing keys off this value via + /// `SHA256(text || timestamp.littleEndian)`; callers that index reactions + /// for the resent packet must pass this value rather than the pre-resend + /// timestamp from the queued envelope. + @discardableResult + func resendChannelMessage( + messageID: UUID, + preserveTimestamp: Bool = false + ) async throws -> UInt32 { + // Broadcast .resent only after .sent is committed. The sentCommitted + // flag suppresses a spurious broadcast if catch 1 rethrows; + // failMessageAndRethrow always re-throws, so a catch-1 failure exits + // the function before the broadcast site is reached and the guard is + // belt-and-braces. The event still fires when catch 2 bookkeeping + // throws, since sentCommitted is set before catch 2 runs. Yielding + // after catch 2 and before return preserves the committed-before- + // broadcast ordering the resend tests assert. + var sentCommitted = false - if !preserveTimestamp { - try await dataStore.updateMessageTimestamp(id: messageID, timestamp: wireTimestamp) - } - try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { - try await session.sendChannelMessage( - channel: channelIndex, - text: message.text, - timestamp: wireDate - ) - } - try await dataStore.updateMessageStatus(id: messageID, status: .sent) - sentCommitted = true - } catch { - try await failMessageAndRethrow(error, messageID: messageID) - } + // Catch 1: validation guards + timestamp update + send + status flip. + // Validation lives inside the do/catch so a missing message or missing + // channel index consistently writes `.failed` before propagating — + // paired with the queue's outer catch. Once `.sent` is committed, the + // per-attempt bookkeeping below cannot retroactively flip it. + let wireTimestamp: UInt32 + do { + guard let message = try await dataStore.fetchMessage(id: messageID) else { + throw MessageServiceError.sendFailed("Message not found") + } + guard let channelIndex = message.channelIndex else { + throw MessageServiceError.sendFailed("Not a channel message") + } - // Catch 2: per-attempt bookkeeping. Status is already .sent in DB and - // the radio broadcast happened. Failures here log only — the message - // is correctly marked .sent and the missing metadata (sendCount, - // heardRepeats clear) is non-load-bearing for delivery semantics; the - // next inbound ack/repeat event will reconverge. - do { - _ = try await dataStore.incrementMessageSendCount(id: messageID) - try await dataStore.updateMessageHeardRepeats(id: messageID, heardRepeats: 0) - try await dataStore.deleteMessageRepeats(messageID: messageID) - } catch { - logger.warning("Resend post-status bookkeeping failed messageID=\(messageID) status=.sent already committed: \(String(describing: error))") - } + let wireDate: Date + if preserveTimestamp { + wireTimestamp = message.timestamp + wireDate = Date(timeIntervalSince1970: TimeInterval(wireTimestamp)) + } else { + let now = Date() + wireTimestamp = UInt32(now.timeIntervalSince1970) + wireDate = now + } - if sentCommitted { - statusEventBroadcaster.yield(.resent(messageID: messageID)) - } + if !preserveTimestamp { + try await dataStore.updateMessageTimestamp(id: messageID, timestamp: wireTimestamp) + } + try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.channelMessageNotFound, config: config.poolBackoff, logger: logger) { + try await session.sendChannelMessage( + channel: channelIndex, + text: message.text, + timestamp: wireDate + ) + } + try await dataStore.updateMessageStatus(id: messageID, status: .sent) + sentCommitted = true + } catch { + try await failMessageAndRethrow(error, messageID: messageID) + } - return wireTimestamp + // Catch 2: per-attempt bookkeeping. Status is already .sent in DB and + // the radio broadcast happened. Failures here log only — the message + // is correctly marked .sent and the missing metadata (sendCount, + // heardRepeats clear) is non-load-bearing for delivery semantics; the + // next inbound ack/repeat event will reconverge. + do { + _ = try await dataStore.incrementMessageSendCount(id: messageID) + try await dataStore.updateMessageHeardRepeats(id: messageID, heardRepeats: 0) + try await dataStore.deleteMessageRepeats(messageID: messageID) + } catch { + logger.warning("Resend post-status bookkeeping failed messageID=\(messageID) status=.sent already committed: \(String(describing: error))") } - private func createOutgoingChannelMessage( - id: UUID, - radioID: UUID, - channelIndex: UInt8, - text: String, - timestamp: UInt32, - textType: TextType - ) -> MessageDTO { - let message = Message( - id: id, - radioID: radioID, - channelIndex: channelIndex, - text: text, - timestamp: timestamp, - directionRawValue: MessageDirection.outgoing.rawValue, - statusRawValue: MessageStatus.pending.rawValue, - textTypeRawValue: textType.rawValue - ) - return MessageDTO(from: message) + if sentCommitted { + statusEventBroadcaster.yield(.resent(messageID: messageID)) } + + return wireTimestamp + } + + private func createOutgoingChannelMessage( + id: UUID, + radioID: UUID, + channelIndex: UInt8, + text: String, + timestamp: UInt32, + textType: TextType + ) -> MessageDTO { + let message = Message( + id: id, + radioID: radioID, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + directionRawValue: MessageDirection.outgoing.rawValue, + statusRawValue: MessageStatus.pending.rawValue, + textTypeRawValue: textType.rawValue + ) + return MessageDTO(from: message) + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageService+SendDM.swift b/MC1Services/Sources/MC1Services/Services/MessageService+SendDM.swift index fa7b8ada..0d61fe66 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageService+SendDM.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageService+SendDM.swift @@ -4,734 +4,733 @@ import MeshCore // MARK: - Send Direct Message extension MessageService { - - /// Sends a direct message to a contact with a single send attempt. - /// - /// This method sends a message once without automatic retry. Use this when you want - /// to manually control retry logic or when retry is not needed. - /// - /// - Parameters: - /// - text: The message text to send. Bounded by - /// `ProtocolLimits.maxDirectMessageLength` (150 UTF-8 bytes). - /// - contact: The recipient contact - /// - textType: The text encoding type (defaults to `.plain`) - /// - replyToID: Optional ID of message being replied to - /// - /// - Returns: The created message DTO with pending/sent status - /// - /// - Throws: - /// - `MessageServiceError.invalidRecipient` if contact is a repeater - /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxDirectMessageLength` - /// - `MessageServiceError.sessionError` if MeshCore send fails - /// - /// # Example - /// - /// ```swift - /// let message = try await messageService.sendDirectMessage( - /// text: "Hello!", - /// to: contact - /// ) - /// ``` - public func sendDirectMessage( - text: String, - to contact: ContactDTO, - textType: TextType = .plain, - replyToID: UUID? = nil - ) async throws -> MessageDTO { - try validateDirectMessage(text: text, to: contact) - - let messageID = UUID() - let timestamp = UInt32(Date().timeIntervalSince1970) - - let messageDTO = createOutgoingMessage( - id: messageID, - radioID: contact.radioID, - contactID: contact.id, + /// Sends a direct message to a contact with a single send attempt. + /// + /// This method sends a message once without automatic retry. Use this when you want + /// to manually control retry logic or when retry is not needed. + /// + /// - Parameters: + /// - text: The message text to send. Bounded by + /// `ProtocolLimits.maxDirectMessageLength` (150 UTF-8 bytes). + /// - contact: The recipient contact + /// - textType: The text encoding type (defaults to `.plain`) + /// - replyToID: Optional ID of message being replied to + /// + /// - Returns: The created message DTO with pending/sent status + /// + /// - Throws: + /// - `MessageServiceError.invalidRecipient` if contact is a repeater + /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxDirectMessageLength` + /// - `MessageServiceError.sessionError` if MeshCore send fails + /// + /// # Example + /// + /// ```swift + /// let message = try await messageService.sendDirectMessage( + /// text: "Hello!", + /// to: contact + /// ) + /// ``` + public func sendDirectMessage( + text: String, + to contact: ContactDTO, + textType: TextType = .plain, + replyToID: UUID? = nil + ) async throws -> MessageDTO { + try validateDirectMessage(text: text, to: contact) + + let messageID = UUID() + let timestamp = UInt32(Date().timeIntervalSince1970) + + let messageDTO = createOutgoingMessage( + id: messageID, + radioID: contact.radioID, + contactID: contact.id, + text: text, + timestamp: timestamp, + textType: textType, + replyToID: replyToID + ) + try await dataStore.saveMessage(messageDTO) + + let predictedAck: Data + let sentInfo: MessageSentInfo + do { + // Precompute the expected ACK before the send so the persistent + // listener cannot race trackPendingAck on short direct links (same + // pattern used by the retry loop). Reactions and quick replies + // flow through this path and otherwise lose ACKs on fast links. + guard let senderPublicKey = await session.currentSelfInfo?.publicKey else { + throw MessageServiceError.notConnected + } + predictedAck = AckCodeBuilder.expectedAck( + timestamp: timestamp, + attempt: 0, + text: text, + senderPublicKey: senderPublicKey + ) + // Pre-send floor must outlive one checkExpiredAcks tick, otherwise + // a slow BLE round-trip could let the checker expire the + // speculative entry before the authoritative timeout lands. + trackPendingAck( + messageID: messageID, + contactID: contact.id, + ackCode: predictedAck, + timeout: checkInterval + ) + + do { + sentInfo = try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.directMessageTableFull, config: config.poolBackoff, logger: logger) { + try await session.sendMessage( + to: contact.publicKey, text: text, - timestamp: timestamp, - textType: textType, - replyToID: replyToID - ) - try await dataStore.saveMessage(messageDTO) - - let predictedAck: Data - let sentInfo: MessageSentInfo - do { - // Precompute the expected ACK before the send so the persistent - // listener cannot race trackPendingAck on short direct links (same - // pattern used by the retry loop). Reactions and quick replies - // flow through this path and otherwise lose ACKs on fast links. - guard let senderPublicKey = await session.currentSelfInfo?.publicKey else { - throw MessageServiceError.notConnected - } - predictedAck = AckCodeBuilder.expectedAck( - timestamp: timestamp, - attempt: 0, - text: text, - senderPublicKey: senderPublicKey - ) - // Pre-send floor must outlive one checkExpiredAcks tick, otherwise - // a slow BLE round-trip could let the checker expire the - // speculative entry before the authoritative timeout lands. - trackPendingAck( - messageID: messageID, - contactID: contact.id, - ackCode: predictedAck, - timeout: checkInterval - ) - - do { - sentInfo = try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.directMessageTableFull, config: config.poolBackoff, logger: logger) { - try await session.sendMessage( - to: contact.publicKey, - text: text, - timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)) - ) - } - } catch { - pendingAcks.removeValue(forKey: messageID) - throw error - } - } catch { - statusEventBroadcaster.yield(.failed(messageID: messageID)) - try await failMessageAndRethrow(error, messageID: messageID) - } - - let ackTimeout = TimeInterval(sentInfo.suggestedTimeoutMs) / 1000.0 * 1.2 - - if sentInfo.expectedAck != predictedAck { - logger.warning( - "expectedAck mismatch for \(messageID) attempt 0: predicted \(predictedAck.uppercaseHexString()) vs firmware \(sentInfo.expectedAck.uppercaseHexString()); merging firmware code" - ) - trackPendingAck( - messageID: messageID, - contactID: contact.id, - ackCode: sentInfo.expectedAck, - timeout: ackTimeout - ) - } else { - pendingAcks[messageID]?.timeout = ackTimeout - pendingAcks[messageID]?.sentAt = Date() + timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)) + ) } - - // Post-send bookkeeping. The radio accepted the send, so a throw here - // must not mark `.failed` or drop the pendingAcks entry; the genuine - // end-to-end ACK or the expiry checker owns the terminal state now, - // mirroring the channel send path. - do { - // updateMessageAck preserves `.delivered`, so writing `.sent` here - // is a no-op when the listener already won the race. - try await dataStore.updateMessageAck( - id: messageID, - ackCode: sentInfo.expectedAck.ackCodeUInt32, - status: .sent - ) - try await dataStore.updateContactLastMessage(contactID: contact.id, date: Date()) - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) - } catch { - logger.warning("DM post-send bookkeeping failed messageID=\(messageID) send already accepted: \(String(describing: error))") - } - - guard let message = try await dataStore.fetchMessage(id: messageID) else { - throw MessageServiceError.sendFailed("Failed to fetch saved message") - } - return message + } catch { + pendingAcks.removeValue(forKey: messageID) + throw error + } + } catch { + statusEventBroadcaster.yield(.failed(messageID: messageID)) + try await failMessageAndRethrow(error, messageID: messageID) } - // MARK: - Send with Automatic Retry - - /// Sends a direct message with automatic retry and flood routing fallback. - /// - /// This is the recommended method for sending messages. It automatically: - /// 1. Attempts direct routing up to `maxAttempts` times - /// 2. Switches to flood routing after `floodAfter` attempts - /// 3. Makes up to `maxFloodAttempts` using flood routing - /// 4. Returns immediately when ACK is received - /// - /// The message is saved to the database immediately and the `onMessageCreated` - /// callback is invoked, allowing the UI to update before the send completes. - /// - /// - Parameters: - /// - text: The message text to send. Bounded by - /// `ProtocolLimits.maxDirectMessageLength` (150 UTF-8 bytes). - /// - contact: The recipient contact - /// - textType: The text encoding type (defaults to `.plain`) - /// - replyToID: Optional ID of message being replied to - /// - timeout: Custom timeout in seconds (0 = use device-suggested timeout) - /// - onMessageCreated: Callback invoked after message is saved to database - /// - /// - Returns: The message DTO with final delivery status (delivered or failed) - /// - /// - Throws: - /// - `MessageServiceError.invalidRecipient` if contact is a repeater - /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxDirectMessageLength` - /// - `MessageServiceError.sessionError` if MeshCore send fails - /// - /// # Example - /// - /// ```swift - /// let message = try await messageService.sendMessageWithRetry( - /// text: "Hello!", - /// to: contact - /// ) { savedMessage in - /// // Update UI immediately with pending message - /// await updateConversation(with: savedMessage) - /// } - /// // Message is now delivered or failed - /// ``` - public func sendMessageWithRetry( - text: String, - to contact: ContactDTO, - textType: TextType = .plain, - replyToID: UUID? = nil, - timeout: TimeInterval = 0, - onMessageCreated: (@Sendable (MessageDTO) async -> Void)? = nil - ) async throws -> MessageDTO { - try validateDirectMessage(text: text, to: contact) - - let messageID = UUID() - let timestamp = UInt32(Date().timeIntervalSince1970) - - // Save message to store as pending first - let messageDTO = createOutgoingMessage( - id: messageID, - radioID: contact.radioID, - contactID: contact.id, - text: text, - timestamp: timestamp, - textType: textType, - replyToID: replyToID - ) - try await dataStore.saveMessage(messageDTO) - - // Notify caller that message is saved - await onMessageCreated?(messageDTO) - - // Capture initial routing state to detect changes - let initialPathLength = contact.outPathLength - - // Run app-layer retry loop with UI notifications - do { - let sentInfo = try await sendDirectMessageWithRetryLoop( - messageID: messageID, - contactID: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - text: text, - timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)), - timestampRaw: timestamp, - timeout: timeout > 0 ? timeout : nil - ) - - return try await finalizeSend( - messageID: messageID, - contactID: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - sentInfo: sentInfo, - initialPathLength: initialPathLength - ) - } catch { - statusEventBroadcaster.yield(.failed(messageID: messageID)) - try await failMessageAndRethrow(error, messageID: messageID) - } + let ackTimeout = TimeInterval(sentInfo.suggestedTimeoutMs) / 1000.0 * 1.2 + + if sentInfo.expectedAck != predictedAck { + logger.warning( + "expectedAck mismatch for \(messageID) attempt 0: predicted \(predictedAck.uppercaseHexString()) vs firmware \(sentInfo.expectedAck.uppercaseHexString()); merging firmware code" + ) + trackPendingAck( + messageID: messageID, + contactID: contact.id, + ackCode: sentInfo.expectedAck, + timeout: ackTimeout + ) + } else { + pendingAcks[messageID]?.timeout = ackTimeout + pendingAcks[messageID]?.sentAt = Date() } - /// Creates a pending message without sending it. - /// - /// Use this when you want to show the message in the UI immediately - /// and drain it later via ``sendPendingDirectMessage(messageID:to:preserveTimestamp:)``. - /// - /// - Parameters: - /// - text: The message text - /// - contact: The recipient contact - /// - textType: The type of text content (default: .plain) - /// - replyToID: Optional ID of message being replied to - /// - /// - Returns: The created message DTO with pending status - public func createPendingMessage( - text: String, - to contact: ContactDTO, - textType: TextType = .plain, - replyToID: UUID? = nil - ) async throws -> MessageDTO { - try validateDirectMessage(text: text, to: contact) - - let messageID = UUID() - let timestamp = UInt32(Date().timeIntervalSince1970) - - let messageDTO = createOutgoingMessage( - id: messageID, - radioID: contact.radioID, - contactID: contact.id, - text: text, - timestamp: timestamp, - textType: textType, - replyToID: replyToID - ) - try await dataStore.saveMessage(messageDTO) - try await dataStore.updateContactLastMessage(contactID: contact.id, date: messageDTO.date) - - return messageDTO + // Post-send bookkeeping. The radio accepted the send, so a throw here + // must not mark `.failed` or drop the pendingAcks entry; the genuine + // end-to-end ACK or the expiry checker owns the terminal state now, + // mirroring the channel send path. + do { + // updateMessageAck preserves `.delivered`, so writing `.sent` here + // is a no-op when the listener already won the race. + try await dataStore.updateMessageAck( + id: messageID, + ackCode: sentInfo.expectedAck.ackCodeUInt32, + status: .sent + ) + try await dataStore.updateContactLastMessage(contactID: contact.id, date: Date()) + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) + } catch { + logger.warning("DM post-send bookkeeping failed messageID=\(messageID) send already accepted: \(String(describing: error))") } - /// Drains a `.pending` DM through the app-layer retry loop. Does not - /// bump `sendCount` or broadcast `.resent`; both bubble-affecting side - /// effects belong to - /// ``resendDirectMessage(messageID:to:preserveTimestamp:)``. - /// - /// - Parameter preserveTimestamp: True when a prior drain attempt may - /// already have placed the packet on the wire (queue auto-park-and- - /// retry), so the recipient's dedup ring catches the duplicate - /// rather than rendering it twice. False on the first drain, where - /// a fresh timestamp keeps mesh repeaters from filtering the packet. - public func sendPendingDirectMessage( - messageID: UUID, - to contact: ContactDTO, - preserveTimestamp: Bool = false - ) async throws -> MessageDTO { - try await performQueuedDirectMessageSend( - messageID: messageID, - to: contact, - preserveTimestamp: preserveTimestamp, - isResend: false - ) + guard let message = try await dataStore.fetchMessage(id: messageID) else { + throw MessageServiceError.sendFailed("Failed to fetch saved message") } - - /// Resends an already-sent DM. Re-runs the app-layer retry loop, - /// increments `sendCount`, and broadcasts `.resent` so the bubble - /// surfaces "Sent N times". - /// - /// - Parameter preserveTimestamp: True on queue auto-park-and-retry - /// of the resend; false on the first drain attempt so the wire - /// packet hashes distinctly from the original and clears repeater - /// dedup rings. - public func resendDirectMessage( - messageID: UUID, - to contact: ContactDTO, - preserveTimestamp: Bool = false - ) async throws -> MessageDTO { - try await performQueuedDirectMessageSend( - messageID: messageID, - to: contact, - preserveTimestamp: preserveTimestamp, - isResend: true - ) + return message + } + + // MARK: - Send with Automatic Retry + + /// Sends a direct message with automatic retry and flood routing fallback. + /// + /// This is the recommended method for sending messages. It automatically: + /// 1. Attempts direct routing up to `maxAttempts` times + /// 2. Switches to flood routing after `floodAfter` attempts + /// 3. Makes up to `maxFloodAttempts` using flood routing + /// 4. Returns immediately when ACK is received + /// + /// The message is saved to the database immediately and the `onMessageCreated` + /// callback is invoked, allowing the UI to update before the send completes. + /// + /// - Parameters: + /// - text: The message text to send. Bounded by + /// `ProtocolLimits.maxDirectMessageLength` (150 UTF-8 bytes). + /// - contact: The recipient contact + /// - textType: The text encoding type (defaults to `.plain`) + /// - replyToID: Optional ID of message being replied to + /// - timeout: Custom timeout in seconds (0 = use device-suggested timeout) + /// - onMessageCreated: Callback invoked after message is saved to database + /// + /// - Returns: The message DTO with final delivery status (delivered or failed) + /// + /// - Throws: + /// - `MessageServiceError.invalidRecipient` if contact is a repeater + /// - `MessageServiceError.messageTooLong` if text exceeds `ProtocolLimits.maxDirectMessageLength` + /// - `MessageServiceError.sessionError` if MeshCore send fails + /// + /// # Example + /// + /// ```swift + /// let message = try await messageService.sendMessageWithRetry( + /// text: "Hello!", + /// to: contact + /// ) { savedMessage in + /// // Update UI immediately with pending message + /// await updateConversation(with: savedMessage) + /// } + /// // Message is now delivered or failed + /// ``` + public func sendMessageWithRetry( + text: String, + to contact: ContactDTO, + textType: TextType = .plain, + replyToID: UUID? = nil, + timeout: TimeInterval = 0, + onMessageCreated: (@Sendable (MessageDTO) async -> Void)? = nil + ) async throws -> MessageDTO { + try validateDirectMessage(text: text, to: contact) + + let messageID = UUID() + let timestamp = UInt32(Date().timeIntervalSince1970) + + // Save message to store as pending first + let messageDTO = createOutgoingMessage( + id: messageID, + radioID: contact.radioID, + contactID: contact.id, + text: text, + timestamp: timestamp, + textType: textType, + replyToID: replyToID + ) + try await dataStore.saveMessage(messageDTO) + + // Notify caller that message is saved + await onMessageCreated?(messageDTO) + + // Capture initial routing state to detect changes + let initialPathLength = contact.outPathLength + + // Run app-layer retry loop with UI notifications + do { + let sentInfo = try await sendDirectMessageWithRetryLoop( + messageID: messageID, + contactID: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + text: text, + timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)), + timestampRaw: timestamp, + timeout: timeout > 0 ? timeout : nil + ) + + return try await finalizeSend( + messageID: messageID, + contactID: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + sentInfo: sentInfo, + initialPathLength: initialPathLength + ) + } catch { + statusEventBroadcaster.yield(.failed(messageID: messageID)) + try await failMessageAndRethrow(error, messageID: messageID) + } + } + + /// Creates a pending message without sending it. + /// + /// Use this when you want to show the message in the UI immediately + /// and drain it later via ``sendPendingDirectMessage(messageID:to:preserveTimestamp:)``. + /// + /// - Parameters: + /// - text: The message text + /// - contact: The recipient contact + /// - textType: The type of text content (default: .plain) + /// - replyToID: Optional ID of message being replied to + /// + /// - Returns: The created message DTO with pending status + public func createPendingMessage( + text: String, + to contact: ContactDTO, + textType: TextType = .plain, + replyToID: UUID? = nil + ) async throws -> MessageDTO { + try validateDirectMessage(text: text, to: contact) + + let messageID = UUID() + let timestamp = UInt32(Date().timeIntervalSince1970) + + let messageDTO = createOutgoingMessage( + id: messageID, + radioID: contact.radioID, + contactID: contact.id, + text: text, + timestamp: timestamp, + textType: textType, + replyToID: replyToID + ) + try await dataStore.saveMessage(messageDTO) + try await dataStore.updateContactLastMessage(contactID: contact.id, date: messageDTO.date) + + return messageDTO + } + + /// Drains a `.pending` DM through the app-layer retry loop. Does not + /// bump `sendCount` or broadcast `.resent`; both bubble-affecting side + /// effects belong to + /// ``resendDirectMessage(messageID:to:preserveTimestamp:)``. + /// + /// - Parameter preserveTimestamp: True when a prior drain attempt may + /// already have placed the packet on the wire (queue auto-park-and- + /// retry), so the recipient's dedup ring catches the duplicate + /// rather than rendering it twice. False on the first drain, where + /// a fresh timestamp keeps mesh repeaters from filtering the packet. + public func sendPendingDirectMessage( + messageID: UUID, + to contact: ContactDTO, + preserveTimestamp: Bool = false + ) async throws -> MessageDTO { + try await performQueuedDirectMessageSend( + messageID: messageID, + to: contact, + preserveTimestamp: preserveTimestamp, + isResend: false + ) + } + + /// Resends an already-sent DM. Re-runs the app-layer retry loop, + /// increments `sendCount`, and broadcasts `.resent` so the bubble + /// surfaces "Sent N times". + /// + /// - Parameter preserveTimestamp: True on queue auto-park-and-retry + /// of the resend; false on the first drain attempt so the wire + /// packet hashes distinctly from the original and clears repeater + /// dedup rings. + public func resendDirectMessage( + messageID: UUID, + to contact: ContactDTO, + preserveTimestamp: Bool = false + ) async throws -> MessageDTO { + try await performQueuedDirectMessageSend( + messageID: messageID, + to: contact, + preserveTimestamp: preserveTimestamp, + isResend: true + ) + } + + private func performQueuedDirectMessageSend( + messageID: UUID, + to contact: ContactDTO, + preserveTimestamp: Bool, + isResend: Bool + ) async throws -> MessageDTO { + guard !inFlightRetries.contains(messageID) else { + logger.warning("Send already in progress for message: \(messageID)") + throw MessageServiceError.sendFailed("Retry already in progress") } - private func performQueuedDirectMessageSend( - messageID: UUID, - to contact: ContactDTO, - preserveTimestamp: Bool, - isResend: Bool - ) async throws -> MessageDTO { - guard !inFlightRetries.contains(messageID) else { - logger.warning("Send already in progress for message: \(messageID)") - throw MessageServiceError.sendFailed("Retry already in progress") - } - - inFlightRetries.insert(messageID) - defer { inFlightRetries.remove(messageID) } - - let initialPathLength = contact.outPathLength - - guard let existingMessage = try await dataStore.fetchMessage(id: messageID) else { - throw MessageServiceError.sendFailed("Message not found") - } + inFlightRetries.insert(messageID) + defer { inFlightRetries.remove(messageID) } - // Fresh timestamp on first drain — mesh repeaters deduplicate by - // packet content, so reusing the original would be dropped. Queue - // auto-retry passes preserveTimestamp: true so the duplicate landing - // on a recipient that already saw the first send gets caught by - // their dedup ring rather than rendered as a second copy. - let wireTimestamp: Date - let wireTimestampRaw: UInt32 - if preserveTimestamp { - wireTimestampRaw = existingMessage.timestamp - wireTimestamp = Date(timeIntervalSince1970: TimeInterval(wireTimestampRaw)) - } else { - wireTimestamp = Date() - wireTimestampRaw = UInt32(wireTimestamp.timeIntervalSince1970) - try await dataStore.updateMessageTimestamp(id: messageID, timestamp: wireTimestampRaw) - } + let initialPathLength = contact.outPathLength - do { - let sentInfo = try await sendDirectMessageWithRetryLoop( - messageID: messageID, - contactID: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - text: existingMessage.text, - timestamp: wireTimestamp, - timestampRaw: wireTimestampRaw, - timeout: nil - ) - - // Bump only on a confirmed successful resend so sendCount counts - // landed user-initiated sends, not queue-driven attempts; - // bookkeeping failures log because the on-air retransmission - // already happened. - if isResend, sentInfo != nil { - do { - _ = try await dataStore.incrementMessageSendCount(id: messageID) - } catch { - logger.warning("DM resend sendCount bookkeeping failed messageID=\(messageID): \(String(describing: error))") - } - } - - let message = try await finalizeSend( - messageID: messageID, - contactID: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - sentInfo: sentInfo, - initialPathLength: initialPathLength - ) - - // Broadcast after the DB write so the downstream refetch sees both - // the bumped sendCount and the committed terminal status. - if isResend, sentInfo != nil { - statusEventBroadcaster.yield(.resent(messageID: messageID)) - } - - return message - } catch { - try await failMessageAndRethrow(error, messageID: messageID) - } + guard let existingMessage = try await dataStore.fetchMessage(id: messageID) else { + throw MessageServiceError.sendFailed("Message not found") } - // MARK: - Direct Message Retry Loop - - /// Sends a direct message with app-layer retry logic and UI notifications. - /// - /// This function manages the retry loop at the app layer (instead of delegating to MeshCore) - /// to provide per-attempt UI feedback. On each attempt, it: - /// - Updates the message status in the database - /// - Broadcasts `.retrying` for UI retry progress - /// - Switches to flood routing after `floodAfter` failed attempts - /// - Broadcasts `.routingChanged` when routing switches - /// - /// - Parameters: - /// - messageID: The message ID for status updates - /// - contactID: The contact ID for routing change notifications - /// - radioID: The device ID for saving contact updates - /// - publicKey: The full 32-byte destination public key - /// - text: The message text - /// - timestamp: The message timestamp (must remain constant across retries) - /// - timestampRaw: The same timestamp as a `UInt32` epoch-seconds value. The - /// caller must pass the same integer it used to build `timestamp: Date` — - /// resampling inside the loop would break expected-ACK precomputation, - /// since the firmware hash is keyed off the exact `UInt32` that ends up - /// on the wire. - /// - timeout: Optional custom timeout per attempt (nil = use device suggested) - /// - /// - Returns: `MessageSentInfo` if ACK received, `nil` if all attempts exhausted - /// - Throws: `MeshCoreError` if send fails with unrecoverable error - private func sendDirectMessageWithRetryLoop( - messageID: UUID, - contactID: UUID, - radioID: UUID, - publicKey: Data, - text: String, - timestamp: Date, - timestampRaw: UInt32, - timeout: TimeInterval? - ) async throws -> MessageSentInfo? { - var attempts = 0 - var floodAttempts = 0 - var isFloodMode = false - - while attempts < config.maxAttempts && (!isFloodMode || floodAttempts < config.maxFloodAttempts) { - // Check for task cancellation - guard !Task.isCancelled else { - throw CancellationError() - } - - // Update database and notify UI of retry status (only after first attempt fails) - if attempts > 0 { - try await dataStore.updateMessageRetryStatus( - id: messageID, - status: .retrying, - retryAttempt: attempts - 1, - maxRetryAttempts: config.maxAttempts - 1 - ) - statusEventBroadcaster.yield(.retrying(messageID: messageID, attempt: attempts - 1, maxAttempts: config.maxAttempts - 1)) - } - - // Switch to flood routing after floodAfter direct attempts - if attempts == config.floodAfter && !isFloodMode { - logger.info("Resetting path to flood after \(attempts) failed attempts") - do { - try await session.resetPath(publicKey: publicKey) - isFloodMode = true - - // Notify UI of routing change and save updated contact - if let updatedContact = try await session.getContact(publicKey: publicKey) { - _ = try await dataStore.saveContact(radioID: radioID, from: updatedContact.toContactFrame()) - } - statusEventBroadcaster.yield(.routingChanged(contactID: contactID, isFlood: true)) - } catch { - logger.warning("Failed to reset path: \(error.localizedDescription), continuing...") - // Continue anyway - device might handle it - isFloodMode = true - } - } - - if attempts > 0 { - logger.info("Retry sending message: attempt \(attempts + 1)/\(config.maxAttempts)") - } - - // Precompute the expected ACK CRC before the send so the persistent - // ACK listener cannot race ahead of trackPendingAck on short direct links. - guard let senderPublicKey = await session.currentSelfInfo?.publicKey else { - throw MessageServiceError.notConnected - } - - let predictedAck = AckCodeBuilder.expectedAck( - timestamp: timestampRaw, - attempt: UInt8(attempts), - text: text, - senderPublicKey: senderPublicKey - ) - - // Pre-send floor must outlive one checkExpiredAcks tick; otherwise a - // BLE round-trip > 1s could let the checker expire the speculative - // entry before sendMessage returns and we overwrite the timeout with - // the authoritative sentInfo-derived value. - let preSendTimeout = max(timeout ?? config.minTimeout, checkInterval) - trackPendingAck( - messageID: messageID, - contactID: contactID, - ackCode: predictedAck, - timeout: preSendTimeout - ) - - let sentInfo: MessageSentInfo - do { - sentInfo = try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.directMessageTableFull, config: config.poolBackoff, logger: logger) { - try await session.sendMessage( - to: publicKey.prefix(6), - text: text, - timestamp: timestamp, - attempt: UInt8(attempts) - ) - } - } catch { - // Drop the whole speculative entry on send failure. A partial - // remove would leave an empty-codes PendingAck visible to - // checkExpiredAcks if failMessageAndRethrow is ever bypassed. - pendingAcks.removeValue(forKey: messageID) - throw error - } - - // If the persistent ACK listener consumed our predicted ACK during - // sendMessage's cross-actor suspension, the entry is already - // removed or marked delivered. Short-circuit before waitForEvent - // so the retry loop doesn't clobber .delivered via - // updateMessageRetryStatus and broadcast a duplicate DM. - guard let tracked = pendingAcks[messageID], !tracked.isDelivered else { - return sentInfo - } - - let ackTimeout = timeout ?? max( - config.minTimeout, - Double(sentInfo.suggestedTimeoutMs) / 1000.0 * 1.2 - ) - - if sentInfo.expectedAck != predictedAck { - logger.warning( - "expectedAck mismatch for \(messageID) attempt \(attempts): predicted \(predictedAck.uppercaseHexString()) vs firmware \(sentInfo.expectedAck.uppercaseHexString()); merging firmware code" - ) - trackPendingAck( - messageID: messageID, - contactID: contactID, - ackCode: sentInfo.expectedAck, - timeout: ackTimeout - ) - } else { - // Re-stamp sentAt so checkExpiredAcks measures timeout from - // send-return, not from the speculative insert. - pendingAcks[messageID]?.timeout = ackTimeout - pendingAcks[messageID]?.sentAt = Date() - } - - let ackEvent = await session.waitForEvent( - filter: .acknowledgement(code: sentInfo.expectedAck), - timeout: ackTimeout - ) - - if ackEvent != nil { - logger.info("Message acknowledged on attempt \(attempts + 1)") - return sentInfo - } - - // Listener may have consumed the ACK between the pre-send guard - // and waitForEvent's subscription becoming active; re-check before - // retrying so we don't resend a DM the firmware already delivered. - if pendingAcks[messageID]?.isDelivered != false { - return sentInfo - } - - // ACK timeout - increment counters and retry - attempts += 1 - if isFloodMode { - floodAttempts += 1 - } - } - - logger.warning("Message delivery failed after \(attempts) attempts") - return nil + // Fresh timestamp on first drain — mesh repeaters deduplicate by + // packet content, so reusing the original would be dropped. Queue + // auto-retry passes preserveTimestamp: true so the duplicate landing + // on a recipient that already saw the first send gets caught by + // their dedup ring rather than rendered as a second copy. + let wireTimestamp: Date + let wireTimestampRaw: UInt32 + if preserveTimestamp { + wireTimestampRaw = existingMessage.timestamp + wireTimestamp = Date(timeIntervalSince1970: TimeInterval(wireTimestampRaw)) + } else { + wireTimestamp = Date() + wireTimestampRaw = UInt32(wireTimestamp.timeIntervalSince1970) + try await dataStore.updateMessageTimestamp(id: messageID, timestamp: wireTimestampRaw) } - // MARK: - Routing Change Detection - - /// Checks if contact routing changed and broadcasts `.routingChanged` if so. - /// - /// Called after sendMessageWithRetry to detect if routing switched - /// between direct and flood modes during the retry process. - private func checkAndNotifyRoutingChange( - publicKey: Data, - contactID: UUID, - radioID: UUID, - initialPathLength: UInt8 - ) async { + do { + let sentInfo = try await sendDirectMessageWithRetryLoop( + messageID: messageID, + contactID: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + text: existingMessage.text, + timestamp: wireTimestamp, + timestampRaw: wireTimestampRaw, + timeout: nil + ) + + // Bump only on a confirmed successful resend so sendCount counts + // landed user-initiated sends, not queue-driven attempts; + // bookkeeping failures log because the on-air retransmission + // already happened. + if isResend, sentInfo != nil { do { - // Fetch fresh contact state from device - guard let updatedContact = try await session.getContact(publicKey: publicKey) else { - logger.info("Contact not found in device contacts after retry") - return - } - - // Check if routing changed - let newPathLength = updatedContact.outPathLength - if newPathLength != initialPathLength { - logger.info("Routing changed for contact \(contactID): \(initialPathLength) -> \(newPathLength)") - - // Save updated contact to database - _ = try await dataStore.saveContact(radioID: radioID, from: updatedContact.toContactFrame()) - - // Notify UI of routing change - let isNowFlood = newPathLength == PacketBuilder.floodPathSentinel - statusEventBroadcaster.yield(.routingChanged(contactID: contactID, isFlood: isNowFlood)) - } + _ = try await dataStore.incrementMessageSendCount(id: messageID) } catch { - logger.warning("Failed to check routing change: \(error.localizedDescription)") + logger.warning("DM resend sendCount bookkeeping failed messageID=\(messageID): \(String(describing: error))") } + } + + let message = try await finalizeSend( + messageID: messageID, + contactID: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + sentInfo: sentInfo, + initialPathLength: initialPathLength + ) + + // Broadcast after the DB write so the downstream refetch sees both + // the bumped sendCount and the committed terminal status. + if isResend, sentInfo != nil { + statusEventBroadcaster.yield(.resent(messageID: messageID)) + } + + return message + } catch { + try await failMessageAndRethrow(error, messageID: messageID) } + } + + // MARK: - Direct Message Retry Loop + + /// Sends a direct message with app-layer retry logic and UI notifications. + /// + /// This function manages the retry loop at the app layer (instead of delegating to MeshCore) + /// to provide per-attempt UI feedback. On each attempt, it: + /// - Updates the message status in the database + /// - Broadcasts `.retrying` for UI retry progress + /// - Switches to flood routing after `floodAfter` failed attempts + /// - Broadcasts `.routingChanged` when routing switches + /// + /// - Parameters: + /// - messageID: The message ID for status updates + /// - contactID: The contact ID for routing change notifications + /// - radioID: The device ID for saving contact updates + /// - publicKey: The full 32-byte destination public key + /// - text: The message text + /// - timestamp: The message timestamp (must remain constant across retries) + /// - timestampRaw: The same timestamp as a `UInt32` epoch-seconds value. The + /// caller must pass the same integer it used to build `timestamp: Date` — + /// resampling inside the loop would break expected-ACK precomputation, + /// since the firmware hash is keyed off the exact `UInt32` that ends up + /// on the wire. + /// - timeout: Optional custom timeout per attempt (nil = use device suggested) + /// + /// - Returns: `MessageSentInfo` if ACK received, `nil` if all attempts exhausted + /// - Throws: `MeshCoreError` if send fails with unrecoverable error + private func sendDirectMessageWithRetryLoop( + messageID: UUID, + contactID: UUID, + radioID: UUID, + publicKey: Data, + text: String, + timestamp: Date, + timestampRaw: UInt32, + timeout: TimeInterval? + ) async throws -> MessageSentInfo? { + var attempts = 0 + var floodAttempts = 0 + var isFloodMode = false + + while attempts < config.maxAttempts, !isFloodMode || floodAttempts < config.maxFloodAttempts { + // Check for task cancellation + guard !Task.isCancelled else { + throw CancellationError() + } + + // Update database and notify UI of retry status (only after first attempt fails) + if attempts > 0 { + try await dataStore.updateMessageRetryStatus( + id: messageID, + status: .retrying, + retryAttempt: attempts - 1, + maxRetryAttempts: config.maxAttempts - 1 + ) + statusEventBroadcaster.yield(.retrying(messageID: messageID, attempt: attempts - 1, maxAttempts: config.maxAttempts - 1)) + } - // MARK: - Private Helpers - - /// Records a new expected-ACK code for a message awaiting delivery. - /// - /// On the first call for a given `messageID` this creates the entry; on - /// subsequent retry attempts it adds the new `ackCode` to the same entry's - /// `ackCodes` set and resets both `sentAt` and the timeout window to the - /// latest attempt so `checkExpiredAcks` does not fail the in-flight retry. - func trackPendingAck(messageID: UUID, contactID: UUID, ackCode: Data, timeout: TimeInterval) { - // Diagnostic: the firmware ACK CRC is derived from (timestamp, attempt, - // text, sender key) with no recipient, so identical text sent in the - // same second to different contacts produces the same code. Counting - // collisions across distinct in-flight messages measures how often a - // delivery confirmation could be attributed to the wrong message. - if let colliding = pendingAcks.first(where: { $0.key != messageID && $0.value.ackCodes.contains(ackCode) }) { - logger.warning("[ack-diag] ackCode collision: code=\(ackCode.uppercaseHexString()) shared by messages \(colliding.key) and \(messageID)") + // Switch to flood routing after floodAfter direct attempts + if attempts == config.floodAfter, !isFloodMode { + logger.info("Resetting path to flood after \(attempts) failed attempts") + do { + try await session.resetPath(publicKey: publicKey) + isFloodMode = true + + // Notify UI of routing change and save updated contact + if let updatedContact = try await session.getContact(publicKey: publicKey) { + _ = try await dataStore.saveContact(radioID: radioID, from: updatedContact.toContactFrame()) + } + statusEventBroadcaster.yield(.routingChanged(contactID: contactID, isFlood: true)) + } catch { + logger.warning("Failed to reset path: \(error.localizedDescription), continuing...") + // Continue anyway - device might handle it + isFloodMode = true } - if var existing = pendingAcks[messageID] { - existing.ackCodes.insert(ackCode) - existing.sentAt = Date() - existing.timeout = timeout - pendingAcks[messageID] = existing - } else { - pendingAcks[messageID] = PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: [ackCode], - sentAt: Date(), - timeout: timeout - ) + } + + if attempts > 0 { + logger.info("Retry sending message: attempt \(attempts + 1)/\(config.maxAttempts)") + } + + // Precompute the expected ACK CRC before the send so the persistent + // ACK listener cannot race ahead of trackPendingAck on short direct links. + guard let senderPublicKey = await session.currentSelfInfo?.publicKey else { + throw MessageServiceError.notConnected + } + + let predictedAck = AckCodeBuilder.expectedAck( + timestamp: timestampRaw, + attempt: UInt8(attempts), + text: text, + senderPublicKey: senderPublicKey + ) + + // Pre-send floor must outlive one checkExpiredAcks tick; otherwise a + // BLE round-trip > 1s could let the checker expire the speculative + // entry before sendMessage returns and we overwrite the timeout with + // the authoritative sentInfo-derived value. + let preSendTimeout = max(timeout ?? config.minTimeout, checkInterval) + trackPendingAck( + messageID: messageID, + contactID: contactID, + ackCode: predictedAck, + timeout: preSendTimeout + ) + + let sentInfo: MessageSentInfo + do { + sentInfo = try await withPoolBackoff(transientCode: FirmwareDeviceErrorCode.directMessageTableFull, config: config.poolBackoff, logger: logger) { + try await session.sendMessage( + to: publicKey.prefix(6), + text: text, + timestamp: timestamp, + attempt: UInt8(attempts) + ) } + } catch { + // Drop the whole speculative entry on send failure. A partial + // remove would leave an empty-codes PendingAck visible to + // checkExpiredAcks if failMessageAndRethrow is ever bypassed. + pendingAcks.removeValue(forKey: messageID) + throw error + } + + // If the persistent ACK listener consumed our predicted ACK during + // sendMessage's cross-actor suspension, the entry is already + // removed or marked delivered. Short-circuit before waitForEvent + // so the retry loop doesn't clobber .delivered via + // updateMessageRetryStatus and broadcast a duplicate DM. + guard let tracked = pendingAcks[messageID], !tracked.isDelivered else { + return sentInfo + } + + let ackTimeout = timeout ?? max( + config.minTimeout, + Double(sentInfo.suggestedTimeoutMs) / 1000.0 * 1.2 + ) + + if sentInfo.expectedAck != predictedAck { + logger.warning( + "expectedAck mismatch for \(messageID) attempt \(attempts): predicted \(predictedAck.uppercaseHexString()) vs firmware \(sentInfo.expectedAck.uppercaseHexString()); merging firmware code" + ) + trackPendingAck( + messageID: messageID, + contactID: contactID, + ackCode: sentInfo.expectedAck, + timeout: ackTimeout + ) + } else { + // Re-stamp sentAt so checkExpiredAcks measures timeout from + // send-return, not from the speculative insert. + pendingAcks[messageID]?.timeout = ackTimeout + pendingAcks[messageID]?.sentAt = Date() + } + + let ackEvent = await session.waitForEvent( + filter: .acknowledgement(code: sentInfo.expectedAck), + timeout: ackTimeout + ) + + if ackEvent != nil { + logger.info("Message acknowledged on attempt \(attempts + 1)") + return sentInfo + } + + // Listener may have consumed the ACK between the pre-send guard + // and waitForEvent's subscription becoming active; re-check before + // retrying so we don't resend a DM the firmware already delivered. + if pendingAcks[messageID]?.isDelivered != false { + return sentInfo + } + + // ACK timeout - increment counters and retry + attempts += 1 + if isFloodMode { + floodAttempts += 1 + } } - private func validateDirectMessage(text: String, to contact: ContactDTO) throws { - guard contact.type != .repeater else { throw MessageServiceError.invalidRecipient } - guard text.utf8.count <= ProtocolLimits.maxDirectMessageLength else { throw MessageServiceError.messageTooLong } + logger.warning("Message delivery failed after \(attempts) attempts") + return nil + } + + // MARK: - Routing Change Detection + + /// Checks if contact routing changed and broadcasts `.routingChanged` if so. + /// + /// Called after sendMessageWithRetry to detect if routing switched + /// between direct and flood modes during the retry process. + private func checkAndNotifyRoutingChange( + publicKey: Data, + contactID: UUID, + radioID: UUID, + initialPathLength: UInt8 + ) async { + do { + // Fetch fresh contact state from device + guard let updatedContact = try await session.getContact(publicKey: publicKey) else { + logger.info("Contact not found in device contacts after retry") + return + } + + // Check if routing changed + let newPathLength = updatedContact.outPathLength + if newPathLength != initialPathLength { + logger.info("Routing changed for contact \(contactID): \(initialPathLength) -> \(newPathLength)") + + // Save updated contact to database + _ = try await dataStore.saveContact(radioID: radioID, from: updatedContact.toContactFrame()) + + // Notify UI of routing change + let isNowFlood = newPathLength == PacketBuilder.floodPathSentinel + statusEventBroadcaster.yield(.routingChanged(contactID: contactID, isFlood: isNowFlood)) + } + } catch { + logger.warning("Failed to check routing change: \(error.localizedDescription)") } - - func finalizeSend( - messageID: UUID, - contactID: UUID, - radioID: UUID, - publicKey: Data, - sentInfo: MessageSentInfo?, - initialPathLength: UInt8 - ) async throws -> MessageDTO { - // Peek the pendingAcks entry without taking ownership. A missing entry - // means `handleAcknowledgement` already processed the ACK (it removes - // the entry on delivery) or `checkExpiredAcks` already failed and - // removed it; an `isDelivered == true` entry means the listener marked - // it mid-flight. In those cases the owner holds the terminal write - // (including `roundTripTime`) and we drop any residual entry and skip - // the DB update here. - let tracking = pendingAcks[messageID] - - if tracking == nil || tracking?.isDelivered == true { - pendingAcks.removeValue(forKey: messageID) - } else if let sentInfo { - // The retry loop's waitForEvent matched the ACK. Take ownership and - // record the legitimate `.sent` -> `.delivered` upgrade. - pendingAcks.removeValue(forKey: messageID) - try await dataStore.updateMessageAck( - id: messageID, - ackCode: sentInfo.expectedAck.ackCodeUInt32, - status: .delivered - ) - try await dataStore.updateContactLastMessage(contactID: contactID, date: Date()) - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .delivered, roundTripTime: nil)) - } else { - // Retry budget spent without an ACK. Neither fail the row nor remove - // the entry: `checkExpiredAcks` owns the give-up at - // max(ackGiveUpWindow, last attempt's ACK timeout), so a - // late-but-legitimate ACK can still upgrade the row. - // `clearRetryingToSent` rolls any `.retrying` status back to `.sent` - // and is terminal-safe: it no-ops if the checker already failed the - // row in the loop's await-gap, so the row stays `.failed` there. - let movedToSent = try await dataStore.clearRetryingToSent(id: messageID) - if movedToSent { - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) - } - } - await checkAndNotifyRoutingChange( - publicKey: publicKey, - contactID: contactID, - radioID: radioID, - initialPathLength: initialPathLength - ) - guard let message = try await dataStore.fetchMessage(id: messageID) else { - throw MessageServiceError.sendFailed("Failed to fetch message") - } - return message + } + + // MARK: - Private Helpers + + /// Records a new expected-ACK code for a message awaiting delivery. + /// + /// On the first call for a given `messageID` this creates the entry; on + /// subsequent retry attempts it adds the new `ackCode` to the same entry's + /// `ackCodes` set and resets both `sentAt` and the timeout window to the + /// latest attempt so `checkExpiredAcks` does not fail the in-flight retry. + func trackPendingAck(messageID: UUID, contactID: UUID, ackCode: Data, timeout: TimeInterval) { + // Diagnostic: the firmware ACK CRC is derived from (timestamp, attempt, + // text, sender key) with no recipient, so identical text sent in the + // same second to different contacts produces the same code. Counting + // collisions across distinct in-flight messages measures how often a + // delivery confirmation could be attributed to the wrong message. + if let colliding = pendingAcks.first(where: { $0.key != messageID && $0.value.ackCodes.contains(ackCode) }) { + logger.warning("[ack-diag] ackCode collision: code=\(ackCode.uppercaseHexString()) shared by messages \(colliding.key) and \(messageID)") } - - private func createOutgoingMessage( - id: UUID, - radioID: UUID, - contactID: UUID, - text: String, - timestamp: UInt32, - textType: TextType, - replyToID: UUID? - ) -> MessageDTO { - let message = Message( - id: id, - radioID: radioID, - contactID: contactID, - text: text, - timestamp: timestamp, - directionRawValue: MessageDirection.outgoing.rawValue, - statusRawValue: MessageStatus.pending.rawValue, - textTypeRawValue: textType.rawValue, - replyToID: replyToID - ) - return MessageDTO(from: message) + if var existing = pendingAcks[messageID] { + existing.ackCodes.insert(ackCode) + existing.sentAt = Date() + existing.timeout = timeout + pendingAcks[messageID] = existing + } else { + pendingAcks[messageID] = PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: [ackCode], + sentAt: Date(), + timeout: timeout + ) + } + } + + private func validateDirectMessage(text: String, to contact: ContactDTO) throws { + guard contact.type != .repeater else { throw MessageServiceError.invalidRecipient } + guard text.utf8.count <= ProtocolLimits.maxDirectMessageLength else { throw MessageServiceError.messageTooLong } + } + + func finalizeSend( + messageID: UUID, + contactID: UUID, + radioID: UUID, + publicKey: Data, + sentInfo: MessageSentInfo?, + initialPathLength: UInt8 + ) async throws -> MessageDTO { + // Peek the pendingAcks entry without taking ownership. A missing entry + // means `handleAcknowledgement` already processed the ACK (it removes + // the entry on delivery) or `checkExpiredAcks` already failed and + // removed it; an `isDelivered == true` entry means the listener marked + // it mid-flight. In those cases the owner holds the terminal write + // (including `roundTripTime`) and we drop any residual entry and skip + // the DB update here. + let tracking = pendingAcks[messageID] + + if tracking == nil || tracking?.isDelivered == true { + pendingAcks.removeValue(forKey: messageID) + } else if let sentInfo { + // The retry loop's waitForEvent matched the ACK. Take ownership and + // record the legitimate `.sent` -> `.delivered` upgrade. + pendingAcks.removeValue(forKey: messageID) + try await dataStore.updateMessageAck( + id: messageID, + ackCode: sentInfo.expectedAck.ackCodeUInt32, + status: .delivered + ) + try await dataStore.updateContactLastMessage(contactID: contactID, date: Date()) + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .delivered, roundTripTime: nil)) + } else { + // Retry budget spent without an ACK. Neither fail the row nor remove + // the entry: `checkExpiredAcks` owns the give-up at + // max(ackGiveUpWindow, last attempt's ACK timeout), so a + // late-but-legitimate ACK can still upgrade the row. + // `clearRetryingToSent` rolls any `.retrying` status back to `.sent` + // and is terminal-safe: it no-ops if the checker already failed the + // row in the loop's await-gap, so the row stays `.failed` there. + let movedToSent = try await dataStore.clearRetryingToSent(id: messageID) + if movedToSent { + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .sent, roundTripTime: nil)) + } + } + await checkAndNotifyRoutingChange( + publicKey: publicKey, + contactID: contactID, + radioID: radioID, + initialPathLength: initialPathLength + ) + guard let message = try await dataStore.fetchMessage(id: messageID) else { + throw MessageServiceError.sendFailed("Failed to fetch message") } + return message + } + + private func createOutgoingMessage( + id: UUID, + radioID: UUID, + contactID: UUID, + text: String, + timestamp: UInt32, + textType: TextType, + replyToID: UUID? + ) -> MessageDTO { + let message = Message( + id: id, + radioID: radioID, + contactID: contactID, + text: text, + timestamp: timestamp, + directionRawValue: MessageDirection.outgoing.rawValue, + statusRawValue: MessageStatus.pending.rawValue, + textTypeRawValue: textType.rawValue, + replyToID: replyToID + ) + return MessageDTO(from: message) + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageService+SendHelpers.swift b/MC1Services/Sources/MC1Services/Services/MessageService+SendHelpers.swift index 5431d1e0..6a328b7e 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageService+SendHelpers.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageService+SendHelpers.swift @@ -4,9 +4,9 @@ import MeshCore private let millisecondsPerSecond: Int = 1000 private func poolBackoffDelay(forInLoopAttempt attempt: Int, config: PoolBackoffConfig) -> Duration { - let base = config.baseDelay * pow(config.exponentBase, Double(attempt)) - let jittered = base * Double.random(in: config.jitterRange) - return .milliseconds(Int(jittered * Double(millisecondsPerSecond))) + let base = config.baseDelay * pow(config.exponentBase, Double(attempt)) + let jittered = base * Double.random(in: config.jitterRange) + return .milliseconds(Int(jittered * Double(millisecondsPerSecond))) } /// In-loop absorption of short pool-exhaustion bursts. The firmware briefly @@ -14,46 +14,45 @@ private func poolBackoffDelay(forInLoopAttempt attempt: Int, config: PoolBackoff /// helper sleeps according to `config` between attempts (default 500ms / 1s / /// 2s with +/- 20% jitter) and re-throws once `config.attemptCap` is reached. func withPoolBackoff( - transientCode: UInt8, - config: PoolBackoffConfig, - logger: PersistentLogger, - operation: sending () async throws -> T + transientCode: UInt8, + config: PoolBackoffConfig, + logger: PersistentLogger, + operation: sending () async throws -> T ) async throws -> sending T { - var attempt = 0 - while true { - do { - return try await operation() - } catch let error as MeshCoreError { - guard case .deviceError(let code) = error, code == transientCode else { - throw error - } - guard attempt < config.attemptCap else { - logger.warning("Pool backoff exhausted after \(attempt) attempts; re-throwing transient deviceError(\(code))") - throw error - } - try await Task.sleep(for: poolBackoffDelay(forInLoopAttempt: attempt, config: config)) - attempt += 1 - } + var attempt = 0 + while true { + do { + return try await operation() + } catch let error as MeshCoreError { + guard case let .deviceError(code) = error, code == transientCode else { + throw error + } + guard attempt < config.attemptCap else { + logger.warning("Pool backoff exhausted after \(attempt) attempts; re-throwing transient deviceError(\(code))") + throw error + } + try await Task.sleep(for: poolBackoffDelay(forInLoopAttempt: attempt, config: config)) + attempt += 1 } + } } extension MessageService { - - func failMessageAndRethrow(_ error: Error, messageID: UUID) async throws -> Never { - pendingAcks.removeValue(forKey: messageID) - // Persistence layer absorbs `.delivered`: if the listener won the race - // before the throw path runs, the row stays delivered. Same invariant as - // updateMessageRetryStatus and updateMessageAck. The Bool return is - // intentionally discarded; the caller observes the failure via the - // rethrown error. The caller is responsible for broadcasting the - // `.failed` event: inline non-queue catch sites yield it one line - // above this call; queue-routed catch sites delegate to the queue's - // outer catch, which calls `notifyMessageFailed` exactly once on any - // non-`CancellationError` escape. - _ = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) - if let meshError = error as? MeshCoreError { - throw MessageServiceError.sessionError(meshError) - } - throw error + func failMessageAndRethrow(_ error: Error, messageID: UUID) async throws -> Never { + pendingAcks.removeValue(forKey: messageID) + // Persistence layer absorbs `.delivered`: if the listener won the race + // before the throw path runs, the row stays delivered. Same invariant as + // updateMessageRetryStatus and updateMessageAck. The Bool return is + // intentionally discarded; the caller observes the failure via the + // rethrown error. The caller is responsible for broadcasting the + // `.failed` event: inline non-queue catch sites yield it one line + // above this call; queue-routed catch sites delegate to the queue's + // outer catch, which calls `notifyMessageFailed` exactly once on any + // non-`CancellationError` escape. + _ = try await dataStore.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) + if let meshError = error as? MeshCoreError { + throw MessageServiceError.sessionError(meshError) } + throw error + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageService.swift b/MC1Services/Sources/MC1Services/Services/MessageService.swift index 60fdff77..b287bd65 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageService.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageService.swift @@ -34,196 +34,197 @@ import MeshCore /// /// Call `startEventMonitoring()` to begin processing ACKs from the session. public actor MessageService { - - // MARK: - Properties - - let logger = PersistentLogger(subsystem: "com.mc1", category: "MessageService") - - let session: any MeshCoreSessionProtocol - let dataStore: any PersistenceStoreProtocol - let config: MessageServiceConfig - - /// Contact service for path management (optional - retry with reset requires this). - /// Injected by `ServiceContainer` at construction. - private let contactService: ContactService? - - /// In-flight messages awaiting ACK, keyed by messageID. - /// - /// One entry per message. Each entry carries the full set of expected-ACK - /// CRCs accumulated across retry attempts, so a late ACK from any attempt - /// can still mark the message delivered. - var pendingAcks: [UUID: PendingAck] = [:] - - /// Multicast broadcaster for outbound-message lifecycle events. - /// - /// Every yield happens synchronously at its lifecycle site on this actor, - /// so yield order equals consumption order and multi-ACK bursts reach - /// subscribers in the order the actor processed them. - nonisolated let statusEventBroadcaster = EventBroadcaster() - - /// Task for periodic ACK expiry checking - var ackCheckTask: Task? - - /// Task for listening to session events - private var eventListenerTask: Task? - - /// Interval between ACK expiry checks (in seconds) - var checkInterval: TimeInterval = 5.0 - - /// Tracks message IDs currently being retried to prevent concurrent retry attempts - var inFlightRetries: Set = [] - - // MARK: - Initialization - - /// Creates a new message service. - /// - /// - Parameters: - /// - session: The MeshCore session for sending messages - /// - dataStore: The persistence store for saving messages - /// - contactService: Contact service for path management during retry - /// - config: Configuration for retry and routing behavior (defaults to `.default`) - init( - session: any MeshCoreSessionProtocol, - dataStore: any PersistenceStoreProtocol, - contactService: ContactService?, - config: MessageServiceConfig = .default - ) { - self.session = session - self.dataStore = dataStore - self.contactService = contactService - self.config = config - } - - /// Whether a contact service was injected at construction. - var hasContactServiceWired: Bool { contactService != nil } - - // MARK: - Event Listening - - /// Starts the session ACK event listener. - /// - /// Subscribes to `.anyAcknowledgement` events on the session and routes - /// each one through `handleAcknowledgement`. Call after the connection is - /// established; without this the listener never runs and pending DMs stay - /// `.sent` even when ACKs arrive. - /// - /// # Lifecycle scope - /// - /// This method's counterpart is `stopEventMonitoring()`. It does **not** - /// start the periodic ACK expiry checker — that's `startAckExpiryChecking()`, - /// which has its own `stopAckExpiryChecking()` / `stopAndFailAllPending()` - /// counterparts. `ServiceContainer` pairs both lifecycles together; direct - /// callers must do the same. - public func startEventMonitoring() { - eventListenerTask?.cancel() - - eventListenerTask = Task { [weak self] in - guard let self else { return } - - for await event in await session.events(filter: .anyAcknowledgement) { - guard !Task.isCancelled else { break } - - if case .acknowledgement(let code, let tripTime) = event { - await handleAcknowledgement(code: code, tripTime: tripTime) - } - } + // MARK: - Properties + + let logger = PersistentLogger(subsystem: "com.mc1", category: "MessageService") + + let session: any MeshCoreSessionProtocol + let dataStore: any PersistenceStoreProtocol + let config: MessageServiceConfig + + /// Contact service for path management (optional - retry with reset requires this). + /// Injected by `ServiceContainer` at construction. + private let contactService: ContactService? + + /// In-flight messages awaiting ACK, keyed by messageID. + /// + /// One entry per message. Each entry carries the full set of expected-ACK + /// CRCs accumulated across retry attempts, so a late ACK from any attempt + /// can still mark the message delivered. + var pendingAcks: [UUID: PendingAck] = [:] + + /// Multicast broadcaster for outbound-message lifecycle events. + /// + /// Every yield happens synchronously at its lifecycle site on this actor, + /// so yield order equals consumption order and multi-ACK bursts reach + /// subscribers in the order the actor processed them. + nonisolated let statusEventBroadcaster = EventBroadcaster() + + /// Task for periodic ACK expiry checking + var ackCheckTask: Task? + + /// Task for listening to session events + private var eventListenerTask: Task? + + /// Interval between ACK expiry checks (in seconds) + var checkInterval: TimeInterval = 5.0 + + /// Tracks message IDs currently being retried to prevent concurrent retry attempts + var inFlightRetries: Set = [] + + // MARK: - Initialization + + /// Creates a new message service. + /// + /// - Parameters: + /// - session: The MeshCore session for sending messages + /// - dataStore: The persistence store for saving messages + /// - contactService: Contact service for path management during retry + /// - config: Configuration for retry and routing behavior (defaults to `.default`) + init( + session: any MeshCoreSessionProtocol, + dataStore: any PersistenceStoreProtocol, + contactService: ContactService?, + config: MessageServiceConfig = .default + ) { + self.session = session + self.dataStore = dataStore + self.contactService = contactService + self.config = config + } + + /// Whether a contact service was injected at construction. + var hasContactServiceWired: Bool { + contactService != nil + } + + // MARK: - Event Listening + + /// Starts the session ACK event listener. + /// + /// Subscribes to `.anyAcknowledgement` events on the session and routes + /// each one through `handleAcknowledgement`. Call after the connection is + /// established; without this the listener never runs and pending DMs stay + /// `.sent` even when ACKs arrive. + /// + /// # Lifecycle scope + /// + /// This method's counterpart is `stopEventMonitoring()`. It does **not** + /// start the periodic ACK expiry checker — that's `startAckExpiryChecking()`, + /// which has its own `stopAckExpiryChecking()` / `stopAndFailAllPending()` + /// counterparts. `ServiceContainer` pairs both lifecycles together; direct + /// callers must do the same. + public func startEventMonitoring() { + eventListenerTask?.cancel() + + eventListenerTask = Task { [weak self] in + guard let self else { return } + + for await event in await session.events(filter: .anyAcknowledgement) { + guard !Task.isCancelled else { break } + + if case let .acknowledgement(code, tripTime) = event { + await handleAcknowledgement(code: code, tripTime: tripTime) } + } } - - /// Stops the session ACK event listener. - /// - /// Cancels `eventListenerTask` only. Does **not** cancel the periodic ACK - /// expiry checker — for that, call `stopAckExpiryChecking()` (just stop - /// the checker, used on a routine disconnect) or `stopAndFailAllPending()` - /// (stop the checker and fail every in-flight DM, for explicit full teardown). - public func stopEventMonitoring() { - eventListenerTask?.cancel() - eventListenerTask = nil - } - - // MARK: - ACK Handling - - /// Processes an acknowledgement from the session event stream. - /// - /// Finds the in-flight message whose accumulated `ackCodes` contains this - /// CRC, marks it delivered, writes the delivery status + round-trip time - /// to the database, and removes the entry. This is the sole DB writer for - /// delivered status on the late-ACK path. - func handleAcknowledgement(code: Data, tripTime: UInt32?) async { - guard let (messageID, tracking) = pendingAcks.first(where: { - $0.value.ackCodes.contains(code) && !$0.value.isDelivered - }) else { - // Diagnostic: the firmware delivered an end-to-end ACK we have no - // live entry for. This is either a genuinely late ACK that arrived - // after the message was already failed and removed, or a duplicate - // for an already-delivered message. `orphanSentRow` flags a `.sent` - // DM that lost its pending entry to a teardown and would otherwise - // show "Sent" forever; its field rate informs whether a - // reconnect-time orphan sweep is worth adding. - let orphanSentRow = (try? await dataStore.hasOutgoingSentDM(ackCode: code.ackCodeUInt32)) ?? false - logger.warning("[ack-diag] unmatched ACK code=\(code.uppercaseHexString()) livePending=\(pendingAcks.count) orphanSentRow=\(orphanSentRow) (late post-teardown or duplicate)") - return - } - - pendingAcks[messageID]?.isDelivered = true - - // Persist `tripTime` only when firmware supplied it. Date()-based - // fallbacks against `tracking.sentAt` would be wrong in either - // direction once retries reset the timestamp, so we prefer a nil - // `roundTripTime` over a fabricated value. - let ackCodeUInt32 = code.ackCodeUInt32 - - do { - try await dataStore.updateMessageAck( - id: messageID, - ackCode: ackCodeUInt32, - status: .delivered, - roundTripTime: tripTime - ) - } catch { - logger.error("Failed to write delivered status: \(error.localizedDescription)") - } - - do { - try await dataStore.updateContactLastMessage(contactID: tracking.contactID, date: Date()) - } catch { - logger.error("Failed to update contact lastMessageDate: \(error.localizedDescription)") - } - - pendingAcks.removeValue(forKey: messageID) - - statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .delivered, roundTripTime: tripTime)) - - // Diagnostic: how long the ACK took relative to the last send attempt - // (sentAt is re-stamped per retry), the firmware-reported round trip, - // and how many other entries were in flight. Distinguishes late-but- - // arriving ACKs from never-arriving ones and measures table pressure. - let ackDelta = Date().timeIntervalSince(tracking.sentAt) - logger.info("[ack-diag] ACK matched deltaSinceLastSend=\(String(format: "%.2f", ackDelta))s firmwareTrip=\(tripTime.map { "\($0)ms" } ?? "nil") livePending=\(pendingAcks.count)") + } + + /// Stops the session ACK event listener. + /// + /// Cancels `eventListenerTask` only. Does **not** cancel the periodic ACK + /// expiry checker — for that, call `stopAckExpiryChecking()` (just stop + /// the checker, used on a routine disconnect) or `stopAndFailAllPending()` + /// (stop the checker and fail every in-flight DM, for explicit full teardown). + public func stopEventMonitoring() { + eventListenerTask?.cancel() + eventListenerTask = nil + } + + // MARK: - ACK Handling + + /// Processes an acknowledgement from the session event stream. + /// + /// Finds the in-flight message whose accumulated `ackCodes` contains this + /// CRC, marks it delivered, writes the delivery status + round-trip time + /// to the database, and removes the entry. This is the sole DB writer for + /// delivered status on the late-ACK path. + func handleAcknowledgement(code: Data, tripTime: UInt32?) async { + guard let (messageID, tracking) = pendingAcks.first(where: { + $0.value.ackCodes.contains(code) && !$0.value.isDelivered + }) else { + // Diagnostic: the firmware delivered an end-to-end ACK we have no + // live entry for. This is either a genuinely late ACK that arrived + // after the message was already failed and removed, or a duplicate + // for an already-delivered message. `orphanSentRow` flags a `.sent` + // DM that lost its pending entry to a teardown and would otherwise + // show "Sent" forever; its field rate informs whether a + // reconnect-time orphan sweep is worth adding. + let orphanSentRow = await (try? dataStore.hasOutgoingSentDM(ackCode: code.ackCodeUInt32)) ?? false + logger.warning("[ack-diag] unmatched ACK code=\(code.uppercaseHexString()) livePending=\(pendingAcks.count) orphanSentRow=\(orphanSentRow) (late post-teardown or duplicate)") + return } - // MARK: - Status Events - - /// Returns a fresh stream of outbound-message lifecycle events. - /// Registration is synchronous, so events yielded after this call are - /// never dropped. Consumers must re-subscribe per connection because the - /// owning `ServiceContainer` is rebuilt on every connection. - public nonisolated func statusEvents() -> AsyncStream { - statusEventBroadcaster.subscribe() + pendingAcks[messageID]?.isDelivered = true + + // Persist `tripTime` only when firmware supplied it. Date()-based + // fallbacks against `tracking.sentAt` would be wrong in either + // direction once retries reset the timestamp, so we prefer a nil + // `roundTripTime` over a fabricated value. + let ackCodeUInt32 = code.ackCodeUInt32 + + do { + try await dataStore.updateMessageAck( + id: messageID, + ackCode: ackCodeUInt32, + status: .delivered, + roundTripTime: tripTime + ) + } catch { + logger.error("Failed to write delivered status: \(error.localizedDescription)") } - /// Ends every `statusEvents()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishStatusEvents() { - statusEventBroadcaster.finish() + do { + try await dataStore.updateContactLastMessage(contactID: tracking.contactID, date: Date()) + } catch { + logger.error("Failed to update contact lastMessageDate: \(error.localizedDescription)") } - /// Broadcasts `.failed` for a message from outside the actor. Sole - /// purpose: the queue-side terminal catch in `ChatSendQueueService` runs - /// off-actor and needs to surface the failure. Do not grow other callers; - /// the inline send paths yield the event directly because they own the - /// catch. - public func notifyMessageFailed(messageID: UUID) async { - statusEventBroadcaster.yield(.failed(messageID: messageID)) - } + pendingAcks.removeValue(forKey: messageID) + + statusEventBroadcaster.yield(.statusResolved(messageID: messageID, status: .delivered, roundTripTime: tripTime)) + + // Diagnostic: how long the ACK took relative to the last send attempt + // (sentAt is re-stamped per retry), the firmware-reported round trip, + // and how many other entries were in flight. Distinguishes late-but- + // arriving ACKs from never-arriving ones and measures table pressure. + let ackDelta = Date().timeIntervalSince(tracking.sentAt) + logger.info("[ack-diag] ACK matched deltaSinceLastSend=\(String(format: "%.2f", ackDelta))s firmwareTrip=\(tripTime.map { "\($0)ms" } ?? "nil") livePending=\(pendingAcks.count)") + } + + // MARK: - Status Events + + /// Returns a fresh stream of outbound-message lifecycle events. + /// Registration is synchronous, so events yielded after this call are + /// never dropped. Consumers must re-subscribe per connection because the + /// owning `ServiceContainer` is rebuilt on every connection. + public nonisolated func statusEvents() -> AsyncStream { + statusEventBroadcaster.subscribe() + } + + /// Ends every `statusEvents()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishStatusEvents() { + statusEventBroadcaster.finish() + } + + /// Broadcasts `.failed` for a message from outside the actor. Sole + /// purpose: the queue-side terminal catch in `ChatSendQueueService` runs + /// off-actor and needs to surface the failure. Do not grow other callers; + /// the inline send paths yield the event directly because they own the + /// catch. + public func notifyMessageFailed(messageID: UUID) async { + statusEventBroadcaster.yield(.failed(messageID: messageID)) + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageServiceConfig.swift b/MC1Services/Sources/MC1Services/Services/MessageServiceConfig.swift index 2f62a9d0..ebfbaa00 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageServiceConfig.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageServiceConfig.swift @@ -2,92 +2,92 @@ import Foundation /// Tuning for the `withPoolBackoff` helper that absorbs short bursts of /// firmware pool-exhaustion errors before parking the envelope. -struct PoolBackoffConfig: Sendable { - /// Maximum in-loop retries before re-throwing the transient `deviceError`. - let attemptCap: Int - - /// Delay for the first in-loop retry (multiplied by `exponentBase` for - /// subsequent attempts, then by a value sampled from `jitterRange`). - let baseDelay: TimeInterval - - /// Exponential growth factor applied to `baseDelay` per attempt. - let exponentBase: Double - - /// Multiplicative jitter envelope sampled per retry. - let jitterRange: ClosedRange - - init( - attemptCap: Int = 3, - baseDelay: TimeInterval = 0.5, - exponentBase: Double = 2.0, - jitterRange: ClosedRange = 0.8...1.2 - ) { - self.attemptCap = attemptCap - self.baseDelay = baseDelay - self.exponentBase = exponentBase - self.jitterRange = jitterRange - } - - static let `default` = PoolBackoffConfig() +struct PoolBackoffConfig { + /// Maximum in-loop retries before re-throwing the transient `deviceError`. + let attemptCap: Int + + /// Delay for the first in-loop retry (multiplied by `exponentBase` for + /// subsequent attempts, then by a value sampled from `jitterRange`). + let baseDelay: TimeInterval + + /// Exponential growth factor applied to `baseDelay` per attempt. + let exponentBase: Double + + /// Multiplicative jitter envelope sampled per retry. + let jitterRange: ClosedRange + + init( + attemptCap: Int = 3, + baseDelay: TimeInterval = 0.5, + exponentBase: Double = 2.0, + jitterRange: ClosedRange = 0.8...1.2 + ) { + self.attemptCap = attemptCap + self.baseDelay = baseDelay + self.exponentBase = exponentBase + self.jitterRange = jitterRange + } + + static let `default` = PoolBackoffConfig() } /// Configuration for message retry and routing behavior. /// /// Controls how the message service handles delivery failures and routing fallback. -struct MessageServiceConfig: Sendable { - /// Whether to use flood routing when user manually retries a failed message - let floodFallbackOnRetry: Bool - - /// Maximum total send attempts for automatic retry - let maxAttempts: Int - - /// Maximum attempts to make after switching to flood routing - let maxFloodAttempts: Int - - /// Number of direct attempts before switching to flood routing - let floodAfter: Int - - /// Minimum timeout in seconds (floor for device-suggested timeout) - let minTimeout: TimeInterval - - /// Whether to trigger path discovery after successful flood delivery - let triggerPathDiscoveryAfterFlood: Bool - - /// Floor and post-loop grace for the give-up deadline on fast presets. - /// - /// The effective deadline for each pending entry is `max(ackGiveUpWindow, - /// PendingAck.timeout)`, so on slow high-spreading-factor presets the - /// deadline follows the attempt's real round-trip and the checker never - /// fires mid-attempt. On fast presets where `est_timeout` is small this - /// value is the binding deadline, set large enough for a late ACK to still - /// reconcile yet short enough to keep the silent "sending" tail brief. - let ackGiveUpWindow: TimeInterval - - /// Tuning for the in-loop pool-exhaustion backoff (`withPoolBackoff`). - let poolBackoff: PoolBackoffConfig - - init( - floodFallbackOnRetry: Bool = true, - maxAttempts: Int = 5, - maxFloodAttempts: Int = 1, - floodAfter: Int = 4, - minTimeout: TimeInterval = 0, - triggerPathDiscoveryAfterFlood: Bool = true, - ackGiveUpWindow: TimeInterval = 30, - poolBackoff: PoolBackoffConfig = .default - ) { - // 5 = 4 direct + 1 flood. AckCodeBuilder.expectedAck documents why attempt - // indices through 4 stay ACK-unambiguous; past that the cap bounds airtime. - precondition(maxAttempts <= 5, "maxAttempts must be <= 5 (4 direct + 1 flood)") - self.floodFallbackOnRetry = floodFallbackOnRetry - self.maxAttempts = maxAttempts - self.maxFloodAttempts = maxFloodAttempts - self.floodAfter = floodAfter - self.minTimeout = minTimeout - self.triggerPathDiscoveryAfterFlood = triggerPathDiscoveryAfterFlood - self.ackGiveUpWindow = ackGiveUpWindow - self.poolBackoff = poolBackoff - } - - static let `default` = MessageServiceConfig() +struct MessageServiceConfig { + /// Whether to use flood routing when user manually retries a failed message + let floodFallbackOnRetry: Bool + + /// Maximum total send attempts for automatic retry + let maxAttempts: Int + + /// Maximum attempts to make after switching to flood routing + let maxFloodAttempts: Int + + /// Number of direct attempts before switching to flood routing + let floodAfter: Int + + /// Minimum timeout in seconds (floor for device-suggested timeout) + let minTimeout: TimeInterval + + /// Whether to trigger path discovery after successful flood delivery + let triggerPathDiscoveryAfterFlood: Bool + + /// Floor and post-loop grace for the give-up deadline on fast presets. + /// + /// The effective deadline for each pending entry is `max(ackGiveUpWindow, + /// PendingAck.timeout)`, so on slow high-spreading-factor presets the + /// deadline follows the attempt's real round-trip and the checker never + /// fires mid-attempt. On fast presets where `est_timeout` is small this + /// value is the binding deadline, set large enough for a late ACK to still + /// reconcile yet short enough to keep the silent "sending" tail brief. + let ackGiveUpWindow: TimeInterval + + /// Tuning for the in-loop pool-exhaustion backoff (`withPoolBackoff`). + let poolBackoff: PoolBackoffConfig + + init( + floodFallbackOnRetry: Bool = true, + maxAttempts: Int = 5, + maxFloodAttempts: Int = 1, + floodAfter: Int = 4, + minTimeout: TimeInterval = 0, + triggerPathDiscoveryAfterFlood: Bool = true, + ackGiveUpWindow: TimeInterval = 30, + poolBackoff: PoolBackoffConfig = .default + ) { + // 5 = 4 direct + 1 flood. AckCodeBuilder.expectedAck documents why attempt + // indices through 4 stay ACK-unambiguous; past that the cap bounds airtime. + precondition(maxAttempts <= 5, "maxAttempts must be <= 5 (4 direct + 1 flood)") + self.floodFallbackOnRetry = floodFallbackOnRetry + self.maxAttempts = maxAttempts + self.maxFloodAttempts = maxFloodAttempts + self.floodAfter = floodAfter + self.minTimeout = minTimeout + self.triggerPathDiscoveryAfterFlood = triggerPathDiscoveryAfterFlood + self.ackGiveUpWindow = ackGiveUpWindow + self.poolBackoff = poolBackoff + } + + static let `default` = MessageServiceConfig() } diff --git a/MC1Services/Sources/MC1Services/Services/MessageServiceError.swift b/MC1Services/Sources/MC1Services/Services/MessageServiceError.swift index a46a1b9c..d3f63552 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageServiceError.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageServiceError.swift @@ -3,32 +3,32 @@ import MeshCore /// Errors that can occur during message operations. public enum MessageServiceError: Error, Sendable { - /// Not connected to a device - case notConnected - /// Contact not found in database - case contactNotFound - /// Channel not found in database - case channelNotFound - /// Message send operation failed - case sendFailed(String) - /// Attempted to send message to invalid recipient (e.g., repeater) - case invalidRecipient - /// Message text exceeds maximum allowed length - case messageTooLong - /// Underlying MeshCore session error - case sessionError(MeshCoreError) + /// Not connected to a device + case notConnected + /// Contact not found in database + case contactNotFound + /// Channel not found in database + case channelNotFound + /// Message send operation failed + case sendFailed(String) + /// Attempted to send message to invalid recipient (e.g., repeater) + case invalidRecipient + /// Message text exceeds maximum allowed length + case messageTooLong + /// Underlying MeshCore session error + case sessionError(MeshCoreError) } extension MessageServiceError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .contactNotFound: "Contact not found." - case .channelNotFound: "Channel not found." - case .sendFailed(let msg): "Send failed: \(msg)" - case .invalidRecipient: "Cannot send messages to this recipient." - case .messageTooLong: "Message exceeds the maximum allowed length." - case .sessionError(let e): e.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .contactNotFound: "Contact not found." + case .channelNotFound: "Channel not found." + case let .sendFailed(msg): "Send failed: \(msg)" + case .invalidRecipient: "Cannot send messages to this recipient." + case .messageTooLong: "Message exceeds the maximum allowed length." + case let .sessionError(e): e.localizedDescription } + } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageStatusEvent.swift b/MC1Services/Sources/MC1Services/Services/MessageStatusEvent.swift index b77ae3e0..6d899dbd 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageStatusEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageStatusEvent.swift @@ -8,21 +8,21 @@ import Foundation /// raw ackCode so consumers can gate on conversation membership without /// re-walking the service's pending-ACK table. public enum MessageStatusEvent: Sendable { - /// A message reached a resolved status: `.sent` once the radio queues a - /// DM or channel broadcast, `.delivered` once a DM's end-to-end ACK - /// arrives. Channel broadcasts have no recipient ACK, so `.sent` is their - /// terminal success state. `roundTripTime` is the firmware-reported value - /// when supplied, riding along so the UI can fold status and timing into - /// one in-place bubble update without re-reading the DTO. - case statusResolved(messageID: UUID, status: MessageStatus, roundTripTime: UInt32?) - /// A resend completed with `.sent` committed. Carries no status payload - /// because resends also mutate `heardRepeats` and `sendCount`; consumers - /// must refresh the entire DTO rather than apply a status-only update. - case resent(messageID: UUID) - /// A retry attempt is in flight; use for UI retry progress. - case retrying(messageID: UUID, attempt: Int, maxAttempts: Int) - /// A contact's routing switched between direct and flood during a send. - case routingChanged(contactID: UUID, isFlood: Bool) - /// A message failed after exhausting retries or a terminal send error. - case failed(messageID: UUID) + /// A message reached a resolved status: `.sent` once the radio queues a + /// DM or channel broadcast, `.delivered` once a DM's end-to-end ACK + /// arrives. Channel broadcasts have no recipient ACK, so `.sent` is their + /// terminal success state. `roundTripTime` is the firmware-reported value + /// when supplied, riding along so the UI can fold status and timing into + /// one in-place bubble update without re-reading the DTO. + case statusResolved(messageID: UUID, status: MessageStatus, roundTripTime: UInt32?) + /// A resend completed with `.sent` committed. Carries no status payload + /// because resends also mutate `heardRepeats` and `sendCount`; consumers + /// must refresh the entire DTO rather than apply a status-only update. + case resent(messageID: UUID) + /// A retry attempt is in flight; use for UI retry progress. + case retrying(messageID: UUID, attempt: Int, maxAttempts: Int) + /// A contact's routing switched between direct and flood during a send. + case routingChanged(contactID: UUID, isFlood: Bool) + /// A message failed after exhausting retries or a terminal send error. + case failed(messageID: UUID) } diff --git a/MC1Services/Sources/MC1Services/Services/NodeConfigImportPlanner.swift b/MC1Services/Sources/MC1Services/Services/NodeConfigImportPlanner.swift index 78be4602..d84fafcc 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeConfigImportPlanner.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeConfigImportPlanner.swift @@ -6,11 +6,11 @@ import MeshCore /// A snapshot of one channel slot read from the device during the import read phase. /// Consumed by ``planConfigImport`` /// so slot planning stays a pure, testable function. -struct DeviceChannelSlot: Sendable, Equatable { - let index: UInt8 - let name: String - let secret: Data - let isConfigured: Bool +struct DeviceChannelSlot: Equatable { + let index: UInt8 + let name: String + let secret: Data + let isConfigured: Bool } // MARK: - Config Import Plan @@ -21,35 +21,35 @@ struct DeviceChannelSlot: Sendable, Equatable { /// Building a plan throws `NodeConfigServiceError` on any structural problem, so a /// malformed or poison config is rejected up front and nothing is half-applied. The /// execute phase then performs only writes that have already been proven applyable. -struct ConfigImportPlan: Sendable, Equatable { - struct Coordinate: Sendable, Equatable { - let latitude: Double - let longitude: Double - } - - struct ChannelWrite: Sendable, Equatable { - let index: UInt8 - let name: String - let secret: Data - } +struct ConfigImportPlan: Equatable { + struct Coordinate: Equatable { + let latitude: Double + let longitude: Double + } - /// Validated 64-byte private key to push, when identity is selected and present. - var importPrivateKey: Data? - /// Node name to set, when identity is selected and present. - var nodeName: String? - /// Validated, in-range position to set. - var position: Coordinate? - /// Other-settings to merge at execute time (passed through verbatim, raw bytes preserved). - var otherSettings: MeshCoreNodeConfig.OtherSettings? - /// Validated radio parameters to write, when the radio section is selected and present. - var radioSettings: MeshCoreNodeConfig.RadioSettings? - /// Resolved channel writes (deduplicated; intra-import duplicates folded onto one slot). - var channelWrites: [ChannelWrite] - /// True when any channel write replaces an already-configured slot whose name/secret differs - /// — i.e. the channels section is not purely additive for this config. - var channelsOverwriteExisting: Bool - /// Validated, deduplicated contact records ready to write (raw type byte preserved). - var contactRecords: [MeshContact] + struct ChannelWrite: Equatable { + let index: UInt8 + let name: String + let secret: Data + } + + /// Validated 64-byte private key to push, when identity is selected and present. + var importPrivateKey: Data? + /// Node name to set, when identity is selected and present. + var nodeName: String? + /// Validated, in-range position to set. + var position: Coordinate? + /// Other-settings to merge at execute time (passed through verbatim, raw bytes preserved). + var otherSettings: MeshCoreNodeConfig.OtherSettings? + /// Validated radio parameters to write, when the radio section is selected and present. + var radioSettings: MeshCoreNodeConfig.RadioSettings? + /// Resolved channel writes (deduplicated; intra-import duplicates folded onto one slot). + var channelWrites: [ChannelWrite] + /// True when any channel write replaces an already-configured slot whose name/secret differs + /// — i.e. the channels section is not purely additive for this config. + var channelsOverwriteExisting: Bool + /// Validated, deduplicated contact records ready to write (raw type byte preserved). + var contactRecords: [MeshContact] } // MARK: - Planner @@ -60,90 +60,90 @@ struct ConfigImportPlan: Sendable, Equatable { /// Pure and synchronous so it is unit-testable without a live `MeshCoreSession` — mirrors the /// `resolveEffectiveRadioID` seam pattern. Only sections present in `sections` are planned. func planConfigImport( - config: MeshCoreNodeConfig, - sections: ConfigSections, - maxChannels: UInt8, - maxContacts: Int, - maxTxPower: Int8, - existingChannels: [DeviceChannelSlot], - existingContacts: [String: MeshContact] + config: MeshCoreNodeConfig, + sections: ConfigSections, + maxChannels: UInt8, + maxContacts: Int, + maxTxPower: Int8, + existingChannels: [DeviceChannelSlot], + existingContacts: [String: MeshContact] ) throws -> ConfigImportPlan { - var plan = ConfigImportPlan( - importPrivateKey: nil, - nodeName: nil, - position: nil, - otherSettings: nil, - radioSettings: nil, - channelWrites: [], - channelsOverwriteExisting: false, - contactRecords: [] + var plan = ConfigImportPlan( + importPrivateKey: nil, + nodeName: nil, + position: nil, + otherSettings: nil, + radioSettings: nil, + channelWrites: [], + channelsOverwriteExisting: false, + contactRecords: [] + ) + + if sections.nodeIdentity { + plan.importPrivateKey = try planPrivateKey(config: config) + plan.nodeName = config.name + } + + if sections.positionSettings, let position = config.positionSettings { + let lat = try validatedCoordinate(position.latitude, field: .positionLatitude, range: PacketBuilder.latitudeRange) + let lon = try validatedCoordinate(position.longitude, field: .positionLongitude, range: PacketBuilder.longitudeRange) + plan.position = ConfigImportPlan.Coordinate(latitude: lat, longitude: lon) + } + + if sections.otherSettings { + plan.otherSettings = config.otherSettings + } + + if sections.radioSettings, let radio = config.radioSettings { + plan.radioSettings = try planRadioSettings(radio, maxTxPower: maxTxPower) + } + + if sections.channels, let channels = config.channels { + let (writes, overwrite) = try planChannelWrites( + channels, maxChannels: maxChannels, existingChannels: existingChannels ) + plan.channelWrites = writes + plan.channelsOverwriteExisting = overwrite + } - if sections.nodeIdentity { - plan.importPrivateKey = try planPrivateKey(config: config) - plan.nodeName = config.name - } - - if sections.positionSettings, let position = config.positionSettings { - let lat = try validatedCoordinate(position.latitude, field: .positionLatitude, range: PacketBuilder.latitudeRange) - let lon = try validatedCoordinate(position.longitude, field: .positionLongitude, range: PacketBuilder.longitudeRange) - plan.position = ConfigImportPlan.Coordinate(latitude: lat, longitude: lon) - } - - if sections.otherSettings { - plan.otherSettings = config.otherSettings - } - - if sections.radioSettings, let radio = config.radioSettings { - plan.radioSettings = try planRadioSettings(radio, maxTxPower: maxTxPower) - } - - if sections.channels, let channels = config.channels { - let (writes, overwrite) = try planChannelWrites( - channels, maxChannels: maxChannels, existingChannels: existingChannels - ) - plan.channelWrites = writes - plan.channelsOverwriteExisting = overwrite - } - - if sections.contacts, let contacts = config.contacts { - plan.contactRecords = try planContactRecords( - contacts, maxContacts: maxContacts, existingContacts: existingContacts - ) - } + if sections.contacts, let contacts = config.contacts { + plan.contactRecords = try planContactRecords( + contacts, maxContacts: maxContacts, existingContacts: existingContacts + ) + } - return plan + return plan } // MARK: - Identity private func planPrivateKey(config: MeshCoreNodeConfig) throws -> Data? { - guard let privateKeyHex = config.privateKey else { return nil } - - // A present-but-unparseable key must be rejected, not silently skipped. The key is the 64-byte - // expanded Ed25519 secret (`clamp(SHA512(seed))`). The public key is derivable from this scalar - // (firmware re-derives and validates it on import), but MC1's CryptoKit API operates on the - // 32-byte seed, which the export omits, so MC1 cannot cheaply re-derive and cross-check it here - // and takes the pairing on trust. Firmware additionally rejects all-00/FF-prefix keys and the - // known test keypair; that content check is intentionally deferred to the device and is the one - // identity failure that can surface at execute time, but firmware checks it before saving the - // identity, so it still cannot leave a half-rotated identity. - guard privateKeyHex.allSatisfy(\.isHexDigit), - let privateKeyData = Data(hexString: privateKeyHex), - privateKeyData.count == ProtocolLimits.privateKeySize else { - throw NodeConfigServiceError.invalidPrivateKey(hexLength: privateKeyHex.count) - } - - return privateKeyData + guard let privateKeyHex = config.privateKey else { return nil } + + // A present-but-unparseable key must be rejected, not silently skipped. The key is the 64-byte + // expanded Ed25519 secret (`clamp(SHA512(seed))`). The public key is derivable from this scalar + // (firmware re-derives and validates it on import), but MC1's CryptoKit API operates on the + // 32-byte seed, which the export omits, so MC1 cannot cheaply re-derive and cross-check it here + // and takes the pairing on trust. Firmware additionally rejects all-00/FF-prefix keys and the + // known test keypair; that content check is intentionally deferred to the device and is the one + // identity failure that can surface at execute time, but firmware checks it before saving the + // identity, so it still cannot leave a half-rotated identity. + guard privateKeyHex.allSatisfy(\.isHexDigit), + let privateKeyData = Data(hexString: privateKeyHex), + privateKeyData.count == ProtocolLimits.privateKeySize else { + throw NodeConfigServiceError.invalidPrivateKey(hexLength: privateKeyHex.count) + } + + return privateKeyData } // MARK: - Coordinates private func validatedCoordinate(_ raw: String, field: CoordinateField, range: ClosedRange) throws -> Double { - guard let value = Double(raw), value.isFinite, range.contains(value) else { - throw NodeConfigServiceError.invalidCoordinate(field: field) - } - return value + guard let value = Double(raw), value.isFinite, range.contains(value) else { + throw NodeConfigServiceError.invalidCoordinate(field: field) + } + return value } // MARK: - Radio @@ -153,25 +153,25 @@ private func validatedCoordinate(_ raw: String, field: CoordinateField, range: C /// has already been rotated. The txPower upper bound is the device-reported `maxTxPower`, since it is /// hardware/build-specific; the other ranges are fixed firmware limits in `PacketBuilder`. private func planRadioSettings( - _ radio: MeshCoreNodeConfig.RadioSettings, - maxTxPower: Int8 + _ radio: MeshCoreNodeConfig.RadioSettings, + maxTxPower: Int8 ) throws -> MeshCoreNodeConfig.RadioSettings { - guard PacketBuilder.frequencyRangeKHz.contains(radio.frequency) else { - throw NodeConfigServiceError.invalidRadioSettings(field: .frequency) - } - guard PacketBuilder.bandwidthRangeHz.contains(radio.bandwidth) else { - throw NodeConfigServiceError.invalidRadioSettings(field: .bandwidth) - } - guard PacketBuilder.spreadingFactorRange.contains(radio.spreadingFactor) else { - throw NodeConfigServiceError.invalidRadioSettings(field: .spreadingFactor) - } - guard PacketBuilder.codingRateRange.contains(radio.codingRate) else { - throw NodeConfigServiceError.invalidRadioSettings(field: .codingRate) - } - guard radio.txPower >= PacketBuilder.txPowerFloor, radio.txPower <= maxTxPower else { - throw NodeConfigServiceError.invalidRadioSettings(field: .txPower) - } - return radio + guard PacketBuilder.frequencyRangeKHz.contains(radio.frequency) else { + throw NodeConfigServiceError.invalidRadioSettings(field: .frequency) + } + guard PacketBuilder.bandwidthRangeHz.contains(radio.bandwidth) else { + throw NodeConfigServiceError.invalidRadioSettings(field: .bandwidth) + } + guard PacketBuilder.spreadingFactorRange.contains(radio.spreadingFactor) else { + throw NodeConfigServiceError.invalidRadioSettings(field: .spreadingFactor) + } + guard PacketBuilder.codingRateRange.contains(radio.codingRate) else { + throw NodeConfigServiceError.invalidRadioSettings(field: .codingRate) + } + guard radio.txPower >= PacketBuilder.txPowerFloor, radio.txPower <= maxTxPower else { + throw NodeConfigServiceError.invalidRadioSettings(field: .txPower) + } + return radio } // MARK: - Channels @@ -180,95 +180,95 @@ private func planRadioSettings( /// hashtag name or secret — onto one slot so a config never consumes two slots for the same /// channel, and flagging overwrites of already-configured slots. private func planChannelWrites( - _ channels: [MeshCoreNodeConfig.ChannelConfig], - maxChannels: UInt8, - existingChannels: [DeviceChannelSlot] + _ channels: [MeshCoreNodeConfig.ChannelConfig], + maxChannels: UInt8, + existingChannels: [DeviceChannelSlot] ) throws -> (writes: [ConfigImportPlan.ChannelWrite], overwrite: Bool) { - var hashtagNameToIndex: [String: UInt8] = [:] - var secretToIndex: [String: UInt8] = [:] - var emptyIndices: [UInt8] = [] - var existingByIndex: [UInt8: (name: String, secret: Data)] = [:] - - for slot in existingChannels where slot.index < maxChannels { - if slot.isConfigured { - existingByIndex[slot.index] = (slot.name, slot.secret) - // Index every configured slot by secret, so a same-secret import folds onto it - // regardless of whether the existing slot is a hashtag channel. Hashtag slots are - // additionally indexed by their (already device-truncated) name. - secretToIndex[slot.secret.hexString] = slot.index - if slot.name.hasPrefix("#") { - hashtagNameToIndex[slot.name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes)] = slot.index - } - } else { - emptyIndices.append(slot.index) - } + var hashtagNameToIndex: [String: UInt8] = [:] + var secretToIndex: [String: UInt8] = [:] + var emptyIndices: [UInt8] = [] + var existingByIndex: [UInt8: (name: String, secret: Data)] = [:] + + for slot in existingChannels where slot.index < maxChannels { + if slot.isConfigured { + existingByIndex[slot.index] = (slot.name, slot.secret) + // Index every configured slot by secret, so a same-secret import folds onto it + // regardless of whether the existing slot is a hashtag channel. Hashtag slots are + // additionally indexed by their (already device-truncated) name. + secretToIndex[slot.secret.hexString] = slot.index + if slot.name.hasPrefix("#") { + hashtagNameToIndex[slot.name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes)] = slot.index + } + } else { + emptyIndices.append(slot.index) + } + } + + var writes: [ConfigImportPlan.ChannelWrite] = [] + var overwrite = false + // The value each slot will hold given the writes planned so far, seeded from the device's + // configured slots. The no-op skip compares against this, not the frozen `existingByIndex`, so + // a later duplicate that restores a slot an earlier write changed is not dropped. + var plannedByIndex = existingByIndex + + for (i, channel) in channels.enumerated() { + guard channel.secret.allSatisfy(\.isHexDigit), + let secretData = Data(hexString: channel.secret), + secretData.count == ProtocolLimits.channelSecretSize else { + throw NodeConfigServiceError.invalidChannelSecret(index: i, hexLength: channel.secret.count) + } + // Key on the re-hexed parsed bytes (canonical) so a config secret with non-canonical + // casing still dedups against the device's canonically-keyed slots. + let secretKey = secretData.hexString + // The device stores names truncated to the firmware field width, so dedup and overwrite + // comparison must use the truncated form — otherwise a long hashtag name misses its slot. + let lookupName = channel.name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + + // The secret is firmware's channel-match key (findChannelIdx memcmp), so it must stay + // single-homed. Resolve any slot the secret already occupies first; a hashtag-name match + // must defer to it, otherwise a "#name"+new-secret import would duplicate that secret onto + // the name's slot while its original slot still holds it, mis-attributing mesh traffic. + let secretSlot = secretToIndex[secretKey] + + let targetIndex: UInt8 + if let secretSlot { + targetIndex = secretSlot + } else if channel.name.hasPrefix("#"), let existing = hashtagNameToIndex[lookupName] { + targetIndex = existing + } else if let empty = emptyIndices.first { + emptyIndices.removeFirst() + targetIndex = empty + } else { + throw NodeConfigServiceError.noAvailableChannelSlot(name: channel.name) + } + + // Skip the write when the slot's effective value already matches, comparing the truncated + // name the device actually stores. Writing it would re-commit /channels2 for no change. + if let planned = plannedByIndex[targetIndex], planned.name == lookupName, planned.secret == secretData { + // Keep the lookup tables current so a later same-key import still folds onto this slot. + if channel.name.hasPrefix("#") { hashtagNameToIndex[lookupName] = targetIndex } + secretToIndex[secretKey] = targetIndex + continue + } + + // An overwrite is a change to a slot the device itself had configured, so it keys off the + // original device state rather than the in-progress planned state. + if let original = existingByIndex[targetIndex], original.name != lookupName || original.secret != secretData { + overwrite = true } - var writes: [ConfigImportPlan.ChannelWrite] = [] - var overwrite = false - // The value each slot will hold given the writes planned so far, seeded from the device's - // configured slots. The no-op skip compares against this, not the frozen `existingByIndex`, so - // a later duplicate that restores a slot an earlier write changed is not dropped. - var plannedByIndex = existingByIndex - - for (i, channel) in channels.enumerated() { - guard channel.secret.allSatisfy(\.isHexDigit), - let secretData = Data(hexString: channel.secret), - secretData.count == ProtocolLimits.channelSecretSize else { - throw NodeConfigServiceError.invalidChannelSecret(index: i, hexLength: channel.secret.count) - } - // Key on the re-hexed parsed bytes (canonical) so a config secret with non-canonical - // casing still dedups against the device's canonically-keyed slots. - let secretKey = secretData.hexString - // The device stores names truncated to the firmware field width, so dedup and overwrite - // comparison must use the truncated form — otherwise a long hashtag name misses its slot. - let lookupName = channel.name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - - // The secret is firmware's channel-match key (findChannelIdx memcmp), so it must stay - // single-homed. Resolve any slot the secret already occupies first; a hashtag-name match - // must defer to it, otherwise a "#name"+new-secret import would duplicate that secret onto - // the name's slot while its original slot still holds it, mis-attributing mesh traffic. - let secretSlot = secretToIndex[secretKey] - - let targetIndex: UInt8 - if let secretSlot { - targetIndex = secretSlot - } else if channel.name.hasPrefix("#"), let existing = hashtagNameToIndex[lookupName] { - targetIndex = existing - } else if let empty = emptyIndices.first { - emptyIndices.removeFirst() - targetIndex = empty - } else { - throw NodeConfigServiceError.noAvailableChannelSlot(name: channel.name) - } - - // Skip the write when the slot's effective value already matches, comparing the truncated - // name the device actually stores. Writing it would re-commit /channels2 for no change. - if let planned = plannedByIndex[targetIndex], planned.name == lookupName, planned.secret == secretData { - // Keep the lookup tables current so a later same-key import still folds onto this slot. - if channel.name.hasPrefix("#") { hashtagNameToIndex[lookupName] = targetIndex } - secretToIndex[secretKey] = targetIndex - continue - } - - // An overwrite is a change to a slot the device itself had configured, so it keys off the - // original device state rather than the in-progress planned state. - if let original = existingByIndex[targetIndex], original.name != lookupName || original.secret != secretData { - overwrite = true - } - - // Update lookup tables so a later same-name/same-secret import folds onto this slot - // instead of consuming a fresh one, and record the slot's new effective value. - if channel.name.hasPrefix("#") { - hashtagNameToIndex[lookupName] = targetIndex - } - secretToIndex[secretKey] = targetIndex - plannedByIndex[targetIndex] = (lookupName, secretData) - - writes.append(ConfigImportPlan.ChannelWrite(index: targetIndex, name: channel.name, secret: secretData)) + // Update lookup tables so a later same-name/same-secret import folds onto this slot + // instead of consuming a fresh one, and record the slot's new effective value. + if channel.name.hasPrefix("#") { + hashtagNameToIndex[lookupName] = targetIndex } + secretToIndex[secretKey] = targetIndex + plannedByIndex[targetIndex] = (lookupName, secretData) - return (writes, overwrite) + writes.append(ConfigImportPlan.ChannelWrite(index: targetIndex, name: channel.name, secret: secretData)) + } + + return (writes, overwrite) } // MARK: - Contacts @@ -278,53 +278,53 @@ private func planChannelWrites( /// the device already stores byte-for-byte, and rejects invalid keys, path modes, coordinates, and /// routing paths before any write. private func planContactRecords( - _ contacts: [MeshCoreNodeConfig.ContactConfig], - maxContacts: Int, - existingContacts: [String: MeshContact] + _ contacts: [MeshCoreNodeConfig.ContactConfig], + maxContacts: Int, + existingContacts: [String: MeshContact] ) throws -> [MeshContact] { - var byKey: [String: (config: MeshCoreNodeConfig.ContactConfig, publicKey: Data)] = [:] - var order: [String] = [] - - for contact in contacts { - guard contact.publicKey.allSatisfy(\.isHexDigit), - let publicKey = Data(hexString: contact.publicKey), - publicKey.count == ProtocolLimits.publicKeySize else { - throw NodeConfigServiceError.invalidContactPublicKey(name: contact.name) - } - let key = publicKey.hexString - if let existing = byKey[key] { - if contact.lastModified >= existing.config.lastModified { - byKey[key] = (contact, publicKey) - } - } else { - byKey[key] = (contact, publicKey) - order.append(key) - } - } - - // A firmware update of a key already on the device consumes no slot, so only keys not already - // present count against free capacity. Checking remaining slots (not the absolute table size) - // lets the non-destructive preview reject an overflow up front instead of failing partway - // through the contact writes with `TABLE_FULL`, after identity and channels have committed. - let newKeyCount = order.reduce(0) { count, key in - existingContacts.keys.contains(key) ? count : count + 1 + var byKey: [String: (config: MeshCoreNodeConfig.ContactConfig, publicKey: Data)] = [:] + var order: [String] = [] + + for contact in contacts { + guard contact.publicKey.allSatisfy(\.isHexDigit), + let publicKey = Data(hexString: contact.publicKey), + publicKey.count == ProtocolLimits.publicKeySize else { + throw NodeConfigServiceError.invalidContactPublicKey(name: contact.name) } - let availableSlots = maxContacts - existingContacts.count - guard newKeyCount <= availableSlots else { - throw NodeConfigServiceError.contactCapacityExceeded(needed: newKeyCount, available: availableSlots) + let key = publicKey.hexString + if let existing = byKey[key] { + if contact.lastModified >= existing.config.lastModified { + byKey[key] = (contact, publicKey) + } + } else { + byKey[key] = (contact, publicKey) + order.append(key) } - - // Drop a record the device already stores byte-for-byte: re-adding it would arm a - // /contacts3 rewrite for no change. A dropped record was an existing key, so it never - // counted against free capacity above. - return try order.compactMap { key in - let entry = byKey[key]! - let record = try buildContactRecord(entry.config, publicKey: entry.publicKey, hexKey: key) - if let existing = existingContacts[key], persistedContactFieldsMatch(existing, record) { - return nil - } - return record + } + + // A firmware update of a key already on the device consumes no slot, so only keys not already + // present count against free capacity. Checking remaining slots (not the absolute table size) + // lets the non-destructive preview reject an overflow up front instead of failing partway + // through the contact writes with `TABLE_FULL`, after identity and channels have committed. + let newKeyCount = order.reduce(0) { count, key in + existingContacts.keys.contains(key) ? count : count + 1 + } + let availableSlots = maxContacts - existingContacts.count + guard newKeyCount <= availableSlots else { + throw NodeConfigServiceError.contactCapacityExceeded(needed: newKeyCount, available: availableSlots) + } + + // Drop a record the device already stores byte-for-byte: re-adding it would arm a + // /contacts3 rewrite for no change. A dropped record was an existing key, so it never + // counted against free capacity above. + return try order.compactMap { key in + let entry = byKey[key]! + let record = try buildContactRecord(entry.config, publicKey: entry.publicKey, hexKey: key) + if let existing = existingContacts[key], persistedContactFieldsMatch(existing, record) { + return nil } + return record + } } /// True when `existing` (a device-resident contact) already stores exactly what `record` would @@ -334,79 +334,79 @@ private func planContactRecords( /// skipped. Names compare at the firmware field width, and coordinates by the integer the device /// actually stores, so a difference the device cannot represent is treated as equal. private func persistedContactFieldsMatch(_ existing: MeshContact, _ record: MeshContact) -> Bool { - guard existing.typeRawValue == record.typeRawValue, - existing.flags == record.flags, - existing.outPathLength == record.outPathLength, - existing.advertisedName.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - == record.advertisedName.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes), - existing.outPath.prefix(existing.pathByteLength) == record.outPath.prefix(record.pathByteLength), - Int(existing.lastModified.timeIntervalSince1970) == Int(record.lastModified.timeIntervalSince1970) else { - return false - } - return PacketBuilder.scaledCoordinate(existing.latitude, in: PacketBuilder.latitudeRange) - == PacketBuilder.scaledCoordinate(record.latitude, in: PacketBuilder.latitudeRange) - && PacketBuilder.scaledCoordinate(existing.longitude, in: PacketBuilder.longitudeRange) - == PacketBuilder.scaledCoordinate(record.longitude, in: PacketBuilder.longitudeRange) + guard existing.typeRawValue == record.typeRawValue, + existing.flags == record.flags, + existing.outPathLength == record.outPathLength, + existing.advertisedName.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + == record.advertisedName.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes), + existing.outPath.prefix(existing.pathByteLength) == record.outPath.prefix(record.pathByteLength), + Int(existing.lastModified.timeIntervalSince1970) == Int(record.lastModified.timeIntervalSince1970) else { + return false + } + return PacketBuilder.scaledCoordinate(existing.latitude, in: PacketBuilder.latitudeRange) + == PacketBuilder.scaledCoordinate(record.latitude, in: PacketBuilder.latitudeRange) + && PacketBuilder.scaledCoordinate(existing.longitude, in: PacketBuilder.longitudeRange) + == PacketBuilder.scaledCoordinate(record.longitude, in: PacketBuilder.longitudeRange) } /// Builds one validated contact record. `publicKey` and `hexKey` are the already-decoded key /// and its lowercase hex from ``planContactRecords``, so the key is parsed exactly once. private func buildContactRecord( - _ contact: MeshCoreNodeConfig.ContactConfig, - publicKey: Data, - hexKey: String + _ contact: MeshCoreNodeConfig.ContactConfig, + publicKey: Data, + hexKey: String ) throws -> MeshContact { - let (outPath, outPathLength) = try resolveOutPath(contact) - - let lat = try validatedCoordinate(contact.latitude, field: .contactLatitude(name: contact.name), range: PacketBuilder.latitudeRange) - let lon = try validatedCoordinate(contact.longitude, field: .contactLongitude(name: contact.name), range: PacketBuilder.longitudeRange) - - return MeshContact( - id: hexKey, - publicKey: publicKey, - type: ContactType(rawValue: contact.type) ?? .chat, - typeRawValue: contact.type, - flags: ContactFlags(rawValue: contact.flags), - outPathLength: outPathLength, - outPath: outPath, - advertisedName: contact.name, - lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvert)), - latitude: lat, - longitude: lon, - lastModified: Date(timeIntervalSince1970: TimeInterval(contact.lastModified)) - ) + let (outPath, outPathLength) = try resolveOutPath(contact) + + let lat = try validatedCoordinate(contact.latitude, field: .contactLatitude(name: contact.name), range: PacketBuilder.latitudeRange) + let lon = try validatedCoordinate(contact.longitude, field: .contactLongitude(name: contact.name), range: PacketBuilder.longitudeRange) + + return MeshContact( + id: hexKey, + publicKey: publicKey, + type: ContactType(rawValue: contact.type) ?? .chat, + typeRawValue: contact.type, + flags: ContactFlags(rawValue: contact.flags), + outPathLength: outPathLength, + outPath: outPath, + advertisedName: contact.name, + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvert)), + latitude: lat, + longitude: lon, + lastModified: Date(timeIntervalSince1970: TimeInterval(contact.lastModified)) + ) } /// Resolves the encoded out-path for a contact, rejecting malformed routing data instead of /// silently downgrading a routed contact to direct. private func resolveOutPath(_ contact: MeshCoreNodeConfig.ContactConfig) throws -> (path: Data, length: UInt8) { - guard let pathHex = contact.outPath else { - // Absent path: flood routing. - return (Data(), PacketBuilder.floodPathSentinel) - } - if pathHex.isEmpty { - // Explicit empty string: direct contact. - return (Data(), 0) - } - // `Data(hexString:)` silently drops non-hex characters, so a routed path like "zzz" would - // parse to empty and masquerade as direct. Require contiguous, even-length hex up front. - guard pathHex.count.isMultiple(of: 2), - pathHex.allSatisfy(\.isHexDigit), - let pathData = Data(hexString: pathHex), !pathData.isEmpty else { - throw NodeConfigServiceError.invalidOutPath(name: contact.name) - } - let mode = contact.pathHashMode ?? 0 - guard mode <= UInt8(PathEncoding.maxPathHashMode) else { - throw NodeConfigServiceError.invalidPathHashMode(name: contact.name, mode: mode) - } - let hashSize = Int(mode) + 1 - // Reject paths that would silently truncate (non-multiple length, hop count past the 6-bit - // field) or exceed the firmware out-path buffer (firmware `isValidPathLen`). - guard pathData.count % hashSize == 0, - pathData.count / hashSize <= PathEncoding.maxHopCount, - pathData.count <= PathEncoding.maxPathBytes else { - throw NodeConfigServiceError.invalidOutPath(name: contact.name) - } - let hopCount = pathData.count / hashSize - return (pathData, encodePathLen(hashSize: hashSize, hopCount: hopCount)) + guard let pathHex = contact.outPath else { + // Absent path: flood routing. + return (Data(), PacketBuilder.floodPathSentinel) + } + if pathHex.isEmpty { + // Explicit empty string: direct contact. + return (Data(), 0) + } + // `Data(hexString:)` silently drops non-hex characters, so a routed path like "zzz" would + // parse to empty and masquerade as direct. Require contiguous, even-length hex up front. + guard pathHex.count.isMultiple(of: 2), + pathHex.allSatisfy(\.isHexDigit), + let pathData = Data(hexString: pathHex), !pathData.isEmpty else { + throw NodeConfigServiceError.invalidOutPath(name: contact.name) + } + let mode = contact.pathHashMode ?? 0 + guard mode <= UInt8(PathEncoding.maxPathHashMode) else { + throw NodeConfigServiceError.invalidPathHashMode(name: contact.name, mode: mode) + } + let hashSize = Int(mode) + 1 + // Reject paths that would silently truncate (non-multiple length, hop count past the 6-bit + // field) or exceed the firmware out-path buffer (firmware `isValidPathLen`). + guard pathData.count % hashSize == 0, + pathData.count / hashSize <= PathEncoding.maxHopCount, + pathData.count <= PathEncoding.maxPathBytes else { + throw NodeConfigServiceError.invalidOutPath(name: contact.name) + } + let hopCount = pathData.count / hashSize + return (pathData, encodePathLen(hashSize: hashSize, hopCount: hopCount)) } diff --git a/MC1Services/Sources/MC1Services/Services/NodeConfigService.swift b/MC1Services/Sources/MC1Services/Services/NodeConfigService.swift index 635ab38c..ecdc50f6 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeConfigService.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeConfigService.swift @@ -7,49 +7,49 @@ import OSLog /// Identifies which radio parameter failed range validation. Kept `L10n`-free so the service /// layer carries no localization; the app layer maps each case to a localized field label. public enum RadioField: Sendable, Equatable { - case frequency, bandwidth, spreadingFactor, codingRate, txPower + case frequency, bandwidth, spreadingFactor, codingRate, txPower } /// Identifies which coordinate failed range validation, and on which record. Kept `L10n`-free /// so the service layer carries no localization; the app layer maps each case to a localized label. public enum CoordinateField: Sendable, Equatable { - case positionLatitude, positionLongitude - case contactLatitude(name: String), contactLongitude(name: String) + case positionLatitude, positionLongitude + case contactLatitude(name: String), contactLongitude(name: String) } public enum NodeConfigServiceError: Error, LocalizedError, Sendable { - case invalidChannelSecret(index: Int, hexLength: Int) - case invalidContactPublicKey(name: String) - case invalidPathHashMode(name: String, mode: UInt8) - case invalidPrivateKey(hexLength: Int) - case invalidRadioSettings(field: RadioField) - case noAvailableChannelSlot(name: String) - case invalidCoordinate(field: CoordinateField) - case invalidOutPath(name: String) - case contactCapacityExceeded(needed: Int, available: Int) - - public var errorDescription: String? { - switch self { - case .invalidChannelSecret(let index, let hexLength): - "Channel \(index) has invalid secret (\(hexLength) hex chars, expected 32)" - case .invalidContactPublicKey(let name): - "Contact \"\(name)\" has an invalid public key" - case .invalidPathHashMode(let name, let mode): - "Contact \"\(name)\" has unsupported path hash mode \(mode) (expected 0, 1, or 2)" - case .invalidPrivateKey(let hexLength): - "Invalid private key (\(hexLength) hex chars, expected \(ProtocolLimits.privateKeySize * 2))" - case .invalidRadioSettings: - "Radio parameter is outside the supported range" - case .noAvailableChannelSlot(let name): - "No empty channel slot available for \"\(name)\"" - case .invalidCoordinate: - "Coordinate is invalid or out of range" - case .invalidOutPath(let name): - "Contact \"\(name)\" has an invalid routing path" - case .contactCapacityExceeded(let needed, let available): - "Import needs \(needed) free contact slot(s) but only \(available) remain on the device" - } + case invalidChannelSecret(index: Int, hexLength: Int) + case invalidContactPublicKey(name: String) + case invalidPathHashMode(name: String, mode: UInt8) + case invalidPrivateKey(hexLength: Int) + case invalidRadioSettings(field: RadioField) + case noAvailableChannelSlot(name: String) + case invalidCoordinate(field: CoordinateField) + case invalidOutPath(name: String) + case contactCapacityExceeded(needed: Int, available: Int) + + public var errorDescription: String? { + switch self { + case let .invalidChannelSecret(index, hexLength): + "Channel \(index) has invalid secret (\(hexLength) hex chars, expected 32)" + case let .invalidContactPublicKey(name): + "Contact \"\(name)\" has an invalid public key" + case let .invalidPathHashMode(name, mode): + "Contact \"\(name)\" has unsupported path hash mode \(mode) (expected 0, 1, or 2)" + case let .invalidPrivateKey(hexLength): + "Invalid private key (\(hexLength) hex chars, expected \(ProtocolLimits.privateKeySize * 2))" + case .invalidRadioSettings: + "Radio parameter is outside the supported range" + case let .noAvailableChannelSlot(name): + "No empty channel slot available for \"\(name)\"" + case .invalidCoordinate: + "Coordinate is invalid or out of range" + case let .invalidOutPath(name): + "Contact \"\(name)\" has an invalid routing path" + case let .contactCapacityExceeded(needed, available): + "Import needs \(needed) free contact slot(s) but only \(available) remain on the device" } + } } // MARK: - Import Progress @@ -58,17 +58,17 @@ public enum NodeConfigServiceError: Error, LocalizedError, Sendable { /// so the service layer carries no localization; the app layer maps each case to a localized /// description for display. public enum ImportStep: Sendable, Equatable { - case position, otherParameters, privateKey, nodeName - case radioParameters, txPower - case channel(name: String) - case contact(name: String) + case position, otherParameters, privateKey, nodeName + case radioParameters, txPower + case channel(name: String) + case contact(name: String) } /// Reports import progress to the UI. public struct ImportProgress: Sendable { - public let step: ImportStep - public let current: Int - public let total: Int + public let step: ImportStep + public let current: Int + public let total: Int } // MARK: - Import Preview @@ -76,9 +76,9 @@ public struct ImportProgress: Sendable { /// A non-destructive summary of what an import would do, used to drive the confirmation UI /// before any write. Computed by running the same planner the import uses. public struct ImportPreview: Sendable { - /// True when at least one channel write would replace an already-configured slot whose - /// name or secret differs — i.e. the channels section is not purely additive. - public let channelsOverwriteExisting: Bool + /// True when at least one channel write would replace an already-configured slot whose + /// name or secret differs — i.e. the channels section is not purely additive. + public let channelsOverwriteExisting: Bool } // MARK: - Node Config Service @@ -86,324 +86,325 @@ public struct ImportPreview: Sendable { /// Exports device configuration to `MeshCoreNodeConfig` and imports it back, /// handling section filtering, other-params merging, and safe import ordering. public actor NodeConfigService { - private let session: any MeshCoreSessionProtocol - private let settingsService: SettingsService - private let channelService: ChannelService - private let dataStore: any PersistenceStoreProtocol - /// Injected by `ServiceContainer` at construction. - private weak var syncCoordinator: SyncCoordinator? - private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigService") - /// Called after a config import restores a private key, so the connection layer can - /// reconcile device identity. Installed by `ConnectionManager.buildServicesAndSaveDevice`. - private var onPostIdentityImport: (@Sendable () async throws -> UUID?)? - - public init( - session: any MeshCoreSessionProtocol, - settingsService: SettingsService, - channelService: ChannelService, - dataStore: any PersistenceStoreProtocol, - syncCoordinator: SyncCoordinator? - ) { - self.session = session - self.settingsService = settingsService - self.channelService = channelService - self.dataStore = dataStore - self.syncCoordinator = syncCoordinator - } + private let session: any MeshCoreSessionProtocol + private let settingsService: SettingsService + private let channelService: ChannelService + private let dataStore: any PersistenceStoreProtocol + /// Injected by `ServiceContainer` at construction. + private weak var syncCoordinator: SyncCoordinator? + private let logger = Logger(subsystem: "com.mc1", category: "NodeConfigService") + /// Called after a config import restores a private key, so the connection layer can + /// reconcile device identity. Installed by `ConnectionManager.buildServicesAndSaveDevice`. + private var onPostIdentityImport: (@Sendable () async throws -> UUID?)? + + public init( + session: any MeshCoreSessionProtocol, + settingsService: SettingsService, + channelService: ChannelService, + dataStore: any PersistenceStoreProtocol, + syncCoordinator: SyncCoordinator? + ) { + self.session = session + self.settingsService = settingsService + self.channelService = channelService + self.dataStore = dataStore + self.syncCoordinator = syncCoordinator + } + + /// Wires a late-bound callback that fires after `importIdentity` succeeds. + /// The callback is responsible for reconciling the radio's restored + /// `publicKey` with any ghost `Device` row left by a prior "remove from MC1". + /// Its return value, when non-nil, replaces the `radioID` used by all + /// subsequent steps in `importConfig` (channels, contacts). + public func setOnPostIdentityImport( + _ callback: (@Sendable () async throws -> UUID?)? + ) { + onPostIdentityImport = callback + } + + /// Whether a sync coordinator was injected at construction. + var hasSyncCoordinatorWired: Bool { + syncCoordinator != nil + } + + // MARK: - Export + + /// Reads the device state and builds a `MeshCoreNodeConfig`. + /// - Parameter sections: Which sections to include in the export. + /// - Returns: A populated config struct. + public func exportConfig(sections: ConfigSections) async throws -> MeshCoreNodeConfig { + let selfInfo = try await settingsService.getSelfInfo() + + var config = MeshCoreNodeConfig() - /// Wires a late-bound callback that fires after `importIdentity` succeeds. - /// The callback is responsible for reconciling the radio's restored - /// `publicKey` with any ghost `Device` row left by a prior "remove from MC1". - /// Its return value, when non-nil, replaces the `radioID` used by all - /// subsequent steps in `importConfig` (channels, contacts). - public func setOnPostIdentityImport( - _ callback: (@Sendable () async throws -> UUID?)? - ) { - self.onPostIdentityImport = callback + if sections.nodeIdentity { + config.name = selfInfo.name + config.publicKey = selfInfo.publicKey.hexString + // Hardened firmware disables private-key export (`featureDisabled`). Emit name + + // public key in that case rather than failing the whole export. Any other failure + // (transient BLE timeout, device error, cancellation) propagates, so the user sees + // the export fail and retries instead of saving an identity backup missing its key. + do { + let privateKey = try await settingsService.exportPrivateKey() + config.privateKey = privateKey.hexString + } catch SettingsServiceError.sessionError(.featureDisabled) { + logger.warning("Private key export disabled by firmware; exporting identity without it") + } } - /// Whether a sync coordinator was injected at construction. - var hasSyncCoordinatorWired: Bool { syncCoordinator != nil } - - // MARK: - Export - - /// Reads the device state and builds a `MeshCoreNodeConfig`. - /// - Parameter sections: Which sections to include in the export. - /// - Returns: A populated config struct. - public func exportConfig(sections: ConfigSections) async throws -> MeshCoreNodeConfig { - let selfInfo = try await settingsService.getSelfInfo() - - var config = MeshCoreNodeConfig() - - if sections.nodeIdentity { - config.name = selfInfo.name - config.publicKey = selfInfo.publicKey.hexString - // Hardened firmware disables private-key export (`featureDisabled`). Emit name + - // public key in that case rather than failing the whole export. Any other failure - // (transient BLE timeout, device error, cancellation) propagates, so the user sees - // the export fail and retries instead of saving an identity backup missing its key. - do { - let privateKey = try await settingsService.exportPrivateKey() - config.privateKey = privateKey.hexString - } catch SettingsServiceError.sessionError(.featureDisabled) { - logger.warning("Private key export disabled by firmware; exporting identity without it") - } - } - - if sections.radioSettings { - config.radioSettings = Self.buildRadioSettings(from: selfInfo) - } - - if sections.positionSettings { - config.positionSettings = MeshCoreNodeConfig.PositionSettings( - latitude: String(selfInfo.latitude), - longitude: String(selfInfo.longitude) - ) - } - - if sections.otherSettings { - config.otherSettings = Self.buildOtherSettings(from: selfInfo) - } - - if sections.channels { - let capabilities = try await settingsService.queryDevice() - config.channels = try await exportChannels(maxChannels: UInt8(capabilities.maxChannels)) - } - - if sections.contacts { - let meshContacts = try await session.getContacts(since: nil) - config.contacts = meshContacts.map { Self.buildContactConfig(from: $0) } - } - - return config + if sections.radioSettings { + config.radioSettings = Self.buildRadioSettings(from: selfInfo) } - // MARK: - Import - - /// Writes a `MeshCoreNodeConfig` to the device in safe order (radio last). - /// - /// Runs in three phases: a non-destructive **read** of device capabilities and current - /// channels, a pure **validate/plan** pass (``planConfigImport``) - /// that rejects a malformed config before any write, then **execute**. Because everything - /// that can be validated is validated up front, no validation error can land after the - /// identity has already been rotated. - /// - Parameters: - /// - config: The config to import. - /// - sections: Which sections to actually apply. - /// - radioID: The connected device UUID (needed for channel writes). - /// - onProgress: Optional callback for UI progress updates. - public func importConfig( - _ config: MeshCoreNodeConfig, - sections: ConfigSections, - radioID: UUID, - onProgress: (@Sendable (ImportProgress) -> Void)? = nil - ) async throws { - // Read + validate/plan phase (non-destructive): rejects a poison/malformed config before any write. - let plan = try await buildImportPlan(config, sections: sections) - - // Execute phase, driven through the testable `executeConfigImport` seam. The actor supplies - // the concrete write closures; the sequencing, progress, and cancellation live in the seam. - let coordinator = syncCoordinator - try await executeConfigImport( - plan: plan, sections: sections, radioID: radioID, - writers: makeWriters(), logger: logger, onProgress: onProgress, - notifyContactsChanged: { await coordinator?.notifyContactsChanged() } - ) + if sections.positionSettings { + config.positionSettings = MeshCoreNodeConfig.PositionSettings( + latitude: String(selfInfo.latitude), + longitude: String(selfInfo.longitude) + ) } - /// Builds the concrete write closures `executeConfigImport` calls, capturing this actor's - /// services. Each closure performs exactly one device (and, for contacts, database) write. - private func makeWriters() -> ConfigImportWriters { - let settings = settingsService - let channels = channelService - let session = self.session - let store = dataStore - let logger = self.logger - let callback = onPostIdentityImport - return ConfigImportWriters( - getSelfInfo: { try await settings.getSelfInfo() }, - importPrivateKey: { try await settings.importPrivateKey($0) }, - setNodeName: { try await settings.setNodeName($0) }, - setLocation: { try await settings.setLocation(latitude: $0, longitude: $1) }, - setOtherParams: { [self] in try await self.importOtherParams($0) }, - resolveEffectiveRadioID: { - try await resolveEffectiveRadioID(original: $0, didImportPrivateKey: $1, callback: callback) - }, - setRadioParams: { radio in - // bandwidthKHz parameter actually takes Hz (misnomer); pass directly. - try await settings.setRadioParams( - frequencyKHz: radio.frequency, bandwidthKHz: radio.bandwidth, - spreadingFactor: radio.spreadingFactor, codingRate: radio.codingRate - ) - }, - setTxPower: { try await settings.setTxPower($0) }, - setChannel: { radioID, write in - try await channels.setChannelWithSecret( - radioID: radioID, index: write.index, name: write.name, secret: write.secret - ) - }, - addContact: { radioID, contact in - try await session.addContact(contact) - // The device add is the irreversible change. The local row is an idempotent - // (radioID, publicKey) upsert that the next contacts sync reconciles, so a save - // failure here must not mask that the contact already landed on the device. - do { - let frame = contact.toContactFrame() - _ = try await store.saveContact(radioID: radioID, from: frame) - } catch { - logger.error("Contact added to device but local save failed; next sync will reconcile: \(error.localizedDescription)") - } - } - ) + if sections.otherSettings { + config.otherSettings = Self.buildOtherSettings(from: selfInfo) } - /// Total destructive steps for progress reporting, derived entirely from the resolved plan so the - /// bar reflects post-dedup channel/contact counts and validated sections rather than raw config. - /// - /// This must mirror the write gates in `importConfig`: every conditional `progress(...)` call - /// there needs a matching term here, or the progress bar will under- or over-count. Keep the - /// two in sync when adding or removing a write step. Internal (not private) so it is unit-testable. - static func stepCount(for plan: ConfigImportPlan) -> Int { - var count = 0 - if plan.importPrivateKey != nil { count += 1 } - if plan.nodeName != nil { count += 1 } - if plan.position != nil { count += 1 } - if plan.otherSettings != nil { count += 1 } - count += plan.channelWrites.count - count += plan.contactRecords.count - if plan.radioSettings != nil { count += 2 } // radio params + tx power - return count + if sections.channels { + let capabilities = try await settingsService.queryDevice() + config.channels = try await exportChannels(maxChannels: UInt8(capabilities.maxChannels)) } - /// Non-destructively summarizes what an import would do, so the confirmation UI can warn - /// about channel overwrites before any write. Runs the same planner as `importConfig`, so it - /// also surfaces validation errors early. The selection UI shows raw per-section counts; the - /// planner's post-dedup counts are not surfaced here, only the overwrite flag. - public func previewImport( - _ config: MeshCoreNodeConfig, - sections: ConfigSections - ) async throws -> ImportPreview { - let plan = try await buildImportPlan(config, sections: sections) - return ImportPreview(channelsOverwriteExisting: plan.channelsOverwriteExisting) + if sections.contacts { + let meshContacts = try await session.getContacts(since: nil) + config.contacts = meshContacts.map { Self.buildContactConfig(from: $0) } } - // MARK: - Internal Helpers - - /// Read phase shared by `importConfig` and `previewImport`: gathers device capabilities and current - /// state (channel slots, existing contact keys, max TX power) needed to validate the config, then - /// runs the pure planner. Non-destructive. `importConfig` re-runs this at execute time for TOCTOU - /// freshness, so the device reads happen twice across a preview-then-apply cycle; that is an - /// accepted cost on this cold, user-initiated path. - private func buildImportPlan( - _ config: MeshCoreNodeConfig, - sections: ConfigSections - ) async throws -> ConfigImportPlan { - let capabilities = try await settingsService.queryDevice() - let maxChannels = UInt8(capabilities.maxChannels) - let maxContacts = Int(capabilities.maxContacts) - - let existingChannels = sections.channels - ? try await readExistingChannels(maxChannels: maxChannels) - : [] - - // Existing contacts credit firmware updates (which consume no slot) against free capacity, - // so an over-capacity import is rejected up front instead of failing partway through the - // writes. The full records (not just keys) also let the planner drop contacts the device - // already stores byte-for-byte, sparing a redundant /contacts3 commit per unchanged contact. - let existingContacts: [String: MeshContact] = sections.contacts - ? Dictionary( - try await session.getContacts(since: nil).map { ($0.publicKey.hexString, $0) }, - uniquingKeysWith: { _, latest in latest } - ) - : [:] - - // The txPower upper bound is hardware/build-specific, so read the device's max rather than - // assuming a fixed maximum. Only needed when the radio section is selected. - let maxTxPower = sections.radioSettings - ? try await settingsService.getSelfInfo().maxTxPower - : 0 - - return try planConfigImport( - config: config, - sections: sections, - maxChannels: maxChannels, - maxContacts: maxContacts, - maxTxPower: maxTxPower, - existingChannels: existingChannels, - existingContacts: existingContacts + return config + } + + // MARK: - Import + + /// Writes a `MeshCoreNodeConfig` to the device in safe order (radio last). + /// + /// Runs in three phases: a non-destructive **read** of device capabilities and current + /// channels, a pure **validate/plan** pass (``planConfigImport``) + /// that rejects a malformed config before any write, then **execute**. Because everything + /// that can be validated is validated up front, no validation error can land after the + /// identity has already been rotated. + /// - Parameters: + /// - config: The config to import. + /// - sections: Which sections to actually apply. + /// - radioID: The connected device UUID (needed for channel writes). + /// - onProgress: Optional callback for UI progress updates. + public func importConfig( + _ config: MeshCoreNodeConfig, + sections: ConfigSections, + radioID: UUID, + onProgress: (@Sendable (ImportProgress) -> Void)? = nil + ) async throws { + // Read + validate/plan phase (non-destructive): rejects a poison/malformed config before any write. + let plan = try await buildImportPlan(config, sections: sections) + + // Execute phase, driven through the testable `executeConfigImport` seam. The actor supplies + // the concrete write closures; the sequencing, progress, and cancellation live in the seam. + let coordinator = syncCoordinator + try await executeConfigImport( + plan: plan, sections: sections, radioID: radioID, + writers: makeWriters(), logger: logger, onProgress: onProgress, + notifyContactsChanged: { await coordinator?.notifyContactsChanged() } + ) + } + + /// Builds the concrete write closures `executeConfigImport` calls, capturing this actor's + /// services. Each closure performs exactly one device (and, for contacts, database) write. + private func makeWriters() -> ConfigImportWriters { + let settings = settingsService + let channels = channelService + let session = session + let store = dataStore + let logger = logger + let callback = onPostIdentityImport + return ConfigImportWriters( + getSelfInfo: { try await settings.getSelfInfo() }, + importPrivateKey: { try await settings.importPrivateKey($0) }, + setNodeName: { try await settings.setNodeName($0) }, + setLocation: { try await settings.setLocation(latitude: $0, longitude: $1) }, + setOtherParams: { [self] in try await importOtherParams($0) }, + resolveEffectiveRadioID: { + try await resolveEffectiveRadioID(original: $0, didImportPrivateKey: $1, callback: callback) + }, + setRadioParams: { radio in + // bandwidthKHz parameter actually takes Hz (misnomer); pass directly. + try await settings.setRadioParams( + frequencyKHz: radio.frequency, bandwidthKHz: radio.bandwidth, + spreadingFactor: radio.spreadingFactor, codingRate: radio.codingRate ) - } - - /// Reads every channel slot from the device and classifies it as configured or empty. - private func readExistingChannels(maxChannels: UInt8) async throws -> [DeviceChannelSlot] { - var slots: [DeviceChannelSlot] = [] - for index in 0 as UInt8.. Int { + var count = 0 + if plan.importPrivateKey != nil { count += 1 } + if plan.nodeName != nil { count += 1 } + if plan.position != nil { count += 1 } + if plan.otherSettings != nil { count += 1 } + count += plan.channelWrites.count + count += plan.contactRecords.count + if plan.radioSettings != nil { count += 2 } // radio params + tx power + return count + } + + /// Non-destructively summarizes what an import would do, so the confirmation UI can warn + /// about channel overwrites before any write. Runs the same planner as `importConfig`, so it + /// also surfaces validation errors early. The selection UI shows raw per-section counts; the + /// planner's post-dedup counts are not surfaced here, only the overwrite flag. + public func previewImport( + _ config: MeshCoreNodeConfig, + sections: ConfigSections + ) async throws -> ImportPreview { + let plan = try await buildImportPlan(config, sections: sections) + return ImportPreview(channelsOverwriteExisting: plan.channelsOverwriteExisting) + } + + // MARK: - Internal Helpers + + /// Read phase shared by `importConfig` and `previewImport`: gathers device capabilities and current + /// state (channel slots, existing contact keys, max TX power) needed to validate the config, then + /// runs the pure planner. Non-destructive. `importConfig` re-runs this at execute time for TOCTOU + /// freshness, so the device reads happen twice across a preview-then-apply cycle; that is an + /// accepted cost on this cold, user-initiated path. + private func buildImportPlan( + _ config: MeshCoreNodeConfig, + sections: ConfigSections + ) async throws -> ConfigImportPlan { + let capabilities = try await settingsService.queryDevice() + let maxChannels = UInt8(capabilities.maxChannels) + let maxContacts = Int(capabilities.maxContacts) + + let existingChannels = sections.channels + ? try await readExistingChannels(maxChannels: maxChannels) + : [] + + // Existing contacts credit firmware updates (which consume no slot) against free capacity, + // so an over-capacity import is rejected up front instead of failing partway through the + // writes. The full records (not just keys) also let the planner drop contacts the device + // already stores byte-for-byte, sparing a redundant /contacts3 commit per unchanged contact. + let existingContacts: [String: MeshContact] = try await sections.contacts + ? Dictionary( + session.getContacts(since: nil).map { ($0.publicKey.hexString, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + : [:] + + // The txPower upper bound is hardware/build-specific, so read the device's max rather than + // assuming a fixed maximum. Only needed when the radio section is selected. + let maxTxPower = sections.radioSettings + ? try await settingsService.getSelfInfo().maxTxPower + : 0 + + return try planConfigImport( + config: config, + sections: sections, + maxChannels: maxChannels, + maxContacts: maxContacts, + maxTxPower: maxTxPower, + existingChannels: existingChannels, + existingContacts: existingContacts + ) + } + + /// Reads every channel slot from the device and classifies it as configured or empty. + private func readExistingChannels(maxChannels: UInt8) async throws -> [DeviceChannelSlot] { + var slots: [DeviceChannelSlot] = [] + for index in 0 as UInt8.. [MeshCoreNodeConfig.ChannelConfig] { - var channels: [MeshCoreNodeConfig.ChannelConfig] = [] - for index in 0.. [MeshCoreNodeConfig.ChannelConfig] { + var channels: [MeshCoreNodeConfig.ChannelConfig] = [] + for index in 0.. UUID?)? +func resolveEffectiveRadioID( + original: UUID, + didImportPrivateKey: Bool, + callback: (@Sendable () async throws -> UUID?)? ) async throws -> UUID { - guard didImportPrivateKey, let cb = callback else { - return original - } - if let reconciled = try await cb() { - return reconciled - } + guard didImportPrivateKey, let cb = callback else { return original + } + if let reconciled = try await cb() { + return reconciled + } + return original } // MARK: - Execute Orchestration (testable seam) @@ -433,19 +434,19 @@ internal func resolveEffectiveRadioID( /// The concrete device/database write operations `executeConfigImport` performs, one per closure. /// `NodeConfigService` builds these from its services; tests build spies, so the destructive-path /// sequencing can be exercised without a live `MeshCoreSession`. -struct ConfigImportWriters: Sendable { - /// Reads the device's current state so the pref/radio writes below can be diff-gated against - /// it; a read, not a commit, so eliding redundant pref commits dwarfs its cost. - let getSelfInfo: @Sendable () async throws -> SelfInfo - let importPrivateKey: @Sendable (Data) async throws -> Void - let setNodeName: @Sendable (String) async throws -> Void - let setLocation: @Sendable (_ latitude: Double, _ longitude: Double) async throws -> Void - let setOtherParams: @Sendable (MeshCoreNodeConfig.OtherSettings) async throws -> Void - let resolveEffectiveRadioID: @Sendable (_ original: UUID, _ didImportPrivateKey: Bool) async throws -> UUID - let setRadioParams: @Sendable (MeshCoreNodeConfig.RadioSettings) async throws -> Void - let setTxPower: @Sendable (Int8) async throws -> Void - let setChannel: @Sendable (_ radioID: UUID, _ write: ConfigImportPlan.ChannelWrite) async throws -> Void - let addContact: @Sendable (_ radioID: UUID, _ contact: MeshContact) async throws -> Void +struct ConfigImportWriters { + /// Reads the device's current state so the pref/radio writes below can be diff-gated against + /// it; a read, not a commit, so eliding redundant pref commits dwarfs its cost. + let getSelfInfo: @Sendable () async throws -> SelfInfo + let importPrivateKey: @Sendable (Data) async throws -> Void + let setNodeName: @Sendable (String) async throws -> Void + let setLocation: @Sendable (_ latitude: Double, _ longitude: Double) async throws -> Void + let setOtherParams: @Sendable (MeshCoreNodeConfig.OtherSettings) async throws -> Void + let resolveEffectiveRadioID: @Sendable (_ original: UUID, _ didImportPrivateKey: Bool) async throws -> UUID + let setRadioParams: @Sendable (MeshCoreNodeConfig.RadioSettings) async throws -> Void + let setTxPower: @Sendable (Int8) async throws -> Void + let setChannel: @Sendable (_ radioID: UUID, _ write: ConfigImportPlan.ChannelWrite) async throws -> Void + let addContact: @Sendable (_ radioID: UUID, _ contact: MeshContact) async throws -> Void } // MARK: - Pref/radio diff gates (testable seam) @@ -459,31 +460,31 @@ struct ConfigImportWriters: Sendable { /// /// Pure and synchronous so the per-setter decisions can be asserted against a `SelfInfo` fixture. func nodeNameNeedsWrite(_ name: String, current: SelfInfo) -> Bool { - // Compare the truncated name the device actually stores: setNodeName truncates to the firmware - // field width before writing, so a longer name whose stored bytes already match must not re-commit. - name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) != current.name + // Compare the truncated name the device actually stores: setNodeName truncates to the firmware + // field width before writing, so a longer name whose stored bytes already match must not re-commit. + name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) != current.name } func locationNeedsWrite(_ position: ConfigImportPlan.Coordinate, current: SelfInfo) -> Bool { - // Compare the integer the device persists, not raw doubles, so two coordinates that encode - // identically are treated as equal. - PacketBuilder.scaledCoordinate(position.latitude, in: PacketBuilder.latitudeRange) - != PacketBuilder.scaledCoordinate(current.latitude, in: PacketBuilder.latitudeRange) + // Compare the integer the device persists, not raw doubles, so two coordinates that encode + // identically are treated as equal. + PacketBuilder.scaledCoordinate(position.latitude, in: PacketBuilder.latitudeRange) + != PacketBuilder.scaledCoordinate(current.latitude, in: PacketBuilder.latitudeRange) || PacketBuilder.scaledCoordinate(position.longitude, in: PacketBuilder.longitudeRange) - != PacketBuilder.scaledCoordinate(current.longitude, in: PacketBuilder.longitudeRange) + != PacketBuilder.scaledCoordinate(current.longitude, in: PacketBuilder.longitudeRange) } func radioParamsNeedWrite(_ radio: MeshCoreNodeConfig.RadioSettings, current: SelfInfo) -> Bool { - // Compare through the same MHz/kHz scaling the export used, so a re-import of an unchanged - // radio matches. TX power is gated separately (it can legitimately differ alone), so normalize - // it out and let the struct's Equatable cover every other field as the type evolves. - var expected = NodeConfigService.buildRadioSettings(from: current) - expected.txPower = radio.txPower - return radio != expected + // Compare through the same MHz/kHz scaling the export used, so a re-import of an unchanged + // radio matches. TX power is gated separately (it can legitimately differ alone), so normalize + // it out and let the struct's Equatable cover every other field as the type evolves. + var expected = NodeConfigService.buildRadioSettings(from: current) + expected.txPower = radio.txPower + return radio != expected } func txPowerNeedsWrite(_ radio: MeshCoreNodeConfig.RadioSettings, current: SelfInfo) -> Bool { - radio.txPower != current.txPower + radio.txPower != current.txPower } /// Executes a validated `ConfigImportPlan` in safe order: identity (so a private-key import can @@ -494,172 +495,171 @@ func txPowerNeedsWrite(_ radio: MeshCoreNodeConfig.RadioSettings, current: SelfI /// /// Extracted as a free function taking write closures (mirroring `resolveEffectiveRadioID`) so the /// ordering, progress, and cancellation behavior are unit-testable without a live session. -internal func executeConfigImport( - plan: ConfigImportPlan, - sections: ConfigSections, - radioID: UUID, - writers: ConfigImportWriters, - logger: Logger, - onProgress: (@Sendable (ImportProgress) -> Void)?, - notifyContactsChanged: @Sendable () async -> Void = {} +func executeConfigImport( + plan: ConfigImportPlan, + sections: ConfigSections, + radioID: UUID, + writers: ConfigImportWriters, + logger: Logger, + onProgress: (@Sendable (ImportProgress) -> Void)?, + notifyContactsChanged: @Sendable () async -> Void = {} ) async throws { - let totalSteps = NodeConfigService.stepCount(for: plan) - var currentStep = 0 - func progress(_ step: ImportStep) { - currentStep += 1 - onProgress?(ImportProgress(step: step, current: currentStep, total: totalSteps)) - } - func checkCancellation() throws { - guard !Task.isCancelled else { throw CancellationError() } - } - - // Read current device state once so the pref/radio writes below can be diff-gated against it. - // Only fetched when a section that carries a savePrefs commit is present; otherParams gates - // itself inside `importOtherParams` (where the merge lives), so it is not a trigger here. - let needsPrefState = plan.nodeName != nil || plan.position != nil || plan.radioSettings != nil - let prefState = needsPrefState ? try await writers.getSelfInfo() : nil - - var effectiveRadioID = radioID - if let privateKey = plan.importPrivateKey { - try checkCancellation() - try await writers.importPrivateKey(privateKey) - progress(.privateKey) - logger.info("Imported private key") - } - if let name = plan.nodeName { - try checkCancellation() - if prefState.map({ nodeNameNeedsWrite(name, current: $0) }) ?? true { - try await writers.setNodeName(name) - logger.info("Set node name: \(name)") - } else { - logger.info("Skipped node name (already \(name))") - } - progress(.nodeName) - } - if sections.nodeIdentity { - effectiveRadioID = try await writers.resolveEffectiveRadioID(radioID, plan.importPrivateKey != nil) - if effectiveRadioID != radioID { - logger.info("Post-identity reconciliation reassigned radioID to \(effectiveRadioID)") - } - } - - if let position = plan.position { - try checkCancellation() - if prefState.map({ locationNeedsWrite(position, current: $0) }) ?? true { - try await writers.setLocation(position.latitude, position.longitude) - logger.info("Set position: \(position.latitude), \(position.longitude)") - } else { - logger.info("Skipped position (unchanged)") - } - progress(.position) - } - - if let other = plan.otherSettings { - try checkCancellation() - try await writers.setOtherParams(other) - progress(.otherParameters) - logger.info("Set other params") + let totalSteps = NodeConfigService.stepCount(for: plan) + var currentStep = 0 + func progress(_ step: ImportStep) { + currentStep += 1 + onProgress?(ImportProgress(step: step, current: currentStep, total: totalSteps)) + } + func checkCancellation() throws { + guard !Task.isCancelled else { throw CancellationError() } + } + + // Read current device state once so the pref/radio writes below can be diff-gated against it. + // Only fetched when a section that carries a savePrefs commit is present; otherParams gates + // itself inside `importOtherParams` (where the merge lives), so it is not a trigger here. + let needsPrefState = plan.nodeName != nil || plan.position != nil || plan.radioSettings != nil + let prefState = needsPrefState ? try await writers.getSelfInfo() : nil + + var effectiveRadioID = radioID + if let privateKey = plan.importPrivateKey { + try checkCancellation() + try await writers.importPrivateKey(privateKey) + progress(.privateKey) + logger.info("Imported private key") + } + if let name = plan.nodeName { + try checkCancellation() + if prefState.map({ nodeNameNeedsWrite(name, current: $0) }) ?? true { + try await writers.setNodeName(name) + logger.info("Set node name: \(name)") + } else { + logger.info("Skipped node name (already \(name))") } - - for write in plan.channelWrites { - try checkCancellation() - try await writers.setChannel(effectiveRadioID, write) - progress(.channel(name: write.name)) - logger.info("Set channel \(write.index): \(write.name)") + progress(.nodeName) + } + if sections.nodeIdentity { + effectiveRadioID = try await writers.resolveEffectiveRadioID(radioID, plan.importPrivateKey != nil) + if effectiveRadioID != radioID { + logger.info("Post-identity reconciliation reassigned radioID to \(effectiveRadioID)") } - - for contact in plan.contactRecords { - try checkCancellation() - try await writers.addContact(effectiveRadioID, contact) - // writers.addContact only throws if the device add failed; a local-save failure is - // logged and swallowed there, so reaching this line means the contact is on the - // device and the progress yield must fire. - progress(.contact(name: contact.advertisedName)) - logger.info("Imported contact: \(contact.advertisedName)") + } + + if let position = plan.position { + try checkCancellation() + if prefState.map({ locationNeedsWrite(position, current: $0) }) ?? true { + try await writers.setLocation(position.latitude, position.longitude) + logger.info("Set position: \(position.latitude), \(position.longitude)") + } else { + logger.info("Skipped position (unchanged)") } - if sections.contacts { - // Refresh contacts as soon as they are written, before the radio step, so a later radio - // failure does not suppress the UI update for contacts that already landed. - await notifyContactsChanged() + progress(.position) + } + + if let other = plan.otherSettings { + try checkCancellation() + try await writers.setOtherParams(other) + progress(.otherParameters) + logger.info("Set other params") + } + + for write in plan.channelWrites { + try checkCancellation() + try await writers.setChannel(effectiveRadioID, write) + progress(.channel(name: write.name)) + logger.info("Set channel \(write.index): \(write.name)") + } + + for contact in plan.contactRecords { + try checkCancellation() + try await writers.addContact(effectiveRadioID, contact) + // writers.addContact only throws if the device add failed; a local-save failure is + // logged and swallowed there, so reaching this line means the contact is on the + // device and the progress yield must fire. + progress(.contact(name: contact.advertisedName)) + logger.info("Imported contact: \(contact.advertisedName)") + } + if sections.contacts { + // Refresh contacts as soon as they are written, before the radio step, so a later radio + // failure does not suppress the UI update for contacts that already landed. + await notifyContactsChanged() + } + + // Radio goes last (minimizes mesh isolation on BLE disconnect). Params and TX power are gated + // independently: a change to only one re-commits only the pref it touched. + if let radio = plan.radioSettings { + try checkCancellation() + if prefState.map({ radioParamsNeedWrite(radio, current: $0) }) ?? true { + try await writers.setRadioParams(radio) + logger.info("Set radio params") + } else { + logger.info("Skipped radio params (unchanged)") } - - // Radio goes last (minimizes mesh isolation on BLE disconnect). Params and TX power are gated - // independently: a change to only one re-commits only the pref it touched. - if let radio = plan.radioSettings { - try checkCancellation() - if prefState.map({ radioParamsNeedWrite(radio, current: $0) }) ?? true { - try await writers.setRadioParams(radio) - logger.info("Set radio params") - } else { - logger.info("Skipped radio params (unchanged)") - } - progress(.radioParameters) - - try checkCancellation() - if prefState.map({ txPowerNeedsWrite(radio, current: $0) }) ?? true { - do { - try await writers.setTxPower(radio.txPower) - } catch { - // Params already retuned the node; flag that power did not follow so the failure - // isn't mistaken for "radio unchanged." - logger.error("Radio params applied but TX power did not: \(error.localizedDescription)") - throw error - } - logger.info("Set TX power: \(radio.txPower)") - } else { - logger.info("Skipped TX power (already \(radio.txPower))") - } - progress(.txPower) + progress(.radioParameters) + + try checkCancellation() + if prefState.map({ txPowerNeedsWrite(radio, current: $0) }) ?? true { + do { + try await writers.setTxPower(radio.txPower) + } catch { + // Params already retuned the node; flag that power did not follow so the failure + // isn't mistaken for "radio unchanged." + logger.error("Radio params applied but TX power did not: \(error.localizedDescription)") + throw error + } + logger.info("Set TX power: \(radio.txPower)") + } else { + logger.info("Skipped TX power (already \(radio.txPower))") } + progress(.txPower) + } } // MARK: - Static Builders (testable without actor) -extension NodeConfigService { - /// Builds radio settings from SelfInfo. - public static func buildRadioSettings(from info: SelfInfo) -> MeshCoreNodeConfig.RadioSettings { - MeshCoreNodeConfig.RadioSettings( - frequency: UInt32((info.radioFrequency * 1000).rounded()), - bandwidth: UInt32((info.radioBandwidth * 1000).rounded()), - spreadingFactor: info.radioSpreadingFactor, - codingRate: info.radioCodingRate, - txPower: info.txPower - ) +public extension NodeConfigService { + /// Builds radio settings from SelfInfo. + static func buildRadioSettings(from info: SelfInfo) -> MeshCoreNodeConfig.RadioSettings { + MeshCoreNodeConfig.RadioSettings( + frequency: UInt32((info.radioFrequency * 1000).rounded()), + bandwidth: UInt32((info.radioBandwidth * 1000).rounded()), + spreadingFactor: info.radioSpreadingFactor, + codingRate: info.radioCodingRate, + txPower: info.txPower + ) + } + + /// Builds other settings from SelfInfo, matching official companion app format. + static func buildOtherSettings(from info: SelfInfo) -> MeshCoreNodeConfig.OtherSettings { + MeshCoreNodeConfig.OtherSettings( + manualAddContacts: info.manualAddContacts ? 1 : 0, + advertLocationPolicy: info.advertisementLocationPolicy + ) + } + + /// Builds a contact config from a MeshContact. + internal static func buildContactConfig(from contact: MeshContact) -> MeshCoreNodeConfig.ContactConfig { + let outPath: String? = if contact.isFloodPath { + nil + } else if contact.pathByteLength > 0 && !contact.outPath.isEmpty { + contact.outPath.prefix(contact.pathByteLength).hexString + } else { + "" } - /// Builds other settings from SelfInfo, matching official companion app format. - public static func buildOtherSettings(from info: SelfInfo) -> MeshCoreNodeConfig.OtherSettings { - MeshCoreNodeConfig.OtherSettings( - manualAddContacts: info.manualAddContacts ? 1 : 0, - advertLocationPolicy: info.advertisementLocationPolicy - ) - } - - /// Builds a contact config from a MeshContact. - static func buildContactConfig(from contact: MeshContact) -> MeshCoreNodeConfig.ContactConfig { - let outPath: String? - if contact.isFloodPath { - outPath = nil - } else if contact.pathByteLength > 0 && !contact.outPath.isEmpty { - outPath = contact.outPath.prefix(contact.pathByteLength).hexString - } else { - outPath = "" - } - - // Extract hash mode from encoded outPathLength (upper 2 bits) - let pathHashMode: UInt8? = contact.isFloodPath ? nil : contact.outPathLength >> 6 - - return MeshCoreNodeConfig.ContactConfig( - type: contact.typeRawValue, - name: contact.advertisedName, - publicKey: contact.publicKey.hexString, - flags: contact.flags.rawValue, - latitude: String(contact.latitude), - longitude: String(contact.longitude), - lastAdvert: UInt32(contact.lastAdvertisement.timeIntervalSince1970), - lastModified: UInt32(contact.lastModified.timeIntervalSince1970), - outPath: outPath, - pathHashMode: pathHashMode - ) - } + // Extract hash mode from encoded outPathLength (upper 2 bits) + let pathHashMode: UInt8? = contact.isFloodPath ? nil : contact.outPathLength >> 6 + + return MeshCoreNodeConfig.ContactConfig( + type: contact.typeRawValue, + name: contact.advertisedName, + publicKey: contact.publicKey.hexString, + flags: contact.flags.rawValue, + latitude: String(contact.latitude), + longitude: String(contact.longitude), + lastAdvert: UInt32(contact.lastAdvertisement.timeIntervalSince1970), + lastModified: UInt32(contact.lastModified.timeIntervalSince1970), + outPath: outPath, + pathHashMode: pathHashMode + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift b/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift index cdffda91..3be09d52 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift @@ -5,204 +5,203 @@ import Foundation /// mapping, and success/error classification. Builds on `CLIResponse` and /// holds no state, so every function is directly unit-testable. public enum NodeSettingsResponseParser { - - // MARK: - Settings Fields - - /// A node settings field recoverable from an uncorrelated CLI response. - public enum SettingsField: Sendable { - case radio - case txPower - case firmwareVersion - case deviceTime - case latitude - case longitude - case name - case ownerInfo - - /// The CLI query whose response shape this field matches. - var query: String { - switch self { - case .radio: return "get radio" - case .txPower: return "get tx" - case .firmwareVersion: return "ver" - case .deviceTime: return "clock" - case .latitude: return "get lat" - case .longitude: return "get lon" - case .name: return "get name" - case .ownerInfo: return "get owner.info" - } - } + // MARK: - Settings Fields + + /// A node settings field recoverable from an uncorrelated CLI response. + public enum SettingsField: Sendable { + case radio + case txPower + case firmwareVersion + case deviceTime + case latitude + case longitude + case name + case ownerInfo + + /// The CLI query whose response shape this field matches. + var query: String { + switch self { + case .radio: "get radio" + case .txPower: "get tx" + case .firmwareVersion: "ver" + case .deviceTime: "clock" + case .latitude: "get lat" + case .longitude: "get lon" + case .name: "get name" + case .ownerInfo: "get owner.info" + } } - - /// A parsed value for one of the shared settings fields. - public enum SettingsValue: Equatable, Sendable { - case radio(frequency: Double, bandwidth: Double, spreadingFactor: Int, codingRate: Int) - case txPower(Int) - case firmwareVersion(String) - case deviceTime(String) - case latitude(Double) - case longitude(Double) - case name(String) - case ownerInfo(String) + } + + /// A parsed value for one of the shared settings fields. + public enum SettingsValue: Equatable, Sendable { + case radio(frequency: Double, bandwidth: Double, spreadingFactor: Int, codingRate: Int) + case txPower(Int) + case firmwareVersion(String) + case deviceTime(String) + case latitude(Double) + case longitude(Double) + case name(String) + case ownerInfo(String) + } + + /// Matches an uncorrelated (late) CLI response against the given fields in + /// order and returns the first field value it parses as. + /// + /// Order matters: `name` matches any free-form text, so callers must list + /// numeric fields (`latitude`, `longitude`) before it to avoid capturing a + /// stray number as the node name. + public static func firstSettingsValue( + in response: String, + checking fields: [SettingsField] + ) -> SettingsValue? { + for field in fields { + let parsed = CLIResponse.parse(response, forQuery: field.query) + switch (field, parsed) { + case let (.radio, .radio(frequency, bandwidth, spreadingFactor, codingRate)): + return .radio( + frequency: frequency, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate + ) + case let (.txPower, .txPower(power)): + return .txPower(power) + case let (.firmwareVersion, .version(version)): + return .firmwareVersion(version) + case let (.deviceTime, .deviceTime(time)): + return .deviceTime(time) + case let (.latitude, .latitude(latitude)): + return .latitude(latitude) + case let (.longitude, .longitude(longitude)): + return .longitude(longitude) + case let (.name, .name(name)): + return .name(name) + case let (.ownerInfo, .ownerInfo(info)): + return .ownerInfo(info) + default: + continue + } } - - /// Matches an uncorrelated (late) CLI response against the given fields in - /// order and returns the first field value it parses as. - /// - /// Order matters: `name` matches any free-form text, so callers must list - /// numeric fields (`latitude`, `longitude`) before it to avoid capturing a - /// stray number as the node name. - public static func firstSettingsValue( - in response: String, - checking fields: [SettingsField] - ) -> SettingsValue? { - for field in fields { - let parsed = CLIResponse.parse(response, forQuery: field.query) - switch (field, parsed) { - case (.radio, .radio(let frequency, let bandwidth, let spreadingFactor, let codingRate)): - return .radio( - frequency: frequency, - bandwidth: bandwidth, - spreadingFactor: spreadingFactor, - codingRate: codingRate - ) - case (.txPower, .txPower(let power)): - return .txPower(power) - case (.firmwareVersion, .version(let version)): - return .firmwareVersion(version) - case (.deviceTime, .deviceTime(let time)): - return .deviceTime(time) - case (.latitude, .latitude(let latitude)): - return .latitude(latitude) - case (.longitude, .longitude(let longitude)): - return .longitude(longitude) - case (.name, .name(let name)): - return .name(name) - case (.ownerInfo, .ownerInfo(let info)): - return .ownerInfo(info) - default: - continue - } - } - return nil + return nil + } + + // MARK: - Behavior Fields + + /// A repeater/room behavior field recovered from a late CLI response. + public enum BehaviorValue: Equatable, Sendable { + case advertInterval(Int) + case floodAdvertInterval(Int) + case floodMax(Int) + } + + /// Try to parse a late response as one of the shared behavior fields. + /// Returns `nil` if the response didn't match any field that's still missing. + public static func behaviorLateResponse( + _ response: String, + hasAdvertInterval: Bool, + hasFloodInterval: Bool, + hasFloodMaxHops: Bool + ) -> BehaviorValue? { + if !hasAdvertInterval { + if case let .advertInterval(interval) = CLIResponse.parse(response, forQuery: "get advert.interval") { + return .advertInterval(interval) + } } - - // MARK: - Behavior Fields - - /// A repeater/room behavior field recovered from a late CLI response. - public enum BehaviorValue: Equatable, Sendable { - case advertInterval(Int) - case floodAdvertInterval(Int) - case floodMax(Int) + if !hasFloodInterval { + if case let .floodAdvertInterval(interval) = CLIResponse.parse( + response, forQuery: "get flood.advert.interval" + ) { + return .floodAdvertInterval(interval) + } } - - /// Try to parse a late response as one of the shared behavior fields. - /// Returns `nil` if the response didn't match any field that's still missing. - public static func behaviorLateResponse( - _ response: String, - hasAdvertInterval: Bool, - hasFloodInterval: Bool, - hasFloodMaxHops: Bool - ) -> BehaviorValue? { - if !hasAdvertInterval { - if case .advertInterval(let interval) = CLIResponse.parse(response, forQuery: "get advert.interval") { - return .advertInterval(interval) - } - } - if !hasFloodInterval { - if case .floodAdvertInterval(let interval) = CLIResponse.parse( - response, forQuery: "get flood.advert.interval" - ) { - return .floodAdvertInterval(interval) - } - } - if !hasFloodMaxHops { - if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { - return .floodMax(hops) - } - } - return nil + if !hasFloodMaxHops { + if case let .floodMax(hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + return .floodMax(hops) + } } - - // MARK: - Device Clock - - private static let clockResponseDateFormat = "HH:mm d/M/yyyy" - - /// Parses a firmware clock response like "06:40 - 18/4/2025 UTC" into a `Date`. - /// Returns `nil` when the text doesn't carry the expected UTC clock shape. - public static func utcDate(fromClockResponse response: String) -> Date? { - // Regex isn't Sendable, so the literal lives here instead of in a static. - let clockResponseRegex = /(\d{1,2}:\d{2}) - (\d{1,2}\/\d{1,2}\/\d{4}) UTC/ - guard let match = response.firstMatch(of: clockResponseRegex) else { return nil } - - // A fresh formatter per call keeps this Sendable; clock parsing is rare. - let formatter = DateFormatter() - formatter.dateFormat = clockResponseDateFormat - formatter.timeZone = TimeZone(identifier: "UTC") - return formatter.date(from: "\(match.output.1) \(match.output.2)") - } - - // MARK: - Clock Sync - - /// Outcome of a `clock sync` command response. - public enum ClockSyncOutcome: Equatable, Sendable { - case synced - /// Firmware refused the sync because its clock is ahead of the phone's. - case clockAhead - /// Firmware reported an error; `message` is the response with the - /// "ERR: " prefix stripped and may be empty. - case failed(message: String) - case unexpected - } - - private static let clockAheadErrorFragment = "clock cannot go backwards" - private static let cliErrorPrefix = "ERR: " - - /// Classifies a `clock sync` response into a typed outcome. - public static func classifyClockSyncResponse(_ response: String) -> ClockSyncOutcome { - switch CLIResponse.parse(response) { - case .ok: - return .synced - case .error(let message): - if message.contains(clockAheadErrorFragment) { - return .clockAhead - } - return .failed(message: message.replacing(cliErrorPrefix, with: "")) - default: - return .unexpected - } + return nil + } + + // MARK: - Device Clock + + private static let clockResponseDateFormat = "HH:mm d/M/yyyy" + + /// Parses a firmware clock response like "06:40 - 18/4/2025 UTC" into a `Date`. + /// Returns `nil` when the text doesn't carry the expected UTC clock shape. + public static func utcDate(fromClockResponse response: String) -> Date? { + // Regex isn't Sendable, so the literal lives here instead of in a static. + let clockResponseRegex = /(\d{1,2}:\d{2}) - (\d{1,2}\/\d{1,2}\/\d{4}) UTC/ + guard let match = response.firstMatch(of: clockResponseRegex) else { return nil } + + // A fresh formatter per call keeps this Sendable; clock parsing is rare. + let formatter = DateFormatter() + formatter.dateFormat = clockResponseDateFormat + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.date(from: "\(match.output.1) \(match.output.2)") + } + + // MARK: - Clock Sync + + /// Outcome of a `clock sync` command response. + public enum ClockSyncOutcome: Equatable, Sendable { + case synced + /// Firmware refused the sync because its clock is ahead of the phone's. + case clockAhead + /// Firmware reported an error; `message` is the response with the + /// "ERR: " prefix stripped and may be empty. + case failed(message: String) + case unexpected + } + + private static let clockAheadErrorFragment = "clock cannot go backwards" + private static let cliErrorPrefix = "ERR: " + + /// Classifies a `clock sync` response into a typed outcome. + public static func classifyClockSyncResponse(_ response: String) -> ClockSyncOutcome { + switch CLIResponse.parse(response) { + case .ok: + return .synced + case let .error(message): + if message.contains(clockAheadErrorFragment) { + return .clockAhead + } + return .failed(message: message.replacing(cliErrorPrefix, with: "")) + default: + return .unexpected } - - // MARK: - Password - - /// Firmware echoes "password now: {pw}" on success instead of "OK". - private static let passwordChangedPrefix = "password now:" - - /// Whether a `password` command response indicates the change was accepted. - public static func isPasswordChangeSuccessful(_ response: String) -> Bool { - switch CLIResponse.parse(response) { - case .ok: - return true - case .raw(let text): - return text.hasPrefix(passwordChangedPrefix) - default: - return false - } + } + + // MARK: - Password + + /// Firmware echoes "password now: {pw}" on success instead of "OK". + private static let passwordChangedPrefix = "password now:" + + /// Whether a `password` command response indicates the change was accepted. + public static func isPasswordChangeSuccessful(_ response: String) -> Bool { + switch CLIResponse.parse(response) { + case .ok: + true + case let .raw(text): + text.hasPrefix(passwordChangedPrefix) + default: + false } + } - // MARK: - Owner Info + // MARK: - Owner Info - /// Firmware stores owner info as a single line with "|" separating rows. - private static let ownerInfoWireSeparator = "|" - private static let ownerInfoDisplaySeparator = "\n" + /// Firmware stores owner info as a single line with "|" separating rows. + private static let ownerInfoWireSeparator = "|" + private static let ownerInfoDisplaySeparator = "\n" - /// Maps the wire form ("|"-separated) to the multi-line display form. - public static func displayOwnerInfo(fromWire wire: String) -> String { - wire.replacing(ownerInfoWireSeparator, with: ownerInfoDisplaySeparator) - } + /// Maps the wire form ("|"-separated) to the multi-line display form. + public static func displayOwnerInfo(fromWire wire: String) -> String { + wire.replacing(ownerInfoWireSeparator, with: ownerInfoDisplaySeparator) + } - /// Maps the multi-line display form back to the "|"-separated wire form. - public static func wireOwnerInfo(fromDisplay display: String) -> String { - display.replacing(ownerInfoDisplaySeparator, with: ownerInfoWireSeparator) - } + /// Maps the multi-line display form back to the "|"-separated wire form. + public static func wireOwnerInfo(fromDisplay display: String) -> String { + display.replacing(ownerInfoDisplaySeparator, with: ownerInfoWireSeparator) + } } diff --git a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift index 81cb1d26..2e185676 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift @@ -3,77 +3,77 @@ import OSLog /// Service for managing node status snapshots with throttled capture. public actor NodeSnapshotService { - private let dataStore: any NodeSnapshotPersisting - private let logger = Logger(subsystem: "com.mc1", category: "NodeSnapshotService") + private let dataStore: any NodeSnapshotPersisting + private let logger = Logger(subsystem: "com.mc1", category: "NodeSnapshotService") - init(dataStore: any NodeSnapshotPersisting) { - self.dataStore = dataStore - } + init(dataStore: any NodeSnapshotPersisting) { + self.dataStore = dataStore + } - /// Capture a status, telemetry, and/or neighbor reading for a node, enriching - /// the latest in-window snapshot or inserting a new one. Returns the snapshot - /// ID so callers can target later enrichment, or nil on persistence failure. - /// The throttle check and the write are atomic in the store, so concurrent - /// captures never duplicate an in-window row. - public func recordSnapshot( - nodePublicKey: Data, - status: NodeStatusMetrics? = nil, - telemetry: [TelemetrySnapshotEntry]? = nil, - neighbors: [NeighborSnapshotEntry]? = nil - ) async -> UUID? { - do { - return try await dataStore.recordNodeStatusSnapshot( - nodePublicKey: nodePublicKey, - status: status, - telemetry: telemetry, - neighbors: neighbors - ) - } catch { - logger.error("Failed to record snapshot: \(error)") - return nil - } + /// Capture a status, telemetry, and/or neighbor reading for a node, enriching + /// the latest in-window snapshot or inserting a new one. Returns the snapshot + /// ID so callers can target later enrichment, or nil on persistence failure. + /// The throttle check and the write are atomic in the store, so concurrent + /// captures never duplicate an in-window row. + public func recordSnapshot( + nodePublicKey: Data, + status: NodeStatusMetrics? = nil, + telemetry: [TelemetrySnapshotEntry]? = nil, + neighbors: [NeighborSnapshotEntry]? = nil + ) async -> UUID? { + do { + return try await dataStore.recordNodeStatusSnapshot( + nodePublicKey: nodePublicKey, + status: status, + telemetry: telemetry, + neighbors: neighbors + ) + } catch { + logger.error("Failed to record snapshot: \(error)") + return nil } + } - /// Fetch the most recent snapshot carrying neighbor data, for neighbor delta - /// display. Skips status- or telemetry-only rows and the current in-window capture. - public func previousNeighborSnapshot(for nodePublicKey: Data) async -> NodeStatusSnapshotDTO? { - do { - return try await dataStore.fetchPreviousNeighborSnapshot(nodePublicKey: nodePublicKey) - } catch { - logger.error("Failed to fetch previous neighbor snapshot: \(error)") - return nil - } + /// Fetch the most recent snapshot carrying neighbor data, for neighbor delta + /// display. Skips status- or telemetry-only rows and the current in-window capture. + public func previousNeighborSnapshot(for nodePublicKey: Data) async -> NodeStatusSnapshotDTO? { + do { + return try await dataStore.fetchPreviousNeighborSnapshot(nodePublicKey: nodePublicKey) + } catch { + logger.error("Failed to fetch previous neighbor snapshot: \(error)") + return nil } + } - /// Fetch the most recent snapshot carrying status fields, for the status delta. - /// Skips neighbor- or telemetry-only rows so the delta is taken against the - /// previous actual status reading rather than blanking out. - public func previousStatusSnapshot(for nodePublicKey: Data, before date: Date) async -> NodeStatusSnapshotDTO? { - do { - return try await dataStore.fetchPreviousStatusSnapshot(nodePublicKey: nodePublicKey, before: date) - } catch { - logger.error("Failed to fetch previous status snapshot: \(error)") - return nil - } + /// Fetch the most recent snapshot carrying status fields, for the status delta. + /// Skips neighbor- or telemetry-only rows so the delta is taken against the + /// previous actual status reading rather than blanking out. + public func previousStatusSnapshot(for nodePublicKey: Data, before date: Date) async -> NodeStatusSnapshotDTO? { + do { + return try await dataStore.fetchPreviousStatusSnapshot(nodePublicKey: nodePublicKey, before: date) + } catch { + logger.error("Failed to fetch previous status snapshot: \(error)") + return nil } + } - /// Fetch all snapshots for a node, optionally filtered by date range. - public func fetchSnapshots(for nodePublicKey: Data, since: Date? = nil) async -> [NodeStatusSnapshotDTO] { - do { - return try await dataStore.fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: since) - } catch { - logger.error("Failed to fetch snapshots: \(error)") - return [] - } + /// Fetch all snapshots for a node, optionally filtered by date range. + public func fetchSnapshots(for nodePublicKey: Data, since: Date? = nil) async -> [NodeStatusSnapshotDTO] { + do { + return try await dataStore.fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: since) + } catch { + logger.error("Failed to fetch snapshots: \(error)") + return [] } + } - /// Delete snapshots older than the given date. - public func pruneOldSnapshots(olderThan date: Date) async { - do { - try await dataStore.deleteOldNodeStatusSnapshots(olderThan: date) - logger.info("Pruned snapshots older than \(date)") - } catch { - logger.error("Failed to prune old snapshots: \(error)") - } + /// Delete snapshots older than the given date. + public func pruneOldSnapshots(olderThan date: Date) async { + do { + try await dataStore.deleteOldNodeStatusSnapshots(olderThan: date) + logger.info("Pruned snapshots older than \(date)") + } catch { + logger.error("Failed to prune old snapshots: \(error)") } + } } diff --git a/MC1Services/Sources/MC1Services/Services/NotificationActionHandler.swift b/MC1Services/Sources/MC1Services/Services/NotificationActionHandler.swift index 168e26a8..576a8fe5 100644 --- a/MC1Services/Sources/MC1Services/Services/NotificationActionHandler.swift +++ b/MC1Services/Sources/MC1Services/Services/NotificationActionHandler.swift @@ -9,237 +9,238 @@ import os /// `configure(isConnectionReady:localNodeName:)`. @MainActor public final class NotificationActionHandler { - - private static let logger = Logger(subsystem: "com.mc1", category: "NotificationActionHandler") - - // MARK: - Reaction Preview Truncation - - nonisolated static let reactionPreviewMaxLength = 50 - nonisolated static let reactionPreviewKeepLength = 47 - nonisolated static let reactionPreviewEllipsis = "..." - - /// Truncates a reacted-to message for display in the notification body. - nonisolated static func reactionPreview(for text: String) -> String { - text.count > reactionPreviewMaxLength - ? String(text.prefix(reactionPreviewKeepLength)) + reactionPreviewEllipsis - : text + private static let logger = Logger(subsystem: "com.mc1", category: "NotificationActionHandler") + + // MARK: - Reaction Preview Truncation + + nonisolated static let reactionPreviewMaxLength = 50 + nonisolated static let reactionPreviewKeepLength = 47 + nonisolated static let reactionPreviewEllipsis = "..." + + /// Truncates a reacted-to message for display in the notification body. + nonisolated static func reactionPreview(for text: String) -> String { + text.count > reactionPreviewMaxLength + ? String(text.prefix(reactionPreviewKeepLength)) + reactionPreviewEllipsis + : text + } + + // MARK: - Dependencies + + private let dataStore: any PersistenceStoreProtocol + private let messageService: MessageService + private let notificationService: NotificationService + private let roomServerService: RoomServerService + private let syncCoordinator: SyncCoordinator + + /// Whether the connection is ready for sends. Injected as a closure so + /// every call reads the live app-facing connection state. + private var isConnectionReady: @MainActor () -> Bool = { false } + + /// The connected device's node name, used to suppress self-reaction + /// notifications. Nil means `configure` has not yet been called; a + /// non-nil closure that returns nil means configured but the device name + /// is not yet known. Injected as a closure because identity reconciliation + /// can refresh the connected device at runtime. + private var localNodeName: (@MainActor () -> String?)? + + public init( + dataStore: any PersistenceStoreProtocol, + messageService: MessageService, + notificationService: NotificationService, + roomServerService: RoomServerService, + syncCoordinator: SyncCoordinator + ) { + self.dataStore = dataStore + self.messageService = messageService + self.notificationService = notificationService + self.roomServerService = roomServerService + self.syncCoordinator = syncCoordinator + } + + /// Injects the app-layer inputs. Idempotent; re-run per connection when + /// notification handling is configured. + public func configure( + isConnectionReady: @escaping @MainActor () -> Bool, + localNodeName: @escaping @MainActor () -> String? + ) { + self.isConnectionReady = isConnectionReady + self.localNodeName = localNodeName + } + + /// Whether `configure` has been called. Used to distinguish the pre-wiring + /// window from the steady-state where node name may legitimately be nil. + var isConfigured: Bool { + localNodeName != nil + } + + // MARK: - Quick Reply + + public func handleQuickReply(contactID: UUID, text: String) async { + guard let contact = try? await dataStore.fetchContact(id: contactID) else { return } + + if isConnectionReady() { + do { + _ = try await messageService.sendDirectMessage(text: text, to: contact) + + // Clear unread state - user replied so they've seen the chat + try? await dataStore.clearUnreadCount(contactID: contactID) + await notificationService.removeDeliveredNotifications(forContactID: contactID) + await notificationService.updateBadgeCount() + syncCoordinator.notifyConversationsChanged() + return + } catch { + // Fall through to draft handling + } } - // MARK: - Dependencies - - private let dataStore: any PersistenceStoreProtocol - private let messageService: MessageService - private let notificationService: NotificationService - private let roomServerService: RoomServerService - private let syncCoordinator: SyncCoordinator - - /// Whether the connection is ready for sends. Injected as a closure so - /// every call reads the live app-facing connection state. - private var isConnectionReady: @MainActor () -> Bool = { false } - - /// The connected device's node name, used to suppress self-reaction - /// notifications. Nil means `configure` has not yet been called; a - /// non-nil closure that returns nil means configured but the device name - /// is not yet known. Injected as a closure because identity reconciliation - /// can refresh the connected device at runtime. - private var localNodeName: (@MainActor () -> String?)? - - public init( - dataStore: any PersistenceStoreProtocol, - messageService: MessageService, - notificationService: NotificationService, - roomServerService: RoomServerService, - syncCoordinator: SyncCoordinator - ) { - self.dataStore = dataStore - self.messageService = messageService - self.notificationService = notificationService - self.roomServerService = roomServerService - self.syncCoordinator = syncCoordinator + notificationService.saveDraft(for: contactID, text: text) + await notificationService.postQuickReplyFailedNotification( + contactName: contact.displayName, + contactID: contactID + ) + } + + public func handleChannelQuickReply(radioID: UUID, channelIndex: UInt8, text: String) async { + // Fetch channel for display name in failure notification + let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) + let channelName = channelDisplayName(name: channel?.name, index: channelIndex) + + guard isConnectionReady() else { + await notificationService.postChannelQuickReplyFailedNotification( + channelName: channelName, + radioID: radioID, + channelIndex: channelIndex + ) + return } - /// Injects the app-layer inputs. Idempotent; re-run per connection when - /// notification handling is configured. - public func configure( - isConnectionReady: @escaping @MainActor () -> Bool, - localNodeName: @escaping @MainActor () -> String? - ) { - self.isConnectionReady = isConnectionReady - self.localNodeName = localNodeName + do { + _ = try await messageService.sendChannelMessage( + text: text, + channelIndex: channelIndex, + radioID: radioID + ) + + // Clear unread state - user replied so they've seen the channel + try? await dataStore.clearChannelUnreadCount(radioID: radioID, index: channelIndex) + await notificationService.removeDeliveredNotifications( + forChannelIndex: channelIndex, + radioID: radioID + ) + await notificationService.updateBadgeCount() + syncCoordinator.notifyConversationsChanged() + } catch { + await notificationService.postChannelQuickReplyFailedNotification( + channelName: channelName, + radioID: radioID, + channelIndex: channelIndex + ) } - - /// Whether `configure` has been called. Used to distinguish the pre-wiring - /// window from the steady-state where node name may legitimately be nil. - var isConfigured: Bool { localNodeName != nil } - - // MARK: - Quick Reply - - public func handleQuickReply(contactID: UUID, text: String) async { - guard let contact = try? await dataStore.fetchContact(id: contactID) else { return } - - if isConnectionReady() { - do { - _ = try await messageService.sendDirectMessage(text: text, to: contact) - - // Clear unread state - user replied so they've seen the chat - try? await dataStore.clearUnreadCount(contactID: contactID) - await notificationService.removeDeliveredNotifications(forContactID: contactID) - await notificationService.updateBadgeCount() - syncCoordinator.notifyConversationsChanged() - return - } catch { - // Fall through to draft handling - } - } - - notificationService.saveDraft(for: contactID, text: text) - await notificationService.postQuickReplyFailedNotification( - contactName: contact.displayName, - contactID: contactID - ) + } + + /// Resolves a channel's display name, preferring the stored name, then + /// the localized fallback, then a last-resort English literal. + func channelDisplayName(name: String?, index: UInt8) -> String { + name + ?? notificationService.strings?.defaultChannelName(index: Int(index)) + ?? "Channel \(index)" + } + + // MARK: - Mark as Read + + public func handleMarkAsRead(contactID: UUID, messageID: UUID) async { + do { + try await dataStore.markMessageAsRead(id: messageID) + try await dataStore.clearUnreadCount(contactID: contactID) + notificationService.removeDeliveredNotification(messageID: messageID) + await notificationService.updateBadgeCount() + syncCoordinator.notifyConversationsChanged() + } catch { + // Silently ignore } - - public func handleChannelQuickReply(radioID: UUID, channelIndex: UInt8, text: String) async { - // Fetch channel for display name in failure notification - let channel = try? await dataStore.fetchChannel(radioID: radioID, index: channelIndex) - let channelName = channelDisplayName(name: channel?.name, index: channelIndex) - - guard isConnectionReady() else { - await notificationService.postChannelQuickReplyFailedNotification( - channelName: channelName, - radioID: radioID, - channelIndex: channelIndex - ) - return - } - - do { - _ = try await messageService.sendChannelMessage( - text: text, - channelIndex: channelIndex, - radioID: radioID - ) - - // Clear unread state - user replied so they've seen the channel - try? await dataStore.clearChannelUnreadCount(radioID: radioID, index: channelIndex) - await notificationService.removeDeliveredNotifications( - forChannelIndex: channelIndex, - radioID: radioID - ) - await notificationService.updateBadgeCount() - syncCoordinator.notifyConversationsChanged() - } catch { - await notificationService.postChannelQuickReplyFailedNotification( - channelName: channelName, - radioID: radioID, - channelIndex: channelIndex - ) - } + } + + public func handleChannelMarkAsRead(radioID: UUID, channelIndex: UInt8, messageID: UUID) async { + do { + try await dataStore.markMessageAsRead(id: messageID) + try await dataStore.clearChannelUnreadCount(radioID: radioID, index: channelIndex) + notificationService.removeDeliveredNotification(messageID: messageID) + await notificationService.updateBadgeCount() + syncCoordinator.notifyConversationsChanged() + } catch { + // Silently ignore } - - /// Resolves a channel's display name, preferring the stored name, then - /// the localized fallback, then a last-resort English literal. - func channelDisplayName(name: String?, index: UInt8) -> String { - name - ?? notificationService.strings?.defaultChannelName(index: Int(index)) - ?? "Channel \(index)" + } + + public func handleRoomMarkAsRead(sessionID: UUID, messageID: UUID) async { + do { + try await roomServerService.markAsRead(sessionID: sessionID) + notificationService.removeDeliveredNotification(messageID: messageID) + await notificationService.updateBadgeCount() + syncCoordinator.notifyConversationsChanged() + } catch { + // Silently ignore + } + } + + // MARK: - Reactions + + /// Handle posting a notification when someone reacts to the user's message + public func handleReactionNotification(messageID: UUID) async { + // Suppress the notification entirely when configure() has not yet been + // called. Posting during that window risks notifying the user about their + // own reaction; missing a stranger's reaction for a moment is harmless. + guard let localNodeNameClosure = localNodeName else { + Self.logger.debug("Reaction notification suppressed: handler not yet configured") + return } - // MARK: - Mark as Read - - public func handleMarkAsRead(contactID: UUID, messageID: UUID) async { - do { - try await dataStore.markMessageAsRead(id: messageID) - try await dataStore.clearUnreadCount(contactID: contactID) - notificationService.removeDeliveredNotification(messageID: messageID) - await notificationService.updateBadgeCount() - syncCoordinator.notifyConversationsChanged() - } catch { - // Silently ignore - } + // Fetch the message to check if it's outgoing + guard let message = try? await dataStore.fetchMessage(id: messageID), + message.direction == .outgoing else { + return } - public func handleChannelMarkAsRead(radioID: UUID, channelIndex: UInt8, messageID: UUID) async { - do { - try await dataStore.markMessageAsRead(id: messageID) - try await dataStore.clearChannelUnreadCount(radioID: radioID, index: channelIndex) - notificationService.removeDeliveredNotification(messageID: messageID) - await notificationService.updateBadgeCount() - syncCoordinator.notifyConversationsChanged() - } catch { - // Silently ignore - } + // Fetch the latest reaction for this message + guard let reactions = try? await dataStore.fetchReactions(for: messageID, limit: 1), + let latestReaction = reactions.first else { + return } - public func handleRoomMarkAsRead(sessionID: UUID, messageID: UUID) async { - do { - try await roomServerService.markAsRead(sessionID: sessionID) - notificationService.removeDeliveredNotification(messageID: messageID) - await notificationService.updateBadgeCount() - syncCoordinator.notifyConversationsChanged() - } catch { - // Silently ignore - } + // Check if this is a self-reaction (user reacting to their own message) + if let nodeName = localNodeNameClosure(), + latestReaction.senderName == nodeName { + return } - // MARK: - Reactions - - /// Handle posting a notification when someone reacts to the user's message - public func handleReactionNotification(messageID: UUID) async { - // Suppress the notification entirely when configure() has not yet been - // called. Posting during that window risks notifying the user about their - // own reaction; missing a stranger's reaction for a moment is harmless. - guard let localNodeNameClosure = localNodeName else { - Self.logger.debug("Reaction notification suppressed: handler not yet configured") - return - } - - // Fetch the message to check if it's outgoing - guard let message = try? await dataStore.fetchMessage(id: messageID), - message.direction == .outgoing else { - return - } - - // Fetch the latest reaction for this message - guard let reactions = try? await dataStore.fetchReactions(for: messageID, limit: 1), - let latestReaction = reactions.first else { - return - } - - // Check if this is a self-reaction (user reacting to their own message) - if let nodeName = localNodeNameClosure(), - latestReaction.senderName == nodeName { - return - } - - // Check mute status based on message type - let isMuted: Bool - if let contactID = message.contactID { - let contact = try? await dataStore.fetchContact(id: contactID) - isMuted = contact?.isMuted ?? false - } else if let channelIndex = message.channelIndex { - let channel = try? await dataStore.fetchChannel(radioID: message.radioID, index: channelIndex) - isMuted = channel?.isMuted ?? false - } else { - isMuted = false - } - - guard !isMuted else { return } - - let truncatedPreview = Self.reactionPreview(for: message.text) - let body = notificationService.strings?.reactionNotificationBody( - emoji: latestReaction.emoji, - messagePreview: truncatedPreview - ) ?? "Reacted \(latestReaction.emoji) to your message: \"\(truncatedPreview)\"" - - // Post the notification - await notificationService.postReactionNotification( - reactorName: latestReaction.senderName, - body: body, - messageID: messageID, - contactID: message.contactID, - channelIndex: message.channelIndex, - radioID: message.channelIndex != nil ? message.radioID : nil - ) + // Check mute status based on message type + let isMuted: Bool + if let contactID = message.contactID { + let contact = try? await dataStore.fetchContact(id: contactID) + isMuted = contact?.isMuted ?? false + } else if let channelIndex = message.channelIndex { + let channel = try? await dataStore.fetchChannel(radioID: message.radioID, index: channelIndex) + isMuted = channel?.isMuted ?? false + } else { + isMuted = false } + + guard !isMuted else { return } + + let truncatedPreview = Self.reactionPreview(for: message.text) + let body = notificationService.strings?.reactionNotificationBody( + emoji: latestReaction.emoji, + messagePreview: truncatedPreview + ) ?? "Reacted \(latestReaction.emoji) to your message: \"\(truncatedPreview)\"" + + // Post the notification + await notificationService.postReactionNotification( + reactorName: latestReaction.senderName, + body: body, + messageID: messageID, + contactID: message.contactID, + channelIndex: message.channelIndex, + radioID: message.channelIndex != nil ? message.radioID : nil + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/NotificationService.swift b/MC1Services/Sources/MC1Services/Services/NotificationService.swift index 34c1a97e..ea815cf7 100644 --- a/MC1Services/Sources/MC1Services/Services/NotificationService.swift +++ b/MC1Services/Sources/MC1Services/Services/NotificationService.swift @@ -1,23 +1,23 @@ import Foundation -import UserNotifications import os +import UserNotifications // MARK: - Notification Categories /// Notification category identifiers -enum NotificationCategory: String, Sendable { - case directMessage = "DIRECT_MESSAGE" - case channelMessage = "CHANNEL_MESSAGE" - case roomMessage = "ROOM_MESSAGE" - case reaction = "REACTION" - case lowBattery = "LOW_BATTERY" +enum NotificationCategory: String { + case directMessage = "DIRECT_MESSAGE" + case channelMessage = "CHANNEL_MESSAGE" + case roomMessage = "ROOM_MESSAGE" + case reaction = "REACTION" + case lowBattery = "LOW_BATTERY" } /// Notification action identifiers -enum NotificationAction: String, Sendable { - case reply = "REPLY_ACTION" - case markRead = "MARK_READ_ACTION" - case dismiss = "DISMISS_ACTION" +enum NotificationAction: String { + case reply = "REPLY_ACTION" + case markRead = "MARK_READ_ACTION" + case dismiss = "DISMISS_ACTION" } // MARK: - Notification Service @@ -27,959 +27,958 @@ enum NotificationAction: String, Sendable { @Observable @MainActor public final class NotificationService: NSObject { - - // MARK: - Properties - - private let logger = Logger(subsystem: "com.mc1", category: "Notifications") - - /// Whether notification permissions are authorized - public private(set) var isAuthorized: Bool = false - - /// Current authorization status - public private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined - - /// Callback for when a quick reply action is triggered - /// Installed by `AppState.configureNotificationHandlers`. - /// Must be @MainActor so the callback body executes on the main thread. - /// Without @MainActor, the callback runs on a background executor even when - /// called from MainActor context, causing "Call must be made on main thread" crashes. - public var onQuickReply: (@MainActor @Sendable (_ contactID: UUID, _ text: String) async -> Void)? - - /// Callback for when a notification is tapped - /// Installed by `NavigationCoordinator.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onNotificationTapped: (@MainActor @Sendable (_ contactID: UUID) async -> Void)? - - /// Callback for when a channel notification is tapped - /// Installed by `NavigationCoordinator.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onChannelNotificationTapped: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8) async -> Void)? - - /// Callback for when a room notification is tapped. - /// Identified by the room's stable session ID. - /// Installed by `NavigationCoordinator.configureNotificationHandlers`. - /// Must be @MainActor for main-thread callback execution; see onQuickReply. - public var onRoomNotificationTapped: (@MainActor @Sendable (_ sessionID: UUID) async -> Void)? - - /// Callback for when a new contact discovered notification is tapped - /// Installed by `NavigationCoordinator.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onNewContactNotificationTapped: (@MainActor @Sendable (_ contactID: UUID) async -> Void)? - - /// Callback for when a reaction notification is tapped - /// Parameters: contactID (for DM) or nil, channelIndex/radioID (for channel) or nil, and messageID - /// Installed by `NavigationCoordinator.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onReactionNotificationTapped: (@MainActor @Sendable ( - _ contactID: UUID?, - _ channelIndex: UInt8?, - _ radioID: UUID?, - _ messageID: UUID - ) async -> Void)? - - /// Provider for localized notification strings - private var stringProvider: NotificationStringProvider? - - /// Sets the string provider for localized notification content. - /// - Parameter provider: The provider implementation from the app layer - public func setStringProvider(_ provider: NotificationStringProvider) { - self.stringProvider = provider - } - - /// Read access for same-module collaborators (`NotificationActionHandler`) - /// that build localized display strings. - var strings: NotificationStringProvider? { stringProvider } - - /// Callback for when mark as read action is triggered - /// Installed by `AppState.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onMarkAsRead: (@MainActor @Sendable (_ contactID: UUID, _ messageID: UUID) async -> Void)? - - /// Callback for when mark as read action is triggered on a channel message - /// Includes radioID to correctly identify the channel across multiple connected devices - /// Installed by `AppState.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onChannelMarkAsRead: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8, _ messageID: UUID) async -> Void)? - - /// Callback for when mark as read action is triggered on a room message. - /// Identified by the room's stable session ID. - /// Installed by `AppState.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onRoomMarkAsRead: (@MainActor @Sendable (_ sessionID: UUID, _ messageID: UUID) async -> Void)? - - /// Callback for when a quick reply action is triggered on a channel message. - /// Includes radioID to correctly identify the channel across multiple connected devices. - /// Installed by `AppState.configureNotificationHandlers`. - /// Must be @MainActor; see the onQuickReply comment. - public var onChannelQuickReply: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8, _ text: String) async -> Void)? - - /// Badge count - public private(set) var badgeCount: Int = 0 - - /// Whether message notifications are temporarily suppressed (during sync window) - public var isSuppressingNotifications: Bool = false - - // MARK: - Active Conversation Tracking - - /// Currently active contact ID (user is viewing this chat) - public var activeContactID: UUID? - - /// Currently active channel index (user is viewing this channel) - public var activeChannelIndex: UInt8? - - /// Device ID for the active channel - public var activeChannelRadioID: UUID? - - /// Currently active room session ID (user is viewing this room) - public var activeRoomSessionID: UUID? - - /// Atomically sets the active conversation, clearing every slot the caller - /// does not pass. Opening one conversation type therefore clears the other - /// types in a single assignment, so a foreground banner is never suppressed - /// for a conversation the user just left. Only one slot is ever populated - /// because exactly one conversation detail is visible at a time. - public func setActiveConversation( - contactID: UUID? = nil, - channelIndex: UInt8? = nil, - channelRadioID: UUID? = nil, - roomSessionID: UUID? = nil - ) { - activeContactID = contactID - activeChannelIndex = channelIndex - activeChannelRadioID = channelRadioID - activeRoomSessionID = roomSessionID - } - - // MARK: - Badge Management - - /// Callback to get total unread count from data layer - /// Returns (contactUnread, channelUnread, roomUnread) tuple for preference-aware calculation - /// Installed by `AppState.wireServicesIfConnected`. - public var getBadgeCount: (@Sendable () async -> (contacts: Int, channels: Int, rooms: Int))? - - /// Cached notification preferences (refreshed on each badge update) - private var cachedPreferences: NotificationPreferences? - - /// Last time preferences were refreshed - private var preferencesLastRefreshed: Date = .distantPast - - /// Pending badge update task (for debouncing rapid updates) - private var pendingBadgeUpdate: Task? - - /// Stored draft messages for contacts (keyed by contactID string). - /// Used when quick reply fails due to disconnection. - /// - /// - Important: Drafts are stored in-memory ONLY and will be LOST if: - /// - The app is force quit by the user - /// - The app is terminated by iOS due to memory pressure - /// - The device is restarted - /// - /// Drafts persist until consumed via `consumeDraft(for:)` or until the app - /// terminates. If disk persistence is needed in the future, consider SwiftData - /// storage with appropriate cleanup policies. - @MainActor private var pendingDrafts: [String: String] = [:] - - // MARK: - Initialization - - public override init() { - super.init() - } - - /// Sets up notification categories and checks current authorization status. - public func setup() async { - await registerCategories() - await checkAuthorizationStatus() - } - - // MARK: - Authorization - - /// Requests notification authorization. - @discardableResult - public func requestAuthorization() async -> Bool { - do { - let options: UNAuthorizationOptions = [.alert, .sound, .badge] - let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: options) - isAuthorized = granted - authorizationStatus = granted ? .authorized : .denied - return granted - } catch { - isAuthorized = false - authorizationStatus = .denied - return false - } + // MARK: - Properties + + private let logger = Logger(subsystem: "com.mc1", category: "Notifications") + + /// Whether notification permissions are authorized + public private(set) var isAuthorized: Bool = false + + /// Current authorization status + public private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined + + /// Callback for when a quick reply action is triggered + /// Installed by `AppState.configureNotificationHandlers`. + /// Must be @MainActor so the callback body executes on the main thread. + /// Without @MainActor, the callback runs on a background executor even when + /// called from MainActor context, causing "Call must be made on main thread" crashes. + public var onQuickReply: (@MainActor @Sendable (_ contactID: UUID, _ text: String) async -> Void)? + + /// Callback for when a notification is tapped + /// Installed by `NavigationCoordinator.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onNotificationTapped: (@MainActor @Sendable (_ contactID: UUID) async -> Void)? + + /// Callback for when a channel notification is tapped + /// Installed by `NavigationCoordinator.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onChannelNotificationTapped: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8) async -> Void)? + + /// Callback for when a room notification is tapped. + /// Identified by the room's stable session ID. + /// Installed by `NavigationCoordinator.configureNotificationHandlers`. + /// Must be @MainActor for main-thread callback execution; see onQuickReply. + public var onRoomNotificationTapped: (@MainActor @Sendable (_ sessionID: UUID) async -> Void)? + + /// Callback for when a new contact discovered notification is tapped + /// Installed by `NavigationCoordinator.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onNewContactNotificationTapped: (@MainActor @Sendable (_ contactID: UUID) async -> Void)? + + /// Callback for when a reaction notification is tapped + /// Parameters: contactID (for DM) or nil, channelIndex/radioID (for channel) or nil, and messageID + /// Installed by `NavigationCoordinator.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onReactionNotificationTapped: (@MainActor @Sendable ( + _ contactID: UUID?, + _ channelIndex: UInt8?, + _ radioID: UUID?, + _ messageID: UUID + ) async -> Void)? + + /// Provider for localized notification strings + private var stringProvider: NotificationStringProvider? + + /// Sets the string provider for localized notification content. + /// - Parameter provider: The provider implementation from the app layer + public func setStringProvider(_ provider: NotificationStringProvider) { + stringProvider = provider + } + + /// Read access for same-module collaborators (`NotificationActionHandler`) + /// that build localized display strings. + var strings: NotificationStringProvider? { + stringProvider + } + + /// Callback for when mark as read action is triggered + /// Installed by `AppState.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onMarkAsRead: (@MainActor @Sendable (_ contactID: UUID, _ messageID: UUID) async -> Void)? + + /// Callback for when mark as read action is triggered on a channel message + /// Includes radioID to correctly identify the channel across multiple connected devices + /// Installed by `AppState.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onChannelMarkAsRead: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8, _ messageID: UUID) async -> Void)? + + /// Callback for when mark as read action is triggered on a room message. + /// Identified by the room's stable session ID. + /// Installed by `AppState.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onRoomMarkAsRead: (@MainActor @Sendable (_ sessionID: UUID, _ messageID: UUID) async -> Void)? + + /// Callback for when a quick reply action is triggered on a channel message. + /// Includes radioID to correctly identify the channel across multiple connected devices. + /// Installed by `AppState.configureNotificationHandlers`. + /// Must be @MainActor; see the onQuickReply comment. + public var onChannelQuickReply: (@MainActor @Sendable (_ radioID: UUID, _ channelIndex: UInt8, _ text: String) async -> Void)? + + /// Badge count + public private(set) var badgeCount: Int = 0 + + /// Whether message notifications are temporarily suppressed (during sync window) + public var isSuppressingNotifications: Bool = false + + // MARK: - Active Conversation Tracking + + /// Currently active contact ID (user is viewing this chat) + public var activeContactID: UUID? + + /// Currently active channel index (user is viewing this channel) + public var activeChannelIndex: UInt8? + + /// Device ID for the active channel + public var activeChannelRadioID: UUID? + + /// Currently active room session ID (user is viewing this room) + public var activeRoomSessionID: UUID? + + /// Atomically sets the active conversation, clearing every slot the caller + /// does not pass. Opening one conversation type therefore clears the other + /// types in a single assignment, so a foreground banner is never suppressed + /// for a conversation the user just left. Only one slot is ever populated + /// because exactly one conversation detail is visible at a time. + public func setActiveConversation( + contactID: UUID? = nil, + channelIndex: UInt8? = nil, + channelRadioID: UUID? = nil, + roomSessionID: UUID? = nil + ) { + activeContactID = contactID + activeChannelIndex = channelIndex + activeChannelRadioID = channelRadioID + activeRoomSessionID = roomSessionID + } + + // MARK: - Badge Management + + /// Callback to get total unread count from data layer + /// Returns (contactUnread, channelUnread, roomUnread) tuple for preference-aware calculation + /// Installed by `AppState.wireServicesIfConnected`. + public var getBadgeCount: (@Sendable () async -> (contacts: Int, channels: Int, rooms: Int))? + + /// Cached notification preferences (refreshed on each badge update) + private var cachedPreferences: NotificationPreferences? + + /// Last time preferences were refreshed + private var preferencesLastRefreshed: Date = .distantPast + + /// Pending badge update task (for debouncing rapid updates) + private var pendingBadgeUpdate: Task? + + /// Stored draft messages for contacts (keyed by contactID string). + /// Used when quick reply fails due to disconnection. + /// + /// - Important: Drafts are stored in-memory ONLY and will be LOST if: + /// - The app is force quit by the user + /// - The app is terminated by iOS due to memory pressure + /// - The device is restarted + /// + /// Drafts persist until consumed via `consumeDraft(for:)` or until the app + /// terminates. If disk persistence is needed in the future, consider SwiftData + /// storage with appropriate cleanup policies. + @MainActor private var pendingDrafts: [String: String] = [:] + + // MARK: - Initialization + + override public init() { + super.init() + } + + /// Sets up notification categories and checks current authorization status. + public func setup() async { + await registerCategories() + await checkAuthorizationStatus() + } + + // MARK: - Authorization + + /// Requests notification authorization. + @discardableResult + public func requestAuthorization() async -> Bool { + do { + let options: UNAuthorizationOptions = [.alert, .sound, .badge] + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: options) + isAuthorized = granted + authorizationStatus = granted ? .authorized : .denied + return granted + } catch { + isAuthorized = false + authorizationStatus = .denied + return false } - - /// Checks current authorization status. - public func checkAuthorizationStatus() async { - let settings = await UNUserNotificationCenter.current().notificationSettings() - authorizationStatus = settings.authorizationStatus - isAuthorized = settings.authorizationStatus == .authorized - } - - // MARK: - Category Registration - - /// Registers notification categories with actions. - private func registerCategories() async { - // Reply action with text input - let replyAction = UNTextInputNotificationAction( - identifier: NotificationAction.reply.rawValue, - title: stringProvider?.replyActionTitle ?? "Reply", - options: [], - textInputButtonTitle: stringProvider?.sendButtonTitle ?? "Send", - textInputPlaceholder: stringProvider?.messagePlaceholder ?? "Message..." - ) - - // Mark as read action - let markReadAction = UNNotificationAction( - identifier: NotificationAction.markRead.rawValue, - title: stringProvider?.markAsReadActionTitle ?? "Mark as Read", - options: [] - ) - - // Direct message category - let directMessageCategory = UNNotificationCategory( - identifier: NotificationCategory.directMessage.rawValue, - actions: [replyAction, markReadAction], - intentIdentifiers: [], - options: [.customDismissAction] - ) - - // Channel message category (with reply action) - let channelMessageCategory = UNNotificationCategory( - identifier: NotificationCategory.channelMessage.rawValue, - actions: [replyAction, markReadAction], - intentIdentifiers: [], - options: [.customDismissAction] - ) - - // Room message category (no reply action) - let roomMessageCategory = UNNotificationCategory( - identifier: NotificationCategory.roomMessage.rawValue, - actions: [markReadAction], - intentIdentifiers: [], - options: [.customDismissAction] - ) - - // Reaction category (no actions - just tap to navigate) - let reactionCategory = UNNotificationCategory( - identifier: NotificationCategory.reaction.rawValue, - actions: [], - intentIdentifiers: [], - options: [] - ) - - // Low battery category - let lowBatteryCategory = UNNotificationCategory( - identifier: NotificationCategory.lowBattery.rawValue, - actions: [], - intentIdentifiers: [], - options: [] - ) - - let categories: Set = [ - directMessageCategory, - channelMessageCategory, - roomMessageCategory, - reactionCategory, - lowBatteryCategory - ] - - UNUserNotificationCenter.current().setNotificationCategories(categories) - } - - // MARK: - Sending Notifications - - /// Posts a notification for a direct message. - public func postDirectMessageNotification( - from contactName: String, - contactID: UUID, - messageText: String, - messageID: UUID, - isMuted: Bool = false - ) async { - guard !isMuted else { return } - guard isAuthorized else { return } - - // Check granular preference (uses cached preferences) - guard preferences.contactMessagesEnabled else { return } - - // Skip system notification if suppressed (during sync window) - // Unread counts and badges are updated separately by the caller - guard !isSuppressingNotifications else { return } - - let content = UNMutableNotificationContent() - content.title = contactName - content.body = messageText - content.sound = preferences.soundEnabled ? .default : nil - content.categoryIdentifier = NotificationCategory.directMessage.rawValue - content.userInfo = [ - "contactID": contactID.uuidString, - "messageID": messageID.uuidString, - "type": "directMessage" - ] - content.threadIdentifier = contactID.uuidString - - // Use current badge count (will be updated after posting) - if preferences.badgeEnabled { - content.badge = NSNumber(value: badgeCount + 1) - } - - let request = UNNotificationRequest( - identifier: messageID.uuidString, - content: content, - trigger: nil - ) - - // Post notification immediately - don't block on badge calculation - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } - - // Update badge from database AFTER posting (non-blocking for notification display) - if preferences.badgeEnabled { - await updateBadgeCount() - } + } + + /// Checks current authorization status. + public func checkAuthorizationStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + authorizationStatus = settings.authorizationStatus + isAuthorized = settings.authorizationStatus == .authorized + } + + // MARK: - Category Registration + + /// Registers notification categories with actions. + private func registerCategories() async { + // Reply action with text input + let replyAction = UNTextInputNotificationAction( + identifier: NotificationAction.reply.rawValue, + title: stringProvider?.replyActionTitle ?? "Reply", + options: [], + textInputButtonTitle: stringProvider?.sendButtonTitle ?? "Send", + textInputPlaceholder: stringProvider?.messagePlaceholder ?? "Message..." + ) + + // Mark as read action + let markReadAction = UNNotificationAction( + identifier: NotificationAction.markRead.rawValue, + title: stringProvider?.markAsReadActionTitle ?? "Mark as Read", + options: [] + ) + + // Direct message category + let directMessageCategory = UNNotificationCategory( + identifier: NotificationCategory.directMessage.rawValue, + actions: [replyAction, markReadAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + // Channel message category (with reply action) + let channelMessageCategory = UNNotificationCategory( + identifier: NotificationCategory.channelMessage.rawValue, + actions: [replyAction, markReadAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + // Room message category (no reply action) + let roomMessageCategory = UNNotificationCategory( + identifier: NotificationCategory.roomMessage.rawValue, + actions: [markReadAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + // Reaction category (no actions - just tap to navigate) + let reactionCategory = UNNotificationCategory( + identifier: NotificationCategory.reaction.rawValue, + actions: [], + intentIdentifiers: [], + options: [] + ) + + // Low battery category + let lowBatteryCategory = UNNotificationCategory( + identifier: NotificationCategory.lowBattery.rawValue, + actions: [], + intentIdentifiers: [], + options: [] + ) + + let categories: Set = [ + directMessageCategory, + channelMessageCategory, + roomMessageCategory, + reactionCategory, + lowBatteryCategory + ] + + UNUserNotificationCenter.current().setNotificationCategories(categories) + } + + // MARK: - Sending Notifications + + /// Posts a notification for a direct message. + public func postDirectMessageNotification( + from contactName: String, + contactID: UUID, + messageText: String, + messageID: UUID, + isMuted: Bool = false + ) async { + guard !isMuted else { return } + guard isAuthorized else { return } + + // Check granular preference (uses cached preferences) + guard preferences.contactMessagesEnabled else { return } + + // Skip system notification if suppressed (during sync window) + // Unread counts and badges are updated separately by the caller + guard !isSuppressingNotifications else { return } + + let content = UNMutableNotificationContent() + content.title = contactName + content.body = messageText + content.sound = preferences.soundEnabled ? .default : nil + content.categoryIdentifier = NotificationCategory.directMessage.rawValue + content.userInfo = [ + "contactID": contactID.uuidString, + "messageID": messageID.uuidString, + "type": "directMessage" + ] + content.threadIdentifier = contactID.uuidString + + // Use current badge count (will be updated after posting) + if preferences.badgeEnabled { + content.badge = NSNumber(value: badgeCount + 1) } - /// Posts a notification for a channel message. - public func postChannelMessageNotification( - channelName: String, - channelIndex: UInt8, - radioID: UUID, - senderName: String?, - messageText: String, - messageID: UUID, - notificationLevel: NotificationLevel, - hasSelfMention: Bool - ) async { - guard notificationLevel != .muted else { return } - guard notificationLevel != .mentionsOnly || hasSelfMention else { return } - guard isAuthorized else { return } - - // Check granular preference (uses cached preferences) - guard preferences.channelMessagesEnabled else { return } - - // Skip system notification if suppressed (during sync window) - guard !isSuppressingNotifications else { return } - - let content = UNMutableNotificationContent() - content.title = channelName - if let sender = senderName { - content.body = "\(sender): \(messageText)" - } else { - content.body = messageText - } - content.sound = preferences.soundEnabled ? .default : nil - content.categoryIdentifier = NotificationCategory.channelMessage.rawValue - content.userInfo = [ - "channelIndex": Int(channelIndex), - "radioID": radioID.uuidString, - "messageID": messageID.uuidString, - "type": "channelMessage" - ] - content.threadIdentifier = "channel-\(radioID.uuidString)-\(channelIndex)" - - // Use current badge count (will be updated after posting) - if preferences.badgeEnabled { - content.badge = NSNumber(value: badgeCount + 1) - } - - let request = UNNotificationRequest( - identifier: messageID.uuidString, - content: content, - trigger: nil - ) - - // Post notification immediately - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } - - // Update badge from database AFTER posting - if preferences.badgeEnabled { - await updateBadgeCount() - } + let request = UNNotificationRequest( + identifier: messageID.uuidString, + content: content, + trigger: nil + ) + + // Post notification immediately - don't block on badge calculation + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") } - /// Posts a notification for a room message. - public func postRoomMessageNotification( - roomName: String, - sessionID: UUID, - senderName: String?, - messageText: String, - messageID: UUID, - notificationLevel: NotificationLevel - ) async { - guard notificationLevel != .muted else { return } - guard isAuthorized else { return } - - // Check granular preference - guard preferences.roomMessagesEnabled else { return } - - // Skip system notification if suppressed (during sync window) - guard !isSuppressingNotifications else { return } - - let content = UNMutableNotificationContent() - content.title = roomName - if let sender = senderName { - content.body = "\(sender): \(messageText)" - } else { - content.body = messageText - } - content.sound = preferences.soundEnabled ? .default : nil - content.categoryIdentifier = NotificationCategory.roomMessage.rawValue - content.userInfo = [ - "roomName": roomName, - "sessionID": sessionID.uuidString, - "messageID": messageID.uuidString, - "type": "roomMessage" - ] - content.threadIdentifier = "room-\(sessionID.uuidString)" - - if preferences.badgeEnabled { - content.badge = NSNumber(value: badgeCount + 1) - } - - let request = UNNotificationRequest( - identifier: messageID.uuidString, - content: content, - trigger: nil - ) + // Update badge from database AFTER posting (non-blocking for notification display) + if preferences.badgeEnabled { + await updateBadgeCount() + } + } + + /// Posts a notification for a channel message. + public func postChannelMessageNotification( + channelName: String, + channelIndex: UInt8, + radioID: UUID, + senderName: String?, + messageText: String, + messageID: UUID, + notificationLevel: NotificationLevel, + hasSelfMention: Bool + ) async { + guard notificationLevel != .muted else { return } + guard notificationLevel != .mentionsOnly || hasSelfMention else { return } + guard isAuthorized else { return } + + // Check granular preference (uses cached preferences) + guard preferences.channelMessagesEnabled else { return } + + // Skip system notification if suppressed (during sync window) + guard !isSuppressingNotifications else { return } + + let content = UNMutableNotificationContent() + content.title = channelName + if let sender = senderName { + content.body = "\(sender): \(messageText)" + } else { + content.body = messageText + } + content.sound = preferences.soundEnabled ? .default : nil + content.categoryIdentifier = NotificationCategory.channelMessage.rawValue + content.userInfo = [ + "channelIndex": Int(channelIndex), + "radioID": radioID.uuidString, + "messageID": messageID.uuidString, + "type": "channelMessage" + ] + content.threadIdentifier = "channel-\(radioID.uuidString)-\(channelIndex)" + + // Use current badge count (will be updated after posting) + if preferences.badgeEnabled { + content.badge = NSNumber(value: badgeCount + 1) + } - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + let request = UNNotificationRequest( + identifier: messageID.uuidString, + content: content, + trigger: nil + ) + + // Post notification immediately + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") + } - if preferences.badgeEnabled { - await updateBadgeCount() - } + // Update badge from database AFTER posting + if preferences.badgeEnabled { + await updateBadgeCount() + } + } + + /// Posts a notification for a room message. + public func postRoomMessageNotification( + roomName: String, + sessionID: UUID, + senderName: String?, + messageText: String, + messageID: UUID, + notificationLevel: NotificationLevel + ) async { + guard notificationLevel != .muted else { return } + guard isAuthorized else { return } + + // Check granular preference + guard preferences.roomMessagesEnabled else { return } + + // Skip system notification if suppressed (during sync window) + guard !isSuppressingNotifications else { return } + + let content = UNMutableNotificationContent() + content.title = roomName + if let sender = senderName { + content.body = "\(sender): \(messageText)" + } else { + content.body = messageText + } + content.sound = preferences.soundEnabled ? .default : nil + content.categoryIdentifier = NotificationCategory.roomMessage.rawValue + content.userInfo = [ + "roomName": roomName, + "sessionID": sessionID.uuidString, + "messageID": messageID.uuidString, + "type": "roomMessage" + ] + content.threadIdentifier = "room-\(sessionID.uuidString)" + + if preferences.badgeEnabled { + content.badge = NSNumber(value: badgeCount + 1) } - /// Posts a notification that a new contact was discovered. - /// - Parameters: - /// - contactName: Display name of the discovered contact - /// - contactID: Unique identifier for the contact - /// - contactType: Type of node discovered (contact, repeater, or room) - public func postNewContactNotification( - contactName: String, - contactID: UUID, - contactType: ContactType - ) async { - guard isAuthorized else { return } - - // Check granular preference - guard preferences.newContactDiscoveredEnabled else { return } - - // Per-type filtering - switch contactType { - case .chat: guard preferences.discoveryContactEnabled else { return } - case .repeater: guard preferences.discoveryRepeaterEnabled else { return } - case .room: guard preferences.discoveryRoomEnabled else { return } - } + let request = UNNotificationRequest( + identifier: messageID.uuidString, + content: content, + trigger: nil + ) - // Get localized title from provider, fallback to English - let title = stringProvider?.discoveryNotificationTitle(for: contactType) - ?? defaultDiscoveryTitle(for: contactType) - - let content = UNMutableNotificationContent() - content.title = title - content.body = contactName.isEmpty - ? (stringProvider?.unknownContactName ?? "Unknown Contact") - : contactName - content.sound = preferences.soundEnabled ? .default : nil - content.threadIdentifier = "discovery" - content.userInfo = [ - "contactID": contactID.uuidString, - "type": "newContact" - ] - - let request = UNNotificationRequest( - identifier: "new-contact-\(contactID.uuidString)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") } - /// Default English titles when no string provider is set. - func defaultDiscoveryTitle(for type: ContactType) -> String { - switch type { - case .chat: "New Contact Discovered" - case .repeater: "New Repeater Discovered" - case .room: "New Room Discovered" - } + if preferences.badgeEnabled { + await updateBadgeCount() } - - /// Posts a notification when someone reacts to the user's message. - /// - Parameters: - /// - reactorName: Name of the person who reacted - /// - body: Localized notification body text - /// - messageID: ID of the message that was reacted to - /// - contactID: Contact ID if this is a direct message reaction (nil for channels) - /// - channelIndex: Channel index if this is a channel reaction (nil for DMs) - /// - radioID: Device ID for channel reactions (nil for DMs) - public func postReactionNotification( - reactorName: String, - body: String, - messageID: UUID, - contactID: UUID?, - channelIndex: UInt8?, - radioID: UUID? - ) async { - guard isAuthorized else { return } - - // Check reaction-specific preference - guard preferences.reactionNotificationsEnabled else { return } - - // Skip if suppressed (during sync window) - guard !isSuppressingNotifications else { return } - - let content = UNMutableNotificationContent() - content.title = reactorName - content.body = body - - content.sound = preferences.soundEnabled ? .default : nil - content.categoryIdentifier = NotificationCategory.reaction.rawValue - - // Build userInfo with all identifiers needed for navigation - var userInfo: [String: Any] = [ - "messageID": messageID.uuidString, - "type": "reaction" - ] - if let contactID { - userInfo["contactID"] = contactID.uuidString - content.threadIdentifier = "reaction-contact-\(contactID.uuidString)" - } - if let channelIndex, let radioID { - userInfo["channelIndex"] = Int(channelIndex) - userInfo["radioID"] = radioID.uuidString - content.threadIdentifier = "reaction-channel-\(radioID.uuidString)-\(channelIndex)" - } - content.userInfo = userInfo - - // Unique identifier per reaction to avoid replacing previous notifications - let request = UNNotificationRequest( - identifier: "reaction-\(messageID.uuidString)-\(reactorName)-\(Date().timeIntervalSince1970)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + } + + /// Posts a notification that a new contact was discovered. + /// - Parameters: + /// - contactName: Display name of the discovered contact + /// - contactID: Unique identifier for the contact + /// - contactType: Type of node discovered (contact, repeater, or room) + public func postNewContactNotification( + contactName: String, + contactID: UUID, + contactType: ContactType + ) async { + guard isAuthorized else { return } + + // Check granular preference + guard preferences.newContactDiscoveredEnabled else { return } + + // Per-type filtering + switch contactType { + case .chat: guard preferences.discoveryContactEnabled else { return } + case .repeater: guard preferences.discoveryRepeaterEnabled else { return } + case .room: guard preferences.discoveryRoomEnabled else { return } } - /// Posts a low battery warning notification. - public func postLowBatteryNotification( - deviceName: String, - batteryPercentage: Int - ) async { - guard isAuthorized else { return } - - guard preferences.lowBatteryEnabled else { return } - - let content = UNMutableNotificationContent() - content.title = stringProvider?.lowBatteryTitle ?? "Low Battery" - content.body = stringProvider?.lowBatteryBody(deviceName: deviceName, percentage: batteryPercentage) - ?? "\(deviceName) battery is at \(batteryPercentage)%" - content.sound = preferences.soundEnabled ? .default : nil - content.categoryIdentifier = NotificationCategory.lowBattery.rawValue - content.userInfo = [ - "type": "lowBattery", - "batteryPercentage": batteryPercentage - ] - - // Use device name as identifier to avoid duplicate notifications - let request = UNNotificationRequest( - identifier: "low-battery-\(deviceName)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + // Get localized title from provider, fallback to English + let title = stringProvider?.discoveryNotificationTitle(for: contactType) + ?? defaultDiscoveryTitle(for: contactType) + + let content = UNMutableNotificationContent() + content.title = title + content.body = contactName.isEmpty + ? (stringProvider?.unknownContactName ?? "Unknown Contact") + : contactName + content.sound = preferences.soundEnabled ? .default : nil + content.threadIdentifier = "discovery" + content.userInfo = [ + "contactID": contactID.uuidString, + "type": "newContact" + ] + + let request = UNNotificationRequest( + identifier: "new-contact-\(contactID.uuidString)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") } - - /// Posts a notification that a quick reply failed to send. - public func postQuickReplyFailedNotification( - contactName: String, - contactID: UUID - ) async { - guard isAuthorized else { return } - - let content = UNMutableNotificationContent() - content.title = stringProvider?.quickReplyFailedTitle ?? "Message Not Sent" - content.body = stringProvider?.quickReplyFailedBody(conversationName: contactName) - ?? "Your reply to \(contactName) couldn't be sent." - content.sound = .default - content.categoryIdentifier = NotificationCategory.directMessage.rawValue - content.userInfo = [ - "contactID": contactID.uuidString, - "type": "quickReplyFailed" - ] - - let request = UNNotificationRequest( - identifier: "quick-reply-failed-\(contactID.uuidString)-\(Date().timeIntervalSince1970)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + } + + /// Default English titles when no string provider is set. + func defaultDiscoveryTitle(for type: ContactType) -> String { + switch type { + case .chat: "New Contact Discovered" + case .repeater: "New Repeater Discovered" + case .room: "New Room Discovered" } - - /// Posts a notification that a channel quick reply failed to send. - public func postChannelQuickReplyFailedNotification( - channelName: String, - radioID: UUID, - channelIndex: UInt8 - ) async { - guard isAuthorized else { return } - - let content = UNMutableNotificationContent() - content.title = stringProvider?.quickReplyFailedTitle ?? "Message Not Sent" - content.body = stringProvider?.quickReplyFailedBody(conversationName: channelName) - ?? "Your reply to \(channelName) couldn't be sent." - content.sound = .default - content.userInfo = [ - "channelIndex": Int(channelIndex), - "radioID": radioID.uuidString, - "type": "channelQuickReplyFailed" - ] - - let request = UNNotificationRequest( - identifier: "channel-reply-failed-\(radioID.uuidString)-\(channelIndex)-\(Date().timeIntervalSince1970)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - logger.warning("Failed to post notification: \(error.localizedDescription)") - } + } + + /// Posts a notification when someone reacts to the user's message. + /// - Parameters: + /// - reactorName: Name of the person who reacted + /// - body: Localized notification body text + /// - messageID: ID of the message that was reacted to + /// - contactID: Contact ID if this is a direct message reaction (nil for channels) + /// - channelIndex: Channel index if this is a channel reaction (nil for DMs) + /// - radioID: Device ID for channel reactions (nil for DMs) + public func postReactionNotification( + reactorName: String, + body: String, + messageID: UUID, + contactID: UUID?, + channelIndex: UInt8?, + radioID: UUID? + ) async { + guard isAuthorized else { return } + + // Check reaction-specific preference + guard preferences.reactionNotificationsEnabled else { return } + + // Skip if suppressed (during sync window) + guard !isSuppressingNotifications else { return } + + let content = UNMutableNotificationContent() + content.title = reactorName + content.body = body + + content.sound = preferences.soundEnabled ? .default : nil + content.categoryIdentifier = NotificationCategory.reaction.rawValue + + // Build userInfo with all identifiers needed for navigation + var userInfo: [String: Any] = [ + "messageID": messageID.uuidString, + "type": "reaction" + ] + if let contactID { + userInfo["contactID"] = contactID.uuidString + content.threadIdentifier = "reaction-contact-\(contactID.uuidString)" } - - // MARK: - Draft Message Storage - - /// Saves a draft message for a contact when quick reply fails. - /// - /// - Important: Draft is stored in-memory only and will be lost if app is force quit. - /// - /// - Parameters: - /// - contactID: The UUID of the contact - /// - text: The draft message text to save - @MainActor public func saveDraft(for contactID: UUID, text: String) { - pendingDrafts[contactID.uuidString] = text - } - - /// Retrieves and removes a draft message for a contact. - /// - /// The draft is removed from storage after retrieval (consumed). - /// Returns `nil` if no draft exists for the contact. - /// - /// - Parameter contactID: The UUID of the contact - /// - Returns: The draft text if one exists, otherwise `nil` - @MainActor public func consumeDraft(for contactID: UUID) -> String? { - let key = contactID.uuidString - guard let draft = pendingDrafts[key] else { return nil } - pendingDrafts.removeValue(forKey: key) - return draft - } - - // MARK: - Badge Management Methods - - /// Refresh preferences if stale (older than 5 seconds) - /// Note: 5s cache prevents excessive UserDefaults reads during rapid message arrival - private func refreshPreferencesIfNeeded() { - let now = Date() - if cachedPreferences == nil || now.timeIntervalSince(preferencesLastRefreshed) > 5.0 { - cachedPreferences = NotificationPreferences() - preferencesLastRefreshed = now - } + if let channelIndex, let radioID { + userInfo["channelIndex"] = Int(channelIndex) + userInfo["radioID"] = radioID.uuidString + content.threadIdentifier = "reaction-channel-\(radioID.uuidString)-\(channelIndex)" } - - /// Get current preferences (uses cache) - private var preferences: NotificationPreferences { - refreshPreferencesIfNeeded() - return cachedPreferences ?? NotificationPreferences() + content.userInfo = userInfo + + // Unique identifier per reaction to avoid replacing previous notifications + let request = UNNotificationRequest( + identifier: "reaction-\(messageID.uuidString)-\(reactorName)-\(Date().timeIntervalSince1970)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") } - - /// Updates the app badge count from the database. - /// Uses 100ms debounce to handle rapid-fire message arrivals efficiently. - public func updateBadgeCount() async { - // Cancel any pending update to debounce rapid arrivals - pendingBadgeUpdate?.cancel() - - // Create new debounced update - pendingBadgeUpdate = Task { - // Wait 100ms before actually updating (allows batching multiple arrivals) - try? await Task.sleep(for: .milliseconds(100)) - guard !Task.isCancelled else { return } - - await performBadgeUpdate() - } - - // Wait for the update to complete - await pendingBadgeUpdate?.value + } + + /// Posts a low battery warning notification. + public func postLowBatteryNotification( + deviceName: String, + batteryPercentage: Int + ) async { + guard isAuthorized else { return } + + guard preferences.lowBatteryEnabled else { return } + + let content = UNMutableNotificationContent() + content.title = stringProvider?.lowBatteryTitle ?? "Low Battery" + content.body = stringProvider?.lowBatteryBody(deviceName: deviceName, percentage: batteryPercentage) + ?? "\(deviceName) battery is at \(batteryPercentage)%" + content.sound = preferences.soundEnabled ? .default : nil + content.categoryIdentifier = NotificationCategory.lowBattery.rawValue + content.userInfo = [ + "type": "lowBattery", + "batteryPercentage": batteryPercentage + ] + + // Use device name as identifier to avoid duplicate notifications + let request = UNNotificationRequest( + identifier: "low-battery-\(deviceName)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") + } + } + + /// Posts a notification that a quick reply failed to send. + public func postQuickReplyFailedNotification( + contactName: String, + contactID: UUID + ) async { + guard isAuthorized else { return } + + let content = UNMutableNotificationContent() + content.title = stringProvider?.quickReplyFailedTitle ?? "Message Not Sent" + content.body = stringProvider?.quickReplyFailedBody(conversationName: contactName) + ?? "Your reply to \(contactName) couldn't be sent." + content.sound = .default + content.categoryIdentifier = NotificationCategory.directMessage.rawValue + content.userInfo = [ + "contactID": contactID.uuidString, + "type": "quickReplyFailed" + ] + + let request = UNNotificationRequest( + identifier: "quick-reply-failed-\(contactID.uuidString)-\(Date().timeIntervalSince1970)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") + } + } + + /// Posts a notification that a channel quick reply failed to send. + public func postChannelQuickReplyFailedNotification( + channelName: String, + radioID: UUID, + channelIndex: UInt8 + ) async { + guard isAuthorized else { return } + + let content = UNMutableNotificationContent() + content.title = stringProvider?.quickReplyFailedTitle ?? "Message Not Sent" + content.body = stringProvider?.quickReplyFailedBody(conversationName: channelName) + ?? "Your reply to \(channelName) couldn't be sent." + content.sound = .default + content.userInfo = [ + "channelIndex": Int(channelIndex), + "radioID": radioID.uuidString, + "type": "channelQuickReplyFailed" + ] + + let request = UNNotificationRequest( + identifier: "channel-reply-failed-\(radioID.uuidString)-\(channelIndex)-\(Date().timeIntervalSince1970)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.warning("Failed to post notification: \(error.localizedDescription)") + } + } + + // MARK: - Draft Message Storage + + /// Saves a draft message for a contact when quick reply fails. + /// + /// - Important: Draft is stored in-memory only and will be lost if app is force quit. + /// + /// - Parameters: + /// - contactID: The UUID of the contact + /// - text: The draft message text to save + @MainActor public func saveDraft(for contactID: UUID, text: String) { + pendingDrafts[contactID.uuidString] = text + } + + /// Retrieves and removes a draft message for a contact. + /// + /// The draft is removed from storage after retrieval (consumed). + /// Returns `nil` if no draft exists for the contact. + /// + /// - Parameter contactID: The UUID of the contact + /// - Returns: The draft text if one exists, otherwise `nil` + @MainActor public func consumeDraft(for contactID: UUID) -> String? { + let key = contactID.uuidString + guard let draft = pendingDrafts[key] else { return nil } + pendingDrafts.removeValue(forKey: key) + return draft + } + + // MARK: - Badge Management Methods + + /// Refresh preferences if stale (older than 5 seconds) + /// Note: 5s cache prevents excessive UserDefaults reads during rapid message arrival + private func refreshPreferencesIfNeeded() { + let now = Date() + if cachedPreferences == nil || now.timeIntervalSince(preferencesLastRefreshed) > 5.0 { + cachedPreferences = NotificationPreferences() + preferencesLastRefreshed = now + } + } + + /// Get current preferences (uses cache) + private var preferences: NotificationPreferences { + refreshPreferencesIfNeeded() + return cachedPreferences ?? NotificationPreferences() + } + + /// Updates the app badge count from the database. + /// Uses 100ms debounce to handle rapid-fire message arrivals efficiently. + public func updateBadgeCount() async { + // Cancel any pending update to debounce rapid arrivals + pendingBadgeUpdate?.cancel() + + // Create new debounced update + pendingBadgeUpdate = Task { + // Wait 100ms before actually updating (allows batching multiple arrivals) + try? await Task.sleep(for: .milliseconds(100)) + guard !Task.isCancelled else { return } + + await performBadgeUpdate() } - /// Performs the actual badge update (called by debounced updateBadgeCount) - private func performBadgeUpdate() async { - guard let getBadgeCount else { - return - } - - refreshPreferencesIfNeeded() - let prefs = preferences - - // If badge is disabled, clear it and return - guard prefs.badgeEnabled else { - badgeCount = 0 - do { - try await UNUserNotificationCenter.current().setBadgeCount(0) - } catch { - // Failed to clear badge - } - return - } - - // Get counts from data layer via callback - let counts = await getBadgeCount() + // Wait for the update to complete + await pendingBadgeUpdate?.value + } - var totalUnread = 0 + /// Performs the actual badge update (called by debounced updateBadgeCount) + private func performBadgeUpdate() async { + guard let getBadgeCount else { + return + } - // Only include contact messages if preference enabled - if prefs.contactMessagesEnabled { - totalUnread += counts.contacts - } + refreshPreferencesIfNeeded() + let prefs = preferences + + // If badge is disabled, clear it and return + guard prefs.badgeEnabled else { + badgeCount = 0 + do { + try await UNUserNotificationCenter.current().setBadgeCount(0) + } catch { + // Failed to clear badge + } + return + } - // Only include channel messages if preference enabled - if prefs.channelMessagesEnabled { - totalUnread += counts.channels - } + // Get counts from data layer via callback + let counts = await getBadgeCount() - // Only include room messages if preference enabled - if prefs.roomMessagesEnabled { - totalUnread += counts.rooms - } + var totalUnread = 0 - // Update badge - badgeCount = totalUnread - do { - try await UNUserNotificationCenter.current().setBadgeCount(totalUnread) - } catch { - // Failed to set badge count - } + // Only include contact messages if preference enabled + if prefs.contactMessagesEnabled { + totalUnread += counts.contacts } - /// Remove a delivered notification by message ID - public func removeDeliveredNotification(messageID: UUID) { - UNUserNotificationCenter.current().removeDeliveredNotifications( - withIdentifiers: [messageID.uuidString] - ) + // Only include channel messages if preference enabled + if prefs.channelMessagesEnabled { + totalUnread += counts.channels } - /// Remove all delivered notifications for a contact - public func removeDeliveredNotifications(forContactID contactID: UUID) async { - let center = UNUserNotificationCenter.current() - let notifications = await center.deliveredNotifications() - let idsToRemove = notifications - .filter { $0.request.content.userInfo["contactID"] as? String == contactID.uuidString } - .map(\.request.identifier) - - if !idsToRemove.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: idsToRemove) - } + // Only include room messages if preference enabled + if prefs.roomMessagesEnabled { + totalUnread += counts.rooms } - /// Remove all delivered notifications for a channel - public func removeDeliveredNotifications(forChannelIndex channelIndex: UInt8, radioID: UUID) async { - let center = UNUserNotificationCenter.current() - let notifications = await center.deliveredNotifications() - - let identifiersToRemove = notifications.compactMap { notification -> String? in - let userInfo = notification.request.content.userInfo - guard let notifChannelIndex = userInfo["channelIndex"] as? Int, - let notifDeviceIDString = userInfo["radioID"] as? String, - UInt8(notifChannelIndex) == channelIndex, - notifDeviceIDString == radioID.uuidString else { - return nil - } - return notification.request.identifier - } - - if !identifiersToRemove.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: identifiersToRemove) - } + // Update badge + badgeCount = totalUnread + do { + try await UNUserNotificationCenter.current().setBadgeCount(totalUnread) + } catch { + // Failed to set badge count } - - /// Remove all delivered notifications for a room session - public func removeDeliveredNotifications(forRoomSessionID sessionID: UUID) async { - let center = UNUserNotificationCenter.current() - let notifications = await center.deliveredNotifications() - let idsToRemove = notifications - .filter { $0.request.content.userInfo["sessionID"] as? String == sessionID.uuidString } - .map(\.request.identifier) - - if !idsToRemove.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: idsToRemove) - } + } + + /// Remove a delivered notification by message ID + public func removeDeliveredNotification(messageID: UUID) { + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: [messageID.uuidString] + ) + } + + /// Remove all delivered notifications for a contact + public func removeDeliveredNotifications(forContactID contactID: UUID) async { + let center = UNUserNotificationCenter.current() + let notifications = await center.deliveredNotifications() + let idsToRemove = notifications + .filter { $0.request.content.userInfo["contactID"] as? String == contactID.uuidString } + .map(\.request.identifier) + + if !idsToRemove.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: idsToRemove) + } + } + + /// Remove all delivered notifications for a channel + public func removeDeliveredNotifications(forChannelIndex channelIndex: UInt8, radioID: UUID) async { + let center = UNUserNotificationCenter.current() + let notifications = await center.deliveredNotifications() + + let identifiersToRemove = notifications.compactMap { notification -> String? in + let userInfo = notification.request.content.userInfo + guard let notifChannelIndex = userInfo["channelIndex"] as? Int, + let notifDeviceIDString = userInfo["radioID"] as? String, + UInt8(notifChannelIndex) == channelIndex, + notifDeviceIDString == radioID.uuidString else { + return nil + } + return notification.request.identifier } + if !identifiersToRemove.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: identifiersToRemove) + } + } + + /// Remove all delivered notifications for a room session + public func removeDeliveredNotifications(forRoomSessionID sessionID: UUID) async { + let center = UNUserNotificationCenter.current() + let notifications = await center.deliveredNotifications() + let idsToRemove = notifications + .filter { $0.request.content.userInfo["sessionID"] as? String == sessionID.uuidString } + .map(\.request.identifier) + + if !idsToRemove.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: idsToRemove) + } + } } // MARK: - UNUserNotificationCenterDelegate extension NotificationService: @preconcurrency UNUserNotificationCenterDelegate { + /// Called when a notification is received while the app is in the foreground. + /// With @preconcurrency, this method inherits @MainActor from the class. + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + let userInfo = notification.request.content.userInfo + + // Check if this is a direct message notification for the active chat + if let contactIDString = userInfo["contactID"] as? String, + let contactID = UUID(uuidString: contactIDString), + contactID == activeContactID { + // User is viewing this chat - don't show notification + return [] + } - /// Called when a notification is received while the app is in the foreground. - /// With @preconcurrency, this method inherits @MainActor from the class. - public func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification - ) async -> UNNotificationPresentationOptions { - let userInfo = notification.request.content.userInfo - - // Check if this is a direct message notification for the active chat - if let contactIDString = userInfo["contactID"] as? String, - let contactID = UUID(uuidString: contactIDString), - contactID == activeContactID { - // User is viewing this chat - don't show notification - return [] - } - - // Check if this is a channel message notification for the active channel - // Must check BOTH channelIndex AND radioID for multi-device scenarios - if let channelIndex = userInfo["channelIndex"] as? Int, - let radioIDString = userInfo["radioID"] as? String, - let radioID = UUID(uuidString: radioIDString), - UInt8(channelIndex) == activeChannelIndex, - radioID == activeChannelRadioID { - // User is viewing this channel - don't show notification - return [] - } + // Check if this is a channel message notification for the active channel + // Must check BOTH channelIndex AND radioID for multi-device scenarios + if let channelIndex = userInfo["channelIndex"] as? Int, + let radioIDString = userInfo["radioID"] as? String, + let radioID = UUID(uuidString: radioIDString), + UInt8(channelIndex) == activeChannelIndex, + radioID == activeChannelRadioID { + // User is viewing this channel - don't show notification + return [] + } - // Check if this is a room message notification for the active room - if let sessionIDString = userInfo["sessionID"] as? String, - let sessionID = UUID(uuidString: sessionIDString), - sessionID == activeRoomSessionID { - // User is viewing this room - don't show notification - return [] - } + // Check if this is a room message notification for the active room + if let sessionIDString = userInfo["sessionID"] as? String, + let sessionID = UUID(uuidString: sessionIDString), + sessionID == activeRoomSessionID { + // User is viewing this room - don't show notification + return [] + } - // Show banner and sound for other notifications - return [.banner, .sound, .badge] - } - - /// Called when the user interacts with a notification. - /// With @preconcurrency, this method inherits @MainActor from the class, - /// so we can directly access self and all @Observable properties. - public func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse - ) async { - let userInfo = response.notification.request.content.userInfo - - switch response.actionIdentifier { - case NotificationAction.reply.rawValue: - // Handle quick reply - guard let textResponse = response as? UNTextInputNotificationResponse else { - return - } - - let replyText = textResponse.userText - - // Check if it's a direct message reply (existing) - if let contactIDString = userInfo["contactID"] as? String, - let contactID = UUID(uuidString: contactIDString) { - await onQuickReply?(contactID, replyText) - } - // Check if it's a channel reply (new) - else if let channelIndex = userInfo["channelIndex"] as? Int, - let radioIDString = userInfo["radioID"] as? String, - let radioID = UUID(uuidString: radioIDString) { - await onChannelQuickReply?(radioID, UInt8(channelIndex), replyText) - } - - case NotificationAction.markRead.rawValue: - // Handle mark as read - let messageIDString = userInfo["messageID"] as? String - let messageID = messageIDString.flatMap { UUID(uuidString: $0) } - - if let contactIDString = userInfo["contactID"] as? String, - let contactID = UUID(uuidString: contactIDString), - let messageID { - // Direct message mark as read - await onMarkAsRead?(contactID, messageID) - } else if let channelIndex = userInfo["channelIndex"] as? Int, - let radioIDString = userInfo["radioID"] as? String, - let radioID = UUID(uuidString: radioIDString), - let messageID { - // Channel message mark as read (includes radioID for multi-device) - await onChannelMarkAsRead?(radioID, UInt8(channelIndex), messageID) - } else if let sessionIDString = userInfo["sessionID"] as? String, - let sessionID = UUID(uuidString: sessionIDString), - let messageID { - // Room message mark as read (identified by stable session ID) - await onRoomMarkAsRead?(sessionID, messageID) - } - - case UNNotificationDefaultActionIdentifier: - // User tapped the notification - let notificationType = userInfo["type"] as? String - - // Handle reaction notifications (includes messageID for scroll-to) - if notificationType == "reaction", - let messageIDString = userInfo["messageID"] as? String, - let messageID = UUID(uuidString: messageIDString) { - let contactID = (userInfo["contactID"] as? String).flatMap { UUID(uuidString: $0) } - let channelIndex = (userInfo["channelIndex"] as? Int).map { UInt8($0) } - let radioID = (userInfo["radioID"] as? String).flatMap { UUID(uuidString: $0) } - await onReactionNotificationTapped?(contactID, channelIndex, radioID, messageID) - } - // Handle contact-based notifications (DM, new contact) - else if let contactIDString = userInfo["contactID"] as? String, - let contactID = UUID(uuidString: contactIDString) { - if notificationType == "newContact" { - await onNewContactNotificationTapped?(contactID) - } else { - await onNotificationTapped?(contactID) - } - } - // Handle channel notifications - else if let channelIndex = userInfo["channelIndex"] as? Int, - let radioIDString = userInfo["radioID"] as? String, - let radioID = UUID(uuidString: radioIDString) { - await onChannelNotificationTapped?(radioID, UInt8(channelIndex)) - } - // Handle room notifications (identified by stable session ID) - else if let sessionIDString = userInfo["sessionID"] as? String, - let sessionID = UUID(uuidString: sessionIDString) { - await onRoomNotificationTapped?(sessionID) - } - - default: - break - } + // Show banner and sound for other notifications + return [.banner, .sound, .badge] + } + + /// Called when the user interacts with a notification. + /// With @preconcurrency, this method inherits @MainActor from the class, + /// so we can directly access self and all @Observable properties. + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let userInfo = response.notification.request.content.userInfo + + switch response.actionIdentifier { + case NotificationAction.reply.rawValue: + // Handle quick reply + guard let textResponse = response as? UNTextInputNotificationResponse else { + return + } + + let replyText = textResponse.userText + + // Check if it's a direct message reply (existing) + if let contactIDString = userInfo["contactID"] as? String, + let contactID = UUID(uuidString: contactIDString) { + await onQuickReply?(contactID, replyText) + } + // Check if it's a channel reply (new) + else if let channelIndex = userInfo["channelIndex"] as? Int, + let radioIDString = userInfo["radioID"] as? String, + let radioID = UUID(uuidString: radioIDString) { + await onChannelQuickReply?(radioID, UInt8(channelIndex), replyText) + } + + case NotificationAction.markRead.rawValue: + // Handle mark as read + let messageIDString = userInfo["messageID"] as? String + let messageID = messageIDString.flatMap { UUID(uuidString: $0) } + + if let contactIDString = userInfo["contactID"] as? String, + let contactID = UUID(uuidString: contactIDString), + let messageID { + // Direct message mark as read + await onMarkAsRead?(contactID, messageID) + } else if let channelIndex = userInfo["channelIndex"] as? Int, + let radioIDString = userInfo["radioID"] as? String, + let radioID = UUID(uuidString: radioIDString), + let messageID { + // Channel message mark as read (includes radioID for multi-device) + await onChannelMarkAsRead?(radioID, UInt8(channelIndex), messageID) + } else if let sessionIDString = userInfo["sessionID"] as? String, + let sessionID = UUID(uuidString: sessionIDString), + let messageID { + // Room message mark as read (identified by stable session ID) + await onRoomMarkAsRead?(sessionID, messageID) + } + + case UNNotificationDefaultActionIdentifier: + // User tapped the notification + let notificationType = userInfo["type"] as? String + + // Handle reaction notifications (includes messageID for scroll-to) + if notificationType == "reaction", + let messageIDString = userInfo["messageID"] as? String, + let messageID = UUID(uuidString: messageIDString) { + let contactID = (userInfo["contactID"] as? String).flatMap { UUID(uuidString: $0) } + let channelIndex = (userInfo["channelIndex"] as? Int).map { UInt8($0) } + let radioID = (userInfo["radioID"] as? String).flatMap { UUID(uuidString: $0) } + await onReactionNotificationTapped?(contactID, channelIndex, radioID, messageID) + } + // Handle contact-based notifications (DM, new contact) + else if let contactIDString = userInfo["contactID"] as? String, + let contactID = UUID(uuidString: contactIDString) { + if notificationType == "newContact" { + await onNewContactNotificationTapped?(contactID) + } else { + await onNotificationTapped?(contactID) + } + } + // Handle channel notifications + else if let channelIndex = userInfo["channelIndex"] as? Int, + let radioIDString = userInfo["radioID"] as? String, + let radioID = UUID(uuidString: radioIDString) { + await onChannelNotificationTapped?(radioID, UInt8(channelIndex)) + } + // Handle room notifications (identified by stable session ID) + else if let sessionIDString = userInfo["sessionID"] as? String, + let sessionID = UUID(uuidString: sessionIDString) { + await onRoomNotificationTapped?(sessionID) + } + + default: + break } + } } diff --git a/MC1Services/Sources/MC1Services/Services/PendingAck.swift b/MC1Services/Sources/MC1Services/Services/PendingAck.swift index 6ab96f97..f1fa272a 100644 --- a/MC1Services/Sources/MC1Services/Services/PendingAck.swift +++ b/MC1Services/Sources/MC1Services/Services/PendingAck.swift @@ -7,14 +7,14 @@ import Foundation /// All attempts for the same message share one `PendingAck` entry. /// /// DM-only: channel/room broadcasts do not generate ACKs and are not tracked here. -struct PendingAck: Sendable { - let messageID: UUID - let contactID: UUID - var ackCodes: Set - var sentAt: Date - /// The latest send attempt's ACK-wait derived from the firmware's `est_timeout` hint. - /// `checkExpiredAcks` reads this as the per-attempt floor on the give-up deadline - /// so a slow high-spreading-factor round-trip is not failed while still in flight. - var timeout: TimeInterval - var isDelivered: Bool = false +struct PendingAck { + let messageID: UUID + let contactID: UUID + var ackCodes: Set + var sentAt: Date + /// The latest send attempt's ACK-wait derived from the firmware's `est_timeout` hint. + /// `checkExpiredAcks` reads this as the per-attempt floor on the give-up deadline + /// so a slow high-spreading-factor round-trip is not failed while still in flight. + var timeout: TimeInterval + var isDelivered: Bool = false } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift index 3e681c3b..c4c537b2 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift @@ -8,530 +8,528 @@ private let firstRelocatableChannelIndex = 1 // MARK: - Batch Insert extension PersistenceStore { - - /// Inserts DTOs into the model context, skipping any whose `key` is - /// already in `existingKeys`. Covers the "insert if absent" shape used - /// by most backup batch-insert paths. - /// - /// Dedup is by business `key`; each model's surrogate `id` is a random UUIDv4 minted once - /// at creation, so a colliding `id` with a divergent business key is statistically - /// unreachable on any normal path. We deliberately do not re-mint ids here: re-minting a - /// child's `id` would orphan its inbound foreign keys (`Reaction.messageID`/ - /// `MessageRepeat.messageID` key on `Message.id`; `replyToID` is rewritten only for - /// duplicate parents). Only Device (recurring CBPeripheral id) and Channel (children key by - /// slot/`channelIndex`, never `Channel.id`) are safe to re-mint, and both already do - /// (`batchInsertDevices`, `batchInsertChannels`). If a future change ever makes an `id` - /// content-derived or reused across rows, revisit this — a colliding insert would upsert the - /// local row. - @discardableResult - private func insertUnique( - _ dtos: [DTO], - existingKeys: Set, - key: (DTO) -> Key, - construct: (DTO) -> M - ) -> (inserted: Int, skipped: Int) { - var knownKeys = existingKeys - var inserted = 0 - var skipped = 0 - for dto in dtos { - if !knownKeys.insert(key(dto)).inserted { - skipped += 1 - continue - } - modelContext.insert(construct(dto)) - inserted += 1 - } - return (inserted, skipped) + /// Inserts DTOs into the model context, skipping any whose `key` is + /// already in `existingKeys`. Covers the "insert if absent" shape used + /// by most backup batch-insert paths. + /// + /// Dedup is by business `key`; each model's surrogate `id` is a random UUIDv4 minted once + /// at creation, so a colliding `id` with a divergent business key is statistically + /// unreachable on any normal path. We deliberately do not re-mint ids here: re-minting a + /// child's `id` would orphan its inbound foreign keys (`Reaction.messageID`/ + /// `MessageRepeat.messageID` key on `Message.id`; `replyToID` is rewritten only for + /// duplicate parents). Only Device (recurring CBPeripheral id) and Channel (children key by + /// slot/`channelIndex`, never `Channel.id`) are safe to re-mint, and both already do + /// (`batchInsertDevices`, `batchInsertChannels`). If a future change ever makes an `id` + /// content-derived or reused across rows, revisit this — a colliding insert would upsert the + /// local row. + @discardableResult + private func insertUnique( + _ dtos: [DTO], + existingKeys: Set, + key: (DTO) -> Key, + construct: (DTO) -> some PersistentModel + ) -> (inserted: Int, skipped: Int) { + var knownKeys = existingKeys + var inserted = 0 + var skipped = 0 + for dto in dtos { + if !knownKeys.insert(key(dto)).inserted { + skipped += 1 + continue + } + modelContext.insert(construct(dto)) + inserted += 1 } - - /// Inserts DTOs whose parent exists in `existingParentIDs`, skipping - /// orphans or duplicate keys. Inserted DTOs contribute their parent - /// ID to `affectedParentIDs` for downstream recompute work. - @discardableResult - private func insertUniqueWithParent( - _ dtos: [DTO], - existingKeys: Set, - existingParentIDs: Set, - parentID: (DTO) -> ParentKey, - key: (DTO) -> Key, - construct: (DTO) -> M - ) -> (inserted: Int, skipped: Int, affectedParentIDs: Set) { - var knownKeys = existingKeys - var inserted = 0 - var skipped = 0 - var affectedParentIDs = Set() - for dto in dtos { - let parent = parentID(dto) - guard existingParentIDs.contains(parent) else { - skipped += 1 - continue - } - if !knownKeys.insert(key(dto)).inserted { - skipped += 1 - continue - } - modelContext.insert(construct(dto)) - affectedParentIDs.insert(parent) - inserted += 1 + return (inserted, skipped) + } + + /// Inserts DTOs whose parent exists in `existingParentIDs`, skipping + /// orphans or duplicate keys. Inserted DTOs contribute their parent + /// ID to `affectedParentIDs` for downstream recompute work. + @discardableResult + private func insertUniqueWithParent( + _ dtos: [DTO], + existingKeys: Set, + existingParentIDs: Set, + parentID: (DTO) -> ParentKey, + key: (DTO) -> Key, + construct: (DTO) -> some PersistentModel + ) -> (inserted: Int, skipped: Int, affectedParentIDs: Set) { + var knownKeys = existingKeys + var inserted = 0 + var skipped = 0 + var affectedParentIDs = Set() + for dto in dtos { + let parent = parentID(dto) + guard existingParentIDs.contains(parent) else { + skipped += 1 + continue + } + if !knownKeys.insert(key(dto)).inserted { + skipped += 1 + continue + } + modelContext.insert(construct(dto)) + affectedParentIDs.insert(parent) + inserted += 1 + } + return (inserted, skipped, affectedParentIDs) + } + + @discardableResult + func batchInsertDevices( + _ dtos: [DeviceDTO], + existingKeys: Set + ) throws -> (inserted: Int, skipped: Int) { + // Assign a fresh Device.id on insert: the backup UUID was `CBPeripheral.identifier` + // from the source phone, and `Device.id` is `@Attribute(.unique)`. Reusing the + // backup UUID here could collide with a live local peripheral identifier and + // trigger SwiftData's upsert, silently overwriting the current Device row. + // Child records key by `radioID`, not `Device.id`, so the fresh id breaks no + // linkage; `ConnectionManager.buildServicesAndSaveDevice` cleans up the stub + // row when the user reconnects to the real radio. + insertUnique( + dtos, + existingKeys: existingKeys, + key: \.publicKey, + construct: { Device(dto: $0.cleanedForImport().copy { $0.id = UUID() }) } + ) + } + + @discardableResult + func batchInsertContacts( + _ dtos: [ContactDTO], + radioIDs: Set + ) throws -> (inserted: Int, skipped: Int, merged: Int, contactIDsByKey: [String: UUID]) { + var existingContactsByKey = try fetchExistingContactsByKey(radioIDs: radioIDs) + var contactIDsByKey: [String: UUID] = [:] + contactIDsByKey.reserveCapacity(dtos.count) + var inserted = 0 + var skipped = 0 + var merged = 0 + for dto in dtos { + let key = contactKey(radioID: dto.radioID, publicKey: dto.publicKey) + if let existing = existingContactsByKey[key] { + if mergeBackupMetadata(into: existing, from: dto) { + merged += 1 } - return (inserted, skipped, affectedParentIDs) + skipped += 1 + contactIDsByKey[key] = existing.id + continue + } + let contact = Contact(dto: dto) + modelContext.insert(contact) + existingContactsByKey[key] = contact + contactIDsByKey[key] = contact.id + inserted += 1 } - @discardableResult - func batchInsertDevices( - _ dtos: [DeviceDTO], - existingKeys: Set - ) throws -> (inserted: Int, skipped: Int) { - // Assign a fresh Device.id on insert: the backup UUID was `CBPeripheral.identifier` - // from the source phone, and `Device.id` is `@Attribute(.unique)`. Reusing the - // backup UUID here could collide with a live local peripheral identifier and - // trigger SwiftData's upsert, silently overwriting the current Device row. - // Child records key by `radioID`, not `Device.id`, so the fresh id breaks no - // linkage; `ConnectionManager.buildServicesAndSaveDevice` cleans up the stub - // row when the user reconnects to the real radio. - insertUnique( - dtos, - existingKeys: existingKeys, - key: \.publicKey, - construct: { Device(dto: $0.cleanedForImport().copy { $0.id = UUID() }) } - ) + return (inserted, skipped, merged, contactIDsByKey) + } + + /// Outcome of reconciling backup channels against local channels. + /// + /// `channelIndexRemap` maps `radioID -> [backupIndex: localIndex]` for every channel + /// whose placement differs from its backup slot, and `droppedChannelIndices` lists the + /// `(radioID, backupIndex)` slots that had no free local placement. The caller applies + /// both to channel messages before insert. + /// + /// `insertedLocalIndices` lists the local slots a foreign backup channel newly occupied + /// (every insert, whether relocated or placed at its own free index). These are the only + /// slots whose local channel identity changed, so they — together with `droppedChannelIndices` + /// — are the slots whose chat drafts the caller must clear. Slots reached via secret/slot + /// merge are excluded: their occupant is unchanged, so their draft stays valid. + struct ChannelBatchInsertResult { + let inserted: Int + let skipped: Int + let merged: Int + let dropped: Int + let channelIndexRemap: [UUID: [UInt8: UInt8]] + let droppedChannelIndices: [UUID: Set] + let insertedLocalIndices: [UUID: Set] + } + + /// Reconciles backup channels against local channels by stable cryptographic identity + /// `(radioID, secret)`, treating the slot `index` as mere placement. A backup channel + /// whose secret matches a local channel merges into it (and records a message-index + /// remap if the slot differs); a backup channel with no local secret match is placed at + /// its own slot when free, otherwise relocated to the lowest free slot within the + /// radio's channel capacity. Channels with an empty (all-zero) secret carry no stable + /// identity — the public channel and unconfigured slots — so they fall back to + /// index-based placement, matching legacy behavior and keeping distinct empty-secret + /// slots from collapsing. + /// + /// A backup channel with no free slot is dropped and reported via `skipped`; its + /// messages are dropped by the caller because no placement exists. + @discardableResult + func batchInsertChannels( + _ dtos: [ChannelDTO], + radioIDs: Set, + maxChannelsByRadioID: [UUID: UInt8] = [:] + ) throws -> ChannelBatchInsertResult { + let existingChannels = try fetchExistingChannels(radioIDs: radioIDs) + + var localChannelsBySecret: [UUID: [Data: Channel]] = [:] + var occupiedIndicesByRadioID: [UUID: Set] = [:] + var localChannelByIndex: [UUID: [UInt8: Channel]] = [:] + for channel in existingChannels { + occupiedIndicesByRadioID[channel.radioID, default: []].insert(channel.index) + localChannelByIndex[channel.radioID, default: [:]][channel.index] = channel + if channelHasStableSecret(channel.secret) { + localChannelsBySecret[channel.radioID, default: [:]][channel.secret] = channel + } } - @discardableResult - func batchInsertContacts( - _ dtos: [ContactDTO], - radioIDs: Set - ) throws -> (inserted: Int, skipped: Int, merged: Int, contactIDsByKey: [String: UUID]) { - var existingContactsByKey = try fetchExistingContactsByKey(radioIDs: radioIDs) - var contactIDsByKey: [String: UUID] = [:] - contactIDsByKey.reserveCapacity(dtos.count) - var inserted = 0 - var skipped = 0 - var merged = 0 - for dto in dtos { - let key = contactKey(radioID: dto.radioID, publicKey: dto.publicKey) - if let existing = existingContactsByKey[key] { - if mergeBackupMetadata(into: existing, from: dto) { - merged += 1 - } - skipped += 1 - contactIDsByKey[key] = existing.id - continue - } - let contact = Contact(dto: dto) - modelContext.insert(contact) - existingContactsByKey[key] = contact - contactIDsByKey[key] = contact.id - inserted += 1 - } - - return (inserted, skipped, merged, contactIDsByKey) + var inserted = 0 + var skipped = 0 + var merged = 0 + var dropped = 0 + var channelIndexRemap: [UUID: [UInt8: UInt8]] = [:] + var droppedChannelIndices: [UUID: Set] = [:] + var insertedLocalIndices: [UUID: Set] = [:] + + // Sort by (radioID, index) so free-slot assignment is deterministic regardless of + // the envelope's array order. + let orderedDTOs = dtos.sorted { + $0.radioID == $1.radioID ? $0.index < $1.index : $0.radioID.uuidString < $1.radioID.uuidString } - /// Outcome of reconciling backup channels against local channels. - /// - /// `channelIndexRemap` maps `radioID -> [backupIndex: localIndex]` for every channel - /// whose placement differs from its backup slot, and `droppedChannelIndices` lists the - /// `(radioID, backupIndex)` slots that had no free local placement. The caller applies - /// both to channel messages before insert. - /// - /// `insertedLocalIndices` lists the local slots a foreign backup channel newly occupied - /// (every insert, whether relocated or placed at its own free index). These are the only - /// slots whose local channel identity changed, so they — together with `droppedChannelIndices` - /// — are the slots whose chat drafts the caller must clear. Slots reached via secret/slot - /// merge are excluded: their occupant is unchanged, so their draft stays valid. - struct ChannelBatchInsertResult: Sendable { - let inserted: Int - let skipped: Int - let merged: Int - let dropped: Int - let channelIndexRemap: [UUID: [UInt8: UInt8]] - let droppedChannelIndices: [UUID: Set] - let insertedLocalIndices: [UUID: Set] - } + for dto in orderedDTOs { + let radioID = dto.radioID - /// Reconciles backup channels against local channels by stable cryptographic identity - /// `(radioID, secret)`, treating the slot `index` as mere placement. A backup channel - /// whose secret matches a local channel merges into it (and records a message-index - /// remap if the slot differs); a backup channel with no local secret match is placed at - /// its own slot when free, otherwise relocated to the lowest free slot within the - /// radio's channel capacity. Channels with an empty (all-zero) secret carry no stable - /// identity — the public channel and unconfigured slots — so they fall back to - /// index-based placement, matching legacy behavior and keeping distinct empty-secret - /// slots from collapsing. - /// - /// A backup channel with no free slot is dropped and reported via `skipped`; its - /// messages are dropped by the caller because no placement exists. - @discardableResult - func batchInsertChannels( - _ dtos: [ChannelDTO], - radioIDs: Set, - maxChannelsByRadioID: [UUID: UInt8] = [:] - ) throws -> ChannelBatchInsertResult { - let existingChannels = try fetchExistingChannels(radioIDs: radioIDs) - - var localChannelsBySecret: [UUID: [Data: Channel]] = [:] - var occupiedIndicesByRadioID: [UUID: Set] = [:] - var localChannelByIndex: [UUID: [UInt8: Channel]] = [:] - for channel in existingChannels { - occupiedIndicesByRadioID[channel.radioID, default: []].insert(channel.index) - localChannelByIndex[channel.radioID, default: [:]][channel.index] = channel - if channelHasStableSecret(channel.secret) { - localChannelsBySecret[channel.radioID, default: [:]][channel.secret] = channel - } + if channelHasStableSecret(dto.secret), + let existing = localChannelsBySecret[radioID]?[dto.secret] { + if mergeBackupMetadata(into: existing, from: dto) { + merged += 1 } - - var inserted = 0 - var skipped = 0 - var merged = 0 - var dropped = 0 - var channelIndexRemap: [UUID: [UInt8: UInt8]] = [:] - var droppedChannelIndices: [UUID: Set] = [:] - var insertedLocalIndices: [UUID: Set] = [:] - - // Sort by (radioID, index) so free-slot assignment is deterministic regardless of - // the envelope's array order. - let orderedDTOs = dtos.sorted { - $0.radioID == $1.radioID ? $0.index < $1.index : $0.radioID.uuidString < $1.radioID.uuidString + skipped += 1 + if existing.index != dto.index { + channelIndexRemap[radioID, default: [:]][dto.index] = existing.index } - - for dto in orderedDTOs { - let radioID = dto.radioID - - if channelHasStableSecret(dto.secret), - let existing = localChannelsBySecret[radioID]?[dto.secret] { - if mergeBackupMetadata(into: existing, from: dto) { - merged += 1 - } - skipped += 1 - if existing.index != dto.index { - channelIndexRemap[radioID, default: [:]][dto.index] = existing.index - } - continue - } - - // Empty-secret channels reconcile by slot (the public channel's slot is its - // identity); a local channel already in that slot is the merge target. - if !channelHasStableSecret(dto.secret), - let existing = localChannelByIndex[radioID]?[dto.index] { - if mergeBackupMetadata(into: existing, from: dto) { - merged += 1 - } - skipped += 1 - continue - } - - guard let placementIndex = resolveChannelPlacementIndex( - backupIndex: dto.index, - occupiedIndices: occupiedIndicesByRadioID[radioID] ?? [], - maxChannels: maxChannelsByRadioID[radioID] - ) else { - backupLogger.warning( - "Backup channel at slot \(dto.index) for radio \(radioID.uuidString) has no free local slot; dropping it and its messages." - ) - dropped += 1 - droppedChannelIndices[radioID, default: []].insert(dto.index) - continue - } - - // Re-mint the surrogate id on insert so a backup channel can never upsert a live - // local channel that shares its `@Attribute(.unique)` id (e.g. re-importing a stale - // backup after the same slot was reconfigured and its secret rotated in place). - // Channel has no inbound foreign key, so the fresh id breaks no message/reaction - // linkage (those key by channelIndex). Mirrors `batchInsertDevices`. - let relocatedDTO = placementIndex == dto.index ? dto : dto.with(index: placementIndex) - let placedDTO = relocatedDTO.with(id: UUID()) - let channel = Channel(dto: placedDTO) - modelContext.insert(channel) - occupiedIndicesByRadioID[radioID, default: []].insert(placementIndex) - localChannelByIndex[radioID, default: [:]][placementIndex] = channel - if channelHasStableSecret(channel.secret) { - localChannelsBySecret[radioID, default: [:]][channel.secret] = channel - } - insertedLocalIndices[radioID, default: []].insert(placementIndex) - if placementIndex != dto.index { - channelIndexRemap[radioID, default: [:]][dto.index] = placementIndex - } - inserted += 1 + continue + } + + // Empty-secret channels reconcile by slot (the public channel's slot is its + // identity); a local channel already in that slot is the merge target. + if !channelHasStableSecret(dto.secret), + let existing = localChannelByIndex[radioID]?[dto.index] { + if mergeBackupMetadata(into: existing, from: dto) { + merged += 1 } - return ChannelBatchInsertResult( - inserted: inserted, - skipped: skipped, - merged: merged, - dropped: dropped, - channelIndexRemap: channelIndexRemap, - droppedChannelIndices: droppedChannelIndices, - insertedLocalIndices: insertedLocalIndices + skipped += 1 + continue + } + + guard let placementIndex = resolveChannelPlacementIndex( + backupIndex: dto.index, + occupiedIndices: occupiedIndicesByRadioID[radioID] ?? [], + maxChannels: maxChannelsByRadioID[radioID] + ) else { + backupLogger.warning( + "Backup channel at slot \(dto.index) for radio \(radioID.uuidString) has no free local slot; dropping it and its messages." ) + dropped += 1 + droppedChannelIndices[radioID, default: []].insert(dto.index) + continue + } + + // Re-mint the surrogate id on insert so a backup channel can never upsert a live + // local channel that shares its `@Attribute(.unique)` id (e.g. re-importing a stale + // backup after the same slot was reconfigured and its secret rotated in place). + // Channel has no inbound foreign key, so the fresh id breaks no message/reaction + // linkage (those key by channelIndex). Mirrors `batchInsertDevices`. + let relocatedDTO = placementIndex == dto.index ? dto : dto.with(index: placementIndex) + let placedDTO = relocatedDTO.with(id: UUID()) + let channel = Channel(dto: placedDTO) + modelContext.insert(channel) + occupiedIndicesByRadioID[radioID, default: []].insert(placementIndex) + localChannelByIndex[radioID, default: [:]][placementIndex] = channel + if channelHasStableSecret(channel.secret) { + localChannelsBySecret[radioID, default: [:]][channel.secret] = channel + } + insertedLocalIndices[radioID, default: []].insert(placementIndex) + if placementIndex != dto.index { + channelIndexRemap[radioID, default: [:]][dto.index] = placementIndex + } + inserted += 1 } - - /// Lowest free slot for a relocating channel: prefer the backup's own slot when free, - /// otherwise the lowest unoccupied index within `[firstRelocatableChannelIndex, maxChannels)` - /// so slot 0 stays reserved for the public channel. Returns nil when no slot is free so the - /// caller can drop the channel rather than mis-associate it. When `maxChannels` is unavailable - /// (no matching device DTO), the search is bounded by the highest occupied slot plus one so it - /// still terminates without inventing a capacity. - private func resolveChannelPlacementIndex( - backupIndex: UInt8, - occupiedIndices: Set, - maxChannels: UInt8? - ) -> UInt8? { - if !occupiedIndices.contains(backupIndex) { - return backupIndex - } - let upperBound: Int - if let maxChannels { - upperBound = Int(maxChannels) - } else { - upperBound = Int(occupiedIndices.max() ?? 0) + 1 - } - guard upperBound > firstRelocatableChannelIndex else { return nil } - for candidate in firstRelocatableChannelIndex.., + maxChannels: UInt8? + ) -> UInt8? { + if !occupiedIndices.contains(backupIndex) { + return backupIndex } - - /// A channel secret carries stable cryptographic identity only when it is non-empty. - /// An all-zero secret marks the public channel or an unconfigured slot, which is keyed - /// by slot index instead. - private func channelHasStableSecret(_ secret: Data) -> Bool { - !secret.isEmpty && !secret.allSatisfy { $0 == 0 } + let upperBound = if let maxChannels { + Int(maxChannels) + } else { + Int(occupiedIndices.max() ?? 0) + 1 } - - @discardableResult - func batchInsertMessages( - _ dtos: [MessageDTO], - existingKeys: Set, - existingIDsByKey: [String: [UUID]] - ) throws -> (inserted: Int, skipped: Int, messageIDByBackupID: [UUID: UUID]) { - var knownKeys = existingKeys - var idsByKey = existingIDsByKey - var messageIDByBackupID: [UUID: UUID] = [:] - var toInsert: [MessageDTO] = [] - toInsert.reserveCapacity(dtos.count) - var skipped = 0 - - // Pass 1: resolve duplicates and build the backup→local ID remap. Doing this - // before any insert lets pass 2 rewrite replyToID onto winning local UUIDs, - // so reply chains survive imports where the parent already exists locally. - for dto in dtos { - let key = messageBackupKey(for: dto) - if knownKeys.contains(key) { - // Deterministic tiebreak when multiple locals share a key. - let winning = idsByKey[key]?.min(by: { $0.uuidString < $1.uuidString }) - if let winning, winning != dto.id { - messageIDByBackupID[dto.id] = winning - } - skipped += 1 - continue - } - knownKeys.insert(key) - idsByKey[key, default: []].append(dto.id) - toInsert.append(dto) - } - - // Pass 2: insert, rewriting replyToID through the remap so retained messages - // point at the winning local parent rather than a skipped backup UUID. - for var dto in toInsert { - if let replyTo = dto.replyToID, let winning = messageIDByBackupID[replyTo] { - dto.replyToID = winning - } - modelContext.insert(Message(dto: dto)) - } - - return (inserted: toInsert.count, skipped: skipped, messageIDByBackupID: messageIDByBackupID) + guard upperBound > firstRelocatableChannelIndex else { return nil } + for candidate in firstRelocatableChannelIndex.., - existingMessageIDs: Set - ) throws -> (inserted: Int, skipped: Int, affectedMessageIDs: Set) { - // Only pre-fetch parents that actually appear as a MessageRepeat.messageID; - // on a large import the repeat set is orders of magnitude smaller than - // the full envelope message set, so loading every message into the - // context just to key a relationship map is wasted work. - let referencedParentIDs = Set(dtos.map(\.messageID)).intersection(existingMessageIDs) - let parentMessages = try fetchInChunks(keys: Array(referencedParentIDs)) { chunk in - let predicate = #Predicate { chunk.contains($0.id) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + return nil + } + + /// A channel secret carries stable cryptographic identity only when it is non-empty. + /// An all-zero secret marks the public channel or an unconfigured slot, which is keyed + /// by slot index instead. + private func channelHasStableSecret(_ secret: Data) -> Bool { + !secret.isEmpty && !secret.allSatisfy { $0 == 0 } + } + + @discardableResult + func batchInsertMessages( + _ dtos: [MessageDTO], + existingKeys: Set, + existingIDsByKey: [String: [UUID]] + ) throws -> (inserted: Int, skipped: Int, messageIDByBackupID: [UUID: UUID]) { + var knownKeys = existingKeys + var idsByKey = existingIDsByKey + var messageIDByBackupID: [UUID: UUID] = [:] + var toInsert: [MessageDTO] = [] + toInsert.reserveCapacity(dtos.count) + var skipped = 0 + + // Pass 1: resolve duplicates and build the backup→local ID remap. Doing this + // before any insert lets pass 2 rewrite replyToID onto winning local UUIDs, + // so reply chains survive imports where the parent already exists locally. + for dto in dtos { + let key = messageBackupKey(for: dto) + if knownKeys.contains(key) { + // Deterministic tiebreak when multiple locals share a key. + let winning = idsByKey[key]?.min(by: { $0.uuidString < $1.uuidString }) + if let winning, winning != dto.id { + messageIDByBackupID[dto.id] = winning } - let messagesByID = Dictionary(uniqueKeysWithValues: parentMessages.map { ($0.id, $0) }) - - let result = insertUniqueWithParent( - dtos, - existingKeys: existingIDs, - existingParentIDs: existingMessageIDs, - parentID: \.messageID, - key: \.id, - construct: { MessageRepeat(dto: $0, message: messagesByID[$0.messageID]) } - ) - return (result.inserted, result.skipped, result.affectedParentIDs) + skipped += 1 + continue + } + knownKeys.insert(key) + idsByKey[key, default: []].append(dto.id) + toInsert.append(dto) } - @discardableResult - func batchInsertReactions( - _ dtos: [ReactionDTO], - existingKeys: Set, - existingMessageIDs: Set - ) throws -> (inserted: Int, skipped: Int, affectedMessageIDs: Set) { - let result = insertUniqueWithParent( - dtos, - existingKeys: existingKeys, - existingParentIDs: existingMessageIDs, - parentID: \.messageID, - key: { reactionKey(messageID: $0.messageID, senderName: $0.senderName, emoji: $0.emoji) }, - construct: Reaction.init(dto:) - ) - return (result.inserted, result.skipped, result.affectedParentIDs) + // Pass 2: insert, rewriting replyToID through the remap so retained messages + // point at the winning local parent rather than a skipped backup UUID. + for var dto in toInsert { + if let replyTo = dto.replyToID, let winning = messageIDByBackupID[replyTo] { + dto.replyToID = winning + } + modelContext.insert(Message(dto: dto)) } - @discardableResult - func batchInsertRoomMessages( - _ dtos: [RoomMessageDTO], - existingKeys: Set, - existingSessionIDs: Set - ) throws -> (inserted: Int, skipped: Int, affectedSessionIDs: Set) { - let result = insertUniqueWithParent( - dtos, - existingKeys: existingKeys, - existingParentIDs: existingSessionIDs, - parentID: \.sessionID, - key: { roomMessageKey(sessionID: $0.sessionID, deduplicationKey: $0.deduplicationKey) }, - construct: RoomMessage.init(dto:) - ) - return (result.inserted, result.skipped, result.affectedParentIDs) + return (inserted: toInsert.count, skipped: skipped, messageIDByBackupID: messageIDByBackupID) + } + + @discardableResult + func batchInsertMessageRepeats( + _ dtos: [MessageRepeatDTO], + existingIDs: Set, + existingMessageIDs: Set + ) throws -> (inserted: Int, skipped: Int, affectedMessageIDs: Set) { + // Only pre-fetch parents that actually appear as a MessageRepeat.messageID; + // on a large import the repeat set is orders of magnitude smaller than + // the full envelope message set, so loading every message into the + // context just to key a relationship map is wasted work. + let referencedParentIDs = Set(dtos.map(\.messageID)).intersection(existingMessageIDs) + let parentMessages = try fetchInChunks(keys: Array(referencedParentIDs)) { chunk in + let predicate = #Predicate { chunk.contains($0.id) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } - - @discardableResult - func batchInsertRemoteNodeSessions( - _ dtos: [RemoteNodeSessionDTO], - radioIDs: Set - ) throws -> (inserted: Int, skipped: Int, merged: Int, sessionIDsByKey: [String: UUID]) { - var existingSessionsByKey = try fetchExistingRemoteNodeSessionsByKey(radioIDs: radioIDs) - var sessionIDsByKey: [String: UUID] = [:] - sessionIDsByKey.reserveCapacity(dtos.count) - var inserted = 0 - var skipped = 0 - var merged = 0 - for dto in dtos { - let key = remoteNodeSessionKey(radioID: dto.radioID, publicKey: dto.publicKey) - if let existing = existingSessionsByKey[key] { - if mergeBackupMetadata(into: existing, from: dto) { - merged += 1 - } - skipped += 1 - sessionIDsByKey[key] = existing.id - continue - } - let session = RemoteNodeSession(dto: dto) - // Restored sessions are never live BLE connections. - session.isConnected = false - modelContext.insert(session) - existingSessionsByKey[key] = session - sessionIDsByKey[key] = session.id - inserted += 1 + let messagesByID = Dictionary(uniqueKeysWithValues: parentMessages.map { ($0.id, $0) }) + + let result = insertUniqueWithParent( + dtos, + existingKeys: existingIDs, + existingParentIDs: existingMessageIDs, + parentID: \.messageID, + key: \.id, + construct: { MessageRepeat(dto: $0, message: messagesByID[$0.messageID]) } + ) + return (result.inserted, result.skipped, result.affectedParentIDs) + } + + @discardableResult + func batchInsertReactions( + _ dtos: [ReactionDTO], + existingKeys: Set, + existingMessageIDs: Set + ) throws -> (inserted: Int, skipped: Int, affectedMessageIDs: Set) { + let result = insertUniqueWithParent( + dtos, + existingKeys: existingKeys, + existingParentIDs: existingMessageIDs, + parentID: \.messageID, + key: { reactionKey(messageID: $0.messageID, senderName: $0.senderName, emoji: $0.emoji) }, + construct: Reaction.init(dto:) + ) + return (result.inserted, result.skipped, result.affectedParentIDs) + } + + @discardableResult + func batchInsertRoomMessages( + _ dtos: [RoomMessageDTO], + existingKeys: Set, + existingSessionIDs: Set + ) throws -> (inserted: Int, skipped: Int, affectedSessionIDs: Set) { + let result = insertUniqueWithParent( + dtos, + existingKeys: existingKeys, + existingParentIDs: existingSessionIDs, + parentID: \.sessionID, + key: { roomMessageKey(sessionID: $0.sessionID, deduplicationKey: $0.deduplicationKey) }, + construct: RoomMessage.init(dto:) + ) + return (result.inserted, result.skipped, result.affectedParentIDs) + } + + @discardableResult + func batchInsertRemoteNodeSessions( + _ dtos: [RemoteNodeSessionDTO], + radioIDs: Set + ) throws -> (inserted: Int, skipped: Int, merged: Int, sessionIDsByKey: [String: UUID]) { + var existingSessionsByKey = try fetchExistingRemoteNodeSessionsByKey(radioIDs: radioIDs) + var sessionIDsByKey: [String: UUID] = [:] + sessionIDsByKey.reserveCapacity(dtos.count) + var inserted = 0 + var skipped = 0 + var merged = 0 + for dto in dtos { + let key = remoteNodeSessionKey(radioID: dto.radioID, publicKey: dto.publicKey) + if let existing = existingSessionsByKey[key] { + if mergeBackupMetadata(into: existing, from: dto) { + merged += 1 } + skipped += 1 + sessionIDsByKey[key] = existing.id + continue + } + let session = RemoteNodeSession(dto: dto) + // Restored sessions are never live BLE connections. + session.isConnected = false + modelContext.insert(session) + existingSessionsByKey[key] = session + sessionIDsByKey[key] = session.id + inserted += 1 + } - return (inserted, skipped, merged, sessionIDsByKey) + return (inserted, skipped, merged, sessionIDsByKey) + } + + @discardableResult + func batchInsertSavedTracePaths( + _ dtos: [SavedTracePathDTO] + ) throws -> (inserted: Int, skipped: Int, merged: Int) { + guard !dtos.isEmpty else { return (0, 0, 0) } + + var inserted = 0 + var skipped = 0 + var merged = 0 + let radioIDArray = Array(Set(dtos.map(\.radioID))) + let existingPaths = try fetchInChunks(keys: radioIDArray) { chunk in + let predicate = #Predicate { chunk.contains($0.radioID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } + var existingPathsByKey: [String: SavedTracePath] = [:] - @discardableResult - func batchInsertSavedTracePaths( - _ dtos: [SavedTracePathDTO] - ) throws -> (inserted: Int, skipped: Int, merged: Int) { - guard !dtos.isEmpty else { return (0, 0, 0) } - - var inserted = 0 - var skipped = 0 - var merged = 0 - let radioIDArray = Array(Set(dtos.map(\.radioID))) - let existingPaths = try fetchInChunks(keys: radioIDArray) { chunk in - let predicate = #Predicate { chunk.contains($0.radioID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - var existingPathsByKey: [String: SavedTracePath] = [:] + for path in existingPaths { + let pathKey = savedTracePathKey(radioID: path.radioID, pathBytes: path.pathBytes, hashSize: path.hashSize) + existingPathsByKey[pathKey] = path + } - for path in existingPaths { - let pathKey = savedTracePathKey(radioID: path.radioID, pathBytes: path.pathBytes, hashSize: path.hashSize) - existingPathsByKey[pathKey] = path + // Run ids are globally unique (`TracePathRun.id` is `.unique`), so dedupe + // store-wide, not per path: inserting a run whose id already exists + // anywhere would upsert the existing row and silently relocate it under + // a different path. + var seenRunIDs = try Set(modelContext.fetch(FetchDescriptor()).map(\.id)) + + for dto in dtos { + let key = savedTracePathKey(radioID: dto.radioID, pathBytes: dto.pathBytes, hashSize: dto.hashSize) + if let existingPath = existingPathsByKey[key] { + skipped += 1 + var appendedRun = false + + for runDTO in dto.runs where !seenRunIDs.contains(runDTO.id) { + let run = try TracePathRun(dto: runDTO) + run.savedPath = existingPath + modelContext.insert(run) + seenRunIDs.insert(runDTO.id) + appendedRun = true } - // Run ids are globally unique (`TracePathRun.id` is `.unique`), so dedupe - // store-wide, not per path: inserting a run whose id already exists - // anywhere would upsert the existing row and silently relocate it under - // a different path. - var seenRunIDs = Set(try modelContext.fetch(FetchDescriptor()).map(\.id)) - - for dto in dtos { - let key = savedTracePathKey(radioID: dto.radioID, pathBytes: dto.pathBytes, hashSize: dto.hashSize) - if let existingPath = existingPathsByKey[key] { - skipped += 1 - var appendedRun = false - - for runDTO in dto.runs where !seenRunIDs.contains(runDTO.id) { - let run = try TracePathRun(dto: runDTO) - run.savedPath = existingPath - modelContext.insert(run) - seenRunIDs.insert(runDTO.id) - appendedRun = true - } - - if appendedRun { - merged += 1 - } - continue - } - - let path = SavedTracePath( - id: dto.id, - radioID: dto.radioID, - name: dto.name, - pathBytes: dto.pathBytes, - hashSize: dto.hashSize, - createdDate: dto.createdDate - ) - modelContext.insert(path) - - for runDTO in dto.runs { - guard !seenRunIDs.contains(runDTO.id) else { continue } - let run = try TracePathRun(dto: runDTO) - run.savedPath = path - modelContext.insert(run) - seenRunIDs.insert(runDTO.id) - } - - existingPathsByKey[key] = path - inserted += 1 + if appendedRun { + merged += 1 } - return (inserted, skipped, merged) - } - - @discardableResult - func batchInsertBlockedChannelSenders( - _ dtos: [BlockedChannelSenderDTO], - existingKeys: Set - ) throws -> (inserted: Int, skipped: Int) { - insertUnique( - dtos, - existingKeys: existingKeys, - key: { blockedChannelSenderKey(radioID: $0.radioID, name: $0.name) }, - construct: BlockedChannelSender.init(dto:) - ) - } - - @discardableResult - func batchInsertNodeStatusSnapshots( - _ dtos: [NodeStatusSnapshotDTO], - existingKeys: Set - ) throws -> (inserted: Int, skipped: Int) { - insertUnique( - dtos, - existingKeys: existingKeys, - key: { nodeStatusSnapshotKey(nodePublicKey: $0.nodePublicKey, timestamp: $0.timestamp) }, - construct: NodeStatusSnapshot.init(dto:) - ) + continue + } + + let path = SavedTracePath( + id: dto.id, + radioID: dto.radioID, + name: dto.name, + pathBytes: dto.pathBytes, + hashSize: dto.hashSize, + createdDate: dto.createdDate + ) + modelContext.insert(path) + + for runDTO in dto.runs { + guard !seenRunIDs.contains(runDTO.id) else { continue } + let run = try TracePathRun(dto: runDTO) + run.savedPath = path + modelContext.insert(run) + seenRunIDs.insert(runDTO.id) + } + + existingPathsByKey[key] = path + inserted += 1 } + return (inserted, skipped, merged) + } + + @discardableResult + func batchInsertBlockedChannelSenders( + _ dtos: [BlockedChannelSenderDTO], + existingKeys: Set + ) throws -> (inserted: Int, skipped: Int) { + insertUnique( + dtos, + existingKeys: existingKeys, + key: { blockedChannelSenderKey(radioID: $0.radioID, name: $0.name) }, + construct: BlockedChannelSender.init(dto:) + ) + } + + @discardableResult + func batchInsertNodeStatusSnapshots( + _ dtos: [NodeStatusSnapshotDTO], + existingKeys: Set + ) throws -> (inserted: Int, skipped: Int) { + insertUnique( + dtos, + existingKeys: existingKeys, + key: { nodeStatusSnapshotKey(nodePublicKey: $0.nodePublicKey, timestamp: $0.timestamp) }, + construct: NodeStatusSnapshot.init(dto:) + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupExport.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupExport.swift index 652e24b2..c7c0ecc5 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupExport.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupExport.swift @@ -1,139 +1,138 @@ import Foundation import SwiftData -struct BackupExportSnapshot: Sendable { - let devices: [DeviceDTO] - let contacts: [ContactDTO] - let channels: [ChannelDTO] - let messages: [MessageDTO] - let messageRepeats: [MessageRepeatDTO] - let reactions: [ReactionDTO] - let roomMessages: [RoomMessageDTO] - let remoteNodeSessions: [RemoteNodeSessionDTO] - let savedTracePaths: [SavedTracePathDTO] - let blockedChannelSenders: [BlockedChannelSenderDTO] - let nodeStatusSnapshots: [NodeStatusSnapshotDTO] +struct BackupExportSnapshot { + let devices: [DeviceDTO] + let contacts: [ContactDTO] + let channels: [ChannelDTO] + let messages: [MessageDTO] + let messageRepeats: [MessageRepeatDTO] + let reactions: [ReactionDTO] + let roomMessages: [RoomMessageDTO] + let remoteNodeSessions: [RemoteNodeSessionDTO] + let savedTracePaths: [SavedTracePathDTO] + let blockedChannelSenders: [BlockedChannelSenderDTO] + let nodeStatusSnapshots: [NodeStatusSnapshotDTO] } // MARK: - Export (fetchAll) -extension PersistenceStore { - - public func fetchAllDevices() throws -> [DeviceDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { DeviceDTO(from: $0) } - } - - public func fetchAllContacts() throws -> [ContactDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { ContactDTO(from: $0) } - } - - public func fetchAllChannels() throws -> [ChannelDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { ChannelDTO(from: $0) } - } - - /// Fetches all messages for backup export, skipping external-storage link-preview - /// blobs at the DTO boundary so SwiftData never faults them in. Backfills the - /// deduplication key on pre-migration incoming messages so import can match them. - /// Outgoing messages are intentionally left nil — restore keys them on UUID identity. - func fetchAllMessagesForBackup() throws -> [MessageDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { message in - var dto = MessageDTO(from: message, includeLinkPreviewBlobs: false) - if dto.direction != .outgoing, dto.deduplicationKey == nil { - dto.deduplicationKey = DeduplicationKey.contentBased( - contactID: dto.contactID, - channelIndex: dto.channelIndex, - senderNodeName: dto.senderNodeName, - timestamp: dto.timestamp, - content: dto.text - ) - } - return dto - } - } - - public func fetchAllReactions() throws -> [ReactionDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { ReactionDTO(from: $0) } - } - - public func fetchAllRemoteNodeSessions() throws -> [RemoteNodeSessionDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { RemoteNodeSessionDTO(from: $0) } - } - - public func fetchAllSavedTracePaths() throws -> [SavedTracePathDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { SavedTracePathDTO(from: $0) } - } - - public func fetchAllBlockedChannelSenders() throws -> [BlockedChannelSenderDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { BlockedChannelSenderDTO(from: $0) } - } - - /// Scoped by parent message IDs so we don't fetch orphaned repeats. - /// Chunks the `contains` predicate to stay under SQLite's bound-parameter limit - /// on histories with tens of thousands of messages. - public func fetchAllMessageRepeats(messageIDs: Set) throws -> [MessageRepeatDTO] { - let messageIDArray = Array(messageIDs) - guard !messageIDArray.isEmpty else { return [] } - let repeats = try fetchInChunks(keys: messageIDArray) { chunk in - let predicate = #Predicate { chunk.contains($0.messageID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - return repeats.map { MessageRepeatDTO(from: $0) } - } - - /// Scoped by parent session IDs so we don't fetch orphaned room messages. - public func fetchAllRoomMessages(sessionIDs: Set) throws -> [RoomMessageDTO] { - let sessionIDArray = Array(sessionIDs) - guard !sessionIDArray.isEmpty else { return [] } - let messages = try fetchInChunks(keys: sessionIDArray) { chunk in - let predicate = #Predicate { chunk.contains($0.sessionID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - return messages.map { RoomMessageDTO(from: $0) } +public extension PersistenceStore { + func fetchAllDevices() throws -> [DeviceDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { DeviceDTO(from: $0) } + } + + func fetchAllContacts() throws -> [ContactDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { ContactDTO(from: $0) } + } + + func fetchAllChannels() throws -> [ChannelDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { ChannelDTO(from: $0) } + } + + /// Fetches all messages for backup export, skipping external-storage link-preview + /// blobs at the DTO boundary so SwiftData never faults them in. Backfills the + /// deduplication key on pre-migration incoming messages so import can match them. + /// Outgoing messages are intentionally left nil — restore keys them on UUID identity. + internal func fetchAllMessagesForBackup() throws -> [MessageDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { message in + var dto = MessageDTO(from: message, includeLinkPreviewBlobs: false) + if dto.direction != .outgoing, dto.deduplicationKey == nil { + dto.deduplicationKey = DeduplicationKey.contentBased( + contactID: dto.contactID, + channelIndex: dto.channelIndex, + senderNodeName: dto.senderNodeName, + timestamp: dto.timestamp, + content: dto.text + ) + } + return dto } - - public func fetchAllNodeStatusSnapshots() throws -> [NodeStatusSnapshotDTO] { - let descriptor = FetchDescriptor() - return try modelContext.fetch(descriptor).map { NodeStatusSnapshotDTO(from: $0) } + } + + func fetchAllReactions() throws -> [ReactionDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { ReactionDTO(from: $0) } + } + + func fetchAllRemoteNodeSessions() throws -> [RemoteNodeSessionDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { RemoteNodeSessionDTO(from: $0) } + } + + func fetchAllSavedTracePaths() throws -> [SavedTracePathDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { SavedTracePathDTO(from: $0) } + } + + func fetchAllBlockedChannelSenders() throws -> [BlockedChannelSenderDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { BlockedChannelSenderDTO(from: $0) } + } + + /// Scoped by parent message IDs so we don't fetch orphaned repeats. + /// Chunks the `contains` predicate to stay under SQLite's bound-parameter limit + /// on histories with tens of thousands of messages. + func fetchAllMessageRepeats(messageIDs: Set) throws -> [MessageRepeatDTO] { + let messageIDArray = Array(messageIDs) + guard !messageIDArray.isEmpty else { return [] } + let repeats = try fetchInChunks(keys: messageIDArray) { chunk in + let predicate = #Predicate { chunk.contains($0.messageID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } - - /// Fetches all backup-relevant model data in a single store-actor turn. - /// - /// This keeps the export parent/child relationships aligned with the same - /// view of the store, rather than allowing sync writes on the authoritative - /// store actor to interleave between separate awaited fetches. - func fetchBackupExportSnapshot() throws -> BackupExportSnapshot { - let devices = try fetchAllDevices().map { $0.redactedForBackup() } - let contacts = try fetchAllContacts() - let channels = try fetchAllChannels() - let messages = try fetchAllMessagesForBackup() - let reactions = try fetchAllReactions() - let sessions = try fetchAllRemoteNodeSessions() - let tracePaths = try fetchAllSavedTracePaths() - let blockedSenders = try fetchAllBlockedChannelSenders() - let messageRepeats = try fetchAllMessageRepeats(messageIDs: Set(messages.map(\.id))) - let roomMessages = try fetchAllRoomMessages(sessionIDs: Set(sessions.map(\.id))) - let nodeSnapshots = try fetchAllNodeStatusSnapshots() - - return BackupExportSnapshot( - devices: devices, - contacts: contacts, - channels: channels, - messages: messages, - messageRepeats: messageRepeats, - reactions: reactions, - roomMessages: roomMessages, - remoteNodeSessions: sessions, - savedTracePaths: tracePaths, - blockedChannelSenders: blockedSenders, - nodeStatusSnapshots: nodeSnapshots - ) + return repeats.map { MessageRepeatDTO(from: $0) } + } + + /// Scoped by parent session IDs so we don't fetch orphaned room messages. + func fetchAllRoomMessages(sessionIDs: Set) throws -> [RoomMessageDTO] { + let sessionIDArray = Array(sessionIDs) + guard !sessionIDArray.isEmpty else { return [] } + let messages = try fetchInChunks(keys: sessionIDArray) { chunk in + let predicate = #Predicate { chunk.contains($0.sessionID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } + return messages.map { RoomMessageDTO(from: $0) } + } + + func fetchAllNodeStatusSnapshots() throws -> [NodeStatusSnapshotDTO] { + let descriptor = FetchDescriptor() + return try modelContext.fetch(descriptor).map { NodeStatusSnapshotDTO(from: $0) } + } + + /// Fetches all backup-relevant model data in a single store-actor turn. + /// + /// This keeps the export parent/child relationships aligned with the same + /// view of the store, rather than allowing sync writes on the authoritative + /// store actor to interleave between separate awaited fetches. + internal func fetchBackupExportSnapshot() throws -> BackupExportSnapshot { + let devices = try fetchAllDevices().map { $0.redactedForBackup() } + let contacts = try fetchAllContacts() + let channels = try fetchAllChannels() + let messages = try fetchAllMessagesForBackup() + let reactions = try fetchAllReactions() + let sessions = try fetchAllRemoteNodeSessions() + let tracePaths = try fetchAllSavedTracePaths() + let blockedSenders = try fetchAllBlockedChannelSenders() + let messageRepeats = try fetchAllMessageRepeats(messageIDs: Set(messages.map(\.id))) + let roomMessages = try fetchAllRoomMessages(sessionIDs: Set(sessions.map(\.id))) + let nodeSnapshots = try fetchAllNodeStatusSnapshots() + + return BackupExportSnapshot( + devices: devices, + contacts: contacts, + channels: channels, + messages: messages, + messageRepeats: messageRepeats, + reactions: reactions, + roomMessages: roomMessages, + remoteNodeSessions: sessions, + savedTracePaths: tracePaths, + blockedChannelSenders: blockedSenders, + nodeStatusSnapshots: nodeSnapshots + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift index 434d0e5c..29fb01dd 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift @@ -10,287 +10,284 @@ private let maxKeysPerFetch = 900 /// `#Predicate` that filters by `keys.contains($0.field)` when `keys.count` could /// realistically grow into the thousands. func fetchInChunks( - keys: [Key], - chunkSize: Int = maxKeysPerFetch, - fetcher: ([Key]) throws -> [Model] + keys: [Key], + chunkSize: Int = maxKeysPerFetch, + fetcher: ([Key]) throws -> [Model] ) throws -> [Model] { - guard !keys.isEmpty else { return [] } - if keys.count <= chunkSize { - return try fetcher(keys) - } - var combined: [Model] = [] - var index = 0 - while index < keys.count { - try Task.checkCancellation() - let end = Swift.min(index + chunkSize, keys.count) - combined.append(contentsOf: try fetcher(Array(keys[index.. String { + "\(radioID)-\(pathBytes.base64EncodedString())-\(hashSize)" + } - func savedTracePathKey(radioID: UUID, pathBytes: Data, hashSize: Int) -> String { - "\(radioID)-\(pathBytes.base64EncodedString())-\(hashSize)" - } + func contactKey(radioID: UUID, publicKey: Data) -> String { + "\(radioID)-\(publicKey.base64EncodedString())" + } - func contactKey(radioID: UUID, publicKey: Data) -> String { - "\(radioID)-\(publicKey.base64EncodedString())" - } + func channelKey(radioID: UUID, index: UInt8) -> String { + "\(radioID)-\(index)" + } - func channelKey(radioID: UUID, index: UInt8) -> String { - "\(radioID)-\(index)" + /// Dedup key for backup reconciliation. + /// Outgoing messages key on their UUID so two intentional sends with identical + /// text/timestamp/recipient do not collapse on restore. Incoming messages fall + /// back to their stored live-sync dedup key (or a content-based derivation when + /// the field was never populated — e.g. pre-schema rows), scoped by `radioID` + /// so two companion radios that both received the same wire packet restore as + /// two rows rather than collapsing into one under a single radio. + func messageBackupKey(for dto: MessageDTO) -> String { + if dto.direction == .outgoing { + return "\(DeduplicationKey.outgoingIdentityPrefix)\(dto.id.uuidString)" } + let base = dto.deduplicationKey ?? DeduplicationKey.contentBased( + contactID: dto.contactID, + channelIndex: dto.channelIndex, + senderNodeName: dto.senderNodeName, + timestamp: dto.timestamp, + content: dto.text + ) + return "\(dto.radioID.uuidString)-\(base)" + } - /// Dedup key for backup reconciliation. - /// Outgoing messages key on their UUID so two intentional sends with identical - /// text/timestamp/recipient do not collapse on restore. Incoming messages fall - /// back to their stored live-sync dedup key (or a content-based derivation when - /// the field was never populated — e.g. pre-schema rows), scoped by `radioID` - /// so two companion radios that both received the same wire packet restore as - /// two rows rather than collapsing into one under a single radio. - func messageBackupKey(for dto: MessageDTO) -> String { - if dto.direction == .outgoing { - return "\(DeduplicationKey.outgoingIdentityPrefix)\(dto.id.uuidString)" - } - let base = dto.deduplicationKey ?? DeduplicationKey.contentBased( - contactID: dto.contactID, - channelIndex: dto.channelIndex, - senderNodeName: dto.senderNodeName, - timestamp: dto.timestamp, - content: dto.text - ) - return "\(dto.radioID.uuidString)-\(base)" + func messageBackupKey(for message: Message) -> String { + if message.direction == .outgoing { + return "\(DeduplicationKey.outgoingIdentityPrefix)\(message.id.uuidString)" } + let base = message.deduplicationKey ?? DeduplicationKey.contentBased( + contactID: message.contactID, + channelIndex: message.channelIndex, + senderNodeName: message.senderNodeName, + timestamp: message.timestamp, + content: message.text + ) + return "\(message.radioID.uuidString)-\(base)" + } - func messageBackupKey(for message: Message) -> String { - if message.direction == .outgoing { - return "\(DeduplicationKey.outgoingIdentityPrefix)\(message.id.uuidString)" - } - let base = message.deduplicationKey ?? DeduplicationKey.contentBased( - contactID: message.contactID, - channelIndex: message.channelIndex, - senderNodeName: message.senderNodeName, - timestamp: message.timestamp, - content: message.text - ) - return "\(message.radioID.uuidString)-\(base)" - } + func reactionKey(messageID: UUID, senderName: String, emoji: String) -> String { + "\(messageID)-\(senderName)-\(emoji)" + } - func reactionKey(messageID: UUID, senderName: String, emoji: String) -> String { - "\(messageID)-\(senderName)-\(emoji)" - } - - func roomMessageKey(sessionID: UUID, deduplicationKey: String) -> String { - "\(sessionID)-\(deduplicationKey)" - } + func roomMessageKey(sessionID: UUID, deduplicationKey: String) -> String { + "\(sessionID)-\(deduplicationKey)" + } - func blockedChannelSenderKey(radioID: UUID, name: String) -> String { - "\(radioID)-\(name)" - } + func blockedChannelSenderKey(radioID: UUID, name: String) -> String { + "\(radioID)-\(name)" + } - func remoteNodeSessionKey(radioID: UUID, publicKey: Data) -> String { - "\(radioID)-\(publicKey.base64EncodedString())" - } + func remoteNodeSessionKey(radioID: UUID, publicKey: Data) -> String { + "\(radioID)-\(publicKey.base64EncodedString())" + } - func nodeStatusSnapshotKey(nodePublicKey: Data, timestamp: Date) -> String { - // Use milliseconds (Int) to stay stable across JSON `.secondsSince1970` - // encode/decode roundtrips — Double.bitPattern can drift on roundtrip - // even when the two Dates compare equal. - // Two distinct snapshots for one node within the same millisecond intentionally - // coalesce (the second is counted as skipped). This is acceptable, rare diagnostic - // coalescing; the id is deliberately not part of the key so re-import stays idempotent. - let milliseconds = Int(timestamp.timeIntervalSince1970 * 1000) - return "\(nodePublicKey.base64EncodedString())-\(milliseconds)" - } + func nodeStatusSnapshotKey(nodePublicKey: Data, timestamp: Date) -> String { + // Use milliseconds (Int) to stay stable across JSON `.secondsSince1970` + // encode/decode roundtrips — Double.bitPattern can drift on roundtrip + // even when the two Dates compare equal. + // Two distinct snapshots for one node within the same millisecond intentionally + // coalesce (the second is counted as skipped). This is acceptable, rare diagnostic + // coalescing; the id is deliberately not part of the key so re-import stays idempotent. + let milliseconds = Int(timestamp.timeIntervalSince1970 * 1000) + return "\(nodePublicKey.base64EncodedString())-\(milliseconds)" + } - /// Rewrite the contact-UUID segment of a DM dedup key. Only the `dm-{uuid}-` prefix - /// is touched, never raw substrings elsewhere in the key, so a UUID that happens to - /// appear inside the trailing hash (vanishingly unlikely, but possible) is preserved. - func rewriteDMDeduplicationKey(_ key: String, from backupID: UUID, to localID: UUID) -> String { - let oldPrefix = "\(DeduplicationKey.directMessagePrefix)\(backupID.uuidString)-" - guard key.hasPrefix(oldPrefix) else { return key } - let newPrefix = "\(DeduplicationKey.directMessagePrefix)\(localID.uuidString)-" - return newPrefix + key.dropFirst(oldPrefix.count) - } + /// Rewrite the contact-UUID segment of a DM dedup key. Only the `dm-{uuid}-` prefix + /// is touched, never raw substrings elsewhere in the key, so a UUID that happens to + /// appear inside the trailing hash (vanishingly unlikely, but possible) is preserved. + func rewriteDMDeduplicationKey(_ key: String, from backupID: UUID, to localID: UUID) -> String { + let oldPrefix = "\(DeduplicationKey.directMessagePrefix)\(backupID.uuidString)-" + guard key.hasPrefix(oldPrefix) else { return key } + let newPrefix = "\(DeduplicationKey.directMessagePrefix)\(localID.uuidString)-" + return newPrefix + key.dropFirst(oldPrefix.count) + } - /// Rewrite the channel-index segment of a content-based channel dedup key when a - /// backup channel is relocated to a different local slot. Only the leading - /// `ch-{index}-` segment is touched, mirroring ``rewriteDMDeduplicationKey``, so a - /// numeric run elsewhere in the key (timestamp, hash) cannot be misinterpreted as the - /// index. Keys that don't carry this prefix (outgoing identity keys, DM keys) pass - /// through unchanged. - func rewriteChannelDeduplicationKey(_ key: String, from backupIndex: UInt8, to localIndex: UInt8) -> String { - let oldPrefix = "\(DeduplicationKey.channelPrefix)\(backupIndex)-" - guard key.hasPrefix(oldPrefix) else { return key } - let newPrefix = "\(DeduplicationKey.channelPrefix)\(localIndex)-" - return newPrefix + key.dropFirst(oldPrefix.count) - } + /// Rewrite the channel-index segment of a content-based channel dedup key when a + /// backup channel is relocated to a different local slot. Only the leading + /// `ch-{index}-` segment is touched, mirroring ``rewriteDMDeduplicationKey``, so a + /// numeric run elsewhere in the key (timestamp, hash) cannot be misinterpreted as the + /// index. Keys that don't carry this prefix (outgoing identity keys, DM keys) pass + /// through unchanged. + func rewriteChannelDeduplicationKey(_ key: String, from backupIndex: UInt8, to localIndex: UInt8) -> String { + let oldPrefix = "\(DeduplicationKey.channelPrefix)\(backupIndex)-" + guard key.hasPrefix(oldPrefix) else { return key } + let newPrefix = "\(DeduplicationKey.channelPrefix)\(localIndex)-" + return newPrefix + key.dropFirst(oldPrefix.count) + } } // MARK: - Per-table existing-row lookups extension PersistenceStore { + func fetchExistingContactsByKey(radioIDs: Set) throws -> [String: Contact] { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return [:] } - func fetchExistingContactsByKey(radioIDs: Set) throws -> [String: Contact] { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return [:] } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + return Dictionary(uniqueKeysWithValues: contacts.map { + (contactKey(radioID: $0.radioID, publicKey: $0.publicKey), $0) + }) + } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - return Dictionary(uniqueKeysWithValues: contacts.map { - (contactKey(radioID: $0.radioID, publicKey: $0.publicKey), $0) - }) - } + func fetchExistingChannelsByKey(radioIDs: Set) throws -> [String: Channel] { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return [:] } - func fetchExistingChannelsByKey(radioIDs: Set) throws -> [String: Channel] { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return [:] } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + return Dictionary(uniqueKeysWithValues: channels.map { + (channelKey(radioID: $0.radioID, index: $0.index), $0) + }) + } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - return Dictionary(uniqueKeysWithValues: channels.map { - (channelKey(radioID: $0.radioID, index: $0.index), $0) - }) - } - - /// Fetches every local channel for the given radios as raw models. Channel - /// reconciliation needs to index local rows by both secret and slot in one pass, - /// which the `(radioID, index)`-keyed dictionary above can't express. - func fetchExistingChannels(radioIDs: Set) throws -> [Channel] { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return [] } + /// Fetches every local channel for the given radios as raw models. Channel + /// reconciliation needs to index local rows by both secret and slot in one pass, + /// which the `(radioID, index)`-keyed dictionary above can't express. + func fetchExistingChannels(radioIDs: Set) throws -> [Channel] { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return [] } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + } - func fetchExistingRemoteNodeSessionsByKey(radioIDs: Set) throws -> [String: RemoteNodeSession] { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return [:] } + func fetchExistingRemoteNodeSessionsByKey(radioIDs: Set) throws -> [String: RemoteNodeSession] { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return [:] } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let sessions = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - return Dictionary(uniqueKeysWithValues: sessions.map { - (remoteNodeSessionKey(radioID: $0.radioID, publicKey: $0.publicKey), $0) - }) - } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let sessions = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + return Dictionary(uniqueKeysWithValues: sessions.map { + (remoteNodeSessionKey(radioID: $0.radioID, publicKey: $0.publicKey), $0) + }) + } } // MARK: - Merge pre-existing rows from backup metadata extension PersistenceStore { - - func mergeBackupMetadata(into contact: Contact, from dto: ContactDTO) -> Bool { - var changed = false - if contact.nickname == nil, let backupNickname = dto.nickname { - contact.nickname = backupNickname - changed = true - } - // Safety: never un-block, un-mute, or un-favorite via import - if dto.isBlocked && !contact.isBlocked { - contact.isBlocked = true - changed = true - } - if dto.isMuted && !contact.isMuted { - contact.isMuted = true - changed = true - } - if dto.isFavorite && !contact.isFavorite { - contact.isFavorite = true - changed = true - } - if let backupDate = dto.lastMessageDate { - if contact.lastMessageDate == nil || contact.lastMessageDate! < backupDate { - contact.lastMessageDate = backupDate - changed = true - } - } - let mergedUnread = max(contact.unreadCount, dto.unreadCount) - if contact.unreadCount != mergedUnread { - contact.unreadCount = mergedUnread - changed = true - } - let mergedMention = max(contact.unreadMentionCount, dto.unreadMentionCount) - if contact.unreadMentionCount != mergedMention { - contact.unreadMentionCount = mergedMention - changed = true - } - if contact.ocvPreset == nil, let backupPreset = dto.ocvPreset { - contact.ocvPreset = backupPreset - changed = true - } - if contact.customOCVArrayString == nil, let backupOCV = dto.customOCVArrayString { - contact.customOCVArrayString = backupOCV - changed = true - } - return changed + func mergeBackupMetadata(into contact: Contact, from dto: ContactDTO) -> Bool { + var changed = false + if contact.nickname == nil, let backupNickname = dto.nickname { + contact.nickname = backupNickname + changed = true + } + // Safety: never un-block, un-mute, or un-favorite via import + if dto.isBlocked, !contact.isBlocked { + contact.isBlocked = true + changed = true + } + if dto.isMuted, !contact.isMuted { + contact.isMuted = true + changed = true + } + if dto.isFavorite, !contact.isFavorite { + contact.isFavorite = true + changed = true + } + if let backupDate = dto.lastMessageDate { + if contact.lastMessageDate == nil || contact.lastMessageDate! < backupDate { + contact.lastMessageDate = backupDate + changed = true + } + } + let mergedUnread = max(contact.unreadCount, dto.unreadCount) + if contact.unreadCount != mergedUnread { + contact.unreadCount = mergedUnread + changed = true + } + let mergedMention = max(contact.unreadMentionCount, dto.unreadMentionCount) + if contact.unreadMentionCount != mergedMention { + contact.unreadMentionCount = mergedMention + changed = true + } + if contact.ocvPreset == nil, let backupPreset = dto.ocvPreset { + contact.ocvPreset = backupPreset + changed = true + } + if contact.customOCVArrayString == nil, let backupOCV = dto.customOCVArrayString { + contact.customOCVArrayString = backupOCV + changed = true } + return changed + } - func mergeBackupMetadata(into channel: Channel, from dto: ChannelDTO) -> Bool { - var changed = false - if let backupDate = dto.lastMessageDate { - if channel.lastMessageDate == nil || channel.lastMessageDate! < backupDate { - channel.lastMessageDate = backupDate - changed = true - } - } - let mergedUnread = max(channel.unreadCount, dto.unreadCount) - if channel.unreadCount != mergedUnread { - channel.unreadCount = mergedUnread - changed = true - } - let mergedMention = max(channel.unreadMentionCount, dto.unreadMentionCount) - if channel.unreadMentionCount != mergedMention { - channel.unreadMentionCount = mergedMention - changed = true - } - // Only adopt backup notification level if local is at default - if channel.notificationLevel == .all && dto.notificationLevel != .all { - channel.notificationLevel = dto.notificationLevel - changed = true - } - if dto.isFavorite && !channel.isFavorite { - channel.isFavorite = true - changed = true - } - if channel.floodScope == .inherit, dto.floodScope != .inherit { - channel.floodScope = dto.floodScope - changed = true - } - return changed + func mergeBackupMetadata(into channel: Channel, from dto: ChannelDTO) -> Bool { + var changed = false + if let backupDate = dto.lastMessageDate { + if channel.lastMessageDate == nil || channel.lastMessageDate! < backupDate { + channel.lastMessageDate = backupDate + changed = true + } } + let mergedUnread = max(channel.unreadCount, dto.unreadCount) + if channel.unreadCount != mergedUnread { + channel.unreadCount = mergedUnread + changed = true + } + let mergedMention = max(channel.unreadMentionCount, dto.unreadMentionCount) + if channel.unreadMentionCount != mergedMention { + channel.unreadMentionCount = mergedMention + changed = true + } + // Only adopt backup notification level if local is at default + if channel.notificationLevel == .all, dto.notificationLevel != .all { + channel.notificationLevel = dto.notificationLevel + changed = true + } + if dto.isFavorite, !channel.isFavorite { + channel.isFavorite = true + changed = true + } + if channel.floodScope == .inherit, dto.floodScope != .inherit { + channel.floodScope = dto.floodScope + changed = true + } + return changed + } - func mergeBackupMetadata(into session: RemoteNodeSession, from dto: RemoteNodeSessionDTO) -> Bool { - var changed = false - let mergedUnread = max(session.unreadCount, dto.unreadCount) - if session.unreadCount != mergedUnread { - session.unreadCount = mergedUnread - changed = true - } - // Only adopt backup notification level if local is at default - if session.notificationLevel == .all && dto.notificationLevel != .all { - session.notificationLevel = dto.notificationLevel - changed = true - } - if dto.isFavorite && !session.isFavorite { - session.isFavorite = true - changed = true - } - let mergedSyncTimestamp = max(session.lastSyncTimestamp, dto.lastSyncTimestamp) - if session.lastSyncTimestamp != mergedSyncTimestamp { - session.lastSyncTimestamp = mergedSyncTimestamp - changed = true - } - if let backupDate = dto.lastMessageDate { - if session.lastMessageDate == nil || session.lastMessageDate! < backupDate { - session.lastMessageDate = backupDate - changed = true - } - } - return changed + func mergeBackupMetadata(into session: RemoteNodeSession, from dto: RemoteNodeSessionDTO) -> Bool { + var changed = false + let mergedUnread = max(session.unreadCount, dto.unreadCount) + if session.unreadCount != mergedUnread { + session.unreadCount = mergedUnread + changed = true + } + // Only adopt backup notification level if local is at default + if session.notificationLevel == .all, dto.notificationLevel != .all { + session.notificationLevel = dto.notificationLevel + changed = true + } + if dto.isFavorite, !session.isFavorite { + session.isFavorite = true + changed = true + } + let mergedSyncTimestamp = max(session.lastSyncTimestamp, dto.lastSyncTimestamp) + if session.lastSyncTimestamp != mergedSyncTimestamp { + session.lastSyncTimestamp = mergedSyncTimestamp + changed = true + } + if let backupDate = dto.lastMessageDate { + if session.lastMessageDate == nil || session.lastMessageDate! < backupDate { + session.lastMessageDate = backupDate + changed = true + } } + return changed + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift index b04c14b5..65fac9bf 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift @@ -10,709 +10,705 @@ private let cancellationCheckStride = 500 // MARK: - Import Key Lookups (existingKeys) -extension PersistenceStore { - - /// Fetches every local Device in one pass and returns a publicKey → radioID map, - /// which both `buildRadioIDMapping` and `batchInsertDevices` consume — avoiding the - /// previous per-device `fetchDevice(publicKey:)` loop plus a second full-table scan. - public func existingDeviceRadioIDsByPublicKey() throws -> [Data: UUID] { - let descriptor = FetchDescriptor() - let devices = try modelContext.fetch(descriptor) - return Dictionary(devices.map { ($0.publicKey, $0.radioID) }, uniquingKeysWith: { first, _ in first }) - } - - /// Fetches all messages for the given radioIDs and returns both the dedup key set - /// and the dedup-key-to-IDs mapping in a single pass (avoids two full-table scans). - /// Outgoing messages key on identity; incoming messages share a content-based key - /// only when they represent the same wire packet. - public func existingMessageLookups( - radioIDs: Set - ) throws -> (keys: Set, idsByKey: [String: [UUID]]) { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return ([], [:]) } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let descriptor = FetchDescriptor(predicate: predicate) - let messages = try modelContext.fetch(descriptor) - var keys = Set() - var idsByKey: [String: [UUID]] = [:] - for message in messages { - let key = messageBackupKey(for: message) - keys.insert(key) - idsByKey[key, default: []].append(message.id) - } - return (keys, idsByKey) - } - - /// Each `MessageRepeat` row represents a distinct hearing of a message, so keying - /// on `id` (unique-by-construction) is sufficient and preserves multiple repeats - /// that traverse the same route. - public func existingMessageRepeatIDs(messageIDs: Set) throws -> Set { - let messageIDArray = Array(messageIDs) - guard !messageIDArray.isEmpty else { return [] } - let repeats = try fetchInChunks(keys: messageIDArray) { chunk in - let predicate = #Predicate { chunk.contains($0.messageID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - return Set(repeats.map(\.id)) +public extension PersistenceStore { + /// Fetches every local Device in one pass and returns a publicKey → radioID map, + /// which both `buildRadioIDMapping` and `batchInsertDevices` consume — avoiding the + /// previous per-device `fetchDevice(publicKey:)` loop plus a second full-table scan. + func existingDeviceRadioIDsByPublicKey() throws -> [Data: UUID] { + let descriptor = FetchDescriptor() + let devices = try modelContext.fetch(descriptor) + return Dictionary(devices.map { ($0.publicKey, $0.radioID) }, uniquingKeysWith: { first, _ in first }) + } + + /// Fetches all messages for the given radioIDs and returns both the dedup key set + /// and the dedup-key-to-IDs mapping in a single pass (avoids two full-table scans). + /// Outgoing messages key on identity; incoming messages share a content-based key + /// only when they represent the same wire packet. + func existingMessageLookups( + radioIDs: Set + ) throws -> (keys: Set, idsByKey: [String: [UUID]]) { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return ([], [:]) } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let descriptor = FetchDescriptor(predicate: predicate) + let messages = try modelContext.fetch(descriptor) + var keys = Set() + var idsByKey: [String: [UUID]] = [:] + for message in messages { + let key = messageBackupKey(for: message) + keys.insert(key) + idsByKey[key, default: []].append(message.id) } - - public func existingReactionKeys(messageIDs: Set) throws -> Set { - let messageIDArray = Array(messageIDs) - guard !messageIDArray.isEmpty else { return [] } - let reactions = try fetchInChunks(keys: messageIDArray) { chunk in - let predicate = #Predicate { chunk.contains($0.messageID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - return Set(reactions.map { reactionKey(messageID: $0.messageID, senderName: $0.senderName, emoji: $0.emoji) }) + return (keys, idsByKey) + } + + /// Each `MessageRepeat` row represents a distinct hearing of a message, so keying + /// on `id` (unique-by-construction) is sufficient and preserves multiple repeats + /// that traverse the same route. + func existingMessageRepeatIDs(messageIDs: Set) throws -> Set { + let messageIDArray = Array(messageIDs) + guard !messageIDArray.isEmpty else { return [] } + let repeats = try fetchInChunks(keys: messageIDArray) { chunk in + let predicate = #Predicate { chunk.contains($0.messageID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } - - public func existingRoomMessageKeys(sessionIDs: Set) throws -> Set { - let sessionIDArray = Array(sessionIDs) - guard !sessionIDArray.isEmpty else { return [] } - let predicate = #Predicate { sessionIDArray.contains($0.sessionID) } - let descriptor = FetchDescriptor(predicate: predicate) - let messages = try modelContext.fetch(descriptor) - return Set(messages.map { roomMessageKey(sessionID: $0.sessionID, deduplicationKey: $0.deduplicationKey) }) - } - - public func existingBlockedSenderKeys(radioIDs: Set) throws -> Set { - let radioIDArray = Array(radioIDs) - guard !radioIDArray.isEmpty else { return [] } - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let descriptor = FetchDescriptor(predicate: predicate) - let senders = try modelContext.fetch(descriptor) - return Set(senders.map { blockedChannelSenderKey(radioID: $0.radioID, name: $0.name) }) - } - - public func existingNodeStatusSnapshotKeys() throws -> Set { - let descriptor = FetchDescriptor() - let snapshots = try modelContext.fetch(descriptor) - return Set(snapshots.map { - nodeStatusSnapshotKey(nodePublicKey: $0.nodePublicKey, timestamp: $0.timestamp) - }) + return Set(repeats.map(\.id)) + } + + func existingReactionKeys(messageIDs: Set) throws -> Set { + let messageIDArray = Array(messageIDs) + guard !messageIDArray.isEmpty else { return [] } + let reactions = try fetchInChunks(keys: messageIDArray) { chunk in + let predicate = #Predicate { chunk.contains($0.messageID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } + return Set(reactions.map { reactionKey(messageID: $0.messageID, senderName: $0.senderName, emoji: $0.emoji) }) + } + + func existingRoomMessageKeys(sessionIDs: Set) throws -> Set { + let sessionIDArray = Array(sessionIDs) + guard !sessionIDArray.isEmpty else { return [] } + let predicate = #Predicate { sessionIDArray.contains($0.sessionID) } + let descriptor = FetchDescriptor(predicate: predicate) + let messages = try modelContext.fetch(descriptor) + return Set(messages.map { roomMessageKey(sessionID: $0.sessionID, deduplicationKey: $0.deduplicationKey) }) + } + + func existingBlockedSenderKeys(radioIDs: Set) throws -> Set { + let radioIDArray = Array(radioIDs) + guard !radioIDArray.isEmpty else { return [] } + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let descriptor = FetchDescriptor(predicate: predicate) + let senders = try modelContext.fetch(descriptor) + return Set(senders.map { blockedChannelSenderKey(radioID: $0.radioID, name: $0.name) }) + } + + func existingNodeStatusSnapshotKeys() throws -> Set { + let descriptor = FetchDescriptor() + let snapshots = try modelContext.fetch(descriptor) + return Set(snapshots.map { + nodeStatusSnapshotKey(nodePublicKey: $0.nodePublicKey, timestamp: $0.timestamp) + }) + } } // MARK: - Import -extension PersistenceStore { - - /// Result of matching backup devices to local devices by publicKey. - fileprivate struct RadioIDMapping { - let mapping: [UUID: UUID] - let unmatchedDevices: [DeviceDTO] - let duplicateDeviceCount: Int - } - - /// Imports the SwiftData-backed portion of a backup in one store-actor turn. - /// - /// Running the whole database import inside `PersistenceStore` prevents other - /// live-store writes from interleaving between awaited hops, and the `defer` - /// cleanup guarantees autosave state is restored even if the batch fails. - @discardableResult - func importBackupDatabase( - _ envelope: AppBackupEnvelope - ) throws -> ImportResult { - var result = ImportResult() - let originalAutosaveEnabled = modelContext.autosaveEnabled - var didCommit = false - - modelContext.autosaveEnabled = false - - defer { - if !didCommit { - modelContext.rollback() - } - modelContext.autosaveEnabled = originalAutosaveEnabled - } - - let localDeviceRadioIDsByPublicKey = try existingDeviceRadioIDsByPublicKey() - let radioMap = buildRadioIDMapping( - from: envelope, - localDeviceRadioIDsByPublicKey: localDeviceRadioIDsByPublicKey - ) - if radioMap.duplicateDeviceCount > 0 { - backupLogger.warning( - "Backup contained \(radioMap.duplicateDeviceCount) duplicate device public key(s); first occurrence wins." - ) - result.record(.devices, skipped: radioMap.duplicateDeviceCount) - } - - var contacts = envelope.contacts - var channels = envelope.channels - var messages = envelope.messages - var reactions = envelope.reactions - var sessions = envelope.remoteNodeSessions - var tracePaths = envelope.savedTracePaths - var blockedSenders = envelope.blockedChannelSenders - var messageRepeats = envelope.messageRepeats - var roomMessages = envelope.roomMessages - - // Skip the rewrite when every mapping entry is identity (same-device restore), - // avoiding seven copy-on-write copies. - if radioMap.mapping.contains(where: { $0.key != $0.value }) { - applyRadioIDMapping(radioMap.mapping, to: &contacts, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &channels, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &messages, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &reactions, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &sessions, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &tracePaths, keyPath: \.radioID) - applyRadioIDMapping(radioMap.mapping, to: &blockedSenders, keyPath: \.radioID) - } - - try Task.checkCancellation() - let deviceResult = try batchInsertDevices( - radioMap.unmatchedDevices, - existingKeys: Set(localDeviceRadioIDsByPublicKey.keys) - ) - result.record(.devices, inserted: deviceResult.inserted, skipped: deviceResult.skipped) - - var allRadioIDs = Set(radioMap.mapping.values) - allRadioIDs.formUnion(contacts.map(\.radioID)) - allRadioIDs.formUnion(channels.map(\.radioID)) - allRadioIDs.formUnion(messages.map(\.radioID)) - allRadioIDs.formUnion(reactions.map(\.radioID)) - allRadioIDs.formUnion(sessions.map(\.radioID)) - allRadioIDs.formUnion(tracePaths.map(\.radioID)) - allRadioIDs.formUnion(blockedSenders.map(\.radioID)) - - let contactResult = try batchInsertContacts(contacts, radioIDs: allRadioIDs) - result.record( - .contacts, - inserted: contactResult.inserted, - merged: contactResult.merged, - skipped: contactResult.skipped - ) - - let contactIDMapping = buildContactIDMapping( - contacts: contacts, - contactIDsByKey: contactResult.contactIDsByKey - ) - applyContactIDMapping(contactIDMapping, toMessages: &messages, toReactions: &reactions) - - try Task.checkCancellation() - let maxChannelsByRadioID = maxChannelsByLocalRadioID( - devices: envelope.devices, - radioMap: radioMap.mapping - ) - let channelResult = try batchInsertChannels( - channels, - radioIDs: allRadioIDs, - maxChannelsByRadioID: maxChannelsByRadioID - ) - result.record( - .channels, - inserted: channelResult.inserted, - merged: channelResult.merged, - skipped: channelResult.skipped, - dropped: channelResult.dropped - ) - - // Slots whose channel identity changed — newly occupied inserts plus dropped slots — so - // the caller clears their drafts. Merge-by-secret slots keep their occupant and are - // excluded; see `ChannelBatchInsertResult.insertedLocalIndices` for the full rationale. - for (radioID, indices) in channelResult.insertedLocalIndices { - result.channelSlotsAffectedByImport[radioID, default: []].formUnion(indices) - } - for (radioID, dropped) in channelResult.droppedChannelIndices { - result.channelSlotsAffectedByImport[radioID, default: []].formUnion(dropped) - } - - // Channels are now final, so rewrite channel-message slots before inserting - // messages: relocated channels carry their messages to the new slot, and messages - // for a dropped (no-free-slot) channel are removed rather than mis-associated. - let droppedChildren = applyChannelIndexMapping( - channelResult.channelIndexRemap, - droppedChannelIndices: channelResult.droppedChannelIndices, - toMessages: &messages, - toReactions: &reactions - ) +public extension PersistenceStore { + /// Result of matching backup devices to local devices by publicKey. + fileprivate struct RadioIDMapping { + let mapping: [UUID: UUID] + let unmatchedDevices: [DeviceDTO] + let duplicateDeviceCount: Int + } + + /// Imports the SwiftData-backed portion of a backup in one store-actor turn. + /// + /// Running the whole database import inside `PersistenceStore` prevents other + /// live-store writes from interleaving between awaited hops, and the `defer` + /// cleanup guarantees autosave state is restored even if the batch fails. + @discardableResult + internal func importBackupDatabase( + _ envelope: AppBackupEnvelope + ) throws -> ImportResult { + var result = ImportResult() + let originalAutosaveEnabled = modelContext.autosaveEnabled + var didCommit = false + + modelContext.autosaveEnabled = false + + defer { + if !didCommit { + modelContext.rollback() + } + modelContext.autosaveEnabled = originalAutosaveEnabled + } - try Task.checkCancellation() - let sessionResult = try batchInsertRemoteNodeSessions(sessions, radioIDs: allRadioIDs) - result.record( - .remoteNodeSessions, - inserted: sessionResult.inserted, - merged: sessionResult.merged, - skipped: sessionResult.skipped - ) + let localDeviceRadioIDsByPublicKey = try existingDeviceRadioIDsByPublicKey() + let radioMap = buildRadioIDMapping( + from: envelope, + localDeviceRadioIDsByPublicKey: localDeviceRadioIDsByPublicKey + ) + if radioMap.duplicateDeviceCount > 0 { + backupLogger.warning( + "Backup contained \(radioMap.duplicateDeviceCount) duplicate device public key(s); first occurrence wins." + ) + result.record(.devices, skipped: radioMap.duplicateDeviceCount) + } - try Task.checkCancellation() - let messageLookups = try existingMessageLookups(radioIDs: allRadioIDs) - let messageResult = try batchInsertMessages( - messages, - existingKeys: messageLookups.keys, - existingIDsByKey: messageLookups.idsByKey - ) - result.record(.messages, inserted: messageResult.inserted, skipped: messageResult.skipped, dropped: droppedChildren.messages) - - let messageIDMapping = messageResult.messageIDByBackupID - let allMessageIDs = Set(messages.map { messageIDMapping[$0.id] ?? $0.id }) - applyMessageIDMapping(messageIDMapping, toRepeats: &messageRepeats, toReactions: &reactions) - - let existingRepeatIDs = try existingMessageRepeatIDs(messageIDs: allMessageIDs) - let repeatResult = try batchInsertMessageRepeats( - messageRepeats, - existingIDs: existingRepeatIDs, - existingMessageIDs: allMessageIDs - ) - result.record(.messageRepeats, inserted: repeatResult.inserted, skipped: repeatResult.skipped) - - let existingReactionKeys = try existingReactionKeys(messageIDs: allMessageIDs) - let reactionResult = try batchInsertReactions( - reactions, - existingKeys: existingReactionKeys, - existingMessageIDs: allMessageIDs - ) - result.record(.reactions, inserted: reactionResult.inserted, skipped: reactionResult.skipped, dropped: droppedChildren.reactions) - - let affectedMessageIDs = repeatResult.affectedMessageIDs.union(reactionResult.affectedMessageIDs) - try recomputeMessageCaches(messageIDs: affectedMessageIDs) - - let sessionIDMapping = buildSessionIDMapping( - sessions: sessions, - sessionIDsByKey: sessionResult.sessionIDsByKey - ) - let allSessionIDs = Set(sessions.map { sessionIDMapping[$0.id] ?? $0.id }) - applySessionIDMapping(sessionIDMapping, toRoomMessages: &roomMessages) + var contacts = envelope.contacts + var channels = envelope.channels + var messages = envelope.messages + var reactions = envelope.reactions + var sessions = envelope.remoteNodeSessions + var tracePaths = envelope.savedTracePaths + var blockedSenders = envelope.blockedChannelSenders + var messageRepeats = envelope.messageRepeats + var roomMessages = envelope.roomMessages + + // Skip the rewrite when every mapping entry is identity (same-device restore), + // avoiding seven copy-on-write copies. + if radioMap.mapping.contains(where: { $0.key != $0.value }) { + applyRadioIDMapping(radioMap.mapping, to: &contacts, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &channels, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &messages, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &reactions, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &sessions, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &tracePaths, keyPath: \.radioID) + applyRadioIDMapping(radioMap.mapping, to: &blockedSenders, keyPath: \.radioID) + } - try Task.checkCancellation() - let existingRoomMsgKeys = try existingRoomMessageKeys(sessionIDs: allSessionIDs) - let roomMsgResult = try batchInsertRoomMessages( - roomMessages, - existingKeys: existingRoomMsgKeys, - existingSessionIDs: allSessionIDs - ) - result.record(.roomMessages, inserted: roomMsgResult.inserted, skipped: roomMsgResult.skipped) - - let traceResult = try batchInsertSavedTracePaths(tracePaths) - result.record( - .savedTracePaths, - inserted: traceResult.inserted, - merged: traceResult.merged, - skipped: traceResult.skipped - ) - - let existingBlockedKeys = try existingBlockedSenderKeys(radioIDs: allRadioIDs) - let blockedResult = try batchInsertBlockedChannelSenders( - blockedSenders, - existingKeys: existingBlockedKeys - ) - result.record(.blockedChannelSenders, inserted: blockedResult.inserted, skipped: blockedResult.skipped) - - let existingSnapshotKeys = try existingNodeStatusSnapshotKeys() - let snapshotResult = try batchInsertNodeStatusSnapshots( - envelope.nodeStatusSnapshots, - existingKeys: existingSnapshotKeys - ) - result.record(.nodeStatusSnapshots, inserted: snapshotResult.inserted, skipped: snapshotResult.skipped) - - try reconcileLastMessageDates( - messages: messages, - roomMessages: roomMessages, - affectedRoomSessionIDs: roomMsgResult.affectedSessionIDs - ) + try Task.checkCancellation() + let deviceResult = try batchInsertDevices( + radioMap.unmatchedDevices, + existingKeys: Set(localDeviceRadioIDsByPublicKey.keys) + ) + result.record(.devices, inserted: deviceResult.inserted, skipped: deviceResult.skipped) + + var allRadioIDs = Set(radioMap.mapping.values) + allRadioIDs.formUnion(contacts.map(\.radioID)) + allRadioIDs.formUnion(channels.map(\.radioID)) + allRadioIDs.formUnion(messages.map(\.radioID)) + allRadioIDs.formUnion(reactions.map(\.radioID)) + allRadioIDs.formUnion(sessions.map(\.radioID)) + allRadioIDs.formUnion(tracePaths.map(\.radioID)) + allRadioIDs.formUnion(blockedSenders.map(\.radioID)) + + let contactResult = try batchInsertContacts(contacts, radioIDs: allRadioIDs) + result.record( + .contacts, + inserted: contactResult.inserted, + merged: contactResult.merged, + skipped: contactResult.skipped + ) + + let contactIDMapping = buildContactIDMapping( + contacts: contacts, + contactIDsByKey: contactResult.contactIDsByKey + ) + applyContactIDMapping(contactIDMapping, toMessages: &messages, toReactions: &reactions) + + try Task.checkCancellation() + let maxChannelsByRadioID = maxChannelsByLocalRadioID( + devices: envelope.devices, + radioMap: radioMap.mapping + ) + let channelResult = try batchInsertChannels( + channels, + radioIDs: allRadioIDs, + maxChannelsByRadioID: maxChannelsByRadioID + ) + result.record( + .channels, + inserted: channelResult.inserted, + merged: channelResult.merged, + skipped: channelResult.skipped, + dropped: channelResult.dropped + ) + + // Slots whose channel identity changed — newly occupied inserts plus dropped slots — so + // the caller clears their drafts. Merge-by-secret slots keep their occupant and are + // excluded; see `ChannelBatchInsertResult.insertedLocalIndices` for the full rationale. + for (radioID, indices) in channelResult.insertedLocalIndices { + result.channelSlotsAffectedByImport[radioID, default: []].formUnion(indices) + } + for (radioID, dropped) in channelResult.droppedChannelIndices { + result.channelSlotsAffectedByImport[radioID, default: []].formUnion(dropped) + } - try Task.checkCancellation() - #if DEBUG - try backupImportFaultInjection?() - #endif - try modelContext.save() - didCommit = true + // Channels are now final, so rewrite channel-message slots before inserting + // messages: relocated channels carry their messages to the new slot, and messages + // for a dropped (no-free-slot) channel are removed rather than mis-associated. + let droppedChildren = applyChannelIndexMapping( + channelResult.channelIndexRemap, + droppedChannelIndices: channelResult.droppedChannelIndices, + toMessages: &messages, + toReactions: &reactions + ) + + try Task.checkCancellation() + let sessionResult = try batchInsertRemoteNodeSessions(sessions, radioIDs: allRadioIDs) + result.record( + .remoteNodeSessions, + inserted: sessionResult.inserted, + merged: sessionResult.merged, + skipped: sessionResult.skipped + ) + + try Task.checkCancellation() + let messageLookups = try existingMessageLookups(radioIDs: allRadioIDs) + let messageResult = try batchInsertMessages( + messages, + existingKeys: messageLookups.keys, + existingIDsByKey: messageLookups.idsByKey + ) + result.record(.messages, inserted: messageResult.inserted, skipped: messageResult.skipped, dropped: droppedChildren.messages) + + let messageIDMapping = messageResult.messageIDByBackupID + let allMessageIDs = Set(messages.map { messageIDMapping[$0.id] ?? $0.id }) + applyMessageIDMapping(messageIDMapping, toRepeats: &messageRepeats, toReactions: &reactions) + + let existingRepeatIDs = try existingMessageRepeatIDs(messageIDs: allMessageIDs) + let repeatResult = try batchInsertMessageRepeats( + messageRepeats, + existingIDs: existingRepeatIDs, + existingMessageIDs: allMessageIDs + ) + result.record(.messageRepeats, inserted: repeatResult.inserted, skipped: repeatResult.skipped) + + let existingReactionKeys = try existingReactionKeys(messageIDs: allMessageIDs) + let reactionResult = try batchInsertReactions( + reactions, + existingKeys: existingReactionKeys, + existingMessageIDs: allMessageIDs + ) + result.record(.reactions, inserted: reactionResult.inserted, skipped: reactionResult.skipped, dropped: droppedChildren.reactions) + + let affectedMessageIDs = repeatResult.affectedMessageIDs.union(reactionResult.affectedMessageIDs) + try recomputeMessageCaches(messageIDs: affectedMessageIDs) + + let sessionIDMapping = buildSessionIDMapping( + sessions: sessions, + sessionIDsByKey: sessionResult.sessionIDsByKey + ) + let allSessionIDs = Set(sessions.map { sessionIDMapping[$0.id] ?? $0.id }) + applySessionIDMapping(sessionIDMapping, toRoomMessages: &roomMessages) + + try Task.checkCancellation() + let existingRoomMsgKeys = try existingRoomMessageKeys(sessionIDs: allSessionIDs) + let roomMsgResult = try batchInsertRoomMessages( + roomMessages, + existingKeys: existingRoomMsgKeys, + existingSessionIDs: allSessionIDs + ) + result.record(.roomMessages, inserted: roomMsgResult.inserted, skipped: roomMsgResult.skipped) + + let traceResult = try batchInsertSavedTracePaths(tracePaths) + result.record( + .savedTracePaths, + inserted: traceResult.inserted, + merged: traceResult.merged, + skipped: traceResult.skipped + ) + + let existingBlockedKeys = try existingBlockedSenderKeys(radioIDs: allRadioIDs) + let blockedResult = try batchInsertBlockedChannelSenders( + blockedSenders, + existingKeys: existingBlockedKeys + ) + result.record(.blockedChannelSenders, inserted: blockedResult.inserted, skipped: blockedResult.skipped) + + let existingSnapshotKeys = try existingNodeStatusSnapshotKeys() + let snapshotResult = try batchInsertNodeStatusSnapshots( + envelope.nodeStatusSnapshots, + existingKeys: existingSnapshotKeys + ) + result.record(.nodeStatusSnapshots, inserted: snapshotResult.inserted, skipped: snapshotResult.skipped) + + try reconcileLastMessageDates( + messages: messages, + roomMessages: roomMessages, + affectedRoomSessionIDs: roomMsgResult.affectedSessionIDs + ) + + try Task.checkCancellation() + #if DEBUG + try backupImportFaultInjection?() + #endif + try modelContext.save() + didCommit = true - #if DEBUG - backupImportPostCommitHook?() - #endif + #if DEBUG + backupImportPostCommitHook?() + #endif - return result - } + return result + } - #if DEBUG - public func setBackupImportFaultInjection(_ hook: (@Sendable () throws -> Void)?) { - backupImportFaultInjection = hook + #if DEBUG + func setBackupImportFaultInjection(_ hook: (@Sendable () throws -> Void)?) { + backupImportFaultInjection = hook } - public func setBackupImportPostCommitHook(_ hook: (@Sendable () -> Void)?) { - backupImportPostCommitHook = hook + func setBackupImportPostCommitHook(_ hook: (@Sendable () -> Void)?) { + backupImportPostCommitHook = hook } - #endif + #endif } // MARK: - Import Mapping Helpers -extension PersistenceStore { - - /// Matches each backup device to a local device by publicKey, producing a - /// radioID remap for child records. Duplicate publicKeys in the envelope - /// (corruption) are counted so the caller can surface the skip. - /// - /// Keys are matched verbatim, with no empty/zero-key guard, because no MC1 export can - /// produce an empty or all-zero `publicKey`: every persist path sources it from a 32-byte - /// post-handshake `selfInfo.publicKey`, and `Device.init` requires it, so this branch only - /// ever sees real, distinct keys. The guard is intentionally omitted — the only way to reach - /// a collision (two devices carrying empty or zero `publicKey`s on distinct `radioID`s, which - /// would collapse into one partition and merge two radios' rows) is a hand-edited file, which - /// is out of scope for import validation. - fileprivate func buildRadioIDMapping( - from envelope: AppBackupEnvelope, - localDeviceRadioIDsByPublicKey: [Data: UUID] - ) -> RadioIDMapping { - var mapping: [UUID: UUID] = [:] - var unmatched: [DeviceDTO] = [] - var firstRadioIDByPublicKey: [Data: UUID] = [:] - var duplicates = 0 - for backupDevice in envelope.devices { - if let firstRadioID = firstRadioIDByPublicKey[backupDevice.publicKey] { - duplicates += 1 - // Point the duplicate's radioID at the first occurrence's local mapping so - // any child records keyed off it resolve to a Device row that actually exists. - if backupDevice.radioID != firstRadioID, mapping[backupDevice.radioID] == nil, - let winningLocal = mapping[firstRadioID] { - mapping[backupDevice.radioID] = winningLocal - } - continue - } - firstRadioIDByPublicKey[backupDevice.publicKey] = backupDevice.radioID - if let localRadioID = localDeviceRadioIDsByPublicKey[backupDevice.publicKey] { - mapping[backupDevice.radioID] = localRadioID - } else { - mapping[backupDevice.radioID] = backupDevice.radioID - unmatched.append(backupDevice) - } - } - return RadioIDMapping( - mapping: mapping, - unmatchedDevices: unmatched, - duplicateDeviceCount: duplicates - ) - } - - /// Rewrites a UUID field on every element of `dtos` through `mapping`. - /// Leaves elements whose current value isn't in the mapping untouched. - fileprivate func applyRadioIDMapping( - _ mapping: [UUID: UUID], - to dtos: inout [T], - keyPath: WritableKeyPath - ) { - for i in dtos.indices { - let current = dtos[i][keyPath: keyPath] - if let remapped = mapping[current] { - dtos[i][keyPath: keyPath] = remapped - } - } +private extension PersistenceStore { + /// Matches each backup device to a local device by publicKey, producing a + /// radioID remap for child records. Duplicate publicKeys in the envelope + /// (corruption) are counted so the caller can surface the skip. + /// + /// Keys are matched verbatim, with no empty/zero-key guard, because no MC1 export can + /// produce an empty or all-zero `publicKey`: every persist path sources it from a 32-byte + /// post-handshake `selfInfo.publicKey`, and `Device.init` requires it, so this branch only + /// ever sees real, distinct keys. The guard is intentionally omitted — the only way to reach + /// a collision (two devices carrying empty or zero `publicKey`s on distinct `radioID`s, which + /// would collapse into one partition and merge two radios' rows) is a hand-edited file, which + /// is out of scope for import validation. + func buildRadioIDMapping( + from envelope: AppBackupEnvelope, + localDeviceRadioIDsByPublicKey: [Data: UUID] + ) -> RadioIDMapping { + var mapping: [UUID: UUID] = [:] + var unmatched: [DeviceDTO] = [] + var firstRadioIDByPublicKey: [Data: UUID] = [:] + var duplicates = 0 + for backupDevice in envelope.devices { + if let firstRadioID = firstRadioIDByPublicKey[backupDevice.publicKey] { + duplicates += 1 + // Point the duplicate's radioID at the first occurrence's local mapping so + // any child records keyed off it resolve to a Device row that actually exists. + if backupDevice.radioID != firstRadioID, mapping[backupDevice.radioID] == nil, + let winningLocal = mapping[firstRadioID] { + mapping[backupDevice.radioID] = winningLocal + } + continue + } + firstRadioIDByPublicKey[backupDevice.publicKey] = backupDevice.radioID + if let localRadioID = localDeviceRadioIDsByPublicKey[backupDevice.publicKey] { + mapping[backupDevice.radioID] = localRadioID + } else { + mapping[backupDevice.radioID] = backupDevice.radioID + unmatched.append(backupDevice) + } } - - /// Returns backup-contact-ID → local-contact-ID entries for contacts that - /// merged into an existing local row (identity mappings are omitted). - fileprivate func buildContactIDMapping( - contacts: [ContactDTO], - contactIDsByKey: [String: UUID] - ) -> [UUID: UUID] { - var mapping: [UUID: UUID] = [:] - for contact in contacts { - let key = contactKey(radioID: contact.radioID, publicKey: contact.publicKey) - if let localID = contactIDsByKey[key], localID != contact.id { - mapping[contact.id] = localID - } - } - return mapping - } - - /// Rewrites `contactID` on messages (including DM dedup keys) and reactions. - fileprivate func applyContactIDMapping( - _ mapping: [UUID: UUID], - toMessages messages: inout [MessageDTO], - toReactions reactions: inout [ReactionDTO] - ) { - guard !mapping.isEmpty else { return } - for i in messages.indices { - guard let backupID = messages[i].contactID, - let localID = mapping[backupID] else { continue } - messages[i].contactID = localID - if let dedupKey = messages[i].deduplicationKey { - messages[i].deduplicationKey = rewriteDMDeduplicationKey(dedupKey, from: backupID, to: localID) - } - } - for i in reactions.indices { - guard let backupID = reactions[i].contactID, - let localID = mapping[backupID] else { continue } - reactions[i].contactID = localID - } + return RadioIDMapping( + mapping: mapping, + unmatchedDevices: unmatched, + duplicateDeviceCount: duplicates + ) + } + + /// Rewrites a UUID field on every element of `dtos` through `mapping`. + /// Leaves elements whose current value isn't in the mapping untouched. + func applyRadioIDMapping( + _ mapping: [UUID: UUID], + to dtos: inout [T], + keyPath: WritableKeyPath + ) { + for i in dtos.indices { + let current = dtos[i][keyPath: keyPath] + if let remapped = mapping[current] { + dtos[i][keyPath: keyPath] = remapped + } } - - /// Resolves each device's channel capacity to the local radioID it maps to, so the - /// channel reconciler can bound free-slot search by `maxChannels`. The envelope's - /// device radioIDs are pre-remap; `radioMap` rewrites them to the local partition key, - /// matching the radioID the channel DTOs were already remapped onto. - fileprivate func maxChannelsByLocalRadioID( - devices: [DeviceDTO], - radioMap: [UUID: UUID] - ) -> [UUID: UInt8] { - var result: [UUID: UInt8] = [:] - for device in devices { - let localRadioID = radioMap[device.radioID] ?? device.radioID - // First occurrence wins, consistent with duplicate-device handling upstream. - if result[localRadioID] == nil { - result[localRadioID] = device.maxChannels - } - } - return result - } - - /// Rewrites `channelIndex` on channel messages and reactions to the slot their channel - /// was placed at locally, rewriting the content-based channel dedup key in lockstep so - /// a second import of the same backup still deduplicates. Messages and reactions - /// belonging to a channel that had no free local slot are dropped, since no placement - /// exists to attach them to. Mirrors how ``applyContactIDMapping`` rewrites the DM - /// dedup key alongside the remapped identifier. - @discardableResult - fileprivate func applyChannelIndexMapping( - _ remap: [UUID: [UInt8: UInt8]], - droppedChannelIndices: [UUID: Set], - toMessages messages: inout [MessageDTO], - toReactions reactions: inout [ReactionDTO] - ) -> (messages: Int, reactions: Int) { - guard !remap.isEmpty || !droppedChannelIndices.isEmpty else { return (0, 0) } - - var droppedMessages = 0 - var droppedReactions = 0 - if !droppedChannelIndices.isEmpty { - let beforeMessages = messages.count - messages.removeAll { dto in - guard let index = dto.channelIndex else { return false } - return droppedChannelIndices[dto.radioID]?.contains(index) ?? false - } - droppedMessages = beforeMessages - messages.count - - let beforeReactions = reactions.count - reactions.removeAll { dto in - guard let index = dto.channelIndex else { return false } - return droppedChannelIndices[dto.radioID]?.contains(index) ?? false - } - droppedReactions = beforeReactions - reactions.count - } - - if !remap.isEmpty { - for i in messages.indices { - guard let backupIndex = messages[i].channelIndex, - let localIndex = remap[messages[i].radioID]?[backupIndex] else { continue } - messages[i].channelIndex = localIndex - if let dedupKey = messages[i].deduplicationKey { - messages[i].deduplicationKey = rewriteChannelDeduplicationKey( - dedupKey, from: backupIndex, to: localIndex - ) - } - } - for i in reactions.indices { - guard let backupIndex = reactions[i].channelIndex, - let localIndex = remap[reactions[i].radioID]?[backupIndex] else { continue } - reactions[i].channelIndex = localIndex - } - } - return (droppedMessages, droppedReactions) - } - - /// Rewrites `messageID` on both message repeats and reactions so they - /// reference the local (post-merge) parent. - fileprivate func applyMessageIDMapping( - _ mapping: [UUID: UUID], - toRepeats repeats: inout [MessageRepeatDTO], - toReactions reactions: inout [ReactionDTO] - ) { - guard !mapping.isEmpty else { return } - for i in repeats.indices { - if let localID = mapping[repeats[i].messageID] { - repeats[i].messageID = localID - } - } - for i in reactions.indices { - if let localID = mapping[reactions[i].messageID] { - reactions[i].messageID = localID - } - } + } + + /// Returns backup-contact-ID → local-contact-ID entries for contacts that + /// merged into an existing local row (identity mappings are omitted). + func buildContactIDMapping( + contacts: [ContactDTO], + contactIDsByKey: [String: UUID] + ) -> [UUID: UUID] { + var mapping: [UUID: UUID] = [:] + for contact in contacts { + let key = contactKey(radioID: contact.radioID, publicKey: contact.publicKey) + if let localID = contactIDsByKey[key], localID != contact.id { + mapping[contact.id] = localID + } } - - /// Returns backup-session-ID → local-session-ID entries for sessions - /// that merged into an existing local row (identity mappings omitted). - fileprivate func buildSessionIDMapping( - sessions: [RemoteNodeSessionDTO], - sessionIDsByKey: [String: UUID] - ) -> [UUID: UUID] { - var mapping: [UUID: UUID] = [:] - for session in sessions { - let key = remoteNodeSessionKey(radioID: session.radioID, publicKey: session.publicKey) - if let localID = sessionIDsByKey[key], localID != session.id { - mapping[session.id] = localID - } - } - return mapping - } - - /// Rewrites `sessionID` on room messages so they attach to the local - /// (post-merge) session row. - fileprivate func applySessionIDMapping( - _ mapping: [UUID: UUID], - toRoomMessages roomMessages: inout [RoomMessageDTO] - ) { - guard !mapping.isEmpty else { return } - for i in roomMessages.indices { - if let localID = mapping[roomMessages[i].sessionID] { - roomMessages[i].sessionID = localID - } - } + return mapping + } + + /// Rewrites `contactID` on messages (including DM dedup keys) and reactions. + func applyContactIDMapping( + _ mapping: [UUID: UUID], + toMessages messages: inout [MessageDTO], + toReactions reactions: inout [ReactionDTO] + ) { + guard !mapping.isEmpty else { return } + for i in messages.indices { + guard let backupID = messages[i].contactID, + let localID = mapping[backupID] else { continue } + messages[i].contactID = localID + if let dedupKey = messages[i].deduplicationKey { + messages[i].deduplicationKey = rewriteDMDeduplicationKey(dedupKey, from: backupID, to: localID) + } + } + for i in reactions.indices { + guard let backupID = reactions[i].contactID, + let localID = mapping[backupID] else { continue } + reactions[i].contactID = localID + } + } + + /// Resolves each device's channel capacity to the local radioID it maps to, so the + /// channel reconciler can bound free-slot search by `maxChannels`. The envelope's + /// device radioIDs are pre-remap; `radioMap` rewrites them to the local partition key, + /// matching the radioID the channel DTOs were already remapped onto. + func maxChannelsByLocalRadioID( + devices: [DeviceDTO], + radioMap: [UUID: UUID] + ) -> [UUID: UInt8] { + var result: [UUID: UInt8] = [:] + for device in devices { + let localRadioID = radioMap[device.radioID] ?? device.radioID + // First occurrence wins, consistent with duplicate-device handling upstream. + if result[localRadioID] == nil { + result[localRadioID] = device.maxChannels + } + } + return result + } + + /// Rewrites `channelIndex` on channel messages and reactions to the slot their channel + /// was placed at locally, rewriting the content-based channel dedup key in lockstep so + /// a second import of the same backup still deduplicates. Messages and reactions + /// belonging to a channel that had no free local slot are dropped, since no placement + /// exists to attach them to. Mirrors how ``applyContactIDMapping`` rewrites the DM + /// dedup key alongside the remapped identifier. + @discardableResult + func applyChannelIndexMapping( + _ remap: [UUID: [UInt8: UInt8]], + droppedChannelIndices: [UUID: Set], + toMessages messages: inout [MessageDTO], + toReactions reactions: inout [ReactionDTO] + ) -> (messages: Int, reactions: Int) { + guard !remap.isEmpty || !droppedChannelIndices.isEmpty else { return (0, 0) } + + var droppedMessages = 0 + var droppedReactions = 0 + if !droppedChannelIndices.isEmpty { + let beforeMessages = messages.count + messages.removeAll { dto in + guard let index = dto.channelIndex else { return false } + return droppedChannelIndices[dto.radioID]?.contains(index) ?? false + } + droppedMessages = beforeMessages - messages.count + + let beforeReactions = reactions.count + reactions.removeAll { dto in + guard let index = dto.channelIndex else { return false } + return droppedChannelIndices[dto.radioID]?.contains(index) ?? false + } + droppedReactions = beforeReactions - reactions.count } - /// Refreshes `lastMessageDate` on contacts, channels, and remote-node - /// sessions that received new rows during this import. - /// - /// The max date per target is computed from the imported DTOs in-memory, - /// so the store only fetches the contact/channel/session rows it needs - /// to mutate. Duplicates in the envelope are harmless: their timestamps - /// are already reflected in the pre-existing `lastMessageDate`, and the - /// max-vs-current check filters them out. - fileprivate func reconcileLastMessageDates( - messages: [MessageDTO], - roomMessages: [RoomMessageDTO], - affectedRoomSessionIDs: Set - ) throws { - var contactMaxDates: [UUID: Date] = [:] - var channelMaxDates: [UUID: [UInt8: Date]] = [:] - for message in messages { - if let contactID = message.contactID { - let existing = contactMaxDates[contactID] - if existing == nil || existing! < message.createdAt { - contactMaxDates[contactID] = message.createdAt - } - } - if let index = message.channelIndex { - let existing = channelMaxDates[message.radioID]?[index] - if existing == nil || existing! < message.createdAt { - channelMaxDates[message.radioID, default: [:]][index] = message.createdAt - } - } - } + if !remap.isEmpty { + for i in messages.indices { + guard let backupIndex = messages[i].channelIndex, + let localIndex = remap[messages[i].radioID]?[backupIndex] else { continue } + messages[i].channelIndex = localIndex + if let dedupKey = messages[i].deduplicationKey { + messages[i].deduplicationKey = rewriteChannelDeduplicationKey( + dedupKey, from: backupIndex, to: localIndex + ) + } + } + for i in reactions.indices { + guard let backupIndex = reactions[i].channelIndex, + let localIndex = remap[reactions[i].radioID]?[backupIndex] else { continue } + reactions[i].channelIndex = localIndex + } + } + return (droppedMessages, droppedReactions) + } + + /// Rewrites `messageID` on both message repeats and reactions so they + /// reference the local (post-merge) parent. + func applyMessageIDMapping( + _ mapping: [UUID: UUID], + toRepeats repeats: inout [MessageRepeatDTO], + toReactions reactions: inout [ReactionDTO] + ) { + guard !mapping.isEmpty else { return } + for i in repeats.indices { + if let localID = mapping[repeats[i].messageID] { + repeats[i].messageID = localID + } + } + for i in reactions.indices { + if let localID = mapping[reactions[i].messageID] { + reactions[i].messageID = localID + } + } + } + + /// Returns backup-session-ID → local-session-ID entries for sessions + /// that merged into an existing local row (identity mappings omitted). + func buildSessionIDMapping( + sessions: [RemoteNodeSessionDTO], + sessionIDsByKey: [String: UUID] + ) -> [UUID: UUID] { + var mapping: [UUID: UUID] = [:] + for session in sessions { + let key = remoteNodeSessionKey(radioID: session.radioID, publicKey: session.publicKey) + if let localID = sessionIDsByKey[key], localID != session.id { + mapping[session.id] = localID + } + } + return mapping + } + + /// Rewrites `sessionID` on room messages so they attach to the local + /// (post-merge) session row. + func applySessionIDMapping( + _ mapping: [UUID: UUID], + toRoomMessages roomMessages: inout [RoomMessageDTO] + ) { + guard !mapping.isEmpty else { return } + for i in roomMessages.indices { + if let localID = mapping[roomMessages[i].sessionID] { + roomMessages[i].sessionID = localID + } + } + } + + /// Refreshes `lastMessageDate` on contacts, channels, and remote-node + /// sessions that received new rows during this import. + /// + /// The max date per target is computed from the imported DTOs in-memory, + /// so the store only fetches the contact/channel/session rows it needs + /// to mutate. Duplicates in the envelope are harmless: their timestamps + /// are already reflected in the pre-existing `lastMessageDate`, and the + /// max-vs-current check filters them out. + func reconcileLastMessageDates( + messages: [MessageDTO], + roomMessages: [RoomMessageDTO], + affectedRoomSessionIDs: Set + ) throws { + var contactMaxDates: [UUID: Date] = [:] + var channelMaxDates: [UUID: [UInt8: Date]] = [:] + for message in messages { + if let contactID = message.contactID { + let existing = contactMaxDates[contactID] + if existing == nil || existing! < message.createdAt { + contactMaxDates[contactID] = message.createdAt + } + } + if let index = message.channelIndex { + let existing = channelMaxDates[message.radioID]?[index] + if existing == nil || existing! < message.createdAt { + channelMaxDates[message.radioID, default: [:]][index] = message.createdAt + } + } + } - if !contactMaxDates.isEmpty { - try applyLastMessageDatesToContacts(contactMaxDates) - } - if !channelMaxDates.isEmpty { - try applyLastMessageDatesToChannels(channelMaxDates) - } + if !contactMaxDates.isEmpty { + try applyLastMessageDatesToContacts(contactMaxDates) + } + if !channelMaxDates.isEmpty { + try applyLastMessageDatesToChannels(channelMaxDates) + } - guard !affectedRoomSessionIDs.isEmpty else { return } - var sessionMaxDates: [UUID: Date] = [:] - for roomMessage in roomMessages where affectedRoomSessionIDs.contains(roomMessage.sessionID) { - let existing = sessionMaxDates[roomMessage.sessionID] - if existing == nil || existing! < roomMessage.createdAt { - sessionMaxDates[roomMessage.sessionID] = roomMessage.createdAt - } - } - if !sessionMaxDates.isEmpty { - try applyLastMessageDatesToRemoteNodeSessions(sessionMaxDates) - } + guard !affectedRoomSessionIDs.isEmpty else { return } + var sessionMaxDates: [UUID: Date] = [:] + for roomMessage in roomMessages where affectedRoomSessionIDs.contains(roomMessage.sessionID) { + let existing = sessionMaxDates[roomMessage.sessionID] + if existing == nil || existing! < roomMessage.createdAt { + sessionMaxDates[roomMessage.sessionID] = roomMessage.createdAt + } } + if !sessionMaxDates.isEmpty { + try applyLastMessageDatesToRemoteNodeSessions(sessionMaxDates) + } + } } // MARK: - Import Reconciliation extension PersistenceStore { - - /// Recomputes cached heardRepeats count and reactionSummary for messages - /// that received new child rows during import. - public func recomputeMessageCaches(messageIDs: Set) throws { - guard !messageIDs.isEmpty else { return } - try Task.checkCancellation() - let idArray = Array(messageIDs) - - let messages = try fetchInChunks(keys: idArray) { chunk in - let predicate = #Predicate { chunk.contains($0.id) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - - let allRepeats = try fetchInChunks(keys: idArray) { chunk in - let predicate = #Predicate { chunk.contains($0.messageID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - var repeatCountsByMessageID: [UUID: Int] = [:] - for r in allRepeats { - repeatCountsByMessageID[r.messageID, default: 0] += 1 - } - - try Task.checkCancellation() - let allReactions = try fetchInChunks(keys: idArray) { chunk in - let predicate = #Predicate { chunk.contains($0.messageID) } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - } - var reactionsByMessageID: [UUID: [Reaction]] = [:] - for r in allReactions { - reactionsByMessageID[r.messageID, default: []].append(r) - } - - for (offset, message) in messages.enumerated() { - if offset % cancellationCheckStride == 0 { - try Task.checkCancellation() - } - let newHeardRepeats = repeatCountsByMessageID[message.id] ?? 0 - if message.heardRepeats != newHeardRepeats { - message.heardRepeats = newHeardRepeats - } - - let newSummary: String? - if let reactions = reactionsByMessageID[message.id], !reactions.isEmpty { - let reactionDTOs = reactions.map { ReactionDTO(from: $0) } - newSummary = ReactionParser.buildSummary(from: reactionDTOs) - } else { - newSummary = nil - } - if message.reactionSummary != newSummary { - message.reactionSummary = newSummary - } - } + /// Recomputes cached heardRepeats count and reactionSummary for messages + /// that received new child rows during import. + public func recomputeMessageCaches(messageIDs: Set) throws { + guard !messageIDs.isEmpty else { return } + try Task.checkCancellation() + let idArray = Array(messageIDs) + + let messages = try fetchInChunks(keys: idArray) { chunk in + let predicate = #Predicate { chunk.contains($0.id) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) } - /// Advances `Contact.lastMessageDate` toward the per-contact max timestamp - /// from the just-imported messages. Existing values are preserved when - /// they're already newer (e.g., a DTO imported out of order). - fileprivate func applyLastMessageDatesToContacts(_ maxDates: [UUID: Date]) throws { - try Task.checkCancellation() - let idArray = Array(maxDates.keys) - let predicate = #Predicate { idArray.contains($0.id) } - let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - for contact in contacts { - guard let latest = maxDates[contact.id] else { continue } - if contact.lastMessageDate == nil || contact.lastMessageDate! < latest { - contact.lastMessageDate = latest - } - } + let allRepeats = try fetchInChunks(keys: idArray) { chunk in + let predicate = #Predicate { chunk.contains($0.messageID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + } + var repeatCountsByMessageID: [UUID: Int] = [:] + for r in allRepeats { + repeatCountsByMessageID[r.messageID, default: 0] += 1 } - /// Advances `Channel.lastMessageDate` using max timestamps keyed by - /// `(radioID, channelIndex)`. - fileprivate func applyLastMessageDatesToChannels(_ maxDates: [UUID: [UInt8: Date]]) throws { - try Task.checkCancellation() - let radioIDArray = Array(maxDates.keys) - let predicate = #Predicate { radioIDArray.contains($0.radioID) } - let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - for channel in channels { - guard let latest = maxDates[channel.radioID]?[channel.index] else { continue } - if channel.lastMessageDate == nil || channel.lastMessageDate! < latest { - channel.lastMessageDate = latest - } - } + try Task.checkCancellation() + let allReactions = try fetchInChunks(keys: idArray) { chunk in + let predicate = #Predicate { chunk.contains($0.messageID) } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + } + var reactionsByMessageID: [UUID: [Reaction]] = [:] + for r in allReactions { + reactionsByMessageID[r.messageID, default: []].append(r) } - /// Advances `RemoteNodeSession.lastMessageDate` using max timestamps - /// keyed by session ID. - fileprivate func applyLastMessageDatesToRemoteNodeSessions(_ maxDates: [UUID: Date]) throws { + for (offset, message) in messages.enumerated() { + if offset % cancellationCheckStride == 0 { try Task.checkCancellation() - let idArray = Array(maxDates.keys) - let predicate = #Predicate { idArray.contains($0.id) } - let sessions = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - for session in sessions { - guard let latest = maxDates[session.id] else { continue } - if session.lastMessageDate == nil || session.lastMessageDate! < latest { - session.lastMessageDate = latest - } - } + } + let newHeardRepeats = repeatCountsByMessageID[message.id] ?? 0 + if message.heardRepeats != newHeardRepeats { + message.heardRepeats = newHeardRepeats + } + + let newSummary: String? + if let reactions = reactionsByMessageID[message.id], !reactions.isEmpty { + let reactionDTOs = reactions.map { ReactionDTO(from: $0) } + newSummary = ReactionParser.buildSummary(from: reactionDTOs) + } else { + newSummary = nil + } + if message.reactionSummary != newSummary { + message.reactionSummary = newSummary + } + } + } + + /// Advances `Contact.lastMessageDate` toward the per-contact max timestamp + /// from the just-imported messages. Existing values are preserved when + /// they're already newer (e.g., a DTO imported out of order). + private func applyLastMessageDatesToContacts(_ maxDates: [UUID: Date]) throws { + try Task.checkCancellation() + let idArray = Array(maxDates.keys) + let predicate = #Predicate { idArray.contains($0.id) } + let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + for contact in contacts { + guard let latest = maxDates[contact.id] else { continue } + if contact.lastMessageDate == nil || contact.lastMessageDate! < latest { + contact.lastMessageDate = latest + } + } + } + + /// Advances `Channel.lastMessageDate` using max timestamps keyed by + /// `(radioID, channelIndex)`. + private func applyLastMessageDatesToChannels(_ maxDates: [UUID: [UInt8: Date]]) throws { + try Task.checkCancellation() + let radioIDArray = Array(maxDates.keys) + let predicate = #Predicate { radioIDArray.contains($0.radioID) } + let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + for channel in channels { + guard let latest = maxDates[channel.radioID]?[channel.index] else { continue } + if channel.lastMessageDate == nil || channel.lastMessageDate! < latest { + channel.lastMessageDate = latest + } + } + } + + /// Advances `RemoteNodeSession.lastMessageDate` using max timestamps + /// keyed by session ID. + private func applyLastMessageDatesToRemoteNodeSessions(_ maxDates: [UUID: Date]) throws { + try Task.checkCancellation() + let idArray = Array(maxDates.keys) + let predicate = #Predicate { idArray.contains($0.id) } + let sessions = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + for session in sessions { + guard let latest = maxDates[session.id] else { continue } + if session.lastMessageDate == nil || session.lastMessageDate! < latest { + session.lastMessageDate = latest + } } + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift index f738a1ab..8da98c18 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift @@ -2,418 +2,417 @@ import Foundation import MeshCore import SwiftData -extension PersistenceStore { - - // MARK: - Blocked Channel Senders - - public func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) throws { - let targetRadioID = dto.radioID - let targetName = dto.name - let predicate = #Predicate { entry in - entry.radioID == targetRadioID && entry.name == targetName - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let existing = try modelContext.fetch(descriptor).first { - existing.dateBlocked = dto.dateBlocked - } else { - let entry = BlockedChannelSender( - id: dto.id, - name: targetName, - radioID: dto.radioID, - dateBlocked: dto.dateBlocked - ) - modelContext.insert(entry) - } - - try modelContext.save() - } - - public func deleteBlockedChannelSender(radioID: UUID, name: String) throws { - let targetRadioID = radioID - let targetName = name - let predicate = #Predicate { entry in - entry.radioID == targetRadioID && entry.name == targetName - } - if let entry = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { - modelContext.delete(entry) - try modelContext.save() - } - } - - public func fetchBlockedChannelSenders(radioID: UUID) throws -> [BlockedChannelSenderDTO] { - let targetRadioID = radioID - let predicate = #Predicate { entry in - entry.radioID == targetRadioID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.dateBlocked, order: .reverse)] - ) - let entries = try modelContext.fetch(descriptor) - return entries.map { BlockedChannelSenderDTO(from: $0) } - } - - // MARK: - Mention Tracking - - public func incrementChannelUnreadMentionCount(channelID: UUID) throws { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { return } - channel.unreadMentionCount += 1 - try modelContext.save() - } - - public func decrementChannelUnreadMentionCount(channelID: UUID) throws { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { return } - channel.unreadMentionCount = max(0, channel.unreadMentionCount - 1) - try modelContext.save() - } - - public func clearChannelUnreadMentionCount(channelID: UUID) throws { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { return } - channel.unreadMentionCount = 0 - try modelContext.save() - } - - public func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) throws -> [UUID] { - let targetRadioID = radioID - let targetIndex: UInt8? = channelIndex - let predicate = #Predicate { message in - message.radioID == targetRadioID && - message.channelIndex == targetIndex && - message.containsSelfMention == true && - message.mentionSeen == false - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.sortBy = [SortDescriptor(\.timestamp, order: .forward)] - - let messages = try modelContext.fetch(descriptor) - return messages.map(\.id) - } - - // MARK: - Channel Operations - - /// Fetch all channels for a device - public func fetchChannels(radioID: UUID) throws -> [ChannelDTO] { - let targetRadioID = radioID - let predicate = #Predicate { channel in - channel.radioID == targetRadioID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.index)] - ) - let channels = try modelContext.fetch(descriptor) - return channels.map { ChannelDTO(from: $0) } - } - - /// Fetch a channel by index - public func fetchChannel(radioID: UUID, index: UInt8) throws -> ChannelDTO? { - let targetRadioID = radioID - let targetIndex = index - let predicate = #Predicate { channel in - channel.radioID == targetRadioID && channel.index == targetIndex - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { ChannelDTO(from: $0) } - } - - /// Fetch a channel by ID - public func fetchChannel(id: UUID) throws -> ChannelDTO? { - let targetID = id - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { ChannelDTO(from: $0) } - } - - /// Save or update a channel from ChannelInfo - public func saveChannel(radioID: UUID, from info: ChannelInfo) throws -> UUID { - let targetRadioID = radioID - let targetIndex = info.index - let predicate = #Predicate { channel in - channel.radioID == targetRadioID && channel.index == targetIndex - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - let channel: Channel - if let existing = try modelContext.fetch(descriptor).first { - existing.update(from: info) - channel = existing - } else { - channel = Channel(radioID: radioID, from: info) - modelContext.insert(channel) - } - - try modelContext.save() - return channel.id - } - - /// Persists a full channel-sync pass in a single transaction. See - /// ``PersistenceStoreProtocol/batchSaveChannels(radioID:configured:unconfiguredIndices:pruneBeyond:)`` - /// for the contract. Collapses the per-index `saveChannel`/`deleteChannel` calls — each its - /// own commit, plus a redundant re-fetch — into one fetch, one mutation pass, and one - /// `save()`. Indices that were neither confirmed configured nor reported unconfigured are - /// left untouched, so a circuit-breaker abort never deletes channels it could not read. - public func batchSaveChannels( - radioID: UUID, - configured: [ChannelInfo], - unconfiguredIndices: [UInt8], - pruneBeyond maxChannels: UInt8? - ) throws -> [ChannelDTO] { - let targetRadioID = radioID - let predicate = #Predicate { channel in - channel.radioID == targetRadioID - } - let existing = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - var byIndex = Dictionary(existing.map { ($0.index, $0) }, uniquingKeysWith: { current, _ in current }) - - for info in configured { - if let row = byIndex[info.index] { - row.update(from: info) - } else { - let channel = Channel(radioID: radioID, from: info) - modelContext.insert(channel) - byIndex[info.index] = channel - } - } - - for index in unconfiguredIndices { - if let stale = byIndex[index] { - modelContext.delete(stale) - byIndex[index] = nil - } - } - - if let maxChannels { - for (index, row) in byIndex where index >= maxChannels { - modelContext.delete(row) - byIndex[index] = nil - } - } - - do { - try modelContext.save() - } catch { - // Discard the staged upserts and deletes so a later successful save on this shared - // context cannot flush them and delete channels this failed pass meant to keep. - modelContext.rollback() - throw error - } - return try fetchChannels(radioID: radioID) - } - - /// Save or update a channel from DTO - public func saveChannel(_ dto: ChannelDTO) throws { - let targetID = dto.id - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let existing = try modelContext.fetch(descriptor).first { - existing.apply(dto) - } else { - modelContext.insert(Channel(dto: dto)) - } - - try modelContext.save() - } - - /// Delete a channel - public func deleteChannel(id: UUID) throws { - let targetID = id - let predicate = #Predicate { channel in - channel.id == targetID - } - if let channel = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { - modelContext.delete(channel) - try modelContext.save() - } - } - - /// Delete all messages for a channel. - /// Cascades PendingSend, MessageRepeat, and Reaction rows associated with the deleted - /// messages within a single save. - public func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) throws { - let targetRadioID = radioID - let targetChannelIndex: UInt8? = channelIndex - let messagePredicate = #Predicate { message in - message.radioID == targetRadioID && message.channelIndex == targetChannelIndex - } - - let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) - - if !messageIDs.isEmpty { - try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) - // Cascade MessageRepeat alongside Reaction. Bulk `delete(model:where:)` - // bypasses the `@Relationship(deleteRule: .cascade)` declared on - // `Message → MessageRepeat`. Chunk both predicates to stay under - // SQLITE_MAX_VARIABLE_NUMBER (32766 on iOS 18+). - let chunkSize = 500 - for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { - let chunk = Array(messageIDs[start.. { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let channel = try modelContext.fetch(descriptor).first { - channel.lastMessageDate = date - try modelContext.save() - } - } - - // MARK: - Channel Unread Count - - /// Increment unread count for a channel - public func incrementChannelUnreadCount(channelID: UUID) throws { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let channel = try modelContext.fetch(descriptor).first { - channel.unreadCount += 1 - try modelContext.save() - } - } - - /// Clear unread count for a channel - public func clearChannelUnreadCount(channelID: UUID) throws { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let channel = try modelContext.fetch(descriptor).first { - channel.unreadCount = 0 - try modelContext.save() - } - } - - /// Clear unread count for a channel by radioID and index - /// More efficient than fetching the full channel DTO when only clearing unread - public func clearChannelUnreadCount(radioID: UUID, index: UInt8) throws { - let targetRadioID = radioID - let targetIndex = index - let predicate = #Predicate { channel in - channel.radioID == targetRadioID && channel.index == targetIndex - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - if let channel = try modelContext.fetch(descriptor).first { - channel.unreadCount = 0 - try modelContext.save() - } - } - - /// Sets the muted state for a channel - public func setChannelMuted(_ channelID: UUID, isMuted: Bool) throws { - let targetID = channelID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.channelNotFound - } - - channel.notificationLevel = isMuted ? .muted : .all - try modelContext.save() - } - - /// Sets the notification level for a channel - public func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) throws { - let targetID = channelID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.channelNotFound - } - - channel.notificationLevel = level - try modelContext.save() - } - - /// Sets the favorite state for a channel - public func setChannelFavorite(_ channelID: UUID, isFavorite: Bool) throws { - let targetID = channelID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.channelNotFound - } - - channel.isFavorite = isFavorite - try modelContext.save() - } - - /// Atomically updates the per-channel flood-scope preference. Writes both backing - /// storage fields (`floodScopeModeRawValue` and `regionScope`) in one step so - /// callers cannot persist a malformed combination. - public func setChannelFloodScope(_ channelID: UUID, floodScope: ChannelFloodScope) throws { - let targetID = channelID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let channel = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.channelNotFound - } - - channel.floodScope = floodScope - try modelContext.save() +public extension PersistenceStore { + // MARK: - Blocked Channel Senders + + func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) throws { + let targetRadioID = dto.radioID + let targetName = dto.name + let predicate = #Predicate { entry in + entry.radioID == targetRadioID && entry.name == targetName } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let existing = try modelContext.fetch(descriptor).first { + existing.dateBlocked = dto.dateBlocked + } else { + let entry = BlockedChannelSender( + id: dto.id, + name: targetName, + radioID: dto.radioID, + dateBlocked: dto.dateBlocked + ) + modelContext.insert(entry) + } + + try modelContext.save() + } + + func deleteBlockedChannelSender(radioID: UUID, name: String) throws { + let targetRadioID = radioID + let targetName = name + let predicate = #Predicate { entry in + entry.radioID == targetRadioID && entry.name == targetName + } + if let entry = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + modelContext.delete(entry) + try modelContext.save() + } + } + + func fetchBlockedChannelSenders(radioID: UUID) throws -> [BlockedChannelSenderDTO] { + let targetRadioID = radioID + let predicate = #Predicate { entry in + entry.radioID == targetRadioID + } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.dateBlocked, order: .reverse)] + ) + let entries = try modelContext.fetch(descriptor) + return entries.map { BlockedChannelSenderDTO(from: $0) } + } + + // MARK: - Mention Tracking + + func incrementChannelUnreadMentionCount(channelID: UUID) throws { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { return } + channel.unreadMentionCount += 1 + try modelContext.save() + } + + func decrementChannelUnreadMentionCount(channelID: UUID) throws { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { return } + channel.unreadMentionCount = max(0, channel.unreadMentionCount - 1) + try modelContext.save() + } + + func clearChannelUnreadMentionCount(channelID: UUID) throws { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { return } + channel.unreadMentionCount = 0 + try modelContext.save() + } + + func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) throws -> [UUID] { + let targetRadioID = radioID + let targetIndex: UInt8? = channelIndex + let predicate = #Predicate { message in + message.radioID == targetRadioID && + message.channelIndex == targetIndex && + message.containsSelfMention == true && + message.mentionSeen == false + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.sortBy = [SortDescriptor(\.timestamp, order: .forward)] + + let messages = try modelContext.fetch(descriptor) + return messages.map(\.id) + } + + // MARK: - Channel Operations + + /// Fetch all channels for a device + func fetchChannels(radioID: UUID) throws -> [ChannelDTO] { + let targetRadioID = radioID + let predicate = #Predicate { channel in + channel.radioID == targetRadioID + } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.index)] + ) + let channels = try modelContext.fetch(descriptor) + return channels.map { ChannelDTO(from: $0) } + } + + /// Fetch a channel by index + func fetchChannel(radioID: UUID, index: UInt8) throws -> ChannelDTO? { + let targetRadioID = radioID + let targetIndex = index + let predicate = #Predicate { channel in + channel.radioID == targetRadioID && channel.index == targetIndex + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { ChannelDTO(from: $0) } + } + + /// Fetch a channel by ID + func fetchChannel(id: UUID) throws -> ChannelDTO? { + let targetID = id + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { ChannelDTO(from: $0) } + } + + /// Save or update a channel from ChannelInfo + func saveChannel(radioID: UUID, from info: ChannelInfo) throws -> UUID { + let targetRadioID = radioID + let targetIndex = info.index + let predicate = #Predicate { channel in + channel.radioID == targetRadioID && channel.index == targetIndex + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + let channel: Channel + if let existing = try modelContext.fetch(descriptor).first { + existing.update(from: info) + channel = existing + } else { + channel = Channel(radioID: radioID, from: info) + modelContext.insert(channel) + } + + try modelContext.save() + return channel.id + } + + /// Persists a full channel-sync pass in a single transaction. See + /// ``PersistenceStoreProtocol/batchSaveChannels(radioID:configured:unconfiguredIndices:pruneBeyond:)`` + /// for the contract. Collapses the per-index `saveChannel`/`deleteChannel` calls — each its + /// own commit, plus a redundant re-fetch — into one fetch, one mutation pass, and one + /// `save()`. Indices that were neither confirmed configured nor reported unconfigured are + /// left untouched, so a circuit-breaker abort never deletes channels it could not read. + func batchSaveChannels( + radioID: UUID, + configured: [ChannelInfo], + unconfiguredIndices: [UInt8], + pruneBeyond maxChannels: UInt8? + ) throws -> [ChannelDTO] { + let targetRadioID = radioID + let predicate = #Predicate { channel in + channel.radioID == targetRadioID + } + let existing = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + var byIndex = Dictionary(existing.map { ($0.index, $0) }, uniquingKeysWith: { current, _ in current }) + + for info in configured { + if let row = byIndex[info.index] { + row.update(from: info) + } else { + let channel = Channel(radioID: radioID, from: info) + modelContext.insert(channel) + byIndex[info.index] = channel + } + } + + for index in unconfiguredIndices { + if let stale = byIndex[index] { + modelContext.delete(stale) + byIndex[index] = nil + } + } + + if let maxChannels { + for (index, row) in byIndex where index >= maxChannels { + modelContext.delete(row) + byIndex[index] = nil + } + } + + do { + try modelContext.save() + } catch { + // Discard the staged upserts and deletes so a later successful save on this shared + // context cannot flush them and delete channels this failed pass meant to keep. + modelContext.rollback() + throw error + } + return try fetchChannels(radioID: radioID) + } + + /// Save or update a channel from DTO + func saveChannel(_ dto: ChannelDTO) throws { + let targetID = dto.id + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let existing = try modelContext.fetch(descriptor).first { + existing.apply(dto) + } else { + modelContext.insert(Channel(dto: dto)) + } + + try modelContext.save() + } + + /// Delete a channel + func deleteChannel(id: UUID) throws { + let targetID = id + let predicate = #Predicate { channel in + channel.id == targetID + } + if let channel = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + modelContext.delete(channel) + try modelContext.save() + } + } + + /// Delete all messages for a channel. + /// Cascades PendingSend, MessageRepeat, and Reaction rows associated with the deleted + /// messages within a single save. + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) throws { + let targetRadioID = radioID + let targetChannelIndex: UInt8? = channelIndex + let messagePredicate = #Predicate { message in + message.radioID == targetRadioID && message.channelIndex == targetChannelIndex + } + + let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) + + if !messageIDs.isEmpty { + try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) + // Cascade MessageRepeat alongside Reaction. Bulk `delete(model:where:)` + // bypasses the `@Relationship(deleteRule: .cascade)` declared on + // `Message → MessageRepeat`. Chunk both predicates to stay under + // SQLITE_MAX_VARIABLE_NUMBER (32766 on iOS 18+). + let chunkSize = 500 + for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { + let chunk = Array(messageIDs[start.. { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let channel = try modelContext.fetch(descriptor).first { + channel.lastMessageDate = date + try modelContext.save() + } + } + + // MARK: - Channel Unread Count + + /// Increment unread count for a channel + func incrementChannelUnreadCount(channelID: UUID) throws { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let channel = try modelContext.fetch(descriptor).first { + channel.unreadCount += 1 + try modelContext.save() + } + } + + /// Clear unread count for a channel + func clearChannelUnreadCount(channelID: UUID) throws { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let channel = try modelContext.fetch(descriptor).first { + channel.unreadCount = 0 + try modelContext.save() + } + } + + /// Clear unread count for a channel by radioID and index + /// More efficient than fetching the full channel DTO when only clearing unread + func clearChannelUnreadCount(radioID: UUID, index: UInt8) throws { + let targetRadioID = radioID + let targetIndex = index + let predicate = #Predicate { channel in + channel.radioID == targetRadioID && channel.index == targetIndex + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let channel = try modelContext.fetch(descriptor).first { + channel.unreadCount = 0 + try modelContext.save() + } + } + + /// Sets the muted state for a channel + func setChannelMuted(_ channelID: UUID, isMuted: Bool) throws { + let targetID = channelID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.channelNotFound + } + + channel.notificationLevel = isMuted ? .muted : .all + try modelContext.save() + } + + /// Sets the notification level for a channel + func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) throws { + let targetID = channelID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.channelNotFound + } + + channel.notificationLevel = level + try modelContext.save() + } + + /// Sets the favorite state for a channel + func setChannelFavorite(_ channelID: UUID, isFavorite: Bool) throws { + let targetID = channelID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.channelNotFound + } + + channel.isFavorite = isFavorite + try modelContext.save() + } + + /// Atomically updates the per-channel flood-scope preference. Writes both backing + /// storage fields (`floodScopeModeRawValue` and `regionScope`) in one step so + /// callers cannot persist a malformed combination. + func setChannelFloodScope(_ channelID: UUID, floodScope: ChannelFloodScope) throws { + let targetID = channelID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.channelNotFound + } + + channel.floodScope = floodScope + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift index 520eafc3..5c37ab49 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift @@ -1,375 +1,374 @@ import Foundation import SwiftData -extension PersistenceStore { - - // MARK: - Contact Operations - - /// Fetch all contacts for a device - public func fetchContacts(radioID: UUID) throws -> [ContactDTO] { - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.name)] - ) - let contacts = try modelContext.fetch(descriptor) - return contacts.map { ContactDTO(from: $0) } +public extension PersistenceStore { + // MARK: - Contact Operations + + /// Fetch all contacts for a device + func fetchContacts(radioID: UUID) throws -> [ContactDTO] { + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID } - - /// Fetch contacts with recent messages (for chat list) - public func fetchConversations(radioID: UUID) throws -> [ContactDTO] { - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID && contact.lastMessageDate != nil - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\Contact.lastMessageDate, order: .reverse)] - ) - let contacts = try modelContext.fetch(descriptor) - return contacts.map { ContactDTO(from: $0) } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.name)] + ) + let contacts = try modelContext.fetch(descriptor) + return contacts.map { ContactDTO(from: $0) } + } + + /// Fetch contacts with recent messages (for chat list) + func fetchConversations(radioID: UUID) throws -> [ContactDTO] { + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID && contact.lastMessageDate != nil } - - /// Fetch a contact by ID - public func fetchContact(id: UUID) throws -> ContactDTO? { - let targetID = id - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\Contact.lastMessageDate, order: .reverse)] + ) + let contacts = try modelContext.fetch(descriptor) + return contacts.map { ContactDTO(from: $0) } + } + + /// Fetch a contact by ID + func fetchContact(id: UUID) throws -> ContactDTO? { + let targetID = id + let predicate = #Predicate { contact in + contact.id == targetID } - - /// Fetch a contact by public key - public func fetchContact(radioID: UUID, publicKey: Data) throws -> ContactDTO? { - let targetRadioID = radioID - let targetKey = publicKey - let predicate = #Predicate { contact in - contact.radioID == targetRadioID && contact.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + } + + /// Fetch a contact by public key + func fetchContact(radioID: UUID, publicKey: Data) throws -> ContactDTO? { + let targetRadioID = radioID + let targetKey = publicKey + let predicate = #Predicate { contact in + contact.radioID == targetRadioID && contact.publicKey == targetKey } - - /// Fetch a contact by public key prefix (6 bytes) - public func fetchContact(radioID: UUID, publicKeyPrefix: Data) throws -> ContactDTO? { - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID - } - let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - return contacts.first { $0.publicKey.prefix(6) == publicKeyPrefix }.map { ContactDTO(from: $0) } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + } + + /// Fetch a contact by public key prefix (6 bytes) + func fetchContact(radioID: UUID, publicKeyPrefix: Data) throws -> ContactDTO? { + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID } - - /// Fetch all contacts with their public keys grouped by 1-byte prefix. - /// Used for crypto operations when looking up contacts by public key prefix. - public func fetchContactPublicKeysByPrefix(radioID: UUID) throws -> [UInt8: [Data]] { - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID - } - let descriptor = FetchDescriptor(predicate: predicate) - let contacts = try modelContext.fetch(descriptor) - - var result: [UInt8: [Data]] = [:] - for contact in contacts { - guard contact.publicKey.count >= 1 else { continue } - let prefix = contact.publicKey[0] - result[prefix, default: []].append(contact.publicKey) - } - return result - } - - /// Save or update a contact from a ContactFrame - public func saveContact(radioID: UUID, from frame: ContactFrame) throws -> UUID { - let targetRadioID = radioID - let targetKey = frame.publicKey - let predicate = #Predicate { contact in - contact.radioID == targetRadioID && contact.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - let contact: Contact - if let existing = try modelContext.fetch(descriptor).first { - existing.update(from: frame) - contact = existing - } else { - contact = Contact(radioID: radioID, from: frame) - modelContext.insert(contact) - } - - try modelContext.save() - return contact.id + let contacts = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + return contacts.first { $0.publicKey.prefix(6) == publicKeyPrefix }.map { ContactDTO(from: $0) } + } + + /// Fetch all contacts with their public keys grouped by 1-byte prefix. + /// Used for crypto operations when looking up contacts by public key prefix. + func fetchContactPublicKeysByPrefix(radioID: UUID) throws -> [UInt8: [Data]] { + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID } - - /// Upserts contacts from frames in a single transaction, matching local rows by - /// `(radioID, publicKey)`. Commits once for the whole batch instead of once per contact, - /// which is the dominant cost of a full contact sync over BLE. Returns the number of - /// frames persisted. - @discardableResult - public func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) throws -> Int { - guard !frames.isEmpty else { return 0 } - - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID - } - let existing = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - var byKey = Dictionary(existing.map { ($0.publicKey, $0) }, uniquingKeysWith: { current, _ in current }) - - for frame in frames { - if let row = byKey[frame.publicKey] { - row.update(from: frame) - } else { - let contact = Contact(radioID: radioID, from: frame) - modelContext.insert(contact) - byKey[frame.publicKey] = contact - } - } - - try modelContext.save() - return frames.count + let descriptor = FetchDescriptor(predicate: predicate) + let contacts = try modelContext.fetch(descriptor) + + var result: [UInt8: [Data]] = [:] + for contact in contacts { + guard contact.publicKey.count >= 1 else { continue } + let prefix = contact.publicKey[0] + result[prefix, default: []].append(contact.publicKey) } - - /// Save or update a contact from DTO - public func saveContact(_ dto: ContactDTO) throws { - let targetID = dto.id - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let existing = try modelContext.fetch(descriptor).first { - existing.apply(dto) - } else { - modelContext.insert(Contact(dto: dto)) - } - - try modelContext.save() + return result + } + + /// Save or update a contact from a ContactFrame + func saveContact(radioID: UUID, from frame: ContactFrame) throws -> UUID { + let targetRadioID = radioID + let targetKey = frame.publicKey + let predicate = #Predicate { contact in + contact.radioID == targetRadioID && contact.publicKey == targetKey } - - /// Delete a contact - public func deleteContact(id: UUID) throws { - let targetID = id - let predicate = #Predicate { contact in - contact.id == targetID - } - if let contact = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { - modelContext.delete(contact) - try modelContext.save() - } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + let contact: Contact + if let existing = try modelContext.fetch(descriptor).first { + existing.update(from: frame) + contact = existing + } else { + contact = Contact(radioID: radioID, from: frame) + modelContext.insert(contact) } - /// Fetch all blocked contacts for a device - public func fetchBlockedContacts(radioID: UUID) throws -> [ContactDTO] { - let targetRadioID = radioID - let predicate = #Predicate { contact in - contact.radioID == targetRadioID && contact.isBlocked == true - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.name)] - ) - let contacts = try modelContext.fetch(descriptor) - return contacts.map { ContactDTO(from: $0) } + try modelContext.save() + return contact.id + } + + /// Upserts contacts from frames in a single transaction, matching local rows by + /// `(radioID, publicKey)`. Commits once for the whole batch instead of once per contact, + /// which is the dominant cost of a full contact sync over BLE. Returns the number of + /// frames persisted. + @discardableResult + func batchSaveContacts(radioID: UUID, from frames: [ContactFrame]) throws -> Int { + guard !frames.isEmpty else { return 0 } + + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID } - - /// Update contact's last message info (nil clears the date, removing from conversations list) - public func updateContactLastMessage(contactID: UUID, date: Date?) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let contact = try modelContext.fetch(descriptor).first { - contact.lastMessageDate = date - try modelContext.save() - } + let existing = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + var byKey = Dictionary(existing.map { ($0.publicKey, $0) }, uniquingKeysWith: { current, _ in current }) + + for frame in frames { + if let row = byKey[frame.publicKey] { + row.update(from: frame) + } else { + let contact = Contact(radioID: radioID, from: frame) + modelContext.insert(contact) + byKey[frame.publicKey] = contact + } } - /// Re-derives a contact's lastMessageDate from its newest remaining message, - /// clearing it (removing the conversation from the list) when none remain. - /// Returns the resulting date so callers can react to removal. - @discardableResult - public func recomputeContactLastMessageDate(contactID: UUID) throws -> Date? { - let newest = try fetchMessages(contactID: contactID, limit: 1, offset: 0).first - try updateContactLastMessage(contactID: contactID, date: newest?.date) - return newest?.date - } + try modelContext.save() + return frames.count + } - /// Increment unread count for a contact - public func incrementUnreadCount(contactID: UUID) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let contact = try modelContext.fetch(descriptor).first { - contact.unreadCount += 1 - try modelContext.save() - } + /// Save or update a contact from DTO + func saveContact(_ dto: ContactDTO) throws { + let targetID = dto.id + let predicate = #Predicate { contact in + contact.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - /// Clear unread count for a contact - public func clearUnreadCount(contactID: UUID) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let contact = try modelContext.fetch(descriptor).first { - contact.unreadCount = 0 - try modelContext.save() - } + if let existing = try modelContext.fetch(descriptor).first { + existing.apply(dto) + } else { + modelContext.insert(Contact(dto: dto)) } - // MARK: - Mention Tracking - - public func incrementUnreadMentionCount(contactID: UUID) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + try modelContext.save() + } - guard let contact = try modelContext.fetch(descriptor).first else { return } - contact.unreadMentionCount += 1 - try modelContext.save() + /// Delete a contact + func deleteContact(id: UUID) throws { + let targetID = id + let predicate = #Predicate { contact in + contact.id == targetID } - - public func decrementUnreadMentionCount(contactID: UUID) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let contact = try modelContext.fetch(descriptor).first else { return } - contact.unreadMentionCount = max(0, contact.unreadMentionCount - 1) - try modelContext.save() + if let contact = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + modelContext.delete(contact) + try modelContext.save() } + } - public func clearUnreadMentionCount(contactID: UUID) throws { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let contact = try modelContext.fetch(descriptor).first else { return } - contact.unreadMentionCount = 0 - try modelContext.save() + /// Fetch all blocked contacts for a device + func fetchBlockedContacts(radioID: UUID) throws -> [ContactDTO] { + let targetRadioID = radioID + let predicate = #Predicate { contact in + contact.radioID == targetRadioID && contact.isBlocked == true } - - public func fetchUnseenMentionIDs(contactID: UUID) throws -> [UUID] { - let targetID = contactID - let predicate = #Predicate { message in - message.contactID == targetID && - message.containsSelfMention == true && - message.mentionSeen == false - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.sortBy = [SortDescriptor(\.timestamp, order: .forward)] - - let messages = try modelContext.fetch(descriptor) - return messages.map(\.id) + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.name)] + ) + let contacts = try modelContext.fetch(descriptor) + return contacts.map { ContactDTO(from: $0) } + } + + /// Update contact's last message info (nil clears the date, removing from conversations list) + func updateContactLastMessage(contactID: UUID, date: Date?) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - /// Sets the muted state for a contact - public func setContactMuted(_ contactID: UUID, isMuted: Bool) throws { - let targetID = contactID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let contact = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.contactNotFound - } + if let contact = try modelContext.fetch(descriptor).first { + contact.lastMessageDate = date + try modelContext.save() + } + } + + /// Re-derives a contact's lastMessageDate from its newest remaining message, + /// clearing it (removing the conversation from the list) when none remain. + /// Returns the resulting date so callers can react to removal. + @discardableResult + func recomputeContactLastMessageDate(contactID: UUID) throws -> Date? { + let newest = try fetchMessages(contactID: contactID, limit: 1, offset: 0).first + try updateContactLastMessage(contactID: contactID, date: newest?.date) + return newest?.date + } + + /// Increment unread count for a contact + func incrementUnreadCount(contactID: UUID) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - contact.isMuted = isMuted - try modelContext.save() + if let contact = try modelContext.fetch(descriptor).first { + contact.unreadCount += 1 + try modelContext.save() } + } - /// Delete all messages, reactions, message repeats, and pending sends for a contact - /// in a single transactional save. - public func deleteMessagesForContact(contactID: UUID) throws { - let targetContactID: UUID? = contactID - let messagePredicate = #Predicate { message in - message.contactID == targetContactID - } + /// Clear unread count for a contact + func clearUnreadCount(contactID: UUID) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) + if let contact = try modelContext.fetch(descriptor).first { + contact.unreadCount = 0 + try modelContext.save() + } + } - try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) + // MARK: - Mention Tracking - // Reaction is keyed by contactID directly here (single-value predicate) — - // no SQLITE_MAX_VARIABLE_NUMBER risk, no chunking needed. - try modelContext.delete(model: Reaction.self, where: #Predicate { - $0.contactID == targetContactID - }) - // Cascade MessageRepeat — no contactID column, so key by messageIDs. - // Chunk to stay under SQLITE_MAX_VARIABLE_NUMBER (32766 on iOS 18+). - if !messageIDs.isEmpty { - let chunkSize = 500 - for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { - let chunk = Array(messageIDs[start.. { contact in + contact.id == targetID } - - // MARK: - Contact Helper Methods - - /// Find contact display name by 4-byte or 6-byte public key prefix. - /// Searches across all devices — room message authors may only be known - /// from a previously-connected radio's contact list. - public func findContactNameByKeyPrefix(_ prefix: Data) throws -> String? { - // Fetch all contacts and filter by prefix match - let contacts = try modelContext.fetch(FetchDescriptor()) - let prefixLength = prefix.count - return contacts.first { contact in - contact.publicKey.prefix(prefixLength) == prefix - }?.displayName + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let contact = try modelContext.fetch(descriptor).first else { return } + contact.unreadMentionCount += 1 + try modelContext.save() + } + + func decrementUnreadMentionCount(contactID: UUID) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let contact = try modelContext.fetch(descriptor).first else { return } + contact.unreadMentionCount = max(0, contact.unreadMentionCount - 1) + try modelContext.save() + } + + func clearUnreadMentionCount(contactID: UUID) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let contact = try modelContext.fetch(descriptor).first else { return } + contact.unreadMentionCount = 0 + try modelContext.save() + } + + func fetchUnseenMentionIDs(contactID: UUID) throws -> [UUID] { + let targetID = contactID + let predicate = #Predicate { message in + message.contactID == targetID && + message.containsSelfMention == true && + message.mentionSeen == false + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.sortBy = [SortDescriptor(\.timestamp, order: .forward)] + + let messages = try modelContext.fetch(descriptor) + return messages.map(\.id) + } + + /// Sets the muted state for a contact + func setContactMuted(_ contactID: UUID, isMuted: Bool) throws { + let targetID = contactID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let contact = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.contactNotFound } - /// Find contact by 32-byte public key. - /// Searches across all devices — used for routing hints where the contact - /// may exist under a different device's ID. - public func findContactByPublicKey(_ publicKey: Data) throws -> ContactDTO? { - let targetKey = publicKey - let predicate = #Predicate { contact in - contact.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + contact.isMuted = isMuted + try modelContext.save() + } + + /// Delete all messages, reactions, message repeats, and pending sends for a contact + /// in a single transactional save. + func deleteMessagesForContact(contactID: UUID) throws { + let targetContactID: UUID? = contactID + let messagePredicate = #Predicate { message in + message.contactID == targetContactID } - public func fetchContactPublicKeys(radioID: UUID) throws -> Set { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - let descriptor = FetchDescriptor(predicate: predicate) - let contacts = try modelContext.fetch(descriptor) - return Set(contacts.map { $0.publicKey }) + let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) + + try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) + + // Reaction is keyed by contactID directly here (single-value predicate) — + // no SQLITE_MAX_VARIABLE_NUMBER risk, no chunking needed. + try modelContext.delete(model: Reaction.self, where: #Predicate { + $0.contactID == targetContactID + }) + // Cascade MessageRepeat — no contactID column, so key by messageIDs. + // Chunk to stay under SQLITE_MAX_VARIABLE_NUMBER (32766 on iOS 18+). + if !messageIDs.isEmpty { + let chunkSize = 500 + for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { + let chunk = Array(messageIDs[start.. String? { + // Fetch all contacts and filter by prefix match + let contacts = try modelContext.fetch(FetchDescriptor()) + let prefixLength = prefix.count + return contacts.first { contact in + contact.publicKey.prefix(prefixLength) == prefix + }?.displayName + } + + /// Find contact by 32-byte public key. + /// Searches across all devices — used for routing hints where the contact + /// may exist under a different device's ID. + func findContactByPublicKey(_ publicKey: Data) throws -> ContactDTO? { + let targetKey = publicKey + let predicate = #Predicate { contact in + contact.publicKey == targetKey } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { ContactDTO(from: $0) } + } + + func fetchContactPublicKeys(radioID: UUID) throws -> Set { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + let descriptor = FetchDescriptor(predicate: predicate) + let contacts = try modelContext.fetch(descriptor) + return Set(contacts.map(\.publicKey)) + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift index 958f84e8..99270764 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift @@ -1,417 +1,434 @@ import Foundation import SwiftData -extension PersistenceStore { - - // MARK: - Device Operations - - /// Fetch all devices - public func fetchDevices() throws -> [DeviceDTO] { - let descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\Device.lastConnected, order: .reverse)] - ) - let devices = try modelContext.fetch(descriptor) - return devices.map { DeviceDTO(from: $0) } - } - - /// Fetch a device by ID - public func fetchDevice(id: UUID) throws -> DeviceDTO? { - let targetID = id - let predicate = #Predicate { device in - device.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } - } - - /// Fetch a device by radio ID - public func fetchDevice(radioID: UUID) throws -> DeviceDTO? { - let targetRadioID = radioID - let predicate = #Predicate { device in - device.radioID == targetRadioID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } - } - - /// Fetch a device by public key - public func fetchDevice(publicKey: Data) throws -> DeviceDTO? { - let targetKey = publicKey - let predicate = #Predicate { device in - device.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } - } - - /// Fetch the active device - public func fetchActiveDevice() throws -> DeviceDTO? { - let predicate = #Predicate { device in - device.isActive == true - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } - } - - /// Save or update a device - public func saveDevice(_ dto: DeviceDTO) throws { - let targetID = dto.id - let predicate = #Predicate { device in - device.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let existing = try modelContext.fetch(descriptor).first { - existing.apply(dto) - } else { - modelContext.insert(Device(dto: dto)) - } - - try modelContext.save() - } - - /// Set a device as active (deactivates others) - public func setActiveDevice(id: UUID) throws { - // Deactivate all devices first - let allDevices = try modelContext.fetch(FetchDescriptor()) - for device in allDevices { - device.isActive = false - } - - // Activate the specified device - let targetID = id - let predicate = #Predicate { device in - device.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let device = try modelContext.fetch(descriptor).first { - device.isActive = true - device.lastConnected = Date() - } - - try modelContext.save() - } - - /// Update the lastContactSync timestamp for a device. - /// Used to track incremental sync progress. - public func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) throws { - let targetRadioID = radioID - let predicate = #Predicate { device in - device.radioID == targetRadioID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let device = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.deviceNotFound - } - device.lastContactSync = timestamp - try modelContext.save() - } - - /// Adds a known region to a device if not already present - public func addDeviceKnownRegion(radioID: UUID, region: String) throws { - let targetRadioID = radioID - let devicePredicate = #Predicate { $0.radioID == targetRadioID } - var deviceDescriptor = FetchDescriptor(predicate: devicePredicate) - deviceDescriptor.fetchLimit = 1 - - guard let device = try modelContext.fetch(deviceDescriptor).first else { - throw PersistenceStoreError.deviceNotFound - } - - guard !device.knownRegions.contains(region) else { return } - device.knownRegions.append(region) - try modelContext.save() - } - - /// Removes a known region from a device and resets channels that had been scoped - /// to the removed region back to ``ChannelFloodScope/inherit``. - public func removeDeviceKnownRegion(radioID: UUID, region: String) throws { - let targetRadioID = radioID - let devicePredicate = #Predicate { $0.radioID == targetRadioID } - var deviceDescriptor = FetchDescriptor(predicate: devicePredicate) - deviceDescriptor.fetchLimit = 1 - - guard let device = try modelContext.fetch(deviceDescriptor).first else { - throw PersistenceStoreError.deviceNotFound - } - - device.knownRegions.removeAll { $0 == region } - - let channelPredicate = #Predicate { $0.radioID == targetRadioID } - let channels = try modelContext.fetch(FetchDescriptor(predicate: channelPredicate)) - for channel in channels where channel.floodScope == .region(region) { - channel.floodScope = .inherit - } - - try modelContext.save() - } - - /// Delete all data associated with a device. - /// Deletes: reactions, room messages, remote node sessions, blocked channel senders, - /// RX log entries, discovered nodes, contacts, messages, channels, and saved trace paths. - /// Does NOT delete the Device record itself. - public func deleteDeviceData(id: UUID) throws { - try _deleteAllDeviceData(id: id) - try modelContext.save() - } - - /// Delete a device record only. Does NOT delete associated data. - public func deleteDevice(id: UUID) throws { - let targetID = id - let devicePredicate = #Predicate { device in - device.id == targetID - } - if let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first { - modelContext.delete(device) - } - try modelContext.save() - } - - /// Demotes a device row to a "ghost": preserves `publicKey` and `radioID` so the - /// publicKey ↔ radioID bridge survives, but assigns a fresh `id`, clears `isActive`, - /// and strips ALL connection methods so the row is hidden from - /// `DeviceSelectionSheet`. Used in place of `deleteDevice(id:)` whenever the user - /// removes a device but keeps their data — the next time the same physical radio - /// is re-paired (or its keypair is restored via config import), reconciliation can - /// rejoin the orphaned children to the new pairing. - /// - /// Distinct from `DeviceDTO.cleanedForImport()`, which strips only Bluetooth and - /// keeps WiFi (so a `.mc1backup`-restored radio stays reachable over WiFi without - /// re-pairing). Demote-on-remove is the user saying "make this go away"; preserving - /// any connection method would leave the row showing in `DeviceSelectionSheet` - /// because `DeviceSelectionFilter.isConnectable` matches any WiFi method. - public func demoteDeviceToGhost(id: UUID) throws { - let targetID = id - let predicate = #Predicate { device in - device.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let existing = try modelContext.fetch(descriptor).first else { return } - - let ghostDTO = DeviceDTO(from: existing).copy { - $0.id = UUID() - $0.isActive = false - $0.connectionMethods = [] - } - - modelContext.delete(existing) - let ghost = Device(dto: ghostDTO) - modelContext.insert(ghost) - try modelContext.save() - } - - /// Delete a device and all its associated data atomically (single save). - /// Use for factory reset and explicit "delete all data" user action. - public func deleteDeviceAndData(id: UUID) throws { - try _deleteAllDeviceData(id: id) - - let targetID = id - let devicePredicate = #Predicate { device in - device.id == targetID - } - if let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first { - modelContext.delete(device) - } - try modelContext.save() - } - - /// Looks for a ghost `Device` row whose `publicKey` matches `newPublicKey` - /// (left by a prior remove-from-MC1, or a `.mc1backup` shadow row). If found, - /// rewrites the current device's `radioID` and `publicKey` to the ghost's values - /// and deletes the ghost. All child rows (Contact, Message, Channel, etc.) keyed - /// by the ghost's `radioID` are now linked to the current device for free. - /// - /// The predicate filters by `isActive == false`, but that alone isn't a ghost - /// marker — `setActiveDevice(id:)` deactivates every other Device row, so a - /// saved-but-not-currently-active real device also has `isActive == false`. To - /// avoid ever deleting a real device row (defense against an upstream publicKey-dedup - /// violation), we additionally require the matched row to have **no Bluetooth - /// connection methods**: demoted ghosts strip all methods, `.mc1backup` shadow rows - /// strip BLE via `cleanedForImport()`, and a real saved device retains its BLE - /// `ConnectionMethod`. SwiftData `#Predicate` cannot reliably introspect array - /// elements, so the BLE check happens post-fetch in code. - /// - /// - Parameters: - /// - currentDeviceID: The BLE peripheral UUID of the live, currently-paired device. - /// - newPublicKey: The radio's `selfInfo.publicKey` after `importPrivateKey`. - /// - Returns: The new `radioID` if reconciliation occurred, otherwise `nil`. - public func reconcileGhostIdentity(currentDeviceID: UUID, newPublicKey: Data) throws -> UUID? { - let lookupKey = newPublicKey - let lookupID = currentDeviceID - let ghostPredicate = #Predicate { device in - device.publicKey == lookupKey && device.id != lookupID && device.isActive == false - } - var ghostDescriptor = FetchDescriptor(predicate: ghostPredicate) - ghostDescriptor.fetchLimit = 1 - guard let ghost = try modelContext.fetch(ghostDescriptor).first else { return nil } - - // Defensive: refuse to delete a row that still carries a Bluetooth method. - // It's a real saved-but-inactive device (per `setActiveDevice` semantics), - // not a ghost. Returning nil here is safe — no reconciliation, no data loss. - if ghost.connectionMethods.contains(where: { $0.isBluetooth }) { - return nil - } - - let currentPredicate = #Predicate { device in - device.id == lookupID - } - var currentDescriptor = FetchDescriptor(predicate: currentPredicate) - currentDescriptor.fetchLimit = 1 - guard let current = try modelContext.fetch(currentDescriptor).first else { return nil } - - let newRadioID = ghost.radioID - current.radioID = newRadioID - current.publicKey = newPublicKey - for method in ghost.connectionMethods where !method.isBluetooth { - if !current.connectionMethods.contains(where: { $0.id == method.id }) { - current.connectionMethods.append(method) - } - } - - modelContext.delete(ghost) - try modelContext.save() - return newRadioID - } - - /// Stages deletion of all device-scoped data without calling save(). - /// Used by both `deleteDeviceData` and `deleteDeviceAndData` to compose - /// operations while maintaining single-save atomicity. - private func _deleteAllDeviceData(id: UUID) throws { - // Look up the Device's radioID since child records are keyed by radioID, not BLE UUID - let targetBLEID = id - let devicePredicate = #Predicate { device in - device.id == targetBLEID - } - guard let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first else { - return - } - let targetRadioID = device.radioID - - // Delete reactions (references messages via messageID) - let reactionPredicate = #Predicate { reaction in - reaction.radioID == targetRadioID - } - let reactions = try modelContext.fetch(FetchDescriptor(predicate: reactionPredicate)) - for reaction in reactions { modelContext.delete(reaction) } - - // Delete room messages via their sessions, then clean up NodeStatusSnapshots - let sessionPredicate = #Predicate { session in - session.radioID == targetRadioID - } - let sessions = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)) - - // Collect publicKeys before deleting sessions for NodeStatusSnapshot cleanup - let sessionPublicKeys = sessions.map { $0.publicKey } - - for session in sessions { - let sessionID = session.id - let roomMessagePredicate = #Predicate { message in - message.sessionID == sessionID - } - let roomMessages = try modelContext.fetch(FetchDescriptor(predicate: roomMessagePredicate)) - for roomMessage in roomMessages { modelContext.delete(roomMessage) } - modelContext.delete(session) - } - - // Delete NodeStatusSnapshots only when no other session references that node - for pubKey in sessionPublicKeys { - let remainingPredicate = #Predicate { session in - session.publicKey == pubKey - } - let remainingCount = try modelContext.fetchCount(FetchDescriptor(predicate: remainingPredicate)) - guard remainingCount == 0 else { continue } - - let snapshotPredicate = #Predicate { snapshot in - snapshot.nodePublicKey == pubKey - } - let snapshots = try modelContext.fetch(FetchDescriptor(predicate: snapshotPredicate)) - for snapshot in snapshots { modelContext.delete(snapshot) } - } - - // Delete blocked channel senders - let blockedPredicate = #Predicate { blocked in - blocked.radioID == targetRadioID - } - let blockedSenders = try modelContext.fetch(FetchDescriptor(predicate: blockedPredicate)) - for blocked in blockedSenders { modelContext.delete(blocked) } - - // Delete RX log entries; drop the count cache so the next save re-seeds - // from disk instead of pruning against a stale pre-delete count. - let rxLogPredicate = #Predicate { entry in - entry.radioID == targetRadioID - } - let rxLogEntries = try modelContext.fetch(FetchDescriptor(predicate: rxLogPredicate)) - for entry in rxLogEntries { modelContext.delete(entry) } - rxLogEntryCountsByDevice[targetRadioID] = nil - - // Delete discovered nodes - let discoveredPredicate = #Predicate { node in - node.radioID == targetRadioID - } - let discoveredNodes = try modelContext.fetch(FetchDescriptor(predicate: discoveredPredicate)) - for node in discoveredNodes { modelContext.delete(node) } - - // Delete contacts - let contactPredicate = #Predicate { contact in - contact.radioID == targetRadioID - } - let contacts = try modelContext.fetch(FetchDescriptor(predicate: contactPredicate)) - for contact in contacts { modelContext.delete(contact) } - - // Delete messages — cascade PendingSend and MessageRepeat first. - // Bulk `delete(model:where:)` bypasses `@Relationship(deleteRule: .cascade)`, - // and `purgeOrphanPendingSends` cannot reap radio-scoped PendingSends - // while the Device row survives (deleteDeviceData preserves it). - let messagePredicate = #Predicate { message in - message.radioID == targetRadioID - } - let messages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) - let messageIDs = messages.map(\.id) - try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) - if !messageIDs.isEmpty { - let chunkSize = 500 - for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { - let chunk = Array(messageIDs[start.. { row in - row.radioID == targetRadioID +public extension PersistenceStore { + // MARK: - Device Operations + + /// Fetch all devices + func fetchDevices() throws -> [DeviceDTO] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\Device.lastConnected, order: .reverse)] + ) + let devices = try modelContext.fetch(descriptor) + return devices.map { DeviceDTO(from: $0) } + } + + /// Fetch a device by ID + func fetchDevice(id: UUID) throws -> DeviceDTO? { + let targetID = id + let predicate = #Predicate { device in + device.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } + } + + /// Fetch a device by radio ID + func fetchDevice(radioID: UUID) throws -> DeviceDTO? { + let targetRadioID = radioID + let predicate = #Predicate { device in + device.radioID == targetRadioID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } + } + + /// Fetch a device by public key + func fetchDevice(publicKey: Data) throws -> DeviceDTO? { + let targetKey = publicKey + let predicate = #Predicate { device in + device.publicKey == targetKey + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } + } + + /// Fetch the active device + func fetchActiveDevice() throws -> DeviceDTO? { + let predicate = #Predicate { device in + device.isActive == true + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { DeviceDTO(from: $0) } + } + + /// Save or update a device + func saveDevice(_ dto: DeviceDTO) throws { + let targetID = dto.id + let predicate = #Predicate { device in + device.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let existing = try modelContext.fetch(descriptor).first { + existing.apply(dto) + } else { + modelContext.insert(Device(dto: dto)) + } + + try modelContext.save() + } + + /// Set a device as active (deactivates others) + func setActiveDevice(id: UUID) throws { + // Deactivate all devices first + let allDevices = try modelContext.fetch(FetchDescriptor()) + for device in allDevices { + device.isActive = false + } + + // Activate the specified device + let targetID = id + let predicate = #Predicate { device in + device.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let device = try modelContext.fetch(descriptor).first { + device.isActive = true + device.lastConnected = Date() + } + + try modelContext.save() + } + + /// Update the lastContactSync timestamp for a device. + /// Used to track incremental sync progress. + func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) throws { + let targetRadioID = radioID + let predicate = #Predicate { device in + device.radioID == targetRadioID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let device = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.deviceNotFound + } + device.lastContactSync = timestamp + try modelContext.save() + } + + /// Adds a known region to a device if not already present + func addDeviceKnownRegion(radioID: UUID, region: String) throws { + let targetRadioID = radioID + let devicePredicate = #Predicate { $0.radioID == targetRadioID } + var deviceDescriptor = FetchDescriptor(predicate: devicePredicate) + deviceDescriptor.fetchLimit = 1 + + guard let device = try modelContext.fetch(deviceDescriptor).first else { + throw PersistenceStoreError.deviceNotFound + } + + guard !device.knownRegions.contains(region) else { return } + device.knownRegions.append(region) + try modelContext.save() + } + + /// Removes a known region from a device and resets channels that had been scoped + /// to the removed region back to ``ChannelFloodScope/inherit``. + func removeDeviceKnownRegion(radioID: UUID, region: String) throws { + let targetRadioID = radioID + let devicePredicate = #Predicate { $0.radioID == targetRadioID } + var deviceDescriptor = FetchDescriptor(predicate: devicePredicate) + deviceDescriptor.fetchLimit = 1 + + guard let device = try modelContext.fetch(deviceDescriptor).first else { + throw PersistenceStoreError.deviceNotFound + } + + device.knownRegions.removeAll { $0 == region } + + let channelPredicate = #Predicate { $0.radioID == targetRadioID } + let channels = try modelContext.fetch(FetchDescriptor(predicate: channelPredicate)) + for channel in channels where channel.floodScope == .region(region) { + channel.floodScope = .inherit + } + + try modelContext.save() + } + + /// Delete all data associated with a device. + /// Deletes: reactions, room messages, remote node sessions, blocked channel senders, + /// RX log entries, discovered nodes, contacts, messages, channels, and saved trace paths. + /// Does NOT delete the Device record itself. + func deleteDeviceData(id: UUID) throws { + try _deleteAllDeviceData(id: id) + try modelContext.save() + } + + /// Delete a device record only. Does NOT delete associated data. + func deleteDevice(id: UUID) throws { + let targetID = id + let devicePredicate = #Predicate { device in + device.id == targetID + } + if let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first { + modelContext.delete(device) + } + try modelContext.save() + } + + /// Demotes a device row to a "ghost": preserves `publicKey` and `radioID` so the + /// publicKey ↔ radioID bridge survives, but assigns a fresh `id`, clears `isActive`, + /// and strips ALL connection methods so the row is hidden from + /// `DeviceSelectionSheet`. Used in place of `deleteDevice(id:)` whenever the user + /// removes a device but keeps their data — the next time the same physical radio + /// is re-paired (or its keypair is restored via config import), reconciliation can + /// rejoin the orphaned children to the new pairing. + /// + /// Distinct from `DeviceDTO.cleanedForImport()`, which strips only Bluetooth and + /// keeps WiFi (so a `.mc1backup`-restored radio stays reachable over WiFi without + /// re-pairing). Demote-on-remove is the user saying "make this go away"; preserving + /// any connection method would leave the row showing in `DeviceSelectionSheet` + /// because `DeviceSelectionFilter.isConnectable` matches any WiFi method. + func demoteDeviceToGhost(id: UUID) throws { + let targetID = id + let predicate = #Predicate { device in + device.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let existing = try modelContext.fetch(descriptor).first else { return } + + let ghostDTO = DeviceDTO(from: existing).copy { + $0.id = UUID() + $0.isActive = false + $0.connectionMethods = [] + } + + modelContext.delete(existing) + let ghost = Device(dto: ghostDTO) + modelContext.insert(ghost) + try modelContext.save() + } + + /// Delete a device and all its associated data atomically (single save). + /// Use for factory reset and explicit "delete all data" user action. + func deleteDeviceAndData(id: UUID) throws { + try _deleteAllDeviceData(id: id) + + let targetID = id + let devicePredicate = #Predicate { device in + device.id == targetID + } + if let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first { + modelContext.delete(device) + } + try modelContext.save() + } + + /// Looks for a ghost `Device` row whose `publicKey` matches `newPublicKey` + /// (left by a prior remove-from-MC1, or a `.mc1backup` shadow row). If found, + /// rewrites the current device's `radioID` and `publicKey` to the ghost's values + /// and deletes the ghost. All child rows (Contact, Message, Channel, etc.) keyed + /// by the ghost's `radioID` are now linked to the current device for free. + /// + /// The predicate filters by `isActive == false`, but that alone isn't a ghost + /// marker — `setActiveDevice(id:)` deactivates every other Device row, so a + /// saved-but-not-currently-active real device also has `isActive == false`. To + /// avoid ever deleting a real device row (defense against an upstream publicKey-dedup + /// violation), we additionally require the matched row to have **no Bluetooth + /// connection methods**: demoted ghosts strip all methods, `.mc1backup` shadow rows + /// strip BLE via `cleanedForImport()`, and a real saved device retains its BLE + /// `ConnectionMethod`. SwiftData `#Predicate` cannot reliably introspect array + /// elements, so the BLE check happens post-fetch in code. + /// + /// - Parameters: + /// - currentDeviceID: The BLE peripheral UUID of the live, currently-paired device. + /// - newPublicKey: The radio's `selfInfo.publicKey` after `importPrivateKey`. + /// - Returns: The new `radioID` if reconciliation occurred, otherwise `nil`. + func reconcileGhostIdentity(currentDeviceID: UUID, newPublicKey: Data) throws -> UUID? { + let lookupKey = newPublicKey + let lookupID = currentDeviceID + let ghostPredicate = #Predicate { device in + device.publicKey == lookupKey && device.id != lookupID && device.isActive == false + } + var ghostDescriptor = FetchDescriptor(predicate: ghostPredicate) + ghostDescriptor.fetchLimit = 1 + guard let ghost = try modelContext.fetch(ghostDescriptor).first else { return nil } + + // Defensive: refuse to delete a row that still carries a Bluetooth method. + // It's a real saved-but-inactive device (per `setActiveDevice` semantics), + // not a ghost. Returning nil here is safe — no reconciliation, no data loss. + if ghost.connectionMethods.contains(where: \.isBluetooth) { + return nil + } + + let currentPredicate = #Predicate { device in + device.id == lookupID + } + var currentDescriptor = FetchDescriptor(predicate: currentPredicate) + currentDescriptor.fetchLimit = 1 + guard let current = try modelContext.fetch(currentDescriptor).first else { return nil } + + let newRadioID = ghost.radioID + current.radioID = newRadioID + current.publicKey = newPublicKey + for method in ghost.connectionMethods where !method.isBluetooth { + if !current.connectionMethods.contains(where: { $0.id == method.id }) { + current.connectionMethods.append(method) + } + } + + modelContext.delete(ghost) + try modelContext.save() + return newRadioID + } + + /// Stages deletion of all device-scoped data without calling save(). + /// Used by both `deleteDeviceData` and `deleteDeviceAndData` to compose + /// operations while maintaining single-save atomicity. + private func _deleteAllDeviceData(id: UUID) throws { + // Look up the Device's radioID since child records are keyed by radioID, not BLE UUID + let targetBLEID = id + let devicePredicate = #Predicate { device in + device.id == targetBLEID + } + guard let device = try modelContext.fetch(FetchDescriptor(predicate: devicePredicate)).first else { + return + } + let targetRadioID = device.radioID + + // Delete reactions (references messages via messageID) + let reactionPredicate = #Predicate { reaction in + reaction.radioID == targetRadioID + } + let reactions = try modelContext.fetch(FetchDescriptor(predicate: reactionPredicate)) + for reaction in reactions { + modelContext.delete(reaction) + } + + // Delete room messages via their sessions, then clean up NodeStatusSnapshots + let sessionPredicate = #Predicate { session in + session.radioID == targetRadioID + } + let sessions = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)) + + // Collect publicKeys before deleting sessions for NodeStatusSnapshot cleanup + let sessionPublicKeys = sessions.map(\.publicKey) + + for session in sessions { + let sessionID = session.id + let roomMessagePredicate = #Predicate { message in + message.sessionID == sessionID + } + let roomMessages = try modelContext.fetch(FetchDescriptor(predicate: roomMessagePredicate)) + for roomMessage in roomMessages { + modelContext.delete(roomMessage) + } + modelContext.delete(session) + } + + // Delete NodeStatusSnapshots only when no other session references that node + for pubKey in sessionPublicKeys { + let remainingPredicate = #Predicate { session in + session.publicKey == pubKey + } + let remainingCount = try modelContext.fetchCount(FetchDescriptor(predicate: remainingPredicate)) + guard remainingCount == 0 else { continue } + + let snapshotPredicate = #Predicate { snapshot in + snapshot.nodePublicKey == pubKey + } + let snapshots = try modelContext.fetch(FetchDescriptor(predicate: snapshotPredicate)) + for snapshot in snapshots { + modelContext.delete(snapshot) + } + } + + // Delete blocked channel senders + let blockedPredicate = #Predicate { blocked in + blocked.radioID == targetRadioID + } + let blockedSenders = try modelContext.fetch(FetchDescriptor(predicate: blockedPredicate)) + for blocked in blockedSenders { + modelContext.delete(blocked) + } + + // Delete RX log entries; drop the count cache so the next save re-seeds + // from disk instead of pruning against a stale pre-delete count. + let rxLogPredicate = #Predicate { entry in + entry.radioID == targetRadioID + } + let rxLogEntries = try modelContext.fetch(FetchDescriptor(predicate: rxLogPredicate)) + for entry in rxLogEntries { + modelContext.delete(entry) + } + rxLogEntryCountsByDevice[targetRadioID] = nil + + // Delete discovered nodes + let discoveredPredicate = #Predicate { node in + node.radioID == targetRadioID + } + let discoveredNodes = try modelContext.fetch(FetchDescriptor(predicate: discoveredPredicate)) + for node in discoveredNodes { + modelContext.delete(node) + } + + // Delete contacts + let contactPredicate = #Predicate { contact in + contact.radioID == targetRadioID + } + let contacts = try modelContext.fetch(FetchDescriptor(predicate: contactPredicate)) + for contact in contacts { + modelContext.delete(contact) + } + + // Delete messages — cascade PendingSend and MessageRepeat first. + // Bulk `delete(model:where:)` bypasses `@Relationship(deleteRule: .cascade)`, + // and `purgeOrphanPendingSends` cannot reap radio-scoped PendingSends + // while the Device row survives (deleteDeviceData preserves it). + let messagePredicate = #Predicate { message in + message.radioID == targetRadioID + } + let messages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) + let messageIDs = messages.map(\.id) + try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) + if !messageIDs.isEmpty { + let chunkSize = 500 + for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { + let chunk = Array(messageIDs[start.. { channel in - channel.radioID == targetRadioID - } - let channels = try modelContext.fetch(FetchDescriptor(predicate: channelPredicate)) - for channel in channels { modelContext.delete(channel) } - - // Delete saved trace paths - let pathPredicate = #Predicate { path in - path.radioID == targetRadioID - } - let paths = try modelContext.fetch(FetchDescriptor(predicate: pathPredicate)) - for path in paths { modelContext.delete(path) } + } + } + // Defensive: reap PendingSend rows for this radio that have no matching + // Message (e.g. from a same-millisecond race between deleteMessage and + // upsertPendingSend, or historical bugs). The messageIDs cascade above + // does not see them; purgeOrphanPendingSends does not see them while + // the Device row survives. + try modelContext.delete(model: PendingSend.self, where: #Predicate { row in + row.radioID == targetRadioID + }) + // Delete Message rows with a store-level batch, not per-object: a per-object delete + // enters cascade propagation over Message.repeats and traps resolving the + // already-batch-deleted MessageRepeat rows. + try modelContext.delete(model: Message.self, where: messagePredicate) + + // Delete channels + let channelPredicate = #Predicate { channel in + channel.radioID == targetRadioID + } + let channels = try modelContext.fetch(FetchDescriptor(predicate: channelPredicate)) + for channel in channels { + modelContext.delete(channel) + } + + // Delete saved trace paths + let pathPredicate = #Predicate { path in + path.radioID == targetRadioID + } + let paths = try modelContext.fetch(FetchDescriptor(predicate: pathPredicate)) + for path in paths { + modelContext.delete(path) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift index 35739007..9363b831 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift @@ -2,669 +2,667 @@ import Foundation import MeshCore import SwiftData -extension PersistenceStore { - - // MARK: - Saved Trace Path Operations - - public func fetchSavedTracePaths(radioID: UUID) throws -> [SavedTracePathDTO] { - let targetRadioID = radioID - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.radioID == targetRadioID }, - sortBy: [SortDescriptor(\.createdDate, order: .reverse)] - ) - let paths = try modelContext.fetch(descriptor) - return paths.map { SavedTracePathDTO(from: $0) } - } - - public func fetchSavedTracePath(id: UUID) throws -> SavedTracePathDTO? { - let targetID = id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let path = try modelContext.fetch(descriptor).first else { return nil } - return SavedTracePathDTO(from: path) - } - - public func createSavedTracePath( - radioID: UUID, - name: String, - pathBytes: Data, - hashSize: Int = 1, - initialRun: TracePathRunDTO? - ) throws -> SavedTracePathDTO { - let path = SavedTracePath( - radioID: radioID, - name: name, - pathBytes: pathBytes, - hashSize: hashSize - ) - - if let runDTO = initialRun { - let run = try TracePathRun(dto: runDTO) - run.savedPath = path - path.runs.append(run) - modelContext.insert(run) - } - - modelContext.insert(path) - try modelContext.save() - return SavedTracePathDTO(from: path) - } - - public func updateSavedTracePathName(id: UUID, name: String) throws { - let targetID = id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let path = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.fetchFailed("SavedTracePath not found") - } - path.name = name - try modelContext.save() - } - - public func deleteSavedTracePath(id: UUID) throws { - let targetID = id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let path = try modelContext.fetch(descriptor).first else { return } - modelContext.delete(path) - try modelContext.save() - } - - public func appendTracePathRun(pathID: UUID, run runDTO: TracePathRunDTO) throws { - let targetID = pathID - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let path = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.fetchFailed("SavedTracePath not found") - } - - let run = try TracePathRun(dto: runDTO) - run.savedPath = path - path.runs.append(run) - modelContext.insert(run) - try modelContext.save() - } - - // MARK: - RxLogEntry - - /// Save a new RX log entry. - public func saveRxLogEntry(_ dto: RxLogEntryDTO) throws { - // Seed the count cache from disk before the insert; incrementing a - // missing entry from zero would undercount rows persisted by earlier - // sessions and suppress pruning indefinitely. - let countBeforeInsert = try cachedRxLogEntryCount(radioID: dto.radioID) - let entry = RxLogEntry( - id: dto.id, - radioID: dto.radioID, - receivedAt: dto.receivedAt, - snr: dto.snr, - rssi: dto.rssi, - routeType: Int(dto.routeType.rawValue), - payloadType: Int(dto.payloadType.rawValue), - payloadVersion: Int(dto.payloadVersion), - transportCode: dto.transportCode, - pathLength: Int(dto.pathLength), - pathNodes: dto.pathNodes, - packetPayload: dto.packetPayload, - rawPayload: dto.rawPayload, - packetHash: dto.packetHash, - channelIndex: dto.channelIndex.map { Int($0) }, - channelName: dto.channelName, - decryptStatus: dto.decryptStatus.rawValue, - fromContactName: dto.fromContactName, - toContactName: dto.toContactName, - senderTimestamp: dto.senderTimestamp.map { Int($0) }, - regionScope: dto.regionScope, - payloadTypeBits: Int(dto.payloadTypeBits) - ) - modelContext.insert(entry) - try modelContext.save() - rxLogEntryCountsByDevice[dto.radioID] = countBeforeInsert + 1 - } - - /// Fetch RX log entries for a device, most recent first. - public func fetchRxLogEntries(radioID: UUID, limit: Int = 500) throws -> [RxLogEntryDTO] { - let targetRadioID = radioID - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.radioID == targetRadioID }, - sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] - ) - descriptor.fetchLimit = limit - let entries = try modelContext.fetch(descriptor) - return entries.map { RxLogEntryDTO(from: $0) } - } - - /// Count RX log entries for a device. - public func countRxLogEntries(radioID: UUID) throws -> Int { - let targetRadioID = radioID - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.radioID == targetRadioID } - ) - return try modelContext.fetchCount(descriptor) - } - - /// Delete oldest entries once the log materially exceeds the retention cap. - /// - /// This avoids repeated count/fetch/delete maintenance on every RX packet while keeping - /// retention bounded to `keepCount + pruneThreshold` entries between prune passes. - public func pruneRxLogEntries( - radioID: UUID, - keepCount: Int = 1000, - pruneThreshold: Int = 100 - ) throws { - let count = try cachedRxLogEntryCount(radioID: radioID) - guard count > keepCount + pruneThreshold else { return } - - let deleteCount = count - keepCount - let targetRadioID = radioID - - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.radioID == targetRadioID }, - sortBy: [SortDescriptor(\.receivedAt, order: .forward)] // Oldest first - ) - descriptor.fetchLimit = deleteCount - - let toDelete = try modelContext.fetch(descriptor) - for entry in toDelete { - modelContext.delete(entry) - } - try modelContext.save() - rxLogEntryCountsByDevice[radioID] = keepCount - } - - /// Clear all RX log entries for a device. - public func clearRxLogEntries(radioID: UUID) throws { - let targetRadioID = radioID - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.radioID == targetRadioID } - ) - let entries = try modelContext.fetch(descriptor) - for entry in entries { - modelContext.delete(entry) - } - try modelContext.save() - rxLogEntryCountsByDevice[radioID] = 0 - } - - private func cachedRxLogEntryCount(radioID: UUID) throws -> Int { - if let cached = rxLogEntryCountsByDevice[radioID] { - return cached - } - - let count = try countRxLogEntries(radioID: radioID) - rxLogEntryCountsByDevice[radioID] = count - return count - } - - /// Find RxLogEntry matching an incoming message for path correlation. - /// - /// For channel messages: Correlates by channel index and sender timestamp. - /// For direct messages: Correlates by sender timestamp and payload type. - public func findRxLogEntry( - radioID: UUID, - channelIndex: UInt8?, - senderTimestamp: UInt32 - ) throws -> RxLogEntryDTO? { - let targetRadioID = radioID - let targetTimestamp = Int(senderTimestamp) - - if let channelIndex { - // Channel message: match on channelIndex and senderTimestamp - let channelIndexInt = Int(channelIndex) - - let predicate = #Predicate { entry in - entry.radioID == targetRadioID && - entry.channelIndex == channelIndexInt && - entry.senderTimestamp == targetTimestamp - } - - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] - - let results = try modelContext.fetch(descriptor) - return results.first.map { RxLogEntryDTO(from: $0) } - } else { - // Direct message: match on senderTimestamp - let textMessageType = Int(PayloadType.textMessage.rawValue) - - let predicate = #Predicate { entry in - entry.radioID == targetRadioID && - entry.senderTimestamp == targetTimestamp && - entry.channelIndex == nil && - entry.payloadType == textMessageType - } - - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] - - let results = try modelContext.fetch(descriptor) - return results.first.map { RxLogEntryDTO(from: $0) } - } - } - - /// Find a DM RxLogEntry by matching the sender prefix byte in the packet payload. - /// - /// Fallback for when the primary `findRxLogEntry(senderTimestamp:)` fails because - /// DM decryption hadn't succeeded yet (senderTimestamp was nil). Matches on the - /// unencrypted srcHash byte at `packetPayload[1]` and a receive-time window. - public func findRxLogEntryBySenderPrefix( - radioID: UUID, - senderPrefixByte: UInt8, - receivedSince: Date - ) throws -> RxLogEntryDTO? { - let targetRadioID = radioID - let textMessageType = Int(PayloadType.textMessage.rawValue) - let cutoff = receivedSince - - let predicate = #Predicate { entry in - entry.radioID == targetRadioID && - entry.channelIndex == nil && - entry.payloadType == textMessageType && - entry.receivedAt >= cutoff - } - - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] - descriptor.fetchLimit = 20 - - let candidates = try modelContext.fetch(descriptor) - - // Filter in-memory: match sender prefix byte at packetPayload[1] - let match = candidates.first { entry in - entry.packetPayload.count >= 2 && entry.packetPayload[1] == senderPrefixByte - } - - return match.map { RxLogEntryDTO(from: $0) } - } - - /// Fetch recent RX log entries with a given decrypt status. - public func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) throws -> [RxLogEntryDTO] { - let targetRadioID = radioID - let targetStatus = status.rawValue - let cutoff = since - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.radioID == targetRadioID && - $0.decryptStatus == targetStatus && - $0.receivedAt >= cutoff - }, - sortBy: [SortDescriptor(\.receivedAt, order: .forward)] - ) - let entries = try modelContext.fetch(descriptor) - return entries.map { RxLogEntryDTO(from: $0) } - } - - /// Batch update RX log entries after successful decryption. - /// Note: decodedText is @Transient and not persisted. - public func batchUpdateRxLogDecryption( - _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] - ) throws { - for update in updates { - let targetID = update.id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let entry = try modelContext.fetch(descriptor).first else { continue } - - entry.channelIndex = update.channelIndex.map { Int($0) } - entry.channelName = update.channelName - entry.decryptStatus = DecryptStatus.success.rawValue - entry.senderTimestamp = update.senderTimestamp.map { Int($0) } - } - try modelContext.save() - } - - /// Fetch RX log entries that have a transport code but no resolved - /// region yet — the back-fill candidate set. - public func fetchEntriesWithMissingRegion(radioID: UUID) throws -> [RxLogEntryDTO] { - let targetRadioID = radioID - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.radioID == targetRadioID && - $0.transportCode != nil && - $0.regionScope == nil - }, - sortBy: [SortDescriptor(\.receivedAt, order: .forward)] - ) - let entries = try modelContext.fetch(descriptor) - return entries.map { RxLogEntryDTO(from: $0) } - } - - /// Batch update `regionScope` on RX log entries by id. - public func batchUpdateRxLogRegion( - updates: [(id: UUID, regionScope: String?)] - ) throws { - for update in updates { - let targetID = update.id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == targetID } - ) - guard let entry = try modelContext.fetch(descriptor).first else { continue } - entry.regionScope = update.regionScope - } - try modelContext.save() - } - - /// Batch update `regionScope` on incoming **channel** `Message` rows - /// correlated by `(channelIndex, senderTimestamp)`. The wire timestamp - /// fallback is required because `Message.senderTimestamp` is only - /// populated for the rare timestamp-corrected case; the normal case puts - /// the wire timestamp on `Message.timestamp`. - public func batchUpdateChannelMessageRegion( - radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) throws { - let targetRadioID = radioID - let incoming = MessageDirection.incoming.rawValue - for update in updates { - let targetIndex: UInt8? = update.channelIndex - let targetSenderTimestamp: UInt32? = update.senderTimestamp - let targetWireTimestamp: UInt32 = update.senderTimestamp - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.radioID == targetRadioID && - $0.channelIndex == targetIndex && - $0.directionRawValue == incoming && - ($0.senderTimestamp == targetSenderTimestamp || - ($0.senderTimestamp == nil && $0.timestamp == targetWireTimestamp)) - } - ) - for message in try modelContext.fetch(descriptor) { - message.regionScope = update.regionScope - } - } - try modelContext.save() - } - - /// Batch update `regionScope` on incoming **DM** `Message` rows. DMs - /// carry the sender prefix byte at `RxLogEntry.packetPayload[1]` but - /// the Message side stores the full multi-byte `senderKeyPrefix`, so - /// the predicate fetches by timestamp + DM channel and an in-memory - /// pass disambiguates by first-byte equality. Mirrors the correlation - /// key used by `findRxLogEntryBySenderPrefix`. - public func batchUpdateDMMessageRegion( - radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) throws { - let targetRadioID = radioID - let nilChannel: UInt8? = nil - let incoming = MessageDirection.incoming.rawValue - for update in updates { - let targetSenderTimestamp: UInt32? = update.senderTimestamp - let targetWireTimestamp: UInt32 = update.senderTimestamp - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.radioID == targetRadioID && - $0.channelIndex == nilChannel && - $0.directionRawValue == incoming && - ($0.senderTimestamp == targetSenderTimestamp || - ($0.senderTimestamp == nil && $0.timestamp == targetWireTimestamp)) - } - ) - let prefixByte = update.senderPrefixByte - let candidates = try modelContext.fetch(descriptor) - for message in candidates where message.senderKeyPrefix?.first == prefixByte { - message.regionScope = update.regionScope - } - } - try modelContext.save() - } - - // MARK: - Debug Log Entries - - /// Saves a batch of debug log entries. - public func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) throws { - for dto in dtos { - let entry = DebugLogEntry( - id: dto.id, - timestamp: dto.timestamp, - level: dto.level.rawValue, - subsystem: dto.subsystem, - category: dto.category, - message: dto.message - ) - modelContext.insert(entry) +public extension PersistenceStore { + // MARK: - Saved Trace Path Operations + + func fetchSavedTracePaths(radioID: UUID) throws -> [SavedTracePathDTO] { + let targetRadioID = radioID + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.radioID == targetRadioID }, + sortBy: [SortDescriptor(\.createdDate, order: .reverse)] + ) + let paths = try modelContext.fetch(descriptor) + return paths.map { SavedTracePathDTO(from: $0) } + } + + func fetchSavedTracePath(id: UUID) throws -> SavedTracePathDTO? { + let targetID = id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let path = try modelContext.fetch(descriptor).first else { return nil } + return SavedTracePathDTO(from: path) + } + + func createSavedTracePath( + radioID: UUID, + name: String, + pathBytes: Data, + hashSize: Int = 1, + initialRun: TracePathRunDTO? + ) throws -> SavedTracePathDTO { + let path = SavedTracePath( + radioID: radioID, + name: name, + pathBytes: pathBytes, + hashSize: hashSize + ) + + if let runDTO = initialRun { + let run = try TracePathRun(dto: runDTO) + run.savedPath = path + path.runs.append(run) + modelContext.insert(run) + } + + modelContext.insert(path) + try modelContext.save() + return SavedTracePathDTO(from: path) + } + + func updateSavedTracePathName(id: UUID, name: String) throws { + let targetID = id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let path = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.fetchFailed("SavedTracePath not found") + } + path.name = name + try modelContext.save() + } + + func deleteSavedTracePath(id: UUID) throws { + let targetID = id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let path = try modelContext.fetch(descriptor).first else { return } + modelContext.delete(path) + try modelContext.save() + } + + func appendTracePathRun(pathID: UUID, run runDTO: TracePathRunDTO) throws { + let targetID = pathID + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let path = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.fetchFailed("SavedTracePath not found") + } + + let run = try TracePathRun(dto: runDTO) + run.savedPath = path + path.runs.append(run) + modelContext.insert(run) + try modelContext.save() + } + + // MARK: - RxLogEntry + + /// Save a new RX log entry. + func saveRxLogEntry(_ dto: RxLogEntryDTO) throws { + // Seed the count cache from disk before the insert; incrementing a + // missing entry from zero would undercount rows persisted by earlier + // sessions and suppress pruning indefinitely. + let countBeforeInsert = try cachedRxLogEntryCount(radioID: dto.radioID) + let entry = RxLogEntry( + id: dto.id, + radioID: dto.radioID, + receivedAt: dto.receivedAt, + snr: dto.snr, + rssi: dto.rssi, + routeType: Int(dto.routeType.rawValue), + payloadType: Int(dto.payloadType.rawValue), + payloadVersion: Int(dto.payloadVersion), + transportCode: dto.transportCode, + pathLength: Int(dto.pathLength), + pathNodes: dto.pathNodes, + packetPayload: dto.packetPayload, + rawPayload: dto.rawPayload, + packetHash: dto.packetHash, + channelIndex: dto.channelIndex.map { Int($0) }, + channelName: dto.channelName, + decryptStatus: dto.decryptStatus.rawValue, + fromContactName: dto.fromContactName, + toContactName: dto.toContactName, + senderTimestamp: dto.senderTimestamp.map { Int($0) }, + regionScope: dto.regionScope, + payloadTypeBits: Int(dto.payloadTypeBits) + ) + modelContext.insert(entry) + try modelContext.save() + rxLogEntryCountsByDevice[dto.radioID] = countBeforeInsert + 1 + } + + /// Fetch RX log entries for a device, most recent first. + func fetchRxLogEntries(radioID: UUID, limit: Int = 500) throws -> [RxLogEntryDTO] { + let targetRadioID = radioID + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.radioID == targetRadioID }, + sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] + ) + descriptor.fetchLimit = limit + let entries = try modelContext.fetch(descriptor) + return entries.map { RxLogEntryDTO(from: $0) } + } + + /// Count RX log entries for a device. + func countRxLogEntries(radioID: UUID) throws -> Int { + let targetRadioID = radioID + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.radioID == targetRadioID } + ) + return try modelContext.fetchCount(descriptor) + } + + /// Delete oldest entries once the log materially exceeds the retention cap. + /// + /// This avoids repeated count/fetch/delete maintenance on every RX packet while keeping + /// retention bounded to `keepCount + pruneThreshold` entries between prune passes. + func pruneRxLogEntries( + radioID: UUID, + keepCount: Int = 1000, + pruneThreshold: Int = 100 + ) throws { + let count = try cachedRxLogEntryCount(radioID: radioID) + guard count > keepCount + pruneThreshold else { return } + + let deleteCount = count - keepCount + let targetRadioID = radioID + + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.radioID == targetRadioID }, + sortBy: [SortDescriptor(\.receivedAt, order: .forward)] // Oldest first + ) + descriptor.fetchLimit = deleteCount + + let toDelete = try modelContext.fetch(descriptor) + for entry in toDelete { + modelContext.delete(entry) + } + try modelContext.save() + rxLogEntryCountsByDevice[radioID] = keepCount + } + + /// Clear all RX log entries for a device. + func clearRxLogEntries(radioID: UUID) throws { + let targetRadioID = radioID + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.radioID == targetRadioID } + ) + let entries = try modelContext.fetch(descriptor) + for entry in entries { + modelContext.delete(entry) + } + try modelContext.save() + rxLogEntryCountsByDevice[radioID] = 0 + } + + private func cachedRxLogEntryCount(radioID: UUID) throws -> Int { + if let cached = rxLogEntryCountsByDevice[radioID] { + return cached + } + + let count = try countRxLogEntries(radioID: radioID) + rxLogEntryCountsByDevice[radioID] = count + return count + } + + /// Find RxLogEntry matching an incoming message for path correlation. + /// + /// For channel messages: Correlates by channel index and sender timestamp. + /// For direct messages: Correlates by sender timestamp and payload type. + func findRxLogEntry( + radioID: UUID, + channelIndex: UInt8?, + senderTimestamp: UInt32 + ) throws -> RxLogEntryDTO? { + let targetRadioID = radioID + let targetTimestamp = Int(senderTimestamp) + + if let channelIndex { + // Channel message: match on channelIndex and senderTimestamp + let channelIndexInt = Int(channelIndex) + + let predicate = #Predicate { entry in + entry.radioID == targetRadioID && + entry.channelIndex == channelIndexInt && + entry.senderTimestamp == targetTimestamp + } + + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] + + let results = try modelContext.fetch(descriptor) + return results.first.map { RxLogEntryDTO(from: $0) } + } else { + // Direct message: match on senderTimestamp + let textMessageType = Int(PayloadType.textMessage.rawValue) + + let predicate = #Predicate { entry in + entry.radioID == targetRadioID && + entry.senderTimestamp == targetTimestamp && + entry.channelIndex == nil && + entry.payloadType == textMessageType + } + + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] + + let results = try modelContext.fetch(descriptor) + return results.first.map { RxLogEntryDTO(from: $0) } + } + } + + /// Find a DM RxLogEntry by matching the sender prefix byte in the packet payload. + /// + /// Fallback for when the primary `findRxLogEntry(senderTimestamp:)` fails because + /// DM decryption hadn't succeeded yet (senderTimestamp was nil). Matches on the + /// unencrypted srcHash byte at `packetPayload[1]` and a receive-time window. + func findRxLogEntryBySenderPrefix( + radioID: UUID, + senderPrefixByte: UInt8, + receivedSince: Date + ) throws -> RxLogEntryDTO? { + let targetRadioID = radioID + let textMessageType = Int(PayloadType.textMessage.rawValue) + let cutoff = receivedSince + + let predicate = #Predicate { entry in + entry.radioID == targetRadioID && + entry.channelIndex == nil && + entry.payloadType == textMessageType && + entry.receivedAt >= cutoff + } + + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.sortBy = [SortDescriptor(\.receivedAt, order: .reverse)] + descriptor.fetchLimit = 20 + + let candidates = try modelContext.fetch(descriptor) + + // Filter in-memory: match sender prefix byte at packetPayload[1] + let match = candidates.first { entry in + entry.packetPayload.count >= 2 && entry.packetPayload[1] == senderPrefixByte + } + + return match.map { RxLogEntryDTO(from: $0) } + } + + /// Fetch recent RX log entries with a given decrypt status. + func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) throws -> [RxLogEntryDTO] { + let targetRadioID = radioID + let targetStatus = status.rawValue + let cutoff = since + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.radioID == targetRadioID && + $0.decryptStatus == targetStatus && + $0.receivedAt >= cutoff + }, + sortBy: [SortDescriptor(\.receivedAt, order: .forward)] + ) + let entries = try modelContext.fetch(descriptor) + return entries.map { RxLogEntryDTO(from: $0) } + } + + /// Batch update RX log entries after successful decryption. + /// Note: decodedText is @Transient and not persisted. + func batchUpdateRxLogDecryption( + _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] + ) throws { + for update in updates { + let targetID = update.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let entry = try modelContext.fetch(descriptor).first else { continue } + + entry.channelIndex = update.channelIndex.map { Int($0) } + entry.channelName = update.channelName + entry.decryptStatus = DecryptStatus.success.rawValue + entry.senderTimestamp = update.senderTimestamp.map { Int($0) } + } + try modelContext.save() + } + + /// Fetch RX log entries that have a transport code but no resolved + /// region yet — the back-fill candidate set. + func fetchEntriesWithMissingRegion(radioID: UUID) throws -> [RxLogEntryDTO] { + let targetRadioID = radioID + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.radioID == targetRadioID && + $0.transportCode != nil && + $0.regionScope == nil + }, + sortBy: [SortDescriptor(\.receivedAt, order: .forward)] + ) + let entries = try modelContext.fetch(descriptor) + return entries.map { RxLogEntryDTO(from: $0) } + } + + /// Batch update `regionScope` on RX log entries by id. + func batchUpdateRxLogRegion( + updates: [(id: UUID, regionScope: String?)] + ) throws { + for update in updates { + let targetID = update.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let entry = try modelContext.fetch(descriptor).first else { continue } + entry.regionScope = update.regionScope + } + try modelContext.save() + } + + /// Batch update `regionScope` on incoming **channel** `Message` rows + /// correlated by `(channelIndex, senderTimestamp)`. The wire timestamp + /// fallback is required because `Message.senderTimestamp` is only + /// populated for the rare timestamp-corrected case; the normal case puts + /// the wire timestamp on `Message.timestamp`. + func batchUpdateChannelMessageRegion( + radioID: UUID, + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) throws { + let targetRadioID = radioID + let incoming = MessageDirection.incoming.rawValue + for update in updates { + let targetIndex: UInt8? = update.channelIndex + let targetSenderTimestamp: UInt32? = update.senderTimestamp + let targetWireTimestamp: UInt32 = update.senderTimestamp + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.radioID == targetRadioID && + $0.channelIndex == targetIndex && + $0.directionRawValue == incoming && + ($0.senderTimestamp == targetSenderTimestamp || + ($0.senderTimestamp == nil && $0.timestamp == targetWireTimestamp)) } - try modelContext.save() - } - - /// Fetches debug log entries since a given date. - public func fetchDebugLogEntries(since date: Date, limit: Int = 1000) throws -> [DebugLogEntryDTO] { - let startDate = date - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.timestamp >= startDate }, - sortBy: [SortDescriptor(\.timestamp, order: .reverse)] - ) - descriptor.fetchLimit = limit - let entries = try modelContext.fetch(descriptor) - return entries.map { DebugLogEntryDTO(from: $0) } - } - - /// Counts all debug log entries. - public func countDebugLogEntries() throws -> Int { - let descriptor = FetchDescriptor() - return try modelContext.fetchCount(descriptor) - } - - /// Prunes debug log entries, keeping only the most recent entries. - public func pruneDebugLogEntries(keepCount: Int = 1000) throws { - let count = try countDebugLogEntries() - guard count > keepCount else { return } - - let deleteCount = count - keepCount - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.timestamp, order: .forward)] - ) - descriptor.fetchLimit = deleteCount - - let toDelete = try modelContext.fetch(descriptor) - for entry in toDelete { - modelContext.delete(entry) + ) + for message in try modelContext.fetch(descriptor) { + message.regionScope = update.regionScope + } + } + try modelContext.save() + } + + /// Batch update `regionScope` on incoming **DM** `Message` rows. DMs + /// carry the sender prefix byte at `RxLogEntry.packetPayload[1]` but + /// the Message side stores the full multi-byte `senderKeyPrefix`, so + /// the predicate fetches by timestamp + DM channel and an in-memory + /// pass disambiguates by first-byte equality. Mirrors the correlation + /// key used by `findRxLogEntryBySenderPrefix`. + func batchUpdateDMMessageRegion( + radioID: UUID, + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) throws { + let targetRadioID = radioID + let nilChannel: UInt8? = nil + let incoming = MessageDirection.incoming.rawValue + for update in updates { + let targetSenderTimestamp: UInt32? = update.senderTimestamp + let targetWireTimestamp: UInt32 = update.senderTimestamp + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.radioID == targetRadioID && + $0.channelIndex == nilChannel && + $0.directionRawValue == incoming && + ($0.senderTimestamp == targetSenderTimestamp || + ($0.senderTimestamp == nil && $0.timestamp == targetWireTimestamp)) } - try modelContext.save() - } - - /// Clears all debug log entries. - public func clearDebugLogEntries() throws { - try modelContext.delete(model: DebugLogEntry.self) - try modelContext.save() - } - - // MARK: - Node Status Snapshots - - public func saveNodeStatusSnapshot( - nodePublicKey: Data, - batteryMillivolts: UInt16?, - lastSNR: Double?, - lastRSSI: Int16?, - noiseFloor: Int16?, - uptimeSeconds: UInt32?, - rxAirtimeSeconds: UInt32?, - packetsSent: UInt32?, - packetsReceived: UInt32?, - receiveErrors: UInt32?, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil - ) throws -> UUID { - try saveNodeStatusSnapshot( - timestamp: .now, - nodePublicKey: nodePublicKey, - batteryMillivolts: batteryMillivolts, - lastSNR: lastSNR, - lastRSSI: lastRSSI, - noiseFloor: noiseFloor, - uptimeSeconds: uptimeSeconds, - rxAirtimeSeconds: rxAirtimeSeconds, - packetsSent: packetsSent, - packetsReceived: packetsReceived, - receiveErrors: receiveErrors, - postedCount: postedCount, - postPushCount: postPushCount - ) - } - - // Overload that accepts an explicit timestamp, used by tests to avoid timing-dependent sleeps. - // swiftlint:disable:next function_parameter_count - public func saveNodeStatusSnapshot( - timestamp: Date, - nodePublicKey: Data, - batteryMillivolts: UInt16?, - lastSNR: Double?, - lastRSSI: Int16?, - noiseFloor: Int16?, - uptimeSeconds: UInt32?, - rxAirtimeSeconds: UInt32?, - packetsSent: UInt32?, - packetsReceived: UInt32?, - receiveErrors: UInt32?, - postedCount: UInt16? = nil, - postPushCount: UInt16? = nil - ) throws -> UUID { - let snapshot = NodeStatusSnapshot( - timestamp: timestamp, - nodePublicKey: nodePublicKey, - batteryMillivolts: batteryMillivolts, - lastSNR: lastSNR, - lastRSSI: lastRSSI, - noiseFloor: noiseFloor, - uptimeSeconds: uptimeSeconds, - rxAirtimeSeconds: rxAirtimeSeconds, - packetsSent: packetsSent, - packetsReceived: packetsReceived, - receiveErrors: receiveErrors, - postedCount: postedCount, - postPushCount: postPushCount - ) - modelContext.insert(snapshot) - try modelContext.save() - return snapshot.id - } - - public func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) throws -> NodeStatusSnapshotDTO? { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, - sortBy: [SortDescriptor(\.timestamp, order: .reverse)] - ) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map(NodeStatusSnapshotDTO.init) - } - - public func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) throws -> [NodeStatusSnapshotDTO] { - let descriptor: FetchDescriptor - if let since { - descriptor = FetchDescriptor( - predicate: #Predicate { $0.nodePublicKey == nodePublicKey && $0.timestamp >= since }, - sortBy: [SortDescriptor(\.timestamp)] - ) - } else { - descriptor = FetchDescriptor( - predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, - sortBy: [SortDescriptor(\.timestamp)] - ) - } - return try modelContext.fetch(descriptor).map(NodeStatusSnapshotDTO.init) - } - - public func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) throws { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - guard let snapshot = try modelContext.fetch(descriptor).first else { return } - snapshot.neighborSnapshots = neighbors - try modelContext.save() - } - - public func saveTelemetryOnlySnapshot( - nodePublicKey: Data, - telemetryEntries: [TelemetrySnapshotEntry] - ) throws -> UUID { - let snapshot = NodeStatusSnapshot( - nodePublicKey: nodePublicKey, - telemetryEntries: telemetryEntries - ) - modelContext.insert(snapshot) - try modelContext.save() - return snapshot.id - } - - public func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) throws { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - guard let snapshot = try modelContext.fetch(descriptor).first else { return } - snapshot.telemetryEntries = telemetry - try modelContext.save() - } - - /// Atomically capture a status, telemetry, and/or neighbor snapshot for a node. - /// - /// The fetch-latest decision and the insert-or-enrich both run in this single - /// `@ModelActor` method body with no `await`, so two concurrent captures - /// serialize and the second observes the first's row — there is no window for a - /// duplicate in-window insert. Keeping the body suspension-free is the whole - /// point; an `await` here would reopen that race. Mirrors the same-isolation - /// guarantee `insertPendingSendAssigningSequence` relies on. - /// - /// Within `NodeSnapshotPolicy.minimumInterval` of the latest snapshot the - /// capture enriches that row: status fields are applied only when the row is - /// still telemetry-only (`uptimeSeconds == nil`), preserving the - /// one-status-point-per-window throttle; telemetry and neighbor arrays are - /// applied whenever supplied. Outside the window a new snapshot is inserted. - public func recordNodeStatusSnapshot( - nodePublicKey: Data, - status: NodeStatusMetrics?, - telemetry: [TelemetrySnapshotEntry]?, - neighbors: [NeighborSnapshotEntry]? - ) throws -> UUID { - var latestDescriptor = FetchDescriptor( - predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, - sortBy: [SortDescriptor(\.timestamp, order: .reverse)] - ) - latestDescriptor.fetchLimit = 1 - - if let latest = try modelContext.fetch(latestDescriptor).first, - latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { - if let status, latest.uptimeSeconds == nil { - latest.apply(status) - } - if let telemetry { - latest.telemetryEntries = telemetry - } - if let neighbors { - latest.neighborSnapshots = neighbors - } - try modelContext.save() - return latest.id - } - - let snapshot = NodeStatusSnapshot( - nodePublicKey: nodePublicKey, - telemetryEntries: telemetry - ) - if let status { - snapshot.apply(status) - } - if let neighbors { - snapshot.neighborSnapshots = neighbors - } - modelContext.insert(snapshot) - try modelContext.save() - return snapshot.id - } - - public func deleteOldNodeStatusSnapshots(olderThan date: Date) throws { - try modelContext.delete( - model: NodeStatusSnapshot.self, - where: #Predicate { $0.timestamp < date } - ) - try modelContext.save() - } + ) + let prefixByte = update.senderPrefixByte + let candidates = try modelContext.fetch(descriptor) + for message in candidates where message.senderKeyPrefix?.first == prefixByte { + message.regionScope = update.regionScope + } + } + try modelContext.save() + } + + // MARK: - Debug Log Entries + + /// Saves a batch of debug log entries. + func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) throws { + for dto in dtos { + let entry = DebugLogEntry( + id: dto.id, + timestamp: dto.timestamp, + level: dto.level.rawValue, + subsystem: dto.subsystem, + category: dto.category, + message: dto.message + ) + modelContext.insert(entry) + } + try modelContext.save() + } + + /// Fetches debug log entries since a given date. + func fetchDebugLogEntries(since date: Date, limit: Int = 1000) throws -> [DebugLogEntryDTO] { + let startDate = date + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.timestamp >= startDate }, + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + descriptor.fetchLimit = limit + let entries = try modelContext.fetch(descriptor) + return entries.map { DebugLogEntryDTO(from: $0) } + } + + /// Counts all debug log entries. + func countDebugLogEntries() throws -> Int { + let descriptor = FetchDescriptor() + return try modelContext.fetchCount(descriptor) + } + + /// Prunes debug log entries, keeping only the most recent entries. + func pruneDebugLogEntries(keepCount: Int = 1000) throws { + let count = try countDebugLogEntries() + guard count > keepCount else { return } + + let deleteCount = count - keepCount + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.timestamp, order: .forward)] + ) + descriptor.fetchLimit = deleteCount + + let toDelete = try modelContext.fetch(descriptor) + for entry in toDelete { + modelContext.delete(entry) + } + try modelContext.save() + } + + /// Clears all debug log entries. + func clearDebugLogEntries() throws { + try modelContext.delete(model: DebugLogEntry.self) + try modelContext.save() + } + + // MARK: - Node Status Snapshots + + func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) throws -> UUID { + try saveNodeStatusSnapshot( + timestamp: .now, + nodePublicKey: nodePublicKey, + batteryMillivolts: batteryMillivolts, + lastSNR: lastSNR, + lastRSSI: lastRSSI, + noiseFloor: noiseFloor, + uptimeSeconds: uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: packetsSent, + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + } + + // Overload that accepts an explicit timestamp, used by tests to avoid timing-dependent sleeps. + // swiftlint:disable:next function_parameter_count + func saveNodeStatusSnapshot( + timestamp: Date, + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) throws -> UUID { + let snapshot = NodeStatusSnapshot( + timestamp: timestamp, + nodePublicKey: nodePublicKey, + batteryMillivolts: batteryMillivolts, + lastSNR: lastSNR, + lastRSSI: lastRSSI, + noiseFloor: noiseFloor, + uptimeSeconds: uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: packetsSent, + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + modelContext.insert(snapshot) + try modelContext.save() + return snapshot.id + } + + func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) throws -> NodeStatusSnapshotDTO? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map(NodeStatusSnapshotDTO.init) + } + + func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) throws -> [NodeStatusSnapshotDTO] { + let descriptor = if let since { + FetchDescriptor( + predicate: #Predicate { $0.nodePublicKey == nodePublicKey && $0.timestamp >= since }, + sortBy: [SortDescriptor(\.timestamp)] + ) + } else { + FetchDescriptor( + predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, + sortBy: [SortDescriptor(\.timestamp)] + ) + } + return try modelContext.fetch(descriptor).map(NodeStatusSnapshotDTO.init) + } + + func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) throws { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + guard let snapshot = try modelContext.fetch(descriptor).first else { return } + snapshot.neighborSnapshots = neighbors + try modelContext.save() + } + + func saveTelemetryOnlySnapshot( + nodePublicKey: Data, + telemetryEntries: [TelemetrySnapshotEntry] + ) throws -> UUID { + let snapshot = NodeStatusSnapshot( + nodePublicKey: nodePublicKey, + telemetryEntries: telemetryEntries + ) + modelContext.insert(snapshot) + try modelContext.save() + return snapshot.id + } + + func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) throws { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + guard let snapshot = try modelContext.fetch(descriptor).first else { return } + snapshot.telemetryEntries = telemetry + try modelContext.save() + } + + /// Atomically capture a status, telemetry, and/or neighbor snapshot for a node. + /// + /// The fetch-latest decision and the insert-or-enrich both run in this single + /// `@ModelActor` method body with no `await`, so two concurrent captures + /// serialize and the second observes the first's row — there is no window for a + /// duplicate in-window insert. Keeping the body suspension-free is the whole + /// point; an `await` here would reopen that race. Mirrors the same-isolation + /// guarantee `insertPendingSendAssigningSequence` relies on. + /// + /// Within `NodeSnapshotPolicy.minimumInterval` of the latest snapshot the + /// capture enriches that row: status fields are applied only when the row is + /// still telemetry-only (`uptimeSeconds == nil`), preserving the + /// one-status-point-per-window throttle; telemetry and neighbor arrays are + /// applied whenever supplied. Outside the window a new snapshot is inserted. + func recordNodeStatusSnapshot( + nodePublicKey: Data, + status: NodeStatusMetrics?, + telemetry: [TelemetrySnapshotEntry]?, + neighbors: [NeighborSnapshotEntry]? + ) throws -> UUID { + var latestDescriptor = FetchDescriptor( + predicate: #Predicate { $0.nodePublicKey == nodePublicKey }, + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + latestDescriptor.fetchLimit = 1 + + if let latest = try modelContext.fetch(latestDescriptor).first, + latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { + if let status, latest.uptimeSeconds == nil { + latest.apply(status) + } + if let telemetry { + latest.telemetryEntries = telemetry + } + if let neighbors { + latest.neighborSnapshots = neighbors + } + try modelContext.save() + return latest.id + } + + let snapshot = NodeStatusSnapshot( + nodePublicKey: nodePublicKey, + telemetryEntries: telemetry + ) + if let status { + snapshot.apply(status) + } + if let neighbors { + snapshot.neighborSnapshots = neighbors + } + modelContext.insert(snapshot) + try modelContext.save() + return snapshot.id + } + + func deleteOldNodeStatusSnapshots(olderThan date: Date) throws { + try modelContext.delete( + model: NodeStatusSnapshot.self, + where: #Predicate { $0.timestamp < date } + ) + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift index 9743a127..2dbc6269 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift @@ -2,773 +2,772 @@ import Foundation import os import SwiftData -extension PersistenceStore { - - // MARK: - Mention Tracking - - public func markMentionSeen(messageID: UUID) throws { - let targetID = messageID - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first else { return } - message.mentionSeen = true - try modelContext.save() - } - - // MARK: - Message Operations - - /// Batch fetch last messages for multiple contacts in a single actor-isolated call. - /// Runs N fetches with zero suspension points between them, avoiding N actor hops. - public func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { - var result: [UUID: [MessageDTO]] = [:] - result.reserveCapacity(contactIDs.count) - for contactID in contactIDs { - result[contactID] = try fetchMessages(contactID: contactID, limit: limit) - } - return result - } - - /// Batch fetch last messages for multiple channels in a single actor-isolated call. - /// Runs N fetches with zero suspension points between them, avoiding N actor hops. - public func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { - var result: [UUID: [MessageDTO]] = [:] - result.reserveCapacity(channels.count) - for channel in channels { - result[channel.id] = try fetchMessages(radioID: channel.radioID, channelIndex: channel.channelIndex, limit: limit) - } - return result - } - - /// Fetch messages for a contact - public func fetchMessages(contactID: UUID, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { - let targetContactID: UUID? = contactID - let predicate = #Predicate { message in - message.contactID == targetContactID - } - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\Message.sortDate, order: .reverse), - SortDescriptor(\Message.timestamp, order: .reverse), - SortDescriptor(\Message.createdAt, order: .reverse) - ] - ) - descriptor.fetchLimit = limit - descriptor.fetchOffset = offset - - let messages = try modelContext.fetch(descriptor) - let dtos = messages.reversed().map { MessageDTO(from: $0) } - return MessageDTO.reorderSameSenderClusters(dtos) - } - - /// Fetch messages for a channel - public func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { - let targetRadioID = radioID - let targetChannelIndex: UInt8? = channelIndex - let predicate = #Predicate { message in - message.radioID == targetRadioID && message.channelIndex == targetChannelIndex - } - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\Message.sortDate, order: .reverse), - SortDescriptor(\Message.timestamp, order: .reverse), - SortDescriptor(\Message.createdAt, order: .reverse) - ] - ) - descriptor.fetchLimit = limit - descriptor.fetchOffset = offset - - let messages = try modelContext.fetch(descriptor) - let dtos = messages.reversed().map { MessageDTO(from: $0) } - return MessageDTO.reorderSameSenderClusters(dtos) - } - - /// Finds a channel message matching a parsed reaction within a timestamp window. - public func findChannelMessageForReaction( - radioID: UUID, - channelIndex: UInt8, - parsedReaction: ParsedReaction, - localNodeName: String?, - timestampWindow: ClosedRange, - limit: Int - ) throws -> MessageDTO? { - let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") - // swiftlint:disable:next line_length - logger.debug("[REACTION-MATCH] Looking for message: targetSender=\(parsedReaction.targetSender), hash=\(parsedReaction.messageHash), localNodeName=\(localNodeName ?? "nil"), window=\(timestampWindow.lowerBound)...\(timestampWindow.upperBound)") - - let candidates = try fetchChannelMessageCandidates( - radioID: radioID, - channelIndex: channelIndex, - timestampWindow: timestampWindow, - limit: limit - ) - logger.debug("[REACTION-MATCH] Found \(candidates.count) candidates in window") - guard !candidates.isEmpty else { return nil } - - for candidate in candidates { - let direction = candidate.direction == .outgoing ? "outgoing" : "incoming" - let candidateHash = ReactionParser.generateMessageHash(text: candidate.text, timestamp: candidate.reactionTimestamp) - logger.debug("[REACTION-MATCH] Candidate: direction=\(direction), senderNodeName=\(candidate.senderNodeName ?? "nil"), hash=\(candidateHash), text=\(candidate.text.prefix(30))") - - if candidate.direction == .outgoing { - guard let localNodeName, parsedReaction.targetSender == localNodeName else { - logger.debug("[REACTION-MATCH] Skip outgoing: localNodeName=\(localNodeName ?? "nil"), targetSender=\(parsedReaction.targetSender)") - continue - } - } else { - guard candidate.senderNodeName == parsedReaction.targetSender else { - logger.debug("[REACTION-MATCH] Skip incoming: senderNodeName=\(candidate.senderNodeName ?? "nil") != targetSender=\(parsedReaction.targetSender)") - continue - } - } - - guard candidateHash == parsedReaction.messageHash else { - logger.debug("[REACTION-MATCH] Hash mismatch: \(candidateHash) != \(parsedReaction.messageHash)") - continue - } - - logger.debug("[REACTION-MATCH] Found match!") - return candidate - } - - logger.debug("[REACTION-MATCH] No match found") - return nil - } - - /// Fetches channel message candidates within a timestamp window for meshcore-open reaction matching. - /// - /// Returns raw candidates without hash matching — the caller performs Dart hash comparison. - public func fetchChannelMessageCandidates( - radioID: UUID, - channelIndex: UInt8, - timestampWindow: ClosedRange, - limit: Int - ) throws -> [MessageDTO] { - let targetRadioID = radioID - let targetChannelIndex: UInt8? = channelIndex - let start = timestampWindow.lowerBound - let end = timestampWindow.upperBound - - let predicate = #Predicate { message in - message.radioID == targetRadioID && - message.channelIndex == targetChannelIndex && - message.timestamp >= start && - message.timestamp <= end - } - - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\Message.createdAt, order: .reverse), - SortDescriptor(\Message.timestamp, order: .reverse) - ] - ) - descriptor.fetchLimit = limit - - return try modelContext.fetch(descriptor).map { MessageDTO(from: $0) } - } - - /// Fetches DM message candidates within a timestamp window for meshcore-open reaction matching. - /// - /// Returns raw candidates without hash matching — the caller performs Dart hash comparison. - public func fetchDMMessageCandidates( - radioID: UUID, - contactID: UUID, - timestampWindow: ClosedRange, - limit: Int - ) throws -> [MessageDTO] { - let targetRadioID = radioID - let targetContactID: UUID? = contactID - let start = timestampWindow.lowerBound - let end = timestampWindow.upperBound - - let predicate = #Predicate { message in - message.radioID == targetRadioID && - message.contactID == targetContactID && - message.timestamp >= start && - message.timestamp <= end - } - - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\Message.createdAt, order: .reverse), - SortDescriptor(\Message.timestamp, order: .reverse) - ] - ) - descriptor.fetchLimit = limit - - return try modelContext.fetch(descriptor).map { MessageDTO(from: $0) } - } - - /// Finds a DM message matching a reaction by hash within a timestamp window. - public func findDMMessageForReaction( - radioID: UUID, - contactID: UUID, - messageHash: String, - timestampWindow: ClosedRange, - limit: Int - ) throws -> MessageDTO? { - let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") - logger.debug("[DM-REACTION-MATCH] Looking for DM: hash=\(messageHash), contactID=\(contactID)") - - let candidates = try fetchDMMessageCandidates( - radioID: radioID, - contactID: contactID, - timestampWindow: timestampWindow, - limit: limit - ) - logger.debug("[DM-REACTION-MATCH] Found \(candidates.count) candidates") - - for candidate in candidates { - // Skip messages that are themselves reactions - if ReactionParser.isReactionText(candidate.text, isDM: true) { - logger.debug("[DM-REACTION-MATCH] Skipping candidate (is reaction): \(candidate.text.prefix(30))") - continue - } - - let direction = candidate.direction == .outgoing ? "outgoing" : "incoming" - let candidateHash = ReactionParser.generateMessageHash( - text: candidate.text, - timestamp: candidate.reactionTimestamp - ) - logger.debug("[DM-REACTION-MATCH] Candidate: direction=\(direction), timestamp=\(candidate.timestamp), senderTimestamp=\(candidate.senderTimestamp ?? 0), hash=\(candidateHash), text=\(candidate.text.prefix(30))") - if candidateHash == messageHash { - logger.debug("[DM-REACTION-MATCH] Found match: \(candidate.id)") - return candidate - } else { - logger.debug("[DM-REACTION-MATCH] Hash mismatch: \(candidateHash) != \(messageHash)") - } - } - - return nil - } - - /// Fetch a message by ID - public func fetchMessage(id: UUID) throws -> MessageDTO? { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { MessageDTO(from: $0) } - } - - /// Check if a message with this deduplication key already exists for the given radio. - public func isDuplicateMessage(deduplicationKey: String, radioID: UUID) throws -> Bool { - let targetKey = deduplicationKey - let targetRadioID = radioID - let predicate = #Predicate { - $0.deduplicationKey == targetKey && $0.radioID == targetRadioID - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 - } - - /// Save a new message - public func saveMessage(_ dto: MessageDTO) throws { - modelContext.insert(Message(dto: dto)) - try modelContext.save() - } - - /// Update message status - public func updateMessageStatus(id: UUID, status: MessageStatus) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - message.status = status - try modelContext.save() - } - } - - /// Update message status unless delivery has already won the race. - /// - /// - Returns: `true` if the row's status was changed, `false` if no row was - /// updated (either the row is already `.delivered`, or no row exists for - /// the given `id`). Callers must gate failure side effects (e.g., the - /// `MessageStatusEvent.failed` broadcast, UI toasts) on the return value - /// so they do not surface a `.failed` event for a delivered or absent row. - public func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) throws -> Bool { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first, message.status != .delivered else { - return false - } - message.status = status - try modelContext.save() - return true - } - - /// Clears a retry-loop status (`.retrying`/`.pending`) back to `.sent` when - /// the app-layer retry budget is spent but the end-to-end ACK may still - /// arrive within `ackGiveUpWindow`. - /// - /// Terminal-safe: no-ops and returns `false` on a `.delivered` or `.failed` - /// row, so it can never resurrect a row the expiry checker already failed or - /// downgrade a delivered row. Returns `true` when the row moved to `.sent`. - public func clearRetryingToSent(id: UUID) throws -> Bool { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first, - message.status != .delivered, - message.status != .failed else { - return false - } - message.status = .sent - try modelContext.save() - return true - } - - /// Update message status with retry attempt information. - /// - /// Skips the write on a terminal row (`.delivered` or `.failed`) so a stale - /// retry iteration cannot clobber a winning ACK, nor resurrect a row the - /// expiry checker already failed in the loop's `waitForEvent` await-gap. - /// This matches the terminal-safety of `clearRetryingToSent` and - /// `updateMessageAck`. - public func updateMessageRetryStatus( - id: UUID, - status: MessageStatus, - retryAttempt: Int, - maxRetryAttempts: Int - ) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first, - message.status != .delivered, - message.status != .failed { - message.status = status - message.retryAttempt = retryAttempt - message.maxRetryAttempts = maxRetryAttempts - try modelContext.save() - } - } - - /// Update message timestamp (for resending) - public func updateMessageTimestamp(id: UUID, timestamp: UInt32) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - message.timestamp = timestamp - try modelContext.save() - } - } - - /// Update message ACK info. - /// - /// Both `.delivered` and `.failed` are terminal for this write: once the - /// listener (or `finalizeSend`) writes `.delivered` + `roundTripTime`, a - /// later `.sent` write from the send-return path is skipped so the - /// authoritative delivery state is preserved; and once the expiry checker - /// writes `.failed`, a late ACK landing in the checker's await-gap cannot - /// flip the row to `.delivered`. The only late transition this write allows - /// is the legitimate `.sent` -> `.delivered` upgrade. - public func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32? = nil) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - if (message.status == .delivered || message.status == .failed) && status != message.status { return } - message.ackCode = ackCode - message.status = status - message.roundTripTime = roundTripTime - try modelContext.save() - } - } - - /// Read-only diagnostic: whether an outgoing DM row persists `.sent` with - /// this `ackCode`. - public func hasOutgoingSentDM(ackCode: UInt32) throws -> Bool { - let target: UInt32? = ackCode - let sentRaw = MessageStatus.sent.rawValue - let outgoingRaw = MessageDirection.outgoing.rawValue - let predicate = #Predicate { message in - message.ackCode == target && - message.statusRawValue == sentRaw && - message.directionRawValue == outgoingRaw && - message.channelIndex == nil - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 - } - - /// Mark a message as read - public func markMessageAsRead(id: UUID) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - message.isRead = true - try modelContext.save() - } - } - - /// Updates the heard repeats count for a message - public func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - message.heardRepeats = heardRepeats - try modelContext.save() - } - } - - /// Update link preview data for a message - public func updateMessageLinkPreview( - id: UUID, - url: String?, - title: String?, - imageData: Data?, - iconData: Data?, - fetched: Bool - ) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let message = try modelContext.fetch(descriptor).first { - message.linkPreviewURL = url - message.linkPreviewTitle = title - message.linkPreviewImageData = imageData - message.linkPreviewIconData = iconData - message.linkPreviewFetched = fetched - try modelContext.save() - } - } - - /// Delete a message and its reactions - public func deleteMessage(id: UUID) throws { - let targetID = id - - // Cascade PendingSend outside the `if let message` guard. An orphan - // PendingSend (no matching Message row) can exist via several paths: - // the queue's success-path `deletePendingSends` racing a deleteMessage - // from another path; same-millisecond user delete + queue upsert; - // historical bugs; tests. Reaping the PendingSend unconditionally on - // deleteMessage is strictly stronger than gating on the Message - // existing — there is no Message left to be "consistent" with, and - // orphan PendingSends otherwise survive until the next purge cycle. - let pendingPredicate = #Predicate { row in - row.messageID == targetID - } - for row in try modelContext.fetch(FetchDescriptor(predicate: pendingPredicate)) { - modelContext.delete(row) - } - - let predicate = #Predicate { message in - message.id == targetID - } - if let message = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { - // Inlined Reaction cascade — only meaningful when the Message - // exists. Reactions are anchored to the Message row, so an - // orphan-Message reaction is its own cleanup problem. - try modelContext.delete(model: Reaction.self, where: #Predicate { reaction in - reaction.messageID == targetID - }) - modelContext.delete(message) - } - try modelContext.save() - } - - /// Delete all channel messages from a specific sender for a device. - /// Only deletes messages with a non-nil channelIndex (channel messages), preserving DMs. - /// Cascades PendingSend, MessageRepeat, and Reaction rows associated with the deleted - /// messages within a single save. - public func deleteChannelMessages(fromSender senderName: String, radioID: UUID) throws { - let targetRadioID = radioID - let targetSenderName: String? = senderName - let messagePredicate = #Predicate { message in - message.radioID == targetRadioID && - message.senderNodeName == targetSenderName && - message.channelIndex != nil - } - - let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) - - if !messageIDs.isEmpty { - try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) - // Cascade MessageRepeat alongside Reaction. Bulk `delete(model:where:)` - // bypasses the `@Relationship(deleteRule: .cascade)` declared on - // `Message → MessageRepeat`, so the cascade has to be explicit. - // Chunk both predicates to stay under SQLITE_MAX_VARIABLE_NUMBER - // (32766 on iOS 18+). - let chunkSize = 500 - for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { - let chunk = Array(messageIDs[start.. Int { - let targetRadioID = radioID - let pendingStatus = MessageStatus.pending.rawValue - let sendingStatus = MessageStatus.sending.rawValue - let predicate = #Predicate { message in - message.radioID == targetRadioID && - (message.statusRawValue == pendingStatus || - message.statusRawValue == sendingStatus) - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) - } - - // MARK: - Heard Repeats - - /// Finds a sent channel message matching the given criteria within a time window. - /// Used for correlating RX log entries to sent messages. - /// - /// - Parameters: - /// - radioID: The radio that sent the message - /// - channelIndex: Channel the message was sent on - /// - timestamp: Sender timestamp from the message - /// - text: Message text to match - /// - withinSeconds: Time window to search (default 10 seconds) - /// - Returns: MessageDTO if found, nil otherwise - public func findSentChannelMessage( - radioID: UUID, - channelIndex: UInt8, - timestamp: UInt32, - text: String, - withinSeconds: Int = 10 - ) throws -> MessageDTO? { - let targetRadioID = radioID - let targetChannelIndex: UInt8? = channelIndex - let targetTimestamp = timestamp - let outgoingDirection = MessageDirection.outgoing.rawValue - - // Calculate time window - let now = Date() - let windowStart = now.addingTimeInterval(-TimeInterval(withinSeconds)) - let windowStartTimestamp = UInt32(windowStart.timeIntervalSince1970) - - let predicate = #Predicate { message in - message.radioID == targetRadioID && - message.channelIndex == targetChannelIndex && - message.timestamp == targetTimestamp && - message.directionRawValue == outgoingDirection && - message.timestamp >= windowStartTimestamp - } - - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first else { - return nil - } - - // Verify text matches (outgoing channel messages store just the text) - guard message.text == text else { - return nil - } - - return MessageDTO(from: message) - } - - /// Saves a new MessageRepeat entry and links it to the parent message. - public func saveMessageRepeat(_ dto: MessageRepeatDTO) throws { - // Fetch the parent message for relationship - let targetMessageID = dto.messageID - let messagePredicate = #Predicate { message in - message.id == targetMessageID - } - var messageDescriptor = FetchDescriptor(predicate: messagePredicate) - messageDescriptor.fetchLimit = 1 - - guard let parentMessage = try modelContext.fetch(messageDescriptor).first else { - throw PersistenceStoreError.messageNotFound - } - - modelContext.insert(MessageRepeat(dto: dto, message: parentMessage)) - try modelContext.save() - } - - /// Fetches all repeats for a given message, sorted by receivedAt ascending. - public func fetchMessageRepeats(messageID: UUID) throws -> [MessageRepeatDTO] { - let targetMessageID = messageID - let predicate = #Predicate { repeat_ in - repeat_.messageID == targetMessageID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\MessageRepeat.receivedAt, order: .forward)] - ) - - let results = try modelContext.fetch(descriptor) - return results.map { MessageRepeatDTO(from: $0) } - } - - /// Deletes all repeats for a given message. - public func deleteMessageRepeats(messageID: UUID) throws { - let targetMessageID = messageID - let predicate = #Predicate { repeat_ in - repeat_.messageID == targetMessageID - } - let descriptor = FetchDescriptor(predicate: predicate) - - let results = try modelContext.fetch(descriptor) - for repeat_ in results { - modelContext.delete(repeat_) - } - try modelContext.save() - } - - /// Checks if a repeat already exists for the given RX log entry. - public func messageRepeatExists(rxLogEntryID: UUID) throws -> Bool { - let targetID: UUID? = rxLogEntryID - let predicate = #Predicate { repeat_ in - repeat_.rxLogEntryID == targetID - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 - } - - /// Increments the heardRepeats count for a message and returns the new count. - public func incrementMessageHeardRepeats(id: UUID) throws -> Int { - let targetID = id - let predicate = #Predicate { message in message.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first else { - return 0 - } - - message.heardRepeats += 1 - try modelContext.save() - return message.heardRepeats - } - - /// Increments the sendCount for a message and returns the new count. - public func incrementMessageSendCount(id: UUID) throws -> Int { - let targetID = id - let predicate = #Predicate { message in message.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first else { - return 0 - } - - message.sendCount += 1 - try modelContext.save() - return message.sendCount - } - - // MARK: - Reactions - - /// Saves a new reaction - public func saveReaction(_ dto: ReactionDTO) throws { - let reaction = Reaction( - id: dto.id, - messageID: dto.messageID, - emoji: dto.emoji, - senderName: dto.senderName, - messageHash: dto.messageHash, - rawText: dto.rawText, - receivedAt: dto.receivedAt, - channelIndex: dto.channelIndex, - contactID: dto.contactID, - radioID: dto.radioID - ) - modelContext.insert(reaction) - try modelContext.save() - } - - /// Fetches reactions for a message - public func fetchReactions(for messageID: UUID, limit: Int = 100) throws -> [ReactionDTO] { - let targetMessageID = messageID - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.messageID == targetMessageID }, - sortBy: [SortDescriptor(\Reaction.receivedAt, order: .reverse)] - ) - descriptor.fetchLimit = limit - return try modelContext.fetch(descriptor).map { ReactionDTO(from: $0) } - } - - /// Checks if a reaction already exists (deduplication) - public func reactionExists(messageID: UUID, senderName: String, emoji: String) throws -> Bool { - let targetMessageID = messageID - let targetSenderName = senderName - let targetEmoji = emoji - let predicate = #Predicate { - $0.messageID == targetMessageID && - $0.senderName == targetSenderName && - $0.emoji == targetEmoji - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 - } - - /// Updates a message's reaction summary cache - public func updateMessageReactionSummary(messageID: UUID, summary: String?) throws { - let targetMessageID = messageID - let predicate = #Predicate { $0.id == targetMessageID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let message = try modelContext.fetch(descriptor).first else { return } - message.reactionSummary = summary - try modelContext.save() - } - - /// Deletes all reactions for a message - public func deleteReactionsForMessage(messageID: UUID) throws { - let targetMessageID = messageID +public extension PersistenceStore { + // MARK: - Mention Tracking + + func markMentionSeen(messageID: UUID) throws { + let targetID = messageID + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first else { return } + message.mentionSeen = true + try modelContext.save() + } + + // MARK: - Message Operations + + /// Batch fetch last messages for multiple contacts in a single actor-isolated call. + /// Runs N fetches with zero suspension points between them, avoiding N actor hops. + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + var result: [UUID: [MessageDTO]] = [:] + result.reserveCapacity(contactIDs.count) + for contactID in contactIDs { + result[contactID] = try fetchMessages(contactID: contactID, limit: limit) + } + return result + } + + /// Batch fetch last messages for multiple channels in a single actor-isolated call. + /// Runs N fetches with zero suspension points between them, avoiding N actor hops. + func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + var result: [UUID: [MessageDTO]] = [:] + result.reserveCapacity(channels.count) + for channel in channels { + result[channel.id] = try fetchMessages(radioID: channel.radioID, channelIndex: channel.channelIndex, limit: limit) + } + return result + } + + /// Fetch messages for a contact + func fetchMessages(contactID: UUID, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { + let targetContactID: UUID? = contactID + let predicate = #Predicate { message in + message.contactID == targetContactID + } + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.sortDate, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse), + SortDescriptor(\Message.createdAt, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + descriptor.fetchOffset = offset + + let messages = try modelContext.fetch(descriptor) + let dtos = messages.reversed().map { MessageDTO(from: $0) } + return MessageDTO.reorderSameSenderClusters(dtos) + } + + /// Fetch messages for a channel + func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { + let targetRadioID = radioID + let targetChannelIndex: UInt8? = channelIndex + let predicate = #Predicate { message in + message.radioID == targetRadioID && message.channelIndex == targetChannelIndex + } + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.sortDate, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse), + SortDescriptor(\Message.createdAt, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + descriptor.fetchOffset = offset + + let messages = try modelContext.fetch(descriptor) + let dtos = messages.reversed().map { MessageDTO(from: $0) } + return MessageDTO.reorderSameSenderClusters(dtos) + } + + /// Finds a channel message matching a parsed reaction within a timestamp window. + func findChannelMessageForReaction( + radioID: UUID, + channelIndex: UInt8, + parsedReaction: ParsedReaction, + localNodeName: String?, + timestampWindow: ClosedRange, + limit: Int + ) throws -> MessageDTO? { + let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") + // swiftlint:disable:next line_length + logger.debug("[REACTION-MATCH] Looking for message: targetSender=\(parsedReaction.targetSender), hash=\(parsedReaction.messageHash), localNodeName=\(localNodeName ?? "nil"), window=\(timestampWindow.lowerBound)...\(timestampWindow.upperBound)") + + let candidates = try fetchChannelMessageCandidates( + radioID: radioID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: limit + ) + logger.debug("[REACTION-MATCH] Found \(candidates.count) candidates in window") + guard !candidates.isEmpty else { return nil } + + for candidate in candidates { + let direction = candidate.direction == .outgoing ? "outgoing" : "incoming" + let candidateHash = ReactionParser.generateMessageHash(text: candidate.text, timestamp: candidate.reactionTimestamp) + logger.debug("[REACTION-MATCH] Candidate: direction=\(direction), senderNodeName=\(candidate.senderNodeName ?? "nil"), hash=\(candidateHash), text=\(candidate.text.prefix(30))") + + if candidate.direction == .outgoing { + guard let localNodeName, parsedReaction.targetSender == localNodeName else { + logger.debug("[REACTION-MATCH] Skip outgoing: localNodeName=\(localNodeName ?? "nil"), targetSender=\(parsedReaction.targetSender)") + continue + } + } else { + guard candidate.senderNodeName == parsedReaction.targetSender else { + logger.debug("[REACTION-MATCH] Skip incoming: senderNodeName=\(candidate.senderNodeName ?? "nil") != targetSender=\(parsedReaction.targetSender)") + continue + } + } + + guard candidateHash == parsedReaction.messageHash else { + logger.debug("[REACTION-MATCH] Hash mismatch: \(candidateHash) != \(parsedReaction.messageHash)") + continue + } + + logger.debug("[REACTION-MATCH] Found match!") + return candidate + } + + logger.debug("[REACTION-MATCH] No match found") + return nil + } + + /// Fetches channel message candidates within a timestamp window for meshcore-open reaction matching. + /// + /// Returns raw candidates without hash matching — the caller performs Dart hash comparison. + func fetchChannelMessageCandidates( + radioID: UUID, + channelIndex: UInt8, + timestampWindow: ClosedRange, + limit: Int + ) throws -> [MessageDTO] { + let targetRadioID = radioID + let targetChannelIndex: UInt8? = channelIndex + let start = timestampWindow.lowerBound + let end = timestampWindow.upperBound + + let predicate = #Predicate { message in + message.radioID == targetRadioID && + message.channelIndex == targetChannelIndex && + message.timestamp >= start && + message.timestamp <= end + } + + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.createdAt, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + + return try modelContext.fetch(descriptor).map { MessageDTO(from: $0) } + } + + /// Fetches DM message candidates within a timestamp window for meshcore-open reaction matching. + /// + /// Returns raw candidates without hash matching — the caller performs Dart hash comparison. + func fetchDMMessageCandidates( + radioID: UUID, + contactID: UUID, + timestampWindow: ClosedRange, + limit: Int + ) throws -> [MessageDTO] { + let targetRadioID = radioID + let targetContactID: UUID? = contactID + let start = timestampWindow.lowerBound + let end = timestampWindow.upperBound + + let predicate = #Predicate { message in + message.radioID == targetRadioID && + message.contactID == targetContactID && + message.timestamp >= start && + message.timestamp <= end + } + + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.createdAt, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + + return try modelContext.fetch(descriptor).map { MessageDTO(from: $0) } + } + + /// Finds a DM message matching a reaction by hash within a timestamp window. + func findDMMessageForReaction( + radioID: UUID, + contactID: UUID, + messageHash: String, + timestampWindow: ClosedRange, + limit: Int + ) throws -> MessageDTO? { + let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") + logger.debug("[DM-REACTION-MATCH] Looking for DM: hash=\(messageHash), contactID=\(contactID)") + + let candidates = try fetchDMMessageCandidates( + radioID: radioID, + contactID: contactID, + timestampWindow: timestampWindow, + limit: limit + ) + logger.debug("[DM-REACTION-MATCH] Found \(candidates.count) candidates") + + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: true) { + logger.debug("[DM-REACTION-MATCH] Skipping candidate (is reaction): \(candidate.text.prefix(30))") + continue + } + + let direction = candidate.direction == .outgoing ? "outgoing" : "incoming" + let candidateHash = ReactionParser.generateMessageHash( + text: candidate.text, + timestamp: candidate.reactionTimestamp + ) + logger.debug("[DM-REACTION-MATCH] Candidate: direction=\(direction), timestamp=\(candidate.timestamp), senderTimestamp=\(candidate.senderTimestamp ?? 0), hash=\(candidateHash), text=\(candidate.text.prefix(30))") + if candidateHash == messageHash { + logger.debug("[DM-REACTION-MATCH] Found match: \(candidate.id)") + return candidate + } else { + logger.debug("[DM-REACTION-MATCH] Hash mismatch: \(candidateHash) != \(messageHash)") + } + } + + return nil + } + + /// Fetch a message by ID + func fetchMessage(id: UUID) throws -> MessageDTO? { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { MessageDTO(from: $0) } + } + + /// Check if a message with this deduplication key already exists for the given radio. + func isDuplicateMessage(deduplicationKey: String, radioID: UUID) throws -> Bool { + let targetKey = deduplicationKey + let targetRadioID = radioID + let predicate = #Predicate { + $0.deduplicationKey == targetKey && $0.radioID == targetRadioID + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + } + + /// Save a new message + func saveMessage(_ dto: MessageDTO) throws { + modelContext.insert(Message(dto: dto)) + try modelContext.save() + } + + /// Update message status + func updateMessageStatus(id: UUID, status: MessageStatus) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + message.status = status + try modelContext.save() + } + } + + /// Update message status unless delivery has already won the race. + /// + /// - Returns: `true` if the row's status was changed, `false` if no row was + /// updated (either the row is already `.delivered`, or no row exists for + /// the given `id`). Callers must gate failure side effects (e.g., the + /// `MessageStatusEvent.failed` broadcast, UI toasts) on the return value + /// so they do not surface a `.failed` event for a delivered or absent row. + func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) throws -> Bool { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first, message.status != .delivered else { + return false + } + message.status = status + try modelContext.save() + return true + } + + /// Clears a retry-loop status (`.retrying`/`.pending`) back to `.sent` when + /// the app-layer retry budget is spent but the end-to-end ACK may still + /// arrive within `ackGiveUpWindow`. + /// + /// Terminal-safe: no-ops and returns `false` on a `.delivered` or `.failed` + /// row, so it can never resurrect a row the expiry checker already failed or + /// downgrade a delivered row. Returns `true` when the row moved to `.sent`. + func clearRetryingToSent(id: UUID) throws -> Bool { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first, + message.status != .delivered, + message.status != .failed else { + return false + } + message.status = .sent + try modelContext.save() + return true + } + + /// Update message status with retry attempt information. + /// + /// Skips the write on a terminal row (`.delivered` or `.failed`) so a stale + /// retry iteration cannot clobber a winning ACK, nor resurrect a row the + /// expiry checker already failed in the loop's `waitForEvent` await-gap. + /// This matches the terminal-safety of `clearRetryingToSent` and + /// `updateMessageAck`. + func updateMessageRetryStatus( + id: UUID, + status: MessageStatus, + retryAttempt: Int, + maxRetryAttempts: Int + ) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first, + message.status != .delivered, + message.status != .failed { + message.status = status + message.retryAttempt = retryAttempt + message.maxRetryAttempts = maxRetryAttempts + try modelContext.save() + } + } + + /// Update message timestamp (for resending) + func updateMessageTimestamp(id: UUID, timestamp: UInt32) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + message.timestamp = timestamp + try modelContext.save() + } + } + + /// Update message ACK info. + /// + /// Both `.delivered` and `.failed` are terminal for this write: once the + /// listener (or `finalizeSend`) writes `.delivered` + `roundTripTime`, a + /// later `.sent` write from the send-return path is skipped so the + /// authoritative delivery state is preserved; and once the expiry checker + /// writes `.failed`, a late ACK landing in the checker's await-gap cannot + /// flip the row to `.delivered`. The only late transition this write allows + /// is the legitimate `.sent` -> `.delivered` upgrade. + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32? = nil) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + if message.status == .delivered || message.status == .failed, status != message.status { return } + message.ackCode = ackCode + message.status = status + message.roundTripTime = roundTripTime + try modelContext.save() + } + } + + /// Read-only diagnostic: whether an outgoing DM row persists `.sent` with + /// this `ackCode`. + func hasOutgoingSentDM(ackCode: UInt32) throws -> Bool { + let target: UInt32? = ackCode + let sentRaw = MessageStatus.sent.rawValue + let outgoingRaw = MessageDirection.outgoing.rawValue + let predicate = #Predicate { message in + message.ackCode == target && + message.statusRawValue == sentRaw && + message.directionRawValue == outgoingRaw && + message.channelIndex == nil + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + } + + /// Mark a message as read + func markMessageAsRead(id: UUID) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + message.isRead = true + try modelContext.save() + } + } + + /// Updates the heard repeats count for a message + func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + message.heardRepeats = heardRepeats + try modelContext.save() + } + } + + /// Update link preview data for a message + func updateMessageLinkPreview( + id: UUID, + url: String?, + title: String?, + imageData: Data?, + iconData: Data?, + fetched: Bool + ) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let message = try modelContext.fetch(descriptor).first { + message.linkPreviewURL = url + message.linkPreviewTitle = title + message.linkPreviewImageData = imageData + message.linkPreviewIconData = iconData + message.linkPreviewFetched = fetched + try modelContext.save() + } + } + + /// Delete a message and its reactions + func deleteMessage(id: UUID) throws { + let targetID = id + + // Cascade PendingSend outside the `if let message` guard. An orphan + // PendingSend (no matching Message row) can exist via several paths: + // the queue's success-path `deletePendingSends` racing a deleteMessage + // from another path; same-millisecond user delete + queue upsert; + // historical bugs; tests. Reaping the PendingSend unconditionally on + // deleteMessage is strictly stronger than gating on the Message + // existing — there is no Message left to be "consistent" with, and + // orphan PendingSends otherwise survive until the next purge cycle. + let pendingPredicate = #Predicate { row in + row.messageID == targetID + } + for row in try modelContext.fetch(FetchDescriptor(predicate: pendingPredicate)) { + modelContext.delete(row) + } + + let predicate = #Predicate { message in + message.id == targetID + } + if let message = try modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + // Inlined Reaction cascade — only meaningful when the Message + // exists. Reactions are anchored to the Message row, so an + // orphan-Message reaction is its own cleanup problem. + try modelContext.delete(model: Reaction.self, where: #Predicate { reaction in + reaction.messageID == targetID + }) + modelContext.delete(message) + } + try modelContext.save() + } + + /// Delete all channel messages from a specific sender for a device. + /// Only deletes messages with a non-nil channelIndex (channel messages), preserving DMs. + /// Cascades PendingSend, MessageRepeat, and Reaction rows associated with the deleted + /// messages within a single save. + func deleteChannelMessages(fromSender senderName: String, radioID: UUID) throws { + let targetRadioID = radioID + let targetSenderName: String? = senderName + let messagePredicate = #Predicate { message in + message.radioID == targetRadioID && + message.senderNodeName == targetSenderName && + message.channelIndex != nil + } + + let messageIDs = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)).map(\.id) + + if !messageIDs.isEmpty { + try _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: messageIDs) + // Cascade MessageRepeat alongside Reaction. Bulk `delete(model:where:)` + // bypasses the `@Relationship(deleteRule: .cascade)` declared on + // `Message → MessageRepeat`, so the cascade has to be explicit. + // Chunk both predicates to stay under SQLITE_MAX_VARIABLE_NUMBER + // (32766 on iOS 18+). + let chunkSize = 500 + for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { + let chunk = Array(messageIDs[start.. Int { + let targetRadioID = radioID + let pendingStatus = MessageStatus.pending.rawValue + let sendingStatus = MessageStatus.sending.rawValue + let predicate = #Predicate { message in + message.radioID == targetRadioID && + (message.statusRawValue == pendingStatus || + message.statusRawValue == sendingStatus) + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) + } + + // MARK: - Heard Repeats + + /// Finds a sent channel message matching the given criteria within a time window. + /// Used for correlating RX log entries to sent messages. + /// + /// - Parameters: + /// - radioID: The radio that sent the message + /// - channelIndex: Channel the message was sent on + /// - timestamp: Sender timestamp from the message + /// - text: Message text to match + /// - withinSeconds: Time window to search (default 10 seconds) + /// - Returns: MessageDTO if found, nil otherwise + func findSentChannelMessage( + radioID: UUID, + channelIndex: UInt8, + timestamp: UInt32, + text: String, + withinSeconds: Int = 10 + ) throws -> MessageDTO? { + let targetRadioID = radioID + let targetChannelIndex: UInt8? = channelIndex + let targetTimestamp = timestamp + let outgoingDirection = MessageDirection.outgoing.rawValue + + // Calculate time window + let now = Date() + let windowStart = now.addingTimeInterval(-TimeInterval(withinSeconds)) + let windowStartTimestamp = UInt32(windowStart.timeIntervalSince1970) + + let predicate = #Predicate { message in + message.radioID == targetRadioID && + message.channelIndex == targetChannelIndex && + message.timestamp == targetTimestamp && + message.directionRawValue == outgoingDirection && + message.timestamp >= windowStartTimestamp + } + + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first else { + return nil + } + + // Verify text matches (outgoing channel messages store just the text) + guard message.text == text else { + return nil + } + + return MessageDTO(from: message) + } + + /// Saves a new MessageRepeat entry and links it to the parent message. + func saveMessageRepeat(_ dto: MessageRepeatDTO) throws { + // Fetch the parent message for relationship + let targetMessageID = dto.messageID + let messagePredicate = #Predicate { message in + message.id == targetMessageID + } + var messageDescriptor = FetchDescriptor(predicate: messagePredicate) + messageDescriptor.fetchLimit = 1 + + guard let parentMessage = try modelContext.fetch(messageDescriptor).first else { + throw PersistenceStoreError.messageNotFound + } + + modelContext.insert(MessageRepeat(dto: dto, message: parentMessage)) + try modelContext.save() + } + + /// Fetches all repeats for a given message, sorted by receivedAt ascending. + func fetchMessageRepeats(messageID: UUID) throws -> [MessageRepeatDTO] { + let targetMessageID = messageID + let predicate = #Predicate { repeat_ in + repeat_.messageID == targetMessageID + } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\MessageRepeat.receivedAt, order: .forward)] + ) + + let results = try modelContext.fetch(descriptor) + return results.map { MessageRepeatDTO(from: $0) } + } + + /// Deletes all repeats for a given message. + func deleteMessageRepeats(messageID: UUID) throws { + let targetMessageID = messageID + let predicate = #Predicate { repeat_ in + repeat_.messageID == targetMessageID + } + let descriptor = FetchDescriptor(predicate: predicate) + + let results = try modelContext.fetch(descriptor) + for repeat_ in results { + modelContext.delete(repeat_) + } + try modelContext.save() + } + + /// Checks if a repeat already exists for the given RX log entry. + func messageRepeatExists(rxLogEntryID: UUID) throws -> Bool { + let targetID: UUID? = rxLogEntryID + let predicate = #Predicate { repeat_ in + repeat_.rxLogEntryID == targetID + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + } + + /// Increments the heardRepeats count for a message and returns the new count. + func incrementMessageHeardRepeats(id: UUID) throws -> Int { + let targetID = id + let predicate = #Predicate { message in message.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first else { + return 0 + } + + message.heardRepeats += 1 + try modelContext.save() + return message.heardRepeats + } + + /// Increments the sendCount for a message and returns the new count. + func incrementMessageSendCount(id: UUID) throws -> Int { + let targetID = id + let predicate = #Predicate { message in message.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first else { + return 0 + } + + message.sendCount += 1 + try modelContext.save() + return message.sendCount + } + + // MARK: - Reactions + + /// Saves a new reaction + func saveReaction(_ dto: ReactionDTO) throws { + let reaction = Reaction( + id: dto.id, + messageID: dto.messageID, + emoji: dto.emoji, + senderName: dto.senderName, + messageHash: dto.messageHash, + rawText: dto.rawText, + receivedAt: dto.receivedAt, + channelIndex: dto.channelIndex, + contactID: dto.contactID, + radioID: dto.radioID + ) + modelContext.insert(reaction) + try modelContext.save() + } + + /// Fetches reactions for a message + func fetchReactions(for messageID: UUID, limit: Int = 100) throws -> [ReactionDTO] { + let targetMessageID = messageID + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.messageID == targetMessageID }, + sortBy: [SortDescriptor(\Reaction.receivedAt, order: .reverse)] + ) + descriptor.fetchLimit = limit + return try modelContext.fetch(descriptor).map { ReactionDTO(from: $0) } + } + + /// Checks if a reaction already exists (deduplication) + func reactionExists(messageID: UUID, senderName: String, emoji: String) throws -> Bool { + let targetMessageID = messageID + let targetSenderName = senderName + let targetEmoji = emoji + let predicate = #Predicate { + $0.messageID == targetMessageID && + $0.senderName == targetSenderName && + $0.emoji == targetEmoji + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + } + + /// Updates a message's reaction summary cache + func updateMessageReactionSummary(messageID: UUID, summary: String?) throws { + let targetMessageID = messageID + let predicate = #Predicate { $0.id == targetMessageID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let message = try modelContext.fetch(descriptor).first else { return } + message.reactionSummary = summary + try modelContext.save() + } + + /// Deletes all reactions for a message + func deleteReactionsForMessage(messageID: UUID) throws { + let targetMessageID = messageID + try modelContext.delete(model: Reaction.self, where: #Predicate { + $0.messageID == targetMessageID + }) + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Metadata.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Metadata.swift index 05e277be..66988a9b 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Metadata.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Metadata.swift @@ -1,132 +1,131 @@ import Foundation import SwiftData -extension PersistenceStore { +public extension PersistenceStore { + // MARK: - Badge Count Support - // MARK: - Badge Count Support + /// Efficiently calculate total unread counts for badge display + /// Returns tuple of (contactUnread, channelUnread, roomUnread) for preference-aware calculation + /// Optimization: Only fetches entities with unread > 0 to minimize memory usage + func getTotalUnreadCounts(radioID: UUID) throws -> (contacts: Int, channels: Int, rooms: Int) { + let targetRadioID = radioID - /// Efficiently calculate total unread counts for badge display - /// Returns tuple of (contactUnread, channelUnread, roomUnread) for preference-aware calculation - /// Optimization: Only fetches entities with unread > 0 to minimize memory usage - public func getTotalUnreadCounts(radioID: UUID) throws -> (contacts: Int, channels: Int, rooms: Int) { - let targetRadioID = radioID - - // Only fetch non-blocked, non-muted, non-repeater contacts with unread messages for this device. - // Repeater contacts are filtered out of the chats list (ChatViewModel), so unread on them - // is unreachable to the user and must not inflate the badge. - let repeaterContactRaw = ContactType.repeater.rawValue - let contactPredicate = #Predicate { - $0.radioID == targetRadioID && - $0.unreadCount > 0 && - !$0.isMuted && - !$0.isBlocked && - $0.typeRawValue != repeaterContactRaw - } - let contactDescriptor = FetchDescriptor(predicate: contactPredicate) - let contactsWithUnread = try modelContext.fetch(contactDescriptor) - let contactTotal = contactsWithUnread.reduce(0) { $0 + $1.unreadCount } - - // Channels: exclude muted, include if unreadCount > 0 OR unreadMentionCount > 0 - let mutedRawValue = NotificationLevel.muted.rawValue - let channelPredicate = #Predicate { - $0.radioID == targetRadioID && - $0.notificationLevelRawValue != mutedRawValue && - ($0.unreadCount > 0 || $0.unreadMentionCount > 0) - } - let channelDescriptor = FetchDescriptor(predicate: channelPredicate) - let channelsWithUnread = try modelContext.fetch(channelDescriptor) - let channelTotal = channelsWithUnread.reduce(0) { total, channel in - if channel.notificationLevel == .mentionsOnly { - return total + channel.unreadMentionCount - } - return total + channel.unreadCount - } + // Only fetch non-blocked, non-muted, non-repeater contacts with unread messages for this device. + // Repeater contacts are filtered out of the chats list (ChatViewModel), so unread on them + // is unreachable to the user and must not inflate the badge. + let repeaterContactRaw = ContactType.repeater.rawValue + let contactPredicate = #Predicate { + $0.radioID == targetRadioID && + $0.unreadCount > 0 && + !$0.isMuted && + !$0.isBlocked && + $0.typeRawValue != repeaterContactRaw + } + let contactDescriptor = FetchDescriptor(predicate: contactPredicate) + let contactsWithUnread = try modelContext.fetch(contactDescriptor) + let contactTotal = contactsWithUnread.reduce(0) { $0 + $1.unreadCount } - // Rooms: only include room-server sessions; repeater-role admin sessions are filtered out - // of the chats list and would otherwise be unreachable badge contributors. - let roomServerRoleRaw = RemoteNodeRole.roomServer.rawValue - let roomPredicate = #Predicate { - $0.radioID == targetRadioID && - $0.notificationLevelRawValue != mutedRawValue && - $0.unreadCount > 0 && - $0.roleRawValue == roomServerRoleRaw - } - let roomDescriptor = FetchDescriptor(predicate: roomPredicate) - let roomsWithUnread = try modelContext.fetch(roomDescriptor) - let roomTotal = roomsWithUnread.reduce(0) { $0 + $1.unreadCount } + // Channels: exclude muted, include if unreadCount > 0 OR unreadMentionCount > 0 + let mutedRawValue = NotificationLevel.muted.rawValue + let channelPredicate = #Predicate { + $0.radioID == targetRadioID && + $0.notificationLevelRawValue != mutedRawValue && + ($0.unreadCount > 0 || $0.unreadMentionCount > 0) + } + let channelDescriptor = FetchDescriptor(predicate: channelPredicate) + let channelsWithUnread = try modelContext.fetch(channelDescriptor) + let channelTotal = channelsWithUnread.reduce(0) { total, channel in + if channel.notificationLevel == .mentionsOnly { + return total + channel.unreadMentionCount + } + return total + channel.unreadCount + } - return (contacts: contactTotal, channels: channelTotal, rooms: roomTotal) + // Rooms: only include room-server sessions; repeater-role admin sessions are filtered out + // of the chats list and would otherwise be unreachable badge contributors. + let roomServerRoleRaw = RemoteNodeRole.roomServer.rawValue + let roomPredicate = #Predicate { + $0.radioID == targetRadioID && + $0.notificationLevelRawValue != mutedRawValue && + $0.unreadCount > 0 && + $0.roleRawValue == roomServerRoleRaw } + let roomDescriptor = FetchDescriptor(predicate: roomPredicate) + let roomsWithUnread = try modelContext.fetch(roomDescriptor) + let roomTotal = roomsWithUnread.reduce(0) { $0 + $1.unreadCount } + + return (contacts: contactTotal, channels: channelTotal, rooms: roomTotal) + } - /// Get total unread count for a contact - public func getUnreadCount(contactID: UUID) throws -> Int { - let targetID = contactID - let predicate = #Predicate { contact in - contact.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first?.unreadCount ?? 0 + /// Get total unread count for a contact + func getUnreadCount(contactID: UUID) throws -> Int { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first?.unreadCount ?? 0 + } - /// Get total unread count for a channel - public func getChannelUnreadCount(channelID: UUID) throws -> Int { - let targetID = channelID - let predicate = #Predicate { channel in - channel.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first?.unreadCount ?? 0 + /// Get total unread count for a channel + func getChannelUnreadCount(channelID: UUID) throws -> Int { + let targetID = channelID + let predicate = #Predicate { channel in + channel.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first?.unreadCount ?? 0 + } - // MARK: - Link Preview Data + // MARK: - Link Preview Data - /// Fetches link preview data by URL - public func fetchLinkPreview(url: String) throws -> LinkPreviewDataDTO? { - let urlToFind = url - let predicate = #Predicate { preview in - preview.url == urlToFind - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + /// Fetches link preview data by URL + func fetchLinkPreview(url: String) throws -> LinkPreviewDataDTO? { + let urlToFind = url + let predicate = #Predicate { preview in + preview.url == urlToFind + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - guard let preview = try modelContext.fetch(descriptor).first else { - return nil - } - return LinkPreviewDataDTO(from: preview) + guard let preview = try modelContext.fetch(descriptor).first else { + return nil } + return LinkPreviewDataDTO(from: preview) + } - /// Saves or updates link preview data - public func saveLinkPreview(_ dto: LinkPreviewDataDTO) throws { - let urlToFind = dto.url - let predicate = #Predicate { preview in - preview.url == urlToFind - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + /// Saves or updates link preview data + func saveLinkPreview(_ dto: LinkPreviewDataDTO) throws { + let urlToFind = dto.url + let predicate = #Predicate { preview in + preview.url == urlToFind + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - if let existing = try modelContext.fetch(descriptor).first { - // Update existing - existing.title = dto.title - existing.imageData = dto.imageData - existing.iconData = dto.iconData - existing.imageWidth = dto.imageWidth - existing.imageHeight = dto.imageHeight - existing.fetchedAt = dto.fetchedAt - } else { - // Insert new - let preview = LinkPreviewData( - url: dto.url, - title: dto.title, - imageData: dto.imageData, - iconData: dto.iconData, - imageWidth: dto.imageWidth, - imageHeight: dto.imageHeight, - fetchedAt: dto.fetchedAt - ) - modelContext.insert(preview) - } - try modelContext.save() + if let existing = try modelContext.fetch(descriptor).first { + // Update existing + existing.title = dto.title + existing.imageData = dto.imageData + existing.iconData = dto.iconData + existing.imageWidth = dto.imageWidth + existing.imageHeight = dto.imageHeight + existing.fetchedAt = dto.fetchedAt + } else { + // Insert new + let preview = LinkPreviewData( + url: dto.url, + title: dto.title, + imageData: dto.imageData, + iconData: dto.iconData, + imageWidth: dto.imageWidth, + imageHeight: dto.imageHeight, + fetchedAt: dto.fetchedAt + ) + modelContext.insert(preview) } + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Migration.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Migration.swift index 356d25f4..12674bce 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Migration.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Migration.swift @@ -3,260 +3,277 @@ import os import SwiftData extension PersistenceStore { + private static let migrationLogger = Logger(subsystem: "com.mc1", category: "RadioIDMigration") + private static let migrationKey = "hasPopulatedRadioIDs" + + /// One-time migration: populate radioID on all Devices and propagate to children, + /// then backfill deduplicationKey on outgoing Messages with nil keys. + public func performRadioIDMigration() throws { + guard !UserDefaults.standard.bool(forKey: Self.migrationKey) else { return } + + // Step 1: For each Device, children's radioID column still contains the old BLE UUID + // (device.id) due to the @Attribute(originalName: "deviceID") rename. Generate a new + // radioID for each Device and propagate to all children. + let devices = try modelContext.fetch(FetchDescriptor()) + + let lastDeviceIDString = UserDefaults.standard.string(forKey: PersistenceKeys.lastConnectedDeviceID) + let lastDeviceID = lastDeviceIDString.flatMap(UUID.init) + var mappedRadioID: UUID? + + for device in devices { + let newRadioID = UUID() + let oldRadioID = device.id + device.radioID = newRadioID + + if oldRadioID == lastDeviceID { + mappedRadioID = newRadioID + } + + let targetOldID = oldRadioID + + let contacts = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for contact in contacts { + contact.radioID = newRadioID + } + + let channels = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for channel in channels { + channel.radioID = newRadioID + } + + let messages = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for message in messages { + message.radioID = newRadioID + } + + let reactions = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for reaction in reactions { + reaction.radioID = newRadioID + } + + let sessions = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for session in sessions { + session.radioID = newRadioID + } + + let paths = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for path in paths { + path.radioID = newRadioID + } + + let nodes = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for node in nodes { + node.radioID = newRadioID + } + + let blocked = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for sender in blocked { + sender.radioID = newRadioID + } + + let logs = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) + for log in logs { + log.radioID = newRadioID + } + } - private static let migrationLogger = Logger(subsystem: "com.mc1", category: "RadioIDMigration") - private static let migrationKey = "hasPopulatedRadioIDs" - - /// One-time migration: populate radioID on all Devices and propagate to children, - /// then backfill deduplicationKey on outgoing Messages with nil keys. - public func performRadioIDMigration() throws { - guard !UserDefaults.standard.bool(forKey: Self.migrationKey) else { return } - - // Step 1: For each Device, children's radioID column still contains the old BLE UUID - // (device.id) due to the @Attribute(originalName: "deviceID") rename. Generate a new - // radioID for each Device and propagate to all children. - let devices = try modelContext.fetch(FetchDescriptor()) - - let lastDeviceIDString = UserDefaults.standard.string(forKey: PersistenceKeys.lastConnectedDeviceID) - let lastDeviceID = lastDeviceIDString.flatMap(UUID.init) - var mappedRadioID: UUID? - - for device in devices { - let newRadioID = UUID() - let oldRadioID = device.id - device.radioID = newRadioID - - if oldRadioID == lastDeviceID { - mappedRadioID = newRadioID - } - - let targetOldID = oldRadioID - - let contacts = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for contact in contacts { contact.radioID = newRadioID } - - let channels = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for channel in channels { channel.radioID = newRadioID } - - let messages = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for message in messages { message.radioID = newRadioID } - - let reactions = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for reaction in reactions { reaction.radioID = newRadioID } - - let sessions = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for session in sessions { session.radioID = newRadioID } + // Step 2: Backfill deduplicationKey on outgoing messages with nil keys. + // Only outgoing (directionRawValue == 1); incoming messages get keys during re-sync. + let outgoingDirection = MessageDirection.outgoing.rawValue + let nilKeyPredicate = #Predicate { message in + message.deduplicationKey == nil && message.directionRawValue == outgoingDirection + } + let messagesNeedingKeys = try modelContext.fetch(FetchDescriptor(predicate: nilKeyPredicate)) + + for message in messagesNeedingKeys { + message.deduplicationKey = DeduplicationKey.contentBased( + contactID: message.contactID, + channelIndex: message.channelIndex, + senderNodeName: message.senderNodeName, + timestamp: message.timestamp, + content: message.text + ) + } - let paths = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for path in paths { path.radioID = newRadioID } + try modelContext.save() - let nodes = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for node in nodes { node.radioID = newRadioID } + if let mappedRadioID { + UserDefaults.standard.set(mappedRadioID.uuidString, forKey: PersistenceKeys.lastConnectedRadioID) + } else if lastDeviceID != nil { + Self.migrationLogger.warning("lastConnectedDeviceID did not match any stored device; lastConnectedRadioID not backfilled") + } - let blocked = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for sender in blocked { sender.radioID = newRadioID } + UserDefaults.standard.set(true, forKey: Self.migrationKey) - let logs = try modelContext.fetch(FetchDescriptor(predicate: #Predicate { $0.radioID == targetOldID })) - for log in logs { log.radioID = newRadioID } - } + Self.migrationLogger.info("radioID migration complete: \(devices.count) devices, \(messagesNeedingKeys.count) dedup keys backfilled") + } - // Step 2: Backfill deduplicationKey on outgoing messages with nil keys. - // Only outgoing (directionRawValue == 1); incoming messages get keys during re-sync. - let outgoingDirection = MessageDirection.outgoing.rawValue - let nilKeyPredicate = #Predicate { message in - message.deduplicationKey == nil && message.directionRawValue == outgoingDirection - } - let messagesNeedingKeys = try modelContext.fetch(FetchDescriptor(predicate: nilKeyPredicate)) + /// Resets the migration flag (for testing only). + public func resetRadioIDMigrationFlag() { + UserDefaults.standard.removeObject(forKey: Self.migrationKey) + } - for message in messagesNeedingKeys { - message.deduplicationKey = DeduplicationKey.contentBased( - contactID: message.contactID, - channelIndex: message.channelIndex, - senderNodeName: message.senderNodeName, - timestamp: message.timestamp, - content: message.text - ) - } + // MARK: - Channel flood scope corrective migration - try modelContext.save() + private static let floodScopeMigrationKey = "hasMigratedChannelFloodScope" + private static let floodScopeMigrationLogger = Logger( + subsystem: "com.mc1", + category: "ChannelFloodScopeMigration" + ) - if let mappedRadioID { - UserDefaults.standard.set(mappedRadioID.uuidString, forKey: PersistenceKeys.lastConnectedRadioID) - } else if lastDeviceID != nil { - Self.migrationLogger.warning("lastConnectedDeviceID did not match any stored device; lastConnectedRadioID not backfilled") - } + /// One-time migration: pre-existing rows were persisted before the flood-scope mode + /// column existed and all come up with the default `.inherit` value, even when they + /// had a per-channel `regionScope` override. Promote those to `.specific` so the + /// user's prior choice keeps working. Rows whose `regionScope` was nil keep + /// `.inherit` — corrective semantics, so the device default applies. + public func performChannelFloodScopeMigration() throws { + guard !UserDefaults.standard.bool(forKey: Self.floodScopeMigrationKey) else { return } - UserDefaults.standard.set(true, forKey: Self.migrationKey) + let inheritRaw = ChannelFloodScopeStorage.Mode.inherit.rawValue + let specificRaw = ChannelFloodScopeStorage.Mode.specific.rawValue - Self.migrationLogger.info("radioID migration complete: \(devices.count) devices, \(messagesNeedingKeys.count) dedup keys backfilled") + let predicate = #Predicate { channel in + channel.regionScope != nil && channel.floodScopeModeRawValue == inheritRaw } - - /// Resets the migration flag (for testing only). - public func resetRadioIDMigrationFlag() { - UserDefaults.standard.removeObject(forKey: Self.migrationKey) + let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + for channel in channels { + channel.floodScopeModeRawValue = specificRaw } + try modelContext.save() - // MARK: - Channel flood scope corrective migration + UserDefaults.standard.set(true, forKey: Self.floodScopeMigrationKey) - private static let floodScopeMigrationKey = "hasMigratedChannelFloodScope" - private static let floodScopeMigrationLogger = Logger( - subsystem: "com.mc1", - category: "ChannelFloodScopeMigration" + Self.floodScopeMigrationLogger.info( + "channel flood-scope migration complete: \(channels.count) rows promoted to .specific" ) - - /// One-time migration: pre-existing rows were persisted before the flood-scope mode - /// column existed and all come up with the default `.inherit` value, even when they - /// had a per-channel `regionScope` override. Promote those to `.specific` so the - /// user's prior choice keeps working. Rows whose `regionScope` was nil keep - /// `.inherit` — corrective semantics, so the device default applies. - public func performChannelFloodScopeMigration() throws { - guard !UserDefaults.standard.bool(forKey: Self.floodScopeMigrationKey) else { return } - - let inheritRaw = ChannelFloodScopeStorage.Mode.inherit.rawValue - let specificRaw = ChannelFloodScopeStorage.Mode.specific.rawValue - - let predicate = #Predicate { channel in - channel.regionScope != nil && channel.floodScopeModeRawValue == inheritRaw - } - let channels = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - for channel in channels { - channel.floodScopeModeRawValue = specificRaw - } - try modelContext.save() - - UserDefaults.standard.set(true, forKey: Self.floodScopeMigrationKey) - - Self.floodScopeMigrationLogger.info( - "channel flood-scope migration complete: \(channels.count) rows promoted to .specific" - ) + } + + /// Resets the migration flag (for testing only). + public func resetChannelFloodScopeMigrationFlag() { + UserDefaults.standard.removeObject(forKey: Self.floodScopeMigrationKey) + } + + // MARK: - Repeater unread-count corrective migration + + private static let repeaterUnreadMigrationKey = "hasMigratedRepeaterUnreadCounts" + private static let repeaterUnreadMigrationLogger = Logger( + subsystem: "com.mc1", + category: "RepeaterUnreadMigration" + ) + + /// One-time migration: prior versions counted unread on repeater-type contacts and + /// repeater-role node sessions toward the OS badge, even though those records are + /// filtered out of the chats list and so unreachable to the user. Zero out any + /// accumulated counters so the badge drops to a sane number on first launch after + /// upgrading. The predicate fix in `getTotalUnreadCounts` prevents new accumulation + /// from inflating the badge; this sweep clears the historical residue. + public func performRepeaterUnreadCountMigration() throws { + guard !UserDefaults.standard.bool(forKey: Self.repeaterUnreadMigrationKey) else { return } + + let repeaterContactRaw = ContactType.repeater.rawValue + let contactPredicate = #Predicate { contact in + contact.typeRawValue == repeaterContactRaw && + (contact.unreadCount > 0 || contact.unreadMentionCount > 0) } - - /// Resets the migration flag (for testing only). - public func resetChannelFloodScopeMigrationFlag() { - UserDefaults.standard.removeObject(forKey: Self.floodScopeMigrationKey) + let contacts = try modelContext.fetch(FetchDescriptor(predicate: contactPredicate)) + for contact in contacts { + contact.unreadCount = 0 + contact.unreadMentionCount = 0 } - // MARK: - Repeater unread-count corrective migration - - private static let repeaterUnreadMigrationKey = "hasMigratedRepeaterUnreadCounts" - private static let repeaterUnreadMigrationLogger = Logger( - subsystem: "com.mc1", - category: "RepeaterUnreadMigration" - ) - - /// One-time migration: prior versions counted unread on repeater-type contacts and - /// repeater-role node sessions toward the OS badge, even though those records are - /// filtered out of the chats list and so unreachable to the user. Zero out any - /// accumulated counters so the badge drops to a sane number on first launch after - /// upgrading. The predicate fix in `getTotalUnreadCounts` prevents new accumulation - /// from inflating the badge; this sweep clears the historical residue. - public func performRepeaterUnreadCountMigration() throws { - guard !UserDefaults.standard.bool(forKey: Self.repeaterUnreadMigrationKey) else { return } - - let repeaterContactRaw = ContactType.repeater.rawValue - let contactPredicate = #Predicate { contact in - contact.typeRawValue == repeaterContactRaw && - (contact.unreadCount > 0 || contact.unreadMentionCount > 0) - } - let contacts = try modelContext.fetch(FetchDescriptor(predicate: contactPredicate)) - for contact in contacts { - contact.unreadCount = 0 - contact.unreadMentionCount = 0 - } - - let repeaterRoleRaw = RemoteNodeRole.repeater.rawValue - let sessionPredicate = #Predicate { session in - session.roleRawValue == repeaterRoleRaw && session.unreadCount > 0 - } - let sessions = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)) - for session in sessions { - session.unreadCount = 0 - } - - try modelContext.save() - - UserDefaults.standard.set(true, forKey: Self.repeaterUnreadMigrationKey) - - Self.repeaterUnreadMigrationLogger.info( - "repeater unread migration complete: \(contacts.count) contacts, \(sessions.count) sessions cleared" - ) + let repeaterRoleRaw = RemoteNodeRole.repeater.rawValue + let sessionPredicate = #Predicate { session in + session.roleRawValue == repeaterRoleRaw && session.unreadCount > 0 } - - /// Resets the migration flag (for testing only). - public func resetRepeaterUnreadMigrationFlag() { - UserDefaults.standard.removeObject(forKey: Self.repeaterUnreadMigrationKey) + let sessions = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)) + for session in sessions { + session.unreadCount = 0 } - // MARK: - Message sortDate normalization - - /// Normalizes every message's `sortDate` to its `createdAt`, guarded by a one-time flag. - /// Shared by the backfill and reset migrations, which differ only in which flag gates - /// them and what they log. Returns the row count, or `nil` when the flag was already set - /// and the migration was skipped. - private func normalizeMessageSortDates(flagKey: String) throws -> Int? { - guard !UserDefaults.standard.bool(forKey: flagKey) else { return nil } - - let messages = try modelContext.fetch(FetchDescriptor()) - for message in messages { - message.sortDate = message.createdAt - } - try modelContext.save() - - UserDefaults.standard.set(true, forKey: flagKey) - return messages.count - } + try modelContext.save() - // MARK: - Message sortDate backfill migration + UserDefaults.standard.set(true, forKey: Self.repeaterUnreadMigrationKey) - private static let sortDateBackfillMigrationKey = "hasBackfilledMessageSortDate" - private static let sortDateBackfillMigrationLogger = Logger( - subsystem: "com.mc1", - category: "SortDateBackfillMigration" + Self.repeaterUnreadMigrationLogger.info( + "repeater unread migration complete: \(contacts.count) contacts, \(sessions.count) sessions cleared" ) + } - /// One-time backfill: pre-existing rows were persisted before the `sortDate` - /// column existed and come up with the `Date.distantPast` schema default. - /// Set `sortDate` to `createdAt` on every row so the date-header grouping - /// preserves their current display order. This runs once at launch before - /// any sync, so every row present is a pre-feature row. - public func performSortDateBackfillMigration() throws { - guard let count = try normalizeMessageSortDates(flagKey: Self.sortDateBackfillMigrationKey) else { return } - - Self.sortDateBackfillMigrationLogger.info( - "sortDate backfill complete: \(count) messages backfilled" - ) - } - - /// Resets the migration flag (for testing only). - public func resetSortDateBackfillMigrationFlag() { - UserDefaults.standard.removeObject(forKey: Self.sortDateBackfillMigrationKey) - } + /// Resets the migration flag (for testing only). + public func resetRepeaterUnreadMigrationFlag() { + UserDefaults.standard.removeObject(forKey: Self.repeaterUnreadMigrationKey) + } - // MARK: - Message sortDate reset migration + // MARK: - Message sortDate normalization - private static let sortDateResetMigrationKey = "hasResetMessageSortDate" - private static let sortDateResetMigrationLogger = Logger( - subsystem: "com.mc1", - category: "SortDateResetMigration" - ) + /// Normalizes every message's `sortDate` to its `createdAt`, guarded by a one-time flag. + /// Shared by the backfill and reset migrations, which differ only in which flag gates + /// them and what they log. Returns the row count, or `nil` when the flag was already set + /// and the migration was skipped. + private func normalizeMessageSortDates(flagKey: String) throws -> Int? { + guard !UserDefaults.standard.bool(forKey: flagKey) else { return nil } - /// One-time reset: an interim build derived `sortDate` from the sender's send time, - /// which buried just-synced backlog deep in scrollback. The original backfill - /// (`performSortDateBackfillMigration`) already ran on those installs, so its flag is - /// set and it can no longer touch the rows. Re-normalize every row's `sortDate` to - /// `createdAt` so block-at-reconnect ordering starts from a clean receive-time baseline; - /// subsequent syncs derive a fresh drain anchor per batch. Runs once at launch before - /// any sync, so every row present is a pre-feature row. - public func performSortDateResetMigration() throws { - guard let count = try normalizeMessageSortDates(flagKey: Self.sortDateResetMigrationKey) else { return } - - Self.sortDateResetMigrationLogger.info( - "sortDate reset complete: \(count) messages re-normalized to createdAt" - ) + let messages = try modelContext.fetch(FetchDescriptor()) + for message in messages { + message.sortDate = message.createdAt } + try modelContext.save() + + UserDefaults.standard.set(true, forKey: flagKey) + return messages.count + } + + // MARK: - Message sortDate backfill migration + + private static let sortDateBackfillMigrationKey = "hasBackfilledMessageSortDate" + private static let sortDateBackfillMigrationLogger = Logger( + subsystem: "com.mc1", + category: "SortDateBackfillMigration" + ) + + /// One-time backfill: pre-existing rows were persisted before the `sortDate` + /// column existed and come up with the `Date.distantPast` schema default. + /// Set `sortDate` to `createdAt` on every row so the date-header grouping + /// preserves their current display order. This runs once at launch before + /// any sync, so every row present is a pre-feature row. + public func performSortDateBackfillMigration() throws { + guard let count = try normalizeMessageSortDates(flagKey: Self.sortDateBackfillMigrationKey) else { return } + + Self.sortDateBackfillMigrationLogger.info( + "sortDate backfill complete: \(count) messages backfilled" + ) + } + + /// Resets the migration flag (for testing only). + public func resetSortDateBackfillMigrationFlag() { + UserDefaults.standard.removeObject(forKey: Self.sortDateBackfillMigrationKey) + } + + // MARK: - Message sortDate reset migration + + private static let sortDateResetMigrationKey = "hasResetMessageSortDate" + private static let sortDateResetMigrationLogger = Logger( + subsystem: "com.mc1", + category: "SortDateResetMigration" + ) + + /// One-time reset: an interim build derived `sortDate` from the sender's send time, + /// which buried just-synced backlog deep in scrollback. The original backfill + /// (`performSortDateBackfillMigration`) already ran on those installs, so its flag is + /// set and it can no longer touch the rows. Re-normalize every row's `sortDate` to + /// `createdAt` so block-at-reconnect ordering starts from a clean receive-time baseline; + /// subsequent syncs derive a fresh drain anchor per batch. Runs once at launch before + /// any sync, so every row present is a pre-feature row. + public func performSortDateResetMigration() throws { + guard let count = try normalizeMessageSortDates(flagKey: Self.sortDateResetMigrationKey) else { return } + + Self.sortDateResetMigrationLogger.info( + "sortDate reset complete: \(count) messages re-normalized to createdAt" + ) + } - /// Resets the migration flag (for testing only). - public func resetSortDateResetMigrationFlag() { - UserDefaults.standard.removeObject(forKey: Self.sortDateResetMigrationKey) - } + /// Resets the migration flag (for testing only). + public func resetSortDateResetMigrationFlag() { + UserDefaults.standard.removeObject(forKey: Self.sortDateResetMigrationKey) + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+PendingSends.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+PendingSends.swift index 213fd893..f8d3bb96 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+PendingSends.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+PendingSends.swift @@ -2,352 +2,353 @@ import Foundation import os import SwiftData -extension PersistenceStore { +public extension PersistenceStore { + private static let pendingSendLogger = Logger( + subsystem: "com.mc1", + category: "PersistenceStore.PendingSends" + ) - private static let pendingSendLogger = Logger( - subsystem: "com.mc1", - category: "PersistenceStore.PendingSends" - ) + // MARK: - PendingSend CRUD - // MARK: - PendingSend CRUD + /// Insert (or update if `dto.id` already exists) a pending send row. + /// The `dto.sequence` value is used as-is. Used by tests that need to + /// pin sequence values; the production enqueue path uses + /// `insertPendingSendAssigningSequence(_:)` instead so the sequence + /// is assigned atomically. + func upsertPendingSend(_ dto: PendingSendDTO) throws { + let id = dto.id + let predicate = #Predicate { row in + row.id == id + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - /// Insert (or update if `dto.id` already exists) a pending send row. - /// The `dto.sequence` value is used as-is. Used by tests that need to - /// pin sequence values; the production enqueue path uses - /// `insertPendingSendAssigningSequence(_:)` instead so the sequence - /// is assigned atomically. - public func upsertPendingSend(_ dto: PendingSendDTO) throws { - let id = dto.id - let predicate = #Predicate { row in - row.id == id - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + if let existing = try modelContext.fetch(descriptor).first { + existing.radioID = dto.radioID + existing.messageID = dto.messageID + existing.kindRawValue = dto.kind.rawValue + existing.contactID = dto.contactID + existing.channelIndex = dto.channelIndex + existing.isResend = dto.isResend + existing.messageText = dto.messageText + existing.messageTimestamp = dto.messageTimestamp + existing.localNodeName = dto.localNodeName + existing.sequence = dto.sequence + existing.enqueuedAt = dto.enqueuedAt + existing.attemptCount = dto.attemptCount + } else { + modelContext.insert(PendingSend(dto: dto)) + } + try modelContext.save() + } - if let existing = try modelContext.fetch(descriptor).first { - existing.radioID = dto.radioID - existing.messageID = dto.messageID - existing.kindRawValue = dto.kind.rawValue - existing.contactID = dto.contactID - existing.channelIndex = dto.channelIndex - existing.isResend = dto.isResend - existing.messageText = dto.messageText - existing.messageTimestamp = dto.messageTimestamp - existing.localNodeName = dto.localNodeName - existing.sequence = dto.sequence - existing.enqueuedAt = dto.enqueuedAt - existing.attemptCount = dto.attemptCount - } else { - modelContext.insert(PendingSend(dto: dto)) - } - try modelContext.save() + /// Insert a new pending send row, atomically computing and assigning + /// the next sequence number for the row's radio. The `dto.sequence` + /// field is ignored — the assigned value is the one that lands on + /// disk, and it is also the return value. + /// + /// Atomicity: the read of `max(sequence)` and the insert both run on + /// `PersistenceStore`'s serial `@ModelActor` isolation, so two + /// concurrent enqueues from main never see the same `max+1`. This is + /// the production enqueue path; `upsertPendingSend(_:)` above is + /// reserved for tests and explicit-sequence scenarios. + func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) throws -> Int { + let scopedRadioID = dto.radioID + let radioPredicate = #Predicate { row in + row.radioID == scopedRadioID } + var seqDescriptor = FetchDescriptor( + predicate: radioPredicate, + sortBy: [SortDescriptor(\.sequence, order: .reverse)] + ) + seqDescriptor.fetchLimit = 1 + let latest = try modelContext.fetch(seqDescriptor).first + let assignedSequence = (latest?.sequence ?? 0) + 1 - /// Insert a new pending send row, atomically computing and assigning - /// the next sequence number for the row's radio. The `dto.sequence` - /// field is ignored — the assigned value is the one that lands on - /// disk, and it is also the return value. - /// - /// Atomicity: the read of `max(sequence)` and the insert both run on - /// `PersistenceStore`'s serial `@ModelActor` isolation, so two - /// concurrent enqueues from main never see the same `max+1`. This is - /// the production enqueue path; `upsertPendingSend(_:)` above is - /// reserved for tests and explicit-sequence scenarios. - public func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) throws -> Int { - let scopedRadioID = dto.radioID - let radioPredicate = #Predicate { row in - row.radioID == scopedRadioID - } - var seqDescriptor = FetchDescriptor( - predicate: radioPredicate, - sortBy: [SortDescriptor(\.sequence, order: .reverse)] - ) - seqDescriptor.fetchLimit = 1 - let latest = try modelContext.fetch(seqDescriptor).first - let assignedSequence = (latest?.sequence ?? 0) + 1 + let inserted = PendingSend(dto: dto) + inserted.sequence = assignedSequence + modelContext.insert(inserted) + try modelContext.save() + return assignedSequence + } - let inserted = PendingSend(dto: dto) - inserted.sequence = assignedSequence - modelContext.insert(inserted) - try modelContext.save() - return assignedSequence + /// Atomically replaces any existing `PendingSend` rows for the given + /// `messageID`, flips the matching `Message.status` to `.pending` + /// (unless already `.delivered`), and inserts the new `PendingSendDTO`. + /// All three operations land in a single `modelContext.save()` so a + /// crash mid-call cannot leave the row at `.pending` with no + /// `PendingSend` available for replay. The manual DM retry path uses + /// this to keep the queue authoritative for retries while the prior + /// multi-await sequence (delete + status flip + enqueue) had crash + /// windows that stranded `.pending` rows. + /// + /// `.delivered` is intentionally preserved — a late-arriving ACK that + /// landed while the user was reaching for the retry button must not be + /// clobbered. The `PendingSend` row is still inserted; the queue's + /// drain-time `hasPendingSend` gate notices the row but `MessageService` + /// short-circuits the wire send for an already-delivered message. + /// + /// Returns the assigned per-radio sequence number, matching the + /// `insertPendingSendAssigningSequence(_:)` contract. + func replacePendingSendForRetry( + messageID: UUID, + dto: PendingSendDTO + ) async throws -> Int { + let scopedMessageID = messageID + let pendingPredicate = #Predicate { row in + row.messageID == scopedMessageID + } + let existing = try modelContext.fetch(FetchDescriptor(predicate: pendingPredicate)) + for row in existing { + modelContext.delete(row) } - /// Atomically replaces any existing `PendingSend` rows for the given - /// `messageID`, flips the matching `Message.status` to `.pending` - /// (unless already `.delivered`), and inserts the new `PendingSendDTO`. - /// All three operations land in a single `modelContext.save()` so a - /// crash mid-call cannot leave the row at `.pending` with no - /// `PendingSend` available for replay. The manual DM retry path uses - /// this to keep the queue authoritative for retries while the prior - /// multi-await sequence (delete + status flip + enqueue) had crash - /// windows that stranded `.pending` rows. - /// - /// `.delivered` is intentionally preserved — a late-arriving ACK that - /// landed while the user was reaching for the retry button must not be - /// clobbered. The `PendingSend` row is still inserted; the queue's - /// drain-time `hasPendingSend` gate notices the row but `MessageService` - /// short-circuits the wire send for an already-delivered message. - /// - /// Returns the assigned per-radio sequence number, matching the - /// `insertPendingSendAssigningSequence(_:)` contract. - public func replacePendingSendForRetry( - messageID: UUID, - dto: PendingSendDTO - ) async throws -> Int { - let scopedMessageID = messageID - let pendingPredicate = #Predicate { row in - row.messageID == scopedMessageID - } - let existing = try modelContext.fetch(FetchDescriptor(predicate: pendingPredicate)) - for row in existing { - modelContext.delete(row) - } + let messagePredicate = #Predicate { message in + message.id == scopedMessageID + } + var messageDescriptor = FetchDescriptor(predicate: messagePredicate) + messageDescriptor.fetchLimit = 1 + if let message = try modelContext.fetch(messageDescriptor).first, + message.status != .delivered { + message.status = .pending + } - let messagePredicate = #Predicate { message in - message.id == scopedMessageID - } - var messageDescriptor = FetchDescriptor(predicate: messagePredicate) - messageDescriptor.fetchLimit = 1 - if let message = try modelContext.fetch(messageDescriptor).first, - message.status != .delivered { - message.status = .pending - } + let scopedRadioID = dto.radioID + let radioPredicate = #Predicate { row in + row.radioID == scopedRadioID + } + var seqDescriptor = FetchDescriptor( + predicate: radioPredicate, + sortBy: [SortDescriptor(\.sequence, order: .reverse)] + ) + seqDescriptor.fetchLimit = 1 + let latest = try modelContext.fetch(seqDescriptor).first + let assignedSequence = (latest?.sequence ?? 0) + 1 - let scopedRadioID = dto.radioID - let radioPredicate = #Predicate { row in - row.radioID == scopedRadioID - } - var seqDescriptor = FetchDescriptor( - predicate: radioPredicate, - sortBy: [SortDescriptor(\.sequence, order: .reverse)] - ) - seqDescriptor.fetchLimit = 1 - let latest = try modelContext.fetch(seqDescriptor).first - let assignedSequence = (latest?.sequence ?? 0) + 1 + let inserted = PendingSend(dto: dto) + inserted.sequence = assignedSequence + modelContext.insert(inserted) - let inserted = PendingSend(dto: dto) - inserted.sequence = assignedSequence - modelContext.insert(inserted) + try modelContext.save() + return assignedSequence + } - try modelContext.save() - return assignedSequence + /// Fetch all pending sends for a given radio, ordered by sequence + /// ascending. Rows whose `kindRawValue` does not resolve to a known + /// `PendingSendKind` case are skipped and logged at warning level — + /// such a row could only exist after a downgrade from a future build + /// that introduced a new case, and silently routing it to `.dm` would + /// strand the row on disk via the envelope-materialization nil guard. + func fetchPendingSends(radioID: UUID) throws -> [PendingSendDTO] { + let scopedRadioID = radioID + let predicate = #Predicate { row in + row.radioID == scopedRadioID } - - /// Fetch all pending sends for a given radio, ordered by sequence - /// ascending. Rows whose `kindRawValue` does not resolve to a known - /// `PendingSendKind` case are skipped and logged at warning level — - /// such a row could only exist after a downgrade from a future build - /// that introduced a new case, and silently routing it to `.dm` would - /// strand the row on disk via the envelope-materialization nil guard. - public func fetchPendingSends(radioID: UUID) throws -> [PendingSendDTO] { - let scopedRadioID = radioID - let predicate = #Predicate { row in - row.radioID == scopedRadioID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.sequence, order: .forward)] + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.sequence, order: .forward)] + ) + return try modelContext.fetch(descriptor).compactMap { row in + guard PendingSendKind(rawValue: row.kindRawValue) != nil else { + Self.pendingSendLogger.warning( + "Skipping PendingSend \(row.id, privacy: .public) with unknown kindRawValue=\(row.kindRawValue, privacy: .public)" ) - return try modelContext.fetch(descriptor).compactMap { row in - guard PendingSendKind(rawValue: row.kindRawValue) != nil else { - Self.pendingSendLogger.warning( - "Skipping PendingSend \(row.id, privacy: .public) with unknown kindRawValue=\(row.kindRawValue, privacy: .public)" - ) - return nil - } - return row.toDTO() - } + return nil + } + return row.toDTO() } + } - /// Delete a pending send by row id. No-op if the id is not present. - public func deletePendingSend(id: UUID) throws { - let target = id - let predicate = #Predicate { row in - row.id == target - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - if let row = try modelContext.fetch(descriptor).first { - modelContext.delete(row) - try modelContext.save() - } + /// Delete a pending send by row id. No-op if the id is not present. + func deletePendingSend(id: UUID) throws { + let target = id + let predicate = #Predicate { row in + row.id == target } - - /// Delete every `PendingSend` row whose `radioID` does not match any - /// known `Device` row. Returns the number of rows deleted. An orphan row - /// can never drain (`fetchPendingSends(radioID:)` filters by radio and - /// the radio is gone), so leaving it on disk only wastes space. - /// - /// **Precondition (callers must satisfy):** every paired Device row, - /// including the one currently being constructed, must already be - /// persisted before this runs. The production wiring at - /// `ConnectionManager.buildServicesAndSaveDevice` saves the Device row - /// before invoking warmUp, so this precondition holds for the in-progress - /// radio as well as every previously-paired one. The doc-comment exists - /// to head off future refactors that might invoke warmUp ahead of the - /// Device save — in that order, an in-flight new-pair Device whose row - /// hasn't been flushed yet would have its enqueued PendingSends wrongly - /// classified as orphans. - @discardableResult - public func purgeOrphanPendingSends() throws -> Int { - let knownRadioIDs = try Set(modelContext.fetch(FetchDescriptor()).map(\.radioID)) - let allRows = try modelContext.fetch(FetchDescriptor()) - let orphans = allRows.filter { !knownRadioIDs.contains($0.radioID) } - guard !orphans.isEmpty else { return 0 } - for row in orphans { - modelContext.delete(row) - } - try modelContext.save() - Self.pendingSendLogger.info("Purged \(orphans.count, privacy: .public) orphan PendingSend rows") - return orphans.count + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let row = try modelContext.fetch(descriptor).first { + modelContext.delete(row) + try modelContext.save() } + } - /// Delete every pending send row whose `messageID` matches. No-op if no - /// rows match. Used at drain time when only the envelope's `messageID` - /// is available; the radio is intentionally not part of the predicate - /// because the user can switch conversations (and therefore the radio - /// in scope) between enqueue and drain, and the same `messageID` may - /// be enqueued more than once on the resend path. `messageID` is a - /// UUID so cross-radio collision is effectively zero. - public func deletePendingSendsForMessage(messageID: UUID) throws { - let scopedMessageID = messageID - let predicate = #Predicate { row in - row.messageID == scopedMessageID - } - let descriptor = FetchDescriptor(predicate: predicate) - let rows = try modelContext.fetch(descriptor) - guard !rows.isEmpty else { return } - for row in rows { - modelContext.delete(row) - } - try modelContext.save() + /// Delete every `PendingSend` row whose `radioID` does not match any + /// known `Device` row. Returns the number of rows deleted. An orphan row + /// can never drain (`fetchPendingSends(radioID:)` filters by radio and + /// the radio is gone), so leaving it on disk only wastes space. + /// + /// **Precondition (callers must satisfy):** every paired Device row, + /// including the one currently being constructed, must already be + /// persisted before this runs. The production wiring at + /// `ConnectionManager.buildServicesAndSaveDevice` saves the Device row + /// before invoking warmUp, so this precondition holds for the in-progress + /// radio as well as every previously-paired one. The doc-comment exists + /// to head off future refactors that might invoke warmUp ahead of the + /// Device save — in that order, an in-flight new-pair Device whose row + /// hasn't been flushed yet would have its enqueued PendingSends wrongly + /// classified as orphans. + @discardableResult + func purgeOrphanPendingSends() throws -> Int { + let knownRadioIDs = try Set(modelContext.fetch(FetchDescriptor()).map(\.radioID)) + let allRows = try modelContext.fetch(FetchDescriptor()) + let orphans = allRows.filter { !knownRadioIDs.contains($0.radioID) } + guard !orphans.isEmpty else { return 0 } + for row in orphans { + modelContext.delete(row) } + try modelContext.save() + Self.pendingSendLogger.info("Purged \(orphans.count, privacy: .public) orphan PendingSend rows") + return orphans.count + } - /// Bulk non-saving cascade helper. Caller owns the single transactional - /// save. Issues one `delete(model:where:)` per chunk so wiping thousands - /// of messages costs O(chunks) bulk ops rather than O(n) fetches. - /// - /// **Cascade atomicity:** the cascade is serial within `@ModelActor` - /// (`PersistenceStore`'s actor isolation serialises awaits), not - /// transactional in the SQL sense. A crash mid-cascade leaves a - /// partially-deleted state on disk; the next `purgeOrphanPendingSends` - /// + hydrate cycle reconciles. Callers requiring strict atomicity stage - /// every delete in this function and call `save()` exactly once at the - /// end (the convention every cascade site in this codebase follows). - /// - /// **Chunking:** SwiftData translates `contains` predicates to SQL - /// `IN (?, ?, …)`. SQLite's default `SQLITE_MAX_VARIABLE_NUMBER` on - /// iOS 18+ is 32766, so wiping a radio with >32k messages would hit - /// the ceiling unchunked. 500 keeps headroom and keeps the predicate - /// compact for SwiftData's compile-time validation. - /// - /// **`@Relationship` cascade bypass:** bulk `delete(model:where:)` - /// skips the SwiftData change-tracking pipeline, so - /// `@Relationship(deleteRule: .cascade)` declarations do not fire on - /// these paths. Any future cascade-by-relationship added to `Message` - /// must be mirrored explicitly in every cascade site that bulk-deletes - /// messages. - internal func _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: [UUID]) throws { - guard !messageIDs.isEmpty else { return } - let chunkSize = 500 - for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { - let chunk = Array(messageIDs[start.. { row in + row.messageID == scopedMessageID + } + let descriptor = FetchDescriptor(predicate: predicate) + let rows = try modelContext.fetch(descriptor) + guard !rows.isEmpty else { return } + for row in rows { + modelContext.delete(row) } + try modelContext.save() + } - public func hasPendingSend(messageID: UUID) throws -> Bool { - let scopedMessageID = messageID - let predicate = #Predicate { row in - row.messageID == scopedMessageID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try !modelContext.fetch(descriptor).isEmpty + /// Bulk non-saving cascade helper. Caller owns the single transactional + /// save. Issues one `delete(model:where:)` per chunk so wiping thousands + /// of messages costs O(chunks) bulk ops rather than O(n) fetches. + /// + /// **Cascade atomicity:** the cascade is serial within `@ModelActor` + /// (`PersistenceStore`'s actor isolation serialises awaits), not + /// transactional in the SQL sense. A crash mid-cascade leaves a + /// partially-deleted state on disk; the next `purgeOrphanPendingSends` + /// + hydrate cycle reconciles. Callers requiring strict atomicity stage + /// every delete in this function and call `save()` exactly once at the + /// end (the convention every cascade site in this codebase follows). + /// + /// **Chunking:** SwiftData translates `contains` predicates to SQL + /// `IN (?, ?, …)`. SQLite's default `SQLITE_MAX_VARIABLE_NUMBER` on + /// iOS 18+ is 32766, so wiping a radio with >32k messages would hit + /// the ceiling unchunked. 500 keeps headroom and keeps the predicate + /// compact for SwiftData's compile-time validation. + /// + /// **`@Relationship` cascade bypass:** bulk `delete(model:where:)` + /// skips the SwiftData change-tracking pipeline, so + /// `@Relationship(deleteRule: .cascade)` declarations do not fire on + /// these paths. Any future cascade-by-relationship added to `Message` + /// must be mirrored explicitly in every cascade site that bulk-deletes + /// messages. + internal func _deletePendingSendsForMessageIDsWithoutSaving(messageIDs: [UUID]) throws { + guard !messageIDs.isEmpty else { return } + let chunkSize = 500 + for start in stride(from: 0, to: messageIDs.count, by: chunkSize) { + let chunk = Array(messageIDs[start.. [PendingSendDTO] { - let scopedMessageID = messageID - let predicate = #Predicate { row in - row.messageID == scopedMessageID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.sequence, order: .forward)] - ) - return try modelContext.fetch(descriptor).compactMap { row in - guard PendingSendKind(rawValue: row.kindRawValue) != nil else { return nil } - return row.toDTO() - } + func hasPendingSend(messageID: UUID) throws -> Bool { + let scopedMessageID = messageID + let predicate = #Predicate { row in + row.messageID == scopedMessageID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try !modelContext.fetch(descriptor).isEmpty + } - /// Deletes any `PendingSend` row where `attemptCount` is `nil`. Such rows - /// can only exist via lightweight migration from a schema version that - /// predates `attemptCount`; their drain history is ambiguous, so the user - /// re-sends if they care. - /// - /// Idempotent: an empty fetch after the first purge across the lifetime of - /// the storage. - /// - /// Called from `PersistenceStore.warmUp()` on every connect. - @discardableResult - public func purgeLegacyAttemptCountRows() throws -> Int { - let nilPredicate = #Predicate { row in - row.attemptCount == nil - } - let rows = try modelContext.fetch(FetchDescriptor(predicate: nilPredicate)) - guard !rows.isEmpty else { return 0 } - for row in rows { modelContext.delete(row) } - try modelContext.save() - Self.pendingSendLogger.notice( - "purgeLegacyAttemptCountRows deleted \(rows.count, privacy: .public) row(s) with attemptCount=nil" - ) - return rows.count + /// Fetch every `PendingSend` row that matches a given message ID, ordered + /// by sequence ascending. Returns DTOs for cross-actor safety. Rows whose + /// `kindRawValue` does not resolve to a known `PendingSendKind` case are + /// skipped, matching the contract of `fetchPendingSends(radioID:)`. + /// + /// Symmetric with `deletePendingSendsForMessage(messageID:)` — both ignore + /// the radio because the user can switch radios between enqueue and + /// observation, and a UUID `messageID` makes cross-radio collision + /// effectively zero. + func fetchPendingSendsForMessage(messageID: UUID) throws -> [PendingSendDTO] { + let scopedMessageID = messageID + let predicate = #Predicate { row in + row.messageID == scopedMessageID + } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.sequence, order: .forward)] + ) + return try modelContext.fetch(descriptor).compactMap { row in + guard PendingSendKind(rawValue: row.kindRawValue) != nil else { return nil } + return row.toDTO() } + } - /// Increments the `attemptCount` for the `PendingSend` row matching - /// `messageID`. Returns the new count, or `nil` if no row matched. - /// Throws on SwiftData read/write failure — callers must treat a thrown - /// error as transient (park + retry) and `nil` as terminal (deleted row). - /// - /// `warmUp`'s purge runs before hydrate, so a `nil` row reaching increment - /// time would indicate the purge step was skipped (programmer error). The - /// `?? 0` fallback degrades gracefully without pretending the row had - /// prior drain attempts. - @discardableResult - public func incrementPendingSendAttemptCount(messageID: UUID) throws -> Int? { - #if DEBUG - try incrementPendingSendAttemptCountFaultInjection?() - #endif - let scopedMessageID = messageID - let predicate = #Predicate { row in - row.messageID == scopedMessageID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let row = try modelContext.fetch(descriptor).first else { return nil } - let nextCount = (row.attemptCount ?? 0) + 1 - row.attemptCount = nextCount - try modelContext.save() - return nextCount + /// Deletes any `PendingSend` row where `attemptCount` is `nil`. Such rows + /// can only exist via lightweight migration from a schema version that + /// predates `attemptCount`; their drain history is ambiguous, so the user + /// re-sends if they care. + /// + /// Idempotent: an empty fetch after the first purge across the lifetime of + /// the storage. + /// + /// Called from `PersistenceStore.warmUp()` on every connect. + @discardableResult + func purgeLegacyAttemptCountRows() throws -> Int { + let nilPredicate = #Predicate { row in + row.attemptCount == nil } + let rows = try modelContext.fetch(FetchDescriptor(predicate: nilPredicate)) + guard !rows.isEmpty else { return 0 } + for row in rows { + modelContext.delete(row) + } + try modelContext.save() + Self.pendingSendLogger.notice( + "purgeLegacyAttemptCountRows deleted \(rows.count, privacy: .public) row(s) with attemptCount=nil" + ) + return rows.count + } + /// Increments the `attemptCount` for the `PendingSend` row matching + /// `messageID`. Returns the new count, or `nil` if no row matched. + /// Throws on SwiftData read/write failure — callers must treat a thrown + /// error as transient (park + retry) and `nil` as terminal (deleted row). + /// + /// `warmUp`'s purge runs before hydrate, so a `nil` row reaching increment + /// time would indicate the purge step was skipped (programmer error). The + /// `?? 0` fallback degrades gracefully without pretending the row had + /// prior drain attempts. + @discardableResult + func incrementPendingSendAttemptCount(messageID: UUID) throws -> Int? { #if DEBUG - public func setIncrementPendingSendAttemptCountFaultInjection(_ hook: (@Sendable () throws -> Void)?) { - incrementPendingSendAttemptCountFaultInjection = hook - } + try incrementPendingSendAttemptCountFaultInjection?() #endif + let scopedMessageID = messageID + let predicate = #Predicate { row in + row.messageID == scopedMessageID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let row = try modelContext.fetch(descriptor).first else { return nil } + let nextCount = (row.attemptCount ?? 0) + 1 + row.attemptCount = nextCount + try modelContext.save() + return nextCount + } + + #if DEBUG + func setIncrementPendingSendAttemptCountFaultInjection(_ hook: (@Sendable () throws -> Void)?) { + incrementPendingSendAttemptCountFaultInjection = hook + } + #endif } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift index ff654d79..58ca7c80 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift @@ -2,422 +2,421 @@ import Foundation import os import SwiftData -extension PersistenceStore { - - // MARK: - RemoteNodeSession Operations - - /// Fetch remote node session by UUID - public func fetchRemoteNodeSession(id: UUID) throws -> RemoteNodeSessionDTO? { - let targetID = id - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { RemoteNodeSessionDTO(from: $0) } +public extension PersistenceStore { + // MARK: - RemoteNodeSession Operations + + /// Fetch remote node session by UUID + func fetchRemoteNodeSession(id: UUID) throws -> RemoteNodeSessionDTO? { + let targetID = id + let predicate = #Predicate { session in + session.id == targetID } - - /// Fetch remote node session by 32-byte public key - public func fetchRemoteNodeSession(publicKey: Data) throws -> RemoteNodeSessionDTO? { - let targetKey = publicKey - let predicate = #Predicate { session in - session.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map { RemoteNodeSessionDTO(from: $0) } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { RemoteNodeSessionDTO(from: $0) } + } + + /// Fetch remote node session by 32-byte public key + func fetchRemoteNodeSession(publicKey: Data) throws -> RemoteNodeSessionDTO? { + let targetKey = publicKey + let predicate = #Predicate { session in + session.publicKey == targetKey } - - /// Fetch remote node session by 6-byte public key prefix - public func fetchRemoteNodeSessionByPrefix(_ prefix: Data) throws -> RemoteNodeSessionDTO? { - // SwiftData predicates don't support prefix matching directly - // Fetch all sessions and filter in memory - let sessions = try modelContext.fetch(FetchDescriptor()) - return sessions.first { $0.publicKey.prefix(6) == prefix }.map { RemoteNodeSessionDTO(from: $0) } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map { RemoteNodeSessionDTO(from: $0) } + } + + /// Fetch remote node session by 6-byte public key prefix + func fetchRemoteNodeSessionByPrefix(_ prefix: Data) throws -> RemoteNodeSessionDTO? { + // SwiftData predicates don't support prefix matching directly + // Fetch all sessions and filter in memory + let sessions = try modelContext.fetch(FetchDescriptor()) + return sessions.first { $0.publicKey.prefix(6) == prefix }.map { RemoteNodeSessionDTO(from: $0) } + } + + /// Fetch all connected sessions for re-authentication after BLE reconnection + func fetchConnectedRemoteNodeSessions() throws -> [RemoteNodeSessionDTO] { + let predicate = #Predicate { session in + session.isConnected == true } - - /// Fetch all connected sessions for re-authentication after BLE reconnection - public func fetchConnectedRemoteNodeSessions() throws -> [RemoteNodeSessionDTO] { - let predicate = #Predicate { session in - session.isConnected == true - } - let descriptor = FetchDescriptor(predicate: predicate) - let sessions = try modelContext.fetch(descriptor) - return sessions.map { RemoteNodeSessionDTO(from: $0) } + let descriptor = FetchDescriptor(predicate: predicate) + let sessions = try modelContext.fetch(descriptor) + return sessions.map { RemoteNodeSessionDTO(from: $0) } + } + + /// Fetch all remote node sessions for a device + func fetchRemoteNodeSessions(radioID: UUID) throws -> [RemoteNodeSessionDTO] { + let targetRadioID = radioID + let predicate = #Predicate { session in + session.radioID == targetRadioID } - - /// Fetch all remote node sessions for a device - public func fetchRemoteNodeSessions(radioID: UUID) throws -> [RemoteNodeSessionDTO] { - let targetRadioID = radioID - let predicate = #Predicate { session in - session.radioID == targetRadioID - } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\RemoteNodeSession.name)] - ) - let sessions = try modelContext.fetch(descriptor) - return sessions.map { RemoteNodeSessionDTO(from: $0) } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\RemoteNodeSession.name)] + ) + let sessions = try modelContext.fetch(descriptor) + return sessions.map { RemoteNodeSessionDTO(from: $0) } + } + + /// Save or update a remote node session (void version for cross-actor calls) + func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) throws { + _ = try saveRemoteNodeSession(dto) + } + + /// Save or update a remote node session + @discardableResult + private func saveRemoteNodeSession(_ dto: RemoteNodeSessionDTO) throws -> RemoteNodeSession { + let targetID = dto.id + let predicate = #Predicate { session in + session.id == targetID } - - /// Save or update a remote node session (void version for cross-actor calls) - public func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) throws { - _ = try saveRemoteNodeSession(dto) + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let existing = try modelContext.fetch(descriptor).first { + existing.apply(dto) + try modelContext.save() + return existing + } else { + let session = RemoteNodeSession(dto: dto) + modelContext.insert(session) + try modelContext.save() + return session } - - /// Save or update a remote node session - @discardableResult - private func saveRemoteNodeSession(_ dto: RemoteNodeSessionDTO) throws -> RemoteNodeSession { - let targetID = dto.id - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let existing = try modelContext.fetch(descriptor).first { - existing.apply(dto) - try modelContext.save() - return existing - } else { - let session = RemoteNodeSession(dto: dto) - modelContext.insert(session) - try modelContext.save() - return session - } + } + + /// Update session connection state + func updateRemoteNodeSessionConnection( + id: UUID, + isConnected: Bool, + permissionLevel: RoomPermissionLevel + ) throws { + let targetID = id + let predicate = #Predicate { session in + session.id == targetID } - - /// Update session connection state - public func updateRemoteNodeSessionConnection( - id: UUID, - isConnected: Bool, - permissionLevel: RoomPermissionLevel - ) throws { - let targetID = id - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let session = try modelContext.fetch(descriptor).first { - session.isConnected = isConnected - session.permissionLevelRawValue = permissionLevel.rawValue - if isConnected { - session.lastConnectedDate = Date() - } - try modelContext.save() - } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let session = try modelContext.fetch(descriptor).first { + session.isConnected = isConnected + session.permissionLevelRawValue = permissionLevel.rawValue + if isConnected { + session.lastConnectedDate = Date() + } + try modelContext.save() } - - /// Reset all remote node sessions to disconnected state. - /// Call this on app launch since connections don't persist across restarts. - public func resetAllRemoteNodeSessionConnections() throws { - let descriptor = FetchDescriptor() - let sessions = try modelContext.fetch(descriptor) - for session in sessions { - session.isConnected = false - } - try modelContext.save() + } + + /// Reset all remote node sessions to disconnected state. + /// Call this on app launch since connections don't persist across restarts. + func resetAllRemoteNodeSessionConnections() throws { + let descriptor = FetchDescriptor() + let sessions = try modelContext.fetch(descriptor) + for session in sessions { + session.isConnected = false } - - /// Clean up duplicate remote node sessions with the same public key. - /// Keeps the session with the specified ID and deletes any others. - /// This prevents stale sessions from causing connection state issues. - public func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) throws { - let targetKey = publicKey - let targetKeepID = keepID - let predicate = #Predicate { session in - session.publicKey == targetKey && session.id != targetKeepID - } - let duplicates = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - - if !duplicates.isEmpty { - let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") - logger.warning("Found \(duplicates.count) duplicate session(s) for public key, cleaning up") - - for duplicate in duplicates { - // Delete associated room messages first - let duplicateID = duplicate.id - let messagePredicate = #Predicate { message in - message.sessionID == duplicateID - } - let messages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) - for message in messages { - modelContext.delete(message) - } - - modelContext.delete(duplicate) - } - try modelContext.save() - } + try modelContext.save() + } + + /// Clean up duplicate remote node sessions with the same public key. + /// Keeps the session with the specified ID and deletes any others. + /// This prevents stale sessions from causing connection state issues. + func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) throws { + let targetKey = publicKey + let targetKeepID = keepID + let predicate = #Predicate { session in + session.publicKey == targetKey && session.id != targetKeepID } + let duplicates = try modelContext.fetch(FetchDescriptor(predicate: predicate)) - /// Delete remote node session and all associated room messages - public func deleteRemoteNodeSession(id: UUID) throws { - let targetID = id + if !duplicates.isEmpty { + let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") + logger.warning("Found \(duplicates.count) duplicate session(s) for public key, cleaning up") - // Delete associated room messages + for duplicate in duplicates { + // Delete associated room messages first + let duplicateID = duplicate.id let messagePredicate = #Predicate { message in - message.sessionID == targetID + message.sessionID == duplicateID } let messages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) for message in messages { - modelContext.delete(message) + modelContext.delete(message) } - // Delete the session - let sessionPredicate = #Predicate { session in - session.id == targetID - } - if let session = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)).first { - modelContext.delete(session) - } - - try modelContext.save() + modelContext.delete(duplicate) + } + try modelContext.save() } + } - /// Mark a room session as connected. Called when an incoming message proves - /// the session is active. Only sets isConnected; does not change permissionLevel. - /// - Returns: true if the session was actually changed (was disconnected, now connected). - @discardableResult - public func markRoomSessionConnected(_ sessionID: UUID) throws -> Bool { - let targetID = sessionID - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let session = try modelContext.fetch(descriptor).first else { return false } - guard !session.isConnected else { return false } + /// Delete remote node session and all associated room messages + func deleteRemoteNodeSession(id: UUID) throws { + let targetID = id - session.isConnected = true - try modelContext.save() - return true + // Delete associated room messages + let messagePredicate = #Predicate { message in + message.sessionID == targetID + } + let messages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) + for message in messages { + modelContext.delete(message) } - /// Mark a session as disconnected without changing permission level. - /// Use for transient disconnections (BLE drop, keep-alive failure, re-auth failure). - /// Only logout() should reset permissionLevel to .guest. - public func markSessionDisconnected(_ sessionID: UUID) throws { - let targetID = sessionID - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + // Delete the session + let sessionPredicate = #Predicate { session in + session.id == targetID + } + if let session = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)).first { + modelContext.delete(session) + } - if let session = try modelContext.fetch(descriptor).first { - session.isConnected = false - try modelContext.save() - } + try modelContext.save() + } + + /// Mark a room session as connected. Called when an incoming message proves + /// the session is active. Only sets isConnected; does not change permissionLevel. + /// - Returns: true if the session was actually changed (was disconnected, now connected). + @discardableResult + func markRoomSessionConnected(_ sessionID: UUID) throws -> Bool { + let targetID = sessionID + let predicate = #Predicate { session in + session.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let session = try modelContext.fetch(descriptor).first else { return false } + guard !session.isConnected else { return false } + + session.isConnected = true + try modelContext.save() + return true + } + + /// Mark a session as disconnected without changing permission level. + /// Use for transient disconnections (BLE drop, keep-alive failure, re-auth failure). + /// Only logout() should reset permissionLevel to .guest. + func markSessionDisconnected(_ sessionID: UUID) throws { + let targetID = sessionID + let predicate = #Predicate { session in + session.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - /// Update room activity timestamps. - /// - Parameters: - /// - sessionID: The room session ID - /// - syncTimestamp: Optional sender-clock timestamp for sync bookmark advancement. - /// Only provided on the receive path. Omit on the send path to avoid clock skew - /// issues where local send timestamps could advance the sync bookmark past - /// messages the server hasn't delivered yet. - public func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32? = nil) throws { - let targetID = sessionID - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - if let session = try modelContext.fetch(descriptor).first { - if let syncTimestamp, syncTimestamp > session.lastSyncTimestamp { - session.lastSyncTimestamp = syncTimestamp - } - session.lastMessageDate = Date() - try modelContext.save() - } + if let session = try modelContext.fetch(descriptor).first { + session.isConnected = false + try modelContext.save() } + } + + /// Update room activity timestamps. + /// - Parameters: + /// - sessionID: The room session ID + /// - syncTimestamp: Optional sender-clock timestamp for sync bookmark advancement. + /// Only provided on the receive path. Omit on the send path to avoid clock skew + /// issues where local send timestamps could advance the sync bookmark past + /// messages the server hasn't delivered yet. + func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32? = nil) throws { + let targetID = sessionID + let predicate = #Predicate { session in + session.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let session = try modelContext.fetch(descriptor).first { + if let syncTimestamp, syncTimestamp > session.lastSyncTimestamp { + session.lastSyncTimestamp = syncTimestamp + } + session.lastMessageDate = Date() + try modelContext.save() + } + } - // MARK: - RoomMessage Operations + // MARK: - RoomMessage Operations - /// Check for duplicate room message using deduplication key - public func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) throws -> Bool { - let targetSessionID = sessionID - let targetKey = deduplicationKey - let predicate = #Predicate { message in - message.sessionID == targetSessionID && message.deduplicationKey == targetKey - } - return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + /// Check for duplicate room message using deduplication key + func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) throws -> Bool { + let targetSessionID = sessionID + let targetKey = deduplicationKey + let predicate = #Predicate { message in + message.sessionID == targetSessionID && message.deduplicationKey == targetKey + } + return try modelContext.fetchCount(FetchDescriptor(predicate: predicate)) > 0 + } + + /// Save room message (checks deduplication automatically) + func saveRoomMessage(_ dto: RoomMessageDTO) throws { + // Check for duplicate first + if try isDuplicateRoomMessage(sessionID: dto.sessionID, deduplicationKey: dto.deduplicationKey) { + return // Silently ignore duplicates } - /// Save room message (checks deduplication automatically) - public func saveRoomMessage(_ dto: RoomMessageDTO) throws { - // Check for duplicate first - if try isDuplicateRoomMessage(sessionID: dto.sessionID, deduplicationKey: dto.deduplicationKey) { - return // Silently ignore duplicates - } + modelContext.insert(RoomMessage(dto: dto)) + try modelContext.save() + } - modelContext.insert(RoomMessage(dto: dto)) - try modelContext.save() + /// Fetch a room message by ID + func fetchRoomMessage(id: UUID) throws -> RoomMessageDTO? { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let message = try modelContext.fetch(descriptor).first else { + return nil + } + return RoomMessageDTO(from: message) + } + + /// Update room message status after send attempt + func updateRoomMessageStatus( + id: UUID, + status: MessageStatus, + ackCode: UInt32? = nil, + roundTripTime: UInt32? = nil + ) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let message = try modelContext.fetch(descriptor).first else { + return + } + message.statusRawValue = status.rawValue + if let ackCode { + message.ackCode = ackCode } + if let roundTripTime { + message.roundTripTime = roundTripTime + } + try modelContext.save() + } + + /// Update room message retry status + func updateRoomMessageRetryStatus( + id: UUID, + status: MessageStatus, + retryAttempt: Int, + maxRetryAttempts: Int + ) throws { + let targetID = id + let predicate = #Predicate { message in + message.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let message = try modelContext.fetch(descriptor).first else { + return + } + message.statusRawValue = status.rawValue + message.retryAttempt = retryAttempt + message.maxRetryAttempts = maxRetryAttempts + try modelContext.save() + } + + /// Increment unread message count for a room session + func incrementRoomUnreadCount(_ sessionID: UUID) throws { + let targetID = sessionID + let predicate = #Predicate { session in + session.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - /// Fetch a room message by ID - public func fetchRoomMessage(id: UUID) throws -> RoomMessageDTO? { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let message = try modelContext.fetch(descriptor).first else { - return nil - } - return RoomMessageDTO(from: message) - } - - /// Update room message status after send attempt - public func updateRoomMessageStatus( - id: UUID, - status: MessageStatus, - ackCode: UInt32? = nil, - roundTripTime: UInt32? = nil - ) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let message = try modelContext.fetch(descriptor).first else { - return - } - message.statusRawValue = status.rawValue - if let ackCode { - message.ackCode = ackCode - } - if let roundTripTime { - message.roundTripTime = roundTripTime - } - try modelContext.save() - } - - /// Update room message retry status - public func updateRoomMessageRetryStatus( - id: UUID, - status: MessageStatus, - retryAttempt: Int, - maxRetryAttempts: Int - ) throws { - let targetID = id - let predicate = #Predicate { message in - message.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let message = try modelContext.fetch(descriptor).first else { - return - } - message.statusRawValue = status.rawValue - message.retryAttempt = retryAttempt - message.maxRetryAttempts = maxRetryAttempts - try modelContext.save() + if let session = try modelContext.fetch(descriptor).first { + session.unreadCount += 1 + try modelContext.save() } + } - /// Increment unread message count for a room session - public func incrementRoomUnreadCount(_ sessionID: UUID) throws { - let targetID = sessionID - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + /// Reset unread count to zero (called when user views conversation) + func resetRoomUnreadCount(_ sessionID: UUID) throws { + let targetID = sessionID + let predicate = #Predicate { session in + session.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - if let session = try modelContext.fetch(descriptor).first { - session.unreadCount += 1 - try modelContext.save() - } + if let session = try modelContext.fetch(descriptor).first { + session.unreadCount = 0 + try modelContext.save() } + } - /// Reset unread count to zero (called when user views conversation) - public func resetRoomUnreadCount(_ sessionID: UUID) throws { - let targetID = sessionID - let predicate = #Predicate { session in - session.id == targetID - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + /// Sets the muted state for a remote node session + func setSessionMuted(_ sessionID: UUID, isMuted: Bool) throws { + let targetID = sessionID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - if let session = try modelContext.fetch(descriptor).first { - session.unreadCount = 0 - try modelContext.save() - } + guard let session = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.remoteNodeSessionNotFound } - /// Sets the muted state for a remote node session - public func setSessionMuted(_ sessionID: UUID, isMuted: Bool) throws { - let targetID = sessionID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + session.notificationLevel = isMuted ? .muted : .all + try modelContext.save() + } - guard let session = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.remoteNodeSessionNotFound - } + /// Sets the notification level for a remote node session + func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) throws { + let targetID = sessionID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - session.notificationLevel = isMuted ? .muted : .all - try modelContext.save() + guard let session = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.remoteNodeSessionNotFound } - /// Sets the notification level for a remote node session - public func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) throws { - let targetID = sessionID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 + session.notificationLevel = level + try modelContext.save() + } - guard let session = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.remoteNodeSessionNotFound - } + /// Sets the favorite state for a remote node session + func setSessionFavorite(_ sessionID: UUID, isFavorite: Bool) throws { + let targetID = sessionID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 - session.notificationLevel = level - try modelContext.save() + guard let session = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.remoteNodeSessionNotFound } - /// Sets the favorite state for a remote node session - public func setSessionFavorite(_ sessionID: UUID, isFavorite: Bool) throws { - let targetID = sessionID - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - - guard let session = try modelContext.fetch(descriptor).first else { - throw PersistenceStoreError.remoteNodeSessionNotFound - } + session.isFavorite = isFavorite + try modelContext.save() + } - session.isFavorite = isFavorite - try modelContext.save() + /// Fetch room messages for a session, ordered by timestamp + func fetchRoomMessages(sessionID: UUID, limit: Int? = nil, offset: Int? = nil) throws -> [RoomMessageDTO] { + let targetSessionID = sessionID + let predicate = #Predicate { message in + message.sessionID == targetSessionID } - - /// Fetch room messages for a session, ordered by timestamp - public func fetchRoomMessages(sessionID: UUID, limit: Int? = nil, offset: Int? = nil) throws -> [RoomMessageDTO] { - let targetSessionID = sessionID - let predicate = #Predicate { message in - message.sessionID == targetSessionID - } - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\RoomMessage.timestamp, order: .forward), - SortDescriptor(\RoomMessage.createdAt, order: .forward) - ] - ) - if let limit { - descriptor.fetchLimit = limit - } - if let offset { - descriptor.fetchOffset = offset - } - let messages = try modelContext.fetch(descriptor) - return messages.map { RoomMessageDTO(from: $0) } + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\RoomMessage.timestamp, order: .forward), + SortDescriptor(\RoomMessage.createdAt, order: .forward) + ] + ) + if let limit { + descriptor.fetchLimit = limit + } + if let offset { + descriptor.fetchOffset = offset } + let messages = try modelContext.fetch(descriptor) + return messages.map { RoomMessageDTO(from: $0) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift index 197d1750..8ac1fb1f 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift @@ -5,29 +5,29 @@ import SwiftData // MARK: - PersistenceStore Errors public enum PersistenceStoreError: Error, Sendable { - case deviceNotFound - case contactNotFound - case messageNotFound - case channelNotFound - case remoteNodeSessionNotFound - case saveFailed(String) - case fetchFailed(String) - case invalidData + case deviceNotFound + case contactNotFound + case messageNotFound + case channelNotFound + case remoteNodeSessionNotFound + case saveFailed(String) + case fetchFailed(String) + case invalidData } extension PersistenceStoreError: LocalizedError { - public var errorDescription: String? { - switch self { - case .deviceNotFound: "Device not found." - case .contactNotFound: "Contact not found." - case .messageNotFound: "Message not found." - case .channelNotFound: "Channel not found." - case .remoteNodeSessionNotFound: "Remote node session not found." - case .saveFailed(let msg): "Failed to save: \(msg)" - case .fetchFailed(let msg): "Failed to fetch: \(msg)" - case .invalidData: "Invalid data." - } + public var errorDescription: String? { + switch self { + case .deviceNotFound: "Device not found." + case .contactNotFound: "Contact not found." + case .messageNotFound: "Message not found." + case .channelNotFound: "Channel not found." + case .remoteNodeSessionNotFound: "Remote node session not found." + case let .saveFailed(msg): "Failed to save: \(msg)" + case let .fetchFailed(msg): "Failed to fetch: \(msg)" + case .invalidData: "Invalid data." } + } } // MARK: - PersistenceStore Actor @@ -36,9 +36,9 @@ extension PersistenceStoreError: LocalizedError { /// Provides per-device data isolation and thread-safe access. @ModelActor public actor PersistenceStore: PersistenceStoreProtocol { - var rxLogEntryCountsByDevice: [UUID: Int] = [:] + var rxLogEntryCountsByDevice: [UUID: Int] = [:] - #if DEBUG + #if DEBUG /// Test-only fault-injection hook fired immediately before `modelContext.save()` /// in `importBackupDatabase`. Debug-only so the production API stays clean. /// SwiftData upserts on unique-constraint conflicts, so there is no reliable @@ -56,274 +56,274 @@ public actor PersistenceStore: PersistenceStoreProtocol { /// closures — a real SwiftData save failure here is otherwise hard to /// provoke from a well-formed row. var incrementPendingSendAttemptCountFaultInjection: (@Sendable () throws -> Void)? - #endif - - /// Shared schema for MeshCore One models - public static let schema = Schema([ - Device.self, - Contact.self, - Message.self, - MessageRepeat.self, - Reaction.self, - Channel.self, - RemoteNodeSession.self, - RoomMessage.self, - SavedTracePath.self, - TracePathRun.self, - RxLogEntry.self, - DebugLogEntry.self, - LinkPreviewData.self, - DiscoveredNode.self, - NodeStatusSnapshot.self, - BlockedChannelSender.self, - PendingSend.self - ]) - - /// Creates a ModelContainer for the app. - /// - /// Schema evolution (no VersionedSchema — handled via lightweight migration): - /// - v1→v2: Contact.outPathLength, DiscoveredNode.outPathLength changed Int8→UInt8 - /// (SQLite INTEGER is identical for both; bit pattern -1 == 0xFF). - /// Added MessageRepeat.pathLength (UInt8, default 0). - /// Added SavedTracePath.hashSize (Int, default 1). - /// - v2→v3: Added PendingSend (new table; no migration impact on existing rows). - /// - v3→v4: Added PendingSend.attemptCount (Int?, default nil). Existing rows - /// lightweight-migrate to NULL; PersistenceStore.warmUp() runs - /// purgeLegacyAttemptCountRows() on connect to delete any nil row. - /// - v4→v5: Added TracePathRun.id uniqueness (run ids are minted once and - /// immutable, and backup import dedupes them store-wide, so existing - /// stores hold no duplicates) and RxLogEntry [radioID, receivedAt] - /// index. - public static func createContainer(inMemory: Bool = false) throws -> ModelContainer { - if !inMemory { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) - } - - let configuration = ModelConfiguration( - schema: schema, - isStoredInMemoryOnly: inMemory, - allowsSave: true - ) - return try ModelContainer(for: schema, configurations: [configuration]) + #endif + + /// Shared schema for MeshCore One models + public static let schema = Schema([ + Device.self, + Contact.self, + Message.self, + MessageRepeat.self, + Reaction.self, + Channel.self, + RemoteNodeSession.self, + RoomMessage.self, + SavedTracePath.self, + TracePathRun.self, + RxLogEntry.self, + DebugLogEntry.self, + LinkPreviewData.self, + DiscoveredNode.self, + NodeStatusSnapshot.self, + BlockedChannelSender.self, + PendingSend.self + ]) + + /// Creates a ModelContainer for the app. + /// + /// Schema evolution (no VersionedSchema — handled via lightweight migration): + /// - v1→v2: Contact.outPathLength, DiscoveredNode.outPathLength changed Int8→UInt8 + /// (SQLite INTEGER is identical for both; bit pattern -1 == 0xFF). + /// Added MessageRepeat.pathLength (UInt8, default 0). + /// Added SavedTracePath.hashSize (Int, default 1). + /// - v2→v3: Added PendingSend (new table; no migration impact on existing rows). + /// - v3→v4: Added PendingSend.attemptCount (Int?, default nil). Existing rows + /// lightweight-migrate to NULL; PersistenceStore.warmUp() runs + /// purgeLegacyAttemptCountRows() on connect to delete any nil row. + /// - v4→v5: Added TracePathRun.id uniqueness (run ids are minted once and + /// immutable, and backup import dedupes them store-wide, so existing + /// stores hold no duplicates) and RxLogEntry [radioID, receivedAt] + /// index. + public static func createContainer(inMemory: Bool = false) throws -> ModelContainer { + if !inMemory { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) } - // MARK: - Database Warm-up - - /// Forces SwiftData to initialize the database. - /// Call this early in app lifecycle to avoid lazy initialization during user operations. - public func warmUp() throws { - // Perform a simple fetch to trigger modelContext initialization - _ = try modelContext.fetchCount(FetchDescriptor()) - - // Both inner operations are idempotent under their own predicates: - // purgeOrphanPendingSends filters by absence of a matching - // Device.radioID and returns an empty row set once Device rows catch - // up; purgeLegacyAttemptCountRows filters by `attemptCount == nil` - // and returns empty after the first purge across the lifetime of - // the storage. Running both on every connect costs an empty fetch and - // self-heals the "erase device then re-pair" path — a process-level - // latch would suppress that recovery. - let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore.warmUp") - do { - try purgeOrphanPendingSends() - } catch { - logger.warning("purgeOrphanPendingSends failed: \(error.localizedDescription, privacy: .public)") - } - do { - try purgeLegacyAttemptCountRows() - } catch { - logger.warning("purgeLegacyAttemptCountRows failed: \(error.localizedDescription, privacy: .public)") - } + let configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: inMemory, + allowsSave: true + ) + return try ModelContainer(for: schema, configurations: [configuration]) + } + + // MARK: - Database Warm-up + + /// Forces SwiftData to initialize the database. + /// Call this early in app lifecycle to avoid lazy initialization during user operations. + public func warmUp() throws { + // Perform a simple fetch to trigger modelContext initialization + _ = try modelContext.fetchCount(FetchDescriptor()) + + // Both inner operations are idempotent under their own predicates: + // purgeOrphanPendingSends filters by absence of a matching + // Device.radioID and returns an empty row set once Device rows catch + // up; purgeLegacyAttemptCountRows filters by `attemptCount == nil` + // and returns empty after the first purge across the lifetime of + // the storage. Running both on every connect costs an empty fetch and + // self-heals the "erase device then re-pair" path — a process-level + // latch would suppress that recovery. + let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore.warmUp") + do { + try purgeOrphanPendingSends() + } catch { + logger.warning("purgeOrphanPendingSends failed: \(error.localizedDescription, privacy: .public)") + } + do { + try purgeLegacyAttemptCountRows() + } catch { + logger.warning("purgeLegacyAttemptCountRows failed: \(error.localizedDescription, privacy: .public)") + } + } + + // MARK: - Discovered Nodes + + private let maxDiscoveredNodes = 1000 + + /// Inbound advert hop counts heard via the RX log before the matching node row exists. + /// The firmware emits the 0x88 RX packet before the 0x80 advert push that creates the row, + /// so a keyed write would otherwise miss on first contact. Buffered here and drained by the + /// upsert when the row lands. Bounded; the partner advert normally drains an entry at once. + private var pendingInboundHops: [PendingHopKey: PendingHop] = [:] + private let maxPendingInboundHops = 256 + + private struct PendingHopKey: Hashable { + let radioID: UUID + let publicKey: Data + } + + private struct PendingHop { + let hopCount: Int + let advertTimestamp: UInt32? + } + + public func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) throws -> (node: DiscoveredNodeDTO, isNew: Bool) { + let targetRadioID = radioID + let publicKey = frame.publicKey + let predicate = #Predicate { node in + node.radioID == targetRadioID && node.publicKey == publicKey + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + let existing = try modelContext.fetch(descriptor) + + let node: DiscoveredNode + let isNew: Bool + if let existingNode = existing.first { + existingNode.name = frame.name + existingNode.typeRawValue = frame.type.rawValue + existingNode.lastHeard = Date() + existingNode.lastAdvertTimestamp = frame.lastAdvertTimestamp + existingNode.latitude = frame.latitude + existingNode.longitude = frame.longitude + existingNode.outPathLength = frame.outPathLength + existingNode.outPath = frame.outPath + node = existingNode + isNew = false + } else { + node = DiscoveredNode( + radioID: radioID, + publicKey: frame.publicKey, + name: frame.name, + typeRawValue: frame.type.rawValue, + lastAdvertTimestamp: frame.lastAdvertTimestamp, + latitude: frame.latitude, + longitude: frame.longitude, + outPathLength: frame.outPathLength, + outPath: frame.outPath + ) + modelContext.insert(node) + isNew = true + + try enforceDiscoveredNodeCap(radioID: radioID) } - // MARK: - Discovered Nodes + applyPendingInboundHop(to: node) - private let maxDiscoveredNodes = 1000 + try modelContext.save() - /// Inbound advert hop counts heard via the RX log before the matching node row exists. - /// The firmware emits the 0x88 RX packet before the 0x80 advert push that creates the row, - /// so a keyed write would otherwise miss on first contact. Buffered here and drained by the - /// upsert when the row lands. Bounded; the partner advert normally drains an entry at once. - private var pendingInboundHops: [PendingHopKey: PendingHop] = [:] - private let maxPendingInboundHops = 256 + // Temporary Discover trace: confirm the row committed and report the + // post-save row count for this radio (catches silent cap eviction). + // Filter by category "discover-trace"; remove with the matching probes. + let radioRows = (try? modelContext.fetchCount( + FetchDescriptor(predicate: #Predicate { $0.radioID == targetRadioID }) + )) ?? -1 + PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + .info("B3 persisted DiscoveredNode isNew=\(isNew) radioRows=\(radioRows)/\(maxDiscoveredNodes)") - private struct PendingHopKey: Hashable { - let radioID: UUID - let publicKey: Data - } + return (node: DiscoveredNodeDTO(from: node), isNew: isNew) + } - private struct PendingHop { - let hopCount: Int - let advertTimestamp: UInt32? + public func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) throws { + let targetRadioID = radioID + let targetKey = publicKey + let predicate = #Predicate { node in + node.radioID == targetRadioID && node.publicKey == targetKey } - - public func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) throws -> (node: DiscoveredNodeDTO, isNew: Bool) { - let targetRadioID = radioID - let publicKey = frame.publicKey - let predicate = #Predicate { node in - node.radioID == targetRadioID && node.publicKey == publicKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - let existing = try modelContext.fetch(descriptor) - - let node: DiscoveredNode - let isNew: Bool - if let existingNode = existing.first { - existingNode.name = frame.name - existingNode.typeRawValue = frame.type.rawValue - existingNode.lastHeard = Date() - existingNode.lastAdvertTimestamp = frame.lastAdvertTimestamp - existingNode.latitude = frame.latitude - existingNode.longitude = frame.longitude - existingNode.outPathLength = frame.outPathLength - existingNode.outPath = frame.outPath - node = existingNode - isNew = false - } else { - node = DiscoveredNode( - radioID: radioID, - publicKey: frame.publicKey, - name: frame.name, - typeRawValue: frame.type.rawValue, - lastAdvertTimestamp: frame.lastAdvertTimestamp, - latitude: frame.latitude, - longitude: frame.longitude, - outPathLength: frame.outPathLength, - outPath: frame.outPath - ) - modelContext.insert(node) - isNew = true - - try enforceDiscoveredNodeCap(radioID: radioID) - } - - applyPendingInboundHop(to: node) - - try modelContext.save() - - // Temporary Discover trace: confirm the row committed and report the - // post-save row count for this radio (catches silent cap eviction). - // Filter by category "discover-trace"; remove with the matching probes. - let radioRows = (try? modelContext.fetchCount( - FetchDescriptor(predicate: #Predicate { $0.radioID == targetRadioID }) - )) ?? -1 - PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - .info("B3 persisted DiscoveredNode isNew=\(isNew) radioRows=\(radioRows)/\(maxDiscoveredNodes)") - - return (node: DiscoveredNodeDTO(from: node), isNew: isNew) + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + // No row yet: buffer the pair so the advert upsert drains it on first contact. + guard let node = try modelContext.fetch(descriptor).first else { + bufferPendingInboundHop(radioID: targetRadioID, publicKey: targetKey, hopCount: hopCount, advertTimestamp: advertTimestamp) + return } - - public func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) throws { - let targetRadioID = radioID - let targetKey = publicKey - let predicate = #Predicate { node in - node.radioID == targetRadioID && node.publicKey == targetKey - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - // No row yet: buffer the pair so the advert upsert drains it on first contact. - guard let node = try modelContext.fetch(descriptor).first else { - bufferPendingInboundHop(radioID: targetRadioID, publicKey: targetKey, hopCount: hopCount, advertTimestamp: advertTimestamp) - return - } - guard let adopted = adoptInboundHop( - storedHops: node.inboundHopCount, - storedTimestamp: node.inboundHopAdvertTimestamp, - incomingHops: hopCount, - incomingTimestamp: advertTimestamp - ) else { - return - } - node.inboundHopCount = adopted.hopCount - node.inboundHopAdvertTimestamp = adopted.advertTimestamp - try modelContext.save() + guard let adopted = adoptInboundHop( + storedHops: node.inboundHopCount, + storedTimestamp: node.inboundHopAdvertTimestamp, + incomingHops: hopCount, + incomingTimestamp: advertTimestamp + ) else { + return } - - /// Stash an inbound hop count heard before its node row exists. Applies the same adopt rule - /// as the live-row path. Evicts an arbitrary entry past the cap so a flood of never-resolved - /// adverts can't grow this without bound. - private func bufferPendingInboundHop(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) { - let key = PendingHopKey(radioID: radioID, publicKey: publicKey) - let existing = pendingInboundHops[key] - guard let adopted = adoptInboundHop( - storedHops: existing?.hopCount, - storedTimestamp: existing?.advertTimestamp, - incomingHops: hopCount, - incomingTimestamp: advertTimestamp - ) else { return } - if existing == nil, - pendingInboundHops.count >= maxPendingInboundHops, - let evicted = pendingInboundHops.keys.first { - pendingInboundHops.removeValue(forKey: evicted) - } - pendingInboundHops[key] = PendingHop(hopCount: adopted.hopCount, advertTimestamp: adopted.advertTimestamp) + node.inboundHopCount = adopted.hopCount + node.inboundHopAdvertTimestamp = adopted.advertTimestamp + try modelContext.save() + } + + /// Stash an inbound hop count heard before its node row exists. Applies the same adopt rule + /// as the live-row path. Evicts an arbitrary entry past the cap so a flood of never-resolved + /// adverts can't grow this without bound. + private func bufferPendingInboundHop(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) { + let key = PendingHopKey(radioID: radioID, publicKey: publicKey) + let existing = pendingInboundHops[key] + guard let adopted = adoptInboundHop( + storedHops: existing?.hopCount, + storedTimestamp: existing?.advertTimestamp, + incomingHops: hopCount, + incomingTimestamp: advertTimestamp + ) else { return } + if existing == nil, + pendingInboundHops.count >= maxPendingInboundHops, + let evicted = pendingInboundHops.keys.first { + pendingInboundHops.removeValue(forKey: evicted) } - - /// Apply and clear any inbound hop count buffered before this row existed. - private func applyPendingInboundHop(to node: DiscoveredNode) { - let key = PendingHopKey(radioID: node.radioID, publicKey: node.publicKey) - guard let buffered = pendingInboundHops.removeValue(forKey: key) else { return } - if let adopted = adoptInboundHop( - storedHops: node.inboundHopCount, - storedTimestamp: node.inboundHopAdvertTimestamp, - incomingHops: buffered.hopCount, - incomingTimestamp: buffered.advertTimestamp - ) { - node.inboundHopCount = adopted.hopCount - node.inboundHopAdvertTimestamp = adopted.advertTimestamp - } - } - - private func enforceDiscoveredNodeCap(radioID: UUID) throws { - let targetRadioID = radioID - let countPredicate = #Predicate { $0.radioID == targetRadioID } - let countDescriptor = FetchDescriptor(predicate: countPredicate) - let count = try modelContext.fetchCount(countDescriptor) - - if count > maxDiscoveredNodes { - var oldestDescriptor = FetchDescriptor( - predicate: countPredicate, - sortBy: [SortDescriptor(\.lastHeard, order: .forward)] - ) - oldestDescriptor.fetchLimit = count - maxDiscoveredNodes - let toDelete = try modelContext.fetch(oldestDescriptor) - for node in toDelete { - modelContext.delete(node) - } - let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") - logger.warning("DiscoveredNode cap exceeded, evicted \(toDelete.count) oldest nodes") - } + pendingInboundHops[key] = PendingHop(hopCount: adopted.hopCount, advertTimestamp: adopted.advertTimestamp) + } + + /// Apply and clear any inbound hop count buffered before this row existed. + private func applyPendingInboundHop(to node: DiscoveredNode) { + let key = PendingHopKey(radioID: node.radioID, publicKey: node.publicKey) + guard let buffered = pendingInboundHops.removeValue(forKey: key) else { return } + if let adopted = adoptInboundHop( + storedHops: node.inboundHopCount, + storedTimestamp: node.inboundHopAdvertTimestamp, + incomingHops: buffered.hopCount, + incomingTimestamp: buffered.advertTimestamp + ) { + node.inboundHopCount = adopted.hopCount + node.inboundHopAdvertTimestamp = adopted.advertTimestamp } - - public func fetchDiscoveredNodes(radioID: UUID) throws -> [DiscoveredNodeDTO] { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - let descriptor = FetchDescriptor(predicate: predicate) - let nodes = try modelContext.fetch(descriptor) - return nodes.map { DiscoveredNodeDTO(from: $0) } + } + + private func enforceDiscoveredNodeCap(radioID: UUID) throws { + let targetRadioID = radioID + let countPredicate = #Predicate { $0.radioID == targetRadioID } + let countDescriptor = FetchDescriptor(predicate: countPredicate) + let count = try modelContext.fetchCount(countDescriptor) + + if count > maxDiscoveredNodes { + var oldestDescriptor = FetchDescriptor( + predicate: countPredicate, + sortBy: [SortDescriptor(\.lastHeard, order: .forward)] + ) + oldestDescriptor.fetchLimit = count - maxDiscoveredNodes + let toDelete = try modelContext.fetch(oldestDescriptor) + for node in toDelete { + modelContext.delete(node) + } + let logger = Logger(subsystem: "com.mc1", category: "PersistenceStore") + logger.warning("DiscoveredNode cap exceeded, evicted \(toDelete.count) oldest nodes") } - - public func deleteDiscoveredNode(id: UUID) throws { - let targetID = id - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - if let node = try modelContext.fetch(descriptor).first { - modelContext.delete(node) - try modelContext.save() - } + } + + public func fetchDiscoveredNodes(radioID: UUID) throws -> [DiscoveredNodeDTO] { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + let descriptor = FetchDescriptor(predicate: predicate) + let nodes = try modelContext.fetch(descriptor) + return nodes.map { DiscoveredNodeDTO(from: $0) } + } + + public func deleteDiscoveredNode(id: UUID) throws { + let targetID = id + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let node = try modelContext.fetch(descriptor).first { + modelContext.delete(node) + try modelContext.save() } - - public func clearDiscoveredNodes(radioID: UUID) throws { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - let descriptor = FetchDescriptor(predicate: predicate) - let nodes = try modelContext.fetch(descriptor) - for node in nodes { - modelContext.delete(node) - } - try modelContext.save() + } + + public func clearDiscoveredNodes(radioID: UUID) throws { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + let descriptor = FetchDescriptor(predicate: predicate) + let nodes = try modelContext.fetch(descriptor) + for node in nodes { + modelContext.delete(node) } + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistentLogger.swift b/MC1Services/Sources/MC1Services/Services/PersistentLogger.swift index 9a7cb693..5b35b2ff 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistentLogger.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistentLogger.swift @@ -4,57 +4,57 @@ import os /// Drop-in replacement for Logger that uses the shared DebugLogBuffer. /// Writes to both OSLog (for system integration) and SwiftData (for persistence). public struct PersistentLogger: Sendable { - private let logger: Logger - private let subsystem: String - private let category: String - - public init(subsystem: String, category: String) { - self.logger = Logger(subsystem: subsystem, category: category) - self.subsystem = subsystem - self.category = category - } - - public func debug(_ message: String) { - logger.debug("\(message)") - persist(level: .debug, message: message) - } - - public func info(_ message: String) { - logger.info("\(message)") - persist(level: .info, message: message) - } - - public func notice(_ message: String) { - logger.notice("\(message)") - persist(level: .notice, message: message) - } - - public func warning(_ message: String) { - logger.warning("\(message)") - persist(level: .warning, message: message) - } - - public func error(_ message: String) { - logger.error("\(message)") - persist(level: .error, message: message) - } - - public func fault(_ message: String) { - logger.fault("\(message)") - persist(level: .fault, message: message) - } - - private func persist(level: DebugLogLevel, message: String) { - guard let buffer = DebugLogBuffer.shared else { return } - - let dto = DebugLogEntryDTO( - level: level, - subsystem: subsystem, - category: category, - message: message - ) - Task { - await buffer.append(dto) - } - } + private let logger: Logger + private let subsystem: String + private let category: String + + public init(subsystem: String, category: String) { + logger = Logger(subsystem: subsystem, category: category) + self.subsystem = subsystem + self.category = category + } + + public func debug(_ message: String) { + logger.debug("\(message)") + persist(level: .debug, message: message) + } + + public func info(_ message: String) { + logger.info("\(message)") + persist(level: .info, message: message) + } + + public func notice(_ message: String) { + logger.notice("\(message)") + persist(level: .notice, message: message) + } + + public func warning(_ message: String) { + logger.warning("\(message)") + persist(level: .warning, message: message) + } + + public func error(_ message: String) { + logger.error("\(message)") + persist(level: .error, message: message) + } + + public func fault(_ message: String) { + logger.fault("\(message)") + persist(level: .fault, message: message) + } + + private func persist(level: DebugLogLevel, message: String) { + guard let buffer = DebugLogBuffer.shared else { return } + + let dto = DebugLogEntryDTO( + level: level, + subsystem: subsystem, + category: category, + message: message + ) + Task { + await buffer.append(dto) + } + } } diff --git a/MC1Services/Sources/MC1Services/Services/RadioOptions.swift b/MC1Services/Sources/MC1Services/Services/RadioOptions.swift index 2980137c..16ad73dc 100644 --- a/MC1Services/Sources/MC1Services/Services/RadioOptions.swift +++ b/MC1Services/Sources/MC1Services/Services/RadioOptions.swift @@ -5,71 +5,71 @@ private let radioLogger = PersistentLogger(subsystem: "com.mc1", category: "radi /// Standard LoRa radio parameter options for configuration UI public enum RadioOptions { - /// Available bandwidth options in Hz (internal representation for picker tags) - /// Display values: 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500 kHz - /// - /// Note: These values are passed directly to the protocol layer. Despite the - /// misleading parameter name `bandwidthKHz` in FrameCodec.encodeSetRadioParams, - /// the firmware actually expects bandwidth in Hz. - public static let bandwidthsHz: [UInt32] = [ - 7_800, 10_400, 15_600, 20_800, 31_250, 41_700, 62_500, 125_000, 250_000, 500_000 - ] + /// Available bandwidth options in Hz (internal representation for picker tags) + /// Display values: 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500 kHz + /// + /// Note: These values are passed directly to the protocol layer. Despite the + /// misleading parameter name `bandwidthKHz` in FrameCodec.encodeSetRadioParams, + /// the firmware actually expects bandwidth in Hz. + public static let bandwidthsHz: [UInt32] = [ + 7800, 10400, 15600, 20800, 31250, 41700, 62500, 125_000, 250_000, 500_000 + ] - /// Bandwidth options in kHz for CLI protocol display - public static let bandwidthsKHz: [Double] = bandwidthsHz.map { Double($0) / 1000.0 } + /// Bandwidth options in kHz for CLI protocol display + public static let bandwidthsKHz: [Double] = bandwidthsHz.map { Double($0) / 1000.0 } - /// Valid spreading factor range (SF5-SF12) - public static let spreadingFactors: ClosedRange = 5...12 + /// Valid spreading factor range (SF5-SF12) + public static let spreadingFactors: ClosedRange = 5...12 - /// Valid coding rate range (5-8, representing 4/5 through 4/8) - public static let codingRates: ClosedRange = 5...8 + /// Valid coding rate range (5-8, representing 4/5 through 4/8) + public static let codingRates: ClosedRange = 5...8 - /// Format bandwidth Hz value for display (e.g., 7800 -> "7.8", 125000 -> "125") - /// Uses switch for known values to ensure deterministic, O(1) output. - public static func formatBandwidth(_ hz: UInt32) -> String { - switch hz { - case 7_800: return "7.8" - case 10_400: return "10.4" - case 15_600: return "15.6" - case 20_800: return "20.8" - case 31_250: return "31.25" - case 41_700: return "41.7" - case 62_500: return "62.5" - case 125_000: return "125" - case 250_000: return "250" - case 500_000: return "500" - default: - // Fallback for unexpected values (e.g., from nearestBandwidth edge cases) - let khz = Double(hz) / 1000.0 - if khz.truncatingRemainder(dividingBy: 1) == 0 { - return "\(Int(khz))" - } else { - let formatter = NumberFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 2 - return formatter.string(from: NSNumber(value: khz)) ?? "\(khz)" - } - } + /// Format bandwidth Hz value for display (e.g., 7800 -> "7.8", 125000 -> "125") + /// Uses switch for known values to ensure deterministic, O(1) output. + public static func formatBandwidth(_ hz: UInt32) -> String { + switch hz { + case 7800: return "7.8" + case 10400: return "10.4" + case 15600: return "15.6" + case 20800: return "20.8" + case 31250: return "31.25" + case 41700: return "41.7" + case 62500: return "62.5" + case 125_000: return "125" + case 250_000: return "250" + case 500_000: return "500" + default: + // Fallback for unexpected values (e.g., from nearestBandwidth edge cases) + let khz = Double(hz) / 1000.0 + if khz.truncatingRemainder(dividingBy: 1) == 0 { + return "\(Int(khz))" + } else { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 2 + return formatter.string(from: NSNumber(value: khz)) ?? "\(khz)" + } } + } - /// Find nearest valid bandwidth for a device value that may not be in the standard list. - /// Handles firmware float precision issues where values like 7800 Hz may be stored as - /// 7.8 kHz (float) and returned as 7799 or 7801 Hz. - /// - /// Logs a debug message when fallback occurs to help diagnose unexpected device values. - public static func nearestBandwidth(to hz: UInt32) -> UInt32 { - if bandwidthsHz.contains(hz) { - return hz - } - // Use explicit unsigned comparison to avoid Int64 type promotion - let nearest = bandwidthsHz.min { lhs, rhs in - let lhsDiff = lhs > hz ? lhs - hz : hz - lhs - let rhsDiff = rhs > hz ? rhs - hz : hz - rhs - return lhsDiff < rhsDiff - } ?? 250_000 - - radioLogger.debug("Bandwidth \(hz) Hz not in standard options, using nearest: \(nearest) Hz") - return nearest + /// Find nearest valid bandwidth for a device value that may not be in the standard list. + /// Handles firmware float precision issues where values like 7800 Hz may be stored as + /// 7.8 kHz (float) and returned as 7799 or 7801 Hz. + /// + /// Logs a debug message when fallback occurs to help diagnose unexpected device values. + public static func nearestBandwidth(to hz: UInt32) -> UInt32 { + if bandwidthsHz.contains(hz) { + return hz } + // Use explicit unsigned comparison to avoid Int64 type promotion + let nearest = bandwidthsHz.min { lhs, rhs in + let lhsDiff = lhs > hz ? lhs - hz : hz - lhs + let rhsDiff = rhs > hz ? rhs - hz : hz - rhs + return lhsDiff < rhsDiff + } ?? 250_000 + + radioLogger.debug("Bandwidth \(hz) Hz not in standard options, using nearest: \(nearest) Hz") + return nearest + } } diff --git a/MC1Services/Sources/MC1Services/Services/RadioPresets.swift b/MC1Services/Sources/MC1Services/Services/RadioPresets.swift index 3f6e753f..f0c29a93 100644 --- a/MC1Services/Sources/MC1Services/Services/RadioPresets.swift +++ b/MC1Services/Sources/MC1Services/Services/RadioPresets.swift @@ -2,334 +2,338 @@ import Foundation /// Geographic regions for radio preset filtering public enum RadioRegion: String, CaseIterable, Sendable { - case northAmerica = "North America" - case southAmerica = "South America" - case europe = "Europe" - case oceania = "Oceania" - case asia = "Asia" + case northAmerica = "North America" + case southAmerica = "South America" + case europe = "Europe" + case oceania = "Oceania" + case asia = "Asia" - /// Regions that should be shown for a given locale - public static func regionsForLocale(_ locale: Locale = .current) -> [RadioRegion] { - guard let regionCode = locale.region?.identifier else { - return RadioRegion.allCases - } + /// Regions that should be shown for a given locale + public static func regionsForLocale(_ locale: Locale = .current) -> [RadioRegion] { + guard let regionCode = locale.region?.identifier else { + return RadioRegion.allCases + } - switch regionCode { - case "US", "CA": - return [.northAmerica, .europe, .oceania, .asia] - case "AU", "NZ": - return [.oceania, .northAmerica, .europe, .asia] - case "GB", "DE", "FR", "IT", "ES", "PT", "CH", "CZ", "IE", "NL", "BE", "AT": - return [.europe, .northAmerica, .oceania, .asia] - case "VN", "TH", "MY", "SG", "PH", "ID": - return [.asia, .oceania, .europe, .northAmerica] - case "CL": - return [.southAmerica, .northAmerica, .europe, .oceania, .asia] - default: - return RadioRegion.allCases - } + switch regionCode { + case "US", "CA": + return [.northAmerica, .europe, .oceania, .asia] + case "AU", "NZ": + return [.oceania, .northAmerica, .europe, .asia] + case "GB", "DE", "FR", "IT", "ES", "PT", "CH", "CZ", "IE", "NL", "BE", "AT": + return [.europe, .northAmerica, .oceania, .asia] + case "VN", "TH", "MY", "SG", "PH", "ID": + return [.asia, .oceania, .europe, .northAmerica] + case "CL": + return [.southAmerica, .northAmerica, .europe, .oceania, .asia] + default: + return RadioRegion.allCases } + } - /// Short code for display in compact UI elements - public var shortCode: String { - switch self { - case .northAmerica: return "NA" - case .southAmerica: return "SA" - case .europe: return "EU" - case .oceania: return "AU" - case .asia: return "AS" - } + /// Short code for display in compact UI elements + public var shortCode: String { + switch self { + case .northAmerica: "NA" + case .southAmerica: "SA" + case .europe: "EU" + case .oceania: "AU" + case .asia: "AS" } + } } /// Geographic availability tier for a `RadioPreset`. Used by the recommendation /// algorithm to choose the most-specific community-curated preset that matches /// the user's `RegionSelection`. -enum PresetAvailability: Sendable, Equatable { - case continent(RadioRegion) - case countries(Set) // ISO-3166 α-2 - case subRegions(country: String, areas: Set) // ISO 3166-2 - case counties(country: String, state: String, keys: Set) // normalized US county names +enum PresetAvailability: Equatable { + case continent(RadioRegion) + case countries(Set) // ISO-3166 α-2 + case subRegions(country: String, areas: Set) // ISO 3166-2 + case counties(country: String, state: String, keys: Set) // normalized US county names } /// Radio configuration preset for common regional settings public struct RadioPreset: Identifiable, Sendable, Equatable { - public let id: String - public let name: String - public let region: RadioRegion - public let frequencyMHz: Double - public let bandwidthKHz: Double - public let spreadingFactor: UInt8 - public let codingRate: UInt8 + public let id: String + public let name: String + public let region: RadioRegion + public let frequencyMHz: Double + public let bandwidthKHz: Double + public let spreadingFactor: UInt8 + public let codingRate: UInt8 - /// Section header for repeat mode presets (e.g., "EU/Asia", "US/AU/NZ") - public let repeatSectionHeader: String? - let availability: PresetAvailability - /// Higher value = preferred within a geographic tier. Standard presets use 100; community-recommended favorites use 110. - public let recommendationPriority: Int + /// Section header for repeat mode presets (e.g., "EU/Asia", "US/AU/NZ") + public let repeatSectionHeader: String? + let availability: PresetAvailability + /// Higher value = preferred within a geographic tier. Standard presets use 100; community-recommended favorites use 110. + public let recommendationPriority: Int - /// Frequency in kHz for protocol encoding - public var frequencyKHz: UInt32 { UInt32((frequencyMHz * 1000).rounded()) } + /// Frequency in kHz for protocol encoding + public var frequencyKHz: UInt32 { + UInt32((frequencyMHz * 1000).rounded()) + } - /// Bandwidth in Hz for protocol encoding - public var bandwidthHz: UInt32 { UInt32((bandwidthKHz * 1000).rounded()) } + /// Bandwidth in Hz for protocol encoding + public var bandwidthHz: UInt32 { + UInt32((bandwidthKHz * 1000).rounded()) + } - init( - id: String, - name: String, - region: RadioRegion, - frequencyMHz: Double, - bandwidthKHz: Double, - spreadingFactor: UInt8, - codingRate: UInt8, - repeatSectionHeader: String? = nil, - availability: PresetAvailability, - recommendationPriority: Int = 100 - ) { - self.id = id - self.name = name - self.region = region - self.frequencyMHz = frequencyMHz - self.bandwidthKHz = bandwidthKHz - self.spreadingFactor = spreadingFactor - self.codingRate = codingRate - self.repeatSectionHeader = repeatSectionHeader - self.availability = availability - self.recommendationPriority = recommendationPriority - } + init( + id: String, + name: String, + region: RadioRegion, + frequencyMHz: Double, + bandwidthKHz: Double, + spreadingFactor: UInt8, + codingRate: UInt8, + repeatSectionHeader: String? = nil, + availability: PresetAvailability, + recommendationPriority: Int = 100 + ) { + self.id = id + self.name = name + self.region = region + self.frequencyMHz = frequencyMHz + self.bandwidthKHz = bandwidthKHz + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + self.repeatSectionHeader = repeatSectionHeader + self.availability = availability + self.recommendationPriority = recommendationPriority + } } /// Static collection of all available radio presets public enum RadioPresets { - public static let all: [RadioPreset] = [ - // Oceania - RadioPreset(id: "au-915", name: "Australia", region: .oceania, - frequencyMHz: 915.800, bandwidthKHz: 250, spreadingFactor: 10, codingRate: 5, - availability: .countries(["AU"])), - RadioPreset(id: "au-narrow", name: "Australia (Narrow)", region: .oceania, - frequencyMHz: 916.575, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 8, - availability: .countries(["AU"])), - RadioPreset(id: "au-mid", name: "Australia (Mid)", region: .oceania, - frequencyMHz: 915.075, bandwidthKHz: 125, spreadingFactor: 9, codingRate: 5, - availability: .countries(["AU"])), - RadioPreset(id: "au-sa-wa", name: "Australia: SA, WA", region: .oceania, - frequencyMHz: 923.125, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, - availability: .subRegions(country: "AU", areas: ["AU-SA", "AU-WA"])), - RadioPreset(id: "au-qld", name: "Australia: QLD", region: .oceania, - frequencyMHz: 923.125, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, - availability: .subRegions(country: "AU", areas: ["AU-QLD"])), - RadioPreset(id: "nz-lr", name: "New Zealand", region: .oceania, - frequencyMHz: 917.375, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, - availability: .countries(["NZ"])), - RadioPreset(id: "nz-narrow", name: "New Zealand (Narrow)", region: .oceania, - frequencyMHz: 917.375, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, - availability: .countries(["NZ"])), + public static let all: [RadioPreset] = [ + // Oceania + RadioPreset(id: "au-915", name: "Australia", region: .oceania, + frequencyMHz: 915.800, bandwidthKHz: 250, spreadingFactor: 10, codingRate: 5, + availability: .countries(["AU"])), + RadioPreset(id: "au-narrow", name: "Australia (Narrow)", region: .oceania, + frequencyMHz: 916.575, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 8, + availability: .countries(["AU"])), + RadioPreset(id: "au-mid", name: "Australia (Mid)", region: .oceania, + frequencyMHz: 915.075, bandwidthKHz: 125, spreadingFactor: 9, codingRate: 5, + availability: .countries(["AU"])), + RadioPreset(id: "au-sa-wa", name: "Australia: SA, WA", region: .oceania, + frequencyMHz: 923.125, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, + availability: .subRegions(country: "AU", areas: ["AU-SA", "AU-WA"])), + RadioPreset(id: "au-qld", name: "Australia: QLD", region: .oceania, + frequencyMHz: 923.125, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, + availability: .subRegions(country: "AU", areas: ["AU-QLD"])), + RadioPreset(id: "nz-lr", name: "New Zealand", region: .oceania, + frequencyMHz: 917.375, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, + availability: .countries(["NZ"])), + RadioPreset(id: "nz-narrow", name: "New Zealand (Narrow)", region: .oceania, + frequencyMHz: 917.375, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, + availability: .countries(["NZ"])), - // Europe - RadioPreset(id: "eu-narrow", name: "EU/UK (Narrow)", region: .europe, - frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, - availability: .continent(.europe), recommendationPriority: 110), - RadioPreset(id: "eu-lr", name: "EU/UK (Deprecated)", region: .europe, - frequencyMHz: 869.525, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, - availability: .continent(.europe)), - RadioPreset(id: "cz-narrow", name: "Czech Republic (Narrow)", region: .europe, - frequencyMHz: 869.432, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, - availability: .countries(["CZ"])), - RadioPreset(id: "eu-433-lr", name: "EU 433MHz (Long Range)", region: .europe, - frequencyMHz: 433.650, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, - availability: .continent(.europe)), - RadioPreset(id: "eu-433-narrow", name: "EU 433MHz (Narrow)", region: .europe, - frequencyMHz: 433.650, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, - availability: .continent(.europe)), - RadioPreset(id: "pt-433", name: "Portugal 433", region: .europe, - frequencyMHz: 433.375, bandwidthKHz: 62.5, spreadingFactor: 9, codingRate: 6, - availability: .countries(["PT"])), - RadioPreset(id: "pt-868", name: "Portugal 868", region: .europe, - frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 6, - availability: .countries(["PT"]), recommendationPriority: 110), - RadioPreset(id: "ch", name: "Switzerland", region: .europe, - frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, - availability: .countries(["CH"])), - RadioPreset(id: "nl", name: "Netherlands", region: .europe, - frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, - availability: .countries(["NL"]), recommendationPriority: 110), + // Europe + RadioPreset(id: "eu-narrow", name: "EU/UK (Narrow)", region: .europe, + frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, + availability: .continent(.europe), recommendationPriority: 110), + RadioPreset(id: "eu-lr", name: "EU/UK (Deprecated)", region: .europe, + frequencyMHz: 869.525, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, + availability: .continent(.europe)), + RadioPreset(id: "cz-narrow", name: "Czech Republic (Narrow)", region: .europe, + frequencyMHz: 869.432, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, + availability: .countries(["CZ"])), + RadioPreset(id: "eu-433-lr", name: "EU 433MHz (Long Range)", region: .europe, + frequencyMHz: 433.650, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, + availability: .continent(.europe)), + RadioPreset(id: "eu-433-narrow", name: "EU 433MHz (Narrow)", region: .europe, + frequencyMHz: 433.650, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, + availability: .continent(.europe)), + RadioPreset(id: "pt-433", name: "Portugal 433", region: .europe, + frequencyMHz: 433.375, bandwidthKHz: 62.5, spreadingFactor: 9, codingRate: 6, + availability: .countries(["PT"])), + RadioPreset(id: "pt-868", name: "Portugal 868", region: .europe, + frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 6, + availability: .countries(["PT"]), recommendationPriority: 110), + RadioPreset(id: "ch", name: "Switzerland", region: .europe, + frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, + availability: .countries(["CH"])), + RadioPreset(id: "nl", name: "Netherlands", region: .europe, + frequencyMHz: 869.618, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, + availability: .countries(["NL"]), recommendationPriority: 110), - // North America - RadioPreset(id: "us-ca", name: "USA/Canada", region: .northAmerica, - frequencyMHz: 910.525, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, - availability: .countries(["US", "CA"]), recommendationPriority: 110), - RadioPreset(id: "wcmesh", name: "WCMesh (SoCal)", region: .northAmerica, - frequencyMHz: 927.875, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, - availability: .counties(country: "US", state: "US-CA", keys: [ - "los angeles", "orange", "san diego", "riverside", "san bernardino", - "ventura", "imperial", "kern", "santa barbara", "san luis obispo", - ])), + // North America + RadioPreset(id: "us-ca", name: "USA/Canada", region: .northAmerica, + frequencyMHz: 910.525, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, + availability: .countries(["US", "CA"]), recommendationPriority: 110), + RadioPreset(id: "wcmesh", name: "WCMesh (SoCal)", region: .northAmerica, + frequencyMHz: 927.875, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 5, + availability: .counties(country: "US", state: "US-CA", keys: [ + "los angeles", "orange", "san diego", "riverside", "san bernardino", + "ventura", "imperial", "kern", "santa barbara", "san luis obispo", + ])), - // South America - // Chile: community-standard settings from the MeshChile network (https://meshchile.cl). - RadioPreset(id: "cl", name: "Chile", region: .southAmerica, - frequencyMHz: 927.875, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, - availability: .countries(["CL"])), + // South America + // Chile: community-standard settings from the MeshChile network (https://meshchile.cl). + RadioPreset(id: "cl", name: "Chile", region: .southAmerica, + frequencyMHz: 927.875, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, + availability: .countries(["CL"])), - // Asia - RadioPreset(id: "vn-narrow", name: "Vietnam (Narrow)", region: .asia, - frequencyMHz: 920.250, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, - availability: .countries(["VN"]), recommendationPriority: 110), - RadioPreset(id: "vn", name: "Vietnam (Deprecated)", region: .asia, - frequencyMHz: 920.250, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, - availability: .countries(["VN"])), - ] + // Asia + RadioPreset(id: "vn-narrow", name: "Vietnam (Narrow)", region: .asia, + frequencyMHz: 920.250, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 5, + availability: .countries(["VN"]), recommendationPriority: 110), + RadioPreset(id: "vn", name: "Vietnam (Deprecated)", region: .asia, + frequencyMHz: 920.250, bandwidthKHz: 250, spreadingFactor: 11, codingRate: 5, + availability: .countries(["VN"])), + ] - /// Repeat mode frequency presets with regional grouping. - /// BW 62.5 kHz + CR 8 maximize range for portable repeaters. - /// SF varies by band: higher for lower-power EU bands, lower for US. - public static let repeatPresets: [RadioPreset] = [ - RadioPreset(id: "repeat-433", name: "433 MHz", region: .europe, - frequencyMHz: 433.000, bandwidthKHz: 62.5, spreadingFactor: 9, codingRate: 8, - repeatSectionHeader: "EU/Asia", - availability: .continent(.europe)), - RadioPreset(id: "repeat-869", name: "869 MHz", region: .europe, - frequencyMHz: 869.000, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, - repeatSectionHeader: "EU", - availability: .continent(.europe)), - RadioPreset(id: "repeat-918", name: "918 MHz", region: .northAmerica, - frequencyMHz: 918.000, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 8, - repeatSectionHeader: "US/AU/NZ", - availability: .continent(.northAmerica)), - ] + /// Repeat mode frequency presets with regional grouping. + /// BW 62.5 kHz + CR 8 maximize range for portable repeaters. + /// SF varies by band: higher for lower-power EU bands, lower for US. + public static let repeatPresets: [RadioPreset] = [ + RadioPreset(id: "repeat-433", name: "433 MHz", region: .europe, + frequencyMHz: 433.000, bandwidthKHz: 62.5, spreadingFactor: 9, codingRate: 8, + repeatSectionHeader: "EU/Asia", + availability: .continent(.europe)), + RadioPreset(id: "repeat-869", name: "869 MHz", region: .europe, + frequencyMHz: 869.000, bandwidthKHz: 62.5, spreadingFactor: 8, codingRate: 8, + repeatSectionHeader: "EU", + availability: .continent(.europe)), + RadioPreset(id: "repeat-918", name: "918 MHz", region: .northAmerica, + frequencyMHz: 918.000, bandwidthKHz: 62.5, spreadingFactor: 7, codingRate: 8, + repeatSectionHeader: "US/AU/NZ", + availability: .continent(.northAmerica)), + ] - /// Get presets filtered and sorted by user's locale - public static func presetsForLocale(_ locale: Locale = .current) -> [RadioPreset] { - let preferredRegions = RadioRegion.regionsForLocale(locale) + /// Get presets filtered and sorted by user's locale + public static func presetsForLocale(_ locale: Locale = .current) -> [RadioPreset] { + let preferredRegions = RadioRegion.regionsForLocale(locale) - return all.sorted { a, b in - let aIndex = preferredRegions.firstIndex(of: a.region) ?? preferredRegions.count - let bIndex = preferredRegions.firstIndex(of: b.region) ?? preferredRegions.count - if aIndex != bIndex { - return aIndex < bIndex - } - return a.name < b.name - } + return all.sorted { a, b in + let aIndex = preferredRegions.firstIndex(of: a.region) ?? preferredRegions.count + let bIndex = preferredRegions.firstIndex(of: b.region) ?? preferredRegions.count + if aIndex != bIndex { + return aIndex < bIndex + } + return a.name < b.name } + } - /// Find preset matching current device settings (approximate match) - public static func matchingPreset( - frequencyKHz: UInt32, - bandwidthKHz: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8 - ) -> RadioPreset? { - let freqMHz = Double(frequencyKHz) / 1000.0 - let bwKHz = Double(bandwidthKHz) / 1000.0 + /// Find preset matching current device settings (approximate match) + public static func matchingPreset( + frequencyKHz: UInt32, + bandwidthKHz: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8 + ) -> RadioPreset? { + let freqMHz = Double(frequencyKHz) / 1000.0 + let bwKHz = Double(bandwidthKHz) / 1000.0 - return all.first { preset in - abs(preset.frequencyMHz - freqMHz) < 0.1 && - abs(preset.bandwidthKHz - bwKHz) < 1.0 && - preset.spreadingFactor == spreadingFactor && - preset.codingRate == codingRate - } + return all.first { preset in + abs(preset.frequencyMHz - freqMHz) < 0.1 && + abs(preset.bandwidthKHz - bwKHz) < 1.0 && + preset.spreadingFactor == spreadingFactor && + preset.codingRate == codingRate } + } - /// Find repeat preset matching current device settings (exact match) - public static func matchingRepeatPreset( - frequencyKHz: UInt32, - bandwidthKHz: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8 - ) -> RadioPreset? { - repeatPresets.first { preset in - preset.frequencyKHz == frequencyKHz && - preset.bandwidthHz == bandwidthKHz && - preset.spreadingFactor == spreadingFactor && - preset.codingRate == codingRate - } + /// Find repeat preset matching current device settings (exact match) + public static func matchingRepeatPreset( + frequencyKHz: UInt32, + bandwidthKHz: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8 + ) -> RadioPreset? { + repeatPresets.first { preset in + preset.frequencyKHz == frequencyKHz && + preset.bandwidthHz == bandwidthKHz && + preset.spreadingFactor == spreadingFactor && + preset.codingRate == codingRate } + } - /// Stable recommendation order, computed once. `recommendationPriority` is a - /// compile-time constant on each preset so the sort output never changes. - private static let recommendationOrder: [RadioPreset] = all.sorted { - $0.recommendationPriority != $1.recommendationPriority - ? $0.recommendationPriority > $1.recommendationPriority - : $0.id < $1.id - } + /// Stable recommendation order, computed once. `recommendationPriority` is a + /// compile-time constant on each preset so the sort output never changes. + private static let recommendationOrder: [RadioPreset] = all.sorted { + $0.recommendationPriority != $1.recommendationPriority + ? $0.recommendationPriority > $1.recommendationPriority + : $0.id < $1.id + } - /// Returns the most-specific community-curated preset for `region`. - /// Tier 0 (county) → Tier 1 (sub-region) → Tier 2 (country) → Tier 3 (continent). - /// Returns nil for regions not covered by any tier (e.g. Bermuda). - public static func recommended(for region: RegionSelection) -> RadioPreset? { - let stable = recommendationOrder + /// Returns the most-specific community-curated preset for `region`. + /// Tier 0 (county) → Tier 1 (sub-region) → Tier 2 (country) → Tier 3 (continent). + /// Returns nil for regions not covered by any tier (e.g. Bermuda). + public static func recommended(for region: RegionSelection) -> RadioPreset? { + let stable = recommendationOrder - // Tier 0: counties - if let adminCode = region.administrativeAreaCode, - let countyKey = region.countyKey, - let preset = stable.first(where: { - if case .counties(let c, let s, let keys) = $0.availability { - return c == region.countryCode && s == adminCode && keys.contains(countyKey) - } - return false - }) { return preset } + // Tier 0: counties + if let adminCode = region.administrativeAreaCode, + let countyKey = region.countyKey, + let preset = stable.first(where: { + if case let .counties(c, s, keys) = $0.availability { + return c == region.countryCode && s == adminCode && keys.contains(countyKey) + } + return false + }) { return preset } - // Tier 1: sub-regions - if let adminCode = region.administrativeAreaCode, - let preset = stable.first(where: { - if case .subRegions(let c, let areas) = $0.availability { - return c == region.countryCode && areas.contains(adminCode) - } - return false - }) { return preset } + // Tier 1: sub-regions + if let adminCode = region.administrativeAreaCode, + let preset = stable.first(where: { + if case let .subRegions(c, areas) = $0.availability { + return c == region.countryCode && areas.contains(adminCode) + } + return false + }) { return preset } - // Tier 2: countries - if let preset = stable.first(where: { - if case .countries(let codes) = $0.availability { - return codes.contains(region.countryCode) - } - return false - }) { return preset } + // Tier 2: countries + if let preset = stable.first(where: { + if case let .countries(codes) = $0.availability { + return codes.contains(region.countryCode) + } + return false + }) { return preset } - // Tier 3: continent - if let continent = RegionalAreas.continents[region.countryCode], - let preset = stable.first(where: { - if case .continent(let r) = $0.availability { return r == continent } - return false - }) { return preset } + // Tier 3: continent + if let continent = RegionalAreas.continents[region.countryCode], + let preset = stable.first(where: { + if case let .continent(r) = $0.availability { return r == continent } + return false + }) { return preset } - return nil - } + return nil + } - /// Returns the alternatives list for the region's country (or continent if no - /// country-level matches exist). The list always includes `.counties` and - /// `.subRegions` presets for the country regardless of the user's specific - /// county/state, so a Sacramento user can still pick `wcmesh` manually. - public static func presets(for region: RegionSelection) -> [RadioPreset] { - let countryAndBelow = all.filter { preset in - switch preset.availability { - case .counties(let c, _, _): return c == region.countryCode - case .subRegions(let c, _): return c == region.countryCode - case .countries(let codes): return codes.contains(region.countryCode) - case .continent: return false - } - } - if !countryAndBelow.isEmpty { return countryAndBelow } - guard let continent = RegionalAreas.continents[region.countryCode] else { return [] } - return all.filter { preset in - if case .continent(let r) = preset.availability { return r == continent } - return false - } + /// Returns the alternatives list for the region's country (or continent if no + /// country-level matches exist). The list always includes `.counties` and + /// `.subRegions` presets for the country regardless of the user's specific + /// county/state, so a Sacramento user can still pick `wcmesh` manually. + public static func presets(for region: RegionSelection) -> [RadioPreset] { + let countryAndBelow = all.filter { preset in + switch preset.availability { + case let .counties(c, _, _): c == region.countryCode + case let .subRegions(c, _): c == region.countryCode + case let .countries(codes): codes.contains(region.countryCode) + case .continent: false + } + } + if !countryAndBelow.isEmpty { return countryAndBelow } + guard let continent = RegionalAreas.continents[region.countryCode] else { return [] } + return all.filter { preset in + if case let .continent(r) = preset.availability { return r == continent } + return false } + } - /// Whether `preset` should appear in a manual picker for `region`. Only county-scoped presets are - /// gated: they appear only when `region` resolves to one of their counties (a nil region hides them). - /// Every other tier is always selectable. - public static func isSelectable(_ preset: RadioPreset, in region: RegionSelection?) -> Bool { - guard case .counties(let country, let state, let keys) = preset.availability else { - return true - } - guard let region, - region.countryCode == country, - region.administrativeAreaCode == state, - let countyKey = region.countyKey else { - return false - } - return keys.contains(countyKey) + /// Whether `preset` should appear in a manual picker for `region`. Only county-scoped presets are + /// gated: they appear only when `region` resolves to one of their counties (a nil region hides them). + /// Every other tier is always selectable. + public static func isSelectable(_ preset: RadioPreset, in region: RegionSelection?) -> Bool { + guard case let .counties(country, state, keys) = preset.availability else { + return true + } + guard let region, + region.countryCode == country, + region.administrativeAreaCode == state, + let countyKey = region.countyKey else { + return false } + return keys.contains(countyKey) + } } diff --git a/MC1Services/Sources/MC1Services/Services/ReactionParser.swift b/MC1Services/Sources/MC1Services/Services/ReactionParser.swift index e27c02fb..940c460b 100644 --- a/MC1Services/Sources/MC1Services/Services/ReactionParser.swift +++ b/MC1Services/Sources/MC1Services/Services/ReactionParser.swift @@ -1,5 +1,5 @@ -import Foundation import CryptoKit +import Foundation // MARK: - Crockford Base32 @@ -8,221 +8,220 @@ private let crockfordAlphabet = Array("0123456789abcdefghjkmnpqrstvwxyz") /// Crockford Base32 decode table (maps ASCII to 5-bit values, -1 for invalid) private let crockfordDecodeTable: [Int8] = { - var table = [Int8](repeating: -1, count: 128) - for (index, char) in crockfordAlphabet.enumerated() { - table[Int(char.asciiValue!)] = Int8(index) - if let upper = Character(char.uppercased()).asciiValue { - table[Int(upper)] = Int8(index) - } - } - // Handle common substitutions (both cases) - table[Int(Character("O").asciiValue!)] = 0 // O -> 0 - table[Int(Character("o").asciiValue!)] = 0 - table[Int(Character("I").asciiValue!)] = 1 // I -> 1 - table[Int(Character("i").asciiValue!)] = 1 - table[Int(Character("L").asciiValue!)] = 1 // L -> 1 - table[Int(Character("l").asciiValue!)] = 1 - return table + var table = [Int8](repeating: -1, count: 128) + for (index, char) in crockfordAlphabet.enumerated() { + table[Int(char.asciiValue!)] = Int8(index) + if let upper = Character(char.uppercased()).asciiValue { + table[Int(upper)] = Int8(index) + } + } + // Handle common substitutions (both cases) + table[Int(Character("O").asciiValue!)] = 0 // O -> 0 + table[Int(Character("o").asciiValue!)] = 0 + table[Int(Character("I").asciiValue!)] = 1 // I -> 1 + table[Int(Character("i").asciiValue!)] = 1 + table[Int(Character("L").asciiValue!)] = 1 // L -> 1 + table[Int(Character("l").asciiValue!)] = 1 + return table }() /// Encodes 5 bytes (40 bits) to 8 Crockford Base32 characters private func encodeCrockfordBase32(_ bytes: some Collection) -> String { - precondition(bytes.count == 5) - let byteArray = Array(bytes) - - // Pack 5 bytes into a 40-bit value - var bits: UInt64 = 0 - for byte in byteArray { - bits = (bits << 8) | UInt64(byte) - } - - // Extract 8 groups of 5 bits, MSB first - var result = "" - result.reserveCapacity(8) - for shift in stride(from: 35, through: 0, by: -5) { - let index = Int((bits >> shift) & 0x1F) - result.append(crockfordAlphabet[index]) - } - return result + precondition(bytes.count == 5) + let byteArray = Array(bytes) + + // Pack 5 bytes into a 40-bit value + var bits: UInt64 = 0 + for byte in byteArray { + bits = (bits << 8) | UInt64(byte) + } + + // Extract 8 groups of 5 bits, MSB first + var result = "" + result.reserveCapacity(8) + for shift in stride(from: 35, through: 0, by: -5) { + let index = Int((bits >> shift) & 0x1F) + result.append(crockfordAlphabet[index]) + } + return result } /// Validates a string contains only valid Crockford Base32 characters private func isValidCrockfordBase32(_ string: String) -> Bool { - for char in string { - guard let ascii = char.asciiValue, ascii < 128, crockfordDecodeTable[Int(ascii)] >= 0 else { - return false - } + for char in string { + guard let ascii = char.asciiValue, ascii < 128, crockfordDecodeTable[Int(ascii)] >= 0 else { + return false } - return true + } + return true } /// Normalizes a Crockford Base32 string to lowercase canonical form private func normalizeCrockfordBase32(_ string: String) -> String { - var result = "" - result.reserveCapacity(string.count) - for char in string { - guard let ascii = char.asciiValue, ascii < 128 else { continue } - let value = crockfordDecodeTable[Int(ascii)] - if value >= 0 { - result.append(crockfordAlphabet[Int(value)]) - } - } - return result + var result = "" + result.reserveCapacity(string.count) + for char in string { + guard let ascii = char.asciiValue, ascii < 128 else { continue } + let value = crockfordDecodeTable[Int(ascii)] + if value >= 0 { + result.append(crockfordAlphabet[Int(value)]) + } + } + return result } /// Parsed reaction data extracted from wire format public struct ParsedReaction: Sendable, Equatable { - public let emoji: String - public let targetSender: String - public let messageHash: String // 8 Crockford Base32 chars (lowercase) + public let emoji: String + public let targetSender: String + public let messageHash: String // 8 Crockford Base32 chars (lowercase) } /// Parsed DM reaction data (shorter format without sender) public struct ParsedDMReaction: Sendable, Equatable { - public let emoji: String - public let messageHash: String // 8 Crockford Base32 chars (lowercase) + public let emoji: String + public let messageHash: String // 8 Crockford Base32 chars (lowercase) } /// Parses reaction wire format using end-to-start strategy. /// Format: `{emoji}@[{sender}]\nxxxxxxxx` public enum ReactionParser { + /// Returns true if the text matches any known reaction format (PocketMesh or meshcore-open). + static func isReactionText(_ text: String, isDM: Bool) -> Bool { + if MeshCoreOpenReactionParser.parse(text) != nil { return true } + if MeshCoreOpenReactionParser.parseV1(text) != nil { return true } + return isDM ? parseDM(text) != nil : parse(text) != nil + } + + /// Parses reaction text, returns nil if format doesn't match + public static func parse(_ text: String) -> ParsedReaction? { + // Step 1: Split on last newline to get hash + guard let newlineIndex = text.lastIndex(of: "\n") else { + return nil + } + + let rawHash = String(text[text.index(after: newlineIndex)...]) + guard rawHash.count == 8, isValidCrockfordBase32(rawHash) else { + return nil + } + let messageHash = normalizeCrockfordBase32(rawHash) + + // Remove hash suffix (everything before the newline) + let withoutHash = String(text[.. ParsedDMReaction? { + // Reject channel format (contains `@[`) + if text.contains("@[") { + return nil + } + + // Split on newline to get hash + guard let newlineIndex = text.lastIndex(of: "\n") else { + return nil + } + + let rawHash = String(text[text.index(after: newlineIndex)...]) + guard rawHash.count == 8, isValidCrockfordBase32(rawHash) else { + return nil + } + let messageHash = normalizeCrockfordBase32(rawHash) + + // Extract emoji (everything before the newline) + let emoji = String(text[.. Bool { - if MeshCoreOpenReactionParser.parse(text) != nil { return true } - if MeshCoreOpenReactionParser.parseV1(text) != nil { return true } - return isDM ? parseDM(text) != nil : parse(text) != nil + // Validate emoji is not empty and starts with emoji character + guard !emoji.isEmpty, emoji.first?.isEmoji == true else { + return nil } - /// Parses reaction text, returns nil if format doesn't match - public static func parse(_ text: String) -> ParsedReaction? { - // Step 1: Split on last newline to get hash - guard let newlineIndex = text.lastIndex(of: "\n") else { - return nil - } - - let rawHash = String(text[text.index(after: newlineIndex)...]) - guard rawHash.count == 8, isValidCrockfordBase32(rawHash) else { - return nil - } - let messageHash = normalizeCrockfordBase32(rawHash) - - // Remove hash suffix (everything before the newline) - let withoutHash = String(text[.. ParsedDMReaction? { - // Reject channel format (contains `@[`) - if text.contains("@[") { - return nil - } - - // Split on newline to get hash - guard let newlineIndex = text.lastIndex(of: "\n") else { - return nil - } - - let rawHash = String(text[text.index(after: newlineIndex)...]) - guard rawHash.count == 8, isValidCrockfordBase32(rawHash) else { - return nil - } - let messageHash = normalizeCrockfordBase32(rawHash) - - // Extract emoji (everything before the newline) - let emoji = String(text[.. String { - let hash = generateMessageHash(text: targetText, timestamp: targetTimestamp) - return "\(emoji)\n\(hash)" - } - - /// Generates message identifier for reaction wire format (8-char Crockford Base32) - public static func generateMessageHash(text: String, timestamp: UInt32) -> String { - var data = Data(text.utf8) - withUnsafeBytes(of: timestamp.littleEndian) { data.append(contentsOf: $0) } - let digest = SHA256.hash(data: data) - let bytes = Array(digest.prefix(5)) - return encodeCrockfordBase32(bytes) - } - - /// Builds summary string from emoji counts, sorted by count descending - static func buildSummary(from reactions: [(emoji: String, count: Int)]) -> String { - reactions - .sorted { $0.count > $1.count } - .map { "\($0.emoji):\($0.count)" } - .joined(separator: ",") - } - - /// Builds summary string from reaction DTOs. - /// Sorts by count descending, then by earliest timestamp ascending for tie-breaker. - static func buildSummary(from reactions: [ReactionDTO]) -> String { - let grouped = Dictionary(grouping: reactions, by: \.emoji) - let sorted = grouped.map { emoji, items in - (emoji: emoji, count: items.count, earliest: items.map(\.receivedAt).min() ?? Date.distantPast) - } - .sorted { lhs, rhs in - if lhs.count != rhs.count { return lhs.count > rhs.count } - return lhs.earliest < rhs.earliest - } - return sorted.map { "\($0.emoji):\($0.count)" }.joined(separator: ",") - } - - /// Parses summary string into emoji/count pairs - public static func parseSummary(_ summary: String?) -> [(emoji: String, count: Int)] { - guard let summary, !summary.isEmpty else { return [] } - - return summary.split(separator: ",").compactMap { part in - let components = part.split(separator: ":") - guard components.count == 2, - let count = Int(components[1]) else { return nil } - return (String(components[0]), count) - } + return ParsedDMReaction(emoji: emoji, messageHash: messageHash) + } + + /// Builds DM reaction text in wire format. + /// Format: `{emoji}\n{hash}` + static func buildDMReactionText( + emoji: String, + targetText: String, + targetTimestamp: UInt32 + ) -> String { + let hash = generateMessageHash(text: targetText, timestamp: targetTimestamp) + return "\(emoji)\n\(hash)" + } + + /// Generates message identifier for reaction wire format (8-char Crockford Base32) + public static func generateMessageHash(text: String, timestamp: UInt32) -> String { + var data = Data(text.utf8) + withUnsafeBytes(of: timestamp.littleEndian) { data.append(contentsOf: $0) } + let digest = SHA256.hash(data: data) + let bytes = Array(digest.prefix(5)) + return encodeCrockfordBase32(bytes) + } + + /// Builds summary string from emoji counts, sorted by count descending + static func buildSummary(from reactions: [(emoji: String, count: Int)]) -> String { + reactions + .sorted { $0.count > $1.count } + .map { "\($0.emoji):\($0.count)" } + .joined(separator: ",") + } + + /// Builds summary string from reaction DTOs. + /// Sorts by count descending, then by earliest timestamp ascending for tie-breaker. + static func buildSummary(from reactions: [ReactionDTO]) -> String { + let grouped = Dictionary(grouping: reactions, by: \.emoji) + let sorted = grouped.map { emoji, items in + (emoji: emoji, count: items.count, earliest: items.map(\.receivedAt).min() ?? Date.distantPast) + } + .sorted { lhs, rhs in + if lhs.count != rhs.count { return lhs.count > rhs.count } + return lhs.earliest < rhs.earliest + } + return sorted.map { "\($0.emoji):\($0.count)" }.joined(separator: ",") + } + + /// Parses summary string into emoji/count pairs + public static func parseSummary(_ summary: String?) -> [(emoji: String, count: Int)] { + guard let summary, !summary.isEmpty else { return [] } + + return summary.split(separator: ",").compactMap { part in + let components = part.split(separator: ":") + guard components.count == 2, + let count = Int(components[1]) else { return nil } + return (String(components[0]), count) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/ReactionService.swift b/MC1Services/Sources/MC1Services/Services/ReactionService.swift index 698470ea..eddb043c 100644 --- a/MC1Services/Sources/MC1Services/Services/ReactionService.swift +++ b/MC1Services/Sources/MC1Services/Services/ReactionService.swift @@ -1,323 +1,323 @@ -import Foundation import CryptoKit +import Foundation import OSLog /// A reaction waiting for its target message to be indexed public struct PendingReaction: Sendable { - public let parsed: ParsedReaction - public let channelIndex: UInt8 - public let senderNodeName: String - public let rawText: String - public let radioID: UUID - public let receivedAt: Date + public let parsed: ParsedReaction + public let channelIndex: UInt8 + public let senderNodeName: String + public let rawText: String + public let radioID: UUID + public let receivedAt: Date } /// A DM reaction waiting for its target message to be indexed public struct PendingDMReaction: Sendable { - public let parsed: ParsedDMReaction - public let contactID: UUID - public let senderName: String - public let rawText: String - public let radioID: UUID - public let receivedAt: Date + public let parsed: ParsedDMReaction + public let contactID: UUID + public let senderName: String + public let rawText: String + public let radioID: UUID + public let receivedAt: Date } /// Result of persisting a reaction public struct ReactionPersistResult: Sendable { - public let messageID: UUID - public let summary: String + public let messageID: UUID + public let summary: String } /// Service for handling emoji reactions on channel messages public actor ReactionService { - private let logger = Logger(subsystem: "com.mc1", category: "ReactionService") - private let messageCache: MessageLRUCache - - private static let maxPendingReactions = 100 - - // Pending reactions queue: no TTL, session lifetime. - private var pendingReactions: [PendingReactionKey: [PendingReaction]] = [:] - private var pendingOrder: [PendingReactionKey] = [] - - private struct PendingReactionKey: Hashable { - let channelIndex: UInt8 - let targetSender: String - let messageHash: String + private let logger = Logger(subsystem: "com.mc1", category: "ReactionService") + private let messageCache: MessageLRUCache + + private static let maxPendingReactions = 100 + + // Pending reactions queue: no TTL, session lifetime. + private var pendingReactions: [PendingReactionKey: [PendingReaction]] = [:] + private var pendingOrder: [PendingReactionKey] = [] + + private struct PendingReactionKey: Hashable { + let channelIndex: UInt8 + let targetSender: String + let messageHash: String + } + + private struct PendingDMReactionKey: Hashable { + let contactID: UUID + let messageHash: String + } + + private var pendingDMReactions: [PendingDMReactionKey: [PendingDMReaction]] = [:] + private var pendingDMOrder: [PendingDMReactionKey] = [] + + init(messageCache: MessageLRUCache = MessageLRUCache()) { + self.messageCache = messageCache + } + + /// Indexes a message for reaction matching and returns any pending reactions that now match + public func indexMessage( + id: UUID, + channelIndex: UInt8, + senderName: String, + text: String, + timestamp: UInt32 + ) async -> [PendingReaction] { + await messageCache.index( + messageID: id, + channelIndex: channelIndex, + senderName: senderName, + text: text, + timestamp: timestamp + ) + + // Check pending queue for matching reactions + let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) + let key = PendingReactionKey( + channelIndex: channelIndex, + targetSender: senderName, + messageHash: hash + ) + + guard let matched = pendingReactions.removeValue(forKey: key) else { + return [] } - private struct PendingDMReactionKey: Hashable { - let contactID: UUID - let messageHash: String + pendingOrder.removeAll { $0 == key } + + logger.debug("Matched \(matched.count) pending reaction(s) to message \(id)") + + return matched + } + + /// Builds reaction wire format text for sending + /// Format: `{emoji}@[{sender}]\n{hash}` + public nonisolated func buildReactionText( + emoji: String, + targetSender: String, + targetText: String, + targetTimestamp: UInt32 + ) -> String { + let hash = ReactionParser.generateMessageHash(text: targetText, timestamp: targetTimestamp) + return "\(emoji)@[\(targetSender)]\n\(hash)" + } + + /// Builds DM reaction wire format (shorter, no sender) + public nonisolated func buildDMReactionText( + emoji: String, + targetText: String, + targetTimestamp: UInt32 + ) -> String { + ReactionParser.buildDMReactionText( + emoji: emoji, + targetText: targetText, + targetTimestamp: targetTimestamp + ) + } + + /// Finds target message ID for a parsed reaction using hash-based lookup + func findTargetMessage(parsed: ParsedReaction, channelIndex: UInt8) async -> UUID? { + let candidates = await messageCache.lookup( + channelIndex: channelIndex, + senderName: parsed.targetSender, + messageHash: parsed.messageHash + ) + + // Return most recently indexed match + return candidates.max(by: { $0.indexedAt < $1.indexedAt })?.messageID + } + + /// Attempts to process incoming text as a reaction + /// Returns true if handled as reaction, false to process as regular message + nonisolated func tryProcessAsReaction(_ text: String) -> ParsedReaction? { + ReactionParser.parse(text) + } + + /// Queues a reaction that couldn't find its target message + func queuePendingReaction( + parsed: ParsedReaction, + channelIndex: UInt8, + senderNodeName: String, + rawText: String, + radioID: UUID + ) { + let key = PendingReactionKey( + channelIndex: channelIndex, + targetSender: parsed.targetSender, + messageHash: parsed.messageHash + ) + let pending = PendingReaction( + parsed: parsed, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + rawText: rawText, + radioID: radioID, + receivedAt: Date() + ) + + if pendingReactions[key] != nil { + pendingReactions[key]!.append(pending) + } else { + pendingReactions[key] = [pending] + pendingOrder.append(key) } - private var pendingDMReactions: [PendingDMReactionKey: [PendingDMReaction]] = [:] - private var pendingDMOrder: [PendingDMReactionKey] = [] - - init(messageCache: MessageLRUCache = MessageLRUCache()) { - self.messageCache = messageCache - } - - /// Indexes a message for reaction matching and returns any pending reactions that now match - public func indexMessage( - id: UUID, - channelIndex: UInt8, - senderName: String, - text: String, - timestamp: UInt32 - ) async -> [PendingReaction] { - await messageCache.index( - messageID: id, - channelIndex: channelIndex, - senderName: senderName, - text: text, - timestamp: timestamp - ) - - // Check pending queue for matching reactions - let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) - let key = PendingReactionKey( - channelIndex: channelIndex, - targetSender: senderName, - messageHash: hash - ) - - guard let matched = pendingReactions.removeValue(forKey: key) else { - return [] - } - - pendingOrder.removeAll { $0 == key } - - logger.debug("Matched \(matched.count) pending reaction(s) to message \(id)") - - return matched + evictIfNeeded() + logger.debug("Queued pending reaction \(parsed.emoji) for \(parsed.targetSender)") + } + + /// Clears all pending reactions (call on disconnect) + func clearPendingReactions() { + let count = pendingReactions.values.reduce(0) { $0 + $1.count } + let dmCount = pendingDMReactions.values.reduce(0) { $0 + $1.count } + pendingReactions.removeAll() + pendingOrder.removeAll() + pendingDMReactions.removeAll() + pendingDMOrder.removeAll() + if count + dmCount > 0 { + logger.debug("Cleared \(count + dmCount) pending reaction(s)") } - - /// Builds reaction wire format text for sending - /// Format: `{emoji}@[{sender}]\n{hash}` - public nonisolated func buildReactionText( - emoji: String, - targetSender: String, - targetText: String, - targetTimestamp: UInt32 - ) -> String { - let hash = ReactionParser.generateMessageHash(text: targetText, timestamp: targetTimestamp) - return "\(emoji)@[\(targetSender)]\n\(hash)" + } + + // MARK: - Persistence + + /// Persists a reaction and updates the message's reaction summary. + /// Logs errors instead of silently discarding them. + public func persistReactionAndUpdateSummary( + _ reaction: ReactionDTO, + using dataStore: any PersistenceStoreProtocol + ) async -> ReactionPersistResult? { + do { + try await dataStore.saveReaction(reaction) + } catch { + logger.error("Failed to save reaction for message \(reaction.messageID): \(error.localizedDescription)") + return nil } - /// Builds DM reaction wire format (shorter, no sender) - public nonisolated func buildDMReactionText( - emoji: String, - targetText: String, - targetTimestamp: UInt32 - ) -> String { - ReactionParser.buildDMReactionText( - emoji: emoji, - targetText: targetText, - targetTimestamp: targetTimestamp - ) + let reactions: [ReactionDTO] + do { + reactions = try await dataStore.fetchReactions(for: reaction.messageID) + } catch { + logger.error("Failed to fetch reactions for message \(reaction.messageID): \(error.localizedDescription)") + return nil } - /// Finds target message ID for a parsed reaction using hash-based lookup - func findTargetMessage(parsed: ParsedReaction, channelIndex: UInt8) async -> UUID? { - let candidates = await messageCache.lookup( - channelIndex: channelIndex, - senderName: parsed.targetSender, - messageHash: parsed.messageHash - ) - - // Return most recently indexed match - return candidates.max(by: { $0.indexedAt < $1.indexedAt })?.messageID + let summary = ReactionParser.buildSummary(from: reactions) + do { + try await dataStore.updateMessageReactionSummary(messageID: reaction.messageID, summary: summary) + } catch { + logger.error("Failed to update reaction summary for message \(reaction.messageID): \(error.localizedDescription)") + return nil } - /// Attempts to process incoming text as a reaction - /// Returns true if handled as reaction, false to process as regular message - nonisolated func tryProcessAsReaction(_ text: String) -> ParsedReaction? { - ReactionParser.parse(text) + return ReactionPersistResult(messageID: reaction.messageID, summary: summary) + } + + // MARK: - DM Reactions + + /// Indexes a DM message for reaction matching and returns any pending reactions that now match + public func indexDMMessage( + id: UUID, + contactID: UUID, + text: String, + timestamp: UInt32 + ) async -> [PendingDMReaction] { + await messageCache.indexDM( + messageID: id, + contactID: contactID, + text: text, + timestamp: timestamp + ) + + // Check pending queue for matching reactions + let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) + let key = PendingDMReactionKey(contactID: contactID, messageHash: hash) + + guard let matched = pendingDMReactions.removeValue(forKey: key) else { + return [] } - /// Queues a reaction that couldn't find its target message - func queuePendingReaction( - parsed: ParsedReaction, - channelIndex: UInt8, - senderNodeName: String, - rawText: String, - radioID: UUID - ) { - let key = PendingReactionKey( - channelIndex: channelIndex, - targetSender: parsed.targetSender, - messageHash: parsed.messageHash - ) - let pending = PendingReaction( - parsed: parsed, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - rawText: rawText, - radioID: radioID, - receivedAt: Date() - ) - - if pendingReactions[key] != nil { - pendingReactions[key]!.append(pending) - } else { - pendingReactions[key] = [pending] - pendingOrder.append(key) - } - - evictIfNeeded() - logger.debug("Queued pending reaction \(parsed.emoji) for \(parsed.targetSender)") + pendingDMOrder.removeAll { $0 == key } + + logger.debug("Matched \(matched.count) pending DM reaction(s) to message \(id)") + + return matched + } + + /// Finds target DM message ID by hash and contact + func findDMTargetMessage(messageHash: String, contactID: UUID) async -> UUID? { + let candidates = await messageCache.lookupDM(contactID: contactID, messageHash: messageHash) + return candidates.max(by: { $0.indexedAt < $1.indexedAt })?.messageID + } + + /// Queues a DM reaction that couldn't find its target message + func queuePendingDMReaction( + parsed: ParsedDMReaction, + contactID: UUID, + senderName: String, + rawText: String, + radioID: UUID + ) { + let key = PendingDMReactionKey(contactID: contactID, messageHash: parsed.messageHash) + let pending = PendingDMReaction( + parsed: parsed, + contactID: contactID, + senderName: senderName, + rawText: rawText, + radioID: radioID, + receivedAt: Date() + ) + + if pendingDMReactions[key] != nil { + pendingDMReactions[key]!.append(pending) + } else { + pendingDMReactions[key] = [pending] + pendingDMOrder.append(key) } - /// Clears all pending reactions (call on disconnect) - func clearPendingReactions() { - let count = pendingReactions.values.reduce(0) { $0 + $1.count } - let dmCount = pendingDMReactions.values.reduce(0) { $0 + $1.count } - pendingReactions.removeAll() - pendingOrder.removeAll() - pendingDMReactions.removeAll() - pendingDMOrder.removeAll() - if count + dmCount > 0 { - logger.debug("Cleared \(count + dmCount) pending reaction(s)") - } - } + evictDMIfNeeded() + logger.debug("Queued pending DM reaction \(parsed.emoji)") + } - // MARK: - Persistence - - /// Persists a reaction and updates the message's reaction summary. - /// Logs errors instead of silently discarding them. - public func persistReactionAndUpdateSummary( - _ reaction: ReactionDTO, - using dataStore: any PersistenceStoreProtocol - ) async -> ReactionPersistResult? { - do { - try await dataStore.saveReaction(reaction) - } catch { - logger.error("Failed to save reaction for message \(reaction.messageID): \(error.localizedDescription)") - return nil - } + private func evictIfNeeded() { + var totalCount = pendingReactions.values.reduce(0) { $0 + $1.count } - let reactions: [ReactionDTO] - do { - reactions = try await dataStore.fetchReactions(for: reaction.messageID) - } catch { - logger.error("Failed to fetch reactions for message \(reaction.messageID): \(error.localizedDescription)") - return nil - } + while totalCount > Self.maxPendingReactions, let oldestKey = pendingOrder.first { + if var entries = pendingReactions[oldestKey], !entries.isEmpty { + entries.removeFirst() + totalCount -= 1 - let summary = ReactionParser.buildSummary(from: reactions) - do { - try await dataStore.updateMessageReactionSummary(messageID: reaction.messageID, summary: summary) - } catch { - logger.error("Failed to update reaction summary for message \(reaction.messageID): \(error.localizedDescription)") - return nil + if entries.isEmpty { + pendingReactions.removeValue(forKey: oldestKey) + pendingOrder.removeFirst() + } else { + pendingReactions[oldestKey] = entries } - - return ReactionPersistResult(messageID: reaction.messageID, summary: summary) + } else { + pendingOrder.removeFirst() + } } + } - // MARK: - DM Reactions - - /// Indexes a DM message for reaction matching and returns any pending reactions that now match - public func indexDMMessage( - id: UUID, - contactID: UUID, - text: String, - timestamp: UInt32 - ) async -> [PendingDMReaction] { - await messageCache.indexDM( - messageID: id, - contactID: contactID, - text: text, - timestamp: timestamp - ) - - // Check pending queue for matching reactions - let hash = ReactionParser.generateMessageHash(text: text, timestamp: timestamp) - let key = PendingDMReactionKey(contactID: contactID, messageHash: hash) - - guard let matched = pendingDMReactions.removeValue(forKey: key) else { - return [] - } - - pendingDMOrder.removeAll { $0 == key } - - logger.debug("Matched \(matched.count) pending DM reaction(s) to message \(id)") - - return matched - } + private func evictDMIfNeeded() { + var totalCount = pendingDMReactions.values.reduce(0) { $0 + $1.count } - /// Finds target DM message ID by hash and contact - func findDMTargetMessage(messageHash: String, contactID: UUID) async -> UUID? { - let candidates = await messageCache.lookupDM(contactID: contactID, messageHash: messageHash) - return candidates.max(by: { $0.indexedAt < $1.indexedAt })?.messageID - } + while totalCount > Self.maxPendingReactions, let oldestKey = pendingDMOrder.first { + if var entries = pendingDMReactions[oldestKey], !entries.isEmpty { + entries.removeFirst() + totalCount -= 1 - /// Queues a DM reaction that couldn't find its target message - func queuePendingDMReaction( - parsed: ParsedDMReaction, - contactID: UUID, - senderName: String, - rawText: String, - radioID: UUID - ) { - let key = PendingDMReactionKey(contactID: contactID, messageHash: parsed.messageHash) - let pending = PendingDMReaction( - parsed: parsed, - contactID: contactID, - senderName: senderName, - rawText: rawText, - radioID: radioID, - receivedAt: Date() - ) - - if pendingDMReactions[key] != nil { - pendingDMReactions[key]!.append(pending) + if entries.isEmpty { + pendingDMReactions.removeValue(forKey: oldestKey) + pendingDMOrder.removeFirst() } else { - pendingDMReactions[key] = [pending] - pendingDMOrder.append(key) - } - - evictDMIfNeeded() - logger.debug("Queued pending DM reaction \(parsed.emoji)") - } - - private func evictIfNeeded() { - var totalCount = pendingReactions.values.reduce(0) { $0 + $1.count } - - while totalCount > Self.maxPendingReactions, let oldestKey = pendingOrder.first { - if var entries = pendingReactions[oldestKey], !entries.isEmpty { - entries.removeFirst() - totalCount -= 1 - - if entries.isEmpty { - pendingReactions.removeValue(forKey: oldestKey) - pendingOrder.removeFirst() - } else { - pendingReactions[oldestKey] = entries - } - } else { - pendingOrder.removeFirst() - } - } - } - - private func evictDMIfNeeded() { - var totalCount = pendingDMReactions.values.reduce(0) { $0 + $1.count } - - while totalCount > Self.maxPendingReactions, let oldestKey = pendingDMOrder.first { - if var entries = pendingDMReactions[oldestKey], !entries.isEmpty { - entries.removeFirst() - totalCount -= 1 - - if entries.isEmpty { - pendingDMReactions.removeValue(forKey: oldestKey) - pendingDMOrder.removeFirst() - } else { - pendingDMReactions[oldestKey] = entries - } - } else { - pendingDMOrder.removeFirst() - } + pendingDMReactions[oldestKey] = entries } + } else { + pendingDMOrder.removeFirst() + } } + } } diff --git a/MC1Services/Sources/MC1Services/Services/RegionDiscoveryService.swift b/MC1Services/Sources/MC1Services/Services/RegionDiscoveryService.swift index 429fe747..40bf219b 100644 --- a/MC1Services/Sources/MC1Services/Services/RegionDiscoveryService.swift +++ b/MC1Services/Sources/MC1Services/Services/RegionDiscoveryService.swift @@ -8,180 +8,179 @@ import MeshCore /// onboarding redesign) use "region" to mean *user geographic location*. These /// two concepts share a word but no semantic overlap. public enum RegionDiscoveryService { - - /// Filter value for DISCOVER_REQ that requests only repeaters. - /// Mirrors `NodeDiscoveryFilter.repeaters.filterValue` in the app target. - public static let repeatersFilter: UInt8 = 0x04 - - /// How long to listen for DISCOVER_RESP before closing the event stream. - /// Matches the scan duration used by NodeDiscoveryViewModel so flood-routed - /// responses from distant mesh nodes have time to arrive. - public static let listenDuration: Duration = .seconds(15) - - /// Firmware error code for "contact table full" responses (`ERR_CODE_TABLE_FULL`). - private static let tableFullErrorCode: UInt8 = 3 - - public enum Outcome: Sendable { - /// The DISCOVER_REQ itself failed to send. - case sendFailed - /// No repeaters responded to the probe, or no query targets could be built. - case noRepeatersResponded - /// Could not load the contact pool used to route region queries. - case errorLoadingRepeaters - /// Discovery completed. `newRegions` is filtered against the caller's - /// `knownRegions` and sorted. `allRepeatersTableFull` is `true` when every - /// successful query returned `ERR_CODE_TABLE_FULL` from the repeater. - case completed(newRegions: [String], allRepeatersTableFull: Bool) - } - - /// Runs the discovery probe and aggregates regions from all responders. - /// - /// - Parameters: - /// - session: Active mesh session for the connected device. - /// - contactService: Source of the caller's existing contacts; used to find - /// routing data for repeaters the user has already added. - /// - dataStore: Optional offline data store; when provided, responders that - /// aren't contacts are matched against the discovered-nodes table. - /// - radioID: Radio identifier used to scope contact and discovered-node lookups. - /// - knownRegions: Regions the caller already knows about; subtracted from the result. - /// - supportsAdHocRequest: Whether the radio can query a repeater that isn't a - /// contact (firmware v1.16+, `DeviceDTO.supportsAdHocRepeaterRequest`). When - /// `false`, only responders the user has already added as contacts are queried; - /// anonymous requests to non-contact keys would be rejected by the local radio. - public static func discover( - session: MeshCoreSession, - contactService: ContactService, - dataStore: PersistenceStore?, - radioID: UUID, - knownRegions: [String], - supportsAdHocRequest: Bool - ) async -> Outcome { - let discoveredPubkeys: Set - do { - let tag = try await session.sendNodeDiscoverRequest( - filter: repeatersFilter, - prefixOnly: false - ) - let tagData = withUnsafeBytes(of: tag.littleEndian) { Data($0) } - - let (subscriptionID, events) = await session.eventsTracked() - let listenTask = Task { () -> Set in - var keys = Set() - for await event in events { - if case .discoverResponse(let response) = event, - response.tag == tagData { - keys.insert(response.publicKey) - } - } - return keys - } - - try? await Task.sleep(for: listenDuration) - await session.finishEvents(id: subscriptionID) - discoveredPubkeys = await listenTask.value - } catch { - return .sendFailed + /// Filter value for DISCOVER_REQ that requests only repeaters. + /// Mirrors `NodeDiscoveryFilter.repeaters.filterValue` in the app target. + public static let repeatersFilter: UInt8 = 0x04 + + /// How long to listen for DISCOVER_RESP before closing the event stream. + /// Matches the scan duration used by NodeDiscoveryViewModel so flood-routed + /// responses from distant mesh nodes have time to arrive. + public static let listenDuration: Duration = .seconds(15) + + /// Firmware error code for "contact table full" responses (`ERR_CODE_TABLE_FULL`). + private static let tableFullErrorCode: UInt8 = 3 + + public enum Outcome: Sendable { + /// The DISCOVER_REQ itself failed to send. + case sendFailed + /// No repeaters responded to the probe, or no query targets could be built. + case noRepeatersResponded + /// Could not load the contact pool used to route region queries. + case errorLoadingRepeaters + /// Discovery completed. `newRegions` is filtered against the caller's + /// `knownRegions` and sorted. `allRepeatersTableFull` is `true` when every + /// successful query returned `ERR_CODE_TABLE_FULL` from the repeater. + case completed(newRegions: [String], allRepeatersTableFull: Bool) + } + + /// Runs the discovery probe and aggregates regions from all responders. + /// + /// - Parameters: + /// - session: Active mesh session for the connected device. + /// - contactService: Source of the caller's existing contacts; used to find + /// routing data for repeaters the user has already added. + /// - dataStore: Optional offline data store; when provided, responders that + /// aren't contacts are matched against the discovered-nodes table. + /// - radioID: Radio identifier used to scope contact and discovered-node lookups. + /// - knownRegions: Regions the caller already knows about; subtracted from the result. + /// - supportsAdHocRequest: Whether the radio can query a repeater that isn't a + /// contact (firmware v1.16+, `DeviceDTO.supportsAdHocRepeaterRequest`). When + /// `false`, only responders the user has already added as contacts are queried; + /// anonymous requests to non-contact keys would be rejected by the local radio. + public static func discover( + session: MeshCoreSession, + contactService: ContactService, + dataStore: PersistenceStore?, + radioID: UUID, + knownRegions: [String], + supportsAdHocRequest: Bool + ) async -> Outcome { + let discoveredPubkeys: Set + do { + let tag = try await session.sendNodeDiscoverRequest( + filter: repeatersFilter, + prefixOnly: false + ) + let tagData = withUnsafeBytes(of: tag.littleEndian) { Data($0) } + + let (subscriptionID, events) = await session.eventsTracked() + let listenTask = Task { () -> Set in + var keys = Set() + for await event in events { + if case let .discoverResponse(response) = event, + response.tag == tagData { + keys.insert(response.publicKey) + } } + return keys + } + + try? await Task.sleep(for: listenDuration) + await session.finishEvents(id: subscriptionID) + discoveredPubkeys = await listenTask.value + } catch { + return .sendFailed + } - guard !Task.isCancelled else { return .sendFailed } - - if discoveredPubkeys.isEmpty { - return .noRepeatersResponded - } + guard !Task.isCancelled else { return .sendFailed } - let queryTargets: [MeshContact] - do { - let contacts = try await contactService.getContacts(radioID: radioID) - let discoveredNodes = (try? await dataStore?.fetchDiscoveredNodes(radioID: radioID)) ?? [] - queryTargets = buildRegionQueryTargets( - responders: discoveredPubkeys, - contacts: contacts, - discoveredNodes: discoveredNodes, - supportsAdHocRequest: supportsAdHocRequest - ) - } catch { - return .errorLoadingRepeaters - } - - if queryTargets.isEmpty { - return .noRepeatersResponded - } + if discoveredPubkeys.isEmpty { + return .noRepeatersResponded + } - var allRegions = Set() - var anyTableFull = false - - await withTaskGroup(of: RegionQueryOutcome.self) { group in - for meshContact in queryTargets { - guard !Task.isCancelled else { break } - group.addTask { - do { - let regions = try await session.requestRegions(from: meshContact) - return .regions(regions) - } catch let MeshCoreError.deviceError(code) where code == tableFullErrorCode { - return .tableFull - } catch { - return .otherFailure - } - } - } - for await outcome in group { - switch outcome { - case .regions(let regions): - allRegions.formUnion(regions) - case .tableFull: - anyTableFull = true - case .otherFailure: - break - } - } - } + let queryTargets: [MeshContact] + do { + let contacts = try await contactService.getContacts(radioID: radioID) + let discoveredNodes = await (try? dataStore?.fetchDiscoveredNodes(radioID: radioID)) ?? [] + queryTargets = buildRegionQueryTargets( + responders: discoveredPubkeys, + contacts: contacts, + discoveredNodes: discoveredNodes, + supportsAdHocRequest: supportsAdHocRequest + ) + } catch { + return .errorLoadingRepeaters + } - let knownSet = Set(knownRegions) - let newRegions = allRegions.subtracting(knownSet).sorted() - return .completed(newRegions: newRegions, allRepeatersTableFull: anyTableFull) + if queryTargets.isEmpty { + return .noRepeatersResponded } - /// Builds the `MeshContact` query pool used to fetch regions from each responder. - /// Prefers contact records (they carry direct routing data when available) and fills - /// in from the discovered-nodes table for responders the user has not added as contacts. - /// - /// Non-contact responders are only included when `supportsAdHocRequest` is `true`: - /// older firmware rejects an anonymous request to a key it doesn't hold as a contact, - /// so querying them would only produce spurious failures. - static func buildRegionQueryTargets( - responders: Set, - contacts: [ContactDTO], - discoveredNodes: [DiscoveredNodeDTO], - supportsAdHocRequest: Bool - ) -> [MeshContact] { - var byKey: [Data: MeshContact] = [:] - for contact in contacts where contact.type == .repeater && responders.contains(contact.publicKey) { - byKey[contact.publicKey] = contact.toContactFrame().toMeshContact() + var allRegions = Set() + var anyTableFull = false + + await withTaskGroup(of: RegionQueryOutcome.self) { group in + for meshContact in queryTargets { + guard !Task.isCancelled else { break } + group.addTask { + do { + let regions = try await session.requestRegions(from: meshContact) + return .regions(regions) + } catch let MeshCoreError.deviceError(code) where code == tableFullErrorCode { + return .tableFull + } catch { + return .otherFailure + } } - guard supportsAdHocRequest else { return Array(byKey.values) } - for node in discoveredNodes where node.nodeType == .repeater - && responders.contains(node.publicKey) - && byKey[node.publicKey] == nil { - let frame = ContactFrame( - publicKey: node.publicKey, - type: node.nodeType, - flags: 0, - outPathLength: node.outPathLength, - outPath: node.outPath, - name: node.name, - lastAdvertTimestamp: node.lastAdvertTimestamp, - latitude: node.latitude, - longitude: node.longitude, - lastModified: 0 - ) - byKey[node.publicKey] = frame.toMeshContact() + } + for await outcome in group { + switch outcome { + case let .regions(regions): + allRegions.formUnion(regions) + case .tableFull: + anyTableFull = true + case .otherFailure: + break } - return Array(byKey.values) + } } - private enum RegionQueryOutcome: Sendable { - case regions([String]) - case tableFull - case otherFailure + let knownSet = Set(knownRegions) + let newRegions = allRegions.subtracting(knownSet).sorted() + return .completed(newRegions: newRegions, allRepeatersTableFull: anyTableFull) + } + + /// Builds the `MeshContact` query pool used to fetch regions from each responder. + /// Prefers contact records (they carry direct routing data when available) and fills + /// in from the discovered-nodes table for responders the user has not added as contacts. + /// + /// Non-contact responders are only included when `supportsAdHocRequest` is `true`: + /// older firmware rejects an anonymous request to a key it doesn't hold as a contact, + /// so querying them would only produce spurious failures. + static func buildRegionQueryTargets( + responders: Set, + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + supportsAdHocRequest: Bool + ) -> [MeshContact] { + var byKey: [Data: MeshContact] = [:] + for contact in contacts where contact.type == .repeater && responders.contains(contact.publicKey) { + byKey[contact.publicKey] = contact.toContactFrame().toMeshContact() + } + guard supportsAdHocRequest else { return Array(byKey.values) } + for node in discoveredNodes where node.nodeType == .repeater + && responders.contains(node.publicKey) + && byKey[node.publicKey] == nil { + let frame = ContactFrame( + publicKey: node.publicKey, + type: node.nodeType, + flags: 0, + outPathLength: node.outPathLength, + outPath: node.outPath, + name: node.name, + lastAdvertTimestamp: node.lastAdvertTimestamp, + latitude: node.latitude, + longitude: node.longitude, + lastModified: 0 + ) + byKey[node.publicKey] = frame.toMeshContact() } + return Array(byKey.values) + } + + private enum RegionQueryOutcome { + case regions([String]) + case tableFull + case otherFailure + } } diff --git a/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift b/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift index bcadfda9..0c11f9fd 100644 --- a/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift +++ b/MC1Services/Sources/MC1Services/Services/RegionalAreas.swift @@ -7,178 +7,177 @@ import Foundation /// mean a *firmware mesh region* (named flood-routing scope on a repeater). /// This file's "region" vocabulary refers to *user geographic location*. public enum RegionalAreas { + public struct Country: Sendable, Identifiable { + public let id: String // ISO α-2 + public let subdivisions: [Subdivision]? - public struct Country: Sendable, Identifiable { - public let id: String // ISO α-2 - public let subdivisions: [Subdivision]? - - public var localizedName: String { - Locale.current.localizedString(forRegionCode: id) ?? id - } + public var localizedName: String { + Locale.current.localizedString(forRegionCode: id) ?? id } + } - public struct Subdivision: Sendable, Identifiable { - public let id: String // ISO 3166-2 code - public let normalizedNames: Set // lowercased, diacritic-folded matchers from CLPlacemark - public let nameKey: String // L10n key under Settings.strings - - public init(id: String, normalizedNames: Set, nameKey: String) { - self.id = id - self.normalizedNames = normalizedNames - self.nameKey = nameKey - } - } + public struct Subdivision: Sendable, Identifiable { + public let id: String // ISO 3166-2 code + public let normalizedNames: Set // lowercased, diacritic-folded matchers from CLPlacemark + public let nameKey: String // L10n key under Settings.strings - public static let usSubdivisions: [Subdivision] = [ - Subdivision(id: "US-CA", - normalizedNames: ["california", "ca"], - nameKey: "region.subdivision.US-CA"), - ] - - public static let auSubdivisions: [Subdivision] = [ - Subdivision(id: "AU-QLD", - normalizedNames: ["queensland", "qld"], - nameKey: "region.subdivision.AU-QLD"), - Subdivision(id: "AU-SA", - normalizedNames: ["south australia", "sa"], - nameKey: "region.subdivision.AU-SA"), - Subdivision(id: "AU-WA", - normalizedNames: ["western australia", "wa"], - nameKey: "region.subdivision.AU-WA"), - ] - - /// ISO α-2 → `RadioRegion` mapping. Mexico (MX) and Africa are intentionally - /// absent — those countries fall through `recommended(for:)` to the - /// empty-region fallback. South America is currently limited to Chile (CL). - public static let continents: [String: RadioRegion] = [ - // North America - "US": .northAmerica, "CA": .northAmerica, - // South America - "CL": .southAmerica, - // Europe - "GB": .europe, "IE": .europe, "DE": .europe, "FR": .europe, - "IT": .europe, "ES": .europe, "PT": .europe, "NL": .europe, - "BE": .europe, "CH": .europe, "AT": .europe, "CZ": .europe, - "PL": .europe, "DK": .europe, "SE": .europe, "NO": .europe, - "FI": .europe, "GR": .europe, "HU": .europe, "RO": .europe, - // Oceania - "AU": .oceania, "NZ": .oceania, - // Asia - "VN": .asia, "TH": .asia, "MY": .asia, "SG": .asia, - "PH": .asia, "ID": .asia, "JP": .asia, "KR": .asia, - ] - - /// `countries` sorted by `localizedName` once at first access. The order freezes for - /// the process lifetime — acceptable because iOS app-language changes require relaunch, - /// so the user never sees a stale order in practice. - public static let countriesSortedByLocalizedName: [Country] = countries.sorted { - $0.localizedName < $1.localizedName + public init(id: String, normalizedNames: Set, nameKey: String) { + self.id = id + self.normalizedNames = normalizedNames + self.nameKey = nameKey } - - public static let countries: [Country] = [ - Country(id: "US", subdivisions: usSubdivisions), - Country(id: "CA", subdivisions: nil), - Country(id: "CL", subdivisions: nil), - Country(id: "AU", subdivisions: auSubdivisions), - Country(id: "NZ", subdivisions: nil), - Country(id: "GB", subdivisions: nil), - Country(id: "IE", subdivisions: nil), - Country(id: "DE", subdivisions: nil), - Country(id: "FR", subdivisions: nil), - Country(id: "IT", subdivisions: nil), - Country(id: "ES", subdivisions: nil), - Country(id: "PT", subdivisions: nil), - Country(id: "NL", subdivisions: nil), - Country(id: "BE", subdivisions: nil), - Country(id: "CH", subdivisions: nil), - Country(id: "AT", subdivisions: nil), - Country(id: "CZ", subdivisions: nil), - Country(id: "PL", subdivisions: nil), - Country(id: "DK", subdivisions: nil), - Country(id: "SE", subdivisions: nil), - Country(id: "NO", subdivisions: nil), - Country(id: "FI", subdivisions: nil), - Country(id: "GR", subdivisions: nil), - Country(id: "HU", subdivisions: nil), - Country(id: "RO", subdivisions: nil), - Country(id: "VN", subdivisions: nil), - Country(id: "TH", subdivisions: nil), - Country(id: "MY", subdivisions: nil), - Country(id: "SG", subdivisions: nil), - Country(id: "PH", subdivisions: nil), - Country(id: "ID", subdivisions: nil), - Country(id: "JP", subdivisions: nil), - Country(id: "KR", subdivisions: nil), - ] - - /// Normalized US county names (lowercased, diacritic-folded, "county" suffix stripped), - /// indexed by ISO 3166-2 state code. Only states with county-scoped presets are filled. - public static let usCounties: [String: Set] = [ - "US-CA": [ - "los angeles", "orange", "san diego", "riverside", "san bernardino", - "ventura", "imperial", "kern", "santa barbara", "san luis obispo", - ], - ] - - /// Returns the subdivisions catalog for an ISO α-2 country code, or an empty array - /// when the country has no sub-region presets (or the code is unknown). - public static func subdivisions(for country: String?) -> [Subdivision] { - guard let country, - let entry = countries.first(where: { $0.id == country }) else { return [] } - return entry.subdivisions ?? [] + } + + public static let usSubdivisions: [Subdivision] = [ + Subdivision(id: "US-CA", + normalizedNames: ["california", "ca"], + nameKey: "region.subdivision.US-CA"), + ] + + public static let auSubdivisions: [Subdivision] = [ + Subdivision(id: "AU-QLD", + normalizedNames: ["queensland", "qld"], + nameKey: "region.subdivision.AU-QLD"), + Subdivision(id: "AU-SA", + normalizedNames: ["south australia", "sa"], + nameKey: "region.subdivision.AU-SA"), + Subdivision(id: "AU-WA", + normalizedNames: ["western australia", "wa"], + nameKey: "region.subdivision.AU-WA"), + ] + + /// ISO α-2 → `RadioRegion` mapping. Mexico (MX) and Africa are intentionally + /// absent — those countries fall through `recommended(for:)` to the + /// empty-region fallback. South America is currently limited to Chile (CL). + public static let continents: [String: RadioRegion] = [ + // North America + "US": .northAmerica, "CA": .northAmerica, + // South America + "CL": .southAmerica, + // Europe + "GB": .europe, "IE": .europe, "DE": .europe, "FR": .europe, + "IT": .europe, "ES": .europe, "PT": .europe, "NL": .europe, + "BE": .europe, "CH": .europe, "AT": .europe, "CZ": .europe, + "PL": .europe, "DK": .europe, "SE": .europe, "NO": .europe, + "FI": .europe, "GR": .europe, "HU": .europe, "RO": .europe, + // Oceania + "AU": .oceania, "NZ": .oceania, + // Asia + "VN": .asia, "TH": .asia, "MY": .asia, "SG": .asia, + "PH": .asia, "ID": .asia, "JP": .asia, "KR": .asia, + ] + + /// `countries` sorted by `localizedName` once at first access. The order freezes for + /// the process lifetime — acceptable because iOS app-language changes require relaunch, + /// so the user never sees a stale order in practice. + public static let countriesSortedByLocalizedName: [Country] = countries.sorted { + $0.localizedName < $1.localizedName + } + + public static let countries: [Country] = [ + Country(id: "US", subdivisions: usSubdivisions), + Country(id: "CA", subdivisions: nil), + Country(id: "CL", subdivisions: nil), + Country(id: "AU", subdivisions: auSubdivisions), + Country(id: "NZ", subdivisions: nil), + Country(id: "GB", subdivisions: nil), + Country(id: "IE", subdivisions: nil), + Country(id: "DE", subdivisions: nil), + Country(id: "FR", subdivisions: nil), + Country(id: "IT", subdivisions: nil), + Country(id: "ES", subdivisions: nil), + Country(id: "PT", subdivisions: nil), + Country(id: "NL", subdivisions: nil), + Country(id: "BE", subdivisions: nil), + Country(id: "CH", subdivisions: nil), + Country(id: "AT", subdivisions: nil), + Country(id: "CZ", subdivisions: nil), + Country(id: "PL", subdivisions: nil), + Country(id: "DK", subdivisions: nil), + Country(id: "SE", subdivisions: nil), + Country(id: "NO", subdivisions: nil), + Country(id: "FI", subdivisions: nil), + Country(id: "GR", subdivisions: nil), + Country(id: "HU", subdivisions: nil), + Country(id: "RO", subdivisions: nil), + Country(id: "VN", subdivisions: nil), + Country(id: "TH", subdivisions: nil), + Country(id: "MY", subdivisions: nil), + Country(id: "SG", subdivisions: nil), + Country(id: "PH", subdivisions: nil), + Country(id: "ID", subdivisions: nil), + Country(id: "JP", subdivisions: nil), + Country(id: "KR", subdivisions: nil), + ] + + /// Normalized US county names (lowercased, diacritic-folded, "county" suffix stripped), + /// indexed by ISO 3166-2 state code. Only states with county-scoped presets are filled. + public static let usCounties: [String: Set] = [ + "US-CA": [ + "los angeles", "orange", "san diego", "riverside", "san bernardino", + "ventura", "imperial", "kern", "santa barbara", "san luis obispo", + ], + ] + + /// Returns the subdivisions catalog for an ISO α-2 country code, or an empty array + /// when the country has no sub-region presets (or the code is unknown). + public static func subdivisions(for country: String?) -> [Subdivision] { + guard let country, + let entry = countries.first(where: { $0.id == country }) else { return [] } + return entry.subdivisions ?? [] + } + + /// Returns the ISO 3166-2 subdivision code matching a normalized administrative area name. + /// Matches against `Subdivision.normalizedNames`, which contains both the English long form + /// (e.g. "california") and short codes (e.g. "ca") so `CLPlacemark.administrativeArea` + /// returning either form resolves correctly. Returns nil on miss; recommendation falls + /// to country tier. + public static func matchSubdivision(country: String, normalized: String?) -> String? { + guard let normalized, + let entry = countries.first(where: { $0.id == country }), + let subdivisions = entry.subdivisions else { return nil } + return subdivisions.first(where: { $0.normalizedNames.contains(normalized) })?.id + } + + /// Returns the normalized county key when (country, state, name) all match the catalog. + /// US counties have no ISO identifier — the normalized name *is* the key. + public static func matchCounty(country: String, state: String?, normalized: String?) -> String? { + guard country == "US", + let state, + let normalized, + let countiesForState = usCounties[state], + countiesForState.contains(normalized) else { return nil } + return normalized + } + + /// Returns a localized display name for Settings detail and Radio footer. + /// Short form for unambiguous US states; disambiguated "State, Country" for ambiguous regions. + public static func displayName(for region: RegionSelection) -> String { + let countryName = Locale.current.localizedString(forRegionCode: region.countryCode) ?? region.countryCode + guard let admin = region.administrativeAreaCode else { return countryName } + let stateName = subdivisionDisplayName(admin) ?? admin + if region.countryCode == "US" || region.countryCode == "CA" { + return stateName } - - /// Returns the ISO 3166-2 subdivision code matching a normalized administrative area name. - /// Matches against `Subdivision.normalizedNames`, which contains both the English long form - /// (e.g. "california") and short codes (e.g. "ca") so `CLPlacemark.administrativeArea` - /// returning either form resolves correctly. Returns nil on miss; recommendation falls - /// to country tier. - public static func matchSubdivision(country: String, normalized: String?) -> String? { - guard let normalized, - let entry = countries.first(where: { $0.id == country }), - let subdivisions = entry.subdivisions else { return nil } - return subdivisions.first(where: { $0.normalizedNames.contains(normalized) })?.id - } - - /// Returns the normalized county key when (country, state, name) all match the catalog. - /// US counties have no ISO identifier — the normalized name *is* the key. - public static func matchCounty(country: String, state: String?, normalized: String?) -> String? { - guard country == "US", - let state, - let normalized, - let countiesForState = usCounties[state], - countiesForState.contains(normalized) else { return nil } - return normalized - } - - /// Returns a localized display name for Settings detail and Radio footer. - /// Short form for unambiguous US states; disambiguated "State, Country" for ambiguous regions. - public static func displayName(for region: RegionSelection) -> String { - let countryName = Locale.current.localizedString(forRegionCode: region.countryCode) ?? region.countryCode - guard let admin = region.administrativeAreaCode else { return countryName } - let stateName = subdivisionDisplayName(admin) ?? admin - if region.countryCode == "US" || region.countryCode == "CA" { - return stateName - } - return "\(stateName), \(countryName)" - } - - /// Returns the localized subdivision name for an ISO 3166-2 code (e.g. "US-CA" → "California"). - /// Looks up `region.subdivision.` in the host app bundle's `Settings.strings` and falls - /// back to the English value when the key is missing, so unit tests running outside an app - /// bundle still resolve a deterministic name. - public static func subdivisionDisplayName(_ code: String) -> String? { - guard let englishFallback = englishSubdivisionFallbacks[code] else { return nil } - let key = "region.subdivision.\(code)" - return Bundle.main.localizedString(forKey: key, value: englishFallback, table: "Settings") - } - - /// English values used as the `value:` fallback in `bundle.localizedString` and as the source - /// of truth for `Settings.strings` `region.subdivision.*` entries. - private static let englishSubdivisionFallbacks: [String: String] = [ - "US-CA": "California", - "AU-QLD": "Queensland", - "AU-SA": "South Australia", - "AU-WA": "Western Australia", - ] + return "\(stateName), \(countryName)" + } + + /// Returns the localized subdivision name for an ISO 3166-2 code (e.g. "US-CA" → "California"). + /// Looks up `region.subdivision.` in the host app bundle's `Settings.strings` and falls + /// back to the English value when the key is missing, so unit tests running outside an app + /// bundle still resolve a deterministic name. + public static func subdivisionDisplayName(_ code: String) -> String? { + guard let englishFallback = englishSubdivisionFallbacks[code] else { return nil } + let key = "region.subdivision.\(code)" + return Bundle.main.localizedString(forKey: key, value: englishFallback, table: "Settings") + } + + /// English values used as the `value:` fallback in `bundle.localizedString` and as the source + /// of truth for `Settings.strings` `region.subdivision.*` entries. + private static let englishSubdivisionFallbacks: [String: String] = [ + "US-CA": "California", + "AU-QLD": "Queensland", + "AU-SA": "South Australia", + "AU-WA": "Western Australia", + ] } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeEvent.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeEvent.swift index 1e246478..bf78f4c7 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeEvent.swift @@ -3,7 +3,7 @@ import Foundation /// Remote-node session notifications broadcast by `RemoteNodeService.events()`. /// The stream is multicast: every subscriber receives every event. public enum RemoteNodeEvent: Sendable { - /// A remote-node session's connection state changed (login, logout, - /// keep-alive failure, or BLE loss). - case sessionStateChanged(sessionID: UUID, isConnected: Bool) + /// A remote-node session's connection state changed (login, logout, + /// keep-alive failure, or BLE loss). + case sessionStateChanged(sessionID: UUID, isConnected: Bool) } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift index 4aad54ec..2368d3dc 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift @@ -2,211 +2,210 @@ import Foundation import MeshCore extension RemoteNodeService { + // MARK: - CLI Commands + + /// Send a CLI command to a remote node and wait for response (admin only). + /// - Parameters: + /// - sessionID: The remote node session ID. + /// - command: The CLI command to send. + /// - timeout: Hard maximum time to wait for response (default 10 seconds). + /// - Returns: The CLI response text from the remote node. + public func sendCLICommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - // MARK: - CLI Commands - - /// Send a CLI command to a remote node and wait for response (admin only). - /// - Parameters: - /// - sessionID: The remote node session ID. - /// - command: The CLI command to send. - /// - timeout: Hard maximum time to wait for response (default 10 seconds). - /// - Returns: The CLI response text from the remote node. - public func sendCLICommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + guard remoteSession.isAdmin else { + throw RemoteNodeError.permissionDenied + } - guard remoteSession.isAdmin else { - throw RemoteNodeError.permissionDenied - } + // Log CLI command (with password redaction) + await auditLogger.logCLICommand(publicKey: remoteSession.publicKey, command: command) - // Log CLI command (with password redaction) - await auditLogger.logCLICommand(publicKey: remoteSession.publicKey, command: command) - - let destinationPrefix = Data(remoteSession.publicKey.prefix(6)) - let requestTimestamp = Date() - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - let request = PendingCLIRequest( - command: command, - continuation: continuation, - timestamp: requestTimestamp - ) - - if pendingCLIRequests[destinationPrefix] == nil { - pendingCLIRequests[destinationPrefix] = [] - } - pendingCLIRequests[destinationPrefix]!.append(request) - - Task { [self] in - let sentInfo: MessageSentInfo - do { - sentInfo = try await session.sendCommand(to: remoteSession.publicKey, command: command) - } catch { - if var requests = pendingCLIRequests[destinationPrefix], - let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { - let failed = requests.remove(at: index) - pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests - let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) - failed.continuation.resume(throwing: RemoteNodeError.sessionError(meshError)) - } - return - } - - let effectiveTimeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: timeout) - let deadline = ContinuousClock.now.advanced(by: effectiveTimeout) - while ContinuousClock.now < deadline { - if let requests = pendingCLIRequests[destinationPrefix], - !requests.contains(where: { $0.timestamp == requestTimestamp }) { - return - } else if pendingCLIRequests[destinationPrefix] == nil { - return - } - - let remaining = deadline - .now - let pollDuration = min(RemoteOperationTimeoutPolicy.pollInterval, remaining) - _ = try? await session.getMessage(timeout: max(0.1, timeInterval(for: pollDuration))) - } - - if var requests = pendingCLIRequests[destinationPrefix], - let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { - let timedOut = requests.remove(at: index) - pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests - timedOut.continuation.resume(throwing: RemoteNodeError.timeout) - } - } + let destinationPrefix = Data(remoteSession.publicKey.prefix(6)) + let requestTimestamp = Date() + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let request = PendingCLIRequest( + command: command, + continuation: continuation, + timestamp: requestTimestamp + ) + + if pendingCLIRequests[destinationPrefix] == nil { + pendingCLIRequests[destinationPrefix] = [] + } + pendingCLIRequests[destinationPrefix]!.append(request) + + Task { [self] in + let sentInfo: MessageSentInfo + do { + sentInfo = try await session.sendCommand(to: remoteSession.publicKey, command: command) + } catch { + if var requests = pendingCLIRequests[destinationPrefix], + let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { + let failed = requests.remove(at: index) + pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests + let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) + failed.continuation.resume(throwing: RemoteNodeError.sessionError(meshError)) } - } onCancel: { [weak self] in - Task { [weak self] in - await self?.cancelPendingCLIRequest(for: destinationPrefix, timestamp: requestTimestamp) + return + } + + let effectiveTimeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: timeout) + let deadline = ContinuousClock.now.advanced(by: effectiveTimeout) + while ContinuousClock.now < deadline { + if let requests = pendingCLIRequests[destinationPrefix], + !requests.contains(where: { $0.timestamp == requestTimestamp }) { + return + } else if pendingCLIRequests[destinationPrefix] == nil { + return } + + let remaining = deadline - .now + let pollDuration = min(RemoteOperationTimeoutPolicy.pollInterval, remaining) + _ = try? await session.getMessage(timeout: max(0.1, timeInterval(for: pollDuration))) + } + + if var requests = pendingCLIRequests[destinationPrefix], + let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { + let timedOut = requests.remove(at: index) + pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests + timedOut.continuation.resume(throwing: RemoteNodeError.timeout) + } } + } + } onCancel: { [weak self] in + Task { [weak self] in + await self?.cancelPendingCLIRequest(for: destinationPrefix, timestamp: requestTimestamp) + } + } + } + + /// Send a raw CLI command to a remote node using FIFO response matching (admin only). + /// Unlike `sendCLICommand`, this method accepts any response from the target node + /// without content-based matching. Used by CLI tool for passthrough commands. + /// - Parameters: + /// - sessionID: The remote node session ID. + /// - command: The CLI command to send. + /// - timeout: Hard maximum time to wait for response (default 10 seconds). + /// - Returns: The raw response text from the remote node. + public func sendRawCLICommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound } - /// Send a raw CLI command to a remote node using FIFO response matching (admin only). - /// Unlike `sendCLICommand`, this method accepts any response from the target node - /// without content-based matching. Used by CLI tool for passthrough commands. - /// - Parameters: - /// - sessionID: The remote node session ID. - /// - command: The CLI command to send. - /// - timeout: Hard maximum time to wait for response (default 10 seconds). - /// - Returns: The raw response text from the remote node. - public func sendRawCLICommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + guard remoteSession.isAdmin else { + throw RemoteNodeError.permissionDenied + } - guard remoteSession.isAdmin else { - throw RemoteNodeError.permissionDenied - } + // Log CLI command (with password redaction) + await auditLogger.logCLICommand(publicKey: remoteSession.publicKey, command: command) - // Log CLI command (with password redaction) - await auditLogger.logCLICommand(publicKey: remoteSession.publicKey, command: command) + let destinationPrefix = Data(remoteSession.publicKey.prefix(6)) - let destinationPrefix = Data(remoteSession.publicKey.prefix(6)) + // Only one raw CLI request per sender at a time (FIFO matching) + guard pendingRawCLIRequests[destinationPrefix] == nil else { + throw RemoteNodeError.sessionError(.connectionLost(underlying: nil)) + } - // Only one raw CLI request per sender at a time (FIFO matching) - guard pendingRawCLIRequests[destinationPrefix] == nil else { - throw RemoteNodeError.sessionError(.connectionLost(underlying: nil)) - } + // Register continuation BEFORE sending to avoid race condition + // Use withTaskCancellationHandler to clean up pending request if caller cancels + let response = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + pendingRawCLIRequests[destinationPrefix] = continuation + + Task { [self] in + // Send CLI command + let sentInfo: MessageSentInfo + do { + sentInfo = try await session.sendCommand(to: remoteSession.publicKey, command: command) + } catch { + // Send failed - remove pending request and resume with error + if let pending = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { + let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) + pending.resume(throwing: RemoteNodeError.sessionError(meshError)) + } + return + } + + let effectiveTimeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: timeout) - // Register continuation BEFORE sending to avoid race condition - // Use withTaskCancellationHandler to clean up pending request if caller cancels - let response = try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - pendingRawCLIRequests[destinationPrefix] = continuation - - Task { [self] in - // Send CLI command - let sentInfo: MessageSentInfo - do { - sentInfo = try await session.sendCommand(to: remoteSession.publicKey, command: command) - } catch { - // Send failed - remove pending request and resume with error - if let pending = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { - let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) - pending.resume(throwing: RemoteNodeError.sessionError(meshError)) - } - return - } - - let effectiveTimeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: timeout) - - // Poll for response - let deadline = ContinuousClock.now.advanced(by: effectiveTimeout) - while ContinuousClock.now < deadline { - // Check if our request was already satisfied - guard pendingRawCLIRequests[destinationPrefix] != nil else { - return // Request was matched and removed by handleCLIResponse - } - - // Check for task cancellation - if Task.isCancelled { - if let cancelled = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { - cancelled.resume(throwing: CancellationError()) - } - return - } - - // Poll device for pending messages - let remaining = deadline - .now - let pollDuration = min(RemoteOperationTimeoutPolicy.pollInterval, remaining) - _ = try? await session.getMessage(timeout: max(0.1, timeInterval(for: pollDuration))) - } - - // Timeout - remove pending request and resume with error - if let timedOut = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { - timedOut.resume(throwing: RemoteNodeError.timeout) - } - } + // Poll for response + let deadline = ContinuousClock.now.advanced(by: effectiveTimeout) + while ContinuousClock.now < deadline { + // Check if our request was already satisfied + guard pendingRawCLIRequests[destinationPrefix] != nil else { + return // Request was matched and removed by handleCLIResponse } - } onCancel: { [weak self] in - Task { [weak self] in - await self?.cancelPendingRawCLIRequest(for: destinationPrefix) + + // Check for task cancellation + if Task.isCancelled { + if let cancelled = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { + cancelled.resume(throwing: CancellationError()) + } + return } - } - // Clear stored password after admin password change - await handlePasswordChangeIfNeeded(command: command, sessionID: sessionID) + // Poll device for pending messages + let remaining = deadline - .now + let pollDuration = min(RemoteOperationTimeoutPolicy.pollInterval, remaining) + _ = try? await session.getMessage(timeout: max(0.1, timeInterval(for: pollDuration))) + } - return response + // Timeout - remove pending request and resume with error + if let timedOut = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { + timedOut.resume(throwing: RemoteNodeError.timeout) + } + } + } + } onCancel: { [weak self] in + Task { [weak self] in + await self?.cancelPendingRawCLIRequest(for: destinationPrefix) + } } - /// Clear stored password if command is an admin password change. - private func handlePasswordChangeIfNeeded(command: String, sessionID: UUID) async { - let lower = command.lowercased().trimmingCharacters(in: .whitespaces) + // Clear stored password after admin password change + await handlePasswordChangeIfNeeded(command: command, sessionID: sessionID) - // Only admin password changes, not guest - guard lower.hasPrefix("password ") && !lower.contains("guest.password") else { - return - } + return response + } - guard let session = try? await dataStore.fetchRemoteNodeSession(id: sessionID) else { - return - } + /// Clear stored password if command is an admin password change. + private func handlePasswordChangeIfNeeded(command: String, sessionID: UUID) async { + let lower = command.lowercased().trimmingCharacters(in: .whitespaces) - do { - try await keychainService.deletePassword(forNodeKey: session.publicKey) - logger.info("Cleared stored password after password change for session \(sessionID)") - } catch { - logger.warning("Failed to clear stored password for session \(sessionID): \(error)") - // Next login fails naturally - user re-enters password, overwrites stale credential - } + // Only admin password changes, not guest + guard lower.hasPrefix("password "), !lower.contains("guest.password") else { + return } - /// Cancel a pending raw CLI request when the calling task is cancelled. - private func cancelPendingRawCLIRequest(for prefix: Data) { - if let cancelled = pendingRawCLIRequests.removeValue(forKey: prefix) { - cancelled.resume(throwing: CancellationError()) - } + guard let session = try? await dataStore.fetchRemoteNodeSession(id: sessionID) else { + return + } + + do { + try await keychainService.deletePassword(forNodeKey: session.publicKey) + logger.info("Cleared stored password after password change for session \(sessionID)") + } catch { + logger.warning("Failed to clear stored password for session \(sessionID): \(error)") + // Next login fails naturally - user re-enters password, overwrites stale credential + } + } + + /// Cancel a pending raw CLI request when the calling task is cancelled. + private func cancelPendingRawCLIRequest(for prefix: Data) { + if let cancelled = pendingRawCLIRequests.removeValue(forKey: prefix) { + cancelled.resume(throwing: CancellationError()) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift index 5fe064a5..b4885afd 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift @@ -1,407 +1,406 @@ import Foundation import MeshCore -extension RemoteNodeService { - - // MARK: - Login - - /// Login to a remote node. - /// Works for both room servers and repeaters. - /// - Parameters: - /// - sessionID: The remote session ID. - /// - password: Optional password (uses stored password if nil). - /// - pathLength: Path length hint for timeout calculation. - /// - onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. - public func login( - sessionID: UUID, - password: String? = nil, - pathLength: UInt8 = 0, - onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil - ) async throws -> LoginResult { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } - - // Get password from parameter or keychain - let pwd: String - if let password { - pwd = password - } else if let stored = try await keychainService.retrievePassword(forNodeKey: remoteSession.publicKey) { - pwd = stored - } else { - throw RemoteNodeError.passwordNotFound - } +public extension RemoteNodeService { + // MARK: - Login + + /// Login to a remote node. + /// Works for both room servers and repeaters. + /// - Parameters: + /// - sessionID: The remote session ID. + /// - password: Optional password (uses stored password if nil). + /// - pathLength: Path length hint for timeout calculation. + /// - onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. + func login( + sessionID: UUID, + password: String? = nil, + pathLength: UInt8 = 0, + onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil + ) async throws -> LoginResult { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - let prefix = Data(remoteSession.publicKey.prefix(6)) + // Get password from parameter or keychain + let pwd: String + if let password { + pwd = password + } else if let stored = try await keychainService.retrievePassword(forNodeKey: remoteSession.publicKey) { + pwd = stored + } else { + throw RemoteNodeError.passwordNotFound + } - // Cancel any existing pending login for this prefix - if let existing = pendingLogins.removeValue(forKey: prefix) { - let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() - logger.warning("Overwriting pending login for prefix \(prefixHex)") - pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() - existing.resume(throwing: RemoteNodeError.cancelled) - } + let prefix = Data(remoteSession.publicKey.prefix(6)) - // Log login request - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logLoginRequest(target: targetType, publicKey: remoteSession.publicKey, pathLength: pathLength) - - // Register continuation BEFORE sending to avoid race condition with loginSuccess event - let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() - logger.info("login: registering pending login for prefix \(prefixHex)") - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - pendingLogins[prefix] = continuation - - let timeoutTask = Task { [self] in - let sentInfo: MessageSentInfo - do { - sentInfo = try await sendLoginHealingIfNeeded( - publicKey: remoteSession.publicKey, - radioID: remoteSession.radioID, - password: pwd - ) - } catch { - // Cancellation means this task no longer owns the continuation: whoever - // cancelled it (an overlapping login for this prefix, or cancelPendingLogin - // on outer-task cancellation) has already resumed and removed it. - guard !Task.isCancelled else { return } - pendingLoginTimeoutTasks.removeValue(forKey: prefix) - if let pending = pendingLogins.removeValue(forKey: prefix) { - let rnError = (error as? RemoteNodeError) - ?? .sessionError(error as? MeshCoreError ?? .connectionLost(underlying: error)) - pending.resume(throwing: rnError) - } - return - } - - let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: pathLength) - logger.info("login: send succeeded, starting \(timeout) timeout for prefix \(prefixHex)") - - if let onTimeoutKnown { - let timeoutSeconds = max(1, Int(timeInterval(for: timeout).rounded(.up))) - await onTimeoutKnown(timeoutSeconds) - } - try? await Task.sleep(for: timeout) - guard !Task.isCancelled else { return } - if let pending = pendingLogins.removeValue(forKey: prefix) { - logger.warning("Login timeout after \(timeout) for session \(sessionID), prefix \(prefixHex)") - pendingLoginTimeoutTasks.removeValue(forKey: prefix) - pending.resume(throwing: RemoteNodeError.timeout) - } else { - logger.info("login: timeout elapsed but continuation already consumed for prefix \(prefixHex)") - } - } - pendingLoginTimeoutTasks[prefix] = timeoutTask - } - } onCancel: { [weak self] in - Task { [weak self] in - await self?.cancelPendingLogin(for: prefix) - } - } + // Cancel any existing pending login for this prefix + if let existing = pendingLogins.removeValue(forKey: prefix) { + let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() + logger.warning("Overwriting pending login for prefix \(prefixHex)") + pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() + existing.resume(throwing: RemoteNodeError.cancelled) } - /// Sends the login; if the radio reports the contact is missing from its table, pushes the local - /// copy (flood-routed) and retries once. A contact can be in the app database but absent on the - /// radio after a backup restore or a radio swap, which the firmware answers with notFound (0x02). - /// The parked login continuation is covered here by the session commands' own timeouts, not the - /// login timeout, which only starts after this returns. - func sendLoginHealingIfNeeded( - publicKey: Data, - radioID: UUID, - password: String - ) async throws -> MessageSentInfo { - do { - return try await session.sendLogin(to: publicKey, password: password) - } catch let error as MeshCoreError { - guard case .deviceError(let code) = error, code == ProtocolError.notFound.rawValue else { - throw error + // Log login request + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logLoginRequest(target: targetType, publicKey: remoteSession.publicKey, pathLength: pathLength) + + // Register continuation BEFORE sending to avoid race condition with loginSuccess event + let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() + logger.info("login: registering pending login for prefix \(prefixHex)") + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + pendingLogins[prefix] = continuation + + let timeoutTask = Task { [self] in + let sentInfo: MessageSentInfo + do { + sentInfo = try await sendLoginHealingIfNeeded( + publicKey: remoteSession.publicKey, + radioID: remoteSession.radioID, + password: pwd + ) + } catch { + // Cancellation means this task no longer owns the continuation: whoever + // cancelled it (an overlapping login for this prefix, or cancelPendingLogin + // on outer-task cancellation) has already resumed and removed it. + guard !Task.isCancelled else { return } + pendingLoginTimeoutTasks.removeValue(forKey: prefix) + if let pending = pendingLogins.removeValue(forKey: prefix) { + let rnError = (error as? RemoteNodeError) + ?? .sessionError(error as? MeshCoreError ?? .connectionLost(underlying: error)) + pending.resume(throwing: rnError) } - try Task.checkCancellation() - try await addLocalContactToRadio(publicKey: publicKey, radioID: radioID) - // Bounded to a single retry: a second notFound after a successful add is a firmware - // inconsistency, left to propagate rather than loop. - return try await session.sendLogin(to: publicKey, password: password) + return + } + + let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: pathLength) + logger.info("login: send succeeded, starting \(timeout) timeout for prefix \(prefixHex)") + + if let onTimeoutKnown { + let timeoutSeconds = max(1, Int(timeInterval(for: timeout).rounded(.up))) + await onTimeoutKnown(timeoutSeconds) + } + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + if let pending = pendingLogins.removeValue(forKey: prefix) { + logger.warning("Login timeout after \(timeout) for session \(sessionID), prefix \(prefixHex)") + pendingLoginTimeoutTasks.removeValue(forKey: prefix) + pending.resume(throwing: RemoteNodeError.timeout) + } else { + logger.info("login: timeout elapsed but continuation already consumed for prefix \(prefixHex)") + } } + pendingLoginTimeoutTasks[prefix] = timeoutTask + } + } onCancel: { [weak self] in + Task { [weak self] in + await self?.cancelPendingLogin(for: prefix) + } } - - /// Pushes the local contact to the radio's contact table with flood routing, then reconciles the - /// local row so keep-alive routing agrees with the radio. - private func addLocalContactToRadio(publicKey: Data, radioID: UUID) async throws { - guard let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) else { - throw RemoteNodeError.contactNotFound - } - let frame = contact.floodedContactFrame(asOf: UInt32(Date().timeIntervalSince1970)) - do { - try await session.addContact(frame.toMeshContact()) - } catch let error as MeshCoreError { - guard case .deviceError(let code) = error, code == ProtocolError.tableFull.rawValue else { - throw error - } - throw RemoteNodeError.radioContactsFull - } - // The radio is healed; failing to sync the local row is a bookkeeping issue that must not - // abort the login retry. Keep-alive routing self-corrects on the next contact refresh. - do { - _ = try await dataStore.saveContact(radioID: radioID, from: frame) - } catch { - logger.warning("Re-added contact to radio but failed to sync local row: \(error)") - } - logger.info("Re-added missing contact to radio during login") + } + + /// Sends the login; if the radio reports the contact is missing from its table, pushes the local + /// copy (flood-routed) and retries once. A contact can be in the app database but absent on the + /// radio after a backup restore or a radio swap, which the firmware answers with notFound (0x02). + /// The parked login continuation is covered here by the session commands' own timeouts, not the + /// login timeout, which only starts after this returns. + internal func sendLoginHealingIfNeeded( + publicKey: Data, + radioID: UUID, + password: String + ) async throws -> MessageSentInfo { + do { + return try await session.sendLogin(to: publicKey, password: password) + } catch let error as MeshCoreError { + guard case let .deviceError(code) = error, code == ProtocolError.notFound.rawValue else { + throw error + } + try Task.checkCancellation() + try await addLocalContactToRadio(publicKey: publicKey, radioID: radioID) + // Bounded to a single retry: a second notFound after a successful add is a firmware + // inconsistency, left to propagate rather than loop. + return try await session.sendLogin(to: publicKey, password: password) } + } - /// Handle login result push from device. - func handleLoginResult(_ result: LoginResult, fromPublicKeyPrefix: Data) async { - guard fromPublicKeyPrefix.count >= 6 else { - logger.warning("Login result has invalid prefix length: \(fromPublicKeyPrefix.count)") - return - } - - let prefix = Data(fromPublicKeyPrefix.prefix(6)) - let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() - let pendingKeys = pendingLogins.keys.map { $0.map { String(format: "%02x", $0) }.joined() } - logger.info("handleLoginResult: looking for prefix \(prefixHex), pending keys: \(pendingKeys)") - guard let continuation = pendingLogins.removeValue(forKey: prefix) else { - logger.warning("Login result with no pending request. Prefix: \(prefixHex)") - return - } - pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() - logger.info("handleLoginResult: found continuation for prefix \(prefixHex)") - - if result.success { - // Update session state - do { - guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(prefix) else { - logger.error("handleLoginResult: no session found for prefix \(prefixHex) - database may be corrupted") - continuation.resume(returning: result) - return - } - - // Log successful login - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logLoginSuccess(target: targetType, publicKey: prefix, isAdmin: result.isAdmin) - - let permission = result.permissionLevel - - logger.info("handleLoginResult: updating session \(remoteSession.id) isConnected=true, permission=\(permission.rawValue)") - - try await dataStore.updateRemoteNodeSessionConnection( - id: remoteSession.id, - isConnected: true, - permissionLevel: permission - ) - - // Verify the update succeeded - if let verifySession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) { - if verifySession.isConnected { - logger.info("handleLoginResult: verified session \(remoteSession.id) isConnected=true") - } else { - logger.error("handleLoginResult: session \(remoteSession.id) still shows isConnected=false after update!") - } - } - - // Notify UI of session state change - eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: true)) - - keepAliveIntervals[remoteSession.id] = Self.defaultKeepAliveInterval - } catch { - logger.error("handleLoginResult: failed to update session state: \(error)") - } - continuation.resume(returning: result) - } else { - // Log failed login - // Try to determine target type from existing session - if let remoteSession = try? await dataStore.fetchRemoteNodeSessionByPrefix(prefix) { - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logLoginFailed(target: targetType, publicKey: prefix, reason: "authentication failed") - } else { - await auditLogger.logLoginFailed(target: .repeater, publicKey: prefix, reason: "authentication failed") - } - continuation.resume(throwing: RemoteNodeError.loginFailed("authentication failed")) - } + /// Pushes the local contact to the radio's contact table with flood routing, then reconciles the + /// local row so keep-alive routing agrees with the radio. + private func addLocalContactToRadio(publicKey: Data, radioID: UUID) async throws { + guard let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) else { + throw RemoteNodeError.contactNotFound + } + let frame = contact.floodedContactFrame(asOf: UInt32(Date().timeIntervalSince1970)) + do { + try await session.addContact(frame.toMeshContact()) + } catch let error as MeshCoreError { + guard case let .deviceError(code) = error, code == ProtocolError.tableFull.rawValue else { + throw error + } + throw RemoteNodeError.radioContactsFull + } + // The radio is healed; failing to sync the local row is a bookkeeping issue that must not + // abort the login retry. Keep-alive routing self-corrects on the next contact refresh. + do { + _ = try await dataStore.saveContact(radioID: radioID, from: frame) + } catch { + logger.warning("Re-added contact to radio but failed to sync local row: \(error)") + } + logger.info("Re-added missing contact to radio during login") + } + + /// Handle login result push from device. + internal func handleLoginResult(_ result: LoginResult, fromPublicKeyPrefix: Data) async { + guard fromPublicKeyPrefix.count >= 6 else { + logger.warning("Login result has invalid prefix length: \(fromPublicKeyPrefix.count)") + return } - // MARK: - Keep-Alive (Room Servers) + let prefix = Data(fromPublicKeyPrefix.prefix(6)) + let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() + let pendingKeys = pendingLogins.keys.map { $0.map { String(format: "%02x", $0) }.joined() } + logger.info("handleLoginResult: looking for prefix \(prefixHex), pending keys: \(pendingKeys)") + guard let continuation = pendingLogins.removeValue(forKey: prefix) else { + logger.warning("Login result with no pending request. Prefix: \(prefixHex)") + return + } + pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() + logger.info("handleLoginResult: found continuation for prefix \(prefixHex)") + + if result.success { + // Update session state + do { + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(prefix) else { + logger.error("handleLoginResult: no session found for prefix \(prefixHex) - database may be corrupted") + continuation.resume(returning: result) + return + } - /// Start periodic keep-alive for a room server session. - /// Sends an immediate keep-alive on start (for connectivity check + sync_since update), - /// then continues at the configured interval. Transient failures are retried up to - /// `KeepAliveRetryPolicy.maxConsecutiveFailures` times before disconnecting. - private func startKeepAlive(sessionID: UUID, publicKey: Data) { - stopKeepAlive(sessionID: sessionID) + // Log successful login + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logLoginSuccess(target: targetType, publicKey: prefix, isAdmin: result.isAdmin) - let interval = keepAliveIntervals[sessionID] ?? Self.defaultKeepAliveInterval + let permission = result.permissionLevel - // The actor retains this task via keepAliveTasks, so the loop captures - // self weakly and rebinds per tick; a strong capture would keep the - // actor (and its session/dataStore) alive for the app lifetime. - let task = Task { [weak self] in - var consecutiveFailures = 0 + logger.info("handleLoginResult: updating session \(remoteSession.id) isConnected=true, permission=\(permission.rawValue)") - while !Task.isCancelled { - guard let tick = await self?.performKeepAliveTick( - sessionID: sessionID, - publicKey: publicKey, - consecutiveFailures: consecutiveFailures - ), tick.shouldContinue else { return } - consecutiveFailures = tick.consecutiveFailures + try await dataStore.updateRemoteNodeSessionConnection( + id: remoteSession.id, + isConnected: true, + permissionLevel: permission + ) - try? await Task.sleep(for: interval) - } + // Verify the update succeeded + if let verifySession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) { + if verifySession.isConnected { + logger.info("handleLoginResult: verified session \(remoteSession.id) isConnected=true") + } else { + logger.error("handleLoginResult: session \(remoteSession.id) still shows isConnected=false after update!") + } } - keepAliveTasks[sessionID] = task + // Notify UI of session state change + eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: true)) + + keepAliveIntervals[remoteSession.id] = Self.defaultKeepAliveInterval + } catch { + logger.error("handleLoginResult: failed to update session state: \(error)") + } + continuation.resume(returning: result) + } else { + // Log failed login + // Try to determine target type from existing session + if let remoteSession = try? await dataStore.fetchRemoteNodeSessionByPrefix(prefix) { + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logLoginFailed(target: targetType, publicKey: prefix, reason: "authentication failed") + } else { + await auditLogger.logLoginFailed(target: .repeater, publicKey: prefix, reason: "authentication failed") + } + continuation.resume(throwing: RemoteNodeError.loginFailed("authentication failed")) + } + } + + // MARK: - Keep-Alive (Room Servers) + + /// Start periodic keep-alive for a room server session. + /// Sends an immediate keep-alive on start (for connectivity check + sync_since update), + /// then continues at the configured interval. Transient failures are retried up to + /// `KeepAliveRetryPolicy.maxConsecutiveFailures` times before disconnecting. + private func startKeepAlive(sessionID: UUID, publicKey: Data) { + stopKeepAlive(sessionID: sessionID) + + let interval = keepAliveIntervals[sessionID] ?? Self.defaultKeepAliveInterval + + // The actor retains this task via keepAliveTasks, so the loop captures + // self weakly and rebinds per tick; a strong capture would keep the + // actor (and its session/dataStore) alive for the app lifetime. + let task = Task { [weak self] in + var consecutiveFailures = 0 + + while !Task.isCancelled { + guard let tick = await self?.performKeepAliveTick( + sessionID: sessionID, + publicKey: publicKey, + consecutiveFailures: consecutiveFailures + ), tick.shouldContinue else { return } + consecutiveFailures = tick.consecutiveFailures + + try? await Task.sleep(for: interval) + } } - /// Runs one keep-alive attempt for the loop in `startKeepAlive`. - /// Returns whether the loop should continue and the updated failure count. - private func performKeepAliveTick( - sessionID: UUID, - publicKey: Data, - consecutiveFailures: Int - ) async -> (shouldContinue: Bool, consecutiveFailures: Int) { - var failures = consecutiveFailures + keepAliveTasks[sessionID] = task + } + + /// Runs one keep-alive attempt for the loop in `startKeepAlive`. + /// Returns whether the loop should continue and the updated failure count. + private func performKeepAliveTick( + sessionID: UUID, + publicKey: Data, + consecutiveFailures: Int + ) async -> (shouldContinue: Bool, consecutiveFailures: Int) { + var failures = consecutiveFailures + do { + try await sendKeepAliveIfDirectRouted(sessionID: sessionID, publicKey: publicKey) + KeepAliveRetryPolicy.recordSuccess(consecutiveFailures: &failures) + return (shouldContinue: true, consecutiveFailures: failures) + } catch { + let action = KeepAliveRetryPolicy.evaluate(error: error, consecutiveFailures: &failures) + switch action { + case .stop: + break + case .skip: + logger.info("Skipping keep-alive for flood-routed session \(sessionID)") + case .retryNextInterval: + let reason = KeepAliveRetryPolicy.failureReason(for: error) + logger.warning("Keep-alive \(failures)/\(KeepAliveRetryPolicy.maxConsecutiveFailures) failed for \(sessionID): \(reason)") + case .disconnect, .disconnectNow: + let reason = KeepAliveRetryPolicy.failureReason(for: error) + logger.warning("Keep-alive failed for \(sessionID): \(reason)") do { - try await sendKeepAliveIfDirectRouted(sessionID: sessionID, publicKey: publicKey) - KeepAliveRetryPolicy.recordSuccess(consecutiveFailures: &failures) - return (shouldContinue: true, consecutiveFailures: failures) + try await dataStore.markSessionDisconnected(sessionID) } catch { - let action = KeepAliveRetryPolicy.evaluate(error: error, consecutiveFailures: &failures) - switch action { - case .stop: - break - case .skip: - logger.info("Skipping keep-alive for flood-routed session \(sessionID)") - case .retryNextInterval: - let reason = KeepAliveRetryPolicy.failureReason(for: error) - logger.warning("Keep-alive \(failures)/\(KeepAliveRetryPolicy.maxConsecutiveFailures) failed for \(sessionID): \(reason)") - case .disconnect, .disconnectNow: - let reason = KeepAliveRetryPolicy.failureReason(for: error) - logger.warning("Keep-alive failed for \(sessionID): \(reason)") - do { - try await dataStore.markSessionDisconnected(sessionID) - } catch { - logger.error("Failed to persist disconnected state for session \(sessionID): \(error)") - } - eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) - } - return (shouldContinue: !action.shouldExitLoop, consecutiveFailures: failures) + logger.error("Failed to persist disconnected state for session \(sessionID): \(error)") } + eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) + } + return (shouldContinue: !action.shouldExitLoop, consecutiveFailures: failures) } - - /// Stop keep-alive for a session - func stopKeepAlive(sessionID: UUID) { - keepAliveTasks[sessionID]?.cancel() - keepAliveTasks.removeValue(forKey: sessionID) + } + + /// Stop keep-alive for a session + internal func stopKeepAlive(sessionID: UUID) { + keepAliveTasks[sessionID]?.cancel() + keepAliveTasks.removeValue(forKey: sessionID) + } + + /// Send keep-alive only if the session has a direct routing path. + private func sendKeepAliveIfDirectRouted(sessionID: UUID, publicKey: Data) async throws { + // Fetch session to get radioID + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound } - /// Send keep-alive only if the session has a direct routing path. - private func sendKeepAliveIfDirectRouted(sessionID: UUID, publicKey: Data) async throws { - // Fetch session to get radioID - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } - - // Check contact's routing status - guard let contact = try await dataStore.fetchContact(radioID: remoteSession.radioID, publicKey: publicKey) else { - throw RemoteNodeError.contactNotFound - } - - // Keep-alive only works with direct routing - if contact.isFloodRouted { - throw RemoteNodeError.floodRouted - } - - // Log keep-alive - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logKeepAlive(target: targetType, publicKey: publicKey) - - // Send keep-alive with sync_since for force-resync hint - do { - _ = try await session.sendKeepAlive(to: publicKey, syncSince: remoteSession.lastSyncTimestamp) - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } + // Check contact's routing status + guard let contact = try await dataStore.fetchContact(radioID: remoteSession.radioID, publicKey: publicKey) else { + throw RemoteNodeError.contactNotFound } - /// Public method to send keep-alive (for manual refresh). - public func sendKeepAlive(sessionID: UUID) async throws { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } - try await sendKeepAliveIfDirectRouted(sessionID: sessionID, publicKey: remoteSession.publicKey) + // Keep-alive only works with direct routing + if contact.isFloodRouted { + throw RemoteNodeError.floodRouted } - /// Start keep-alive for a room session (called when room view appears). - public func startSessionKeepAlive(sessionID: UUID, publicKey: Data) { - startKeepAlive(sessionID: sessionID, publicKey: publicKey) + // Log keep-alive + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logKeepAlive(target: targetType, publicKey: publicKey) + + // Send keep-alive with sync_since for force-resync hint + do { + _ = try await session.sendKeepAlive(to: publicKey, syncSince: remoteSession.lastSyncTimestamp) + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) } + } - /// Stop keep-alive for a room session (called when room view disappears). - public func stopSessionKeepAlive(sessionID: UUID) { - stopKeepAlive(sessionID: sessionID) + /// Public method to send keep-alive (for manual refresh). + func sendKeepAlive(sessionID: UUID) async throws { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound } + try await sendKeepAliveIfDirectRouted(sessionID: sessionID, publicKey: remoteSession.publicKey) + } - // MARK: - History Sync + /// Start keep-alive for a room session (called when room view appears). + func startSessionKeepAlive(sessionID: UUID, publicKey: Data) { + startKeepAlive(sessionID: sessionID, publicKey: publicKey) + } - /// Request message history from a room server. - public func requestHistorySync(sessionID: UUID) async throws { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + /// Stop keep-alive for a room session (called when room view disappears). + func stopSessionKeepAlive(sessionID: UUID) { + stopKeepAlive(sessionID: sessionID) + } - guard remoteSession.isRoom else { - throw RemoteNodeError.invalidResponse - } + // MARK: - History Sync - // Check for direct route - guard let contact = try await dataStore.fetchContact(radioID: remoteSession.radioID, publicKey: remoteSession.publicKey) else { - throw RemoteNodeError.contactNotFound - } + /// Request message history from a room server. + func requestHistorySync(sessionID: UUID) async throws { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - if contact.isFloodRouted { - throw RemoteNodeError.floodRouted - } + guard remoteSession.isRoom else { + throw RemoteNodeError.invalidResponse + } - // Request status (which triggers sync) - do { - let contactType: ContactType = remoteSession.isRoom ? .room : .repeater - _ = try await session.requestStatus(from: remoteSession.publicKey, type: contactType) - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } + // Check for direct route + guard let contact = try await dataStore.fetchContact(radioID: remoteSession.radioID, publicKey: remoteSession.publicKey) else { + throw RemoteNodeError.contactNotFound + } - logger.info("Requested history sync for room \(remoteSession.name)") + if contact.isFloodRouted { + throw RemoteNodeError.floodRouted } - // MARK: - Logout + // Request status (which triggers sync) + do { + let contactType: ContactType = remoteSession.isRoom ? .room : .repeater + _ = try await session.requestStatus(from: remoteSession.publicKey, type: contactType) + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) + } - /// Explicitly logout from a remote node. - public func logout(sessionID: UUID) async throws { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + logger.info("Requested history sync for room \(remoteSession.name)") + } - // Log logout - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logLogout(target: targetType, publicKey: remoteSession.publicKey) + // MARK: - Logout - stopKeepAlive(sessionID: sessionID) + /// Explicitly logout from a remote node. + func logout(sessionID: UUID) async throws { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - do { - try await session.sendLogout(to: remoteSession.publicKey) - } catch { - // Ignore errors - we're disconnecting anyway - logger.info("Logout send failed (ignoring): \(error)") - } + // Log logout + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logLogout(target: targetType, publicKey: remoteSession.publicKey) - try await dataStore.updateRemoteNodeSessionConnection( - id: sessionID, - isConnected: false, - permissionLevel: .guest - ) + stopKeepAlive(sessionID: sessionID) - // Notify UI of session state change - eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) + do { + try await session.sendLogout(to: remoteSession.publicKey) + } catch { + // Ignore errors - we're disconnecting anyway + logger.info("Logout send failed (ignoring): \(error)") } + + try await dataStore.updateRemoteNodeSessionConnection( + id: sessionID, + isConnected: false, + permissionLevel: .guest + ) + + // Notify UI of session state change + eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) + } } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Reconnection.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Reconnection.swift index 961b5f36..0383227c 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Reconnection.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Reconnection.swift @@ -1,98 +1,97 @@ import Foundation -extension RemoteNodeService { +public extension RemoteNodeService { + // MARK: - BLE Disconnection - // MARK: - BLE Disconnection - - /// Called when BLE connection is lost. - /// Marks all connected sessions as disconnected, stops keep-alive timers, - /// and broadcasts `RemoteNodeEvent.sessionStateChanged` for each. - /// Returns the set of session IDs that were connected, for re-auth on reconnect. - public func handleBLEDisconnection() async -> Set { - let connectedSessions: [RemoteNodeSessionDTO] - do { - connectedSessions = try await dataStore.fetchConnectedRemoteNodeSessions() - } catch { - logger.error("Failed to fetch connected sessions for BLE disconnection: \(error)") - return [] - } + /// Called when BLE connection is lost. + /// Marks all connected sessions as disconnected, stops keep-alive timers, + /// and broadcasts `RemoteNodeEvent.sessionStateChanged` for each. + /// Returns the set of session IDs that were connected, for re-auth on reconnect. + func handleBLEDisconnection() async -> Set { + let connectedSessions: [RemoteNodeSessionDTO] + do { + connectedSessions = try await dataStore.fetchConnectedRemoteNodeSessions() + } catch { + logger.error("Failed to fetch connected sessions for BLE disconnection: \(error)") + return [] + } - guard !connectedSessions.isEmpty else { return [] } + guard !connectedSessions.isEmpty else { return [] } - logger.info("BLE disconnection: marking \(connectedSessions.count) session(s) disconnected") - var sessionIDs: Set = [] + logger.info("BLE disconnection: marking \(connectedSessions.count) session(s) disconnected") + var sessionIDs: Set = [] - for session in connectedSessions { - sessionIDs.insert(session.id) - stopKeepAlive(sessionID: session.id) - do { - try await dataStore.markSessionDisconnected(session.id) - } catch { - logger.error("Failed to mark session \(session.id) disconnected: \(error)") - } - eventBroadcaster.yield(.sessionStateChanged(sessionID: session.id, isConnected: false)) - } - - return sessionIDs + for session in connectedSessions { + sessionIDs.insert(session.id) + stopKeepAlive(sessionID: session.id) + do { + try await dataStore.markSessionDisconnected(session.id) + } catch { + logger.error("Failed to mark session \(session.id) disconnected: \(error)") + } + eventBroadcaster.yield(.sessionStateChanged(sessionID: session.id, isConnected: false)) } - // MARK: - BLE Reconnection + return sessionIDs + } - /// Called when BLE connection is re-established. - /// Re-authenticates sessions that were connected before BLE loss. - /// - Parameter sessionIDs: Session IDs from `handleBLEDisconnection()`. - /// If empty (e.g., after app restart), no sessions are re-authenticated; - /// the user can manually reconnect. - public func handleBLEReconnection(sessionIDs: Set) async { - guard !isReauthenticating else { - logger.info("Skipping re-auth: already in progress") - return - } + // MARK: - BLE Reconnection - guard !sessionIDs.isEmpty else { return } + /// Called when BLE connection is re-established. + /// Re-authenticates sessions that were connected before BLE loss. + /// - Parameter sessionIDs: Session IDs from `handleBLEDisconnection()`. + /// If empty (e.g., after app restart), no sessions are re-authenticated; + /// the user can manually reconnect. + func handleBLEReconnection(sessionIDs: Set) async { + guard !isReauthenticating else { + logger.info("Skipping re-auth: already in progress") + return + } - // Fetch current session state for each ID - var sessionsToReauth: [RemoteNodeSessionDTO] = [] - for id in sessionIDs { - if let session = try? await dataStore.fetchRemoteNodeSession(id: id) { - sessionsToReauth.append(session) - } else { - logger.warning("Session \(id) not found for re-auth, skipping") - } - } + guard !sessionIDs.isEmpty else { return } + + // Fetch current session state for each ID + var sessionsToReauth: [RemoteNodeSessionDTO] = [] + for id in sessionIDs { + if let session = try? await dataStore.fetchRemoteNodeSession(id: id) { + sessionsToReauth.append(session) + } else { + logger.warning("Session \(id) not found for re-auth, skipping") + } + } - guard !sessionsToReauth.isEmpty else { return } + guard !sessionsToReauth.isEmpty else { return } - logger.info("BLE reconnection: re-authenticating \(sessionsToReauth.count) session(s)") - isReauthenticating = true - defer { isReauthenticating = false } + logger.info("BLE reconnection: re-authenticating \(sessionsToReauth.count) session(s)") + isReauthenticating = true + defer { isReauthenticating = false } - await withTaskGroup(of: Void.self) { group in - for remoteSession in sessionsToReauth { - group.addTask { [self] in - let previousPermission = remoteSession.permissionLevel - do { - let result = try await self.login(sessionID: remoteSession.id) - let newPermission = result.permissionLevel - if newPermission < previousPermission { - self.logger.warning( - "Re-auth returned degraded permission for session \(remoteSession.id): " - + "\(previousPermission) -> \(newPermission), marking disconnected" - ) - try? await self.dataStore.markSessionDisconnected(remoteSession.id) - self.eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: false)) - } - } catch { - self.logger.warning("Re-auth failed for session \(remoteSession.id): \(error)") - do { - try await self.dataStore.markSessionDisconnected(remoteSession.id) - } catch { - self.logger.error("Failed to persist disconnected state for session \(remoteSession.id): \(error)") - } - self.eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: false)) - } - } + await withTaskGroup(of: Void.self) { group in + for remoteSession in sessionsToReauth { + group.addTask { [self] in + let previousPermission = remoteSession.permissionLevel + do { + let result = try await login(sessionID: remoteSession.id) + let newPermission = result.permissionLevel + if newPermission < previousPermission { + logger.warning( + "Re-auth returned degraded permission for session \(remoteSession.id): " + + "\(previousPermission) -> \(newPermission), marking disconnected" + ) + try? await dataStore.markSessionDisconnected(remoteSession.id) + eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: false)) + } + } catch { + logger.warning("Re-auth failed for session \(remoteSession.id): \(error)") + do { + try await dataStore.markSessionDisconnected(remoteSession.id) + } catch { + logger.error("Failed to persist disconnected state for session \(remoteSession.id): \(error)") } + eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: false)) + } } + } } + } } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Telemetry.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Telemetry.swift index 74aa7652..c65fa666 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Telemetry.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Telemetry.swift @@ -1,74 +1,73 @@ import Foundation import MeshCore -extension RemoteNodeService { +public extension RemoteNodeService { + // MARK: - Status - // MARK: - Status - - /// Request status from a remote node. - public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + /// Request status from a remote node. + func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - // Log status request - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logStatusRequest(target: targetType, publicKey: remoteSession.publicKey) + // Log status request + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logStatusRequest(target: targetType, publicKey: remoteSession.publicKey) - do { - let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum - return try await withTimeout(effectiveTimeout, operationName: "remoteStatus") { - let contactType: ContactType = remoteSession.isRoom ? .room : .repeater - return try await self.session.requestStatus(from: remoteSession.publicKey, type: contactType) - } - } catch is TimeoutError { - throw RemoteNodeError.timeout - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } + do { + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteStatus") { + let contactType: ContactType = remoteSession.isRoom ? .room : .repeater + return try await self.session.requestStatus(from: remoteSession.publicKey, type: contactType) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) } + } - // MARK: - Telemetry + // MARK: - Telemetry - /// Request telemetry from a remote node - public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + /// Request telemetry from a remote node + func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - // Log telemetry request - let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater - await auditLogger.logTelemetryRequest(target: targetType, publicKey: remoteSession.publicKey) + // Log telemetry request + let targetType: CommandAuditLogger.Target = remoteSession.isRoom ? .room : .repeater + await auditLogger.logTelemetryRequest(target: targetType, publicKey: remoteSession.publicKey) - do { - let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum - return try await withTimeout(effectiveTimeout, operationName: "remoteTelemetry") { - try await self.session.requestTelemetry(from: remoteSession.publicKey) - } - } catch is TimeoutError { - throw RemoteNodeError.timeout - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } + do { + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteTelemetry") { + try await self.session.requestTelemetry(from: remoteSession.publicKey) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) } + } - // MARK: - Owner Info + // MARK: - Owner Info - /// Request owner info from a repeater using binary protocol. - public func requestOwnerInfo(sessionID: UUID, timeout: Duration? = nil) async throws -> OwnerInfoResponse { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + /// Request owner info from a repeater using binary protocol. + func requestOwnerInfo(sessionID: UUID, timeout: Duration? = nil) async throws -> OwnerInfoResponse { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } - do { - let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum - return try await withTimeout(effectiveTimeout, operationName: "remoteOwnerInfo") { - try await self.session.requestOwnerInfo(from: remoteSession.publicKey) - } - } catch is TimeoutError { - throw RemoteNodeError.timeout - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } + do { + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteOwnerInfo") { + try await self.session.requestOwnerInfo(from: remoteSession.publicKey) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift index 92add9cf..32c7352c 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift @@ -14,381 +14,381 @@ private let cliResponseTextType: UInt8 = 0x01 /// Shared service for remote node operations. /// Handles login, keep-alive, status, telemetry, and CLI for both room servers and repeaters. public actor RemoteNodeService { - - // MARK: - Properties - - let session: any RemoteAccessSessionOps & SessionEventStreaming & ContactSessionOps - let dataStore: any PersistenceStoreProtocol - let keychainService: KeychainService - let logger = PersistentLogger(subsystem: "com.mc1", category: "RemoteNode") - let auditLogger = CommandAuditLogger() - - /// Pending login continuations keyed by 6-byte public key prefix. - /// Using 6-byte prefix matches MeshCore protocol format for login results. - var pendingLogins: [Data: CheckedContinuation] = [:] - - /// Timeout tasks for pending logins, keyed by 6-byte public key prefix. - /// Cancelled when login succeeds/fails before timeout. - var pendingLoginTimeoutTasks: [Data: Task] = [:] - - /// Pending CLI request with command info for content-based response matching - struct PendingCLIRequest { - let command: String - let continuation: CheckedContinuation - let timestamp: Date + // MARK: - Properties + + let session: any RemoteAccessSessionOps & SessionEventStreaming & ContactSessionOps + let dataStore: any PersistenceStoreProtocol + let keychainService: KeychainService + let logger = PersistentLogger(subsystem: "com.mc1", category: "RemoteNode") + let auditLogger = CommandAuditLogger() + + /// Pending login continuations keyed by 6-byte public key prefix. + /// Using 6-byte prefix matches MeshCore protocol format for login results. + var pendingLogins: [Data: CheckedContinuation] = [:] + + /// Timeout tasks for pending logins, keyed by 6-byte public key prefix. + /// Cancelled when login succeeds/fails before timeout. + var pendingLoginTimeoutTasks: [Data: Task] = [:] + + /// Pending CLI request with command info for content-based response matching + struct PendingCLIRequest { + let command: String + let continuation: CheckedContinuation + let timestamp: Date + } + + /// Pending CLI requests keyed by 6-byte public key prefix. + /// Multiple requests per destination stored in order for FIFO fallback. + var pendingCLIRequests: [Data: [PendingCLIRequest]] = [:] + + /// Pending raw CLI requests for passthrough (FIFO matching, single request per sender). + /// Used by CLI tool where any response should be delivered without content-based matching. + var pendingRawCLIRequests: [Data: CheckedContinuation] = [:] + + /// Keep-alive timer tasks + var keepAliveTasks: [UUID: Task] = [:] + + /// Keep-alive intervals per session (from login response, in seconds) + /// Default to 90 seconds if not specified + var keepAliveIntervals: [UUID: Duration] = [:] + static let defaultKeepAliveInterval: Duration = .seconds(90) + + /// Reentrancy guard for BLE reconnection handling + var isReauthenticating = false + + /// Event monitoring task + private var eventMonitorTask: Task? + + // MARK: - Handlers + + /// Handler for keep-alive ACK responses + /// Called when ACK with unsynced count is received. + /// Nothing assigns or reads this property today. + public var keepAliveResponseHandler: (@Sendable (UUID, Int) async -> Void)? + + // MARK: - Events + + /// Multicast broadcaster for session connection-state events. + nonisolated let eventBroadcaster = EventBroadcaster() + + /// Returns a fresh stream of remote-node session events. Registration is + /// synchronous, so events yielded after this call are never dropped. + /// Consumers must re-subscribe per connection because the owning + /// `ServiceContainer` is rebuilt on every connection. + public nonisolated func events() -> AsyncStream { + eventBroadcaster.subscribe() + } + + /// Ends every `events()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishEvents() { + eventBroadcaster.finish() + } + + // MARK: - Initialization + + init( + session: any RemoteAccessSessionOps & SessionEventStreaming & ContactSessionOps, + dataStore: any PersistenceStoreProtocol, + keychainService: KeychainService + ) { + self.session = session + self.dataStore = dataStore + self.keychainService = keychainService + } + + deinit { + eventMonitorTask?.cancel() + for task in keepAliveTasks.values { + task.cancel() } + } - /// Pending CLI requests keyed by 6-byte public key prefix. - /// Multiple requests per destination stored in order for FIFO fallback. - var pendingCLIRequests: [Data: [PendingCLIRequest]] = [:] - - /// Pending raw CLI requests for passthrough (FIFO matching, single request per sender). - /// Used by CLI tool where any response should be delivered without content-based matching. - var pendingRawCLIRequests: [Data: CheckedContinuation] = [:] - - /// Keep-alive timer tasks - var keepAliveTasks: [UUID: Task] = [:] - - /// Keep-alive intervals per session (from login response, in seconds) - /// Default to 90 seconds if not specified - var keepAliveIntervals: [UUID: Duration] = [:] - static let defaultKeepAliveInterval: Duration = .seconds(90) + // MARK: - Event Monitoring - /// Reentrancy guard for BLE reconnection handling - var isReauthenticating = false + /// Start monitoring MeshCore events for login results + public func startEventMonitoring() { + eventMonitorTask?.cancel() - /// Event monitoring task - private var eventMonitorTask: Task? - - // MARK: - Handlers - - /// Handler for keep-alive ACK responses - /// Called when ACK with unsynced count is received. - /// Nothing assigns or reads this property today. - public var keepAliveResponseHandler: (@Sendable (UUID, Int) async -> Void)? - - // MARK: - Events - - /// Multicast broadcaster for session connection-state events. - nonisolated let eventBroadcaster = EventBroadcaster() - - /// Returns a fresh stream of remote-node session events. Registration is - /// synchronous, so events yielded after this call are never dropped. - /// Consumers must re-subscribe per connection because the owning - /// `ServiceContainer` is rebuilt on every connection. - public nonisolated func events() -> AsyncStream { - eventBroadcaster.subscribe() - } + eventMonitorTask = Task { [weak self] in + guard let self else { return } + let filter = EventFilter { event in + switch event { + case .loginSuccess, .loginFailed: + true + case let .contactMessageReceived(info) where info.textType == cliResponseTextType: + true + default: + false + } + } + let events = await session.events(filter: filter) - /// Ends every `events()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishEvents() { - eventBroadcaster.finish() + for await event in events { + guard !Task.isCancelled else { break } + await handleEvent(event) + } } + } + + /// Stop monitoring events + public func stopEventMonitoring() { + eventMonitorTask?.cancel() + eventMonitorTask = nil + } + + /// Handle incoming MeshCore event + private func handleEvent(_ event: MeshEvent) async { + switch event { + case let .loginSuccess(info): + let prefixHex = info.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + logger.info("loginSuccess received for prefix \(prefixHex)") + let result = LoginResult( + success: true, + isAdmin: info.isAdmin, + aclPermissions: info.permissions, + publicKeyPrefix: info.publicKeyPrefix + ) + await handleLoginResult(result, fromPublicKeyPrefix: info.publicKeyPrefix) + + case let .loginFailed(publicKeyPrefix): + if let prefix = publicKeyPrefix { + let result = LoginResult( + success: false, + isAdmin: false, + aclPermissions: nil, + publicKeyPrefix: prefix + ) + await handleLoginResult(result, fromPublicKeyPrefix: prefix) + } - // MARK: - Initialization - - init( - session: any RemoteAccessSessionOps & SessionEventStreaming & ContactSessionOps, - dataStore: any PersistenceStoreProtocol, - keychainService: KeychainService - ) { - self.session = session - self.dataStore = dataStore - self.keychainService = keychainService - } + case let .contactMessageReceived(message): + if message.textType == cliResponseTextType { + handleCLIResponse(message) + } - deinit { - eventMonitorTask?.cancel() - for task in keepAliveTasks.values { - task.cancel() - } + default: + break } + } - // MARK: - Event Monitoring - - /// Start monitoring MeshCore events for login results - public func startEventMonitoring() { - eventMonitorTask?.cancel() - - eventMonitorTask = Task { [weak self] in - guard let self else { return } - let filter = EventFilter { event in - switch event { - case .loginSuccess, .loginFailed: - return true - case .contactMessageReceived(let info) where info.textType == cliResponseTextType: - return true - default: - return false - } - } - let events = await session.events(filter: filter) - - for await event in events { - guard !Task.isCancelled else { break } - await self.handleEvent(event) - } - } - } + /// Handle CLI response from a contact message. + /// Content-based matching runs first — raw (FIFO) matching only gets responses + /// that no typed query claimed. + private func handleCLIResponse(_ message: ContactMessage) { + let prefix = Data(message.senderPublicKeyPrefix.prefix(6)) - /// Stop monitoring events - public func stopEventMonitoring() { - eventMonitorTask?.cancel() - eventMonitorTask = nil - } + // Try content-based matching first for structured requests + if var requests = pendingCLIRequests[prefix], !requests.isEmpty { + let (matchIndex, matchCount) = findBestMatch(response: message.text, in: requests) - /// Handle incoming MeshCore event - private func handleEvent(_ event: MeshEvent) async { - switch event { - case .loginSuccess(let info): - let prefixHex = info.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() - logger.info("loginSuccess received for prefix \(prefixHex)") - let result = LoginResult( - success: true, - isAdmin: info.isAdmin, - aclPermissions: info.permissions, - publicKeyPrefix: info.publicKeyPrefix - ) - await handleLoginResult(result, fromPublicKeyPrefix: info.publicKeyPrefix) - - case .loginFailed(let publicKeyPrefix): - if let prefix = publicKeyPrefix { - let result = LoginResult( - success: false, - isAdmin: false, - aclPermissions: nil, - publicKeyPrefix: prefix - ) - await handleLoginResult(result, fromPublicKeyPrefix: prefix) - } - - case .contactMessageReceived(let message): - if message.textType == cliResponseTextType { - handleCLIResponse(message) - } + if let matchIndex { + let matched = requests.remove(at: matchIndex) + pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests + matched.continuation.resume(returning: message.text) + return + } - default: - break - } + if matchCount > 1 { + // Multiple matches (ambiguous like "OK") - fall back to FIFO + let oldest = requests.removeFirst() + pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests + oldest.continuation.resume(returning: message.text) + return + } } - /// Handle CLI response from a contact message. - /// Content-based matching runs first — raw (FIFO) matching only gets responses - /// that no typed query claimed. - private func handleCLIResponse(_ message: ContactMessage) { - let prefix = Data(message.senderPublicKeyPrefix.prefix(6)) - - // Try content-based matching first for structured requests - if var requests = pendingCLIRequests[prefix], !requests.isEmpty { - let (matchIndex, matchCount) = findBestMatch(response: message.text, in: requests) - - if let matchIndex { - let matched = requests.remove(at: matchIndex) - pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests - matched.continuation.resume(returning: message.text) - return - } - - if matchCount > 1 { - // Multiple matches (ambiguous like "OK") - fall back to FIFO - let oldest = requests.removeFirst() - pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests - oldest.continuation.resume(returning: message.text) - return - } - } - - // No content match — deliver to raw CLI request if one is pending - if let continuation = pendingRawCLIRequests.removeValue(forKey: prefix) { - continuation.resume(returning: message.text) - return - } - - // No pending requests matched - logger.debug("Unmatched CLI response (no pending request): \(message.text.prefix(50))") + // No content match — deliver to raw CLI request if one is pending + if let continuation = pendingRawCLIRequests.removeValue(forKey: prefix) { + continuation.resume(returning: message.text) + return } - /// Find best matching request for a response based on CLIResponse parsing - /// Returns the matching index (if exactly one) and total match count - private func findBestMatch(response: String, in requests: [PendingCLIRequest]) -> (index: Int?, matchCount: Int) { - var matchingIndices: [Int] = [] + // No pending requests matched + logger.debug("Unmatched CLI response (no pending request): \(message.text.prefix(50))") + } - for (index, request) in requests.enumerated() { - let parsed = CLIResponse.parse(response, forQuery: request.command) - - // If parsing with this query produces a specific result (not .raw), - // it's a potential match - if case .raw = parsed { - continue - } - matchingIndices.append(index) - } + /// Find best matching request for a response based on CLIResponse parsing + /// Returns the matching index (if exactly one) and total match count + private func findBestMatch(response: String, in requests: [PendingCLIRequest]) -> (index: Int?, matchCount: Int) { + var matchingIndices: [Int] = [] - // Return match only if exactly one command matches - if matchingIndices.count == 1 { - return (matchingIndices[0], 1) - } + for (index, request) in requests.enumerated() { + let parsed = CLIResponse.parse(response, forQuery: request.command) - return (nil, matchingIndices.count) + // If parsing with this query produces a specific result (not .raw), + // it's a potential match + if case .raw = parsed { + continue + } + matchingIndices.append(index) } - func timeInterval(for duration: Duration) -> TimeInterval { - let (seconds, attoseconds) = duration.components - return TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18 + // Return match only if exactly one command matches + if matchingIndices.count == 1 { + return (matchingIndices[0], 1) } - func cancelPendingLogin(for prefix: Data) { - pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() - if let continuation = pendingLogins.removeValue(forKey: prefix) { - continuation.resume(throwing: RemoteNodeError.cancelled) - } - } + return (nil, matchingIndices.count) + } - func cancelPendingCLIRequest(for prefix: Data, timestamp: Date) { - guard var requests = pendingCLIRequests[prefix], - let index = requests.firstIndex(where: { $0.timestamp == timestamp }) else { - return - } + func timeInterval(for duration: Duration) -> TimeInterval { + let (seconds, attoseconds) = duration.components + return TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18 + } - let cancelled = requests.remove(at: index) - pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests - cancelled.continuation.resume(throwing: CancellationError()) + func cancelPendingLogin(for prefix: Data) { + pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() + if let continuation = pendingLogins.removeValue(forKey: prefix) { + continuation.resume(throwing: RemoteNodeError.cancelled) } + } - // MARK: - Session Management - - /// Create a session DTO for a contact, optionally preserving data from an existing session. - private func makeSessionDTO( - radioID: UUID, - contact: ContactDTO, - role: RemoteNodeRole, - preserving existing: RemoteNodeSessionDTO? = nil - ) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: existing?.id ?? UUID(), - radioID: radioID, - publicKey: contact.publicKey, - name: contact.displayName, - role: role, - latitude: contact.latitude, - longitude: contact.longitude, - isConnected: false, - permissionLevel: existing?.permissionLevel ?? .guest, - lastConnectedDate: existing?.lastConnectedDate, - lastBatteryMillivolts: existing?.lastBatteryMillivolts, - lastUptimeSeconds: existing?.lastUptimeSeconds, - lastNoiseFloor: existing?.lastNoiseFloor, - unreadCount: existing?.unreadCount ?? 0, - notificationLevel: existing?.notificationLevel ?? .all, - lastRxAirtimeSeconds: existing?.lastRxAirtimeSeconds, - neighborCount: existing?.neighborCount ?? 0, - lastSyncTimestamp: existing?.lastSyncTimestamp ?? 0, - lastMessageDate: existing?.lastMessageDate - ) + func cancelPendingCLIRequest(for prefix: Data, timestamp: Date) { + guard var requests = pendingCLIRequests[prefix], + let index = requests.firstIndex(where: { $0.timestamp == timestamp }) else { + return } - /// Create a new session for a remote node. - public func createSession( - radioID: UUID, - contact: ContactDTO - ) async throws -> RemoteNodeSessionDTO { - guard let role = RemoteNodeRole(contactType: contact.type) else { - throw RemoteNodeError.invalidResponse - } - - guard contact.publicKey.count == ProtocolLimits.publicKeySize else { - throw RemoteNodeError.loginFailed("Invalid public key length: expected \(ProtocolLimits.publicKeySize) bytes, got \(contact.publicKey.count)") - } - - let pubKeyHex = contact.publicKey.prefix(6).map { String(format: "%02x", $0) }.joined() - - // Check for existing session - reuse to avoid duplicates - let existing = try? await dataStore.fetchRemoteNodeSession(publicKey: contact.publicKey) - - if let existing { - logger.info("createSession: reusing existing session \(existing.id) for \(pubKeyHex), isConnected=\(existing.isConnected)") - } else { - logger.info("createSession: creating new session for \(pubKeyHex)") - } - - let dto = makeSessionDTO(radioID: radioID, contact: contact, role: role, preserving: existing) + let cancelled = requests.remove(at: index) + pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests + cancelled.continuation.resume(throwing: CancellationError()) + } + + // MARK: - Session Management + + /// Create a session DTO for a contact, optionally preserving data from an existing session. + private func makeSessionDTO( + radioID: UUID, + contact: ContactDTO, + role: RemoteNodeRole, + preserving existing: RemoteNodeSessionDTO? = nil + ) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: existing?.id ?? UUID(), + radioID: radioID, + publicKey: contact.publicKey, + name: contact.displayName, + role: role, + latitude: contact.latitude, + longitude: contact.longitude, + isConnected: false, + permissionLevel: existing?.permissionLevel ?? .guest, + lastConnectedDate: existing?.lastConnectedDate, + lastBatteryMillivolts: existing?.lastBatteryMillivolts, + lastUptimeSeconds: existing?.lastUptimeSeconds, + lastNoiseFloor: existing?.lastNoiseFloor, + unreadCount: existing?.unreadCount ?? 0, + notificationLevel: existing?.notificationLevel ?? .all, + lastRxAirtimeSeconds: existing?.lastRxAirtimeSeconds, + neighborCount: existing?.neighborCount ?? 0, + lastSyncTimestamp: existing?.lastSyncTimestamp ?? 0, + lastMessageDate: existing?.lastMessageDate + ) + } + + /// Create a new session for a remote node. + public func createSession( + radioID: UUID, + contact: ContactDTO + ) async throws -> RemoteNodeSessionDTO { + guard let role = RemoteNodeRole(contactType: contact.type) else { + throw RemoteNodeError.invalidResponse + } - try await dataStore.saveRemoteNodeSessionDTO(dto) + guard contact.publicKey.count == ProtocolLimits.publicKeySize else { + throw RemoteNodeError.loginFailed("Invalid public key length: expected \(ProtocolLimits.publicKeySize) bytes, got \(contact.publicKey.count)") + } - // Clean up any duplicate sessions with the same public key but different IDs - try await dataStore.cleanupDuplicateRemoteNodeSessions(publicKey: contact.publicKey, keepID: dto.id) + let pubKeyHex = contact.publicKey.prefix(6).map { String(format: "%02x", $0) }.joined() - guard let saved = try await dataStore.fetchRemoteNodeSession(publicKey: contact.publicKey) else { - logger.error("createSession: failed to fetch saved session for \(pubKeyHex)") - throw RemoteNodeError.sessionNotFound - } + // Check for existing session - reuse to avoid duplicates + let existing = try? await dataStore.fetchRemoteNodeSession(publicKey: contact.publicKey) - logger.info("createSession: saved session \(saved.id) for \(pubKeyHex)") - return saved + if let existing { + logger.info("createSession: reusing existing session \(existing.id) for \(pubKeyHex), isConnected=\(existing.isConnected)") + } else { + logger.info("createSession: creating new session for \(pubKeyHex)") } - /// Remove a session and its associated data - public func removeSession(id: UUID, publicKey: Data) async throws { - stopKeepAlive(sessionID: id) - try await keychainService.deletePassword(forNodeKey: publicKey) - try await dataStore.deleteRemoteNodeSession(id: id) - } + let dto = makeSessionDTO(radioID: radioID, contact: contact, role: role, preserving: existing) - /// Check if a password is stored for a contact's public key. - public func hasPassword(forContact contact: ContactDTO) async -> Bool { - await keychainService.hasPassword(forNodeKey: contact.publicKey) - } + try await dataStore.saveRemoteNodeSessionDTO(dto) - /// Retrieve the stored password for a contact's public key. - public func retrievePassword(forContact contact: ContactDTO) async -> String? { - try? await keychainService.retrievePassword(forNodeKey: contact.publicKey) - } + // Clean up any duplicate sessions with the same public key but different IDs + try await dataStore.cleanupDuplicateRemoteNodeSessions(publicKey: contact.publicKey, keepID: dto.id) - /// Store a password for a remote node. - /// Call this after successful login to save correct passwords only. - public func storePassword(_ password: String, forNodeKey publicKey: Data) async throws { - try await keychainService.storePassword(password, forNodeKey: publicKey) + guard let saved = try await dataStore.fetchRemoteNodeSession(publicKey: contact.publicKey) else { + logger.error("createSession: failed to fetch saved session for \(pubKeyHex)") + throw RemoteNodeError.sessionNotFound } - /// Delete the stored password for a contact's public key. - public func deletePassword(forContact contact: ContactDTO) async throws { - try await keychainService.deletePassword(forNodeKey: contact.publicKey) + logger.info("createSession: saved session \(saved.id) for \(pubKeyHex)") + return saved + } + + /// Remove a session and its associated data + public func removeSession(id: UUID, publicKey: Data) async throws { + stopKeepAlive(sessionID: id) + try await keychainService.deletePassword(forNodeKey: publicKey) + try await dataStore.deleteRemoteNodeSession(id: id) + } + + /// Check if a password is stored for a contact's public key. + public func hasPassword(forContact contact: ContactDTO) async -> Bool { + await keychainService.hasPassword(forNodeKey: contact.publicKey) + } + + /// Retrieve the stored password for a contact's public key. + public func retrievePassword(forContact contact: ContactDTO) async -> String? { + try? await keychainService.retrievePassword(forNodeKey: contact.publicKey) + } + + /// Store a password for a remote node. + /// Call this after successful login to save correct passwords only. + public func storePassword(_ password: String, forNodeKey publicKey: Data) async throws { + try await keychainService.storePassword(password, forNodeKey: publicKey) + } + + /// Delete the stored password for a contact's public key. + public func deletePassword(forContact contact: ContactDTO) async throws { + try await keychainService.deletePassword(forNodeKey: contact.publicKey) + } + + // MARK: - Disconnect + + /// Mark session as disconnected without sending logout. + public func disconnect(sessionID: UUID) async { + stopKeepAlive(sessionID: sessionID) + do { + try await dataStore.markSessionDisconnected(sessionID) + } catch { + logger.error("Failed to persist disconnected state for session \(sessionID): \(error)") } - // MARK: - Disconnect + // Notify UI of session state change + eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) + } - /// Mark session as disconnected without sending logout. - public func disconnect(sessionID: UUID) async { - stopKeepAlive(sessionID: sessionID) - do { - try await dataStore.markSessionDisconnected(sessionID) - } catch { - logger.error("Failed to persist disconnected state for session \(sessionID): \(error)") - } + // MARK: - Cleanup - // Notify UI of session state change - eventBroadcaster.yield(.sessionStateChanged(sessionID: sessionID, isConnected: false)) + /// Stop all keep-alive timers and resume any parked login continuations (call on app termination) + public func stopAllKeepAlives() { + for task in keepAliveTasks.values { + task.cancel() } + keepAliveTasks.removeAll() - // MARK: - Cleanup - - /// Stop all keep-alive timers and resume any parked login continuations (call on app termination) - public func stopAllKeepAlives() { - for task in keepAliveTasks.values { - task.cancel() - } - keepAliveTasks.removeAll() - - // Resume parked logins before dropping their timeout tasks; otherwise the - // continuation that would have resumed them is gone and the caller hangs. - for prefix in Array(pendingLogins.keys) { - cancelPendingLogin(for: prefix) - } + // Resume parked logins before dropping their timeout tasks; otherwise the + // continuation that would have resumed them is gone and the caller hangs. + for prefix in Array(pendingLogins.keys) { + cancelPendingLogin(for: prefix) } + } - /// Number of login continuations currently parked. Exposed for teardown tests. - var pendingLoginCount: Int { pendingLogins.count } - + /// Number of login continuations currently parked. Exposed for teardown tests. + var pendingLoginCount: Int { + pendingLogins.count + } } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteOperationTimeoutPolicy.swift b/MC1Services/Sources/MC1Services/Services/RemoteOperationTimeoutPolicy.swift index a81d9571..d69eb6cf 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteOperationTimeoutPolicy.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteOperationTimeoutPolicy.swift @@ -2,22 +2,22 @@ import Foundation import MeshCore public enum RemoteOperationTimeoutPolicy { - static let firmwareRoundTripMultiplier = 2 - static let loginMaximum: Duration = .seconds(20) - public static let binaryMaximum: Duration = .seconds(15) - static let cliMaximum: Duration = .seconds(15) - static let fireAndForgetCLI: Duration = .seconds(2) - static let pollInterval: Duration = .milliseconds(500) + static let firmwareRoundTripMultiplier = 2 + static let loginMaximum: Duration = .seconds(20) + public static let binaryMaximum: Duration = .seconds(15) + static let cliMaximum: Duration = .seconds(15) + static let fireAndForgetCLI: Duration = .seconds(2) + static let pollInterval: Duration = .milliseconds(500) - static func firmwareRoundTripTimeout(from sentInfo: MessageSentInfo) -> Duration { - .milliseconds(Int(sentInfo.suggestedTimeoutMs) * firmwareRoundTripMultiplier) - } + static func firmwareRoundTripTimeout(from sentInfo: MessageSentInfo) -> Duration { + .milliseconds(Int(sentInfo.suggestedTimeoutMs) * firmwareRoundTripMultiplier) + } - static func loginTimeout(for sentInfo: MessageSentInfo, pathLength: UInt8) -> Duration { - min(max(firmwareRoundTripTimeout(from: sentInfo), LoginTimeoutConfig.timeout(forPathLength: pathLength)), loginMaximum) - } + static func loginTimeout(for sentInfo: MessageSentInfo, pathLength: UInt8) -> Duration { + min(max(firmwareRoundTripTimeout(from: sentInfo), LoginTimeoutConfig.timeout(forPathLength: pathLength)), loginMaximum) + } - static func cliTimeout(for sentInfo: MessageSentInfo, requestedTimeout: Duration) -> Duration { - min(max(requestedTimeout, firmwareRoundTripTimeout(from: sentInfo)), cliMaximum) - } + static func cliTimeout(for sentInfo: MessageSentInfo, requestedTimeout: Duration) -> Duration { + min(max(requestedTimeout, firmwareRoundTripTimeout(from: sentInfo)), cliMaximum) + } } diff --git a/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift b/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift index 9cae9044..e2eb9c73 100644 --- a/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift +++ b/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift @@ -6,10 +6,10 @@ import os /// Sort order options for neighbor queries public enum NeighborSortOrder: UInt8, Sendable { - case newestFirst = 0 - case oldestFirst = 1 - case strongestFirst = 2 - case weakestFirst = 3 + case newestFirst = 0 + case oldestFirst = 1 + case strongestFirst = 2 + case weakestFirst = 3 } // MARK: - Repeater Admin Service @@ -17,321 +17,320 @@ public enum NeighborSortOrder: UInt8, Sendable { /// Service for repeater admin interactions. /// Handles connecting as admin, viewing status/telemetry/neighbors, and sending CLI commands. public actor RepeaterAdminService { - - // MARK: - Properties - - private let session: any RemoteAccessSessionOps - private let remoteNodeService: RemoteNodeService - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "RepeaterAdmin") - private let auditLogger = CommandAuditLogger() - - /// Handler for neighbor responses - public var neighboursResponseHandler: (@Sendable (NeighboursResponse) async -> Void)? - - /// Handler for telemetry responses - public var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? - - /// Handler for status responses - public var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? - - /// Handler for CLI text responses - public var cliResponseHandler: (@Sendable (ContactMessage, ContactDTO) async -> Void)? - - /// Default pubkey prefix length for neighbor queries. - public static let defaultPubkeyPrefixLength: UInt8 = 6 - - /// Per-request neighbour count used while paginating. A node caps each response to one - /// radio frame regardless, so requesting the maximum minimizes the number of round-trips. - private static let neighborPageSize: UInt8 = 255 - - // MARK: - Initialization - - public init( - session: any RemoteAccessSessionOps, - remoteNodeService: RemoteNodeService, - dataStore: any PersistenceStoreProtocol - ) { - self.session = session - self.remoteNodeService = remoteNodeService - self.dataStore = dataStore - } - - // MARK: - Admin Connection - - /// Connect to a repeater as admin by creating a session and authenticating. - /// - Parameter onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. - public func connectAsAdmin( - radioID: UUID, - contact: ContactDTO, - password: String?, - rememberPassword: Bool = true, - pathLength: UInt8 = 0, - onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil - ) async throws -> RemoteNodeSessionDTO { - let remoteSession = try await remoteNodeService.createSession( - radioID: radioID, - contact: contact - ) - - // Login to the repeater with appropriate timeout - _ = try await remoteNodeService.login( - sessionID: remoteSession.id, - password: password, - pathLength: pathLength, - onTimeoutKnown: onTimeoutKnown - ) - - // Store password only after successful login - if let password, rememberPassword { - try await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) - } - - guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) else { - throw RemoteNodeError.sessionNotFound - } - return updatedSession - } - - /// Disconnect from a repeater by sending logout and removing the session. - public func disconnect(sessionID: UUID, publicKey: Data) async throws { - try await remoteNodeService.logout(sessionID: sessionID) - try await remoteNodeService.removeSession(id: sessionID, publicKey: publicKey) - } - - // MARK: - Neighbors (Repeater-Specific) - - /// Request neighbors list from repeater. - public func requestNeighbors( - sessionID: UUID, - count: UInt8 = 20, - offset: UInt16 = 0, - orderBy: NeighborSortOrder = .newestFirst, - pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength, - timeout: Duration? = nil - ) async throws -> NeighboursResponse { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), - remoteSession.isRepeater else { - throw RemoteNodeError.sessionNotFound - } - - // Log neighbors request - await auditLogger.logNeighborsRequest(publicKey: remoteSession.publicKey, count: count, offset: offset) - - do { - let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum - return try await withTimeout(effectiveTimeout, operationName: "remoteNeighbours") { - try await self.session.requestNeighbours( - from: remoteSession.publicKey, - count: count, - offset: offset, - orderBy: orderBy.rawValue, - pubkeyPrefixLength: pubkeyPrefixLength - ) - } - } catch is TimeoutError { - throw RemoteNodeError.timeout - } catch let error as MeshCoreError { - throw RemoteNodeError.sessionError(error) - } - } - - /// Fetch all neighbors with automatic pagination. - public func fetchAllNeighbors( - sessionID: UUID, - orderBy: NeighborSortOrder = .newestFirst, - pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength, - timeout: Duration? = nil - ) async throws -> NeighboursResponse { - // Paginate over the per-page request so each round-trip keeps its audit log entry - // and timeout ceiling; a single node response is capped to one radio frame. - let response = try await NeighboursResponse.collectingAllPages { offset in - if offset > 0 { try await Task.sleep(for: NeighboursResponse.interPageDelay) } - return try await self.requestNeighbors( - sessionID: sessionID, - count: Self.neighborPageSize, - offset: offset, - orderBy: orderBy, - pubkeyPrefixLength: pubkeyPrefixLength, - timeout: timeout - ) - } - - if response.neighbours.count < response.totalCount { - logger.warning( - "Neighbour pagination returned \(response.neighbours.count) of \(response.totalCount) entries" - ) - } - return response - } - - // MARK: - Status - - /// Request status from a repeater. - public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { - try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) - } - - // MARK: - Telemetry - - /// Request telemetry from a repeater. - public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { - try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) - } - - // MARK: - Owner Info - - /// Request owner info from a repeater using binary protocol. - public func requestOwnerInfo(sessionID: UUID, timeout: Duration? = nil) async throws -> OwnerInfoResponse { - try await remoteNodeService.requestOwnerInfo(sessionID: sessionID, timeout: timeout) - } - - // MARK: - CLI Commands - - /// Send a CLI command to a repeater and wait for response (admin only). - /// Uses content-based matching for structured CLI responses. - public func sendCommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - try await remoteNodeService.sendCLICommand( - sessionID: sessionID, - command: command, - timeout: timeout - ) + // MARK: - Properties + + private let session: any RemoteAccessSessionOps + private let remoteNodeService: RemoteNodeService + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "RepeaterAdmin") + private let auditLogger = CommandAuditLogger() + + /// Handler for neighbor responses + public var neighboursResponseHandler: (@Sendable (NeighboursResponse) async -> Void)? + + /// Handler for telemetry responses + public var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? + + /// Handler for status responses + public var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? + + /// Handler for CLI text responses + public var cliResponseHandler: (@Sendable (ContactMessage, ContactDTO) async -> Void)? + + /// Default pubkey prefix length for neighbor queries. + public static let defaultPubkeyPrefixLength: UInt8 = 6 + + /// Per-request neighbour count used while paginating. A node caps each response to one + /// radio frame regardless, so requesting the maximum minimizes the number of round-trips. + private static let neighborPageSize: UInt8 = 255 + + // MARK: - Initialization + + public init( + session: any RemoteAccessSessionOps, + remoteNodeService: RemoteNodeService, + dataStore: any PersistenceStoreProtocol + ) { + self.session = session + self.remoteNodeService = remoteNodeService + self.dataStore = dataStore + } + + // MARK: - Admin Connection + + /// Connect to a repeater as admin by creating a session and authenticating. + /// - Parameter onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. + public func connectAsAdmin( + radioID: UUID, + contact: ContactDTO, + password: String?, + rememberPassword: Bool = true, + pathLength: UInt8 = 0, + onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil + ) async throws -> RemoteNodeSessionDTO { + let remoteSession = try await remoteNodeService.createSession( + radioID: radioID, + contact: contact + ) + + // Login to the repeater with appropriate timeout + _ = try await remoteNodeService.login( + sessionID: remoteSession.id, + password: password, + pathLength: pathLength, + onTimeoutKnown: onTimeoutKnown + ) + + // Store password only after successful login + if let password, rememberPassword { + try await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) } - /// Send a raw CLI command using FIFO response matching (admin only). - /// Used by CLI tool for passthrough where any response is accepted. - public func sendRawCommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - try await remoteNodeService.sendRawCLICommand( - sessionID: sessionID, - command: command, - timeout: timeout - ) - } - - // MARK: - Session Queries - - /// Fetch all repeater sessions for a device. - public func fetchRepeaterSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { - let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) - return sessions.filter { $0.isRepeater } + guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) else { + throw RemoteNodeError.sessionNotFound } - - /// Check if a contact is a known repeater with an active session. - public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { - guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), - remoteSession.isRepeater && remoteSession.isConnected else { - return nil - } - return remoteSession - } - - // MARK: - Handler Invocation - - /// Invoke the status response handler safely from actor context - public func invokeStatusHandler(_ status: StatusResponse) async { - // Log status response - await auditLogger.logStatusResponse( - target: .repeater, - publicKey: status.publicKeyPrefix, - batteryMv: status.batteryMillivolts, - uptimeSec: status.uptimeSeconds - ) - - guard let handler = statusResponseHandler else { - let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() - logger.debug("No status handler registered for response from \(prefixHex), ignoring") - return - } - await handler(status) + return updatedSession + } + + /// Disconnect from a repeater by sending logout and removing the session. + public func disconnect(sessionID: UUID, publicKey: Data) async throws { + try await remoteNodeService.logout(sessionID: sessionID) + try await remoteNodeService.removeSession(id: sessionID, publicKey: publicKey) + } + + // MARK: - Neighbors (Repeater-Specific) + + /// Request neighbors list from repeater. + public func requestNeighbors( + sessionID: UUID, + count: UInt8 = 20, + offset: UInt16 = 0, + orderBy: NeighborSortOrder = .newestFirst, + pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength, + timeout: Duration? = nil + ) async throws -> NeighboursResponse { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), + remoteSession.isRepeater else { + throw RemoteNodeError.sessionNotFound } - /// Invoke the neighbours response handler safely from actor context - public func invokeNeighboursHandler(_ response: NeighboursResponse) async { - // Log neighbors response - await auditLogger.logNeighborsResponse( - publicKey: response.publicKeyPrefix, - totalCount: Int(response.totalCount), - returnedCount: response.neighbours.count + // Log neighbors request + await auditLogger.logNeighborsRequest(publicKey: remoteSession.publicKey, count: count, offset: offset) + + do { + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteNeighbours") { + try await self.session.requestNeighbours( + from: remoteSession.publicKey, + count: count, + offset: offset, + orderBy: orderBy.rawValue, + pubkeyPrefixLength: pubkeyPrefixLength ) - - guard let handler = neighboursResponseHandler else { - logger.debug("No neighbours handler registered, ignoring response with \(response.neighbours.count) neighbours") - return - } - await handler(response) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) } - - /// Invoke the telemetry response handler safely from actor context - public func invokeTelemetryHandler(_ response: TelemetryResponse) async { - // Log telemetry response - await auditLogger.logTelemetryResponse( - target: .repeater, - publicKey: response.publicKeyPrefix, - pointCount: response.dataPoints.count - ) - - guard let handler = telemetryResponseHandler else { - logger.debug("No telemetry handler registered, ignoring response") - return - } - await handler(response) + } + + /// Fetch all neighbors with automatic pagination. + public func fetchAllNeighbors( + sessionID: UUID, + orderBy: NeighborSortOrder = .newestFirst, + pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength, + timeout: Duration? = nil + ) async throws -> NeighboursResponse { + // Paginate over the per-page request so each round-trip keeps its audit log entry + // and timeout ceiling; a single node response is capped to one radio frame. + let response = try await NeighboursResponse.collectingAllPages { offset in + if offset > 0 { try await Task.sleep(for: NeighboursResponse.interPageDelay) } + return try await self.requestNeighbors( + sessionID: sessionID, + count: Self.neighborPageSize, + offset: offset, + orderBy: orderBy, + pubkeyPrefixLength: pubkeyPrefixLength, + timeout: timeout + ) } - /// Invoke the CLI response handler safely from actor context - public func invokeCLIHandler(_ message: ContactMessage, fromContact contact: ContactDTO) async { - // Log CLI response (full content) - await auditLogger.logCLIResponse(publicKey: contact.publicKey, response: message.text) - - guard let handler = cliResponseHandler else { - logger.debug("No CLI handler registered, ignoring response from \(contact.displayName)") - return - } - await handler(message, contact) + if response.neighbours.count < response.totalCount { + logger.warning( + "Neighbour pagination returned \(response.neighbours.count) of \(response.totalCount) entries" + ) } - - // MARK: - Handler Setters - - /// Set handler for status responses - public func setStatusHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { - self.statusResponseHandler = handler + return response + } + + // MARK: - Status + + /// Request status from a repeater. + public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { + try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) + } + + // MARK: - Telemetry + + /// Request telemetry from a repeater. + public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { + try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) + } + + // MARK: - Owner Info + + /// Request owner info from a repeater using binary protocol. + public func requestOwnerInfo(sessionID: UUID, timeout: Duration? = nil) async throws -> OwnerInfoResponse { + try await remoteNodeService.requestOwnerInfo(sessionID: sessionID, timeout: timeout) + } + + // MARK: - CLI Commands + + /// Send a CLI command to a repeater and wait for response (admin only). + /// Uses content-based matching for structured CLI responses. + public func sendCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + /// Send a raw CLI command using FIFO response matching (admin only). + /// Used by CLI tool for passthrough where any response is accepted. + public func sendRawCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendRawCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + // MARK: - Session Queries + + /// Fetch all repeater sessions for a device. + public func fetchRepeaterSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) + return sessions.filter(\.isRepeater) + } + + /// Check if a contact is a known repeater with an active session. + public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), + remoteSession.isRepeater, remoteSession.isConnected else { + return nil } - - /// Set handler for neighbours responses - public func setNeighboursHandler(_ handler: @escaping @Sendable (NeighboursResponse) async -> Void) { - self.neighboursResponseHandler = handler + return remoteSession + } + + // MARK: - Handler Invocation + + /// Invoke the status response handler safely from actor context + public func invokeStatusHandler(_ status: StatusResponse) async { + // Log status response + await auditLogger.logStatusResponse( + target: .repeater, + publicKey: status.publicKeyPrefix, + batteryMv: status.batteryMillivolts, + uptimeSec: status.uptimeSeconds + ) + + guard let handler = statusResponseHandler else { + let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + logger.debug("No status handler registered for response from \(prefixHex), ignoring") + return } - - /// Set handler for telemetry responses - public func setTelemetryHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { - self.telemetryResponseHandler = handler + await handler(status) + } + + /// Invoke the neighbours response handler safely from actor context + public func invokeNeighboursHandler(_ response: NeighboursResponse) async { + // Log neighbors response + await auditLogger.logNeighborsResponse( + publicKey: response.publicKeyPrefix, + totalCount: Int(response.totalCount), + returnedCount: response.neighbours.count + ) + + guard let handler = neighboursResponseHandler else { + logger.debug("No neighbours handler registered, ignoring response with \(response.neighbours.count) neighbours") + return } - - /// Set handler for CLI responses - public func setCLIHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO) async -> Void) { - self.cliResponseHandler = handler + await handler(response) + } + + /// Invoke the telemetry response handler safely from actor context + public func invokeTelemetryHandler(_ response: TelemetryResponse) async { + // Log telemetry response + await auditLogger.logTelemetryResponse( + target: .repeater, + publicKey: response.publicKeyPrefix, + pointCount: response.dataPoints.count + ) + + guard let handler = telemetryResponseHandler else { + logger.debug("No telemetry handler registered, ignoring response") + return } + await handler(response) + } - /// Clear all handlers (called when view disappears) - public func clearHandlers() { - self.statusResponseHandler = nil - self.neighboursResponseHandler = nil - self.telemetryResponseHandler = nil - self.cliResponseHandler = nil - } + /// Invoke the CLI response handler safely from actor context + public func invokeCLIHandler(_ message: ContactMessage, fromContact contact: ContactDTO) async { + // Log CLI response (full content) + await auditLogger.logCLIResponse(publicKey: contact.publicKey, response: message.text) - /// Clears only the status-surface handlers so the merged admin surface can tear down its - /// status segment without dropping the settings VM's CLI handler on the shared per-connection service. - public func clearStatusHandlers() { - self.statusResponseHandler = nil - self.neighboursResponseHandler = nil - self.telemetryResponseHandler = nil + guard let handler = cliResponseHandler else { + logger.debug("No CLI handler registered, ignoring response from \(contact.displayName)") + return } + await handler(message, contact) + } + + // MARK: - Handler Setters + + /// Set handler for status responses + public func setStatusHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { + statusResponseHandler = handler + } + + /// Set handler for neighbours responses + public func setNeighboursHandler(_ handler: @escaping @Sendable (NeighboursResponse) async -> Void) { + neighboursResponseHandler = handler + } + + /// Set handler for telemetry responses + public func setTelemetryHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { + telemetryResponseHandler = handler + } + + /// Set handler for CLI responses + public func setCLIHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO) async -> Void) { + cliResponseHandler = handler + } + + /// Clear all handlers (called when view disappears) + public func clearHandlers() { + statusResponseHandler = nil + neighboursResponseHandler = nil + telemetryResponseHandler = nil + cliResponseHandler = nil + } + + /// Clears only the status-surface handlers so the merged admin surface can tear down its + /// status segment without dropping the settings VM's CLI handler on the shared per-connection service. + public func clearStatusHandlers() { + statusResponseHandler = nil + neighboursResponseHandler = nil + telemetryResponseHandler = nil + } } diff --git a/MC1Services/Sources/MC1Services/Services/ResolvedFloodScope.swift b/MC1Services/Sources/MC1Services/Services/ResolvedFloodScope.swift index 9b36c1b6..02cd548c 100644 --- a/MC1Services/Sources/MC1Services/Services/ResolvedFloodScope.swift +++ b/MC1Services/Sources/MC1Services/Services/ResolvedFloodScope.swift @@ -8,9 +8,9 @@ import MeshCore /// resets the session scope and lets the device fall back to its persisted default, so it /// cannot stand in for an explicit "all regions" override on firmware that supports one. public enum ResolvedFloodScope: Sendable, Equatable { - /// Force un-scoped flood broadcasts, overriding the device default. Push via - /// ``MeshCoreSession/setFloodScopeUnscoped()``. Requires firmware v12+. - case unscoped - /// Push a concrete ``FloodScope`` via ``MeshCoreSession/setFloodScope(_:)``. - case scope(FloodScope) + /// Force un-scoped flood broadcasts, overriding the device default. Push via + /// ``MeshCoreSession/setFloodScopeUnscoped()``. Requires firmware v12+. + case unscoped + /// Push a concrete ``FloodScope`` via ``MeshCoreSession/setFloodScope(_:)``. + case scope(FloodScope) } diff --git a/MC1Services/Sources/MC1Services/Services/RestoreOutcome.swift b/MC1Services/Sources/MC1Services/Services/RestoreOutcome.swift index e85dc9bc..00104313 100644 --- a/MC1Services/Sources/MC1Services/Services/RestoreOutcome.swift +++ b/MC1Services/Sources/MC1Services/Services/RestoreOutcome.swift @@ -4,6 +4,6 @@ import Foundation /// iCloud password prompt or the AppStore-sync confirmation sheet is an outcome, not an error /// (the caller must avoid celebrating a cancellation as a successful restore). public enum RestoreOutcome: Sendable, Equatable { - case completed - case cancelled + case completed + case cancelled } diff --git a/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift b/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift index 961bd6a4..5e24433b 100644 --- a/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift +++ b/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift @@ -6,158 +6,157 @@ import os /// Handles viewing status/telemetry and sending CLI commands to room servers. /// Room authentication is handled by `RoomServerService.joinRoom()` via `NodeAuthenticationSheet`. public actor RoomAdminService { - - // MARK: - Properties - - private let remoteNodeService: RemoteNodeService - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "RoomAdmin") - private let auditLogger = CommandAuditLogger() - - private var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? - private var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? - private var cliResponseHandler: (@Sendable (ContactMessage, ContactDTO) async -> Void)? - - // MARK: - Initialization - - public init( - remoteNodeService: RemoteNodeService, - dataStore: any PersistenceStoreProtocol - ) { - self.remoteNodeService = remoteNodeService - self.dataStore = dataStore + // MARK: - Properties + + private let remoteNodeService: RemoteNodeService + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "RoomAdmin") + private let auditLogger = CommandAuditLogger() + + private var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? + private var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? + private var cliResponseHandler: (@Sendable (ContactMessage, ContactDTO) async -> Void)? + + // MARK: - Initialization + + public init( + remoteNodeService: RemoteNodeService, + dataStore: any PersistenceStoreProtocol + ) { + self.remoteNodeService = remoteNodeService + self.dataStore = dataStore + } + + // MARK: - Status + + /// Request status from a room server. + public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { + try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) + } + + // MARK: - Telemetry + + /// Request telemetry from a room server. + public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { + try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) + } + + // MARK: - CLI Commands + + /// Send a CLI command to a room server and wait for response (admin only). + /// Uses content-based matching for structured CLI responses. + public func sendCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + /// Send a raw CLI command using FIFO response matching (admin only). + public func sendRawCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendRawCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + // MARK: - Session Queries + + /// Fetch all room admin sessions for a device. + public func fetchRoomAdminSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) + return sessions.filter(\.isRoom) + } + + /// Check if a contact is a known room with an active session. + public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), + remoteSession.isRoom, remoteSession.isConnected else { + return nil } - - // MARK: - Status - - /// Request status from a room server. - public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { - try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) + return remoteSession + } + + // MARK: - Handler Invocation + + /// Invoke the status response handler safely from actor context + public func invokeStatusHandler(_ status: StatusResponse) async { + await auditLogger.logStatusResponse( + target: .room, + publicKey: status.publicKeyPrefix, + batteryMv: status.batteryMillivolts, + uptimeSec: status.uptimeSeconds + ) + + guard let handler = statusResponseHandler else { + let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + logger.debug("No status handler registered for room response from \(prefixHex), ignoring") + return } - - // MARK: - Telemetry - - /// Request telemetry from a room server. - public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { - try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) + await handler(status) + } + + /// Invoke the telemetry response handler safely from actor context + public func invokeTelemetryHandler(_ response: TelemetryResponse) async { + await auditLogger.logTelemetryResponse( + target: .room, + publicKey: response.publicKeyPrefix, + pointCount: response.dataPoints.count + ) + + guard let handler = telemetryResponseHandler else { + logger.debug("No telemetry handler registered for room, ignoring response") + return } + await handler(response) + } - // MARK: - CLI Commands - - /// Send a CLI command to a room server and wait for response (admin only). - /// Uses content-based matching for structured CLI responses. - public func sendCommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - try await remoteNodeService.sendCLICommand( - sessionID: sessionID, - command: command, - timeout: timeout - ) - } - - /// Send a raw CLI command using FIFO response matching (admin only). - public func sendRawCommand( - sessionID: UUID, - command: String, - timeout: Duration = .seconds(10) - ) async throws -> String { - try await remoteNodeService.sendRawCLICommand( - sessionID: sessionID, - command: command, - timeout: timeout - ) - } - - // MARK: - Session Queries - - /// Fetch all room admin sessions for a device. - public func fetchRoomAdminSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { - let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) - return sessions.filter { $0.isRoom } - } - - /// Check if a contact is a known room with an active session. - public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { - guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), - remoteSession.isRoom && remoteSession.isConnected else { - return nil - } - return remoteSession - } - - // MARK: - Handler Invocation - - /// Invoke the status response handler safely from actor context - public func invokeStatusHandler(_ status: StatusResponse) async { - await auditLogger.logStatusResponse( - target: .room, - publicKey: status.publicKeyPrefix, - batteryMv: status.batteryMillivolts, - uptimeSec: status.uptimeSeconds - ) - - guard let handler = statusResponseHandler else { - let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() - logger.debug("No status handler registered for room response from \(prefixHex), ignoring") - return - } - await handler(status) - } - - /// Invoke the telemetry response handler safely from actor context - public func invokeTelemetryHandler(_ response: TelemetryResponse) async { - await auditLogger.logTelemetryResponse( - target: .room, - publicKey: response.publicKeyPrefix, - pointCount: response.dataPoints.count - ) - - guard let handler = telemetryResponseHandler else { - logger.debug("No telemetry handler registered for room, ignoring response") - return - } - await handler(response) - } - - /// Invoke the CLI response handler safely from actor context - public func invokeCLIHandler(_ message: ContactMessage, fromContact contact: ContactDTO) async { - await auditLogger.logCLIResponse(publicKey: contact.publicKey, response: message.text) - - guard let handler = cliResponseHandler else { - logger.debug("No CLI handler registered for room, ignoring response from \(contact.displayName)") - return - } - await handler(message, contact) - } - - // MARK: - Handler Setters - - public func setStatusHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { - self.statusResponseHandler = handler - } - - public func setTelemetryHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { - self.telemetryResponseHandler = handler - } - - public func setCLIHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO) async -> Void) { - self.cliResponseHandler = handler - } - - /// Clear all handlers (called when view disappears) - public func clearHandlers() { - self.statusResponseHandler = nil - self.telemetryResponseHandler = nil - self.cliResponseHandler = nil - } + /// Invoke the CLI response handler safely from actor context + public func invokeCLIHandler(_ message: ContactMessage, fromContact contact: ContactDTO) async { + await auditLogger.logCLIResponse(publicKey: contact.publicKey, response: message.text) - /// Clears only the status-surface handlers so the merged admin surface can tear down its - /// status segment without dropping the settings VM's CLI handler on the shared per-connection service. - public func clearStatusHandlers() { - self.statusResponseHandler = nil - self.telemetryResponseHandler = nil + guard let handler = cliResponseHandler else { + logger.debug("No CLI handler registered for room, ignoring response from \(contact.displayName)") + return } + await handler(message, contact) + } + + // MARK: - Handler Setters + + public func setStatusHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { + statusResponseHandler = handler + } + + public func setTelemetryHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { + telemetryResponseHandler = handler + } + + public func setCLIHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO) async -> Void) { + cliResponseHandler = handler + } + + /// Clear all handlers (called when view disappears) + public func clearHandlers() { + statusResponseHandler = nil + telemetryResponseHandler = nil + cliResponseHandler = nil + } + + /// Clears only the status-surface handlers so the merged admin surface can tear down its + /// status segment without dropping the settings VM's CLI handler on the shared per-connection service. + public func clearStatusHandlers() { + statusResponseHandler = nil + telemetryResponseHandler = nil + } } diff --git a/MC1Services/Sources/MC1Services/Services/RoomServerEvent.swift b/MC1Services/Sources/MC1Services/Services/RoomServerEvent.swift index 7dd1c07e..eb5ce56a 100644 --- a/MC1Services/Sources/MC1Services/Services/RoomServerEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/RoomServerEvent.swift @@ -3,8 +3,8 @@ import Foundation /// Room-server notifications broadcast by `RoomServerService.events()`. /// The stream is multicast: every subscriber receives every event. public enum RoomServerEvent: Sendable { - /// An outbound room message's delivery status changed. - case statusUpdated(messageID: UUID, status: MessageStatus) - /// An incoming message recovered a disconnected room session. - case connectionRecovered(sessionID: UUID) + /// An outbound room message's delivery status changed. + case statusUpdated(messageID: UUID, status: MessageStatus) + /// An incoming message recovered a disconnected room session. + case connectionRecovered(sessionID: UUID) } diff --git a/MC1Services/Sources/MC1Services/Services/RoomServerService.swift b/MC1Services/Sources/MC1Services/Services/RoomServerService.swift index edf144df..c2f73fba 100644 --- a/MC1Services/Sources/MC1Services/Services/RoomServerService.swift +++ b/MC1Services/Sources/MC1Services/Services/RoomServerService.swift @@ -5,25 +5,25 @@ import os // MARK: - Room Server Errors public enum RoomServerError: Error, Sendable { - case notConnected - case sessionNotFound - case sendFailed(String) - case permissionDenied - case invalidResponse - case sessionError(MeshCoreError) + case notConnected + case sessionNotFound + case sendFailed(String) + case permissionDenied + case invalidResponse + case sessionError(MeshCoreError) } extension RoomServerError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .sessionNotFound: "Room session not found." - case .sendFailed(let msg): "Send failed: \(msg)" - case .permissionDenied: "Permission denied." - case .invalidResponse: "Invalid response from device." - case .sessionError(let e): e.localizedDescription - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case .sessionNotFound: "Room session not found." + case let .sendFailed(msg): "Send failed: \(msg)" + case .permissionDenied: "Permission denied." + case .invalidResponse: "Invalid response from device." + case let .sessionError(e): e.localizedDescription } + } } // MARK: - Room Server Service @@ -31,576 +31,575 @@ extension RoomServerError: LocalizedError { /// Service for room server interactions. /// Handles joining rooms, posting messages, and receiving room messages. public actor RoomServerService { - - // MARK: - Properties - - private let session: any RemoteAccessSessionOps - private let remoteNodeService: RemoteNodeService - private let dataStore: any PersistenceStoreProtocol - private let logger = PersistentLogger(subsystem: "com.mc1", category: "RoomServer") - private let auditLogger = CommandAuditLogger() - - /// Self public key prefix for author comparison. - /// Set from SelfInfo when device connects. - private var selfPublicKeyPrefix: Data? - - /// Configuration for retry behavior (shared with MessageService) - private let config: MessageServiceConfig - - /// Multicast broadcaster for status-update and connection-recovery events. - private nonisolated let eventBroadcaster = EventBroadcaster() - - /// Tracks message IDs currently being retried to prevent concurrent retry attempts - private var inFlightRetries: Set = [] - - // MARK: - Initialization - - init( - session: any RemoteAccessSessionOps, - remoteNodeService: RemoteNodeService, - dataStore: any PersistenceStoreProtocol, - config: MessageServiceConfig = MessageServiceConfig() - ) { - self.session = session - self.remoteNodeService = remoteNodeService - self.dataStore = dataStore - self.config = config - } - - /// Set self public key prefix from SelfInfo. - /// Call this when device info is received. - public func setSelfPublicKeyPrefix(_ prefix: Data) { - self.selfPublicKeyPrefix = prefix.prefix(4) + // MARK: - Properties + + private let session: any RemoteAccessSessionOps + private let remoteNodeService: RemoteNodeService + private let dataStore: any PersistenceStoreProtocol + private let logger = PersistentLogger(subsystem: "com.mc1", category: "RoomServer") + private let auditLogger = CommandAuditLogger() + + /// Self public key prefix for author comparison. + /// Set from SelfInfo when device connects. + private var selfPublicKeyPrefix: Data? + + /// Configuration for retry behavior (shared with MessageService) + private let config: MessageServiceConfig + + /// Multicast broadcaster for status-update and connection-recovery events. + private nonisolated let eventBroadcaster = EventBroadcaster() + + /// Tracks message IDs currently being retried to prevent concurrent retry attempts + private var inFlightRetries: Set = [] + + // MARK: - Initialization + + init( + session: any RemoteAccessSessionOps, + remoteNodeService: RemoteNodeService, + dataStore: any PersistenceStoreProtocol, + config: MessageServiceConfig = MessageServiceConfig() + ) { + self.session = session + self.remoteNodeService = remoteNodeService + self.dataStore = dataStore + self.config = config + } + + /// Set self public key prefix from SelfInfo. + /// Call this when device info is received. + public func setSelfPublicKeyPrefix(_ prefix: Data) { + selfPublicKeyPrefix = prefix.prefix(4) + } + + // MARK: - Events + + /// Returns a fresh stream of room-server events. Registration is + /// synchronous, so events yielded after this call are never dropped. + /// Consumers must re-subscribe per connection because the owning + /// `ServiceContainer` is rebuilt on every connection. + public nonisolated func events() -> AsyncStream { + eventBroadcaster.subscribe() + } + + /// Ends every `events()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishEvents() { + eventBroadcaster.finish() + } + + // MARK: - Room Management + + /// Join a room server by creating a session and authenticating. + /// Automatically syncs message history based on local state. + /// - Parameters: + /// - radioID: The companion radio device ID + /// - contact: The room server contact + /// - password: Authentication password (uses keychain if not provided) + /// - rememberPassword: Whether to store password in keychain + /// - pathLength: Path length for timeout calculation (0 = direct) + /// - onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. + /// - Returns: The authenticated session + public func joinRoom( + radioID: UUID, + contact: ContactDTO, + password: String?, + rememberPassword: Bool = true, + pathLength: UInt8 = 0, + onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil + ) async throws -> RemoteNodeSessionDTO { + let remoteSession = try await remoteNodeService.createSession( + radioID: radioID, + contact: contact + ) + + // Login to the room + _ = try await remoteNodeService.login( + sessionID: remoteSession.id, + password: password, + pathLength: pathLength, + onTimeoutKnown: onTimeoutKnown + ) + + // Store password only after successful login + if let password, rememberPassword { + try await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) } - // MARK: - Events + // Attempt additional history sync if needed (non-blocking) + await syncHistoryIfPossible(sessionID: remoteSession.id) - /// Returns a fresh stream of room-server events. Registration is - /// synchronous, so events yielded after this call are never dropped. - /// Consumers must re-subscribe per connection because the owning - /// `ServiceContainer` is rebuilt on every connection. - public nonisolated func events() -> AsyncStream { - eventBroadcaster.subscribe() + guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) else { + throw RemoteNodeError.sessionNotFound } - - /// Ends every `events()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishEvents() { - eventBroadcaster.finish() + return updatedSession + } + + /// Reconnect to an existing room session and sync any missed messages. + /// Use this when re-authenticating to a room after app restart or BLE reconnection. + /// - Parameters: + /// - sessionID: The existing room session ID + /// - pathLength: Optional path length hint (0 = use shortest known path) + /// - Returns: Updated session DTO + /// - Throws: RemoteNodeError if reconnection fails + public func reconnectRoom( + sessionID: UUID, + pathLength: UInt8 = 0 + ) async throws -> RemoteNodeSessionDTO { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound } - // MARK: - Room Management - - /// Join a room server by creating a session and authenticating. - /// Automatically syncs message history based on local state. - /// - Parameters: - /// - radioID: The companion radio device ID - /// - contact: The room server contact - /// - password: Authentication password (uses keychain if not provided) - /// - rememberPassword: Whether to store password in keychain - /// - pathLength: Path length for timeout calculation (0 = direct) - /// - onTimeoutKnown: Optional callback invoked with timeout in seconds once firmware responds. - /// - Returns: The authenticated session - public func joinRoom( - radioID: UUID, - contact: ContactDTO, - password: String?, - rememberPassword: Bool = true, - pathLength: UInt8 = 0, - onTimeoutKnown: (@Sendable (Int) async -> Void)? = nil - ) async throws -> RemoteNodeSessionDTO { - let remoteSession = try await remoteNodeService.createSession( - radioID: radioID, - contact: contact - ) - - // Login to the room - _ = try await remoteNodeService.login( - sessionID: remoteSession.id, - password: password, - pathLength: pathLength, - onTimeoutKnown: onTimeoutKnown - ) - - // Store password only after successful login - if let password, rememberPassword { - try await remoteNodeService.storePassword(password, forNodeKey: contact.publicKey) - } - - // Attempt additional history sync if needed (non-blocking) - await syncHistoryIfPossible(sessionID: remoteSession.id) - - guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: remoteSession.id) else { - throw RemoteNodeError.sessionNotFound - } - return updatedSession + guard remoteSession.isRoom else { + throw RemoteNodeError.invalidResponse } - /// Reconnect to an existing room session and sync any missed messages. - /// Use this when re-authenticating to a room after app restart or BLE reconnection. - /// - Parameters: - /// - sessionID: The existing room session ID - /// - pathLength: Optional path length hint (0 = use shortest known path) - /// - Returns: Updated session DTO - /// - Throws: RemoteNodeError if reconnection fails - public func reconnectRoom( - sessionID: UUID, - pathLength: UInt8 = 0 - ) async throws -> RemoteNodeSessionDTO { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } + // Re-authenticate to the room + _ = try await remoteNodeService.login( + sessionID: sessionID, + pathLength: pathLength + ) - guard remoteSession.isRoom else { - throw RemoteNodeError.invalidResponse - } + // Attempt additional history sync if needed (non-blocking) + await syncHistoryIfPossible(sessionID: sessionID) - // Re-authenticate to the room - _ = try await remoteNodeService.login( - sessionID: sessionID, - pathLength: pathLength - ) - - // Attempt additional history sync if needed (non-blocking) - await syncHistoryIfPossible(sessionID: sessionID) - - guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RemoteNodeError.sessionNotFound - } - return updatedSession + guard let updatedSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } + return updatedSession + } + + /// Leave a room by sending logout and removing the session. + /// - Parameters: + /// - sessionID: The session to leave + /// - publicKey: The room's public key (for keychain cleanup) + public func leaveRoom(sessionID: UUID, publicKey: Data) async throws { + try await remoteNodeService.logout(sessionID: sessionID) + try await remoteNodeService.removeSession(id: sessionID, publicKey: publicKey) + } + + // MARK: - Message Posting + + /// Post a message to a room server. + /// + /// Posts use `TextType.plain`. The room server converts to `signedPlain` + /// when pushing to other clients. The server does not push messages back + /// to their authors, so the local message record is created immediately. + /// - Parameters: + /// - sessionID: The room session + /// - text: The message text + /// - Returns: The saved message DTO + public func postMessage(sessionID: UUID, text: String) async throws -> RoomMessageDTO { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RoomServerError.sessionNotFound } - /// Leave a room by sending logout and removing the session. - /// - Parameters: - /// - sessionID: The session to leave - /// - publicKey: The room's public key (for keychain cleanup) - public func leaveRoom(sessionID: UUID, publicKey: Data) async throws { - try await remoteNodeService.logout(sessionID: sessionID) - try await remoteNodeService.removeSession(id: sessionID, publicKey: publicKey) + guard remoteSession.canPost else { + throw RoomServerError.permissionDenied } - // MARK: - Message Posting - - /// Post a message to a room server. - /// - /// Posts use `TextType.plain`. The room server converts to `signedPlain` - /// when pushing to other clients. The server does not push messages back - /// to their authors, so the local message record is created immediately. - /// - Parameters: - /// - sessionID: The room session - /// - text: The message text - /// - Returns: The saved message DTO - public func postMessage(sessionID: UUID, text: String) async throws -> RoomMessageDTO { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { - throw RoomServerError.sessionNotFound - } + let timestamp = Date() + let messageID = UUID() + + // Create local message record immediately with pending status + let messageDTO = RoomMessageDTO( + id: messageID, + sessionID: sessionID, + authorKeyPrefix: selfPublicKeyPrefix ?? Data(repeating: 0, count: 4), + authorName: "Me", + text: text, + timestamp: UInt32(timestamp.timeIntervalSince1970), + isFromSelf: true, + status: .pending, + maxRetryAttempts: config.maxAttempts + ) + + try await dataStore.saveRoomMessage(messageDTO) + + // Log room message posted (metadata only, no content) + await auditLogger.logRoomMessagePosted(publicKey: remoteSession.publicKey, messageLength: text.count) + + // Send message in background so UI can show pending message immediately + Task { [weak self] in + guard let self else { return } + + // Send message with retry logic + // NOTE: sendMessageWithRetry requires full 32-byte public key for path reset + do { + let sentInfo = try await session.sendMessageWithRetry( + to: remoteSession.publicKey, // Full 32-byte key required + text: text, + timestamp: timestamp, + maxAttempts: config.maxAttempts, + floodAfter: config.floodAfter, + maxFloodAttempts: config.maxFloodAttempts + ) - guard remoteSession.canPost else { - throw RoomServerError.permissionDenied + if let sentInfo { + // Success - update to delivered + let ackCodeUInt32 = sentInfo.expectedAck.ackCodeUInt32 + // Handle database errors gracefully - don't lose send success state + do { + try await dataStore.updateRoomMessageStatus( + id: messageID, + status: .delivered, + ackCode: ackCodeUInt32, + roundTripTime: UInt32(sentInfo.suggestedTimeoutMs) + ) + } catch { + logger.error("Failed to update message status after successful send: \(error)") + } + eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .delivered)) + } else { + // All retries exhausted — radio transmitted but no ACK received. + // Mark as sent (not failed) since the message likely reached the room server. + do { + try await dataStore.updateRoomMessageStatus( + id: messageID, + status: .sent, + ackCode: nil, + roundTripTime: nil + ) + } catch { + logger.error("Failed to update message status to sent: \(error)") + } + eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .sent)) } - - let timestamp = Date() - let messageID = UUID() - - // Create local message record immediately with pending status - let messageDTO = RoomMessageDTO( + // Update sort date only (no sync bookmark — avoids clock skew issues) + try? await dataStore.updateRoomActivity(sessionID) + } catch { + // Send failed with error — do not update activity + do { + try await dataStore.updateRoomMessageStatus( id: messageID, - sessionID: sessionID, - authorKeyPrefix: selfPublicKeyPrefix ?? Data(repeating: 0, count: 4), - authorName: "Me", - text: text, - timestamp: UInt32(timestamp.timeIntervalSince1970), - isFromSelf: true, - status: .pending, - maxRetryAttempts: config.maxAttempts - ) - - try await dataStore.saveRoomMessage(messageDTO) - - // Log room message posted (metadata only, no content) - await auditLogger.logRoomMessagePosted(publicKey: remoteSession.publicKey, messageLength: text.count) - - // Send message in background so UI can show pending message immediately - Task { [weak self] in - guard let self else { return } - - // Send message with retry logic - // NOTE: sendMessageWithRetry requires full 32-byte public key for path reset - do { - let sentInfo = try await session.sendMessageWithRetry( - to: remoteSession.publicKey, // Full 32-byte key required - text: text, - timestamp: timestamp, - maxAttempts: config.maxAttempts, - floodAfter: config.floodAfter, - maxFloodAttempts: config.maxFloodAttempts - ) - - if let sentInfo { - // Success - update to delivered - let ackCodeUInt32 = sentInfo.expectedAck.ackCodeUInt32 - // Handle database errors gracefully - don't lose send success state - do { - try await dataStore.updateRoomMessageStatus( - id: messageID, - status: .delivered, - ackCode: ackCodeUInt32, - roundTripTime: UInt32(sentInfo.suggestedTimeoutMs) - ) - } catch { - logger.error("Failed to update message status after successful send: \(error)") - } - eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .delivered)) - } else { - // All retries exhausted — radio transmitted but no ACK received. - // Mark as sent (not failed) since the message likely reached the room server. - do { - try await dataStore.updateRoomMessageStatus( - id: messageID, - status: .sent, - ackCode: nil, - roundTripTime: nil - ) - } catch { - logger.error("Failed to update message status to sent: \(error)") - } - eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .sent)) - } - // Update sort date only (no sync bookmark — avoids clock skew issues) - try? await dataStore.updateRoomActivity(sessionID) - } catch { - // Send failed with error — do not update activity - do { - try await dataStore.updateRoomMessageStatus( - id: messageID, - status: .failed, - ackCode: nil, - roundTripTime: nil - ) - } catch let dbError { - logger.error("Failed to update message status after send error: \(dbError)") - } - eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .failed)) - logger.warning("Room message send failed: \(error)") - } + status: .failed, + ackCode: nil, + roundTripTime: nil + ) + } catch let dbError { + logger.error("Failed to update message status after send error: \(dbError)") } - - // Return pending message immediately so UI can display it - return messageDTO + eventBroadcaster.yield(.statusUpdated(messageID: messageID, status: .failed)) + logger.warning("Room message send failed: \(error)") + } } - /// Retry sending a failed room message. - /// - Parameter id: The message ID to retry - /// - Returns: Updated message DTO - public func retryMessage(id: UUID) async throws -> RoomMessageDTO { - // Guard against concurrent retries - guard !inFlightRetries.contains(id) else { - logger.warning("Retry already in progress for message: \(id)") - throw RoomServerError.sendFailed("Retry already in progress") - } - - inFlightRetries.insert(id) - defer { inFlightRetries.remove(id) } + // Return pending message immediately so UI can display it + return messageDTO + } + + /// Retry sending a failed room message. + /// - Parameter id: The message ID to retry + /// - Returns: Updated message DTO + public func retryMessage(id: UUID) async throws -> RoomMessageDTO { + // Guard against concurrent retries + guard !inFlightRetries.contains(id) else { + logger.warning("Retry already in progress for message: \(id)") + throw RoomServerError.sendFailed("Retry already in progress") + } - guard let message = try await dataStore.fetchRoomMessage(id: id) else { - throw RoomServerError.sendFailed("Message not found") - } + inFlightRetries.insert(id) + defer { inFlightRetries.remove(id) } - guard message.status == .failed else { - throw RoomServerError.sendFailed("Message is not in failed state") - } + guard let message = try await dataStore.fetchRoomMessage(id: id) else { + throw RoomServerError.sendFailed("Message not found") + } - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: message.sessionID) else { - throw RoomServerError.sessionNotFound - } + guard message.status == .failed else { + throw RoomServerError.sendFailed("Message is not in failed state") + } - // Update to pending/retrying - let newRetryAttempt = message.retryAttempt + 1 - try await dataStore.updateRoomMessageRetryStatus( - id: id, - status: .pending, - retryAttempt: newRetryAttempt, - maxRetryAttempts: config.maxAttempts - ) - eventBroadcaster.yield(.statusUpdated(messageID: id, status: .pending)) + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: message.sessionID) else { + throw RoomServerError.sessionNotFound + } - // Retry send with retry logic - // NOTE: sendMessageWithRetry requires full 32-byte public key for path reset + // Update to pending/retrying + let newRetryAttempt = message.retryAttempt + 1 + try await dataStore.updateRoomMessageRetryStatus( + id: id, + status: .pending, + retryAttempt: newRetryAttempt, + maxRetryAttempts: config.maxAttempts + ) + eventBroadcaster.yield(.statusUpdated(messageID: id, status: .pending)) + + // Retry send with retry logic + // NOTE: sendMessageWithRetry requires full 32-byte public key for path reset + do { + let sentInfo = try await session.sendMessageWithRetry( + to: remoteSession.publicKey, // Full 32-byte key required + text: message.text, + timestamp: Date(), + maxAttempts: config.maxAttempts, + floodAfter: config.floodAfter, + maxFloodAttempts: config.maxFloodAttempts + ) + + if let sentInfo { + let ackCodeUInt32 = sentInfo.expectedAck.ackCodeUInt32 do { - let sentInfo = try await session.sendMessageWithRetry( - to: remoteSession.publicKey, // Full 32-byte key required - text: message.text, - timestamp: Date(), - maxAttempts: config.maxAttempts, - floodAfter: config.floodAfter, - maxFloodAttempts: config.maxFloodAttempts - ) - - if let sentInfo { - let ackCodeUInt32 = sentInfo.expectedAck.ackCodeUInt32 - do { - try await dataStore.updateRoomMessageStatus( - id: id, - status: .delivered, - ackCode: ackCodeUInt32, - roundTripTime: UInt32(sentInfo.suggestedTimeoutMs) - ) - } catch { - logger.error("Failed to update message status after successful retry: \(error)") - } - eventBroadcaster.yield(.statusUpdated(messageID: id, status: .delivered)) - } else { - // All retries exhausted — radio transmitted but no ACK received. - // Mark as sent (not failed) since the message likely reached the room server. - do { - try await dataStore.updateRoomMessageStatus( - id: id, - status: .sent, - ackCode: nil, - roundTripTime: nil - ) - } catch { - logger.error("Failed to update message status to sent: \(error)") - } - eventBroadcaster.yield(.statusUpdated(messageID: id, status: .sent)) - } - // Update sort date so room moves to top of conversation list - try? await dataStore.updateRoomActivity(message.sessionID) + try await dataStore.updateRoomMessageStatus( + id: id, + status: .delivered, + ackCode: ackCodeUInt32, + roundTripTime: UInt32(sentInfo.suggestedTimeoutMs) + ) } catch { - do { - try await dataStore.updateRoomMessageStatus( - id: id, - status: .failed, - ackCode: nil, - roundTripTime: nil - ) - } catch let dbError { - logger.error("Failed to update message status after retry error: \(dbError)") - } - eventBroadcaster.yield(.statusUpdated(messageID: id, status: .failed)) - logger.warning("Room message retry failed: \(error)") - } - - guard let updatedMessage = try await dataStore.fetchRoomMessage(id: id) else { - throw RoomServerError.sendFailed("Failed to fetch message after retry") - } - return updatedMessage - } - - // MARK: - Incoming Messages - - /// Handle incoming room message. - /// Called by MessagePollingService when a signedPlain message arrives from a room. - /// - /// Messages arrive as `TextType.signedPlain` with the room server's key as - /// `senderPublicKeyPrefix` and the original author's 4-byte key prefix in - /// the payload (extracted to `extraData` by `decodeMessageV3`). - /// - /// Since room servers don't push messages back to their authors, incoming - /// messages should not be from self. However, we check defensively. - /// - Parameters: - /// - senderPublicKeyPrefix: The room server's 6-byte key prefix - /// - timestamp: Message timestamp from server - /// - authorPrefix: The original author's 4-byte key prefix - /// - text: The message text - /// - Returns: The saved message DTO, or nil if the message was a duplicate - @discardableResult - public func handleIncomingMessage( - senderPublicKeyPrefix: Data, - timestamp: UInt32, - authorPrefix: Data, - text: String - ) async throws -> RoomMessageDTO? { - // Find session by room server's key prefix - guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(senderPublicKeyPrefix), - remoteSession.isRoom else { - return nil // Not from a known room - } - - // Receiving any message (even duplicate) proves session is active - if !remoteSession.isConnected { - let recovered = (try? await dataStore.markRoomSessionConnected(remoteSession.id)) ?? false - if recovered { - eventBroadcaster.yield(.connectionRecovered(sessionID: remoteSession.id)) - } + logger.error("Failed to update message status after successful retry: \(error)") } - - // Generate deduplication key - let dedupKey = RoomMessage.generateDeduplicationKey( - timestamp: timestamp, - authorKeyPrefix: authorPrefix, - text: text - ) - - // Check for duplicate using deduplication key - if try await dataStore.isDuplicateRoomMessage( - sessionID: remoteSession.id, - deduplicationKey: dedupKey - ) { - return nil - } - - // Log room message received (metadata only, no content) - await auditLogger.logRoomMessageReceived( - roomPublicKey: senderPublicKeyPrefix, - authorPrefix: authorPrefix, - messageLength: text.count - ) - - // Defensive check: room servers shouldn't push our own messages back - let isFromSelf = selfPublicKeyPrefix?.prefix(4) == authorPrefix.prefix(4) - if isFromSelf { - logger.info("Received self message from room server (unexpected)") + eventBroadcaster.yield(.statusUpdated(messageID: id, status: .delivered)) + } else { + // All retries exhausted — radio transmitted but no ACK received. + // Mark as sent (not failed) since the message likely reached the room server. + do { + try await dataStore.updateRoomMessageStatus( + id: id, + status: .sent, + ackCode: nil, + roundTripTime: nil + ) + } catch { + logger.error("Failed to update message status to sent: \(error)") } - - let authorName = try await resolveAuthorName(keyPrefix: authorPrefix) - - let messageDTO = RoomMessageDTO( - sessionID: remoteSession.id, - authorKeyPrefix: authorPrefix, - authorName: authorName, - text: text, - timestamp: timestamp, - isFromSelf: isFromSelf + eventBroadcaster.yield(.statusUpdated(messageID: id, status: .sent)) + } + // Update sort date so room moves to top of conversation list + try? await dataStore.updateRoomActivity(message.sessionID) + } catch { + do { + try await dataStore.updateRoomMessageStatus( + id: id, + status: .failed, + ackCode: nil, + roundTripTime: nil ) + } catch let dbError { + logger.error("Failed to update message status after retry error: \(dbError)") + } + eventBroadcaster.yield(.statusUpdated(messageID: id, status: .failed)) + logger.warning("Room message retry failed: \(error)") + } - try await dataStore.saveRoomMessage(messageDTO) - - // Update sync bookmark and sort date - try await dataStore.updateRoomActivity(remoteSession.id, syncTimestamp: timestamp) - - // Increment unread count if not from self - if !isFromSelf { - try await dataStore.incrementRoomUnreadCount(remoteSession.id) - } - - return messageDTO + guard let updatedMessage = try await dataStore.fetchRoomMessage(id: id) else { + throw RoomServerError.sendFailed("Failed to fetch message after retry") + } + return updatedMessage + } + + // MARK: - Incoming Messages + + /// Handle incoming room message. + /// Called by MessagePollingService when a signedPlain message arrives from a room. + /// + /// Messages arrive as `TextType.signedPlain` with the room server's key as + /// `senderPublicKeyPrefix` and the original author's 4-byte key prefix in + /// the payload (extracted to `extraData` by `decodeMessageV3`). + /// + /// Since room servers don't push messages back to their authors, incoming + /// messages should not be from self. However, we check defensively. + /// - Parameters: + /// - senderPublicKeyPrefix: The room server's 6-byte key prefix + /// - timestamp: Message timestamp from server + /// - authorPrefix: The original author's 4-byte key prefix + /// - text: The message text + /// - Returns: The saved message DTO, or nil if the message was a duplicate + @discardableResult + public func handleIncomingMessage( + senderPublicKeyPrefix: Data, + timestamp: UInt32, + authorPrefix: Data, + text: String + ) async throws -> RoomMessageDTO? { + // Find session by room server's key prefix + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(senderPublicKeyPrefix), + remoteSession.isRoom else { + return nil // Not from a known room } - // MARK: - Message Retrieval + // Receiving any message (even duplicate) proves session is active + if !remoteSession.isConnected { + let recovered = await (try? dataStore.markRoomSessionConnected(remoteSession.id)) ?? false + if recovered { + eventBroadcaster.yield(.connectionRecovered(sessionID: remoteSession.id)) + } + } - /// Fetch messages for a room session. - /// - Parameters: - /// - sessionID: The room session ID - /// - limit: Maximum number of messages to return - /// - offset: Offset for pagination - /// - Returns: Array of room message DTOs - public func fetchMessages(sessionID: UUID, limit: Int? = nil, offset: Int? = nil) async throws -> [RoomMessageDTO] { - try await dataStore.fetchRoomMessages(sessionID: sessionID, limit: limit, offset: offset) + // Generate deduplication key + let dedupKey = RoomMessage.generateDeduplicationKey( + timestamp: timestamp, + authorKeyPrefix: authorPrefix, + text: text + ) + + // Check for duplicate using deduplication key + if try await dataStore.isDuplicateRoomMessage( + sessionID: remoteSession.id, + deduplicationKey: dedupKey + ) { + return nil } - /// Mark room as read (reset unread count). - /// Call when user views the conversation. - /// - Parameter sessionID: The room session ID - public func markAsRead(sessionID: UUID) async throws { - try await dataStore.resetRoomUnreadCount(sessionID) + // Log room message received (metadata only, no content) + await auditLogger.logRoomMessageReceived( + roomPublicKey: senderPublicKeyPrefix, + authorPrefix: authorPrefix, + messageLength: text.count + ) + + // Defensive check: room servers shouldn't push our own messages back + let isFromSelf = selfPublicKeyPrefix?.prefix(4) == authorPrefix.prefix(4) + if isFromSelf { + logger.info("Received self message from room server (unexpected)") } - // MARK: - Session Queries + let authorName = try await resolveAuthorName(keyPrefix: authorPrefix) - /// Fetch all room sessions for a device. - /// - Parameter radioID: The companion radio device ID - /// - Returns: Array of room session DTOs - public func fetchRoomSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { - let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) - return sessions.filter { $0.isRoom } - } + let messageDTO = RoomMessageDTO( + sessionID: remoteSession.id, + authorKeyPrefix: authorPrefix, + authorName: authorName, + text: text, + timestamp: timestamp, + isFromSelf: isFromSelf + ) - /// Check if a contact is a known room server with an active session. - /// - Parameter publicKeyPrefix: The 6-byte public key prefix - /// - Returns: The session if found and connected, nil otherwise - public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { - guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), - remoteSession.isRoom && remoteSession.isConnected else { - return nil - } - return remoteSession - } + try await dataStore.saveRoomMessage(messageDTO) - // MARK: - Private Helpers + // Update sync bookmark and sort date + try await dataStore.updateRoomActivity(remoteSession.id, syncTimestamp: timestamp) - private func resolveAuthorName(keyPrefix: Data) async throws -> String? { - // Try to find contact with matching public key prefix - // Returns nil if no matching contact found - try await dataStore.findContactNameByKeyPrefix(keyPrefix) + // Increment unread count if not from self + if !isFromSelf { + try await dataStore.incrementRoomUnreadCount(remoteSession.id) } - /// Attempt to sync history, using advert path first, then falling back to path discovery. - private func syncHistoryIfPossible(sessionID: UUID) async { + return messageDTO + } + + // MARK: - Message Retrieval + + /// Fetch messages for a room session. + /// - Parameters: + /// - sessionID: The room session ID + /// - limit: Maximum number of messages to return + /// - offset: Offset for pagination + /// - Returns: Array of room message DTOs + public func fetchMessages(sessionID: UUID, limit: Int? = nil, offset: Int? = nil) async throws -> [RoomMessageDTO] { + try await dataStore.fetchRoomMessages(sessionID: sessionID, limit: limit, offset: offset) + } + + /// Mark room as read (reset unread count). + /// Call when user views the conversation. + /// - Parameter sessionID: The room session ID + public func markAsRead(sessionID: UUID) async throws { + try await dataStore.resetRoomUnreadCount(sessionID) + } + + // MARK: - Session Queries + + /// Fetch all room sessions for a device. + /// - Parameter radioID: The companion radio device ID + /// - Returns: Array of room session DTOs + public func fetchRoomSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + let sessions = try await dataStore.fetchRemoteNodeSessions(radioID: radioID) + return sessions.filter(\.isRoom) + } + + /// Check if a contact is a known room server with an active session. + /// - Parameter publicKeyPrefix: The 6-byte public key prefix + /// - Returns: The session if found and connected, nil otherwise + public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), + remoteSession.isRoom, remoteSession.isConnected else { + return nil + } + return remoteSession + } + + // MARK: - Private Helpers + + private func resolveAuthorName(keyPrefix: Data) async throws -> String? { + // Try to find contact with matching public key prefix + // Returns nil if no matching contact found + try await dataStore.findContactNameByKeyPrefix(keyPrefix) + } + + /// Attempt to sync history, using advert path first, then falling back to path discovery. + private func syncHistoryIfPossible(sessionID: UUID) async { + do { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), + let contact = try await dataStore.findContactByPublicKey(remoteSession.publicKey) else { + return + } + + // Strategy: + // 1. If contact has a path from advertisement (not flood-routed), try it first + // 2. If that fails or contact is flood-routed, trigger path discovery + // 3. Wait for discovery result and retry + + if !contact.isFloodRouted { + // Contact has a path from advertisement - try it directly + logger.info("Trying advert path for room \(remoteSession.name)") do { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), - let contact = try await dataStore.findContactByPublicKey(remoteSession.publicKey) else { - return - } - - // Strategy: - // 1. If contact has a path from advertisement (not flood-routed), try it first - // 2. If that fails or contact is flood-routed, trigger path discovery - // 3. Wait for discovery result and retry - - if !contact.isFloodRouted { - // Contact has a path from advertisement - try it directly - logger.info("Trying advert path for room \(remoteSession.name)") - do { - try await remoteNodeService.requestHistorySync(sessionID: sessionID) - logger.info("History sync succeeded using advert path") - return - } catch { - // Advert path didn't work - fall through to path discovery - logger.info("Advert path failed for \(remoteSession.name): \(error), trying path discovery") - } - } else { - logger.info("Room \(remoteSession.name) is flood-routed, attempting path discovery") - } - - // Path discovery fallback - let hasDirectRoute = try await discoverPathAndWait(sessionID: sessionID) - if !hasDirectRoute { - logger.info("Could not establish direct route for \(remoteSession.name), skipping history sync") - return - } - - // Retry with newly discovered path - try await remoteNodeService.requestHistorySync(sessionID: sessionID) - logger.info("History sync succeeded after path discovery") + try await remoteNodeService.requestHistorySync(sessionID: sessionID) + logger.info("History sync succeeded using advert path") + return } catch { - logger.warning("Failed to sync history for session \(sessionID): \(error)") - // Don't fail the join - messages will arrive via normal flow + // Advert path didn't work - fall through to path discovery + logger.info("Advert path failed for \(remoteSession.name): \(error), trying path discovery") } + } else { + logger.info("Room \(remoteSession.name) is flood-routed, attempting path discovery") + } + + // Path discovery fallback + let hasDirectRoute = try await discoverPathAndWait(sessionID: sessionID) + if !hasDirectRoute { + logger.info("Could not establish direct route for \(remoteSession.name), skipping history sync") + return + } + + // Retry with newly discovered path + try await remoteNodeService.requestHistorySync(sessionID: sessionID) + logger.info("History sync succeeded after path discovery") + } catch { + logger.warning("Failed to sync history for session \(sessionID): \(error)") + // Don't fail the join - messages will arrive via normal flow } + } - /// Discover path and wait for direct route. - private func discoverPathAndWait(sessionID: UUID, timeout: Duration = .seconds(10)) async throws -> Bool { - guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), - let contact = try await dataStore.findContactByPublicKey(remoteSession.publicKey) else { - return false - } - - // Already direct? - if !contact.isFloodRouted { - return true - } + /// Discover path and wait for direct route. + private func discoverPathAndWait(sessionID: UUID, timeout: Duration = .seconds(10)) async throws -> Bool { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), + let contact = try await dataStore.findContactByPublicKey(remoteSession.publicKey) else { + return false + } - // Trigger path discovery via MeshCore session - do { - _ = try await session.sendPathDiscovery(to: remoteSession.publicKey) - } catch { - logger.warning("Path discovery send failed: \(error)") - return false - } + // Already direct? + if !contact.isFloodRouted { + return true + } - // Wait for result - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(500)) + // Trigger path discovery via MeshCore session + do { + _ = try await session.sendPathDiscovery(to: remoteSession.publicKey) + } catch { + logger.warning("Path discovery send failed: \(error)") + return false + } - if let updated = try await dataStore.findContactByPublicKey(remoteSession.publicKey), - !updated.isFloodRouted { - return true - } - } + // Wait for result + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(500)) - return false + if let updated = try await dataStore.findContactByPublicKey(remoteSession.publicKey), + !updated.isFloodRouted { + return true + } } + + return false + } } diff --git a/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift b/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift index 6ed07952..3f4c760d 100644 --- a/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift +++ b/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift @@ -5,173 +5,171 @@ import OSLog private let logger = PersistentLogger(subsystem: "com.mc1", category: "RxLogService.Region") extension RxLogService { - - private static let missLogThrottleSeconds: TimeInterval = 60 - - /// Offset of the unencrypted sender prefix byte in a DM `packetPayload`, - /// matching `findRxLogEntryBySenderPrefix`'s correlation key. - private static let dmSenderPrefixByteOffset = 1 - - /// Build a `[(name, scopeKey)]` array from the supplied region names. - /// Skips names that `TransportCodeRegionResolver.deriveScopeKey` rejects - /// (`$`-prefixed and empty/whitespace names). - static func buildScopeKeyCache(from regions: [String]) -> [(name: String, key: Data)] { - regions.compactMap { name in - guard let key = TransportCodeRegionResolver.deriveScopeKey(regionName: name) else { - return nil - } - return (name: name, key: key) - } + private static let missLogThrottleSeconds: TimeInterval = 60 + + /// Offset of the unencrypted sender prefix byte in a DM `packetPayload`, + /// matching `findRxLogEntryBySenderPrefix`'s correlation key. + private static let dmSenderPrefixByteOffset = 1 + + /// Build a `[(name, scopeKey)]` array from the supplied region names. + /// Skips names that `TransportCodeRegionResolver.deriveScopeKey` rejects + /// (`$`-prefixed and empty/whitespace names). + static func buildScopeKeyCache(from regions: [String]) -> [(name: String, key: Data)] { + regions.compactMap { name in + guard let key = TransportCodeRegionResolver.deriveScopeKey(regionName: name) else { + return nil + } + return (name: name, key: key) } - - /// Resolve `transport_codes[0]` from a parsed RX packet to a known region - /// name. Returns nil for packets without a transport code, when the - /// scope-key cache is empty, or when no region matches. - func resolveRegionScope(for parsed: ParsedRxLogData) -> String? { - guard let transportCode = parsed.transportCode, transportCode.count >= 2 else { - return nil - } - let match = findRegionScope( - transportCode: transportCode, - payloadTypeBits: parsed.payloadTypeBits, - payload: parsed.packetPayload - ) - if match == nil { - logRegionMissThrottled() - } - return match + } + + /// Resolve `transport_codes[0]` from a parsed RX packet to a known region + /// name. Returns nil for packets without a transport code, when the + /// scope-key cache is empty, or when no region matches. + func resolveRegionScope(for parsed: ParsedRxLogData) -> String? { + guard let transportCode = parsed.transportCode, transportCode.count >= 2 else { + return nil } - - /// Update the known-regions list and rebuild the scope-key cache. Also - /// triggers back-fill of recent entries that arrived before the regions - /// were known. Wired from `ConnectionManager+Pairing` on - /// `addKnownRegion` / `removeKnownRegion`. - public func updateKnownRegions(_ regions: [String]) async { - guard knownRegions != regions else { return } - knownRegions = regions - scopeKeyCache = Self.buildScopeKeyCache(from: regions) - if !regions.isEmpty { - await reprocessNoRegionEntries() - } + let match = findRegionScope( + transportCode: transportCode, + payloadTypeBits: parsed.payloadTypeBits, + payload: parsed.packetPayload + ) + if match == nil { + logRegionMissThrottled() } - - /// Re-resolve all entries with non-nil `transportCode` and nil - /// `regionScope` against the current scope-key cache, and back-fill the - /// resolved region onto both `RxLogEntry` rows and any correlated - /// `Message` rows (keyed by `(channelIndex, senderTimestamp)`). - /// - /// This is the explicit mitigation for two races: - /// 1. Discovery-time race: regions discovered seconds after first - /// connection while messages are already arriving. - /// 2. `addKnownRegion` / `removeKnownRegion` suspension-window race: - /// packets that arrive between the synchronous `connectedDevice` - /// mutation and the dispatched `updateKnownRegions(_:)` call resolve - /// against the stale cache. - func reprocessNoRegionEntries() async { - if isReprocessingRegions { - regionReprocessDirty = true - return - } - isReprocessingRegions = true - defer { isReprocessingRegions = false } - - guard let radioID else { return } - - repeat { - regionReprocessDirty = false - await runReprocessPass(radioID: radioID) - } while regionReprocessDirty + return match + } + + /// Update the known-regions list and rebuild the scope-key cache. Also + /// triggers back-fill of recent entries that arrived before the regions + /// were known. Wired from `ConnectionManager+Pairing` on + /// `addKnownRegion` / `removeKnownRegion`. + public func updateKnownRegions(_ regions: [String]) async { + guard knownRegions != regions else { return } + knownRegions = regions + scopeKeyCache = Self.buildScopeKeyCache(from: regions) + if !regions.isEmpty { + await reprocessNoRegionEntries() } - - private func runReprocessPass(radioID: UUID) async { - do { - let entries = try await dataStore.fetchEntriesWithMissingRegion( - radioID: radioID - ) - - guard !entries.isEmpty else { return } - logger.info("Re-processing \(entries.count) entries for region back-fill") - - var rxUpdates: [(id: UUID, regionScope: String?)] = [] - var channelMessageUpdates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] - var dmMessageUpdates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] - - for entry in entries { - guard !Task.isCancelled else { break } - guard let resolved = resolveRegionScope(for: entry) else { continue } - - rxUpdates.append((id: entry.id, regionScope: resolved)) - - guard let senderTimestamp = entry.senderTimestamp else { continue } - if let channelIndex = entry.channelIndex { - channelMessageUpdates.append(( - channelIndex: channelIndex, - senderTimestamp: senderTimestamp, - regionScope: resolved - )) - } else if entry.packetPayload.count >= Self.dmSenderPrefixByteOffset + 1 { - let prefixByte = entry.packetPayload[Self.dmSenderPrefixByteOffset] - dmMessageUpdates.append(( - senderPrefixByte: prefixByte, - senderTimestamp: senderTimestamp, - regionScope: resolved - )) - } - } - - if !rxUpdates.isEmpty { - try await dataStore.batchUpdateRxLogRegion(updates: rxUpdates) - } - if !channelMessageUpdates.isEmpty { - try await dataStore.batchUpdateChannelMessageRegion(radioID: radioID, updates: channelMessageUpdates) - } - if !dmMessageUpdates.isEmpty { - try await dataStore.batchUpdateDMMessageRegion(radioID: radioID, updates: dmMessageUpdates) - } - - let messageCount = channelMessageUpdates.count + dmMessageUpdates.count - if !rxUpdates.isEmpty || messageCount > 0 { - logger.info("Back-filled \(rxUpdates.count) RxLog entries, \(messageCount) messages") - } - } catch { - logger.error("Failed to re-process region entries: \(error.localizedDescription)") - } + } + + /// Re-resolve all entries with non-nil `transportCode` and nil + /// `regionScope` against the current scope-key cache, and back-fill the + /// resolved region onto both `RxLogEntry` rows and any correlated + /// `Message` rows (keyed by `(channelIndex, senderTimestamp)`). + /// + /// This is the explicit mitigation for two races: + /// 1. Discovery-time race: regions discovered seconds after first + /// connection while messages are already arriving. + /// 2. `addKnownRegion` / `removeKnownRegion` suspension-window race: + /// packets that arrive between the synchronous `connectedDevice` + /// mutation and the dispatched `updateKnownRegions(_:)` call resolve + /// against the stale cache. + func reprocessNoRegionEntries() async { + if isReprocessingRegions { + regionReprocessDirty = true + return } - - private func logRegionMissThrottled() { - let now = Date() - if let last = lastRegionMissLogTime, now.timeIntervalSince(last) < Self.missLogThrottleSeconds { - return - } - lastRegionMissLogTime = now - if knownRegions.isEmpty { - logger.debug("Region resolution skipped: no known regions loaded") - } else { - logger.debug("Region resolution miss against \(self.knownRegions.count) known regions") + isReprocessingRegions = true + defer { isReprocessingRegions = false } + + guard let radioID else { return } + + repeat { + regionReprocessDirty = false + await runReprocessPass(radioID: radioID) + } while regionReprocessDirty + } + + private func runReprocessPass(radioID: UUID) async { + do { + let entries = try await dataStore.fetchEntriesWithMissingRegion( + radioID: radioID + ) + + guard !entries.isEmpty else { return } + logger.info("Re-processing \(entries.count) entries for region back-fill") + + var rxUpdates: [(id: UUID, regionScope: String?)] = [] + var channelMessageUpdates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] + var dmMessageUpdates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] + + for entry in entries { + guard !Task.isCancelled else { break } + guard let resolved = resolveRegionScope(for: entry) else { continue } + + rxUpdates.append((id: entry.id, regionScope: resolved)) + + guard let senderTimestamp = entry.senderTimestamp else { continue } + if let channelIndex = entry.channelIndex { + channelMessageUpdates.append(( + channelIndex: channelIndex, + senderTimestamp: senderTimestamp, + regionScope: resolved + )) + } else if entry.packetPayload.count >= Self.dmSenderPrefixByteOffset + 1 { + let prefixByte = entry.packetPayload[Self.dmSenderPrefixByteOffset] + dmMessageUpdates.append(( + senderPrefixByte: prefixByte, + senderTimestamp: senderTimestamp, + regionScope: resolved + )) } + } + + if !rxUpdates.isEmpty { + try await dataStore.batchUpdateRxLogRegion(updates: rxUpdates) + } + if !channelMessageUpdates.isEmpty { + try await dataStore.batchUpdateChannelMessageRegion(radioID: radioID, updates: channelMessageUpdates) + } + if !dmMessageUpdates.isEmpty { + try await dataStore.batchUpdateDMMessageRegion(radioID: radioID, updates: dmMessageUpdates) + } + + let messageCount = channelMessageUpdates.count + dmMessageUpdates.count + if !rxUpdates.isEmpty || messageCount > 0 { + logger.info("Back-filled \(rxUpdates.count) RxLog entries, \(messageCount) messages") + } + } catch { + logger.error("Failed to re-process region entries: \(error.localizedDescription)") } + } - /// Resolve region scope against an existing `RxLogEntryDTO` (used by the - /// back-fill path, which has the persisted entry in hand rather than a - /// fresh `ParsedRxLogData`). - private func resolveRegionScope(for entry: RxLogEntryDTO) -> String? { - findRegionScope( - transportCode: entry.transportCode, - payloadTypeBits: entry.payloadTypeBits, - payload: entry.packetPayload - ) + private func logRegionMissThrottled() { + let now = Date() + if let last = lastRegionMissLogTime, now.timeIntervalSince(last) < Self.missLogThrottleSeconds { + return } - - private func findRegionScope(transportCode: Data?, payloadTypeBits: UInt8, payload: Data) -> String? { - guard let transportCode, transportCode.count >= 2 else { return nil } - guard !scopeKeyCache.isEmpty else { return nil } - let code0 = transportCode.readUInt16LE(at: 0) - return TransportCodeRegionResolver.findMatchingRegion( - scopeKeys: scopeKeyCache, - expectedTransportCode0: code0, - payloadTypeBits: payloadTypeBits, - payload: payload - ) + lastRegionMissLogTime = now + if knownRegions.isEmpty { + logger.debug("Region resolution skipped: no known regions loaded") + } else { + logger.debug("Region resolution miss against \(knownRegions.count) known regions") } - + } + + /// Resolve region scope against an existing `RxLogEntryDTO` (used by the + /// back-fill path, which has the persisted entry in hand rather than a + /// fresh `ParsedRxLogData`). + private func resolveRegionScope(for entry: RxLogEntryDTO) -> String? { + findRegionScope( + transportCode: entry.transportCode, + payloadTypeBits: entry.payloadTypeBits, + payload: entry.packetPayload + ) + } + + private func findRegionScope(transportCode: Data?, payloadTypeBits: UInt8, payload: Data) -> String? { + guard let transportCode, transportCode.count >= 2 else { return nil } + guard !scopeKeyCache.isEmpty else { return nil } + let code0 = transportCode.readUInt16LE(at: 0) + return TransportCodeRegionResolver.findMatchingRegion( + scopeKeys: scopeKeyCache, + expectedTransportCode0: code0, + payloadTypeBits: payloadTypeBits, + payload: payload + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/RxLogService.swift b/MC1Services/Sources/MC1Services/Services/RxLogService.swift index 6547b7d5..8effd76b 100644 --- a/MC1Services/Sources/MC1Services/Services/RxLogService.swift +++ b/MC1Services/Sources/MC1Services/Services/RxLogService.swift @@ -6,560 +6,561 @@ private let logger = PersistentLogger(subsystem: "com.mc1", category: "RxLogServ /// Actor that processes RX log events, decodes channel messages, and persists to database. public actor RxLogService { - private let session: any MeshCoreSessionProtocol - let dataStore: any PersistenceStoreProtocol - var radioID: UUID? - - // Caches for fast lookup - private var channelSecrets: [UInt8: Data] = [:] // channelIndex -> secret - private var channelNames: [UInt8: String] = [:] // channelIndex -> name - private var contactNames: [Data: String] = [:] // pubkey prefix -> name - - // Crypto keys for direct message decryption - private var myPrivateKey: Data? - private var contactPublicKeysByPrefix: [UInt8: [Data]] = [:] // 1-byte prefix -> array of 32-byte public keys - - /// Multicast broadcaster for newly persisted entries. Consumers subscribe - /// via `entryStream()`; finished by `ServiceContainer.tearDown()`. - private nonisolated let entryBroadcaster = EventBroadcaster() - - // Event monitoring - private var eventMonitorTask: Task? - - // Heard repeats processing. - // Injected by `ServiceContainer` at construction. - private let heardRepeatsService: HeardRepeatsService? - - // Reentrancy guards for reprocessing (separate to avoid mutual blocking) - private var isReprocessingChannels = false - private var isReprocessingDMs = false - var isReprocessingRegions = false - - // Region resolution state - var knownRegions: [String] = [] - var scopeKeyCache: [(name: String, key: Data)] = [] - var lastRegionMissLogTime: Date? - - /// Set when a region back-fill request arrives while one is already in - /// flight. The in-flight pass re-runs after it completes so rapid - /// `addKnownRegion` / `removeKnownRegion` sequences do not lose work. - var regionReprocessDirty = false - - public init(session: any MeshCoreSessionProtocol, dataStore: any PersistenceStoreProtocol, heardRepeatsService: HeardRepeatsService?) { - self.session = session - self.dataStore = dataStore - self.heardRepeatsService = heardRepeatsService - } - - /// Whether a heard repeats service was injected at construction. - var hasHeardRepeatsServiceWired: Bool { heardRepeatsService != nil } - - deinit { - eventMonitorTask?.cancel() - } - - // MARK: - Event Monitoring - - /// Start monitoring for RX log events from MeshCore. - public func startEventMonitoring(radioID: UUID) { - self.radioID = radioID - eventMonitorTask?.cancel() - - eventMonitorTask = Task { [weak self] in - guard let self else { return } - - // Load secrets from database before entering event loop - // This eliminates the race condition where events arrive before secrets are synced - await self.loadSecretsFromDatabase(radioID: radioID) - - let events = await session.events(filter: .rxLogData) - - for await event in events { - guard !Task.isCancelled else { break } - if case .rxLogData(let parsed) = event { - await self.process(parsed) - } - } - } - } - - /// Load channel secrets and contact public keys from database to enable decryption before sync completes. - private func loadSecretsFromDatabase(radioID: UUID) async { - do { - let channels = try await dataStore.fetchChannels(radioID: radioID) - // Channels arrive sorted by slot index; a corrupt store can hold duplicate - // indices, so keep the first match for a deterministic choice. - channelSecrets = Dictionary(channels.map { ($0.index, $0.secret) }, uniquingKeysWith: { first, _ in first }) - channelNames = Dictionary(channels.map { ($0.index, $0.name) }, uniquingKeysWith: { first, _ in first }) - if !channels.isEmpty { - logger.info("Loaded \(channels.count) channel secrets from database") - } - } catch { - logger.error("Failed to load channel secrets: \(error.localizedDescription)") - } - - do { - let publicKeys = try await dataStore.fetchContactPublicKeysByPrefix(radioID: radioID) - if !publicKeys.isEmpty { - contactPublicKeysByPrefix = Self.convertPublicKeysToX25519(publicKeys) - logger.info("Loaded \(publicKeys.count) contact public key prefixes from database") - } - } catch { - logger.error("Failed to load contact public keys: \(error.localizedDescription)") - } - - let device = try? await dataStore.fetchDevice(radioID: radioID) - knownRegions = device?.knownRegions ?? [] - scopeKeyCache = Self.buildScopeKeyCache(from: knownRegions) - if !knownRegions.isEmpty { - logger.info("Loaded \(knownRegions.count) known regions from database (\(scopeKeyCache.count) resolvable)") + private let session: any MeshCoreSessionProtocol + let dataStore: any PersistenceStoreProtocol + var radioID: UUID? + + // Caches for fast lookup + private var channelSecrets: [UInt8: Data] = [:] // channelIndex -> secret + private var channelNames: [UInt8: String] = [:] // channelIndex -> name + private var contactNames: [Data: String] = [:] // pubkey prefix -> name + + // Crypto keys for direct message decryption + private var myPrivateKey: Data? + private var contactPublicKeysByPrefix: [UInt8: [Data]] = [:] // 1-byte prefix -> array of 32-byte public keys + + /// Multicast broadcaster for newly persisted entries. Consumers subscribe + /// via `entryStream()`; finished by `ServiceContainer.tearDown()`. + private nonisolated let entryBroadcaster = EventBroadcaster() + + /// Event monitoring + private var eventMonitorTask: Task? + + /// Heard repeats processing. + /// Injected by `ServiceContainer` at construction. + private let heardRepeatsService: HeardRepeatsService? + + // Reentrancy guards for reprocessing (separate to avoid mutual blocking) + private var isReprocessingChannels = false + private var isReprocessingDMs = false + var isReprocessingRegions = false + + // Region resolution state + var knownRegions: [String] = [] + var scopeKeyCache: [(name: String, key: Data)] = [] + var lastRegionMissLogTime: Date? + + /// Set when a region back-fill request arrives while one is already in + /// flight. The in-flight pass re-runs after it completes so rapid + /// `addKnownRegion` / `removeKnownRegion` sequences do not lose work. + var regionReprocessDirty = false + + public init(session: any MeshCoreSessionProtocol, dataStore: any PersistenceStoreProtocol, heardRepeatsService: HeardRepeatsService?) { + self.session = session + self.dataStore = dataStore + self.heardRepeatsService = heardRepeatsService + } + + /// Whether a heard repeats service was injected at construction. + var hasHeardRepeatsServiceWired: Bool { + heardRepeatsService != nil + } + + deinit { + eventMonitorTask?.cancel() + } + + // MARK: - Event Monitoring + + /// Start monitoring for RX log events from MeshCore. + public func startEventMonitoring(radioID: UUID) { + self.radioID = radioID + eventMonitorTask?.cancel() + + eventMonitorTask = Task { [weak self] in + guard let self else { return } + + // Load secrets from database before entering event loop + // This eliminates the race condition where events arrive before secrets are synced + await loadSecretsFromDatabase(radioID: radioID) + + let events = await session.events(filter: .rxLogData) + + for await event in events { + guard !Task.isCancelled else { break } + if case let .rxLogData(parsed) = event { + await process(parsed) } + } } - - /// Stop monitoring events. - public func stopEventMonitoring() { - eventMonitorTask?.cancel() - eventMonitorTask = nil + } + + /// Load channel secrets and contact public keys from database to enable decryption before sync completes. + private func loadSecretsFromDatabase(radioID: UUID) async { + do { + let channels = try await dataStore.fetchChannels(radioID: radioID) + // Channels arrive sorted by slot index; a corrupt store can hold duplicate + // indices, so keep the first match for a deterministic choice. + channelSecrets = Dictionary(channels.map { ($0.index, $0.secret) }, uniquingKeysWith: { first, _ in first }) + channelNames = Dictionary(channels.map { ($0.index, $0.name) }, uniquingKeysWith: { first, _ in first }) + if !channels.isEmpty { + logger.info("Loaded \(channels.count) channel secrets from database") + } + } catch { + logger.error("Failed to load channel secrets: \(error.localizedDescription)") } - /// Returns a fresh multicast stream of newly persisted entries. - /// Registration is synchronous, so entries yielded after this call returns - /// are never dropped, and coexisting subscribers each receive every entry. - public nonisolated func entryStream() -> AsyncStream { - entryBroadcaster.subscribe() + do { + let publicKeys = try await dataStore.fetchContactPublicKeysByPrefix(radioID: radioID) + if !publicKeys.isEmpty { + contactPublicKeysByPrefix = Self.convertPublicKeysToX25519(publicKeys) + logger.info("Loaded \(publicKeys.count) contact public key prefixes from database") + } + } catch { + logger.error("Failed to load contact public keys: \(error.localizedDescription)") } - /// Ends every `entryStream()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release their service - /// references. - nonisolated func finishEntryStream() { - entryBroadcaster.finish() + let device = try? await dataStore.fetchDevice(radioID: radioID) + knownRegions = device?.knownRegions ?? [] + scopeKeyCache = Self.buildScopeKeyCache(from: knownRegions) + if !knownRegions.isEmpty { + logger.info("Loaded \(knownRegions.count) known regions from database (\(scopeKeyCache.count) resolvable)") } - - /// Rebuilds the channel cache from a fresh channel list. - /// `ChannelService` forwards every channel write and sync result here so - /// captured packets can be decoded with current secrets. - public func updateChannels(from channels: [ChannelDTO]) async { - let secrets: [UInt8: Data] = Dictionary( - uniqueKeysWithValues: channels.map { ($0.index, $0.secret) } - ) - let names: [UInt8: String] = Dictionary( - uniqueKeysWithValues: channels.map { ($0.index, $0.name) } - ) - await updateChannels(secrets: secrets, names: names) + } + + /// Stop monitoring events. + public func stopEventMonitoring() { + eventMonitorTask?.cancel() + eventMonitorTask = nil + } + + /// Returns a fresh multicast stream of newly persisted entries. + /// Registration is synchronous, so entries yielded after this call returns + /// are never dropped, and coexisting subscribers each receive every entry. + public nonisolated func entryStream() -> AsyncStream { + entryBroadcaster.subscribe() + } + + /// Ends every `entryStream()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release their service + /// references. + nonisolated func finishEntryStream() { + entryBroadcaster.finish() + } + + /// Rebuilds the channel cache from a fresh channel list. + /// `ChannelService` forwards every channel write and sync result here so + /// captured packets can be decoded with current secrets. + public func updateChannels(from channels: [ChannelDTO]) async { + let secrets: [UInt8: Data] = Dictionary( + uniqueKeysWithValues: channels.map { ($0.index, $0.secret) } + ) + let names: [UInt8: String] = Dictionary( + uniqueKeysWithValues: channels.map { ($0.index, $0.name) } + ) + await updateChannels(secrets: secrets, names: names) + } + + /// Update channel cache (secrets and names). + /// Re-processes any recent noMatchingKey entries when secrets are provided. + public func updateChannels(secrets: [UInt8: Data], names: [UInt8: String]) async { + channelSecrets = secrets + channelNames = names + + if !secrets.isEmpty { + await reprocessNoMatchingKeyEntries() } - - /// Update channel cache (secrets and names). - /// Re-processes any recent noMatchingKey entries when secrets are provided. - public func updateChannels(secrets: [UInt8: Data], names: [UInt8: String]) async { - channelSecrets = secrets - channelNames = names - - if !secrets.isEmpty { - await reprocessNoMatchingKeyEntries() + } + + /// Re-process recent entries that failed decryption due to missing keys. + /// Uses a reentrancy guard to prevent overlapping reprocessing. + private func reprocessNoMatchingKeyEntries() async { + guard !isReprocessingChannels else { return } + isReprocessingChannels = true + defer { isReprocessingChannels = false } + + guard let radioID else { return } + + let cutoff = Date().addingTimeInterval(-60) + + do { + let entries = try await dataStore.fetchRecentEntriesByDecryptStatus( + radioID: radioID, + status: .noMatchingKey, + since: cutoff + ) + + guard !entries.isEmpty else { return } + logger.info("Re-processing \(entries.count) noMatchingKey entries") + + // Collect successful decryptions for batch update + var updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] = [] + var decryptedEntries: [RxLogEntryDTO] = [] + + for entry in entries { + guard !Task.isCancelled else { break } + + let decrypted = decryptEntry(entry) + guard decrypted.decodedText != nil else { continue } + + updates.append(( + id: entry.id, + channelIndex: decrypted.channelIndex, + channelName: decrypted.channelName, + senderTimestamp: decrypted.senderTimestamp + )) + decryptedEntries.append(decrypted) + } + + // Batch update database (decodedText is @Transient, not persisted) + if !updates.isEmpty { + try await dataStore.batchUpdateRxLogDecryption(updates) + + // Process for heard repeats after DB update + if let heardRepeatsService { + for entry in decryptedEntries { + guard !Task.isCancelled else { break } + await heardRepeatsService.processForRepeats(entry) + } } + + logger.info("Successfully re-processed \(updates.count) entries") + } + } catch { + logger.error("Failed to re-process noMatchingKey entries: \(error.localizedDescription)") } + } - /// Re-process recent entries that failed decryption due to missing keys. - /// Uses a reentrancy guard to prevent overlapping reprocessing. - private func reprocessNoMatchingKeyEntries() async { - guard !isReprocessingChannels else { return } - isReprocessingChannels = true - defer { isReprocessingChannels = false } + /// Re-process recent DM entries that failed decryption due to missing keys. + /// Called when contact public keys or the device private key become available. + private func reprocessDMEntries() async { + guard !isReprocessingDMs else { return } + isReprocessingDMs = true + defer { isReprocessingDMs = false } - guard let radioID else { return } + guard let radioID, let myPrivateKey else { return } + guard !contactPublicKeysByPrefix.isEmpty else { return } - let cutoff = Date().addingTimeInterval(-60) + let cutoff = Date().addingTimeInterval(-60) - do { - let entries = try await dataStore.fetchRecentEntriesByDecryptStatus( - radioID: radioID, - status: .noMatchingKey, - since: cutoff - ) - - guard !entries.isEmpty else { return } - logger.info("Re-processing \(entries.count) noMatchingKey entries") - - // Collect successful decryptions for batch update - var updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] = [] - var decryptedEntries: [RxLogEntryDTO] = [] - - for entry in entries { - guard !Task.isCancelled else { break } - - let decrypted = decryptEntry(entry) - guard decrypted.decodedText != nil else { continue } - - updates.append(( - id: entry.id, - channelIndex: decrypted.channelIndex, - channelName: decrypted.channelName, - senderTimestamp: decrypted.senderTimestamp - )) - decryptedEntries.append(decrypted) - } - - // Batch update database (decodedText is @Transient, not persisted) - if !updates.isEmpty { - try await dataStore.batchUpdateRxLogDecryption(updates) - - // Process for heard repeats after DB update - if let heardRepeatsService { - for entry in decryptedEntries { - guard !Task.isCancelled else { break } - await heardRepeatsService.processForRepeats(entry) - } - } - - logger.info("Successfully re-processed \(updates.count) entries") - } - } catch { - logger.error("Failed to re-process noMatchingKey entries: \(error.localizedDescription)") - } - } + do { + let entries = try await dataStore.fetchRecentEntriesByDecryptStatus( + radioID: radioID, + status: .dmNoMatchingKey, + since: cutoff + ) - /// Re-process recent DM entries that failed decryption due to missing keys. - /// Called when contact public keys or the device private key become available. - private func reprocessDMEntries() async { - guard !isReprocessingDMs else { return } - isReprocessingDMs = true - defer { isReprocessingDMs = false } + guard !entries.isEmpty else { return } + logger.info("Re-processing \(entries.count) DM entries for timestamp extraction") - guard let radioID, let myPrivateKey else { return } - guard !contactPublicKeysByPrefix.isEmpty else { return } + var updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] = [] - let cutoff = Date().addingTimeInterval(-60) + for entry in entries { + guard !Task.isCancelled else { break } - do { - let entries = try await dataStore.fetchRecentEntriesByDecryptStatus( - radioID: radioID, - status: .dmNoMatchingKey, - since: cutoff - ) - - guard !entries.isEmpty else { return } - logger.info("Re-processing \(entries.count) DM entries for timestamp extraction") - - var updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] = [] - - for entry in entries { - guard !Task.isCancelled else { break } - - // Extract sender prefix from packetPayload: [destHash:1][srcHash:1]... - let payloadHashSize = 1 - guard entry.packetPayload.count >= payloadHashSize * 2, - entry.routeType == .direct || entry.routeType == .tcDirect else { - continue - } - let senderPrefix = entry.packetPayload[payloadHashSize] - - guard let candidateKeys = contactPublicKeysByPrefix[senderPrefix] else { - continue - } - - for senderPublicKey in candidateKeys { - if let timestamp = DirectMessageCrypto.extractTimestamp( - payload: entry.packetPayload, - myPrivateKey: myPrivateKey, - senderPublicKey: senderPublicKey - ) { - updates.append(( - id: entry.id, - channelIndex: nil, - channelName: nil, - senderTimestamp: timestamp - )) - break - } - } - } - - if !updates.isEmpty { - try await dataStore.batchUpdateRxLogDecryption(updates) - logger.info("Successfully re-processed \(updates.count) DM entries") - } - } catch { - logger.error("Failed to re-process DM entries: \(error.localizedDescription)") + // Extract sender prefix from packetPayload: [destHash:1][srcHash:1]... + let payloadHashSize = 1 + guard entry.packetPayload.count >= payloadHashSize * 2, + entry.routeType == .direct || entry.routeType == .tcDirect else { + continue } - } + let senderPrefix = entry.packetPayload[payloadHashSize] - /// Update contact names cache. - public func updateContactNames(_ names: [Data: String]) { - contactNames = names - } - - /// Update device private key for direct message decryption. - /// The exported key is 64 bytes: `[expanded_scalar:32][nonce:32]`. - /// DirectMessageCrypto needs the 32-byte Curve25519 scalar (first half). - public func updatePrivateKey(_ key: Data?) async { - myPrivateKey = key.flatMap { $0.count >= 32 ? Data($0.prefix(32)) : nil } - if myPrivateKey != nil { - await reprocessDMEntries() + guard let candidateKeys = contactPublicKeysByPrefix[senderPrefix] else { + continue } - } - /// Update contact public keys for direct message decryption. - /// Called when contacts sync completes. Re-processes any recent DM entries. - /// Input keys are Ed25519 public keys; converted to Curve25519 for ECDH. - public func updateContactPublicKeys(_ keys: [UInt8: [Data]]) async { - contactPublicKeysByPrefix = Self.convertPublicKeysToX25519(keys) - if !contactPublicKeysByPrefix.isEmpty { - await reprocessDMEntries() + for senderPublicKey in candidateKeys { + if let timestamp = DirectMessageCrypto.extractTimestamp( + payload: entry.packetPayload, + myPrivateKey: myPrivateKey, + senderPublicKey: senderPublicKey + ) { + updates.append(( + id: entry.id, + channelIndex: nil, + channelName: nil, + senderTimestamp: timestamp + )) + break + } } + } + + if !updates.isEmpty { + try await dataStore.batchUpdateRxLogDecryption(updates) + logger.info("Successfully re-processed \(updates.count) DM entries") + } + } catch { + logger.error("Failed to re-process DM entries: \(error.localizedDescription)") } - - /// Convert Ed25519 contact public keys to Curve25519 for DirectMessageCrypto. - private static func convertPublicKeysToX25519(_ keys: [UInt8: [Data]]) -> [UInt8: [Data]] { - var converted: [UInt8: [Data]] = [:] - for (prefix, publicKeys) in keys { - let x25519Keys = publicKeys.compactMap { Ed25519ToX25519.convertPublicKey($0) } - if !x25519Keys.isEmpty { - converted[prefix] = x25519Keys - } - } - return converted + } + + /// Update contact names cache. + public func updateContactNames(_ names: [Data: String]) { + contactNames = names + } + + /// Update device private key for direct message decryption. + /// The exported key is 64 bytes: `[expanded_scalar:32][nonce:32]`. + /// DirectMessageCrypto needs the 32-byte Curve25519 scalar (first half). + public func updatePrivateKey(_ key: Data?) async { + myPrivateKey = key.flatMap { $0.count >= 32 ? Data($0.prefix(32)) : nil } + if myPrivateKey != nil { + await reprocessDMEntries() } + } + + /// Update contact public keys for direct message decryption. + /// Called when contacts sync completes. Re-processes any recent DM entries. + /// Input keys are Ed25519 public keys; converted to Curve25519 for ECDH. + public func updateContactPublicKeys(_ keys: [UInt8: [Data]]) async { + contactPublicKeysByPrefix = Self.convertPublicKeysToX25519(keys) + if !contactPublicKeysByPrefix.isEmpty { + await reprocessDMEntries() + } + } + + /// Convert Ed25519 contact public keys to Curve25519 for DirectMessageCrypto. + private static func convertPublicKeysToX25519(_ keys: [UInt8: [Data]]) -> [UInt8: [Data]] { + var converted: [UInt8: [Data]] = [:] + for (prefix, publicKeys) in keys { + let x25519Keys = publicKeys.compactMap { Ed25519ToX25519.convertPublicKey($0) } + if !x25519Keys.isEmpty { + converted[prefix] = x25519Keys + } + } + return converted + } + + /// Process a parsed RX log event. + public func process(_ parsed: ParsedRxLogData) async { + guard let radioID else { return } + + // Decode channel message if applicable + var channelIndex: UInt8? + var channelName: String? + var decryptStatus = DecryptStatus.notApplicable + var decodedText: String? + var senderTimestamp: UInt32? + var fromContactName: String? + + if parsed.payloadType == .groupText || parsed.payloadType == .groupData { + // Channel payload format: [channelHash: 1B] [MAC: 2B] [ciphertext: NB] + // The first byte is a truncated channel hash (not the index), so we must + // try all known secrets to find the one where MAC validates. + let rawPayload = parsed.packetPayload + + // Need at least: 1 (channel hash) + 2 (MAC) + 16 (min ciphertext block) + if rawPayload.count >= 1 + ChannelCrypto.macSize + 16 { + let encryptedPayload = Data(rawPayload.dropFirst(1)) - /// Process a parsed RX log event. - public func process(_ parsed: ParsedRxLogData) async { - guard let radioID else { return } - - // Decode channel message if applicable - var channelIndex: UInt8? - var channelName: String? - var decryptStatus = DecryptStatus.notApplicable - var decodedText: String? - var senderTimestamp: UInt32? - var fromContactName: String? - - if parsed.payloadType == .groupText || parsed.payloadType == .groupData { - // Channel payload format: [channelHash: 1B] [MAC: 2B] [ciphertext: NB] - // The first byte is a truncated channel hash (not the index), so we must - // try all known secrets to find the one where MAC validates. - let rawPayload = parsed.packetPayload - - // Need at least: 1 (channel hash) + 2 (MAC) + 16 (min ciphertext block) - if rawPayload.count >= 1 + ChannelCrypto.macSize + 16 { - let encryptedPayload = Data(rawPayload.dropFirst(1)) - - for (index, secret) in self.channelSecrets { - let result = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) - if case .success(let timestamp, _, let text) = result { - channelIndex = index - channelName = channelNames[index] ?? "Channel \(index)" - decryptStatus = .success - senderTimestamp = timestamp - decodedText = text - break - } - } - - if decryptStatus == .notApplicable { - decryptStatus = .noMatchingKey - } - } else { - decryptStatus = .pending - } - } - - // Decrypt direct text messages to extract senderTimestamp and text - if parsed.payloadType == .textMessage, - parsed.routeType == .direct || parsed.routeType == .tcDirect { - - if let myPrivateKey = self.myPrivateKey, - let dmResult = Self.tryDecryptDM( - payload: parsed.packetPayload, - myPrivateKey: myPrivateKey, - contactPublicKeysByPrefix: contactPublicKeysByPrefix - ) { - senderTimestamp = dmResult.timestamp - decodedText = dmResult.text - decryptStatus = .success - logger.debug("Decrypted direct message senderTimestamp: \(dmResult.timestamp)") - } - - if senderTimestamp == nil { - decryptStatus = .dmNoMatchingKey - logger.debug("DM decryption failed, marking as dmNoMatchingKey for reprocessing") - } + for (index, secret) in channelSecrets { + let result = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) + if case let .success(timestamp, _, text) = result { + channelIndex = index + channelName = channelNames[index] ?? "Channel \(index)" + decryptStatus = .success + senderTimestamp = timestamp + decodedText = text + break + } } - // Resolve contact name from sender pubkey prefix (direct messages) - if let senderPrefix = parsed.senderPubkeyPrefix { - fromContactName = contactNames.first { storedPrefix, _ in - storedPrefix.starts(with: senderPrefix) || senderPrefix.starts(with: storedPrefix) - }?.value + if decryptStatus == .notApplicable { + decryptStatus = .noMatchingKey } + } else { + decryptStatus = .pending + } + } - // The advert payload carries the sender's full pubkey at offset 0, followed by the advert - // timestamp at offset 32. The inbound hop count is an exact keyed write, not a timing - // correlation. A reserved or truncated encoding skips the write rather than stamping a - // wrong value. Only flood-routed adverts accumulate a hop path; a direct-routed advert's - // path length is the remaining route, not hops traversed, so it is excluded. - if parsed.payloadType == .advert, parsed.routeType.isFlood { - let payloadBytes = parsed.packetPayload.count - if payloadBytes >= ProtocolLimits.publicKeySize, let inboundHops = decodePathLen(parsed.pathLength)?.hopCount { - let advertiserPubKey = Data(parsed.packetPayload.prefix(ProtocolLimits.publicKeySize)) - let advertTimestamp: UInt32? = payloadBytes >= ProtocolLimits.publicKeySize + ProtocolLimits.advertTimestampSize - ? parsed.packetPayload.readUInt32LE(at: ProtocolLimits.publicKeySize) - : nil - do { - try await dataStore.setInboundHopCount( - radioID: radioID, - publicKey: advertiserPubKey, - hopCount: inboundHops, - advertTimestamp: advertTimestamp - ) - } catch { - logger.error("Failed to stamp inbound hop count: \(error.localizedDescription)") - } - } - } + // Decrypt direct text messages to extract senderTimestamp and text + if parsed.payloadType == .textMessage, + parsed.routeType == .direct || parsed.routeType == .tcDirect { + if let myPrivateKey, + let dmResult = Self.tryDecryptDM( + payload: parsed.packetPayload, + myPrivateKey: myPrivateKey, + contactPublicKeysByPrefix: contactPublicKeysByPrefix + ) { + senderTimestamp = dmResult.timestamp + decodedText = dmResult.text + decryptStatus = .success + logger.debug("Decrypted direct message senderTimestamp: \(dmResult.timestamp)") + } + + if senderTimestamp == nil { + decryptStatus = .dmNoMatchingKey + logger.debug("DM decryption failed, marking as dmNoMatchingKey for reprocessing") + } + } - let regionScope = resolveRegionScope(for: parsed) + // Resolve contact name from sender pubkey prefix (direct messages) + if let senderPrefix = parsed.senderPubkeyPrefix { + fromContactName = contactNames.first { storedPrefix, _ in + storedPrefix.starts(with: senderPrefix) || senderPrefix.starts(with: storedPrefix) + }?.value + } - // Create DTO - let dto = RxLogEntryDTO( - radioID: radioID, - from: parsed, - channelIndex: channelIndex, - channelName: channelName, - decryptStatus: decryptStatus, - fromContactName: fromContactName, - senderTimestamp: senderTimestamp, - regionScope: regionScope, - decodedText: decodedText - ) - - // Persist + // The advert payload carries the sender's full pubkey at offset 0, followed by the advert + // timestamp at offset 32. The inbound hop count is an exact keyed write, not a timing + // correlation. A reserved or truncated encoding skips the write rather than stamping a + // wrong value. Only flood-routed adverts accumulate a hop path; a direct-routed advert's + // path length is the remaining route, not hops traversed, so it is excluded. + if parsed.payloadType == .advert, parsed.routeType.isFlood { + let payloadBytes = parsed.packetPayload.count + if payloadBytes >= ProtocolLimits.publicKeySize, let inboundHops = decodePathLen(parsed.pathLength)?.hopCount { + let advertiserPubKey = Data(parsed.packetPayload.prefix(ProtocolLimits.publicKeySize)) + let advertTimestamp: UInt32? = payloadBytes >= ProtocolLimits.publicKeySize + ProtocolLimits.advertTimestampSize + ? parsed.packetPayload.readUInt32LE(at: ProtocolLimits.publicKeySize) + : nil do { - try await dataStore.saveRxLogEntry(dto) - try await dataStore.pruneRxLogEntries(radioID: radioID) + try await dataStore.setInboundHopCount( + radioID: radioID, + publicKey: advertiserPubKey, + hopCount: inboundHops, + advertTimestamp: advertTimestamp + ) } catch { - logger.error("Failed to save RX log entry: \(error.localizedDescription)") - } - - // Emit to stream consumers (RX log screens, Live Activity freshness) - entryBroadcaster.yield(dto) - - // Process for heard repeats (inline await provides natural backpressure, - // preventing unbounded Task accumulation under high RX volume) - if let heardRepeatsService = self.heardRepeatsService { - await heardRepeatsService.processForRepeats(dto) + logger.error("Failed to stamp inbound hop count: \(error.localizedDescription)") } + } } - /// Load existing entries from database, re-decrypting payloads with current secrets. - public func loadExistingEntries() async -> [RxLogEntryDTO] { - guard let radioID else { return [] } - do { - let entries = try await dataStore.fetchRxLogEntries(radioID: radioID) - return entries.map { decryptEntry($0) } - } catch { - logger.error("Failed to load RX log entries: \(error.localizedDescription)") - return [] - } + let regionScope = resolveRegionScope(for: parsed) + + // Create DTO + let dto = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: channelIndex, + channelName: channelName, + decryptStatus: decryptStatus, + fromContactName: fromContactName, + senderTimestamp: senderTimestamp, + regionScope: regionScope, + decodedText: decodedText + ) + + // Persist + do { + try await dataStore.saveRxLogEntry(dto) + try await dataStore.pruneRxLogEntries(radioID: radioID) + } catch { + logger.error("Failed to save RX log entry: \(error.localizedDescription)") } - // MARK: - Decryption - - /// Attempt to decrypt a channel message entry using current secrets. - /// Returns a copy of the entry with `decodedText` populated if decryption succeeds. - /// This is reusable for export and other features that need decrypted content. - /// - /// If the entry was previously decrypted (`decryptStatus == .success`), - /// we use the stored channel index for O(1) secret lookup instead of trying all keys. - public func decryptEntry(_ entry: RxLogEntryDTO) -> RxLogEntryDTO { - var result = entry - - // Attempt DM decryption for direct text messages - if entry.payloadType == .textMessage, - entry.routeType == .direct || entry.routeType == .tcDirect { - if let myPrivateKey = self.myPrivateKey, - let dmResult = Self.tryDecryptDM( - payload: entry.packetPayload, - myPrivateKey: myPrivateKey, - contactPublicKeysByPrefix: contactPublicKeysByPrefix - ) { - result.senderTimestamp = dmResult.timestamp - result.decodedText = dmResult.text - } - return result - } + // Emit to stream consumers (RX log screens, Live Activity freshness) + entryBroadcaster.yield(dto) - // Only attempt decryption for channel messages - guard entry.payloadType == .groupText || entry.payloadType == .groupData else { - return result - } + // Process for heard repeats (inline await provides natural backpressure, + // preventing unbounded Task accumulation under high RX volume) + if let heardRepeatsService { + await heardRepeatsService.processForRepeats(dto) + } + } + + /// Load existing entries from database, re-decrypting payloads with current secrets. + public func loadExistingEntries() async -> [RxLogEntryDTO] { + guard let radioID else { return [] } + do { + let entries = try await dataStore.fetchRxLogEntries(radioID: radioID) + return entries.map { decryptEntry($0) } + } catch { + logger.error("Failed to load RX log entries: \(error.localizedDescription)") + return [] + } + } + + // MARK: - Decryption + + /// Attempt to decrypt a channel message entry using current secrets. + /// Returns a copy of the entry with `decodedText` populated if decryption succeeds. + /// This is reusable for export and other features that need decrypted content. + /// + /// If the entry was previously decrypted (`decryptStatus == .success`), + /// we use the stored channel index for O(1) secret lookup instead of trying all keys. + public func decryptEntry(_ entry: RxLogEntryDTO) -> RxLogEntryDTO { + var result = entry + + // Attempt DM decryption for direct text messages + if entry.payloadType == .textMessage, + entry.routeType == .direct || entry.routeType == .tcDirect { + if let myPrivateKey, + let dmResult = Self.tryDecryptDM( + payload: entry.packetPayload, + myPrivateKey: myPrivateKey, + contactPublicKeysByPrefix: contactPublicKeysByPrefix + ) { + result.senderTimestamp = dmResult.timestamp + result.decodedText = dmResult.text + } + return result + } - // Skip if payload is too small - guard entry.packetPayload.count >= 1 + ChannelCrypto.macSize + 16 else { - return result - } + // Only attempt decryption for channel messages + guard entry.payloadType == .groupText || entry.payloadType == .groupData else { + return result + } - // Channel payload format: [channelHash: 1B] [MAC: 2B] [ciphertext: NB] - let encryptedPayload = Data(entry.packetPayload.dropFirst(1)) - - // Fast path: use stored channel index if previously decrypted successfully - if entry.decryptStatus == .success, let channelIndex = entry.channelIndex, - let secret = channelSecrets[channelIndex] { - let decryptResult = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) - if case .success(let timestamp, _, let text) = decryptResult { - result.senderTimestamp = timestamp - result.decodedText = text - return result - } - } + // Skip if payload is too small + guard entry.packetPayload.count >= 1 + ChannelCrypto.macSize + 16 else { + return result + } - // Slow path: try all secrets (for .noMatchingKey entries or if fast path failed) - // and record which channel's secret matched so the entry is attributed correctly. - for (index, secret) in channelSecrets { - let decryptResult = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) - if case .success(let timestamp, _, let text) = decryptResult { - result.channelIndex = index - result.channelName = channelNames[index] ?? "Channel \(index)" - result.senderTimestamp = timestamp - result.decodedText = text - break - } - } + // Channel payload format: [channelHash: 1B] [MAC: 2B] [ciphertext: NB] + let encryptedPayload = Data(entry.packetPayload.dropFirst(1)) + // Fast path: use stored channel index if previously decrypted successfully + if entry.decryptStatus == .success, let channelIndex = entry.channelIndex, + let secret = channelSecrets[channelIndex] { + let decryptResult = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) + if case let .success(timestamp, _, text) = decryptResult { + result.senderTimestamp = timestamp + result.decodedText = text return result + } } - /// Try decrypting a DM payload by iterating candidate keys for the sender prefix byte. - /// Returns the decrypted timestamp and text, or nil if no key matched. - private static func tryDecryptDM( - payload: Data, - myPrivateKey: Data, - contactPublicKeysByPrefix: [UInt8: [Data]] - ) -> (timestamp: UInt32, text: String?)? { - guard payload.count >= DirectMessageCrypto.minPacketSize else { return nil } - // DM payload: [destHash:1][srcHash:1][MAC:2][ciphertext:N] - let payloadHashSize = 1 - let senderPrefix = payload[payloadHashSize] - guard let candidateKeys = contactPublicKeysByPrefix[senderPrefix] else { return nil } - - for senderPublicKey in candidateKeys { - if case .success(let timestamp, _, let text) = DirectMessageCrypto.decrypt( - payload: payload, - myPrivateKey: myPrivateKey, - senderPublicKey: senderPublicKey - ) { - return (timestamp, text) - } - } - return nil + // Slow path: try all secrets (for .noMatchingKey entries or if fast path failed) + // and record which channel's secret matched so the entry is attributed correctly. + for (index, secret) in channelSecrets { + let decryptResult = ChannelCrypto.decrypt(payload: encryptedPayload, secret: secret) + if case let .success(timestamp, _, text) = decryptResult { + result.channelIndex = index + result.channelName = channelNames[index] ?? "Channel \(index)" + result.senderTimestamp = timestamp + result.decodedText = text + break + } } - /// Clear all entries. - public func clearEntries() async { - guard let radioID else { return } - do { - try await dataStore.clearRxLogEntries(radioID: radioID) - } catch { - logger.error("Failed to clear RX log entries: \(error.localizedDescription)") - } + return result + } + + /// Try decrypting a DM payload by iterating candidate keys for the sender prefix byte. + /// Returns the decrypted timestamp and text, or nil if no key matched. + private static func tryDecryptDM( + payload: Data, + myPrivateKey: Data, + contactPublicKeysByPrefix: [UInt8: [Data]] + ) -> (timestamp: UInt32, text: String?)? { + guard payload.count >= DirectMessageCrypto.minPacketSize else { return nil } + // DM payload: [destHash:1][srcHash:1][MAC:2][ciphertext:N] + let payloadHashSize = 1 + let senderPrefix = payload[payloadHashSize] + guard let candidateKeys = contactPublicKeysByPrefix[senderPrefix] else { return nil } + + for senderPublicKey in candidateKeys { + if case let .success(timestamp, _, text) = DirectMessageCrypto.decrypt( + payload: payload, + myPrivateKey: myPrivateKey, + senderPublicKey: senderPublicKey + ) { + return (timestamp, text) + } + } + return nil + } + + /// Clear all entries. + public func clearEntries() async { + guard let radioID else { return } + do { + try await dataStore.clearRxLogEntries(radioID: radioID) + } catch { + logger.error("Failed to clear RX log entries: \(error.localizedDescription)") } + } } diff --git a/MC1Services/Sources/MC1Services/Services/SceneStorageKey.swift b/MC1Services/Sources/MC1Services/Services/SceneStorageKey.swift index 76105a8c..332aeadf 100644 --- a/MC1Services/Sources/MC1Services/Services/SceneStorageKey.swift +++ b/MC1Services/Sources/MC1Services/Services/SceneStorageKey.swift @@ -10,8 +10,8 @@ import Foundation /// Raw values are pinned to their exact on-disk key so a case rename can't /// silently mint a new key and orphan restored state. Keep them pinned. public enum SceneStorageKey: String { - // swiftlint:disable redundant_string_enum_value - /// The user's last map camera region, serialized by `MapCameraStore`. - case mapCameraRegion = "mapCameraRegion" - // swiftlint:enable redundant_string_enum_value + // swiftlint:disable redundant_string_enum_value + /// The user's last map camera region, serialized by `MapCameraStore`. + case mapCameraRegion + // swiftlint:enable redundant_string_enum_value } diff --git a/MC1Services/Sources/MC1Services/Services/SendQueue.swift b/MC1Services/Sources/MC1Services/Services/SendQueue.swift index 51960515..ef9ee480 100644 --- a/MC1Services/Sources/MC1Services/Services/SendQueue.swift +++ b/MC1Services/Sources/MC1Services/Services/SendQueue.swift @@ -22,117 +22,118 @@ import Foundation /// the drain completes — a popped owner mid-drain cannot strand an /// in-flight send. Strong capture is broken naturally by Task completion. actor SendQueue { + typealias Sender = @Sendable (Envelope) async throws -> Void + typealias OnError = @Sendable (Error, Envelope) async -> Void - typealias Sender = @Sendable (Envelope) async throws -> Void - typealias OnError = @Sendable (Error, Envelope) async -> Void + /// Fires once per drain pass after the inner `while !pending.isEmpty` + /// completes. The parameter is the most recent non-cancellation + /// `Error` raised by `send(_:)` during this drain (or `nil` if no + /// envelope failed). Last-error-wins matches the original + /// `processQueue`'s `var pendingError: String?` semantics. + typealias OnDrain = @Sendable (Error?) async -> Void - /// Fires once per drain pass after the inner `while !pending.isEmpty` - /// completes. The parameter is the most recent non-cancellation - /// `Error` raised by `send(_:)` during this drain (or `nil` if no - /// envelope failed). Last-error-wins matches the original - /// `processQueue`'s `var pendingError: String?` semantics. - typealias OnDrain = @Sendable (Error?) async -> Void + private var pending: [Envelope] = [] + private var processingTask: Task? + private let send: Sender + private let onError: OnError + private let onDrain: OnDrain - private var pending: [Envelope] = [] - private var processingTask: Task? - private let send: Sender - private let onError: OnError - private let onDrain: OnDrain + init( + send: @escaping Sender, + onError: @escaping OnError, + onDrain: @escaping OnDrain + ) { + self.send = send + self.onError = onError + self.onDrain = onDrain + } - init( - send: @escaping Sender, - onError: @escaping OnError, - onDrain: @escaping OnDrain - ) { - self.send = send - self.onError = onError - self.onDrain = onDrain - } - - /// Append an envelope and ensure a drain task is running. - func enqueue(_ envelope: Envelope) { - pending.append(envelope) - ensureDraining() - } + /// Append an envelope and ensure a drain task is running. + func enqueue(_ envelope: Envelope) { + pending.append(envelope) + ensureDraining() + } - #if DEBUG + #if DEBUG /// Number of envelopes waiting. Exposed for tests; no view consumer. - var count: Int { pending.count } + var count: Int { + pending.count + } /// Await the current drain pass to completion. If no drain is in /// progress this returns immediately. Used by tests to synchronize /// on the drain task without polling — production consumers observe /// drain completion via the `onDrain` callback. func awaitDrainCompletion() async { - await processingTask?.value + await processingTask?.value } - #endif + #endif - /// Cancel the in-flight drain task and suppress the auto-respawn that - /// would otherwise follow a `CancellationError` re-insertion. The - /// current send closure's next `await` propagates `CancellationError`, - /// which the `catch is CancellationError` branch handles by - /// re-inserting the envelope; `taskCompleted` then observes - /// `Task.isCancelled` and skips its respawn so the queue goes - /// dormant. A subsequent `enqueue(_:)` schedules a fresh drain task - /// because `processingTask` is back to `nil`. - /// - /// Provided so test teardown can release a SendQueue whose send - /// closure suspends, and so a future production teardown does not - /// leak the actor through an unbounded respawn cycle. - func cancelDrain() { - processingTask?.cancel() - } + /// Cancel the in-flight drain task and suppress the auto-respawn that + /// would otherwise follow a `CancellationError` re-insertion. The + /// current send closure's next `await` propagates `CancellationError`, + /// which the `catch is CancellationError` branch handles by + /// re-inserting the envelope; `taskCompleted` then observes + /// `Task.isCancelled` and skips its respawn so the queue goes + /// dormant. A subsequent `enqueue(_:)` schedules a fresh drain task + /// because `processingTask` is back to `nil`. + /// + /// Provided so test teardown can release a SendQueue whose send + /// closure suspends, and so a future production teardown does not + /// leak the actor through an unbounded respawn cycle. + func cancelDrain() { + processingTask?.cancel() + } - private func ensureDraining() { - // Only one task in flight at a time. A draining task that requeued - // via CancellationError but hasn't yet completed still holds the - // slot; taskCompleted will respawn after it returns. - guard processingTask == nil else { return } - spawnDrainTask() - } + private func ensureDraining() { + // Only one task in flight at a time. A draining task that requeued + // via CancellationError but hasn't yet completed still holds the + // slot; taskCompleted will respawn after it returns. + guard processingTask == nil else { return } + spawnDrainTask() + } - private func spawnDrainTask() { - processingTask = Task { [self] in - await drain() - taskCompleted() - } + private func spawnDrainTask() { + processingTask = Task { [self] in + await drain() + taskCompleted() } + } - private func taskCompleted() { - processingTask = nil - // If the completing task was cancelled (via cancelDrain or an - // upstream cancellation), treat it as an intentional halt — leave - // the queue dormant. A later enqueue still schedules a fresh - // drain task because processingTask is back to nil. - if Task.isCancelled { return } - // A send-closure CancellationError may have re-inserted an envelope - // before this task returned; respawn so that envelope drains. - if !pending.isEmpty { - spawnDrainTask() - } + private func taskCompleted() { + processingTask = nil + // If the completing task was cancelled (via cancelDrain or an + // upstream cancellation), treat it as an intentional halt — leave + // the queue dormant. A later enqueue still schedules a fresh + // drain task because processingTask is back to nil. + if Task.isCancelled { return } + // A send-closure CancellationError may have re-inserted an envelope + // before this task returned; respawn so that envelope drains. + if !pending.isEmpty { + spawnDrainTask() } + } - private func drain() async { - var lastError: Error? - repeat { - while !pending.isEmpty { - let envelope = pending.removeFirst() - do { - try await send(envelope) - } catch is CancellationError { - pending.insert(envelope, at: 0) - return - } catch { - lastError = error - await onError(error, envelope) - } - } - // Outer re-check after onDrain handler suspends. The handler - // typically calls loadMessages/loadConversations, during which an - // enqueue can land. Without this outer pass, that envelope would - // sit in pending with no scheduled drain until a future enqueue. - await onDrain(lastError) - } while !pending.isEmpty - } + private func drain() async { + var lastError: Error? + repeat { + while !pending.isEmpty { + let envelope = pending.removeFirst() + do { + try await send(envelope) + } catch is CancellationError { + pending.insert(envelope, at: 0) + return + } catch { + lastError = error + await onError(error, envelope) + } + } + // Outer re-check after onDrain handler suspends. The handler + // typically calls loadMessages/loadConversations, during which an + // enqueue can land. Without this outer pass, that envelope would + // sit in pending with no scheduled drain until a future enqueue. + await onDrain(lastError) + } while !pending.isEmpty + } } diff --git a/MC1Services/Sources/MC1Services/Services/SettingsEvent.swift b/MC1Services/Sources/MC1Services/Services/SettingsEvent.swift index 2e06927f..f512fa13 100644 --- a/MC1Services/Sources/MC1Services/Services/SettingsEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/SettingsEvent.swift @@ -3,10 +3,10 @@ import MeshCore /// Events emitted by SettingsService when device settings change. public enum SettingsEvent: Sendable { - case deviceUpdated(MeshCore.SelfInfo) - case autoAddConfigUpdated(MeshCore.AutoAddConfig) - case clientRepeatUpdated(Bool) - case pathHashModeUpdated(UInt8) - case allowedRepeatFreqUpdated([MeshCore.FrequencyRange]) - case defaultFloodScopeUpdated(String?) + case deviceUpdated(MeshCore.SelfInfo) + case autoAddConfigUpdated(MeshCore.AutoAddConfig) + case clientRepeatUpdated(Bool) + case pathHashModeUpdated(UInt8) + case allowedRepeatFreqUpdated([MeshCore.FrequencyRange]) + case defaultFloodScopeUpdated(String?) } diff --git a/MC1Services/Sources/MC1Services/Services/SettingsService+Verified.swift b/MC1Services/Sources/MC1Services/Services/SettingsService+Verified.swift index 00f37513..0baf6fce 100644 --- a/MC1Services/Sources/MC1Services/Services/SettingsService+Verified.swift +++ b/MC1Services/Sources/MC1Services/Services/SettingsService+Verified.swift @@ -3,225 +3,227 @@ import MeshCore // MARK: - Verified Settings Methods -extension SettingsService { - - /// Set node name with verification - /// Returns the verified self info for UI update - public func setNodeNameVerified(_ name: String) async throws -> MeshCore.SelfInfo { - let truncated = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - try await setNodeName(truncated) - - let selfInfo = try await getSelfInfo() - - guard selfInfo.name == truncated else { - throw SettingsServiceError.verificationFailed( - expected: truncated, - actual: selfInfo.name - ) - } - - eventContinuation?.yield(.deviceUpdated(selfInfo)) - return selfInfo +public extension SettingsService { + /// Set node name with verification + /// Returns the verified self info for UI update + func setNodeNameVerified(_ name: String) async throws -> MeshCore.SelfInfo { + let truncated = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + try await setNodeName(truncated) + + let selfInfo = try await getSelfInfo() + + guard selfInfo.name == truncated else { + throw SettingsServiceError.verificationFailed( + expected: truncated, + actual: selfInfo.name + ) } - /// Set location with verification - public func setLocationVerified(latitude: Double, longitude: Double) async throws -> MeshCore.SelfInfo { - // Calculate the scaled values we're actually sending - let scaledLatSent = Int32(latitude * 1_000_000) - let scaledLonSent = Int32(longitude * 1_000_000) - - // log when attempting to clear location - let isClearingLocation = scaledLatSent == 0 && scaledLonSent == 0 - logger.debug("[Location] setLocationVerified called - lat: \(latitude), lon: \(longitude), isClearing: \(isClearingLocation)") + eventContinuation?.yield(.deviceUpdated(selfInfo)) + return selfInfo + } - try await setLocation(latitude: latitude, longitude: longitude) + /// Set location with verification + func setLocationVerified(latitude: Double, longitude: Double) async throws -> MeshCore.SelfInfo { + // Calculate the scaled values we're actually sending + let scaledLatSent = Int32(latitude * 1_000_000) + let scaledLonSent = Int32(longitude * 1_000_000) - // Read back and compare at scaled integer level for precise diagnostics - let selfInfo = try await getSelfInfo() - let scaledLatReceived = Int32(selfInfo.latitude * 1_000_000) - let scaledLonReceived = Int32(selfInfo.longitude * 1_000_000) + // log when attempting to clear location + let isClearingLocation = scaledLatSent == 0 && scaledLonSent == 0 + logger.debug("[Location] setLocationVerified called - lat: \(latitude), lon: \(longitude), isClearing: \(isClearingLocation)") - let latDiff = abs(scaledLatSent - scaledLatReceived) - let lonDiff = abs(scaledLonSent - scaledLonReceived) + try await setLocation(latitude: latitude, longitude: longitude) - // Tolerance of 2 scaled units (~0.2m) handles floating-point conversion - let tolerance: Int32 = 2 + // Read back and compare at scaled integer level for precise diagnostics + let selfInfo = try await getSelfInfo() + let scaledLatReceived = Int32(selfInfo.latitude * 1_000_000) + let scaledLonReceived = Int32(selfInfo.longitude * 1_000_000) - guard latDiff <= tolerance && lonDiff <= tolerance else { - logger.error("[Location] Verification failed - sent: (\(scaledLatSent), \(scaledLonSent)), received: (\(scaledLatReceived), \(scaledLonReceived)), diff: (lat=\(latDiff), lon=\(lonDiff))") + let latDiff = abs(scaledLatSent - scaledLatReceived) + let lonDiff = abs(scaledLonSent - scaledLonReceived) - if isClearingLocation { - logger.warning("[Location] Clear location failed - device reports non-zero coordinates. Device may have active GPS or firmware doesn't support (0,0).") - } + // Tolerance of 2 scaled units (~0.2m) handles floating-point conversion + let tolerance: Int32 = 2 - let expectedLat = Double(scaledLatSent) / 1_000_000 - let expectedLon = Double(scaledLonSent) / 1_000_000 - throw SettingsServiceError.verificationFailed( - expected: "(\(expectedLat), \(expectedLon))", - actual: "(\(selfInfo.latitude), \(selfInfo.longitude))" - ) - } + guard latDiff <= tolerance, lonDiff <= tolerance else { + logger.error("[Location] Verification failed - sent: (\(scaledLatSent), \(scaledLonSent)), received: (\(scaledLatReceived), \(scaledLonReceived)), diff: (lat=\(latDiff), lon=\(lonDiff))") - eventContinuation?.yield(.deviceUpdated(selfInfo)) - return selfInfo - } + if isClearingLocation { + logger.warning("[Location] Clear location failed - device reports non-zero coordinates. Device may have active GPS or firmware doesn't support (0,0).") + } - /// Set a manual location, turning off device GPS first when needed so the value persists. - public func setManualLocationVerified(latitude: Double, longitude: Double) async throws -> MeshCore.SelfInfo { - let gpsState = try await getDeviceGPSState() - if gpsState.isSupported, gpsState.isEnabled { - _ = try await setDeviceGPSEnabledVerified(false) - } - return try await setLocationVerified(latitude: latitude, longitude: longitude) + let expectedLat = Double(scaledLatSent) / 1_000_000 + let expectedLon = Double(scaledLonSent) / 1_000_000 + throw SettingsServiceError.verificationFailed( + expected: "(\(expectedLat), \(expectedLon))", + actual: "(\(selfInfo.latitude), \(selfInfo.longitude))" + ) } - /// Set radio parameters with verification. - /// - /// Same unit conventions as `setRadioParams(frequencyKHz:bandwidthKHz:...)` — - /// `frequencyKHz` is in kHz (869618 → 869.618 MHz) and `bandwidthKHz` is in Hz - /// (62500 → 62.5 kHz) despite the suffix. See that method for the full rationale. - public func setRadioParamsVerified( - frequencyKHz: UInt32, - bandwidthKHz: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8, - clientRepeat: Bool? = nil - ) async throws -> MeshCore.SelfInfo { - logger.info("[Radio] Sending params: freq=\(frequencyKHz)kHz, bw=\(bandwidthKHz)Hz, sf=\(spreadingFactor), cr=\(codingRate), repeat=\(String(describing: clientRepeat))") - - try await setRadioParams( - frequencyKHz: frequencyKHz, - bandwidthKHz: bandwidthKHz, - spreadingFactor: spreadingFactor, - codingRate: codingRate, - clientRepeat: clientRepeat - ) + eventContinuation?.yield(.deviceUpdated(selfInfo)) + return selfInfo + } - let selfInfo = try await getSelfInfo() - - let expectedFreqMHz = Double(frequencyKHz) / 1000.0 - let expectedBwMHz = Double(bandwidthKHz) / 1000.0 - - guard abs(selfInfo.radioFrequency - expectedFreqMHz) < 0.001 && - abs(selfInfo.radioBandwidth - expectedBwMHz) < 0.001 && - selfInfo.radioSpreadingFactor == spreadingFactor && - selfInfo.radioCodingRate == codingRate else { - // swiftlint:disable:next line_length - logger.warning("[Radio] Verification failed - expected: freq=\(expectedFreqMHz)MHz, bw=\(expectedBwMHz)kHz, sf=\(spreadingFactor), cr=\(codingRate); device reports: freq=\(selfInfo.radioFrequency)MHz, bw=\(selfInfo.radioBandwidth)kHz, sf=\(selfInfo.radioSpreadingFactor), cr=\(selfInfo.radioCodingRate)") - throw SettingsServiceError.verificationFailed( - expected: "freq=\(frequencyKHz), bw=\(bandwidthKHz), sf=\(spreadingFactor), cr=\(codingRate)", - actual: "freq=\(selfInfo.radioFrequency), bw=\(selfInfo.radioBandwidth), sf=\(selfInfo.radioSpreadingFactor), cr=\(selfInfo.radioCodingRate)" - ) - } - - // Verify clientRepeat via queryDevice if it was explicitly set - if let expectedRepeat = clientRepeat { - let capabilities = try await queryDevice() - guard capabilities.clientRepeat == expectedRepeat else { - logger.warning("[Radio] Client repeat verification failed - expected: \(expectedRepeat), device reports: \(capabilities.clientRepeat)") - throw SettingsServiceError.verificationFailed( - expected: "clientRepeat=\(expectedRepeat)", - actual: "clientRepeat=\(capabilities.clientRepeat)" - ) - } - logger.info("[Radio] Client repeat verified: \(expectedRepeat)") - eventContinuation?.yield(.clientRepeatUpdated(expectedRepeat)) - } - - logger.info("[Radio] Params verified successfully") - eventContinuation?.yield(.deviceUpdated(selfInfo)) - return selfInfo + /// Set a manual location, turning off device GPS first when needed so the value persists. + func setManualLocationVerified(latitude: Double, longitude: Double) async throws -> MeshCore.SelfInfo { + let gpsState = try await getDeviceGPSState() + if gpsState.isSupported, gpsState.isEnabled { + _ = try await setDeviceGPSEnabledVerified(false) } - - /// Apply radio preset with verification - public func applyRadioPresetVerified(_ preset: RadioPreset) async throws -> MeshCore.SelfInfo { - logger.info("[Radio] Applying preset: \(preset.name) (\(preset.id))") - return try await setRadioParamsVerified( - frequencyKHz: preset.frequencyKHz, - bandwidthKHz: preset.bandwidthHz, - spreadingFactor: preset.spreadingFactor, - codingRate: preset.codingRate + return try await setLocationVerified(latitude: latitude, longitude: longitude) + } + + /// Set radio parameters with verification. + /// + /// Same unit conventions as `setRadioParams(frequencyKHz:bandwidthKHz:...)` — + /// `frequencyKHz` is in kHz (869618 → 869.618 MHz) and `bandwidthKHz` is in Hz + /// (62500 → 62.5 kHz) despite the suffix. See that method for the full rationale. + func setRadioParamsVerified( + frequencyKHz: UInt32, + bandwidthKHz: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8, + clientRepeat: Bool? = nil + ) async throws -> MeshCore.SelfInfo { + logger.info("[Radio] Sending params: freq=\(frequencyKHz)kHz, bw=\(bandwidthKHz)Hz, sf=\(spreadingFactor), cr=\(codingRate), repeat=\(String(describing: clientRepeat))") + + try await setRadioParams( + frequencyKHz: frequencyKHz, + bandwidthKHz: bandwidthKHz, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + clientRepeat: clientRepeat + ) + + let selfInfo = try await getSelfInfo() + + let expectedFreqMHz = Double(frequencyKHz) / 1000.0 + let expectedBwMHz = Double(bandwidthKHz) / 1000.0 + + guard abs(selfInfo.radioFrequency - expectedFreqMHz) < 0.001, + abs(selfInfo.radioBandwidth - expectedBwMHz) < 0.001, + selfInfo.radioSpreadingFactor == spreadingFactor, + selfInfo.radioCodingRate == codingRate else { + // swiftlint:disable:next line_length + logger + .warning( + "[Radio] Verification failed - expected: freq=\(expectedFreqMHz)MHz, bw=\(expectedBwMHz)kHz, sf=\(spreadingFactor), cr=\(codingRate); device reports: freq=\(selfInfo.radioFrequency)MHz, bw=\(selfInfo.radioBandwidth)kHz, sf=\(selfInfo.radioSpreadingFactor), cr=\(selfInfo.radioCodingRate)" ) + throw SettingsServiceError.verificationFailed( + expected: "freq=\(frequencyKHz), bw=\(bandwidthKHz), sf=\(spreadingFactor), cr=\(codingRate)", + actual: "freq=\(selfInfo.radioFrequency), bw=\(selfInfo.radioBandwidth), sf=\(selfInfo.radioSpreadingFactor), cr=\(selfInfo.radioCodingRate)" + ) } - /// Set TX power with verification - public func setTxPowerVerified(_ power: Int8) async throws -> MeshCore.SelfInfo { - logger.info("[Radio] Sending TX power: \(power)dBm") - - try await setTxPower(power) - - let selfInfo = try await getSelfInfo() - - guard selfInfo.txPower == power else { - logger.warning("[Radio] TX power verification failed - expected: \(power)dBm, device reports: \(selfInfo.txPower)dBm") - throw SettingsServiceError.verificationFailed( - expected: "\(power)", - actual: "\(selfInfo.txPower)" - ) - } - - logger.info("[Radio] TX power verified: \(power)dBm") - eventContinuation?.yield(.deviceUpdated(selfInfo)) - return selfInfo - } - - /// Set other params with verification - public func setOtherParamsVerified( - autoAddContacts: Bool, - telemetryModes: TelemetryModes, - advertLocationPolicy: AdvertLocationPolicy, - multiAcks: UInt8 - ) async throws -> MeshCore.SelfInfo { - try await setOtherParams( - autoAddContacts: autoAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicy: advertLocationPolicy, - multiAcks: multiAcks + // Verify clientRepeat via queryDevice if it was explicitly set + if let expectedRepeat = clientRepeat { + let capabilities = try await queryDevice() + guard capabilities.clientRepeat == expectedRepeat else { + logger.warning("[Radio] Client repeat verification failed - expected: \(expectedRepeat), device reports: \(capabilities.clientRepeat)") + throw SettingsServiceError.verificationFailed( + expected: "clientRepeat=\(expectedRepeat)", + actual: "clientRepeat=\(capabilities.clientRepeat)" ) - - let selfInfo = try await getSelfInfo() - - // manualAddContacts is inverted (false = auto-add enabled) - guard selfInfo.manualAddContacts != autoAddContacts else { - throw SettingsServiceError.verificationFailed( - expected: "autoAdd=\(autoAddContacts)", - actual: "autoAdd=\(!selfInfo.manualAddContacts)" - ) - } - - eventContinuation?.yield(.deviceUpdated(selfInfo)) - return selfInfo + } + logger.info("[Radio] Client repeat verified: \(expectedRepeat)") + eventContinuation?.yield(.clientRepeatUpdated(expectedRepeat)) } - /// Convenience overload: uses the device's current values as defaults, overriding only the supplied parameters. - public func setOtherParamsVerified( - from device: DeviceDTO, - autoAddContacts: Bool? = nil, - telemetryModes: TelemetryModes? = nil, - advertLocationPolicy: AdvertLocationPolicy? = nil, - multiAcks: UInt8? = nil - ) async throws -> MeshCore.SelfInfo { - try await setOtherParamsVerified( - autoAddContacts: autoAddContacts ?? !device.manualAddContacts, - telemetryModes: telemetryModes ?? device.telemetryModes, - advertLocationPolicy: advertLocationPolicy ?? device.advertLocationPolicyMode, - multiAcks: multiAcks ?? device.multiAcks - ) + logger.info("[Radio] Params verified successfully") + eventContinuation?.yield(.deviceUpdated(selfInfo)) + return selfInfo + } + + /// Apply radio preset with verification + func applyRadioPresetVerified(_ preset: RadioPreset) async throws -> MeshCore.SelfInfo { + logger.info("[Radio] Applying preset: \(preset.name) (\(preset.id))") + return try await setRadioParamsVerified( + frequencyKHz: preset.frequencyKHz, + bandwidthKHz: preset.bandwidthHz, + spreadingFactor: preset.spreadingFactor, + codingRate: preset.codingRate + ) + } + + /// Set TX power with verification + func setTxPowerVerified(_ power: Int8) async throws -> MeshCore.SelfInfo { + logger.info("[Radio] Sending TX power: \(power)dBm") + + try await setTxPower(power) + + let selfInfo = try await getSelfInfo() + + guard selfInfo.txPower == power else { + logger.warning("[Radio] TX power verification failed - expected: \(power)dBm, device reports: \(selfInfo.txPower)dBm") + throw SettingsServiceError.verificationFailed( + expected: "\(power)", + actual: "\(selfInfo.txPower)" + ) } - /// Compatibility overload: map boolean sharing to `prefs` policy when enabled. - @available(*, deprecated, message: "Use advertLocationPolicy overload instead") - public func setOtherParamsVerified( - autoAddContacts: Bool, - telemetryModes: TelemetryModes, - shareLocationPublicly: Bool, - multiAcks: UInt8 - ) async throws -> MeshCore.SelfInfo { - try await setOtherParamsVerified( - autoAddContacts: autoAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicy: shareLocationPublicly ? .prefs : .none, - multiAcks: multiAcks - ) + logger.info("[Radio] TX power verified: \(power)dBm") + eventContinuation?.yield(.deviceUpdated(selfInfo)) + return selfInfo + } + + /// Set other params with verification + func setOtherParamsVerified( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + advertLocationPolicy: AdvertLocationPolicy, + multiAcks: UInt8 + ) async throws -> MeshCore.SelfInfo { + try await setOtherParams( + autoAddContacts: autoAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: advertLocationPolicy, + multiAcks: multiAcks + ) + + let selfInfo = try await getSelfInfo() + + // manualAddContacts is inverted (false = auto-add enabled) + guard selfInfo.manualAddContacts != autoAddContacts else { + throw SettingsServiceError.verificationFailed( + expected: "autoAdd=\(autoAddContacts)", + actual: "autoAdd=\(!selfInfo.manualAddContacts)" + ) } + + eventContinuation?.yield(.deviceUpdated(selfInfo)) + return selfInfo + } + + /// Convenience overload: uses the device's current values as defaults, overriding only the supplied parameters. + func setOtherParamsVerified( + from device: DeviceDTO, + autoAddContacts: Bool? = nil, + telemetryModes: TelemetryModes? = nil, + advertLocationPolicy: AdvertLocationPolicy? = nil, + multiAcks: UInt8? = nil + ) async throws -> MeshCore.SelfInfo { + try await setOtherParamsVerified( + autoAddContacts: autoAddContacts ?? !device.manualAddContacts, + telemetryModes: telemetryModes ?? device.telemetryModes, + advertLocationPolicy: advertLocationPolicy ?? device.advertLocationPolicyMode, + multiAcks: multiAcks ?? device.multiAcks + ) + } + + /// Compatibility overload: map boolean sharing to `prefs` policy when enabled. + @available(*, deprecated, message: "Use advertLocationPolicy overload instead") + func setOtherParamsVerified( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + shareLocationPublicly: Bool, + multiAcks: UInt8 + ) async throws -> MeshCore.SelfInfo { + try await setOtherParamsVerified( + autoAddContacts: autoAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: shareLocationPublicly ? .prefs : .none, + multiAcks: multiAcks + ) + } } diff --git a/MC1Services/Sources/MC1Services/Services/SettingsService.swift b/MC1Services/Sources/MC1Services/Services/SettingsService.swift index 51104975..631251ad 100644 --- a/MC1Services/Sources/MC1Services/Services/SettingsService.swift +++ b/MC1Services/Sources/MC1Services/Services/SettingsService.swift @@ -7,506 +7,506 @@ import os /// Service for managing device settings via MeshCore session. /// Handles radio configuration, node settings, Bluetooth settings, and device info. public actor SettingsService { - private let session: any ConfigurationSessionOps - let logger = PersistentLogger(subsystem: "com.mc1", category: "SettingsService") - - // Event stream subscriber. The subscription ID lets a replaced subscriber's - // onTermination distinguish itself from the subscriber that replaced it. - var eventContinuation: AsyncStream.Continuation? - private var eventSubscriptionID: UUID? - - public init(session: any ConfigurationSessionOps) { - self.session = session - } - - /// Stream of settings change events. - /// Only one active subscriber is supported. Subsequent calls replace the previous subscriber. - public func events() -> AsyncStream { - // Register synchronously so events yielded right after this call - // returns are not dropped behind a registration Task. - let (stream, continuation) = AsyncStream.makeStream(of: SettingsEvent.self) - let subscriptionID = UUID() - setContinuation(continuation, subscriptionID: subscriptionID) - continuation.onTermination = { @Sendable _ in - Task { await self.clearContinuation(subscriptionID: subscriptionID) } - } - return stream - } - - private func setContinuation( - _ continuation: AsyncStream.Continuation, - subscriptionID: UUID - ) { - if eventContinuation != nil { - logger.warning("Replacing existing SettingsService event stream subscriber") - } - eventContinuation?.finish() - eventContinuation = continuation - eventSubscriptionID = subscriptionID - } - - /// Clears the continuation only while the terminating subscription is still - /// current, so a replaced subscriber cannot disconnect its replacement. - private func clearContinuation(subscriptionID: UUID) { - guard eventSubscriptionID == subscriptionID else { return } - eventContinuation = nil - eventSubscriptionID = nil - } - - // MARK: - Radio Settings - - /// Apply a radio preset to the device - public func applyRadioPreset(_ preset: RadioPreset) async throws { - try await setRadioParams( - frequencyKHz: preset.frequencyKHz, - bandwidthKHz: preset.bandwidthHz, - spreadingFactor: preset.spreadingFactor, - codingRate: preset.codingRate - ) - } - - /// Set radio parameters manually. - /// - /// Both numeric parameters are integer values that get divided by 1000 before being - /// forwarded to `session.setRadio`. Pass values in the same scaled-integer form that - /// `RadioPreset.frequencyKHz` and `RadioPreset.bandwidthHz` use: - /// - `frequencyKHz`: frequency expressed in kHz (e.g. `869618` → 869.618 MHz on the wire) - /// - `bandwidthKHz`: bandwidth expressed in Hz (e.g. `62500` → 62.5 kHz on the wire) - /// - /// The `bandwidthKHz` name is preserved for source compatibility despite the value - /// actually being in Hz; do not pass `Int(62.5)` here. - public func setRadioParams( - frequencyKHz: UInt32, - bandwidthKHz: UInt32, - spreadingFactor: UInt8, - codingRate: UInt8, - clientRepeat: Bool? = nil - ) async throws { - do { - try await session.setRadio( - frequency: Double(frequencyKHz) / 1000.0, - bandwidth: Double(bandwidthKHz) / 1000.0, - spreadingFactor: spreadingFactor, - codingRate: codingRate, - clientRepeat: clientRepeat - ) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Set transmit power - public func setTxPower(_ power: Int8) async throws { - do { - try await session.setTxPower(power) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Node Settings - - /// Set the publicly visible node name - public func setNodeName(_ name: String) async throws { - let truncated = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) - do { - try await session.setName(truncated) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Set node location (latitude/longitude in degrees) - public func setLocation(latitude: Double, longitude: Double) async throws { - do { - try await session.setCoordinates(latitude: latitude, longitude: longitude) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Bluetooth Settings - - /// Set BLE PIN (0 = disabled/random, 100000-999999 = fixed PIN) - public func setBlePin(_ pin: UInt32) async throws { - do { - try await session.setDevicePin(pin) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Other Settings - - /// Set other device parameters (contacts, telemetry, location policy) - public func setOtherParams( - autoAddContacts: Bool, - telemetryModes: TelemetryModes, - advertLocationPolicy: AdvertLocationPolicy, - multiAcks: UInt8 - ) async throws { - try await setOtherParams( - autoAddContacts: autoAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicyRaw: advertLocationPolicy.rawValue, - multiAcks: multiAcks - ) - } - - /// Set other device parameters, taking the advertisement location policy as a raw byte. - /// - /// Used by config import so a policy value not modeled by ``AdvertLocationPolicy`` (e.g. from - /// newer firmware) is forwarded to the device verbatim instead of being coerced. - public func setOtherParams( - autoAddContacts: Bool, - telemetryModes: TelemetryModes, - advertLocationPolicyRaw: UInt8, - multiAcks: UInt8 - ) async throws { - do { - try await session.setOtherParams( - manualAddContacts: !autoAddContacts, - telemetryModeEnvironment: telemetryModes.environment, - telemetryModeLocation: telemetryModes.location, - telemetryModeBase: telemetryModes.base, - advertisementLocationPolicy: advertLocationPolicyRaw, - multiAcks: multiAcks - ) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Compatibility overload: map boolean sharing to `prefs` policy when enabled. - @available(*, deprecated, message: "Use advertLocationPolicy overload instead") - public func setOtherParams( - autoAddContacts: Bool, - telemetryModes: TelemetryModes, - shareLocationPublicly: Bool, - multiAcks: UInt8 - ) async throws { - try await setOtherParams( - autoAddContacts: autoAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicy: shareLocationPublicly ? .prefs : .none, - multiAcks: multiAcks - ) - } - - // MARK: - Factory Reset - - /// Perform factory reset on device - public func factoryReset() async throws { - do { - try await session.factoryReset() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Reboot the device - public func reboot() async throws { - do { - try await session.reboot() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Device Info - - /// Fetch battery and storage information from device - /// - Returns: BatteryInfo with current values - /// - Throws: SettingsServiceError if not connected or communication fails - public func getBattery() async throws -> BatteryInfo { - do { - return try await session.getBattery() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Query device capabilities - public func queryDevice() async throws -> DeviceCapabilities { - do { - return try await session.queryDevice() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Get self info by sending appStart - public func getSelfInfo() async throws -> MeshCore.SelfInfo { - do { - return try await session.sendAppStart() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Auto-Add Config - - /// Get auto-add configuration from device - public func getAutoAddConfig() async throws -> MeshCore.AutoAddConfig { - do { - return try await session.getAutoAddConfig() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Refresh auto-add config from device (for initial load) - /// Fetches current value and triggers callback to update connected device - public func refreshAutoAddConfig() async throws { - let config = try await getAutoAddConfig() - eventContinuation?.yield(.autoAddConfigUpdated(config)) - } - - // MARK: - Repeat Frequency Ranges - - /// Get allowed repeat frequency ranges from device - private func getRepeatFreq() async throws -> [MeshCore.FrequencyRange] { - do { - return try await session.getRepeatFreq() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Refresh repeat frequency ranges from device and notify observers - public func refreshRepeatFreqRanges() async throws { - let ranges = try await getRepeatFreq() - eventContinuation?.yield(.allowedRepeatFreqUpdated(ranges)) - } - - /// Refresh device info from the device and notify observers. - /// Use this instead of `setLocationVerified` when the device already has correct coordinates (e.g. from its own GPS). - public func refreshDeviceInfo() async throws { - let selfInfo = try await getSelfInfo() - eventContinuation?.yield(.deviceUpdated(selfInfo)) - } - - /// Set auto-add configuration on device - public func setAutoAddConfig(_ config: MeshCore.AutoAddConfig) async throws { - do { - try await session.setAutoAddConfig(config) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Set auto-add configuration with verification - public func setAutoAddConfigVerified(_ config: MeshCore.AutoAddConfig) async throws -> MeshCore.AutoAddConfig { - try await setAutoAddConfig(config) - - let actualConfig = try await getAutoAddConfig() - - guard actualConfig == config else { - throw SettingsServiceError.verificationFailed( - expected: "bitmask=\(config.bitmask), maxHops=\(config.maxHops)", - actual: "bitmask=\(actualConfig.bitmask), maxHops=\(actualConfig.maxHops)" - ) - } - - eventContinuation?.yield(.autoAddConfigUpdated(actualConfig)) - return actualConfig - } - - // MARK: - Path Hash Mode - - /// Sets the path hash mode on the device. - /// - /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). - public func setPathHashMode(_ mode: UInt8) async throws { - do { - try await session.setPathHashMode(mode) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Sets the path hash mode with verification via queryDevice. - /// - /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). - /// - Returns: The verified mode value from the device. - public func setPathHashModeVerified(_ mode: UInt8) async throws -> UInt8 { - try await setPathHashMode(mode) - - let capabilities = try await queryDevice() - guard capabilities.pathHashMode == mode else { - throw SettingsServiceError.verificationFailed( - expected: "pathHashMode=\(mode)", - actual: "pathHashMode=\(capabilities.pathHashMode)" - ) - } - - eventContinuation?.yield(.pathHashModeUpdated(mode)) - return mode - } - - // MARK: - Default Flood Scope - - /// Fetches the device's persisted default flood scope. - /// - /// Requires firmware v11+; older firmware rejects the opcode and surfaces - /// ``SettingsServiceError/sessionError(_:)`` with `MeshCoreError.deviceError`. - /// - /// - Returns: The persisted scope name, or `nil` when none is configured. - public func getDefaultFloodScope() async throws -> String? { - do { - let scope = try await session.getDefaultFloodScope() - let name = scope?.name - eventContinuation?.yield(.defaultFloodScopeUpdated(name)) - return name - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Persists the device's default flood scope and verifies via a follow-up read. - /// - /// Passing `nil` for `name` clears the persisted scope. Non-nil names are sent as - /// ``MeshCore/FloodScope/region(_:)`` — firmware derives the key and stores both. - /// Names are truncated to ``ProtocolLimits/maxDefaultFloodScopeNameBytes`` UTF-8 bytes - /// before both key derivation and send, so the stored display and derived scope key - /// agree on the same byte sequence. - /// - /// - Parameter name: Region name to persist, or `nil` to clear. - /// - Returns: The verified name read back from the device. - public func setDefaultFloodScopeVerified(name: String?) async throws -> String? { - let expected: String? = (name?.isEmpty == false) - ? name?.utf8Prefix(maxBytes: ProtocolLimits.maxDefaultFloodScopeNameBytes) - : nil - do { - if let expected { - try await session.setDefaultFloodScope(name: expected, scope: .region(expected)) - } else { - try await session.setDefaultFloodScope(name: "", scope: .disabled) - } - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - - let actual = try await getDefaultFloodScope() - guard actual == expected else { - throw SettingsServiceError.verificationFailed( - expected: expected ?? "(cleared)", - actual: actual ?? "(cleared)" - ) - } - return actual - } - - // MARK: - Stats - - /// Get core statistics - public func getStatsCore() async throws -> CoreStats { - do { - return try await session.getStatsCore() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Get radio statistics - public func getStatsRadio() async throws -> RadioStats { - do { - return try await session.getStatsRadio() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Get packet statistics - public func getStatsPackets() async throws -> PacketStats { - do { - return try await session.getStatsPackets() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - // MARK: - Custom Variables - - /// Get custom variables from device - public func getCustomVars() async throws -> [String: String] { - do { - return try await session.getCustomVars() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - public func getDeviceGPSState() async throws -> DeviceGPSState { - let vars = try await getCustomVars() - return Self.deviceGPSState(from: vars) - } - - /// Set a custom variable on device - public func setCustomVar(key: String, value: String) async throws { - do { - try await session.setCustomVar(key: key, value: value) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - public func setDeviceGPSEnabledVerified(_ enabled: Bool) async throws -> DeviceGPSState { - try await setCustomVar(key: "gps", value: enabled ? "1" : "0") - - let state = try await getDeviceGPSState() - guard state.isSupported else { - throw SettingsServiceError.deviceGPSVerificationFailed( - expectedEnabled: enabled, - actualEnabled: false - ) - } - guard state.isEnabled == enabled else { - throw SettingsServiceError.deviceGPSVerificationFailed( - expectedEnabled: enabled, - actualEnabled: state.isEnabled - ) - } - - try await refreshDeviceInfo() - return state - } - - // MARK: - Private Key Management - - /// Export private key from device - public func exportPrivateKey() async throws -> Data { - do { - return try await session.exportPrivateKey() - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } - - /// Import private key to device - public func importPrivateKey(_ key: Data) async throws { - do { - try await session.importPrivateKey(key) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } + private let session: any ConfigurationSessionOps + let logger = PersistentLogger(subsystem: "com.mc1", category: "SettingsService") + + // Event stream subscriber. The subscription ID lets a replaced subscriber's + // onTermination distinguish itself from the subscriber that replaced it. + var eventContinuation: AsyncStream.Continuation? + private var eventSubscriptionID: UUID? + + public init(session: any ConfigurationSessionOps) { + self.session = session + } + + /// Stream of settings change events. + /// Only one active subscriber is supported. Subsequent calls replace the previous subscriber. + public func events() -> AsyncStream { + // Register synchronously so events yielded right after this call + // returns are not dropped behind a registration Task. + let (stream, continuation) = AsyncStream.makeStream(of: SettingsEvent.self) + let subscriptionID = UUID() + setContinuation(continuation, subscriptionID: subscriptionID) + continuation.onTermination = { @Sendable _ in + Task { await self.clearContinuation(subscriptionID: subscriptionID) } + } + return stream + } + + private func setContinuation( + _ continuation: AsyncStream.Continuation, + subscriptionID: UUID + ) { + if eventContinuation != nil { + logger.warning("Replacing existing SettingsService event stream subscriber") + } + eventContinuation?.finish() + eventContinuation = continuation + eventSubscriptionID = subscriptionID + } + + /// Clears the continuation only while the terminating subscription is still + /// current, so a replaced subscriber cannot disconnect its replacement. + private func clearContinuation(subscriptionID: UUID) { + guard eventSubscriptionID == subscriptionID else { return } + eventContinuation = nil + eventSubscriptionID = nil + } + + // MARK: - Radio Settings + + /// Apply a radio preset to the device + public func applyRadioPreset(_ preset: RadioPreset) async throws { + try await setRadioParams( + frequencyKHz: preset.frequencyKHz, + bandwidthKHz: preset.bandwidthHz, + spreadingFactor: preset.spreadingFactor, + codingRate: preset.codingRate + ) + } + + /// Set radio parameters manually. + /// + /// Both numeric parameters are integer values that get divided by 1000 before being + /// forwarded to `session.setRadio`. Pass values in the same scaled-integer form that + /// `RadioPreset.frequencyKHz` and `RadioPreset.bandwidthHz` use: + /// - `frequencyKHz`: frequency expressed in kHz (e.g. `869618` → 869.618 MHz on the wire) + /// - `bandwidthKHz`: bandwidth expressed in Hz (e.g. `62500` → 62.5 kHz on the wire) + /// + /// The `bandwidthKHz` name is preserved for source compatibility despite the value + /// actually being in Hz; do not pass `Int(62.5)` here. + public func setRadioParams( + frequencyKHz: UInt32, + bandwidthKHz: UInt32, + spreadingFactor: UInt8, + codingRate: UInt8, + clientRepeat: Bool? = nil + ) async throws { + do { + try await session.setRadio( + frequency: Double(frequencyKHz) / 1000.0, + bandwidth: Double(bandwidthKHz) / 1000.0, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + clientRepeat: clientRepeat + ) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Set transmit power + public func setTxPower(_ power: Int8) async throws { + do { + try await session.setTxPower(power) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Node Settings + + /// Set the publicly visible node name + public func setNodeName(_ name: String) async throws { + let truncated = name.utf8Prefix(maxBytes: ProtocolLimits.maxUsableNameBytes) + do { + try await session.setName(truncated) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Set node location (latitude/longitude in degrees) + public func setLocation(latitude: Double, longitude: Double) async throws { + do { + try await session.setCoordinates(latitude: latitude, longitude: longitude) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Bluetooth Settings + + /// Set BLE PIN (0 = disabled/random, 100000-999999 = fixed PIN) + public func setBlePin(_ pin: UInt32) async throws { + do { + try await session.setDevicePin(pin) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Other Settings + + /// Set other device parameters (contacts, telemetry, location policy) + public func setOtherParams( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + advertLocationPolicy: AdvertLocationPolicy, + multiAcks: UInt8 + ) async throws { + try await setOtherParams( + autoAddContacts: autoAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicyRaw: advertLocationPolicy.rawValue, + multiAcks: multiAcks + ) + } + + /// Set other device parameters, taking the advertisement location policy as a raw byte. + /// + /// Used by config import so a policy value not modeled by ``AdvertLocationPolicy`` (e.g. from + /// newer firmware) is forwarded to the device verbatim instead of being coerced. + public func setOtherParams( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + advertLocationPolicyRaw: UInt8, + multiAcks: UInt8 + ) async throws { + do { + try await session.setOtherParams( + manualAddContacts: !autoAddContacts, + telemetryModeEnvironment: telemetryModes.environment, + telemetryModeLocation: telemetryModes.location, + telemetryModeBase: telemetryModes.base, + advertisementLocationPolicy: advertLocationPolicyRaw, + multiAcks: multiAcks + ) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Compatibility overload: map boolean sharing to `prefs` policy when enabled. + @available(*, deprecated, message: "Use advertLocationPolicy overload instead") + public func setOtherParams( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + shareLocationPublicly: Bool, + multiAcks: UInt8 + ) async throws { + try await setOtherParams( + autoAddContacts: autoAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: shareLocationPublicly ? .prefs : .none, + multiAcks: multiAcks + ) + } + + // MARK: - Factory Reset + + /// Perform factory reset on device + public func factoryReset() async throws { + do { + try await session.factoryReset() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Reboot the device + public func reboot() async throws { + do { + try await session.reboot() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Device Info + + /// Fetch battery and storage information from device + /// - Returns: BatteryInfo with current values + /// - Throws: SettingsServiceError if not connected or communication fails + public func getBattery() async throws -> BatteryInfo { + do { + return try await session.getBattery() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Query device capabilities + public func queryDevice() async throws -> DeviceCapabilities { + do { + return try await session.queryDevice() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Get self info by sending appStart + public func getSelfInfo() async throws -> MeshCore.SelfInfo { + do { + return try await session.sendAppStart() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Auto-Add Config + + /// Get auto-add configuration from device + public func getAutoAddConfig() async throws -> MeshCore.AutoAddConfig { + do { + return try await session.getAutoAddConfig() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Refresh auto-add config from device (for initial load) + /// Fetches current value and triggers callback to update connected device + public func refreshAutoAddConfig() async throws { + let config = try await getAutoAddConfig() + eventContinuation?.yield(.autoAddConfigUpdated(config)) + } + + // MARK: - Repeat Frequency Ranges + + /// Get allowed repeat frequency ranges from device + private func getRepeatFreq() async throws -> [MeshCore.FrequencyRange] { + do { + return try await session.getRepeatFreq() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Refresh repeat frequency ranges from device and notify observers + public func refreshRepeatFreqRanges() async throws { + let ranges = try await getRepeatFreq() + eventContinuation?.yield(.allowedRepeatFreqUpdated(ranges)) + } + + /// Refresh device info from the device and notify observers. + /// Use this instead of `setLocationVerified` when the device already has correct coordinates (e.g. from its own GPS). + public func refreshDeviceInfo() async throws { + let selfInfo = try await getSelfInfo() + eventContinuation?.yield(.deviceUpdated(selfInfo)) + } + + /// Set auto-add configuration on device + public func setAutoAddConfig(_ config: MeshCore.AutoAddConfig) async throws { + do { + try await session.setAutoAddConfig(config) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Set auto-add configuration with verification + public func setAutoAddConfigVerified(_ config: MeshCore.AutoAddConfig) async throws -> MeshCore.AutoAddConfig { + try await setAutoAddConfig(config) + + let actualConfig = try await getAutoAddConfig() + + guard actualConfig == config else { + throw SettingsServiceError.verificationFailed( + expected: "bitmask=\(config.bitmask), maxHops=\(config.maxHops)", + actual: "bitmask=\(actualConfig.bitmask), maxHops=\(actualConfig.maxHops)" + ) + } + + eventContinuation?.yield(.autoAddConfigUpdated(actualConfig)) + return actualConfig + } + + // MARK: - Path Hash Mode + + /// Sets the path hash mode on the device. + /// + /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). + public func setPathHashMode(_ mode: UInt8) async throws { + do { + try await session.setPathHashMode(mode) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Sets the path hash mode with verification via queryDevice. + /// + /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). + /// - Returns: The verified mode value from the device. + public func setPathHashModeVerified(_ mode: UInt8) async throws -> UInt8 { + try await setPathHashMode(mode) + + let capabilities = try await queryDevice() + guard capabilities.pathHashMode == mode else { + throw SettingsServiceError.verificationFailed( + expected: "pathHashMode=\(mode)", + actual: "pathHashMode=\(capabilities.pathHashMode)" + ) + } + + eventContinuation?.yield(.pathHashModeUpdated(mode)) + return mode + } + + // MARK: - Default Flood Scope + + /// Fetches the device's persisted default flood scope. + /// + /// Requires firmware v11+; older firmware rejects the opcode and surfaces + /// ``SettingsServiceError/sessionError(_:)`` with `MeshCoreError.deviceError`. + /// + /// - Returns: The persisted scope name, or `nil` when none is configured. + public func getDefaultFloodScope() async throws -> String? { + do { + let scope = try await session.getDefaultFloodScope() + let name = scope?.name + eventContinuation?.yield(.defaultFloodScopeUpdated(name)) + return name + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Persists the device's default flood scope and verifies via a follow-up read. + /// + /// Passing `nil` for `name` clears the persisted scope. Non-nil names are sent as + /// ``MeshCore/FloodScope/region(_:)`` — firmware derives the key and stores both. + /// Names are truncated to ``ProtocolLimits/maxDefaultFloodScopeNameBytes`` UTF-8 bytes + /// before both key derivation and send, so the stored display and derived scope key + /// agree on the same byte sequence. + /// + /// - Parameter name: Region name to persist, or `nil` to clear. + /// - Returns: The verified name read back from the device. + public func setDefaultFloodScopeVerified(name: String?) async throws -> String? { + let expected: String? = (name?.isEmpty == false) + ? name?.utf8Prefix(maxBytes: ProtocolLimits.maxDefaultFloodScopeNameBytes) + : nil + do { + if let expected { + try await session.setDefaultFloodScope(name: expected, scope: .region(expected)) + } else { + try await session.setDefaultFloodScope(name: "", scope: .disabled) + } + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + + let actual = try await getDefaultFloodScope() + guard actual == expected else { + throw SettingsServiceError.verificationFailed( + expected: expected ?? "(cleared)", + actual: actual ?? "(cleared)" + ) + } + return actual + } + + // MARK: - Stats + + /// Get core statistics + public func getStatsCore() async throws -> CoreStats { + do { + return try await session.getStatsCore() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Get radio statistics + public func getStatsRadio() async throws -> RadioStats { + do { + return try await session.getStatsRadio() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Get packet statistics + public func getStatsPackets() async throws -> PacketStats { + do { + return try await session.getStatsPackets() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + // MARK: - Custom Variables + + /// Get custom variables from device + public func getCustomVars() async throws -> [String: String] { + do { + return try await session.getCustomVars() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + public func getDeviceGPSState() async throws -> DeviceGPSState { + let vars = try await getCustomVars() + return Self.deviceGPSState(from: vars) + } + + /// Set a custom variable on device + public func setCustomVar(key: String, value: String) async throws { + do { + try await session.setCustomVar(key: key, value: value) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + public func setDeviceGPSEnabledVerified(_ enabled: Bool) async throws -> DeviceGPSState { + try await setCustomVar(key: "gps", value: enabled ? "1" : "0") + + let state = try await getDeviceGPSState() + guard state.isSupported else { + throw SettingsServiceError.deviceGPSVerificationFailed( + expectedEnabled: enabled, + actualEnabled: false + ) + } + guard state.isEnabled == enabled else { + throw SettingsServiceError.deviceGPSVerificationFailed( + expectedEnabled: enabled, + actualEnabled: state.isEnabled + ) + } + + try await refreshDeviceInfo() + return state + } + + // MARK: - Private Key Management + + /// Export private key from device + public func exportPrivateKey() async throws -> Data { + do { + return try await session.exportPrivateKey() + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } + + /// Import private key to device + public func importPrivateKey(_ key: Data) async throws { + do { + try await session.importPrivateKey(key) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } - // MARK: - Signing + // MARK: - Signing - /// Sign data using device's private key - public func sign(_ data: Data) async throws -> Data { - do { - return try await session.sign(data) - } catch let error as MeshCoreError { - throw SettingsServiceError.sessionError(error) - } - } + /// Sign data using device's private key + public func sign(_ data: Data) async throws -> Data { + do { + return try await session.sign(data) + } catch let error as MeshCoreError { + throw SettingsServiceError.sessionError(error) + } + } - private static func deviceGPSState(from vars: [String: String]) -> DeviceGPSState { - guard let value = vars["gps"] else { - return DeviceGPSState(isSupported: false, isEnabled: false) - } - return DeviceGPSState(isSupported: true, isEnabled: value == "1") + private static func deviceGPSState(from vars: [String: String]) -> DeviceGPSState { + guard let value = vars["gps"] else { + return DeviceGPSState(isSupported: false, isEnabled: false) } + return DeviceGPSState(isSupported: true, isEnabled: value == "1") + } } diff --git a/MC1Services/Sources/MC1Services/Services/StoreCatalog.swift b/MC1Services/Sources/MC1Services/Services/StoreCatalog.swift index c5b9694c..db08a506 100644 --- a/MC1Services/Sources/MC1Services/Services/StoreCatalog.swift +++ b/MC1Services/Sources/MC1Services/Services/StoreCatalog.swift @@ -3,39 +3,39 @@ import Foundation /// Single source of truth for every in-app-purchase product identifier. /// Adding a new theme, bundle, or tip means adding a constant here. public enum StoreCatalog { - public enum Theme { - public static let ember = "io.pocketmesh.app.theme.ember" - public static let fern = "io.pocketmesh.app.theme.fern" - public static let marine = "io.pocketmesh.app.theme.marine" - public static let olive = "io.pocketmesh.app.theme.olive" - public static let lavender = "io.pocketmesh.app.theme.lavender" - public static let sakura = "io.pocketmesh.app.theme.sakura" - public static let solarized = "io.pocketmesh.app.theme.solarized" - public static let nord = "io.pocketmesh.app.theme.nord" - public static let catppuccin = "io.pocketmesh.app.theme.catppuccin" - public static let bundleAll = "io.pocketmesh.app.theme.bundle.all" + public enum Theme { + public static let ember = "io.pocketmesh.app.theme.ember" + public static let fern = "io.pocketmesh.app.theme.fern" + public static let marine = "io.pocketmesh.app.theme.marine" + public static let olive = "io.pocketmesh.app.theme.olive" + public static let lavender = "io.pocketmesh.app.theme.lavender" + public static let sakura = "io.pocketmesh.app.theme.sakura" + public static let solarized = "io.pocketmesh.app.theme.solarized" + public static let nord = "io.pocketmesh.app.theme.nord" + public static let catppuccin = "io.pocketmesh.app.theme.catppuccin" + public static let bundleAll = "io.pocketmesh.app.theme.bundle.all" - /// Every theme the `bundleAll` purchase unlocks. Themes are not sold individually — the - /// bundle is the only theme purchase — so this set is purely the bundle's entitlement - /// expansion and the per-theme ownership keys used to render locked/owned state. - public static let bundledThemeIDs: Set = - [ember, fern, marine, olive, lavender, sakura, solarized, nord, catppuccin] - } + /// Every theme the `bundleAll` purchase unlocks. Themes are not sold individually — the + /// bundle is the only theme purchase — so this set is purely the bundle's entitlement + /// expansion and the per-theme ownership keys used to render locked/owned state. + public static let bundledThemeIDs: Set = + [ember, fern, marine, olive, lavender, sakura, solarized, nord, catppuccin] + } - public enum Tip { - public static let coffee = "io.pocketmesh.app.tips.coffee" - public static let lunch = "io.pocketmesh.app.tips.lunch" - public static let dinner = "io.pocketmesh.app.tips.dinner" - public static let generous = "io.pocketmesh.app.tips.generous" - public static let massive = "io.pocketmesh.app.tips.massive" - public static let epic = "io.pocketmesh.app.tips.epic" + public enum Tip { + public static let coffee = "io.pocketmesh.app.tips.coffee" + public static let lunch = "io.pocketmesh.app.tips.lunch" + public static let dinner = "io.pocketmesh.app.tips.dinner" + public static let generous = "io.pocketmesh.app.tips.generous" + public static let massive = "io.pocketmesh.app.tips.massive" + public static let epic = "io.pocketmesh.app.tips.epic" - public static let all: Set = [coffee, lunch, dinner, generous, massive, epic] - } + public static let all: Set = [coffee, lunch, dinner, generous, massive, epic] + } - /// The product IDs the app fetches from the App Store and sells: the All Themes bundle and the - /// tips. Individual themes are not sold, so they are never requested from StoreKit — the bundle - /// purchase confers them as entitlements via `Theme.bundledThemeIDs`. - public static let sellableProductIDs: Set = - Tip.all.union([Theme.bundleAll]) + /// The product IDs the app fetches from the App Store and sells: the All Themes bundle and the + /// tips. Individual themes are not sold, so they are never requested from StoreKit — the bundle + /// purchase confers them as entitlements via `Theme.bundledThemeIDs`. + public static let sellableProductIDs: Set = + Tip.all.union([Theme.bundleAll]) } diff --git a/MC1Services/Sources/MC1Services/Services/StoreLoadState.swift b/MC1Services/Sources/MC1Services/Services/StoreLoadState.swift index e6a59b8e..c362bd0f 100644 --- a/MC1Services/Sources/MC1Services/Services/StoreLoadState.swift +++ b/MC1Services/Sources/MC1Services/Services/StoreLoadState.swift @@ -2,8 +2,8 @@ import Foundation /// Lifecycle of the product catalog load. public enum StoreLoadState: Sendable, Equatable { - case idle - case loading - case loaded - case failed + case idle + case loading + case loaded + case failed } diff --git a/MC1Services/Sources/MC1Services/Services/StorePurchaseOutcome.swift b/MC1Services/Sources/MC1Services/Services/StorePurchaseOutcome.swift index bd34b2a3..e658bf20 100644 --- a/MC1Services/Sources/MC1Services/Services/StorePurchaseOutcome.swift +++ b/MC1Services/Sources/MC1Services/Services/StorePurchaseOutcome.swift @@ -2,8 +2,8 @@ import Foundation /// Non-error result of a purchase attempt. User cancellation is an outcome, not an error. public enum StorePurchaseOutcome: Sendable, Equatable { - case purchased - case userCancelled - /// Ask-to-Buy or Strong Customer Authentication: resolution arrives later via `Transaction.updates`. - case pending + case purchased + case userCancelled + /// Ask-to-Buy or Strong Customer Authentication: resolution arrives later via `Transaction.updates`. + case pending } diff --git a/MC1Services/Sources/MC1Services/Services/StoreService.swift b/MC1Services/Sources/MC1Services/Services/StoreService.swift index e550d24a..1b841898 100644 --- a/MC1Services/Sources/MC1Services/Services/StoreService.swift +++ b/MC1Services/Sources/MC1Services/Services/StoreService.swift @@ -6,263 +6,263 @@ import StoreKit @Observable @MainActor public final class StoreService { - public private(set) var products: [Product] = [] - public private(set) var ownedThemeIDs: Set = [] - public private(set) var loadState: StoreLoadState = .idle + public private(set) var products: [Product] = [] + public private(set) var ownedThemeIDs: Set = [] + public private(set) var loadState: StoreLoadState = .idle - /// Invoked on `@MainActor` after each entitlement walk (load, restore, transaction update). - /// A later plan registers `ThemeService` here to drive theme-revert reactivity. - public var onEntitlementsChanged: (@MainActor () -> Void)? + /// Invoked on `@MainActor` after each entitlement walk (load, restore, transaction update). + /// A later plan registers `ThemeService` here to drive theme-revert reactivity. + public var onEntitlementsChanged: (@MainActor () -> Void)? - /// Invoked on `@MainActor` after a consumable transaction (tip) is finished via - /// `applyTransactionUpdate`. Consumables never appear in `Transaction.currentEntitlements`, - /// so they cannot be observed via `ownedThemeIDs` — `StoreState` uses this hook to clear - /// `pendingPurchase` when an Ask-to-Buy tip is approved out-of-band. - public var onConsumablePurchased: (@MainActor (String) -> Void)? + /// Invoked on `@MainActor` after a consumable transaction (tip) is finished via + /// `applyTransactionUpdate`. Consumables never appear in `Transaction.currentEntitlements`, + /// so they cannot be observed via `ownedThemeIDs` — `StoreState` uses this hook to clear + /// `pendingPurchase` when an Ask-to-Buy tip is approved out-of-band. + public var onConsumablePurchased: (@MainActor (String) -> Void)? - /// Invoked on `@MainActor` whenever `load()` transitions to `.failed` (product fetch - /// threw or returned empty). `StoreState` uses this to surface an actionable error message - /// so the initial load failure isn't silent — the user would otherwise see disabled '—' - /// price buttons with no explanation, and the existing `.errorAlert` retry would never fire. - public var onLoadStateFailed: (@MainActor () -> Void)? + /// Invoked on `@MainActor` whenever `load()` transitions to `.failed` (product fetch + /// threw or returned empty). `StoreState` uses this to surface an actionable error message + /// so the initial load failure isn't silent — the user would otherwise see disabled '—' + /// price buttons with no explanation, and the existing `.errorAlert` retry would never fire. + public var onLoadStateFailed: (@MainActor () -> Void)? - /// Internal (not private) so `@testable` tests can assert listener teardown. - private(set) var transactionListenerTask: Task? - private var loggedUnverifiedIDs: Set = [] - /// Caps the unverified-log dedup set on an app-lifetime service. Distinct unverified - /// transaction IDs are tiny in practice; this is a backstop against unbounded growth. - private static let maxLoggedUnverifiedIDs = 256 - /// Persisted so in-app-purchase diagnostics survive into the Settings "Export Debug Logs" - /// output, for triaging purchase failures reported from TestFlight. `[IAP]`-prefixed lines - /// trace the purchase and entitlement flow. - private let logger = PersistentLogger(subsystem: "com.mc1", category: "Store") + /// Internal (not private) so `@testable` tests can assert listener teardown. + private(set) var transactionListenerTask: Task? + private var loggedUnverifiedIDs: Set = [] + /// Caps the unverified-log dedup set on an app-lifetime service. Distinct unverified + /// transaction IDs are tiny in practice; this is a backstop against unbounded growth. + private static let maxLoggedUnverifiedIDs = 256 + /// Persisted so in-app-purchase diagnostics survive into the Settings "Export Debug Logs" + /// output, for triaging purchase failures reported from TestFlight. `[IAP]`-prefixed lines + /// trace the purchase and entitlement flow. + private let logger = PersistentLogger(subsystem: "com.mc1", category: "Store") - /// Test seam: when non-nil, `restorePurchases()` invokes this closure instead of - /// `AppStore.sync()`. Exists because SKTestSession exposes no way to drive - /// `AppStore.sync()` into either of `restorePurchases()`'s two cancel paths - /// (`CancellationError` or `StoreKitError.userCancelled`), and without this the - /// `.cancelled` arm of `RestoreOutcome` is unreachable from tests. Nil in production. - internal var appStoreSyncForTesting: (@Sendable () async throws -> Void)? + /// Test seam: when non-nil, `restorePurchases()` invokes this closure instead of + /// `AppStore.sync()`. Exists because SKTestSession exposes no way to drive + /// `AppStore.sync()` into either of `restorePurchases()`'s two cancel paths + /// (`CancellationError` or `StoreKitError.userCancelled`), and without this the + /// `.cancelled` arm of `RestoreOutcome` is unreachable from tests. Nil in production. + var appStoreSyncForTesting: (@Sendable () async throws -> Void)? - public init() { - transactionListenerTask = Task { [weak self] in - await self?.observeTransactionUpdates() - } + public init() { + transactionListenerTask = Task { [weak self] in + await self?.observeTransactionUpdates() } + } - /// Cancels the listener task. There is no reliable SwiftUI app-teardown hook to call this - /// from, so in production the listener dies with the process; this exists for deterministic - /// test teardown. `deinit` is avoided because a non-isolated `deinit` cannot touch - /// `@MainActor` state under strict concurrency, and `isolated deinit` requires iOS 18.4+ - /// (the project floor is 18.0). - public func shutdown() { - transactionListenerTask?.cancel() - transactionListenerTask = nil - } + /// Cancels the listener task. There is no reliable SwiftUI app-teardown hook to call this + /// from, so in production the listener dies with the process; this exists for deterministic + /// test teardown. `deinit` is avoided because a non-isolated `deinit` cannot touch + /// `@MainActor` state under strict concurrency, and `isolated deinit` requires iOS 18.4+ + /// (the project floor is 18.0). + public func shutdown() { + transactionListenerTask?.cancel() + transactionListenerTask = nil + } - /// Loads the catalog and walks current entitlements. Non-throwing: surfaces failure via - /// `loadState`. `productIDs` defaults to the full catalog; tests override it to exercise - /// the empty-result path. - public func load(productIDs: Set = StoreCatalog.sellableProductIDs) async { - loadState = .loading - await processUnfinishedTransactions() - do { - let loaded = try await Product.products(for: productIDs) - guard !loaded.isEmpty else { - logger.error("[IAP] load failed: 0 products returned for \(productIDs.count) IDs") - products = [] - loadState = .failed - onLoadStateFailed?() - return - } - products = loaded - } catch { - logger.error("[IAP] load failed: \(String(describing: error))") - products = [] - loadState = .failed - onLoadStateFailed?() - return - } - await walkCurrentEntitlements() - loadState = .loaded - logger.notice("[IAP] load complete: \(products.count) products, owned=\(ownedThemeIDs.count)") + /// Loads the catalog and walks current entitlements. Non-throwing: surfaces failure via + /// `loadState`. `productIDs` defaults to the full catalog; tests override it to exercise + /// the empty-result path. + public func load(productIDs: Set = StoreCatalog.sellableProductIDs) async { + loadState = .loading + await processUnfinishedTransactions() + do { + let loaded = try await Product.products(for: productIDs) + guard !loaded.isEmpty else { + logger.error("[IAP] load failed: 0 products returned for \(productIDs.count) IDs") + products = [] + loadState = .failed + onLoadStateFailed?() + return + } + products = loaded + } catch { + logger.error("[IAP] load failed: \(String(describing: error))") + products = [] + loadState = .failed + onLoadStateFailed?() + return } + await walkCurrentEntitlements() + loadState = .loaded + logger.notice("[IAP] load complete: \(products.count) products, owned=\(ownedThemeIDs.count)") + } - public func product(for productID: String) -> Product? { - products.first { $0.id == productID } - } + public func product(for productID: String) -> Product? { + products.first { $0.id == productID } + } - /// Initiates a purchase via `Product.purchase()` and processes the result. Retained for the - /// `SKTestSession` suite, which cannot drive SwiftUI's `@Environment(\.purchase)`. The - /// production view path initiates through that environment action instead (see - /// `completePurchase(_:)`), so StoreKit resolves the confirmation scene from the view - /// environment rather than a bare `UIWindowScene` lookup. - public func purchase(_ product: Product) async throws -> StorePurchaseOutcome { - let result: Product.PurchaseResult - do { - result = try await product.purchase() - } catch let error as StoreKitError { - if let mapped = StoreServiceError.from(error) { throw mapped } - return .userCancelled - } catch { - throw StoreServiceError.purchaseFailed(reason: String(describing: error)) - } - return try await completePurchase(result) + /// Initiates a purchase via `Product.purchase()` and processes the result. Retained for the + /// `SKTestSession` suite, which cannot drive SwiftUI's `@Environment(\.purchase)`. The + /// production view path initiates through that environment action instead (see + /// `completePurchase(_:)`), so StoreKit resolves the confirmation scene from the view + /// environment rather than a bare `UIWindowScene` lookup. + public func purchase(_ product: Product) async throws -> StorePurchaseOutcome { + let result: Product.PurchaseResult + do { + result = try await product.purchase() + } catch let error as StoreKitError { + if let mapped = StoreServiceError.from(error) { throw mapped } + return .userCancelled + } catch { + throw StoreServiceError.purchaseFailed(reason: String(describing: error)) } + return try await completePurchase(result) + } - /// Processes a `Product.PurchaseResult` the caller already obtained: grants the entitlement - /// from the verified transaction (rather than re-reading `currentEntitlements`, which can lag), - /// finishes it, and maps to an outcome. The production view path obtains the result through - /// SwiftUI's `@Environment(\.purchase)` action so the confirmation scene is resolved from the - /// view environment; a bare `product.purchase()` relies on implicit foreground-scene lookup, - /// which returns a spurious `.userCancelled` when the active scene churns (e.g. lifecycle - /// reconciliation firing while the purchase sheet is presented). - public func completePurchase(_ result: Product.PurchaseResult) async throws -> StorePurchaseOutcome { - switch result { - case .success(let verification): - switch verification { - case .verified(let transaction): - logger.notice("[IAP] purchase verified product=\(transaction.productID)") - apply(transaction) - await transaction.finish() - return .purchased - case .unverified(let transaction, let error): - logger.error("[IAP] purchase unverified product=\(transaction.productID): \(String(describing: error))") - throw StoreServiceError.verificationFailed - } - case .userCancelled: - logger.notice("[IAP] purchase userCancelled") - return .userCancelled - case .pending: - logger.notice("[IAP] purchase pending (ask-to-buy)") - return .pending - @unknown default: - throw StoreServiceError.purchaseFailed(reason: "Unhandled purchase result") - } + /// Processes a `Product.PurchaseResult` the caller already obtained: grants the entitlement + /// from the verified transaction (rather than re-reading `currentEntitlements`, which can lag), + /// finishes it, and maps to an outcome. The production view path obtains the result through + /// SwiftUI's `@Environment(\.purchase)` action so the confirmation scene is resolved from the + /// view environment; a bare `product.purchase()` relies on implicit foreground-scene lookup, + /// which returns a spurious `.userCancelled` when the active scene churns (e.g. lifecycle + /// reconciliation firing while the purchase sheet is presented). + public func completePurchase(_ result: Product.PurchaseResult) async throws -> StorePurchaseOutcome { + switch result { + case let .success(verification): + switch verification { + case let .verified(transaction): + logger.notice("[IAP] purchase verified product=\(transaction.productID)") + apply(transaction) + await transaction.finish() + return .purchased + case let .unverified(transaction, error): + logger.error("[IAP] purchase unverified product=\(transaction.productID): \(String(describing: error))") + throw StoreServiceError.verificationFailed + } + case .userCancelled: + logger.notice("[IAP] purchase userCancelled") + return .userCancelled + case .pending: + logger.notice("[IAP] purchase pending (ask-to-buy)") + return .pending + @unknown default: + throw StoreServiceError.purchaseFailed(reason: "Unhandled purchase result") } + } - @discardableResult - public func restorePurchases() async throws -> RestoreOutcome { - do { - if let appStoreSyncForTesting { - try await appStoreSyncForTesting() - } else { - try await AppStore.sync() - } - } catch is CancellationError { - return .cancelled // view dismissal mid-sync is not an error; surface as cancel. - } catch let error as StoreKitError { - if let mapped = StoreServiceError.from(error) { - logger.error("[IAP] restore failed: \(String(describing: error))") - throw mapped - } - // .userCancelled (mapped to nil by StoreServiceError.from) lands here — it is a - // user choice, not a successful restore. Surface as cancel so the caller does not - // fire a success haptic for a dismissed iCloud password sheet. - return .cancelled - } catch { - logger.error("[IAP] restore failed: \(String(describing: error))") - throw StoreServiceError.purchaseFailed(reason: String(describing: error)) - } - await walkCurrentEntitlements() - logger.notice("[IAP] restore complete, owned=\(ownedThemeIDs.count)") - return .completed + @discardableResult + public func restorePurchases() async throws -> RestoreOutcome { + do { + if let appStoreSyncForTesting { + try await appStoreSyncForTesting() + } else { + try await AppStore.sync() + } + } catch is CancellationError { + return .cancelled // view dismissal mid-sync is not an error; surface as cancel. + } catch let error as StoreKitError { + if let mapped = StoreServiceError.from(error) { + logger.error("[IAP] restore failed: \(String(describing: error))") + throw mapped + } + // .userCancelled (mapped to nil by StoreServiceError.from) lands here — it is a + // user choice, not a successful restore. Surface as cancel so the caller does not + // fire a success haptic for a dismissed iCloud password sheet. + return .cancelled + } catch { + logger.error("[IAP] restore failed: \(String(describing: error))") + throw StoreServiceError.purchaseFailed(reason: String(describing: error)) } + await walkCurrentEntitlements() + logger.notice("[IAP] restore complete, owned=\(ownedThemeIDs.count)") + return .completed + } - nonisolated private func observeTransactionUpdates() async { - for await update in Transaction.updates { - await applyTransactionUpdate(update) - } + private nonisolated func observeTransactionUpdates() async { + for await update in Transaction.updates { + await applyTransactionUpdate(update) } + } - private func applyTransactionUpdate(_ result: VerificationResult) async { - guard case .verified(let transaction) = result else { - noteUnverified(result) - return - } - if transaction.revocationDate != nil { - // A refund or revocation is rare and the authoritative remaining set is whatever - // currentEntitlements reports, so rebuild from it rather than fold the change in. - await walkCurrentEntitlements() - } else { - apply(transaction) - } - await transaction.finish() - if transaction.productType == .consumable { - onConsumablePurchased?(transaction.productID) - } + private func applyTransactionUpdate(_ result: VerificationResult) async { + guard case let .verified(transaction) = result else { + noteUnverified(result) + return } - - /// Finishes any transactions left unfinished while the app was closed (renewals, - /// Ask-to-Buy approvals, refunds, cross-device purchases). Their entitlement state is - /// applied by the subsequent `walkCurrentEntitlements()` call in `load()`. - private func processUnfinishedTransactions() async { - for await result in Transaction.unfinished { - guard case .verified(let transaction) = result else { - noteUnverified(result) - continue - } - await transaction.finish() - } + if transaction.revocationDate != nil { + // A refund or revocation is rare and the authoritative remaining set is whatever + // currentEntitlements reports, so rebuild from it rather than fold the change in. + await walkCurrentEntitlements() + } else { + apply(transaction) } - - /// Rebuilds `ownedThemeIDs` from scratch each call (idempotent — double application is a - /// no-op). Bundle ownership expands to every purchasable theme ID. - private func walkCurrentEntitlements() async { - var owned: Set = [] - for await result in Transaction.currentEntitlements { - guard case .verified(let transaction) = result else { - noteUnverified(result) - continue - } - owned.formUnion(affectedThemeIDs(forProductID: transaction.productID)) - // Tip transactions are consumable and never appear in currentEntitlements. - } - ownedThemeIDs = owned - logger.notice("[IAP] ownedThemeIDs count=\(ownedThemeIDs.count)") - onEntitlementsChanged?() + await transaction.finish() + if transaction.productType == .consumable { + onConsumablePurchased?(transaction.productID) } + } - /// The theme product IDs an entitlement for `productID` confers: the bundle unlocks every - /// bundled theme, and anything else (tips) unlocks none. Themes are sold only as the bundle, - /// so no standalone theme entitlement is ever granted. - private func affectedThemeIDs(forProductID productID: String) -> Set { - productID == StoreCatalog.Theme.bundleAll ? StoreCatalog.Theme.bundledThemeIDs : [] + /// Finishes any transactions left unfinished while the app was closed (renewals, + /// Ask-to-Buy approvals, refunds, cross-device purchases). Their entitlement state is + /// applied by the subsequent `walkCurrentEntitlements()` call in `load()`. + private func processUnfinishedTransactions() async { + for await result in Transaction.unfinished { + guard case let .verified(transaction) = result else { + noteUnverified(result) + continue + } + await transaction.finish() } + } - /// Folds a verified transaction's entitlement into `ownedThemeIDs` directly, without re-reading - /// `Transaction.currentEntitlements` — which can lag or return empty immediately after a - /// purchase, leaving the just-bought theme locked. Grant paths (`purchase`, Ask-to-Buy - /// approval) use this; revocation does not, since a refund rebuilds from currentEntitlements. - private func apply(_ transaction: Transaction) { - applyEntitlement(productID: transaction.productID, isRevoked: transaction.revocationDate != nil) + /// Rebuilds `ownedThemeIDs` from scratch each call (idempotent — double application is a + /// no-op). Bundle ownership expands to every purchasable theme ID. + private func walkCurrentEntitlements() async { + var owned: Set = [] + for await result in Transaction.currentEntitlements { + guard case let .verified(transaction) = result else { + noteUnverified(result) + continue + } + owned.formUnion(affectedThemeIDs(forProductID: transaction.productID)) + // Tip transactions are consumable and never appear in currentEntitlements. } + ownedThemeIDs = owned + logger.notice("[IAP] ownedThemeIDs count=\(ownedThemeIDs.count)") + onEntitlementsChanged?() + } + + /// The theme product IDs an entitlement for `productID` confers: the bundle unlocks every + /// bundled theme, and anything else (tips) unlocks none. Themes are sold only as the bundle, + /// so no standalone theme entitlement is ever granted. + private func affectedThemeIDs(forProductID productID: String) -> Set { + productID == StoreCatalog.Theme.bundleAll ? StoreCatalog.Theme.bundledThemeIDs : [] + } - /// Additively grants or revokes the theme entitlements a product confers. Internal so the fold - /// logic is unit-testable without StoreKit. Fires `onEntitlementsChanged` only on a real change. - func applyEntitlement(productID: String, isRevoked: Bool) { - let affected = affectedThemeIDs(forProductID: productID) - logger.notice("[IAP] entitlement \(isRevoked ? "revoked" : "granted") product=\(productID) affected=\(affected.count)") - guard !affected.isEmpty else { return } - var owned = ownedThemeIDs - if isRevoked { - owned.subtract(affected) - } else { - owned.formUnion(affected) - } - guard owned != ownedThemeIDs else { return } - ownedThemeIDs = owned - onEntitlementsChanged?() + /// Folds a verified transaction's entitlement into `ownedThemeIDs` directly, without re-reading + /// `Transaction.currentEntitlements` — which can lag or return empty immediately after a + /// purchase, leaving the just-bought theme locked. Grant paths (`purchase`, Ask-to-Buy + /// approval) use this; revocation does not, since a refund rebuilds from currentEntitlements. + private func apply(_ transaction: Transaction) { + applyEntitlement(productID: transaction.productID, isRevoked: transaction.revocationDate != nil) + } + + /// Additively grants or revokes the theme entitlements a product confers. Internal so the fold + /// logic is unit-testable without StoreKit. Fires `onEntitlementsChanged` only on a real change. + func applyEntitlement(productID: String, isRevoked: Bool) { + let affected = affectedThemeIDs(forProductID: productID) + logger.notice("[IAP] entitlement \(isRevoked ? "revoked" : "granted") product=\(productID) affected=\(affected.count)") + guard !affected.isEmpty else { return } + var owned = ownedThemeIDs + if isRevoked { + owned.subtract(affected) + } else { + owned.formUnion(affected) } + guard owned != ownedThemeIDs else { return } + ownedThemeIDs = owned + onEntitlementsChanged?() + } - /// Logs an unverified transaction once per transaction ID, then drops it (Apple guidance). - private func noteUnverified(_ result: VerificationResult) { - guard case .unverified(let transaction, let error) = result else { return } - if loggedUnverifiedIDs.count >= Self.maxLoggedUnverifiedIDs { - loggedUnverifiedIDs.removeAll(keepingCapacity: true) - } - if loggedUnverifiedIDs.insert(transaction.id).inserted { - logger.warning("Dropping unverified transaction \(transaction.id): \(String(describing: error))") - } + /// Logs an unverified transaction once per transaction ID, then drops it (Apple guidance). + private func noteUnverified(_ result: VerificationResult) { + guard case let .unverified(transaction, error) = result else { return } + if loggedUnverifiedIDs.count >= Self.maxLoggedUnverifiedIDs { + loggedUnverifiedIDs.removeAll(keepingCapacity: true) + } + if loggedUnverifiedIDs.insert(transaction.id).inserted { + logger.warning("Dropping unverified transaction \(transaction.id): \(String(describing: error))") } + } } diff --git a/MC1Services/Sources/MC1Services/Services/TelemetryModes.swift b/MC1Services/Sources/MC1Services/Services/TelemetryModes.swift index 7f39949d..d8dff9f0 100644 --- a/MC1Services/Sources/MC1Services/Services/TelemetryModes.swift +++ b/MC1Services/Sources/MC1Services/Services/TelemetryModes.swift @@ -2,24 +2,24 @@ import Foundation /// Packed telemetry mode configuration public struct TelemetryModes: Sendable, Equatable { - public var base: UInt8 - public var location: UInt8 - public var environment: UInt8 + public var base: UInt8 + public var location: UInt8 + public var environment: UInt8 - public init(base: UInt8 = 0, location: UInt8 = 0, environment: UInt8 = 0) { - self.base = base & 0b11 - self.location = location & 0b11 - self.environment = environment & 0b11 - } + public init(base: UInt8 = 0, location: UInt8 = 0, environment: UInt8 = 0) { + self.base = base & 0b11 + self.location = location & 0b11 + self.environment = environment & 0b11 + } - /// Packed value for protocol encoding - public var packed: UInt8 { - (environment << 4) | (location << 2) | base - } + /// Packed value for protocol encoding + public var packed: UInt8 { + (environment << 4) | (location << 2) | base + } - public init(packed: UInt8) { - self.base = packed & 0b11 - self.location = (packed >> 2) & 0b11 - self.environment = (packed >> 4) & 0b11 - } + public init(packed: UInt8) { + base = packed & 0b11 + location = (packed >> 2) & 0b11 + environment = (packed >> 4) & 0b11 + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift index 41cedbbd..ba58c229 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift @@ -1,136 +1,135 @@ import Foundation extension MockDataProvider { - - /// Generate mock channel messages for a channel slot index. Channel messages - /// carry `channelIndex` (not `contactID`); incoming rows populate `senderNodeName` - /// and `senderKeyPrefix` for sender resolution. - public static func channelMessages(for index: UInt8) -> [MessageDTO] { - let now = Date() - switch index { - case publicChannelIndex: return publicChannelMessages(now: now) - case bayAreaChannelIndex: return bayAreaChannelMessages(now: now) - case trailCrewChannelIndex: return trailCrewChannelMessages(now: now) - default: return [] - } + /// Generate mock channel messages for a channel slot index. Channel messages + /// carry `channelIndex` (not `contactID`); incoming rows populate `senderNodeName` + /// and `senderKeyPrefix` for sender resolution. + public static func channelMessages(for index: UInt8) -> [MessageDTO] { + let now = Date() + switch index { + case publicChannelIndex: return publicChannelMessages(now: now) + case bayAreaChannelIndex: return bayAreaChannelMessages(now: now) + case trailCrewChannelIndex: return trailCrewChannelMessages(now: now) + default: return [] } + } - /// Public: multi-sender chatter with a same-sender cluster, plus our own reply. - private static func publicChannelMessages(now: Date) -> [MessageDTO] { - let path = encodePathLen(hashSize: 1, hopCount: 2) - return [ - channelIncoming( - "C0000000-0000-0000-0000-000000000001", - now.addingTimeInterval(-9_000), - "Anyone monitoring the north repeater today?", - sender: "Alice Chen", keySeed: 10, snr: 7.4, path: path - ), - // Same-sender cluster (consecutive messages from Alice). - channelIncoming( - "C0000000-0000-0000-0000-000000000002", - now.addingTimeInterval(-8_940), - "Signal's been solid on my end all morning.", - sender: "Alice Chen", keySeed: 10, snr: 7.6, path: path - ), - channelIncoming( - "C0000000-0000-0000-0000-000000000003", - now.addingTimeInterval(-6_000), - "Same here, clear copy from the south ridge.", - sender: "Bob Martinez", keySeed: 20, snr: 6.9, path: path - ), - MockMessageFactory.message( - id: UUID(uuidString: "C0000000-0000-0000-0000-000000000004")!, - createdAt: now.addingTimeInterval(-1_200), - text: "Good to hear. I'll run a trace this afternoon.", - direction: .outgoing, - status: .sent, - channelIndex: publicChannelIndex, - ackCode: 50_001, - pathLength: path - ) - ] - } + /// Public: multi-sender chatter with a same-sender cluster, plus our own reply. + private static func publicChannelMessages(now: Date) -> [MessageDTO] { + let path = encodePathLen(hashSize: 1, hopCount: 2) + return [ + channelIncoming( + "C0000000-0000-0000-0000-000000000001", + now.addingTimeInterval(-9000), + "Anyone monitoring the north repeater today?", + sender: "Alice Chen", keySeed: 10, snr: 7.4, path: path + ), + // Same-sender cluster (consecutive messages from Alice). + channelIncoming( + "C0000000-0000-0000-0000-000000000002", + now.addingTimeInterval(-8940), + "Signal's been solid on my end all morning.", + sender: "Alice Chen", keySeed: 10, snr: 7.6, path: path + ), + channelIncoming( + "C0000000-0000-0000-0000-000000000003", + now.addingTimeInterval(-6000), + "Same here, clear copy from the south ridge.", + sender: "Bob Martinez", keySeed: 20, snr: 6.9, path: path + ), + MockMessageFactory.message( + id: UUID(uuidString: "C0000000-0000-0000-0000-000000000004")!, + createdAt: now.addingTimeInterval(-1200), + text: "Good to hear. I'll run a trace this afternoon.", + direction: .outgoing, + status: .sent, + channelIndex: publicChannelIndex, + ackCode: 50001, + pathLength: path + ) + ] + } - /// Bay Area (favorite): a reacted message and a self-mention (unread mention badge). - private static func bayAreaChannelMessages(now: Date) -> [MessageDTO] { - let path = encodePathLen(hashSize: 1, hopCount: 1) - return [ - channelIncoming( - "C1000000-0000-0000-0000-000000000001", - now.addingTimeInterval(-7_200), - "Welcome to all the new members this week!", - sender: "Carol Diaz", keySeed: 90, snr: 9.1, path: path - ), - // Reacted message (badge applied via updateMessageReactionSummary). - channelIncoming( - "C1000000-0000-0000-0000-000000000002", - now.addingTimeInterval(-5_400), - "We just passed 50 active nodes in the area 🎉", - sender: "Carol Diaz", keySeed: 90, snr: 9.0, path: path - ), - // Self-mention drives the mention highlight and unread-mention badge; also reacted. - channelIncoming( - bayAreaMentionMessageID.uuidString, - now.addingTimeInterval(-3_600), - "@[Sim] can you cover the cleanup this Saturday?", - sender: "Carol Diaz", keySeed: 90, snr: 8.8, path: path, - isRead: false, containsSelfMention: true, mentionSeen: false - ) - ] - } + /// Bay Area (favorite): a reacted message and a self-mention (unread mention badge). + private static func bayAreaChannelMessages(now: Date) -> [MessageDTO] { + let path = encodePathLen(hashSize: 1, hopCount: 1) + return [ + channelIncoming( + "C1000000-0000-0000-0000-000000000001", + now.addingTimeInterval(-7200), + "Welcome to all the new members this week!", + sender: "Carol Diaz", keySeed: 90, snr: 9.1, path: path + ), + // Reacted message (badge applied via updateMessageReactionSummary). + channelIncoming( + "C1000000-0000-0000-0000-000000000002", + now.addingTimeInterval(-5400), + "We just passed 50 active nodes in the area 🎉", + sender: "Carol Diaz", keySeed: 90, snr: 9.0, path: path + ), + // Self-mention drives the mention highlight and unread-mention badge; also reacted. + channelIncoming( + bayAreaMentionMessageID.uuidString, + now.addingTimeInterval(-3600), + "@[Sim] can you cover the cleanup this Saturday?", + sender: "Carol Diaz", keySeed: 90, snr: 8.8, path: path, + isRead: false, containsSelfMention: true, mentionSeen: false + ) + ] + } - /// Trail Crew (muted): a short exchange to show a muted channel. - private static func trailCrewChannelMessages(now: Date) -> [MessageDTO] { - let path = encodePathLen(hashSize: 1, hopCount: 2) - return [ - channelIncoming( - "C2000000-0000-0000-0000-000000000001", - now.addingTimeInterval(-9_600), - "Bridge repair is done. Trail's open again.", - sender: "Frank Wilson", keySeed: 60, snr: 5.2, path: path - ), - MockMessageFactory.message( - id: UUID(uuidString: "C2000000-0000-0000-0000-000000000002")!, - createdAt: now.addingTimeInterval(-7_200), - text: "Nice work everyone.", - direction: .outgoing, - status: .sent, - channelIndex: trailCrewChannelIndex, - ackCode: 50_002, - pathLength: path - ) - ] - } + /// Trail Crew (muted): a short exchange to show a muted channel. + private static func trailCrewChannelMessages(now: Date) -> [MessageDTO] { + let path = encodePathLen(hashSize: 1, hopCount: 2) + return [ + channelIncoming( + "C2000000-0000-0000-0000-000000000001", + now.addingTimeInterval(-9600), + "Bridge repair is done. Trail's open again.", + sender: "Frank Wilson", keySeed: 60, snr: 5.2, path: path + ), + MockMessageFactory.message( + id: UUID(uuidString: "C2000000-0000-0000-0000-000000000002")!, + createdAt: now.addingTimeInterval(-7200), + text: "Nice work everyone.", + direction: .outgoing, + status: .sent, + channelIndex: trailCrewChannelIndex, + ackCode: 50002, + pathLength: path + ) + ] + } - /// Builds an incoming channel message, resolving the slot index from the id prefix. - private static func channelIncoming( - _ id: String, - _ createdAt: Date, - _ text: String, - sender: String, - keySeed: UInt8, - snr: Double, - path: UInt8, - isRead: Bool = true, - containsSelfMention: Bool = false, - mentionSeen: Bool = false - ) -> MessageDTO { - let index: UInt8 = id.hasPrefix("C1") ? bayAreaChannelIndex - : id.hasPrefix("C2") ? trailCrewChannelIndex - : publicChannelIndex - return MockMessageFactory.message( - id: UUID(uuidString: id)!, - createdAt: createdAt, - text: text, - direction: .incoming, - channelIndex: index, - pathLength: path, - snr: snr, - senderKeyPrefix: mockPublicKey(seed: keySeed).prefix(6), - senderNodeName: sender, - isRead: isRead, - containsSelfMention: containsSelfMention, - mentionSeen: mentionSeen - ) - } + /// Builds an incoming channel message, resolving the slot index from the id prefix. + private static func channelIncoming( + _ id: String, + _ createdAt: Date, + _ text: String, + sender: String, + keySeed: UInt8, + snr: Double, + path: UInt8, + isRead: Bool = true, + containsSelfMention: Bool = false, + mentionSeen: Bool = false + ) -> MessageDTO { + let index: UInt8 = id.hasPrefix("C1") ? bayAreaChannelIndex + : id.hasPrefix("C2") ? trailCrewChannelIndex + : publicChannelIndex + return MockMessageFactory.message( + id: UUID(uuidString: id)!, + createdAt: createdAt, + text: text, + direction: .incoming, + channelIndex: index, + pathLength: path, + snr: snr, + senderKeyPrefix: mockPublicKey(seed: keySeed).prefix(6), + senderNodeName: sender, + isRead: isRead, + containsSelfMention: containsSelfMention, + mentionSeen: mentionSeen + ) + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift index 2a202839..52ec340b 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift @@ -1,55 +1,54 @@ import Foundation extension MockDataProvider { + /// Seeded channels with varied notification levels and a favorite, so the + /// channel list exercises all/favorite/muted states. Saved via the DTO-based + /// `saveChannel(_:)` (the wire `ChannelInfo` carries no notification/favorite state). + public static var channels: [ChannelDTO] { + let now = Date() + return [ + ChannelDTO( + id: publicChannelID, + radioID: simulatorDeviceID, + index: publicChannelIndex, + name: "Public", + secret: channelSecret(seed: 0xA0), + isEnabled: true, + lastMessageDate: now.addingTimeInterval(-1200), + unreadCount: 2, + notificationLevel: .all, + isFavorite: false + ), + ChannelDTO( + id: bayAreaChannelID, + radioID: simulatorDeviceID, + index: bayAreaChannelIndex, + name: "Bay Area", + secret: channelSecret(seed: 0xB0), + isEnabled: true, + lastMessageDate: now.addingTimeInterval(-3600), + unreadCount: 1, + unreadMentionCount: 1, + notificationLevel: .all, + isFavorite: true + ), + ChannelDTO( + id: trailCrewChannelID, + radioID: simulatorDeviceID, + index: trailCrewChannelIndex, + name: "Trail Crew", + secret: channelSecret(seed: 0xC0), + isEnabled: true, + lastMessageDate: now.addingTimeInterval(-7200), + unreadCount: 0, + notificationLevel: .muted, + isFavorite: false + ) + ] + } - /// Seeded channels with varied notification levels and a favorite, so the - /// channel list exercises all/favorite/muted states. Saved via the DTO-based - /// `saveChannel(_:)` (the wire `ChannelInfo` carries no notification/favorite state). - public static var channels: [ChannelDTO] { - let now = Date() - return [ - ChannelDTO( - id: publicChannelID, - radioID: simulatorDeviceID, - index: publicChannelIndex, - name: "Public", - secret: channelSecret(seed: 0xA0), - isEnabled: true, - lastMessageDate: now.addingTimeInterval(-1_200), - unreadCount: 2, - notificationLevel: .all, - isFavorite: false - ), - ChannelDTO( - id: bayAreaChannelID, - radioID: simulatorDeviceID, - index: bayAreaChannelIndex, - name: "Bay Area", - secret: channelSecret(seed: 0xB0), - isEnabled: true, - lastMessageDate: now.addingTimeInterval(-3_600), - unreadCount: 1, - unreadMentionCount: 1, - notificationLevel: .all, - isFavorite: true - ), - ChannelDTO( - id: trailCrewChannelID, - radioID: simulatorDeviceID, - index: trailCrewChannelIndex, - name: "Trail Crew", - secret: channelSecret(seed: 0xC0), - isEnabled: true, - lastMessageDate: now.addingTimeInterval(-7_200), - unreadCount: 0, - notificationLevel: .muted, - isFavorite: false - ) - ] - } - - /// Deterministic 16-byte channel PSK from a seed. - private static func channelSecret(seed: UInt8) -> Data { - Data((0..<16).map { UInt8($0) &+ seed }) - } + /// Deterministic 16-byte channel PSK from a seed. + private static func channelSecret(seed: UInt8) -> Data { + Data((0..<16).map { UInt8($0) &+ seed }) + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift index 1ad52079..94c15103 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift @@ -1,186 +1,186 @@ import Foundation -extension MockDataProvider { - /// All mock contacts for simulator testing - public static var contacts: [ContactDTO] { - let now = Date() +public extension MockDataProvider { + /// All mock contacts for simulator testing + static var contacts: [ContactDTO] { + let now = Date() - return [ - // Alice Chen - chat, normal, 3 unread, 2 hops - ContactDTO( - id: aliceChenID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 10), - name: "Alice Chen", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 2, - outPath: Data([0x10, 0x20]), // 2-hop path - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 300, // 5 min ago - latitude: 37.7849, - longitude: -122.4094, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: now.addingTimeInterval(-1800), // 30 min ago - unreadCount: 3 - ), + return [ + // Alice Chen - chat, normal, 3 unread, 2 hops + ContactDTO( + id: aliceChenID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 10), + name: "Alice Chen", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 2, + outPath: Data([0x10, 0x20]), // 2-hop path + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 300, // 5 min ago + latitude: 37.7849, + longitude: -122.4094, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: now.addingTimeInterval(-1800), // 30 min ago + unreadCount: 3 + ), - // Bob Martinez - chat, 1 hop (direct) - ContactDTO( - id: bobMartinezID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 20), - name: "Bob Martinez", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 1, - outPath: Data([0x20]), // Direct - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 60, // 1 min ago - latitude: 37.7649, - longitude: -122.4294, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: now.addingTimeInterval(-900), // 15 min ago - unreadCount: 0 - ), + // Bob Martinez - chat, 1 hop (direct) + ContactDTO( + id: bobMartinezID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 20), + name: "Bob Martinez", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 1, + outPath: Data([0x20]), // Direct + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 60, // 1 min ago + latitude: 37.7649, + longitude: -122.4294, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: now.addingTimeInterval(-900), // 15 min ago + unreadCount: 0 + ), - // Charlie Node - repeater, 0 hops (self) - ContactDTO( - id: charlieNodeID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 30), - name: "Charlie Node", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 120, // 2 min ago - latitude: 37.7549, - longitude: -122.4394, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ), + // Charlie Node - repeater, 0 hops (self) + ContactDTO( + id: charlieNodeID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 30), + name: "Charlie Node", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 120, // 2 min ago + latitude: 37.7549, + longitude: -122.4394, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ), - // Diana's Room - room, 3 hops - ContactDTO( - id: dianasRoomID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 40), - name: "Diana's Room", - typeRawValue: ContactType.room.rawValue, - flags: 0, - outPathLength: 3, - outPath: Data([0x10, 0x20, 0x40]), // 3-hop path - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 600, // 10 min ago - latitude: 37.7449, - longitude: -122.4494, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ), + // Diana's Room - room, 3 hops + ContactDTO( + id: dianasRoomID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 40), + name: "Diana's Room", + typeRawValue: ContactType.room.rawValue, + flags: 0, + outPathLength: 3, + outPath: Data([0x10, 0x20, 0x40]), // 3-hop path + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 600, // 10 min ago + latitude: 37.7449, + longitude: -122.4494, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ), - // Eve Thompson - chat, blocked, 4 hops - ContactDTO( - id: eveThompsonID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 50), - name: "Eve Thompson", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 4, - outPath: Data([0x10, 0x20, 0x30, 0x50]), // 4-hop path - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 1800, // 30 min ago - latitude: 37.7349, - longitude: -122.4594, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: true, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ), + // Eve Thompson - chat, blocked, 4 hops + ContactDTO( + id: eveThompsonID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 50), + name: "Eve Thompson", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 4, + outPath: Data([0x10, 0x20, 0x30, 0x50]), // 4-hop path + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 1800, // 30 min ago + latitude: 37.7349, + longitude: -122.4594, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: true, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ), - // Frank Wilson - chat, nickname "Dad", 2 hops - ContactDTO( - id: frankWilsonID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 60), - name: "Frank Wilson", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 2, - outPath: Data([0x10, 0x60]), // 2-hop path - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 3600, // 1 hour ago - latitude: 37.7249, - longitude: -122.4694, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: "Dad", - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: now.addingTimeInterval(-7200), // 2 hours ago - unreadCount: 0 - ), + // Frank Wilson - chat, nickname "Dad", 2 hops + ContactDTO( + id: frankWilsonID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 60), + name: "Frank Wilson", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 2, + outPath: Data([0x10, 0x60]), // 2-hop path + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 3600, // 1 hour ago + latitude: 37.7249, + longitude: -122.4694, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: "Dad", + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: now.addingTimeInterval(-7200), // 2 hours ago + unreadCount: 0 + ), - // Ghost Node - repeater, no recent contact, 5 hops - ContactDTO( - id: ghostNodeID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 70), - name: "Ghost Node", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 5, - outPath: Data([0x10, 0x20, 0x30, 0x40, 0x70]), // 5-hop path (stale) - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 86400, // 24 hours ago - latitude: 0, - longitude: 0, - lastModified: UInt32(now.timeIntervalSince1970) - 86400, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ), + // Ghost Node - repeater, no recent contact, 5 hops + ContactDTO( + id: ghostNodeID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 70), + name: "Ghost Node", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 5, + outPath: Data([0x10, 0x20, 0x30, 0x40, 0x70]), // 5-hop path (stale) + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 86400, // 24 hours ago + latitude: 0, + longitude: 0, + lastModified: UInt32(now.timeIntervalSince1970) - 86400, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ), - // Hannah Lee - chat, direct, short greeting conversation - ContactDTO( - id: hannahLeeID, - radioID: simulatorDeviceID, - publicKey: mockPublicKey(seed: 80), - name: "Hannah Lee", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 1, - outPath: Data([0x80]), // Direct - lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 30, // 30 seconds ago - latitude: 37.7149, - longitude: -122.4794, - lastModified: UInt32(now.timeIntervalSince1970), - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: true, - lastMessageDate: now.addingTimeInterval(-600), // 10 min ago - unreadCount: 0 - ) - ] - } + // Hannah Lee - chat, direct, short greeting conversation + ContactDTO( + id: hannahLeeID, + radioID: simulatorDeviceID, + publicKey: mockPublicKey(seed: 80), + name: "Hannah Lee", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 1, + outPath: Data([0x80]), // Direct + lastAdvertTimestamp: UInt32(now.timeIntervalSince1970) - 30, // 30 seconds ago + latitude: 37.7149, + longitude: -122.4794, + lastModified: UInt32(now.timeIntervalSince1970), + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: true, + lastMessageDate: now.addingTimeInterval(-600), // 10 min ago + unreadCount: 0 + ) + ] + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift index 95b95e20..83728a37 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift @@ -1,306 +1,305 @@ import Foundation extension MockDataProvider { + /// A seeded offline link preview. `saveMessage` drops the link-preview columns, + /// so these are applied via `updateMessageLinkPreview` after the message is saved. + struct LinkPreviewSeed { + let messageID: UUID + let url: String + let title: String + let imageData: Data + } - /// A seeded offline link preview. `saveMessage` drops the link-preview columns, - /// so these are applied via `updateMessageLinkPreview` after the message is saved. - struct LinkPreviewSeed: Sendable { - let messageID: UUID - let url: String - let title: String - let imageData: Data - } + /// Link previews to apply after their parent messages are saved. + static var linkPreviewSeeds: [LinkPreviewSeed] { + [ + LinkPreviewSeed( + messageID: aliceLinkPreviewMessageID, + url: "https://meshcoreone.com/trails/skyline", + title: "Skyline Ridge Trail Guide", + imageData: demoImageData + ) + ] + } - /// Link previews to apply after their parent messages are saved. - static var linkPreviewSeeds: [LinkPreviewSeed] { - [ - LinkPreviewSeed( - messageID: aliceLinkPreviewMessageID, - url: "https://meshcoreone.com/trails/skyline", - title: "Skyline Ridge Trail Guide", - imageData: demoImageData - ) - ] + /// Generate mock messages for a specific contact. + public static func messages(for contactID: UUID) -> [MessageDTO] { + let now = Date() + switch contactID { + case aliceChenID: return aliceMessages(now: now) + case bobMartinezID: return bobMessages(now: now) + case frankWilsonID: return frankMessages(now: now) + case hannahLeeID: return hannahMessages(now: now) + default: return [] // Charlie, Diana, Eve, Ghost remain contact-list-only fixtures } + } - /// Generate mock messages for a specific contact. - public static func messages(for contactID: UUID) -> [MessageDTO] { - let now = Date() - switch contactID { - case aliceChenID: return aliceMessages(now: now) - case bobMartinezID: return bobMessages(now: now) - case frankWilsonID: return frankMessages(now: now) - case hannahLeeID: return hannahMessages(now: now) - default: return [] // Charlie, Diana, Eve, Ghost remain contact-list-only fixtures - } - } + // MARK: - Per-contact builders - // MARK: - Per-contact builders + /// Alice (2-hop): reply, reaction badge, offline link preview, inline image, + /// clock-corrected incoming, signed-plain outgoing, varied path-hash byte size. + private static func aliceMessages(now: Date) -> [MessageDTO] { + let key = mockPublicKey(seed: 10).prefix(6) + return [ + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, + createdAt: now.addingTimeInterval(-90000), + text: "Hey Alice, are you free this weekend?", + direction: .outgoing, + status: .delivered, + contactID: aliceChenID, + ackCode: 12345, + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + roundTripTime: 2500, + heardRepeats: 1 + ), + // Reacted message (badge applied via updateMessageReactionSummary). + MockMessageFactory.message( + id: aliceReactedMessageID, + createdAt: now.addingTimeInterval(-86400), + text: "Yeah! Want to go hiking? 🥾", + direction: .incoming, + contactID: aliceChenID, + pathLength: encodePathLen(hashSize: 2, hopCount: 2), + snr: 8.5, + pathNodes: Data([0x10, 0xA3, 0x20, 0xB7]), // 2 hops x 2-byte hash + senderKeyPrefix: key + ), + // Outgoing reply referencing Alice's reacted message. + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!, + createdAt: now.addingTimeInterval(-82000), + text: "Perfect! I know a great trail.", + direction: .outgoing, + status: .sent, + contactID: aliceChenID, + ackCode: 12346, + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + replyToID: aliceReactedMessageID + ), + // Signed-plain outgoing (CLI/signed styling). + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000009")!, + createdAt: now.addingTimeInterval(-78000), + text: "see you at 9am", + direction: .outgoing, + status: .delivered, + contactID: aliceChenID, + textType: .signedPlain, + ackCode: 12347, + pathLength: encodePathLen(hashSize: 1, hopCount: 2) + ), + // Clock-corrected incoming: wire send time skewed 2h behind the corrected time. + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-00000000000C")!, + createdAt: now.addingTimeInterval(-7200), + text: "Sorry, my clock was way off 😅", + direction: .incoming, + contactID: aliceChenID, + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + snr: 6.5, + pathNodes: Data([0x10, 0x20]), + senderKeyPrefix: key, + isRead: false, + timestampCorrected: true, + senderTimestamp: UInt32(now.addingTimeInterval(-14400).timeIntervalSince1970) + ), + // Offline link preview (URL in body; preview applied post-save). + MockMessageFactory.message( + id: aliceLinkPreviewMessageID, + createdAt: now.addingTimeInterval(-5400), + text: "Trail details here: https://meshcoreone.com/trails/skyline", + direction: .incoming, + contactID: aliceChenID, + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + snr: 7.9, + pathNodes: Data([0x10, 0x20]), + senderKeyPrefix: key, + isRead: false + ), + // Inline image (URL in body; bytes pre-seeded into the app-layer cache). + MockMessageFactory.message( + id: UUID(uuidString: "10000000-0000-0000-0000-00000000000B")!, + createdAt: now.addingTimeInterval(-3600), + text: "Made it to the summit! \(inlineImageURL)", + direction: .incoming, + contactID: aliceChenID, + pathLength: encodePathLen(hashSize: 2, hopCount: 2), + snr: 8.2, + pathNodes: Data([0x10, 0xA3, 0x20, 0xB7]), + senderKeyPrefix: key, + isRead: false + ) + ] + } - /// Alice (2-hop): reply, reaction badge, offline link preview, inline image, - /// clock-corrected incoming, signed-plain outgoing, varied path-hash byte size. - private static func aliceMessages(now: Date) -> [MessageDTO] { - let key = mockPublicKey(seed: 10).prefix(6) - return [ - MockMessageFactory.message( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, - createdAt: now.addingTimeInterval(-90_000), - text: "Hey Alice, are you free this weekend?", - direction: .outgoing, - status: .delivered, - contactID: aliceChenID, - ackCode: 12_345, - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - roundTripTime: 2_500, - heardRepeats: 1 - ), - // Reacted message (badge applied via updateMessageReactionSummary). - MockMessageFactory.message( - id: aliceReactedMessageID, - createdAt: now.addingTimeInterval(-86_400), - text: "Yeah! Want to go hiking? 🥾", - direction: .incoming, - contactID: aliceChenID, - pathLength: encodePathLen(hashSize: 2, hopCount: 2), - snr: 8.5, - pathNodes: Data([0x10, 0xA3, 0x20, 0xB7]), // 2 hops x 2-byte hash - senderKeyPrefix: key - ), - // Outgoing reply referencing Alice's reacted message. - MockMessageFactory.message( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!, - createdAt: now.addingTimeInterval(-82_000), - text: "Perfect! I know a great trail.", - direction: .outgoing, - status: .sent, - contactID: aliceChenID, - ackCode: 12_346, - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - replyToID: aliceReactedMessageID - ), - // Signed-plain outgoing (CLI/signed styling). - MockMessageFactory.message( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000009")!, - createdAt: now.addingTimeInterval(-78_000), - text: "see you at 9am", - direction: .outgoing, - status: .delivered, - contactID: aliceChenID, - textType: .signedPlain, - ackCode: 12_347, - pathLength: encodePathLen(hashSize: 1, hopCount: 2) - ), - // Clock-corrected incoming: wire send time skewed 2h behind the corrected time. - MockMessageFactory.message( - id: UUID(uuidString: "10000000-0000-0000-0000-00000000000C")!, - createdAt: now.addingTimeInterval(-7_200), - text: "Sorry, my clock was way off 😅", - direction: .incoming, - contactID: aliceChenID, - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - snr: 6.5, - pathNodes: Data([0x10, 0x20]), - senderKeyPrefix: key, - isRead: false, - timestampCorrected: true, - senderTimestamp: UInt32(now.addingTimeInterval(-14_400).timeIntervalSince1970) - ), - // Offline link preview (URL in body; preview applied post-save). - MockMessageFactory.message( - id: aliceLinkPreviewMessageID, - createdAt: now.addingTimeInterval(-5_400), - text: "Trail details here: https://meshcoreone.com/trails/skyline", - direction: .incoming, - contactID: aliceChenID, - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - snr: 7.9, - pathNodes: Data([0x10, 0x20]), - senderKeyPrefix: key, - isRead: false - ), - // Inline image (URL in body; bytes pre-seeded into the app-layer cache). - MockMessageFactory.message( - id: UUID(uuidString: "10000000-0000-0000-0000-00000000000B")!, - createdAt: now.addingTimeInterval(-3_600), - text: "Made it to the summit! \(inlineImageURL)", - direction: .incoming, - contactID: aliceChenID, - pathLength: encodePathLen(hashSize: 2, hopCount: 2), - snr: 8.2, - pathNodes: Data([0x10, 0xA3, 0x20, 0xB7]), - senderKeyPrefix: key, - isRead: false - ) - ] - } + /// Bob (direct): full delivery-status spread (pending through failed/retrying). + private static func bobMessages(now: Date) -> [MessageDTO] { + let key = mockPublicKey(seed: 20).prefix(6) + let direct = encodePathLen(hashSize: 1, hopCount: 1) + return [ + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000001")!, + createdAt: now.addingTimeInterval(-172_800), + text: "Bob, can you check the weather?", + direction: .outgoing, + status: .delivered, + contactID: bobMartinezID, + ackCode: 23456, + pathLength: direct, + roundTripTime: 850 + ), + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!, + createdAt: now.addingTimeInterval(-7200), + text: "This message failed to send", + direction: .outgoing, + status: .failed, + contactID: bobMartinezID, + ackCode: 23457, + pathLength: direct, + retryAttempt: 3 + ), + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000003")!, + createdAt: now.addingTimeInterval(-3600), + text: "Retrying this one...", + direction: .outgoing, + status: .retrying, + contactID: bobMartinezID, + ackCode: 23458, + pathLength: direct, + retryAttempt: 1 + ), + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000004")!, + createdAt: now.addingTimeInterval(-900), + text: "Are you there?", + direction: .outgoing, + status: .pending, + contactID: bobMartinezID, + ackCode: 23459, + pathLength: direct + ), + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000005")!, + createdAt: now.addingTimeInterval(-720), + text: "Testing connection...", + direction: .outgoing, + status: .sending, + contactID: bobMartinezID, + ackCode: 23460, + pathLength: direct + ), + MockMessageFactory.message( + id: UUID(uuidString: "20000000-0000-0000-0000-000000000006")!, + createdAt: now.addingTimeInterval(-600), + text: "Yeah, I'm here!", + direction: .incoming, + contactID: bobMartinezID, + pathLength: direct, + snr: 12.3, // strong, direct + pathNodes: Data([0x20]), + senderKeyPrefix: key + ) + ] + } - /// Bob (direct): full delivery-status spread (pending through failed/retrying). - private static func bobMessages(now: Date) -> [MessageDTO] { - let key = mockPublicKey(seed: 20).prefix(6) - let direct = encodePathLen(hashSize: 1, hopCount: 1) - return [ - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000001")!, - createdAt: now.addingTimeInterval(-172_800), - text: "Bob, can you check the weather?", - direction: .outgoing, - status: .delivered, - contactID: bobMartinezID, - ackCode: 23_456, - pathLength: direct, - roundTripTime: 850 - ), - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!, - createdAt: now.addingTimeInterval(-7_200), - text: "This message failed to send", - direction: .outgoing, - status: .failed, - contactID: bobMartinezID, - ackCode: 23_457, - pathLength: direct, - retryAttempt: 3 - ), - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000003")!, - createdAt: now.addingTimeInterval(-3_600), - text: "Retrying this one...", - direction: .outgoing, - status: .retrying, - contactID: bobMartinezID, - ackCode: 23_458, - pathLength: direct, - retryAttempt: 1 - ), - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000004")!, - createdAt: now.addingTimeInterval(-900), - text: "Are you there?", - direction: .outgoing, - status: .pending, - contactID: bobMartinezID, - ackCode: 23_459, - pathLength: direct - ), - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000005")!, - createdAt: now.addingTimeInterval(-720), - text: "Testing connection...", - direction: .outgoing, - status: .sending, - contactID: bobMartinezID, - ackCode: 23_460, - pathLength: direct - ), - MockMessageFactory.message( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000006")!, - createdAt: now.addingTimeInterval(-600), - text: "Yeah, I'm here!", - direction: .incoming, - contactID: bobMartinezID, - pathLength: direct, - snr: 12.3, // strong, direct - pathNodes: Data([0x20]), - senderKeyPrefix: key - ) - ] - } + /// Frank "Dad" (2-hop): weak/very-weak SNR, a flood-routed incoming with a region + /// scope, and a heard-repeat-backed outgoing (repeats seeded separately). + private static func frankMessages(now: Date) -> [MessageDTO] { + let key = mockPublicKey(seed: 60).prefix(6) + return [ + MockMessageFactory.message( + id: UUID(uuidString: "60000000-0000-0000-0000-000000000001")!, + createdAt: now.addingTimeInterval(-259_200), + text: "Hey kiddo, how are you?", + direction: .incoming, + contactID: frankWilsonID, + pathLength: encodePathLen(hashSize: 2, hopCount: 2), + snr: 2.1, // weak + pathNodes: Data([0x10, 0x4F, 0x60, 0x9C]), + senderKeyPrefix: key + ), + // Heard-repeat-backed outgoing; heardRepeats matches the seeded repeat rows. + MockMessageFactory.message( + id: frankRepeatMessageID, + createdAt: now.addingTimeInterval(-255_600), + text: "Doing great Dad! How about you?", + direction: .outgoing, + status: .delivered, + contactID: frankWilsonID, + ackCode: 34567, + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + roundTripTime: 3200, + heardRepeats: 3 + ), + MockMessageFactory.message( + id: UUID(uuidString: "60000000-0000-0000-0000-000000000003")!, + createdAt: now.addingTimeInterval(-10800), + text: "Good! Talk soon.", + direction: .incoming, + contactID: frankWilsonID, + pathLength: encodePathLen(hashSize: 2, hopCount: 2), + snr: 0.8, // very weak + pathNodes: Data([0x10, 0x4F, 0x60, 0x9C]), + senderKeyPrefix: key + ), + // Flood-routed incoming carrying a region scope. + MockMessageFactory.message( + id: UUID(uuidString: "60000000-0000-0000-0000-000000000004")!, + createdAt: now.addingTimeInterval(-3600), + text: "Storm warning for the ridge tonight ⛈️", + direction: .incoming, + contactID: frankWilsonID, + pathLength: encodePathLen(hashSize: 1, hopCount: 3), + snr: 3.2, + pathNodes: Data([0x10, 0x44, 0x60]), + senderKeyPrefix: key, + routeType: .tcFlood, + regionScope: "US915" + ) + ] + } - /// Frank "Dad" (2-hop): weak/very-weak SNR, a flood-routed incoming with a region - /// scope, and a heard-repeat-backed outgoing (repeats seeded separately). - private static func frankMessages(now: Date) -> [MessageDTO] { - let key = mockPublicKey(seed: 60).prefix(6) - return [ - MockMessageFactory.message( - id: UUID(uuidString: "60000000-0000-0000-0000-000000000001")!, - createdAt: now.addingTimeInterval(-259_200), - text: "Hey kiddo, how are you?", - direction: .incoming, - contactID: frankWilsonID, - pathLength: encodePathLen(hashSize: 2, hopCount: 2), - snr: 2.1, // weak - pathNodes: Data([0x10, 0x4F, 0x60, 0x9C]), - senderKeyPrefix: key - ), - // Heard-repeat-backed outgoing; heardRepeats matches the seeded repeat rows. - MockMessageFactory.message( - id: frankRepeatMessageID, - createdAt: now.addingTimeInterval(-255_600), - text: "Doing great Dad! How about you?", - direction: .outgoing, - status: .delivered, - contactID: frankWilsonID, - ackCode: 34_567, - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - roundTripTime: 3_200, - heardRepeats: 3 - ), - MockMessageFactory.message( - id: UUID(uuidString: "60000000-0000-0000-0000-000000000003")!, - createdAt: now.addingTimeInterval(-10_800), - text: "Good! Talk soon.", - direction: .incoming, - contactID: frankWilsonID, - pathLength: encodePathLen(hashSize: 2, hopCount: 2), - snr: 0.8, // very weak - pathNodes: Data([0x10, 0x4F, 0x60, 0x9C]), - senderKeyPrefix: key - ), - // Flood-routed incoming carrying a region scope. - MockMessageFactory.message( - id: UUID(uuidString: "60000000-0000-0000-0000-000000000004")!, - createdAt: now.addingTimeInterval(-3_600), - text: "Storm warning for the ridge tonight ⛈️", - direction: .incoming, - contactID: frankWilsonID, - pathLength: encodePathLen(hashSize: 1, hopCount: 3), - snr: 3.2, - pathNodes: Data([0x10, 0x44, 0x60]), - senderKeyPrefix: key, - routeType: .tcFlood, - regionScope: "US915" - ) - ] - } - - /// Hannah (direct): short greeting plus a message with a coordinate (map preview). - private static func hannahMessages(now: Date) -> [MessageDTO] { - let key = mockPublicKey(seed: 80).prefix(6) - let direct = encodePathLen(hashSize: 1, hopCount: 1) - return [ - MockMessageFactory.message( - id: UUID(uuidString: "80000000-0000-0000-0000-000000000001")!, - createdAt: now.addingTimeInterval(-1_200), - text: "Hi! This is Hannah from the trail club 👋", - direction: .incoming, - contactID: hannahLeeID, - pathLength: direct, - snr: 9.0, - pathNodes: Data([0x80]), - senderKeyPrefix: key - ), - MockMessageFactory.message( - id: UUID(uuidString: "80000000-0000-0000-0000-000000000002")!, - createdAt: now.addingTimeInterval(-900), - text: "Hey Hannah! Welcome aboard.", - direction: .outgoing, - status: .delivered, - contactID: hannahLeeID, - ackCode: 45_678, - pathLength: direct - ), - // Coordinate in body renders a map-preview fragment. - MockMessageFactory.message( - id: UUID(uuidString: "80000000-0000-0000-0000-000000000003")!, - createdAt: now.addingTimeInterval(-600), - text: "Meet me at the trailhead: 37.8651, -119.5383", - direction: .incoming, - contactID: hannahLeeID, - pathLength: direct, - snr: 8.7, - pathNodes: Data([0x80]), - senderKeyPrefix: key - ) - ] - } + /// Hannah (direct): short greeting plus a message with a coordinate (map preview). + private static func hannahMessages(now: Date) -> [MessageDTO] { + let key = mockPublicKey(seed: 80).prefix(6) + let direct = encodePathLen(hashSize: 1, hopCount: 1) + return [ + MockMessageFactory.message( + id: UUID(uuidString: "80000000-0000-0000-0000-000000000001")!, + createdAt: now.addingTimeInterval(-1200), + text: "Hi! This is Hannah from the trail club 👋", + direction: .incoming, + contactID: hannahLeeID, + pathLength: direct, + snr: 9.0, + pathNodes: Data([0x80]), + senderKeyPrefix: key + ), + MockMessageFactory.message( + id: UUID(uuidString: "80000000-0000-0000-0000-000000000002")!, + createdAt: now.addingTimeInterval(-900), + text: "Hey Hannah! Welcome aboard.", + direction: .outgoing, + status: .delivered, + contactID: hannahLeeID, + ackCode: 45678, + pathLength: direct + ), + // Coordinate in body renders a map-preview fragment. + MockMessageFactory.message( + id: UUID(uuidString: "80000000-0000-0000-0000-000000000003")!, + createdAt: now.addingTimeInterval(-600), + text: "Meet me at the trailhead: 37.8651, -119.5383", + direction: .incoming, + contactID: hannahLeeID, + pathLength: direct, + snr: 8.7, + pathNodes: Data([0x80]), + senderKeyPrefix: key + ) + ] + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DemoImage.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DemoImage.swift index 11196958..3ae0d807 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DemoImage.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DemoImage.swift @@ -1,18 +1,18 @@ import Foundation extension MockDataProvider { - /// Base64 of the embedded 120x80 gradient PNG used for the offline link-preview hero - /// and inline image. Generated, not hand-authored, and decodes to a valid - /// 1292-byte PNG. Decoded once into ``demoImageData``. - static let demoImageBase64 = """ - iVBORw0KGgoAAAANSUhEUgAAAHgAAABQCAIAAABd+SbeAAAE00lEQVR42u3Q1Y4dVBiA0f0gXHDFo0AIIYQQAiGkIaS0pVBKKW0ppUNl9Iy7u7u7u7u7u7tVaGbSyekc+78bQshO1hMsdRLwodwp8YJ4SbwiXv83qEP/j84dEcfEidgp8YJ4SbwiXsuofb+PjR0Qh8QR - cUyciJ0SL4iXtqhd309M7RH7xAFxSBwRx8SJ2KllatvnUyt2xHaJPWKfOCAOiSPi2ITa9P7Mpi1im9gR2yX2iH3igDg0ota9PhfaIDaJLWKb2BHbJfaI/TNqxfOLVWKNWCc2iE1ii9gmdsTUkseXF5aJFbFVYo1YJzaITWJLQC14fHXJIrFELBMrYqvEGrFObFil5ty/ - NmueWCAWiSVimVgRWyXWLFAzbt9YMUvMEfPEArFILBHLxMq71JTrtxLTxIzYLDFHzBMLxCKx9JaaMFyRmySmiGliRmyWmCPmiQXDFTXq8t0bY8Q4MUFMElPENDFDzMqoYefvjY0Qo2JjxDgxQUwSU8S0LWrQ6aqpIWKYGCFGxcaIcWKCmLRM9Ttes2SAGCSGiGFihBgV - GyPGzVG9jjes6yP6iQFikBgihokRYtSI6na4KdRD9Ir1Ef3EADFIDBHDZ1SH/U+dRBfRTfQQvWJ9RD8xQKi257fOtRMdRCfRRXQTPUSvWJ+Manl2+5JWsTaineggOokuopvosUo1Pb1jVjPRQrSKtRHtRAfRSXRZoBqe3LWikWgimokWolWsjWgnOkyour/v2VRPNBCN - RBPRTLQQrWJtRlSN3QO5WrE6op5oIBqJJqKZaLF7oCrtHr5RRVQTNUQtUSdWTzQQjWKq/PGjCxVEJVFFVBM1RC1RJ1YvoEr/emyqTKycqCAqiSqimqghai1TxY/sLCkhSokysXKigqgkqohqc1Thn0+sKyKKiRKilCgTKycqiMp3qfyHzyQKiEKiiCgmSohSokys/C2V - 84d9LpEnlk8UEIVEEVFMlBAq64HjuWwih8gl8sTyiQKikCiSURn3nY1lEllENpFD5BJ5YvlEgS0q7b7BVDqRQWQSWUQ2kUPkEnkWqJR7blakEmli6UQGkUlkEdlEjgmV9LuHTclECpFKpImlExlEJpFlRCXc9RJKJJKIZCKFSCXSxNKJjDMq9jfvN+KIeLEEIpFIIpKJ - FCJVTEXf8b0QQ8QScUS8WAKRSCQRyQIq8lf/S6KIaCKGiCXiiHixBCLRKhV+O9CsCCKSiCKiiRgilogj4s1RobeDrQsjwsUiiEgiiogmYojYd6ngX0IlQohQIowIF4sgIokoIvotFXgrXC6ICCZCiFAijAgXiyAib4Urv58jz/mLBRCBRBARTIQQoUSYjPL5KdqYL+FH - +IsFEIFEEBFMhNiivG7GmvImfAhfwo/wFwsgAokgy5THj3GWeBJehDfhQ/gSfoS/WIA5yu1Ggk3uYh6EJ+FFeBM+hC/hZ0QZricJuRJuhLuYB+FJeBHehM8Z5XQ9xZlwIQyEK+FGuBMeYp6EcriWdsFRzIlwJlwIA+FKuBHuAur5DxmX2BMOhKOYE+FMuBAGwtUq9fRq - llnPiOeEPeFAOIo5Ec6EiwXqvQ/e1/4FOlpH62hNR+toHa0LdLSO1nS0jtbRekFH62hNR+toHa0XdLSO1nS0jtbRmo7+//gHeCggAUg2kXEAAAAASUVORK5CYII= - """ + /// Base64 of the embedded 120x80 gradient PNG used for the offline link-preview hero + /// and inline image. Generated, not hand-authored, and decodes to a valid + /// 1292-byte PNG. Decoded once into ``demoImageData``. + static let demoImageBase64 = """ + iVBORw0KGgoAAAANSUhEUgAAAHgAAABQCAIAAABd+SbeAAAE00lEQVR42u3Q1Y4dVBiA0f0gXHDFo0AIIYQQAiGkIaS0pVBKKW0ppUNl9Iy7u7u7u7u7u7tVaGbSyekc+78bQshO1hMsdRLwodwp8YJ4SbwiXv83qEP/j84dEcfEidgp8YJ4SbwiXsuofb+PjR0Qh8QR + cUyciJ0SL4iXtqhd309M7RH7xAFxSBwRx8SJ2KllatvnUyt2xHaJPWKfOCAOiSPi2ITa9P7Mpi1im9gR2yX2iH3igDg0ota9PhfaIDaJLWKb2BHbJfaI/TNqxfOLVWKNWCc2iE1ii9gmdsTUkseXF5aJFbFVYo1YJzaITWJLQC14fHXJIrFELBMrYqvEGrFObFil5ty/ + NmueWCAWiSVimVgRWyXWLFAzbt9YMUvMEfPEArFILBHLxMq71JTrtxLTxIzYLDFHzBMLxCKx9JaaMFyRmySmiGliRmyWmCPmiQXDFTXq8t0bY8Q4MUFMElPENDFDzMqoYefvjY0Qo2JjxDgxQUwSU8S0LWrQ6aqpIWKYGCFGxcaIcWKCmLRM9Ttes2SAGCSGiGFihBgV + GyPGzVG9jjes6yP6iQFikBgihokRYtSI6na4KdRD9Ir1Ef3EADFIDBHDZ1SH/U+dRBfRTfQQvWJ9RD8xQKi257fOtRMdRCfRRXQTPUSvWJ+Manl2+5JWsTaineggOokuopvosUo1Pb1jVjPRQrSKtRHtRAfRSXRZoBqe3LWikWgimokWolWsjWgnOkyour/v2VRPNBCN + RBPRTLQQrWJtRlSN3QO5WrE6op5oIBqJJqKZaLF7oCrtHr5RRVQTNUQtUSdWTzQQjWKq/PGjCxVEJVFFVBM1RC1RJ1YvoEr/emyqTKycqCAqiSqimqghai1TxY/sLCkhSokysXKigqgkqohqc1Thn0+sKyKKiRKilCgTKycqiMp3qfyHzyQKiEKiiCgmSohSokys/C2V + 84d9LpEnlk8UEIVEEVFMlBAq64HjuWwih8gl8sTyiQKikCiSURn3nY1lEllENpFD5BJ5YvlEgS0q7b7BVDqRQWQSWUQ2kUPkEnkWqJR7blakEmli6UQGkUlkEdlEjgmV9LuHTclECpFKpImlExlEJpFlRCXc9RJKJJKIZCKFSCXSxNKJjDMq9jfvN+KIeLEEIpFIIpKJ + FCJVTEXf8b0QQ8QScUS8WAKRSCQRyQIq8lf/S6KIaCKGiCXiiHixBCLRKhV+O9CsCCKSiCKiiRgilogj4s1RobeDrQsjwsUiiEgiiogmYojYd6ngX0IlQohQIowIF4sgIokoIvotFXgrXC6ICCZCiFAijAgXiyAib4Urv58jz/mLBRCBRBARTIQQoUSYjPL5KdqYL+FH + +IsFEIFEEBFMhNiivG7GmvImfAhfwo/wFwsgAokgy5THj3GWeBJehDfhQ/gSfoS/WIA5yu1Ggk3uYh6EJ+FFeBM+hC/hZ0QZricJuRJuhLuYB+FJeBHehM8Z5XQ9xZlwIQyEK+FGuBMeYp6EcriWdsFRzIlwJlwIA+FKuBHuAur5DxmX2BMOhKOYE+FMuBAGwtUq9fRq + llnPiOeEPeFAOIo5Ec6EiwXqvQ/e1/4FOlpH62hNR+toHa0LdLSO1nS0jtbRekFH62hNR+toHa0XdLSO1nS0jtbRmo7+//gHeCggAUg2kXEAAAAASUVORK5CYII= + """ } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Reactions.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Reactions.swift index 2bf37f66..f206994d 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Reactions.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Reactions.swift @@ -1,58 +1,57 @@ import Foundation extension MockDataProvider { + /// Messages that carry seeded reactions, paired with the denormalized badge + /// summary. `saveMessage` drops `reactionSummary`, so the summary is written via + /// `updateMessageReactionSummary`; the per-reactor rows render the detail list. + /// Each summary's counts match the rows returned by `reactions(for:)`. + static let reactedMessages: [(messageID: UUID, summary: String)] = [ + (aliceReactedMessageID, "👍:2,❤️:1"), + (bayAreaReactedMessageID, "🎉:2"), + (bayAreaMentionMessageID, "👍:1") + ] - /// Messages that carry seeded reactions, paired with the denormalized badge - /// summary. `saveMessage` drops `reactionSummary`, so the summary is written via - /// `updateMessageReactionSummary`; the per-reactor rows render the detail list. - /// Each summary's counts match the rows returned by `reactions(for:)`. - static let reactedMessages: [(messageID: UUID, summary: String)] = [ - (aliceReactedMessageID, "👍:2,❤️:1"), - (bayAreaReactedMessageID, "🎉:2"), - (bayAreaMentionMessageID, "👍:1") - ] - - /// Per-reactor reaction rows for the reactor-detail list. - static func reactions(for messageID: UUID) -> [ReactionDTO] { - switch messageID { - case aliceReactedMessageID: - return [ - reaction("A0000000-0000-0000-0000-000000000001", messageID, "👍", "You", contactID: aliceChenID), - reaction("A0000000-0000-0000-0000-000000000002", messageID, "👍", "Bob Martinez", contactID: aliceChenID), - reaction("A0000000-0000-0000-0000-000000000003", messageID, "❤️", "Alice Chen", contactID: aliceChenID) - ] - case bayAreaReactedMessageID: - return [ - reaction("A1000000-0000-0000-0000-000000000001", messageID, "🎉", "Alice Chen", channelIndex: bayAreaChannelIndex), - reaction("A1000000-0000-0000-0000-000000000002", messageID, "🎉", "Bob Martinez", channelIndex: bayAreaChannelIndex) - ] - case bayAreaMentionMessageID: - return [ - reaction("A1000000-0000-0000-0000-000000000003", messageID, "👍", "Sim", channelIndex: bayAreaChannelIndex) - ] - default: - return [] - } + /// Per-reactor reaction rows for the reactor-detail list. + static func reactions(for messageID: UUID) -> [ReactionDTO] { + switch messageID { + case aliceReactedMessageID: + [ + reaction("A0000000-0000-0000-0000-000000000001", messageID, "👍", "You", contactID: aliceChenID), + reaction("A0000000-0000-0000-0000-000000000002", messageID, "👍", "Bob Martinez", contactID: aliceChenID), + reaction("A0000000-0000-0000-0000-000000000003", messageID, "❤️", "Alice Chen", contactID: aliceChenID) + ] + case bayAreaReactedMessageID: + [ + reaction("A1000000-0000-0000-0000-000000000001", messageID, "🎉", "Alice Chen", channelIndex: bayAreaChannelIndex), + reaction("A1000000-0000-0000-0000-000000000002", messageID, "🎉", "Bob Martinez", channelIndex: bayAreaChannelIndex) + ] + case bayAreaMentionMessageID: + [ + reaction("A1000000-0000-0000-0000-000000000003", messageID, "👍", "Sim", channelIndex: bayAreaChannelIndex) + ] + default: + [] } + } - private static func reaction( - _ id: String, - _ messageID: UUID, - _ emoji: String, - _ sender: String, - contactID: UUID? = nil, - channelIndex: UInt8? = nil - ) -> ReactionDTO { - ReactionDTO( - id: UUID(uuidString: id)!, - messageID: messageID, - emoji: emoji, - senderName: sender, - messageHash: String(messageID.uuidString.prefix(8)), - rawText: emoji, - channelIndex: channelIndex, - contactID: contactID, - radioID: simulatorDeviceID - ) - } + private static func reaction( + _ id: String, + _ messageID: UUID, + _ emoji: String, + _ sender: String, + contactID: UUID? = nil, + channelIndex: UInt8? = nil + ) -> ReactionDTO { + ReactionDTO( + id: UUID(uuidString: id)!, + messageID: messageID, + emoji: emoji, + senderName: sender, + messageHash: String(messageID.uuidString.prefix(8)), + rawText: emoji, + channelIndex: channelIndex, + contactID: contactID, + radioID: simulatorDeviceID + ) + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Repeats.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Repeats.swift index db78a77e..84ef22d4 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Repeats.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Repeats.swift @@ -1,47 +1,46 @@ import Foundation extension MockDataProvider { + /// Message IDs that carry seeded heard-repeat rows ("Repeat Details"). + static let messagesWithRepeats: [UUID] = [frankRepeatMessageID] - /// Message IDs that carry seeded heard-repeat rows ("Repeat Details"). - static let messagesWithRepeats: [UUID] = [frankRepeatMessageID] - - /// Heard repeats for a message: distinct repeater-hash prefixes, hop counts, and - /// signal stats so "Repeat Details" lists multiple repeaters. The count matches the - /// parent message's `heardRepeats`. - static func messageRepeats(for messageID: UUID) -> [MessageRepeatDTO] { - guard messageID == frankRepeatMessageID else { return [] } - let now = Date() - return [ - MessageRepeatDTO( - id: UUID(uuidString: "B0000000-0000-0000-0000-000000000001")!, - messageID: messageID, - receivedAt: now.addingTimeInterval(-255_590), - pathNodes: Data([0x31]), // 1 hop, 1-byte hash - pathLength: encodePathLen(hashSize: 1, hopCount: 1), - snr: 6.5, - rssi: -92, - rxLogEntryID: nil - ), - MessageRepeatDTO( - id: UUID(uuidString: "B0000000-0000-0000-0000-000000000002")!, - messageID: messageID, - receivedAt: now.addingTimeInterval(-255_585), - pathNodes: Data([0x8F, 0x2C]), // 1 hop, 2-byte hash - pathLength: encodePathLen(hashSize: 2, hopCount: 1), - snr: 4.2, - rssi: -101, - rxLogEntryID: nil - ), - MessageRepeatDTO( - id: UUID(uuidString: "B0000000-0000-0000-0000-000000000003")!, - messageID: messageID, - receivedAt: now.addingTimeInterval(-255_580), - pathNodes: Data([0x44, 0x71]), // 2 hops, 1-byte hash - pathLength: encodePathLen(hashSize: 1, hopCount: 2), - snr: 1.9, - rssi: -108, - rxLogEntryID: nil - ) - ] - } + /// Heard repeats for a message: distinct repeater-hash prefixes, hop counts, and + /// signal stats so "Repeat Details" lists multiple repeaters. The count matches the + /// parent message's `heardRepeats`. + static func messageRepeats(for messageID: UUID) -> [MessageRepeatDTO] { + guard messageID == frankRepeatMessageID else { return [] } + let now = Date() + return [ + MessageRepeatDTO( + id: UUID(uuidString: "B0000000-0000-0000-0000-000000000001")!, + messageID: messageID, + receivedAt: now.addingTimeInterval(-255_590), + pathNodes: Data([0x31]), // 1 hop, 1-byte hash + pathLength: encodePathLen(hashSize: 1, hopCount: 1), + snr: 6.5, + rssi: -92, + rxLogEntryID: nil + ), + MessageRepeatDTO( + id: UUID(uuidString: "B0000000-0000-0000-0000-000000000002")!, + messageID: messageID, + receivedAt: now.addingTimeInterval(-255_585), + pathNodes: Data([0x8F, 0x2C]), // 1 hop, 2-byte hash + pathLength: encodePathLen(hashSize: 2, hopCount: 1), + snr: 4.2, + rssi: -101, + rxLogEntryID: nil + ), + MessageRepeatDTO( + id: UUID(uuidString: "B0000000-0000-0000-0000-000000000003")!, + messageID: messageID, + receivedAt: now.addingTimeInterval(-255_580), + pathNodes: Data([0x44, 0x71]), // 2 hops, 1-byte hash + pathLength: encodePathLen(hashSize: 1, hopCount: 2), + snr: 1.9, + rssi: -108, + rxLogEntryID: nil + ) + ] + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift index 3e61d4ea..4d42a89f 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift @@ -5,95 +5,95 @@ import Foundation /// seed data lives in `MockDataProvider+…` extensions; this file holds the shared /// identity constants, the simulated device, and the offline demo image. public enum MockDataProvider { - // MARK: - Deterministic IDs + // MARK: - Deterministic IDs - /// Simulator device UUID - public static let simulatorDeviceID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + /// Simulator device UUID + public static let simulatorDeviceID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! - /// Contact UUIDs - public static let aliceChenID = UUID(uuidString: "00000000-0000-0000-0000-000000000010")! - public static let bobMartinezID = UUID(uuidString: "00000000-0000-0000-0000-000000000020")! - public static let charlieNodeID = UUID(uuidString: "00000000-0000-0000-0000-000000000030")! - public static let dianasRoomID = UUID(uuidString: "00000000-0000-0000-0000-000000000040")! - public static let eveThompsonID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")! - public static let frankWilsonID = UUID(uuidString: "00000000-0000-0000-0000-000000000060")! - public static let ghostNodeID = UUID(uuidString: "00000000-0000-0000-0000-000000000070")! - public static let hannahLeeID = UUID(uuidString: "00000000-0000-0000-0000-000000000080")! + /// Contact UUIDs + public static let aliceChenID = UUID(uuidString: "00000000-0000-0000-0000-000000000010")! + public static let bobMartinezID = UUID(uuidString: "00000000-0000-0000-0000-000000000020")! + public static let charlieNodeID = UUID(uuidString: "00000000-0000-0000-0000-000000000030")! + public static let dianasRoomID = UUID(uuidString: "00000000-0000-0000-0000-000000000040")! + public static let eveThompsonID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")! + public static let frankWilsonID = UUID(uuidString: "00000000-0000-0000-0000-000000000060")! + public static let ghostNodeID = UUID(uuidString: "00000000-0000-0000-0000-000000000070")! + public static let hannahLeeID = UUID(uuidString: "00000000-0000-0000-0000-000000000080")! - /// Channel UUIDs - public static let publicChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000000")! - public static let bayAreaChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000001")! - public static let trailCrewChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000002")! + /// Channel UUIDs + public static let publicChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000000")! + public static let bayAreaChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000001")! + public static let trailCrewChannelID = UUID(uuidString: "000000C0-0000-0000-0000-000000000002")! - /// Channel slot indices (mirror firmware slot positions) - public static let publicChannelIndex: UInt8 = 0 - public static let bayAreaChannelIndex: UInt8 = 1 - public static let trailCrewChannelIndex: UInt8 = 2 + /// Channel slot indices (mirror firmware slot positions) + public static let publicChannelIndex: UInt8 = 0 + public static let bayAreaChannelIndex: UInt8 = 1 + public static let trailCrewChannelIndex: UInt8 = 2 - /// Message IDs referenced by more than one seed helper (reaction / repeat / - /// link-preview targets) so the builders and the post-save mutators agree. - static let aliceReactedMessageID = UUID(uuidString: "10000000-0000-0000-0000-000000000002")! - static let aliceLinkPreviewMessageID = UUID(uuidString: "10000000-0000-0000-0000-00000000000A")! - static let frankRepeatMessageID = UUID(uuidString: "60000000-0000-0000-0000-000000000002")! - static let bayAreaReactedMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000002")! - static let bayAreaMentionMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000003")! + /// Message IDs referenced by more than one seed helper (reaction / repeat / + /// link-preview targets) so the builders and the post-save mutators agree. + static let aliceReactedMessageID = UUID(uuidString: "10000000-0000-0000-0000-000000000002")! + static let aliceLinkPreviewMessageID = UUID(uuidString: "10000000-0000-0000-0000-00000000000A")! + static let frankRepeatMessageID = UUID(uuidString: "60000000-0000-0000-0000-000000000002")! + static let bayAreaReactedMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000002")! + static let bayAreaMentionMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000003")! - // MARK: - Mock Public Keys + // MARK: - Mock Public Keys - /// Deterministic 32-byte public key from a seed. Internal so the per-feature - /// builders in sibling files can resolve sender key prefixes. - static func mockPublicKey(seed: UInt8) -> Data { - Data((0.. Data { + Data((0.. MessageDTO { - MessageDTO( - id: id, - radioID: MockDataProvider.simulatorDeviceID, - contactID: contactID, - channelIndex: channelIndex, - text: text, - timestamp: UInt32(createdAt.timeIntervalSince1970), - createdAt: createdAt, - direction: direction, - status: status, - textType: textType, - ackCode: ackCode, - pathLength: pathLength, - snr: snr, - pathNodes: pathNodes, - senderKeyPrefix: senderKeyPrefix, - senderNodeName: senderNodeName, - isRead: isRead, - replyToID: replyToID, - roundTripTime: roundTripTime, - heardRepeats: heardRepeats, - retryAttempt: retryAttempt, - maxRetryAttempts: maxRetryAttempts, - containsSelfMention: containsSelfMention, - mentionSeen: mentionSeen, - timestampCorrected: timestampCorrected, - senderTimestamp: senderTimestamp, - routeType: routeType, - regionScope: regionScope - ) - } + static func message( + id: UUID, + createdAt: Date, + text: String, + direction: MessageDirection, + status: MessageStatus = .delivered, + contactID: UUID? = nil, + channelIndex: UInt8? = nil, + textType: TextType = .plain, + ackCode: UInt32? = nil, + pathLength: UInt8 = 1, + snr: Double? = nil, + pathNodes: Data? = nil, + senderKeyPrefix: Data? = nil, + senderNodeName: String? = nil, + isRead: Bool = true, + replyToID: UUID? = nil, + roundTripTime: UInt32? = nil, + heardRepeats: Int = 0, + retryAttempt: Int = 0, + maxRetryAttempts: Int = 3, + containsSelfMention: Bool = false, + mentionSeen: Bool = false, + timestampCorrected: Bool = false, + senderTimestamp: UInt32? = nil, + routeType: RouteType? = nil, + regionScope: String? = nil + ) -> MessageDTO { + MessageDTO( + id: id, + radioID: MockDataProvider.simulatorDeviceID, + contactID: contactID, + channelIndex: channelIndex, + text: text, + timestamp: UInt32(createdAt.timeIntervalSince1970), + createdAt: createdAt, + direction: direction, + status: status, + textType: textType, + ackCode: ackCode, + pathLength: pathLength, + snr: snr, + pathNodes: pathNodes, + senderKeyPrefix: senderKeyPrefix, + senderNodeName: senderNodeName, + isRead: isRead, + replyToID: replyToID, + roundTripTime: roundTripTime, + heardRepeats: heardRepeats, + retryAttempt: retryAttempt, + maxRetryAttempts: maxRetryAttempts, + containsSelfMention: containsSelfMention, + mentionSeen: mentionSeen, + timestampCorrected: timestampCorrected, + senderTimestamp: senderTimestamp, + routeType: routeType, + regionScope: regionScope + ) + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift b/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift index 7d89be2e..0c0b1ffd 100644 --- a/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift +++ b/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift @@ -1,97 +1,96 @@ import Foundation -import SwiftData import OSLog +import SwiftData /// Connection mode for simulator and demo mode on device. /// Provides mock data and simulated connections without requiring real hardware. @MainActor final class SimulatorConnectionMode { - - private let logger = PersistentLogger(subsystem: "com.mc1", category: "SimulatorConnectionMode") - - /// Whether simulator is "connected" - private(set) var isConnected = false - - /// The simulated device - var device: DeviceDTO? { - isConnected ? MockDataProvider.simulatorDevice : nil + private let logger = PersistentLogger(subsystem: "com.mc1", category: "SimulatorConnectionMode") + + /// Whether simulator is "connected" + private(set) var isConnected = false + + /// The simulated device + var device: DeviceDTO? { + isConnected ? MockDataProvider.simulatorDevice : nil + } + + init() {} + + /// Simulates connecting to the simulator device + func connect() async { + logger.info("Simulator: connecting to mock device") + try? await Task.sleep(for: .milliseconds(200)) // Brief delay + isConnected = true + logger.info("Simulator: connected") + } + + /// Simulates disconnecting + func disconnect() async { + logger.info("Simulator: disconnecting") + isConnected = false + } + + /// Seeds the data store with mock data. Each message is saved before its + /// link-preview, reaction, and repeat rows: `saveMessage` does not persist the + /// link-preview or `reactionSummary` columns, and `saveMessageRepeat` needs the + /// parent present. Re-seeding upserts on each row's unique `id`, so it is idempotent. + func seedDataStore(_ dataStore: PersistenceStore) async throws { + try await dataStore.saveDevice(MockDataProvider.simulatorDevice) + + for contact in MockDataProvider.contacts { + try await dataStore.saveContact(contact) } - init() {} - - /// Simulates connecting to the simulator device - func connect() async { - logger.info("Simulator: connecting to mock device") - try? await Task.sleep(for: .milliseconds(200)) // Brief delay - isConnected = true - logger.info("Simulator: connected") + for channel in MockDataProvider.channels { + try await dataStore.saveChannel(channel) } - /// Simulates disconnecting - func disconnect() async { - logger.info("Simulator: disconnecting") - isConnected = false + for contact in MockDataProvider.contacts { + for message in MockDataProvider.messages(for: contact.id) { + try await dataStore.saveMessage(message) + } } - /// Seeds the data store with mock data. Each message is saved before its - /// link-preview, reaction, and repeat rows: `saveMessage` does not persist the - /// link-preview or `reactionSummary` columns, and `saveMessageRepeat` needs the - /// parent present. Re-seeding upserts on each row's unique `id`, so it is idempotent. - func seedDataStore(_ dataStore: PersistenceStore) async throws { - try await dataStore.saveDevice(MockDataProvider.simulatorDevice) - - for contact in MockDataProvider.contacts { - try await dataStore.saveContact(contact) - } - - for channel in MockDataProvider.channels { - try await dataStore.saveChannel(channel) - } - - for contact in MockDataProvider.contacts { - for message in MockDataProvider.messages(for: contact.id) { - try await dataStore.saveMessage(message) - } - } - - for channel in MockDataProvider.channels { - for message in MockDataProvider.channelMessages(for: channel.index) { - try await dataStore.saveMessage(message) - } - } - - // Link previews render offline from the message-owned columns, which saveMessage drops. - for seed in MockDataProvider.linkPreviewSeeds { - try await dataStore.updateMessageLinkPreview( - id: seed.messageID, - url: seed.url, - title: seed.title, - imageData: seed.imageData, - iconData: nil, - fetched: true - ) - } + for channel in MockDataProvider.channels { + for message in MockDataProvider.channelMessages(for: channel.index) { + try await dataStore.saveMessage(message) + } + } - // Reaction rows feed the reactor-detail list; the summary drives the badge. - for reacted in MockDataProvider.reactedMessages { - for reaction in MockDataProvider.reactions(for: reacted.messageID) { - try await dataStore.saveReaction(reaction) - } - try await dataStore.updateMessageReactionSummary( - messageID: reacted.messageID, - summary: reacted.summary - ) - } + // Link previews render offline from the message-owned columns, which saveMessage drops. + for seed in MockDataProvider.linkPreviewSeeds { + try await dataStore.updateMessageLinkPreview( + id: seed.messageID, + url: seed.url, + title: seed.title, + imageData: seed.imageData, + iconData: nil, + fetched: true + ) + } - for messageID in MockDataProvider.messagesWithRepeats { - for repeatRow in MockDataProvider.messageRepeats(for: messageID) { - try await dataStore.saveMessageRepeat(repeatRow) - } - } + // Reaction rows feed the reactor-detail list; the summary drives the badge. + for reacted in MockDataProvider.reactedMessages { + for reaction in MockDataProvider.reactions(for: reacted.messageID) { + try await dataStore.saveReaction(reaction) + } + try await dataStore.updateMessageReactionSummary( + messageID: reacted.messageID, + summary: reacted.summary + ) + } - logger.info( - "Simulator: seeded \(MockDataProvider.contacts.count) contacts and " + - "\(MockDataProvider.channels.count) channels with messages" - ) + for messageID in MockDataProvider.messagesWithRepeats { + for repeatRow in MockDataProvider.messageRepeats(for: messageID) { + try await dataStore.saveMessageRepeat(repeatRow) + } } + + logger.info( + "Simulator: seeded \(MockDataProvider.contacts.count) contacts and " + + "\(MockDataProvider.channels.count) channels with messages" + ) + } } diff --git a/MC1Services/Sources/MC1Services/Simulator/SimulatorMockTransport.swift b/MC1Services/Sources/MC1Services/Simulator/SimulatorMockTransport.swift index f32e916e..385892b2 100644 --- a/MC1Services/Sources/MC1Services/Simulator/SimulatorMockTransport.swift +++ b/MC1Services/Sources/MC1Services/Simulator/SimulatorMockTransport.swift @@ -5,37 +5,37 @@ import MeshCore /// This is a minimal stub that fulfills the MeshTransport protocol /// but doesn't actually communicate with a device. actor SimulatorMockTransport: MeshTransport { - private let continuation: AsyncStream.Continuation - private var _isConnected = false + private let continuation: AsyncStream.Continuation + private var _isConnected = false - /// Stream of received data (always empty for simulator) - let receivedData: AsyncStream + /// Stream of received data (always empty for simulator) + let receivedData: AsyncStream - var isConnected: Bool { - _isConnected - } + var isConnected: Bool { + _isConnected + } - init() { - var cont: AsyncStream.Continuation! - receivedData = AsyncStream { continuation in - cont = continuation - } - self.continuation = cont + init() { + var cont: AsyncStream.Continuation! + receivedData = AsyncStream { continuation in + cont = continuation } + continuation = cont + } - func connect() async throws { - _isConnected = true - } + func connect() async throws { + _isConnected = true + } - func disconnect() async { - continuation.finish() - _isConnected = false - } + func disconnect() async { + continuation.finish() + _isConnected = false + } - func send(_ data: Data) async throws { - guard _isConnected else { - throw MeshTransportError.notConnected - } - // Simulator transport doesn't actually send data + func send(_ data: Data) async throws { + guard _isConnected else { + throw MeshTransportError.notConnected } + // Simulator transport doesn't actually send data + } } diff --git a/MC1Services/Sources/MC1Services/Sync/ConnectionManager+SyncRetry.swift b/MC1Services/Sources/MC1Services/Sync/ConnectionManager+SyncRetry.swift index 705ce6a8..ffa0f13e 100644 --- a/MC1Services/Sources/MC1Services/Sync/ConnectionManager+SyncRetry.swift +++ b/MC1Services/Sources/MC1Services/Sync/ConnectionManager+SyncRetry.swift @@ -5,295 +5,293 @@ import Foundation /// (`resyncTask`, `channelRetryTask`) and `resyncAttemptCount` live on /// `ConnectionManager` itself because extensions cannot add instance storage. extension ConnectionManager { + // MARK: - Retry Policy Constants + + /// Maximum resync attempts before giving up + static let maxResyncAttempts = 3 + + /// Interval between resync attempts + static let resyncInterval: Duration = .seconds(2) + + static let maxChannelRetryAttempts = 2 + static let channelRetryInitialDelay: Duration = .seconds(2) + + // MARK: - Cancellation Helpers + + /// Cancels any resync retry loop in progress. + /// The cancelled task's catch-all calls endResyncActivity(succeeded: false) asynchronously, + /// but callers that also trigger handleDisconnect don't need to wait for it; + /// handleDisconnect zeroes syncActivityCount independently, and the onEnded callback's + /// guard (syncActivityCount > 0) prevents underflow. + func cancelResyncLoop() { + resyncTask?.cancel() + resyncTask = nil + resyncAttemptCount = 0 + } + + func cancelChannelRetry() { + channelRetryTask?.cancel() + channelRetryTask = nil + } + + // MARK: - Initial Sync + + /// Performs initial sync with automatic resync loop on failure. + /// Returns `true` if sync completed successfully, `false` if it failed and a resync loop was started. + /// - Parameters: + /// - radioID: The radio ID for data scoping + /// - services: The service container + /// - transportType: The transport being used (determines whether BLE throttling applies) + /// - context: Optional context string for logging (e.g., "WiFi reconnect") + /// - forceFullSync: When true, forces complete data exchange regardless of sync state + func performInitialSync( + radioID: UUID, + services: ServiceContainer, + transportType: TransportType = .bluetooth, + context: String = "", + forceFullSync: Bool = false + ) async -> Bool { + let channelSyncConfig = currentChannelSyncConfig(for: radioID, transportType: transportType) + do { + let result = try await services.syncCoordinator.onConnectionEstablished( + radioID: radioID, + dependencies: services.syncDependencies, + forceFullSync: forceFullSync, + channelSyncConfig: channelSyncConfig, + platformName: "\(detectedPlatform)" + ) + + if !result.channelRetryIndices.isEmpty { + scheduleChannelOnlyRetry( + radioID: radioID, + services: services, + indices: result.channelRetryIndices + ) + } + + if result.isConnectionUsable { + return true + } + + guard connectionIntent.wantsConnection else { return false } + let prefix = context.isEmpty ? "" : "\(context): " + logger.warning("\(prefix)Initial sync did not produce usable contacts, starting resync loop") + startResyncLoop(radioID: radioID, services: services, transportType: transportType, forceFullSync: forceFullSync) + return false + } catch { + // Don't start resync if user disconnected while sync was in progress + guard connectionIntent.wantsConnection else { return false } + let prefix = context.isEmpty ? "" : "\(context): " + logger.warning("\(prefix)Initial sync failed, starting resync loop: \(error.localizedDescription)") + startResyncLoop(radioID: radioID, services: services, transportType: transportType, forceFullSync: forceFullSync) + return false + } + } + + /// Starts a retry loop to resync after initial sync failure. + /// Retries every 2 seconds, shows "Sync Failed" pill and disconnects after 3 failures. + /// Holds a sync activity bracket so the "Syncing" pill stays visible across retries. + /// - Parameters: + /// - radioID: The radio ID for data scoping + /// - services: The ServiceContainer with all services + /// - forceFullSync: When true, forces complete data exchange regardless of sync state + func startResyncLoop( + radioID: UUID, + services: ServiceContainer, + transportType: TransportType = .bluetooth, + forceFullSync: Bool = false + ) { + resyncTask?.cancel() + resyncAttemptCount = 0 + + // Note: No [weak self] needed - Task is stored property, self is @MainActor class. + // Task inherits MainActor isolation, no retain cycle risk. + resyncTask = Task { + // Hold sync activity for the entire resync loop so the "Syncing" pill stays visible. + // Must be inside the task body: placing it before task assignment introduced a + // suspension point where resyncTask was still nil, breaking the dedup guard in + // checkSyncHealth(). + await services.syncCoordinator.beginResyncActivity() + var didEndResyncActivity = false + + while !Task.isCancelled { + try? await Task.sleep(for: Self.resyncInterval) + guard !Task.isCancelled else { break } + + guard connectionIntent.wantsConnection, + connectionState.isOperational else { break } + + resyncAttemptCount += 1 + logger.info("Resync attempt \(resyncAttemptCount)/\(Self.maxResyncAttempts)") + + let channelSyncConfig = self.currentChannelSyncConfig(for: radioID, transportType: transportType) + let success = await services.syncCoordinator.performResync( + radioID: radioID, + dependencies: services.syncDependencies, + forceFullSync: forceFullSync, + channelSyncConfig: channelSyncConfig, + platformName: "\(self.detectedPlatform)" + ) - // MARK: - Retry Policy Constants - - /// Maximum resync attempts before giving up - static let maxResyncAttempts = 3 + if success { + logger.info("Resync succeeded") + resyncAttemptCount = 0 + + // Run post-sync hooks deferred when initial sync failed. + // Guard each await: disconnect(), device switch, or a new + // reconnect cycle may have torn down the connection. + guard !Task.isCancelled, + connectionIntent.wantsConnection, + connectionState.isOperational, + self.services === services else { break } + + await syncDeviceTimeIfNeeded() + + guard !Task.isCancelled, + connectionIntent.wantsConnection, + connectionState.isOperational, + self.services === services else { break } + + // Re-authenticate room sessions before onDeviceSynced to avoid + // BLE contention with stale node cleanup's fire-and-forget Task. + let sessionIDs = sessionsAwaitingReauth + if !sessionIDs.isEmpty { + await services.remoteNodeService.handleBLEReconnection(sessionIDs: sessionIDs) + } + + guard !Task.isCancelled, + connectionIntent.wantsConnection, + connectionState.isOperational, + self.services === services else { break } + + // Report success only after confirming the loop is still authoritative. + // Earlier placement fired the "Ready" toast before these guards, + // relying on handleDisconnect as an accidental backstop. + await services.syncCoordinator.endResyncActivity(succeeded: true) + didEndResyncActivity = true + + // Only clear consumed IDs after confirming the loop is still valid. + // Any IDs appended during the await (via teardownSessionForReconnect) survive. + sessionsAwaitingReauth.subtract(sessionIDs) + + // Promote from .syncing to .ready now that sync completed. + // Not using promoteToReady() because: (1) its guards (services identity, + // connectionIntent) are already checked above, and (2) it would re-run + // time sync and onDeviceSynced, duplicating the resync loop's own post-sync work. + connectionState = .ready + + await onDeviceSynced?() + + break + } - /// Interval between resync attempts - static let resyncInterval: Duration = .seconds(2) + if resyncAttemptCount >= Self.maxResyncAttempts { + logger.warning("Resync failed \(Self.maxResyncAttempts) times, disconnecting") + await services.syncCoordinator.endResyncActivity(succeeded: false) + didEndResyncActivity = true + onResyncFailed?() + await disconnect(reason: .resyncFailed) + break + } + } + + // Catch-all for cancellation or guard exits + if !didEndResyncActivity { + await services.syncCoordinator.endResyncActivity(succeeded: false) + } + + // Only nil resyncTask if this task wasn't cancelled. When startResyncLoop() + // is called while a previous loop is running, it cancels the old task and + // assigns a new one. The old task's catch-all must not nil resyncTask or it + // would destroy the replacement. + if !Task.isCancelled { + resyncTask = nil + } + } + } - static let maxChannelRetryAttempts = 2 - static let channelRetryInitialDelay: Duration = .seconds(2) + /// Schedules a bounded channel-only retry after a partial channel phase. + /// This keeps contacts/messages out of the retry path when the connection is otherwise usable. + func scheduleChannelOnlyRetry( + radioID: UUID, + services: ServiceContainer, + indices: [UInt8] + ) { + let initialIndices = Array(Set(indices)).sorted() + guard !initialIndices.isEmpty else { return } - // MARK: - Cancellation Helpers + channelRetryTask?.cancel() - /// Cancels any resync retry loop in progress. - /// The cancelled task's catch-all calls endResyncActivity(succeeded: false) asynchronously, - /// but callers that also trigger handleDisconnect don't need to wait for it; - /// handleDisconnect zeroes syncActivityCount independently, and the onEnded callback's - /// guard (syncActivityCount > 0) prevents underflow. - func cancelResyncLoop() { - resyncTask?.cancel() - resyncTask = nil - resyncAttemptCount = 0 - } + channelRetryTask = Task { + var pendingIndices = initialIndices - func cancelChannelRetry() { - channelRetryTask?.cancel() - channelRetryTask = nil - } + for attempt in 1...Self.maxChannelRetryAttempts { + guard !Task.isCancelled else { break } - // MARK: - Initial Sync - - /// Performs initial sync with automatic resync loop on failure. - /// Returns `true` if sync completed successfully, `false` if it failed and a resync loop was started. - /// - Parameters: - /// - radioID: The radio ID for data scoping - /// - services: The service container - /// - transportType: The transport being used (determines whether BLE throttling applies) - /// - context: Optional context string for logging (e.g., "WiFi reconnect") - /// - forceFullSync: When true, forces complete data exchange regardless of sync state - func performInitialSync( - radioID: UUID, - services: ServiceContainer, - transportType: TransportType = .bluetooth, - context: String = "", - forceFullSync: Bool = false - ) async -> Bool { - let channelSyncConfig = currentChannelSyncConfig(for: radioID, transportType: transportType) + let delaySeconds = 2 << (attempt - 1) + let delay = max(Self.channelRetryInitialDelay, .seconds(delaySeconds)) do { - let result = try await services.syncCoordinator.onConnectionEstablished( - radioID: radioID, - dependencies: services.syncDependencies, - forceFullSync: forceFullSync, - channelSyncConfig: channelSyncConfig, - platformName: "\(self.detectedPlatform)" - ) - - if !result.channelRetryIndices.isEmpty { - scheduleChannelOnlyRetry( - radioID: radioID, - services: services, - indices: result.channelRetryIndices - ) - } - - if result.isConnectionUsable { - return true - } - - guard connectionIntent.wantsConnection else { return false } - let prefix = context.isEmpty ? "" : "\(context): " - logger.warning("\(prefix)Initial sync did not produce usable contacts, starting resync loop") - startResyncLoop(radioID: radioID, services: services, transportType: transportType, forceFullSync: forceFullSync) - return false + try await Task.sleep(for: delay) } catch { - // Don't start resync if user disconnected while sync was in progress - guard connectionIntent.wantsConnection else { return false } - let prefix = context.isEmpty ? "" : "\(context): " - logger.warning("\(prefix)Initial sync failed, starting resync loop: \(error.localizedDescription)") - startResyncLoop(radioID: radioID, services: services, transportType: transportType, forceFullSync: forceFullSync) - return false + break } - } - /// Starts a retry loop to resync after initial sync failure. - /// Retries every 2 seconds, shows "Sync Failed" pill and disconnects after 3 failures. - /// Holds a sync activity bracket so the "Syncing" pill stays visible across retries. - /// - Parameters: - /// - radioID: The radio ID for data scoping - /// - services: The ServiceContainer with all services - /// - forceFullSync: When true, forces complete data exchange regardless of sync state - func startResyncLoop( - radioID: UUID, - services: ServiceContainer, - transportType: TransportType = .bluetooth, - forceFullSync: Bool = false - ) { - resyncTask?.cancel() - resyncAttemptCount = 0 - - // Note: No [weak self] needed - Task is stored property, self is @MainActor class. - // Task inherits MainActor isolation, no retain cycle risk. - resyncTask = Task { - // Hold sync activity for the entire resync loop so the "Syncing" pill stays visible. - // Must be inside the task body: placing it before task assignment introduced a - // suspension point where resyncTask was still nil, breaking the dedup guard in - // checkSyncHealth(). - await services.syncCoordinator.beginResyncActivity() - var didEndResyncActivity = false - - while !Task.isCancelled { - try? await Task.sleep(for: Self.resyncInterval) - guard !Task.isCancelled else { break } - - guard connectionIntent.wantsConnection, - connectionState.isOperational else { break } - - resyncAttemptCount += 1 - logger.info("Resync attempt \(resyncAttemptCount)/\(Self.maxResyncAttempts)") - - let channelSyncConfig = self.currentChannelSyncConfig(for: radioID, transportType: transportType) - let success = await services.syncCoordinator.performResync( - radioID: radioID, - dependencies: services.syncDependencies, - forceFullSync: forceFullSync, - channelSyncConfig: channelSyncConfig, - platformName: "\(self.detectedPlatform)" - ) - - if success { - logger.info("Resync succeeded") - resyncAttemptCount = 0 - - // Run post-sync hooks deferred when initial sync failed. - // Guard each await: disconnect(), device switch, or a new - // reconnect cycle may have torn down the connection. - guard !Task.isCancelled, - connectionIntent.wantsConnection, - connectionState.isOperational, - self.services === services else { break } - - await syncDeviceTimeIfNeeded() - - guard !Task.isCancelled, - connectionIntent.wantsConnection, - connectionState.isOperational, - self.services === services else { break } - - // Re-authenticate room sessions before onDeviceSynced to avoid - // BLE contention with stale node cleanup's fire-and-forget Task. - let sessionIDs = sessionsAwaitingReauth - if !sessionIDs.isEmpty { - await services.remoteNodeService.handleBLEReconnection(sessionIDs: sessionIDs) - } - - guard !Task.isCancelled, - connectionIntent.wantsConnection, - connectionState.isOperational, - self.services === services else { break } - - // Report success only after confirming the loop is still authoritative. - // Earlier placement fired the "Ready" toast before these guards, - // relying on handleDisconnect as an accidental backstop. - await services.syncCoordinator.endResyncActivity(succeeded: true) - didEndResyncActivity = true - - // Only clear consumed IDs after confirming the loop is still valid. - // Any IDs appended during the await (via teardownSessionForReconnect) survive. - sessionsAwaitingReauth.subtract(sessionIDs) - - // Promote from .syncing to .ready now that sync completed. - // Not using promoteToReady() because: (1) its guards (services identity, - // connectionIntent) are already checked above, and (2) it would re-run - // time sync and onDeviceSynced, duplicating the resync loop's own post-sync work. - connectionState = .ready - - await onDeviceSynced?() - - break - } - - if resyncAttemptCount >= Self.maxResyncAttempts { - logger.warning("Resync failed \(Self.maxResyncAttempts) times, disconnecting") - await services.syncCoordinator.endResyncActivity(succeeded: false) - didEndResyncActivity = true - onResyncFailed?() - await disconnect(reason: .resyncFailed) - break - } - } - - // Catch-all for cancellation or guard exits - if !didEndResyncActivity { - await services.syncCoordinator.endResyncActivity(succeeded: false) - } - - // Only nil resyncTask if this task wasn't cancelled. When startResyncLoop() - // is called while a previous loop is running, it cancels the old task and - // assigns a new one. The old task's catch-all must not nil resyncTask or it - // would destroy the replacement. - if !Task.isCancelled { - resyncTask = nil - } - } - } + guard connectionIntent.wantsConnection, + connectionState.isOperational, + self.services === services else { break } - /// Schedules a bounded channel-only retry after a partial channel phase. - /// This keeps contacts/messages out of the retry path when the connection is otherwise usable. - func scheduleChannelOnlyRetry( - radioID: UUID, - services: ServiceContainer, - indices: [UInt8] - ) { - let initialIndices = Array(Set(indices)).sorted() - guard !initialIndices.isEmpty else { return } - - channelRetryTask?.cancel() - - channelRetryTask = Task { - var pendingIndices = initialIndices - - for attempt in 1...Self.maxChannelRetryAttempts { - guard !Task.isCancelled else { break } - - let delaySeconds = 2 << (attempt - 1) - let delay = max(Self.channelRetryInitialDelay, .seconds(delaySeconds)) - do { - try await Task.sleep(for: delay) - } catch { - break - } - - guard connectionIntent.wantsConnection, - connectionState.isOperational, - self.services === services else { break } - - logger.info("Channel-only retry \(attempt)/\(Self.maxChannelRetryAttempts) for \(pendingIndices.count) channel(s)") - let result = await services.syncCoordinator.retryChannels( - radioID: radioID, - channelService: services.channelService, - indices: pendingIndices - ) - - if result.isComplete { - logger.info("Channel-only retry recovered all pending channels") - pendingIndices = [] - break - } - - pendingIndices = result.retryableIndices - guard !pendingIndices.isEmpty else { - logger.warning("Channel-only retry stopped with non-retryable channel errors") - break - } - } - - if !Task.isCancelled { - if !pendingIndices.isEmpty { - logger.warning("Channel-only retry exhausted with \(pendingIndices.count) retryable channel(s) still pending") - } - channelRetryTask = nil - } + logger.info("Channel-only retry \(attempt)/\(Self.maxChannelRetryAttempts) for \(pendingIndices.count) channel(s)") + let result = await services.syncCoordinator.retryChannels( + radioID: radioID, + channelService: services.channelService, + indices: pendingIndices + ) + + if result.isComplete { + logger.info("Channel-only retry recovered all pending channels") + pendingIndices = [] + break } - } - // MARK: - Channel Sync Configuration - - /// Builds a channel sync config for the current device and transport. - /// BLE and WiFi both use platform-specific values because ESP32 radios can saturate either transport. - func currentChannelSyncConfig(for radioID: UUID, transportType: TransportType) -> ChannelSyncConfig { - // Policy gate for pipelined channel reads. nRF52 over BLE amortizes the radio's - // per-write slave-latency penalty; ESP32 over WiFi avoids the per-round-trip TCP stall - // that makes serial channel reads roughly 200ms each. ESP32 over BLE has a write-only - // characteristic (no Write Commands), so it stays serial. MeshCoreSession.getChannels - // enforces a second capability gate on the transport, so this is the policy half of a - // two-gate design and the downstream re-check is intentional, not dead. - let usePipelinedChannelRead: Bool - switch (detectedPlatform, transportType) { - case (.nrf52, .bluetooth): usePipelinedChannelRead = true - case (.esp32, .wifi): usePipelinedChannelRead = true - default: usePipelinedChannelRead = false + pendingIndices = result.retryableIndices + guard !pendingIndices.isEmpty else { + logger.warning("Channel-only retry stopped with non-retryable channel errors") + break } + } - return detectedPlatform.channelSyncConfig( - lastCleanChannelSync: lastCleanChannelSync?.radioID == radioID - ? lastCleanChannelSync?.completedAt : nil, - lastAttemptedChannelSync: lastAttemptedChannelSync?.radioID == radioID - ? lastAttemptedChannelSync?.attemptedAt : nil, - usePipelinedChannelRead: usePipelinedChannelRead - ) + if !Task.isCancelled { + if !pendingIndices.isEmpty { + logger.warning("Channel-only retry exhausted with \(pendingIndices.count) retryable channel(s) still pending") + } + channelRetryTask = nil + } } + } + + // MARK: - Channel Sync Configuration + + /// Builds a channel sync config for the current device and transport. + /// BLE and WiFi both use platform-specific values because ESP32 radios can saturate either transport. + func currentChannelSyncConfig(for radioID: UUID, transportType: TransportType) -> ChannelSyncConfig { + // Policy gate for pipelined channel reads. nRF52 over BLE amortizes the radio's + // per-write slave-latency penalty; ESP32 over WiFi avoids the per-round-trip TCP stall + // that makes serial channel reads roughly 200ms each. ESP32 over BLE has a write-only + // characteristic (no Write Commands), so it stays serial. MeshCoreSession.getChannels + // enforces a second capability gate on the transport, so this is the policy half of a + // two-gate design and the downstream re-check is intentional, not dead. + let usePipelinedChannelRead = switch (detectedPlatform, transportType) { + case (.nrf52, .bluetooth): true + case (.esp32, .wifi): true + default: false + } + + return detectedPlatform.channelSyncConfig( + lastCleanChannelSync: lastCleanChannelSync?.radioID == radioID + ? lastCleanChannelSync?.completedAt : nil, + lastAttemptedChannelSync: lastAttemptedChannelSync?.radioID == radioID + ? lastAttemptedChannelSync?.attemptedAt : nil, + usePipelinedChannelRead: usePipelinedChannelRead + ) + } } diff --git a/MC1Services/Sources/MC1Services/Sync/DevicePlatform+SyncThrottling.swift b/MC1Services/Sources/MC1Services/Sync/DevicePlatform+SyncThrottling.swift index 979020a6..8f571263 100644 --- a/MC1Services/Sources/MC1Services/Sync/DevicePlatform+SyncThrottling.swift +++ b/MC1Services/Sources/MC1Services/Sync/DevicePlatform+SyncThrottling.swift @@ -3,29 +3,28 @@ import Foundation // MARK: - Channel Sync Configuration extension DevicePlatform { - - /// If channels were synced more recently than this, skip channel re-sync on resync. - /// Only enabled for ESP32 where channel re-sync wastes scarce connection time. - /// Channel skipping is a correctness tradeoff (not just performance), so it stays - /// disabled for unknown platforms until field evidence warrants it. - var channelSyncSkipWindow: Duration { - switch self { - case .esp32: .seconds(30) - case .nrf52, .unknown: .zero - } + /// If channels were synced more recently than this, skip channel re-sync on resync. + /// Only enabled for ESP32 where channel re-sync wastes scarce connection time. + /// Channel skipping is a correctness tradeoff (not just performance), so it stays + /// disabled for unknown platforms until field evidence warrants it. + var channelSyncSkipWindow: Duration { + switch self { + case .esp32: .seconds(30) + case .nrf52, .unknown: .zero } + } - /// Builds a channel sync config for a sync operation. - func channelSyncConfig( - lastCleanChannelSync: Date?, - lastAttemptedChannelSync: Date? = nil, - usePipelinedChannelRead: Bool = false - ) -> ChannelSyncConfig { - ChannelSyncConfig( - channelSyncSkipWindow: channelSyncSkipWindow, - lastCleanChannelSync: lastCleanChannelSync, - lastAttemptedChannelSync: lastAttemptedChannelSync, - usePipelinedChannelRead: usePipelinedChannelRead - ) - } + /// Builds a channel sync config for a sync operation. + func channelSyncConfig( + lastCleanChannelSync: Date?, + lastAttemptedChannelSync: Date? = nil, + usePipelinedChannelRead: Bool = false + ) -> ChannelSyncConfig { + ChannelSyncConfig( + channelSyncSkipWindow: channelSyncSkipWindow, + lastCleanChannelSync: lastCleanChannelSync, + lastAttemptedChannelSync: lastAttemptedChannelSync, + usePipelinedChannelRead: usePipelinedChannelRead + ) + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift index f9bf159e..a788755d 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift @@ -4,180 +4,178 @@ import Foundation // MARK: - Message Handler Helpers extension SyncCoordinator { - - struct RxLogLookupResult { - let pathNodes: Data? - let pathLength: UInt8 - let packetHash: String? - let routeType: RouteType? - let regionScope: String? + struct RxLogLookupResult { + let pathNodes: Data? + let pathLength: UInt8 + let packetHash: String? + let routeType: RouteType? + let regionScope: String? + } + + /// Looks up path data from an RxLogEntry to correlate with an incoming message. + func lookupRxLogEntry( + dependencies: SyncDependencies, + radioID: UUID, + channelIndex: UInt8?, + senderTimestamp: UInt32, + senderPublicKeyPrefix: Data?, + defaultPathLength: UInt8 + ) async -> RxLogLookupResult { + if let channelIndex { + logger.debug("Looking up RxLogEntry for channel \(channelIndex) with senderTimestamp: \(senderTimestamp)") } - /// Looks up path data from an RxLogEntry to correlate with an incoming message. - func lookupRxLogEntry( - dependencies: SyncDependencies, - radioID: UUID, - channelIndex: UInt8?, - senderTimestamp: UInt32, - senderPublicKeyPrefix: Data?, - defaultPathLength: UInt8 - ) async -> RxLogLookupResult { - if let channelIndex { - logger.debug("Looking up RxLogEntry for channel \(channelIndex) with senderTimestamp: \(senderTimestamp)") + do { + if let rxEntry = try await dependencies.dataStore.findRxLogEntry( + radioID: radioID, + channelIndex: channelIndex, + senderTimestamp: senderTimestamp + ) { + let pathLength = rxEntry.pathLength + let pathNodes = rxEntry.pathNodes + if channelIndex != nil { + logger.info("Correlated channel message to RxLogEntry: pathLength=\(pathLength), pathNodes=\(pathNodes.count) bytes") + } else { + logger.debug("Correlated incoming direct message to RxLogEntry, pathLength: \(pathLength), pathNodes: \(pathNodes.count) bytes") } - - do { - if let rxEntry = try await dependencies.dataStore.findRxLogEntry( - radioID: radioID, - channelIndex: channelIndex, - senderTimestamp: senderTimestamp - ) { - let pathLength = rxEntry.pathLength - let pathNodes = rxEntry.pathNodes - if channelIndex != nil { - logger.info("Correlated channel message to RxLogEntry: pathLength=\(pathLength), pathNodes=\(pathNodes.count) bytes") - } else { - logger.debug("Correlated incoming direct message to RxLogEntry, pathLength: \(pathLength), pathNodes: \(pathNodes.count) bytes") - } - return RxLogLookupResult(pathNodes: pathNodes, pathLength: pathLength, packetHash: rxEntry.packetHash, routeType: rxEntry.routeType, regionScope: rxEntry.regionScope) - } - - // Fallback for DMs: if timestamp-based lookup failed (e.g., RxLog decryption - // hadn't extracted the timestamp yet), try matching by sender prefix byte - // in the raw packet payload within a recent time window. - if channelIndex == nil, - let prefixByte = senderPublicKeyPrefix?.first { - let lookbackWindow = Date().addingTimeInterval(-30) - if let rxEntry = try await dependencies.dataStore.findRxLogEntryBySenderPrefix( - radioID: radioID, - senderPrefixByte: prefixByte, - receivedSince: lookbackWindow - ) { - logger.debug("Correlated DM to RxLogEntry via sender prefix fallback, pathLength: \(rxEntry.pathLength)") - return RxLogLookupResult(pathNodes: rxEntry.pathNodes, pathLength: rxEntry.pathLength, packetHash: rxEntry.packetHash, routeType: rxEntry.routeType, regionScope: rxEntry.regionScope) - } - logger.debug("No RxLogEntry found for direct message (primary + fallback), senderTimestamp: \(senderTimestamp)") - } else if let channelIndex { - logger.warning("No RxLogEntry found for channel \(channelIndex), senderTimestamp: \(senderTimestamp)") - } else { - logger.debug("No RxLogEntry found for direct message, senderTimestamp: \(senderTimestamp)") - } - } catch { - if channelIndex != nil { - logger.error("Failed to lookup RxLogEntry for channel message: \(error)") - } else { - logger.error("Failed to lookup RxLogEntry for direct message: \(error)") - } + return RxLogLookupResult(pathNodes: pathNodes, pathLength: pathLength, packetHash: rxEntry.packetHash, routeType: rxEntry.routeType, regionScope: rxEntry.regionScope) + } + + // Fallback for DMs: if timestamp-based lookup failed (e.g., RxLog decryption + // hadn't extracted the timestamp yet), try matching by sender prefix byte + // in the raw packet payload within a recent time window. + if channelIndex == nil, + let prefixByte = senderPublicKeyPrefix?.first { + let lookbackWindow = Date().addingTimeInterval(-30) + if let rxEntry = try await dependencies.dataStore.findRxLogEntryBySenderPrefix( + radioID: radioID, + senderPrefixByte: prefixByte, + receivedSince: lookbackWindow + ) { + logger.debug("Correlated DM to RxLogEntry via sender prefix fallback, pathLength: \(rxEntry.pathLength)") + return RxLogLookupResult(pathNodes: rxEntry.pathNodes, pathLength: rxEntry.pathLength, packetHash: rxEntry.packetHash, routeType: rxEntry.routeType, regionScope: rxEntry.regionScope) } - - return RxLogLookupResult(pathNodes: nil, pathLength: defaultPathLength, packetHash: nil, routeType: nil, regionScope: nil) + logger.debug("No RxLogEntry found for direct message (primary + fallback), senderTimestamp: \(senderTimestamp)") + } else if let channelIndex { + logger.warning("No RxLogEntry found for channel \(channelIndex), senderTimestamp: \(senderTimestamp)") + } else { + logger.debug("No RxLogEntry found for direct message, senderTimestamp: \(senderTimestamp)") + } + } catch { + if channelIndex != nil { + logger.error("Failed to lookup RxLogEntry for channel message: \(error)") + } else { + logger.error("Failed to lookup RxLogEntry for direct message: \(error)") + } } - /// Increments unread counts and posts a notification for a direct message. - func updateDMUnreadsAndNotify( - messageDTO: MessageDTO, - contactID: UUID, - contact: ContactDTO?, - messageText: String, - hasSelfMention: Bool, - dependencies: SyncDependencies - ) async throws { - // Only increment unread if user is NOT currently viewing this contact's chat - let isViewingContact = await dependencies.notificationService.activeContactID == contactID - if !isViewingContact { - try await dependencies.dataStore.incrementUnreadCount(contactID: contactID) - - // Increment unread mention count if message contains self-mention - if hasSelfMention { - try await dependencies.dataStore.incrementUnreadMentionCount(contactID: contactID) - } - } - - await dependencies.notificationService.postDirectMessageNotification( - from: contact?.displayName ?? "Unknown", - contactID: contactID, - messageText: messageText, - messageID: messageDTO.id, - isMuted: contact?.isMuted ?? false - ) - await dependencies.notificationService.updateBadgeCount() + return RxLogLookupResult(pathNodes: nil, pathLength: defaultPathLength, packetHash: nil, routeType: nil, regionScope: nil) + } + + /// Increments unread counts and posts a notification for a direct message. + func updateDMUnreadsAndNotify( + messageDTO: MessageDTO, + contactID: UUID, + contact: ContactDTO?, + messageText: String, + hasSelfMention: Bool, + dependencies: SyncDependencies + ) async throws { + // Only increment unread if user is NOT currently viewing this contact's chat + let isViewingContact = await dependencies.notificationService.activeContactID == contactID + if !isViewingContact { + try await dependencies.dataStore.incrementUnreadCount(contactID: contactID) + + // Increment unread mention count if message contains self-mention + if hasSelfMention { + try await dependencies.dataStore.incrementUnreadMentionCount(contactID: contactID) + } } - /// Increments unread counts, posts a notification, and notifies real-time listeners for a channel message. - func updateChannelUnreadsAndNotify( - messageDTO: MessageDTO, - channel: ChannelDTO?, - channelIndex: UInt8, - senderNodeName: String?, - messageText: String, - timestamp: UInt32, - hasSelfMention: Bool, - radioID: UUID, - dependencies: SyncDependencies - ) async throws { - if let channelID = channel?.id { - // Only increment unread if user is NOT currently viewing this channel - let activeIndex = await dependencies.notificationService.activeChannelIndex - let activeRadioID = await dependencies.notificationService.activeChannelRadioID - let isViewingChannel = activeIndex == channel?.index && activeRadioID == channel?.radioID - if !isViewingChannel { - try await dependencies.dataStore.incrementChannelUnreadCount(channelID: channelID) - - // Increment unread mention count if message contains self-mention - if hasSelfMention { - try await dependencies.dataStore.incrementChannelUnreadMentionCount(channelID: channelID) - } - } + await dependencies.notificationService.postDirectMessageNotification( + from: contact?.displayName ?? "Unknown", + contactID: contactID, + messageText: messageText, + messageID: messageDTO.id, + isMuted: contact?.isMuted ?? false + ) + await dependencies.notificationService.updateBadgeCount() + } + + /// Increments unread counts, posts a notification, and notifies real-time listeners for a channel message. + func updateChannelUnreadsAndNotify( + messageDTO: MessageDTO, + channel: ChannelDTO?, + channelIndex: UInt8, + senderNodeName: String?, + messageText: String, + timestamp: UInt32, + hasSelfMention: Bool, + radioID: UUID, + dependencies: SyncDependencies + ) async throws { + if let channelID = channel?.id { + // Only increment unread if user is NOT currently viewing this channel + let activeIndex = await dependencies.notificationService.activeChannelIndex + let activeRadioID = await dependencies.notificationService.activeChannelRadioID + let isViewingChannel = activeIndex == channel?.index && activeRadioID == channel?.radioID + if !isViewingChannel { + try await dependencies.dataStore.incrementChannelUnreadCount(channelID: channelID) + + // Increment unread mention count if message contains self-mention + if hasSelfMention { + try await dependencies.dataStore.incrementChannelUnreadMentionCount(channelID: channelID) } - if Self.shouldPostChannelNotification(forResolvedChannel: channel) { - await dependencies.notificationService.postChannelMessageNotification( - channelName: channel?.name ?? "Channel \(channelIndex)", - channelIndex: channelIndex, - radioID: radioID, - senderName: senderNodeName, - messageText: messageText, - messageID: messageDTO.id, - notificationLevel: channel?.notificationLevel ?? .all, - hasSelfMention: hasSelfMention - ) - } else { - recordUnresolvedChannel( - channelIndex: channelIndex, - radioID: radioID, - senderTimestamp: timestamp - ) - } - await dependencies.notificationService.updateBadgeCount() - - // Broadcast for real-time chat updates - dataEventBroadcaster.yield(.channelMessageReceived(message: messageDTO, channelIndex: channelIndex)) + } } - - private func recordUnresolvedChannel( - channelIndex: UInt8, - radioID: UUID, - senderTimestamp: UInt32 - ) { - let isNewIndex = unresolvedChannelIndices.insert(channelIndex).inserted - logger.warning( - "Suppressing notification for unresolved channel \(channelIndex) on device \(radioID), senderTimestamp: \(senderTimestamp) — no local channel for this slot" - ) - - let now = Date() - let shouldEmitSummary: Bool - if isNewIndex { - shouldEmitSummary = true - } else if let lastSummary = lastUnresolvedChannelSummaryAt { - shouldEmitSummary = now.timeIntervalSince(lastSummary) >= unresolvedChannelSummaryIntervalSeconds - } else { - shouldEmitSummary = true - } - - guard shouldEmitSummary else { return } - let sortedIndices = unresolvedChannelIndices.sorted() - logger.warning( - "Unresolved channel summary: total=\(sortedIndices.count), indices=\(sortedIndices)" - ) - lastUnresolvedChannelSummaryAt = now + if Self.shouldPostChannelNotification(forResolvedChannel: channel) { + await dependencies.notificationService.postChannelMessageNotification( + channelName: channel?.name ?? "Channel \(channelIndex)", + channelIndex: channelIndex, + radioID: radioID, + senderName: senderNodeName, + messageText: messageText, + messageID: messageDTO.id, + notificationLevel: channel?.notificationLevel ?? .all, + hasSelfMention: hasSelfMention + ) + } else { + recordUnresolvedChannel( + channelIndex: channelIndex, + radioID: radioID, + senderTimestamp: timestamp + ) } + await dependencies.notificationService.updateBadgeCount() + + // Broadcast for real-time chat updates + dataEventBroadcaster.yield(.channelMessageReceived(message: messageDTO, channelIndex: channelIndex)) + } + + private func recordUnresolvedChannel( + channelIndex: UInt8, + radioID: UUID, + senderTimestamp: UInt32 + ) { + let isNewIndex = unresolvedChannelIndices.insert(channelIndex).inserted + logger.warning( + "Suppressing notification for unresolved channel \(channelIndex) on device \(radioID), senderTimestamp: \(senderTimestamp) — no local channel for this slot" + ) + + let now = Date() + let shouldEmitSummary: Bool = if isNewIndex { + true + } else if let lastSummary = lastUnresolvedChannelSummaryAt { + now.timeIntervalSince(lastSummary) >= unresolvedChannelSummaryIntervalSeconds + } else { + true + } + + guard shouldEmitSummary else { return } + let sortedIndices = unresolvedChannelIndices.sorted() + logger.warning( + "Unresolved channel summary: total=\(sortedIndices.count), indices=\(sortedIndices)" + ) + lastUnresolvedChannelSummaryAt = now + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift index 4d92da4a..37a68a1c 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift @@ -5,570 +5,568 @@ import Foundation // MARK: - Message & Discovery Handler Wiring extension SyncCoordinator { - - // MARK: - Message Handler Wiring - - func wireMessageHandlers(dependencies: SyncDependencies, radioID: UUID) async { - logger.info("Wiring message handlers for device \(radioID)") - - // Populate blocked contacts cache - await refreshBlockedContactsCache(radioID: radioID, dataStore: dependencies.dataStore) - - // Cache device node name for self-mention detection - let device = try? await dependencies.dataStore.fetchDevice(radioID: radioID) - let selfNodeName = device?.nodeName ?? "" - - await wireContactMessageHandler(dependencies: dependencies, radioID: radioID, selfNodeName: selfNodeName) - await wireChannelMessageHandler(dependencies: dependencies, radioID: radioID, selfNodeName: selfNodeName) - await wireSignedMessageHandler(dependencies: dependencies) - await wireCLIMessageHandler(dependencies: dependencies) - - logger.info("Message handlers wired successfully") + // MARK: - Message Handler Wiring + + func wireMessageHandlers(dependencies: SyncDependencies, radioID: UUID) async { + logger.info("Wiring message handlers for device \(radioID)") + + // Populate blocked contacts cache + await refreshBlockedContactsCache(radioID: radioID, dataStore: dependencies.dataStore) + + // Cache device node name for self-mention detection + let device = try? await dependencies.dataStore.fetchDevice(radioID: radioID) + let selfNodeName = device?.nodeName ?? "" + + await wireContactMessageHandler(dependencies: dependencies, radioID: radioID, selfNodeName: selfNodeName) + await wireChannelMessageHandler(dependencies: dependencies, radioID: radioID, selfNodeName: selfNodeName) + await wireSignedMessageHandler(dependencies: dependencies) + await wireCLIMessageHandler(dependencies: dependencies) + + logger.info("Message handlers wired successfully") + } + + // MARK: - Contact Message Handler + + private func wireContactMessageHandler(dependencies: SyncDependencies, radioID: UUID, selfNodeName: String) async { + await dependencies.messagePollingService.setContactMessageHandler { [weak self] message, contact, context in + guard let self else { return } + await handleIncomingMessage( + kind: .direct(message, contact: contact), + context: context, + dependencies: dependencies, + radioID: radioID, + selfNodeName: selfNodeName + ) } - - // MARK: - Contact Message Handler - - private func wireContactMessageHandler(dependencies: SyncDependencies, radioID: UUID, selfNodeName: String) async { - await dependencies.messagePollingService.setContactMessageHandler { [weak self] message, contact, context in - guard let self else { return } - await self.handleIncomingMessage( - kind: .direct(message, contact: contact), - context: context, - dependencies: dependencies, - radioID: radioID, - selfNodeName: selfNodeName - ) - } + } + + // MARK: - Channel Message Handler + + private func wireChannelMessageHandler(dependencies: SyncDependencies, radioID: UUID, selfNodeName: String) async { + await dependencies.messagePollingService.setChannelMessageHandler { [weak self] message, channel, context in + guard let self else { return } + await handleIncomingMessage( + kind: .channel(message, channel: channel), + context: context, + dependencies: dependencies, + radioID: radioID, + selfNodeName: selfNodeName + ) } - - // MARK: - Channel Message Handler - - private func wireChannelMessageHandler(dependencies: SyncDependencies, radioID: UUID, selfNodeName: String) async { - await dependencies.messagePollingService.setChannelMessageHandler { [weak self] message, channel, context in - guard let self else { return } - await self.handleIncomingMessage( - kind: .channel(message, channel: channel), - context: context, - dependencies: dependencies, - radioID: radioID, - selfNodeName: selfNodeName - ) - } + } + + // MARK: - Incoming Message Pipeline + + /// Discriminates the two text-message ingestion paths, carrying the wire + /// message and the resolved conversation for each. + private enum IncomingMessageKind { + case direct(ContactMessage, contact: ContactDTO?) + case channel(ChannelMessage, channel: ChannelDTO?) + + /// Log noun distinguishing the two paths in shared diagnostics. + var logLabel: String { + switch self { + case .direct: "direct" + case .channel: "channel" + } + } + } + + /// Shared ingestion pipeline for incoming direct and channel messages: + /// timestamp correction, RX-log path correlation, dedup, reaction + /// short-circuit, persistence, unread/notification updates, and UI refresh. + private func handleIncomingMessage( + kind: IncomingMessageKind, + context: DeliveryContext, + dependencies: SyncDependencies, + radioID: UUID, + selfNodeName: String + ) async { + // Per-kind wire fields. Channel messages embed the sender as a + // "NodeName: text" prefix; direct messages carry the sender key prefix. + let text: String + let senderNodeName: String? + let senderTimestampDate: Date + let textTypeRaw: UInt8 + let snr: Double? + let reportedPathLength: UInt8 + let contactID: UUID? + let channelIndex: UInt8? + let senderKeyPrefix: Data? + switch kind { + case let .direct(message, contact): + // The firmware cannot surface a self-DM here: decrypt runs against a + // contact's ECDH shared secret and the local self_id is never in + // contacts[], so an echo of the user's own DM never reaches this arm. + text = message.text + senderNodeName = nil + senderTimestampDate = message.senderTimestamp + textTypeRaw = message.textType + snr = message.snr + reportedPathLength = message.pathLength + contactID = contact?.id + channelIndex = nil + senderKeyPrefix = message.senderPublicKeyPrefix + case let .channel(message, _): + // Parse "NodeName: text" format for sender name + (senderNodeName, text) = Self.parseChannelMessage(message.text) + senderTimestampDate = message.senderTimestamp + textTypeRaw = message.textType + snr = message.snr + reportedPathLength = message.pathLength + contactID = nil + channelIndex = message.channelIndex + senderKeyPrefix = nil } - // MARK: - Incoming Message Pipeline + let timestamp = UInt32(senderTimestampDate.timeIntervalSince1970) - /// Discriminates the two text-message ingestion paths, carrying the wire - /// message and the resolved conversation for each. - private enum IncomingMessageKind { - case direct(ContactMessage, contact: ContactDTO?) - case channel(ChannelMessage, channel: ChannelDTO?) + // Correct invalid timestamps (sender clock wrong) + let receiveTime = Date() + let (finalTimestamp, timestampCorrected) = Self.correctTimestampIfNeeded(timestamp, receiveTime: receiveTime) + if timestampCorrected { + logger.debug("Corrected invalid \(kind.logLabel) message timestamp from \(Date(timeIntervalSince1970: TimeInterval(timestamp))) to \(receiveTime)") + } - /// Log noun distinguishing the two paths in shared diagnostics. - var logLabel: String { - switch self { - case .direct: "direct" - case .channel: "channel" - } - } + let sortDate = Self.sortDate(for: context, receiveTime: receiveTime) + + // Look up path data from RxLogEntry using the sender timestamp stored + // during decryption (for direct messages, channelIndex is nil) + let rxResult = await lookupRxLogEntry( + dependencies: dependencies, + radioID: radioID, + channelIndex: channelIndex, + senderTimestamp: timestamp, + senderPublicKeyPrefix: senderKeyPrefix, + defaultPathLength: reportedPathLength + ) + + // Use content-based key for dedup (stable across retry attempts). + // The RX log packetHash is per-encrypted-packet and differs between + // retries with different attempt counters, so it must not drive dedup. + let deduplicationKey = Self.fallbackDeduplicationKey( + contactID: contactID, channelIndex: channelIndex, + senderNodeName: senderNodeName, timestamp: timestamp, content: text + ) + + // Check for self-mention before creating DTO + // For channel messages, filter out messages where the user mentions themselves + let hasSelfMention: Bool = switch kind { + case .direct: + !selfNodeName.isEmpty && + MentionUtilities.containsSelfMention(in: text, selfName: selfNodeName) + case .channel: + !selfNodeName.isEmpty && + senderNodeName != selfNodeName && + MentionUtilities.containsSelfMention(in: text, selfName: selfNodeName) } - /// Shared ingestion pipeline for incoming direct and channel messages: - /// timestamp correction, RX-log path correlation, dedup, reaction - /// short-circuit, persistence, unread/notification updates, and UI refresh. - private func handleIncomingMessage( - kind: IncomingMessageKind, - context: DeliveryContext, - dependencies: SyncDependencies, - radioID: UUID, - selfNodeName: String - ) async { - // Per-kind wire fields. Channel messages embed the sender as a - // "NodeName: text" prefix; direct messages carry the sender key prefix. - let text: String - let senderNodeName: String? - let senderTimestampDate: Date - let textTypeRaw: UInt8 - let snr: Double? - let reportedPathLength: UInt8 - let contactID: UUID? - let channelIndex: UInt8? - let senderKeyPrefix: Data? - switch kind { - case .direct(let message, let contact): - // The firmware cannot surface a self-DM here: decrypt runs against a - // contact's ECDH shared secret and the local self_id is never in - // contacts[], so an echo of the user's own DM never reaches this arm. - text = message.text - senderNodeName = nil - senderTimestampDate = message.senderTimestamp - textTypeRaw = message.textType - snr = message.snr - reportedPathLength = message.pathLength - contactID = contact?.id - channelIndex = nil - senderKeyPrefix = message.senderPublicKeyPrefix - case .channel(let message, _): - // Parse "NodeName: text" format for sender name - (senderNodeName, text) = Self.parseChannelMessage(message.text) - senderTimestampDate = message.senderTimestamp - textTypeRaw = message.textType - snr = message.snr - reportedPathLength = message.pathLength - contactID = nil - channelIndex = message.channelIndex - senderKeyPrefix = nil - } + // Clamp an unknown wire textType to .plain. MessageDTO decodes textType + // non-optionally, so an out-of-range raw value would throw and fail the + // whole envelope decode on backup round-trip; guaranteeing only the + // known cases reach persistence keeps that round-trip safe. + let resolvedTextType: TextType + if let parsed = TextType(rawValue: textTypeRaw) { + resolvedTextType = parsed + } else { + logger.warning("Unknown \(kind.logLabel) message textType raw=\(textTypeRaw); clamping to .plain") + resolvedTextType = .plain + } - let timestamp = UInt32(senderTimestampDate.timeIntervalSince1970) + // regionScope is incoming-only by data-pipeline design — outgoing + // messages do not flow through 0x88 / RxLogEntry correlation. + let messageDTO = MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: channelIndex, + text: text, + timestamp: finalTimestamp, + createdAt: receiveTime, + sortDate: sortDate, + direction: .incoming, + status: .delivered, + textType: resolvedTextType, + ackCode: nil, + pathLength: rxResult.pathLength, + snr: snr, + pathNodes: rxResult.pathNodes, + senderKeyPrefix: senderKeyPrefix, + senderNodeName: senderNodeName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0, + deduplicationKey: deduplicationKey, + containsSelfMention: hasSelfMention, + mentionSeen: false, + timestampCorrected: timestampCorrected, + senderTimestamp: timestampCorrected ? timestamp : nil, + routeType: rxResult.routeType, + regionScope: rxResult.regionScope + ) + + // Check for duplicate before saving + do { + if try await dependencies.dataStore.isDuplicateMessage(deduplicationKey: deduplicationKey, radioID: radioID) { + logger.info("Skipping duplicate \(kind.logLabel) message") + return + } + } catch { + logger.warning("Dedup check failed, proceeding with save: \(error)") + } - // Correct invalid timestamps (sender clock wrong) - let receiveTime = Date() - let (finalTimestamp, timestampCorrected) = Self.correctTimestampIfNeeded(timestamp, receiveTime: receiveTime) - if timestampCorrected { - logger.debug("Corrected invalid \(kind.logLabel) message timestamp from \(Date(timeIntervalSince1970: TimeInterval(timestamp))) to \(receiveTime)") - } + switch kind { + case let .direct(_, contact): + // Check if this is a DM reaction + if let contact, + await handleDMReaction( + text: text, + contact: contact, + radioID: radioID, + dependencies: dependencies + ) { + return + } + case let .channel(message, _): + // Discard messages from blocked senders + if isBlockedSender(senderNodeName) { + return + } + + // Check if this is a reaction + if await handleChannelReaction( + text: text, + channelIndex: message.channelIndex, + senderNodeName: senderNodeName, + selfNodeName: selfNodeName, + receiveTime: receiveTime, + radioID: radioID, + dependencies: dependencies + ) { + return + } + } - let sortDate = Self.sortDate(for: context, receiveTime: receiveTime) - - // Look up path data from RxLogEntry using the sender timestamp stored - // during decryption (for direct messages, channelIndex is nil) - let rxResult = await lookupRxLogEntry( - dependencies: dependencies, - radioID: radioID, - channelIndex: channelIndex, - senderTimestamp: timestamp, - senderPublicKeyPrefix: senderKeyPrefix, - defaultPathLength: reportedPathLength + do { + try await dependencies.dataStore.saveMessage(messageDTO) + + switch kind { + case let .direct(_, contact): + try await indexAndNotifyDirectMessage( + messageDTO: messageDTO, + contact: contact, + messageText: text, + timestamp: timestamp, + hasSelfMention: hasSelfMention, + dependencies: dependencies, + radioID: radioID ) - - // Use content-based key for dedup (stable across retry attempts). - // The RX log packetHash is per-encrypted-packet and differs between - // retries with different attempt counters, so it must not drive dedup. - let deduplicationKey = Self.fallbackDeduplicationKey( - contactID: contactID, channelIndex: channelIndex, - senderNodeName: senderNodeName, timestamp: timestamp, content: text + case let .channel(message, channel): + try await indexAndNotifyChannelMessage( + messageDTO: messageDTO, + channel: channel, + channelIndex: message.channelIndex, + senderNodeName: senderNodeName, + messageText: text, + timestamp: timestamp, + hasSelfMention: hasSelfMention, + dependencies: dependencies, + radioID: radioID ) - - // Check for self-mention before creating DTO - // For channel messages, filter out messages where the user mentions themselves - let hasSelfMention: Bool - switch kind { - case .direct: - hasSelfMention = !selfNodeName.isEmpty && - MentionUtilities.containsSelfMention(in: text, selfName: selfNodeName) - case .channel: - hasSelfMention = !selfNodeName.isEmpty && - senderNodeName != selfNodeName && - MentionUtilities.containsSelfMention(in: text, selfName: selfNodeName) - } - - // Clamp an unknown wire textType to .plain. MessageDTO decodes textType - // non-optionally, so an out-of-range raw value would throw and fail the - // whole envelope decode on backup round-trip; guaranteeing only the - // known cases reach persistence keeps that round-trip safe. - let resolvedTextType: TextType - if let parsed = TextType(rawValue: textTypeRaw) { - resolvedTextType = parsed - } else { - logger.warning("Unknown \(kind.logLabel) message textType raw=\(textTypeRaw); clamping to .plain") - resolvedTextType = .plain - } - - // regionScope is incoming-only by data-pipeline design — outgoing - // messages do not flow through 0x88 / RxLogEntry correlation. - let messageDTO = MessageDTO( - id: UUID(), - radioID: radioID, - contactID: contactID, - channelIndex: channelIndex, - text: text, - timestamp: finalTimestamp, - createdAt: receiveTime, - sortDate: sortDate, - direction: .incoming, - status: .delivered, - textType: resolvedTextType, - ackCode: nil, - pathLength: rxResult.pathLength, - snr: snr, - pathNodes: rxResult.pathNodes, - senderKeyPrefix: senderKeyPrefix, - senderNodeName: senderNodeName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0, - deduplicationKey: deduplicationKey, - containsSelfMention: hasSelfMention, - mentionSeen: false, - timestampCorrected: timestampCorrected, - senderTimestamp: timestampCorrected ? timestamp : nil, - routeType: rxResult.routeType, - regionScope: rxResult.regionScope + } + + // Notify conversation list of changes + await notifyConversationsChanged() + + // Broadcast for real-time chat updates + if case let .direct(_, contact) = kind, let contact { + dataEventBroadcaster.yield(.directMessageReceived(message: messageDTO, contact: contact)) + } + } catch { + switch kind { + case .direct: + logger.error("Failed to save contact message: \(error)") + case .channel: + logger.error("Failed to save channel message: \(error)") + } + } + } + + /// Post-save side effects for a direct message: reaction indexing, contact + /// bookkeeping, and unread/notification updates. + private func indexAndNotifyDirectMessage( + messageDTO: MessageDTO, + contact: ContactDTO?, + messageText: String, + timestamp: UInt32, + hasSelfMention: Bool, + dependencies: SyncDependencies, + radioID: UUID + ) async throws { + // Index DM message for reaction targeting + if let contact { + let pendingMatches = await dependencies.reactionService.indexDMMessage( + id: messageDTO.id, + contactID: contact.id, + text: messageText, + timestamp: timestamp + ) + + // Process pending reactions that now have their target + for pending in pendingMatches { + let reactionDTO = ReactionDTO( + messageID: messageDTO.id, + emoji: pending.parsed.emoji, + senderName: pending.senderName, + messageHash: pending.parsed.messageHash, + rawText: pending.rawText, + contactID: contact.id, + radioID: radioID ) - - // Check for duplicate before saving - do { - if try await dependencies.dataStore.isDuplicateMessage(deduplicationKey: deduplicationKey, radioID: radioID) { - logger.info("Skipping duplicate \(kind.logLabel) message") - return - } - } catch { - logger.warning("Dedup check failed, proceeding with save: \(error)") - } - - switch kind { - case .direct(_, let contact): - // Check if this is a DM reaction - if let contact, - await handleDMReaction( - text: text, - contact: contact, - radioID: radioID, - dependencies: dependencies - ) { - return - } - case .channel(let message, _): - // Discard messages from blocked senders - if isBlockedSender(senderNodeName) { - return - } - - // Check if this is a reaction - if await handleChannelReaction( - text: text, - channelIndex: message.channelIndex, - senderNodeName: senderNodeName, - selfNodeName: selfNodeName, - receiveTime: receiveTime, - radioID: radioID, - dependencies: dependencies - ) { - return - } - } - - do { - try await dependencies.dataStore.saveMessage(messageDTO) - - switch kind { - case .direct(_, let contact): - try await indexAndNotifyDirectMessage( - messageDTO: messageDTO, - contact: contact, - messageText: text, - timestamp: timestamp, - hasSelfMention: hasSelfMention, - dependencies: dependencies, - radioID: radioID - ) - case .channel(let message, let channel): - try await indexAndNotifyChannelMessage( - messageDTO: messageDTO, - channel: channel, - channelIndex: message.channelIndex, - senderNodeName: senderNodeName, - messageText: text, - timestamp: timestamp, - hasSelfMention: hasSelfMention, - dependencies: dependencies, - radioID: radioID - ) - } - - // Notify conversation list of changes - await notifyConversationsChanged() - - // Broadcast for real-time chat updates - if case .direct(_, let contact) = kind, let contact { - dataEventBroadcaster.yield(.directMessageReceived(message: messageDTO, contact: contact)) - } - } catch { - switch kind { - case .direct: - logger.error("Failed to save contact message: \(error)") - case .channel: - logger.error("Failed to save channel message: \(error)") - } + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Processed pending DM reaction \(pending.parsed.emoji)") } + } } - /// Post-save side effects for a direct message: reaction indexing, contact - /// bookkeeping, and unread/notification updates. - private func indexAndNotifyDirectMessage( - messageDTO: MessageDTO, - contact: ContactDTO?, - messageText: String, - timestamp: UInt32, - hasSelfMention: Bool, - dependencies: SyncDependencies, - radioID: UUID - ) async throws { - // Index DM message for reaction targeting - if let contact { - let pendingMatches = await dependencies.reactionService.indexDMMessage( - id: messageDTO.id, - contactID: contact.id, - text: messageText, - timestamp: timestamp - ) - - // Process pending reactions that now have their target - for pending in pendingMatches { - let reactionDTO = ReactionDTO( - messageID: messageDTO.id, - emoji: pending.parsed.emoji, - senderName: pending.senderName, - messageHash: pending.parsed.messageHash, - rawText: pending.rawText, - contactID: contact.id, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Processed pending DM reaction \(pending.parsed.emoji)") - } - } - } - - // Update contact's last message date - if let contactID = contact?.id { - try await dependencies.dataStore.updateContactLastMessage(contactID: contactID, date: Date()) - } - - // Only increment unread count, post notification, and update badge for non-blocked contacts - if let contactID = contact?.id, contact?.isBlocked != true { - try await updateDMUnreadsAndNotify( - messageDTO: messageDTO, - contactID: contactID, - contact: contact, - messageText: messageText, - hasSelfMention: hasSelfMention, - dependencies: dependencies - ) - } + // Update contact's last message date + if let contactID = contact?.id { + try await dependencies.dataStore.updateContactLastMessage(contactID: contactID, date: Date()) } - /// Post-save side effects for a channel message: reaction indexing, channel - /// bookkeeping, and unread/notification updates. - private func indexAndNotifyChannelMessage( - messageDTO: MessageDTO, - channel: ChannelDTO?, - channelIndex: UInt8, - senderNodeName: String?, - messageText: String, - timestamp: UInt32, - hasSelfMention: Bool, - dependencies: SyncDependencies, - radioID: UUID - ) async throws { - // Index message for reaction matching and process any pending reactions - // Use original timestamp for indexing so pending reactions can match - if let senderName = senderNodeName { - let pendingMatches = await dependencies.reactionService.indexMessage( - id: messageDTO.id, - channelIndex: channelIndex, - senderName: senderName, - text: messageText, - timestamp: timestamp - ) - - // Process any pending reactions that now have their target - for pending in pendingMatches { - let reactionDTO = ReactionDTO( - messageID: messageDTO.id, - emoji: pending.parsed.emoji, - senderName: pending.senderNodeName, - messageHash: pending.parsed.messageHash, - rawText: pending.rawText, - channelIndex: pending.channelIndex, - radioID: pending.radioID - ) - await persistReactionIfNew(reactionDTO, dependencies: dependencies) - } - } - - // Update channel's last message date - if let channelID = channel?.id { - try await dependencies.dataStore.updateChannelLastMessage(channelID: channelID, date: Date()) - } - - // Only update unread count, badges, and notify UI for non-blocked senders - if !isBlockedSender(senderNodeName) { - try await updateChannelUnreadsAndNotify( - messageDTO: messageDTO, - channel: channel, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - messageText: messageText, - timestamp: timestamp, - hasSelfMention: hasSelfMention, - radioID: radioID, - dependencies: dependencies - ) - } + // Only increment unread count, post notification, and update badge for non-blocked contacts + if let contactID = contact?.id, contact?.isBlocked != true { + try await updateDMUnreadsAndNotify( + messageDTO: messageDTO, + contactID: contactID, + contact: contact, + messageText: messageText, + hasSelfMention: hasSelfMention, + dependencies: dependencies + ) } - - // MARK: - Signed Message Handler - - private func wireSignedMessageHandler(dependencies: SyncDependencies) async { - await dependencies.messagePollingService.setSignedMessageHandler { [weak self] message, _ in - guard let self else { return } - await self.handleIncomingSignedMessage(message, dependencies: dependencies) - } + } + + /// Post-save side effects for a channel message: reaction indexing, channel + /// bookkeeping, and unread/notification updates. + private func indexAndNotifyChannelMessage( + messageDTO: MessageDTO, + channel: ChannelDTO?, + channelIndex: UInt8, + senderNodeName: String?, + messageText: String, + timestamp: UInt32, + hasSelfMention: Bool, + dependencies: SyncDependencies, + radioID: UUID + ) async throws { + // Index message for reaction matching and process any pending reactions + // Use original timestamp for indexing so pending reactions can match + if let senderName = senderNodeName { + let pendingMatches = await dependencies.reactionService.indexMessage( + id: messageDTO.id, + channelIndex: channelIndex, + senderName: senderName, + text: messageText, + timestamp: timestamp + ) + + // Process any pending reactions that now have their target + for pending in pendingMatches { + let reactionDTO = ReactionDTO( + messageID: messageDTO.id, + emoji: pending.parsed.emoji, + senderName: pending.senderNodeName, + messageHash: pending.parsed.messageHash, + rawText: pending.rawText, + channelIndex: pending.channelIndex, + radioID: pending.radioID + ) + await persistReactionIfNew(reactionDTO, dependencies: dependencies) + } } - /// Persists a signed room message via `RoomServerService`, then posts the - /// notification and refreshes the UI when the message was new. - private func handleIncomingSignedMessage(_ message: ContactMessage, dependencies: SyncDependencies) async { - // For signed room messages, the signature contains the 4-byte author key prefix - guard let authorPrefix = message.signature?.prefix(4), authorPrefix.count == 4 else { - logger.warning("Dropping signed message: missing or invalid author prefix") - return - } + // Update channel's last message date + if let channelID = channel?.id { + try await dependencies.dataStore.updateChannelLastMessage(channelID: channelID, date: Date()) + } - let timestamp = UInt32(message.senderTimestamp.timeIntervalSince1970) - - do { - let savedMessage = try await dependencies.roomServerService.handleIncomingMessage( - senderPublicKeyPrefix: message.senderPublicKeyPrefix, - timestamp: timestamp, - authorPrefix: Data(authorPrefix), - text: message.text - ) - - // If message was saved (not a duplicate), notify UI and post notification - if let savedMessage { - // Fetch session for room name and mute status - let session = try? await dependencies.dataStore.fetchRemoteNodeSession(id: savedMessage.sessionID) - - // Post notification for room message - await dependencies.notificationService.postRoomMessageNotification( - roomName: session?.name ?? "Room", - sessionID: savedMessage.sessionID, - senderName: savedMessage.authorName, - messageText: savedMessage.text, - messageID: savedMessage.id, - notificationLevel: session?.notificationLevel ?? .all - ) - await dependencies.notificationService.updateBadgeCount() - - await notifyConversationsChanged() - dataEventBroadcaster.yield(.roomMessageReceived(savedMessage)) - } - } catch { - logger.error("Failed to handle room message: \(error)") - } + // Only update unread count, badges, and notify UI for non-blocked senders + if !isBlockedSender(senderNodeName) { + try await updateChannelUnreadsAndNotify( + messageDTO: messageDTO, + channel: channel, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + messageText: messageText, + timestamp: timestamp, + hasSelfMention: hasSelfMention, + radioID: radioID, + dependencies: dependencies + ) } + } - // MARK: - CLI Message Handler + // MARK: - Signed Message Handler - private func wireCLIMessageHandler(dependencies: SyncDependencies) async { - await dependencies.messagePollingService.setCLIMessageHandler { [weak self] message, contact in - guard let self else { return } - await self.handleIncomingCLIMessage(message, contact: contact, dependencies: dependencies) - } + private func wireSignedMessageHandler(dependencies: SyncDependencies) async { + await dependencies.messagePollingService.setSignedMessageHandler { [weak self] message, _ in + guard let self else { return } + await handleIncomingSignedMessage(message, dependencies: dependencies) } - - /// Routes a CLI response to the room or repeater admin service for the sending contact. - private func handleIncomingCLIMessage( - _ message: ContactMessage, - contact: ContactDTO?, - dependencies: SyncDependencies - ) async { - if let contact { - if contact.type == .room { - await dependencies.roomAdminService.invokeCLIHandler(message, fromContact: contact) - } else { - await dependencies.repeaterAdminService.invokeCLIHandler(message, fromContact: contact) - } - } else { - logger.warning("Dropping CLI response: no contact found for sender") - } + } + + /// Persists a signed room message via `RoomServerService`, then posts the + /// notification and refreshes the UI when the message was new. + private func handleIncomingSignedMessage(_ message: ContactMessage, dependencies: SyncDependencies) async { + // For signed room messages, the signature contains the 4-byte author key prefix + guard let authorPrefix = message.signature?.prefix(4), authorPrefix.count == 4 else { + logger.warning("Dropping signed message: missing or invalid author prefix") + return } - // MARK: - Discovery Event Monitoring - - /// Consumes the advertisement event stream to post new-contact - /// notifications and refresh contact lists. The subscription is - /// registered synchronously before this method returns; events yielded - /// earlier (during the initial sync) are deliberately not seen. - func startDiscoveryEventMonitoring(dependencies: SyncDependencies, radioID: UUID) { - logger.info("Starting discovery event monitoring for device \(radioID)") - discoveryEventsTask?.cancel() - let events = dependencies.advertisementService.events() - discoveryEventsTask = Task { [weak self] in - for await event in events { - guard let self else { return } - switch event { - case .newContactDiscovered(let name, let contactID, let contactType): - // Manual-add mode: a new contact was discovered via advertisement - PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - .info("B4 relay newContactDiscovered \(contactID) -> notifyContactsChanged") - await dependencies.notificationService.postNewContactNotification( - contactName: name, - contactID: contactID, - contactType: contactType - ) - await self.notifyContactsChanged() - case .contactSyncRequested: - // Auto-add mode: AdvertisementService already fetched and - // saved the contact, so only a UI refresh is needed - PersistentLogger(subsystem: "com.mc1", category: "discover-trace") - .info("B4 relay contactSyncRequested -> notifyContactsChanged") - await self.notifyContactsChanged() - case .contactUpdated, .nodeStorageFullChanged, .contactDeletedCleanup, - .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: - break - } - } - } - } + let timestamp = UInt32(message.senderTimestamp.timeIntervalSince1970) + + do { + let savedMessage = try await dependencies.roomServerService.handleIncomingMessage( + senderPublicKeyPrefix: message.senderPublicKeyPrefix, + timestamp: timestamp, + authorPrefix: Data(authorPrefix), + text: message.text + ) + + // If message was saved (not a duplicate), notify UI and post notification + if let savedMessage { + // Fetch session for room name and mute status + let session = try? await dependencies.dataStore.fetchRemoteNodeSession(id: savedMessage.sessionID) + + // Post notification for room message + await dependencies.notificationService.postRoomMessageNotification( + roomName: session?.name ?? "Room", + sessionID: savedMessage.sessionID, + senderName: savedMessage.authorName, + messageText: savedMessage.text, + messageID: savedMessage.id, + notificationLevel: session?.notificationLevel ?? .all + ) + await dependencies.notificationService.updateBadgeCount() - /// Cancels the discovery event task so it releases the service references - /// it captures. Called by `ServiceContainer.tearDown()`. - func cancelDiscoveryEventMonitoring() { - discoveryEventsTask?.cancel() - discoveryEventsTask = nil + await notifyConversationsChanged() + dataEventBroadcaster.yield(.roomMessageReceived(savedMessage)) + } + } catch { + logger.error("Failed to handle room message: \(error)") } + } - // MARK: - Static Helpers + // MARK: - CLI Message Handler - nonisolated static func fallbackDeduplicationKey( - contactID: UUID?, - channelIndex: UInt8?, - senderNodeName: String?, - timestamp: UInt32, - content: String - ) -> String { - DeduplicationKey.contentBased( - contactID: contactID, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - timestamp: timestamp, - content: content - ) + private func wireCLIMessageHandler(dependencies: SyncDependencies) async { + await dependencies.messagePollingService.setCLIMessageHandler { [weak self] message, contact in + guard let self else { return } + await handleIncomingCLIMessage(message, contact: contact, dependencies: dependencies) } - - nonisolated static func parseChannelMessage(_ text: String) -> (senderNodeName: String?, messageText: String) { - let parts = text.split(separator: ":", maxSplits: 1) - if parts.count > 1 { - let senderName = String(parts[0]).trimmingCharacters(in: .whitespaces) - let messageText = String(parts[1]).trimmingCharacters(in: .whitespaces) - return (senderName, messageText) + } + + /// Routes a CLI response to the room or repeater admin service for the sending contact. + private func handleIncomingCLIMessage( + _ message: ContactMessage, + contact: ContactDTO?, + dependencies: SyncDependencies + ) async { + if let contact { + if contact.type == .room { + await dependencies.roomAdminService.invokeCLIHandler(message, fromContact: contact) + } else { + await dependencies.repeaterAdminService.invokeCLIHandler(message, fromContact: contact) + } + } else { + logger.warning("Dropping CLI response: no contact found for sender") + } + } + + // MARK: - Discovery Event Monitoring + + /// Consumes the advertisement event stream to post new-contact + /// notifications and refresh contact lists. The subscription is + /// registered synchronously before this method returns; events yielded + /// earlier (during the initial sync) are deliberately not seen. + func startDiscoveryEventMonitoring(dependencies: SyncDependencies, radioID: UUID) { + logger.info("Starting discovery event monitoring for device \(radioID)") + discoveryEventsTask?.cancel() + let events = dependencies.advertisementService.events() + discoveryEventsTask = Task { [weak self] in + for await event in events { + guard let self else { return } + switch event { + case let .newContactDiscovered(name, contactID, contactType): + // Manual-add mode: a new contact was discovered via advertisement + PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + .info("B4 relay newContactDiscovered \(contactID) -> notifyContactsChanged") + await dependencies.notificationService.postNewContactNotification( + contactName: name, + contactID: contactID, + contactType: contactType + ) + await notifyContactsChanged() + case .contactSyncRequested: + // Auto-add mode: AdvertisementService already fetched and + // saved the contact, so only a UI refresh is needed + PersistentLogger(subsystem: "com.mc1", category: "discover-trace") + .info("B4 relay contactSyncRequested -> notifyContactsChanged") + await notifyContactsChanged() + case .contactUpdated, .nodeStorageFullChanged, .contactDeletedCleanup, + .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: + break } - return (nil, text) + } } - - /// A received channel message only warrants a user notification when it resolves to a known - /// local channel. The radio can report a message on a slot the app has no `Channel` for: the - /// firmware decrypts zero-key group traffic on an unconfigured slot and attributes it to the - /// first empty slot. Such messages have no openable chat, so they are logged as unresolved - /// but never surfaced as a notification. - nonisolated static func shouldPostChannelNotification(forResolvedChannel channel: ChannelDTO?) -> Bool { - channel != nil + } + + /// Cancels the discovery event task so it releases the service references + /// it captures. Called by `ServiceContainer.tearDown()`. + func cancelDiscoveryEventMonitoring() { + discoveryEventsTask?.cancel() + discoveryEventsTask = nil + } + + // MARK: - Static Helpers + + nonisolated static func fallbackDeduplicationKey( + contactID: UUID?, + channelIndex: UInt8?, + senderNodeName: String?, + timestamp: UInt32, + content: String + ) -> String { + DeduplicationKey.contentBased( + contactID: contactID, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + timestamp: timestamp, + content: content + ) + } + + nonisolated static func parseChannelMessage(_ text: String) -> (senderNodeName: String?, messageText: String) { + let parts = text.split(separator: ":", maxSplits: 1) + if parts.count > 1 { + let senderName = String(parts[0]).trimmingCharacters(in: .whitespaces) + let messageText = String(parts[1]).trimmingCharacters(in: .whitespaces) + return (senderName, messageText) } + return (nil, text) + } + + /// A received channel message only warrants a user notification when it resolves to a known + /// local channel. The radio can report a message on a slot the app has no `Channel` for: the + /// firmware decrypts zero-key group traffic on an unconfigured slot and attributes it to the + /// first empty slot. Such messages have no openable chat, so they are logged as unresolved + /// but never surfaced as a notification. + nonisolated static func shouldPostChannelNotification(forResolvedChannel channel: ChannelDTO?) -> Bool { + channel != nil + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+ReactionHandlers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+ReactionHandlers.swift index f8042c9c..cbf5240b 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+ReactionHandlers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+ReactionHandlers.swift @@ -4,492 +4,488 @@ import Foundation // MARK: - Reaction Handlers extension SyncCoordinator { - - /// Persists a reaction if it doesn't already exist, notifying the UI on success. - /// - /// Deduplicates the check-exists → create → persist → notify pattern used across - /// DM and channel reaction handlers. - /// - /// - Returns: `true` if the reaction was new and saved - @discardableResult - func persistReactionIfNew( - _ reactionDTO: ReactionDTO, - dependencies: SyncDependencies - ) async -> Bool { - let exists = try? await dependencies.dataStore.reactionExists( - messageID: reactionDTO.messageID, - senderName: reactionDTO.senderName, - emoji: reactionDTO.emoji - ) - - guard exists != true else { return false } - - if let result = await dependencies.reactionService.persistReactionAndUpdateSummary( - reactionDTO, - using: dependencies.dataStore - ) { - dataEventBroadcaster.yield(.reactionReceived(messageID: result.messageID, summary: result.summary)) - } - - return true + /// Persists a reaction if it doesn't already exist, notifying the UI on success. + /// + /// Deduplicates the check-exists → create → persist → notify pattern used across + /// DM and channel reaction handlers. + /// + /// - Returns: `true` if the reaction was new and saved + @discardableResult + func persistReactionIfNew( + _ reactionDTO: ReactionDTO, + dependencies: SyncDependencies + ) async -> Bool { + let exists = try? await dependencies.dataStore.reactionExists( + messageID: reactionDTO.messageID, + senderName: reactionDTO.senderName, + emoji: reactionDTO.emoji + ) + + guard exists != true else { return false } + + if let result = await dependencies.reactionService.persistReactionAndUpdateSummary( + reactionDTO, + using: dependencies.dataStore + ) { + dataEventBroadcaster.yield(.reactionReceived(messageID: result.messageID, summary: result.summary)) } - /// Handles an incoming DM reaction by looking up the target message and persisting the reaction. - /// - /// - Returns: `true` if the message was consumed as a reaction (caller should return early). - func handleDMReaction( - text: String, - contact: ContactDTO, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - // Try meshcore-open v3 format - if let mcoReaction = MeshCoreOpenReactionParser.parse(text) { - return await handleMCODMReaction( - mcoReaction, - rawText: text, - contact: contact, - radioID: radioID, - dependencies: dependencies - ) - } + return true + } + + /// Handles an incoming DM reaction by looking up the target message and persisting the reaction. + /// + /// - Returns: `true` if the message was consumed as a reaction (caller should return early). + func handleDMReaction( + text: String, + contact: ContactDTO, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + // Try meshcore-open v3 format + if let mcoReaction = MeshCoreOpenReactionParser.parse(text) { + return await handleMCODMReaction( + mcoReaction, + rawText: text, + contact: contact, + radioID: radioID, + dependencies: dependencies + ) + } - // Try meshcore-open v1 format - if let v1Reaction = MeshCoreOpenReactionParser.parseV1(text) { - return await handleMCOV1DMReaction( - v1Reaction, - rawText: text, - contact: contact, - radioID: radioID, - dependencies: dependencies - ) - } + // Try meshcore-open v1 format + if let v1Reaction = MeshCoreOpenReactionParser.parseV1(text) { + return await handleMCOV1DMReaction( + v1Reaction, + rawText: text, + contact: contact, + radioID: radioID, + dependencies: dependencies + ) + } - guard let parsed = ReactionParser.parseDM(text) else { return false } - - // Try to find target in cache first - if let targetMessageID = await dependencies.reactionService.findDMTargetMessage( - messageHash: parsed.messageHash, - contactID: contact.id - ) { - let reactionDTO = ReactionDTO( - messageID: targetMessageID, - emoji: parsed.emoji, - senderName: contact.displayName, - messageHash: parsed.messageHash, - rawText: text, - contactID: contact.id, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved DM reaction \(parsed.emoji) to message \(targetMessageID)") - } - - return true - } + guard let parsed = ReactionParser.parseDM(text) else { return false } + + // Try to find target in cache first + if let targetMessageID = await dependencies.reactionService.findDMTargetMessage( + messageHash: parsed.messageHash, + contactID: contact.id + ) { + let reactionDTO = ReactionDTO( + messageID: targetMessageID, + emoji: parsed.emoji, + senderName: contact.displayName, + messageHash: parsed.messageHash, + rawText: text, + contactID: contact.id, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved DM reaction \(parsed.emoji) to message \(targetMessageID)") + } + + return true + } - // Try persistence fallback - let timestampWindow = reactionTimestampWindow() - - if let targetMessage = try? await dependencies.dataStore.findDMMessageForReaction( - radioID: radioID, - contactID: contact.id, - messageHash: parsed.messageHash, - timestampWindow: timestampWindow, - limit: 200 - ) { - let reactionDTO = ReactionDTO( - messageID: targetMessage.id, - emoji: parsed.emoji, - senderName: contact.displayName, - messageHash: parsed.messageHash, - rawText: text, - contactID: contact.id, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved DM reaction \(parsed.emoji) to message \(targetMessage.id) (from DB)") - } - - return true - } + // Try persistence fallback + let timestampWindow = reactionTimestampWindow() + + if let targetMessage = try? await dependencies.dataStore.findDMMessageForReaction( + radioID: radioID, + contactID: contact.id, + messageHash: parsed.messageHash, + timestampWindow: timestampWindow, + limit: 200 + ) { + let reactionDTO = ReactionDTO( + messageID: targetMessage.id, + emoji: parsed.emoji, + senderName: contact.displayName, + messageHash: parsed.messageHash, + rawText: text, + contactID: contact.id, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved DM reaction \(parsed.emoji) to message \(targetMessage.id) (from DB)") + } + + return true + } - // Queue as pending if target not found - await dependencies.reactionService.queuePendingDMReaction( - parsed: parsed, - contactID: contact.id, - senderName: contact.displayName, - rawText: text, - radioID: radioID - ) - - logger.debug("Queued pending DM reaction \(parsed.emoji)") - return true + // Queue as pending if target not found + await dependencies.reactionService.queuePendingDMReaction( + parsed: parsed, + contactID: contact.id, + senderName: contact.displayName, + rawText: text, + radioID: radioID + ) + + logger.debug("Queued pending DM reaction \(parsed.emoji)") + return true + } + + /// Handles an incoming channel reaction by looking up the target message and persisting the reaction. + /// + /// - Returns: `true` if the message was consumed as a reaction. + func handleChannelReaction( + text: String, + channelIndex: UInt8, + senderNodeName: String?, + selfNodeName: String, + receiveTime: Date, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + // Try meshcore-open v3 format + if let mcoReaction = MeshCoreOpenReactionParser.parse(text) { + return await handleMCOChannelReaction( + mcoReaction, + rawText: text, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + selfNodeName: selfNodeName, + receiveTime: receiveTime, + radioID: radioID, + dependencies: dependencies + ) } - /// Handles an incoming channel reaction by looking up the target message and persisting the reaction. - /// - /// - Returns: `true` if the message was consumed as a reaction. - func handleChannelReaction( - text: String, - channelIndex: UInt8, - senderNodeName: String?, - selfNodeName: String, - receiveTime: Date, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - // Try meshcore-open v3 format - if let mcoReaction = MeshCoreOpenReactionParser.parse(text) { - return await handleMCOChannelReaction( - mcoReaction, - rawText: text, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - selfNodeName: selfNodeName, - receiveTime: receiveTime, - radioID: radioID, - dependencies: dependencies - ) - } + // Try meshcore-open v1 format + if let v1Reaction = MeshCoreOpenReactionParser.parseV1(text) { + return await handleMCOV1ChannelReaction( + v1Reaction, + rawText: text, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + selfNodeName: selfNodeName, + radioID: radioID, + dependencies: dependencies + ) + } - // Try meshcore-open v1 format - if let v1Reaction = MeshCoreOpenReactionParser.parseV1(text) { - return await handleMCOV1ChannelReaction( - v1Reaction, - rawText: text, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - selfNodeName: selfNodeName, - radioID: radioID, - dependencies: dependencies - ) - } + guard let parsed = dependencies.reactionService.tryProcessAsReaction(text) else { return false } + + let senderName = senderNodeName ?? "Unknown" + + if let targetMessageID = await dependencies.reactionService.findTargetMessage( + parsed: parsed, + channelIndex: channelIndex + ) { + let reactionDTO = ReactionDTO( + messageID: targetMessageID, + emoji: parsed.emoji, + senderName: senderName, + messageHash: parsed.messageHash, + rawText: text, + channelIndex: channelIndex, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved reaction \(parsed.emoji) to message \(targetMessageID)") + } + + return true + } - guard let parsed = dependencies.reactionService.tryProcessAsReaction(text) else { return false } - - let senderName = senderNodeName ?? "Unknown" - - if let targetMessageID = await dependencies.reactionService.findTargetMessage( - parsed: parsed, - channelIndex: channelIndex - ) { - let reactionDTO = ReactionDTO( - messageID: targetMessageID, - emoji: parsed.emoji, - senderName: senderName, - messageHash: parsed.messageHash, - rawText: text, - channelIndex: channelIndex, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved reaction \(parsed.emoji) to message \(targetMessageID)") - } - - return true + let timestampWindow = reactionTimestampWindow(at: receiveTime) + + logger.debug("DB lookup: selfNodeName='\(selfNodeName)', targetSender=\(parsed.targetSender), hash=\(parsed.messageHash)") + + if let targetMessage = try? await dependencies.dataStore.findChannelMessageForReaction( + radioID: radioID, + channelIndex: channelIndex, + parsedReaction: parsed, + localNodeName: selfNodeName.isEmpty ? nil : selfNodeName, + timestampWindow: timestampWindow, + limit: 200 + ) { + let targetMessageID = targetMessage.id + let reactionDTO = ReactionDTO( + messageID: targetMessageID, + emoji: parsed.emoji, + senderName: senderName, + messageHash: parsed.messageHash, + rawText: text, + channelIndex: channelIndex, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + let targetSenderName: String? = if targetMessage.direction == .outgoing { + selfNodeName.isEmpty ? nil : selfNodeName + } else { + targetMessage.senderNodeName } - let timestampWindow = reactionTimestampWindow(at: receiveTime) - - logger.debug("DB lookup: selfNodeName='\(selfNodeName)', targetSender=\(parsed.targetSender), hash=\(parsed.messageHash)") - - if let targetMessage = try? await dependencies.dataStore.findChannelMessageForReaction( - radioID: radioID, + if let targetSenderName { + // Index for future reactions (pending matches not needed here since + // message exists in DB, so pending reactions would also match via DB fallback) + _ = await dependencies.reactionService.indexMessage( + id: targetMessageID, channelIndex: channelIndex, - parsedReaction: parsed, - localNodeName: selfNodeName.isEmpty ? nil : selfNodeName, - timestampWindow: timestampWindow, - limit: 200 - ) { - let targetMessageID = targetMessage.id - let reactionDTO = ReactionDTO( - messageID: targetMessageID, - emoji: parsed.emoji, - senderName: senderName, - messageHash: parsed.messageHash, - rawText: text, - channelIndex: channelIndex, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - let targetSenderName: String? - if targetMessage.direction == .outgoing { - targetSenderName = selfNodeName.isEmpty ? nil : selfNodeName - } else { - targetSenderName = targetMessage.senderNodeName - } - - if let targetSenderName { - // Index for future reactions (pending matches not needed here since - // message exists in DB, so pending reactions would also match via DB fallback) - _ = await dependencies.reactionService.indexMessage( - id: targetMessageID, - channelIndex: channelIndex, - senderName: targetSenderName, - text: targetMessage.text, - timestamp: targetMessage.reactionTimestamp - ) - } - - logger.debug("Saved reaction \(parsed.emoji) to message \(targetMessageID) via DB lookup") - } - - return true + senderName: targetSenderName, + text: targetMessage.text, + timestamp: targetMessage.reactionTimestamp + ) } - // Queue reaction for later matching when target message arrives - await dependencies.reactionService.queuePendingReaction( - parsed: parsed, - channelIndex: channelIndex, - senderNodeName: senderName, - rawText: text, - radioID: radioID - ) - return true - } + logger.debug("Saved reaction \(parsed.emoji) to message \(targetMessageID) via DB lookup") + } - /// Computes a symmetric timestamp window around the given time for reaction matching. - private func reactionTimestampWindow(at time: Date = Date()) -> ClosedRange { - reactionTimestampWindow(anchor: UInt32(time.timeIntervalSince1970)) + return true } - /// Computes a symmetric timestamp window around a specific anchor timestamp. - private func reactionTimestampWindow(anchor: UInt32) -> ClosedRange { - let start = anchor > reactionTimestampWindowSeconds ? anchor - reactionTimestampWindowSeconds : 0 - return start...(anchor + reactionTimestampWindowSeconds) + // Queue reaction for later matching when target message arrives + await dependencies.reactionService.queuePendingReaction( + parsed: parsed, + channelIndex: channelIndex, + senderNodeName: senderName, + rawText: text, + radioID: radioID + ) + return true + } + + /// Computes a symmetric timestamp window around the given time for reaction matching. + private func reactionTimestampWindow(at time: Date = Date()) -> ClosedRange { + reactionTimestampWindow(anchor: UInt32(time.timeIntervalSince1970)) + } + + /// Computes a symmetric timestamp window around a specific anchor timestamp. + private func reactionTimestampWindow(anchor: UInt32) -> ClosedRange { + let start = anchor > reactionTimestampWindowSeconds ? anchor - reactionTimestampWindowSeconds : 0 + return start...(anchor + reactionTimestampWindowSeconds) + } + + // MARK: - meshcore-open Reaction Handlers + + /// Handles a meshcore-open DM reaction by computing Dart hashes against DB candidates. + /// + /// No LRU cache or pending queue — if no match is found, the reaction is silently dropped. + private func handleMCODMReaction( + _ mcoReaction: ParsedMCOReaction, + rawText: String, + contact: ContactDTO, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + let timestampWindow = reactionTimestampWindow() + + guard let candidates = try? await dependencies.dataStore.fetchDMMessageCandidates( + radioID: radioID, + contactID: contact.id, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO DM reaction \(mcoReaction.emoji): no candidates in window") + return true } - // MARK: - meshcore-open Reaction Handlers - - /// Handles a meshcore-open DM reaction by computing Dart hashes against DB candidates. - /// - /// No LRU cache or pending queue — if no match is found, the reaction is silently dropped. - private func handleMCODMReaction( - _ mcoReaction: ParsedMCOReaction, - rawText: String, - contact: ContactDTO, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - let timestampWindow = reactionTimestampWindow() - - guard let candidates = try? await dependencies.dataStore.fetchDMMessageCandidates( - radioID: radioID, - contactID: contact.id, - timestampWindow: timestampWindow, - limit: 200 - ), !candidates.isEmpty else { - logger.debug("MCO DM reaction \(mcoReaction.emoji): no candidates in window") - return true - } - - for candidate in candidates { - // Skip messages that are themselves reactions - if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } - - let candidateHash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: candidate.reactionTimestamp, - senderName: nil, - text: candidate.text - ) - - guard candidateHash == mcoReaction.dartHash else { continue } - - let reactionDTO = ReactionDTO( - messageID: candidate.id, - emoji: mcoReaction.emoji, - senderName: contact.displayName, - messageHash: mcoReaction.dartHash, - rawText: rawText, - contactID: contact.id, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved MCO DM reaction \(mcoReaction.emoji) to message \(candidate.id)") - } - return true - } - - logger.debug("MCO DM reaction \(mcoReaction.emoji): no hash match found") - return true + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } + + let candidateHash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: candidate.reactionTimestamp, + senderName: nil, + text: candidate.text + ) + + guard candidateHash == mcoReaction.dartHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: mcoReaction.emoji, + senderName: contact.displayName, + messageHash: mcoReaction.dartHash, + rawText: rawText, + contactID: contact.id, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved MCO DM reaction \(mcoReaction.emoji) to message \(candidate.id)") + } + return true } - /// Handles a meshcore-open channel reaction by computing Dart hashes against DB candidates. - /// - /// No LRU cache or pending queue — if no match is found, the reaction is silently dropped. - private func handleMCOChannelReaction( - _ mcoReaction: ParsedMCOReaction, - rawText: String, - channelIndex: UInt8, - senderNodeName: String?, - selfNodeName: String, - receiveTime: Date, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - let senderName = senderNodeName ?? "Unknown" - let timestampWindow = reactionTimestampWindow(at: receiveTime) - - guard let candidates = try? await dependencies.dataStore.fetchChannelMessageCandidates( - radioID: radioID, - channelIndex: channelIndex, - timestampWindow: timestampWindow, - limit: 200 - ), !candidates.isEmpty else { - logger.debug("MCO channel reaction \(mcoReaction.emoji): no candidates in window") - return true - } - - for candidate in candidates { - // Skip messages that are themselves reactions - if ReactionParser.isReactionText(candidate.text, isDM: false) { continue } - - // For channel messages, the Dart hash includes the sender name - let candidateSenderName: String? - if candidate.direction == .outgoing { - candidateSenderName = selfNodeName.isEmpty ? nil : selfNodeName - } else { - candidateSenderName = candidate.senderNodeName - } - - let candidateHash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: candidate.reactionTimestamp, - senderName: candidateSenderName, - text: candidate.text - ) - - guard candidateHash == mcoReaction.dartHash else { continue } - - let reactionDTO = ReactionDTO( - messageID: candidate.id, - emoji: mcoReaction.emoji, - senderName: senderName, - messageHash: mcoReaction.dartHash, - rawText: rawText, - channelIndex: channelIndex, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved MCO channel reaction \(mcoReaction.emoji) to message \(candidate.id)") - } - return true - } - - logger.debug("MCO channel reaction \(mcoReaction.emoji): no hash match found") - return true + logger.debug("MCO DM reaction \(mcoReaction.emoji): no hash match found") + return true + } + + /// Handles a meshcore-open channel reaction by computing Dart hashes against DB candidates. + /// + /// No LRU cache or pending queue — if no match is found, the reaction is silently dropped. + private func handleMCOChannelReaction( + _ mcoReaction: ParsedMCOReaction, + rawText: String, + channelIndex: UInt8, + senderNodeName: String?, + selfNodeName: String, + receiveTime: Date, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + let senderName = senderNodeName ?? "Unknown" + let timestampWindow = reactionTimestampWindow(at: receiveTime) + + guard let candidates = try? await dependencies.dataStore.fetchChannelMessageCandidates( + radioID: radioID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO channel reaction \(mcoReaction.emoji): no candidates in window") + return true } - // MARK: - meshcore-open V1 Reaction Handlers - - /// Handles a meshcore-open v1 DM reaction by matching timestamp + Dart text hash. - private func handleMCOV1DMReaction( - _ v1Reaction: ParsedMCOReactionV1, - rawText: String, - contact: ContactDTO, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - let timestampWindow = reactionTimestampWindow( - anchor: v1Reaction.timestampSeconds - ) - - guard let candidates = try? await dependencies.dataStore.fetchDMMessageCandidates( - radioID: radioID, - contactID: contact.id, - timestampWindow: timestampWindow, - limit: 200 - ), !candidates.isEmpty else { - logger.debug("MCO v1 DM reaction \(v1Reaction.emoji): no candidates in window") - return true - } - - for candidate in candidates { - if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } - - let textHash = MeshCoreOpenReactionParser.dartStringHash(candidate.text) - guard textHash == v1Reaction.textHash else { continue } - - let reactionDTO = ReactionDTO( - messageID: candidate.id, - emoji: v1Reaction.emoji, - senderName: contact.displayName, - messageHash: v1Reaction.messageIdHash, - rawText: rawText, - contactID: contact.id, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved MCO v1 DM reaction \(v1Reaction.emoji) to message \(candidate.id)") - } - return true - } + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: false) { continue } + + // For channel messages, the Dart hash includes the sender name + let candidateSenderName: String? = if candidate.direction == .outgoing { + selfNodeName.isEmpty ? nil : selfNodeName + } else { + candidate.senderNodeName + } + + let candidateHash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: candidate.reactionTimestamp, + senderName: candidateSenderName, + text: candidate.text + ) + + guard candidateHash == mcoReaction.dartHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: mcoReaction.emoji, + senderName: senderName, + messageHash: mcoReaction.dartHash, + rawText: rawText, + channelIndex: channelIndex, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved MCO channel reaction \(mcoReaction.emoji) to message \(candidate.id)") + } + return true + } - logger.debug("MCO v1 DM reaction \(v1Reaction.emoji): no hash match found") - return true + logger.debug("MCO channel reaction \(mcoReaction.emoji): no hash match found") + return true + } + + // MARK: - meshcore-open V1 Reaction Handlers + + /// Handles a meshcore-open v1 DM reaction by matching timestamp + Dart text hash. + private func handleMCOV1DMReaction( + _ v1Reaction: ParsedMCOReactionV1, + rawText: String, + contact: ContactDTO, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + let timestampWindow = reactionTimestampWindow( + anchor: v1Reaction.timestampSeconds + ) + + guard let candidates = try? await dependencies.dataStore.fetchDMMessageCandidates( + radioID: radioID, + contactID: contact.id, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO v1 DM reaction \(v1Reaction.emoji): no candidates in window") + return true } - /// Handles a meshcore-open v1 channel reaction by matching timestamp + Dart sender/text hashes. - private func handleMCOV1ChannelReaction( - _ v1Reaction: ParsedMCOReactionV1, - rawText: String, - channelIndex: UInt8, - senderNodeName: String?, - selfNodeName: String, - radioID: UUID, - dependencies: SyncDependencies - ) async -> Bool { - let senderName = senderNodeName ?? "Unknown" - let timestampWindow = reactionTimestampWindow( - anchor: v1Reaction.timestampSeconds - ) - - guard let candidates = try? await dependencies.dataStore.fetchChannelMessageCandidates( - radioID: radioID, - channelIndex: channelIndex, - timestampWindow: timestampWindow, - limit: 200 - ), !candidates.isEmpty else { - logger.debug("MCO v1 channel reaction \(v1Reaction.emoji): no candidates in window") - return true - } + for candidate in candidates { + if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } + + let textHash = MeshCoreOpenReactionParser.dartStringHash(candidate.text) + guard textHash == v1Reaction.textHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: v1Reaction.emoji, + senderName: contact.displayName, + messageHash: v1Reaction.messageIdHash, + rawText: rawText, + contactID: contact.id, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved MCO v1 DM reaction \(v1Reaction.emoji) to message \(candidate.id)") + } + return true + } - for candidate in candidates { - if ReactionParser.isReactionText(candidate.text, isDM: false) { continue } - - // Verify sender name hash - let candidateSenderName: String? - if candidate.direction == .outgoing { - candidateSenderName = selfNodeName.isEmpty ? nil : selfNodeName - } else { - candidateSenderName = candidate.senderNodeName - } - - if let name = candidateSenderName { - let nameHash = MeshCoreOpenReactionParser.dartStringHash(name) - guard nameHash == v1Reaction.senderNameHash else { continue } - } - - // Verify text hash - let textHash = MeshCoreOpenReactionParser.dartStringHash(candidate.text) - guard textHash == v1Reaction.textHash else { continue } - - let reactionDTO = ReactionDTO( - messageID: candidate.id, - emoji: v1Reaction.emoji, - senderName: senderName, - messageHash: v1Reaction.messageIdHash, - rawText: rawText, - channelIndex: channelIndex, - radioID: radioID - ) - if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { - logger.debug("Saved MCO v1 channel reaction \(v1Reaction.emoji) to message \(candidate.id)") - } - return true - } + logger.debug("MCO v1 DM reaction \(v1Reaction.emoji): no hash match found") + return true + } + + /// Handles a meshcore-open v1 channel reaction by matching timestamp + Dart sender/text hashes. + private func handleMCOV1ChannelReaction( + _ v1Reaction: ParsedMCOReactionV1, + rawText: String, + channelIndex: UInt8, + senderNodeName: String?, + selfNodeName: String, + radioID: UUID, + dependencies: SyncDependencies + ) async -> Bool { + let senderName = senderNodeName ?? "Unknown" + let timestampWindow = reactionTimestampWindow( + anchor: v1Reaction.timestampSeconds + ) + + guard let candidates = try? await dependencies.dataStore.fetchChannelMessageCandidates( + radioID: radioID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO v1 channel reaction \(v1Reaction.emoji): no candidates in window") + return true + } - logger.debug("MCO v1 channel reaction \(v1Reaction.emoji): no hash match found") - return true + for candidate in candidates { + if ReactionParser.isReactionText(candidate.text, isDM: false) { continue } + + // Verify sender name hash + let candidateSenderName: String? = if candidate.direction == .outgoing { + selfNodeName.isEmpty ? nil : selfNodeName + } else { + candidate.senderNodeName + } + + if let name = candidateSenderName { + let nameHash = MeshCoreOpenReactionParser.dartStringHash(name) + guard nameHash == v1Reaction.senderNameHash else { continue } + } + + // Verify text hash + let textHash = MeshCoreOpenReactionParser.dartStringHash(candidate.text) + guard textHash == v1Reaction.textHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: v1Reaction.emoji, + senderName: senderName, + messageHash: v1Reaction.messageIdHash, + rawText: rawText, + channelIndex: channelIndex, + radioID: radioID + ) + if await persistReactionIfNew(reactionDTO, dependencies: dependencies) { + logger.debug("Saved MCO v1 channel reaction \(v1Reaction.emoji) to message \(candidate.id)") + } + return true } + + logger.debug("MCO v1 channel reaction \(v1Reaction.emoji): no hash match found") + return true + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift index ac03856c..bf0d06da 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift @@ -4,734 +4,734 @@ import Foundation // MARK: - Full Sync & Connection Lifecycle extension SyncCoordinator { - private struct ContactChannelSyncResult: Sendable { - let contacts: SyncPhaseStatus - let channels: SyncPhaseStatus - let channelRetryIndices: [UInt8] + private struct ContactChannelSyncResult { + let contacts: SyncPhaseStatus + let channels: SyncPhaseStatus + let channelRetryIndices: [UInt8] + } + + // MARK: - Full Sync + + /// Performs full sync of contacts, channels, and messages from device. + /// + /// This is the core sync method that ensures all data is pulled from the device. + /// It syncs in order: contacts → channels → messages. + /// + /// - Parameters: + /// - radioID: The connected device UUID + /// - dataStore: Persistence store for data operations + /// - contactService: Service for contact sync + /// - channelService: Service for channel sync + /// - messagePollingService: Service for message polling + /// - appStateProvider: Optional provider for foreground/background state. When nil, + /// defaults to foreground mode (channels sync). When provided and app is backgrounded, + /// channel sync is skipped to reduce BLE traffic. + /// - rxLogService: Optional service for updating contact public keys after sync. + /// - forceFullSync: When true, ignores lastContactSync watermark and fetches all contacts. + @discardableResult + func performFullSync( + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + contactService: some ContactServiceProtocol, + channelService: some ChannelServiceProtocol, + messagePollingService: some MessagePollingServiceProtocol, + appStateProvider: AppStateProvider? = nil, + rxLogService: RxLogService? = nil, + notificationService: NotificationService? = nil, + forceFullSync: Bool = false, + channelSyncConfig: ChannelSyncConfig = .none, + platformName: String = "unknown" + ) async throws -> FullSyncResult { + // Prevent concurrent syncs — actor-local flag avoids the TOCTOU window + // that existed when guarding via `await state.isSyncing` + guard !isSyncInProgress else { + logger.warning("performFullSync called while already syncing, ignoring duplicate") + return .skipped } - - // MARK: - Full Sync - - /// Performs full sync of contacts, channels, and messages from device. - /// - /// This is the core sync method that ensures all data is pulled from the device. - /// It syncs in order: contacts → channels → messages. - /// - /// - Parameters: - /// - radioID: The connected device UUID - /// - dataStore: Persistence store for data operations - /// - contactService: Service for contact sync - /// - channelService: Service for channel sync - /// - messagePollingService: Service for message polling - /// - appStateProvider: Optional provider for foreground/background state. When nil, - /// defaults to foreground mode (channels sync). When provided and app is backgrounded, - /// channel sync is skipped to reduce BLE traffic. - /// - rxLogService: Optional service for updating contact public keys after sync. - /// - forceFullSync: When true, ignores lastContactSync watermark and fetches all contacts. - @discardableResult - func performFullSync( - radioID: UUID, - dataStore: any PersistenceStoreProtocol, - contactService: some ContactServiceProtocol, - channelService: some ChannelServiceProtocol, - messagePollingService: some MessagePollingServiceProtocol, - appStateProvider: AppStateProvider? = nil, - rxLogService: RxLogService? = nil, - notificationService: NotificationService? = nil, - forceFullSync: Bool = false, - channelSyncConfig: ChannelSyncConfig = .none, - platformName: String = "unknown" - ) async throws -> FullSyncResult { - // Prevent concurrent syncs — actor-local flag avoids the TOCTOU window - // that existed when guarding via `await state.isSyncing` - guard !isSyncInProgress else { - logger.warning("performFullSync called while already syncing, ignoring duplicate") - return .skipped + isSyncInProgress = true + defer { isSyncInProgress = false } + + return try await runFullSync( + radioID: radioID, + dataStore: dataStore, + contactService: contactService, + channelService: channelService, + messagePollingService: messagePollingService, + appStateProvider: appStateProvider, + rxLogService: rxLogService, + notificationService: notificationService, + forceFullSync: forceFullSync, + channelSyncConfig: channelSyncConfig, + platformName: platformName + ) + } + + /// Full-sync body without the `isSyncInProgress` claim. Callers must hold + /// the claim already: `performFullSync` takes it per call, while + /// `onConnectionEstablished` claims it before its handler-wiring awaits so + /// racing connection setups cannot double-wire. + private func runFullSync( + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + contactService: some ContactServiceProtocol, + channelService: some ChannelServiceProtocol, + messagePollingService: some MessagePollingServiceProtocol, + appStateProvider: AppStateProvider? = nil, + rxLogService: RxLogService? = nil, + notificationService: NotificationService? = nil, + forceFullSync: Bool = false, + channelSyncConfig: ChannelSyncConfig = .none, + platformName: String = "unknown" + ) async throws -> FullSyncResult { + logger.info("Starting full sync for device \(radioID)") + let syncStart = ContinuousClock.now + + do { + // Set phase before triggering pill visibility + logger.info("[Sync] State → .syncing(.contacts)") + await setState(.syncing(progress: SyncProgress(phase: .contacts, current: 0, total: 0))) + hasEndedSyncActivity = false + logger.info("[Sync] Calling onSyncActivityStarted") + await onSyncActivityStarted?() + + // Perform contacts and channels sync (activity should show pill). + // Contacts remain connection-critical. Channel errors are degraded + // state: keep existing local channels and let the caller schedule a + // channel-only retry instead of replaying contacts. + let contactChannelResult = try await syncContactsAndChannels( + radioID: radioID, + dataStore: dataStore, + contactService: contactService, + channelService: channelService, + appStateProvider: appStateProvider, + rxLogService: rxLogService, + forceFullSync: forceFullSync, + channelSyncSkipWindow: channelSyncConfig.channelSyncSkipWindow, + lastCleanChannelSync: channelSyncConfig.lastCleanChannelSync, + lastAttemptedChannelSync: channelSyncConfig.lastAttemptedChannelSync, + usePipelinedRead: channelSyncConfig.usePipelinedChannelRead + ) + + // End sync activity before messages phase (pill should hide). + // During resync, the outer bracket (beginResyncActivity) holds syncActivityCount >= 1, + // so this inner succeeded=true decrement cannot reach zero and prematurely trigger + // the "Ready" toast. During initial sync there is no outer bracket, and reaching + // zero here is the correct "sync complete" signal. + await endSyncActivityOnce(succeeded: true) + + // Phase 3: Messages (no pill for this phase) + logger.info("[Sync] State → .syncing(.messages)") + await setState(.syncing(progress: SyncProgress(phase: .messages, current: 0, total: 0))) + let messageStart = ContinuousClock.now + let messageCount: Int + let messageStatus: SyncPhaseStatus + do { + messageCount = try await messagePollingService.pollAllMessages() + messageStatus = .clean + } catch let error as CancellationError { + throw error + } catch { + messageCount = 0 + messageStatus = .failed(error.localizedDescription) + logger.warning("[Sync] Message polling failed after contacts/channels: \(error.localizedDescription)") + } + let messageElapsed = ContinuousClock.now - messageStart + logger.info("[Sync] Phase end: messages - \(messageCount) polled in \(messageElapsed)") + + // Clear notification suppression immediately after catch-up poll completes. + // All catch-up messages have been processed with suppression active; + // any subsequent event-monitor messages are genuinely new and should notify. + if let notificationService { + cancelSuppressionWatchdog() + await MainActor.run { + notificationService.isSuppressingNotifications = false } - isSyncInProgress = true - defer { isSyncInProgress = false } - - return try await runFullSync( - radioID: radioID, - dataStore: dataStore, - contactService: contactService, - channelService: channelService, - messagePollingService: messagePollingService, - appStateProvider: appStateProvider, - rxLogService: rxLogService, - notificationService: notificationService, - forceFullSync: forceFullSync, - channelSyncConfig: channelSyncConfig, - platformName: platformName - ) + } + + await notifyConversationsChanged() + + // Complete + logger.info("[Sync] State → .synced") + await setState(.synced) + await setLastSyncDate(Date()) + + let elapsed = ContinuousClock.now - syncStart + logger.info("[Sync] Complete: platform=\(platformName), messages=\(messageCount), duration=\(elapsed)") + return FullSyncResult( + contacts: contactChannelResult.contacts, + channels: contactChannelResult.channels, + messages: messageStatus, + channelRetryIndices: contactChannelResult.channelRetryIndices + ) + } catch let error as CancellationError { + // Defensive: ensure activity count is decremented even if cancellation + // occurs outside the contacts/channels error path. + await endSyncActivityOnce() + await setState(.idle) + throw error + } catch { + // Defensive: ensure activity count is decremented even if an error is + // thrown from a path that bypasses the inner contacts/channels catch. + await endSyncActivityOnce() + logger.warning("[Sync] State → .failed: \(error.localizedDescription)") + await setState(.failed(.syncFailed(error.localizedDescription))) + throw error } - - /// Full-sync body without the `isSyncInProgress` claim. Callers must hold - /// the claim already: `performFullSync` takes it per call, while - /// `onConnectionEstablished` claims it before its handler-wiring awaits so - /// racing connection setups cannot double-wire. - private func runFullSync( - radioID: UUID, - dataStore: any PersistenceStoreProtocol, - contactService: some ContactServiceProtocol, - channelService: some ChannelServiceProtocol, - messagePollingService: some MessagePollingServiceProtocol, - appStateProvider: AppStateProvider? = nil, - rxLogService: RxLogService? = nil, - notificationService: NotificationService? = nil, - forceFullSync: Bool = false, - channelSyncConfig: ChannelSyncConfig = .none, - platformName: String = "unknown" - ) async throws -> FullSyncResult { - logger.info("Starting full sync for device \(radioID)") - let syncStart = ContinuousClock.now - - do { - // Set phase before triggering pill visibility - logger.info("[Sync] State → .syncing(.contacts)") - await setState(.syncing(progress: SyncProgress(phase: .contacts, current: 0, total: 0))) - hasEndedSyncActivity = false - logger.info("[Sync] Calling onSyncActivityStarted") - await onSyncActivityStarted?() - - // Perform contacts and channels sync (activity should show pill). - // Contacts remain connection-critical. Channel errors are degraded - // state: keep existing local channels and let the caller schedule a - // channel-only retry instead of replaying contacts. - let contactChannelResult = try await syncContactsAndChannels( - radioID: radioID, - dataStore: dataStore, - contactService: contactService, - channelService: channelService, - appStateProvider: appStateProvider, - rxLogService: rxLogService, - forceFullSync: forceFullSync, - channelSyncSkipWindow: channelSyncConfig.channelSyncSkipWindow, - lastCleanChannelSync: channelSyncConfig.lastCleanChannelSync, - lastAttemptedChannelSync: channelSyncConfig.lastAttemptedChannelSync, - usePipelinedRead: channelSyncConfig.usePipelinedChannelRead - ) - - // End sync activity before messages phase (pill should hide). - // During resync, the outer bracket (beginResyncActivity) holds syncActivityCount >= 1, - // so this inner succeeded=true decrement cannot reach zero and prematurely trigger - // the "Ready" toast. During initial sync there is no outer bracket, and reaching - // zero here is the correct "sync complete" signal. - await endSyncActivityOnce(succeeded: true) - - // Phase 3: Messages (no pill for this phase) - logger.info("[Sync] State → .syncing(.messages)") - await setState(.syncing(progress: SyncProgress(phase: .messages, current: 0, total: 0))) - let messageStart = ContinuousClock.now - let messageCount: Int - let messageStatus: SyncPhaseStatus - do { - messageCount = try await messagePollingService.pollAllMessages() - messageStatus = .clean - } catch let error as CancellationError { - throw error - } catch { - messageCount = 0 - messageStatus = .failed(error.localizedDescription) - logger.warning("[Sync] Message polling failed after contacts/channels: \(error.localizedDescription)") - } - let messageElapsed = ContinuousClock.now - messageStart - logger.info("[Sync] Phase end: messages - \(messageCount) polled in \(messageElapsed)") - - // Clear notification suppression immediately after catch-up poll completes. - // All catch-up messages have been processed with suppression active; - // any subsequent event-monitor messages are genuinely new and should notify. - if let notificationService { - cancelSuppressionWatchdog() - await MainActor.run { - notificationService.isSuppressingNotifications = false - } - } - - await notifyConversationsChanged() - - // Complete - logger.info("[Sync] State → .synced") - await setState(.synced) - await setLastSyncDate(Date()) - - let elapsed = ContinuousClock.now - syncStart - logger.info("[Sync] Complete: platform=\(platformName), messages=\(messageCount), duration=\(elapsed)") - return FullSyncResult( - contacts: contactChannelResult.contacts, - channels: contactChannelResult.channels, - messages: messageStatus, - channelRetryIndices: contactChannelResult.channelRetryIndices - ) - } catch let error as CancellationError { - // Defensive: ensure activity count is decremented even if cancellation - // occurs outside the contacts/channels error path. - await endSyncActivityOnce() - await setState(.idle) - throw error - } catch { - // Defensive: ensure activity count is decremented even if an error is - // thrown from a path that bypasses the inner contacts/channels catch. - await endSyncActivityOnce() - logger.warning("[Sync] State → .failed: \(error.localizedDescription)") - await setState(.failed(.syncFailed(error.localizedDescription))) - throw error - } + } + + /// Attempts to resync data after a previous sync failure. + /// Unlike onConnectionEstablished, does not rewire handlers or restart event monitoring. + /// - Parameters: + /// - radioID: The connected device UUID + /// - dependencies: The sync dependency surface + /// - forceFullSync: When true, forces a full contact sync instead of incremental. + /// - channelSyncConfig: Channel sync skip configuration. + /// - platformName: Platform name for instrumentation logging. + /// - Returns: `true` if sync succeeded, `false` if it failed + func performResync( + radioID: UUID, + dependencies: SyncDependencies, + forceFullSync: Bool = false, + channelSyncConfig: ChannelSyncConfig = .none, + platformName: String = "unknown" + ) async -> Bool { + #if DEBUG + if let override = performResyncOverride { + return await override(radioID, dependencies) + } + #endif + logger.info("Attempting resync for device \(radioID)") + + await MainActor.run { + logger.info("Suppressing message notifications during resync") + dependencies.notificationService.isSuppressingNotifications = true } - - /// Attempts to resync data after a previous sync failure. - /// Unlike onConnectionEstablished, does not rewire handlers or restart event monitoring. - /// - Parameters: - /// - radioID: The connected device UUID - /// - dependencies: The sync dependency surface - /// - forceFullSync: When true, forces a full contact sync instead of incremental. - /// - channelSyncConfig: Channel sync skip configuration. - /// - platformName: Platform name for instrumentation logging. - /// - Returns: `true` if sync succeeded, `false` if it failed - func performResync( - radioID: UUID, - dependencies: SyncDependencies, - forceFullSync: Bool = false, - channelSyncConfig: ChannelSyncConfig = .none, - platformName: String = "unknown" - ) async -> Bool { - #if DEBUG - if let override = performResyncOverride { - return await override(radioID, dependencies) - } - #endif - logger.info("Attempting resync for device \(radioID)") - - await MainActor.run { - logger.info("Suppressing message notifications during resync") - dependencies.notificationService.isSuppressingNotifications = true - } - startSuppressionWatchdog(notificationService: dependencies.notificationService) - logger.info("[Sync] Pausing auto-fetch for resync") - await dependencies.messagePollingService.pauseAutoFetch() - - do { - let result = try await performFullSync( - radioID: radioID, - dataStore: dependencies.dataStore, - contactService: dependencies.contactService, - channelService: dependencies.channelService, - messagePollingService: dependencies.messagePollingService, - appStateProvider: dependencies.appStateProvider, - rxLogService: dependencies.rxLogService, - notificationService: dependencies.notificationService, - forceFullSync: forceFullSync, - channelSyncConfig: channelSyncConfig, - platformName: platformName - ) - - startDiscoveryEventMonitoring(dependencies: dependencies, radioID: radioID) - - await drainHandlersAndResumeNotifications( - notificationService: dependencies.notificationService, - messagePollingService: dependencies.messagePollingService, - context: "resync complete" - ) - logger.info("[Sync] Resuming auto-fetch after resync") - await dependencies.messagePollingService.resumeAutoFetch() - - if result.isConnectionUsable { - logger.info("Resync succeeded") - return true - } - - logger.warning("Resync completed without usable contacts") - return false - } catch { - await drainHandlersAndResumeNotifications( - notificationService: dependencies.notificationService, - messagePollingService: dependencies.messagePollingService, - context: "resync failed" - ) - await dependencies.messagePollingService.resumeAutoFetch() - - logger.warning("Resync failed: \(error.localizedDescription)") - await setState(.failed(.syncFailed(error.localizedDescription))) - return false - } + startSuppressionWatchdog(notificationService: dependencies.notificationService) + logger.info("[Sync] Pausing auto-fetch for resync") + await dependencies.messagePollingService.pauseAutoFetch() + + do { + let result = try await performFullSync( + radioID: radioID, + dataStore: dependencies.dataStore, + contactService: dependencies.contactService, + channelService: dependencies.channelService, + messagePollingService: dependencies.messagePollingService, + appStateProvider: dependencies.appStateProvider, + rxLogService: dependencies.rxLogService, + notificationService: dependencies.notificationService, + forceFullSync: forceFullSync, + channelSyncConfig: channelSyncConfig, + platformName: platformName + ) + + startDiscoveryEventMonitoring(dependencies: dependencies, radioID: radioID) + + await drainHandlersAndResumeNotifications( + notificationService: dependencies.notificationService, + messagePollingService: dependencies.messagePollingService, + context: "resync complete" + ) + logger.info("[Sync] Resuming auto-fetch after resync") + await dependencies.messagePollingService.resumeAutoFetch() + + if result.isConnectionUsable { + logger.info("Resync succeeded") + return true + } + + logger.warning("Resync completed without usable contacts") + return false + } catch { + await drainHandlersAndResumeNotifications( + notificationService: dependencies.notificationService, + messagePollingService: dependencies.messagePollingService, + context: "resync failed" + ) + await dependencies.messagePollingService.resumeAutoFetch() + + logger.warning("Resync failed: \(error.localizedDescription)") + await setState(.failed(.syncFailed(error.localizedDescription))) + return false } - - // MARK: - Connection Lifecycle - - /// Called by ConnectionManager when connection is established. - /// Wires handlers, starts event monitoring, and performs initial sync. - /// - /// This is the critical method that fixes the handler wiring gap: - /// 1. Wire message handlers first (before events can arrive) - /// 2. Start event monitoring (handlers are now ready) - /// 3. Perform full sync (contacts, channels, messages) - /// 4. Start discovery event monitoring (for ongoing contact discovery) - /// - /// - Parameters: - /// - radioID: The connected device UUID - /// - dependencies: The sync dependency surface - /// - forceFullSync: When true, forces a full contact sync instead of incremental. - /// - channelSyncConfig: Channel sync skip configuration. - /// - platformName: Platform name for instrumentation logging. - @discardableResult - func onConnectionEstablished( - radioID: UUID, - dependencies: SyncDependencies, - forceFullSync: Bool = false, - channelSyncConfig: ChannelSyncConfig = .none, - platformName: String = "unknown" - ) async throws -> FullSyncResult { - logger.info("Connection established for device \(radioID)") - - // Claim synchronously before the wiring awaits below. A read-only guard - // let two racing calls (rapid auto-reconnect cycles) both pass and - // double-wire handlers; the claim makes the loser skip immediately. - guard !isSyncInProgress else { - logger.warning("onConnectionEstablished called while already syncing, ignoring duplicate") - return .skipped - } - isSyncInProgress = true - defer { isSyncInProgress = false } - - // Suppress message notifications during sync to avoid flooding user on reconnect - // Unread counts and badges still update - only system notifications are suppressed - await MainActor.run { - logger.info("Suppressing message notifications during sync") - dependencies.notificationService.isSuppressingNotifications = true - } - startSuppressionWatchdog(notificationService: dependencies.notificationService) - - do { - // Defer advert-driven contact fetches during sync to avoid BLE contention - await dependencies.advertisementService.setSyncingContacts(true) - - // 1. Wire message handlers first (before events can arrive) - await wireMessageHandlers(dependencies: dependencies, radioID: radioID) - - // Clean up legacy blocked sender messages still in DB from older app versions - await deleteBlockedSenderMessages(radioID: radioID, dataStore: dependencies.dataStore) - - // 2. Now start event monitoring (handlers are ready), but delay auto-fetch and advert monitoring until after sync - logger.info("[Sync] Starting event monitoring for device \(radioID.uuidString.prefix(8))") - await dependencies.startEventMonitoring(radioID, false) - - // 3. Export device private key for direct message decryption - do { - let privateKey = try await dependencies.exportPrivateKey() - await dependencies.rxLogService.updatePrivateKey(privateKey) - logger.debug("Device private key exported for direct message decryption") - } catch { - logger.warning("Failed to export private key: \(error.localizedDescription)") - } - - // 4. Perform full sync (claim is already held; the guarded entry - // point would see its own claim and skip) - let syncResult = try await runFullSync( - radioID: radioID, - dataStore: dependencies.dataStore, - contactService: dependencies.contactService, - channelService: dependencies.channelService, - messagePollingService: dependencies.messagePollingService, - appStateProvider: dependencies.appStateProvider, - rxLogService: dependencies.rxLogService, - notificationService: dependencies.notificationService, - forceFullSync: forceFullSync, - channelSyncConfig: channelSyncConfig, - platformName: platformName - ) - - // 5. Start discovery event monitoring (for ongoing contact discovery). - // Intentionally after the full sync so adverts arriving during sync - // do not spam notifications. - startDiscoveryEventMonitoring(dependencies: dependencies, radioID: radioID) - - // 6. Flush deferred advert-driven contact fetches now that discovery monitoring is live - await dependencies.advertisementService.setSyncingContacts(false) - - // 7. Drain pending message handlers and resume notifications - await drainHandlersAndResumeNotifications( - notificationService: dependencies.notificationService, - messagePollingService: dependencies.messagePollingService, - context: "sync complete" - ) - - // 8. Start auto-fetch after suppression is cleared to avoid notification spam - logger.info("[Sync] Starting auto-fetch for device \(radioID.uuidString.prefix(8))") - await dependencies.messagePollingService.startAutoFetch(radioID: radioID) - - logger.info("Connection setup complete for device \(radioID)") - return syncResult - } catch { - // Drain pending message handlers and resume notifications - await drainHandlersAndResumeNotifications( - notificationService: dependencies.notificationService, - messagePollingService: dependencies.messagePollingService, - context: "sync failed" - ) - await dependencies.advertisementService.setSyncingContacts(false) - throw error - } + } + + // MARK: - Connection Lifecycle + + /// Called by ConnectionManager when connection is established. + /// Wires handlers, starts event monitoring, and performs initial sync. + /// + /// This is the critical method that fixes the handler wiring gap: + /// 1. Wire message handlers first (before events can arrive) + /// 2. Start event monitoring (handlers are now ready) + /// 3. Perform full sync (contacts, channels, messages) + /// 4. Start discovery event monitoring (for ongoing contact discovery) + /// + /// - Parameters: + /// - radioID: The connected device UUID + /// - dependencies: The sync dependency surface + /// - forceFullSync: When true, forces a full contact sync instead of incremental. + /// - channelSyncConfig: Channel sync skip configuration. + /// - platformName: Platform name for instrumentation logging. + @discardableResult + func onConnectionEstablished( + radioID: UUID, + dependencies: SyncDependencies, + forceFullSync: Bool = false, + channelSyncConfig: ChannelSyncConfig = .none, + platformName: String = "unknown" + ) async throws -> FullSyncResult { + logger.info("Connection established for device \(radioID)") + + // Claim synchronously before the wiring awaits below. A read-only guard + // let two racing calls (rapid auto-reconnect cycles) both pass and + // double-wire handlers; the claim makes the loser skip immediately. + guard !isSyncInProgress else { + logger.warning("onConnectionEstablished called while already syncing, ignoring duplicate") + return .skipped + } + isSyncInProgress = true + defer { isSyncInProgress = false } + + // Suppress message notifications during sync to avoid flooding user on reconnect + // Unread counts and badges still update - only system notifications are suppressed + await MainActor.run { + logger.info("Suppressing message notifications during sync") + dependencies.notificationService.isSuppressingNotifications = true + } + startSuppressionWatchdog(notificationService: dependencies.notificationService) + + do { + // Defer advert-driven contact fetches during sync to avoid BLE contention + await dependencies.advertisementService.setSyncingContacts(true) + + // 1. Wire message handlers first (before events can arrive) + await wireMessageHandlers(dependencies: dependencies, radioID: radioID) + + // Clean up legacy blocked sender messages still in DB from older app versions + await deleteBlockedSenderMessages(radioID: radioID, dataStore: dependencies.dataStore) + + // 2. Now start event monitoring (handlers are ready), but delay auto-fetch and advert monitoring until after sync + logger.info("[Sync] Starting event monitoring for device \(radioID.uuidString.prefix(8))") + await dependencies.startEventMonitoring(radioID, false) + + // 3. Export device private key for direct message decryption + do { + let privateKey = try await dependencies.exportPrivateKey() + await dependencies.rxLogService.updatePrivateKey(privateKey) + logger.debug("Device private key exported for direct message decryption") + } catch { + logger.warning("Failed to export private key: \(error.localizedDescription)") + } + + // 4. Perform full sync (claim is already held; the guarded entry + // point would see its own claim and skip) + let syncResult = try await runFullSync( + radioID: radioID, + dataStore: dependencies.dataStore, + contactService: dependencies.contactService, + channelService: dependencies.channelService, + messagePollingService: dependencies.messagePollingService, + appStateProvider: dependencies.appStateProvider, + rxLogService: dependencies.rxLogService, + notificationService: dependencies.notificationService, + forceFullSync: forceFullSync, + channelSyncConfig: channelSyncConfig, + platformName: platformName + ) + + // 5. Start discovery event monitoring (for ongoing contact discovery). + // Intentionally after the full sync so adverts arriving during sync + // do not spam notifications. + startDiscoveryEventMonitoring(dependencies: dependencies, radioID: radioID) + + // 6. Flush deferred advert-driven contact fetches now that discovery monitoring is live + await dependencies.advertisementService.setSyncingContacts(false) + + // 7. Drain pending message handlers and resume notifications + await drainHandlersAndResumeNotifications( + notificationService: dependencies.notificationService, + messagePollingService: dependencies.messagePollingService, + context: "sync complete" + ) + + // 8. Start auto-fetch after suppression is cleared to avoid notification spam + logger.info("[Sync] Starting auto-fetch for device \(radioID.uuidString.prefix(8))") + await dependencies.messagePollingService.startAutoFetch(radioID: radioID) + + logger.info("Connection setup complete for device \(radioID)") + return syncResult + } catch { + // Drain pending message handlers and resume notifications + await drainHandlersAndResumeNotifications( + notificationService: dependencies.notificationService, + messagePollingService: dependencies.messagePollingService, + context: "sync failed" + ) + await dependencies.advertisementService.setSyncingContacts(false) + throw error + } + } + + /// Called when disconnecting from device + /// + /// If disconnect occurs mid-sync (during contacts or channels phase), we must call + /// onSyncActivityEnded to decrement the activity count, otherwise the pill stays stuck. + func onDisconnected(notificationService: NotificationService) async { + let currentState = await state + logger.warning( + "[Sync] onDisconnected called - syncState: \(String(describing: currentState)), hasEndedSyncActivity: \(hasEndedSyncActivity)" + ) + + // Safety net: clear sync guard flag on disconnect + if isSyncInProgress { + logger.warning("isSyncInProgress still true at disconnect — clearing as safety net") } + isSyncInProgress = false - /// Called when disconnecting from device - /// - /// If disconnect occurs mid-sync (during contacts or channels phase), we must call - /// onSyncActivityEnded to decrement the activity count, otherwise the pill stays stuck. - func onDisconnected(notificationService: NotificationService) async { - let currentState = await state - logger.warning( - "[Sync] onDisconnected called - syncState: \(String(describing: currentState)), hasEndedSyncActivity: \(hasEndedSyncActivity)" - ) + // Note: pending reactions are not cleared on disconnect - they persist for the app session + // This handles temporary BLE disconnects without losing queued reactions + unresolvedChannelIndices.removeAll() + lastUnresolvedChannelSummaryAt = nil - // Safety net: clear sync guard flag on disconnect - if isSyncInProgress { - logger.warning("isSyncInProgress still true at disconnect — clearing as safety net") - } - isSyncInProgress = false + // If we're mid-sync in contacts or channels phase, end the activity to hide the pill + if case let .syncing(progress) = currentState, + progress.phase == .contacts || progress.phase == .channels { + await endSyncActivityOnce() + } - // Note: pending reactions are not cleared on disconnect - they persist for the app session - // This handles temporary BLE disconnects without losing queued reactions - unresolvedChannelIndices.removeAll() - lastUnresolvedChannelSummaryAt = nil + logger.info("[Sync] State → .idle (disconnected)") + await setState(.idle) - // If we're mid-sync in contacts or channels phase, end the activity to hide the pill - if case .syncing(let progress) = currentState, - progress.phase == .contacts || progress.phase == .channels { - await endSyncActivityOnce() - } + // Safety net: ensure suppression is cleared on disconnect + // Handles edge cases like connection dropping mid-sync or force-quit + cancelSuppressionWatchdog() + await MainActor.run { + notificationService.isSuppressingNotifications = false + } - logger.info("[Sync] State → .idle (disconnected)") - await setState(.idle) + logger.info("Disconnected, sync state reset to idle") + } + + // MARK: - Sync Helpers + + /// Syncs contacts and channels from the device (phases 1 and 2 of full sync). + private func syncContactsAndChannels( + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + contactService: some ContactServiceProtocol, + channelService: some ChannelServiceProtocol, + appStateProvider: AppStateProvider?, + rxLogService: RxLogService?, + forceFullSync: Bool, + channelSyncSkipWindow: Duration = .zero, + lastCleanChannelSync: Date? = nil, + lastAttemptedChannelSync: Date? = nil, + usePipelinedRead: Bool = false + ) async throws -> ContactChannelSyncResult { + // Fetch device once for both contacts (lastContactSync) and channels (maxChannels) + let device = try await dataStore.fetchDevice(radioID: radioID) + + // Phase 1: Contacts (incremental unless forced full) + let lastContactSync: Date? = forceFullSync ? nil : { + guard let timestamp = device?.lastContactSync, timestamp > 0 else { return nil } + return Date(timeIntervalSince1970: Double(timestamp)) + }() + if let watermark = lastContactSync { + logger.info("[Sync] Phase start: contacts (incremental, watermark=\(watermark.formatted(.iso8601)))") + } else { + let reason = forceFullSync ? "forceFullSync" : "no watermark" + logger.notice("[Sync] Phase start: contacts (FULL sync, reason=\(reason)) — local contacts not on device will be pruned") + } - // Safety net: ensure suppression is cleared on disconnect - // Handles edge cases like connection dropping mid-sync or force-quit - cancelSuppressionWatchdog() - await MainActor.run { - notificationService.isSuppressingNotifications = false - } + let contactStart = ContinuousClock.now + let contactResult = try await contactService.syncContacts(radioID: radioID, since: lastContactSync) + let contactElapsed = ContinuousClock.now - contactStart + let syncType = contactResult.isIncremental ? "incremental" : "full" + let forced = forceFullSync ? ", forced" : "" + logger.info("[Sync] Phase end: contacts - \(contactResult.contactsReceived) (\(syncType)\(forced)) in \(contactElapsed)") + await notifyContactsChanged() + + // Update lastContactSync watermark for future incremental syncs + if contactResult.lastSyncTimestamp > 0 { + try await dataStore.updateDeviceLastContactSync( + radioID: radioID, + timestamp: contactResult.lastSyncTimestamp + ) + } - logger.info("Disconnected, sync state reset to idle") + // Update RxLogService with contact public keys for direct message decryption + if let rxLogService { + do { + let publicKeys = try await dataStore.fetchContactPublicKeysByPrefix(radioID: radioID) + await rxLogService.updateContactPublicKeys(publicKeys) + logger.debug("Updated \(publicKeys.count) contact public keys for direct message decryption") + } catch { + logger.error("Failed to fetch contact public keys: \(error)") + } } - // MARK: - Sync Helpers - - /// Syncs contacts and channels from the device (phases 1 and 2 of full sync). - private func syncContactsAndChannels( - radioID: UUID, - dataStore: any PersistenceStoreProtocol, - contactService: some ContactServiceProtocol, - channelService: some ChannelServiceProtocol, - appStateProvider: AppStateProvider?, - rxLogService: RxLogService?, - forceFullSync: Bool, - channelSyncSkipWindow: Duration = .zero, - lastCleanChannelSync: Date? = nil, - lastAttemptedChannelSync: Date? = nil, - usePipelinedRead: Bool = false - ) async throws -> ContactChannelSyncResult { - // Fetch device once for both contacts (lastContactSync) and channels (maxChannels) - let device = try await dataStore.fetchDevice(radioID: radioID) - - // Phase 1: Contacts (incremental unless forced full) - let lastContactSync: Date? = forceFullSync ? nil : { - guard let timestamp = device?.lastContactSync, timestamp > 0 else { return nil } - return Date(timeIntervalSince1970: Double(timestamp)) - }() - if let watermark = lastContactSync { - logger.info("[Sync] Phase start: contacts (incremental, watermark=\(watermark.formatted(.iso8601)))") - } else { - let reason = forceFullSync ? "forceFullSync" : "no watermark" - logger.notice("[Sync] Phase start: contacts (FULL sync, reason=\(reason)) — local contacts not on device will be pruned") + // Phase 2: Channels (foreground only) + var channelStatus: SyncPhaseStatus = .skipped + var channelRetryIndices: [UInt8] = [] + logger.debug("About to check foreground state, provider exists: \(appStateProvider != nil)") + let shouldSyncChannels: Bool + if let provider = appStateProvider { + logger.debug("Calling isInForeground...") + shouldSyncChannels = await provider.isInForeground + logger.debug("isInForeground returned: \(shouldSyncChannels)") + } else { + logger.debug("No appStateProvider, defaulting to foreground mode") + shouldSyncChannels = true + } + logger.debug("Proceeding with shouldSyncChannels=\(shouldSyncChannels)") + if shouldSyncChannels { + let shouldSkipChannels: Bool = { + guard !forceFullSync, + channelSyncSkipWindow > .zero else { return false } + let now = Date() + if let lastSync = lastCleanChannelSync, + now.timeIntervalSince(lastSync) < Double(channelSyncSkipWindow.components.seconds) { + return true } - - let contactStart = ContinuousClock.now - let contactResult = try await contactService.syncContacts(radioID: radioID, since: lastContactSync) - let contactElapsed = ContinuousClock.now - contactStart - let syncType = contactResult.isIncremental ? "incremental" : "full" - let forced = forceFullSync ? ", forced" : "" - logger.info("[Sync] Phase end: contacts - \(contactResult.contactsReceived) (\(syncType)\(forced)) in \(contactElapsed)") - await notifyContactsChanged() - - // Update lastContactSync watermark for future incremental syncs - if contactResult.lastSyncTimestamp > 0 { - try await dataStore.updateDeviceLastContactSync( - radioID: radioID, - timestamp: contactResult.lastSyncTimestamp - ) + if let lastAttempt = lastAttemptedChannelSync, + now.timeIntervalSince(lastAttempt) < Double(channelSyncSkipWindow.components.seconds) { + return true } + return false + }() + + if shouldSkipChannels { + channelStatus = .skipped + logger.info("[Sync] Skipping channel sync (recent clean or attempted sync)") + } else { + logger.info("[Sync] State → .syncing(.channels)") + await setState(.syncing(progress: SyncProgress(phase: .channels, current: 0, total: 0))) + let maxChannels = device?.maxChannels ?? 0 + await onChannelSyncAttempted?(radioID) - // Update RxLogService with contact public keys for direct message decryption - if let rxLogService { + let channelStart = ContinuousClock.now + let channelResult: ChannelSyncResult + do { + channelResult = try await channelService.syncChannels( + radioID: radioID, + maxChannels: maxChannels, + usePipelinedRead: usePipelinedRead + ) + } catch let error as CancellationError { + throw error + } catch { + let channelElapsed = ContinuousClock.now - channelStart + channelStatus = .partial + logger.warning( + "[Sync] Phase end: channels partial after thrown error in \(channelElapsed): \(error.localizedDescription)" + ) + await logPostSyncChannelDiagnostics(radioID: radioID, dataStore: dataStore) + if let rxLogService { + await refreshRxLogChannels(radioID: radioID, dataStore: dataStore, rxLogService: rxLogService) + } + return ContactChannelSyncResult( + contacts: .clean, + channels: channelStatus, + channelRetryIndices: channelRetryIndices + ) + } + let channelElapsed = ContinuousClock.now - channelStart + logger.info("[Sync] Phase end: channels - \(channelResult.channelsSynced) synced (device capacity: \(maxChannels)) in \(channelElapsed)") + + var channelPhaseClean = channelResult.isComplete + let hasNonRetryableErrors = channelResult.errors.count > channelResult.retryableIndices.count + var remainingRetryableIndices = channelResult.retryableIndices + + // Retry failed channels once if there are retryable errors + if !channelResult.isComplete { + let retryableIndices = channelResult.retryableIndices + if !retryableIndices.isEmpty { + logger.info("Retrying \(retryableIndices.count) failed channels") + let retryResult: ChannelSyncResult do { - let publicKeys = try await dataStore.fetchContactPublicKeysByPrefix(radioID: radioID) - await rxLogService.updateContactPublicKeys(publicKeys) - logger.debug("Updated \(publicKeys.count) contact public keys for direct message decryption") + retryResult = try await channelService.retryFailedChannels( + radioID: radioID, + indices: retryableIndices + ) + } catch let error as CancellationError { + throw error } catch { - logger.error("Failed to fetch contact public keys: \(error)") - } - } - - // Phase 2: Channels (foreground only) - var channelStatus: SyncPhaseStatus = .skipped - var channelRetryIndices: [UInt8] = [] - logger.debug("About to check foreground state, provider exists: \(appStateProvider != nil)") - let shouldSyncChannels: Bool - if let provider = appStateProvider { - logger.debug("Calling isInForeground...") - shouldSyncChannels = await provider.isInForeground - logger.debug("isInForeground returned: \(shouldSyncChannels)") - } else { - logger.debug("No appStateProvider, defaulting to foreground mode") - shouldSyncChannels = true - } - logger.debug("Proceeding with shouldSyncChannels=\(shouldSyncChannels)") - if shouldSyncChannels { - let shouldSkipChannels: Bool = { - guard !forceFullSync, - channelSyncSkipWindow > .zero else { return false } - let now = Date() - if let lastSync = lastCleanChannelSync, - now.timeIntervalSince(lastSync) < Double(channelSyncSkipWindow.components.seconds) { - return true - } - if let lastAttempt = lastAttemptedChannelSync, - now.timeIntervalSince(lastAttempt) < Double(channelSyncSkipWindow.components.seconds) { - return true + logger.warning("Channel retry failed: \(error.localizedDescription)") + channelPhaseClean = false + remainingRetryableIndices = retryableIndices + retryResult = ChannelSyncResult( + channelsSynced: 0, + errors: retryableIndices.map { + ChannelSyncError( + index: $0, + errorType: .timeout, + description: error.localizedDescription + ) } - return false - }() + ) + } - if shouldSkipChannels { - channelStatus = .skipped - logger.info("[Sync] Skipping channel sync (recent clean or attempted sync)") + if retryResult.isComplete, !hasNonRetryableErrors { + logger.info("Retry recovered \(retryResult.channelsSynced) channels") + channelPhaseClean = true } else { - logger.info("[Sync] State → .syncing(.channels)") - await setState(.syncing(progress: SyncProgress(phase: .channels, current: 0, total: 0))) - let maxChannels = device?.maxChannels ?? 0 - await onChannelSyncAttempted?(radioID) - - let channelStart = ContinuousClock.now - let channelResult: ChannelSyncResult - do { - channelResult = try await channelService.syncChannels( - radioID: radioID, - maxChannels: maxChannels, - usePipelinedRead: usePipelinedRead - ) - } catch let error as CancellationError { - throw error - } catch { - let channelElapsed = ContinuousClock.now - channelStart - channelStatus = .partial - logger.warning( - "[Sync] Phase end: channels partial after thrown error in \(channelElapsed): \(error.localizedDescription)" - ) - await logPostSyncChannelDiagnostics(radioID: radioID, dataStore: dataStore) - if let rxLogService { - await refreshRxLogChannels(radioID: radioID, dataStore: dataStore, rxLogService: rxLogService) - } - return ContactChannelSyncResult( - contacts: .clean, - channels: channelStatus, - channelRetryIndices: channelRetryIndices - ) - } - let channelElapsed = ContinuousClock.now - channelStart - logger.info("[Sync] Phase end: channels - \(channelResult.channelsSynced) synced (device capacity: \(maxChannels)) in \(channelElapsed)") - - var channelPhaseClean = channelResult.isComplete - let hasNonRetryableErrors = channelResult.errors.count > channelResult.retryableIndices.count - var remainingRetryableIndices = channelResult.retryableIndices - - // Retry failed channels once if there are retryable errors - if !channelResult.isComplete { - let retryableIndices = channelResult.retryableIndices - if !retryableIndices.isEmpty { - logger.info("Retrying \(retryableIndices.count) failed channels") - let retryResult: ChannelSyncResult - do { - retryResult = try await channelService.retryFailedChannels( - radioID: radioID, - indices: retryableIndices - ) - } catch let error as CancellationError { - throw error - } catch { - logger.warning("Channel retry failed: \(error.localizedDescription)") - channelPhaseClean = false - remainingRetryableIndices = retryableIndices - retryResult = ChannelSyncResult( - channelsSynced: 0, - errors: retryableIndices.map { - ChannelSyncError( - index: $0, - errorType: .timeout, - description: error.localizedDescription - ) - } - ) - } - - if retryResult.isComplete && !hasNonRetryableErrors { - logger.info("Retry recovered \(retryResult.channelsSynced) channels") - channelPhaseClean = true - } else { - remainingRetryableIndices = retryResult.retryableIndices - if hasNonRetryableErrors { - logger.warning("Channels have non-retryable errors, phase not clean") - } - logger.warning("Channels still failing after retry: \(retryResult.errors.map { $0.index })") - channelPhaseClean = false - } - } - } - - if channelPhaseClean { - channelStatus = .clean - await onCleanChannelSync?(radioID) - } else { - channelStatus = .partial - channelRetryIndices = remainingRetryableIndices - } + remainingRetryableIndices = retryResult.retryableIndices + if hasNonRetryableErrors { + logger.warning("Channels have non-retryable errors, phase not clean") + } + logger.warning("Channels still failing after retry: \(retryResult.errors.map(\.index))") + channelPhaseClean = false } + } + } - await logPostSyncChannelDiagnostics(radioID: radioID, dataStore: dataStore) - if let rxLogService { - await refreshRxLogChannels(radioID: radioID, dataStore: dataStore, rxLogService: rxLogService) - } + if channelPhaseClean { + channelStatus = .clean + await onCleanChannelSync?(radioID) } else { - channelStatus = .skipped - logger.info("Skipping channel sync (app in background)") + channelStatus = .partial + channelRetryIndices = remainingRetryableIndices } - - return ContactChannelSyncResult( - contacts: .clean, - channels: channelStatus, - channelRetryIndices: channelRetryIndices - ) + } + + await logPostSyncChannelDiagnostics(radioID: radioID, dataStore: dataStore) + if let rxLogService { + await refreshRxLogChannels(radioID: radioID, dataStore: dataStore, rxLogService: rxLogService) + } + } else { + channelStatus = .skipped + logger.info("Skipping channel sync (app in background)") } - /// Retries only unresolved channel indices without replaying contacts/messages. - @discardableResult - func retryChannels( - radioID: UUID, - channelService: some ChannelServiceProtocol, - indices: [UInt8] - ) async -> ChannelSyncResult { - guard !indices.isEmpty else { - return ChannelSyncResult(channelsSynced: 0, errors: []) - } + return ContactChannelSyncResult( + contacts: .clean, + channels: channelStatus, + channelRetryIndices: channelRetryIndices + ) + } + + /// Retries only unresolved channel indices without replaying contacts/messages. + @discardableResult + func retryChannels( + radioID: UUID, + channelService: some ChannelServiceProtocol, + indices: [UInt8] + ) async -> ChannelSyncResult { + guard !indices.isEmpty else { + return ChannelSyncResult(channelsSynced: 0, errors: []) + } - guard !isSyncInProgress else { - logger.info("Channel-only retry skipped because sync is already active") - return ChannelSyncResult( - channelsSynced: 0, - errors: indices.map { - ChannelSyncError( - index: $0, - errorType: .circuitBreaker, - description: "Skipped because sync is already active" - ) - } - ) + guard !isSyncInProgress else { + logger.info("Channel-only retry skipped because sync is already active") + return ChannelSyncResult( + channelsSynced: 0, + errors: indices.map { + ChannelSyncError( + index: $0, + errorType: .circuitBreaker, + description: "Skipped because sync is already active" + ) } + ) + } - isSyncInProgress = true - defer { isSyncInProgress = false } - - logger.info("[Sync] State → .syncing(.channels) (channel-only retry)") - await setState(.syncing(progress: SyncProgress(phase: .channels, current: 0, total: indices.count))) - await onSyncActivityStarted?() - await onChannelSyncAttempted?(radioID) - - let result: ChannelSyncResult - do { - result = try await channelService.retryFailedChannels(radioID: radioID, indices: indices) - } catch is CancellationError { - await onSyncActivityEnded?(false) - await setState(.idle) - return ChannelSyncResult( - channelsSynced: 0, - errors: indices.map { - ChannelSyncError(index: $0, errorType: .transportError, description: "Retry cancelled") - } - ) - } catch { - logger.warning("Channel-only retry failed: \(error.localizedDescription)") - await onSyncActivityEnded?(false) - await setState(.synced) - return ChannelSyncResult( - channelsSynced: 0, - errors: indices.map { - ChannelSyncError(index: $0, errorType: .transportError, description: error.localizedDescription) - } - ) + isSyncInProgress = true + defer { isSyncInProgress = false } + + logger.info("[Sync] State → .syncing(.channels) (channel-only retry)") + await setState(.syncing(progress: SyncProgress(phase: .channels, current: 0, total: indices.count))) + await onSyncActivityStarted?() + await onChannelSyncAttempted?(radioID) + + let result: ChannelSyncResult + do { + result = try await channelService.retryFailedChannels(radioID: radioID, indices: indices) + } catch is CancellationError { + await onSyncActivityEnded?(false) + await setState(.idle) + return ChannelSyncResult( + channelsSynced: 0, + errors: indices.map { + ChannelSyncError(index: $0, errorType: .transportError, description: "Retry cancelled") } - - if result.isComplete { - await onCleanChannelSync?(radioID) + ) + } catch { + logger.warning("Channel-only retry failed: \(error.localizedDescription)") + await onSyncActivityEnded?(false) + await setState(.synced) + return ChannelSyncResult( + channelsSynced: 0, + errors: indices.map { + ChannelSyncError(index: $0, errorType: .transportError, description: error.localizedDescription) } - - await onSyncActivityEnded?(result.isComplete) - await setState(.synced) - return result + ) } - /// Cancels the suppression watchdog, resumes notifications, then waits for pending - /// message handlers to drain. Suppression is cleared first as defense-in-depth for - /// error paths where `performFullSync` throws before reaching `pollAllMessages()`. - /// Both operations are idempotent, so double-clearing from the happy path is harmless. - private func drainHandlersAndResumeNotifications( - notificationService: NotificationService, - messagePollingService: any MessagePollingServiceProtocol, - context: String - ) async { - cancelSuppressionWatchdog() - await MainActor.run { - logger.info("Resuming message notifications (\(context))") - notificationService.isSuppressingNotifications = false - } - - let pendingHandlerDrainTimeout: Duration = .seconds(30) - let didDrainPendingHandlers = await messagePollingService.waitForPendingHandlers(timeout: pendingHandlerDrainTimeout) - if !didDrainPendingHandlers { - logger.warning("Timed out waiting for pending message handlers") - } + if result.isComplete { + await onCleanChannelSync?(radioID) } - private func logPostSyncChannelDiagnostics(radioID: UUID, dataStore: any PersistenceStoreProtocol) async { - do { - let channels = try await dataStore.fetchChannels(radioID: radioID) - let emptyNameWithSecretIndices = channels - .filter { $0.name.isEmpty && $0.hasSecret } - .map(\.index) - .sorted() - logger.info( - "Post-sync channel diagnostics: total=\(channels.count), emptyNameWithSecret=\(emptyNameWithSecretIndices.count)" - ) - if !emptyNameWithSecretIndices.isEmpty { - logger.warning( - "Post-sync channels with empty names and non-zero secrets: \(emptyNameWithSecretIndices)" - ) - } - } catch { - logger.error("Failed to compute post-sync channel diagnostics: \(error)") - } + await onSyncActivityEnded?(result.isComplete) + await setState(.synced) + return result + } + + /// Cancels the suppression watchdog, resumes notifications, then waits for pending + /// message handlers to drain. Suppression is cleared first as defense-in-depth for + /// error paths where `performFullSync` throws before reaching `pollAllMessages()`. + /// Both operations are idempotent, so double-clearing from the happy path is harmless. + private func drainHandlersAndResumeNotifications( + notificationService: NotificationService, + messagePollingService: any MessagePollingServiceProtocol, + context: String + ) async { + cancelSuppressionWatchdog() + await MainActor.run { + logger.info("Resuming message notifications (\(context))") + notificationService.isSuppressingNotifications = false } - private func refreshRxLogChannels( - radioID: UUID, - dataStore: any PersistenceStoreProtocol, - rxLogService: RxLogService - ) async { - do { - let channels = try await dataStore.fetchChannels(radioID: radioID) - let secrets = Dictionary(uniqueKeysWithValues: channels.map { ($0.index, $0.secret) }) - let names = Dictionary(uniqueKeysWithValues: channels.map { ($0.index, $0.name) }) - await rxLogService.updateChannels(secrets: secrets, names: names) - logger.debug("Refreshed RxLogService channel cache with \(channels.count) channels") - } catch { - logger.error("Failed to refresh RxLogService channel cache: \(error)") - } + let pendingHandlerDrainTimeout: Duration = .seconds(30) + let didDrainPendingHandlers = await messagePollingService.waitForPendingHandlers(timeout: pendingHandlerDrainTimeout) + if !didDrainPendingHandlers { + logger.warning("Timed out waiting for pending message handlers") + } + } + + private func logPostSyncChannelDiagnostics(radioID: UUID, dataStore: any PersistenceStoreProtocol) async { + do { + let channels = try await dataStore.fetchChannels(radioID: radioID) + let emptyNameWithSecretIndices = channels + .filter { $0.name.isEmpty && $0.hasSecret } + .map(\.index) + .sorted() + logger.info( + "Post-sync channel diagnostics: total=\(channels.count), emptyNameWithSecret=\(emptyNameWithSecretIndices.count)" + ) + if !emptyNameWithSecretIndices.isEmpty { + logger.warning( + "Post-sync channels with empty names and non-zero secrets: \(emptyNameWithSecretIndices)" + ) + } + } catch { + logger.error("Failed to compute post-sync channel diagnostics: \(error)") + } + } + + private func refreshRxLogChannels( + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + rxLogService: RxLogService + ) async { + do { + let channels = try await dataStore.fetchChannels(radioID: radioID) + let secrets = Dictionary(uniqueKeysWithValues: channels.map { ($0.index, $0.secret) }) + let names = Dictionary(uniqueKeysWithValues: channels.map { ($0.index, $0.name) }) + await rxLogService.updateChannels(secrets: secrets, names: names) + logger.debug("Refreshed RxLogService channel cache with \(channels.count) channels") + } catch { + logger.error("Failed to refresh RxLogService channel cache: \(error)") } + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift index c00d8ef0..63305c89 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift @@ -4,100 +4,100 @@ import Foundation // MARK: - Sync Types /// Current state of the sync coordinator -enum SyncState: Sendable, Equatable { - case idle - case syncing(progress: SyncProgress) - case synced - case failed(SyncCoordinatorError) - - /// Whether currently syncing - var isSyncing: Bool { - if case .syncing = self { return true } - return false - } - - static func == (lhs: SyncState, rhs: SyncState) -> Bool { - switch (lhs, rhs) { - case (.idle, .idle): return true - case (.synced, .synced): return true - case (.syncing(let a), .syncing(let b)): return a == b - case (.failed, .failed): return true // Simplified equality - default: return false - } +enum SyncState: Equatable { + case idle + case syncing(progress: SyncProgress) + case synced + case failed(SyncCoordinatorError) + + /// Whether currently syncing + var isSyncing: Bool { + if case .syncing = self { return true } + return false + } + + static func == (lhs: SyncState, rhs: SyncState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle): true + case (.synced, .synced): true + case let (.syncing(a), .syncing(b)): a == b + case (.failed, .failed): true // Simplified equality + default: false } + } } /// Progress information during sync -struct SyncProgress: Sendable, Equatable { - let phase: SyncPhase - let current: Int - let total: Int +struct SyncProgress: Equatable { + let phase: SyncPhase + let current: Int + let total: Int } /// Phases of the sync process public enum SyncPhase: Sendable, Equatable { - case contacts - case channels - case messages + case contacts + case channels + case messages } /// Phase-level sync outcome. -enum SyncPhaseStatus: Sendable, Equatable { - case clean - case partial - case skipped - case failed(String) - - var isClean: Bool { - self == .clean - } +enum SyncPhaseStatus: Equatable { + case clean + case partial + case skipped + case failed(String) + + var isClean: Bool { + self == .clean + } } /// Structured result for an initial or full sync. -struct FullSyncResult: Sendable, Equatable { - let contacts: SyncPhaseStatus - let channels: SyncPhaseStatus - let messages: SyncPhaseStatus - let channelRetryIndices: [UInt8] - - var isConnectionUsable: Bool { - contacts == .clean - } - - init( - contacts: SyncPhaseStatus, - channels: SyncPhaseStatus, - messages: SyncPhaseStatus, - channelRetryIndices: [UInt8] = [] - ) { - self.contacts = contacts - self.channels = channels - self.messages = messages - self.channelRetryIndices = channelRetryIndices - } - - static let skipped = FullSyncResult( - contacts: .skipped, - channels: .skipped, - messages: .skipped - ) +struct FullSyncResult: Equatable { + let contacts: SyncPhaseStatus + let channels: SyncPhaseStatus + let messages: SyncPhaseStatus + let channelRetryIndices: [UInt8] + + var isConnectionUsable: Bool { + contacts == .clean + } + + init( + contacts: SyncPhaseStatus, + channels: SyncPhaseStatus, + messages: SyncPhaseStatus, + channelRetryIndices: [UInt8] = [] + ) { + self.contacts = contacts + self.channels = channels + self.messages = messages + self.channelRetryIndices = channelRetryIndices + } + + static let skipped = FullSyncResult( + contacts: .skipped, + channels: .skipped, + messages: .skipped + ) } /// Errors from SyncCoordinator operations public enum SyncCoordinatorError: Error, Sendable { - case notConnected - case syncFailed(String) - case alreadySyncing + case notConnected + case syncFailed(String) + case alreadySyncing } extension SyncCoordinatorError: LocalizedError { - public var errorDescription: String? { - switch self { - case .notConnected: "Not connected to device." - case .syncFailed(let msg): "Sync failed: \(msg)" - case .alreadySyncing: "A sync is already in progress." - } + public var errorDescription: String? { + switch self { + case .notConnected: "Not connected to device." + case let .syncFailed(msg): "Sync failed: \(msg)" + case .alreadySyncing: "A sync is already in progress." } + } } // MARK: - SyncCoordinator Actor @@ -110,321 +110,320 @@ extension SyncCoordinatorError: LocalizedError { /// - Full sync (contacts, channels, messages) /// - UI refresh notifications public actor SyncCoordinator { + // MARK: - Logging - // MARK: - Logging + let logger = PersistentLogger(subsystem: "com.mc1", category: "SyncCoordinator") - let logger = PersistentLogger(subsystem: "com.mc1", category: "SyncCoordinator") + /// Actor-local guard against concurrent sync execution. + /// Checked and set synchronously (no `await`) to eliminate the TOCTOU window + /// that existed when guarding via the `@MainActor`-isolated `state` property. + var isSyncInProgress = false - /// Actor-local guard against concurrent sync execution. - /// Checked and set synchronously (no `await`) to eliminate the TOCTOU window - /// that existed when guarding via the `@MainActor`-isolated `state` property. - var isSyncInProgress = false + /// Cached blocked names (contacts + channel senders) for O(1) lookup in message handlers + private var blockedNames: Set = [] - /// Cached blocked names (contacts + channel senders) for O(1) lookup in message handlers - private var blockedNames: Set = [] + /// Tracks channel indices received for slots with no local channel (notifications suppressed) + /// in this connection session. + var unresolvedChannelIndices: Set = [] + var lastUnresolvedChannelSummaryAt: Date? + let unresolvedChannelSummaryIntervalSeconds: TimeInterval = 60 - /// Tracks channel indices received for slots with no local channel (notifications suppressed) - /// in this connection session. - var unresolvedChannelIndices: Set = [] - var lastUnresolvedChannelSummaryAt: Date? - let unresolvedChannelSummaryIntervalSeconds: TimeInterval = 60 + /// Timestamp window size (in seconds) for matching reactions to messages. + /// Allows for clock drift and delayed delivery within a 5-minute window. + let reactionTimestampWindowSeconds: UInt32 = 300 - /// Timestamp window size (in seconds) for matching reactions to messages. - /// Allows for clock drift and delayed delivery within a 5-minute window. - let reactionTimestampWindowSeconds: UInt32 = 300 + // MARK: - Observable State (@MainActor for SwiftUI) - // MARK: - Observable State (@MainActor for SwiftUI) + /// Current sync state + @MainActor private(set) var state: SyncState = .idle - /// Current sync state - @MainActor private(set) var state: SyncState = .idle + /// Incremented when contacts data changes + @MainActor private(set) var contactsVersion: Int = 0 - /// Incremented when contacts data changes - @MainActor private(set) var contactsVersion: Int = 0 + /// Incremented when conversations data changes + @MainActor private(set) var conversationsVersion: Int = 0 - /// Incremented when conversations data changes - @MainActor private(set) var conversationsVersion: Int = 0 + /// Last successful sync date + @MainActor private(set) var lastSyncDate: Date? - /// Last successful sync date - @MainActor private(set) var lastSyncDate: Date? + /// Called when channel sync completes with zero errors (including retries). + /// Used by ConnectionManager to track clean channel completions for smart resync. + /// Installed by `ConnectionManager.wireCleanChannelSyncCallback`. + var onCleanChannelSync: (@Sendable (_ radioID: UUID) async -> Void)? - /// Called when channel sync completes with zero errors (including retries). - /// Used by ConnectionManager to track clean channel completions for smart resync. - /// Installed by `ConnectionManager.wireCleanChannelSyncCallback`. - var onCleanChannelSync: (@Sendable (_ radioID: UUID) async -> Void)? + /// Called when a channel sync attempt starts, clean or partial. + /// Used by ConnectionManager to cool down immediate channel retry loops. + /// Installed by `ConnectionManager.wireCleanChannelSyncCallback`. + var onChannelSyncAttempted: (@Sendable (_ radioID: UUID) async -> Void)? - /// Called when a channel sync attempt starts, clean or partial. - /// Used by ConnectionManager to cool down immediate channel retry loops. - /// Installed by `ConnectionManager.wireCleanChannelSyncCallback`. - var onChannelSyncAttempted: (@Sendable (_ radioID: UUID) async -> Void)? + /// Sets the callback for clean channel sync completion. + func setCleanChannelSyncCallback(_ callback: @escaping @Sendable (_ radioID: UUID) async -> Void) { + onCleanChannelSync = callback + } - /// Sets the callback for clean channel sync completion. - func setCleanChannelSyncCallback(_ callback: @escaping @Sendable (_ radioID: UUID) async -> Void) { - onCleanChannelSync = callback - } + /// Sets the callback for any channel sync attempt. + func setChannelSyncAttemptedCallback(_ callback: @escaping @Sendable (_ radioID: UUID) async -> Void) { + onChannelSyncAttempted = callback + } - /// Sets the callback for any channel sync attempt. - func setChannelSyncAttemptedCallback(_ callback: @escaping @Sendable (_ radioID: UUID) async -> Void) { - onChannelSyncAttempted = callback - } + /// Callback when non-message sync activity starts. + /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. + var onSyncActivityStarted: (@Sendable () async -> Void)? - /// Callback when non-message sync activity starts. - /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. - var onSyncActivityStarted: (@Sendable () async -> Void)? + /// Callback when non-message sync activity ends. + /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. + var onSyncActivityEnded: (@Sendable (_ succeeded: Bool) async -> Void)? - /// Callback when non-message sync activity ends. - /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. - var onSyncActivityEnded: (@Sendable (_ succeeded: Bool) async -> Void)? + /// Tracks whether onSyncActivityEnded has been called for the current sync cycle. + /// Prevents double-callback when disconnect occurs mid-sync (both onDisconnected + /// and error path would otherwise call onSyncActivityEnded). + var hasEndedSyncActivity = true - /// Tracks whether onSyncActivityEnded has been called for the current sync cycle. - /// Prevents double-callback when disconnect occurs mid-sync (both onDisconnected - /// and error path would otherwise call onSyncActivityEnded). - var hasEndedSyncActivity = true + /// Watchdog task that force-clears notification suppression after 120s. + /// Prevents stuck suppression if sync completes abnormally without clearing it. + private var suppressionWatchdogTask: Task? - /// Watchdog task that force-clears notification suppression after 120s. - /// Prevents stuck suppression if sync completes abnormally without clearing it. - private var suppressionWatchdogTask: Task? + /// Callback when sync phase changes (for SwiftUI observation). + /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. + @MainActor private var onPhaseChanged: (@Sendable @MainActor (_ phase: SyncPhase?) -> Void)? - /// Callback when sync phase changes (for SwiftUI observation). - /// Installed by `ConnectionUIState.wireCallbacks` via `setSyncActivityCallbacks`. - @MainActor private var onPhaseChanged: (@Sendable @MainActor (_ phase: SyncPhase?) -> Void)? + /// Multicast broadcaster for data-change and incoming-message events. + /// Producers yield synchronously from any isolation; consumers subscribe + /// via `dataEvents()`. + nonisolated let dataEventBroadcaster = EventBroadcaster() - /// Multicast broadcaster for data-change and incoming-message events. - /// Producers yield synchronously from any isolation; consumers subscribe - /// via `dataEvents()`. - nonisolated let dataEventBroadcaster = EventBroadcaster() + /// Task consuming `AdvertisementService.events()` for ongoing contact + /// discovery. Started by `startDiscoveryEventMonitoring` only after the + /// initial sync so adverts arriving during sync do not spam notifications; + /// cancelled by `ServiceContainer.tearDown()`. + var discoveryEventsTask: Task? - /// Task consuming `AdvertisementService.events()` for ongoing contact - /// discovery. Started by `startDiscoveryEventMonitoring` only after the - /// initial sync so adverts arriving during sync do not spam notifications; - /// cancelled by `ServiceContainer.tearDown()`. - var discoveryEventsTask: Task? + // MARK: - Test Seams - // MARK: - Test Seams - - #if DEBUG + #if DEBUG /// Test override for `performResync`. When set, bypasses the real sync path. var performResyncOverride: ((_ radioID: UUID, _ dependencies: SyncDependencies) async -> Bool)? /// Sets the test override for `performResync`. func setPerformResyncOverride(_ override: @escaping @Sendable (_ radioID: UUID, _ dependencies: SyncDependencies) async -> Bool) { - performResyncOverride = override - } - #endif - - // MARK: - Initialization - - init() {} - - // MARK: - State Setters - - @MainActor - func setState(_ newState: SyncState) { - state = newState - if case .syncing(let progress) = newState { - onPhaseChanged?(progress.phase) - } else { - onPhaseChanged?(nil) - } - } - - @MainActor - func setLastSyncDate(_ date: Date) { - lastSyncDate = date - } - - /// Sets callbacks for sync activity tracking (used by UI to show syncing pill) - /// Only called for contacts and channels phases, NOT for messages. - public func setSyncActivityCallbacks( - onStarted: @escaping @Sendable () async -> Void, - onEnded: @escaping @Sendable (_ succeeded: Bool) async -> Void, - onPhaseChanged: @escaping @Sendable @MainActor (_ phase: SyncPhase?) -> Void - ) async { - onSyncActivityStarted = onStarted - onSyncActivityEnded = onEnded - await MainActor.run { self.onPhaseChanged = onPhaseChanged } - } - - // MARK: - Data Events - - /// Returns a fresh stream of data-change and incoming-message events. - /// Registration is synchronous, so events yielded after this call are - /// never dropped. Consumers must re-subscribe per connection because the - /// owning `ServiceContainer` is rebuilt on every connection. - public nonisolated func dataEvents() -> AsyncStream { - dataEventBroadcaster.subscribe() + performResyncOverride = override } + #endif - /// Ends every `dataEvents()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release the service - /// references they hold. - nonisolated func finishDataEvents() { - dataEventBroadcaster.finish() - } + // MARK: - Initialization - // MARK: - Sync Activity Tracking + init() {} - /// Calls onSyncActivityEnded at most once per sync cycle. - /// Guards against double-callback when disconnect occurs mid-sync. - func endSyncActivityOnce(succeeded: Bool = false) async { - guard !hasEndedSyncActivity else { return } - hasEndedSyncActivity = true - logger.info("[Sync] Calling onSyncActivityEnded (succeeded: \(succeeded))") - await onSyncActivityEnded?(succeeded) - } + // MARK: - State Setters - /// Called by ConnectionManager when a resync loop starts. - /// Increments the activity count so "Syncing" pill stays visible across retries. - func beginResyncActivity() async { - await onSyncActivityStarted?() + @MainActor + func setState(_ newState: SyncState) { + state = newState + if case let .syncing(progress) = newState { + onPhaseChanged?(progress.phase) + } else { + onPhaseChanged?(nil) } - - /// Called by ConnectionManager when a resync loop ends (success or exhausted retries). - /// Decrements the activity count. Only triggers "Ready" toast on success. - func endResyncActivity(succeeded: Bool) async { - await onSyncActivityEnded?(succeeded) + } + + @MainActor + func setLastSyncDate(_ date: Date) { + lastSyncDate = date + } + + /// Sets callbacks for sync activity tracking (used by UI to show syncing pill) + /// Only called for contacts and channels phases, NOT for messages. + public func setSyncActivityCallbacks( + onStarted: @escaping @Sendable () async -> Void, + onEnded: @escaping @Sendable (_ succeeded: Bool) async -> Void, + onPhaseChanged: @escaping @Sendable @MainActor (_ phase: SyncPhase?) -> Void + ) async { + onSyncActivityStarted = onStarted + onSyncActivityEnded = onEnded + await MainActor.run { self.onPhaseChanged = onPhaseChanged } + } + + // MARK: - Data Events + + /// Returns a fresh stream of data-change and incoming-message events. + /// Registration is synchronous, so events yielded after this call are + /// never dropped. Consumers must re-subscribe per connection because the + /// owning `ServiceContainer` is rebuilt on every connection. + public nonisolated func dataEvents() -> AsyncStream { + dataEventBroadcaster.subscribe() + } + + /// Ends every `dataEvents()` subscriber's for-await loop. Called by + /// `ServiceContainer.tearDown()` so consumer tasks release the service + /// references they hold. + nonisolated func finishDataEvents() { + dataEventBroadcaster.finish() + } + + // MARK: - Sync Activity Tracking + + /// Calls onSyncActivityEnded at most once per sync cycle. + /// Guards against double-callback when disconnect occurs mid-sync. + func endSyncActivityOnce(succeeded: Bool = false) async { + guard !hasEndedSyncActivity else { return } + hasEndedSyncActivity = true + logger.info("[Sync] Calling onSyncActivityEnded (succeeded: \(succeeded))") + await onSyncActivityEnded?(succeeded) + } + + /// Called by ConnectionManager when a resync loop starts. + /// Increments the activity count so "Syncing" pill stays visible across retries. + func beginResyncActivity() async { + await onSyncActivityStarted?() + } + + /// Called by ConnectionManager when a resync loop ends (success or exhausted retries). + /// Decrements the activity count. Only triggers "Ready" toast on success. + func endResyncActivity(succeeded: Bool) async { + await onSyncActivityEnded?(succeeded) + } + + // MARK: - Notification Suppression Watchdog + + func startSuppressionWatchdog(notificationService: NotificationService) { + suppressionWatchdogTask?.cancel() + suppressionWatchdogTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(120)) + guard !Task.isCancelled, let self else { return } + let isSuppressing = await notificationService.isSuppressingNotifications + guard isSuppressing else { return } + logger.warning("[Sync] Notification suppression watchdog fired after 120s - force clearing") + await MainActor.run { + notificationService.isSuppressingNotifications = false + } } - - // MARK: - Notification Suppression Watchdog - - func startSuppressionWatchdog(notificationService: NotificationService) { - suppressionWatchdogTask?.cancel() - suppressionWatchdogTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(120)) - guard !Task.isCancelled, let self else { return } - let isSuppressing = await notificationService.isSuppressingNotifications - guard isSuppressing else { return } - self.logger.warning("[Sync] Notification suppression watchdog fired after 120s - force clearing") - await MainActor.run { - notificationService.isSuppressingNotifications = false - } - } + } + + func cancelSuppressionWatchdog() { + suppressionWatchdogTask?.cancel() + suppressionWatchdogTask = nil + } + + // MARK: - Notifications + + /// Notify that contacts data changed (triggers UI refresh) + @MainActor + public func notifyContactsChanged() { + logger.info("notifyContactsChanged: version \(contactsVersion) → \(contactsVersion + 1)") + contactsVersion += 1 + dataEventBroadcaster.yield(.contactsChanged) + } + + /// Notify that conversations data changed (triggers UI refresh) + @MainActor + public func notifyConversationsChanged() { + conversationsVersion += 1 + dataEventBroadcaster.yield(.conversationsChanged) + } + + // MARK: - Blocked Contacts Cache + + /// Refresh the blocked names cache from the data store (contacts + channel senders) + public func refreshBlockedContactsCache(radioID: UUID, dataStore: any ContactPersisting) async { + do { + let blockedContacts = try await dataStore.fetchBlockedContacts(radioID: radioID) + let blockedSenders = try await dataStore.fetchBlockedChannelSenders(radioID: radioID) + blockedNames = Set(blockedContacts.map(\.name)) + .union(Set(blockedSenders.map(\.name))) + logger.debug("Refreshed blocked names cache: \(blockedNames.count) entries") + } catch { + logger.error("Failed to refresh blocked names cache: \(error)") + blockedNames = [] } - - func cancelSuppressionWatchdog() { - suppressionWatchdogTask?.cancel() - suppressionWatchdogTask = nil + } + + /// Check if a sender name is blocked (O(1) lookup) + func isBlockedSender(_ name: String?) -> Bool { + guard let name else { return false } + return blockedNames.contains(name) + } + + /// Returns a snapshot of blocked sender names for synchronous filtering + public func blockedSenderNames() -> Set { + blockedNames + } + + /// Delete any channel messages from blocked senders still in the DB. + /// Runs on every connection. After the first pass, delete queries match zero rows + /// and are effectively free (indexed predicate, no mutations). This handles legacy + /// data from app versions that filtered at read time instead of deleting at block time. + func deleteBlockedSenderMessages(radioID: UUID, dataStore: any PersistenceStoreProtocol) async { + let names = blockedNames + guard !names.isEmpty else { return } + + for name in names { + try? await dataStore.deleteChannelMessages(fromSender: name, radioID: radioID) } - - // MARK: - Notifications - - /// Notify that contacts data changed (triggers UI refresh) - @MainActor - public func notifyContactsChanged() { - logger.info("notifyContactsChanged: version \(self.contactsVersion) → \(self.contactsVersion + 1)") - contactsVersion += 1 - dataEventBroadcaster.yield(.contactsChanged) + } + + // MARK: - Timestamp Correction + + /// Maximum acceptable time in the future for a sender timestamp (5 minutes) + private static let timestampToleranceFuture: TimeInterval = 5 * 60 + + /// Maximum acceptable time in the past for a sender timestamp (6 months) + private static let timestampTolerancePast: TimeInterval = 6 * 30 * 24 * 60 * 60 + + /// Corrects invalid timestamps from senders with broken clocks. + /// + /// MeshCore protocol does not specify timestamp validation. This is a client-side + /// policy to prevent timeline corruption when devices have severely incorrect clocks + /// (a common issue per MeshCore FAQ 6.1, 6.2). Original timestamps are preserved + /// for ACK deduplication (per payloads.md:65). + /// + /// Returns the corrected timestamp and whether correction was applied. + /// Timestamps are considered invalid if: + /// - More than 5 minutes in the future (relative to receive time) + /// - More than 6 months in the past (relative to receive time) + /// + /// - Parameters: + /// - timestamp: The sender's claimed timestamp + /// - receiveTime: When the message was received (defaults to now) + /// - Returns: Tuple of (corrected timestamp, was corrected flag) + nonisolated static func correctTimestampIfNeeded( + _ timestamp: UInt32, + receiveTime: Date = Date() + ) -> (correctedTimestamp: UInt32, wasCorrected: Bool) { + let receiveSeconds = receiveTime.timeIntervalSince1970 + let timestampSeconds = TimeInterval(timestamp) + + let isTooFarInFuture = timestampSeconds > receiveSeconds + timestampToleranceFuture + let isTooFarInPast = timestampSeconds < receiveSeconds - timestampTolerancePast + + if isTooFarInFuture || isTooFarInPast { + return (UInt32(receiveSeconds), true) } - - /// Notify that conversations data changed (triggers UI refresh) - @MainActor - public func notifyConversationsChanged() { - conversationsVersion += 1 - dataEventBroadcaster.yield(.conversationsChanged) - } - - // MARK: - Blocked Contacts Cache - - /// Refresh the blocked names cache from the data store (contacts + channel senders) - public func refreshBlockedContactsCache(radioID: UUID, dataStore: any ContactPersisting) async { - do { - let blockedContacts = try await dataStore.fetchBlockedContacts(radioID: radioID) - let blockedSenders = try await dataStore.fetchBlockedChannelSenders(radioID: radioID) - blockedNames = Set(blockedContacts.map(\.name)) - .union(Set(blockedSenders.map(\.name))) - logger.debug("Refreshed blocked names cache: \(self.blockedNames.count) entries") - } catch { - logger.error("Failed to refresh blocked names cache: \(error)") - blockedNames = [] - } - } - - /// Check if a sender name is blocked (O(1) lookup) - func isBlockedSender(_ name: String?) -> Bool { - guard let name else { return false } - return blockedNames.contains(name) - } - - /// Returns a snapshot of blocked sender names for synchronous filtering - public func blockedSenderNames() -> Set { - blockedNames - } - - /// Delete any channel messages from blocked senders still in the DB. - /// Runs on every connection. After the first pass, delete queries match zero rows - /// and are effectively free (indexed predicate, no mutations). This handles legacy - /// data from app versions that filtered at read time instead of deleting at block time. - func deleteBlockedSenderMessages(radioID: UUID, dataStore: any PersistenceStoreProtocol) async { - let names = blockedNames - guard !names.isEmpty else { return } - - for name in names { - try? await dataStore.deleteChannelMessages(fromSender: name, radioID: radioID) - } - } - - // MARK: - Timestamp Correction - - /// Maximum acceptable time in the future for a sender timestamp (5 minutes) - private static let timestampToleranceFuture: TimeInterval = 5 * 60 - - /// Maximum acceptable time in the past for a sender timestamp (6 months) - private static let timestampTolerancePast: TimeInterval = 6 * 30 * 24 * 60 * 60 - - /// Corrects invalid timestamps from senders with broken clocks. - /// - /// MeshCore protocol does not specify timestamp validation. This is a client-side - /// policy to prevent timeline corruption when devices have severely incorrect clocks - /// (a common issue per MeshCore FAQ 6.1, 6.2). Original timestamps are preserved - /// for ACK deduplication (per payloads.md:65). - /// - /// Returns the corrected timestamp and whether correction was applied. - /// Timestamps are considered invalid if: - /// - More than 5 minutes in the future (relative to receive time) - /// - More than 6 months in the past (relative to receive time) - /// - /// - Parameters: - /// - timestamp: The sender's claimed timestamp - /// - receiveTime: When the message was received (defaults to now) - /// - Returns: Tuple of (corrected timestamp, was corrected flag) - nonisolated static func correctTimestampIfNeeded( - _ timestamp: UInt32, - receiveTime: Date = Date() - ) -> (correctedTimestamp: UInt32, wasCorrected: Bool) { - let receiveSeconds = receiveTime.timeIntervalSince1970 - let timestampSeconds = TimeInterval(timestamp) - - let isTooFarInFuture = timestampSeconds > receiveSeconds + timestampToleranceFuture - let isTooFarInPast = timestampSeconds < receiveSeconds - timestampTolerancePast - - if isTooFarInFuture || isTooFarInPast { - return (UInt32(receiveSeconds), true) - } - return (timestamp, false) - } - - /// Computes the persisted sort date for an incoming message based on its delivery context. - /// - /// Live messages sort by receive time so a just-arrived message stays at the bottom of the - /// transcript. Backlog messages drained during sync sort by the drain `anchor`, so a whole - /// batch lands as one contiguous block at delivery time — recent, never buried — ordered - /// within the block by the secondary `timestamp` fetch key. The sort key no longer reads - /// sender time, so a skewed sender clock cannot scatter backlog into deep scrollback; - /// `correctTimestampIfNeeded` still governs the persisted `Message.timestamp` used for - /// dedup, display, and within-block ordering. - /// - /// - Parameters: - /// - context: Whether the message arrived live or via a backlog drain (carrying the anchor). - /// - receiveTime: When a live message was received. Ignored for `initialSync`. - /// - Returns: The date to persist as the message's sort key. - nonisolated static func sortDate( - for context: DeliveryContext, - receiveTime: Date - ) -> Date { - switch context { - case .live: - return receiveTime - case .initialSync(let anchor): - return anchor - } + return (timestamp, false) + } + + /// Computes the persisted sort date for an incoming message based on its delivery context. + /// + /// Live messages sort by receive time so a just-arrived message stays at the bottom of the + /// transcript. Backlog messages drained during sync sort by the drain `anchor`, so a whole + /// batch lands as one contiguous block at delivery time — recent, never buried — ordered + /// within the block by the secondary `timestamp` fetch key. The sort key no longer reads + /// sender time, so a skewed sender clock cannot scatter backlog into deep scrollback; + /// `correctTimestampIfNeeded` still governs the persisted `Message.timestamp` used for + /// dedup, display, and within-block ordering. + /// + /// - Parameters: + /// - context: Whether the message arrived live or via a backlog drain (carrying the anchor). + /// - receiveTime: When a live message was received. Ignored for `initialSync`. + /// - Returns: The date to persist as the message's sort key. + nonisolated static func sortDate( + for context: DeliveryContext, + receiveTime: Date + ) -> Date { + switch context { + case .live: + receiveTime + case let .initialSync(anchor): + anchor } + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncDataEvent.swift b/MC1Services/Sources/MC1Services/Sync/SyncDataEvent.swift index a33b9349..b06850db 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncDataEvent.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncDataEvent.swift @@ -7,16 +7,16 @@ import Foundation /// version bumps, `MessageEventDispatcher` for chat forwarding) never steal /// each other's events. public enum SyncDataEvent: Sendable { - /// Contacts data changed; observers should reload contact lists. - case contactsChanged - /// Conversations data changed; observers should reload chat lists. - case conversationsChanged - /// An incoming direct message was persisted for a known contact. - case directMessageReceived(message: MessageDTO, contact: ContactDTO) - /// An incoming channel message was persisted. - case channelMessageReceived(message: MessageDTO, channelIndex: UInt8) - /// An incoming signed room message was persisted. - case roomMessageReceived(RoomMessageDTO) - /// A reaction was persisted and its target message's summary updated. - case reactionReceived(messageID: UUID, summary: String) + /// Contacts data changed; observers should reload contact lists. + case contactsChanged + /// Conversations data changed; observers should reload chat lists. + case conversationsChanged + /// An incoming direct message was persisted for a known contact. + case directMessageReceived(message: MessageDTO, contact: ContactDTO) + /// An incoming channel message was persisted. + case channelMessageReceived(message: MessageDTO, channelIndex: UInt8) + /// An incoming signed room message was persisted. + case roomMessageReceived(RoomMessageDTO) + /// A reaction was persisted and its target message's summary updated. + case reactionReceived(messageID: UUID, summary: String) } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncDependencies.swift b/MC1Services/Sources/MC1Services/Sync/SyncDependencies.swift index 43f7fef4..e046743d 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncDependencies.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncDependencies.swift @@ -7,112 +7,110 @@ import Foundation /// but constructible directly in tests so sync paths can run without the full /// container. Members are protocol-typed where a seam protocol exists and /// concrete elsewhere. -struct SyncDependencies: Sendable { - - /// Persistence store for device, contact, channel, message, and RX log operations. - let dataStore: any PersistenceStoreProtocol - - /// Service performing the contact sync phase. - let contactService: any ContactServiceProtocol - - /// Service performing the channel sync phase. - let channelService: any ChannelServiceProtocol - - /// Service for message polling, auto-fetch, and ingestion handler wiring. - let messagePollingService: any MessagePollingServiceProtocol - - /// Service for posting notifications and gating suppression during sync. - let notificationService: NotificationService - - /// Service handling emoji reactions on direct and channel messages. - let reactionService: ReactionService - - /// Service for advertisement events and contact discovery. - let advertisementService: AdvertisementService - - /// Service maintaining the RX log decryption caches (private key, contact - /// public keys, channel secrets). - let rxLogService: RxLogService - - /// Service persisting signed room messages. - let roomServerService: RoomServerService - - /// Service routing CLI responses from room contacts. - let roomAdminService: RoomAdminService - - /// Service routing CLI responses from repeater contacts. - let repeaterAdminService: RepeaterAdminService - - /// Optional provider for foreground/background state. When nil, sync - /// defaults to foreground behavior (channels sync). - let appStateProvider: AppStateProvider? - - /// Starts service event monitoring for the connected radio. - let startEventMonitoring: @Sendable (_ radioID: UUID, _ enableAutoFetch: Bool) async -> Void - - /// Exports the device private key for direct message decryption. - let exportPrivateKey: @Sendable () async throws -> Data - - init( - dataStore: any PersistenceStoreProtocol, - contactService: any ContactServiceProtocol, - channelService: any ChannelServiceProtocol, - messagePollingService: any MessagePollingServiceProtocol, - notificationService: NotificationService, - reactionService: ReactionService, - advertisementService: AdvertisementService, - rxLogService: RxLogService, - roomServerService: RoomServerService, - roomAdminService: RoomAdminService, - repeaterAdminService: RepeaterAdminService, - appStateProvider: AppStateProvider? = nil, - startEventMonitoring: @escaping @Sendable (_ radioID: UUID, _ enableAutoFetch: Bool) async -> Void, - exportPrivateKey: @escaping @Sendable () async throws -> Data - ) { - self.dataStore = dataStore - self.contactService = contactService - self.channelService = channelService - self.messagePollingService = messagePollingService - self.notificationService = notificationService - self.reactionService = reactionService - self.advertisementService = advertisementService - self.rxLogService = rxLogService - self.roomServerService = roomServerService - self.roomAdminService = roomAdminService - self.repeaterAdminService = repeaterAdminService - self.appStateProvider = appStateProvider - self.startEventMonitoring = startEventMonitoring - self.exportPrivateKey = exportPrivateKey - } +struct SyncDependencies { + /// Persistence store for device, contact, channel, message, and RX log operations. + let dataStore: any PersistenceStoreProtocol + + /// Service performing the contact sync phase. + let contactService: any ContactServiceProtocol + + /// Service performing the channel sync phase. + let channelService: any ChannelServiceProtocol + + /// Service for message polling, auto-fetch, and ingestion handler wiring. + let messagePollingService: any MessagePollingServiceProtocol + + /// Service for posting notifications and gating suppression during sync. + let notificationService: NotificationService + + /// Service handling emoji reactions on direct and channel messages. + let reactionService: ReactionService + + /// Service for advertisement events and contact discovery. + let advertisementService: AdvertisementService + + /// Service maintaining the RX log decryption caches (private key, contact + /// public keys, channel secrets). + let rxLogService: RxLogService + + /// Service persisting signed room messages. + let roomServerService: RoomServerService + + /// Service routing CLI responses from room contacts. + let roomAdminService: RoomAdminService + + /// Service routing CLI responses from repeater contacts. + let repeaterAdminService: RepeaterAdminService + + /// Optional provider for foreground/background state. When nil, sync + /// defaults to foreground behavior (channels sync). + let appStateProvider: AppStateProvider? + + /// Starts service event monitoring for the connected radio. + let startEventMonitoring: @Sendable (_ radioID: UUID, _ enableAutoFetch: Bool) async -> Void + + /// Exports the device private key for direct message decryption. + let exportPrivateKey: @Sendable () async throws -> Data + + init( + dataStore: any PersistenceStoreProtocol, + contactService: any ContactServiceProtocol, + channelService: any ChannelServiceProtocol, + messagePollingService: any MessagePollingServiceProtocol, + notificationService: NotificationService, + reactionService: ReactionService, + advertisementService: AdvertisementService, + rxLogService: RxLogService, + roomServerService: RoomServerService, + roomAdminService: RoomAdminService, + repeaterAdminService: RepeaterAdminService, + appStateProvider: AppStateProvider? = nil, + startEventMonitoring: @escaping @Sendable (_ radioID: UUID, _ enableAutoFetch: Bool) async -> Void, + exportPrivateKey: @escaping @Sendable () async throws -> Data + ) { + self.dataStore = dataStore + self.contactService = contactService + self.channelService = channelService + self.messagePollingService = messagePollingService + self.notificationService = notificationService + self.reactionService = reactionService + self.advertisementService = advertisementService + self.rxLogService = rxLogService + self.roomServerService = roomServerService + self.roomAdminService = roomAdminService + self.repeaterAdminService = repeaterAdminService + self.appStateProvider = appStateProvider + self.startEventMonitoring = startEventMonitoring + self.exportPrivateKey = exportPrivateKey + } } extension ServiceContainer { - - /// Builds the sync dependency surface from this container's services. - /// - /// `startEventMonitoring` captures the container weakly: the wired sync - /// closures hold a `SyncDependencies` copy, and a strong container capture - /// here would keep a torn-down service graph alive across reconnects. - var syncDependencies: SyncDependencies { - SyncDependencies( - dataStore: dataStore, - contactService: contactService, - channelService: channelService, - messagePollingService: messagePollingService, - notificationService: notificationService, - reactionService: reactionService, - advertisementService: advertisementService, - rxLogService: rxLogService, - roomServerService: roomServerService, - roomAdminService: roomAdminService, - repeaterAdminService: repeaterAdminService, - appStateProvider: appStateProvider, - startEventMonitoring: { [weak self] radioID, enableAutoFetch in - await self?.startEventMonitoring(radioID: radioID, enableAutoFetch: enableAutoFetch) - }, - exportPrivateKey: { [session] in - try await session.exportPrivateKey() - } - ) - } + /// Builds the sync dependency surface from this container's services. + /// + /// `startEventMonitoring` captures the container weakly: the wired sync + /// closures hold a `SyncDependencies` copy, and a strong container capture + /// here would keep a torn-down service graph alive across reconnects. + var syncDependencies: SyncDependencies { + SyncDependencies( + dataStore: dataStore, + contactService: contactService, + channelService: channelService, + messagePollingService: messagePollingService, + notificationService: notificationService, + reactionService: reactionService, + advertisementService: advertisementService, + rxLogService: rxLogService, + roomServerService: roomServerService, + roomAdminService: roomAdminService, + repeaterAdminService: repeaterAdminService, + appStateProvider: appStateProvider, + startEventMonitoring: { [weak self] radioID, enableAutoFetch in + await self?.startEventMonitoring(radioID: radioID, enableAutoFetch: enableAutoFetch) + }, + exportPrivateKey: { [session] in + try await session.exportPrivateKey() + } + ) + } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncThrottlingConfig.swift b/MC1Services/Sources/MC1Services/Sync/SyncThrottlingConfig.swift index de979521..64138f76 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncThrottlingConfig.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncThrottlingConfig.swift @@ -2,32 +2,32 @@ import Foundation /// Controls whether channel re-sync is skipped on resync. /// Computed from `DevicePlatform` and `lastCleanChannelSync` state at sync start. -struct ChannelSyncConfig: Sendable { - /// If channels were synced more recently than this window, skip channel re-sync. - let channelSyncSkipWindow: Duration +struct ChannelSyncConfig { + /// If channels were synced more recently than this window, skip channel re-sync. + let channelSyncSkipWindow: Duration - /// Timestamp of the last fully-clean channel sync for the current device. - let lastCleanChannelSync: Date? + /// Timestamp of the last fully-clean channel sync for the current device. + let lastCleanChannelSync: Date? - /// Timestamp of the last attempted channel sync, even if it was partial. - let lastAttemptedChannelSync: Date? + /// Timestamp of the last attempted channel sync, even if it was partial. + let lastAttemptedChannelSync: Date? - /// Whether channel reads should use the windowed read pipeline — nRF52 over BLE (Write - /// Commands) or ESP32 over WiFi (TCP back-to-back sends). `false` for ESP32 over BLE. - let usePipelinedChannelRead: Bool + /// Whether channel reads should use the windowed read pipeline — nRF52 over BLE (Write + /// Commands) or ESP32 over WiFi (TCP back-to-back sends). `false` for ESP32 over BLE. + let usePipelinedChannelRead: Bool - init( - channelSyncSkipWindow: Duration = .zero, - lastCleanChannelSync: Date? = nil, - lastAttemptedChannelSync: Date? = nil, - usePipelinedChannelRead: Bool = false - ) { - self.channelSyncSkipWindow = channelSyncSkipWindow - self.lastCleanChannelSync = lastCleanChannelSync - self.lastAttemptedChannelSync = lastAttemptedChannelSync - self.usePipelinedChannelRead = usePipelinedChannelRead - } + init( + channelSyncSkipWindow: Duration = .zero, + lastCleanChannelSync: Date? = nil, + lastAttemptedChannelSync: Date? = nil, + usePipelinedChannelRead: Bool = false + ) { + self.channelSyncSkipWindow = channelSyncSkipWindow + self.lastCleanChannelSync = lastCleanChannelSync + self.lastAttemptedChannelSync = lastAttemptedChannelSync + self.usePipelinedChannelRead = usePipelinedChannelRead + } - /// No skip — used for WiFi connections. - static let none = ChannelSyncConfig() + /// No skip — used for WiFi connections. + static let none = ChannelSyncConfig() } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEPhase.swift b/MC1Services/Sources/MC1Services/Transport/BLEPhase.swift index 50d326ba..06c9d196 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEPhase.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEPhase.swift @@ -9,137 +9,135 @@ import Foundation /// CoreBluetooth types (CBPeripheral, CBService, CBCharacteristic). The /// BLEStateMachine actor ensures these are only accessed from appropriate contexts. enum BLEPhase: @unchecked Sendable { - - /// Initial state, no operations in progress - case idle - - /// Waiting for CBCentralManager to reach .poweredOn - case waitingForBluetooth( - continuation: CheckedContinuation - ) - - /// Actively connecting to a peripheral - case connecting( - peripheral: CBPeripheral, - continuation: CheckedContinuation, - timeoutTask: Task - ) - - /// Connected, discovering services - case discoveringServices( - peripheral: CBPeripheral, - continuation: CheckedContinuation - ) - - /// Services found, discovering characteristics - case discoveringCharacteristics( - peripheral: CBPeripheral, - service: CBService, - continuation: CheckedContinuation - ) - - /// Characteristics found, subscribing to notifications - case subscribingToNotifications( - peripheral: CBPeripheral, - tx: CBCharacteristic, - rx: CBCharacteristic, - continuation: CheckedContinuation - ) - - /// Discovery chain complete, continuation consumed. - /// Transitional phase between notification subscription success and - /// `connect()` creating the data stream. Holds characteristics without - /// a continuation, preventing double-resume if `cancelCurrentOperation` - /// runs before `connect()` transitions to `.connected`. - case discoveryComplete( - peripheral: CBPeripheral, - tx: CBCharacteristic, - rx: CBCharacteristic - ) - - /// Fully connected and ready for communication - case connected( - peripheral: CBPeripheral, - tx: CBCharacteristic, - rx: CBCharacteristic, - dataContinuation: AsyncStream.Continuation - ) - - /// iOS auto-reconnect in progress. - /// Progressively populated as discovery completes. - case autoReconnecting( - peripheral: CBPeripheral, - tx: CBCharacteristic?, - rx: CBCharacteristic? - ) - - /// iOS state restoration received, waiting for Bluetooth power state. - /// Transitions to .autoReconnecting when Bluetooth powers on. - case restoringState(peripheral: CBPeripheral) - - /// Intentionally disconnecting - case disconnecting( - peripheral: CBPeripheral - ) - - // MARK: - Computed Properties - - /// Human-readable name for logging - var name: String { - switch self { - case .idle: "idle" - case .waitingForBluetooth: "waitingForBluetooth" - case .connecting: "connecting" - case .discoveringServices: "discoveringServices" - case .discoveringCharacteristics: "discoveringCharacteristics" - case .subscribingToNotifications: "subscribingToNotifications" - case .discoveryComplete: "discoveryComplete" - case .connected: "connected" - case .autoReconnecting: "autoReconnecting" - case .restoringState: "restoringState" - case .disconnecting: "disconnecting" - } - } - - /// Whether this phase is part of the service/characteristic discovery chain. - /// Used by `cleanupPhaseResources` to preserve the discovery timeout when - /// transitioning within the chain. - var isDiscoveryChain: Bool { - switch self { - case .discoveringServices, .discoveringCharacteristics, .subscribingToNotifications: - return true - default: - return false - } + /// Initial state, no operations in progress + case idle + + /// Waiting for CBCentralManager to reach .poweredOn + case waitingForBluetooth( + continuation: CheckedContinuation + ) + + /// Actively connecting to a peripheral + case connecting( + peripheral: CBPeripheral, + continuation: CheckedContinuation, + timeoutTask: Task + ) + + /// Connected, discovering services + case discoveringServices( + peripheral: CBPeripheral, + continuation: CheckedContinuation + ) + + /// Services found, discovering characteristics + case discoveringCharacteristics( + peripheral: CBPeripheral, + service: CBService, + continuation: CheckedContinuation + ) + + /// Characteristics found, subscribing to notifications + case subscribingToNotifications( + peripheral: CBPeripheral, + tx: CBCharacteristic, + rx: CBCharacteristic, + continuation: CheckedContinuation + ) + + /// Discovery chain complete, continuation consumed. + /// Transitional phase between notification subscription success and + /// `connect()` creating the data stream. Holds characteristics without + /// a continuation, preventing double-resume if `cancelCurrentOperation` + /// runs before `connect()` transitions to `.connected`. + case discoveryComplete( + peripheral: CBPeripheral, + tx: CBCharacteristic, + rx: CBCharacteristic + ) + + /// Fully connected and ready for communication + case connected( + peripheral: CBPeripheral, + tx: CBCharacteristic, + rx: CBCharacteristic, + dataContinuation: AsyncStream.Continuation + ) + + /// iOS auto-reconnect in progress. + /// Progressively populated as discovery completes. + case autoReconnecting( + peripheral: CBPeripheral, + tx: CBCharacteristic?, + rx: CBCharacteristic? + ) + + /// iOS state restoration received, waiting for Bluetooth power state. + /// Transitions to .autoReconnecting when Bluetooth powers on. + case restoringState(peripheral: CBPeripheral) + + /// Intentionally disconnecting + case disconnecting( + peripheral: CBPeripheral + ) + + // MARK: - Computed Properties + + /// Human-readable name for logging + var name: String { + switch self { + case .idle: "idle" + case .waitingForBluetooth: "waitingForBluetooth" + case .connecting: "connecting" + case .discoveringServices: "discoveringServices" + case .discoveringCharacteristics: "discoveringCharacteristics" + case .subscribingToNotifications: "subscribingToNotifications" + case .discoveryComplete: "discoveryComplete" + case .connected: "connected" + case .autoReconnecting: "autoReconnecting" + case .restoringState: "restoringState" + case .disconnecting: "disconnecting" } - - /// Whether this phase represents an active operation (not idle) - var isActive: Bool { - if case .idle = self { return false } - return true + } + + /// Whether this phase is part of the service/characteristic discovery chain. + /// Used by `cleanupPhaseResources` to preserve the discovery timeout when + /// transitioning within the chain. + var isDiscoveryChain: Bool { + switch self { + case .discoveringServices, .discoveringCharacteristics, .subscribingToNotifications: + true + default: + false } - - /// The peripheral associated with this phase, if any - var peripheral: CBPeripheral? { - switch self { - case .connecting(let p, _, _), - .discoveringServices(let p, _), - .discoveringCharacteristics(let p, _, _), - .subscribingToNotifications(let p, _, _, _), - .discoveryComplete(let p, _, _), - .connected(let p, _, _, _), - .autoReconnecting(let p, _, _), - .restoringState(let p), - .disconnecting(let p): - return p - case .idle, .waitingForBluetooth: - return nil - } - } - - /// The device ID associated with this phase, if any - var deviceID: UUID? { - peripheral?.identifier + } + + /// Whether this phase represents an active operation (not idle) + var isActive: Bool { + if case .idle = self { return false } + return true + } + + /// The peripheral associated with this phase, if any + var peripheral: CBPeripheral? { + switch self { + case let .connecting(p, _, _), + let .discoveringServices(p, _), + let .discoveringCharacteristics(p, _, _), + let .subscribingToNotifications(p, _, _, _), + let .discoveryComplete(p, _, _), + let .connected(p, _, _, _), + let .autoReconnecting(p, _, _), + let .restoringState(p), + let .disconnecting(p): + p + case .idle, .waitingForBluetooth: + nil } + } + /// The device ID associated with this phase, if any + var deviceID: UUID? { + peripheral?.identifier + } } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEServiceUUID.swift b/MC1Services/Sources/MC1Services/Transport/BLEServiceUUID.swift index 439936f3..d01e389f 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEServiceUUID.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEServiceUUID.swift @@ -12,10 +12,10 @@ import Foundation /// This is inverted from the Nordic UART Service standard naming, which uses /// the peripheral's perspective. The actual UUIDs are correct. enum BLEServiceUUID { - /// Nordic UART Service UUID - static let nordicUART = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" - /// TX Characteristic - central writes to this (Nordic: RX Characteristic) - static let txCharacteristic = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" - /// RX Characteristic - central receives notifications (Nordic: TX Characteristic) - static let rxCharacteristic = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" + /// Nordic UART Service UUID + static let nordicUART = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" + /// TX Characteristic - central writes to this (Nordic: RX Characteristic) + static let txCharacteristic = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" + /// RX Characteristic - central receives notifications (Nordic: TX Characteristic) + static let rxCharacteristic = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CBDelegate.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CBDelegate.swift index dabf2ae9..89ac809a 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CBDelegate.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CBDelegate.swift @@ -23,144 +23,143 @@ import os /// the serial CBCentralManager queue, avoiding the race conditions that occur when /// multiple unstructured Tasks compete for actor access with priority-based scheduling. final class BLEDelegateHandler: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, @unchecked Sendable { - - weak var stateMachine: BLEStateMachine? - - private let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEDelegateHandler") - - /// Lock-protected continuation for yielding received data directly. - /// Using OSAllocatedUnfairLock ensures thread-safe access from the CBCentralManager queue. - private let dataContinuationLock = OSAllocatedUnfairLock.Continuation?>(initialState: nil) - - /// FIFO of sequence numbers for `.withResponse` writes issued but not yet - /// acknowledged. CoreBluetooth delivers exactly one `didWriteValueFor` per - /// write request, in issue order, on the serial queue, so popping the head - /// tags each callback with the sequence of the write that produced it. - /// Tagging at write time (not delivery time) lets a callback that arrives - /// after its write already timed out be recognized as the old write instead - /// of being mistaken for the current one. - private let issuedWriteSequencesLock = OSAllocatedUnfairLock<[UInt64]>(initialState: []) - - /// Records a write's sequence as issued. The actor calls this immediately - /// before `writeValue` so the callback can never outrun the record. - func recordIssuedWriteSequence(_ sequence: UInt64) { - issuedWriteSequencesLock.withLock { $0.append(sequence) } - } - - /// Drops all recorded write sequences. Called when pending writes are - /// cancelled (disconnect, auto-reconnect teardown), where the outstanding - /// callbacks either never arrive or no longer have a continuation; a stale - /// entry left behind would mis-tag the next connection's first callback. - func clearIssuedWriteSequences() { - issuedWriteSequencesLock.withLock { $0.removeAll() } - } - - /// Sets the data continuation for direct yielding from delegate callbacks. - /// Call this when transitioning to connected state. - func setDataContinuation(_ continuation: AsyncStream.Continuation?) { - dataContinuationLock.withLock { $0 = continuation } - } - - // MARK: - CBCentralManagerDelegate - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - guard let sm = stateMachine else { return } - Task { await sm.handleCentralManagerDidUpdateState(central.state) } - } - - func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { - guard let sm = stateMachine else { return } - // Extract peripheral synchronously before crossing actor boundary - guard let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], - let peripheral = peripherals.first else { - return - } - Task { await sm.handleWillRestoreState(peripheral) } - } - - func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], rssi RSSI: NSNumber) { - guard let sm = stateMachine else { return } - let peripheralID = peripheral.identifier - let rssiValue = RSSI.intValue - // Prefer the advertised local name; it is present before a connection is established - // and fresher than the cached `peripheral.name`. - let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name - Task { await sm.handleDidDiscoverPeripheral(peripheralID: peripheralID, name: name, rssi: rssiValue) } - } - - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidConnect(peripheral) } - } - - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidFailToConnect(peripheral, error: error) } - } - - func centralManager( - _ central: CBCentralManager, - didDisconnectPeripheral peripheral: CBPeripheral, - timestamp: CFAbsoluteTime, - isReconnecting: Bool, - error: Error? - ) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidDisconnect(peripheral, timestamp: timestamp, isReconnecting: isReconnecting, error: error) } - } - - // MARK: - CBPeripheralDelegate - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidDiscoverServices(peripheral, error: error) } - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidDiscoverCharacteristics(peripheral, service: service, error: error) } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidUpdateNotificationState(peripheral, characteristic: characteristic, error: error) } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - // Yield data directly to preserve ordering from the serial CBCentralManager queue. - // Do not spawn a Task here - that breaks ordering guarantees. - if let error { - logger.warning("[BLE] didUpdateValueFor error: \(peripheral.identifier.uuidString.prefix(8)), char: \(characteristic.uuid.uuidString.prefix(8)), error: \(error.localizedDescription)") - return - } - guard let data = characteristic.value, !data.isEmpty else { - logger.debug("[BLE] didUpdateValueFor: empty data from \(peripheral.identifier.uuidString.prefix(8)), char: \(characteristic.uuid.uuidString.prefix(8))") - return - } - _ = dataContinuationLock.withLock { $0?.yield(data) } - } - - func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { - guard let sm = stateMachine else { return } - Task { await sm.handleDidReadRSSI(RSSI: RSSI, error: error) } - } - - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - guard let sm = stateMachine else { return } - // Pop the oldest unacknowledged write's sequence (callbacks arrive in - // issue order on this serial queue) so the callback is tagged with the - // write that produced it, not whichever write is current by delivery time. - let seq = issuedWriteSequencesLock.withLock { $0.isEmpty ? nil : $0.removeFirst() } - guard let seq else { - logger.debug("[BLE] didWriteValueFor with no recorded write, ignoring") - return - } - Task { await sm.handleDidWriteValue(peripheral, characteristic: characteristic, error: error, writeSequence: seq) } - } - - func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { - guard let sm = stateMachine else { return } - Task { await sm.handlePeripheralReadyForWriteWithoutResponse() } - } + weak var stateMachine: BLEStateMachine? + + private let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEDelegateHandler") + + /// Lock-protected continuation for yielding received data directly. + /// Using OSAllocatedUnfairLock ensures thread-safe access from the CBCentralManager queue. + private let dataContinuationLock = OSAllocatedUnfairLock.Continuation?>(initialState: nil) + + /// FIFO of sequence numbers for `.withResponse` writes issued but not yet + /// acknowledged. CoreBluetooth delivers exactly one `didWriteValueFor` per + /// write request, in issue order, on the serial queue, so popping the head + /// tags each callback with the sequence of the write that produced it. + /// Tagging at write time (not delivery time) lets a callback that arrives + /// after its write already timed out be recognized as the old write instead + /// of being mistaken for the current one. + private let issuedWriteSequencesLock = OSAllocatedUnfairLock<[UInt64]>(initialState: []) + + /// Records a write's sequence as issued. The actor calls this immediately + /// before `writeValue` so the callback can never outrun the record. + func recordIssuedWriteSequence(_ sequence: UInt64) { + issuedWriteSequencesLock.withLock { $0.append(sequence) } + } + + /// Drops all recorded write sequences. Called when pending writes are + /// cancelled (disconnect, auto-reconnect teardown), where the outstanding + /// callbacks either never arrive or no longer have a continuation; a stale + /// entry left behind would mis-tag the next connection's first callback. + func clearIssuedWriteSequences() { + issuedWriteSequencesLock.withLock { $0.removeAll() } + } + + /// Sets the data continuation for direct yielding from delegate callbacks. + /// Call this when transitioning to connected state. + func setDataContinuation(_ continuation: AsyncStream.Continuation?) { + dataContinuationLock.withLock { $0 = continuation } + } + + // MARK: - CBCentralManagerDelegate + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + guard let sm = stateMachine else { return } + Task { await sm.handleCentralManagerDidUpdateState(central.state) } + } + + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + guard let sm = stateMachine else { return } + // Extract peripheral synchronously before crossing actor boundary + guard let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], + let peripheral = peripherals.first else { + return + } + Task { await sm.handleWillRestoreState(peripheral) } + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber) { + guard let sm = stateMachine else { return } + let peripheralID = peripheral.identifier + let rssiValue = RSSI.intValue + // Prefer the advertised local name; it is present before a connection is established + // and fresher than the cached `peripheral.name`. + let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name + Task { await sm.handleDidDiscoverPeripheral(peripheralID: peripheralID, name: name, rssi: rssiValue) } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidConnect(peripheral) } + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidFailToConnect(peripheral, error: error) } + } + + func centralManager( + _ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + timestamp: CFAbsoluteTime, + isReconnecting: Bool, + error: Error? + ) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidDisconnect(peripheral, timestamp: timestamp, isReconnecting: isReconnecting, error: error) } + } + + // MARK: - CBPeripheralDelegate + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidDiscoverServices(peripheral, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidDiscoverCharacteristics(peripheral, service: service, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidUpdateNotificationState(peripheral, characteristic: characteristic, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + // Yield data directly to preserve ordering from the serial CBCentralManager queue. + // Do not spawn a Task here - that breaks ordering guarantees. + if let error { + logger.warning("[BLE] didUpdateValueFor error: \(peripheral.identifier.uuidString.prefix(8)), char: \(characteristic.uuid.uuidString.prefix(8)), error: \(error.localizedDescription)") + return + } + guard let data = characteristic.value, !data.isEmpty else { + logger.debug("[BLE] didUpdateValueFor: empty data from \(peripheral.identifier.uuidString.prefix(8)), char: \(characteristic.uuid.uuidString.prefix(8))") + return + } + _ = dataContinuationLock.withLock { $0?.yield(data) } + } + + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + guard let sm = stateMachine else { return } + Task { await sm.handleDidReadRSSI(RSSI: RSSI, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + guard let sm = stateMachine else { return } + // Pop the oldest unacknowledged write's sequence (callbacks arrive in + // issue order on this serial queue) so the callback is tagged with the + // write that produced it, not whichever write is current by delivery time. + let seq = issuedWriteSequencesLock.withLock { $0.isEmpty ? nil : $0.removeFirst() } + guard let seq else { + logger.debug("[BLE] didWriteValueFor with no recorded write, ignoring") + return + } + Task { await sm.handleDidWriteValue(peripheral, characteristic: characteristic, error: error, writeSequence: seq) } + } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + guard let sm = stateMachine else { return } + Task { await sm.handlePeripheralReadyForWriteWithoutResponse() } + } } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift index 9136c057..6536721c 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift @@ -4,661 +4,659 @@ import Foundation // MARK: - Internal Callback Handlers extension BLEStateMachine { - - func handleCentralManagerDidUpdateState(_ state: CBManagerState) { - let stateString: String - switch state { - case .unknown: stateString = "unknown" - case .resetting: stateString = "resetting" - case .unsupported: stateString = "unsupported" - case .unauthorized: stateString = "unauthorized" - case .poweredOff: stateString = "poweredOff" - case .poweredOn: stateString = "poweredOn" - @unknown default: stateString = "unknown(\(state.rawValue))" - } - if lastCentralState != state { - lastCentralState = state - logger.info( - "[BLE] Central manager state changed: \(stateString), currentPhase: \(self.phase.name), instance: \(instanceID), \(processContext)" - ) - } - onBluetoothStateChange?(state) - - switch state { - case .poweredOn: - // Cancel any poweredOff grace period — Bluetooth is now available - bluetoothPowerOffGraceTask?.cancel() - bluetoothPowerOffGraceTask = nil - - // Resume waiting continuation if any - if case .waitingForBluetooth(let continuation) = phase { - transition(to: .idle) - continuation.resume() - } - - // Handle state restoration from phase - if case .restoringState(let peripheral) = phase { - handleRestoredPeripheral(peripheral) - } - - // Fulfill pending scan request - if pendingScanRequest { - startScanning() - } - - // Notify handler for power-on events - onBluetoothPoweredOn?() - - case .poweredOff: - let wasScanning = isCurrentlyScanning - isCurrentlyScanning = false - if wasScanning { - pendingScanRequest = true - } - - if case .waitingForBluetooth = phase { - // A freshly created CBCentralManager may briefly report poweredOff - // before settling on poweredOn. Start a grace period instead of - // failing immediately, so the initialization can complete. - if bluetoothPowerOffGraceTask == nil { - logger.info("[BLE] poweredOff during waitingForBluetooth — starting grace period") - bluetoothPowerOffGraceTask = Task { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { return } - self.handleBluetoothPowerOffGraceExpired() - } - } - } else { - // Not waiting — cancel any active operation immediately - let deviceID = phase.deviceID - cancelCurrentOperation(with: BLEError.bluetoothPoweredOff) - if let deviceID { - onDisconnection?(deviceID, nil) - } - } - - case .unauthorized: - handleBluetoothBecomingUnavailable(error: .bluetoothUnauthorized) - - case .unsupported: - handleBluetoothBecomingUnavailable(error: .bluetoothUnavailable) - - default: - break - } + func handleCentralManagerDidUpdateState(_ state: CBManagerState) { + let stateString = switch state { + case .unknown: "unknown" + case .resetting: "resetting" + case .unsupported: "unsupported" + case .unauthorized: "unauthorized" + case .poweredOff: "poweredOff" + case .poweredOn: "poweredOn" + @unknown default: "unknown(\(state.rawValue))" + } + if lastCentralState != state { + lastCentralState = state + logger.info( + "[BLE] Central manager state changed: \(stateString), currentPhase: \(phase.name), instance: \(instanceID), \(processContext)" + ) } + onBluetoothStateChange?(state) - /// Called when the poweredOff grace period expires without poweredOn arriving. - private func handleBluetoothPowerOffGraceExpired() { - bluetoothPowerOffGraceTask = nil - guard case .waitingForBluetooth = phase else { return } - logger.info("[BLE] poweredOff grace period expired — Bluetooth is off") + switch state { + case .poweredOn: + // Cancel any poweredOff grace period — Bluetooth is now available + bluetoothPowerOffGraceTask?.cancel() + bluetoothPowerOffGraceTask = nil + + // Resume waiting continuation if any + if case let .waitingForBluetooth(continuation) = phase { + transition(to: .idle) + continuation.resume() + } + + // Handle state restoration from phase + if case let .restoringState(peripheral) = phase { + handleRestoredPeripheral(peripheral) + } + + // Fulfill pending scan request + if pendingScanRequest { + startScanning() + } + + // Notify handler for power-on events + onBluetoothPoweredOn?() + + case .poweredOff: + let wasScanning = isCurrentlyScanning + isCurrentlyScanning = false + if wasScanning { + pendingScanRequest = true + } + + if case .waitingForBluetooth = phase { + // A freshly created CBCentralManager may briefly report poweredOff + // before settling on poweredOn. Start a grace period instead of + // failing immediately, so the initialization can complete. + if bluetoothPowerOffGraceTask == nil { + logger.info("[BLE] poweredOff during waitingForBluetooth — starting grace period") + bluetoothPowerOffGraceTask = Task { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + self.handleBluetoothPowerOffGraceExpired() + } + } + } else { + // Not waiting — cancel any active operation immediately let deviceID = phase.deviceID cancelCurrentOperation(with: BLEError.bluetoothPoweredOff) if let deviceID { - onDisconnection?(deviceID, nil) - } - } - - /// Handles Bluetooth becoming permanently unavailable (unauthorized or unsupported). - private func handleBluetoothBecomingUnavailable(error: BLEError) { - isCurrentlyScanning = false - pendingScanRequest = false - if case .waitingForBluetooth(let continuation) = phase { - transition(to: .idle) - continuation.resume(throwing: error) - } - if case .restoringState(let peripheral) = phase { - transition(to: .idle) - onDisconnection?(peripheral.identifier, nil) + onDisconnection?(deviceID, nil) } - } - - func handleRestoredPeripheral(_ peripheral: CBPeripheral, source: RestoredPeripheralSource = .stateRestoration) { - let pState = peripheralStateString(peripheral.state) - logger.info("[BLE] Processing restored peripheral: \(peripheral.identifier.uuidString.prefix(8)), state: \(pState), source: \(source)") - - peripheral.delegate = delegateHandler - - // Advance connection generation for restoration-driven reconnect - advanceConnectionGeneration() + } - // Start timeout for auto-reconnect discovery - armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) + case .unauthorized: + handleBluetoothBecomingUnavailable(error: .bluetoothUnauthorized) - transition(to: .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil)) + case .unsupported: + handleBluetoothBecomingUnavailable(error: .bluetoothUnavailable) - // State-restoration paths claim the reconnect cycle here so the coordinator's - // strict completion guard accepts the eventual onReconnection. The adoption - // path already claimed via startAdoptingLastSystemConnectedPeripheralIfAvailable - // and passes .adoption to skip and avoid double-claiming. - if source == .stateRestoration { - onAutoReconnecting?(peripheral.identifier, "state-restoration") - } - - if peripheral.state == .connected { - // Already connected, just need to rediscover services - peripheral.discoverServices([nordicUARTServiceUUID]) - } else if peripheral.state == .connecting { - // Connection in progress, wait for didConnect - } else { - // Not connected, try to reconnect - let options: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionEnableAutoReconnect: true - ] - centralManager.connect(peripheral, options: options) - } + default: + break } - - func handleWillRestoreState(_ peripheral: CBPeripheral) { - let pState = peripheralStateString(peripheral.state) - logger.info("[BLE] State restoration callback: \(peripheral.identifier.uuidString.prefix(8)), state: \(pState)") - - // If Bluetooth is already powered on, proceed directly to restoration. - // This handles the edge case where .poweredOn Task runs before this Task. - if centralManager.state == .poweredOn { - handleRestoredPeripheral(peripheral) - } else { - transition(to: .restoringState(peripheral: peripheral)) - } + } + + /// Called when the poweredOff grace period expires without poweredOn arriving. + private func handleBluetoothPowerOffGraceExpired() { + bluetoothPowerOffGraceTask = nil + guard case .waitingForBluetooth = phase else { return } + logger.info("[BLE] poweredOff grace period expired — Bluetooth is off") + let deviceID = phase.deviceID + cancelCurrentOperation(with: BLEError.bluetoothPoweredOff) + if let deviceID { + onDisconnection?(deviceID, nil) + } + } + + /// Handles Bluetooth becoming permanently unavailable (unauthorized or unsupported). + private func handleBluetoothBecomingUnavailable(error: BLEError) { + isCurrentlyScanning = false + pendingScanRequest = false + if case let .waitingForBluetooth(continuation) = phase { + transition(to: .idle) + continuation.resume(throwing: error) } + if case let .restoringState(peripheral) = phase { + transition(to: .idle) + onDisconnection?(peripheral.identifier, nil) + } + } - func handleDidConnect(_ peripheral: CBPeripheral) { - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - logger.info("[BLE] Did connect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), phase: \(self.phase.name), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + func handleRestoredPeripheral(_ peripheral: CBPeripheral, source: RestoredPeripheralSource = .stateRestoration) { + let pState = peripheralStateString(peripheral.state) + logger.info("[BLE] Processing restored peripheral: \(peripheral.identifier.uuidString.prefix(8)), state: \(pState), source: \(source)") - // Handle auto-reconnect - if case .autoReconnecting(let expected, _, _) = phase, - peripheral.identifier == expected.identifier { - logger.info("[BLE] Auto-reconnect: peripheral connected, discovering services") - peripheral.delegate = delegateHandler - peripheral.discoverServices([nordicUARTServiceUUID]) + peripheral.delegate = delegateHandler - // Cancel any existing timeout (e.g., from handleRestoredPeripheral) and restart - armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) - return - } + // Advance connection generation for restoration-driven reconnect + advanceConnectionGeneration() - // Normal connection flow - guard case .connecting(let expected, let continuation, let timeoutTask) = phase, - expected.identifier == peripheral.identifier else { - logger.warning("Unexpected didConnect for \(peripheral.identifier)") - cancelUnexpectedPeripheral(peripheral) - return - } + // Start timeout for auto-reconnect discovery + armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) - timeoutTask.cancel() + transition(to: .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil)) - // Arm discovery timeout before starting discovery so the callback - // window between discoverServices() and timeout creation is closed. - armServiceDiscoveryTimeout(for: peripheral) + // State-restoration paths claim the reconnect cycle here so the coordinator's + // strict completion guard accepts the eventual onReconnection. The adoption + // path already claimed via startAdoptingLastSystemConnectedPeripheralIfAvailable + // and passes .adoption to skip and avoid double-claiming. + if source == .stateRestoration { + onAutoReconnecting?(peripheral.identifier, "state-restoration") + } - transition(to: .discoveringServices( - peripheral: peripheral, - continuation: continuation - )) + if peripheral.state == .connected { + // Already connected, just need to rediscover services + peripheral.discoverServices([nordicUARTServiceUUID]) + } else if peripheral.state == .connecting { + // Connection in progress, wait for didConnect + } else { + // Not connected, try to reconnect + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionEnableAutoReconnect: true + ] + centralManager.connect(peripheral, options: options) + } + } + + func handleWillRestoreState(_ peripheral: CBPeripheral) { + let pState = peripheralStateString(peripheral.state) + logger.info("[BLE] State restoration callback: \(peripheral.identifier.uuidString.prefix(8)), state: \(pState)") + + // If Bluetooth is already powered on, proceed directly to restoration. + // This handles the edge case where .poweredOn Task runs before this Task. + if centralManager.state == .poweredOn { + handleRestoredPeripheral(peripheral) + } else { + transition(to: .restoringState(peripheral: peripheral)) + } + } + + func handleDidConnect(_ peripheral: CBPeripheral) { + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + logger.info("[BLE] Did connect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), phase: \(phase.name), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + + // Handle auto-reconnect + if case let .autoReconnecting(expected, _, _) = phase, + peripheral.identifier == expected.identifier { + logger.info("[BLE] Auto-reconnect: peripheral connected, discovering services") + peripheral.delegate = delegateHandler + peripheral.discoverServices([nordicUARTServiceUUID]) + + // Cancel any existing timeout (e.g., from handleRestoredPeripheral) and restart + armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) + return + } - peripheral.delegate = delegateHandler - peripheral.discoverServices([nordicUARTServiceUUID]) + // Normal connection flow + guard case let .connecting(expected, continuation, timeoutTask) = phase, + expected.identifier == peripheral.identifier else { + logger.warning("Unexpected didConnect for \(peripheral.identifier)") + cancelUnexpectedPeripheral(peripheral) + return } - func handleDidFailToConnect(_ peripheral: CBPeripheral, error: Error?) { - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - var errorInfo = "none" - if let error = error as NSError? { - errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" - } - logger.warning( - "[BLE] Did fail to connect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), phase: \(self.phase.name), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s, error: \(errorInfo)" - ) + timeoutTask.cancel() - // Handle failure during auto-reconnect (iOS auto-reconnect gave up) - if case .autoReconnecting(let expected, _, _) = phase, - expected.identifier == peripheral.identifier { - logger.warning("Auto-reconnect failed for \(peripheral.identifier) - transitioning to idle") - transition(to: .idle) - onDisconnection?(peripheral.identifier, error) - return - } + // Arm discovery timeout before starting discovery so the callback + // window between discoverServices() and timeout creation is closed. + armServiceDiscoveryTimeout(for: peripheral) - guard case .connecting(let expected, let continuation, let timeoutTask) = phase, - expected.identifier == peripheral.identifier else { - logger.info("Ignoring didFailToConnect - not our peripheral or unexpected phase") - return - } + transition(to: .discoveringServices( + peripheral: peripheral, + continuation: continuation + )) - timeoutTask.cancel() - transition(to: .idle) - continuation.resume(throwing: Self.makeConnectionError(error)) - } - - /// Maps a CoreBluetooth error to a typed BLEError. Auth/encryption codes - /// from CBATTError or CBError get the typed `.authenticationFailed` case so - /// detection survives iOS localizing the error description in any locale. - static func makeConnectionError(_ error: Error?, fallback: String = "Unknown error") -> BLEError { - if let nsError = error as NSError? { - if nsError.domain == CBATTErrorDomain { - switch nsError.code { - case CBATTError.insufficientAuthentication.rawValue, - CBATTError.insufficientAuthorization.rawValue, - CBATTError.insufficientEncryption.rawValue, - CBATTError.insufficientEncryptionKeySize.rawValue: - return .authenticationFailed - default: - break - } - } - if nsError.domain == CBErrorDomain, - nsError.code == CBError.encryptionTimedOut.rawValue { - return .authenticationFailed - } - } - return .connectionFailed(error?.localizedDescription ?? fallback) + peripheral.delegate = delegateHandler + peripheral.discoverServices([nordicUARTServiceUUID]) + } + + func handleDidFailToConnect(_ peripheral: CBPeripheral, error: Error?) { + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + var errorInfo = "none" + if let error = error as NSError? { + errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" + } + logger.warning( + "[BLE] Did fail to connect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), phase: \(phase.name), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s, error: \(errorInfo)" + ) + + // Handle failure during auto-reconnect (iOS auto-reconnect gave up) + if case let .autoReconnecting(expected, _, _) = phase, + expected.identifier == peripheral.identifier { + logger.warning("Auto-reconnect failed for \(peripheral.identifier) - transitioning to idle") + transition(to: .idle) + onDisconnection?(peripheral.identifier, error) + return } - func handleDidDisconnect(_ peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: Error?) { - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - var errorInfo = "none" - if let error = error as NSError? { - errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" - } - let elapsedStr = elapsed.formatted(.number.precision(.fractionLength(2))) - logger.info( - "[BLE] Did disconnect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), isReconnecting: \(isReconnecting), phase: \(self.phase.name), elapsed: \(elapsedStr)s, error: \(errorInfo)" - ) + guard case let .connecting(expected, continuation, timeoutTask) = phase, + expected.identifier == peripheral.identifier else { + logger.info("Ignoring didFailToConnect - not our peripheral or unexpected phase") + return + } - // C7: Ignore stale disconnects for peripherals that don't match the active session. - // A delayed callback from an old peripheral must not cancel the current session. - if let activePeripheral = phase.peripheral, - activePeripheral.identifier != peripheral.identifier { - logger.warning("[BLE] Ignoring stale didDisconnect for \(peripheral.identifier.uuidString.prefix(8)), active: \(activePeripheral.identifier.uuidString.prefix(8))") - return + timeoutTask.cancel() + transition(to: .idle) + continuation.resume(throwing: Self.makeConnectionError(error)) + } + + /// Maps a CoreBluetooth error to a typed BLEError. Auth/encryption codes + /// from CBATTError or CBError get the typed `.authenticationFailed` case so + /// detection survives iOS localizing the error description in any locale. + static func makeConnectionError(_ error: Error?, fallback: String = "Unknown error") -> BLEError { + if let nsError = error as NSError? { + if nsError.domain == CBATTErrorDomain { + switch nsError.code { + case CBATTError.insufficientAuthentication.rawValue, + CBATTError.insufficientAuthorization.rawValue, + CBATTError.insufficientEncryption.rawValue, + CBATTError.insufficientEncryptionKeySize.rawValue: + return .authenticationFailed + default: + break } + } + if nsError.domain == CBErrorDomain, + nsError.code == CBError.encryptionTimedOut.rawValue { + return .authenticationFailed + } + } + return .connectionFailed(error?.localizedDescription ?? fallback) + } + + func handleDidDisconnect(_ peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: Error?) { + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + var errorInfo = "none" + if let error = error as NSError? { + errorInfo = "domain=\(error.domain), code=\(error.code), desc=\(error.localizedDescription)" + } + let elapsedStr = elapsed.formatted(.number.precision(.fractionLength(2))) + logger.info( + "[BLE] Did disconnect: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), isReconnecting: \(isReconnecting), phase: \(phase.name), elapsed: \(elapsedStr)s, error: \(errorInfo)" + ) + + // C7: Ignore stale disconnects for peripherals that don't match the active session. + // A delayed callback from an old peripheral must not cancel the current session. + if let activePeripheral = phase.peripheral, + activePeripheral.identifier != peripheral.identifier { + logger.warning("[BLE] Ignoring stale didDisconnect for \(peripheral.identifier.uuidString.prefix(8)), active: \(activePeripheral.identifier.uuidString.prefix(8))") + return + } - // Primary stale-callback fence: reject disconnect callbacks from a previous generation. - // After app resume, iOS may deliver queued disconnects from before the suspend. - // We use CFAbsoluteTime (not a generation counter captured at callback delivery time) - // because CoreBluetooth's didDisconnectPeripheral timestamp reflects the disconnect - // event time per Apple's header ("now or a few seconds ago"), not delivery time. - // A generation captured at delivery time would be unsafe if advanceConnectionGeneration() - // runs between the event and callback delivery. CFAbsoluteTimeGetCurrent() is not - // guaranteed monotonic (NTP adjustments can cause backward jumps), so the 1.0s - // tolerance accommodates typical clock corrections. The peripheral identity check - // above provides the primary defense; this timestamp fence is a secondary guard for - // same-peripheral stale callbacks across generation boundaries. - let generationStart = connectionGenerationStartTime - if Self.isDisconnectCallbackFromPreviousGeneration( - timestamp: timestamp, - generationStart: generationStart - ) { - let callbackAge = CFAbsoluteTimeGetCurrent() - timestamp - logger.warning( - "[BLE] Ignoring stale disconnect callback: " + - "age=\(callbackAge.formatted(.number.precision(.fractionLength(1))))s, " + - "generation=\(connectionGeneration), phase=\(phase.name)" - ) - return - } + // Primary stale-callback fence: reject disconnect callbacks from a previous generation. + // After app resume, iOS may deliver queued disconnects from before the suspend. + // We use CFAbsoluteTime (not a generation counter captured at callback delivery time) + // because CoreBluetooth's didDisconnectPeripheral timestamp reflects the disconnect + // event time per Apple's header ("now or a few seconds ago"), not delivery time. + // A generation captured at delivery time would be unsafe if advanceConnectionGeneration() + // runs between the event and callback delivery. CFAbsoluteTimeGetCurrent() is not + // guaranteed monotonic (NTP adjustments can cause backward jumps), so the 1.0s + // tolerance accommodates typical clock corrections. The peripheral identity check + // above provides the primary defense; this timestamp fence is a secondary guard for + // same-peripheral stale callbacks across generation boundaries. + let generationStart = connectionGenerationStartTime + if Self.isDisconnectCallbackFromPreviousGeneration( + timestamp: timestamp, + generationStart: generationStart + ) { + let callbackAge = CFAbsoluteTimeGetCurrent() - timestamp + logger.warning( + "[BLE] Ignoring stale disconnect callback: " + + "age=\(callbackAge.formatted(.number.precision(.fractionLength(1))))s, " + + "generation=\(connectionGeneration), phase=\(phase.name)" + ) + return + } - // Secondary diagnostic: flag very old callbacks, but do not drop callbacks - // that belong to the current connection generation. - let callbackAge = CFAbsoluteTimeGetCurrent() - timestamp - if callbackAge > 120 { - logger.warning( - "[BLE] Processing aged disconnect callback: " + - "age=\(callbackAge.formatted(.number.precision(.fractionLength(1))))s, " + - "generation=\(connectionGeneration), phase=\(phase.name)" - ) - } + // Secondary diagnostic: flag very old callbacks, but do not drop callbacks + // that belong to the current connection generation. + let callbackAge = CFAbsoluteTimeGetCurrent() - timestamp + if callbackAge > 120 { + logger.warning( + "[BLE] Processing aged disconnect callback: " + + "age=\(callbackAge.formatted(.number.precision(.fractionLength(1))))s, " + + "generation=\(connectionGeneration), phase=\(phase.name)" + ) + } - let deviceID = peripheral.identifier + let deviceID = peripheral.identifier - if isReconnecting { - handleAutoReconnectDisconnect(peripheral: peripheral, error: error) - } else { - handleFullDisconnect(deviceID: deviceID, error: error) - } + if isReconnecting { + handleAutoReconnectDisconnect(peripheral: peripheral, error: error) + } else { + handleFullDisconnect(deviceID: deviceID, error: error) + } + } + + /// Handles a disconnect where iOS is auto-reconnecting the peripheral. + /// Setup-phase continuations route through makeConnectionError so a CBATT + /// auth/encryption code arriving before the bond is established still maps + /// to the typed BLEError.authenticationFailed. + private func handleAutoReconnectDisconnect(peripheral: CBPeripheral, error: Error?) { + let deviceID = peripheral.identifier + var errorInfo = "none" + if let nsError = error as NSError? { + errorInfo = "domain=\(nsError.domain), code=\(nsError.code), desc=\(nsError.localizedDescription)" + } + logger.info("[BLE] iOS auto-reconnect started: \(deviceID.uuidString.prefix(8)), will attempt automatic reconnection") + + // Clean up pending operations before transitioning. + // This ensures any pending setup continuations and write waiters are properly + // resumed/failed, preventing orphaned continuations and waiter starvation. + cancelPendingWriteOperations() + + // Clean up current state but preserve peripheral for reconnection. + // transition() handles dataContinuation cleanup when leaving .connected. + // Note: We handle phase continuations manually below since cancelCurrentOperation + // would transition to .idle, but we need to go to .autoReconnecting. + let setupError = Self.makeConnectionError(error, fallback: "Disconnected during setup") + switch phase { + case let .connecting(_, continuation, timeoutTask): + timeoutTask.cancel() + continuation.resume(throwing: setupError) + case let .discoveringServices(_, continuation): + continuation.resume(throwing: setupError) + case let .discoveringCharacteristics(_, _, continuation): + continuation.resume(throwing: setupError) + case let .subscribingToNotifications(_, _, _, continuation): + continuation.resume(throwing: setupError) + default: + break } - /// Handles a disconnect where iOS is auto-reconnecting the peripheral. - /// Setup-phase continuations route through makeConnectionError so a CBATT - /// auth/encryption code arriving before the bond is established still maps - /// to the typed BLEError.authenticationFailed. - private func handleAutoReconnectDisconnect(peripheral: CBPeripheral, error: Error?) { - let deviceID = peripheral.identifier - var errorInfo = "none" - if let nsError = error as NSError? { - errorInfo = "domain=\(nsError.domain), code=\(nsError.code), desc=\(nsError.localizedDescription)" - } - logger.info("[BLE] iOS auto-reconnect started: \(deviceID.uuidString.prefix(8)), will attempt automatic reconnection") - - // Clean up pending operations before transitioning. - // This ensures any pending setup continuations and write waiters are properly - // resumed/failed, preventing orphaned continuations and waiter starvation. - cancelPendingWriteOperations() - - // Clean up current state but preserve peripheral for reconnection. - // transition() handles dataContinuation cleanup when leaving .connected. - // Note: We handle phase continuations manually below since cancelCurrentOperation - // would transition to .idle, but we need to go to .autoReconnecting. - let setupError = Self.makeConnectionError(error, fallback: "Disconnected during setup") - switch phase { - case .connecting(_, let continuation, let timeoutTask): - timeoutTask.cancel() - continuation.resume(throwing: setupError) - case .discoveringServices(_, let continuation): - continuation.resume(throwing: setupError) - case .discoveringCharacteristics(_, _, let continuation): - continuation.resume(throwing: setupError) - case .subscribingToNotifications(_, _, _, let continuation): - continuation.resume(throwing: setupError) - default: - break - } - - // Advance generation for the auto-reconnect cycle - advanceConnectionGeneration() + // Advance generation for the auto-reconnect cycle + advanceConnectionGeneration() - transition(to: .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil)) + transition(to: .autoReconnecting(peripheral: peripheral, tx: nil, rx: nil)) - // C5: Arm the auto-reconnect discovery timeout (same as restoration path) - armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) + // C5: Arm the auto-reconnect discovery timeout (same as restoration path) + armAutoReconnectDiscoveryTimeout(for: peripheral, generation: connectionGeneration) - // Notify handler so UI can show "connecting" state - onAutoReconnecting?(deviceID, errorInfo) - } + // Notify handler so UI can show "connecting" state + onAutoReconnecting?(deviceID, errorInfo) + } - /// Handles a full (non-reconnecting) disconnection. - private func handleFullDisconnect(deviceID: UUID, error: Error?) { - switch phase { - case .disconnecting: - // Expected disconnection, transition handled by disconnect() - break + /// Handles a full (non-reconnecting) disconnection. + private func handleFullDisconnect(deviceID: UUID, error: Error?) { + switch phase { + case .disconnecting: + // Expected disconnection, transition handled by disconnect() + break - case .connected, .autoReconnecting: - // Unexpected disconnection - cancelCurrentOperation(with: BLEError.notConnected) - onDisconnection?(deviceID, error) + case .connected, .autoReconnecting: + // Unexpected disconnection + cancelCurrentOperation(with: BLEError.notConnected) + onDisconnection?(deviceID, error) - default: - // Disconnection during connection attempt - cancelCurrentOperation(with: Self.makeConnectionError(error, fallback: "Disconnected during setup")) - } + default: + // Disconnection during connection attempt + cancelCurrentOperation(with: Self.makeConnectionError(error, fallback: "Disconnected during setup")) } + } + + func handleDidDiscoverServices(_ peripheral: CBPeripheral, error: Error?) { + let serviceCount = peripheral.services?.count ?? 0 + let hasNordicUART = peripheral.services?.contains { $0.uuid == nordicUARTServiceUUID } ?? false + logger.info("[BLE] Did discover services: \(peripheral.identifier.uuidString.prefix(8)), count: \(serviceCount), hasNordicUART: \(hasNordicUART), error: \(error?.localizedDescription ?? "none")") + + // Handle auto-reconnect + if case let .autoReconnecting(expected, _, _) = phase, + peripheral.identifier == expected.identifier { + if let error { + logger.warning("Auto-reconnect service discovery failed: \(error.localizedDescription)") + transition(to: .idle) + onDisconnection?(expected.identifier, error) + return + } - func handleDidDiscoverServices(_ peripheral: CBPeripheral, error: Error?) { - let serviceCount = peripheral.services?.count ?? 0 - let hasNordicUART = peripheral.services?.contains { $0.uuid == nordicUARTServiceUUID } ?? false - logger.info("[BLE] Did discover services: \(peripheral.identifier.uuidString.prefix(8)), count: \(serviceCount), hasNordicUART: \(hasNordicUART), error: \(error?.localizedDescription ?? "none")") - - // Handle auto-reconnect - if case .autoReconnecting(let expected, _, _) = phase, - peripheral.identifier == expected.identifier { - if let error { - logger.warning("Auto-reconnect service discovery failed: \(error.localizedDescription)") - transition(to: .idle) - onDisconnection?(expected.identifier, error) - return - } - - guard let service = peripheral.services?.first(where: { $0.uuid == nordicUARTServiceUUID }) else { - logger.warning("Auto-reconnect: service not found") - transition(to: .idle) - onDisconnection?(expected.identifier, nil) - return - } - - peripheral.discoverCharacteristics([txCharacteristicUUID, rxCharacteristicUUID], for: service) - return - } + guard let service = peripheral.services?.first(where: { $0.uuid == nordicUARTServiceUUID }) else { + logger.warning("Auto-reconnect: service not found") + transition(to: .idle) + onDisconnection?(expected.identifier, nil) + return + } - // Normal flow - guard case .discoveringServices(let expected, let continuation) = phase, - expected.identifier == peripheral.identifier else { - logger.warning("Unexpected didDiscoverServices") - return - } + peripheral.discoverCharacteristics([txCharacteristicUUID, rxCharacteristicUUID], for: service) + return + } - if let error { - transition(to: .idle) - continuation.resume(throwing: Self.makeConnectionError(error)) - return - } + // Normal flow + guard case let .discoveringServices(expected, continuation) = phase, + expected.identifier == peripheral.identifier else { + logger.warning("Unexpected didDiscoverServices") + return + } - guard let service = peripheral.services?.first(where: { $0.uuid == nordicUARTServiceUUID }) else { - transition(to: .idle) - continuation.resume(throwing: BLEError.characteristicNotFound) - return - } + if let error { + transition(to: .idle) + continuation.resume(throwing: Self.makeConnectionError(error)) + return + } - transition(to: .discoveringCharacteristics( - peripheral: peripheral, - service: service, - continuation: continuation - )) - - peripheral.discoverCharacteristics([txCharacteristicUUID, rxCharacteristicUUID], for: service) - } - - func handleDidDiscoverCharacteristics(_ peripheral: CBPeripheral, service: CBService, error: Error?) { - let characteristics = service.characteristics ?? [] - let hasTX = characteristics.contains { $0.uuid == txCharacteristicUUID } - let hasRX = characteristics.contains { $0.uuid == rxCharacteristicUUID } - logger.info("[BLE] Did discover characteristics: \(peripheral.identifier.uuidString.prefix(8)), count: \(characteristics.count), hasTX: \(hasTX), hasRX: \(hasRX), error: \(error?.localizedDescription ?? "none")") - - // Handle auto-reconnect - if case .autoReconnecting(let expected, _, _) = phase, - peripheral.identifier == expected.identifier { - if let error { - logger.warning("Auto-reconnect characteristic discovery failed: \(error.localizedDescription)") - transition(to: .idle) - onDisconnection?(expected.identifier, error) - return - } - - guard let characteristics = service.characteristics, - let tx = characteristics.first(where: { $0.uuid == txCharacteristicUUID }), - let rx = characteristics.first(where: { $0.uuid == rxCharacteristicUUID }) else { - logger.warning("Auto-reconnect: characteristics not found") - transition(to: .idle) - onDisconnection?(expected.identifier, nil) - return - } - - captureWriteWithoutResponseCapability(from: tx) - - // Store tx/rx in phase for use when notification subscription completes - transition(to: .autoReconnecting(peripheral: peripheral, tx: tx, rx: rx)) - - // Subscribe to notifications to complete reconnection - peripheral.setNotifyValue(true, for: rx) - return - } + guard let service = peripheral.services?.first(where: { $0.uuid == nordicUARTServiceUUID }) else { + transition(to: .idle) + continuation.resume(throwing: BLEError.characteristicNotFound) + return + } - // Normal flow - guard case .discoveringCharacteristics(let expected, let expectedService, let continuation) = phase, - expected.identifier == peripheral.identifier, - expectedService.uuid == service.uuid else { - logger.warning("Unexpected didDiscoverCharacteristics") - return - } + transition(to: .discoveringCharacteristics( + peripheral: peripheral, + service: service, + continuation: continuation + )) + + peripheral.discoverCharacteristics([txCharacteristicUUID, rxCharacteristicUUID], for: service) + } + + func handleDidDiscoverCharacteristics(_ peripheral: CBPeripheral, service: CBService, error: Error?) { + let characteristics = service.characteristics ?? [] + let hasTX = characteristics.contains { $0.uuid == txCharacteristicUUID } + let hasRX = characteristics.contains { $0.uuid == rxCharacteristicUUID } + logger.info("[BLE] Did discover characteristics: \(peripheral.identifier.uuidString.prefix(8)), count: \(characteristics.count), hasTX: \(hasTX), hasRX: \(hasRX), error: \(error?.localizedDescription ?? "none")") + + // Handle auto-reconnect + if case let .autoReconnecting(expected, _, _) = phase, + peripheral.identifier == expected.identifier { + if let error { + logger.warning("Auto-reconnect characteristic discovery failed: \(error.localizedDescription)") + transition(to: .idle) + onDisconnection?(expected.identifier, error) + return + } + + guard let characteristics = service.characteristics, + let tx = characteristics.first(where: { $0.uuid == txCharacteristicUUID }), + let rx = characteristics.first(where: { $0.uuid == rxCharacteristicUUID }) else { + logger.warning("Auto-reconnect: characteristics not found") + transition(to: .idle) + onDisconnection?(expected.identifier, nil) + return + } - if let error { - transition(to: .idle) - continuation.resume(throwing: Self.makeConnectionError(error)) - return - } + captureWriteWithoutResponseCapability(from: tx) - guard let characteristics = service.characteristics, - let tx = characteristics.first(where: { $0.uuid == txCharacteristicUUID }), - let rx = characteristics.first(where: { $0.uuid == rxCharacteristicUUID }) else { - transition(to: .idle) - continuation.resume(throwing: BLEError.characteristicNotFound) - return - } + // Store tx/rx in phase for use when notification subscription completes + transition(to: .autoReconnecting(peripheral: peripheral, tx: tx, rx: rx)) - captureWriteWithoutResponseCapability(from: tx) + // Subscribe to notifications to complete reconnection + peripheral.setNotifyValue(true, for: rx) + return + } - transition(to: .subscribingToNotifications( - peripheral: peripheral, - tx: tx, - rx: rx, - continuation: continuation - )) + // Normal flow + guard case let .discoveringCharacteristics(expected, expectedService, continuation) = phase, + expected.identifier == peripheral.identifier, + expectedService.uuid == service.uuid else { + logger.warning("Unexpected didDiscoverCharacteristics") + return + } - peripheral.setNotifyValue(true, for: rx) + if let error { + transition(to: .idle) + continuation.resume(throwing: Self.makeConnectionError(error)) + return } - func handleDidUpdateNotificationState(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?) { - // swiftlint:disable:next line_length - logger.info("[BLE] Did update notification state: \(peripheral.identifier.uuidString.prefix(8)), isNotifying: \(characteristic.isNotifying), charUUID: \(characteristic.uuid.uuidString.prefix(8)), error: \(error?.localizedDescription ?? "none")") + guard let characteristics = service.characteristics, + let tx = characteristics.first(where: { $0.uuid == txCharacteristicUUID }), + let rx = characteristics.first(where: { $0.uuid == rxCharacteristicUUID }) else { + transition(to: .idle) + continuation.resume(throwing: BLEError.characteristicNotFound) + return + } - guard case .subscribingToNotifications(let expected, let tx, let rx, let continuation) = phase, - expected.identifier == peripheral.identifier, - characteristic.uuid == rxCharacteristicUUID else { - // Could be auto-reconnect scenario - handle separately - handleReconnectionNotificationState(peripheral, characteristic: characteristic, error: error) - return - } + captureWriteWithoutResponseCapability(from: tx) - if let error { - centralManager.cancelPeripheralConnection(expected) - transition(to: .idle) - continuation.resume(throwing: Self.makeConnectionError(error)) - return - } + transition(to: .subscribingToNotifications( + peripheral: peripheral, + tx: tx, + rx: rx, + continuation: continuation + )) - // C9: Verify notification subscription actually succeeded - guard characteristic.isNotifying else { - logger.warning("[BLE] Notification subscription completed without isNotifying=true") - transition(to: .idle) - continuation.resume(throwing: BLEError.connectionFailed("Notification subscription failed")) - return - } + peripheral.setNotifyValue(true, for: rx) + } - // Cancel the service discovery timeout since we completed successfully - serviceDiscoveryTimeoutTask?.cancel() - serviceDiscoveryTimeoutTask = nil + func handleDidUpdateNotificationState(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?) { + // swiftlint:disable:next line_length + logger.info("[BLE] Did update notification state: \(peripheral.identifier.uuidString.prefix(8)), isNotifying: \(characteristic.isNotifying), charUUID: \(characteristic.uuid.uuidString.prefix(8)), error: \(error?.localizedDescription ?? "none")") - // Transition to discoveryComplete before resuming the continuation. - // This prevents double-resume if cancelCurrentOperation, disconnect(), - // or a timeout handler runs before connect() transitions to .connected. - transition(to: .discoveryComplete(peripheral: expected, tx: tx, rx: rx)) - continuation.resume() + guard case let .subscribingToNotifications(expected, tx, rx, continuation) = phase, + expected.identifier == peripheral.identifier, + characteristic.uuid == rxCharacteristicUUID else { + // Could be auto-reconnect scenario - handle separately + handleReconnectionNotificationState(peripheral, characteristic: characteristic, error: error) + return } - private func handleReconnectionNotificationState(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?) { - // Handle auto-reconnect notification subscription completion - guard case .autoReconnecting(let expected, let tx, let rx) = phase, - peripheral.identifier == expected.identifier else { - return - } - - // C9: Verify characteristic UUID matches RX and notification is active - guard characteristic.uuid == rxCharacteristicUUID else { - logger.debug("[BLE] Auto-reconnect: ignoring notification state for non-RX characteristic \(characteristic.uuid.uuidString.prefix(8))") - return - } - - if let error { - logger.warning("Auto-reconnect notification subscription failed: \(error.localizedDescription)") - transition(to: .idle) - onDisconnection?(peripheral.identifier, error) - return - } - - guard characteristic.isNotifying else { - logger.warning("[BLE] Auto-reconnect: notification subscription completed without isNotifying=true") - transition(to: .idle) - onDisconnection?(peripheral.identifier, nil) - return - } + if let error { + centralManager.cancelPeripheralConnection(expected) + transition(to: .idle) + continuation.resume(throwing: Self.makeConnectionError(error)) + return + } - guard let tx, let rx else { - logger.error("Auto-reconnect: tx/rx characteristics missing from phase") - transition(to: .idle) - onDisconnection?(peripheral.identifier, nil) - return - } + // C9: Verify notification subscription actually succeeded + guard characteristic.isNotifying else { + logger.warning("[BLE] Notification subscription completed without isNotifying=true") + transition(to: .idle) + continuation.resume(throwing: BLEError.connectionFailed("Notification subscription failed")) + return + } - // Cancel the auto-reconnect discovery timeout since we completed successfully - autoReconnectDiscoveryTimeoutTask?.cancel() - autoReconnectDiscoveryTimeoutTask = nil + // Cancel the service discovery timeout since we completed successfully + serviceDiscoveryTimeoutTask?.cancel() + serviceDiscoveryTimeoutTask = nil + + // Transition to discoveryComplete before resuming the continuation. + // This prevents double-resume if cancelCurrentOperation, disconnect(), + // or a timeout handler runs before connect() transitions to .connected. + transition(to: .discoveryComplete(peripheral: expected, tx: tx, rx: rx)) + continuation.resume() + } + + private func handleReconnectionNotificationState(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?) { + // Handle auto-reconnect notification subscription completion + guard case let .autoReconnecting(expected, tx, rx) = phase, + peripheral.identifier == expected.identifier else { + return + } - let elapsed = Date().timeIntervalSince(phaseStartTime) - logger.info("[BLE] Auto-reconnect notification subscription complete, elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + // C9: Verify characteristic UUID matches RX and notification is active + guard characteristic.uuid == rxCharacteristicUUID else { + logger.debug("[BLE] Auto-reconnect: ignoring notification state for non-RX characteristic \(characteristic.uuid.uuidString.prefix(8))") + return + } - // Create data stream and transition to connected - let (stream, continuation) = AsyncStream.makeStream( - of: Data.self, - bufferingPolicy: .bufferingOldest(512) - ) + if let error { + logger.warning("Auto-reconnect notification subscription failed: \(error.localizedDescription)") + transition(to: .idle) + onDisconnection?(peripheral.identifier, error) + return + } - // Pass continuation to delegate handler for direct yielding (preserves ordering) - delegateHandler.setDataContinuation(continuation) + guard characteristic.isNotifying else { + logger.warning("[BLE] Auto-reconnect: notification subscription completed without isNotifying=true") + transition(to: .idle) + onDisconnection?(peripheral.identifier, nil) + return + } - transition(to: .connected( - peripheral: peripheral, - tx: tx, - rx: rx, - dataContinuation: continuation - )) - startRSSIKeepalive(for: peripheral) + guard let tx, let rx else { + logger.error("Auto-reconnect: tx/rx characteristics missing from phase") + transition(to: .idle) + onDisconnection?(peripheral.identifier, nil) + return + } - logger.info("[BLE] iOS auto-reconnect complete: \(peripheral.identifier.uuidString.prefix(8))") - onReconnection?(peripheral.identifier, stream) + // Cancel the auto-reconnect discovery timeout since we completed successfully + autoReconnectDiscoveryTimeoutTask?.cancel() + autoReconnectDiscoveryTimeoutTask = nil + + let elapsed = Date().timeIntervalSince(phaseStartTime) + logger.info("[BLE] Auto-reconnect notification subscription complete, elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + + // Create data stream and transition to connected + let (stream, continuation) = AsyncStream.makeStream( + of: Data.self, + bufferingPolicy: .bufferingOldest(512) + ) + + // Pass continuation to delegate handler for direct yielding (preserves ordering) + delegateHandler.setDataContinuation(continuation) + + transition(to: .connected( + peripheral: peripheral, + tx: tx, + rx: rx, + dataContinuation: continuation + )) + startRSSIKeepalive(for: peripheral) + + logger.info("[BLE] iOS auto-reconnect complete: \(peripheral.identifier.uuidString.prefix(8))") + onReconnection?(peripheral.identifier, stream) + } + + func handleDidWriteValue(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?, writeSequence: UInt64) { + guard let continuation = pendingWriteContinuation else { + logger.debug("[BLE] didWriteValue with no pending continuation, ignoring") + return } - func handleDidWriteValue(_ peripheral: CBPeripheral, characteristic: CBCharacteristic, error: Error?, writeSequence: UInt64) { - guard let continuation = pendingWriteContinuation else { - logger.debug("[BLE] didWriteValue with no pending continuation, ignoring") - return - } + // C8: Reject stale write callbacks from a previous (timed-out) write + if writeSequence != pendingWriteSequence { + logger.warning("[BLE] Stale didWriteValue: seq=\(writeSequence), expected=\(pendingWriteSequence), ignoring") + return + } - // C8: Reject stale write callbacks from a previous (timed-out) write - if writeSequence != pendingWriteSequence { - logger.warning("[BLE] Stale didWriteValue: seq=\(writeSequence), expected=\(self.pendingWriteSequence), ignoring") - return - } + // Cancel the timeout task since write completed + writeTimeoutTask?.cancel() + writeTimeoutTask = nil - // Cancel the timeout task since write completed - writeTimeoutTask?.cancel() - writeTimeoutTask = nil + // Reset queue tracking on successful completion + consecutiveQueuedWrites = 0 - // Reset queue tracking on successful completion - consecutiveQueuedWrites = 0 + pendingWriteContinuation = nil - pendingWriteContinuation = nil + if let error { + logger.warning("[BLE] Write error: seq=\(writeSequence), error=\(error.localizedDescription)") + continuation.resume(throwing: BLEError.writeError(error.localizedDescription)) + } else { + logger.debug("[BLE] Write complete: seq=\(writeSequence)") + continuation.resume() + } - if let error { - logger.warning("[BLE] Write error: seq=\(writeSequence), error=\(error.localizedDescription)") - continuation.resume(throwing: BLEError.writeError(error.localizedDescription)) - } else { - logger.debug("[BLE] Write complete: seq=\(writeSequence)") - continuation.resume() - } + earliestNextWrite = ContinuousClock.now.advanced(by: .seconds(writePacingDelay)) + resumeNextWriteWaiter() + } - earliestNextWrite = ContinuousClock.now.advanced(by: .seconds(writePacingDelay)) - resumeNextWriteWaiter() - } - - func handleDidReadRSSI(RSSI: NSNumber, error: Error?) { - if let error { - consecutiveRSSIFailures += 1 - if consecutiveRSSIFailures == 3 || consecutiveRSSIFailures % 10 == 0 { - logger.warning( - "[BLE] RSSI read failed (\(self.consecutiveRSSIFailures) consecutive): \(error.localizedDescription)" - ) - } - } else { - if consecutiveRSSIFailures > 0 { - logger.info("[BLE] RSSI read recovered after \(self.consecutiveRSSIFailures) failures, RSSI: \(RSSI)") - } - consecutiveRSSIFailures = 0 - } + func handleDidReadRSSI(RSSI: NSNumber, error: Error?) { + if let error { + consecutiveRSSIFailures += 1 + if consecutiveRSSIFailures == 3 || consecutiveRSSIFailures % 10 == 0 { + logger.warning( + "[BLE] RSSI read failed (\(consecutiveRSSIFailures) consecutive): \(error.localizedDescription)" + ) + } + } else { + if consecutiveRSSIFailures > 0 { + logger.info("[BLE] RSSI read recovered after \(consecutiveRSSIFailures) failures, RSSI: \(RSSI)") + } + consecutiveRSSIFailures = 0 } + } } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift index 0c3929e7..abaa801c 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift @@ -9,1208 +9,1216 @@ import Foundation /// owns its resources (continuations, timeouts), ensuring proper cleanup /// on any transition. actor BLEStateMachine: BLEStateMachineProtocol { - - // MARK: - Logging - - let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEStateMachine") - let instanceID = String(UUID().uuidString.prefix(8)) - var lastCentralState: CBManagerState? - - nonisolated var processContext: String { - let processName = ProcessInfo.processInfo.processName - let bundleID = Bundle.main.bundleIdentifier ?? "unknown" - return "process: \(processName), bundle: \(bundleID)" - } - - /// Converts CBPeripheralState to readable string for diagnostics - nonisolated func peripheralStateString(_ state: CBPeripheralState) -> String { - switch state { - case .disconnected: return "disconnected" - case .connecting: return "connecting" - case .connected: return "connected" - case .disconnecting: return "disconnecting" - @unknown default: return "unknown(\(state.rawValue))" - } - } - - // MARK: - State - - var phase: BLEPhase = .idle - - /// Tracks when the current phase started (for timing diagnostics) - var phaseStartTime: Date = Date() - - /// Monotonically increasing generation counter. Incremented on each new - /// connection or auto-reconnect cycle. Used to reject stale disconnect - /// callbacks that arrive after a newer connection has started. - var connectionGeneration: UInt64 = 0 - - /// Monotonic boundary timestamp for the current generation. - /// Disconnect callbacks older than this belong to a previous generation. - var connectionGenerationStartTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() - - /// Number of times a discovery watchdog has deferred teardown within the - /// current generation because the peripheral was already connected. Reset - /// per generation in `advanceConnectionGeneration()`. - var discoveryTimeoutExtensions = 0 - - /// Max times a discovery watchdog defers teardown while the peripheral is - /// already connected, before forcing a reconnect. Bounds recovery so a - /// genuinely wedged-but-connected link still tears down eventually. - static let maxDiscoveryTimeoutExtensions = 2 - - /// Expose current phase for testing - var currentPhase: BLEPhase { phase } - - /// Expose current connection generation for testing - var currentConnectionGeneration: UInt64 { connectionGeneration } - - /// Expose the per-generation discovery-extension count for testing - var currentDiscoveryTimeoutExtensions: Int { discoveryTimeoutExtensions } - - // MARK: - CoreBluetooth - - /// The central manager instance. - /// - /// Marked `nonisolated(unsafe)` because: - /// 1. CBCentralManager is not Sendable - /// 2. We need nonisolated access from `bluetoothState` property - /// 3. The manager is only mutated once during initialization - /// 4. All other access is from the actor's isolated context - /// 5. The `bluetoothState` property returns `.unknown` during the brief - /// initialization window before the manager is assigned - nonisolated(unsafe) var centralManager: CBCentralManager! - let delegateHandler: BLEDelegateHandler - - // MARK: - Configuration - - private let stateRestorationID = "com.pocketmesh.ble.central" - private let connectionTimeout: TimeInterval - let serviceDiscoveryTimeout: TimeInterval - private let autoReconnectDiscoveryTimeout: TimeInterval - private let writeTimeout: TimeInterval - - /// Delay between write operations for ESP32 compatibility (0 = no pacing) - var writePacingDelay: TimeInterval = 0 - - /// Next write blocked until this instant. Checked in claimWriteSlot - /// so both queued and sequential writes are paced. - var earliestNextWrite: ContinuousClock.Instant = .now - - /// Tracks consecutive queued writes for diagnostic logging - var consecutiveQueuedWrites = 0 - private let queuePressureThreshold = 3 - - // MARK: - UUIDs - - let nordicUARTServiceUUID = CBUUID(string: BLEServiceUUID.nordicUART) - let txCharacteristicUUID = CBUUID(string: BLEServiceUUID.txCharacteristic) - let rxCharacteristicUUID = CBUUID(string: BLEServiceUUID.rxCharacteristic) - - /// Pending write continuation (only one write at a time) - var pendingWriteContinuation: CheckedContinuation? - - /// Monotonic sequence number for correlating didWriteValue callbacks to the active write. - /// Each write's sequence is recorded with the delegate handler at issue time - /// (`recordIssuedWriteSequence`), and the delegate tags each callback with the oldest - /// unacknowledged write's sequence. A callback from write N that arrives after N timed - /// out therefore carries N, mismatches `pendingWriteSequence` (N+1), and is dropped - /// instead of resuming write N+1's continuation with write N's result. - private var writeSequenceNumber: UInt64 = 0 - var pendingWriteSequence: UInt64 = 0 - - /// Queue of tasks waiting to write (serializes concurrent sends) - private var writeWaiters: [CheckedContinuation] = [] - - /// Tracks the current write timeout task so it can be cancelled when write completes - var writeTimeoutTask: Task? - - /// Whether the connected peripheral's write (tx) characteristic advertises - /// `.writeWithoutResponse`. Captured at characteristic discovery (both the normal and - /// auto-reconnect branches) and only read while `.connected`, so it cannot go stale for - /// the same radio: a fresh value is written before any send can occur. nRF52 (Adafruit - /// BLEUart) advertises it; ESP32 does not, so ESP32 stays on the acknowledged path. - private var txSupportsWriteWithoutResponse = false - - /// Continuation for a `sendWithoutResponse` caller parked on CoreBluetooth backpressure - /// (`canSendWriteWithoutResponse == false`). Resumed by the `peripheralIsReady` delegate - /// callback, by `cancelPendingWriteOperations` on teardown, or thrown by its own timeout - /// backstop, so a sender never hangs across a disconnect or a peripheral that goes silent. - /// Single slot: the pipeline issues one Write Command at a time. - private var writeWithoutResponseReadyContinuation: CheckedContinuation? - - /// Backstop timeout for `writeWithoutResponseReadyContinuation`: fails a parked sender if - /// the peripheral never re-signals readiness and no disconnect arrives, so a stuck Write - /// Command cannot defeat the session's pipeline timeout and hang the channel sync. - private var writeWithoutResponseReadyTimeoutTask: Task? - - /// Tracks the service discovery timeout task so it can be cancelled on success - var serviceDiscoveryTimeoutTask: Task? - - /// Tracks the auto-reconnect discovery timeout task so it can be cancelled on success - var autoReconnectDiscoveryTimeoutTask: Task? - - /// Periodic RSSI read task that keeps the BLE connection alive in background. - /// Without periodic BLE activity, iOS may drop idle connections. - private var rssiKeepaliveTask: Task? - - /// Consecutive RSSI read failures. Reset on success. Logged for diagnostics. - var consecutiveRSSIFailures = 0 - - /// Tracks whether the app is in the foreground. Used to gate - /// keepalive and timeout behavior. - private var isAppActive = true - - /// Tracks whether CBCentralManager has been created - private var isActivated = false - - /// Grace period task for poweredOff during waitingForBluetooth. - /// Allows CBCentralManager initialization to settle (poweredOff → poweredOn). - var bluetoothPowerOffGraceTask: Task? - - // MARK: - Scanning (orthogonal to connection lifecycle) - - var isCurrentlyScanning = false - var pendingScanRequest = false - /// Installed by `ConnectionManager.startBLEScanning` and reset to a no-op when scanning ends. - private var onDeviceDiscovered: (@Sendable (UUID, String?, Int) -> Void)? - - // MARK: - Callbacks - - /// Installed by `ConnectionManager.init` (via `iOSBLETransport.setDisconnectionHandler`). - var onDisconnection: (@Sendable (UUID, Error?) -> Void)? - /// Installed by `ConnectionManager.init` (via `iOSBLETransport.setReconnectionHandler`, - /// which wraps it to capture the data stream before the handler runs). - var onReconnection: (@Sendable (UUID, AsyncStream) -> Void)? - /// Installed by `ConnectionManager.init`. - var onBluetoothStateChange: (@Sendable (CBManagerState) -> Void)? - /// Installed by `ConnectionManager.init`. - var onBluetoothPoweredOn: (@Sendable () -> Void)? - /// Called when entering iOS auto-reconnecting phase. - /// The device has disconnected but iOS will attempt automatic reconnection. - /// Note: The MeshCore session is invalid at this point and will be rebuilt upon successful reconnection. - /// Installed by `ConnectionManager.init`. - var onAutoReconnecting: (@Sendable (UUID, String) -> Void)? - - /// Distinguishes the call site driving `handleRestoredPeripheral` so the function - /// fires `onAutoReconnecting` only when no upstream caller has already claimed the cycle. - enum RestoredPeripheralSource { - case stateRestoration - case adoption + // MARK: - Logging + + let logger = PersistentLogger(subsystem: "com.mc1", category: "BLEStateMachine") + let instanceID = String(UUID().uuidString.prefix(8)) + var lastCentralState: CBManagerState? + + nonisolated var processContext: String { + let processName = ProcessInfo.processInfo.processName + let bundleID = Bundle.main.bundleIdentifier ?? "unknown" + return "process: \(processName), bundle: \(bundleID)" + } + + /// Converts CBPeripheralState to readable string for diagnostics + nonisolated func peripheralStateString(_ state: CBPeripheralState) -> String { + switch state { + case .disconnected: return "disconnected" + case .connecting: return "connecting" + case .connected: return "connected" + case .disconnecting: return "disconnecting" + @unknown default: return "unknown(\(state.rawValue))" } - - // MARK: - Initialization - - /// Creates a new BLE state machine. - /// - /// - Parameters: - /// - connectionTimeout: Timeout for initial connection (default 10s) - /// - serviceDiscoveryTimeout: Timeout for service/characteristic discovery (default 40s for pairing dialog) - /// - autoReconnectDiscoveryTimeout: Timeout for auto-reconnect discovery (default 15s, shorter since no pairing expected) - /// - writeTimeout: Timeout for write operations (default 5s) - /// - writePacingDelay: Delay between write operations for ESP32 compatibility (default 0 = no pacing) - init( - connectionTimeout: TimeInterval = 10.0, - serviceDiscoveryTimeout: TimeInterval = 40.0, - autoReconnectDiscoveryTimeout: TimeInterval = 15.0, - writeTimeout: TimeInterval = 5.0, - writePacingDelay: TimeInterval = 0 - ) { - self.connectionTimeout = connectionTimeout - self.serviceDiscoveryTimeout = serviceDiscoveryTimeout - self.autoReconnectDiscoveryTimeout = autoReconnectDiscoveryTimeout - self.writeTimeout = writeTimeout - self.writePacingDelay = writePacingDelay - self.delegateHandler = BLEDelegateHandler() + } + + // MARK: - State + + var phase: BLEPhase = .idle + + /// Tracks when the current phase started (for timing diagnostics) + var phaseStartTime: Date = .init() + + /// Monotonically increasing generation counter. Incremented on each new + /// connection or auto-reconnect cycle. Used to reject stale disconnect + /// callbacks that arrive after a newer connection has started. + var connectionGeneration: UInt64 = 0 + + /// Monotonic boundary timestamp for the current generation. + /// Disconnect callbacks older than this belong to a previous generation. + var connectionGenerationStartTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() + + /// Number of times a discovery watchdog has deferred teardown within the + /// current generation because the peripheral was already connected. Reset + /// per generation in `advanceConnectionGeneration()`. + var discoveryTimeoutExtensions = 0 + + /// Max times a discovery watchdog defers teardown while the peripheral is + /// already connected, before forcing a reconnect. Bounds recovery so a + /// genuinely wedged-but-connected link still tears down eventually. + static let maxDiscoveryTimeoutExtensions = 2 + + /// Expose current phase for testing + var currentPhase: BLEPhase { + phase + } + + /// Expose current connection generation for testing + var currentConnectionGeneration: UInt64 { + connectionGeneration + } + + /// Expose the per-generation discovery-extension count for testing + var currentDiscoveryTimeoutExtensions: Int { + discoveryTimeoutExtensions + } + + // MARK: - CoreBluetooth + + /// The central manager instance. + /// + /// Marked `nonisolated(unsafe)` because: + /// 1. CBCentralManager is not Sendable + /// 2. We need nonisolated access from `bluetoothState` property + /// 3. The manager is only mutated once during initialization + /// 4. All other access is from the actor's isolated context + /// 5. The `bluetoothState` property returns `.unknown` during the brief + /// initialization window before the manager is assigned + nonisolated(unsafe) var centralManager: CBCentralManager! + let delegateHandler: BLEDelegateHandler + + // MARK: - Configuration + + private let stateRestorationID = "com.pocketmesh.ble.central" + private let connectionTimeout: TimeInterval + let serviceDiscoveryTimeout: TimeInterval + private let autoReconnectDiscoveryTimeout: TimeInterval + private let writeTimeout: TimeInterval + + /// Delay between write operations for ESP32 compatibility (0 = no pacing) + var writePacingDelay: TimeInterval = 0 + + /// Next write blocked until this instant. Checked in claimWriteSlot + /// so both queued and sequential writes are paced. + var earliestNextWrite: ContinuousClock.Instant = .now + + /// Tracks consecutive queued writes for diagnostic logging + var consecutiveQueuedWrites = 0 + private let queuePressureThreshold = 3 + + // MARK: - UUIDs + + let nordicUARTServiceUUID = CBUUID(string: BLEServiceUUID.nordicUART) + let txCharacteristicUUID = CBUUID(string: BLEServiceUUID.txCharacteristic) + let rxCharacteristicUUID = CBUUID(string: BLEServiceUUID.rxCharacteristic) + + /// Pending write continuation (only one write at a time) + var pendingWriteContinuation: CheckedContinuation? + + /// Monotonic sequence number for correlating didWriteValue callbacks to the active write. + /// Each write's sequence is recorded with the delegate handler at issue time + /// (`recordIssuedWriteSequence`), and the delegate tags each callback with the oldest + /// unacknowledged write's sequence. A callback from write N that arrives after N timed + /// out therefore carries N, mismatches `pendingWriteSequence` (N+1), and is dropped + /// instead of resuming write N+1's continuation with write N's result. + private var writeSequenceNumber: UInt64 = 0 + var pendingWriteSequence: UInt64 = 0 + + /// Queue of tasks waiting to write (serializes concurrent sends) + private var writeWaiters: [CheckedContinuation] = [] + + /// Tracks the current write timeout task so it can be cancelled when write completes + var writeTimeoutTask: Task? + + /// Whether the connected peripheral's write (tx) characteristic advertises + /// `.writeWithoutResponse`. Captured at characteristic discovery (both the normal and + /// auto-reconnect branches) and only read while `.connected`, so it cannot go stale for + /// the same radio: a fresh value is written before any send can occur. nRF52 (Adafruit + /// BLEUart) advertises it; ESP32 does not, so ESP32 stays on the acknowledged path. + private var txSupportsWriteWithoutResponse = false + + /// Continuation for a `sendWithoutResponse` caller parked on CoreBluetooth backpressure + /// (`canSendWriteWithoutResponse == false`). Resumed by the `peripheralIsReady` delegate + /// callback, by `cancelPendingWriteOperations` on teardown, or thrown by its own timeout + /// backstop, so a sender never hangs across a disconnect or a peripheral that goes silent. + /// Single slot: the pipeline issues one Write Command at a time. + private var writeWithoutResponseReadyContinuation: CheckedContinuation? + + /// Backstop timeout for `writeWithoutResponseReadyContinuation`: fails a parked sender if + /// the peripheral never re-signals readiness and no disconnect arrives, so a stuck Write + /// Command cannot defeat the session's pipeline timeout and hang the channel sync. + private var writeWithoutResponseReadyTimeoutTask: Task? + + /// Tracks the service discovery timeout task so it can be cancelled on success + var serviceDiscoveryTimeoutTask: Task? + + /// Tracks the auto-reconnect discovery timeout task so it can be cancelled on success + var autoReconnectDiscoveryTimeoutTask: Task? + + /// Periodic RSSI read task that keeps the BLE connection alive in background. + /// Without periodic BLE activity, iOS may drop idle connections. + private var rssiKeepaliveTask: Task? + + /// Consecutive RSSI read failures. Reset on success. Logged for diagnostics. + var consecutiveRSSIFailures = 0 + + /// Tracks whether the app is in the foreground. Used to gate + /// keepalive and timeout behavior. + private var isAppActive = true + + /// Tracks whether CBCentralManager has been created + private var isActivated = false + + /// Grace period task for poweredOff during waitingForBluetooth. + /// Allows CBCentralManager initialization to settle (poweredOff → poweredOn). + var bluetoothPowerOffGraceTask: Task? + + // MARK: - Scanning (orthogonal to connection lifecycle) + + var isCurrentlyScanning = false + var pendingScanRequest = false + /// Installed by `ConnectionManager.startBLEScanning` and reset to a no-op when scanning ends. + private var onDeviceDiscovered: (@Sendable (UUID, String?, Int) -> Void)? + + // MARK: - Callbacks + + /// Installed by `ConnectionManager.init` (via `iOSBLETransport.setDisconnectionHandler`). + var onDisconnection: (@Sendable (UUID, Error?) -> Void)? + /// Installed by `ConnectionManager.init` (via `iOSBLETransport.setReconnectionHandler`, + /// which wraps it to capture the data stream before the handler runs). + var onReconnection: (@Sendable (UUID, AsyncStream) -> Void)? + /// Installed by `ConnectionManager.init`. + var onBluetoothStateChange: (@Sendable (CBManagerState) -> Void)? + /// Installed by `ConnectionManager.init`. + var onBluetoothPoweredOn: (@Sendable () -> Void)? + /// Called when entering iOS auto-reconnecting phase. + /// The device has disconnected but iOS will attempt automatic reconnection. + /// Note: The MeshCore session is invalid at this point and will be rebuilt upon successful reconnection. + /// Installed by `ConnectionManager.init`. + var onAutoReconnecting: (@Sendable (UUID, String) -> Void)? + + /// Distinguishes the call site driving `handleRestoredPeripheral` so the function + /// fires `onAutoReconnecting` only when no upstream caller has already claimed the cycle. + enum RestoredPeripheralSource { + case stateRestoration + case adoption + } + + // MARK: - Initialization + + /// Creates a new BLE state machine. + /// + /// - Parameters: + /// - connectionTimeout: Timeout for initial connection (default 10s) + /// - serviceDiscoveryTimeout: Timeout for service/characteristic discovery (default 40s for pairing dialog) + /// - autoReconnectDiscoveryTimeout: Timeout for auto-reconnect discovery (default 15s, shorter since no pairing expected) + /// - writeTimeout: Timeout for write operations (default 5s) + /// - writePacingDelay: Delay between write operations for ESP32 compatibility (default 0 = no pacing) + init( + connectionTimeout: TimeInterval = 10.0, + serviceDiscoveryTimeout: TimeInterval = 40.0, + autoReconnectDiscoveryTimeout: TimeInterval = 15.0, + writeTimeout: TimeInterval = 5.0, + writePacingDelay: TimeInterval = 0 + ) { + self.connectionTimeout = connectionTimeout + self.serviceDiscoveryTimeout = serviceDiscoveryTimeout + self.autoReconnectDiscoveryTimeout = autoReconnectDiscoveryTimeout + self.writeTimeout = writeTimeout + self.writePacingDelay = writePacingDelay + delegateHandler = BLEDelegateHandler() + } + + /// Sets the write pacing delay for ESP32 compatibility. + /// - Parameter delay: Delay in seconds between write operations (0 = no pacing) + func setWritePacingDelay(_ delay: TimeInterval) { + writePacingDelay = delay + } + + /// Activates the BLE state machine, creating the CBCentralManager. + /// Call once during app initialization. Safe to call multiple times. + func activate() { + guard !isActivated else { return } + isActivated = true + logger.info("[BLE] Activating state machine, instance: \(instanceID), \(processContext)") + initializeCentralManager() + } + + private let centralQueue = DispatchQueue(label: "com.pocketmesh.ble.central") + + private func initializeCentralManager() { + // Set stateMachine reference before creating CBCentralManager. + // iOS calls willRestoreState during or immediately after CBCentralManager.init(), + // and the delegate handler needs the stateMachine reference to process it. + delegateHandler.stateMachine = self + + logger.info("[BLE] Initializing central manager, instance: \(instanceID), \(processContext)") + let options: [String: Any] = [ + CBCentralManagerOptionRestoreIdentifierKey: stateRestorationID, + CBCentralManagerOptionShowPowerAlertKey: true + ] + centralManager = CBCentralManager( + delegate: delegateHandler, + queue: centralQueue, + options: options + ) + } + + // MARK: - Connection Generation + + /// Advances the connection generation counter and records the boundary timestamp. + /// Called when starting a new connection, auto-reconnect cycle, or restoration reconnect + /// so that stale disconnect callbacks from previous generations can be identified and rejected. + func advanceConnectionGeneration() { + connectionGeneration &+= 1 + connectionGenerationStartTime = CFAbsoluteTimeGetCurrent() + discoveryTimeoutExtensions = 0 + } + + /// Returns true when a disconnect callback's timestamp predates the current generation boundary. + /// Uses CFAbsoluteTime from CoreBluetooth's didDisconnectPeripheral (reflects disconnect event + /// time per Apple's header: "now or a few seconds ago", not callback delivery time). + /// The tolerance accounts for non-monotonic clock adjustments (NTP sync, user clock changes). + static func isDisconnectCallbackFromPreviousGeneration( + timestamp: CFAbsoluteTime, + generationStart: CFAbsoluteTime, + tolerance: CFAbsoluteTime = 1.0 + ) -> Bool { + timestamp + tolerance < generationStart + } + + // MARK: - API + + /// Whether the state machine is currently connected to a device + var isConnected: Bool { + if case .connected = phase { return true } + return false + } + + /// Whether the state machine is currently handling iOS auto-reconnect or state restoration + var isAutoReconnecting: Bool { + switch phase { + case .autoReconnecting, .restoringState: + true + default: + false } - - /// Sets the write pacing delay for ESP32 compatibility. - /// - Parameter delay: Delay in seconds between write operations (0 = no pacing) - func setWritePacingDelay(_ delay: TimeInterval) { - writePacingDelay = delay + } + + /// UUID of the currently connected device, or nil if not connected + var connectedDeviceID: UUID? { + phase.deviceID + } + + /// Current Bluetooth hardware state + nonisolated var bluetoothState: CBManagerState { + centralManager?.state ?? .unknown + } + + /// Current phase name for diagnostic logging + var currentPhaseName: String { + phase.name + } + + /// Current peripheral state for diagnostic logging (nil if no peripheral) + var currentPeripheralState: String? { + guard let peripheral = phase.peripheral else { return nil } + return peripheralStateString(peripheral.state) + } + + /// Whether the Bluetooth central manager is in the powered-off state. + var isBluetoothPoweredOff: Bool { + centralManager?.state == .poweredOff + } + + /// Current CBCentralManager state name for diagnostic logging + var centralManagerStateName: String { + guard let manager = centralManager else { return "notActivated" } + switch manager.state { + case .unknown: return "unknown" + case .resetting: return "resetting" + case .unsupported: return "unsupported" + case .unauthorized: return "unauthorized" + case .poweredOff: return "poweredOff" + case .poweredOn: return "poweredOn" + @unknown default: return "unknown(\(manager.state.rawValue))" } - - /// Activates the BLE state machine, creating the CBCentralManager. - /// Call once during app initialization. Safe to call multiple times. - func activate() { - guard !isActivated else { return } - isActivated = true - logger.info("[BLE] Activating state machine, instance: \(instanceID), \(processContext)") - initializeCentralManager() + } + + /// Checks if a device is connected to the system (possibly by another app). + /// Call this before attempting connection when in `.idle` phase. + /// - Parameter deviceID: The UUID of the device to check + /// - Returns: `true` if the device is connected to the system + func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool { + activate() + let connectedPeripherals = centralManager.retrieveConnectedPeripherals( + withServices: [nordicUARTServiceUUID] + ) + return connectedPeripherals.contains { $0.identifier == deviceID } + } + + func systemConnectedPeripheralIDs() -> [UUID] { + activate() + return centralManager.retrieveConnectedPeripherals( + withServices: [nordicUARTServiceUUID] + ).map(\.identifier) + } + + /// Starts a best-effort adoption of an already system-connected peripheral. + /// + /// When iOS keeps the BLE link alive but state restoration does not fire (common across app updates), + /// `retrieveConnectedPeripherals` may report the radio as system-connected while our state machine + /// remains `.idle`. In that scenario, we can adopt the existing link by running the restoration + /// discovery chain against the connected peripheral. + /// + /// - Parameter deviceID: The UUID of the device to adopt. + /// - Returns: `true` if an adoption attempt was started. + func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool { + activate() + + guard case .idle = phase else { + logger.info("[BLE] adoptSystemConnectedPeripheral skipped - phase: \(phase.name)") + return false } - private let centralQueue = DispatchQueue(label: "com.pocketmesh.ble.central") - - private func initializeCentralManager() { - // Set stateMachine reference before creating CBCentralManager. - // iOS calls willRestoreState during or immediately after CBCentralManager.init(), - // and the delegate handler needs the stateMachine reference to process it. - delegateHandler.stateMachine = self - - logger.info("[BLE] Initializing central manager, instance: \(instanceID), \(processContext)") - let options: [String: Any] = [ - CBCentralManagerOptionRestoreIdentifierKey: stateRestorationID, - CBCentralManagerOptionShowPowerAlertKey: true - ] - self.centralManager = CBCentralManager( - delegate: delegateHandler, - queue: centralQueue, - options: options - ) + let connectedPeripherals = centralManager.retrieveConnectedPeripherals(withServices: [nordicUARTServiceUUID]) + guard let peripheral = connectedPeripherals.first(where: { $0.identifier == deviceID }) else { + return false } - // MARK: - Connection Generation - - /// Advances the connection generation counter and records the boundary timestamp. - /// Called when starting a new connection, auto-reconnect cycle, or restoration reconnect - /// so that stale disconnect callbacks from previous generations can be identified and rejected. - func advanceConnectionGeneration() { - connectionGeneration &+= 1 - connectionGenerationStartTime = CFAbsoluteTimeGetCurrent() - discoveryTimeoutExtensions = 0 + let pState = peripheralStateString(peripheral.state) + logger.info("[BLE] Adopting system-connected peripheral: \(deviceID.uuidString.prefix(8)), state: \(pState)") + handleRestoredPeripheral(peripheral, source: .adoption) + return true + } + + // MARK: - Event Handler Registration + + /// Sets a handler for disconnection events + func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) { + onDisconnection = handler + } + + /// Sets a handler for reconnection events. + /// The handler receives the device ID and the data stream for receiving data. + func setReconnectionHandler(_ handler: @escaping @Sendable (UUID, AsyncStream) -> Void) { + onReconnection = handler + } + + /// Sets a handler for Bluetooth state changes + func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) { + onBluetoothStateChange = handler + } + + /// Sets a handler called when Bluetooth powers on + func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) { + onBluetoothPoweredOn = handler + } + + /// Sets a handler for auto-reconnecting events. + /// Called when device disconnects but iOS is attempting automatic reconnection. + func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) { + onAutoReconnecting = handler + } + + // MARK: - BLE Scanning + + /// Sets a handler called when a device is discovered during scanning. + /// - Parameter handler: Callback with (deviceID, advertised name, rssi) + func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) { + onDeviceDiscovered = handler + } + + /// Starts scanning for BLE peripherals advertising the Nordic UART service. + /// Scanning is orthogonal to the connection lifecycle — it works while connected. + /// Requires `activate()` to have been called and Bluetooth to be powered on. + func startScanning() { + activate() + guard centralManager.state == .poweredOn else { + logger.info("[BLE] Cannot start scanning: Bluetooth not powered on, will start when ready") + pendingScanRequest = true + return } - - /// Returns true when a disconnect callback's timestamp predates the current generation boundary. - /// Uses CFAbsoluteTime from CoreBluetooth's didDisconnectPeripheral (reflects disconnect event - /// time per Apple's header: "now or a few seconds ago", not callback delivery time). - /// The tolerance accounts for non-monotonic clock adjustments (NTP sync, user clock changes). - static func isDisconnectCallbackFromPreviousGeneration( - timestamp: CFAbsoluteTime, - generationStart: CFAbsoluteTime, - tolerance: CFAbsoluteTime = 1.0 - ) -> Bool { - timestamp + tolerance < generationStart + pendingScanRequest = false + guard !isCurrentlyScanning else { return } + isCurrentlyScanning = true + logger.info("[BLE] Starting BLE scan for device discovery") + centralManager.scanForPeripherals( + withServices: [nordicUARTServiceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] + ) + } + + /// Stops an active BLE scan. + func stopScanning() { + pendingScanRequest = false + guard isCurrentlyScanning else { return } + isCurrentlyScanning = false + logger.info("[BLE] Stopping BLE scan") + centralManager.stopScan() + } + + /// Handles a discovered peripheral during scanning. + func handleDidDiscoverPeripheral(peripheralID: UUID, name: String?, rssi: Int) { + guard isCurrentlyScanning else { return } + onDeviceDiscovered?(peripheralID, name, rssi) + } + + /// Waits for Bluetooth to be powered on. + /// + /// - Throws: `BLEError.bluetoothUnavailable` if Bluetooth is not supported + /// `BLEError.bluetoothUnauthorized` if access is denied + /// `BLEError.bluetoothPoweredOff` if Bluetooth is off and doesn't turn on + func waitForPoweredOn() async throws { + activate() + + // Already powered on + if centralManager.state == .poweredOn { return } + + // Terminal states won't produce further callbacks - fail immediately + switch centralManager.state { + case .unsupported: + throw BLEError.bluetoothUnavailable + case .unauthorized: + throw BLEError.bluetoothUnauthorized + default: + break } - // MARK: - API - - /// Whether the state machine is currently connected to a device - var isConnected: Bool { - if case .connected = phase { return true } - return false + // Wait for state change (.unknown, .resetting, and .poweredOff reach here). + // poweredOff is included because a freshly created CBCentralManager may + // briefly report poweredOff before settling on poweredOn. + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard case .idle = phase else { + continuation.resume(throwing: BLEError.connectionFailed("Already in operation")) + return + } + transition(to: .waitingForBluetooth(continuation: continuation)) } - - /// Whether the state machine is currently handling iOS auto-reconnect or state restoration - var isAutoReconnecting: Bool { - switch phase { - case .autoReconnecting, .restoringState: - return true - default: - return false - } + } + + /// Connects to a BLE device and returns a data stream. + /// + /// - Parameter deviceID: UUID of the device to connect to + /// - Returns: AsyncStream of data received from the device + /// - Throws: BLEError if connection fails + func connect(to deviceID: UUID) async throws -> AsyncStream { + logger.info("Connect requested for device: \(deviceID)") + + // Ensure we're in idle state + guard case .idle = phase else { + // Diagnostic: Log detailed state when connection is rejected + let peripheralState = phase.peripheral.map { peripheralStateString($0.state) } ?? "none" + let phaseDeviceID = phase.deviceID?.uuidString ?? "none" + logger.warning( + "Connect rejected - phase: \(phase.name), peripheralState: \(peripheralState), phaseDeviceID: \(phaseDeviceID), requestedDeviceID: \(deviceID)" + ) + throw BLEError.connectionFailed("Already in operation: \(phase.name)") } - /// UUID of the currently connected device, or nil if not connected - var connectedDeviceID: UUID? { - phase.deviceID - } + // Wait for Bluetooth + try await waitForPoweredOn() - /// Current Bluetooth hardware state - nonisolated var bluetoothState: CBManagerState { - centralManager?.state ?? .unknown + // Re-validate after the suspension: state restoration or a poweredOn + // callback can claim the machine (e.g. .autoReconnecting) while waiting, + // and proceeding would clobber that phase's armed timeout and CB connect. + guard case .idle = phase else { + logger.warning("Connect rejected after waitForPoweredOn - phase: \(phase.name), requestedDeviceID: \(deviceID)") + throw BLEError.connectionFailed("Already in operation: \(phase.name)") } - /// Current phase name for diagnostic logging - var currentPhaseName: String { - phase.name + // Retrieve peripheral + let peripherals = centralManager.retrievePeripherals(withIdentifiers: [deviceID]) + guard let peripheral = peripherals.first else { + logger.warning("[BLE] Device not found in peripheral cache: \(deviceID.uuidString.prefix(8))") + throw BLEError.deviceNotFound } - /// Current peripheral state for diagnostic logging (nil if no peripheral) - var currentPeripheralState: String? { - guard let peripheral = phase.peripheral else { return nil } - return peripheralStateString(peripheral.state) - } + // Advance connection generation before starting connection + advanceConnectionGeneration() + logger.info("[BLE] Connection generation advanced to \(connectionGeneration) for device: \(deviceID.uuidString.prefix(8))") - /// Whether the Bluetooth central manager is in the powered-off state. - var isBluetoothPoweredOff: Bool { - centralManager?.state == .poweredOff - } + // Connect and discover services (continuation spans entire discovery chain) + try await connectToPeripheral(peripheral) - /// Current CBCentralManager state name for diagnostic logging - var centralManagerStateName: String { - guard let manager = centralManager else { return "notActivated" } - switch manager.state { - case .unknown: return "unknown" - case .resetting: return "resetting" - case .unsupported: return "unsupported" - case .unauthorized: return "unauthorized" - case .poweredOff: return "poweredOff" - case .poweredOn: return "poweredOn" - @unknown default: return "unknown(\(manager.state.rawValue))" - } - } + // Create data stream and transition to connected + let (stream, continuation) = AsyncStream.makeStream( + of: Data.self, + bufferingPolicy: .bufferingOldest(512) + ) - /// Checks if a device is connected to the system (possibly by another app). - /// Call this before attempting connection when in `.idle` phase. - /// - Parameter deviceID: The UUID of the device to check - /// - Returns: `true` if the device is connected to the system - func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool { - activate() - let connectedPeripherals = centralManager.retrieveConnectedPeripherals( - withServices: [nordicUARTServiceUUID] - ) - return connectedPeripherals.contains { $0.identifier == deviceID } + guard case let .discoveryComplete(_, tx, rx) = phase else { + throw BLEError.connectionFailed("Unexpected state after service discovery") } - func systemConnectedPeripheralIDs() -> [UUID] { - activate() - return centralManager.retrieveConnectedPeripherals( - withServices: [nordicUARTServiceUUID] - ).map(\.identifier) + // Pass continuation to delegate handler for direct yielding (preserves ordering) + delegateHandler.setDataContinuation(continuation) + + transition(to: .connected( + peripheral: peripheral, + tx: tx, + rx: rx, + dataContinuation: continuation + )) + startRSSIKeepalive(for: peripheral) + + logger.info("Connection complete for device: \(deviceID)") + return stream + } + + /// Sends data to the connected device. + /// + /// This method serializes concurrent calls - if a write is already in progress, + /// subsequent calls will wait until the previous write completes. + /// + /// - Parameter data: Data to send + /// - Throws: BLEError if not connected or write fails + func send(_ data: Data) async throws { + logger.info("[BLE] send: \(data.count) bytes") + try await claimWriteSlot(data: data) + } + + /// Whether the connected peripheral's write characteristic supports ATT Write Commands. + /// Drives the transport's `supportsWriteWithoutResponse` capability gate. + var supportsWriteWithoutResponse: Bool { + txSupportsWriteWithoutResponse + } + + /// Test observability: whether a `sendWithoutResponse` caller is currently parked + /// awaiting peripheral readiness. Reflects existing state; does not alter behavior. + var isAwaitingWriteWithoutResponseReady: Bool { + writeWithoutResponseReadyContinuation != nil + } + + /// Sends data as an unacknowledged ATT Write Command (no `didWriteValue` ACK), which is + /// what lets a caller pipeline back-to-back requests. + /// + /// This path is independent of the `.withResponse` machinery (`pendingWriteContinuation`, + /// `writeTimeoutTask`, `writeWaiters`): flow control is CoreBluetooth's + /// `canSendWriteWithoutResponse` backpressure plus the caller's bounded send window. It + /// deliberately skips `writePacingDelay` — that delay protects the ESP32 RX queue during + /// `.withResponse` bursts, and Write Commands are an nRF52-only path. A radio whose write + /// characteristic does not advertise the capability degrades to the acknowledged path. + func sendWithoutResponse(_ data: Data) async throws { + let myGeneration = connectionGeneration + + guard case let .connected(peripheral, _, _, _) = phase, + peripheral.state == .connected else { + throw BLEError.notConnected } - /// Starts a best-effort adoption of an already system-connected peripheral. - /// - /// When iOS keeps the BLE link alive but state restoration does not fire (common across app updates), - /// `retrieveConnectedPeripherals` may report the radio as system-connected while our state machine - /// remains `.idle`. In that scenario, we can adopt the existing link by running the restoration - /// discovery chain against the connected peripheral. - /// - /// - Parameter deviceID: The UUID of the device to adopt. - /// - Returns: `true` if an adoption attempt was started. - func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool { - activate() - - guard case .idle = phase else { - logger.info("[BLE] adoptSystemConnectedPeripheral skipped - phase: \(self.phase.name)") - return false - } - - let connectedPeripherals = centralManager.retrieveConnectedPeripherals(withServices: [nordicUARTServiceUUID]) - guard let peripheral = connectedPeripherals.first(where: { $0.identifier == deviceID }) else { - return false - } - - let pState = peripheralStateString(peripheral.state) - logger.info("[BLE] Adopting system-connected peripheral: \(deviceID.uuidString.prefix(8)), state: \(pState)") - handleRestoredPeripheral(peripheral, source: .adoption) - return true + guard txSupportsWriteWithoutResponse else { + try await claimWriteSlot(data: data) + return } - // MARK: - Event Handler Registration - - /// Sets a handler for disconnection events - func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) { - onDisconnection = handler - } - - /// Sets a handler for reconnection events. - /// The handler receives the device ID and the data stream for receiving data. - func setReconnectionHandler(_ handler: @escaping @Sendable (UUID, AsyncStream) -> Void) { - onReconnection = handler - } - - /// Sets a handler for Bluetooth state changes - func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) { - onBluetoothStateChange = handler - } - - /// Sets a handler called when Bluetooth powers on - func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) { - onBluetoothPoweredOn = handler - } - - /// Sets a handler for auto-reconnecting events. - /// Called when device disconnects but iOS is attempting automatic reconnection. - func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) { - onAutoReconnecting = handler - } - - // MARK: - BLE Scanning - - /// Sets a handler called when a device is discovered during scanning. - /// - Parameter handler: Callback with (deviceID, advertised name, rssi) - func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) { - onDeviceDiscovered = handler - } - - /// Starts scanning for BLE peripherals advertising the Nordic UART service. - /// Scanning is orthogonal to the connection lifecycle — it works while connected. - /// Requires `activate()` to have been called and Bluetooth to be powered on. - func startScanning() { - activate() - guard centralManager.state == .poweredOn else { - logger.info("[BLE] Cannot start scanning: Bluetooth not powered on, will start when ready") - pendingScanRequest = true - return - } - pendingScanRequest = false - guard !isCurrentlyScanning else { return } - isCurrentlyScanning = true - logger.info("[BLE] Starting BLE scan for device discovery") - centralManager.scanForPeripherals( - withServices: [nordicUARTServiceUUID], - options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] - ) - } - - /// Stops an active BLE scan. - func stopScanning() { - pendingScanRequest = false - guard isCurrentlyScanning else { return } - isCurrentlyScanning = false - logger.info("[BLE] Stopping BLE scan") - centralManager.stopScan() - } - - /// Handles a discovered peripheral during scanning. - func handleDidDiscoverPeripheral(peripheralID: UUID, name: String?, rssi: Int) { - guard isCurrentlyScanning else { return } - onDeviceDiscovered?(peripheralID, name, rssi) - } - - /// Waits for Bluetooth to be powered on. - /// - /// - Throws: `BLEError.bluetoothUnavailable` if Bluetooth is not supported - /// `BLEError.bluetoothUnauthorized` if access is denied - /// `BLEError.bluetoothPoweredOff` if Bluetooth is off and doesn't turn on - func waitForPoweredOn() async throws { - activate() - - // Already powered on - if centralManager.state == .poweredOn { return } - - // Terminal states won't produce further callbacks - fail immediately - switch centralManager.state { - case .unsupported: - throw BLEError.bluetoothUnavailable - case .unauthorized: - throw BLEError.bluetoothUnauthorized - default: - break - } - - // Wait for state change (.unknown, .resetting, and .poweredOff reach here). - // poweredOff is included because a freshly created CBCentralManager may - // briefly report poweredOff before settling on poweredOn. - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - guard case .idle = phase else { - continuation.resume(throwing: BLEError.connectionFailed("Already in operation")) - return - } - transition(to: .waitingForBluetooth(continuation: continuation)) - } - } - - /// Connects to a BLE device and returns a data stream. - /// - /// - Parameter deviceID: UUID of the device to connect to - /// - Returns: AsyncStream of data received from the device - /// - Throws: BLEError if connection fails - func connect(to deviceID: UUID) async throws -> AsyncStream { - logger.info("Connect requested for device: \(deviceID)") - - // Ensure we're in idle state - guard case .idle = phase else { - // Diagnostic: Log detailed state when connection is rejected - let peripheralState = phase.peripheral.map { peripheralStateString($0.state) } ?? "none" - let phaseDeviceID = phase.deviceID?.uuidString ?? "none" - logger.warning( - "Connect rejected - phase: \(self.phase.name), peripheralState: \(peripheralState), phaseDeviceID: \(phaseDeviceID), requestedDeviceID: \(deviceID)" - ) - throw BLEError.connectionFailed("Already in operation: \(phase.name)") - } - - // Wait for Bluetooth - try await waitForPoweredOn() - - // Re-validate after the suspension: state restoration or a poweredOn - // callback can claim the machine (e.g. .autoReconnecting) while waiting, - // and proceeding would clobber that phase's armed timeout and CB connect. - guard case .idle = phase else { - logger.warning("Connect rejected after waitForPoweredOn - phase: \(self.phase.name), requestedDeviceID: \(deviceID)") - throw BLEError.connectionFailed("Already in operation: \(phase.name)") - } - - // Retrieve peripheral - let peripherals = centralManager.retrievePeripherals(withIdentifiers: [deviceID]) - guard let peripheral = peripherals.first else { - logger.warning("[BLE] Device not found in peripheral cache: \(deviceID.uuidString.prefix(8))") - throw BLEError.deviceNotFound - } - - // Advance connection generation before starting connection - advanceConnectionGeneration() - logger.info("[BLE] Connection generation advanced to \(self.connectionGeneration) for device: \(deviceID.uuidString.prefix(8))") - - // Connect and discover services (continuation spans entire discovery chain) - try await connectToPeripheral(peripheral) - - // Create data stream and transition to connected - let (stream, continuation) = AsyncStream.makeStream( - of: Data.self, - bufferingPolicy: .bufferingOldest(512) - ) - - guard case .discoveryComplete(_, let tx, let rx) = phase else { - throw BLEError.connectionFailed("Unexpected state after service discovery") - } - - // Pass continuation to delegate handler for direct yielding (preserves ordering) - delegateHandler.setDataContinuation(continuation) - - transition(to: .connected( - peripheral: peripheral, - tx: tx, - rx: rx, - dataContinuation: continuation - )) - startRSSIKeepalive(for: peripheral) + try await awaitWriteWithoutResponseReadiness(alreadyReady: peripheral.canSendWriteWithoutResponse) - logger.info("Connection complete for device: \(deviceID)") - return stream + // Re-validate after a possible suspension: the connection may have dropped while parked. + guard case let .connected(readyPeripheral, readyTx, _, _) = phase, + readyPeripheral.state == .connected, + connectionGeneration == myGeneration else { + throw BLEError.notConnected } - /// Sends data to the connected device. - /// - /// This method serializes concurrent calls - if a write is already in progress, - /// subsequent calls will wait until the previous write completes. - /// - /// - Parameter data: Data to send - /// - Throws: BLEError if not connected or write fails - func send(_ data: Data) async throws { - logger.info("[BLE] send: \(data.count) bytes") - try await claimWriteSlot(data: data) - } - - /// Whether the connected peripheral's write characteristic supports ATT Write Commands. - /// Drives the transport's `supportsWriteWithoutResponse` capability gate. - var supportsWriteWithoutResponse: Bool { txSupportsWriteWithoutResponse } - - /// Test observability: whether a `sendWithoutResponse` caller is currently parked - /// awaiting peripheral readiness. Reflects existing state; does not alter behavior. - var isAwaitingWriteWithoutResponseReady: Bool { writeWithoutResponseReadyContinuation != nil } - - /// Sends data as an unacknowledged ATT Write Command (no `didWriteValue` ACK), which is - /// what lets a caller pipeline back-to-back requests. - /// - /// This path is independent of the `.withResponse` machinery (`pendingWriteContinuation`, - /// `writeTimeoutTask`, `writeWaiters`): flow control is CoreBluetooth's - /// `canSendWriteWithoutResponse` backpressure plus the caller's bounded send window. It - /// deliberately skips `writePacingDelay` — that delay protects the ESP32 RX queue during - /// `.withResponse` bursts, and Write Commands are an nRF52-only path. A radio whose write - /// characteristic does not advertise the capability degrades to the acknowledged path. - func sendWithoutResponse(_ data: Data) async throws { - let myGeneration = connectionGeneration - - guard case .connected(let peripheral, _, _, _) = phase, - peripheral.state == .connected else { - throw BLEError.notConnected + readyPeripheral.writeValue(data, for: readyTx, type: .withoutResponse) + } + + /// Serializes concurrent writes by waiting for any pending write to complete, + /// then claims the write slot and issues the BLE write. + private func claimWriteSlot(data: Data) async throws { + let myGeneration = connectionGeneration + while true { + guard connectionGeneration == myGeneration else { + throw BLEError.notConnected + } + + // Pacing: wait until earliestNextWrite. + if writePacingDelay > 0, ContinuousClock.now < earliestNextWrite { + try await Task.sleep(until: earliestNextWrite, clock: .continuous) + try Task.checkCancellation() + guard connectionGeneration == myGeneration else { + throw BLEError.notConnected } - - guard txSupportsWriteWithoutResponse else { - try await claimWriteSlot(data: data) - return + } + + try Task.checkCancellation() + + guard case let .connected(peripheral, _, _, _) = phase else { + throw BLEError.notConnected + } + + guard peripheral.state == .connected else { + throw BLEError.notConnected + } + + // Wait for any pending write to complete (serializes concurrent sends). + // After waking, loop and re-check slot ownership to avoid + // continuation overwrite if multiple waiters are resumed together. + if pendingWriteContinuation != nil { + consecutiveQueuedWrites += 1 + let queueDepth = writeWaiters.count + 1 + if consecutiveQueuedWrites >= queuePressureThreshold { + logger.warning("[BLE] Write queue pressure: depth=\(queueDepth), consecutive=\(consecutiveQueuedWrites)") + } else { + logger.debug("[BLE] Write queued, depth: \(queueDepth)") } - - try await awaitWriteWithoutResponseReadiness(alreadyReady: peripheral.canSendWriteWithoutResponse) - - // Re-validate after a possible suspension: the connection may have dropped while parked. - guard case .connected(let readyPeripheral, let readyTx, _, _) = phase, - readyPeripheral.state == .connected, - connectionGeneration == myGeneration else { - throw BLEError.notConnected + await withCheckedContinuation { (waiter: CheckedContinuation) in + writeWaiters.append(waiter) } - - readyPeripheral.writeValue(data, for: readyTx, type: .withoutResponse) - } - - /// Serializes concurrent writes by waiting for any pending write to complete, - /// then claims the write slot and issues the BLE write. - private func claimWriteSlot(data: Data) async throws { - let myGeneration = connectionGeneration - while true { - guard connectionGeneration == myGeneration else { - throw BLEError.notConnected - } - - // Pacing: wait until earliestNextWrite. - if writePacingDelay > 0, ContinuousClock.now < earliestNextWrite { - try await Task.sleep(until: earliestNextWrite, clock: .continuous) - try Task.checkCancellation() - guard connectionGeneration == myGeneration else { - throw BLEError.notConnected - } - } - - try Task.checkCancellation() - - guard case .connected(let peripheral, _, _, _) = phase else { - throw BLEError.notConnected - } - - guard peripheral.state == .connected else { - throw BLEError.notConnected - } - - // Wait for any pending write to complete (serializes concurrent sends). - // After waking, loop and re-check slot ownership to avoid - // continuation overwrite if multiple waiters are resumed together. - if pendingWriteContinuation != nil { - consecutiveQueuedWrites += 1 - let queueDepth = writeWaiters.count + 1 - if consecutiveQueuedWrites >= queuePressureThreshold { - logger.warning("[BLE] Write queue pressure: depth=\(queueDepth), consecutive=\(consecutiveQueuedWrites)") - } else { - logger.debug("[BLE] Write queued, depth: \(queueDepth)") - } - await withCheckedContinuation { (waiter: CheckedContinuation) in - writeWaiters.append(waiter) - } - continue - } - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // Revalidate at claim time in case phase changed between loop iterations. - guard case .connected(let currentPeripheral, let currentTx, _, _) = self.phase, - currentPeripheral.state == .connected, - self.pendingWriteContinuation == nil else { - continuation.resume(throwing: BLEError.notConnected) - return - } - - self.writeSequenceNumber += 1 - let currentSeq = self.writeSequenceNumber - self.pendingWriteSequence = currentSeq - self.pendingWriteContinuation = continuation - // Record the sequence before issuing the write so didWriteValue - // can tag its callback with the originating write - self.delegateHandler.recordIssuedWriteSequence(currentSeq) - currentPeripheral.writeValue(data, for: currentTx, type: .withResponse) - - // Cancel any previous timeout task and create a new one - let seq = self.pendingWriteSequence - self.writeTimeoutTask?.cancel() - self.writeTimeoutTask = Task { - try? await Task.sleep(for: .seconds(self.writeTimeout)) - guard !Task.isCancelled else { return } - guard self.pendingWriteSequence == seq else { return } - if let pending = self.pendingWriteContinuation { - self.logger.warning("[BLE] Write timeout: seq=\(seq), elapsed=\(self.writeTimeout)s") - self.pendingWriteContinuation = nil - self.consecutiveQueuedWrites = 0 - pending.resume(throwing: BLEError.operationTimeout) - self.writeTimeoutTask = nil - self.earliestNextWrite = ContinuousClock.now.advanced(by: .seconds(self.writePacingDelay)) - self.resumeNextWriteWaiter() - } - } - } - return + continue + } + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // Revalidate at claim time in case phase changed between loop iterations. + guard case let .connected(currentPeripheral, currentTx, _, _) = self.phase, + currentPeripheral.state == .connected, + self.pendingWriteContinuation == nil else { + continuation.resume(throwing: BLEError.notConnected) + return } - } - - /// Resumes the next task waiting to write. - /// Pacing is enforced in claimWriteSlot via earliestNextWrite. - func resumeNextWriteWaiter() { - guard !writeWaiters.isEmpty else { return } - let waiter = writeWaiters.removeFirst() - waiter.resume() - } - /// Parks the caller until the peripheral can accept another Write Command. Returns - /// immediately when `alreadyReady`. Otherwise suspends on a single continuation that is - /// resumed by `handlePeripheralReadyForWriteWithoutResponse()`, released on teardown by - /// `cancelPendingWriteOperations`, or failed by a timeout backstop — so a peripheral that - /// stops signalling readiness cannot hang the sender past the per-write timeout. - func awaitWriteWithoutResponseReadiness(alreadyReady: Bool) async throws { - guard !alreadyReady else { return } - let timeout = writeTimeout - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // The pipeline issues one Write Command at a time, so the slot is normally free. - // Release any stale waiter rather than leak it if that invariant is ever broken. - if let stale = writeWithoutResponseReadyContinuation { - writeWithoutResponseReadyContinuation = nil - writeWithoutResponseReadyTimeoutTask?.cancel() - writeWithoutResponseReadyTimeoutTask = nil - stale.resume(throwing: BLEError.notConnected) - } - writeWithoutResponseReadyContinuation = continuation - writeWithoutResponseReadyTimeoutTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(timeout)) - guard !Task.isCancelled else { return } - await self?.timeoutWriteWithoutResponseReadiness() - } + self.writeSequenceNumber += 1 + let currentSeq = self.writeSequenceNumber + self.pendingWriteSequence = currentSeq + self.pendingWriteContinuation = continuation + // Record the sequence before issuing the write so didWriteValue + // can tag its callback with the originating write + self.delegateHandler.recordIssuedWriteSequence(currentSeq) + currentPeripheral.writeValue(data, for: currentTx, type: .withResponse) + + // Cancel any previous timeout task and create a new one + let seq = self.pendingWriteSequence + self.writeTimeoutTask?.cancel() + self.writeTimeoutTask = Task { + try? await Task.sleep(for: .seconds(self.writeTimeout)) + guard !Task.isCancelled else { return } + guard self.pendingWriteSequence == seq else { return } + if let pending = self.pendingWriteContinuation { + self.logger.warning("[BLE] Write timeout: seq=\(seq), elapsed=\(self.writeTimeout)s") + self.pendingWriteContinuation = nil + self.consecutiveQueuedWrites = 0 + pending.resume(throwing: BLEError.operationTimeout) + self.writeTimeoutTask = nil + self.earliestNextWrite = ContinuousClock.now.advanced(by: .seconds(self.writePacingDelay)) + self.resumeNextWriteWaiter() + } } + } + return } - - /// Fails a parked `sendWithoutResponse` caller when the peripheral has not re-signalled - /// readiness within the backstop interval. A no-op when no caller is parked. - private func timeoutWriteWithoutResponseReadiness() { - guard let continuation = writeWithoutResponseReadyContinuation else { return } - writeWithoutResponseReadyContinuation = nil - writeWithoutResponseReadyTimeoutTask = nil - logger.warning("[BLE] Write-without-response readiness timeout after \(self.writeTimeout)s") - continuation.resume(throwing: BLEError.operationTimeout) - } - - /// Resumes a parked `sendWithoutResponse` caller when CoreBluetooth reports the peripheral - /// can accept another Write Command. A no-op when no caller is parked. - func handlePeripheralReadyForWriteWithoutResponse() { - guard let continuation = writeWithoutResponseReadyContinuation else { return } + } + + /// Resumes the next task waiting to write. + /// Pacing is enforced in claimWriteSlot via earliestNextWrite. + func resumeNextWriteWaiter() { + guard !writeWaiters.isEmpty else { return } + let waiter = writeWaiters.removeFirst() + waiter.resume() + } + + /// Parks the caller until the peripheral can accept another Write Command. Returns + /// immediately when `alreadyReady`. Otherwise suspends on a single continuation that is + /// resumed by `handlePeripheralReadyForWriteWithoutResponse()`, released on teardown by + /// `cancelPendingWriteOperations`, or failed by a timeout backstop — so a peripheral that + /// stops signalling readiness cannot hang the sender past the per-write timeout. + func awaitWriteWithoutResponseReadiness(alreadyReady: Bool) async throws { + guard !alreadyReady else { return } + let timeout = writeTimeout + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // The pipeline issues one Write Command at a time, so the slot is normally free. + // Release any stale waiter rather than leak it if that invariant is ever broken. + if let stale = writeWithoutResponseReadyContinuation { writeWithoutResponseReadyContinuation = nil writeWithoutResponseReadyTimeoutTask?.cancel() writeWithoutResponseReadyTimeoutTask = nil - continuation.resume() + stale.resume(throwing: BLEError.notConnected) + } + writeWithoutResponseReadyContinuation = continuation + writeWithoutResponseReadyTimeoutTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(timeout)) + guard !Task.isCancelled else { return } + await self?.timeoutWriteWithoutResponseReadiness() + } } - - /// Records whether the resolved write characteristic advertises Write-Without-Response. - /// Called from characteristic discovery; the log doubles as the on-device capability check. - func captureWriteWithoutResponseCapability(from tx: CBCharacteristic) { - txSupportsWriteWithoutResponse = tx.properties.contains(.writeWithoutResponse) - logger.info("[BLE] tx writeWithoutResponse capability: \(self.txSupportsWriteWithoutResponse)") + } + + /// Fails a parked `sendWithoutResponse` caller when the peripheral has not re-signalled + /// readiness within the backstop interval. A no-op when no caller is parked. + private func timeoutWriteWithoutResponseReadiness() { + guard let continuation = writeWithoutResponseReadyContinuation else { return } + writeWithoutResponseReadyContinuation = nil + writeWithoutResponseReadyTimeoutTask = nil + logger.warning("[BLE] Write-without-response readiness timeout after \(writeTimeout)s") + continuation.resume(throwing: BLEError.operationTimeout) + } + + /// Resumes a parked `sendWithoutResponse` caller when CoreBluetooth reports the peripheral + /// can accept another Write Command. A no-op when no caller is parked. + func handlePeripheralReadyForWriteWithoutResponse() { + guard let continuation = writeWithoutResponseReadyContinuation else { return } + writeWithoutResponseReadyContinuation = nil + writeWithoutResponseReadyTimeoutTask?.cancel() + writeWithoutResponseReadyTimeoutTask = nil + continuation.resume() + } + + /// Records whether the resolved write characteristic advertises Write-Without-Response. + /// Called from characteristic discovery; the log doubles as the on-device capability check. + func captureWriteWithoutResponseCapability(from tx: CBCharacteristic) { + txSupportsWriteWithoutResponse = tx.properties.contains(.writeWithoutResponse) + logger.info("[BLE] tx writeWithoutResponse capability: \(txSupportsWriteWithoutResponse)") + } + + /// Gracefully shuts down the state machine, resuming all pending operations with cancellation. + /// Call this before dropping the last reference to the actor. + func shutdown() { + logger.info("[BLE] Shutting down state machine, instance: \(instanceID)") + + stopScanning() + + // Cancel all timeout tasks + bluetoothPowerOffGraceTask?.cancel() + bluetoothPowerOffGraceTask = nil + autoReconnectDiscoveryTimeoutTask?.cancel() + autoReconnectDiscoveryTimeoutTask = nil + serviceDiscoveryTimeoutTask?.cancel() + serviceDiscoveryTimeoutTask = nil + + cancelPendingWriteOperations(error: CancellationError()) + + // Resume any phase continuation with cancellation + switch phase { + case let .waitingForBluetooth(continuation): + continuation.resume(throwing: CancellationError()) + case let .connecting(_, continuation, timeoutTask): + timeoutTask.cancel() + continuation.resume(throwing: CancellationError()) + case let .discoveringServices(_, continuation): + continuation.resume(throwing: CancellationError()) + case let .discoveringCharacteristics(_, _, continuation): + continuation.resume(throwing: CancellationError()) + case let .subscribingToNotifications(_, _, _, continuation): + continuation.resume(throwing: CancellationError()) + case let .connected(_, _, _, dataContinuation): + delegateHandler.setDataContinuation(nil) + dataContinuation.finish() + default: + break } - /// Gracefully shuts down the state machine, resuming all pending operations with cancellation. - /// Call this before dropping the last reference to the actor. - func shutdown() { - logger.info("[BLE] Shutting down state machine, instance: \(instanceID)") - - stopScanning() - - // Cancel all timeout tasks - bluetoothPowerOffGraceTask?.cancel() - bluetoothPowerOffGraceTask = nil - autoReconnectDiscoveryTimeoutTask?.cancel() - autoReconnectDiscoveryTimeoutTask = nil - serviceDiscoveryTimeoutTask?.cancel() - serviceDiscoveryTimeoutTask = nil - - cancelPendingWriteOperations(error: CancellationError()) - - // Resume any phase continuation with cancellation - switch phase { - case .waitingForBluetooth(let continuation): - continuation.resume(throwing: CancellationError()) - case .connecting(_, let continuation, let timeoutTask): - timeoutTask.cancel() - continuation.resume(throwing: CancellationError()) - case .discoveringServices(_, let continuation): - continuation.resume(throwing: CancellationError()) - case .discoveringCharacteristics(_, _, let continuation): - continuation.resume(throwing: CancellationError()) - case .subscribingToNotifications(_, _, _, let continuation): - continuation.resume(throwing: CancellationError()) - case .connected(_, _, _, let dataContinuation): - delegateHandler.setDataContinuation(nil) - dataContinuation.finish() - default: - break - } + let deviceID = phase.deviceID + phase = .idle + phaseStartTime = Date() - let deviceID = phase.deviceID - phase = .idle - phaseStartTime = Date() - - if let deviceID { - onDisconnection?(deviceID, nil) - } + if let deviceID { + onDisconnection?(deviceID, nil) } - - func appDidEnterBackground() { - isAppActive = false - autoReconnectDiscoveryTimeoutTask?.cancel() - autoReconnectDiscoveryTimeoutTask = nil - logger.info("[BLE] App entered background: cancelled auto-reconnect timeout (keepalive persists)") + } + + func appDidEnterBackground() { + isAppActive = false + autoReconnectDiscoveryTimeoutTask?.cancel() + autoReconnectDiscoveryTimeoutTask = nil + logger.info("[BLE] App entered background: cancelled auto-reconnect timeout (keepalive persists)") + } + + func appDidBecomeActive() { + isAppActive = true + logger.info("[BLE] App became active, phase: \(phase.name)") + + // Defensive restart: only if connected but keepalive task died unexpectedly + if case let .connected(peripheral, _, _, _) = phase, rssiKeepaliveTask == nil { + logger.warning("[BLE] Keepalive task died while connected - restarting defensively") + startRSSIKeepalive(for: peripheral) } - func appDidBecomeActive() { - isAppActive = true - logger.info("[BLE] App became active, phase: \(phase.name)") - - // Defensive restart: only if connected but keepalive task died unexpectedly - if case .connected(let peripheral, _, _, _) = phase, rssiKeepaliveTask == nil { - logger.warning("[BLE] Keepalive task died while connected - restarting defensively") - startRSSIKeepalive(for: peripheral) - } - - // Re-arm auto-reconnect timeout if in auto-reconnecting phase - if case .autoReconnecting(let peripheral, _, _) = phase { - phaseStartTime = Date() - armAutoReconnectDiscoveryTimeout( - for: peripheral, - generation: connectionGeneration - ) - logger.info("[BLE] Re-armed auto-reconnect timeout after foreground return") - } + // Re-arm auto-reconnect timeout if in auto-reconnecting phase + if case let .autoReconnecting(peripheral, _, _) = phase { + phaseStartTime = Date() + armAutoReconnectDiscoveryTimeout( + for: peripheral, + generation: connectionGeneration + ) + logger.info("[BLE] Re-armed auto-reconnect timeout after foreground return") } - - func armAutoReconnectDiscoveryTimeout( - for peripheral: CBPeripheral, - generation: UInt64 - ) { - autoReconnectDiscoveryTimeoutTask?.cancel() - logger.info("[BLE] Arming auto-reconnect discovery timeout: \(self.autoReconnectDiscoveryTimeout)s, generation: \(generation), device: \(peripheral.identifier.uuidString.prefix(8))") - autoReconnectDiscoveryTimeoutTask = Task { - try? await Task.sleep(for: .seconds(autoReconnectDiscoveryTimeout)) - guard !Task.isCancelled else { return } - handleAutoReconnectDiscoveryTimeout( - for: peripheral, - generation: generation - ) - } + } + + func armAutoReconnectDiscoveryTimeout( + for peripheral: CBPeripheral, + generation: UInt64 + ) { + autoReconnectDiscoveryTimeoutTask?.cancel() + logger.info("[BLE] Arming auto-reconnect discovery timeout: \(autoReconnectDiscoveryTimeout)s, generation: \(generation), device: \(peripheral.identifier.uuidString.prefix(8))") + autoReconnectDiscoveryTimeoutTask = Task { + try? await Task.sleep(for: .seconds(autoReconnectDiscoveryTimeout)) + guard !Task.isCancelled else { return } + handleAutoReconnectDiscoveryTimeout( + for: peripheral, + generation: generation + ) } + } - // Note: `isolated deinit` would be the ideal safety net here, but it requires - // a deployment target of macOS 15.4 / iOS 18.4+. Since we target iOS 18.0, - // callers must call shutdown() explicitly before dropping the actor reference. + // Note: `isolated deinit` would be the ideal safety net here, but it requires + // a deployment target of macOS 15.4 / iOS 18.4+. Since we target iOS 18.0, + // callers must call shutdown() explicitly before dropping the actor reference. - /// Disconnects from the current device. - func disconnect() async { - logger.info("Disconnect requested") + /// Disconnects from the current device. + func disconnect() async { + logger.info("Disconnect requested") - // Cancel Bluetooth power-off grace period - bluetoothPowerOffGraceTask?.cancel() - bluetoothPowerOffGraceTask = nil + // Cancel Bluetooth power-off grace period + bluetoothPowerOffGraceTask?.cancel() + bluetoothPowerOffGraceTask = nil - // Cancel write timeout task - writeTimeoutTask?.cancel() - writeTimeoutTask = nil + // Cancel write timeout task + writeTimeoutTask?.cancel() + writeTimeoutTask = nil - // Reset queue tracking - consecutiveQueuedWrites = 0 - earliestNextWrite = .now + // Reset queue tracking + consecutiveQueuedWrites = 0 + earliestNextWrite = .now - // Cancel pending write - if let pending = pendingWriteContinuation { - pendingWriteContinuation = nil - pending.resume(throwing: BLEError.notConnected) - } + // Cancel pending write + if let pending = pendingWriteContinuation { + pendingWriteContinuation = nil + pending.resume(throwing: BLEError.notConnected) + } - // Resume all write waiters (they'll fail on the .connected check) - while !writeWaiters.isEmpty { - writeWaiters.removeFirst().resume() - } + // Resume all write waiters (they'll fail on the .connected check) + while !writeWaiters.isEmpty { + writeWaiters.removeFirst().resume() + } - // Get peripheral before cancelling - let peripheral = phase.peripheral + // Get peripheral before cancelling + let peripheral = phase.peripheral - // Cancel current operation - cancelCurrentOperation(with: BLEError.notConnected) + // Cancel current operation + cancelCurrentOperation(with: BLEError.notConnected) - // Disconnect peripheral if connected - if let peripheral, peripheral.state == .connected || peripheral.state == .connecting { - transition(to: .disconnecting(peripheral: peripheral)) - centralManager.cancelPeripheralConnection(peripheral) + // Disconnect peripheral if connected + if let peripheral, peripheral.state == .connected || peripheral.state == .connecting { + transition(to: .disconnecting(peripheral: peripheral)) + centralManager.cancelPeripheralConnection(peripheral) - // Wait briefly for disconnection to complete - try? await Task.sleep(for: .milliseconds(100)) - } - - transition(to: .idle) - logger.info("Disconnect complete") + // Wait briefly for disconnection to complete + try? await Task.sleep(for: .milliseconds(100)) } - /// Switches to a different device. - /// - /// Disconnects from current device (if any) and connects to the new one. - /// - /// - Parameter deviceID: UUID of the new device to connect to - /// - Returns: AsyncStream of data from the new device - /// - Throws: BLEError if connection fails - func switchDevice(to deviceID: UUID) async throws -> AsyncStream { - logger.info("Switch device requested: \(deviceID)") - - // Disconnect current device - await disconnect() - - // Connect to new device - return try await connect(to: deviceID) + transition(to: .idle) + logger.info("Disconnect complete") + } + + /// Switches to a different device. + /// + /// Disconnects from current device (if any) and connects to the new one. + /// + /// - Parameter deviceID: UUID of the new device to connect to + /// - Returns: AsyncStream of data from the new device + /// - Throws: BLEError if connection fails + func switchDevice(to deviceID: UUID) async throws -> AsyncStream { + logger.info("Switch device requested: \(deviceID)") + + // Disconnect current device + await disconnect() + + // Connect to new device + return try await connect(to: deviceID) + } + + private func connectToPeripheral(_ peripheral: CBPeripheral) async throws { + let pState = peripheralStateString(peripheral.state) + logger.info("[BLE] Connecting to peripheral: \(peripheral.identifier.uuidString.prefix(8)), currentState: \(pState), timeout: \(connectionTimeout)s, autoReconnect: enabled") + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // Create timeout task + let timeoutTask = Task { + try? await Task.sleep(for: .seconds(connectionTimeout)) + self.handleConnectionTimeout(for: peripheral) + } + + transition(to: .connecting( + peripheral: peripheral, + continuation: continuation, + timeoutTask: timeoutTask + )) + + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true, + CBConnectPeripheralOptionEnableAutoReconnect: true + ] + centralManager.connect(peripheral, options: options) } + } - private func connectToPeripheral(_ peripheral: CBPeripheral) async throws { - let pState = peripheralStateString(peripheral.state) - logger.info("[BLE] Connecting to peripheral: \(peripheral.identifier.uuidString.prefix(8)), currentState: \(pState), timeout: \(self.connectionTimeout)s, autoReconnect: enabled") - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // Create timeout task - let timeoutTask = Task { - try? await Task.sleep(for: .seconds(connectionTimeout)) - self.handleConnectionTimeout(for: peripheral) - } - - transition(to: .connecting( - peripheral: peripheral, - continuation: continuation, - timeoutTask: timeoutTask - )) - - let options: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true, - CBConnectPeripheralOptionEnableAutoReconnect: true - ] - centralManager.connect(peripheral, options: options) - } + private func handleConnectionTimeout(for peripheral: CBPeripheral) { + guard case let .connecting(expected, continuation, _) = phase, + expected.identifier == peripheral.identifier else { + return // No longer connecting to this peripheral } - private func handleConnectionTimeout(for peripheral: CBPeripheral) { - guard case .connecting(let expected, let continuation, _) = phase, - expected.identifier == peripheral.identifier else { - return // No longer connecting to this peripheral - } - - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - logger.warning("[BLE] Connection timeout: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") - centralManager.cancelPeripheralConnection(peripheral) - transition(to: .idle) - continuation.resume(throwing: BLEError.connectionTimeout) - } + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + logger.warning("[BLE] Connection timeout: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + centralManager.cancelPeripheralConnection(peripheral) + transition(to: .idle) + continuation.resume(throwing: BLEError.connectionTimeout) + } } // MARK: - State Transitions extension BLEStateMachine { - - /// Transitions to a new phase, cleaning up the old phase's resources. - /// - /// - Parameter newPhase: The phase to transition to - /// - Returns: The previous phase (for logging/debugging) - @discardableResult - func transition(to newPhase: BLEPhase) -> BLEPhase { - let oldPhase = phase - let elapsed = Date().timeIntervalSince(phaseStartTime) - let deviceID = oldPhase.deviceID?.uuidString.prefix(8) ?? "none" - logger.info("[BLE] Transition: \(oldPhase.name) → \(newPhase.name), device: \(deviceID), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") - - // Clean up old phase resources (except continuations - caller handles those) - cleanupPhaseResources(oldPhase, newPhase: newPhase) - - phase = newPhase - phaseStartTime = Date() - return oldPhase + /// Transitions to a new phase, cleaning up the old phase's resources. + /// + /// - Parameter newPhase: The phase to transition to + /// - Returns: The previous phase (for logging/debugging) + @discardableResult + func transition(to newPhase: BLEPhase) -> BLEPhase { + let oldPhase = phase + let elapsed = Date().timeIntervalSince(phaseStartTime) + let deviceID = oldPhase.deviceID?.uuidString.prefix(8) ?? "none" + logger.info("[BLE] Transition: \(oldPhase.name) → \(newPhase.name), device: \(deviceID), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + + // Clean up old phase resources (except continuations - caller handles those) + cleanupPhaseResources(oldPhase, newPhase: newPhase) + + phase = newPhase + phaseStartTime = Date() + return oldPhase + } + + /// Starts a periodic RSSI read to keep the BLE connection alive. + /// In foreground, fires every 15s. In background, the task freezes during + /// iOS suspension; when a BLE event wakes the app, the expired sleep resumes + /// and fires an opportunistic RSSI read within the ~10s wake window. + func startRSSIKeepalive(for peripheral: CBPeripheral) { + rssiKeepaliveTask?.cancel() + consecutiveRSSIFailures = 0 + rssiKeepaliveTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(15)) + guard !Task.isCancelled else { break } + peripheral.readRSSI() + } } - - /// Starts a periodic RSSI read to keep the BLE connection alive. - /// In foreground, fires every 15s. In background, the task freezes during - /// iOS suspension; when a BLE event wakes the app, the expired sleep resumes - /// and fires an opportunistic RSSI read within the ~10s wake window. - func startRSSIKeepalive(for peripheral: CBPeripheral) { - rssiKeepaliveTask?.cancel() - consecutiveRSSIFailures = 0 - rssiKeepaliveTask = Task { - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(15)) - guard !Task.isCancelled else { break } - peripheral.readRSSI() - } - } + } + + /// Cleans up non-continuation resources owned by a phase. + /// + /// Timeout cancellation is phase-aware: + /// - Discovery timeout is preserved when transitioning within the discovery chain + /// - Auto-reconnect timeout is preserved when staying in auto-reconnect + private func cleanupPhaseResources(_ oldPhase: BLEPhase, newPhase: BLEPhase) { + // Only cancel discovery timeout when leaving the discovery chain + if !newPhase.isDiscoveryChain { + serviceDiscoveryTimeoutTask?.cancel() + serviceDiscoveryTimeoutTask = nil } - /// Cleans up non-continuation resources owned by a phase. - /// - /// Timeout cancellation is phase-aware: - /// - Discovery timeout is preserved when transitioning within the discovery chain - /// - Auto-reconnect timeout is preserved when staying in auto-reconnect - private func cleanupPhaseResources(_ oldPhase: BLEPhase, newPhase: BLEPhase) { - // Only cancel discovery timeout when leaving the discovery chain - if !newPhase.isDiscoveryChain { - serviceDiscoveryTimeoutTask?.cancel() - serviceDiscoveryTimeoutTask = nil - } - - // Only cancel auto-reconnect timeout when leaving auto-reconnect - if case .autoReconnecting = newPhase { - // preserve - } else { - autoReconnectDiscoveryTimeoutTask?.cancel() - autoReconnectDiscoveryTimeoutTask = nil - } + // Only cancel auto-reconnect timeout when leaving auto-reconnect + if case .autoReconnecting = newPhase { + // preserve + } else { + autoReconnectDiscoveryTimeoutTask?.cancel() + autoReconnectDiscoveryTimeoutTask = nil + } - switch oldPhase { - case .connecting(_, _, let timeoutTask): - timeoutTask.cancel() + switch oldPhase { + case let .connecting(_, _, timeoutTask): + timeoutTask.cancel() - case .connected(_, _, _, let dataContinuation): - rssiKeepaliveTask?.cancel() - rssiKeepaliveTask = nil - consecutiveRSSIFailures = 0 - // Clear delegate handler's continuation first to stop data flow - delegateHandler.setDataContinuation(nil) - dataContinuation.finish() + case let .connected(_, _, _, dataContinuation): + rssiKeepaliveTask?.cancel() + rssiKeepaliveTask = nil + consecutiveRSSIFailures = 0 + // Clear delegate handler's continuation first to stop data flow + delegateHandler.setDataContinuation(nil) + dataContinuation.finish() - default: - break - } + default: + break + } + } + + /// Cancels pending write operations and write waiters without touching phase state. + /// Used when transitioning to auto-reconnect where we need to clean up writes + /// but handle the phase continuation separately. + func cancelPendingWriteOperations(error: Error = BLEError.notConnected) { + writeTimeoutTask?.cancel() + writeTimeoutTask = nil + consecutiveQueuedWrites = 0 + earliestNextWrite = .now + // Outstanding write callbacks either never arrive (link dropped) or + // find no continuation; their recorded sequences must not survive to + // mis-tag the next connection's first write callback. + delegateHandler.clearIssuedWriteSequences() + + if let pending = pendingWriteContinuation { + pendingWriteContinuation = nil + pending.resume(throwing: error) } - /// Cancels pending write operations and write waiters without touching phase state. - /// Used when transitioning to auto-reconnect where we need to clean up writes - /// but handle the phase continuation separately. - func cancelPendingWriteOperations(error: Error = BLEError.notConnected) { - writeTimeoutTask?.cancel() - writeTimeoutTask = nil - consecutiveQueuedWrites = 0 - earliestNextWrite = .now - // Outstanding write callbacks either never arrive (link dropped) or - // find no continuation; their recorded sequences must not survive to - // mis-tag the next connection's first write callback. - delegateHandler.clearIssuedWriteSequences() - - if let pending = pendingWriteContinuation { - pendingWriteContinuation = nil - pending.resume(throwing: error) - } - - while !writeWaiters.isEmpty { - writeWaiters.removeFirst().resume() - } - - // Release a Write-Command sender parked on backpressure so it doesn't hang past the drop. - handlePeripheralReadyForWriteWithoutResponse() + while !writeWaiters.isEmpty { + writeWaiters.removeFirst().resume() } - /// Cancels the current operation, resuming any pending continuation with an error. - /// - /// - Parameter error: The error to resume continuations with - func cancelCurrentOperation(with error: Error) { - logger.warning("[BLE] cancelCurrentOperation: phase=\(self.phase.name), error=\(error.localizedDescription)") - cancelPendingWriteOperations(error: error) + // Release a Write-Command sender parked on backpressure so it doesn't hang past the drop. + handlePeripheralReadyForWriteWithoutResponse() + } - switch phase { - case .waitingForBluetooth(let continuation): - continuation.resume(throwing: error) + /// Cancels the current operation, resuming any pending continuation with an error. + /// + /// - Parameter error: The error to resume continuations with + func cancelCurrentOperation(with error: Error) { + logger.warning("[BLE] cancelCurrentOperation: phase=\(phase.name), error=\(error.localizedDescription)") + cancelPendingWriteOperations(error: error) - case .connecting(_, let continuation, let timeoutTask): - timeoutTask.cancel() - continuation.resume(throwing: error) + switch phase { + case let .waitingForBluetooth(continuation): + continuation.resume(throwing: error) - case .discoveringServices(_, let continuation): - continuation.resume(throwing: error) + case let .connecting(_, continuation, timeoutTask): + timeoutTask.cancel() + continuation.resume(throwing: error) - case .discoveringCharacteristics(_, _, let continuation): - continuation.resume(throwing: error) + case let .discoveringServices(_, continuation): + continuation.resume(throwing: error) - case .subscribingToNotifications(_, _, _, let continuation): - continuation.resume(throwing: error) + case let .discoveringCharacteristics(_, _, continuation): + continuation.resume(throwing: error) - case .connected(_, _, _, let dataContinuation): - dataContinuation.finish() + case let .subscribingToNotifications(_, _, _, continuation): + continuation.resume(throwing: error) - case .discoveryComplete: - // Continuation already consumed — nothing to resume - break + case let .connected(_, _, _, dataContinuation): + dataContinuation.finish() - case .idle, .autoReconnecting, .restoringState, .disconnecting: - break - } + case .discoveryComplete: + // Continuation already consumed — nothing to resume + break - transition(to: .idle) + case .idle, .autoReconnecting, .restoringState, .disconnecting: + break } - /// Cancels connection to a peripheral if we're not expecting it. - func cancelUnexpectedPeripheral(_ peripheral: CBPeripheral) { - logger.warning("Cancelling unexpected peripheral: \(peripheral.identifier)") - centralManager.cancelPeripheralConnection(peripheral) + transition(to: .idle) + } + + /// Cancels connection to a peripheral if we're not expecting it. + func cancelUnexpectedPeripheral(_ peripheral: CBPeripheral) { + logger.warning("Cancelling unexpected peripheral: \(peripheral.identifier)") + centralManager.cancelPeripheralConnection(peripheral) + } + + /// Decides whether a discovery-phase watchdog should extend its window + /// rather than tear the link down. When the peripheral is already + /// `.connected` the BLE link is up and a `didConnect`/discovery callback is + /// in flight or merely slow; tearing it down kills a working reconnection. + /// Extend a bounded number of times so a genuinely wedged link still + /// recovers via reconnect. + static func shouldExtendDiscoveryTimeout( + peripheralState: CBPeripheralState, + extensions: Int, + maxExtensions: Int + ) -> Bool { + peripheralState == .connected && extensions < maxExtensions + } + + /// Counts one watchdog deferral against the current generation's budget. + func recordDiscoveryTimeoutExtension() { + discoveryTimeoutExtensions += 1 + } + + func armServiceDiscoveryTimeout(for peripheral: CBPeripheral) { + serviceDiscoveryTimeoutTask?.cancel() + serviceDiscoveryTimeoutTask = Task { + try? await Task.sleep(for: .seconds(serviceDiscoveryTimeout)) + guard !Task.isCancelled else { return } + handleServiceDiscoveryTimeout(for: peripheral) } - - /// Decides whether a discovery-phase watchdog should extend its window - /// rather than tear the link down. When the peripheral is already - /// `.connected` the BLE link is up and a `didConnect`/discovery callback is - /// in flight or merely slow; tearing it down kills a working reconnection. - /// Extend a bounded number of times so a genuinely wedged link still - /// recovers via reconnect. - static func shouldExtendDiscoveryTimeout( - peripheralState: CBPeripheralState, - extensions: Int, - maxExtensions: Int - ) -> Bool { - peripheralState == .connected && extensions < maxExtensions + } + + func handleServiceDiscoveryTimeout(for peripheral: CBPeripheral) { + // Guard against stale timeout: if the normal path already cleared the + // task reference, this timeout fired after cancellation took effect. + guard serviceDiscoveryTimeoutTask != nil else { return } + + switch phase { + case let .discoveringServices(p, c), + let .discoveringCharacteristics(p, _, c), + let .subscribingToNotifications(p, _, _, c): + guard p.identifier == peripheral.identifier else { return } + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + logger.warning("[BLE] Service discovery timeout: \(peripheral.identifier.uuidString.prefix(8)), phase: \(phase.name), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") + + if Self.shouldExtendDiscoveryTimeout( + peripheralState: peripheral.state, + extensions: discoveryTimeoutExtensions, + maxExtensions: Self.maxDiscoveryTimeoutExtensions + ) { + recordDiscoveryTimeoutExtension() + logger.warning("[BLE] Peripheral still connected; extending service discovery window (\(discoveryTimeoutExtensions)/\(Self.maxDiscoveryTimeoutExtensions)) instead of failing") + armServiceDiscoveryTimeout(for: peripheral) + return + } + + centralManager.cancelPeripheralConnection(peripheral) + transition(to: .idle) + c.resume(throwing: BLEError.connectionTimeout) + default: + break } + } - /// Counts one watchdog deferral against the current generation's budget. - func recordDiscoveryTimeoutExtension() { - discoveryTimeoutExtensions += 1 - } + private func handleAutoReconnectDiscoveryTimeout(for peripheral: CBPeripheral, generation: UInt64) { + // Guard against stale timeout: if the normal path already cleared the + // task reference, this timeout fired after cancellation took effect. + guard autoReconnectDiscoveryTimeoutTask != nil else { return } - func armServiceDiscoveryTimeout(for peripheral: CBPeripheral) { - serviceDiscoveryTimeoutTask?.cancel() - serviceDiscoveryTimeoutTask = Task { - try? await Task.sleep(for: .seconds(serviceDiscoveryTimeout)) - guard !Task.isCancelled else { return } - handleServiceDiscoveryTimeout(for: peripheral) - } + // Skip timeout enforcement while app is inactive + guard isAppActive else { + logger.info("[BLE] Skipping auto-reconnect timeout while app inactive") + return } - func handleServiceDiscoveryTimeout(for peripheral: CBPeripheral) { - // Guard against stale timeout: if the normal path already cleared the - // task reference, this timeout fired after cancellation took effect. - guard serviceDiscoveryTimeoutTask != nil else { return } - - switch phase { - case .discoveringServices(let p, let c), - .discoveringCharacteristics(let p, _, let c), - .subscribingToNotifications(let p, _, _, let c): - guard p.identifier == peripheral.identifier else { return } - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - logger.warning("[BLE] Service discovery timeout: \(peripheral.identifier.uuidString.prefix(8)), phase: \(self.phase.name), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s") - - if Self.shouldExtendDiscoveryTimeout( - peripheralState: peripheral.state, - extensions: discoveryTimeoutExtensions, - maxExtensions: Self.maxDiscoveryTimeoutExtensions - ) { - recordDiscoveryTimeoutExtension() - logger.warning("[BLE] Peripheral still connected; extending service discovery window (\(self.discoveryTimeoutExtensions)/\(Self.maxDiscoveryTimeoutExtensions)) instead of failing") - armServiceDiscoveryTimeout(for: peripheral) - return - } - - centralManager.cancelPeripheralConnection(peripheral) - transition(to: .idle) - c.resume(throwing: BLEError.connectionTimeout) - default: - break - } + // Reject stale timeout from a previous generation + if generation != connectionGeneration { + logger.info("[BLE] Ignoring stale auto-reconnect timeout for generation \(generation)") + return } - private func handleAutoReconnectDiscoveryTimeout(for peripheral: CBPeripheral, generation: UInt64) { - // Guard against stale timeout: if the normal path already cleared the - // task reference, this timeout fired after cancellation took effect. - guard autoReconnectDiscoveryTimeoutTask != nil else { return } - - // Skip timeout enforcement while app is inactive - guard isAppActive else { - logger.info("[BLE] Skipping auto-reconnect timeout while app inactive") - return - } - - // Reject stale timeout from a previous generation - if generation != connectionGeneration { - logger.info("[BLE] Ignoring stale auto-reconnect timeout for generation \(generation)") - return - } - - guard case .autoReconnecting(let expected, _, _) = phase, - expected.identifier == peripheral.identifier else { - return - } - - let pState = peripheralStateString(peripheral.state) - let elapsed = Date().timeIntervalSince(phaseStartTime) - logger.warning( - "[BLE] Auto-reconnect discovery timeout: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s" - ) - - // iOS may flip the peripheral to .connected before delivering didConnect. - // Tearing the link down here cancels a reconnection that actually - // succeeded, after which the late didConnect lands in .idle and is - // rejected. Give discovery another window instead of destroying it. - if Self.shouldExtendDiscoveryTimeout( - peripheralState: peripheral.state, - extensions: discoveryTimeoutExtensions, - maxExtensions: Self.maxDiscoveryTimeoutExtensions - ) { - recordDiscoveryTimeoutExtension() - logger.warning("[BLE] Peripheral still connected; extending auto-reconnect discovery window (\(self.discoveryTimeoutExtensions)/\(Self.maxDiscoveryTimeoutExtensions)) instead of tearing down") - armAutoReconnectDiscoveryTimeout(for: peripheral, generation: generation) - return - } + guard case let .autoReconnecting(expected, _, _) = phase, + expected.identifier == peripheral.identifier else { + return + } - centralManager.cancelPeripheralConnection(peripheral) - transition(to: .idle) - onDisconnection?(peripheral.identifier, BLEError.connectionTimeout) + let pState = peripheralStateString(peripheral.state) + let elapsed = Date().timeIntervalSince(phaseStartTime) + logger.warning( + "[BLE] Auto-reconnect discovery timeout: \(peripheral.identifier.uuidString.prefix(8)), peripheralState: \(pState), elapsed: \(elapsed.formatted(.number.precision(.fractionLength(2))))s" + ) + + // iOS may flip the peripheral to .connected before delivering didConnect. + // Tearing the link down here cancels a reconnection that actually + // succeeded, after which the late didConnect lands in .idle and is + // rejected. Give discovery another window instead of destroying it. + if Self.shouldExtendDiscoveryTimeout( + peripheralState: peripheral.state, + extensions: discoveryTimeoutExtensions, + maxExtensions: Self.maxDiscoveryTimeoutExtensions + ) { + recordDiscoveryTimeoutExtension() + logger.warning("[BLE] Peripheral still connected; extending auto-reconnect discovery window (\(discoveryTimeoutExtensions)/\(Self.maxDiscoveryTimeoutExtensions)) instead of tearing down") + armAutoReconnectDiscoveryTimeout(for: peripheral, generation: generation) + return } + + centralManager.cancelPeripheralConnection(peripheral) + transition(to: .idle) + onDisconnection?(peripheral.identifier, BLEError.connectionTimeout) + } } diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachineProtocol.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachineProtocol.swift index 373acbc8..97a132bb 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachineProtocol.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachineProtocol.swift @@ -6,91 +6,90 @@ import Foundation /// Protocol for BLE state machine operations used by ConnectionManager. /// Enables dependency injection for testing. public protocol BLEStateMachineProtocol: Actor { + // MARK: - State Properties - // MARK: - State Properties + /// Whether the state machine is currently connected to a device + var isConnected: Bool { get } - /// Whether the state machine is currently connected to a device - var isConnected: Bool { get } + /// Whether the state machine is currently handling iOS auto-reconnect or state restoration + var isAutoReconnecting: Bool { get } - /// Whether the state machine is currently handling iOS auto-reconnect or state restoration - var isAutoReconnecting: Bool { get } + /// UUID of the currently connected device, or nil if not connected + var connectedDeviceID: UUID? { get } - /// UUID of the currently connected device, or nil if not connected - var connectedDeviceID: UUID? { get } + /// Current phase name for diagnostic logging + var currentPhaseName: String { get } - /// Current phase name for diagnostic logging - var currentPhaseName: String { get } + /// Current peripheral state for diagnostic logging (nil if no peripheral) + var currentPeripheralState: String? { get } - /// Current peripheral state for diagnostic logging (nil if no peripheral) - var currentPeripheralState: String? { get } + /// Current CBCentralManager state name for diagnostic logging + var centralManagerStateName: String { get } - /// Current CBCentralManager state name for diagnostic logging - var centralManagerStateName: String { get } + /// Whether the Bluetooth central manager is in the powered-off state. + var isBluetoothPoweredOff: Bool { get } - /// Whether the Bluetooth central manager is in the powered-off state. - var isBluetoothPoweredOff: Bool { get } + // MARK: - Methods - // MARK: - Methods + /// Checks if a device is connected to the system (possibly by another app). + /// - Parameter deviceID: The UUID of the device to check + /// - Returns: `true` if the device is connected to the system + func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool - /// Checks if a device is connected to the system (possibly by another app). - /// - Parameter deviceID: The UUID of the device to check - /// - Returns: `true` if the device is connected to the system - func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool + /// Returns the UUIDs of all peripherals currently connected to the system via Nordic UART. + /// Used for diagnostics — exposes the raw `retrieveConnectedPeripherals` result. + func systemConnectedPeripheralIDs() -> [UUID] - /// Returns the UUIDs of all peripherals currently connected to the system via Nordic UART. - /// Used for diagnostics — exposes the raw `retrieveConnectedPeripherals` result. - func systemConnectedPeripheralIDs() -> [UUID] + /// Starts a best-effort adoption of an already system-connected peripheral. + /// + /// This is used to recover from cases where iOS keeps the BLE link alive across app termination + /// (e.g., app update) but state restoration did not run. The adoption uses the same discovery + /// chain as state restoration/auto-reconnect. + /// + /// - Parameter deviceID: The UUID of the device to adopt. + /// - Returns: `true` if an adoption attempt was started. + func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool - /// Starts a best-effort adoption of an already system-connected peripheral. - /// - /// This is used to recover from cases where iOS keeps the BLE link alive across app termination - /// (e.g., app update) but state restoration did not run. The adoption uses the same discovery - /// chain as state restoration/auto-reconnect. - /// - /// - Parameter deviceID: The UUID of the device to adopt. - /// - Returns: `true` if an adoption attempt was started. - func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool + /// Activates the central manager if needed. + func activate() - /// Activates the central manager if needed. - func activate() + /// Sets a handler for auto-reconnecting events. + /// Called when device disconnects but iOS is attempting automatic reconnection. + /// - Parameters: + /// - deviceID: The disconnected device ID. + /// - errorInfo: Best-effort error summary from CoreBluetooth. + func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) - /// Sets a handler for auto-reconnecting events. - /// Called when device disconnects but iOS is attempting automatic reconnection. - /// - Parameters: - /// - deviceID: The disconnected device ID. - /// - errorInfo: Best-effort error summary from CoreBluetooth. - func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) + /// Sets a handler called when Bluetooth powers on + func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) - /// Sets a handler called when Bluetooth powers on - func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) + /// Sets a handler for Bluetooth state changes + func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) - /// Sets a handler for Bluetooth state changes - func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) + /// Sets the delay between write operations for pacing. + func setWritePacingDelay(_ delay: TimeInterval) - /// Sets the delay between write operations for pacing. - func setWritePacingDelay(_ delay: TimeInterval) + /// Sets a handler called when a device is discovered during scanning. + func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) - /// Sets a handler called when a device is discovered during scanning. - func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) + /// Starts scanning for BLE peripherals. Works while connected. + func startScanning() - /// Starts scanning for BLE peripherals. Works while connected. - func startScanning() + /// Stops an active BLE scan. + func stopScanning() - /// Stops an active BLE scan. - func stopScanning() + /// Gracefully shuts down the state machine, resuming all pending operations. + /// Call before dropping the last reference to the actor. + func shutdown() - /// Gracefully shuts down the state machine, resuming all pending operations. - /// Call before dropping the last reference to the actor. - func shutdown() + /// Notifies the state machine that the app entered background. + /// Cancels foreground-only timeouts (auto-reconnect discovery) while + /// preserving the RSSI keepalive for background connection maintenance. + func appDidEnterBackground() - /// Notifies the state machine that the app entered background. - /// Cancels foreground-only timeouts (auto-reconnect discovery) while - /// preserving the RSSI keepalive for background connection maintenance. - func appDidEnterBackground() - - /// Notifies the state machine that the app became active. - /// Defensively restarts RSSI keepalive if connected, and re-arms - /// auto-reconnect discovery timeout with generation fencing if in - /// the auto-reconnecting phase. - func appDidBecomeActive() + /// Notifies the state machine that the app became active. + /// Defensively restarts RSSI keepalive if connected, and re-arms + /// auto-reconnect discovery timeout with generation fencing if in + /// the auto-reconnecting phase. + func appDidBecomeActive() } diff --git a/MC1Services/Sources/MC1Services/Transport/BluetoothAvailability.swift b/MC1Services/Sources/MC1Services/Transport/BluetoothAvailability.swift index ce194d66..48daf098 100644 --- a/MC1Services/Sources/MC1Services/Transport/BluetoothAvailability.swift +++ b/MC1Services/Sources/MC1Services/Transport/BluetoothAvailability.swift @@ -4,10 +4,10 @@ /// powered off, or the app is not authorized to use Bluetooth. Transient and unsupported states map /// to `.ready` so a picker keeps showing its scanning state rather than a remedy the user cannot act on. public enum BluetoothAvailability: Sendable { - /// Bluetooth is usable, or in a transient state expected to resolve on its own. - case ready - /// Bluetooth is powered off; the user can turn it on in system settings. - case poweredOff - /// The app lacks Bluetooth permission; the user can grant it in system settings. - case unauthorized + /// Bluetooth is usable, or in a transient state expected to resolve on its own. + case ready + /// Bluetooth is powered off; the user can turn it on in system settings. + case poweredOff + /// The app lacks Bluetooth permission; the user can grant it in system settings. + case unauthorized } diff --git a/MC1Services/Sources/MC1Services/Transport/DiscoveredDevice.swift b/MC1Services/Sources/MC1Services/Transport/DiscoveredDevice.swift index 3b3b2447..841d5699 100644 --- a/MC1Services/Sources/MC1Services/Transport/DiscoveredDevice.swift +++ b/MC1Services/Sources/MC1Services/Transport/DiscoveredDevice.swift @@ -7,16 +7,16 @@ import Foundation /// CoreBluetooth identifier used to connect, the advertised name to show the user, /// and the signal strength. public struct DiscoveredDevice: Identifiable, Sendable, Equatable { - /// The peripheral's CoreBluetooth identifier. Becomes `Device.id` on connect. - public let id: UUID - /// The advertised local name, if the peripheral published one. - public let name: String? - /// The received signal strength indicator, in dBm. - public let rssi: Int + /// The peripheral's CoreBluetooth identifier. Becomes `Device.id` on connect. + public let id: UUID + /// The advertised local name, if the peripheral published one. + public let name: String? + /// The received signal strength indicator, in dBm. + public let rssi: Int - public init(id: UUID, name: String?, rssi: Int) { - self.id = id - self.name = name - self.rssi = rssi - } + public init(id: UUID, name: String?, rssi: Int) { + self.id = id + self.name = name + self.rssi = rssi + } } diff --git a/MC1Services/Sources/MC1Services/Transport/iOSBLETransport.swift b/MC1Services/Sources/MC1Services/Transport/iOSBLETransport.swift index c76ddcb4..43fbc59e 100644 --- a/MC1Services/Sources/MC1Services/Transport/iOSBLETransport.swift +++ b/MC1Services/Sources/MC1Services/Transport/iOSBLETransport.swift @@ -26,152 +26,151 @@ import os /// try await transport.switchDevice(to: otherDeviceUUID) /// ``` actor iOSBLETransport: MeshTransport { - - private let logger = PersistentLogger(subsystem: "com.mc1", category: "iOSBLETransport") - - private let stateMachine: BLEStateMachine - private var deviceID: UUID? - - /// Lock-protected data stream storage. - /// Using a lock allows synchronous access from BLEStateMachine callbacks without spawning Tasks. - private let dataStreamLock = OSAllocatedUnfairLock?>(initialState: nil) - - // MARK: - Initialization - - /// Creates an iOS BLE transport with an optional shared state machine. - /// - /// - Parameter stateMachine: The BLE state machine to use. If nil, creates a new one. - init(stateMachine: BLEStateMachine? = nil) { - self.stateMachine = stateMachine ?? BLEStateMachine() - } - - // MARK: - Configuration - - /// Sets the device UUID to connect to. - /// - /// - Parameter id: The UUID of the BLE device. - func setDeviceID(_ id: UUID) { - deviceID = id - } - - /// Sets a handler for disconnection events. - /// - /// - Parameter handler: Called when the device disconnects, with the device ID and optional error. - func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) async { - await stateMachine.setDisconnectionHandler(handler) - } - - /// Sets a handler for reconnection events. - /// - /// When iOS auto-reconnect completes, the transport captures the data stream - /// and then calls your handler. The `receivedData` property will be ready - /// when your handler is called. - /// - /// - Parameter handler: Called when iOS auto-reconnect completes successfully. - func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) async { - await stateMachine.setReconnectionHandler { [dataStreamLock, logger] deviceID, stream in - // Capture the stream synchronously using lock (no Task spawning). - // This ensures the stream is available before any handler code runs. - logger.info("[BLE] Auto-reconnect stream captured for device: \(deviceID.uuidString.prefix(8))") - dataStreamLock.withLock { $0 = stream } - handler(deviceID) - } - } - - // MARK: - MeshTransport Protocol - - /// Whether the transport is currently connected to a device. - var isConnected: Bool { - get async { await stateMachine.isConnected } - } - - /// Async stream of data received from the connected device. - /// - /// Returns an empty stream if not connected. - var receivedData: AsyncStream { - dataStreamLock.withLock { $0 } ?? AsyncStream { $0.finish() } - } - - /// Connects to the configured device. - /// - /// This method is idempotent: if already connected to the same device, - /// it returns without error. Use ``switchDevice(to:)`` to change devices. - /// - /// - Throws: `BLEError.deviceNotFound` if no device ID is set. - /// - Throws: `BLEError.connectionFailed` if connected to a different device. - /// - Throws: `BLEError` for connection failures. - func connect() async throws { - let connectedID = await stateMachine.connectedDeviceID - let effectiveDeviceID = self.deviceID ?? connectedID - - guard let deviceID = effectiveDeviceID else { - logger.warning("[BLE] connect() called with no device ID set") - throw BLEError.deviceNotFound - } - - // Already connected - check if it's the same device - if await stateMachine.isConnected { - if connectedID == deviceID { - logger.info("Already connected to device: \(deviceID)") - return - } else { - throw BLEError.connectionFailed("Already connected to different device: \(connectedID?.uuidString ?? "unknown"). Use switchDevice() instead.") - } - } - - logger.info("Connecting to device: \(deviceID)") - let stream = try await stateMachine.connect(to: deviceID) - logger.info("[BLE] Stream captured for device: \(deviceID.uuidString.prefix(8))") - dataStreamLock.withLock { $0 = stream } - } - - /// Disconnects from the current device. - func disconnect() async { - logger.info("Disconnecting") - await stateMachine.disconnect() - dataStreamLock.withLock { $0 = nil } + private let logger = PersistentLogger(subsystem: "com.mc1", category: "iOSBLETransport") + + private let stateMachine: BLEStateMachine + private var deviceID: UUID? + + /// Lock-protected data stream storage. + /// Using a lock allows synchronous access from BLEStateMachine callbacks without spawning Tasks. + private let dataStreamLock = OSAllocatedUnfairLock?>(initialState: nil) + + // MARK: - Initialization + + /// Creates an iOS BLE transport with an optional shared state machine. + /// + /// - Parameter stateMachine: The BLE state machine to use. If nil, creates a new one. + init(stateMachine: BLEStateMachine? = nil) { + self.stateMachine = stateMachine ?? BLEStateMachine() + } + + // MARK: - Configuration + + /// Sets the device UUID to connect to. + /// + /// - Parameter id: The UUID of the BLE device. + func setDeviceID(_ id: UUID) { + deviceID = id + } + + /// Sets a handler for disconnection events. + /// + /// - Parameter handler: Called when the device disconnects, with the device ID and optional error. + func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) async { + await stateMachine.setDisconnectionHandler(handler) + } + + /// Sets a handler for reconnection events. + /// + /// When iOS auto-reconnect completes, the transport captures the data stream + /// and then calls your handler. The `receivedData` property will be ready + /// when your handler is called. + /// + /// - Parameter handler: Called when iOS auto-reconnect completes successfully. + func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) async { + await stateMachine.setReconnectionHandler { [dataStreamLock, logger] deviceID, stream in + // Capture the stream synchronously using lock (no Task spawning). + // This ensures the stream is available before any handler code runs. + logger.info("[BLE] Auto-reconnect stream captured for device: \(deviceID.uuidString.prefix(8))") + dataStreamLock.withLock { $0 = stream } + handler(deviceID) } - - /// Sends data to the connected device. - /// - /// - Parameter data: The data to send. - /// - Throws: `BLEError.notConnected` if not connected. - /// - Throws: `BLEError.writeError` if the write fails. - func send(_ data: Data) async throws { - try await stateMachine.send(data) - } - - /// Whether the connected radio's write characteristic advertises Write-Without-Response. - var supportsWriteWithoutResponse: Bool { - get async { await stateMachine.supportsWriteWithoutResponse } + } + + // MARK: - MeshTransport Protocol + + /// Whether the transport is currently connected to a device. + var isConnected: Bool { + get async { await stateMachine.isConnected } + } + + /// Async stream of data received from the connected device. + /// + /// Returns an empty stream if not connected. + var receivedData: AsyncStream { + dataStreamLock.withLock { $0 } ?? AsyncStream { $0.finish() } + } + + /// Connects to the configured device. + /// + /// This method is idempotent: if already connected to the same device, + /// it returns without error. Use ``switchDevice(to:)`` to change devices. + /// + /// - Throws: `BLEError.deviceNotFound` if no device ID is set. + /// - Throws: `BLEError.connectionFailed` if connected to a different device. + /// - Throws: `BLEError` for connection failures. + func connect() async throws { + let connectedID = await stateMachine.connectedDeviceID + let effectiveDeviceID = deviceID ?? connectedID + + guard let deviceID = effectiveDeviceID else { + logger.warning("[BLE] connect() called with no device ID set") + throw BLEError.deviceNotFound } - /// Sends data as an unacknowledged ATT Write Command, enabling request pipelining. - /// - /// - Parameter data: The data to send. - /// - Throws: `BLEError.notConnected` if not connected. - func sendWithoutResponse(_ data: Data) async throws { - try await stateMachine.sendWithoutResponse(data) + // Already connected - check if it's the same device + if await stateMachine.isConnected { + if connectedID == deviceID { + logger.info("Already connected to device: \(deviceID)") + return + } else { + throw BLEError.connectionFailed("Already connected to different device: \(connectedID?.uuidString ?? "unknown"). Use switchDevice() instead.") + } } - // MARK: - Extended API - - /// UUID of the currently connected device, or nil if not connected. - var connectedDeviceID: UUID? { - get async { await stateMachine.connectedDeviceID } - } - - /// Switches to a different device. - /// - /// Disconnects from the current device (if any) and connects to the new one. - /// More efficient than separate disconnect/connect calls. - /// - /// - Parameter deviceID: UUID of the new device to connect to. - /// - Throws: `BLEError` if connection fails. - func switchDevice(to deviceID: UUID) async throws { - logger.info("Switching to device: \(deviceID)") - self.deviceID = deviceID - let stream = try await stateMachine.switchDevice(to: deviceID) - dataStreamLock.withLock { $0 = stream } - } + logger.info("Connecting to device: \(deviceID)") + let stream = try await stateMachine.connect(to: deviceID) + logger.info("[BLE] Stream captured for device: \(deviceID.uuidString.prefix(8))") + dataStreamLock.withLock { $0 = stream } + } + + /// Disconnects from the current device. + func disconnect() async { + logger.info("Disconnecting") + await stateMachine.disconnect() + dataStreamLock.withLock { $0 = nil } + } + + /// Sends data to the connected device. + /// + /// - Parameter data: The data to send. + /// - Throws: `BLEError.notConnected` if not connected. + /// - Throws: `BLEError.writeError` if the write fails. + func send(_ data: Data) async throws { + try await stateMachine.send(data) + } + + /// Whether the connected radio's write characteristic advertises Write-Without-Response. + var supportsWriteWithoutResponse: Bool { + get async { await stateMachine.supportsWriteWithoutResponse } + } + + /// Sends data as an unacknowledged ATT Write Command, enabling request pipelining. + /// + /// - Parameter data: The data to send. + /// - Throws: `BLEError.notConnected` if not connected. + func sendWithoutResponse(_ data: Data) async throws { + try await stateMachine.sendWithoutResponse(data) + } + + // MARK: - Extended API + + /// UUID of the currently connected device, or nil if not connected. + var connectedDeviceID: UUID? { + get async { await stateMachine.connectedDeviceID } + } + + /// Switches to a different device. + /// + /// Disconnects from the current device (if any) and connects to the new one. + /// More efficient than separate disconnect/connect calls. + /// + /// - Parameter deviceID: UUID of the new device to connect to. + /// - Throws: `BLEError` if connection fails. + func switchDevice(to deviceID: UUID) async throws { + logger.info("Switching to device: \(deviceID)") + self.deviceID = deviceID + let stream = try await stateMachine.switchDevice(to: deviceID) + dataStreamLock.withLock { $0 = stream } + } } diff --git a/MC1Services/Sources/MC1Services/Transport/iOSMeshTransport.swift b/MC1Services/Sources/MC1Services/Transport/iOSMeshTransport.swift index 40bff585..e1c16f6f 100644 --- a/MC1Services/Sources/MC1Services/Transport/iOSMeshTransport.swift +++ b/MC1Services/Sources/MC1Services/Transport/iOSMeshTransport.swift @@ -6,10 +6,10 @@ import MeshCore /// targets a place to inject a mock transport while leaving the platform-agnostic /// `MeshTransport` contract unchanged. public protocol iOSMeshTransport: MeshTransport { - func setDeviceID(_ id: UUID) async - func switchDevice(to deviceID: UUID) async throws - func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) async - func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) async + func setDeviceID(_ id: UUID) async + func switchDevice(to deviceID: UUID) async throws + func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) async + func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) async } extension iOSBLETransport: iOSMeshTransport {} diff --git a/MC1Services/Sources/MC1Services/Utilities/ChannelMessageFormat.swift b/MC1Services/Sources/MC1Services/Utilities/ChannelMessageFormat.swift index 5de7299d..d6faa063 100644 --- a/MC1Services/Sources/MC1Services/Utilities/ChannelMessageFormat.swift +++ b/MC1Services/Sources/MC1Services/Utilities/ChannelMessageFormat.swift @@ -3,23 +3,23 @@ import Foundation /// Utilities for parsing the "NodeName: MessageText" format used in channel messages. /// The firmware prepends the sender's node name before encryption. enum ChannelMessageFormat { - /// Parses "NodeName: MessageText" format from decrypted channel messages. - /// - Parameter text: The full decrypted channel message text - /// - Returns: Tuple of (senderName, messageText) or nil if format doesn't match - static func parse(_ text: String) -> (senderName: String, messageText: String)? { - guard let colonIndex = text.firstIndex(of: ":"), - colonIndex != text.startIndex else { - return nil - } - - let senderName = String(text[.. (senderName: String, messageText: String)? { + guard let colonIndex = text.firstIndex(of: ":"), + colonIndex != text.startIndex else { + return nil + } - guard afterColon < text.endIndex else { - return (senderName, "") - } + let senderName = String(text[..` is the reserved terminator and is stripped /// from the name on emit; the name may legitimately contain `:`. public enum ContactShareUtilities { - /// Marks the start of a share token. - private static let tokenOpen: Character = "<" - - /// Marks the end of a share token and is reserved out of names. - private static let tokenClose: Character = ">" - - /// Separates the public key, type, and name fields inside a token. - private static let fieldSeparator: Character = ":" - - /// Splitting the interior keeps the public key and type as their own fields while - /// letting the name retain any internal `:` characters. - private static let interiorMaxSplits = 2 - - /// Number of fields a well-formed token interior splits into. - private static let expectedFieldCount = 3 - - /// Regex pattern for a contact share token: `<64-hex:digits:name>`. - public static let shareTokenPattern = #"<[0-9a-fA-F]{64}:\d+:[^>]+>"# - - /// Pre-compiled regex for token matching (avoids recompilation per call). - public static let shareTokenRegex: NSRegularExpression? = { - try? NSRegularExpression(pattern: shareTokenPattern) - }() - - /// Formats a contact into a share token. - /// - Parameters: - /// - publicKey: The contact's 32-byte public key (rendered as uppercase hex). - /// - type: The contact type. - /// - name: The contact's advertised name; any `>` characters are stripped. - /// - Returns: A `` token. - public static func formatShare(publicKey: Data, type: ContactType, name: String) -> String { - let sanitizedName = name.filter { $0 != tokenClose } - return "\(tokenOpen)\(publicKey.uppercaseHexString())\(fieldSeparator)\(type.rawValue)\(fieldSeparator)\(sanitizedName)\(tokenClose)" + /// Marks the start of a share token. + private static let tokenOpen: Character = "<" + + /// Marks the end of a share token and is reserved out of names. + private static let tokenClose: Character = ">" + + /// Separates the public key, type, and name fields inside a token. + private static let fieldSeparator: Character = ":" + + /// Splitting the interior keeps the public key and type as their own fields while + /// letting the name retain any internal `:` characters. + private static let interiorMaxSplits = 2 + + /// Number of fields a well-formed token interior splits into. + private static let expectedFieldCount = 3 + + /// Regex pattern for a contact share token: `<64-hex:digits:name>`. + public static let shareTokenPattern = #"<[0-9a-fA-F]{64}:\d+:[^>]+>"# + + /// Pre-compiled regex for token matching (avoids recompilation per call). + public static let shareTokenRegex: NSRegularExpression? = try? NSRegularExpression(pattern: shareTokenPattern) + + /// Formats a contact into a share token. + /// - Parameters: + /// - publicKey: The contact's 32-byte public key (rendered as uppercase hex). + /// - type: The contact type. + /// - name: The contact's advertised name; any `>` characters are stripped. + /// - Returns: A `` token. + public static func formatShare(publicKey: Data, type: ContactType, name: String) -> String { + let sanitizedName = name.filter { $0 != tokenClose } + return "\(tokenOpen)\(publicKey.uppercaseHexString())\(fieldSeparator)\(type.rawValue)\(fieldSeparator)\(sanitizedName)\(tokenClose)" + } + + /// Parses the first contact share token found in the input. + /// - Parameter token: Text that may contain a share token. + /// - Returns: The recovered ``ContactResult``, or nil if no valid token is present. + public static func parseShare(_ token: String) -> ContactResult? { + guard let regex = shareTokenRegex else { return nil } + + let range = NSRange(token.startIndex..., in: token) + guard let match = regex.firstMatch(in: token, range: range), + let matchRange = Range(match.range, in: token) else { + return nil } - /// Parses the first contact share token found in the input. - /// - Parameter token: Text that may contain a share token. - /// - Returns: The recovered ``ContactResult``, or nil if no valid token is present. - public static func parseShare(_ token: String) -> ContactResult? { - guard let regex = shareTokenRegex else { return nil } + return contactResult(fromMatched: token[matchRange]) + } - let range = NSRange(token.startIndex..., in: token) - guard let match = regex.firstMatch(in: token, range: range), - let matchRange = Range(match.range, in: token) else { - return nil - } + /// Extracts every contact share token from the input text. + /// - Parameter text: Text that may contain share tokens. + /// - Returns: The recovered contacts in the order they appear. + public static func extractShares(from text: String) -> [ContactResult] { + guard let regex = shareTokenRegex else { return [] } - return contactResult(fromMatched: token[matchRange]) - } - - /// Extracts every contact share token from the input text. - /// - Parameter text: Text that may contain share tokens. - /// - Returns: The recovered contacts in the order they appear. - public static func extractShares(from text: String) -> [ContactResult] { - guard let regex = shareTokenRegex else { return [] } - - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) - return matches.compactMap { match in - guard let matchRange = Range(match.range, in: text) else { return nil } - return contactResult(fromMatched: text[matchRange]) - } + return matches.compactMap { match in + guard let matchRange = Range(match.range, in: text) else { return nil } + return contactResult(fromMatched: text[matchRange]) + } + } + + /// Validates a regex-matched token and builds a ``ContactResult``. + /// - Parameter matched: The full matched token including delimiters. + /// - Returns: The recovered contact, or nil if any field is invalid. + private static func contactResult(fromMatched matched: Substring) -> ContactResult? { + let interior = matched.dropFirst().dropLast() + let fields = interior.split( + separator: fieldSeparator, + maxSplits: interiorMaxSplits, + omittingEmptySubsequences: false + ) + guard fields.count == expectedFieldCount else { return nil } + + let publicKeyHex = String(fields[0]) + guard let publicKey = Data(hexString: publicKeyHex), + publicKey.count == ProtocolLimits.publicKeySize else { + return nil } - /// Validates a regex-matched token and builds a ``ContactResult``. - /// - Parameter matched: The full matched token including delimiters. - /// - Returns: The recovered contact, or nil if any field is invalid. - private static func contactResult(fromMatched matched: Substring) -> ContactResult? { - let interior = matched.dropFirst().dropLast() - let fields = interior.split( - separator: fieldSeparator, - maxSplits: interiorMaxSplits, - omittingEmptySubsequences: false - ) - guard fields.count == expectedFieldCount else { return nil } - - let publicKeyHex = String(fields[0]) - guard let publicKey = Data(hexString: publicKeyHex), - publicKey.count == ProtocolLimits.publicKeySize else { - return nil - } - - guard let typeValue = Int(fields[1]), - let typeByte = UInt8(exactly: typeValue), - let contactType = ContactType(rawValue: typeByte) else { - return nil - } - - let name = String(fields[2]) - guard !name.isEmpty else { return nil } - - return ContactResult(name: name, publicKey: publicKey, contactType: contactType) + guard let typeValue = Int(fields[1]), + let typeByte = UInt8(exactly: typeValue), + let contactType = ContactType(rawValue: typeByte) else { + return nil } + + let name = String(fields[2]) + guard !name.isEmpty else { return nil } + + return ContactResult(name: name, publicKey: publicKey, contactType: contactType) + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/DeduplicationKey.swift b/MC1Services/Sources/MC1Services/Utilities/DeduplicationKey.swift index 43dc71ac..71c176c1 100644 --- a/MC1Services/Sources/MC1Services/Utilities/DeduplicationKey.swift +++ b/MC1Services/Sources/MC1Services/Utilities/DeduplicationKey.swift @@ -5,24 +5,24 @@ import Foundation /// Used by SyncCoordinator (live sync), PersistenceStore+Migration (on-device backfill), /// and PersistenceStore+Backup (export/import). enum DeduplicationKey { - static let channelPrefix = "ch-" - static let directMessagePrefix = "dm-" - static let outgoingIdentityPrefix = "out-" - static let unknownContactPlaceholder = "unknown" + static let channelPrefix = "ch-" + static let directMessagePrefix = "dm-" + static let outgoingIdentityPrefix = "out-" + static let unknownContactPlaceholder = "unknown" - static func contentBased( - contactID: UUID?, - channelIndex: UInt8?, - senderNodeName: String?, - timestamp: UInt32, - content: String - ) -> String { - let contentHash = SHA256.hash(data: Data(content.utf8)) - let hashPrefix = contentHash.prefix(4).map { String(format: "%02X", $0) }.joined() - if let channelIndex { - return "\(channelPrefix)\(channelIndex)-\(timestamp)-\(senderNodeName ?? "")-\(hashPrefix)" - } - let contactSegment = contactID?.uuidString ?? unknownContactPlaceholder - return "\(directMessagePrefix)\(contactSegment)-\(timestamp)-\(hashPrefix)" + static func contentBased( + contactID: UUID?, + channelIndex: UInt8?, + senderNodeName: String?, + timestamp: UInt32, + content: String + ) -> String { + let contentHash = SHA256.hash(data: Data(content.utf8)) + let hashPrefix = contentHash.prefix(4).map { String(format: "%02X", $0) }.joined() + if let channelIndex { + return "\(channelPrefix)\(channelIndex)-\(timestamp)-\(senderNodeName ?? "")-\(hashPrefix)" } + let contactSegment = contactID?.uuidString ?? unknownContactPlaceholder + return "\(directMessagePrefix)\(contactSegment)-\(timestamp)-\(hashPrefix)" + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/DeviceIdentity.swift b/MC1Services/Sources/MC1Services/Utilities/DeviceIdentity.swift index faf91e6c..91a53270 100644 --- a/MC1Services/Sources/MC1Services/Utilities/DeviceIdentity.swift +++ b/MC1Services/Sources/MC1Services/Utilities/DeviceIdentity.swift @@ -1,20 +1,19 @@ -import Foundation import CryptoKit +import Foundation /// Utilities for deriving stable device identity from cryptographic keys. -enum DeviceIdentity: Sendable { - - /// Derives a stable UUID from a device's Ed25519 public key. - /// Uses SHA256 hash of the public key, taking first 16 bytes as UUID. - static func deriveUUID(from publicKey: Data) -> UUID { - let hash = SHA256.hash(data: publicKey) - let hashBytes = Array(hash) +enum DeviceIdentity { + /// Derives a stable UUID from a device's Ed25519 public key. + /// Uses SHA256 hash of the public key, taking first 16 bytes as UUID. + static func deriveUUID(from publicKey: Data) -> UUID { + let hash = SHA256.hash(data: publicKey) + let hashBytes = Array(hash) - return UUID(uuid: ( - hashBytes[0], hashBytes[1], hashBytes[2], hashBytes[3], - hashBytes[4], hashBytes[5], hashBytes[6], hashBytes[7], - hashBytes[8], hashBytes[9], hashBytes[10], hashBytes[11], - hashBytes[12], hashBytes[13], hashBytes[14], hashBytes[15] - )) - } + return UUID(uuid: ( + hashBytes[0], hashBytes[1], hashBytes[2], hashBytes[3], + hashBytes[4], hashBytes[5], hashBytes[6], hashBytes[7], + hashBytes[8], hashBytes[9], hashBytes[10], hashBytes[11], + hashBytes[12], hashBytes[13], hashBytes[14], hashBytes[15] + )) + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift b/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift index d909d498..25f01bef 100644 --- a/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift +++ b/MC1Services/Sources/MC1Services/Utilities/EventBroadcaster.swift @@ -17,71 +17,70 @@ import os /// Calling `subscribe()` after `finish()` returns a stream that is already /// finished, so its for-await loop exits immediately rather than parking. final class EventBroadcaster: Sendable { + private struct State { + var continuations: [UUID: AsyncStream.Continuation] = [:] + var isFinished: Bool = false + } - private struct State { - var continuations: [UUID: AsyncStream.Continuation] = [:] - var isFinished: Bool = false - } - - private let state = OSAllocatedUnfairLock(initialState: State()) + private let state = OSAllocatedUnfairLock(initialState: State()) - init() {} + init() {} - /// Returns a stream receiving every event yielded after this call. - /// If `finish()` has already been called, returns a stream that finishes - /// immediately. Cancelling the consuming task unregisters the subscriber. - func subscribe() -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: Event.self) - let alreadyFinished = state.withLock { locked -> Bool in - guard !locked.isFinished else { return true } - let id = UUID() - locked.continuations[id] = continuation - continuation.onTermination = { [state] _ in - state.withLock { _ = $0.continuations.removeValue(forKey: id) } - } - return false - } - if alreadyFinished { - continuation.finish() - } - return stream + /// Returns a stream receiving every event yielded after this call. + /// If `finish()` has already been called, returns a stream that finishes + /// immediately. Cancelling the consuming task unregisters the subscriber. + func subscribe() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Event.self) + let alreadyFinished = state.withLock { locked -> Bool in + guard !locked.isFinished else { return true } + let id = UUID() + locked.continuations[id] = continuation + continuation.onTermination = { [state] _ in + state.withLock { _ = $0.continuations.removeValue(forKey: id) } + } + return false } - - /// Delivers the event to every active subscriber, pruning any that - /// terminated without unregistering. - func yield(_ event: Event) { - let snapshot = state.withLock { $0.continuations } - var staleIDs: [UUID] = [] - for (id, continuation) in snapshot { - if case .terminated = continuation.yield(event) { - staleIDs.append(id) - } - } - let staleToPrune = staleIDs - guard !staleToPrune.isEmpty else { return } - state.withLock { locked in - for id in staleToPrune { - locked.continuations.removeValue(forKey: id) - } - } + if alreadyFinished { + continuation.finish() } + return stream + } - /// Ends every subscriber's stream and unregisters them all. - /// Any subsequent `subscribe()` call returns an already-finished stream. - func finish() { - let snapshot = state.withLock { locked -> [UUID: AsyncStream.Continuation] in - let current = locked.continuations - locked.continuations.removeAll() - locked.isFinished = true - return current - } - for continuation in snapshot.values { - continuation.finish() - } + /// Delivers the event to every active subscriber, pruning any that + /// terminated without unregistering. + func yield(_ event: Event) { + let snapshot = state.withLock { $0.continuations } + var staleIDs: [UUID] = [] + for (id, continuation) in snapshot { + if case .terminated = continuation.yield(event) { + staleIDs.append(id) + } } + let staleToPrune = staleIDs + guard !staleToPrune.isEmpty else { return } + state.withLock { locked in + for id in staleToPrune { + locked.continuations.removeValue(forKey: id) + } + } + } - /// Number of currently registered subscribers. - var subscriberCount: Int { - state.withLock { $0.continuations.count } + /// Ends every subscriber's stream and unregisters them all. + /// Any subsequent `subscribe()` call returns an already-finished stream. + func finish() { + let snapshot = state.withLock { locked -> [UUID: AsyncStream.Continuation] in + let current = locked.continuations + locked.continuations.removeAll() + locked.isFinished = true + return current + } + for continuation in snapshot.values { + continuation.finish() } + } + + /// Number of currently registered subscribers. + var subscriberCount: Int { + state.withLock { $0.continuations.count } + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/HashtagUtilities.swift b/MC1Services/Sources/MC1Services/Utilities/HashtagUtilities.swift index c6bb5ba3..98bf81af 100644 --- a/MC1Services/Sources/MC1Services/Utilities/HashtagUtilities.swift +++ b/MC1Services/Sources/MC1Services/Utilities/HashtagUtilities.swift @@ -2,126 +2,121 @@ import Foundation /// Utilities for detecting and processing hashtag channel references in messages public enum HashtagUtilities { + static let hashtagPattern = "#[A-Za-z0-9][A-Za-z0-9-]*" + + /// Pre-compiled regex for hashtag matching (avoids recompilation per call) + static let hashtagRegex: NSRegularExpression? = try? NSRegularExpression(pattern: hashtagPattern) + + /// Represents a detected hashtag with its location in the source text + public struct DetectedHashtag: Equatable, Sendable { + public let name: String + public let range: Range + } + + /// Extracts all valid hashtags from text, excluding those within URLs + /// - Parameter text: The message text to search + /// - Returns: Array of detected hashtags with their ranges + static func extractHashtags(from text: String) -> [DetectedHashtag] { + extractHashtags(from: text, urlRanges: findURLRanges(in: text)) + } + + /// Extracts all valid hashtags from text, using pre-computed URL ranges to skip + /// - Parameters: + /// - text: The message text to search + /// - urlRanges: Pre-computed URL ranges (avoids duplicate NSDataDetector scan) + /// - Returns: Array of detected hashtags with their ranges + public static func extractHashtags( + from text: String, + urlRanges: [Range] + ) -> [DetectedHashtag] { + guard !text.isEmpty else { return [] } + + guard let regex = hashtagRegex else { return [] } + + let nsRange = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: nsRange) + + return matches.compactMap { match -> DetectedHashtag? in + guard let range = Range(match.range, in: text) else { return nil } + + // Skip hashtags that fall within URL ranges + for urlRange in urlRanges { + if range.lowerBound >= urlRange.lowerBound, range.upperBound <= urlRange.upperBound { + return nil + } + } - static let hashtagPattern = "#[A-Za-z0-9][A-Za-z0-9-]*" - - /// Pre-compiled regex for hashtag matching (avoids recompilation per call) - static let hashtagRegex: NSRegularExpression? = { - try? NSRegularExpression(pattern: hashtagPattern) - }() - - /// Represents a detected hashtag with its location in the source text - public struct DetectedHashtag: Equatable, Sendable { - public let name: String - public let range: Range + let name = String(text[range]) + return DetectedHashtag(name: name, range: range) } - - /// Extracts all valid hashtags from text, excluding those within URLs - /// - Parameter text: The message text to search - /// - Returns: Array of detected hashtags with their ranges - static func extractHashtags(from text: String) -> [DetectedHashtag] { - extractHashtags(from: text, urlRanges: findURLRanges(in: text)) + } + + /// Validates that a channel name contains only valid characters + /// - Parameter name: Channel name without # prefix + /// - Returns: True if valid (starts with alphanumeric, then lowercase letters, numbers, hyphens only) + public static func isValidHashtagName(_ name: String) -> Bool { + guard let first = name.unicodeScalars.first else { return false } + guard isAllowedHashtagNameScalar(first, allowsHyphen: false) else { return false } + return name.unicodeScalars.allSatisfy { scalar in + isAllowedHashtagNameScalar(scalar, allowsHyphen: true) } + } - /// Extracts all valid hashtags from text, using pre-computed URL ranges to skip - /// - Parameters: - /// - text: The message text to search - /// - urlRanges: Pre-computed URL ranges (avoids duplicate NSDataDetector scan) - /// - Returns: Array of detected hashtags with their ranges - public static func extractHashtags( - from text: String, - urlRanges: [Range] - ) -> [DetectedHashtag] { - guard !text.isEmpty else { return [] } - - guard let regex = hashtagRegex else { return [] } - - let nsRange = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: nsRange) - - return matches.compactMap { match -> DetectedHashtag? in - guard let range = Range(match.range, in: text) else { return nil } - - // Skip hashtags that fall within URL ranges - for urlRange in urlRanges { - if range.lowerBound >= urlRange.lowerBound && range.upperBound <= urlRange.upperBound { - return nil - } - } - - let name = String(text[range]) - return DetectedHashtag(name: name, range: range) - } - } + public static func sanitizeHashtagNameInput(_ input: String) -> String { + var result = String() + result.reserveCapacity(input.count) - /// Validates that a channel name contains only valid characters - /// - Parameter name: Channel name without # prefix - /// - Returns: True if valid (starts with alphanumeric, then lowercase letters, numbers, hyphens only) - public static func isValidHashtagName(_ name: String) -> Bool { - guard let first = name.unicodeScalars.first else { return false } - guard isAllowedHashtagNameScalar(first, allowsHyphen: false) else { return false } - return name.unicodeScalars.allSatisfy { scalar in - isAllowedHashtagNameScalar(scalar, allowsHyphen: true) - } + for scalar in input.lowercased().unicodeScalars { + guard isAllowedHashtagNameScalar(scalar, allowsHyphen: true) else { continue } + result.unicodeScalars.append(scalar) } - public static func sanitizeHashtagNameInput(_ input: String) -> String { - var result = String() - result.reserveCapacity(input.count) - - for scalar in input.lowercased().unicodeScalars { - guard isAllowedHashtagNameScalar(scalar, allowsHyphen: true) else { continue } - result.unicodeScalars.append(scalar) - } - - while result.hasPrefix("-") { - result.removeFirst() - } - - return result + while result.hasPrefix("-") { + result.removeFirst() } - /// Normalizes a hashtag name by lowercasing and removing # prefix - /// - Parameter name: The hashtag name (with or without #) - /// - Returns: Normalized lowercase name without prefix - public static func normalizeHashtagName(_ name: String) -> String { - var normalized = name.lowercased() - if normalized.hasPrefix("#") { - normalized.removeFirst() - } - return normalized + return result + } + + /// Normalizes a hashtag name by lowercasing and removing # prefix + /// - Parameter name: The hashtag name (with or without #) + /// - Returns: Normalized lowercase name without prefix + public static func normalizeHashtagName(_ name: String) -> String { + var normalized = name.lowercased() + if normalized.hasPrefix("#") { + normalized.removeFirst() } + return normalized + } - // MARK: - Private Helpers + // MARK: - Private Helpers - private static let urlDetector: NSDataDetector? = { - try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - }() + private static let urlDetector: NSDataDetector? = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - private static func isAllowedHashtagNameScalar(_ scalar: UnicodeScalar, allowsHyphen: Bool) -> Bool { - switch scalar.value { - case 48...57, 65...90, 97...122: - return true - case 45: - return allowsHyphen - default: - return false - } + private static func isAllowedHashtagNameScalar(_ scalar: UnicodeScalar, allowsHyphen: Bool) -> Bool { + switch scalar.value { + case 48...57, 65...90, 97...122: + true + case 45: + allowsHyphen + default: + false } + } - private static func findURLRanges(in text: String) -> [Range] { - guard let detector = urlDetector else { return [] } + private static func findURLRanges(in text: String) -> [Range] { + guard let detector = urlDetector else { return [] } - let nsRange = NSRange(text.startIndex..., in: text) - let matches = detector.matches(in: text, options: [], range: nsRange) + let nsRange = NSRange(text.startIndex..., in: text) + let matches = detector.matches(in: text, options: [], range: nsRange) - return matches.compactMap { match -> Range? in - guard let url = match.url, - let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { - return nil - } - return Range(match.range, in: text) - } + return matches.compactMap { match -> Range? in + guard let url = match.url, + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + return Range(match.range, in: text) } + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/ImageURLClassifier.swift b/MC1Services/Sources/MC1Services/Utilities/ImageURLClassifier.swift index 914ea547..359385a0 100644 --- a/MC1Services/Sources/MC1Services/Utilities/ImageURLClassifier.swift +++ b/MC1Services/Sources/MC1Services/Utilities/ImageURLClassifier.swift @@ -7,65 +7,63 @@ import Foundation /// The companion `ImageURLDetector` in MC1 owns image *decoding* (depends on /// UIKit/ImageIO) and forwards URL classification to this type. public enum ImageURLClassifier { - - private static let imageExtensions: Set = [ - "jpg", "jpeg", "png", "gif", "webp", "heic" - ] - - /// Returns `true` if the URL's path extension is a known image type. - public static func isDirectImageURL(_ url: URL) -> Bool { - imageExtensions.contains(url.pathExtension.lowercased()) - } - - /// Returns the direct image URL for known hosting page URLs, or `nil` if - /// not resolvable. - public static func resolveImageURL(_ url: URL) -> URL? { - guard let host = url.host()?.lowercased() else { return nil } - - if host == "giphy.com" || host == "www.giphy.com" { - return resolveGiphyURL(url) - } - - // Already a direct Giphy media URL — no resolution needed. - if host == "media.giphy.com" || host == "i.giphy.com" { - return nil - } - - return nil + private static let imageExtensions: Set = [ + "jpg", "jpeg", "png", "gif", "webp", "heic" + ] + + /// Returns `true` if the URL's path extension is a known image type. + public static func isDirectImageURL(_ url: URL) -> Bool { + imageExtensions.contains(url.pathExtension.lowercased()) + } + + /// Returns the direct image URL for known hosting page URLs, or `nil` if + /// not resolvable. + public static func resolveImageURL(_ url: URL) -> URL? { + guard let host = url.host()?.lowercased() else { return nil } + + if host == "giphy.com" || host == "www.giphy.com" { + return resolveGiphyURL(url) } - /// Returns `true` if the URL points to a direct image or a resolvable - /// hosting page. - public static func isImageURL(_ url: URL) -> Bool { - isDirectImageURL(url) || resolveImageURL(url) != nil + // Already a direct Giphy media URL — no resolution needed. + if host == "media.giphy.com" || host == "i.giphy.com" { + return nil } - /// Returns the direct image URL: the URL itself for direct images, or - /// the resolved URL for hosting pages. - public static func directImageURL(for url: URL) -> URL { - if isDirectImageURL(url) { return url } - return resolveImageURL(url) ?? url - } + return nil + } - private static func resolveGiphyURL(_ url: URL) -> URL? { - let pathComponents = url.pathComponents + /// Returns `true` if the URL points to a direct image or a resolvable + /// hosting page. + public static func isImageURL(_ url: URL) -> Bool { + isDirectImageURL(url) || resolveImageURL(url) != nil + } - guard pathComponents.count >= 3 else { return nil } + /// Returns the direct image URL: the URL itself for direct images, or + /// the resolved URL for hosting pages. + public static func directImageURL(for url: URL) -> URL { + if isDirectImageURL(url) { return url } + return resolveImageURL(url) ?? url + } - let section = pathComponents[1].lowercased() - guard section == "gifs" || section == "embed" else { return nil } + private static func resolveGiphyURL(_ url: URL) -> URL? { + let pathComponents = url.pathComponents - let lastComponent = pathComponents[2] + guard pathComponents.count >= 3 else { return nil } - let giphyID: String - if section == "gifs" { - giphyID = lastComponent.components(separatedBy: "-").last ?? lastComponent - } else { - giphyID = lastComponent - } + let section = pathComponents[1].lowercased() + guard section == "gifs" || section == "embed" else { return nil } - guard !giphyID.isEmpty else { return nil } + let lastComponent = pathComponents[2] - return URL(string: "https://i.giphy.com/media/\(giphyID)/giphy.gif") + let giphyID: String = if section == "gifs" { + lastComponent.components(separatedBy: "-").last ?? lastComponent + } else { + lastComponent } + + guard !giphyID.isEmpty else { return nil } + + return URL(string: "https://i.giphy.com/media/\(giphyID)/giphy.gif") + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/LogRedaction.swift b/MC1Services/Sources/MC1Services/Utilities/LogRedaction.swift index f6ff2ba8..547dd846 100644 --- a/MC1Services/Sources/MC1Services/Utilities/LogRedaction.swift +++ b/MC1Services/Sources/MC1Services/Utilities/LogRedaction.swift @@ -2,41 +2,40 @@ import Foundation /// Centralized redaction utility for logging sensitive data. enum LogRedaction { + /// Formats public key prefix as hex string. + /// Public keys are NOT redacted since they're public. + /// - Parameters: + /// - key: The public key data + /// - prefixLength: Number of bytes to include (default 6) + /// - Returns: Hex string representation + static func publicKeyHex(_ key: Data, prefixLength: Int = 6) -> String { + key.prefix(prefixLength).map { String(format: "%02x", $0) }.joined() + } - /// Formats public key prefix as hex string. - /// Public keys are NOT redacted since they're public. - /// - Parameters: - /// - key: The public key data - /// - prefixLength: Number of bytes to include (default 6) - /// - Returns: Hex string representation - static func publicKeyHex(_ key: Data, prefixLength: Int = 6) -> String { - key.prefix(prefixLength).map { String(format: "%02x", $0) }.joined() - } - - /// Redacts a node/room name to first 3 chars + "***". - /// - Parameter name: The node or room name - /// - Returns: Redacted name - static func nodeName(_ name: String) -> String { - guard name.count > 3 else { return "***" } - return String(name.prefix(3)) + "***" - } + /// Redacts a node/room name to first 3 chars + "***". + /// - Parameter name: The node or room name + /// - Returns: Redacted name + static func nodeName(_ name: String) -> String { + guard name.count > 3 else { return "***" } + return String(name.prefix(3)) + "***" + } - /// Redacts sensitive parts of CLI commands (passwords). - /// - Parameter command: The CLI command string - /// - Returns: Command with password values redacted - static func cliCommand(_ command: String) -> String { - let lower = command.lowercased() - // Redact password values in CLI commands like "set password XYZ" or "password XYZ" - if lower.hasPrefix("password") || lower.contains("set password") { - let parts = command.split(separator: " ", maxSplits: 2) - if parts.count >= 2 { - return parts.dropLast().joined(separator: " ") + " [REDACTED]" - } - } - // Truncate long commands - return command.count <= 40 ? command : String(command.prefix(40)) + "..." + /// Redacts sensitive parts of CLI commands (passwords). + /// - Parameter command: The CLI command string + /// - Returns: Command with password values redacted + static func cliCommand(_ command: String) -> String { + let lower = command.lowercased() + // Redact password values in CLI commands like "set password XYZ" or "password XYZ" + if lower.hasPrefix("password") || lower.contains("set password") { + let parts = command.split(separator: " ", maxSplits: 2) + if parts.count >= 2 { + return parts.dropLast().joined(separator: " ") + " [REDACTED]" + } } + // Truncate long commands + return command.count <= 40 ? command : String(command.prefix(40)) + "..." + } - /// Placeholder for password values that should never be logged. - static let passwordPlaceholder = "[REDACTED]" + /// Placeholder for password values that should never be logged. + static let passwordPlaceholder = "[REDACTED]" } diff --git a/MC1Services/Sources/MC1Services/Utilities/MentionUtilities.swift b/MC1Services/Sources/MC1Services/Utilities/MentionUtilities.swift index e3ca6126..c27322f6 100644 --- a/MC1Services/Sources/MC1Services/Utilities/MentionUtilities.swift +++ b/MC1Services/Sources/MC1Services/Utilities/MentionUtilities.swift @@ -2,173 +2,168 @@ import Foundation /// Utilities for working with MeshCore mention format: @[nodeContactName] public enum MentionUtilities { - /// The regex pattern for matching mentions: @[name] - public static let mentionPattern = #"@\[([^\]]+)\]"# - - /// Pre-compiled regex for mention matching (avoids recompilation per call) - public static let mentionRegex: NSRegularExpression? = { - try? NSRegularExpression(pattern: mentionPattern) - }() - - /// Creates a mention string from a node contact name - /// - Parameter name: The mesh network contact name (not nickname) - /// - Returns: Formatted mention string "@[name]" - public static func createMention(for name: String) -> String { - "@[\(name)]" - } - - /// Appends a mention to the existing composer draft, preserving it. Inserts a - /// separating space only when the draft does not already end in whitespace, and - /// always leaves a trailing space. - public static func appendMention(for name: String, to draft: String) -> String { - let mention = createMention(for: name) - guard !draft.isEmpty else { return mention + " " } - let separator = draft.last?.isWhitespace == true ? "" : " " - return draft + separator + mention + " " - } - - /// Extracts all mentions from message text - /// - Parameter text: The message text to parse - /// - Returns: Array of mentioned contact names (without @[] wrapper) - public static func extractMentions(from text: String) -> [String] { - guard let regex = mentionRegex else { return [] } - - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) - - return matches.compactMap { match in - guard let captureRange = Range(match.range(at: 1), in: text) else { - return nil - } - return String(text[captureRange]) - } - } - - /// Detects an active mention query from input text. - /// Returns the search query (text after @) if user is typing a mention, nil otherwise. - /// Triggers when @ is at start or after whitespace. Returns empty string for standalone @. - public static func detectActiveMention(in text: String) -> String? { - guard !text.isEmpty else { return nil } - - // Find the last @ that could start a mention - var searchStart = text.endIndex - - while let atIndex = text[.. String { + "@[\(name)]" + } + + /// Appends a mention to the existing composer draft, preserving it. Inserts a + /// separating space only when the draft does not already end in whitespace, and + /// always leaves a trailing space. + public static func appendMention(for name: String, to draft: String) -> String { + let mention = createMention(for: name) + guard !draft.isEmpty else { return mention + " " } + let separator = draft.last?.isWhitespace == true ? "" : " " + return draft + separator + mention + " " + } + + /// Extracts all mentions from message text + /// - Parameter text: The message text to parse + /// - Returns: Array of mentioned contact names (without @[] wrapper) + public static func extractMentions(from text: String) -> [String] { + guard let regex = mentionRegex else { return [] } + + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) + + return matches.compactMap { match in + guard let captureRange = Range(match.range(at: 1), in: text) else { return nil + } + return String(text[captureRange]) } - - /// Filters contacts for mention suggestions. - /// - Parameters: - /// - contacts: All available contacts - /// - query: Search query (text after @) - /// - senderOrder: Sender name → timestamp map for recency sorting (nil = alphabetical) - /// - Returns: Chat-type contacts matching query, sorted by recency then alphabetically - public static func filterContacts( - _ contacts: [ContactDTO], - query: String, - senderOrder: [String: UInt32]? = nil - ) -> [ContactDTO] { - let filtered = contacts - .filter { $0.type == .chat } - .filter { query.isEmpty || $0.displayName.localizedStandardContains(query) } - - guard let senderOrder, !senderOrder.isEmpty else { - return filtered.sorted { - $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending - } + } + + /// Detects an active mention query from input text. + /// Returns the search query (text after @) if user is typing a mention, nil otherwise. + /// Triggers when @ is at start or after whitespace. Returns empty string for standalone @. + public static func detectActiveMention(in text: String) -> String? { + guard !text.isEmpty else { return nil } + + // Find the last @ that could start a mention + var searchStart = text.endIndex + + while let atIndex = text[.. bT - case (_?, nil): - // Only a has a timestamp: a comes first - return true - case (nil, _?): - // Only b has a timestamp: b comes first - return false - case (nil, nil): - // Neither has a timestamp: alphabetical - return a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending - } - } + // Extract query until space or end + let query = afterAt.prefix(while: { !$0.isWhitespace }) + return String(query) } - /// Checks if text contains a mention of the specified user name - /// - Parameters: - /// - text: The message text to check - /// - selfName: The current user's node name - /// - Returns: true if text contains @[selfName] - public static func containsSelfMention(in text: String, selfName: String) -> Bool { - let mentions = extractMentions(from: text) - return mentions.contains { $0.caseInsensitiveCompare(selfName) == .orderedSame } + return nil + } + + /// Filters contacts for mention suggestions. + /// - Parameters: + /// - contacts: All available contacts + /// - query: Search query (text after @) + /// - senderOrder: Sender name → timestamp map for recency sorting (nil = alphabetical) + /// - Returns: Chat-type contacts matching query, sorted by recency then alphabetically + public static func filterContacts( + _ contacts: [ContactDTO], + query: String, + senderOrder: [String: UInt32]? = nil + ) -> [ContactDTO] { + let filtered = contacts + .filter { $0.type == .chat } + .filter { query.isEmpty || $0.displayName.localizedStandardContains(query) } + + guard let senderOrder, !senderOrder.isEmpty else { + return filtered.sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } } - /// Pre-compiled regex for stripping a leading mention from reply text - private static let leadingMentionRegex: NSRegularExpression? = { - try? NSRegularExpression(pattern: #"^@\[[^\]]+\]\s*"#) - }() - - /// Builds reply text with a mention and quoted preview of the original message. - /// Strips any leading mention from the message text before generating the preview. - public static func buildReplyText(mentionName: String, messageText: String) -> String { - let previewSource: String - if let regex = leadingMentionRegex, - let match = regex.firstMatch(in: messageText, range: NSRange(messageText.startIndex..., in: messageText)), - let matchRange = Range(match.range, in: messageText) { - previewSource = String(messageText[matchRange.upperBound...]) - } else { - previewSource = messageText - } - let preview = String(previewSource.prefix(10)) - let suffix = previewSource.count > 10 ? ".." : "" - let mention = createMention(for: mentionName) - return "\(mention)\n>\(preview)\(suffix)\n" + return filtered.sorted { a, b in + let aTimestamp = senderOrder[a.name] + let bTimestamp = senderOrder[b.name] + + switch (aTimestamp, bTimestamp) { + case let (aT?, bT?): + // Both have timestamps: most recent first + return aT > bT + case (_?, nil): + // Only a has a timestamp: a comes first + return true + case (nil, _?): + // Only b has a timestamp: b comes first + return false + case (nil, nil): + // Neither has a timestamp: alphabetical + return a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending + } + } + } + + /// Checks if text contains a mention of the specified user name + /// - Parameters: + /// - text: The message text to check + /// - selfName: The current user's node name + /// - Returns: true if text contains @[selfName] + public static func containsSelfMention(in text: String, selfName: String) -> Bool { + let mentions = extractMentions(from: text) + return mentions.contains { $0.caseInsensitiveCompare(selfName) == .orderedSame } + } + + /// Pre-compiled regex for stripping a leading mention from reply text + private static let leadingMentionRegex: NSRegularExpression? = try? NSRegularExpression(pattern: #"^@\[[^\]]+\]\s*"#) + + /// Builds reply text with a mention and quoted preview of the original message. + /// Strips any leading mention from the message text before generating the preview. + public static func buildReplyText(mentionName: String, messageText: String) -> String { + let previewSource: String = if let regex = leadingMentionRegex, + let match = regex.firstMatch(in: messageText, range: NSRange(messageText.startIndex..., in: messageText)), + let matchRange = Range(match.range, in: messageText) { + String(messageText[matchRange.upperBound...]) + } else { + messageText } + let preview = String(previewSource.prefix(10)) + let suffix = previewSource.count > 10 ? ".." : "" + let mention = createMention(for: mentionName) + return "\(mention)\n>\(preview)\(suffix)\n" + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift b/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift index 8c04eb84..e021fe55 100644 --- a/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift +++ b/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift @@ -9,16 +9,16 @@ import Foundation /// plus the theme keys, which predate the typed enum. User preferences and UI /// state belong in `AppStorageKey` instead; the two namespaces never share a key. public enum PersistenceKeys { - public static let lastConnectedDeviceID = "com.pocketmesh.lastConnectedDeviceID" - public static let lastConnectedDeviceName = "com.pocketmesh.lastConnectedDeviceName" - public static let lastConnectedRadioID = "com.pocketmesh.lastConnectedRadioID" - public static let lastDisconnectDiagnostic = "com.pocketmesh.lastDisconnectDiagnostic" - public static let userExplicitlyDisconnected = "com.pocketmesh.userExplicitlyDisconnected" + public static let lastConnectedDeviceID = "com.pocketmesh.lastConnectedDeviceID" + public static let lastConnectedDeviceName = "com.pocketmesh.lastConnectedDeviceName" + public static let lastConnectedRadioID = "com.pocketmesh.lastConnectedRadioID" + public static let lastDisconnectDiagnostic = "com.pocketmesh.lastDisconnectDiagnostic" + public static let userExplicitlyDisconnected = "com.pocketmesh.userExplicitlyDisconnected" - /// Selected theme ID (bare-string key, no `com.pocketmesh.` prefix — matches the - /// `BackupUserDefaults` string-mapping convention and the value's L10n-key form). - public static let selectedThemeID = "selectedThemeID" + /// Selected theme ID (bare-string key, no `com.pocketmesh.` prefix — matches the + /// `BackupUserDefaults` string-mapping convention and the value's L10n-key form). + public static let selectedThemeID = "selectedThemeID" - /// App color-scheme preference raw value (`"system"` | `"light"` | `"dark"`). Bare string. - public static let appColorSchemePreference = "appColorSchemePreference" + /// App color-scheme preference raw value (`"system"` | `"light"` | `"dark"`). Bare string. + public static let appColorSchemePreference = "appColorSchemePreference" } diff --git a/MC1Services/Sources/MC1Services/Utilities/Sequence+IndexByID.swift b/MC1Services/Sources/MC1Services/Utilities/Sequence+IndexByID.swift index e3f78a45..de8cb4c7 100644 --- a/MC1Services/Sources/MC1Services/Utilities/Sequence+IndexByID.swift +++ b/MC1Services/Sources/MC1Services/Utilities/Sequence+IndexByID.swift @@ -1,12 +1,12 @@ import Foundation -extension Sequence where Element: Identifiable { - /// Build a `[Element.ID: Int]` mapping each element's ID to its - /// position in the sequence. On duplicate IDs the later offset wins, - /// matching the last-write-wins semantics used by `replaceAll` and - /// related chat-timeline mutations. Used by chat-timeline code to - /// maintain an O(1) lookup from message ID to row index. - public func indexByID() -> [Element.ID: Int] { - Dictionary(enumerated().map { ($0.element.id, $0.offset) }, uniquingKeysWith: { _, new in new }) - } +public extension Sequence where Element: Identifiable { + /// Build a `[Element.ID: Int]` mapping each element's ID to its + /// position in the sequence. On duplicate IDs the later offset wins, + /// matching the last-write-wins semantics used by `replaceAll` and + /// related chat-timeline mutations. Used by chat-timeline code to + /// maintain an O(1) lookup from message ID to row index. + func indexByID() -> [Element.ID: Int] { + Dictionary(enumerated().map { ($0.element.id, $0.offset) }, uniquingKeysWith: { _, new in new }) + } } diff --git a/MC1Services/Sources/MC1Services/Utilities/TimeoutUtility.swift b/MC1Services/Sources/MC1Services/Utilities/TimeoutUtility.swift index a4b2173c..6932a4d3 100644 --- a/MC1Services/Sources/MC1Services/Utilities/TimeoutUtility.swift +++ b/MC1Services/Sources/MC1Services/Utilities/TimeoutUtility.swift @@ -4,12 +4,12 @@ import Foundation /// Error thrown when an async operation exceeds its timeout. public struct TimeoutError: Error, LocalizedError, Sendable { - public let operationName: String - public let timeout: Duration + public let operationName: String + public let timeout: Duration - public var errorDescription: String? { - "Operation '\(operationName)' timed out after \(timeout)" - } + public var errorDescription: String? { + "Operation '\(operationName)' timed out after \(timeout)" + } } // MARK: - Timeout Helpers @@ -19,15 +19,15 @@ public struct TimeoutError: Error, LocalizedError, Sendable { /// suspends the app; current callers wrap BLE operations that should not time out /// during suspension. See `raceAgainstDeadline` for the cancellation contract. public func withTimeout( - _ timeout: Duration, - operationName: String = #function, - operation: @escaping @Sendable () async throws -> T + _ timeout: Duration, + operationName: String = #function, + operation: @escaping @Sendable () async throws -> T ) async throws -> T { - try await raceAgainstDeadline( - sleepUntilDeadline: { try await Task.sleep(for: timeout, clock: .suspending) }, - makeTimeoutError: { TimeoutError(operationName: operationName, timeout: timeout) }, - operation: operation - ) + try await raceAgainstDeadline( + sleepUntilDeadline: { try await Task.sleep(for: timeout, clock: .suspending) }, + makeTimeoutError: { TimeoutError(operationName: operationName, timeout: timeout) }, + operation: operation + ) } /// Races an async operation against a deadline, throwing `CancellationError` when @@ -37,14 +37,14 @@ public func withTimeout( /// elapsing while the app is suspended. See `raceAgainstDeadline` for the /// cancellation contract. func withCooperativeTimeout( - seconds: TimeInterval, - operation: @escaping @Sendable () async throws -> T + seconds: TimeInterval, + operation: @escaping @Sendable () async throws -> T ) async throws -> T { - try await raceAgainstDeadline( - sleepUntilDeadline: { try await Task.sleep(for: .seconds(seconds)) }, - makeTimeoutError: { CancellationError() }, - operation: operation - ) + try await raceAgainstDeadline( + sleepUntilDeadline: { try await Task.sleep(for: .seconds(seconds)) }, + makeTimeoutError: { CancellationError() }, + operation: operation + ) } /// Core racer shared by `withTimeout` and `withCooperativeTimeout`. @@ -60,20 +60,20 @@ func withCooperativeTimeout( /// in-flight continuation it owned (e.g. `BLETransportOpenedSignal.wait`'s pending /// waiter slot). private func raceAgainstDeadline( - sleepUntilDeadline: @escaping @Sendable () async throws -> Void, - makeTimeoutError: @escaping @Sendable () -> any Error, - operation: @escaping @Sendable () async throws -> T + sleepUntilDeadline: @escaping @Sendable () async throws -> Void, + makeTimeoutError: @escaping @Sendable () -> any Error, + operation: @escaping @Sendable () async throws -> T ) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - defer { group.cancelAll() } - group.addTask { try await operation() } - group.addTask { - try await sleepUntilDeadline() - throw makeTimeoutError() - } - guard let result = try await group.next() else { - throw makeTimeoutError() - } - return result + try await withThrowingTaskGroup(of: T.self) { group in + defer { group.cancelAll() } + group.addTask { try await operation() } + group.addTask { + try await sleepUntilDeadline() + throw makeTimeoutError() + } + guard let result = try await group.next() else { + throw makeTimeoutError() } + return result + } } diff --git a/MC1Services/Tests/MC1ServicesTests/AppBackupEnvelopeTests.swift b/MC1Services/Tests/MC1ServicesTests/AppBackupEnvelopeTests.swift index 2363f958..9172b7bb 100644 --- a/MC1Services/Tests/MC1ServicesTests/AppBackupEnvelopeTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/AppBackupEnvelopeTests.swift @@ -1,349 +1,348 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("AppBackupEnvelope") struct AppBackupEnvelopeTests { - - // MARK: - Round-trip encoding - - @Test("Envelope round-trips through JSON encode/decode") - func envelopeRoundTrip() throws { - let radioID = UUID() - let envelope = makeTestEnvelope(radioID: radioID) - - let encoder = makeBackupJSONEncoder() - let json = try encoder.encode(envelope) - - let decoder = makeBackupJSONDecoder() - let decoded = try decoder.decode(AppBackupEnvelope.self, from: json) - - #expect(decoded.version == envelope.version) - #expect(decoded.appVersion == envelope.appVersion) - #expect(decoded.appBuild == envelope.appBuild) - #expect(decoded.manifest == envelope.manifest) - #expect(decoded.devices.count == envelope.devices.count) - #expect(decoded.contacts.count == envelope.contacts.count) - #expect(decoded.channels.count == envelope.channels.count) - #expect(decoded.messages.count == envelope.messages.count) - #expect(decoded.messageRepeats.count == envelope.messageRepeats.count) - #expect(decoded.reactions.count == envelope.reactions.count) - #expect(decoded.roomMessages.count == envelope.roomMessages.count) - #expect(decoded.remoteNodeSessions.count == envelope.remoteNodeSessions.count) - #expect(decoded.savedTracePaths.count == envelope.savedTracePaths.count) - #expect(decoded.blockedChannelSenders.count == envelope.blockedChannelSenders.count) - #expect(decoded.nodeStatusSnapshots.count == envelope.nodeStatusSnapshots.count) - #expect(decoded.userDefaults == envelope.userDefaults) - } - - @Test("Backup JSON round-trips sub-second timestamps without truncation") - func backupJSONPreservesSubsecondTimestamps() throws { - let radioID = UUID() - let exportDate = Date(timeIntervalSince1970: 1_700_000_500.9876542) - let messageDate = Date(timeIntervalSince1970: 1_700_000_501.1234567) - let snapshotTimestamp = Date(timeIntervalSince1970: 1_700_000_502.7654321) - - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let session = RemoteNodeSessionDTO.testSession( - radioID: radioID, - lastMessageDate: messageDate - ) - let snapshot = NodeStatusSnapshotDTO.testSnapshot( - timestamp: snapshotTimestamp, - nodePublicKey: Data(repeating: 0xAB, count: 32) - ) - let envelope = AppBackupEnvelope( - exportDate: exportDate, - appVersion: "1.0.0", - appBuild: "42", - manifest: BackupManifest( - deviceCount: 1, - remoteNodeSessionCount: 1, - nodeStatusSnapshotCount: 1 - ), - devices: [device], - remoteNodeSessions: [session], - nodeStatusSnapshots: [snapshot] - ) - - let json = try makeBackupJSONEncoder().encode(envelope) - let decoded = try makeBackupJSONDecoder().decode(AppBackupEnvelope.self, from: json) - - #expect(decoded.exportDate == exportDate) - #expect(decoded.remoteNodeSessions.first?.lastMessageDate == messageDate) - #expect(decoded.nodeStatusSnapshots.first?.timestamp == snapshotTimestamp) - } - - // MARK: - parseBackup (compress -> parse round-trip) - - @Test("parseBackup decompresses and decodes a valid backup") - func parseBackupValidFile() throws { - let envelope = makeTestEnvelope(radioID: UUID()) - - let json = try makeBackupJSONEncoder().encode(envelope) - let compressed = try json.zlibCompressed() - - let parsed = try parseBackup(data: compressed) - #expect(parsed.version == AppBackupEnvelope.currentVersion) - #expect(parsed.appVersion == "1.0.0") - #expect(parsed.devices.count == 1) - #expect(parsed.contacts.count == 1) - } - - @Test("parseBackup throws invalidFile for garbage data") - func parseBackupGarbageData() { - let garbage = Data([0x00, 0xFF, 0xAB, 0xCD]) - #expect(throws: AppBackupError.self) { - try parseBackup(data: garbage) - } - } - - @Test("parseBackup throws invalidFile for truncated zlib payloads") - func parseBackupTruncatedPayload() throws { - let envelope = makeTestEnvelope(radioID: UUID()) - let json = try makeBackupJSONEncoder().encode(envelope) - let compressed = try json.zlibCompressed() - // Valid zlib header, missing trailer + tail of deflate stream — matches - // what a truncated download or interrupted file-copy would produce. - let truncated = Data(compressed.prefix(max(compressed.count / 2, 4))) - - #expect { - try parseBackup(data: truncated) - } throws: { error in - guard let backupError = error as? AppBackupError, - case .invalidFile = backupError else { - return false - } - return true - } - } - - @Test("parseBackup rejects files larger than the size cap") - func parseBackupRejectsOversizedFile() { - let oversized = Data(count: maxBackupCompressedBytes + 1) - #expect { - try parseBackup(data: oversized) - } throws: { error in - guard let backupError = error as? AppBackupError, - case .fileTooLarge = backupError else { - return false - } - return true - } - } - - @Test("parseBackup rejects payloads whose decompressed size exceeds the cap") - func parseBackupRejectsDecompressionBomb() throws { - // 2 MB of zeros compresses to a few KB, well under the compressed cap. - // With a 1 MB uncompressed cap, decompression must abort partway through. - let testUncompressedCap = 1 * 1_048_576 - let bomb = Data(count: 2 * 1_048_576) - let compressed = try bomb.zlibCompressed() - #expect(compressed.count < maxBackupCompressedBytes) - - #expect { - try parseBackup(data: compressed, maxUncompressedBytes: testUncompressedCap) - } throws: { error in - guard let backupError = error as? AppBackupError, - case .decompressedTooLarge(let maxBytes) = backupError else { - return false - } - return maxBytes == testUncompressedCap - } + // MARK: - Round-trip encoding + + @Test + func `Envelope round-trips through JSON encode/decode`() throws { + let radioID = UUID() + let envelope = makeTestEnvelope(radioID: radioID) + + let encoder = makeBackupJSONEncoder() + let json = try encoder.encode(envelope) + + let decoder = makeBackupJSONDecoder() + let decoded = try decoder.decode(AppBackupEnvelope.self, from: json) + + #expect(decoded.version == envelope.version) + #expect(decoded.appVersion == envelope.appVersion) + #expect(decoded.appBuild == envelope.appBuild) + #expect(decoded.manifest == envelope.manifest) + #expect(decoded.devices.count == envelope.devices.count) + #expect(decoded.contacts.count == envelope.contacts.count) + #expect(decoded.channels.count == envelope.channels.count) + #expect(decoded.messages.count == envelope.messages.count) + #expect(decoded.messageRepeats.count == envelope.messageRepeats.count) + #expect(decoded.reactions.count == envelope.reactions.count) + #expect(decoded.roomMessages.count == envelope.roomMessages.count) + #expect(decoded.remoteNodeSessions.count == envelope.remoteNodeSessions.count) + #expect(decoded.savedTracePaths.count == envelope.savedTracePaths.count) + #expect(decoded.blockedChannelSenders.count == envelope.blockedChannelSenders.count) + #expect(decoded.nodeStatusSnapshots.count == envelope.nodeStatusSnapshots.count) + #expect(decoded.userDefaults == envelope.userDefaults) + } + + @Test + func `Backup JSON round-trips sub-second timestamps without truncation`() throws { + let radioID = UUID() + let exportDate = Date(timeIntervalSince1970: 1_700_000_500.9876542) + let messageDate = Date(timeIntervalSince1970: 1_700_000_501.1234567) + let snapshotTimestamp = Date(timeIntervalSince1970: 1_700_000_502.7654321) + + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let session = RemoteNodeSessionDTO.testSession( + radioID: radioID, + lastMessageDate: messageDate + ) + let snapshot = NodeStatusSnapshotDTO.testSnapshot( + timestamp: snapshotTimestamp, + nodePublicKey: Data(repeating: 0xAB, count: 32) + ) + let envelope = AppBackupEnvelope( + exportDate: exportDate, + appVersion: "1.0.0", + appBuild: "42", + manifest: BackupManifest( + deviceCount: 1, + remoteNodeSessionCount: 1, + nodeStatusSnapshotCount: 1 + ), + devices: [device], + remoteNodeSessions: [session], + nodeStatusSnapshots: [snapshot] + ) + + let json = try makeBackupJSONEncoder().encode(envelope) + let decoded = try makeBackupJSONDecoder().decode(AppBackupEnvelope.self, from: json) + + #expect(decoded.exportDate == exportDate) + #expect(decoded.remoteNodeSessions.first?.lastMessageDate == messageDate) + #expect(decoded.nodeStatusSnapshots.first?.timestamp == snapshotTimestamp) + } + + // MARK: - parseBackup (compress -> parse round-trip) + + @Test + func `parseBackup decompresses and decodes a valid backup`() throws { + let envelope = makeTestEnvelope(radioID: UUID()) + + let json = try makeBackupJSONEncoder().encode(envelope) + let compressed = try json.zlibCompressed() + + let parsed = try parseBackup(data: compressed) + #expect(parsed.version == AppBackupEnvelope.currentVersion) + #expect(parsed.appVersion == "1.0.0") + #expect(parsed.devices.count == 1) + #expect(parsed.contacts.count == 1) + } + + @Test + func `parseBackup throws invalidFile for garbage data`() { + let garbage = Data([0x00, 0xFF, 0xAB, 0xCD]) + #expect(throws: AppBackupError.self) { + try parseBackup(data: garbage) } - - @Test("parseBackup throws unsupportedVersion for future versions") - func parseBackupFutureVersion() throws { - let envelope = makeTestEnvelope(radioID: UUID()) - let encoded = try makeBackupJSONEncoder().encode(envelope) - guard var json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { - Issue.record("Failed to deserialize envelope JSON") - return - } - json["version"] = 999 - let modified = try JSONSerialization.data(withJSONObject: json) - let compressed = try modified.zlibCompressed() - - #expect { - try parseBackup(data: compressed) - } throws: { error in - guard let backupError = error as? AppBackupError, - case .unsupportedVersion(let found, let max) = backupError else { - return false - } - return found == 999 && max == AppBackupEnvelope.currentVersion - } + } + + @Test + func `parseBackup throws invalidFile for truncated zlib payloads`() throws { + let envelope = makeTestEnvelope(radioID: UUID()) + let json = try makeBackupJSONEncoder().encode(envelope) + let compressed = try json.zlibCompressed() + // Valid zlib header, missing trailer + tail of deflate stream — matches + // what a truncated download or interrupted file-copy would produce. + let truncated = Data(compressed.prefix(max(compressed.count / 2, 4))) + + #expect { + try parseBackup(data: truncated) + } throws: { error in + guard let backupError = error as? AppBackupError, + case .invalidFile = backupError else { + return false + } + return true } - - @Test("parseBackup throws corruptedManifest when counts mismatch") - func parseBackupCorruptedManifest() throws { - let envelope = makeTestEnvelope(radioID: UUID()) - let wrongManifest = BackupManifest(deviceCount: 0) - let tampered = AppBackupEnvelope( - appVersion: envelope.appVersion, - appBuild: envelope.appBuild, - manifest: wrongManifest, - devices: envelope.devices, - contacts: envelope.contacts - ) - - let json = try makeBackupJSONEncoder().encode(tampered) - let compressed = try json.zlibCompressed() - - #expect(throws: AppBackupError.self) { - try parseBackup(data: compressed) - } + } + + @Test + func `parseBackup rejects files larger than the size cap`() { + let oversized = Data(count: maxBackupCompressedBytes + 1) + #expect { + try parseBackup(data: oversized) + } throws: { error in + guard let backupError = error as? AppBackupError, + case .fileTooLarge = backupError else { + return false + } + return true } - - // MARK: - BackupManifest validation - - @Test("Manifest validates correctly when counts match") - func manifestValidatesCorrectly() { - let envelope = makeTestEnvelope(radioID: UUID()) - #expect(envelope.manifest.validate(against: envelope)) + } + + @Test + func `parseBackup rejects payloads whose decompressed size exceeds the cap`() throws { + // 2 MB of zeros compresses to a few KB, well under the compressed cap. + // With a 1 MB uncompressed cap, decompression must abort partway through. + let testUncompressedCap = 1 * 1_048_576 + let bomb = Data(count: 2 * 1_048_576) + let compressed = try bomb.zlibCompressed() + #expect(compressed.count < maxBackupCompressedBytes) + + #expect { + try parseBackup(data: compressed, maxUncompressedBytes: testUncompressedCap) + } throws: { error in + guard let backupError = error as? AppBackupError, + case let .decompressedTooLarge(maxBytes) = backupError else { + return false + } + return maxBytes == testUncompressedCap } - - @Test("Manifest detects mismatch") - func manifestDetectsMismatch() { - var envelope = makeTestEnvelope(radioID: UUID()) - envelope.devices.append(DeviceDTO.testDevice()) - #expect(!envelope.manifest.validate(against: envelope)) + } + + @Test + func `parseBackup throws unsupportedVersion for future versions`() throws { + let envelope = makeTestEnvelope(radioID: UUID()) + let encoded = try makeBackupJSONEncoder().encode(envelope) + guard var json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { + Issue.record("Failed to deserialize envelope JSON") + return } - - // MARK: - ImportResult - - @Test("ImportResult computes totals correctly") - func importResultTotals() { - var result = ImportResult() - result.record(.devices, inserted: 2) - result.record(.contacts, inserted: 5, merged: 1) - result.record(.messages, skipped: 3) - result.userDefaultsRestored = true - - #expect(result.totalInserted == 7) - #expect(result.totalMerged == 1) - #expect(result.totalSkipped == 3) - #expect(result.hasRestoredChanges) + json["version"] = 999 + let modified = try JSONSerialization.data(withJSONObject: json) + let compressed = try modified.zlibCompressed() + + #expect { + try parseBackup(data: compressed) + } throws: { error in + guard let backupError = error as? AppBackupError, + case let .unsupportedVersion(found, max) = backupError else { + return false + } + return found == 999 && max == AppBackupEnvelope.currentVersion } - - // MARK: - BackupUserDefaults Codable round-trip - - @Test("BackupUserDefaults round-trips through JSON") - func userDefaultsRoundTrip() throws { - var prefs = BackupUserDefaults() - prefs.hasCompletedOnboarding = true - prefs.mapStyleSelection = "topo" - prefs.autoDeleteStaleNodesDays = 30 - prefs.frequentEmojis = ["👍", "❤️"] - prefs.recentEmojis = ["😂", "😮"] - prefs.notifyContactMessages = false - prefs.linkPreviewsEnabled = true - prefs.showIncomingRegion = true - prefs.showIncomingSendTime = true - - let data = try JSONEncoder().encode(prefs) - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) - - #expect(decoded == prefs) - #expect(decoded.showIncomingRegion == true) - #expect(decoded.showIncomingSendTime == true) - } - - @Test("Legacy envelope without showIncomingSendTime decodes to nil and restore skips it") - func legacyEnvelopeMissingShowIncomingSendTime() throws { - // A backup predating the toggle has no key. decodeIfPresent must yield nil, - // and write-if-missing restore must leave any existing local value untouched. - let legacyJSON = "{\"hasCompletedOnboarding\":true}" - let data = Data(legacyJSON.utf8) - - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) - #expect(decoded.showIncomingSendTime == nil) - - let suiteName = "test.showIncomingSendTime.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let key = AppStorageKey.showIncomingSendTime.rawValue - let setKeys = decoded.restore(to: defaults) - #expect(!setKeys.contains(key)) - #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `parseBackup throws corruptedManifest when counts mismatch`() throws { + let envelope = makeTestEnvelope(radioID: UUID()) + let wrongManifest = BackupManifest(deviceCount: 0) + let tampered = AppBackupEnvelope( + appVersion: envelope.appVersion, + appBuild: envelope.appBuild, + manifest: wrongManifest, + devices: envelope.devices, + contacts: envelope.contacts + ) + + let json = try makeBackupJSONEncoder().encode(tampered) + let compressed = try json.zlibCompressed() + + #expect(throws: AppBackupError.self) { + try parseBackup(data: compressed) } - - // MARK: - AppBackupError descriptions - - @Test("AppBackupError provides user-facing descriptions") - func errorDescriptions() { - let errors: [AppBackupError] = [ - .invalidFile, - .fileTooLarge(actualBytes: 100_000_000, maxBytes: 50_000_000), - .unsupportedVersion(found: 5, maxSupported: 1), - .corruptedManifest, - .exportFailed(underlying: NSError(domain: "test", code: 1)), - .importFailed(underlying: NSError(domain: "test", code: 2)), - ] - - for error in errors { - #expect(error.errorDescription != nil) - } - - // Verify importFailed does not claim "no data was changed" - let importError = AppBackupError.importFailed(underlying: NSError(domain: "", code: 0)) - let description = importError.errorDescription ?? "" - #expect(!description.lowercased().contains("no data was changed")) + } + + // MARK: - BackupManifest validation + + @Test + func `Manifest validates correctly when counts match`() { + let envelope = makeTestEnvelope(radioID: UUID()) + #expect(envelope.manifest.validate(against: envelope)) + } + + @Test + func `Manifest detects mismatch`() { + var envelope = makeTestEnvelope(radioID: UUID()) + envelope.devices.append(DeviceDTO.testDevice()) + #expect(!envelope.manifest.validate(against: envelope)) + } + + // MARK: - ImportResult + + @Test + func `ImportResult computes totals correctly`() { + var result = ImportResult() + result.record(.devices, inserted: 2) + result.record(.contacts, inserted: 5, merged: 1) + result.record(.messages, skipped: 3) + result.userDefaultsRestored = true + + #expect(result.totalInserted == 7) + #expect(result.totalMerged == 1) + #expect(result.totalSkipped == 3) + #expect(result.hasRestoredChanges) + } + + // MARK: - BackupUserDefaults Codable round-trip + + @Test + func `BackupUserDefaults round-trips through JSON`() throws { + var prefs = BackupUserDefaults() + prefs.hasCompletedOnboarding = true + prefs.mapStyleSelection = "topo" + prefs.autoDeleteStaleNodesDays = 30 + prefs.frequentEmojis = ["👍", "❤️"] + prefs.recentEmojis = ["😂", "😮"] + prefs.notifyContactMessages = false + prefs.linkPreviewsEnabled = true + prefs.showIncomingRegion = true + prefs.showIncomingSendTime = true + + let data = try JSONEncoder().encode(prefs) + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + + #expect(decoded == prefs) + #expect(decoded.showIncomingRegion == true) + #expect(decoded.showIncomingSendTime == true) + } + + @Test + func `Legacy envelope without showIncomingSendTime decodes to nil and restore skips it`() throws { + // A backup predating the toggle has no key. decodeIfPresent must yield nil, + // and write-if-missing restore must leave any existing local value untouched. + let legacyJSON = "{\"hasCompletedOnboarding\":true}" + let data = Data(legacyJSON.utf8) + + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + #expect(decoded.showIncomingSendTime == nil) + + let suiteName = "test.showIncomingSendTime.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let key = AppStorageKey.showIncomingSendTime.rawValue + let setKeys = decoded.restore(to: defaults) + #expect(!setKeys.contains(key)) + #expect(defaults.object(forKey: key) == nil) + } + + // MARK: - AppBackupError descriptions + + @Test + func `AppBackupError provides user-facing descriptions`() { + let errors: [AppBackupError] = [ + .invalidFile, + .fileTooLarge(actualBytes: 100_000_000, maxBytes: 50_000_000), + .unsupportedVersion(found: 5, maxSupported: 1), + .corruptedManifest, + .exportFailed(underlying: NSError(domain: "test", code: 1)), + .importFailed(underlying: NSError(domain: "test", code: 2)), + ] + + for error in errors { + #expect(error.errorDescription != nil) } - // MARK: - Helpers - - private func makeTestEnvelope(radioID: UUID) -> AppBackupEnvelope { - let device = DeviceDTO.testDevice() - let contact = ContactDTO.testContact(radioID: radioID) - let channel = ChannelDTO.testChannel(radioID: radioID) - let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: UUID()) - let messageRepeat = MessageRepeatDTO.testRepeat(messageID: UUID()) - let reaction = ReactionDTO.testReaction(messageID: UUID(), radioID: radioID) - let roomMessage = RoomMessageDTO.testRoomMessage(sessionID: UUID()) - let session = RemoteNodeSessionDTO.testSession(radioID: radioID) - let tracePath = SavedTracePathDTO.testPath(radioID: radioID, runs: [TracePathRunDTO.testRun()]) - let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) - let snapshot = NodeStatusSnapshotDTO.testSnapshot( - nodePublicKey: Data(repeating: 0xAA, count: 32), - neighborSnapshots: [] - ) - - var prefs = BackupUserDefaults() - prefs.hasCompletedOnboarding = true - prefs.mapStyleSelection = "standard" - - let manifest = BackupManifest( - deviceCount: 1, - contactCount: 1, - channelCount: 1, - messageCount: 1, - messageRepeatCount: 1, - reactionCount: 1, - roomMessageCount: 1, - remoteNodeSessionCount: 1, - savedTracePathCount: 1, - blockedChannelSenderCount: 1, - nodeStatusSnapshotCount: 1 - ) - - return AppBackupEnvelope( - appVersion: "1.0.0", - appBuild: "42", - manifest: manifest, - devices: [device], - contacts: [contact], - channels: [channel], - messages: [message], - messageRepeats: [messageRepeat], - reactions: [reaction], - roomMessages: [roomMessage], - remoteNodeSessions: [session], - savedTracePaths: [tracePath], - blockedChannelSenders: [blocked], - nodeStatusSnapshots: [snapshot], - userDefaults: prefs - ) - } + // Verify importFailed does not claim "no data was changed" + let importError = AppBackupError.importFailed(underlying: NSError(domain: "", code: 0)) + let description = importError.errorDescription ?? "" + #expect(!description.lowercased().contains("no data was changed")) + } + + // MARK: - Helpers + + private func makeTestEnvelope(radioID: UUID) -> AppBackupEnvelope { + let device = DeviceDTO.testDevice() + let contact = ContactDTO.testContact(radioID: radioID) + let channel = ChannelDTO.testChannel(radioID: radioID) + let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: UUID()) + let messageRepeat = MessageRepeatDTO.testRepeat(messageID: UUID()) + let reaction = ReactionDTO.testReaction(messageID: UUID(), radioID: radioID) + let roomMessage = RoomMessageDTO.testRoomMessage(sessionID: UUID()) + let session = RemoteNodeSessionDTO.testSession(radioID: radioID) + let tracePath = SavedTracePathDTO.testPath(radioID: radioID, runs: [TracePathRunDTO.testRun()]) + let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) + let snapshot = NodeStatusSnapshotDTO.testSnapshot( + nodePublicKey: Data(repeating: 0xAA, count: 32), + neighborSnapshots: [] + ) + + var prefs = BackupUserDefaults() + prefs.hasCompletedOnboarding = true + prefs.mapStyleSelection = "standard" + + let manifest = BackupManifest( + deviceCount: 1, + contactCount: 1, + channelCount: 1, + messageCount: 1, + messageRepeatCount: 1, + reactionCount: 1, + roomMessageCount: 1, + remoteNodeSessionCount: 1, + savedTracePathCount: 1, + blockedChannelSenderCount: 1, + nodeStatusSnapshotCount: 1 + ) + + return AppBackupEnvelope( + appVersion: "1.0.0", + appBuild: "42", + manifest: manifest, + devices: [device], + contacts: [contact], + channels: [channel], + messages: [message], + messageRepeats: [messageRepeat], + reactions: [reaction], + roomMessages: [roomMessage], + remoteNodeSessions: [session], + savedTracePaths: [tracePath], + blockedChannelSenders: [blocked], + nodeStatusSnapshots: [snapshot], + userDefaults: prefs + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/AppBackupServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/AppBackupServiceTests.swift index cef04d18..46ad10a5 100644 --- a/MC1Services/Tests/MC1ServicesTests/AppBackupServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/AppBackupServiceTests.swift @@ -1,628 +1,627 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("AppBackupService") struct AppBackupServiceTests { - - // MARK: - Export: happy path - - @Test("Export produces valid compressed backup") - func exportProducesValidBackup() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Seed one contact and two messages (one with link preview blobs) - let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xAA, count: 32)) - try await store.saveContact(contact) - - let plainMsg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) - try await store.saveMessage(plainMsg) - - let blobMsgID = UUID() - let blobMsg = MessageDTO.testDirectMessage( - id: blobMsgID, - radioID: radioID, - contactID: contact.id, - text: "Check this out: https://example.com" - ) - try await store.saveMessage(blobMsg) - try await store.updateMessageLinkPreview( - id: blobMsgID, - url: "https://example.com", - title: "Example", - imageData: Data(repeating: 0xFF, count: 1024), - iconData: Data(repeating: 0xFE, count: 32), - fetched: true - ) - - let service = AppBackupService() - - let result = try await service.export(persistenceStore: store) - - // Must be non-empty compressed data - #expect(!result.data.isEmpty) - - // Round-trip: parse it back - let envelope = try parseBackup(data: result.data) - - // Manifest counts - #expect(envelope.devices.count == 1) - #expect(envelope.contacts.count == 1) - #expect(envelope.messages.count == 2) - #expect(envelope.manifest.validate(against: envelope)) - } - - // MARK: - Link preview blobs are stripped - - @Test("Export strips link preview blobs from messages") - func exportStripsLinkPreviewBlobs() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xBB, count: 32)) - try await store.saveContact(contact) - - let msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) - try await store.saveMessage(msg) - - // Populate link preview fields (set separately after initial save) - try await store.updateMessageLinkPreview( - id: msg.id, - url: "https://example.com", - title: "Example Domain", - imageData: Data(repeating: 0x01, count: 500), - iconData: Data(repeating: 0x02, count: 64), - fetched: true - ) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - let envelope = try parseBackup(data: result.data) - let exported = try #require(envelope.messages.first) - - // URL and title are preserved - #expect(exported.linkPreviewURL == "https://example.com") - #expect(exported.linkPreviewTitle == "Example Domain") - - // Blobs and fetched flag are cleared - #expect(exported.linkPreviewImageData == nil) - #expect(exported.linkPreviewIconData == nil) - #expect(exported.linkPreviewFetched == false) - } - - // MARK: - Empty store - - @Test("Export succeeds with empty store") - func exportEmptyStore() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - let envelope = try parseBackup(data: result.data) - #expect(envelope.devices.count == 1) - #expect(envelope.contacts.count == 0) - #expect(envelope.messages.count == 0) - #expect(envelope.manifest.validate(against: envelope)) - } - - // MARK: - Manifest accuracy - - @Test("Manifest counts match exported array sizes") - func manifestCountsMatchArraySizes() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Add 3 contacts - for i in 0..<3 { - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: UInt8(i + 1), count: 32) - ) - try await store.saveContact(contact) - } - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - let envelope = try parseBackup(data: result.data) - #expect(envelope.manifest.contactCount == 3) - #expect(envelope.manifest.deviceCount == 1) - #expect(envelope.manifest.validate(against: envelope)) - } - - // MARK: - Output is compressed - - @Test("Export output is smaller than raw JSON for large payloads") - func exportOutputIsCompressed() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Add enough messages to make compression meaningful - for i in 0..<20 { - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: UInt8(i % 255 + 1), count: 32) + Data([UInt8(i / 255)]) - ) - try await store.saveContact(contact) - } - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - // Verify it decompresses successfully (proves it is valid compressed data) - let decompressed = try (result.data as NSData).decompressed(using: .zlib) as Data - #expect(decompressed.count > result.data.count) + // MARK: - Export: happy path + + @Test + func `Export produces valid compressed backup`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Seed one contact and two messages (one with link preview blobs) + let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xAA, count: 32)) + try await store.saveContact(contact) + + let plainMsg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) + try await store.saveMessage(plainMsg) + + let blobMsgID = UUID() + let blobMsg = MessageDTO.testDirectMessage( + id: blobMsgID, + radioID: radioID, + contactID: contact.id, + text: "Check this out: https://example.com" + ) + try await store.saveMessage(blobMsg) + try await store.updateMessageLinkPreview( + id: blobMsgID, + url: "https://example.com", + title: "Example", + imageData: Data(repeating: 0xFF, count: 1024), + iconData: Data(repeating: 0xFE, count: 32), + fetched: true + ) + + let service = AppBackupService() + + let result = try await service.export(persistenceStore: store) + + // Must be non-empty compressed data + #expect(!result.data.isEmpty) + + // Round-trip: parse it back + let envelope = try parseBackup(data: result.data) + + // Manifest counts + #expect(envelope.devices.count == 1) + #expect(envelope.contacts.count == 1) + #expect(envelope.messages.count == 2) + #expect(envelope.manifest.validate(against: envelope)) + } + + // MARK: - Link preview blobs are stripped + + @Test + func `Export strips link preview blobs from messages`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xBB, count: 32)) + try await store.saveContact(contact) + + let msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) + try await store.saveMessage(msg) + + // Populate link preview fields (set separately after initial save) + try await store.updateMessageLinkPreview( + id: msg.id, + url: "https://example.com", + title: "Example Domain", + imageData: Data(repeating: 0x01, count: 500), + iconData: Data(repeating: 0x02, count: 64), + fetched: true + ) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + let envelope = try parseBackup(data: result.data) + let exported = try #require(envelope.messages.first) + + // URL and title are preserved + #expect(exported.linkPreviewURL == "https://example.com") + #expect(exported.linkPreviewTitle == "Example Domain") + + // Blobs and fetched flag are cleared + #expect(exported.linkPreviewImageData == nil) + #expect(exported.linkPreviewIconData == nil) + #expect(exported.linkPreviewFetched == false) + } + + // MARK: - Empty store + + @Test + func `Export succeeds with empty store`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + let envelope = try parseBackup(data: result.data) + #expect(envelope.devices.count == 1) + #expect(envelope.contacts.count == 0) + #expect(envelope.messages.count == 0) + #expect(envelope.manifest.validate(against: envelope)) + } + + // MARK: - Manifest accuracy + + @Test + func `Manifest counts match exported array sizes`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Add 3 contacts + for i in 0..<3 { + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: UInt8(i + 1), count: 32) + ) + try await store.saveContact(contact) } - // MARK: - Version and source bundle - - @Test("Envelope contains expected version") - func envelopeVersionIsCorrect() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - let envelope = try parseBackup(data: result.data) - #expect(envelope.version == AppBackupEnvelope.currentVersion) + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + let envelope = try parseBackup(data: result.data) + #expect(envelope.manifest.contactCount == 3) + #expect(envelope.manifest.deviceCount == 1) + #expect(envelope.manifest.validate(against: envelope)) + } + + // MARK: - Output is compressed + + @Test + func `Export output is smaller than raw JSON for large payloads`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Add enough messages to make compression meaningful + for i in 0..<20 { + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: UInt8(i % 255 + 1), count: 32) + Data([UInt8(i / 255)]) + ) + try await store.saveContact(contact) } - // MARK: - Import: into empty store - - @Test("Import into empty store restores all records") - func importIntoEmptyStore() async throws { - let radioID = UUID() - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID, publicKey: Data(repeating: 0x01, count: 32)) - let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xAA, count: 32)) - let channel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") - let msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) - let session = RemoteNodeSessionDTO.testSession(radioID: radioID) - let roomMsg = RoomMessageDTO.testRoomMessage(sessionID: session.id) - let tracePath = SavedTracePathDTO.testPath(radioID: radioID) - let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) - let snapshot = NodeStatusSnapshotDTO.testSnapshot() - let reaction = ReactionDTO.testReaction(messageID: msg.id, radioID: radioID) - let msgRepeat = MessageRepeatDTO.testRepeat(messageID: msg.id) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - channels: [channel], - messages: [msg], - messageRepeats: [msgRepeat], - reactions: [reaction], - roomMessages: [roomMsg], - remoteNodeSessions: [session], - savedTracePaths: [tracePath], - blockedChannelSenders: [blocked], - nodeStatusSnapshots: [snapshot] - ) - - // Import into a fresh empty store - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.devicesInserted == 1) - #expect(result.contactsInserted == 1) - #expect(result.channelsInserted == 1) - #expect(result.messagesInserted == 1) - #expect(result.messageRepeatsInserted == 1) - #expect(result.reactionsInserted == 1) - #expect(result.remoteNodeSessionsInserted == 1) - #expect(result.roomMessagesInserted == 1) - #expect(result.savedTracePathsInserted == 1) - #expect(result.blockedChannelSendersInserted == 1) - #expect(result.nodeStatusSnapshotsInserted == 1) - #expect(result.totalSkipped == 0) - - // Verify records persisted - let destDevices = try await destStore.fetchAllDevices() - #expect(destDevices.count == 1) - - let destContacts = try await destStore.fetchAllContacts(radioID: radioID) - #expect(destContacts.count == 1) + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + // Verify it decompresses successfully (proves it is valid compressed data) + let decompressed = try (result.data as NSData).decompressed(using: .zlib) as Data + #expect(decompressed.count > result.data.count) + } + + // MARK: - Version and source bundle + + @Test + func `Envelope contains expected version`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + let envelope = try parseBackup(data: result.data) + #expect(envelope.version == AppBackupEnvelope.currentVersion) + } + + // MARK: - Import: into empty store + + @Test + func `Import into empty store restores all records`() async throws { + let radioID = UUID() + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID, publicKey: Data(repeating: 0x01, count: 32)) + let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xAA, count: 32)) + let channel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") + let msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) + let session = RemoteNodeSessionDTO.testSession(radioID: radioID) + let roomMsg = RoomMessageDTO.testRoomMessage(sessionID: session.id) + let tracePath = SavedTracePathDTO.testPath(radioID: radioID) + let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) + let snapshot = NodeStatusSnapshotDTO.testSnapshot() + let reaction = ReactionDTO.testReaction(messageID: msg.id, radioID: radioID) + let msgRepeat = MessageRepeatDTO.testRepeat(messageID: msg.id) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + channels: [channel], + messages: [msg], + messageRepeats: [msgRepeat], + reactions: [reaction], + roomMessages: [roomMsg], + remoteNodeSessions: [session], + savedTracePaths: [tracePath], + blockedChannelSenders: [blocked], + nodeStatusSnapshots: [snapshot] + ) + + // Import into a fresh empty store + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.devicesInserted == 1) + #expect(result.contactsInserted == 1) + #expect(result.channelsInserted == 1) + #expect(result.messagesInserted == 1) + #expect(result.messageRepeatsInserted == 1) + #expect(result.reactionsInserted == 1) + #expect(result.remoteNodeSessionsInserted == 1) + #expect(result.roomMessagesInserted == 1) + #expect(result.savedTracePathsInserted == 1) + #expect(result.blockedChannelSendersInserted == 1) + #expect(result.nodeStatusSnapshotsInserted == 1) + #expect(result.totalSkipped == 0) + + // Verify records persisted + let destDevices = try await destStore.fetchAllDevices() + #expect(destDevices.count == 1) + + let destContacts = try await destStore.fetchAllContacts(radioID: radioID) + #expect(destContacts.count == 1) + } + + // MARK: - Import: radioID remapping + + @Test + func `Import remaps radioID when local device has same publicKey`() async throws { + let sharedPublicKey = Data(repeating: 0xDD, count: 32) + let backupRadioID = UUID() + let localRadioID = UUID() + + // Build a backup envelope with the backup radioID + let backupDevice = DeviceDTO.testDevice( + id: backupRadioID, + radioID: backupRadioID, + publicKey: sharedPublicKey + ) + let backupContact = ContactDTO.testContact( + radioID: backupRadioID, + publicKey: Data(repeating: 0xEE, count: 32) + ) + let backupChannel = ChannelDTO.testChannel(radioID: backupRadioID, index: 1, name: "Remapped") + let backupMessage = MessageDTO.testDirectMessage(radioID: backupRadioID, contactID: backupContact.id) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + contacts: [backupContact], + channels: [backupChannel], + messages: [backupMessage] + ) + + // Create local store with a device using the same publicKey but different radioID + let localStore = try await PersistenceStore.createTestDataStore(radioID: localRadioID) + // Replace the test device with one that has the shared public key + let localDevice = DeviceDTO.testDevice( + id: localRadioID, + radioID: localRadioID, + publicKey: sharedPublicKey + ) + try await localStore.saveDevice(localDevice) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: localStore + ) + + // Backup device should be skipped (matched by publicKey) + #expect(result.devicesInserted == 0) + + // Child records should be inserted with the local radioID + #expect(result.contactsInserted == 1) + #expect(result.channelsInserted == 1) + #expect(result.messagesInserted == 1) + + // Verify the contact was stored with localRadioID, not backupRadioID + let localContacts = try await localStore.fetchAllContacts(radioID: localRadioID) + #expect(localContacts.count == 1) + + // The backup radioID should have no contacts + let backupContacts = try await localStore.fetchAllContacts(radioID: backupRadioID) + #expect(backupContacts.count == 0) + + // Same for channels + let localChannels = try await localStore.fetchAllChannels(radioID: localRadioID) + let remappedChannel = localChannels.first { $0.name == "Remapped" } + #expect(remappedChannel != nil) + } + + // MARK: - Export: device redaction + + @Test + func `Export redacts sensitive device fields (BLE PIN, radio config)`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Override the test device with sensitive values + let sensitiveDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { + $0.blePin = 123_456 + $0.frequency = 869_500 + $0.bandwidth = 125_000 + $0.spreadingFactor = 12 + $0.codingRate = 8 + $0.txPower = 14 + $0.maxTxPower = 22 + $0.latitude = 48.8566 + $0.longitude = 2.3522 + $0.clientRepeat = true + $0.pathHashMode = 2 + $0.preRepeatFrequency = 915_000 + $0.preRepeatBandwidth = 250_000 + $0.preRepeatSpreadingFactor = 10 + $0.preRepeatCodingRate = 5 + $0.autoAddConfig = 0x0F + $0.autoAddMaxHops = 3 + $0.telemetryModeBase = 3 + $0.telemetryModeLoc = 2 + $0.telemetryModeEnv = 1 + $0.advertLocationPolicy = 2 + $0.manualAddContacts = true + $0.multiAcks = 4 + $0.connectionMethods = [ + .bluetooth(peripheralUUID: UUID(), displayName: "BT"), + .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") + ] } - - // MARK: - Import: radioID remapping - - @Test("Import remaps radioID when local device has same publicKey") - func importRemapsRadioID() async throws { - let sharedPublicKey = Data(repeating: 0xDD, count: 32) - let backupRadioID = UUID() - let localRadioID = UUID() - - // Build a backup envelope with the backup radioID - let backupDevice = DeviceDTO.testDevice( - id: backupRadioID, - radioID: backupRadioID, - publicKey: sharedPublicKey - ) - let backupContact = ContactDTO.testContact( - radioID: backupRadioID, - publicKey: Data(repeating: 0xEE, count: 32) - ) - let backupChannel = ChannelDTO.testChannel(radioID: backupRadioID, index: 1, name: "Remapped") - let backupMessage = MessageDTO.testDirectMessage(radioID: backupRadioID, contactID: backupContact.id) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - contacts: [backupContact], - channels: [backupChannel], - messages: [backupMessage] - ) - - // Create local store with a device using the same publicKey but different radioID - let localStore = try await PersistenceStore.createTestDataStore(radioID: localRadioID) - // Replace the test device with one that has the shared public key - let localDevice = DeviceDTO.testDevice( - id: localRadioID, - radioID: localRadioID, - publicKey: sharedPublicKey - ) - try await localStore.saveDevice(localDevice) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: localStore - ) - - // Backup device should be skipped (matched by publicKey) - #expect(result.devicesInserted == 0) - - // Child records should be inserted with the local radioID - #expect(result.contactsInserted == 1) - #expect(result.channelsInserted == 1) - #expect(result.messagesInserted == 1) - - // Verify the contact was stored with localRadioID, not backupRadioID - let localContacts = try await localStore.fetchAllContacts(radioID: localRadioID) - #expect(localContacts.count == 1) - - // The backup radioID should have no contacts - let backupContacts = try await localStore.fetchAllContacts(radioID: backupRadioID) - #expect(backupContacts.count == 0) - - // Same for channels - let localChannels = try await localStore.fetchAllChannels(radioID: localRadioID) - let remappedChannel = localChannels.first { $0.name == "Remapped" } - #expect(remappedChannel != nil) + try await store.saveDevice(sensitiveDevice) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + let envelope = try parseBackup(data: result.data) + + let exported = try #require(envelope.devices.first) + + // Sensitive fields must be reset to Device.init defaults + #expect(exported.blePin == 0) + #expect(exported.frequency == 915_000) + #expect(exported.bandwidth == 250_000) + #expect(exported.spreadingFactor == 10) + #expect(exported.codingRate == 5) + #expect(exported.txPower == 20) + #expect(exported.maxTxPower == 20) + #expect(exported.latitude == 0) + #expect(exported.longitude == 0) + #expect(exported.clientRepeat == false) + #expect(exported.pathHashMode == 0) + #expect(exported.preRepeatFrequency == nil) + #expect(exported.preRepeatBandwidth == nil) + #expect(exported.preRepeatSpreadingFactor == nil) + #expect(exported.preRepeatCodingRate == nil) + #expect(exported.autoAddConfig == 0) + #expect(exported.autoAddMaxHops == 0) + #expect(exported.telemetryModeBase == 2) + #expect(exported.telemetryModeLoc == 0) + #expect(exported.telemetryModeEnv == 0) + #expect(exported.advertLocationPolicy == 0) + + // id is reset so the source phone's CBPeripheral UUID never leaves the device. + #expect(exported.id != radioID) + // WiFi is intentionally retained (needed for restore-then-reconnect) and disclosed in + // the export Security Notice; only Bluetooth is stripped. + #expect(exported.connectionMethods.contains { $0.isWiFi }) + #expect(!exported.connectionMethods.contains { $0.isBluetooth }) + // Radio-config preferences reset to defaults. + #expect(exported.manualAddContacts == Device.Defaults.manualAddContacts) + #expect(exported.multiAcks == Device.Defaults.multiAcks) + } + + @Test + func `Export preserves non-sensitive device fields`() async throws { + let radioID = UUID() + let customPublicKey = Data(repeating: 0xF7, count: 32) + let connectedDate = Date(timeIntervalSince1970: 1_700_000_000) + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let device = DeviceDTO.testDevice( + id: radioID, + radioID: radioID, + publicKey: customPublicKey, + nodeName: "FieldUnit", + firmwareVersion: 11, + firmwareVersionString: "v1.15.0", + lastConnected: connectedDate, + lastContactSync: 42 + ).copy { + $0.ocvPreset = "liIon" + $0.customOCVArrayString = "4200,4100,4000" + $0.connectionMethods = [ + .bluetooth(peripheralUUID: UUID(), displayName: "BT"), + .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") + ] } - - // MARK: - Export: device redaction - - @Test("Export redacts sensitive device fields (BLE PIN, radio config)") - func exportRedactsSensitiveDeviceFields() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Override the test device with sensitive values - let sensitiveDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { - $0.blePin = 123_456 - $0.frequency = 869_500 - $0.bandwidth = 125_000 - $0.spreadingFactor = 12 - $0.codingRate = 8 - $0.txPower = 14 - $0.maxTxPower = 22 - $0.latitude = 48.8566 - $0.longitude = 2.3522 - $0.clientRepeat = true - $0.pathHashMode = 2 - $0.preRepeatFrequency = 915_000 - $0.preRepeatBandwidth = 250_000 - $0.preRepeatSpreadingFactor = 10 - $0.preRepeatCodingRate = 5 - $0.autoAddConfig = 0x0F - $0.autoAddMaxHops = 3 - $0.telemetryModeBase = 3 - $0.telemetryModeLoc = 2 - $0.telemetryModeEnv = 1 - $0.advertLocationPolicy = 2 - $0.manualAddContacts = true - $0.multiAcks = 4 - $0.connectionMethods = [ - .bluetooth(peripheralUUID: UUID(), displayName: "BT"), - .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") - ] - } - try await store.saveDevice(sensitiveDevice) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - let envelope = try parseBackup(data: result.data) - - let exported = try #require(envelope.devices.first) - - // Sensitive fields must be reset to Device.init defaults - #expect(exported.blePin == 0) - #expect(exported.frequency == 915_000) - #expect(exported.bandwidth == 250_000) - #expect(exported.spreadingFactor == 10) - #expect(exported.codingRate == 5) - #expect(exported.txPower == 20) - #expect(exported.maxTxPower == 20) - #expect(exported.latitude == 0) - #expect(exported.longitude == 0) - #expect(exported.clientRepeat == false) - #expect(exported.pathHashMode == 0) - #expect(exported.preRepeatFrequency == nil) - #expect(exported.preRepeatBandwidth == nil) - #expect(exported.preRepeatSpreadingFactor == nil) - #expect(exported.preRepeatCodingRate == nil) - #expect(exported.autoAddConfig == 0) - #expect(exported.autoAddMaxHops == 0) - #expect(exported.telemetryModeBase == 2) - #expect(exported.telemetryModeLoc == 0) - #expect(exported.telemetryModeEnv == 0) - #expect(exported.advertLocationPolicy == 0) - - // id is reset so the source phone's CBPeripheral UUID never leaves the device. - #expect(exported.id != radioID) - // WiFi is intentionally retained (needed for restore-then-reconnect) and disclosed in - // the export Security Notice; only Bluetooth is stripped. - #expect(exported.connectionMethods.contains { $0.isWiFi }) - #expect(!exported.connectionMethods.contains { $0.isBluetooth }) - // Radio-config preferences reset to defaults. - #expect(exported.manualAddContacts == Device.Defaults.manualAddContacts) - #expect(exported.multiAcks == Device.Defaults.multiAcks) + try await store.saveDevice(device) + + // knownRegions is owned by the targeted add path, not saveDevice's overwrite. + try await store.addDeviceKnownRegion(radioID: radioID, region: "US") + try await store.addDeviceKnownRegion(radioID: radioID, region: "EU") + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + let envelope = try parseBackup(data: result.data) + + let exported = try #require(envelope.devices.first) + + #expect(exported.publicKey == customPublicKey) + #expect(exported.nodeName == "FieldUnit") + #expect(exported.firmwareVersionString == "v1.15.0") + #expect(exported.lastConnected == connectedDate) + #expect(exported.lastContactSync == 42) + #expect(exported.ocvPreset == "liIon") + #expect(exported.customOCVArrayString == "4200,4100,4000") + #expect(exported.connectionMethods.count == 1) + #expect(exported.connectionMethods.first?.isWiFi == true) + #expect(exported.knownRegions == ["US", "EU"]) + } + + @Test + func `Export strips Bluetooth connection methods`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { + $0.connectionMethods = [.bluetooth(peripheralUUID: UUID(), displayName: "BT")] } - - @Test("Export preserves non-sensitive device fields") - func exportPreservesNonSensitiveDeviceFields() async throws { - let radioID = UUID() - let customPublicKey = Data(repeating: 0xF7, count: 32) - let connectedDate = Date(timeIntervalSince1970: 1_700_000_000) - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let device = DeviceDTO.testDevice( - id: radioID, - radioID: radioID, - publicKey: customPublicKey, - nodeName: "FieldUnit", - firmwareVersion: 11, - firmwareVersionString: "v1.15.0", - lastConnected: connectedDate, - lastContactSync: 42 - ).copy { - $0.ocvPreset = "liIon" - $0.customOCVArrayString = "4200,4100,4000" - $0.connectionMethods = [ - .bluetooth(peripheralUUID: UUID(), displayName: "BT"), - .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") - ] - } - try await store.saveDevice(device) - - // knownRegions is owned by the targeted add path, not saveDevice's overwrite. - try await store.addDeviceKnownRegion(radioID: radioID, region: "US") - try await store.addDeviceKnownRegion(radioID: radioID, region: "EU") - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - let envelope = try parseBackup(data: result.data) - - let exported = try #require(envelope.devices.first) - - #expect(exported.publicKey == customPublicKey) - #expect(exported.nodeName == "FieldUnit") - #expect(exported.firmwareVersionString == "v1.15.0") - #expect(exported.lastConnected == connectedDate) - #expect(exported.lastContactSync == 42) - #expect(exported.ocvPreset == "liIon") - #expect(exported.customOCVArrayString == "4200,4100,4000") - #expect(exported.connectionMethods.count == 1) - #expect(exported.connectionMethods.first?.isWiFi == true) - #expect(exported.knownRegions == ["US", "EU"]) - } - - @Test("Export strips Bluetooth connection methods") - func exportStripsBluetoothConnectionMethods() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { - $0.connectionMethods = [.bluetooth(peripheralUUID: UUID(), displayName: "BT")] - } - try await store.saveDevice(device) - - let result = try await AppBackupService().export(persistenceStore: store) - let envelope = try parseBackup(data: result.data) - - let exported = try #require(envelope.devices.first) - #expect(exported.connectionMethods.isEmpty) - } - - @Test("Import strips Bluetooth connection methods from legacy backups") - func importStripsBluetoothConnectionMethodsFromLegacyBackups() async throws { - let radioID = UUID() - let localStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Simulate a legacy backup where the export-side filter did not yet - // strip BLE methods — the importer must still remove them so the - // restored row isn't keyed to a nonexistent local peripheral. - let legacyBackupDevice = DeviceDTO.testDevice( - id: UUID(), - publicKey: Data(repeating: 0xAB, count: 32) - ).copy { - $0.connectionMethods = [ - .bluetooth(peripheralUUID: UUID(), displayName: "BT"), - .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") - ] - } - let envelope = AppBackupEnvelope.test(devices: [legacyBackupDevice]) - - _ = try await localStore.importBackupDatabase(envelope) - - let localDevices = try await localStore.fetchAllDevices() - let restored = try #require(localDevices.first { $0.publicKey == legacyBackupDevice.publicKey }) - #expect(restored.connectionMethods.count == 1) - #expect(restored.connectionMethods.first?.isWiFi == true) + try await store.saveDevice(device) + + let result = try await AppBackupService().export(persistenceStore: store) + let envelope = try parseBackup(data: result.data) + + let exported = try #require(envelope.devices.first) + #expect(exported.connectionMethods.isEmpty) + } + + @Test + func `Import strips Bluetooth connection methods from legacy backups`() async throws { + let radioID = UUID() + let localStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Simulate a legacy backup where the export-side filter did not yet + // strip BLE methods — the importer must still remove them so the + // restored row isn't keyed to a nonexistent local peripheral. + let legacyBackupDevice = DeviceDTO.testDevice( + id: UUID(), + publicKey: Data(repeating: 0xAB, count: 32) + ).copy { + $0.connectionMethods = [ + .bluetooth(peripheralUUID: UUID(), displayName: "BT"), + .wifi(host: "10.0.0.2", port: 5000, displayName: "WiFi") + ] } - - @Test("Redacted device round-trips through export and import with safe defaults") - func redactedDeviceRoundTripsWithSafeDefaults() async throws { - let radioID = UUID() - let customPublicKey = Data(repeating: 0xF8, count: 32) - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let sensitiveDevice = DeviceDTO.testDevice( - id: radioID, - radioID: radioID, - publicKey: customPublicKey - ).copy { - $0.blePin = 999_999 - $0.frequency = 433_000 - $0.txPower = 10 - } - try await sourceStore.saveDevice(sensitiveDevice) - - // Export - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - // Import into a fresh store (unmatched device — will be inserted) - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.devicesInserted == 1) - - // Verify the imported device has redacted values, not the originals - let imported = try #require(await destStore.fetchAllDevices().first) - #expect(imported.blePin == 0) - #expect(imported.frequency == 915_000) - #expect(imported.txPower == 20) - // Identity preserved - #expect(imported.publicKey == customPublicKey) - } - - // MARK: - Import: idempotent - - @Test("Importing same backup twice results in zero inserts on second pass") - func importIsIdempotent() async throws { - let radioID = UUID() - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID, publicKey: Data(repeating: 0x02, count: 32)) - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xCC, count: 32) - ) - // Messages must have a deduplicationKey to be detected as duplicates - var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) - msg.deduplicationKey = "test-dedup-key-1" - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [msg] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let service = AppBackupService() - - let firstResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - #expect(firstResult.devicesInserted == 1) - #expect(firstResult.contactsInserted == 1) - #expect(firstResult.messagesInserted == 1) - - // Import the same backup again - let secondResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(secondResult.devicesInserted == 0) - #expect(secondResult.contactsInserted == 0) - #expect(secondResult.messagesInserted == 0) - #expect(secondResult.totalInserted == 0) - } - - // MARK: - Export: ExportResult - - @Test("Export returns ExportResult carrying the envelope manifest") - func exportReturnsManifest() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xBB, count: 32)) - try await store.saveContact(contact) - - let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) - try await store.saveMessage(message) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: store) - - #expect(result.data.isEmpty == false) - #expect(result.manifest.contactCount == 1) - #expect(result.manifest.messageCount == 1) - #expect(result.manifest.count(for: .contacts) == 1) - #expect(result.manifest.count(for: .messages) == 1) - } - - // MARK: - Import: Device.id collision does not overwrite local row - - @Test("Import assigns fresh Device.id so an id collision cannot upsert the local row") - func importAssignsFreshDeviceIDOnCollision() async throws { - // Simulates radio identity rotation: the backup's DeviceDTO carries a UUID - // that happens to equal the current local peripheral's UUID (iOS reuses the - // same CBPeripheral.identifier after a firmware pubKey change). Without the - // fix, SwiftData's upsert on `@Attribute(.unique) Device.id` would silently - // overwrite the local row's publicKey; with the fix, the backup row is - // inserted with a fresh id alongside the existing local row. - let collidingID = UUID() - let oldPublicKey = Data(repeating: 0xA0, count: 32) - let newPublicKey = Data(repeating: 0xB0, count: 32) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: collidingID) - let localDevice = DeviceDTO.testDevice( - id: collidingID, - radioID: collidingID, - publicKey: newPublicKey - ) - try await destStore.saveDevice(localDevice) - - let backupDevice = DeviceDTO.testDevice( - id: collidingID, - radioID: UUID(), - publicKey: oldPublicKey - ) - let envelope = AppBackupEnvelope.test(devices: [backupDevice]) - - let result = try await AppBackupService().importBackup( - envelope: envelope, - into: destStore - ) - #expect(result.devicesInserted == 1) - - let stored = try await destStore.fetchAllDevices() - #expect(stored.count == 2) - - // The original local device is still there, with its original pubKey intact. - let localAfter = try #require(stored.first { $0.publicKey == newPublicKey }) - #expect(localAfter.id == collidingID) - - // The backup landed as a separate row with a fresh id. - let backupAfter = try #require(stored.first { $0.publicKey == oldPublicKey }) - #expect(backupAfter.id != collidingID) + let envelope = AppBackupEnvelope.test(devices: [legacyBackupDevice]) + + _ = try await localStore.importBackupDatabase(envelope) + + let localDevices = try await localStore.fetchAllDevices() + let restored = try #require(localDevices.first { $0.publicKey == legacyBackupDevice.publicKey }) + #expect(restored.connectionMethods.count == 1) + #expect(restored.connectionMethods.first?.isWiFi == true) + } + + @Test + func `Redacted device round-trips through export and import with safe defaults`() async throws { + let radioID = UUID() + let customPublicKey = Data(repeating: 0xF8, count: 32) + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let sensitiveDevice = DeviceDTO.testDevice( + id: radioID, + radioID: radioID, + publicKey: customPublicKey + ).copy { + $0.blePin = 999_999 + $0.frequency = 433_000 + $0.txPower = 10 } + try await sourceStore.saveDevice(sensitiveDevice) + + // Export + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + // Import into a fresh store (unmatched device — will be inserted) + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.devicesInserted == 1) + + // Verify the imported device has redacted values, not the originals + let imported = try #require(await destStore.fetchAllDevices().first) + #expect(imported.blePin == 0) + #expect(imported.frequency == 915_000) + #expect(imported.txPower == 20) + // Identity preserved + #expect(imported.publicKey == customPublicKey) + } + + // MARK: - Import: idempotent + + @Test + func `Importing same backup twice results in zero inserts on second pass`() async throws { + let radioID = UUID() + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID, publicKey: Data(repeating: 0x02, count: 32)) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xCC, count: 32) + ) + // Messages must have a deduplicationKey to be detected as duplicates + var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) + msg.deduplicationKey = "test-dedup-key-1" + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [msg] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let service = AppBackupService() + + let firstResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + #expect(firstResult.devicesInserted == 1) + #expect(firstResult.contactsInserted == 1) + #expect(firstResult.messagesInserted == 1) + + // Import the same backup again + let secondResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(secondResult.devicesInserted == 0) + #expect(secondResult.contactsInserted == 0) + #expect(secondResult.messagesInserted == 0) + #expect(secondResult.totalInserted == 0) + } + + // MARK: - Export: ExportResult + + @Test + func `Export returns ExportResult carrying the envelope manifest`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact(radioID: radioID, publicKey: Data(repeating: 0xBB, count: 32)) + try await store.saveContact(contact) + + let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id) + try await store.saveMessage(message) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: store) + + #expect(result.data.isEmpty == false) + #expect(result.manifest.contactCount == 1) + #expect(result.manifest.messageCount == 1) + #expect(result.manifest.count(for: .contacts) == 1) + #expect(result.manifest.count(for: .messages) == 1) + } + + // MARK: - Import: Device.id collision does not overwrite local row + + @Test + func `Import assigns fresh Device.id so an id collision cannot upsert the local row`() async throws { + // Simulates radio identity rotation: the backup's DeviceDTO carries a UUID + // that happens to equal the current local peripheral's UUID (iOS reuses the + // same CBPeripheral.identifier after a firmware pubKey change). Without the + // fix, SwiftData's upsert on `@Attribute(.unique) Device.id` would silently + // overwrite the local row's publicKey; with the fix, the backup row is + // inserted with a fresh id alongside the existing local row. + let collidingID = UUID() + let oldPublicKey = Data(repeating: 0xA0, count: 32) + let newPublicKey = Data(repeating: 0xB0, count: 32) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: collidingID) + let localDevice = DeviceDTO.testDevice( + id: collidingID, + radioID: collidingID, + publicKey: newPublicKey + ) + try await destStore.saveDevice(localDevice) + + let backupDevice = DeviceDTO.testDevice( + id: collidingID, + radioID: UUID(), + publicKey: oldPublicKey + ) + let envelope = AppBackupEnvelope.test(devices: [backupDevice]) + + let result = try await AppBackupService().importBackup( + envelope: envelope, + into: destStore + ) + #expect(result.devicesInserted == 1) + + let stored = try await destStore.fetchAllDevices() + #expect(stored.count == 2) + + // The original local device is still there, with its original pubKey intact. + let localAfter = try #require(stored.first { $0.publicKey == newPublicKey }) + #expect(localAfter.id == collidingID) + + // The backup landed as a separate row with a fresh id. + let backupAfter = try #require(stored.first { $0.publicKey == oldPublicKey }) + #expect(backupAfter.id != collidingID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BLEReconnectionCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/BLEReconnectionCoordinatorTests.swift index d79b694a..aea54691 100644 --- a/MC1Services/Tests/MC1ServicesTests/BLEReconnectionCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BLEReconnectionCoordinatorTests.swift @@ -1,572 +1,572 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("BLEReconnectionCoordinator Tests") @MainActor struct BLEReconnectionCoordinatorTests { + // MARK: - Test Helpers - // MARK: - Test Helpers - - private func createCoordinator( - delegate: MockReconnectionDelegate? = nil, - uiTimeoutDuration: TimeInterval = 10, - maxConnectingUIWindow: TimeInterval = 60 - ) -> (BLEReconnectionCoordinator, MockReconnectionDelegate) { - let coordinator = BLEReconnectionCoordinator( - uiTimeoutDuration: uiTimeoutDuration, - maxConnectingUIWindow: maxConnectingUIWindow - ) - let mockDelegate = delegate ?? MockReconnectionDelegate() - coordinator.delegate = mockDelegate - return (coordinator, mockDelegate) - } + private func createCoordinator( + delegate: MockReconnectionDelegate? = nil, + uiTimeoutDuration: TimeInterval = 10, + maxConnectingUIWindow: TimeInterval = 60 + ) -> (BLEReconnectionCoordinator, MockReconnectionDelegate) { + let coordinator = BLEReconnectionCoordinator( + uiTimeoutDuration: uiTimeoutDuration, + maxConnectingUIWindow: maxConnectingUIWindow + ) + let mockDelegate = delegate ?? MockReconnectionDelegate() + coordinator.delegate = mockDelegate + return (coordinator, mockDelegate) + } + + // MARK: - handleEnteringAutoReconnect Tests + + @Test + func `entering auto-reconnect sets state to .connecting when user wants connection`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + + #expect(delegate.connectionState == .connecting) + } + + @Test + func `entering auto-reconnect tears down session`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + + #expect(delegate.teardownSessionCallCount == 1) + } + + @Test + func `entering auto-reconnect is ignored when intent is .userDisconnected`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .userDisconnected + delegate.connectionState = .disconnected + + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + + #expect(delegate.connectionState == .disconnected, "State should not change when user disconnected") + #expect(delegate.teardownSessionCallCount == 0, "Session should not be torn down") + #expect(delegate.disconnectTransportCallCount == 1, "Transport should be disconnected") + } + + @Test + func `entering auto-reconnect is ignored when intent is .none`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .none + delegate.connectionState = .disconnected + + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + + #expect(delegate.connectionState == .disconnected) + #expect(delegate.disconnectTransportCallCount == 1) + } + + // MARK: - handleReconnectionComplete Tests + + // + // The coordinator only accepts a completion for a cycle it explicitly claimed + // via handleEnteringAutoReconnect. Each happy-path test below claims first to + // mirror the production wiring (setAutoReconnectingHandler always fires before + // setReconnectionHandler for the same cycle). + + @Test + func `reconnection complete keeps state .connecting when claim matches`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + + await coordinator.handleReconnectionComplete(deviceID: deviceID) + + #expect(delegate.connectionState == .connecting) + } + + @Test + func `reconnection complete calls rebuildSession when claim matches`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + + await coordinator.handleReconnectionComplete(deviceID: deviceID) + + #expect(delegate.rebuildSessionCalls.count == 1) + #expect(delegate.rebuildSessionCalls.first == deviceID) + } + + @Test + func `reconnection complete is ignored when no entry was claimed`() async { + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .disconnected + + // No prior handleEnteringAutoReconnect — claim is nil. This mirrors the + // pairing race where the entry handler was suppressed but a late completion + // still arrives. + await coordinator.handleReconnectionComplete(deviceID: UUID()) - // MARK: - handleEnteringAutoReconnect Tests + #expect(delegate.connectionState == .disconnected, "Should not transition without claim") + #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild without claim") + } - @Test("entering auto-reconnect sets state to .connecting when user wants connection") - func enteringAutoReconnectSetsConnecting() async { - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + @Test + func `reconnection complete is ignored when intent is .userDisconnected`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + // Now the user disconnects mid-reconnect. + delegate.connectionIntent = .userDisconnected - #expect(delegate.connectionState == .connecting) - } + await coordinator.handleReconnectionComplete(deviceID: deviceID) - @Test("entering auto-reconnect tears down session") - func enteringAutoReconnectTearsDownSession() async { - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() + #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when user disconnected") + #expect(delegate.disconnectTransportCallCount == 1, "Should disconnect transport") + } - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + @Test + func `reconnection complete is ignored when already .ready`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.teardownSessionCallCount == 1) - } + // Force state back to .ready (e.g., a parallel resync promoted the session). + delegate.connectionState = .ready - @Test("entering auto-reconnect is ignored when intent is .userDisconnected") - func enteringAutoReconnectIgnoredForUserDisconnected() async { - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .userDisconnected - delegate.connectionState = .disconnected + await coordinator.handleReconnectionComplete(deviceID: deviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + #expect(delegate.connectionState == .ready, "Should not change state when already ready") + #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when already ready") + } - #expect(delegate.connectionState == .disconnected, "State should not change when user disconnected") - #expect(delegate.teardownSessionCallCount == 0, "Session should not be torn down") - #expect(delegate.disconnectTransportCallCount == 1, "Transport should be disconnected") - } + @Test + func `reconnection complete is ignored when .syncing (session alive, resync running)`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - @Test("entering auto-reconnect is ignored when intent is .none") - func enteringAutoReconnectIgnoredForNone() async { - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .none - delegate.connectionState = .disconnected + // Simulate a parallel resync promoting the session to .syncing. + delegate.connectionState = .syncing + + await coordinator.handleReconnectionComplete(deviceID: deviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + #expect(delegate.connectionState == .syncing, "Should not change state when syncing") + #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when syncing") + } + + @Test + func `reconnection complete handles rebuild failure`() async { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.rebuildSessionShouldThrow = true - #expect(delegate.connectionState == .disconnected) - #expect(delegate.disconnectTransportCallCount == 1) - } + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + await coordinator.handleReconnectionComplete(deviceID: deviceID) - // MARK: - handleReconnectionComplete Tests - // - // The coordinator only accepts a completion for a cycle it explicitly claimed - // via handleEnteringAutoReconnect. Each happy-path test below claims first to - // mirror the production wiring (setAutoReconnectingHandler always fires before - // setReconnectionHandler for the same cycle). + #expect(delegate.handleReconnectionFailureCallCount == 1) + } - @Test("reconnection complete keeps state .connecting when claim matches") - func reconnectionCompleteKeepsConnectingWhenClaimed() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + @Test + func `stale device completion does not cancel active timeout`() async throws { + let activeDevice = UUID() + let staleDevice = UUID() + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: activeDevice) + #expect(delegate.connectionState == .connecting) - await coordinator.handleReconnectionComplete(deviceID: deviceID) + // Stale completion for a different device should be rejected + await coordinator.handleReconnectionComplete(deviceID: staleDevice) + + // Timeout should still fire because it was not canceled + try await waitUntil("Timeout should still fire after stale completion") { + delegate.connectionState == .disconnected + } + + #expect(delegate.connectionState == .disconnected, "Timeout should still fire after stale completion") + #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild for stale device") + } - #expect(delegate.connectionState == .connecting) - } + // MARK: - UI Timeout Tests - @Test("reconnection complete calls rebuildSession when claim matches") - func reconnectionCompleteCallsRebuild() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + @Test + func `UI timeout transitions to disconnected after duration`() async throws { + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + #expect(delegate.connectionState == .connecting) + + // Wait for timeout to fire and transition state + try await waitUntil("Timeout should transition to disconnected") { + delegate.connectionState == .disconnected + } + + #expect(delegate.connectionState == .disconnected) + #expect(delegate.connectedDeviceWasCleared == true) + #expect(delegate.notifyConnectionLostCallCount == 1) + } - await coordinator.handleReconnectionComplete(deviceID: deviceID) + @Test + func `UI timeout is cancelled when reconnection completes`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready - #expect(delegate.rebuildSessionCalls.count == 1) - #expect(delegate.rebuildSessionCalls.first == deviceID) - } + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + #expect(delegate.connectionState == .connecting) - @Test("reconnection complete is ignored when no entry was claimed") - func reconnectionCompleteIgnoredWithoutClaim() async { - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .disconnected + // Complete reconnection before timeout (same device) + await coordinator.handleReconnectionComplete(deviceID: deviceID) - // No prior handleEnteringAutoReconnect — claim is nil. This mirrors the - // pairing race where the entry handler was suppressed but a late completion - // still arrives. - await coordinator.handleReconnectionComplete(deviceID: UUID()) + // Fixed sleep: negative assertion — confirm timeout did NOT fire + try await Task.sleep(for: .milliseconds(250)) - #expect(delegate.connectionState == .disconnected, "Should not transition without claim") - #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild without claim") - } + // Should be .connecting from reconnection complete, not .disconnected from timeout + #expect(delegate.connectionState == .connecting) + #expect(delegate.notifyConnectionLostCallCount == 0) + } - @Test("reconnection complete is ignored when intent is .userDisconnected") - func reconnectionCompleteIgnoredForUserDisconnected() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + // MARK: - Stale Retry Tests - // Now the user disconnects mid-reconnect. - delegate.connectionIntent = .userDisconnected + @Test + func `stale rebuild retry is aborted when new reconnect cycle starts during delay`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator() + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.rebuildSessionShouldThrow = true - await coordinator.handleReconnectionComplete(deviceID: deviceID) + // Claim the first cycle so the completion is accepted. + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when user disconnected") - #expect(delegate.disconnectTransportCallCount == 1, "Should disconnect transport") + // Start first reconnection — rebuild will fail, triggering 2s retry delay + let firstReconnectTask = Task { + await coordinator.handleReconnectionComplete(deviceID: deviceID) } - @Test("reconnection complete is ignored when already .ready") - func reconnectionCompleteIgnoredWhenReady() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - - // Force state back to .ready (e.g., a parallel resync promoted the session). - delegate.connectionState = .ready - - await coordinator.handleReconnectionComplete(deviceID: deviceID) - - #expect(delegate.connectionState == .ready, "Should not change state when already ready") - #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when already ready") + // Wait for first rebuild to fail and enter the 2s retry delay + try await waitUntil("First rebuild should have been attempted") { + delegate.rebuildSessionCalls.count == 1 } + #expect(delegate.rebuildSessionCalls.count == 1, "First rebuild should have been attempted") - @Test("reconnection complete is ignored when .syncing (session alive, resync running)") - func reconnectionCompleteIgnoredWhenSyncing() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + // Start a new reconnect cycle during the delay — this bumps the generation counter. + // Re-claim the cycle since the prior completion cleared reconnectingDeviceID. + delegate.rebuildSessionShouldThrow = false + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + await coordinator.handleReconnectionComplete(deviceID: deviceID) - // Simulate a parallel resync promoting the session to .syncing. - delegate.connectionState = .syncing + // Wait for the first task's stale retry to wake and be aborted + await firstReconnectTask.value - await coordinator.handleReconnectionComplete(deviceID: deviceID) + // Should have exactly 2 rebuild calls: first (failed) + new cycle (succeeded). + // The stale retry should have been aborted by the generation check. + #expect(delegate.rebuildSessionCalls.count == 2, "Stale retry should have been aborted") + #expect(delegate.handleReconnectionFailureCallCount == 0, "No failure handler since new cycle succeeded") + } - #expect(delegate.connectionState == .syncing, "Should not change state when syncing") - #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild when syncing") - } + // MARK: - Max Connecting Window Tests - @Test("reconnection complete handles rebuild failure") - func reconnectionCompleteHandlesRebuildFailure() async { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.rebuildSessionShouldThrow = true + @Test + func `UI timeout disconnects when max connecting window exceeded`() async throws { + let (coordinator, delegate) = createCoordinator( + uiTimeoutDuration: 0.05, + maxConnectingUIWindow: 0.15 + ) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - await coordinator.handleReconnectionComplete(deviceID: deviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - #expect(delegate.handleReconnectionFailureCallCount == 1) + // Wait for max connecting window to expire and disconnect + try await waitUntil("Max connecting window should trigger disconnect") { + delegate.connectionState == .disconnected } - @Test("stale device completion does not cancel active timeout") - func staleDeviceDoesNotCancelTimeout() async throws { - let activeDevice = UUID() - let staleDevice = UUID() - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - - await coordinator.handleEnteringAutoReconnect(deviceID: activeDevice) - #expect(delegate.connectionState == .connecting) + #expect(delegate.connectionState == .disconnected) + #expect(delegate.notifyConnectionLostCallCount == 1) + } - // Stale completion for a different device should be rejected - await coordinator.handleReconnectionComplete(deviceID: staleDevice) + @Test + func `same-device completion is accepted after UI timeout while transport auto-reconnects`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator( + uiTimeoutDuration: 0.05, + maxConnectingUIWindow: 0.15 + ) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - // Timeout should still fire because it was not canceled - try await waitUntil("Timeout should still fire after stale completion") { - delegate.connectionState == .disconnected - } + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.connectionState == .disconnected, "Timeout should still fire after stale completion") - #expect(delegate.rebuildSessionCalls.isEmpty, "Should not rebuild for stale device") + try await waitUntil("UI timeout should transition presentation to disconnected") { + delegate.connectionState == .disconnected } - // MARK: - UI Timeout Tests + await coordinator.handleReconnectionComplete(deviceID: deviceID) - @Test("UI timeout transitions to disconnected after duration") - func uiTimeoutTransitionsToDisconnected() async throws { - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + #expect(delegate.rebuildSessionCalls == [deviceID]) + #expect(delegate.connectionState == .connecting) + } - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - #expect(delegate.connectionState == .connecting) + @Test + func `UI timeout clears cycle when transport stops auto-reconnecting`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = false - // Wait for timeout to fire and transition state - try await waitUntil("Timeout should transition to disconnected") { - delegate.connectionState == .disconnected - } + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.connectionState == .disconnected) - #expect(delegate.connectedDeviceWasCleared == true) - #expect(delegate.notifyConnectionLostCallCount == 1) + try await waitUntil("UI timeout should transition to disconnected") { + delegate.connectionState == .disconnected } - @Test("UI timeout is cancelled when reconnection completes") - func uiTimeoutCancelledOnReconnection() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready + await coordinator.handleReconnectionComplete(deviceID: deviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.connectionState == .connecting) + #expect(delegate.rebuildSessionCalls.isEmpty) + } - // Complete reconnection before timeout (same device) - await coordinator.handleReconnectionComplete(deviceID: deviceID) + @Test + func `different-device completion is rejected after UI timeout`() async throws { + let activeDeviceID = UUID() + let staleDeviceID = UUID() + let (coordinator, delegate) = createCoordinator( + uiTimeoutDuration: 0.05, + maxConnectingUIWindow: 0.15 + ) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - // Fixed sleep: negative assertion — confirm timeout did NOT fire - try await Task.sleep(for: .milliseconds(250)) - - // Should be .connecting from reconnection complete, not .disconnected from timeout - #expect(delegate.connectionState == .connecting) - #expect(delegate.notifyConnectionLostCallCount == 0) - } + await coordinator.handleEnteringAutoReconnect(deviceID: activeDeviceID) - // MARK: - Stale Retry Tests - - @Test("stale rebuild retry is aborted when new reconnect cycle starts during delay") - func staleRetryAbortedOnNewCycle() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator() - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.rebuildSessionShouldThrow = true - - // Claim the first cycle so the completion is accepted. - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - - // Start first reconnection — rebuild will fail, triggering 2s retry delay - let firstReconnectTask = Task { - await coordinator.handleReconnectionComplete(deviceID: deviceID) - } - - // Wait for first rebuild to fail and enter the 2s retry delay - try await waitUntil("First rebuild should have been attempted") { - delegate.rebuildSessionCalls.count == 1 - } - #expect(delegate.rebuildSessionCalls.count == 1, "First rebuild should have been attempted") - - // Start a new reconnect cycle during the delay — this bumps the generation counter. - // Re-claim the cycle since the prior completion cleared reconnectingDeviceID. - delegate.rebuildSessionShouldThrow = false - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - await coordinator.handleReconnectionComplete(deviceID: deviceID) - - // Wait for the first task's stale retry to wake and be aborted - await firstReconnectTask.value - - // Should have exactly 2 rebuild calls: first (failed) + new cycle (succeeded). - // The stale retry should have been aborted by the generation check. - #expect(delegate.rebuildSessionCalls.count == 2, "Stale retry should have been aborted") - #expect(delegate.handleReconnectionFailureCallCount == 0, "No failure handler since new cycle succeeded") + try await waitUntil("UI timeout should transition presentation to disconnected") { + delegate.connectionState == .disconnected } - // MARK: - Max Connecting Window Tests + await coordinator.handleReconnectionComplete(deviceID: staleDeviceID) - @Test("UI timeout disconnects when max connecting window exceeded") - func uiTimeoutDisconnectsAtMaxWindow() async throws { - let (coordinator, delegate) = createCoordinator( - uiTimeoutDuration: 0.05, - maxConnectingUIWindow: 0.15 - ) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + #expect(delegate.rebuildSessionCalls.isEmpty) + #expect(delegate.connectionState == .disconnected) + } - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + @Test + func `user-disconnected completion after UI timeout remains rejected`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator( + uiTimeoutDuration: 0.05, + maxConnectingUIWindow: 0.15 + ) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - // Wait for max connecting window to expire and disconnect - try await waitUntil("Max connecting window should trigger disconnect") { - delegate.connectionState == .disconnected - } + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - #expect(delegate.connectionState == .disconnected) - #expect(delegate.notifyConnectionLostCallCount == 1) + try await waitUntil("UI timeout should transition presentation to disconnected") { + delegate.connectionState == .disconnected } - @Test("same-device completion is accepted after UI timeout while transport auto-reconnects") - func sameDeviceCompletionAcceptedAfterUITimeoutWhileAutoReconnecting() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator( - uiTimeoutDuration: 0.05, - maxConnectingUIWindow: 0.15 - ) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + delegate.connectionIntent = .userDisconnected + await coordinator.handleReconnectionComplete(deviceID: deviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + #expect(delegate.rebuildSessionCalls.isEmpty) + #expect(delegate.disconnectTransportCallCount == 1) + } - try await waitUntil("UI timeout should transition presentation to disconnected") { - delegate.connectionState == .disconnected - } + // MARK: - cancelTimeout Tests - await coordinator.handleReconnectionComplete(deviceID: deviceID) + @Test + func `UI timeout re-arms if BLE is still auto-reconnecting`() async throws { + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - #expect(delegate.rebuildSessionCalls == [deviceID]) - #expect(delegate.connectionState == .connecting) - } - - @Test("UI timeout clears cycle when transport stops auto-reconnecting") - func uiTimeoutClearsCycleWhenTransportStopsAutoReconnecting() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = false + let deviceID = UUID() + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + // Fixed sleep: negative assertion — confirm re-arm keeps state as .connecting + try await Task.sleep(for: .milliseconds(200)) - try await waitUntil("UI timeout should transition to disconnected") { - delegate.connectionState == .disconnected - } + // Should still be .connecting because BLE is auto-reconnecting + #expect(delegate.connectionState == .connecting) + #expect(delegate.notifyConnectionLostCallCount == 0) + } - await coordinator.handleReconnectionComplete(deviceID: deviceID) + @Test + func `UI timeout eventually disconnects when max window exceeded`() async throws { + // Use a very short maxConnectingUIWindow via a coordinator with short timeout + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = true - #expect(delegate.rebuildSessionCalls.isEmpty) - } + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - @Test("different-device completion is rejected after UI timeout") - func differentDeviceCompletionRejectedAfterUITimeout() async throws { - let activeDeviceID = UUID() - let staleDeviceID = UUID() - let (coordinator, delegate) = createCoordinator( - uiTimeoutDuration: 0.05, - maxConnectingUIWindow: 0.15 - ) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + // Fixed sleep: negative assertion — within the 60s max window, re-arm + // should keep the state as .connecting. We can't wait 60s in a test, so + // verify the re-arm mechanism works within a short window. + try await Task.sleep(for: .milliseconds(200)) - await coordinator.handleEnteringAutoReconnect(deviceID: activeDeviceID) + // Within the 60s window, should still be .connecting + #expect(delegate.connectionState == .connecting) + } - try await waitUntil("UI timeout should transition presentation to disconnected") { - delegate.connectionState == .disconnected - } + @Test + func `UI timeout fires normally when BLE is not auto-reconnecting`() async throws { + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = false - await coordinator.handleReconnectionComplete(deviceID: staleDeviceID) + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - #expect(delegate.rebuildSessionCalls.isEmpty) - #expect(delegate.connectionState == .disconnected) + try await waitUntil("Timeout should fire when BLE is not auto-reconnecting") { + delegate.connectionState == .disconnected } - @Test("user-disconnected completion after UI timeout remains rejected") - func userDisconnectedCompletionAfterUITimeoutRejected() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator( - uiTimeoutDuration: 0.05, - maxConnectingUIWindow: 0.15 - ) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + #expect(delegate.connectionState == .disconnected) + #expect(delegate.notifyConnectionLostCallCount == 1) + } - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) + @Test + func `UI timeout aborts when reconnection completes during its transport query`() async throws { + let deviceID = UUID() + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready + delegate.stubbedBLEPhaseIsAutoReconnecting = false - try await waitUntil("UI timeout should transition presentation to disconnected") { - delegate.connectionState == .disconnected - } + await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - delegate.connectionIntent = .userDisconnected - await coordinator.handleReconnectionComplete(deviceID: deviceID) - - #expect(delegate.rebuildSessionCalls.isEmpty) - #expect(delegate.disconnectTransportCallCount == 1) + // Run handleReconnectionComplete while the timeout body is suspended on + // isTransportAutoReconnecting(), the reentrancy window where a stale + // timeout could clobber the freshly completed reconnection. + delegate.onIsTransportAutoReconnecting = { [weak coordinator, weak delegate] in + delegate?.onIsTransportAutoReconnecting = nil + await coordinator?.handleReconnectionComplete(deviceID: deviceID) } - // MARK: - cancelTimeout Tests - - @Test("UI timeout re-arms if BLE is still auto-reconnecting") - func uiTimeoutRearmsWhenAutoReconnecting() async throws { - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true - - let deviceID = UUID() - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - - // Fixed sleep: negative assertion — confirm re-arm keeps state as .connecting - try await Task.sleep(for: .milliseconds(200)) - - // Should still be .connecting because BLE is auto-reconnecting - #expect(delegate.connectionState == .connecting) - #expect(delegate.notifyConnectionLostCallCount == 0) + try await waitUntil("completion should run during the timeout suspension") { + delegate.rebuildSessionCalls == [deviceID] } - @Test("UI timeout eventually disconnects when max window exceeded") - func uiTimeoutEventuallyDisconnects() async throws { - // Use a very short maxConnectingUIWindow via a coordinator with short timeout - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = true + // Fixed sleep: negative assertion: give the resumed timeout body a + // chance to (incorrectly) force disconnected state. + try await Task.sleep(for: .milliseconds(150)) - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + #expect(delegate.connectionState == .connecting, "Stale timeout must not clobber the completed reconnection") + #expect(delegate.notifyConnectionLostCallCount == 0) + #expect(delegate.connectedDeviceWasCleared == false) + } - // Fixed sleep: negative assertion — within the 60s max window, re-arm - // should keep the state as .connecting. We can't wait 60s in a test, so - // verify the re-arm mechanism works within a short window. - try await Task.sleep(for: .milliseconds(200)) + @Test + func `cancelTimeout prevents timeout from firing`() async throws { + let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) + delegate.connectionIntent = .wantsConnection() + delegate.connectionState = .ready - // Within the 60s window, should still be .connecting - #expect(delegate.connectionState == .connecting) - } + await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) + coordinator.cancelTimeout() - @Test("UI timeout fires normally when BLE is not auto-reconnecting") - func uiTimeoutFiresWhenNotAutoReconnecting() async throws { - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = false + // Fixed sleep: negative assertion — confirm timeout was cancelled + try await Task.sleep(for: .milliseconds(250)) - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - - try await waitUntil("Timeout should fire when BLE is not auto-reconnecting") { - delegate.connectionState == .disconnected - } - - #expect(delegate.connectionState == .disconnected) - #expect(delegate.notifyConnectionLostCallCount == 1) - } - - @Test("UI timeout aborts when reconnection completes during its transport query") - func uiTimeoutAbortsWhenCompletionInterleaves() async throws { - let deviceID = UUID() - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.05) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - delegate.stubbedBLEPhaseIsAutoReconnecting = false - - await coordinator.handleEnteringAutoReconnect(deviceID: deviceID) - - // Run handleReconnectionComplete while the timeout body is suspended on - // isTransportAutoReconnecting(), the reentrancy window where a stale - // timeout could clobber the freshly completed reconnection. - delegate.onIsTransportAutoReconnecting = { [weak coordinator, weak delegate] in - delegate?.onIsTransportAutoReconnecting = nil - await coordinator?.handleReconnectionComplete(deviceID: deviceID) - } - - try await waitUntil("completion should run during the timeout suspension") { - delegate.rebuildSessionCalls == [deviceID] - } - - // Fixed sleep: negative assertion: give the resumed timeout body a - // chance to (incorrectly) force disconnected state. - try await Task.sleep(for: .milliseconds(150)) - - #expect(delegate.connectionState == .connecting, "Stale timeout must not clobber the completed reconnection") - #expect(delegate.notifyConnectionLostCallCount == 0) - #expect(delegate.connectedDeviceWasCleared == false) - } - - @Test("cancelTimeout prevents timeout from firing") - func cancelTimeoutPreventsTimeout() async throws { - let (coordinator, delegate) = createCoordinator(uiTimeoutDuration: 0.1) - delegate.connectionIntent = .wantsConnection() - delegate.connectionState = .ready - - await coordinator.handleEnteringAutoReconnect(deviceID: UUID()) - coordinator.cancelTimeout() - - // Fixed sleep: negative assertion — confirm timeout was cancelled - try await Task.sleep(for: .milliseconds(250)) - - // State should remain .connecting (timeout was cancelled) - #expect(delegate.connectionState == .connecting) - } + // State should remain .connecting (timeout was cancelled) + #expect(delegate.connectionState == .connecting) + } } // MARK: - Mock Delegate @MainActor private final class MockReconnectionDelegate: BLEReconnectionDelegate { - var connectionIntent: ConnectionIntent = .none - var connectionState: DeviceConnectionState = .disconnected - - var teardownSessionCallCount = 0 - var rebuildSessionCalls: [UUID] = [] - var rebuildSessionShouldThrow = false - var disconnectTransportCallCount = 0 - var notifyConnectionLostCallCount = 0 - var handleReconnectionFailureCallCount = 0 - var connectedDeviceWasCleared = false - var stubbedBLEPhaseIsAutoReconnecting = false - /// Runs inside `isTransportAutoReconnecting()` so tests can interleave work - /// at that suspension point before the stubbed value is returned. - var onIsTransportAutoReconnecting: (@MainActor () async -> Void)? - - func setConnectionState(_ state: DeviceConnectionState) { - connectionState = state - } - - func setConnectedDevice(_ device: DeviceDTO?) { - if device == nil { - connectedDeviceWasCleared = true - } - } - - func teardownSessionForReconnect() async { - teardownSessionCallCount += 1 - } - - func rebuildSession(deviceID: UUID) async throws { - rebuildSessionCalls.append(deviceID) - if rebuildSessionShouldThrow { - throw ReconnectionTestError.rebuildFailed - } - } - - func disconnectTransport() async { - disconnectTransportCallCount += 1 - } - - func notifyConnectionLost() async { - notifyConnectionLostCallCount += 1 - } - - func handleReconnectionFailure() async { - handleReconnectionFailureCallCount += 1 - } - - func isTransportAutoReconnecting() async -> Bool { - if let hook = onIsTransportAutoReconnecting { - await hook() - } - return stubbedBLEPhaseIsAutoReconnecting - } + var connectionIntent: ConnectionIntent = .none + var connectionState: DeviceConnectionState = .disconnected + + var teardownSessionCallCount = 0 + var rebuildSessionCalls: [UUID] = [] + var rebuildSessionShouldThrow = false + var disconnectTransportCallCount = 0 + var notifyConnectionLostCallCount = 0 + var handleReconnectionFailureCallCount = 0 + var connectedDeviceWasCleared = false + var stubbedBLEPhaseIsAutoReconnecting = false + /// Runs inside `isTransportAutoReconnecting()` so tests can interleave work + /// at that suspension point before the stubbed value is returned. + var onIsTransportAutoReconnecting: (@MainActor () async -> Void)? + + func setConnectionState(_ state: DeviceConnectionState) { + connectionState = state + } + + func setConnectedDevice(_ device: DeviceDTO?) { + if device == nil { + connectedDeviceWasCleared = true + } + } + + func teardownSessionForReconnect() async { + teardownSessionCallCount += 1 + } + + func rebuildSession(deviceID: UUID) async throws { + rebuildSessionCalls.append(deviceID) + if rebuildSessionShouldThrow { + throw ReconnectionTestError.rebuildFailed + } + } + + func disconnectTransport() async { + disconnectTransportCallCount += 1 + } + + func notifyConnectionLost() async { + notifyConnectionLostCallCount += 1 + } + + func handleReconnectionFailure() async { + handleReconnectionFailureCallCount += 1 + } + + func isTransportAutoReconnecting() async -> Bool { + if let hook = onIsTransportAutoReconnecting { + await hook() + } + return stubbedBLEPhaseIsAutoReconnecting + } } private enum ReconnectionTestError: Error { - case rebuildFailed + case rebuildFailed } diff --git a/MC1Services/Tests/MC1ServicesTests/BLEStateMachineErrorMappingTests.swift b/MC1Services/Tests/MC1ServicesTests/BLEStateMachineErrorMappingTests.swift index d02e2f88..477a69be 100644 --- a/MC1Services/Tests/MC1ServicesTests/BLEStateMachineErrorMappingTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BLEStateMachineErrorMappingTests.swift @@ -1,77 +1,76 @@ import CoreBluetooth import Foundation -import Testing @testable import MC1Services +import Testing @Suite("BLEStateMachine.makeConnectionError") struct BLEStateMachineErrorMappingTests { + @Test + func `CBATT auth/encryption codes map to BLEError.authenticationFailed`() { + let authCodes = [ + CBATTError.insufficientAuthentication.rawValue, + CBATTError.insufficientAuthorization.rawValue, + CBATTError.insufficientEncryption.rawValue, + CBATTError.insufficientEncryptionKeySize.rawValue + ] - @Test("CBATT auth/encryption codes map to BLEError.authenticationFailed") - func mapsAuthCodes() { - let authCodes = [ - CBATTError.insufficientAuthentication.rawValue, - CBATTError.insufficientAuthorization.rawValue, - CBATTError.insufficientEncryption.rawValue, - CBATTError.insufficientEncryptionKeySize.rawValue - ] - - for code in authCodes { - let nsError = NSError(domain: CBATTErrorDomain, code: code) - let result = BLEStateMachine.makeConnectionError(nsError) - guard case BLEError.authenticationFailed = result else { - Issue.record("Expected .authenticationFailed for CBATTError code \(code), got \(result)") - continue - } - } + for code in authCodes { + let nsError = NSError(domain: CBATTErrorDomain, code: code) + let result = BLEStateMachine.makeConnectionError(nsError) + guard case BLEError.authenticationFailed = result else { + Issue.record("Expected .authenticationFailed for CBATTError code \(code), got \(result)") + continue + } } + } - @Test("CBError.encryptionTimedOut maps to BLEError.authenticationFailed") - func mapsEncryptionTimeout() { - let nsError = NSError(domain: CBErrorDomain, code: CBError.encryptionTimedOut.rawValue) - let result = BLEStateMachine.makeConnectionError(nsError) - guard case BLEError.authenticationFailed = result else { - Issue.record("Expected .authenticationFailed, got \(result)") - return - } + @Test + func `CBError.encryptionTimedOut maps to BLEError.authenticationFailed`() { + let nsError = NSError(domain: CBErrorDomain, code: CBError.encryptionTimedOut.rawValue) + let result = BLEStateMachine.makeConnectionError(nsError) + guard case BLEError.authenticationFailed = result else { + Issue.record("Expected .authenticationFailed, got \(result)") + return } + } - @Test("Non-auth CBATT codes fall through to .connectionFailed") - func nonAuthCodesFallThrough() { - let nsError = NSError( - domain: CBATTErrorDomain, - code: CBATTError.requestNotSupported.rawValue, - userInfo: [NSLocalizedDescriptionKey: "Request not supported"] - ) - let result = BLEStateMachine.makeConnectionError(nsError) - guard case BLEError.connectionFailed(let msg) = result else { - Issue.record("Expected .connectionFailed, got \(result)") - return - } - #expect(msg == "Request not supported") + @Test + func `Non-auth CBATT codes fall through to .connectionFailed`() { + let nsError = NSError( + domain: CBATTErrorDomain, + code: CBATTError.requestNotSupported.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Request not supported"] + ) + let result = BLEStateMachine.makeConnectionError(nsError) + guard case let BLEError.connectionFailed(msg) = result else { + Issue.record("Expected .connectionFailed, got \(result)") + return } + #expect(msg == "Request not supported") + } - @Test("Detection survives a localized description") - func detectionSurvivesLocalizedDescription() { - // Simulate iOS localizing the auth-failure description into German. - let nsError = NSError( - domain: CBATTErrorDomain, - code: CBATTError.insufficientAuthentication.rawValue, - userInfo: [NSLocalizedDescriptionKey: "Authentifizierung ist unzureichend."] - ) - let result = BLEStateMachine.makeConnectionError(nsError) - guard case BLEError.authenticationFailed = result else { - Issue.record("Expected .authenticationFailed for localized German auth error, got \(result)") - return - } + @Test + func `Detection survives a localized description`() { + // Simulate iOS localizing the auth-failure description into German. + let nsError = NSError( + domain: CBATTErrorDomain, + code: CBATTError.insufficientAuthentication.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Authentifizierung ist unzureichend."] + ) + let result = BLEStateMachine.makeConnectionError(nsError) + guard case BLEError.authenticationFailed = result else { + Issue.record("Expected .authenticationFailed for localized German auth error, got \(result)") + return } + } - @Test("nil error uses fallback message") - func nilErrorUsesFallback() { - let result = BLEStateMachine.makeConnectionError(nil, fallback: "Disconnected during setup") - guard case BLEError.connectionFailed(let msg) = result else { - Issue.record("Expected .connectionFailed, got \(result)") - return - } - #expect(msg == "Disconnected during setup") + @Test + func `nil error uses fallback message`() { + let result = BLEStateMachine.makeConnectionError(nil, fallback: "Disconnected during setup") + guard case let BLEError.connectionFailed(msg) = result else { + Issue.record("Expected .connectionFailed, got \(result)") + return } + #expect(msg == "Disconnected during setup") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift index 90fdc1c9..ee6725ee 100644 --- a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift @@ -1,3120 +1,3119 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services /// Integration tests covering the full export → parse → import pipeline. /// These tests use real in-memory PersistenceStores and the public AppBackupService API, /// exercising the complete data flow rather than manually constructed envelopes. @Suite("BackupIntegration") struct BackupIntegrationTests { + // MARK: - Test 1: Full round-trip + + /// Exports from a populated store and imports into a fresh store, verifying all data survives. + @Test + func `Full round-trip: export then import into fresh store restores all data`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Seed data + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xAB, count: 32), + name: "Alice" + ) + try await sourceStore.saveContact(contact) + + let channel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") + try await sourceStore.saveChannel(channel) + + var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id, text: "Hello") + msg.deduplicationKey = "integration-dedup-\(UUID())" + try await sourceStore.saveMessage(msg) + + let reaction = ReactionDTO.testReaction(messageID: msg.id, radioID: radioID) + try await sourceStore.saveReaction(reaction) + + // Export + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + + // Parse + let envelope = try parseBackup(data: exportResult.data) + #expect(envelope.manifest.validate(against: envelope)) + + // Import into a fresh store + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + // Verify insert counts + #expect(result.devicesInserted == 1) + #expect(result.contactsInserted == 1) + #expect(result.channelsInserted == 1) + #expect(result.messagesInserted == 1) + #expect(result.reactionsInserted == 1) + #expect(result.totalSkipped == 0) + + // Verify data actually persisted in the destination store + let destContacts = try await destStore.fetchAllContacts(radioID: radioID) + #expect(destContacts.count == 1) + #expect(destContacts.first?.name == "Alice") + + let destChannels = try await destStore.fetchAllChannels(radioID: radioID) + #expect(destChannels.count == 1) + #expect(destChannels.first?.name == "General") + + let destMessages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(destMessages.count == 1) + #expect(destMessages.first?.text == "Hello") + + let messageIDs = Set(destMessages.map(\.id)) + let destReactions = try await destStore.fetchAllReactions(radioID: radioID) + #expect(destReactions.count == 1) + // Reaction messageID must match a message in the destination store + #expect(try messageIDs.contains(#require(destReactions.first?.messageID))) + } + + // MARK: - Test 1b: Unmodeled contact type byte survives backup + + /// `ContactDTO.typeRawValue` is a non-optional field that has existed since the backup + /// feature shipped, so the contract is a plain encode → decode round-trip (no + /// `decodeIfPresent` legacy path). An unmodeled type byte must survive verbatim so a + /// future refactor can't silently re-collapse it onto a modeled `ContactType`. + @Test + func `Unmodeled contact type byte (0x04) survives DTO encode → decode round-trip`() throws { + let dto = ContactDTO.testContact(typeRawValue: 0x04) + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(ContactDTO.self, from: encoded) + #expect(decoded.typeRawValue == 0x04) + #expect(decoded.type == .chat) + } + + /// Full envelope path: an unmodeled type byte must survive export → import into a fresh store, + /// not just an isolated DTO round-trip. + @Test + func `Unmodeled contact type byte (0x04) survives full backup export → import`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xAB, count: 32), + name: "FutureType", + typeRawValue: 0x04 + ) + try await sourceStore.saveContact(contact) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + _ = try await service.importBackup(envelope: envelope, into: destStore) + + let destContacts = try await destStore.fetchAllContacts(radioID: radioID) + #expect(destContacts.first?.typeRawValue == 0x04) + #expect(destContacts.first?.type == .chat) + } + + // MARK: - Test 1c: Device knownRegions survives backup + + /// `DeviceDTO.knownRegions` is a non-optional `[String]` that shipped before the + /// backup feature, so no envelope can predate it: the contract is a plain encode → + /// decode round-trip, not a `decodeIfPresent` legacy path. This locks that contract + /// at the DTO boundary so a future refactor can't drop the field from the wire format. + @Test + func `Device knownRegions survives DTO encode → decode round-trip`() throws { + let dto = DeviceDTO.testDevice().copy { $0.knownRegions = ["US915", "EU868"] } + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(DeviceDTO.self, from: encoded) + #expect(decoded.knownRegions == ["US915", "EU868"]) + } + + /// Full envelope path: regions added through the targeted, list-owning writer must + /// survive export → import into a fresh store, exercising the `Device(dto:)` insert + /// seeding the restore relies on. + @Test + func `Device knownRegions survives full backup export → import into a fresh store`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Discovery owns knownRegions through the targeted add path, not a full saveDevice. + try await sourceStore.addDeviceKnownRegion(radioID: radioID, region: "US915") + try await sourceStore.addDeviceKnownRegion(radioID: radioID, region: "EU868") + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + #expect(envelope.devices.first?.knownRegions == ["US915", "EU868"]) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let result = try await service.importBackup(envelope: envelope, into: destStore) + #expect(result.devicesInserted == 1) + + // Import re-mints Device.id; radioID is the surviving partition key. + let restored = try #require(await destStore.fetchDevice(radioID: radioID)) + #expect(restored.knownRegions == ["US915", "EU868"]) + } + + // MARK: - Test 2: Cross-bundle radioID remapping + + /// When the target store contains a device with the same publicKey as the backup but a + /// different radioID, all child records must be remapped to the local radioID. + @Test + func `Cross-bundle: child records are remapped to local radioID on publicKey match`() async throws { + let sharedPublicKey = Data(repeating: 0xCC, count: 32) + let sourceRadioID = UUID() + let targetRadioID = UUID() + + // Source store: device with sharedPublicKey, radioID = sourceRadioID + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: sourceRadioID) + let sourceDevice = DeviceDTO.testDevice( + id: sourceRadioID, + radioID: sourceRadioID, + publicKey: sharedPublicKey + ) + try await sourceStore.saveDevice(sourceDevice) + + let contact = ContactDTO.testContact( + radioID: sourceRadioID, + publicKey: Data(repeating: 0xDD, count: 32), + name: "Bob" + ) + try await sourceStore.saveContact(contact) + + // Export from source + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + // Target store: device with same publicKey but different radioID + let targetStore = try await PersistenceStore.createTestDataStore(radioID: targetRadioID) + let targetDevice = DeviceDTO.testDevice( + id: targetRadioID, + radioID: targetRadioID, + publicKey: sharedPublicKey + ) + try await targetStore.saveDevice(targetDevice) + + // Import + let result = try await service.importBackup( + envelope: envelope, + into: targetStore + ) + + // Backup device matched by publicKey → not inserted + #expect(result.devicesInserted == 0) + #expect(result.contactsInserted == 1) + + // Contact must live under targetRadioID, not sourceRadioID + let contactsUnderTarget = try await targetStore.fetchAllContacts(radioID: targetRadioID) + #expect(contactsUnderTarget.count == 1) + #expect(contactsUnderTarget.first?.name == "Bob") + + let contactsUnderSource = try await targetStore.fetchAllContacts(radioID: sourceRadioID) + #expect(contactsUnderSource.count == 0) + } + + // MARK: - Test 4: Merge import — DM thread visible for existing contact + + @Test + func `Import onto existing contact with nil lastMessageDate makes DM thread visible`() async throws { + let radioID = UUID() + let sharedPublicKey = Data(repeating: 0xAB, count: 32) + + // Destination store has a contact with no message history + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: sharedPublicKey, + name: "Alice", + lastMessageDate: nil + ) + try await destStore.saveContact(contact) + + // Verify contact is NOT in conversations list (lastMessageDate is nil) + let beforeConversations = try await destStore.fetchConversations(radioID: radioID) + #expect(beforeConversations.isEmpty) + + // Build backup with DMs for the same contact + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupContact = ContactDTO.testContact( + radioID: radioID, + publicKey: sharedPublicKey, + name: "Alice", + lastMessageDate: Date() + ) + var msg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: backupContact.id, + text: "Restored DM" + ) + msg.deduplicationKey = "merge-dm-\(UUID())" + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [backupContact], + messages: [msg] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + // Contact was skipped (already exists), message was inserted + #expect(result.contactsSkipped == 1) + #expect(result.messagesInserted == 1) + + // Contact must now appear in conversations + let afterConversations = try await destStore.fetchConversations(radioID: radioID) + #expect(afterConversations.count == 1) + #expect(afterConversations.first?.name == "Alice") + #expect(afterConversations.first?.lastMessageDate != nil) + } + + // MARK: - Test 5: Merge import — MessageRepeat relationship set (pre-existing parent) + + @Test + func `Import repeats onto existing message sets relationship and enables cascade delete`() async throws { + let radioID = UUID() + + // Destination store has an existing message + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + try await destStore.saveContact(contact) + + var existingMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Existing", + direction: .incoming + ) + existingMsg.deduplicationKey = "existing-msg-key" + try await destStore.saveMessage(existingMsg) + + // Build backup that contributes a repeat for the same message + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + var backupMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Existing", + direction: .incoming + ) + backupMsg.deduplicationKey = "existing-msg-key" + + let repeat1 = MessageRepeatDTO.testRepeat( + messageID: backupMsg.id, + pathNodes: Data([0x31]) + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [backupMsg], + messageRepeats: [repeat1] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.messagesSkipped == 1) + #expect(result.messageRepeatsInserted == 1) + + // Verify the repeat was linked to the existing message + let repeats = try await destStore.fetchMessageRepeats(messageID: existingMsg.id) + #expect(repeats.count == 1) + + // Delete the message — repeat must cascade-delete + try await destStore.deleteMessage(id: existingMsg.id) + let repeatsAfterDelete = try await destStore.fetchMessageRepeats(messageID: existingMsg.id) + #expect(repeatsAfterDelete.isEmpty) + } + + // MARK: - Test 6: Fresh-store import — MessageRepeat cascade delete + + @Test + func `Fresh-store import sets MessageRepeat relationship and cascade deletes work`() async throws { + let radioID = UUID() + + // Source store with a message and repeats + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xBB, count: 32), + name: "Bob" + ) + try await sourceStore.saveContact(contact) + + var msg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + text: "Hello mesh" + ) + msg.deduplicationKey = "fresh-cascade-\(UUID())" + try await sourceStore.saveMessage(msg) + + // Save repeat using the normal (relationship-setting) path + let repeatDTO = MessageRepeatDTO.testRepeat(messageID: msg.id, pathNodes: Data([0x42])) + try await sourceStore.saveMessageRepeat(repeatDTO) + + // Export + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + // Import into fresh store + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + #expect(result.messagesInserted == 1) + #expect(result.messageRepeatsInserted == 1) + + // Verify repeats exist + let destMessages = try await destStore.fetchAllMessages(radioID: radioID) + let destMsgID = try #require(destMessages.first?.id) + let repeats = try await destStore.fetchMessageRepeats(messageID: destMsgID) + #expect(repeats.count == 1) + + // Delete the message — repeat must cascade-delete + try await destStore.deleteMessage(id: destMsgID) + let repeatsAfterDelete = try await destStore.fetchMessageRepeats(messageID: destMsgID) + #expect(repeatsAfterDelete.isEmpty) + } + + // MARK: - Test 7: Merge import — caches recomputed + + @Test + func `Import repeats/reactions onto existing message recomputes heardRepeats and reactionSummary`() async throws { + let radioID = UUID() + + // Destination store with a message (heardRepeats=0, no reactions) + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + try await destStore.saveContact(contact) + + var existingMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Cache test", + direction: .incoming, + heardRepeats: 0 + ) + existingMsg.deduplicationKey = "cache-test-key" + try await destStore.saveMessage(existingMsg) + + // Build backup that adds 2 repeats and 1 reaction to the same message + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + var backupMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Cache test", + direction: .incoming + ) + backupMsg.deduplicationKey = "cache-test-key" + + let repeat1 = MessageRepeatDTO.testRepeat( + messageID: backupMsg.id, + pathNodes: Data([0x31]) + ) + let repeat2 = MessageRepeatDTO.testRepeat( + messageID: backupMsg.id, + pathNodes: Data([0x42]) + ) + let reaction = ReactionDTO.testReaction( + messageID: backupMsg.id, + radioID: radioID, + emoji: "👍", + senderName: "Eve" + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [backupMsg], + messageRepeats: [repeat1, repeat2], + reactions: [reaction] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.messagesSkipped == 1) + #expect(result.messageRepeatsInserted == 2) + #expect(result.reactionsInserted == 1) + + // Verify heardRepeats was recomputed + let updatedMsg = try await destStore.fetchMessage(id: existingMsg.id) + #expect(updatedMsg?.heardRepeats == 2) + + // Verify reactionSummary was recomputed + #expect(updatedMsg?.reactionSummary == "👍:1") + } + + // MARK: - Test 8: Merge import — channel metadata refreshed + + @Test + func `Import messages onto existing channel with nil lastMessageDate refreshes metadata`() async throws { + let radioID = UUID() + + // Destination store has a channel with no messages + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let channel = ChannelDTO.testChannel( + radioID: radioID, + index: 0, + name: "General", + lastMessageDate: nil + ) + try await destStore.saveChannel(channel) + + // Verify channel has nil lastMessageDate + let beforeChannel = try await destStore.fetchChannel(radioID: radioID, index: 0) + #expect(beforeChannel?.lastMessageDate == nil) + + // Build backup with messages for the same channel + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") + var msg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + text: "Restored channel msg" + ) + msg.deduplicationKey = "channel-meta-\(UUID())" + + let envelope = AppBackupEnvelope.test( + devices: [device], + channels: [backupChannel], + messages: [msg] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.channelsSkipped == 1) + #expect(result.messagesInserted == 1) + + // Channel must now have lastMessageDate set + let afterChannel = try await destStore.fetchChannel(radioID: radioID, index: 0) + #expect(afterChannel?.lastMessageDate != nil) + } + + // MARK: - Test 9: Merge import — saved trace path runs preserved + + @Test + func `Import onto existing saved trace path merges runs without duplicating them`() async throws { + let radioID = UUID() + let pathBytes = Data([0x12, 0x34, 0x56, 0x78]) + let existingRun = TracePathRunDTO.testRun( + date: Date().addingTimeInterval(-120), + roundTripMs: 180 + ) + let importedRun = TracePathRunDTO.testRun( + date: Date(), + roundTripMs: 95 + ) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingPath = try await destStore.createSavedTracePath( + radioID: radioID, + name: "Shared Route", + pathBytes: pathBytes, + hashSize: 2, + initialRun: existingRun + ) + + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupPath = SavedTracePathDTO.testPath( + radioID: radioID, + name: "Shared Route", + pathBytes: pathBytes, + hashSize: 2, + runs: [importedRun] + ) + let envelope = AppBackupEnvelope.test( + devices: [device], + savedTracePaths: [backupPath] + ) + + let service = AppBackupService() + + let firstResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(firstResult.savedTracePathsInserted == 0) + #expect(firstResult.savedTracePathsSkipped == 1) + #expect(firstResult.savedTracePathsMerged == 1) + #expect(firstResult.hasRestoredChanges) + + let mergedPath = try #require(await destStore.fetchSavedTracePath(id: existingPath.id)) + #expect(mergedPath.runs.count == 2) + #expect(Set(mergedPath.runs.map(\.id)) == Set([existingRun.id, importedRun.id])) + + let secondResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(secondResult.savedTracePathsInserted == 0) + #expect(secondResult.savedTracePathsSkipped == 1) + #expect(secondResult.savedTracePathsMerged == 0) + #expect(!secondResult.hasRestoredChanges) + + let deduplicatedPath = try #require(await destStore.fetchSavedTracePath(id: existingPath.id)) + #expect(deduplicatedPath.runs.count == 2) + #expect(Set(deduplicatedPath.runs.map(\.id)) == Set([existingRun.id, importedRun.id])) + } + + // MARK: - Test 9b: Cross-path run id reuse never relocates a run + + @Test + func `Import reusing a local run's id under a different path drops the duplicate run rather than relocating it`() async throws { + let radioID = UUID() + let pathABytes = Data([0x12, 0x34, 0x56, 0x78]) + let pathBBytes = Data([0xAB, 0xCD, 0xEF, 0x01]) + let sharedRunID = UUID() + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Local path A owns run R. + let runR = TracePathRunDTO.testRun(id: sharedRunID, roundTripMs: 180) + let pathA = try await destStore.createSavedTracePath( + radioID: radioID, + name: "Path A", + pathBytes: pathABytes, + hashSize: 1, + initialRun: runR + ) + + // Backup path B is a distinct path whose run reuses R's id. + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupPathB = SavedTracePathDTO.testPath( + radioID: radioID, + name: "Path B", + pathBytes: pathBBytes, + hashSize: 1, + runs: [TracePathRunDTO.testRun(id: sharedRunID, roundTripMs: 999)] + ) + let envelope = AppBackupEnvelope.test( + devices: [device], + savedTracePaths: [backupPathB] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + // Path B is a new path, but its duplicate-id run is dropped store-wide. + #expect(result.savedTracePathsInserted == 1) + + let allPaths = try await destStore.fetchSavedTracePaths(radioID: radioID) + #expect(allPaths.count == 2) + + // Run R stays under path A; path B gains no run. + let pathAAfter = try #require(await destStore.fetchSavedTracePath(id: pathA.id)) + #expect(pathAAfter.runs.count == 1) + #expect(pathAAfter.runs.first?.id == sharedRunID) + + let pathBAfter = try #require(allPaths.first { $0.pathBytes == pathBBytes }) + #expect(pathBAfter.runs.isEmpty) + + // The run exists exactly once in the whole store. + let totalRuns = allPaths.reduce(0) { $0 + $1.runs.count } + #expect(totalRuns == 1) + } + + // MARK: - Test 10: Orphaned radio-scoped data survives export/import + + @Test + func `Export preserves orphaned radio-scoped data after device-only delete`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xD1, count: 32), + name: "Orphaned Contact" + ) + try await sourceStore.saveContact(contact) + + let channel = ChannelDTO.testChannel( + radioID: radioID, + index: 1, + name: "Orphaned Channel", + unreadCount: 3, + notificationLevel: .mentionsOnly, + isFavorite: true + ) + try await sourceStore.saveChannel(channel) + + var message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Preserve me" + ) + message.deduplicationKey = "orphaned-message-\(UUID())" + try await sourceStore.saveMessage(message) + + let session = RemoteNodeSessionDTO.testSession(radioID: radioID) + try await sourceStore.saveRemoteNodeSessionDTO(session) + + let roomMessage = RoomMessageDTO.testRoomMessage(sessionID: session.id) + try await sourceStore.saveRoomMessage(roomMessage) + + let blockedSender = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) + try await sourceStore.saveBlockedChannelSender(blockedSender) + + let tracePath = SavedTracePathDTO.testPath(radioID: radioID) + let initialRun = tracePath.runs.first + _ = try await sourceStore.createSavedTracePath( + radioID: tracePath.radioID, + name: tracePath.name, + pathBytes: tracePath.pathBytes, + hashSize: tracePath.hashSize, + initialRun: initialRun + ) + + try await sourceStore.deleteDevice(id: radioID) + #expect(try await sourceStore.fetchDevice(id: radioID) == nil) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: result.data) + + #expect(envelope.devices.isEmpty) + #expect(envelope.contacts.count == 1) + #expect(envelope.channels.count == 1) + #expect(envelope.messages.count == 1) + #expect(envelope.remoteNodeSessions.count == 1) + #expect(envelope.roomMessages.count == 1) + #expect(envelope.savedTracePaths.count == 1) + #expect(envelope.blockedChannelSenders.count == 1) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let firstResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(firstResult.devicesInserted == 0) + #expect(firstResult.contactsInserted == 1) + #expect(firstResult.channelsInserted == 1) + #expect(firstResult.messagesInserted == 1) + #expect(firstResult.remoteNodeSessionsInserted == 1) + #expect(firstResult.roomMessagesInserted == 1) + #expect(firstResult.savedTracePathsInserted == 1) + #expect(firstResult.blockedChannelSendersInserted == 1) + + #expect(try await destStore.fetchAllContacts(radioID: radioID).count == 1) + #expect(try await destStore.fetchAllChannels(radioID: radioID).count == 1) + #expect(try await destStore.fetchAllMessages(radioID: radioID).count == 1) + #expect(try await destStore.fetchRemoteNodeSessions(radioID: radioID).count == 1) + #expect(try await destStore.fetchRoomMessages(sessionID: session.id).count == 1) + #expect(try await destStore.fetchSavedTracePaths(radioID: radioID).count == 1) + #expect(try await destStore.fetchBlockedChannelSenders(radioID: radioID).count == 1) + + let secondResult = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(secondResult.totalInserted == 0) + #expect(secondResult.contactsSkipped == 1) + #expect(secondResult.channelsSkipped == 1) + #expect(secondResult.messagesSkipped == 1) + #expect(secondResult.remoteNodeSessionsSkipped == 1) + #expect(secondResult.roomMessagesSkipped == 1) + #expect(secondResult.savedTracePathsSkipped == 1) + #expect(secondResult.blockedChannelSendersSkipped == 1) + } + + // MARK: - Test 11: Merge import — contact metadata restored + + @Test + func `Import onto existing contact restores backup-owned contact metadata`() async throws { + let radioID = UUID() + let sharedPublicKey = Data(repeating: 0xE2, count: 32) + let importedDate = Date(timeIntervalSince1970: 1_700_000_000) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingContact = ContactDTO.testContact( + radioID: radioID, + publicKey: sharedPublicKey, + name: "Alice", + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0 + ) + try await destStore.saveContact(existingContact) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupContact = ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: sharedPublicKey, + name: "Alice", + typeRawValue: existingContact.typeRawValue, + flags: existingContact.flags, + outPathLength: existingContact.outPathLength, + outPath: existingContact.outPath, + lastAdvertTimestamp: existingContact.lastAdvertTimestamp, + latitude: existingContact.latitude, + longitude: existingContact.longitude, + lastModified: existingContact.lastModified, + nickname: "Field Ops", + isBlocked: true, + isMuted: true, + isFavorite: true, + lastMessageDate: importedDate, + unreadCount: 7, + unreadMentionCount: 2, + ocvPreset: OCVPreset.custom.rawValue, + customOCVArrayString: "4200,4100,4000" + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + contacts: [backupContact] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.contactsInserted == 0) + #expect(result.contactsSkipped == 1) + + let mergedContact = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: sharedPublicKey) + ) + #expect(mergedContact.nickname == "Field Ops") + #expect(mergedContact.isBlocked == true) + #expect(mergedContact.isMuted == true) + #expect(mergedContact.isFavorite == true) + #expect(mergedContact.lastMessageDate == importedDate) + #expect(mergedContact.unreadCount == 7) + #expect(mergedContact.unreadMentionCount == 2) + #expect(mergedContact.ocvPreset == OCVPreset.custom.rawValue) + #expect(mergedContact.customOCVArrayString == "4200,4100,4000") + } + + // MARK: - Test 12: Merge import — channel metadata restored + + @Test + func `Import onto existing channel restores backup-owned channel metadata`() async throws { + let radioID = UUID() + let importedDate = Date(timeIntervalSince1970: 1_700_000_100) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 2, + name: "Ops", + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false, + floodScope: .inherit + ) + try await destStore.saveChannel(existingChannel) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: existingChannel.index, + name: existingChannel.name, + lastMessageDate: importedDate, + unreadCount: 11, + unreadMentionCount: 4, + notificationLevel: .mentionsOnly, + isFavorite: true, + floodScope: .region("US") + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupChannel] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + + let mergedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 2)) + #expect(mergedChannel.lastMessageDate == importedDate) + #expect(mergedChannel.unreadCount == 11) + #expect(mergedChannel.unreadMentionCount == 4) + #expect(mergedChannel.notificationLevel == .mentionsOnly) + #expect(mergedChannel.isFavorite == true) + #expect(mergedChannel.regionScope == "US") + } + + // MARK: - Test 12b: Different-secret slot collision relocates instead of merging + + /// Local `#kosice` (secret 0x01) occupies slot 2; the backup carries a distinct channel + /// `#praha` (secret 0x02) that also claims slot 2. Because the two secrets differ, this + /// is "different channel, relocate", never a metadata merge: `#kosice` keeps its slot, + /// name, and secret untouched, and `#praha` is inserted as a separate channel on a free + /// slot. Contrast with same-secret merges, where backup metadata is adopted in place. + @Test + func `Import onto a different-secret slot keeps the local channel and inserts the backup channel separately`() async throws { + let radioID = UUID() + let localSecret = Data(repeating: 0x01, count: 16) + let backupSecret = Data(repeating: 0x02, count: 16) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 2, + name: "#kosice", + secret: localSecret + ) + try await destStore.saveChannel(existingChannel) + + // Same (radioID, index) slot, but a different name and secret in the backup. + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: 2, + name: "#praha", + secret: backupSecret + ) + + let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupChannel]) + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + // The backup channel is a distinct identity, so it is inserted (relocated), not + // merged into #kosice's slot. + #expect(result.channelsInserted == 1) + #expect(result.channelsSkipped == 0) + + // #kosice keeps its slot, name, and secret. + let kosice = try #require(await destStore.fetchChannel(radioID: radioID, index: 2)) + #expect(kosice.name == "#kosice") + #expect(kosice.secret == localSecret) + + // #praha exists as a separate channel on a different slot, with its own name/secret. + let channels = try await destStore.fetchAllChannels(radioID: radioID) + let praha = try #require(channels.first { $0.secret == backupSecret }) + #expect(praha.name == "#praha") + #expect(praha.index != 2) + } + + // MARK: - Channel reconciliation by secret (slot relocation) — REPRO + + /// Reproduction for silent wrong-context delivery. Backup `#praha` (secretA) lives + /// at slot 3 with a channel message; locally slot 3 is `#brno` (secretB). With slot-only + /// reconciliation the imported message attaches to `#brno` and `#praha` is dropped. The + /// fix reconciles by `(radioID, secret)`: `#praha` relocates to a free slot and its + /// message follows; `#brno` is untouched. + @Test + func `Channel reconcile: backup channel collides with a different-secret local slot, relocates and carries its message`() async throws { + let radioID = UUID() + let secretBrno = Data(repeating: 0xB0, count: 16) + let secretPraha = Data(repeating: 0xA0, count: 16) + let collisionIndex: UInt8 = 3 + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let localBrno = ChannelDTO.testChannel( + radioID: radioID, + index: collisionIndex, + name: "#brno", + secret: secretBrno + ) + try await destStore.saveChannel(localBrno) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupPraha = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: collisionIndex, + name: "#praha", + secret: secretPraha + ) + var prahaMessage = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: collisionIndex, + text: "Praha checkpoint", + timestamp: 1_700_000_900, + direction: .incoming, + senderNodeName: "Jan" + ) + prahaMessage.deduplicationKey = "ch-\(collisionIndex)-1700000900-Jan-DEADBEEF" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupPraha], + messages: [prahaMessage] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + // #praha is a distinct channel: inserted (relocated), not merged into #brno's slot. + #expect(result.channelsInserted == 1) + #expect(result.channelsSkipped == 0) + #expect(result.messagesInserted == 1) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + let brno = try #require(channels.first { $0.secret == secretBrno }) + let praha = try #require(channels.first { $0.secret == secretPraha }) + + // #brno keeps its slot and name; #praha lands on a different (free) slot. + #expect(brno.index == collisionIndex) + #expect(brno.name == "#brno") + #expect(praha.index != collisionIndex) + #expect(praha.name == "#praha") + + // The message followed #praha's relocated slot, not #brno's. + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.count == 1) + let restored = try #require(messages.first) + #expect(restored.channelIndex == praha.index) + #expect(restored.text == "Praha checkpoint") + + // #brno received no foreign message. + let brnoMessages = messages.filter { $0.channelIndex == collisionIndex } + #expect(brnoMessages.isEmpty) + } + + @Test + func `Re-importing a stale backup never upserts a reconfigured live channel`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let sharedID = UUID() + + // Live channel: same surrogate id as the backup, but secret rotated in place (S2), + // occupying the backup's slot. + let liveSecret = Data(repeating: 0xB2, count: 32) + let live = ChannelDTO( + id: sharedID, radioID: radioID, index: 3, name: "Live Net", + secret: liveSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + try await store.batchInsertChannels([live], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8]) + + // Backup channel: same id, slot 3, but the original secret S1 (no local match). + let backupSecret = Data(repeating: 0xA1, count: 32) + let backup = ChannelDTO( + id: sharedID, radioID: radioID, index: 3, name: "Stale Net", + secret: backupSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + let result = try await store.batchInsertChannels([backup], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8]) + + let channels = try await store.fetchChannels(radioID: radioID) + // Live channel survives untouched at slot 3 with its rotated secret. + let liveRow = try #require(channels.first { $0.secret == liveSecret }) + #expect(liveRow.index == 3) + #expect(liveRow.name == "Live Net") + // Backup channel coexists, relocated to a free slot with a fresh id. + let backupRow = try #require(channels.first { $0.secret == backupSecret }) + #expect(backupRow.id != sharedID) + #expect(backupRow.index != 3) + #expect(result.inserted == 1) + } + + @Test + func `batchInsertChannels reports newly-occupied local slots for draft clearing, excluding merges`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + + let mergeSecret = Data(repeating: 0xC1, count: 32) + let occupiedSecret = Data(repeating: 0xC4, count: 32) + let localAtSlot2 = ChannelDTO( + id: UUID(), radioID: radioID, index: 2, name: "Keep", + secret: mergeSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + let localAtSlot4 = ChannelDTO( + id: UUID(), radioID: radioID, index: 4, name: "Occupied", + secret: occupiedSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + try await store.batchInsertChannels( + [localAtSlot2, localAtSlot4], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8] + ) + + // (a) merges by secret into local slot 2 though recorded at slot 6 — occupant unchanged. + let mergeBackup = ChannelDTO( + id: UUID(), radioID: radioID, index: 6, name: "Keep", + secret: mergeSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + // (b) foreign channel placed at its own free slot 5. + let collideSecret = Data(repeating: 0xD4, count: 32) + let freshAtFreeSlot = ChannelDTO( + id: UUID(), radioID: radioID, index: 5, name: "Fresh", + secret: Data(repeating: 0xD5, count: 32), isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + // (c) foreign channel colliding with occupied slot 4, relocated to a free slot. + let collideAtSlot4 = ChannelDTO( + id: UUID(), radioID: radioID, index: 4, name: "Collide", + secret: collideSecret, isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, + notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + + let result = try await store.batchInsertChannels( + [mergeBackup, freshAtFreeSlot, collideAtSlot4], + radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8] + ) + + let inserted = result.insertedLocalIndices[radioID] ?? [] + // Fresh insert recorded at its own free slot. + #expect(inserted.contains(5)) + // Merge-by-secret destination (2) and backup-file source (6) are not local inserts. + #expect(!inserted.contains(2)) + #expect(!inserted.contains(6)) + // The colliding channel's untouched source slot (4) is not recorded; its relocation + // destination is. + #expect(!inserted.contains(4)) + let relocatedRow = try #require( + try await (store.fetchChannels(radioID: radioID)).first { $0.secret == collideSecret } + ) + #expect(relocatedRow.index != 4) + #expect(inserted.contains(relocatedRow.index)) + #expect(result.inserted == 2) + } + + @Test + func `Exporting a channel with an unmigrated notification level does not migrate the live row`() { + let channel = Channel( + radioID: UUID(), index: 1, name: "Ops", + secret: Data(repeating: 0x33, count: 32) + ) + // Simulate a pre-migration row: -1 sentinel with the legacy muted flag set. + channel.notificationLevelRawValue = -1 + channel.legacyIsMuted = true + + let dto = ChannelDTO(from: channel) + + // The read did not trigger the migrating getter's in-memory write-back. + #expect(channel.notificationLevelRawValue == -1) + // The DTO still carries the correctly decoded level (muted, from legacy isMuted). + #expect(dto.notificationLevel == .muted) + } + + @Test + func `Duplicate device public keys in one envelope skip the loser and remap its children`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let key = Data(repeating: 0x42, count: 32) + let radioA = UUID(), radioB = UUID() + let devA = DeviceDTO.testDevice().copy { $0.publicKey = key; $0.radioID = radioA } + let devB = DeviceDTO.testDevice().copy { $0.publicKey = key; $0.radioID = radioB } + // Both devices share the duplicate `key` (the scenario under test), but the contacts + // must carry distinct public keys: after both contacts' radioID is remapped to the + // single surviving local radioID they would otherwise share an identical `contactKey` + // (radioID + publicKey) and the second would be deduped, collapsing to one row. + let contactA = ContactDTO.testContact(radioID: radioA, publicKey: Data(repeating: 0x01, count: 32)) + let contactB = ContactDTO.testContact(radioID: radioB, publicKey: Data(repeating: 0x02, count: 32)) + let envelope = AppBackupEnvelope.test( + devices: [devA, devB], contacts: [contactA, contactB] + ) + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: store) + #expect(result.counts[.devices]?.skipped == 1) // loser duplicate + #expect(result.counts[.devices]?.inserted == 1) + // Both contacts land under the single surviving local radioID. + let devices = try await store.fetchAllDevices() + let localRadioID = try #require(devices.first?.radioID) + let contacts = try await store.fetchContacts(radioID: localRadioID) + #expect(contacts.count == 2) + } + + @Test + func `Channel floodScope survives a fresh-insert export/import round-trip`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + try await sourceStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General", floodScope: .inherit) + ) + try await sourceStore.saveChannel( + ChannelDTO.testChannel( + radioID: radioID, index: 1, name: "Regional", + secret: Data(repeating: 0x55, count: 32), floodScope: .region("US") + ) + ) + + let service = AppBackupService() + let envelope = try await parseBackup(data: service.export(persistenceStore: sourceStore).data) + + // Fresh dest store: both channels take the fresh-insert path (Channel(dto:)), not merge. + let destStore = try PersistenceStore(modelContainer: PersistenceStore.createContainer(inMemory: true)) + let result = try await service.importBackup(envelope: envelope, into: destStore) + #expect(result.channelsInserted == 2) + + let restoredRegional = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) + #expect(restoredRegional.floodScope == .region("US")) + let restoredInherit = try #require(try await destStore.fetchChannel(radioID: radioID, index: 0)) + #expect(restoredInherit.floodScope == .inherit) + } + + @Test + func `Re-importing the same backup is idempotent for unread counts and last-message dates`() async throws { + let radioID = UUID() + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Local channel the backup will merge into (matched by stable secret). + let secret = Data(repeating: 0x77, count: 32) + let localDate = Date(timeIntervalSince1970: 1_700_000_000) + try await destStore.saveChannel( + ChannelDTO.testChannel( + radioID: radioID, index: 1, name: "Ops", secret: secret, + lastMessageDate: localDate, unreadCount: 5 + ) + ) + + // Backup carries the same channel with a higher unread count and a later date. + let backupDate = Date(timeIntervalSince1970: 1_700_009_000) + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + radioID: radioID, index: 1, name: "Ops", secret: secret, + lastMessageDate: backupDate, unreadCount: 9 + ) + let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupChannel]) + + let service = AppBackupService() + _ = try await service.importBackup(envelope: envelope, into: destStore) + let afterFirst = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) + + _ = try await service.importBackup(envelope: envelope, into: destStore) + let afterSecond = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) + + // The merge (max of local/backup) and date reconciliation are idempotent: a repeat + // import neither double-counts unread nor oscillates the last-message date. + #expect(afterSecond.unreadCount == afterFirst.unreadCount) + #expect(afterSecond.unreadMentionCount == afterFirst.unreadMentionCount) + #expect(afterSecond.lastMessageDate == afterFirst.lastMessageDate) + } + + /// Case A: same secret at the same slot is a pure metadata merge with no relocation. + @Test + func `Channel reconcile: same secret at same slot merges metadata, index unchanged`() async throws { + let radioID = UUID() + let secret = Data(repeating: 0x44, count: 16) + let slot: UInt8 = 2 + let importedDate = Date(timeIntervalSince1970: 1_700_000_950) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let localChannel = ChannelDTO.testChannel( + radioID: radioID, + index: slot, + name: "#ops", + secret: secret, + lastMessageDate: nil + ) + try await destStore.saveChannel(localChannel) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: slot, + name: "#ops", + secret: secret, + lastMessageDate: importedDate + ) + var msg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: slot, + text: "Same slot", + timestamp: 1_700_000_950, + // Older than importedDate so the merged channel date is the max and survives + // the lastMessageDate reconcile pass (which keys on createdAt). + createdAt: Date(timeIntervalSince1970: 1_600_000_000), + direction: .incoming, + senderNodeName: "Eva" + ) + msg.deduplicationKey = "ch-\(slot)-1700000950-Eva-CAFE0001" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupChannel], + messages: [msg] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + #expect(result.messagesInserted == 1) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + #expect(channels.count == 1) + #expect(channels.first?.index == slot) + #expect(channels.first?.secret == secret) + #expect(channels.first?.lastMessageDate == importedDate) + + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.count == 1) + #expect(messages.first?.channelIndex == slot) + } + + /// Case B: the same secret moved slots between backup and restore. The message + /// remaps to the local slot; no duplicate channel is inserted. + @Test + func `Channel reconcile: same secret on a different slot remaps the message, no duplicate channel`() async throws { + let radioID = UUID() + let secret = Data(repeating: 0x55, count: 16) + let backupSlot: UInt8 = 3 + let localSlot: UInt8 = 5 + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let localChannel = ChannelDTO.testChannel( + radioID: radioID, + index: localSlot, + name: "#praha", + secret: secret + ) + try await destStore.saveChannel(localChannel) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: backupSlot, + name: "#praha", + secret: secret + ) + var msg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: backupSlot, + text: "Moved slots", + timestamp: 1_700_001_000, + direction: .incoming, + senderNodeName: "Lena" + ) + msg.deduplicationKey = "ch-\(backupSlot)-1700001000-Lena-0BADF00D" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupChannel], + messages: [msg] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + #expect(result.messagesInserted == 1) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + #expect(channels.count == 1) + #expect(channels.first?.index == localSlot) + + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.count == 1) + #expect(messages.first?.channelIndex == localSlot) + } + + /// The public channel (index 0, all-zero secret) merges into the local public channel + /// without relocating, because empty-secret channels match by index. + @Test + func `Channel reconcile: public channel merges into local public channel without relocation`() async throws { + let radioID = UUID() + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let localPublic = ChannelDTO.testChannel( + radioID: radioID, + index: 0, + name: "Public", + lastMessageDate: nil + ) + try await destStore.saveChannel(localPublic) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupPublic = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: 0, + name: "Public", + lastMessageDate: Date(timeIntervalSince1970: 1_700_001_100) + ) + var msg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + text: "Public broadcast", + timestamp: 1_700_001_100, + direction: .incoming, + senderNodeName: "Mara" + ) + msg.deduplicationKey = "ch-0-1700001100-Mara-FEEDFACE" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupPublic], + messages: [msg] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + #expect(result.messagesInserted == 1) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + #expect(channels.count == 1) + #expect(channels.first?.index == 0) + + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.count == 1) + #expect(messages.first?.channelIndex == 0) + } + + /// Dedup-key preservation: re-importing the same relocated backup must skip the + /// channel message on the second pass, proving the dedup key was rewritten to the + /// relocated index consistently across both runs. + @Test + func `Channel reconcile: re-importing a relocated channel skips the message the second time`() async throws { + let radioID = UUID() + let secretBrno = Data(repeating: 0xB2, count: 16) + let secretPraha = Data(repeating: 0xA2, count: 16) + let collisionIndex: UInt8 = 3 + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: collisionIndex, name: "#brno", secret: secretBrno) + ) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupPraha = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: collisionIndex, + name: "#praha", + secret: secretPraha + ) + var prahaMessage = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: collisionIndex, + text: "Praha checkpoint", + timestamp: 1_700_001_200, + direction: .incoming, + senderNodeName: "Jan" + ) + prahaMessage.deduplicationKey = "ch-\(collisionIndex)-1700001200-Jan-12345678" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupPraha], + messages: [prahaMessage] + ) + + let service = AppBackupService() + let firstResult = try await service.importBackup(envelope: envelope, into: destStore) + #expect(firstResult.messagesInserted == 1) + + let secondResult = try await service.importBackup(envelope: envelope, into: destStore) + #expect(secondResult.messagesInserted == 0) + #expect(secondResult.messagesSkipped == 1) + + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.count == 1) + } + + /// Safety case: every local slot within the radio's channel capacity is occupied by a + /// different secret, so the backup channel has nowhere to land. It must be dropped (not + /// mis-associated), and its messages must be dropped with it rather than attaching to a + /// foreign channel. + @Test + func `Channel reconcile: backup channel with no free slot is dropped along with its messages`() async throws { + let radioID = UUID() + let maxChannels: UInt8 = 2 + let secretPraha = Data(repeating: 0xA4, count: 16) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // Fill every slot in [0, maxChannels) with distinct-secret local channels. + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 0, name: "#a", secret: Data(repeating: 0x10, count: 16)) + ) + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#b", secret: Data(repeating: 0x11, count: 16)) + ) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = maxChannels } + let backupPraha = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: 1, + name: "#praha", + secret: secretPraha + ) + var prahaMessage = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 1, + text: "Should not survive", + timestamp: 1_700_001_300, + direction: .incoming, + senderNodeName: "Jan" + ) + prahaMessage.deduplicationKey = "ch-1-1700001300-Jan-AABBCCDD" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupPraha], + messages: [prahaMessage] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + // #praha is dropped: not inserted, counted as dropped; its message is not inserted. + #expect(result.channelsInserted == 0) + #expect(result.channelsDropped == 1) + #expect(result.messagesInserted == 0) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + #expect(channels.count == 2) + #expect(channels.contains { $0.secret == secretPraha } == false) + + let messages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(messages.isEmpty) + } + + @Test + func `Dropped channels and their messages are accounted as dropped, not already-here`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + + // Local radio is full to capacity (maxChannels = 2: slot 0 public + slot 1 used). + let existing = ChannelDTO( + id: UUID(), radioID: radioID, index: 1, name: "Local", + secret: Data(repeating: 0x11, count: 32), isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + try await store.batchInsertChannels([existing], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 2]) + + // Backup channel at slot 1 with a different secret has no free slot -> dropped. + let backupChannel = ChannelDTO( + id: UUID(), radioID: radioID, index: 1, name: "Backup", + secret: Data(repeating: 0x22, count: 32), isEnabled: true, lastMessageDate: nil, + unreadCount: 0, unreadMentionCount: 0, notificationLevel: .all, isFavorite: false, floodScope: .inherit + ) + let channelResult = try await store.batchInsertChannels( + [backupChannel], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 2] + ) + #expect(channelResult.dropped == 1) + #expect(channelResult.skipped == 0) + #expect(channelResult.droppedChannelIndices[radioID]?.contains(1) == true) + } + + @Test + func `Full import accounts a no-slot channel's messages as dropped and the manifest balances`() async throws { + let radioID = UUID() + let maxChannels: UInt8 = 2 + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // Fill every slot in [0, maxChannels) with distinct-secret local channels. + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 0, name: "#a", secret: Data(repeating: 0x10, count: 16)) + ) + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#b", secret: Data(repeating: 0x11, count: 16)) + ) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = maxChannels } + // One backup channel that merges into an existing slot (slot 0, same empty-ish handling + // avoided by using a distinct secret at slot 0) and one that has no free slot -> dropped. + let droppedChannel = ChannelDTO.testChannel( + id: UUID(), radioID: radioID, index: 1, name: "#dropped", secret: Data(repeating: 0xA4, count: 16) + ) + var droppedMessage1 = MessageDTO.testChannelMessage( + radioID: radioID, channelIndex: 1, text: "lost one", + timestamp: 1_700_002_000, direction: .incoming, senderNodeName: "Jan" + ) + droppedMessage1.deduplicationKey = "ch-1-1700002000-Jan-AABBCCDD" + var droppedMessage2 = MessageDTO.testChannelMessage( + radioID: radioID, channelIndex: 1, text: "lost two", + timestamp: 1_700_002_100, direction: .incoming, senderNodeName: "Eva" + ) + droppedMessage2.deduplicationKey = "ch-1-1700002100-Eva-AABBCCDE" + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [droppedChannel], + messages: [droppedMessage1, droppedMessage2] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.counts[.channels]?.dropped == 1) + #expect(result.counts[.messages]?.dropped == 2) + + // Message balance invariant: every declared message is inserted, merged, skipped, or dropped. + let m = try #require(result.counts[.messages]) + #expect(envelope.manifest.messageCount == m.inserted + m.merged + m.skipped + m.dropped) + } + + /// Slot 0 is reserved for the public channel, so a relocating non-public channel skips it + /// even when free. Local `#brno` occupies slot 1; slots 0 and 2 are free. The colliding + /// backup `#praha` must relocate to slot 2, never slot 0. + @Test + func `Channel reconcile: relocation reserves slot 0 for the public channel`() async throws { + let radioID = UUID() + let secretBrno = Data(repeating: 0xB6, count: 16) + let secretPraha = Data(repeating: 0xA6, count: 16) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // Local channel occupies slot 1; slot 0 is intentionally left free (no public channel). + try await destStore.saveChannel( + ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#brno", secret: secretBrno) + ) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = 3 } + let backupPraha = ChannelDTO.testChannel( + id: UUID(), + radioID: radioID, + index: 1, + name: "#praha", + secret: secretPraha + ) + + let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupPraha]) + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 1) + + let channels = try await destStore.fetchAllChannels(radioID: radioID) + let praha = try #require(channels.first { $0.secret == secretPraha }) + // Lowest free slot at or above 1 — slot 0 is skipped even though it is free. + #expect(praha.index == 2) + } + + // MARK: - Test 13: Fresh-store import — remote sessions start disconnected + + @Test + func `Import inserts remote sessions as disconnected while preserving backup metadata`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xF3, count: 32) + let importedDate = Date(timeIntervalSince1970: 1_700_000_200) + + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupSession = RemoteNodeSessionDTO.testSession( + radioID: radioID, + publicKey: publicKey, + name: "Ops Room", + role: .roomServer, + isConnected: true, + permissionLevel: .admin, + lastConnectedDate: importedDate, + unreadCount: 5, + notificationLevel: .mentionsOnly, + isFavorite: true, + neighborCount: 4, + lastSyncTimestamp: 88, + lastMessageDate: importedDate + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + remoteNodeSessions: [backupSession] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let service = AppBackupService() + + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.remoteNodeSessionsInserted == 1) + #expect(result.remoteNodeSessionsSkipped == 0) + + let importedSession = try #require(await destStore.fetchRemoteNodeSession(id: backupSession.id)) + #expect(importedSession.isConnected == false) + #expect(importedSession.permissionLevel == .admin) + #expect(importedSession.lastConnectedDate == importedDate) + #expect(importedSession.unreadCount == 5) + #expect(importedSession.notificationLevel == .mentionsOnly) + #expect(importedSession.isFavorite == true) + #expect(importedSession.lastSyncTimestamp == 88) + #expect(importedSession.lastMessageDate == importedDate) + } + + // MARK: - Test 14: Room-message delivery metadata survives import + + @Test + func `Import preserves room-message delivery metadata`() async throws { + let radioID = UUID() + let createdAt = Date(timeIntervalSince1970: 1_700_000_275.25) + let session = RemoteNodeSessionDTO.testSession(radioID: radioID) + let roomMessage = RoomMessageDTO( + id: UUID(), + sessionID: session.id, + authorKeyPrefix: Data([0xCA, 0xFE, 0xBA, 0xBE]), + authorName: "Ops", + text: "Retry me", + timestamp: 1_700_000_275, + createdAt: createdAt, + isFromSelf: true, + status: .failed, + ackCode: 0xDEAD_BEEF, + roundTripTime: 1450, + retryAttempt: 3, + maxRetryAttempts: 7 + ) + + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + roomMessages: [roomMessage], + remoteNodeSessions: [session] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let service = AppBackupService() + + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.roomMessagesInserted == 1) + + let importedMessage = try #require(await destStore.fetchRoomMessage(id: roomMessage.id)) + #expect(importedMessage.createdAt == createdAt) + #expect(importedMessage.status == .failed) + #expect(importedMessage.ackCode == 0xDEAD_BEEF) + #expect(importedMessage.roundTripTime == 1450) + #expect(importedMessage.retryAttempt == 3) + #expect(importedMessage.maxRetryAttempts == 7) + } + + // MARK: - Test 15: Merge import — remote session metadata restored + + @Test + func `Import onto existing remote session restores backup metadata without clobbering live state`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xF4, count: 32) + let localConnectedDate = Date(timeIntervalSince1970: 1_700_000_250) + let importedDate = Date(timeIntervalSince1970: 1_700_000_300) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingSession = RemoteNodeSessionDTO.testSession( + radioID: radioID, + publicKey: publicKey, + name: "Ops Room", + role: .roomServer, + isConnected: true, + permissionLevel: .admin, + lastConnectedDate: localConnectedDate, + unreadCount: 0, + notificationLevel: .all, + isFavorite: false, + neighborCount: 2, + lastSyncTimestamp: 123, + lastMessageDate: nil + ) + try await destStore.saveRemoteNodeSessionDTO(existingSession) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupSession = RemoteNodeSessionDTO.testSession( + id: UUID(), + radioID: radioID, + publicKey: publicKey, + name: "Ops Room", + role: .roomServer, + isConnected: false, + permissionLevel: .guest, + lastConnectedDate: Date(timeIntervalSince1970: 1_700_000_225), + unreadCount: 9, + notificationLevel: .mentionsOnly, + isFavorite: true, + neighborCount: 7, + lastSyncTimestamp: 8, + lastMessageDate: importedDate + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + remoteNodeSessions: [backupSession] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.remoteNodeSessionsInserted == 0) + #expect(result.remoteNodeSessionsSkipped == 1) + #expect(result.remoteNodeSessionsMerged == 1) + #expect(result.hasRestoredChanges) + + let mergedSession = try #require(await destStore.fetchRemoteNodeSession(id: existingSession.id)) + #expect(mergedSession.isConnected == true) + #expect(mergedSession.permissionLevel == .admin) + #expect(mergedSession.lastConnectedDate == localConnectedDate) + #expect(mergedSession.unreadCount == 9) + #expect(mergedSession.notificationLevel == .mentionsOnly) + #expect(mergedSession.isFavorite == true) + #expect(mergedSession.lastSyncTimestamp == 123) + #expect(mergedSession.lastMessageDate == importedDate) + } + + // MARK: - Test 16: Merge import — same-second node snapshots preserved + + @Test + func `Import preserves distinct node snapshots recorded within the same second`() async throws { + let radioID = UUID() + let nodePublicKey = Data(repeating: 0xF5, count: 32) + let baseTimestamp = Date(timeIntervalSince1970: 1_700_000_400) + let existingTimestamp = baseTimestamp.addingTimeInterval(0.100) + let importedTimestamp = baseTimestamp.addingTimeInterval(0.900) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + _ = try await destStore.saveNodeStatusSnapshot( + timestamp: existingTimestamp, + nodePublicKey: nodePublicKey, + batteryMillivolts: 3800, + lastSNR: 8.5, + lastRSSI: -90, + noiseFloor: -112, + uptimeSeconds: 60, + rxAirtimeSeconds: nil, + packetsSent: nil, + packetsReceived: nil, + receiveErrors: nil + ) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let existingSnapshot = NodeStatusSnapshotDTO.testSnapshot( + timestamp: existingTimestamp, + nodePublicKey: nodePublicKey, + batteryMillivolts: 3800, + lastSNR: 8.5, + lastRSSI: -90, + noiseFloor: -112, + uptimeSeconds: 60 + ) + let importedSnapshot = NodeStatusSnapshotDTO.testSnapshot( + timestamp: importedTimestamp, + nodePublicKey: nodePublicKey, + batteryMillivolts: nil, + lastSNR: nil, + lastRSSI: nil, + noiseFloor: nil, + uptimeSeconds: nil, + telemetryEntries: [TelemetrySnapshotEntry(channel: 1, type: "temperature", value: 21.5)] + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + nodeStatusSnapshots: [existingSnapshot, importedSnapshot] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.nodeStatusSnapshotsInserted == 1) + #expect(result.nodeStatusSnapshotsSkipped == 1) + + let snapshots = try await destStore.fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) + #expect(snapshots.count == 2) + #expect(snapshots.map(\.timestamp) == [existingTimestamp, importedTimestamp]) + #expect(snapshots.last?.telemetryEntries?.count == 1) + } + + // MARK: - Test 17: Failed import cleanup + + @Test + func `Successful import restores autosave on the destination store`() async throws { + let radioID = UUID() + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + await destStore.setAutosaveEnabledForTesting(true) + + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)] + ) + + let service = AppBackupService() + let result = try await service.importBackup( + envelope: envelope, + into: destStore + ) + + #expect(result.devicesInserted == 1) + #expect(await destStore.autosaveEnabledForTesting()) + #expect(await !(destStore.hasPendingChangesForTesting())) + #expect(try await destStore.fetchAllDevices().count == 1) + } + + @Test + func `Failed import rolls back reconcile-phase mutations to pre-existing rows`() async throws { + let radioID = UUID() + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + await destStore.setAutosaveEnabledForTesting(true) + + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xC2, count: 32), + name: "Pre-existing" + ) + try await destStore.saveContact(contact) + // Pre-existing contacts always have lastMessageDate = nil out of the gate. + let baselineContacts = try await destStore.fetchAllContacts(radioID: radioID) + let preImportLastMessageDate = baselineContacts.first?.lastMessageDate + + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupMessage = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Reconcile target", + timestamp: 99999 + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [backupMessage] + ) + + await destStore.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } + + await #expect(throws: InjectedImportFailure.simulated) { + try await destStore.importBackupDatabase(envelope) + } + + let postImportContacts = try await destStore.fetchAllContacts(radioID: radioID) + #expect(postImportContacts.count == 1) + #expect(postImportContacts.first?.lastMessageDate == preImportLastMessageDate) + #expect(try await destStore.fetchAllMessages().isEmpty) + #expect(await !(destStore.hasPendingChangesForTesting())) + } + + @Test + func `Failed import clears pending data and restores autosave`() async throws { + let radioID = UUID() + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + await destStore.setAutosaveEnabledForTesting(true) + + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)] + ) + + await destStore.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } + + await #expect(throws: InjectedImportFailure.simulated) { + try await destStore.importBackupDatabase(envelope) + } + + #expect(await destStore.autosaveEnabledForTesting()) + #expect(await !(destStore.hasPendingChangesForTesting())) + #expect(try await destStore.fetchAllDevices().isEmpty) + + try await destStore.saveContact( + ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xC1, count: 32), + name: "Recovered Contact" + ) + ) + + let contacts = try await destStore.fetchAllContacts(radioID: radioID) + #expect(contacts.count == 1) + #expect(contacts.first?.name == "Recovered Contact") + } + + @Test + func `Disk-backed container has zero partial state after faulted import is abandoned`() async throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("backup-crash-\(UUID().uuidString).store") + defer { + let fm = FileManager.default + try? fm.removeItem(at: storeURL) + try? fm.removeItem(at: storeURL.appendingPathExtension("shm")) + try? fm.removeItem(at: storeURL.appendingPathExtension("wal")) + } + + let radioID = UUID() + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [ + ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0x42, count: 32), + name: "Should not survive" + ) + ] + ) + + // Throw before save() to exercise the error path. With autosaveEnabled = false + // and save() never reached, nothing was flushed to SQLite — the defer's + // rollback() just clears the in-memory context. This is the error path, not a + // crash path: Swift's defer runs on throw, so a true bypassed-defer scenario + // would require a child process that exits mid-import. + do { + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let container = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let store = PersistenceStore(modelContainer: container) + await store.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } + await #expect(throws: InjectedImportFailure.simulated) { + try await store.importBackupDatabase(envelope) + } + } + + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let reopened = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let freshStore = PersistenceStore(modelContainer: reopened) + + #expect(try await freshStore.fetchAllDevices().isEmpty) + #expect(try await freshStore.fetchAllContacts(radioID: radioID).isEmpty) + } + + @Test + func `Concurrent live-store writer during import preserves both datasets`() async throws { + // Two @ModelActor instances on the same ModelContainer simulate a radio + // connecting mid-import: the backup flow resolved a standalone + // PersistenceStore at T=0, then ConnectionManager stood up a second + // PersistenceStore on the same container to service the live link. + let sharedContainer = try PersistenceStore.createContainer(inMemory: true) + let backupStore = PersistenceStore(modelContainer: sharedContainer) + let liveStore = PersistenceStore(modelContainer: sharedContainer) + + let backupRadioID = UUID() + let liveRadioID = UUID() + let backupDevicePublicKey = Data(repeating: 0xB0, count: 32) + let liveDevicePublicKey = Data(repeating: 0xC0, count: 32) + + let backupContact = ContactDTO.testContact( + radioID: backupRadioID, + publicKey: Data(repeating: 0xB1, count: 32), + name: "From backup" + ) + let envelope = AppBackupEnvelope.test( + devices: [ + DeviceDTO.testDevice( + id: backupRadioID, + radioID: backupRadioID, + publicKey: backupDevicePublicKey + ) + ], + contacts: [backupContact] + ) + + try await liveStore.saveDevice( + DeviceDTO.testDevice( + id: liveRadioID, + radioID: liveRadioID, + publicKey: liveDevicePublicKey + ) + ) + let liveContact = ContactDTO.testContact( + radioID: liveRadioID, + publicKey: Data(repeating: 0xC1, count: 32), + name: "From connect" + ) + + async let importResult: ImportResult = backupStore.importBackupDatabase(envelope) + async let liveWrite: Void = liveStore.saveContact(liveContact) + + _ = try await importResult + try await liveWrite + + // A third actor guarantees we read through the persistent store rather than + // either writer's context cache — `fetchAllContacts` on the writers can miss + // the other actor's commits until the cache invalidates. + let verifier = PersistenceStore(modelContainer: sharedContainer) + let liveContacts = try await verifier.fetchAllContacts(radioID: liveRadioID) + #expect(liveContacts.contains { $0.publicKey == liveContact.publicKey }) + let backupContacts = try await verifier.fetchAllContacts(radioID: backupRadioID) + #expect(backupContacts.contains { $0.publicKey == backupContact.publicKey }) + + let allDevices = try await verifier.fetchAllDevices() + #expect(allDevices.count == 2) + } + + // MARK: - Test 18: Export assigns content-based keys to nil-keyed messages + + @Test + func `Export assigns content-based dedup keys to incoming messages with nil deduplicationKey`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xF6, count: 32), + name: "Dan" + ) + try await sourceStore.saveContact(contact) + + // Save an incoming DM with nil deduplicationKey (simulates pre-migration message) + let dm = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Pre-migration DM", + timestamp: 12345, + direction: .incoming + ) + try await sourceStore.saveMessage(dm) + + // Save an incoming channel message with nil deduplicationKey + let chMsg = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 2, + text: "Pre-migration channel msg", + timestamp: 67890, + direction: .incoming, + senderNodeName: "Node1" + ) + try await sourceStore.saveMessage(chMsg) + + // Export + let service = AppBackupService() + let result = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: result.data) + + // Verify exported messages have content-based keys, not backup- + let exportedDM = try #require(envelope.messages.first { $0.contactID == contact.id }) + #expect(exportedDM.deduplicationKey?.hasPrefix("dm-") == true) + #expect(exportedDM.deduplicationKey?.hasPrefix("backup-") != true) + + let exportedCh = try #require(envelope.messages.first { $0.channelIndex == 2 }) + #expect(exportedCh.deduplicationKey?.hasPrefix("ch-") == true) + #expect(exportedCh.deduplicationKey?.hasPrefix("backup-") != true) + } + + // MARK: - Test 19: Export preserves existing content-based keys + + @Test + func `Export preserves existing content-based dedup keys unchanged`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact(radioID: radioID) + try await sourceStore.saveContact(contact) + + let existingKey = "dm-\(contact.id.uuidString)-99999-AABBCCDD" + var msg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Already has key" + ) + msg.deduplicationKey = existingKey + try await sourceStore.saveMessage(msg) + + let service = AppBackupService() + let result = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: result.data) + + let exported = try #require(envelope.messages.first) + #expect(exported.deduplicationKey == existingKey) + } + + // MARK: - Test 23: Duplicates within a single backup don't orphan their children + + /// Two incoming messages in the same envelope that share a content key — the + /// second is skipped (same wire packet seen twice), and its repeats/reactions + /// must be remapped to the first (winning) UUID. + @Test + func `Duplicate messages within one envelope: children of the skipped duplicate link to the inserted message`() async throws { + let radioID = UUID() + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + + var firstMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Duplicate in envelope", + timestamp: 1_700_000_500, + direction: .incoming + ) + firstMsg.deduplicationKey = nil + + var secondMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Duplicate in envelope", + timestamp: 1_700_000_500, + direction: .incoming + ) + secondMsg.deduplicationKey = nil + #expect(firstMsg.id != secondMsg.id) + + // Children reference the second (to-be-skipped) message's UUID. + let repeatForSecond = MessageRepeatDTO.testRepeat( + messageID: secondMsg.id, + pathNodes: Data([0x11]) + ) + let reactionForSecond = ReactionDTO.testReaction( + messageID: secondMsg.id, + radioID: radioID, + emoji: "🌶️", + senderName: "Dup" + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [firstMsg, secondMsg], + messageRepeats: [repeatForSecond], + reactions: [reactionForSecond] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.messagesInserted == 1) + #expect(result.messagesSkipped == 1) + #expect(result.messageRepeatsInserted == 1) + #expect(result.reactionsInserted == 1) + + // Children must attach to the first (winning) UUID; the second UUID must have no rows. + let repeatsUnderFirst = try await destStore.fetchMessageRepeats(messageID: firstMsg.id) + #expect(repeatsUnderFirst.count == 1) + let orphanedRepeats = try await destStore.fetchMessageRepeats(messageID: secondMsg.id) + #expect(orphanedRepeats.isEmpty) + + let winner = try #require(await destStore.fetchMessage(id: firstMsg.id)) + #expect(winner.heardRepeats == 1) + #expect(winner.reactionSummary == "🌶️:1") + } + + // MARK: - Test 24: Cancellation after DB commit reports success, not cancelled + + /// A task cancelled between the DB commit and the rest of `importBackup` + /// must not throw CancellationError. The DB write has already landed, so + /// a throw here would report cancellation while the database actually + /// persisted. + @Test + func `Task cancellation after DB commit does not throw and returns a successful result`() async throws { + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice()] + ) + + // Post-commit hook cancels the task that's running the import (the + // child Task below), mirroring the race where the user taps Cancel + // mid-save. Running the import in a child Task keeps the outer test's + // cancellation state clean so post-import fetches can run. + await destStore.setBackupImportPostCommitHook { + withUnsafeCurrentTask { $0?.cancel() } + } + + let service = AppBackupService() + let importTask = Task { + try await service.importBackup(envelope: envelope, into: destStore) + } + + let result: ImportResult + do { + result = try await importTask.value + } catch { + Issue.record("Import after post-commit cancellation should succeed, got: \(error)") + return + } + + #expect(result.devicesInserted == 1) + #expect(try await destStore.fetchAllDevices().count == 1) + } + + // MARK: - Test 25: userDefaultsRestored reflects actual writes + + /// When every UserDefaults key carried in the backup is already set + /// locally, `restore(to:)` writes nothing. The import result must then + /// report `userDefaultsRestored == false`, otherwise a second no-op + /// import would claim `hasRestoredChanges` with nothing actually changed. + @Test + func `Import reports userDefaultsRestored=false when no new keys were written`() async throws { + let key = "hasCompletedOnboarding" + let defaults = UserDefaults.standard + let originalValue = defaults.object(forKey: key) + defaults.set(true, forKey: key) + defer { + if let value = originalValue { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + var backupDefaults = BackupUserDefaults() + backupDefaults.hasCompletedOnboarding = true + + let envelope = AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice()], + userDefaults: backupDefaults + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(!result.userDefaultsRestored) + } + + // MARK: - Test 26: Fresh-store import — contact unread counts preserved + + @Test + func `Fresh-insert import preserves contact unread counts from backup`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xE5, count: 32) + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupContact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + unreadCount: 7, + unreadMentionCount: 2 + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [backupContact] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let service = AppBackupService() + + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.contactsInserted == 1) + #expect(result.contactsSkipped == 0) + + let importedContact = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect(importedContact.unreadCount == 7) + #expect(importedContact.unreadMentionCount == 2) + } + + // MARK: - Test 27: Fresh-store import — channel unread counts preserved + + @Test + func `Fresh-insert import preserves channel unread counts from backup`() async throws { + let radioID = UUID() + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 4, + unreadCount: 3, + unreadMentionCount: 1 + ) + + let envelope = AppBackupEnvelope.test( + devices: [device], + channels: [backupChannel] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let service = AppBackupService() + + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 1) + #expect(result.channelsSkipped == 0) + + let importedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 4)) + #expect(importedChannel.unreadCount == 3) + #expect(importedChannel.unreadMentionCount == 1) + } + + // MARK: - Test 28: Merge import — contact unread counts max with local + + @Test + func `Merge import keeps local contact unread counts when they exceed backup values`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xE6, count: 32) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingContact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + unreadCount: 9, + unreadMentionCount: 4 + ) + try await destStore.saveContact(existingContact) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupContact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + unreadCount: 2, + unreadMentionCount: 1 + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + contacts: [backupContact] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.contactsInserted == 0) + #expect(result.contactsSkipped == 1) + + let mergedContact = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect(mergedContact.unreadCount == 9) + #expect(mergedContact.unreadMentionCount == 4) + } + + // MARK: - Test 29: Merge import — channel unread counts max with local + + @Test + func `Merge import keeps local channel unread counts when they exceed backup values`() async throws { + let radioID = UUID() + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 5, + unreadCount: 6, + unreadMentionCount: 3 + ) + try await destStore.saveChannel(existingChannel) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupChannel = ChannelDTO.testChannel( + radioID: radioID, + index: 5, + unreadCount: 1, + unreadMentionCount: 0 + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + channels: [backupChannel] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.channelsInserted == 0) + #expect(result.channelsSkipped == 1) + + let mergedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 5)) + #expect(mergedChannel.unreadCount == 6) + #expect(mergedChannel.unreadMentionCount == 3) + } + + // MARK: - Test 30: Merge import — remote session unread count max with local + + @Test + func `Merge import keeps local remote-session unread count when it exceeds backup value`() async throws { + let radioID = UUID() + let publicKey = Data(repeating: 0xE7, count: 32) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let existingSession = RemoteNodeSessionDTO.testSession( + radioID: radioID, + publicKey: publicKey, + unreadCount: 8 + ) + try await destStore.saveRemoteNodeSessionDTO(existingSession) + + let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + let backupSession = RemoteNodeSessionDTO.testSession( + id: UUID(), + radioID: radioID, + publicKey: publicKey, + unreadCount: 2 + ) + + let envelope = AppBackupEnvelope.test( + devices: [backupDevice], + remoteNodeSessions: [backupSession] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.remoteNodeSessionsInserted == 0) + #expect(result.remoteNodeSessionsSkipped == 1) + + let mergedSession = try #require(await destStore.fetchRemoteNodeSession(id: existingSession.id)) + #expect(mergedSession.unreadCount == 8) + } + + // MARK: - Outgoing duplicates must not collapse + + /// A user who double-taps send (or retries within the same UInt32 second) ends up + /// with two outgoing rows that share recipient + text + timestamp. They are two + /// distinct intentional actions, and backup/restore must keep both. + @Test + func `Outgoing messages with identical recipient/text/timestamp survive round-trip without deduplication`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + try await sourceStore.saveContact(contact) + + let sharedTimestamp: UInt32 = 1_700_001_234 + + let firstMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "ok", + timestamp: sharedTimestamp, + direction: .outgoing + ) + let secondMsg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "ok", + timestamp: sharedTimestamp, + direction: .outgoing + ) + #expect(firstMsg.id != secondMsg.id) + try await sourceStore.saveMessage(firstMsg) + try await sourceStore.saveMessage(secondMsg) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.messagesInserted == 2) + #expect(result.messagesSkipped == 0) + + let destMessages = try await destStore.fetchAllMessages(radioID: radioID) + #expect(destMessages.count == 2) + #expect(Set(destMessages.map(\.id)) == Set([firstMsg.id, secondMsg.id])) + } + + // MARK: - Repeat rows through the same route must not collapse + + /// `MessageRepeat` distinguishes hearings by `id`/`rxLogEntryID`. Two observations + /// of the same message through the same path (e.g. a flood echo) are genuinely + /// distinct and must both survive round-trip so `heardRepeats` stays accurate. + @Test + func `Repeats with identical path but distinct ids survive round-trip and heardRepeats is recomputed`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + try await sourceStore.saveContact(contact) + + var msg = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "echoed", + direction: .outgoing + ) + msg.deduplicationKey = nil + try await sourceStore.saveMessage(msg) + + let sharedPath = Data([0x42]) + let firstRepeat = MessageRepeatDTO.testRepeat( + messageID: msg.id, + pathNodes: sharedPath, + rxLogEntryID: UUID() + ) + let secondRepeat = MessageRepeatDTO.testRepeat( + messageID: msg.id, + pathNodes: sharedPath, + rxLogEntryID: UUID() + ) + #expect(firstRepeat.id != secondRepeat.id) + try await sourceStore.saveMessageRepeat(firstRepeat) + try await sourceStore.saveMessageRepeat(secondRepeat) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.messageRepeatsInserted == 2) + #expect(result.messageRepeatsSkipped == 0) + + let restoredRepeats = try await destStore.fetchMessageRepeats(messageID: msg.id) + #expect(restoredRepeats.count == 2) + #expect(Set(restoredRepeats.map(\.id)) == Set([firstRepeat.id, secondRepeat.id])) + + let restoredMsg = try #require(await destStore.fetchMessage(id: msg.id)) + #expect(restoredMsg.heardRepeats == 2) + } + + // MARK: - Reply chain remap + + /// When a backup's replied-to parent already exists locally (content-keyed merge), + /// the reply's replyToID must be rewritten onto the local parent UUID — otherwise + /// reply navigation dangles on the pre-merge backup UUID. + @Test + func `Reply remaps onto local parent when parent is merged during import`() async throws { + let radioID = UUID() + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(radioID: radioID) + try await destStore.saveContact(contact) + + var existingParent = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Parent", + direction: .incoming + ) + existingParent.deduplicationKey = "reply-remap-parent" + try await destStore.saveMessage(existingParent) + + // Backup contains the same parent (will be skipped/merged) and a reply + // whose replyToID points at the backup-side parent UUID. + let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) + var backupParent = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Parent", + direction: .incoming + ) + backupParent.deduplicationKey = "reply-remap-parent" + #expect(backupParent.id != existingParent.id) + + var reply = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contact.id, + text: "Reply", + direction: .incoming, + replyToID: backupParent.id + ) + reply.deduplicationKey = "reply-remap-reply" + + let envelope = AppBackupEnvelope.test( + devices: [device], + contacts: [contact], + messages: [backupParent, reply] + ) + + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + #expect(result.messagesSkipped == 1) + #expect(result.messagesInserted == 1) + + let destMessages = try await destStore.fetchAllMessages(radioID: radioID) + let restoredReply = try #require(destMessages.first { $0.id == reply.id }) + #expect(restoredReply.replyToID == existingParent.id) + } + + // MARK: - Cross-radio channel message preservation + + /// Two companion radios often receive the same over-the-air channel packet. The stored + /// live-sync deduplicationKey is radio-agnostic by construction, so the backup-reconciliation + /// key must be scoped by radioID. Otherwise the import path collapses both rows into one + /// and the second companion's channel view renders blank after restore — the same failure + /// mode fixed in live sync (issue #288) leaking back through backup/restore. + @Test + func `Backup restores the same channel packet under each companion radio that received it`() async throws { + let radioA = UUID() + let radioB = UUID() + let deviceA = DeviceDTO.testDevice( + id: radioA, + radioID: radioA, + publicKey: Data(repeating: 0xAA, count: 32), + nodeName: "CompanionA" + ) + let deviceB = DeviceDTO.testDevice( + id: radioB, + radioID: radioB, + publicKey: Data(repeating: 0xBB, count: 32), + nodeName: "CompanionB" + ) + let channelA = ChannelDTO.testChannel(radioID: radioA, index: 0, name: "General") + let channelB = ChannelDTO.testChannel(radioID: radioB, index: 0, name: "General") + + // Same wire packet — identical content-based dedup key — seen by both companions. + let sharedDedupKey = "ch-0-1700000000-Alice-A1B2C3D4" + var msgFromA = MessageDTO.testChannelMessage( + radioID: radioA, + channelIndex: 0, + text: "aaaaa", + timestamp: 1_700_000_000, + direction: .incoming, + status: .delivered, + senderNodeName: "Alice" + ) + msgFromA.deduplicationKey = sharedDedupKey + + var msgFromB = MessageDTO.testChannelMessage( + radioID: radioB, + channelIndex: 0, + text: "aaaaa", + timestamp: 1_700_000_000, + direction: .incoming, + status: .delivered, + senderNodeName: "Alice" + ) + msgFromB.deduplicationKey = sharedDedupKey + + let envelope = AppBackupEnvelope.test( + devices: [deviceA, deviceB], + channels: [channelA, channelB], + messages: [msgFromA, msgFromB] + ) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + let service = AppBackupService() + let result = try await service.importBackup(envelope: envelope, into: destStore) + + #expect(result.messagesInserted == 2, + "Both companions' copies of the same channel packet must be restored — otherwise the second companion's channel view goes blank post-restore") + #expect(result.messagesSkipped == 0) + + let messagesForA = try await destStore.fetchAllMessages(radioID: radioA) + let messagesForB = try await destStore.fetchAllMessages(radioID: radioB) + #expect(messagesForA.count == 1) + #expect(messagesForB.count == 1) + #expect(messagesForA.first?.text == "aaaaa") + #expect(messagesForB.first?.text == "aaaaa") + } + + // MARK: - regionSelection backup contract + + @Test + func `regionSelection round-trips through encode/decode`() throws { + var prefs = BackupUserDefaults() + prefs.regionSelection = RegionSelection( + countryCode: "US", + administrativeAreaCode: "US-CA", + countyKey: "los angeles", + source: .location + ) + let data = try JSONEncoder().encode(prefs) + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + #expect(decoded.regionSelection == prefs.regionSelection) + } + + @Test + func `Legacy envelope without regionSelection decodes as nil`() throws { + let legacyJSON = """ + { + "hasCompletedOnboarding": true, + "mapStyleSelection": "topo" + } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) + #expect(decoded.regionSelection == nil) + #expect(decoded.hasCompletedOnboarding == true) + } + + @Test + func `restore writes regionSelection only when local key is missing`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + let local = RegionSelection(countryCode: "DE", source: .manual) + try defaults.set(JSONEncoder().encode(local), forKey: BackupUserDefaults.regionSelectionKey) + + var prefs = BackupUserDefaults() + prefs.regionSelection = RegionSelection(countryCode: "US", source: .location) + let setKeys = prefs.restore(to: defaults) + #expect(!setKeys.contains(BackupUserDefaults.regionSelectionKey)) + + let stillThere = try JSONDecoder().decode( + RegionSelection.self, + from: #require(defaults.data(forKey: BackupUserDefaults.regionSelectionKey)) + ) + #expect(stillThere == local) + } + + @Test + func `restore writes regionSelection when local is missing (fresh install)`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + var prefs = BackupUserDefaults() + prefs.regionSelection = RegionSelection(countryCode: "US", source: .location) + let setKeys = prefs.restore(to: defaults) + + #expect(setKeys.contains(BackupUserDefaults.regionSelectionKey)) + let restored = try JSONDecoder().decode( + RegionSelection.self, + from: #require(defaults.data(forKey: BackupUserDefaults.regionSelectionKey)) + ) + #expect(restored == prefs.regionSelection) + } + + // MARK: - Message.regionScope round-trip + + @Test + func `Message.regionScope round-trips through full export/import`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xAB, count: 32), + name: "Alice" + ) + try await sourceStore.saveContact(contact) + + var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id, text: "Greetings") + msg.regionScope = "Germany" + msg.deduplicationKey = "region-scope-roundtrip-\(UUID())" + try await sourceStore.saveMessage(msg) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + _ = try await service.importBackup(envelope: envelope, into: destStore) + + let restored = try await destStore.fetchAllMessages(radioID: radioID) + #expect(restored.count == 1) + #expect(restored.first?.regionScope == "Germany") + } + + @Test + func `MessageDTO Codable: regionScope set decodes round-trip`() throws { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Test") + dto.regionScope = "Bavaria" + + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) + #expect(decoded.regionScope == "Bavaria") + } + + @Test + func `Legacy MessageDTO envelope without regionScope decodes as nil`() throws { + let baseDTO = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Legacy") + let encoded = try JSONEncoder().encode(baseDTO) + var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + json.removeValue(forKey: "regionScope") + + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) + #expect(decoded.regionScope == nil) + } + + @Test + func `Message(dto:) forwards regionScope verbatim through DTO to model`() { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Forward") + dto.regionScope = "USA" + let model = Message(dto: dto) + #expect(model.regionScope == "USA") + + var nilDTO = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "NilCase") + nilDTO.regionScope = nil + let nilModel = Message(dto: nilDTO) + #expect(nilModel.regionScope == nil) + } + + // MARK: - MessageDTO.sortDate round-trip + + @Test + func `MessageDTO Codable: sortDate distinct from createdAt round-trips`() throws { + let createdAt = Date(timeIntervalSince1970: 1_700_000_000) + let sortDate = Date(timeIntervalSince1970: 1_600_000_000) + var dto = MessageDTO.testDirectMessage( + radioID: UUID(), + contactID: UUID(), + text: "Test", + createdAt: createdAt + ) + dto.sortDate = sortDate + + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) + #expect(decoded.sortDate == sortDate) + #expect(decoded.sortDate != decoded.createdAt) + } + + @Test + func `Legacy MessageDTO envelope without sortDate falls back to createdAt`() throws { + let baseDTO = MessageDTO.testDirectMessage( + radioID: UUID(), + contactID: UUID(), + text: "Legacy", + createdAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let encoded = try JSONEncoder().encode(baseDTO) + var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + json.removeValue(forKey: "sortDate") + + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) + #expect(decoded.sortDate == decoded.createdAt) + } + + @Test + func `Message(dto:) forwards sortDate verbatim through DTO to model`() { + let createdAt = Date(timeIntervalSince1970: 1_700_000_000) + let sortDate = Date(timeIntervalSince1970: 1_600_000_000) + var dto = MessageDTO.testDirectMessage( + radioID: UUID(), + contactID: UUID(), + text: "Forward", + createdAt: createdAt + ) + dto.sortDate = sortDate + let model = Message(dto: dto) + #expect(model.sortDate == sortDate) + #expect(model.sortDate != model.createdAt) + } + + @Test + func `Fully-populated MessageDTO survives encode/decode with every field distinct`() throws { + let dto = MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + channelIndex: 7, + text: "Every field set", + timestamp: 1_700_000_001, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + direction: .incoming, + status: .delivered, + textType: .signedPlain, + ackCode: 0xDEAD_BEEF, + pathLength: 5, + snr: 12.5, + pathNodes: Data([0x01, 0x02, 0x03]), + senderKeyPrefix: Data([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), + senderNodeName: "Bob", + isRead: true, + replyToID: UUID(), + roundTripTime: 432, + heardRepeats: 3, + sendCount: 2, + retryAttempt: 1, + maxRetryAttempts: 4, + deduplicationKey: "dedup-key", + linkPreviewURL: "https://example.com", + linkPreviewTitle: "Example", + linkPreviewImageData: Data([0x10, 0x20]), + linkPreviewIconData: Data([0x30, 0x40]), + linkPreviewFetched: true, + containsSelfMention: true, + mentionSeen: true, + timestampCorrected: true, + senderTimestamp: 1_699_999_999, + reactionSummary: "👍:3,❤️:2", + routeType: .tcDirect, + regionScope: "Germany" + ) + var populated = dto + populated.sortDate = Date(timeIntervalSince1970: 1_600_000_000) + + let encoded = try JSONEncoder().encode(populated) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) + #expect(decoded == populated) + } + + @Test + func `saveMessage persists send metadata and preview fields through export and import`() async throws { + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + var msg = MessageDTO.testDirectMessage(radioID: radioID, sendCount: 3) + msg.deduplicationKey = "save-message-fields-\(UUID())" + msg.linkPreviewURL = "https://example.com" + msg.linkPreviewTitle = "Example" + msg.reactionSummary = "👍:2" + try await sourceStore.saveMessage(msg) + + // saveMessage routes through Message(dto:), so the live row keeps every DTO field. + let savedRow = try #require(await sourceStore.fetchMessage(id: msg.id)) + #expect(savedRow.sendCount == 3) + #expect(savedRow.linkPreviewURL == "https://example.com") + #expect(savedRow.linkPreviewTitle == "Example") + #expect(savedRow.reactionSummary == "👍:2") + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destContainer = try PersistenceStore.createContainer(inMemory: true) + let destStore = PersistenceStore(modelContainer: destContainer) + _ = try await service.importBackup(envelope: envelope, into: destStore) + + let imported = try #require(await destStore.fetchMessage(id: msg.id)) + #expect(imported.sendCount == 3) + #expect(imported.linkPreviewURL == "https://example.com") + #expect(imported.linkPreviewTitle == "Example") + #expect(imported.reactionSummary == "👍:2") + } + + // MARK: - selectedThemeID backup contract + + @Test + func `selectedThemeID round-trips through encode/decode`() throws { + var prefs = BackupUserDefaults() + prefs.selectedThemeID = "ember" + let data = try JSONEncoder().encode(prefs) + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + #expect(decoded.selectedThemeID == "ember") + } + + @Test + func `Legacy envelope without selectedThemeID decodes as nil`() throws { + let legacyJSON = """ + { "hasCompletedOnboarding": true, "mapStyleSelection": "topo" } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) + #expect(decoded.selectedThemeID == nil) + #expect(decoded.hasCompletedOnboarding == true) + } + + @Test + func `restore writes selectedThemeID only when local key is missing`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + defaults.set("marine", forKey: PersistenceKeys.selectedThemeID) + var prefs = BackupUserDefaults() + prefs.selectedThemeID = "ember" + let setKeys = prefs.restore(to: defaults) + #expect(!setKeys.contains(PersistenceKeys.selectedThemeID)) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == "marine") + } + + @Test + func `restore writes selectedThemeID when local is missing (fresh install)`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + var prefs = BackupUserDefaults() + prefs.selectedThemeID = "ember" + let setKeys = prefs.restore(to: defaults) + #expect(setKeys.contains(PersistenceKeys.selectedThemeID)) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == "ember") + } + + // MARK: - appColorSchemePreference backup contract + + @Test + func `appColorSchemePreference round-trips through encode/decode`() throws { + var prefs = BackupUserDefaults() + prefs.appColorSchemePreference = "dark" + let data = try JSONEncoder().encode(prefs) + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) + #expect(decoded.appColorSchemePreference == "dark") + } + + @Test + func `Legacy envelope without appColorSchemePreference decodes as nil`() throws { + let legacyJSON = """ + { "hasCompletedOnboarding": true, "mapStyleSelection": "topo" } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) + #expect(decoded.appColorSchemePreference == nil) + } + + @Test + func `restore writes appColorSchemePreference only when local key is missing`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + defaults.set("light", forKey: PersistenceKeys.appColorSchemePreference) + var prefs = BackupUserDefaults() + prefs.appColorSchemePreference = "dark" + let setKeys = prefs.restore(to: defaults) + #expect(!setKeys.contains(PersistenceKeys.appColorSchemePreference)) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "light") + } + + @Test + func `restore writes appColorSchemePreference when local is missing (fresh install)`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + var prefs = BackupUserDefaults() + prefs.appColorSchemePreference = "dark" + let setKeys = prefs.restore(to: defaults) + #expect(setKeys.contains(PersistenceKeys.appColorSchemePreference)) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "dark") + } + + // MARK: - Export read path (snapshot) for the new appearance keys + + @Test + func `snapshot reads selectedThemeID and appColorSchemePreference from UserDefaults`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + defaults.set("marine", forKey: PersistenceKeys.selectedThemeID) + defaults.set("dark", forKey: PersistenceKeys.appColorSchemePreference) + let snapshot = BackupUserDefaults.snapshot(from: defaults) + #expect(snapshot.selectedThemeID == "marine") + #expect(snapshot.appColorSchemePreference == "dark") + } + + @Test + func `snapshot leaves appearance keys nil when unset`() throws { + let defaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + let snapshot = BackupUserDefaults.snapshot(from: defaults) + #expect(snapshot.selectedThemeID == nil) + #expect(snapshot.appColorSchemePreference == nil) + } +} - // MARK: - Test 1: Full round-trip - - /// Exports from a populated store and imports into a fresh store, verifying all data survives. - @Test("Full round-trip: export then import into fresh store restores all data") - func fullRoundTrip() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Seed data - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xAB, count: 32), - name: "Alice" - ) - try await sourceStore.saveContact(contact) - - let channel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") - try await sourceStore.saveChannel(channel) - - var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id, text: "Hello") - msg.deduplicationKey = "integration-dedup-\(UUID())" - try await sourceStore.saveMessage(msg) - - let reaction = ReactionDTO.testReaction(messageID: msg.id, radioID: radioID) - try await sourceStore.saveReaction(reaction) - - // Export - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - - // Parse - let envelope = try parseBackup(data: exportResult.data) - #expect(envelope.manifest.validate(against: envelope)) - - // Import into a fresh store - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - // Verify insert counts - #expect(result.devicesInserted == 1) - #expect(result.contactsInserted == 1) - #expect(result.channelsInserted == 1) - #expect(result.messagesInserted == 1) - #expect(result.reactionsInserted == 1) - #expect(result.totalSkipped == 0) - - // Verify data actually persisted in the destination store - let destContacts = try await destStore.fetchAllContacts(radioID: radioID) - #expect(destContacts.count == 1) - #expect(destContacts.first?.name == "Alice") - - let destChannels = try await destStore.fetchAllChannels(radioID: radioID) - #expect(destChannels.count == 1) - #expect(destChannels.first?.name == "General") - - let destMessages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(destMessages.count == 1) - #expect(destMessages.first?.text == "Hello") - - let messageIDs = Set(destMessages.map(\.id)) - let destReactions = try await destStore.fetchAllReactions(radioID: radioID) - #expect(destReactions.count == 1) - // Reaction messageID must match a message in the destination store - #expect(messageIDs.contains(destReactions.first!.messageID)) - } - - // MARK: - Test 1b: Unmodeled contact type byte survives backup - - /// `ContactDTO.typeRawValue` is a non-optional field that has existed since the backup - /// feature shipped, so the contract is a plain encode → decode round-trip (no - /// `decodeIfPresent` legacy path). An unmodeled type byte must survive verbatim so a - /// future refactor can't silently re-collapse it onto a modeled `ContactType`. - @Test("Unmodeled contact type byte (0x04) survives DTO encode → decode round-trip") - func unmodeledContactTypeSurvivesCodableRoundTrip() throws { - let dto = ContactDTO.testContact(typeRawValue: 0x04) - let encoded = try JSONEncoder().encode(dto) - let decoded = try JSONDecoder().decode(ContactDTO.self, from: encoded) - #expect(decoded.typeRawValue == 0x04) - #expect(decoded.type == .chat) - } - - /// Full envelope path: an unmodeled type byte must survive export → import into a fresh store, - /// not just an isolated DTO round-trip. - @Test("Unmodeled contact type byte (0x04) survives full backup export → import") - func unmodeledContactTypeSurvivesBackupPipeline() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xAB, count: 32), - name: "FutureType", - typeRawValue: 0x04 - ) - try await sourceStore.saveContact(contact) - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - _ = try await service.importBackup(envelope: envelope, into: destStore) - - let destContacts = try await destStore.fetchAllContacts(radioID: radioID) - #expect(destContacts.first?.typeRawValue == 0x04) - #expect(destContacts.first?.type == .chat) - } - - // MARK: - Test 1c: Device knownRegions survives backup - - /// `DeviceDTO.knownRegions` is a non-optional `[String]` that shipped before the - /// backup feature, so no envelope can predate it: the contract is a plain encode → - /// decode round-trip, not a `decodeIfPresent` legacy path. This locks that contract - /// at the DTO boundary so a future refactor can't drop the field from the wire format. - @Test("Device knownRegions survives DTO encode → decode round-trip") - func knownRegionsSurvivesCodableRoundTrip() throws { - let dto = DeviceDTO.testDevice().copy { $0.knownRegions = ["US915", "EU868"] } - let encoded = try JSONEncoder().encode(dto) - let decoded = try JSONDecoder().decode(DeviceDTO.self, from: encoded) - #expect(decoded.knownRegions == ["US915", "EU868"]) - } - - /// Full envelope path: regions added through the targeted, list-owning writer must - /// survive export → import into a fresh store, exercising the `Device(dto:)` insert - /// seeding the restore relies on. - @Test("Device knownRegions survives full backup export → import into a fresh store") - func knownRegionsSurvivesBackupPipeline() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Discovery owns knownRegions through the targeted add path, not a full saveDevice. - try await sourceStore.addDeviceKnownRegion(radioID: radioID, region: "US915") - try await sourceStore.addDeviceKnownRegion(radioID: radioID, region: "EU868") - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - #expect(envelope.devices.first?.knownRegions == ["US915", "EU868"]) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let result = try await service.importBackup(envelope: envelope, into: destStore) - #expect(result.devicesInserted == 1) - - // Import re-mints Device.id; radioID is the surviving partition key. - let restored = try #require(await destStore.fetchDevice(radioID: radioID)) - #expect(restored.knownRegions == ["US915", "EU868"]) - } - - // MARK: - Test 2: Cross-bundle radioID remapping - - /// When the target store contains a device with the same publicKey as the backup but a - /// different radioID, all child records must be remapped to the local radioID. - @Test("Cross-bundle: child records are remapped to local radioID on publicKey match") - func crossBundleRadioIDRemapping() async throws { - let sharedPublicKey = Data(repeating: 0xCC, count: 32) - let sourceRadioID = UUID() - let targetRadioID = UUID() - - // Source store: device with sharedPublicKey, radioID = sourceRadioID - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: sourceRadioID) - let sourceDevice = DeviceDTO.testDevice( - id: sourceRadioID, - radioID: sourceRadioID, - publicKey: sharedPublicKey - ) - try await sourceStore.saveDevice(sourceDevice) - - let contact = ContactDTO.testContact( - radioID: sourceRadioID, - publicKey: Data(repeating: 0xDD, count: 32), - name: "Bob" - ) - try await sourceStore.saveContact(contact) - - // Export from source - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - // Target store: device with same publicKey but different radioID - let targetStore = try await PersistenceStore.createTestDataStore(radioID: targetRadioID) - let targetDevice = DeviceDTO.testDevice( - id: targetRadioID, - radioID: targetRadioID, - publicKey: sharedPublicKey - ) - try await targetStore.saveDevice(targetDevice) - - // Import - let result = try await service.importBackup( - envelope: envelope, - into: targetStore - ) - - // Backup device matched by publicKey → not inserted - #expect(result.devicesInserted == 0) - #expect(result.contactsInserted == 1) - - // Contact must live under targetRadioID, not sourceRadioID - let contactsUnderTarget = try await targetStore.fetchAllContacts(radioID: targetRadioID) - #expect(contactsUnderTarget.count == 1) - #expect(contactsUnderTarget.first?.name == "Bob") - - let contactsUnderSource = try await targetStore.fetchAllContacts(radioID: sourceRadioID) - #expect(contactsUnderSource.count == 0) - } - - // MARK: - Test 4: Merge import — DM thread visible for existing contact - - @Test("Import onto existing contact with nil lastMessageDate makes DM thread visible") - func importOntoExistingContact_DmThreadVisible() async throws { - let radioID = UUID() - let sharedPublicKey = Data(repeating: 0xAB, count: 32) - - // Destination store has a contact with no message history - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: sharedPublicKey, - name: "Alice", - lastMessageDate: nil - ) - try await destStore.saveContact(contact) - - // Verify contact is NOT in conversations list (lastMessageDate is nil) - let beforeConversations = try await destStore.fetchConversations(radioID: radioID) - #expect(beforeConversations.isEmpty) - - // Build backup with DMs for the same contact - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupContact = ContactDTO.testContact( - radioID: radioID, - publicKey: sharedPublicKey, - name: "Alice", - lastMessageDate: Date() - ) - var msg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: backupContact.id, - text: "Restored DM" - ) - msg.deduplicationKey = "merge-dm-\(UUID())" - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [backupContact], - messages: [msg] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - // Contact was skipped (already exists), message was inserted - #expect(result.contactsSkipped == 1) - #expect(result.messagesInserted == 1) - - // Contact must now appear in conversations - let afterConversations = try await destStore.fetchConversations(radioID: radioID) - #expect(afterConversations.count == 1) - #expect(afterConversations.first?.name == "Alice") - #expect(afterConversations.first?.lastMessageDate != nil) - } - - // MARK: - Test 5: Merge import — MessageRepeat relationship set (pre-existing parent) - - @Test("Import repeats onto existing message sets relationship and enables cascade delete") - func importOntoExistingMessage_RepeatRelationshipSet() async throws { - let radioID = UUID() - - // Destination store has an existing message - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - try await destStore.saveContact(contact) - - var existingMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Existing", - direction: .incoming - ) - existingMsg.deduplicationKey = "existing-msg-key" - try await destStore.saveMessage(existingMsg) - - // Build backup that contributes a repeat for the same message - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - var backupMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Existing", - direction: .incoming - ) - backupMsg.deduplicationKey = "existing-msg-key" - - let repeat1 = MessageRepeatDTO.testRepeat( - messageID: backupMsg.id, - pathNodes: Data([0x31]) - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [backupMsg], - messageRepeats: [repeat1] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.messagesSkipped == 1) - #expect(result.messageRepeatsInserted == 1) - - // Verify the repeat was linked to the existing message - let repeats = try await destStore.fetchMessageRepeats(messageID: existingMsg.id) - #expect(repeats.count == 1) - - // Delete the message — repeat must cascade-delete - try await destStore.deleteMessage(id: existingMsg.id) - let repeatsAfterDelete = try await destStore.fetchMessageRepeats(messageID: existingMsg.id) - #expect(repeatsAfterDelete.isEmpty) - } - - // MARK: - Test 6: Fresh-store import — MessageRepeat cascade delete - - @Test("Fresh-store import sets MessageRepeat relationship and cascade deletes work") - func freshStoreImport_RepeatCascadeDelete() async throws { - let radioID = UUID() - - // Source store with a message and repeats - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xBB, count: 32), - name: "Bob" - ) - try await sourceStore.saveContact(contact) - - var msg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: 0, - text: "Hello mesh" - ) - msg.deduplicationKey = "fresh-cascade-\(UUID())" - try await sourceStore.saveMessage(msg) - - // Save repeat using the normal (relationship-setting) path - let repeatDTO = MessageRepeatDTO.testRepeat(messageID: msg.id, pathNodes: Data([0x42])) - try await sourceStore.saveMessageRepeat(repeatDTO) - - // Export - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - // Import into fresh store - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - #expect(result.messagesInserted == 1) - #expect(result.messageRepeatsInserted == 1) - - // Verify repeats exist - let destMessages = try await destStore.fetchAllMessages(radioID: radioID) - let destMsgID = try #require(destMessages.first?.id) - let repeats = try await destStore.fetchMessageRepeats(messageID: destMsgID) - #expect(repeats.count == 1) - - // Delete the message — repeat must cascade-delete - try await destStore.deleteMessage(id: destMsgID) - let repeatsAfterDelete = try await destStore.fetchMessageRepeats(messageID: destMsgID) - #expect(repeatsAfterDelete.isEmpty) - } - - // MARK: - Test 7: Merge import — caches recomputed - - @Test("Import repeats/reactions onto existing message recomputes heardRepeats and reactionSummary") - func importOntoExistingMessage_CachesRecomputed() async throws { - let radioID = UUID() - - // Destination store with a message (heardRepeats=0, no reactions) - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - try await destStore.saveContact(contact) - - var existingMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Cache test", - direction: .incoming, - heardRepeats: 0 - ) - existingMsg.deduplicationKey = "cache-test-key" - try await destStore.saveMessage(existingMsg) - - // Build backup that adds 2 repeats and 1 reaction to the same message - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - var backupMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Cache test", - direction: .incoming - ) - backupMsg.deduplicationKey = "cache-test-key" - - let repeat1 = MessageRepeatDTO.testRepeat( - messageID: backupMsg.id, - pathNodes: Data([0x31]) - ) - let repeat2 = MessageRepeatDTO.testRepeat( - messageID: backupMsg.id, - pathNodes: Data([0x42]) - ) - let reaction = ReactionDTO.testReaction( - messageID: backupMsg.id, - radioID: radioID, - emoji: "👍", - senderName: "Eve" - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [backupMsg], - messageRepeats: [repeat1, repeat2], - reactions: [reaction] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.messagesSkipped == 1) - #expect(result.messageRepeatsInserted == 2) - #expect(result.reactionsInserted == 1) - - // Verify heardRepeats was recomputed - let updatedMsg = try await destStore.fetchMessage(id: existingMsg.id) - #expect(updatedMsg?.heardRepeats == 2) - - // Verify reactionSummary was recomputed - #expect(updatedMsg?.reactionSummary == "👍:1") - } - - // MARK: - Test 8: Merge import — channel metadata refreshed - - @Test("Import messages onto existing channel with nil lastMessageDate refreshes metadata") - func importOntoExistingChannel_ChannelMetadataRefreshed() async throws { - let radioID = UUID() - - // Destination store has a channel with no messages - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let channel = ChannelDTO.testChannel( - radioID: radioID, - index: 0, - name: "General", - lastMessageDate: nil - ) - try await destStore.saveChannel(channel) - - // Verify channel has nil lastMessageDate - let beforeChannel = try await destStore.fetchChannel(radioID: radioID, index: 0) - #expect(beforeChannel?.lastMessageDate == nil) - - // Build backup with messages for the same channel - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General") - var msg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: 0, - text: "Restored channel msg" - ) - msg.deduplicationKey = "channel-meta-\(UUID())" - - let envelope = AppBackupEnvelope.test( - devices: [device], - channels: [backupChannel], - messages: [msg] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.channelsSkipped == 1) - #expect(result.messagesInserted == 1) - - // Channel must now have lastMessageDate set - let afterChannel = try await destStore.fetchChannel(radioID: radioID, index: 0) - #expect(afterChannel?.lastMessageDate != nil) - } - - // MARK: - Test 9: Merge import — saved trace path runs preserved - - @Test("Import onto existing saved trace path merges runs without duplicating them") - func importOntoExistingSavedTracePath_MergesRuns() async throws { - let radioID = UUID() - let pathBytes = Data([0x12, 0x34, 0x56, 0x78]) - let existingRun = TracePathRunDTO.testRun( - date: Date().addingTimeInterval(-120), - roundTripMs: 180 - ) - let importedRun = TracePathRunDTO.testRun( - date: Date(), - roundTripMs: 95 - ) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingPath = try await destStore.createSavedTracePath( - radioID: radioID, - name: "Shared Route", - pathBytes: pathBytes, - hashSize: 2, - initialRun: existingRun - ) - - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupPath = SavedTracePathDTO.testPath( - radioID: radioID, - name: "Shared Route", - pathBytes: pathBytes, - hashSize: 2, - runs: [importedRun] - ) - let envelope = AppBackupEnvelope.test( - devices: [device], - savedTracePaths: [backupPath] - ) - - let service = AppBackupService() - - let firstResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(firstResult.savedTracePathsInserted == 0) - #expect(firstResult.savedTracePathsSkipped == 1) - #expect(firstResult.savedTracePathsMerged == 1) - #expect(firstResult.hasRestoredChanges) - - let mergedPath = try #require(await destStore.fetchSavedTracePath(id: existingPath.id)) - #expect(mergedPath.runs.count == 2) - #expect(Set(mergedPath.runs.map(\.id)) == Set([existingRun.id, importedRun.id])) - - let secondResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(secondResult.savedTracePathsInserted == 0) - #expect(secondResult.savedTracePathsSkipped == 1) - #expect(secondResult.savedTracePathsMerged == 0) - #expect(!secondResult.hasRestoredChanges) - - let deduplicatedPath = try #require(await destStore.fetchSavedTracePath(id: existingPath.id)) - #expect(deduplicatedPath.runs.count == 2) - #expect(Set(deduplicatedPath.runs.map(\.id)) == Set([existingRun.id, importedRun.id])) - } - - // MARK: - Test 9b: Cross-path run id reuse never relocates a run - - @Test("Import reusing a local run's id under a different path drops the duplicate run rather than relocating it") - func importReusingRunIDUnderDifferentPath_DropsDuplicate() async throws { - let radioID = UUID() - let pathABytes = Data([0x12, 0x34, 0x56, 0x78]) - let pathBBytes = Data([0xAB, 0xCD, 0xEF, 0x01]) - let sharedRunID = UUID() - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Local path A owns run R. - let runR = TracePathRunDTO.testRun(id: sharedRunID, roundTripMs: 180) - let pathA = try await destStore.createSavedTracePath( - radioID: radioID, - name: "Path A", - pathBytes: pathABytes, - hashSize: 1, - initialRun: runR - ) - - // Backup path B is a distinct path whose run reuses R's id. - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupPathB = SavedTracePathDTO.testPath( - radioID: radioID, - name: "Path B", - pathBytes: pathBBytes, - hashSize: 1, - runs: [TracePathRunDTO.testRun(id: sharedRunID, roundTripMs: 999)] - ) - let envelope = AppBackupEnvelope.test( - devices: [device], - savedTracePaths: [backupPathB] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - // Path B is a new path, but its duplicate-id run is dropped store-wide. - #expect(result.savedTracePathsInserted == 1) - - let allPaths = try await destStore.fetchSavedTracePaths(radioID: radioID) - #expect(allPaths.count == 2) - - // Run R stays under path A; path B gains no run. - let pathAAfter = try #require(await destStore.fetchSavedTracePath(id: pathA.id)) - #expect(pathAAfter.runs.count == 1) - #expect(pathAAfter.runs.first?.id == sharedRunID) - - let pathBAfter = try #require(allPaths.first { $0.pathBytes == pathBBytes }) - #expect(pathBAfter.runs.isEmpty) - - // The run exists exactly once in the whole store. - let totalRuns = allPaths.reduce(0) { $0 + $1.runs.count } - #expect(totalRuns == 1) - } - - // MARK: - Test 10: Orphaned radio-scoped data survives export/import - - @Test("Export preserves orphaned radio-scoped data after device-only delete") - func exportPreservesOrphanedRadioScopedData() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xD1, count: 32), - name: "Orphaned Contact" - ) - try await sourceStore.saveContact(contact) - - let channel = ChannelDTO.testChannel( - radioID: radioID, - index: 1, - name: "Orphaned Channel", - unreadCount: 3, - notificationLevel: .mentionsOnly, - isFavorite: true - ) - try await sourceStore.saveChannel(channel) - - var message = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Preserve me" - ) - message.deduplicationKey = "orphaned-message-\(UUID())" - try await sourceStore.saveMessage(message) - - let session = RemoteNodeSessionDTO.testSession(radioID: radioID) - try await sourceStore.saveRemoteNodeSessionDTO(session) - - let roomMessage = RoomMessageDTO.testRoomMessage(sessionID: session.id) - try await sourceStore.saveRoomMessage(roomMessage) - - let blockedSender = BlockedChannelSenderDTO.testBlockedSender(radioID: radioID) - try await sourceStore.saveBlockedChannelSender(blockedSender) - - let tracePath = SavedTracePathDTO.testPath(radioID: radioID) - let initialRun = tracePath.runs.first - _ = try await sourceStore.createSavedTracePath( - radioID: tracePath.radioID, - name: tracePath.name, - pathBytes: tracePath.pathBytes, - hashSize: tracePath.hashSize, - initialRun: initialRun - ) - - try await sourceStore.deleteDevice(id: radioID) - #expect(try await sourceStore.fetchDevice(id: radioID) == nil) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: result.data) - - #expect(envelope.devices.isEmpty) - #expect(envelope.contacts.count == 1) - #expect(envelope.channels.count == 1) - #expect(envelope.messages.count == 1) - #expect(envelope.remoteNodeSessions.count == 1) - #expect(envelope.roomMessages.count == 1) - #expect(envelope.savedTracePaths.count == 1) - #expect(envelope.blockedChannelSenders.count == 1) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let firstResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(firstResult.devicesInserted == 0) - #expect(firstResult.contactsInserted == 1) - #expect(firstResult.channelsInserted == 1) - #expect(firstResult.messagesInserted == 1) - #expect(firstResult.remoteNodeSessionsInserted == 1) - #expect(firstResult.roomMessagesInserted == 1) - #expect(firstResult.savedTracePathsInserted == 1) - #expect(firstResult.blockedChannelSendersInserted == 1) - - #expect(try await destStore.fetchAllContacts(radioID: radioID).count == 1) - #expect(try await destStore.fetchAllChannels(radioID: radioID).count == 1) - #expect(try await destStore.fetchAllMessages(radioID: radioID).count == 1) - #expect(try await destStore.fetchRemoteNodeSessions(radioID: radioID).count == 1) - #expect(try await destStore.fetchRoomMessages(sessionID: session.id).count == 1) - #expect(try await destStore.fetchSavedTracePaths(radioID: radioID).count == 1) - #expect(try await destStore.fetchBlockedChannelSenders(radioID: radioID).count == 1) - - let secondResult = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(secondResult.totalInserted == 0) - #expect(secondResult.contactsSkipped == 1) - #expect(secondResult.channelsSkipped == 1) - #expect(secondResult.messagesSkipped == 1) - #expect(secondResult.remoteNodeSessionsSkipped == 1) - #expect(secondResult.roomMessagesSkipped == 1) - #expect(secondResult.savedTracePathsSkipped == 1) - #expect(secondResult.blockedChannelSendersSkipped == 1) - } - - // MARK: - Test 11: Merge import — contact metadata restored - - @Test("Import onto existing contact restores backup-owned contact metadata") - func importOntoExistingContact_RestoresBackupMetadata() async throws { - let radioID = UUID() - let sharedPublicKey = Data(repeating: 0xE2, count: 32) - let importedDate = Date(timeIntervalSince1970: 1_700_000_000) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingContact = ContactDTO.testContact( - radioID: radioID, - publicKey: sharedPublicKey, - name: "Alice", - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0 - ) - try await destStore.saveContact(existingContact) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupContact = ContactDTO( - id: UUID(), - radioID: radioID, - publicKey: sharedPublicKey, - name: "Alice", - typeRawValue: existingContact.typeRawValue, - flags: existingContact.flags, - outPathLength: existingContact.outPathLength, - outPath: existingContact.outPath, - lastAdvertTimestamp: existingContact.lastAdvertTimestamp, - latitude: existingContact.latitude, - longitude: existingContact.longitude, - lastModified: existingContact.lastModified, - nickname: "Field Ops", - isBlocked: true, - isMuted: true, - isFavorite: true, - lastMessageDate: importedDate, - unreadCount: 7, - unreadMentionCount: 2, - ocvPreset: OCVPreset.custom.rawValue, - customOCVArrayString: "4200,4100,4000" - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - contacts: [backupContact] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.contactsInserted == 0) - #expect(result.contactsSkipped == 1) - - let mergedContact = try #require( - await destStore.fetchContact(radioID: radioID, publicKey: sharedPublicKey) - ) - #expect(mergedContact.nickname == "Field Ops") - #expect(mergedContact.isBlocked == true) - #expect(mergedContact.isMuted == true) - #expect(mergedContact.isFavorite == true) - #expect(mergedContact.lastMessageDate == importedDate) - #expect(mergedContact.unreadCount == 7) - #expect(mergedContact.unreadMentionCount == 2) - #expect(mergedContact.ocvPreset == OCVPreset.custom.rawValue) - #expect(mergedContact.customOCVArrayString == "4200,4100,4000") - } - - // MARK: - Test 12: Merge import — channel metadata restored - - @Test("Import onto existing channel restores backup-owned channel metadata") - func importOntoExistingChannel_RestoresBackupMetadata() async throws { - let radioID = UUID() - let importedDate = Date(timeIntervalSince1970: 1_700_000_100) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingChannel = ChannelDTO.testChannel( - radioID: radioID, - index: 2, - name: "Ops", - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false, - floodScope: .inherit - ) - try await destStore.saveChannel(existingChannel) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: existingChannel.index, - name: existingChannel.name, - lastMessageDate: importedDate, - unreadCount: 11, - unreadMentionCount: 4, - notificationLevel: .mentionsOnly, - isFavorite: true, - floodScope: .region("US") - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupChannel] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.channelsInserted == 0) - #expect(result.channelsSkipped == 1) - - let mergedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 2)) - #expect(mergedChannel.lastMessageDate == importedDate) - #expect(mergedChannel.unreadCount == 11) - #expect(mergedChannel.unreadMentionCount == 4) - #expect(mergedChannel.notificationLevel == .mentionsOnly) - #expect(mergedChannel.isFavorite == true) - #expect(mergedChannel.regionScope == "US") - } - - // MARK: - Test 12b: Different-secret slot collision relocates instead of merging - - /// Local `#kosice` (secret 0x01) occupies slot 2; the backup carries a distinct channel - /// `#praha` (secret 0x02) that also claims slot 2. Because the two secrets differ, this - /// is "different channel, relocate", never a metadata merge: `#kosice` keeps its slot, - /// name, and secret untouched, and `#praha` is inserted as a separate channel on a free - /// slot. Contrast with same-secret merges, where backup metadata is adopted in place. - @Test("Import onto a different-secret slot keeps the local channel and inserts the backup channel separately") - func importOntoExistingChannel_DifferentSecretRelocatesInsteadOfMerging() async throws { - let radioID = UUID() - let localSecret = Data(repeating: 0x01, count: 16) - let backupSecret = Data(repeating: 0x02, count: 16) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingChannel = ChannelDTO.testChannel( - radioID: radioID, - index: 2, - name: "#kosice", - secret: localSecret - ) - try await destStore.saveChannel(existingChannel) - - // Same (radioID, index) slot, but a different name and secret in the backup. - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: 2, - name: "#praha", - secret: backupSecret - ) - - let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupChannel]) - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - // The backup channel is a distinct identity, so it is inserted (relocated), not - // merged into #kosice's slot. - #expect(result.channelsInserted == 1) - #expect(result.channelsSkipped == 0) - - // #kosice keeps its slot, name, and secret. - let kosice = try #require(await destStore.fetchChannel(radioID: radioID, index: 2)) - #expect(kosice.name == "#kosice") - #expect(kosice.secret == localSecret) - - // #praha exists as a separate channel on a different slot, with its own name/secret. - let channels = try await destStore.fetchAllChannels(radioID: radioID) - let praha = try #require(channels.first { $0.secret == backupSecret }) - #expect(praha.name == "#praha") - #expect(praha.index != 2) - } - - // MARK: - Channel reconciliation by secret (slot relocation) — REPRO - - /// Reproduction for silent wrong-context delivery. Backup `#praha` (secretA) lives - /// at slot 3 with a channel message; locally slot 3 is `#brno` (secretB). With slot-only - /// reconciliation the imported message attaches to `#brno` and `#praha` is dropped. The - /// fix reconciles by `(radioID, secret)`: `#praha` relocates to a free slot and its - /// message follows; `#brno` is untouched. - @Test("Channel reconcile: backup channel collides with a different-secret local slot, relocates and carries its message") - func channelSecretReconcile_RelocatesOnDifferentSecretSlotCollision() async throws { - let radioID = UUID() - let secretBrno = Data(repeating: 0xB0, count: 16) - let secretPraha = Data(repeating: 0xA0, count: 16) - let collisionIndex: UInt8 = 3 - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let localBrno = ChannelDTO.testChannel( - radioID: radioID, - index: collisionIndex, - name: "#brno", - secret: secretBrno - ) - try await destStore.saveChannel(localBrno) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupPraha = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: collisionIndex, - name: "#praha", - secret: secretPraha - ) - var prahaMessage = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: collisionIndex, - text: "Praha checkpoint", - timestamp: 1_700_000_900, - direction: .incoming, - senderNodeName: "Jan" - ) - prahaMessage.deduplicationKey = "ch-\(collisionIndex)-1700000900-Jan-DEADBEEF" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupPraha], - messages: [prahaMessage] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - // #praha is a distinct channel: inserted (relocated), not merged into #brno's slot. - #expect(result.channelsInserted == 1) - #expect(result.channelsSkipped == 0) - #expect(result.messagesInserted == 1) - - let channels = try await destStore.fetchAllChannels(radioID: radioID) - let brno = try #require(channels.first { $0.secret == secretBrno }) - let praha = try #require(channels.first { $0.secret == secretPraha }) - - // #brno keeps its slot and name; #praha lands on a different (free) slot. - #expect(brno.index == collisionIndex) - #expect(brno.name == "#brno") - #expect(praha.index != collisionIndex) - #expect(praha.name == "#praha") - - // The message followed #praha's relocated slot, not #brno's. - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.count == 1) - let restored = try #require(messages.first) - #expect(restored.channelIndex == praha.index) - #expect(restored.text == "Praha checkpoint") - - // #brno received no foreign message. - let brnoMessages = messages.filter { $0.channelIndex == collisionIndex } - #expect(brnoMessages.isEmpty) - } - - @Test("Re-importing a stale backup never upserts a reconfigured live channel") - func staleBackupDoesNotOverwriteReconfiguredChannel() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let radioID = UUID() - let sharedID = UUID() - - // Live channel: same surrogate id as the backup, but secret rotated in place (S2), - // occupying the backup's slot. - let liveSecret = Data(repeating: 0xB2, count: 32) - let live = ChannelDTO( - id: sharedID, radioID: radioID, index: 3, name: "Live Net", - secret: liveSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - try await store.batchInsertChannels([live], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8]) - - // Backup channel: same id, slot 3, but the original secret S1 (no local match). - let backupSecret = Data(repeating: 0xA1, count: 32) - let backup = ChannelDTO( - id: sharedID, radioID: radioID, index: 3, name: "Stale Net", - secret: backupSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - let result = try await store.batchInsertChannels([backup], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8]) - - let channels = try await store.fetchChannels(radioID: radioID) - // Live channel survives untouched at slot 3 with its rotated secret. - let liveRow = try #require(channels.first { $0.secret == liveSecret }) - #expect(liveRow.index == 3) - #expect(liveRow.name == "Live Net") - // Backup channel coexists, relocated to a free slot with a fresh id. - let backupRow = try #require(channels.first { $0.secret == backupSecret }) - #expect(backupRow.id != sharedID) - #expect(backupRow.index != 3) - #expect(result.inserted == 1) - } - - @Test("batchInsertChannels reports newly-occupied local slots for draft clearing, excluding merges") - func batchInsertReportsInsertedLocalIndicesForDraftClearing() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let radioID = UUID() - - let mergeSecret = Data(repeating: 0xC1, count: 32) - let occupiedSecret = Data(repeating: 0xC4, count: 32) - let localAtSlot2 = ChannelDTO( - id: UUID(), radioID: radioID, index: 2, name: "Keep", - secret: mergeSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - let localAtSlot4 = ChannelDTO( - id: UUID(), radioID: radioID, index: 4, name: "Occupied", - secret: occupiedSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - try await store.batchInsertChannels( - [localAtSlot2, localAtSlot4], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8] - ) - - // (a) merges by secret into local slot 2 though recorded at slot 6 — occupant unchanged. - let mergeBackup = ChannelDTO( - id: UUID(), radioID: radioID, index: 6, name: "Keep", - secret: mergeSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - // (b) foreign channel placed at its own free slot 5. - let collideSecret = Data(repeating: 0xD4, count: 32) - let freshAtFreeSlot = ChannelDTO( - id: UUID(), radioID: radioID, index: 5, name: "Fresh", - secret: Data(repeating: 0xD5, count: 32), isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - // (c) foreign channel colliding with occupied slot 4, relocated to a free slot. - let collideAtSlot4 = ChannelDTO( - id: UUID(), radioID: radioID, index: 4, name: "Collide", - secret: collideSecret, isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, - notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - - let result = try await store.batchInsertChannels( - [mergeBackup, freshAtFreeSlot, collideAtSlot4], - radioIDs: [radioID], maxChannelsByRadioID: [radioID: 8] - ) - - let inserted = result.insertedLocalIndices[radioID] ?? [] - // Fresh insert recorded at its own free slot. - #expect(inserted.contains(5)) - // Merge-by-secret destination (2) and backup-file source (6) are not local inserts. - #expect(!inserted.contains(2)) - #expect(!inserted.contains(6)) - // The colliding channel's untouched source slot (4) is not recorded; its relocation - // destination is. - #expect(!inserted.contains(4)) - let relocatedRow = try #require( - (try await store.fetchChannels(radioID: radioID)).first { $0.secret == collideSecret } - ) - #expect(relocatedRow.index != 4) - #expect(inserted.contains(relocatedRow.index)) - #expect(result.inserted == 2) - } - - @Test("Exporting a channel with an unmigrated notification level does not migrate the live row") - func exportReadDoesNotMigrateNotificationLevel() throws { - let channel = Channel( - radioID: UUID(), index: 1, name: "Ops", - secret: Data(repeating: 0x33, count: 32) - ) - // Simulate a pre-migration row: -1 sentinel with the legacy muted flag set. - channel.notificationLevelRawValue = -1 - channel.legacyIsMuted = true - - let dto = ChannelDTO(from: channel) - - // The read did not trigger the migrating getter's in-memory write-back. - #expect(channel.notificationLevelRawValue == -1) - // The DTO still carries the correctly decoded level (muted, from legacy isMuted). - #expect(dto.notificationLevel == .muted) - } - - @Test("Duplicate device public keys in one envelope skip the loser and remap its children") - func duplicatePublicKeyRemapsChildrenToSinglePartition() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let key = Data(repeating: 0x42, count: 32) - let radioA = UUID(), radioB = UUID() - let devA = DeviceDTO.testDevice().copy { $0.publicKey = key; $0.radioID = radioA } - let devB = DeviceDTO.testDevice().copy { $0.publicKey = key; $0.radioID = radioB } - // Both devices share the duplicate `key` (the scenario under test), but the contacts - // must carry distinct public keys: after both contacts' radioID is remapped to the - // single surviving local radioID they would otherwise share an identical `contactKey` - // (radioID + publicKey) and the second would be deduped, collapsing to one row. - let contactA = ContactDTO.testContact(radioID: radioA, publicKey: Data(repeating: 0x01, count: 32)) - let contactB = ContactDTO.testContact(radioID: radioB, publicKey: Data(repeating: 0x02, count: 32)) - let envelope = AppBackupEnvelope.test( - devices: [devA, devB], contacts: [contactA, contactB] - ) - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: store) - #expect(result.counts[.devices]?.skipped == 1) // loser duplicate - #expect(result.counts[.devices]?.inserted == 1) - // Both contacts land under the single surviving local radioID. - let devices = try await store.fetchAllDevices() - let localRadioID = try #require(devices.first?.radioID) - let contacts = try await store.fetchContacts(radioID: localRadioID) - #expect(contacts.count == 2) - } - - @Test("Channel floodScope survives a fresh-insert export/import round-trip") - func channelFloodScopeRoundTripsOnFreshInsert() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - try await sourceStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 0, name: "General", floodScope: .inherit) - ) - try await sourceStore.saveChannel( - ChannelDTO.testChannel( - radioID: radioID, index: 1, name: "Regional", - secret: Data(repeating: 0x55, count: 32), floodScope: .region("US") - ) - ) - - let service = AppBackupService() - let envelope = try parseBackup(data: try await service.export(persistenceStore: sourceStore).data) - - // Fresh dest store: both channels take the fresh-insert path (Channel(dto:)), not merge. - let destStore = PersistenceStore(modelContainer: try PersistenceStore.createContainer(inMemory: true)) - let result = try await service.importBackup(envelope: envelope, into: destStore) - #expect(result.channelsInserted == 2) - - let restoredRegional = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) - #expect(restoredRegional.floodScope == .region("US")) - let restoredInherit = try #require(try await destStore.fetchChannel(radioID: radioID, index: 0)) - #expect(restoredInherit.floodScope == .inherit) - } - - @Test("Re-importing the same backup is idempotent for unread counts and last-message dates") - func secondImportIsIdempotentForMergeFields() async throws { - let radioID = UUID() - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Local channel the backup will merge into (matched by stable secret). - let secret = Data(repeating: 0x77, count: 32) - let localDate = Date(timeIntervalSince1970: 1_700_000_000) - try await destStore.saveChannel( - ChannelDTO.testChannel( - radioID: radioID, index: 1, name: "Ops", secret: secret, - lastMessageDate: localDate, unreadCount: 5 - ) - ) - - // Backup carries the same channel with a higher unread count and a later date. - let backupDate = Date(timeIntervalSince1970: 1_700_009_000) - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - radioID: radioID, index: 1, name: "Ops", secret: secret, - lastMessageDate: backupDate, unreadCount: 9 - ) - let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupChannel]) - - let service = AppBackupService() - _ = try await service.importBackup(envelope: envelope, into: destStore) - let afterFirst = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) - - _ = try await service.importBackup(envelope: envelope, into: destStore) - let afterSecond = try #require(try await destStore.fetchChannel(radioID: radioID, index: 1)) - - // The merge (max of local/backup) and date reconciliation are idempotent: a repeat - // import neither double-counts unread nor oscillates the last-message date. - #expect(afterSecond.unreadCount == afterFirst.unreadCount) - #expect(afterSecond.unreadMentionCount == afterFirst.unreadMentionCount) - #expect(afterSecond.lastMessageDate == afterFirst.lastMessageDate) - } - - /// Case A: same secret at the same slot is a pure metadata merge with no relocation. - @Test("Channel reconcile: same secret at same slot merges metadata, index unchanged") - func channelSecretReconcile_SameSecretSameSlotIsNoOp() async throws { - let radioID = UUID() - let secret = Data(repeating: 0x44, count: 16) - let slot: UInt8 = 2 - let importedDate = Date(timeIntervalSince1970: 1_700_000_950) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let localChannel = ChannelDTO.testChannel( - radioID: radioID, - index: slot, - name: "#ops", - secret: secret, - lastMessageDate: nil - ) - try await destStore.saveChannel(localChannel) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: slot, - name: "#ops", - secret: secret, - lastMessageDate: importedDate - ) - var msg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: slot, - text: "Same slot", - timestamp: 1_700_000_950, - // Older than importedDate so the merged channel date is the max and survives - // the lastMessageDate reconcile pass (which keys on createdAt). - createdAt: Date(timeIntervalSince1970: 1_600_000_000), - direction: .incoming, - senderNodeName: "Eva" - ) - msg.deduplicationKey = "ch-\(slot)-1700000950-Eva-CAFE0001" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupChannel], - messages: [msg] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 0) - #expect(result.channelsSkipped == 1) - #expect(result.messagesInserted == 1) - - let channels = try await destStore.fetchAllChannels(radioID: radioID) - #expect(channels.count == 1) - #expect(channels.first?.index == slot) - #expect(channels.first?.secret == secret) - #expect(channels.first?.lastMessageDate == importedDate) - - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.count == 1) - #expect(messages.first?.channelIndex == slot) - } - - /// Case B: the same secret moved slots between backup and restore. The message - /// remaps to the local slot; no duplicate channel is inserted. - @Test("Channel reconcile: same secret on a different slot remaps the message, no duplicate channel") - func channelSecretReconcile_SameSecretDifferentSlotRemapsMessage() async throws { - let radioID = UUID() - let secret = Data(repeating: 0x55, count: 16) - let backupSlot: UInt8 = 3 - let localSlot: UInt8 = 5 - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let localChannel = ChannelDTO.testChannel( - radioID: radioID, - index: localSlot, - name: "#praha", - secret: secret - ) - try await destStore.saveChannel(localChannel) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: backupSlot, - name: "#praha", - secret: secret - ) - var msg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: backupSlot, - text: "Moved slots", - timestamp: 1_700_001_000, - direction: .incoming, - senderNodeName: "Lena" - ) - msg.deduplicationKey = "ch-\(backupSlot)-1700001000-Lena-0BADF00D" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupChannel], - messages: [msg] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 0) - #expect(result.channelsSkipped == 1) - #expect(result.messagesInserted == 1) +private enum InjectedImportFailure: Error { + case simulated +} - let channels = try await destStore.fetchAllChannels(radioID: radioID) - #expect(channels.count == 1) - #expect(channels.first?.index == localSlot) +private extension PersistenceStore { + func setAutosaveEnabledForTesting(_ isEnabled: Bool) { + modelContext.autosaveEnabled = isEnabled + } - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.count == 1) - #expect(messages.first?.channelIndex == localSlot) - } + func autosaveEnabledForTesting() -> Bool { + modelContext.autosaveEnabled + } - /// The public channel (index 0, all-zero secret) merges into the local public channel - /// without relocating, because empty-secret channels match by index. - @Test("Channel reconcile: public channel merges into local public channel without relocation") - func channelSecretReconcile_PublicChannelMergesIntoLocalPublic() async throws { - let radioID = UUID() - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let localPublic = ChannelDTO.testChannel( - radioID: radioID, - index: 0, - name: "Public", - lastMessageDate: nil - ) - try await destStore.saveChannel(localPublic) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupPublic = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: 0, - name: "Public", - lastMessageDate: Date(timeIntervalSince1970: 1_700_001_100) - ) - var msg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: 0, - text: "Public broadcast", - timestamp: 1_700_001_100, - direction: .incoming, - senderNodeName: "Mara" - ) - msg.deduplicationKey = "ch-0-1700001100-Mara-FEEDFACE" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupPublic], - messages: [msg] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 0) - #expect(result.channelsSkipped == 1) - #expect(result.messagesInserted == 1) - - let channels = try await destStore.fetchAllChannels(radioID: radioID) - #expect(channels.count == 1) - #expect(channels.first?.index == 0) - - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.count == 1) - #expect(messages.first?.channelIndex == 0) - } - - /// Dedup-key preservation: re-importing the same relocated backup must skip the - /// channel message on the second pass, proving the dedup key was rewritten to the - /// relocated index consistently across both runs. - @Test("Channel reconcile: re-importing a relocated channel skips the message the second time") - func channelSecretReconcile_RemapPreservesDedupKeyAcrossReimport() async throws { - let radioID = UUID() - let secretBrno = Data(repeating: 0xB2, count: 16) - let secretPraha = Data(repeating: 0xA2, count: 16) - let collisionIndex: UInt8 = 3 - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: collisionIndex, name: "#brno", secret: secretBrno) - ) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupPraha = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: collisionIndex, - name: "#praha", - secret: secretPraha - ) - var prahaMessage = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: collisionIndex, - text: "Praha checkpoint", - timestamp: 1_700_001_200, - direction: .incoming, - senderNodeName: "Jan" - ) - prahaMessage.deduplicationKey = "ch-\(collisionIndex)-1700001200-Jan-12345678" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupPraha], - messages: [prahaMessage] - ) - - let service = AppBackupService() - let firstResult = try await service.importBackup(envelope: envelope, into: destStore) - #expect(firstResult.messagesInserted == 1) - - let secondResult = try await service.importBackup(envelope: envelope, into: destStore) - #expect(secondResult.messagesInserted == 0) - #expect(secondResult.messagesSkipped == 1) - - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.count == 1) - } - - /// Safety case: every local slot within the radio's channel capacity is occupied by a - /// different secret, so the backup channel has nowhere to land. It must be dropped (not - /// mis-associated), and its messages must be dropped with it rather than attaching to a - /// foreign channel. - @Test("Channel reconcile: backup channel with no free slot is dropped along with its messages") - func channelSecretReconcile_NoFreeSlotDropsChannelAndMessages() async throws { - let radioID = UUID() - let maxChannels: UInt8 = 2 - let secretPraha = Data(repeating: 0xA4, count: 16) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - // Fill every slot in [0, maxChannels) with distinct-secret local channels. - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 0, name: "#a", secret: Data(repeating: 0x10, count: 16)) - ) - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#b", secret: Data(repeating: 0x11, count: 16)) - ) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = maxChannels } - let backupPraha = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: 1, - name: "#praha", - secret: secretPraha - ) - var prahaMessage = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: 1, - text: "Should not survive", - timestamp: 1_700_001_300, - direction: .incoming, - senderNodeName: "Jan" - ) - prahaMessage.deduplicationKey = "ch-1-1700001300-Jan-AABBCCDD" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupPraha], - messages: [prahaMessage] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - // #praha is dropped: not inserted, counted as dropped; its message is not inserted. - #expect(result.channelsInserted == 0) - #expect(result.channelsDropped == 1) - #expect(result.messagesInserted == 0) - - let channels = try await destStore.fetchAllChannels(radioID: radioID) - #expect(channels.count == 2) - #expect(channels.contains { $0.secret == secretPraha } == false) - - let messages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(messages.isEmpty) - } - - @Test("Dropped channels and their messages are accounted as dropped, not already-here") - func droppedChannelHistoryIsAccountedDistinctly() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let radioID = UUID() - - // Local radio is full to capacity (maxChannels = 2: slot 0 public + slot 1 used). - let existing = ChannelDTO( - id: UUID(), radioID: radioID, index: 1, name: "Local", - secret: Data(repeating: 0x11, count: 32), isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - try await store.batchInsertChannels([existing], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 2]) - - // Backup channel at slot 1 with a different secret has no free slot -> dropped. - let backupChannel = ChannelDTO( - id: UUID(), radioID: radioID, index: 1, name: "Backup", - secret: Data(repeating: 0x22, count: 32), isEnabled: true, lastMessageDate: nil, - unreadCount: 0, unreadMentionCount: 0, notificationLevel: .all, isFavorite: false, floodScope: .inherit - ) - let channelResult = try await store.batchInsertChannels( - [backupChannel], radioIDs: [radioID], maxChannelsByRadioID: [radioID: 2] - ) - #expect(channelResult.dropped == 1) - #expect(channelResult.skipped == 0) - #expect(channelResult.droppedChannelIndices[radioID]?.contains(1) == true) - } - - @Test("Full import accounts a no-slot channel's messages as dropped and the manifest balances") - func droppedChannelFullImportBalancesManifest() async throws { - let radioID = UUID() - let maxChannels: UInt8 = 2 - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - // Fill every slot in [0, maxChannels) with distinct-secret local channels. - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 0, name: "#a", secret: Data(repeating: 0x10, count: 16)) - ) - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#b", secret: Data(repeating: 0x11, count: 16)) - ) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = maxChannels } - // One backup channel that merges into an existing slot (slot 0, same empty-ish handling - // avoided by using a distinct secret at slot 0) and one that has no free slot -> dropped. - let droppedChannel = ChannelDTO.testChannel( - id: UUID(), radioID: radioID, index: 1, name: "#dropped", secret: Data(repeating: 0xA4, count: 16) - ) - var droppedMessage1 = MessageDTO.testChannelMessage( - radioID: radioID, channelIndex: 1, text: "lost one", - timestamp: 1_700_002_000, direction: .incoming, senderNodeName: "Jan" - ) - droppedMessage1.deduplicationKey = "ch-1-1700002000-Jan-AABBCCDD" - var droppedMessage2 = MessageDTO.testChannelMessage( - radioID: radioID, channelIndex: 1, text: "lost two", - timestamp: 1_700_002_100, direction: .incoming, senderNodeName: "Eva" - ) - droppedMessage2.deduplicationKey = "ch-1-1700002100-Eva-AABBCCDE" - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [droppedChannel], - messages: [droppedMessage1, droppedMessage2] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.counts[.channels]?.dropped == 1) - #expect(result.counts[.messages]?.dropped == 2) - - // Message balance invariant: every declared message is inserted, merged, skipped, or dropped. - let m = try #require(result.counts[.messages]) - #expect(envelope.manifest.messageCount == m.inserted + m.merged + m.skipped + m.dropped) - } - - /// Slot 0 is reserved for the public channel, so a relocating non-public channel skips it - /// even when free. Local `#brno` occupies slot 1; slots 0 and 2 are free. The colliding - /// backup `#praha` must relocate to slot 2, never slot 0. - @Test("Channel reconcile: relocation reserves slot 0 for the public channel") - func channelSecretReconcile_RelocationReservesPublicSlotZero() async throws { - let radioID = UUID() - let secretBrno = Data(repeating: 0xB6, count: 16) - let secretPraha = Data(repeating: 0xA6, count: 16) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - // Local channel occupies slot 1; slot 0 is intentionally left free (no public channel). - try await destStore.saveChannel( - ChannelDTO.testChannel(radioID: radioID, index: 1, name: "#brno", secret: secretBrno) - ) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID).copy { $0.maxChannels = 3 } - let backupPraha = ChannelDTO.testChannel( - id: UUID(), - radioID: radioID, - index: 1, - name: "#praha", - secret: secretPraha - ) - - let envelope = AppBackupEnvelope.test(devices: [backupDevice], channels: [backupPraha]) - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 1) - - let channels = try await destStore.fetchAllChannels(radioID: radioID) - let praha = try #require(channels.first { $0.secret == secretPraha }) - // Lowest free slot at or above 1 — slot 0 is skipped even though it is free. - #expect(praha.index == 2) - } - - // MARK: - Test 13: Fresh-store import — remote sessions start disconnected - - @Test("Import inserts remote sessions as disconnected while preserving backup metadata") - func importRemoteSession_ResetsTransientConnectionState() async throws { - let radioID = UUID() - let publicKey = Data(repeating: 0xF3, count: 32) - let importedDate = Date(timeIntervalSince1970: 1_700_000_200) - - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupSession = RemoteNodeSessionDTO.testSession( - radioID: radioID, - publicKey: publicKey, - name: "Ops Room", - role: .roomServer, - isConnected: true, - permissionLevel: .admin, - lastConnectedDate: importedDate, - unreadCount: 5, - notificationLevel: .mentionsOnly, - isFavorite: true, - neighborCount: 4, - lastSyncTimestamp: 88, - lastMessageDate: importedDate - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - remoteNodeSessions: [backupSession] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let service = AppBackupService() - - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.remoteNodeSessionsInserted == 1) - #expect(result.remoteNodeSessionsSkipped == 0) - - let importedSession = try #require(await destStore.fetchRemoteNodeSession(id: backupSession.id)) - #expect(importedSession.isConnected == false) - #expect(importedSession.permissionLevel == .admin) - #expect(importedSession.lastConnectedDate == importedDate) - #expect(importedSession.unreadCount == 5) - #expect(importedSession.notificationLevel == .mentionsOnly) - #expect(importedSession.isFavorite == true) - #expect(importedSession.lastSyncTimestamp == 88) - #expect(importedSession.lastMessageDate == importedDate) - } - - // MARK: - Test 14: Room-message delivery metadata survives import - - @Test("Import preserves room-message delivery metadata") - func importRoomMessage_PreservesDeliveryMetadata() async throws { - let radioID = UUID() - let createdAt = Date(timeIntervalSince1970: 1_700_000_275.25) - let session = RemoteNodeSessionDTO.testSession(radioID: radioID) - let roomMessage = RoomMessageDTO( - id: UUID(), - sessionID: session.id, - authorKeyPrefix: Data([0xCA, 0xFE, 0xBA, 0xBE]), - authorName: "Ops", - text: "Retry me", - timestamp: 1_700_000_275, - createdAt: createdAt, - isFromSelf: true, - status: .failed, - ackCode: 0xDEADBEEF, - roundTripTime: 1_450, - retryAttempt: 3, - maxRetryAttempts: 7 - ) - - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], - roomMessages: [roomMessage], - remoteNodeSessions: [session] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let service = AppBackupService() - - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.roomMessagesInserted == 1) - - let importedMessage = try #require(await destStore.fetchRoomMessage(id: roomMessage.id)) - #expect(importedMessage.createdAt == createdAt) - #expect(importedMessage.status == .failed) - #expect(importedMessage.ackCode == 0xDEADBEEF) - #expect(importedMessage.roundTripTime == 1_450) - #expect(importedMessage.retryAttempt == 3) - #expect(importedMessage.maxRetryAttempts == 7) - } - - // MARK: - Test 15: Merge import — remote session metadata restored - - @Test("Import onto existing remote session restores backup metadata without clobbering live state") - func importOntoExistingRemoteSession_RestoresBackupMetadata() async throws { - let radioID = UUID() - let publicKey = Data(repeating: 0xF4, count: 32) - let localConnectedDate = Date(timeIntervalSince1970: 1_700_000_250) - let importedDate = Date(timeIntervalSince1970: 1_700_000_300) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingSession = RemoteNodeSessionDTO.testSession( - radioID: radioID, - publicKey: publicKey, - name: "Ops Room", - role: .roomServer, - isConnected: true, - permissionLevel: .admin, - lastConnectedDate: localConnectedDate, - unreadCount: 0, - notificationLevel: .all, - isFavorite: false, - neighborCount: 2, - lastSyncTimestamp: 123, - lastMessageDate: nil - ) - try await destStore.saveRemoteNodeSessionDTO(existingSession) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupSession = RemoteNodeSessionDTO.testSession( - id: UUID(), - radioID: radioID, - publicKey: publicKey, - name: "Ops Room", - role: .roomServer, - isConnected: false, - permissionLevel: .guest, - lastConnectedDate: Date(timeIntervalSince1970: 1_700_000_225), - unreadCount: 9, - notificationLevel: .mentionsOnly, - isFavorite: true, - neighborCount: 7, - lastSyncTimestamp: 8, - lastMessageDate: importedDate - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - remoteNodeSessions: [backupSession] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.remoteNodeSessionsInserted == 0) - #expect(result.remoteNodeSessionsSkipped == 1) - #expect(result.remoteNodeSessionsMerged == 1) - #expect(result.hasRestoredChanges) - - let mergedSession = try #require(await destStore.fetchRemoteNodeSession(id: existingSession.id)) - #expect(mergedSession.isConnected == true) - #expect(mergedSession.permissionLevel == .admin) - #expect(mergedSession.lastConnectedDate == localConnectedDate) - #expect(mergedSession.unreadCount == 9) - #expect(mergedSession.notificationLevel == .mentionsOnly) - #expect(mergedSession.isFavorite == true) - #expect(mergedSession.lastSyncTimestamp == 123) - #expect(mergedSession.lastMessageDate == importedDate) - } - - // MARK: - Test 16: Merge import — same-second node snapshots preserved - - @Test("Import preserves distinct node snapshots recorded within the same second") - func importNodeSnapshots_PreservesSubsecondHistory() async throws { - let radioID = UUID() - let nodePublicKey = Data(repeating: 0xF5, count: 32) - let baseTimestamp = Date(timeIntervalSince1970: 1_700_000_400) - let existingTimestamp = baseTimestamp.addingTimeInterval(0.100) - let importedTimestamp = baseTimestamp.addingTimeInterval(0.900) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - _ = try await destStore.saveNodeStatusSnapshot( - timestamp: existingTimestamp, - nodePublicKey: nodePublicKey, - batteryMillivolts: 3800, - lastSNR: 8.5, - lastRSSI: -90, - noiseFloor: -112, - uptimeSeconds: 60, - rxAirtimeSeconds: nil, - packetsSent: nil, - packetsReceived: nil, - receiveErrors: nil - ) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let existingSnapshot = NodeStatusSnapshotDTO.testSnapshot( - timestamp: existingTimestamp, - nodePublicKey: nodePublicKey, - batteryMillivolts: 3800, - lastSNR: 8.5, - lastRSSI: -90, - noiseFloor: -112, - uptimeSeconds: 60 - ) - let importedSnapshot = NodeStatusSnapshotDTO.testSnapshot( - timestamp: importedTimestamp, - nodePublicKey: nodePublicKey, - batteryMillivolts: nil, - lastSNR: nil, - lastRSSI: nil, - noiseFloor: nil, - uptimeSeconds: nil, - telemetryEntries: [TelemetrySnapshotEntry(channel: 1, type: "temperature", value: 21.5)] - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - nodeStatusSnapshots: [existingSnapshot, importedSnapshot] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.nodeStatusSnapshotsInserted == 1) - #expect(result.nodeStatusSnapshotsSkipped == 1) - - let snapshots = try await destStore.fetchNodeStatusSnapshots(nodePublicKey: nodePublicKey, since: nil) - #expect(snapshots.count == 2) - #expect(snapshots.map(\.timestamp) == [existingTimestamp, importedTimestamp]) - #expect(snapshots.last?.telemetryEntries?.count == 1) - } - - // MARK: - Test 17: Failed import cleanup - - @Test("Successful import restores autosave on the destination store") - func successfulImport_RestoresAutosave() async throws { - let radioID = UUID() - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - await destStore.setAutosaveEnabledForTesting(true) - - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)] - ) - - let service = AppBackupService() - let result = try await service.importBackup( - envelope: envelope, - into: destStore - ) - - #expect(result.devicesInserted == 1) - #expect(await destStore.autosaveEnabledForTesting()) - #expect(!(await destStore.hasPendingChangesForTesting())) - #expect(try await destStore.fetchAllDevices().count == 1) - } - - @Test("Failed import rolls back reconcile-phase mutations to pre-existing rows") - func failedImport_RollsBackReconcileMutationsOnExistingRows() async throws { - let radioID = UUID() - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - await destStore.setAutosaveEnabledForTesting(true) - - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xC2, count: 32), - name: "Pre-existing" - ) - try await destStore.saveContact(contact) - // Pre-existing contacts always have lastMessageDate = nil out of the gate. - let baselineContacts = try await destStore.fetchAllContacts(radioID: radioID) - let preImportLastMessageDate = baselineContacts.first?.lastMessageDate - - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupMessage = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Reconcile target", - timestamp: 99999 - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [backupMessage] - ) - - await destStore.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } - - await #expect(throws: InjectedImportFailure.simulated) { - try await destStore.importBackupDatabase(envelope) - } - - let postImportContacts = try await destStore.fetchAllContacts(radioID: radioID) - #expect(postImportContacts.count == 1) - #expect(postImportContacts.first?.lastMessageDate == preImportLastMessageDate) - #expect(try await destStore.fetchAllMessages().isEmpty) - #expect(!(await destStore.hasPendingChangesForTesting())) - } - - @Test("Failed import clears pending data and restores autosave") - func failedImport_RollsBackPendingChangesAndRestoresAutosave() async throws { - let radioID = UUID() - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - await destStore.setAutosaveEnabledForTesting(true) - - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)] - ) - - await destStore.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } - - await #expect(throws: InjectedImportFailure.simulated) { - try await destStore.importBackupDatabase(envelope) - } - - #expect(await destStore.autosaveEnabledForTesting()) - #expect(!(await destStore.hasPendingChangesForTesting())) - #expect(try await destStore.fetchAllDevices().isEmpty) - - try await destStore.saveContact( - ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xC1, count: 32), - name: "Recovered Contact" - ) - ) - - let contacts = try await destStore.fetchAllContacts(radioID: radioID) - #expect(contacts.count == 1) - #expect(contacts.first?.name == "Recovered Contact") - } - - @Test("Disk-backed container has zero partial state after faulted import is abandoned") - func faultedImport_LeavesNoPartialStateOnDiskAfterReopen() async throws { - let storeURL = FileManager.default.temporaryDirectory - .appendingPathComponent("backup-crash-\(UUID().uuidString).store") - defer { - let fm = FileManager.default - try? fm.removeItem(at: storeURL) - try? fm.removeItem(at: storeURL.appendingPathExtension("shm")) - try? fm.removeItem(at: storeURL.appendingPathExtension("wal")) - } - - let radioID = UUID() - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], - contacts: [ - ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0x42, count: 32), - name: "Should not survive" - ) - ] - ) - - // Throw before save() to exercise the error path. With autosaveEnabled = false - // and save() never reached, nothing was flushed to SQLite — the defer's - // rollback() just clears the in-memory context. This is the error path, not a - // crash path: Swift's defer runs on throw, so a true bypassed-defer scenario - // would require a child process that exits mid-import. - do { - let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) - let container = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) - let store = PersistenceStore(modelContainer: container) - await store.setBackupImportFaultInjection { throw InjectedImportFailure.simulated } - await #expect(throws: InjectedImportFailure.simulated) { - try await store.importBackupDatabase(envelope) - } - } - - let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) - let reopened = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) - let freshStore = PersistenceStore(modelContainer: reopened) - - #expect(try await freshStore.fetchAllDevices().isEmpty) - #expect(try await freshStore.fetchAllContacts(radioID: radioID).isEmpty) - } - - @Test("Concurrent live-store writer during import preserves both datasets") - func concurrentLiveWrite_DuringImport_PreservesBoth() async throws { - // Two @ModelActor instances on the same ModelContainer simulate a radio - // connecting mid-import: the backup flow resolved a standalone - // PersistenceStore at T=0, then ConnectionManager stood up a second - // PersistenceStore on the same container to service the live link. - let sharedContainer = try PersistenceStore.createContainer(inMemory: true) - let backupStore = PersistenceStore(modelContainer: sharedContainer) - let liveStore = PersistenceStore(modelContainer: sharedContainer) - - let backupRadioID = UUID() - let liveRadioID = UUID() - let backupDevicePublicKey = Data(repeating: 0xB0, count: 32) - let liveDevicePublicKey = Data(repeating: 0xC0, count: 32) - - let backupContact = ContactDTO.testContact( - radioID: backupRadioID, - publicKey: Data(repeating: 0xB1, count: 32), - name: "From backup" - ) - let envelope = AppBackupEnvelope.test( - devices: [ - DeviceDTO.testDevice( - id: backupRadioID, - radioID: backupRadioID, - publicKey: backupDevicePublicKey - ) - ], - contacts: [backupContact] - ) - - try await liveStore.saveDevice( - DeviceDTO.testDevice( - id: liveRadioID, - radioID: liveRadioID, - publicKey: liveDevicePublicKey - ) - ) - let liveContact = ContactDTO.testContact( - radioID: liveRadioID, - publicKey: Data(repeating: 0xC1, count: 32), - name: "From connect" - ) - - async let importResult: ImportResult = backupStore.importBackupDatabase(envelope) - async let liveWrite: Void = liveStore.saveContact(liveContact) - - _ = try await importResult - try await liveWrite - - // A third actor guarantees we read through the persistent store rather than - // either writer's context cache — `fetchAllContacts` on the writers can miss - // the other actor's commits until the cache invalidates. - let verifier = PersistenceStore(modelContainer: sharedContainer) - let liveContacts = try await verifier.fetchAllContacts(radioID: liveRadioID) - #expect(liveContacts.contains { $0.publicKey == liveContact.publicKey }) - let backupContacts = try await verifier.fetchAllContacts(radioID: backupRadioID) - #expect(backupContacts.contains { $0.publicKey == backupContact.publicKey }) - - let allDevices = try await verifier.fetchAllDevices() - #expect(allDevices.count == 2) - } - - // MARK: - Test 18: Export assigns content-based keys to nil-keyed messages - - @Test("Export assigns content-based dedup keys to incoming messages with nil deduplicationKey") - func exportAssignsContentBasedKeysToNilKeyedMessages() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xF6, count: 32), - name: "Dan" - ) - try await sourceStore.saveContact(contact) - - // Save an incoming DM with nil deduplicationKey (simulates pre-migration message) - let dm = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Pre-migration DM", - timestamp: 12345, - direction: .incoming - ) - try await sourceStore.saveMessage(dm) - - // Save an incoming channel message with nil deduplicationKey - let chMsg = MessageDTO.testChannelMessage( - radioID: radioID, - channelIndex: 2, - text: "Pre-migration channel msg", - timestamp: 67890, - direction: .incoming, - senderNodeName: "Node1" - ) - try await sourceStore.saveMessage(chMsg) - - // Export - let service = AppBackupService() - let result = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: result.data) - - // Verify exported messages have content-based keys, not backup- - let exportedDM = try #require(envelope.messages.first { $0.contactID == contact.id }) - #expect(exportedDM.deduplicationKey?.hasPrefix("dm-") == true) - #expect(exportedDM.deduplicationKey?.hasPrefix("backup-") != true) - - let exportedCh = try #require(envelope.messages.first { $0.channelIndex == 2 }) - #expect(exportedCh.deduplicationKey?.hasPrefix("ch-") == true) - #expect(exportedCh.deduplicationKey?.hasPrefix("backup-") != true) - } - - // MARK: - Test 19: Export preserves existing content-based keys - - @Test("Export preserves existing content-based dedup keys unchanged") - func exportPreservesExistingContentBasedKeys() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact(radioID: radioID) - try await sourceStore.saveContact(contact) - - let existingKey = "dm-\(contact.id.uuidString)-99999-AABBCCDD" - var msg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Already has key" - ) - msg.deduplicationKey = existingKey - try await sourceStore.saveMessage(msg) - - let service = AppBackupService() - let result = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: result.data) - - let exported = try #require(envelope.messages.first) - #expect(exported.deduplicationKey == existingKey) - } - - // MARK: - Test 23: Duplicates within a single backup don't orphan their children - - /// Two incoming messages in the same envelope that share a content key — the - /// second is skipped (same wire packet seen twice), and its repeats/reactions - /// must be remapped to the first (winning) UUID. - @Test("Duplicate messages within one envelope: children of the skipped duplicate link to the inserted message") - func importDuplicateMessagesInEnvelope_ChildrenLinkToInsertedDuplicate() async throws { - let radioID = UUID() - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - - var firstMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Duplicate in envelope", - timestamp: 1_700_000_500, - direction: .incoming - ) - firstMsg.deduplicationKey = nil - - var secondMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Duplicate in envelope", - timestamp: 1_700_000_500, - direction: .incoming - ) - secondMsg.deduplicationKey = nil - #expect(firstMsg.id != secondMsg.id) - - // Children reference the second (to-be-skipped) message's UUID. - let repeatForSecond = MessageRepeatDTO.testRepeat( - messageID: secondMsg.id, - pathNodes: Data([0x11]) - ) - let reactionForSecond = ReactionDTO.testReaction( - messageID: secondMsg.id, - radioID: radioID, - emoji: "🌶️", - senderName: "Dup" - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [firstMsg, secondMsg], - messageRepeats: [repeatForSecond], - reactions: [reactionForSecond] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.messagesInserted == 1) - #expect(result.messagesSkipped == 1) - #expect(result.messageRepeatsInserted == 1) - #expect(result.reactionsInserted == 1) - - // Children must attach to the first (winning) UUID; the second UUID must have no rows. - let repeatsUnderFirst = try await destStore.fetchMessageRepeats(messageID: firstMsg.id) - #expect(repeatsUnderFirst.count == 1) - let orphanedRepeats = try await destStore.fetchMessageRepeats(messageID: secondMsg.id) - #expect(orphanedRepeats.isEmpty) - - let winner = try #require(await destStore.fetchMessage(id: firstMsg.id)) - #expect(winner.heardRepeats == 1) - #expect(winner.reactionSummary == "🌶️:1") - } - - // MARK: - Test 24: Cancellation after DB commit reports success, not cancelled - - /// A task cancelled between the DB commit and the rest of `importBackup` - /// must not throw CancellationError. The DB write has already landed, so - /// a throw here would report cancellation while the database actually - /// persisted. - @Test("Task cancellation after DB commit does not throw and returns a successful result") - func cancellationAfterCommit_ImportReturnsSuccess() async throws { - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice()] - ) - - // Post-commit hook cancels the task that's running the import (the - // child Task below), mirroring the race where the user taps Cancel - // mid-save. Running the import in a child Task keeps the outer test's - // cancellation state clean so post-import fetches can run. - await destStore.setBackupImportPostCommitHook { - withUnsafeCurrentTask { $0?.cancel() } - } - - let service = AppBackupService() - let importTask = Task { - try await service.importBackup(envelope: envelope, into: destStore) - } - - let result: ImportResult - do { - result = try await importTask.value - } catch { - Issue.record("Import after post-commit cancellation should succeed, got: \(error)") - return - } - - #expect(result.devicesInserted == 1) - #expect(try await destStore.fetchAllDevices().count == 1) - } - - // MARK: - Test 25: userDefaultsRestored reflects actual writes - - /// When every UserDefaults key carried in the backup is already set - /// locally, `restore(to:)` writes nothing. The import result must then - /// report `userDefaultsRestored == false`, otherwise a second no-op - /// import would claim `hasRestoredChanges` with nothing actually changed. - @Test("Import reports userDefaultsRestored=false when no new keys were written") - func importWithAllDefaultsAlreadySet_ReportsRestoredFalse() async throws { - let key = "hasCompletedOnboarding" - let defaults = UserDefaults.standard - let originalValue = defaults.object(forKey: key) - defaults.set(true, forKey: key) - defer { - if let value = originalValue { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - - var backupDefaults = BackupUserDefaults() - backupDefaults.hasCompletedOnboarding = true - - let envelope = AppBackupEnvelope.test( - devices: [DeviceDTO.testDevice()], - userDefaults: backupDefaults - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(!result.userDefaultsRestored) - } - - // MARK: - Test 26: Fresh-store import — contact unread counts preserved - - @Test("Fresh-insert import preserves contact unread counts from backup") - func importContact_FreshInsertPreservesUnreadCount() async throws { - let radioID = UUID() - let publicKey = Data(repeating: 0xE5, count: 32) - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupContact = ContactDTO.testContact( - radioID: radioID, - publicKey: publicKey, - unreadCount: 7, - unreadMentionCount: 2 - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [backupContact] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let service = AppBackupService() - - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.contactsInserted == 1) - #expect(result.contactsSkipped == 0) - - let importedContact = try #require( - await destStore.fetchContact(radioID: radioID, publicKey: publicKey) - ) - #expect(importedContact.unreadCount == 7) - #expect(importedContact.unreadMentionCount == 2) - } - - // MARK: - Test 27: Fresh-store import — channel unread counts preserved - - @Test("Fresh-insert import preserves channel unread counts from backup") - func importChannel_FreshInsertPreservesUnreadCount() async throws { - let radioID = UUID() - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - radioID: radioID, - index: 4, - unreadCount: 3, - unreadMentionCount: 1 - ) - - let envelope = AppBackupEnvelope.test( - devices: [device], - channels: [backupChannel] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let service = AppBackupService() - - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 1) - #expect(result.channelsSkipped == 0) - - let importedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 4)) - #expect(importedChannel.unreadCount == 3) - #expect(importedChannel.unreadMentionCount == 1) - } - - // MARK: - Test 28: Merge import — contact unread counts max with local - - @Test("Merge import keeps local contact unread counts when they exceed backup values") - func importOntoExistingContact_UnreadMergesUsingMax() async throws { - let radioID = UUID() - let publicKey = Data(repeating: 0xE6, count: 32) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingContact = ContactDTO.testContact( - radioID: radioID, - publicKey: publicKey, - unreadCount: 9, - unreadMentionCount: 4 - ) - try await destStore.saveContact(existingContact) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupContact = ContactDTO.testContact( - radioID: radioID, - publicKey: publicKey, - unreadCount: 2, - unreadMentionCount: 1 - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - contacts: [backupContact] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.contactsInserted == 0) - #expect(result.contactsSkipped == 1) - - let mergedContact = try #require( - await destStore.fetchContact(radioID: radioID, publicKey: publicKey) - ) - #expect(mergedContact.unreadCount == 9) - #expect(mergedContact.unreadMentionCount == 4) - } - - // MARK: - Test 29: Merge import — channel unread counts max with local - - @Test("Merge import keeps local channel unread counts when they exceed backup values") - func importOntoExistingChannel_UnreadMergesUsingMax() async throws { - let radioID = UUID() - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingChannel = ChannelDTO.testChannel( - radioID: radioID, - index: 5, - unreadCount: 6, - unreadMentionCount: 3 - ) - try await destStore.saveChannel(existingChannel) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupChannel = ChannelDTO.testChannel( - radioID: radioID, - index: 5, - unreadCount: 1, - unreadMentionCount: 0 - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - channels: [backupChannel] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.channelsInserted == 0) - #expect(result.channelsSkipped == 1) - - let mergedChannel = try #require(await destStore.fetchChannel(radioID: radioID, index: 5)) - #expect(mergedChannel.unreadCount == 6) - #expect(mergedChannel.unreadMentionCount == 3) - } - - // MARK: - Test 30: Merge import — remote session unread count max with local - - @Test("Merge import keeps local remote-session unread count when it exceeds backup value") - func importOntoExistingRemoteSession_UnreadMergesUsingMax() async throws { - let radioID = UUID() - let publicKey = Data(repeating: 0xE7, count: 32) - - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let existingSession = RemoteNodeSessionDTO.testSession( - radioID: radioID, - publicKey: publicKey, - unreadCount: 8 - ) - try await destStore.saveRemoteNodeSessionDTO(existingSession) - - let backupDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) - let backupSession = RemoteNodeSessionDTO.testSession( - id: UUID(), - radioID: radioID, - publicKey: publicKey, - unreadCount: 2 - ) - - let envelope = AppBackupEnvelope.test( - devices: [backupDevice], - remoteNodeSessions: [backupSession] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.remoteNodeSessionsInserted == 0) - #expect(result.remoteNodeSessionsSkipped == 1) - - let mergedSession = try #require(await destStore.fetchRemoteNodeSession(id: existingSession.id)) - #expect(mergedSession.unreadCount == 8) - } - - // MARK: - Outgoing duplicates must not collapse - - /// A user who double-taps send (or retries within the same UInt32 second) ends up - /// with two outgoing rows that share recipient + text + timestamp. They are two - /// distinct intentional actions, and backup/restore must keep both. - @Test("Outgoing messages with identical recipient/text/timestamp survive round-trip without deduplication") - func outgoingDuplicates_PreservedAcrossRoundTrip() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - try await sourceStore.saveContact(contact) - - let sharedTimestamp: UInt32 = 1_700_001_234 - - let firstMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "ok", - timestamp: sharedTimestamp, - direction: .outgoing - ) - let secondMsg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "ok", - timestamp: sharedTimestamp, - direction: .outgoing - ) - #expect(firstMsg.id != secondMsg.id) - try await sourceStore.saveMessage(firstMsg) - try await sourceStore.saveMessage(secondMsg) - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.messagesInserted == 2) - #expect(result.messagesSkipped == 0) - - let destMessages = try await destStore.fetchAllMessages(radioID: radioID) - #expect(destMessages.count == 2) - #expect(Set(destMessages.map(\.id)) == Set([firstMsg.id, secondMsg.id])) - } - - // MARK: - Repeat rows through the same route must not collapse - - /// `MessageRepeat` distinguishes hearings by `id`/`rxLogEntryID`. Two observations - /// of the same message through the same path (e.g. a flood echo) are genuinely - /// distinct and must both survive round-trip so `heardRepeats` stays accurate. - @Test("Repeats with identical path but distinct ids survive round-trip and heardRepeats is recomputed") - func repeatsOnSamePath_PreservedAcrossRoundTrip() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - try await sourceStore.saveContact(contact) - - var msg = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "echoed", - direction: .outgoing - ) - msg.deduplicationKey = nil - try await sourceStore.saveMessage(msg) - - let sharedPath = Data([0x42]) - let firstRepeat = MessageRepeatDTO.testRepeat( - messageID: msg.id, - pathNodes: sharedPath, - rxLogEntryID: UUID() - ) - let secondRepeat = MessageRepeatDTO.testRepeat( - messageID: msg.id, - pathNodes: sharedPath, - rxLogEntryID: UUID() - ) - #expect(firstRepeat.id != secondRepeat.id) - try await sourceStore.saveMessageRepeat(firstRepeat) - try await sourceStore.saveMessageRepeat(secondRepeat) - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.messageRepeatsInserted == 2) - #expect(result.messageRepeatsSkipped == 0) - - let restoredRepeats = try await destStore.fetchMessageRepeats(messageID: msg.id) - #expect(restoredRepeats.count == 2) - #expect(Set(restoredRepeats.map(\.id)) == Set([firstRepeat.id, secondRepeat.id])) - - let restoredMsg = try #require(await destStore.fetchMessage(id: msg.id)) - #expect(restoredMsg.heardRepeats == 2) - } - - // MARK: - Reply chain remap - - /// When a backup's replied-to parent already exists locally (content-keyed merge), - /// the reply's replyToID must be rewritten onto the local parent UUID — otherwise - /// reply navigation dangles on the pre-merge backup UUID. - @Test("Reply remaps onto local parent when parent is merged during import") - func replyRemapOntoMergedParent() async throws { - let radioID = UUID() - let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(radioID: radioID) - try await destStore.saveContact(contact) - - var existingParent = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Parent", - direction: .incoming - ) - existingParent.deduplicationKey = "reply-remap-parent" - try await destStore.saveMessage(existingParent) - - // Backup contains the same parent (will be skipped/merged) and a reply - // whose replyToID points at the backup-side parent UUID. - let device = DeviceDTO.testDevice(id: radioID, radioID: radioID) - var backupParent = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Parent", - direction: .incoming - ) - backupParent.deduplicationKey = "reply-remap-parent" - #expect(backupParent.id != existingParent.id) - - var reply = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contact.id, - text: "Reply", - direction: .incoming, - replyToID: backupParent.id - ) - reply.deduplicationKey = "reply-remap-reply" - - let envelope = AppBackupEnvelope.test( - devices: [device], - contacts: [contact], - messages: [backupParent, reply] - ) - - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - #expect(result.messagesSkipped == 1) - #expect(result.messagesInserted == 1) - - let destMessages = try await destStore.fetchAllMessages(radioID: radioID) - let restoredReply = try #require(destMessages.first { $0.id == reply.id }) - #expect(restoredReply.replyToID == existingParent.id) - } - - // MARK: - Cross-radio channel message preservation - - /// Two companion radios often receive the same over-the-air channel packet. The stored - /// live-sync deduplicationKey is radio-agnostic by construction, so the backup-reconciliation - /// key must be scoped by radioID. Otherwise the import path collapses both rows into one - /// and the second companion's channel view renders blank after restore — the same failure - /// mode fixed in live sync (issue #288) leaking back through backup/restore. - @Test("Backup restores the same channel packet under each companion radio that received it") - func crossRadioChannelMessagesBothSurviveImport() async throws { - let radioA = UUID() - let radioB = UUID() - let deviceA = DeviceDTO.testDevice( - id: radioA, - radioID: radioA, - publicKey: Data(repeating: 0xAA, count: 32), - nodeName: "CompanionA" - ) - let deviceB = DeviceDTO.testDevice( - id: radioB, - radioID: radioB, - publicKey: Data(repeating: 0xBB, count: 32), - nodeName: "CompanionB" - ) - let channelA = ChannelDTO.testChannel(radioID: radioA, index: 0, name: "General") - let channelB = ChannelDTO.testChannel(radioID: radioB, index: 0, name: "General") - - // Same wire packet — identical content-based dedup key — seen by both companions. - let sharedDedupKey = "ch-0-1700000000-Alice-A1B2C3D4" - var msgFromA = MessageDTO.testChannelMessage( - radioID: radioA, - channelIndex: 0, - text: "aaaaa", - timestamp: 1_700_000_000, - direction: .incoming, - status: .delivered, - senderNodeName: "Alice" - ) - msgFromA.deduplicationKey = sharedDedupKey - - var msgFromB = MessageDTO.testChannelMessage( - radioID: radioB, - channelIndex: 0, - text: "aaaaa", - timestamp: 1_700_000_000, - direction: .incoming, - status: .delivered, - senderNodeName: "Alice" - ) - msgFromB.deduplicationKey = sharedDedupKey - - let envelope = AppBackupEnvelope.test( - devices: [deviceA, deviceB], - channels: [channelA, channelB], - messages: [msgFromA, msgFromB] - ) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - let service = AppBackupService() - let result = try await service.importBackup(envelope: envelope, into: destStore) - - #expect(result.messagesInserted == 2, - "Both companions' copies of the same channel packet must be restored — otherwise the second companion's channel view goes blank post-restore") - #expect(result.messagesSkipped == 0) - - let messagesForA = try await destStore.fetchAllMessages(radioID: radioA) - let messagesForB = try await destStore.fetchAllMessages(radioID: radioB) - #expect(messagesForA.count == 1) - #expect(messagesForB.count == 1) - #expect(messagesForA.first?.text == "aaaaa") - #expect(messagesForB.first?.text == "aaaaa") - } - - // MARK: - regionSelection backup contract - - @Test("regionSelection round-trips through encode/decode") - func regionSelectionRoundTrips() throws { - var prefs = BackupUserDefaults() - prefs.regionSelection = RegionSelection( - countryCode: "US", - administrativeAreaCode: "US-CA", - countyKey: "los angeles", - source: .location - ) - let data = try JSONEncoder().encode(prefs) - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) - #expect(decoded.regionSelection == prefs.regionSelection) - } - - @Test("Legacy envelope without regionSelection decodes as nil") - func legacyEnvelopeDecodesRegionAsNil() throws { - let legacyJSON = """ - { - "hasCompletedOnboarding": true, - "mapStyleSelection": "topo" - } - """.data(using: .utf8)! - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) - #expect(decoded.regionSelection == nil) - #expect(decoded.hasCompletedOnboarding == true) - } - - @Test("restore writes regionSelection only when local key is missing") - func restoreRespectsExistingValue() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - let local = RegionSelection(countryCode: "DE", source: .manual) - defaults.set(try JSONEncoder().encode(local), forKey: BackupUserDefaults.regionSelectionKey) - - var prefs = BackupUserDefaults() - prefs.regionSelection = RegionSelection(countryCode: "US", source: .location) - let setKeys = prefs.restore(to: defaults) - #expect(!setKeys.contains(BackupUserDefaults.regionSelectionKey)) - - let stillThere = try JSONDecoder().decode( - RegionSelection.self, - from: defaults.data(forKey: BackupUserDefaults.regionSelectionKey)! - ) - #expect(stillThere == local) - } - - @Test("restore writes regionSelection when local is missing (fresh install)") - func restoreWritesWhenMissing() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - var prefs = BackupUserDefaults() - prefs.regionSelection = RegionSelection(countryCode: "US", source: .location) - let setKeys = prefs.restore(to: defaults) - - #expect(setKeys.contains(BackupUserDefaults.regionSelectionKey)) - let restored = try JSONDecoder().decode( - RegionSelection.self, - from: defaults.data(forKey: BackupUserDefaults.regionSelectionKey)! - ) - #expect(restored == prefs.regionSelection) - } - - // MARK: - Message.regionScope round-trip - - @Test("Message.regionScope round-trips through full export/import") - func messageRegionScopeRoundTrip() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Data(repeating: 0xAB, count: 32), - name: "Alice" - ) - try await sourceStore.saveContact(contact) - - var msg = MessageDTO.testDirectMessage(radioID: radioID, contactID: contact.id, text: "Greetings") - msg.regionScope = "Germany" - msg.deduplicationKey = "region-scope-roundtrip-\(UUID())" - try await sourceStore.saveMessage(msg) - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - _ = try await service.importBackup(envelope: envelope, into: destStore) - - let restored = try await destStore.fetchAllMessages(radioID: radioID) - #expect(restored.count == 1) - #expect(restored.first?.regionScope == "Germany") - } - - @Test("MessageDTO Codable: regionScope set decodes round-trip") - func messageDTORegionScopeCodableRoundTrip() throws { - var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Test") - dto.regionScope = "Bavaria" - - let encoded = try JSONEncoder().encode(dto) - let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) - #expect(decoded.regionScope == "Bavaria") - } - - @Test("Legacy MessageDTO envelope without regionScope decodes as nil") - func messageDTOLegacyEnvelopeMissingRegionScope() throws { - let baseDTO = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Legacy") - let encoded = try JSONEncoder().encode(baseDTO) - var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - json.removeValue(forKey: "regionScope") - - let stripped = try JSONSerialization.data(withJSONObject: json) - let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) - #expect(decoded.regionScope == nil) - } - - @Test("Message(dto:) forwards regionScope verbatim through DTO to model") - func messageInitFromDTOForwardsRegionScope() { - var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Forward") - dto.regionScope = "USA" - let model = Message(dto: dto) - #expect(model.regionScope == "USA") - - var nilDTO = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "NilCase") - nilDTO.regionScope = nil - let nilModel = Message(dto: nilDTO) - #expect(nilModel.regionScope == nil) - } - - // MARK: - MessageDTO.sortDate round-trip - - @Test("MessageDTO Codable: sortDate distinct from createdAt round-trips") - func messageDTOSortDateCodableRoundTrip() throws { - let createdAt = Date(timeIntervalSince1970: 1_700_000_000) - let sortDate = Date(timeIntervalSince1970: 1_600_000_000) - var dto = MessageDTO.testDirectMessage( - radioID: UUID(), - contactID: UUID(), - text: "Test", - createdAt: createdAt - ) - dto.sortDate = sortDate - - let encoded = try JSONEncoder().encode(dto) - let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) - #expect(decoded.sortDate == sortDate) - #expect(decoded.sortDate != decoded.createdAt) - } - - @Test("Legacy MessageDTO envelope without sortDate falls back to createdAt") - func messageDTOLegacyEnvelopeMissingSortDate() throws { - let baseDTO = MessageDTO.testDirectMessage( - radioID: UUID(), - contactID: UUID(), - text: "Legacy", - createdAt: Date(timeIntervalSince1970: 1_700_000_000) - ) - let encoded = try JSONEncoder().encode(baseDTO) - var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - json.removeValue(forKey: "sortDate") - - let stripped = try JSONSerialization.data(withJSONObject: json) - let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) - #expect(decoded.sortDate == decoded.createdAt) - } - - @Test("Message(dto:) forwards sortDate verbatim through DTO to model") - func messageInitFromDTOForwardsSortDate() { - let createdAt = Date(timeIntervalSince1970: 1_700_000_000) - let sortDate = Date(timeIntervalSince1970: 1_600_000_000) - var dto = MessageDTO.testDirectMessage( - radioID: UUID(), - contactID: UUID(), - text: "Forward", - createdAt: createdAt - ) - dto.sortDate = sortDate - let model = Message(dto: dto) - #expect(model.sortDate == sortDate) - #expect(model.sortDate != model.createdAt) - } - - @Test("Fully-populated MessageDTO survives encode/decode with every field distinct") - func messageDTOFullyPopulatedCodableRoundTrip() throws { - let dto = MessageDTO( - id: UUID(), - radioID: UUID(), - contactID: UUID(), - channelIndex: 7, - text: "Every field set", - timestamp: 1_700_000_001, - createdAt: Date(timeIntervalSince1970: 1_700_000_000), - direction: .incoming, - status: .delivered, - textType: .signedPlain, - ackCode: 0xDEAD_BEEF, - pathLength: 5, - snr: 12.5, - pathNodes: Data([0x01, 0x02, 0x03]), - senderKeyPrefix: Data([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), - senderNodeName: "Bob", - isRead: true, - replyToID: UUID(), - roundTripTime: 432, - heardRepeats: 3, - sendCount: 2, - retryAttempt: 1, - maxRetryAttempts: 4, - deduplicationKey: "dedup-key", - linkPreviewURL: "https://example.com", - linkPreviewTitle: "Example", - linkPreviewImageData: Data([0x10, 0x20]), - linkPreviewIconData: Data([0x30, 0x40]), - linkPreviewFetched: true, - containsSelfMention: true, - mentionSeen: true, - timestampCorrected: true, - senderTimestamp: 1_699_999_999, - reactionSummary: "👍:3,❤️:2", - routeType: .tcDirect, - regionScope: "Germany" - ) - var populated = dto - populated.sortDate = Date(timeIntervalSince1970: 1_600_000_000) - - let encoded = try JSONEncoder().encode(populated) - let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) - #expect(decoded == populated) - } - - @Test("saveMessage persists send metadata and preview fields through export and import") - func saveMessagePreservesMetadataThroughBackupRoundTrip() async throws { - let radioID = UUID() - let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - var msg = MessageDTO.testDirectMessage(radioID: radioID, sendCount: 3) - msg.deduplicationKey = "save-message-fields-\(UUID())" - msg.linkPreviewURL = "https://example.com" - msg.linkPreviewTitle = "Example" - msg.reactionSummary = "👍:2" - try await sourceStore.saveMessage(msg) - - // saveMessage routes through Message(dto:), so the live row keeps every DTO field. - let savedRow = try #require(await sourceStore.fetchMessage(id: msg.id)) - #expect(savedRow.sendCount == 3) - #expect(savedRow.linkPreviewURL == "https://example.com") - #expect(savedRow.linkPreviewTitle == "Example") - #expect(savedRow.reactionSummary == "👍:2") - - let service = AppBackupService() - let exportResult = try await service.export(persistenceStore: sourceStore) - let envelope = try parseBackup(data: exportResult.data) - - let destContainer = try PersistenceStore.createContainer(inMemory: true) - let destStore = PersistenceStore(modelContainer: destContainer) - _ = try await service.importBackup(envelope: envelope, into: destStore) - - let imported = try #require(await destStore.fetchMessage(id: msg.id)) - #expect(imported.sendCount == 3) - #expect(imported.linkPreviewURL == "https://example.com") - #expect(imported.linkPreviewTitle == "Example") - #expect(imported.reactionSummary == "👍:2") - } - - // MARK: - selectedThemeID backup contract - - @Test("selectedThemeID round-trips through encode/decode") - func selectedThemeIDRoundTrips() throws { - var prefs = BackupUserDefaults() - prefs.selectedThemeID = "ember" - let data = try JSONEncoder().encode(prefs) - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) - #expect(decoded.selectedThemeID == "ember") - } - - @Test("Legacy envelope without selectedThemeID decodes as nil") - func legacyEnvelopeDecodesSelectedThemeIDAsNil() throws { - let legacyJSON = """ - { "hasCompletedOnboarding": true, "mapStyleSelection": "topo" } - """.data(using: .utf8)! - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) - #expect(decoded.selectedThemeID == nil) - #expect(decoded.hasCompletedOnboarding == true) - } - - @Test("restore writes selectedThemeID only when local key is missing") - func restoreRespectsExistingSelectedThemeID() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - defaults.set("marine", forKey: PersistenceKeys.selectedThemeID) - var prefs = BackupUserDefaults() - prefs.selectedThemeID = "ember" - let setKeys = prefs.restore(to: defaults) - #expect(!setKeys.contains(PersistenceKeys.selectedThemeID)) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == "marine") - } - - @Test("restore writes selectedThemeID when local is missing (fresh install)") - func restoreWritesSelectedThemeIDWhenMissing() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - var prefs = BackupUserDefaults() - prefs.selectedThemeID = "ember" - let setKeys = prefs.restore(to: defaults) - #expect(setKeys.contains(PersistenceKeys.selectedThemeID)) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == "ember") - } - - // MARK: - appColorSchemePreference backup contract - - @Test("appColorSchemePreference round-trips through encode/decode") - func appColorSchemePreferenceRoundTrips() throws { - var prefs = BackupUserDefaults() - prefs.appColorSchemePreference = "dark" - let data = try JSONEncoder().encode(prefs) - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: data) - #expect(decoded.appColorSchemePreference == "dark") - } - - @Test("Legacy envelope without appColorSchemePreference decodes as nil") - func legacyEnvelopeDecodesAppColorSchemePreferenceAsNil() throws { - let legacyJSON = """ - { "hasCompletedOnboarding": true, "mapStyleSelection": "topo" } - """.data(using: .utf8)! - let decoded = try JSONDecoder().decode(BackupUserDefaults.self, from: legacyJSON) - #expect(decoded.appColorSchemePreference == nil) - } - - @Test("restore writes appColorSchemePreference only when local key is missing") - func restoreRespectsExistingAppColorSchemePreference() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - defaults.set("light", forKey: PersistenceKeys.appColorSchemePreference) - var prefs = BackupUserDefaults() - prefs.appColorSchemePreference = "dark" - let setKeys = prefs.restore(to: defaults) - #expect(!setKeys.contains(PersistenceKeys.appColorSchemePreference)) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "light") - } - - @Test("restore writes appColorSchemePreference when local is missing (fresh install)") - func restoreWritesAppColorSchemePreferenceWhenMissing() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - var prefs = BackupUserDefaults() - prefs.appColorSchemePreference = "dark" - let setKeys = prefs.restore(to: defaults) - #expect(setKeys.contains(PersistenceKeys.appColorSchemePreference)) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "dark") - } - - // MARK: - Export read path (snapshot) for the new appearance keys - - @Test("snapshot reads selectedThemeID and appColorSchemePreference from UserDefaults") - func snapshotReadsAppearanceKeys() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - defaults.set("marine", forKey: PersistenceKeys.selectedThemeID) - defaults.set("dark", forKey: PersistenceKeys.appColorSchemePreference) - let snapshot = BackupUserDefaults.snapshot(from: defaults) - #expect(snapshot.selectedThemeID == "marine") - #expect(snapshot.appColorSchemePreference == "dark") - } - - @Test("snapshot leaves appearance keys nil when unset") - func snapshotLeavesAppearanceKeysNilWhenUnset() throws { - let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - let snapshot = BackupUserDefaults.snapshot(from: defaults) - #expect(snapshot.selectedThemeID == nil) - #expect(snapshot.appColorSchemePreference == nil) - } -} - -private enum InjectedImportFailure: Error { - case simulated -} - -private extension PersistenceStore { - func setAutosaveEnabledForTesting(_ isEnabled: Bool) { - modelContext.autosaveEnabled = isEnabled - } - - func autosaveEnabledForTesting() -> Bool { - modelContext.autosaveEnabled - } - - func hasPendingChangesForTesting() -> Bool { - modelContext.hasChanges - } + func hasPendingChangesForTesting() -> Bool { + modelContext.hasChanges + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BackupUserDefaultsCoverageTests.swift b/MC1Services/Tests/MC1ServicesTests/BackupUserDefaultsCoverageTests.swift index 908a54b7..b4167d9d 100644 --- a/MC1Services/Tests/MC1ServicesTests/BackupUserDefaultsCoverageTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BackupUserDefaultsCoverageTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import MC1Services +import Testing /// Coverage tests for `BackupUserDefaults`. Asserts every `Bool?`/`String?` property /// has a matching mapping row, and every `Optional<*>` property is covered by a @@ -23,93 +23,92 @@ import Testing /// test must be revisited. @Suite("BackupUserDefaultsCoverage") struct BackupUserDefaultsCoverageTests { - - @Test("Every Bool? property has a boolMappings row") - func everyBoolPropertyHasMapping() { - let propertyNames = optionalPropertyNames(matching: Optional.self) - let mappingKeys = BackupUserDefaults.boolMappingKeys - - let missing = propertyNames.subtracting(mappingKeys) - let extra = mappingKeys.subtracting(propertyNames) - - #expect(missing.isEmpty, """ - Bool? properties without boolMappings rows: \(missing.sorted()). - Add (\\., "") to BackupUserDefaults.boolMappings, - or hand-roll a special-cased branch in snapshot/restore (see regionSelection). - See BackupUserDefaults.boolMappings. - """) - - #expect(extra.isEmpty, """ - boolMappings rows without matching Bool? properties: \(extra.sorted()). - Likely a stale entry after a property rename or removal. - See BackupUserDefaults.boolMappings. - """) - } - - @Test("Every String? property has a stringMappings row") - func everyStringPropertyHasMapping() { - let propertyNames = optionalPropertyNames(matching: Optional.self) - let mappingKeys = BackupUserDefaults.stringMappingKeys - - let missing = propertyNames.subtracting(mappingKeys) - let extra = mappingKeys.subtracting(propertyNames) - - #expect(missing.isEmpty, """ - String? properties without stringMappings rows: \(missing.sorted()). - Add (\\., "") to BackupUserDefaults.stringMappings, - or hand-roll a special-cased branch in snapshot/restore. - See BackupUserDefaults.stringMappings. - """) - - #expect(extra.isEmpty, """ - stringMappings rows without matching String? properties: \(extra.sorted()). - Likely a stale entry after a property rename or removal. - See BackupUserDefaults.stringMappings. - """) - } - - @Test("Every Optional property is covered by a mapping or special-cased") - func everyOptionalPropertyIsCovered() { - let allOptional = allOptionalPropertyNames() - let covered = BackupUserDefaults.boolMappingKeys - .union(BackupUserDefaults.stringMappingKeys) - .union(BackupUserDefaults.specialCasedPropertyNames) - - let uncovered = allOptional.subtracting(covered) - let staleAllowList = BackupUserDefaults.specialCasedPropertyNames.subtracting(allOptional) - - #expect(uncovered.isEmpty, """ - Optional properties not covered by any mapping or allow-list: \(uncovered.sorted()). - Either: - (a) add to boolMappings/stringMappings if Bool?/String?, or - (b) hand-roll snapshot/restore branches and add to specialCasedPropertyNames. - See BackupUserDefaults.boolMappings / stringMappings / specialCasedPropertyNames. - """) - - #expect(staleAllowList.isEmpty, """ - specialCasedPropertyNames entries without matching Optional properties: \(staleAllowList.sorted()). - Likely a stale allow-list entry after a property rename or removal. - See BackupUserDefaults.specialCasedPropertyNames. - """) - } - - // MARK: - Mirror helpers - - private func optionalPropertyNames(matching: T.Type) -> Set { - let mirror = Mirror(reflecting: BackupUserDefaults()) - return Set(mirror.children.compactMap { child -> String? in - guard let label = child.label, - type(of: child.value) == T.self else { return nil } - return label - }) - } - - private func allOptionalPropertyNames() -> Set { - let mirror = Mirror(reflecting: BackupUserDefaults()) - return Set(mirror.children.compactMap { child -> String? in - guard let label = child.label, - Mirror(reflecting: child.value).displayStyle == .optional else { return nil } - return label - }) - } + @Test + func `Every Bool? property has a boolMappings row`() { + let propertyNames = optionalPropertyNames(matching: Bool?.self) + let mappingKeys = BackupUserDefaults.boolMappingKeys + + let missing = propertyNames.subtracting(mappingKeys) + let extra = mappingKeys.subtracting(propertyNames) + + #expect(missing.isEmpty, """ + Bool? properties without boolMappings rows: \(missing.sorted()). + Add (\\., "") to BackupUserDefaults.boolMappings, + or hand-roll a special-cased branch in snapshot/restore (see regionSelection). + See BackupUserDefaults.boolMappings. + """) + + #expect(extra.isEmpty, """ + boolMappings rows without matching Bool? properties: \(extra.sorted()). + Likely a stale entry after a property rename or removal. + See BackupUserDefaults.boolMappings. + """) + } + + @Test + func `Every String? property has a stringMappings row`() { + let propertyNames = optionalPropertyNames(matching: String?.self) + let mappingKeys = BackupUserDefaults.stringMappingKeys + + let missing = propertyNames.subtracting(mappingKeys) + let extra = mappingKeys.subtracting(propertyNames) + + #expect(missing.isEmpty, """ + String? properties without stringMappings rows: \(missing.sorted()). + Add (\\., "") to BackupUserDefaults.stringMappings, + or hand-roll a special-cased branch in snapshot/restore. + See BackupUserDefaults.stringMappings. + """) + + #expect(extra.isEmpty, """ + stringMappings rows without matching String? properties: \(extra.sorted()). + Likely a stale entry after a property rename or removal. + See BackupUserDefaults.stringMappings. + """) + } + + @Test + func `Every Optional property is covered by a mapping or special-cased`() { + let allOptional = allOptionalPropertyNames() + let covered = BackupUserDefaults.boolMappingKeys + .union(BackupUserDefaults.stringMappingKeys) + .union(BackupUserDefaults.specialCasedPropertyNames) + + let uncovered = allOptional.subtracting(covered) + let staleAllowList = BackupUserDefaults.specialCasedPropertyNames.subtracting(allOptional) + + #expect(uncovered.isEmpty, """ + Optional properties not covered by any mapping or allow-list: \(uncovered.sorted()). + Either: + (a) add to boolMappings/stringMappings if Bool?/String?, or + (b) hand-roll snapshot/restore branches and add to specialCasedPropertyNames. + See BackupUserDefaults.boolMappings / stringMappings / specialCasedPropertyNames. + """) + + #expect(staleAllowList.isEmpty, """ + specialCasedPropertyNames entries without matching Optional properties: \(staleAllowList.sorted()). + Likely a stale allow-list entry after a property rename or removal. + See BackupUserDefaults.specialCasedPropertyNames. + """) + } + + // MARK: - Mirror helpers + + private func optionalPropertyNames(matching: T.Type) -> Set { + let mirror = Mirror(reflecting: BackupUserDefaults()) + return Set(mirror.children.compactMap { child -> String? in + guard let label = child.label, + type(of: child.value) == T.self else { return nil } + return label + }) + } + + private func allOptionalPropertyNames() -> Set { + let mirror = Mirror(reflecting: BackupUserDefaults()) + return Set(mirror.children.compactMap { child -> String? in + guard let label = child.label, + Mirror(reflecting: child.value).displayStyle == .optional else { return nil } + return label + }) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BlockedChannelSenderPersistenceTests.swift b/MC1Services/Tests/MC1ServicesTests/BlockedChannelSenderPersistenceTests.swift index 6e78dde8..cf50af2b 100644 --- a/MC1Services/Tests/MC1ServicesTests/BlockedChannelSenderPersistenceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BlockedChannelSenderPersistenceTests.swift @@ -1,152 +1,151 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("BlockedChannelSender Persistence Tests") struct BlockedChannelSenderPersistenceTests { + // MARK: - Test Helpers - // MARK: - Test Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private let radioID = UUID() - - // MARK: - Save & Fetch + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } - @Test("Save and fetch round-trip returns the blocked sender") - func saveAndFetchRoundTrip() async throws { - let store = try await createTestStore() - let dto = BlockedChannelSenderDTO(name: "Spammer", radioID: radioID) + private let radioID = UUID() - try await store.saveBlockedChannelSender(dto) - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + // MARK: - Save & Fetch - #expect(fetched.count == 1) - #expect(fetched.first?.name == "Spammer") - #expect(fetched.first?.radioID == radioID) - } + @Test + func `Save and fetch round-trip returns the blocked sender`() async throws { + let store = try await createTestStore() + let dto = BlockedChannelSenderDTO(name: "Spammer", radioID: radioID) + + try await store.saveBlockedChannelSender(dto) + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - // MARK: - Upsert + #expect(fetched.count == 1) + #expect(fetched.first?.name == "Spammer") + #expect(fetched.first?.radioID == radioID) + } - @Test("Re-saving same name updates dateBlocked instead of creating duplicate") - func upsertUpdateDateBlocked() async throws { - let store = try await createTestStore() - let earlier = Date.distantPast - let later = Date.now + // MARK: - Upsert - let first = BlockedChannelSenderDTO(name: "Troll", radioID: radioID, dateBlocked: earlier) - try await store.saveBlockedChannelSender(first) - - let second = BlockedChannelSenderDTO(name: "Troll", radioID: radioID, dateBlocked: later) - try await store.saveBlockedChannelSender(second) - - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(fetched.count == 1) - #expect(fetched.first?.dateBlocked == later) - } - - // MARK: - Delete - - @Test("Delete removes the blocked sender entry") - func deleteRemovesEntry() async throws { - let store = try await createTestStore() - let dto = BlockedChannelSenderDTO(name: "BadGuy", radioID: radioID) - try await store.saveBlockedChannelSender(dto) - - try await store.deleteBlockedChannelSender(radioID: radioID, name: "BadGuy") - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - - #expect(fetched.isEmpty) - } - - // MARK: - Device Scoping - - @Test("Fetch returns only senders blocked for the specified device") - func fetchScopesToDeviceID() async throws { - let store = try await createTestStore() - let otherDeviceID = UUID() - - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "Alice", radioID: radioID) - ) - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "Bob", radioID: otherDeviceID) - ) - - let device1Results = try await store.fetchBlockedChannelSenders(radioID: radioID) - let device2Results = try await store.fetchBlockedChannelSenders(radioID: otherDeviceID) - - #expect(device1Results.count == 1) - #expect(device1Results.first?.name == "Alice") - #expect(device2Results.count == 1) - #expect(device2Results.first?.name == "Bob") - } - - // MARK: - Case Insensitivity - - @Test("Name preserves original casing for display") - func namePreservesOriginalCasing() async throws { - let store = try await createTestStore() - let dto = BlockedChannelSenderDTO(name: "Alice", radioID: radioID) - try await store.saveBlockedChannelSender(dto) - - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(fetched.first?.name == "Alice") - } - - @Test("Saving same name with different case creates separate entries") - func caseSensitiveSave() async throws { - let store = try await createTestStore() - - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "Alice", radioID: radioID, dateBlocked: .distantPast) - ) - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "ALICE", radioID: radioID, dateBlocked: .now) - ) - - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(fetched.count == 2) - } - - @Test("Delete requires exact case match") - func caseSensitiveDelete() async throws { - let store = try await createTestStore() - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "Alice", radioID: radioID) - ) - - try await store.deleteBlockedChannelSender(radioID: radioID, name: "ALICE") - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - - #expect(fetched.count == 1) - #expect(fetched.first?.name == "Alice") - } - - // MARK: - Sort Order - - @Test("Fetch returns senders sorted by most recently blocked first") - func fetchSortedByDateBlockedDescending() async throws { - let store = try await createTestStore() - let oldest = Date(timeIntervalSince1970: 1_000_000) - let middle = Date(timeIntervalSince1970: 2_000_000) - let newest = Date(timeIntervalSince1970: 3_000_000) - - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "first", radioID: radioID, dateBlocked: oldest) - ) - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "second", radioID: radioID, dateBlocked: newest) - ) - try await store.saveBlockedChannelSender( - BlockedChannelSenderDTO(name: "third", radioID: radioID, dateBlocked: middle) - ) - - let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(fetched.map(\.name) == ["second", "third", "first"]) - } + @Test + func `Re-saving same name updates dateBlocked instead of creating duplicate`() async throws { + let store = try await createTestStore() + let earlier = Date.distantPast + let later = Date.now + + let first = BlockedChannelSenderDTO(name: "Troll", radioID: radioID, dateBlocked: earlier) + try await store.saveBlockedChannelSender(first) + + let second = BlockedChannelSenderDTO(name: "Troll", radioID: radioID, dateBlocked: later) + try await store.saveBlockedChannelSender(second) + + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(fetched.count == 1) + #expect(fetched.first?.dateBlocked == later) + } + + // MARK: - Delete + + @Test + func `Delete removes the blocked sender entry`() async throws { + let store = try await createTestStore() + let dto = BlockedChannelSenderDTO(name: "BadGuy", radioID: radioID) + try await store.saveBlockedChannelSender(dto) + + try await store.deleteBlockedChannelSender(radioID: radioID, name: "BadGuy") + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + + #expect(fetched.isEmpty) + } + + // MARK: - Device Scoping + + @Test + func `Fetch returns only senders blocked for the specified device`() async throws { + let store = try await createTestStore() + let otherDeviceID = UUID() + + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "Alice", radioID: radioID) + ) + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "Bob", radioID: otherDeviceID) + ) + + let device1Results = try await store.fetchBlockedChannelSenders(radioID: radioID) + let device2Results = try await store.fetchBlockedChannelSenders(radioID: otherDeviceID) + + #expect(device1Results.count == 1) + #expect(device1Results.first?.name == "Alice") + #expect(device2Results.count == 1) + #expect(device2Results.first?.name == "Bob") + } + + // MARK: - Case Insensitivity + + @Test + func `Name preserves original casing for display`() async throws { + let store = try await createTestStore() + let dto = BlockedChannelSenderDTO(name: "Alice", radioID: radioID) + try await store.saveBlockedChannelSender(dto) + + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(fetched.first?.name == "Alice") + } + + @Test + func `Saving same name with different case creates separate entries`() async throws { + let store = try await createTestStore() + + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "Alice", radioID: radioID, dateBlocked: .distantPast) + ) + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "ALICE", radioID: radioID, dateBlocked: .now) + ) + + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(fetched.count == 2) + } + + @Test + func `Delete requires exact case match`() async throws { + let store = try await createTestStore() + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "Alice", radioID: radioID) + ) + + try await store.deleteBlockedChannelSender(radioID: radioID, name: "ALICE") + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + + #expect(fetched.count == 1) + #expect(fetched.first?.name == "Alice") + } + + // MARK: - Sort Order + + @Test + func `Fetch returns senders sorted by most recently blocked first`() async throws { + let store = try await createTestStore() + let oldest = Date(timeIntervalSince1970: 1_000_000) + let middle = Date(timeIntervalSince1970: 2_000_000) + let newest = Date(timeIntervalSince1970: 3_000_000) + + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "first", radioID: radioID, dateBlocked: oldest) + ) + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "second", radioID: radioID, dateBlocked: newest) + ) + try await store.saveBlockedChannelSender( + BlockedChannelSenderDTO(name: "third", radioID: radioID, dateBlocked: middle) + ) + + let fetched = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(fetched.map(\.name) == ["second", "third", "first"]) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BlockedSenderMessageDeletionTests.swift b/MC1Services/Tests/MC1ServicesTests/BlockedSenderMessageDeletionTests.swift index 03f75c74..64c67615 100644 --- a/MC1Services/Tests/MC1ServicesTests/BlockedSenderMessageDeletionTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BlockedSenderMessageDeletionTests.swift @@ -1,231 +1,230 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("Blocked Sender Message Deletion Tests") struct BlockedSenderMessageDeletionTests { - - // MARK: - Test Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private let radioID = UUID() - - private func makeChannelMessage( - radioID: UUID, - senderName: String, - channelIndex: UInt8 = 0, - text: String = "test" - ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: radioID, - contactID: nil, - channelIndex: channelIndex, - text: text, - timestamp: UInt32(Date().timeIntervalSince1970), - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: senderName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - } - - private func makeDirectMessage( - radioID: UUID, - senderName: String, - contactID: UUID = UUID() - ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: radioID, - contactID: contactID, - channelIndex: nil, - text: "dm", - timestamp: UInt32(Date().timeIntervalSince1970), - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: senderName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - } - - // MARK: - Tests - - @Test("Deletes all channel messages from a named sender") - func deletesAllChannelMessagesFromSender() async throws { - let store = try await createTestStore() - - let spam1 = makeChannelMessage(radioID: radioID, senderName: "Spammer", channelIndex: 0) - let spam2 = makeChannelMessage(radioID: radioID, senderName: "Spammer", channelIndex: 1) - let legit = makeChannelMessage(radioID: radioID, senderName: "Legit", channelIndex: 0) - - try await store.saveMessage(spam1) - try await store.saveMessage(spam2) - try await store.saveMessage(legit) - - try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) - - let remainingSpam1 = try await store.fetchMessage(id: spam1.id) - let remainingSpam2 = try await store.fetchMessage(id: spam2.id) - let remainingLegit = try await store.fetchMessage(id: legit.id) - - #expect(remainingSpam1 == nil) - #expect(remainingSpam2 == nil) - #expect(remainingLegit != nil) - } - - @Test("Does not delete DMs from the same sender") - func preservesDirectMessages() async throws { - let store = try await createTestStore() - - let channelMsg = makeChannelMessage(radioID: radioID, senderName: "Spammer") - let dmMsg = makeDirectMessage(radioID: radioID, senderName: "Spammer") - - try await store.saveMessage(channelMsg) - try await store.saveMessage(dmMsg) - - try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) - - let remainingChannel = try await store.fetchMessage(id: channelMsg.id) - let remainingDM = try await store.fetchMessage(id: dmMsg.id) - - #expect(remainingChannel == nil) - #expect(remainingDM != nil) - } - - @Test("Scopes deletion to the specified device") - func scopesDeletionToDevice() async throws { - let store = try await createTestStore() - - let otherDevice = UUID() - let msg1 = makeChannelMessage(radioID: radioID, senderName: "Spammer") - let msg2 = makeChannelMessage(radioID: otherDevice, senderName: "Spammer") - - try await store.saveMessage(msg1) - try await store.saveMessage(msg2) - - try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) - - let remaining1 = try await store.fetchMessage(id: msg1.id) - let remaining2 = try await store.fetchMessage(id: msg2.id) - - #expect(remaining1 == nil) - #expect(remaining2 != nil) - } - - @Test("No-op when sender has no messages") - func noOpWhenNoMessages() async throws { - let store = try await createTestStore() - - let legit = makeChannelMessage(radioID: radioID, senderName: "Legit") - try await store.saveMessage(legit) - - try await store.deleteChannelMessages(fromSender: "Ghost", radioID: radioID) - - let remaining = try await store.fetchMessage(id: legit.id) - #expect(remaining != nil) - } - - @Test("Deletes reactions associated with deleted channel messages") - func deletesReactionsForDeletedMessages() async throws { - let store = try await createTestStore() - - let spamMsg = makeChannelMessage(radioID: radioID, senderName: "Spammer") - let legitMsg = makeChannelMessage(radioID: radioID, senderName: "Legit") - try await store.saveMessage(spamMsg) - try await store.saveMessage(legitMsg) - - // Add reactions to both messages (from other users) - let reactionOnSpam = ReactionDTO( - messageID: spamMsg.id, - emoji: "👍", - senderName: "Reactor", - messageHash: "AABB1122", - rawText: ":thumbsup:", - channelIndex: 0, - radioID: radioID - ) - let reactionOnLegit = ReactionDTO( - messageID: legitMsg.id, - emoji: "❤️", - senderName: "Reactor", - messageHash: "CCDD3344", - rawText: ":heart:", - channelIndex: 0, - radioID: radioID - ) - try await store.saveReaction(reactionOnSpam) - try await store.saveReaction(reactionOnLegit) - - try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) - - let reactionsOnSpam = try await store.fetchReactions(for: spamMsg.id) - let reactionsOnLegit = try await store.fetchReactions(for: legitMsg.id) - - #expect(reactionsOnSpam.isEmpty) - #expect(reactionsOnLegit.count == 1) - } - - @Test("Preserves messages with nil senderNodeName") - func preservesNilSenderNodeName() async throws { - let store = try await createTestStore() - - // Message with nil senderNodeName (e.g., system message) - let nilSenderMsg = MessageDTO( - id: UUID(), - radioID: radioID, - contactID: nil, - channelIndex: 0, - text: "system message", - timestamp: UInt32(Date().timeIntervalSince1970), - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - try await store.saveMessage(nilSenderMsg) - - try await store.deleteChannelMessages(fromSender: "AnyName", radioID: radioID) - - let remaining = try await store.fetchMessage(id: nilSenderMsg.id) - #expect(remaining != nil) - } + // MARK: - Test Helpers + + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private let radioID = UUID() + + private func makeChannelMessage( + radioID: UUID, + senderName: String, + channelIndex: UInt8 = 0, + text: String = "test" + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: channelIndex, + text: text, + timestamp: UInt32(Date().timeIntervalSince1970), + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + private func makeDirectMessage( + radioID: UUID, + senderName: String, + contactID: UUID = UUID() + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "dm", + timestamp: UInt32(Date().timeIntervalSince1970), + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + // MARK: - Tests + + @Test + func `Deletes all channel messages from a named sender`() async throws { + let store = try await createTestStore() + + let spam1 = makeChannelMessage(radioID: radioID, senderName: "Spammer", channelIndex: 0) + let spam2 = makeChannelMessage(radioID: radioID, senderName: "Spammer", channelIndex: 1) + let legit = makeChannelMessage(radioID: radioID, senderName: "Legit", channelIndex: 0) + + try await store.saveMessage(spam1) + try await store.saveMessage(spam2) + try await store.saveMessage(legit) + + try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) + + let remainingSpam1 = try await store.fetchMessage(id: spam1.id) + let remainingSpam2 = try await store.fetchMessage(id: spam2.id) + let remainingLegit = try await store.fetchMessage(id: legit.id) + + #expect(remainingSpam1 == nil) + #expect(remainingSpam2 == nil) + #expect(remainingLegit != nil) + } + + @Test + func `Does not delete DMs from the same sender`() async throws { + let store = try await createTestStore() + + let channelMsg = makeChannelMessage(radioID: radioID, senderName: "Spammer") + let dmMsg = makeDirectMessage(radioID: radioID, senderName: "Spammer") + + try await store.saveMessage(channelMsg) + try await store.saveMessage(dmMsg) + + try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) + + let remainingChannel = try await store.fetchMessage(id: channelMsg.id) + let remainingDM = try await store.fetchMessage(id: dmMsg.id) + + #expect(remainingChannel == nil) + #expect(remainingDM != nil) + } + + @Test + func `Scopes deletion to the specified device`() async throws { + let store = try await createTestStore() + + let otherDevice = UUID() + let msg1 = makeChannelMessage(radioID: radioID, senderName: "Spammer") + let msg2 = makeChannelMessage(radioID: otherDevice, senderName: "Spammer") + + try await store.saveMessage(msg1) + try await store.saveMessage(msg2) + + try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) + + let remaining1 = try await store.fetchMessage(id: msg1.id) + let remaining2 = try await store.fetchMessage(id: msg2.id) + + #expect(remaining1 == nil) + #expect(remaining2 != nil) + } + + @Test + func `No-op when sender has no messages`() async throws { + let store = try await createTestStore() + + let legit = makeChannelMessage(radioID: radioID, senderName: "Legit") + try await store.saveMessage(legit) + + try await store.deleteChannelMessages(fromSender: "Ghost", radioID: radioID) + + let remaining = try await store.fetchMessage(id: legit.id) + #expect(remaining != nil) + } + + @Test + func `Deletes reactions associated with deleted channel messages`() async throws { + let store = try await createTestStore() + + let spamMsg = makeChannelMessage(radioID: radioID, senderName: "Spammer") + let legitMsg = makeChannelMessage(radioID: radioID, senderName: "Legit") + try await store.saveMessage(spamMsg) + try await store.saveMessage(legitMsg) + + // Add reactions to both messages (from other users) + let reactionOnSpam = ReactionDTO( + messageID: spamMsg.id, + emoji: "👍", + senderName: "Reactor", + messageHash: "AABB1122", + rawText: ":thumbsup:", + channelIndex: 0, + radioID: radioID + ) + let reactionOnLegit = ReactionDTO( + messageID: legitMsg.id, + emoji: "❤️", + senderName: "Reactor", + messageHash: "CCDD3344", + rawText: ":heart:", + channelIndex: 0, + radioID: radioID + ) + try await store.saveReaction(reactionOnSpam) + try await store.saveReaction(reactionOnLegit) + + try await store.deleteChannelMessages(fromSender: "Spammer", radioID: radioID) + + let reactionsOnSpam = try await store.fetchReactions(for: spamMsg.id) + let reactionsOnLegit = try await store.fetchReactions(for: legitMsg.id) + + #expect(reactionsOnSpam.isEmpty) + #expect(reactionsOnLegit.count == 1) + } + + @Test + func `Preserves messages with nil senderNodeName`() async throws { + let store = try await createTestStore() + + // Message with nil senderNodeName (e.g., system message) + let nilSenderMsg = MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: 0, + text: "system message", + timestamp: UInt32(Date().timeIntervalSince1970), + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + try await store.saveMessage(nilSenderMsg) + + try await store.deleteChannelMessages(fromSender: "AnyName", radioID: radioID) + + let remaining = try await store.fetchMessage(id: nilSenderMsg.id) + #expect(remaining != nil) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/BluetoothScanPairingServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/BluetoothScanPairingServiceTests.swift index f4484fb3..a80ed28d 100644 --- a/MC1Services/Tests/MC1ServicesTests/BluetoothScanPairingServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BluetoothScanPairingServiceTests.swift @@ -1,116 +1,115 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("BluetoothScanPairingService") @MainActor struct BluetoothScanPairingServiceTests { + @Test + func `discoverDevice presents the picker and resolves with the selected id`() async throws { + let service = BluetoothScanPairingService() + let expected = UUID() - @Test("discoverDevice presents the picker and resolves with the selected id") - func selectResolvesDiscovery() async throws { - let service = BluetoothScanPairingService() - let expected = UUID() + let task = Task { try await service.discoverDevice() } + try await waitUntil("picker should request presentation") { service.isPresenting } - let task = Task { try await service.discoverDevice() } - try await waitUntil("picker should request presentation") { service.isPresenting } + service.select(expected) - service.select(expected) - - let result = try await task.value - #expect(result == expected) - #expect(service.isPresenting == false) - } + let result = try await task.value + #expect(result == expected) + #expect(service.isPresenting == false) + } - @Test("cancel surfaces DevicePairingError.cancelled and lowers presentation") - func cancelThrowsCancelled() async throws { - let service = BluetoothScanPairingService() + @Test + func `cancel surfaces DevicePairingError.cancelled and lowers presentation`() async throws { + let service = BluetoothScanPairingService() - let task = Task { try await service.discoverDevice() } - try await waitUntil("picker should request presentation") { service.isPresenting } + let task = Task { try await service.discoverDevice() } + try await waitUntil("picker should request presentation") { service.isPresenting } - service.cancel() + service.cancel() - await #expect(throws: DevicePairingError.self) { - _ = try await task.value - } - #expect(service.isPresenting == false) + await #expect(throws: DevicePairingError.self) { + _ = try await task.value } + #expect(service.isPresenting == false) + } - @Test("a new discovery resolves a stranded prior discovery") - func newDiscoveryResolvesPrior() async throws { - let service = BluetoothScanPairingService() + @Test + func `a new discovery resolves a stranded prior discovery`() async throws { + let service = BluetoothScanPairingService() - let first = Task { try await service.discoverDevice() } - try await waitUntil("first discovery should present") { service.isPresenting } + let first = Task { try await service.discoverDevice() } + try await waitUntil("first discovery should present") { service.isPresenting } - // Starting a second discovery must resolve the first's stranded continuation before - // installing its own. Awaiting `first` is the real synchronization point: it can only - // throw once the second call has run `resolveDiscovery` on the prior continuation. - let second = Task { try await service.discoverDevice() } + // Starting a second discovery must resolve the first's stranded continuation before + // installing its own. Awaiting `first` is the real synchronization point: it can only + // throw once the second call has run `resolveDiscovery` on the prior continuation. + let second = Task { try await service.discoverDevice() } - await #expect(throws: DevicePairingError.self) { - _ = try await first.value - } - - // The second continuation is now installed; selecting resolves it with the chosen id. - let expected = UUID() - service.select(expected) - #expect(try await second.value == expected) + await #expect(throws: DevicePairingError.self) { + _ = try await first.value } - @Test("cancelling the discovery task resolves it and lowers presentation") - func taskCancellationResolvesDiscovery() async throws { - let service = BluetoothScanPairingService() + // The second continuation is now installed; selecting resolves it with the chosen id. + let expected = UUID() + service.select(expected) + #expect(try await second.value == expected) + } - let task = Task { try await service.discoverDevice() } - try await waitUntil("discovery should present") { service.isPresenting } + @Test + func `cancelling the discovery task resolves it and lowers presentation`() async throws { + let service = BluetoothScanPairingService() - task.cancel() + let task = Task { try await service.discoverDevice() } + try await waitUntil("discovery should present") { service.isPresenting } - // Cancellation must surface via the same shared path as an explicit cancel(): - // onCancel -> cancel() -> resolveDiscovery(.failure(cancelled)), not a bare - // CancellationError, so call sites keep one cancellation path on both platforms. - await #expect(throws: DevicePairingError.self) { - _ = try await task.value - } - try await waitUntil("presentation should clear after cancellation") { !service.isPresenting } - } + task.cancel() - @Test("a selection racing a cancelled discovery task surfaces cancelled, not the selection") - func selectionRacingCancellationSurfacesCancelled() async throws { - let service = BluetoothScanPairingService() + // Cancellation must surface via the same shared path as an explicit cancel(): + // onCancel -> cancel() -> resolveDiscovery(.failure(cancelled)), not a bare + // CancellationError, so call sites keep one cancellation path on both platforms. + await #expect(throws: DevicePairingError.self) { + _ = try await task.value + } + try await waitUntil("presentation should clear after cancellation") { !service.isPresenting } + } - let task = Task { try await service.discoverDevice() } - try await waitUntil("discovery should present") { service.isPresenting } + @Test + func `a selection racing a cancelled discovery task surfaces cancelled, not the selection`() async throws { + let service = BluetoothScanPairingService() - // Cancelling the task runs `onCancel` synchronously, which sets the cancellation flag - // before its hop to `cancel()` is scheduled. A `select(_:)` landing on the MainActor in - // that window (here, before the hop runs) must not resolve the continuation with the stale - // selection — discovery must still surface `.cancelled`. - task.cancel() - service.select(UUID()) + let task = Task { try await service.discoverDevice() } + try await waitUntil("discovery should present") { service.isPresenting } - await #expect(throws: DevicePairingError.self) { - _ = try await task.value - } - try await waitUntil("presentation should clear after cancellation") { !service.isPresenting } - } + // Cancelling the task runs `onCancel` synchronously, which sets the cancellation flag + // before its hop to `cancel()` is scheduled. A `select(_:)` landing on the MainActor in + // that window (here, before the hop runs) must not resolve the continuation with the stale + // selection — discovery must still surface `.cancelled`. + task.cancel() + service.select(UUID()) - @Test("system-registry operations are inert on the macOS path") - func registryOperationsAreNoOps() async throws { - let service = BluetoothScanPairingService() - - #expect(service.isSessionActive == false) - #expect(service.hasSystemPairingRegistry == false) - #expect(service.registeredDeviceCount == 0) - #expect(service.supportsSystemRename == false) - #expect(service.isDeviceConnectable(UUID()) == true) - #expect(service.registeredDeviceInfos().isEmpty) - - // None of these should throw or have observable effect. - try await service.activate() - try await service.removeDevice(UUID()) - try await service.renameDevice(UUID()) - await service.clearStaleRegistrations() + await #expect(throws: DevicePairingError.self) { + _ = try await task.value } + try await waitUntil("presentation should clear after cancellation") { !service.isPresenting } + } + + @Test + func `system-registry operations are inert on the macOS path`() async throws { + let service = BluetoothScanPairingService() + + #expect(service.isSessionActive == false) + #expect(service.hasSystemPairingRegistry == false) + #expect(service.registeredDeviceCount == 0) + #expect(service.supportsSystemRename == false) + #expect(service.isDeviceConnectable(UUID()) == true) + #expect(service.registeredDeviceInfos().isEmpty) + + // None of these should throw or have observable effect. + try await service.activate() + try await service.removeDevice(UUID()) + try await service.renameDevice(UUID()) + await service.clearStaleRegistrations() + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeMigrationTests.swift b/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeMigrationTests.swift index 290102a6..c2f8db36 100644 --- a/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeMigrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeMigrationTests.swift @@ -1,116 +1,115 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("ChannelFloodScope corrective migration", .serialized) struct ChannelFloodScopeMigrationTests { - - // MARK: - Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - /// Inserts a Channel model with raw fields — simulates a row persisted by an older - /// app version (before `floodScopeModeRawValue` existed, so every row was "inherit" - /// regardless of the per-channel region override). - private func insertLegacyChannel( - into store: PersistenceStore, - radioID: UUID, - index: UInt8, - regionScope: String? - ) async throws { - let inheritRaw = ChannelFloodScopeStorage.Mode.inherit.rawValue - let dto = ChannelDTO( - id: UUID(), - radioID: radioID, - index: index, - name: "Chan\(index)", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false, - floodScopeModeRawValue: inheritRaw, - regionScope: regionScope - ) - try await store.saveChannel(dto) - } - - // MARK: - Migration behavior - - @Test("Non-nil regionScope with inherit mode is promoted to .specific") - func legacyRegionRowBecomesSpecific() async throws { - let store = try await createTestStore() - await store.resetChannelFloodScopeMigrationFlag() - let radioID = UUID() - try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: "Germany") - - try await store.performChannelFloodScopeMigration() - - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.first?.floodScope == .region("Germany")) - } - - @Test("Nil regionScope with inherit mode stays at .inherit (corrective)") - func legacyNilRegionStaysInherit() async throws { - let store = try await createTestStore() - await store.resetChannelFloodScopeMigrationFlag() - let radioID = UUID() - try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: nil) - - try await store.performChannelFloodScopeMigration() - - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.first?.floodScope == .inherit) - } - - @Test("Mixed rows migrate correctly in one pass") - func mixedRowsMigrate() async throws { - let store = try await createTestStore() - await store.resetChannelFloodScopeMigrationFlag() - let radioID = UUID() - try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: nil) - try await insertLegacyChannel(into: store, radioID: radioID, index: 2, regionScope: "Germany") - try await insertLegacyChannel(into: store, radioID: radioID, index: 3, regionScope: "France") - - try await store.performChannelFloodScopeMigration() - - let channels = try await store.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } - #expect(channels.map(\.floodScope) == [.inherit, .region("Germany"), .region("France")]) - } - - @Test("Migration is idempotent — second run is a no-op") - func migrationIsIdempotent() async throws { - let store = try await createTestStore() - await store.resetChannelFloodScopeMigrationFlag() - let radioID = UUID() - try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: "Germany") - - try await store.performChannelFloodScopeMigration() - // Second run with the flag set must not touch anything. - try await store.performChannelFloodScopeMigration() - - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.first?.floodScope == .region("Germany")) - } - - @Test("Post-migration writes to .allRegions are not clobbered by a re-run") - func postMigrationAllRegionsPreserved() async throws { - let store = try await createTestStore() - await store.resetChannelFloodScopeMigrationFlag() - let radioID = UUID() - // A channel the user explicitly set to .allRegions after upgrading. - let dto = ChannelDTO.testChannel(radioID: radioID, index: 1, floodScope: .allRegions) - try await store.saveChannel(dto) - - try await store.performChannelFloodScopeMigration() - - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.first?.floodScope == .allRegions) - } + // MARK: - Helpers + + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + /// Inserts a Channel model with raw fields — simulates a row persisted by an older + /// app version (before `floodScopeModeRawValue` existed, so every row was "inherit" + /// regardless of the per-channel region override). + private func insertLegacyChannel( + into store: PersistenceStore, + radioID: UUID, + index: UInt8, + regionScope: String? + ) async throws { + let inheritRaw = ChannelFloodScopeStorage.Mode.inherit.rawValue + let dto = ChannelDTO( + id: UUID(), + radioID: radioID, + index: index, + name: "Chan\(index)", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false, + floodScopeModeRawValue: inheritRaw, + regionScope: regionScope + ) + try await store.saveChannel(dto) + } + + // MARK: - Migration behavior + + @Test + func `Non-nil regionScope with inherit mode is promoted to .specific`() async throws { + let store = try await createTestStore() + await store.resetChannelFloodScopeMigrationFlag() + let radioID = UUID() + try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: "Germany") + + try await store.performChannelFloodScopeMigration() + + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.first?.floodScope == .region("Germany")) + } + + @Test + func `Nil regionScope with inherit mode stays at .inherit (corrective)`() async throws { + let store = try await createTestStore() + await store.resetChannelFloodScopeMigrationFlag() + let radioID = UUID() + try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: nil) + + try await store.performChannelFloodScopeMigration() + + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.first?.floodScope == .inherit) + } + + @Test + func `Mixed rows migrate correctly in one pass`() async throws { + let store = try await createTestStore() + await store.resetChannelFloodScopeMigrationFlag() + let radioID = UUID() + try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: nil) + try await insertLegacyChannel(into: store, radioID: radioID, index: 2, regionScope: "Germany") + try await insertLegacyChannel(into: store, radioID: radioID, index: 3, regionScope: "France") + + try await store.performChannelFloodScopeMigration() + + let channels = try await store.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } + #expect(channels.map(\.floodScope) == [.inherit, .region("Germany"), .region("France")]) + } + + @Test + func `Migration is idempotent — second run is a no-op`() async throws { + let store = try await createTestStore() + await store.resetChannelFloodScopeMigrationFlag() + let radioID = UUID() + try await insertLegacyChannel(into: store, radioID: radioID, index: 1, regionScope: "Germany") + + try await store.performChannelFloodScopeMigration() + // Second run with the flag set must not touch anything. + try await store.performChannelFloodScopeMigration() + + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.first?.floodScope == .region("Germany")) + } + + @Test + func `Post-migration writes to .allRegions are not clobbered by a re-run`() async throws { + let store = try await createTestStore() + await store.resetChannelFloodScopeMigrationFlag() + let radioID = UUID() + // A channel the user explicitly set to .allRegions after upgrading. + let dto = ChannelDTO.testChannel(radioID: radioID, index: 1, floodScope: .allRegions) + try await store.saveChannel(dto) + + try await store.performChannelFloodScopeMigration() + + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.first?.floodScope == .allRegions) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeResolverTests.swift b/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeResolverTests.swift index 6f8044e5..e890d703 100644 --- a/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeResolverTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeResolverTests.swift @@ -1,86 +1,85 @@ import Foundation +@testable import MC1Services import MeshCore import Testing -@testable import MC1Services @Suite("ChannelFloodScopeResolver") struct ChannelFloodScopeResolverTests { + @Test + func `.inherit with device default set resolves to .scope(.region(default))`() { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: .inherit, + deviceDefaultFloodScopeName: "Germany", + supportsUnscopedFloodSend: true + ) + #expect(resolved == .scope(.region("Germany"))) + } - @Test(".inherit with device default set resolves to .scope(.region(default))") - func inheritWithDefaultResolvesRegion() { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: .inherit, - deviceDefaultFloodScopeName: "Germany", - supportsUnscopedFloodSend: true - ) - #expect(resolved == .scope(.region("Germany"))) - } - - @Test(".inherit with no device default resolves to .scope(.disabled)") - func inheritWithoutDefaultResolvesDisabled() { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: .inherit, - deviceDefaultFloodScopeName: nil, - supportsUnscopedFloodSend: true - ) - #expect(resolved == .scope(.disabled)) - } + @Test + func `.inherit with no device default resolves to .scope(.disabled)`() { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: .inherit, + deviceDefaultFloodScopeName: nil, + supportsUnscopedFloodSend: true + ) + #expect(resolved == .scope(.disabled)) + } - @Test(".inherit with empty-string default treats it as no default") - func inheritWithEmptyDefaultResolvesDisabled() { - let resolved = ChannelFloodScopeResolver.resolve( - channelFloodScope: .inherit, - deviceDefaultFloodScopeName: "", - supportsUnscopedFloodSend: true - ) - #expect(resolved == .scope(.disabled)) - } + @Test + func `.inherit with empty-string default treats it as no default`() { + let resolved = ChannelFloodScopeResolver.resolve( + channelFloodScope: .inherit, + deviceDefaultFloodScopeName: "", + supportsUnscopedFloodSend: true + ) + #expect(resolved == .scope(.disabled)) + } - @Test(".allRegions on firmware v12+ resolves to .unscoped (true override)") - func allRegionsWithCapabilityResolvesUnscoped() { - let withDefault = ChannelFloodScopeResolver.resolve( - channelFloodScope: .allRegions, - deviceDefaultFloodScopeName: "Germany", - supportsUnscopedFloodSend: true - ) - let withoutDefault = ChannelFloodScopeResolver.resolve( - channelFloodScope: .allRegions, - deviceDefaultFloodScopeName: nil, - supportsUnscopedFloodSend: true - ) - #expect(withDefault == .unscoped) - #expect(withoutDefault == .unscoped) - } + @Test + func `.allRegions on firmware v12+ resolves to .unscoped (true override)`() { + let withDefault = ChannelFloodScopeResolver.resolve( + channelFloodScope: .allRegions, + deviceDefaultFloodScopeName: "Germany", + supportsUnscopedFloodSend: true + ) + let withoutDefault = ChannelFloodScopeResolver.resolve( + channelFloodScope: .allRegions, + deviceDefaultFloodScopeName: nil, + supportsUnscopedFloodSend: true + ) + #expect(withDefault == .unscoped) + #expect(withoutDefault == .unscoped) + } - @Test(".allRegions on older firmware falls back to .scope(.disabled)") - func allRegionsWithoutCapabilityFallsBackToDisabled() { - let withDefault = ChannelFloodScopeResolver.resolve( - channelFloodScope: .allRegions, - deviceDefaultFloodScopeName: "Germany", - supportsUnscopedFloodSend: false - ) - let withoutDefault = ChannelFloodScopeResolver.resolve( - channelFloodScope: .allRegions, - deviceDefaultFloodScopeName: nil, - supportsUnscopedFloodSend: false - ) - #expect(withDefault == .scope(.disabled)) - #expect(withoutDefault == .scope(.disabled)) - } + @Test + func `.allRegions on older firmware falls back to .scope(.disabled)`() { + let withDefault = ChannelFloodScopeResolver.resolve( + channelFloodScope: .allRegions, + deviceDefaultFloodScopeName: "Germany", + supportsUnscopedFloodSend: false + ) + let withoutDefault = ChannelFloodScopeResolver.resolve( + channelFloodScope: .allRegions, + deviceDefaultFloodScopeName: nil, + supportsUnscopedFloodSend: false + ) + #expect(withDefault == .scope(.disabled)) + #expect(withoutDefault == .scope(.disabled)) + } - @Test(".region(name) resolves to that region regardless of default or capability") - func specificRegionResolvesThatRegion() { - let withDefault = ChannelFloodScopeResolver.resolve( - channelFloodScope: .region("France"), - deviceDefaultFloodScopeName: "Germany", - supportsUnscopedFloodSend: true - ) - let withoutCapability = ChannelFloodScopeResolver.resolve( - channelFloodScope: .region("France"), - deviceDefaultFloodScopeName: nil, - supportsUnscopedFloodSend: false - ) - #expect(withDefault == .scope(.region("France"))) - #expect(withoutCapability == .scope(.region("France"))) - } + @Test + func `.region(name) resolves to that region regardless of default or capability`() { + let withDefault = ChannelFloodScopeResolver.resolve( + channelFloodScope: .region("France"), + deviceDefaultFloodScopeName: "Germany", + supportsUnscopedFloodSend: true + ) + let withoutCapability = ChannelFloodScopeResolver.resolve( + channelFloodScope: .region("France"), + deviceDefaultFloodScopeName: nil, + supportsUnscopedFloodSend: false + ) + #expect(withDefault == .scope(.region("France"))) + #expect(withoutCapability == .scope(.region("France"))) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeTests.swift b/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeTests.swift index 21a63b1b..ccfd691b 100644 --- a/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ChannelFloodScopeTests.swift @@ -1,172 +1,171 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ChannelFloodScope storage") struct ChannelFloodScopeTests { - - // MARK: - Enum shape - - @Test("Three distinct cases compare as equal only to themselves") - func enumCasesAreDistinct() { - #expect(ChannelFloodScope.inherit == .inherit) - #expect(ChannelFloodScope.allRegions == .allRegions) - #expect(ChannelFloodScope.region("Germany") == .region("Germany")) - #expect(ChannelFloodScope.inherit != .allRegions) - #expect(ChannelFloodScope.inherit != .region("")) - #expect(ChannelFloodScope.region("Germany") != .region("France")) - } - - // MARK: - ChannelDTO round-trip - - @Test("DTO default floodScope is .inherit") - func dtoDefaultIsInherit() { - let dto = makeDTO() - #expect(dto.floodScope == .inherit) - } - - @Test("DTO floodScope roundtrips for .inherit") - func dtoInheritRoundtrip() { - let dto = makeDTO(floodScope: .inherit) - #expect(dto.floodScope == .inherit) - #expect(dto.regionScope == nil) - } - - @Test("DTO floodScope roundtrips for .allRegions") - func dtoAllRegionsRoundtrip() { - let dto = makeDTO(floodScope: .allRegions) - #expect(dto.floodScope == .allRegions) - #expect(dto.regionScope == nil) - } - - @Test("DTO floodScope roundtrips for .region(name)") - func dtoSpecificRegionRoundtrip() { - let dto = makeDTO(floodScope: .region("Germany")) - #expect(dto.floodScope == .region("Germany")) - #expect(dto.regionScope == "Germany") - } - - @Test("DTO treats .region with empty name as .inherit defensively") - func dtoEmptyRegionNameFallsBackToInherit() { - // A storage layer pathology: floodScopeModeRawValue says "specific" but regionScope is nil. - // Public enum should never expose a malformed .region(nil); fall back to .inherit. - let dto = ChannelDTO.testChannel( - radioID: UUID(), - floodScope: .region("Germany") - ).with(regionScope: nil) - #expect(dto.floodScope == .inherit) - } - - // MARK: - Channel model round-trip (through persistence) - - @Test("Channel model default floodScope is .inherit") - func channelDefaultIsInherit() async throws { - let store = try await createTestStore() - let radioID = UUID() - let dto = makeDTO(radioID: radioID) - try await store.saveChannel(dto) - - let fetched = try await store.fetchChannel(id: dto.id) - #expect(fetched?.floodScope == .inherit) - } - - @Test("Channel model persists .allRegions distinctly from .inherit") - func channelPersistsAllRegionsDistinct() async throws { - let store = try await createTestStore() - let radioID = UUID() - let dto = makeDTO(radioID: radioID, floodScope: .allRegions) - try await store.saveChannel(dto) - - let fetched = try await store.fetchChannel(id: dto.id) - #expect(fetched?.floodScope == .allRegions) - } - - @Test("Channel model persists .region(name)") - func channelPersistsSpecificRegion() async throws { - let store = try await createTestStore() - let radioID = UUID() - let dto = makeDTO(radioID: radioID, floodScope: .region("Germany")) - try await store.saveChannel(dto) - - let fetched = try await store.fetchChannel(id: dto.id) - #expect(fetched?.floodScope == .region("Germany")) - } - - // MARK: - Codable envelope migration - - @Test("Codable round-trips all three cases for current envelope format") - func codableRoundTripCurrent() throws { - let radioID = UUID() - for scope in [ChannelFloodScope.inherit, .allRegions, .region("Germany")] { - let dto = makeDTO(radioID: radioID, floodScope: scope) - let encoded = try JSONEncoder().encode(dto) - let decoded = try JSONDecoder().decode(ChannelDTO.self, from: encoded) - #expect(decoded.floodScope == scope, "roundtrip lost \(scope)") - } - } - - @Test("Legacy envelope (missing mode key, nil regionScope) decodes as .inherit") - func legacyEnvelopeNilRegionDecodesInherit() throws { - let json = legacyJSON(regionScope: nil) - let decoded = try JSONDecoder().decode(ChannelDTO.self, from: Data(json.utf8)) - #expect(decoded.floodScope == .inherit) - } - - @Test("Legacy envelope (missing mode key, named regionScope) decodes as .region") - func legacyEnvelopeNamedRegionDecodesSpecific() throws { - let json = legacyJSON(regionScope: "Germany") - let decoded = try JSONDecoder().decode(ChannelDTO.self, from: Data(json.utf8)) - #expect(decoded.floodScope == .region("Germany")) - } - - @Test("setChannelFloodScope updates existing channel atomically") - func setChannelFloodScopeAtomic() async throws { - let store = try await createTestStore() - let radioID = UUID() - let dto = makeDTO(radioID: radioID, floodScope: .region("Germany")) - try await store.saveChannel(dto) - - try await store.setChannelFloodScope(dto.id, floodScope: .allRegions) - let after = try await store.fetchChannel(id: dto.id) - #expect(after?.floodScope == .allRegions) - #expect(after?.regionScope == nil, "switching to .allRegions must clear the region name") - - try await store.setChannelFloodScope(dto.id, floodScope: .inherit) - let afterInherit = try await store.fetchChannel(id: dto.id) - #expect(afterInherit?.floodScope == .inherit) - #expect(afterInherit?.regionScope == nil) - } - - // MARK: - Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private func makeDTO( - radioID: UUID = UUID(), - floodScope: ChannelFloodScope = .inherit - ) -> ChannelDTO { - ChannelDTO.testChannel(radioID: radioID, floodScope: floodScope) - } - - private func legacyJSON(regionScope: String?) -> String { - let region = regionScope.map { "\"\($0)\"" } ?? "null" - return """ - { - "id": "\(UUID().uuidString)", - "radioID": "\(UUID().uuidString)", - "index": 1, - "name": "General", - "secret": "\(Data(repeating: 0, count: 16).base64EncodedString())", - "isEnabled": true, - "unreadCount": 0, - "unreadMentionCount": 0, - "notificationLevel": 2, - "isFavorite": false, - "regionScope": \(region) - } - """ - } + // MARK: - Enum shape + + @Test + func `Three distinct cases compare as equal only to themselves`() { + #expect(ChannelFloodScope.inherit == .inherit) + #expect(ChannelFloodScope.allRegions == .allRegions) + #expect(ChannelFloodScope.region("Germany") == .region("Germany")) + #expect(ChannelFloodScope.inherit != .allRegions) + #expect(ChannelFloodScope.inherit != .region("")) + #expect(ChannelFloodScope.region("Germany") != .region("France")) + } + + // MARK: - ChannelDTO round-trip + + @Test + func `DTO default floodScope is .inherit`() { + let dto = makeDTO() + #expect(dto.floodScope == .inherit) + } + + @Test + func `DTO floodScope roundtrips for .inherit`() { + let dto = makeDTO(floodScope: .inherit) + #expect(dto.floodScope == .inherit) + #expect(dto.regionScope == nil) + } + + @Test + func `DTO floodScope roundtrips for .allRegions`() { + let dto = makeDTO(floodScope: .allRegions) + #expect(dto.floodScope == .allRegions) + #expect(dto.regionScope == nil) + } + + @Test + func `DTO floodScope roundtrips for .region(name)`() { + let dto = makeDTO(floodScope: .region("Germany")) + #expect(dto.floodScope == .region("Germany")) + #expect(dto.regionScope == "Germany") + } + + @Test + func `DTO treats .region with empty name as .inherit defensively`() { + // A storage layer pathology: floodScopeModeRawValue says "specific" but regionScope is nil. + // Public enum should never expose a malformed .region(nil); fall back to .inherit. + let dto = ChannelDTO.testChannel( + radioID: UUID(), + floodScope: .region("Germany") + ).with(regionScope: nil) + #expect(dto.floodScope == .inherit) + } + + // MARK: - Channel model round-trip (through persistence) + + @Test + func `Channel model default floodScope is .inherit`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let dto = makeDTO(radioID: radioID) + try await store.saveChannel(dto) + + let fetched = try await store.fetchChannel(id: dto.id) + #expect(fetched?.floodScope == .inherit) + } + + @Test + func `Channel model persists .allRegions distinctly from .inherit`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let dto = makeDTO(radioID: radioID, floodScope: .allRegions) + try await store.saveChannel(dto) + + let fetched = try await store.fetchChannel(id: dto.id) + #expect(fetched?.floodScope == .allRegions) + } + + @Test + func `Channel model persists .region(name)`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let dto = makeDTO(radioID: radioID, floodScope: .region("Germany")) + try await store.saveChannel(dto) + + let fetched = try await store.fetchChannel(id: dto.id) + #expect(fetched?.floodScope == .region("Germany")) + } + + // MARK: - Codable envelope migration + + @Test + func `Codable round-trips all three cases for current envelope format`() throws { + let radioID = UUID() + for scope in [ChannelFloodScope.inherit, .allRegions, .region("Germany")] { + let dto = makeDTO(radioID: radioID, floodScope: scope) + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(ChannelDTO.self, from: encoded) + #expect(decoded.floodScope == scope, "roundtrip lost \(scope)") + } + } + + @Test + func `Legacy envelope (missing mode key, nil regionScope) decodes as .inherit`() throws { + let json = legacyJSON(regionScope: nil) + let decoded = try JSONDecoder().decode(ChannelDTO.self, from: Data(json.utf8)) + #expect(decoded.floodScope == .inherit) + } + + @Test + func `Legacy envelope (missing mode key, named regionScope) decodes as .region`() throws { + let json = legacyJSON(regionScope: "Germany") + let decoded = try JSONDecoder().decode(ChannelDTO.self, from: Data(json.utf8)) + #expect(decoded.floodScope == .region("Germany")) + } + + @Test + func `setChannelFloodScope updates existing channel atomically`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let dto = makeDTO(radioID: radioID, floodScope: .region("Germany")) + try await store.saveChannel(dto) + + try await store.setChannelFloodScope(dto.id, floodScope: .allRegions) + let after = try await store.fetchChannel(id: dto.id) + #expect(after?.floodScope == .allRegions) + #expect(after?.regionScope == nil, "switching to .allRegions must clear the region name") + + try await store.setChannelFloodScope(dto.id, floodScope: .inherit) + let afterInherit = try await store.fetchChannel(id: dto.id) + #expect(afterInherit?.floodScope == .inherit) + #expect(afterInherit?.regionScope == nil) + } + + // MARK: - Helpers + + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func makeDTO( + radioID: UUID = UUID(), + floodScope: ChannelFloodScope = .inherit + ) -> ChannelDTO { + ChannelDTO.testChannel(radioID: radioID, floodScope: floodScope) + } + + private func legacyJSON(regionScope: String?) -> String { + let region = regionScope.map { "\"\($0)\"" } ?? "null" + return """ + { + "id": "\(UUID().uuidString)", + "radioID": "\(UUID().uuidString)", + "index": 1, + "name": "General", + "secret": "\(Data(repeating: 0, count: 16).base64EncodedString())", + "isEnabled": true, + "unreadCount": 0, + "unreadMentionCount": 0, + "notificationLevel": 2, + "isFavorite": false, + "regionScope": \(region) + } + """ + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Connection/LastConnectionStoreTests.swift b/MC1Services/Tests/MC1ServicesTests/Connection/LastConnectionStoreTests.swift index d9e6cfc1..39b71271 100644 --- a/MC1Services/Tests/MC1ServicesTests/Connection/LastConnectionStoreTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Connection/LastConnectionStoreTests.swift @@ -1,86 +1,85 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("LastConnectionStore Tests") struct LastConnectionStoreTests { + /// Runs `body` against a per-test isolated `UserDefaults` suite, + /// removing the persistent domain afterwards so no state leaks. + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) rethrows { + let suiteName = "test.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + try body(defaults) + } - /// Runs `body` against a per-test isolated `UserDefaults` suite, - /// removing the persistent domain afterwards so no state leaks. - private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) rethrows { - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - try body(defaults) - } - - @Test("persist then read round-trips deviceID, radioID, and deviceName") - func persistRoundTrip() throws { - try withIsolatedDefaults { defaults in - let store = LastConnectionStore(defaults: defaults) - let deviceID = UUID() - let radioID = UUID() + @Test + func `persist then read round-trips deviceID, radioID, and deviceName`() throws { + try withIsolatedDefaults { defaults in + let store = LastConnectionStore(defaults: defaults) + let deviceID = UUID() + let radioID = UUID() - store.persist(deviceID: deviceID, radioID: radioID, deviceName: "Test Radio") + store.persist(deviceID: deviceID, radioID: radioID, deviceName: "Test Radio") - #expect(store.deviceID == deviceID) - #expect(store.radioID == radioID) - #expect(defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) == "Test Radio") - } + #expect(store.deviceID == deviceID) + #expect(store.radioID == radioID) + #expect(defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) == "Test Radio") } + } - @Test("clear removes all three persisted values") - func clearRemovesAll() throws { - try withIsolatedDefaults { defaults in - let store = LastConnectionStore(defaults: defaults) - store.persist(deviceID: UUID(), radioID: UUID(), deviceName: "Test Radio") + @Test + func `clear removes all three persisted values`() throws { + try withIsolatedDefaults { defaults in + let store = LastConnectionStore(defaults: defaults) + store.persist(deviceID: UUID(), radioID: UUID(), deviceName: "Test Radio") - store.clear() + store.clear() - #expect(store.deviceID == nil) - #expect(store.radioID == nil) - #expect(defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) == nil) - } + #expect(store.deviceID == nil) + #expect(store.radioID == nil) + #expect(defaults.string(forKey: PersistenceKeys.lastConnectedDeviceName) == nil) } + } - @Test("empty defaults read as nil") - func emptyDefaultsReadNil() { - withIsolatedDefaults { defaults in - let store = LastConnectionStore(defaults: defaults) + @Test + func `empty defaults read as nil`() { + withIsolatedDefaults { defaults in + let store = LastConnectionStore(defaults: defaults) - #expect(store.deviceID == nil) - #expect(store.radioID == nil) - #expect(store.disconnectDiagnostic == nil) - } + #expect(store.deviceID == nil) + #expect(store.radioID == nil) + #expect(store.disconnectDiagnostic == nil) } + } - @Test("malformed UUID strings read as nil") - func malformedUUIDReadsNil() { - withIsolatedDefaults { defaults in - defaults.set("not-a-uuid", forKey: PersistenceKeys.lastConnectedDeviceID) - defaults.set("also-not-a-uuid", forKey: PersistenceKeys.lastConnectedRadioID) - let store = LastConnectionStore(defaults: defaults) + @Test + func `malformed UUID strings read as nil`() { + withIsolatedDefaults { defaults in + defaults.set("not-a-uuid", forKey: PersistenceKeys.lastConnectedDeviceID) + defaults.set("also-not-a-uuid", forKey: PersistenceKeys.lastConnectedRadioID) + let store = LastConnectionStore(defaults: defaults) - #expect(store.deviceID == nil) - #expect(store.radioID == nil) - } + #expect(store.deviceID == nil) + #expect(store.radioID == nil) } + } - @Test("persistDisconnectDiagnostic prefixes a parseable ISO8601 timestamp") - func disconnectDiagnosticTimestampPrefix() throws { - try withIsolatedDefaults { defaults in - let store = LastConnectionStore(defaults: defaults) - let summary = "source=unitTest, reason=verifyFormat" + @Test + func `persistDisconnectDiagnostic prefixes a parseable ISO8601 timestamp`() throws { + try withIsolatedDefaults { defaults in + let store = LastConnectionStore(defaults: defaults) + let summary = "source=unitTest, reason=verifyFormat" - store.persistDisconnectDiagnostic(summary) + store.persistDisconnectDiagnostic(summary) - let stored = try #require(store.disconnectDiagnostic) - let separatorIndex = try #require(stored.firstIndex(of: " ")) - let timestampPart = String(stored[.. (MeshCoreSession, ServiceContainer, DeviceDTO) { - let session = MeshCoreSession(transport: SimulatorMockTransport()) - let services = try await ServiceContainer.forTesting(session: session) - let device = DeviceDTO.testDevice(id: deviceID, radioID: deviceID) - try await services.dataStore.saveDevice(device) - if startEventMonitoring { - await services.startEventMonitoring(radioID: device.radioID) - } - return (session, services, device) + // MARK: - Test Helpers + + private func makeServices( + deviceID: UUID, + startEventMonitoring: Bool = false + ) async throws -> (MeshCoreSession, ServiceContainer, DeviceDTO) { + let session = MeshCoreSession(transport: SimulatorMockTransport()) + let services = try await ServiceContainer.forTesting(session: session) + let device = DeviceDTO.testDevice(id: deviceID, radioID: deviceID) + try await services.dataStore.saveDevice(device) + if startEventMonitoring { + await services.startEventMonitoring(radioID: device.radioID) } - - // MARK: - Early Return Tests - - @Test("returns early when transport type is WiFi") - func returnsEarlyForWiFiTransport() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - - manager.setTestState( - connectionState: .ready, - currentTransportType: .wifi, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = UUID() - - await manager.checkBLEConnectionHealth() - - // Should not change state since WiFi transport is handled elsewhere - #expect(manager.connectionState == .ready) + return (session, services, device) + } + + // MARK: - Early Return Tests + + @Test + func `returns early when transport type is WiFi`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setTestState( + connectionState: .ready, + currentTransportType: .wifi, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = UUID() + + await manager.checkBLEConnectionHealth() + + // Should not change state since WiFi transport is handled elsewhere + #expect(manager.connectionState == .ready) + } + + @Test + func `returns early when shouldBeConnected is false`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .none + ) + manager.testLastConnectedDeviceID = UUID() + + await manager.checkBLEConnectionHealth() + + // Should not trigger cleanup since user doesn't expect to be connected + #expect(manager.connectionState == .ready) + } + + @Test + func `returns early when no lastConnectedDeviceID`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + // testLastConnectedDeviceID is nil by default + + await manager.checkBLEConnectionHealth() + + // Should not trigger cleanup without a device to reconnect to + #expect(manager.connectionState == .ready) + } + + @Test + func `returns early when BLE is connected and app stack is healthy`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + let (session, services, device) = try await makeServices(deviceID: deviceID, startEventMonitoring: true) + let tracker = SessionRebuildTracker() + + await mock.setStubbedIsConnected(true) + await mock.setStubbedConnectedDeviceID(deviceID) + manager.rebuildSessionForHealthCheckOverride = { deviceID in + await tracker.record(deviceID) } - @Test("returns early when shouldBeConnected is false") - func returnsEarlyWhenNotExpectingConnection() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .none - ) - manager.testLastConnectedDeviceID = UUID() - - await manager.checkBLEConnectionHealth() - - // Should not trigger cleanup since user doesn't expect to be connected - #expect(manager.connectionState == .ready) + manager.setTestState( + connectionState: .ready, + services: services, + session: session, + connectedDevice: device, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(manager.connectionState == .ready) + #expect(services.isEventMonitoringActive) + #expect(await services.messagePollingService.isAutoFetching) + #expect(await tracker.recordedCalls().isEmpty) + } + + @Test + func `skips reconnect during iOS auto-reconnect`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(true) + + manager.setTestState( + connectionState: .connecting, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = UUID() + + await manager.checkBLEConnectionHealth() + + // Should not interfere with iOS auto-reconnect + #expect(manager.connectionState == .connecting) + } + + @Test + func `skips foreground reconnect while pairing is in progress`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + isPairingInProgress: true + ) + manager.testLastConnectedDeviceID = UUID() + + await manager.checkBLEConnectionHealth() + + // Gate fires before connect(to:) — connectionState stays .disconnected + #expect(manager.connectionState == .disconnected) + } + + @Test + func `skips reconnect while session rebuild is in progress`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + sessionRebuildDeviceID: deviceID + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(manager.connectionState == .disconnected) + #expect(manager.sessionRebuildDeviceID == deviceID) + } + + // MARK: - Connected Transport App-Stack Reconciliation Tests + + @Test + func `BLE connected with missing session and services rebuilds app stack`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + let tracker = SessionRebuildTracker() + + await mock.setStubbedIsConnected(true) + await mock.setStubbedConnectedDeviceID(deviceID) + await mock.setStubbedIsAutoReconnecting(false) + manager.rebuildSessionForHealthCheckOverride = { deviceID in + await tracker.record(deviceID) } - @Test("returns early when no lastConnectedDeviceID") - func returnsEarlyWhenNoLastDevice() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - // testLastConnectedDeviceID is nil by default - - await manager.checkBLEConnectionHealth() - - // Should not trigger cleanup without a device to reconnect to - #expect(manager.connectionState == .ready) + manager.setTestState( + connectionState: .disconnected, + services: nil, + session: nil, + connectedDevice: nil, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(await tracker.recordedCalls() == [deviceID]) + } + + @Test + func `BLE connected ready state restarts missing event monitoring`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + let (session, services, device) = try await makeServices(deviceID: deviceID) + + await mock.setStubbedIsConnected(true) + await mock.setStubbedConnectedDeviceID(deviceID) + await mock.setStubbedIsAutoReconnecting(false) + manager.setTestState( + connectionState: .ready, + services: services, + session: session, + connectedDevice: device, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + #expect(!services.isEventMonitoringActive) + #expect(await !(services.messagePollingService.isAutoFetching)) + + await manager.checkBLEConnectionHealth() + + #expect(services.isEventMonitoringActive) + #expect(await services.messagePollingService.isAutoFetching) + } + + @Test + func `BLE connected with healthy listeners does not rebuild`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + let (session, services, device) = try await makeServices(deviceID: deviceID, startEventMonitoring: true) + let tracker = SessionRebuildTracker() + + await mock.setStubbedIsConnected(true) + await mock.setStubbedConnectedDeviceID(deviceID) + await mock.setStubbedIsAutoReconnecting(false) + manager.rebuildSessionForHealthCheckOverride = { deviceID in + await tracker.record(deviceID) } - - @Test("returns early when BLE is connected and app stack is healthy") - func returnsEarlyWhenBLEConnectedAndAppStackHealthy() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - let (session, services, device) = try await makeServices(deviceID: deviceID, startEventMonitoring: true) - let tracker = SessionRebuildTracker() - - await mock.setStubbedIsConnected(true) - await mock.setStubbedConnectedDeviceID(deviceID) - manager.rebuildSessionForHealthCheckOverride = { deviceID in - await tracker.record(deviceID) - } - - manager.setTestState( - connectionState: .ready, - services: services, - session: session, - connectedDevice: device, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - #expect(manager.connectionState == .ready) - #expect(services.isEventMonitoringActive) - #expect(await services.messagePollingService.isAutoFetching) - #expect(await tracker.recordedCalls().isEmpty) + manager.setTestState( + connectionState: .ready, + services: services, + session: session, + connectedDevice: device, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(await tracker.recordedCalls().isEmpty) + #expect(services.isEventMonitoringActive) + #expect(await services.messagePollingService.isAutoFetching) + } + + @Test + func `BLE connected skips duplicate recovery while session rebuild is active`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + let tracker = SessionRebuildTracker() + + await mock.setStubbedIsConnected(true) + await mock.setStubbedConnectedDeviceID(deviceID) + await mock.setStubbedIsAutoReconnecting(false) + manager.rebuildSessionForHealthCheckOverride = { deviceID in + await tracker.record(deviceID) } - - @Test("skips reconnect during iOS auto-reconnect") - func skipsWhenAutoReconnecting() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(true) - - manager.setTestState( - connectionState: .connecting, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = UUID() - - await manager.checkBLEConnectionHealth() - - // Should not interfere with iOS auto-reconnect - #expect(manager.connectionState == .connecting) + manager.setTestState( + connectionState: .disconnected, + services: nil, + session: nil, + connectedDevice: nil, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + sessionRebuildDeviceID: deviceID + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(await tracker.recordedCalls().isEmpty) + #expect(manager.sessionRebuildDeviceID == deviceID) + } + + @Test + func `health check does not attempt adoption while BLE restoration is already in progress`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedCurrentPhaseName("restoringState") + await mock.setStubbedIsDeviceConnectedToSystem(true) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + let adoptionCalls = await mock.startAdoptingSystemConnectedPeripheralCalls + #expect(adoptionCalls.isEmpty) + } + + @Test + func `health check skips reconnection when Bluetooth is powered off`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + await mock.setStubbedIsBluetoothPoweredOff(true) + await mock.setStubbedIsAutoReconnecting(false) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = UUID() + + await manager.checkBLEConnectionHealth() + + // Should remain disconnected without attempting reconnection when BT is off + #expect(manager.connectionState == .disconnected) + } + + // MARK: - Stale State Detection Tests (Key Fix from b2ab8f17) + + @Test + func `detects stale state when connectionState is .ready but BLE disconnected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + // BLE is actually disconnected + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) + + // But connectionState thinks we're ready (stale state) + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + // After detecting stale state and cleanup, connectionState should be .disconnected + #expect(manager.connectionState == .disconnected) + } + + @Test + func `detects stale state when connectionState is .connected but BLE disconnected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + // BLE is actually disconnected + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) + + // But connectionState thinks we're connected (stale state) + manager.setTestState( + connectionState: .connected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + // After detecting stale state and cleanup, connectionState should be .disconnected + #expect(manager.connectionState == .disconnected) + } + + @Test + func `detects stale state when connectionState is .syncing but BLE disconnected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) + + try await manager.setTestState( + connectionState: .syncing, + services: ServiceContainer.forTesting( + session: MeshCoreSession(transport: SimulatorMockTransport()) + ), + session: MeshCoreSession(transport: SimulatorMockTransport()), + connectedDevice: DeviceDTO.testDevice(id: deviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + #expect(manager.connectionState == .disconnected) + } + + @Test + func `does not trigger cleanup when already disconnected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) + + // Already in disconnected state (not stale, expected state) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + // State should remain disconnected, no double-cleanup needed + #expect(manager.connectionState == .disconnected) + } + + // MARK: - Callback Verification Test + + @Test + func `calls onConnectionLost when stale state detected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) + + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + let tracker = ConnectionLostTracker() + manager.onConnectionLost = { + await tracker.markConnectionLost() } - @Test("skips foreground reconnect while pairing is in progress") - func skipsWhenPairingInProgress() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() + await manager.checkBLEConnectionHealth() - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) + let wasCalled = await tracker.connectionLostCalled + #expect(wasCalled, "onConnectionLost should be called when stale state is detected") + } - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection(), - isPairingInProgress: true - ) - manager.testLastConnectedDeviceID = UUID() + // MARK: - Intent Preservation Tests - await manager.checkBLEConnectionHealth() + @Test + func `preserves wantsConnection intent after resyncFailed disconnect`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() - // Gate fires before connect(to:) — connectionState stays .disconnected - #expect(manager.connectionState == .disconnected) - } + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) - @Test("skips reconnect while session rebuild is in progress") - func skipsWhenSessionRebuildInProgress() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + // Simulate state where we were connected and user wants connection + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) + // Disconnect due to resync failure (internal reason) + await manager.disconnect(reason: .resyncFailed) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection(), - sessionRebuildDeviceID: deviceID - ) - manager.testLastConnectedDeviceID = deviceID + // Intent should be preserved — user never asked to disconnect + #expect(manager.connectionIntent.wantsConnection, + "connectionIntent should remain .wantsConnection after resyncFailed disconnect") - await manager.checkBLEConnectionHealth() + // Health check should proceed past the guard and attempt reconnection + // (it won't actually connect since there's no real BLE, but it shouldn't + // bail out at the intent check) + await manager.checkBLEConnectionHealth() - #expect(manager.connectionState == .disconnected) - #expect(manager.sessionRebuildDeviceID == deviceID) - } + // After health check, state should still reflect wanting connection + #expect(manager.connectionIntent.wantsConnection, + "connectionIntent should still be .wantsConnection after health check") + } - // MARK: - Connected Transport App-Stack Reconciliation Tests - - @Test("BLE connected with missing session and services rebuilds app stack") - func bleConnectedMissingAppStackRebuildsSession() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - let tracker = SessionRebuildTracker() - - await mock.setStubbedIsConnected(true) - await mock.setStubbedConnectedDeviceID(deviceID) - await mock.setStubbedIsAutoReconnecting(false) - manager.rebuildSessionForHealthCheckOverride = { deviceID in - await tracker.record(deviceID) - } - - manager.setTestState( - connectionState: .disconnected, - services: nil, - session: nil, - connectedDevice: nil, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - #expect(await tracker.recordedCalls() == [deviceID]) - } + // MARK: - Lifecycle Tests - @Test("BLE connected ready state restarts missing event monitoring") - func bleConnectedReadyRestartsMissingEventMonitoring() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - let (session, services, device) = try await makeServices(deviceID: deviceID) - - await mock.setStubbedIsConnected(true) - await mock.setStubbedConnectedDeviceID(deviceID) - await mock.setStubbedIsAutoReconnecting(false) - manager.setTestState( - connectionState: .ready, - services: services, - session: session, - connectedDevice: device, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - #expect(!services.isEventMonitoringActive) - #expect(!(await services.messagePollingService.isAutoFetching)) - - await manager.checkBLEConnectionHealth() - - #expect(services.isEventMonitoringActive) - #expect(await services.messagePollingService.isAutoFetching) - } - - @Test("BLE connected with healthy listeners does not rebuild") - func bleConnectedHealthyListenersDoesNotRebuild() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - let (session, services, device) = try await makeServices(deviceID: deviceID, startEventMonitoring: true) - let tracker = SessionRebuildTracker() - - await mock.setStubbedIsConnected(true) - await mock.setStubbedConnectedDeviceID(deviceID) - await mock.setStubbedIsAutoReconnecting(false) - manager.rebuildSessionForHealthCheckOverride = { deviceID in - await tracker.record(deviceID) - } - manager.setTestState( - connectionState: .ready, - services: services, - session: session, - connectedDevice: device, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - #expect(await tracker.recordedCalls().isEmpty) - #expect(services.isEventMonitoringActive) - #expect(await services.messagePollingService.isAutoFetching) - } - - @Test("BLE connected skips duplicate recovery while session rebuild is active") - func bleConnectedSkipsDuplicateRecoveryDuringSessionRebuild() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - let tracker = SessionRebuildTracker() - - await mock.setStubbedIsConnected(true) - await mock.setStubbedConnectedDeviceID(deviceID) - await mock.setStubbedIsAutoReconnecting(false) - manager.rebuildSessionForHealthCheckOverride = { deviceID in - await tracker.record(deviceID) - } - manager.setTestState( - connectionState: .disconnected, - services: nil, - session: nil, - connectedDevice: nil, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection(), - sessionRebuildDeviceID: deviceID - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - #expect(await tracker.recordedCalls().isEmpty) - #expect(manager.sessionRebuildDeviceID == deviceID) - } - - @Test("health check does not attempt adoption while BLE restoration is already in progress") - func healthCheckSkipsAdoptionDuringRestoration() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedCurrentPhaseName("restoringState") - await mock.setStubbedIsDeviceConnectedToSystem(true) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - let adoptionCalls = await mock.startAdoptingSystemConnectedPeripheralCalls - #expect(adoptionCalls.isEmpty) - } + @Test + func `appDidEnterBackground forwards to state machine`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() - @Test("health check skips reconnection when Bluetooth is powered off") - func healthCheckSkipsReconnectionWhenPoweredOff() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - await mock.setStubbedIsBluetoothPoweredOff(true) - await mock.setStubbedIsAutoReconnecting(false) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = UUID() - - await manager.checkBLEConnectionHealth() - - // Should remain disconnected without attempting reconnection when BT is off - #expect(manager.connectionState == .disconnected) - } - - // MARK: - Stale State Detection Tests (Key Fix from b2ab8f17) - - @Test("detects stale state when connectionState is .ready but BLE disconnected") - func detectsStaleReadyState() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + await manager.appDidEnterBackground() - // BLE is actually disconnected - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) + let callCount = await mock.appDidEnterBackgroundCallCount + #expect(callCount == 1) + } - // But connectionState thinks we're ready (stale state) - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID + @Test + func `appDidBecomeActive forwards to state machine and triggers health check`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() - await manager.checkBLEConnectionHealth() + await manager.appDidBecomeActive() - // After detecting stale state and cleanup, connectionState should be .disconnected - #expect(manager.connectionState == .disconnected) - } + let callCount = await mock.appDidBecomeActiveCallCount + #expect(callCount == 1) + } - @Test("detects stale state when connectionState is .connected but BLE disconnected") - func detectsStaleConnectedState() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + @Test + func `appDidEnterBackground stops running watchdog`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + await mock.setStubbedIsAutoReconnecting(false) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) - // BLE is actually disconnected - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) + // Start watchdog via foreground + await manager.appDidBecomeActive() + #expect(manager.isReconnectionWatchdogRunning) - // But connectionState thinks we're connected (stale state) - manager.setTestState( - connectionState: .connected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID + // Background should stop it + await manager.appDidEnterBackground() + #expect(!manager.isReconnectionWatchdogRunning) + } - await manager.checkBLEConnectionHealth() + @Test + func `appDidBecomeActive re-arms watchdog when disconnected and wants connection`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() - // After detecting stale state and cleanup, connectionState should be .disconnected - #expect(manager.connectionState == .disconnected) - } + await mock.setStubbedIsAutoReconnecting(false) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) - @Test("detects stale state when connectionState is .syncing but BLE disconnected") - func detectsStaleSyncingState() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) - - manager.setTestState( - connectionState: .syncing, - services: try await ServiceContainer.forTesting( - session: MeshCoreSession(transport: SimulatorMockTransport()) - ), - session: MeshCoreSession(transport: SimulatorMockTransport()), - connectedDevice: DeviceDTO.testDevice(id: deviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - #expect(manager.connectionState == .disconnected) - } + await manager.appDidBecomeActive() - @Test("does not trigger cleanup when already disconnected") - func noCleanupWhenAlreadyDisconnected() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) - - // Already in disconnected state (not stale, expected state) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - // State should remain disconnected, no double-cleanup needed - #expect(manager.connectionState == .disconnected) - } + #expect(manager.isReconnectionWatchdogRunning) - // MARK: - Callback Verification Test + await manager.appDidEnterBackground() + } + + @Test + func `appDidBecomeActive does not arm watchdog when user does not want connection`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + + await mock.setStubbedIsAutoReconnecting(false) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .none + ) + + await manager.appDidBecomeActive() + + #expect(!manager.isReconnectionWatchdogRunning) + } + + @Test + func `appDidBecomeActive does not arm watchdog during auto-reconnect`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + + await mock.setStubbedIsAutoReconnecting(true) + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) - @Test("calls onConnectionLost when stale state detected") - func callsOnConnectionLostForStaleState() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + await manager.appDidBecomeActive() - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) - - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - let tracker = ConnectionLostTracker() - manager.onConnectionLost = { - await tracker.markConnectionLost() - } - - await manager.checkBLEConnectionHealth() - - let wasCalled = await tracker.connectionLostCalled - #expect(wasCalled, "onConnectionLost should be called when stale state is detected") - } - - // MARK: - Intent Preservation Tests - - @Test("preserves wantsConnection intent after resyncFailed disconnect") - func preservesIntentAfterResyncFailed() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(false) - - // Simulate state where we were connected and user wants connection - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - // Disconnect due to resync failure (internal reason) - await manager.disconnect(reason: .resyncFailed) - - // Intent should be preserved — user never asked to disconnect - #expect(manager.connectionIntent.wantsConnection, - "connectionIntent should remain .wantsConnection after resyncFailed disconnect") - - // Health check should proceed past the guard and attempt reconnection - // (it won't actually connect since there's no real BLE, but it shouldn't - // bail out at the intent check) - await manager.checkBLEConnectionHealth() - - // After health check, state should still reflect wanting connection - #expect(manager.connectionIntent.wantsConnection, - "connectionIntent should still be .wantsConnection after health check") - } - - // MARK: - Lifecycle Tests - - @Test("appDidEnterBackground forwards to state machine") - func appDidEnterBackgroundForwardsToStateMachine() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await manager.appDidEnterBackground() - - let callCount = await mock.appDidEnterBackgroundCallCount - #expect(callCount == 1) - } - - @Test("appDidBecomeActive forwards to state machine and triggers health check") - func appDidBecomeActiveForwardsToStateMachine() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await manager.appDidBecomeActive() - - let callCount = await mock.appDidBecomeActiveCallCount - #expect(callCount == 1) - } - - @Test("appDidEnterBackground stops running watchdog") - func appDidEnterBackgroundStopsWatchdog() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - await mock.setStubbedIsAutoReconnecting(false) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - // Start watchdog via foreground - await manager.appDidBecomeActive() - #expect(manager.isReconnectionWatchdogRunning) - - // Background should stop it - await manager.appDidEnterBackground() - #expect(!manager.isReconnectionWatchdogRunning) - } - - @Test("appDidBecomeActive re-arms watchdog when disconnected and wants connection") - func appDidBecomeActiveRearmsWatchdogWhenDisconnected() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await mock.setStubbedIsAutoReconnecting(false) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - await manager.appDidBecomeActive() - - #expect(manager.isReconnectionWatchdogRunning) - - await manager.appDidEnterBackground() - } - - @Test("appDidBecomeActive does not arm watchdog when user does not want connection") - func appDidBecomeActiveDoesNotArmWatchdogWhenIntentOff() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await mock.setStubbedIsAutoReconnecting(false) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .none - ) - - await manager.appDidBecomeActive() - - #expect(!manager.isReconnectionWatchdogRunning) - } - - @Test("appDidBecomeActive does not arm watchdog during auto-reconnect") - func appDidBecomeActiveDoesNotArmWatchdogDuringAutoReconnect() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - await mock.setStubbedIsAutoReconnecting(true) - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - await manager.appDidBecomeActive() - - #expect(!manager.isReconnectionWatchdogRunning) - } + #expect(!manager.isReconnectionWatchdogRunning) + } } // MARK: - Test Helpers private actor ConnectionLostTracker { - var connectionLostCalled = false + var connectionLostCalled = false - func markConnectionLost() { - connectionLostCalled = true - } + func markConnectionLost() { + connectionLostCalled = true + } } private actor SessionRebuildTracker { - private var calls: [UUID] = [] + private var calls: [UUID] = [] - func record(_ deviceID: UUID) { - calls.append(deviceID) - } + func record(_ deviceID: UUID) { + calls.append(deviceID) + } - func recordedCalls() -> [UUID] { - calls - } + func recordedCalls() -> [UUID] { + calls + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerBLEScanningTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerBLEScanningTests.swift index cb593a4c..ff82021e 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerBLEScanningTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerBLEScanningTests.swift @@ -1,104 +1,103 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ConnectionManager BLE Scanning Tests") @MainActor struct ConnectionManagerBLEScanningTests { + @Test + func `startBLEScanning starts scan and forwards discoveries`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let stream = manager.startBLEScanning() - @Test("startBLEScanning starts scan and forwards discoveries") - func startBLEScanningForwardsDiscoveries() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let stream = manager.startBLEScanning() - - let expectedID = UUID() - let receiveTask = Task { await nextValue(from: stream, timeout: .seconds(10)) } - - try await waitUntil("BLE scanning should start") { - await mock.isScanning - } - await mock.simulateDiscoveredDevice(id: expectedID, name: "MeshCore-Test", rssi: -68) - - let discovery = await receiveTask.value - #expect(discovery?.id == expectedID) - #expect(discovery?.name == "MeshCore-Test") - #expect(discovery?.rssi == -68) - #expect(await mock.startScanningCallCount == 1) - #expect(await mock.isScanning) + let expectedID = UUID() + let receiveTask = Task { await nextValue(from: stream, timeout: .seconds(10)) } - await manager.stopBLEScanning() - #expect(await mock.isScanning == false) + try await waitUntil("BLE scanning should start") { + await mock.isScanning } + await mock.simulateDiscoveredDevice(id: expectedID, name: "MeshCore-Test", rssi: -68) + + let discovery = await receiveTask.value + #expect(discovery?.id == expectedID) + #expect(discovery?.name == "MeshCore-Test") + #expect(discovery?.rssi == -68) + #expect(await mock.startScanningCallCount == 1) + #expect(await mock.isScanning) + + await manager.stopBLEScanning() + #expect(await mock.isScanning == false) + } + + @Test + func `terminated scan stream does not leave scanning active`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let stream = manager.startBLEScanning() + + let consumeTask = Task { + for await _ in stream {} + } + consumeTask.cancel() + _ = await consumeTask.result - @Test("terminated scan stream does not leave scanning active") - func terminatedStreamDoesNotLeakScanning() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let stream = manager.startBLEScanning() + try await waitUntil("Stream cleanup should stop scanning") { + await mock.isScanning == false + } - let consumeTask = Task { - for await _ in stream {} - } - consumeTask.cancel() - _ = await consumeTask.result + #expect(await mock.isScanning == false) + #expect(await mock.stopScanningCallCount >= 1) + } - try await waitUntil("Stream cleanup should stop scanning") { - await mock.isScanning == false - } + @Test + func `older stream termination does not stop newer scan`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() - #expect(await mock.isScanning == false) - #expect(await mock.stopScanningCallCount >= 1) + let stream1 = manager.startBLEScanning() + let consumeTask1 = Task { + for await _ in stream1 {} + } + try await waitUntil("stream1 scanning should start") { + await mock.isScanning } - @Test("older stream termination does not stop newer scan") - func oldStreamTerminationDoesNotStopNewScan() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - - let stream1 = manager.startBLEScanning() - let consumeTask1 = Task { - for await _ in stream1 {} - } - try await waitUntil("stream1 scanning should start") { - await mock.isScanning - } - - let stream2 = manager.startBLEScanning() - let consumeTask2 = Task { - for await _ in stream2 {} - } - try await waitUntil("stream2 scanning should start") { - await mock.startScanningCallCount == 2 - } - - consumeTask1.cancel() - _ = await consumeTask1.result - - // Fixed sleep: negative assertion — older stream cleanup must not stop the newer scan. - // We wait briefly to confirm no erroneous stopScanning call fires. - try? await Task.sleep(for: .milliseconds(100)) - - #expect(await mock.isScanning, "Newer scan should remain active") - - consumeTask2.cancel() - _ = await consumeTask2.result + let stream2 = manager.startBLEScanning() + let consumeTask2 = Task { + for await _ in stream2 {} + } + try await waitUntil("stream2 scanning should start") { + await mock.startScanningCallCount == 2 } - private func nextValue( - from stream: AsyncStream, - timeout: Duration - ) async -> DiscoveredDevice? { - await withTaskGroup(of: DiscoveredDevice?.self) { group in - group.addTask { - var iterator = stream.makeAsyncIterator() - return await iterator.next() - } - group.addTask { - try? await Task.sleep(for: timeout) - return nil - } - - let first = await group.next() ?? nil - group.cancelAll() - return first - } + consumeTask1.cancel() + _ = await consumeTask1.result + + // Fixed sleep: negative assertion — older stream cleanup must not stop the newer scan. + // We wait briefly to confirm no erroneous stopScanning call fires. + try? await Task.sleep(for: .milliseconds(100)) + + #expect(await mock.isScanning, "Newer scan should remain active") + + consumeTask2.cancel() + _ = await consumeTask2.result + } + + private func nextValue( + from stream: AsyncStream, + timeout: Duration + ) async -> DiscoveredDevice? { + await withTaskGroup(of: DiscoveredDevice?.self) { group in + group.addTask { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + + let first = await group.next() ?? nil + group.cancelAll() + return first } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerDisconnectDiagnosticsTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerDisconnectDiagnosticsTests.swift index fbd7c630..d8ac42b3 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerDisconnectDiagnosticsTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerDisconnectDiagnosticsTests.swift @@ -1,175 +1,173 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ConnectionManager Disconnect Diagnostics Tests") @MainActor struct ConnectionManagerDisconnectDiagnosticsTests { - - private let defaults: UserDefaults - - init() { - defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + private let defaults: UserDefaults + + init() { + defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + @Test + func `auto-reconnect entry persists disconnect diagnostic with error info`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) + let deviceID = UUID() + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + // Wait for ConnectionManager init to wire auto-reconnect handler. + try await waitUntil("auto-reconnect handler should be installed") { + await mock.hasAutoReconnectingHandler } - @Test("auto-reconnect entry persists disconnect diagnostic with error info") - func autoReconnectEntryPersistsDisconnectDiagnostic() async throws { - - let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - // Wait for ConnectionManager init to wire auto-reconnect handler. - try await waitUntil("auto-reconnect handler should be installed") { - await mock.hasAutoReconnectingHandler - } - - await mock.simulateAutoReconnecting( - deviceID: deviceID, - errorInfo: "domain=CBErrorDomain, code=15, desc=Failed to encrypt" - ) - - // Wait for both the .connecting state transition and the diagnostic write — - // the handler claims the cycle (sync) before running the state-machine - // queries that feed the diagnostic, so observing only the state can race - // ahead of the persistDisconnectDiagnostic call. - try await waitUntil("connectionState should transition to .connecting and diagnostic should be persisted") { - manager.connectionState == .connecting - && (manager.lastDisconnectDiagnostic ?? "") - .localizedStandardContains("source=bleStateMachine.autoReconnectingHandler") - } - - let diagnostic = manager.lastDisconnectDiagnostic ?? "" - #expect(diagnostic.localizedStandardContains("code=15")) - #expect(manager.connectionState == .connecting) + await mock.simulateAutoReconnecting( + deviceID: deviceID, + errorInfo: "domain=CBErrorDomain, code=15, desc=Failed to encrypt" + ) + + // Wait for both the .connecting state transition and the diagnostic write — + // the handler claims the cycle (sync) before running the state-machine + // queries that feed the diagnostic, so observing only the state can race + // ahead of the persistDisconnectDiagnostic call. + try await waitUntil("connectionState should transition to .connecting and diagnostic should be persisted") { + manager.connectionState == .connecting + && (manager.lastDisconnectDiagnostic ?? "") + .localizedStandardContains("source=bleStateMachine.autoReconnectingHandler") } - @Test("auto-reconnect entry skips the claim but tears down the stale OLD session during pairing") - func autoReconnectSuppressedDuringPairing() async throws { - let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) - let oldDeviceID = UUID() - - manager.setTestState( - connectionState: .ready, - connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection(), - isPairingInProgress: true - ) - - try await waitUntil("auto-reconnect handler should be installed") { - await mock.hasAutoReconnectingHandler - } - - await mock.simulateAutoReconnecting(deviceID: oldDeviceID) - - // Wait for the gate's teardown path to run. handleConnectionLoss transitions - // state to .disconnected and clears connectedDevice; observing both confirms - // teardown completed without claiming a reconnect cycle. - try await waitUntil("suppression branch should tear down OLD session") { - manager.connectionState == .disconnected && manager.connectedDevice == nil - } - - #expect(manager.activeReconnectDeviceID == nil, "Claim must remain unset so pairing's connect(to:) is not preempted") - #expect(manager.connectionState == .disconnected, "OLD session is dead — UI must not stay on .ready") - #expect(manager.connectedDevice == nil, "Stale connectedDevice must be cleared so message sends fail loudly instead of silently") - - // The suppression gate runs before the diagnostic preamble, so - // `handleConnectionLoss`'s diagnostic is the only writer for this branch - // and is the observable proof the suppression branch took the loss path. - let diagnostic = manager.lastDisconnectDiagnostic ?? "" - #expect(diagnostic.localizedStandardContains("source=handleConnectionLoss")) + let diagnostic = manager.lastDisconnectDiagnostic ?? "" + #expect(diagnostic.localizedStandardContains("code=15")) + #expect(manager.connectionState == .connecting) + } + + @Test + func `auto-reconnect entry skips the claim but tears down the stale OLD session during pairing`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) + let oldDeviceID = UUID() + + manager.setTestState( + connectionState: .ready, + connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + isPairingInProgress: true + ) + + try await waitUntil("auto-reconnect handler should be installed") { + await mock.hasAutoReconnectingHandler } - @Test("health check preserves intent and persists diagnostic when other app is connected") - func healthCheckPersistsDiagnosticWhenOtherAppConnected() async throws { - let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsDeviceConnectedToSystem(true) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID + await mock.simulateAutoReconnecting(deviceID: oldDeviceID) - await manager.checkBLEConnectionHealth() - - let diagnostic = manager.lastDisconnectDiagnostic ?? "" - #expect( - diagnostic.localizedStandardContains("source=checkBLEConnectionHealth.otherAppConnected") - ) - #expect(manager.connectionIntent.wantsConnection) - #expect(manager.isReconnectionWatchdogRunning) - - await manager.appDidEnterBackground() + // Wait for the gate's teardown path to run. handleConnectionLoss transitions + // state to .disconnected and clears connectedDevice; observing both confirms + // teardown completed without claiming a reconnect cycle. + try await waitUntil("suppression branch should tear down OLD session") { + manager.connectionState == .disconnected && manager.connectedDevice == nil } - @Test("health check adopts system-connected last device when adoption can start") - func healthCheckAdoptsSystemConnectedPeripheral() async throws { - let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsBluetoothPoweredOff(false) - await mock.setStubbedIsDeviceConnectedToSystem(true) - await mock.setStubbedDidStartAdoptingSystemConnectedPeripheral(true) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - manager.testLastConnectedDeviceID = deviceID - - await manager.checkBLEConnectionHealth() - - let calls = await mock.startAdoptingSystemConnectedPeripheralCalls - #expect(calls == [deviceID]) - - let diagnostic = manager.lastDisconnectDiagnostic ?? "" - #expect( - diagnostic.localizedStandardContains("source=checkBLEConnectionHealth.adoptSystemConnectedPeripheral") - ) - #expect(manager.connectionState == .connecting) - #expect(manager.connectionIntent.wantsConnection) - } - - @Test("manual connect adopts system-connected last device instead of throwing deviceConnectedToOtherApp") - func manualConnectAdoptsSystemConnectedPeripheral() async throws { - let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - - await mock.setStubbedIsConnected(false) - await mock.setStubbedIsAutoReconnecting(false) - await mock.setStubbedIsBluetoothPoweredOff(false) - await mock.setStubbedIsDeviceConnectedToSystem(true) - await mock.setStubbedDidStartAdoptingSystemConnectedPeripheral(true) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .none - ) - manager.testLastConnectedDeviceID = deviceID - - try await manager.connect(to: deviceID, forceFullSync: true, forceReconnect: true) - - let calls = await mock.startAdoptingSystemConnectedPeripheralCalls - #expect(calls == [deviceID]) - - let diagnostic = manager.lastDisconnectDiagnostic ?? "" - #expect(diagnostic.localizedStandardContains("source=connect(to:).adoptSystemConnectedPeripheral")) - #expect(manager.connectionState == .connecting) - #expect(manager.connectionIntent.wantsConnection) - } + #expect(manager.activeReconnectDeviceID == nil, "Claim must remain unset so pairing's connect(to:) is not preempted") + #expect(manager.connectionState == .disconnected, "OLD session is dead — UI must not stay on .ready") + #expect(manager.connectedDevice == nil, "Stale connectedDevice must be cleared so message sends fail loudly instead of silently") + + // The suppression gate runs before the diagnostic preamble, so + // `handleConnectionLoss`'s diagnostic is the only writer for this branch + // and is the observable proof the suppression branch took the loss path. + let diagnostic = manager.lastDisconnectDiagnostic ?? "" + #expect(diagnostic.localizedStandardContains("source=handleConnectionLoss")) + } + + @Test + func `health check preserves intent and persists diagnostic when other app is connected`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsDeviceConnectedToSystem(true) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + let diagnostic = manager.lastDisconnectDiagnostic ?? "" + #expect( + diagnostic.localizedStandardContains("source=checkBLEConnectionHealth.otherAppConnected") + ) + #expect(manager.connectionIntent.wantsConnection) + #expect(manager.isReconnectionWatchdogRunning) + + await manager.appDidEnterBackground() + } + + @Test + func `health check adopts system-connected last device when adoption can start`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsBluetoothPoweredOff(false) + await mock.setStubbedIsDeviceConnectedToSystem(true) + await mock.setStubbedDidStartAdoptingSystemConnectedPeripheral(true) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + manager.testLastConnectedDeviceID = deviceID + + await manager.checkBLEConnectionHealth() + + let calls = await mock.startAdoptingSystemConnectedPeripheralCalls + #expect(calls == [deviceID]) + + let diagnostic = manager.lastDisconnectDiagnostic ?? "" + #expect( + diagnostic.localizedStandardContains("source=checkBLEConnectionHealth.adoptSystemConnectedPeripheral") + ) + #expect(manager.connectionState == .connecting) + #expect(manager.connectionIntent.wantsConnection) + } + + @Test + func `manual connect adopts system-connected last device instead of throwing deviceConnectedToOtherApp`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting(defaults: defaults) + let deviceID = UUID() + + await mock.setStubbedIsConnected(false) + await mock.setStubbedIsAutoReconnecting(false) + await mock.setStubbedIsBluetoothPoweredOff(false) + await mock.setStubbedIsDeviceConnectedToSystem(true) + await mock.setStubbedDidStartAdoptingSystemConnectedPeripheral(true) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .none + ) + manager.testLastConnectedDeviceID = deviceID + + try await manager.connect(to: deviceID, forceFullSync: true, forceReconnect: true) + + let calls = await mock.startAdoptingSystemConnectedPeripheralCalls + #expect(calls == [deviceID]) + + let diagnostic = manager.lastDisconnectDiagnostic ?? "" + #expect(diagnostic.localizedStandardContains("source=connect(to:).adoptSystemConnectedPeripheral")) + #expect(manager.connectionState == .connecting) + #expect(manager.connectionIntent.wantsConnection) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPairingTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPairingTests.swift index 54b6f7c6..ba578458 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPairingTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPairingTests.swift @@ -1,337 +1,338 @@ import Foundation +@testable import MC1Services import os import Testing -@testable import MC1Services /// Thread-safe counter for tracking call counts in mock handlers. private final class Counter: Sendable { - private let lock = OSAllocatedUnfairLock(initialState: 0) - - /// Increments the counter and returns the new value. - func increment() -> Int { - lock.withLock { value in - value += 1 - return value - } + private let lock = OSAllocatedUnfairLock(initialState: 0) + + /// Increments the counter and returns the new value. + func increment() -> Int { + lock.withLock { value in + value += 1 + return value } + } } @Suite("ConnectionManager Pairing Tests") @MainActor struct ConnectionManagerPairingTests { + // MARK: - State Guard Tests - // MARK: - State Guard Tests + @Test + func `unfavoritedNodeCount throws when not connected`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() - @Test("unfavoritedNodeCount throws when not connected") - func unfavoritedNodeCountThrowsWhenDisconnected() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + try await #expect { + _ = try await manager.unfavoritedNodeCount() + } throws: { error in + guard let e = error as? ConnectionError, case .notConnected = e else { return false } + return true + } + } - try await #expect { - _ = try await manager.unfavoritedNodeCount() - } throws: { error in - guard let e = error as? ConnectionError, case .notConnected = e else { return false } - return true - } + @Test + func `removeUnfavoritedNodes throws when not connected`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + + try await #expect { + _ = try await manager.removeUnfavoritedNodes() + } throws: { error in + guard let e = error as? ConnectionError, case .notConnected = e else { return false } + return true } + } - @Test("removeUnfavoritedNodes throws when not connected") - func removeUnfavoritedNodesThrowsWhenDisconnected() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + func `removeStaleNodes throws when not connected`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() - try await #expect { - _ = try await manager.removeUnfavoritedNodes() - } throws: { error in - guard let e = error as? ConnectionError, case .notConnected = e else { return false } - return true - } + try await #expect { + _ = try await manager.removeStaleNodes(olderThanDays: 30) + } throws: { error in + guard let e = error as? ConnectionError, case .notConnected = e else { return false } + return true } + } + + @Test + func `pairNewDevice rejects re-entry without clearing the outer call's flag`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() - @Test("removeStaleNodes throws when not connected") - func removeStaleNodesThrowsWhenDisconnected() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + // Simulate the outer call having already entered pairNewDevice and + // suspended in showPicker. + manager.setTestState(isPairingInProgress: true) - try await #expect { - _ = try await manager.removeStaleNodes(olderThanDays: 30) - } throws: { error in - guard let e = error as? ConnectionError, case .notConnected = e else { return false } - return true - } + try await #expect { + try await manager.pairNewDevice() + } throws: { error in + guard let e = error as? DevicePairingError, case .alreadyInProgress = e else { return false } + return true } - @Test("pairNewDevice rejects re-entry without clearing the outer call's flag") - func pairNewDeviceRejectsReEntry() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + // The inner call's defer must not unwind the outer call's state. + #expect(manager.isPairingInProgress == true) + } - // Simulate the outer call having already entered pairNewDevice and - // suspended in showPicker. - manager.setTestState(isPairingInProgress: true) + @Test + func `pairNewDevice stops BLE scanning before showing ASK picker`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } - try await #expect { - try await manager.pairNewDevice() - } throws: { error in - guard let e = error as? DevicePairingError, case .alreadyInProgress = e else { return false } - return true - } + let stream = env.manager.startBLEScanning() + let scanConsumer = Task { + for await _ in stream {} + } + defer { scanConsumer.cancel() } - // The inner call's defer must not unwind the outer call's state. - #expect(manager.isPairingInProgress == true) + try await waitUntil("BLE scanning should start") { + await env.stateMachine.isScanning } - @Test("pairNewDevice stops BLE scanning before showing ASK picker") - func pairNewDeviceStopsBLEScanningBeforeShowingPicker() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } + let pickerEntered = AsyncStream.makeStream() + let pickerGate = AsyncStream.makeStream() + env.accessorySetupKit.pickerEnteredSignal = pickerEntered.continuation + env.accessorySetupKit.pickerGate = pickerGate.stream + env.accessorySetupKit.setPickerResult(.failure(AccessorySetupKitError.pickerDismissed)) - let stream = env.manager.startBLEScanning() - let scanConsumer = Task { - for await _ in stream {} - } - defer { scanConsumer.cancel() } + let pairTask = Task { + try? await env.manager.pairNewDevice() + } - try await waitUntil("BLE scanning should start") { - await env.stateMachine.isScanning - } + for await _ in pickerEntered.stream { + break + } - let pickerEntered = AsyncStream.makeStream() - let pickerGate = AsyncStream.makeStream() - env.accessorySetupKit.pickerEnteredSignal = pickerEntered.continuation - env.accessorySetupKit.pickerGate = pickerGate.stream - env.accessorySetupKit.setPickerResult(.failure(AccessorySetupKitError.pickerDismissed)) + #expect(await env.stateMachine.stopScanningCallCount == 1) + #expect(await env.stateMachine.isScanning == false) - let pairTask = Task { - try? await env.manager.pairNewDevice() - } + pickerGate.continuation.finish() + _ = await pairTask.result + } - for await _ in pickerEntered.stream { break } + // MARK: - Device Update Tests - #expect(await env.stateMachine.stopScanningCallCount == 1) - #expect(await env.stateMachine.isScanning == false) + @Test + func `updateDevice(with:) updates connectedDevice`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice(nodeName: "NewDevice") - pickerGate.continuation.finish() - _ = await pairTask.result - } + manager.updateDevice(with: device) - // MARK: - Device Update Tests + #expect(manager.connectedDevice?.nodeName == "NewDevice") + #expect(manager.connectedDevice?.id == device.id) + } - @Test("updateDevice(with:) updates connectedDevice") - func updateDeviceWithDTO() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice(nodeName: "NewDevice") + @Test + func `updateAutoAddConfig updates config when connected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice() + manager.updateDevice(with: device) - manager.updateDevice(with: device) + manager.updateAutoAddConfig(AutoAddConfig(bitmask: 5, maxHops: 3)) - #expect(manager.connectedDevice?.nodeName == "NewDevice") - #expect(manager.connectedDevice?.id == device.id) - } + #expect(manager.connectedDevice?.autoAddConfig == 5) + #expect(manager.connectedDevice?.autoAddMaxHops == 3) + } - @Test("updateAutoAddConfig updates config when connected") - func updateAutoAddConfigWhenConnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice() - manager.updateDevice(with: device) + @Test + func `updateAutoAddConfig does nothing when not connected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.updateAutoAddConfig(AutoAddConfig(bitmask: 5, maxHops: 3)) + manager.updateAutoAddConfig(AutoAddConfig(bitmask: 5, maxHops: 3)) - #expect(manager.connectedDevice?.autoAddConfig == 5) - #expect(manager.connectedDevice?.autoAddMaxHops == 3) - } + #expect(manager.connectedDevice == nil) + } - @Test("updateAutoAddConfig does nothing when not connected") - func updateAutoAddConfigWhenDisconnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + func `updateClientRepeat updates repeat flag when connected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice() + manager.updateDevice(with: device) - manager.updateAutoAddConfig(AutoAddConfig(bitmask: 5, maxHops: 3)) + manager.updateClientRepeat(true) - #expect(manager.connectedDevice == nil) - } + #expect(manager.connectedDevice?.clientRepeat == true) + } - @Test("updateClientRepeat updates repeat flag when connected") - func updateClientRepeatWhenConnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice() - manager.updateDevice(with: device) + @Test + func `updatePathHashMode updates hash mode when connected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice() + manager.updateDevice(with: device) - manager.updateClientRepeat(true) + manager.updatePathHashMode(2) - #expect(manager.connectedDevice?.clientRepeat == true) - } + #expect(manager.connectedDevice?.pathHashMode == 2) + } - @Test("updatePathHashMode updates hash mode when connected") - func updatePathHashModeWhenConnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice() - manager.updateDevice(with: device) + // MARK: - Pre-Repeat Settings Tests - manager.updatePathHashMode(2) + @Test + func `savePreRepeatSettings changes connectedDevice`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice( + frequency: 915_000, + bandwidth: 250_000, + spreadingFactor: 10, + codingRate: 5, + txPower: 20 + ) + manager.updateDevice(with: device) + let original = manager.connectedDevice - #expect(manager.connectedDevice?.pathHashMode == 2) - } + manager.savePreRepeatSettings() - // MARK: - Pre-Repeat Settings Tests - - @Test("savePreRepeatSettings changes connectedDevice") - func savePreRepeatSettingsChangesDevice() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice( - frequency: 915_000, - bandwidth: 250_000, - spreadingFactor: 10, - codingRate: 5, - txPower: 20 - ) - manager.updateDevice(with: device) - let original = manager.connectedDevice - - manager.savePreRepeatSettings() - - #expect(manager.connectedDevice != original) - #expect(manager.connectedDevice != nil) - } + #expect(manager.connectedDevice != original) + #expect(manager.connectedDevice != nil) + } - @Test("clearPreRepeatSettings clears saved settings") - func clearPreRepeatSettingsClears() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice() - manager.updateDevice(with: device) + @Test + func `clearPreRepeatSettings clears saved settings`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice() + manager.updateDevice(with: device) - manager.savePreRepeatSettings() - let afterSave = manager.connectedDevice + manager.savePreRepeatSettings() + let afterSave = manager.connectedDevice - manager.clearPreRepeatSettings() - let afterClear = manager.connectedDevice + manager.clearPreRepeatSettings() + let afterClear = manager.connectedDevice - #expect(afterSave != afterClear) - } + #expect(afterSave != afterClear) + } - // MARK: - Other-App Reconnection Polling + // MARK: - Other-App Reconnection Polling - @Test("waitForOtherAppReconnection returns true on immediate detection") - func waitForOtherAppReconnectionImmediate() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + @Test + func `waitForOtherAppReconnection returns true on immediate detection`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() - await mock.setStubbedIsDeviceConnectedToSystem(true) + await mock.setStubbedIsDeviceConnectedToSystem(true) - let result = await manager.waitForOtherAppReconnection(deviceID) + let result = await manager.waitForOtherAppReconnection(deviceID) - #expect(result == true) - let callCount = await mock.isDeviceConnectedToSystemCalls.count - #expect(callCount == 1) - } + #expect(result == true) + let callCount = await mock.isDeviceConnectedToSystemCalls.count + #expect(callCount == 1) + } - @Test("waitForOtherAppReconnection returns false after all checks") - func waitForOtherAppReconnectionNoOtherApp() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + @Test + func `waitForOtherAppReconnection returns false after all checks`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() - await mock.setStubbedIsDeviceConnectedToSystem(false) + await mock.setStubbedIsDeviceConnectedToSystem(false) - let result = await manager.waitForOtherAppReconnection(deviceID) + let result = await manager.waitForOtherAppReconnection(deviceID) - #expect(result == false) - let callCount = await mock.isDeviceConnectedToSystemCalls.count - #expect(callCount == 6) - } + #expect(result == false) + let callCount = await mock.isDeviceConnectedToSystemCalls.count + #expect(callCount == 6) + } - @Test("waitForOtherAppReconnection detects delayed reconnection") - func waitForOtherAppReconnectionDelayed() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() - let deviceID = UUID() + @Test + func `waitForOtherAppReconnection detects delayed reconnection`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() - // Return true on the 3rd call using a counter outside the actor - let callCounter = Counter() - await mock.setIsDeviceConnectedToSystemHandler { _ in - return callCounter.increment() >= 3 - } + // Return true on the 3rd call using a counter outside the actor + let callCounter = Counter() + await mock.setIsDeviceConnectedToSystemHandler { _ in + callCounter.increment() >= 3 + } - let result = await manager.waitForOtherAppReconnection(deviceID) + let result = await manager.waitForOtherAppReconnection(deviceID) - #expect(result == true) - let callCount = await mock.isDeviceConnectedToSystemCalls.count - #expect(callCount == 3) - } + #expect(result == true) + let callCount = await mock.isDeviceConnectedToSystemCalls.count + #expect(callCount == 3) + } - // MARK: - Data Operations + // MARK: - Data Operations - @Test("fetchSavedDevices returns empty array when no devices saved") - func fetchSavedDevicesEmpty() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + func `fetchSavedDevices returns empty array when no devices saved`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() - let devices = try await manager.fetchSavedDevices() + let devices = try await manager.fetchSavedDevices() - #expect(devices.isEmpty) - } + #expect(devices.isEmpty) + } - @Test("deleteDevice completes without error for non-existent device") - func deleteDeviceNonExistent() async throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + func `deleteDevice completes without error for non-existent device`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() - try await manager.deleteDevice(id: UUID()) - } + try await manager.deleteDevice(id: UUID()) + } - // MARK: - Forget/Resume Signal + // MARK: - Forget/Resume Signal - @Test("deleteDevice clears the persisted connection when removing the last-connected radio") - func deleteDeviceClearsLastConnectedSignal() async throws { - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) + @Test + func `deleteDevice clears the persisted connection when removing the last-connected radio`() async throws { + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - manager.persistConnection(deviceID: deviceID, radioID: UUID(), deviceName: "Radio") - #expect(manager.lastConnectedDeviceID == deviceID) + let deviceID = UUID() + manager.persistConnection(deviceID: deviceID, radioID: UUID(), deviceName: "Radio") + #expect(manager.lastConnectedDeviceID == deviceID) - try await manager.deleteDevice(id: deviceID) + try await manager.deleteDevice(id: deviceID) - // The auto-reconnect / onboarding-resume signal must not survive deleting its own radio. - #expect(manager.lastConnectedDeviceID == nil) - } + // The auto-reconnect / onboarding-resume signal must not survive deleting its own radio. + #expect(manager.lastConnectedDeviceID == nil) + } - @Test("deleteDevice preserves the persisted connection when removing a different radio") - func deleteDevicePreservesOtherLastConnectedSignal() async throws { - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) + @Test + func `deleteDevice preserves the persisted connection when removing a different radio`() async throws { + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) - let lastConnected = UUID() - manager.persistConnection(deviceID: lastConnected, radioID: UUID(), deviceName: "Radio") + let lastConnected = UUID() + manager.persistConnection(deviceID: lastConnected, radioID: UUID(), deviceName: "Radio") - try await manager.deleteDevice(id: UUID()) + try await manager.deleteDevice(id: UUID()) - #expect(manager.lastConnectedDeviceID == lastConnected) - } + #expect(manager.lastConnectedDeviceID == lastConnected) + } - @Test("forgetDevice clears the persisted connection when removing the last-connected radio") - func forgetDeviceClearsLastConnectedSignal() async throws { - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) + @Test + func `forgetDevice clears the persisted connection when removing the last-connected radio`() async throws { + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) - let deviceID = UUID() - manager.persistConnection(deviceID: deviceID, radioID: UUID(), deviceName: "Radio") - #expect(manager.lastConnectedDeviceID == deviceID) + let deviceID = UUID() + manager.persistConnection(deviceID: deviceID, radioID: UUID(), deviceName: "Radio") + #expect(manager.lastConnectedDeviceID == deviceID) - await manager.forgetDevice(id: deviceID) + await manager.forgetDevice(id: deviceID) - // The auto-reconnect / onboarding-resume signal must not survive forgetting its own radio. - #expect(manager.lastConnectedDeviceID == nil) - } + // The auto-reconnect / onboarding-resume signal must not survive forgetting its own radio. + #expect(manager.lastConnectedDeviceID == nil) + } - @Test("forgetDevice preserves the persisted connection when removing a different radio") - func forgetDevicePreservesOtherLastConnectedSignal() async throws { - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) + @Test + func `forgetDevice preserves the persisted connection when removing a different radio`() async throws { + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + let (manager, _) = try ConnectionManager.createForTesting(defaults: defaults) - let lastConnected = UUID() - manager.persistConnection(deviceID: lastConnected, radioID: UUID(), deviceName: "Radio") + let lastConnected = UUID() + manager.persistConnection(deviceID: lastConnected, radioID: UUID(), deviceName: "Radio") - await manager.forgetDevice(id: UUID()) + await manager.forgetDevice(id: UUID()) - #expect(manager.lastConnectedDeviceID == lastConnected) - } + #expect(manager.lastConnectedDeviceID == lastConnected) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPromotionTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPromotionTests.swift index d6d45b5e..3fc8256d 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPromotionTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerPromotionTests.swift @@ -1,246 +1,245 @@ import Foundation -import Testing -import MeshCoreTestSupport @testable import MC1Services +import MeshCoreTestSupport +import Testing @Suite("ConnectionManager Promotion Tests") @MainActor struct ConnectionManagerPromotionTests { - - private func makeTestServices() async throws -> ServiceContainer { - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - return try await ServiceContainer.forTesting(session: session) - } - - /// Sets up manager state for promotion tests. Sets session and connectedDevice - /// so the .ready invariant can pass when promotion succeeds. - private func setupForPromotion( - manager: ConnectionManager, - services: ServiceContainer, - connectionState: DeviceConnectionState = .connected, - connectionIntent: ConnectionIntent = .wantsConnection() - ) { - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - manager.setTestState( - connectionState: connectionState, - services: services, - session: session, - connectedDevice: DeviceDTO.testDevice(), - connectionIntent: connectionIntent - ) - } - - // MARK: - Suppression: services replaced - - @Test("promoteToReady suppressed when services replaced during sync") - func promoteSuppressedOnServicesReplacement() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let originalServices = try await makeTestServices() - let replacementServices = try await makeTestServices() - - setupForPromotion(manager: manager, services: originalServices) - - // Simulate: disconnect + new connection replaced services - manager.setTestState(services: replacementServices) - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: originalServices, - transportType: .bluetooth - ) - - #expect(!promoted) - #expect(manager.connectionState == .connected) - } - - // MARK: - Suppression: services nil (disconnected) - - @Test("promoteToReady suppressed when services nil (disconnected)") - func promoteSuppressedOnDisconnect() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let originalServices = try await makeTestServices() - - manager.setTestState( - connectionState: .disconnected, - services: .some(nil), - connectionIntent: .wantsConnection() - ) - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: originalServices, - transportType: .bluetooth - ) - - #expect(!promoted) - #expect(manager.connectionState == .disconnected) - } - - // MARK: - Sync failure sets .syncing (resync loop continues from there) - - @Test("promoteToReady sets .syncing when sync failed") - func promoteSetsSyncingOnFailure() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - - setupForPromotion(manager: manager, services: services) - - let promoted = await manager.promoteToReady( - syncSucceeded: false, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(promoted) - #expect(manager.connectionState == .syncing) - } - - // MARK: - Sync failure skips onDeviceSynced - - @Test("promoteToReady skips onDeviceSynced when sync failed") - func syncFailureSkipsPostSyncWork() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - var onDeviceSyncedCalled = false - - setupForPromotion(manager: manager, services: services) - manager.onDeviceSynced = { onDeviceSyncedCalled = true } - - let promoted = await manager.promoteToReady( - syncSucceeded: false, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(promoted, "Should still promote to .syncing for resync loop") - #expect(!onDeviceSyncedCalled, "onDeviceSynced should be skipped on sync failure") - } - - // MARK: - Sync success sets .ready and fires onDeviceSynced - - @Test("promoteToReady sets .ready when sync succeeded") - func promoteReadyOnSuccess() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - - setupForPromotion(manager: manager, services: services) - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(promoted) - #expect(manager.connectionState == .ready) - } - - @Test("promoteToReady calls onDeviceSynced when sync succeeded") - func promoteCallsOnDeviceSyncedOnSuccess() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - var onDeviceSyncedCalled = false - - setupForPromotion(manager: manager, services: services) - manager.onDeviceSynced = { onDeviceSyncedCalled = true } - - await manager.promoteToReady( - syncSucceeded: true, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(onDeviceSyncedCalled) - } - - @Test("promoteToReady does NOT call onDeviceSynced when sync failed") - func promoteSkipsOnDeviceSyncedOnFailure() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - var onDeviceSyncedCalled = false - - setupForPromotion(manager: manager, services: services) - manager.onDeviceSynced = { onDeviceSyncedCalled = true } - - await manager.promoteToReady( - syncSucceeded: false, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(!onDeviceSyncedCalled) - } - - // MARK: - Happy path - - @Test("promoteToReady sets .ready and fires onDeviceSynced on successful sync") - func promoteSucceedsOnHappyPath() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - var onDeviceSyncedCalled = false - - setupForPromotion(manager: manager, services: services) - manager.onDeviceSynced = { onDeviceSyncedCalled = true } - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(promoted) - #expect(manager.connectionState == .ready) - #expect(manager.currentTransportType == .bluetooth) - #expect(onDeviceSyncedCalled, "onDeviceSynced should fire on successful sync") - } - - // MARK: - Suppression: user disconnected - - @Test("promoteToReady suppressed when user disconnected during sync") - func promoteSuppressedOnUserDisconnect() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - - setupForPromotion(manager: manager, services: services, connectionIntent: .userDisconnected) - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: services, - transportType: .bluetooth - ) - - #expect(!promoted) - #expect(manager.connectionState == .connected) - } - - // MARK: - Additional guard suppresses promotion - - @Test("promoteToReady suppressed when additionalGuard returns false") - func promoteSuppressedByAdditionalGuard() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let services = try await makeTestServices() - - setupForPromotion(manager: manager, services: services) - - let promoted = await manager.promoteToReady( - syncSucceeded: true, - expectedServices: services, - transportType: .bluetooth, - additionalGuard: { false } - ) - - #expect(!promoted) - #expect(manager.connectionState == .connected) - } - - // MARK: - Post-time-sync re-validation (not unit-testable) - - // promoteToReady re-checks connectionIntent, services identity, and additionalGuard - // after syncDeviceTimeIfNeeded() returns. These guards are structurally identical to - // the pre-await guards tested above. Exercising them requires mutating state during - // the syncDeviceTimeIfNeeded() suspension point, which needs concurrency interleaving - // that unit tests cannot reliably control. Covering this path requires integration - // tests with a real (or delay-injected) session where state can change mid-await. + private func makeTestServices() async throws -> ServiceContainer { + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + return try await ServiceContainer.forTesting(session: session) + } + + /// Sets up manager state for promotion tests. Sets session and connectedDevice + /// so the .ready invariant can pass when promotion succeeds. + private func setupForPromotion( + manager: ConnectionManager, + services: ServiceContainer, + connectionState: DeviceConnectionState = .connected, + connectionIntent: ConnectionIntent = .wantsConnection() + ) { + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + manager.setTestState( + connectionState: connectionState, + services: services, + session: session, + connectedDevice: DeviceDTO.testDevice(), + connectionIntent: connectionIntent + ) + } + + // MARK: - Suppression: services replaced + + @Test + func `promoteToReady suppressed when services replaced during sync`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let originalServices = try await makeTestServices() + let replacementServices = try await makeTestServices() + + setupForPromotion(manager: manager, services: originalServices) + + // Simulate: disconnect + new connection replaced services + manager.setTestState(services: replacementServices) + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: originalServices, + transportType: .bluetooth + ) + + #expect(!promoted) + #expect(manager.connectionState == .connected) + } + + // MARK: - Suppression: services nil (disconnected) + + @Test + func `promoteToReady suppressed when services nil (disconnected)`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let originalServices = try await makeTestServices() + + manager.setTestState( + connectionState: .disconnected, + services: .some(nil), + connectionIntent: .wantsConnection() + ) + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: originalServices, + transportType: .bluetooth + ) + + #expect(!promoted) + #expect(manager.connectionState == .disconnected) + } + + // MARK: - Sync failure sets .syncing (resync loop continues from there) + + @Test + func `promoteToReady sets .syncing when sync failed`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + + setupForPromotion(manager: manager, services: services) + + let promoted = await manager.promoteToReady( + syncSucceeded: false, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(promoted) + #expect(manager.connectionState == .syncing) + } + + // MARK: - Sync failure skips onDeviceSynced + + @Test + func `promoteToReady skips onDeviceSynced when sync failed`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + var onDeviceSyncedCalled = false + + setupForPromotion(manager: manager, services: services) + manager.onDeviceSynced = { onDeviceSyncedCalled = true } + + let promoted = await manager.promoteToReady( + syncSucceeded: false, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(promoted, "Should still promote to .syncing for resync loop") + #expect(!onDeviceSyncedCalled, "onDeviceSynced should be skipped on sync failure") + } + + // MARK: - Sync success sets .ready and fires onDeviceSynced + + @Test + func `promoteToReady sets .ready when sync succeeded`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + + setupForPromotion(manager: manager, services: services) + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(promoted) + #expect(manager.connectionState == .ready) + } + + @Test + func `promoteToReady calls onDeviceSynced when sync succeeded`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + var onDeviceSyncedCalled = false + + setupForPromotion(manager: manager, services: services) + manager.onDeviceSynced = { onDeviceSyncedCalled = true } + + await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(onDeviceSyncedCalled) + } + + @Test + func `promoteToReady does NOT call onDeviceSynced when sync failed`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + var onDeviceSyncedCalled = false + + setupForPromotion(manager: manager, services: services) + manager.onDeviceSynced = { onDeviceSyncedCalled = true } + + await manager.promoteToReady( + syncSucceeded: false, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(!onDeviceSyncedCalled) + } + + // MARK: - Happy path + + @Test + func `promoteToReady sets .ready and fires onDeviceSynced on successful sync`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + var onDeviceSyncedCalled = false + + setupForPromotion(manager: manager, services: services) + manager.onDeviceSynced = { onDeviceSyncedCalled = true } + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(promoted) + #expect(manager.connectionState == .ready) + #expect(manager.currentTransportType == .bluetooth) + #expect(onDeviceSyncedCalled, "onDeviceSynced should fire on successful sync") + } + + // MARK: - Suppression: user disconnected + + @Test + func `promoteToReady suppressed when user disconnected during sync`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + + setupForPromotion(manager: manager, services: services, connectionIntent: .userDisconnected) + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth + ) + + #expect(!promoted) + #expect(manager.connectionState == .connected) + } + + // MARK: - Additional guard suppresses promotion + + @Test + func `promoteToReady suppressed when additionalGuard returns false`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let services = try await makeTestServices() + + setupForPromotion(manager: manager, services: services) + + let promoted = await manager.promoteToReady( + syncSucceeded: true, + expectedServices: services, + transportType: .bluetooth, + additionalGuard: { false } + ) + + #expect(!promoted) + #expect(manager.connectionState == .connected) + } + + // MARK: - Post-time-sync re-validation (not unit-testable) + + // promoteToReady re-checks connectionIntent, services identity, and additionalGuard + // after syncDeviceTimeIfNeeded() returns. These guards are structurally identical to + // the pre-await guards tested above. Exercising them requires mutating state during + // the syncDeviceTimeIfNeeded() suspension point, which needs concurrency interleaving + // that unit tests cannot reliably control. Covering this path requires integration + // tests with a real (or delay-injected) session where state can change mid-await. } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerResyncLoopTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerResyncLoopTests.swift index 2cb27862..03ca708e 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerResyncLoopTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerResyncLoopTests.swift @@ -1,278 +1,282 @@ import Foundation -import Testing +@testable import MC1Services import MeshCore import MeshCoreTestSupport -@testable import MC1Services +import Testing @Suite("ConnectionManager Resync Loop Tests") @MainActor struct ConnectionManagerResyncLoopTests { - - /// Creates a manager + services where performResync fails immediately - /// (transport not connected → syncContacts throws .notConnected). - private func makeResyncTestHarness(deviceID: UUID) async throws -> ( - manager: ConnectionManager, - services: ServiceContainer, - session: MeshCoreSession - ) { - let (manager, _) = try ConnectionManager.createForTesting() - let transport = SimulatorMockTransport() - // Don't connect transport — commands fail immediately with .notConnected - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 0.5, clientIdentifier: "ResyncTest") - ) - let services = try await ServiceContainer.forTesting(session: session) - - // Insert a device so fetchDevice doesn't fail inside performFullSync - let device = DeviceDTO.testDevice(id: deviceID) - try await services.dataStore.saveDevice(device) - - // Set manager to .syncing with .wantsConnection — the resync loop's guard requires .isOperational - manager.setTestState( - connectionState: .syncing, - services: services, - session: session, - connectedDevice: DeviceDTO.testDevice(id: deviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - return (manager, services, session) + /// Creates a manager + services where performResync fails immediately + /// (transport not connected → syncContacts throws .notConnected). + private func makeResyncTestHarness(deviceID: UUID) async throws -> ( + manager: ConnectionManager, + services: ServiceContainer, + session: MeshCoreSession + ) { + let (manager, _) = try ConnectionManager.createForTesting() + let transport = SimulatorMockTransport() + // Don't connect transport — commands fail immediately with .notConnected + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.5, clientIdentifier: "ResyncTest") + ) + let services = try await ServiceContainer.forTesting(session: session) + + // Insert a device so fetchDevice doesn't fail inside performFullSync + let device = DeviceDTO.testDevice(id: deviceID) + try await services.dataStore.saveDevice(device) + + // Set manager to .syncing with .wantsConnection — the resync loop's guard requires .isOperational + manager.setTestState( + connectionState: .syncing, + services: services, + session: session, + connectedDevice: DeviceDTO.testDevice(id: deviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + return (manager, services, session) + } + + // MARK: - Cancellation + + @Test + func `Cancelling resync loop closes the activity bracket with succeeded=false`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + + let startedTracker = CallTracker() + let succeededValues = ValueTracker() + + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) + + manager.startResyncLoop(radioID: deviceID, services: services) + + // Wait for the outer bracket to open + try await waitUntil("beginResyncActivity should fire") { + startedTracker.wasCalled } - // MARK: - Cancellation - - @Test("Cancelling resync loop closes the activity bracket with succeeded=false") - func cancellationClosesBracket() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - - let startedTracker = CallTracker() - let succeededValues = ValueTracker() - - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) - - manager.startResyncLoop(radioID: deviceID, services: services) + // Cancel the loop + manager.cancelResyncLoop() - // Wait for the outer bracket to open - try await waitUntil("beginResyncActivity should fire") { - startedTracker.wasCalled - } - - // Cancel the loop - manager.cancelResyncLoop() - - // Wait for the catch-all to close the bracket - try await waitUntil("endResyncActivity should fire after cancellation") { - !succeededValues.values.isEmpty - } - - // The catch-all should report failure - let lastSucceeded = succeededValues.values.last - #expect(lastSucceeded == false, "Cancelled resync should report succeeded=false") + // Wait for the catch-all to close the bracket + try await waitUntil("endResyncActivity should fire after cancellation") { + !succeededValues.values.isEmpty } - // MARK: - Guard Exit + // The catch-all should report failure + let lastSucceeded = succeededValues.values.last + #expect(lastSucceeded == false, "Cancelled resync should report succeeded=false") + } - @Test("Resync loop exits and closes bracket when connectionIntent changes") - func guardExitClosesBracket() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + // MARK: - Guard Exit - let startedTracker = CallTracker() - let succeededValues = ValueTracker() + @Test + func `Resync loop exits and closes bracket when connectionIntent changes`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + let startedTracker = CallTracker() + let succeededValues = ValueTracker() - manager.startResyncLoop(radioID: deviceID, services: services) + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) - // Wait for the outer bracket to open - try await waitUntil("beginResyncActivity should fire") { - startedTracker.wasCalled - } + manager.startResyncLoop(radioID: deviceID, services: services) - // Change intent while the loop is sleeping between retries. - // The MainActor is free during Task.sleep, so this runs immediately. - manager.setTestState(connectionIntent: .userDisconnected) + // Wait for the outer bracket to open + try await waitUntil("beginResyncActivity should fire") { + startedTracker.wasCalled + } - // The guard at the top of the while loop will fail after the sleep, - // causing a break → catch-all fires endResyncActivity(succeeded: false). - try await waitUntil(timeout: .seconds(5), "endResyncActivity should fire after guard exit") { - !succeededValues.values.isEmpty - } + // Change intent while the loop is sleeping between retries. + // The MainActor is free during Task.sleep, so this runs immediately. + manager.setTestState(connectionIntent: .userDisconnected) - let lastSucceeded = succeededValues.values.last - #expect(lastSucceeded == false, "Guard exit should report succeeded=false") + // The guard at the top of the while loop will fail after the sleep, + // causing a break → catch-all fires endResyncActivity(succeeded: false). + try await waitUntil(timeout: .seconds(5), "endResyncActivity should fire after guard exit") { + !succeededValues.values.isEmpty } - // MARK: - Max Attempts - - @Test("Max resync attempts triggers disconnect and onResyncFailed", - .timeLimit(.minutes(1))) - func maxAttemptsTriggersDisconnect() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + let lastSucceeded = succeededValues.values.last + #expect(lastSucceeded == false, "Guard exit should report succeeded=false") + } - let resyncFailedTracker = CallTracker() - manager.onResyncFailed = { - resyncFailedTracker.markCalled() - } + // MARK: - Max Attempts - let succeededValues = ValueTracker() + @Test( + .timeLimit(.minutes(1)) + ) + func `Max resync attempts triggers disconnect and onResyncFailed`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + let resyncFailedTracker = CallTracker() + manager.onResyncFailed = { + resyncFailedTracker.markCalled() + } - manager.startResyncLoop(radioID: deviceID, services: services) + let succeededValues = ValueTracker() - // Wait for max attempts to be exhausted (3 attempts × 2s intervals) - try await waitUntil(timeout: .seconds(15), "onResyncFailed should fire after max attempts") { - resyncFailedTracker.wasCalled - } + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) - // Manager should have disconnected - #expect(manager.connectionState == .disconnected) + manager.startResyncLoop(radioID: deviceID, services: services) - // The endResyncActivity(succeeded: false) should have been called - // (once for each inner performFullSync failure + once for the outer bracket) - let finalValues = succeededValues.values - #expect(finalValues.last == false, "Max-attempts bracket should close with succeeded=false") - #expect(!finalValues.contains(true), "No resync iteration should report success") + // Wait for max attempts to be exhausted (3 attempts × 2s intervals) + try await waitUntil(timeout: .seconds(15), "onResyncFailed should fire after max attempts") { + resyncFailedTracker.wasCalled } - // MARK: - Bracket Counting + // Manager should have disconnected + #expect(manager.connectionState == .disconnected) + + // The endResyncActivity(succeeded: false) should have been called + // (once for each inner performFullSync failure + once for the outer bracket) + let finalValues = succeededValues.values + #expect(finalValues.last == false, "Max-attempts bracket should close with succeeded=false") + #expect(!finalValues.contains(true), "No resync iteration should report success") + } - @Test("Each resync attempt opens and closes an inner bracket", - .timeLimit(.minutes(1))) - func innerBracketPerAttempt() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + // MARK: - Bracket Counting - let startCount = CallTracker() - let endCount = CallTracker() + @Test( + .timeLimit(.minutes(1)) + ) + func `Each resync attempt opens and closes an inner bracket`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { startCount.markCalled() }, - onEnded: { _ in endCount.markCalled() }, - onPhaseChanged: { _ in } - ) + let startCount = CallTracker() + let endCount = CallTracker() - let resyncFailedTracker = CallTracker() - manager.onResyncFailed = { resyncFailedTracker.markCalled() } + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: { startCount.markCalled() }, + onEnded: { _ in endCount.markCalled() }, + onPhaseChanged: { _ in } + ) - manager.startResyncLoop(radioID: deviceID, services: services) + let resyncFailedTracker = CallTracker() + manager.onResyncFailed = { resyncFailedTracker.markCalled() } - // Wait for loop to exhaust - try await waitUntil(timeout: .seconds(15), "resync loop should exhaust") { - resyncFailedTracker.wasCalled - } + manager.startResyncLoop(radioID: deviceID, services: services) - // 1 outer start + 3 inner starts = 4 - #expect(startCount.callCount == 4, "Expected 1 outer + 3 inner activity starts, got \(startCount.callCount)") - // 3 inner ends + 1 outer end = 4 - #expect(endCount.callCount == 4, "Expected 3 inner + 1 outer activity ends, got \(endCount.callCount)") + // Wait for loop to exhaust + try await waitUntil(timeout: .seconds(15), "resync loop should exhaust") { + resyncFailedTracker.wasCalled } - // MARK: - Resync Success Promotion + // 1 outer start + 3 inner starts = 4 + #expect(startCount.callCount == 4, "Expected 1 outer + 3 inner activity starts, got \(startCount.callCount)") + // 3 inner ends + 1 outer end = 4 + #expect(endCount.callCount == 4, "Expected 3 inner + 1 outer activity ends, got \(endCount.callCount)") + } - @Test("Resync success promotes .syncing → .ready", - .timeLimit(.minutes(1))) - func resyncSuccessPromotesToReady() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + // MARK: - Resync Success Promotion - // Override performResync to succeed - await services.syncCoordinator.setPerformResyncOverride { _, _ in true } + @Test( + .timeLimit(.minutes(1)) + ) + func `Resync success promotes .syncing → .ready`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - let succeededValues = ValueTracker() - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + // Override performResync to succeed + await services.syncCoordinator.setPerformResyncOverride { _, _ in true } - manager.startResyncLoop(radioID: deviceID, services: services) + let succeededValues = ValueTracker() + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) - try await waitUntil(timeout: .seconds(10), "endResyncActivity should fire with success") { - succeededValues.values.contains(true) - } + manager.startResyncLoop(radioID: deviceID, services: services) - #expect(manager.connectionState == .ready) + try await waitUntil(timeout: .seconds(10), "endResyncActivity should fire with success") { + succeededValues.values.contains(true) } - @Test("Resync success calls onDeviceSynced", - .timeLimit(.minutes(1))) - func resyncSuccessCallsOnDeviceSynced() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + #expect(manager.connectionState == .ready) + } - // Override performResync to succeed - await services.syncCoordinator.setPerformResyncOverride { _, _ in true } + @Test( + .timeLimit(.minutes(1)) + ) + func `Resync success calls onDeviceSynced`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - let onDeviceSyncedTracker = CallTracker() - manager.onDeviceSynced = { onDeviceSyncedTracker.markCalled() } + // Override performResync to succeed + await services.syncCoordinator.setPerformResyncOverride { _, _ in true } - let succeededValues = ValueTracker() - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + let onDeviceSyncedTracker = CallTracker() + manager.onDeviceSynced = { onDeviceSyncedTracker.markCalled() } - manager.startResyncLoop(radioID: deviceID, services: services) + let succeededValues = ValueTracker() + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) - try await waitUntil(timeout: .seconds(10), "onDeviceSynced should fire after resync success") { - onDeviceSyncedTracker.wasCalled - } + manager.startResyncLoop(radioID: deviceID, services: services) - #expect(onDeviceSyncedTracker.wasCalled) + try await waitUntil(timeout: .seconds(10), "onDeviceSynced should fire after resync success") { + onDeviceSyncedTracker.wasCalled } - // MARK: - Disconnect During Syncing + #expect(onDeviceSyncedTracker.wasCalled) + } - @Test("Disconnect during .syncing transitions to .disconnected and closes bracket", - .timeLimit(.minutes(1))) - func disconnectDuringSyncingClosesBracket() async throws { - let deviceID = UUID() - let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) + // MARK: - Disconnect During Syncing - let startedTracker = CallTracker() - let succeededValues = ValueTracker() + @Test( + .timeLimit(.minutes(1)) + ) + func `Disconnect during .syncing transitions to .disconnected and closes bracket`() async throws { + let deviceID = UUID() + let (manager, services, _) = try await makeResyncTestHarness(deviceID: deviceID) - await services.syncCoordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) + let startedTracker = CallTracker() + let succeededValues = ValueTracker() - manager.startResyncLoop(radioID: deviceID, services: services) + await services.syncCoordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) - // Wait for the outer bracket to open - try await waitUntil("beginResyncActivity should fire") { - startedTracker.wasCalled - } + manager.startResyncLoop(radioID: deviceID, services: services) - // Disconnect while in .syncing - await manager.disconnect(reason: .userInitiated) + // Wait for the outer bracket to open + try await waitUntil("beginResyncActivity should fire") { + startedTracker.wasCalled + } - // Wait for bracket to close - try await waitUntil("endResyncActivity should fire after disconnect") { - !succeededValues.values.isEmpty - } + // Disconnect while in .syncing + await manager.disconnect(reason: .userInitiated) - #expect(manager.connectionState == .disconnected) - #expect(succeededValues.values.last == false) + // Wait for bracket to close + try await waitUntil("endResyncActivity should fire after disconnect") { + !succeededValues.values.isEmpty } + + #expect(manager.connectionState == .disconnected) + #expect(succeededValues.values.last == false) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerRetryBudgetTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerRetryBudgetTests.swift index 632210f8..dee9a30f 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerRetryBudgetTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerRetryBudgetTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import MC1Services +import Testing /// Covers the platform-aware BLE connect retry budget in `connect(to:forceReconnect:)`. /// @@ -11,127 +11,142 @@ import Testing @Suite("ConnectionManager Retry Budget") @MainActor struct ConnectionManagerRetryBudgetTests { + /// Minimal `DevicePairingService` with a configurable `hasSystemPairingRegistry` and, for the + /// registration-guard case, a configurable `isSessionActive`/`isDeviceConnectable`. The + /// defaults (`sessionActive: false`, `deviceConnectable: true`) make `connect(to:)` skip its + /// registration guard and reach the retry loop on either platform. Discovery is never + /// exercised by these tests. + @MainActor + private final class StubPairingService: DevicePairingService { + var delegate: (any DevicePairingDelegate)? + let hasSystemPairingRegistry: Bool + let sessionActive: Bool + let deviceConnectable: Bool + + init(hasSystemPairingRegistry: Bool, sessionActive: Bool = false, deviceConnectable: Bool = true) { + self.hasSystemPairingRegistry = hasSystemPairingRegistry + self.sessionActive = sessionActive + self.deviceConnectable = deviceConnectable + } + + var isSessionActive: Bool { + sessionActive + } + + var registeredDeviceCount: Int { + 0 + } + + var supportsSystemRename: Bool { + false + } + + func activate() async throws {} + func discoverDevice() async throws -> UUID { + throw CancellationError() + } - /// Minimal `DevicePairingService` with a configurable `hasSystemPairingRegistry` and, for the - /// registration-guard case, a configurable `isSessionActive`/`isDeviceConnectable`. The - /// defaults (`sessionActive: false`, `deviceConnectable: true`) make `connect(to:)` skip its - /// registration guard and reach the retry loop on either platform. Discovery is never - /// exercised by these tests. - @MainActor - private final class StubPairingService: DevicePairingService { - var delegate: (any DevicePairingDelegate)? - let hasSystemPairingRegistry: Bool - let sessionActive: Bool - let deviceConnectable: Bool - - init(hasSystemPairingRegistry: Bool, sessionActive: Bool = false, deviceConnectable: Bool = true) { - self.hasSystemPairingRegistry = hasSystemPairingRegistry - self.sessionActive = sessionActive - self.deviceConnectable = deviceConnectable - } - - var isSessionActive: Bool { sessionActive } - var registeredDeviceCount: Int { 0 } - var supportsSystemRename: Bool { false } - - func activate() async throws {} - func discoverDevice() async throws -> UUID { throw CancellationError() } - func isDeviceConnectable(_ id: UUID) -> Bool { deviceConnectable } - func registeredDeviceInfos() -> [(id: UUID, name: String)] { [] } - func removeDevice(_ id: UUID) async throws {} - func renameDevice(_ id: UUID) async throws {} - func clearStaleRegistrations() async {} + func isDeviceConnectable(_ id: UUID) -> Bool { + deviceConnectable } - /// `expectsBoundedBudget` is the hand-specified intent for each scenario (true only for a - /// macOS user-initiated tap), kept independent of the production formula so the test catches - /// a flipped boolean or a dropped qualifier rather than mirroring whatever the code computes. - struct RetryBudgetCase: Sendable, CustomTestStringConvertible { - let hasSystemPairingRegistry: Bool - let forceReconnect: Bool - let expectsBoundedBudget: Bool - - var testDescription: String { - "registry=\(hasSystemPairingRegistry) force=\(forceReconnect) → bounded=\(expectsBoundedBudget)" - } + func registeredDeviceInfos() -> [(id: UUID, name: String)] { + [] } - @Test( - "connect uses the unverified budget only for a macOS user-initiated tap, the full budget otherwise", - arguments: [ - // macOS user tap on an unreachable radio: bounded. - RetryBudgetCase(hasSystemPairingRegistry: false, forceReconnect: true, expectsBoundedBudget: true), - // macOS background reconnect: full budget, so the forceReconnect qualifier can't be dropped. - RetryBudgetCase(hasSystemPairingRegistry: false, forceReconnect: false, expectsBoundedBudget: false), - // iOS user tap: the registry validates the device, so the full budget always applies. - RetryBudgetCase(hasSystemPairingRegistry: true, forceReconnect: true, expectsBoundedBudget: false) - ] + func removeDevice(_ id: UUID) async throws {} + func renameDevice(_ id: UUID) async throws {} + func clearStaleRegistrations() async {} + } + + /// `expectsBoundedBudget` is the hand-specified intent for each scenario (true only for a + /// macOS user-initiated tap), kept independent of the production formula so the test catches + /// a flipped boolean or a dropped qualifier rather than mirroring whatever the code computes. + struct RetryBudgetCase: CustomTestStringConvertible { + let hasSystemPairingRegistry: Bool + let forceReconnect: Bool + let expectsBoundedBudget: Bool + + var testDescription: String { + "registry=\(hasSystemPairingRegistry) force=\(forceReconnect) → bounded=\(expectsBoundedBudget)" + } + } + + @Test( + arguments: [ + // macOS user tap on an unreachable radio: bounded. + RetryBudgetCase(hasSystemPairingRegistry: false, forceReconnect: true, expectsBoundedBudget: true), + // macOS background reconnect: full budget, so the forceReconnect qualifier can't be dropped. + RetryBudgetCase(hasSystemPairingRegistry: false, forceReconnect: false, expectsBoundedBudget: false), + // iOS user tap: the registry validates the device, so the full budget always applies. + RetryBudgetCase(hasSystemPairingRegistry: true, forceReconnect: true, expectsBoundedBudget: false) + ] + ) + func `connect uses the unverified budget only for a macOS user-initiated tap, the full budget otherwise`(_ testCase: RetryBudgetCase) async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let stateMachine = MockBLEStateMachine() + let transport = MockMeshTransport() + await transport.setConnectError(ConnectionError.connectionFailed("simulated unreachable radio")) + + let manager = ConnectionManager( + modelContainer: container, + defaults: defaults, + stateMachine: stateMachine, + transport: transport, + pairing: StubPairingService(hasSystemPairingRegistry: testCase.hasSystemPairingRegistry) ) - func retryBudgetMatchesPlatformAndIntent(_ testCase: RetryBudgetCase) async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - - let stateMachine = MockBLEStateMachine() - let transport = MockMeshTransport() - await transport.setConnectError(ConnectionError.connectionFailed("simulated unreachable radio")) - - let manager = ConnectionManager( - modelContainer: container, - defaults: defaults, - stateMachine: stateMachine, - transport: transport, - pairing: StubPairingService(hasSystemPairingRegistry: testCase.hasSystemPairingRegistry) - ) - - let deviceID = UUID() - await #expect(throws: Error.self) { - try await manager.connect(to: deviceID, forceReconnect: testCase.forceReconnect) - } - - let expectedAttempts = testCase.expectsBoundedBudget - ? ConnectionManager.unverifiedConnectAttempts - : ConnectionManager.defaultConnectAttempts - let attempts = await transport.connectInvocations.count - #expect(attempts == expectedAttempts) + + let deviceID = UUID() + await #expect(throws: Error.self) { + try await manager.connect(to: deviceID, forceReconnect: testCase.forceReconnect) } - /// When the pairing registry is active and reports the device as not connectable (iOS, where - /// the saved peripheral is no longer registered), `connect(to:)` must fail fast with - /// `deviceNotFound` before spending a single transport attempt — the registry, not the retry - /// budget, is the authority on reachability. - @Test("connect rejects an unconnectable registered device before any transport attempt") - func connectRejectsUnconnectableRegisteredDevice() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let suiteName = "test.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - - let stateMachine = MockBLEStateMachine() - let transport = MockMeshTransport() - - let manager = ConnectionManager( - modelContainer: container, - defaults: defaults, - stateMachine: stateMachine, - transport: transport, - pairing: StubPairingService( - hasSystemPairingRegistry: true, - sessionActive: true, - deviceConnectable: false - ) - ) - - let deviceID = UUID() - try await #expect { - try await manager.connect(to: deviceID) - } throws: { error in - guard let e = error as? ConnectionError, case .deviceNotFound = e else { return false } - return true - } - - let attempts = await transport.connectInvocations.count - #expect(attempts == 0) + let expectedAttempts = testCase.expectsBoundedBudget + ? ConnectionManager.unverifiedConnectAttempts + : ConnectionManager.defaultConnectAttempts + let attempts = await transport.connectInvocations.count + #expect(attempts == expectedAttempts) + } + + /// When the pairing registry is active and reports the device as not connectable (iOS, where + /// the saved peripheral is no longer registered), `connect(to:)` must fail fast with + /// `deviceNotFound` before spending a single transport attempt — the registry, not the retry + /// budget, is the authority on reachability. + @Test + func `connect rejects an unconnectable registered device before any transport attempt`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let suiteName = "test.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let stateMachine = MockBLEStateMachine() + let transport = MockMeshTransport() + + let manager = ConnectionManager( + modelContainer: container, + defaults: defaults, + stateMachine: stateMachine, + transport: transport, + pairing: StubPairingService( + hasSystemPairingRegistry: true, + sessionActive: true, + deviceConnectable: false + ) + ) + + let deviceID = UUID() + try await #expect { + try await manager.connect(to: deviceID) + } throws: { error in + guard let e = error as? ConnectionError, case .deviceNotFound = e else { return false } + return true } + + let attempts = await transport.connectInvocations.count + #expect(attempts == 0) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerSessionTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerSessionTests.swift index ea19f4d2..6def0b56 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerSessionTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerSessionTests.swift @@ -1,196 +1,195 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ConnectionManager Session Tests") @MainActor struct ConnectionManagerSessionTests { + // MARK: - setConnectionState Tests - // MARK: - setConnectionState Tests - - @Test("setConnectionState updates to connected") - func setConnectionStateConnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(connectionIntent: .wantsConnection()) - - manager.setConnectionState(.connected) + @Test + func `setConnectionState updates to connected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(connectionIntent: .wantsConnection()) - #expect(manager.connectionState == .connected) - } + manager.setConnectionState(.connected) - @Test("setConnectionState updates to disconnected") - func setConnectionStateDisconnected() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(connectionState: .connected, connectionIntent: .wantsConnection()) + #expect(manager.connectionState == .connected) + } - manager.setConnectionState(.disconnected) + @Test + func `setConnectionState updates to disconnected`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(connectionState: .connected, connectionIntent: .wantsConnection()) - #expect(manager.connectionState == .disconnected) - } + manager.setConnectionState(.disconnected) - // MARK: - setConnectedDevice Tests + #expect(manager.connectionState == .disconnected) + } - @Test("setConnectedDevice sets device") - func setConnectedDeviceSets() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice(nodeName: "TestDevice") + // MARK: - setConnectedDevice Tests - manager.setConnectedDevice(device) + @Test + func `setConnectedDevice sets device`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice(nodeName: "TestDevice") - #expect(manager.connectedDevice?.nodeName == "TestDevice") - } + manager.setConnectedDevice(device) - @Test("setConnectedDevice sets nil") - func setConnectedDeviceSetsNil() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let device = DeviceDTO.testDevice() - manager.setConnectedDevice(device) - #expect(manager.connectedDevice != nil) + #expect(manager.connectedDevice?.nodeName == "TestDevice") + } - manager.setConnectedDevice(nil) + @Test + func `setConnectedDevice sets nil`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let device = DeviceDTO.testDevice() + manager.setConnectedDevice(device) + #expect(manager.connectedDevice != nil) - #expect(manager.connectedDevice == nil) - } + manager.setConnectedDevice(nil) - // MARK: - isTransportAutoReconnecting Tests + #expect(manager.connectedDevice == nil) + } - @Test("isTransportAutoReconnecting delegates to stateMachine") - func isTransportAutoReconnectingDelegates() async throws { - let (manager, mock) = try ConnectionManager.createForTesting() + // MARK: - isTransportAutoReconnecting Tests - await mock.setStubbedIsAutoReconnecting(false) - var result = await manager.isTransportAutoReconnecting() - #expect(!result) + @Test + func `isTransportAutoReconnecting delegates to stateMachine`() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() - await mock.setStubbedIsAutoReconnecting(true) - result = await manager.isTransportAutoReconnecting() - #expect(result) - } + await mock.setStubbedIsAutoReconnecting(false) + var result = await manager.isTransportAutoReconnecting() + #expect(!result) - @Test("activeConnectionAttemptDeviceID prefers session rebuild device") - func activeConnectionAttemptDeviceIDPrefersSessionRebuildDevice() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let deviceID = UUID() + await mock.setStubbedIsAutoReconnecting(true) + result = await manager.isTransportAutoReconnecting() + #expect(result) + } - manager.setTestState(sessionRebuildDeviceID: deviceID) + @Test + func `activeConnectionAttemptDeviceID prefers session rebuild device`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let deviceID = UUID() - #expect(manager.activeConnectionAttemptDeviceID == deviceID) - #expect(manager.activeReconnectDeviceID == deviceID) - } + manager.setTestState(sessionRebuildDeviceID: deviceID) + + #expect(manager.activeConnectionAttemptDeviceID == deviceID) + #expect(manager.activeReconnectDeviceID == deviceID) + } - @Test("connect to same device returns early while session rebuild is in progress") - func connectToSameDeviceReturnsEarlyDuringSessionRebuild() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - let deviceID = UUID() + @Test + func `connect to same device returns early while session rebuild is in progress`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + let deviceID = UUID() - manager.testLastConnectedDeviceID = deviceID - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection(), - sessionRebuildDeviceID: deviceID - ) + manager.testLastConnectedDeviceID = deviceID + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection(), + sessionRebuildDeviceID: deviceID + ) - try await manager.connect(to: deviceID) + try await manager.connect(to: deviceID) - #expect(manager.connectionState == .disconnected) - #expect(manager.sessionRebuildDeviceID == deviceID) - #expect(manager.connectionIntent == .wantsConnection()) - #expect(manager.reconnectionCoordinator.reconnectingDeviceID == nil) - } + #expect(manager.connectionState == .disconnected) + #expect(manager.sessionRebuildDeviceID == deviceID) + #expect(manager.connectionIntent == .wantsConnection()) + #expect(manager.reconnectionCoordinator.reconnectingDeviceID == nil) + } - // MARK: - handleReconnectionFailure Tests + // MARK: - handleReconnectionFailure Tests - @Test("handleReconnectionFailure clears state and sets disconnected") - func handleReconnectionFailureClearsState() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.updateDevice(with: DeviceDTO.testDevice()) - manager.setTestState( - connectionState: .connected, - connectionIntent: ConnectionIntent.none - ) + @Test + func `handleReconnectionFailure clears state and sets disconnected`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.updateDevice(with: DeviceDTO.testDevice()) + manager.setTestState( + connectionState: .connected, + connectionIntent: ConnectionIntent.none + ) - await manager.handleReconnectionFailure() + await manager.handleReconnectionFailure() - #expect(manager.connectionState == .disconnected) - #expect(manager.connectedDevice == nil) - #expect(manager.allowedRepeatFreqRanges.isEmpty) - } + #expect(manager.connectionState == .disconnected) + #expect(manager.connectedDevice == nil) + #expect(manager.allowedRepeatFreqRanges.isEmpty) + } - // MARK: - WiFi Health Check Early Returns - - @Test("checkWiFiConnectionHealth returns early when reconnect in progress") - func wifiHealthCheckReturnsEarlyWhenReconnecting() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState( - connectionState: .ready, - currentTransportType: .wifi, - connectionIntent: .wantsConnection() - ) - - manager.wifiReconnectTask = Task { } - - await manager.checkWiFiConnectionHealth() - - #expect(manager.connectionState == .ready) - - manager.wifiReconnectTask?.cancel() - manager.wifiReconnectTask = nil - } - - @Test("checkWiFiConnectionHealth returns early when disconnected without intent") - func wifiHealthCheckReturnsEarlyWhenNoIntent() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState( - connectionState: .disconnected, - currentTransportType: nil, - connectionIntent: ConnectionIntent.none - ) - - await manager.checkWiFiConnectionHealth() - - #expect(manager.connectionState == .disconnected) - } - - @Test("checkWiFiConnectionHealth returns early when transport is bluetooth") - func wifiHealthCheckReturnsEarlyWhenBluetooth() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState( - connectionState: .ready, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - await manager.checkWiFiConnectionHealth() - - #expect(manager.connectionState == .ready) - } - - @Test("handleReconnectionFailure notifies onConnectionLost so UI can react") - func handleReconnectionFailureNotifiesConnectionLost() async throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.updateDevice(with: DeviceDTO.testDevice()) - manager.setTestState( - connectionState: .connected, - connectionIntent: ConnectionIntent.none - ) - - let tracker = ReconnectionFailureLostTracker() - manager.onConnectionLost = { await tracker.markConnectionLost() } - - await manager.handleReconnectionFailure() - - let wasCalled = await tracker.connectionLostCalled - #expect(wasCalled, "onConnectionLost must fire so AppState can update the Live Activity to disconnected") - } + // MARK: - WiFi Health Check Early Returns + + @Test + func `checkWiFiConnectionHealth returns early when reconnect in progress`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState( + connectionState: .ready, + currentTransportType: .wifi, + connectionIntent: .wantsConnection() + ) + + manager.wifiReconnectTask = Task {} + + await manager.checkWiFiConnectionHealth() + + #expect(manager.connectionState == .ready) + + manager.wifiReconnectTask?.cancel() + manager.wifiReconnectTask = nil + } + + @Test + func `checkWiFiConnectionHealth returns early when disconnected without intent`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState( + connectionState: .disconnected, + currentTransportType: nil, + connectionIntent: ConnectionIntent.none + ) + + await manager.checkWiFiConnectionHealth() + + #expect(manager.connectionState == .disconnected) + } + + @Test + func `checkWiFiConnectionHealth returns early when transport is bluetooth`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState( + connectionState: .ready, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + await manager.checkWiFiConnectionHealth() + + #expect(manager.connectionState == .ready) + } + + @Test + func `handleReconnectionFailure notifies onConnectionLost so UI can react`() async throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.updateDevice(with: DeviceDTO.testDevice()) + manager.setTestState( + connectionState: .connected, + connectionIntent: ConnectionIntent.none + ) + + let tracker = ReconnectionFailureLostTracker() + manager.onConnectionLost = { await tracker.markConnectionLost() } + + await manager.handleReconnectionFailure() + + let wasCalled = await tracker.connectionLostCalled + #expect(wasCalled, "onConnectionLost must fire so AppState can update the Live Activity to disconnected") + } } // MARK: - Test Helpers private actor ReconnectionFailureLostTracker { - var connectionLostCalled = false + var connectionLostCalled = false - func markConnectionLost() { - connectionLostCalled = true - } + func markConnectionLost() { + connectionLostCalled = true + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ContactDTOPathTests.swift b/MC1Services/Tests/MC1ServicesTests/ContactDTOPathTests.swift index 8396b24e..f1d073c8 100644 --- a/MC1Services/Tests/MC1ServicesTests/ContactDTOPathTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ContactDTOPathTests.swift @@ -1,95 +1,94 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("ContactDTO Path Hops") struct ContactDTOPathTests { - - @Test("Single-byte hops chunk into one (data, hex) pair each") - func singleByteHops() { - let contact = ContactDTO.testContact( - outPathLength: encodePathLen(hashSize: 1, hopCount: 3), - outPath: Data([0x1A, 0x2B, 0x3C]) - ) - - let hops = contact.pathHops - #expect(hops.map(\.data) == [Data([0x1A]), Data([0x2B]), Data([0x3C])]) - #expect(hops.map(\.hex) == ["1A", "2B", "3C"]) - #expect(contact.pathNodesHex == ["1A", "2B", "3C"]) - } - - @Test("Multi-byte hops chunk by the encoded hash size") - func multiByteHops() { - let contact = ContactDTO.testContact( - outPathLength: encodePathLen(hashSize: 2, hopCount: 2), - outPath: Data([0x1A, 0x2B, 0x3C, 0x4D]) - ) - - let hops = contact.pathHops - #expect(hops.map(\.data) == [Data([0x1A, 0x2B]), Data([0x3C, 0x4D])]) - #expect(hops.map(\.hex) == ["1A2B", "3C4D"]) - } - - @Test("Hops ignore bytes beyond the encoded path length") - func ignoresTrailingBytes() { - let contact = ContactDTO.testContact( - outPathLength: encodePathLen(hashSize: 1, hopCount: 2), - outPath: Data([0x1A, 0x2B, 0xFF, 0xFF]) - ) - - #expect(contact.pathHops.map(\.hex) == ["1A", "2B"]) - } - - @Test("A flood-routed contact has no hops") - func floodRoutedHasNoHops() { - let contact = ContactDTO.testContact( - outPathLength: PacketBuilder.floodPathSentinel, - outPath: Data([0x1A, 0x2B]) - ) - - #expect(contact.pathHops.isEmpty) - #expect(contact.pathNodesHex.isEmpty) - } - - @Test("Displayed hops use the out-path count when a route is set, ignoring inbound") - func displayedHopsPrefersOutPath() { - let contact = ContactDTO.testContact( - outPathLength: encodePathLen(hashSize: 1, hopCount: 3), - outPath: Data([0x1A, 0x2B, 0x3C]) - ) - - #expect(contact.displayedHopCount(inboundHopCount: 5) == 3) - } - - @Test("A set out-path of zero hops displays zero, not the inbound fallback") - func displayedHopsOutPathZeroWins() { - let contact = ContactDTO.testContact( - outPathLength: encodePathLen(hashSize: 1, hopCount: 0) - ) - - #expect(!contact.isFloodRouted) - #expect(contact.displayedHopCount(inboundHopCount: 5) == 0) - } - - @Test("A flood-routed contact falls back to the inbound advert hops") - func displayedHopsFloodFallsBackToInbound() { - let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) - - #expect(contact.displayedHopCount(inboundHopCount: 2) == 2) - } - - @Test("An inbound zero is a real value, not nil") - func displayedHopsInboundZeroIsPresent() { - let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) - - #expect(contact.displayedHopCount(inboundHopCount: 0) == 0) - } - - @Test("Flood-routed with no inbound reception has no displayed hops") - func displayedHopsFloodWithoutInboundIsNil() { - let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) - - #expect(contact.displayedHopCount(inboundHopCount: nil) == nil) - } + @Test + func `Single-byte hops chunk into one (data, hex) pair each`() { + let contact = ContactDTO.testContact( + outPathLength: encodePathLen(hashSize: 1, hopCount: 3), + outPath: Data([0x1A, 0x2B, 0x3C]) + ) + + let hops = contact.pathHops + #expect(hops.map(\.data) == [Data([0x1A]), Data([0x2B]), Data([0x3C])]) + #expect(hops.map(\.hex) == ["1A", "2B", "3C"]) + #expect(contact.pathNodesHex == ["1A", "2B", "3C"]) + } + + @Test + func `Multi-byte hops chunk by the encoded hash size`() { + let contact = ContactDTO.testContact( + outPathLength: encodePathLen(hashSize: 2, hopCount: 2), + outPath: Data([0x1A, 0x2B, 0x3C, 0x4D]) + ) + + let hops = contact.pathHops + #expect(hops.map(\.data) == [Data([0x1A, 0x2B]), Data([0x3C, 0x4D])]) + #expect(hops.map(\.hex) == ["1A2B", "3C4D"]) + } + + @Test + func `Hops ignore bytes beyond the encoded path length`() { + let contact = ContactDTO.testContact( + outPathLength: encodePathLen(hashSize: 1, hopCount: 2), + outPath: Data([0x1A, 0x2B, 0xFF, 0xFF]) + ) + + #expect(contact.pathHops.map(\.hex) == ["1A", "2B"]) + } + + @Test + func `A flood-routed contact has no hops`() { + let contact = ContactDTO.testContact( + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data([0x1A, 0x2B]) + ) + + #expect(contact.pathHops.isEmpty) + #expect(contact.pathNodesHex.isEmpty) + } + + @Test + func `Displayed hops use the out-path count when a route is set, ignoring inbound`() { + let contact = ContactDTO.testContact( + outPathLength: encodePathLen(hashSize: 1, hopCount: 3), + outPath: Data([0x1A, 0x2B, 0x3C]) + ) + + #expect(contact.displayedHopCount(inboundHopCount: 5) == 3) + } + + @Test + func `A set out-path of zero hops displays zero, not the inbound fallback`() { + let contact = ContactDTO.testContact( + outPathLength: encodePathLen(hashSize: 1, hopCount: 0) + ) + + #expect(!contact.isFloodRouted) + #expect(contact.displayedHopCount(inboundHopCount: 5) == 0) + } + + @Test + func `A flood-routed contact falls back to the inbound advert hops`() { + let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) + + #expect(contact.displayedHopCount(inboundHopCount: 2) == 2) + } + + @Test + func `An inbound zero is a real value, not nil`() { + let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) + + #expect(contact.displayedHopCount(inboundHopCount: 0) == 0) + } + + @Test + func `Flood-routed with no inbound reception has no displayed hops`() { + let contact = ContactDTO.testContact(outPathLength: PacketBuilder.floodPathSentinel) + + #expect(contact.displayedHopCount(inboundHopCount: nil) == nil) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ContactShareUtilitiesTests.swift b/MC1Services/Tests/MC1ServicesTests/ContactShareUtilitiesTests.swift index 7335c2f8..42520896 100644 --- a/MC1Services/Tests/MC1ServicesTests/ContactShareUtilitiesTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ContactShareUtilitiesTests.swift @@ -1,142 +1,143 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("ContactShareUtilities Tests") struct ContactShareUtilitiesTests { - - /// A real 32-byte public key rendered as uppercase hex (64 chars). - static let validHex = "A1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" - - // MARK: - Round-trip - - @Test("formatShare then parseShare round-trips for every contact type", - arguments: [ContactType.chat, .repeater, .room]) - func testRoundTrip(type: ContactType) throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let name = "AVN2" - - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: type, name: name) - let result = try #require(ContactShareUtilities.parseShare(token)) - - #expect(result.publicKey == publicKey) - #expect(result.contactType == type) - #expect(result.name == name) - } - - @Test("formatShare emits uppercase hex") - func testFormatEmitsUppercaseHex() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "Node") - #expect(token.contains(Self.validHex)) - #expect(token == "<\(Self.validHex):1:Node>") - } - - // MARK: - Malformed rejection - - @Test("parseShare rejects a 63-char (short) public key") - func testRejectsShortPublicKey() { - let shortHex = String(Self.validHex.dropLast()) - #expect(ContactShareUtilities.parseShare("<\(shortHex):1:Node>") == nil) - } - - @Test("parseShare rejects non-hex characters in the public key") - func testRejectsNonHexPublicKey() { - let nonHex = "G1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" - #expect(ContactShareUtilities.parseShare("<\(nonHex):1:Node>") == nil) - } - - @Test("parseShare rejects a token missing the name field") - func testRejectsMissingName() { - #expect(ContactShareUtilities.parseShare("<\(Self.validHex):1>") == nil) - } - - @Test("parseShare rejects a token missing type and name") - func testRejectsMissingTypeAndName() { - #expect(ContactShareUtilities.parseShare("<\(Self.validHex)>") == nil) - } - - @Test("parseShare rejects an empty name") - func testRejectsEmptyName() { - #expect(ContactShareUtilities.parseShare("<\(Self.validHex):1:>") == nil) - } - - // MARK: - Adversarial type bounds (regression for the trap) - - @Test("parseShare rejects out-of-range and overflowing type values without trapping", - arguments: ["256", "300", "99999999999999999999", "0", "4"]) - func testRejectsAdversarialType(typeDigits: String) { - let token = "<\(Self.validHex):\(typeDigits):x>" - #expect(ContactShareUtilities.parseShare(token) == nil) - } - - // MARK: - Name escaping - - @Test("parseShare round-trips a name containing '>' to its stripped form") - func testNameWithTerminatorStripped() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "AV>N2") - #expect(!token.dropFirst().dropLast().contains(">")) - - let result = try #require(ContactShareUtilities.parseShare(token)) - #expect(result.name == "AVN2") - } - - @Test("parseShare preserves a colon inside the name") - func testNameWithColonPreserved() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let name = "Base:Camp:1" - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) - let result = try #require(ContactShareUtilities.parseShare(token)) - #expect(result.name == name) - } - - @Test("parseShare preserves a newline inside the name") - func testNameWithNewlinePreserved() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let name = "Line1\nLine2" - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) - let result = try #require(ContactShareUtilities.parseShare(token)) - #expect(result.name == name) - } - - @Test("parseShare preserves an RTL-override character in the name") - func testNameWithRTLOverridePreserved() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let name = "Node\u{202E}flip" - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) - let result = try #require(ContactShareUtilities.parseShare(token)) - #expect(result.name == name) - } - - @Test("parseShare round-trips a 1000-character name") - func testLongNameRoundTrips() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let name = String(repeating: "n", count: 1000) - let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) - let result = try #require(ContactShareUtilities.parseShare(token)) - #expect(result.name == name) - } - - // MARK: - extractShares - - @Test("extractShares finds multiple tokens amid plain text") - func testExtractMultiple() throws { - let publicKey = try #require(Data(hexString: Self.validHex)) - let first = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "Alice") - let second = ContactShareUtilities.formatShare(publicKey: publicKey, type: .repeater, name: "Bob") - let text = "Add these: \(first) and also \(second) thanks" - - let results = ContactShareUtilities.extractShares(from: text) - #expect(results.count == 2) - #expect(results[0].name == "Alice") - #expect(results[0].contactType == .chat) - #expect(results[1].name == "Bob") - #expect(results[1].contactType == .repeater) - } - - @Test("extractShares returns empty when there are no tokens") - func testExtractNone() { - #expect(ContactShareUtilities.extractShares(from: "Just some plain text, no tokens here.").isEmpty) - } + /// A real 32-byte public key rendered as uppercase hex (64 chars). + static let validHex = "A1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" + + // MARK: - Round-trip + + @Test( + arguments: [ContactType.chat, .repeater, .room] + ) + func `formatShare then parseShare round-trips for every contact type`(type: ContactType) throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let name = "AVN2" + + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: type, name: name) + let result = try #require(ContactShareUtilities.parseShare(token)) + + #expect(result.publicKey == publicKey) + #expect(result.contactType == type) + #expect(result.name == name) + } + + @Test + func `formatShare emits uppercase hex`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "Node") + #expect(token.contains(Self.validHex)) + #expect(token == "<\(Self.validHex):1:Node>") + } + + // MARK: - Malformed rejection + + @Test + func `parseShare rejects a 63-char (short) public key`() { + let shortHex = String(Self.validHex.dropLast()) + #expect(ContactShareUtilities.parseShare("<\(shortHex):1:Node>") == nil) + } + + @Test + func `parseShare rejects non-hex characters in the public key`() { + let nonHex = "G1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" + #expect(ContactShareUtilities.parseShare("<\(nonHex):1:Node>") == nil) + } + + @Test + func `parseShare rejects a token missing the name field`() { + #expect(ContactShareUtilities.parseShare("<\(Self.validHex):1>") == nil) + } + + @Test + func `parseShare rejects a token missing type and name`() { + #expect(ContactShareUtilities.parseShare("<\(Self.validHex)>") == nil) + } + + @Test + func `parseShare rejects an empty name`() { + #expect(ContactShareUtilities.parseShare("<\(Self.validHex):1:>") == nil) + } + + // MARK: - Adversarial type bounds (regression for the trap) + + @Test( + arguments: ["256", "300", "99999999999999999999", "0", "4"] + ) + func `parseShare rejects out-of-range and overflowing type values without trapping`(typeDigits: String) { + let token = "<\(Self.validHex):\(typeDigits):x>" + #expect(ContactShareUtilities.parseShare(token) == nil) + } + + // MARK: - Name escaping + + @Test + func `parseShare round-trips a name containing '>' to its stripped form`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "AV>N2") + #expect(!token.dropFirst().dropLast().contains(">")) + + let result = try #require(ContactShareUtilities.parseShare(token)) + #expect(result.name == "AVN2") + } + + @Test + func `parseShare preserves a colon inside the name`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let name = "Base:Camp:1" + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) + let result = try #require(ContactShareUtilities.parseShare(token)) + #expect(result.name == name) + } + + @Test + func `parseShare preserves a newline inside the name`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let name = "Line1\nLine2" + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) + let result = try #require(ContactShareUtilities.parseShare(token)) + #expect(result.name == name) + } + + @Test + func `parseShare preserves an RTL-override character in the name`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let name = "Node\u{202E}flip" + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) + let result = try #require(ContactShareUtilities.parseShare(token)) + #expect(result.name == name) + } + + @Test + func `parseShare round-trips a 1000-character name`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let name = String(repeating: "n", count: 1000) + let token = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: name) + let result = try #require(ContactShareUtilities.parseShare(token)) + #expect(result.name == name) + } + + // MARK: - extractShares + + @Test + func `extractShares finds multiple tokens amid plain text`() throws { + let publicKey = try #require(Data(hexString: Self.validHex)) + let first = ContactShareUtilities.formatShare(publicKey: publicKey, type: .chat, name: "Alice") + let second = ContactShareUtilities.formatShare(publicKey: publicKey, type: .repeater, name: "Bob") + let text = "Add these: \(first) and also \(second) thanks" + + let results = ContactShareUtilities.extractShares(from: text) + #expect(results.count == 2) + #expect(results[0].name == "Alice") + #expect(results[0].contactType == .chat) + #expect(results[1].name == "Bob") + #expect(results[1].contactType == .repeater) + } + + @Test + func `extractShares returns empty when there are no tokens`() { + #expect(ContactShareUtilities.extractShares(from: "Just some plain text, no tokens here.").isEmpty) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/DeduplicationKeyTests.swift b/MC1Services/Tests/MC1ServicesTests/DeduplicationKeyTests.swift index e5427160..dc9afab2 100644 --- a/MC1Services/Tests/MC1ServicesTests/DeduplicationKeyTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DeduplicationKeyTests.swift @@ -1,95 +1,93 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("DeduplicationKey") struct DeduplicationKeyTests { + // MARK: - Content-based key format - // MARK: - Content-based key format - - @Test( - "Content-based key uses the expected prefix for DM and channel scopes", - arguments: [ - (contactID: UUID?.some(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!), - channelIndex: UInt8?.none, - senderNodeName: String?.none, - timestamp: UInt32(1000), - content: "hello", - expectedPrefix: "dm-AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-1000-"), - (contactID: UUID?.none, - channelIndex: UInt8?.some(3), - senderNodeName: String?.some("Alice"), - timestamp: UInt32(2000), - content: "test", - expectedPrefix: "ch-3-2000-Alice-"), - (contactID: UUID?.none, - channelIndex: UInt8?.some(0), - senderNodeName: String?.none, - timestamp: UInt32(500), - content: "msg", - expectedPrefix: "ch-0-500--"), - (contactID: UUID?.none, - channelIndex: UInt8?.none, - senderNodeName: String?.none, - timestamp: UInt32(100), - content: "x", - expectedPrefix: "dm-unknown-100-") - ] + @Test( + arguments: [ + (contactID: UUID?.some(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!), + channelIndex: UInt8?.none, + senderNodeName: String?.none, + timestamp: UInt32(1000), + content: "hello", + expectedPrefix: "dm-AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-1000-"), + (contactID: UUID?.none, + channelIndex: UInt8?.some(3), + senderNodeName: String?.some("Alice"), + timestamp: UInt32(2000), + content: "test", + expectedPrefix: "ch-3-2000-Alice-"), + (contactID: UUID?.none, + channelIndex: UInt8?.some(0), + senderNodeName: String?.none, + timestamp: UInt32(500), + content: "msg", + expectedPrefix: "ch-0-500--"), + (contactID: UUID?.none, + channelIndex: UInt8?.none, + senderNodeName: String?.none, + timestamp: UInt32(100), + content: "x", + expectedPrefix: "dm-unknown-100-") + ] + ) + func `Content-based key uses the expected prefix for DM and channel scopes`( + contactID: UUID?, + channelIndex: UInt8?, + senderNodeName: String?, + timestamp: UInt32, + content: String, + expectedPrefix: String + ) { + let key = DeduplicationKey.contentBased( + contactID: contactID, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + timestamp: timestamp, + content: content ) - func contentBasedKeyPrefix( - contactID: UUID?, - channelIndex: UInt8?, - senderNodeName: String?, - timestamp: UInt32, - content: String, - expectedPrefix: String - ) { - let key = DeduplicationKey.contentBased( - contactID: contactID, - channelIndex: channelIndex, - senderNodeName: senderNodeName, - timestamp: timestamp, - content: content - ) - #expect(key.hasPrefix(expectedPrefix)) - } + #expect(key.hasPrefix(expectedPrefix)) + } - @Test("Content-based key has an 8-char hex hash suffix") - func contentBasedKeyHashSuffix() { - let key = DeduplicationKey.contentBased( - contactID: UUID(), channelIndex: nil, - senderNodeName: nil, timestamp: 1000, content: "hello" - ) - let hashSuffix = String(key.split(separator: "-").last ?? "") - #expect(hashSuffix.count == 8) - } + @Test + func `Content-based key has an 8-char hex hash suffix`() { + let key = DeduplicationKey.contentBased( + contactID: UUID(), channelIndex: nil, + senderNodeName: nil, timestamp: 1000, content: "hello" + ) + let hashSuffix = String(key.split(separator: "-").last ?? "") + #expect(hashSuffix.count == 8) + } - @Test("Content-based key is deterministic and distinguishes different content") - func contentBasedKeyEquality() { - let contactID = UUID() - let keyA1 = DeduplicationKey.contentBased( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1, content: "aaa" - ) - let keyA2 = DeduplicationKey.contentBased( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1, content: "aaa" - ) - let keyB = DeduplicationKey.contentBased( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1, content: "bbb" - ) - #expect(keyA1 == keyA2) - #expect(keyA1 != keyB) - } + @Test + func `Content-based key is deterministic and distinguishes different content`() { + let contactID = UUID() + let keyA1 = DeduplicationKey.contentBased( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1, content: "aaa" + ) + let keyA2 = DeduplicationKey.contentBased( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1, content: "aaa" + ) + let keyB = DeduplicationKey.contentBased( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1, content: "bbb" + ) + #expect(keyA1 == keyA2) + #expect(keyA1 != keyB) + } - @Test("Empty content still produces a well-formed key") - func emptyContent() { - let key = DeduplicationKey.contentBased( - contactID: UUID(), channelIndex: nil, - senderNodeName: nil, timestamp: 0, content: "" - ) - #expect(key.hasPrefix("dm-")) - #expect(key.count > 10) - } + @Test + func `Empty content still produces a well-formed key`() { + let key = DeduplicationKey.contentBased( + contactID: UUID(), channelIndex: nil, + senderNodeName: nil, timestamp: 0, content: "" + ) + #expect(key.hasPrefix("dm-")) + #expect(key.count > 10) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/DeviceConnectionStateTests.swift b/MC1Services/Tests/MC1ServicesTests/DeviceConnectionStateTests.swift index d9574ad3..d765b9c0 100644 --- a/MC1Services/Tests/MC1ServicesTests/DeviceConnectionStateTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DeviceConnectionStateTests.swift @@ -1,23 +1,23 @@ -import Testing @testable import MC1Services +import Testing @Suite("DeviceConnectionState") struct DeviceConnectionStateTests { - @Test("isOperational returns true only for syncing and ready") - func isOperational() { - #expect(!DeviceConnectionState.disconnected.isOperational) - #expect(!DeviceConnectionState.connecting.isOperational) - #expect(!DeviceConnectionState.connected.isOperational) - #expect(DeviceConnectionState.syncing.isOperational) - #expect(DeviceConnectionState.ready.isOperational) - } + @Test + func `isOperational returns true only for syncing and ready`() { + #expect(!DeviceConnectionState.disconnected.isOperational) + #expect(!DeviceConnectionState.connecting.isOperational) + #expect(!DeviceConnectionState.connected.isOperational) + #expect(DeviceConnectionState.syncing.isOperational) + #expect(DeviceConnectionState.ready.isOperational) + } - @Test("isConnected returns true for connected, syncing, and ready") - func isConnected() { - #expect(!DeviceConnectionState.disconnected.isConnected) - #expect(!DeviceConnectionState.connecting.isConnected) - #expect(DeviceConnectionState.connected.isConnected) - #expect(DeviceConnectionState.syncing.isConnected) - #expect(DeviceConnectionState.ready.isConnected) - } + @Test + func `isConnected returns true for connected, syncing, and ready`() { + #expect(!DeviceConnectionState.disconnected.isConnected) + #expect(!DeviceConnectionState.connecting.isConnected) + #expect(DeviceConnectionState.connected.isConnected) + #expect(DeviceConnectionState.syncing.isConnected) + #expect(DeviceConnectionState.ready.isConnected) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/DevicePlatformSyncThrottlingTests.swift b/MC1Services/Tests/MC1ServicesTests/DevicePlatformSyncThrottlingTests.swift index 6c4cc453..93ccdddb 100644 --- a/MC1Services/Tests/MC1ServicesTests/DevicePlatformSyncThrottlingTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DevicePlatformSyncThrottlingTests.swift @@ -1,143 +1,142 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("DevicePlatform Channel Sync Config") struct DevicePlatformChannelSyncConfigTests { + @Test + func `ESP32 has 30s channel sync skip window`() { + let platform = DevicePlatform.esp32 + #expect(platform.channelSyncSkipWindow == .seconds(30)) + } - @Test("ESP32 has 30s channel sync skip window") - func esp32ChannelSyncSkipWindow() { - let platform = DevicePlatform.esp32 - #expect(platform.channelSyncSkipWindow == .seconds(30)) - } - - @Test("nRF52 has zero channel sync skip window") - func nrf52ChannelSyncSkipWindow() { - let platform = DevicePlatform.nrf52 - #expect(platform.channelSyncSkipWindow == .zero) - } - - @Test("Unknown has zero channel sync skip window") - func unknownChannelSyncSkipWindow() { - let platform = DevicePlatform.unknown - #expect(platform.channelSyncSkipWindow == .zero) - } + @Test + func `nRF52 has zero channel sync skip window`() { + let platform = DevicePlatform.nrf52 + #expect(platform.channelSyncSkipWindow == .zero) + } + + @Test + func `Unknown has zero channel sync skip window`() { + let platform = DevicePlatform.unknown + #expect(platform.channelSyncSkipWindow == .zero) + } - @Test("WiFi uses ESP32 channel sync cooldown") - @MainActor - func wifiUsesESP32ChannelSyncCooldown() throws { - let (manager, _) = try ConnectionManager.createForTesting() - let radioID = UUID() - let attemptedAt = Date() + @Test + @MainActor + func `WiFi uses ESP32 channel sync cooldown`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + let radioID = UUID() + let attemptedAt = Date() - manager.setTestState( - detectedPlatform: .esp32, - lastAttemptedChannelSync: (radioID: radioID, attemptedAt: attemptedAt) - ) + manager.setTestState( + detectedPlatform: .esp32, + lastAttemptedChannelSync: (radioID: radioID, attemptedAt: attemptedAt) + ) - let config = manager.currentChannelSyncConfig(for: radioID, transportType: .wifi) + let config = manager.currentChannelSyncConfig(for: radioID, transportType: .wifi) - #expect(config.channelSyncSkipWindow == .seconds(30)) - #expect(config.lastAttemptedChannelSync == attemptedAt) - } + #expect(config.channelSyncSkipWindow == .seconds(30)) + #expect(config.lastAttemptedChannelSync == attemptedAt) + } - @Test("nRF52 over BLE enables pipelined channel reads") - @MainActor - func nrf52OverBLEEnablesPipelinedRead() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(detectedPlatform: .nrf52) + @Test + @MainActor + func `nRF52 over BLE enables pipelined channel reads`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(detectedPlatform: .nrf52) - let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .bluetooth) + let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .bluetooth) - #expect(config.usePipelinedChannelRead) - } + #expect(config.usePipelinedChannelRead) + } - @Test("nRF52 over WiFi does not pipeline channel reads") - @MainActor - func nrf52OverWiFiDoesNotPipeline() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(detectedPlatform: .nrf52) + @Test + @MainActor + func `nRF52 over WiFi does not pipeline channel reads`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(detectedPlatform: .nrf52) - let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) + let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) - #expect(!config.usePipelinedChannelRead) - } + #expect(!config.usePipelinedChannelRead) + } - @Test("ESP32 over BLE does not pipeline channel reads") - @MainActor - func esp32OverBLEDoesNotPipeline() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(detectedPlatform: .esp32) + @Test + @MainActor + func `ESP32 over BLE does not pipeline channel reads`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(detectedPlatform: .esp32) - let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .bluetooth) + let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .bluetooth) - #expect(!config.usePipelinedChannelRead) - } + #expect(!config.usePipelinedChannelRead) + } - @Test("ESP32 over WiFi enables pipelined channel reads") - @MainActor - func esp32OverWiFiEnablesPipelinedRead() throws { - let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(detectedPlatform: .esp32) + @Test + @MainActor + func `ESP32 over WiFi enables pipelined channel reads`() throws { + let (manager, _) = try ConnectionManager.createForTesting() + manager.setTestState(detectedPlatform: .esp32) - let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) + let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) - #expect(config.usePipelinedChannelRead) - } + #expect(config.usePipelinedChannelRead) + } - @Test("WiFi connect resolves a recognized ESP32 model to ESP32") - @MainActor - func wifiRecognizedModelResolvesToESP32() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + @MainActor + func `WiFi connect resolves a recognized ESP32 model to ESP32`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.detectAndStorePlatform(model: "Heltec V3", transportType: .wifi) + manager.detectAndStorePlatform(model: "Heltec V3", transportType: .wifi) - #expect(manager.detectedPlatform == .esp32) - } + #expect(manager.detectedPlatform == .esp32) + } - @Test("WiFi connect resolves an unrecognized model to ESP32 (WiFi implies ESP32-class)") - @MainActor - func wifiUnknownModelResolvesToESP32() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + @MainActor + func `WiFi connect resolves an unrecognized model to ESP32 (WiFi implies ESP32-class)`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.detectAndStorePlatform(model: "Totally Unknown Radio 9000", transportType: .wifi) + manager.detectAndStorePlatform(model: "Totally Unknown Radio 9000", transportType: .wifi) - #expect(manager.detectedPlatform == .esp32) + #expect(manager.detectedPlatform == .esp32) - // The skip window is earned and, because the resolved platform is ESP32 over WiFi, - // the channel-read pipeline is also enabled. - let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) - #expect(config.channelSyncSkipWindow == .seconds(30)) - #expect(config.usePipelinedChannelRead) - } + // The skip window is earned and, because the resolved platform is ESP32 over WiFi, + // the channel-read pipeline is also enabled. + let config = manager.currentChannelSyncConfig(for: UUID(), transportType: .wifi) + #expect(config.channelSyncSkipWindow == .seconds(30)) + #expect(config.usePipelinedChannelRead) + } - @Test("BLE connect leaves an unrecognized model as unknown (no WiFi coalesce)") - @MainActor - func bleUnknownModelStaysUnknown() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + @MainActor + func `BLE connect leaves an unrecognized model as unknown (no WiFi coalesce)`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.detectAndStorePlatform(model: "Totally Unknown Radio 9000", transportType: .bluetooth) + manager.detectAndStorePlatform(model: "Totally Unknown Radio 9000", transportType: .bluetooth) - #expect(manager.detectedPlatform == .unknown) - } + #expect(manager.detectedPlatform == .unknown) + } - @Test("BLE connect resolves an nRF52 model to nRF52") - @MainActor - func bleNRF52ModelResolvesToNRF52() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + @MainActor + func `BLE connect resolves an nRF52 model to nRF52`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.detectAndStorePlatform(model: "T1000-E", transportType: .bluetooth) + manager.detectAndStorePlatform(model: "T1000-E", transportType: .bluetooth) - #expect(manager.detectedPlatform == .nrf52) - } + #expect(manager.detectedPlatform == .nrf52) + } - @Test("Heartbeat pauses while syncing") - @MainActor - func heartbeatPausesWhileSyncing() throws { - let (manager, _) = try ConnectionManager.createForTesting() + @Test + @MainActor + func `Heartbeat pauses while syncing`() throws { + let (manager, _) = try ConnectionManager.createForTesting() - manager.setTestState(connectionState: .syncing) + manager.setTestState(connectionState: .syncing) - #expect(manager.shouldPauseWiFiHeartbeatProbe) - } + #expect(manager.shouldPauseWiFiHeartbeatProbe) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/DevicePublicKeyDeduplicationTests.swift b/MC1Services/Tests/MC1ServicesTests/DevicePublicKeyDeduplicationTests.swift index cc1ba566..dce60ff2 100644 --- a/MC1Services/Tests/MC1ServicesTests/DevicePublicKeyDeduplicationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DevicePublicKeyDeduplicationTests.swift @@ -1,213 +1,212 @@ import Foundation +@testable import MC1Services +import MeshCore import SwiftData import Testing -import MeshCore -@testable import MC1Services @Suite("Device publicKey deduplication") struct DevicePublicKeyDeduplicationTests { - - // MARK: - Test Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private static let testPublicKey = Data(repeating: 0xAB, count: 32) - - private static func makeSelfInfo(publicKey: Data = testPublicKey) -> SelfInfo { - SelfInfo( - advertisementType: 0, - txPower: 20, - maxTxPower: 20, - publicKey: publicKey, - latitude: 0, - longitude: 0, - multiAcks: 2, - advertisementLocationPolicy: 0, - telemetryModeEnvironment: 0, - telemetryModeLocation: 0, - telemetryModeBase: 2, - manualAddContacts: false, - radioFrequency: 915.0, - radioBandwidth: 250.0, - radioSpreadingFactor: 10, - radioCodingRate: 5, - name: "TestNode" - ) - } - - private static let testCapabilities = DeviceCapabilities( - firmwareVersion: 9, - maxContacts: 100, - maxChannels: 8, - blePin: 0, - firmwareBuild: "01 Jan 2025", - model: "T-Deck", - version: "v1.13.0" + // MARK: - Test Helpers + + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private static let testPublicKey = Data(repeating: 0xAB, count: 32) + + private static func makeSelfInfo(publicKey: Data = testPublicKey) -> SelfInfo { + SelfInfo( + advertisementType: 0, + txPower: 20, + maxTxPower: 20, + publicKey: publicKey, + latitude: 0, + longitude: 0, + multiAcks: 2, + advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, + telemetryModeLocation: 0, + telemetryModeBase: 2, + manualAddContacts: false, + radioFrequency: 915.0, + radioBandwidth: 250.0, + radioSpreadingFactor: 10, + radioCodingRate: 5, + name: "TestNode" + ) + } + + private static let testCapabilities = DeviceCapabilities( + firmwareVersion: 9, + maxContacts: 100, + maxChannels: 8, + blePin: 0, + firmwareBuild: "01 Jan 2025", + model: "T-Deck", + version: "v1.13.0" + ) + + // MARK: - fetchDevice(publicKey:) + + @Test + func `fetchDevice(publicKey:) returns matching device`() async throws { + let store = try await createTestStore() + let device = DeviceDTO.testDevice(publicKey: Self.testPublicKey) + try await store.saveDevice(device) + + let fetched = try await store.fetchDevice(publicKey: Self.testPublicKey) + #expect(fetched != nil) + #expect(fetched?.id == device.id) + #expect(fetched?.publicKey == Self.testPublicKey) + } + + @Test + func `fetchDevice(publicKey:) returns nil for unknown key`() async throws { + let store = try await createTestStore() + let device = DeviceDTO.testDevice(publicKey: Data(repeating: 0x01, count: 32)) + try await store.saveDevice(device) + + let unknownKey = Data(repeating: 0xFF, count: 32) + let fetched = try await store.fetchDevice(publicKey: unknownKey) + #expect(fetched == nil) + } + + // MARK: - radioID preservation via createDevice + + @Test + @MainActor + func `createDevice preserves radioID from existing device`() throws { + let existingRadioID = UUID() + let existingDevice = DeviceDTO.testDevice( + radioID: existingRadioID, + publicKey: Self.testPublicKey ) - // MARK: - fetchDevice(publicKey:) - - @Test("fetchDevice(publicKey:) returns matching device") - func fetchByPublicKeyHit() async throws { - let store = try await createTestStore() - let device = DeviceDTO.testDevice(publicKey: Self.testPublicKey) - try await store.saveDevice(device) + let (cm, _) = try ConnectionManager.createForTesting() + let newBLEUUID = UUID() - let fetched = try await store.fetchDevice(publicKey: Self.testPublicKey) - #expect(fetched != nil) - #expect(fetched?.id == device.id) - #expect(fetched?.publicKey == Self.testPublicKey) - } + let device = cm.createDevice( + deviceID: newBLEUUID, + radioID: existingRadioID, + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities, + autoAddConfig: AutoAddConfig(bitmask: 0), + existingDevice: existingDevice + ) - @Test("fetchDevice(publicKey:) returns nil for unknown key") - func fetchByPublicKeyMiss() async throws { - let store = try await createTestStore() - let device = DeviceDTO.testDevice(publicKey: Data(repeating: 0x01, count: 32)) - try await store.saveDevice(device) + #expect(device.id == newBLEUUID) + #expect(device.radioID == existingRadioID) + } + + @Test + @MainActor + func `createDevice uses the provided radioID for new pairings`() throws { + let (cm, _) = try ConnectionManager.createForTesting() + let bleUUID = UUID() + let freshRadioID = UUID() + + let device = cm.createDevice( + deviceID: bleUUID, + radioID: freshRadioID, + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities, + autoAddConfig: AutoAddConfig(bitmask: 0) + ) - let unknownKey = Data(repeating: 0xFF, count: 32) - let fetched = try await store.fetchDevice(publicKey: unknownKey) - #expect(fetched == nil) - } + #expect(device.id == bleUUID) + #expect(device.radioID == freshRadioID) + } + + // MARK: - Bluetooth connection method persistence + + /// The BLE connect ceremony passes a `.bluetooth` method so the saved row is + /// reachable on macOS, where there is no AccessorySetupKit registry to validate + /// against and `DeviceSelectionFilter` treats the method itself as the signal. + @Test + @MainActor + func `createDevice persists the Bluetooth method supplied by the BLE connect path`() throws { + let (cm, _) = try ConnectionManager.createForTesting() + let bleUUID = UUID() + + let device = cm.createDevice( + deviceID: bleUUID, + radioID: UUID(), + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities, + autoAddConfig: AutoAddConfig(bitmask: 0), + connectionMethods: [.bluetooth(peripheralUUID: bleUUID, displayName: nil)] + ) - // MARK: - radioID preservation via createDevice - - @Test("createDevice preserves radioID from existing device") - @MainActor - func createDevicePreservesRadioID() throws { - let existingRadioID = UUID() - let existingDevice = DeviceDTO.testDevice( - radioID: existingRadioID, - publicKey: Self.testPublicKey - ) - - let (cm, _) = try ConnectionManager.createForTesting() - let newBLEUUID = UUID() - - let device = cm.createDevice( - deviceID: newBLEUUID, - radioID: existingRadioID, - selfInfo: Self.makeSelfInfo(), - capabilities: Self.testCapabilities, - autoAddConfig: AutoAddConfig(bitmask: 0), - existingDevice: existingDevice - ) - - #expect(device.id == newBLEUUID) - #expect(device.radioID == existingRadioID) + #expect(device.connectionMethods.contains { $0.isBluetooth }) + } + + /// A radio reachable over both transports must keep its WiFi method when it + /// reconnects over BLE; the merge replaces by transport type rather than + /// discarding the other transport. + @Test + @MainActor + func `createDevice merges a Bluetooth method with an existing WiFi method`() throws { + let (cm, _) = try ConnectionManager.createForTesting() + let bleUUID = UUID() + let existingRadioID = UUID() + let existingDevice = DeviceDTO.testDevice( + radioID: existingRadioID, + publicKey: Self.testPublicKey + ).copy { + $0.connectionMethods = [.wifi(host: "10.0.0.2", port: 5000, displayName: nil)] } - @Test("createDevice uses the provided radioID for new pairings") - @MainActor - func createDeviceUsesProvidedRadioID() throws { - let (cm, _) = try ConnectionManager.createForTesting() - let bleUUID = UUID() - let freshRadioID = UUID() - - let device = cm.createDevice( - deviceID: bleUUID, - radioID: freshRadioID, - selfInfo: Self.makeSelfInfo(), - capabilities: Self.testCapabilities, - autoAddConfig: AutoAddConfig(bitmask: 0) - ) - - #expect(device.id == bleUUID) - #expect(device.radioID == freshRadioID) - } + let device = cm.createDevice( + deviceID: bleUUID, + radioID: existingRadioID, + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities, + autoAddConfig: AutoAddConfig(bitmask: 0), + existingDevice: existingDevice, + connectionMethods: [.bluetooth(peripheralUUID: bleUUID, displayName: nil)] + ) - // MARK: - Bluetooth connection method persistence - - /// The BLE connect ceremony passes a `.bluetooth` method so the saved row is - /// reachable on macOS, where there is no AccessorySetupKit registry to validate - /// against and `DeviceSelectionFilter` treats the method itself as the signal. - @Test("createDevice persists the Bluetooth method supplied by the BLE connect path") - @MainActor - func createDevicePersistsBluetoothMethod() throws { - let (cm, _) = try ConnectionManager.createForTesting() - let bleUUID = UUID() - - let device = cm.createDevice( - deviceID: bleUUID, - radioID: UUID(), - selfInfo: Self.makeSelfInfo(), - capabilities: Self.testCapabilities, - autoAddConfig: AutoAddConfig(bitmask: 0), - connectionMethods: [.bluetooth(peripheralUUID: bleUUID, displayName: nil)] - ) - - #expect(device.connectionMethods.contains { $0.isBluetooth }) + // Exactly one of each transport: the Bluetooth method is added without discarding + // the WiFi method, and neither transport is duplicated. + #expect(device.connectionMethods.filter(\.isWiFi).count == 1) + #expect(device.connectionMethods.filter(\.isBluetooth).count == 1) + } + + /// Reconnecting over BLE must replace the prior Bluetooth method, not append a second one. + /// macOS reachability and the live connect handle both key off the stored peripheralUUID, so + /// a stale UUID accumulating alongside the fresh one would strand or mis-route the device. + /// This pins the merge loop's `removeAll { $0.isBluetooth }` so dropping it would fail here. + @Test + @MainActor + func `createDevice replaces a stale Bluetooth peripheral UUID instead of accumulating`() throws { + let (cm, _) = try ConnectionManager.createForTesting() + let existingRadioID = UUID() + let staleUUID = UUID() + let existingDevice = DeviceDTO.testDevice( + radioID: existingRadioID, + publicKey: Self.testPublicKey + ).copy { + $0.connectionMethods = [.bluetooth(peripheralUUID: staleUUID, displayName: "Old")] } - /// A radio reachable over both transports must keep its WiFi method when it - /// reconnects over BLE; the merge replaces by transport type rather than - /// discarding the other transport. - @Test("createDevice merges a Bluetooth method with an existing WiFi method") - @MainActor - func createDeviceMergesBluetoothWithExistingWiFi() throws { - let (cm, _) = try ConnectionManager.createForTesting() - let bleUUID = UUID() - let existingRadioID = UUID() - let existingDevice = DeviceDTO.testDevice( - radioID: existingRadioID, - publicKey: Self.testPublicKey - ).copy { - $0.connectionMethods = [.wifi(host: "10.0.0.2", port: 5000, displayName: nil)] - } - - let device = cm.createDevice( - deviceID: bleUUID, - radioID: existingRadioID, - selfInfo: Self.makeSelfInfo(), - capabilities: Self.testCapabilities, - autoAddConfig: AutoAddConfig(bitmask: 0), - existingDevice: existingDevice, - connectionMethods: [.bluetooth(peripheralUUID: bleUUID, displayName: nil)] - ) - - // Exactly one of each transport: the Bluetooth method is added without discarding - // the WiFi method, and neither transport is duplicated. - #expect(device.connectionMethods.filter(\.isWiFi).count == 1) - #expect(device.connectionMethods.filter(\.isBluetooth).count == 1) - } + let freshUUID = UUID() + let device = cm.createDevice( + deviceID: freshUUID, + radioID: existingRadioID, + selfInfo: Self.makeSelfInfo(), + capabilities: Self.testCapabilities, + autoAddConfig: AutoAddConfig(bitmask: 0), + existingDevice: existingDevice, + connectionMethods: [.bluetooth(peripheralUUID: freshUUID, displayName: nil)] + ) - /// Reconnecting over BLE must replace the prior Bluetooth method, not append a second one. - /// macOS reachability and the live connect handle both key off the stored peripheralUUID, so - /// a stale UUID accumulating alongside the fresh one would strand or mis-route the device. - /// This pins the merge loop's `removeAll { $0.isBluetooth }` so dropping it would fail here. - @Test("createDevice replaces a stale Bluetooth peripheral UUID instead of accumulating") - @MainActor - func createDeviceReplacesStaleBluetoothMethod() throws { - let (cm, _) = try ConnectionManager.createForTesting() - let existingRadioID = UUID() - let staleUUID = UUID() - let existingDevice = DeviceDTO.testDevice( - radioID: existingRadioID, - publicKey: Self.testPublicKey - ).copy { - $0.connectionMethods = [.bluetooth(peripheralUUID: staleUUID, displayName: "Old")] - } - - let freshUUID = UUID() - let device = cm.createDevice( - deviceID: freshUUID, - radioID: existingRadioID, - selfInfo: Self.makeSelfInfo(), - capabilities: Self.testCapabilities, - autoAddConfig: AutoAddConfig(bitmask: 0), - existingDevice: existingDevice, - connectionMethods: [.bluetooth(peripheralUUID: freshUUID, displayName: nil)] - ) - - let bluetoothMethods = device.connectionMethods.filter(\.isBluetooth) - #expect(bluetoothMethods.count == 1) - guard case .bluetooth(let uuid, _) = bluetoothMethods.first else { - Issue.record("Expected exactly one surviving Bluetooth method") - return - } - #expect(uuid == freshUUID) + let bluetoothMethods = device.connectionMethods.filter(\.isBluetooth) + #expect(bluetoothMethods.count == 1) + guard case let .bluetooth(uuid, _) = bluetoothMethods.first else { + Issue.record("Expected exactly one surviving Bluetooth method") + return } + #expect(uuid == freshUUID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/DraftStoreTests.swift b/MC1Services/Tests/MC1ServicesTests/DraftStoreTests.swift index 6b4ba438..bdea7109 100644 --- a/MC1Services/Tests/MC1ServicesTests/DraftStoreTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DraftStoreTests.swift @@ -1,231 +1,230 @@ -import Testing import Foundation @testable import MC1Services +import Testing /// Covers `DraftStore` persistence and the `draftToApply` restore guard, plus the /// pinned `ChatConversationID.draftStorageKey` encoding the store keys on. @Suite("DraftStore Tests") @MainActor struct DraftStoreTests { + /// A per-test isolated `UserDefaults` so suites never share draft state. + private func makeDefaults() -> UserDefaults { + let suiteName = "test.\(UUID().uuidString)" + return UserDefaults(suiteName: suiteName)! + } - /// A per-test isolated `UserDefaults` so suites never share draft state. - private func makeDefaults() -> UserDefaults { - let suiteName = "test.\(UUID().uuidString)" - return UserDefaults(suiteName: suiteName)! - } - - private let radioID = UUID() - private let contactID = UUID() + private let radioID = UUID() + private let contactID = UUID() - private func dm(_ radio: UUID, _ contact: UUID) -> ChatConversationID { - .dm(radioID: radio, contactID: contact) - } + private func dm(_ radio: UUID, _ contact: UUID) -> ChatConversationID { + .dm(radioID: radio, contactID: contact) + } - private func channel(_ radio: UUID, _ index: UInt8) -> ChatConversationID { - .channel(radioID: radio, channelIndex: index) - } + private func channel(_ radio: UUID, _ index: UInt8) -> ChatConversationID { + .channel(radioID: radio, channelIndex: index) + } - // MARK: - Round-trip and persistence + // MARK: - Round-trip and persistence - @Test("setDraft / draft / clearDraft round-trip") - func roundTrip() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) + @Test + func `setDraft / draft / clearDraft round-trip`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) - #expect(store.draft(for: id) == nil) + #expect(store.draft(for: id) == nil) - store.setDraft("hello", for: id) - #expect(store.draft(for: id) == "hello") + store.setDraft("hello", for: id) + #expect(store.draft(for: id) == "hello") - store.clearDraft(for: id) - #expect(store.draft(for: id) == nil) - } + store.clearDraft(for: id) + #expect(store.draft(for: id) == nil) + } - @Test("draft survives a new DraftStore instance on the same suite (restart)") - func survivesRestart() { - let defaults = makeDefaults() - let id = dm(radioID, contactID) + @Test + func `draft survives a new DraftStore instance on the same suite (restart)`() { + let defaults = makeDefaults() + let id = dm(radioID, contactID) - let first = DraftStore(defaults: defaults) - first.setDraft("persisted", for: id) + let first = DraftStore(defaults: defaults) + first.setDraft("persisted", for: id) - let second = DraftStore(defaults: defaults) - #expect(second.draft(for: id) == "persisted") - } + let second = DraftStore(defaults: defaults) + #expect(second.draft(for: id) == "persisted") + } - // MARK: - Emptiness handling + // MARK: - Emptiness handling - @Test("whitespace / newline-only input removes the entry") - func whitespaceRemovesEntry() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) + @Test + func `whitespace / newline-only input removes the entry`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) - store.setDraft("real", for: id) - #expect(store.draft(for: id) == "real") + store.setDraft("real", for: id) + #expect(store.draft(for: id) == "real") - store.setDraft(" \n\t ", for: id) - #expect(store.draft(for: id) == nil) - } + store.setDraft(" \n\t ", for: id) + #expect(store.draft(for: id) == nil) + } - @Test("non-empty draft with a trailing newline is stored verbatim") - func trailingNewlineStoredVerbatim() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) + @Test + func `non-empty draft with a trailing newline is stored verbatim`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) - store.setDraft("hi\n", for: id) - #expect(store.draft(for: id) == "hi\n") - } + store.setDraft("hi\n", for: id) + #expect(store.draft(for: id) == "hi\n") + } - // MARK: - draftToApply restore guard + // MARK: - draftToApply restore guard - @Test("draftToApply returns the saved draft when the field is empty") - func draftToApplyReturnsWhenEmpty() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) - store.setDraft("saved", for: id) + @Test + func `draftToApply returns the saved draft when the field is empty`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) + store.setDraft("saved", for: id) - #expect(store.draftToApply(over: "", for: id) == "saved") - } + #expect(store.draftToApply(over: "", for: id) == "saved") + } - @Test("draftToApply returns nil when the field already has text") - func draftToApplyGuardsNonEmpty() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) - store.setDraft("saved", for: id) + @Test + func `draftToApply returns nil when the field already has text`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) + store.setDraft("saved", for: id) - #expect(store.draftToApply(over: "typing", for: id) == nil) - } + #expect(store.draftToApply(over: "typing", for: id) == nil) + } - @Test("draftToApply returns nil when no draft exists") - func draftToApplyNilWhenAbsent() { - let store = DraftStore(defaults: makeDefaults()) - let id = dm(radioID, contactID) + @Test + func `draftToApply returns nil when no draft exists`() { + let store = DraftStore(defaults: makeDefaults()) + let id = dm(radioID, contactID) - #expect(store.draftToApply(over: "", for: id) == nil) - } + #expect(store.draftToApply(over: "", for: id) == nil) + } - // MARK: - Key isolation + // MARK: - Key isolation - @Test("dm and channel drafts at matching identifiers do not collide") - func dmVsChannelIsolation() { - let store = DraftStore(defaults: makeDefaults()) - let dmID = dm(radioID, contactID) - let channelID = channel(radioID, 3) + @Test + func `dm and channel drafts at matching identifiers do not collide`() { + let store = DraftStore(defaults: makeDefaults()) + let dmID = dm(radioID, contactID) + let channelID = channel(radioID, 3) - store.setDraft("dm-text", for: dmID) - store.setDraft("channel-text", for: channelID) + store.setDraft("dm-text", for: dmID) + store.setDraft("channel-text", for: channelID) - #expect(store.draft(for: dmID) == "dm-text") - #expect(store.draft(for: channelID) == "channel-text") - } + #expect(store.draft(for: dmID) == "dm-text") + #expect(store.draft(for: channelID) == "channel-text") + } - @Test("same channel index on different radios is isolated") - func sameIndexDifferentRadioIsolation() { - let store = DraftStore(defaults: makeDefaults()) - let radioA = UUID() - let radioB = UUID() + @Test + func `same channel index on different radios is isolated`() { + let store = DraftStore(defaults: makeDefaults()) + let radioA = UUID() + let radioB = UUID() - store.setDraft("a", for: channel(radioA, 1)) - store.setDraft("b", for: channel(radioB, 1)) - - #expect(store.draft(for: channel(radioA, 1)) == "a") - #expect(store.draft(for: channel(radioB, 1)) == "b") - } + store.setDraft("a", for: channel(radioA, 1)) + store.setDraft("b", for: channel(radioB, 1)) + + #expect(store.draft(for: channel(radioA, 1)) == "a") + #expect(store.draft(for: channel(radioB, 1)) == "b") + } - // MARK: - Cleared state persistence + // MARK: - Cleared state persistence - @Test("clearDraft persists the deletion across a fresh instance (restart)") - func clearedDraftDoesNotSurviveRestart() { - let defaults = makeDefaults() - let id = dm(radioID, contactID) - - let first = DraftStore(defaults: defaults) - first.setDraft("persisted", for: id) - first.clearDraft(for: id) - - let second = DraftStore(defaults: defaults) - #expect(second.draft(for: id) == nil) - } - - @Test("whitespace-clear persists the deletion across a fresh instance (restart)") - func whitespaceClearedDraftDoesNotSurviveRestart() { - let defaults = makeDefaults() - let id = dm(radioID, contactID) - - let first = DraftStore(defaults: defaults) - first.setDraft("persisted", for: id) - first.setDraft(" \n ", for: id) - - let second = DraftStore(defaults: defaults) - #expect(second.draft(for: id) == nil) - } - - @Test("clearDraft on a never-set id is a no-op") - func clearDraftOnUnsetIdIsNoOp() { - let defaults = makeDefaults() - let id = channel(radioID, 4) - - let store = DraftStore(defaults: defaults) - store.clearDraft(for: id) - #expect(store.draft(for: id) == nil) - - let reloaded = DraftStore(defaults: defaults) - #expect(reloaded.draft(for: id) == nil) - } - - // MARK: - Batch channel-draft clearing - - @Test("clearChannelDrafts(radioID:indices:) clears the given slots and persists") - func clearChannelDraftsByRadioClearsAndPersists() { - let defaults = makeDefaults() - let store = DraftStore(defaults: defaults) - - store.setDraft("one", for: channel(radioID, 1)) - store.setDraft("two", for: channel(radioID, 2)) - store.setDraft("keep", for: channel(radioID, 3)) - - store.clearChannelDrafts(radioID: radioID, indices: [1, 2]) - - #expect(store.draft(for: channel(radioID, 1)) == nil) - #expect(store.draft(for: channel(radioID, 2)) == nil) - #expect(store.draft(for: channel(radioID, 3)) == "keep") - - let reloaded = DraftStore(defaults: defaults) - #expect(reloaded.draft(for: channel(radioID, 1)) == nil) - #expect(reloaded.draft(for: channel(radioID, 3)) == "keep") - } - - @Test("clearChannelDrafts(slotsByRadio:) clears slots across radios, leaving dm drafts intact") - func clearChannelDraftsBySlotsByRadioIsScoped() { - let store = DraftStore(defaults: makeDefaults()) - let radioA = UUID() - let radioB = UUID() - - store.setDraft("a1", for: channel(radioA, 1)) - store.setDraft("b1", for: channel(radioB, 1)) - store.setDraft("dm", for: dm(radioA, contactID)) - - store.clearChannelDrafts(slotsByRadio: [radioA: [1], radioB: [1]]) - - #expect(store.draft(for: channel(radioA, 1)) == nil) - #expect(store.draft(for: channel(radioB, 1)) == nil) - #expect(store.draft(for: dm(radioA, contactID)) == "dm") - } - - // MARK: - Pinned key encoding - - @Test("draftStorageKey encodes dm and channel keys in the pinned format") - func pinnedKeyStrings() { - let radio = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let contact = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! - - #expect( - dm(radio, contact).draftStorageKey - == "11111111-1111-1111-1111-111111111111|dm|22222222-2222-2222-2222-222222222222" - ) - #expect( - channel(radio, 5).draftStorageKey - == "11111111-1111-1111-1111-111111111111|ch|5" - ) - } + @Test + func `clearDraft persists the deletion across a fresh instance (restart)`() { + let defaults = makeDefaults() + let id = dm(radioID, contactID) + + let first = DraftStore(defaults: defaults) + first.setDraft("persisted", for: id) + first.clearDraft(for: id) + + let second = DraftStore(defaults: defaults) + #expect(second.draft(for: id) == nil) + } + + @Test + func `whitespace-clear persists the deletion across a fresh instance (restart)`() { + let defaults = makeDefaults() + let id = dm(radioID, contactID) + + let first = DraftStore(defaults: defaults) + first.setDraft("persisted", for: id) + first.setDraft(" \n ", for: id) + + let second = DraftStore(defaults: defaults) + #expect(second.draft(for: id) == nil) + } + + @Test + func `clearDraft on a never-set id is a no-op`() { + let defaults = makeDefaults() + let id = channel(radioID, 4) + + let store = DraftStore(defaults: defaults) + store.clearDraft(for: id) + #expect(store.draft(for: id) == nil) + + let reloaded = DraftStore(defaults: defaults) + #expect(reloaded.draft(for: id) == nil) + } + + // MARK: - Batch channel-draft clearing + + @Test + func `clearChannelDrafts(radioID:indices:) clears the given slots and persists`() { + let defaults = makeDefaults() + let store = DraftStore(defaults: defaults) + + store.setDraft("one", for: channel(radioID, 1)) + store.setDraft("two", for: channel(radioID, 2)) + store.setDraft("keep", for: channel(radioID, 3)) + + store.clearChannelDrafts(radioID: radioID, indices: [1, 2]) + + #expect(store.draft(for: channel(radioID, 1)) == nil) + #expect(store.draft(for: channel(radioID, 2)) == nil) + #expect(store.draft(for: channel(radioID, 3)) == "keep") + + let reloaded = DraftStore(defaults: defaults) + #expect(reloaded.draft(for: channel(radioID, 1)) == nil) + #expect(reloaded.draft(for: channel(radioID, 3)) == "keep") + } + + @Test + func `clearChannelDrafts(slotsByRadio:) clears slots across radios, leaving dm drafts intact`() { + let store = DraftStore(defaults: makeDefaults()) + let radioA = UUID() + let radioB = UUID() + + store.setDraft("a1", for: channel(radioA, 1)) + store.setDraft("b1", for: channel(radioB, 1)) + store.setDraft("dm", for: dm(radioA, contactID)) + + store.clearChannelDrafts(slotsByRadio: [radioA: [1], radioB: [1]]) + + #expect(store.draft(for: channel(radioA, 1)) == nil) + #expect(store.draft(for: channel(radioB, 1)) == nil) + #expect(store.draft(for: dm(radioA, contactID)) == "dm") + } + + // MARK: - Pinned key encoding + + @Test + func `draftStorageKey encodes dm and channel keys in the pinned format`() throws { + let radio = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let contact = try #require(UUID(uuidString: "22222222-2222-2222-2222-222222222222")) + + #expect( + dm(radio, contact).draftStorageKey + == "11111111-1111-1111-1111-111111111111|dm|22222222-2222-2222-2222-222222222222" + ) + #expect( + channel(radio, 5).draftStorageKey + == "11111111-1111-1111-1111-111111111111|ch|5" + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift b/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift index d301a07b..71691a0c 100644 --- a/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift @@ -1,44 +1,43 @@ -import Testing @testable import MC1Services +import Testing @Suite("EnvInputs theme token") struct EnvInputsThemeTokenTests { + private func make(themeID: String) -> EnvInputs { + EnvInputs( + showInlineImages: true, + autoPlayGIFs: true, + showIncomingPath: true, + showIncomingHopCount: true, + showIncomingRegion: true, + showIncomingSendTime: true, + previewsEnabled: true, + isHighContrast: false, + isDark: false, + showMapPreviews: true, + isOffline: false, + currentUserName: "Tester", + themeID: themeID, + contentSizeCategory: EnvInputs.defaultContentSizeCategory + ) + } - private func make(themeID: String) -> EnvInputs { - EnvInputs( - showInlineImages: true, - autoPlayGIFs: true, - showIncomingPath: true, - showIncomingHopCount: true, - showIncomingRegion: true, - showIncomingSendTime: true, - previewsEnabled: true, - isHighContrast: false, - isDark: false, - showMapPreviews: true, - isOffline: false, - currentUserName: "Tester", - themeID: themeID, - contentSizeCategory: EnvInputs.defaultContentSizeCategory - ) - } - - @Test("changing only themeID makes EnvInputs unequal (drives cache invalidation)") - func themeIDChangeIsObservable() { - #expect(make(themeID: "default") != make(themeID: "ember")) - #expect(make(themeID: "default") == make(themeID: "default")) - } + @Test + func `changing only themeID makes EnvInputs unequal (drives cache invalidation)`() { + #expect(make(themeID: "default") != make(themeID: "ember")) + #expect(make(themeID: "default") == make(themeID: "default")) + } - @Test("equal themeIDs produce equal hashes") - func equalThemeIDsHashEqually() { - // Hashable only guarantees equal values hash equally; distinct values may legally collide, - // so asserting unequal hashes for distinct themeIDs is non-contractual. The meaningful - // property — themeID affecting equality — is covered by `themeIDChangeIsObservable`. - #expect(make(themeID: "marine").hashValue == make(themeID: "marine").hashValue) - } + @Test + func `equal themeIDs produce equal hashes`() { + // Hashable only guarantees equal values hash equally; distinct values may legally collide, + // so asserting unequal hashes for distinct themeIDs is non-contractual. The meaningful + // property — themeID affecting equality — is covered by `themeIDChangeIsObservable`. + #expect(make(themeID: "marine").hashValue == make(themeID: "marine").hashValue) + } - @Test("EnvInputs.default carries the default theme id") - func defaultTokenIsDefault() { - #expect(EnvInputs.default.themeID == "default") - } + @Test + func `EnvInputs.default carries the default theme id`() { + #expect(EnvInputs.default.themeID == "default") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/EventBroadcasterTests.swift b/MC1Services/Tests/MC1ServicesTests/EventBroadcasterTests.swift index 51b3950f..3fee8528 100644 --- a/MC1Services/Tests/MC1ServicesTests/EventBroadcasterTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/EventBroadcasterTests.swift @@ -1,113 +1,112 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("EventBroadcaster Tests") struct EventBroadcasterTests { - - @Test("two subscribers both receive every event in yield order") - func twoSubscribersReceiveAllEventsInOrder() async { - let broadcaster = EventBroadcaster() - let streamA = broadcaster.subscribe() - let streamB = broadcaster.subscribe() - let events = [1, 2, 3, 4, 5] - - for value in events { - broadcaster.yield(value) - } - broadcaster.finish() - - var receivedA: [Int] = [] - for await value in streamA { - receivedA.append(value) - } - var receivedB: [Int] = [] - for await value in streamB { - receivedB.append(value) - } - - #expect(receivedA == events) - #expect(receivedB == events) + @Test + func `two subscribers both receive every event in yield order`() async { + let broadcaster = EventBroadcaster() + let streamA = broadcaster.subscribe() + let streamB = broadcaster.subscribe() + let events = [1, 2, 3, 4, 5] + + for value in events { + broadcaster.yield(value) } + broadcaster.finish() - @Test("a cancelled subscriber is pruned and does not affect its sibling") - func cancelledSubscriberIsPrunedWithoutAffectingSibling() async { - let broadcaster = EventBroadcaster() - let doomed = broadcaster.subscribe() - let survivor = broadcaster.subscribe() - #expect(broadcaster.subscriberCount == 2) - - let consumer = Task { - for await _ in doomed {} - } - consumer.cancel() - await consumer.value - #expect(broadcaster.subscriberCount == 1) - - broadcaster.yield(7) - broadcaster.finish() - - var received: [Int] = [] - for await value in survivor { - received.append(value) - } - #expect(received == [7]) + var receivedA: [Int] = [] + for await value in streamA { + receivedA.append(value) } - - @Test("finish ends every subscriber's for-await loop") - func finishEndsAllSubscriberLoops() async { - let broadcaster = EventBroadcaster() - let streamA = broadcaster.subscribe() - let streamB = broadcaster.subscribe() - - let consumerA = Task { - for await _ in streamA {} - return true - } - let consumerB = Task { - for await _ in streamB {} - return true - } - - broadcaster.finish() - - #expect(await consumerA.value) - #expect(await consumerB.value) - #expect(broadcaster.subscriberCount == 0) + var receivedB: [Int] = [] + for await value in streamB { + receivedB.append(value) } - @Test("an event yielded immediately after subscribe is never dropped") - func subscribeThenImmediateYieldIsDelivered() async { - let broadcaster = EventBroadcaster() - let stream = broadcaster.subscribe() - broadcaster.yield("first") - broadcaster.finish() - - var received: [String] = [] - for await value in stream { - received.append(value) - } - #expect(received == ["first"]) + #expect(receivedA == events) + #expect(receivedB == events) + } + + @Test + func `a cancelled subscriber is pruned and does not affect its sibling`() async { + let broadcaster = EventBroadcaster() + let doomed = broadcaster.subscribe() + let survivor = broadcaster.subscribe() + #expect(broadcaster.subscriberCount == 2) + + let consumer = Task { + for await _ in doomed {} } + consumer.cancel() + await consumer.value + #expect(broadcaster.subscriberCount == 1) + + broadcaster.yield(7) + broadcaster.finish() - @Test("subscribing after finish returns a stream that completes immediately") - func subscribeAfterFinishCompletesImmediately() async { - let broadcaster = EventBroadcaster() - broadcaster.finish() - - let stream = broadcaster.subscribe() - var received: [Int] = [] - for await value in stream { - received.append(value) - } - #expect(received.isEmpty) + var received: [Int] = [] + for await value in survivor { + received.append(value) } + #expect(received == [7]) + } + + @Test + func `finish ends every subscriber's for-await loop`() async { + let broadcaster = EventBroadcaster() + let streamA = broadcaster.subscribe() + let streamB = broadcaster.subscribe() + + let consumerA = Task { + for await _ in streamA {} + return true + } + let consumerB = Task { + for await _ in streamB {} + return true + } + + broadcaster.finish() - @Test("yield after finish reaches no subscriber") - func yieldAfterFinishReachesNobody() async { - let broadcaster = EventBroadcaster() - broadcaster.finish() - broadcaster.yield(42) - #expect(broadcaster.subscriberCount == 0) + #expect(await consumerA.value) + #expect(await consumerB.value) + #expect(broadcaster.subscriberCount == 0) + } + + @Test + func `an event yielded immediately after subscribe is never dropped`() async { + let broadcaster = EventBroadcaster() + let stream = broadcaster.subscribe() + broadcaster.yield("first") + broadcaster.finish() + + var received: [String] = [] + for await value in stream { + received.append(value) + } + #expect(received == ["first"]) + } + + @Test + func `subscribing after finish returns a stream that completes immediately`() async { + let broadcaster = EventBroadcaster() + broadcaster.finish() + + let stream = broadcaster.subscribe() + var received: [Int] = [] + for await value in stream { + received.append(value) } + #expect(received.isEmpty) + } + + @Test + func `yield after finish reaches no subscriber`() { + let broadcaster = EventBroadcaster() + broadcaster.finish() + broadcaster.yield(42) + #expect(broadcaster.subscriberCount == 0) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/AppBackupEnvelope+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/AppBackupEnvelope+Testing.swift index 1f4e887f..6514bdd4 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/AppBackupEnvelope+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/AppBackupEnvelope+Testing.swift @@ -2,45 +2,44 @@ import Foundation @testable import MC1Services extension AppBackupEnvelope { - - /// Builds a test envelope with a manifest already derived from the passed arrays. - /// Every DTO array and userDefaults payload defaults to empty, so call sites - /// only pass what the specific test needs. - static func test( - exportDate: Date = Date(timeIntervalSince1970: 1_700_000_000), - appVersion: String = "test", - appBuild: String = "1", - devices: [DeviceDTO] = [], - contacts: [ContactDTO] = [], - channels: [ChannelDTO] = [], - messages: [MessageDTO] = [], - messageRepeats: [MessageRepeatDTO] = [], - reactions: [ReactionDTO] = [], - roomMessages: [RoomMessageDTO] = [], - remoteNodeSessions: [RemoteNodeSessionDTO] = [], - savedTracePaths: [SavedTracePathDTO] = [], - blockedChannelSenders: [BlockedChannelSenderDTO] = [], - nodeStatusSnapshots: [NodeStatusSnapshotDTO] = [], - userDefaults: BackupUserDefaults? = nil - ) -> AppBackupEnvelope { - var envelope = AppBackupEnvelope( - exportDate: exportDate, - appVersion: appVersion, - appBuild: appBuild, - devices: devices, - contacts: contacts, - channels: channels, - messages: messages, - messageRepeats: messageRepeats, - reactions: reactions, - roomMessages: roomMessages, - remoteNodeSessions: remoteNodeSessions, - savedTracePaths: savedTracePaths, - blockedChannelSenders: blockedChannelSenders, - nodeStatusSnapshots: nodeStatusSnapshots, - userDefaults: userDefaults - ) - envelope.manifest = BackupManifest(from: envelope) - return envelope - } + /// Builds a test envelope with a manifest already derived from the passed arrays. + /// Every DTO array and userDefaults payload defaults to empty, so call sites + /// only pass what the specific test needs. + static func test( + exportDate: Date = Date(timeIntervalSince1970: 1_700_000_000), + appVersion: String = "test", + appBuild: String = "1", + devices: [DeviceDTO] = [], + contacts: [ContactDTO] = [], + channels: [ChannelDTO] = [], + messages: [MessageDTO] = [], + messageRepeats: [MessageRepeatDTO] = [], + reactions: [ReactionDTO] = [], + roomMessages: [RoomMessageDTO] = [], + remoteNodeSessions: [RemoteNodeSessionDTO] = [], + savedTracePaths: [SavedTracePathDTO] = [], + blockedChannelSenders: [BlockedChannelSenderDTO] = [], + nodeStatusSnapshots: [NodeStatusSnapshotDTO] = [], + userDefaults: BackupUserDefaults? = nil + ) -> AppBackupEnvelope { + var envelope = AppBackupEnvelope( + exportDate: exportDate, + appVersion: appVersion, + appBuild: appBuild, + devices: devices, + contacts: contacts, + channels: channels, + messages: messages, + messageRepeats: messageRepeats, + reactions: reactions, + roomMessages: roomMessages, + remoteNodeSessions: remoteNodeSessions, + savedTracePaths: savedTracePaths, + blockedChannelSenders: blockedChannelSenders, + nodeStatusSnapshots: nodeStatusSnapshots, + userDefaults: userDefaults + ) + envelope.manifest = BackupManifest(from: envelope) + return envelope + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/BlockedChannelSenderDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/BlockedChannelSenderDTO+Testing.swift index c18d065b..368f4c3f 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/BlockedChannelSenderDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/BlockedChannelSenderDTO+Testing.swift @@ -2,24 +2,23 @@ import Foundation @testable import MC1Services extension BlockedChannelSenderDTO { - - /// Creates a BlockedChannelSenderDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: myRadioID) - /// ``` - static func testBlockedSender( - id: UUID = UUID(), - name: String = "SpammerNode", - radioID: UUID, - dateBlocked: Date = Date() - ) -> BlockedChannelSenderDTO { - BlockedChannelSenderDTO( - id: id, - name: name, - radioID: radioID, - dateBlocked: dateBlocked - ) - } + /// Creates a BlockedChannelSenderDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let blocked = BlockedChannelSenderDTO.testBlockedSender(radioID: myRadioID) + /// ``` + static func testBlockedSender( + id: UUID = UUID(), + name: String = "SpammerNode", + radioID: UUID, + dateBlocked: Date = Date() + ) -> BlockedChannelSenderDTO { + BlockedChannelSenderDTO( + id: id, + name: name, + radioID: radioID, + dateBlocked: dateBlocked + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ChannelDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ChannelDTO+Testing.swift index f79d0784..cfdc807a 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ChannelDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ChannelDTO+Testing.swift @@ -2,41 +2,40 @@ import Foundation @testable import MC1Services extension ChannelDTO { - - /// Creates a ChannelDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let channel = ChannelDTO.testChannel(radioID: myRadioID) - /// let private = ChannelDTO.testChannel(radioID: myRadioID, index: 1, name: "Private") - /// ``` - static func testChannel( - id: UUID = UUID(), - radioID: UUID, - index: UInt8 = 0, - name: String = "General", - secret: Data = Data(repeating: 0, count: 16), - isEnabled: Bool = true, - lastMessageDate: Date? = nil, - unreadCount: Int = 0, - unreadMentionCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - floodScope: ChannelFloodScope = .inherit - ) -> ChannelDTO { - ChannelDTO( - id: id, - radioID: radioID, - index: index, - name: name, - secret: secret, - isEnabled: isEnabled, - lastMessageDate: lastMessageDate, - unreadCount: unreadCount, - unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, - isFavorite: isFavorite, - floodScope: floodScope - ) - } + /// Creates a ChannelDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let channel = ChannelDTO.testChannel(radioID: myRadioID) + /// let private = ChannelDTO.testChannel(radioID: myRadioID, index: 1, name: "Private") + /// ``` + static func testChannel( + id: UUID = UUID(), + radioID: UUID, + index: UInt8 = 0, + name: String = "General", + secret: Data = Data(repeating: 0, count: 16), + isEnabled: Bool = true, + lastMessageDate: Date? = nil, + unreadCount: Int = 0, + unreadMentionCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + floodScope: ChannelFloodScope = .inherit + ) -> ChannelDTO { + ChannelDTO( + id: id, + radioID: radioID, + index: index, + name: name, + secret: secret, + isEnabled: isEnabled, + lastMessageDate: lastMessageDate, + unreadCount: unreadCount, + unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, + isFavorite: isFavorite, + floodScope: floodScope + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ConnectionManager+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ConnectionManager+Testing.swift index 2e4b97fe..aac6b97d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ConnectionManager+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ConnectionManager+Testing.swift @@ -1,82 +1,81 @@ import Foundation -import SwiftData @testable import MC1Services +import SwiftData extension ConnectionManager { - static func createForTesting( - defaults: UserDefaults? = nil - ) throws -> (ConnectionManager, MockBLEStateMachine) { - let container = try PersistenceStore.createContainer(inMemory: true) - let mock = MockBLEStateMachine() - let manager: ConnectionManager - if let defaults { - manager = ConnectionManager(modelContainer: container, defaults: defaults, stateMachine: mock) - } else { - manager = ConnectionManager(modelContainer: container, stateMachine: mock) - } - return (manager, mock) + static func createForTesting( + defaults: UserDefaults? = nil + ) throws -> (ConnectionManager, MockBLEStateMachine) { + let container = try PersistenceStore.createContainer(inMemory: true) + let mock = MockBLEStateMachine() + let manager = if let defaults { + ConnectionManager(modelContainer: container, defaults: defaults, stateMachine: mock) + } else { + ConnectionManager(modelContainer: container, stateMachine: mock) } + return (manager, mock) + } - @MainActor - static func createForPairingTesting( - defaults: UserDefaults? = nil, - transport: MockMeshTransport? = nil, - accessorySetupKit: MockAccessorySetupKitService? = nil - ) throws -> PairingTestEnvironment { - let container = try PersistenceStore.createContainer(inMemory: true) - let stateMachine = MockBLEStateMachine() - let mockTransport = transport ?? MockMeshTransport() - let mockASK = accessorySetupKit ?? MockAccessorySetupKitService() - - let createdSuiteName: String? - let resolvedDefaults: UserDefaults - if let defaults { - resolvedDefaults = defaults - createdSuiteName = nil - } else { - // Per-test suite isolates UserDefaults so parallel runs don't leak - // connectionIntent into one another's init paths. - let name = "test.\(UUID().uuidString)" - resolvedDefaults = UserDefaults(suiteName: name)! - createdSuiteName = name - } + @MainActor + static func createForPairingTesting( + defaults: UserDefaults? = nil, + transport: MockMeshTransport? = nil, + accessorySetupKit: MockAccessorySetupKitService? = nil + ) throws -> PairingTestEnvironment { + let container = try PersistenceStore.createContainer(inMemory: true) + let stateMachine = MockBLEStateMachine() + let mockTransport = transport ?? MockMeshTransport() + let mockASK = accessorySetupKit ?? MockAccessorySetupKitService() - // Wrap the ASK mock in the production iOS pairing adapter so pairing flows exercise - // the real `DevicePairingService` seam while tests still assert on the mock's counts. - let manager = ConnectionManager( - modelContainer: container, - defaults: resolvedDefaults, - stateMachine: stateMachine, - transport: mockTransport, - pairing: AccessorySetupPairingService(accessorySetupKit: mockASK) - ) + let createdSuiteName: String? + let resolvedDefaults: UserDefaults + if let defaults { + resolvedDefaults = defaults + createdSuiteName = nil + } else { + // Per-test suite isolates UserDefaults so parallel runs don't leak + // connectionIntent into one another's init paths. + let name = "test.\(UUID().uuidString)" + resolvedDefaults = UserDefaults(suiteName: name)! + createdSuiteName = name + } - // Cleanup closure: tests `defer { env.cleanup() }` to remove the persistent - // suite and avoid plist accumulation in `~/Library/Preferences`. No-op - // when the caller supplied their own defaults. - let cleanup: () -> Void = { - if let name = createdSuiteName { - UserDefaults().removePersistentDomain(forName: name) - } - } + // Wrap the ASK mock in the production iOS pairing adapter so pairing flows exercise + // the real `DevicePairingService` seam while tests still assert on the mock's counts. + let manager = ConnectionManager( + modelContainer: container, + defaults: resolvedDefaults, + stateMachine: stateMachine, + transport: mockTransport, + pairing: AccessorySetupPairingService(accessorySetupKit: mockASK) + ) - return PairingTestEnvironment( - manager: manager, - stateMachine: stateMachine, - transport: mockTransport, - accessorySetupKit: mockASK, - cleanup: cleanup - ) + // Cleanup closure: tests `defer { env.cleanup() }` to remove the persistent + // suite and avoid plist accumulation in `~/Library/Preferences`. No-op + // when the caller supplied their own defaults. + let cleanup: () -> Void = { + if let name = createdSuiteName { + UserDefaults().removePersistentDomain(forName: name) + } } + + return PairingTestEnvironment( + manager: manager, + stateMachine: stateMachine, + transport: mockTransport, + accessorySetupKit: mockASK, + cleanup: cleanup + ) + } } /// Bundle of mocks and a cleanup closure produced by `createForPairingTesting`. /// Tests `defer { env.cleanup() }` to release the per-test UserDefaults suite. @MainActor struct PairingTestEnvironment { - let manager: ConnectionManager - let stateMachine: MockBLEStateMachine - let transport: MockMeshTransport - let accessorySetupKit: MockAccessorySetupKitService - let cleanup: () -> Void + let manager: ConnectionManager + let stateMachine: MockBLEStateMachine + let transport: MockMeshTransport + let accessorySetupKit: MockAccessorySetupKitService + let cleanup: () -> Void } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift index 1757da5b..fca685c4 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift @@ -1,57 +1,56 @@ import Foundation -import MeshCore @testable import MC1Services +import MeshCore extension ContactDTO { - - /// Creates a ContactDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let contact = ContactDTO.testContact(radioID: myRadioID) - /// let repeater = ContactDTO.testContact(radioID: myRadioID, typeRawValue: ContactType.repeater.rawValue) - /// ``` - static func testContact( - id: UUID = UUID(), - radioID: UUID = UUID(), - publicKey: Data = Data(repeating: 0xAB, count: 32), - name: String = "TestContact", - typeRawValue: UInt8 = ContactType.chat.rawValue, - flags: UInt8 = 0, - outPathLength: UInt8 = 0xFF, - outPath: Data = Data(), - lastAdvertTimestamp: UInt32 = 0, - latitude: Double = 0, - longitude: Double = 0, - lastModified: UInt32 = 0, - nickname: String? = nil, - isBlocked: Bool = false, - isMuted: Bool = false, - isFavorite: Bool = false, - lastMessageDate: Date? = nil, - unreadCount: Int = 0, - unreadMentionCount: Int = 0 - ) -> ContactDTO { - ContactDTO( - id: id, - radioID: radioID, - publicKey: publicKey, - name: name, - typeRawValue: typeRawValue, - flags: flags, - outPathLength: outPathLength, - outPath: outPath, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: lastModified, - nickname: nickname, - isBlocked: isBlocked, - isMuted: isMuted, - isFavorite: isFavorite, - lastMessageDate: lastMessageDate, - unreadCount: unreadCount, - unreadMentionCount: unreadMentionCount - ) - } + /// Creates a ContactDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let contact = ContactDTO.testContact(radioID: myRadioID) + /// let repeater = ContactDTO.testContact(radioID: myRadioID, typeRawValue: ContactType.repeater.rawValue) + /// ``` + static func testContact( + id: UUID = UUID(), + radioID: UUID = UUID(), + publicKey: Data = Data(repeating: 0xAB, count: 32), + name: String = "TestContact", + typeRawValue: UInt8 = ContactType.chat.rawValue, + flags: UInt8 = 0, + outPathLength: UInt8 = 0xFF, + outPath: Data = Data(), + lastAdvertTimestamp: UInt32 = 0, + latitude: Double = 0, + longitude: Double = 0, + lastModified: UInt32 = 0, + nickname: String? = nil, + isBlocked: Bool = false, + isMuted: Bool = false, + isFavorite: Bool = false, + lastMessageDate: Date? = nil, + unreadCount: Int = 0, + unreadMentionCount: Int = 0 + ) -> ContactDTO { + ContactDTO( + id: id, + radioID: radioID, + publicKey: publicKey, + name: name, + typeRawValue: typeRawValue, + flags: flags, + outPathLength: outPathLength, + outPath: outPath, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified, + nickname: nickname, + isBlocked: isBlocked, + isMuted: isMuted, + isFavorite: isFavorite, + lastMessageDate: lastMessageDate, + unreadCount: unreadCount, + unreadMentionCount: unreadMentionCount + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/DeviceDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/DeviceDTO+Testing.swift index a44da7eb..ffbeca18 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/DeviceDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/DeviceDTO+Testing.swift @@ -2,67 +2,66 @@ import Foundation @testable import MC1Services extension DeviceDTO { - - /// Creates a DeviceDTO with sensible test defaults. Override specific fields with `copy {}`. - /// - /// Usage: - /// ``` - /// let device = DeviceDTO.testDevice() - /// let custom = DeviceDTO.testDevice(id: myID).copy { $0.firmwareVersion = 10 } - /// ``` - static func testDevice( - id: UUID = UUID(), - radioID: UUID? = nil, - publicKey: Data = Data(repeating: 0x01, count: 32), - nodeName: String = "TestDevice", - firmwareVersion: UInt8 = 9, - firmwareVersionString: String = "v1.13.0", - maxContacts: UInt16 = 100, - maxChannels: UInt8 = 8, - frequency: UInt32 = 915_000, - bandwidth: UInt32 = 250_000, - spreadingFactor: UInt8 = 10, - codingRate: UInt8 = 5, - txPower: Int8 = 20, - maxTxPower: Int8 = 20, - manualAddContacts: Bool = false, - multiAcks: UInt8 = 2, - lastConnected: Date = Date(), - lastContactSync: UInt32 = 0, - isActive: Bool = true - ) -> DeviceDTO { - let resolvedRadioID = radioID ?? id - return DeviceDTO( - id: id, - radioID: resolvedRadioID, - publicKey: publicKey, - nodeName: nodeName, - firmwareVersion: firmwareVersion, - firmwareVersionString: firmwareVersionString, - manufacturerName: "TestMfg", - buildDate: "01 Jan 2025", - maxContacts: maxContacts, - maxChannels: maxChannels, - frequency: frequency, - bandwidth: bandwidth, - spreadingFactor: spreadingFactor, - codingRate: codingRate, - txPower: txPower, - maxTxPower: maxTxPower, - latitude: 0, - longitude: 0, - blePin: 0, - manualAddContacts: manualAddContacts, - multiAcks: multiAcks, - telemetryModeBase: 2, - telemetryModeLoc: 0, - telemetryModeEnv: 0, - advertLocationPolicy: 0, - lastConnected: lastConnected, - lastContactSync: lastContactSync, - isActive: isActive, - ocvPreset: nil, - customOCVArrayString: nil - ) - } + /// Creates a DeviceDTO with sensible test defaults. Override specific fields with `copy {}`. + /// + /// Usage: + /// ``` + /// let device = DeviceDTO.testDevice() + /// let custom = DeviceDTO.testDevice(id: myID).copy { $0.firmwareVersion = 10 } + /// ``` + static func testDevice( + id: UUID = UUID(), + radioID: UUID? = nil, + publicKey: Data = Data(repeating: 0x01, count: 32), + nodeName: String = "TestDevice", + firmwareVersion: UInt8 = 9, + firmwareVersionString: String = "v1.13.0", + maxContacts: UInt16 = 100, + maxChannels: UInt8 = 8, + frequency: UInt32 = 915_000, + bandwidth: UInt32 = 250_000, + spreadingFactor: UInt8 = 10, + codingRate: UInt8 = 5, + txPower: Int8 = 20, + maxTxPower: Int8 = 20, + manualAddContacts: Bool = false, + multiAcks: UInt8 = 2, + lastConnected: Date = Date(), + lastContactSync: UInt32 = 0, + isActive: Bool = true + ) -> DeviceDTO { + let resolvedRadioID = radioID ?? id + return DeviceDTO( + id: id, + radioID: resolvedRadioID, + publicKey: publicKey, + nodeName: nodeName, + firmwareVersion: firmwareVersion, + firmwareVersionString: firmwareVersionString, + manufacturerName: "TestMfg", + buildDate: "01 Jan 2025", + maxContacts: maxContacts, + maxChannels: maxChannels, + frequency: frequency, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + txPower: txPower, + maxTxPower: maxTxPower, + latitude: 0, + longitude: 0, + blePin: 0, + manualAddContacts: manualAddContacts, + multiAcks: multiAcks, + telemetryModeBase: 2, + telemetryModeLoc: 0, + telemetryModeEnv: 0, + advertLocationPolicy: 0, + lastConnected: lastConnected, + lastContactSync: lastContactSync, + isActive: isActive, + ocvPreset: nil, + customOCVArrayString: nil + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ImportResult+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ImportResult+Testing.swift index 6a66194b..aff3c934 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ImportResult+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ImportResult+Testing.swift @@ -2,31 +2,111 @@ import Foundation @testable import MC1Services extension ImportResult { - var devicesInserted: Int { counts[.devices]?.inserted ?? 0 } - var devicesSkipped: Int { counts[.devices]?.skipped ?? 0 } - var contactsInserted: Int { counts[.contacts]?.inserted ?? 0 } - var contactsSkipped: Int { counts[.contacts]?.skipped ?? 0 } - var contactsMerged: Int { counts[.contacts]?.merged ?? 0 } - var channelsInserted: Int { counts[.channels]?.inserted ?? 0 } - var channelsSkipped: Int { counts[.channels]?.skipped ?? 0 } - var channelsMerged: Int { counts[.channels]?.merged ?? 0 } - var channelsDropped: Int { counts[.channels]?.dropped ?? 0 } - var messagesInserted: Int { counts[.messages]?.inserted ?? 0 } - var messagesSkipped: Int { counts[.messages]?.skipped ?? 0 } - var messageRepeatsInserted: Int { counts[.messageRepeats]?.inserted ?? 0 } - var messageRepeatsSkipped: Int { counts[.messageRepeats]?.skipped ?? 0 } - var reactionsInserted: Int { counts[.reactions]?.inserted ?? 0 } - var reactionsSkipped: Int { counts[.reactions]?.skipped ?? 0 } - var roomMessagesInserted: Int { counts[.roomMessages]?.inserted ?? 0 } - var roomMessagesSkipped: Int { counts[.roomMessages]?.skipped ?? 0 } - var remoteNodeSessionsInserted: Int { counts[.remoteNodeSessions]?.inserted ?? 0 } - var remoteNodeSessionsSkipped: Int { counts[.remoteNodeSessions]?.skipped ?? 0 } - var remoteNodeSessionsMerged: Int { counts[.remoteNodeSessions]?.merged ?? 0 } - var savedTracePathsInserted: Int { counts[.savedTracePaths]?.inserted ?? 0 } - var savedTracePathsSkipped: Int { counts[.savedTracePaths]?.skipped ?? 0 } - var savedTracePathsMerged: Int { counts[.savedTracePaths]?.merged ?? 0 } - var blockedChannelSendersInserted: Int { counts[.blockedChannelSenders]?.inserted ?? 0 } - var blockedChannelSendersSkipped: Int { counts[.blockedChannelSenders]?.skipped ?? 0 } - var nodeStatusSnapshotsInserted: Int { counts[.nodeStatusSnapshots]?.inserted ?? 0 } - var nodeStatusSnapshotsSkipped: Int { counts[.nodeStatusSnapshots]?.skipped ?? 0 } + var devicesInserted: Int { + counts[.devices]?.inserted ?? 0 + } + + var devicesSkipped: Int { + counts[.devices]?.skipped ?? 0 + } + + var contactsInserted: Int { + counts[.contacts]?.inserted ?? 0 + } + + var contactsSkipped: Int { + counts[.contacts]?.skipped ?? 0 + } + + var contactsMerged: Int { + counts[.contacts]?.merged ?? 0 + } + + var channelsInserted: Int { + counts[.channels]?.inserted ?? 0 + } + + var channelsSkipped: Int { + counts[.channels]?.skipped ?? 0 + } + + var channelsMerged: Int { + counts[.channels]?.merged ?? 0 + } + + var channelsDropped: Int { + counts[.channels]?.dropped ?? 0 + } + + var messagesInserted: Int { + counts[.messages]?.inserted ?? 0 + } + + var messagesSkipped: Int { + counts[.messages]?.skipped ?? 0 + } + + var messageRepeatsInserted: Int { + counts[.messageRepeats]?.inserted ?? 0 + } + + var messageRepeatsSkipped: Int { + counts[.messageRepeats]?.skipped ?? 0 + } + + var reactionsInserted: Int { + counts[.reactions]?.inserted ?? 0 + } + + var reactionsSkipped: Int { + counts[.reactions]?.skipped ?? 0 + } + + var roomMessagesInserted: Int { + counts[.roomMessages]?.inserted ?? 0 + } + + var roomMessagesSkipped: Int { + counts[.roomMessages]?.skipped ?? 0 + } + + var remoteNodeSessionsInserted: Int { + counts[.remoteNodeSessions]?.inserted ?? 0 + } + + var remoteNodeSessionsSkipped: Int { + counts[.remoteNodeSessions]?.skipped ?? 0 + } + + var remoteNodeSessionsMerged: Int { + counts[.remoteNodeSessions]?.merged ?? 0 + } + + var savedTracePathsInserted: Int { + counts[.savedTracePaths]?.inserted ?? 0 + } + + var savedTracePathsSkipped: Int { + counts[.savedTracePaths]?.skipped ?? 0 + } + + var savedTracePathsMerged: Int { + counts[.savedTracePaths]?.merged ?? 0 + } + + var blockedChannelSendersInserted: Int { + counts[.blockedChannelSenders]?.inserted ?? 0 + } + + var blockedChannelSendersSkipped: Int { + counts[.blockedChannelSenders]?.skipped ?? 0 + } + + var nodeStatusSnapshotsInserted: Int { + counts[.nodeStatusSnapshots]?.inserted ?? 0 + } + + var nodeStatusSnapshotsSkipped: Int { + counts[.nodeStatusSnapshots]?.skipped ?? 0 + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageDTO+Testing.swift index 32b6f007..9428f0c2 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageDTO+Testing.swift @@ -2,114 +2,113 @@ import Foundation @testable import MC1Services extension MessageDTO { + /// Creates a MessageDTO with sensible test defaults for a direct message. + /// + /// Usage: + /// ``` + /// let message = MessageDTO.testDirectMessage(radioID: myRadioID, contactID: contactID) + /// let failed = MessageDTO.testDirectMessage(radioID: id, contactID: cID, status: .failed) + /// ``` + static func testDirectMessage( + id: UUID = UUID(), + radioID: UUID = UUID(), + contactID: UUID = UUID(), + text: String = "Test message", + timestamp: UInt32 = UInt32(Date().timeIntervalSince1970), + createdAt: Date = Date(), + direction: MessageDirection = .outgoing, + status: MessageStatus = .pending, + textType: TextType = .plain, + ackCode: UInt32? = nil, + pathLength: UInt8 = 0, + snr: Double? = nil, + pathNodes: Data? = nil, + senderKeyPrefix: Data? = nil, + isRead: Bool = false, + replyToID: UUID? = nil, + roundTripTime: UInt32? = nil, + heardRepeats: Int = 0, + sendCount: Int = 1, + retryAttempt: Int = 0, + maxRetryAttempts: Int = 0, + containsSelfMention: Bool = false + ) -> MessageDTO { + MessageDTO( + id: id, + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: text, + timestamp: timestamp, + createdAt: createdAt, + direction: direction, + status: status, + textType: textType, + ackCode: ackCode, + pathLength: pathLength, + snr: snr, + pathNodes: pathNodes, + senderKeyPrefix: senderKeyPrefix, + senderNodeName: nil, + isRead: isRead, + replyToID: replyToID, + roundTripTime: roundTripTime, + heardRepeats: heardRepeats, + sendCount: sendCount, + retryAttempt: retryAttempt, + maxRetryAttempts: maxRetryAttempts, + containsSelfMention: containsSelfMention + ) + } - /// Creates a MessageDTO with sensible test defaults for a direct message. - /// - /// Usage: - /// ``` - /// let message = MessageDTO.testDirectMessage(radioID: myRadioID, contactID: contactID) - /// let failed = MessageDTO.testDirectMessage(radioID: id, contactID: cID, status: .failed) - /// ``` - static func testDirectMessage( - id: UUID = UUID(), - radioID: UUID = UUID(), - contactID: UUID = UUID(), - text: String = "Test message", - timestamp: UInt32 = UInt32(Date().timeIntervalSince1970), - createdAt: Date = Date(), - direction: MessageDirection = .outgoing, - status: MessageStatus = .pending, - textType: TextType = .plain, - ackCode: UInt32? = nil, - pathLength: UInt8 = 0, - snr: Double? = nil, - pathNodes: Data? = nil, - senderKeyPrefix: Data? = nil, - isRead: Bool = false, - replyToID: UUID? = nil, - roundTripTime: UInt32? = nil, - heardRepeats: Int = 0, - sendCount: Int = 1, - retryAttempt: Int = 0, - maxRetryAttempts: Int = 0, - containsSelfMention: Bool = false - ) -> MessageDTO { - MessageDTO( - id: id, - radioID: radioID, - contactID: contactID, - channelIndex: nil, - text: text, - timestamp: timestamp, - createdAt: createdAt, - direction: direction, - status: status, - textType: textType, - ackCode: ackCode, - pathLength: pathLength, - snr: snr, - pathNodes: pathNodes, - senderKeyPrefix: senderKeyPrefix, - senderNodeName: nil, - isRead: isRead, - replyToID: replyToID, - roundTripTime: roundTripTime, - heardRepeats: heardRepeats, - sendCount: sendCount, - retryAttempt: retryAttempt, - maxRetryAttempts: maxRetryAttempts, - containsSelfMention: containsSelfMention - ) - } - - /// Creates a MessageDTO with sensible test defaults for a channel message. - /// - /// Usage: - /// ``` - /// let message = MessageDTO.testChannelMessage(radioID: myRadioID, channelIndex: 0) - /// ``` - static func testChannelMessage( - id: UUID = UUID(), - radioID: UUID = UUID(), - channelIndex: UInt8 = 0, - text: String = "Test channel message", - timestamp: UInt32 = UInt32(Date().timeIntervalSince1970), - createdAt: Date = Date(), - direction: MessageDirection = .outgoing, - status: MessageStatus = .sent, - textType: TextType = .plain, - pathLength: UInt8 = 0, - snr: Double? = nil, - senderNodeName: String? = nil, - isRead: Bool = false, - heardRepeats: Int = 0, - sendCount: Int = 1, - containsSelfMention: Bool = false - ) -> MessageDTO { - MessageDTO( - id: id, - radioID: radioID, - contactID: nil, - channelIndex: channelIndex, - text: text, - timestamp: timestamp, - createdAt: createdAt, - direction: direction, - status: status, - textType: textType, - ackCode: nil, - pathLength: pathLength, - snr: snr, - senderKeyPrefix: nil, - senderNodeName: senderNodeName, - isRead: isRead, - replyToID: nil, - roundTripTime: nil, - heardRepeats: heardRepeats, - sendCount: sendCount, - retryAttempt: 0, - maxRetryAttempts: 0, - containsSelfMention: containsSelfMention - ) - } + /// Creates a MessageDTO with sensible test defaults for a channel message. + /// + /// Usage: + /// ``` + /// let message = MessageDTO.testChannelMessage(radioID: myRadioID, channelIndex: 0) + /// ``` + static func testChannelMessage( + id: UUID = UUID(), + radioID: UUID = UUID(), + channelIndex: UInt8 = 0, + text: String = "Test channel message", + timestamp: UInt32 = UInt32(Date().timeIntervalSince1970), + createdAt: Date = Date(), + direction: MessageDirection = .outgoing, + status: MessageStatus = .sent, + textType: TextType = .plain, + pathLength: UInt8 = 0, + snr: Double? = nil, + senderNodeName: String? = nil, + isRead: Bool = false, + heardRepeats: Int = 0, + sendCount: Int = 1, + containsSelfMention: Bool = false + ) -> MessageDTO { + MessageDTO( + id: id, + radioID: radioID, + contactID: nil, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + createdAt: createdAt, + direction: direction, + status: status, + textType: textType, + ackCode: nil, + pathLength: pathLength, + snr: snr, + senderKeyPrefix: nil, + senderNodeName: senderNodeName, + isRead: isRead, + replyToID: nil, + roundTripTime: nil, + heardRepeats: heardRepeats, + sendCount: sendCount, + retryAttempt: 0, + maxRetryAttempts: 0, + containsSelfMention: containsSelfMention + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageRepeatDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageRepeatDTO+Testing.swift index 7641856a..3a4fa02a 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageRepeatDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageRepeatDTO+Testing.swift @@ -2,32 +2,31 @@ import Foundation @testable import MC1Services extension MessageRepeatDTO { - - /// Creates a MessageRepeatDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let repeat = MessageRepeatDTO.testRepeat(messageID: myMessageID) - /// ``` - static func testRepeat( - id: UUID = UUID(), - messageID: UUID, - receivedAt: Date = Date(), - pathNodes: Data = Data([0x31]), - pathLength: UInt8 = 0, - snr: Double? = 8.5, - rssi: Int? = -90, - rxLogEntryID: UUID? = nil - ) -> MessageRepeatDTO { - MessageRepeatDTO( - id: id, - messageID: messageID, - receivedAt: receivedAt, - pathNodes: pathNodes, - pathLength: pathLength, - snr: snr, - rssi: rssi, - rxLogEntryID: rxLogEntryID - ) - } + /// Creates a MessageRepeatDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let repeat = MessageRepeatDTO.testRepeat(messageID: myMessageID) + /// ``` + static func testRepeat( + id: UUID = UUID(), + messageID: UUID, + receivedAt: Date = Date(), + pathNodes: Data = Data([0x31]), + pathLength: UInt8 = 0, + snr: Double? = 8.5, + rssi: Int? = -90, + rxLogEntryID: UUID? = nil + ) -> MessageRepeatDTO { + MessageRepeatDTO( + id: id, + messageID: messageID, + receivedAt: receivedAt, + pathNodes: pathNodes, + pathLength: pathLength, + snr: snr, + rssi: rssi, + rxLogEntryID: rxLogEntryID + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageService+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageService+Testing.swift index 8e5fa387..8de63925 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/MessageService+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/MessageService+Testing.swift @@ -1,150 +1,150 @@ import Foundation -import MeshCoreTestSupport -import Testing @testable import MC1Services @testable import MeshCore +import MeshCoreTestSupport +import Testing extension MessageService { - static func createForTesting( - defaultTimeout: TimeInterval = 5.0, - connectTransport: Bool = false, - config: MessageServiceConfig = .default - ) async throws -> (MessageService, PersistenceStore) { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let transport = SimulatorMockTransport() - if connectTransport { - try await transport.connect() - } - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: defaultTimeout) - ) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil, config: config) - return (service, dataStore) + static func createForTesting( + defaultTimeout: TimeInterval = 5.0, + connectTransport: Bool = false, + config: MessageServiceConfig = .default + ) async throws -> (MessageService, PersistenceStore) { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let transport = SimulatorMockTransport() + if connectTransport { + try await transport.connect() } + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: defaultTimeout) + ) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil, config: config) + return (service, dataStore) + } - func insertInFlightRetryForTest(_ messageID: UUID) { - inFlightRetries.insert(messageID) - } + func insertInFlightRetryForTest(_ messageID: UUID) { + inFlightRetries.insert(messageID) + } - func setPendingAckForTest(_ tracking: PendingAck) { - pendingAcks[tracking.messageID] = tracking - } + func setPendingAckForTest(_ tracking: PendingAck) { + pendingAcks[tracking.messageID] = tracking + } - func pendingAckForTest(_ messageID: UUID) -> PendingAck? { - pendingAcks[messageID] - } + func pendingAckForTest(_ messageID: UUID) -> PendingAck? { + pendingAcks[messageID] + } - /// Ends the status-event stream and returns everything `stream` buffered. - /// Subscribe via `statusEvents()` before triggering the behavior under - /// test: registration is synchronous and production yields happen before - /// the triggering call returns, so the drained array is complete. - nonisolated func drainStatusEvents(_ stream: AsyncStream) async -> [MessageStatusEvent] { - finishStatusEvents() - var events: [MessageStatusEvent] = [] - for await event in stream { - events.append(event) - } - return events + /// Ends the status-event stream and returns everything `stream` buffered. + /// Subscribe via `statusEvents()` before triggering the behavior under + /// test: registration is synchronous and production yields happen before + /// the triggering call returns, so the drained array is complete. + nonisolated func drainStatusEvents(_ stream: AsyncStream) async -> [MessageStatusEvent] { + finishStatusEvents() + var events: [MessageStatusEvent] = [] + for await event in stream { + events.append(event) } + return events + } - /// The concrete session injected by `createForTesting`, for test-only hooks - /// (`dispatchForTesting`, `subscriberCountForTest`) the protocol does not carry. - var sessionForTest: MeshCoreSession { - guard let concrete = session as? MeshCoreSession else { - fatalError("MessageService under test must be built with a concrete MeshCoreSession") - } - return concrete + /// The concrete session injected by `createForTesting`, for test-only hooks + /// (`dispatchForTesting`, `subscriberCountForTest`) the protocol does not carry. + var sessionForTest: MeshCoreSession { + guard let concrete = session as? MeshCoreSession else { + fatalError("MessageService under test must be built with a concrete MeshCoreSession") } + return concrete + } - func installSelfInfoForTest(publicKey: Data = Data(repeating: 0xAB, count: 32)) async { - await sessionForTest.installSelfInfoForTest(.testSelfInfo(publicKey: publicKey)) - } + func installSelfInfoForTest(publicKey: Data = Data(repeating: 0xAB, count: 32)) async { + await sessionForTest.installSelfInfoForTest(.testSelfInfo(publicKey: publicKey)) + } - /// Waits until the session's dispatcher holds exactly `expectedCount` - /// subscriptions, polling `subscriberCountForTest` without sleeping in - /// the test itself. - /// - /// Required because `startEventMonitoring()` spawns a `Task` whose subscribe - /// call races any subsequent `dispatchForTesting`. A one-hop `await` into the - /// session actor is not sufficient: the listener Task must traverse - /// session → dispatcher before the dispatch arrives. - /// - /// On restart, callers should first wait for `expectedCount: 0` to confirm - /// the previous subscription has torn down (its `onTermination` cleanup - /// runs on its own Task) before waiting for the new subscription. - func waitForSubscriberCount( - _ expectedCount: Int, - timeout: Duration = .milliseconds(500) - ) async { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if await sessionForTest.subscriberCountForTest == expectedCount { - return - } - await Task.yield() - } - Issue.record("subscriber count did not reach \(expectedCount) within \(timeout)") + /// Waits until the session's dispatcher holds exactly `expectedCount` + /// subscriptions, polling `subscriberCountForTest` without sleeping in + /// the test itself. + /// + /// Required because `startEventMonitoring()` spawns a `Task` whose subscribe + /// call races any subsequent `dispatchForTesting`. A one-hop `await` into the + /// session actor is not sufficient: the listener Task must traverse + /// session → dispatcher before the dispatch arrives. + /// + /// On restart, callers should first wait for `expectedCount: 0` to confirm + /// the previous subscription has torn down (its `onTermination` cleanup + /// runs on its own Task) before waiting for the new subscription. + func waitForSubscriberCount( + _ expectedCount: Int, + timeout: Duration = .milliseconds(500) + ) async { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if await sessionForTest.subscriberCountForTest == expectedCount { + return + } + await Task.yield() } + Issue.record("subscriber count did not reach \(expectedCount) within \(timeout)") + } } extension SelfInfo { - static func testSelfInfo(publicKey: Data = Data(repeating: 0xAB, count: 32)) -> SelfInfo { - SelfInfo( - advertisementType: 0, - txPower: 20, - maxTxPower: 20, - publicKey: publicKey, - latitude: 0, - longitude: 0, - multiAcks: 2, - advertisementLocationPolicy: 0, - telemetryModeEnvironment: 0, - telemetryModeLocation: 0, - telemetryModeBase: 2, - manualAddContacts: false, - radioFrequency: 915.0, - radioBandwidth: 250.0, - radioSpreadingFactor: 10, - radioCodingRate: 5, - name: "TestNode" - ) - } + static func testSelfInfo(publicKey: Data = Data(repeating: 0xAB, count: 32)) -> SelfInfo { + SelfInfo( + advertisementType: 0, + txPower: 20, + maxTxPower: 20, + publicKey: publicKey, + latitude: 0, + longitude: 0, + multiAcks: 2, + advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, + telemetryModeLocation: 0, + telemetryModeBase: 2, + manualAddContacts: false, + radioFrequency: 915.0, + radioBandwidth: 250.0, + radioSpreadingFactor: 10, + radioCodingRate: 5, + name: "TestNode" + ) + } } -extension Array where Element == MessageStatusEvent { - /// Message IDs carried by `.failed` events, in yield order. - var failedIDs: [UUID] { - compactMap { - if case .failed(let id) = $0 { return id } - return nil - } +extension [MessageStatusEvent] { + /// Message IDs carried by `.failed` events, in yield order. + var failedIDs: [UUID] { + compactMap { + if case let .failed(id) = $0 { return id } + return nil } + } - /// Message IDs carried by `.resent` events, in yield order. - var resentIDs: [UUID] { - compactMap { - if case .resent(let id) = $0 { return id } - return nil - } + /// Message IDs carried by `.resent` events, in yield order. + var resentIDs: [UUID] { + compactMap { + if case let .resent(id) = $0 { return id } + return nil } + } - /// Message IDs carried by `.statusResolved` events, in yield order. - var resolvedIDs: [UUID] { - compactMap { - if case .statusResolved(let id, _, _) = $0 { return id } - return nil - } + /// Message IDs carried by `.statusResolved` events, in yield order. + var resolvedIDs: [UUID] { + compactMap { + if case let .statusResolved(id, _, _) = $0 { return id } + return nil } + } - /// Payloads carried by `.retrying` events, in yield order. - var retryUpdates: [(messageID: UUID, attempt: Int, maxAttempts: Int)] { - compactMap { - if case .retrying(let id, let attempt, let maxAttempts) = $0 { - return (id, attempt, maxAttempts) - } - return nil - } + /// Payloads carried by `.retrying` events, in yield order. + var retryUpdates: [(messageID: UUID, attempt: Int, maxAttempts: Int)] { + compactMap { + if case let .retrying(id, attempt, maxAttempts) = $0 { + return (id, attempt, maxAttempts) + } + return nil } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/NodeStatusSnapshotDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/NodeStatusSnapshotDTO+Testing.swift index 116e9941..e5336c18 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/NodeStatusSnapshotDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/NodeStatusSnapshotDTO+Testing.swift @@ -2,36 +2,35 @@ import Foundation @testable import MC1Services extension NodeStatusSnapshotDTO { - - /// Creates a NodeStatusSnapshotDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let snapshot = NodeStatusSnapshotDTO.testSnapshot(nodePublicKey: key) - /// ``` - static func testSnapshot( - id: UUID = UUID(), - timestamp: Date = Date(), - nodePublicKey: Data = Data(repeating: 0xDD, count: 32), - batteryMillivolts: UInt16? = 3800, - lastSNR: Double? = 9.0, - lastRSSI: Int16? = -85, - noiseFloor: Int16? = -110, - uptimeSeconds: UInt32? = 3600, - neighborSnapshots: [NeighborSnapshotEntry]? = nil, - telemetryEntries: [TelemetrySnapshotEntry]? = nil - ) -> NodeStatusSnapshotDTO { - NodeStatusSnapshotDTO( - id: id, - timestamp: timestamp, - nodePublicKey: nodePublicKey, - batteryMillivolts: batteryMillivolts, - lastSNR: lastSNR, - lastRSSI: lastRSSI, - noiseFloor: noiseFloor, - uptimeSeconds: uptimeSeconds, - neighborSnapshots: neighborSnapshots, - telemetryEntries: telemetryEntries - ) - } + /// Creates a NodeStatusSnapshotDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let snapshot = NodeStatusSnapshotDTO.testSnapshot(nodePublicKey: key) + /// ``` + static func testSnapshot( + id: UUID = UUID(), + timestamp: Date = Date(), + nodePublicKey: Data = Data(repeating: 0xDD, count: 32), + batteryMillivolts: UInt16? = 3800, + lastSNR: Double? = 9.0, + lastRSSI: Int16? = -85, + noiseFloor: Int16? = -110, + uptimeSeconds: UInt32? = 3600, + neighborSnapshots: [NeighborSnapshotEntry]? = nil, + telemetryEntries: [TelemetrySnapshotEntry]? = nil + ) -> NodeStatusSnapshotDTO { + NodeStatusSnapshotDTO( + id: id, + timestamp: timestamp, + nodePublicKey: nodePublicKey, + batteryMillivolts: batteryMillivolts, + lastSNR: lastSNR, + lastRSSI: lastRSSI, + noiseFloor: noiseFloor, + uptimeSeconds: uptimeSeconds, + neighborSnapshots: neighborSnapshots, + telemetryEntries: telemetryEntries + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+TestFetch.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+TestFetch.swift index 58914b29..a857cf66 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+TestFetch.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+TestFetch.swift @@ -1,74 +1,73 @@ import Foundation -import SwiftData @testable import MC1Services +import SwiftData /// Test-only radio-scoped fetchAll helpers. These exist solely so the backup /// integration tests can assert on what a specific radio imported, without /// widening the production `PersistenceStore` API. -extension PersistenceStore { - - public func fetchAllContacts(radioID: UUID) throws -> [ContactDTO] { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - .map { ContactDTO(from: $0) } - } +public extension PersistenceStore { + func fetchAllContacts(radioID: UUID) throws -> [ContactDTO] { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + .map { ContactDTO(from: $0) } + } - public func fetchAllChannels(radioID: UUID) throws -> [ChannelDTO] { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - .map { ChannelDTO(from: $0) } - } + func fetchAllChannels(radioID: UUID) throws -> [ChannelDTO] { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + .map { ChannelDTO(from: $0) } + } - public func fetchAllMessages() throws -> [MessageDTO] { - try modelContext.fetch(FetchDescriptor()).map { MessageDTO(from: $0) } - } + func fetchAllMessages() throws -> [MessageDTO] { + try modelContext.fetch(FetchDescriptor()).map { MessageDTO(from: $0) } + } - public func fetchAllMessages(radioID: UUID) throws -> [MessageDTO] { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - .map { MessageDTO(from: $0) } - } + func fetchAllMessages(radioID: UUID) throws -> [MessageDTO] { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + .map { MessageDTO(from: $0) } + } - public func fetchAllReactions(radioID: UUID) throws -> [ReactionDTO] { - let targetRadioID = radioID - let predicate = #Predicate { $0.radioID == targetRadioID } - return try modelContext.fetch(FetchDescriptor(predicate: predicate)) - .map { ReactionDTO(from: $0) } - } + func fetchAllReactions(radioID: UUID) throws -> [ReactionDTO] { + let targetRadioID = radioID + let predicate = #Predicate { $0.radioID == targetRadioID } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + .map { ReactionDTO(from: $0) } + } - /// Inserts a raw Message with an explicit `sortDate` distinct from `createdAt`, - /// simulating a row persisted before the `sortDate` column existed (which comes - /// up with the `Date.distantPast` schema default). The production save path - /// always pins `sortDate` to `createdAt`, so this is test-only. - public func insertMessageWithSortDate( - id: UUID, - radioID: UUID, - text: String, - createdAt: Date, - sortDate: Date - ) throws { - let message = Message( - id: id, - radioID: radioID, - text: text, - createdAt: createdAt, - sortDate: sortDate - ) - modelContext.insert(message) - try modelContext.save() - } + /// Inserts a raw Message with an explicit `sortDate` distinct from `createdAt`, + /// simulating a row persisted before the `sortDate` column existed (which comes + /// up with the `Date.distantPast` schema default). The production save path + /// always pins `sortDate` to `createdAt`, so this is test-only. + func insertMessageWithSortDate( + id: UUID, + radioID: UUID, + text: String, + createdAt: Date, + sortDate: Date + ) throws { + let message = Message( + id: id, + radioID: radioID, + text: text, + createdAt: createdAt, + sortDate: sortDate + ) + modelContext.insert(message) + try modelContext.save() + } - /// Overwrites the `sortDate` on an existing Message row (test-only). - public func setMessageSortDate(id: UUID, sortDate: Date) throws { - let targetID = id - let predicate = #Predicate { $0.id == targetID } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - guard let message = try modelContext.fetch(descriptor).first else { return } - message.sortDate = sortDate - try modelContext.save() - } + /// Overwrites the `sortDate` on an existing Message row (test-only). + func setMessageSortDate(id: UUID, sortDate: Date) throws { + let targetID = id + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + guard let message = try modelContext.fetch(descriptor).first else { return } + message.sortDate = sortDate + try modelContext.save() + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift index 7eb60b92..9d807a64 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift @@ -2,25 +2,24 @@ import Foundation @testable import MC1Services extension PersistenceStore { - - /// Creates an in-memory persistence store pre-populated with a test device. - static func createTestDataStore( - radioID: UUID, - maxChannels: UInt8 = 8, - lastContactSync: UInt32 = 0 - ) async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let device = DeviceDTO.testDevice( - id: radioID, - radioID: radioID, - firmwareVersion: 8, - firmwareVersionString: "v1.0.0", - maxChannels: maxChannels, - multiAcks: 0, - lastContactSync: lastContactSync - ) - try await store.saveDevice(device) - return store - } + /// Creates an in-memory persistence store pre-populated with a test device. + static func createTestDataStore( + radioID: UUID, + maxChannels: UInt8 = 8, + lastContactSync: UInt32 = 0 + ) async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let device = DeviceDTO.testDevice( + id: radioID, + radioID: radioID, + firmwareVersion: 8, + firmwareVersionString: "v1.0.0", + maxChannels: maxChannels, + multiAcks: 0, + lastContactSync: lastContactSync + ) + try await store.saveDevice(device) + return store + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ReactionDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ReactionDTO+Testing.swift index a2412362..71ec1672 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ReactionDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ReactionDTO+Testing.swift @@ -2,36 +2,35 @@ import Foundation @testable import MC1Services extension ReactionDTO { - - /// Creates a ReactionDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let reaction = ReactionDTO.testReaction(messageID: myMessageID, radioID: myRadioID) - /// ``` - static func testReaction( - id: UUID = UUID(), - messageID: UUID, - radioID: UUID, - emoji: String = "👍", - senderName: String = "TestSender", - messageHash: String = "a1b2c3d4", - rawText: String = "+m:a1b2c3d4:👍", - receivedAt: Date = Date(), - channelIndex: UInt8? = 0, - contactID: UUID? = nil - ) -> ReactionDTO { - ReactionDTO( - id: id, - messageID: messageID, - emoji: emoji, - senderName: senderName, - messageHash: messageHash, - rawText: rawText, - receivedAt: receivedAt, - channelIndex: channelIndex, - contactID: contactID, - radioID: radioID - ) - } + /// Creates a ReactionDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let reaction = ReactionDTO.testReaction(messageID: myMessageID, radioID: myRadioID) + /// ``` + static func testReaction( + id: UUID = UUID(), + messageID: UUID, + radioID: UUID, + emoji: String = "👍", + senderName: String = "TestSender", + messageHash: String = "a1b2c3d4", + rawText: String = "+m:a1b2c3d4:👍", + receivedAt: Date = Date(), + channelIndex: UInt8? = 0, + contactID: UUID? = nil + ) -> ReactionDTO { + ReactionDTO( + id: id, + messageID: messageID, + emoji: emoji, + senderName: senderName, + messageHash: messageHash, + rawText: rawText, + receivedAt: receivedAt, + channelIndex: channelIndex, + contactID: contactID, + radioID: radioID + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/RemoteNodeSessionDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/RemoteNodeSessionDTO+Testing.swift index 58941e41..4e70a958 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/RemoteNodeSessionDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/RemoteNodeSessionDTO+Testing.swift @@ -2,49 +2,48 @@ import Foundation @testable import MC1Services extension RemoteNodeSessionDTO { - - /// Creates a RemoteNodeSessionDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let session = RemoteNodeSessionDTO.testSession(radioID: myRadioID) - /// let room = RemoteNodeSessionDTO.testSession(radioID: myRadioID, role: .roomServer) - /// ``` - static func testSession( - id: UUID = UUID(), - radioID: UUID, - publicKey: Data = Data(repeating: 0xCC, count: 32), - name: String = "TestNode", - role: RemoteNodeRole = .roomServer, - latitude: Double = 0, - longitude: Double = 0, - isConnected: Bool = false, - permissionLevel: RoomPermissionLevel = .guest, - lastConnectedDate: Date? = nil, - unreadCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false, - neighborCount: Int = 0, - lastSyncTimestamp: UInt32 = 0, - lastMessageDate: Date? = nil - ) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: id, - radioID: radioID, - publicKey: publicKey, - name: name, - role: role, - latitude: latitude, - longitude: longitude, - isConnected: isConnected, - permissionLevel: permissionLevel, - lastConnectedDate: lastConnectedDate, - unreadCount: unreadCount, - notificationLevel: notificationLevel, - isFavorite: isFavorite, - neighborCount: neighborCount, - lastSyncTimestamp: lastSyncTimestamp, - lastMessageDate: lastMessageDate - ) - } + /// Creates a RemoteNodeSessionDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let session = RemoteNodeSessionDTO.testSession(radioID: myRadioID) + /// let room = RemoteNodeSessionDTO.testSession(radioID: myRadioID, role: .roomServer) + /// ``` + static func testSession( + id: UUID = UUID(), + radioID: UUID, + publicKey: Data = Data(repeating: 0xCC, count: 32), + name: String = "TestNode", + role: RemoteNodeRole = .roomServer, + latitude: Double = 0, + longitude: Double = 0, + isConnected: Bool = false, + permissionLevel: RoomPermissionLevel = .guest, + lastConnectedDate: Date? = nil, + unreadCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false, + neighborCount: Int = 0, + lastSyncTimestamp: UInt32 = 0, + lastMessageDate: Date? = nil + ) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: id, + radioID: radioID, + publicKey: publicKey, + name: name, + role: role, + latitude: latitude, + longitude: longitude, + isConnected: isConnected, + permissionLevel: permissionLevel, + lastConnectedDate: lastConnectedDate, + unreadCount: unreadCount, + notificationLevel: notificationLevel, + isFavorite: isFavorite, + neighborCount: neighborCount, + lastSyncTimestamp: lastSyncTimestamp, + lastMessageDate: lastMessageDate + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift index f7d52904..23cdf18e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift @@ -2,34 +2,33 @@ import Foundation @testable import MC1Services extension RoomMessageDTO { - - /// Creates a RoomMessageDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let message = RoomMessageDTO.testRoomMessage(sessionID: mySessionID) - /// ``` - static func testRoomMessage( - id: UUID = UUID(), - sessionID: UUID, - authorKeyPrefix: Data = Data([0xAB, 0xCD, 0xEF, 0x01]), - authorName: String? = "TestAuthor", - text: String = "Hello from the room", - timestamp: UInt32 = 1_700_000_000, - createdAt: Date = Date(), - isFromSelf: Bool = false, - status: MessageStatus = .delivered - ) -> RoomMessageDTO { - RoomMessageDTO( - id: id, - sessionID: sessionID, - authorKeyPrefix: authorKeyPrefix, - authorName: authorName, - text: text, - timestamp: timestamp, - createdAt: createdAt, - isFromSelf: isFromSelf, - status: status - ) - } + /// Creates a RoomMessageDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let message = RoomMessageDTO.testRoomMessage(sessionID: mySessionID) + /// ``` + static func testRoomMessage( + id: UUID = UUID(), + sessionID: UUID, + authorKeyPrefix: Data = Data([0xAB, 0xCD, 0xEF, 0x01]), + authorName: String? = "TestAuthor", + text: String = "Hello from the room", + timestamp: UInt32 = 1_700_000_000, + createdAt: Date = Date(), + isFromSelf: Bool = false, + status: MessageStatus = .delivered + ) -> RoomMessageDTO { + RoomMessageDTO( + id: id, + sessionID: sessionID, + authorKeyPrefix: authorKeyPrefix, + authorName: authorName, + text: text, + timestamp: timestamp, + createdAt: createdAt, + isFromSelf: isFromSelf, + status: status + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/SavedTracePathDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/SavedTracePathDTO+Testing.swift index 0bc185de..4a272074 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/SavedTracePathDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/SavedTracePathDTO+Testing.swift @@ -2,55 +2,53 @@ import Foundation @testable import MC1Services extension SavedTracePathDTO { - - /// Creates a SavedTracePathDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let path = SavedTracePathDTO.testPath(radioID: myRadioID) - /// ``` - static func testPath( - id: UUID = UUID(), - radioID: UUID, - name: String = "Test Path", - pathBytes: Data = Data([0x31, 0xA7]), - hashSize: Int = 1, - createdDate: Date = Date(), - runs: [TracePathRunDTO] = [] - ) -> SavedTracePathDTO { - SavedTracePathDTO( - id: id, - radioID: radioID, - name: name, - pathBytes: pathBytes, - hashSize: hashSize, - createdDate: createdDate, - runs: runs - ) - } + /// Creates a SavedTracePathDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let path = SavedTracePathDTO.testPath(radioID: myRadioID) + /// ``` + static func testPath( + id: UUID = UUID(), + radioID: UUID, + name: String = "Test Path", + pathBytes: Data = Data([0x31, 0xA7]), + hashSize: Int = 1, + createdDate: Date = Date(), + runs: [TracePathRunDTO] = [] + ) -> SavedTracePathDTO { + SavedTracePathDTO( + id: id, + radioID: radioID, + name: name, + pathBytes: pathBytes, + hashSize: hashSize, + createdDate: createdDate, + runs: runs + ) + } } extension TracePathRunDTO { - - /// Creates a TracePathRunDTO with sensible test defaults. - /// - /// Usage: - /// ``` - /// let run = TracePathRunDTO.testRun() - /// ``` - static func testRun( - id: UUID = UUID(), - date: Date = Date(), - success: Bool = true, - roundTripMs: Int = 250, - hopsSNR: [Double] = [8.5, 7.0] - ) -> TracePathRunDTO { - TracePathRunDTO( - id: id, - date: date, - success: success, - roundTripMs: roundTripMs, - hopsSNR: hopsSNR - ) - } + /// Creates a TracePathRunDTO with sensible test defaults. + /// + /// Usage: + /// ``` + /// let run = TracePathRunDTO.testRun() + /// ``` + static func testRun( + id: UUID = UUID(), + date: Date = Date(), + success: Bool = true, + roundTripMs: Int = 250, + hopsSNR: [Double] = [8.5, 7.0] + ) -> TracePathRunDTO { + TracePathRunDTO( + id: id, + date: date, + success: success, + roundTripMs: roundTripMs, + hopsSNR: hopsSNR + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/TestPolling.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/TestPolling.swift index 2447a8da..1ddafc60 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/TestPolling.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/TestPolling.swift @@ -9,19 +9,19 @@ import Foundation /// - message: Failure message if the timeout expires. /// - condition: An async closure returning `true` when the expected state is reached. func waitUntil( - timeout: Duration = .seconds(2), - pollingInterval: Duration = .milliseconds(10), - _ message: String = "waitUntil timed out", - condition: @escaping @MainActor () async -> Bool + timeout: Duration = .seconds(2), + pollingInterval: Duration = .milliseconds(10), + _ message: String = "waitUntil timed out", + condition: @escaping @MainActor () async -> Bool ) async throws { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - if await condition() { return } - try await Task.sleep(for: pollingInterval) - } + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { if await condition() { return } - struct WaitTimeoutError: Error, CustomStringConvertible { - let description: String - } - throw WaitTimeoutError(description: message) + try await Task.sleep(for: pollingInterval) + } + if await condition() { return } + struct WaitTimeoutError: Error, CustomStringConvertible { + let description: String + } + throw WaitTimeoutError(description: message) } diff --git a/MC1Services/Tests/MC1ServicesTests/IdentityReconciliationIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/IdentityReconciliationIntegrationTests.swift index b6874edd..a3974c3a 100644 --- a/MC1Services/Tests/MC1ServicesTests/IdentityReconciliationIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/IdentityReconciliationIntegrationTests.swift @@ -1,194 +1,193 @@ import Foundation +@testable import MC1Services +import MeshCore import SwiftData import Testing -import MeshCore -@testable import MC1Services @Suite("Identity reconciliation after config import") struct IdentityReconciliationIntegrationTests { - - private func createStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private func randomKey() -> Data { - Data((0.. PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func randomKey() -> Data { + Data((0.. URL { - let dir = FileManager.default.temporaryDirectory - .appending(path: "InlineImageDimensionsStoreTests-\(UUID().uuidString)", directoryHint: .isDirectory) - return dir.appending(path: "InlineImageDimensions.json") - } - - @Test("save then aspect(for:) returns the expected ratio") - func saveThenAspectReturnsRatio() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = InlineImageDimensionsStore(fileURL: fileURL) - let url = URL(string: "https://example.com/a.png")! - await store.save(url: url, size: CGSize(width: 200, height: 100)) - - #expect(store.aspect(for: url) == 2.0) + private static let streamWaitNanoseconds: UInt64 = 1_000_000_000 + + private static func makeTempFileURL() -> URL { + let dir = FileManager.default.temporaryDirectory + .appending(path: "InlineImageDimensionsStoreTests-\(UUID().uuidString)", directoryHint: .isDirectory) + return dir.appending(path: "InlineImageDimensions.json") + } + + @Test + func `save then aspect(for:) returns the expected ratio`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let url = try #require(URL(string: "https://example.com/a.png")) + await store.save(url: url, size: CGSize(width: 200, height: 100)) + + #expect(store.aspect(for: url) == 2.0) + } + + @Test + func `save with zero width is rejected silently`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let url = try #require(URL(string: "https://example.com/zero-width.png")) + await store.save(url: url, size: CGSize(width: 0, height: 100)) + + #expect(store.aspect(for: url) == nil) + } + + @Test + func `save with zero height is rejected silently`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let url = try #require(URL(string: "https://example.com/zero-height.png")) + await store.save(url: url, size: CGSize(width: 100, height: 0)) + + #expect(store.aspect(for: url) == nil) + } + + @Test + func `init recovers from a corrupt file by starting empty`() throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("not json".utf8).write(to: fileURL) + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let sampleURL = try #require(URL(string: "https://example.com/anything.png")) + + #expect(store.aspect(for: sampleURL) == nil) + } + + @Test + func `init on non-existent file yields empty store`() throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let url = try #require(URL(string: "https://example.com/unknown.png")) + + #expect(store.aspect(for: url) == nil) + } + + @Test + func `two saves are both readable via aspect(for:)`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let urlA = try #require(URL(string: "https://example.com/a.png")) + let urlB = try #require(URL(string: "https://example.com/b.png")) + await store.save(url: urlA, size: CGSize(width: 400, height: 200)) + await store.save(url: urlB, size: CGSize(width: 100, height: 400)) + + #expect(store.aspect(for: urlA) == 2.0) + #expect(store.aspect(for: urlB) == 0.25) + } + + @Test + func `resolutionStream emits the URL on save`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = InlineImageDimensionsStore(fileURL: fileURL) + let url = try #require(URL(string: "https://example.com/stream.png")) + + let receiveTask = Task { + for await emitted in store.resolutionStream { + return emitted + } + return nil } - @Test("save with zero width is rejected silently") - func saveWithZeroWidthIsRejected() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = InlineImageDimensionsStore(fileURL: fileURL) - let url = URL(string: "https://example.com/zero-width.png")! - await store.save(url: url, size: CGSize(width: 0, height: 100)) - - #expect(store.aspect(for: url) == nil) + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: Self.streamWaitNanoseconds) + receiveTask.cancel() + return nil } - @Test("save with zero height is rejected silently") - func saveWithZeroHeightIsRejected() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + await store.save(url: url, size: CGSize(width: 300, height: 150)) - let store = InlineImageDimensionsStore(fileURL: fileURL) - let url = URL(string: "https://example.com/zero-height.png")! - await store.save(url: url, size: CGSize(width: 100, height: 0)) + let received = await receiveTask.value + timeoutTask.cancel() - #expect(store.aspect(for: url) == nil) - } + #expect(received == url) + } - @Test("init recovers from a corrupt file by starting empty") - func initRecoversFromCorruptFile() async throws { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + @Test + func `round-trip: recreated store reads previously persisted aspect`() async throws { + let fileURL = Self.makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try Data("not json".utf8).write(to: fileURL) + let url = try #require(URL(string: "https://example.com/persisted.png")) - let store = InlineImageDimensionsStore(fileURL: fileURL) - let sampleURL = URL(string: "https://example.com/anything.png")! + let writer = InlineImageDimensionsStore(fileURL: fileURL) + await writer.save(url: url, size: CGSize(width: 600, height: 300)) - #expect(store.aspect(for: sampleURL) == nil) - } - - @Test("init on non-existent file yields empty store") - func initOnMissingFileYieldsEmptyStore() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = InlineImageDimensionsStore(fileURL: fileURL) - let url = URL(string: "https://example.com/unknown.png")! - - #expect(store.aspect(for: url) == nil) - } - - @Test("two saves are both readable via aspect(for:)") - func twoSavesAreBothReadable() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = InlineImageDimensionsStore(fileURL: fileURL) - let urlA = URL(string: "https://example.com/a.png")! - let urlB = URL(string: "https://example.com/b.png")! - await store.save(url: urlA, size: CGSize(width: 400, height: 200)) - await store.save(url: urlB, size: CGSize(width: 100, height: 400)) - - #expect(store.aspect(for: urlA) == 2.0) - #expect(store.aspect(for: urlB) == 0.25) - } - - @Test("resolutionStream emits the URL on save") - func resolutionStreamEmitsOnSave() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = InlineImageDimensionsStore(fileURL: fileURL) - let url = URL(string: "https://example.com/stream.png")! - - let receiveTask = Task { - for await emitted in store.resolutionStream { - return emitted - } - return nil - } - - let timeoutTask = Task { - try? await Task.sleep(nanoseconds: Self.streamWaitNanoseconds) - receiveTask.cancel() - return nil - } - - await store.save(url: url, size: CGSize(width: 300, height: 150)) - - let received = await receiveTask.value - timeoutTask.cancel() - - #expect(received == url) - } - - @Test("round-trip: recreated store reads previously persisted aspect") - func roundTripPersistence() async { - let fileURL = Self.makeTempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let url = URL(string: "https://example.com/persisted.png")! - - let writer = InlineImageDimensionsStore(fileURL: fileURL) - await writer.save(url: url, size: CGSize(width: 600, height: 300)) - - let reader = InlineImageDimensionsStore(fileURL: fileURL) - #expect(reader.aspect(for: url) == 2.0) - } + let reader = InlineImageDimensionsStore(fileURL: fileURL) + #expect(reader.aspect(for: url) == 2.0) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/KnownRegionTests.swift b/MC1Services/Tests/MC1ServicesTests/KnownRegionTests.swift index 4f0c7868..77f7afb6 100644 --- a/MC1Services/Tests/MC1ServicesTests/KnownRegionTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/KnownRegionTests.swift @@ -1,165 +1,164 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("Known-region persistence methods") struct KnownRegionTests { + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - @Test("addDeviceKnownRegion finds device by radioID, not by id") - func addRegionByRadioID() async throws { - let store = try await createTestStore() - - let bleUUID = UUID() - let radioID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) - try await store.saveDevice(device) - - try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") - - let fetched = try await store.fetchDevice(id: bleUUID) - #expect(fetched?.knownRegions.contains("US915") == true) - } + @Test + func `addDeviceKnownRegion finds device by radioID, not by id`() async throws { + let store = try await createTestStore() - @Test("addDeviceKnownRegion does not duplicate existing region") - func addRegionSkipsDuplicate() async throws { - let store = try await createTestStore() + let bleUUID = UUID() + let radioID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) + try await store.saveDevice(device) - let bleUUID = UUID() - let radioID = UUID() - var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) - device.knownRegions = ["US915"] - try await store.saveDevice(device) + try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") - try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") + let fetched = try await store.fetchDevice(id: bleUUID) + #expect(fetched?.knownRegions.contains("US915") == true) + } - let fetched = try await store.fetchDevice(id: bleUUID) - #expect(fetched?.knownRegions == ["US915"]) - } + @Test + func `addDeviceKnownRegion does not duplicate existing region`() async throws { + let store = try await createTestStore() - @Test("addDeviceKnownRegion throws when device not found") - func addRegionThrowsForMissingDevice() async throws { - let store = try await createTestStore() + let bleUUID = UUID() + let radioID = UUID() + var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) + device.knownRegions = ["US915"] + try await store.saveDevice(device) - await #expect(throws: PersistenceStoreError.self) { - try await store.addDeviceKnownRegion(radioID: UUID(), region: "EU868") - } - } + try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") - @Test("removeDeviceKnownRegion clears region and channel regionScope") - func removeRegionClearsRegionScope() async throws { - let store = try await createTestStore() - - let bleUUID = UUID() - let radioID = UUID() - var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) - device.knownRegions = ["US915", "EU868"] - try await store.saveDevice(device) - - let channelDTO = ChannelDTO( - id: UUID(), - radioID: radioID, - index: 1, - name: "Test", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - floodScope: .region("US915") - ) - try await store.saveChannel(channelDTO) - - try await store.removeDeviceKnownRegion(radioID: radioID, region: "US915") - - let fetchedDevice = try await store.fetchDevice(id: bleUUID) - #expect(fetchedDevice?.knownRegions.contains("US915") == false) - #expect(fetchedDevice?.knownRegions.contains("EU868") == true) - - let channels = try await store.fetchChannels(radioID: radioID) - let channel = channels.first - #expect(channel?.floodScope == .inherit) - } + let fetched = try await store.fetchDevice(id: bleUUID) + #expect(fetched?.knownRegions == ["US915"]) + } - @Test("removeDeviceKnownRegion leaves unrelated channel regionScope intact") - func removeRegionLeavesUnrelatedChannels() async throws { - let store = try await createTestStore() - - let bleUUID = UUID() - let radioID = UUID() - var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) - device.knownRegions = ["US915", "EU868"] - try await store.saveDevice(device) - - let channel1 = ChannelDTO( - id: UUID(), - radioID: radioID, - index: 1, - name: "Chan1", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - floodScope: .region("US915") - ) - let channel2 = ChannelDTO( - id: UUID(), - radioID: radioID, - index: 2, - name: "Chan2", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - floodScope: .region("EU868") - ) - try await store.saveChannel(channel1) - try await store.saveChannel(channel2) - - try await store.removeDeviceKnownRegion(radioID: radioID, region: "US915") - - let channels = try await store.fetchChannels(radioID: radioID) - let scopes = channels.sorted(by: { $0.index < $1.index }).map(\.floodScope) - #expect(scopes == [.inherit, .region("EU868")]) - } - - @Test("removeDeviceKnownRegion throws when device not found") - func removeRegionThrowsForMissingDevice() async throws { - let store = try await createTestStore() + @Test + func `addDeviceKnownRegion throws when device not found`() async throws { + let store = try await createTestStore() - await #expect(throws: PersistenceStoreError.self) { - try await store.removeDeviceKnownRegion(radioID: UUID(), region: "US915") - } + await #expect(throws: PersistenceStoreError.self) { + try await store.addDeviceKnownRegion(radioID: UUID(), region: "EU868") } - - /// knownRegions is app-only state the radio never reports, owned solely by - /// the targeted add/remove methods. A full saveDevice carrying a stale or - /// empty list (an in-flight updateDevice* Task, or a connect-time rebuild - /// whose snapshot predates the add) must not overwrite it, or the user's - /// discovered regions silently vanish. - @Test("Full saveDevice does not clobber regions added via the targeted path") - func staleSaveDeviceDoesNotClobberKnownRegions() async throws { - let store = try await createTestStore() - - let bleUUID = UUID() - let radioID = UUID() - // The snapshot a long-lived caller holds, captured before any region - // was added; its knownRegions is still empty. - let staleSnapshot = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) - try await store.saveDevice(staleSnapshot) - - // Discovery adds regions through the targeted, list-owning path. - try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") - try await store.addDeviceKnownRegion(radioID: radioID, region: "EU868") - - // The stale snapshot is written back as a full overwrite. The regions - // added above must survive. - try await store.saveDevice(staleSnapshot) - - let fetched = try await store.fetchDevice(id: bleUUID) - #expect(fetched?.knownRegions == ["US915", "EU868"]) + } + + @Test + func `removeDeviceKnownRegion clears region and channel regionScope`() async throws { + let store = try await createTestStore() + + let bleUUID = UUID() + let radioID = UUID() + var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) + device.knownRegions = ["US915", "EU868"] + try await store.saveDevice(device) + + let channelDTO = ChannelDTO( + id: UUID(), + radioID: radioID, + index: 1, + name: "Test", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + floodScope: .region("US915") + ) + try await store.saveChannel(channelDTO) + + try await store.removeDeviceKnownRegion(radioID: radioID, region: "US915") + + let fetchedDevice = try await store.fetchDevice(id: bleUUID) + #expect(fetchedDevice?.knownRegions.contains("US915") == false) + #expect(fetchedDevice?.knownRegions.contains("EU868") == true) + + let channels = try await store.fetchChannels(radioID: radioID) + let channel = channels.first + #expect(channel?.floodScope == .inherit) + } + + @Test + func `removeDeviceKnownRegion leaves unrelated channel regionScope intact`() async throws { + let store = try await createTestStore() + + let bleUUID = UUID() + let radioID = UUID() + var device = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) + device.knownRegions = ["US915", "EU868"] + try await store.saveDevice(device) + + let channel1 = ChannelDTO( + id: UUID(), + radioID: radioID, + index: 1, + name: "Chan1", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + floodScope: .region("US915") + ) + let channel2 = ChannelDTO( + id: UUID(), + radioID: radioID, + index: 2, + name: "Chan2", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + floodScope: .region("EU868") + ) + try await store.saveChannel(channel1) + try await store.saveChannel(channel2) + + try await store.removeDeviceKnownRegion(radioID: radioID, region: "US915") + + let channels = try await store.fetchChannels(radioID: radioID) + let scopes = channels.sorted(by: { $0.index < $1.index }).map(\.floodScope) + #expect(scopes == [.inherit, .region("EU868")]) + } + + @Test + func `removeDeviceKnownRegion throws when device not found`() async throws { + let store = try await createTestStore() + + await #expect(throws: PersistenceStoreError.self) { + try await store.removeDeviceKnownRegion(radioID: UUID(), region: "US915") } + } + + /// knownRegions is app-only state the radio never reports, owned solely by + /// the targeted add/remove methods. A full saveDevice carrying a stale or + /// empty list (an in-flight updateDevice* Task, or a connect-time rebuild + /// whose snapshot predates the add) must not overwrite it, or the user's + /// discovered regions silently vanish. + @Test + func `Full saveDevice does not clobber regions added via the targeted path`() async throws { + let store = try await createTestStore() + + let bleUUID = UUID() + let radioID = UUID() + // The snapshot a long-lived caller holds, captured before any region + // was added; its knownRegions is still empty. + let staleSnapshot = DeviceDTO.testDevice(id: bleUUID, radioID: radioID) + try await store.saveDevice(staleSnapshot) + + // Discovery adds regions through the targeted, list-owning path. + try await store.addDeviceKnownRegion(radioID: radioID, region: "US915") + try await store.addDeviceKnownRegion(radioID: radioID, region: "EU868") + + // The stale snapshot is written back as a full overwrite. The regions + // added above must survive. + try await store.saveDevice(staleSnapshot) + + let fetched = try await store.fetchDevice(id: bleUUID) + #expect(fetched?.knownRegions == ["US915", "EU868"]) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/MC1ServicesTests.swift b/MC1Services/Tests/MC1ServicesTests/MC1ServicesTests.swift index 8c0f78c6..0370e093 100644 --- a/MC1Services/Tests/MC1ServicesTests/MC1ServicesTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/MC1ServicesTests.swift @@ -1,19 +1,18 @@ -import Testing @testable import MC1Services +import Testing @Suite("MC1Services Basic Tests") struct MC1ServicesTests { + @Test + func `Version is accessible`() { + #expect(MC1ServicesVersion.version == "0.1.0") + } - @Test("Version is accessible") - func versionAccessible() { - #expect(MC1ServicesVersion.version == "0.1.0") - } - - @Test("MeshCore types are re-exported") - func meshCoreReExported() { - // Verify MeshCore types are accessible without explicit import - let _: MeshEvent.Type = MeshEvent.self - let _: PacketBuilder.Type = PacketBuilder.self - let _: PacketParser.Type = PacketParser.self - } + @Test + func `MeshCore types are re-exported`() { + // Verify MeshCore types are accessible without explicit import + let _: MeshEvent.Type = MeshEvent.self + let _: PacketBuilder.Type = PacketBuilder.self + let _: PacketParser.Type = PacketParser.self + } } diff --git a/MC1Services/Tests/MC1ServicesTests/MessageDTOPathTests.swift b/MC1Services/Tests/MC1ServicesTests/MessageDTOPathTests.swift index 8b95607b..8618dddf 100644 --- a/MC1Services/Tests/MC1ServicesTests/MessageDTOPathTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/MessageDTOPathTests.swift @@ -1,54 +1,53 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("MessageDTO Path Hops") struct MessageDTOPathTests { - - @Test("Single-byte hops chunk into one (data, hex) pair each") - func singleByteHops() { - let message = MessageDTO.testDirectMessage( - pathLength: encodePathLen(hashSize: 1, hopCount: 3), - pathNodes: Data([0x1A, 0x2B, 0x3C]) - ) - - let hops = message.pathHops - #expect(hops.map(\.data) == [Data([0x1A]), Data([0x2B]), Data([0x3C])]) - #expect(hops.map(\.hex) == ["1A", "2B", "3C"]) - #expect(message.pathNodesHex == ["1A", "2B", "3C"]) - } - - @Test("Multi-byte hops chunk by the encoded hash size") - func multiByteHops() { - let message = MessageDTO.testDirectMessage( - pathLength: encodePathLen(hashSize: 2, hopCount: 2), - pathNodes: Data([0x1A, 0x2B, 0x3C, 0x4D]) - ) - - let hops = message.pathHops - #expect(hops.map(\.data) == [Data([0x1A, 0x2B]), Data([0x3C, 0x4D])]) - #expect(hops.map(\.hex) == ["1A2B", "3C4D"]) - } - - @Test("A trailing partial hop is kept as a short final chunk") - func trailingPartialHop() { - let message = MessageDTO.testDirectMessage( - pathLength: encodePathLen(hashSize: 2, hopCount: 1), - pathNodes: Data([0x1A, 0x2B, 0x3C]) - ) - - #expect(message.pathHops.map(\.hex) == ["1A2B", "3C"]) - } - - @Test("A message with no path nodes has no hops") - func noPathNodesHasNoHops() { - let message = MessageDTO.testDirectMessage( - pathLength: encodePathLen(hashSize: 1, hopCount: 0), - pathNodes: nil - ) - - #expect(message.pathHops.isEmpty) - #expect(message.pathNodesHex.isEmpty) - } + @Test + func `Single-byte hops chunk into one (data, hex) pair each`() { + let message = MessageDTO.testDirectMessage( + pathLength: encodePathLen(hashSize: 1, hopCount: 3), + pathNodes: Data([0x1A, 0x2B, 0x3C]) + ) + + let hops = message.pathHops + #expect(hops.map(\.data) == [Data([0x1A]), Data([0x2B]), Data([0x3C])]) + #expect(hops.map(\.hex) == ["1A", "2B", "3C"]) + #expect(message.pathNodesHex == ["1A", "2B", "3C"]) + } + + @Test + func `Multi-byte hops chunk by the encoded hash size`() { + let message = MessageDTO.testDirectMessage( + pathLength: encodePathLen(hashSize: 2, hopCount: 2), + pathNodes: Data([0x1A, 0x2B, 0x3C, 0x4D]) + ) + + let hops = message.pathHops + #expect(hops.map(\.data) == [Data([0x1A, 0x2B]), Data([0x3C, 0x4D])]) + #expect(hops.map(\.hex) == ["1A2B", "3C4D"]) + } + + @Test + func `A trailing partial hop is kept as a short final chunk`() { + let message = MessageDTO.testDirectMessage( + pathLength: encodePathLen(hashSize: 2, hopCount: 1), + pathNodes: Data([0x1A, 0x2B, 0x3C]) + ) + + #expect(message.pathHops.map(\.hex) == ["1A2B", "3C"]) + } + + @Test + func `A message with no path nodes has no hops`() { + let message = MessageDTO.testDirectMessage( + pathLength: encodePathLen(hashSize: 1, hopCount: 0), + pathNodes: nil + ) + + #expect(message.pathHops.isEmpty) + #expect(message.pathNodesHex.isEmpty) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockAccessorySetupKitService.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockAccessorySetupKitService.swift index 8ef2cd79..6facf466 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockAccessorySetupKitService.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockAccessorySetupKitService.swift @@ -1,5 +1,5 @@ #if canImport(UIKit) -import AccessorySetupKit + import AccessorySetupKit #endif import Foundation @testable import MC1Services @@ -9,70 +9,72 @@ import Foundation /// letting tests pin pairing in specific suspension points. @MainActor public final class MockAccessorySetupKitService: AccessorySetupKitServicing { - public var pairedAccessories: [ASAccessory] = [] - public var isSessionActive: Bool = true - public weak var delegate: AccessorySetupKitServiceDelegate? + public var pairedAccessories: [ASAccessory] = [] + public var isSessionActive: Bool = true + public weak var delegate: AccessorySetupKitServiceDelegate? - public private(set) var removeAccessoryCallCount = 0 - public private(set) var lastRemovedDeviceID: UUID? - public private(set) var renameAccessoryCallCount = 0 - public private(set) var activateSessionCallCount = 0 - public private(set) var invalidateSessionCallCount = 0 + public private(set) var removeAccessoryCallCount = 0 + public private(set) var lastRemovedDeviceID: UUID? + public private(set) var renameAccessoryCallCount = 0 + public private(set) var activateSessionCallCount = 0 + public private(set) var invalidateSessionCallCount = 0 - /// Result for the next `showPicker()` call. Tests configure this before invoking - /// pairing flows. - public var pickerResult: Result = .failure(AccessorySetupKitError.sessionNotActive) + /// Result for the next `showPicker()` call. Tests configure this before invoking + /// pairing flows. + public var pickerResult: Result = .failure(AccessorySetupKitError.sessionNotActive) - /// Optional gate: when non-nil, `showPicker` awaits this stream's first element - /// before returning `pickerResult`. Used to pin the awaiting Task in suspension. - public var pickerGate: AsyncStream? + /// Optional gate: when non-nil, `showPicker` awaits this stream's first element + /// before returning `pickerResult`. Used to pin the awaiting Task in suspension. + public var pickerGate: AsyncStream? - /// Optional signal: when non-nil, `showPicker` yields to this continuation as - /// soon as it enters, before awaiting `pickerGate`. Tests await this signal to - /// know deterministically that the pair task has reached the picker await. - public var pickerEnteredSignal: AsyncStream.Continuation? + /// Optional signal: when non-nil, `showPicker` yields to this continuation as + /// soon as it enters, before awaiting `pickerGate`. Tests await this signal to + /// know deterministically that the pair task has reached the picker await. + public var pickerEnteredSignal: AsyncStream.Continuation? - public init() {} + public init() {} - public func setPickerResult(_ result: Result) { - self.pickerResult = result - } + public func setPickerResult(_ result: Result) { + pickerResult = result + } - public func setPairedAccessories(_ accessories: [ASAccessory]) { - self.pairedAccessories = accessories - } + public func setPairedAccessories(_ accessories: [ASAccessory]) { + pairedAccessories = accessories + } - public func activateSession() async throws { - activateSessionCallCount += 1 - } + public func activateSession() async throws { + activateSessionCallCount += 1 + } - public func showPicker() async throws -> UUID { - pickerEnteredSignal?.yield() - if let gate = pickerGate { - for await _ in gate { break } - } - switch pickerResult { - case .success(let id): - return id - case .failure(let error): - throw error - } + public func showPicker() async throws -> UUID { + pickerEnteredSignal?.yield() + if let gate = pickerGate { + for await _ in gate { + break + } } - - public func removeAccessory(_ accessory: ASAccessory) async throws { - removeAccessoryCallCount += 1 - lastRemovedDeviceID = accessory.bluetoothIdentifier + switch pickerResult { + case let .success(id): + return id + case let .failure(error): + throw error } + } - public func renameAccessory(_ accessory: ASAccessory) async throws { - renameAccessoryCallCount += 1 - } + public func removeAccessory(_ accessory: ASAccessory) async throws { + removeAccessoryCallCount += 1 + lastRemovedDeviceID = accessory.bluetoothIdentifier + } - public func accessory(for bluetoothID: UUID) -> ASAccessory? { - pairedAccessories.first { $0.bluetoothIdentifier == bluetoothID } - } + public func renameAccessory(_ accessory: ASAccessory) async throws { + renameAccessoryCallCount += 1 + } - public func invalidateSession() { - invalidateSessionCallCount += 1 - } + public func accessory(for bluetoothID: UUID) -> ASAccessory? { + pairedAccessories.first { $0.bluetoothIdentifier == bluetoothID } + } + + public func invalidateSession() { + invalidateSessionCallCount += 1 + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockAppStateProvider.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockAppStateProvider.swift index 5e70eaf0..47875a4d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockAppStateProvider.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockAppStateProvider.swift @@ -4,28 +4,27 @@ import Foundation /// Mock implementation of AppStateProvider for testing. /// Uses actor for thread-safe mutable state access. public actor MockAppStateProvider: AppStateProvider { + // MARK: - Stubs - // MARK: - Stubs + /// Configurable foreground state for tests + public var stubbedIsInForeground: Bool - /// Configurable foreground state for tests - public var stubbedIsInForeground: Bool + // MARK: - Protocol Properties - // MARK: - Protocol Properties + public var isInForeground: Bool { + get async { stubbedIsInForeground } + } - public var isInForeground: Bool { - get async { stubbedIsInForeground } - } + // MARK: - Initialization - // MARK: - Initialization + public init(isInForeground: Bool = true) { + stubbedIsInForeground = isInForeground + } - public init(isInForeground: Bool = true) { - self.stubbedIsInForeground = isInForeground - } + // MARK: - Test Helpers - // MARK: - Test Helpers - - /// Sets the stubbed foreground state - public func setIsInForeground(_ value: Bool) { - stubbedIsInForeground = value - } + /// Sets the stubbed foreground state + public func setIsInForeground(_ value: Bool) { + stubbedIsInForeground = value + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockBLEStateMachine.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockBLEStateMachine.swift index bb8ab745..7119d52d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockBLEStateMachine.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockBLEStateMachine.swift @@ -5,201 +5,223 @@ import Foundation /// Mock BLE state machine for testing ConnectionManager. /// Uses actor for thread-safe mutable state access. public actor MockBLEStateMachine: BLEStateMachineProtocol { + // MARK: - Stubs - // MARK: - Stubs - - public var stubbedIsConnected: Bool = false - public var stubbedIsAutoReconnecting: Bool = false - public var stubbedConnectedDeviceID: UUID? - public var stubbedCurrentPhaseName: String = "idle" - public var stubbedCurrentPeripheralState: String? - public var stubbedCentralManagerStateName: String = "poweredOn" - public var stubbedIsBluetoothPoweredOff: Bool = false - public var stubbedIsDeviceConnectedToSystem: Bool = false - public var isDeviceConnectedToSystemHandler: (@Sendable (UUID) -> Bool)? - public var stubbedDidStartAdoptingSystemConnectedPeripheral: Bool = false - - // MARK: - Protocol Properties - - public var isConnected: Bool { stubbedIsConnected } - public var isAutoReconnecting: Bool { stubbedIsAutoReconnecting } - public var connectedDeviceID: UUID? { stubbedConnectedDeviceID } - public var currentPhaseName: String { stubbedCurrentPhaseName } - public var currentPeripheralState: String? { stubbedCurrentPeripheralState } - public var centralManagerStateName: String { stubbedCentralManagerStateName } - public var isBluetoothPoweredOff: Bool { stubbedIsBluetoothPoweredOff } - public var hasAutoReconnectingHandler: Bool { autoReconnectingHandler != nil } - - // MARK: - Recorded Invocations - - public private(set) var activateCallCount = 0 - public private(set) var isDeviceConnectedToSystemCalls: [UUID] = [] - public private(set) var startAdoptingSystemConnectedPeripheralCalls: [UUID] = [] - public private(set) var startScanningCallCount = 0 - public private(set) var stopScanningCallCount = 0 - public private(set) var isScanning = false - - // MARK: - Captured Handlers - - private var autoReconnectingHandler: (@Sendable (UUID, String) -> Void)? - private var bluetoothPoweredOnHandler: (@Sendable () -> Void)? - private var bluetoothStateChangeHandler: (@Sendable (CBManagerState) -> Void)? - private var deviceDiscoveredHandler: (@Sendable (UUID, String?, Int) -> Void)? - - // MARK: - Initialization - - public init() {} - - // MARK: - Protocol Methods - - public func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool { - isDeviceConnectedToSystemCalls.append(deviceID) - if let handler = isDeviceConnectedToSystemHandler { - return handler(deviceID) - } - return stubbedIsDeviceConnectedToSystem - } + public var stubbedIsConnected: Bool = false + public var stubbedIsAutoReconnecting: Bool = false + public var stubbedConnectedDeviceID: UUID? + public var stubbedCurrentPhaseName: String = "idle" + public var stubbedCurrentPeripheralState: String? + public var stubbedCentralManagerStateName: String = "poweredOn" + public var stubbedIsBluetoothPoweredOff: Bool = false + public var stubbedIsDeviceConnectedToSystem: Bool = false + public var isDeviceConnectedToSystemHandler: (@Sendable (UUID) -> Bool)? + public var stubbedDidStartAdoptingSystemConnectedPeripheral: Bool = false - public func systemConnectedPeripheralIDs() -> [UUID] { - [] - } + // MARK: - Protocol Properties - public func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool { - startAdoptingSystemConnectedPeripheralCalls.append(deviceID) - return stubbedDidStartAdoptingSystemConnectedPeripheral - } + public var isConnected: Bool { + stubbedIsConnected + } - public func activate() { - activateCallCount += 1 - } + public var isAutoReconnecting: Bool { + stubbedIsAutoReconnecting + } - public func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) { - autoReconnectingHandler = handler - } + public var connectedDeviceID: UUID? { + stubbedConnectedDeviceID + } - public func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) { - bluetoothPoweredOnHandler = handler - } + public var currentPhaseName: String { + stubbedCurrentPhaseName + } - public func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) { - bluetoothStateChangeHandler = handler - } + public var currentPeripheralState: String? { + stubbedCurrentPeripheralState + } - public func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) { - deviceDiscoveredHandler = handler - } + public var centralManagerStateName: String { + stubbedCentralManagerStateName + } - public func startScanning() { - startScanningCallCount += 1 - isScanning = true - } + public var isBluetoothPoweredOff: Bool { + stubbedIsBluetoothPoweredOff + } - public func stopScanning() { - stopScanningCallCount += 1 - isScanning = false - } - - public func setWritePacingDelay(_ delay: TimeInterval) { - // No-op for testing - } - - public private(set) var shutdownCallCount = 0 - public private(set) var appDidEnterBackgroundCallCount = 0 - public private(set) var appDidBecomeActiveCallCount = 0 - - public func shutdown() { - shutdownCallCount += 1 - } - - public func appDidEnterBackground() { - appDidEnterBackgroundCallCount += 1 - } - - public func appDidBecomeActive() { - appDidBecomeActiveCallCount += 1 - } - - // MARK: - Test Helpers - - /// Resets all stubs and recorded invocations - public func reset() { - stubbedIsConnected = false - stubbedIsAutoReconnecting = false - stubbedConnectedDeviceID = nil - stubbedCurrentPhaseName = "idle" - stubbedCurrentPeripheralState = nil - stubbedCentralManagerStateName = "poweredOn" - stubbedIsBluetoothPoweredOff = false - stubbedIsDeviceConnectedToSystem = false - isDeviceConnectedToSystemHandler = nil - stubbedDidStartAdoptingSystemConnectedPeripheral = false - activateCallCount = 0 - isDeviceConnectedToSystemCalls = [] - startAdoptingSystemConnectedPeripheralCalls = [] - startScanningCallCount = 0 - stopScanningCallCount = 0 - isScanning = false - appDidEnterBackgroundCallCount = 0 - appDidBecomeActiveCallCount = 0 - autoReconnectingHandler = nil - bluetoothPoweredOnHandler = nil - bluetoothStateChangeHandler = nil - deviceDiscoveredHandler = nil - } - - /// Simulates auto-reconnecting event - public func simulateAutoReconnecting(deviceID: UUID, errorInfo: String = "none") { - autoReconnectingHandler?(deviceID, errorInfo) - } - - /// Simulates Bluetooth powered on event - public func simulateBluetoothPoweredOn() { - bluetoothPoweredOnHandler?() - } - - /// Simulates a Bluetooth state change event - public func simulateBluetoothStateChange(_ state: CBManagerState) { - bluetoothStateChangeHandler?(state) - } - - /// Simulates BLE discovery callback while scanning. - public func simulateDiscoveredDevice(id: UUID, name: String? = nil, rssi: Int) { - deviceDiscoveredHandler?(id, name, rssi) - } + public var hasAutoReconnectingHandler: Bool { + autoReconnectingHandler != nil + } + + // MARK: - Recorded Invocations + + public private(set) var activateCallCount = 0 + public private(set) var isDeviceConnectedToSystemCalls: [UUID] = [] + public private(set) var startAdoptingSystemConnectedPeripheralCalls: [UUID] = [] + public private(set) var startScanningCallCount = 0 + public private(set) var stopScanningCallCount = 0 + public private(set) var isScanning = false + + // MARK: - Captured Handlers + + private var autoReconnectingHandler: (@Sendable (UUID, String) -> Void)? + private var bluetoothPoweredOnHandler: (@Sendable () -> Void)? + private var bluetoothStateChangeHandler: (@Sendable (CBManagerState) -> Void)? + private var deviceDiscoveredHandler: (@Sendable (UUID, String?, Int) -> Void)? + + // MARK: - Initialization + + public init() {} + + // MARK: - Protocol Methods + + public func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool { + isDeviceConnectedToSystemCalls.append(deviceID) + if let handler = isDeviceConnectedToSystemHandler { + return handler(deviceID) + } + return stubbedIsDeviceConnectedToSystem + } + + public func systemConnectedPeripheralIDs() -> [UUID] { + [] + } + + public func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool { + startAdoptingSystemConnectedPeripheralCalls.append(deviceID) + return stubbedDidStartAdoptingSystemConnectedPeripheral + } + + public func activate() { + activateCallCount += 1 + } + + public func setAutoReconnectingHandler(_ handler: @escaping @Sendable (UUID, String) -> Void) { + autoReconnectingHandler = handler + } + + public func setBluetoothPoweredOnHandler(_ handler: @escaping @Sendable () -> Void) { + bluetoothPoweredOnHandler = handler + } + + public func setBluetoothStateChangeHandler(_ handler: @escaping @Sendable (CBManagerState) -> Void) { + bluetoothStateChangeHandler = handler + } + + public func setDeviceDiscoveredHandler(_ handler: @escaping @Sendable (UUID, String?, Int) -> Void) { + deviceDiscoveredHandler = handler + } + + public func startScanning() { + startScanningCallCount += 1 + isScanning = true + } + + public func stopScanning() { + stopScanningCallCount += 1 + isScanning = false + } + + public func setWritePacingDelay(_ delay: TimeInterval) { + // No-op for testing + } + + public private(set) var shutdownCallCount = 0 + public private(set) var appDidEnterBackgroundCallCount = 0 + public private(set) var appDidBecomeActiveCallCount = 0 + + public func shutdown() { + shutdownCallCount += 1 + } + + public func appDidEnterBackground() { + appDidEnterBackgroundCallCount += 1 + } + + public func appDidBecomeActive() { + appDidBecomeActiveCallCount += 1 + } + + // MARK: - Test Helpers + + /// Resets all stubs and recorded invocations + public func reset() { + stubbedIsConnected = false + stubbedIsAutoReconnecting = false + stubbedConnectedDeviceID = nil + stubbedCurrentPhaseName = "idle" + stubbedCurrentPeripheralState = nil + stubbedCentralManagerStateName = "poweredOn" + stubbedIsBluetoothPoweredOff = false + stubbedIsDeviceConnectedToSystem = false + isDeviceConnectedToSystemHandler = nil + stubbedDidStartAdoptingSystemConnectedPeripheral = false + activateCallCount = 0 + isDeviceConnectedToSystemCalls = [] + startAdoptingSystemConnectedPeripheralCalls = [] + startScanningCallCount = 0 + stopScanningCallCount = 0 + isScanning = false + appDidEnterBackgroundCallCount = 0 + appDidBecomeActiveCallCount = 0 + autoReconnectingHandler = nil + bluetoothPoweredOnHandler = nil + bluetoothStateChangeHandler = nil + deviceDiscoveredHandler = nil + } + + /// Simulates auto-reconnecting event + public func simulateAutoReconnecting(deviceID: UUID, errorInfo: String = "none") { + autoReconnectingHandler?(deviceID, errorInfo) + } + + /// Simulates Bluetooth powered on event + public func simulateBluetoothPoweredOn() { + bluetoothPoweredOnHandler?() + } + + /// Simulates a Bluetooth state change event + public func simulateBluetoothStateChange(_ state: CBManagerState) { + bluetoothStateChangeHandler?(state) + } + + /// Simulates BLE discovery callback while scanning. + public func simulateDiscoveredDevice(id: UUID, name: String? = nil, rssi: Int) { + deviceDiscoveredHandler?(id, name, rssi) + } } // MARK: - Stubbed Property Setters extension MockBLEStateMachine { - func setStubbedIsConnected(_ value: Bool) { - stubbedIsConnected = value - } + func setStubbedIsConnected(_ value: Bool) { + stubbedIsConnected = value + } - func setStubbedIsAutoReconnecting(_ value: Bool) { - stubbedIsAutoReconnecting = value - } + func setStubbedIsAutoReconnecting(_ value: Bool) { + stubbedIsAutoReconnecting = value + } - func setStubbedIsDeviceConnectedToSystem(_ value: Bool) { - stubbedIsDeviceConnectedToSystem = value - } + func setStubbedIsDeviceConnectedToSystem(_ value: Bool) { + stubbedIsDeviceConnectedToSystem = value + } - func setIsDeviceConnectedToSystemHandler(_ handler: sending (@Sendable (UUID) -> Bool)?) { - isDeviceConnectedToSystemHandler = handler - } + func setIsDeviceConnectedToSystemHandler(_ handler: sending (@Sendable (UUID) -> Bool)?) { + isDeviceConnectedToSystemHandler = handler + } - func setStubbedIsBluetoothPoweredOff(_ value: Bool) { - stubbedIsBluetoothPoweredOff = value - } + func setStubbedIsBluetoothPoweredOff(_ value: Bool) { + stubbedIsBluetoothPoweredOff = value + } - func setStubbedDidStartAdoptingSystemConnectedPeripheral(_ value: Bool) { - stubbedDidStartAdoptingSystemConnectedPeripheral = value - } + func setStubbedDidStartAdoptingSystemConnectedPeripheral(_ value: Bool) { + stubbedDidStartAdoptingSystemConnectedPeripheral = value + } - func setStubbedCurrentPhaseName(_ value: String) { - stubbedCurrentPhaseName = value - } + func setStubbedCurrentPhaseName(_ value: String) { + stubbedCurrentPhaseName = value + } - func setStubbedConnectedDeviceID(_ value: UUID?) { - stubbedConnectedDeviceID = value - } + func setStubbedConnectedDeviceID(_ value: UUID?) { + stubbedConnectedDeviceID = value + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockChannelService.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockChannelService.swift index ed932833..aeb552f5 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockChannelService.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockChannelService.swift @@ -1,74 +1,73 @@ import Foundation -import MeshCore @testable import MC1Services +import MeshCore /// Mock implementation of ChannelServiceProtocol for testing. /// /// Configure the mock by setting the stub properties before calling methods. /// Track method calls by examining the recorded invocations. public actor MockChannelService: ChannelServiceProtocol { + // MARK: - Stubs - // MARK: - Stubs + /// Result to return from syncChannels + public var stubbedSyncChannelsResult: Result = .success( + ChannelSyncResult(channelsSynced: 0, errors: []) + ) - /// Result to return from syncChannels - public var stubbedSyncChannelsResult: Result = .success( - ChannelSyncResult(channelsSynced: 0, errors: []) - ) + /// Result to return from retryFailedChannels + public var stubbedRetryResult: Result = .success( + ChannelSyncResult(channelsSynced: 0, errors: []) + ) - /// Result to return from retryFailedChannels - public var stubbedRetryResult: Result = .success( - ChannelSyncResult(channelsSynced: 0, errors: []) - ) + // MARK: - Recorded Invocations - // MARK: - Recorded Invocations + public struct SyncChannelsInvocation: Sendable, Equatable { + public let radioID: UUID + public let maxChannels: UInt8 + public let usePipelinedRead: Bool + } - public struct SyncChannelsInvocation: Sendable, Equatable { - public let radioID: UUID - public let maxChannels: UInt8 - public let usePipelinedRead: Bool - } + public struct RetryInvocation: Sendable, Equatable { + public let radioID: UUID + public let indices: [UInt8] + } - public struct RetryInvocation: Sendable, Equatable { - public let radioID: UUID - public let indices: [UInt8] - } - - public private(set) var syncChannelsInvocations: [SyncChannelsInvocation] = [] - public private(set) var retryInvocations: [RetryInvocation] = [] + public private(set) var syncChannelsInvocations: [SyncChannelsInvocation] = [] + public private(set) var retryInvocations: [RetryInvocation] = [] - // MARK: - Initialization + // MARK: - Initialization - public init() {} + public init() {} - // MARK: - Protocol Methods + // MARK: - Protocol Methods - public func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult { - syncChannelsInvocations.append( - SyncChannelsInvocation(radioID: radioID, maxChannels: maxChannels, usePipelinedRead: usePipelinedRead) - ) - switch stubbedSyncChannelsResult { - case .success(let result): - return result - case .failure(let error): - throw error - } + public func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult { + syncChannelsInvocations.append( + SyncChannelsInvocation(radioID: radioID, maxChannels: maxChannels, usePipelinedRead: usePipelinedRead) + ) + switch stubbedSyncChannelsResult { + case let .success(result): + return result + case let .failure(error): + throw error } + } - public func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { - retryInvocations.append(RetryInvocation(radioID: radioID, indices: indices)) - switch stubbedRetryResult { - case .success(let result): - return result - case .failure(let error): - throw error - } + public func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { + retryInvocations.append(RetryInvocation(radioID: radioID, indices: indices)) + switch stubbedRetryResult { + case let .success(result): + return result + case let .failure(error): + throw error } + } - // MARK: - Test Helpers + // MARK: - Test Helpers - /// Resets all recorded invocations - public func reset() { - syncChannelsInvocations = [] - retryInvocations = [] - } + /// Resets all recorded invocations + public func reset() { + syncChannelsInvocations = [] + retryInvocations = [] + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockContactService.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockContactService.swift index e1fa8c17..b828cc34 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockContactService.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockContactService.swift @@ -1,54 +1,53 @@ import Foundation -import MeshCore @testable import MC1Services +import MeshCore /// Mock implementation of ContactServiceProtocol for testing. /// /// Configure the mock by setting the stub properties before calling methods. /// Track method calls by examining the recorded invocations. public actor MockContactService: ContactServiceProtocol { + // MARK: - Stubs - // MARK: - Stubs - - /// Result to return from syncContacts - public var stubbedSyncContactsResult: Result = .success( - ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: false) - ) + /// Result to return from syncContacts + public var stubbedSyncContactsResult: Result = .success( + ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: false) + ) - // MARK: - Recorded Invocations + // MARK: - Recorded Invocations - public struct SyncContactsInvocation: Sendable, Equatable { - public let radioID: UUID - public let since: Date? - } + public struct SyncContactsInvocation: Sendable, Equatable { + public let radioID: UUID + public let since: Date? + } - public private(set) var syncContactsInvocations: [SyncContactsInvocation] = [] + public private(set) var syncContactsInvocations: [SyncContactsInvocation] = [] - // MARK: - Initialization + // MARK: - Initialization - public init() {} + public init() {} - // MARK: - Protocol Methods + // MARK: - Protocol Methods - public func syncContacts(radioID: UUID, since: Date? = nil) async throws -> ContactSyncResult { - syncContactsInvocations.append(SyncContactsInvocation(radioID: radioID, since: since)) - switch stubbedSyncContactsResult { - case .success(let result): - return result - case .failure(let error): - throw error - } + public func syncContacts(radioID: UUID, since: Date? = nil) async throws -> ContactSyncResult { + syncContactsInvocations.append(SyncContactsInvocation(radioID: radioID, since: since)) + switch stubbedSyncContactsResult { + case let .success(result): + return result + case let .failure(error): + throw error } + } - // MARK: - Test Helpers + // MARK: - Test Helpers - /// Resets all recorded invocations - public func reset() { - syncContactsInvocations = [] - } + /// Resets all recorded invocations + public func reset() { + syncContactsInvocations = [] + } - /// Sets the stubbed result for syncContacts - public func setStubbedSyncContactsResult(_ result: Result) { - stubbedSyncContactsResult = result - } + /// Sets the stubbed result for syncContacts + public func setStubbedSyncContactsResult(_ result: Result) { + stubbedSyncContactsResult = result + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift index 046981f4..74d51d73 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift @@ -6,419 +6,417 @@ import MeshCore /// Configure the mock by setting the stub properties before calling methods. /// Track method calls by examining the recorded invocations. public actor MockMeshCoreSession: MeshCoreSessionProtocol { + // MARK: - Connection State - // MARK: - Connection State - - public var connectionState: AsyncStream { - AsyncStream { continuation in - continuation.yield(stubbedConnectionState) - continuation.finish() - } - } - - /// Self info to return from currentSelfInfo. Configure via `setCurrentSelfInfo(_:)`. - public private(set) var currentSelfInfo: SelfInfo? - - // MARK: - Event Streaming - - private struct EventSubscription { - let filter: EventFilter? - let continuation: AsyncStream.Continuation + public var connectionState: AsyncStream { + AsyncStream { continuation in + continuation.yield(stubbedConnectionState) + continuation.finish() } + } - /// Active `events()` subscriptions, keyed so a terminated stream can deregister itself. - private var eventSubscriptions: [UUID: EventSubscription] = [:] - - public func events() async -> AsyncStream { - makeEventStream(filter: nil) - } - - public func events(filter: EventFilter) async -> AsyncStream { - makeEventStream(filter: filter) - } - - private func makeEventStream(filter: EventFilter?) -> AsyncStream { - let id = UUID() - let (stream, continuation) = AsyncStream.makeStream() - eventSubscriptions[id] = EventSubscription(filter: filter, continuation: continuation) - continuation.onTermination = { [weak self] _ in - guard let self else { return } - Task { await self.removeEventSubscription(id: id) } - } - return stream - } - - private func removeEventSubscription(id: UUID) { - eventSubscriptions[id] = nil - } - - /// Yields an event to every active `events()` subscriber whose filter matches. - public func yieldEvent(_ event: MeshEvent) { - for subscription in eventSubscriptions.values where subscription.filter?.matches(event) != false { - subscription.continuation.yield(event) - } - } - - /// Finishes every active `events()` stream, ending subscribers' for-await loops. - public func finishEventStreams() { - for subscription in eventSubscriptions.values { - subscription.continuation.finish() - } - eventSubscriptions.removeAll() - } - - // MARK: - Stubs - - /// The connection state to return from connectionState stream - public var stubbedConnectionState: ConnectionState = .disconnected - - /// Result to return from sendMessage - public var stubbedSendMessageResult: Result = .success( - MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000) - ) - - /// Result to return from sendChannelMessage - public var stubbedSendChannelMessageError: Error? + /// Self info to return from currentSelfInfo. Configure via `setCurrentSelfInfo(_:)`. + public private(set) var currentSelfInfo: SelfInfo? - /// Contacts to return from getContacts - public var stubbedContacts: [MeshContact] = [] + // MARK: - Event Streaming - /// Error to throw from getContacts - public var stubbedGetContactsError: Error? + private struct EventSubscription { + let filter: EventFilter? + let continuation: AsyncStream.Continuation + } - /// Contact to return from getContact (by public key) - public var stubbedContact: MeshContact? - - /// Error to throw from getContact - public var stubbedGetContactError: Error? - - /// Error to throw from addContact - public var stubbedAddContactError: Error? - - /// Error to throw from removeContact - public var stubbedRemoveContactError: Error? - - /// Error to throw from resetPath - public var stubbedResetPathError: Error? - - /// Result to return from sendPathDiscovery - public var stubbedSendPathDiscoveryResult: Result = .success( - MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000) - ) - - /// Channel info to return from getChannel, keyed by index - public var stubbedChannels: [UInt8: ChannelInfo] = [:] - - /// Error to throw from getChannel - public var stubbedGetChannelError: Error? - - /// Error to throw from setChannel - public var stubbedSetChannelError: Error? - - /// Event to return from waitForEvent (nil simulates a timeout) - public var stubbedWaitForEventResult: MeshEvent? - - /// Result to return from getMessage - public var stubbedGetMessageResult: Result = .success(.noMoreMessages) - - /// Results to return from successive `sendLogin` calls, consumed FIFO. - public var stubbedSendLoginResults: [Result] = [] - - // MARK: - Recorded Invocations - - public struct SendMessageInvocation: Sendable, Equatable { - public let destination: Data - public let text: String - public let timestamp: Date - public let attempt: UInt8 - } - - public struct SendChannelMessageInvocation: Sendable, Equatable { - public let channel: UInt8 - public let text: String - public let timestamp: Date - } - - public struct AddContactInvocation: Sendable, Equatable { - public let contact: MeshContact - } - - public struct SendLoginInvocation: Sendable, Equatable { - public let destination: Data - public let password: String - } + /// Active `events()` subscriptions, keyed so a terminated stream can deregister itself. + private var eventSubscriptions: [UUID: EventSubscription] = [:] - public struct SetChannelInvocation: Sendable, Equatable { - public let index: UInt8 - public let name: String - public let secret: Data - } + public func events() async -> AsyncStream { + makeEventStream(filter: nil) + } - public struct WaitForEventInvocation: Sendable { - public let filter: EventFilter - public let timeout: TimeInterval? - } + public func events(filter: EventFilter) async -> AsyncStream { + makeEventStream(filter: filter) + } - public private(set) var sendMessageInvocations: [SendMessageInvocation] = [] - public private(set) var sendChannelMessageInvocations: [SendChannelMessageInvocation] = [] - public private(set) var getContactsInvocations: [Date?] = [] - public private(set) var getContactPublicKeys: [Data] = [] - public private(set) var addContactInvocations: [AddContactInvocation] = [] - public private(set) var sendLoginInvocations: [SendLoginInvocation] = [] - public private(set) var removeContactPublicKeys: [Data] = [] - public private(set) var resetPathPublicKeys: [Data] = [] - public private(set) var sendPathDiscoveryDestinations: [Data] = [] - public private(set) var getChannelIndices: [UInt8] = [] - public private(set) var setChannelInvocations: [SetChannelInvocation] = [] - public private(set) var waitForEventInvocations: [WaitForEventInvocation] = [] - public private(set) var getMessageTimeouts: [TimeInterval?] = [] - public private(set) var startAutoMessageFetchingCallCount = 0 - public private(set) var stopAutoMessageFetchingCallCount = 0 - - // MARK: - Initialization - - public init() {} - - // MARK: - Test Configuration - - /// Sets the contacts returned by `getContacts(since:)`. Actor isolation forbids writing the - /// stub property directly from a test, so configuration goes through this isolated setter. - public func setStubbedContacts(_ contacts: [MeshContact]) { - stubbedContacts = contacts + private func makeEventStream(filter: EventFilter?) -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream() + eventSubscriptions[id] = EventSubscription(filter: filter, continuation: continuation) + continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { await self.removeEventSubscription(id: id) } } + return stream + } - /// Sets the self info returned by `currentSelfInfo`, through an isolated setter - /// for the same actor-isolation reason as `setStubbedContacts`. - public func setCurrentSelfInfo(_ selfInfo: SelfInfo?) { - currentSelfInfo = selfInfo - } + private func removeEventSubscription(id: UUID) { + eventSubscriptions[id] = nil + } - /// Sets the results returned by successive `sendLogin` calls (isolated setter). - public func setSendLoginResults(_ results: [Result]) { - stubbedSendLoginResults = results + /// Yields an event to every active `events()` subscriber whose filter matches. + public func yieldEvent(_ event: MeshEvent) { + for subscription in eventSubscriptions.values where subscription.filter?.matches(event) != false { + subscription.continuation.yield(event) } + } - /// Sets the error thrown by `addContact` (isolated setter). - public func setAddContactError(_ error: Error?) { - stubbedAddContactError = error + /// Finishes every active `events()` stream, ending subscribers' for-await loops. + public func finishEventStreams() { + for subscription in eventSubscriptions.values { + subscription.continuation.finish() } + eventSubscriptions.removeAll() + } - // MARK: - Protocol Methods + // MARK: - Stubs - public func sendMessage(to destination: Data, text: String, timestamp: Date, attempt: UInt8) async throws -> MessageSentInfo { - sendMessageInvocations.append(SendMessageInvocation(destination: destination, text: text, timestamp: timestamp, attempt: attempt)) - switch stubbedSendMessageResult { - case .success(let info): - return info - case .failure(let error): - throw error - } - } + /// The connection state to return from connectionState stream + public var stubbedConnectionState: ConnectionState = .disconnected - public func sendChannelMessage(channel: UInt8, text: String, timestamp: Date) async throws { - sendChannelMessageInvocations.append(SendChannelMessageInvocation(channel: channel, text: text, timestamp: timestamp)) - if let error = stubbedSendChannelMessageError { - throw error - } - } + /// Result to return from sendMessage + public var stubbedSendMessageResult: Result = .success( + MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000) + ) - public func getContacts(since lastModified: Date?) async throws -> [MeshContact] { - getContactsInvocations.append(lastModified) - if let error = stubbedGetContactsError { - throw error - } - return stubbedContacts - } + /// Result to return from sendChannelMessage + public var stubbedSendChannelMessageError: Error? - public func getContact(publicKey: Data) async throws -> MeshContact? { - getContactPublicKeys.append(publicKey) - if let error = stubbedGetContactError { - throw error - } - return stubbedContact - } + /// Contacts to return from getContacts + public var stubbedContacts: [MeshContact] = [] - public func addContact(_ contact: MeshContact) async throws { - addContactInvocations.append(AddContactInvocation(contact: contact)) - if let error = stubbedAddContactError { - throw error - } - } + /// Error to throw from getContacts + public var stubbedGetContactsError: Error? - public func removeContact(publicKey: Data) async throws { - removeContactPublicKeys.append(publicKey) - if let error = stubbedRemoveContactError { - throw error - } - } + /// Contact to return from getContact (by public key) + public var stubbedContact: MeshContact? - public func resetPath(publicKey: Data) async throws { - resetPathPublicKeys.append(publicKey) - if let error = stubbedResetPathError { - throw error - } - } + /// Error to throw from getContact + public var stubbedGetContactError: Error? - public func sendPathDiscovery(to destination: Data) async throws -> MessageSentInfo { - sendPathDiscoveryDestinations.append(destination) - switch stubbedSendPathDiscoveryResult { - case .success(let info): - return info - case .failure(let error): - throw error - } - } + /// Error to throw from addContact + public var stubbedAddContactError: Error? - public func getChannel(index: UInt8) async throws -> ChannelInfo { - getChannelIndices.append(index) - if let error = stubbedGetChannelError { - throw error - } - if let channel = stubbedChannels[index] { - return channel - } - // Return a default empty channel - return ChannelInfo(index: index, name: "", secret: Data(repeating: 0, count: 16)) - } + /// Error to throw from removeContact + public var stubbedRemoveContactError: Error? - public func getChannels(indices: [UInt8]) async throws -> (received: [ChannelInfo], missing: [UInt8]) { - getChannelIndices.append(contentsOf: indices) - if let error = stubbedGetChannelError { - throw error - } - // The firmware answers every requested index, so the mock returns one per request. - let received = indices.map { index in - stubbedChannels[index] ?? ChannelInfo(index: index, name: "", secret: Data(repeating: 0, count: 16)) - } - return (received: received, missing: []) - } + /// Error to throw from resetPath + public var stubbedResetPathError: Error? - public func setChannel(index: UInt8, name: String, secret: Data) async throws { - setChannelInvocations.append(SetChannelInvocation(index: index, name: name, secret: secret)) - if let error = stubbedSetChannelError { - throw error - } - } + /// Result to return from sendPathDiscovery + public var stubbedSendPathDiscoveryResult: Result = .success( + MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000) + ) - public func shareContact(publicKey: Data) async throws { - // Stub - not used in current tests - } + /// Channel info to return from getChannel, keyed by index + public var stubbedChannels: [UInt8: ChannelInfo] = [:] - public func exportContact(publicKey: Data?) async throws -> String { - // Stub - not used in current tests - return "" - } + /// Error to throw from getChannel + public var stubbedGetChannelError: Error? + + /// Error to throw from setChannel + public var stubbedSetChannelError: Error? - public func importContact(cardData: Data) async throws { - // Stub - not used in current tests - } + /// Event to return from waitForEvent (nil simulates a timeout) + public var stubbedWaitForEventResult: MeshEvent? + + /// Result to return from getMessage + public var stubbedGetMessageResult: Result = .success(.noMoreMessages) + + /// Results to return from successive `sendLogin` calls, consumed FIFO. + public var stubbedSendLoginResults: [Result] = [] + + // MARK: - Recorded Invocations + + public struct SendMessageInvocation: Sendable, Equatable { + public let destination: Data + public let text: String + public let timestamp: Date + public let attempt: UInt8 + } + + public struct SendChannelMessageInvocation: Sendable, Equatable { + public let channel: UInt8 + public let text: String + public let timestamp: Date + } + + public struct AddContactInvocation: Sendable, Equatable { + public let contact: MeshContact + } - public func changeContactFlags(_ contact: MeshContact, flags: ContactFlags) async throws { - // Stub - not used in current tests - } + public struct SendLoginInvocation: Sendable, Equatable { + public let destination: Data + public let password: String + } - public func waitForEvent(filter: EventFilter, timeout: TimeInterval?) async -> MeshEvent? { - waitForEventInvocations.append(WaitForEventInvocation(filter: filter, timeout: timeout)) - return stubbedWaitForEventResult - } + public struct SetChannelInvocation: Sendable, Equatable { + public let index: UInt8 + public let name: String + public let secret: Data + } - public func getMessage(timeout: TimeInterval?) async throws -> MessageResult { - getMessageTimeouts.append(timeout) - switch stubbedGetMessageResult { - case .success(let result): - return result - case .failure(let error): - throw error - } - } + public struct WaitForEventInvocation: Sendable { + public let filter: EventFilter + public let timeout: TimeInterval? + } - public func startAutoMessageFetching() async { - startAutoMessageFetchingCallCount += 1 - } + public private(set) var sendMessageInvocations: [SendMessageInvocation] = [] + public private(set) var sendChannelMessageInvocations: [SendChannelMessageInvocation] = [] + public private(set) var getContactsInvocations: [Date?] = [] + public private(set) var getContactPublicKeys: [Data] = [] + public private(set) var addContactInvocations: [AddContactInvocation] = [] + public private(set) var sendLoginInvocations: [SendLoginInvocation] = [] + public private(set) var removeContactPublicKeys: [Data] = [] + public private(set) var resetPathPublicKeys: [Data] = [] + public private(set) var sendPathDiscoveryDestinations: [Data] = [] + public private(set) var getChannelIndices: [UInt8] = [] + public private(set) var setChannelInvocations: [SetChannelInvocation] = [] + public private(set) var waitForEventInvocations: [WaitForEventInvocation] = [] + public private(set) var getMessageTimeouts: [TimeInterval?] = [] + public private(set) var startAutoMessageFetchingCallCount = 0 + public private(set) var stopAutoMessageFetchingCallCount = 0 + + // MARK: - Initialization - public func stopAutoMessageFetching() { - stopAutoMessageFetchingCallCount += 1 - } - - // MARK: - Test Helpers - - /// Error thrown by remote-access methods that a given test has not configured. - enum NotStubbed: Error { case method(String) } - - /// Resets all recorded invocations - public func reset() { - sendMessageInvocations = [] - sendChannelMessageInvocations = [] - getContactsInvocations = [] - getContactPublicKeys = [] - addContactInvocations = [] - sendLoginInvocations = [] - removeContactPublicKeys = [] - resetPathPublicKeys = [] - sendPathDiscoveryDestinations = [] - getChannelIndices = [] - setChannelInvocations = [] - waitForEventInvocations = [] - getMessageTimeouts = [] - startAutoMessageFetchingCallCount = 0 - stopAutoMessageFetchingCallCount = 0 - } + public init() {} + + // MARK: - Test Configuration + + /// Sets the contacts returned by `getContacts(since:)`. Actor isolation forbids writing the + /// stub property directly from a test, so configuration goes through this isolated setter. + public func setStubbedContacts(_ contacts: [MeshContact]) { + stubbedContacts = contacts + } + + /// Sets the self info returned by `currentSelfInfo`, through an isolated setter + /// for the same actor-isolation reason as `setStubbedContacts`. + public func setCurrentSelfInfo(_ selfInfo: SelfInfo?) { + currentSelfInfo = selfInfo + } + + /// Sets the results returned by successive `sendLogin` calls (isolated setter). + public func setSendLoginResults(_ results: [Result]) { + stubbedSendLoginResults = results + } + + /// Sets the error thrown by `addContact` (isolated setter). + public func setAddContactError(_ error: Error?) { + stubbedAddContactError = error + } + + // MARK: - Protocol Methods + + public func sendMessage(to destination: Data, text: String, timestamp: Date, attempt: UInt8) async throws -> MessageSentInfo { + sendMessageInvocations.append(SendMessageInvocation(destination: destination, text: text, timestamp: timestamp, attempt: attempt)) + switch stubbedSendMessageResult { + case let .success(info): + return info + case let .failure(error): + throw error + } + } + + public func sendChannelMessage(channel: UInt8, text: String, timestamp: Date) async throws { + sendChannelMessageInvocations.append(SendChannelMessageInvocation(channel: channel, text: text, timestamp: timestamp)) + if let error = stubbedSendChannelMessageError { + throw error + } + } + + public func getContacts(since lastModified: Date?) async throws -> [MeshContact] { + getContactsInvocations.append(lastModified) + if let error = stubbedGetContactsError { + throw error + } + return stubbedContacts + } + + public func getContact(publicKey: Data) async throws -> MeshContact? { + getContactPublicKeys.append(publicKey) + if let error = stubbedGetContactError { + throw error + } + return stubbedContact + } + + public func addContact(_ contact: MeshContact) async throws { + addContactInvocations.append(AddContactInvocation(contact: contact)) + if let error = stubbedAddContactError { + throw error + } + } + + public func removeContact(publicKey: Data) async throws { + removeContactPublicKeys.append(publicKey) + if let error = stubbedRemoveContactError { + throw error + } + } + + public func resetPath(publicKey: Data) async throws { + resetPathPublicKeys.append(publicKey) + if let error = stubbedResetPathError { + throw error + } + } + + public func sendPathDiscovery(to destination: Data) async throws -> MessageSentInfo { + sendPathDiscoveryDestinations.append(destination) + switch stubbedSendPathDiscoveryResult { + case let .success(info): + return info + case let .failure(error): + throw error + } + } + + public func getChannel(index: UInt8) async throws -> ChannelInfo { + getChannelIndices.append(index) + if let error = stubbedGetChannelError { + throw error + } + if let channel = stubbedChannels[index] { + return channel + } + // Return a default empty channel + return ChannelInfo(index: index, name: "", secret: Data(repeating: 0, count: 16)) + } + + public func getChannels(indices: [UInt8]) async throws -> (received: [ChannelInfo], missing: [UInt8]) { + getChannelIndices.append(contentsOf: indices) + if let error = stubbedGetChannelError { + throw error + } + // The firmware answers every requested index, so the mock returns one per request. + let received = indices.map { index in + stubbedChannels[index] ?? ChannelInfo(index: index, name: "", secret: Data(repeating: 0, count: 16)) + } + return (received: received, missing: []) + } + + public func setChannel(index: UInt8, name: String, secret: Data) async throws { + setChannelInvocations.append(SetChannelInvocation(index: index, name: name, secret: secret)) + if let error = stubbedSetChannelError { + throw error + } + } + + public func shareContact(publicKey: Data) async throws { + // Stub - not used in current tests + } + + public func exportContact(publicKey: Data?) async throws -> String { + // Stub - not used in current tests + "" + } + + public func importContact(cardData: Data) async throws { + // Stub - not used in current tests + } + + public func changeContactFlags(_ contact: MeshContact, flags: ContactFlags) async throws { + // Stub - not used in current tests + } + + public func waitForEvent(filter: EventFilter, timeout: TimeInterval?) async -> MeshEvent? { + waitForEventInvocations.append(WaitForEventInvocation(filter: filter, timeout: timeout)) + return stubbedWaitForEventResult + } + + public func getMessage(timeout: TimeInterval?) async throws -> MessageResult { + getMessageTimeouts.append(timeout) + switch stubbedGetMessageResult { + case let .success(result): + return result + case let .failure(error): + throw error + } + } + + public func startAutoMessageFetching() async { + startAutoMessageFetchingCallCount += 1 + } + + public func stopAutoMessageFetching() { + stopAutoMessageFetchingCallCount += 1 + } + + // MARK: - Test Helpers + + /// Error thrown by remote-access methods that a given test has not configured. + enum NotStubbed: Error { case method(String) } + + /// Resets all recorded invocations + public func reset() { + sendMessageInvocations = [] + sendChannelMessageInvocations = [] + getContactsInvocations = [] + getContactPublicKeys = [] + addContactInvocations = [] + sendLoginInvocations = [] + removeContactPublicKeys = [] + resetPathPublicKeys = [] + sendPathDiscoveryDestinations = [] + getChannelIndices = [] + setChannelInvocations = [] + waitForEventInvocations = [] + getMessageTimeouts = [] + startAutoMessageFetchingCallCount = 0 + stopAutoMessageFetchingCallCount = 0 + } } // MARK: - RemoteAccessSessionOps extension MockMeshCoreSession: RemoteAccessSessionOps { - - public func sendLogin(to destination: Data, password: String) async throws -> MessageSentInfo { - sendLoginInvocations.append(SendLoginInvocation(destination: destination, password: password)) - guard !stubbedSendLoginResults.isEmpty else { throw NotStubbed.method("sendLogin") } - switch stubbedSendLoginResults.removeFirst() { - case .success(let info): return info - case .failure(let error): throw error - } - } - - public func sendLogout(to destination: Data) async throws {} - - public func sendCommand(to destination: Data, command: String, timestamp: Date) async throws -> MessageSentInfo { - throw NotStubbed.method("sendCommand") - } - - public func sendKeepAlive(to publicKey: Data, syncSince: UInt32) async throws -> MessageSentInfo { - throw NotStubbed.method("sendKeepAlive") - } - - public func requestOwnerInfo(from publicKey: Data) async throws -> OwnerInfoResponse { - throw NotStubbed.method("requestOwnerInfo") - } - - public func requestStatus(from publicKey: Data, type: ContactType) async throws -> StatusResponse { - throw NotStubbed.method("requestStatus") - } - - public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { - throw NotStubbed.method("requestTelemetry") - } - - public func requestNeighbours( - from publicKey: Data, - count: UInt8, - offset: UInt16, - orderBy: UInt8, - pubkeyPrefixLength: UInt8 - ) async throws -> NeighboursResponse { - throw NotStubbed.method("requestNeighbours") - } - - public func sendMessageWithRetry( - to destination: Data, - text: String, - timestamp: Date, - maxAttempts: Int, - floodAfter: Int, - maxFloodAttempts: Int, - timeout: TimeInterval? - ) async throws -> MessageSentInfo? { - throw NotStubbed.method("sendMessageWithRetry") - } + public func sendLogin(to destination: Data, password: String) async throws -> MessageSentInfo { + sendLoginInvocations.append(SendLoginInvocation(destination: destination, password: password)) + guard !stubbedSendLoginResults.isEmpty else { throw NotStubbed.method("sendLogin") } + switch stubbedSendLoginResults.removeFirst() { + case let .success(info): return info + case let .failure(error): throw error + } + } + + public func sendLogout(to destination: Data) async throws {} + + public func sendCommand(to destination: Data, command: String, timestamp: Date) async throws -> MessageSentInfo { + throw NotStubbed.method("sendCommand") + } + + public func sendKeepAlive(to publicKey: Data, syncSince: UInt32) async throws -> MessageSentInfo { + throw NotStubbed.method("sendKeepAlive") + } + + public func requestOwnerInfo(from publicKey: Data) async throws -> OwnerInfoResponse { + throw NotStubbed.method("requestOwnerInfo") + } + + public func requestStatus(from publicKey: Data, type: ContactType) async throws -> StatusResponse { + throw NotStubbed.method("requestStatus") + } + + public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { + throw NotStubbed.method("requestTelemetry") + } + + public func requestNeighbours( + from publicKey: Data, + count: UInt8, + offset: UInt16, + orderBy: UInt8, + pubkeyPrefixLength: UInt8 + ) async throws -> NeighboursResponse { + throw NotStubbed.method("requestNeighbours") + } + + public func sendMessageWithRetry( + to destination: Data, + text: String, + timestamp: Date, + maxAttempts: Int, + floodAfter: Int, + maxFloodAttempts: Int, + timeout: TimeInterval? + ) async throws -> MessageSentInfo? { + throw NotStubbed.method("sendMessageWithRetry") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshTransport.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshTransport.swift index ef5285c2..6dd601e9 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshTransport.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshTransport.swift @@ -8,82 +8,91 @@ import Foundation /// yielding session bytes — chokepoint-dispatch tests don't need a "ready" /// session to fire their assertions. public actor MockMeshTransport: iOSMeshTransport { - public struct ConnectInvocation: Sendable, Equatable { - public let deviceID: UUID? - public let timestamp: Date - } - - public private(set) var connectInvocations: [ConnectInvocation] = [] - private var currentDeviceID: UUID? - private var disconnectionHandler: (@Sendable (UUID, Error?) -> Void)? - private var reconnectionHandler: (@Sendable (UUID) -> Void)? - private let dataStream: AsyncStream - /// Retains the stream's continuation so it stays open. Tests don't yield bytes; - /// the stalled stream models a transport that connected at the BLE layer but - /// never delivers session data, letting cancellation/teardown tests run without - /// driving a full session lifecycle. Without this property the continuation - /// would deinit and the stream would finish, ending consumer awaits prematurely. - private let dataContinuation: AsyncStream.Continuation - private var connected = false - private var connectError: Error? - - public init() { - var continuation: AsyncStream.Continuation! - self.dataStream = AsyncStream { continuation = $0 } - self.dataContinuation = continuation - } - - // MARK: - MeshTransport - - public var receivedData: AsyncStream { dataStream } - public var isConnected: Bool { connected } - - public func connect() async throws { - connectInvocations.append(ConnectInvocation(deviceID: currentDeviceID, timestamp: Date())) - if let connectError { throw connectError } - connected = true - } - - public func disconnect() async { - connected = false - } - - public func send(_ data: Data) async throws {} - - // MARK: - iOSMeshTransport - - public func setDeviceID(_ id: UUID) { self.currentDeviceID = id } - - public func switchDevice(to deviceID: UUID) async throws { - connectInvocations.append(ConnectInvocation(deviceID: deviceID, timestamp: Date())) - self.currentDeviceID = deviceID - } - - public func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) { - self.disconnectionHandler = handler - } - - public func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) { - self.reconnectionHandler = handler - } - - // MARK: - Test Helpers - - /// When set, `connect()` records the invocation then throws this error on every attempt, - /// modeling a transport that never reaches a BLE link. Lets tests drive the retry budget - /// without standing up a real session. - public func setConnectError(_ error: Error?) { - self.connectError = error - } - - /// Indicates whether `setReconnectionHandler` was wired up during init. - /// Tests poll this before invoking `simulateReconnection` to avoid races. - public var hasReconnectionHandler: Bool { reconnectionHandler != nil } - - /// Invokes the registered reconnection handler as if iOS auto-reconnect completed - /// for `deviceID`. Mirrors `MockBLEStateMachine.simulateAutoReconnecting` for the - /// completion side of the auto-reconnect lifecycle. - public func simulateReconnection(deviceID: UUID) { - reconnectionHandler?(deviceID) - } + public struct ConnectInvocation: Sendable, Equatable { + public let deviceID: UUID? + public let timestamp: Date + } + + public private(set) var connectInvocations: [ConnectInvocation] = [] + private var currentDeviceID: UUID? + private var disconnectionHandler: (@Sendable (UUID, Error?) -> Void)? + private var reconnectionHandler: (@Sendable (UUID) -> Void)? + private let dataStream: AsyncStream + /// Retains the stream's continuation so it stays open. Tests don't yield bytes; + /// the stalled stream models a transport that connected at the BLE layer but + /// never delivers session data, letting cancellation/teardown tests run without + /// driving a full session lifecycle. Without this property the continuation + /// would deinit and the stream would finish, ending consumer awaits prematurely. + private let dataContinuation: AsyncStream.Continuation + private var connected = false + private var connectError: Error? + + public init() { + var continuation: AsyncStream.Continuation! + dataStream = AsyncStream { continuation = $0 } + dataContinuation = continuation + } + + // MARK: - MeshTransport + + public var receivedData: AsyncStream { + dataStream + } + + public var isConnected: Bool { + connected + } + + public func connect() async throws { + connectInvocations.append(ConnectInvocation(deviceID: currentDeviceID, timestamp: Date())) + if let connectError { throw connectError } + connected = true + } + + public func disconnect() async { + connected = false + } + + public func send(_ data: Data) async throws {} + + // MARK: - iOSMeshTransport + + public func setDeviceID(_ id: UUID) { + currentDeviceID = id + } + + public func switchDevice(to deviceID: UUID) async throws { + connectInvocations.append(ConnectInvocation(deviceID: deviceID, timestamp: Date())) + currentDeviceID = deviceID + } + + public func setDisconnectionHandler(_ handler: @escaping @Sendable (UUID, Error?) -> Void) { + disconnectionHandler = handler + } + + public func setReconnectionHandler(_ handler: @escaping @Sendable (UUID) -> Void) { + reconnectionHandler = handler + } + + // MARK: - Test Helpers + + /// When set, `connect()` records the invocation then throws this error on every attempt, + /// modeling a transport that never reaches a BLE link. Lets tests drive the retry budget + /// without standing up a real session. + public func setConnectError(_ error: Error?) { + connectError = error + } + + /// Indicates whether `setReconnectionHandler` was wired up during init. + /// Tests poll this before invoking `simulateReconnection` to avoid races. + public var hasReconnectionHandler: Bool { + reconnectionHandler != nil + } + + /// Invokes the registered reconnection handler as if iOS auto-reconnect completed + /// for `deviceID`. Mirrors `MockBLEStateMachine.simulateAutoReconnecting` for the + /// completion side of the auto-reconnect lifecycle. + public func simulateReconnection(deviceID: UUID) { + reconnectionHandler?(deviceID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMessagePollingService.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMessagePollingService.swift index 7226e786..c7de04e4 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMessagePollingService.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMessagePollingService.swift @@ -1,107 +1,106 @@ import Foundation -import MeshCore @testable import MC1Services +import MeshCore /// Mock implementation of MessagePollingServiceProtocol for testing. /// /// Configure the mock by setting the stub properties before calling methods. /// Track method calls by examining the recorded invocations. public actor MockMessagePollingService: MessagePollingServiceProtocol { + // MARK: - Stubs - // MARK: - Stubs + /// Result to return from pollAllMessages + public var stubbedPollAllMessagesResult: Result = .success(0) - /// Result to return from pollAllMessages - public var stubbedPollAllMessagesResult: Result = .success(0) + // MARK: - Recorded Invocations - // MARK: - Recorded Invocations + public private(set) var pollAllMessagesCallCount: Int = 0 + public private(set) var waitForPendingHandlersInvocations: Int = 0 + public private(set) var startAutoFetchRadioIDs: [UUID] = [] + public private(set) var pauseAutoFetchCallCount: Int = 0 + public private(set) var resumeAutoFetchCallCount: Int = 0 - public private(set) var pollAllMessagesCallCount: Int = 0 - public private(set) var waitForPendingHandlersInvocations: Int = 0 - public private(set) var startAutoFetchRadioIDs: [UUID] = [] - public private(set) var pauseAutoFetchCallCount: Int = 0 - public private(set) var resumeAutoFetchCallCount: Int = 0 + // MARK: - Initialization - // MARK: - Initialization + public init() {} - public init() {} + // MARK: - Protocol Methods - // MARK: - Protocol Methods - - public func pollAllMessages() async throws -> Int { - pollAllMessagesCallCount += 1 - switch stubbedPollAllMessagesResult { - case .success(let count): - return count - case .failure(let error): - throw error - } + public func pollAllMessages() async throws -> Int { + pollAllMessagesCallCount += 1 + switch stubbedPollAllMessagesResult { + case let .success(count): + return count + case let .failure(error): + throw error } + } - public func waitForPendingHandlers(timeout: Duration) async -> Bool { - waitForPendingHandlersInvocations += 1 - return true - } + public func waitForPendingHandlers(timeout: Duration) async -> Bool { + waitForPendingHandlersInvocations += 1 + return true + } - public func startAutoFetch(radioID: UUID) async { - startAutoFetchRadioIDs.append(radioID) - } + public func startAutoFetch(radioID: UUID) async { + startAutoFetchRadioIDs.append(radioID) + } - public func pauseAutoFetch() async { - pauseAutoFetchCallCount += 1 - } + public func pauseAutoFetch() async { + pauseAutoFetchCallCount += 1 + } - public func resumeAutoFetch() async { - resumeAutoFetchCallCount += 1 - } + public func resumeAutoFetch() async { + resumeAutoFetchCallCount += 1 + } - // MARK: - Captured Handlers + // MARK: - Captured Handlers - /// Captured contact message handler (set via setContactMessageHandler) - public private(set) var capturedContactMessageHandler: (@Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void)? + /// Captured contact message handler (set via setContactMessageHandler) + public private(set) var capturedContactMessageHandler: (@Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void)? - /// Captured channel message handler (set via setChannelMessageHandler) - public private(set) var capturedChannelMessageHandler: (@Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void)? + /// Captured channel message handler (set via setChannelMessageHandler) + public private(set) var capturedChannelMessageHandler: (@Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void)? - /// Captured signed message handler (set via setSignedMessageHandler) - public private(set) var capturedSignedMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? + /// Captured signed message handler (set via setSignedMessageHandler) + public private(set) var capturedSignedMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? - /// Captured CLI message handler (set via setCLIMessageHandler) - public private(set) var capturedCLIMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? + /// Captured CLI message handler (set via setCLIMessageHandler) + public private(set) var capturedCLIMessageHandler: (@Sendable (ContactMessage, ContactDTO?) async -> Void)? - // MARK: - Handler Setter Methods (matching MessagePollingService) + // MARK: - Handler Setter Methods (matching MessagePollingService) - public func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) { - capturedContactMessageHandler = handler - } + public func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) { + capturedContactMessageHandler = handler + } - public func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) { - capturedChannelMessageHandler = handler - } + public func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) { + capturedChannelMessageHandler = handler + } - public func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { - capturedSignedMessageHandler = handler - } + public func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { + capturedSignedMessageHandler = handler + } - public func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { - capturedCLIMessageHandler = handler - } + public func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) { + capturedCLIMessageHandler = handler + } - // MARK: - Test Helpers - - /// Resets all recorded invocations and captured handlers - public func reset() { - pollAllMessagesCallCount = 0 - waitForPendingHandlersInvocations = 0 - startAutoFetchRadioIDs = [] - pauseAutoFetchCallCount = 0 - resumeAutoFetchCallCount = 0 - capturedContactMessageHandler = nil - capturedChannelMessageHandler = nil - capturedSignedMessageHandler = nil - capturedCLIMessageHandler = nil - } + // MARK: - Test Helpers - public func setStubbedPollAllMessagesResult(_ result: Result) { - stubbedPollAllMessagesResult = result - } + /// Resets all recorded invocations and captured handlers + public func reset() { + pollAllMessagesCallCount = 0 + waitForPendingHandlersInvocations = 0 + startAutoFetchRadioIDs = [] + pauseAutoFetchCallCount = 0 + resumeAutoFetchCallCount = 0 + capturedContactMessageHandler = nil + capturedChannelMessageHandler = nil + capturedSignedMessageHandler = nil + capturedCLIMessageHandler = nil + } + + public func setStubbedPollAllMessagesResult(_ result: Result) { + stubbedPollAllMessagesResult = result + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index f4ea2114..05f9dc9c 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -1,1943 +1,1942 @@ import Foundation -import MeshCore @testable import MC1Services +import MeshCore /// Mock implementation of PersistenceStoreProtocol for testing. /// /// Uses in-memory storage for all data. Configure by adding items to the /// storage dictionaries or setting stubbed errors. public actor MockPersistenceStore: PersistenceStoreProtocol { - - // MARK: - In-Memory Storage - - public var messages: [UUID: MessageDTO] = [:] - public var contacts: [UUID: ContactDTO] = [:] - public var channels: [UUID: ChannelDTO] = [:] - public var debugLogEntries: [DebugLogEntryDTO] = [] - - // MARK: - Stubbed Errors - - public var stubbedSaveMessageError: Error? - public var stubbedFetchMessageError: Error? - public var stubbedUpdateMessageStatusError: Error? - public var stubbedSaveContactError: Error? - public var stubbedFetchContactError: Error? - public var stubbedDeleteContactError: Error? - public var stubbedSaveChannelError: Error? - public var stubbedFetchChannelError: Error? - public var stubbedDeleteChannelError: Error? - public var stubbedDebugLogError: Error? - - // MARK: - Recorded Invocations - - public private(set) var savedMessages: [MessageDTO] = [] - public private(set) var savedContacts: [ContactDTO] = [] - public private(set) var savedChannels: [ChannelDTO] = [] - public private(set) var deletedContactIDs: [UUID] = [] - public private(set) var deletedChannelIDs: [UUID] = [] - public private(set) var deletedMessagesForContactIDs: [UUID] = [] - public private(set) var deletedMessagesForChannelCalls: [(radioID: UUID, channelIndex: UInt8)] = [] - public private(set) var deletedChannelMessagesFromSenderCalls: [(senderName: String, radioID: UUID)] = [] - public private(set) var updatedMessageStatuses: [(id: UUID, status: MessageStatus)] = [] - public private(set) var updatedMessageAcks: [(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?)] = [] - - // MARK: - Initialization - - public init() {} - - // MARK: - Message Operations - - public func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { - messages.values.contains { $0.deduplicationKey == deduplicationKey && $0.radioID == radioID } - } - - public func saveMessage(_ dto: MessageDTO) async throws { - savedMessages.append(dto) - if let error = stubbedSaveMessageError { - throw error - } - messages[dto.id] = dto - } - - public func fetchMessage(id: UUID) async throws -> MessageDTO? { - if let error = stubbedFetchMessageError { - throw error - } - return messages[id] - } - - public func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { - if let error = stubbedFetchMessageError { throw error } - var result: [UUID: [MessageDTO]] = [:] - for contactID in contactIDs { - let filtered = messages.values.filter { $0.contactID == contactID } - .sorted { $0.timestamp < $1.timestamp } - result[contactID] = Array(filtered.prefix(limit)) - } - return result - } - - public func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { - if let error = stubbedFetchMessageError { throw error } - var result: [UUID: [MessageDTO]] = [:] - for channel in channels { - let filtered = messages.values.filter { $0.radioID == channel.radioID && $0.channelIndex == channel.channelIndex } - .sorted { $0.timestamp < $1.timestamp } - result[channel.id] = Array(filtered.prefix(limit)) - } - return result - } - - public func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { - if let error = stubbedFetchMessageError { - throw error - } - let filtered = messages.values.filter { $0.contactID == contactID } - .sorted { $0.timestamp < $1.timestamp } - return Array(filtered.dropFirst(offset).prefix(limit)) - } - - public func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { - if let error = stubbedFetchMessageError { - throw error - } - let filtered = messages.values.filter { $0.radioID == radioID && $0.channelIndex == channelIndex } - .sorted { $0.timestamp < $1.timestamp } - return Array(filtered.dropFirst(offset).prefix(limit)) - } - - public func findChannelMessageForReaction( - radioID: UUID, - channelIndex: UInt8, - parsedReaction: ParsedReaction, - localNodeName: String?, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> MessageDTO? { - let candidates = try await fetchChannelMessageCandidates( - radioID: radioID, - channelIndex: channelIndex, - timestampWindow: timestampWindow, - limit: limit - ) - - for candidate in candidates { - if candidate.direction == .outgoing { - guard let localNodeName, parsedReaction.targetSender == localNodeName else { - continue - } - } else { - guard candidate.senderNodeName == parsedReaction.targetSender else { - continue - } - } - - let hash = ReactionParser.generateMessageHash( - text: candidate.text, - timestamp: candidate.reactionTimestamp - ) - guard hash == parsedReaction.messageHash else { continue } - - return candidate - } - - return nil - } - - public func fetchChannelMessageCandidates( - radioID: UUID, - channelIndex: UInt8, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> [MessageDTO] { - if let error = stubbedFetchMessageError { - throw error - } - - return messages.values.filter { - $0.radioID == radioID && - $0.channelIndex == channelIndex && - timestampWindow.contains($0.timestamp) - } - .sorted { - if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } - return $0.createdAt > $1.createdAt - } - .prefix(limit) - .map { $0 } - } - - public func fetchDMMessageCandidates( - radioID: UUID, - contactID: UUID, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> [MessageDTO] { - if let error = stubbedFetchMessageError { - throw error - } - - return messages.values.filter { - $0.radioID == radioID && - $0.contactID == contactID && - timestampWindow.contains($0.timestamp) - } - .sorted { - if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } - return $0.createdAt > $1.createdAt - } + // MARK: - In-Memory Storage + + public var messages: [UUID: MessageDTO] = [:] + public var contacts: [UUID: ContactDTO] = [:] + public var channels: [UUID: ChannelDTO] = [:] + public var debugLogEntries: [DebugLogEntryDTO] = [] + + // MARK: - Stubbed Errors + + public var stubbedSaveMessageError: Error? + public var stubbedFetchMessageError: Error? + public var stubbedUpdateMessageStatusError: Error? + public var stubbedSaveContactError: Error? + public var stubbedFetchContactError: Error? + public var stubbedDeleteContactError: Error? + public var stubbedSaveChannelError: Error? + public var stubbedFetchChannelError: Error? + public var stubbedDeleteChannelError: Error? + public var stubbedDebugLogError: Error? + + // MARK: - Recorded Invocations + + public private(set) var savedMessages: [MessageDTO] = [] + public private(set) var savedContacts: [ContactDTO] = [] + public private(set) var savedChannels: [ChannelDTO] = [] + public private(set) var deletedContactIDs: [UUID] = [] + public private(set) var deletedChannelIDs: [UUID] = [] + public private(set) var deletedMessagesForContactIDs: [UUID] = [] + public private(set) var deletedMessagesForChannelCalls: [(radioID: UUID, channelIndex: UInt8)] = [] + public private(set) var deletedChannelMessagesFromSenderCalls: [(senderName: String, radioID: UUID)] = [] + public private(set) var updatedMessageStatuses: [(id: UUID, status: MessageStatus)] = [] + public private(set) var updatedMessageAcks: [(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?)] = [] + + // MARK: - Initialization + + public init() {} + + // MARK: - Message Operations + + public func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { + messages.values.contains { $0.deduplicationKey == deduplicationKey && $0.radioID == radioID } + } + + public func saveMessage(_ dto: MessageDTO) async throws { + savedMessages.append(dto) + if let error = stubbedSaveMessageError { + throw error + } + messages[dto.id] = dto + } + + public func fetchMessage(id: UUID) async throws -> MessageDTO? { + if let error = stubbedFetchMessageError { + throw error + } + return messages[id] + } + + public func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + if let error = stubbedFetchMessageError { throw error } + var result: [UUID: [MessageDTO]] = [:] + for contactID in contactIDs { + let filtered = messages.values.filter { $0.contactID == contactID } + .sorted { $0.timestamp < $1.timestamp } + result[contactID] = Array(filtered.prefix(limit)) + } + return result + } + + public func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + if let error = stubbedFetchMessageError { throw error } + var result: [UUID: [MessageDTO]] = [:] + for channel in channels { + let filtered = messages.values.filter { $0.radioID == channel.radioID && $0.channelIndex == channel.channelIndex } + .sorted { $0.timestamp < $1.timestamp } + result[channel.id] = Array(filtered.prefix(limit)) + } + return result + } + + public func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { + if let error = stubbedFetchMessageError { + throw error + } + let filtered = messages.values.filter { $0.contactID == contactID } + .sorted { $0.timestamp < $1.timestamp } + return Array(filtered.dropFirst(offset).prefix(limit)) + } + + public func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { + if let error = stubbedFetchMessageError { + throw error + } + let filtered = messages.values.filter { $0.radioID == radioID && $0.channelIndex == channelIndex } + .sorted { $0.timestamp < $1.timestamp } + return Array(filtered.dropFirst(offset).prefix(limit)) + } + + public func findChannelMessageForReaction( + radioID: UUID, + channelIndex: UInt8, + parsedReaction: ParsedReaction, + localNodeName: String?, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> MessageDTO? { + let candidates = try await fetchChannelMessageCandidates( + radioID: radioID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: limit + ) + + for candidate in candidates { + if candidate.direction == .outgoing { + guard let localNodeName, parsedReaction.targetSender == localNodeName else { + continue + } + } else { + guard candidate.senderNodeName == parsedReaction.targetSender else { + continue + } + } + + let hash = ReactionParser.generateMessageHash( + text: candidate.text, + timestamp: candidate.reactionTimestamp + ) + guard hash == parsedReaction.messageHash else { continue } + + return candidate + } + + return nil + } + + public func fetchChannelMessageCandidates( + radioID: UUID, + channelIndex: UInt8, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] { + if let error = stubbedFetchMessageError { + throw error + } + + return messages.values.filter { + $0.radioID == radioID && + $0.channelIndex == channelIndex && + timestampWindow.contains($0.timestamp) + } + .sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } + return $0.createdAt > $1.createdAt + } + .prefix(limit) + .map(\.self) + } + + public func fetchDMMessageCandidates( + radioID: UUID, + contactID: UUID, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] { + if let error = stubbedFetchMessageError { + throw error + } + + return messages.values.filter { + $0.radioID == radioID && + $0.contactID == contactID && + timestampWindow.contains($0.timestamp) + } + .sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } + return $0.createdAt > $1.createdAt + } + .prefix(limit) + .map(\.self) + } + + public func findDMMessageForReaction( + radioID: UUID, + contactID: UUID, + messageHash: String, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> MessageDTO? { + let candidates = try await fetchDMMessageCandidates( + radioID: radioID, + contactID: contactID, + timestampWindow: timestampWindow, + limit: limit + ) + + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } + + let hash = ReactionParser.generateMessageHash( + text: candidate.text, + timestamp: candidate.reactionTimestamp + ) + if hash == messageHash { + return candidate + } + } + + return nil + } + + public func updateMessageStatus(id: UUID, status: MessageStatus) async throws { + updatedMessageStatuses.append((id: id, status: status)) + if let error = stubbedUpdateMessageStatusError { + throw error + } + if var message = messages[id] { + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + } + } + + public func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { + guard let message = messages[id], message.status != .delivered else { return false } + try await updateMessageStatus(id: id, status: status) + return true + } + + public func clearRetryingToSent(id: UUID) async throws -> Bool { + guard let message = messages[id], + message.status != .delivered, + message.status != .failed else { return false } + try await updateMessageStatus(id: id, status: .sent) + return true + } + + public func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { + messages.values.contains { + $0.ackCode == ackCode && $0.status == .sent && $0.direction == .outgoing && $0.channelIndex == nil + } + } + + public func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws { + updatedMessageAcks.append((id: id, ackCode: ackCode, status: status, roundTripTime: roundTripTime)) + if let error = stubbedUpdateMessageStatusError { + throw error + } + if var message = messages[id] { + // Mirror PersistenceStore.updateMessageAck: a terminal row + // (.delivered/.failed) is not flipped by a late ACK; only the + // legitimate .sent -> .delivered upgrade is allowed through. + if message.status == .delivered || message.status == .failed, status != message.status { return } + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: status, + textType: message.textType, + ackCode: ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: roundTripTime, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + } + } + + public func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws { + if let error = stubbedUpdateMessageStatusError { + throw error + } + // Mirror PersistenceStore: a terminal row (.delivered/.failed) is not + // overwritten by a stale retry iteration. + if let message = messages[id], message.status != .delivered, message.status != .failed { + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + sendCount: message.sendCount, + retryAttempt: retryAttempt, + maxRetryAttempts: maxRetryAttempts + ) + } + } + + public func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws { + if let message = messages[id] { + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + sendCount: message.sendCount, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + } + } + + public func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws { + if let message = messages[id] { + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + } + } + + public func markMessageAsRead(id: UUID) async throws { + if var message = messages[id] { + message.isRead = true + messages[id] = message + } + } + + public func updateMessageLinkPreview( + id: UUID, + url: String?, + title: String?, + imageData: Data?, + iconData: Data?, + fetched: Bool + ) throws { + if let message = messages[id] { + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts, + linkPreviewURL: url, + linkPreviewTitle: title, + linkPreviewImageData: imageData, + linkPreviewIconData: iconData, + linkPreviewFetched: fetched + ) + } + } + + // MARK: - Contact Operations + + public func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { + if let error = stubbedFetchContactError { + throw error + } + return Array(contacts.values.filter { $0.radioID == radioID }) + } + + public func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { + if let error = stubbedFetchContactError { + throw error + } + return contacts.values + .filter { $0.radioID == radioID && $0.lastMessageDate != nil } + .sorted { ($0.lastMessageDate ?? .distantPast) > ($1.lastMessageDate ?? .distantPast) } + } + + public func fetchContact(id: UUID) async throws -> ContactDTO? { + if let error = stubbedFetchContactError { + throw error + } + return contacts[id] + } + + public func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { + if let error = stubbedFetchContactError { + throw error + } + return contacts.values.first { $0.radioID == radioID && $0.publicKey == publicKey } + } + + public func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { + if let error = stubbedFetchContactError { + throw error + } + return contacts.values.first { $0.radioID == radioID && $0.publicKey.prefix(6) == publicKeyPrefix } + } + + public func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { + if let error = stubbedFetchContactError { + throw error + } + var result: [UInt8: [Data]] = [:] + for contact in contacts.values { + guard contact.radioID == radioID, contact.publicKey.count >= 1 else { continue } + let prefix = contact.publicKey[0] + result[prefix, default: []].append(contact.publicKey) + } + return result + } + + public func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { + if let error = stubbedFetchContactError { + throw error + } + return contacts.values.first { $0.publicKey.prefix(prefix.count) == prefix }?.displayName + } + + public func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { + if let error = stubbedFetchContactError { + throw error + } + return contacts.values.first { $0.publicKey == publicKey } + } + + @discardableResult + public func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { + if let error = stubbedSaveContactError { + throw error + } + // Check if contact already exists + if let existing = contacts.values.first(where: { $0.radioID == radioID && $0.publicKey == frame.publicKey }) { + return existing.id + } + let id = UUID() + let dto = ContactDTO( + id: id, + radioID: radioID, + publicKey: frame.publicKey, + name: frame.name, + typeRawValue: frame.typeRawValue, + flags: frame.flags, + outPathLength: frame.outPathLength, + outPath: frame.outPath, + lastAdvertTimestamp: frame.lastAdvertTimestamp, + latitude: frame.latitude, + longitude: frame.longitude, + lastModified: frame.lastModified, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + contacts[id] = dto + savedContacts.append(dto) + return id + } + + public func saveContact(_ dto: ContactDTO) async throws { + savedContacts.append(dto) + if let error = stubbedSaveContactError { + throw error + } + contacts[dto.id] = dto + } + + public func deleteContact(id: UUID) async throws { + deletedContactIDs.append(id) + if let error = stubbedDeleteContactError { + throw error + } + contacts.removeValue(forKey: id) + } + + public func updateContactLastMessage(contactID: UUID, date: Date?) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: date, + unreadCount: contact.unreadCount, + unreadMentionCount: contact.unreadMentionCount + ) + } + } + + public func incrementUnreadCount(contactID: UUID) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: contact.unreadCount + 1, + unreadMentionCount: contact.unreadMentionCount + ) + } + } + + public func clearUnreadCount(contactID: UUID) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: 0, + unreadMentionCount: contact.unreadMentionCount + ) + } + } + + // MARK: - Mention Tracking + + public func markMentionSeen(messageID: UUID) async throws { + if let message = messages[messageID] { + messages[messageID] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts, + deduplicationKey: message.deduplicationKey, + linkPreviewURL: message.linkPreviewURL, + linkPreviewTitle: message.linkPreviewTitle, + linkPreviewImageData: message.linkPreviewImageData, + linkPreviewIconData: message.linkPreviewIconData, + linkPreviewFetched: message.linkPreviewFetched, + containsSelfMention: message.containsSelfMention, + mentionSeen: true + ) + } + } + + public func incrementUnreadMentionCount(contactID: UUID) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: contact.unreadCount, + unreadMentionCount: contact.unreadMentionCount + 1 + ) + } + } + + public func decrementUnreadMentionCount(contactID: UUID) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: contact.unreadCount, + unreadMentionCount: max(0, contact.unreadMentionCount - 1) + ) + } + } + + public func clearUnreadMentionCount(contactID: UUID) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + radioID: contact.radioID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: contact.unreadCount, + unreadMentionCount: 0 + ) + } + } + + public func incrementChannelUnreadMentionCount(channelID: UUID) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: channel.unreadCount, + unreadMentionCount: channel.unreadMentionCount + 1, + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func decrementChannelUnreadMentionCount(channelID: UUID) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: channel.unreadCount, + unreadMentionCount: max(0, channel.unreadMentionCount - 1), + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func clearChannelUnreadMentionCount(channelID: UUID) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: channel.unreadCount, + unreadMentionCount: 0, + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { + messages.values + .filter { $0.contactID == contactID && $0.containsSelfMention && !$0.mentionSeen } + .sorted { $0.timestamp < $1.timestamp } + .map(\.id) + } + + public func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { + messages.values + .filter { $0.radioID == radioID && $0.channelIndex == channelIndex && $0.containsSelfMention && !$0.mentionSeen } + .sorted { $0.timestamp < $1.timestamp } + .map(\.id) + } + + public func deleteMessagesForContact(contactID: UUID) async throws { + deletedMessagesForContactIDs.append(contactID) + messages = messages.filter { $0.value.contactID != contactID } + } + + public func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { + if let error = stubbedFetchContactError { + throw error + } + return Array(contacts.values.filter { $0.radioID == radioID && $0.isBlocked }) + } + + // MARK: - Blocked Channel Senders + + public var blockedChannelSenders: [String: BlockedChannelSenderDTO] = [:] + public private(set) var savedBlockedChannelSenders: [BlockedChannelSenderDTO] = [] + public private(set) var deletedBlockedChannelSenderNames: [(radioID: UUID, name: String)] = [] + + public func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws { + savedBlockedChannelSenders.append(dto) + blockedChannelSenders["\(dto.radioID)-\(dto.name)"] = dto + } + + public func deleteBlockedChannelSender(radioID: UUID, name: String) async throws { + deletedBlockedChannelSenderNames.append((radioID: radioID, name: name)) + blockedChannelSenders.removeValue(forKey: "\(radioID)-\(name)") + } + + public func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { + Array(blockedChannelSenders.values.filter { $0.radioID == radioID }) + } + + // MARK: - Channel Operations + + public func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { + if let error = stubbedFetchChannelError { + throw error + } + return channels.values.filter { $0.radioID == radioID }.sorted { $0.index < $1.index } + } + + public func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { + if let error = stubbedFetchChannelError { + throw error + } + return channels.values.first { $0.radioID == radioID && $0.index == index } + } + + public func fetchChannel(id: UUID) async throws -> ChannelDTO? { + if let error = stubbedFetchChannelError { + throw error + } + return channels[id] + } + + @discardableResult + public func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { + if let error = stubbedSaveChannelError { + throw error + } + // Check if channel already exists + if let existing = channels.values.first(where: { $0.radioID == radioID && $0.index == info.index }) { + // Update existing + channels[existing.id] = ChannelDTO( + id: existing.id, + radioID: radioID, + index: info.index, + name: info.name, + secret: info.secret, + isEnabled: !info.name.isEmpty, + lastMessageDate: existing.lastMessageDate, + unreadCount: existing.unreadCount, + unreadMentionCount: existing.unreadMentionCount, + notificationLevel: existing.notificationLevel, + isFavorite: existing.isFavorite + ) + return existing.id + } + let id = UUID() + let dto = ChannelDTO( + id: id, + radioID: radioID, + index: info.index, + name: info.name, + secret: info.secret, + isEnabled: !info.name.isEmpty, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + channels[id] = dto + savedChannels.append(dto) + return id + } + + public func saveChannel(_ dto: ChannelDTO) async throws { + savedChannels.append(dto) + if let error = stubbedSaveChannelError { + throw error + } + channels[dto.id] = dto + } + + public func deleteChannel(id: UUID) async throws { + deletedChannelIDs.append(id) + if let error = stubbedDeleteChannelError { + throw error + } + channels.removeValue(forKey: id) + } + + public func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws { + deletedMessagesForChannelCalls.append((radioID: radioID, channelIndex: channelIndex)) + messages = messages.filter { $0.value.radioID != radioID || $0.value.channelIndex != channelIndex } + } + + public func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws { + deletedChannelMessagesFromSenderCalls.append((senderName: senderName, radioID: radioID)) + messages = messages.filter { _, msg in + !(msg.senderNodeName == senderName && msg.radioID == radioID && msg.channelIndex != nil) + } + } + + public func updateChannelLastMessage(channelID: UUID, date: Date?) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: date, + unreadCount: channel.unreadCount, + unreadMentionCount: channel.unreadMentionCount, + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func incrementChannelUnreadCount(channelID: UUID) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: channel.unreadCount + 1, + unreadMentionCount: channel.unreadMentionCount, + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws { + if let channel = channels.values.first(where: { $0.radioID == radioID && $0.index == index }) { + try await clearChannelUnreadCount(channelID: channel.id) + } + } + + public func clearChannelUnreadCount(channelID: UUID) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: 0, + unreadMentionCount: channel.unreadMentionCount, + notificationLevel: channel.notificationLevel, + isFavorite: channel.isFavorite + ) + } + } + + public func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws { + if let channel = channels[channelID] { + channels[channelID] = ChannelDTO( + id: channel.id, + radioID: channel.radioID, + index: channel.index, + name: channel.name, + secret: channel.secret, + isEnabled: channel.isEnabled, + lastMessageDate: channel.lastMessageDate, + unreadCount: channel.unreadCount, + unreadMentionCount: channel.unreadMentionCount, + notificationLevel: level, + isFavorite: channel.isFavorite + ) + } + } + + public func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws { + // Stub - sessions not tracked in mock + } + + // MARK: - RxLogEntry Lookup + + private var mockRxLogEntries: [RxLogEntryDTO] = [] + + public func setMockRxLogEntry(_ entry: RxLogEntryDTO) { + mockRxLogEntries.append(entry) + } + + public func findRxLogEntry( + radioID: UUID, + channelIndex: UInt8?, + senderTimestamp: UInt32 + ) throws -> RxLogEntryDTO? { + if let channelIndex { + mockRxLogEntries.first { entry in + entry.radioID == radioID && + entry.channelIndex == channelIndex && + entry.senderTimestamp == senderTimestamp + } + } else { + mockRxLogEntries.first { entry in + entry.radioID == radioID && + entry.senderTimestamp == senderTimestamp && + entry.channelIndex == nil + } + } + } + + public func findRxLogEntryBySenderPrefix( + radioID: UUID, + senderPrefixByte: UInt8, + receivedSince: Date + ) throws -> RxLogEntryDTO? { + mockRxLogEntries.first { entry in + entry.radioID == radioID && + entry.channelIndex == nil && + entry.payloadType == .textMessage && + entry.receivedAt >= receivedSince && + entry.packetPayload.count >= 2 && + entry.packetPayload[1] == senderPrefixByte + } + } + + // MARK: - RX Log + + public private(set) var updatedRxLogRegions: [(id: UUID, regionScope: String?)] = [] + public private(set) var updatedChannelMessageRegions: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] + public private(set) var updatedDMMessageRegions: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] + + public func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws { + mockRxLogEntries.append(dto) + } + + public func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { + Array( + mockRxLogEntries + .filter { $0.radioID == radioID } + .sorted { $0.receivedAt > $1.receivedAt } .prefix(limit) - .map { $0 } - } - - public func findDMMessageForReaction( - radioID: UUID, - contactID: UUID, - messageHash: String, - timestampWindow: ClosedRange, - limit: Int - ) async throws -> MessageDTO? { - let candidates = try await fetchDMMessageCandidates( - radioID: radioID, - contactID: contactID, - timestampWindow: timestampWindow, - limit: limit - ) - - for candidate in candidates { - // Skip messages that are themselves reactions - if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } - - let hash = ReactionParser.generateMessageHash( - text: candidate.text, - timestamp: candidate.reactionTimestamp - ) - if hash == messageHash { - return candidate - } - } - - return nil - } - - public func updateMessageStatus(id: UUID, status: MessageStatus) async throws { - updatedMessageStatuses.append((id: id, status: status)) - if let error = stubbedUpdateMessageStatusError { - throw error - } - if var message = messages[id] { - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - } - } - - public func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { - guard let message = messages[id], message.status != .delivered else { return false } - try await updateMessageStatus(id: id, status: status) - return true - } - - public func clearRetryingToSent(id: UUID) async throws -> Bool { - guard let message = messages[id], - message.status != .delivered, - message.status != .failed else { return false } - try await updateMessageStatus(id: id, status: .sent) - return true - } - - public func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { - messages.values.contains { - $0.ackCode == ackCode && $0.status == .sent && $0.direction == .outgoing && $0.channelIndex == nil - } - } - - public func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws { - updatedMessageAcks.append((id: id, ackCode: ackCode, status: status, roundTripTime: roundTripTime)) - if let error = stubbedUpdateMessageStatusError { - throw error - } - if var message = messages[id] { - // Mirror PersistenceStore.updateMessageAck: a terminal row - // (.delivered/.failed) is not flipped by a late ACK; only the - // legitimate .sent -> .delivered upgrade is allowed through. - if (message.status == .delivered || message.status == .failed) && status != message.status { return } - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: status, - textType: message.textType, - ackCode: ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: roundTripTime, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - } - } - - public func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws { - if let error = stubbedUpdateMessageStatusError { - throw error - } - // Mirror PersistenceStore: a terminal row (.delivered/.failed) is not - // overwritten by a stale retry iteration. - if let message = messages[id], message.status != .delivered, message.status != .failed { - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - sendCount: message.sendCount, - retryAttempt: retryAttempt, - maxRetryAttempts: maxRetryAttempts - ) - } - } - - public func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws { - if let message = messages[id] { - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - sendCount: message.sendCount, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - } - } - - public func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws { - if let message = messages[id] { - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - } - } - - public func markMessageAsRead(id: UUID) async throws { - if var message = messages[id] { - message.isRead = true - messages[id] = message - } - } - - public func updateMessageLinkPreview( - id: UUID, - url: String?, - title: String?, - imageData: Data?, - iconData: Data?, - fetched: Bool - ) throws { - if let message = messages[id] { - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts, - linkPreviewURL: url, - linkPreviewTitle: title, - linkPreviewImageData: imageData, - linkPreviewIconData: iconData, - linkPreviewFetched: fetched - ) - } - } - - // MARK: - Contact Operations - - public func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { - if let error = stubbedFetchContactError { - throw error - } - return Array(contacts.values.filter { $0.radioID == radioID }) - } - - public func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { - if let error = stubbedFetchContactError { - throw error - } - return contacts.values - .filter { $0.radioID == radioID && $0.lastMessageDate != nil } - .sorted { ($0.lastMessageDate ?? .distantPast) > ($1.lastMessageDate ?? .distantPast) } - } - - public func fetchContact(id: UUID) async throws -> ContactDTO? { - if let error = stubbedFetchContactError { - throw error - } - return contacts[id] - } - - public func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { - if let error = stubbedFetchContactError { - throw error - } - return contacts.values.first { $0.radioID == radioID && $0.publicKey == publicKey } - } - - public func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { - if let error = stubbedFetchContactError { - throw error - } - return contacts.values.first { $0.radioID == radioID && $0.publicKey.prefix(6) == publicKeyPrefix } - } - - public func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { - if let error = stubbedFetchContactError { - throw error - } - var result: [UInt8: [Data]] = [:] - for contact in contacts.values { - guard contact.radioID == radioID, contact.publicKey.count >= 1 else { continue } - let prefix = contact.publicKey[0] - result[prefix, default: []].append(contact.publicKey) - } - return result - } - - public func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { - if let error = stubbedFetchContactError { - throw error - } - return contacts.values.first { $0.publicKey.prefix(prefix.count) == prefix }?.displayName - } - - public func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { - if let error = stubbedFetchContactError { - throw error - } - return contacts.values.first { $0.publicKey == publicKey } - } - - @discardableResult - public func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { - if let error = stubbedSaveContactError { - throw error - } - // Check if contact already exists - if let existing = contacts.values.first(where: { $0.radioID == radioID && $0.publicKey == frame.publicKey }) { - return existing.id - } - let id = UUID() - let dto = ContactDTO( - id: id, - radioID: radioID, - publicKey: frame.publicKey, - name: frame.name, - typeRawValue: frame.typeRawValue, - flags: frame.flags, - outPathLength: frame.outPathLength, - outPath: frame.outPath, - lastAdvertTimestamp: frame.lastAdvertTimestamp, - latitude: frame.latitude, - longitude: frame.longitude, - lastModified: frame.lastModified, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - contacts[id] = dto - savedContacts.append(dto) - return id - } - - public func saveContact(_ dto: ContactDTO) async throws { - savedContacts.append(dto) - if let error = stubbedSaveContactError { - throw error - } - contacts[dto.id] = dto - } - - public func deleteContact(id: UUID) async throws { - deletedContactIDs.append(id) - if let error = stubbedDeleteContactError { - throw error - } - contacts.removeValue(forKey: id) - } - - public func updateContactLastMessage(contactID: UUID, date: Date?) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: date, - unreadCount: contact.unreadCount, - unreadMentionCount: contact.unreadMentionCount - ) - } - } - - public func incrementUnreadCount(contactID: UUID) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: contact.lastMessageDate, - unreadCount: contact.unreadCount + 1, - unreadMentionCount: contact.unreadMentionCount - ) - } - } - - public func clearUnreadCount(contactID: UUID) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: contact.lastMessageDate, - unreadCount: 0, - unreadMentionCount: contact.unreadMentionCount - ) - } - } - - // MARK: - Mention Tracking - - public func markMentionSeen(messageID: UUID) async throws { - if let message = messages[messageID] { - messages[messageID] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts, - deduplicationKey: message.deduplicationKey, - linkPreviewURL: message.linkPreviewURL, - linkPreviewTitle: message.linkPreviewTitle, - linkPreviewImageData: message.linkPreviewImageData, - linkPreviewIconData: message.linkPreviewIconData, - linkPreviewFetched: message.linkPreviewFetched, - containsSelfMention: message.containsSelfMention, - mentionSeen: true - ) - } - } - - public func incrementUnreadMentionCount(contactID: UUID) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: contact.lastMessageDate, - unreadCount: contact.unreadCount, - unreadMentionCount: contact.unreadMentionCount + 1 - ) - } - } - - public func decrementUnreadMentionCount(contactID: UUID) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: contact.lastMessageDate, - unreadCount: contact.unreadCount, - unreadMentionCount: max(0, contact.unreadMentionCount - 1) - ) - } - } - - public func clearUnreadMentionCount(contactID: UUID) async throws { - if let contact = contacts[contactID] { - contacts[contactID] = ContactDTO( - id: contact.id, - radioID: contact.radioID, - publicKey: contact.publicKey, - name: contact.name, - typeRawValue: contact.typeRawValue, - flags: contact.flags, - outPathLength: contact.outPathLength, - outPath: contact.outPath, - lastAdvertTimestamp: contact.lastAdvertTimestamp, - latitude: contact.latitude, - longitude: contact.longitude, - lastModified: contact.lastModified, - nickname: contact.nickname, - isBlocked: contact.isBlocked, - isMuted: contact.isMuted, - isFavorite: contact.isFavorite, - lastMessageDate: contact.lastMessageDate, - unreadCount: contact.unreadCount, - unreadMentionCount: 0 - ) - } - } - - public func incrementChannelUnreadMentionCount(channelID: UUID) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: channel.unreadCount, - unreadMentionCount: channel.unreadMentionCount + 1, - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func decrementChannelUnreadMentionCount(channelID: UUID) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: channel.unreadCount, - unreadMentionCount: max(0, channel.unreadMentionCount - 1), - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func clearChannelUnreadMentionCount(channelID: UUID) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: channel.unreadCount, - unreadMentionCount: 0, - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { - messages.values - .filter { $0.contactID == contactID && $0.containsSelfMention && !$0.mentionSeen } - .sorted { $0.timestamp < $1.timestamp } - .map(\.id) - } - - public func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { - messages.values - .filter { $0.radioID == radioID && $0.channelIndex == channelIndex && $0.containsSelfMention && !$0.mentionSeen } - .sorted { $0.timestamp < $1.timestamp } - .map(\.id) - } - - public func deleteMessagesForContact(contactID: UUID) async throws { - deletedMessagesForContactIDs.append(contactID) - messages = messages.filter { $0.value.contactID != contactID } - } - - public func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { - if let error = stubbedFetchContactError { - throw error - } - return Array(contacts.values.filter { $0.radioID == radioID && $0.isBlocked }) - } - - // MARK: - Blocked Channel Senders - - public var blockedChannelSenders: [String: BlockedChannelSenderDTO] = [:] - public private(set) var savedBlockedChannelSenders: [BlockedChannelSenderDTO] = [] - public private(set) var deletedBlockedChannelSenderNames: [(radioID: UUID, name: String)] = [] - - public func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws { - savedBlockedChannelSenders.append(dto) - blockedChannelSenders["\(dto.radioID)-\(dto.name)"] = dto - } - - public func deleteBlockedChannelSender(radioID: UUID, name: String) async throws { - deletedBlockedChannelSenderNames.append((radioID: radioID, name: name)) - blockedChannelSenders.removeValue(forKey: "\(radioID)-\(name)") - } - - public func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { - Array(blockedChannelSenders.values.filter { $0.radioID == radioID }) - } - - // MARK: - Channel Operations - - public func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { - if let error = stubbedFetchChannelError { - throw error - } - return channels.values.filter { $0.radioID == radioID }.sorted { $0.index < $1.index } - } - - public func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { - if let error = stubbedFetchChannelError { - throw error - } - return channels.values.first { $0.radioID == radioID && $0.index == index } - } - - public func fetchChannel(id: UUID) async throws -> ChannelDTO? { - if let error = stubbedFetchChannelError { - throw error - } - return channels[id] - } - - @discardableResult - public func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { - if let error = stubbedSaveChannelError { - throw error - } - // Check if channel already exists - if let existing = channels.values.first(where: { $0.radioID == radioID && $0.index == info.index }) { - // Update existing - channels[existing.id] = ChannelDTO( - id: existing.id, - radioID: radioID, - index: info.index, - name: info.name, - secret: info.secret, - isEnabled: !info.name.isEmpty, - lastMessageDate: existing.lastMessageDate, - unreadCount: existing.unreadCount, - unreadMentionCount: existing.unreadMentionCount, - notificationLevel: existing.notificationLevel, - isFavorite: existing.isFavorite - ) - return existing.id - } - let id = UUID() - let dto = ChannelDTO( - id: id, - radioID: radioID, - index: info.index, - name: info.name, - secret: info.secret, - isEnabled: !info.name.isEmpty, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false - ) - channels[id] = dto - savedChannels.append(dto) - return id - } - - public func saveChannel(_ dto: ChannelDTO) async throws { - savedChannels.append(dto) - if let error = stubbedSaveChannelError { - throw error - } - channels[dto.id] = dto - } - - public func deleteChannel(id: UUID) async throws { - deletedChannelIDs.append(id) - if let error = stubbedDeleteChannelError { - throw error - } - channels.removeValue(forKey: id) - } - - public func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws { - deletedMessagesForChannelCalls.append((radioID: radioID, channelIndex: channelIndex)) - messages = messages.filter { $0.value.radioID != radioID || $0.value.channelIndex != channelIndex } - } - - public func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws { - deletedChannelMessagesFromSenderCalls.append((senderName: senderName, radioID: radioID)) - messages = messages.filter { _, msg in - !(msg.senderNodeName == senderName && msg.radioID == radioID && msg.channelIndex != nil) - } - } - - public func updateChannelLastMessage(channelID: UUID, date: Date?) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: date, - unreadCount: channel.unreadCount, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func incrementChannelUnreadCount(channelID: UUID) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: channel.unreadCount + 1, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws { - if let channel = channels.values.first(where: { $0.radioID == radioID && $0.index == index }) { - try await clearChannelUnreadCount(channelID: channel.id) - } - } - - public func clearChannelUnreadCount(channelID: UUID) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: 0, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: channel.notificationLevel, - isFavorite: channel.isFavorite - ) - } - } - - public func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws { - if let channel = channels[channelID] { - channels[channelID] = ChannelDTO( - id: channel.id, - radioID: channel.radioID, - index: channel.index, - name: channel.name, - secret: channel.secret, - isEnabled: channel.isEnabled, - lastMessageDate: channel.lastMessageDate, - unreadCount: channel.unreadCount, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: level, - isFavorite: channel.isFavorite - ) - } - } - - public func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws { - // Stub - sessions not tracked in mock - } - - // MARK: - RxLogEntry Lookup - - private var mockRxLogEntries: [RxLogEntryDTO] = [] - - public func setMockRxLogEntry(_ entry: RxLogEntryDTO) { - mockRxLogEntries.append(entry) - } - - public func findRxLogEntry( - radioID: UUID, - channelIndex: UInt8?, - senderTimestamp: UInt32 - ) throws -> RxLogEntryDTO? { - if let channelIndex { - return mockRxLogEntries.first { entry in - entry.radioID == radioID && - entry.channelIndex == channelIndex && - entry.senderTimestamp == senderTimestamp - } - } else { - return mockRxLogEntries.first { entry in - entry.radioID == radioID && - entry.senderTimestamp == senderTimestamp && - entry.channelIndex == nil - } - } - } - - public func findRxLogEntryBySenderPrefix( - radioID: UUID, - senderPrefixByte: UInt8, - receivedSince: Date - ) throws -> RxLogEntryDTO? { - mockRxLogEntries.first { entry in - entry.radioID == radioID && - entry.channelIndex == nil && - entry.payloadType == .textMessage && - entry.receivedAt >= receivedSince && - entry.packetPayload.count >= 2 && - entry.packetPayload[1] == senderPrefixByte - } - } - - // MARK: - RX Log - - public private(set) var updatedRxLogRegions: [(id: UUID, regionScope: String?)] = [] - public private(set) var updatedChannelMessageRegions: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] - public private(set) var updatedDMMessageRegions: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] - - public func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws { - mockRxLogEntries.append(dto) - } - - public func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { - Array( - mockRxLogEntries - .filter { $0.radioID == radioID } - .sorted { $0.receivedAt > $1.receivedAt } - .prefix(limit) - ) - } - - public func clearRxLogEntries(radioID: UUID) async throws { - mockRxLogEntries.removeAll { $0.radioID == radioID } - } - - public func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws { - let entries = mockRxLogEntries.filter { $0.radioID == radioID } - guard entries.count > keepCount + pruneThreshold else { return } - let keptIDs = Set(entries.sorted { $0.receivedAt > $1.receivedAt }.prefix(keepCount).map(\.id)) - mockRxLogEntries.removeAll { $0.radioID == radioID && !keptIDs.contains($0.id) } - } - - public func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { - mockRxLogEntries.filter { $0.radioID == radioID && $0.transportCode != nil && $0.regionScope == nil } - } - - public func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { - mockRxLogEntries.filter { $0.radioID == radioID && $0.decryptStatus == status && $0.receivedAt >= since } - } - - public func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws { - updatedRxLogRegions.append(contentsOf: updates) - } - - public func batchUpdateRxLogDecryption( - _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] - ) async throws { - for update in updates { - guard let index = mockRxLogEntries.firstIndex(where: { $0.id == update.id }) else { continue } - mockRxLogEntries[index].channelIndex = update.channelIndex - mockRxLogEntries[index].channelName = update.channelName - mockRxLogEntries[index].senderTimestamp = update.senderTimestamp - } - } - - public func batchUpdateChannelMessageRegion( - radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws { - updatedChannelMessageRegions.append(contentsOf: updates) - } - - public func batchUpdateDMMessageRegion( - radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws { - updatedDMMessageRegions.append(contentsOf: updates) - } - - // MARK: - Saved Trace Paths - - public var savedTracePaths: [UUID: SavedTracePathDTO] = [:] - - public func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { - savedTracePaths.values.filter { $0.radioID == radioID }.sorted(by: { $0.createdDate > $1.createdDate }) - } - - public func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { - savedTracePaths[id] - } - - public func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { - let id = UUID() - let runs = initialRun.map { [$0] } ?? [] - let dto = SavedTracePathDTO( - id: id, - radioID: radioID, - name: name, - pathBytes: pathBytes, - hashSize: hashSize, - createdDate: Date(), - runs: runs - ) - savedTracePaths[id] = dto - return dto - } - - public func updateSavedTracePathName(id: UUID, name: String) async throws { - if let path = savedTracePaths[id] { - savedTracePaths[id] = SavedTracePathDTO( - id: path.id, - radioID: path.radioID, - name: name, - pathBytes: path.pathBytes, - hashSize: path.hashSize, - createdDate: path.createdDate, - runs: path.runs - ) - } - } - - public func deleteSavedTracePath(id: UUID) async throws { - savedTracePaths.removeValue(forKey: id) - } - - public func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws { - if let path = savedTracePaths[pathID] { - var runs = path.runs - runs.append(run) - savedTracePaths[pathID] = SavedTracePathDTO( - id: path.id, - radioID: path.radioID, - name: path.name, - pathBytes: path.pathBytes, - hashSize: path.hashSize, - createdDate: path.createdDate, - runs: runs - ) - } - } - - // MARK: - Heard Repeats - - public func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? { - return nil // Stub - } - - public func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws { - // Stub - no-op - } - - public func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { - return [] // Stub - } - - public func deleteMessageRepeats(messageID: UUID) async throws { - // Stub - no-op - } - - public func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { - return false // Stub - } - - public func incrementMessageHeardRepeats(id: UUID) async throws -> Int { - return 0 // Stub - } - - public func incrementMessageSendCount(id: UUID) async throws -> Int { - if let message = messages[id] { - let newCount = message.sendCount + 1 - messages[id] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - sendCount: newCount, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - return newCount - } - return 0 - } - - // MARK: - Debug Log Operations - - public func saveDebugLogEntries(_ entries: [DebugLogEntryDTO]) async throws { - if let error = stubbedDebugLogError { - throw error - } - debugLogEntries.append(contentsOf: entries) - } - - public func fetchDebugLogEntries(since: Date, limit: Int) async throws -> [DebugLogEntryDTO] { - if let error = stubbedDebugLogError { - throw error - } - return debugLogEntries - .filter { $0.timestamp >= since } - .sorted { $0.timestamp < $1.timestamp } - .prefix(limit) - .map { $0 } - } - - public func countDebugLogEntries() async throws -> Int { - if let error = stubbedDebugLogError { - throw error - } - return debugLogEntries.count - } - - public func pruneDebugLogEntries(keepCount: Int) async throws { - if let error = stubbedDebugLogError { - throw error - } - let sorted = debugLogEntries.sorted { $0.timestamp > $1.timestamp } - let toKeep = Set(sorted.prefix(keepCount).map { $0.id }) - debugLogEntries.removeAll { !toKeep.contains($0.id) } - } - - public func clearDebugLogEntries() async throws { - if let error = stubbedDebugLogError { - throw error - } - debugLogEntries.removeAll() - } - - // MARK: - Link Preview Cache - - public var linkPreviews: [String: LinkPreviewDataDTO] = [:] - - public func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { - linkPreviews[url] - } - - public func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws { - linkPreviews[dto.url] = dto - } - - // MARK: - Device Operations - - /// Devices keyed by radio ID - public var devices: [UUID: DeviceDTO] = [:] - public private(set) var updatedLastContactSyncs: [(radioID: UUID, timestamp: UInt32)] = [] - - public func fetchDevice(id: UUID) async throws -> DeviceDTO? { - devices.values.first { $0.id == id } - } - - public func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { - devices[radioID] - } - - public func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws { - updatedLastContactSyncs.append((radioID: radioID, timestamp: timestamp)) - if var device = devices[radioID] { - device.lastContactSync = timestamp - devices[radioID] = device - } - } - - public func saveDevice(_ dto: DeviceDTO) async throws { - devices[dto.radioID] = dto - } - - // MARK: - Room Session State - - public var remoteNodeSessions: [UUID: RemoteNodeSessionDTO] = [:] - public private(set) var markedDisconnectedSessionIDs: [UUID] = [] - public private(set) var markedConnectedSessionIDs: [UUID] = [] - public private(set) var updatedRoomActivitySessionIDs: [UUID] = [] - public private(set) var savedRemoteNodeSessions: [RemoteNodeSessionDTO] = [] - public private(set) var updatedSessionConnections: [(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel)] = [] - public private(set) var deletedRemoteNodeSessionIDs: [UUID] = [] - - public func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { - remoteNodeSessions[id] - } - - public func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { - remoteNodeSessions.values.first { $0.publicKey == publicKey } - } - - public func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { - remoteNodeSessions.values.first { $0.publicKey.prefix(6) == prefix } - } - - public func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { - remoteNodeSessions.values.filter { $0.radioID == radioID } - } - - public func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { - remoteNodeSessions.values.filter(\.isConnected) - } - - public func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws { - savedRemoteNodeSessions.append(dto) - remoteNodeSessions[dto.id] = dto - } - - public func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws { - updatedSessionConnections.append((id: id, isConnected: isConnected, permissionLevel: permissionLevel)) - if let session = remoteNodeSessions[id] { - remoteNodeSessions[id] = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - latitude: session.latitude, - longitude: session.longitude, - isConnected: isConnected, - permissionLevel: permissionLevel, - lastConnectedDate: session.lastConnectedDate, - lastBatteryMillivolts: session.lastBatteryMillivolts, - lastUptimeSeconds: session.lastUptimeSeconds, - lastNoiseFloor: session.lastNoiseFloor, - unreadCount: session.unreadCount, - notificationLevel: session.notificationLevel, - lastRxAirtimeSeconds: session.lastRxAirtimeSeconds, - neighborCount: session.neighborCount, - lastSyncTimestamp: session.lastSyncTimestamp, - lastMessageDate: session.lastMessageDate - ) - } - } - - public func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws { - let duplicateIDs = remoteNodeSessions.values - .filter { $0.publicKey == publicKey && $0.id != keepID } - .map(\.id) - for id in duplicateIDs { - remoteNodeSessions.removeValue(forKey: id) - } - } - - public func deleteRemoteNodeSession(id: UUID) async throws { - deletedRemoteNodeSessionIDs.append(id) - remoteNodeSessions.removeValue(forKey: id) - roomMessages = roomMessages.filter { $0.value.sessionID != id } - } - - public func markSessionDisconnected(_ sessionID: UUID) throws { - markedDisconnectedSessionIDs.append(sessionID) - } - - @discardableResult - public func markRoomSessionConnected(_ sessionID: UUID) throws -> Bool { - markedConnectedSessionIDs.append(sessionID) - return true - } - - public func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) throws { - updatedRoomActivitySessionIDs.append(sessionID) - } - - // MARK: - Room Message Operations - - public var roomMessages: [UUID: RoomMessageDTO] = [:] - public private(set) var savedRoomMessages: [RoomMessageDTO] = [] - public private(set) var updatedRoomMessageStatuses: [(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?)] = [] - - public func saveRoomMessage(_ dto: RoomMessageDTO) async throws { - savedRoomMessages.append(dto) - roomMessages[dto.id] = dto - } - - public func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { - roomMessages[id] - } - - public func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { - let filtered = roomMessages.values.filter { $0.sessionID == sessionID } - .sorted { $0.timestamp < $1.timestamp } - var result = Array(filtered) - if let offset { - result = Array(result.dropFirst(offset)) - } - if let limit { - result = Array(result.prefix(limit)) - } - return result - } - - public func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { - roomMessages.values.contains { $0.sessionID == sessionID && $0.deduplicationKey == deduplicationKey } - } - - public func updateRoomMessageStatus( - id: UUID, - status: MessageStatus, - ackCode: UInt32?, - roundTripTime: UInt32? - ) async throws { - updatedRoomMessageStatuses.append((id: id, status: status, ackCode: ackCode, roundTripTime: roundTripTime)) - if let message = roomMessages[id] { - roomMessages[id] = RoomMessageDTO( - id: message.id, - sessionID: message.sessionID, - authorKeyPrefix: message.authorKeyPrefix, - authorName: message.authorName, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - isFromSelf: message.isFromSelf, - status: status, - ackCode: ackCode ?? message.ackCode, - roundTripTime: roundTripTime ?? message.roundTripTime, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts - ) - } - } - - public func updateRoomMessageRetryStatus( - id: UUID, - status: MessageStatus, - retryAttempt: Int, - maxRetryAttempts: Int - ) async throws { - if let message = roomMessages[id] { - roomMessages[id] = RoomMessageDTO( - id: message.id, - sessionID: message.sessionID, - authorKeyPrefix: message.authorKeyPrefix, - authorName: message.authorName, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - isFromSelf: message.isFromSelf, - status: status, - ackCode: message.ackCode, - roundTripTime: message.roundTripTime, - retryAttempt: retryAttempt, - maxRetryAttempts: maxRetryAttempts - ) - } - } - - public private(set) var incrementedRoomUnreadSessionIDs: [UUID] = [] - public private(set) var resetRoomUnreadSessionIDs: [UUID] = [] - - public func incrementRoomUnreadCount(_ sessionID: UUID) async throws { - incrementedRoomUnreadSessionIDs.append(sessionID) - } - - public func resetRoomUnreadCount(_ sessionID: UUID) async throws { - resetRoomUnreadSessionIDs.append(sessionID) - } - - // MARK: - Discovered Nodes - - public var discoveredNodes: [UUID: DiscoveredNodeDTO] = [:] - - public func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { - if let existing = discoveredNodes.values.first(where: { $0.radioID == radioID && $0.publicKey == frame.publicKey }) { - let updated = DiscoveredNodeDTO( - id: existing.id, - radioID: radioID, - publicKey: frame.publicKey, - name: frame.name, - typeRawValue: frame.type.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: frame.lastAdvertTimestamp, - latitude: frame.latitude, - longitude: frame.longitude, - outPathLength: frame.outPathLength, - outPath: frame.outPath, - inboundHopCount: existing.inboundHopCount, - inboundHopAdvertTimestamp: existing.inboundHopAdvertTimestamp - ) - discoveredNodes[existing.id] = updated - return (node: updated, isNew: false) - } - - let id = UUID() - let dto = DiscoveredNodeDTO( - id: id, - radioID: radioID, - publicKey: frame.publicKey, - name: frame.name, - typeRawValue: frame.type.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: frame.lastAdvertTimestamp, - latitude: frame.latitude, - longitude: frame.longitude, - outPathLength: frame.outPathLength, - outPath: frame.outPath, - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - discoveredNodes[id] = dto - return (node: dto, isNew: true) - } - - /// Records every `setInboundHopCount` call for assertion and applies the same adopt rule as the - /// production store so tests can observe the resulting stored value via `discoveredNodes`. - public private(set) var inboundHopCountCalls: [(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?)] = [] - - public func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws { - inboundHopCountCalls.append((radioID: radioID, publicKey: publicKey, hopCount: hopCount, advertTimestamp: advertTimestamp)) - guard let existing = discoveredNodes.values.first(where: { - $0.radioID == radioID && $0.publicKey == publicKey - }) else { return } - guard let adopted = adoptInboundHop( - storedHops: existing.inboundHopCount, - storedTimestamp: existing.inboundHopAdvertTimestamp, - incomingHops: hopCount, - incomingTimestamp: advertTimestamp - ) else { return } - discoveredNodes[existing.id] = DiscoveredNodeDTO( - id: existing.id, - radioID: existing.radioID, - publicKey: existing.publicKey, - name: existing.name, - typeRawValue: existing.typeRawValue, - lastHeard: existing.lastHeard, - lastAdvertTimestamp: existing.lastAdvertTimestamp, - latitude: existing.latitude, - longitude: existing.longitude, - outPathLength: existing.outPathLength, - outPath: existing.outPath, - inboundHopCount: adopted.hopCount, - inboundHopAdvertTimestamp: adopted.advertTimestamp - ) - } - - public func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { - discoveredNodes.values.filter { $0.radioID == radioID } - } - - public func deleteDiscoveredNode(id: UUID) async throws { - discoveredNodes.removeValue(forKey: id) - } - - public func clearDiscoveredNodes(radioID: UUID) async throws { - let keysToRemove = discoveredNodes.values - .filter { $0.radioID == radioID } - .map(\.id) - for key in keysToRemove { - discoveredNodes.removeValue(forKey: key) - } - } - - public func fetchContactPublicKeys(radioID: UUID) async throws -> Set { - if let error = stubbedFetchContactError { - throw error - } - return Set(contacts.values.filter { $0.radioID == radioID }.map(\.publicKey)) - } - - // MARK: - Reactions - - public var reactions: [UUID: [ReactionDTO]] = [:] - public private(set) var savedReactions: [ReactionDTO] = [] - public private(set) var deletedReactionsForMessageIDs: [UUID] = [] - - public func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { - let messageReactions = reactions[messageID] ?? [] - return Array(messageReactions.sorted { $0.receivedAt > $1.receivedAt }.prefix(limit)) - } - - public func saveReaction(_ dto: ReactionDTO) async throws { - savedReactions.append(dto) - reactions[dto.messageID, default: []].append(dto) - } - - public func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { - let messageReactions = reactions[messageID] ?? [] - return messageReactions.contains { $0.senderName == senderName && $0.emoji == emoji } - } - - public func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws { - if let message = messages[messageID] { - messages[messageID] = MessageDTO( - id: message.id, - radioID: message.radioID, - contactID: message.contactID, - channelIndex: message.channelIndex, - text: message.text, - timestamp: message.timestamp, - createdAt: message.createdAt, - direction: message.direction, - status: message.status, - textType: message.textType, - ackCode: message.ackCode, - pathLength: message.pathLength, - snr: message.snr, - senderKeyPrefix: message.senderKeyPrefix, - senderNodeName: message.senderNodeName, - isRead: message.isRead, - replyToID: message.replyToID, - roundTripTime: message.roundTripTime, - heardRepeats: message.heardRepeats, - retryAttempt: message.retryAttempt, - maxRetryAttempts: message.maxRetryAttempts, - reactionSummary: summary - ) - } - } - - public func deleteReactionsForMessage(messageID: UUID) async throws { - deletedReactionsForMessageIDs.append(messageID) - reactions.removeValue(forKey: messageID) - } - - // MARK: - Node Status Snapshots - - public var nodeStatusSnapshots: [NodeStatusSnapshotDTO] = [] - - // swiftlint:disable:next function_parameter_count - public func saveNodeStatusSnapshot( - nodePublicKey: Data, - batteryMillivolts: UInt16?, - lastSNR: Double?, - lastRSSI: Int16?, - noiseFloor: Int16?, - uptimeSeconds: UInt32?, - rxAirtimeSeconds: UInt32?, - packetsSent: UInt32?, - packetsReceived: UInt32?, - receiveErrors: UInt32?, - postedCount: UInt16?, - postPushCount: UInt16? - ) async throws -> UUID { - let dto = NodeStatusSnapshotDTO( - nodePublicKey: nodePublicKey, - batteryMillivolts: batteryMillivolts, - lastSNR: lastSNR, - lastRSSI: lastRSSI, - noiseFloor: noiseFloor, - uptimeSeconds: uptimeSeconds, - rxAirtimeSeconds: rxAirtimeSeconds, - packetsSent: packetsSent, - packetsReceived: packetsReceived, - receiveErrors: receiveErrors, - postedCount: postedCount, - postPushCount: postPushCount - ) - nodeStatusSnapshots.append(dto) - return dto.id - } - - public func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { - nodeStatusSnapshots - .filter { $0.nodePublicKey == nodePublicKey } - .sorted { $0.timestamp > $1.timestamp } - .first - } - - public func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { - nodeStatusSnapshots - .filter { $0.nodePublicKey == nodePublicKey && (since == nil || $0.timestamp >= since!) } - .sorted { $0.timestamp < $1.timestamp } - } - - public func saveTelemetryOnlySnapshot( - nodePublicKey: Data, - telemetryEntries: [TelemetrySnapshotEntry] - ) async throws -> UUID { - let dto = NodeStatusSnapshotDTO( - nodePublicKey: nodePublicKey, - telemetryEntries: telemetryEntries - ) - nodeStatusSnapshots.append(dto) - return dto.id - } - - public func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws { - if let index = nodeStatusSnapshots.firstIndex(where: { $0.id == id }) { - let existing = nodeStatusSnapshots[index] - nodeStatusSnapshots[index] = NodeStatusSnapshotDTO( - id: existing.id, - timestamp: existing.timestamp, - nodePublicKey: existing.nodePublicKey, - batteryMillivolts: existing.batteryMillivolts, - lastSNR: existing.lastSNR, - lastRSSI: existing.lastRSSI, - noiseFloor: existing.noiseFloor, - uptimeSeconds: existing.uptimeSeconds, - rxAirtimeSeconds: existing.rxAirtimeSeconds, - packetsSent: existing.packetsSent, - packetsReceived: existing.packetsReceived, - receiveErrors: existing.receiveErrors, - postedCount: existing.postedCount, - postPushCount: existing.postPushCount, - neighborSnapshots: neighbors, - telemetryEntries: existing.telemetryEntries - ) - } - } - - public func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws { - if let index = nodeStatusSnapshots.firstIndex(where: { $0.id == id }) { - let existing = nodeStatusSnapshots[index] - nodeStatusSnapshots[index] = NodeStatusSnapshotDTO( - id: existing.id, - timestamp: existing.timestamp, - nodePublicKey: existing.nodePublicKey, - batteryMillivolts: existing.batteryMillivolts, - lastSNR: existing.lastSNR, - lastRSSI: existing.lastRSSI, - noiseFloor: existing.noiseFloor, - uptimeSeconds: existing.uptimeSeconds, - rxAirtimeSeconds: existing.rxAirtimeSeconds, - packetsSent: existing.packetsSent, - packetsReceived: existing.packetsReceived, - receiveErrors: existing.receiveErrors, - postedCount: existing.postedCount, - postPushCount: existing.postPushCount, - neighborSnapshots: existing.neighborSnapshots, - telemetryEntries: telemetry - ) - } - } - - /// Non-atomic in-memory stand-in for the concrete store's atomic override. - /// This double is never driven concurrently, so it enriches the latest - /// in-window row or inserts a new one without the single-turn guarantee. - public func recordNodeStatusSnapshot( - nodePublicKey: Data, - status: NodeStatusMetrics?, - telemetry: [TelemetrySnapshotEntry]?, - neighbors: [NeighborSnapshotEntry]? - ) async throws -> UUID { - if let latest = try await fetchLatestNodeStatusSnapshot(nodePublicKey: nodePublicKey), - latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { - if let telemetry { try await updateSnapshotTelemetry(id: latest.id, telemetry: telemetry) } - if let neighbors { try await updateSnapshotNeighbors(id: latest.id, neighbors: neighbors) } - return latest.id - } - - if let status { - return try await saveNodeStatusSnapshot( - nodePublicKey: nodePublicKey, - batteryMillivolts: status.batteryMillivolts, - lastSNR: status.lastSNR, - lastRSSI: status.lastRSSI, - noiseFloor: status.noiseFloor, - uptimeSeconds: status.uptimeSeconds, - rxAirtimeSeconds: status.rxAirtimeSeconds, - packetsSent: status.packetsSent, - packetsReceived: status.packetsReceived, - receiveErrors: status.receiveErrors, - postedCount: status.postedCount, - postPushCount: status.postPushCount - ) - } - - let id = try await saveTelemetryOnlySnapshot( - nodePublicKey: nodePublicKey, - telemetryEntries: telemetry ?? [] - ) - if let neighbors { try await updateSnapshotNeighbors(id: id, neighbors: neighbors) } - return id - } - - public func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws { - nodeStatusSnapshots.removeAll { $0.timestamp < date } - } - - // MARK: - Pending Sends - - public var pendingSends: [UUID: PendingSendDTO] = [:] - - public func upsertPendingSend(_ dto: PendingSendDTO) async throws { - pendingSends[dto.id] = dto - } - - public func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { - let nextSequence = (pendingSends.values.filter { $0.radioID == dto.radioID }.map(\.sequence).max() ?? 0) + 1 - let assigned = PendingSendDTO( - id: dto.id, - radioID: dto.radioID, - messageID: dto.messageID, - kind: dto.kind, - contactID: dto.contactID, - channelIndex: dto.channelIndex, - isResend: dto.isResend, - messageText: dto.messageText, - messageTimestamp: dto.messageTimestamp, - localNodeName: dto.localNodeName, - sequence: nextSequence, - enqueuedAt: dto.enqueuedAt, - attemptCount: dto.attemptCount - ) - pendingSends[dto.id] = assigned - return nextSequence - } - - public func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { - pendingSends.values.filter { $0.radioID == radioID }.sorted { $0.sequence < $1.sequence } - } - - public func deletePendingSend(id: UUID) async throws { - pendingSends.removeValue(forKey: id) - } - - public func deletePendingSendsForMessage(messageID: UUID) async throws { - let matchingIDs = pendingSends.values.filter { $0.messageID == messageID }.map(\.id) - for id in matchingIDs { - pendingSends.removeValue(forKey: id) - } - } - - public func hasPendingSend(messageID: UUID) async throws -> Bool { - pendingSends.values.contains { $0.messageID == messageID } - } - - @discardableResult - public func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { - guard let row = pendingSends.values.first(where: { $0.messageID == messageID }) else { return nil } - let nextCount = (row.attemptCount ?? 0) + 1 - pendingSends[row.id] = PendingSendDTO( - id: row.id, - radioID: row.radioID, - messageID: row.messageID, - kind: row.kind, - contactID: row.contactID, - channelIndex: row.channelIndex, - isResend: row.isResend, - messageText: row.messageText, - messageTimestamp: row.messageTimestamp, - localNodeName: row.localNodeName, - sequence: row.sequence, - enqueuedAt: row.enqueuedAt, - attemptCount: nextCount - ) - return nextCount - } - - // MARK: - Test Helpers - - /// Resets all storage and recorded invocations - public func reset() { - messages = [:] - contacts = [:] - channels = [:] - blockedChannelSenders = [:] - debugLogEntries = [] - mockRxLogEntries = [] - linkPreviews = [:] - pendingSends = [:] - roomMessages = [:] - discoveredNodes = [:] - reactions = [:] - nodeStatusSnapshots = [] - savedTracePaths = [:] - savedMessages = [] - savedContacts = [] - savedChannels = [] - savedReactions = [] - savedRoomMessages = [] - deletedContactIDs = [] - deletedChannelIDs = [] - deletedMessagesForContactIDs = [] - deletedReactionsForMessageIDs = [] - deletedMessagesForChannelCalls = [] - updatedMessageStatuses = [] - updatedMessageAcks = [] - updatedRoomMessageStatuses = [] - } + ) + } + + public func clearRxLogEntries(radioID: UUID) async throws { + mockRxLogEntries.removeAll { $0.radioID == radioID } + } + + public func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws { + let entries = mockRxLogEntries.filter { $0.radioID == radioID } + guard entries.count > keepCount + pruneThreshold else { return } + let keptIDs = Set(entries.sorted { $0.receivedAt > $1.receivedAt }.prefix(keepCount).map(\.id)) + mockRxLogEntries.removeAll { $0.radioID == radioID && !keptIDs.contains($0.id) } + } + + public func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + mockRxLogEntries.filter { $0.radioID == radioID && $0.transportCode != nil && $0.regionScope == nil } + } + + public func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { + mockRxLogEntries.filter { $0.radioID == radioID && $0.decryptStatus == status && $0.receivedAt >= since } + } + + public func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws { + updatedRxLogRegions.append(contentsOf: updates) + } + + public func batchUpdateRxLogDecryption( + _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] + ) async throws { + for update in updates { + guard let index = mockRxLogEntries.firstIndex(where: { $0.id == update.id }) else { continue } + mockRxLogEntries[index].channelIndex = update.channelIndex + mockRxLogEntries[index].channelName = update.channelName + mockRxLogEntries[index].senderTimestamp = update.senderTimestamp + } + } + + public func batchUpdateChannelMessageRegion( + radioID: UUID, + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) async throws { + updatedChannelMessageRegions.append(contentsOf: updates) + } + + public func batchUpdateDMMessageRegion( + radioID: UUID, + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] + ) async throws { + updatedDMMessageRegions.append(contentsOf: updates) + } + + // MARK: - Saved Trace Paths + + public var savedTracePaths: [UUID: SavedTracePathDTO] = [:] + + public func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { + savedTracePaths.values.filter { $0.radioID == radioID }.sorted(by: { $0.createdDate > $1.createdDate }) + } + + public func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { + savedTracePaths[id] + } + + public func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { + let id = UUID() + let runs = initialRun.map { [$0] } ?? [] + let dto = SavedTracePathDTO( + id: id, + radioID: radioID, + name: name, + pathBytes: pathBytes, + hashSize: hashSize, + createdDate: Date(), + runs: runs + ) + savedTracePaths[id] = dto + return dto + } + + public func updateSavedTracePathName(id: UUID, name: String) async throws { + if let path = savedTracePaths[id] { + savedTracePaths[id] = SavedTracePathDTO( + id: path.id, + radioID: path.radioID, + name: name, + pathBytes: path.pathBytes, + hashSize: path.hashSize, + createdDate: path.createdDate, + runs: path.runs + ) + } + } + + public func deleteSavedTracePath(id: UUID) async throws { + savedTracePaths.removeValue(forKey: id) + } + + public func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws { + if let path = savedTracePaths[pathID] { + var runs = path.runs + runs.append(run) + savedTracePaths[pathID] = SavedTracePathDTO( + id: path.id, + radioID: path.radioID, + name: path.name, + pathBytes: path.pathBytes, + hashSize: path.hashSize, + createdDate: path.createdDate, + runs: runs + ) + } + } + + // MARK: - Heard Repeats + + public func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? { + nil // Stub + } + + public func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws { + // Stub - no-op + } + + public func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { + [] // Stub + } + + public func deleteMessageRepeats(messageID: UUID) async throws { + // Stub - no-op + } + + public func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { + false // Stub + } + + public func incrementMessageHeardRepeats(id: UUID) async throws -> Int { + 0 // Stub + } + + public func incrementMessageSendCount(id: UUID) async throws -> Int { + if let message = messages[id] { + let newCount = message.sendCount + 1 + messages[id] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + sendCount: newCount, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + return newCount + } + return 0 + } + + // MARK: - Debug Log Operations + + public func saveDebugLogEntries(_ entries: [DebugLogEntryDTO]) async throws { + if let error = stubbedDebugLogError { + throw error + } + debugLogEntries.append(contentsOf: entries) + } + + public func fetchDebugLogEntries(since: Date, limit: Int) async throws -> [DebugLogEntryDTO] { + if let error = stubbedDebugLogError { + throw error + } + return debugLogEntries + .filter { $0.timestamp >= since } + .sorted { $0.timestamp < $1.timestamp } + .prefix(limit) + .map(\.self) + } + + public func countDebugLogEntries() async throws -> Int { + if let error = stubbedDebugLogError { + throw error + } + return debugLogEntries.count + } + + public func pruneDebugLogEntries(keepCount: Int) async throws { + if let error = stubbedDebugLogError { + throw error + } + let sorted = debugLogEntries.sorted { $0.timestamp > $1.timestamp } + let toKeep = Set(sorted.prefix(keepCount).map(\.id)) + debugLogEntries.removeAll { !toKeep.contains($0.id) } + } + + public func clearDebugLogEntries() async throws { + if let error = stubbedDebugLogError { + throw error + } + debugLogEntries.removeAll() + } + + // MARK: - Link Preview Cache + + public var linkPreviews: [String: LinkPreviewDataDTO] = [:] + + public func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { + linkPreviews[url] + } + + public func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws { + linkPreviews[dto.url] = dto + } + + // MARK: - Device Operations + + /// Devices keyed by radio ID + public var devices: [UUID: DeviceDTO] = [:] + public private(set) var updatedLastContactSyncs: [(radioID: UUID, timestamp: UInt32)] = [] + + public func fetchDevice(id: UUID) async throws -> DeviceDTO? { + devices.values.first { $0.id == id } + } + + public func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { + devices[radioID] + } + + public func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws { + updatedLastContactSyncs.append((radioID: radioID, timestamp: timestamp)) + if var device = devices[radioID] { + device.lastContactSync = timestamp + devices[radioID] = device + } + } + + public func saveDevice(_ dto: DeviceDTO) async throws { + devices[dto.radioID] = dto + } + + // MARK: - Room Session State + + public var remoteNodeSessions: [UUID: RemoteNodeSessionDTO] = [:] + public private(set) var markedDisconnectedSessionIDs: [UUID] = [] + public private(set) var markedConnectedSessionIDs: [UUID] = [] + public private(set) var updatedRoomActivitySessionIDs: [UUID] = [] + public private(set) var savedRemoteNodeSessions: [RemoteNodeSessionDTO] = [] + public private(set) var updatedSessionConnections: [(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel)] = [] + public private(set) var deletedRemoteNodeSessionIDs: [UUID] = [] + + public func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { + remoteNodeSessions[id] + } + + public func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { + remoteNodeSessions.values.first { $0.publicKey == publicKey } + } + + public func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { + remoteNodeSessions.values.first { $0.publicKey.prefix(6) == prefix } + } + + public func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + remoteNodeSessions.values.filter { $0.radioID == radioID } + } + + public func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { + remoteNodeSessions.values.filter(\.isConnected) + } + + public func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws { + savedRemoteNodeSessions.append(dto) + remoteNodeSessions[dto.id] = dto + } + + public func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws { + updatedSessionConnections.append((id: id, isConnected: isConnected, permissionLevel: permissionLevel)) + if let session = remoteNodeSessions[id] { + remoteNodeSessions[id] = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + latitude: session.latitude, + longitude: session.longitude, + isConnected: isConnected, + permissionLevel: permissionLevel, + lastConnectedDate: session.lastConnectedDate, + lastBatteryMillivolts: session.lastBatteryMillivolts, + lastUptimeSeconds: session.lastUptimeSeconds, + lastNoiseFloor: session.lastNoiseFloor, + unreadCount: session.unreadCount, + notificationLevel: session.notificationLevel, + lastRxAirtimeSeconds: session.lastRxAirtimeSeconds, + neighborCount: session.neighborCount, + lastSyncTimestamp: session.lastSyncTimestamp, + lastMessageDate: session.lastMessageDate + ) + } + } + + public func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws { + let duplicateIDs = remoteNodeSessions.values + .filter { $0.publicKey == publicKey && $0.id != keepID } + .map(\.id) + for id in duplicateIDs { + remoteNodeSessions.removeValue(forKey: id) + } + } + + public func deleteRemoteNodeSession(id: UUID) async throws { + deletedRemoteNodeSessionIDs.append(id) + remoteNodeSessions.removeValue(forKey: id) + roomMessages = roomMessages.filter { $0.value.sessionID != id } + } + + public func markSessionDisconnected(_ sessionID: UUID) throws { + markedDisconnectedSessionIDs.append(sessionID) + } + + @discardableResult + public func markRoomSessionConnected(_ sessionID: UUID) throws -> Bool { + markedConnectedSessionIDs.append(sessionID) + return true + } + + public func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) throws { + updatedRoomActivitySessionIDs.append(sessionID) + } + + // MARK: - Room Message Operations + + public var roomMessages: [UUID: RoomMessageDTO] = [:] + public private(set) var savedRoomMessages: [RoomMessageDTO] = [] + public private(set) var updatedRoomMessageStatuses: [(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?)] = [] + + public func saveRoomMessage(_ dto: RoomMessageDTO) async throws { + savedRoomMessages.append(dto) + roomMessages[dto.id] = dto + } + + public func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { + roomMessages[id] + } + + public func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { + let filtered = roomMessages.values.filter { $0.sessionID == sessionID } + .sorted { $0.timestamp < $1.timestamp } + var result = Array(filtered) + if let offset { + result = Array(result.dropFirst(offset)) + } + if let limit { + result = Array(result.prefix(limit)) + } + return result + } + + public func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { + roomMessages.values.contains { $0.sessionID == sessionID && $0.deduplicationKey == deduplicationKey } + } + + public func updateRoomMessageStatus( + id: UUID, + status: MessageStatus, + ackCode: UInt32?, + roundTripTime: UInt32? + ) async throws { + updatedRoomMessageStatuses.append((id: id, status: status, ackCode: ackCode, roundTripTime: roundTripTime)) + if let message = roomMessages[id] { + roomMessages[id] = RoomMessageDTO( + id: message.id, + sessionID: message.sessionID, + authorKeyPrefix: message.authorKeyPrefix, + authorName: message.authorName, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + isFromSelf: message.isFromSelf, + status: status, + ackCode: ackCode ?? message.ackCode, + roundTripTime: roundTripTime ?? message.roundTripTime, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts + ) + } + } + + public func updateRoomMessageRetryStatus( + id: UUID, + status: MessageStatus, + retryAttempt: Int, + maxRetryAttempts: Int + ) async throws { + if let message = roomMessages[id] { + roomMessages[id] = RoomMessageDTO( + id: message.id, + sessionID: message.sessionID, + authorKeyPrefix: message.authorKeyPrefix, + authorName: message.authorName, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + isFromSelf: message.isFromSelf, + status: status, + ackCode: message.ackCode, + roundTripTime: message.roundTripTime, + retryAttempt: retryAttempt, + maxRetryAttempts: maxRetryAttempts + ) + } + } + + public private(set) var incrementedRoomUnreadSessionIDs: [UUID] = [] + public private(set) var resetRoomUnreadSessionIDs: [UUID] = [] + + public func incrementRoomUnreadCount(_ sessionID: UUID) async throws { + incrementedRoomUnreadSessionIDs.append(sessionID) + } + + public func resetRoomUnreadCount(_ sessionID: UUID) async throws { + resetRoomUnreadSessionIDs.append(sessionID) + } + + // MARK: - Discovered Nodes + + public var discoveredNodes: [UUID: DiscoveredNodeDTO] = [:] + + public func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { + if let existing = discoveredNodes.values.first(where: { $0.radioID == radioID && $0.publicKey == frame.publicKey }) { + let updated = DiscoveredNodeDTO( + id: existing.id, + radioID: radioID, + publicKey: frame.publicKey, + name: frame.name, + typeRawValue: frame.type.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: frame.lastAdvertTimestamp, + latitude: frame.latitude, + longitude: frame.longitude, + outPathLength: frame.outPathLength, + outPath: frame.outPath, + inboundHopCount: existing.inboundHopCount, + inboundHopAdvertTimestamp: existing.inboundHopAdvertTimestamp + ) + discoveredNodes[existing.id] = updated + return (node: updated, isNew: false) + } + + let id = UUID() + let dto = DiscoveredNodeDTO( + id: id, + radioID: radioID, + publicKey: frame.publicKey, + name: frame.name, + typeRawValue: frame.type.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: frame.lastAdvertTimestamp, + latitude: frame.latitude, + longitude: frame.longitude, + outPathLength: frame.outPathLength, + outPath: frame.outPath, + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + discoveredNodes[id] = dto + return (node: dto, isNew: true) + } + + /// Records every `setInboundHopCount` call for assertion and applies the same adopt rule as the + /// production store so tests can observe the resulting stored value via `discoveredNodes`. + public private(set) var inboundHopCountCalls: [(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?)] = [] + + public func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws { + inboundHopCountCalls.append((radioID: radioID, publicKey: publicKey, hopCount: hopCount, advertTimestamp: advertTimestamp)) + guard let existing = discoveredNodes.values.first(where: { + $0.radioID == radioID && $0.publicKey == publicKey + }) else { return } + guard let adopted = adoptInboundHop( + storedHops: existing.inboundHopCount, + storedTimestamp: existing.inboundHopAdvertTimestamp, + incomingHops: hopCount, + incomingTimestamp: advertTimestamp + ) else { return } + discoveredNodes[existing.id] = DiscoveredNodeDTO( + id: existing.id, + radioID: existing.radioID, + publicKey: existing.publicKey, + name: existing.name, + typeRawValue: existing.typeRawValue, + lastHeard: existing.lastHeard, + lastAdvertTimestamp: existing.lastAdvertTimestamp, + latitude: existing.latitude, + longitude: existing.longitude, + outPathLength: existing.outPathLength, + outPath: existing.outPath, + inboundHopCount: adopted.hopCount, + inboundHopAdvertTimestamp: adopted.advertTimestamp + ) + } + + public func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { + discoveredNodes.values.filter { $0.radioID == radioID } + } + + public func deleteDiscoveredNode(id: UUID) async throws { + discoveredNodes.removeValue(forKey: id) + } + + public func clearDiscoveredNodes(radioID: UUID) async throws { + let keysToRemove = discoveredNodes.values + .filter { $0.radioID == radioID } + .map(\.id) + for key in keysToRemove { + discoveredNodes.removeValue(forKey: key) + } + } + + public func fetchContactPublicKeys(radioID: UUID) async throws -> Set { + if let error = stubbedFetchContactError { + throw error + } + return Set(contacts.values.filter { $0.radioID == radioID }.map(\.publicKey)) + } + + // MARK: - Reactions + + public var reactions: [UUID: [ReactionDTO]] = [:] + public private(set) var savedReactions: [ReactionDTO] = [] + public private(set) var deletedReactionsForMessageIDs: [UUID] = [] + + public func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { + let messageReactions = reactions[messageID] ?? [] + return Array(messageReactions.sorted { $0.receivedAt > $1.receivedAt }.prefix(limit)) + } + + public func saveReaction(_ dto: ReactionDTO) async throws { + savedReactions.append(dto) + reactions[dto.messageID, default: []].append(dto) + } + + public func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { + let messageReactions = reactions[messageID] ?? [] + return messageReactions.contains { $0.senderName == senderName && $0.emoji == emoji } + } + + public func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws { + if let message = messages[messageID] { + messages[messageID] = MessageDTO( + id: message.id, + radioID: message.radioID, + contactID: message.contactID, + channelIndex: message.channelIndex, + text: message.text, + timestamp: message.timestamp, + createdAt: message.createdAt, + direction: message.direction, + status: message.status, + textType: message.textType, + ackCode: message.ackCode, + pathLength: message.pathLength, + snr: message.snr, + senderKeyPrefix: message.senderKeyPrefix, + senderNodeName: message.senderNodeName, + isRead: message.isRead, + replyToID: message.replyToID, + roundTripTime: message.roundTripTime, + heardRepeats: message.heardRepeats, + retryAttempt: message.retryAttempt, + maxRetryAttempts: message.maxRetryAttempts, + reactionSummary: summary + ) + } + } + + public func deleteReactionsForMessage(messageID: UUID) async throws { + deletedReactionsForMessageIDs.append(messageID) + reactions.removeValue(forKey: messageID) + } + + // MARK: - Node Status Snapshots + + public var nodeStatusSnapshots: [NodeStatusSnapshotDTO] = [] + + // swiftlint:disable:next function_parameter_count + public func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? + ) async throws -> UUID { + let dto = NodeStatusSnapshotDTO( + nodePublicKey: nodePublicKey, + batteryMillivolts: batteryMillivolts, + lastSNR: lastSNR, + lastRSSI: lastRSSI, + noiseFloor: noiseFloor, + uptimeSeconds: uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: packetsSent, + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + nodeStatusSnapshots.append(dto) + return dto.id + } + + public func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { + nodeStatusSnapshots + .filter { $0.nodePublicKey == nodePublicKey } + .sorted { $0.timestamp > $1.timestamp } + .first + } + + public func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { + nodeStatusSnapshots + .filter { $0.nodePublicKey == nodePublicKey && (since == nil || $0.timestamp >= since!) } + .sorted { $0.timestamp < $1.timestamp } + } + + public func saveTelemetryOnlySnapshot( + nodePublicKey: Data, + telemetryEntries: [TelemetrySnapshotEntry] + ) async throws -> UUID { + let dto = NodeStatusSnapshotDTO( + nodePublicKey: nodePublicKey, + telemetryEntries: telemetryEntries + ) + nodeStatusSnapshots.append(dto) + return dto.id + } + + public func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws { + if let index = nodeStatusSnapshots.firstIndex(where: { $0.id == id }) { + let existing = nodeStatusSnapshots[index] + nodeStatusSnapshots[index] = NodeStatusSnapshotDTO( + id: existing.id, + timestamp: existing.timestamp, + nodePublicKey: existing.nodePublicKey, + batteryMillivolts: existing.batteryMillivolts, + lastSNR: existing.lastSNR, + lastRSSI: existing.lastRSSI, + noiseFloor: existing.noiseFloor, + uptimeSeconds: existing.uptimeSeconds, + rxAirtimeSeconds: existing.rxAirtimeSeconds, + packetsSent: existing.packetsSent, + packetsReceived: existing.packetsReceived, + receiveErrors: existing.receiveErrors, + postedCount: existing.postedCount, + postPushCount: existing.postPushCount, + neighborSnapshots: neighbors, + telemetryEntries: existing.telemetryEntries + ) + } + } + + public func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws { + if let index = nodeStatusSnapshots.firstIndex(where: { $0.id == id }) { + let existing = nodeStatusSnapshots[index] + nodeStatusSnapshots[index] = NodeStatusSnapshotDTO( + id: existing.id, + timestamp: existing.timestamp, + nodePublicKey: existing.nodePublicKey, + batteryMillivolts: existing.batteryMillivolts, + lastSNR: existing.lastSNR, + lastRSSI: existing.lastRSSI, + noiseFloor: existing.noiseFloor, + uptimeSeconds: existing.uptimeSeconds, + rxAirtimeSeconds: existing.rxAirtimeSeconds, + packetsSent: existing.packetsSent, + packetsReceived: existing.packetsReceived, + receiveErrors: existing.receiveErrors, + postedCount: existing.postedCount, + postPushCount: existing.postPushCount, + neighborSnapshots: existing.neighborSnapshots, + telemetryEntries: telemetry + ) + } + } + + /// Non-atomic in-memory stand-in for the concrete store's atomic override. + /// This double is never driven concurrently, so it enriches the latest + /// in-window row or inserts a new one without the single-turn guarantee. + public func recordNodeStatusSnapshot( + nodePublicKey: Data, + status: NodeStatusMetrics?, + telemetry: [TelemetrySnapshotEntry]?, + neighbors: [NeighborSnapshotEntry]? + ) async throws -> UUID { + if let latest = try await fetchLatestNodeStatusSnapshot(nodePublicKey: nodePublicKey), + latest.timestamp.distance(to: .now) < NodeSnapshotPolicy.minimumInterval { + if let telemetry { try await updateSnapshotTelemetry(id: latest.id, telemetry: telemetry) } + if let neighbors { try await updateSnapshotNeighbors(id: latest.id, neighbors: neighbors) } + return latest.id + } + + if let status { + return try await saveNodeStatusSnapshot( + nodePublicKey: nodePublicKey, + batteryMillivolts: status.batteryMillivolts, + lastSNR: status.lastSNR, + lastRSSI: status.lastRSSI, + noiseFloor: status.noiseFloor, + uptimeSeconds: status.uptimeSeconds, + rxAirtimeSeconds: status.rxAirtimeSeconds, + packetsSent: status.packetsSent, + packetsReceived: status.packetsReceived, + receiveErrors: status.receiveErrors, + postedCount: status.postedCount, + postPushCount: status.postPushCount + ) + } + + let id = try await saveTelemetryOnlySnapshot( + nodePublicKey: nodePublicKey, + telemetryEntries: telemetry ?? [] + ) + if let neighbors { try await updateSnapshotNeighbors(id: id, neighbors: neighbors) } + return id + } + + public func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws { + nodeStatusSnapshots.removeAll { $0.timestamp < date } + } + + // MARK: - Pending Sends + + public var pendingSends: [UUID: PendingSendDTO] = [:] + + public func upsertPendingSend(_ dto: PendingSendDTO) async throws { + pendingSends[dto.id] = dto + } + + public func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { + let nextSequence = (pendingSends.values.filter { $0.radioID == dto.radioID }.map(\.sequence).max() ?? 0) + 1 + let assigned = PendingSendDTO( + id: dto.id, + radioID: dto.radioID, + messageID: dto.messageID, + kind: dto.kind, + contactID: dto.contactID, + channelIndex: dto.channelIndex, + isResend: dto.isResend, + messageText: dto.messageText, + messageTimestamp: dto.messageTimestamp, + localNodeName: dto.localNodeName, + sequence: nextSequence, + enqueuedAt: dto.enqueuedAt, + attemptCount: dto.attemptCount + ) + pendingSends[dto.id] = assigned + return nextSequence + } + + public func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { + pendingSends.values.filter { $0.radioID == radioID }.sorted { $0.sequence < $1.sequence } + } + + public func deletePendingSend(id: UUID) async throws { + pendingSends.removeValue(forKey: id) + } + + public func deletePendingSendsForMessage(messageID: UUID) async throws { + let matchingIDs = pendingSends.values.filter { $0.messageID == messageID }.map(\.id) + for id in matchingIDs { + pendingSends.removeValue(forKey: id) + } + } + + public func hasPendingSend(messageID: UUID) async throws -> Bool { + pendingSends.values.contains { $0.messageID == messageID } + } + + @discardableResult + public func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { + guard let row = pendingSends.values.first(where: { $0.messageID == messageID }) else { return nil } + let nextCount = (row.attemptCount ?? 0) + 1 + pendingSends[row.id] = PendingSendDTO( + id: row.id, + radioID: row.radioID, + messageID: row.messageID, + kind: row.kind, + contactID: row.contactID, + channelIndex: row.channelIndex, + isResend: row.isResend, + messageText: row.messageText, + messageTimestamp: row.messageTimestamp, + localNodeName: row.localNodeName, + sequence: row.sequence, + enqueuedAt: row.enqueuedAt, + attemptCount: nextCount + ) + return nextCount + } + + // MARK: - Test Helpers + + /// Resets all storage and recorded invocations + public func reset() { + messages = [:] + contacts = [:] + channels = [:] + blockedChannelSenders = [:] + debugLogEntries = [] + mockRxLogEntries = [] + linkPreviews = [:] + pendingSends = [:] + roomMessages = [:] + discoveredNodes = [:] + reactions = [:] + nodeStatusSnapshots = [] + savedTracePaths = [:] + savedMessages = [] + savedContacts = [] + savedChannels = [] + savedReactions = [] + savedRoomMessages = [] + deletedContactIDs = [] + deletedChannelIDs = [] + deletedMessagesForContactIDs = [] + deletedReactionsForMessageIDs = [] + deletedMessagesForChannelCalls = [] + updatedMessageStatuses = [] + updatedMessageAcks = [] + updatedRoomMessageStatuses = [] + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift index c2debefc..caed527d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift @@ -1,128 +1,127 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("ChannelDTO floodScope propagation through with(...) methods") struct ChannelRegionScopeTests { - - // MARK: - Helpers - - private func makeDTO(floodScope: ChannelFloodScope = .inherit) -> ChannelDTO { - ChannelDTO( - id: UUID(), - radioID: UUID(), - index: 1, - name: "Test", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - floodScope: floodScope - ) - } - - // MARK: - Init Tests - - @Test("Default floodScope is .inherit") - func defaultFloodScopeIsInherit() { - let dto = makeDTO() - #expect(dto.floodScope == .inherit) - } - - @Test(".region(name) preserved on init") - func regionPreserved() { - let dto = makeDTO(floodScope: .region("Europe")) - #expect(dto.floodScope == .region("Europe")) - } - - // MARK: - with(notificationLevel:) - - @Test("with(notificationLevel:) preserves .region(name)") - func withNotificationLevelPreservesRegion() { - let dto = makeDTO(floodScope: .region("UK")) - let updated = dto.with(notificationLevel: .muted) - - #expect(updated.floodScope == .region("UK")) - #expect(updated.notificationLevel == .muted) - } - - @Test("with(notificationLevel:) preserves .allRegions") - func withNotificationLevelPreservesAllRegions() { - let dto = makeDTO(floodScope: .allRegions) - let updated = dto.with(notificationLevel: .mentionsOnly) - - #expect(updated.floodScope == .allRegions) - } - - @Test("with(notificationLevel:) preserves .inherit") - func withNotificationLevelPreservesInherit() { - let dto = makeDTO() - let updated = dto.with(notificationLevel: .mentionsOnly) - - #expect(updated.floodScope == .inherit) - } - - // MARK: - with(isFavorite:) - - @Test("with(isFavorite:) preserves .region(name)") - func withIsFavoritePreservesRegion() { - let dto = makeDTO(floodScope: .region("France")) - let updated = dto.with(isFavorite: true) - - #expect(updated.floodScope == .region("France")) - #expect(updated.isFavorite == true) - } - - @Test("with(isFavorite:) preserves .inherit") - func withIsFavoritePreservesInherit() { - let dto = makeDTO() - let updated = dto.with(isFavorite: true) - - #expect(updated.floodScope == .inherit) - } - - // MARK: - with(floodScope:) - - @Test("with(floodScope:) updates from .region to .region") - func withFloodScopeUpdatesBetweenRegions() { - let dto = makeDTO(floodScope: .region("Europe")) - let updated = dto.with(floodScope: .region("UK")) - - #expect(updated.floodScope == .region("UK")) - } - - @Test("with(floodScope: .inherit) clears region name in storage") - func withFloodScopeInheritClears() { - let dto = makeDTO(floodScope: .region("Europe")) - let updated = dto.with(floodScope: .inherit) - - #expect(updated.floodScope == .inherit) - #expect(updated.regionScope == nil) - } - - @Test("with(floodScope:) sets specific region from inherit") - func withFloodScopeSetsFromInherit() { - let dto = makeDTO() - let updated = dto.with(floodScope: .region("Asia")) - - #expect(updated.floodScope == .region("Asia")) - } - - @Test("with(floodScope:) preserves all other fields") - func withFloodScopePreservesOtherFields() { - let dto = makeDTO(floodScope: .region("Europe")) - let updated = dto.with(floodScope: .region("UK")) - - #expect(updated.id == dto.id) - #expect(updated.radioID == dto.radioID) - #expect(updated.index == dto.index) - #expect(updated.name == dto.name) - #expect(updated.secret == dto.secret) - #expect(updated.isEnabled == dto.isEnabled) - #expect(updated.lastMessageDate == dto.lastMessageDate) - #expect(updated.unreadCount == dto.unreadCount) - #expect(updated.unreadMentionCount == dto.unreadMentionCount) - #expect(updated.notificationLevel == dto.notificationLevel) - #expect(updated.isFavorite == dto.isFavorite) - } + // MARK: - Helpers + + private func makeDTO(floodScope: ChannelFloodScope = .inherit) -> ChannelDTO { + ChannelDTO( + id: UUID(), + radioID: UUID(), + index: 1, + name: "Test", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + floodScope: floodScope + ) + } + + // MARK: - Init Tests + + @Test + func `Default floodScope is .inherit`() { + let dto = makeDTO() + #expect(dto.floodScope == .inherit) + } + + @Test + func `.region(name) preserved on init`() { + let dto = makeDTO(floodScope: .region("Europe")) + #expect(dto.floodScope == .region("Europe")) + } + + // MARK: - with(notificationLevel:) + + @Test + func `with(notificationLevel:) preserves .region(name)`() { + let dto = makeDTO(floodScope: .region("UK")) + let updated = dto.with(notificationLevel: .muted) + + #expect(updated.floodScope == .region("UK")) + #expect(updated.notificationLevel == .muted) + } + + @Test + func `with(notificationLevel:) preserves .allRegions`() { + let dto = makeDTO(floodScope: .allRegions) + let updated = dto.with(notificationLevel: .mentionsOnly) + + #expect(updated.floodScope == .allRegions) + } + + @Test + func `with(notificationLevel:) preserves .inherit`() { + let dto = makeDTO() + let updated = dto.with(notificationLevel: .mentionsOnly) + + #expect(updated.floodScope == .inherit) + } + + // MARK: - with(isFavorite:) + + @Test + func `with(isFavorite:) preserves .region(name)`() { + let dto = makeDTO(floodScope: .region("France")) + let updated = dto.with(isFavorite: true) + + #expect(updated.floodScope == .region("France")) + #expect(updated.isFavorite == true) + } + + @Test + func `with(isFavorite:) preserves .inherit`() { + let dto = makeDTO() + let updated = dto.with(isFavorite: true) + + #expect(updated.floodScope == .inherit) + } + + // MARK: - with(floodScope:) + + @Test + func `with(floodScope:) updates from .region to .region`() { + let dto = makeDTO(floodScope: .region("Europe")) + let updated = dto.with(floodScope: .region("UK")) + + #expect(updated.floodScope == .region("UK")) + } + + @Test + func `with(floodScope: .inherit) clears region name in storage`() { + let dto = makeDTO(floodScope: .region("Europe")) + let updated = dto.with(floodScope: .inherit) + + #expect(updated.floodScope == .inherit) + #expect(updated.regionScope == nil) + } + + @Test + func `with(floodScope:) sets specific region from inherit`() { + let dto = makeDTO() + let updated = dto.with(floodScope: .region("Asia")) + + #expect(updated.floodScope == .region("Asia")) + } + + @Test + func `with(floodScope:) preserves all other fields`() { + let dto = makeDTO(floodScope: .region("Europe")) + let updated = dto.with(floodScope: .region("UK")) + + #expect(updated.id == dto.id) + #expect(updated.radioID == dto.radioID) + #expect(updated.index == dto.index) + #expect(updated.name == dto.name) + #expect(updated.secret == dto.secret) + #expect(updated.isEnabled == dto.isEnabled) + #expect(updated.lastMessageDate == dto.lastMessageDate) + #expect(updated.unreadCount == dto.unreadCount) + #expect(updated.unreadMentionCount == dto.unreadMentionCount) + #expect(updated.notificationLevel == dto.notificationLevel) + #expect(updated.isFavorite == dto.isFavorite) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Models/ConnectionMethodTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/ConnectionMethodTests.swift index 688f4f42..1f048515 100644 --- a/MC1Services/Tests/MC1ServicesTests/Models/ConnectionMethodTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Models/ConnectionMethodTests.swift @@ -1,99 +1,98 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("ConnectionMethod Tests") struct ConnectionMethodTests { - - @Test("ConnectionMethod decodes the frozen synthesized wire shape (guards against silent rename)") - func decodesFrozenLegacyWireShape() throws { - let decoder = JSONDecoder() - - let wifiJSON = #"{"wifi":{"displayName":"Field Radio","host":"192.168.1.50","port":5000}}"# - let wifi = try decoder.decode(ConnectionMethod.self, from: Data(wifiJSON.utf8)) - guard case let .wifi(host, port, displayName) = wifi else { - Issue.record("expected .wifi, got \(wifi)"); return - } - #expect(host == "192.168.1.50") - #expect(port == 5000) - #expect(displayName == "Field Radio") - - let bleJSON = #"{"bluetooth":{"displayName":null,"peripheralUUID":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F"}}"# - let ble = try decoder.decode(ConnectionMethod.self, from: Data(bleJSON.utf8)) - guard case let .bluetooth(uuid, name) = ble else { - Issue.record("expected .bluetooth, got \(ble)"); return - } - #expect(uuid == UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")) - #expect(name == nil) - } - - @Test("ConnectionMethod round-trips through the backup encoder") - func roundTripsThroughBackupEncoder() throws { - let methods: [ConnectionMethod] = [ - .wifi(host: "10.0.0.2", port: 4403, displayName: nil), - .bluetooth(peripheralUUID: UUID(), displayName: "Pocket") - ] - let data = try makeBackupJSONEncoder().encode(methods) - let decoded = try makeBackupJSONDecoder().decode([ConnectionMethod].self, from: data) - #expect(decoded == methods) - } - - @Test("ConnectionMethod ENCODES to the frozen synthesized wire shape (pins the write shape)") - func encodesToFrozenWireShape() throws { - // Decode-of-frozen-input and the symmetric round-trip do not pin the encoder's output: - // a synthesized-Codable change that altered encode and decode in lockstep would pass - // both while still bricking files written by the current app. This asserts the produced - // JSON. makeBackupJSONEncoder() uses .sortedKeys, so the literal is key-stable. - let wifi: ConnectionMethod = .wifi(host: "192.168.1.50", port: 5000, displayName: "Field Radio") - let json = String(decoding: try makeBackupJSONEncoder().encode(wifi), as: UTF8.self) - #expect(json == #"{"wifi":{"displayName":"Field Radio","host":"192.168.1.50","port":5000}}"#) - } - - @Test("Bluetooth method has correct identifier") - func bluetoothIdentifier() { - let uuid = UUID() - let method = ConnectionMethod.bluetooth(peripheralUUID: uuid, displayName: "My Device") - - #expect(method.id == "ble:\(uuid.uuidString)") - } - - @Test("WiFi method has correct identifier") - func wifiIdentifier() { - let method = ConnectionMethod.wifi(host: "192.168.1.50", port: 5000, displayName: "Home") - - #expect(method.id == "wifi:192.168.1.50:5000") - } - - @Test("Codable round-trip for Bluetooth") - func bluetoothCodable() throws { - let uuid = UUID() - let original = ConnectionMethod.bluetooth(peripheralUUID: uuid, displayName: "Test") - - let encoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(ConnectionMethod.self, from: encoded) - - #expect(decoded.id == original.id) - } - - @Test("Codable round-trip for WiFi") - func wifiCodable() throws { - let original = ConnectionMethod.wifi(host: "10.0.0.1", port: 8080, displayName: nil) - - let encoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(ConnectionMethod.self, from: encoded) - - #expect(decoded.id == original.id) + @Test + func `ConnectionMethod decodes the frozen synthesized wire shape (guards against silent rename)`() throws { + let decoder = JSONDecoder() + + let wifiJSON = #"{"wifi":{"displayName":"Field Radio","host":"192.168.1.50","port":5000}}"# + let wifi = try decoder.decode(ConnectionMethod.self, from: Data(wifiJSON.utf8)) + guard case let .wifi(host, port, displayName) = wifi else { + Issue.record("expected .wifi, got \(wifi)"); return } - - @Test("Display name returns custom name when set") - func displayNameCustom() { - let method = ConnectionMethod.wifi(host: "192.168.1.1", port: 5000, displayName: "Office Router") - #expect(method.displayName == "Office Router") - } - - @Test("Display name returns nil when not set") - func displayNameNil() { - let method = ConnectionMethod.wifi(host: "192.168.1.1", port: 5000, displayName: nil) - #expect(method.displayName == nil) + #expect(host == "192.168.1.50") + #expect(port == 5000) + #expect(displayName == "Field Radio") + + let bleJSON = #"{"bluetooth":{"displayName":null,"peripheralUUID":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F"}}"# + let ble = try decoder.decode(ConnectionMethod.self, from: Data(bleJSON.utf8)) + guard case let .bluetooth(uuid, name) = ble else { + Issue.record("expected .bluetooth, got \(ble)"); return } + #expect(uuid == UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")) + #expect(name == nil) + } + + @Test + func `ConnectionMethod round-trips through the backup encoder`() throws { + let methods: [ConnectionMethod] = [ + .wifi(host: "10.0.0.2", port: 4403, displayName: nil), + .bluetooth(peripheralUUID: UUID(), displayName: "Pocket") + ] + let data = try makeBackupJSONEncoder().encode(methods) + let decoded = try makeBackupJSONDecoder().decode([ConnectionMethod].self, from: data) + #expect(decoded == methods) + } + + @Test + func `ConnectionMethod ENCODES to the frozen synthesized wire shape (pins the write shape)`() throws { + // Decode-of-frozen-input and the symmetric round-trip do not pin the encoder's output: + // a synthesized-Codable change that altered encode and decode in lockstep would pass + // both while still bricking files written by the current app. This asserts the produced + // JSON. makeBackupJSONEncoder() uses .sortedKeys, so the literal is key-stable. + let wifi: ConnectionMethod = .wifi(host: "192.168.1.50", port: 5000, displayName: "Field Radio") + let json = try String(decoding: makeBackupJSONEncoder().encode(wifi), as: UTF8.self) + #expect(json == #"{"wifi":{"displayName":"Field Radio","host":"192.168.1.50","port":5000}}"#) + } + + @Test + func `Bluetooth method has correct identifier`() { + let uuid = UUID() + let method = ConnectionMethod.bluetooth(peripheralUUID: uuid, displayName: "My Device") + + #expect(method.id == "ble:\(uuid.uuidString)") + } + + @Test + func `WiFi method has correct identifier`() { + let method = ConnectionMethod.wifi(host: "192.168.1.50", port: 5000, displayName: "Home") + + #expect(method.id == "wifi:192.168.1.50:5000") + } + + @Test + func `Codable round-trip for Bluetooth`() throws { + let uuid = UUID() + let original = ConnectionMethod.bluetooth(peripheralUUID: uuid, displayName: "Test") + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ConnectionMethod.self, from: encoded) + + #expect(decoded.id == original.id) + } + + @Test + func `Codable round-trip for WiFi`() throws { + let original = ConnectionMethod.wifi(host: "10.0.0.1", port: 8080, displayName: nil) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ConnectionMethod.self, from: encoded) + + #expect(decoded.id == original.id) + } + + @Test + func `Display name returns custom name when set`() { + let method = ConnectionMethod.wifi(host: "192.168.1.1", port: 5000, displayName: "Office Router") + #expect(method.displayName == "Office Router") + } + + @Test + func `Display name returns nil when not set`() { + let method = ConnectionMethod.wifi(host: "192.168.1.1", port: 5000, displayName: nil) + #expect(method.displayName == nil) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Models/DeviceDTOClientRepeatTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/DeviceDTOClientRepeatTests.swift index 284042ac..024ec2cd 100644 --- a/MC1Services/Tests/MC1ServicesTests/Models/DeviceDTOClientRepeatTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Models/DeviceDTOClientRepeatTests.swift @@ -1,261 +1,260 @@ import Foundation -import Testing -@testable import MeshCore @testable import MC1Services +@testable import MeshCore +import Testing @Suite("DeviceDTO Client Repeat Tests") struct DeviceDTOClientRepeatTests { - - // MARK: - Test Data - - private static let testSelfInfo = SelfInfo( - advertisementType: 1, - txPower: 20, - maxTxPower: 30, - publicKey: Data(repeating: 0xAB, count: 32), - latitude: 37.7749, - longitude: -122.4194, - multiAcks: 2, - advertisementLocationPolicy: 1, - telemetryModeEnvironment: 0, - telemetryModeLocation: 0, - telemetryModeBase: 2, - manualAddContacts: false, - radioFrequency: 906.875, - radioBandwidth: 250.0, - radioSpreadingFactor: 11, - radioCodingRate: 8, - name: "UpdatedNode" - ) - - private func makeDevice( - firmwareVersion: UInt8 = 9, - frequency: UInt32 = 915_000, - bandwidth: UInt32 = 250_000, - spreadingFactor: UInt8 = 8, - codingRate: UInt8 = 5, - clientRepeat: Bool = false, - preRepeatFrequency: UInt32? = nil, - preRepeatBandwidth: UInt32? = nil, - preRepeatSpreadingFactor: UInt8? = nil, - preRepeatCodingRate: UInt8? = nil - ) -> DeviceDTO { - DeviceDTO.testDevice( - firmwareVersion: firmwareVersion, - frequency: frequency, - bandwidth: bandwidth, - spreadingFactor: spreadingFactor, - codingRate: codingRate - ).copy { - $0.clientRepeat = clientRepeat - $0.preRepeatFrequency = preRepeatFrequency - $0.preRepeatBandwidth = preRepeatBandwidth - $0.preRepeatSpreadingFactor = preRepeatSpreadingFactor - $0.preRepeatCodingRate = preRepeatCodingRate - } + // MARK: - Test Data + + private static let testSelfInfo = SelfInfo( + advertisementType: 1, + txPower: 20, + maxTxPower: 30, + publicKey: Data(repeating: 0xAB, count: 32), + latitude: 37.7749, + longitude: -122.4194, + multiAcks: 2, + advertisementLocationPolicy: 1, + telemetryModeEnvironment: 0, + telemetryModeLocation: 0, + telemetryModeBase: 2, + manualAddContacts: false, + radioFrequency: 906.875, + radioBandwidth: 250.0, + radioSpreadingFactor: 11, + radioCodingRate: 8, + name: "UpdatedNode" + ) + + private func makeDevice( + firmwareVersion: UInt8 = 9, + frequency: UInt32 = 915_000, + bandwidth: UInt32 = 250_000, + spreadingFactor: UInt8 = 8, + codingRate: UInt8 = 5, + clientRepeat: Bool = false, + preRepeatFrequency: UInt32? = nil, + preRepeatBandwidth: UInt32? = nil, + preRepeatSpreadingFactor: UInt8? = nil, + preRepeatCodingRate: UInt8? = nil + ) -> DeviceDTO { + DeviceDTO.testDevice( + firmwareVersion: firmwareVersion, + frequency: frequency, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate + ).copy { + $0.clientRepeat = clientRepeat + $0.preRepeatFrequency = preRepeatFrequency + $0.preRepeatBandwidth = preRepeatBandwidth + $0.preRepeatSpreadingFactor = preRepeatSpreadingFactor + $0.preRepeatCodingRate = preRepeatCodingRate } - - @Test("test helper defaults radioID to id") - func testHelper_defaultsRadioIDToID() { - let id = UUID() - let device = DeviceDTO.testDevice(id: id) - - #expect(device.id == id) - #expect(device.radioID == id) + } + + @Test + func `helper defaults radioID to id`() { + let id = UUID() + let device = DeviceDTO.testDevice(id: id) + + #expect(device.id == id) + #expect(device.radioID == id) + } + + // MARK: - supportsClientRepeat + + @Test + func `supportsClientRepeat returns true for firmware v9`() { + let device = makeDevice(firmwareVersion: 9) + #expect(device.supportsClientRepeat == true) + } + + @Test + func `supportsClientRepeat returns false for firmware v8`() { + let device = makeDevice(firmwareVersion: 8) + #expect(device.supportsClientRepeat == false) + } + + @Test + func `supportsClientRepeat returns true for firmware v10`() { + let device = makeDevice(firmwareVersion: 10) + #expect(device.supportsClientRepeat == true) + } + + // MARK: - advert location policy + + @Test + func `sharesLocationPublicly is false for policy none`() { + let device = makeDevice().copy { $0.advertLocationPolicy = 0 } + #expect(device.sharesLocationPublicly == false) + #expect(device.advertLocationPolicyMode == .none) + } + + @Test + func `sharesLocationPublicly is true for policy share`() { + let device = makeDevice().copy { $0.advertLocationPolicy = 1 } + #expect(device.sharesLocationPublicly == true) + #expect(device.advertLocationPolicyMode == .share) + } + + @Test + func `sharesLocationPublicly is true for policy prefs`() { + let device = makeDevice().copy { $0.advertLocationPolicy = 2 } + #expect(device.sharesLocationPublicly == true) + #expect(device.advertLocationPolicyMode == .prefs) + } + + // MARK: - copy + + @Test + func `copy mutates only specified fields`() { + let device = makeDevice(clientRepeat: false, preRepeatFrequency: 906_875) + let updated = device.copy { $0.clientRepeat = true } + + #expect(updated.clientRepeat == true) + // Other fields unchanged + #expect(updated.nodeName == device.nodeName) + #expect(updated.frequency == device.frequency) + #expect(updated.preRepeatFrequency == 906_875) + } + + @Test + func `copy can set optional fields to nil`() { + let device = makeDevice(preRepeatFrequency: 915_000) + let updated = device.copy { $0.preRepeatFrequency = nil } + + #expect(updated.preRepeatFrequency == nil) + } + + @Test + func `copy can update multiple fields at once`() { + let device = makeDevice(clientRepeat: false) + let updated = device.copy { + $0.clientRepeat = true + $0.autoAddConfig = 0x0F } - // MARK: - supportsClientRepeat - - @Test("supportsClientRepeat returns true for firmware v9") - func supportsClientRepeat_v9_returnsTrue() { - let device = makeDevice(firmwareVersion: 9) - #expect(device.supportsClientRepeat == true) - } + #expect(updated.clientRepeat == true) + #expect(updated.autoAddConfig == 0x0F) + #expect(updated.nodeName == device.nodeName) + } - @Test("supportsClientRepeat returns false for firmware v8") - func supportsClientRepeat_v8_returnsFalse() { - let device = makeDevice(firmwareVersion: 8) - #expect(device.supportsClientRepeat == false) - } - - @Test("supportsClientRepeat returns true for firmware v10") - func supportsClientRepeat_v10_returnsTrue() { - let device = makeDevice(firmwareVersion: 10) - #expect(device.supportsClientRepeat == true) - } + // MARK: - savingPreRepeatSettings - // MARK: - advert location policy - - @Test("sharesLocationPublicly is false for policy none") - func sharesLocationPublicly_none() { - let device = makeDevice().copy { $0.advertLocationPolicy = 0 } - #expect(device.sharesLocationPublicly == false) - #expect(device.advertLocationPolicyMode == .none) - } - - @Test("sharesLocationPublicly is true for policy share") - func sharesLocationPublicly_share() { - let device = makeDevice().copy { $0.advertLocationPolicy = 1 } - #expect(device.sharesLocationPublicly == true) - #expect(device.advertLocationPolicyMode == .share) - } - - @Test("sharesLocationPublicly is true for policy prefs") - func sharesLocationPublicly_prefs() { - let device = makeDevice().copy { $0.advertLocationPolicy = 2 } - #expect(device.sharesLocationPublicly == true) - #expect(device.advertLocationPolicyMode == .prefs) - } - - // MARK: - copy - - @Test("copy mutates only specified fields") - func copy_mutatesOnlySpecifiedFields() { - let device = makeDevice(clientRepeat: false, preRepeatFrequency: 906_875) - let updated = device.copy { $0.clientRepeat = true } - - #expect(updated.clientRepeat == true) - // Other fields unchanged - #expect(updated.nodeName == device.nodeName) - #expect(updated.frequency == device.frequency) - #expect(updated.preRepeatFrequency == 906_875) - } - - @Test("copy can set optional fields to nil") - func copy_canSetOptionalToNil() { - let device = makeDevice(preRepeatFrequency: 915_000) - let updated = device.copy { $0.preRepeatFrequency = nil } - - #expect(updated.preRepeatFrequency == nil) - } - - @Test("copy can update multiple fields at once") - func copy_updatesMultipleFields() { - let device = makeDevice(clientRepeat: false) - let updated = device.copy { - $0.clientRepeat = true - $0.autoAddConfig = 0x0F - } - - #expect(updated.clientRepeat == true) - #expect(updated.autoAddConfig == 0x0F) - #expect(updated.nodeName == device.nodeName) - } - - // MARK: - savingPreRepeatSettings - - @Test("savingPreRepeatSettings copies current radio params") - func savingPreRepeatSettings_copiesCurrentRadioParams() { - let device = makeDevice( - frequency: 915_000, - bandwidth: 250_000, - spreadingFactor: 8, - codingRate: 5 - ) - let saved = device.savingPreRepeatSettings() - - #expect(saved.preRepeatFrequency == 915_000) - #expect(saved.preRepeatBandwidth == 250_000) - #expect(saved.preRepeatSpreadingFactor == 8) - #expect(saved.preRepeatCodingRate == 5) - } - - @Test("savingPreRepeatSettings preserves other fields") - func savingPreRepeatSettings_preservesOtherFields() { - let device = makeDevice(clientRepeat: true) - let saved = device.savingPreRepeatSettings() - - #expect(saved.clientRepeat == true) - #expect(saved.nodeName == device.nodeName) - #expect(saved.firmwareVersion == device.firmwareVersion) - } - - // MARK: - clearingPreRepeatSettings - - @Test("clearingPreRepeatSettings sets all preRepeat fields to nil") - func clearingPreRepeatSettings_setsAllToNil() { - let device = makeDevice( - preRepeatFrequency: 915_000, - preRepeatBandwidth: 250_000, - preRepeatSpreadingFactor: 8, - preRepeatCodingRate: 5 - ) - let cleared = device.clearingPreRepeatSettings() - - #expect(cleared.preRepeatFrequency == nil) - #expect(cleared.preRepeatBandwidth == nil) - #expect(cleared.preRepeatSpreadingFactor == nil) - #expect(cleared.preRepeatCodingRate == nil) - #expect(cleared.hasPreRepeatSettings == false) - } - - // MARK: - hasPreRepeatSettings - - @Test("hasPreRepeatSettings requires all four fields") - func hasPreRepeatSettings_requiresAllFourFields() { - // Only frequency set - let partial = makeDevice(preRepeatFrequency: 915_000) - #expect(partial.hasPreRepeatSettings == false) - - // All four set - let complete = makeDevice( - preRepeatFrequency: 915_000, - preRepeatBandwidth: 250_000, - preRepeatSpreadingFactor: 8, - preRepeatCodingRate: 5 - ) - #expect(complete.hasPreRepeatSettings == true) - } - - @Test("hasPreRepeatSettings returns false when all nil") - func hasPreRepeatSettings_allNil() { - let device = makeDevice() - #expect(device.hasPreRepeatSettings == false) - } - - // MARK: - updating(from: SelfInfo) carries forward client repeat fields - - @Test("updating(from: SelfInfo) carries forward clientRepeat") - func updatingFromSelfInfo_carriesForwardClientRepeat() { - let device = makeDevice(clientRepeat: true) - let updated = device.updating(from: Self.testSelfInfo) - - #expect(updated.clientRepeat == true) - } - - @Test("updating(from: SelfInfo) carries forward preRepeat settings") - func updatingFromSelfInfo_carriesForwardPreRepeatSettings() { - let device = makeDevice( - preRepeatFrequency: 915_000, - preRepeatBandwidth: 250_000, - preRepeatSpreadingFactor: 8, - preRepeatCodingRate: 5 - ) - let updated = device.updating(from: Self.testSelfInfo) - - #expect(updated.preRepeatFrequency == 915_000) - #expect(updated.preRepeatBandwidth == 250_000) - #expect(updated.preRepeatSpreadingFactor == 8) - #expect(updated.preRepeatCodingRate == 5) - #expect(updated.hasPreRepeatSettings == true) - } - - @Test("updating(from: SelfInfo) updates radio params from SelfInfo") - func updatingFromSelfInfo_updatesRadioParams() { - let device = makeDevice( - frequency: 915_000, - bandwidth: 250_000, - spreadingFactor: 8, - codingRate: 5 - ) - let updated = device.updating(from: Self.testSelfInfo) - - // SelfInfo has radioFrequency: 906.875 MHz -> 906875 kHz - #expect(updated.frequency == 906_875) - // SelfInfo has radioBandwidth: 250.0 kHz -> 250000 kHz - #expect(updated.bandwidth == 250_000) - #expect(updated.spreadingFactor == 11) - #expect(updated.codingRate == 8) - #expect(updated.nodeName == "UpdatedNode") - } + @Test + func `savingPreRepeatSettings copies current radio params`() { + let device = makeDevice( + frequency: 915_000, + bandwidth: 250_000, + spreadingFactor: 8, + codingRate: 5 + ) + let saved = device.savingPreRepeatSettings() + + #expect(saved.preRepeatFrequency == 915_000) + #expect(saved.preRepeatBandwidth == 250_000) + #expect(saved.preRepeatSpreadingFactor == 8) + #expect(saved.preRepeatCodingRate == 5) + } + + @Test + func `savingPreRepeatSettings preserves other fields`() { + let device = makeDevice(clientRepeat: true) + let saved = device.savingPreRepeatSettings() + + #expect(saved.clientRepeat == true) + #expect(saved.nodeName == device.nodeName) + #expect(saved.firmwareVersion == device.firmwareVersion) + } + + // MARK: - clearingPreRepeatSettings + + @Test + func `clearingPreRepeatSettings sets all preRepeat fields to nil`() { + let device = makeDevice( + preRepeatFrequency: 915_000, + preRepeatBandwidth: 250_000, + preRepeatSpreadingFactor: 8, + preRepeatCodingRate: 5 + ) + let cleared = device.clearingPreRepeatSettings() + + #expect(cleared.preRepeatFrequency == nil) + #expect(cleared.preRepeatBandwidth == nil) + #expect(cleared.preRepeatSpreadingFactor == nil) + #expect(cleared.preRepeatCodingRate == nil) + #expect(cleared.hasPreRepeatSettings == false) + } + + // MARK: - hasPreRepeatSettings + + @Test + func `hasPreRepeatSettings requires all four fields`() { + // Only frequency set + let partial = makeDevice(preRepeatFrequency: 915_000) + #expect(partial.hasPreRepeatSettings == false) + + // All four set + let complete = makeDevice( + preRepeatFrequency: 915_000, + preRepeatBandwidth: 250_000, + preRepeatSpreadingFactor: 8, + preRepeatCodingRate: 5 + ) + #expect(complete.hasPreRepeatSettings == true) + } + + @Test + func `hasPreRepeatSettings returns false when all nil`() { + let device = makeDevice() + #expect(device.hasPreRepeatSettings == false) + } + + // MARK: - updating(from: SelfInfo) carries forward client repeat fields + + @Test + func `updating(from: SelfInfo) carries forward clientRepeat`() { + let device = makeDevice(clientRepeat: true) + let updated = device.updating(from: Self.testSelfInfo) + + #expect(updated.clientRepeat == true) + } + + @Test + func `updating(from: SelfInfo) carries forward preRepeat settings`() { + let device = makeDevice( + preRepeatFrequency: 915_000, + preRepeatBandwidth: 250_000, + preRepeatSpreadingFactor: 8, + preRepeatCodingRate: 5 + ) + let updated = device.updating(from: Self.testSelfInfo) + + #expect(updated.preRepeatFrequency == 915_000) + #expect(updated.preRepeatBandwidth == 250_000) + #expect(updated.preRepeatSpreadingFactor == 8) + #expect(updated.preRepeatCodingRate == 5) + #expect(updated.hasPreRepeatSettings == true) + } + + @Test + func `updating(from: SelfInfo) updates radio params from SelfInfo`() { + let device = makeDevice( + frequency: 915_000, + bandwidth: 250_000, + spreadingFactor: 8, + codingRate: 5 + ) + let updated = device.updating(from: Self.testSelfInfo) + + // SelfInfo has radioFrequency: 906.875 MHz -> 906875 kHz + #expect(updated.frequency == 906_875) + // SelfInfo has radioBandwidth: 250.0 kHz -> 250000 kHz + #expect(updated.bandwidth == 250_000) + #expect(updated.spreadingFactor == 11) + #expect(updated.codingRate == 8) + #expect(updated.nodeName == "UpdatedNode") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Models/DevicePlatformTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/DevicePlatformTests.swift index 102b33af..97fdaef3 100644 --- a/MC1Services/Tests/MC1ServicesTests/Models/DevicePlatformTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Models/DevicePlatformTests.swift @@ -1,275 +1,281 @@ -import Testing @testable import MC1Services +import Testing @Suite("DevicePlatform Detection Tests") struct DevicePlatformTests { - - // MARK: - ESP32 Devices - - @Test("Heltec V2 detected as ESP32") - func heltecV2() { - #expect(DevicePlatform.detect(from: "Heltec V2") == .esp32) - } - - @Test("Heltec V3 detected as ESP32") - func heltecV3() { - #expect(DevicePlatform.detect(from: "Heltec V3") == .esp32) - } - - @Test("Heltec V4 detected as ESP32") - func heltecV4() { - #expect(DevicePlatform.detect(from: "Heltec V4") == .esp32) - } - - @Test("Heltec Tracker detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'Tracker' substring")) - func heltecTracker() { - #expect(DevicePlatform.detect(from: "Heltec Tracker") == .esp32) - } - - @Test("Heltec E290 detected as ESP32") - func heltecE290() { - #expect(DevicePlatform.detect(from: "Heltec E290") == .esp32) - } - - @Test("Heltec E213 detected as ESP32") - func heltecE213() { - #expect(DevicePlatform.detect(from: "Heltec E213") == .esp32) - } - - @Test("Heltec T190 detected as ESP32") - func heltecT190() { - #expect(DevicePlatform.detect(from: "Heltec T190") == .esp32) - } - - @Test("Heltec CT62 detected as ESP32") - func heltecCT62() { - #expect(DevicePlatform.detect(from: "Heltec CT62") == .esp32) - } - - @Test("T-Beam detected as ESP32") - func tBeam() { - #expect(DevicePlatform.detect(from: "T-Beam") == .esp32) - } - - @Test("T-Deck detected as ESP32") - func tDeck() { - #expect(DevicePlatform.detect(from: "T-Deck") == .esp32) - } - - @Test("T-LoRa detected as ESP32") - func tLora() { - #expect(DevicePlatform.detect(from: "T-LoRa") == .esp32) - } - - @Test("TLora detected as ESP32") - func tLoraAlt() { - #expect(DevicePlatform.detect(from: "TLora") == .esp32) - } - - @Test("Xiao S3 WIO detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'Seeed' vendor prefix")) - func xiaoS3WIO() { - #expect(DevicePlatform.detect(from: "Xiao S3 WIO") == .esp32) - } - - @Test("Xiao C3 detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'Seeed' vendor prefix")) - func xiaoC3() { - #expect(DevicePlatform.detect(from: "Xiao C3") == .esp32) - } - - @Test("Xiao C6 detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'Seeed' vendor prefix")) - func xiaoC6() { - #expect(DevicePlatform.detect(from: "Xiao C6") == .esp32) - } - - @Test("RAK 3112 detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'RAK' vendor prefix")) - func rak3112() { - #expect(DevicePlatform.detect(from: "RAK 3112") == .esp32) - } - - @Test("Station G2 detected as ESP32") - func stationG2() { - #expect(DevicePlatform.detect(from: "Station G2") == .esp32) - } - - @Test("Meshadventurer detected as ESP32") - func meshadventurer() { - #expect(DevicePlatform.detect(from: "Meshadventurer") == .esp32) - } - - @Test("Generic ESP32 detected as ESP32") - func genericESP32() { - #expect(DevicePlatform.detect(from: "Generic ESP32") == .esp32) - } - - @Test("ThinkNode M2 detected as ESP32", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Was wrongly matched as nRF52 by 'Seeed' or other vendor prefix")) - func thinkNodeM2() { - #expect(DevicePlatform.detect(from: "ThinkNode M2") == .esp32) - } - - // MARK: - nRF52 Devices - - @Test("MeshPocket detected as nRF52") - func meshPocket() { - #expect(DevicePlatform.detect(from: "MeshPocket") == .nrf52) - } - - @Test("Mesh Pocket (with space) detected as nRF52") - func meshPocketSpace() { - #expect(DevicePlatform.detect(from: "Mesh Pocket") == .nrf52) - } - - @Test("T114 detected as nRF52") - func t114() { - #expect(DevicePlatform.detect(from: "T114") == .nrf52) - } - - @Test("Mesh Solar detected as nRF52") - func meshSolar() { - #expect(DevicePlatform.detect(from: "Mesh Solar") == .nrf52) - } - - @Test("Xiao-nrf52 detected as nRF52") - func xiaoNrf52Dash() { - #expect(DevicePlatform.detect(from: "Xiao-nrf52") == .nrf52) - } - - @Test("Xiao_nrf52 detected as nRF52") - func xiaoNrf52Underscore() { - #expect(DevicePlatform.detect(from: "Xiao_nrf52") == .nrf52) - } - - @Test("WM1110 detected as nRF52") - func wm1110() { - #expect(DevicePlatform.detect(from: "WM1110") == .nrf52) - } - - @Test("Wio Tracker detected as nRF52") - func wioTracker() { - #expect(DevicePlatform.detect(from: "Wio Tracker") == .nrf52) - } - - @Test("T1000-E detected as nRF52") - func t1000E() { - #expect(DevicePlatform.detect(from: "T1000-E") == .nrf52) - } - - @Test("SenseCap Solar detected as nRF52") - func senseCapSolar() { - #expect(DevicePlatform.detect(from: "SenseCap Solar") == .nrf52) - } - - @Test("WisMesh Tag detected as nRF52") - func wisMeshTag() { - #expect(DevicePlatform.detect(from: "WisMesh Tag") == .nrf52) - } - - @Test("RAK 4631 detected as nRF52") - func rak4631() { - #expect(DevicePlatform.detect(from: "RAK 4631") == .nrf52) - } - - @Test("RAK 3401 detected as nRF52") - func rak3401() { - #expect(DevicePlatform.detect(from: "RAK 3401") == .nrf52) - } - - @Test("T-Echo detected as nRF52") - func tEcho() { - #expect(DevicePlatform.detect(from: "T-Echo") == .nrf52) - } - - @Test("ThinkNode-M1 detected as nRF52") - func thinkNodeM1() { - #expect(DevicePlatform.detect(from: "ThinkNode-M1") == .nrf52) - } - - @Test("ThinkNode M3 detected as nRF52") - func thinkNodeM3() { - #expect(DevicePlatform.detect(from: "ThinkNode M3") == .nrf52) - } - - @Test("ThinkNode-M6 detected as nRF52") - func thinkNodeM6() { - #expect(DevicePlatform.detect(from: "ThinkNode-M6") == .nrf52) - } - - @Test("Ikoka detected as nRF52") - func ikoka() { - #expect(DevicePlatform.detect(from: "Ikoka") == .nrf52) - } - - @Test("ProMicro detected as nRF52") - func proMicro() { - #expect(DevicePlatform.detect(from: "ProMicro") == .nrf52) - } - - @Test("Minewsemi detected as nRF52") - func minewsemi() { - #expect(DevicePlatform.detect(from: "Minewsemi") == .nrf52) - } - - @Test("Meshtiny detected as nRF52") - func meshtiny() { - #expect(DevicePlatform.detect(from: "Meshtiny") == .nrf52) - } - - @Test("Keepteen detected as nRF52") - func keepteen() { - #expect(DevicePlatform.detect(from: "Keepteen") == .nrf52) - } - - @Test("Nano G2 Ultra detected as nRF52") - func nanoG2Ultra() { - #expect(DevicePlatform.detect(from: "Nano G2 Ultra") == .nrf52) - } - - // MARK: - Regression: Vendor prefix no longer causes wrong match - - @Test("Bare 'Heltec' vendor name is unknown (not assumed ESP32)", - .bug("https://github.com/pocketmesh/pocketmesh/issues/0", - "Old code matched 'Heltec' prefix as ESP32, but Heltec ships nRF52 devices too")) - func bareHeltecUnknown() { - #expect(DevicePlatform.detect(from: "Heltec") == .unknown) - } - - // MARK: - Edge Cases - - @Test("Empty model string returns unknown") - func emptyString() { - #expect(DevicePlatform.detect(from: "") == .unknown) - } - - @Test("Unrecognized device returns unknown") - func unrecognizedDevice() { - #expect(DevicePlatform.detect(from: "SomeNewDevice XYZ") == .unknown) - } - - // MARK: - Pacing Values - - @Test("ESP32 pacing is 60ms") - func esp32Pacing() { - #expect(DevicePlatform.esp32.recommendedWritePacing == 0.060) - } - - @Test("nRF52 pacing is 25ms") - func nrf52Pacing() { - #expect(DevicePlatform.nrf52.recommendedWritePacing == 0.025) - } - - @Test("Unknown pacing is 60ms (conservative default)") - func unknownPacing() { - #expect(DevicePlatform.unknown.recommendedWritePacing == 0.060) - } + // MARK: - ESP32 Devices + + @Test + func `Heltec V2 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec V2") == .esp32) + } + + @Test + func `Heltec V3 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec V3") == .esp32) + } + + @Test + func `Heltec V4 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec V4") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'Tracker' substring") + ) + func `Heltec Tracker detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec Tracker") == .esp32) + } + + @Test + func `Heltec E290 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec E290") == .esp32) + } + + @Test + func `Heltec E213 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec E213") == .esp32) + } + + @Test + func `Heltec T190 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec T190") == .esp32) + } + + @Test + func `Heltec CT62 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Heltec CT62") == .esp32) + } + + @Test + func `T-Beam detected as ESP32`() { + #expect(DevicePlatform.detect(from: "T-Beam") == .esp32) + } + + @Test + func `T-Deck detected as ESP32`() { + #expect(DevicePlatform.detect(from: "T-Deck") == .esp32) + } + + @Test + func `T-LoRa detected as ESP32`() { + #expect(DevicePlatform.detect(from: "T-LoRa") == .esp32) + } + + @Test + func `TLora detected as ESP32`() { + #expect(DevicePlatform.detect(from: "TLora") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'Seeed' vendor prefix") + ) + func `Xiao S3 WIO detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Xiao S3 WIO") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'Seeed' vendor prefix") + ) + func `Xiao C3 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Xiao C3") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'Seeed' vendor prefix") + ) + func `Xiao C6 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Xiao C6") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'RAK' vendor prefix") + ) + func `RAK 3112 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "RAK 3112") == .esp32) + } + + @Test + func `Station G2 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Station G2") == .esp32) + } + + @Test + func `Meshadventurer detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Meshadventurer") == .esp32) + } + + @Test + func `Generic ESP32 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "Generic ESP32") == .esp32) + } + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Was wrongly matched as nRF52 by 'Seeed' or other vendor prefix") + ) + func `ThinkNode M2 detected as ESP32`() { + #expect(DevicePlatform.detect(from: "ThinkNode M2") == .esp32) + } + + // MARK: - nRF52 Devices + + @Test + func `MeshPocket detected as nRF52`() { + #expect(DevicePlatform.detect(from: "MeshPocket") == .nrf52) + } + + @Test + func `Mesh Pocket (with space) detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Mesh Pocket") == .nrf52) + } + + @Test + func `T114 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "T114") == .nrf52) + } + + @Test + func `Mesh Solar detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Mesh Solar") == .nrf52) + } + + @Test + func `Xiao-nrf52 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Xiao-nrf52") == .nrf52) + } + + @Test + func `Xiao_nrf52 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Xiao_nrf52") == .nrf52) + } + + @Test + func `WM1110 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "WM1110") == .nrf52) + } + + @Test + func `Wio Tracker detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Wio Tracker") == .nrf52) + } + + @Test + func `T1000-E detected as nRF52`() { + #expect(DevicePlatform.detect(from: "T1000-E") == .nrf52) + } + + @Test + func `SenseCap Solar detected as nRF52`() { + #expect(DevicePlatform.detect(from: "SenseCap Solar") == .nrf52) + } + + @Test + func `WisMesh Tag detected as nRF52`() { + #expect(DevicePlatform.detect(from: "WisMesh Tag") == .nrf52) + } + + @Test + func `RAK 4631 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "RAK 4631") == .nrf52) + } + + @Test + func `RAK 3401 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "RAK 3401") == .nrf52) + } + + @Test + func `T-Echo detected as nRF52`() { + #expect(DevicePlatform.detect(from: "T-Echo") == .nrf52) + } + + @Test + func `ThinkNode-M1 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "ThinkNode-M1") == .nrf52) + } + + @Test + func `ThinkNode M3 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "ThinkNode M3") == .nrf52) + } + + @Test + func `ThinkNode-M6 detected as nRF52`() { + #expect(DevicePlatform.detect(from: "ThinkNode-M6") == .nrf52) + } + + @Test + func `Ikoka detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Ikoka") == .nrf52) + } + + @Test + func `ProMicro detected as nRF52`() { + #expect(DevicePlatform.detect(from: "ProMicro") == .nrf52) + } + + @Test + func `Minewsemi detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Minewsemi") == .nrf52) + } + + @Test + func `Meshtiny detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Meshtiny") == .nrf52) + } + + @Test + func `Keepteen detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Keepteen") == .nrf52) + } + + @Test + func `Nano G2 Ultra detected as nRF52`() { + #expect(DevicePlatform.detect(from: "Nano G2 Ultra") == .nrf52) + } + + // MARK: - Regression: Vendor prefix no longer causes wrong match + + @Test( + .bug("https://github.com/pocketmesh/pocketmesh/issues/0", + "Old code matched 'Heltec' prefix as ESP32, but Heltec ships nRF52 devices too") + ) + func `Bare 'Heltec' vendor name is unknown (not assumed ESP32)`() { + #expect(DevicePlatform.detect(from: "Heltec") == .unknown) + } + + // MARK: - Edge Cases + + @Test + func `Empty model string returns unknown`() { + #expect(DevicePlatform.detect(from: "") == .unknown) + } + + @Test + func `Unrecognized device returns unknown`() { + #expect(DevicePlatform.detect(from: "SomeNewDevice XYZ") == .unknown) + } + + // MARK: - Pacing Values + + @Test + func `ESP32 pacing is 60ms`() { + #expect(DevicePlatform.esp32.recommendedWritePacing == 0.060) + } + + @Test + func `nRF52 pacing is 25ms`() { + #expect(DevicePlatform.nrf52.recommendedWritePacing == 0.025) + } + + @Test + func `Unknown pacing is 60ms (conservative default)`() { + #expect(DevicePlatform.unknown.recommendedWritePacing == 0.060) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift b/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift index f32cd351..78c49de6 100644 --- a/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift @@ -1,186 +1,185 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("NodeSettingsResponseParser") struct NodeSettingsResponseParserTests { - - // MARK: - Settings Field Matching - - @Test("Radio response matches the radio field through the prompt prefix") - func radioResponse() { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: "> 915.000,250.0,10,5", - checking: [.radio, .txPower] - ) - #expect(value == .radio(frequency: 915.0, bandwidth: 250.0, spreadingFactor: 10, codingRate: 5)) - } - - @Test("Integer response matches TX power") - func txPowerResponse() { - let value = NodeSettingsResponseParser.firstSettingsValue(in: "22", checking: [.txPower]) - #expect(value == .txPower(22)) - } - - @Test("Version response matches firmware version with and without MeshCore prefix") - func versionResponse() { - let prefixed = NodeSettingsResponseParser.firstSettingsValue( - in: "MeshCore v1.11.0 (2025-04-18)", - checking: [.firmwareVersion] - ) - #expect(prefixed == .firmwareVersion("MeshCore v1.11.0 (2025-04-18)")) - - let bare = NodeSettingsResponseParser.firstSettingsValue( - in: "v1.10.0 (2025-03-02)", - checking: [.firmwareVersion] - ) - #expect(bare == .firmwareVersion("v1.10.0 (2025-03-02)")) - } - - @Test("Clock response matches device time") - func deviceTimeResponse() { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: "06:40 - 18/4/2025 UTC", - checking: [.deviceTime] - ) - #expect(value == .deviceTime("06:40 - 18/4/2025 UTC")) - } - - @Test("Numeric responses match latitude and longitude") - func coordinateResponses() { - let lat = NodeSettingsResponseParser.firstSettingsValue(in: "-36.8485", checking: [.latitude]) - #expect(lat == .latitude(-36.8485)) - - let lon = NodeSettingsResponseParser.firstSettingsValue(in: "174.7633", checking: [.longitude]) - #expect(lon == .longitude(174.7633)) - } - - @Test("Free-form text falls through numeric fields to name") - func nameOrderingAfterCoordinates() { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: "Alpha Repeater", - checking: [.latitude, .longitude, .name] - ) - #expect(value == .name("Alpha Repeater")) - } - - @Test("A bare number is captured by latitude before name") - func numericCaptureOrdering() { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: "12.5", - checking: [.latitude, .name] - ) - #expect(value == .latitude(12.5)) - } - - @Test("Owner info keeps the wire pipe separator") - func ownerInfoResponse() { - let value = NodeSettingsResponseParser.firstSettingsValue( - in: "Contact: KD7ABC|ch 31", - checking: [.ownerInfo] - ) - #expect(value == .ownerInfo("Contact: KD7ABC|ch 31")) - } - - @Test("OK and error responses match no settings field") - func okAndErrorResponses() { - let fields: [NodeSettingsResponseParser.SettingsField] = [ - .radio, .txPower, .firmwareVersion, .latitude, .longitude, .name, .ownerInfo, - ] - #expect(NodeSettingsResponseParser.firstSettingsValue(in: "OK", checking: fields) == nil) - #expect(NodeSettingsResponseParser.firstSettingsValue(in: "ERR: no such setting", checking: fields) == nil) - } - - @Test("Empty field list never matches") - func emptyFieldList() { - #expect(NodeSettingsResponseParser.firstSettingsValue(in: "22", checking: []) == nil) - } - - // MARK: - Behavior Fields - - @Test("Integer fills the first missing behavior field in fixed order") - func behaviorOrdering() { - let advert = NodeSettingsResponseParser.behaviorLateResponse( - "90", hasAdvertInterval: false, hasFloodInterval: false, hasFloodMaxHops: false - ) - #expect(advert == .advertInterval(90)) - - let flood = NodeSettingsResponseParser.behaviorLateResponse( - "24", hasAdvertInterval: true, hasFloodInterval: false, hasFloodMaxHops: false - ) - #expect(flood == .floodAdvertInterval(24)) - - let hops = NodeSettingsResponseParser.behaviorLateResponse( - "8", hasAdvertInterval: true, hasFloodInterval: true, hasFloodMaxHops: false - ) - #expect(hops == .floodMax(8)) - } - - @Test("Behavior matching returns nil when all fields are present or text is non-numeric") - func behaviorNoMatch() { - #expect(NodeSettingsResponseParser.behaviorLateResponse( - "90", hasAdvertInterval: true, hasFloodInterval: true, hasFloodMaxHops: true - ) == nil) - #expect(NodeSettingsResponseParser.behaviorLateResponse( - "Alpha Repeater", hasAdvertInterval: false, hasFloodInterval: false, hasFloodMaxHops: false - ) == nil) - } - - // MARK: - Device Clock - - @Test("Firmware clock response parses to the exact UTC date") - func clockResponseParses() throws { - let date = try #require(NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025 UTC")) - - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = try #require(TimeZone(identifier: "UTC")) - let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) - #expect(components.year == 2025) - #expect(components.month == 4) - #expect(components.day == 18) - #expect(components.hour == 6) - #expect(components.minute == 40) - } - - @Test("Non-clock text returns nil") - func clockResponseRejectsOtherText() { - #expect(NodeSettingsResponseParser.utcDate(fromClockResponse: "Alpha Repeater") == nil) - #expect(NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025") == nil) - } - - // MARK: - Clock Sync - - @Test("Clock sync outcomes classify OK, clock-ahead, generic error, and unexpected text") - func clockSyncClassification() { - #expect(NodeSettingsResponseParser.classifyClockSyncResponse("OK - clock set") == .synced) - #expect(NodeSettingsResponseParser.classifyClockSyncResponse( - "ERR: clock cannot go backwards" - ) == .clockAhead) - #expect(NodeSettingsResponseParser.classifyClockSyncResponse( - "ERR: invalid time" - ) == .failed(message: "invalid time")) - #expect(NodeSettingsResponseParser.classifyClockSyncResponse("hello") == .unexpected) - } - - // MARK: - Password - - @Test("Password change succeeds on OK or the firmware echo, fails otherwise") - func passwordClassification() { - #expect(NodeSettingsResponseParser.isPasswordChangeSuccessful("> password now: hunter2")) - #expect(NodeSettingsResponseParser.isPasswordChangeSuccessful("OK")) - #expect(!NodeSettingsResponseParser.isPasswordChangeSuccessful("ERR: bad password")) - #expect(!NodeSettingsResponseParser.isPasswordChangeSuccessful("Alpha Repeater")) - } - - // MARK: - Owner Info - - @Test("Owner info wire and display forms round-trip") - func ownerInfoMapping() { - #expect(NodeSettingsResponseParser.displayOwnerInfo(fromWire: "KD7ABC|ch 31") == "KD7ABC\nch 31") - #expect(NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: "KD7ABC\nch 31") == "KD7ABC|ch 31") - let display = "line one\nline two\nline three" - #expect(NodeSettingsResponseParser.displayOwnerInfo( - fromWire: NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: display) - ) == display) - } + // MARK: - Settings Field Matching + + @Test + func `Radio response matches the radio field through the prompt prefix`() { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: "> 915.000,250.0,10,5", + checking: [.radio, .txPower] + ) + #expect(value == .radio(frequency: 915.0, bandwidth: 250.0, spreadingFactor: 10, codingRate: 5)) + } + + @Test + func `Integer response matches TX power`() { + let value = NodeSettingsResponseParser.firstSettingsValue(in: "22", checking: [.txPower]) + #expect(value == .txPower(22)) + } + + @Test + func `Version response matches firmware version with and without MeshCore prefix`() { + let prefixed = NodeSettingsResponseParser.firstSettingsValue( + in: "MeshCore v1.11.0 (2025-04-18)", + checking: [.firmwareVersion] + ) + #expect(prefixed == .firmwareVersion("MeshCore v1.11.0 (2025-04-18)")) + + let bare = NodeSettingsResponseParser.firstSettingsValue( + in: "v1.10.0 (2025-03-02)", + checking: [.firmwareVersion] + ) + #expect(bare == .firmwareVersion("v1.10.0 (2025-03-02)")) + } + + @Test + func `Clock response matches device time`() { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: "06:40 - 18/4/2025 UTC", + checking: [.deviceTime] + ) + #expect(value == .deviceTime("06:40 - 18/4/2025 UTC")) + } + + @Test + func `Numeric responses match latitude and longitude`() { + let lat = NodeSettingsResponseParser.firstSettingsValue(in: "-36.8485", checking: [.latitude]) + #expect(lat == .latitude(-36.8485)) + + let lon = NodeSettingsResponseParser.firstSettingsValue(in: "174.7633", checking: [.longitude]) + #expect(lon == .longitude(174.7633)) + } + + @Test + func `Free-form text falls through numeric fields to name`() { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: "Alpha Repeater", + checking: [.latitude, .longitude, .name] + ) + #expect(value == .name("Alpha Repeater")) + } + + @Test + func `A bare number is captured by latitude before name`() { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: "12.5", + checking: [.latitude, .name] + ) + #expect(value == .latitude(12.5)) + } + + @Test + func `Owner info keeps the wire pipe separator`() { + let value = NodeSettingsResponseParser.firstSettingsValue( + in: "Contact: KD7ABC|ch 31", + checking: [.ownerInfo] + ) + #expect(value == .ownerInfo("Contact: KD7ABC|ch 31")) + } + + @Test + func `OK and error responses match no settings field`() { + let fields: [NodeSettingsResponseParser.SettingsField] = [ + .radio, .txPower, .firmwareVersion, .latitude, .longitude, .name, .ownerInfo, + ] + #expect(NodeSettingsResponseParser.firstSettingsValue(in: "OK", checking: fields) == nil) + #expect(NodeSettingsResponseParser.firstSettingsValue(in: "ERR: no such setting", checking: fields) == nil) + } + + @Test + func `Empty field list never matches`() { + #expect(NodeSettingsResponseParser.firstSettingsValue(in: "22", checking: []) == nil) + } + + // MARK: - Behavior Fields + + @Test + func `Integer fills the first missing behavior field in fixed order`() { + let advert = NodeSettingsResponseParser.behaviorLateResponse( + "90", hasAdvertInterval: false, hasFloodInterval: false, hasFloodMaxHops: false + ) + #expect(advert == .advertInterval(90)) + + let flood = NodeSettingsResponseParser.behaviorLateResponse( + "24", hasAdvertInterval: true, hasFloodInterval: false, hasFloodMaxHops: false + ) + #expect(flood == .floodAdvertInterval(24)) + + let hops = NodeSettingsResponseParser.behaviorLateResponse( + "8", hasAdvertInterval: true, hasFloodInterval: true, hasFloodMaxHops: false + ) + #expect(hops == .floodMax(8)) + } + + @Test + func `Behavior matching returns nil when all fields are present or text is non-numeric`() { + #expect(NodeSettingsResponseParser.behaviorLateResponse( + "90", hasAdvertInterval: true, hasFloodInterval: true, hasFloodMaxHops: true + ) == nil) + #expect(NodeSettingsResponseParser.behaviorLateResponse( + "Alpha Repeater", hasAdvertInterval: false, hasFloodInterval: false, hasFloodMaxHops: false + ) == nil) + } + + // MARK: - Device Clock + + @Test + func `Firmware clock response parses to the exact UTC date`() throws { + let date = try #require(NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025 UTC")) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) + #expect(components.year == 2025) + #expect(components.month == 4) + #expect(components.day == 18) + #expect(components.hour == 6) + #expect(components.minute == 40) + } + + @Test + func `Non-clock text returns nil`() { + #expect(NodeSettingsResponseParser.utcDate(fromClockResponse: "Alpha Repeater") == nil) + #expect(NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025") == nil) + } + + // MARK: - Clock Sync + + @Test + func `Clock sync outcomes classify OK, clock-ahead, generic error, and unexpected text`() { + #expect(NodeSettingsResponseParser.classifyClockSyncResponse("OK - clock set") == .synced) + #expect(NodeSettingsResponseParser.classifyClockSyncResponse( + "ERR: clock cannot go backwards" + ) == .clockAhead) + #expect(NodeSettingsResponseParser.classifyClockSyncResponse( + "ERR: invalid time" + ) == .failed(message: "invalid time")) + #expect(NodeSettingsResponseParser.classifyClockSyncResponse("hello") == .unexpected) + } + + // MARK: - Password + + @Test + func `Password change succeeds on OK or the firmware echo, fails otherwise`() { + #expect(NodeSettingsResponseParser.isPasswordChangeSuccessful("> password now: hunter2")) + #expect(NodeSettingsResponseParser.isPasswordChangeSuccessful("OK")) + #expect(!NodeSettingsResponseParser.isPasswordChangeSuccessful("ERR: bad password")) + #expect(!NodeSettingsResponseParser.isPasswordChangeSuccessful("Alpha Repeater")) + } + + // MARK: - Owner Info + + @Test + func `Owner info wire and display forms round-trip`() { + #expect(NodeSettingsResponseParser.displayOwnerInfo(fromWire: "KD7ABC|ch 31") == "KD7ABC\nch 31") + #expect(NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: "KD7ABC\nch 31") == "KD7ABC|ch 31") + let display = "line one\nline two\nline three" + #expect(NodeSettingsResponseParser.displayOwnerInfo( + fromWire: NodeSettingsResponseParser.wireOwnerInfo(fromDisplay: display) + ) == display) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift index 62e29da6..4d20020b 100644 --- a/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift @@ -1,562 +1,561 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("NodeSnapshotService Tests") struct NodeSnapshotServiceTests { - - private let testPublicKey = Data(repeating: 0x42, count: 32) - - private func createTestService() async throws -> (NodeSnapshotService, PersistenceStore) { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let service = NodeSnapshotService(dataStore: store) - return (service, store) - } - - private func metrics(battery: UInt16, uptime: UInt32?) -> NodeStatusMetrics { - NodeStatusMetrics( - batteryMillivolts: battery, - lastSNR: 8.5, - lastRSSI: -87, - noiseFloor: -120, - uptimeSeconds: uptime, - rxAirtimeSeconds: 100, - packetsSent: 500, - packetsReceived: 1000, - receiveErrors: nil - ) - } - - @Test("Record returns an ID on first capture") - func recordFirstSnapshot() async throws { - let (service, _) = try await createTestService() - - let id = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - - #expect(id != nil) - } - - @Test("Second status within the window enriches the same row, not a new one") - func inWindowStatusKeepsSingleRow() async throws { - let (service, _) = try await createTestService() - - let first = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - #expect(first != nil) - - let second = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3900, uptime: 7200) - ) - #expect(second == first, "An in-window status capture returns the existing row's ID") - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 1, "No second snapshot is created within the window") - #expect(snapshots[0].batteryMillivolts == 3850, "A row already carrying status is not overwritten") - #expect(snapshots[0].uptimeSeconds == 3600) - } - - @Test("Different nodes get independent snapshots") - func differentNodesIndependent() async throws { - let (service, _) = try await createTestService() - let otherKey = Data(repeating: 0x99, count: 32) - - let first = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - let second = await service.recordSnapshot( - nodePublicKey: otherKey, - status: metrics(battery: 3700, uptime: 1800) - ) - - #expect(first != nil) - #expect(second != nil) - #expect(first != second, "Different nodes are not throttled against each other") - } - - @Test("Neighbors enrich the in-window snapshot") - func neighborsEnrichInWindow() async throws { - let (service, store) = try await createTestService() - - let statusID = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - let neighbors = [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 5.5, secondsAgo: 30) - ] - let neighborID = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: neighbors) - #expect(neighborID == statusID, "Neighbors land on the existing in-window row") - - let latest = try await store.fetchLatestNodeStatusSnapshot(nodePublicKey: testPublicKey) - #expect(latest?.neighborSnapshots?.count == 1) - #expect(latest?.neighborSnapshots?.first?.snr == 5.5) + private let testPublicKey = Data(repeating: 0x42, count: 32) + + private func createTestService() async throws -> (NodeSnapshotService, PersistenceStore) { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let service = NodeSnapshotService(dataStore: store) + return (service, store) + } + + private func metrics(battery: UInt16, uptime: UInt32?) -> NodeStatusMetrics { + NodeStatusMetrics( + batteryMillivolts: battery, + lastSNR: 8.5, + lastRSSI: -87, + noiseFloor: -120, + uptimeSeconds: uptime, + rxAirtimeSeconds: 100, + packetsSent: 500, + packetsReceived: 1000, + receiveErrors: nil + ) + } + + @Test + func `Record returns an ID on first capture`() async throws { + let (service, _) = try await createTestService() + + let id = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + + #expect(id != nil) + } + + @Test + func `Second status within the window enriches the same row, not a new one`() async throws { + let (service, _) = try await createTestService() + + let first = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + #expect(first != nil) + + let second = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3900, uptime: 7200) + ) + #expect(second == first, "An in-window status capture returns the existing row's ID") + + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 1, "No second snapshot is created within the window") + #expect(snapshots[0].batteryMillivolts == 3850, "A row already carrying status is not overwritten") + #expect(snapshots[0].uptimeSeconds == 3600) + } + + @Test + func `Different nodes get independent snapshots`() async throws { + let (service, _) = try await createTestService() + let otherKey = Data(repeating: 0x99, count: 32) + + let first = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + let second = await service.recordSnapshot( + nodePublicKey: otherKey, + status: metrics(battery: 3700, uptime: 1800) + ) + + #expect(first != nil) + #expect(second != nil) + #expect(first != second, "Different nodes are not throttled against each other") + } + + @Test + func `Neighbors enrich the in-window snapshot`() async throws { + let (service, store) = try await createTestService() + + let statusID = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + let neighbors = [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 5.5, secondsAgo: 30) + ] + let neighborID = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: neighbors) + #expect(neighborID == statusID, "Neighbors land on the existing in-window row") + + let latest = try await store.fetchLatestNodeStatusSnapshot(nodePublicKey: testPublicKey) + #expect(latest?.neighborSnapshots?.count == 1) + #expect(latest?.neighborSnapshots?.first?.snr == 5.5) + } + + @Test + func `Telemetry enriches the in-window snapshot`() async throws { + let (service, store) = try await createTestService() + + let statusID = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + let telemetry = [ + TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 32.5) + ] + let telemetryID = await service.recordSnapshot(nodePublicKey: testPublicKey, telemetry: telemetry) + #expect(telemetryID == statusID, "Telemetry lands on the existing in-window row") + + let latest = try await store.fetchLatestNodeStatusSnapshot(nodePublicKey: testPublicKey) + #expect(latest?.telemetryEntries?.count == 1) + #expect(latest?.telemetryEntries?.first?.value == 32.5) + } + + @Test + func `Fetch snapshots returns ascending order`() async throws { + let (service, store) = try await createTestService() + let t1 = Date.now.addingTimeInterval(-20) + let t2 = Date.now.addingTimeInterval(-10) + + _ = try await store.saveNodeStatusSnapshot( + timestamp: t1, + nodePublicKey: testPublicKey, + batteryMillivolts: 3600, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + _ = try await store.saveNodeStatusSnapshot( + timestamp: t2, + nodePublicKey: testPublicKey, + batteryMillivolts: 3800, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 2) + #expect(snapshots[0].batteryMillivolts == 3600) + #expect(snapshots[1].batteryMillivolts == 3800) + } + + @Test + func `Prune only deletes snapshots older than cutoff`() async throws { + let (service, store) = try await createTestService() + let oldTime = Date.now.addingTimeInterval(-60) + let cutoff = Date.now.addingTimeInterval(-30) + let recentTime = Date.now.addingTimeInterval(-10) + + // Save an "old" snapshot by writing directly to the store (bypass throttle) + _ = try await store.saveNodeStatusSnapshot( + timestamp: oldTime, + nodePublicKey: testPublicKey, + batteryMillivolts: 3600, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + + // Save a "recent" snapshot + let recentID = try await store.saveNodeStatusSnapshot( + timestamp: recentTime, + nodePublicKey: testPublicKey, + batteryMillivolts: 3800, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + await service.pruneOldSnapshots(olderThan: cutoff) + + let remaining = await service.fetchSnapshots(for: testPublicKey) + #expect(remaining.count == 1, "Old snapshot should be pruned, recent should remain") + #expect(remaining.first?.id == recentID) + } + + @Test + func `Prune with future cutoff does not delete recent snapshots`() async throws { + let (service, store) = try await createTestService() + + _ = try await store.saveNodeStatusSnapshot( + nodePublicKey: testPublicKey, + batteryMillivolts: 3850, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + + // Prune with a cutoff 1 year ago — recent data should survive + let oneYearAgo = try #require(Calendar.current.date(byAdding: .year, value: -1, to: .now)) + await service.pruneOldSnapshots(olderThan: oneYearAgo) + + let remaining = await service.fetchSnapshots(for: testPublicKey) + #expect(remaining.count == 1, "Recent snapshot should not be pruned") + } + + @Test + func `Fetch snapshots with since filter`() async throws { + let (service, store) = try await createTestService() + let t1 = Date.now.addingTimeInterval(-30) + let cutoff = Date.now.addingTimeInterval(-15) + let t2 = Date.now.addingTimeInterval(-5) + + _ = try await store.saveNodeStatusSnapshot( + timestamp: t1, + nodePublicKey: testPublicKey, + batteryMillivolts: 3600, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + _ = try await store.saveNodeStatusSnapshot( + timestamp: t2, + nodePublicKey: testPublicKey, + batteryMillivolts: 3800, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + + let snapshots = await service.fetchSnapshots(for: testPublicKey, since: cutoff) + #expect(snapshots.count == 1) + #expect(snapshots[0].batteryMillivolts == 3800) + } + + // MARK: - Round-trip enrichment tests + + @Test + func `Enrichment data survives save -> enrich -> fetchAll round-trip`() async throws { + let (service, store) = try await createTestService() + + // Save two snapshots directly to the store (bypass the window) + let t1 = Date.now.addingTimeInterval(-20) + let t2 = Date.now.addingTimeInterval(-10) + let id1 = try await store.saveNodeStatusSnapshot( + timestamp: t1, + nodePublicKey: testPublicKey, + batteryMillivolts: 3600, + lastSNR: 7.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + let id2 = try await store.saveNodeStatusSnapshot( + timestamp: t2, + nodePublicKey: testPublicKey, + batteryMillivolts: 3800, + lastSNR: 8.5, lastRSSI: -85, noiseFloor: -118, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + + // Enrich both directly through the store, targeting specific rows + let telemetry1 = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 25.0)] + let telemetry2 = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 30.0)] + try await store.updateSnapshotTelemetry(id: id1, telemetry: telemetry1) + try await store.updateSnapshotTelemetry(id: id2, telemetry: telemetry2) + + let neighbors1 = [NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 5.0, secondsAgo: 60)] + let neighbors2 = [NeighborSnapshotEntry(publicKeyPrefix: Data([0x05, 0x06, 0x07, 0x08]), snr: 9.0, secondsAgo: 10)] + try await store.updateSnapshotNeighbors(id: id1, neighbors: neighbors1) + try await store.updateSnapshotNeighbors(id: id2, neighbors: neighbors2) + + // Fetch all via the method used by history views + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 2) + + #expect(snapshots[0].telemetryEntries?.count == 1, "Snapshot 1 telemetry should persist") + #expect(snapshots[0].telemetryEntries?.first?.value == 25.0) + #expect(snapshots[0].neighborSnapshots?.count == 1, "Snapshot 1 neighbors should persist") + #expect(snapshots[0].neighborSnapshots?.first?.snr == 5.0) + + #expect(snapshots[1].telemetryEntries?.count == 1, "Snapshot 2 telemetry should persist") + #expect(snapshots[1].telemetryEntries?.first?.value == 30.0) + #expect(snapshots[1].neighborSnapshots?.count == 1, "Snapshot 2 neighbors should persist") + #expect(snapshots[1].neighborSnapshots?.first?.snr == 9.0) + } + + @Test + func `Status + telemetry + neighbors in one window collapse onto a single row`() async throws { + let (service, _) = try await createTestService() + + let statusID = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3700, uptime: 3600) + ) + guard let snapshotID = statusID else { + Issue.record("First capture should return an ID") + return } - @Test("Telemetry enriches the in-window snapshot") - func telemetryEnrichInWindow() async throws { - let (service, store) = try await createTestService() - - let statusID = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - let telemetry = [ - TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 32.5) - ] - let telemetryID = await service.recordSnapshot(nodePublicKey: testPublicKey, telemetry: telemetry) - #expect(telemetryID == statusID, "Telemetry lands on the existing in-window row") - - let latest = try await store.fetchLatestNodeStatusSnapshot(nodePublicKey: testPublicKey) - #expect(latest?.telemetryEntries?.count == 1) - #expect(latest?.telemetryEntries?.first?.value == 32.5) - } - - @Test("Fetch snapshots returns ascending order") - func fetchSnapshotsOrdering() async throws { - let (service, store) = try await createTestService() - let t1 = Date.now.addingTimeInterval(-20) - let t2 = Date.now.addingTimeInterval(-10) - - _ = try await store.saveNodeStatusSnapshot( - timestamp: t1, - nodePublicKey: testPublicKey, - batteryMillivolts: 3600, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - _ = try await store.saveNodeStatusSnapshot( - timestamp: t2, - nodePublicKey: testPublicKey, - batteryMillivolts: 3800, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 2) - #expect(snapshots[0].batteryMillivolts == 3600) - #expect(snapshots[1].batteryMillivolts == 3800) - } - - @Test("Prune only deletes snapshots older than cutoff") - func pruneOldSnapshots() async throws { - let (service, store) = try await createTestService() - let oldTime = Date.now.addingTimeInterval(-60) - let cutoff = Date.now.addingTimeInterval(-30) - let recentTime = Date.now.addingTimeInterval(-10) - - // Save an "old" snapshot by writing directly to the store (bypass throttle) - _ = try await store.saveNodeStatusSnapshot( - timestamp: oldTime, - nodePublicKey: testPublicKey, - batteryMillivolts: 3600, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - // Save a "recent" snapshot - let recentID = try await store.saveNodeStatusSnapshot( - timestamp: recentTime, - nodePublicKey: testPublicKey, - batteryMillivolts: 3800, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - await service.pruneOldSnapshots(olderThan: cutoff) - - let remaining = await service.fetchSnapshots(for: testPublicKey) - #expect(remaining.count == 1, "Old snapshot should be pruned, recent should remain") - #expect(remaining.first?.id == recentID) - } - - @Test("Prune with future cutoff does not delete recent snapshots") - func prunePreservesRecentSnapshots() async throws { - let (service, store) = try await createTestService() - - _ = try await store.saveNodeStatusSnapshot( - nodePublicKey: testPublicKey, - batteryMillivolts: 3850, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - // Prune with a cutoff 1 year ago — recent data should survive - let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: .now)! - await service.pruneOldSnapshots(olderThan: oneYearAgo) - - let remaining = await service.fetchSnapshots(for: testPublicKey) - #expect(remaining.count == 1, "Recent snapshot should not be pruned") - } - - @Test("Fetch snapshots with since filter") - func fetchSnapshotsSinceDate() async throws { - let (service, store) = try await createTestService() - let t1 = Date.now.addingTimeInterval(-30) - let cutoff = Date.now.addingTimeInterval(-15) - let t2 = Date.now.addingTimeInterval(-5) - - _ = try await store.saveNodeStatusSnapshot( - timestamp: t1, - nodePublicKey: testPublicKey, - batteryMillivolts: 3600, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - _ = try await store.saveNodeStatusSnapshot( - timestamp: t2, - nodePublicKey: testPublicKey, - batteryMillivolts: 3800, - lastSNR: nil, lastRSSI: nil, noiseFloor: nil, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - let snapshots = await service.fetchSnapshots(for: testPublicKey, since: cutoff) - #expect(snapshots.count == 1) - #expect(snapshots[0].batteryMillivolts == 3800) - } - - // MARK: - Round-trip enrichment tests - - @Test("Enrichment data survives save -> enrich -> fetchAll round-trip") - func enrichmentRoundTrip() async throws { - let (service, store) = try await createTestService() - - // Save two snapshots directly to the store (bypass the window) - let t1 = Date.now.addingTimeInterval(-20) - let t2 = Date.now.addingTimeInterval(-10) - let id1 = try await store.saveNodeStatusSnapshot( - timestamp: t1, - nodePublicKey: testPublicKey, - batteryMillivolts: 3600, - lastSNR: 7.0, lastRSSI: -90, noiseFloor: -120, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - let id2 = try await store.saveNodeStatusSnapshot( - timestamp: t2, - nodePublicKey: testPublicKey, - batteryMillivolts: 3800, - lastSNR: 8.5, lastRSSI: -85, noiseFloor: -118, - uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - // Enrich both directly through the store, targeting specific rows - let telemetry1 = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 25.0)] - let telemetry2 = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 30.0)] - try await store.updateSnapshotTelemetry(id: id1, telemetry: telemetry1) - try await store.updateSnapshotTelemetry(id: id2, telemetry: telemetry2) - - let neighbors1 = [NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 5.0, secondsAgo: 60)] - let neighbors2 = [NeighborSnapshotEntry(publicKeyPrefix: Data([0x05, 0x06, 0x07, 0x08]), snr: 9.0, secondsAgo: 10)] - try await store.updateSnapshotNeighbors(id: id1, neighbors: neighbors1) - try await store.updateSnapshotNeighbors(id: id2, neighbors: neighbors2) - - // Fetch all via the method used by history views - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 2) - - #expect(snapshots[0].telemetryEntries?.count == 1, "Snapshot 1 telemetry should persist") - #expect(snapshots[0].telemetryEntries?.first?.value == 25.0) - #expect(snapshots[0].neighborSnapshots?.count == 1, "Snapshot 1 neighbors should persist") - #expect(snapshots[0].neighborSnapshots?.first?.snr == 5.0) - - #expect(snapshots[1].telemetryEntries?.count == 1, "Snapshot 2 telemetry should persist") - #expect(snapshots[1].telemetryEntries?.first?.value == 30.0) - #expect(snapshots[1].neighborSnapshots?.count == 1, "Snapshot 2 neighbors should persist") - #expect(snapshots[1].neighborSnapshots?.first?.snr == 9.0) - } - - @Test("Status + telemetry + neighbors in one window collapse onto a single row") - func combinedCaptureSingleRow() async throws { - let (service, _) = try await createTestService() - - let statusID = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3700, uptime: 3600) - ) - guard let snapshotID = statusID else { - Issue.record("First capture should return an ID") - return - } - - let telemetry = [ - TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 28.5), - TelemetrySnapshotEntry(channel: 1, type: "humidity", value: 65.0), - ] - let neighbors = [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0xAA, 0xBB, 0xCC, 0xDD]), snr: 6.5, secondsAgo: 45), - ] - let enrichedID = await service.recordSnapshot( - nodePublicKey: testPublicKey, - telemetry: telemetry, - neighbors: neighbors - ) - #expect(enrichedID == snapshotID) - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 1) - #expect(snapshots[0].telemetryEntries?.count == 2, "Both telemetry entries should persist") - #expect(snapshots[0].neighborSnapshots?.count == 1, "Neighbor entry should persist") - } - - // MARK: - Concurrent and out-of-order capture coverage - - @Test("Telemetry-first then status-within-window backfills status onto the single snapshot") - func statusEnrichesTelemetryOnlySnapshot() async throws { - let (service, _) = try await createTestService() - - // Telemetry expanded before status: a telemetry-only snapshot is created. - let telemetry = [TelemetrySnapshotEntry(channel: 1, type: "temperature", value: 21.5)] - let telemetryID = await service.recordSnapshot(nodePublicKey: testPublicKey, telemetry: telemetry) - #expect(telemetryID != nil, "Telemetry-only capture should create a snapshot") - - // Status applied within the window backfills the telemetry-only row - // rather than being dropped or creating a duplicate. - let statusMetrics = NodeStatusMetrics( - batteryMillivolts: 3900, - lastSNR: 9.0, lastRSSI: -84, noiseFloor: -119, - uptimeSeconds: 7200, rxAirtimeSeconds: 150, - packetsSent: 600, packetsReceived: 1200, - receiveErrors: 3 - ) - let statusID = await service.recordSnapshot(nodePublicKey: testPublicKey, status: statusMetrics) - #expect(statusID == telemetryID, "Status enriches the telemetry-only row, no new snapshot") - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 1, "Should remain a single snapshot carrying both data sets") - #expect(snapshots[0].telemetryEntries?.first?.value == 21.5, "Telemetry should be preserved") - #expect(snapshots[0].uptimeSeconds == 7200, "Status counters should be backfilled") - #expect(snapshots[0].batteryMillivolts == 3900) - #expect(snapshots[0].receiveErrors == 3) - } - - @Test("Neighbors captured before any status persist on a fresh snapshot") - func neighborsWithoutStatusPersist() async throws { - let (service, _) = try await createTestService() - - let neighbors = [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x0A, 0x0B, 0x0C, 0x0D]), snr: 4.0, secondsAgo: 90) - ] - let id = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: neighbors) - #expect(id != nil, "Neighbors expanded without status must still persist") - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 1) - #expect(snapshots[0].neighborSnapshots?.count == 1, "Neighbor data should persist on a fresh row") - #expect(snapshots[0].uptimeSeconds == nil, "A neighbor-only row carries no status fields yet") - } - - // MARK: - Neighbor delta baseline - - @Test("Previous neighbor snapshot skips a more recent status-only row") - func previousNeighborSkipsStatusOnly() async throws { - let (service, store) = try await createTestService() - let older = Date.now.addingTimeInterval(-3600) - let recent = Date.now.addingTimeInterval(-1800) - - let neighborID = try await store.saveNodeStatusSnapshot( - timestamp: older, - nodePublicKey: testPublicKey, - batteryMillivolts: 3700, - lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, - uptimeSeconds: 3600, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - try await store.updateSnapshotNeighbors(id: neighborID, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) - ]) - _ = try await store.saveNodeStatusSnapshot( - timestamp: recent, - nodePublicKey: testPublicKey, - batteryMillivolts: 3850, - lastSNR: 8.0, lastRSSI: -85, noiseFloor: -118, - uptimeSeconds: 7200, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - - let previous = await service.previousNeighborSnapshot(for: testPublicKey) - #expect(previous?.neighborSnapshots?.first?.snr == 6.0, - "Returns the neighbor-bearing row, not the newer status-only row") - } - - @Test("Previous neighbor snapshot excludes the current in-window capture") - func previousNeighborExcludesCurrentWindow() async throws { - let (service, store) = try await createTestService() - let older = Date.now.addingTimeInterval(-3600) - - let priorID = try await store.saveNodeStatusSnapshot( - timestamp: older, - nodePublicKey: testPublicKey, - batteryMillivolts: 3700, - lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, - uptimeSeconds: 3600, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - try await store.updateSnapshotNeighbors(id: priorID, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) - ]) - // The reading being viewed now lands on a fresh in-window row. - _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) - ]) - - let previous = await service.previousNeighborSnapshot(for: testPublicKey) - #expect(previous?.neighborSnapshots?.first?.snr == 6.0, - "The current in-window row is excluded; baseline is the prior neighbor capture") - } - - @Test("Previous neighbor snapshot is nil when no prior neighbor data exists") - func previousNeighborNilWithoutHistory() async throws { - let (service, _) = try await createTestService() - - _ = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - - let previous = await service.previousNeighborSnapshot(for: testPublicKey) - #expect(previous == nil, "Status-only history yields no neighbor baseline") - } - - @Test("Previous neighbor snapshot returns an out-of-window neighbor row as the baseline") - func previousNeighborReturnsOutOfWindowRow() async throws { - let (service, store) = try await createTestService() - let older = Date.now.addingTimeInterval(-3600) - - let priorID = try await store.saveNodeStatusSnapshot( - timestamp: older, - nodePublicKey: testPublicKey, - batteryMillivolts: 3700, - lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, - uptimeSeconds: 3600, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - try await store.updateSnapshotNeighbors(id: priorID, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) - ]) - - let previous = await service.previousNeighborSnapshot(for: testPublicKey) - #expect(previous?.neighborSnapshots?.first?.snr == 6.0, - "With the latest row outside the in-window cutoff, it is the baseline") - } - - @Test("Previous neighbor snapshot is nil when the only neighbor row is the in-window capture") - func previousNeighborNilWhenOnlyInWindowCapture() async throws { - let (service, _) = try await createTestService() - - // A single fresh in-window neighbor capture is its own reading, so it has no baseline. - _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) - ]) - - let previous = await service.previousNeighborSnapshot(for: testPublicKey) - #expect(previous == nil, "The in-window capture is excluded and there is no prior neighbor row") - } - - // MARK: - Status delta baseline - - @Test("Previous status snapshot skips a more recent neighbor-only row") - func previousStatusSkipsNeighborOnly() async throws { - let (service, store) = try await createTestService() - let older = Date.now.addingTimeInterval(-3600) - - _ = try await store.saveNodeStatusSnapshot( - timestamp: older, - nodePublicKey: testPublicKey, - batteryMillivolts: 3700, - lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, - uptimeSeconds: 3600, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil, - receiveErrors: nil - ) - // A newer neighbor-only capture carries no status fields. - _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) - ]) - - let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) - #expect(previous?.uptimeSeconds == 3600, - "Returns the status-bearing row, not the newer neighbor-only row") - } - - @Test("Previous status snapshot includes an in-window status capture") - func previousStatusIncludesInWindowCapture() async throws { - let (service, _) = try await createTestService() - - // Unlike the neighbor baseline, an early in-window status reading is a valid - // baseline: status capture is throttled and never overwrites itself. - _ = await service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - - let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) - #expect(previous?.uptimeSeconds == 3600, "The in-window status row is a usable baseline") - } - - @Test("Previous status snapshot is nil when only neighbor data exists") - func previousStatusNilWithoutStatusHistory() async throws { - let (service, _) = try await createTestService() - - _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ - NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) - ]) - - let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) - #expect(previous == nil, "Neighbor-only history yields no status baseline") - } - - @Test("Concurrent in-window captures never duplicate a snapshot") - func concurrentCapturesSingleRow() async throws { - let (service, _) = try await createTestService() - - // Status and telemetry captured concurrently must collapse onto one - // in-window row; the atomic store serializes the read-modify-write. - let telemetry = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 19.0)] - async let statusResult = service.recordSnapshot( - nodePublicKey: testPublicKey, - status: metrics(battery: 3850, uptime: 3600) - ) - async let telemetryResult = service.recordSnapshot( - nodePublicKey: testPublicKey, - telemetry: telemetry - ) - let (statusID, telemetryID) = await (statusResult, telemetryResult) - #expect(statusID != nil) - #expect(telemetryID != nil) - #expect(statusID == telemetryID, "Concurrent captures resolve to the same in-window row") - - let snapshots = await service.fetchSnapshots(for: testPublicKey) - #expect(snapshots.count == 1, "Atomic record collapses concurrent captures into one row") - } + let telemetry = [ + TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 28.5), + TelemetrySnapshotEntry(channel: 1, type: "humidity", value: 65.0), + ] + let neighbors = [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0xAA, 0xBB, 0xCC, 0xDD]), snr: 6.5, secondsAgo: 45), + ] + let enrichedID = await service.recordSnapshot( + nodePublicKey: testPublicKey, + telemetry: telemetry, + neighbors: neighbors + ) + #expect(enrichedID == snapshotID) + + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 1) + #expect(snapshots[0].telemetryEntries?.count == 2, "Both telemetry entries should persist") + #expect(snapshots[0].neighborSnapshots?.count == 1, "Neighbor entry should persist") + } + + // MARK: - Concurrent and out-of-order capture coverage + + @Test + func `Telemetry-first then status-within-window backfills status onto the single snapshot`() async throws { + let (service, _) = try await createTestService() + + // Telemetry expanded before status: a telemetry-only snapshot is created. + let telemetry = [TelemetrySnapshotEntry(channel: 1, type: "temperature", value: 21.5)] + let telemetryID = await service.recordSnapshot(nodePublicKey: testPublicKey, telemetry: telemetry) + #expect(telemetryID != nil, "Telemetry-only capture should create a snapshot") + + // Status applied within the window backfills the telemetry-only row + // rather than being dropped or creating a duplicate. + let statusMetrics = NodeStatusMetrics( + batteryMillivolts: 3900, + lastSNR: 9.0, lastRSSI: -84, noiseFloor: -119, + uptimeSeconds: 7200, rxAirtimeSeconds: 150, + packetsSent: 600, packetsReceived: 1200, + receiveErrors: 3 + ) + let statusID = await service.recordSnapshot(nodePublicKey: testPublicKey, status: statusMetrics) + #expect(statusID == telemetryID, "Status enriches the telemetry-only row, no new snapshot") + + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 1, "Should remain a single snapshot carrying both data sets") + #expect(snapshots[0].telemetryEntries?.first?.value == 21.5, "Telemetry should be preserved") + #expect(snapshots[0].uptimeSeconds == 7200, "Status counters should be backfilled") + #expect(snapshots[0].batteryMillivolts == 3900) + #expect(snapshots[0].receiveErrors == 3) + } + + @Test + func `Neighbors captured before any status persist on a fresh snapshot`() async throws { + let (service, _) = try await createTestService() + + let neighbors = [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x0A, 0x0B, 0x0C, 0x0D]), snr: 4.0, secondsAgo: 90) + ] + let id = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: neighbors) + #expect(id != nil, "Neighbors expanded without status must still persist") + + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 1) + #expect(snapshots[0].neighborSnapshots?.count == 1, "Neighbor data should persist on a fresh row") + #expect(snapshots[0].uptimeSeconds == nil, "A neighbor-only row carries no status fields yet") + } + + // MARK: - Neighbor delta baseline + + @Test + func `Previous neighbor snapshot skips a more recent status-only row`() async throws { + let (service, store) = try await createTestService() + let older = Date.now.addingTimeInterval(-3600) + let recent = Date.now.addingTimeInterval(-1800) + + let neighborID = try await store.saveNodeStatusSnapshot( + timestamp: older, + nodePublicKey: testPublicKey, + batteryMillivolts: 3700, + lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: 3600, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + try await store.updateSnapshotNeighbors(id: neighborID, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) + ]) + _ = try await store.saveNodeStatusSnapshot( + timestamp: recent, + nodePublicKey: testPublicKey, + batteryMillivolts: 3850, + lastSNR: 8.0, lastRSSI: -85, noiseFloor: -118, + uptimeSeconds: 7200, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + + let previous = await service.previousNeighborSnapshot(for: testPublicKey) + #expect(previous?.neighborSnapshots?.first?.snr == 6.0, + "Returns the neighbor-bearing row, not the newer status-only row") + } + + @Test + func `Previous neighbor snapshot excludes the current in-window capture`() async throws { + let (service, store) = try await createTestService() + let older = Date.now.addingTimeInterval(-3600) + + let priorID = try await store.saveNodeStatusSnapshot( + timestamp: older, + nodePublicKey: testPublicKey, + batteryMillivolts: 3700, + lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: 3600, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + try await store.updateSnapshotNeighbors(id: priorID, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) + ]) + // The reading being viewed now lands on a fresh in-window row. + _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) + ]) + + let previous = await service.previousNeighborSnapshot(for: testPublicKey) + #expect(previous?.neighborSnapshots?.first?.snr == 6.0, + "The current in-window row is excluded; baseline is the prior neighbor capture") + } + + @Test + func `Previous neighbor snapshot is nil when no prior neighbor data exists`() async throws { + let (service, _) = try await createTestService() + + _ = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + + let previous = await service.previousNeighborSnapshot(for: testPublicKey) + #expect(previous == nil, "Status-only history yields no neighbor baseline") + } + + @Test + func `Previous neighbor snapshot returns an out-of-window neighbor row as the baseline`() async throws { + let (service, store) = try await createTestService() + let older = Date.now.addingTimeInterval(-3600) + + let priorID = try await store.saveNodeStatusSnapshot( + timestamp: older, + nodePublicKey: testPublicKey, + batteryMillivolts: 3700, + lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: 3600, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + try await store.updateSnapshotNeighbors(id: priorID, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 6.0, secondsAgo: 30) + ]) + + let previous = await service.previousNeighborSnapshot(for: testPublicKey) + #expect(previous?.neighborSnapshots?.first?.snr == 6.0, + "With the latest row outside the in-window cutoff, it is the baseline") + } + + @Test + func `Previous neighbor snapshot is nil when the only neighbor row is the in-window capture`() async throws { + let (service, _) = try await createTestService() + + // A single fresh in-window neighbor capture is its own reading, so it has no baseline. + _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) + ]) + + let previous = await service.previousNeighborSnapshot(for: testPublicKey) + #expect(previous == nil, "The in-window capture is excluded and there is no prior neighbor row") + } + + // MARK: - Status delta baseline + + @Test + func `Previous status snapshot skips a more recent neighbor-only row`() async throws { + let (service, store) = try await createTestService() + let older = Date.now.addingTimeInterval(-3600) + + _ = try await store.saveNodeStatusSnapshot( + timestamp: older, + nodePublicKey: testPublicKey, + batteryMillivolts: 3700, + lastSNR: 6.0, lastRSSI: -90, noiseFloor: -120, + uptimeSeconds: 3600, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil + ) + // A newer neighbor-only capture carries no status fields. + _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) + ]) + + let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) + #expect(previous?.uptimeSeconds == 3600, + "Returns the status-bearing row, not the newer neighbor-only row") + } + + @Test + func `Previous status snapshot includes an in-window status capture`() async throws { + let (service, _) = try await createTestService() + + // Unlike the neighbor baseline, an early in-window status reading is a valid + // baseline: status capture is throttled and never overwrites itself. + _ = await service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + + let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) + #expect(previous?.uptimeSeconds == 3600, "The in-window status row is a usable baseline") + } + + @Test + func `Previous status snapshot is nil when only neighbor data exists`() async throws { + let (service, _) = try await createTestService() + + _ = await service.recordSnapshot(nodePublicKey: testPublicKey, neighbors: [ + NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), snr: 9.0, secondsAgo: 5) + ]) + + let previous = await service.previousStatusSnapshot(for: testPublicKey, before: .now) + #expect(previous == nil, "Neighbor-only history yields no status baseline") + } + + @Test + func `Concurrent in-window captures never duplicate a snapshot`() async throws { + let (service, _) = try await createTestService() + + // Status and telemetry captured concurrently must collapse onto one + // in-window row; the atomic store serializes the read-modify-write. + let telemetry = [TelemetrySnapshotEntry(channel: 0, type: "temperature", value: 19.0)] + async let statusResult = service.recordSnapshot( + nodePublicKey: testPublicKey, + status: metrics(battery: 3850, uptime: 3600) + ) + async let telemetryResult = service.recordSnapshot( + nodePublicKey: testPublicKey, + telemetry: telemetry + ) + let (statusID, telemetryID) = await (statusResult, telemetryResult) + #expect(statusID != nil) + #expect(telemetryID != nil) + #expect(statusID == telemetryID, "Concurrent captures resolve to the same in-window row") + + let snapshots = await service.fetchSnapshots(for: testPublicKey) + #expect(snapshots.count == 1, "Atomic record collapses concurrent captures into one row") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift index 8ffdb11b..0bbb4049 100644 --- a/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift @@ -1,122 +1,131 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("PairingCancellation") @MainActor struct PairingCancellationTests { + /// When pairNewDevice is cancelled before the picker resolves, ASK has not + /// added the device — there's nothing to clean up. + @Test + func `cancellation before showPicker completes does not call removeAccessory`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + + let pickerEntered = AsyncStream.makeStream() + let pickerGate = AsyncStream.makeStream() + mockASK.pickerEnteredSignal = pickerEntered.continuation + mockASK.pickerGate = pickerGate.stream + mockASK.setPickerResult(.success(UUID())) + + let pairTask = Task { try? await manager.pairNewDevice() } + + // Wait for the pair task to deterministically reach showPicker. + for await _ in pickerEntered.stream { + break + } - /// When pairNewDevice is cancelled before the picker resolves, ASK has not - /// added the device — there's nothing to clean up. - @Test("cancellation before showPicker completes does not call removeAccessory") - func cancellationBeforePickerDoesNotRemove() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockASK = env.accessorySetupKit - - let pickerEntered = AsyncStream.makeStream() - let pickerGate = AsyncStream.makeStream() - mockASK.pickerEnteredSignal = pickerEntered.continuation - mockASK.pickerGate = pickerGate.stream - mockASK.setPickerResult(.success(UUID())) + pairTask.cancel() + pickerGate.continuation.finish() + + _ = await pairTask.value + + #expect(mockASK.removeAccessoryCallCount == 0) + } + + /// When pairNewDevice is cancelled after ASK adds the device but before + /// connect(to:) finishes, cleanupPartialPairing must remove the bond from + /// ASK so iOS doesn't retain a paired accessory with no app-level state. + @Test + func `cancellation in connect(to:) phase removes accessory from ASK`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let deviceID = UUID() + mockASK.setPickerResult(.success(deviceID)) + mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + // Pin the wait so we can deterministically cancel after the pair task + // has entered waitForOtherAppReconnection — i.e., past showPicker. + let waitEntered = AsyncStream.makeStream() + let releaseWait = AsyncStream.makeStream() + manager.otherAppWaitStrategyOverride = { _ in + waitEntered.continuation.yield() + for await _ in releaseWait.stream { + break + } + return false + } - let pairTask = Task { try? await manager.pairNewDevice() } + let pairTask = Task { try? await manager.pairNewDevice() } - // Wait for the pair task to deterministically reach showPicker. - for await _ in pickerEntered.stream { break } + for await _ in waitEntered.stream { + break + } - pairTask.cancel() - pickerGate.continuation.finish() + pairTask.cancel() + releaseWait.continuation.finish() + _ = await pairTask.value + + #expect(mockASK.removeAccessoryCallCount == 1) + #expect(mockASK.lastRemovedDeviceID == deviceID) + } + + /// Regression test for the silent-cancellation hole in connect(to:): a + /// pairNewDevice task cancelled between picker dismiss and connect(to:) + /// returning successfully must NOT drive a real BLE connect to completion. + /// Without the entry-point `Task.checkCancellation()` in connect(to:), the + /// transport.connect() invocation would still fire (the inner awaits all + /// swallowed cancellation), leaving the user paired and connected to a + /// device they tried to abandon. + @Test + func `cancellation before connect(to:) bails before transport.connect runs`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let mockTransport = env.transport + let deviceID = UUID() + mockASK.setPickerResult(.success(deviceID)) + mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) + + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + let waitEntered = AsyncStream.makeStream() + let releaseWait = AsyncStream.makeStream() + manager.otherAppWaitStrategyOverride = { _ in + waitEntered.continuation.yield() + for await _ in releaseWait.stream { + break + } + return false + } - _ = await pairTask.value + let pairTask = Task { try? await manager.pairNewDevice() } - #expect(mockASK.removeAccessoryCallCount == 0) + for await _ in waitEntered.stream { + break } - /// When pairNewDevice is cancelled after ASK adds the device but before - /// connect(to:) finishes, cleanupPartialPairing must remove the bond from - /// ASK so iOS doesn't retain a paired accessory with no app-level state. - @Test("cancellation in connect(to:) phase removes accessory from ASK") - func cancellationInConnectPhaseRemoves() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockASK = env.accessorySetupKit - let deviceID = UUID() - mockASK.setPickerResult(.success(deviceID)) - mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - // Pin the wait so we can deterministically cancel after the pair task - // has entered waitForOtherAppReconnection — i.e., past showPicker. - let waitEntered = AsyncStream.makeStream() - let releaseWait = AsyncStream.makeStream() - manager.otherAppWaitStrategyOverride = { _ in - waitEntered.continuation.yield() - for await _ in releaseWait.stream { break } - return false - } - - let pairTask = Task { try? await manager.pairNewDevice() } - - for await _ in waitEntered.stream { break } - - pairTask.cancel() - releaseWait.continuation.finish() - _ = await pairTask.value - - #expect(mockASK.removeAccessoryCallCount == 1) - #expect(mockASK.lastRemovedDeviceID == deviceID) - } + pairTask.cancel() + releaseWait.continuation.finish() + _ = await pairTask.value - /// Regression test for the silent-cancellation hole in connect(to:): a - /// pairNewDevice task cancelled between picker dismiss and connect(to:) - /// returning successfully must NOT drive a real BLE connect to completion. - /// Without the entry-point `Task.checkCancellation()` in connect(to:), the - /// transport.connect() invocation would still fire (the inner awaits all - /// swallowed cancellation), leaving the user paired and connected to a - /// device they tried to abandon. - @Test("cancellation before connect(to:) bails before transport.connect runs") - func cancellationBeforeConnectBailsBeforeBLE() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockASK = env.accessorySetupKit - let mockTransport = env.transport - let deviceID = UUID() - mockASK.setPickerResult(.success(deviceID)) - mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) - - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - let waitEntered = AsyncStream.makeStream() - let releaseWait = AsyncStream.makeStream() - manager.otherAppWaitStrategyOverride = { _ in - waitEntered.continuation.yield() - for await _ in releaseWait.stream { break } - return false - } - - let pairTask = Task { try? await manager.pairNewDevice() } - - for await _ in waitEntered.stream { break } - - pairTask.cancel() - releaseWait.continuation.finish() - _ = await pairTask.value - - let invocations = await mockTransport.connectInvocations - #expect(invocations.isEmpty, "Cancelled connect(to:) must not establish a BLE link") - #expect(mockASK.removeAccessoryCallCount == 1, "ASK bond must be cleaned up after cancellation") - } + let invocations = await mockTransport.connectInvocations + #expect(invocations.isEmpty, "Cancelled connect(to:) must not establish a BLE link") + #expect(mockASK.removeAccessoryCallCount == 1, "ASK bond must be cleaned up after cancellation") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PairingRaceIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingRaceIntegrationTests.swift index d61c8217..c8e2548b 100644 --- a/MC1Services/Tests/MC1ServicesTests/PairingRaceIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PairingRaceIntegrationTests.swift @@ -1,71 +1,74 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("PairingRaceIntegration") @MainActor struct PairingRaceIntegrationTests { + /// While `pairNewDevice` is suspended in `waitForOtherAppReconnection`, + /// `appDidBecomeActive` (or any other foreground transition that would + /// normally trigger an opportunistic reconnect) must not race the pairing + /// flow's `connect(to:)`. The fix: `shouldDeferOpportunisticReconnect` + /// gates `attemptOpportunisticReconnect` for the duration of the pair flow. + /// + /// This test pins `pairNewDevice` in the wait via the strategy override, + /// drives `checkBLEConnectionHealth` (the foreground reconnect path) + /// concurrently, then asserts the transport never received a connect call + /// for the previously-connected device. + @Test + func `opportunistic reconnect to old device is gated while pairing is suspended in waitForOtherAppReconnection`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockTransport = env.transport + let mockASK = env.accessorySetupKit + let oldDeviceID = UUID() + let newDeviceID = UUID() - /// While `pairNewDevice` is suspended in `waitForOtherAppReconnection`, - /// `appDidBecomeActive` (or any other foreground transition that would - /// normally trigger an opportunistic reconnect) must not race the pairing - /// flow's `connect(to:)`. The fix: `shouldDeferOpportunisticReconnect` - /// gates `attemptOpportunisticReconnect` for the duration of the pair flow. - /// - /// This test pins `pairNewDevice` in the wait via the strategy override, - /// drives `checkBLEConnectionHealth` (the foreground reconnect path) - /// concurrently, then asserts the transport never received a connect call - /// for the previously-connected device. - @Test("opportunistic reconnect to old device is gated while pairing is suspended in waitForOtherAppReconnection") - func opportunisticReconnectGatedDuringPairingWait() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockTransport = env.transport - let mockASK = env.accessorySetupKit - let oldDeviceID = UUID() - let newDeviceID = UUID() - - mockASK.setPickerResult(.success(newDeviceID)) + mockASK.setPickerResult(.success(newDeviceID)) - manager.testLastConnectedDeviceID = oldDeviceID - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) + manager.testLastConnectedDeviceID = oldDeviceID + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) - let waitStarted = AsyncStream.makeStream() - let releaseWait = AsyncStream.makeStream() + let waitStarted = AsyncStream.makeStream() + let releaseWait = AsyncStream.makeStream() - manager.otherAppWaitStrategyOverride = { _ in - waitStarted.continuation.yield() - for await _ in releaseWait.stream { break } - return false - } + manager.otherAppWaitStrategyOverride = { _ in + waitStarted.continuation.yield() + for await _ in releaseWait.stream { + break + } + return false + } - let pairTask = Task { try? await manager.pairNewDevice() } + let pairTask = Task { try? await manager.pairNewDevice() } - // Yield to ensure pairTask has scheduled and reached the wait strategy - // before we drive the racer. Without this yield, scheduler ordering - // determines whether the racer fires before or after the gate engages. - await Task.yield() - for await _ in waitStarted.stream { break } + // Yield to ensure pairTask has scheduled and reached the wait strategy + // before we drive the racer. Without this yield, scheduler ordering + // determines whether the racer fires before or after the gate engages. + await Task.yield() + for await _ in waitStarted.stream { + break + } - await manager.checkBLEConnectionHealth() + await manager.checkBLEConnectionHealth() - let invocations = await mockTransport.connectInvocations - let invocationsForOld = invocations.filter { $0.deviceID == oldDeviceID } - #expect( - invocationsForOld.isEmpty, - "Opportunistic reconnect to old device should be gated while pairing is in progress" - ) + let invocations = await mockTransport.connectInvocations + let invocationsForOld = invocations.filter { $0.deviceID == oldDeviceID } + #expect( + invocationsForOld.isEmpty, + "Opportunistic reconnect to old device should be gated while pairing is in progress" + ) - releaseWait.continuation.finish() - // Don't await pairTask — the connect(to:) path needs a fully wired transport - // to make progress, and the mock intentionally stalls to keep the test focused - // on the gate behavior. Cancel it so it unwinds promptly. - pairTask.cancel() - _ = await pairTask.value - } + releaseWait.continuation.finish() + // Don't await pairTask — the connect(to:) path needs a fully wired transport + // to make progress, and the mock intentionally stalls to keep the test focused + // on the gate behavior. Cancel it so it unwinds promptly. + pairTask.cancel() + _ = await pairTask.value + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PairingWhileConnectedTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingWhileConnectedTests.swift index f10a9796..923eea83 100644 --- a/MC1Services/Tests/MC1ServicesTests/PairingWhileConnectedTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PairingWhileConnectedTests.swift @@ -1,218 +1,227 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("PairingWhileConnected") @MainActor struct PairingWhileConnectedTests { + /// iOS auto-reconnect for the previously-connected radio can fire 0–~3 seconds + /// after ASK severance, landing during pairNewDevice's waitForOtherAppReconnection + /// poll. Without the auto-reconnect-handler gate, that handler would claim the + /// state machine via `reconnectionCoordinator.handleEnteringAutoReconnect` and + /// starve the pairing's subsequent `connect(to: newDeviceID)`. + /// + /// This test starts a real pairNewDevice flow, pins it in the wait override, + /// then simulates iOS auto-reconnect for the old device firing. The handler + /// must observe `shouldDeferOpportunisticReconnect`, run the old-device + /// session teardown via `handleConnectionLoss`, and return without claiming + /// the coordinator. + @Test + func `auto-reconnect during waitForOtherAppReconnection tears down old-device session without claiming coordinator`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let stateMachine = env.stateMachine + let mockASK = env.accessorySetupKit + let oldDeviceID = UUID() + let newDeviceID = UUID() + + mockASK.setPickerResult(.success(newDeviceID)) + + manager.testLastConnectedDeviceID = oldDeviceID + manager.setTestState( + connectionState: .ready, + connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + let waitStarted = AsyncStream.makeStream() + let releaseWait = AsyncStream.makeStream() + manager.otherAppWaitStrategyOverride = { _ in + waitStarted.continuation.yield() + for await _ in releaseWait.stream { + break + } + return false + } + + try await waitUntil("auto-reconnect handler should be installed") { + await stateMachine.hasAutoReconnectingHandler + } + + let pairTask = Task { try? await manager.pairNewDevice() } + + await Task.yield() + for await _ in waitStarted.stream { + break + } + + await stateMachine.simulateAutoReconnecting(deviceID: oldDeviceID) - /// iOS auto-reconnect for the previously-connected radio can fire 0–~3 seconds - /// after ASK severance, landing during pairNewDevice's waitForOtherAppReconnection - /// poll. Without the auto-reconnect-handler gate, that handler would claim the - /// state machine via `reconnectionCoordinator.handleEnteringAutoReconnect` and - /// starve the pairing's subsequent `connect(to: newDeviceID)`. - /// - /// This test starts a real pairNewDevice flow, pins it in the wait override, - /// then simulates iOS auto-reconnect for the old device firing. The handler - /// must observe `shouldDeferOpportunisticReconnect`, run the old-device - /// session teardown via `handleConnectionLoss`, and return without claiming - /// the coordinator. - @Test("auto-reconnect during waitForOtherAppReconnection tears down old-device session without claiming coordinator") - func autoReconnectDuringWaitDoesNotClaimCoordinator() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let stateMachine = env.stateMachine - let mockASK = env.accessorySetupKit - let oldDeviceID = UUID() - let newDeviceID = UUID() - - mockASK.setPickerResult(.success(newDeviceID)) - - manager.testLastConnectedDeviceID = oldDeviceID - manager.setTestState( - connectionState: .ready, - connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - let waitStarted = AsyncStream.makeStream() - let releaseWait = AsyncStream.makeStream() - manager.otherAppWaitStrategyOverride = { _ in - waitStarted.continuation.yield() - for await _ in releaseWait.stream { break } - return false - } - - try await waitUntil("auto-reconnect handler should be installed") { - await stateMachine.hasAutoReconnectingHandler - } - - let pairTask = Task { try? await manager.pairNewDevice() } - - await Task.yield() - for await _ in waitStarted.stream { break } - - await stateMachine.simulateAutoReconnecting(deviceID: oldDeviceID) - - // The suppression branch routes through handleConnectionLoss to clear the - // stale old-device session. Observing the state transition and connectedDevice - // clear is the proof the handler ran without claiming the coordinator. - try await waitUntil("suppression branch should tear down old-device session") { - manager.connectionState == .disconnected && manager.connectedDevice == nil - } - - #expect(manager.activeReconnectDeviceID == nil) - #expect(manager.connectedDevice == nil) - - releaseWait.continuation.finish() - pairTask.cancel() - _ = await pairTask.value + // The suppression branch routes through handleConnectionLoss to clear the + // stale old-device session. Observing the state transition and connectedDevice + // clear is the proof the handler ran without claiming the coordinator. + try await waitUntil("suppression branch should tear down old-device session") { + manager.connectionState == .disconnected && manager.connectedDevice == nil } - /// Companion to the entry-suppression test above. The entry handler being - /// suppressed leaves `reconnectingDeviceID` as nil. A late completion for the - /// old device must be rejected by the coordinator's claim guard — otherwise - /// `rebuildSession(oldDeviceID)` would race the new pairing's `connect(to: newDeviceID)`, - /// reading the new device's traffic against the old device's identity. - @Test("auto-reconnect completion during pair-wait does not run rebuildSession") - func autoReconnectCompletionDuringWaitDoesNotRebuild() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockTransport = env.transport - let mockASK = env.accessorySetupKit - let oldDeviceID = UUID() - let newDeviceID = UUID() - - mockASK.setPickerResult(.success(newDeviceID)) - - // Pre-pairing: previously connected to the old device; entry was suppressed - // during ASK pairing severance, so connectionState is .disconnected and no - // claim exists in the coordinator. - manager.testLastConnectedDeviceID = oldDeviceID - manager.setTestState( - connectionState: .disconnected, - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - let waitStarted = AsyncStream.makeStream() - let releaseWait = AsyncStream.makeStream() - manager.otherAppWaitStrategyOverride = { _ in - waitStarted.continuation.yield() - for await _ in releaseWait.stream { break } - return false - } - - try await waitUntil("reconnection handler should be installed") { - await mockTransport.hasReconnectionHandler - } - - let pairTask = Task { try? await manager.pairNewDevice() } - - await Task.yield() - for await _ in waitStarted.stream { break } - - // iOS auto-reconnect for the old device completes during the wait. Without - // the claim guard this would set state to .connecting and call - // rebuildSession(oldDeviceID). - await mockTransport.simulateReconnection(deviceID: oldDeviceID) - - // Drain the dispatched @MainActor Task. With the claim guard the path returns - // synchronously after the guard check, so a few yields are sufficient. - // Without the guard the buggy path sets state to .connecting before its first - // await, which is also visible after the same yields. - for _ in 0..<5 { await Task.yield() } - - #expect(manager.connectionState == .disconnected, "Late completion must not transition state") - - releaseWait.continuation.finish() - pairTask.cancel() - _ = await pairTask.value + #expect(manager.activeReconnectDeviceID == nil) + #expect(manager.connectedDevice == nil) + + releaseWait.continuation.finish() + pairTask.cancel() + _ = await pairTask.value + } + + /// Companion to the entry-suppression test above. The entry handler being + /// suppressed leaves `reconnectingDeviceID` as nil. A late completion for the + /// old device must be rejected by the coordinator's claim guard — otherwise + /// `rebuildSession(oldDeviceID)` would race the new pairing's `connect(to: newDeviceID)`, + /// reading the new device's traffic against the old device's identity. + @Test + func `auto-reconnect completion during pair-wait does not run rebuildSession`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockTransport = env.transport + let mockASK = env.accessorySetupKit + let oldDeviceID = UUID() + let newDeviceID = UUID() + + mockASK.setPickerResult(.success(newDeviceID)) + + // Pre-pairing: previously connected to the old device; entry was suppressed + // during ASK pairing severance, so connectionState is .disconnected and no + // claim exists in the coordinator. + manager.testLastConnectedDeviceID = oldDeviceID + manager.setTestState( + connectionState: .disconnected, + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + let waitStarted = AsyncStream.makeStream() + let releaseWait = AsyncStream.makeStream() + manager.otherAppWaitStrategyOverride = { _ in + waitStarted.continuation.yield() + for await _ in releaseWait.stream { + break + } + return false } - /// pairNewDevice routes through `connect(to:)`'s switch-device branch when an - /// old radio is still connected. Without the catch in `switchDevice`, a mid- - /// switch throw propagates without resetting `connectionState`, `connectedDevice`, - /// or `services` — leaving the UI on `.ready` pointing at the old device while - /// the failure-alert recovery actions act on stale state. - /// - /// The mock ASK starts with empty `pairedAccessories`, so `switchDevice`'s ASK - /// validation throws `ConnectionError.deviceNotFound` for the new device. That - /// throw must drive a clean teardown. - @Test("pair-while-connected with switchDevice throw leaves state .disconnected") - func switchDeviceFailureLeavesCleanState() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockASK = env.accessorySetupKit - let oldDeviceID = UUID() - let newDeviceID = UUID() - - mockASK.setPickerResult(.success(newDeviceID)) - - // Skip the polling wait so the test exercises the connect/switch path directly. - manager.otherAppWaitStrategyOverride = { _ in false } - - manager.testLastConnectedDeviceID = oldDeviceID - manager.setTestState( - connectionState: .ready, - connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - await #expect(throws: PairingError.self) { - try await manager.pairNewDevice() - } - - #expect(manager.connectionState == .disconnected, "switchDevice catch must reset state") - #expect(manager.connectedDevice == nil, "switchDevice catch must clear stale device") - #expect(manager.services == nil, "switchDevice catch must clear stale services") + try await waitUntil("reconnection handler should be installed") { + await mockTransport.hasReconnectionHandler } - /// `cleanupConnection` in switchDevice's catch resets state without firing - /// `onConnectionLost`, so AppState observers — and the old radio's Live Activity — - /// would stay stranded on the prior device's connected state. The catch must route - /// through `onConnectionLost` like the transport-loss and reconnect-failure paths. - @Test("failed switchDevice fires onConnectionLost so observers tear down") - func switchDeviceFailureFiresConnectionLost() async throws { - let env = try ConnectionManager.createForPairingTesting() - defer { env.cleanup() } - let manager = env.manager - let mockASK = env.accessorySetupKit - let oldDeviceID = UUID() - let newDeviceID = UUID() - - mockASK.setPickerResult(.success(newDeviceID)) - manager.otherAppWaitStrategyOverride = { _ in false } - - manager.testLastConnectedDeviceID = oldDeviceID - manager.setTestState( - connectionState: .ready, - connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), - currentTransportType: .bluetooth, - connectionIntent: .wantsConnection() - ) - - let tracker = SwitchFailureLostTracker() - manager.onConnectionLost = { await tracker.markConnectionLost() } - - await #expect(throws: PairingError.self) { - try await manager.pairNewDevice() - } - - let wasCalled = await tracker.connectionLostCalled - #expect(wasCalled, "switchDevice catch must fire onConnectionLost so AppState retires the orphaned Live Activity") + let pairTask = Task { try? await manager.pairNewDevice() } + + await Task.yield() + for await _ in waitStarted.stream { + break + } + + // iOS auto-reconnect for the old device completes during the wait. Without + // the claim guard this would set state to .connecting and call + // rebuildSession(oldDeviceID). + await mockTransport.simulateReconnection(deviceID: oldDeviceID) + + // Drain the dispatched @MainActor Task. With the claim guard the path returns + // synchronously after the guard check, so a few yields are sufficient. + // Without the guard the buggy path sets state to .connecting before its first + // await, which is also visible after the same yields. + for _ in 0..<5 { + await Task.yield() + } + + #expect(manager.connectionState == .disconnected, "Late completion must not transition state") + + releaseWait.continuation.finish() + pairTask.cancel() + _ = await pairTask.value + } + + /// pairNewDevice routes through `connect(to:)`'s switch-device branch when an + /// old radio is still connected. Without the catch in `switchDevice`, a mid- + /// switch throw propagates without resetting `connectionState`, `connectedDevice`, + /// or `services` — leaving the UI on `.ready` pointing at the old device while + /// the failure-alert recovery actions act on stale state. + /// + /// The mock ASK starts with empty `pairedAccessories`, so `switchDevice`'s ASK + /// validation throws `ConnectionError.deviceNotFound` for the new device. That + /// throw must drive a clean teardown. + @Test + func `pair-while-connected with switchDevice throw leaves state .disconnected`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let oldDeviceID = UUID() + let newDeviceID = UUID() + + mockASK.setPickerResult(.success(newDeviceID)) + + // Skip the polling wait so the test exercises the connect/switch path directly. + manager.otherAppWaitStrategyOverride = { _ in false } + + manager.testLastConnectedDeviceID = oldDeviceID + manager.setTestState( + connectionState: .ready, + connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + await #expect(throws: PairingError.self) { + try await manager.pairNewDevice() } + + #expect(manager.connectionState == .disconnected, "switchDevice catch must reset state") + #expect(manager.connectedDevice == nil, "switchDevice catch must clear stale device") + #expect(manager.services == nil, "switchDevice catch must clear stale services") + } + + /// `cleanupConnection` in switchDevice's catch resets state without firing + /// `onConnectionLost`, so AppState observers — and the old radio's Live Activity — + /// would stay stranded on the prior device's connected state. The catch must route + /// through `onConnectionLost` like the transport-loss and reconnect-failure paths. + @Test + func `failed switchDevice fires onConnectionLost so observers tear down`() async throws { + let env = try ConnectionManager.createForPairingTesting() + defer { env.cleanup() } + let manager = env.manager + let mockASK = env.accessorySetupKit + let oldDeviceID = UUID() + let newDeviceID = UUID() + + mockASK.setPickerResult(.success(newDeviceID)) + manager.otherAppWaitStrategyOverride = { _ in false } + + manager.testLastConnectedDeviceID = oldDeviceID + manager.setTestState( + connectionState: .ready, + connectedDevice: DeviceDTO.testDevice(id: oldDeviceID), + currentTransportType: .bluetooth, + connectionIntent: .wantsConnection() + ) + + let tracker = SwitchFailureLostTracker() + manager.onConnectionLost = { await tracker.markConnectionLost() } + + await #expect(throws: PairingError.self) { + try await manager.pairNewDevice() + } + + let wasCalled = await tracker.connectionLostCalled + #expect(wasCalled, "switchDevice catch must fire onConnectionLost so AppState retires the orphaned Live Activity") + } } private actor SwitchFailureLostTracker { - var connectionLostCalled = false + var connectionLostCalled = false - func markConnectionLost() { - connectionLostCalled = true - } + func markConnectionLost() { + connectionLostCalled = true + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PendingSendPersistenceTests.swift b/MC1Services/Tests/MC1ServicesTests/PendingSendPersistenceTests.swift index 17fec9ee..32e47836 100644 --- a/MC1Services/Tests/MC1ServicesTests/PendingSendPersistenceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PendingSendPersistenceTests.swift @@ -1,415 +1,414 @@ -import Testing import Foundation -import SwiftData @testable import MC1Services +import SwiftData +import Testing @MainActor struct PendingSendPersistenceTests { - - @Test("PendingSendDTO round-trips through Model and back") - func dtoRoundTrip() async throws { - let store = try makeStore() - let radioID = UUID() - let dto = PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: UUID(), - kind: .channel, - contactID: nil, - channelIndex: 3, - isResend: true, - messageText: "test", - messageTimestamp: 1_700_000_000, - localNodeName: "Alice", - sequence: 1, - enqueuedAt: Date(timeIntervalSince1970: 1_700_000_000) - ) - - try await store.upsertPendingSend(dto) - let fetched = try await store.fetchPendingSends(radioID: radioID) - - #expect(fetched.count == 1) - #expect(fetched.first == dto) - } - - @Test("Pending sends are scoped by radioID") - func radioScoping() async throws { - let store = try makeStore() - let radioA = UUID() - let radioB = UUID() - try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 1)) - try await store.upsertPendingSend(.fixture(radioID: radioB, sequence: 1)) - - let aRows = try await store.fetchPendingSends(radioID: radioA) - let bRows = try await store.fetchPendingSends(radioID: radioB) - - #expect(aRows.count == 1) - #expect(bRows.count == 1) - #expect(aRows.first?.radioID == radioA) - #expect(bRows.first?.radioID == radioB) - } - - @Test("fetchPendingSends returns rows in sequence order") - func sequenceOrdering() async throws { - let store = try makeStore() - let radioID = UUID() - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 3)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 2)) - - let rows = try await store.fetchPendingSends(radioID: radioID) - - #expect(rows.map(\.sequence) == [1, 2, 3]) - } - - @Test("deletePendingSend removes the row") - func deletion() async throws { - let store = try makeStore() - let radioID = UUID() - let dto = PendingSendDTO.fixture(radioID: radioID, sequence: 1) - try await store.upsertPendingSend(dto) - try await store.deletePendingSend(id: dto.id) - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.isEmpty) - } - - @Test("insertPendingSendAssigningSequence returns 1 on an empty table") - func assignSequenceOnEmptyTable() async throws { - let store = try makeStore() - let radioID = UUID() - let dto = PendingSendDTO.fixture(radioID: radioID, sequence: 0) - - let assigned = try await store.insertPendingSendAssigningSequence(dto) - - #expect(assigned == 1) - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 1) - #expect(rows.first?.sequence == 1) - } - - @Test("insertPendingSendAssigningSequence assigns per-radio monotonic sequences") - func assignSequenceMonotonicPerRadio() async throws { - let store = try makeStore() - let radioA = UUID() - let radioB = UUID() - - let firstA = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioA, sequence: 0)) - let secondA = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioA, sequence: 0)) - let firstB = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioB, sequence: 0)) - - #expect(firstA == 1) - #expect(secondA == 2) - #expect(firstB == 1, "Sequence numbering is per-radio") - } - - @Test("Concurrent inserts on the same radio produce distinct sequences") - func assignSequenceConcurrentInserts() async throws { - let store = try makeStore() - let radioID = UUID() - let dtoA = PendingSendDTO.fixture(radioID: radioID, sequence: 0) - let dtoB = PendingSendDTO.fixture(radioID: radioID, sequence: 0) - - async let seqA = store.insertPendingSendAssigningSequence(dtoA) - async let seqB = store.insertPendingSendAssigningSequence(dtoB) - let assigned = try await [seqA, seqB].sorted() - - #expect(assigned == [1, 2], "Serial @ModelActor isolation must produce distinct sequences") - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 2) - #expect(Set(rows.map(\.sequence)) == [1, 2]) - } - - /// `attemptCount` rides through `insertPendingSendAssigningSequence` → - /// `fetchPendingSends` (DTO → @Model → DTO) without normalization or - /// loss. The convenience initializer `PendingSend(dto:)` must forward - /// the value verbatim — per CLAUDE.md "Backup and restore", a stored - /// field that distinguishes pre-migration rows (`nil`) from current-build - /// rows (`0` or positive) must not collapse through a normalizing - /// accessor at materialization time. Round-tripping each of the three - /// distinguishable states verifies the column survives intact. - @Test("attemptCount round-trips through insertPendingSendAssigningSequence and fetchPendingSends") - func attemptCountRoundTripsThroughInsertAndFetch() async throws { - let store = try makeStore() - let radioID = UUID() - - func dto(attemptCount: Int?, messageID: UUID) -> PendingSendDTO { - PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: messageID, - kind: .dm, - contactID: UUID(), - channelIndex: nil, - isResend: false, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: 0, - enqueuedAt: Date(), - attemptCount: attemptCount - ) - } - - let legacyID = UUID() - let raceWindowID = UUID() - let drainedID = UUID() - _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: nil, messageID: legacyID)) - _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: 0, messageID: raceWindowID)) - _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: 4, messageID: drainedID)) - - let rows = try await store.fetchPendingSends(radioID: radioID) - let legacyRow = try #require(rows.first(where: { $0.messageID == legacyID })) - let raceWindowRow = try #require(rows.first(where: { $0.messageID == raceWindowID })) - let drainedRow = try #require(rows.first(where: { $0.messageID == drainedID })) - - #expect(legacyRow.attemptCount == nil, - "pre-migration row stored as nil must come back as nil — the field remains optional in storage") - #expect(raceWindowRow.attemptCount == 0, - "current-build race-window row stored as 0 must come back as 0") - #expect(drainedRow.attemptCount == 4, - "positive attemptCount must round-trip without normalization") - } - - @Test("deletePendingSendsForMessage removes every row matching the messageID") - func deletePendingSendsForMessageRemovesMatchingRows() async throws { - let store = try makeStore() - let radioA = UUID() - let radioB = UUID() - let sharedMessageID = UUID() - let otherMessageID = UUID() - - try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 1, messageID: sharedMessageID)) - try await store.upsertPendingSend(.fixture(radioID: radioB, sequence: 1, messageID: sharedMessageID)) - try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 2, messageID: otherMessageID)) - - try await store.deletePendingSendsForMessage(messageID: sharedMessageID) - - let rowsA = try await store.fetchPendingSends(radioID: radioA) - let rowsB = try await store.fetchPendingSends(radioID: radioB) - #expect(rowsA.count == 1) - #expect(rowsA.first?.messageID == otherMessageID) - #expect(rowsB.isEmpty) - } - - @Test("deletePendingSendsForMessage is a no-op when no rows match") - func deletePendingSendsForMessageNoMatch() async throws { - let store = try makeStore() - let radioID = UUID() - let messageID = UUID() - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) - - try await store.deletePendingSendsForMessage(messageID: UUID()) - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 1) - #expect(rows.first?.messageID == messageID) + @Test + func `PendingSendDTO round-trips through Model and back`() async throws { + let store = try makeStore() + let radioID = UUID() + let dto = PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: UUID(), + kind: .channel, + contactID: nil, + channelIndex: 3, + isResend: true, + messageText: "test", + messageTimestamp: 1_700_000_000, + localNodeName: "Alice", + sequence: 1, + enqueuedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + + try await store.upsertPendingSend(dto) + let fetched = try await store.fetchPendingSends(radioID: radioID) + + #expect(fetched.count == 1) + #expect(fetched.first == dto) + } + + @Test + func `Pending sends are scoped by radioID`() async throws { + let store = try makeStore() + let radioA = UUID() + let radioB = UUID() + try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 1)) + try await store.upsertPendingSend(.fixture(radioID: radioB, sequence: 1)) + + let aRows = try await store.fetchPendingSends(radioID: radioA) + let bRows = try await store.fetchPendingSends(radioID: radioB) + + #expect(aRows.count == 1) + #expect(bRows.count == 1) + #expect(aRows.first?.radioID == radioA) + #expect(bRows.first?.radioID == radioB) + } + + @Test + func `fetchPendingSends returns rows in sequence order`() async throws { + let store = try makeStore() + let radioID = UUID() + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 3)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 2)) + + let rows = try await store.fetchPendingSends(radioID: radioID) + + #expect(rows.map(\.sequence) == [1, 2, 3]) + } + + @Test + func `deletePendingSend removes the row`() async throws { + let store = try makeStore() + let radioID = UUID() + let dto = PendingSendDTO.fixture(radioID: radioID, sequence: 1) + try await store.upsertPendingSend(dto) + try await store.deletePendingSend(id: dto.id) + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.isEmpty) + } + + @Test + func `insertPendingSendAssigningSequence returns 1 on an empty table`() async throws { + let store = try makeStore() + let radioID = UUID() + let dto = PendingSendDTO.fixture(radioID: radioID, sequence: 0) + + let assigned = try await store.insertPendingSendAssigningSequence(dto) + + #expect(assigned == 1) + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 1) + #expect(rows.first?.sequence == 1) + } + + @Test + func `insertPendingSendAssigningSequence assigns per-radio monotonic sequences`() async throws { + let store = try makeStore() + let radioA = UUID() + let radioB = UUID() + + let firstA = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioA, sequence: 0)) + let secondA = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioA, sequence: 0)) + let firstB = try await store.insertPendingSendAssigningSequence(.fixture(radioID: radioB, sequence: 0)) + + #expect(firstA == 1) + #expect(secondA == 2) + #expect(firstB == 1, "Sequence numbering is per-radio") + } + + @Test + func `Concurrent inserts on the same radio produce distinct sequences`() async throws { + let store = try makeStore() + let radioID = UUID() + let dtoA = PendingSendDTO.fixture(radioID: radioID, sequence: 0) + let dtoB = PendingSendDTO.fixture(radioID: radioID, sequence: 0) + + async let seqA = store.insertPendingSendAssigningSequence(dtoA) + async let seqB = store.insertPendingSendAssigningSequence(dtoB) + let assigned = try await [seqA, seqB].sorted() + + #expect(assigned == [1, 2], "Serial @ModelActor isolation must produce distinct sequences") + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 2) + #expect(Set(rows.map(\.sequence)) == [1, 2]) + } + + /// `attemptCount` rides through `insertPendingSendAssigningSequence` → + /// `fetchPendingSends` (DTO → @Model → DTO) without normalization or + /// loss. The convenience initializer `PendingSend(dto:)` must forward + /// the value verbatim — per CLAUDE.md "Backup and restore", a stored + /// field that distinguishes pre-migration rows (`nil`) from current-build + /// rows (`0` or positive) must not collapse through a normalizing + /// accessor at materialization time. Round-tripping each of the three + /// distinguishable states verifies the column survives intact. + @Test + func `attemptCount round-trips through insertPendingSendAssigningSequence and fetchPendingSends`() async throws { + let store = try makeStore() + let radioID = UUID() + + func dto(attemptCount: Int?, messageID: UUID) -> PendingSendDTO { + PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: messageID, + kind: .dm, + contactID: UUID(), + channelIndex: nil, + isResend: false, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: 0, + enqueuedAt: Date(), + attemptCount: attemptCount + ) } - @Test("fetchPendingSends skips rows whose kindRawValue is unknown") - func fetchSkipsUnknownKindRows() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let radioID = UUID() - - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1)) - - let futureCaseRow = PendingSend( - id: UUID(), - radioID: radioID, - messageID: UUID(), - kindRawValue: 99, - contactID: nil, - channelIndex: nil, - isResend: false, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: 2, - enqueuedAt: Date() - ) - container.mainContext.insert(futureCaseRow) - try container.mainContext.save() - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 1, "Unknown-kind row must be filtered out") - #expect(rows.first?.sequence == 1) - } - - /// `replacePendingSendForRetry` happy path: a `.failed` row is flipped - /// to `.pending`, the prior `PendingSend` for the same `messageID` is - /// gone, and a fresh `PendingSend` row exists with the supplied DTO - /// fields and a sequence assigned per-radio. - @Test("replacePendingSendForRetry flips status and inserts a new PendingSend row") - func replacePendingSendForRetry_FlipsStatusAndInsertsRow() async throws { - let store = try makeStore() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - - try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) - - let newDTO = PendingSendDTO( - envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), - radioID: radioID - ) - let assigned = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) - - let refreshed = try #require(try await store.fetchMessage(id: messageID)) - #expect(refreshed.status == .pending, "Status must flip to .pending") - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 1, "Old PendingSend row must be replaced, not duplicated") - let row = try #require(rows.first) - #expect(row.id == newDTO.id, "Stored row id must match the new DTO") - #expect(row.messageID == messageID) - #expect(row.kind == .dm) - #expect(row.contactID == contactID) - #expect(row.sequence == assigned, "Assigned sequence must match the persisted row") - #expect(assigned == 1, "Replacing the only row for the radio starts fresh at 1") - } - - /// `.delivered` is the race-won state: a late ACK that landed while - /// the user was reaching for retry must not be clobbered back to - /// `.pending`. The `PendingSend` row is still inserted — the queue's - /// drain-time gate handles the no-op send. - @Test("replacePendingSendForRetry preserves .delivered status") - func replacePendingSendForRetry_PreservesDelivered() async throws { - let store = try makeStore() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - - try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .delivered)) - - let newDTO = PendingSendDTO( - envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), - radioID: radioID - ) - _ = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) - - let refreshed = try #require(try await store.fetchMessage(id: messageID)) - #expect(refreshed.status == .delivered, ".delivered must not be downgraded by retry") - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 1, "PendingSend row is still inserted; drain gate handles no-op") - } - - /// All prior PendingSend rows that match the messageID are removed. - /// Older runs that may have racked up multiple PendingSend rows on - /// the same messageID across reconnects must not survive the retry. - @Test("replacePendingSendForRetry removes every existing PendingSend row for the messageID") - func replacePendingSendForRetry_RemovesExistingPendingSendRow() async throws { - let store = try makeStore() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - - try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 2, messageID: messageID)) - let unrelatedMessageID = UUID() - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 3, messageID: unrelatedMessageID)) - - let newDTO = PendingSendDTO( - envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), - radioID: radioID - ) - _ = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) - - let rows = try await store.fetchPendingSends(radioID: radioID) - #expect(rows.count == 2, "Unrelated row survives; both prior rows for the retried messageID are gone") - let retried = try #require(rows.first(where: { $0.messageID == messageID })) - #expect(retried.id == newDTO.id) - let unrelated = try #require(rows.first(where: { $0.messageID == unrelatedMessageID })) - #expect(unrelated.sequence == 3, "Unrelated row keeps its prior sequence") - } - - /// Sequence assignment matches `insertPendingSendAssigningSequence`: - /// the new row gets `max(sequence) + 1` for the radio. Verifies the - /// per-radio monotonic invariant survives the delete-then-insert - /// transaction. - @Test("replacePendingSendForRetry assigns the next per-radio sequence") - func replacePendingSendForRetry_AssignsNextSequence() async throws { - let store = try makeStore() - let radioID = UUID() - let otherRadioID = UUID() - let messageID = UUID() - let contactID = UUID() - - try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) - try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 7, messageID: UUID())) - try await store.upsertPendingSend(.fixture(radioID: otherRadioID, sequence: 99, messageID: UUID())) - - let newDTO = PendingSendDTO( - envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), - radioID: radioID - ) - let assigned = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) - - #expect(assigned == 8, "Sequence advances from the highest live value for this radio") - let rows = try await store.fetchPendingSends(radioID: radioID) - let inserted = try #require(rows.first(where: { $0.messageID == messageID })) - #expect(inserted.sequence == 8) - } - - private func makeOutgoingDM( - id: UUID, - radioID: UUID, - contactID: UUID, - status: MessageStatus - ) -> MessageDTO { - MessageDTO( - id: id, - radioID: radioID, - contactID: contactID, - channelIndex: nil, - text: "retry me", - timestamp: 1_700_000_000, - createdAt: Date(timeIntervalSince1970: 1_700_000_000), - direction: .outgoing, - status: status, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: true, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - } - - private func makeStore() throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } + let legacyID = UUID() + let raceWindowID = UUID() + let drainedID = UUID() + _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: nil, messageID: legacyID)) + _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: 0, messageID: raceWindowID)) + _ = try await store.insertPendingSendAssigningSequence(dto(attemptCount: 4, messageID: drainedID)) + + let rows = try await store.fetchPendingSends(radioID: radioID) + let legacyRow = try #require(rows.first(where: { $0.messageID == legacyID })) + let raceWindowRow = try #require(rows.first(where: { $0.messageID == raceWindowID })) + let drainedRow = try #require(rows.first(where: { $0.messageID == drainedID })) + + #expect(legacyRow.attemptCount == nil, + "pre-migration row stored as nil must come back as nil — the field remains optional in storage") + #expect(raceWindowRow.attemptCount == 0, + "current-build race-window row stored as 0 must come back as 0") + #expect(drainedRow.attemptCount == 4, + "positive attemptCount must round-trip without normalization") + } + + @Test + func `deletePendingSendsForMessage removes every row matching the messageID`() async throws { + let store = try makeStore() + let radioA = UUID() + let radioB = UUID() + let sharedMessageID = UUID() + let otherMessageID = UUID() + + try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 1, messageID: sharedMessageID)) + try await store.upsertPendingSend(.fixture(radioID: radioB, sequence: 1, messageID: sharedMessageID)) + try await store.upsertPendingSend(.fixture(radioID: radioA, sequence: 2, messageID: otherMessageID)) + + try await store.deletePendingSendsForMessage(messageID: sharedMessageID) + + let rowsA = try await store.fetchPendingSends(radioID: radioA) + let rowsB = try await store.fetchPendingSends(radioID: radioB) + #expect(rowsA.count == 1) + #expect(rowsA.first?.messageID == otherMessageID) + #expect(rowsB.isEmpty) + } + + @Test + func `deletePendingSendsForMessage is a no-op when no rows match`() async throws { + let store = try makeStore() + let radioID = UUID() + let messageID = UUID() + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) + + try await store.deletePendingSendsForMessage(messageID: UUID()) + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 1) + #expect(rows.first?.messageID == messageID) + } + + @Test + func `fetchPendingSends skips rows whose kindRawValue is unknown`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1)) + + let futureCaseRow = PendingSend( + id: UUID(), + radioID: radioID, + messageID: UUID(), + kindRawValue: 99, + contactID: nil, + channelIndex: nil, + isResend: false, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: 2, + enqueuedAt: Date() + ) + container.mainContext.insert(futureCaseRow) + try container.mainContext.save() + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 1, "Unknown-kind row must be filtered out") + #expect(rows.first?.sequence == 1) + } + + /// `replacePendingSendForRetry` happy path: a `.failed` row is flipped + /// to `.pending`, the prior `PendingSend` for the same `messageID` is + /// gone, and a fresh `PendingSend` row exists with the supplied DTO + /// fields and a sequence assigned per-radio. + @Test + func `replacePendingSendForRetry flips status and inserts a new PendingSend row`() async throws { + let store = try makeStore() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + + try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) + + let newDTO = PendingSendDTO( + envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), + radioID: radioID + ) + let assigned = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) + + let refreshed = try #require(try await store.fetchMessage(id: messageID)) + #expect(refreshed.status == .pending, "Status must flip to .pending") + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 1, "Old PendingSend row must be replaced, not duplicated") + let row = try #require(rows.first) + #expect(row.id == newDTO.id, "Stored row id must match the new DTO") + #expect(row.messageID == messageID) + #expect(row.kind == .dm) + #expect(row.contactID == contactID) + #expect(row.sequence == assigned, "Assigned sequence must match the persisted row") + #expect(assigned == 1, "Replacing the only row for the radio starts fresh at 1") + } + + /// `.delivered` is the race-won state: a late ACK that landed while + /// the user was reaching for retry must not be clobbered back to + /// `.pending`. The `PendingSend` row is still inserted — the queue's + /// drain-time gate handles the no-op send. + @Test + func `replacePendingSendForRetry preserves .delivered status`() async throws { + let store = try makeStore() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + + try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .delivered)) + + let newDTO = PendingSendDTO( + envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), + radioID: radioID + ) + _ = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) + + let refreshed = try #require(try await store.fetchMessage(id: messageID)) + #expect(refreshed.status == .delivered, ".delivered must not be downgraded by retry") + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 1, "PendingSend row is still inserted; drain gate handles no-op") + } + + /// All prior PendingSend rows that match the messageID are removed. + /// Older runs that may have racked up multiple PendingSend rows on + /// the same messageID across reconnects must not survive the retry. + @Test + func `replacePendingSendForRetry removes every existing PendingSend row for the messageID`() async throws { + let store = try makeStore() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + + try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 1, messageID: messageID)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 2, messageID: messageID)) + let unrelatedMessageID = UUID() + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 3, messageID: unrelatedMessageID)) + + let newDTO = PendingSendDTO( + envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), + radioID: radioID + ) + _ = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) + + let rows = try await store.fetchPendingSends(radioID: radioID) + #expect(rows.count == 2, "Unrelated row survives; both prior rows for the retried messageID are gone") + let retried = try #require(rows.first(where: { $0.messageID == messageID })) + #expect(retried.id == newDTO.id) + let unrelated = try #require(rows.first(where: { $0.messageID == unrelatedMessageID })) + #expect(unrelated.sequence == 3, "Unrelated row keeps its prior sequence") + } + + /// Sequence assignment matches `insertPendingSendAssigningSequence`: + /// the new row gets `max(sequence) + 1` for the radio. Verifies the + /// per-radio monotonic invariant survives the delete-then-insert + /// transaction. + @Test + func `replacePendingSendForRetry assigns the next per-radio sequence`() async throws { + let store = try makeStore() + let radioID = UUID() + let otherRadioID = UUID() + let messageID = UUID() + let contactID = UUID() + + try await store.saveMessage(makeOutgoingDM(id: messageID, radioID: radioID, contactID: contactID, status: .failed)) + try await store.upsertPendingSend(.fixture(radioID: radioID, sequence: 7, messageID: UUID())) + try await store.upsertPendingSend(.fixture(radioID: otherRadioID, sequence: 99, messageID: UUID())) + + let newDTO = PendingSendDTO( + envelope: DirectMessageEnvelope(messageID: messageID, contactID: contactID), + radioID: radioID + ) + let assigned = try await store.replacePendingSendForRetry(messageID: messageID, dto: newDTO) + + #expect(assigned == 8, "Sequence advances from the highest live value for this radio") + let rows = try await store.fetchPendingSends(radioID: radioID) + let inserted = try #require(rows.first(where: { $0.messageID == messageID })) + #expect(inserted.sequence == 8) + } + + private func makeOutgoingDM( + id: UUID, + radioID: UUID, + contactID: UUID, + status: MessageStatus + ) -> MessageDTO { + MessageDTO( + id: id, + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "retry me", + timestamp: 1_700_000_000, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + direction: .outgoing, + status: status, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: true, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + private func makeStore() throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } } private extension PendingSendDTO { - static func fixture( - radioID: UUID = UUID(), - sequence: Int, - messageID: UUID = UUID() - ) -> PendingSendDTO { - PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: messageID, - kind: .dm, - contactID: UUID(), - channelIndex: nil, - isResend: false, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: sequence, - enqueuedAt: Date() - ) - } + static func fixture( + radioID: UUID = UUID(), + sequence: Int, + messageID: UUID = UUID() + ) -> PendingSendDTO { + PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: messageID, + kind: .dm, + contactID: UUID(), + channelIndex: nil, + isResend: false, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: sequence, + enqueuedAt: Date() + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceKeysThemeTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceKeysThemeTests.swift index 4dbba308..ce1bdc66 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceKeysThemeTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceKeysThemeTests.swift @@ -1,18 +1,17 @@ -import Testing @testable import MC1Services +import Testing @Suite("PersistenceKeysTheme") struct PersistenceKeysThemeTests { + @Test + func `theme keys are the exact bare-string on-disk names`() { + #expect(PersistenceKeys.selectedThemeID == "selectedThemeID") + #expect(PersistenceKeys.appColorSchemePreference == "appColorSchemePreference") + } - @Test("theme keys are the exact bare-string on-disk names") - func themeKeyLiterals() { - #expect(PersistenceKeys.selectedThemeID == "selectedThemeID") - #expect(PersistenceKeys.appColorSchemePreference == "appColorSchemePreference") - } - - @Test("theme keys carry no com.pocketmesh prefix (bare-string convention)") - func themeKeysAreBare() { - #expect(!PersistenceKeys.selectedThemeID.contains("com.pocketmesh")) - #expect(!PersistenceKeys.appColorSchemePreference.contains("com.pocketmesh")) - } + @Test + func `theme keys carry no com.pocketmesh prefix (bare-string convention)`() { + #expect(!PersistenceKeys.selectedThemeID.contains("com.pocketmesh")) + #expect(!PersistenceKeys.appColorSchemePreference.contains("com.pocketmesh")) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift index 6b2fee79..e20970d1 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift @@ -1,7 +1,7 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing /// Covers the single-transaction sync-persistence methods added for BLE sync-speed /// optimization (`batchSaveChannels` / `batchSaveContacts`). These carry the @@ -9,220 +9,219 @@ import Foundation /// capacity pruning, and leaving circuit-breaker-skipped slots untouched. @Suite("PersistenceStore Batch Sync Tests") struct PersistenceStoreBatchSyncTests { - - private func secret(_ byte: UInt8) -> Data { - Data(repeating: byte, count: ProtocolLimits.channelSecretSize) - } - - private func publicKey(_ byte: UInt8) -> Data { - Data(repeating: byte, count: ProtocolLimits.publicKeySize) - } - - private func channelInfo(_ index: UInt8, name: String, secretByte: UInt8) -> ChannelInfo { - ChannelInfo(index: index, name: name, secret: secret(secretByte)) - } - - private func contactFrame(_ keyByte: UInt8, name: String, flags: UInt8 = 0) -> ContactFrame { - ContactFrame( - publicKey: publicKey(keyByte), - type: .chat, - flags: flags, - outPathLength: 0, - outPath: Data(), - name: name, - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - } - - // MARK: - batchSaveChannels - - @Test("batchSaveChannels inserts new and updates existing in one pass") - func batchSaveChannelsInsertsAndUpdates() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "Old", secretByte: 0x11)) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [ - channelInfo(1, name: "New", secretByte: 0x99), - channelInfo(2, name: "Two", secretByte: 0x22) - ], - unconfiguredIndices: [], - pruneBeyond: 8 - ) - - #expect(result.map(\.index) == [1, 2]) - let one = try #require(try await store.fetchChannel(radioID: radioID, index: 1)) - #expect(one.name == "New") - #expect(one.secret == secret(0x99)) - let two = try #require(try await store.fetchChannel(radioID: radioID, index: 2)) - #expect(two.name == "Two") - } - - @Test("batchSaveChannels deletes stale rows at unconfigured indices") - func batchSaveChannelsDeletesUnconfigured() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "Keep", secretByte: 0x11)) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(2, name: "Gone", secretByte: 0x22)) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [channelInfo(1, name: "Keep", secretByte: 0x11)], - unconfiguredIndices: [2], - pruneBeyond: 8 - ) - - #expect(result.map(\.index) == [1]) - #expect(try await store.fetchChannel(radioID: radioID, index: 2) == nil) - } - - @Test("batchSaveChannels prunes orphans beyond device capacity") - func batchSaveChannelsPrunesOrphans() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "InRange", secretByte: 0x11)) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(10, name: "Orphan", secretByte: 0xAA)) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [channelInfo(1, name: "InRange", secretByte: 0x11)], - unconfiguredIndices: [], - pruneBeyond: 8 - ) - - #expect(result.map(\.index) == [1]) - #expect(try await store.fetchChannel(radioID: radioID, index: 10) == nil) - } - - @Test("batchSaveChannels leaves circuit-breaker-skipped slots untouched") - func batchSaveChannelsLeavesSkippedSlotsUntouched() async throws { - // Simulates a sync that read index 1, then the circuit breaker aborted, so indices - // 2 and 3 were never queried — they are in neither the configured nor the - // unconfigured list and must survive the persist pass. - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "One", secretByte: 0x11)) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(2, name: "Two", secretByte: 0x22)) - _ = try await store.saveChannel(radioID: radioID, from: channelInfo(3, name: "Three", secretByte: 0x33)) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [channelInfo(1, name: "OneUpdated", secretByte: 0x11)], - unconfiguredIndices: [], - pruneBeyond: 8 - ) - - #expect(result.map(\.index) == [1, 2, 3]) - #expect(try await store.fetchChannel(radioID: radioID, index: 1)?.name == "OneUpdated") - #expect(try await store.fetchChannel(radioID: radioID, index: 2)?.name == "Two") - #expect(try await store.fetchChannel(radioID: radioID, index: 3)?.name == "Three") - } - - @Test("batchSaveChannels handles non-contiguous configured indices") - func batchSaveChannelsHandlesGaps() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [ - channelInfo(0, name: "Public", secretByte: 0x00), - channelInfo(2, name: "Two", secretByte: 0x22), - channelInfo(7, name: "Seven", secretByte: 0x77) - ], - unconfiguredIndices: [1, 3, 4, 5, 6], - pruneBeyond: 8 - ) - - #expect(result.map(\.index) == [0, 2, 7]) - #expect(try await store.fetchChannel(radioID: radioID, index: 7)?.name == "Seven") - } - - @Test("batchSaveChannels on empty store with no work returns empty") - func batchSaveChannelsEmpty() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - let result = try await store.batchSaveChannels( - radioID: radioID, - configured: [], - unconfiguredIndices: [], - pruneBeyond: 8 - ) - - #expect(result.isEmpty) - } - - @Test("batchSaveChannels scopes to radioID") - func batchSaveChannelsScopesToRadio() async throws { - let radioA = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioA, maxChannels: 8) - let radioB = UUID() - _ = try await store.saveChannel(radioID: radioB, from: channelInfo(1, name: "OtherRadio", secretByte: 0x55)) - - _ = try await store.batchSaveChannels( - radioID: radioA, - configured: [channelInfo(1, name: "Mine", secretByte: 0x11)], - unconfiguredIndices: [], - pruneBeyond: 8 - ) - - // Radio B's channel must be unaffected by a Radio A sync. - #expect(try await store.fetchChannel(radioID: radioB, index: 1)?.name == "OtherRadio") - } - - // MARK: - batchSaveContacts - - @Test("batchSaveContacts inserts all and returns count") - func batchSaveContactsInserts() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - let count = try await store.batchSaveContacts(radioID: radioID, from: [ - contactFrame(0x01, name: "Alice"), - contactFrame(0x02, name: "Bob"), - contactFrame(0x03, name: "Carol") - ]) - - #expect(count == 3) - let stored = try await store.fetchContacts(radioID: radioID) - #expect(stored.count == 3) - #expect(Set(stored.map(\.name)) == ["Alice", "Bob", "Carol"]) - } - - @Test("batchSaveContacts updates existing rows and preserves favorite bit") - func batchSaveContactsUpdatesAndPreservesFavorite() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - // Seed a favorited contact (flags bit 0 set). - _ = try await store.saveContact(radioID: radioID, from: contactFrame(0x01, name: "Original", flags: 0x01)) - - // Re-sync with a frame whose flags clear bit 0; update(from:) must keep the favorite. - let count = try await store.batchSaveContacts(radioID: radioID, from: [ - contactFrame(0x01, name: "Renamed", flags: 0x00) - ]) - - #expect(count == 1) - let stored = try await store.fetchContacts(radioID: radioID) - #expect(stored.count == 1) - let contact = try #require(stored.first) - #expect(contact.name == "Renamed") - #expect(contact.isFavorite) - } - - @Test("batchSaveContacts with empty frames returns zero") - func batchSaveContactsEmpty() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - let count = try await store.batchSaveContacts(radioID: radioID, from: []) - #expect(count == 0) - #expect(try await store.fetchContacts(radioID: radioID).isEmpty) - } + private func secret(_ byte: UInt8) -> Data { + Data(repeating: byte, count: ProtocolLimits.channelSecretSize) + } + + private func publicKey(_ byte: UInt8) -> Data { + Data(repeating: byte, count: ProtocolLimits.publicKeySize) + } + + private func channelInfo(_ index: UInt8, name: String, secretByte: UInt8) -> ChannelInfo { + ChannelInfo(index: index, name: name, secret: secret(secretByte)) + } + + private func contactFrame(_ keyByte: UInt8, name: String, flags: UInt8 = 0) -> ContactFrame { + ContactFrame( + publicKey: publicKey(keyByte), + type: .chat, + flags: flags, + outPathLength: 0, + outPath: Data(), + name: name, + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + } + + // MARK: - batchSaveChannels + + @Test + func `batchSaveChannels inserts new and updates existing in one pass`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "Old", secretByte: 0x11)) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [ + channelInfo(1, name: "New", secretByte: 0x99), + channelInfo(2, name: "Two", secretByte: 0x22) + ], + unconfiguredIndices: [], + pruneBeyond: 8 + ) + + #expect(result.map(\.index) == [1, 2]) + let one = try #require(try await store.fetchChannel(radioID: radioID, index: 1)) + #expect(one.name == "New") + #expect(one.secret == secret(0x99)) + let two = try #require(try await store.fetchChannel(radioID: radioID, index: 2)) + #expect(two.name == "Two") + } + + @Test + func `batchSaveChannels deletes stale rows at unconfigured indices`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "Keep", secretByte: 0x11)) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(2, name: "Gone", secretByte: 0x22)) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [channelInfo(1, name: "Keep", secretByte: 0x11)], + unconfiguredIndices: [2], + pruneBeyond: 8 + ) + + #expect(result.map(\.index) == [1]) + #expect(try await store.fetchChannel(radioID: radioID, index: 2) == nil) + } + + @Test + func `batchSaveChannels prunes orphans beyond device capacity`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "InRange", secretByte: 0x11)) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(10, name: "Orphan", secretByte: 0xAA)) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [channelInfo(1, name: "InRange", secretByte: 0x11)], + unconfiguredIndices: [], + pruneBeyond: 8 + ) + + #expect(result.map(\.index) == [1]) + #expect(try await store.fetchChannel(radioID: radioID, index: 10) == nil) + } + + @Test + func `batchSaveChannels leaves circuit-breaker-skipped slots untouched`() async throws { + // Simulates a sync that read index 1, then the circuit breaker aborted, so indices + // 2 and 3 were never queried — they are in neither the configured nor the + // unconfigured list and must survive the persist pass. + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(1, name: "One", secretByte: 0x11)) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(2, name: "Two", secretByte: 0x22)) + _ = try await store.saveChannel(radioID: radioID, from: channelInfo(3, name: "Three", secretByte: 0x33)) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [channelInfo(1, name: "OneUpdated", secretByte: 0x11)], + unconfiguredIndices: [], + pruneBeyond: 8 + ) + + #expect(result.map(\.index) == [1, 2, 3]) + #expect(try await store.fetchChannel(radioID: radioID, index: 1)?.name == "OneUpdated") + #expect(try await store.fetchChannel(radioID: radioID, index: 2)?.name == "Two") + #expect(try await store.fetchChannel(radioID: radioID, index: 3)?.name == "Three") + } + + @Test + func `batchSaveChannels handles non-contiguous configured indices`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [ + channelInfo(0, name: "Public", secretByte: 0x00), + channelInfo(2, name: "Two", secretByte: 0x22), + channelInfo(7, name: "Seven", secretByte: 0x77) + ], + unconfiguredIndices: [1, 3, 4, 5, 6], + pruneBeyond: 8 + ) + + #expect(result.map(\.index) == [0, 2, 7]) + #expect(try await store.fetchChannel(radioID: radioID, index: 7)?.name == "Seven") + } + + @Test + func `batchSaveChannels on empty store with no work returns empty`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + let result = try await store.batchSaveChannels( + radioID: radioID, + configured: [], + unconfiguredIndices: [], + pruneBeyond: 8 + ) + + #expect(result.isEmpty) + } + + @Test + func `batchSaveChannels scopes to radioID`() async throws { + let radioA = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioA, maxChannels: 8) + let radioB = UUID() + _ = try await store.saveChannel(radioID: radioB, from: channelInfo(1, name: "OtherRadio", secretByte: 0x55)) + + _ = try await store.batchSaveChannels( + radioID: radioA, + configured: [channelInfo(1, name: "Mine", secretByte: 0x11)], + unconfiguredIndices: [], + pruneBeyond: 8 + ) + + // Radio B's channel must be unaffected by a Radio A sync. + #expect(try await store.fetchChannel(radioID: radioB, index: 1)?.name == "OtherRadio") + } + + // MARK: - batchSaveContacts + + @Test + func `batchSaveContacts inserts all and returns count`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + let count = try await store.batchSaveContacts(radioID: radioID, from: [ + contactFrame(0x01, name: "Alice"), + contactFrame(0x02, name: "Bob"), + contactFrame(0x03, name: "Carol") + ]) + + #expect(count == 3) + let stored = try await store.fetchContacts(radioID: radioID) + #expect(stored.count == 3) + #expect(Set(stored.map(\.name)) == ["Alice", "Bob", "Carol"]) + } + + @Test + func `batchSaveContacts updates existing rows and preserves favorite bit`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + // Seed a favorited contact (flags bit 0 set). + _ = try await store.saveContact(radioID: radioID, from: contactFrame(0x01, name: "Original", flags: 0x01)) + + // Re-sync with a frame whose flags clear bit 0; update(from:) must keep the favorite. + let count = try await store.batchSaveContacts(radioID: radioID, from: [ + contactFrame(0x01, name: "Renamed", flags: 0x00) + ]) + + #expect(count == 1) + let stored = try await store.fetchContacts(radioID: radioID) + #expect(stored.count == 1) + let contact = try #require(stored.first) + #expect(contact.name == "Renamed") + #expect(contact.isFavorite) + } + + @Test + func `batchSaveContacts with empty frames returns zero`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + let count = try await store.batchSaveContacts(radioID: radioID, from: []) + #expect(count == 0) + #expect(try await store.fetchContacts(radioID: radioID).isEmpty) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift index b86f1317..a400429e 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift @@ -1,2947 +1,2946 @@ import Foundation +@testable import MC1Services +import MeshCore import SwiftData import Testing -import MeshCore -@testable import MC1Services @Suite("PersistenceStore Tests") struct PersistenceStoreTests { - - // MARK: - Test Helpers - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private func createTestDevice(id: UUID = UUID()) -> DeviceDTO { - DeviceDTO.testDevice( - id: id, - publicKey: Data((0.. ContactFrame { - ContactFrame( - publicKey: Data((0.. ( - contactID: UUID, messageID: UUID, channelID: UUID, sessionID: UUID - ) { - let contactFrame = createTestContactFrame(name: "TestContact") - let contactID = try await store.saveContact(radioID: radioID, from: contactFrame) - - let message = MessageDTO(from: Message( - radioID: radioID, - contactID: contactID, - text: "Hello!", - timestamp: UInt32(Date().timeIntervalSince1970) - )) - try await store.saveMessage(message) - try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) - - let channelInfo = ChannelInfo(index: 1, name: "Private", secret: Data(repeating: 0x42, count: 16)) - let channelID = try await store.saveChannel(radioID: radioID, from: channelInfo) - - let reaction = ReactionDTO( - messageID: message.id, - emoji: "👍", - senderName: "Reactor", - messageHash: "AABBCCDD", - rawText: "👍", - radioID: radioID - ) - try await store.saveReaction(reaction) - - let session = createTestRoomSession(radioID: radioID) - try await store.saveRemoteNodeSessionDTO(session) - - let roomMessage = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), - authorName: "Author", - text: "Room message", - timestamp: UInt32(Date().timeIntervalSince1970) - ) - try await store.saveRoomMessage(roomMessage) - - let blocked = BlockedChannelSenderDTO(name: "Spammer", radioID: radioID) - try await store.saveBlockedChannelSender(blocked) - - let rxLog = createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: 12345) - try await store.saveRxLogEntry(rxLog) - - let discoveredFrame = createTestContactFrame(name: "Discovered") - _ = try await store.upsertDiscoveredNode(radioID: radioID, from: discoveredFrame) - - return (contactID, message.id, channelID, session.id) - } - - /// Asserts all entity types for a device are present. - private func assertAllDataExists( - store: PersistenceStore, radioID: UUID, sessionID: UUID, messageID: UUID - ) async throws { - let contacts = try await store.fetchContacts(radioID: radioID) - #expect(contacts.count == 1, "Expected 1 contact") - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.count == 1, "Expected 1 channel") - let reactions = try await store.fetchReactions(for: messageID) - #expect(reactions.count == 1, "Expected 1 reaction") - let sessions = try await store.fetchRemoteNodeSessions(radioID: radioID) - #expect(sessions.count == 1, "Expected 1 session") - let roomMessages = try await store.fetchRoomMessages(sessionID: sessionID) - #expect(roomMessages.count == 1, "Expected 1 room message") - let blockedSenders = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(blockedSenders.count == 1, "Expected 1 blocked sender") - let rxEntries = try await store.fetchRxLogEntries(radioID: radioID) - #expect(rxEntries.count == 1, "Expected 1 RX log entry") - let discoveredNodes = try await store.fetchDiscoveredNodes(radioID: radioID) - #expect(discoveredNodes.count == 1, "Expected 1 discovered node") - } - - /// Asserts all entity types for a device have been deleted. - private func assertAllDataDeleted( - store: PersistenceStore, radioID: UUID, sessionID: UUID, messageID: UUID - ) async throws { - let contacts = try await store.fetchContacts(radioID: radioID) - #expect(contacts.isEmpty, "Expected no contacts") - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.isEmpty, "Expected no channels") - let reactions = try await store.fetchReactions(for: messageID) - #expect(reactions.isEmpty, "Expected no reactions") - let sessions = try await store.fetchRemoteNodeSessions(radioID: radioID) - #expect(sessions.isEmpty, "Expected no sessions") - let roomMessages = try await store.fetchRoomMessages(sessionID: sessionID) - #expect(roomMessages.isEmpty, "Expected no room messages") - let blockedSenders = try await store.fetchBlockedChannelSenders(radioID: radioID) - #expect(blockedSenders.isEmpty, "Expected no blocked senders") - let rxEntries = try await store.fetchRxLogEntries(radioID: radioID) - #expect(rxEntries.isEmpty, "Expected no RX log entries") - let discoveredNodes = try await store.fetchDiscoveredNodes(radioID: radioID) - #expect(discoveredNodes.isEmpty, "Expected no discovered nodes") - let repeats = try await store.fetchMessageRepeats(messageID: messageID) - #expect(repeats.isEmpty, "Expected no message repeats") - } - - @Test("deleteDevice removes only device record, preserves all associated data") - func deleteDevicePreservesData() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let ids = try await seedAllEntityTypes(store: store, radioID: device.id) - - try await store.deleteDevice(id: device.id) - - let fetchedDevice = try await store.fetchDevice(id: device.id) - #expect(fetchedDevice == nil, "Device record should be deleted") - - try await assertAllDataExists( - store: store, radioID: device.id, - sessionID: ids.sessionID, messageID: ids.messageID - ) - } - - @Test("deleteDeviceData removes all associated data but not device record") - func deleteDeviceDataPreservesDevice() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let ids = try await seedAllEntityTypes(store: store, radioID: device.id) - - try await store.deleteDeviceData(id: device.id) - - let fetchedDevice = try await store.fetchDevice(id: device.id) - #expect(fetchedDevice != nil, "Device record should be preserved") - - try await assertAllDataDeleted( - store: store, radioID: device.id, - sessionID: ids.sessionID, messageID: ids.messageID - ) - } - - @Test("deleteDeviceAndData removes device and all data atomically") - func deleteDeviceAndDataRemovesAll() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let ids = try await seedAllEntityTypes(store: store, radioID: device.id) - - try await store.deleteDeviceAndData(id: device.id) - - let fetchedDevice = try await store.fetchDevice(id: device.id) - #expect(fetchedDevice == nil, "Device record should be deleted") - - try await assertAllDataDeleted( - store: store, radioID: device.id, - sessionID: ids.sessionID, messageID: ids.messageID - ) - } - - @Test("deleteDeviceAndData does not trap when a message has heard repeats (cascade-vs-batch)") - func deleteDeviceAndDataWithMessageRepeatsDoesNotTrap() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let contactID = try await store.saveContact(radioID: device.id, from: createTestContactFrame()) - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "broadcast", - timestamp: UInt32(Date().timeIntervalSince1970), - directionRawValue: MessageDirection.outgoing.rawValue - )) - try await store.saveMessage(message) - // Two heard repeats wire up message.repeats so the delete exercises cascade - // propagation over a relationship whose child rows were batch-deleted first. - try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) - try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) - - try await store.deleteDeviceAndData(id: device.id) - - #expect(try await store.fetchDevice(id: device.id) == nil) - #expect(try await store.fetchMessage(id: message.id) == nil) - #expect(try await store.fetchMessageRepeats(messageID: message.id).isEmpty) - } - - @Test("deleteDeviceData reaps a message and its heard repeats while preserving the device") - func deleteDeviceDataWithMessageRepeatsPreservesDevice() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let contactID = try await store.saveContact(radioID: device.id, from: createTestContactFrame()) - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "broadcast", - timestamp: UInt32(Date().timeIntervalSince1970), - directionRawValue: MessageDirection.outgoing.rawValue - )) - try await store.saveMessage(message) - try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) - try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) - - try await store.deleteDeviceData(id: device.id) - - #expect(try await store.fetchDevice(id: device.id) != nil) - #expect(try await store.fetchMessage(id: message.id) == nil) - #expect(try await store.fetchMessageRepeats(messageID: message.id).isEmpty) - } - - @Test("deleteDeviceData for non-existent device does not throw") - func deleteDeviceDataNonExistent() async throws { - let store = try await createTestStore() - try await store.deleteDeviceData(id: UUID()) - } - - @Test("deleteDeviceAndData for non-existent device does not throw") - func deleteDeviceAndDataNonExistent() async throws { - let store = try await createTestStore() - try await store.deleteDeviceAndData(id: UUID()) - } - - @Test("Re-pair after device deletion re-associates orphaned data") - func rePairReassociatesOrphanedData() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let contactFrame = createTestContactFrame(name: "Survivor") - _ = try await store.saveContact(radioID: device.id, from: contactFrame) - - let channelInfo = ChannelInfo(index: 0, name: "General", secret: Data(repeating: 0, count: 16)) - _ = try await store.saveChannel(radioID: device.id, from: channelInfo) - - // Simulate ASK removal: delete device record only - try await store.deleteDevice(id: device.id) - - // Simulate re-pair: saveDevice upserts with same ID - try await store.saveDevice(device) - - let contacts = try await store.fetchContacts(radioID: device.id) - #expect(contacts.count == 1) - #expect(contacts.first?.name == "Survivor") - - let channels = try await store.fetchChannels(radioID: device.id) - #expect(channels.count == 1) - #expect(channels.first?.name == "General") - } - - @Test("Demote device to ghost preserves publicKey and radioID with fresh id") - func demoteDeviceToGhostPreservesIdentity() async throws { - let store = try await createTestStore() - let original = createTestDevice().copy { - $0.isActive = true - } - try await store.saveDevice(original) - - try await store.demoteDeviceToGhost(id: original.id) - - let originalLookup = try await store.fetchDevice(id: original.id) - #expect(originalLookup == nil, "Original BLE id should no longer resolve") - - let ghost = try await store.fetchDevice(publicKey: original.publicKey) - #expect(ghost != nil) - #expect(ghost?.id != original.id, "Ghost must have a fresh id") - #expect(ghost?.publicKey == original.publicKey) - #expect(ghost?.radioID == original.radioID) - #expect(ghost?.isActive == false) - } - - @Test("Demote device strips all connection methods so it stays hidden") - func demoteDeviceToGhostStripsAllConnectionMethods() async throws { - let store = try await createTestStore() - let wifi = ConnectionMethod.wifi(host: "10.0.0.5", port: 5000, displayName: nil) - let bluetooth = ConnectionMethod.bluetooth(peripheralUUID: UUID(), displayName: nil) - let original = createTestDevice().copy { - $0.connectionMethods = [wifi, bluetooth] - } - try await store.saveDevice(original) - - try await store.demoteDeviceToGhost(id: original.id) - - let ghost = try await store.fetchDevice(publicKey: original.publicKey) - #expect(ghost?.connectionMethods.isEmpty == true, - "Demoted ghost must have no connection methods so DeviceSelectionFilter hides it") - } - - @Test("Demote device with unknown id is a no-op") - func demoteDeviceToGhostUnknownIDNoOp() async throws { - let store = try await createTestStore() - try await store.demoteDeviceToGhost(id: UUID()) - let devices = try await store.fetchDevices() - #expect(devices.isEmpty) - } - - @Test("Removing a paired device preserves child contacts via radioID") - func demoteRetainsChildLinkageByRadioID() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let contactFrame = createTestContactFrame(name: "Alice") - _ = try await store.saveContact(radioID: device.radioID, from: contactFrame) - - try await store.demoteDeviceToGhost(id: device.id) - - let ghost = try await store.fetchDevice(publicKey: device.publicKey) - #expect(ghost?.radioID == device.radioID) - let contacts = try await store.fetchContacts(radioID: device.radioID) - #expect(contacts.count == 1) - #expect(contacts.first?.name == "Alice") - } - - // MARK: - Contact Tests - - @Test("Save and fetch contact from frame") - func saveAndFetchContactFromFrame() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame(name: "Alice") - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - let contact = try await store.fetchContact(id: contactID) - #expect(contact != nil) - #expect(contact?.name == "Alice") - #expect(contact?.type == .chat) - } - - @Test("Fetch contact by public key") - func fetchContactByPublicKey() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame(name: "Bob") - _ = try await store.saveContact(radioID: device.id, from: frame) - - let contact = try await store.fetchContact(radioID: device.id, publicKey: frame.publicKey) - #expect(contact != nil) - #expect(contact?.name == "Bob") - } - - @Test("Update contact last message and unread count") - func updateContactLastMessageAndUnread() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - let now = Date() - try await store.updateContactLastMessage(contactID: contactID, date: now) - try await store.incrementUnreadCount(contactID: contactID) - try await store.incrementUnreadCount(contactID: contactID) - - var contact = try await store.fetchContact(id: contactID) - #expect(contact?.unreadCount == 2) - #expect(contact?.lastMessageDate != nil) - - try await store.clearUnreadCount(contactID: contactID) - - contact = try await store.fetchContact(id: contactID) - #expect(contact?.unreadCount == 0) - } - - @Test("deleteMessagesForContact removes all messages for a contact") - func deleteMessagesForContactRemovesAll() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Create first contact - let frame1 = createTestContactFrame(name: "Contact1") - let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) - - // Create multiple messages for this contact - for i in 0..<5 { - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contact1ID, - text: "Message \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - )) - try await store.saveMessage(message) - } - - // Create a second contact with a message (should not be deleted) - let frame2 = createTestContactFrame(name: "Contact2") - let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) - let otherMessage = MessageDTO(from: Message( - radioID: device.id, - contactID: contact2ID, - text: "Other message", - timestamp: UInt32(Date().timeIntervalSince1970) + 100 - )) - try await store.saveMessage(otherMessage) - - // Verify messages exist before deletion - var contact1Messages = try await store.fetchMessages(contactID: contact1ID) - #expect(contact1Messages.count == 5) - - var contact2Messages = try await store.fetchMessages(contactID: contact2ID) - #expect(contact2Messages.count == 1) - - // Delete messages for the first contact - try await store.deleteMessagesForContact(contactID: contact1ID) - - // Verify messages for deleted contact are gone - contact1Messages = try await store.fetchMessages(contactID: contact1ID) - #expect(contact1Messages.isEmpty) - - // Verify messages for other contact still exist - contact2Messages = try await store.fetchMessages(contactID: contact2ID) - #expect(contact2Messages.count == 1) - } - - @Test("recomputeContactLastMessageDate keeps a conversation visible while older messages remain and clears it only when empty") - func recomputeContactLastMessageDateTracksRemainingMessages() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame(name: "Conversation") - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - let base = Date(timeIntervalSince1970: 1_700_000_000) - let messages = (0..<3).map { i in - MessageDTO.testDirectMessage( - radioID: device.id, - contactID: contactID, - text: "Message \(i)", - timestamp: UInt32(base.timeIntervalSince1970) + UInt32(i), - createdAt: base.addingTimeInterval(Double(i)) - ) - } - for message in messages { - try await store.saveMessage(message) - } - - // Newest message sets the date; the conversation is visible. - var newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) - #expect(newDate == messages[2].date) - var conversations = try await store.fetchConversations(radioID: device.id) - #expect(conversations.contains { $0.id == contactID }) - - // Deleting the newest message falls back to the next remaining message, - // not nil: the conversation must stay visible while messages remain. - try await store.deleteMessage(id: messages[2].id) - newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) - #expect(newDate == messages[1].date) - conversations = try await store.fetchConversations(radioID: device.id) - #expect(conversations.contains { $0.id == contactID }) - - // Deleting the last remaining messages clears the date and removes the conversation. - try await store.deleteMessage(id: messages[1].id) - try await store.deleteMessage(id: messages[0].id) - newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) - #expect(newDate == nil) - conversations = try await store.fetchConversations(radioID: device.id) - #expect(!conversations.contains { $0.id == contactID }) - } - - @Test("deleteMessagesForChannel removes all messages for a channel") - func deleteMessagesForChannelRemovesAll() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let channelIndex0: UInt8 = 0 - let channelIndex1: UInt8 = 1 - - // Create messages for channel 0 - for i in 0..<5 { - let message = MessageDTO(from: Message( - radioID: device.id, - channelIndex: channelIndex0, - text: "Channel 0 Message \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - )) - try await store.saveMessage(message) - } - - // Create messages for channel 1 (should not be deleted) - for i in 0..<3 { - let message = MessageDTO(from: Message( - radioID: device.id, - channelIndex: channelIndex1, - text: "Channel 1 Message \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i + 100) - )) - try await store.saveMessage(message) - } - - // Create a contact message (should not be deleted) - let frame = createTestContactFrame(name: "Contact1") - let contactID = try await store.saveContact(radioID: device.id, from: frame) - let contactMessage = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Contact message", - timestamp: UInt32(Date().timeIntervalSince1970) + 200 - )) - try await store.saveMessage(contactMessage) - - // Verify messages exist before deletion - var channel0Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex0) - #expect(channel0Messages.count == 5) - - var channel1Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex1) - #expect(channel1Messages.count == 3) - - var contactMessages = try await store.fetchMessages(contactID: contactID) - #expect(contactMessages.count == 1) - - // Delete messages for channel 0 - try await store.deleteMessagesForChannel(radioID: device.id, channelIndex: channelIndex0) - - // Verify channel 0 messages are gone - channel0Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex0) - #expect(channel0Messages.isEmpty) - - // Verify channel 1 messages still exist - channel1Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex1) - #expect(channel1Messages.count == 3) - - // Verify contact messages still exist - contactMessages = try await store.fetchMessages(contactID: contactID) - #expect(contactMessages.count == 1) - } - - // MARK: - Message Tests - - @Test("Save and fetch messages for contact") - func saveAndFetchMessagesForContact() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - // Save multiple messages - for i in 0..<5 { - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Message \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - )) - try await store.saveMessage(message) - } - - let messages = try await store.fetchMessages(contactID: contactID) - #expect(messages.count == 5) - // Messages should be in chronological order (oldest first) - #expect(messages.first?.text == "Message 0") - #expect(messages.last?.text == "Message 4") - } - - @Test("Interleaved backlog from multiple senders reassembles in sortDate (send) order") - func backlogReassemblesInSortDateOrder() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - // Backlog drains in a scrambled receive order: rows arrive (createdAt) - // in the order 2, 0, 3, 1 but their send times (sortDate) are 0..<4. - // Display must reassemble by send order regardless of receive order. - let drainBase = Date(timeIntervalSince1970: 2_000_000) - let sendBase = Date(timeIntervalSince1970: 1_000_000) - let scrambledSendOrder = [2, 0, 3, 1] - for (receiveOffset, sendIndex) in scrambledSendOrder.enumerated() { - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Send \(sendIndex)", - timestamp: UInt32(sendBase.timeIntervalSince1970) + UInt32(sendIndex), - createdAt: drainBase.addingTimeInterval(TimeInterval(receiveOffset)), - sortDate: sendBase.addingTimeInterval(TimeInterval(sendIndex)) - )) - try await store.saveMessage(message) - } - - let messages = try await store.fetchMessages(contactID: contactID) - #expect(messages.map(\.text) == ["Send 0", "Send 1", "Send 2", "Send 3"]) - } - - @Test("Live message stays last even when an earlier-sortDate backlog row is inserted after it") - func liveMessageStaysLastDespiteLaterBacklogInsert() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - // A live row: sortDate == createdAt == now. - let now = Date(timeIntervalSince1970: 3_000_000) - let liveMessage = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Live", - timestamp: UInt32(now.timeIntervalSince1970), - createdAt: now, - sortDate: now - )) - try await store.saveMessage(liveMessage) - - // A backlog row inserted afterwards (later createdAt) but with an - // earlier send time. Its skewed sender clock must not let it sort - // above the live row. - let laterCreatedAt = now.addingTimeInterval(60) - let earlierSendTime = now.addingTimeInterval(-3600) - let backlogMessage = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Backlog", - timestamp: UInt32(earlierSendTime.timeIntervalSince1970), - createdAt: laterCreatedAt, - sortDate: earlierSendTime - )) - try await store.saveMessage(backlogMessage) - - let messages = try await store.fetchMessages(contactID: contactID) - #expect(messages.map(\.text) == ["Backlog", "Live"]) - #expect(messages.last?.text == "Live") - } - - @Test("Equal sortDate and timestamp fall back to createdAt order (tertiary key)") - func equalSortDateAndTimestampFallBackToCreatedAt() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - // All three rows share an identical sortDate (primary key, e.g. un-backfilled - // rows that all defaulted to the same value) and an identical timestamp (secondary - // key), so only createdAt — the tertiary sort key — can break the tie. Varying - // timestamp too would let the secondary key drive the order and mask whether - // createdAt is honored. - let sharedSortDate = Date(timeIntervalSince1970: 4_000_000) - let sharedTimestamp = UInt32(sharedSortDate.timeIntervalSince1970) - let createdBase = Date(timeIntervalSince1970: 5_000_000) - for i in 0..<3 { - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Tie \(i)", - timestamp: sharedTimestamp, - createdAt: createdBase.addingTimeInterval(TimeInterval(i)), - sortDate: sharedSortDate - )) - try await store.saveMessage(message) - } - - let messages = try await store.fetchMessages(contactID: contactID) - #expect(messages.map(\.text) == ["Tie 0", "Tie 1", "Tie 2"]) - } - - @Test("Backlog block orders by send time within a shared drain anchor") - func backlogBlockOrdersBySendTimeWithinAnchor() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let channelIndex: UInt8 = 0 - // One drain anchor shared by the whole block (block-at-reconnect). - let anchor = Date(timeIntervalSince1970: 1_700_000_000) - - // Three different senders so reorderSameSenderClusters leaves them alone, - // isolating the fetch's sort keys. createdAt (drain order) runs opposite to - // send time, proving the secondary sort key is timestamp, not createdAt. - func backlogMessage(sender: String, sendTime: UInt32, drainOffset: TimeInterval) -> MessageDTO { - MessageDTO(from: Message( - radioID: device.id, - channelIndex: channelIndex, - text: sender, - timestamp: sendTime, - createdAt: anchor.addingTimeInterval(drainOffset), - sortDate: anchor, - directionRawValue: MessageDirection.incoming.rawValue, - senderNodeName: sender - )) - } - // Drained Carol, Bob, Alice (createdAt 0,1,2) but sent Alice < Bob < Carol. - try await store.saveMessage(backlogMessage(sender: "Carol", sendTime: 300, drainOffset: 0)) - try await store.saveMessage(backlogMessage(sender: "Bob", sendTime: 200, drainOffset: 1)) - try await store.saveMessage(backlogMessage(sender: "Alice", sendTime: 100, drainOffset: 2)) - - let messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex) - - #expect(messages.map(\.text) == ["Alice", "Bob", "Carol"]) - } - - @Test("Find channel message for reaction within timestamp window") - func findChannelMessageForReactionWithinWindow() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let channelIndex: UInt8 = 1 - let baseTimestamp: UInt32 = 1_700_000_000 - var targetMessage: MessageDTO? - - for i in 0..<120 { - let timestamp = baseTimestamp + UInt32(i) - let message = MessageDTO( - id: UUID(), - radioID: device.id, - contactID: nil, - channelIndex: channelIndex, - text: "Message \(i)", - timestamp: timestamp, - createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: "RemoteNode", - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - try await store.saveMessage(message) - if i == 80 { - targetMessage = message - } - } - - let message = try #require(targetMessage) - let reactionService = ReactionService() - let reactionText = reactionService.buildReactionText( - emoji: "👍", - targetSender: "RemoteNode", - targetText: message.text, - targetTimestamp: message.timestamp - ) - let parsed = try #require(ReactionParser.parse(reactionText)) - - let now = message.timestamp - let windowStart = now > 300 ? now - 300 : 0 - let windowEnd = now + 300 - - let found = try await store.findChannelMessageForReaction( - radioID: device.id, - channelIndex: channelIndex, - parsedReaction: parsed, - localNodeName: "LocalNode", - timestampWindow: windowStart...windowEnd, - limit: 200 - ) - - #expect(found?.id == message.id) - } - - @Test("Find outgoing channel message for reaction using local node name") - func findOutgoingChannelMessageForReaction() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let channelIndex: UInt8 = 2 - let timestamp: UInt32 = 1_700_000_200 - - let outgoingMessage = MessageDTO( - id: UUID(), - radioID: device.id, - contactID: nil, - channelIndex: channelIndex, - text: "Local message", - timestamp: timestamp, - createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), - direction: .outgoing, - status: .sent, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - try await store.saveMessage(outgoingMessage) - - let reactionService = ReactionService() - let reactionText = reactionService.buildReactionText( - emoji: "🔥", - targetSender: "LocalNode", - targetText: outgoingMessage.text, - targetTimestamp: outgoingMessage.timestamp - ) - let parsed = try #require(ReactionParser.parse(reactionText)) - - let now = outgoingMessage.timestamp - let windowStart = now > 300 ? now - 300 : 0 - let windowEnd = now + 300 - - let found = try await store.findChannelMessageForReaction( - radioID: device.id, - channelIndex: channelIndex, - parsedReaction: parsed, - localNodeName: "LocalNode", - timestampWindow: windowStart...windowEnd, - limit: 200 - ) - - #expect(found?.id == outgoingMessage.id) - } - - @Test("Update message status") - func updateMessageStatus() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame() - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - let message = MessageDTO(from: Message( - radioID: device.id, - contactID: contactID, - text: "Test", - statusRawValue: MessageStatus.pending.rawValue - )) - try await store.saveMessage(message) - - // Update status to sending - try await store.updateMessageStatus(id: message.id, status: .sending) - var fetched = try await store.fetchMessage(id: message.id) - #expect(fetched?.status == .sending) - - // Update status to sent - try await store.updateMessageStatus(id: message.id, status: .sent) - fetched = try await store.fetchMessage(id: message.id) - #expect(fetched?.status == .sent) - } - - // MARK: - Channel Tests - - @Test("Save and fetch channels") - func saveAndFetchChannels() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Add public channel - let publicChannel = ChannelInfo(index: 0, name: "Public", secret: Data(repeating: 0, count: 16)) - _ = try await store.saveChannel(radioID: device.id, from: publicChannel) - - // Add private channel - let privateChannel = ChannelInfo(index: 1, name: "Private", secret: Data(repeating: 0x42, count: 16)) - _ = try await store.saveChannel(radioID: device.id, from: privateChannel) - - let channels = try await store.fetchChannels(radioID: device.id) - #expect(channels.count == 2) - #expect(channels[0].index == 0) - #expect(channels[0].name == "Public") - #expect(channels[1].index == 1) - #expect(channels[1].name == "Private") - } - - // MARK: - RemoteNodeSession Tests - - private func createTestRoomSession(radioID: UUID) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: UUID(), - radioID: radioID, - publicKey: Data((0..= firstDate!) - } - - @Test("Update room activity ignores older sync timestamps") - func updateRoomActivityIgnoresOlderSyncTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - // Set initial timestamp - try await store.updateRoomActivity(session.id, syncTimestamp: 5000) - - var fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.lastSyncTimestamp == 5000) - - // Try to update with older timestamp - sync timestamp should be ignored - try await store.updateRoomActivity(session.id, syncTimestamp: 3000) - - fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.lastSyncTimestamp == 5000) - // But lastMessageDate should still be updated - #expect(fetched?.lastMessageDate != nil) - } - - @Test("Update room activity without sync timestamp does not change lastSyncTimestamp") - func updateRoomActivityWithoutSyncTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - // Set initial sync timestamp - try await store.updateRoomActivity(session.id, syncTimestamp: 5000) - - // Call without sync timestamp (send path) - try await store.updateRoomActivity(session.id) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.lastSyncTimestamp == 5000) - #expect(fetched?.lastMessageDate != nil) - } - - @Test("Mark room session connected changes isConnected and returns true") - func markRoomSessionConnected() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Create a disconnected session with admin permission - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: false, - permissionLevel: .admin, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - let result = try await store.markRoomSessionConnected(session.id) - #expect(result == true) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.isConnected == true) - // Permission level must not be changed - #expect(fetched?.permissionLevel == .admin) - } - - @Test("Mark room session connected returns false when already connected") - func markRoomSessionConnectedAlreadyConnected() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: true, - permissionLevel: .guest, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - let result = try await store.markRoomSessionConnected(session.id) - #expect(result == false) - } - - @Test("Mark session disconnected preserves permission level") - func markSessionDisconnectedPreservesPermissionLevel() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: true, - permissionLevel: .admin, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - try await store.markSessionDisconnected(session.id) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.isConnected == false) - #expect(fetched?.permissionLevel == .admin) - } - - @Test("Mark session disconnected is no-op when already disconnected") - func markSessionDisconnectedAlreadyDisconnected() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: false, - permissionLevel: .admin, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - try await store.markSessionDisconnected(session.id) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.isConnected == false) - #expect(fetched?.permissionLevel == .admin) - } - - @Test("Disconnect then recover preserves permission level") - func disconnectThenRecoverPreservesPermissionLevel() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: true, - permissionLevel: .admin, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - try await store.markSessionDisconnected(session.id) - _ = try await store.markRoomSessionConnected(session.id) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.isConnected == true) - #expect(fetched?.permissionLevel == .admin) - } - - @Test("Update remote node session connection can reset permission to guest") - func updateRemoteNodeSessionConnectionResetsPermissionToGuest() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - var session = createTestRoomSession(radioID: device.id) - session = RemoteNodeSessionDTO( - id: session.id, - radioID: session.radioID, - publicKey: session.publicKey, - name: session.name, - role: session.role, - isConnected: true, - permissionLevel: .admin, - lastSyncTimestamp: session.lastSyncTimestamp - ) - try await store.saveRemoteNodeSessionDTO(session) - - try await store.updateRemoteNodeSessionConnection( - id: session.id, - isConnected: false, - permissionLevel: .guest - ) - - let fetched = try await store.fetchRemoteNodeSession(id: session.id) - #expect(fetched?.isConnected == false) - #expect(fetched?.permissionLevel == .guest) - } - - // MARK: - RoomMessage Tests - - @Test("Save and fetch room messages") - func saveAndFetchRoomMessages() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - // Save room messages - for i in 0..<3 { - let message = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), - authorName: "Author\(i)", - text: "Room message \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - ) - try await store.saveRoomMessage(message) - } - - let messages = try await store.fetchRoomMessages(sessionID: session.id) - #expect(messages.count == 3) - } - - @Test("Room messages tied on timestamp order deterministically by createdAt") - func roomMessagesEqualTimestampOrderByCreatedAt() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - // Same wire timestamp (1-second resolution) but distinct arrival times. Inserted - // out of arrival order so the fetch must impose the createdAt tie-break itself. - let sharedTimestamp = UInt32(Date().timeIntervalSince1970) - let base = Date(timeIntervalSince1970: 1_700_000_000) - let arrivals: [(text: String, createdAt: Date)] = [ - ("second", base.addingTimeInterval(1)), - ("third", base.addingTimeInterval(2)), - ("first", base) - ] - for arrival in arrivals { - let message = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), - text: arrival.text, - timestamp: sharedTimestamp, - createdAt: arrival.createdAt - ) - try await store.saveRoomMessage(message) - } - - let messages = try await store.fetchRoomMessages(sessionID: session.id) - #expect(messages.map(\.text) == ["first", "second", "third"]) - } - - @Test("Room messages order primarily by wire timestamp") - func roomMessagesOrderByTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - // Distinct timestamps inserted out of order; arrival order is the inverse of - // send order to prove the timestamp key wins over createdAt. - let baseTimestamp = UInt32(Date().timeIntervalSince1970) - let arrival = Date(timeIntervalSince1970: 1_700_000_000) - let entries: [(text: String, offset: UInt32, arrivalOffset: TimeInterval)] = [ - ("newest", 2, 0), - ("oldest", 0, 2), - ("middle", 1, 1) - ] - for entry in entries { - let message = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), - text: entry.text, - timestamp: baseTimestamp + entry.offset, - createdAt: arrival.addingTimeInterval(entry.arrivalOffset) - ) - try await store.saveRoomMessage(message) - } - - let messages = try await store.fetchRoomMessages(sessionID: session.id) - #expect(messages.map(\.text) == ["oldest", "middle", "newest"]) - } - - @Test("Room message deduplication") - func roomMessageDeduplication() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let session = createTestRoomSession(radioID: device.id) - try await store.saveRemoteNodeSessionDTO(session) - - let timestamp = UInt32(Date().timeIntervalSince1970) - let authorKeyPrefix = Data([0x01, 0x02, 0x03, 0x04]) - let text = "Duplicate message" - - // Save message - let message1 = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: authorKeyPrefix, - text: text, - timestamp: timestamp - ) - try await store.saveRoomMessage(message1) - - // Try to save duplicate (same timestamp, author, and content hash) - let message2 = RoomMessageDTO( - sessionID: session.id, - authorKeyPrefix: authorKeyPrefix, - text: text, - timestamp: timestamp - ) - try await store.saveRoomMessage(message2) - - // Should only have one message - let messages = try await store.fetchRoomMessages(sessionID: session.id) - #expect(messages.count == 1) - } - - // MARK: - Duplicate Session Cleanup Tests - - @Test("Cleanup duplicate remote node sessions keeps target and deletes others") - func cleanupDuplicateRemoteNodeSessions() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let sharedKey = Data((0.. PendingSendDTO { - PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: messageID, - kind: .dm, - contactID: UUID(), - channelIndex: nil, - isResend: false, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: sequence, - enqueuedAt: Date(), - attemptCount: attemptCount - ) - } - - @Test("incrementPendingSendAttemptCount from 0 bumps to 1") - func testIncrementPendingSendAttemptCount_FromZero_BumpsToOne() async throws { - let store = try await createTestStore() - let messageID = UUID() - let dto = makePendingSendDTO(messageID: messageID, attemptCount: 0) - try await store.upsertPendingSend(dto) - - let result = try await store.incrementPendingSendAttemptCount(messageID: messageID) - #expect(result == 1, "first drain attempt should bump 0 → 1") - - let persisted = try await store.fetchPendingSends(radioID: dto.radioID).first - #expect(persisted?.attemptCount == 1, "persisted attemptCount should match return value") - } - - @Test("incrementPendingSendAttemptCount returns nil when no row matches") - func testIncrementPendingSendAttemptCount_NoRow_ReturnsNil() async throws { - let store = try await createTestStore() - let messageID = UUID() - - let result = try await store.incrementPendingSendAttemptCount(messageID: messageID) - #expect(result == nil, "missing-row case is terminal — return nil instead of creating a new row") - } - - @Test("purgeLegacyAttemptCountRows deletes only legacy nil rows") - func testPurgeLegacyAttemptCountRows_DeletesLegacyNilRowsOnly() async throws { - let store = try await createTestStore() - let radioID = UUID() - let legacyDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: nil, sequence: 1) - let raceDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: 0, sequence: 2) - let drainedDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: 3, sequence: 3) - try await store.upsertPendingSend(legacyDTO) - try await store.upsertPendingSend(raceDTO) - try await store.upsertPendingSend(drainedDTO) - - let deleted = try await store.purgeLegacyAttemptCountRows() - #expect(deleted == 1, "only the single nil-valued row should be deleted") - - let rows = try await store.fetchPendingSends(radioID: radioID) - let messageIDs = Set(rows.map(\.messageID)) - #expect(!messageIDs.contains(legacyDTO.messageID), "legacy nil row must be deleted") - let byMessageID = Dictionary(uniqueKeysWithValues: rows.map { ($0.messageID, $0.attemptCount) }) - #expect(byMessageID[raceDTO.messageID] == 0, "race-window 0 row stays at 0") - #expect(byMessageID[drainedDTO.messageID] == 3, "already-drained row stays untouched") - } - - @Test("purgeLegacyAttemptCountRows is idempotent") - func testPurgeLegacyAttemptCountRows_IsIdempotent() async throws { - let store = try await createTestStore() - let radioID = UUID() - let dto = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: nil) - try await store.upsertPendingSend(dto) - - let firstDeleted = try await store.purgeLegacyAttemptCountRows() - let secondDeleted = try await store.purgeLegacyAttemptCountRows() - #expect(firstDeleted == 1, "first call deletes the legacy nil row") - #expect(secondDeleted == 0, "second call: predicate matches nothing — idempotent on an empty nil set") - } - - @Test("warmUp runs both purgeOrphanPendingSends and purgeLegacyAttemptCountRows") - func testWarmUp_RunsBothPurges() async throws { - let store = try await createTestStore() - let radioWithDevice = UUID() - let radioWithoutDevice = UUID() - - // Device for one of the two radios — the other's PendingSends are orphans. - let scopedDevice = DeviceDTO.testDevice(id: radioWithDevice, radioID: radioWithDevice) - try await store.saveDevice(scopedDevice) - - let nilCountOnDeviceRadio = makePendingSendDTO( - messageID: UUID(), radioID: radioWithDevice, attemptCount: nil, sequence: 1 - ) - let orphanOnUnknownRadio = makePendingSendDTO( - messageID: UUID(), radioID: radioWithoutDevice, attemptCount: nil, sequence: 1 - ) - try await store.upsertPendingSend(nilCountOnDeviceRadio) - try await store.upsertPendingSend(orphanOnUnknownRadio) - - try await store.warmUp() - - let survivingForDevice = try await store.fetchPendingSends(radioID: radioWithDevice) - let survivingForUnknown = try await store.fetchPendingSends(radioID: radioWithoutDevice) - #expect(survivingForDevice.isEmpty, - "warmUp must run both purges: nil-attemptCount rows deleted even when radio has a paired device") - #expect(survivingForUnknown.isEmpty, - "row attached to no-device radio must be purged by purgeOrphanPendingSends") - } - - @Test("deletePendingSendsForMessage public API saves on return") - func testDeletePendingSendsForMessage_PublicAPISavesOnReturn() async throws { - let store = try await createTestStore() - let radioID = UUID() - let messageID = UUID() - try await store.upsertPendingSend(makePendingSendDTO( - messageID: messageID, radioID: radioID, attemptCount: 1, sequence: 1 - )) - - try await store.deletePendingSendsForMessage(messageID: messageID) - - // No explicit save from the test — visibility of the deletion to a - // subsequent fetch confirms the public method saved on return, - // matching the contract expected by ChatSendQueueService callers. - let hasPending = try await store.hasPendingSend(messageID: messageID) - #expect(hasPending == false, - "public deletePendingSendsForMessage must save before returning") - } - - // MARK: - PendingSend Cascade Tests - - @Test("deleteMessage cascades the matching PendingSend in a single transaction") - func testDeleteMessage_CascadesPendingSends() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let messageID = UUID() - let message = MessageDTO(from: Message( - id: messageID, - radioID: device.id, - contactID: nil, - channelIndex: 0, - text: "hello", - timestamp: UInt32(Date().timeIntervalSince1970) - )) - try await store.saveMessage(message) - - let pending = makePendingSendDTO(messageID: messageID, radioID: device.id, attemptCount: 0) - try await store.upsertPendingSend(pending) - - try await store.deleteMessage(id: messageID) - - let remainingPending = try await store.fetchPendingSends(radioID: device.id) - #expect(remainingPending.isEmpty, - "deleteMessage must cascade the PendingSend row keyed by the deleted messageID") - let remainingMessages = try await store.fetchAllMessages(radioID: device.id) - #expect(remainingMessages.isEmpty, - "deleteMessage must still remove the Message row") - } - - @Test("deleteMessage reaps an orphan PendingSend even when no Message row exists") - func testDeleteMessage_NoMatchingMessage_StillReapsOrphanPendingSend() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // An orphan PendingSend with no corresponding Message — can arise from - // a same-millisecond race between deleteMessage and upsertPendingSend. - let orphanMessageID = UUID() - let pending = makePendingSendDTO( - messageID: orphanMessageID, radioID: device.id, attemptCount: 0 - ) - try await store.upsertPendingSend(pending) - - try await store.deleteMessage(id: orphanMessageID) - - let remainingPending = try await store.fetchPendingSends(radioID: device.id) - #expect(remainingPending.isEmpty, - "deleteMessage must reap orphan PendingSends even without a matching Message row") - } - - @Test("deleteDeviceData reaps radio-scoped orphan PendingSends and preserves the Device row") - func testDeleteDeviceData_ReapsRadioOrphanPendingSends() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Message + matching PendingSend (messageID cascade reaches this row). - let matchedMessageID = UUID() - let matchedMessage = MessageDTO(from: Message( - id: matchedMessageID, - radioID: device.id, - contactID: nil, - channelIndex: 0, - text: "matched", - timestamp: UInt32(Date().timeIntervalSince1970) - )) - try await store.saveMessage(matchedMessage) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: matchedMessageID, radioID: device.id, attemptCount: 0, sequence: 1 - )) - - // PendingSend whose messageID does not correspond to any saved Message. - // The messageIDs-keyed cascade cannot see it; the radioID-keyed defensive - // delete must reap it. - try await store.upsertPendingSend(makePendingSendDTO( - messageID: UUID(), radioID: device.id, attemptCount: 0, sequence: 2 - )) - - try await store.deleteDeviceData(id: device.id) - - let surviving = try await store.fetchPendingSends(radioID: device.id) - #expect(surviving.isEmpty, - "deleteDeviceData must reap both Message-matched and orphan PendingSends for the radio") - - let fetchedDevice = try await store.fetchDevice(id: device.id) - #expect(fetchedDevice != nil, - "deleteDeviceData must preserve the Device row") - } - - @Test("deleteMessagesForContact cascades PendingSends and spares unrelated contacts") - func testDeleteMessagesForContact_CascadesPendingSends() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame1 = createTestContactFrame(name: "Contact1") - let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) - let frame2 = createTestContactFrame(name: "Contact2") - let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) - - var contact1MessageIDs: [UUID] = [] - for i in 0..<3 { - let messageID = UUID() - contact1MessageIDs.append(messageID) - let message = MessageDTO(from: Message( - id: messageID, - radioID: device.id, - contactID: contact1ID, - text: "C1 \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - )) - try await store.saveMessage(message) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 - )) - } - - let contact2MessageID = UUID() - let contact2Message = MessageDTO(from: Message( - id: contact2MessageID, - radioID: device.id, - contactID: contact2ID, - text: "C2 keep", - timestamp: UInt32(Date().timeIntervalSince1970) + 100 - )) - try await store.saveMessage(contact2Message) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: contact2MessageID, radioID: device.id, attemptCount: 0, sequence: 99 - )) - - try await store.deleteMessagesForContact(contactID: contact1ID) - - let remaining = try await store.fetchPendingSends(radioID: device.id) - #expect(remaining.count == 1, - "only the unrelated contact's PendingSend should survive") - #expect(remaining.first?.messageID == contact2MessageID, - "surviving PendingSend must belong to the untouched contact") - - let contact2Messages = try await store.fetchMessages(contactID: contact2ID) - #expect(contact2Messages.count == 1, - "unrelated contact's Message row must be preserved") - } - - @Test("deleteMessagesForChannel cascades PendingSends and spares other channels") - func testDeleteMessagesForChannel_CascadesPendingSends() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let targetChannel: UInt8 = 0 - let untouchedChannel: UInt8 = 1 - - for i in 0..<3 { - let messageID = UUID() - let message = MessageDTO(from: Message( - id: messageID, - radioID: device.id, - contactID: nil, - channelIndex: targetChannel, - text: "Ch0 \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) - )) - try await store.saveMessage(message) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 - )) - } - - let untouchedMessageID = UUID() - let untouchedMessage = MessageDTO(from: Message( - id: untouchedMessageID, - radioID: device.id, - contactID: nil, - channelIndex: untouchedChannel, - text: "Ch1 keep", - timestamp: UInt32(Date().timeIntervalSince1970) + 100 - )) - try await store.saveMessage(untouchedMessage) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: untouchedMessageID, radioID: device.id, attemptCount: 0, sequence: 99 - )) - - try await store.deleteMessagesForChannel(radioID: device.id, channelIndex: targetChannel) - - let remaining = try await store.fetchPendingSends(radioID: device.id) - #expect(remaining.count == 1, - "only the untouched channel's PendingSend should survive") - #expect(remaining.first?.messageID == untouchedMessageID, - "surviving PendingSend must belong to the untouched channel") - - let untouchedChannelMessages = try await store.fetchMessages( - radioID: device.id, channelIndex: untouchedChannel - ) - #expect(untouchedChannelMessages.count == 1, - "unrelated channel's Message row must be preserved") - } - - @Test("deleteChannelMessages(fromSender:) cascades PendingSends and spares other senders") - func testDeleteChannelMessagesFromSender_CascadesPendingSends() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let channelIndex: UInt8 = 0 - let targetSender = "Spammer" - let untouchedSender = "Friend" - - for i in 0..<3 { - let messageID = UUID() - let message = MessageDTO(from: Message( - id: messageID, - radioID: device.id, - contactID: nil, - channelIndex: channelIndex, - text: "spam \(i)", - timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i), - senderNodeName: targetSender - )) - try await store.saveMessage(message) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 - )) - } - - let untouchedMessageID = UUID() - let untouchedMessage = MessageDTO(from: Message( - id: untouchedMessageID, - radioID: device.id, - contactID: nil, - channelIndex: channelIndex, - text: "friend", - timestamp: UInt32(Date().timeIntervalSince1970) + 100, - senderNodeName: untouchedSender - )) - try await store.saveMessage(untouchedMessage) - try await store.upsertPendingSend(makePendingSendDTO( - messageID: untouchedMessageID, radioID: device.id, attemptCount: 0, sequence: 99 - )) - - try await store.deleteChannelMessages(fromSender: targetSender, radioID: device.id) - - let remaining = try await store.fetchPendingSends(radioID: device.id) - #expect(remaining.count == 1, - "only the other sender's PendingSend should survive") - #expect(remaining.first?.messageID == untouchedMessageID, - "surviving PendingSend must belong to the untouched sender") - } - - // MARK: - RxLogEntry Tests - - private func createTestRxLogEntryDTO( - radioID: UUID, - senderTimestamp: UInt32? = nil, - regionScope: String? = nil, - payloadTypeBits: UInt8 = 5, - transportCode: Data? = nil, - channelIndex: UInt8? = 1, - packetPayload: Data = Data([0xAB, 0xCD, 0xEF]) - ) -> RxLogEntryDTO { - // Create minimal ParsedRxLogData for the DTO - let parsed = ParsedRxLogData( - snr: 10.5, - rssi: -65, - rawPayload: Data([0x15, 0x01, 0x02, 0x03]), - routeType: .flood, - payloadType: .groupText, - payloadVersion: 0, - payloadTypeBits: payloadTypeBits, - transportCode: transportCode, - pathLength: 1, - pathNodes: [0x42], - packetPayload: packetPayload - ) - - return RxLogEntryDTO( - radioID: radioID, - from: parsed, - channelIndex: channelIndex, - channelName: "TestChannel", - decryptStatus: .success, - senderTimestamp: senderTimestamp, - regionScope: regionScope, - decodedText: "Hello mesh!" - ) - } - - @Test("RxLogEntryDTO(from:) falls back on out-of-range stored values instead of trapping") - func rxLogEntryDTOClampsOutOfRangeStoredValues() { - let model = RxLogEntry( - radioID: UUID(), - routeType: 999, - payloadType: -1, - payloadVersion: 5_000, - pathLength: 400, - pathNodes: Data(), - packetPayload: Data(), - rawPayload: Data(), - packetHash: "deadbeef", - channelIndex: 9_999, - senderTimestamp: -10 - ) - - let dto = RxLogEntryDTO(from: model) - - #expect(dto.routeType == .flood) - #expect(dto.payloadType == .unknown) - #expect(dto.payloadVersion == 0) - #expect(dto.pathLength == 0) - #expect(dto.channelIndex == nil) - #expect(dto.senderTimestamp == nil) - } - - @Test("Save and fetch RxLogEntry preserves senderTimestamp") - func saveAndFetchRxLogEntryPreservesSenderTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let expectedTimestamp: UInt32 = 1703123456 - let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: expectedTimestamp) - - try await store.saveRxLogEntry(dto) - - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.count == 1) - #expect(entries.first?.senderTimestamp == expectedTimestamp) - } - - @Test("Save and fetch RxLogEntry with nil senderTimestamp") - func saveAndFetchRxLogEntryWithNilTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: nil) - - try await store.saveRxLogEntry(dto) - - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.count == 1) - #expect(entries.first?.senderTimestamp == nil) - } - - @Test("RxLogEntryDTO init from model preserves senderTimestamp") - func rxLogEntryDTOInitFromModelPreservesTimestamp() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Save with timestamp - let expectedTimestamp: UInt32 = 1703123456 - let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: expectedTimestamp) - try await store.saveRxLogEntry(dto) - - // Fetch back (this uses RxLogEntryDTO.init(from: RxLogEntry)) - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.first?.senderTimestamp == expectedTimestamp) - - // Verify the conversion handles the Int -> UInt32 correctly - // The model stores Int, DTO uses UInt32 - #expect(entries.first?.senderTimestamp == 1703123456) - } - - @Test("RX log prune is deferred until threshold is exceeded") - func rxLogPruneDefersUntilThresholdExceeded() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - for index in 0..<1_100 { - let dto = createTestRxLogEntryDTO( - radioID: device.id, - senderTimestamp: UInt32(index) - ) - try await store.saveRxLogEntry(dto) - try await store.pruneRxLogEntries(radioID: device.id) - } - - let entriesBeforeThreshold = try await store.fetchRxLogEntries(radioID: device.id, limit: 1_200) - #expect(entriesBeforeThreshold.count == 1_100) - - let thresholdEntry = createTestRxLogEntryDTO( - radioID: device.id, - senderTimestamp: UInt32(1_100) - ) - try await store.saveRxLogEntry(thresholdEntry) - try await store.pruneRxLogEntries(radioID: device.id) - - let entriesAfterThreshold = try await store.fetchRxLogEntries(radioID: device.id, limit: 1_200) - #expect(entriesAfterThreshold.count == 1_000) - #expect(entriesAfterThreshold.first?.senderTimestamp == 1_100) - #expect(entriesAfterThreshold.last?.senderTimestamp == 101) - } - - @Test("Clearing RX log resets cached count for future pruning") - func clearRxLogResetsCachedCount() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - for index in 0..<1_101 { - let dto = createTestRxLogEntryDTO( - radioID: device.id, - senderTimestamp: UInt32(index) - ) - try await store.saveRxLogEntry(dto) - } - try await store.pruneRxLogEntries(radioID: device.id) - try await store.clearRxLogEntries(radioID: device.id) - - let replacement = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: 42) - try await store.saveRxLogEntry(replacement) - try await store.pruneRxLogEntries(radioID: device.id) - - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.count == 1) - #expect(entries.first?.senderTimestamp == 42) - } - - @Test("A store rebuilt over a populated container seeds its prune cache from disk") - func rxLogPruneSeedsCacheFromDiskOnRebuiltStore() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let radioID = UUID() - - let storeA = PersistenceStore(modelContainer: container) - try await storeA.saveDevice(createTestDevice().copy { $0.id = radioID; $0.radioID = radioID }) - - // Fill to the retention cap (keepCount + pruneThreshold) without exceeding it. - for index in 0..<1_100 { - try await storeA.saveRxLogEntry( - createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: UInt32(index)) - ) - try await storeA.pruneRxLogEntries(radioID: radioID) - } - let beforeReconnect = try await storeA.fetchRxLogEntries(radioID: radioID, limit: 1_200) - #expect(beforeReconnect.count == 1_100) - - // Reconnect: a new store over the same container starts with a cold cache. - // Writing one full prune cycle past the cap drives the count back to keepCount - // only if storeB seeded from disk; a cold-from-zero cache never trips the gate. - let storeB = PersistenceStore(modelContainer: container) - for index in 1_100..<1_202 { - try await storeB.saveRxLogEntry( - createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: UInt32(index)) - ) - try await storeB.pruneRxLogEntries(radioID: radioID) - } - - // Pruning only fires if storeB seeded its count from disk rather than from zero. - let afterReconnect = try await storeB.fetchRxLogEntries(radioID: radioID, limit: 1_300) - #expect(afterReconnect.count == 1_000) - } - - // MARK: - Region Scope Tests - - @Test("saveRxLogEntry forwards regionScope to the persisted model") - func saveRxLogEntryForwardsRegionScope() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let dto = createTestRxLogEntryDTO( - radioID: device.id, - senderTimestamp: 1_703_000_000, - regionScope: "Germany" - ) - try await store.saveRxLogEntry(dto) - - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.first?.regionScope == "Germany") - } - - @Test("saveRxLogEntry preserves payloadTypeBits including unknown nibbles") - func saveRxLogEntryPreservesPayloadTypeBits() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let dto = createTestRxLogEntryDTO( - radioID: device.id, - senderTimestamp: 1_703_000_001, - payloadTypeBits: 0x0C - ) - try await store.saveRxLogEntry(dto) - - let entries = try await store.fetchRxLogEntries(radioID: device.id) - #expect(entries.first?.payloadTypeBits == 0x0C) - } - - @Test("batchUpdateChannelMessageRegion back-fills normal-case message via timestamp fallback") - func batchUpdateChannelMessageRegionBackfillsNormalCase() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let wireTimestamp: UInt32 = 1_703_111_111 - // Normal case: senderTimestamp stays nil, wire timestamp lives on `timestamp`. - let dto = MessageDTO.testChannelMessage( - radioID: device.id, - channelIndex: 0, - timestamp: wireTimestamp, - direction: .incoming, - status: .delivered - ) - try await store.saveMessage(dto) - - try await store.batchUpdateChannelMessageRegion( - radioID: device.id, - updates: [(channelIndex: 0, senderTimestamp: wireTimestamp, regionScope: "Germany")] - ) - - let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 0) - #expect(saved.first?.regionScope == "Germany") - } - - @Test("batchUpdateChannelMessageRegion back-fills timestamp-corrected message") - func batchUpdateChannelMessageRegionBackfillsCorrectedCase() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let originalWire: UInt32 = 1_703_222_222 - var dto = MessageDTO.testChannelMessage( - radioID: device.id, - channelIndex: 1, - timestamp: UInt32(Date().timeIntervalSince1970), - direction: .incoming, - status: .delivered - ) - dto.senderTimestamp = originalWire - try await store.saveMessage(dto) - - try await store.batchUpdateChannelMessageRegion( - radioID: device.id, - updates: [(channelIndex: 1, senderTimestamp: originalWire, regionScope: "USA")] - ) - - let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 1) - #expect(saved.first?.regionScope == "USA") - } - - @Test("batchUpdateChannelMessageRegion skips outgoing messages") - func batchUpdateChannelMessageRegionSkipsOutgoing() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let wireTimestamp: UInt32 = 1_703_333_333 - let dto = MessageDTO.testChannelMessage( - radioID: device.id, - channelIndex: 2, - timestamp: wireTimestamp, - direction: .outgoing, - status: .sent - ) - try await store.saveMessage(dto) - - try await store.batchUpdateChannelMessageRegion( - radioID: device.id, - updates: [(channelIndex: 2, senderTimestamp: wireTimestamp, regionScope: "France")] - ) - - let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 2) - #expect(saved.first?.regionScope == nil) - } - - @Test("batchUpdateDMMessageRegion back-fills DM by sender prefix byte") - func batchUpdateDMMessageRegionBackfillsByPrefix() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let wireTimestamp: UInt32 = 1_703_444_444 - let senderKey = Data([0xAB, 0xCD, 0xEF, 0x01, 0x02, 0x03]) - let contactID = UUID() - let dto = MessageDTO.testDirectMessage( - radioID: device.id, - contactID: contactID, - timestamp: wireTimestamp, - direction: .incoming, - status: .delivered, - senderKeyPrefix: senderKey - ) - try await store.saveMessage(dto) - - try await store.batchUpdateDMMessageRegion( - radioID: device.id, - updates: [(senderPrefixByte: 0xAB, senderTimestamp: wireTimestamp, regionScope: "Germany")] - ) - - let saved = try await store.fetchMessages(contactID: contactID) - #expect(saved.first?.regionScope == "Germany") - } - - @Test("batchUpdateDMMessageRegion ignores DMs from other senders at same timestamp") - func batchUpdateDMMessageRegionIgnoresOtherSenders() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let wireTimestamp: UInt32 = 1_703_555_555 - let aliceKey = Data([0xAA, 0x11, 0x22, 0x33, 0x44, 0x55]) - let bobKey = Data([0xBB, 0x66, 0x77, 0x88, 0x99, 0x00]) - let aliceContact = UUID() - let bobContact = UUID() - - let alice = MessageDTO.testDirectMessage( - radioID: device.id, - contactID: aliceContact, - text: "From Alice", - timestamp: wireTimestamp, - direction: .incoming, - status: .delivered, - senderKeyPrefix: aliceKey - ) - let bob = MessageDTO.testDirectMessage( - radioID: device.id, - contactID: bobContact, - text: "From Bob", - timestamp: wireTimestamp, - direction: .incoming, - status: .delivered, - senderKeyPrefix: bobKey - ) - try await store.saveMessage(alice) - try await store.saveMessage(bob) - - try await store.batchUpdateDMMessageRegion( - radioID: device.id, - updates: [(senderPrefixByte: 0xAA, senderTimestamp: wireTimestamp, regionScope: "Germany")] - ) - - let aliceSaved = try await store.fetchMessages(contactID: aliceContact) - let bobSaved = try await store.fetchMessages(contactID: bobContact) - #expect(aliceSaved.first?.regionScope == "Germany") - #expect(bobSaved.first?.regionScope == nil) - } - - // MARK: - Mute Tests - - @Test("Set contact muted") - func setContactMuted() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - let frame = createTestContactFrame(name: "Alice") - let contactID = try await store.saveContact(radioID: device.id, from: frame) - - // Initially not muted - var contact = try await store.fetchContact(id: contactID) - #expect(contact?.isMuted == false) - - // Mute - try await store.setContactMuted(contactID, isMuted: true) - contact = try await store.fetchContact(id: contactID) - #expect(contact?.isMuted == true) - - // Unmute - try await store.setContactMuted(contactID, isMuted: false) - contact = try await store.fetchContact(id: contactID) - #expect(contact?.isMuted == false) - } - - @Test("Muted contacts excluded from badge count") - func mutedContactsExcludedFromBadgeCount() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Create contact with unreads - let frame1 = createTestContactFrame(name: "Alice") - let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) - try await store.incrementUnreadCount(contactID: contact1ID) - try await store.incrementUnreadCount(contactID: contact1ID) - - // Create muted contact with unreads - let frame2 = createTestContactFrame(name: "Bob") - let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) - try await store.incrementUnreadCount(contactID: contact2ID) - try await store.setContactMuted(contact2ID, isMuted: true) - - let (contacts, _, _) = try await store.getTotalUnreadCounts(radioID: device.id) - - // Only Alice's 2 unreads should count, Bob is muted - #expect(contacts == 2) - } - - @Test("Notification levels affect badge count correctly") - func notificationLevelsAffectBadgeCount() async throws { - let store = try await createTestStore() - let device = createTestDevice() - try await store.saveDevice(device) - - // Create channel with unreads - let channelInfo = ChannelInfo(index: 1, name: "Test", secret: Data(repeating: 0x42, count: 16)) - let channelID = try await store.saveChannel(radioID: device.id, from: channelInfo) - try await store.incrementChannelUnreadCount(channelID: channelID) - try await store.incrementChannelUnreadCount(channelID: channelID) - - // Default (all) - should count all unreads - var counts = try await store.getTotalUnreadCounts(radioID: device.id) - #expect(counts.channels == 2) - - // Muted - should exclude from badge - try await store.setChannelNotificationLevel(channelID, level: .muted) - counts = try await store.getTotalUnreadCounts(radioID: device.id) - #expect(counts.channels == 0) - - // Mentions only with no mentions - should show 0 - try await store.setChannelNotificationLevel(channelID, level: .mentionsOnly) - counts = try await store.getTotalUnreadCounts(radioID: device.id) - #expect(counts.channels == 0) - - // Mentions only with mentions - should show mention count - try await store.incrementChannelUnreadMentionCount(channelID: channelID) - counts = try await store.getTotalUnreadCounts(radioID: device.id) - #expect(counts.channels == 1) - } - - // MARK: - Ghost Identity Reconciliation Tests - - @Test("reconcileGhostIdentity rewrites current device when ghost matches publicKey") - func reconcileGhostIdentityHappyPath() async throws { - let store = try await createTestStore() - - let oldPublicKey = Data((0.. .pending is not a constraint violation because the row can - // only ever reach .delivered by going forward through .sent again. - let remapped = try await store.updateMessageStatusUnlessDelivered(id: messageID, status: .pending) - #expect(remapped == true) - #expect(try await store.fetchMessage(id: messageID)?.status == .pending) - - // A subsequent successful redelivery still reaches .delivered. - try await store.updateMessageAck(id: messageID, ackCode: 0x12345678, status: .delivered) - #expect(try await store.fetchMessage(id: messageID)?.status == .delivered) - } - - @Test("hasOutgoingSentDM flags a stuck .sent DM by ackCode and ignores other rows") - func hasOutgoingSentDMDetectsOrphan() async throws { - let store = try await createTestStore() - let ackCode: UInt32 = 0xCAFEF00D - - let sentID = UUID() - try await store.saveMessage( - MessageDTO.testDirectMessage(id: sentID, status: .sent, ackCode: ackCode) - ) - #expect(try await store.hasOutgoingSentDM(ackCode: ackCode) == true) - - // A different ackCode must not match. - #expect(try await store.hasOutgoingSentDM(ackCode: 0x00000001) == false) - - // A delivered row with the same ackCode is not an orphan. - let deliveredID = UUID() - try await store.saveMessage( - MessageDTO.testDirectMessage(id: deliveredID, status: .delivered, ackCode: 0xBADC0DE5) - ) - #expect(try await store.hasOutgoingSentDM(ackCode: 0xBADC0DE5) == false) - } - - // MARK: - Inbound Advert Hop Count - - @Test("setInboundHopCount round-trips onto an existing discovered node") - func setInboundHopCountRoundTrips() async throws { - let store = try await createTestStore() - let radioID = UUID() - let frame = createTestContactFrame(name: "Advertiser") - let (node, _) = try await store.upsertDiscoveredNode(radioID: radioID, from: frame) - - try await store.setInboundHopCount(radioID: radioID, publicKey: node.publicKey, hopCount: 4, advertTimestamp: 100) - - let fetched = try await store.fetchDiscoveredNodes(radioID: radioID) - #expect(fetched.count == 1) - #expect(fetched.first?.inboundHopCount == 4) - #expect(fetched.first?.inboundHopAdvertTimestamp == 100) - } - - @Test("setInboundHopCount is a no-op when no matching row exists") - func setInboundHopCountNoRowNoOp() async throws { - let store = try await createTestStore() - let radioID = UUID() - let unknownKey = Data((0.. PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func createTestDevice(id: UUID = UUID()) -> DeviceDTO { + DeviceDTO.testDevice( + id: id, + publicKey: Data((0.. ContactFrame { + ContactFrame( + publicKey: Data((0.. ( + contactID: UUID, messageID: UUID, channelID: UUID, sessionID: UUID + ) { + let contactFrame = createTestContactFrame(name: "TestContact") + let contactID = try await store.saveContact(radioID: radioID, from: contactFrame) + + let message = MessageDTO(from: Message( + radioID: radioID, + contactID: contactID, + text: "Hello!", + timestamp: UInt32(Date().timeIntervalSince1970) + )) + try await store.saveMessage(message) + try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) + + let channelInfo = ChannelInfo(index: 1, name: "Private", secret: Data(repeating: 0x42, count: 16)) + let channelID = try await store.saveChannel(radioID: radioID, from: channelInfo) + + let reaction = ReactionDTO( + messageID: message.id, + emoji: "👍", + senderName: "Reactor", + messageHash: "AABBCCDD", + rawText: "👍", + radioID: radioID + ) + try await store.saveReaction(reaction) + + let session = createTestRoomSession(radioID: radioID) + try await store.saveRemoteNodeSessionDTO(session) + + let roomMessage = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), + authorName: "Author", + text: "Room message", + timestamp: UInt32(Date().timeIntervalSince1970) + ) + try await store.saveRoomMessage(roomMessage) + + let blocked = BlockedChannelSenderDTO(name: "Spammer", radioID: radioID) + try await store.saveBlockedChannelSender(blocked) + + let rxLog = createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: 12345) + try await store.saveRxLogEntry(rxLog) + + let discoveredFrame = createTestContactFrame(name: "Discovered") + _ = try await store.upsertDiscoveredNode(radioID: radioID, from: discoveredFrame) + + return (contactID, message.id, channelID, session.id) + } + + /// Asserts all entity types for a device are present. + private func assertAllDataExists( + store: PersistenceStore, radioID: UUID, sessionID: UUID, messageID: UUID + ) async throws { + let contacts = try await store.fetchContacts(radioID: radioID) + #expect(contacts.count == 1, "Expected 1 contact") + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.count == 1, "Expected 1 channel") + let reactions = try await store.fetchReactions(for: messageID) + #expect(reactions.count == 1, "Expected 1 reaction") + let sessions = try await store.fetchRemoteNodeSessions(radioID: radioID) + #expect(sessions.count == 1, "Expected 1 session") + let roomMessages = try await store.fetchRoomMessages(sessionID: sessionID) + #expect(roomMessages.count == 1, "Expected 1 room message") + let blockedSenders = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(blockedSenders.count == 1, "Expected 1 blocked sender") + let rxEntries = try await store.fetchRxLogEntries(radioID: radioID) + #expect(rxEntries.count == 1, "Expected 1 RX log entry") + let discoveredNodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(discoveredNodes.count == 1, "Expected 1 discovered node") + } + + /// Asserts all entity types for a device have been deleted. + private func assertAllDataDeleted( + store: PersistenceStore, radioID: UUID, sessionID: UUID, messageID: UUID + ) async throws { + let contacts = try await store.fetchContacts(radioID: radioID) + #expect(contacts.isEmpty, "Expected no contacts") + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.isEmpty, "Expected no channels") + let reactions = try await store.fetchReactions(for: messageID) + #expect(reactions.isEmpty, "Expected no reactions") + let sessions = try await store.fetchRemoteNodeSessions(radioID: radioID) + #expect(sessions.isEmpty, "Expected no sessions") + let roomMessages = try await store.fetchRoomMessages(sessionID: sessionID) + #expect(roomMessages.isEmpty, "Expected no room messages") + let blockedSenders = try await store.fetchBlockedChannelSenders(radioID: radioID) + #expect(blockedSenders.isEmpty, "Expected no blocked senders") + let rxEntries = try await store.fetchRxLogEntries(radioID: radioID) + #expect(rxEntries.isEmpty, "Expected no RX log entries") + let discoveredNodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(discoveredNodes.isEmpty, "Expected no discovered nodes") + let repeats = try await store.fetchMessageRepeats(messageID: messageID) + #expect(repeats.isEmpty, "Expected no message repeats") + } + + @Test + func `deleteDevice removes only device record, preserves all associated data`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let ids = try await seedAllEntityTypes(store: store, radioID: device.id) + + try await store.deleteDevice(id: device.id) + + let fetchedDevice = try await store.fetchDevice(id: device.id) + #expect(fetchedDevice == nil, "Device record should be deleted") + + try await assertAllDataExists( + store: store, radioID: device.id, + sessionID: ids.sessionID, messageID: ids.messageID + ) + } + + @Test + func `deleteDeviceData removes all associated data but not device record`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let ids = try await seedAllEntityTypes(store: store, radioID: device.id) + + try await store.deleteDeviceData(id: device.id) + + let fetchedDevice = try await store.fetchDevice(id: device.id) + #expect(fetchedDevice != nil, "Device record should be preserved") + + try await assertAllDataDeleted( + store: store, radioID: device.id, + sessionID: ids.sessionID, messageID: ids.messageID + ) + } + + @Test + func `deleteDeviceAndData removes device and all data atomically`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let ids = try await seedAllEntityTypes(store: store, radioID: device.id) + + try await store.deleteDeviceAndData(id: device.id) + + let fetchedDevice = try await store.fetchDevice(id: device.id) + #expect(fetchedDevice == nil, "Device record should be deleted") + + try await assertAllDataDeleted( + store: store, radioID: device.id, + sessionID: ids.sessionID, messageID: ids.messageID + ) + } + + @Test + func `deleteDeviceAndData does not trap when a message has heard repeats (cascade-vs-batch)`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let contactID = try await store.saveContact(radioID: device.id, from: createTestContactFrame()) + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "broadcast", + timestamp: UInt32(Date().timeIntervalSince1970), + directionRawValue: MessageDirection.outgoing.rawValue + )) + try await store.saveMessage(message) + // Two heard repeats wire up message.repeats so the delete exercises cascade + // propagation over a relationship whose child rows were batch-deleted first. + try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) + try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) + + try await store.deleteDeviceAndData(id: device.id) + + #expect(try await store.fetchDevice(id: device.id) == nil) + #expect(try await store.fetchMessage(id: message.id) == nil) + #expect(try await store.fetchMessageRepeats(messageID: message.id).isEmpty) + } + + @Test + func `deleteDeviceData reaps a message and its heard repeats while preserving the device`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let contactID = try await store.saveContact(radioID: device.id, from: createTestContactFrame()) + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "broadcast", + timestamp: UInt32(Date().timeIntervalSince1970), + directionRawValue: MessageDirection.outgoing.rawValue + )) + try await store.saveMessage(message) + try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) + try await store.saveMessageRepeat(.testRepeat(messageID: message.id)) + + try await store.deleteDeviceData(id: device.id) + + #expect(try await store.fetchDevice(id: device.id) != nil) + #expect(try await store.fetchMessage(id: message.id) == nil) + #expect(try await store.fetchMessageRepeats(messageID: message.id).isEmpty) + } + + @Test + func `deleteDeviceData for non-existent device does not throw`() async throws { + let store = try await createTestStore() + try await store.deleteDeviceData(id: UUID()) + } + + @Test + func `deleteDeviceAndData for non-existent device does not throw`() async throws { + let store = try await createTestStore() + try await store.deleteDeviceAndData(id: UUID()) + } + + @Test + func `Re-pair after device deletion re-associates orphaned data`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let contactFrame = createTestContactFrame(name: "Survivor") + _ = try await store.saveContact(radioID: device.id, from: contactFrame) + + let channelInfo = ChannelInfo(index: 0, name: "General", secret: Data(repeating: 0, count: 16)) + _ = try await store.saveChannel(radioID: device.id, from: channelInfo) + + // Simulate ASK removal: delete device record only + try await store.deleteDevice(id: device.id) + + // Simulate re-pair: saveDevice upserts with same ID + try await store.saveDevice(device) + + let contacts = try await store.fetchContacts(radioID: device.id) + #expect(contacts.count == 1) + #expect(contacts.first?.name == "Survivor") + + let channels = try await store.fetchChannels(radioID: device.id) + #expect(channels.count == 1) + #expect(channels.first?.name == "General") + } + + @Test + func `Demote device to ghost preserves publicKey and radioID with fresh id`() async throws { + let store = try await createTestStore() + let original = createTestDevice().copy { + $0.isActive = true + } + try await store.saveDevice(original) + + try await store.demoteDeviceToGhost(id: original.id) + + let originalLookup = try await store.fetchDevice(id: original.id) + #expect(originalLookup == nil, "Original BLE id should no longer resolve") + + let ghost = try await store.fetchDevice(publicKey: original.publicKey) + #expect(ghost != nil) + #expect(ghost?.id != original.id, "Ghost must have a fresh id") + #expect(ghost?.publicKey == original.publicKey) + #expect(ghost?.radioID == original.radioID) + #expect(ghost?.isActive == false) + } + + @Test + func `Demote device strips all connection methods so it stays hidden`() async throws { + let store = try await createTestStore() + let wifi = ConnectionMethod.wifi(host: "10.0.0.5", port: 5000, displayName: nil) + let bluetooth = ConnectionMethod.bluetooth(peripheralUUID: UUID(), displayName: nil) + let original = createTestDevice().copy { + $0.connectionMethods = [wifi, bluetooth] + } + try await store.saveDevice(original) + + try await store.demoteDeviceToGhost(id: original.id) + + let ghost = try await store.fetchDevice(publicKey: original.publicKey) + #expect(ghost?.connectionMethods.isEmpty == true, + "Demoted ghost must have no connection methods so DeviceSelectionFilter hides it") + } + + @Test + func `Demote device with unknown id is a no-op`() async throws { + let store = try await createTestStore() + try await store.demoteDeviceToGhost(id: UUID()) + let devices = try await store.fetchDevices() + #expect(devices.isEmpty) + } + + @Test + func `Removing a paired device preserves child contacts via radioID`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let contactFrame = createTestContactFrame(name: "Alice") + _ = try await store.saveContact(radioID: device.radioID, from: contactFrame) + + try await store.demoteDeviceToGhost(id: device.id) + + let ghost = try await store.fetchDevice(publicKey: device.publicKey) + #expect(ghost?.radioID == device.radioID) + let contacts = try await store.fetchContacts(radioID: device.radioID) + #expect(contacts.count == 1) + #expect(contacts.first?.name == "Alice") + } + + // MARK: - Contact Tests + + @Test + func `Save and fetch contact from frame`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "Alice") + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + let contact = try await store.fetchContact(id: contactID) + #expect(contact != nil) + #expect(contact?.name == "Alice") + #expect(contact?.type == .chat) + } + + @Test + func `Fetch contact by public key`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "Bob") + _ = try await store.saveContact(radioID: device.id, from: frame) + + let contact = try await store.fetchContact(radioID: device.id, publicKey: frame.publicKey) + #expect(contact != nil) + #expect(contact?.name == "Bob") + } + + @Test + func `Update contact last message and unread count`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + let now = Date() + try await store.updateContactLastMessage(contactID: contactID, date: now) + try await store.incrementUnreadCount(contactID: contactID) + try await store.incrementUnreadCount(contactID: contactID) + + var contact = try await store.fetchContact(id: contactID) + #expect(contact?.unreadCount == 2) + #expect(contact?.lastMessageDate != nil) + + try await store.clearUnreadCount(contactID: contactID) + + contact = try await store.fetchContact(id: contactID) + #expect(contact?.unreadCount == 0) + } + + @Test + func `deleteMessagesForContact removes all messages for a contact`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Create first contact + let frame1 = createTestContactFrame(name: "Contact1") + let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) + + // Create multiple messages for this contact + for i in 0..<5 { + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contact1ID, + text: "Message \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + )) + try await store.saveMessage(message) + } + + // Create a second contact with a message (should not be deleted) + let frame2 = createTestContactFrame(name: "Contact2") + let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) + let otherMessage = MessageDTO(from: Message( + radioID: device.id, + contactID: contact2ID, + text: "Other message", + timestamp: UInt32(Date().timeIntervalSince1970) + 100 + )) + try await store.saveMessage(otherMessage) + + // Verify messages exist before deletion + var contact1Messages = try await store.fetchMessages(contactID: contact1ID) + #expect(contact1Messages.count == 5) + + var contact2Messages = try await store.fetchMessages(contactID: contact2ID) + #expect(contact2Messages.count == 1) + + // Delete messages for the first contact + try await store.deleteMessagesForContact(contactID: contact1ID) + + // Verify messages for deleted contact are gone + contact1Messages = try await store.fetchMessages(contactID: contact1ID) + #expect(contact1Messages.isEmpty) + + // Verify messages for other contact still exist + contact2Messages = try await store.fetchMessages(contactID: contact2ID) + #expect(contact2Messages.count == 1) + } + + @Test + func `recomputeContactLastMessageDate keeps a conversation visible while older messages remain and clears it only when empty`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "Conversation") + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + let base = Date(timeIntervalSince1970: 1_700_000_000) + let messages = (0..<3).map { i in + MessageDTO.testDirectMessage( + radioID: device.id, + contactID: contactID, + text: "Message \(i)", + timestamp: UInt32(base.timeIntervalSince1970) + UInt32(i), + createdAt: base.addingTimeInterval(Double(i)) + ) + } + for message in messages { + try await store.saveMessage(message) + } + + // Newest message sets the date; the conversation is visible. + var newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) + #expect(newDate == messages[2].date) + var conversations = try await store.fetchConversations(radioID: device.id) + #expect(conversations.contains { $0.id == contactID }) + + // Deleting the newest message falls back to the next remaining message, + // not nil: the conversation must stay visible while messages remain. + try await store.deleteMessage(id: messages[2].id) + newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) + #expect(newDate == messages[1].date) + conversations = try await store.fetchConversations(radioID: device.id) + #expect(conversations.contains { $0.id == contactID }) + + // Deleting the last remaining messages clears the date and removes the conversation. + try await store.deleteMessage(id: messages[1].id) + try await store.deleteMessage(id: messages[0].id) + newDate = try await store.recomputeContactLastMessageDate(contactID: contactID) + #expect(newDate == nil) + conversations = try await store.fetchConversations(radioID: device.id) + #expect(!conversations.contains { $0.id == contactID }) + } + + @Test + func `deleteMessagesForChannel removes all messages for a channel`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let channelIndex0: UInt8 = 0 + let channelIndex1: UInt8 = 1 + + // Create messages for channel 0 + for i in 0..<5 { + let message = MessageDTO(from: Message( + radioID: device.id, + channelIndex: channelIndex0, + text: "Channel 0 Message \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + )) + try await store.saveMessage(message) + } + + // Create messages for channel 1 (should not be deleted) + for i in 0..<3 { + let message = MessageDTO(from: Message( + radioID: device.id, + channelIndex: channelIndex1, + text: "Channel 1 Message \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i + 100) + )) + try await store.saveMessage(message) + } + + // Create a contact message (should not be deleted) + let frame = createTestContactFrame(name: "Contact1") + let contactID = try await store.saveContact(radioID: device.id, from: frame) + let contactMessage = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Contact message", + timestamp: UInt32(Date().timeIntervalSince1970) + 200 + )) + try await store.saveMessage(contactMessage) + + // Verify messages exist before deletion + var channel0Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex0) + #expect(channel0Messages.count == 5) + + var channel1Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex1) + #expect(channel1Messages.count == 3) + + var contactMessages = try await store.fetchMessages(contactID: contactID) + #expect(contactMessages.count == 1) + + // Delete messages for channel 0 + try await store.deleteMessagesForChannel(radioID: device.id, channelIndex: channelIndex0) + + // Verify channel 0 messages are gone + channel0Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex0) + #expect(channel0Messages.isEmpty) + + // Verify channel 1 messages still exist + channel1Messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex1) + #expect(channel1Messages.count == 3) + + // Verify contact messages still exist + contactMessages = try await store.fetchMessages(contactID: contactID) + #expect(contactMessages.count == 1) + } + + // MARK: - Message Tests + + @Test + func `Save and fetch messages for contact`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + // Save multiple messages + for i in 0..<5 { + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Message \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + )) + try await store.saveMessage(message) + } + + let messages = try await store.fetchMessages(contactID: contactID) + #expect(messages.count == 5) + // Messages should be in chronological order (oldest first) + #expect(messages.first?.text == "Message 0") + #expect(messages.last?.text == "Message 4") + } + + @Test + func `Interleaved backlog from multiple senders reassembles in sortDate (send) order`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + // Backlog drains in a scrambled receive order: rows arrive (createdAt) + // in the order 2, 0, 3, 1 but their send times (sortDate) are 0..<4. + // Display must reassemble by send order regardless of receive order. + let drainBase = Date(timeIntervalSince1970: 2_000_000) + let sendBase = Date(timeIntervalSince1970: 1_000_000) + let scrambledSendOrder = [2, 0, 3, 1] + for (receiveOffset, sendIndex) in scrambledSendOrder.enumerated() { + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Send \(sendIndex)", + timestamp: UInt32(sendBase.timeIntervalSince1970) + UInt32(sendIndex), + createdAt: drainBase.addingTimeInterval(TimeInterval(receiveOffset)), + sortDate: sendBase.addingTimeInterval(TimeInterval(sendIndex)) + )) + try await store.saveMessage(message) + } + + let messages = try await store.fetchMessages(contactID: contactID) + #expect(messages.map(\.text) == ["Send 0", "Send 1", "Send 2", "Send 3"]) + } + + @Test + func `Live message stays last even when an earlier-sortDate backlog row is inserted after it`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + // A live row: sortDate == createdAt == now. + let now = Date(timeIntervalSince1970: 3_000_000) + let liveMessage = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Live", + timestamp: UInt32(now.timeIntervalSince1970), + createdAt: now, + sortDate: now + )) + try await store.saveMessage(liveMessage) + + // A backlog row inserted afterwards (later createdAt) but with an + // earlier send time. Its skewed sender clock must not let it sort + // above the live row. + let laterCreatedAt = now.addingTimeInterval(60) + let earlierSendTime = now.addingTimeInterval(-3600) + let backlogMessage = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Backlog", + timestamp: UInt32(earlierSendTime.timeIntervalSince1970), + createdAt: laterCreatedAt, + sortDate: earlierSendTime + )) + try await store.saveMessage(backlogMessage) + + let messages = try await store.fetchMessages(contactID: contactID) + #expect(messages.map(\.text) == ["Backlog", "Live"]) + #expect(messages.last?.text == "Live") + } + + @Test + func `Equal sortDate and timestamp fall back to createdAt order (tertiary key)`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + // All three rows share an identical sortDate (primary key, e.g. un-backfilled + // rows that all defaulted to the same value) and an identical timestamp (secondary + // key), so only createdAt — the tertiary sort key — can break the tie. Varying + // timestamp too would let the secondary key drive the order and mask whether + // createdAt is honored. + let sharedSortDate = Date(timeIntervalSince1970: 4_000_000) + let sharedTimestamp = UInt32(sharedSortDate.timeIntervalSince1970) + let createdBase = Date(timeIntervalSince1970: 5_000_000) + for i in 0..<3 { + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Tie \(i)", + timestamp: sharedTimestamp, + createdAt: createdBase.addingTimeInterval(TimeInterval(i)), + sortDate: sharedSortDate + )) + try await store.saveMessage(message) + } + + let messages = try await store.fetchMessages(contactID: contactID) + #expect(messages.map(\.text) == ["Tie 0", "Tie 1", "Tie 2"]) + } + + @Test + func `Backlog block orders by send time within a shared drain anchor`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let channelIndex: UInt8 = 0 + // One drain anchor shared by the whole block (block-at-reconnect). + let anchor = Date(timeIntervalSince1970: 1_700_000_000) + + /// Three different senders so reorderSameSenderClusters leaves them alone, + /// isolating the fetch's sort keys. createdAt (drain order) runs opposite to + /// send time, proving the secondary sort key is timestamp, not createdAt. + func backlogMessage(sender: String, sendTime: UInt32, drainOffset: TimeInterval) -> MessageDTO { + MessageDTO(from: Message( + radioID: device.id, + channelIndex: channelIndex, + text: sender, + timestamp: sendTime, + createdAt: anchor.addingTimeInterval(drainOffset), + sortDate: anchor, + directionRawValue: MessageDirection.incoming.rawValue, + senderNodeName: sender + )) + } + // Drained Carol, Bob, Alice (createdAt 0,1,2) but sent Alice < Bob < Carol. + try await store.saveMessage(backlogMessage(sender: "Carol", sendTime: 300, drainOffset: 0)) + try await store.saveMessage(backlogMessage(sender: "Bob", sendTime: 200, drainOffset: 1)) + try await store.saveMessage(backlogMessage(sender: "Alice", sendTime: 100, drainOffset: 2)) + + let messages = try await store.fetchMessages(radioID: device.id, channelIndex: channelIndex) + + #expect(messages.map(\.text) == ["Alice", "Bob", "Carol"]) + } + + @Test + func `Find channel message for reaction within timestamp window`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let channelIndex: UInt8 = 1 + let baseTimestamp: UInt32 = 1_700_000_000 + var targetMessage: MessageDTO? + + for i in 0..<120 { + let timestamp = baseTimestamp + UInt32(i) + let message = MessageDTO( + id: UUID(), + radioID: device.id, + contactID: nil, + channelIndex: channelIndex, + text: "Message \(i)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: "RemoteNode", + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + try await store.saveMessage(message) + if i == 80 { + targetMessage = message + } + } + + let message = try #require(targetMessage) + let reactionService = ReactionService() + let reactionText = reactionService.buildReactionText( + emoji: "👍", + targetSender: "RemoteNode", + targetText: message.text, + targetTimestamp: message.timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + let now = message.timestamp + let windowStart = now > 300 ? now - 300 : 0 + let windowEnd = now + 300 + + let found = try await store.findChannelMessageForReaction( + radioID: device.id, + channelIndex: channelIndex, + parsedReaction: parsed, + localNodeName: "LocalNode", + timestampWindow: windowStart...windowEnd, + limit: 200 + ) + + #expect(found?.id == message.id) + } + + @Test + func `Find outgoing channel message for reaction using local node name`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let channelIndex: UInt8 = 2 + let timestamp: UInt32 = 1_700_000_200 + + let outgoingMessage = MessageDTO( + id: UUID(), + radioID: device.id, + contactID: nil, + channelIndex: channelIndex, + text: "Local message", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: .outgoing, + status: .sent, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + try await store.saveMessage(outgoingMessage) + + let reactionService = ReactionService() + let reactionText = reactionService.buildReactionText( + emoji: "🔥", + targetSender: "LocalNode", + targetText: outgoingMessage.text, + targetTimestamp: outgoingMessage.timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + let now = outgoingMessage.timestamp + let windowStart = now > 300 ? now - 300 : 0 + let windowEnd = now + 300 + + let found = try await store.findChannelMessageForReaction( + radioID: device.id, + channelIndex: channelIndex, + parsedReaction: parsed, + localNodeName: "LocalNode", + timestampWindow: windowStart...windowEnd, + limit: 200 + ) + + #expect(found?.id == outgoingMessage.id) + } + + @Test + func `Update message status`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame() + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "Test", + statusRawValue: MessageStatus.pending.rawValue + )) + try await store.saveMessage(message) + + // Update status to sending + try await store.updateMessageStatus(id: message.id, status: .sending) + var fetched = try await store.fetchMessage(id: message.id) + #expect(fetched?.status == .sending) + + // Update status to sent + try await store.updateMessageStatus(id: message.id, status: .sent) + fetched = try await store.fetchMessage(id: message.id) + #expect(fetched?.status == .sent) + } + + // MARK: - Channel Tests + + @Test + func `Save and fetch channels`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Add public channel + let publicChannel = ChannelInfo(index: 0, name: "Public", secret: Data(repeating: 0, count: 16)) + _ = try await store.saveChannel(radioID: device.id, from: publicChannel) + + // Add private channel + let privateChannel = ChannelInfo(index: 1, name: "Private", secret: Data(repeating: 0x42, count: 16)) + _ = try await store.saveChannel(radioID: device.id, from: privateChannel) + + let channels = try await store.fetchChannels(radioID: device.id) + #expect(channels.count == 2) + #expect(channels[0].index == 0) + #expect(channels[0].name == "Public") + #expect(channels[1].index == 1) + #expect(channels[1].name == "Private") + } + + // MARK: - RemoteNodeSession Tests + + private func createTestRoomSession(radioID: UUID) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0..= firstDate!) + } + + @Test + func `Update room activity ignores older sync timestamps`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + // Set initial timestamp + try await store.updateRoomActivity(session.id, syncTimestamp: 5000) + + var fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.lastSyncTimestamp == 5000) + + // Try to update with older timestamp - sync timestamp should be ignored + try await store.updateRoomActivity(session.id, syncTimestamp: 3000) + + fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.lastSyncTimestamp == 5000) + // But lastMessageDate should still be updated + #expect(fetched?.lastMessageDate != nil) + } + + @Test + func `Update room activity without sync timestamp does not change lastSyncTimestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + // Set initial sync timestamp + try await store.updateRoomActivity(session.id, syncTimestamp: 5000) + + // Call without sync timestamp (send path) + try await store.updateRoomActivity(session.id) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.lastSyncTimestamp == 5000) + #expect(fetched?.lastMessageDate != nil) + } + + @Test + func `Mark room session connected changes isConnected and returns true`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Create a disconnected session with admin permission + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: false, + permissionLevel: .admin, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + let result = try await store.markRoomSessionConnected(session.id) + #expect(result == true) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.isConnected == true) + // Permission level must not be changed + #expect(fetched?.permissionLevel == .admin) + } + + @Test + func `Mark room session connected returns false when already connected`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: true, + permissionLevel: .guest, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + let result = try await store.markRoomSessionConnected(session.id) + #expect(result == false) + } + + @Test + func `Mark session disconnected preserves permission level`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: true, + permissionLevel: .admin, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + try await store.markSessionDisconnected(session.id) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.isConnected == false) + #expect(fetched?.permissionLevel == .admin) + } + + @Test + func `Mark session disconnected is no-op when already disconnected`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: false, + permissionLevel: .admin, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + try await store.markSessionDisconnected(session.id) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.isConnected == false) + #expect(fetched?.permissionLevel == .admin) + } + + @Test + func `Disconnect then recover preserves permission level`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: true, + permissionLevel: .admin, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + try await store.markSessionDisconnected(session.id) + _ = try await store.markRoomSessionConnected(session.id) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.isConnected == true) + #expect(fetched?.permissionLevel == .admin) + } + + @Test + func `Update remote node session connection can reset permission to guest`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + var session = createTestRoomSession(radioID: device.id) + session = RemoteNodeSessionDTO( + id: session.id, + radioID: session.radioID, + publicKey: session.publicKey, + name: session.name, + role: session.role, + isConnected: true, + permissionLevel: .admin, + lastSyncTimestamp: session.lastSyncTimestamp + ) + try await store.saveRemoteNodeSessionDTO(session) + + try await store.updateRemoteNodeSessionConnection( + id: session.id, + isConnected: false, + permissionLevel: .guest + ) + + let fetched = try await store.fetchRemoteNodeSession(id: session.id) + #expect(fetched?.isConnected == false) + #expect(fetched?.permissionLevel == .guest) + } + + // MARK: - RoomMessage Tests + + @Test + func `Save and fetch room messages`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + // Save room messages + for i in 0..<3 { + let message = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), + authorName: "Author\(i)", + text: "Room message \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + ) + try await store.saveRoomMessage(message) + } + + let messages = try await store.fetchRoomMessages(sessionID: session.id) + #expect(messages.count == 3) + } + + @Test + func `Room messages tied on timestamp order deterministically by createdAt`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + // Same wire timestamp (1-second resolution) but distinct arrival times. Inserted + // out of arrival order so the fetch must impose the createdAt tie-break itself. + let sharedTimestamp = UInt32(Date().timeIntervalSince1970) + let base = Date(timeIntervalSince1970: 1_700_000_000) + let arrivals: [(text: String, createdAt: Date)] = [ + ("second", base.addingTimeInterval(1)), + ("third", base.addingTimeInterval(2)), + ("first", base) + ] + for arrival in arrivals { + let message = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), + text: arrival.text, + timestamp: sharedTimestamp, + createdAt: arrival.createdAt + ) + try await store.saveRoomMessage(message) + } + + let messages = try await store.fetchRoomMessages(sessionID: session.id) + #expect(messages.map(\.text) == ["first", "second", "third"]) + } + + @Test + func `Room messages order primarily by wire timestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + // Distinct timestamps inserted out of order; arrival order is the inverse of + // send order to prove the timestamp key wins over createdAt. + let baseTimestamp = UInt32(Date().timeIntervalSince1970) + let arrival = Date(timeIntervalSince1970: 1_700_000_000) + let entries: [(text: String, offset: UInt32, arrivalOffset: TimeInterval)] = [ + ("newest", 2, 0), + ("oldest", 0, 2), + ("middle", 1, 1) + ] + for entry in entries { + let message = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), + text: entry.text, + timestamp: baseTimestamp + entry.offset, + createdAt: arrival.addingTimeInterval(entry.arrivalOffset) + ) + try await store.saveRoomMessage(message) + } + + let messages = try await store.fetchRoomMessages(sessionID: session.id) + #expect(messages.map(\.text) == ["oldest", "middle", "newest"]) + } + + @Test + func `Room message deduplication`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let session = createTestRoomSession(radioID: device.id) + try await store.saveRemoteNodeSessionDTO(session) + + let timestamp = UInt32(Date().timeIntervalSince1970) + let authorKeyPrefix = Data([0x01, 0x02, 0x03, 0x04]) + let text = "Duplicate message" + + // Save message + let message1 = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: authorKeyPrefix, + text: text, + timestamp: timestamp + ) + try await store.saveRoomMessage(message1) + + // Try to save duplicate (same timestamp, author, and content hash) + let message2 = RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: authorKeyPrefix, + text: text, + timestamp: timestamp + ) + try await store.saveRoomMessage(message2) + + // Should only have one message + let messages = try await store.fetchRoomMessages(sessionID: session.id) + #expect(messages.count == 1) + } + + // MARK: - Duplicate Session Cleanup Tests + + @Test + func `Cleanup duplicate remote node sessions keeps target and deletes others`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let sharedKey = Data((0.. PendingSendDTO { + PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: messageID, + kind: .dm, + contactID: UUID(), + channelIndex: nil, + isResend: false, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: sequence, + enqueuedAt: Date(), + attemptCount: attemptCount + ) + } + + @Test + func `incrementPendingSendAttemptCount from 0 bumps to 1`() async throws { + let store = try await createTestStore() + let messageID = UUID() + let dto = makePendingSendDTO(messageID: messageID, attemptCount: 0) + try await store.upsertPendingSend(dto) + + let result = try await store.incrementPendingSendAttemptCount(messageID: messageID) + #expect(result == 1, "first drain attempt should bump 0 → 1") + + let persisted = try await store.fetchPendingSends(radioID: dto.radioID).first + #expect(persisted?.attemptCount == 1, "persisted attemptCount should match return value") + } + + @Test + func `incrementPendingSendAttemptCount returns nil when no row matches`() async throws { + let store = try await createTestStore() + let messageID = UUID() + + let result = try await store.incrementPendingSendAttemptCount(messageID: messageID) + #expect(result == nil, "missing-row case is terminal — return nil instead of creating a new row") + } + + @Test + func `purgeLegacyAttemptCountRows deletes only legacy nil rows`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let legacyDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: nil, sequence: 1) + let raceDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: 0, sequence: 2) + let drainedDTO = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: 3, sequence: 3) + try await store.upsertPendingSend(legacyDTO) + try await store.upsertPendingSend(raceDTO) + try await store.upsertPendingSend(drainedDTO) + + let deleted = try await store.purgeLegacyAttemptCountRows() + #expect(deleted == 1, "only the single nil-valued row should be deleted") + + let rows = try await store.fetchPendingSends(radioID: radioID) + let messageIDs = Set(rows.map(\.messageID)) + #expect(!messageIDs.contains(legacyDTO.messageID), "legacy nil row must be deleted") + let byMessageID = Dictionary(uniqueKeysWithValues: rows.map { ($0.messageID, $0.attemptCount) }) + #expect(byMessageID[raceDTO.messageID] == 0, "race-window 0 row stays at 0") + #expect(byMessageID[drainedDTO.messageID] == 3, "already-drained row stays untouched") + } + + @Test + func `purgeLegacyAttemptCountRows is idempotent`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let dto = makePendingSendDTO(messageID: UUID(), radioID: radioID, attemptCount: nil) + try await store.upsertPendingSend(dto) + + let firstDeleted = try await store.purgeLegacyAttemptCountRows() + let secondDeleted = try await store.purgeLegacyAttemptCountRows() + #expect(firstDeleted == 1, "first call deletes the legacy nil row") + #expect(secondDeleted == 0, "second call: predicate matches nothing — idempotent on an empty nil set") + } + + @Test + func `warmUp runs both purgeOrphanPendingSends and purgeLegacyAttemptCountRows`() async throws { + let store = try await createTestStore() + let radioWithDevice = UUID() + let radioWithoutDevice = UUID() + + // Device for one of the two radios — the other's PendingSends are orphans. + let scopedDevice = DeviceDTO.testDevice(id: radioWithDevice, radioID: radioWithDevice) + try await store.saveDevice(scopedDevice) + + let nilCountOnDeviceRadio = makePendingSendDTO( + messageID: UUID(), radioID: radioWithDevice, attemptCount: nil, sequence: 1 + ) + let orphanOnUnknownRadio = makePendingSendDTO( + messageID: UUID(), radioID: radioWithoutDevice, attemptCount: nil, sequence: 1 + ) + try await store.upsertPendingSend(nilCountOnDeviceRadio) + try await store.upsertPendingSend(orphanOnUnknownRadio) + + try await store.warmUp() + + let survivingForDevice = try await store.fetchPendingSends(radioID: radioWithDevice) + let survivingForUnknown = try await store.fetchPendingSends(radioID: radioWithoutDevice) + #expect(survivingForDevice.isEmpty, + "warmUp must run both purges: nil-attemptCount rows deleted even when radio has a paired device") + #expect(survivingForUnknown.isEmpty, + "row attached to no-device radio must be purged by purgeOrphanPendingSends") + } + + @Test + func `deletePendingSendsForMessage public API saves on return`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let messageID = UUID() + try await store.upsertPendingSend(makePendingSendDTO( + messageID: messageID, radioID: radioID, attemptCount: 1, sequence: 1 + )) + + try await store.deletePendingSendsForMessage(messageID: messageID) + + // No explicit save from the test — visibility of the deletion to a + // subsequent fetch confirms the public method saved on return, + // matching the contract expected by ChatSendQueueService callers. + let hasPending = try await store.hasPendingSend(messageID: messageID) + #expect(hasPending == false, + "public deletePendingSendsForMessage must save before returning") + } + + // MARK: - PendingSend Cascade Tests + + @Test + func `deleteMessage cascades the matching PendingSend in a single transaction`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let messageID = UUID() + let message = MessageDTO(from: Message( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: 0, + text: "hello", + timestamp: UInt32(Date().timeIntervalSince1970) + )) + try await store.saveMessage(message) + + let pending = makePendingSendDTO(messageID: messageID, radioID: device.id, attemptCount: 0) + try await store.upsertPendingSend(pending) + + try await store.deleteMessage(id: messageID) + + let remainingPending = try await store.fetchPendingSends(radioID: device.id) + #expect(remainingPending.isEmpty, + "deleteMessage must cascade the PendingSend row keyed by the deleted messageID") + let remainingMessages = try await store.fetchAllMessages(radioID: device.id) + #expect(remainingMessages.isEmpty, + "deleteMessage must still remove the Message row") + } + + @Test + func `deleteMessage reaps an orphan PendingSend even when no Message row exists`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // An orphan PendingSend with no corresponding Message — can arise from + // a same-millisecond race between deleteMessage and upsertPendingSend. + let orphanMessageID = UUID() + let pending = makePendingSendDTO( + messageID: orphanMessageID, radioID: device.id, attemptCount: 0 + ) + try await store.upsertPendingSend(pending) + + try await store.deleteMessage(id: orphanMessageID) + + let remainingPending = try await store.fetchPendingSends(radioID: device.id) + #expect(remainingPending.isEmpty, + "deleteMessage must reap orphan PendingSends even without a matching Message row") + } + + @Test + func `deleteDeviceData reaps radio-scoped orphan PendingSends and preserves the Device row`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Message + matching PendingSend (messageID cascade reaches this row). + let matchedMessageID = UUID() + let matchedMessage = MessageDTO(from: Message( + id: matchedMessageID, + radioID: device.id, + contactID: nil, + channelIndex: 0, + text: "matched", + timestamp: UInt32(Date().timeIntervalSince1970) + )) + try await store.saveMessage(matchedMessage) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: matchedMessageID, radioID: device.id, attemptCount: 0, sequence: 1 + )) + + // PendingSend whose messageID does not correspond to any saved Message. + // The messageIDs-keyed cascade cannot see it; the radioID-keyed defensive + // delete must reap it. + try await store.upsertPendingSend(makePendingSendDTO( + messageID: UUID(), radioID: device.id, attemptCount: 0, sequence: 2 + )) + + try await store.deleteDeviceData(id: device.id) + + let surviving = try await store.fetchPendingSends(radioID: device.id) + #expect(surviving.isEmpty, + "deleteDeviceData must reap both Message-matched and orphan PendingSends for the radio") + + let fetchedDevice = try await store.fetchDevice(id: device.id) + #expect(fetchedDevice != nil, + "deleteDeviceData must preserve the Device row") + } + + @Test + func `deleteMessagesForContact cascades PendingSends and spares unrelated contacts`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame1 = createTestContactFrame(name: "Contact1") + let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) + let frame2 = createTestContactFrame(name: "Contact2") + let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) + + var contact1MessageIDs: [UUID] = [] + for i in 0..<3 { + let messageID = UUID() + contact1MessageIDs.append(messageID) + let message = MessageDTO(from: Message( + id: messageID, + radioID: device.id, + contactID: contact1ID, + text: "C1 \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + )) + try await store.saveMessage(message) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 + )) + } + + let contact2MessageID = UUID() + let contact2Message = MessageDTO(from: Message( + id: contact2MessageID, + radioID: device.id, + contactID: contact2ID, + text: "C2 keep", + timestamp: UInt32(Date().timeIntervalSince1970) + 100 + )) + try await store.saveMessage(contact2Message) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: contact2MessageID, radioID: device.id, attemptCount: 0, sequence: 99 + )) + + try await store.deleteMessagesForContact(contactID: contact1ID) + + let remaining = try await store.fetchPendingSends(radioID: device.id) + #expect(remaining.count == 1, + "only the unrelated contact's PendingSend should survive") + #expect(remaining.first?.messageID == contact2MessageID, + "surviving PendingSend must belong to the untouched contact") + + let contact2Messages = try await store.fetchMessages(contactID: contact2ID) + #expect(contact2Messages.count == 1, + "unrelated contact's Message row must be preserved") + } + + @Test + func `deleteMessagesForChannel cascades PendingSends and spares other channels`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let targetChannel: UInt8 = 0 + let untouchedChannel: UInt8 = 1 + + for i in 0..<3 { + let messageID = UUID() + let message = MessageDTO(from: Message( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: targetChannel, + text: "Ch0 \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i) + )) + try await store.saveMessage(message) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 + )) + } + + let untouchedMessageID = UUID() + let untouchedMessage = MessageDTO(from: Message( + id: untouchedMessageID, + radioID: device.id, + contactID: nil, + channelIndex: untouchedChannel, + text: "Ch1 keep", + timestamp: UInt32(Date().timeIntervalSince1970) + 100 + )) + try await store.saveMessage(untouchedMessage) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: untouchedMessageID, radioID: device.id, attemptCount: 0, sequence: 99 + )) + + try await store.deleteMessagesForChannel(radioID: device.id, channelIndex: targetChannel) + + let remaining = try await store.fetchPendingSends(radioID: device.id) + #expect(remaining.count == 1, + "only the untouched channel's PendingSend should survive") + #expect(remaining.first?.messageID == untouchedMessageID, + "surviving PendingSend must belong to the untouched channel") + + let untouchedChannelMessages = try await store.fetchMessages( + radioID: device.id, channelIndex: untouchedChannel + ) + #expect(untouchedChannelMessages.count == 1, + "unrelated channel's Message row must be preserved") + } + + @Test + func `deleteChannelMessages(fromSender:) cascades PendingSends and spares other senders`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let channelIndex: UInt8 = 0 + let targetSender = "Spammer" + let untouchedSender = "Friend" + + for i in 0..<3 { + let messageID = UUID() + let message = MessageDTO(from: Message( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: channelIndex, + text: "spam \(i)", + timestamp: UInt32(Date().timeIntervalSince1970) + UInt32(i), + senderNodeName: targetSender + )) + try await store.saveMessage(message) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: messageID, radioID: device.id, attemptCount: 0, sequence: i + 1 + )) + } + + let untouchedMessageID = UUID() + let untouchedMessage = MessageDTO(from: Message( + id: untouchedMessageID, + radioID: device.id, + contactID: nil, + channelIndex: channelIndex, + text: "friend", + timestamp: UInt32(Date().timeIntervalSince1970) + 100, + senderNodeName: untouchedSender + )) + try await store.saveMessage(untouchedMessage) + try await store.upsertPendingSend(makePendingSendDTO( + messageID: untouchedMessageID, radioID: device.id, attemptCount: 0, sequence: 99 + )) + + try await store.deleteChannelMessages(fromSender: targetSender, radioID: device.id) + + let remaining = try await store.fetchPendingSends(radioID: device.id) + #expect(remaining.count == 1, + "only the other sender's PendingSend should survive") + #expect(remaining.first?.messageID == untouchedMessageID, + "surviving PendingSend must belong to the untouched sender") + } + + // MARK: - RxLogEntry Tests + + private func createTestRxLogEntryDTO( + radioID: UUID, + senderTimestamp: UInt32? = nil, + regionScope: String? = nil, + payloadTypeBits: UInt8 = 5, + transportCode: Data? = nil, + channelIndex: UInt8? = 1, + packetPayload: Data = Data([0xAB, 0xCD, 0xEF]) + ) -> RxLogEntryDTO { + // Create minimal ParsedRxLogData for the DTO + let parsed = ParsedRxLogData( + snr: 10.5, + rssi: -65, + rawPayload: Data([0x15, 0x01, 0x02, 0x03]), + routeType: .flood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: payloadTypeBits, + transportCode: transportCode, + pathLength: 1, + pathNodes: [0x42], + packetPayload: packetPayload + ) + + return RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: channelIndex, + channelName: "TestChannel", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: regionScope, + decodedText: "Hello mesh!" + ) + } + + @Test + func `RxLogEntryDTO(from:) falls back on out-of-range stored values instead of trapping`() { + let model = RxLogEntry( + radioID: UUID(), + routeType: 999, + payloadType: -1, + payloadVersion: 5000, + pathLength: 400, + pathNodes: Data(), + packetPayload: Data(), + rawPayload: Data(), + packetHash: "deadbeef", + channelIndex: 9999, + senderTimestamp: -10 + ) + + let dto = RxLogEntryDTO(from: model) + + #expect(dto.routeType == .flood) + #expect(dto.payloadType == .unknown) + #expect(dto.payloadVersion == 0) + #expect(dto.pathLength == 0) + #expect(dto.channelIndex == nil) + #expect(dto.senderTimestamp == nil) + } + + @Test + func `Save and fetch RxLogEntry preserves senderTimestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let expectedTimestamp: UInt32 = 1_703_123_456 + let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: expectedTimestamp) + + try await store.saveRxLogEntry(dto) + + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.count == 1) + #expect(entries.first?.senderTimestamp == expectedTimestamp) + } + + @Test + func `Save and fetch RxLogEntry with nil senderTimestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: nil) + + try await store.saveRxLogEntry(dto) + + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.count == 1) + #expect(entries.first?.senderTimestamp == nil) + } + + @Test + func `RxLogEntryDTO init from model preserves senderTimestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Save with timestamp + let expectedTimestamp: UInt32 = 1_703_123_456 + let dto = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: expectedTimestamp) + try await store.saveRxLogEntry(dto) + + // Fetch back (this uses RxLogEntryDTO.init(from: RxLogEntry)) + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.first?.senderTimestamp == expectedTimestamp) + + // Verify the conversion handles the Int -> UInt32 correctly + // The model stores Int, DTO uses UInt32 + #expect(entries.first?.senderTimestamp == 1_703_123_456) + } + + @Test + func `RX log prune is deferred until threshold is exceeded`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + for index in 0..<1100 { + let dto = createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: UInt32(index) + ) + try await store.saveRxLogEntry(dto) + try await store.pruneRxLogEntries(radioID: device.id) + } + + let entriesBeforeThreshold = try await store.fetchRxLogEntries(radioID: device.id, limit: 1200) + #expect(entriesBeforeThreshold.count == 1100) + + let thresholdEntry = createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: UInt32(1100) + ) + try await store.saveRxLogEntry(thresholdEntry) + try await store.pruneRxLogEntries(radioID: device.id) + + let entriesAfterThreshold = try await store.fetchRxLogEntries(radioID: device.id, limit: 1200) + #expect(entriesAfterThreshold.count == 1000) + #expect(entriesAfterThreshold.first?.senderTimestamp == 1100) + #expect(entriesAfterThreshold.last?.senderTimestamp == 101) + } + + @Test + func `Clearing RX log resets cached count for future pruning`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + for index in 0..<1101 { + let dto = createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: UInt32(index) + ) + try await store.saveRxLogEntry(dto) + } + try await store.pruneRxLogEntries(radioID: device.id) + try await store.clearRxLogEntries(radioID: device.id) + + let replacement = createTestRxLogEntryDTO(radioID: device.id, senderTimestamp: 42) + try await store.saveRxLogEntry(replacement) + try await store.pruneRxLogEntries(radioID: device.id) + + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.count == 1) + #expect(entries.first?.senderTimestamp == 42) + } + + @Test + func `A store rebuilt over a populated container seeds its prune cache from disk`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let radioID = UUID() + + let storeA = PersistenceStore(modelContainer: container) + try await storeA.saveDevice(createTestDevice().copy { $0.id = radioID; $0.radioID = radioID }) + + // Fill to the retention cap (keepCount + pruneThreshold) without exceeding it. + for index in 0..<1100 { + try await storeA.saveRxLogEntry( + createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: UInt32(index)) + ) + try await storeA.pruneRxLogEntries(radioID: radioID) + } + let beforeReconnect = try await storeA.fetchRxLogEntries(radioID: radioID, limit: 1200) + #expect(beforeReconnect.count == 1100) + + // Reconnect: a new store over the same container starts with a cold cache. + // Writing one full prune cycle past the cap drives the count back to keepCount + // only if storeB seeded from disk; a cold-from-zero cache never trips the gate. + let storeB = PersistenceStore(modelContainer: container) + for index in 1100..<1202 { + try await storeB.saveRxLogEntry( + createTestRxLogEntryDTO(radioID: radioID, senderTimestamp: UInt32(index)) + ) + try await storeB.pruneRxLogEntries(radioID: radioID) + } + + // Pruning only fires if storeB seeded its count from disk rather than from zero. + let afterReconnect = try await storeB.fetchRxLogEntries(radioID: radioID, limit: 1300) + #expect(afterReconnect.count == 1000) + } + + // MARK: - Region Scope Tests + + @Test + func `saveRxLogEntry forwards regionScope to the persisted model`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let dto = createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: 1_703_000_000, + regionScope: "Germany" + ) + try await store.saveRxLogEntry(dto) + + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.first?.regionScope == "Germany") + } + + @Test + func `saveRxLogEntry preserves payloadTypeBits including unknown nibbles`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let dto = createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: 1_703_000_001, + payloadTypeBits: 0x0C + ) + try await store.saveRxLogEntry(dto) + + let entries = try await store.fetchRxLogEntries(radioID: device.id) + #expect(entries.first?.payloadTypeBits == 0x0C) + } + + @Test + func `batchUpdateChannelMessageRegion back-fills normal-case message via timestamp fallback`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let wireTimestamp: UInt32 = 1_703_111_111 + // Normal case: senderTimestamp stays nil, wire timestamp lives on `timestamp`. + let dto = MessageDTO.testChannelMessage( + radioID: device.id, + channelIndex: 0, + timestamp: wireTimestamp, + direction: .incoming, + status: .delivered + ) + try await store.saveMessage(dto) + + try await store.batchUpdateChannelMessageRegion( + radioID: device.id, + updates: [(channelIndex: 0, senderTimestamp: wireTimestamp, regionScope: "Germany")] + ) + + let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 0) + #expect(saved.first?.regionScope == "Germany") + } + + @Test + func `batchUpdateChannelMessageRegion back-fills timestamp-corrected message`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let originalWire: UInt32 = 1_703_222_222 + var dto = MessageDTO.testChannelMessage( + radioID: device.id, + channelIndex: 1, + timestamp: UInt32(Date().timeIntervalSince1970), + direction: .incoming, + status: .delivered + ) + dto.senderTimestamp = originalWire + try await store.saveMessage(dto) + + try await store.batchUpdateChannelMessageRegion( + radioID: device.id, + updates: [(channelIndex: 1, senderTimestamp: originalWire, regionScope: "USA")] + ) + + let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 1) + #expect(saved.first?.regionScope == "USA") + } + + @Test + func `batchUpdateChannelMessageRegion skips outgoing messages`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let wireTimestamp: UInt32 = 1_703_333_333 + let dto = MessageDTO.testChannelMessage( + radioID: device.id, + channelIndex: 2, + timestamp: wireTimestamp, + direction: .outgoing, + status: .sent + ) + try await store.saveMessage(dto) + + try await store.batchUpdateChannelMessageRegion( + radioID: device.id, + updates: [(channelIndex: 2, senderTimestamp: wireTimestamp, regionScope: "France")] + ) + + let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 2) + #expect(saved.first?.regionScope == nil) + } + + @Test + func `batchUpdateDMMessageRegion back-fills DM by sender prefix byte`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let wireTimestamp: UInt32 = 1_703_444_444 + let senderKey = Data([0xAB, 0xCD, 0xEF, 0x01, 0x02, 0x03]) + let contactID = UUID() + let dto = MessageDTO.testDirectMessage( + radioID: device.id, + contactID: contactID, + timestamp: wireTimestamp, + direction: .incoming, + status: .delivered, + senderKeyPrefix: senderKey + ) + try await store.saveMessage(dto) + + try await store.batchUpdateDMMessageRegion( + radioID: device.id, + updates: [(senderPrefixByte: 0xAB, senderTimestamp: wireTimestamp, regionScope: "Germany")] + ) + + let saved = try await store.fetchMessages(contactID: contactID) + #expect(saved.first?.regionScope == "Germany") + } + + @Test + func `batchUpdateDMMessageRegion ignores DMs from other senders at same timestamp`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let wireTimestamp: UInt32 = 1_703_555_555 + let aliceKey = Data([0xAA, 0x11, 0x22, 0x33, 0x44, 0x55]) + let bobKey = Data([0xBB, 0x66, 0x77, 0x88, 0x99, 0x00]) + let aliceContact = UUID() + let bobContact = UUID() + + let alice = MessageDTO.testDirectMessage( + radioID: device.id, + contactID: aliceContact, + text: "From Alice", + timestamp: wireTimestamp, + direction: .incoming, + status: .delivered, + senderKeyPrefix: aliceKey + ) + let bob = MessageDTO.testDirectMessage( + radioID: device.id, + contactID: bobContact, + text: "From Bob", + timestamp: wireTimestamp, + direction: .incoming, + status: .delivered, + senderKeyPrefix: bobKey + ) + try await store.saveMessage(alice) + try await store.saveMessage(bob) + + try await store.batchUpdateDMMessageRegion( + radioID: device.id, + updates: [(senderPrefixByte: 0xAA, senderTimestamp: wireTimestamp, regionScope: "Germany")] + ) + + let aliceSaved = try await store.fetchMessages(contactID: aliceContact) + let bobSaved = try await store.fetchMessages(contactID: bobContact) + #expect(aliceSaved.first?.regionScope == "Germany") + #expect(bobSaved.first?.regionScope == nil) + } + + // MARK: - Mute Tests + + @Test + func `Set contact muted`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "Alice") + let contactID = try await store.saveContact(radioID: device.id, from: frame) + + // Initially not muted + var contact = try await store.fetchContact(id: contactID) + #expect(contact?.isMuted == false) + + // Mute + try await store.setContactMuted(contactID, isMuted: true) + contact = try await store.fetchContact(id: contactID) + #expect(contact?.isMuted == true) + + // Unmute + try await store.setContactMuted(contactID, isMuted: false) + contact = try await store.fetchContact(id: contactID) + #expect(contact?.isMuted == false) + } + + @Test + func `Muted contacts excluded from badge count`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Create contact with unreads + let frame1 = createTestContactFrame(name: "Alice") + let contact1ID = try await store.saveContact(radioID: device.id, from: frame1) + try await store.incrementUnreadCount(contactID: contact1ID) + try await store.incrementUnreadCount(contactID: contact1ID) + + // Create muted contact with unreads + let frame2 = createTestContactFrame(name: "Bob") + let contact2ID = try await store.saveContact(radioID: device.id, from: frame2) + try await store.incrementUnreadCount(contactID: contact2ID) + try await store.setContactMuted(contact2ID, isMuted: true) + + let (contacts, _, _) = try await store.getTotalUnreadCounts(radioID: device.id) + + // Only Alice's 2 unreads should count, Bob is muted + #expect(contacts == 2) + } + + @Test + func `Notification levels affect badge count correctly`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + // Create channel with unreads + let channelInfo = ChannelInfo(index: 1, name: "Test", secret: Data(repeating: 0x42, count: 16)) + let channelID = try await store.saveChannel(radioID: device.id, from: channelInfo) + try await store.incrementChannelUnreadCount(channelID: channelID) + try await store.incrementChannelUnreadCount(channelID: channelID) + + // Default (all) - should count all unreads + var counts = try await store.getTotalUnreadCounts(radioID: device.id) + #expect(counts.channels == 2) + + // Muted - should exclude from badge + try await store.setChannelNotificationLevel(channelID, level: .muted) + counts = try await store.getTotalUnreadCounts(radioID: device.id) + #expect(counts.channels == 0) + + // Mentions only with no mentions - should show 0 + try await store.setChannelNotificationLevel(channelID, level: .mentionsOnly) + counts = try await store.getTotalUnreadCounts(radioID: device.id) + #expect(counts.channels == 0) + + // Mentions only with mentions - should show mention count + try await store.incrementChannelUnreadMentionCount(channelID: channelID) + counts = try await store.getTotalUnreadCounts(radioID: device.id) + #expect(counts.channels == 1) + } + + // MARK: - Ghost Identity Reconciliation Tests + + @Test + func `reconcileGhostIdentity rewrites current device when ghost matches publicKey`() async throws { + let store = try await createTestStore() + + let oldPublicKey = Data((0.. .pending is not a constraint violation because the row can + // only ever reach .delivered by going forward through .sent again. + let remapped = try await store.updateMessageStatusUnlessDelivered(id: messageID, status: .pending) + #expect(remapped == true) + #expect(try await store.fetchMessage(id: messageID)?.status == .pending) + + // A subsequent successful redelivery still reaches .delivered. + try await store.updateMessageAck(id: messageID, ackCode: 0x1234_5678, status: .delivered) + #expect(try await store.fetchMessage(id: messageID)?.status == .delivered) + } + + @Test + func `hasOutgoingSentDM flags a stuck .sent DM by ackCode and ignores other rows`() async throws { + let store = try await createTestStore() + let ackCode: UInt32 = 0xCAFE_F00D + + let sentID = UUID() + try await store.saveMessage( + MessageDTO.testDirectMessage(id: sentID, status: .sent, ackCode: ackCode) + ) + #expect(try await store.hasOutgoingSentDM(ackCode: ackCode) == true) + + // A different ackCode must not match. + #expect(try await store.hasOutgoingSentDM(ackCode: 0x0000_0001) == false) + + // A delivered row with the same ackCode is not an orphan. + let deliveredID = UUID() + try await store.saveMessage( + MessageDTO.testDirectMessage(id: deliveredID, status: .delivered, ackCode: 0xBADC_0DE5) + ) + #expect(try await store.hasOutgoingSentDM(ackCode: 0xBADC_0DE5) == false) + } + + // MARK: - Inbound Advert Hop Count + + @Test + func `setInboundHopCount round-trips onto an existing discovered node`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let frame = createTestContactFrame(name: "Advertiser") + let (node, _) = try await store.upsertDiscoveredNode(radioID: radioID, from: frame) + + try await store.setInboundHopCount(radioID: radioID, publicKey: node.publicKey, hopCount: 4, advertTimestamp: 100) + + let fetched = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(fetched.count == 1) + #expect(fetched.first?.inboundHopCount == 4) + #expect(fetched.first?.inboundHopAdvertTimestamp == 100) + } + + @Test + func `setInboundHopCount is a no-op when no matching row exists`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let unknownKey = Data((0.. loss1km) - #expect(loss4km > loss2km) - - // Doubling distance adds ~6dB - #expect(abs((loss2km - loss1km) - 6.02) < 0.1) - #expect(abs((loss4km - loss2km) - 6.02) < 0.1) - } - - @Test("Path loss increases with frequency") - func pathLossIncreasesWithFrequency() { - let loss400MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 400) - let loss900MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 900) - let loss2400MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 2400) - - #expect(loss900MHz > loss400MHz) - #expect(loss2400MHz > loss900MHz) - } - - @Test("Path loss returns 0 for invalid inputs") - func pathLossInvalidInputs() { - #expect(RFCalculator.pathLoss(distanceMeters: 0, frequencyMHz: 910) == 0) - #expect(RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 0) == 0) - #expect(RFCalculator.pathLoss(distanceMeters: -100, frequencyMHz: 910) == 0) - #expect(RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: -910) == 0) - } - - // MARK: - Diffraction Loss Tests - - @Test("Diffraction loss is zero for clear line-of-sight (v below grazing)") - func diffractionLossClearLOS() { - // Large negative obstruction height = clear path well below LOS - let loss = RFCalculator.diffractionLoss( - obstructionHeightMeters: -50, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - #expect(loss == 0) - } - - @Test("Diffraction loss is approximately 6 dB for grazing (v near 0)") - func diffractionLossGrazing() { - // At v=0 (grazing), the obstruction is exactly on the line of sight - let loss = RFCalculator.diffractionLoss( - obstructionHeightMeters: 0, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - // At v=0: L ≈ 6.02 dB - #expect(abs(loss - 6.0) < 1.0) - } - - @Test("Diffraction loss increases for blocked path (v near 1)") - func diffractionLossBlocked() { - // Calculate the obstruction height that gives v ≈ 1 - // v = h * sqrt(2 * (d1 + d2) / (lambda * d1 * d2)) - // For 6km at 910MHz: sqrt(2 * 6000 / (0.3294 * 3000 * 3000)) ≈ 0.0636 - // So h = 1 / 0.0636 ≈ 15.7m for v = 1 - let loss = RFCalculator.diffractionLoss( - obstructionHeightMeters: 15.7, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - // ITU-R P.526 J(v) at v ≈ 1 is ≈ 13.9 dB - #expect(abs(loss - 13.9) < 0.5) - } - - @Test("Diffraction loss is greater for larger obstructions") - func diffractionLossIncreasesWithObstruction() { - let lossSmall = RFCalculator.diffractionLoss( - obstructionHeightMeters: 5, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - - let lossMedium = RFCalculator.diffractionLoss( - obstructionHeightMeters: 15, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - - let lossLarge = RFCalculator.diffractionLoss( - obstructionHeightMeters: 30, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - - #expect(lossMedium > lossSmall) - #expect(lossLarge > lossMedium) - } - - @Test("Diffraction loss matches ITU-R P.526 reference value at strong obstruction (v ≈ 2.4)") - func diffractionLossMatchesITUReferenceAtV24() { - // h ≈ 37.7m gives v ≈ 2.4 for 6km at 910MHz; ITU-R P.526 J(2.4) ≈ 20.5 dB. - // A wrong-sign polynomial approximation reads ≈ 35 dB here, so this anchors the regime. - let loss = RFCalculator.diffractionLoss( - obstructionHeightMeters: 37.7, - distanceToAMeters: 3000, - distanceToBMeters: 3000, - frequencyMHz: 910 - ) - #expect(abs(loss - 20.5) < 0.7) - } - - @Test("Diffraction loss returns 0 for invalid inputs") - func diffractionLossInvalidInputs() { - #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 0, distanceToBMeters: 100, frequencyMHz: 910) == 0) - #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 100, distanceToBMeters: 0, frequencyMHz: 910) == 0) - #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 100, distanceToBMeters: 100, frequencyMHz: 0) == 0) - } - - // MARK: - Haversine Distance Tests - - @Test("Distance between same coordinates is zero") - func distanceSameCoordinates() { - let coord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - let distance = RFCalculator.distance(from: coord, to: coord) - #expect(distance == 0) - } - - @Test("Haversine distance calculation is accurate") - func haversineDistanceAccuracy() { - // San Francisco to Los Angeles: approximately 559 km - let sanFrancisco = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - let losAngeles = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) - - let distance = RFCalculator.distance(from: sanFrancisco, to: losAngeles) - - // Expected: ~559 km = 559000 meters (within 10km tolerance) - #expect(abs(distance - 559_000) < 10_000) - } - - @Test("Haversine distance is symmetric") - func haversineDistanceSymmetric() { - let coord1 = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - let coord2 = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) - - let distance1 = RFCalculator.distance(from: coord1, to: coord2) - let distance2 = RFCalculator.distance(from: coord2, to: coord1) - - #expect(abs(distance1 - distance2) < 0.001) - } - - @Test("Distance across date line is correct") - func distanceAcrossDateLine() { - // Tokyo to San Francisco across the Pacific - let tokyo = CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) - let sanFrancisco = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - - let distance = RFCalculator.distance(from: tokyo, to: sanFrancisco) - - // Expected: ~8,280 km = 8,280,000 meters (within 100km tolerance) - #expect(abs(distance - 8_280_000) < 100_000) - } - - @Test("Short distance calculation is accurate") - func shortDistanceAccuracy() { - // Two points approximately 1 km apart - let point1 = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - // Moving ~0.009 degrees north is roughly 1 km - let point2 = CLLocationCoordinate2D(latitude: 37.7839, longitude: -122.4194) - - let distance = RFCalculator.distance(from: point1, to: point2) - - // Expected: ~1 km = 1000 meters (within 50m tolerance) - #expect(abs(distance - 1000) < 100) - } + // MARK: - Constants Tests + + @Test + func `Speed of light constant is correct`() { + #expect(RFCalculator.speedOfLight == 299_792_458) + } + + @Test + func `Earth radius constant is correct`() { + #expect(RFCalculator.earthRadiusKm == 6371) + } + + // MARK: - Wavelength Tests + + @Test + func `Wavelength at 910 MHz is approximately 0.3294m`() { + let wavelength = RFCalculator.wavelength(frequencyMHz: 910) + // Expected: c / f = 299792458 / 910000000 ≈ 0.3294422 + #expect(abs(wavelength - 0.3294) < 0.001) + } + + @Test + func `Wavelength at 2400 MHz is approximately 0.125m`() { + let wavelength = RFCalculator.wavelength(frequencyMHz: 2400) + // Expected: 299792458 / 2400000000 ≈ 0.1249 + #expect(abs(wavelength - 0.125) < 0.001) + } + + @Test + func `Wavelength returns 0 for zero frequency`() { + let wavelength = RFCalculator.wavelength(frequencyMHz: 0) + #expect(wavelength == 0) + } + + @Test + func `Wavelength returns 0 for negative frequency`() { + let wavelength = RFCalculator.wavelength(frequencyMHz: -100) + #expect(wavelength == 0) + } + + // MARK: - Fresnel Radius Tests + + @Test + func `Fresnel radius at midpoint is approximately 22.23m for 6km at 910MHz`() { + let radius = RFCalculator.fresnelRadius( + frequencyMHz: 910, + distanceToAMeters: 3000, + distanceToBMeters: 3000 + ) + // r = sqrt((0.3294 * 3000 * 3000) / 6000) = sqrt(494.1) ≈ 22.23 + #expect(abs(radius - 22.23) < 0.5) + } + + @Test + func `Fresnel radius at quarter point is smaller than at midpoint`() { + let totalDistance = 6000.0 + let quarterPoint = totalDistance / 4 + + let radiusAtQuarter = RFCalculator.fresnelRadius( + frequencyMHz: 910, + distanceToAMeters: quarterPoint, + distanceToBMeters: totalDistance - quarterPoint + ) + + let radiusAtMidpoint = RFCalculator.fresnelRadius( + frequencyMHz: 910, + distanceToAMeters: 3000, + distanceToBMeters: 3000 + ) + + // Quarter point radius should be smaller than midpoint + // r at quarter = sqrt((0.3294 * 1500 * 4500) / 6000) ≈ 19.27 + #expect(radiusAtQuarter < radiusAtMidpoint) + #expect(abs(radiusAtQuarter - 19.27) < 0.5) + } + + @Test + func `Fresnel radius is symmetric`() { + let radius1 = RFCalculator.fresnelRadius( + frequencyMHz: 910, + distanceToAMeters: 2000, + distanceToBMeters: 4000 + ) + + let radius2 = RFCalculator.fresnelRadius( + frequencyMHz: 910, + distanceToAMeters: 4000, + distanceToBMeters: 2000 + ) + + #expect(abs(radius1 - radius2) < 0.001) + } + + @Test + func `Fresnel radius returns 0 for invalid inputs`() { + #expect(RFCalculator.fresnelRadius(frequencyMHz: 0, distanceToAMeters: 100, distanceToBMeters: 100) == 0) + #expect(RFCalculator.fresnelRadius(frequencyMHz: 910, distanceToAMeters: 0, distanceToBMeters: 100) == 0) + #expect(RFCalculator.fresnelRadius(frequencyMHz: 910, distanceToAMeters: 100, distanceToBMeters: 0) == 0) + #expect(RFCalculator.fresnelRadius(frequencyMHz: -100, distanceToAMeters: 100, distanceToBMeters: 100) == 0) + } + + // MARK: - Earth Bulge Tests + + @Test + func `Earth bulge at midpoint with k=0.25 is approximately 2.82m for 6km`() { + let bulge = RFCalculator.earthBulge( + distanceToAMeters: 3000, + distanceToBMeters: 3000, + refractionK: 0.25 + ) + // h = (3000 * 3000) / (2 * 0.25 * 6371000) = 9000000 / 3185500 ≈ 2.82 + #expect(abs(bulge - 2.82) < 0.05) + } + + @Test + func `Earth bulge with standard atmosphere k=1.33 is smaller`() { + let bulgeConservative = RFCalculator.earthBulge( + distanceToAMeters: 3000, + distanceToBMeters: 3000, + refractionK: 0.25 + ) + + let bulgeStandard = RFCalculator.earthBulge( + distanceToAMeters: 3000, + distanceToBMeters: 3000, + refractionK: 1.33 + ) + + // Standard atmosphere (k=1.33) gives smaller bulge due to larger effective earth radius + // h = (3000 * 3000) / (2 * 1.33 * 6371000) ≈ 0.53m + #expect(bulgeStandard < bulgeConservative) + #expect(abs(bulgeStandard - 0.53) < 0.05) + } + + @Test + func `Earth bulge is symmetric`() { + let bulge1 = RFCalculator.earthBulge( + distanceToAMeters: 2000, + distanceToBMeters: 4000, + refractionK: 1.0 + ) + + let bulge2 = RFCalculator.earthBulge( + distanceToAMeters: 4000, + distanceToBMeters: 2000, + refractionK: 1.0 + ) + + #expect(abs(bulge1 - bulge2) < 0.001) + } + + @Test + func `Earth bulge returns 0 for invalid inputs`() { + #expect(RFCalculator.earthBulge(distanceToAMeters: 0, distanceToBMeters: 100, refractionK: 1.0) == 0) + #expect(RFCalculator.earthBulge(distanceToAMeters: 100, distanceToBMeters: 0, refractionK: 1.0) == 0) + #expect(RFCalculator.earthBulge(distanceToAMeters: 100, distanceToBMeters: 100, refractionK: 0) == 0) + #expect(RFCalculator.earthBulge(distanceToAMeters: 100, distanceToBMeters: 100, refractionK: -1) == 0) + } + + // MARK: - Path Loss Tests + + @Test + func `Path loss is approximately 107.2 dB for 6km at 910MHz`() { + let loss = RFCalculator.pathLoss(distanceMeters: 6000, frequencyMHz: 910) + // FSPL = 20*log10(6000) + 20*log10(910) - 27.55 + // = 75.56 + 59.18 - 27.55 ≈ 107.19 + #expect(abs(loss - 107.2) < 0.5) + } + + @Test + func `Path loss increases with distance`() { + let loss1km = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 910) + let loss2km = RFCalculator.pathLoss(distanceMeters: 2000, frequencyMHz: 910) + let loss4km = RFCalculator.pathLoss(distanceMeters: 4000, frequencyMHz: 910) + + #expect(loss2km > loss1km) + #expect(loss4km > loss2km) + + // Doubling distance adds ~6dB + #expect(abs((loss2km - loss1km) - 6.02) < 0.1) + #expect(abs((loss4km - loss2km) - 6.02) < 0.1) + } + + @Test + func `Path loss increases with frequency`() { + let loss400MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 400) + let loss900MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 900) + let loss2400MHz = RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 2400) + + #expect(loss900MHz > loss400MHz) + #expect(loss2400MHz > loss900MHz) + } + + @Test + func `Path loss returns 0 for invalid inputs`() { + #expect(RFCalculator.pathLoss(distanceMeters: 0, frequencyMHz: 910) == 0) + #expect(RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: 0) == 0) + #expect(RFCalculator.pathLoss(distanceMeters: -100, frequencyMHz: 910) == 0) + #expect(RFCalculator.pathLoss(distanceMeters: 1000, frequencyMHz: -910) == 0) + } + + // MARK: - Diffraction Loss Tests + + @Test + func `Diffraction loss is zero for clear line-of-sight (v below grazing)`() { + // Large negative obstruction height = clear path well below LOS + let loss = RFCalculator.diffractionLoss( + obstructionHeightMeters: -50, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + #expect(loss == 0) + } + + @Test + func `Diffraction loss is approximately 6 dB for grazing (v near 0)`() { + // At v=0 (grazing), the obstruction is exactly on the line of sight + let loss = RFCalculator.diffractionLoss( + obstructionHeightMeters: 0, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + // At v=0: L ≈ 6.02 dB + #expect(abs(loss - 6.0) < 1.0) + } + + @Test + func `Diffraction loss increases for blocked path (v near 1)`() { + // Calculate the obstruction height that gives v ≈ 1 + // v = h * sqrt(2 * (d1 + d2) / (lambda * d1 * d2)) + // For 6km at 910MHz: sqrt(2 * 6000 / (0.3294 * 3000 * 3000)) ≈ 0.0636 + // So h = 1 / 0.0636 ≈ 15.7m for v = 1 + let loss = RFCalculator.diffractionLoss( + obstructionHeightMeters: 15.7, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + // ITU-R P.526 J(v) at v ≈ 1 is ≈ 13.9 dB + #expect(abs(loss - 13.9) < 0.5) + } + + @Test + func `Diffraction loss is greater for larger obstructions`() { + let lossSmall = RFCalculator.diffractionLoss( + obstructionHeightMeters: 5, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + + let lossMedium = RFCalculator.diffractionLoss( + obstructionHeightMeters: 15, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + + let lossLarge = RFCalculator.diffractionLoss( + obstructionHeightMeters: 30, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + + #expect(lossMedium > lossSmall) + #expect(lossLarge > lossMedium) + } + + @Test + func `Diffraction loss matches ITU-R P.526 reference value at strong obstruction (v ≈ 2.4)`() { + // h ≈ 37.7m gives v ≈ 2.4 for 6km at 910MHz; ITU-R P.526 J(2.4) ≈ 20.5 dB. + // A wrong-sign polynomial approximation reads ≈ 35 dB here, so this anchors the regime. + let loss = RFCalculator.diffractionLoss( + obstructionHeightMeters: 37.7, + distanceToAMeters: 3000, + distanceToBMeters: 3000, + frequencyMHz: 910 + ) + #expect(abs(loss - 20.5) < 0.7) + } + + @Test + func `Diffraction loss returns 0 for invalid inputs`() { + #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 0, distanceToBMeters: 100, frequencyMHz: 910) == 0) + #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 100, distanceToBMeters: 0, frequencyMHz: 910) == 0) + #expect(RFCalculator.diffractionLoss(obstructionHeightMeters: 10, distanceToAMeters: 100, distanceToBMeters: 100, frequencyMHz: 0) == 0) + } + + // MARK: - Haversine Distance Tests + + @Test + func `Distance between same coordinates is zero`() { + let coord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + let distance = RFCalculator.distance(from: coord, to: coord) + #expect(distance == 0) + } + + @Test + func `Haversine distance calculation is accurate`() { + // San Francisco to Los Angeles: approximately 559 km + let sanFrancisco = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + let losAngeles = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) + + let distance = RFCalculator.distance(from: sanFrancisco, to: losAngeles) + + // Expected: ~559 km = 559000 meters (within 10km tolerance) + #expect(abs(distance - 559_000) < 10000) + } + + @Test + func `Haversine distance is symmetric`() { + let coord1 = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + let coord2 = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) + + let distance1 = RFCalculator.distance(from: coord1, to: coord2) + let distance2 = RFCalculator.distance(from: coord2, to: coord1) + + #expect(abs(distance1 - distance2) < 0.001) + } + + @Test + func `Distance across date line is correct`() { + // Tokyo to San Francisco across the Pacific + let tokyo = CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) + let sanFrancisco = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + + let distance = RFCalculator.distance(from: tokyo, to: sanFrancisco) + + // Expected: ~8,280 km = 8,280,000 meters (within 100km tolerance) + #expect(abs(distance - 8_280_000) < 100_000) + } + + @Test + func `Short distance calculation is accurate`() { + // Two points approximately 1 km apart + let point1 = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + // Moving ~0.009 degrees north is roughly 1 km + let point2 = CLLocationCoordinate2D(latitude: 37.7839, longitude: -122.4194) + + let distance = RFCalculator.distance(from: point1, to: point2) + + // Expected: ~1 km = 1000 meters (within 50m tolerance) + #expect(abs(distance - 1000) < 100) + } } // MARK: - Path Analysis Tests @Suite("PathAnalysis Tests") struct PathAnalysisTests { - - // MARK: - Helper Functions - - /// Creates an elevation profile with flat terrain at specified elevation - private func createFlatProfile( - elevationMeters: Double, - totalDistanceMeters: Double, - sampleCount: Int = 11 - ) -> [ElevationSample] { - var samples: [ElevationSample] = [] - let baseCoord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - - for i in 0.. [ElevationSample] { - var samples: [ElevationSample] = [] - let baseCoord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - let midpoint = sampleCount / 2 - - for i in 0..> 22m Fresnel radius - let profile = createFlatProfile( - elevationMeters: 0, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.clearanceStatus == .clear) - #expect(result.worstClearancePercent >= 80) - #expect(result.obstructionPoints.isEmpty) - #expect(result.distanceMeters == 6000) - #expect(result.distanceKm == 6.0) - } - - @Test("Clear path has only FSPL, no diffraction loss") - func clearPathNoAdditionalLoss() { - let profile = createFlatProfile( - elevationMeters: 0, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - // FSPL for 6km at 910MHz is approximately 107.2 dB - #expect(abs(result.freeSpacePathLoss - 107.2) < 1.0) - #expect(result.peakDiffractionLoss == 0) - #expect(result.totalPathLoss == result.freeSpacePathLoss) - } - - // MARK: - Blocked Path Tests - - @Test("Blocked path with 100m mountain returns blocked status") - func blockedPathWithMountain() { - // 100m mountain at midpoint, antennas at 50m - // LOS at midpoint: 50m - // Mountain peakFactor: 0 + 100 + earth bulge ≈ 103m - // This is well above LOS, so path is blocked - let profile = createObstructedProfile( - baseElevationMeters: 0, - obstructionHeightMeters: 100, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.clearanceStatus == .blocked) - #expect(result.worstClearancePercent < 0) - #expect(!result.obstructionPoints.isEmpty) - } - - @Test("Blocked path has significant diffraction loss") - func blockedPathHasDiffractionLoss() { - let profile = createObstructedProfile( - baseElevationMeters: 0, - obstructionHeightMeters: 100, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.peakDiffractionLoss > 10) - #expect(result.totalPathLoss > result.freeSpacePathLoss) - } - - // MARK: - Marginal Path Tests - - @Test("Marginal path with partial obstruction returns marginal status") - func marginalPathPartialObstruction() { - // Create a scenario where clearance is between 60-80% - // With 50m antennas and ~22m Fresnel zone at midpoint, - // we need terrain that comes within ~30% of LOS - // A small hill of ~25m should give marginal clearance - let profile = createObstructedProfile( - baseElevationMeters: 0, - obstructionHeightMeters: 25, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - // With 25m hill: LOS at 50m, terrain at ~28m (25 + 3m bulge) - // Clearance: 50 - 28 = 22m, Fresnel zone ~22m - // Clearance percent: ~100%, still clear but close to marginal - #expect(result.clearanceStatus == .clear || result.clearanceStatus == .marginal) - #expect(result.worstClearancePercent >= 60) - } - - // MARK: - Partial Obstruction Tests - - @Test("Partial obstruction path returns partial obstruction status") - func partialObstructionPath() { - // Create terrain that just touches the LOS but doesn't fully block - // 45m hill with 50m antennas at 6km - should partially obstruct - let profile = createObstructedProfile( - baseElevationMeters: 0, - obstructionHeightMeters: 45, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - // Hill at 45m + ~3m bulge = ~48m, LOS at 50m - // Clearance: ~2m, Fresnel zone ~22m - // Clearance percent: ~9%, which is partial obstruction - #expect(result.clearanceStatus == .partialObstruction) - #expect(result.worstClearancePercent >= 0) - #expect(result.worstClearancePercent < 60) - #expect(!result.obstructionPoints.isEmpty) - } - - // MARK: - Edge Cases - - @Test("Empty profile returns blocked status") - func emptyProfile() { - let result = RFCalculator.analyzePath( - elevationProfile: [], - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.clearanceStatus == .blocked) - #expect(result.distanceMeters == 0) - } - - @Test("Single sample profile returns blocked status") - func singleSampleProfile() { - let sample = ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 0 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: [sample], - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.clearanceStatus == .blocked) - } - - @Test("Profile with zero total distance returns blocked status") - func zeroDistanceProfile() { - let samples = [ - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 0 - ), - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 0 - ) - ] - - let result = RFCalculator.analyzePath( - elevationProfile: samples, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.0 - ) - - #expect(result.clearanceStatus == .blocked) - #expect(result.distanceMeters == 0) - } - - // MARK: - Asymmetric Antenna Heights - - @Test("Asymmetric antenna heights are handled correctly") - func asymmetricAntennaHeights() { - let profile = createFlatProfile( - elevationMeters: 0, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 100, // Higher antenna at A - pointBHeightMeters: 20, // Lower antenna at B - frequencyMHz: 910, - refractionK: 1.0 - ) - - // LOS slopes downward from A to B - // Even with asymmetry, should still be clear with these heights - #expect(result.clearanceStatus == .clear) - #expect(result.worstClearancePercent >= 80) - } - - // MARK: - Custom K-Factor Tests - - @Test("Custom k-factor affects earth bulge calculation") - func customKFactorAffectsAnalysis() { - let profile = createObstructedProfile( - baseElevationMeters: 0, - obstructionHeightMeters: 40, - totalDistanceMeters: 6000, - sampleCount: 21 - ) - - // Conservative k=0.25 (larger earth bulge) - let resultConservative = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 0.25 - ) - - // Standard atmosphere k=1.33 (smaller earth bulge) - let resultStandard = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 910, - refractionK: 1.33 - ) - - // With smaller effective earth bulge (higher k), clearance should be better - #expect(resultStandard.worstClearancePercent > resultConservative.worstClearancePercent) - } + // MARK: - Helper Functions + + /// Creates an elevation profile with flat terrain at specified elevation + private func createFlatProfile( + elevationMeters: Double, + totalDistanceMeters: Double, + sampleCount: Int = 11 + ) -> [ElevationSample] { + var samples: [ElevationSample] = [] + let baseCoord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + + for i in 0.. [ElevationSample] { + var samples: [ElevationSample] = [] + let baseCoord = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + let midpoint = sampleCount / 2 + + for i in 0..> 22m Fresnel radius + let profile = createFlatProfile( + elevationMeters: 0, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.clearanceStatus == .clear) + #expect(result.worstClearancePercent >= 80) + #expect(result.obstructionPoints.isEmpty) + #expect(result.distanceMeters == 6000) + #expect(result.distanceKm == 6.0) + } + + @Test + func `Clear path has only FSPL, no diffraction loss`() { + let profile = createFlatProfile( + elevationMeters: 0, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + // FSPL for 6km at 910MHz is approximately 107.2 dB + #expect(abs(result.freeSpacePathLoss - 107.2) < 1.0) + #expect(result.peakDiffractionLoss == 0) + #expect(result.totalPathLoss == result.freeSpacePathLoss) + } + + // MARK: - Blocked Path Tests + + @Test + func `Blocked path with 100m mountain returns blocked status`() { + // 100m mountain at midpoint, antennas at 50m + // LOS at midpoint: 50m + // Mountain peakFactor: 0 + 100 + earth bulge ≈ 103m + // This is well above LOS, so path is blocked + let profile = createObstructedProfile( + baseElevationMeters: 0, + obstructionHeightMeters: 100, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.clearanceStatus == .blocked) + #expect(result.worstClearancePercent < 0) + #expect(!result.obstructionPoints.isEmpty) + } + + @Test + func `Blocked path has significant diffraction loss`() { + let profile = createObstructedProfile( + baseElevationMeters: 0, + obstructionHeightMeters: 100, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.peakDiffractionLoss > 10) + #expect(result.totalPathLoss > result.freeSpacePathLoss) + } + + // MARK: - Marginal Path Tests + + @Test + func `Marginal path with partial obstruction returns marginal status`() { + // Create a scenario where clearance is between 60-80% + // With 50m antennas and ~22m Fresnel zone at midpoint, + // we need terrain that comes within ~30% of LOS + // A small hill of ~25m should give marginal clearance + let profile = createObstructedProfile( + baseElevationMeters: 0, + obstructionHeightMeters: 25, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + // With 25m hill: LOS at 50m, terrain at ~28m (25 + 3m bulge) + // Clearance: 50 - 28 = 22m, Fresnel zone ~22m + // Clearance percent: ~100%, still clear but close to marginal + #expect(result.clearanceStatus == .clear || result.clearanceStatus == .marginal) + #expect(result.worstClearancePercent >= 60) + } + + // MARK: - Partial Obstruction Tests + + @Test + func `Partial obstruction path returns partial obstruction status`() { + // Create terrain that just touches the LOS but doesn't fully block + // 45m hill with 50m antennas at 6km - should partially obstruct + let profile = createObstructedProfile( + baseElevationMeters: 0, + obstructionHeightMeters: 45, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + // Hill at 45m + ~3m bulge = ~48m, LOS at 50m + // Clearance: ~2m, Fresnel zone ~22m + // Clearance percent: ~9%, which is partial obstruction + #expect(result.clearanceStatus == .partialObstruction) + #expect(result.worstClearancePercent >= 0) + #expect(result.worstClearancePercent < 60) + #expect(!result.obstructionPoints.isEmpty) + } + + // MARK: - Edge Cases + + @Test + func `Empty profile returns blocked status`() { + let result = RFCalculator.analyzePath( + elevationProfile: [], + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.clearanceStatus == .blocked) + #expect(result.distanceMeters == 0) + } + + @Test + func `Single sample profile returns blocked status`() { + let sample = ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 0 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: [sample], + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.clearanceStatus == .blocked) + } + + @Test + func `Profile with zero total distance returns blocked status`() { + let samples = [ + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 0 + ), + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 0 + ) + ] + + let result = RFCalculator.analyzePath( + elevationProfile: samples, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.0 + ) + + #expect(result.clearanceStatus == .blocked) + #expect(result.distanceMeters == 0) + } + + // MARK: - Asymmetric Antenna Heights + + @Test + func `Asymmetric antenna heights are handled correctly`() { + let profile = createFlatProfile( + elevationMeters: 0, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 100, // Higher antenna at A + pointBHeightMeters: 20, // Lower antenna at B + frequencyMHz: 910, + refractionK: 1.0 + ) + + // LOS slopes downward from A to B + // Even with asymmetry, should still be clear with these heights + #expect(result.clearanceStatus == .clear) + #expect(result.worstClearancePercent >= 80) + } + + // MARK: - Custom K-Factor Tests + + @Test + func `Custom k-factor affects earth bulge calculation`() { + let profile = createObstructedProfile( + baseElevationMeters: 0, + obstructionHeightMeters: 40, + totalDistanceMeters: 6000, + sampleCount: 21 + ) + + // Conservative k=0.25 (larger earth bulge) + let resultConservative = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 0.25 + ) + + // Standard atmosphere k=1.33 (smaller earth bulge) + let resultStandard = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 910, + refractionK: 1.33 + ) + + // With smaller effective earth bulge (higher k), clearance should be better + #expect(resultStandard.worstClearancePercent > resultConservative.worstClearancePercent) + } } // MARK: - PathAnalysisResult Fields Tests @Suite("PathAnalysisResult Fields") struct PathAnalysisResultFieldsTests { - - @Test("PathAnalysisResult includes frequency used in calculation") - func resultIncludesFrequency() { - let profile = [ - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 0 - ), - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 1000 - ) - ] - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 915.0, - refractionK: 1.33 - ) - - #expect(result.frequencyMHz == 915.0) - } - - @Test("PathAnalysisResult includes k-factor used in calculation") - func resultIncludesKFactor() { - let profile = [ - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 0 - ), - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4194), - elevation: 0, - distanceFromAMeters: 1000 - ) - ] - - let result = RFCalculator.analyzePath( - elevationProfile: profile, - pointAHeightMeters: 50, - pointBHeightMeters: 50, - frequencyMHz: 906.0, - refractionK: 1.33 - ) - - #expect(result.refractionK == 1.33) - } + @Test + func `PathAnalysisResult includes frequency used in calculation`() { + let profile = [ + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 0 + ), + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 1000 + ) + ] + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 915.0, + refractionK: 1.33 + ) + + #expect(result.frequencyMHz == 915.0) + } + + @Test + func `PathAnalysisResult includes k-factor used in calculation`() { + let profile = [ + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 0 + ), + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4194), + elevation: 0, + distanceFromAMeters: 1000 + ) + ] + + let result = RFCalculator.analyzePath( + elevationProfile: profile, + pointAHeightMeters: 50, + pointBHeightMeters: 50, + frequencyMHz: 906.0, + refractionK: 1.33 + ) + + #expect(result.refractionK == 1.33) + } } // MARK: - Segment Analysis with ArraySlice Tests @Suite("Segment Analysis with ArraySlice") struct SegmentAnalysisTests { - - @Test("analyzePathSegment works with ArraySlice") - func analyzePathSegmentWithSlice() { - // Create a profile with 11 samples (0-10km, 1km intervals) - let samples = (0...10).map { i in - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.0 + Double(i) * 0.01, longitude: -122.0), - elevation: 100, // flat terrain - distanceFromAMeters: Double(i) * 1000 - ) - } - - // Analyze first half (0-5km) - let firstHalf = samples[0...5] - let result = RFCalculator.analyzePathSegment( - elevationProfile: firstHalf, - startHeightMeters: 50, // 50m antenna height for adequate Fresnel clearance - endHeightMeters: 50, - frequencyMHz: 906, - refractionK: 1.0 - ) - - #expect(result.distanceMeters == 5000) - #expect(result.clearanceStatus == .clear) // flat terrain with 50m antennas should be clear - } - - @Test("analyzePathSegment handles overlapping slice indices") - func analyzePathSegmentOverlappingSlice() { - let samples = (0...10).map { i in - ElevationSample( - coordinate: CLLocationCoordinate2D(latitude: 37.0 + Double(i) * 0.01, longitude: -122.0), - elevation: 100, - distanceFromAMeters: Double(i) * 1000 - ) - } - - // Analyze from index 5 to 10 (second half, includes repeater at index 5) - let secondHalf = samples[5...10] - let result = RFCalculator.analyzePathSegment( - elevationProfile: secondHalf, - startHeightMeters: 10, - endHeightMeters: 10, - frequencyMHz: 906, - refractionK: 1.0 - ) - - #expect(result.distanceMeters == 5000) - } + @Test + func `analyzePathSegment works with ArraySlice`() { + // Create a profile with 11 samples (0-10km, 1km intervals) + let samples = (0...10).map { i in + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.0 + Double(i) * 0.01, longitude: -122.0), + elevation: 100, // flat terrain + distanceFromAMeters: Double(i) * 1000 + ) + } + + // Analyze first half (0-5km) + let firstHalf = samples[0...5] + let result = RFCalculator.analyzePathSegment( + elevationProfile: firstHalf, + startHeightMeters: 50, // 50m antenna height for adequate Fresnel clearance + endHeightMeters: 50, + frequencyMHz: 906, + refractionK: 1.0 + ) + + #expect(result.distanceMeters == 5000) + #expect(result.clearanceStatus == .clear) // flat terrain with 50m antennas should be clear + } + + @Test + func `analyzePathSegment handles overlapping slice indices`() { + let samples = (0...10).map { i in + ElevationSample( + coordinate: CLLocationCoordinate2D(latitude: 37.0 + Double(i) * 0.01, longitude: -122.0), + elevation: 100, + distanceFromAMeters: Double(i) * 1000 + ) + } + + // Analyze from index 5 to 10 (second half, includes repeater at index 5) + let secondHalf = samples[5...10] + let result = RFCalculator.analyzePathSegment( + elevationProfile: secondHalf, + startHeightMeters: 10, + endHeightMeters: 10, + frequencyMHz: 906, + refractionK: 1.0 + ) + + #expect(result.distanceMeters == 5000) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/RFPathAnalysisCharacterizationTests.swift b/MC1Services/Tests/MC1ServicesTests/RFPathAnalysisCharacterizationTests.swift index e8fc7010..26ed3421 100644 --- a/MC1Services/Tests/MC1ServicesTests/RFPathAnalysisCharacterizationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RFPathAnalysisCharacterizationTests.swift @@ -1,270 +1,269 @@ import CoreLocation -import Testing @testable import MC1Services +import Testing /// Pins exact `analyzePath`/`analyzePathSegment` outputs for representative /// terrain profiles so any change to the shared analysis core is caught as a /// numeric regression, not just a status flip. @Suite("RF Path Analysis Characterization") struct RFPathAnalysisCharacterizationTests { - - // MARK: - Profile Builders - - private static func flatProfile( - elevation: Double, - totalDistance: Double, - count: Int - ) -> [ElevationSample] { - (0.. [ElevationSample] { - let midpoint = count / 2 - return (0.. [ElevationSample] { + (0.. [ElevationSample] { - (0.. [ElevationSample] { + let midpoint = count / 2 + return (0.. [ElevationSample] { + (0.. PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - // MARK: - radioID Propagation - - @Test("Migration propagates new radioID from Device to all children") - func radioIDPropagation() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - - // Create device with id = bleUUID; radioID will get UUID() default but we - // simulate the post-rename state where children have radioID == bleUUID. - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - let contact = ContactDTO.testContact(id: UUID(), radioID: bleUUID, name: "Alice") - try await store.saveContact(contact) - - let channel = ChannelDTO( - id: UUID(), - radioID: bleUUID, - index: 0, - name: "General", - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - notificationLevel: .all - ) - try await store.saveChannel(channel) - - let messageID = UUID() - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: bleUUID, - contactID: contact.id, - text: "Hello", - direction: .outgoing, - status: .sent - ) - try await store.saveMessage(message) - - try await store.performRadioIDMigration() - - // Device's radioID should be different from bleUUID - let fetchedDevice = try await store.fetchDevice(id: bleUUID) - #expect(fetchedDevice != nil) - let newRadioID = fetchedDevice!.radioID - #expect(newRadioID != bleUUID, "Device should have a new radioID, not the old BLE UUID") - - // All children should share the new radioID - let contacts = try await store.fetchContacts(radioID: newRadioID) - #expect(contacts.count == 1) - #expect(contacts.first?.radioID == newRadioID) - - let channels = try await store.fetchChannels(radioID: newRadioID) - #expect(channels.count == 1) - #expect(channels.first?.radioID == newRadioID) - - let fetchedMessage = try await store.fetchMessage(id: messageID) - #expect(fetchedMessage != nil) - #expect(fetchedMessage?.radioID == newRadioID) - } - - // MARK: - Dedup Key Backfill - - @Test("Outgoing DM with nil dedup key gets backfilled") - func outgoingDMGetsKey() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - let contactID = UUID() - let timestamp: UInt32 = 1_704_067_200 - let text = "Hello mesh" - let messageID = UUID() - - let message = MessageDTO( - id: messageID, - radioID: bleUUID, - contactID: contactID, - channelIndex: nil, - text: text, - timestamp: timestamp, - createdAt: Date(), - direction: .outgoing, - status: .sent, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0, - deduplicationKey: nil - ) - try await store.saveMessage(message) - - try await store.performRadioIDMigration() - - let fetchedMsg = try await store.fetchMessage(id: messageID) - #expect(fetchedMsg != nil) - - let key = fetchedMsg?.deduplicationKey - #expect(key != nil, "Outgoing message should have a dedup key after migration") - - // Verify format matches fallbackDeduplicationKey exactly - let expectedKey = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, - channelIndex: nil, - senderNodeName: nil, - timestamp: timestamp, - content: text - ) - #expect(key == expectedKey, "Dedup key format must match fallbackDeduplicationKey: got \(key ?? "nil"), expected \(expectedKey)") - } - - @Test("Incoming message with nil dedup key stays nil") - func incomingStaysNil() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - let messageID = UUID() - let message = MessageDTO( - id: messageID, - radioID: bleUUID, - contactID: UUID(), - channelIndex: nil, - text: "Incoming hello", - timestamp: 1_704_067_200, - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 1, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0, - deduplicationKey: nil - ) - try await store.saveMessage(message) - - try await store.performRadioIDMigration() - - let fetchedMsg = try await store.fetchMessage(id: messageID) - #expect(fetchedMsg != nil) - #expect(fetchedMsg?.deduplicationKey == nil, "Incoming message dedup key should stay nil") - } - - @Test("Existing dedup key is not overwritten") - func existingKeyUnchanged() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - let existingKey = "dm-existing-key-12345678" - let messageID = UUID() - let message = MessageDTO( - id: messageID, - radioID: bleUUID, - contactID: UUID(), - channelIndex: nil, - text: "Already keyed", - timestamp: 1_704_067_200, - createdAt: Date(), - direction: .outgoing, - status: .sent, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0, - deduplicationKey: existingKey - ) - try await store.saveMessage(message) - - try await store.performRadioIDMigration() - - let fetchedMsg = try await store.fetchMessage(id: messageID) - #expect(fetchedMsg != nil) - #expect(fetchedMsg?.deduplicationKey == existingKey, "Pre-existing dedup key must not be modified") - } - - @Test("UserDefaults guard prevents re-run") - func migrationRunsOnceOnly() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - let contact = ContactDTO.testContact(id: UUID(), radioID: bleUUID, name: "Bob") - try await store.saveContact(contact) - - // First run - try await store.performRadioIDMigration() - - let fetchedDevice = try await store.fetchDevice(id: bleUUID) - let firstRadioID = fetchedDevice!.radioID - #expect(firstRadioID != bleUUID) - - // Second run should be a no-op (guarded by UserDefaults) - try await store.performRadioIDMigration() - - let fetchedDeviceAgain = try await store.fetchDevice(id: bleUUID) - #expect(fetchedDeviceAgain!.radioID == firstRadioID, "Second migration run should not change radioID") - - let contacts = try await store.fetchContacts(radioID: firstRadioID) - #expect(contacts.count == 1, "Contact should still have the radioID from the first run") - } - - // MARK: - lastConnectedRadioID Backfill - - @Test("Migration backfills lastConnectedRadioID from lastConnectedDeviceID") - func migrationBackfillsLastConnectedRadioID() async throws { - let store = try await createTestStore() - await store.resetRadioIDMigrationFlag() - - let bleUUID = UUID() - let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) - try await store.saveDevice(device) - - // Simulate pre-upgrade state: lastConnectedDeviceID exists, lastConnectedRadioID does not - UserDefaults.standard.set(bleUUID.uuidString, forKey: "com.pocketmesh.lastConnectedDeviceID") - UserDefaults.standard.removeObject(forKey: "com.pocketmesh.lastConnectedRadioID") - - try await store.performRadioIDMigration() - - // lastConnectedRadioID should now be populated - let radioIDString = UserDefaults.standard.string(forKey: "com.pocketmesh.lastConnectedRadioID") - #expect(radioIDString != nil) - - // It should match the device's new radioID - let fetchedDevice = try await store.fetchDevice(id: bleUUID) - #expect(radioIDString == fetchedDevice?.radioID.uuidString) - - // Clean up - UserDefaults.standard.removeObject(forKey: "com.pocketmesh.lastConnectedDeviceID") - UserDefaults.standard.removeObject(forKey: "com.pocketmesh.lastConnectedRadioID") - } + // MARK: - Test Helpers + + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + // MARK: - radioID Propagation + + @Test + func `Migration propagates new radioID from Device to all children`() async throws { + let store = try await createTestStore() + await store.resetRadioIDMigrationFlag() + + let bleUUID = UUID() + + // Create device with id = bleUUID; radioID will get UUID() default but we + // simulate the post-rename state where children have radioID == bleUUID. + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + let contact = ContactDTO.testContact(id: UUID(), radioID: bleUUID, name: "Alice") + try await store.saveContact(contact) + + let channel = ChannelDTO( + id: UUID(), + radioID: bleUUID, + index: 0, + name: "General", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + notificationLevel: .all + ) + try await store.saveChannel(channel) + + let messageID = UUID() + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: bleUUID, + contactID: contact.id, + text: "Hello", + direction: .outgoing, + status: .sent + ) + try await store.saveMessage(message) + + try await store.performRadioIDMigration() + + // Device's radioID should be different from bleUUID + let fetchedDevice = try await store.fetchDevice(id: bleUUID) + #expect(fetchedDevice != nil) + let newRadioID = try #require(fetchedDevice?.radioID) + #expect(newRadioID != bleUUID, "Device should have a new radioID, not the old BLE UUID") + + // All children should share the new radioID + let contacts = try await store.fetchContacts(radioID: newRadioID) + #expect(contacts.count == 1) + #expect(contacts.first?.radioID == newRadioID) + + let channels = try await store.fetchChannels(radioID: newRadioID) + #expect(channels.count == 1) + #expect(channels.first?.radioID == newRadioID) + + let fetchedMessage = try await store.fetchMessage(id: messageID) + #expect(fetchedMessage != nil) + #expect(fetchedMessage?.radioID == newRadioID) + } + + // MARK: - Dedup Key Backfill + + @Test + func `Outgoing DM with nil dedup key gets backfilled`() async throws { + let store = try await createTestStore() + await store.resetRadioIDMigrationFlag() + + let bleUUID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + let contactID = UUID() + let timestamp: UInt32 = 1_704_067_200 + let text = "Hello mesh" + let messageID = UUID() + + let message = MessageDTO( + id: messageID, + radioID: bleUUID, + contactID: contactID, + channelIndex: nil, + text: text, + timestamp: timestamp, + createdAt: Date(), + direction: .outgoing, + status: .sent, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0, + deduplicationKey: nil + ) + try await store.saveMessage(message) + + try await store.performRadioIDMigration() + + let fetchedMsg = try await store.fetchMessage(id: messageID) + #expect(fetchedMsg != nil) + + let key = fetchedMsg?.deduplicationKey + #expect(key != nil, "Outgoing message should have a dedup key after migration") + + // Verify format matches fallbackDeduplicationKey exactly + let expectedKey = SyncCoordinator.fallbackDeduplicationKey( + contactID: contactID, + channelIndex: nil, + senderNodeName: nil, + timestamp: timestamp, + content: text + ) + #expect(key == expectedKey, "Dedup key format must match fallbackDeduplicationKey: got \(key ?? "nil"), expected \(expectedKey)") + } + + @Test + func `Incoming message with nil dedup key stays nil`() async throws { + let store = try await createTestStore() + await store.resetRadioIDMigrationFlag() + + let bleUUID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + let messageID = UUID() + let message = MessageDTO( + id: messageID, + radioID: bleUUID, + contactID: UUID(), + channelIndex: nil, + text: "Incoming hello", + timestamp: 1_704_067_200, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 1, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0, + deduplicationKey: nil + ) + try await store.saveMessage(message) + + try await store.performRadioIDMigration() + + let fetchedMsg = try await store.fetchMessage(id: messageID) + #expect(fetchedMsg != nil) + #expect(fetchedMsg?.deduplicationKey == nil, "Incoming message dedup key should stay nil") + } + + @Test + func `Existing dedup key is not overwritten`() async throws { + let store = try await createTestStore() + await store.resetRadioIDMigrationFlag() + + let bleUUID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + let existingKey = "dm-existing-key-12345678" + let messageID = UUID() + let message = MessageDTO( + id: messageID, + radioID: bleUUID, + contactID: UUID(), + channelIndex: nil, + text: "Already keyed", + timestamp: 1_704_067_200, + createdAt: Date(), + direction: .outgoing, + status: .sent, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0, + deduplicationKey: existingKey + ) + try await store.saveMessage(message) + + try await store.performRadioIDMigration() + + let fetchedMsg = try await store.fetchMessage(id: messageID) + #expect(fetchedMsg != nil) + #expect(fetchedMsg?.deduplicationKey == existingKey, "Pre-existing dedup key must not be modified") + } + + @Test + func `UserDefaults guard prevents re-run`() async throws { + let store = try await createTestStore() + await store.resetRadioIDMigrationFlag() + + let bleUUID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + let contact = ContactDTO.testContact(id: UUID(), radioID: bleUUID, name: "Bob") + try await store.saveContact(contact) + + // First run + try await store.performRadioIDMigration() + + let fetchedDevice = try await store.fetchDevice(id: bleUUID) + let firstRadioID = try #require(fetchedDevice?.radioID) + #expect(firstRadioID != bleUUID) + + // Second run should be a no-op (guarded by UserDefaults) + try await store.performRadioIDMigration() + + let fetchedDeviceAgain = try await store.fetchDevice(id: bleUUID) + #expect(fetchedDeviceAgain?.radioID == firstRadioID, "Second migration run should not change radioID") + + let contacts = try await store.fetchContacts(radioID: firstRadioID) + #expect(contacts.count == 1, "Contact should still have the radioID from the first run") + } + + // MARK: - lastConnectedRadioID Backfill + + @Test + func `Migration backfills lastConnectedRadioID from lastConnectedDeviceID`() async throws { + let store = try await createTestStore() + await store.resetRadioIDMigrationFlag() + + let bleUUID = UUID() + let device = DeviceDTO.testDevice(id: bleUUID, radioID: bleUUID) + try await store.saveDevice(device) + + // Simulate pre-upgrade state: lastConnectedDeviceID exists, lastConnectedRadioID does not + UserDefaults.standard.set(bleUUID.uuidString, forKey: "com.pocketmesh.lastConnectedDeviceID") + UserDefaults.standard.removeObject(forKey: "com.pocketmesh.lastConnectedRadioID") + + try await store.performRadioIDMigration() + + // lastConnectedRadioID should now be populated + let radioIDString = UserDefaults.standard.string(forKey: "com.pocketmesh.lastConnectedRadioID") + #expect(radioIDString != nil) + + // It should match the device's new radioID + let fetchedDevice = try await store.fetchDevice(id: bleUUID) + #expect(radioIDString == fetchedDevice?.radioID.uuidString) + + // Clean up + UserDefaults.standard.removeObject(forKey: "com.pocketmesh.lastConnectedDeviceID") + UserDefaults.standard.removeObject(forKey: "com.pocketmesh.lastConnectedRadioID") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift b/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift index 0a472d85..693a21b7 100644 --- a/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RadioPresetRecommendationTests.swift @@ -1,234 +1,231 @@ -import Testing @testable import MC1Services +import Testing @Suite("RadioPresets.recommended(for:)") struct RadioPresetRecommendationTests { - - // MARK: - Tier 0 (county) - - @Test("LA, CA → WCMesh") - func losAngelesGetsWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "los angeles", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "wcmesh") - } - - @Test("Sacramento (no countyKey match) → us-ca") - func sacramentoFallsToCountry() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "sacramento", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "us-ca") - } - - @Test("Manual California pick (countyKey nil) → us-ca") - func manualCaliforniaIsCountryTier() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) - #expect(RadioPresets.recommended(for: region)?.id == "us-ca") - } - - // MARK: - Tier 1 (sub-region) - - @Test("Queensland → au-qld") - func queenslandGetsAUQLD() { - let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-QLD", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "au-qld") - } - - @Test("Western Australia → au-sa-wa") - func westernAustraliaGetsAUSAWA() { - let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-WA", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "au-sa-wa") - } - - // MARK: - Tier 2 (country) - - @Test("Victoria, AU (no sub-region preset) → au-915 (Tier 2)") - func victoriaAUFallsToAU915() { - let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-VIC", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "au-915") - } - - @Test("Texas → us-ca") - func texasGetsUSCA() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "us-ca") - } - - @Test("Lisbon (PT) → pt-868 (priority 110 beats pt-433)") - func lisbonGetsPT868() { - let region = RegionSelection(countryCode: "PT", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "pt-868") - } - - @Test("Vietnam → vn-narrow (priority 110 beats deprecated vn)") - func vietnamGetsVNNarrow() { - let region = RegionSelection(countryCode: "VN", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "vn-narrow") - } - - @Test("Netherlands → nl (country tier beats EU continent)") - func netherlandsGetsNL() { - let region = RegionSelection(countryCode: "NL", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "nl") - } - - @Test("Chile → cl") - func chileGetsCL() { - let region = RegionSelection(countryCode: "CL", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "cl") - } - - @Test("Chile preset carries the expected radio parameters") - func chilePresetParameters() throws { - let preset = try #require(RadioPresets.all.first(where: { $0.id == "cl" })) - #expect(preset.frequencyMHz == 927.875) - #expect(preset.bandwidthKHz == 62.5) - #expect(preset.spreadingFactor == 8) - #expect(preset.codingRate == 5) - #expect(preset.region == .southAmerica) - } - - // MARK: - Tier 3 (continent) - - @Test("Berlin (DE) → eu-narrow (priority 110 beats eu-lr)") - func berlinGetsEUNarrow() { - let region = RegionSelection(countryCode: "DE", source: .location) - #expect(RadioPresets.recommended(for: region)?.id == "eu-narrow") - } - - // MARK: - No match - - @Test("Bermuda → nil (no continent mapping)") - func bermudaReturnsNil() { - let region = RegionSelection(countryCode: "BM", source: .manual) - #expect(RadioPresets.recommended(for: region) == nil) - } - - // MARK: - presets(for:) - - @Test("presets(for: Sacramento) includes wcmesh in alternatives") - func sacramentoAlternativesIncludeWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "sacramento", source: .location) - let ids = RadioPresets.presets(for: region).map(\.id) - #expect(ids.contains("wcmesh")) - #expect(ids.contains("us-ca")) - } - - @Test("presets(for: DE) returns continent-tier presets") - func presetsForDEReturnsContinentPresets() { - let region = RegionSelection(countryCode: "DE", source: .location) - let ids = RadioPresets.presets(for: region).map(\.id) - #expect(ids.contains("eu-narrow")) - #expect(ids.contains("eu-lr")) - #expect(!ids.contains("us-ca")) - } - - @Test("presets(for: PT) returns country-and-below only") - func presetsForPTSkipsContinent() { - let region = RegionSelection(countryCode: "PT", source: .location) - let ids = RadioPresets.presets(for: region).map(\.id) - #expect(ids.contains("pt-868")) - #expect(ids.contains("pt-433")) - #expect(!ids.contains("eu-narrow")) - } - - @Test("presets(for: VN) includes both vn-narrow and vn") - func presetsForVNIncludesBoth() { - let region = RegionSelection(countryCode: "VN", source: .location) - let ids = RadioPresets.presets(for: region).map(\.id) - #expect(ids.contains("vn-narrow")) - #expect(ids.contains("vn")) - } + // MARK: - Tier 0 (county) + + @Test + func `LA, CA → WCMesh`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "los angeles", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "wcmesh") + } + + @Test + func `Sacramento (no countyKey match) → us-ca`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "sacramento", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "us-ca") + } + + @Test + func `Manual California pick (countyKey nil) → us-ca`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) + #expect(RadioPresets.recommended(for: region)?.id == "us-ca") + } + + // MARK: - Tier 1 (sub-region) + + @Test + func `Queensland → au-qld`() { + let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-QLD", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "au-qld") + } + + @Test + func `Western Australia → au-sa-wa`() { + let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-WA", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "au-sa-wa") + } + + // MARK: - Tier 2 (country) + + @Test + func `Victoria, AU (no sub-region preset) → au-915 (Tier 2)`() { + let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-VIC", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "au-915") + } + + @Test + func `Texas → us-ca`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "us-ca") + } + + @Test + func `Lisbon (PT) → pt-868 (priority 110 beats pt-433)`() { + let region = RegionSelection(countryCode: "PT", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "pt-868") + } + + @Test + func `Vietnam → vn-narrow (priority 110 beats deprecated vn)`() { + let region = RegionSelection(countryCode: "VN", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "vn-narrow") + } + + @Test + func `Netherlands → nl (country tier beats EU continent)`() { + let region = RegionSelection(countryCode: "NL", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "nl") + } + + @Test + func `Chile → cl`() { + let region = RegionSelection(countryCode: "CL", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "cl") + } + + @Test + func `Chile preset carries the expected radio parameters`() throws { + let preset = try #require(RadioPresets.all.first(where: { $0.id == "cl" })) + #expect(preset.frequencyMHz == 927.875) + #expect(preset.bandwidthKHz == 62.5) + #expect(preset.spreadingFactor == 8) + #expect(preset.codingRate == 5) + #expect(preset.region == .southAmerica) + } + + // MARK: - Tier 3 (continent) + + @Test + func `Berlin (DE) → eu-narrow (priority 110 beats eu-lr)`() { + let region = RegionSelection(countryCode: "DE", source: .location) + #expect(RadioPresets.recommended(for: region)?.id == "eu-narrow") + } + + // MARK: - No match + + @Test + func `Bermuda → nil (no continent mapping)`() { + let region = RegionSelection(countryCode: "BM", source: .manual) + #expect(RadioPresets.recommended(for: region) == nil) + } + + // MARK: - presets(for:) + + @Test + func `presets(for: Sacramento) includes wcmesh in alternatives`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "sacramento", source: .location) + let ids = RadioPresets.presets(for: region).map(\.id) + #expect(ids.contains("wcmesh")) + #expect(ids.contains("us-ca")) + } + + @Test + func `presets(for: DE) returns continent-tier presets`() { + let region = RegionSelection(countryCode: "DE", source: .location) + let ids = RadioPresets.presets(for: region).map(\.id) + #expect(ids.contains("eu-narrow")) + #expect(ids.contains("eu-lr")) + #expect(!ids.contains("us-ca")) + } + + @Test + func `presets(for: PT) returns country-and-below only`() { + let region = RegionSelection(countryCode: "PT", source: .location) + let ids = RadioPresets.presets(for: region).map(\.id) + #expect(ids.contains("pt-868")) + #expect(ids.contains("pt-433")) + #expect(!ids.contains("eu-narrow")) + } + + @Test + func `presets(for: VN) includes both vn-narrow and vn`() { + let region = RegionSelection(countryCode: "VN", source: .location) + let ids = RadioPresets.presets(for: region).map(\.id) + #expect(ids.contains("vn-narrow")) + #expect(ids.contains("vn")) + } } @Suite("RadioPresets.isSelectable(_:in:)") struct RadioPresetSelectabilityTests { - - private func preset(_ id: String) -> RadioPreset { - guard let preset = RadioPresets.all.first(where: { $0.id == id }) else { - fatalError("missing preset \(id)") - } - return preset - } - - // MARK: - County-restricted (WCMesh) - - @Test("SoCal county → WCMesh selectable") - func socalShowsWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "los angeles", source: .location) - #expect(RadioPresets.isSelectable(preset("wcmesh"), in: region)) - } - - @Test("NorCal county → WCMesh hidden, us-ca still selectable") - func norcalHidesWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "sacramento", source: .location) - #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) - #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) - } - - @Test("California with no county → WCMesh hidden") - func californiaWithoutCountyHidesWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) - #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) - } - - @Test("Non-CA US state → WCMesh hidden, us-ca selectable") - func texasHidesWCMesh() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location) - #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) - #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) - } - - @Test("nil region → WCMesh hidden, global presets selectable") - func nilRegionHidesWCMesh() { - #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: nil)) - #expect(RadioPresets.isSelectable(preset("us-ca"), in: nil)) - #expect(RadioPresets.isSelectable(preset("eu-narrow"), in: nil)) - } - - // MARK: - Non-county presets are never gated - - @Test("Continent/country presets selectable for any region including nil") - func globalPresetsAlwaysSelectable() { - let regions: [RegionSelection?] = [ - nil, - RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location), - RegionSelection(countryCode: "DE", source: .location), - ] - for region in regions { - #expect(RadioPresets.isSelectable(preset("eu-narrow"), in: region)) - #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) - } - } + private func preset(_ id: String) -> RadioPreset { + guard let preset = RadioPresets.all.first(where: { $0.id == id }) else { + fatalError("missing preset \(id)") + } + return preset + } + + // MARK: - County-restricted (WCMesh) + + @Test + func `SoCal county → WCMesh selectable`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "los angeles", source: .location) + #expect(RadioPresets.isSelectable(preset("wcmesh"), in: region)) + } + + @Test + func `NorCal county → WCMesh hidden, us-ca still selectable`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "sacramento", source: .location) + #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) + #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) + } + + @Test + func `California with no county → WCMesh hidden`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) + #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) + } + + @Test + func `Non-CA US state → WCMesh hidden, us-ca selectable`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location) + #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: region)) + #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) + } + + @Test + func `nil region → WCMesh hidden, global presets selectable`() { + #expect(!RadioPresets.isSelectable(preset("wcmesh"), in: nil)) + #expect(RadioPresets.isSelectable(preset("us-ca"), in: nil)) + #expect(RadioPresets.isSelectable(preset("eu-narrow"), in: nil)) + } + + // MARK: - Non-county presets are never gated + + @Test + func `Continent/country presets selectable for any region including nil`() { + let regions: [RegionSelection?] = [ + nil, + RegionSelection(countryCode: "US", administrativeAreaCode: "US-TX", source: .location), + RegionSelection(countryCode: "DE", source: .location), + ] + for region in regions { + #expect(RadioPresets.isSelectable(preset("eu-narrow"), in: region)) + #expect(RadioPresets.isSelectable(preset("us-ca"), in: region)) + } + } } @Suite("RadioPreset protocol encoding") struct RadioPresetEncodingTests { - - private func preset(frequencyMHz: Double, bandwidthKHz: Double) -> RadioPreset { - RadioPreset( - id: "test", - name: "Test", - region: .northAmerica, - frequencyMHz: frequencyMHz, - bandwidthKHz: bandwidthKHz, - spreadingFactor: 7, - codingRate: 5, - availability: .continent(.northAmerica) - ) - } - - @Test("frequencyKHz rounds to the nearest kHz") - func frequencyRounds() { - // Truncation would yield 512001; rounding restores the representable 512002. - #expect(preset(frequencyMHz: 512.002, bandwidthKHz: 62.5).frequencyKHz == 512_002) - } - - @Test("bandwidthHz rounds to the nearest Hz") - func bandwidthRounds() { - #expect(preset(frequencyMHz: 915.0, bandwidthKHz: 62.501).bandwidthHz == 62_501) - } + private func preset(frequencyMHz: Double, bandwidthKHz: Double) -> RadioPreset { + RadioPreset( + id: "test", + name: "Test", + region: .northAmerica, + frequencyMHz: frequencyMHz, + bandwidthKHz: bandwidthKHz, + spreadingFactor: 7, + codingRate: 5, + availability: .continent(.northAmerica) + ) + } + + @Test + func `frequencyKHz rounds to the nearest kHz`() { + // Truncation would yield 512001; rounding restores the representable 512002. + #expect(preset(frequencyMHz: 512.002, bandwidthKHz: 62.5).frequencyKHz == 512_002) + } + + @Test + func `bandwidthHz rounds to the nearest Hz`() { + #expect(preset(frequencyMHz: 915.0, bandwidthKHz: 62.501).bandwidthHz == 62501) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/RegionalAreasTests.swift b/MC1Services/Tests/MC1ServicesTests/RegionalAreasTests.swift index ccc0cd1c..f9dbb0e9 100644 --- a/MC1Services/Tests/MC1ServicesTests/RegionalAreasTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RegionalAreasTests.swift @@ -1,94 +1,93 @@ -import Testing @testable import MC1Services +import Testing @Suite("RegionalAreas") struct RegionalAreasTests { - - @Test("matchSubdivision finds California from normalized state name") - func matchSubdivisionCalifornia() { - #expect(RegionalAreas.matchSubdivision(country: "US", normalized: "ca") == "US-CA") - } - - @Test("matchSubdivision finds Queensland from short suffix") - func matchSubdivisionQueensland() { - #expect(RegionalAreas.matchSubdivision(country: "AU", normalized: "qld") == "AU-QLD") - } - - @Test("matchSubdivision returns nil for unknown subdivision") - func matchSubdivisionUnknown() { - #expect(RegionalAreas.matchSubdivision(country: "US", normalized: "zz") == nil) - } - - @Test("matchSubdivision returns nil for nil input") - func matchSubdivisionNilInput() { - #expect(RegionalAreas.matchSubdivision(country: "US", normalized: nil) == nil) - } - - @Test("matchCounty finds Los Angeles in US-CA") - func matchCountyLosAngeles() { - #expect(RegionalAreas.matchCounty(country: "US", state: "US-CA", normalized: "los angeles") == "los angeles") - } - - @Test("matchCounty rejects unknown county") - func matchCountyUnknown() { - #expect(RegionalAreas.matchCounty(country: "US", state: "US-CA", normalized: "sacramento") == nil) - } - - @Test("matchCounty rejects non-US country") - func matchCountyNonUS() { - #expect(RegionalAreas.matchCounty(country: "CA", state: "CA-ON", normalized: "york") == nil) - } - - @Test("matchCounty rejects nil state") - func matchCountyNilState() { - #expect(RegionalAreas.matchCounty(country: "US", state: nil, normalized: "los angeles") == nil) - } - - @Test("continents map covers known European countries") - func continentsEurope() { - #expect(RegionalAreas.continents["DE"] == .europe) - #expect(RegionalAreas.continents["GB"] == .europe) - #expect(RegionalAreas.continents["PT"] == .europe) - } - - @Test("continents map covers Oceania and Asia") - func continentsOceaniaAsia() { - #expect(RegionalAreas.continents["AU"] == .oceania) - #expect(RegionalAreas.continents["NZ"] == .oceania) - #expect(RegionalAreas.continents["VN"] == .asia) - } - - @Test("Mexico is intentionally absent from continents") - func continentsMexicoAbsent() { - #expect(RegionalAreas.continents["MX"] == nil) - } - - @Test("displayName uses short form for US states") - func displayNameUSShort() { - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) - #expect(RegionalAreas.displayName(for: region) == "California") - } - - @Test("displayName uses disambiguated form for AU territories") - func displayNameAUDisambiguated() { - let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-QLD", source: .manual) - let name = RegionalAreas.displayName(for: region) - #expect(name.contains("Queensland")) - #expect(name.contains("Australia")) - } - - @Test("displayName falls back to country name when admin is nil") - func displayNameCountryOnly() { - let region = RegionSelection(countryCode: "US", source: .manual) - #expect(RegionalAreas.displayName(for: region) == "United States") - } - - @Test("continents and countries cover the same set of country codes") - func continentsCountriesAlignment() { - // Adding a country to one table without the other silently breaks the picker - // (visible but no recommendation) or the recommendation (no picker entry). - let continentKeys = Set(RegionalAreas.continents.keys) - let countryIDs = Set(RegionalAreas.countries.map(\.id)) - #expect(continentKeys == countryIDs) - } + @Test + func `matchSubdivision finds California from normalized state name`() { + #expect(RegionalAreas.matchSubdivision(country: "US", normalized: "ca") == "US-CA") + } + + @Test + func `matchSubdivision finds Queensland from short suffix`() { + #expect(RegionalAreas.matchSubdivision(country: "AU", normalized: "qld") == "AU-QLD") + } + + @Test + func `matchSubdivision returns nil for unknown subdivision`() { + #expect(RegionalAreas.matchSubdivision(country: "US", normalized: "zz") == nil) + } + + @Test + func `matchSubdivision returns nil for nil input`() { + #expect(RegionalAreas.matchSubdivision(country: "US", normalized: nil) == nil) + } + + @Test + func `matchCounty finds Los Angeles in US-CA`() { + #expect(RegionalAreas.matchCounty(country: "US", state: "US-CA", normalized: "los angeles") == "los angeles") + } + + @Test + func `matchCounty rejects unknown county`() { + #expect(RegionalAreas.matchCounty(country: "US", state: "US-CA", normalized: "sacramento") == nil) + } + + @Test + func `matchCounty rejects non-US country`() { + #expect(RegionalAreas.matchCounty(country: "CA", state: "CA-ON", normalized: "york") == nil) + } + + @Test + func `matchCounty rejects nil state`() { + #expect(RegionalAreas.matchCounty(country: "US", state: nil, normalized: "los angeles") == nil) + } + + @Test + func `continents map covers known European countries`() { + #expect(RegionalAreas.continents["DE"] == .europe) + #expect(RegionalAreas.continents["GB"] == .europe) + #expect(RegionalAreas.continents["PT"] == .europe) + } + + @Test + func `continents map covers Oceania and Asia`() { + #expect(RegionalAreas.continents["AU"] == .oceania) + #expect(RegionalAreas.continents["NZ"] == .oceania) + #expect(RegionalAreas.continents["VN"] == .asia) + } + + @Test + func `Mexico is intentionally absent from continents`() { + #expect(RegionalAreas.continents["MX"] == nil) + } + + @Test + func `displayName uses short form for US states`() { + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", source: .manual) + #expect(RegionalAreas.displayName(for: region) == "California") + } + + @Test + func `displayName uses disambiguated form for AU territories`() { + let region = RegionSelection(countryCode: "AU", administrativeAreaCode: "AU-QLD", source: .manual) + let name = RegionalAreas.displayName(for: region) + #expect(name.contains("Queensland")) + #expect(name.contains("Australia")) + } + + @Test + func `displayName falls back to country name when admin is nil`() { + let region = RegionSelection(countryCode: "US", source: .manual) + #expect(RegionalAreas.displayName(for: region) == "United States") + } + + @Test + func `continents and countries cover the same set of country codes`() { + // Adding a country to one table without the other silently breaks the picker + // (visible but no recommendation) or the recommendation (no picker entry). + let continentKeys = Set(RegionalAreas.continents.keys) + let countryIDs = Set(RegionalAreas.countries.map(\.id)) + #expect(continentKeys == countryIDs) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift b/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift index be9dafbd..ce12b4cd 100644 --- a/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift @@ -1,132 +1,131 @@ import Foundation +@testable import MC1Services +import MeshCore import SwiftData import Testing -import MeshCore -@testable import MC1Services @Suite("Repeater unread-count corrective migration", .serialized) struct RepeaterUnreadMigrationTests { - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private func makeContactDTO( - radioID: UUID, - type: ContactType, - unreadCount: Int, - unreadMentionCount: Int = 0, - name: String = "Contact" - ) -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: radioID, - publicKey: Data((0.. RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: UUID(), - radioID: radioID, - publicKey: Data((0.. PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func makeContactDTO( + radioID: UUID, + type: ContactType, + unreadCount: Int, + unreadMentionCount: Int = 0, + name: String = "Contact" + ) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0.. RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0.. clear - let bothClear = RelayPathAnalysisResult( - segmentAR: SegmentAnalysisResult( - startLabel: "A", endLabel: "R", clearanceStatus: .clear, distanceMeters: 1000, - worstClearancePercent: 90), - segmentRB: SegmentAnalysisResult( - startLabel: "R", endLabel: "B", clearanceStatus: .clear, distanceMeters: 1000, - worstClearancePercent: 85) - ) - #expect(bothClear.overallStatus == .clear) + @Test + func `RelayPathAnalysisResult overall status is worst of segments`() { + // Both clear -> clear + let bothClear = RelayPathAnalysisResult( + segmentAR: SegmentAnalysisResult( + startLabel: "A", endLabel: "R", clearanceStatus: .clear, distanceMeters: 1000, + worstClearancePercent: 90 + ), + segmentRB: SegmentAnalysisResult( + startLabel: "R", endLabel: "B", clearanceStatus: .clear, distanceMeters: 1000, + worstClearancePercent: 85 + ) + ) + #expect(bothClear.overallStatus == .clear) - // One blocked -> blocked - let oneBlocked = RelayPathAnalysisResult( - segmentAR: SegmentAnalysisResult( - startLabel: "A", endLabel: "R", clearanceStatus: .clear, distanceMeters: 1000, - worstClearancePercent: 90), - segmentRB: SegmentAnalysisResult( - startLabel: "R", endLabel: "B", clearanceStatus: .blocked, distanceMeters: 1000, - worstClearancePercent: -10) - ) - #expect(oneBlocked.overallStatus == .blocked) - } + // One blocked -> blocked + let oneBlocked = RelayPathAnalysisResult( + segmentAR: SegmentAnalysisResult( + startLabel: "A", endLabel: "R", clearanceStatus: .clear, distanceMeters: 1000, + worstClearancePercent: 90 + ), + segmentRB: SegmentAnalysisResult( + startLabel: "R", endLabel: "B", clearanceStatus: .blocked, distanceMeters: 1000, + worstClearancePercent: -10 + ) + ) + #expect(oneBlocked.overallStatus == .blocked) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/ServiceContainerWiringTests.swift b/MC1Services/Tests/MC1ServicesTests/ServiceContainerWiringTests.swift index 856996d6..3bb9b4ad 100644 --- a/MC1Services/Tests/MC1ServicesTests/ServiceContainerWiringTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ServiceContainerWiringTests.swift @@ -1,125 +1,124 @@ -import Testing import Foundation -import MeshCore @testable import MC1Services +import MeshCore +import Testing @Suite("ServiceContainer Wiring Tests") struct ServiceContainerWiringTests { - - /// Creates a ServiceContainer using the test factory. - @MainActor - private func makeContainer() async throws -> ServiceContainer { - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - return try await ServiceContainer.forTesting(session: session) + /// Creates a ServiceContainer using the test factory. + @MainActor + private func makeContainer() async throws -> ServiceContainer { + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + return try await ServiceContainer.forTesting(session: session) + } + + @Test + @MainActor + func `init establishes all 6 cross-service connections`() async throws { + let container = try await makeContainer() + + // 1. messageService → contactService + let hasContact = await container.messageService.hasContactServiceWired + #expect(hasContact, "messageService should have contactService injected") + + // 2. contactService → syncCoordinator + let hasContactSync = await container.contactService.hasSyncCoordinatorWired + #expect(hasContactSync, "contactService should have syncCoordinator injected") + + // 3. nodeConfigService → syncCoordinator + let hasNodeSync = await container.nodeConfigService.hasSyncCoordinatorWired + #expect(hasNodeSync, "nodeConfigService should have syncCoordinator injected") + + // 4. contactService → cleanup coordinator + let hasCleanup = await container.contactService.hasCleanupCoordinatorWired + #expect(hasCleanup, "contactService should have cleanupCoordinator injected") + + // 5. channelService → rxLogService + let hasRxLog = await container.channelService.hasRxLogServiceWired + #expect(hasRxLog, "channelService should have rxLogService injected") + + // 6. rxLogService → heardRepeatsService + let hasHeardRepeats = await container.rxLogService.hasHeardRepeatsServiceWired + #expect(hasHeardRepeats, "rxLogService should have heardRepeatsService injected") + } + + @Test + @MainActor + func `tearDown clears the wired message and discovery handlers`() async throws { + let container = try await makeContainer() + let radioID = UUID() + try await container.dataStore.saveDevice( + DeviceDTO.testDevice(id: radioID, radioID: radioID) + ) + + await container.syncCoordinator.wireMessageHandlers(dependencies: container.syncDependencies, radioID: radioID) + await container.syncCoordinator.startDiscoveryEventMonitoring(dependencies: container.syncDependencies, radioID: radioID) + + #expect(await container.messagePollingService.hasMessageHandlersWired) + #expect(container.advertisementService.eventBroadcaster.subscriberCount > 0, + "startDiscoveryEventMonitoring must subscribe to advertisement events") + + await container.tearDown() + + #expect(await container.messagePollingService.hasMessageHandlersWired == false, + "tearDown must clear message handlers to break the container retain cycle") + #expect(container.advertisementService.eventBroadcaster.subscriberCount == 0, + "tearDown must end every advertisement event subscription to break the container retain cycle") + } + + @Test + @MainActor + func `tearDown clears the notification action forwarders that capture the handler`() async throws { + let container = try await makeContainer() + let handler = container.notificationActionHandler + + // Mirror AppState's forwarders: each captures the handler strongly, so a + // dropped nil-out leaves the notificationService <-> handler cycle alive. + container.notificationService.onQuickReply = { contactID, text in + await handler.handleQuickReply(contactID: contactID, text: text) } - - @Test("init establishes all 6 cross-service connections") - @MainActor - func initEstablishesAllConnections() async throws { - let container = try await makeContainer() - - // 1. messageService → contactService - let hasContact = await container.messageService.hasContactServiceWired - #expect(hasContact, "messageService should have contactService injected") - - // 2. contactService → syncCoordinator - let hasContactSync = await container.contactService.hasSyncCoordinatorWired - #expect(hasContactSync, "contactService should have syncCoordinator injected") - - // 3. nodeConfigService → syncCoordinator - let hasNodeSync = await container.nodeConfigService.hasSyncCoordinatorWired - #expect(hasNodeSync, "nodeConfigService should have syncCoordinator injected") - - // 4. contactService → cleanup coordinator - let hasCleanup = await container.contactService.hasCleanupCoordinatorWired - #expect(hasCleanup, "contactService should have cleanupCoordinator injected") - - // 5. channelService → rxLogService - let hasRxLog = await container.channelService.hasRxLogServiceWired - #expect(hasRxLog, "channelService should have rxLogService injected") - - // 6. rxLogService → heardRepeatsService - let hasHeardRepeats = await container.rxLogService.hasHeardRepeatsServiceWired - #expect(hasHeardRepeats, "rxLogService should have heardRepeatsService injected") + container.notificationService.onChannelQuickReply = { radioID, channelIndex, text in + await handler.handleChannelQuickReply(radioID: radioID, channelIndex: channelIndex, text: text) } - - @Test("tearDown clears the wired message and discovery handlers") - @MainActor - func tearDownClearsWiredHandlers() async throws { - let container = try await makeContainer() - let radioID = UUID() - try await container.dataStore.saveDevice( - DeviceDTO.testDevice(id: radioID, radioID: radioID) - ) - - await container.syncCoordinator.wireMessageHandlers(dependencies: container.syncDependencies, radioID: radioID) - await container.syncCoordinator.startDiscoveryEventMonitoring(dependencies: container.syncDependencies, radioID: radioID) - - #expect(await container.messagePollingService.hasMessageHandlersWired) - #expect(container.advertisementService.eventBroadcaster.subscriberCount > 0, - "startDiscoveryEventMonitoring must subscribe to advertisement events") - - await container.tearDown() - - #expect(await container.messagePollingService.hasMessageHandlersWired == false, - "tearDown must clear message handlers to break the container retain cycle") - #expect(container.advertisementService.eventBroadcaster.subscriberCount == 0, - "tearDown must end every advertisement event subscription to break the container retain cycle") + container.notificationService.onMarkAsRead = { contactID, messageID in + await handler.handleMarkAsRead(contactID: contactID, messageID: messageID) } - - @Test("tearDown clears the notification action forwarders that capture the handler") - @MainActor - func tearDownClearsNotificationActionForwarders() async throws { - let container = try await makeContainer() - let handler = container.notificationActionHandler - - // Mirror AppState's forwarders: each captures the handler strongly, so a - // dropped nil-out leaves the notificationService <-> handler cycle alive. - container.notificationService.onQuickReply = { contactID, text in - await handler.handleQuickReply(contactID: contactID, text: text) - } - container.notificationService.onChannelQuickReply = { radioID, channelIndex, text in - await handler.handleChannelQuickReply(radioID: radioID, channelIndex: channelIndex, text: text) - } - container.notificationService.onMarkAsRead = { contactID, messageID in - await handler.handleMarkAsRead(contactID: contactID, messageID: messageID) - } - container.notificationService.onChannelMarkAsRead = { radioID, channelIndex, messageID in - await handler.handleChannelMarkAsRead(radioID: radioID, channelIndex: channelIndex, messageID: messageID) - } - container.notificationService.onRoomMarkAsRead = { sessionID, messageID in - await handler.handleRoomMarkAsRead(sessionID: sessionID, messageID: messageID) - } - - await container.tearDown() - - #expect(container.notificationService.onQuickReply == nil) - #expect(container.notificationService.onChannelQuickReply == nil) - #expect(container.notificationService.onMarkAsRead == nil) - #expect(container.notificationService.onChannelMarkAsRead == nil) - #expect(container.notificationService.onRoomMarkAsRead == nil) + container.notificationService.onChannelMarkAsRead = { radioID, channelIndex, messageID in + await handler.handleChannelMarkAsRead(radioID: radioID, channelIndex: channelIndex, messageID: messageID) } - - @Test("startEventMonitoring activates ACK expiry checker; stopEventMonitoring deactivates it") - @MainActor - func startEventMonitoringActivatesAckChecking() async throws { - let container = try await makeContainer() - let radioID = UUID() - try await container.dataStore.saveDevice( - DeviceDTO.testDevice(id: radioID, radioID: radioID) - ) - - #expect(await container.messageService.isAckExpiryCheckingActive == false) - - await container.startEventMonitoring( - radioID: radioID, - enableAutoFetch: false, - enableAdvertisementMonitoring: false - ) - #expect(await container.messageService.isAckExpiryCheckingActive == true) - - await container.stopEventMonitoring() - #expect(await container.messageService.isAckExpiryCheckingActive == false) + container.notificationService.onRoomMarkAsRead = { sessionID, messageID in + await handler.handleRoomMarkAsRead(sessionID: sessionID, messageID: messageID) } + + await container.tearDown() + + #expect(container.notificationService.onQuickReply == nil) + #expect(container.notificationService.onChannelQuickReply == nil) + #expect(container.notificationService.onMarkAsRead == nil) + #expect(container.notificationService.onChannelMarkAsRead == nil) + #expect(container.notificationService.onRoomMarkAsRead == nil) + } + + @Test + @MainActor + func `startEventMonitoring activates ACK expiry checker; stopEventMonitoring deactivates it`() async throws { + let container = try await makeContainer() + let radioID = UUID() + try await container.dataStore.saveDevice( + DeviceDTO.testDevice(id: radioID, radioID: radioID) + ) + + #expect(await container.messageService.isAckExpiryCheckingActive == false) + + await container.startEventMonitoring( + radioID: radioID, + enableAutoFetch: false, + enableAdvertisementMonitoring: false + ) + #expect(await container.messageService.isAckExpiryCheckingActive == true) + + await container.stopEventMonitoring() + #expect(await container.messageService.isAckExpiryCheckingActive == false) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/AccessorySetupKitDiscoveryCriteriaTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/AccessorySetupKitDiscoveryCriteriaTests.swift index 310a466a..ef29964f 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/AccessorySetupKitDiscoveryCriteriaTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/AccessorySetupKitDiscoveryCriteriaTests.swift @@ -1,43 +1,42 @@ -import Testing @testable import MC1Services +import Testing @Suite("AccessorySetupKit Discovery Criteria Tests") struct AccessorySetupKitDiscoveryCriteriaTests { + @Test + func `supported Bluetooth name substrings match shipped MeshCore families`() { + #expect( + AccessorySetupKitDiscoveryCriteria.bluetoothNameSubstrings == [ + "MeshCore-", + "Whisper-", + "WisCore", + "XIAO", + "elecrow", + "HT-n5262", + "Seeed", + "BQ", + "ProMicro", + "Keepteen", + "Meshtiny", + "T1000-E-BOOT", + "me25ls01-BOOT", + "NRF52 DK", + "T-Impulse", + ] + ) + } - @Test("supported Bluetooth name substrings match shipped MeshCore families") - func supportedBluetoothNameSubstrings() { - #expect( - AccessorySetupKitDiscoveryCriteria.bluetoothNameSubstrings == [ - "MeshCore-", - "Whisper-", - "WisCore", - "XIAO", - "elecrow", - "HT-n5262", - "Seeed", - "BQ", - "ProMicro", - "Keepteen", - "Meshtiny", - "T1000-E-BOOT", - "me25ls01-BOOT", - "NRF52 DK", - "T-Impulse", - ] - ) - } - - @Test("all discovery criteria use the Nordic UART service UUID") - func allDiscoveryCriteriaUseNordicUART() { - let criteria = AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria + @Test + func `all discovery criteria use the Nordic UART service UUID`() { + let criteria = AccessorySetupKitDiscoveryCriteria.supportedBluetoothCriteria - #expect(criteria.count == AccessorySetupKitDiscoveryCriteria.bluetoothNameSubstrings.count) - #expect(criteria.allSatisfy { $0.bluetoothServiceUUID == BLEServiceUUID.nordicUART }) - #expect(Set(criteria.map(\.bluetoothNameSubstring)).count == criteria.count) - } + #expect(criteria.count == AccessorySetupKitDiscoveryCriteria.bluetoothNameSubstrings.count) + #expect(criteria.allSatisfy { $0.bluetoothServiceUUID == BLEServiceUUID.nordicUART }) + #expect(Set(criteria.map(\.bluetoothNameSubstring)).count == criteria.count) + } - @Test("picker opts into filtered discovery so matches can be relabeled with advertised names") - func pickerUsesFilteredDiscovery() { - #expect(AccessorySetupKitDiscoveryCriteria.usesFilteredDiscovery == true) - } + @Test + func `picker opts into filtered discovery so matches can be relabeled with advertised names`() { + #expect(AccessorySetupKitDiscoveryCriteria.usesFilteredDiscovery == true) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/AckCodeBuilderTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/AckCodeBuilderTests.swift index 87b89a19..b8d8e85c 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/AckCodeBuilderTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/AckCodeBuilderTests.swift @@ -1,65 +1,64 @@ import CryptoKit import Foundation -import Testing @testable import MC1Services +import Testing @Suite("AckCodeBuilder.expectedAck") struct AckCodeBuilderTests { + /// Golden vector reproduced in-test from the firmware formula: + /// sha256( LE32(timestamp) || byte(attempt & 0x03) || text || pubkey ) + /// and take bytes [0..3]. + @Test + func `matches firmware formula for known fixture`() { + let timestamp: UInt32 = 0x6624_AABB + let attempt: UInt8 = 2 + let text = "hello" + let pubkey = Data(repeating: 0xAA, count: 32) - // Golden vector reproduced in-test from the firmware formula: - // sha256( LE32(timestamp) || byte(attempt & 0x03) || text || pubkey ) - // and take bytes [0..3]. - @Test("matches firmware formula for known fixture") - func goldenVector() { - let timestamp: UInt32 = 0x6624_AABB - let attempt: UInt8 = 2 - let text = "hello" - let pubkey = Data(repeating: 0xAA, count: 32) - - let code = AckCodeBuilder.expectedAck( - timestamp: timestamp, - attempt: attempt, - text: text, - senderPublicKey: pubkey - ) + let code = AckCodeBuilder.expectedAck( + timestamp: timestamp, + attempt: attempt, + text: text, + senderPublicKey: pubkey + ) - var input = Data() - var ts = timestamp.littleEndian - withUnsafeBytes(of: &ts) { input.append(contentsOf: $0) } - input.append(attempt & 0x03) - input.append(contentsOf: text.utf8) - input.append(pubkey) - let expected = Data(SHA256.hash(data: input).prefix(4)) + var input = Data() + var ts = timestamp.littleEndian + withUnsafeBytes(of: &ts) { input.append(contentsOf: $0) } + input.append(attempt & 0x03) + input.append(contentsOf: text.utf8) + input.append(pubkey) + let expected = Data(SHA256.hash(data: input).prefix(4)) - #expect(code == expected) - } + #expect(code == expected) + } - @Test("different texts produce different codes") - func differentTextDifferentCode() { - let pubkey = Data(repeating: 0x01, count: 32) - let a = AckCodeBuilder.expectedAck(timestamp: 1, attempt: 0, text: "hi", senderPublicKey: pubkey) - let b = AckCodeBuilder.expectedAck(timestamp: 1, attempt: 0, text: "bye", senderPublicKey: pubkey) - #expect(a != b) - } + @Test + func `different texts produce different codes`() { + let pubkey = Data(repeating: 0x01, count: 32) + let a = AckCodeBuilder.expectedAck(timestamp: 1, attempt: 0, text: "hi", senderPublicKey: pubkey) + let b = AckCodeBuilder.expectedAck(timestamp: 1, attempt: 0, text: "bye", senderPublicKey: pubkey) + #expect(a != b) + } - @Test("attempts 0..3 produce four distinct codes") - func directAttemptsDistinct() { - let pubkey = Data(repeating: 0x02, count: 32) - let codes = (0..<4).map { - AckCodeBuilder.expectedAck(timestamp: 100, attempt: UInt8($0), text: "hi", senderPublicKey: pubkey) - } - #expect(Set(codes).count == 4) + @Test + func `attempts 0..3 produce four distinct codes`() { + let pubkey = Data(repeating: 0x02, count: 32) + let codes = (0..<4).map { + AckCodeBuilder.expectedAck(timestamp: 100, attempt: UInt8($0), text: "hi", senderPublicKey: pubkey) } + #expect(Set(codes).count == 4) + } - // The firmware masks the attempt index with & 0x03, so the flood attempt - // (index 4) intentionally reuses attempt 0's code. This is safe: a single - // message accumulates its codes in a Set, so the wrap is a no-op re-add and - // any returned ACK still matches the right message. - @Test("attempt 4 wraps to attempt 0's code") - func floodAttemptWrapsToAttemptZero() { - let pubkey = Data(repeating: 0x03, count: 32) - let attempt0 = AckCodeBuilder.expectedAck(timestamp: 100, attempt: 0, text: "hi", senderPublicKey: pubkey) - let attempt4 = AckCodeBuilder.expectedAck(timestamp: 100, attempt: 4, text: "hi", senderPublicKey: pubkey) - #expect(attempt0 == attempt4) - } + /// The firmware masks the attempt index with & 0x03, so the flood attempt + /// (index 4) intentionally reuses attempt 0's code. This is safe: a single + /// message accumulates its codes in a Set, so the wrap is a no-op re-add and + /// any returned ACK still matches the right message. + @Test + func `attempt 4 wraps to attempt 0's code`() { + let pubkey = Data(repeating: 0x03, count: 32) + let attempt0 = AckCodeBuilder.expectedAck(timestamp: 100, attempt: 0, text: "hi", senderPublicKey: pubkey) + let attempt4 = AckCodeBuilder.expectedAck(timestamp: 100, attempt: 4, text: "hi", senderPublicKey: pubkey) + #expect(attempt0 == attempt4) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/BLETransportOpenedSignalTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/BLETransportOpenedSignalTests.swift index 9483be0a..da3e7f9c 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/BLETransportOpenedSignalTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/BLETransportOpenedSignalTests.swift @@ -1,85 +1,84 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("BLETransportOpenedSignal") struct BLETransportOpenedSignalTests { + @Test + func `wait returns immediately when signal already armed`() async throws { + let triggers = BLETransportOpenedSignal() + await triggers.fire() + try await triggers.wait() + } - @Test("wait returns immediately when signal already armed") - func waitReturnsImmediately() async throws { - let triggers = BLETransportOpenedSignal() - await triggers.fire() - try await triggers.wait() + @Test + func `wait suspends until fire`() async throws { + let triggers = BLETransportOpenedSignal() + let waiting = Task { + try await triggers.wait() } + try? await Task.sleep(for: .milliseconds(50)) + await triggers.fire() + try await waiting.value + } - @Test("wait suspends until fire") - func waitSuspendsUntilFire() async throws { - let triggers = BLETransportOpenedSignal() - let waiting = Task { - try await triggers.wait() - } - try? await Task.sleep(for: .milliseconds(50)) - await triggers.fire() - try await waiting.value + @Test + func `clear drops armed-pending signal`() async throws { + let triggers = BLETransportOpenedSignal() + await triggers.fire() + await triggers.clear() + let waiting = Task { + try await triggers.wait() } + try? await Task.sleep(for: .milliseconds(20)) + await triggers.fire() + try await waiting.value + } - @Test("clear drops armed-pending signal") - func clearDropsArmed() async throws { - let triggers = BLETransportOpenedSignal() - await triggers.fire() - await triggers.clear() - let waiting = Task { - try await triggers.wait() - } - try? await Task.sleep(for: .milliseconds(20)) - await triggers.fire() - try await waiting.value + /// A `wait` whose calling task is cancelled before fire lands must + /// throw `CancellationError` rather than leaking the continuation. + @Test + func `wait throws CancellationError when calling task is cancelled`() async { + let triggers = BLETransportOpenedSignal() + let task = Task { + try await triggers.wait() } + try? await Task.sleep(for: .milliseconds(50)) + task.cancel() - /// A `wait` whose calling task is cancelled before fire lands must - /// throw `CancellationError` rather than leaking the continuation. - @Test("wait throws CancellationError when calling task is cancelled") - func waitThrowsOnCancellation() async { - let triggers = BLETransportOpenedSignal() - let task = Task { - try await triggers.wait() - } - try? await Task.sleep(for: .milliseconds(50)) - task.cancel() - - do { - try await task.value - Issue.record("wait should have thrown CancellationError") - } catch is CancellationError { - // expected - } catch { - Issue.record("wait threw unexpected error: \(error)") - } + do { + try await task.value + Issue.record("wait should have thrown CancellationError") + } catch is CancellationError { + // expected + } catch { + Issue.record("wait threw unexpected error: \(error)") } + } - /// Regression: after a cancellation removes a waiter, a subsequent - /// `fire()` must not double-resume that continuation. The cancellation - /// path removes its own waiter before throwing, so `fire()` sees a - /// clean waiter list. The fired signal is preserved as armed for the - /// next caller (no waiter consumed it). - @Test("fire after cancellation does not double-resume") - func fireAfterCancellationDoesNotDoubleResume() async throws { - let triggers = BLETransportOpenedSignal() - let task = Task { - try await triggers.wait() - } - try? await Task.sleep(for: .milliseconds(50)) - task.cancel() - _ = try? await task.value + /// Regression: after a cancellation removes a waiter, a subsequent + /// `fire()` must not double-resume that continuation. The cancellation + /// path removes its own waiter before throwing, so `fire()` sees a + /// clean waiter list. The fired signal is preserved as armed for the + /// next caller (no waiter consumed it). + @Test + func `fire after cancellation does not double-resume`() async throws { + let triggers = BLETransportOpenedSignal() + let task = Task { + try await triggers.wait() + } + try? await Task.sleep(for: .milliseconds(50)) + task.cancel() + _ = try? await task.value - // If `fire` tried to resume the already-cancelled continuation, - // `withCheckedThrowingContinuation` would trap (precondition: resume - // exactly once). Reaching the next assertion at all proves no - // double-resume happened. - await triggers.fire() + // If `fire` tried to resume the already-cancelled continuation, + // `withCheckedThrowingContinuation` would trap (precondition: resume + // exactly once). Reaching the next assertion at all proves no + // double-resume happened. + await triggers.fire() - // Signal should be armed for the next caller since no waiter - // consumed it. - try await triggers.wait() - } + // Signal should be armed for the next caller since no waiter + // consumed it. + try await triggers.wait() + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChannelServicePipelineTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChannelServicePipelineTests.swift index 3ed70c79..5a87c5cc 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChannelServicePipelineTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChannelServicePipelineTests.swift @@ -1,338 +1,337 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing /// Service-layer validation gates for the pipelined channel sync path (real session over a /// MockTransport): #1 correctness with gaps, #2 mid-burst disconnect (throw and partial-drain /// sub-cases), #4 drop-reconcile, plus serial-path parity. @Suite("ChannelService pipelined sync") struct ChannelServicePipelineTests { + // MARK: - Gate #1: correctness with gaps + + @Test + func `pipelined sync persists non-contiguous configured channels to their own slots`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + let transport = MockTransport() + await transport.setSupportsWriteWithoutResponse(true) + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + + let syncTask = Task { + try await service.syncChannels(radioID: radioID, maxChannels: 8, usePipelinedRead: true) + } - // MARK: - Gate #1: correctness with gaps - - @Test("pipelined sync persists non-contiguous configured channels to their own slots") - func pipelinedSyncPersistsGapsToCorrectSlots() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - let transport = MockTransport() - await transport.setSupportsWriteWithoutResponse(true) - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - - let syncTask = Task { - try await service.syncChannels(radioID: radioID, maxChannels: 8, usePipelinedRead: true) - } - - try await waitUntil("all eight channel reads should be primed") { - await transport.sentData.count == 9 // appStart + 8 reads - } - - let configuredIndices: Set = [0, 2, 7] - for index in UInt8(0)..<8 { - if configuredIndices.contains(index) { - await transport.simulateReceive( - makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) - ) - } else { - await transport.simulateReceive( - makeChannelInfoPacket(index: index, name: "", secret: Data(repeating: 0, count: 16)) - ) - } - } - - let result = try await syncTask.value - #expect(result.channelsSynced == 3) - - let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } - #expect(stored.map(\.index) == [0, 2, 7]) - #expect(stored.first { $0.index == 0 }?.name == "ch0") - #expect(stored.first { $0.index == 2 }?.name == "ch2") - #expect(stored.first { $0.index == 7 }?.name == "ch7") + try await waitUntil("all eight channel reads should be primed") { + await transport.sentData.count == 9 // appStart + 8 reads } - // MARK: - Gate #4: drop-reconcile - - @Test("a dropped write is reconciled with a serial read and persisted") - func droppedWriteIsReconciledAndPersisted() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) - let transport = MockTransport() - await transport.setSupportsWriteWithoutResponse(true) - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - - let syncTask = Task { - try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) - } - - try await waitUntil("all four reads should be primed") { - await transport.sentData.count == 5 - } - - // Index 2 is never answered in the pipeline (simulated dropped Write Command). - for index: UInt8 in [0, 1, 3] { - await transport.simulateReceive( - makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) - ) - } - - // After the idle timeout, the service reconciles index 2 with a serial read. - try await waitUntil("reconcile should issue a serial read for the dropped index") { - await transport.sentData.count == 6 - } + let configuredIndices: Set = [0, 2, 7] + for index in UInt8(0)..<8 { + if configuredIndices.contains(index) { await transport.simulateReceive( - makeChannelInfoPacket(index: 2, name: "ch2", secret: Data(repeating: 0xAB, count: 16)) + makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) ) + } else { + await transport.simulateReceive( + makeChannelInfoPacket(index: index, name: "", secret: Data(repeating: 0, count: 16)) + ) + } + } - let result = try await syncTask.value - #expect(result.channelsSynced == 4) + let result = try await syncTask.value + #expect(result.channelsSynced == 3) + + let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } + #expect(stored.map(\.index) == [0, 2, 7]) + #expect(stored.first { $0.index == 0 }?.name == "ch0") + #expect(stored.first { $0.index == 2 }?.name == "ch2") + #expect(stored.first { $0.index == 7 }?.name == "ch7") + } + + // MARK: - Gate #4: drop-reconcile + + @Test + func `a dropped write is reconciled with a serial read and persisted`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) + let transport = MockTransport() + await transport.setSupportsWriteWithoutResponse(true) + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + + let syncTask = Task { + try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) + } + + try await waitUntil("all four reads should be primed") { + await transport.sentData.count == 5 + } - let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } - #expect(stored.map(\.index) == [0, 1, 2, 3]) - #expect(stored.first { $0.index == 2 }?.name == "ch2") + // Index 2 is never answered in the pipeline (simulated dropped Write Command). + for index: UInt8 in [0, 1, 3] { + await transport.simulateReceive( + makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) + ) } - // MARK: - Gate #2a: transport throws mid-pipeline → nothing persisted + // After the idle timeout, the service reconciles index 2 with a serial read. + try await waitUntil("reconcile should issue a serial read for the dropped index") { + await transport.sentData.count == 6 + } + await transport.simulateReceive( + makeChannelInfoPacket(index: 2, name: "ch2", secret: Data(repeating: 0xAB, count: 16)) + ) + + let result = try await syncTask.value + #expect(result.channelsSynced == 4) - @Test("a transport send failure mid-pipeline throws and persists nothing") - func transportThrowMidPipelinePersistsNothing() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) - let transport = MockTransport() - await transport.setSupportsWriteWithoutResponse(true) - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } + #expect(stored.map(\.index) == [0, 1, 2, 3]) + #expect(stored.first { $0.index == 2 }?.name == "ch2") + } - // appStart was send #1; fail every send from #2 (the first channel read) onward. - await transport.failSends(fromSendIndex: 2) + // MARK: - Gate #2a: transport throws mid-pipeline → nothing persisted - await #expect(throws: Error.self) { - _ = try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) - } + @Test + func `a transport send failure mid-pipeline throws and persists nothing`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) + let transport = MockTransport() + await transport.setSupportsWriteWithoutResponse(true) + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - let stored = try await dataStore.fetchChannels(radioID: radioID) - #expect(stored.isEmpty, "a hard send failure must not persist a partial channel set") + // appStart was send #1; fail every send from #2 (the first channel read) onward. + await transport.failSends(fromSendIndex: 2) + + await #expect(throws: Error.self) { + _ = try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) } - // MARK: - Gate #2b: partial drain → no mis-index, unread configured slot survives + let stored = try await dataStore.fetchChannels(radioID: radioID) + #expect(stored.isEmpty, "a hard send failure must not persist a partial channel set") + } - @Test("a mid-drain stall persists only read slots and never deletes an unread configured slot") - func midDrainStallKeepsUnreadConfiguredSlot() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) + // MARK: - Gate #2b: partial drain → no mis-index, unread configured slot survives - // Pre-seed a configured channel at index 2 that the upcoming sync cannot read. - _ = try await dataStore.batchSaveChannels( - radioID: radioID, - configured: [ChannelInfo(index: 2, name: "keep", secret: Data(repeating: 0xCD, count: 16))], - unconfiguredIndices: [], - pruneBeyond: nil - ) + @Test + func `a mid-drain stall persists only read slots and never deletes an unread configured slot`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 4) + + // Pre-seed a configured channel at index 2 that the upcoming sync cannot read. + _ = try await dataStore.batchSaveChannels( + radioID: radioID, + configured: [ChannelInfo(index: 2, name: "keep", secret: Data(repeating: 0xCD, count: 16))], + unconfiguredIndices: [], + pruneBeyond: nil + ) + + let transport = MockTransport() + await transport.setSupportsWriteWithoutResponse(true) + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - let transport = MockTransport() - await transport.setSupportsWriteWithoutResponse(true) - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - - let syncTask = Task { - try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) - } - - try await waitUntil("all four reads should be primed") { - await transport.sentData.count == 5 - } - - // The reconcile read for the dropped index (send #6, after appStart + 4 primed reads) - // fails as a transport drop, so index 2 cannot be re-read and stays in neither list. - await transport.failSends(fromSendIndex: 6) - - // Answer 0, 1, 3 but never 2. - for index: UInt8 in [0, 1, 3] { - await transport.simulateReceive( - makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) - ) - } - - let result = try await syncTask.value - #expect(result.channelsSynced == 3) - - let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } - #expect(stored.map(\.index) == [0, 1, 2, 3], "no mis-indexed rows; the unread index 2 is not deleted") - #expect(stored.first { $0.index == 2 }?.name == "keep", "the unread configured slot is preserved verbatim") + let syncTask = Task { + try await service.syncChannels(radioID: radioID, maxChannels: 4, usePipelinedRead: true) } - // MARK: - Gate #4b: reconcile circuit breaker - - @Test("reconcile circuit breaker opens after consecutive failures and never deletes unread slots") - func reconcileCircuitBreakerStopsAndPreservesUnreadSlots() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - - // Pre-seed configured rows at every index the pipeline will fail to read. Indices 2-4 - // each fail their reconcile read (transport drop); indices 5-7 are skipped once the - // breaker opens at threshold 3. All six land in neither the configured nor the - // unconfigured list, so the data-loss guard must leave their rows intact. - let preseeded: [ChannelInfo] = (UInt8(2)...UInt8(7)).map { - ChannelInfo(index: $0, name: "keep\($0)", secret: Data(repeating: 0xCD, count: 16)) - } - _ = try await dataStore.batchSaveChannels( - radioID: radioID, - configured: preseeded, - unconfiguredIndices: [], - pruneBeyond: nil - ) + try await waitUntil("all four reads should be primed") { + await transport.sentData.count == 5 + } + + // The reconcile read for the dropped index (send #6, after appStart + 4 primed reads) + // fails as a transport drop, so index 2 cannot be re-read and stays in neither list. + await transport.failSends(fromSendIndex: 6) - let transport = MockTransport() - await transport.setSupportsWriteWithoutResponse(true) - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - - let syncTask = Task { - try await service.syncChannels(radioID: radioID, maxChannels: 8, usePipelinedRead: true) - } - - try await waitUntil("all eight reads should be primed") { - await transport.sentData.count == 9 // appStart + 8 primed reads - } - - // Every reconcile read (send #10 onward) fails as a transport drop. A dropped send - // surfaces as a transportError, which counts toward the breaker and is not retried, so - // indices 2-4 each register one consecutive failure and indices 5-7 are skipped. - await transport.failSends(fromSendIndex: 10) - - // Answer only 0 and 1; leave 2-7 unanswered so they become the missing set. - for index: UInt8 in [0, 1] { - await transport.simulateReceive( - makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) - ) - } - - let result = try await syncTask.value - - #expect(result.channelsSynced == 2) - #expect(result.circuitBreakerAborted) - - let transportErrorIndices = result.errors - .filter { $0.errorType == .transportError } - .map(\.index) - .sorted() - let circuitBreakerIndices = result.errors - .filter { $0.errorType == .circuitBreaker } - .map(\.index) - .sorted() - #expect(transportErrorIndices == [2, 3, 4], "the first three missing indices each fail their reconcile read") - #expect(circuitBreakerIndices == [5, 6, 7], "the open breaker skips the remaining missing indices") - - let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } - #expect(stored.map(\.index) == [0, 1, 2, 3, 4, 5, 6, 7], "no unread slot is deleted") - for index: UInt8 in 2...7 { - #expect( - stored.first { $0.index == index }?.name == "keep\(index)", - "an unread slot (reconcile-failed or breaker-skipped) is preserved verbatim" - ) - } - #expect(stored.first { $0.index == 0 }?.name == "ch0") + // Answer 0, 1, 3 but never 2. + for index: UInt8 in [0, 1, 3] { + await transport.simulateReceive( + makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) + ) } - // MARK: - Parity: serial path unchanged + let result = try await syncTask.value + #expect(result.channelsSynced == 3) - @Test("serial path (usePipelinedRead: false) still syncs via acknowledged reads") - func serialPathStillSyncs() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 2) - let transport = MockTransport() // capability defaults to false - let session = try await startedSession(transport) - defer { Task { await session.stop() } } - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } + #expect(stored.map(\.index) == [0, 1, 2, 3], "no mis-indexed rows; the unread index 2 is not deleted") + #expect(stored.first { $0.index == 2 }?.name == "keep", "the unread configured slot is preserved verbatim") + } - let syncTask = Task { - try await service.syncChannels(radioID: radioID, maxChannels: 2, usePipelinedRead: false) - } + // MARK: - Gate #4b: reconcile circuit breaker - try await waitUntil("first serial read should be sent") { - await transport.sentData.count == 2 - } - await transport.simulateReceive( - makeChannelInfoPacket(index: 0, name: "ch0", secret: Data(repeating: 0xAB, count: 16)) - ) - try await waitUntil("second serial read should be sent after the first responds") { - await transport.sentData.count == 3 - } - await transport.simulateReceive( - makeChannelInfoPacket(index: 1, name: "", secret: Data(repeating: 0, count: 16)) - ) + @Test + func `reconcile circuit breaker opens after consecutive failures and never deletes unread slots`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + + // Pre-seed configured rows at every index the pipeline will fail to read. Indices 2-4 + // each fail their reconcile read (transport drop); indices 5-7 are skipped once the + // breaker opens at threshold 3. All six land in neither the configured nor the + // unconfigured list, so the data-loss guard must leave their rows intact. + let preseeded: [ChannelInfo] = (UInt8(2)...UInt8(7)).map { + ChannelInfo(index: $0, name: "keep\($0)", secret: Data(repeating: 0xCD, count: 16)) + } + _ = try await dataStore.batchSaveChannels( + radioID: radioID, + configured: preseeded, + unconfiguredIndices: [], + pruneBeyond: nil + ) + + let transport = MockTransport() + await transport.setSupportsWriteWithoutResponse(true) + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + + let syncTask = Task { + try await service.syncChannels(radioID: radioID, maxChannels: 8, usePipelinedRead: true) + } + + try await waitUntil("all eight reads should be primed") { + await transport.sentData.count == 9 // appStart + 8 primed reads + } + + // Every reconcile read (send #10 onward) fails as a transport drop. A dropped send + // surfaces as a transportError, which counts toward the breaker and is not retried, so + // indices 2-4 each register one consecutive failure and indices 5-7 are skipped. + await transport.failSends(fromSendIndex: 10) + + // Answer only 0 and 1; leave 2-7 unanswered so they become the missing set. + for index: UInt8 in [0, 1] { + await transport.simulateReceive( + makeChannelInfoPacket(index: index, name: "ch\(index)", secret: Data(repeating: 0xAB, count: 16)) + ) + } - let result = try await syncTask.value - #expect(result.channelsSynced == 1) + let result = try await syncTask.value + + #expect(result.channelsSynced == 2) + #expect(result.circuitBreakerAborted) + + let transportErrorIndices = result.errors + .filter { $0.errorType == .transportError } + .map(\.index) + .sorted() + let circuitBreakerIndices = result.errors + .filter { $0.errorType == .circuitBreaker } + .map(\.index) + .sorted() + #expect(transportErrorIndices == [2, 3, 4], "the first three missing indices each fail their reconcile read") + #expect(circuitBreakerIndices == [5, 6, 7], "the open breaker skips the remaining missing indices") + + let stored = try await dataStore.fetchChannels(radioID: radioID).sorted { $0.index < $1.index } + #expect(stored.map(\.index) == [0, 1, 2, 3, 4, 5, 6, 7], "no unread slot is deleted") + for index: UInt8 in 2...7 { + #expect( + stored.first { $0.index == index }?.name == "keep\(index)", + "an unread slot (reconcile-failed or breaker-skipped) is preserved verbatim" + ) + } + #expect(stored.first { $0.index == 0 }?.name == "ch0") + } + + // MARK: - Parity: serial path unchanged + + @Test + func `serial path (usePipelinedRead: false) still syncs via acknowledged reads`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 2) + let transport = MockTransport() // capability defaults to false + let session = try await startedSession(transport) + defer { Task { await session.stop() } } + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + + let syncTask = Task { + try await service.syncChannels(radioID: radioID, maxChannels: 2, usePipelinedRead: false) + } - let stored = try await dataStore.fetchChannels(radioID: radioID) - #expect(stored.map(\.index) == [0]) + try await waitUntil("first serial read should be sent") { + await transport.sentData.count == 2 } + await transport.simulateReceive( + makeChannelInfoPacket(index: 0, name: "ch0", secret: Data(repeating: 0xAB, count: 16)) + ) + try await waitUntil("second serial read should be sent after the first responds") { + await transport.sentData.count == 3 + } + await transport.simulateReceive( + makeChannelInfoPacket(index: 1, name: "", secret: Data(repeating: 0, count: 16)) + ) + + let result = try await syncTask.value + #expect(result.channelsSynced == 1) + + let stored = try await dataStore.fetchChannels(radioID: radioID) + #expect(stored.map(\.index) == [0]) + } } // MARK: - Helpers private func startedSession(_ transport: MockTransport) async throws -> MeshCoreSession { - // A generous defaultTimeout keeps appStart and acknowledged reads from flaking under - // heavy parallel test load; no test here relies on a getChannel actually timing out, so - // it never slows the suite. The pipeline idle timeout stays small but above the gap - // between back-to-back simulated responses so it never trips mid-stream. - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration( - defaultTimeout: 5.0, - clientIdentifier: "MCTst", - channelPipelineWindow: 8, - channelPipelineIdleTimeout: 0.5, - channelPipelineHardTimeout: 10.0, - channelPipelinePostDrainGrace: 0.05 - ) + // A generous defaultTimeout keeps appStart and acknowledged reads from flaking under + // heavy parallel test load; no test here relies on a getChannel actually timing out, so + // it never slows the suite. The pipeline idle timeout stays small but above the gap + // between back-to-back simulated responses so it never trips mid-stream. + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration( + defaultTimeout: 5.0, + clientIdentifier: "MCTst", + channelPipelineWindow: 8, + channelPipelineIdleTimeout: 0.5, + channelPipelineHardTimeout: 10.0, + channelPipelinePostDrainGrace: 0.05 ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - return session + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + return session } private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) - payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(withUnsafeBytes(of: UInt32(915_000).littleEndian) { Data($0) }) - payload.append(withUnsafeBytes(of: UInt32(125_000).littleEndian) { Data($0) }) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) - - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) + payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(withUnsafeBytes(of: UInt32(915_000).littleEndian) { Data($0) }) + payload.append(withUnsafeBytes(of: UInt32(125_000).littleEndian) { Data($0) }) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet } private func makeChannelInfoPacket(index: UInt8, name: String, secret: Data) -> Data { - var packet = Data([ResponseCode.channelInfo.rawValue, index]) - let nameBytes = Array(name.utf8.prefix(31)) - packet.append(contentsOf: nameBytes) - packet.append(0) - if nameBytes.count < 31 { - packet.append(Data(repeating: 0, count: 31 - nameBytes.count)) - } - packet.append(secret) - return packet + var packet = Data([ResponseCode.channelInfo.rawValue, index]) + let nameBytes = Array(name.utf8.prefix(31)) + packet.append(contentsOf: nameBytes) + packet.append(0) + if nameBytes.count < 31 { + packet.append(Data(repeating: 0, count: 31 - nameBytes.count)) + } + packet.append(secret) + return packet } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChannelServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChannelServiceTests.swift index fce50c82..63748294 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChannelServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChannelServiceTests.swift @@ -1,203 +1,204 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("ChannelService Tests") struct ChannelServiceTests { - - // MARK: - Secret Hashing Tests - - @Test("hashSecret produces 16-byte output") - func hashSecretProduces16Bytes() { - let secret = ChannelService.hashSecret("test passphrase") - #expect(secret.count == ProtocolLimits.channelSecretSize) - } - - @Test("hashSecret is deterministic") - func hashSecretIsDeterministic() { - let secret1 = ChannelService.hashSecret("same passphrase") - let secret2 = ChannelService.hashSecret("same passphrase") - #expect(secret1 == secret2) - } - - @Test("hashSecret differs for different inputs") - func hashSecretDiffersForDifferentInputs() { - let secret1 = ChannelService.hashSecret("passphrase one") - let secret2 = ChannelService.hashSecret("passphrase two") - #expect(secret1 != secret2) - } - - @Test("hashSecret handles empty string") - func hashSecretHandlesEmptyString() { - let secret = ChannelService.hashSecret("") - #expect(secret.count == ProtocolLimits.channelSecretSize) - #expect(secret == Data(repeating: 0, count: ProtocolLimits.channelSecretSize)) - } - - @Test("hashSecret handles unicode") - func hashSecretHandlesUnicode() { - let secret = ChannelService.hashSecret("🔐 secure 密码") - #expect(secret.count == ProtocolLimits.channelSecretSize) - } - - @Test("validateSecret accepts 16-byte secrets") - func validateSecretAccepts16Bytes() { - let validSecret = Data(repeating: 0xAB, count: ProtocolLimits.channelSecretSize) - #expect(ChannelService.validateSecret(validSecret)) - } - - @Test("validateSecret rejects wrong-sized secrets") - func validateSecretRejectsWrongSize() { - let tooShort = Data(repeating: 0xAB, count: 15) - let tooLong = Data(repeating: 0xAB, count: 17) - #expect(!ChannelService.validateSecret(tooShort)) - #expect(!ChannelService.validateSecret(tooLong)) - } - - // MARK: - ChannelSyncError Tests - - @Test("ChannelSyncError timeout is retryable") - func timeoutErrorIsRetryable() { - let error = ChannelSyncError(index: 0, errorType: .timeout, description: "Timeout") - #expect(error.isRetryable) - } - - @Test("ChannelSyncError send timeout is retryable and counted separately") - func sendTimeoutErrorIsRetryableAndCountedSeparately() { - let error = ChannelSyncError(index: 0, errorType: .sendTimeout, description: "Send timed out") - let result = ChannelSyncResult(channelsSynced: 0, errors: [error]) - - #expect(error.isRetryable) - #expect(result.requestTimeoutCount == 0) - #expect(result.sendTimeoutCount == 1) - } - - @Test("ChannelSyncError circuit breaker is not retryable") - func circuitBreakerErrorIsNotRetryable() { - let error = ChannelSyncError(index: 0, errorType: .circuitBreaker, description: "Circuit open") - #expect(!error.isRetryable) - } - - @Test("ChannelSyncError deviceError is not retryable") - func deviceErrorIsNotRetryable() { - let error = ChannelSyncError(index: 0, errorType: .deviceError(code: 0x02), description: "Not found") - #expect(!error.isRetryable) - } - - @Test("ChannelSyncError databaseError is not retryable") - func databaseErrorIsNotRetryable() { - let error = ChannelSyncError(index: 0, errorType: .databaseError, description: "Save failed") - #expect(!error.isRetryable) - } - - @Test("ChannelSyncError unknown is not retryable") - func unknownErrorIsNotRetryable() { - let error = ChannelSyncError(index: 0, errorType: .unknown, description: "Unknown error") - #expect(!error.isRetryable) - } - - // MARK: - ChannelSyncResult Tests - - @Test("ChannelSyncResult isComplete when no errors") - func syncResultIsCompleteWithNoErrors() { - let result = ChannelSyncResult(channelsSynced: 8, errors: []) - #expect(result.isComplete) - } - - @Test("ChannelSyncResult is not complete with errors") - func syncResultIsNotCompleteWithErrors() { - let error = ChannelSyncError(index: 3, errorType: .timeout, description: "Timeout") - let result = ChannelSyncResult(channelsSynced: 7, errors: [error]) - #expect(!result.isComplete) - } - - @Test("ChannelSyncResult retryableIndices filters correctly") - func syncResultRetryableIndicesFiltersCorrectly() { - let errors = [ - ChannelSyncError(index: 1, errorType: .timeout, description: "Timeout"), - ChannelSyncError(index: 2, errorType: .deviceError(code: 0x02), description: "Not found"), - ChannelSyncError(index: 5, errorType: .timeout, description: "Timeout"), - ] - let result = ChannelSyncResult(channelsSynced: 5, errors: errors) - - #expect(result.retryableIndices == [1, 5]) - } - - @Test("ChannelService aborts early when transport send timeouts cascade") - func syncChannelsAbortsEarlyForSendTimeoutCascade() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 6) - let transport = SendTimeoutTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 0.01, clientIdentifier: "MCTst") - ) - let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - - let result = try await service.syncChannels(radioID: radioID, maxChannels: 6) - - #expect(result.sendTimeoutCount == 3) - #expect(result.circuitBreakerAborted) - #expect(await transport.sendCount == 3) - } - - @Test("ChannelSyncResult retryableIndices empty when no retryable errors") - func syncResultRetryableIndicesEmptyWhenNoRetryable() { - let errors = [ - ChannelSyncError(index: 2, errorType: .deviceError(code: 0x02), description: "Not found"), - ChannelSyncError(index: 3, errorType: .databaseError, description: "Save failed"), - ] - let result = ChannelSyncResult(channelsSynced: 6, errors: errors) - - #expect(result.retryableIndices.isEmpty) - } - - @Test("isChannelConfigured returns true for empty name with non-zero secret") - func isChannelConfiguredEmptyNameNonZeroSecret() { - let isConfigured = ChannelService.isChannelConfigured( - name: "", - secret: Data(repeating: 0x42, count: ProtocolLimits.channelSecretSize) - ) - #expect(isConfigured) - } - - @Test("isChannelConfigured returns false for empty name with zero secret") - func isChannelConfiguredEmptyNameZeroSecret() { - let isConfigured = ChannelService.isChannelConfigured( - name: "", - secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) - ) - #expect(!isConfigured) - } - - @Test("isChannelConfigured returns true for named zero-secret channel") - func isChannelConfiguredNamedZeroSecret() { - let isConfigured = ChannelService.isChannelConfigured( - name: "Public", - secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) - ) - #expect(isConfigured) - } + // MARK: - Secret Hashing Tests + + @Test + func `hashSecret produces 16-byte output`() { + let secret = ChannelService.hashSecret("test passphrase") + #expect(secret.count == ProtocolLimits.channelSecretSize) + } + + @Test + func `hashSecret is deterministic`() { + let secret1 = ChannelService.hashSecret("same passphrase") + let secret2 = ChannelService.hashSecret("same passphrase") + #expect(secret1 == secret2) + } + + @Test + func `hashSecret differs for different inputs`() { + let secret1 = ChannelService.hashSecret("passphrase one") + let secret2 = ChannelService.hashSecret("passphrase two") + #expect(secret1 != secret2) + } + + @Test + func `hashSecret handles empty string`() { + let secret = ChannelService.hashSecret("") + #expect(secret.count == ProtocolLimits.channelSecretSize) + #expect(secret == Data(repeating: 0, count: ProtocolLimits.channelSecretSize)) + } + + @Test + func `hashSecret handles unicode`() { + let secret = ChannelService.hashSecret("🔐 secure 密码") + #expect(secret.count == ProtocolLimits.channelSecretSize) + } + + @Test + func `validateSecret accepts 16-byte secrets`() { + let validSecret = Data(repeating: 0xAB, count: ProtocolLimits.channelSecretSize) + #expect(ChannelService.validateSecret(validSecret)) + } + + @Test + func `validateSecret rejects wrong-sized secrets`() { + let tooShort = Data(repeating: 0xAB, count: 15) + let tooLong = Data(repeating: 0xAB, count: 17) + #expect(!ChannelService.validateSecret(tooShort)) + #expect(!ChannelService.validateSecret(tooLong)) + } + + // MARK: - ChannelSyncError Tests + + @Test + func `ChannelSyncError timeout is retryable`() { + let error = ChannelSyncError(index: 0, errorType: .timeout, description: "Timeout") + #expect(error.isRetryable) + } + + @Test + func `ChannelSyncError send timeout is retryable and counted separately`() { + let error = ChannelSyncError(index: 0, errorType: .sendTimeout, description: "Send timed out") + let result = ChannelSyncResult(channelsSynced: 0, errors: [error]) + + #expect(error.isRetryable) + #expect(result.requestTimeoutCount == 0) + #expect(result.sendTimeoutCount == 1) + } + + @Test + func `ChannelSyncError circuit breaker is not retryable`() { + let error = ChannelSyncError(index: 0, errorType: .circuitBreaker, description: "Circuit open") + #expect(!error.isRetryable) + } + + @Test + func `ChannelSyncError deviceError is not retryable`() { + let error = ChannelSyncError(index: 0, errorType: .deviceError(code: 0x02), description: "Not found") + #expect(!error.isRetryable) + } + + @Test + func `ChannelSyncError databaseError is not retryable`() { + let error = ChannelSyncError(index: 0, errorType: .databaseError, description: "Save failed") + #expect(!error.isRetryable) + } + + @Test + func `ChannelSyncError unknown is not retryable`() { + let error = ChannelSyncError(index: 0, errorType: .unknown, description: "Unknown error") + #expect(!error.isRetryable) + } + + // MARK: - ChannelSyncResult Tests + + @Test + func `ChannelSyncResult isComplete when no errors`() { + let result = ChannelSyncResult(channelsSynced: 8, errors: []) + #expect(result.isComplete) + } + + @Test + func `ChannelSyncResult is not complete with errors`() { + let error = ChannelSyncError(index: 3, errorType: .timeout, description: "Timeout") + let result = ChannelSyncResult(channelsSynced: 7, errors: [error]) + #expect(!result.isComplete) + } + + @Test + func `ChannelSyncResult retryableIndices filters correctly`() { + let errors = [ + ChannelSyncError(index: 1, errorType: .timeout, description: "Timeout"), + ChannelSyncError(index: 2, errorType: .deviceError(code: 0x02), description: "Not found"), + ChannelSyncError(index: 5, errorType: .timeout, description: "Timeout"), + ] + let result = ChannelSyncResult(channelsSynced: 5, errors: errors) + + #expect(result.retryableIndices == [1, 5]) + } + + @Test + func `ChannelService aborts early when transport send timeouts cascade`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 6) + let transport = SendTimeoutTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.01, clientIdentifier: "MCTst") + ) + let service = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + + let result = try await service.syncChannels(radioID: radioID, maxChannels: 6) + + #expect(result.sendTimeoutCount == 3) + #expect(result.circuitBreakerAborted) + #expect(await transport.sendCount == 3) + } + + @Test + func `ChannelSyncResult retryableIndices empty when no retryable errors`() { + let errors = [ + ChannelSyncError(index: 2, errorType: .deviceError(code: 0x02), description: "Not found"), + ChannelSyncError(index: 3, errorType: .databaseError, description: "Save failed"), + ] + let result = ChannelSyncResult(channelsSynced: 6, errors: errors) + + #expect(result.retryableIndices.isEmpty) + } + + @Test + func `isChannelConfigured returns true for empty name with non-zero secret`() { + let isConfigured = ChannelService.isChannelConfigured( + name: "", + secret: Data(repeating: 0x42, count: ProtocolLimits.channelSecretSize) + ) + #expect(isConfigured) + } + + @Test + func `isChannelConfigured returns false for empty name with zero secret`() { + let isConfigured = ChannelService.isChannelConfigured( + name: "", + secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) + ) + #expect(!isConfigured) + } + + @Test + func `isChannelConfigured returns true for named zero-secret channel`() { + let isConfigured = ChannelService.isChannelConfigured( + name: "Public", + secret: Data(repeating: 0, count: ProtocolLimits.channelSecretSize) + ) + #expect(isConfigured) + } } private actor SendTimeoutTransport: MeshTransport { - private(set) var sendCount = 0 + private(set) var sendCount = 0 - var receivedData: AsyncStream { - AsyncStream { continuation in - continuation.finish() - } + var receivedData: AsyncStream { + AsyncStream { continuation in + continuation.finish() } + } - var isConnected: Bool { true } + var isConnected: Bool { + true + } - func connect() async throws {} + func connect() async throws {} - func disconnect() async {} + func disconnect() async {} - func send(_ data: Data) async throws { - sendCount += 1 - throw WiFiTransportError.sendTimeout - } + func send(_ data: Data) async throws { + sendCount += 1 + throw WiFiTransportError.sendTimeout + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift index 12641717..25f0ae55 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift @@ -1,56 +1,55 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ChatCoordinatorRegistry Offline") @MainActor struct ChatCoordinatorRegistryOfflineTests { - - @Test func coordinator_againstOfflineStore_returnsCoordinatorBoundToStore() async throws { - let radioID = UUID() - let contactID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact(id: contactID, radioID: radioID) - try await store.saveContact(contact) - let message = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contactID, - text: "hello", - status: .delivered - ) - try await store.saveMessage(message) - - let registry = ChatCoordinatorRegistry(dataStore: store) - let coordinator = registry.coordinator(for: .dm(radioID: radioID, contactID: contactID)) - let messages = try await store.fetchMessages(contactID: contactID) - - #expect(messages.count == 1) - #expect(messages.first?.text == "hello") - #expect(coordinator.dataStore === store) - } - - @Test func rebind_servicesArrives_replacesCoordinatorAgainstNewStore() async throws { - let radioID = UUID() - let contactID = UUID() - let offlineStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - try await offlineStore.saveContact(ContactDTO.testContact(id: contactID, radioID: radioID)) - try await offlineStore.saveMessage(MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contactID, - text: "offline", - status: .delivered - )) - - let registry = ChatCoordinatorRegistry(dataStore: offlineStore) - let id = ChatConversationID.dm(radioID: radioID, contactID: contactID) - let beforeRebind = registry.coordinator(for: id) - - let onlineContainer = try PersistenceStore.createContainer(inMemory: true) - let onlineStore = PersistenceStore(modelContainer: onlineContainer) - registry.rebind(dataStore: onlineStore) - - let afterRebind = registry.coordinator(for: id) - #expect(beforeRebind !== afterRebind, "rebind should tear down the offline coordinator") - #expect(afterRebind.dataStore === onlineStore, "fresh coordinator binds to new store") - } + @Test func `coordinator against offline store returns coordinator bound to store`() async throws { + let radioID = UUID() + let contactID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact(id: contactID, radioID: radioID) + try await store.saveContact(contact) + let message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "hello", + status: .delivered + ) + try await store.saveMessage(message) + + let registry = ChatCoordinatorRegistry(dataStore: store) + let coordinator = registry.coordinator(for: .dm(radioID: radioID, contactID: contactID)) + let messages = try await store.fetchMessages(contactID: contactID) + + #expect(messages.count == 1) + #expect(messages.first?.text == "hello") + #expect(coordinator.dataStore === store) + } + + @Test func `rebind services arrives replaces coordinator against new store`() async throws { + let radioID = UUID() + let contactID = UUID() + let offlineStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + try await offlineStore.saveContact(ContactDTO.testContact(id: contactID, radioID: radioID)) + try await offlineStore.saveMessage(MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "offline", + status: .delivered + )) + + let registry = ChatCoordinatorRegistry(dataStore: offlineStore) + let id = ChatConversationID.dm(radioID: radioID, contactID: contactID) + let beforeRebind = registry.coordinator(for: id) + + let onlineContainer = try PersistenceStore.createContainer(inMemory: true) + let onlineStore = PersistenceStore(modelContainer: onlineContainer) + registry.rebind(dataStore: onlineStore) + + let afterRebind = registry.coordinator(for: id) + #expect(beforeRebind !== afterRebind, "rebind should tear down the offline coordinator") + #expect(afterRebind.dataStore === onlineStore, "fresh coordinator binds to new store") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift index 2c307414..6b6f89d8 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift @@ -1,92 +1,91 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ChatCoordinatorRegistry") @MainActor struct ChatCoordinatorRegistryTests { - - private func makeRegistry() throws -> ChatCoordinatorRegistry { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - return ChatCoordinatorRegistry(dataStore: dataStore) - } - - @Test("coordinator(for:) returns the same instance on repeat calls") - func coordinatorFor_returnsSameInstance() throws { - let registry = try makeRegistry() - let id = ChatConversationID.dm(radioID: UUID(), contactID: UUID()) - - let first = registry.coordinator(for: id) - let second = registry.coordinator(for: id) - - #expect(first === second) - } - - @Test("Distinct conversation IDs yield distinct coordinators") - func distinctIDs_yieldDistinctCoordinators() throws { - let registry = try makeRegistry() - let radioID = UUID() - let dmID = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - let channelID = ChatConversationID.channel(radioID: radioID, channelIndex: 0) - - let dm = registry.coordinator(for: dmID) - let channel = registry.coordinator(for: channelID) - - #expect(dm !== channel) - } - - @MainActor - @Test func rebind_withDifferentStore_clearsExistingCoordinators() async throws { - let containerA = try PersistenceStore.createContainer(inMemory: true) - let containerB = try PersistenceStore.createContainer(inMemory: true) - let storeA = PersistenceStore(modelContainer: containerA) - let storeB = PersistenceStore(modelContainer: containerB) - let registry = ChatCoordinatorRegistry(dataStore: storeA) - let id = ChatConversationID.dm(radioID: UUID(), contactID: UUID()) - let first = registry.coordinator(for: id) - - registry.rebind(dataStore: storeB) - let second = registry.coordinator(for: id) - - #expect(first !== second) - #expect(registry.dataStore === storeB) - } - - @Test func coordinator_exceedingCap_evictsLeastRecentlyUsed() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let registry = ChatCoordinatorRegistry(dataStore: store, capacity: 2) - let radioID = UUID() - - let idA = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - let idB = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - let idC = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - - let firstA = registry.coordinator(for: idA) - _ = registry.coordinator(for: idB) - _ = registry.coordinator(for: idC) // evicts A - - let secondA = registry.coordinator(for: idA) - #expect(firstA !== secondA, "Evicted entry should be reconstructed") - } - - @Test func coordinator_touchingEntry_promotesItToMostRecentlyUsed() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let registry = ChatCoordinatorRegistry(dataStore: store, capacity: 2) - let radioID = UUID() - - let idA = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - let idB = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - let idC = ChatConversationID.dm(radioID: radioID, contactID: UUID()) - - let firstA = registry.coordinator(for: idA) - _ = registry.coordinator(for: idB) - _ = registry.coordinator(for: idA) // touch — promotes A - _ = registry.coordinator(for: idC) // evicts B, not A - - let secondA = registry.coordinator(for: idA) - #expect(firstA === secondA, "Touched entry should survive eviction") - } + private func makeRegistry() throws -> ChatCoordinatorRegistry { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + return ChatCoordinatorRegistry(dataStore: dataStore) + } + + @Test + func `coordinator(for:) returns the same instance on repeat calls`() throws { + let registry = try makeRegistry() + let id = ChatConversationID.dm(radioID: UUID(), contactID: UUID()) + + let first = registry.coordinator(for: id) + let second = registry.coordinator(for: id) + + #expect(first === second) + } + + @Test + func `Distinct conversation IDs yield distinct coordinators`() throws { + let registry = try makeRegistry() + let radioID = UUID() + let dmID = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + let channelID = ChatConversationID.channel(radioID: radioID, channelIndex: 0) + + let dm = registry.coordinator(for: dmID) + let channel = registry.coordinator(for: channelID) + + #expect(dm !== channel) + } + + @MainActor + @Test func `rebind with different store clears existing coordinators`() throws { + let containerA = try PersistenceStore.createContainer(inMemory: true) + let containerB = try PersistenceStore.createContainer(inMemory: true) + let storeA = PersistenceStore(modelContainer: containerA) + let storeB = PersistenceStore(modelContainer: containerB) + let registry = ChatCoordinatorRegistry(dataStore: storeA) + let id = ChatConversationID.dm(radioID: UUID(), contactID: UUID()) + let first = registry.coordinator(for: id) + + registry.rebind(dataStore: storeB) + let second = registry.coordinator(for: id) + + #expect(first !== second) + #expect(registry.dataStore === storeB) + } + + @Test func `coordinator exceeding cap evicts least recently used`() throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let registry = ChatCoordinatorRegistry(dataStore: store, capacity: 2) + let radioID = UUID() + + let idA = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + let idB = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + let idC = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + + let firstA = registry.coordinator(for: idA) + _ = registry.coordinator(for: idB) + _ = registry.coordinator(for: idC) // evicts A + + let secondA = registry.coordinator(for: idA) + #expect(firstA !== secondA, "Evicted entry should be reconstructed") + } + + @Test func `coordinator touching entry promotes it to most recently used`() throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let registry = ChatCoordinatorRegistry(dataStore: store, capacity: 2) + let radioID = UUID() + + let idA = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + let idB = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + let idC = ChatConversationID.dm(radioID: radioID, contactID: UUID()) + + let firstA = registry.coordinator(for: idA) + _ = registry.coordinator(for: idB) + _ = registry.coordinator(for: idA) // touch — promotes A + _ = registry.coordinator(for: idC) // evicts B, not A + + let secondA = registry.coordinator(for: idA) + #expect(firstA === secondA, "Touched entry should survive eviction") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift index 6f920978..a8e064cc 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift @@ -1,427 +1,428 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ChatCoordinator") @MainActor struct ChatCoordinatorTests { - - @Test("append adds a new message and bumps renderStateID") - func append_addsNewMessage() { - let coordinator = ChatCoordinator.makeForTesting() - let radioID = UUID() - let contactID = UUID() - let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: contactID) - - let before = coordinator.renderStateID - let inserted = coordinator.append(message) - - #expect(inserted) - #expect(coordinator.messages.count == 1) - #expect(coordinator.messagesByID[message.id] == message) - #expect(coordinator.renderStateID == before &+ 1) + @Test + func `append adds a new message and bumps renderStateID`() { + let coordinator = ChatCoordinator.makeForTesting() + let radioID = UUID() + let contactID = UUID() + let message = MessageDTO.testDirectMessage(radioID: radioID, contactID: contactID) + + let before = coordinator.renderStateID + let inserted = coordinator.append(message) + + #expect(inserted) + #expect(coordinator.messages.count == 1) + #expect(coordinator.messagesByID[message.id] == message) + #expect(coordinator.renderStateID == before &+ 1) + } + + @Test + func `append is idempotent on duplicate id`() { + let coordinator = ChatCoordinator.makeForTesting() + let message = MessageDTO.testDirectMessage() + _ = coordinator.append(message) + + let countBefore = coordinator.messages.count + let renderIDBefore = coordinator.renderStateID + let inserted = coordinator.append(message) + + #expect(!inserted) + #expect(coordinator.messages.count == countBefore) + #expect(coordinator.renderStateID == renderIDBefore) + } + + @Test + func `update no-ops on missing id`() { + let coordinator = ChatCoordinator.makeForTesting() + let renderIDBefore = coordinator.renderStateID + + coordinator.update(messageID: UUID()) { dto in + dto = MessageDTO.testDirectMessage() } - @Test("append is idempotent on duplicate id") - func append_idempotentOnDuplicateID() { - let coordinator = ChatCoordinator.makeForTesting() - let message = MessageDTO.testDirectMessage() - _ = coordinator.append(message) + #expect(coordinator.messages.isEmpty) + #expect(coordinator.renderStateID == renderIDBefore) + } - let countBefore = coordinator.messages.count - let renderIDBefore = coordinator.renderStateID - let inserted = coordinator.append(message) + @Test + func `update mutates an existing message in place and bumps renderStateID`() { + let coordinator = ChatCoordinator.makeForTesting() + let message = MessageDTO.testDirectMessage(text: "before") + _ = coordinator.append(message) + let renderIDBefore = coordinator.renderStateID - #expect(!inserted) - #expect(coordinator.messages.count == countBefore) - #expect(coordinator.renderStateID == renderIDBefore) + coordinator.update(messageID: message.id) { dto in + dto = MessageDTO.testDirectMessage(id: message.id, text: "after") } - @Test("update no-ops on missing id") - func update_noOpsOnMissingID() { - let coordinator = ChatCoordinator.makeForTesting() - let renderIDBefore = coordinator.renderStateID - - coordinator.update(messageID: UUID()) { dto in - dto = MessageDTO.testDirectMessage() - } - - #expect(coordinator.messages.isEmpty) - #expect(coordinator.renderStateID == renderIDBefore) + #expect(coordinator.messages.first?.text == "after") + #expect(coordinator.renderStateID == renderIDBefore &+ 1) + } + + @Test + func `renderStateID increments on every mutation`() { + let coordinator = ChatCoordinator.makeForTesting() + let initial = coordinator.renderStateID + + coordinator.replaceAll([MessageDTO.testDirectMessage()]) + _ = coordinator.append(MessageDTO.testDirectMessage()) + coordinator.update(messageID: coordinator.messages[0].id) { _ in } + coordinator.remove(messageID: coordinator.messages[0].id) + + #expect(coordinator.renderStateID == initial &+ 4) + } + + /// Regression: when `rebuildItems` completes but a fresher mutation has + /// advanced `renderStateID`, `setRenderState` rejects the build and the + /// `renderStateInvalidated` callback must fire so the view model knows + /// to reassemble inputs and call `rebuildItems` again. + @Test + func `rebuildItems fires renderStateInvalidated when setRenderState rejects stale build`() async { + let coordinator = ChatCoordinator.makeForTesting() + let message = MessageDTO.testDirectMessage() + _ = coordinator.append(message) + + let invalidatedBox = MainActorBox(value: 0) + coordinator.renderStateInvalidated = { + invalidatedBox.value += 1 } - @Test("update mutates an existing message in place and bumps renderStateID") - func update_mutatesAndBumps() { - let coordinator = ChatCoordinator.makeForTesting() - let message = MessageDTO.testDirectMessage(text: "before") - _ = coordinator.append(message) - let renderIDBefore = coordinator.renderStateID - - coordinator.update(messageID: message.id) { dto in - dto = MessageDTO.testDirectMessage(id: message.id, text: "after") - } - - #expect(coordinator.messages.first?.text == "after") - #expect(coordinator.renderStateID == renderIDBefore &+ 1) + // Build minimal inputs for a single message. The off-main builder + // loop must complete (not be cancelled) so the stale-reject path runs. + let inputs: [(MessageDTO, MessageBuildInputs)] = [ + (message, MessageBuildInputs( + messageID: message.id, + previewState: .idle, + loadedPreview: nil, + cachedURL: nil, + hasInlineImageRef: false, + hasPreviewImageRef: false, + hasPreviewIconRef: false, + imageIsGIF: false, + formattedText: nil, + baseColor: .incoming, + formattedPath: nil, + senderResolution: NodeNameResolution(displayName: "", matchKind: .unresolved), + showTimestamp: false, + showDirectionGap: false, + showSenderName: false, + showNewMessagesDivider: false + )) + ] + + // Kick off rebuildItems, which captures the current renderStateID. + coordinator.rebuildItems(inputs: inputs, envInputs: .default) + + // Advance renderStateID before the off-main build lands on main, + // so setRenderState will reject the result. + _ = coordinator.append(MessageDTO.testDirectMessage()) + + // Wait for the in-flight build task to finish. The off-main loop + // and the trailing MainActor.run (which fires the invalidation + // callback on the stale-reject path) both complete before `.value` + // returns. + await coordinator.buildItemsTask?.value + + // The off-main build finished; renderState must be unchanged (reject) + // and the invalidation callback must have fired exactly once. + #expect(coordinator.renderState.items.isEmpty) + #expect(invalidatedBox.value == 1) + } + + @Test + func `setRenderState returns false on stale capturedID`() { + let coordinator = ChatCoordinator.makeForTesting() + let staleID = coordinator.renderStateID + _ = coordinator.append(MessageDTO.testDirectMessage()) + + let applied = coordinator.setRenderState( + ChatRenderState.empty.with(hasMoreMessages: false), + capturedID: staleID + ) + + #expect(!applied) + #expect(coordinator.renderState.hasMoreMessages == true) + } + + @Test + func `setRenderState applies when capturedID matches`() { + let coordinator = ChatCoordinator.makeForTesting() + let currentID = coordinator.renderStateID + + let applied = coordinator.setRenderState( + ChatRenderState.empty.with(hasMoreMessages: false), + capturedID: currentID + ) + + #expect(applied) + #expect(coordinator.renderState.hasMoreMessages == false) + } + + @Test + func `enqueueReload unions IDs into pendingReloadIDs`() { + let coordinator = ChatCoordinator.makeForTesting() + let id1 = UUID() + let id2 = UUID() + + coordinator.enqueueReload(updatedMessageIDs: [id1]) + coordinator.enqueueReload(updatedMessageIDs: [id2]) + + // The drain Task is scheduled but cannot run before this + // synchronous test returns, so `pendingReloadIDs` still holds + // both unioned IDs at this point. + #expect(coordinator.pendingReloadIDs.contains(id1)) + #expect(coordinator.pendingReloadIDs.contains(id2)) + #expect(coordinator.reloadInFlight) + } + + /// Ack / retry / fail / heard-repeat / reaction events route through + /// `enqueueReload` and then `applyReloadedIDs`, which refreshes the + /// canonical DTO in `messages`. The coordinator invokes + /// `renderItemRebuilder` for each refreshed ID so the view model + /// rebuilds the affected `MessageItem` in `renderState.items`; without + /// that callback the rendered bubble would stay visually stale until + /// an unrelated event forced a full timeline rebuild. + @Test + func `applyReloadedIDs invokes renderItemRebuilder after refreshing a DTO`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + + let radioID = UUID() + let contactID = UUID() + let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) + let coordinator = registry.coordinator(for: conversationID) + + let initial = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "before", + status: .sending + ) + try await dataStore.saveMessage(initial) + _ = coordinator.append(initial) + + let rebuiltIDs = MainActorBox<[UUID]>(value: []) + coordinator.renderItemRebuilder = { id in + rebuiltIDs.value.append(id) } - @Test("renderStateID increments on every mutation") - func renderStateID_incrementsOnEveryMutation() { - let coordinator = ChatCoordinator.makeForTesting() - let initial = coordinator.renderStateID + // Simulate an ack landing in the store after the coordinator has + // already loaded the message: status flips from `.sending` to + // `.sent` via the normal update path. + try await dataStore.updateMessageStatus(id: initial.id, status: .sent) - coordinator.replaceAll([MessageDTO.testDirectMessage()]) - _ = coordinator.append(MessageDTO.testDirectMessage()) - coordinator.update(messageID: coordinator.messages[0].id) { _ in } - coordinator.remove(messageID: coordinator.messages[0].id) + coordinator.enqueueReload(messageID: initial.id) - #expect(coordinator.renderStateID == initial &+ 4) + // Drain the coalesced reload. The implementation schedules a + // detached Task, so yield until `reloadInFlight` clears. + let deadline = ContinuousClock.now + .seconds(1) + while coordinator.reloadInFlight, ContinuousClock.now < deadline { + await Task.yield() } - /// Regression: when `rebuildItems` completes but a fresher mutation has - /// advanced `renderStateID`, `setRenderState` rejects the build and the - /// `renderStateInvalidated` callback must fire so the view model knows - /// to reassemble inputs and call `rebuildItems` again. - @Test("rebuildItems fires renderStateInvalidated when setRenderState rejects stale build") - func rebuildItems_firesInvalidatedOnStaleBuild() async { - let coordinator = ChatCoordinator.makeForTesting() - let message = MessageDTO.testDirectMessage() - _ = coordinator.append(message) - - let invalidatedBox = MainActorBox(value: 0) - coordinator.renderStateInvalidated = { - invalidatedBox.value += 1 - } - - // Build minimal inputs for a single message. The off-main builder - // loop must complete (not be cancelled) so the stale-reject path runs. - let inputs: [(MessageDTO, MessageBuildInputs)] = [ - (message, MessageBuildInputs( - messageID: message.id, - previewState: .idle, - loadedPreview: nil, - cachedURL: nil, - hasInlineImageRef: false, - hasPreviewImageRef: false, - hasPreviewIconRef: false, - imageIsGIF: false, - formattedText: nil, - baseColor: .incoming, - formattedPath: nil, - senderResolution: NodeNameResolution(displayName: "", matchKind: .unresolved), - showTimestamp: false, - showDirectionGap: false, - showSenderName: false, - showNewMessagesDivider: false - )) - ] - - // Kick off rebuildItems, which captures the current renderStateID. - coordinator.rebuildItems(inputs: inputs, envInputs: .default) - - // Advance renderStateID before the off-main build lands on main, - // so setRenderState will reject the result. - _ = coordinator.append(MessageDTO.testDirectMessage()) - - // Wait for the in-flight build task to finish. The off-main loop - // and the trailing MainActor.run (which fires the invalidation - // callback on the stale-reject path) both complete before `.value` - // returns. - await coordinator.buildItemsTask?.value - - // The off-main build finished; renderState must be unchanged (reject) - // and the invalidation callback must have fired exactly once. - #expect(coordinator.renderState.items.isEmpty) - #expect(invalidatedBox.value == 1) + #expect(!coordinator.reloadInFlight) + #expect(rebuiltIDs.value == [initial.id]) + #expect(coordinator.messagesByID[initial.id]?.status == .sent) + } + + /// Regression: once a row has flipped to `.failed`, a stale event-stream + /// `applyStatusUpdate(.pending)` (e.g., a delayed `messageStatusResolved` + /// landing after the queue marked the row terminal) must not flicker the + /// bubble back to "Sending". Only an explicit user-initiated retry + /// (`userInitiated: true`) is allowed to downgrade `.failed → .pending`. + @Test + func `applyStatusUpdate blocks .failed -> .pending unless userInitiated`() { + let coordinator = ChatCoordinator.makeForTesting() + let message = MessageDTO.testDirectMessage(status: .pending) + _ = coordinator.append(message) + + coordinator.applyStatusUpdate(messageID: message.id, status: .failed) + #expect(coordinator.messagesByID[message.id]?.status == .failed) + + // Non-user-initiated downgrade is blocked. + coordinator.applyStatusUpdate(messageID: message.id, status: .pending) + #expect(coordinator.messagesByID[message.id]?.status == .failed) + + // User-initiated retry bypasses the guard. + coordinator.applyStatusUpdate(messageID: message.id, status: .pending, userInitiated: true) + #expect(coordinator.messagesByID[message.id]?.status == .pending) + } + + @Test + func `fresh coordinator starts in .uninitialized phase`() { + let coordinator = ChatCoordinator.makeForTesting() + #expect(coordinator.renderState.phase == .uninitialized) + } + + @Test + func `beginLoading transitions .uninitialized to .loading`() { + let coordinator = ChatCoordinator.makeForTesting() + let renderIDBefore = coordinator.renderStateID + + coordinator.beginLoading() + + #expect(coordinator.renderState.phase == .loading) + #expect(coordinator.renderStateID == renderIDBefore &+ 1) + } + + @Test + func `beginLoading is a no-op once .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + coordinator.replaceAll([]) + let renderIDBefore = coordinator.renderStateID + + coordinator.beginLoading() + + #expect(coordinator.renderState.phase == .loaded) + #expect(coordinator.renderStateID == renderIDBefore) + } + + @Test + func `beginLoading is a no-op while already .loading`() { + let coordinator = ChatCoordinator.makeForTesting() + coordinator.beginLoading() + let renderIDBefore = coordinator.renderStateID + + coordinator.beginLoading() + + #expect(coordinator.renderState.phase == .loading) + #expect(coordinator.renderStateID == renderIDBefore) + } + + @Test + func `markLoaded transitions .uninitialized to .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + let renderIDBefore = coordinator.renderStateID + + coordinator.markLoaded() + + #expect(coordinator.renderState.phase == .loaded) + #expect(coordinator.renderStateID == renderIDBefore &+ 1) + } + + @Test + func `markLoaded transitions .loading to .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + coordinator.beginLoading() + let renderIDBefore = coordinator.renderStateID + + coordinator.markLoaded() + + #expect(coordinator.renderState.phase == .loaded) + #expect(coordinator.renderStateID == renderIDBefore &+ 1) + } + + @Test + func `markLoaded is idempotent when already .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + coordinator.markLoaded() + let renderIDBefore = coordinator.renderStateID + + coordinator.markLoaded() + + #expect(coordinator.renderState.phase == .loaded) + #expect(coordinator.renderStateID == renderIDBefore) + } + + @Test + func `replaceAll transitions phase to .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + #expect(coordinator.renderState.phase == .uninitialized) + + coordinator.replaceAll([MessageDTO.testDirectMessage()]) + + #expect(coordinator.renderState.phase == .loaded) + } + + @Test + func `replaceAll with empty list still transitions to .loaded`() { + let coordinator = ChatCoordinator.makeForTesting() + coordinator.beginLoading() + + coordinator.replaceAll([]) + + #expect(coordinator.renderState.phase == .loaded) + #expect(coordinator.messages.isEmpty) + } + + /// Multi-view-model scenario: two `ChatViewModel`s pointing at the same + /// conversation (iPad split view, sheet dismissal) share a single + /// coordinator instance from the registry. When one VM's load + /// completes, both VMs observe `phase == .loaded` because both read + /// through the shared `renderState`. The empty-state gate stays + /// consistent across siblings. + @Test + func `phase is observed identically by sibling view models sharing one coordinator`() throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + + let radioID = UUID() + let contactID = UUID() + let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) + let coordinatorA = registry.coordinator(for: conversationID) + let coordinatorB = registry.coordinator(for: conversationID) + + #expect(coordinatorA === coordinatorB) + #expect(coordinatorA.renderState.phase == .uninitialized) + + coordinatorA.replaceAll([]) + + #expect(coordinatorA.renderState.phase == .loaded) + #expect(coordinatorB.renderState.phase == .loaded) + } + + /// Same regression scope as `applyReloadedIDs_invokesRenderItemRebuilder`, + /// but verifies the rebuilder is not invoked for IDs that are not present + /// in the coordinator's canonical `messages` array. Paginated-out + /// messages must not fire a per-ID rebuild because there is no + /// corresponding `MessageItem` to refresh. + @Test + func `applyReloadedIDs skips renderItemRebuilder for unknown IDs`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + + let radioID = UUID() + let contactID = UUID() + let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) + let coordinator = registry.coordinator(for: conversationID) + + let pagedOut = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "paged out", + status: .sent + ) + try await dataStore.saveMessage(pagedOut) + + let rebuiltIDs = MainActorBox<[UUID]>(value: []) + coordinator.renderItemRebuilder = { id in + rebuiltIDs.value.append(id) } - @Test("setRenderState returns false on stale capturedID") - func setRenderState_returnsFalseOnStale() { - let coordinator = ChatCoordinator.makeForTesting() - let staleID = coordinator.renderStateID - _ = coordinator.append(MessageDTO.testDirectMessage()) - - let applied = coordinator.setRenderState( - ChatRenderState.empty.with(hasMoreMessages: false), - capturedID: staleID - ) + coordinator.enqueueReload(messageID: pagedOut.id) - #expect(!applied) - #expect(coordinator.renderState.hasMoreMessages == true) + let deadline = ContinuousClock.now + .seconds(1) + while coordinator.reloadInFlight, ContinuousClock.now < deadline { + await Task.yield() } - @Test("setRenderState applies when capturedID matches") - func setRenderState_appliesWhenCurrent() { - let coordinator = ChatCoordinator.makeForTesting() - let currentID = coordinator.renderStateID - - let applied = coordinator.setRenderState( - ChatRenderState.empty.with(hasMoreMessages: false), - capturedID: currentID - ) - - #expect(applied) - #expect(coordinator.renderState.hasMoreMessages == false) - } - - @Test("enqueueReload unions IDs into pendingReloadIDs") - func enqueueReload_unionsIDs() { - let coordinator = ChatCoordinator.makeForTesting() - let id1 = UUID() - let id2 = UUID() - - coordinator.enqueueReload(updatedMessageIDs: [id1]) - coordinator.enqueueReload(updatedMessageIDs: [id2]) - - // The drain Task is scheduled but cannot run before this - // synchronous test returns, so `pendingReloadIDs` still holds - // both unioned IDs at this point. - #expect(coordinator.pendingReloadIDs.contains(id1)) - #expect(coordinator.pendingReloadIDs.contains(id2)) - #expect(coordinator.reloadInFlight) - } - - /// Ack / retry / fail / heard-repeat / reaction events route through - /// `enqueueReload` and then `applyReloadedIDs`, which refreshes the - /// canonical DTO in `messages`. The coordinator invokes - /// `renderItemRebuilder` for each refreshed ID so the view model - /// rebuilds the affected `MessageItem` in `renderState.items`; without - /// that callback the rendered bubble would stay visually stale until - /// an unrelated event forced a full timeline rebuild. - @Test("applyReloadedIDs invokes renderItemRebuilder after refreshing a DTO") - func applyReloadedIDs_invokesRenderItemRebuilder() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let registry = ChatCoordinatorRegistry(dataStore: dataStore) - - let radioID = UUID() - let contactID = UUID() - let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) - let coordinator = registry.coordinator(for: conversationID) - - let initial = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contactID, - text: "before", - status: .sending - ) - try await dataStore.saveMessage(initial) - _ = coordinator.append(initial) - - let rebuiltIDs = MainActorBox<[UUID]>(value: []) - coordinator.renderItemRebuilder = { id in - rebuiltIDs.value.append(id) - } - - // Simulate an ack landing in the store after the coordinator has - // already loaded the message: status flips from `.sending` to - // `.sent` via the normal update path. - try await dataStore.updateMessageStatus(id: initial.id, status: .sent) - - coordinator.enqueueReload(messageID: initial.id) - - // Drain the coalesced reload. The implementation schedules a - // detached Task, so yield until `reloadInFlight` clears. - let deadline = ContinuousClock.now + .seconds(1) - while coordinator.reloadInFlight, ContinuousClock.now < deadline { - await Task.yield() - } - - #expect(!coordinator.reloadInFlight) - #expect(rebuiltIDs.value == [initial.id]) - #expect(coordinator.messagesByID[initial.id]?.status == .sent) - } - - /// Regression: once a row has flipped to `.failed`, a stale event-stream - /// `applyStatusUpdate(.pending)` (e.g., a delayed `messageStatusResolved` - /// landing after the queue marked the row terminal) must not flicker the - /// bubble back to "Sending". Only an explicit user-initiated retry - /// (`userInitiated: true`) is allowed to downgrade `.failed → .pending`. - @Test("applyStatusUpdate blocks .failed -> .pending unless userInitiated") - func applyStatusUpdate_blocksFailedToPendingUnlessUserInitiated() { - let coordinator = ChatCoordinator.makeForTesting() - let message = MessageDTO.testDirectMessage(status: .pending) - _ = coordinator.append(message) - - coordinator.applyStatusUpdate(messageID: message.id, status: .failed) - #expect(coordinator.messagesByID[message.id]?.status == .failed) - - // Non-user-initiated downgrade is blocked. - coordinator.applyStatusUpdate(messageID: message.id, status: .pending) - #expect(coordinator.messagesByID[message.id]?.status == .failed) - - // User-initiated retry bypasses the guard. - coordinator.applyStatusUpdate(messageID: message.id, status: .pending, userInitiated: true) - #expect(coordinator.messagesByID[message.id]?.status == .pending) - } - - @Test("fresh coordinator starts in .uninitialized phase") - func phase_startsUninitialized() { - let coordinator = ChatCoordinator.makeForTesting() - #expect(coordinator.renderState.phase == .uninitialized) - } - - @Test("beginLoading transitions .uninitialized to .loading") - func beginLoading_transitionsFromUninitialized() { - let coordinator = ChatCoordinator.makeForTesting() - let renderIDBefore = coordinator.renderStateID - - coordinator.beginLoading() - - #expect(coordinator.renderState.phase == .loading) - #expect(coordinator.renderStateID == renderIDBefore &+ 1) - } - - @Test("beginLoading is a no-op once .loaded") - func beginLoading_noOpWhenLoaded() { - let coordinator = ChatCoordinator.makeForTesting() - coordinator.replaceAll([]) - let renderIDBefore = coordinator.renderStateID - - coordinator.beginLoading() - - #expect(coordinator.renderState.phase == .loaded) - #expect(coordinator.renderStateID == renderIDBefore) - } - - @Test("beginLoading is a no-op while already .loading") - func beginLoading_noOpWhenLoading() { - let coordinator = ChatCoordinator.makeForTesting() - coordinator.beginLoading() - let renderIDBefore = coordinator.renderStateID - - coordinator.beginLoading() - - #expect(coordinator.renderState.phase == .loading) - #expect(coordinator.renderStateID == renderIDBefore) - } - - @Test("markLoaded transitions .uninitialized to .loaded") - func markLoaded_transitionsFromUninitialized() { - let coordinator = ChatCoordinator.makeForTesting() - let renderIDBefore = coordinator.renderStateID - - coordinator.markLoaded() - - #expect(coordinator.renderState.phase == .loaded) - #expect(coordinator.renderStateID == renderIDBefore &+ 1) - } - - @Test("markLoaded transitions .loading to .loaded") - func markLoaded_transitionsFromLoading() { - let coordinator = ChatCoordinator.makeForTesting() - coordinator.beginLoading() - let renderIDBefore = coordinator.renderStateID - - coordinator.markLoaded() - - #expect(coordinator.renderState.phase == .loaded) - #expect(coordinator.renderStateID == renderIDBefore &+ 1) - } - - @Test("markLoaded is idempotent when already .loaded") - func markLoaded_idempotentWhenLoaded() { - let coordinator = ChatCoordinator.makeForTesting() - coordinator.markLoaded() - let renderIDBefore = coordinator.renderStateID - - coordinator.markLoaded() - - #expect(coordinator.renderState.phase == .loaded) - #expect(coordinator.renderStateID == renderIDBefore) - } - - @Test("replaceAll transitions phase to .loaded") - func replaceAll_transitionsToLoaded() { - let coordinator = ChatCoordinator.makeForTesting() - #expect(coordinator.renderState.phase == .uninitialized) - - coordinator.replaceAll([MessageDTO.testDirectMessage()]) - - #expect(coordinator.renderState.phase == .loaded) - } - - @Test("replaceAll with empty list still transitions to .loaded") - func replaceAll_emptyTransitionsToLoaded() { - let coordinator = ChatCoordinator.makeForTesting() - coordinator.beginLoading() - - coordinator.replaceAll([]) - - #expect(coordinator.renderState.phase == .loaded) - #expect(coordinator.messages.isEmpty) - } - - /// Multi-view-model scenario: two `ChatViewModel`s pointing at the same - /// conversation (iPad split view, sheet dismissal) share a single - /// coordinator instance from the registry. When one VM's load - /// completes, both VMs observe `phase == .loaded` because both read - /// through the shared `renderState`. The empty-state gate stays - /// consistent across siblings. - @Test("phase is observed identically by sibling view models sharing one coordinator") - func phase_sharedAcrossSiblings() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let registry = ChatCoordinatorRegistry(dataStore: dataStore) - - let radioID = UUID() - let contactID = UUID() - let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) - let coordinatorA = registry.coordinator(for: conversationID) - let coordinatorB = registry.coordinator(for: conversationID) - - #expect(coordinatorA === coordinatorB) - #expect(coordinatorA.renderState.phase == .uninitialized) - - coordinatorA.replaceAll([]) - - #expect(coordinatorA.renderState.phase == .loaded) - #expect(coordinatorB.renderState.phase == .loaded) - } - - /// Same regression scope as `applyReloadedIDs_invokesRenderItemRebuilder`, - /// but verifies the rebuilder is not invoked for IDs that are not present - /// in the coordinator's canonical `messages` array. Paginated-out - /// messages must not fire a per-ID rebuild because there is no - /// corresponding `MessageItem` to refresh. - @Test("applyReloadedIDs skips renderItemRebuilder for unknown IDs") - func applyReloadedIDs_skipsRebuilderForUnknownID() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let registry = ChatCoordinatorRegistry(dataStore: dataStore) - - let radioID = UUID() - let contactID = UUID() - let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contactID) - let coordinator = registry.coordinator(for: conversationID) - - let pagedOut = MessageDTO.testDirectMessage( - radioID: radioID, - contactID: contactID, - text: "paged out", - status: .sent - ) - try await dataStore.saveMessage(pagedOut) - - let rebuiltIDs = MainActorBox<[UUID]>(value: []) - coordinator.renderItemRebuilder = { id in - rebuiltIDs.value.append(id) - } - - coordinator.enqueueReload(messageID: pagedOut.id) - - let deadline = ContinuousClock.now + .seconds(1) - while coordinator.reloadInFlight, ContinuousClock.now < deadline { - await Task.yield() - } - - #expect(rebuiltIDs.value.isEmpty) - } + #expect(rebuiltIDs.value.isEmpty) + } } /// Test-only main-actor mutable box for closure-side recording. @MainActor private final class MainActorBox { - var value: Value - init(value: Value) { self.value = value } + var value: Value + init(value: Value) { + self.value = value + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift index 1e81320e..7c2a3af7 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift @@ -1,8 +1,8 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore import MeshCoreTestSupport +import Testing /// Acceptance suite for the persistent `PendingSend.attemptCount` surface. /// Covers three cases: @@ -17,190 +17,189 @@ import MeshCoreTestSupport @Suite("ChatSendQueueService.attemptCount") @MainActor struct ChatSendQueueServiceAttemptCountTests { - - /// Helper: build a Device + Contact + Message + PendingSend with the - /// requested attemptCount, returning the service, the store it shares, - /// the messageID, the radioID, and the message's original wire timestamp. - /// The service is not hydrated — the caller decides whether to hydrate or - /// to first run `store.warmUp()`. - /// - /// The message's `timestamp` is pinned to one hour ago so a "fresh wire - /// timestamp" stamp by `updateMessageTimestamp` (preserveTimestamp=false) - /// is observably different from a "preserved" timestamp - /// (preserveTimestamp=true). UInt32 epoch-second granularity would - /// otherwise collide on a fast test run. - /// - /// `radioID` comes from `Device.radioID` (not `Device.id`) so the - /// in-progress radio survives `purgeOrphanPendingSends`, which keys on - /// `Device.radioID`. - private struct QueueHarness { - let service: ChatSendQueueService - let store: PersistenceStore - let messageID: UUID - let radioID: UUID - let originalTimestamp: UInt32 + /// Helper: build a Device + Contact + Message + PendingSend with the + /// requested attemptCount, returning the service, the store it shares, + /// the messageID, the radioID, and the message's original wire timestamp. + /// The service is not hydrated — the caller decides whether to hydrate or + /// to first run `store.warmUp()`. + /// + /// The message's `timestamp` is pinned to one hour ago so a "fresh wire + /// timestamp" stamp by `updateMessageTimestamp` (preserveTimestamp=false) + /// is observably different from a "preserved" timestamp + /// (preserveTimestamp=true). UInt32 epoch-second granularity would + /// otherwise collide on a fast test run. + /// + /// `radioID` comes from `Device.radioID` (not `Device.id`) so the + /// in-progress radio survives `purgeOrphanPendingSends`, which keys on + /// `Device.radioID`. + private struct QueueHarness { + let service: ChatSendQueueService + let store: PersistenceStore + let messageID: UUID + let radioID: UUID + let originalTimestamp: UInt32 + } + + private static func setupQueueWithRow( + attemptCount: Int? + ) async throws -> QueueHarness { + let device = Device( + publicKey: Data(repeating: 1, count: 32), + nodeName: "Test Device" + ) + let container = try PersistenceStore.createContainer(inMemory: true) + container.mainContext.insert(device) + try container.mainContext.save() + let store = PersistenceStore(modelContainer: container) + let radioID = device.radioID + + let contact = Contact( + radioID: radioID, + publicKey: Data(repeating: 2, count: 32), + name: "Test Contact" + ) + container.mainContext.insert(contact) + try container.mainContext.save() + let contactDTO = try #require(try await store.fetchContact(id: contact.id)) + + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + let messageService = MessageService(session: session, dataStore: store, contactService: nil) + let pending = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) + + let pinnedTimestamp = UInt32(Date().addingTimeInterval(-3600).timeIntervalSince1970) + try await store.updateMessageTimestamp(id: pending.id, timestamp: pinnedTimestamp) + let message = try #require(try await store.fetchMessage(id: pending.id)) + + let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contactDTO.id) + let dto = PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: envelope.messageID, + kind: .dm, + contactID: envelope.contactID, + channelIndex: nil, + isResend: false, + messageText: "", + messageTimestamp: 0, + localNodeName: nil, + sequence: 1, + enqueuedAt: Date(), + attemptCount: attemptCount + ) + try await store.upsertPendingSend(dto) + + let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) + let service = ChatSendQueueService( + radioID: radioID, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService() + ) + + return QueueHarness( + service: service, + store: store, + messageID: message.id, + radioID: radioID, + originalTimestamp: message.timestamp + ) + } + + /// Case 1 / Fresh send: a row with `attemptCount = 0` (current-build + /// race-window row that persisted but never progressed past the + /// top-of-drain bump) drains with `preserveTimestamp = false` because + /// `postBumpCount = 1 > 1` is false. The recipient never saw the packet, + /// so a fresh wire timestamp is correct. Observable side effect: + /// `Message.timestamp` changes (updateMessageTimestamp was called by + /// `sendPendingDirectMessage`). + @Test + func `fresh send: row with attemptCount=0 first drain uses fresh timestamp`() async throws { + let harness = try await Self.setupQueueWithRow(attemptCount: 0) + + await harness.service.hydrate() + // Poll for both drain effects so the assertions can't observe a + // half-applied drain on a slow runner. + try await waitUntil(timeout: .seconds(10), "first drain must bump 0 → 1 and stamp a fresh timestamp") { + guard + let row = await (try? harness.store.fetchPendingSends(radioID: harness.radioID))? + .first(where: { $0.messageID == harness.messageID }), + let timestamp = await (try? harness.store.fetchMessage(id: harness.messageID))?.timestamp + else { return false } + return row.attemptCount == 1 && timestamp != harness.originalTimestamp } - private static func setupQueueWithRow( - attemptCount: Int? - ) async throws -> QueueHarness { - let device = Device( - publicKey: Data(repeating: 1, count: 32), - nodeName: "Test Device" - ) - let container = try PersistenceStore.createContainer(inMemory: true) - container.mainContext.insert(device) - try container.mainContext.save() - let store = PersistenceStore(modelContainer: container) - let radioID = device.radioID - - let contact = Contact( - radioID: radioID, - publicKey: Data(repeating: 2, count: 32), - name: "Test Contact" - ) - container.mainContext.insert(contact) - try container.mainContext.save() - let contactDTO = try #require(try await store.fetchContact(id: contact.id)) - - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - let messageService = MessageService(session: session, dataStore: store, contactService: nil) - let pending = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) - - let pinnedTimestamp = UInt32(Date().addingTimeInterval(-3600).timeIntervalSince1970) - try await store.updateMessageTimestamp(id: pending.id, timestamp: pinnedTimestamp) - let message = try #require(try await store.fetchMessage(id: pending.id)) - - let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contactDTO.id) - let dto = PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: envelope.messageID, - kind: .dm, - contactID: envelope.contactID, - channelIndex: nil, - isResend: false, - messageText: "", - messageTimestamp: 0, - localNodeName: nil, - sequence: 1, - enqueuedAt: Date(), - attemptCount: attemptCount - ) - try await store.upsertPendingSend(dto) - - let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) - let service = ChatSendQueueService( - radioID: radioID, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService() - ) - - return QueueHarness( - service: service, - store: store, - messageID: message.id, - radioID: radioID, - originalTimestamp: message.timestamp - ) + let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + let bumped = rows.first(where: { $0.messageID == harness.messageID })?.attemptCount + #expect(bumped == 1, "first drain attempt must bump 0 → 1") + + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.timestamp != harness.originalTimestamp, + "preserveTimestamp=false: updateMessageTimestamp must stamp a fresh wire timestamp") + + await harness.service.shutdown() + } + + /// Case 2 / Process restart: a row left at `attemptCount = 1` simulates + /// "prior process bumped before sending then died before + /// deletePendingSendsForMessage". On rehydrate the drain bumps to 2 and + /// preserves the wire timestamp so mesh dedup catches a duplicate + /// landing if the wire send completed. + @Test + func `process restart: row with attemptCount=1 rehydrates with preserveTimestamp=true`() async throws { + let harness = try await Self.setupQueueWithRow(attemptCount: 1) + + await harness.service.hydrate() + try await waitUntil(timeout: .seconds(10), "rehydrate drain must bump 1 → 2") { + let row = await (try? harness.store.fetchPendingSends(radioID: harness.radioID))? + .first(where: { $0.messageID == harness.messageID }) + return row?.attemptCount == 2 } - /// Case 1 / Fresh send: a row with `attemptCount = 0` (current-build - /// race-window row that persisted but never progressed past the - /// top-of-drain bump) drains with `preserveTimestamp = false` because - /// `postBumpCount = 1 > 1` is false. The recipient never saw the packet, - /// so a fresh wire timestamp is correct. Observable side effect: - /// `Message.timestamp` changes (updateMessageTimestamp was called by - /// `sendPendingDirectMessage`). - @Test("fresh send: row with attemptCount=0 first drain uses fresh timestamp") - func freshSendBumpsToOneAndUsesFreshTimestamp() async throws { - let harness = try await Self.setupQueueWithRow(attemptCount: 0) - - await harness.service.hydrate() - // Poll for both drain effects so the assertions can't observe a - // half-applied drain on a slow runner. - try await waitUntil(timeout: .seconds(10), "first drain must bump 0 → 1 and stamp a fresh timestamp") { - guard - let row = (try? await harness.store.fetchPendingSends(radioID: harness.radioID))? - .first(where: { $0.messageID == harness.messageID }), - let timestamp = (try? await harness.store.fetchMessage(id: harness.messageID))?.timestamp - else { return false } - return row.attemptCount == 1 && timestamp != harness.originalTimestamp - } - - let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - let bumped = rows.first(where: { $0.messageID == harness.messageID })?.attemptCount - #expect(bumped == 1, "first drain attempt must bump 0 → 1") - - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.timestamp != harness.originalTimestamp, - "preserveTimestamp=false: updateMessageTimestamp must stamp a fresh wire timestamp") - - await harness.service.shutdown() + let postDrain = try await harness.store.fetchPendingSends(radioID: harness.radioID) + #expect(postDrain.first(where: { $0.messageID == harness.messageID })?.attemptCount == 2, + "rehydrate drain bumps 1 → 2") + + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.timestamp == harness.originalTimestamp, + "rehydrate must preserve original wire timestamp so mesh dedup catches duplicate landing") + + await harness.service.shutdown() + } + + /// Case 3 / Bump failure: when `incrementPendingSendAttemptCount` throws, + /// the drain closure must park the envelope on the transport-open trigger + /// (status reverts to `.pending`), leave the PendingSend row intact, and + /// must not call `sendPendingDirectMessage`. Without this guarantee a + /// SwiftData failure during the bump could either drop the envelope + /// silently or double-send on retry. + @Test + func `bump failure parks envelope, preserves row, does not call sendPendingDirectMessage`() async throws { + let harness = try await Self.setupQueueWithRow(attemptCount: 0) + + // Force every subsequent incrementPendingSendAttemptCount call to throw + // so the drain closure takes the bump-failure park branch. + struct FakeSaveFailure: Error {} + await harness.store.setIncrementPendingSendAttemptCountFaultInjection { + throw FakeSaveFailure() } - /// Case 2 / Process restart: a row left at `attemptCount = 1` simulates - /// "prior process bumped before sending then died before - /// deletePendingSendsForMessage". On rehydrate the drain bumps to 2 and - /// preserves the wire timestamp so mesh dedup catches a duplicate - /// landing if the wire send completed. - @Test("process restart: row with attemptCount=1 rehydrates with preserveTimestamp=true") - func processRestartPreservesTimestampOnRehydrate() async throws { - let harness = try await Self.setupQueueWithRow(attemptCount: 1) - - await harness.service.hydrate() - try await waitUntil(timeout: .seconds(10), "rehydrate drain must bump 1 → 2") { - let row = (try? await harness.store.fetchPendingSends(radioID: harness.radioID))? - .first(where: { $0.messageID == harness.messageID }) - return row?.attemptCount == 2 - } - - let postDrain = try await harness.store.fetchPendingSends(radioID: harness.radioID) - #expect(postDrain.first(where: { $0.messageID == harness.messageID })?.attemptCount == 2, - "rehydrate drain bumps 1 → 2") - - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.timestamp == harness.originalTimestamp, - "rehydrate must preserve original wire timestamp so mesh dedup catches duplicate landing") - - await harness.service.shutdown() - } + await harness.service.hydrate() + try? await Task.sleep(for: .milliseconds(500)) - /// Case 3 / Bump failure: when `incrementPendingSendAttemptCount` throws, - /// the drain closure must park the envelope on the transport-open trigger - /// (status reverts to `.pending`), leave the PendingSend row intact, and - /// must not call `sendPendingDirectMessage`. Without this guarantee a - /// SwiftData failure during the bump could either drop the envelope - /// silently or double-send on retry. - @Test("bump failure parks envelope, preserves row, does not call sendPendingDirectMessage") - func bumpFailureParksWithoutSending() async throws { - let harness = try await Self.setupQueueWithRow(attemptCount: 0) - - // Force every subsequent incrementPendingSendAttemptCount call to throw - // so the drain closure takes the bump-failure park branch. - struct FakeSaveFailure: Error {} - await harness.store.setIncrementPendingSendAttemptCountFaultInjection { - throw FakeSaveFailure() - } - - await harness.service.hydrate() - try? await Task.sleep(for: .milliseconds(500)) - - let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - #expect(rows.count == 1, - "PendingSend row must survive the bump-failure park so the next transport-open can retry") - #expect(rows.first?.attemptCount == 0, - "attemptCount must not advance when the bump throws — the next drain bumps to the same target") - - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.timestamp == harness.originalTimestamp, - "bump-failure park must run before any wire-affecting work — wire timestamp untouched") - #expect(postDrainMessage?.status == .pending, - "park branch must remap status back to .pending so the bubble does not flicker .failed") - - await harness.service.shutdown() - } + let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + #expect(rows.count == 1, + "PendingSend row must survive the bump-failure park so the next transport-open can retry") + #expect(rows.first?.attemptCount == 0, + "attemptCount must not advance when the bump throws — the next drain bumps to the same target") + + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.timestamp == harness.originalTimestamp, + "bump-failure park must run before any wire-affecting work — wire timestamp untouched") + #expect(postDrainMessage?.status == .pending, + "park branch must remap status back to .pending so the bubble does not flicker .failed") + + await harness.service.shutdown() + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift index eeee6517..e9e44337 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift @@ -1,1198 +1,1198 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore import MeshCoreTestSupport +import Testing @Suite("ChatSendQueueService") @MainActor struct ChatSendQueueServiceTests { + private static func makeStore() throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private static func makeMessageService(dataStore: PersistenceStore) async -> MessageService { + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + return MessageService(session: session, dataStore: dataStore, contactService: nil) + } + + private static func makeChannelService(dataStore: PersistenceStore) async -> ChannelService { + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + return ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + } + + /// PendingSend row pointing at a contact that was deleted between + /// enqueue and hydrate. The send closure's contact lookup fails, + /// which is the "drop envelope" path — the row is purged without + /// calling `sendPendingDirectMessage`. This exercises hydrate's queue + /// loading and the queue's drain → onError → row-cleanup path on + /// the simplest available transport-independent surface. + @Test + func `hydrate replays persisted PendingSend rows and drains them`() async throws { + let store = try Self.makeStore() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + let envelope = DirectMessageEnvelope(messageID: messageID, contactID: contactID) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envelope, radioID: radioID) + ) - private static func makeStore() throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } + let preRows = try await store.fetchPendingSends(radioID: radioID) + #expect(preRows.count == 1, "fixture should have inserted exactly one row") + + let messageService = await Self.makeMessageService(dataStore: store) + let channelService = await Self.makeChannelService(dataStore: store) + let service = ChatSendQueueService( + radioID: radioID, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService() + ) - private static func makeMessageService(dataStore: PersistenceStore) async -> MessageService { - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - return MessageService(session: session, dataStore: dataStore, contactService: nil) - } + await service.hydrate() + await service.awaitDrainCompletion() + + let rowsAfter = try await store.fetchPendingSends(radioID: radioID) + #expect(rowsAfter.isEmpty, "hydrate + drain should clear the persisted row") + } + + /// A `PendingSend` row whose send fails with a transient transport + /// error must survive the failure — the closure parks in + /// `withCooperativeTimeout` on `triggers.wait(forAnyOf:)`, and the + /// row stays on disk until either the trigger fires or the deadline + /// elapses. Without `transportDidOpen()`, the wait suspends. + @Test + func `transient send error preserves the persisted row while parked on the trigger`() async throws { + let device = Device( + publicKey: Data(repeating: 1, count: 32), + nodeName: "Test Device" + ) + let container = try PersistenceStore.createContainer(inMemory: true) + container.mainContext.insert(device) + try container.mainContext.save() + let store = PersistenceStore(modelContainer: container) + + let radioID = device.id + let contact = Contact( + radioID: radioID, + publicKey: Data(repeating: 2, count: 32), + name: "Test Contact" + ) + container.mainContext.insert(contact) + try container.mainContext.save() + let contactDTO = try #require(try await store.fetchContact(id: contact.id)) + + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + let messageService = MessageService(session: session, dataStore: store, contactService: nil) + let message = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) + + let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contactDTO.id) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envelope, radioID: radioID) + ) - private static func makeChannelService(dataStore: PersistenceStore) async -> ChannelService { - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - return ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - } + let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) + let service = ChatSendQueueService( + radioID: radioID, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService() + ) - /// PendingSend row pointing at a contact that was deleted between - /// enqueue and hydrate. The send closure's contact lookup fails, - /// which is the "drop envelope" path — the row is purged without - /// calling `sendPendingDirectMessage`. This exercises hydrate's queue - /// loading and the queue's drain → onError → row-cleanup path on - /// the simplest available transport-independent surface. - @Test("hydrate replays persisted PendingSend rows and drains them") - func hydrateReplaysRows() async throws { - let store = try Self.makeStore() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - let envelope = DirectMessageEnvelope(messageID: messageID, contactID: contactID) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envelope, radioID: radioID) - ) - - let preRows = try await store.fetchPendingSends(radioID: radioID) - #expect(preRows.count == 1, "fixture should have inserted exactly one row") - - let messageService = await Self.makeMessageService(dataStore: store) - let channelService = await Self.makeChannelService(dataStore: store) - let service = ChatSendQueueService( - radioID: radioID, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService() - ) - - await service.hydrate() - await service.awaitDrainCompletion() - - let rowsAfter = try await store.fetchPendingSends(radioID: radioID) - #expect(rowsAfter.isEmpty, "hydrate + drain should clear the persisted row") - } + await service.hydrate() + // Give the drain time to call sendPendingDirectMessage and throw + // a transient transport error, parking inside withCooperativeTimeout. + try? await Task.sleep(for: .milliseconds(200)) + let rowsMidFlight = try await store.fetchPendingSends(radioID: radioID) + #expect(rowsMidFlight.count == 1, + "transient error must not delete the row while suspended on the trigger") + + // Release the queue so the actor can deinit at test scope end — + // otherwise its drain task suspends for up to `transportWaitTimeout` + // before the cooperative timeout fires. + await service.shutdown() + } + + /// `hydrate` must filter by the service's `radioID`. A row inserted + /// for a different radio must not flow into this service's queues + /// (which would either send the wrong envelope or, at minimum, + /// delete the foreign row when the contact lookup fails on the + /// drop path). Regression guard against a future "optimization" + /// that drops the `radioID` predicate from `fetchPendingSends`. + @Test + func `hydrate processes only the service's own radio rows`() async throws { + let store = try Self.makeStore() + let radioA = UUID() + let radioB = UUID() + let envA1 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + let envA2 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + let envB = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envA1, radioID: radioA) + ) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envA2, radioID: radioA) + ) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envB, radioID: radioB) + ) - /// A `PendingSend` row whose send fails with a transient transport - /// error must survive the failure — the closure parks in - /// `withCooperativeTimeout` on `triggers.wait(forAnyOf:)`, and the - /// row stays on disk until either the trigger fires or the deadline - /// elapses. Without `transportDidOpen()`, the wait suspends. - @Test("transient send error preserves the persisted row while parked on the trigger") - func transientErrorPreservesRow() async throws { - let device = Device( - publicKey: Data(repeating: 1, count: 32), - nodeName: "Test Device" - ) - let container = try PersistenceStore.createContainer(inMemory: true) - container.mainContext.insert(device) - try container.mainContext.save() - let store = PersistenceStore(modelContainer: container) - - let radioID = device.id - let contact = Contact( - radioID: radioID, - publicKey: Data(repeating: 2, count: 32), - name: "Test Contact" - ) - container.mainContext.insert(contact) - try container.mainContext.save() - let contactDTO = try #require(try await store.fetchContact(id: contact.id)) - - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - let messageService = MessageService(session: session, dataStore: store, contactService: nil) - let message = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) - - let envelope = DirectMessageEnvelope(messageID: message.id, contactID: contactDTO.id) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envelope, radioID: radioID) - ) - - let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) - let service = ChatSendQueueService( - radioID: radioID, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService() - ) - - await service.hydrate() - // Give the drain time to call sendPendingDirectMessage and throw - // a transient transport error, parking inside withCooperativeTimeout. - try? await Task.sleep(for: .milliseconds(200)) - let rowsMidFlight = try await store.fetchPendingSends(radioID: radioID) - #expect(rowsMidFlight.count == 1, - "transient error must not delete the row while suspended on the trigger") - - // Release the queue so the actor can deinit at test scope end — - // otherwise its drain task suspends for up to `transportWaitTimeout` - // before the cooperative timeout fires. - await service.shutdown() - } + let messageService = await Self.makeMessageService(dataStore: store) + let channelService = await Self.makeChannelService(dataStore: store) + let service = ChatSendQueueService( + radioID: radioA, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService() + ) - /// `hydrate` must filter by the service's `radioID`. A row inserted - /// for a different radio must not flow into this service's queues - /// (which would either send the wrong envelope or, at minimum, - /// delete the foreign row when the contact lookup fails on the - /// drop path). Regression guard against a future "optimization" - /// that drops the `radioID` predicate from `fetchPendingSends`. - @Test("hydrate processes only the service's own radio rows") - func hydrateScopesByRadioID() async throws { - let store = try Self.makeStore() - let radioA = UUID() - let radioB = UUID() - let envA1 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - let envA2 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - let envB = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envA1, radioID: radioA) - ) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envA2, radioID: radioA) - ) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envB, radioID: radioB) - ) - - let messageService = await Self.makeMessageService(dataStore: store) - let channelService = await Self.makeChannelService(dataStore: store) - let service = ChatSendQueueService( - radioID: radioA, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService() - ) - - await service.hydrate() - await service.awaitDrainCompletion() - - let radioARowsAfter = try await store.fetchPendingSends(radioID: radioA) - let radioBRowsAfter = try await store.fetchPendingSends(radioID: radioB) - #expect(radioARowsAfter.isEmpty, - "hydrate must drain only the service's own radio rows") - #expect(radioBRowsAfter.count == 1, - "hydrate must not touch another radio's rows") - #expect(radioBRowsAfter.first?.messageID == envB.messageID, - "the surviving row must be the foreign radio's original envelope") - } + await service.hydrate() + await service.awaitDrainCompletion() + + let radioARowsAfter = try await store.fetchPendingSends(radioID: radioA) + let radioBRowsAfter = try await store.fetchPendingSends(radioID: radioB) + #expect(radioARowsAfter.isEmpty, + "hydrate must drain only the service's own radio rows") + #expect(radioBRowsAfter.count == 1, + "hydrate must not touch another radio's rows") + #expect(radioBRowsAfter.first?.messageID == envB.messageID, + "the surviving row must be the foreign radio's original envelope") + } + + /// `fetchPendingSends` orders by sequence ASC, and `hydrate` iterates + /// that result with sequential `await dmQueue.enqueue(_:)` calls, + /// which `SendQueue` appends to a FIFO list. This verifies the + /// persistence layer preserves enqueue order across the hydrate + /// path so a future "optimize the fetch" change can't silently + /// reorder replays. We assert at the fetch boundary because the + /// downstream `SendQueue` order is exercised by its own tests. + @Test + func `hydrate fetch returns rows in sequence order`() async throws { + let store = try Self.makeStore() + let radioID = UUID() + let envA = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + let envB = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + let envC = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envA, radioID: radioID) + ) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envB, radioID: radioID) + ) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envC, radioID: radioID) + ) - /// `fetchPendingSends` orders by sequence ASC, and `hydrate` iterates - /// that result with sequential `await dmQueue.enqueue(_:)` calls, - /// which `SendQueue` appends to a FIFO list. This verifies the - /// persistence layer preserves enqueue order across the hydrate - /// path so a future "optimize the fetch" change can't silently - /// reorder replays. We assert at the fetch boundary because the - /// downstream `SendQueue` order is exercised by its own tests. - @Test("hydrate fetch returns rows in sequence order") - func hydrateFetchOrdersBySequence() async throws { - let store = try Self.makeStore() - let radioID = UUID() - let envA = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - let envB = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - let envC = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envA, radioID: radioID) - ) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envB, radioID: radioID) - ) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envC, radioID: radioID) - ) - - let rows = try await store.fetchPendingSends(radioID: radioID) - let messageIDs = rows.map(\.messageID) - #expect(messageIDs == [envA.messageID, envB.messageID, envC.messageID], - "fetchPendingSends must return rows in sequence ASC; hydrate depends on this for FIFO replay") + let rows = try await store.fetchPendingSends(radioID: radioID) + let messageIDs = rows.map(\.messageID) + #expect(messageIDs == [envA.messageID, envB.messageID, envC.messageID], + "fetchPendingSends must return rows in sequence ASC; hydrate depends on this for FIFO replay") + } + + /// Regression: the classifier must unwrap `MessageServiceError.sessionError` + /// so the wrapped form produced by `failMessageAndRethrow` matches. DM path + /// treats firmware code 3 (TABLE_FULL pool exhaustion) as transient. + @Test + func `isTransientDirectMessageError unwraps sessionError on deviceError(3)`() { + let wrapped = MessageServiceError.sessionError(.deviceError(code: 3)) + #expect(ChatSendQueueService.isTransientDirectMessageError(wrapped) == true) + } + + /// Symmetric regression for the channel path: code 2 (NOT_FOUND pool + /// exhaustion / stale channel index) is transient when wrapped in + /// MessageServiceError.sessionError. + @Test + func `isTransientChannelMessageError unwraps sessionError on deviceError(2)`() { + let wrapped = MessageServiceError.sessionError(.deviceError(code: 2)) + #expect(ChatSendQueueService.isTransientChannelMessageError(wrapped) == true) + } + + /// A DM-path classifier must not park on code 2 — only code 3 is the DM + /// pool-exhaustion signal. This locks in the asymmetry between paths. + @Test + func `isTransientDirectMessageError treats deviceError(2) as terminal`() { + let wrapped = MessageServiceError.sessionError(.deviceError(code: 2)) + #expect(ChatSendQueueService.isTransientDirectMessageError(wrapped) == false) + } + + /// Symmetric guard for the channel classifier: code 3 is the DM signal, + /// not the channel one. + @Test + func `isTransientChannelMessageError treats deviceError(3) as terminal`() { + let wrapped = MessageServiceError.sessionError(.deviceError(code: 3)) + #expect(ChatSendQueueService.isTransientChannelMessageError(wrapped) == false) + } + + /// Regression: the channel-cap helper recognises the raw + /// `MeshCoreError.deviceError(2)` shape produced by `withPoolBackoff`'s + /// re-throw before `MessageService` wraps it. + @Test + func `isChannelMessageNotFound matches raw MeshCoreError.deviceError(2)`() { + let raw: Error = MeshCoreError.deviceError(code: FirmwareDeviceErrorCode.channelMessageNotFound) + #expect(ChatSendQueueService.isChannelMessageNotFound(raw) == true) + } + + /// Regression: the helper unwraps the `MessageServiceError.sessionError` + /// shape produced by `failMessageAndRethrow` in `MessageService`. + @Test + func `isChannelMessageNotFound unwraps MessageServiceError.sessionError(deviceError(2))`() { + let wrapped: Error = MessageServiceError.sessionError(.deviceError(code: FirmwareDeviceErrorCode.channelMessageNotFound)) + #expect(ChatSendQueueService.isChannelMessageNotFound(wrapped) == true) + } + + /// Regression: only firmware code 2 maps to the cap. Other firmware codes + /// (e.g. code 3 — the DM TABLE_FULL signal) must not be classified as + /// NOT_FOUND, since the channel cap should not trigger on DM-pool exhaustion. + @Test + func `isChannelMessageNotFound rejects non-NOT_FOUND device errors`() { + let wrongCode: Error = MeshCoreError.deviceError(code: FirmwareDeviceErrorCode.directMessageTableFull) + let wrappedWrongCode: Error = MessageServiceError.sessionError(.deviceError(code: FirmwareDeviceErrorCode.directMessageTableFull)) + let timeout: Error = MeshCoreError.timeout + #expect(ChatSendQueueService.isChannelMessageNotFound(wrongCode) == false) + #expect(ChatSendQueueService.isChannelMessageNotFound(wrappedWrongCode) == false) + #expect(ChatSendQueueService.isChannelMessageNotFound(timeout) == false) + } + + /// End-to-end channel-cap behaviour on a high-attempt envelope. A channel + /// envelope with `attemptCount = 7` pre-loaded on its PendingSend row + /// reaches `postBumpCount = 8` on its next drain. When the channel send + /// throws `deviceError(channelMessageNotFound)` the cap branch fires: the + /// catch re-throws (rather than parking), the SendQueue's `onError` deletes + /// the PendingSend row, and the message stays `.failed` (the `.failed` + /// write made by `failMessageAndRethrow` is not remapped back to `.pending`). + /// + /// Drives the channel-send failure by dispatching `.error(code: 2)` events + /// in a tight loop so every `withPoolBackoff` retry sees the firmware + /// `NOT_FOUND` signal. Pool backoff exhausts after 3 in-loop attempts and + /// re-throws as `MessageServiceError.sessionError(.deviceError(2))`, which + /// reaches the queue catch. + @Test( + .disabled(""" + The previous shape exercised the maxChannelNotFoundRetries cap by driving \ + NOT_FOUND through resendChannelMessage. The new behaviour disambiguates by \ + calling ChannelService.fetchChannel(index:); that requires either a mockable \ + ChannelService or a session test harness that resolves getChannel(index:) \ + against the dispatched NOT_FOUND event. Re-enable once that harness lands. + """) + ) + func `channel drain treats deviceError(2) as terminal when fetchChannel confirms the channel is gone`() async throws { + let harness = try await Self.setUpChannelCapHarness(attemptCount: 7) + let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) + defer { dispatchTask.cancel() } + + await harness.queueService.hydrate() + await Self.waitForPendingSendDrained(messageID: harness.messageID, store: harness.store) + + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.status == .failed, + "cap branch must re-throw so .failed is not remapped to .pending") + let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + #expect(postDrainRows.isEmpty, + "cap branch terminal re-throw must route through onError, deleting the PendingSend row") + + dispatchTask.cancel() + await harness.queueService.shutdown() + } + + /// Negative case for the disambiguation gate. A channel envelope with + /// `attemptCount = 0` reaches `postBumpCount = 1` on its first drain. Even + /// with the same `deviceError(channelMessageNotFound)` failure the + /// disambiguation does not fire (1 < disambiguateAfterAttempts). The + /// transient branch instead remaps the status back to `.pending` and parks + /// the envelope. + @Test + func `channel drain parks deviceError(2) below disambiguateAfterAttempts`() async throws { + let harness = try await Self.setUpChannelCapHarness( + attemptCount: 0, + messageConfig: MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) + ) + let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) + defer { dispatchTask.cancel() } + + await harness.queueService.hydrate() + // The shrunk pool-backoff lets the first drain bump attemptCount, + // exhaust backoff in tens of milliseconds, then the transient catch + // branch remaps the `.failed` write back to `.pending` and suspends in + // waitForTransportOpen with no further trigger. Poll for that parked + // steady-state — gated on attemptCount >= 1 so it cannot match the + // initial pre-drain `.pending` — rather than sleeping a fixed interval + // that races a loaded runner against the backoff schedule. + try await Self.waitForCondition(timeout: .seconds(15)) { + let message = try await harness.store.fetchMessage(id: harness.messageID) + let row = try await harness.store.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + return (row?.attemptCount ?? 0) >= 1 && message?.status == .pending } - /// Regression: the classifier must unwrap `MessageServiceError.sessionError` - /// so the wrapped form produced by `failMessageAndRethrow` matches. DM path - /// treats firmware code 3 (TABLE_FULL pool exhaustion) as transient. - @Test("isTransientDirectMessageError unwraps sessionError on deviceError(3)") - func directMessageClassifierUnwrapsSessionErrorOnCode3() { - let wrapped = MessageServiceError.sessionError(.deviceError(code: 3)) - #expect(ChatSendQueueService.isTransientDirectMessageError(wrapped) == true) - } + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.status == .pending, + "transient branch must remap .failed back to .pending so the bubble does not flicker") + let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + #expect(postDrainRows.contains(where: { $0.messageID == harness.messageID }), + "park branch must preserve the PendingSend row for the next transport-open retry") + let postRow = postDrainRows.first(where: { $0.messageID == harness.messageID }) + #expect((postRow?.attemptCount ?? 0) >= 1, + "top-of-drain bump must persist attemptCount >= 1 before the channel send fails") + + dispatchTask.cancel() + await harness.queueService.shutdown() + } + + /// Regression: the channel `fetchChannel` failure counter must be scoped + /// per envelope, not shared across the service. When envelope A drains + /// itself through the cap (consecutive `fetchChannel` throws), the + /// counter must not strand a value that causes the next envelope's + /// first disambiguate-path throw to immediately exceed the cap. + /// + /// With a service-wide `channelFetchFailureCounter` (single + /// `FailureCounter`), envelope A leaves it at the cap, then envelope B's + /// first drain enters disambiguate (postBumpCount >= 3), fetchChannel + /// throws, counter bumps past the cap, `failures >= cap` is true → B + /// terminal-fails on its first attempt. + /// + /// With a per-messageID counter, envelope B starts at 0, bumps to 1 on its + /// first throw, well below the cap → B parks. + /// + /// The harness uses a shrunk cap and tight pool-backoff to keep runtime + /// bounded; the behaviour under test is per-envelope counter scoping, + /// not the cap's absolute value. + @Test + func `Channel fetchChannel failure counter is per-envelope and does not cascade`() async throws { + let queueConfig = ChatSendQueueConfig(maxConsecutiveFetchChannelFailures: 2) + let messageConfig = MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) - /// Symmetric regression for the channel path: code 2 (NOT_FOUND pool - /// exhaustion / stale channel index) is transient when wrapped in - /// MessageServiceError.sessionError. - @Test("isTransientChannelMessageError unwraps sessionError on deviceError(2)") - func channelMessageClassifierUnwrapsSessionErrorOnCode2() { - let wrapped = MessageServiceError.sessionError(.deviceError(code: 2)) - #expect(ChatSendQueueService.isTransientChannelMessageError(wrapped) == true) - } + // attemptCount=7 → postBumpCount=8 on first drain, well above + // disambiguateAfterAttempts (3), so every drain attempt routes through + // fetchChannel and increments the counter. + let harness = try await Self.setUpChannelCapHarness( + attemptCount: 7, + queueConfig: queueConfig, + messageConfig: messageConfig + ) - /// A DM-path classifier must not park on code 2 — only code 3 is the DM - /// pool-exhaustion signal. This locks in the asymmetry between paths. - @Test("isTransientDirectMessageError treats deviceError(2) as terminal") - func directMessageClassifierTreatsCode2AsTerminal() { - let wrapped = MessageServiceError.sessionError(.deviceError(code: 2)) - #expect(ChatSendQueueService.isTransientDirectMessageError(wrapped) == false) + // attemptCount=2 → postBumpCount=3 on first drain, which is exactly + // at disambiguateAfterAttempts. Envelope B's first drain therefore + // enters the disambiguate path and exercises the counter. + let envelopeBSequence = 2 + let dtoB = PendingSendDTO( + id: UUID(), + radioID: harness.radioID, + messageID: harness.envelopeB.messageID, + kind: .channel, + contactID: nil, + channelIndex: harness.envelopeB.channelIndex, + isResend: harness.envelopeB.isResend, + messageText: harness.envelopeB.messageText, + messageTimestamp: harness.envelopeB.messageTimestamp, + localNodeName: harness.envelopeB.localNodeName, + sequence: envelopeBSequence, + enqueuedAt: Date(), + attemptCount: 2 + ) + try await harness.store.upsertPendingSend(dtoB) + + // Continuous .error(channelMessageNotFound) dispatching drives both + // envelopes' pool-backoff loops and never lets the counter reset via + // a successful fetchChannel. The cap path runs. + let dispatchTask = Self.startDeviceErrorDispatch( + service: harness.messageService, + code: FirmwareDeviceErrorCode.channelMessageNotFound + ) + defer { dispatchTask.cancel() } + + // Fire the transport-open trigger continuously so the 30s park after + // each drain attempt completes immediately. Without this, 16 drain + // attempts for envelope A would take >8 minutes; with it, each + // attempt is bounded by pool-backoff (~3.5s) + fetchChannel (~1.9s). + let triggerSpammer = Task.detached { [queue = harness.queueService] in + while !Task.isCancelled { + await queue.transportDidOpen() + try? await Task.sleep(for: .milliseconds(50)) + } } + defer { triggerSpammer.cancel() } - /// Symmetric guard for the channel classifier: code 3 is the DM signal, - /// not the channel one. - @Test("isTransientChannelMessageError treats deviceError(3) as terminal") - func channelMessageClassifierTreatsCode3AsTerminal() { - let wrapped = MessageServiceError.sessionError(.deviceError(code: 3)) - #expect(ChatSendQueueService.isTransientChannelMessageError(wrapped) == false) - } + await harness.queueService.hydrate() - /// Regression: the channel-cap helper recognises the raw - /// `MeshCoreError.deviceError(2)` shape produced by `withPoolBackoff`'s - /// re-throw before `MessageService` wraps it. - @Test("isChannelMessageNotFound matches raw MeshCoreError.deviceError(2)") - func isChannelMessageNotFoundMatchesRawDeviceError() { - let raw: Error = MeshCoreError.deviceError(code: FirmwareDeviceErrorCode.channelMessageNotFound) - #expect(ChatSendQueueService.isChannelMessageNotFound(raw) == true) + // Envelope A cycles through `cap` fetchChannel throws and terminal-fails. + // True terminal-fail is observable as "PendingSend row deleted by + // SendQueue.onError" — the per-cycle `.failed → .pending` oscillation + // inside `failMessageAndRethrow` + park remap is not a terminal signal. + try await Self.waitForCondition(timeout: .seconds(30)) { + let exists = try await harness.store.hasPendingSend(messageID: harness.envelopeA.messageID) + return exists == false } - /// Regression: the helper unwraps the `MessageServiceError.sessionError` - /// shape produced by `failMessageAndRethrow` in `MessageService`. - @Test("isChannelMessageNotFound unwraps MessageServiceError.sessionError(deviceError(2))") - func isChannelMessageNotFoundUnwrapsSessionError() { - let wrapped: Error = MessageServiceError.sessionError(.deviceError(code: FirmwareDeviceErrorCode.channelMessageNotFound)) - #expect(ChatSendQueueService.isChannelMessageNotFound(wrapped) == true) + let statusA = try await harness.store.fetchMessage(id: harness.envelopeA.messageID)?.status + #expect(statusA == .failed, + "Envelope A should be terminal-failed after persistent fetchChannel throws (cap reached)") + + // Envelope B should park (.pending) under the per-envelope counter; a + // service-wide counter would terminal-fail it on its first drain + // because the value is stranded at the cap. Wait long enough for at + // least one full B drain cycle to elapse so steady-state is observable. + try await Task.sleep(for: .seconds(3)) + + let statusB = try await harness.store.fetchMessage(id: harness.envelopeB.messageID)?.status + let existsB = try await harness.store.hasPendingSend(messageID: harness.envelopeB.messageID) + // True terminal-fail deletes the PendingSend row via SendQueue.onError + // and leaves the message at .failed. Park preserves the row and remaps + // .failed → .pending. Asserting on the row presence is the unambiguous + // signal — status oscillates within each drain cycle. + #expect(existsB == true, + "Envelope B's PendingSend row must survive (parked); the stranded counter caused it to be deleted as a terminal-fail. status=\(String(describing: statusB)) rowExists=\(existsB)") + + dispatchTask.cancel() + triggerSpammer.cancel() + await harness.queueService.shutdown() + } + + /// When the transport-open trigger never fires, the drain must time out + /// via `transportWaitTimeout` rather than waiting forever. Verifies the + /// message stays `.pending` and the PendingSend row survives across the + /// wait. Disabled by default because the production constant is 30 seconds + /// — exercise locally by uncommenting `.enabled` or by reducing the + /// timeout via a `#if DEBUG` hook. + @Test( + .disabled("Real-time test depends on the 30s transportWaitTimeout default; enable when reducing the constant via a #if DEBUG hook.") + ) + func `drain bounded wait re-attempts after transportWaitTimeout expires`() async throws { + let harness = try await Self.setUpChannelCapHarness(attemptCount: 0) + let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) + defer { dispatchTask.cancel() } + + await harness.queueService.hydrate() + try await Self.waitForCondition(timeout: .seconds(60)) { + let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + return (rows.first(where: { $0.messageID == harness.messageID })?.attemptCount ?? 0) >= 2 } - /// Regression: only firmware code 2 maps to the cap. Other firmware codes - /// (e.g. code 3 — the DM TABLE_FULL signal) must not be classified as - /// NOT_FOUND, since the channel cap should not trigger on DM-pool exhaustion. - @Test("isChannelMessageNotFound rejects non-NOT_FOUND device errors") - func isChannelMessageNotFoundRejectsOtherCodes() { - let wrongCode: Error = MeshCoreError.deviceError(code: FirmwareDeviceErrorCode.directMessageTableFull) - let wrappedWrongCode: Error = MessageServiceError.sessionError(.deviceError(code: FirmwareDeviceErrorCode.directMessageTableFull)) - let timeout: Error = MeshCoreError.timeout - #expect(ChatSendQueueService.isChannelMessageNotFound(wrongCode) == false) - #expect(ChatSendQueueService.isChannelMessageNotFound(wrappedWrongCode) == false) - #expect(ChatSendQueueService.isChannelMessageNotFound(timeout) == false) + let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) + #expect(postDrainMessage?.status == .pending) + let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) + #expect(postDrainRows.contains(where: { $0.messageID == harness.messageID })) + + dispatchTask.cancel() + await harness.queueService.shutdown() + } + + // MARK: - Channel-cap helpers + + /// In-memory test harness exercising the channel drain end-to-end. + /// + /// `queue` is the canonical accessor used by newer tests; `queueService` + /// remains a synonym so the existing channel-cap suites keep compiling + /// without churn. + /// + /// `envelopeA` is the channel envelope persisted by `setUpChannelCapHarness`. + /// `envelopeB` is a second envelope, not yet enqueued — callers that need + /// to exercise multi-envelope scenarios call `harness.queue.enqueueChannel(harness.envelopeB)` + /// themselves so each test controls its own drain ordering. + @MainActor + private final class ChannelCapHarness { + let queueService: ChatSendQueueService + var queue: ChatSendQueueService { + queueService } - /// End-to-end channel-cap behaviour on a high-attempt envelope. A channel - /// envelope with `attemptCount = 7` pre-loaded on its PendingSend row - /// reaches `postBumpCount = 8` on its next drain. When the channel send - /// throws `deviceError(channelMessageNotFound)` the cap branch fires: the - /// catch re-throws (rather than parking), the SendQueue's `onError` deletes - /// the PendingSend row, and the message stays `.failed` (the `.failed` - /// write made by `failMessageAndRethrow` is not remapped back to `.pending`). - /// - /// Drives the channel-send failure by dispatching `.error(code: 2)` events - /// in a tight loop so every `withPoolBackoff` retry sees the firmware - /// `NOT_FOUND` signal. Pool backoff exhausts after 3 in-loop attempts and - /// re-throws as `MessageServiceError.sessionError(.deviceError(2))`, which - /// reaches the queue catch. - @Test( - "channel drain treats deviceError(2) as terminal when fetchChannel confirms the channel is gone", - .disabled(""" - The previous shape exercised the maxChannelNotFoundRetries cap by driving \ - NOT_FOUND through resendChannelMessage. The new behaviour disambiguates by \ - calling ChannelService.fetchChannel(index:); that requires either a mockable \ - ChannelService or a session test harness that resolves getChannel(index:) \ - against the dispatched NOT_FOUND event. Re-enable once that harness lands. - """) - ) - func testChannelDrain_StaleChannelIndex_DropsEnvelopeWhenFetchChannelReturnsNil() async throws { - let harness = try await Self.setUpChannelCapHarness(attemptCount: 7) - let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) - defer { dispatchTask.cancel() } - - await harness.queueService.hydrate() - await Self.waitForPendingSendDrained(messageID: harness.messageID, store: harness.store) - - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.status == .failed, - "cap branch must re-throw so .failed is not remapped to .pending") - let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - #expect(postDrainRows.isEmpty, - "cap branch terminal re-throw must route through onError, deleting the PendingSend row") - - dispatchTask.cancel() - await harness.queueService.shutdown() + let messageService: MessageService + let store: PersistenceStore + let messageID: UUID + let radioID: UUID + let envelopeA: ChannelMessageEnvelope + let envelopeB: ChannelMessageEnvelope + private var dispatchTask: Task? + + init( + queueService: ChatSendQueueService, + messageService: MessageService, + store: PersistenceStore, + messageID: UUID, + radioID: UUID, + envelopeA: ChannelMessageEnvelope, + envelopeB: ChannelMessageEnvelope + ) { + self.queueService = queueService + self.messageService = messageService + self.store = store + self.messageID = messageID + self.radioID = radioID + self.envelopeA = envelopeA + self.envelopeB = envelopeB } - /// Negative case for the disambiguation gate. A channel envelope with - /// `attemptCount = 0` reaches `postBumpCount = 1` on its first drain. Even - /// with the same `deviceError(channelMessageNotFound)` failure the - /// disambiguation does not fire (1 < disambiguateAfterAttempts). The - /// transient branch instead remaps the status back to `.pending` and parks - /// the envelope. - @Test("channel drain parks deviceError(2) below disambiguateAfterAttempts") - func testChannelDrain_PoolExhaustion_ChannelStillExists_ParksEnvelope() async throws { - let harness = try await Self.setUpChannelCapHarness( - attemptCount: 0, - messageConfig: MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - ) - let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) - defer { dispatchTask.cancel() } - - await harness.queueService.hydrate() - // The shrunk pool-backoff lets the first drain bump attemptCount, - // exhaust backoff in tens of milliseconds, then the transient catch - // branch remaps the `.failed` write back to `.pending` and suspends in - // waitForTransportOpen with no further trigger. Poll for that parked - // steady-state — gated on attemptCount >= 1 so it cannot match the - // initial pre-drain `.pending` — rather than sleeping a fixed interval - // that races a loaded runner against the backoff schedule. - try await Self.waitForCondition(timeout: .seconds(15)) { - let message = try await harness.store.fetchMessage(id: harness.messageID) - let row = try await harness.store.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - return (row?.attemptCount ?? 0) >= 1 && message?.status == .pending + /// Drive `fetchChannel(index:)` failures by streaming `.error(code:)` + /// events at the dispatcher's fixed cadence. The same event stream + /// also throws `MeshCoreError.deviceError(code)` on the channel send + /// path, so callers asserting the disambiguation step should set + /// `count` high enough to cover the in-loop pool-backoff retries + /// plus the trailing `fetchChannel` round-trips. + /// + /// `count == Int.max` dispatches indefinitely until `tearDown()` is + /// called. A finite count emits up to that many events and then + /// stops, allowing later attempts to surface their own outcomes + /// (timeouts or the channel-still-exists branch). + func setFetchChannelFailureCount(_ count: Int) async { + dispatchTask?.cancel() + let messageService = messageService + let target = count + dispatchTask = Task.detached { + let session = await messageService.sessionForTest + var emitted = 0 + while !Task.isCancelled, emitted < target { + await session.dispatchForTesting(.error(code: FirmwareDeviceErrorCode.channelMessageNotFound)) + if target != Int.max { + emitted += 1 + } + try? await Task.sleep(for: .milliseconds(20)) } - - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.status == .pending, - "transient branch must remap .failed back to .pending so the bubble does not flicker") - let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - #expect(postDrainRows.contains(where: { $0.messageID == harness.messageID }), - "park branch must preserve the PendingSend row for the next transport-open retry") - let postRow = postDrainRows.first(where: { $0.messageID == harness.messageID }) - #expect((postRow?.attemptCount ?? 0) >= 1, - "top-of-drain bump must persist attemptCount >= 1 before the channel send fails") - - dispatchTask.cancel() - await harness.queueService.shutdown() + } } - /// Regression: the channel `fetchChannel` failure counter must be scoped - /// per envelope, not shared across the service. When envelope A drains - /// itself through the cap (consecutive `fetchChannel` throws), the - /// counter must not strand a value that causes the next envelope's - /// first disambiguate-path throw to immediately exceed the cap. + /// Run one drain cycle to completion: call `hydrate()` if it has not + /// yet run, then wait for the message to reach a terminal status + /// (`.failed` or `.sent`) or for the PendingSend row to be deleted. + /// Bounded at 8 seconds to keep test runtime predictable; the + /// pool-backoff loop exhausts in ~3.5s under steady error dispatch. /// - /// With a service-wide `channelFetchFailureCounter` (single - /// `FailureCounter`), envelope A leaves it at the cap, then envelope B's - /// first drain enters disambiguate (postBumpCount >= 3), fetchChannel - /// throws, counter bumps past the cap, `failures >= cap` is true → B - /// terminal-fails on its first attempt. - /// - /// With a per-messageID counter, envelope B starts at 0, bumps to 1 on its - /// first throw, well below the cap → B parks. - /// - /// The harness uses a shrunk cap and tight pool-backoff to keep runtime - /// bounded; the behaviour under test is per-envelope counter scoping, - /// not the cap's absolute value. - @Test("Channel fetchChannel failure counter is per-envelope and does not cascade") - func channelFetchFailureCounter_IsPerEnvelope_DoesNotCascade() async throws { - let queueConfig = ChatSendQueueConfig(maxConsecutiveFetchChannelFailures: 2) - let messageConfig = MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - - // attemptCount=7 → postBumpCount=8 on first drain, well above - // disambiguateAfterAttempts (3), so every drain attempt routes through - // fetchChannel and increments the counter. - let harness = try await Self.setUpChannelCapHarness( - attemptCount: 7, - queueConfig: queueConfig, - messageConfig: messageConfig - ) - - // attemptCount=2 → postBumpCount=3 on first drain, which is exactly - // at disambiguateAfterAttempts. Envelope B's first drain therefore - // enters the disambiguate path and exercises the counter. - let envelopeBSequence = 2 - let dtoB = PendingSendDTO( - id: UUID(), - radioID: harness.radioID, - messageID: harness.envelopeB.messageID, - kind: .channel, - contactID: nil, - channelIndex: harness.envelopeB.channelIndex, - isResend: harness.envelopeB.isResend, - messageText: harness.envelopeB.messageText, - messageTimestamp: harness.envelopeB.messageTimestamp, - localNodeName: harness.envelopeB.localNodeName, - sequence: envelopeBSequence, - enqueuedAt: Date(), - attemptCount: 2 - ) - try await harness.store.upsertPendingSend(dtoB) - - // Continuous .error(channelMessageNotFound) dispatching drives both - // envelopes' pool-backoff loops and never lets the counter reset via - // a successful fetchChannel. The cap path runs. - let dispatchTask = Self.startDeviceErrorDispatch( - service: harness.messageService, - code: FirmwareDeviceErrorCode.channelMessageNotFound - ) - defer { dispatchTask.cancel() } - - // Fire the transport-open trigger continuously so the 30s park after - // each drain attempt completes immediately. Without this, 16 drain - // attempts for envelope A would take >8 minutes; with it, each - // attempt is bounded by pool-backoff (~3.5s) + fetchChannel (~1.9s). - let triggerSpammer = Task.detached { [queue = harness.queueService] in - while !Task.isCancelled { - await queue.transportDidOpen() - try? await Task.sleep(for: .milliseconds(50)) - } + /// A parked envelope (row preserved with bumped `attemptCount`) is + /// not treated as terminal here — callers that expect park as a + /// success outcome should poll the row directly after this returns. + func drainOnce(timeout: Duration = .seconds(8)) async { + await queueService.hydrate() + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if let message = try? await store.fetchMessage(id: messageID), + message.status == .failed || message.status == .sent { + return } - defer { triggerSpammer.cancel() } - - await harness.queueService.hydrate() - - // Envelope A cycles through `cap` fetchChannel throws and terminal-fails. - // True terminal-fail is observable as "PendingSend row deleted by - // SendQueue.onError" — the per-cycle `.failed → .pending` oscillation - // inside `failMessageAndRethrow` + park remap is not a terminal signal. - try await Self.waitForCondition(timeout: .seconds(30)) { - let exists = try await harness.store.hasPendingSend(messageID: harness.envelopeA.messageID) - return exists == false + if let exists = try? await store.hasPendingSend(messageID: messageID), exists == false { + return } + try? await Task.sleep(for: .milliseconds(50)) + } + } - let statusA = try await harness.store.fetchMessage(id: harness.envelopeA.messageID)?.status - #expect(statusA == .failed, - "Envelope A should be terminal-failed after persistent fetchChannel throws (cap reached)") - - // Envelope B should park (.pending) under the per-envelope counter; a - // service-wide counter would terminal-fail it on its first drain - // because the value is stranded at the cap. Wait long enough for at - // least one full B drain cycle to elapse so steady-state is observable. - try await Task.sleep(for: .seconds(3)) - - let statusB = try await harness.store.fetchMessage(id: harness.envelopeB.messageID)?.status - let existsB = try await harness.store.hasPendingSend(messageID: harness.envelopeB.messageID) - // True terminal-fail deletes the PendingSend row via SendQueue.onError - // and leaves the message at .failed. Park preserves the row and remaps - // .failed → .pending. Asserting on the row presence is the unambiguous - // signal — status oscillates within each drain cycle. - #expect(existsB == true, - "Envelope B's PendingSend row must survive (parked); the stranded counter caused it to be deleted as a terminal-fail. status=\(String(describing: statusB)) rowExists=\(existsB)") - - dispatchTask.cancel() - triggerSpammer.cancel() - await harness.queueService.shutdown() + func tearDown() async { + dispatchTask?.cancel() + dispatchTask = nil + await queueService.shutdown() } + } + + /// Build a Device + Channel + Message + PendingSend with the requested + /// `attemptCount`. Returns the connected `ChatSendQueueService` (not yet + /// hydrated) and the supporting fixtures so callers can drive the drain. + /// + /// The PendingSend row is built with `isResend = true` so the drain + /// closure calls `MessageService.resendChannelMessage`, which routes + /// through `withPoolBackoff` and `failMessageAndRethrow` — matching the + /// retry path the cap is intended to bound. + private static func setUpChannelCapHarness( + attemptCount: Int?, + queueConfig: ChatSendQueueConfig = .default, + messageConfig: MessageServiceConfig = .default + ) async throws -> ChannelCapHarness { + let device = Device( + publicKey: Data(repeating: 0x11, count: 32), + nodeName: "Test Radio" + ) + let container = try PersistenceStore.createContainer(inMemory: true) + container.mainContext.insert(device) + try container.mainContext.save() + let store = PersistenceStore(modelContainer: container) + let radioID = device.radioID + + let channelIndex: UInt8 = 0 + let channel = Channel( + radioID: radioID, + index: channelIndex, + name: "Test Channel", + secret: Data(repeating: 0xAA, count: 16) + ) + container.mainContext.insert(channel) + try container.mainContext.save() + + // 200ms session timeout so each pool-backoff attempt rejects quickly + // if the test's dispatcher loop misses its window. The transport is + // connected so `transport.send` does not throw `notConnected` and the + // resulting error path runs through the dispatcher / matcher flow + // the cap is intended to exercise. + let transport = SimulatorMockTransport() + try await transport.connect() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2) + ) + let messageService = MessageService(session: session, dataStore: store, contactService: nil, config: messageConfig) - /// When the transport-open trigger never fires, the drain must time out - /// via `transportWaitTimeout` rather than waiting forever. Verifies the - /// message stays `.pending` and the PendingSend row survives across the - /// wait. Disabled by default because the production constant is 30 seconds - /// — exercise locally by uncommenting `.enabled` or by reducing the - /// timeout via a `#if DEBUG` hook. - @Test( - "drain bounded wait re-attempts after transportWaitTimeout expires", - .disabled("Real-time test depends on the 30s transportWaitTimeout default; enable when reducing the constant via a #if DEBUG hook.") + let pending = try await messageService.createPendingChannelMessage( + text: "Hello channel", + channelIndex: channelIndex, + radioID: radioID ) - func testDrain_TimeoutWithoutFire_LogsAndRequeues() async throws { - let harness = try await Self.setUpChannelCapHarness(attemptCount: 0) - let dispatchTask = Self.startDeviceErrorDispatch(service: harness.messageService, code: FirmwareDeviceErrorCode.channelMessageNotFound) - defer { dispatchTask.cancel() } - - await harness.queueService.hydrate() - try await Self.waitForCondition(timeout: .seconds(60)) { - let rows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - return (rows.first(where: { $0.messageID == harness.messageID })?.attemptCount ?? 0) >= 2 - } - let postDrainMessage = try await harness.store.fetchMessage(id: harness.messageID) - #expect(postDrainMessage?.status == .pending) - let postDrainRows = try await harness.store.fetchPendingSends(radioID: harness.radioID) - #expect(postDrainRows.contains(where: { $0.messageID == harness.messageID })) + let envelopeA = ChannelMessageEnvelope( + messageID: pending.id, + channelIndex: channelIndex, + isResend: true, + messageText: pending.text, + messageTimestamp: pending.timestamp, + localNodeName: "Test Radio" + ) + let dto = PendingSendDTO( + id: UUID(), + radioID: radioID, + messageID: envelopeA.messageID, + kind: .channel, + contactID: nil, + channelIndex: channelIndex, + isResend: true, + messageText: pending.text, + messageTimestamp: pending.timestamp, + localNodeName: "Test Radio", + sequence: 1, + enqueuedAt: Date(), + attemptCount: attemptCount + ) + try await store.upsertPendingSend(dto) + + // Second envelope for the same channel — never persisted at setup + // time so consuming tests own the enqueue and observe a clean + // `attemptCount = 0` row when they trigger it themselves. + let pendingB = try await messageService.createPendingChannelMessage( + text: "Second message", + channelIndex: channelIndex, + radioID: radioID + ) + let envelopeB = ChannelMessageEnvelope( + messageID: pendingB.id, + channelIndex: channelIndex, + isResend: false, + messageText: pendingB.text, + messageTimestamp: pendingB.timestamp, + localNodeName: "Test Radio" + ) - dispatchTask.cancel() - await harness.queueService.shutdown() + let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) + let queueService = ChatSendQueueService( + radioID: radioID, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService(), + config: queueConfig + ) + + return ChannelCapHarness( + queueService: queueService, + messageService: messageService, + store: store, + messageID: pending.id, + radioID: radioID, + envelopeA: envelopeA, + envelopeB: envelopeB + ) + } + + /// Background dispatcher that broadcasts `.error(code:)` events at a + /// fixed cadence so every `withPoolBackoff` subscribe-and-wait window + /// in `session.sendChannelMessage` lands on a NOT_FOUND signal. Caller + /// cancels the returned task once the test reaches its assertion. + private static func startDeviceErrorDispatch( + service: MessageService, + code: UInt8, + interval: Duration = .milliseconds(20) + ) -> Task { + Task.detached { + let session = await service.sessionForTest + while !Task.isCancelled { + await session.dispatchForTesting(.error(code: code)) + try? await Task.sleep(for: interval) + } } + } + + /// Polls `block` until it returns true or the timeout elapses; otherwise + /// records a Swift Testing issue. Lighter than spinning a deadline loop + /// inside each test body. + private static func waitForCondition( + timeout: Duration, + pollEvery: Duration = .milliseconds(50), + _ block: () async throws -> Bool + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if try await block() { return } + try await Task.sleep(for: pollEvery) + } + Issue.record("waitForCondition timed out after \(timeout)") + } + + /// Polls until the PendingSend row for `messageID` is gone — i.e. the + /// cap branch (or any terminal `onError`) has deleted it. + private static func waitForPendingSendDrained( + messageID: UUID, + store: PersistenceStore, + timeout: Duration = .seconds(10) + ) async { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if let exists = try? await store.hasPendingSend(messageID: messageID), exists == false { + return + } + try? await Task.sleep(for: .milliseconds(50)) + } + Issue.record("PendingSend row for \(messageID) was not drained within \(timeout)") + } + + @Test + func `hydrate runs once per service instance`() async throws { + let store = try Self.makeStore() + let radioID = UUID() + let envelope = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envelope, radioID: radioID) + ) - // MARK: - Channel-cap helpers + let messageService = await Self.makeMessageService(dataStore: store) + let channelService = await Self.makeChannelService(dataStore: store) + let service = ChatSendQueueService( + radioID: radioID, + dataStore: store, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService() + ) - /// In-memory test harness exercising the channel drain end-to-end. - /// - /// `queue` is the canonical accessor used by newer tests; `queueService` - /// remains a synonym so the existing channel-cap suites keep compiling - /// without churn. - /// - /// `envelopeA` is the channel envelope persisted by `setUpChannelCapHarness`. - /// `envelopeB` is a second envelope, not yet enqueued — callers that need - /// to exercise multi-envelope scenarios call `harness.queue.enqueueChannel(harness.envelopeB)` - /// themselves so each test controls its own drain ordering. - @MainActor - private final class ChannelCapHarness { - let queueService: ChatSendQueueService - var queue: ChatSendQueueService { queueService } - let messageService: MessageService - let store: PersistenceStore - let messageID: UUID - let radioID: UUID - let envelopeA: ChannelMessageEnvelope - let envelopeB: ChannelMessageEnvelope - private var dispatchTask: Task? - - init( - queueService: ChatSendQueueService, - messageService: MessageService, - store: PersistenceStore, - messageID: UUID, - radioID: UUID, - envelopeA: ChannelMessageEnvelope, - envelopeB: ChannelMessageEnvelope - ) { - self.queueService = queueService - self.messageService = messageService - self.store = store - self.messageID = messageID - self.radioID = radioID - self.envelopeA = envelopeA - self.envelopeB = envelopeB - } + await service.hydrate() + await service.awaitDrainCompletion() - /// Drive `fetchChannel(index:)` failures by streaming `.error(code:)` - /// events at the dispatcher's fixed cadence. The same event stream - /// also throws `MeshCoreError.deviceError(code)` on the channel send - /// path, so callers asserting the disambiguation step should set - /// `count` high enough to cover the in-loop pool-backoff retries - /// plus the trailing `fetchChannel` round-trips. - /// - /// `count == Int.max` dispatches indefinitely until `tearDown()` is - /// called. A finite count emits up to that many events and then - /// stops, allowing later attempts to surface their own outcomes - /// (timeouts or the channel-still-exists branch). - func setFetchChannelFailureCount(_ count: Int) async { - dispatchTask?.cancel() - let messageService = self.messageService - let target = count - dispatchTask = Task.detached { - let session = await messageService.sessionForTest - var emitted = 0 - while !Task.isCancelled && emitted < target { - await session.dispatchForTesting(.error(code: FirmwareDeviceErrorCode.channelMessageNotFound)) - if target != Int.max { - emitted += 1 - } - try? await Task.sleep(for: .milliseconds(20)) - } - } - } + // First hydrate drained the row. + let rowsAfterFirst = try await store.fetchPendingSends(radioID: radioID) + #expect(rowsAfterFirst.isEmpty) - /// Run one drain cycle to completion: call `hydrate()` if it has not - /// yet run, then wait for the message to reach a terminal status - /// (`.failed` or `.sent`) or for the PendingSend row to be deleted. - /// Bounded at 8 seconds to keep test runtime predictable; the - /// pool-backoff loop exhausts in ~3.5s under steady error dispatch. - /// - /// A parked envelope (row preserved with bumped `attemptCount`) is - /// not treated as terminal here — callers that expect park as a - /// success outcome should poll the row directly after this returns. - func drainOnce(timeout: Duration = .seconds(8)) async { - await queueService.hydrate() - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if let message = try? await store.fetchMessage(id: messageID), - message.status == .failed || message.status == .sent { - return - } - if let exists = try? await store.hasPendingSend(messageID: messageID), exists == false { - return - } - try? await Task.sleep(for: .milliseconds(50)) - } - } + // Insert a new row to detect whether a second hydrate would enqueue it. + let envelope2 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) + _ = try await store.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envelope2, radioID: radioID) + ) - func tearDown() async { - dispatchTask?.cancel() - dispatchTask = nil - await queueService.shutdown() - } - } + await service.hydrate() + await service.awaitDrainCompletion() + + // The second hydrate must be a no-op; the new row must survive. + let rowsAfterSecond = try await store.fetchPendingSends(radioID: radioID) + #expect(rowsAfterSecond.count == 1, + "second hydrate on same instance must not re-enqueue persisted rows") + } + + @Test + func `ChatSendQueueServiceError.notConnected description is non-empty`() { + let error = ChatSendQueueServiceError.notConnected + #expect(error.errorDescription?.isEmpty == false) + } + + /// Regression: when a DM send fails with a transient firmware code + /// (e.g. `directMessageTableFull`), `failMessageAndRethrow` writes + /// `.failed` and broadcasts the failure before the queue's + /// catch reclassifies the error and remaps the status back to + /// `.pending`. The broadcast propagates an in-memory `.failed` + /// snapshot to the UI even though the persisted state is `.pending`, + /// causing the bubble to flicker "Failed" while the queue is parked. + /// Transient errors must not broadcast `.failed` at all. + @Test + func `Transient DM send error must not broadcast .failed`() async throws { + // Tight pool-backoff so the failure surfaces fast. The invariant + // under test is independent of backoff duration. + let messageConfig = MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) + let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) + defer { Task { await harness.tearDown() } } - /// Build a Device + Channel + Message + PendingSend with the requested - /// `attemptCount`. Returns the connected `ChatSendQueueService` (not yet - /// hydrated) and the supporting fixtures so callers can drive the drain. - /// - /// The PendingSend row is built with `isResend = true` so the drain - /// closure calls `MessageService.resendChannelMessage`, which routes - /// through `withPoolBackoff` and `failMessageAndRethrow` — matching the - /// retry path the cap is intended to bound. - private static func setUpChannelCapHarness( - attemptCount: Int?, - queueConfig: ChatSendQueueConfig = .default, - messageConfig: MessageServiceConfig = .default - ) async throws -> ChannelCapHarness { - let device = Device( - publicKey: Data(repeating: 0x11, count: 32), - nodeName: "Test Radio" - ) - let container = try PersistenceStore.createContainer(inMemory: true) - container.mainContext.insert(device) - try container.mainContext.save() - let store = PersistenceStore(modelContainer: container) - let radioID = device.radioID - - let channelIndex: UInt8 = 0 - let channel = Channel( - radioID: radioID, - index: channelIndex, - name: "Test Channel", - secret: Data(repeating: 0xAA, count: 16) - ) - container.mainContext.insert(channel) - try container.mainContext.save() - - // 200ms session timeout so each pool-backoff attempt rejects quickly - // if the test's dispatcher loop misses its window. The transport is - // connected so `transport.send` does not throw `notConnected` and the - // resulting error path runs through the dispatcher / matcher flow - // the cap is intended to exercise. - let transport = SimulatorMockTransport() - try await transport.connect() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 0.2) - ) - let messageService = MessageService(session: session, dataStore: store, contactService: nil, config: messageConfig) - - let pending = try await messageService.createPendingChannelMessage( - text: "Hello channel", - channelIndex: channelIndex, - radioID: radioID - ) - - let envelopeA = ChannelMessageEnvelope( - messageID: pending.id, - channelIndex: channelIndex, - isResend: true, - messageText: pending.text, - messageTimestamp: pending.timestamp, - localNodeName: "Test Radio" - ) - let dto = PendingSendDTO( - id: UUID(), - radioID: radioID, - messageID: envelopeA.messageID, - kind: .channel, - contactID: nil, - channelIndex: channelIndex, - isResend: true, - messageText: pending.text, - messageTimestamp: pending.timestamp, - localNodeName: "Test Radio", - sequence: 1, - enqueuedAt: Date(), - attemptCount: attemptCount - ) - try await store.upsertPendingSend(dto) - - // Second envelope for the same channel — never persisted at setup - // time so consuming tests own the enqueue and observe a clean - // `attemptCount = 0` row when they trigger it themselves. - let pendingB = try await messageService.createPendingChannelMessage( - text: "Second message", - channelIndex: channelIndex, - radioID: radioID - ) - let envelopeB = ChannelMessageEnvelope( - messageID: pendingB.id, - channelIndex: channelIndex, - isResend: false, - messageText: pendingB.text, - messageTimestamp: pendingB.timestamp, - localNodeName: "Test Radio" - ) - - let channelService = ChannelService(session: session, dataStore: store, rxLogService: nil) - let queueService = ChatSendQueueService( - radioID: radioID, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService(), - config: queueConfig - ) - - return ChannelCapHarness( - queueService: queueService, - messageService: messageService, - store: store, - messageID: pending.id, - radioID: radioID, - envelopeA: envelopeA, - envelopeB: envelopeB - ) - } + let statusEvents = harness.messageService.statusEvents() - /// Background dispatcher that broadcasts `.error(code:)` events at a - /// fixed cadence so every `withPoolBackoff` subscribe-and-wait window - /// in `session.sendChannelMessage` lands on a NOT_FOUND signal. Caller - /// cancels the returned task once the test reaches its assertion. - private static func startDeviceErrorDispatch( - service: MessageService, - code: UInt8, - interval: Duration = .milliseconds(20) - ) -> Task { - Task.detached { - let session = await service.sessionForTest - while !Task.isCancelled { - await session.dispatchForTesting(.error(code: code)) - try? await Task.sleep(for: interval) - } - } - } + // Configure session to throw a transient error code on send. + // The persisted PendingSend row is drained by hydrate() inside + // drainOnceAllowingPark; the drain should park the envelope on + // waitForTransportOpen rather than terminal-fail it. + await harness.setSendDirectMessageFailureMode( + .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) + ) - /// Polls `block` until it returns true or the timeout elapses; otherwise - /// records a Swift Testing issue. Lighter than spinning a deadline loop - /// inside each test body. - private static func waitForCondition( - timeout: Duration, - pollEvery: Duration = .milliseconds(50), - _ block: () async throws -> Bool - ) async throws { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if try await block() { return } - try await Task.sleep(for: pollEvery) - } - Issue.record("waitForCondition timed out after \(timeout)") - } + await harness.drainOnceAllowingPark() + // drainOnceAllowingPark returns as soon as attemptCount >= 1 + // (the top-of-drain bump). The transient failure does not fire + // until withPoolBackoff exhausts. Wait past the backoff window so + // failMessageAndRethrow has definitively run before we assert. + try await Task.sleep(for: .milliseconds(300)) + + let observed = await harness.messageService.drainStatusEvents(statusEvents).failedIDs + #expect(observed.count == 0, "Transient errors must not broadcast .failed; got \(observed.count) events") + } + + /// Complement to `transientDMError_DoesNotFireFailedHandler`. A truly + /// terminal DM send error must still surface to the UI via the `.failed` + /// broadcast, exactly once. Guards against accidentally suppressing the + /// broadcast on the queue-routed path given that `failMessageAndRethrow` + /// does not broadcast itself. + @Test + func `Terminal DM send error broadcasts .failed exactly once`() async throws { + let harness = try await Self.setUpDMHarness() + defer { Task { await harness.tearDown() } } + + let statusEvents = harness.messageService.statusEvents() + + // Non-transient firmware code so the drain's classifier treats the + // error as terminal and rethrows from the inner catch, hitting the + // outer catch in the queue closure that calls `notifyMessageFailed`. + await harness.setSendDirectMessageFailureMode(.invalidInput) + + await harness.drainOnce() + + let ids = await harness.messageService.drainStatusEvents(statusEvents).failedIDs + #expect(ids.count == 1, "Terminal errors must broadcast .failed exactly once; got \(ids.count)") + #expect(ids.first == harness.messageID, ".failed must carry the failed envelope's messageID") + } + + /// A container built after the connection already reached `.ready` receives + /// the edge through `observeConnectionState`'s initial value: an already-ready + /// initial state must fire the trigger so a drain parked in + /// `withCooperativeTimeout` wakes (observable as a second attemptCount bump) + /// instead of waiting out `transportWaitTimeout`. + @Test + func `observeConnectionState fires the trigger for an already-ready initial state`() async throws { + let messageConfig = MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) + let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) + defer { Task { await harness.tearDown() } } - /// Polls until the PendingSend row for `messageID` is gone — i.e. the - /// cap branch (or any terminal `onError`) has deleted it. - private static func waitForPendingSendDrained( - messageID: UUID, - store: PersistenceStore, - timeout: Duration = .seconds(10) - ) async { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if let exists = try? await store.hasPendingSend(messageID: messageID), exists == false { - return - } - try? await Task.sleep(for: .milliseconds(50)) - } - Issue.record("PendingSend row for \(messageID) was not drained within \(timeout)") - } + await harness.setSendDirectMessageFailureMode( + .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) + ) + await harness.drainOnceAllowingPark() - @Test("hydrate runs once per service instance") - func hydrateRunsOncePerInstance() async throws { - let store = try Self.makeStore() - let radioID = UUID() - let envelope = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envelope, radioID: radioID) - ) - - let messageService = await Self.makeMessageService(dataStore: store) - let channelService = await Self.makeChannelService(dataStore: store) - let service = ChatSendQueueService( - radioID: radioID, - dataStore: store, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService() - ) - - await service.hydrate() - await service.awaitDrainCompletion() - - // First hydrate drained the row. - let rowsAfterFirst = try await store.fetchPendingSends(radioID: radioID) - #expect(rowsAfterFirst.isEmpty) - - // Insert a new row to detect whether a second hydrate would enqueue it. - let envelope2 = DirectMessageEnvelope(messageID: UUID(), contactID: UUID()) - _ = try await store.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envelope2, radioID: radioID) - ) - - await service.hydrate() - await service.awaitDrainCompletion() - - // The second hydrate must be a no-op; the new row must survive. - let rowsAfterSecond = try await store.fetchPendingSends(radioID: radioID) - #expect(rowsAfterSecond.count == 1, - "second hydrate on same instance must not re-enqueue persisted rows") - } + harness.queue.observeConnectionState( + initial: .ready, + events: AsyncStream { $0.finish() } + ) - @Test("ChatSendQueueServiceError.notConnected description is non-empty") - func notConnectedErrorHasDescription() { - let error = ChatSendQueueServiceError.notConnected - #expect(error.errorDescription?.isEmpty == false) + try await Self.waitForCondition(timeout: .seconds(10)) { + let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + return (row?.attemptCount ?? 0) >= 2 } + } + + /// `.connected` (link up, initial sync not yet cleared) must NOT wake a + /// parked drain: hydrated sends stay parked through the sync window so they + /// do not contend with sync's reads. The drain only wakes once `.ready` + /// arrives on the stream. + @Test + func `observeConnectionState keeps a drain parked through .connected and wakes it on .ready`() async throws { + let messageConfig = MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) + let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) + defer { Task { await harness.tearDown() } } + + await harness.setSendDirectMessageFailureMode( + .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) + ) - /// Regression: when a DM send fails with a transient firmware code - /// (e.g. `directMessageTableFull`), `failMessageAndRethrow` writes - /// `.failed` and broadcasts the failure before the queue's - /// catch reclassifies the error and remaps the status back to - /// `.pending`. The broadcast propagates an in-memory `.failed` - /// snapshot to the UI even though the persisted state is `.pending`, - /// causing the bubble to flicker "Failed" while the queue is parked. - /// Transient errors must not broadcast `.failed` at all. - @Test("Transient DM send error must not broadcast .failed") - func transientDMError_DoesNotFireFailedHandler() async throws { - // Tight pool-backoff so the failure surfaces fast. The invariant - // under test is independent of backoff duration. - let messageConfig = MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) - defer { Task { await harness.tearDown() } } - - let statusEvents = harness.messageService.statusEvents() - - // Configure session to throw a transient error code on send. - // The persisted PendingSend row is drained by hydrate() inside - // drainOnceAllowingPark; the drain should park the envelope on - // waitForTransportOpen rather than terminal-fail it. - await harness.setSendDirectMessageFailureMode( - .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) - ) - - await harness.drainOnceAllowingPark() - // drainOnceAllowingPark returns as soon as attemptCount >= 1 - // (the top-of-drain bump). The transient failure does not fire - // until withPoolBackoff exhausts. Wait past the backoff window so - // failMessageAndRethrow has definitively run before we assert. - try await Task.sleep(for: .milliseconds(300)) - - let observed = await harness.messageService.drainStatusEvents(statusEvents).failedIDs - #expect(observed.count == 0, "Transient errors must not broadcast .failed; got \(observed.count) events") + let (stream, continuation) = AsyncStream.makeStream(of: DeviceConnectionState.self) + harness.queue.observeConnectionState(initial: .connected, events: stream) + + await harness.drainOnceAllowingPark() + + // `.connected` alone must not arm the trigger: the parked drain stays at one attempt. + continuation.yield(.connected) + try await Task.sleep(for: .seconds(1)) + let parkedRow = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + #expect(parkedRow?.attemptCount == 1, ".connected must not wake the drain during the sync window") + + // Entering `.ready` fires the trigger and the drain wakes for its second attempt. + continuation.yield(.ready) + continuation.finish() + try await Self.waitForCondition(timeout: .seconds(10)) { + let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + return (row?.attemptCount ?? 0) >= 2 } + } + + /// Edge-trigger contract across the connected ramp: a stream driving + /// disconnected, connected, syncing, ready crosses into `.ready` once, so + /// the trigger fires once on that entry. The signal's armed-bit consumption + /// makes a double fire observable: re-arming on a non-`.ready` rung would + /// wake the second park without a new edge, surfacing as a third + /// attemptCount bump. + @Test + func `connection-state ramp fires the trigger exactly once`() async throws { + let messageConfig = MessageServiceConfig( + poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) + ) + let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) + defer { Task { await harness.tearDown() } } - /// Complement to `transientDMError_DoesNotFireFailedHandler`. A truly - /// terminal DM send error must still surface to the UI via the `.failed` - /// broadcast, exactly once. Guards against accidentally suppressing the - /// broadcast on the queue-routed path given that `failMessageAndRethrow` - /// does not broadcast itself. - @Test("Terminal DM send error broadcasts .failed exactly once") - func terminalDMError_FiresFailedHandlerExactlyOnce() async throws { - let harness = try await Self.setUpDMHarness() - defer { Task { await harness.tearDown() } } + await harness.setSendDirectMessageFailureMode( + .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) + ) - let statusEvents = harness.messageService.statusEvents() + let (stream, continuation) = AsyncStream.makeStream(of: DeviceConnectionState.self) + harness.queue.observeConnectionState(initial: .disconnected, events: stream) - // Non-transient firmware code so the drain's classifier treats the - // error as terminal and rethrows from the inner catch, hitting the - // outer catch in the queue closure that calls `notifyMessageFailed`. - await harness.setSendDirectMessageFailureMode(.invalidInput) + await harness.drainOnceAllowingPark() - await harness.drainOnce() + continuation.yield(.disconnected) + continuation.yield(.connected) + continuation.yield(.syncing) + continuation.yield(.ready) + continuation.finish() - let ids = await harness.messageService.drainStatusEvents(statusEvents).failedIDs - #expect(ids.count == 1, "Terminal errors must broadcast .failed exactly once; got \(ids.count)") - #expect(ids.first == harness.messageID, ".failed must carry the failed envelope's messageID") + // The single edge wakes the parked drain once: attempt 2 runs, + // fails transiently, and parks again. + try await Self.waitForCondition(timeout: .seconds(10)) { + let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + return (row?.attemptCount ?? 0) >= 2 } - /// A container built after the connection already reached `.ready` receives - /// the edge through `observeConnectionState`'s initial value: an already-ready - /// initial state must fire the trigger so a drain parked in - /// `withCooperativeTimeout` wakes (observable as a second attemptCount bump) - /// instead of waiting out `transportWaitTimeout`. - @Test("observeConnectionState fires the trigger for an already-ready initial state") - func observeConnectionState_ReadyInitial_WakesParkedDrain() async throws { - let messageConfig = MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) - defer { Task { await harness.tearDown() } } - - await harness.setSendDirectMessageFailureMode( - .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) - ) - await harness.drainOnceAllowingPark() - - harness.queue.observeConnectionState( - initial: .ready, - events: AsyncStream { $0.finish() } - ) - - try await Self.waitForCondition(timeout: .seconds(10)) { - let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - return (row?.attemptCount ?? 0) >= 2 - } + // Steady state: no further wake without a new edge. + try await Task.sleep(for: .seconds(1)) + let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) + .first(where: { $0.messageID == harness.messageID }) + #expect(row?.attemptCount == 2, + "the connected ramp must fire the transport-open trigger exactly once") + } + + // MARK: - DM drain helpers + + /// Distinct DM send-failure modes the harness can simulate. Each maps + /// to a different code path inside the drain closure: + /// + /// - `.normal`: no events dispatched. `session.sendMessage` waits past + /// the session's `defaultTimeout` and throws `MeshCoreError.timeout`, + /// classified transient → park branch. + /// - `.deviceError(code)`: dispatches `.error(code:)` continuously. The + /// classifier treats `FirmwareDeviceErrorCode.directMessageTableFull` + /// as transient and every other code as terminal. + /// - `.connectionLost`: tears down the transport so the next send fails + /// with `MeshCoreError.notConnected` (which `isTransientError` + /// classifies as transient, parking the envelope). One-way: the mock + /// transport's AsyncStream continuation is finished on disconnect and + /// cannot be revived, so `.connectionLost` must be the final mode set + /// before `tearDown()`. + /// - `.invalidInput`: dispatches an illegal-argument firmware code so the + /// send fails with a non-transient `deviceError`, exercising the + /// terminal-error path without touching MeshCore internals. + enum DirectSendFailureMode { + case normal + case deviceError(UInt8) + case connectionLost + case invalidInput + } + + /// DM-drain harness mirroring `ChannelCapHarness` for the + /// `ChatSendQueueService` direct-message path. Owns the transport and + /// dispatcher task so tests can express failure modes declaratively. + @MainActor + private final class DMHarness { + let queue: ChatSendQueueService + let messageService: MessageService + let dataStore: PersistenceStore + let session: MeshCoreSession + let envelope: DirectMessageEnvelope + let radioID: UUID + let messageID: UUID + private let transport: SimulatorMockTransport + private var dispatchTask: Task? + + init( + queue: ChatSendQueueService, + messageService: MessageService, + dataStore: PersistenceStore, + session: MeshCoreSession, + envelope: DirectMessageEnvelope, + radioID: UUID, + transport: SimulatorMockTransport + ) { + self.queue = queue + self.messageService = messageService + self.dataStore = dataStore + self.session = session + self.envelope = envelope + self.radioID = radioID + messageID = envelope.messageID + self.transport = transport } - /// `.connected` (link up, initial sync not yet cleared) must NOT wake a - /// parked drain: hydrated sends stay parked through the sync window so they - /// do not contend with sync's reads. The drain only wakes once `.ready` - /// arrives on the stream. - @Test("observeConnectionState keeps a drain parked through .connected and wakes it on .ready") - func observeConnectionState_ConnectedDoesNotWake_ReadyDoes() async throws { - let messageConfig = MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) - defer { Task { await harness.tearDown() } } - - await harness.setSendDirectMessageFailureMode( - .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) - ) - - let (stream, continuation) = AsyncStream.makeStream(of: DeviceConnectionState.self) - harness.queue.observeConnectionState(initial: .connected, events: stream) - - await harness.drainOnceAllowingPark() - - // `.connected` alone must not arm the trigger: the parked drain stays at one attempt. - continuation.yield(.connected) - try await Task.sleep(for: .seconds(1)) - let parkedRow = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - #expect(parkedRow?.attemptCount == 1, ".connected must not wake the drain during the sync window") - - // Entering `.ready` fires the trigger and the drain wakes for its second attempt. - continuation.yield(.ready) - continuation.finish() - try await Self.waitForCondition(timeout: .seconds(10)) { - let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - return (row?.attemptCount ?? 0) >= 2 + /// Configure the next `sendDirectMessage` to resolve in a specific + /// failure shape. Stops any previously-running dispatcher first so + /// modes do not stack. Subsequent drain attempts inherit whatever + /// mode is active until `.normal` is reapplied or the harness is + /// torn down. `.connectionLost` is one-way: once the transport is + /// disconnected, no subsequent mode (including `.normal`) can revive + /// it — callers must treat `.connectionLost` as the final mode before + /// `tearDown()`. + func setSendDirectMessageFailureMode(_ mode: DirectSendFailureMode) async { + dispatchTask?.cancel() + dispatchTask = nil + switch mode { + case .normal: + break + case let .deviceError(code): + let session = session + dispatchTask = Task.detached { + while !Task.isCancelled { + await session.dispatchForTesting(.error(code: code)) + try? await Task.sleep(for: .milliseconds(20)) + } } - } - - /// Edge-trigger contract across the connected ramp: a stream driving - /// disconnected, connected, syncing, ready crosses into `.ready` once, so - /// the trigger fires once on that entry. The signal's armed-bit consumption - /// makes a double fire observable: re-arming on a non-`.ready` rung would - /// wake the second park without a new edge, surfacing as a third - /// attemptCount bump. - @Test("connection-state ramp fires the trigger exactly once") - func observeConnectionState_ConnectedRamp_FiresExactlyOnce() async throws { - let messageConfig = MessageServiceConfig( - poolBackoff: PoolBackoffConfig(attemptCap: 2, baseDelay: 0.01) - ) - let harness = try await Self.setUpDMHarness(messageConfig: messageConfig) - defer { Task { await harness.tearDown() } } - - await harness.setSendDirectMessageFailureMode( - .deviceError(FirmwareDeviceErrorCode.directMessageTableFull) - ) - - let (stream, continuation) = AsyncStream.makeStream(of: DeviceConnectionState.self) - harness.queue.observeConnectionState(initial: .disconnected, events: stream) - - await harness.drainOnceAllowingPark() - - continuation.yield(.disconnected) - continuation.yield(.connected) - continuation.yield(.syncing) - continuation.yield(.ready) - continuation.finish() - - // The single edge wakes the parked drain once: attempt 2 runs, - // fails transiently, and parks again. - try await Self.waitForCondition(timeout: .seconds(10)) { - let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - return (row?.attemptCount ?? 0) >= 2 + case .connectionLost: + await transport.disconnect() + case .invalidInput: + let session = session + dispatchTask = Task.detached { + while !Task.isCancelled { + await session.dispatchForTesting(.error(code: ProtocolError.illegalArgument.rawValue)) + try? await Task.sleep(for: .milliseconds(20)) + } } - - // Steady state: no further wake without a new edge. - try await Task.sleep(for: .seconds(1)) - let row = try await harness.dataStore.fetchPendingSends(radioID: harness.radioID) - .first(where: { $0.messageID == harness.messageID }) - #expect(row?.attemptCount == 2, - "the connected ramp must fire the transport-open trigger exactly once") + } } - // MARK: - DM drain helpers - - /// Distinct DM send-failure modes the harness can simulate. Each maps - /// to a different code path inside the drain closure: - /// - /// - `.normal`: no events dispatched. `session.sendMessage` waits past - /// the session's `defaultTimeout` and throws `MeshCoreError.timeout`, - /// classified transient → park branch. - /// - `.deviceError(code)`: dispatches `.error(code:)` continuously. The - /// classifier treats `FirmwareDeviceErrorCode.directMessageTableFull` - /// as transient and every other code as terminal. - /// - `.connectionLost`: tears down the transport so the next send fails - /// with `MeshCoreError.notConnected` (which `isTransientError` - /// classifies as transient, parking the envelope). One-way: the mock - /// transport's AsyncStream continuation is finished on disconnect and - /// cannot be revived, so `.connectionLost` must be the final mode set - /// before `tearDown()`. - /// - `.invalidInput`: dispatches an illegal-argument firmware code so the - /// send fails with a non-transient `deviceError`, exercising the - /// terminal-error path without touching MeshCore internals. - enum DirectSendFailureMode { - case normal - case deviceError(UInt8) - case connectionLost - case invalidInput - } - - /// DM-drain harness mirroring `ChannelCapHarness` for the - /// `ChatSendQueueService` direct-message path. Owns the transport and - /// dispatcher task so tests can express failure modes declaratively. - @MainActor - private final class DMHarness { - let queue: ChatSendQueueService - let messageService: MessageService - let dataStore: PersistenceStore - let session: MeshCoreSession - let envelope: DirectMessageEnvelope - let radioID: UUID - let messageID: UUID - private let transport: SimulatorMockTransport - private var dispatchTask: Task? - - init( - queue: ChatSendQueueService, - messageService: MessageService, - dataStore: PersistenceStore, - session: MeshCoreSession, - envelope: DirectMessageEnvelope, - radioID: UUID, - transport: SimulatorMockTransport - ) { - self.queue = queue - self.messageService = messageService - self.dataStore = dataStore - self.session = session - self.envelope = envelope - self.radioID = radioID - self.messageID = envelope.messageID - self.transport = transport + /// Drive one drain cycle: `hydrate()` (or `enqueueDM`) plus a bounded + /// wait for either a terminal status (`.failed` / `.sent`) or for + /// the PendingSend row to be deleted. Records a Swift Testing issue + /// if the envelope parks instead — callers that expect park as a + /// success outcome should use `drainOnceAllowingPark()`. + func drainOnce(timeout: Duration = .seconds(8)) async { + await queue.hydrate() + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if let message = try? await dataStore.fetchMessage(id: messageID) { + if message.status == .failed || message.status == .sent { + return + } } - - /// Configure the next `sendDirectMessage` to resolve in a specific - /// failure shape. Stops any previously-running dispatcher first so - /// modes do not stack. Subsequent drain attempts inherit whatever - /// mode is active until `.normal` is reapplied or the harness is - /// torn down. `.connectionLost` is one-way: once the transport is - /// disconnected, no subsequent mode (including `.normal`) can revive - /// it — callers must treat `.connectionLost` as the final mode before - /// `tearDown()`. - func setSendDirectMessageFailureMode(_ mode: DirectSendFailureMode) async { - dispatchTask?.cancel() - dispatchTask = nil - switch mode { - case .normal: - break - case .deviceError(let code): - let session = self.session - dispatchTask = Task.detached { - while !Task.isCancelled { - await session.dispatchForTesting(.error(code: code)) - try? await Task.sleep(for: .milliseconds(20)) - } - } - case .connectionLost: - await transport.disconnect() - case .invalidInput: - let session = self.session - dispatchTask = Task.detached { - while !Task.isCancelled { - await session.dispatchForTesting(.error(code: ProtocolError.illegalArgument.rawValue)) - try? await Task.sleep(for: .milliseconds(20)) - } - } - } - } - - /// Drive one drain cycle: `hydrate()` (or `enqueueDM`) plus a bounded - /// wait for either a terminal status (`.failed` / `.sent`) or for - /// the PendingSend row to be deleted. Records a Swift Testing issue - /// if the envelope parks instead — callers that expect park as a - /// success outcome should use `drainOnceAllowingPark()`. - func drainOnce(timeout: Duration = .seconds(8)) async { - await queue.hydrate() - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if let message = try? await dataStore.fetchMessage(id: messageID) { - if message.status == .failed || message.status == .sent { - return - } - } - if let exists = try? await dataStore.hasPendingSend(messageID: messageID), exists == false { - return - } - try? await Task.sleep(for: .milliseconds(50)) - } - Issue.record("DM drain did not reach a terminal outcome within \(timeout)") + if let exists = try? await dataStore.hasPendingSend(messageID: messageID), exists == false { + return } + try? await Task.sleep(for: .milliseconds(50)) + } + Issue.record("DM drain did not reach a terminal outcome within \(timeout)") + } - /// Variant of `drainOnce` for transient-failure scenarios where a - /// parked envelope (`attemptCount > 0`, row still present, status - /// remapped to `.pending`) is the expected outcome. Returns as soon - /// as the row is observed parked or terminal. - func drainOnceAllowingPark(timeout: Duration = .seconds(8)) async { - await queue.hydrate() - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - let rows = (try? await dataStore.fetchPendingSends(radioID: radioID)) ?? [] - if let row = rows.first(where: { $0.messageID == messageID }), - (row.attemptCount ?? 0) >= 1 { - return - } - if let exists = try? await dataStore.hasPendingSend(messageID: messageID), exists == false { - return - } - try? await Task.sleep(for: .milliseconds(50)) - } + /// Variant of `drainOnce` for transient-failure scenarios where a + /// parked envelope (`attemptCount > 0`, row still present, status + /// remapped to `.pending`) is the expected outcome. Returns as soon + /// as the row is observed parked or terminal. + func drainOnceAllowingPark(timeout: Duration = .seconds(8)) async { + await queue.hydrate() + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + let rows = await (try? dataStore.fetchPendingSends(radioID: radioID)) ?? [] + if let row = rows.first(where: { $0.messageID == messageID }), + (row.attemptCount ?? 0) >= 1 { + return } - - func tearDown() async { - dispatchTask?.cancel() - dispatchTask = nil - await queue.shutdown() + if let exists = try? await dataStore.hasPendingSend(messageID: messageID), exists == false { + return } + try? await Task.sleep(for: .milliseconds(50)) + } } - /// Builds a DM-side counterpart to `setUpChannelCapHarness`. Inserts a - /// `Device` + `Contact` + pending `Message`, persists a single - /// `PendingSend` row, wires the real session/queue/services together, - /// and returns the harness with no hydration yet performed so the test - /// can configure the failure mode before triggering the drain. - /// - /// The session is configured with a 200ms `defaultTimeout` so each - /// `withPoolBackoff` cycle in `MessageService.sendDirectMessage` resolves - /// quickly when the dispatcher loop misses its window. - private static func setUpDMHarness( - queueConfig: ChatSendQueueConfig = .default, - messageConfig: MessageServiceConfig = .default - ) async throws -> DMHarness { - let device = Device( - publicKey: Data(repeating: 0x22, count: 32), - nodeName: "Test Radio" - ) - let container = try PersistenceStore.createContainer(inMemory: true) - container.mainContext.insert(device) - try container.mainContext.save() - let dataStore = PersistenceStore(modelContainer: container) - let radioID = device.radioID - - let contact = Contact( - radioID: radioID, - publicKey: Data(repeating: 0x33, count: 32), - name: "Test Contact" - ) - container.mainContext.insert(contact) - try container.mainContext.save() - let contactDTO = try #require(try await dataStore.fetchContact(id: contact.id)) - - let transport = SimulatorMockTransport() - try await transport.connect() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 0.2) - ) - let messageService = MessageService(session: session, dataStore: dataStore, contactService: nil, config: messageConfig) - await messageService.installSelfInfoForTest() - let pending = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) - - let envelope = DirectMessageEnvelope(messageID: pending.id, contactID: contactDTO.id) - _ = try await dataStore.insertPendingSendAssigningSequence( - PendingSendDTO(envelope: envelope, radioID: radioID) - ) - - let channelService = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) - let queue = ChatSendQueueService( - radioID: radioID, - dataStore: dataStore, - messageService: messageService, - channelService: channelService, - reactionService: ReactionService(), - config: queueConfig - ) - - return DMHarness( - queue: queue, - messageService: messageService, - dataStore: dataStore, - session: session, - envelope: envelope, - radioID: radioID, - transport: transport - ) + func tearDown() async { + dispatchTask?.cancel() + dispatchTask = nil + await queue.shutdown() } + } + + /// Builds a DM-side counterpart to `setUpChannelCapHarness`. Inserts a + /// `Device` + `Contact` + pending `Message`, persists a single + /// `PendingSend` row, wires the real session/queue/services together, + /// and returns the harness with no hydration yet performed so the test + /// can configure the failure mode before triggering the drain. + /// + /// The session is configured with a 200ms `defaultTimeout` so each + /// `withPoolBackoff` cycle in `MessageService.sendDirectMessage` resolves + /// quickly when the dispatcher loop misses its window. + private static func setUpDMHarness( + queueConfig: ChatSendQueueConfig = .default, + messageConfig: MessageServiceConfig = .default + ) async throws -> DMHarness { + let device = Device( + publicKey: Data(repeating: 0x22, count: 32), + nodeName: "Test Radio" + ) + let container = try PersistenceStore.createContainer(inMemory: true) + container.mainContext.insert(device) + try container.mainContext.save() + let dataStore = PersistenceStore(modelContainer: container) + let radioID = device.radioID + + let contact = Contact( + radioID: radioID, + publicKey: Data(repeating: 0x33, count: 32), + name: "Test Contact" + ) + container.mainContext.insert(contact) + try container.mainContext.save() + let contactDTO = try #require(try await dataStore.fetchContact(id: contact.id)) + + let transport = SimulatorMockTransport() + try await transport.connect() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2) + ) + let messageService = MessageService(session: session, dataStore: dataStore, contactService: nil, config: messageConfig) + await messageService.installSelfInfoForTest() + let pending = try await messageService.createPendingMessage(text: "Hello", to: contactDTO) + + let envelope = DirectMessageEnvelope(messageID: pending.id, contactID: contactDTO.id) + _ = try await dataStore.insertPendingSendAssigningSequence( + PendingSendDTO(envelope: envelope, radioID: radioID) + ) + + let channelService = ChannelService(session: session, dataStore: dataStore, rxLogService: nil) + let queue = ChatSendQueueService( + radioID: radioID, + dataStore: dataStore, + messageService: messageService, + channelService: channelService, + reactionService: ReactionService(), + config: queueConfig + ) + + return DMHarness( + queue: queue, + messageService: messageService, + dataStore: dataStore, + session: session, + envelope: envelope, + radioID: radioID, + transport: transport + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift index 488afe82..058ee362 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift @@ -1,94 +1,93 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing /// Service-level coverage for `ContactService.syncContacts` after it was restructured to /// batch-persist in a single transaction. Uses the real in-memory `PersistenceStore` so the /// optimized `batchSaveContacts` override (not the protocol default) is exercised end to end. @Suite("ContactService Sync Tests") struct ContactServiceSyncTests { + private func publicKey(_ byte: UInt8) -> Data { + Data(repeating: byte, count: ProtocolLimits.publicKeySize) + } - private func publicKey(_ byte: UInt8) -> Data { - Data(repeating: byte, count: ProtocolLimits.publicKeySize) - } - - private func meshContact(_ keyByte: UInt8, name: String, lastModified: Date = Date(timeIntervalSince1970: 0)) -> MeshContact { - let key = publicKey(keyByte) - return MeshContact( - id: key.uppercaseHexString(), - publicKey: key, - type: .chat, - flags: ContactFlags(rawValue: 0), - outPathLength: 0, - outPath: Data(), - advertisedName: name, - lastAdvertisement: Date(timeIntervalSince1970: 0), - latitude: 0, - longitude: 0, - lastModified: lastModified - ) - } + private func meshContact(_ keyByte: UInt8, name: String, lastModified: Date = Date(timeIntervalSince1970: 0)) -> MeshContact { + let key = publicKey(keyByte) + return MeshContact( + id: key.uppercaseHexString(), + publicKey: key, + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: name, + lastAdvertisement: Date(timeIntervalSince1970: 0), + latitude: 0, + longitude: 0, + lastModified: lastModified + ) + } - private func contactFrame(_ keyByte: UInt8, name: String) -> ContactFrame { - ContactFrame( - publicKey: publicKey(keyByte), - type: .chat, - flags: 0, - outPathLength: 0, - outPath: Data(), - name: name, - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - } + private func contactFrame(_ keyByte: UInt8, name: String) -> ContactFrame { + ContactFrame( + publicKey: publicKey(keyByte), + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: name, + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + } - @Test("Full sync persists all device contacts and prunes locals not on device") - func fullSyncPersistsAndPrunes() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - // A stale local contact that the device no longer reports. - _ = try await store.saveContact(radioID: radioID, from: contactFrame(0xDD, name: "Stale")) + @Test + func `Full sync persists all device contacts and prunes locals not on device`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + // A stale local contact that the device no longer reports. + _ = try await store.saveContact(radioID: radioID, from: contactFrame(0xDD, name: "Stale")) - let session = MockMeshCoreSession() - let lastModified = Date(timeIntervalSince1970: 1_700_000_000) - await session.setStubbedContacts([ - meshContact(0xAA, name: "Alice", lastModified: lastModified), - meshContact(0xBB, name: "Bob") - ]) + let session = MockMeshCoreSession() + let lastModified = Date(timeIntervalSince1970: 1_700_000_000) + await session.setStubbedContacts([ + meshContact(0xAA, name: "Alice", lastModified: lastModified), + meshContact(0xBB, name: "Bob") + ]) - let service = ContactService(session: session, dataStore: store, syncCoordinator: nil, cleanupCoordinator: nil) - let result = try await service.syncContacts(radioID: radioID, since: nil) + let service = ContactService(session: session, dataStore: store, syncCoordinator: nil, cleanupCoordinator: nil) + let result = try await service.syncContacts(radioID: radioID, since: nil) - #expect(result.contactsReceived == 2) - #expect(result.isIncremental == false) - #expect(result.lastSyncTimestamp == UInt32(lastModified.timeIntervalSince1970)) + #expect(result.contactsReceived == 2) + #expect(result.isIncremental == false) + #expect(result.lastSyncTimestamp == UInt32(lastModified.timeIntervalSince1970)) - let stored = try await store.fetchContacts(radioID: radioID) - #expect(Set(stored.map(\.name)) == ["Alice", "Bob"]) - // The stale contact was pruned on full sync. - #expect(try await store.fetchContact(radioID: radioID, publicKey: publicKey(0xDD)) == nil) - } + let stored = try await store.fetchContacts(radioID: radioID) + #expect(Set(stored.map(\.name)) == ["Alice", "Bob"]) + // The stale contact was pruned on full sync. + #expect(try await store.fetchContact(radioID: radioID, publicKey: publicKey(0xDD)) == nil) + } - @Test("Incremental sync upserts without pruning unseen locals") - func incrementalSyncDoesNotPrune() async throws { - let radioID = UUID() - let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) - // A local contact not present in this incremental batch must survive. - _ = try await store.saveContact(radioID: radioID, from: contactFrame(0xDD, name: "Existing")) + @Test + func `Incremental sync upserts without pruning unseen locals`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + // A local contact not present in this incremental batch must survive. + _ = try await store.saveContact(radioID: radioID, from: contactFrame(0xDD, name: "Existing")) - let session = MockMeshCoreSession() - await session.setStubbedContacts([meshContact(0xAA, name: "Alice")]) + let session = MockMeshCoreSession() + await session.setStubbedContacts([meshContact(0xAA, name: "Alice")]) - let service = ContactService(session: session, dataStore: store, syncCoordinator: nil, cleanupCoordinator: nil) - let result = try await service.syncContacts(radioID: radioID, since: Date(timeIntervalSince1970: 100)) + let service = ContactService(session: session, dataStore: store, syncCoordinator: nil, cleanupCoordinator: nil) + let result = try await service.syncContacts(radioID: radioID, since: Date(timeIntervalSince1970: 100)) - #expect(result.contactsReceived == 1) - #expect(result.isIncremental == true) + #expect(result.contactsReceived == 1) + #expect(result.isIncremental == true) - let stored = try await store.fetchContacts(radioID: radioID) - #expect(Set(stored.map(\.name)) == ["Alice", "Existing"]) - } + let stored = try await store.fetchContacts(radioID: radioID) + #expect(Set(stored.map(\.name)) == ["Alice", "Existing"]) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift index 99feb68e..e009c242 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift @@ -1,826 +1,824 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("ContactService Tests") struct ContactServiceTests { - - // MARK: - Test Constants - - // Sync result test values - private let testContactsReceived = 5 - private let testSyncTimestamp: UInt32 = 1234567890 - private let maxContactsReceived = Int.max - private let maxSyncTimestamp = UInt32.max - - // Contact test values - private let testPublicKey = Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, - 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, - 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, - 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20]) - private let testTimestamp: UInt32 = 1700000000 - private let testModifiedTimestamp: UInt32 = 1700000100 - private let testFlags: UInt8 = 0x01 - private let invalidContactType: UInt8 = 0xFF - private let testOutPath = Data([0xAA, 0xBB, 0xCC, 0xDD]) - private let floodRoutingPath = Data(repeating: 0xFF, count: 3) - - // MARK: - ContactSyncResult Tests - - @Test("ContactSyncResult initializes correctly") - func contactSyncResultInitializes() { - let result = ContactSyncResult( - contactsReceived: testContactsReceived, - lastSyncTimestamp: testSyncTimestamp, - isIncremental: true - ) - #expect(result.contactsReceived == testContactsReceived) - #expect(result.lastSyncTimestamp == testSyncTimestamp) - #expect(result.isIncremental == true) - } - - @Test("ContactSyncResult handles zero contacts") - func contactSyncResultHandlesZero() { - let result = ContactSyncResult( - contactsReceived: 0, - lastSyncTimestamp: 0, - isIncremental: false - ) - #expect(result.contactsReceived == 0) - #expect(result.lastSyncTimestamp == 0) - #expect(result.isIncremental == false) - } - - @Test("ContactSyncResult handles maximum values") - func contactSyncResultHandlesMaxValues() { - let result = ContactSyncResult( - contactsReceived: maxContactsReceived, - lastSyncTimestamp: maxSyncTimestamp, - isIncremental: true - ) - #expect(result.contactsReceived == maxContactsReceived) - #expect(result.lastSyncTimestamp == maxSyncTimestamp) - #expect(result.isIncremental == true) - } - - // MARK: - ContactServiceError Tests - - @Test("ContactServiceError cases are distinct") - func contactServiceErrorCasesDistinct() { - // Verify basic error cases - let basicErrors: [ContactServiceError] = [ - .notConnected, - .sendFailed, - .invalidResponse, - .syncInterrupted, - .contactNotFound, - .contactTableFull - ] - - // Verify all basic cases are distinct (no duplicates) - let errorDescriptions = basicErrors.map { String(describing: $0) } - let uniqueDescriptions = Set(errorDescriptions) - #expect(errorDescriptions.count == uniqueDescriptions.count) - } - - @Test("ContactServiceError sessionError carries MeshCoreError") - func contactServiceErrorSessionError() { - let meshError = MeshCoreError.notConnected - let error = ContactServiceError.sessionError(meshError) - - // Verify the associated value is accessible - if case .sessionError(let innerError) = error { - // Just verify we can access the inner error - if case .notConnected = innerError { - // Success - } else { - Issue.record("Expected .notConnected case") - } - } else { - Issue.record("Expected sessionError case") - } - } - - // MARK: - MeshContact.toContactFrame() Tests - - @Test("MeshContact converts to ContactFrame correctly") - func meshContactToContactFrame() { - // Create a test MeshContact with all fields populated - let publicKey = testPublicKey - let outPath = testOutPath - let advertisedName = "TestNode" - let lastAdvertDate = Date(timeIntervalSince1970: TimeInterval(testTimestamp)) - let lastModifiedDate = Date(timeIntervalSince1970: TimeInterval(testModifiedTimestamp)) - let latitude = 37.7749 - let longitude = -122.4194 - - let meshContact = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .chat, - flags: ContactFlags(rawValue: testFlags), - outPathLength: 2, - outPath: outPath, - advertisedName: advertisedName, - lastAdvertisement: lastAdvertDate, - latitude: latitude, - longitude: longitude, - lastModified: lastModifiedDate - ) - - // Convert to ContactFrame - let contactFrame = meshContact.toContactFrame() - - // Verify all fields are correctly mapped - #expect(contactFrame.publicKey == publicKey) - #expect(contactFrame.type == .chat) - #expect(contactFrame.flags == testFlags) - #expect(contactFrame.outPathLength == 2) - #expect(contactFrame.outPath == outPath) - #expect(contactFrame.name == advertisedName) - #expect(contactFrame.lastAdvertTimestamp == UInt32(lastAdvertDate.timeIntervalSince1970)) - #expect(contactFrame.latitude == latitude) - #expect(contactFrame.longitude == longitude) - #expect(contactFrame.lastModified == UInt32(lastModifiedDate.timeIntervalSince1970)) - } - - @Test("MeshContact handles all ContactType conversions") - func meshContactContactTypeConversions() { - let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) - - // Test chat type - let chatContact = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .chat, - flags: ContactFlags(rawValue: 0), - outPathLength: 0, - outPath: Data(), - advertisedName: "Chat", - lastAdvertisement: Date(), - latitude: 0, - longitude: 0, - lastModified: Date() - ) - #expect(chatContact.toContactFrame().type == .chat) - - // Test repeater type - let repeaterContact = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .repeater, - flags: ContactFlags(rawValue: 0), - outPathLength: 0, - outPath: Data(), - advertisedName: "Repeater", - lastAdvertisement: Date(), - latitude: 0, - longitude: 0, - lastModified: Date() - ) - #expect(repeaterContact.toContactFrame().type == .repeater) - - // Test room type - let roomContact = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .room, - flags: ContactFlags(rawValue: 0), - outPathLength: 0, - outPath: Data(), - advertisedName: "Room", - lastAdvertisement: Date(), - latitude: 0, - longitude: 0, - lastModified: Date() - ) - #expect(roomContact.toContactFrame().type == .room) - } - - @Test("Parser handles invalid ContactType by defaulting to .chat") - func parserInvalidContactType() { - // Build 147-byte contact data with invalid type byte at offset 32 - var data = Data(repeating: 0x00, count: 147) - data[32] = invalidContactType // type byte - - // Parser should default unknown types to .chat - let contact = Parsers.parseContactData(data) - #expect(contact?.type == .chat) - } - - @Test("MeshContact handles flood routing path") - func meshContactFloodRouting() { - let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) - - let floodContact = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .chat, - flags: ContactFlags(rawValue: 0), - outPathLength: 0xFF, // Flood routing - outPath: Data(), - advertisedName: "Flood", - lastAdvertisement: Date(), - latitude: 0, - longitude: 0, - lastModified: Date() - ) - - let frame = floodContact.toContactFrame() - #expect(frame.outPathLength == 0xFF) - #expect(frame.outPath.isEmpty) - } - - // MARK: - ContactFrame.toMeshContact() Tests - - @Test("ContactFrame converts to MeshContact correctly") - func contactFrameToMeshContact() { - // Create a test ContactFrame with all fields populated - let publicKey = testPublicKey - let outPath = testOutPath - let name = "TestNode" - let lastAdvertTimestamp = testTimestamp - let lastModified = testModifiedTimestamp - let latitude = 37.7749 - let longitude = -122.4194 - - let contactFrame = ContactFrame( - publicKey: publicKey, - type: .chat, - flags: testFlags, - outPathLength: 2, - outPath: outPath, - name: name, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: lastModified - ) - - // Convert to MeshContact - let meshContact = contactFrame.toMeshContact() - - // Verify all fields are correctly mapped - #expect(meshContact.id == publicKey.uppercaseHexString()) - #expect(meshContact.publicKey == publicKey) - #expect(meshContact.type == .chat) - #expect(meshContact.flags == ContactFlags(rawValue: testFlags)) - #expect(meshContact.outPathLength == 2) - #expect(meshContact.outPath == outPath) - #expect(meshContact.advertisedName == name) - #expect(meshContact.lastAdvertisement == Date(timeIntervalSince1970: TimeInterval(lastAdvertTimestamp))) - #expect(meshContact.latitude == latitude) - #expect(meshContact.longitude == longitude) - #expect(meshContact.lastModified == Date(timeIntervalSince1970: TimeInterval(lastModified))) - } - - @Test("ContactFrame ID generation from public key") - func contactFrameIDGeneration() { - let publicKey = Data([0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x56, 0x78, 0x90, - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, - 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]) - - let contactFrame = ContactFrame( - publicKey: publicKey, - type: .chat, - flags: 0, - outPathLength: 0, - outPath: Data(), - name: "Test", - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - - let meshContact = contactFrame.toMeshContact() - - // ID should be hex string of public key (uppercase) - let expectedID = publicKey.uppercaseHexString() - #expect(meshContact.id == expectedID) - } - - @Test("ContactFrame handles all ContactType conversions") - func contactFrameContactTypeConversions() { - let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) - - // Test chat type - let chatFrame = ContactFrame( - publicKey: publicKey, - type: .chat, - flags: 0, - outPathLength: 0, - outPath: Data(), - name: "Chat", - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - #expect(chatFrame.toMeshContact().type == .chat) - - // Test repeater type - let repeaterFrame = ContactFrame( - publicKey: publicKey, - type: .repeater, - flags: 0, - outPathLength: 0, - outPath: Data(), - name: "Repeater", - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - #expect(repeaterFrame.toMeshContact().type == .repeater) - - // Test room type - let roomFrame = ContactFrame( - publicKey: publicKey, - type: .room, - flags: 0, - outPathLength: 0, - outPath: Data(), - name: "Room", - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0 - ) - #expect(roomFrame.toMeshContact().type == .room) - } - - // MARK: - Round-Trip Conversion Tests - - @Test("Round-trip conversion MeshContact -> ContactFrame -> MeshContact") - func roundTripMeshContactConversion() { - let publicKey = testPublicKey - - let original = MeshContact( - id: publicKey.uppercaseHexString(), - publicKey: publicKey, - type: .repeater, - flags: ContactFlags(rawValue: 0x05), - outPathLength: 3, - outPath: Data([0xAA, 0xBB, 0xCC]), - advertisedName: "OriginalNode", - lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(testTimestamp)), - latitude: 40.7128, - longitude: -74.0060, - lastModified: Date(timeIntervalSince1970: 1700000200) - ) - - // Convert to ContactFrame and back - let frame = original.toContactFrame() - let roundTripped = frame.toMeshContact() - - // Verify all fields survived the round trip - #expect(roundTripped.id == original.id) - #expect(roundTripped.publicKey == original.publicKey) - #expect(roundTripped.type == original.type) - #expect(roundTripped.flags == original.flags) - #expect(roundTripped.outPathLength == original.outPathLength) - #expect(roundTripped.outPath == original.outPath) - #expect(roundTripped.advertisedName == original.advertisedName) - #expect(roundTripped.lastAdvertisement == original.lastAdvertisement) - #expect(roundTripped.latitude == original.latitude) - #expect(roundTripped.longitude == original.longitude) - #expect(roundTripped.lastModified == original.lastModified) - } - - @Test("Round-trip conversion ContactFrame -> MeshContact -> ContactFrame") - func roundTripContactFrameConversion() { - let publicKey = testPublicKey - - let original = ContactFrame( - publicKey: publicKey, - type: .room, - flags: 0x03, - outPathLength: 1, - outPath: Data([0xFF]), - name: "OriginalRoom", - lastAdvertTimestamp: testTimestamp, - latitude: 51.5074, - longitude: -0.1278, - lastModified: 1700000300 - ) - - // Convert to MeshContact and back - let meshContact = original.toMeshContact() - let roundTripped = meshContact.toContactFrame() - - // Verify all fields survived the round trip - #expect(roundTripped == original) + // MARK: - Test Constants + + // Sync result test values + private let testContactsReceived = 5 + private let testSyncTimestamp: UInt32 = 1_234_567_890 + private let maxContactsReceived = Int.max + private let maxSyncTimestamp = UInt32.max + + /// Contact test values + private let testPublicKey = Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20]) + private let testTimestamp: UInt32 = 1_700_000_000 + private let testModifiedTimestamp: UInt32 = 1_700_000_100 + private let testFlags: UInt8 = 0x01 + private let invalidContactType: UInt8 = 0xFF + private let testOutPath = Data([0xAA, 0xBB, 0xCC, 0xDD]) + private let floodRoutingPath = Data(repeating: 0xFF, count: 3) + + // MARK: - ContactSyncResult Tests + + @Test + func `ContactSyncResult initializes correctly`() { + let result = ContactSyncResult( + contactsReceived: testContactsReceived, + lastSyncTimestamp: testSyncTimestamp, + isIncremental: true + ) + #expect(result.contactsReceived == testContactsReceived) + #expect(result.lastSyncTimestamp == testSyncTimestamp) + #expect(result.isIncremental == true) + } + + @Test + func `ContactSyncResult handles zero contacts`() { + let result = ContactSyncResult( + contactsReceived: 0, + lastSyncTimestamp: 0, + isIncremental: false + ) + #expect(result.contactsReceived == 0) + #expect(result.lastSyncTimestamp == 0) + #expect(result.isIncremental == false) + } + + @Test + func `ContactSyncResult handles maximum values`() { + let result = ContactSyncResult( + contactsReceived: maxContactsReceived, + lastSyncTimestamp: maxSyncTimestamp, + isIncremental: true + ) + #expect(result.contactsReceived == maxContactsReceived) + #expect(result.lastSyncTimestamp == maxSyncTimestamp) + #expect(result.isIncremental == true) + } + + // MARK: - ContactServiceError Tests + + @Test + func `ContactServiceError cases are distinct`() { + // Verify basic error cases + let basicErrors: [ContactServiceError] = [ + .notConnected, + .sendFailed, + .invalidResponse, + .syncInterrupted, + .contactNotFound, + .contactTableFull + ] + + // Verify all basic cases are distinct (no duplicates) + let errorDescriptions = basicErrors.map { String(describing: $0) } + let uniqueDescriptions = Set(errorDescriptions) + #expect(errorDescriptions.count == uniqueDescriptions.count) + } + + @Test + func `ContactServiceError sessionError carries MeshCoreError`() { + let meshError = MeshCoreError.notConnected + let error = ContactServiceError.sessionError(meshError) + + // Verify the associated value is accessible + if case let .sessionError(innerError) = error { + // Just verify we can access the inner error + if case .notConnected = innerError { + // Success + } else { + Issue.record("Expected .notConnected case") + } + } else { + Issue.record("Expected sessionError case") } - - // MARK: - Cleanup Coordinator Tests - - /// Actor to track cleanup invocations in a thread-safe manner - private actor CleanupTracker { - var invocations: [(contactID: UUID, reason: ContactCleanupReason, publicKey: Data)] = [] - - func record(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) { - invocations.append((contactID: contactID, reason: reason, publicKey: publicKey)) - } + } + + // MARK: - MeshContact.toContactFrame() Tests + + @Test + func `MeshContact converts to ContactFrame correctly`() { + // Create a test MeshContact with all fields populated + let publicKey = testPublicKey + let outPath = testOutPath + let advertisedName = "TestNode" + let lastAdvertDate = Date(timeIntervalSince1970: TimeInterval(testTimestamp)) + let lastModifiedDate = Date(timeIntervalSince1970: TimeInterval(testModifiedTimestamp)) + let latitude = 37.7749 + let longitude = -122.4194 + + let meshContact = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .chat, + flags: ContactFlags(rawValue: testFlags), + outPathLength: 2, + outPath: outPath, + advertisedName: advertisedName, + lastAdvertisement: lastAdvertDate, + latitude: latitude, + longitude: longitude, + lastModified: lastModifiedDate + ) + + // Convert to ContactFrame + let contactFrame = meshContact.toContactFrame() + + // Verify all fields are correctly mapped + #expect(contactFrame.publicKey == publicKey) + #expect(contactFrame.type == .chat) + #expect(contactFrame.flags == testFlags) + #expect(contactFrame.outPathLength == 2) + #expect(contactFrame.outPath == outPath) + #expect(contactFrame.name == advertisedName) + #expect(contactFrame.lastAdvertTimestamp == UInt32(lastAdvertDate.timeIntervalSince1970)) + #expect(contactFrame.latitude == latitude) + #expect(contactFrame.longitude == longitude) + #expect(contactFrame.lastModified == UInt32(lastModifiedDate.timeIntervalSince1970)) + } + + @Test + func `MeshContact handles all ContactType conversions`() { + let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) + + // Test chat type + let chatContact = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: "Chat", + lastAdvertisement: Date(), + latitude: 0, + longitude: 0, + lastModified: Date() + ) + #expect(chatContact.toContactFrame().type == .chat) + + // Test repeater type + let repeaterContact = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .repeater, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: "Repeater", + lastAdvertisement: Date(), + latitude: 0, + longitude: 0, + lastModified: Date() + ) + #expect(repeaterContact.toContactFrame().type == .repeater) + + // Test room type + let roomContact = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .room, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: "Room", + lastAdvertisement: Date(), + latitude: 0, + longitude: 0, + lastModified: Date() + ) + #expect(roomContact.toContactFrame().type == .room) + } + + @Test + func `Parser handles invalid ContactType by defaulting to .chat`() { + // Build 147-byte contact data with invalid type byte at offset 32 + var data = Data(repeating: 0x00, count: 147) + data[32] = invalidContactType // type byte + + // Parser should default unknown types to .chat + let contact = Parsers.parseContactData(data) + #expect(contact?.type == .chat) + } + + @Test + func `MeshContact handles flood routing path`() { + let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) + + let floodContact = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0xFF, // Flood routing + outPath: Data(), + advertisedName: "Flood", + lastAdvertisement: Date(), + latitude: 0, + longitude: 0, + lastModified: Date() + ) + + let frame = floodContact.toContactFrame() + #expect(frame.outPathLength == 0xFF) + #expect(frame.outPath.isEmpty) + } + + // MARK: - ContactFrame.toMeshContact() Tests + + @Test + func `ContactFrame converts to MeshContact correctly`() { + // Create a test ContactFrame with all fields populated + let publicKey = testPublicKey + let outPath = testOutPath + let name = "TestNode" + let lastAdvertTimestamp = testTimestamp + let lastModified = testModifiedTimestamp + let latitude = 37.7749 + let longitude = -122.4194 + + let contactFrame = ContactFrame( + publicKey: publicKey, + type: .chat, + flags: testFlags, + outPathLength: 2, + outPath: outPath, + name: name, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified + ) + + // Convert to MeshContact + let meshContact = contactFrame.toMeshContact() + + // Verify all fields are correctly mapped + #expect(meshContact.id == publicKey.uppercaseHexString()) + #expect(meshContact.publicKey == publicKey) + #expect(meshContact.type == .chat) + #expect(meshContact.flags == ContactFlags(rawValue: testFlags)) + #expect(meshContact.outPathLength == 2) + #expect(meshContact.outPath == outPath) + #expect(meshContact.advertisedName == name) + #expect(meshContact.lastAdvertisement == Date(timeIntervalSince1970: TimeInterval(lastAdvertTimestamp))) + #expect(meshContact.latitude == latitude) + #expect(meshContact.longitude == longitude) + #expect(meshContact.lastModified == Date(timeIntervalSince1970: TimeInterval(lastModified))) + } + + @Test + func `ContactFrame ID generation from public key`() { + let publicKey = Data([0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x56, 0x78, 0x90, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]) + + let contactFrame = ContactFrame( + publicKey: publicKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Test", + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + + let meshContact = contactFrame.toMeshContact() + + // ID should be hex string of public key (uppercase) + let expectedID = publicKey.uppercaseHexString() + #expect(meshContact.id == expectedID) + } + + @Test + func `ContactFrame handles all ContactType conversions`() { + let publicKey = Data(repeating: 0x00, count: ProtocolLimits.publicKeySize) + + // Test chat type + let chatFrame = ContactFrame( + publicKey: publicKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Chat", + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + #expect(chatFrame.toMeshContact().type == .chat) + + // Test repeater type + let repeaterFrame = ContactFrame( + publicKey: publicKey, + type: .repeater, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Repeater", + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + #expect(repeaterFrame.toMeshContact().type == .repeater) + + // Test room type + let roomFrame = ContactFrame( + publicKey: publicKey, + type: .room, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Room", + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + #expect(roomFrame.toMeshContact().type == .room) + } + + // MARK: - Round-Trip Conversion Tests + + @Test + func `Round-trip conversion MeshContact -> ContactFrame -> MeshContact`() { + let publicKey = testPublicKey + + let original = MeshContact( + id: publicKey.uppercaseHexString(), + publicKey: publicKey, + type: .repeater, + flags: ContactFlags(rawValue: 0x05), + outPathLength: 3, + outPath: Data([0xAA, 0xBB, 0xCC]), + advertisedName: "OriginalNode", + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(testTimestamp)), + latitude: 40.7128, + longitude: -74.0060, + lastModified: Date(timeIntervalSince1970: 1_700_000_200) + ) + + // Convert to ContactFrame and back + let frame = original.toContactFrame() + let roundTripped = frame.toMeshContact() + + // Verify all fields survived the round trip + #expect(roundTripped.id == original.id) + #expect(roundTripped.publicKey == original.publicKey) + #expect(roundTripped.type == original.type) + #expect(roundTripped.flags == original.flags) + #expect(roundTripped.outPathLength == original.outPathLength) + #expect(roundTripped.outPath == original.outPath) + #expect(roundTripped.advertisedName == original.advertisedName) + #expect(roundTripped.lastAdvertisement == original.lastAdvertisement) + #expect(roundTripped.latitude == original.latitude) + #expect(roundTripped.longitude == original.longitude) + #expect(roundTripped.lastModified == original.lastModified) + } + + @Test + func `Round-trip conversion ContactFrame -> MeshContact -> ContactFrame`() { + let publicKey = testPublicKey + + let original = ContactFrame( + publicKey: publicKey, + type: .room, + flags: 0x03, + outPathLength: 1, + outPath: Data([0xFF]), + name: "OriginalRoom", + lastAdvertTimestamp: testTimestamp, + latitude: 51.5074, + longitude: -0.1278, + lastModified: 1_700_000_300 + ) + + // Convert to MeshContact and back + let meshContact = original.toMeshContact() + let roundTripped = meshContact.toContactFrame() + + // Verify all fields survived the round trip + #expect(roundTripped == original) + } + + // MARK: - Cleanup Coordinator Tests + + /// Actor to track cleanup invocations in a thread-safe manner + private actor CleanupTracker { + var invocations: [(contactID: UUID, reason: ContactCleanupReason, publicKey: Data)] = [] + + func record(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) { + invocations.append((contactID: contactID, reason: reason, publicKey: publicKey)) } + } - /// Cleanup coordinator stand-in that records each invocation on a `CleanupTracker`. - private struct RecordingCleanupCoordinator: ContactCleanupHandling { - let tracker: CleanupTracker - - func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async { - await tracker.record(contactID: contactID, reason: reason, publicKey: publicKey) - } - } + /// Cleanup coordinator stand-in that records each invocation on a `CleanupTracker`. + private struct RecordingCleanupCoordinator: ContactCleanupHandling { + let tracker: CleanupTracker - @Test("removeContact deletes messages and triggers cleanup") - func removeContactDeletesMessagesAndTriggersCleanup() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - - // Set up contact in the mock store - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 3 - ) - try await mockStore.saveContact(contact) - - // Track cleanup handler invocations - let tracker = CleanupTracker() - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) - ) - - // Remove the contact - try await service.removeContact(radioID: radioID, publicKey: testPublicKey) - - // Verify messages were deleted - let deletedForContacts = await mockStore.deletedMessagesForContactIDs - #expect(deletedForContacts == [contactID]) - - // Verify cleanup handler was called with reason=.deleted - let invocations = await tracker.invocations - #expect(invocations.count == 1) - #expect(invocations[0].contactID == contactID) - #expect(invocations[0].reason == .deleted) + func handleCleanup(contactID: UUID, reason: ContactCleanupReason, publicKey: Data) async { + await tracker.record(contactID: contactID, reason: reason, publicKey: publicKey) } - - @Test("clearContactMessages deletes messages and zeroes both unread counters while preserving lastMessageDate") - func clearContactMessagesPreservesLastMessageDate() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - let lastMessageDate = Date(timeIntervalSince1970: TimeInterval(testTimestamp)) - - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: lastMessageDate, - unreadCount: 4, - unreadMentionCount: 2 - ) - try await mockStore.saveContact(contact) - - // Seed real message rows so deletion is observable, not just forwarded. - for offset in 0..<3 { - try await mockStore.saveMessage(MessageDTO( - id: UUID(), - radioID: radioID, - contactID: contactID, - channelIndex: nil, - text: "msg \(offset)", - timestamp: testTimestamp + UInt32(offset), - createdAt: lastMessageDate, - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: "TestContact", - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - )) - } - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: nil - ) - - try await service.clearContactMessages(contactID: contactID) - - // Messages are actually gone, not merely reported deleted. - let deletedForContacts = await mockStore.deletedMessagesForContactIDs - #expect(deletedForContacts == [contactID]) - let remaining = try await mockStore.fetchMessages(contactID: contactID, limit: 10, offset: 0) - #expect(remaining.isEmpty) - - // The conversation stays listed: lastMessageDate is preserved, both unread counters zeroed. - let updated = try await mockStore.fetchContact(id: contactID) - #expect(updated?.lastMessageDate == lastMessageDate) - #expect(updated?.unreadCount == 0) - #expect(updated?.unreadMentionCount == 0) - } - - @Test("updateContactPreferences clears unread when blocking") - func updateContactPreferencesClearsUnreadWhenBlocking() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - - // Set up contact with unread count - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 5 - ) - try await mockStore.saveContact(contact) - - // Track cleanup handler invocations - let tracker = CleanupTracker() - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) - ) - - // Block the contact - try await service.updateContactPreferences(contactID: contactID, isBlocked: true) - - // Verify unread count was cleared - let updatedContact = await mockStore.contacts[contactID] - #expect(updatedContact?.unreadCount == 0) - #expect(updatedContact?.isBlocked == true) - - // Verify cleanup handler was called with reason=.blocked - let invocations = await tracker.invocations - #expect(invocations.count == 1) - #expect(invocations[0].contactID == contactID) - #expect(invocations[0].reason == .blocked) - } - - @Test("updateContactPreferences does not trigger cleanup when not blocking") - func updateContactPreferencesNoCleanupWhenNotBlocking() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - - // Set up contact - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 5 - ) - try await mockStore.saveContact(contact) - - // Track cleanup handler invocations - let tracker = CleanupTracker() - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) - ) - - // Update nickname (not blocking) - try await service.updateContactPreferences(contactID: contactID, nickname: "NewNickname") - - // Verify unread count was preserved - let updatedContact = await mockStore.contacts[contactID] - #expect(updatedContact?.unreadCount == 5) - #expect(updatedContact?.nickname == "NewNickname") - - // Verify cleanup handler was NOT called - let invocations = await tracker.invocations - #expect(invocations.isEmpty) - } - - @Test("updateContactPreferences preserves fields when blocking") - func updateContactPreferencesPreservesFieldsWhenBlocking() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - - // Set up contact with special fields - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: "MyNickname", - isBlocked: false, - isMuted: false, - isFavorite: true, - lastMessageDate: Date(), - unreadCount: 5, - ocvPreset: "medium", - customOCVArrayString: "custom" - ) - try await mockStore.saveContact(contact) - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: nil - ) - - // Block the contact - try await service.updateContactPreferences(contactID: contactID, isBlocked: true) - - // Verify all fields are preserved except unreadCount - let updatedContact = await mockStore.contacts[contactID] - #expect(updatedContact?.nickname == "MyNickname") - #expect(updatedContact?.isFavorite == true) - #expect(updatedContact?.ocvPreset == "medium") - #expect(updatedContact?.customOCVArrayString == "custom") - #expect(updatedContact?.unreadCount == 0) - #expect(updatedContact?.isBlocked == true) - } - - @Test("unblocking contact triggers cleanup with unblocked reason") - func unblockingContactTriggersCleanupWithUnblockedReason() async throws { - let mockSession = MockMeshCoreSession() - let mockStore = MockPersistenceStore() - - let radioID = UUID() - let contactID = UUID() - - // Set up contact that is already blocked - let contact = ContactDTO( - id: contactID, - radioID: radioID, - publicKey: testPublicKey, - name: "TestContact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: true, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - try await mockStore.saveContact(contact) - - // Track cleanup handler invocations - let tracker = CleanupTracker() - - let service = ContactService( - session: mockSession, - dataStore: mockStore, - syncCoordinator: nil, - cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) - ) - - // Unblock the contact - try await service.updateContactPreferences(contactID: contactID, isBlocked: false) - - // Verify contact was unblocked - let updatedContact = await mockStore.contacts[contactID] - #expect(updatedContact?.isBlocked == false) - - // Verify cleanup handler was called with reason=.unblocked - let invocations = await tracker.invocations - #expect(invocations.count == 1) - #expect(invocations[0].contactID == contactID) - #expect(invocations[0].reason == .unblocked) - } - - // MARK: - Reset Path - - @Test("resetPath flood-routes the contact while preserving an unmodeled type byte") - func resetPathPreservesUnmodeledTypeByte() async throws { - let radioID = UUID() - // The real store round-trips the raw type byte; the mock store normalizes it, so this uses - // a real PersistenceStore to observe what resetPath actually persists. - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let unmodeledType: UInt8 = 0x7F - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: testPublicKey, - typeRawValue: unmodeledType, - outPathLength: 2, - outPath: testOutPath - ) - try await dataStore.saveContact(contact) - - let session = MockMeshCoreSession() - let service = ContactService( - session: session, - dataStore: dataStore, - syncCoordinator: nil, - cleanupCoordinator: nil - ) - - try await service.resetPath(radioID: radioID, publicKey: testPublicKey) - - #expect(await session.resetPathPublicKeys == [testPublicKey]) - let reset = try await dataStore.fetchContact(radioID: radioID, publicKey: testPublicKey) - #expect(reset?.isFloodRouted == true) - #expect(reset?.typeRawValue == unmodeledType) + } + + @Test + func `removeContact deletes messages and triggers cleanup`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + + // Set up contact in the mock store + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 3 + ) + try await mockStore.saveContact(contact) + + // Track cleanup handler invocations + let tracker = CleanupTracker() + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) + ) + + // Remove the contact + try await service.removeContact(radioID: radioID, publicKey: testPublicKey) + + // Verify messages were deleted + let deletedForContacts = await mockStore.deletedMessagesForContactIDs + #expect(deletedForContacts == [contactID]) + + // Verify cleanup handler was called with reason=.deleted + let invocations = await tracker.invocations + #expect(invocations.count == 1) + #expect(invocations[0].contactID == contactID) + #expect(invocations[0].reason == .deleted) + } + + @Test + func `clearContactMessages deletes messages and zeroes both unread counters while preserving lastMessageDate`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + let lastMessageDate = Date(timeIntervalSince1970: TimeInterval(testTimestamp)) + + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: lastMessageDate, + unreadCount: 4, + unreadMentionCount: 2 + ) + try await mockStore.saveContact(contact) + + // Seed real message rows so deletion is observable, not just forwarded. + for offset in 0..<3 { + try await mockStore.saveMessage(MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "msg \(offset)", + timestamp: testTimestamp + UInt32(offset), + createdAt: lastMessageDate, + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: "TestContact", + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + )) } + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + try await service.clearContactMessages(contactID: contactID) + + // Messages are actually gone, not merely reported deleted. + let deletedForContacts = await mockStore.deletedMessagesForContactIDs + #expect(deletedForContacts == [contactID]) + let remaining = try await mockStore.fetchMessages(contactID: contactID, limit: 10, offset: 0) + #expect(remaining.isEmpty) + + // The conversation stays listed: lastMessageDate is preserved, both unread counters zeroed. + let updated = try await mockStore.fetchContact(id: contactID) + #expect(updated?.lastMessageDate == lastMessageDate) + #expect(updated?.unreadCount == 0) + #expect(updated?.unreadMentionCount == 0) + } + + @Test + func `updateContactPreferences clears unread when blocking`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + + // Set up contact with unread count + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 5 + ) + try await mockStore.saveContact(contact) + + // Track cleanup handler invocations + let tracker = CleanupTracker() + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) + ) + + // Block the contact + try await service.updateContactPreferences(contactID: contactID, isBlocked: true) + + // Verify unread count was cleared + let updatedContact = await mockStore.contacts[contactID] + #expect(updatedContact?.unreadCount == 0) + #expect(updatedContact?.isBlocked == true) + + // Verify cleanup handler was called with reason=.blocked + let invocations = await tracker.invocations + #expect(invocations.count == 1) + #expect(invocations[0].contactID == contactID) + #expect(invocations[0].reason == .blocked) + } + + @Test + func `updateContactPreferences does not trigger cleanup when not blocking`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + + // Set up contact + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 5 + ) + try await mockStore.saveContact(contact) + + // Track cleanup handler invocations + let tracker = CleanupTracker() + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) + ) + + // Update nickname (not blocking) + try await service.updateContactPreferences(contactID: contactID, nickname: "NewNickname") + + // Verify unread count was preserved + let updatedContact = await mockStore.contacts[contactID] + #expect(updatedContact?.unreadCount == 5) + #expect(updatedContact?.nickname == "NewNickname") + + // Verify cleanup handler was NOT called + let invocations = await tracker.invocations + #expect(invocations.isEmpty) + } + + @Test + func `updateContactPreferences preserves fields when blocking`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + + // Set up contact with special fields + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: "MyNickname", + isBlocked: false, + isMuted: false, + isFavorite: true, + lastMessageDate: Date(), + unreadCount: 5, + ocvPreset: "medium", + customOCVArrayString: "custom" + ) + try await mockStore.saveContact(contact) + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + // Block the contact + try await service.updateContactPreferences(contactID: contactID, isBlocked: true) + + // Verify all fields are preserved except unreadCount + let updatedContact = await mockStore.contacts[contactID] + #expect(updatedContact?.nickname == "MyNickname") + #expect(updatedContact?.isFavorite == true) + #expect(updatedContact?.ocvPreset == "medium") + #expect(updatedContact?.customOCVArrayString == "custom") + #expect(updatedContact?.unreadCount == 0) + #expect(updatedContact?.isBlocked == true) + } + + @Test + func `unblocking contact triggers cleanup with unblocked reason`() async throws { + let mockSession = MockMeshCoreSession() + let mockStore = MockPersistenceStore() + + let radioID = UUID() + let contactID = UUID() + + // Set up contact that is already blocked + let contact = ContactDTO( + id: contactID, + radioID: radioID, + publicKey: testPublicKey, + name: "TestContact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: true, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + try await mockStore.saveContact(contact) + + // Track cleanup handler invocations + let tracker = CleanupTracker() + + let service = ContactService( + session: mockSession, + dataStore: mockStore, + syncCoordinator: nil, + cleanupCoordinator: RecordingCleanupCoordinator(tracker: tracker) + ) + + // Unblock the contact + try await service.updateContactPreferences(contactID: contactID, isBlocked: false) + + // Verify contact was unblocked + let updatedContact = await mockStore.contacts[contactID] + #expect(updatedContact?.isBlocked == false) + + // Verify cleanup handler was called with reason=.unblocked + let invocations = await tracker.invocations + #expect(invocations.count == 1) + #expect(invocations[0].contactID == contactID) + #expect(invocations[0].reason == .unblocked) + } + + // MARK: - Reset Path + + @Test + func `resetPath flood-routes the contact while preserving an unmodeled type byte`() async throws { + let radioID = UUID() + // The real store round-trips the raw type byte; the mock store normalizes it, so this uses + // a real PersistenceStore to observe what resetPath actually persists. + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let unmodeledType: UInt8 = 0x7F + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: testPublicKey, + typeRawValue: unmodeledType, + outPathLength: 2, + outPath: testOutPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + let service = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + try await service.resetPath(radioID: radioID, publicKey: testPublicKey) + + #expect(await session.resetPathPublicKeys == [testPublicKey]) + let reset = try await dataStore.fetchContact(radioID: radioID, publicKey: testPublicKey) + #expect(reset?.isFloodRouted == true) + #expect(reset?.typeRawValue == unmodeledType) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/DebugLogBufferTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/DebugLogBufferTests.swift index f1e85481..f901d6d9 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/DebugLogBufferTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/DebugLogBufferTests.swift @@ -1,52 +1,51 @@ -import Testing import Foundation @testable import MC1Services +import Testing -// Serialized because both tests reassign the process-global DebugLogBuffer.shared; -// run in parallel, one test's reassignment breaks the other's final-value assertion. +/// Serialized because both tests reassign the process-global DebugLogBuffer.shared; +/// run in parallel, one test's reassignment breaks the other's final-value assertion. @Suite("DebugLogBuffer Tests", .serialized) struct DebugLogBufferTests { + private func makeBuffer() async throws -> DebugLogBuffer { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + return DebugLogBuffer(dataStore: store) + } - private func makeBuffer() async throws -> DebugLogBuffer { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - return DebugLogBuffer(dataStore: store) - } + @Test + func `shared get and set round-trip through the lock`() async throws { + let buffer = try await makeBuffer() + DebugLogBuffer.shared = buffer + #expect(DebugLogBuffer.shared === buffer) + DebugLogBuffer.shared = nil + #expect(DebugLogBuffer.shared == nil) + } - @Test("shared get and set round-trip through the lock") - func sharedRoundTrip() async throws { - let buffer = try await makeBuffer() - DebugLogBuffer.shared = buffer - #expect(DebugLogBuffer.shared === buffer) - DebugLogBuffer.shared = nil - #expect(DebugLogBuffer.shared == nil) - } + /// Reassigning `shared` per connection while loggers read it from arbitrary + /// actors is the data race this guards against; the lock must make + /// concurrent reads and writes safe (verified under the thread sanitizer). + @Test + func `concurrent reads and writes of shared are race-free`() async throws { + let bufferA = try await makeBuffer() + let bufferB = try await makeBuffer() + DebugLogBuffer.shared = bufferA - /// Reassigning `shared` per connection while loggers read it from arbitrary - /// actors is the data race this guards against; the lock must make - /// concurrent reads and writes safe (verified under the thread sanitizer). - @Test("concurrent reads and writes of shared are race-free") - func concurrentAccessIsRaceFree() async throws { - let bufferA = try await makeBuffer() - let bufferB = try await makeBuffer() - DebugLogBuffer.shared = bufferA - - await withTaskGroup(of: Void.self) { group in - for index in 0..<200 { - group.addTask { - if index.isMultiple(of: 2) { - DebugLogBuffer.shared = index.isMultiple(of: 4) ? bufferA : bufferB - } else { - _ = DebugLogBuffer.shared - } - } - } + await withTaskGroup(of: Void.self) { group in + for index in 0..<200 { + group.addTask { + if index.isMultiple(of: 2) { + DebugLogBuffer.shared = index.isMultiple(of: 4) ? bufferA : bufferB + } else { + _ = DebugLogBuffer.shared + } } + } + } - // Final value must be one of the two known buffers, never a torn pointer. - let final = DebugLogBuffer.shared - #expect(final === bufferA || final === bufferB) + // Final value must be one of the two known buffers, never a torn pointer. + let final = DebugLogBuffer.shared + #expect(final === bufferA || final === bufferB) - DebugLogBuffer.shared = nil - } + DebugLogBuffer.shared = nil + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ErrorLocalizationTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ErrorLocalizationTests.swift index 1374e17f..db57c84d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ErrorLocalizationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ErrorLocalizationTests.swift @@ -1,155 +1,154 @@ import Foundation -import Testing -@testable import MeshCore @testable import MC1Services +@testable import MeshCore +import Testing @Suite("Error Localization Tests") struct ErrorLocalizationTests { - - // MARK: - MeshCoreError Tests - - @Test("MeshCoreError.timeout produces human-readable description") - func meshCoreTimeout() { - let error: MeshCoreError = .timeout - #expect(error.localizedDescription == "The operation timed out. Please try again.") - } - - @Test("MeshCoreError.deviceError maps known firmware codes", arguments: [ - (UInt8(0x01), "Command not supported by device firmware."), - (UInt8(0x02), "Item not found on device."), - (UInt8(0x03), "Device storage is full."), - (UInt8(0x04), "Device is in an invalid state for this operation."), - (UInt8(0x05), "Device file system error."), - (UInt8(0x06), "Invalid parameter sent to device."), - ]) - func meshCoreDeviceErrorKnownCodes(code: UInt8, expected: String) { - let error: MeshCoreError = .deviceError(code: code) - #expect(error.localizedDescription == expected, "Code \(code) should produce: \(expected)") - } - - @Test("MeshCoreError.deviceError falls back for unknown codes") - func meshCoreDeviceErrorUnknownCode() { - let error: MeshCoreError = .deviceError(code: 10) - #expect(error.localizedDescription == "Device error (code 10).") - } - - @Test("MeshCoreError.deviceError handles code zero") - func meshCoreDeviceErrorCodeZero() { - let error: MeshCoreError = .deviceError(code: 0) - #expect(error.localizedDescription == "Device error (code 0).") - } - - @Test("MeshCoreError bluetooth errors produce readable descriptions") - func meshCoreBluetoothErrors() { - #expect(MeshCoreError.bluetoothUnavailable.localizedDescription == "Bluetooth is not available on this device.") - #expect(MeshCoreError.bluetoothUnauthorized.localizedDescription == "Bluetooth permission is required. Please enable it in Settings.") - #expect(MeshCoreError.bluetoothPoweredOff.localizedDescription == "Bluetooth is turned off. Please enable Bluetooth to connect.") - } - - @Test("MeshCoreError.connectionLost includes underlying error when present") - func meshCoreConnectionLostWithUnderlying() { - let underlying = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "link dropped"]) - let error: MeshCoreError = .connectionLost(underlying: underlying) - #expect(error.localizedDescription.contains("Connection to device was lost")) - #expect(error.localizedDescription.contains("link dropped")) - } - - @Test("MeshCoreError.connectionLost without underlying error") - func meshCoreConnectionLostWithoutUnderlying() { - let error: MeshCoreError = .connectionLost(underlying: nil) - #expect(error.localizedDescription == "Connection to device was lost.") - } - - @Test("MeshCoreError.featureDisabled produces readable description") - func meshCoreFeatureDisabled() { - #expect(MeshCoreError.featureDisabled.localizedDescription == "This feature is disabled on the device.") - } - - @Test("MeshCoreError.sessionNotStarted produces readable description") - func meshCoreSessionNotStarted() { - #expect(MeshCoreError.sessionNotStarted.localizedDescription == "Session has not been started.") - } - - // MARK: - ProtocolError Tests - - @Test("ProtocolError cases produce non-empty, readable descriptions", arguments: [ - ProtocolError.unsupportedCommand, .notFound, .tableFull, - .badState, .fileIOError, .illegalArgument, - ]) - func protocolErrorDescriptions(protocolError: ProtocolError) { - let description = protocolError.localizedDescription - #expect(!description.isEmpty, "ProtocolError.\(protocolError) should have a description") - #expect(!description.contains("ProtocolError"), "Should not contain raw type name") - } - - // MARK: - Service Error Session Pass-Through Tests - - @Test("MessageServiceError.sessionError passes through MeshCoreError description") - func messageServiceSessionPassThrough() { - let meshError: MeshCoreError = .deviceError(code: 0x03) - let serviceError: MessageServiceError = .sessionError(meshError) - #expect(serviceError.localizedDescription == "Device storage is full.") - } - - @Test("ChannelServiceError.sessionError passes through MeshCoreError description") - func channelServiceSessionPassThrough() { - let meshError: MeshCoreError = .timeout - let serviceError: ChannelServiceError = .sessionError(meshError) - #expect(serviceError.localizedDescription == "The operation timed out. Please try again.") - } - - @Test("SettingsServiceError.sessionError passes through without prefix") - func settingsServiceSessionPassThrough() { - let meshError: MeshCoreError = .notConnected - let serviceError: SettingsServiceError = .sessionError(meshError) - #expect(!serviceError.localizedDescription.contains("Session error:")) - #expect(serviceError.localizedDescription == "Not connected to device.") - } - - @Test("SettingsServiceError.deviceGPSVerificationFailed is human-readable") - func settingsServiceDeviceGPSVerificationFailed() { - let serviceError: SettingsServiceError = .deviceGPSVerificationFailed( - expectedEnabled: false, - actualEnabled: true - ) - #expect( - serviceError.localizedDescription == - "Device GPS setting was not saved. Expected 'Off' but device reports 'On'." - ) - } - - @Test("RemoteNodeError.sessionError passes through without prefix") - func remoteNodeSessionPassThrough() { - let meshError: MeshCoreError = .bluetoothPoweredOff - let serviceError: RemoteNodeError = .sessionError(meshError) - #expect(!serviceError.localizedDescription.contains("Session error:")) - #expect(serviceError.localizedDescription == "Bluetooth is turned off. Please enable Bluetooth to connect.") - } - - // MARK: - Service Error Spot Checks - - @Test("AdvertisementError.notConnected produces readable description") - func advertisementNotConnected() { - #expect(AdvertisementError.notConnected.localizedDescription == "Not connected to device.") - } - - @Test("RoomServerError.permissionDenied produces readable description") - func roomServerPermissionDenied() { - #expect(RoomServerError.permissionDenied.localizedDescription == "Permission denied.") - } - - @Test("BinaryProtocolError.timeout produces readable description") - func binaryProtocolTimeout() { - #expect(BinaryProtocolError.timeout.localizedDescription == "Request timed out.") - } - - @Test("SyncCoordinatorError.alreadySyncing produces readable description") - func syncCoordinatorAlreadySyncing() { - #expect(SyncCoordinatorError.alreadySyncing.localizedDescription == "A sync is already in progress.") - } - - @Test("PersistenceStoreError.contactNotFound produces readable description") - func persistenceStoreContactNotFound() { - #expect(PersistenceStoreError.contactNotFound.localizedDescription == "Contact not found.") - } + // MARK: - MeshCoreError Tests + + @Test + func `MeshCoreError.timeout produces human-readable description`() { + let error: MeshCoreError = .timeout + #expect(error.localizedDescription == "The operation timed out. Please try again.") + } + + @Test(arguments: [ + (UInt8(0x01), "Command not supported by device firmware."), + (UInt8(0x02), "Item not found on device."), + (UInt8(0x03), "Device storage is full."), + (UInt8(0x04), "Device is in an invalid state for this operation."), + (UInt8(0x05), "Device file system error."), + (UInt8(0x06), "Invalid parameter sent to device."), + ]) + func `MeshCoreError.deviceError maps known firmware codes`(code: UInt8, expected: String) { + let error: MeshCoreError = .deviceError(code: code) + #expect(error.localizedDescription == expected, "Code \(code) should produce: \(expected)") + } + + @Test + func `MeshCoreError.deviceError falls back for unknown codes`() { + let error: MeshCoreError = .deviceError(code: 10) + #expect(error.localizedDescription == "Device error (code 10).") + } + + @Test + func `MeshCoreError.deviceError handles code zero`() { + let error: MeshCoreError = .deviceError(code: 0) + #expect(error.localizedDescription == "Device error (code 0).") + } + + @Test + func `MeshCoreError bluetooth errors produce readable descriptions`() { + #expect(MeshCoreError.bluetoothUnavailable.localizedDescription == "Bluetooth is not available on this device.") + #expect(MeshCoreError.bluetoothUnauthorized.localizedDescription == "Bluetooth permission is required. Please enable it in Settings.") + #expect(MeshCoreError.bluetoothPoweredOff.localizedDescription == "Bluetooth is turned off. Please enable Bluetooth to connect.") + } + + @Test + func `MeshCoreError.connectionLost includes underlying error when present`() { + let underlying = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "link dropped"]) + let error: MeshCoreError = .connectionLost(underlying: underlying) + #expect(error.localizedDescription.contains("Connection to device was lost")) + #expect(error.localizedDescription.contains("link dropped")) + } + + @Test + func `MeshCoreError.connectionLost without underlying error`() { + let error: MeshCoreError = .connectionLost(underlying: nil) + #expect(error.localizedDescription == "Connection to device was lost.") + } + + @Test + func `MeshCoreError.featureDisabled produces readable description`() { + #expect(MeshCoreError.featureDisabled.localizedDescription == "This feature is disabled on the device.") + } + + @Test + func `MeshCoreError.sessionNotStarted produces readable description`() { + #expect(MeshCoreError.sessionNotStarted.localizedDescription == "Session has not been started.") + } + + // MARK: - ProtocolError Tests + + @Test(arguments: [ + ProtocolError.unsupportedCommand, .notFound, .tableFull, + .badState, .fileIOError, .illegalArgument, + ]) + func `ProtocolError cases produce non-empty, readable descriptions`(protocolError: ProtocolError) { + let description = protocolError.localizedDescription + #expect(!description.isEmpty, "ProtocolError.\(protocolError) should have a description") + #expect(!description.contains("ProtocolError"), "Should not contain raw type name") + } + + // MARK: - Service Error Session Pass-Through Tests + + @Test + func `MessageServiceError.sessionError passes through MeshCoreError description`() { + let meshError: MeshCoreError = .deviceError(code: 0x03) + let serviceError: MessageServiceError = .sessionError(meshError) + #expect(serviceError.localizedDescription == "Device storage is full.") + } + + @Test + func `ChannelServiceError.sessionError passes through MeshCoreError description`() { + let meshError: MeshCoreError = .timeout + let serviceError: ChannelServiceError = .sessionError(meshError) + #expect(serviceError.localizedDescription == "The operation timed out. Please try again.") + } + + @Test + func `SettingsServiceError.sessionError passes through without prefix`() { + let meshError: MeshCoreError = .notConnected + let serviceError: SettingsServiceError = .sessionError(meshError) + #expect(!serviceError.localizedDescription.contains("Session error:")) + #expect(serviceError.localizedDescription == "Not connected to device.") + } + + @Test + func `SettingsServiceError.deviceGPSVerificationFailed is human-readable`() { + let serviceError: SettingsServiceError = .deviceGPSVerificationFailed( + expectedEnabled: false, + actualEnabled: true + ) + #expect( + serviceError.localizedDescription == + "Device GPS setting was not saved. Expected 'Off' but device reports 'On'." + ) + } + + @Test + func `RemoteNodeError.sessionError passes through without prefix`() { + let meshError: MeshCoreError = .bluetoothPoweredOff + let serviceError: RemoteNodeError = .sessionError(meshError) + #expect(!serviceError.localizedDescription.contains("Session error:")) + #expect(serviceError.localizedDescription == "Bluetooth is turned off. Please enable Bluetooth to connect.") + } + + // MARK: - Service Error Spot Checks + + @Test + func `AdvertisementError.notConnected produces readable description`() { + #expect(AdvertisementError.notConnected.localizedDescription == "Not connected to device.") + } + + @Test + func `RoomServerError.permissionDenied produces readable description`() { + #expect(RoomServerError.permissionDenied.localizedDescription == "Permission denied.") + } + + @Test + func `BinaryProtocolError.timeout produces readable description`() { + #expect(BinaryProtocolError.timeout.localizedDescription == "Request timed out.") + } + + @Test + func `SyncCoordinatorError.alreadySyncing produces readable description`() { + #expect(SyncCoordinatorError.alreadySyncing.localizedDescription == "A sync is already in progress.") + } + + @Test + func `PersistenceStoreError.contactNotFound produces readable description`() { + #expect(PersistenceStoreError.contactNotFound.localizedDescription == "Contact not found.") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift index fc07431e..8e80653d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift @@ -1,67 +1,66 @@ // MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift -import Testing import Foundation @testable import MC1Services +import Testing @Suite("HeardRepeatsService Tests") struct HeardRepeatsServiceTests { + // MARK: - ChannelMessageFormat.parse Tests - // MARK: - ChannelMessageFormat.parse Tests - - @Test("parse with valid format returns sender and message") - func parseValidFormatReturnsSenderAndMessage() { - let result = ChannelMessageFormat.parse("NodeName: Hello world") + @Test + func `parse with valid format returns sender and message`() { + let result = ChannelMessageFormat.parse("NodeName: Hello world") - #expect(result != nil) - #expect(result?.senderName == "NodeName") - #expect(result?.messageText == "Hello world") - } + #expect(result != nil) + #expect(result?.senderName == "NodeName") + #expect(result?.messageText == "Hello world") + } - @Test("parse with no colon returns nil") - func parseNoColonReturnsNil() { - let result = ChannelMessageFormat.parse("No colon here") + @Test + func `parse with no colon returns nil`() { + let result = ChannelMessageFormat.parse("No colon here") - #expect(result == nil) - } + #expect(result == nil) + } - @Test("parse with colon at start returns nil") - func parseColonAtStartReturnsNil() { - let result = ChannelMessageFormat.parse(": Message without sender") + @Test + func `parse with colon at start returns nil`() { + let result = ChannelMessageFormat.parse(": Message without sender") - #expect(result == nil) - } + #expect(result == nil) + } - @Test("parse with empty message returns empty text") - func parseEmptyMessageReturnsEmptyText() { - let result = ChannelMessageFormat.parse("Sender:") + @Test + func `parse with empty message returns empty text`() { + let result = ChannelMessageFormat.parse("Sender:") - #expect(result != nil) - #expect(result?.senderName == "Sender") - #expect(result?.messageText == "") - } + #expect(result != nil) + #expect(result?.senderName == "Sender") + #expect(result?.messageText == "") + } - @Test("parse with message containing colons only splits on first") - func parseMessageWithColonsOnlySplitsOnFirst() { - let result = ChannelMessageFormat.parse("Sender: Time is 10:30:00") + @Test + func `parse with message containing colons only splits on first`() { + let result = ChannelMessageFormat.parse("Sender: Time is 10:30:00") - #expect(result != nil) - #expect(result?.senderName == "Sender") - #expect(result?.messageText == "Time is 10:30:00") - } + #expect(result != nil) + #expect(result?.senderName == "Sender") + #expect(result?.messageText == "Time is 10:30:00") + } - @Test("parse trims whitespace from message") - func parseTrimsWhitespaceFromMessage() { - let result = ChannelMessageFormat.parse("Node: Padded message ") + @Test + func `parse trims whitespace from message`() { + let result = ChannelMessageFormat.parse("Node: Padded message ") - #expect(result != nil) - #expect(result?.messageText == "Padded message") - } + #expect(result != nil) + #expect(result?.messageText == "Padded message") + } - @Test("parse preserves spaces in sender name") - func parseSenderWithSpacesPreservesSpaces() { - let result = ChannelMessageFormat.parse("Node With Spaces: Message") + @Test + func `parse preserves spaces in sender name`() { + let result = ChannelMessageFormat.parse("Node With Spaces: Message") - #expect(result != nil) - #expect(result?.senderName == "Node With Spaces") - } + #expect(result != nil) + #expect(result?.senderName == "Node With Spaces") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/KeyGenerationServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/KeyGenerationServiceTests.swift index 82575dff..590bb282 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/KeyGenerationServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/KeyGenerationServiceTests.swift @@ -1,231 +1,230 @@ import CryptoKit import Foundation -import Testing @testable import MC1Services +import Testing @Suite("KeyGenerationService Tests") struct KeyGenerationServiceTests { - - @Test("Generated expanded key is 64 bytes and public key is 32 bytes") - func keySizes() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - - #expect(result.expandedPrivateKey.count == 64) - #expect(result.publicKey.count == 32) - } - - @Test("Public key never starts with 0x00 or 0xFF") - func reservedBytesRejected() async throws { - // Generate multiple keys and verify none start with reserved bytes - for _ in 0..<20 { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let firstByte = result.publicKey[result.publicKey.startIndex] - #expect(firstByte != 0x00, "Public key should never start with 0x00") - #expect(firstByte != 0xFF, "Public key should never start with 0xFF") - } - } - - @Test("2-char vanity prefix is respected", arguments: ["AA", "42", "7F"]) - func twoCharPrefix(prefix: String) async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") - } - - @Test("1-char vanity prefix is respected", arguments: ["A", "7", "3"]) - func oneCharPrefix(prefix: String) async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") - } - - @Test("3-char vanity prefix is respected") - func threeCharPrefix() async throws { - let prefix = "A7B" - let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") - } - - @Test("4-char vanity prefix is respected") - func fourCharPrefix() async throws { - let prefix = "A7B2" - let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") - } - - @Test("Nil prefix generates any valid key") - func nilPrefix() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - #expect(result.publicKey.count == 32) - } - - @Test("Reserved prefix '00' throws reservedPrefix error") - func reservedPrefixZero() async { - await #expect(throws: KeyGenerationError.reservedPrefix) { - _ = try await KeyGenerationService.generateIdentity(hexPrefix: "00") - } - } - - @Test("Reserved prefix 'FF' throws reservedPrefix error") - func reservedPrefixFF() async { - await #expect(throws: KeyGenerationError.reservedPrefix) { - _ = try await KeyGenerationService.generateIdentity(hexPrefix: "FF") - } - } - - @Test("Reserved multi-char prefix '00A1' throws reservedPrefix error") - func reservedPrefixMultiChar00() async { - await #expect(throws: KeyGenerationError.reservedPrefix) { - _ = try await KeyGenerationService.generateIdentity(hexPrefix: "00A1") - } - } - - @Test("Reserved multi-char prefix 'FFB2' throws reservedPrefix error") - func reservedPrefixMultiCharFF() async { - await #expect(throws: KeyGenerationError.reservedPrefix) { - _ = try await KeyGenerationService.generateIdentity(hexPrefix: "FFB2") - } - } - - @Test("Single-char '0' is not reserved and succeeds") - func singleCharZeroAllowed() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: "0") - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix("0"), "Expected public key hex to start with '0', got \(publicHex)") - } - - @Test("Single-char 'F' is not reserved and succeeds") - func singleCharFAllowed() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: "F") - let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() - #expect(publicHex.hasPrefix("F"), "Expected public key hex to start with 'F', got \(publicHex)") - } - - @Test("Cancellation is respected") - func cancellation() async { - let task = Task { - // Use a very unlikely 4-char prefix to make the loop run long - _ = try await KeyGenerationService.generateIdentity(hexPrefix: "0101") - } - - // Cancel immediately - task.cancel() - - let result = await task.result - switch result { - case .success: - // If the key was found before cancellation, that's fine - break - case .failure(let error): - #expect(error is CancellationError, "Expected CancellationError, got \(error)") - } - } - - @Test("Expanded key has correct SHA-512 clamping") - func expansionClamping() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let expanded = result.expandedPrivateKey - - // RFC 8032 bit clamping checks: - // expanded[0] lowest 3 bits must be clear - #expect(expanded[0] & 0x07 == 0, "Lowest 3 bits of first byte should be cleared") - - // expanded[31] highest bit must be clear, second-highest must be set - #expect(expanded[31] & 0x80 == 0, "Highest bit of byte 31 should be cleared") - #expect(expanded[31] & 0x40 == 0x40, "Second-highest bit of byte 31 should be set") - } - - @Test("Expansion matches manual SHA-512 + clamp") - func expansionMatchesManual() async throws { - // Generate a key, then verify the expansion independently - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let expandedKey = result.expandedPrivateKey - let publicKey = result.publicKey - - // Verify the expanded key has valid structure. - #expect(expandedKey.count == 64) - #expect(publicKey.count == 32) - - // The expanded key's clamping is already verified above. - // Verify the expanded key is different from the public key (they serve different purposes) - #expect(Data(expandedKey.prefix(32)) != publicKey) - } - - @Test("randomGenerationFailed error has a description") - func randomGenerationFailedError() { - let error = KeyGenerationError.randomGenerationFailed - #expect(error.errorDescription != nil) - #expect(error.errorDescription?.isEmpty == false) - } - - @Test("Multiple generations produce different keys") - func uniqueKeys() async throws { - let result1 = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let result2 = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - - #expect(result1.publicKey != result2.publicKey, "Two generated keys should be different") - #expect(result1.expandedPrivateKey != result2.expandedPrivateKey) - } - - // MARK: - validateExpandedKey Tests - - @Test("Valid expanded key passes validation") - func validateValidKey() async throws { - let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - try KeyGenerationService.validateExpandedKey(result.expandedPrivateKey) - } - - @Test("Wrong length throws invalidKey", arguments: [32, 63, 65, 0]) - func validateWrongLength(length: Int) { - let data = Data(repeating: 0x40, count: length) - #expect(throws: KeyGenerationError.invalidKey) { - try KeyGenerationService.validateExpandedKey(data) - } - } - - @Test("Bad clamping byte 0 (lowest 3 bits set) throws invalidKey") - func validateBadByte0() async throws { - var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey - key[0] |= 0x07 // set lowest 3 bits - #expect(throws: KeyGenerationError.invalidKey) { - try KeyGenerationService.validateExpandedKey(key) - } - } - - @Test("Bad clamping byte 31 (highest bit set) throws invalidKey") - func validateBadByte31HighBit() async throws { - var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey - key[31] |= 0x80 // set highest bit - #expect(throws: KeyGenerationError.invalidKey) { - try KeyGenerationService.validateExpandedKey(key) - } - } - - @Test("Bad clamping byte 31 (second-highest bit clear) throws invalidKey") - func validateBadByte31SecondHighBit() async throws { - var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey - key[31] &= ~0x40 // clear second-highest bit - #expect(throws: KeyGenerationError.invalidKey) { - try KeyGenerationService.validateExpandedKey(key) - } - } - - @Test("invalidKey error has a description") - func invalidKeyError() { - let error = KeyGenerationError.invalidKey - #expect(error.errorDescription != nil) - #expect(error.errorDescription?.isEmpty == false) - } - - @Test("Validation works on a non-zero-based Data slice") - func validateNonZeroBasedSlice() async throws { - let key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey - // A slice keeps its parent's indices, so startIndex is non-zero here. - let padded = Data([0xFF, 0xFF, 0xFF, 0xFF]) + key - let slice = padded.suffix(key.count) - #expect(slice.startIndex != 0) - try KeyGenerationService.validateExpandedKey(slice) - } + @Test + func `Generated expanded key is 64 bytes and public key is 32 bytes`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + + #expect(result.expandedPrivateKey.count == 64) + #expect(result.publicKey.count == 32) + } + + @Test + func `Public key never starts with 0x00 or 0xFF`() async throws { + // Generate multiple keys and verify none start with reserved bytes + for _ in 0..<20 { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let firstByte = result.publicKey[result.publicKey.startIndex] + #expect(firstByte != 0x00, "Public key should never start with 0x00") + #expect(firstByte != 0xFF, "Public key should never start with 0xFF") + } + } + + @Test(arguments: ["AA", "42", "7F"]) + func `2-char vanity prefix is respected`(prefix: String) async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") + } + + @Test(arguments: ["A", "7", "3"]) + func `1-char vanity prefix is respected`(prefix: String) async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") + } + + @Test + func `3-char vanity prefix is respected`() async throws { + let prefix = "A7B" + let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") + } + + @Test + func `4-char vanity prefix is respected`() async throws { + let prefix = "A7B2" + let result = try await KeyGenerationService.generateIdentity(hexPrefix: prefix) + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix(prefix), "Expected public key hex to start with \(prefix), got \(publicHex)") + } + + @Test + func `Nil prefix generates any valid key`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + #expect(result.publicKey.count == 32) + } + + @Test + func `Reserved prefix '00' throws reservedPrefix error`() async { + await #expect(throws: KeyGenerationError.reservedPrefix) { + _ = try await KeyGenerationService.generateIdentity(hexPrefix: "00") + } + } + + @Test + func `Reserved prefix 'FF' throws reservedPrefix error`() async { + await #expect(throws: KeyGenerationError.reservedPrefix) { + _ = try await KeyGenerationService.generateIdentity(hexPrefix: "FF") + } + } + + @Test + func `Reserved multi-char prefix '00A1' throws reservedPrefix error`() async { + await #expect(throws: KeyGenerationError.reservedPrefix) { + _ = try await KeyGenerationService.generateIdentity(hexPrefix: "00A1") + } + } + + @Test + func `Reserved multi-char prefix 'FFB2' throws reservedPrefix error`() async { + await #expect(throws: KeyGenerationError.reservedPrefix) { + _ = try await KeyGenerationService.generateIdentity(hexPrefix: "FFB2") + } + } + + @Test + func `Single-char '0' is not reserved and succeeds`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: "0") + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix("0"), "Expected public key hex to start with '0', got \(publicHex)") + } + + @Test + func `Single-char 'F' is not reserved and succeeds`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: "F") + let publicHex = result.publicKey.map { String(format: "%02X", $0) }.joined() + #expect(publicHex.hasPrefix("F"), "Expected public key hex to start with 'F', got \(publicHex)") + } + + @Test + func `Cancellation is respected`() async { + let task = Task { + // Use a very unlikely 4-char prefix to make the loop run long + _ = try await KeyGenerationService.generateIdentity(hexPrefix: "0101") + } + + // Cancel immediately + task.cancel() + + let result = await task.result + switch result { + case .success: + // If the key was found before cancellation, that's fine + break + case let .failure(error): + #expect(error is CancellationError, "Expected CancellationError, got \(error)") + } + } + + @Test + func `Expanded key has correct SHA-512 clamping`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let expanded = result.expandedPrivateKey + + // RFC 8032 bit clamping checks: + // expanded[0] lowest 3 bits must be clear + #expect(expanded[0] & 0x07 == 0, "Lowest 3 bits of first byte should be cleared") + + // expanded[31] highest bit must be clear, second-highest must be set + #expect(expanded[31] & 0x80 == 0, "Highest bit of byte 31 should be cleared") + #expect(expanded[31] & 0x40 == 0x40, "Second-highest bit of byte 31 should be set") + } + + @Test + func `Expansion matches manual SHA-512 + clamp`() async throws { + // Generate a key, then verify the expansion independently + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let expandedKey = result.expandedPrivateKey + let publicKey = result.publicKey + + // Verify the expanded key has valid structure. + #expect(expandedKey.count == 64) + #expect(publicKey.count == 32) + + // The expanded key's clamping is already verified above. + // Verify the expanded key is different from the public key (they serve different purposes) + #expect(Data(expandedKey.prefix(32)) != publicKey) + } + + @Test + func `randomGenerationFailed error has a description`() { + let error = KeyGenerationError.randomGenerationFailed + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.isEmpty == false) + } + + @Test + func `Multiple generations produce different keys`() async throws { + let result1 = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let result2 = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + + #expect(result1.publicKey != result2.publicKey, "Two generated keys should be different") + #expect(result1.expandedPrivateKey != result2.expandedPrivateKey) + } + + // MARK: - validateExpandedKey Tests + + @Test + func `Valid expanded key passes validation`() async throws { + let result = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + try KeyGenerationService.validateExpandedKey(result.expandedPrivateKey) + } + + @Test(arguments: [32, 63, 65, 0]) + func `Wrong length throws invalidKey`(length: Int) { + let data = Data(repeating: 0x40, count: length) + #expect(throws: KeyGenerationError.invalidKey) { + try KeyGenerationService.validateExpandedKey(data) + } + } + + @Test + func `Bad clamping byte 0 (lowest 3 bits set) throws invalidKey`() async throws { + var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey + key[0] |= 0x07 // set lowest 3 bits + #expect(throws: KeyGenerationError.invalidKey) { + try KeyGenerationService.validateExpandedKey(key) + } + } + + @Test + func `Bad clamping byte 31 (highest bit set) throws invalidKey`() async throws { + var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey + key[31] |= 0x80 // set highest bit + #expect(throws: KeyGenerationError.invalidKey) { + try KeyGenerationService.validateExpandedKey(key) + } + } + + @Test + func `Bad clamping byte 31 (second-highest bit clear) throws invalidKey`() async throws { + var key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey + key[31] &= ~0x40 // clear second-highest bit + #expect(throws: KeyGenerationError.invalidKey) { + try KeyGenerationService.validateExpandedKey(key) + } + } + + @Test + func `invalidKey error has a description`() { + let error = KeyGenerationError.invalidKey + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.isEmpty == false) + } + + @Test + func `Validation works on a non-zero-based Data slice`() async throws { + let key = try await KeyGenerationService.generateIdentity(hexPrefix: nil).expandedPrivateKey + // A slice keeps its parent's indices, so startIndex is non-zero here. + let padded = Data([0xFF, 0xFF, 0xFF, 0xFF]) + key + let slice = padded.suffix(key.count) + #expect(slice.startIndex != 0) + try KeyGenerationService.validateExpandedKey(slice) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/LoginTimeoutConfigTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/LoginTimeoutConfigTests.swift index bae5e34e..a5d3e259 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/LoginTimeoutConfigTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/LoginTimeoutConfigTests.swift @@ -1,96 +1,95 @@ import Foundation -import Testing @testable import MC1Services @testable import MeshCore +import Testing @Suite("LoginTimeoutConfig Tests") struct LoginTimeoutConfigTests { - - private func makeSentInfo(timeoutMs: UInt32) -> MessageSentInfo { - MessageSentInfo(route: 0, expectedAck: Data([0x00]), suggestedTimeoutMs: timeoutMs) - } - - @Test("Direct path (mode 0) uses base timeout only") - func directPathMode0() { - // Mode 0, 0 hops → encoded as 0x00 - let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x00) - #expect(timeout == .seconds(5)) - } - - @Test("Direct path (mode 1) uses base timeout only, not mode bits") - func directPathMode1() { - // Mode 1, 0 hops → encoded as 0x40 (64 decimal) - let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x40) - #expect(timeout == .seconds(5)) - } - - @Test("Direct path (mode 2) uses base timeout only, not mode bits") - func directPathMode2() { - // Mode 2, 0 hops → encoded as 0x80 (128 decimal) - let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x80) - #expect(timeout == .seconds(5)) - } - - @Test("Mode 1 with 3 hops computes timeout from hop count") - func mode1With3Hops() { - // Mode 1, 3 hops → encoded as 0x43 - let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x43) - #expect(timeout == .seconds(35)) // 5 + 3*10 - } - - @Test("Mode 0 with 5 hops computes correct timeout") - func mode0With5Hops() { - let timeout = LoginTimeoutConfig.timeout(forPathLength: 5) - #expect(timeout == .seconds(55)) // 5 + 5*10 - } - - @Test("Flood routing (0xFF) falls back to base timeout") - func floodRouting() { - // 0xFF: mode 3 (reserved) → decodePathLen returns nil → 0 hops - let timeout = LoginTimeoutConfig.timeout(forPathLength: 0xFF) - #expect(timeout == .seconds(5)) - } - - @Test("Timeout is capped at maximum") - func timeoutCapped() { - // Mode 0, 6 hops → 5 + 60 = 65, should cap at 60 - let timeout = LoginTimeoutConfig.timeout(forPathLength: 6) - #expect(timeout == .seconds(60)) - } - - @Test("Login timeout policy clamps long firmware suggestions") - func loginTimeoutPolicyClampsFirmwareSuggestion() { - let sentInfo = makeSentInfo(timeoutMs: 20_000) - - let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: 0) - - #expect(timeout == .seconds(20)) - } - - @Test("Login timeout policy respects path floor when firmware is shorter") - func loginTimeoutPolicyUsesPathFloor() { - let sentInfo = makeSentInfo(timeoutMs: 1_000) - - let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: 0x43) - - #expect(timeout == .seconds(20)) - } - - @Test("CLI timeout policy clamps long firmware suggestions") - func cliTimeoutPolicyClampsFirmwareSuggestion() { - let sentInfo = makeSentInfo(timeoutMs: 20_000) - - let timeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: .seconds(10)) - - #expect(timeout == .seconds(15)) - } - - @Test("CLI timeout policy keeps caller budget when firmware is shorter") - func cliTimeoutPolicyUsesCallerBudgetFloor() { - let sentInfo = makeSentInfo(timeoutMs: 1_000) - - let timeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: .seconds(10)) - - #expect(timeout == .seconds(10)) - } + private func makeSentInfo(timeoutMs: UInt32) -> MessageSentInfo { + MessageSentInfo(route: 0, expectedAck: Data([0x00]), suggestedTimeoutMs: timeoutMs) + } + + @Test + func `Direct path (mode 0) uses base timeout only`() { + // Mode 0, 0 hops → encoded as 0x00 + let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x00) + #expect(timeout == .seconds(5)) + } + + @Test + func `Direct path (mode 1) uses base timeout only, not mode bits`() { + // Mode 1, 0 hops → encoded as 0x40 (64 decimal) + let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x40) + #expect(timeout == .seconds(5)) + } + + @Test + func `Direct path (mode 2) uses base timeout only, not mode bits`() { + // Mode 2, 0 hops → encoded as 0x80 (128 decimal) + let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x80) + #expect(timeout == .seconds(5)) + } + + @Test + func `Mode 1 with 3 hops computes timeout from hop count`() { + // Mode 1, 3 hops → encoded as 0x43 + let timeout = LoginTimeoutConfig.timeout(forPathLength: 0x43) + #expect(timeout == .seconds(35)) // 5 + 3*10 + } + + @Test + func `Mode 0 with 5 hops computes correct timeout`() { + let timeout = LoginTimeoutConfig.timeout(forPathLength: 5) + #expect(timeout == .seconds(55)) // 5 + 5*10 + } + + @Test + func `Flood routing (0xFF) falls back to base timeout`() { + // 0xFF: mode 3 (reserved) → decodePathLen returns nil → 0 hops + let timeout = LoginTimeoutConfig.timeout(forPathLength: 0xFF) + #expect(timeout == .seconds(5)) + } + + @Test + func `Timeout is capped at maximum`() { + // Mode 0, 6 hops → 5 + 60 = 65, should cap at 60 + let timeout = LoginTimeoutConfig.timeout(forPathLength: 6) + #expect(timeout == .seconds(60)) + } + + @Test + func `Login timeout policy clamps long firmware suggestions`() { + let sentInfo = makeSentInfo(timeoutMs: 20000) + + let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: 0) + + #expect(timeout == .seconds(20)) + } + + @Test + func `Login timeout policy respects path floor when firmware is shorter`() { + let sentInfo = makeSentInfo(timeoutMs: 1000) + + let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: 0x43) + + #expect(timeout == .seconds(20)) + } + + @Test + func `CLI timeout policy clamps long firmware suggestions`() { + let sentInfo = makeSentInfo(timeoutMs: 20000) + + let timeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: .seconds(10)) + + #expect(timeout == .seconds(15)) + } + + @Test + func `CLI timeout policy keeps caller budget when firmware is shorter`() { + let sentInfo = makeSentInfo(timeoutMs: 1000) + + let timeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: .seconds(10)) + + #expect(timeout == .seconds(10)) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MeshCoreOpenReactionParserTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MeshCoreOpenReactionParserTests.swift index 46a07274..cdbdf304 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MeshCoreOpenReactionParserTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MeshCoreOpenReactionParserTests.swift @@ -1,494 +1,493 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("MeshCoreOpenReactionParser Tests") struct MeshCoreOpenReactionParserTests { - - // MARK: - Parse Valid Format Tests - - @Test("Parses valid reaction with thumbs up (index 00)") - func parsesThumbsUp() { - let result = MeshCoreOpenReactionParser.parse("r:a1b2:00") - - #expect(result != nil) - #expect(result?.emoji == "👍") - #expect(result?.dartHash == "a1b2") - } - - @Test("Parses valid reaction with fire (index 05)") - func parsesFire() { - let result = MeshCoreOpenReactionParser.parse("r:ff00:05") - - #expect(result != nil) - #expect(result?.emoji == "🔥") - #expect(result?.dartHash == "ff00") - } - - @Test("Parses valid reaction with heart (index 01)") - func parsesHeart() { - let result = MeshCoreOpenReactionParser.parse("r:1234:01") - - #expect(result != nil) - #expect(result?.emoji == "❤️") - #expect(result?.dartHash == "1234") - } - - @Test("Parses reaction at max valid emoji index (0xb7)") - func parsesMaxIndex() { - let result = MeshCoreOpenReactionParser.parse("r:abcd:b7") - - #expect(result != nil) - #expect(result?.emoji == "🚀") - #expect(result?.dartHash == "abcd") - } - - // MARK: - Parse Invalid Format Tests - - @Test("Rejects plain text") - func rejectsPlainText() { - #expect(MeshCoreOpenReactionParser.parse("hello world") == nil) - } - - @Test("Rejects wrong prefix") - func rejectsWrongPrefix() { - #expect(MeshCoreOpenReactionParser.parse("x:a1b2:00") == nil) - } - - @Test("Rejects uppercase hex in hash") - func rejectsUppercaseHash() { - #expect(MeshCoreOpenReactionParser.parse("r:A1B2:00") == nil) - } - - @Test("Rejects uppercase hex in index") - func rejectsUppercaseIndex() { - #expect(MeshCoreOpenReactionParser.parse("r:a1b2:0A") == nil) - } - - @Test("Rejects too short") - func rejectsTooShort() { - #expect(MeshCoreOpenReactionParser.parse("r:a1b:00") == nil) - } - - @Test("Rejects too long") - func rejectsTooLong() { - #expect(MeshCoreOpenReactionParser.parse("r:a1b2c:00") == nil) - } - - @Test("Rejects missing colons") - func rejectsMissingColons() { - #expect(MeshCoreOpenReactionParser.parse("r-a1b2-00") == nil) - } - - @Test("Rejects emoji index beyond table size") - func rejectsOutOfRangeIndex() { - // 0xb8 = 184, table has 184 entries (0x00–0xb7) - #expect(MeshCoreOpenReactionParser.parse("r:a1b2:b8") == nil) - } - - @Test("Rejects legacy channel reaction format") - func rejectsLegacyChannelFormat() { - #expect(MeshCoreOpenReactionParser.parse("👍@[AlphaNode]\n7f3a9c12") == nil) - } - - @Test("Rejects legacy DM reaction format") - func rejectsLegacyDMFormat() { - #expect(MeshCoreOpenReactionParser.parse("👍\n7f3a9c12") == nil) - } - - @Test("Rejects empty string") - func rejectsEmpty() { - #expect(MeshCoreOpenReactionParser.parse("") == nil) - } - - // MARK: - Emoji Index Mapping Tests - - @Test("Spot-check emoji indices across all categories") - func spotCheckEmojiIndices() { - // quickEmojis - #expect(MeshCoreOpenReactionParser.parse("r:0000:00")?.emoji == "👍") // 0x00 - #expect(MeshCoreOpenReactionParser.parse("r:0000:02")?.emoji == "😂") // 0x02 - #expect(MeshCoreOpenReactionParser.parse("r:0000:03")?.emoji == "🎉") // 0x03 - - // smileys start at 0x06 - #expect(MeshCoreOpenReactionParser.parse("r:0000:06")?.emoji == "😀") // first smiley - #expect(MeshCoreOpenReactionParser.parse("r:0000:45")?.emoji == "😶") // last smiley - - // gestures start at 0x46 - #expect(MeshCoreOpenReactionParser.parse("r:0000:46")?.emoji == "👍") // first gesture - #expect(MeshCoreOpenReactionParser.parse("r:0000:66")?.emoji == "💪") // last gesture - - // hearts start at 0x67 - #expect(MeshCoreOpenReactionParser.parse("r:0000:67")?.emoji == "❤️") // first heart - - // objects start at 0x87 - #expect(MeshCoreOpenReactionParser.parse("r:0000:87")?.emoji == "🎉") // first object - } - - // MARK: - Dart String Hash Tests - - @Test("Dart hash of empty input produces 1") - func dartHashEmpty() { - // Dart: "".hashCode should be 0, which becomes 1 (zero-guard) - let hash = MeshCoreOpenReactionParser.dartStringHash([]) - #expect(hash == 1) - } - - @Test("Dart hash is deterministic") - func dartHashDeterministic() { - let units: [UInt16] = Array("hello".utf16) - let hash1 = MeshCoreOpenReactionParser.dartStringHash(units) - let hash2 = MeshCoreOpenReactionParser.dartStringHash(units) - #expect(hash1 == hash2) - } - - @Test("Dart hash of single character 'a'") - func dartHashSingleChar() { - // Manually compute: code_unit = 97 (0x61) - // hash = 0 - // hash += 97 → 97 - // hash += 97 << 10 → 97 + 99328 = 99425 - // hash ^= 99425 >> 6 → 99425 ^ 1553 = 100464 - // finalize: - // hash += 100464 << 3 → 100464 + 803712 = 904176 - // hash ^= 904176 >> 11 → 904176 ^ 441 = 904617 - // hash += 904617 << 15 → 904617 + 29640630272 (wraps in UInt32) → need wrapping - // Let's just verify it's > 0 and within 30 bits - let hash = MeshCoreOpenReactionParser.dartStringHash([97]) - #expect(hash > 0) - #expect(hash < (1 << 30)) - } - - @Test("Dart hash result is within 30-bit range") - func dartHashRange() { - let units: [UInt16] = Array("test string with various chars 🎉".utf16) - let hash = MeshCoreOpenReactionParser.dartStringHash(units) - #expect(hash > 0) - #expect(hash <= (1 << 30) - 1) - } - - @Test("Different inputs produce different hashes") - func dartHashDifferentInputs() { - let hash1 = MeshCoreOpenReactionParser.dartStringHash(Array("hello".utf16)) - let hash2 = MeshCoreOpenReactionParser.dartStringHash(Array("world".utf16)) - #expect(hash1 != hash2) - } - - // MARK: - Hash Computation Tests - - @Test("computeReactionHash returns 4-char lowercase hex") - func hashFormat() { - let hash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "AlphaNode", - text: "Hello world" - ) - #expect(hash.count == 4) - #expect(hash.allSatisfy { $0.isHexDigit && !$0.isUppercase }) - } - - @Test("computeReactionHash is deterministic") - func hashDeterministic() { - let hash1 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "AlphaNode", - text: "Hello world" - ) - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "AlphaNode", - text: "Hello world" - ) - #expect(hash1 == hash2) - } - - @Test("computeReactionHash changes with different timestamp") - func hashChangesWithTimestamp() { - let hash1 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "Hello" - ) - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000001, - senderName: "Node", - text: "Hello" - ) - #expect(hash1 != hash2) - } - - @Test("computeReactionHash changes with different sender") - func hashChangesWithSender() { - let hash1 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "AlphaNode", - text: "Hello" - ) - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "BetaNode", - text: "Hello" - ) - #expect(hash1 != hash2) - } - - @Test("computeReactionHash changes with different text") - func hashChangesWithText() { - let hash1 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "Hello" - ) - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "World" - ) - #expect(hash1 != hash2) - } - - @Test("computeReactionHash with nil sender (DM mode)") - func hashDMMode() { - let hash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: nil, - text: "Hello world" - ) - #expect(hash.count == 4) - - // Should differ from channel mode with same params - let channelHash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "Hello world" - ) - #expect(hash != channelHash) - } - - @Test("computeReactionHash truncates text to 5 UTF-16 code units") - func hashTruncatesText() { - // "Hello" is 5 code units, "Hello world" has 11 - // Both should produce the same hash since only first 5 code units are used - let hash1 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "Hello" - ) - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "Hello world" - ) - #expect(hash1 == hash2) - } - - @Test("computeReactionHash handles short text (fewer than 5 code units)") - func hashShortText() { - let hash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: nil, - text: "Hi" - ) - #expect(hash.count == 4) - } - - // MARK: - UTF-16 Edge Cases - - @Test("computeReactionHash handles emoji in text (multi-code-unit)") - func hashEmojiText() { - // 🎉 is 2 UTF-16 code units (surrogate pair), so "🎉abc" = 5 code units - let hash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: nil, - text: "🎉abc" - ) - #expect(hash.count == 4) - - // "🎉abcdef" should hash the same since first 5 code units match - let hash2 = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: nil, - text: "🎉abcdef" - ) - #expect(hash == hash2) - } - - @Test("computeReactionHash handles empty text") - func hashEmptyText() { - let hash = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "Node", - text: "" - ) - #expect(hash.count == 4) - } - - // MARK: - Cross-App Test Vectors - - @Test("Dart hash matches known Dart VM output for 'hello'") - func dartHashKnownVector() { - // In Dart: "hello".hashCode == 150804507 - // This is the definitive cross-app test vector - let hash = MeshCoreOpenReactionParser.dartStringHash(Array("hello".utf16)) - #expect(hash == 150804507) - } - - @Test("computeReactionHash is internally consistent") - func hashInternalConsistency() { - // Verify computeReactionHash assembles code units correctly - // by comparing against manual dartStringHash call - let testUnits = Array("1700000000AHello".utf16) - let fullHash = MeshCoreOpenReactionParser.dartStringHash(testUnits) - let masked = fullHash & 0xFFFF - let expected = String(format: "%04x", masked) - - let computed = MeshCoreOpenReactionParser.computeReactionHash( - timestamp: 1700000000, - senderName: "A", - text: "Hello" - ) - #expect(computed == expected) - } - - @Test("Emoji table has exactly 184 entries") - func emojiTableSize() { - #expect(MeshCoreOpenReactionParser.emojiTable.count == 184) - } - - // MARK: - V1 Parse Tests - - @Test("Parses v1 reaction from real wire capture") - func parsesV1RealCapture() { - let result = MeshCoreOpenReactionParser.parseV1("r:1772600903000_951919033_868488711:👍") - - #expect(result != nil) - #expect(result?.emoji == "👍") - #expect(result?.timestampSeconds == 1_772_600_903) - #expect(result?.senderNameHash == 951_919_033) - #expect(result?.textHash == 868_488_711) - } - - @Test("Parses v1 reaction with heart emoji") - func parsesV1Heart() { - let result = MeshCoreOpenReactionParser.parseV1("r:1700000000000_12345_67890:❤️") - - #expect(result != nil) - #expect(result?.emoji == "❤️") - #expect(result?.timestampSeconds == 1_700_000_000) - #expect(result?.senderNameHash == 12345) - #expect(result?.textHash == 67890) - } - - @Test("Parses v1 reaction with fire emoji") - func parsesV1Fire() { - let result = MeshCoreOpenReactionParser.parseV1("r:1772600903000_100_200:🔥") - - #expect(result != nil) - #expect(result?.emoji == "🔥") - } - - @Test("V1 rejects v3 format") - func v1RejectsV3() { - #expect(MeshCoreOpenReactionParser.parseV1("r:a1b2:00") == nil) - } - - @Test("V1 rejects plain text") - func v1RejectsPlainText() { - #expect(MeshCoreOpenReactionParser.parseV1("hello world") == nil) - } - - @Test("V1 rejects wrong prefix") - func v1RejectsWrongPrefix() { - #expect(MeshCoreOpenReactionParser.parseV1("x:1700000000000_100_200:👍") == nil) - } - - @Test("V1 rejects too few underscore parts") - func v1RejectsTwoParts() { - #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100:👍") == nil) - } - - @Test("V1 rejects too many underscore parts") - func v1RejectsFourParts() { - #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_200_300:👍") == nil) - } - - @Test("V1 rejects non-numeric timestamp") - func v1RejectsNonNumericTimestamp() { - #expect(MeshCoreOpenReactionParser.parseV1("r:abc_100_200:👍") == nil) - } - - @Test("V1 rejects non-numeric hash values") - func v1RejectsNonNumericHash() { - #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_abc_200:👍") == nil) - #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_xyz:👍") == nil) - } - - @Test("V1 rejects empty emoji") - func v1RejectsEmptyEmoji() { - #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_200:") == nil) - } - - @Test("V1 rejects legacy channel format") - func v1RejectsLegacyChannel() { - #expect(MeshCoreOpenReactionParser.parseV1("👍@[AlphaNode]\n7f3a9c12") == nil) - } - - @Test("V1 rejects empty string") - func v1RejectsEmpty() { - #expect(MeshCoreOpenReactionParser.parseV1("") == nil) - } - - @Test("V1 timestamp converts millis to seconds correctly") - func v1TimestampConversion() { - // 1700000000500 ms → 1700000000 s (truncated, not rounded) - let result = MeshCoreOpenReactionParser.parseV1("r:1700000000500_100_200:👍") - #expect(result?.timestampSeconds == 1_700_000_000) - } - - // MARK: - V1 Hash Matching Tests - - @Test("dartStringHash can verify v1 sender name hash") - func v1SenderNameHashVerification() { - // Compute the Dart hash of a known sender name - let senderName = "TestNode" - let expectedHash = MeshCoreOpenReactionParser.dartStringHash(Array(senderName.utf16)) - - // Construct a v1 reaction with that hash - let reactionText = "r:1700000000000_\(expectedHash)_12345:👍" - let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) - - #expect(parsed != nil) - #expect(parsed?.senderNameHash == expectedHash) - } - - @Test("dartStringHash can verify v1 text hash") - func v1TextHashVerification() { - let messageText = "Hello from mesh" - let expectedHash = MeshCoreOpenReactionParser.dartStringHash(Array(messageText.utf16)) - - let reactionText = "r:1700000000000_12345_\(expectedHash):👍" - let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) - - #expect(parsed != nil) - #expect(parsed?.textHash == expectedHash) - } - - @Test("V1 round-trip: construct reaction and verify both hashes match") - func v1RoundTrip() { - let senderName = "AVN1" - let messageText = "Test message content" - let timestampMs: UInt64 = 1_772_600_903_000 - - let senderHash = MeshCoreOpenReactionParser.dartStringHash(Array(senderName.utf16)) - let textHash = MeshCoreOpenReactionParser.dartStringHash(Array(messageText.utf16)) - - let reactionText = "r:\(timestampMs)_\(senderHash)_\(textHash):👍" - let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) - - #expect(parsed != nil) - #expect(parsed?.timestampSeconds == UInt32(timestampMs / 1000)) - #expect(parsed?.senderNameHash == senderHash) - #expect(parsed?.textHash == textHash) - #expect(parsed?.emoji == "👍") - } + // MARK: - Parse Valid Format Tests + + @Test + func `Parses valid reaction with thumbs up (index 00)`() { + let result = MeshCoreOpenReactionParser.parse("r:a1b2:00") + + #expect(result != nil) + #expect(result?.emoji == "👍") + #expect(result?.dartHash == "a1b2") + } + + @Test + func `Parses valid reaction with fire (index 05)`() { + let result = MeshCoreOpenReactionParser.parse("r:ff00:05") + + #expect(result != nil) + #expect(result?.emoji == "🔥") + #expect(result?.dartHash == "ff00") + } + + @Test + func `Parses valid reaction with heart (index 01)`() { + let result = MeshCoreOpenReactionParser.parse("r:1234:01") + + #expect(result != nil) + #expect(result?.emoji == "❤️") + #expect(result?.dartHash == "1234") + } + + @Test + func `Parses reaction at max valid emoji index (0xb7)`() { + let result = MeshCoreOpenReactionParser.parse("r:abcd:b7") + + #expect(result != nil) + #expect(result?.emoji == "🚀") + #expect(result?.dartHash == "abcd") + } + + // MARK: - Parse Invalid Format Tests + + @Test + func `Rejects plain text`() { + #expect(MeshCoreOpenReactionParser.parse("hello world") == nil) + } + + @Test + func `Rejects wrong prefix`() { + #expect(MeshCoreOpenReactionParser.parse("x:a1b2:00") == nil) + } + + @Test + func `Rejects uppercase hex in hash`() { + #expect(MeshCoreOpenReactionParser.parse("r:A1B2:00") == nil) + } + + @Test + func `Rejects uppercase hex in index`() { + #expect(MeshCoreOpenReactionParser.parse("r:a1b2:0A") == nil) + } + + @Test + func `Rejects too short`() { + #expect(MeshCoreOpenReactionParser.parse("r:a1b:00") == nil) + } + + @Test + func `Rejects too long`() { + #expect(MeshCoreOpenReactionParser.parse("r:a1b2c:00") == nil) + } + + @Test + func `Rejects missing colons`() { + #expect(MeshCoreOpenReactionParser.parse("r-a1b2-00") == nil) + } + + @Test + func `Rejects emoji index beyond table size`() { + // 0xb8 = 184, table has 184 entries (0x00–0xb7) + #expect(MeshCoreOpenReactionParser.parse("r:a1b2:b8") == nil) + } + + @Test + func `Rejects legacy channel reaction format`() { + #expect(MeshCoreOpenReactionParser.parse("👍@[AlphaNode]\n7f3a9c12") == nil) + } + + @Test + func `Rejects legacy DM reaction format`() { + #expect(MeshCoreOpenReactionParser.parse("👍\n7f3a9c12") == nil) + } + + @Test + func `Rejects empty string`() { + #expect(MeshCoreOpenReactionParser.parse("") == nil) + } + + // MARK: - Emoji Index Mapping Tests + + @Test + func `Spot-check emoji indices across all categories`() { + // quickEmojis + #expect(MeshCoreOpenReactionParser.parse("r:0000:00")?.emoji == "👍") // 0x00 + #expect(MeshCoreOpenReactionParser.parse("r:0000:02")?.emoji == "😂") // 0x02 + #expect(MeshCoreOpenReactionParser.parse("r:0000:03")?.emoji == "🎉") // 0x03 + + // smileys start at 0x06 + #expect(MeshCoreOpenReactionParser.parse("r:0000:06")?.emoji == "😀") // first smiley + #expect(MeshCoreOpenReactionParser.parse("r:0000:45")?.emoji == "😶") // last smiley + + // gestures start at 0x46 + #expect(MeshCoreOpenReactionParser.parse("r:0000:46")?.emoji == "👍") // first gesture + #expect(MeshCoreOpenReactionParser.parse("r:0000:66")?.emoji == "💪") // last gesture + + // hearts start at 0x67 + #expect(MeshCoreOpenReactionParser.parse("r:0000:67")?.emoji == "❤️") // first heart + + // objects start at 0x87 + #expect(MeshCoreOpenReactionParser.parse("r:0000:87")?.emoji == "🎉") // first object + } + + // MARK: - Dart String Hash Tests + + @Test + func `Dart hash of empty input produces 1`() { + // Dart: "".hashCode should be 0, which becomes 1 (zero-guard) + let hash = MeshCoreOpenReactionParser.dartStringHash([]) + #expect(hash == 1) + } + + @Test + func `Dart hash is deterministic`() { + let units: [UInt16] = Array("hello".utf16) + let hash1 = MeshCoreOpenReactionParser.dartStringHash(units) + let hash2 = MeshCoreOpenReactionParser.dartStringHash(units) + #expect(hash1 == hash2) + } + + @Test + func `Dart hash of single character 'a'`() { + // Manually compute: code_unit = 97 (0x61) + // hash = 0 + // hash += 97 → 97 + // hash += 97 << 10 → 97 + 99328 = 99425 + // hash ^= 99425 >> 6 → 99425 ^ 1553 = 100464 + // finalize: + // hash += 100464 << 3 → 100464 + 803712 = 904176 + // hash ^= 904176 >> 11 → 904176 ^ 441 = 904617 + // hash += 904617 << 15 → 904617 + 29640630272 (wraps in UInt32) → need wrapping + // Let's just verify it's > 0 and within 30 bits + let hash = MeshCoreOpenReactionParser.dartStringHash([97]) + #expect(hash > 0) + #expect(hash < (1 << 30)) + } + + @Test + func `Dart hash result is within 30-bit range`() { + let units: [UInt16] = Array("test string with various chars 🎉".utf16) + let hash = MeshCoreOpenReactionParser.dartStringHash(units) + #expect(hash > 0) + #expect(hash <= (1 << 30) - 1) + } + + @Test + func `Different inputs produce different hashes`() { + let hash1 = MeshCoreOpenReactionParser.dartStringHash(Array("hello".utf16)) + let hash2 = MeshCoreOpenReactionParser.dartStringHash(Array("world".utf16)) + #expect(hash1 != hash2) + } + + // MARK: - Hash Computation Tests + + @Test + func `computeReactionHash returns 4-char lowercase hex`() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "AlphaNode", + text: "Hello world" + ) + #expect(hash.count == 4) + #expect(hash.allSatisfy { $0.isHexDigit && !$0.isUppercase }) + } + + @Test + func `computeReactionHash is deterministic`() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "AlphaNode", + text: "Hello world" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "AlphaNode", + text: "Hello world" + ) + #expect(hash1 == hash2) + } + + @Test + func `computeReactionHash changes with different timestamp`() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_001, + senderName: "Node", + text: "Hello" + ) + #expect(hash1 != hash2) + } + + @Test + func `computeReactionHash changes with different sender`() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "AlphaNode", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "BetaNode", + text: "Hello" + ) + #expect(hash1 != hash2) + } + + @Test + func `computeReactionHash changes with different text`() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "World" + ) + #expect(hash1 != hash2) + } + + @Test + func `computeReactionHash with nil sender (DM mode)`() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: nil, + text: "Hello world" + ) + #expect(hash.count == 4) + + // Should differ from channel mode with same params + let channelHash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "Hello world" + ) + #expect(hash != channelHash) + } + + @Test + func `computeReactionHash truncates text to 5 UTF-16 code units`() { + // "Hello" is 5 code units, "Hello world" has 11 + // Both should produce the same hash since only first 5 code units are used + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "Hello world" + ) + #expect(hash1 == hash2) + } + + @Test + func `computeReactionHash handles short text (fewer than 5 code units)`() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: nil, + text: "Hi" + ) + #expect(hash.count == 4) + } + + // MARK: - UTF-16 Edge Cases + + @Test + func `computeReactionHash handles emoji in text (multi-code-unit)`() { + // 🎉 is 2 UTF-16 code units (surrogate pair), so "🎉abc" = 5 code units + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: nil, + text: "🎉abc" + ) + #expect(hash.count == 4) + + // "🎉abcdef" should hash the same since first 5 code units match + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: nil, + text: "🎉abcdef" + ) + #expect(hash == hash2) + } + + @Test + func `computeReactionHash handles empty text`() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "Node", + text: "" + ) + #expect(hash.count == 4) + } + + // MARK: - Cross-App Test Vectors + + @Test + func `Dart hash matches known Dart VM output for 'hello'`() { + // In Dart: "hello".hashCode == 150804507 + // This is the definitive cross-app test vector + let hash = MeshCoreOpenReactionParser.dartStringHash(Array("hello".utf16)) + #expect(hash == 150_804_507) + } + + @Test + func `computeReactionHash is internally consistent`() { + // Verify computeReactionHash assembles code units correctly + // by comparing against manual dartStringHash call + let testUnits = Array("1700000000AHello".utf16) + let fullHash = MeshCoreOpenReactionParser.dartStringHash(testUnits) + let masked = fullHash & 0xFFFF + let expected = String(format: "%04x", masked) + + let computed = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1_700_000_000, + senderName: "A", + text: "Hello" + ) + #expect(computed == expected) + } + + @Test + func `Emoji table has exactly 184 entries`() { + #expect(MeshCoreOpenReactionParser.emojiTable.count == 184) + } + + // MARK: - V1 Parse Tests + + @Test + func `Parses v1 reaction from real wire capture`() { + let result = MeshCoreOpenReactionParser.parseV1("r:1772600903000_951919033_868488711:👍") + + #expect(result != nil) + #expect(result?.emoji == "👍") + #expect(result?.timestampSeconds == 1_772_600_903) + #expect(result?.senderNameHash == 951_919_033) + #expect(result?.textHash == 868_488_711) + } + + @Test + func `Parses v1 reaction with heart emoji`() { + let result = MeshCoreOpenReactionParser.parseV1("r:1700000000000_12345_67890:❤️") + + #expect(result != nil) + #expect(result?.emoji == "❤️") + #expect(result?.timestampSeconds == 1_700_000_000) + #expect(result?.senderNameHash == 12345) + #expect(result?.textHash == 67890) + } + + @Test + func `Parses v1 reaction with fire emoji`() { + let result = MeshCoreOpenReactionParser.parseV1("r:1772600903000_100_200:🔥") + + #expect(result != nil) + #expect(result?.emoji == "🔥") + } + + @Test + func `V1 rejects v3 format`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:a1b2:00") == nil) + } + + @Test + func `V1 rejects plain text`() { + #expect(MeshCoreOpenReactionParser.parseV1("hello world") == nil) + } + + @Test + func `V1 rejects wrong prefix`() { + #expect(MeshCoreOpenReactionParser.parseV1("x:1700000000000_100_200:👍") == nil) + } + + @Test + func `V1 rejects too few underscore parts`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100:👍") == nil) + } + + @Test + func `V1 rejects too many underscore parts`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_200_300:👍") == nil) + } + + @Test + func `V1 rejects non-numeric timestamp`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:abc_100_200:👍") == nil) + } + + @Test + func `V1 rejects non-numeric hash values`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_abc_200:👍") == nil) + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_xyz:👍") == nil) + } + + @Test + func `V1 rejects empty emoji`() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_200:") == nil) + } + + @Test + func `V1 rejects legacy channel format`() { + #expect(MeshCoreOpenReactionParser.parseV1("👍@[AlphaNode]\n7f3a9c12") == nil) + } + + @Test + func `V1 rejects empty string`() { + #expect(MeshCoreOpenReactionParser.parseV1("") == nil) + } + + @Test + func `V1 timestamp converts millis to seconds correctly`() { + // 1700000000500 ms → 1700000000 s (truncated, not rounded) + let result = MeshCoreOpenReactionParser.parseV1("r:1700000000500_100_200:👍") + #expect(result?.timestampSeconds == 1_700_000_000) + } + + // MARK: - V1 Hash Matching Tests + + @Test + func `dartStringHash can verify v1 sender name hash`() { + // Compute the Dart hash of a known sender name + let senderName = "TestNode" + let expectedHash = MeshCoreOpenReactionParser.dartStringHash(Array(senderName.utf16)) + + // Construct a v1 reaction with that hash + let reactionText = "r:1700000000000_\(expectedHash)_12345:👍" + let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) + + #expect(parsed != nil) + #expect(parsed?.senderNameHash == expectedHash) + } + + @Test + func `dartStringHash can verify v1 text hash`() { + let messageText = "Hello from mesh" + let expectedHash = MeshCoreOpenReactionParser.dartStringHash(Array(messageText.utf16)) + + let reactionText = "r:1700000000000_12345_\(expectedHash):👍" + let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) + + #expect(parsed != nil) + #expect(parsed?.textHash == expectedHash) + } + + @Test + func `V1 round-trip: construct reaction and verify both hashes match`() { + let senderName = "AVN1" + let messageText = "Test message content" + let timestampMs: UInt64 = 1_772_600_903_000 + + let senderHash = MeshCoreOpenReactionParser.dartStringHash(Array(senderName.utf16)) + let textHash = MeshCoreOpenReactionParser.dartStringHash(Array(messageText.utf16)) + + let reactionText = "r:\(timestampMs)_\(senderHash)_\(textHash):👍" + let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) + + #expect(parsed != nil) + #expect(parsed?.timestampSeconds == UInt32(timestampMs / 1000)) + #expect(parsed?.senderNameHash == senderHash) + #expect(parsed?.textHash == textHash) + #expect(parsed?.emoji == "👍") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageDeduplicationTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageDeduplicationTests.swift index a3286ec5..9866f191 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageDeduplicationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageDeduplicationTests.swift @@ -1,186 +1,185 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("Message Deduplication Tests") struct MessageDeduplicationTests { - - // MARK: - Fallback Key Format Tests - - @Test("DM fallback key is deterministic") - func dmFallbackKeyDeterministic() { - let contactID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let key1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1704067200, content: "Hello world" - ) - let key2 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1704067200, content: "Hello world" - ) - #expect(key1 == key2) - #expect(key1.hasPrefix("dm-")) - } - - @Test("Channel fallback key is deterministic") - func channelFallbackKeyDeterministic() { - let key1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 0, - senderNodeName: "Alice", timestamp: 1704067200, content: "Hello channel" - ) - let key2 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 0, - senderNodeName: "Alice", timestamp: 1704067200, content: "Hello channel" - ) - #expect(key1 == key2) - #expect(key1.hasPrefix("ch-")) - } - - @Test("DM and channel fallback keys never collide for same content") - func dmAndChannelKeysNeverCollide() { - let contactID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let dmKey = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: 1704067200, content: "Hello" - ) - let channelKey = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 0, - senderNodeName: "Alice", timestamp: 1704067200, content: "Hello" - ) - #expect(dmKey != channelKey) - } - - @Test("Different contacts produce different DM fallback keys") - func differentContactsDifferentKeys() { - let contact1 = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let contact2 = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! - let key1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contact1, channelIndex: nil, - senderNodeName: nil, timestamp: 1704067200, content: "Hello" - ) - let key2 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contact2, channelIndex: nil, - senderNodeName: nil, timestamp: 1704067200, content: "Hello" - ) - #expect(key1 != key2) - } - - @Test("Different channel indices produce different channel fallback keys") - func differentChannelsDifferentKeys() { - let key1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 0, - senderNodeName: "Alice", timestamp: 1704067200, content: "Hello" - ) - let key2 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 1, - senderNodeName: "Alice", timestamp: 1704067200, content: "Hello" - ) - #expect(key1 != key2) - } - - // MARK: - Retry Dedup Stability - - @Test("DM retry attempts with same content produce identical dedup keys") - func dmRetryAttemptsProduceSameKey() { - let contactID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - let timestamp: UInt32 = 1704067200 - let text = "Hello mesh" - - // Simulate two retry attempts: same contact, timestamp, and text - let keyAttempt0 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: timestamp, content: text - ) - let keyAttempt1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: contactID, channelIndex: nil, - senderNodeName: nil, timestamp: timestamp, content: text - ) - #expect(keyAttempt0 == keyAttempt1, - "Retry attempts with the same content must produce identical dedup keys") - } - - @Test("Channel retry attempts with same content produce identical dedup keys") - func channelRetryAttemptsProduceSameKey() { - let timestamp: UInt32 = 1704067200 - let text = "Hello channel" - - let keyAttempt0 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 2, - senderNodeName: "Bob", timestamp: timestamp, content: text - ) - let keyAttempt1 = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 2, - senderNodeName: "Bob", timestamp: timestamp, content: text - ) - #expect(keyAttempt0 == keyAttempt1, - "Channel retry attempts with the same content must produce identical dedup keys") - } - - // MARK: - isDuplicateMessage via MockPersistenceStore - - @Test("isDuplicateMessage returns false when no matching key exists") - func noDuplicateWhenEmpty() async throws { - let store = MockPersistenceStore() - let result = try await store.isDuplicateMessage(deduplicationKey: "test-key", radioID: UUID()) - #expect(result == false) - } - - @Test("isDuplicateMessage returns true when matching key exists for the same radio") - func duplicateWhenKeyExists() async throws { - let store = MockPersistenceStore() - let radioID = UUID() - let dto = makeChannelMessageDTO(radioID: radioID, deduplicationKey: "test-key") - try await store.saveMessage(dto) - - let result = try await store.isDuplicateMessage(deduplicationKey: "test-key", radioID: radioID) - #expect(result) - } - - @Test("isDuplicateMessage is scoped per-radio so two companions storing the same channel packet both succeed") - func duplicateIsScopedPerRadio() async throws { - let store = MockPersistenceStore() - let radioA = UUID() - let radioB = UUID() - let sharedKey = SyncCoordinator.fallbackDeduplicationKey( - contactID: nil, channelIndex: 0, - senderNodeName: "Alice", timestamp: 1704067200, content: "aaaaa" - ) - try await store.saveMessage(makeChannelMessageDTO(radioID: radioA, deduplicationKey: sharedKey)) - - let duplicateOnA = try await store.isDuplicateMessage(deduplicationKey: sharedKey, radioID: radioA) - let duplicateOnB = try await store.isDuplicateMessage(deduplicationKey: sharedKey, radioID: radioB) - - #expect(duplicateOnA, "Same radio sees the prior save as a duplicate") - #expect(duplicateOnB == false, - "A different companion radio must not inherit radioA's dedup entry — otherwise the second radio's channel view goes blank after 'change device'") - } - - // MARK: - Helpers - - private func makeChannelMessageDTO(radioID: UUID, deduplicationKey: String) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: radioID, - contactID: nil, - channelIndex: 0, - text: "Hello", - timestamp: 1704067200, - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 1, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: "Alice", - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0, - deduplicationKey: deduplicationKey - ) - } + // MARK: - Fallback Key Format Tests + + @Test + func `DM fallback key is deterministic`() throws { + let contactID = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let key1 = SyncCoordinator.fallbackDeduplicationKey( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1_704_067_200, content: "Hello world" + ) + let key2 = SyncCoordinator.fallbackDeduplicationKey( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1_704_067_200, content: "Hello world" + ) + #expect(key1 == key2) + #expect(key1.hasPrefix("dm-")) + } + + @Test + func `Channel fallback key is deterministic`() { + let key1 = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 0, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "Hello channel" + ) + let key2 = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 0, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "Hello channel" + ) + #expect(key1 == key2) + #expect(key1.hasPrefix("ch-")) + } + + @Test + func `DM and channel fallback keys never collide for same content`() throws { + let contactID = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let dmKey = SyncCoordinator.fallbackDeduplicationKey( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: 1_704_067_200, content: "Hello" + ) + let channelKey = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 0, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "Hello" + ) + #expect(dmKey != channelKey) + } + + @Test + func `Different contacts produce different DM fallback keys`() throws { + let contact1 = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let contact2 = try #require(UUID(uuidString: "22222222-2222-2222-2222-222222222222")) + let key1 = SyncCoordinator.fallbackDeduplicationKey( + contactID: contact1, channelIndex: nil, + senderNodeName: nil, timestamp: 1_704_067_200, content: "Hello" + ) + let key2 = SyncCoordinator.fallbackDeduplicationKey( + contactID: contact2, channelIndex: nil, + senderNodeName: nil, timestamp: 1_704_067_200, content: "Hello" + ) + #expect(key1 != key2) + } + + @Test + func `Different channel indices produce different channel fallback keys`() { + let key1 = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 0, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "Hello" + ) + let key2 = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 1, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "Hello" + ) + #expect(key1 != key2) + } + + // MARK: - Retry Dedup Stability + + @Test + func `DM retry attempts with same content produce identical dedup keys`() throws { + let contactID = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let timestamp: UInt32 = 1_704_067_200 + let text = "Hello mesh" + + // Simulate two retry attempts: same contact, timestamp, and text + let keyAttempt0 = SyncCoordinator.fallbackDeduplicationKey( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: timestamp, content: text + ) + let keyAttempt1 = SyncCoordinator.fallbackDeduplicationKey( + contactID: contactID, channelIndex: nil, + senderNodeName: nil, timestamp: timestamp, content: text + ) + #expect(keyAttempt0 == keyAttempt1, + "Retry attempts with the same content must produce identical dedup keys") + } + + @Test + func `Channel retry attempts with same content produce identical dedup keys`() { + let timestamp: UInt32 = 1_704_067_200 + let text = "Hello channel" + + let keyAttempt0 = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 2, + senderNodeName: "Bob", timestamp: timestamp, content: text + ) + let keyAttempt1 = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 2, + senderNodeName: "Bob", timestamp: timestamp, content: text + ) + #expect(keyAttempt0 == keyAttempt1, + "Channel retry attempts with the same content must produce identical dedup keys") + } + + // MARK: - isDuplicateMessage via MockPersistenceStore + + @Test + func `isDuplicateMessage returns false when no matching key exists`() async throws { + let store = MockPersistenceStore() + let result = try await store.isDuplicateMessage(deduplicationKey: "test-key", radioID: UUID()) + #expect(result == false) + } + + @Test + func `isDuplicateMessage returns true when matching key exists for the same radio`() async throws { + let store = MockPersistenceStore() + let radioID = UUID() + let dto = makeChannelMessageDTO(radioID: radioID, deduplicationKey: "test-key") + try await store.saveMessage(dto) + + let result = try await store.isDuplicateMessage(deduplicationKey: "test-key", radioID: radioID) + #expect(result) + } + + @Test + func `isDuplicateMessage is scoped per-radio so two companions storing the same channel packet both succeed`() async throws { + let store = MockPersistenceStore() + let radioA = UUID() + let radioB = UUID() + let sharedKey = SyncCoordinator.fallbackDeduplicationKey( + contactID: nil, channelIndex: 0, + senderNodeName: "Alice", timestamp: 1_704_067_200, content: "aaaaa" + ) + try await store.saveMessage(makeChannelMessageDTO(radioID: radioA, deduplicationKey: sharedKey)) + + let duplicateOnA = try await store.isDuplicateMessage(deduplicationKey: sharedKey, radioID: radioA) + let duplicateOnB = try await store.isDuplicateMessage(deduplicationKey: sharedKey, radioID: radioB) + + #expect(duplicateOnA, "Same radio sees the prior save as a duplicate") + #expect(duplicateOnB == false, + "A different companion radio must not inherit radioA's dedup entry — otherwise the second radio's channel view goes blank after 'change device'") + } + + // MARK: - Helpers + + private func makeChannelMessageDTO(radioID: UUID, deduplicationKey: String) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: 0, + text: "Hello", + timestamp: 1_704_067_200, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 1, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: "Alice", + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0, + deduplicationKey: deduplicationKey + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageLRUCacheTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageLRUCacheTests.swift index 3c3db945..4607286c 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageLRUCacheTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageLRUCacheTests.swift @@ -1,189 +1,188 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("MessageLRUCache Tests") struct MessageLRUCacheTests { - - @Test("Indexes and retrieves message") - func indexesAndRetrievesMessage() async { - let cache = MessageLRUCache() - let messageID = UUID() - - await cache.index( - messageID: messageID, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) - - #expect(candidates.count == 1) - #expect(candidates.first?.messageID == messageID) - #expect(candidates.first?.text == "Hello") - #expect(candidates.first?.timestamp == 1704067200) - } - - @Test("Returns empty array for non-existent message") - func returnsEmptyForNonExistent() async { - let cache = MessageLRUCache() - - let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: "abcd1234") - - #expect(candidates.isEmpty) - } - - @Test("Evicts oldest key at capacity") - func evictsOldestAtCapacity() async { - let cache = MessageLRUCache(capacity: 3) - - // Add 3 messages with different keys - for i in 0..<3 { - await cache.index( - messageID: UUID(), - channelIndex: 0, - senderName: "Node\(i)", - text: "Message \(i)", - timestamp: UInt32(i) - ) - } - - let hash0 = ReactionParser.generateMessageHash(text: "Message 0", timestamp: 0) - let before = await cache.lookup(channelIndex: 0, senderName: "Node0", messageHash: hash0) - #expect(!before.isEmpty) - - // Add 4th message with different key (should evict first key) - await cache.index( - messageID: UUID(), - channelIndex: 0, - senderName: "Node3", - text: "Message 3", - timestamp: 3 - ) - - let after = await cache.lookup(channelIndex: 0, senderName: "Node0", messageHash: hash0) - #expect(after.isEmpty) + @Test + func `Indexes and retrieves message`() async { + let cache = MessageLRUCache() + let messageID = UUID() + + await cache.index( + messageID: messageID, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) + + #expect(candidates.count == 1) + #expect(candidates.first?.messageID == messageID) + #expect(candidates.first?.text == "Hello") + #expect(candidates.first?.timestamp == 1_704_067_200) + } + + @Test + func `Returns empty array for non-existent message`() async { + let cache = MessageLRUCache() + + let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: "abcd1234") + + #expect(candidates.isEmpty) + } + + @Test + func `Evicts oldest key at capacity`() async { + let cache = MessageLRUCache(capacity: 3) + + // Add 3 messages with different keys + for i in 0..<3 { + await cache.index( + messageID: UUID(), + channelIndex: 0, + senderName: "Node\(i)", + text: "Message \(i)", + timestamp: UInt32(i) + ) } - @Test("Different channels are separate") - func differentChannelsAreSeparate() async { - let cache = MessageLRUCache() - let messageID = UUID() - - await cache.index( - messageID: messageID, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - - // Should find on channel 0 - let result0 = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) - #expect(result0.first?.messageID == messageID) - - // Should not find on channel 1 - let result1 = await cache.lookup(channelIndex: 1, senderName: "Node", messageHash: hash) - #expect(result1.isEmpty) + let hash0 = ReactionParser.generateMessageHash(text: "Message 0", timestamp: 0) + let before = await cache.lookup(channelIndex: 0, senderName: "Node0", messageHash: hash0) + #expect(!before.isEmpty) + + // Add 4th message with different key (should evict first key) + await cache.index( + messageID: UUID(), + channelIndex: 0, + senderName: "Node3", + text: "Message 3", + timestamp: 3 + ) + + let after = await cache.lookup(channelIndex: 0, senderName: "Node0", messageHash: hash0) + #expect(after.isEmpty) + } + + @Test + func `Different channels are separate`() async { + let cache = MessageLRUCache() + let messageID = UUID() + + await cache.index( + messageID: messageID, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + + // Should find on channel 0 + let result0 = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) + #expect(result0.first?.messageID == messageID) + + // Should not find on channel 1 + let result1 = await cache.lookup(channelIndex: 1, senderName: "Node", messageHash: hash) + #expect(result1.isEmpty) + } + + @Test + func `Stores multiple candidates per key`() async { + let cache = MessageLRUCache() + let id1 = UUID() + let id2 = UUID() + + // Two messages with same sender but different text that hash to different values + // won't test collision - instead test re-indexing same message updates it + await cache.index( + messageID: id1, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + await cache.index( + messageID: id2, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) + + // Both messages have same hash, so both should be candidates + #expect(candidates.count == 2) + #expect(candidates.contains { $0.messageID == id1 }) + #expect(candidates.contains { $0.messageID == id2 }) + } + + @Test + func `Caps candidates per key`() async { + let cache = MessageLRUCache(maxCandidatesPerKey: 3) + + // Add 5 messages with same hash (same text/timestamp, different IDs) + var ids: [UUID] = [] + for _ in 0..<5 { + let id = UUID() + ids.append(id) + await cache.index( + messageID: id, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) } - @Test("Stores multiple candidates per key") - func storesMultipleCandidatesPerKey() async { - let cache = MessageLRUCache() - let id1 = UUID() - let id2 = UUID() - - // Two messages with same sender but different text that hash to different values - // won't test collision - instead test re-indexing same message updates it - await cache.index( - messageID: id1, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - await cache.index( - messageID: id2, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) - - // Both messages have same hash, so both should be candidates - #expect(candidates.count == 2) - #expect(candidates.contains { $0.messageID == id1 }) - #expect(candidates.contains { $0.messageID == id2 }) - } - - @Test("Caps candidates per key") - func capsCandidatesPerKey() async { - let cache = MessageLRUCache(maxCandidatesPerKey: 3) - - // Add 5 messages with same hash (same text/timestamp, different IDs) - var ids: [UUID] = [] - for _ in 0..<5 { - let id = UUID() - ids.append(id) - await cache.index( - messageID: id, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - } - - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) - - // Should only keep most recent 3 - #expect(candidates.count == 3) - // First two should have been evicted - #expect(!candidates.contains { $0.messageID == ids[0] }) - #expect(!candidates.contains { $0.messageID == ids[1] }) - // Last three should be present - #expect(candidates.contains { $0.messageID == ids[2] }) - #expect(candidates.contains { $0.messageID == ids[3] }) - #expect(candidates.contains { $0.messageID == ids[4] }) - } - - @Test("Re-indexing same messageID updates instead of duplicating") - func reindexingUpdates() async { - let cache = MessageLRUCache() - let messageID = UUID() - - await cache.index( - messageID: messageID, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - // Re-index same message - await cache.index( - messageID: messageID, - channelIndex: 0, - senderName: "Node", - text: "Hello", - timestamp: 1704067200 - ) - - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) - - // Should only have one entry, not two - #expect(candidates.count == 1) - #expect(candidates.first?.messageID == messageID) - } + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) + + // Should only keep most recent 3 + #expect(candidates.count == 3) + // First two should have been evicted + #expect(!candidates.contains { $0.messageID == ids[0] }) + #expect(!candidates.contains { $0.messageID == ids[1] }) + // Last three should be present + #expect(candidates.contains { $0.messageID == ids[2] }) + #expect(candidates.contains { $0.messageID == ids[3] }) + #expect(candidates.contains { $0.messageID == ids[4] }) + } + + @Test + func `Re-indexing same messageID updates instead of duplicating`() async { + let cache = MessageLRUCache() + let messageID = UUID() + + await cache.index( + messageID: messageID, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + // Re-index same message + await cache.index( + messageID: messageID, + channelIndex: 0, + senderName: "Node", + text: "Hello", + timestamp: 1_704_067_200 + ) + + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let candidates = await cache.lookup(channelIndex: 0, senderName: "Node", messageHash: hash) + + // Should only have one entry, not two + #expect(candidates.count == 1) + #expect(candidates.first?.messageID == messageID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceACKTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceACKTests.swift index 1f01e912..72e8a783 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceACKTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceACKTests.swift @@ -1,1044 +1,1043 @@ -import Testing import Foundation -import MeshCoreTestSupport @testable import MC1Services +import MeshCoreTestSupport +import Testing @Suite("MessageService ACK Tests") struct MessageServiceACKTests { - - private let testDeviceID = UUID() - - private func makePending( - messageID: UUID = UUID(), - contactID: UUID = UUID(), - ackCodes: Set, - sentAt: Date = Date(), - timeout: TimeInterval = 30.0, - isDelivered: Bool = false - ) -> PendingAck { - PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: ackCodes, - sentAt: sentAt, - timeout: timeout, - isDelivered: isDelivered - ) - } - - // MARK: - ACK Expiry Checking Toggle - - @Test("isAckExpiryCheckingActive toggles correctly") - func ackExpiryCheckingToggles() async throws { - let (service, _) = try await MessageService.createForTesting() - - #expect(await !service.isAckExpiryCheckingActive) - - await service.startAckExpiryChecking() - #expect(await service.isAckExpiryCheckingActive) - - await service.stopAckExpiryChecking() - #expect(await !service.isAckExpiryCheckingActive) - } - - @Test("stopAckExpiryChecking cancels the background task") - func stopCancelsTask() async throws { - let (service, _) = try await MessageService.createForTesting() - - await service.startAckExpiryChecking() - #expect(await service.isAckExpiryCheckingActive) - - await service.stopAckExpiryChecking() - #expect(await !service.isAckExpiryCheckingActive) - - await service.startAckExpiryChecking() - #expect(await service.isAckExpiryCheckingActive) - await service.stopAckExpiryChecking() - } - - // MARK: - checkExpiredAcks - - @Test("checkExpiredAcks marks expired ACK as failed") - func expiredAckMarkedFailed() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .sent - ) - try await dataStore.saveMessage(message) - - let statusEvents = service.statusEvents() - - let ackCode = Data([0x01, 0x02, 0x03, 0x04]) - await service.setPendingAckForTest( - makePending( - messageID: messageID, - ackCodes: [ackCode], - sentAt: Date().addingTimeInterval(-60), - timeout: 30.0 - ) - ) - - try await service.checkExpiredAcks() - - let fetched = try await dataStore.fetchMessage(id: messageID) - #expect(fetched?.status == .failed) - - let failedIDs = await service.drainStatusEvents(statusEvents).failedIDs - #expect(failedIDs.contains(messageID)) - } - - @Test("ACK timeout keeps message .sent through the grace window so a late ACK can still reconcile") - func ackTimeoutStaysSentDuringGraceWindow() async throws { - // Pin the give-up window so the test exercises "past per-attempt timeout - // but inside the give-up window" independent of the product default. - let (service, dataStore) = try await MessageService.createForTesting( - config: MessageServiceConfig(ackGiveUpWindow: 45) - ) - let messageID = UUID() - - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .sent - ) - try await dataStore.saveMessage(message) - - let statusEvents = service.statusEvents() - - let ackCode = Data([0x11, 0x22, 0x33, 0x44]) - await service.setPendingAckForTest( - makePending( - messageID: messageID, - ackCodes: [ackCode], - sentAt: Date().addingTimeInterval(-31), - timeout: 30.0 - ) - ) - - try await service.checkExpiredAcks() - - let fetched = try await dataStore.fetchMessage(id: messageID) - #expect(fetched?.status == .sent, - "Grace window must not downgrade to .retrying — nothing is actually retrying") - #expect(await service.pendingAckCount == 1) - - let events = await service.drainStatusEvents(statusEvents) - #expect(!events.failedIDs.contains(messageID)) - #expect(events.retryUpdates.isEmpty, - "Grace window is not a retry; no retrying event should fire") - } - - @Test("checkExpiredAcks preserves non-expired ACK") - func nonExpiredAckSurvives() async throws { - let (service, _) = try await MessageService.createForTesting() - - let ackCode = Data([0x05, 0x06, 0x07, 0x08]) - await service.setPendingAckForTest( - makePending(ackCodes: [ackCode], sentAt: Date(), timeout: 30.0) - ) - - try await service.checkExpiredAcks() - - #expect(await service.pendingAckCount == 1, "Non-expired ACK should survive") - } - - @Test("checkExpiredAcks skips already-delivered ACK") - func deliveredAckSkipped() async throws { - let (service, _) = try await MessageService.createForTesting() - - let ackCode = Data([0x0D, 0x0E, 0x0F, 0x10]) - await service.setPendingAckForTest( - makePending( - ackCodes: [ackCode], - sentAt: Date().addingTimeInterval(-60), - timeout: 30.0, - isDelivered: true - ) - ) - - try await service.checkExpiredAcks() - - #expect(await service.pendingAckCount == 1, "Delivered ACK should not be expired") - } - - @Test("checkExpiredAcks does not broadcast failure when DB stays delivered") - func checkExpiredAcksDoesNotFireHandlerOnDeliveredRow() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let ackCode = Data([0xCE, 0xEC, 0xAC, 0xCE]) - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .delivered, - ackCode: ackCode.ackCodeUInt32 - ) - ) - let statusEvents = service.statusEvents() - await service.setPendingAckForTest( - makePending( - messageID: messageID, - ackCodes: [ackCode], - sentAt: Date().addingTimeInterval(-60), - timeout: 30 - ) - ) - - try await service.checkExpiredAcks() - - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .delivered, - "DB layer must absorb .delivered against the .failed write") - let failed = await service.drainStatusEvents(statusEvents).failedIDs - #expect(!failed.contains(messageID), - ".failed must not be broadcast when the DB write is a no-op") + private let testDeviceID = UUID() + + private func makePending( + messageID: UUID = UUID(), + contactID: UUID = UUID(), + ackCodes: Set, + sentAt: Date = Date(), + timeout: TimeInterval = 30.0, + isDelivered: Bool = false + ) -> PendingAck { + PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: ackCodes, + sentAt: sentAt, + timeout: timeout, + isDelivered: isDelivered + ) + } + + // MARK: - ACK Expiry Checking Toggle + + @Test + func `isAckExpiryCheckingActive toggles correctly`() async throws { + let (service, _) = try await MessageService.createForTesting() + + #expect(await !service.isAckExpiryCheckingActive) + + await service.startAckExpiryChecking() + #expect(await service.isAckExpiryCheckingActive) + + await service.stopAckExpiryChecking() + #expect(await !service.isAckExpiryCheckingActive) + } + + @Test + func `stopAckExpiryChecking cancels the background task`() async throws { + let (service, _) = try await MessageService.createForTesting() + + await service.startAckExpiryChecking() + #expect(await service.isAckExpiryCheckingActive) + + await service.stopAckExpiryChecking() + #expect(await !service.isAckExpiryCheckingActive) + + await service.startAckExpiryChecking() + #expect(await service.isAckExpiryCheckingActive) + await service.stopAckExpiryChecking() + } + + // MARK: - checkExpiredAcks + + @Test + func `checkExpiredAcks marks expired ACK as failed`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .sent + ) + try await dataStore.saveMessage(message) + + let statusEvents = service.statusEvents() + + let ackCode = Data([0x01, 0x02, 0x03, 0x04]) + await service.setPendingAckForTest( + makePending( + messageID: messageID, + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-60), + timeout: 30.0 + ) + ) + + try await service.checkExpiredAcks() + + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status == .failed) + + let failedIDs = await service.drainStatusEvents(statusEvents).failedIDs + #expect(failedIDs.contains(messageID)) + } + + @Test + func `ACK timeout keeps message .sent through the grace window so a late ACK can still reconcile`() async throws { + // Pin the give-up window so the test exercises "past per-attempt timeout + // but inside the give-up window" independent of the product default. + let (service, dataStore) = try await MessageService.createForTesting( + config: MessageServiceConfig(ackGiveUpWindow: 45) + ) + let messageID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .sent + ) + try await dataStore.saveMessage(message) + + let statusEvents = service.statusEvents() + + let ackCode = Data([0x11, 0x22, 0x33, 0x44]) + await service.setPendingAckForTest( + makePending( + messageID: messageID, + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-31), + timeout: 30.0 + ) + ) + + try await service.checkExpiredAcks() + + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status == .sent, + "Grace window must not downgrade to .retrying — nothing is actually retrying") + #expect(await service.pendingAckCount == 1) + + let events = await service.drainStatusEvents(statusEvents) + #expect(!events.failedIDs.contains(messageID)) + #expect(events.retryUpdates.isEmpty, + "Grace window is not a retry; no retrying event should fire") + } + + @Test + func `checkExpiredAcks preserves non-expired ACK`() async throws { + let (service, _) = try await MessageService.createForTesting() + + let ackCode = Data([0x05, 0x06, 0x07, 0x08]) + await service.setPendingAckForTest( + makePending(ackCodes: [ackCode], sentAt: Date(), timeout: 30.0) + ) + + try await service.checkExpiredAcks() + + #expect(await service.pendingAckCount == 1, "Non-expired ACK should survive") + } + + @Test + func `checkExpiredAcks skips already-delivered ACK`() async throws { + let (service, _) = try await MessageService.createForTesting() + + let ackCode = Data([0x0D, 0x0E, 0x0F, 0x10]) + await service.setPendingAckForTest( + makePending( + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-60), + timeout: 30.0, + isDelivered: true + ) + ) + + try await service.checkExpiredAcks() + + #expect(await service.pendingAckCount == 1, "Delivered ACK should not be expired") + } + + @Test + func `checkExpiredAcks does not broadcast failure when DB stays delivered`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let ackCode = Data([0xCE, 0xEC, 0xAC, 0xCE]) + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .delivered, + ackCode: ackCode.ackCodeUInt32 + ) + ) + let statusEvents = service.statusEvents() + await service.setPendingAckForTest( + makePending( + messageID: messageID, + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-60), + timeout: 30 + ) + ) + + try await service.checkExpiredAcks() + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .delivered, + "DB layer must absorb .delivered against the .failed write") + let failed = await service.drainStatusEvents(statusEvents).failedIDs + #expect(!failed.contains(messageID), + ".failed must not be broadcast when the DB write is a no-op") + } + + @Test + func `checkExpiredAcks fails a DM after max(ackGiveUpWindow, per-attempt timeout); the window is the floor`() async throws { + let window: TimeInterval = 20 + let shortTimeout: TimeInterval = 5 + let (service, dataStore) = try await MessageService.createForTesting( + config: MessageServiceConfig(ackGiveUpWindow: window) + ) + + let survivingID = UUID() + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: survivingID, radioID: testDeviceID, status: .sent) + ) + // Past the per-attempt timeout but inside the window floor: stays .sent. + await service.setPendingAckForTest( + makePending( + messageID: survivingID, + ackCodes: [Data([0x01, 0x02, 0x03, 0x04])], + sentAt: Date().addingTimeInterval(-(window - 5)), + timeout: shortTimeout + ) + ) + + let expiredID = UUID() + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: expiredID, radioID: testDeviceID, status: .sent) + ) + await service.setPendingAckForTest( + makePending( + messageID: expiredID, + ackCodes: [Data([0x05, 0x06, 0x07, 0x08])], + sentAt: Date().addingTimeInterval(-(window + 5)), + timeout: shortTimeout + ) + ) + + try await service.checkExpiredAcks() + + #expect(try await dataStore.fetchMessage(id: survivingID)?.status == .sent, + "DM still inside the window floor must not be failed") + #expect(try await dataStore.fetchMessage(id: expiredID)?.status == .failed, + "DM past the window floor must be failed") + } + + @Test + func `checkExpiredAcks honors a per-attempt timeout longer than the give-up window (slow preset)`() async throws { + let window: TimeInterval = 20 + let longTimeout: TimeInterval = 60 + let (service, dataStore) = try await MessageService.createForTesting( + config: MessageServiceConfig(ackGiveUpWindow: window) + ) + + // Past the give-up window but still inside the attempt's own ACK wait: + // the loop is legitimately waiting for a slow round-trip, so the checker + // must not fail it. + let waitingID = UUID() + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: waitingID, radioID: testDeviceID, status: .sent) + ) + await service.setPendingAckForTest( + makePending( + messageID: waitingID, + ackCodes: [Data([0x01, 0x02, 0x03, 0x04])], + sentAt: Date().addingTimeInterval(-(window + 10)), + timeout: longTimeout + ) + ) + + // Past both the window and the attempt timeout: genuinely undeliverable. + let expiredID = UUID() + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: expiredID, radioID: testDeviceID, status: .sent) + ) + await service.setPendingAckForTest( + makePending( + messageID: expiredID, + ackCodes: [Data([0x05, 0x06, 0x07, 0x08])], + sentAt: Date().addingTimeInterval(-(longTimeout + 10)), + timeout: longTimeout + ) + ) + + try await service.checkExpiredAcks() + + #expect(try await dataStore.fetchMessage(id: waitingID)?.status == .sent, + "a DM still inside its per-attempt ACK timeout must not be failed even past the window") + #expect(try await dataStore.fetchMessage(id: expiredID)?.status == .failed, + "a DM past max(window, per-attempt timeout) must be failed") + } + + @Test + func `stopAckExpiryChecking leaves in-flight DMs .sent instead of failing them`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID, status: .sent) + ) + + let statusEvents = service.statusEvents() + + await service.setPendingAckForTest( + makePending(messageID: messageID, ackCodes: [Data([0x09, 0x0A, 0x0B, 0x0C])]) + ) + await service.startAckExpiryChecking() + + await service.stopAckExpiryChecking() + + #expect(await !service.isAckExpiryCheckingActive) + #expect(try await dataStore.fetchMessage(id: messageID)?.status == .sent, + "A routine disconnect must not fail in-flight DMs") + #expect(await service.pendingAckCount == 1, + "The pending entry must survive so a reconnect ACK can still reconcile") + #expect(await service.drainStatusEvents(statusEvents).failedIDs.isEmpty) + } + + // MARK: - failAllPendingMessages + + @Test + func `failAllPendingMessages fails all non-delivered and broadcasts .failed`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID1 = UUID() + let messageID2 = UUID() + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: messageID1, radioID: testDeviceID, status: .sent) + ) + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: messageID2, radioID: testDeviceID, status: .sent) + ) + + let statusEvents = service.statusEvents() + + await service.setPendingAckForTest( + makePending(messageID: messageID1, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) + ) + await service.setPendingAckForTest( + makePending(messageID: messageID2, ackCodes: [Data([0x05, 0x06, 0x07, 0x08])]) + ) + + try await service.failAllPendingMessages() + + let msg1 = try await dataStore.fetchMessage(id: messageID1) + let msg2 = try await dataStore.fetchMessage(id: messageID2) + #expect(msg1?.status == .failed) + #expect(msg2?.status == .failed) + + let failedIDs = await service.drainStatusEvents(statusEvents).failedIDs + #expect(failedIDs.count == 2) + #expect(failedIDs.contains(messageID1)) + #expect(failedIDs.contains(messageID2)) + } + + @Test + func `failAllPendingMessages skips already-delivered`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let deliveredID = UUID() + let pendingID = UUID() + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: pendingID, radioID: testDeviceID, status: .sent) + ) + + await service.setPendingAckForTest( + makePending( + messageID: deliveredID, + ackCodes: [Data([0x01, 0x02, 0x03, 0x04])], + isDelivered: true + ) + ) + await service.setPendingAckForTest( + makePending(messageID: pendingID, ackCodes: [Data([0x05, 0x06, 0x07, 0x08])]) + ) + + try await service.failAllPendingMessages() + + let msg = try await dataStore.fetchMessage(id: pendingID) + #expect(msg?.status == .failed) + } + + @Test + func `failAllPendingMessages does not downgrade or notify on a delivered DB row`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let ackCode = Data([0xFA, 0x11, 0x77, 0x33]) + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .delivered, + ackCode: ackCode.ackCodeUInt32 + ) + ) + let statusEvents = service.statusEvents() + await service.setPendingAckForTest( + makePending(messageID: messageID, ackCodes: [ackCode], isDelivered: false) + ) + + try await service.failAllPendingMessages() + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .delivered, + "failAllPendingMessages must not downgrade a delivered row") + let failed = await service.drainStatusEvents(statusEvents).failedIDs + #expect(!failed.contains(messageID), + ".failed must not be broadcast when the DB write is a no-op") + } + + // MARK: - stopAndFailAllPending + + @Test + func `stopAndFailAllPending stops checking and fails all pending`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID, status: .sent) + ) + + await service.startAckExpiryChecking() + #expect(await service.isAckExpiryCheckingActive) + + await service.setPendingAckForTest( + makePending(messageID: messageID, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) + ) + + try await service.stopAndFailAllPending() + + #expect(await !service.isAckExpiryCheckingActive) + let msg = try await dataStore.fetchMessage(id: messageID) + #expect(msg?.status == .failed) + } + + // MARK: - Trip Time Preference + + @Test + func `handleAcknowledgement uses firmware tripTime when provided`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .sent, + ackCode: 0xDEAD_BEEF + ) + try await dataStore.saveMessage(message) + + let ackCode = Data([0xEF, 0xBE, 0xAD, 0xDE]) // 0xDEADBEEF LE + await service.setPendingAckForTest( + makePending( + messageID: messageID, + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-10) + ) + ) + + await service.handleAcknowledgement(code: ackCode, tripTime: 250) + + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status == .delivered) + #expect(fetched?.roundTripTime == 250, + "Should use firmware tripTime (250ms), not Date()-based (~10000ms)") + } + + @Test + func `handleAcknowledgement leaves roundTripTime nil when firmware does not supply tripTime`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .sent, + ackCode: 0xCAFE_BABE + ) + try await dataStore.saveMessage(message) + + let ackCode = Data([0xBE, 0xBA, 0xFE, 0xCA]) // 0xCAFEBABE LE + await service.setPendingAckForTest( + makePending( + messageID: messageID, + ackCodes: [ackCode], + sentAt: Date().addingTimeInterval(-2) + ) + ) + + await service.handleAcknowledgement(code: ackCode, tripTime: nil) + + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status == .delivered) + #expect(fetched?.roundTripTime == nil, + "nil tripTime must not be replaced by a fabricated Date()-based RTT") + } + + // MARK: - Multi-attempt (Issue #283) + + @Test + func `handleAcknowledgement matches any CRC accumulated across retry attempts`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .pending + ) + try await dataStore.saveMessage(message) + + // Simulate three retry attempts, each producing a different CRC + // (firmware hashes attempt index into the ack code). + let attempt0 = Data([0xAA, 0xAA, 0xAA, 0xAA]) + let attempt1 = Data([0xBB, 0xBB, 0xBB, 0xBB]) + let attempt2 = Data([0xCC, 0xCC, 0xCC, 0xCC]) + await service.setPendingAckForTest( + makePending(messageID: messageID, ackCodes: [attempt0, attempt1, attempt2]) + ) + + // A late ACK for attempt 0's CRC arrives after the retry loop has moved + // on to attempt 2 — must still deliver. + await service.handleAcknowledgement(code: attempt0, tripTime: 500) + + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status == .delivered, + "Late ACK from an earlier attempt must still mark delivered") + #expect(fetched?.roundTripTime == 500) + #expect(await service.pendingAckCount == 0, + "Entry should be removed after delivery (no sibling accumulation)") + } + + @Test + func `handleAcknowledgement updates contact lastMessageDate on late ACK`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let radioID = testDeviceID + try await dataStore.saveDevice(DeviceDTO.testDevice(id: radioID, radioID: radioID)) + + let frame = ContactFrame( + publicKey: Data((0..= before, + "Contact lastMessageDate should be updated to approximately now") } - - @Test("checkExpiredAcks fails a DM after max(ackGiveUpWindow, per-attempt timeout); the window is the floor") - func checkExpiredAcksUsesAckGiveUpWindowAsFloor() async throws { - let window: TimeInterval = 20 - let shortTimeout: TimeInterval = 5 - let (service, dataStore) = try await MessageService.createForTesting( - config: MessageServiceConfig(ackGiveUpWindow: window) - ) - - let survivingID = UUID() - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: survivingID, radioID: testDeviceID, status: .sent) - ) - // Past the per-attempt timeout but inside the window floor: stays .sent. - await service.setPendingAckForTest( - makePending( - messageID: survivingID, - ackCodes: [Data([0x01, 0x02, 0x03, 0x04])], - sentAt: Date().addingTimeInterval(-(window - 5)), - timeout: shortTimeout - ) - ) - - let expiredID = UUID() - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: expiredID, radioID: testDeviceID, status: .sent) - ) - await service.setPendingAckForTest( - makePending( - messageID: expiredID, - ackCodes: [Data([0x05, 0x06, 0x07, 0x08])], - sentAt: Date().addingTimeInterval(-(window + 5)), - timeout: shortTimeout - ) - ) - - try await service.checkExpiredAcks() - - #expect(try await dataStore.fetchMessage(id: survivingID)?.status == .sent, - "DM still inside the window floor must not be failed") - #expect(try await dataStore.fetchMessage(id: expiredID)?.status == .failed, - "DM past the window floor must be failed") - } - - @Test("checkExpiredAcks honors a per-attempt timeout longer than the give-up window (slow preset)") - func checkExpiredAcksHonorsLongPerAttemptTimeout() async throws { - let window: TimeInterval = 20 - let longTimeout: TimeInterval = 60 - let (service, dataStore) = try await MessageService.createForTesting( - config: MessageServiceConfig(ackGiveUpWindow: window) - ) - - // Past the give-up window but still inside the attempt's own ACK wait: - // the loop is legitimately waiting for a slow round-trip, so the checker - // must not fail it. - let waitingID = UUID() - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: waitingID, radioID: testDeviceID, status: .sent) - ) - await service.setPendingAckForTest( - makePending( - messageID: waitingID, - ackCodes: [Data([0x01, 0x02, 0x03, 0x04])], - sentAt: Date().addingTimeInterval(-(window + 10)), - timeout: longTimeout - ) - ) - - // Past both the window and the attempt timeout: genuinely undeliverable. - let expiredID = UUID() - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: expiredID, radioID: testDeviceID, status: .sent) - ) - await service.setPendingAckForTest( - makePending( - messageID: expiredID, - ackCodes: [Data([0x05, 0x06, 0x07, 0x08])], - sentAt: Date().addingTimeInterval(-(longTimeout + 10)), - timeout: longTimeout - ) - ) - - try await service.checkExpiredAcks() - - #expect(try await dataStore.fetchMessage(id: waitingID)?.status == .sent, - "a DM still inside its per-attempt ACK timeout must not be failed even past the window") - #expect(try await dataStore.fetchMessage(id: expiredID)?.status == .failed, - "a DM past max(window, per-attempt timeout) must be failed") - } - - @Test("stopAckExpiryChecking leaves in-flight DMs .sent instead of failing them") - func stopAckExpiryCheckingLeavesPendingSent() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID, status: .sent) - ) - - let statusEvents = service.statusEvents() - - await service.setPendingAckForTest( - makePending(messageID: messageID, ackCodes: [Data([0x09, 0x0A, 0x0B, 0x0C])]) - ) - await service.startAckExpiryChecking() - - await service.stopAckExpiryChecking() - - #expect(await !service.isAckExpiryCheckingActive) - #expect(try await dataStore.fetchMessage(id: messageID)?.status == .sent, - "A routine disconnect must not fail in-flight DMs") - #expect(await service.pendingAckCount == 1, - "The pending entry must survive so a reconnect ACK can still reconcile") - #expect(await service.drainStatusEvents(statusEvents).failedIDs.isEmpty) - } - - // MARK: - failAllPendingMessages - - @Test("failAllPendingMessages fails all non-delivered and broadcasts .failed") - func failAllPending() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID1 = UUID() - let messageID2 = UUID() - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: messageID1, radioID: testDeviceID, status: .sent) - ) - try await dataStore.saveMessage( - MessageDTO.testDirectMessage(id: messageID2, radioID: testDeviceID, status: .sent) - ) - - let statusEvents = service.statusEvents() - - await service.setPendingAckForTest( - makePending(messageID: messageID1, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) - ) - await service.setPendingAckForTest( - makePending(messageID: messageID2, ackCodes: [Data([0x05, 0x06, 0x07, 0x08])]) - ) - - try await service.failAllPendingMessages() - - let msg1 = try await dataStore.fetchMessage(id: messageID1) - let msg2 = try await dataStore.fetchMessage(id: messageID2) - #expect(msg1?.status == .failed) - #expect(msg2?.status == .failed) - - let failedIDs = await service.drainStatusEvents(statusEvents).failedIDs - #expect(failedIDs.count == 2) - #expect(failedIDs.contains(messageID1)) - #expect(failedIDs.contains(messageID2)) + } + + @Test + func `trackPendingAck on retry resets sentAt so checkExpiredAcks preserves a retrying message`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let contactID = UUID() + + let message = MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .sent + ) + try await dataStore.saveMessage(message) + + // Seed attempt 0 with sentAt well past its timeout window. + let ancient = Date().addingTimeInterval(-60) + await service.setPendingAckForTest( + makePending( + messageID: messageID, + contactID: contactID, + ackCodes: [Data([0xAA, 0xAA, 0xAA, 0xAA])], + sentAt: ancient, + timeout: 30.0 + ) + ) + + // Retry attempt 1 registers a new ackCode and a fresh timeout window. + // sentAt must also advance, otherwise checkExpiredAcks will fail the + // entry immediately even though the retry is actively in flight. + await service.trackPendingAck( + messageID: messageID, + contactID: contactID, + ackCode: Data([0xBB, 0xBB, 0xBB, 0xBB]), + timeout: 30.0 + ) + + try await service.checkExpiredAcks() + + #expect(await service.pendingAckCount == 1, + "Retry attempt must preserve the entry, not expire it") + let fetched = try await dataStore.fetchMessage(id: messageID) + #expect(fetched?.status != .failed, + "Retry must not flicker to .failed while in flight") + } + + @Test + func `finalizeSend preserves roundTripTime after listener-won delivery`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let contactID = UUID() + let radioID = testDeviceID + let publicKey = Data((0..= before, - "Contact lastMessageDate should be updated to approximately now") - } - } - - @Test("trackPendingAck on retry resets sentAt so checkExpiredAcks preserves a retrying message") - func retryTrackingResetsSentAt() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let contactID = UUID() - - let message = MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .sent - ) - try await dataStore.saveMessage(message) - - // Seed attempt 0 with sentAt well past its timeout window. - let ancient = Date().addingTimeInterval(-60) - await service.setPendingAckForTest( - makePending( - messageID: messageID, - contactID: contactID, - ackCodes: [Data([0xAA, 0xAA, 0xAA, 0xAA])], - sentAt: ancient, - timeout: 30.0 - ) - ) - - // Retry attempt 1 registers a new ackCode and a fresh timeout window. - // sentAt must also advance, otherwise checkExpiredAcks will fail the - // entry immediately even though the retry is actively in flight. - await service.trackPendingAck( - messageID: messageID, - contactID: contactID, - ackCode: Data([0xBB, 0xBB, 0xBB, 0xBB]), - timeout: 30.0 - ) - - try await service.checkExpiredAcks() - - #expect(await service.pendingAckCount == 1, - "Retry attempt must preserve the entry, not expire it") - let fetched = try await dataStore.fetchMessage(id: messageID) - #expect(fetched?.status != .failed, - "Retry must not flicker to .failed while in flight") - } - - @Test("finalizeSend preserves roundTripTime after listener-won delivery") - func finalizeSendPreservesListenerRTT() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let contactID = UUID() - let radioID = testDeviceID - let publicKey = Data((0.. sent is FORBIDDEN)") - let events = await service.drainStatusEvents(statusEvents) - #expect(events.resolvedIDs.isEmpty, "no .sent yield when clearRetryingToSent returns false") - #expect(events.failedIDs.isEmpty) - } - - @Test("finalizeSend nil branch yields .statusResolved(.sent) from a non-terminal row and gates off on a terminal one") - func nilBranchYieldsStatusResolvedSent() async throws { - // Part A: a .retrying row moves to .sent and yields exactly one event. - let (serviceA, storeA) = try await MessageService.createForTesting() - let idA = UUID() - try await storeA.saveMessage( - MessageDTO.testDirectMessage(id: idA, radioID: testDeviceID, status: .retrying) - ) - await serviceA.setPendingAckForTest( - makePending(messageID: idA, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) - ) - let eventsA = serviceA.statusEvents() - _ = try await serviceA.finalizeSend( - messageID: idA, - contactID: UUID(), - radioID: testDeviceID, - publicKey: Data((0.. sent is FORBIDDEN)") + let events = await service.drainStatusEvents(statusEvents) + #expect(events.resolvedIDs.isEmpty, "no .sent yield when clearRetryingToSent returns false") + #expect(events.failedIDs.isEmpty) + } + + @Test + func `finalizeSend nil branch yields .statusResolved(.sent) from a non-terminal row and gates off on a terminal one`() async throws { + // Part A: a .retrying row moves to .sent and yields exactly one event. + let (serviceA, storeA) = try await MessageService.createForTesting() + let idA = UUID() + try await storeA.saveMessage( + MessageDTO.testDirectMessage(id: idA, radioID: testDeviceID, status: .retrying) + ) + await serviceA.setPendingAckForTest( + makePending(messageID: idA, ackCodes: [Data([0x01, 0x02, 0x03, 0x04])]) + ) + let eventsA = serviceA.statusEvents() + _ = try await serviceA.finalizeSend( + messageID: idA, + contactID: UUID(), + radioID: testDeviceID, + publicKey: Data((0.. 5` via a precondition. /// The firmware ACK hash masks the attempt index with `& 0x03`; a single @@ -10,16 +10,15 @@ import Testing /// inline rather than asserted at runtime; the boundary case is exercised here. @Suite("MessageServiceConfig precondition") struct MessageServiceConfigTests { + @Test + func `maxAttempts == 5 is accepted at the precondition boundary`() { + let config = MessageServiceConfig(maxAttempts: 5) + #expect(config.maxAttempts == 5) + } - @Test("maxAttempts == 5 is accepted at the precondition boundary") - func boundaryValueIsAccepted() { - let config = MessageServiceConfig(maxAttempts: 5) - #expect(config.maxAttempts == 5) - } - - @Test("Default config respects the maxAttempts ceiling") - func defaultConfigHonoursCeiling() { - let config = MessageServiceConfig() - #expect(config.maxAttempts <= 5) - } + @Test + func `Default config respects the maxAttempts ceiling`() { + let config = MessageServiceConfig() + #expect(config.maxAttempts <= 5) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceListenerIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceListenerIntegrationTests.swift index a3e4700c..6c4f827f 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceListenerIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceListenerIntegrationTests.swift @@ -1,108 +1,107 @@ import Foundation -import Testing @testable import MC1Services @testable import MeshCore +import Testing @Suite("MessageService listener integration") struct MessageServiceListenerIntegrationTests { + @Test + func `listener flips message to .delivered after a flood of non-matching events`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) - @Test("listener flips message to .delivered after a flood of non-matching events") - func deliveryUnderFlood() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage( - id: messageID, - radioID: radioID, - contactID: contactID, - status: .sent - ) - ) - await service.setPendingAckForTest( - PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: [ackCode], - sentAt: Date(), - timeout: 30 - ) - ) - await service.startEventMonitoring() - await service.waitForSubscriberCount(1) + try await dataStore.saveMessage( + MessageDTO.testDirectMessage( + id: messageID, + radioID: radioID, + contactID: contactID, + status: .sent + ) + ) + await service.setPendingAckForTest( + PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: [ackCode], + sentAt: Date(), + timeout: 30 + ) + ) + await service.startEventMonitoring() + await service.waitForSubscriberCount(1) - let session = await service.sessionForTest - for i in 0..<500 { - await session.dispatchForTesting(.advertisement(publicKey: Data([UInt8(i % 256)]))) - } - await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 1234)) + let session = await service.sessionForTest + for i in 0..<500 { + await session.dispatchForTesting(.advertisement(publicKey: Data([UInt8(i % 256)]))) + } + await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 1234)) - try await waitForStatus(.delivered, messageID: messageID, dataStore: dataStore) + try await waitForStatus(.delivered, messageID: messageID, dataStore: dataStore) - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .delivered) - await service.stopEventMonitoring() - } + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .delivered) + await service.stopEventMonitoring() + } - @Test("listener restart after disconnect/reconnect still observes ACKs") - func deliveryAfterListenerRestart() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let radioID = UUID() - let messageID = UUID() - let contactID = UUID() - let ackCode = Data([0x11, 0x22, 0x33, 0x44]) + @Test + func `listener restart after disconnect/reconnect still observes ACKs`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let radioID = UUID() + let messageID = UUID() + let contactID = UUID() + let ackCode = Data([0x11, 0x22, 0x33, 0x44]) - try await dataStore.saveMessage( - MessageDTO.testDirectMessage( - id: messageID, - radioID: radioID, - contactID: contactID, - status: .sent - ) - ) - await service.setPendingAckForTest( - PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: [ackCode], - sentAt: Date(), - timeout: 30 - ) - ) + try await dataStore.saveMessage( + MessageDTO.testDirectMessage( + id: messageID, + radioID: radioID, + contactID: contactID, + status: .sent + ) + ) + await service.setPendingAckForTest( + PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: [ackCode], + sentAt: Date(), + timeout: 30 + ) + ) - await service.startEventMonitoring() - await service.waitForSubscriberCount(1) - await service.stopEventMonitoring() - await service.waitForSubscriberCount(0) - await service.startEventMonitoring() - await service.waitForSubscriberCount(1) + await service.startEventMonitoring() + await service.waitForSubscriberCount(1) + await service.stopEventMonitoring() + await service.waitForSubscriberCount(0) + await service.startEventMonitoring() + await service.waitForSubscriberCount(1) - let session = await service.sessionForTest - await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 200)) + let session = await service.sessionForTest + await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 200)) - try await waitForStatus(.delivered, messageID: messageID, dataStore: dataStore) + try await waitForStatus(.delivered, messageID: messageID, dataStore: dataStore) - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .delivered) - await service.stopEventMonitoring() - } + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .delivered) + await service.stopEventMonitoring() + } - private func waitForStatus( - _ expected: MessageStatus, - messageID: UUID, - dataStore: PersistenceStore, - timeout: Duration = .milliseconds(500) - ) async throws { - let deadline = ContinuousClock.now.advanced(by: timeout) - while ContinuousClock.now < deadline { - if let m = try await dataStore.fetchMessage(id: messageID), m.status == expected { - return - } - try await Task.sleep(for: .milliseconds(10)) - } - Issue.record("Message did not reach \(expected) within \(timeout)") + private func waitForStatus( + _ expected: MessageStatus, + messageID: UUID, + dataStore: PersistenceStore, + timeout: Duration = .milliseconds(500) + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if let m = try await dataStore.fetchMessage(id: messageID), m.status == expected { + return + } + try await Task.sleep(for: .milliseconds(10)) } + Issue.record("Message did not reach \(expected) within \(timeout)") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendDMBookkeepingTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendDMBookkeepingTests.swift index fb011714..76656f6e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendDMBookkeepingTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendDMBookkeepingTests.swift @@ -1,92 +1,91 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("MessageService DM post-send bookkeeping") struct MessageServiceSendDMBookkeepingTests { + @Test + @MainActor + func `sendDirectMessage keeps ACK tracking and does not report failure when post-send bookkeeping fails`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 10) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } - @Test("sendDirectMessage keeps ACK tracking and does not report failure when post-send bookkeeping fails") - @MainActor - func postSendBookkeepingFailureKeepsPendingAck() async throws { - let transport = MockTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 10) - ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - - let statusEvents = service.statusEvents() + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - let contact = ContactDTO.testContact(radioID: UUID()) + let statusEvents = service.statusEvents() - let sendTask = Task { - try await service.sendDirectMessage(text: "hi", to: contact) - } + let contact = ContactDTO.testContact(radioID: UUID()) - try await waitUntil("send should issue CMD_SEND_TXT_MSG") { - await transport.sentData.count == 2 - } + let sendTask = Task { + try await service.sendDirectMessage(text: "hi", to: contact) + } - // Delete the saved row mid-send so post-send bookkeeping fails after - // the radio has already accepted the message. - let saved = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0) - let messageID = try #require(saved.first?.id) - try await dataStore.deleteMessage(id: messageID) + try await waitUntil("send should issue CMD_SEND_TXT_MSG") { + await transport.sentData.count == 2 + } - var msgSent = Data([ResponseCode.messageSent.rawValue]) - msgSent.append(0) - msgSent.append(Data([0xAB, 0xCD, 0xEF, 0x12])) - msgSent.append(uint32Bytes(5_000)) - await transport.simulateReceive(msgSent) + // Delete the saved row mid-send so post-send bookkeeping fails after + // the radio has already accepted the message. + let saved = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0) + let messageID = try #require(saved.first?.id) + try await dataStore.deleteMessage(id: messageID) - await #expect(throws: Error.self) { - _ = try await sendTask.value - } + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(Data([0xAB, 0xCD, 0xEF, 0x12])) + msgSent.append(uint32Bytes(5000)) + await transport.simulateReceive(msgSent) - #expect(await service.pendingAckCount == 1, - "post-send bookkeeping failure must keep the pendingAcks entry alive for the genuine ACK") - #expect(await service.drainStatusEvents(statusEvents).failedIDs.isEmpty, - "post-send bookkeeping failure must not report the DM as failed") + await #expect(throws: Error.self) { + _ = try await sendTask.value } - private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(int32Bytes(0)) - payload.append(int32Bytes(0)) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(uint32Bytes(915_000)) - payload.append(uint32Bytes(125_000)) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) + #expect(await service.pendingAckCount == 1, + "post-send bookkeeping failure must keep the pendingAcks entry alive for the genuine ACK") + #expect(await service.drainStatusEvents(statusEvents).failedIDs.isEmpty, + "post-send bookkeeping failure must not report the DM as failed") + } - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet - } + private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(0)) + payload.append(int32Bytes(0)) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) - private func int32Bytes(_ value: Double) -> Data { - withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } - } + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } - private func uint32Bytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.littleEndian) { Data($0) } - } + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } + + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendTests.swift index 6a9226fd..c410dee8 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceSendTests.swift @@ -1,942 +1,941 @@ -import Testing import Foundation -import MeshCoreTestSupport @testable import MC1Services @testable import MeshCore +import MeshCoreTestSupport +import Testing @Suite("MessageService Send Tests") struct MessageServiceSendTests { - - private let testDeviceID = UUID() - - // MARK: - sendDirectMessage - - @Test("sendDirectMessage throws invalidRecipient for repeater contacts") - func sendDirectMessageRejectsRepeater() async throws { - let (service, _) = try await MessageService.createForTesting() - let repeater = ContactDTO.testContact( - radioID: testDeviceID, - typeRawValue: ContactType.repeater.rawValue - ) - - try await #expect { - _ = try await service.sendDirectMessage(text: "Hello", to: repeater) - } throws: { error in - guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } - return true - } - } - - @Test("sendDirectMessage throws messageTooLong for oversized text") - func sendDirectMessageRejectsLongText() async throws { - let (service, _) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) - - try await #expect { - _ = try await service.sendDirectMessage(text: longText, to: contact) - } throws: { error in - guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } - return true - } - } - - @Test("sendDirectMessage saves message to dataStore before send attempt") - func sendDirectMessageSavesFirst() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - do { - _ = try await service.sendDirectMessage(text: "Hello", to: contact) - } catch { - // Expected — session not started - } - - let messages = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0) - #expect(!messages.isEmpty, "Message should be saved before send attempt") - #expect(messages.first?.text == "Hello") - #expect(messages.first?.direction == .outgoing) - } - - // MARK: - sendMessageWithRetry - - @Test("sendMessageWithRetry throws invalidRecipient for repeater contacts") - func sendMessageWithRetryRejectsRepeater() async throws { - let (service, _) = try await MessageService.createForTesting() - let repeater = ContactDTO.testContact( - radioID: testDeviceID, - typeRawValue: ContactType.repeater.rawValue - ) - - try await #expect { - _ = try await service.sendMessageWithRetry(text: "Hello", to: repeater) - } throws: { error in - guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } - return true - } - } - - @Test("sendMessageWithRetry throws messageTooLong for oversized text") - func sendMessageWithRetryRejectsLongText() async throws { - let (service, _) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) - - try await #expect { - _ = try await service.sendMessageWithRetry(text: longText, to: contact) - } throws: { error in - guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } - return true - } - } - - // MARK: - createPendingMessage - - @Test("createPendingMessage creates message with pending status") - func createPendingMessageStatus() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - - let message = try await service.createPendingMessage(text: "Pending", to: contact) - - #expect(message.status == .pending) - #expect(message.direction == .outgoing) - #expect(message.text == "Pending") - #expect(message.contactID == contact.id) - - let fetched = try await dataStore.fetchMessage(id: message.id) - #expect(fetched != nil) - #expect(fetched?.status == .pending) - } - - @Test("createPendingMessage throws invalidRecipient for repeater") - func createPendingMessageRejectsRepeater() async throws { - let (service, _) = try await MessageService.createForTesting() - let repeater = ContactDTO.testContact( - radioID: testDeviceID, - typeRawValue: ContactType.repeater.rawValue - ) - - try await #expect { - _ = try await service.createPendingMessage(text: "Test", to: repeater) - } throws: { error in - guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } - return true - } - } - - @Test("createPendingMessage throws messageTooLong for oversized text") - func createPendingMessageRejectsLongText() async throws { - let (service, _) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) - - try await #expect { - _ = try await service.createPendingMessage(text: longText, to: contact) - } throws: { error in - guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } - return true - } - } - - @Test("createPendingMessage returns DTO with correct fields") - func createPendingMessageFields() async throws { - let (service, _) = try await MessageService.createForTesting() - let contactID = UUID() - let contact = ContactDTO.testContact(id: contactID, radioID: testDeviceID) - - let message = try await service.createPendingMessage( - text: "Hello world", - to: contact, - textType: .plain - ) - - #expect(message.text == "Hello world") - #expect(message.contactID == contactID) - #expect(message.radioID == testDeviceID) - #expect(message.direction == .outgoing) - #expect(message.textType == .plain) - #expect(message.channelIndex == nil) - } - - @Test("createPendingMessage stamps lastMessageDate so a first DM appears in the chat list before any send succeeds") - func createPendingMessageMakesConversationVisible() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID, lastMessageDate: nil) - try await dataStore.saveContact(contact) - - let before = try await dataStore.fetchConversations(radioID: testDeviceID) - #expect(before.isEmpty) - - _ = try await service.createPendingMessage(text: "First DM", to: contact) - - let after = try await dataStore.fetchConversations(radioID: testDeviceID) - #expect(after.contains { $0.id == contact.id }) - #expect(after.first { $0.id == contact.id }?.lastMessageDate != nil) - } - - // MARK: - sendPendingDirectMessage / resendDirectMessage - - @Test("sendPendingDirectMessage rejects concurrent send for same messageID") - func sendPendingDirectMessageRejectsConcurrent() async throws { - let (service, _) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - let messageID = UUID() - - await service.insertInFlightRetryForTest(messageID) - - try await #expect { - _ = try await service.sendPendingDirectMessage(messageID: messageID, to: contact) - } throws: { error in - guard let e = error as? MessageServiceError, case .sendFailed(let msg) = e else { return false } - return msg.contains("already in progress") - } - } - - @Test("sendPendingDirectMessage throws when message not found") - func sendPendingDirectMessageThrowsWhenNotFound() async throws { - let (service, _) = try await MessageService.createForTesting() - let contact = ContactDTO.testContact(radioID: testDeviceID) - - try await #expect { - _ = try await service.sendPendingDirectMessage(messageID: UUID(), to: contact) - } throws: { error in - guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } - return true - } - } - - // MARK: - sendChannelMessage - - @Test("sendChannelMessage throws messageTooLong for oversized text") - func sendChannelMessageRejectsLongText() async throws { - let (service, _) = try await MessageService.createForTesting() - let longText = String(repeating: "a", count: ProtocolLimits.maxChannelMessageTotalLength + 1) - - try await #expect { - _ = try await service.sendChannelMessage( - text: longText, - channelIndex: 0, - radioID: testDeviceID - ) - } throws: { error in - guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } - return true - } - } - - @Test("sendChannelMessage saves message to dataStore before send attempt") - func sendChannelMessageSavesFirst() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - do { - _ = try await service.sendChannelMessage( - text: "Hello channel", - channelIndex: 0, - radioID: testDeviceID - ) - } catch { - // Expected — session not started - } - - let messages = try await dataStore.fetchMessages( - radioID: testDeviceID, channelIndex: 0, limit: 10, offset: 0 - ) - #expect(!messages.isEmpty, "Message should be saved before send attempt") - #expect(messages.first?.text == "Hello channel") - #expect(messages.first?.direction == .outgoing) - #expect(messages.first?.status == .failed, "Message should be marked failed after send error") - } - - // MARK: - createPendingChannelMessage - - @Test("createPendingChannelMessage saves to dataStore with pending status") - func createPendingChannelMessageSavesWithPendingStatus() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - - let message = try await service.createPendingChannelMessage( - text: "Hello channel", - channelIndex: 0, - radioID: testDeviceID - ) - - #expect(message.status == .pending) - #expect(message.direction == .outgoing) - #expect(message.text == "Hello channel") - #expect(message.channelIndex == 0) - #expect(message.radioID == testDeviceID) - #expect(message.contactID == nil) - - let stored = try await dataStore.fetchMessage(id: message.id) - #expect(stored != nil, "Message should be persisted to dataStore") - #expect(stored?.status == .pending) - } - - @Test("createPendingChannelMessage throws messageTooLong for oversized text") - func createPendingChannelMessageRejectsLongText() async throws { - let (service, _) = try await MessageService.createForTesting() - let longText = String(repeating: "a", count: ProtocolLimits.maxChannelMessageTotalLength + 1) - - try await #expect { - _ = try await service.createPendingChannelMessage( - text: longText, - channelIndex: 0, - radioID: testDeviceID - ) - } throws: { error in - guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } - return true - } - } - - // MARK: - sendPendingChannelMessage - - @Test("sendPendingChannelMessage throws when message not found") - func sendPendingChannelMessageThrowsWhenNotFound() async throws { - let (service, _) = try await MessageService.createForTesting() - - try await #expect { - try await service.sendPendingChannelMessage(messageID: UUID()) - } throws: { error in - guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } - return true - } - } - - @Test("sendPendingChannelMessage sets failed status on send error") - func sendPendingChannelMessageSetsFailedOnError() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - - let message = try await service.createPendingChannelMessage( - text: "Hello channel", - channelIndex: 0, - radioID: testDeviceID - ) - #expect(message.status == .pending) - - do { - try await service.sendPendingChannelMessage(messageID: message.id) - } catch { - // Expected — session not started - } - - let stored = try await dataStore.fetchMessage(id: message.id) - #expect(stored?.status == .failed, "Message should be marked failed after send error") - } - - // MARK: - resendChannelMessage - - @Test("resendChannelMessage throws when message not found") - func resendChannelMessageThrowsWhenNotFound() async throws { - let (service, _) = try await MessageService.createForTesting() - - try await #expect { - try await service.resendChannelMessage(messageID: UUID()) - } throws: { error in - guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } - return true - } - } - - @Test("resendChannelMessage throws when message is not a channel message") - func resendChannelMessageRejectsNonChannel() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - - let dm = MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID) - try await dataStore.saveMessage(dm) - - try await #expect { - try await service.resendChannelMessage(messageID: messageID) - } throws: { error in - guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } - return true - } - } - - @Test("resendChannelMessage writes .sent before broadcasting .resent and refreshes counts") - @MainActor - func resendChannelMessageFiresResentHandlerAfterDBWrite() async throws { - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - - let messageID = UUID() - let failed = MessageDTO.testChannelMessage( - id: messageID, - radioID: testDeviceID, - channelIndex: 0, - status: .failed, - heardRepeats: 3, - sendCount: 1 - ) - try await dataStore.saveMessage(failed) - - let statusEvents = service.statusEvents() - - let resendTask = Task { try await service.resendChannelMessage(messageID: messageID) } - - try await waitUntil("resend should send CMD_SEND_CHANNEL_MSG") { - await transport.sentData.count == 2 - } - await transport.simulateOK() - - _ = try await resendTask.value - - let recorded = await service.drainStatusEvents(statusEvents).resentIDs - #expect(recorded == [messageID], ".resent must broadcast exactly once with the resent ID") - - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .sent, "resend must write .sent to the DB before broadcasting .resent") - #expect(stored?.heardRepeats == 0, "resend must reset heardRepeats to 0") - #expect(stored?.sendCount == 2, "resend must increment sendCount from 1 to 2") - } - - @Test("resendDirectMessage increments sendCount and broadcasts .resent on a successful resend") - @MainActor - func resendDirectMessageBumpsSendCountAndFiresResentHandler() async throws { - let transport = MockTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 10) - ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - - let messageID = UUID() - let contactID = UUID() - let radioID = testDeviceID - let contact = ContactDTO.testContact(id: contactID, radioID: radioID) - - let delivered = MessageDTO.testDirectMessage( - id: messageID, - radioID: radioID, - contactID: contactID, - status: .delivered, - sendCount: 1 - ) - try await dataStore.saveMessage(delivered) - - let statusEvents = service.statusEvents() - - // Pre-populate the pending-ack entry as already delivered so the - // retry loop short-circuits after sendMessage returns. - let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) - await service.setPendingAckForTest( - PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: [ackCode], - sentAt: Date(), - timeout: 30, - isDelivered: true - ) - ) - - let resendTask = Task { - try await service.resendDirectMessage(messageID: messageID, to: contact) - } - - try await waitUntil("resend should send CMD_SEND_TXT_MSG") { - await transport.sentData.count == 2 - } - - var msgSent = Data([ResponseCode.messageSent.rawValue]) - msgSent.append(0) - msgSent.append(ackCode) - msgSent.append(uint32Bytes(5_000)) - await transport.simulateReceive(msgSent) - - _ = try await resendTask.value - - let recorded = await service.drainStatusEvents(statusEvents).resentIDs - #expect(recorded == [messageID], - ".resent must broadcast exactly once on successful DM resend") - - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.sendCount == 2, - "successful resendDirectMessage must increment sendCount from 1 to 2") - } - - @Test("sendPendingDirectMessage does not bump sendCount or broadcast .resent on first send") - @MainActor - func sendPendingDirectMessageDoesNotBumpSendCount() async throws { - let transport = MockTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 10) - ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - - let messageID = UUID() - let contactID = UUID() - let radioID = testDeviceID - let contact = ContactDTO.testContact(id: contactID, radioID: radioID) - - let pending = MessageDTO.testDirectMessage( - id: messageID, - radioID: radioID, - contactID: contactID, - status: .pending, - sendCount: 1 - ) - try await dataStore.saveMessage(pending) - - let statusEvents = service.statusEvents() - - // Pre-populate the pending-ack entry as already delivered so the - // retry loop short-circuits after sendMessage returns. - let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) - await service.setPendingAckForTest( - PendingAck( - messageID: messageID, - contactID: contactID, - ackCodes: [ackCode], - sentAt: Date(), - timeout: 30, - isDelivered: true - ) - ) - - let sendTask = Task { - try await service.sendPendingDirectMessage(messageID: messageID, to: contact) - } - - try await waitUntil("send should send CMD_SEND_TXT_MSG") { - await transport.sentData.count == 2 - } - - var msgSent = Data([ResponseCode.messageSent.rawValue]) - msgSent.append(0) - msgSent.append(ackCode) - msgSent.append(uint32Bytes(5_000)) - await transport.simulateReceive(msgSent) - - _ = try await sendTask.value - - let recorded = await service.drainStatusEvents(statusEvents).resentIDs - #expect(recorded.isEmpty, - ".resent must not broadcast on first send") - - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.sendCount == 1, - "successful sendPendingDirectMessage must leave sendCount at 1") - } - - private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(int32Bytes(0)) - payload.append(int32Bytes(0)) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(uint32Bytes(915_000)) - payload.append(uint32Bytes(125_000)) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) - - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet - } - - private func int32Bytes(_ value: Double) -> Data { - withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } - } - - private func uint32Bytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.littleEndian) { Data($0) } - } - - @Test("sendDirectMessage tracks pending ACK before session.sendMessage so the listener cannot race") - func sendDirectMessageTracksPendingAckBeforeSend() async throws { - let (service, _) = try await MessageService.createForTesting(defaultTimeout: 10, connectTransport: true) - - // Seed selfInfo so the precompute step can read currentSelfInfo.publicKey - // without simulating an APP_START round-trip. - await service.installSelfInfoForTest(publicKey: Data(repeating: 0xFE, count: 32)) - - let contact = ContactDTO.testContact() - - // The mock transport never emits a messageSent event, so sendDirectMessage - // suspends inside session.sendMessage for the full defaultTimeout, holding - // the speculative pending-ack entry that trackPendingAck adds *before* the - // send. Poll for that entry with a generous ceiling: a correct ordering - // surfaces it near-instantly, while a regression that tracked after - // session.sendMessage would be blocked behind the send's timeout and never - // surface it before the task is cancelled — so this still catches reorders. - let sendTask = Task { - try? await service.sendDirectMessage(text: "hi", to: contact) - } - - try await waitUntil( - timeout: .seconds(8), - "trackPendingAck must run before session.sendMessage so a listener ACK cannot race the tracker" - ) { - await service.pendingAckCount > 0 - } - - sendTask.cancel() - _ = await sendTask.value - } - - @Test("sendMessageWithRetry tracks pending ACK before session.sendMessage") - func sendMessageWithRetryTracksPendingAckBeforeSend() async throws { - let (service, _) = try await MessageService.createForTesting(defaultTimeout: 10, connectTransport: true) - - await service.installSelfInfoForTest(publicKey: Data(repeating: 0xFE, count: 32)) - - let contact = ContactDTO.testContact() - - let sendTask = Task { - try? await service.sendMessageWithRetry(text: "hi", to: contact) - } - - try await waitUntil( - timeout: .seconds(8), - "retry-loop precompute must track before session.sendMessage on every attempt" - ) { - await service.pendingAckCount > 0 - } - - sendTask.cancel() - _ = await sendTask.value - } - - @Test("failMessageAndRethrow does not downgrade a delivered DB row") - func failMessageAndRethrowDoesNotDowngradeDelivered() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let ackCode = Data([0xDD, 0x11, 0x22, 0x33]) - - try await dataStore.saveMessage( - MessageDTO.testDirectMessage( - id: messageID, - radioID: testDeviceID, - status: .delivered, - ackCode: ackCode.ackCodeUInt32 - ) - ) - await service.setPendingAckForTest( - PendingAck( - messageID: messageID, - contactID: UUID(), - ackCodes: [ackCode], - sentAt: Date(), - timeout: 30, - isDelivered: true - ) - ) - - await #expect(throws: MessageServiceError.self) { - try await service.failMessageAndRethrow( - MeshCoreError.notConnected, - messageID: messageID - ) - } - - let stored = try await dataStore.fetchMessage(id: messageID) - #expect(stored?.status == .delivered, - "failMessageAndRethrow must not downgrade a delivered row") - } - - @Test("finalizeSend exhaustion does not downgrade a delivered DB row") - func finalizeSendExhaustionDoesNotDowngradeDelivered() async throws { - let (service, dataStore) = try await MessageService.createForTesting() - let messageID = UUID() - let contactID = UUID() - let radioID = testDeviceID - let publicKey = Data((0.. responded { - responded = count - var msgSent = Data([ResponseCode.messageSent.rawValue]) - msgSent.append(0) - msgSent.append(ackCode) - msgSent.append(uint32Bytes(10)) // ~12ms ack window - await transport.simulateReceive(msgSent) - } - await Task.yield() - } - } - defer { responder.cancel() } - - let sent = try await service.sendMessageWithRetry(text: "exhaust me", to: contact) - - #expect(sent.status == .sent, "retry exhaustion must leave the row .sent, not .failed") - #expect(try await dataStore.fetchMessage(id: sent.id)?.status == .sent) - #expect(await service.pendingAckCount == 1, - "the pending entry must survive so checkExpiredAcks owns the single give-up") - - let events = await service.drainStatusEvents(statusEvents) - #expect(!events.failedIDs.contains(sent.id), "no premature .failed on retry exhaustion") - #expect(!events.retryUpdates.isEmpty, "at least one .retrying must have fired (drove >1 attempt)") - #expect(events.resolvedIDs.contains(sent.id), "the nil branch must yield .statusResolved(.sent)") - } - - @Test("a genuine send exception still fails the DM through the outer catch") - @MainActor - func genuineSendFailureStillFails() async throws { - let transport = MockTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 10) - ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let service = MessageService(session: session, dataStore: dataStore, contactService: nil) - - let contact = ContactDTO.testContact(id: UUID(), radioID: testDeviceID) - - // Every send after app start throws, simulating a transport drop. - await transport.failSends(fromSendIndex: 2) - - await #expect(throws: (any Error).self) { - _ = try await service.sendMessageWithRetry(text: "boom", to: contact) - } - - let stored = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0).first - #expect(stored?.status == .failed, - "a genuine send failure must still reach .failed via the outer catch") - } - - @Test("checkExpiredAcks cannot fail a DM while its retry loop is still inside waitForEvent") - @MainActor - func giveUpCannotFireMidLoop() async throws { - let transport = MockTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 20) - ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - // The manual checkExpiredAcks call runs immediately after the send, so - // the elapsed time since the just-re-stamped sentAt is ~0s, far under the - // default give-up window; the global checker must not fire while the loop - // holds a freshly re-stamped entry. - let service = MessageService( - session: session, - dataStore: dataStore, - contactService: nil, - config: MessageServiceConfig(maxAttempts: 2, floodAfter: 5) - ) - - let contactID = UUID() - let contact = ContactDTO.testContact(id: contactID, radioID: testDeviceID) - let ackCode = Data([0x7A, 0x7B, 0x7C, 0x7D]) - - let sendTask = Task { try await service.sendMessageWithRetry(text: "in flight", to: contact) } - - // Attempt 0's send goes out; feed a messageSent with a long ack window so - // the loop parks in waitForEvent with a just-re-stamped sentAt. - try await waitUntil("attempt 0 should send") { - await transport.sentData.count == 2 - } - var msgSent = Data([ResponseCode.messageSent.rawValue]) - msgSent.append(0) - msgSent.append(ackCode) - msgSent.append(uint32Bytes(15_000)) // ~18s window: the loop stays parked - await transport.simulateReceive(msgSent) - - // Readiness: the loop's waitForEvent subscription is active. - await service.waitForSubscriberCount(1) - - // Run the global checker mid-loop: it must not fail the in-flight DM. - try await service.checkExpiredAcks() - - let midLoop = try await dataStore.fetchMessages(contactID: contactID, limit: 1, offset: 0).first - #expect(midLoop?.status != .failed, - "checkExpiredAcks must not fail a DM whose retry loop is still in flight") - #expect(await service.pendingAckCount == 1, "the in-flight entry must survive the checker tick") - - // Unblock the loop so it completes cleanly (delivered). - await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 100)) - let delivered = try await sendTask.value - #expect(delivered.status == .delivered) - } - - @Test("checkExpiredAcks respects a slow-preset per-attempt timeout that exceeds a tiny give-up window") - @MainActor - func giveUpRespectsSlowPresetTimeout() async throws { - let transport = MockTransport() - let session = MeshCoreSession( - transport: transport, - configuration: SessionConfiguration(defaultTimeout: 20) - ) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } - - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - // give-up window 1s; per-attempt timeout derives from suggestedTimeoutMs 15_000 (~18s). - // After a 2s real wait the elapsed exceeds the 1s window but is inside the 18s attempt - // timeout, so max(1, 18) = 18 and the checker must leave the entry alive. - let service = MessageService( - session: session, - dataStore: dataStore, - contactService: nil, - config: MessageServiceConfig(maxAttempts: 2, floodAfter: 5, ackGiveUpWindow: 1) - ) - - let contactID = UUID() - let contact = ContactDTO.testContact(id: contactID, radioID: testDeviceID) - let ackCode = Data([0x7A, 0x7B, 0x7C, 0x7D]) - - let sendTask = Task { try await service.sendMessageWithRetry(text: "slow preset", to: contact) } - - try await waitUntil("attempt 0 should send") { - await transport.sentData.count == 2 - } - var msgSent = Data([ResponseCode.messageSent.rawValue]) - msgSent.append(0) - msgSent.append(ackCode) - msgSent.append(uint32Bytes(15_000)) // ~18s window: the loop stays parked - await transport.simulateReceive(msgSent) - - await service.waitForSubscriberCount(1) - - // Wait 2 real seconds so elapsed > 1s window, then run the checker. - // Under max(1, 18) = 18 the entry must survive. - try await Task.sleep(for: .seconds(2)) - try await service.checkExpiredAcks() - - let midLoop = try await dataStore.fetchMessages(contactID: contactID, limit: 1, offset: 0).first - #expect(midLoop?.status != .failed, - "checkExpiredAcks must not fail a DM still inside its per-attempt ACK timeout") - #expect(await service.pendingAckCount == 1, "the in-flight entry must survive when timeout > window") - - // Unblock the loop; it returns once the listener flips isDelivered. The - // in-flight waitForEvent still parks its full per-attempt timeout rather than - // hanging, and that timeout must exceed the give-up window to stay meaningful. - await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 100)) - let delivered = try await sendTask.value - #expect(delivered.status == .delivered) - } + private let testDeviceID = UUID() + + // MARK: - sendDirectMessage + + @Test + func `sendDirectMessage throws invalidRecipient for repeater contacts`() async throws { + let (service, _) = try await MessageService.createForTesting() + let repeater = ContactDTO.testContact( + radioID: testDeviceID, + typeRawValue: ContactType.repeater.rawValue + ) + + try await #expect { + _ = try await service.sendDirectMessage(text: "Hello", to: repeater) + } throws: { error in + guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } + return true + } + } + + @Test + func `sendDirectMessage throws messageTooLong for oversized text`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) + + try await #expect { + _ = try await service.sendDirectMessage(text: longText, to: contact) + } throws: { error in + guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } + return true + } + } + + @Test + func `sendDirectMessage saves message to dataStore before send attempt`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + do { + _ = try await service.sendDirectMessage(text: "Hello", to: contact) + } catch { + // Expected — session not started + } + + let messages = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0) + #expect(!messages.isEmpty, "Message should be saved before send attempt") + #expect(messages.first?.text == "Hello") + #expect(messages.first?.direction == .outgoing) + } + + // MARK: - sendMessageWithRetry + + @Test + func `sendMessageWithRetry throws invalidRecipient for repeater contacts`() async throws { + let (service, _) = try await MessageService.createForTesting() + let repeater = ContactDTO.testContact( + radioID: testDeviceID, + typeRawValue: ContactType.repeater.rawValue + ) + + try await #expect { + _ = try await service.sendMessageWithRetry(text: "Hello", to: repeater) + } throws: { error in + guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } + return true + } + } + + @Test + func `sendMessageWithRetry throws messageTooLong for oversized text`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) + + try await #expect { + _ = try await service.sendMessageWithRetry(text: longText, to: contact) + } throws: { error in + guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } + return true + } + } + + // MARK: - createPendingMessage + + @Test + func `createPendingMessage creates message with pending status`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + + let message = try await service.createPendingMessage(text: "Pending", to: contact) + + #expect(message.status == .pending) + #expect(message.direction == .outgoing) + #expect(message.text == "Pending") + #expect(message.contactID == contact.id) + + let fetched = try await dataStore.fetchMessage(id: message.id) + #expect(fetched != nil) + #expect(fetched?.status == .pending) + } + + @Test + func `createPendingMessage throws invalidRecipient for repeater`() async throws { + let (service, _) = try await MessageService.createForTesting() + let repeater = ContactDTO.testContact( + radioID: testDeviceID, + typeRawValue: ContactType.repeater.rawValue + ) + + try await #expect { + _ = try await service.createPendingMessage(text: "Test", to: repeater) + } throws: { error in + guard let e = error as? MessageServiceError, case .invalidRecipient = e else { return false } + return true + } + } + + @Test + func `createPendingMessage throws messageTooLong for oversized text`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + let longText = String(repeating: "a", count: ProtocolLimits.maxDirectMessageLength + 1) + + try await #expect { + _ = try await service.createPendingMessage(text: longText, to: contact) + } throws: { error in + guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } + return true + } + } + + @Test + func `createPendingMessage returns DTO with correct fields`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contactID = UUID() + let contact = ContactDTO.testContact(id: contactID, radioID: testDeviceID) + + let message = try await service.createPendingMessage( + text: "Hello world", + to: contact, + textType: .plain + ) + + #expect(message.text == "Hello world") + #expect(message.contactID == contactID) + #expect(message.radioID == testDeviceID) + #expect(message.direction == .outgoing) + #expect(message.textType == .plain) + #expect(message.channelIndex == nil) + } + + @Test + func `createPendingMessage stamps lastMessageDate so a first DM appears in the chat list before any send succeeds`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID, lastMessageDate: nil) + try await dataStore.saveContact(contact) + + let before = try await dataStore.fetchConversations(radioID: testDeviceID) + #expect(before.isEmpty) + + _ = try await service.createPendingMessage(text: "First DM", to: contact) + + let after = try await dataStore.fetchConversations(radioID: testDeviceID) + #expect(after.contains { $0.id == contact.id }) + #expect(after.first { $0.id == contact.id }?.lastMessageDate != nil) + } + + // MARK: - sendPendingDirectMessage / resendDirectMessage + + @Test + func `sendPendingDirectMessage rejects concurrent send for same messageID`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + let messageID = UUID() + + await service.insertInFlightRetryForTest(messageID) + + try await #expect { + _ = try await service.sendPendingDirectMessage(messageID: messageID, to: contact) + } throws: { error in + guard let e = error as? MessageServiceError, case let .sendFailed(msg) = e else { return false } + return msg.contains("already in progress") + } + } + + @Test + func `sendPendingDirectMessage throws when message not found`() async throws { + let (service, _) = try await MessageService.createForTesting() + let contact = ContactDTO.testContact(radioID: testDeviceID) + + try await #expect { + _ = try await service.sendPendingDirectMessage(messageID: UUID(), to: contact) + } throws: { error in + guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } + return true + } + } + + // MARK: - sendChannelMessage + + @Test + func `sendChannelMessage throws messageTooLong for oversized text`() async throws { + let (service, _) = try await MessageService.createForTesting() + let longText = String(repeating: "a", count: ProtocolLimits.maxChannelMessageTotalLength + 1) + + try await #expect { + _ = try await service.sendChannelMessage( + text: longText, + channelIndex: 0, + radioID: testDeviceID + ) + } throws: { error in + guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } + return true + } + } + + @Test + func `sendChannelMessage saves message to dataStore before send attempt`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + do { + _ = try await service.sendChannelMessage( + text: "Hello channel", + channelIndex: 0, + radioID: testDeviceID + ) + } catch { + // Expected — session not started + } + + let messages = try await dataStore.fetchMessages( + radioID: testDeviceID, channelIndex: 0, limit: 10, offset: 0 + ) + #expect(!messages.isEmpty, "Message should be saved before send attempt") + #expect(messages.first?.text == "Hello channel") + #expect(messages.first?.direction == .outgoing) + #expect(messages.first?.status == .failed, "Message should be marked failed after send error") + } + + // MARK: - createPendingChannelMessage + + @Test + func `createPendingChannelMessage saves to dataStore with pending status`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + + let message = try await service.createPendingChannelMessage( + text: "Hello channel", + channelIndex: 0, + radioID: testDeviceID + ) + + #expect(message.status == .pending) + #expect(message.direction == .outgoing) + #expect(message.text == "Hello channel") + #expect(message.channelIndex == 0) + #expect(message.radioID == testDeviceID) + #expect(message.contactID == nil) + + let stored = try await dataStore.fetchMessage(id: message.id) + #expect(stored != nil, "Message should be persisted to dataStore") + #expect(stored?.status == .pending) + } + + @Test + func `createPendingChannelMessage throws messageTooLong for oversized text`() async throws { + let (service, _) = try await MessageService.createForTesting() + let longText = String(repeating: "a", count: ProtocolLimits.maxChannelMessageTotalLength + 1) + + try await #expect { + _ = try await service.createPendingChannelMessage( + text: longText, + channelIndex: 0, + radioID: testDeviceID + ) + } throws: { error in + guard let e = error as? MessageServiceError, case .messageTooLong = e else { return false } + return true + } + } + + // MARK: - sendPendingChannelMessage + + @Test + func `sendPendingChannelMessage throws when message not found`() async throws { + let (service, _) = try await MessageService.createForTesting() + + try await #expect { + try await service.sendPendingChannelMessage(messageID: UUID()) + } throws: { error in + guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } + return true + } + } + + @Test + func `sendPendingChannelMessage sets failed status on send error`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + + let message = try await service.createPendingChannelMessage( + text: "Hello channel", + channelIndex: 0, + radioID: testDeviceID + ) + #expect(message.status == .pending) + + do { + try await service.sendPendingChannelMessage(messageID: message.id) + } catch { + // Expected — session not started + } + + let stored = try await dataStore.fetchMessage(id: message.id) + #expect(stored?.status == .failed, "Message should be marked failed after send error") + } + + // MARK: - resendChannelMessage + + @Test + func `resendChannelMessage throws when message not found`() async throws { + let (service, _) = try await MessageService.createForTesting() + + try await #expect { + try await service.resendChannelMessage(messageID: UUID()) + } throws: { error in + guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } + return true + } + } + + @Test + func `resendChannelMessage throws when message is not a channel message`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + + let dm = MessageDTO.testDirectMessage(id: messageID, radioID: testDeviceID) + try await dataStore.saveMessage(dm) + + try await #expect { + try await service.resendChannelMessage(messageID: messageID) + } throws: { error in + guard let e = error as? MessageServiceError, case .sendFailed = e else { return false } + return true + } + } + + @Test + @MainActor + func `resendChannelMessage writes .sent before broadcasting .resent and refreshes counts`() async throws { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil) + + let messageID = UUID() + let failed = MessageDTO.testChannelMessage( + id: messageID, + radioID: testDeviceID, + channelIndex: 0, + status: .failed, + heardRepeats: 3, + sendCount: 1 + ) + try await dataStore.saveMessage(failed) + + let statusEvents = service.statusEvents() + + let resendTask = Task { try await service.resendChannelMessage(messageID: messageID) } + + try await waitUntil("resend should send CMD_SEND_CHANNEL_MSG") { + await transport.sentData.count == 2 + } + await transport.simulateOK() + + _ = try await resendTask.value + + let recorded = await service.drainStatusEvents(statusEvents).resentIDs + #expect(recorded == [messageID], ".resent must broadcast exactly once with the resent ID") + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .sent, "resend must write .sent to the DB before broadcasting .resent") + #expect(stored?.heardRepeats == 0, "resend must reset heardRepeats to 0") + #expect(stored?.sendCount == 2, "resend must increment sendCount from 1 to 2") + } + + @Test + @MainActor + func `resendDirectMessage increments sendCount and broadcasts .resent on a successful resend`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 10) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil) + + let messageID = UUID() + let contactID = UUID() + let radioID = testDeviceID + let contact = ContactDTO.testContact(id: contactID, radioID: radioID) + + let delivered = MessageDTO.testDirectMessage( + id: messageID, + radioID: radioID, + contactID: contactID, + status: .delivered, + sendCount: 1 + ) + try await dataStore.saveMessage(delivered) + + let statusEvents = service.statusEvents() + + // Pre-populate the pending-ack entry as already delivered so the + // retry loop short-circuits after sendMessage returns. + let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) + await service.setPendingAckForTest( + PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: [ackCode], + sentAt: Date(), + timeout: 30, + isDelivered: true + ) + ) + + let resendTask = Task { + try await service.resendDirectMessage(messageID: messageID, to: contact) + } + + try await waitUntil("resend should send CMD_SEND_TXT_MSG") { + await transport.sentData.count == 2 + } + + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(5000)) + await transport.simulateReceive(msgSent) + + _ = try await resendTask.value + + let recorded = await service.drainStatusEvents(statusEvents).resentIDs + #expect(recorded == [messageID], + ".resent must broadcast exactly once on successful DM resend") + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.sendCount == 2, + "successful resendDirectMessage must increment sendCount from 1 to 2") + } + + @Test + @MainActor + func `sendPendingDirectMessage does not bump sendCount or broadcast .resent on first send`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 10) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil) + + let messageID = UUID() + let contactID = UUID() + let radioID = testDeviceID + let contact = ContactDTO.testContact(id: contactID, radioID: radioID) + + let pending = MessageDTO.testDirectMessage( + id: messageID, + radioID: radioID, + contactID: contactID, + status: .pending, + sendCount: 1 + ) + try await dataStore.saveMessage(pending) + + let statusEvents = service.statusEvents() + + // Pre-populate the pending-ack entry as already delivered so the + // retry loop short-circuits after sendMessage returns. + let ackCode = Data([0xAB, 0xCD, 0xEF, 0x12]) + await service.setPendingAckForTest( + PendingAck( + messageID: messageID, + contactID: contactID, + ackCodes: [ackCode], + sentAt: Date(), + timeout: 30, + isDelivered: true + ) + ) + + let sendTask = Task { + try await service.sendPendingDirectMessage(messageID: messageID, to: contact) + } + + try await waitUntil("send should send CMD_SEND_TXT_MSG") { + await transport.sentData.count == 2 + } + + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(5000)) + await transport.simulateReceive(msgSent) + + _ = try await sendTask.value + + let recorded = await service.drainStatusEvents(statusEvents).resentIDs + #expect(recorded.isEmpty, + ".resent must not broadcast on first send") + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.sendCount == 1, + "successful sendPendingDirectMessage must leave sendCount at 1") + } + + private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(0)) + payload.append(int32Bytes(0)) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } + + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } + + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + @Test + func `sendDirectMessage tracks pending ACK before session.sendMessage so the listener cannot race`() async throws { + let (service, _) = try await MessageService.createForTesting(defaultTimeout: 10, connectTransport: true) + + // Seed selfInfo so the precompute step can read currentSelfInfo.publicKey + // without simulating an APP_START round-trip. + await service.installSelfInfoForTest(publicKey: Data(repeating: 0xFE, count: 32)) + + let contact = ContactDTO.testContact() + + // The mock transport never emits a messageSent event, so sendDirectMessage + // suspends inside session.sendMessage for the full defaultTimeout, holding + // the speculative pending-ack entry that trackPendingAck adds *before* the + // send. Poll for that entry with a generous ceiling: a correct ordering + // surfaces it near-instantly, while a regression that tracked after + // session.sendMessage would be blocked behind the send's timeout and never + // surface it before the task is cancelled — so this still catches reorders. + let sendTask = Task { + try? await service.sendDirectMessage(text: "hi", to: contact) + } + + try await waitUntil( + timeout: .seconds(8), + "trackPendingAck must run before session.sendMessage so a listener ACK cannot race the tracker" + ) { + await service.pendingAckCount > 0 + } + + sendTask.cancel() + _ = await sendTask.value + } + + @Test + func `sendMessageWithRetry tracks pending ACK before session.sendMessage`() async throws { + let (service, _) = try await MessageService.createForTesting(defaultTimeout: 10, connectTransport: true) + + await service.installSelfInfoForTest(publicKey: Data(repeating: 0xFE, count: 32)) + + let contact = ContactDTO.testContact() + + let sendTask = Task { + try? await service.sendMessageWithRetry(text: "hi", to: contact) + } + + try await waitUntil( + timeout: .seconds(8), + "retry-loop precompute must track before session.sendMessage on every attempt" + ) { + await service.pendingAckCount > 0 + } + + sendTask.cancel() + _ = await sendTask.value + } + + @Test + func `failMessageAndRethrow does not downgrade a delivered DB row`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let ackCode = Data([0xDD, 0x11, 0x22, 0x33]) + + try await dataStore.saveMessage( + MessageDTO.testDirectMessage( + id: messageID, + radioID: testDeviceID, + status: .delivered, + ackCode: ackCode.ackCodeUInt32 + ) + ) + await service.setPendingAckForTest( + PendingAck( + messageID: messageID, + contactID: UUID(), + ackCodes: [ackCode], + sentAt: Date(), + timeout: 30, + isDelivered: true + ) + ) + + await #expect(throws: MessageServiceError.self) { + try await service.failMessageAndRethrow( + MeshCoreError.notConnected, + messageID: messageID + ) + } + + let stored = try await dataStore.fetchMessage(id: messageID) + #expect(stored?.status == .delivered, + "failMessageAndRethrow must not downgrade a delivered row") + } + + @Test + func `finalizeSend exhaustion does not downgrade a delivered DB row`() async throws { + let (service, dataStore) = try await MessageService.createForTesting() + let messageID = UUID() + let contactID = UUID() + let radioID = testDeviceID + let publicKey = Data((0.. responded { + responded = count + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(10)) // ~12ms ack window + await transport.simulateReceive(msgSent) + } + await Task.yield() + } + } + defer { responder.cancel() } + + let sent = try await service.sendMessageWithRetry(text: "exhaust me", to: contact) + + #expect(sent.status == .sent, "retry exhaustion must leave the row .sent, not .failed") + #expect(try await dataStore.fetchMessage(id: sent.id)?.status == .sent) + #expect(await service.pendingAckCount == 1, + "the pending entry must survive so checkExpiredAcks owns the single give-up") + + let events = await service.drainStatusEvents(statusEvents) + #expect(!events.failedIDs.contains(sent.id), "no premature .failed on retry exhaustion") + #expect(!events.retryUpdates.isEmpty, "at least one .retrying must have fired (drove >1 attempt)") + #expect(events.resolvedIDs.contains(sent.id), "the nil branch must yield .statusResolved(.sent)") + } + + @Test + @MainActor + func `a genuine send exception still fails the DM through the outer catch`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 10) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let service = MessageService(session: session, dataStore: dataStore, contactService: nil) + + let contact = ContactDTO.testContact(id: UUID(), radioID: testDeviceID) + + // Every send after app start throws, simulating a transport drop. + await transport.failSends(fromSendIndex: 2) + + await #expect(throws: (any Error).self) { + _ = try await service.sendMessageWithRetry(text: "boom", to: contact) + } + + let stored = try await dataStore.fetchMessages(contactID: contact.id, limit: 10, offset: 0).first + #expect(stored?.status == .failed, + "a genuine send failure must still reach .failed via the outer catch") + } + + @Test + @MainActor + func `checkExpiredAcks cannot fail a DM while its retry loop is still inside waitForEvent`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 20) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + // The manual checkExpiredAcks call runs immediately after the send, so + // the elapsed time since the just-re-stamped sentAt is ~0s, far under the + // default give-up window; the global checker must not fire while the loop + // holds a freshly re-stamped entry. + let service = MessageService( + session: session, + dataStore: dataStore, + contactService: nil, + config: MessageServiceConfig(maxAttempts: 2, floodAfter: 5) + ) + + let contactID = UUID() + let contact = ContactDTO.testContact(id: contactID, radioID: testDeviceID) + let ackCode = Data([0x7A, 0x7B, 0x7C, 0x7D]) + + let sendTask = Task { try await service.sendMessageWithRetry(text: "in flight", to: contact) } + + // Attempt 0's send goes out; feed a messageSent with a long ack window so + // the loop parks in waitForEvent with a just-re-stamped sentAt. + try await waitUntil("attempt 0 should send") { + await transport.sentData.count == 2 + } + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(15000)) // ~18s window: the loop stays parked + await transport.simulateReceive(msgSent) + + // Readiness: the loop's waitForEvent subscription is active. + await service.waitForSubscriberCount(1) + + // Run the global checker mid-loop: it must not fail the in-flight DM. + try await service.checkExpiredAcks() + + let midLoop = try await dataStore.fetchMessages(contactID: contactID, limit: 1, offset: 0).first + #expect(midLoop?.status != .failed, + "checkExpiredAcks must not fail a DM whose retry loop is still in flight") + #expect(await service.pendingAckCount == 1, "the in-flight entry must survive the checker tick") + + // Unblock the loop so it completes cleanly (delivered). + await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 100)) + let delivered = try await sendTask.value + #expect(delivered.status == .delivered) + } + + @Test + @MainActor + func `checkExpiredAcks respects a slow-preset per-attempt timeout that exceeds a tiny give-up window`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 20) + ) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } + + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + // give-up window 1s; per-attempt timeout derives from suggestedTimeoutMs 15_000 (~18s). + // After a 2s real wait the elapsed exceeds the 1s window but is inside the 18s attempt + // timeout, so max(1, 18) = 18 and the checker must leave the entry alive. + let service = MessageService( + session: session, + dataStore: dataStore, + contactService: nil, + config: MessageServiceConfig(maxAttempts: 2, floodAfter: 5, ackGiveUpWindow: 1) + ) + + let contactID = UUID() + let contact = ContactDTO.testContact(id: contactID, radioID: testDeviceID) + let ackCode = Data([0x7A, 0x7B, 0x7C, 0x7D]) + + let sendTask = Task { try await service.sendMessageWithRetry(text: "slow preset", to: contact) } + + try await waitUntil("attempt 0 should send") { + await transport.sentData.count == 2 + } + var msgSent = Data([ResponseCode.messageSent.rawValue]) + msgSent.append(0) + msgSent.append(ackCode) + msgSent.append(uint32Bytes(15000)) // ~18s window: the loop stays parked + await transport.simulateReceive(msgSent) + + await service.waitForSubscriberCount(1) + + // Wait 2 real seconds so elapsed > 1s window, then run the checker. + // Under max(1, 18) = 18 the entry must survive. + try await Task.sleep(for: .seconds(2)) + try await service.checkExpiredAcks() + + let midLoop = try await dataStore.fetchMessages(contactID: contactID, limit: 1, offset: 0).first + #expect(midLoop?.status != .failed, + "checkExpiredAcks must not fail a DM still inside its per-attempt ACK timeout") + #expect(await service.pendingAckCount == 1, "the in-flight entry must survive when timeout > window") + + // Unblock the loop; it returns once the listener flips isDelivered. The + // in-flight waitForEvent still parks its full per-attempt timeout rather than + // hanging, and that timeout must exceed the give-up window to stay meaningful. + await session.dispatchForTesting(.acknowledgement(code: ackCode, tripTime: 100)) + let delivered = try await sendTask.value + #expect(delivered.status == .delivered) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceTests.swift index 38f262ec..5020a249 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/MessageServiceTests.swift @@ -1,40 +1,39 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("MessageService Tests") struct MessageServiceTests { + // MARK: - MessageServiceConfig Tests - // MARK: - MessageServiceConfig Tests - - @Test("MessageServiceConfig default values") - func messageServiceConfigDefaults() { - let config = MessageServiceConfig.default - #expect(config.floodFallbackOnRetry == true) - #expect(config.maxAttempts == 5) - #expect(config.maxFloodAttempts == 1) - #expect(config.floodAfter == 4) - #expect(config.minTimeout == 0) - #expect(config.triggerPathDiscoveryAfterFlood == true) - #expect(config.ackGiveUpWindow == 30) - } + @Test + func `MessageServiceConfig default values`() { + let config = MessageServiceConfig.default + #expect(config.floodFallbackOnRetry == true) + #expect(config.maxAttempts == 5) + #expect(config.maxFloodAttempts == 1) + #expect(config.floodAfter == 4) + #expect(config.minTimeout == 0) + #expect(config.triggerPathDiscoveryAfterFlood == true) + #expect(config.ackGiveUpWindow == 30) + } - @Test("MessageServiceConfig custom values") - func messageServiceConfigCustomValues() { - let config = MessageServiceConfig( - floodFallbackOnRetry: false, - maxAttempts: 3, - maxFloodAttempts: 3, - floodAfter: 1, - minTimeout: 10.0, - triggerPathDiscoveryAfterFlood: false - ) - #expect(config.floodFallbackOnRetry == false) - #expect(config.maxAttempts == 3) - #expect(config.maxFloodAttempts == 3) - #expect(config.floodAfter == 1) - #expect(config.minTimeout == 10.0) - #expect(config.triggerPathDiscoveryAfterFlood == false) - } + @Test + func `MessageServiceConfig custom values`() { + let config = MessageServiceConfig( + floodFallbackOnRetry: false, + maxAttempts: 3, + maxFloodAttempts: 3, + floodAfter: 1, + minTimeout: 10.0, + triggerPathDiscoveryAfterFlood: false + ) + #expect(config.floodFallbackOnRetry == false) + #expect(config.maxAttempts == 3) + #expect(config.maxFloodAttempts == 3) + #expect(config.floodAfter == 1) + #expect(config.minTimeout == 10.0) + #expect(config.triggerPathDiscoveryAfterFlood == false) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigImportPlannerTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigImportPlannerTests.swift index ffa38950..e5c3f3dd 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigImportPlannerTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigImportPlannerTests.swift @@ -1,970 +1,984 @@ import Foundation -import Testing @testable import MC1Services @testable import MeshCore +import Testing /// Covers the pure validate/plan seam (`planConfigImport`) and the non-trapping coordinate/timestamp /// encoders. These exercise the import safety guarantees without a live `MeshCoreSession`. @Suite("NodeConfigImportPlanner Tests") struct NodeConfigImportPlannerTests { - - // MARK: - Fixtures - - // Raw secret bytes paired with their hex string. Building the `Data` directly avoids the - // ambiguous `Data(hexString:)` that exists in both @testable-imported modules. - private static let secretBytesA = Data([ - 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, - 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, - ]) - private static let secretBytesB = Data([ - 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, - 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00, - ]) - private static let validChannelSecretA = "00112233445566778899aabbccddeeff" - private static let validChannelSecretB = "ffeeddccbbaa99887766554433221100" - private static let pubKeyHexA = String(repeating: "ab", count: 32) - private static let pubKeyHexB = String(repeating: "cd", count: 32) - - private static func emptySlots(_ count: UInt8) -> [DeviceChannelSlot] { - (0.. ConfigSections { - ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: false, - otherSettings: false, channels: true, contacts: false - ) - } - - private static func contactSections() -> ConfigSections { - ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: false, - otherSettings: false, channels: false, contacts: true - ) - } - - private static func identitySections() -> ConfigSections { - ConfigSections( - nodeIdentity: true, radioSettings: false, positionSettings: false, - otherSettings: false, channels: false, contacts: false - ) - } - - private static func contact( - type: UInt8 = 1, - name: String, - publicKey: String, - latitude: String = "0", - longitude: String = "0", - lastModified: UInt32 = 0, - outPath: String? = nil, - pathHashMode: UInt8? = nil - ) -> MeshCoreNodeConfig.ContactConfig { - MeshCoreNodeConfig.ContactConfig( - type: type, name: name, publicKey: publicKey, flags: 0, - latitude: latitude, longitude: longitude, - lastAdvert: 0, lastModified: lastModified, - outPath: outPath, pathHashMode: pathHashMode - ) - } - - private static func plan( - channels: [MeshCoreNodeConfig.ChannelConfig]? = nil, - contacts: [MeshCoreNodeConfig.ContactConfig]? = nil, - positionSettings: MeshCoreNodeConfig.PositionSettings? = nil, - radioSettings: MeshCoreNodeConfig.RadioSettings? = nil, - privateKey: String? = nil, - publicKey: String? = nil, - name: String? = nil, - sections: ConfigSections, - maxChannels: UInt8 = 8, - maxContacts: Int = 100, - maxTxPower: Int8 = 30, - existingChannels: [DeviceChannelSlot] = emptySlots(8), - existingContacts: [String: MeshContact] = [:] - ) throws -> ConfigImportPlan { - var config = MeshCoreNodeConfig() - config.channels = channels - config.contacts = contacts - config.positionSettings = positionSettings - config.radioSettings = radioSettings - config.privateKey = privateKey - config.publicKey = publicKey - config.name = name - return try planConfigImport( - config: config, sections: sections, - maxChannels: maxChannels, maxContacts: maxContacts, maxTxPower: maxTxPower, - existingChannels: existingChannels, existingContacts: existingContacts - ) - } - - /// A present-key entry for the existing-contacts map whose fields deliberately differ from the - /// capacity tests' imports, so the key counts toward capacity without triggering an M2 skip. - /// The stored `publicKey` is irrelevant to capacity accounting (which keys on the map), so a - /// placeholder avoids the cross-module `Data(hexString:)` ambiguity. - private static func presentContact(keyedAs hexKey: String) -> (String, MeshContact) { - let contact = MeshContact( - id: hexKey, - publicKey: Data(repeating: 0, count: 32), - type: .chat, - flags: ContactFlags(rawValue: 0), - outPathLength: 0xFF, - outPath: Data(), - advertisedName: "Existing", - lastAdvertisement: Date(timeIntervalSince1970: 0), - latitude: 0, - longitude: 0, - lastModified: Date(timeIntervalSince1970: 0) - ) - return (hexKey, contact) - } - - // MARK: - Coordinate validation - - @Test("Position with NaN latitude is rejected before any write") - func positionNaNRejected() { - let sections = ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: true, - otherSettings: false, channels: false, contacts: false - ) - #expect { - _ = try Self.plan( - positionSettings: .init(latitude: "nan", longitude: "0"), - sections: sections - ) - } throws: { error in - if case NodeConfigServiceError.invalidCoordinate(.positionLatitude) = error { return true } - return false - } - } - - @Test("Position with out-of-range latitude is rejected") - func positionOutOfRangeRejected() { - let sections = ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: true, - otherSettings: false, channels: false, contacts: false - ) - #expect { - _ = try Self.plan( - positionSettings: .init(latitude: "1000000000", longitude: "0"), - sections: sections - ) - } throws: { error in - if case NodeConfigServiceError.invalidCoordinate(.positionLatitude) = error { return true } - return false - } - } - - @Test("Contact with infinite longitude is rejected") - func contactInfiniteCoordinateRejected() { - let contacts = [Self.contact(name: "Bad", publicKey: Self.pubKeyHexA, longitude: "inf")] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) - } throws: { error in - if case NodeConfigServiceError.invalidCoordinate(.contactLongitude(name: "Bad")) = error { return true } - return false - } - } - - @Test("Valid position passes and is carried into the plan") - func validPositionAccepted() throws { - let sections = ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: true, - otherSettings: false, channels: false, contacts: false - ) - let plan = try Self.plan( - positionSettings: .init(latitude: "47.6", longitude: "-122.3"), - sections: sections - ) - #expect(plan.position?.latitude == 47.6) - #expect(plan.position?.longitude == -122.3) - } - - // MARK: - Channels - - @Test("Two same-name hashtag channels fold onto one slot") - func intraImportNameDedup() throws { - let channels = [ - MeshCoreNodeConfig.ChannelConfig(name: "#rescue", secret: Self.validChannelSecretA), - MeshCoreNodeConfig.ChannelConfig(name: "#rescue", secret: Self.validChannelSecretB), - ] - let plan = try Self.plan(channels: channels, sections: Self.channelSections()) - #expect(plan.channelWrites.count == 2) - #expect(Set(plan.channelWrites.map(\.index)).count == 1, - "Same-name channels must not consume two slots") - } - - @Test("Two same-secret channels fold onto one slot") - func intraImportSecretDedup() throws { - let channels = [ - MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA), - MeshCoreNodeConfig.ChannelConfig(name: "Beta", secret: Self.validChannelSecretA), - ] - let plan = try Self.plan(channels: channels, sections: Self.channelSections()) - #expect(plan.channelWrites.count == 2) - #expect(Set(plan.channelWrites.map(\.index)).count == 1, - "Same-secret channels must not consume two slots") - } - - @Test("Two distinct channels land on two separate empty slots") - func distinctChannelsTakeDistinctSlots() throws { - let channels = [ - MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA), - MeshCoreNodeConfig.ChannelConfig(name: "Beta", secret: Self.validChannelSecretB), - ] - let plan = try Self.plan(channels: channels, sections: Self.channelSections()) - #expect(plan.channelWrites.count == 2, - "Two genuinely distinct channels must not be merged into one write") - #expect(Set(plan.channelWrites.map(\.index)).count == 2, - "Distinct channels must occupy distinct slots") - } - - @Test("Overwriting a configured slot with a differing secret is flagged") - func overwriteDetected() throws { - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: "#old", secret: Self.secretBytesA, isConfigured: true) - - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#old", secret: Self.validChannelSecretB)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - - #expect(plan.channelsOverwriteExisting == true) - #expect(plan.channelWrites.first?.index == 0) - } - - @Test("Adding a channel into an empty slot is not an overwrite") - func additiveChannelNotOverwrite() throws { - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#new", secret: Self.validChannelSecretA)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections()) - #expect(plan.channelsOverwriteExisting == false) - } - - @Test("Slot exhaustion is rejected before any write") - func slotExhaustionRejected() { - let slots = [DeviceChannelSlot( - index: 0, name: "Existing", - secret: Self.secretBytesA, isConfigured: true - )] - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "New", secret: Self.validChannelSecretB)] - #expect { - _ = try Self.plan( - channels: channels, sections: Self.channelSections(), - maxChannels: 1, existingChannels: slots - ) - } throws: { error in - if case NodeConfigServiceError.noAvailableChannelSlot(name: "New") = error { return true } - return false - } - } - - @Test("Existing hashtag slot folds a same-secret non-hashtag import onto its slot") - func existingHashtagSlotFoldsSameSecretImport() throws { - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: "#rescue", secret: Self.secretBytesA, isConfigured: true) - - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - - #expect(plan.channelWrites.count == 1) - #expect(plan.channelWrites.first?.index == 0, - "A same-secret import must fold onto the existing hashtag slot, not consume a fresh one") - #expect(plan.channelsOverwriteExisting == true) - } - - @Test("Long hashtag name folds onto its existing slot despite device-side truncation") - func longHashtagNameFoldsOntoExistingSlot() throws { - let fullName = "#" + String(repeating: "a", count: 40) // 41 bytes, exceeds the 31-byte field - let deviceTruncated = "#" + String(repeating: "a", count: 30) // 31 bytes, what firmware stores - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: deviceTruncated, secret: Self.secretBytesA, isConfigured: true) - - let channels = [MeshCoreNodeConfig.ChannelConfig(name: fullName, secret: Self.validChannelSecretB)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - - #expect(plan.channelWrites.count == 1) - #expect(plan.channelWrites.first?.index == 0, - "A long hashtag name must match its truncated device slot, not consume a fresh one") - } - - @Test("Hashtag-name import whose secret already lives on another slot folds onto the secret's slot") - func hashtagImportFoldsToExistingSecretSlot() throws { - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: "#general", secret: Self.secretBytesA, isConfigured: true) - slots[1] = DeviceChannelSlot(index: 1, name: "Other", secret: Self.secretBytesB, isConfigured: true) - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretB)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - #expect(plan.channelWrites.count == 1) - #expect(plan.channelWrites.first?.index == 1, - "A secret already on another slot must keep the write single-homed") - #expect(plan.channelWrites.allSatisfy { $0.index != 0 }, - "The secret must not be duplicated onto the hashtag-name slot") - } - - @Test("Non-canonical secret hex still dedups against the canonically-keyed existing slot") - func nonCanonicalSecretHexFolds() throws { - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: "Existing", secret: Self.secretBytesA, isConfigured: true) - - // Same secret bytes as the existing slot but written in uppercase, a non-canonical casing a - // hand-edited backup might use, paired with a changed name so the fold still produces a write - // (a byte-identical import would correctly skip). It must dedup against the device's - // lowercase-canonical key rather than consume a fresh slot. - let uppercased = Self.validChannelSecretA.uppercased() - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Renamed", secret: uppercased)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - - #expect(plan.channelWrites.count == 1) - #expect(plan.channelWrites.first?.index == 0, - "A non-canonical secret must dedup against the canonical existing slot, not consume a fresh one") - } - - @Test("Invalid channel secret length is rejected") - func invalidChannelSecretRejected() { - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Bad", secret: "abcd")] - #expect { - _ = try Self.plan(channels: channels, sections: Self.channelSections()) - } throws: { error in - if case NodeConfigServiceError.invalidChannelSecret = error { return true } - return false - } - } - - @Test("Channel secret with trailing non-hex characters is rejected, not silently accepted") - func channelSecretTrailingNonHexRejected() { - // Valid 32-char hex with stray non-hex characters appended. The filtering parser would - // strip "zz" and still yield 16 bytes; the strict guard must reject the whole string. - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Bad", secret: Self.validChannelSecretA + "zz")] - #expect { - _ = try Self.plan(channels: channels, sections: Self.channelSections()) - } throws: { error in - if case NodeConfigServiceError.invalidChannelSecret = error { return true } - return false - } - } - - // MARK: - Private key (malformed / wrong length) - - @Test("Present-but-garbage private key is rejected, not silently skipped") - func garbagePrivateKeyRejected() { - #expect { - _ = try Self.plan(privateKey: "zzzz", sections: Self.identitySections()) - } throws: { error in - if case NodeConfigServiceError.invalidPrivateKey = error { return true } - return false - } - } - - @Test("Wrong-length private key is rejected") - func wrongLengthPrivateKeyRejected() { - #expect { - _ = try Self.plan(privateKey: "abcd", sections: Self.identitySections()) - } throws: { error in - if case NodeConfigServiceError.invalidPrivateKey = error { return true } - return false - } - } - - @Test("Valid 64-byte expanded private key without a public key is accepted") - func validPrivateKeyAccepted() async throws { - let identity = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let plan = try Self.plan( - privateKey: identity.expandedPrivateKey.hexString, - sections: Self.identitySections() - ) - #expect(plan.importPrivateKey == identity.expandedPrivateKey) - } - - /// Regression: the real export format is the 64-byte expanded key (`clamp(SHA512(seed))`) - /// alongside its public key, which export always emits. The public key is derivable from the - /// expanded scalar, but MC1's CryptoKit API works from the 32-byte seed (which the export omits), - /// so the plan accepts the pair on trust rather than re-deriving and cross-checking it. - @Test("Expanded private key with its public key round-trips and is accepted") - func matchingKeyPairAccepted() async throws { - let identity = try await KeyGenerationService.generateIdentity(hexPrefix: nil) - let plan = try Self.plan( - privateKey: identity.expandedPrivateKey.hexString, - publicKey: identity.publicKey.hexString, - sections: Self.identitySections() - ) - #expect(plan.importPrivateKey == identity.expandedPrivateKey) - } - - // MARK: - Radio validation - - private static func radioSections() -> ConfigSections { - ConfigSections( - nodeIdentity: false, radioSettings: true, positionSettings: false, - otherSettings: false, channels: false, contacts: false - ) - } - - private static func validRadio( - frequency: UInt32 = 910_525, - bandwidth: UInt32 = 62_500, - spreadingFactor: UInt8 = 7, - codingRate: UInt8 = 5, - txPower: Int8 = 20 - ) -> MeshCoreNodeConfig.RadioSettings { - .init( - frequency: frequency, bandwidth: bandwidth, - spreadingFactor: spreadingFactor, codingRate: codingRate, txPower: txPower - ) - } - - @Test("Valid radio settings within firmware ranges are carried into the plan") - func validRadioAccepted() throws { - let plan = try Self.plan(radioSettings: Self.validRadio(), sections: Self.radioSections()) - #expect(plan.radioSettings == Self.validRadio()) - } - - @Test("Out-of-range spreading factor is rejected before any write") - func radioSpreadingFactorRejected() { - #expect { - _ = try Self.plan(radioSettings: Self.validRadio(spreadingFactor: 99), sections: Self.radioSections()) - } throws: { error in - if case NodeConfigServiceError.invalidRadioSettings(.spreadingFactor) = error { return true } - return false - } - } - - @Test("Frequency below the firmware floor is rejected") - func radioFrequencyRejected() { - #expect { - _ = try Self.plan(radioSettings: Self.validRadio(frequency: 100_000), sections: Self.radioSections()) - } throws: { error in - if case NodeConfigServiceError.invalidRadioSettings(.frequency) = error { return true } - return false - } - } - - @Test("TX power above the device's reported maximum is rejected") - func radioTxPowerAboveDeviceMaxRejected() { - #expect { - _ = try Self.plan( - radioSettings: Self.validRadio(txPower: 25), - sections: Self.radioSections(), maxTxPower: 20 - ) - } throws: { error in - if case NodeConfigServiceError.invalidRadioSettings(.txPower) = error { return true } - return false - } - } - - @Test("TX power up to the device's reported maximum is accepted") - func radioTxPowerAtDeviceMaxAccepted() throws { - let plan = try Self.plan( - radioSettings: Self.validRadio(txPower: 30), - sections: Self.radioSections(), maxTxPower: 30 - ) - #expect(plan.radioSettings?.txPower == 30) - } - - // MARK: - Contacts (dedup, capacity, out_path, unknown type) - - @Test("Duplicate contacts dedup by public key, newest last_modified wins") - func contactDedupNewestWins() throws { - let contacts = [ - Self.contact(name: "Older", publicKey: Self.pubKeyHexA, lastModified: 100), - Self.contact(name: "Newer", publicKey: Self.pubKeyHexA, lastModified: 200), - ] - let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) - #expect(plan.contactRecords.count == 1) - #expect(plan.contactRecords.first?.advertisedName == "Newer") - } - - @Test("Dedup keeps the newer record even when it appears first (inverse comparison branch)") - func contactDedupNewestWinsReversedOrder() throws { - let contacts = [ - Self.contact(name: "Newer", publicKey: Self.pubKeyHexA, lastModified: 200), - Self.contact(name: "Older", publicKey: Self.pubKeyHexA, lastModified: 100), - ] - let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) - #expect(plan.contactRecords.count == 1) - #expect(plan.contactRecords.first?.advertisedName == "Newer", - "The older record must not overwrite the already-seen newer one") - } - - @Test("Dedup of equal-timestamp duplicates collapses to one record deterministically") - func contactDedupEqualTimestamps() throws { - let contacts = [ - Self.contact(name: "First", publicKey: Self.pubKeyHexA, lastModified: 100), - Self.contact(name: "Second", publicKey: Self.pubKeyHexA, lastModified: 100), - ] - let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) - #expect(plan.contactRecords.count == 1) - // The `>=` comparison adopts a same-timestamp later entry, so the last one read wins. - #expect(plan.contactRecords.first?.advertisedName == "Second") - } - - @Test("Contact with a valid out_path but out-of-range path hash mode is rejected") - func contactInvalidPathHashModeRejected() { - let contacts = [Self.contact( - name: "BadMode", publicKey: Self.pubKeyHexA, outPath: "aabb", pathHashMode: 3 - )] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) - } throws: { error in - if case NodeConfigServiceError.invalidPathHashMode(name: "BadMode", mode: 3) = error { return true } - return false - } - } - - @Test("Exceeding device contact capacity is rejected") - func contactCapacityRejected() { - let contacts = [ - Self.contact(name: "A", publicKey: Self.pubKeyHexA), - Self.contact(name: "B", publicKey: Self.pubKeyHexB), - ] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections(), maxContacts: 1) - } throws: { error in - if case NodeConfigServiceError.contactCapacityExceeded = error { return true } - return false - } - } - - @Test("Exactly filling the remaining contact slots is accepted") - func contactCapacityBoundaryAccepted() throws { - let contacts = [ - Self.contact(name: "A", publicKey: Self.pubKeyHexA), - Self.contact(name: "B", publicKey: Self.pubKeyHexB), - ] - let plan = try Self.plan(contacts: contacts, sections: Self.contactSections(), maxContacts: 2) - #expect(plan.contactRecords.count == 2) - } - - @Test("Capacity check credits keys already on the device (updates consume no slot)") - func contactCapacityCreditsExistingKeys() throws { - // Device table is full (maxContacts 1, one existing key), but the import only updates that key. - let plan = try Self.plan( - contacts: [Self.contact(name: "Update", publicKey: Self.pubKeyHexA)], - sections: Self.contactSections(), - maxContacts: 1, - existingContacts: Dictionary(uniqueKeysWithValues: [Self.presentContact(keyedAs: Self.pubKeyHexA.lowercased())]) - ) - #expect(plan.contactRecords.count == 1) - } - - @Test("A new contact has no free slot once the device table is full") - func contactCapacityRejectsNewWhenFull() { - #expect { - _ = try Self.plan( - contacts: [Self.contact(name: "New", publicKey: Self.pubKeyHexB)], - sections: Self.contactSections(), - maxContacts: 1, - existingContacts: Dictionary(uniqueKeysWithValues: [Self.presentContact(keyedAs: Self.pubKeyHexA.lowercased())]) - ) - } throws: { error in - if case NodeConfigServiceError.contactCapacityExceeded = error { return true } - return false - } - } - - @Test("Invalid out_path hex is rejected instead of silently downgrading to direct") - func invalidOutPathRejected() { - let contacts = [Self.contact(name: "BadPath", publicKey: Self.pubKeyHexA, outPath: "zzz")] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) - } throws: { error in - if case NodeConfigServiceError.invalidOutPath(name: "BadPath") = error { return true } - return false - } - } - - @Test("Out_path longer than the firmware buffer is rejected") - func outPathExceedingBufferRejected() { - // 66 bytes of 3-byte hashes = 22 hops: within the 6-bit hop field but past MAX_PATH_SIZE, - // so only the total-length guard catches it, not the hop-count guard. - let longPath = String(repeating: "ab", count: PathEncoding.maxPathBytes / 3 * 3 + 3) - let contacts = [Self.contact( - name: "TooLong", publicKey: Self.pubKeyHexA, outPath: longPath, pathHashMode: 2 - )] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) - } throws: { error in - if case NodeConfigServiceError.invalidOutPath(name: "TooLong") = error { return true } - return false - } - } - - @Test("Invalid contact public key is rejected") - func invalidContactPublicKeyRejected() { - let contacts = [Self.contact(name: "BadKey", publicKey: "abcd")] - #expect { - _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) - } throws: { error in - if case NodeConfigServiceError.invalidContactPublicKey(name: "BadKey") = error { return true } - return false - } - } - - @Test("Unknown contact type byte is preserved verbatim while UI type falls back to chat") - func unknownContactTypePreserved() throws { - let contacts = [Self.contact(type: 99, name: "FutureType", publicKey: Self.pubKeyHexA)] - let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) - let record = try #require(plan.contactRecords.first) - #expect(record.typeRawValue == 99) - #expect(record.type == .chat) - } - - @Test("Absent out_path plans flood routing; empty string plans direct") - func outPathRoutingModes() throws { - let flood = try Self.plan( - contacts: [Self.contact(name: "Flood", publicKey: Self.pubKeyHexA, outPath: nil)], - sections: Self.contactSections() - ) - #expect(flood.contactRecords.first?.outPathLength == 0xFF) - - let direct = try Self.plan( - contacts: [Self.contact(name: "Direct", publicKey: Self.pubKeyHexB, outPath: "")], - sections: Self.contactSections() - ) - #expect(direct.contactRecords.first?.outPathLength == 0) - } - - @Test("Valid out_path resolves to its bytes and encoded length for each hash mode") - func validOutPathPerMode() throws { - // Expected encoded length is a pinned literal (mode << 6 | hopCount), not a re-derivation of - // the planner's own encodePathLen, so a bug in that encoder cannot be mirrored into the value. - let cases: [(mode: UInt8, hex: String, bytes: [UInt8], length: UInt8)] = [ - (0, "aabb", [0xaa, 0xbb], 0x02), - (1, "aabbccdd", [0xaa, 0xbb, 0xcc, 0xdd], 0x42), - (2, "aabbccddeeff", [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff], 0x82), - ] - for testCase in cases { - let plan = try Self.plan( - contacts: [Self.contact( - name: "Routed", publicKey: Self.pubKeyHexA, - outPath: testCase.hex, pathHashMode: testCase.mode - )], - sections: Self.contactSections() - ) - let record = try #require(plan.contactRecords.first) - #expect(record.outPath == Data(testCase.bytes)) - #expect(record.outPathLength == testCase.length) - } - } - - // MARK: - Empty-but-present sections / identity carry-through - - @Test("Empty-but-present channel and contact arrays plan no writes and no overwrite") - func emptyArraysPlanNothing() throws { - let sections = ConfigSections( - nodeIdentity: false, radioSettings: false, positionSettings: false, - otherSettings: false, channels: true, contacts: true - ) - let plan = try Self.plan(channels: [], contacts: [], sections: sections) - #expect(plan.channelWrites.isEmpty) - #expect(plan.contactRecords.isEmpty) - #expect(plan.channelsOverwriteExisting == false) - } - - @Test("Identity plan carries a non-nil node name verbatim") - func identityCarriesNodeName() throws { - let plan = try Self.plan(name: "Rescue Base", sections: Self.identitySections()) - #expect(plan.nodeName == "Rescue Base") - } - - // MARK: - M1: byte-identical channel slot skip - - @Test("A channel byte-identical to its resolved slot plans no write") - func identicalChannelSlotSkipped() throws { - var slots = Self.emptySlots(8) - slots[0] = DeviceChannelSlot(index: 0, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) - - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) - - #expect(plan.channelWrites.isEmpty, "An identical slot must not re-commit /channels2") - #expect(plan.channelsOverwriteExisting == false) - } - - @Test("A name-only diff, secret-only diff, and secret relocation each still plan one write") - func channelDiffsStillWrite() throws { - var nameDiff = Self.emptySlots(8) - nameDiff[0] = DeviceChannelSlot(index: 0, name: "Old", secret: Self.secretBytesA, isConfigured: true) - let nameDiffPlan = try Self.plan( - channels: [MeshCoreNodeConfig.ChannelConfig(name: "New", secret: Self.validChannelSecretA)], - sections: Self.channelSections(), existingChannels: nameDiff) - #expect(nameDiffPlan.channelWrites.count == 1, "A name change must still write") - - var secretDiff = Self.emptySlots(8) - secretDiff[0] = DeviceChannelSlot(index: 0, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) - let secretDiffPlan = try Self.plan( - channels: [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretB)], - sections: Self.channelSections(), existingChannels: secretDiff) - #expect(secretDiffPlan.channelWrites.count == 1, "A secret change must still write") - - // Secret A is homed at slot 2 under a different name: the import folds onto that slot and - // writes because the name differs, so the skip must not swallow it. - var relocate = Self.emptySlots(8) - relocate[2] = DeviceChannelSlot(index: 2, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) - let relocatePlan = try Self.plan( - channels: [MeshCoreNodeConfig.ChannelConfig(name: "Renamed", secret: Self.validChannelSecretA)], - sections: Self.channelSections(), existingChannels: relocate) - #expect(relocatePlan.channelWrites.count == 1, "A name change on the secret's existing slot still writes") - #expect(relocatePlan.channelWrites.first?.index == 2, "It folds onto the secret's existing slot") - } - - @Test("A brand-new channel into an empty slot still plans one write") - func newChannelStillWrites() throws { - let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#new", secret: Self.validChannelSecretA)] - let plan = try Self.plan(channels: channels, sections: Self.channelSections()) - #expect(plan.channelWrites.count == 1) - } - - @Test("A duplicate restoring a slot an earlier write changed is not swallowed by the skip") - func duplicateRestoringFoldedSlotStillWrites() throws { - // Same #hashtag homed at slot 0: the first entry changes the secret, the second restores - // the device's original secret. The skip must compare against the slot's planned value, not - // the frozen original, so the restoring write survives and wins last (slot ends at S1). - var hashtagSlots = Self.emptySlots(8) - hashtagSlots[0] = DeviceChannelSlot(index: 0, name: "#general", secret: Self.secretBytesA, isConfigured: true) - let hashtagPlan = try Self.plan( - channels: [ - MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretB), - MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretA), - ], - sections: Self.channelSections(), existingChannels: hashtagSlots) - #expect(hashtagPlan.channelWrites.count == 2, "Both folded writes are planned, last wins") - #expect(hashtagPlan.channelWrites.last?.secret == Self.secretBytesA, "The restoring write must win, not be dropped") - - // Same-secret name fold: the first entry renames the slot, the second restores the device's - // original name. The restoring write must survive so the slot ends at "Foo", not "Bar". - var secretSlots = Self.emptySlots(8) - secretSlots[0] = DeviceChannelSlot(index: 0, name: "Foo", secret: Self.secretBytesA, isConfigured: true) - let secretPlan = try Self.plan( - channels: [ - MeshCoreNodeConfig.ChannelConfig(name: "Bar", secret: Self.validChannelSecretA), - MeshCoreNodeConfig.ChannelConfig(name: "Foo", secret: Self.validChannelSecretA), - ], - sections: Self.channelSections(), existingChannels: secretSlots) - #expect(secretPlan.channelWrites.count == 2, "Both folded writes are planned, last wins") - #expect(secretPlan.channelWrites.last?.name == "Foo", "The restoring write must win, not be dropped") - } - - // MARK: - M2: byte-identical contact skip - - /// Models the device-resident form of a contact `session.getContacts` would report: the planner's - /// record encoded to the wire and decoded back through the same `parseContactData` the live read - /// uses, so the fixture reflects the device's stored bytes (name trimmed to the field width, coords - /// scaled, path sliced) rather than the planner's own `buildContactRecord` output. `lastModified` - /// models the firmware restamp the sub-148-byte add frame triggers: it defaults to the record's own - /// value (the same-device export/re-import round-trip the skip targets), or pass a different value to - /// model a foreign config the firmware re-stamped. - private func deviceStored(_ record: MeshContact, lastModified deviceLastModified: Date? = nil) -> MeshContact { - // The add frame omits last_modified (3 reserved bytes); the contact-response frame carries it at - // offset 143. Reuse the add encoder for offsets 0..<143, then append the device's last_modified. - var frame = Data(PacketBuilder.updateContact(record).dropFirst(1).prefix(143)) - frame.appendLittleEndian(UInt32((deviceLastModified ?? record.lastModified).timeIntervalSince1970)) - return Parsers.parseContactData(frame)! - } - - /// Runs the import once against an empty device to capture the record the planner builds, then - /// returns the device-resident form `getContacts` would report for it. - private func recordFor(_ contact: MeshCoreNodeConfig.ContactConfig) throws -> MeshContact { - let plan = try Self.plan(contacts: [contact], sections: Self.contactSections()) - return deviceStored(try #require(plan.contactRecords.first)) - } - - @Test("A contact equal on all persisted fields is dropped") - func identicalContactDropped() throws { - let config = Self.contact( - name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", - lastModified: 1000, outPath: "aabb", pathHashMode: 0) - let existing = try recordFor(config) - - let plan = try Self.plan( - contacts: [config], sections: Self.contactSections(), - existingContacts: [existing.id: existing]) - #expect(plan.contactRecords.isEmpty, "A byte-identical contact must not re-commit /contacts3") - } - - @Test("A diff in any single persisted field still emits the contact") - func contactFieldDiffStillEmits() throws { - let base = Self.contact( - name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", - lastModified: 1000, outPath: "aabb", pathHashMode: 0) - let existing = try recordFor(base) - - let variants: [(label: String, config: MeshCoreNodeConfig.ContactConfig)] = [ - ("type", Self.contact(type: 2, name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), - ("name", Self.contact(name: "Charlie", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), - ("path", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "ccdd", pathHashMode: 0)), - ("coords", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "48.0", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), - ("lastModified", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 2000, outPath: "aabb", pathHashMode: 0)), - ] - for variant in variants { - let plan = try Self.plan( - contacts: [variant.config], sections: Self.contactSections(), - existingContacts: [existing.id: existing]) - #expect(plan.contactRecords.count == 1, "A \(variant.label) diff must still write the contact") - } - } - - @Test("A name differing only past the firmware field width is treated as equal and dropped") - func contactNameDiffPastFieldWidthDropped() throws { - let shortConfig = Self.contact( - name: "#" + String(repeating: "a", count: 30), publicKey: Self.pubKeyHexA) - let existing = try recordFor(shortConfig) // 31-byte name, what the device stores - - // Import the same contact whose name only diverges past the 31-byte field width. - let longConfig = Self.contact( - name: "#" + String(repeating: "a", count: 30) + "EXTRA", publicKey: Self.pubKeyHexA) - let plan = try Self.plan( - contacts: [longConfig], sections: Self.contactSections(), - existingContacts: [existing.id: existing]) - #expect(plan.contactRecords.isEmpty, "A name the device cannot represent differently must be treated as equal") - } - - @Test("A new contact is unaffected by the skip and capacity stays correct") - func newContactStillEmittedWithSkip() throws { - // One existing key matches its import (dropped); a second key is new (emitted). - let matchConfig = Self.contact(name: "Match", publicKey: Self.pubKeyHexA, lastModified: 5) - let existing = try recordFor(matchConfig) - let newConfig = Self.contact(name: "Fresh", publicKey: Self.pubKeyHexB, lastModified: 5) - - let plan = try Self.plan( - contacts: [matchConfig, newConfig], sections: Self.contactSections(), - existingContacts: [existing.id: existing]) - #expect(plan.contactRecords.count == 1, "Only the unchanged contact is dropped") - #expect(plan.contactRecords.first?.advertisedName == "Fresh") - } - - @Test("A contact the firmware re-stamped is re-emitted, not dropped (skip is safe-fail)") - func restampedContactReEmitted() throws { - // The sub-148-byte contact-add frame carries no last_modified, so firmware stamps its own clock. - // For a config exported from a different device, the device-read last_modified therefore differs - // from the config's, and the skip must re-emit rather than drop: the optimization only fires for - // a same-device export/re-import round-trip, never for a foreign config the firmware re-stamped. - let config = Self.contact( - name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", - lastModified: 1000, outPath: "aabb", pathHashMode: 0) - let record = try #require( - try Self.plan(contacts: [config], sections: Self.contactSections()).contactRecords.first) - let existing = deviceStored(record, lastModified: Date(timeIntervalSince1970: 9_999)) - - let plan = try Self.plan( - contacts: [config], sections: Self.contactSections(), - existingContacts: [existing.id: existing]) - #expect(plan.contactRecords.count == 1, "A re-stamped last_modified must re-emit, never silently drop") - } + // MARK: - Fixtures + + /// Raw secret bytes paired with their hex string. Building the `Data` directly avoids the + /// ambiguous `Data(hexString:)` that exists in both @testable-imported modules. + private static let secretBytesA = Data([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, + ]) + private static let secretBytesB = Data([ + 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x99, 0x88, + 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00, + ]) + private static let validChannelSecretA = "00112233445566778899aabbccddeeff" + private static let validChannelSecretB = "ffeeddccbbaa99887766554433221100" + private static let pubKeyHexA = String(repeating: "ab", count: 32) + private static let pubKeyHexB = String(repeating: "cd", count: 32) + + private static func emptySlots(_ count: UInt8) -> [DeviceChannelSlot] { + (0.. ConfigSections { + ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: false, + otherSettings: false, channels: true, contacts: false + ) + } + + private static func contactSections() -> ConfigSections { + ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: false, + otherSettings: false, channels: false, contacts: true + ) + } + + private static func identitySections() -> ConfigSections { + ConfigSections( + nodeIdentity: true, radioSettings: false, positionSettings: false, + otherSettings: false, channels: false, contacts: false + ) + } + + private static func contact( + type: UInt8 = 1, + name: String, + publicKey: String, + latitude: String = "0", + longitude: String = "0", + lastModified: UInt32 = 0, + outPath: String? = nil, + pathHashMode: UInt8? = nil + ) -> MeshCoreNodeConfig.ContactConfig { + MeshCoreNodeConfig.ContactConfig( + type: type, name: name, publicKey: publicKey, flags: 0, + latitude: latitude, longitude: longitude, + lastAdvert: 0, lastModified: lastModified, + outPath: outPath, pathHashMode: pathHashMode + ) + } + + private static func plan( + channels: [MeshCoreNodeConfig.ChannelConfig]? = nil, + contacts: [MeshCoreNodeConfig.ContactConfig]? = nil, + positionSettings: MeshCoreNodeConfig.PositionSettings? = nil, + radioSettings: MeshCoreNodeConfig.RadioSettings? = nil, + privateKey: String? = nil, + publicKey: String? = nil, + name: String? = nil, + sections: ConfigSections, + maxChannels: UInt8 = 8, + maxContacts: Int = 100, + maxTxPower: Int8 = 30, + existingChannels: [DeviceChannelSlot] = emptySlots(8), + existingContacts: [String: MeshContact] = [:] + ) throws -> ConfigImportPlan { + var config = MeshCoreNodeConfig() + config.channels = channels + config.contacts = contacts + config.positionSettings = positionSettings + config.radioSettings = radioSettings + config.privateKey = privateKey + config.publicKey = publicKey + config.name = name + return try planConfigImport( + config: config, sections: sections, + maxChannels: maxChannels, maxContacts: maxContacts, maxTxPower: maxTxPower, + existingChannels: existingChannels, existingContacts: existingContacts + ) + } + + /// A present-key entry for the existing-contacts map whose fields deliberately differ from the + /// capacity tests' imports, so the key counts toward capacity without triggering an M2 skip. + /// The stored `publicKey` is irrelevant to capacity accounting (which keys on the map), so a + /// placeholder avoids the cross-module `Data(hexString:)` ambiguity. + private static func presentContact(keyedAs hexKey: String) -> (String, MeshContact) { + let contact = MeshContact( + id: hexKey, + publicKey: Data(repeating: 0, count: 32), + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0xFF, + outPath: Data(), + advertisedName: "Existing", + lastAdvertisement: Date(timeIntervalSince1970: 0), + latitude: 0, + longitude: 0, + lastModified: Date(timeIntervalSince1970: 0) + ) + return (hexKey, contact) + } + + // MARK: - Coordinate validation + + @Test + func `Position with NaN latitude is rejected before any write`() { + let sections = ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: true, + otherSettings: false, channels: false, contacts: false + ) + #expect { + _ = try Self.plan( + positionSettings: .init(latitude: "nan", longitude: "0"), + sections: sections + ) + } throws: { error in + if case NodeConfigServiceError.invalidCoordinate(.positionLatitude) = error { return true } + return false + } + } + + @Test + func `Position with out-of-range latitude is rejected`() { + let sections = ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: true, + otherSettings: false, channels: false, contacts: false + ) + #expect { + _ = try Self.plan( + positionSettings: .init(latitude: "1000000000", longitude: "0"), + sections: sections + ) + } throws: { error in + if case NodeConfigServiceError.invalidCoordinate(.positionLatitude) = error { return true } + return false + } + } + + @Test + func `Contact with infinite longitude is rejected`() { + let contacts = [Self.contact(name: "Bad", publicKey: Self.pubKeyHexA, longitude: "inf")] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) + } throws: { error in + if case NodeConfigServiceError.invalidCoordinate(.contactLongitude(name: "Bad")) = error { return true } + return false + } + } + + @Test + func `Valid position passes and is carried into the plan`() throws { + let sections = ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: true, + otherSettings: false, channels: false, contacts: false + ) + let plan = try Self.plan( + positionSettings: .init(latitude: "47.6", longitude: "-122.3"), + sections: sections + ) + #expect(plan.position?.latitude == 47.6) + #expect(plan.position?.longitude == -122.3) + } + + // MARK: - Channels + + @Test + func `Two same-name hashtag channels fold onto one slot`() throws { + let channels = [ + MeshCoreNodeConfig.ChannelConfig(name: "#rescue", secret: Self.validChannelSecretA), + MeshCoreNodeConfig.ChannelConfig(name: "#rescue", secret: Self.validChannelSecretB), + ] + let plan = try Self.plan(channels: channels, sections: Self.channelSections()) + #expect(plan.channelWrites.count == 2) + #expect(Set(plan.channelWrites.map(\.index)).count == 1, + "Same-name channels must not consume two slots") + } + + @Test + func `Two same-secret channels fold onto one slot`() throws { + let channels = [ + MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA), + MeshCoreNodeConfig.ChannelConfig(name: "Beta", secret: Self.validChannelSecretA), + ] + let plan = try Self.plan(channels: channels, sections: Self.channelSections()) + #expect(plan.channelWrites.count == 2) + #expect(Set(plan.channelWrites.map(\.index)).count == 1, + "Same-secret channels must not consume two slots") + } + + @Test + func `Two distinct channels land on two separate empty slots`() throws { + let channels = [ + MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA), + MeshCoreNodeConfig.ChannelConfig(name: "Beta", secret: Self.validChannelSecretB), + ] + let plan = try Self.plan(channels: channels, sections: Self.channelSections()) + #expect(plan.channelWrites.count == 2, + "Two genuinely distinct channels must not be merged into one write") + #expect(Set(plan.channelWrites.map(\.index)).count == 2, + "Distinct channels must occupy distinct slots") + } + + @Test + func `Overwriting a configured slot with a differing secret is flagged`() throws { + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: "#old", secret: Self.secretBytesA, isConfigured: true) + + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#old", secret: Self.validChannelSecretB)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + + #expect(plan.channelsOverwriteExisting == true) + #expect(plan.channelWrites.first?.index == 0) + } + + @Test + func `Adding a channel into an empty slot is not an overwrite`() throws { + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#new", secret: Self.validChannelSecretA)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections()) + #expect(plan.channelsOverwriteExisting == false) + } + + @Test + func `Slot exhaustion is rejected before any write`() { + let slots = [DeviceChannelSlot( + index: 0, name: "Existing", + secret: Self.secretBytesA, isConfigured: true + )] + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "New", secret: Self.validChannelSecretB)] + #expect { + _ = try Self.plan( + channels: channels, sections: Self.channelSections(), + maxChannels: 1, existingChannels: slots + ) + } throws: { error in + if case NodeConfigServiceError.noAvailableChannelSlot(name: "New") = error { return true } + return false + } + } + + @Test + func `Existing hashtag slot folds a same-secret non-hashtag import onto its slot`() throws { + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: "#rescue", secret: Self.secretBytesA, isConfigured: true) + + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + + #expect(plan.channelWrites.count == 1) + #expect(plan.channelWrites.first?.index == 0, + "A same-secret import must fold onto the existing hashtag slot, not consume a fresh one") + #expect(plan.channelsOverwriteExisting == true) + } + + @Test + func `Long hashtag name folds onto its existing slot despite device-side truncation`() throws { + let fullName = "#" + String(repeating: "a", count: 40) // 41 bytes, exceeds the 31-byte field + let deviceTruncated = "#" + String(repeating: "a", count: 30) // 31 bytes, what firmware stores + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: deviceTruncated, secret: Self.secretBytesA, isConfigured: true) + + let channels = [MeshCoreNodeConfig.ChannelConfig(name: fullName, secret: Self.validChannelSecretB)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + + #expect(plan.channelWrites.count == 1) + #expect(plan.channelWrites.first?.index == 0, + "A long hashtag name must match its truncated device slot, not consume a fresh one") + } + + @Test + func `Hashtag-name import whose secret already lives on another slot folds onto the secret's slot`() throws { + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: "#general", secret: Self.secretBytesA, isConfigured: true) + slots[1] = DeviceChannelSlot(index: 1, name: "Other", secret: Self.secretBytesB, isConfigured: true) + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretB)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + #expect(plan.channelWrites.count == 1) + #expect(plan.channelWrites.first?.index == 1, + "A secret already on another slot must keep the write single-homed") + #expect(plan.channelWrites.allSatisfy { $0.index != 0 }, + "The secret must not be duplicated onto the hashtag-name slot") + } + + @Test + func `Non-canonical secret hex still dedups against the canonically-keyed existing slot`() throws { + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: "Existing", secret: Self.secretBytesA, isConfigured: true) + + // Same secret bytes as the existing slot but written in uppercase, a non-canonical casing a + // hand-edited backup might use, paired with a changed name so the fold still produces a write + // (a byte-identical import would correctly skip). It must dedup against the device's + // lowercase-canonical key rather than consume a fresh slot. + let uppercased = Self.validChannelSecretA.uppercased() + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Renamed", secret: uppercased)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + + #expect(plan.channelWrites.count == 1) + #expect(plan.channelWrites.first?.index == 0, + "A non-canonical secret must dedup against the canonical existing slot, not consume a fresh one") + } + + @Test + func `Invalid channel secret length is rejected`() { + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Bad", secret: "abcd")] + #expect { + _ = try Self.plan(channels: channels, sections: Self.channelSections()) + } throws: { error in + if case NodeConfigServiceError.invalidChannelSecret = error { return true } + return false + } + } + + @Test + func `Channel secret with trailing non-hex characters is rejected, not silently accepted`() { + // Valid 32-char hex with stray non-hex characters appended. The filtering parser would + // strip "zz" and still yield 16 bytes; the strict guard must reject the whole string. + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Bad", secret: Self.validChannelSecretA + "zz")] + #expect { + _ = try Self.plan(channels: channels, sections: Self.channelSections()) + } throws: { error in + if case NodeConfigServiceError.invalidChannelSecret = error { return true } + return false + } + } + + // MARK: - Private key (malformed / wrong length) + + @Test + func `Present-but-garbage private key is rejected, not silently skipped`() { + #expect { + _ = try Self.plan(privateKey: "zzzz", sections: Self.identitySections()) + } throws: { error in + if case NodeConfigServiceError.invalidPrivateKey = error { return true } + return false + } + } + + @Test + func `Wrong-length private key is rejected`() { + #expect { + _ = try Self.plan(privateKey: "abcd", sections: Self.identitySections()) + } throws: { error in + if case NodeConfigServiceError.invalidPrivateKey = error { return true } + return false + } + } + + @Test + func `Valid 64-byte expanded private key without a public key is accepted`() async throws { + let identity = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let plan = try Self.plan( + privateKey: identity.expandedPrivateKey.hexString, + sections: Self.identitySections() + ) + #expect(plan.importPrivateKey == identity.expandedPrivateKey) + } + + /// Regression: the real export format is the 64-byte expanded key (`clamp(SHA512(seed))`) + /// alongside its public key, which export always emits. The public key is derivable from the + /// expanded scalar, but MC1's CryptoKit API works from the 32-byte seed (which the export omits), + /// so the plan accepts the pair on trust rather than re-deriving and cross-checking it. + @Test + func `Expanded private key with its public key round-trips and is accepted`() async throws { + let identity = try await KeyGenerationService.generateIdentity(hexPrefix: nil) + let plan = try Self.plan( + privateKey: identity.expandedPrivateKey.hexString, + publicKey: identity.publicKey.hexString, + sections: Self.identitySections() + ) + #expect(plan.importPrivateKey == identity.expandedPrivateKey) + } + + // MARK: - Radio validation + + private static func radioSections() -> ConfigSections { + ConfigSections( + nodeIdentity: false, radioSettings: true, positionSettings: false, + otherSettings: false, channels: false, contacts: false + ) + } + + private static func validRadio( + frequency: UInt32 = 910_525, + bandwidth: UInt32 = 62500, + spreadingFactor: UInt8 = 7, + codingRate: UInt8 = 5, + txPower: Int8 = 20 + ) -> MeshCoreNodeConfig.RadioSettings { + .init( + frequency: frequency, bandwidth: bandwidth, + spreadingFactor: spreadingFactor, codingRate: codingRate, txPower: txPower + ) + } + + @Test + func `Valid radio settings within firmware ranges are carried into the plan`() throws { + let plan = try Self.plan(radioSettings: Self.validRadio(), sections: Self.radioSections()) + #expect(plan.radioSettings == Self.validRadio()) + } + + @Test + func `Out-of-range spreading factor is rejected before any write`() { + #expect { + _ = try Self.plan(radioSettings: Self.validRadio(spreadingFactor: 99), sections: Self.radioSections()) + } throws: { error in + if case NodeConfigServiceError.invalidRadioSettings(.spreadingFactor) = error { return true } + return false + } + } + + @Test + func `Frequency below the firmware floor is rejected`() { + #expect { + _ = try Self.plan(radioSettings: Self.validRadio(frequency: 100_000), sections: Self.radioSections()) + } throws: { error in + if case NodeConfigServiceError.invalidRadioSettings(.frequency) = error { return true } + return false + } + } + + @Test + func `TX power above the device's reported maximum is rejected`() { + #expect { + _ = try Self.plan( + radioSettings: Self.validRadio(txPower: 25), + sections: Self.radioSections(), maxTxPower: 20 + ) + } throws: { error in + if case NodeConfigServiceError.invalidRadioSettings(.txPower) = error { return true } + return false + } + } + + @Test + func `TX power up to the device's reported maximum is accepted`() throws { + let plan = try Self.plan( + radioSettings: Self.validRadio(txPower: 30), + sections: Self.radioSections(), maxTxPower: 30 + ) + #expect(plan.radioSettings?.txPower == 30) + } + + // MARK: - Contacts (dedup, capacity, out_path, unknown type) + + @Test + func `Duplicate contacts dedup by public key, newest last_modified wins`() throws { + let contacts = [ + Self.contact(name: "Older", publicKey: Self.pubKeyHexA, lastModified: 100), + Self.contact(name: "Newer", publicKey: Self.pubKeyHexA, lastModified: 200), + ] + let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) + #expect(plan.contactRecords.count == 1) + #expect(plan.contactRecords.first?.advertisedName == "Newer") + } + + @Test + func `Dedup keeps the newer record even when it appears first (inverse comparison branch)`() throws { + let contacts = [ + Self.contact(name: "Newer", publicKey: Self.pubKeyHexA, lastModified: 200), + Self.contact(name: "Older", publicKey: Self.pubKeyHexA, lastModified: 100), + ] + let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) + #expect(plan.contactRecords.count == 1) + #expect(plan.contactRecords.first?.advertisedName == "Newer", + "The older record must not overwrite the already-seen newer one") + } + + @Test + func `Dedup of equal-timestamp duplicates collapses to one record deterministically`() throws { + let contacts = [ + Self.contact(name: "First", publicKey: Self.pubKeyHexA, lastModified: 100), + Self.contact(name: "Second", publicKey: Self.pubKeyHexA, lastModified: 100), + ] + let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) + #expect(plan.contactRecords.count == 1) + // The `>=` comparison adopts a same-timestamp later entry, so the last one read wins. + #expect(plan.contactRecords.first?.advertisedName == "Second") + } + + @Test + func `Contact with a valid out_path but out-of-range path hash mode is rejected`() { + let contacts = [Self.contact( + name: "BadMode", publicKey: Self.pubKeyHexA, outPath: "aabb", pathHashMode: 3 + )] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) + } throws: { error in + if case NodeConfigServiceError.invalidPathHashMode(name: "BadMode", mode: 3) = error { return true } + return false + } + } + + @Test + func `Exceeding device contact capacity is rejected`() { + let contacts = [ + Self.contact(name: "A", publicKey: Self.pubKeyHexA), + Self.contact(name: "B", publicKey: Self.pubKeyHexB), + ] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections(), maxContacts: 1) + } throws: { error in + if case NodeConfigServiceError.contactCapacityExceeded = error { return true } + return false + } + } + + @Test + func `Exactly filling the remaining contact slots is accepted`() throws { + let contacts = [ + Self.contact(name: "A", publicKey: Self.pubKeyHexA), + Self.contact(name: "B", publicKey: Self.pubKeyHexB), + ] + let plan = try Self.plan(contacts: contacts, sections: Self.contactSections(), maxContacts: 2) + #expect(plan.contactRecords.count == 2) + } + + @Test + func `Capacity check credits keys already on the device (updates consume no slot)`() throws { + // Device table is full (maxContacts 1, one existing key), but the import only updates that key. + let plan = try Self.plan( + contacts: [Self.contact(name: "Update", publicKey: Self.pubKeyHexA)], + sections: Self.contactSections(), + maxContacts: 1, + existingContacts: Dictionary(uniqueKeysWithValues: [Self.presentContact(keyedAs: Self.pubKeyHexA.lowercased())]) + ) + #expect(plan.contactRecords.count == 1) + } + + @Test + func `A new contact has no free slot once the device table is full`() { + #expect { + _ = try Self.plan( + contacts: [Self.contact(name: "New", publicKey: Self.pubKeyHexB)], + sections: Self.contactSections(), + maxContacts: 1, + existingContacts: Dictionary(uniqueKeysWithValues: [Self.presentContact(keyedAs: Self.pubKeyHexA.lowercased())]) + ) + } throws: { error in + if case NodeConfigServiceError.contactCapacityExceeded = error { return true } + return false + } + } + + @Test + func `Invalid out_path hex is rejected instead of silently downgrading to direct`() { + let contacts = [Self.contact(name: "BadPath", publicKey: Self.pubKeyHexA, outPath: "zzz")] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) + } throws: { error in + if case NodeConfigServiceError.invalidOutPath(name: "BadPath") = error { return true } + return false + } + } + + @Test + func `Out_path longer than the firmware buffer is rejected`() { + // 66 bytes of 3-byte hashes = 22 hops: within the 6-bit hop field but past MAX_PATH_SIZE, + // so only the total-length guard catches it, not the hop-count guard. + let longPath = String(repeating: "ab", count: PathEncoding.maxPathBytes / 3 * 3 + 3) + let contacts = [Self.contact( + name: "TooLong", publicKey: Self.pubKeyHexA, outPath: longPath, pathHashMode: 2 + )] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) + } throws: { error in + if case NodeConfigServiceError.invalidOutPath(name: "TooLong") = error { return true } + return false + } + } + + @Test + func `Invalid contact public key is rejected`() { + let contacts = [Self.contact(name: "BadKey", publicKey: "abcd")] + #expect { + _ = try Self.plan(contacts: contacts, sections: Self.contactSections()) + } throws: { error in + if case NodeConfigServiceError.invalidContactPublicKey(name: "BadKey") = error { return true } + return false + } + } + + @Test + func `Unknown contact type byte is preserved verbatim while UI type falls back to chat`() throws { + let contacts = [Self.contact(type: 99, name: "FutureType", publicKey: Self.pubKeyHexA)] + let plan = try Self.plan(contacts: contacts, sections: Self.contactSections()) + let record = try #require(plan.contactRecords.first) + #expect(record.typeRawValue == 99) + #expect(record.type == .chat) + } + + @Test + func `Absent out_path plans flood routing; empty string plans direct`() throws { + let flood = try Self.plan( + contacts: [Self.contact(name: "Flood", publicKey: Self.pubKeyHexA, outPath: nil)], + sections: Self.contactSections() + ) + #expect(flood.contactRecords.first?.outPathLength == 0xFF) + + let direct = try Self.plan( + contacts: [Self.contact(name: "Direct", publicKey: Self.pubKeyHexB, outPath: "")], + sections: Self.contactSections() + ) + #expect(direct.contactRecords.first?.outPathLength == 0) + } + + @Test + func `Valid out_path resolves to its bytes and encoded length for each hash mode`() throws { + // Expected encoded length is a pinned literal (mode << 6 | hopCount), not a re-derivation of + // the planner's own encodePathLen, so a bug in that encoder cannot be mirrored into the value. + let cases: [(mode: UInt8, hex: String, bytes: [UInt8], length: UInt8)] = [ + (0, "aabb", [0xAA, 0xBB], 0x02), + (1, "aabbccdd", [0xAA, 0xBB, 0xCC, 0xDD], 0x42), + (2, "aabbccddeeff", [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF], 0x82), + ] + for testCase in cases { + let plan = try Self.plan( + contacts: [Self.contact( + name: "Routed", publicKey: Self.pubKeyHexA, + outPath: testCase.hex, pathHashMode: testCase.mode + )], + sections: Self.contactSections() + ) + let record = try #require(plan.contactRecords.first) + #expect(record.outPath == Data(testCase.bytes)) + #expect(record.outPathLength == testCase.length) + } + } + + // MARK: - Empty-but-present sections / identity carry-through + + @Test + func `Empty-but-present channel and contact arrays plan no writes and no overwrite`() throws { + let sections = ConfigSections( + nodeIdentity: false, radioSettings: false, positionSettings: false, + otherSettings: false, channels: true, contacts: true + ) + let plan = try Self.plan(channels: [], contacts: [], sections: sections) + #expect(plan.channelWrites.isEmpty) + #expect(plan.contactRecords.isEmpty) + #expect(plan.channelsOverwriteExisting == false) + } + + @Test + func `Identity plan carries a non-nil node name verbatim`() throws { + let plan = try Self.plan(name: "Rescue Base", sections: Self.identitySections()) + #expect(plan.nodeName == "Rescue Base") + } + + // MARK: - M1: byte-identical channel slot skip + + @Test + func `A channel byte-identical to its resolved slot plans no write`() throws { + var slots = Self.emptySlots(8) + slots[0] = DeviceChannelSlot(index: 0, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) + + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretA)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections(), existingChannels: slots) + + #expect(plan.channelWrites.isEmpty, "An identical slot must not re-commit /channels2") + #expect(plan.channelsOverwriteExisting == false) + } + + @Test + func `A name-only diff, secret-only diff, and secret relocation each still plan one write`() throws { + var nameDiff = Self.emptySlots(8) + nameDiff[0] = DeviceChannelSlot(index: 0, name: "Old", secret: Self.secretBytesA, isConfigured: true) + let nameDiffPlan = try Self.plan( + channels: [MeshCoreNodeConfig.ChannelConfig(name: "New", secret: Self.validChannelSecretA)], + sections: Self.channelSections(), existingChannels: nameDiff + ) + #expect(nameDiffPlan.channelWrites.count == 1, "A name change must still write") + + var secretDiff = Self.emptySlots(8) + secretDiff[0] = DeviceChannelSlot(index: 0, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) + let secretDiffPlan = try Self.plan( + channels: [MeshCoreNodeConfig.ChannelConfig(name: "Alpha", secret: Self.validChannelSecretB)], + sections: Self.channelSections(), existingChannels: secretDiff + ) + #expect(secretDiffPlan.channelWrites.count == 1, "A secret change must still write") + + // Secret A is homed at slot 2 under a different name: the import folds onto that slot and + // writes because the name differs, so the skip must not swallow it. + var relocate = Self.emptySlots(8) + relocate[2] = DeviceChannelSlot(index: 2, name: "Alpha", secret: Self.secretBytesA, isConfigured: true) + let relocatePlan = try Self.plan( + channels: [MeshCoreNodeConfig.ChannelConfig(name: "Renamed", secret: Self.validChannelSecretA)], + sections: Self.channelSections(), existingChannels: relocate + ) + #expect(relocatePlan.channelWrites.count == 1, "A name change on the secret's existing slot still writes") + #expect(relocatePlan.channelWrites.first?.index == 2, "It folds onto the secret's existing slot") + } + + @Test + func `A brand-new channel into an empty slot still plans one write`() throws { + let channels = [MeshCoreNodeConfig.ChannelConfig(name: "#new", secret: Self.validChannelSecretA)] + let plan = try Self.plan(channels: channels, sections: Self.channelSections()) + #expect(plan.channelWrites.count == 1) + } + + @Test + func `A duplicate restoring a slot an earlier write changed is not swallowed by the skip`() throws { + // Same #hashtag homed at slot 0: the first entry changes the secret, the second restores + // the device's original secret. The skip must compare against the slot's planned value, not + // the frozen original, so the restoring write survives and wins last (slot ends at S1). + var hashtagSlots = Self.emptySlots(8) + hashtagSlots[0] = DeviceChannelSlot(index: 0, name: "#general", secret: Self.secretBytesA, isConfigured: true) + let hashtagPlan = try Self.plan( + channels: [ + MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretB), + MeshCoreNodeConfig.ChannelConfig(name: "#general", secret: Self.validChannelSecretA), + ], + sections: Self.channelSections(), existingChannels: hashtagSlots + ) + #expect(hashtagPlan.channelWrites.count == 2, "Both folded writes are planned, last wins") + #expect(hashtagPlan.channelWrites.last?.secret == Self.secretBytesA, "The restoring write must win, not be dropped") + + // Same-secret name fold: the first entry renames the slot, the second restores the device's + // original name. The restoring write must survive so the slot ends at "Foo", not "Bar". + var secretSlots = Self.emptySlots(8) + secretSlots[0] = DeviceChannelSlot(index: 0, name: "Foo", secret: Self.secretBytesA, isConfigured: true) + let secretPlan = try Self.plan( + channels: [ + MeshCoreNodeConfig.ChannelConfig(name: "Bar", secret: Self.validChannelSecretA), + MeshCoreNodeConfig.ChannelConfig(name: "Foo", secret: Self.validChannelSecretA), + ], + sections: Self.channelSections(), existingChannels: secretSlots + ) + #expect(secretPlan.channelWrites.count == 2, "Both folded writes are planned, last wins") + #expect(secretPlan.channelWrites.last?.name == "Foo", "The restoring write must win, not be dropped") + } + + // MARK: - M2: byte-identical contact skip + + /// Models the device-resident form of a contact `session.getContacts` would report: the planner's + /// record encoded to the wire and decoded back through the same `parseContactData` the live read + /// uses, so the fixture reflects the device's stored bytes (name trimmed to the field width, coords + /// scaled, path sliced) rather than the planner's own `buildContactRecord` output. `lastModified` + /// models the firmware restamp the sub-148-byte add frame triggers: it defaults to the record's own + /// value (the same-device export/re-import round-trip the skip targets), or pass a different value to + /// model a foreign config the firmware re-stamped. + private func deviceStored(_ record: MeshContact, lastModified deviceLastModified: Date? = nil) -> MeshContact { + // The add frame omits last_modified (3 reserved bytes); the contact-response frame carries it at + // offset 143. Reuse the add encoder for offsets 0..<143, then append the device's last_modified. + var frame = Data(PacketBuilder.updateContact(record).dropFirst(1).prefix(143)) + frame.appendLittleEndian(UInt32((deviceLastModified ?? record.lastModified).timeIntervalSince1970)) + return Parsers.parseContactData(frame)! + } + + /// Runs the import once against an empty device to capture the record the planner builds, then + /// returns the device-resident form `getContacts` would report for it. + private func recordFor(_ contact: MeshCoreNodeConfig.ContactConfig) throws -> MeshContact { + let plan = try Self.plan(contacts: [contact], sections: Self.contactSections()) + return try deviceStored(#require(plan.contactRecords.first)) + } + + @Test + func `A contact equal on all persisted fields is dropped`() throws { + let config = Self.contact( + name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", + lastModified: 1000, outPath: "aabb", pathHashMode: 0 + ) + let existing = try recordFor(config) + + let plan = try Self.plan( + contacts: [config], sections: Self.contactSections(), + existingContacts: [existing.id: existing] + ) + #expect(plan.contactRecords.isEmpty, "A byte-identical contact must not re-commit /contacts3") + } + + @Test + func `A diff in any single persisted field still emits the contact`() throws { + let base = Self.contact( + name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", + lastModified: 1000, outPath: "aabb", pathHashMode: 0 + ) + let existing = try recordFor(base) + + let variants: [(label: String, config: MeshCoreNodeConfig.ContactConfig)] = [ + ("type", Self.contact(type: 2, name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), + ("name", Self.contact(name: "Charlie", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), + ("path", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 1000, outPath: "ccdd", pathHashMode: 0)), + ("coords", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "48.0", longitude: "-122.5", lastModified: 1000, outPath: "aabb", pathHashMode: 0)), + ("lastModified", Self.contact(name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", lastModified: 2000, outPath: "aabb", pathHashMode: 0)), + ] + for variant in variants { + let plan = try Self.plan( + contacts: [variant.config], sections: Self.contactSections(), + existingContacts: [existing.id: existing] + ) + #expect(plan.contactRecords.count == 1, "A \(variant.label) diff must still write the contact") + } + } + + @Test + func `A name differing only past the firmware field width is treated as equal and dropped`() throws { + let shortConfig = Self.contact( + name: "#" + String(repeating: "a", count: 30), publicKey: Self.pubKeyHexA + ) + let existing = try recordFor(shortConfig) // 31-byte name, what the device stores + + // Import the same contact whose name only diverges past the 31-byte field width. + let longConfig = Self.contact( + name: "#" + String(repeating: "a", count: 30) + "EXTRA", publicKey: Self.pubKeyHexA + ) + let plan = try Self.plan( + contacts: [longConfig], sections: Self.contactSections(), + existingContacts: [existing.id: existing] + ) + #expect(plan.contactRecords.isEmpty, "A name the device cannot represent differently must be treated as equal") + } + + @Test + func `A new contact is unaffected by the skip and capacity stays correct`() throws { + // One existing key matches its import (dropped); a second key is new (emitted). + let matchConfig = Self.contact(name: "Match", publicKey: Self.pubKeyHexA, lastModified: 5) + let existing = try recordFor(matchConfig) + let newConfig = Self.contact(name: "Fresh", publicKey: Self.pubKeyHexB, lastModified: 5) + + let plan = try Self.plan( + contacts: [matchConfig, newConfig], sections: Self.contactSections(), + existingContacts: [existing.id: existing] + ) + #expect(plan.contactRecords.count == 1, "Only the unchanged contact is dropped") + #expect(plan.contactRecords.first?.advertisedName == "Fresh") + } + + @Test + func `A contact the firmware re-stamped is re-emitted, not dropped (skip is safe-fail)`() throws { + // The sub-148-byte contact-add frame carries no last_modified, so firmware stamps its own clock. + // For a config exported from a different device, the device-read last_modified therefore differs + // from the config's, and the skip must re-emit rather than drop: the optimization only fires for + // a same-device export/re-import round-trip, never for a foreign config the firmware re-stamped. + let config = Self.contact( + name: "Bravo", publicKey: Self.pubKeyHexA, latitude: "47.5", longitude: "-122.5", + lastModified: 1000, outPath: "aabb", pathHashMode: 0 + ) + let record = try #require( + try Self.plan(contacts: [config], sections: Self.contactSections()).contactRecords.first + ) + let existing = deviceStored(record, lastModified: Date(timeIntervalSince1970: 9999)) + + let plan = try Self.plan( + contacts: [config], sections: Self.contactSections(), + existingContacts: [existing.id: existing] + ) + #expect(plan.contactRecords.count == 1, "A re-stamped last_modified must re-emit, never silently drop") + } } // MARK: - Encoder no-trap coverage @Suite("PacketBuilder coordinate/timestamp encoders do not trap") struct PacketBuilderEncoderTests { - - private func int32LE(_ data: Data, at offset: Int) -> Int32 { - var value: UInt32 = 0 - for byte in 0..<4 { - value |= UInt32(data[data.startIndex + offset + byte]) << (8 * byte) - } - return Int32(bitPattern: value) - } - - private func uint32LE(_ data: Data, at offset: Int) -> UInt32 { - var value: UInt32 = 0 - for byte in 0..<4 { - value |= UInt32(data[data.startIndex + offset + byte]) << (8 * byte) - } - return value - } - - @Test("setCoordinates clamps NaN to zero instead of trapping") - func setCoordinatesNaN() { - let data = PacketBuilder.setCoordinates(latitude: .nan, longitude: .nan) - #expect(int32LE(data, at: 1) == 0) - #expect(int32LE(data, at: 5) == 0) - } - - @Test("setCoordinates clamps out-of-range degrees to the valid bounds") - func setCoordinatesOutOfRange() { - let data = PacketBuilder.setCoordinates(latitude: 9999, longitude: -9999) - // 90 * 1_000_000 and -180 * 1_000_000 - #expect(int32LE(data, at: 1) == 90_000_000) - #expect(int32LE(data, at: 5) == -180_000_000) - } - - @Test("setCoordinates handles infinity without trapping") - func setCoordinatesInfinity() { - let data = PacketBuilder.setCoordinates(latitude: .infinity, longitude: -.infinity) - #expect(int32LE(data, at: 1) == 0) - #expect(int32LE(data, at: 5) == 0) - } - - @Test("updateContact saturates a pre-1970 advertisement timestamp to zero") - func updateContactNegativeTimestamp() { - let contact = MeshContact( - id: "neg", publicKey: Data(repeating: 1, count: 32), - type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), - advertisedName: "Old", - lastAdvertisement: Date(timeIntervalSince1970: -1_000_000), - latitude: 0, longitude: 0, lastModified: .now - ) - let data = PacketBuilder.updateContact(contact) - #expect(uint32LE(data, at: 132) == 0) - } - - @Test("updateContact saturates a post-2106 advertisement timestamp to UInt32.max") - func updateContactOverflowTimestamp() { - let farFuture = Date(timeIntervalSince1970: TimeInterval(UInt32.max) + 1_000_000) - let contact = MeshContact( - id: "future", publicKey: Data(repeating: 1, count: 32), - type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), - advertisedName: "Future", - lastAdvertisement: farFuture, - latitude: 0, longitude: 0, lastModified: .now - ) - let data = PacketBuilder.updateContact(contact) - #expect(uint32LE(data, at: 132) == UInt32.max) - } - - @Test("updateContact clamps out-of-range coordinates without trapping") - func updateContactCoordinateClamp() { - let contact = MeshContact( - id: "coord", publicKey: Data(repeating: 1, count: 32), - type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), - advertisedName: "Coord", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), - latitude: .nan, longitude: 9999, lastModified: .now - ) - let data = PacketBuilder.updateContact(contact) - #expect(int32LE(data, at: 136) == 0) - #expect(int32LE(data, at: 140) == 180_000_000) - } - - /// `addContact` now encodes via `PacketBuilder.updateContact`. Pin the frame length so a - /// silent layout drift can't change the wire size firmware validates against. - @Test("updateContact emits a 147-byte frame") - func updateContactFrameLength() { - let contact = MeshContact( - id: "len", publicKey: Data(repeating: 1, count: 32), - type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), - advertisedName: "Len", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), - latitude: 0, longitude: 0, lastModified: .now - ) - #expect(PacketBuilder.updateContact(contact).count == 147) - } - - /// Modeled types must encode their raw byte at the type offset unchanged, and an unmodeled - /// byte must pass through verbatim — the type byte sits at packet offset 33 (cmd + 32-byte key). - @Test("updateContact writes the raw type byte verbatim for modeled and unmodeled types") - func updateContactPreservesRawTypeByte() { - for raw: UInt8 in [0x01, 0x02, 0x03, 0x04, 0x99] { - let contact = MeshContact( - id: "type", publicKey: Data(repeating: 1, count: 32), - type: ContactType(rawValue: raw) ?? .chat, typeRawValue: raw, - flags: [], outPathLength: 0xFF, outPath: Data(), - advertisedName: "T", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), - latitude: 0, longitude: 0, lastModified: .now - ) - let data = PacketBuilder.updateContact(contact) - #expect(data[data.startIndex + 33] == raw) - } - } + private func int32LE(_ data: Data, at offset: Int) -> Int32 { + var value: UInt32 = 0 + for byte in 0..<4 { + value |= UInt32(data[data.startIndex + offset + byte]) << (8 * byte) + } + return Int32(bitPattern: value) + } + + private func uint32LE(_ data: Data, at offset: Int) -> UInt32 { + var value: UInt32 = 0 + for byte in 0..<4 { + value |= UInt32(data[data.startIndex + offset + byte]) << (8 * byte) + } + return value + } + + @Test + func `setCoordinates clamps NaN to zero instead of trapping`() { + let data = PacketBuilder.setCoordinates(latitude: .nan, longitude: .nan) + #expect(int32LE(data, at: 1) == 0) + #expect(int32LE(data, at: 5) == 0) + } + + @Test + func `setCoordinates clamps out-of-range degrees to the valid bounds`() { + let data = PacketBuilder.setCoordinates(latitude: 9999, longitude: -9999) + // 90 * 1_000_000 and -180 * 1_000_000 + #expect(int32LE(data, at: 1) == 90_000_000) + #expect(int32LE(data, at: 5) == -180_000_000) + } + + @Test + func `setCoordinates handles infinity without trapping`() { + let data = PacketBuilder.setCoordinates(latitude: .infinity, longitude: -.infinity) + #expect(int32LE(data, at: 1) == 0) + #expect(int32LE(data, at: 5) == 0) + } + + @Test + func `updateContact saturates a pre-1970 advertisement timestamp to zero`() { + let contact = MeshContact( + id: "neg", publicKey: Data(repeating: 1, count: 32), + type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), + advertisedName: "Old", + lastAdvertisement: Date(timeIntervalSince1970: -1_000_000), + latitude: 0, longitude: 0, lastModified: .now + ) + let data = PacketBuilder.updateContact(contact) + #expect(uint32LE(data, at: 132) == 0) + } + + @Test + func `updateContact saturates a post-2106 advertisement timestamp to UInt32.max`() { + let farFuture = Date(timeIntervalSince1970: TimeInterval(UInt32.max) + 1_000_000) + let contact = MeshContact( + id: "future", publicKey: Data(repeating: 1, count: 32), + type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), + advertisedName: "Future", + lastAdvertisement: farFuture, + latitude: 0, longitude: 0, lastModified: .now + ) + let data = PacketBuilder.updateContact(contact) + #expect(uint32LE(data, at: 132) == UInt32.max) + } + + @Test + func `updateContact clamps out-of-range coordinates without trapping`() { + let contact = MeshContact( + id: "coord", publicKey: Data(repeating: 1, count: 32), + type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), + advertisedName: "Coord", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: .nan, longitude: 9999, lastModified: .now + ) + let data = PacketBuilder.updateContact(contact) + #expect(int32LE(data, at: 136) == 0) + #expect(int32LE(data, at: 140) == 180_000_000) + } + + /// `addContact` now encodes via `PacketBuilder.updateContact`. Pin the frame length so a + /// silent layout drift can't change the wire size firmware validates against. + @Test + func `updateContact emits a 147-byte frame`() { + let contact = MeshContact( + id: "len", publicKey: Data(repeating: 1, count: 32), + type: .chat, flags: [], outPathLength: 0xFF, outPath: Data(), + advertisedName: "Len", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: 0, longitude: 0, lastModified: .now + ) + #expect(PacketBuilder.updateContact(contact).count == 147) + } + + /// Modeled types must encode their raw byte at the type offset unchanged, and an unmodeled + /// byte must pass through verbatim — the type byte sits at packet offset 33 (cmd + 32-byte key). + @Test + func `updateContact writes the raw type byte verbatim for modeled and unmodeled types`() { + for raw: UInt8 in [0x01, 0x02, 0x03, 0x04, 0x99] { + let contact = MeshContact( + id: "type", publicKey: Data(repeating: 1, count: 32), + type: ContactType(rawValue: raw) ?? .chat, typeRawValue: raw, + flags: [], outPathLength: 0xFF, outPath: Data(), + advertisedName: "T", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: 0, longitude: 0, lastModified: .now + ) + let data = PacketBuilder.updateContact(contact) + #expect(data[data.startIndex + 33] == raw) + } + } } // MARK: - Contact type-byte preservation @@ -974,68 +988,69 @@ struct PacketBuilderEncoderTests { /// coerced onto a modeled `ContactType` at any hop. @Suite("Contact type-byte round-trips through decode, cache, and export") struct ContactTypeBytePreservationTests { - - /// Builds a 147-byte contact structure (no command byte) as `parseContactData` consumes it. - private func contactBytes(typeByte: UInt8) -> Data { - var data = Data(repeating: 0, count: 147) - for i in 0..<32 { data[i] = 0xAB } // public key - data[32] = typeByte // type - data[34] = 0xFF // path length = flood - return data - } - - @Test("parseContactData preserves an unmodeled type byte while falling back to .chat") - func decodePreservesUnmodeledByte() throws { - let contact = try #require(Parsers.parseContactData(contactBytes(typeByte: 0x04))) - #expect(contact.typeRawValue == 0x04) - #expect(contact.type == .chat) - } - - @Test("parseContactData keeps modeled type bytes and their enum in sync") - func decodePreservesModeledBytes() throws { - for (raw, expected): (UInt8, ContactType) in [(0x01, .chat), (0x02, .repeater), (0x03, .room)] { - let contact = try #require(Parsers.parseContactData(contactBytes(typeByte: raw))) - #expect(contact.typeRawValue == raw) - #expect(contact.type == expected) - } - } - - @Test("ContactFrame ↔ MeshContact ↔ Contact/ContactDTO and export all keep 0x04") - func roundTripPreservesUnmodeledByte() throws { - let radioID = UUID() - let frame = ContactFrame( - publicKey: Data(repeating: 0xAB, count: 32), - type: .chat, typeRawValue: 0x04, flags: 0, - outPathLength: 0xFF, outPath: Data(), name: "FutureType", - lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0 - ) - - // Frame → MeshContact → Frame - let meshContact = frame.toMeshContact() - #expect(meshContact.typeRawValue == 0x04) - #expect(meshContact.toContactFrame().typeRawValue == 0x04) - - // Frame → Contact @Model → Frame, and through the DTO - let contact = Contact(radioID: radioID, from: frame) - #expect(contact.typeRawValue == 0x04) - #expect(contact.toContactFrame().typeRawValue == 0x04) - #expect(ContactDTO(from: contact).toContactFrame().typeRawValue == 0x04) - - // Export re-emits the raw byte - #expect(NodeConfigService.buildContactConfig(from: meshContact).type == 0x04) - } - - @Test("Export and decode stay byte-identical for modeled types") - func exportPreservesModeledBytes() { - for raw: UInt8 in [0x01, 0x02, 0x03] { - let meshContact = MeshContact( - id: "m", publicKey: Data(repeating: 0xAB, count: 32), - type: ContactType(rawValue: raw)!, typeRawValue: raw, - flags: [], outPathLength: 0xFF, outPath: Data(), advertisedName: "M", - lastAdvertisement: Date(timeIntervalSince1970: 0), - latitude: 0, longitude: 0, lastModified: Date(timeIntervalSince1970: 0) - ) - #expect(NodeConfigService.buildContactConfig(from: meshContact).type == raw) - } - } + /// Builds a 147-byte contact structure (no command byte) as `parseContactData` consumes it. + private func contactBytes(typeByte: UInt8) -> Data { + var data = Data(repeating: 0, count: 147) + for i in 0..<32 { + data[i] = 0xAB + } // public key + data[32] = typeByte // type + data[34] = 0xFF // path length = flood + return data + } + + @Test + func `parseContactData preserves an unmodeled type byte while falling back to .chat`() throws { + let contact = try #require(Parsers.parseContactData(contactBytes(typeByte: 0x04))) + #expect(contact.typeRawValue == 0x04) + #expect(contact.type == .chat) + } + + @Test + func `parseContactData keeps modeled type bytes and their enum in sync`() throws { + for (raw, expected): (UInt8, ContactType) in [(0x01, .chat), (0x02, .repeater), (0x03, .room)] { + let contact = try #require(Parsers.parseContactData(contactBytes(typeByte: raw))) + #expect(contact.typeRawValue == raw) + #expect(contact.type == expected) + } + } + + @Test + func `ContactFrame ↔ MeshContact ↔ Contact/ContactDTO and export all keep 0x04`() { + let radioID = UUID() + let frame = ContactFrame( + publicKey: Data(repeating: 0xAB, count: 32), + type: .chat, typeRawValue: 0x04, flags: 0, + outPathLength: 0xFF, outPath: Data(), name: "FutureType", + lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0 + ) + + // Frame → MeshContact → Frame + let meshContact = frame.toMeshContact() + #expect(meshContact.typeRawValue == 0x04) + #expect(meshContact.toContactFrame().typeRawValue == 0x04) + + // Frame → Contact @Model → Frame, and through the DTO + let contact = Contact(radioID: radioID, from: frame) + #expect(contact.typeRawValue == 0x04) + #expect(contact.toContactFrame().typeRawValue == 0x04) + #expect(ContactDTO(from: contact).toContactFrame().typeRawValue == 0x04) + + // Export re-emits the raw byte + #expect(NodeConfigService.buildContactConfig(from: meshContact).type == 0x04) + } + + @Test + func `Export and decode stay byte-identical for modeled types`() throws { + for raw: UInt8 in [0x01, 0x02, 0x03] { + let meshContact = try MeshContact( + id: "m", publicKey: Data(repeating: 0xAB, count: 32), + type: #require(ContactType(rawValue: raw)), typeRawValue: raw, + flags: [], outPathLength: 0xFF, outPath: Data(), advertisedName: "M", + lastAdvertisement: Date(timeIntervalSince1970: 0), + latitude: 0, longitude: 0, lastModified: Date(timeIntervalSince1970: 0) + ) + #expect(NodeConfigService.buildContactConfig(from: meshContact).type == raw) + } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigServiceTests.swift index 31fc1867..b0b01290 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigServiceTests.swift @@ -1,919 +1,927 @@ import Foundation -import OSLog -import Testing @testable import MC1Services @testable import MeshCore +import OSLog +import Testing @Suite("NodeConfigService Tests") struct NodeConfigServiceTests { - - // MARK: - Test Data - - private static let testSelfInfo = SelfInfo( - advertisementType: 1, - txPower: 22, - maxTxPower: 30, - publicKey: Data(repeating: 0xAB, count: 32), - latitude: 47.6062, - longitude: -122.3321, - multiAcks: 2, - advertisementLocationPolicy: 1, - telemetryModeEnvironment: 3, - telemetryModeLocation: 2, - telemetryModeBase: 1, - manualAddContacts: false, - radioFrequency: 910.525, - radioBandwidth: 62.5, - radioSpreadingFactor: 7, - radioCodingRate: 5, - name: "TestNode" + // MARK: - Test Data + + private static let testSelfInfo = SelfInfo( + advertisementType: 1, + txPower: 22, + maxTxPower: 30, + publicKey: Data(repeating: 0xAB, count: 32), + latitude: 47.6062, + longitude: -122.3321, + multiAcks: 2, + advertisementLocationPolicy: 1, + telemetryModeEnvironment: 3, + telemetryModeLocation: 2, + telemetryModeBase: 1, + manualAddContacts: false, + radioFrequency: 910.525, + radioBandwidth: 62.5, + radioSpreadingFactor: 7, + radioCodingRate: 5, + name: "TestNode" + ) + + private static let testContact = MeshContact( + id: Data(repeating: 0x01, count: 32).hexString, + publicKey: Data(repeating: 0x01, count: 32), + type: .chat, + flags: ContactFlags(rawValue: 0x02), + outPathLength: 3, + outPath: Data([0xAA, 0xBB, 0xCC]), + advertisedName: "RemoteNode", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: 47.43, + longitude: -120.36, + lastModified: Date(timeIntervalSince1970: 1_700_000_100) + ) + + private static let floodContact = MeshContact( + id: Data(repeating: 0x02, count: 32).hexString, + publicKey: Data(repeating: 0x02, count: 32), + type: .repeater, + flags: [], + outPathLength: 0xFF, + outPath: Data(), + advertisedName: "FloodNode", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_001_000), + latitude: 0, + longitude: 0, + lastModified: Date(timeIntervalSince1970: 1_700_001_100) + ) + + private static let zeroPathContact = MeshContact( + id: Data(repeating: 0x03, count: 32).hexString, + publicKey: Data(repeating: 0x03, count: 32), + type: .chat, + flags: [], + outPathLength: 0, + outPath: Data(), + advertisedName: "DirectNode", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_002_000), + latitude: 0, + longitude: 0, + lastModified: Date(timeIntervalSince1970: 1_700_002_100) + ) + + // MARK: - buildRadioSettings + + @Test + func `buildRadioSettings converts MHz frequency to kHz`() { + let radio = NodeConfigService.buildRadioSettings(from: Self.testSelfInfo) + + // 910.525 MHz → 910525 kHz + #expect(radio.frequency == 910_525) + } + + @Test + func `buildRadioSettings converts kHz bandwidth to Hz`() { + let radio = NodeConfigService.buildRadioSettings(from: Self.testSelfInfo) + + // 62.5 kHz → 62500 Hz + #expect(radio.bandwidth == 62500) + } + + @Test + func `buildRadioSettings copies spreading factor, coding rate, and tx power`() { + let radio = NodeConfigService.buildRadioSettings(from: Self.testSelfInfo) + + #expect(radio.spreadingFactor == 7) + #expect(radio.codingRate == 5) + #expect(radio.txPower == 22) + } + + @Test + func `buildRadioSettings rounds frequency to the nearest kHz`() { + let info = SelfInfo( + advertisementType: 1, + txPower: 22, + maxTxPower: 30, + publicKey: Data(repeating: 0xAB, count: 32), + latitude: 0, + longitude: 0, + multiAcks: 0, + advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, + telemetryModeLocation: 0, + telemetryModeBase: 0, + manualAddContacts: false, + radioFrequency: 512.002, + radioBandwidth: 62.5, + radioSpreadingFactor: 7, + radioCodingRate: 5, + name: "TestNode" ) - - private static let testContact = MeshContact( - id: Data(repeating: 0x01, count: 32).hexString, - publicKey: Data(repeating: 0x01, count: 32), - type: .chat, - flags: ContactFlags(rawValue: 0x02), - outPathLength: 3, - outPath: Data([0xAA, 0xBB, 0xCC]), - advertisedName: "RemoteNode", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), - latitude: 47.43, - longitude: -120.36, - lastModified: Date(timeIntervalSince1970: 1_700_000_100) + let radio = NodeConfigService.buildRadioSettings(from: info) + + // Truncation would yield 512001; rounding restores the representable 512002. + #expect(radio.frequency == 512_002) + } + + // MARK: - buildOtherSettings + + @Test + func `buildOtherSettings maps manualAddContacts=false to 0`() { + let other = NodeConfigService.buildOtherSettings(from: Self.testSelfInfo) + #expect(other.manualAddContacts == 0) + } + + @Test + func `buildOtherSettings maps manualAddContacts=true to 1`() { + let info = SelfInfo( + advertisementType: 0, txPower: 10, maxTxPower: 30, + publicKey: Data(repeating: 0, count: 32), + latitude: 0, longitude: 0, multiAcks: 0, + advertisementLocationPolicy: 0, telemetryModeEnvironment: 0, + telemetryModeLocation: 0, telemetryModeBase: 0, + manualAddContacts: true, + radioFrequency: 910.525, radioBandwidth: 62.5, + radioSpreadingFactor: 7, radioCodingRate: 5, name: "Test" ) - private static let floodContact = MeshContact( - id: Data(repeating: 0x02, count: 32).hexString, - publicKey: Data(repeating: 0x02, count: 32), - type: .repeater, - flags: [], - outPathLength: 0xFF, - outPath: Data(), - advertisedName: "FloodNode", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_001_000), - latitude: 0, - longitude: 0, - lastModified: Date(timeIntervalSince1970: 1_700_001_100) + let other = NodeConfigService.buildOtherSettings(from: info) + #expect(other.manualAddContacts == 1) + } + + @Test + func `buildOtherSettings exports only 2 companion-app fields`() { + let other = NodeConfigService.buildOtherSettings(from: Self.testSelfInfo) + + #expect(other.manualAddContacts == 0) + #expect(other.advertLocationPolicy == 1) + #expect(other.telemetryModeBase == nil) + #expect(other.telemetryModeLocation == nil) + #expect(other.telemetryModeEnvironment == nil) + #expect(other.multiAcks == nil) + #expect(other.advertisementType == nil) + } + + // MARK: - buildContactConfig + + @Test + func `buildContactConfig populates all fields from MeshContact`() { + let config = NodeConfigService.buildContactConfig(from: Self.testContact) + + #expect(config.type == 1) + #expect(config.name == "RemoteNode") + #expect(config.publicKey == Data(repeating: 0x01, count: 32).hexString) + #expect(config.flags == 0x02) + #expect(config.latitude == "47.43") + #expect(config.longitude == "-120.36") + #expect(config.lastAdvert == 1_700_000_000) + #expect(config.lastModified == 1_700_000_100) + } + + @Test + func `buildContactConfig includes hex outPath for routed contacts`() { + let config = NodeConfigService.buildContactConfig(from: Self.testContact) + #expect(config.outPath == "aabbcc") + } + + @Test + func `buildContactConfig uses nil outPath for flood routing`() { + let config = NodeConfigService.buildContactConfig(from: Self.floodContact) + #expect(config.outPath == nil) + } + + @Test + func `buildContactConfig uses empty string outPath for direct (zero-length) path`() { + let config = NodeConfigService.buildContactConfig(from: Self.zeroPathContact) + #expect(config.outPath == "") + } + + @Test + func `buildContactConfig truncates outPath to outPathLength bytes`() { + // Contact with outPathLength=2 but outPath has 4 bytes + let contact = MeshContact( + id: "test", + publicKey: Data(repeating: 0x04, count: 32), + type: .chat, flags: [], outPathLength: 2, + outPath: Data([0xAA, 0xBB, 0xCC, 0xDD]), + advertisedName: "Truncated", + lastAdvertisement: .now, latitude: 0, longitude: 0, + lastModified: .now ) - private static let zeroPathContact = MeshContact( - id: Data(repeating: 0x03, count: 32).hexString, - publicKey: Data(repeating: 0x03, count: 32), - type: .chat, - flags: [], - outPathLength: 0, - outPath: Data(), - advertisedName: "DirectNode", - lastAdvertisement: Date(timeIntervalSince1970: 1_700_002_000), - latitude: 0, - longitude: 0, - lastModified: Date(timeIntervalSince1970: 1_700_002_100) + let config = NodeConfigService.buildContactConfig(from: contact) + #expect(config.outPath == "aabb") + } + + // MARK: - Step counting (mirrors the write gates, driven by the resolved plan) + + private static func emptySlots(_ count: UInt8) -> [DeviceChannelSlot] { + (0.. [DeviceChannelSlot] { - (0.. 2 as invalid") - func invalidPathHashModeRejected() throws { - let json = """ - { - "type": 1, "name": "BadMode", "public_key": "\(String(repeating: "ab", count: 32))", - "flags": 0, "latitude": "0", "longitude": "0", - "last_advert": 0, "last_modified": 0, - "out_path": "aabbcc", - "path_hash_mode": 3 - } - """ - let config = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: Data(json.utf8)) - #expect(config.pathHashMode == 3) - - // The mode validation guard should reject values > 2 - let mode = config.pathHashMode ?? 0 - #expect(mode > 2) - } - - // MARK: - ImportProgress - - @Test("ImportProgress stores step info") - func importProgressFields() { - let progress = ImportProgress(step: .contact(name: "Alice"), current: 3, total: 10) - #expect(progress.step == .contact(name: "Alice")) - #expect(progress.current == 3) - #expect(progress.total == 10) - } - - // MARK: - Post-Identity Resolution Seam - - @Test("resolveEffectiveRadioID returns callback result when private key was imported") - func resolveReturnsCallbackResult() async throws { - let original = UUID() - let reconciled = UUID() - let result = try await resolveEffectiveRadioID( - original: original, - didImportPrivateKey: true, - callback: { @Sendable in reconciled } - ) - #expect(result == reconciled) - } - - @Test("resolveEffectiveRadioID skips callback when no private key was imported") - func resolveSkipsCallbackWhenNoPrivateKey() async throws { - actor CallTracker { - var calls = 0 - func bump() { calls += 1 } - } - let tracker = CallTracker() - let original = UUID() - let result = try await resolveEffectiveRadioID( - original: original, - didImportPrivateKey: false, - callback: { @Sendable in - await tracker.bump() - return UUID() - } - ) - #expect(result == original) - #expect(await tracker.calls == 0, - "Callback must not fire when no private key was imported") - } - - @Test("resolveEffectiveRadioID returns original when callback returns nil") - func resolveReturnsOriginalWhenCallbackReturnsNil() async throws { - let original = UUID() - let result = try await resolveEffectiveRadioID( - original: original, - didImportPrivateKey: true, - callback: { @Sendable in nil } - ) - #expect(result == original) - } - - @Test("resolveEffectiveRadioID handles nil callback gracefully") - func resolveHandlesNilCallback() async throws { - let original = UUID() - let result = try await resolveEffectiveRadioID( - original: original, - didImportPrivateKey: true, - callback: nil - ) - #expect(result == original) - } - - // MARK: - Execute orchestration seam + // MARK: - OtherSettings merge logic - private static let executeSections = ConfigSections( - nodeIdentity: true, radioSettings: false, positionSettings: false, - otherSettings: false, channels: true, contacts: true + @Test + func `Partial OtherSettings fills missing fields from current device values`() { + // Imported config has only 2 of 7 fields (companion app style) + let imported = MeshCoreNodeConfig.OtherSettings( + manualAddContacts: 1, + advertLocationPolicy: 0 ) - private static let executeLogger = Logger(subsystem: "test", category: "NodeConfigExecuteTests") - - /// A plan with identity, two channels, and one contact, so the seam exercises identity-first - /// ordering and per-write progress. Built through the real planner. - private static func executePlan() throws -> ConfigImportPlan { - let config = MeshCoreNodeConfig( - name: "Node", - privateKey: String(repeating: "ab", count: 64), - channels: [ - .init(name: "Ch1", secret: "00112233445566778899aabbccddeeff"), - .init(name: "Ch2", secret: "ffeeddccbbaa99887766554433221100"), - ], - contacts: [ - .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), - flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), - ] - ) - return try planConfigImport( - config: config, sections: executeSections, - maxChannels: 8, maxContacts: 100, maxTxPower: 30, - existingChannels: emptySlots(8), existingContacts: [:] - ) - } - - private static let radioExecuteSections = ConfigSections( - nodeIdentity: true, radioSettings: true, positionSettings: false, - otherSettings: false, channels: false, contacts: true + // Simulate current device state + let current = Self.testSelfInfo + + // Merge: imported values where present, current values for nil + let manualAdd = imported.manualAddContacts ?? (current.manualAddContacts ? 1 : 0) + let advertPolicy = imported.advertLocationPolicy ?? current.advertisementLocationPolicy + let telBase = imported.telemetryModeBase ?? current.telemetryModeBase + let telLocation = imported.telemetryModeLocation ?? current.telemetryModeLocation + let telEnvironment = imported.telemetryModeEnvironment ?? current.telemetryModeEnvironment + let multiAcks = imported.multiAcks ?? current.multiAcks + + // Imported values should take precedence + #expect(manualAdd == 1) + #expect(advertPolicy == 0) + + // Missing fields should fall back to current device values + #expect(telBase == current.telemetryModeBase) + #expect(telLocation == current.telemetryModeLocation) + #expect(telEnvironment == current.telemetryModeEnvironment) + #expect(multiAcks == current.multiAcks) + } + + @Test + func `Full OtherSettings uses all imported values`() { + let imported = MeshCoreNodeConfig.OtherSettings( + manualAddContacts: 0, + advertLocationPolicy: 2, + telemetryModeBase: 3, + telemetryModeLocation: 1, + telemetryModeEnvironment: 2, + multiAcks: 5, + advertisementType: 4 ) - /// A plan that selects the radio section so the seam exercises the radio-last branch - /// (setRadioParams then setTxPower) after contacts. Radio values mirror the validated - /// set in stepCountFullPlan; txPower 20 is within the maxTxPower 30 bound. - private static func radioExecutePlan() throws -> ConfigImportPlan { - let config = MeshCoreNodeConfig( - name: "Node", - privateKey: String(repeating: "ab", count: 64), - radioSettings: .init(frequency: 910_525, bandwidth: 62_500, - spreadingFactor: 7, codingRate: 5, txPower: 20), - contacts: [ - .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), - flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), - ] - ) - return try planConfigImport( - config: config, sections: radioExecuteSections, - maxChannels: 8, maxContacts: 100, maxTxPower: 30, - existingChannels: emptySlots(8), existingContacts: [:]) + let current = Self.testSelfInfo + + let manualAdd = imported.manualAddContacts ?? (current.manualAddContacts ? 1 : 0) + let advertPolicy = imported.advertLocationPolicy ?? current.advertisementLocationPolicy + let telBase = imported.telemetryModeBase ?? current.telemetryModeBase + let telLocation = imported.telemetryModeLocation ?? current.telemetryModeLocation + let telEnvironment = imported.telemetryModeEnvironment ?? current.telemetryModeEnvironment + let multiAcks = imported.multiAcks ?? current.multiAcks + + #expect(manualAdd == 0) + #expect(advertPolicy == 2) + #expect(telBase == 3) + #expect(telLocation == 1) + #expect(telEnvironment == 2) + #expect(multiAcks == 5) + } + + // MARK: - Export round-trip consistency + + @Test + func `buildRadioSettings round-trips through config format`() { + let radio = NodeConfigService.buildRadioSettings(from: Self.testSelfInfo) + + // Config stores frequency in kHz, bandwidth in Hz. + // setRadioParams's bandwidthKHz parameter actually takes Hz (matching + // RadioPreset.bandwidthHz usage), so import passes values directly + // for a lossless round-trip. + #expect(radio.frequency == 910_525) + #expect(radio.bandwidth == 62500) + } + + @Test + func `buildContactConfig and import produce consistent outPath`() { + let exported = NodeConfigService.buildContactConfig(from: Self.testContact) + #expect(exported.outPath == "aabbcc") + + // "aabbcc" = 3 bytes, matching the original outPathLength + #expect(Self.testContact.outPathLength == 3) + } + + @Test + func `buildContactConfig and import produce consistent flood path`() { + let exported = NodeConfigService.buildContactConfig(from: Self.floodContact) + // Flood routing: nil outPath, outPathLength 0xFF + #expect(exported.outPath == nil) + #expect(Self.floodContact.outPathLength == 0xFF) + } + + @Test + func `Direct contact round-trips through export and import without becoming flood`() throws { + let exported = NodeConfigService.buildContactConfig(from: Self.zeroPathContact) + #expect(exported.outPath == "") + + // Re-encode through JSON to simulate a real import + let encoded = try JSONEncoder().encode(exported) + let reimported = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: encoded) + + // Empty string is non-nil: must be treated as direct, not flood + #expect(reimported.outPath != nil) + #expect(reimported.outPath?.isEmpty == true) + + // Simulate the fixed import logic's three-way branch + let outPathLength: UInt8 = if let pathHex = reimported.outPath, !pathHex.isEmpty { + // Routed path (not reached for direct contacts) + 1 + } else if reimported.outPath != nil { + // Direct (zero-hop) — outPath was explicitly set to "" + 0 + } else { + // Flood — outPath was nil + 0xFF } - private static let fullSections: ConfigSections = { - var s = ConfigSections() - s.selectAll() - return s - }() - - /// A plan exercising every step-emitting branch (identity name+key, position, other, - /// two channels, one contact, radio params + tx power) so the execute seam's progress - /// emissions can be cross-checked against stepCount(for:) across all branches. - private static func fullSectionsPlan() throws -> ConfigImportPlan { - let config = MeshCoreNodeConfig( - name: "Test", - privateKey: String(repeating: "ab", count: 64), - radioSettings: .init(frequency: 910_525, bandwidth: 62_500, - spreadingFactor: 7, codingRate: 5, txPower: 20), - positionSettings: .init(latitude: "47.0", longitude: "-122.0"), - otherSettings: .init(manualAddContacts: 0), - channels: [ - .init(name: "Ch1", secret: "00112233445566778899aabbccddeeff"), - .init(name: "Ch2", secret: "ffeeddccbbaa99887766554433221100"), - ], - contacts: [ - .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), - flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), - ]) - return try planConfigImport( - config: config, sections: fullSections, - maxChannels: 8, maxContacts: 100, maxTxPower: 30, - existingChannels: emptySlots(8), existingContacts: [:]) + #expect(outPathLength == 0, "Direct contact must stay direct (0), not become flood (0xFF)") + } + + // MARK: - Multibyte path hash mode (export) + + @Test + func `buildContactConfig exports pathHashMode for mode 0 contact`() { + let config = NodeConfigService.buildContactConfig(from: Self.testContact) + // testContact has outPathLength=3 → mode 0 (upper 2 bits = 0) + #expect(config.pathHashMode == 0) + } + + @Test + func `buildContactConfig exports pathHashMode for mode 1 (2-byte) contact`() { + // outPathLength = encodePathLen(hashSize: 2, hopCount: 3) = 0b01_000011 = 0x43 + let contact = MeshContact( + id: "mode1", publicKey: Data(repeating: 0x05, count: 32), + type: .chat, flags: [], + outPathLength: encodePathLen(hashSize: 2, hopCount: 3), + outPath: Data([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), + advertisedName: "Mode1Node", + lastAdvertisement: .now, latitude: 0, longitude: 0, lastModified: .now + ) + let config = NodeConfigService.buildContactConfig(from: contact) + + #expect(config.pathHashMode == 1) + #expect(config.outPath == "aabbccddeeff") + } + + @Test + func `buildContactConfig exports nil pathHashMode for flood contacts`() { + let config = NodeConfigService.buildContactConfig(from: Self.floodContact) + #expect(config.pathHashMode == nil) + } + + // MARK: - Multibyte path hash mode (import round-trip via JSON) + + @Test + func `ContactConfig import with pathHashMode encodes outPathLength correctly`() throws { + let json = """ + { + "type": 1, "name": "Test", "public_key": "\(String(repeating: "ab", count: 32))", + "flags": 0, "latitude": "0", "longitude": "0", + "last_advert": 0, "last_modified": 0, + "out_path": "aabbccddeeff", + "path_hash_mode": 1 } - - @Test("Execute applies identity before channels/contacts and reports one step per write") - func executeOrdersIdentityFirst() async throws { - let plan = try Self.executePlan() - let spy = ExecuteSpy() - try await executeConfigImport( - plan: plan, sections: Self.executeSections, radioID: UUID(), - writers: makeSpyWriters(spy), logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) } - ) - let calls = spy.calls - let identityIndex = try #require(calls.firstIndex(of: "importPrivateKey")) - let nameIndex = try #require(calls.firstIndex(of: "setNodeName")) - let channelIndex = try #require(calls.firstIndex { $0.hasPrefix("setChannel") }) - let contactIndex = try #require(calls.firstIndex { $0.hasPrefix("addContact") }) - #expect(identityIndex < channelIndex) - #expect(nameIndex < channelIndex) - #expect(identityIndex < contactIndex) - // privateKey + name + 2 channels + 1 contact = 5 successful writes, each reporting once. - #expect(spy.progress.count == 5) + """ + let config = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: Data(json.utf8)) + + #expect(config.pathHashMode == 1) + + // Simulate what the import code does: 6 hex chars = 3 bytes + let pathByteCount = 6 // "aabbccddeeff" = 6 bytes + let hashSize = Int(config.pathHashMode ?? 0) + 1 + let hopCount = pathByteCount / hashSize + let outPathLength = encodePathLen(hashSize: hashSize, hopCount: hopCount) + + // 6 bytes / 2 bytes per hop = 3 hops, mode 1 → 0b01_000011 = 0x43 + #expect(hashSize == 2) + #expect(hopCount == 3) + #expect(outPathLength == 0x43) + } + + @Test + func `ContactConfig import without pathHashMode defaults to mode 0`() throws { + let json = """ + { + "type": 1, "name": "Test", "public_key": "\(String(repeating: "ab", count: 32))", + "flags": 0, "latitude": "0", "longitude": "0", + "last_advert": 0, "last_modified": 0, + "out_path": "aabbcc" } - - @Test("A first-write failure reports no progress, so the import reads as clean, not partial") - func executeFirstWriteFailureEmitsNoProgress() async throws { - let plan = try Self.executePlan() - let spy = ExecuteSpy() - await #expect(throws: NodeConfigServiceError.self) { - try await executeConfigImport( - plan: plan, sections: Self.executeSections, radioID: UUID(), - writers: makeSpyWriters(spy, throwOn: { $0 == "importPrivateKey" }), - logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) } - ) - } - #expect(spy.progress.isEmpty, "No write succeeded, so no progress must be reported") + """ + let config = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: Data(json.utf8)) + + #expect(config.pathHashMode == nil) + + // Simulate import: nil defaults to mode 0, raw byte count = hop count + let pathByteCount = 3 // "aabbcc" = 3 bytes + let hashSize = Int(config.pathHashMode ?? 0) + 1 + let hopCount = pathByteCount / hashSize + let outPathLength = encodePathLen(hashSize: hashSize, hopCount: hopCount) + + // 3 bytes / 1 byte per hop = 3 hops, mode 0 → 0b00_000011 = 3 + #expect(hashSize == 1) + #expect(hopCount == 3) + #expect(outPathLength == 3) + } + + @Test + func `Direct contact with pathHashMode imports as outPathLength 0, not mode-encoded`() throws { + let json = """ + { + "type": 1, "name": "DirectMode1", "public_key": "\(String(repeating: "cd", count: 32))", + "flags": 0, "latitude": "0", "longitude": "0", + "last_advert": 0, "last_modified": 0, + "out_path": "", + "path_hash_mode": 1 } - - @Test("A mid-sequence failure reports progress only for the writes that already succeeded") - func executeMidSequenceFailureReportsPartialProgress() async throws { - let plan = try Self.executePlan() - let spy = ExecuteSpy() - await #expect(throws: NodeConfigServiceError.self) { - try await executeConfigImport( - plan: plan, sections: Self.executeSections, radioID: UUID(), - writers: makeSpyWriters(spy, throwOn: { $0.hasPrefix("setChannel") }), - logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) } - ) - } - // Identity succeeded and reported; the first channel write failed before its progress fired. - #expect(spy.progress == [.privateKey, .nodeName]) + """ + let config = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: Data(json.utf8)) + #expect(config.pathHashMode == 1) + #expect(config.outPath == "") + + // Simulate the import logic's three-way branch + let outPathLength: UInt8 = if let pathHex = config.outPath, !pathHex.isEmpty { + // Routed path (not reached for empty out_path) + 1 + } else if config.outPath != nil { + 0 + } else { + 0xFF } - @Test("A local-save failure after the device add still reports the contact as applied") - func executeContactDbFailureStillReportsProgress() async throws { - let plan = try Self.executePlan() // identity + 2 channels + 1 contact - let spy = ExecuteSpy() - var writers = makeSpyWriters(spy) - writers = ConfigImportWriters( - getSelfInfo: writers.getSelfInfo, - importPrivateKey: writers.importPrivateKey, - setNodeName: writers.setNodeName, - setLocation: writers.setLocation, - setOtherParams: writers.setOtherParams, - resolveEffectiveRadioID: writers.resolveEffectiveRadioID, - setRadioParams: writers.setRadioParams, - setTxPower: writers.setTxPower, - setChannel: writers.setChannel, - addContact: { _, contact in - spy.record("addContact:\(contact.advertisedName)") - // Models the post-device-add local save failing; the production closure must - // swallow this, so executeConfigImport must complete and report progress. - } - ) - try await executeConfigImport( - plan: plan, sections: Self.executeSections, radioID: UUID(), - writers: writers, logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) } - ) - #expect(spy.progress.contains(.contact(name: "C1"))) + #expect(outPathLength == 0, "Direct contact must encode as 0, not mode-encoded 0x40") + #expect(outPathLength != 0xFF, "Direct contact must not become flood") + } + + // MARK: - Error cases + + @Test + func `NodeConfigServiceError has descriptive messages`() { + let channelError = NodeConfigServiceError.invalidChannelSecret(index: 2, hexLength: 30) + #expect(channelError.localizedDescription.contains("Channel 2")) + + let contactError = NodeConfigServiceError.invalidContactPublicKey(name: "BadContact") + #expect(contactError.localizedDescription.contains("BadContact")) + + let modeError = NodeConfigServiceError.invalidPathHashMode(name: "BadNode", mode: 5) + #expect(modeError.localizedDescription.contains("BadNode")) + #expect(modeError.localizedDescription.contains("5")) + } + + @Test + func `Import rejects pathHashMode > 2 as invalid`() throws { + let json = """ + { + "type": 1, "name": "BadMode", "public_key": "\(String(repeating: "ab", count: 32))", + "flags": 0, "latitude": "0", "longitude": "0", + "last_advert": 0, "last_modified": 0, + "out_path": "aabbcc", + "path_hash_mode": 3 } - - @Test("Execute writes radio after contacts and tx power after radio params") - func executeWritesRadioLastAfterContacts() async throws { - let plan = try Self.radioExecutePlan() - let spy = ExecuteSpy() - try await executeConfigImport( - plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), - writers: makeSpyWriters(spy), logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) }) - let calls = spy.calls - let contactIndex = try #require(calls.lastIndex { $0.hasPrefix("addContact") }) - let radioIndex = try #require(calls.firstIndex(of: "setRadioParams")) - let txPowerIndex = try #require(calls.firstIndex(of: "setTxPower")) - #expect(contactIndex < radioIndex, "Radio must be written after contacts (radio goes last)") - #expect(radioIndex < txPowerIndex, "TX power must follow radio params") - #expect(spy.progress == [.privateKey, .nodeName, .contact(name: "C1"), - .radioParameters, .txPower]) + """ + let config = try JSONDecoder().decode(MeshCoreNodeConfig.ContactConfig.self, from: Data(json.utf8)) + #expect(config.pathHashMode == 3) + + // The mode validation guard should reject values > 2 + let mode = config.pathHashMode ?? 0 + #expect(mode > 2) + } + + // MARK: - ImportProgress + + @Test + func `ImportProgress stores step info`() { + let progress = ImportProgress(step: .contact(name: "Alice"), current: 3, total: 10) + #expect(progress.step == .contact(name: "Alice")) + #expect(progress.current == 3) + #expect(progress.total == 10) + } + + // MARK: - Post-Identity Resolution Seam + + @Test + func `resolveEffectiveRadioID returns callback result when private key was imported`() async throws { + let original = UUID() + let reconciled = UUID() + let result = try await resolveEffectiveRadioID( + original: original, + didImportPrivateKey: true, + callback: { @Sendable in reconciled } + ) + #expect(result == reconciled) + } + + @Test + func `resolveEffectiveRadioID skips callback when no private key was imported`() async throws { + actor CallTracker { + var calls = 0 + func bump() { + calls += 1 + } } - - @Test("A tx-power failure after radio params rethrows and reports radio progress but not tx power") - func executeTxPowerFailureAfterRadioParams() async throws { - let plan = try Self.radioExecutePlan() - let spy = ExecuteSpy() - await #expect(throws: NodeConfigServiceError.self) { - try await executeConfigImport( - plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), - writers: makeSpyWriters(spy, throwOn: { $0 == "setTxPower" }), - logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) }) - } - #expect(spy.calls.contains("setRadioParams")) - #expect(spy.calls.contains("setTxPower")) - #expect(spy.progress.contains(.radioParameters)) - #expect(!spy.progress.contains(.txPower), "TX power progress must not fire when setTxPower throws") + let tracker = CallTracker() + let original = UUID() + let result = try await resolveEffectiveRadioID( + original: original, + didImportPrivateKey: false, + callback: { @Sendable in + await tracker.bump() + return UUID() + } + ) + #expect(result == original) + #expect(await tracker.calls == 0, + "Callback must not fire when no private key was imported") + } + + @Test + func `resolveEffectiveRadioID returns original when callback returns nil`() async throws { + let original = UUID() + let result = try await resolveEffectiveRadioID( + original: original, + didImportPrivateKey: true, + callback: { @Sendable in nil } + ) + #expect(result == original) + } + + @Test + func `resolveEffectiveRadioID handles nil callback gracefully`() async throws { + let original = UUID() + let result = try await resolveEffectiveRadioID( + original: original, + didImportPrivateKey: true, + callback: nil + ) + #expect(result == original) + } + + // MARK: - Execute orchestration seam + + private static let executeSections = ConfigSections( + nodeIdentity: true, radioSettings: false, positionSettings: false, + otherSettings: false, channels: true, contacts: true + ) + + private static let executeLogger = Logger(subsystem: "test", category: "NodeConfigExecuteTests") + + /// A plan with identity, two channels, and one contact, so the seam exercises identity-first + /// ordering and per-write progress. Built through the real planner. + private static func executePlan() throws -> ConfigImportPlan { + let config = MeshCoreNodeConfig( + name: "Node", + privateKey: String(repeating: "ab", count: 64), + channels: [ + .init(name: "Ch1", secret: "00112233445566778899aabbccddeeff"), + .init(name: "Ch2", secret: "ffeeddccbbaa99887766554433221100"), + ], + contacts: [ + .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), + flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), + ] + ) + return try planConfigImport( + config: config, sections: executeSections, + maxChannels: 8, maxContacts: 100, maxTxPower: 30, + existingChannels: emptySlots(8), existingContacts: [:] + ) + } + + private static let radioExecuteSections = ConfigSections( + nodeIdentity: true, radioSettings: true, positionSettings: false, + otherSettings: false, channels: false, contacts: true + ) + + /// A plan that selects the radio section so the seam exercises the radio-last branch + /// (setRadioParams then setTxPower) after contacts. Radio values mirror the validated + /// set in stepCountFullPlan; txPower 20 is within the maxTxPower 30 bound. + private static func radioExecutePlan() throws -> ConfigImportPlan { + let config = MeshCoreNodeConfig( + name: "Node", + privateKey: String(repeating: "ab", count: 64), + radioSettings: .init(frequency: 910_525, bandwidth: 62500, + spreadingFactor: 7, codingRate: 5, txPower: 20), + contacts: [ + .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), + flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), + ] + ) + return try planConfigImport( + config: config, sections: radioExecuteSections, + maxChannels: 8, maxContacts: 100, maxTxPower: 30, + existingChannels: emptySlots(8), existingContacts: [:] + ) + } + + private static let fullSections: ConfigSections = { + var s = ConfigSections() + s.selectAll() + return s + }() + + /// A plan exercising every step-emitting branch (identity name+key, position, other, + /// two channels, one contact, radio params + tx power) so the execute seam's progress + /// emissions can be cross-checked against stepCount(for:) across all branches. + private static func fullSectionsPlan() throws -> ConfigImportPlan { + let config = MeshCoreNodeConfig( + name: "Test", + privateKey: String(repeating: "ab", count: 64), + radioSettings: .init(frequency: 910_525, bandwidth: 62500, + spreadingFactor: 7, codingRate: 5, txPower: 20), + positionSettings: .init(latitude: "47.0", longitude: "-122.0"), + otherSettings: .init(manualAddContacts: 0), + channels: [ + .init(name: "Ch1", secret: "00112233445566778899aabbccddeeff"), + .init(name: "Ch2", secret: "ffeeddccbbaa99887766554433221100"), + ], + contacts: [ + .init(type: 1, name: "C1", publicKey: String(repeating: "ab", count: 32), + flags: 0, latitude: "0", longitude: "0", lastAdvert: 0, lastModified: 0), + ] + ) + return try planConfigImport( + config: config, sections: fullSections, + maxChannels: 8, maxContacts: 100, maxTxPower: 30, + existingChannels: emptySlots(8), existingContacts: [:] + ) + } + + @Test + func `Execute applies identity before channels/contacts and reports one step per write`() async throws { + let plan = try Self.executePlan() + let spy = ExecuteSpy() + try await executeConfigImport( + plan: plan, sections: Self.executeSections, radioID: UUID(), + writers: makeSpyWriters(spy), logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) + let calls = spy.calls + let identityIndex = try #require(calls.firstIndex(of: "importPrivateKey")) + let nameIndex = try #require(calls.firstIndex(of: "setNodeName")) + let channelIndex = try #require(calls.firstIndex { $0.hasPrefix("setChannel") }) + let contactIndex = try #require(calls.firstIndex { $0.hasPrefix("addContact") }) + #expect(identityIndex < channelIndex) + #expect(nameIndex < channelIndex) + #expect(identityIndex < contactIndex) + // privateKey + name + 2 channels + 1 contact = 5 successful writes, each reporting once. + #expect(spy.progress.count == 5) + } + + @Test + func `A first-write failure reports no progress, so the import reads as clean, not partial`() async throws { + let plan = try Self.executePlan() + let spy = ExecuteSpy() + await #expect(throws: NodeConfigServiceError.self) { + try await executeConfigImport( + plan: plan, sections: Self.executeSections, radioID: UUID(), + writers: makeSpyWriters(spy, throwOn: { $0 == "importPrivateKey" }), + logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) } - - @Test("Execute over a full plan emits exactly stepCount progress steps across all branches") - func executeFullPlanProgressMatchesStepCount() async throws { - let plan = try Self.fullSectionsPlan() - let spy = ExecuteSpy() - try await executeConfigImport( - plan: plan, sections: Self.fullSections, radioID: UUID(), - writers: makeSpyWriters(spy), logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) }) - #expect(spy.progress.count == NodeConfigService.stepCount(for: plan)) - let kinds = Set(spy.progress.map { step -> String in - switch step { - case .position: return "position" - case .otherParameters: return "other" - case .privateKey: return "privateKey" - case .nodeName: return "nodeName" - case .radioParameters: return "radio" - case .txPower: return "txPower" - case .channel: return "channel" - case .contact: return "contact" - } - }) - #expect(kinds == ["position", "other", "privateKey", "nodeName", "radio", "txPower", "channel", "contact"]) + #expect(spy.progress.isEmpty, "No write succeeded, so no progress must be reported") + } + + @Test + func `A mid-sequence failure reports progress only for the writes that already succeeded`() async throws { + let plan = try Self.executePlan() + let spy = ExecuteSpy() + await #expect(throws: NodeConfigServiceError.self) { + try await executeConfigImport( + plan: plan, sections: Self.executeSections, radioID: UUID(), + writers: makeSpyWriters(spy, throwOn: { $0.hasPrefix("setChannel") }), + logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) } - - // MARK: - M3: pref/radio diff gates - - /// A `SelfInfo` matching `fullSectionsPlan` on every diff-gated field, with the overridable - /// fields a single test needs to perturb. - private static func matchingSelfInfo( - name: String = "Test", - latitude: Double = 47.0, - longitude: Double = -122.0, - radioFrequencyMHz: Double = 910.525, - radioBandwidthKHz: Double = 62.5, - spreadingFactor: UInt8 = 7, - codingRate: UInt8 = 5, - txPower: Int8 = 20 - ) -> SelfInfo { - SelfInfo( - advertisementType: 0, txPower: txPower, maxTxPower: 30, - publicKey: Data(repeating: 0, count: 32), - latitude: latitude, longitude: longitude, - multiAcks: 0, advertisementLocationPolicy: 0, - telemetryModeEnvironment: 0, telemetryModeLocation: 0, telemetryModeBase: 0, - manualAddContacts: false, - radioFrequency: radioFrequencyMHz, radioBandwidth: radioBandwidthKHz, - radioSpreadingFactor: spreadingFactor, radioCodingRate: codingRate, - name: name - ) + // Identity succeeded and reported; the first channel write failed before its progress fired. + #expect(spy.progress == [.privateKey, .nodeName]) + } + + @Test + func `A local-save failure after the device add still reports the contact as applied`() async throws { + let plan = try Self.executePlan() // identity + 2 channels + 1 contact + let spy = ExecuteSpy() + var writers = makeSpyWriters(spy) + writers = ConfigImportWriters( + getSelfInfo: writers.getSelfInfo, + importPrivateKey: writers.importPrivateKey, + setNodeName: writers.setNodeName, + setLocation: writers.setLocation, + setOtherParams: writers.setOtherParams, + resolveEffectiveRadioID: writers.resolveEffectiveRadioID, + setRadioParams: writers.setRadioParams, + setTxPower: writers.setTxPower, + setChannel: writers.setChannel, + addContact: { _, contact in + spy.record("addContact:\(contact.advertisedName)") + // Models the post-device-add local save failing; the production closure must + // swallow this, so executeConfigImport must complete and report progress. + } + ) + try await executeConfigImport( + plan: plan, sections: Self.executeSections, radioID: UUID(), + writers: writers, logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) + #expect(spy.progress.contains(.contact(name: "C1"))) + } + + @Test + func `Execute writes radio after contacts and tx power after radio params`() async throws { + let plan = try Self.radioExecutePlan() + let spy = ExecuteSpy() + try await executeConfigImport( + plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), + writers: makeSpyWriters(spy), logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) + let calls = spy.calls + let contactIndex = try #require(calls.lastIndex { $0.hasPrefix("addContact") }) + let radioIndex = try #require(calls.firstIndex(of: "setRadioParams")) + let txPowerIndex = try #require(calls.firstIndex(of: "setTxPower")) + #expect(contactIndex < radioIndex, "Radio must be written after contacts (radio goes last)") + #expect(radioIndex < txPowerIndex, "TX power must follow radio params") + #expect(spy.progress == [.privateKey, .nodeName, .contact(name: "C1"), + .radioParameters, .txPower]) + } + + @Test + func `A tx-power failure after radio params rethrows and reports radio progress but not tx power`() async throws { + let plan = try Self.radioExecutePlan() + let spy = ExecuteSpy() + await #expect(throws: NodeConfigServiceError.self) { + try await executeConfigImport( + plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), + writers: makeSpyWriters(spy, throwOn: { $0 == "setTxPower" }), + logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) } + #expect(spy.calls.contains("setRadioParams")) + #expect(spy.calls.contains("setTxPower")) + #expect(spy.progress.contains(.radioParameters)) + #expect(!spy.progress.contains(.txPower), "TX power progress must not fire when setTxPower throws") + } + + @Test + func `Execute over a full plan emits exactly stepCount progress steps across all branches`() async throws { + let plan = try Self.fullSectionsPlan() + let spy = ExecuteSpy() + try await executeConfigImport( + plan: plan, sections: Self.fullSections, radioID: UUID(), + writers: makeSpyWriters(spy), logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) + #expect(spy.progress.count == NodeConfigService.stepCount(for: plan)) + let kinds = Set(spy.progress.map { step -> String in + switch step { + case .position: return "position" + case .otherParameters: return "other" + case .privateKey: return "privateKey" + case .nodeName: return "nodeName" + case .radioParameters: return "radio" + case .txPower: return "txPower" + case .channel: return "channel" + case .contact: return "contact" + } + }) + #expect(kinds == ["position", "other", "privateKey", "nodeName", "radio", "txPower", "channel", "contact"]) + } + + // MARK: - M3: pref/radio diff gates + + /// A `SelfInfo` matching `fullSectionsPlan` on every diff-gated field, with the overridable + /// fields a single test needs to perturb. + private static func matchingSelfInfo( + name: String = "Test", + latitude: Double = 47.0, + longitude: Double = -122.0, + radioFrequencyMHz: Double = 910.525, + radioBandwidthKHz: Double = 62.5, + spreadingFactor: UInt8 = 7, + codingRate: UInt8 = 5, + txPower: Int8 = 20 + ) -> SelfInfo { + SelfInfo( + advertisementType: 0, txPower: txPower, maxTxPower: 30, + publicKey: Data(repeating: 0, count: 32), + latitude: latitude, longitude: longitude, + multiAcks: 0, advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, telemetryModeLocation: 0, telemetryModeBase: 0, + manualAddContacts: false, + radioFrequency: radioFrequencyMHz, radioBandwidth: radioBandwidthKHz, + radioSpreadingFactor: spreadingFactor, radioCodingRate: codingRate, + name: name + ) + } - @Test("Pref decision gates: each setter fires only when its field differs from the device") - func prefDecisionGates() { - let info = Self.matchingSelfInfo() - let radio = MeshCoreNodeConfig.RadioSettings( - frequency: 910_525, bandwidth: 62_500, spreadingFactor: 7, codingRate: 5, txPower: 20) - - #expect(nodeNameNeedsWrite("Test", current: info) == false) - #expect(nodeNameNeedsWrite("Renamed", current: info) == true) - - // setNodeName truncates to the firmware field width, so a name differing only past that - // width stores identically on the device and must not re-commit. - let storedName = String(repeating: "a", count: ProtocolLimits.maxUsableNameBytes) - let longNameInfo = Self.matchingSelfInfo(name: storedName) - #expect(nodeNameNeedsWrite(storedName + "EXTRA", current: longNameInfo) == false) - - #expect(locationNeedsWrite(.init(latitude: 47.0, longitude: -122.0), current: info) == false) - #expect(locationNeedsWrite(.init(latitude: 47.5, longitude: -122.0), current: info) == true) - - #expect(radioParamsNeedWrite(radio, current: info) == false) - var sfChanged = radio - sfChanged = .init(frequency: 910_525, bandwidth: 62_500, spreadingFactor: 8, codingRate: 5, txPower: 20) - #expect(radioParamsNeedWrite(sfChanged, current: info) == true) + @Test + func `Pref decision gates: each setter fires only when its field differs from the device`() { + let info = Self.matchingSelfInfo() + let radio = MeshCoreNodeConfig.RadioSettings( + frequency: 910_525, bandwidth: 62500, spreadingFactor: 7, codingRate: 5, txPower: 20 + ) - #expect(txPowerNeedsWrite(radio, current: info) == false) - #expect(txPowerNeedsWrite(.init(frequency: 910_525, bandwidth: 62_500, spreadingFactor: 7, codingRate: 5, txPower: 19), current: info) == true) - } + #expect(nodeNameNeedsWrite("Test", current: info) == false) + #expect(nodeNameNeedsWrite("Renamed", current: info) == true) + + // setNodeName truncates to the firmware field width, so a name differing only past that + // width stores identically on the device and must not re-commit. + let storedName = String(repeating: "a", count: ProtocolLimits.maxUsableNameBytes) + let longNameInfo = Self.matchingSelfInfo(name: storedName) + #expect(nodeNameNeedsWrite(storedName + "EXTRA", current: longNameInfo) == false) + + #expect(locationNeedsWrite(.init(latitude: 47.0, longitude: -122.0), current: info) == false) + #expect(locationNeedsWrite(.init(latitude: 47.5, longitude: -122.0), current: info) == true) + + #expect(radioParamsNeedWrite(radio, current: info) == false) + var sfChanged = radio + sfChanged = .init(frequency: 910_525, bandwidth: 62500, spreadingFactor: 8, codingRate: 5, txPower: 20) + #expect(radioParamsNeedWrite(sfChanged, current: info) == true) + + #expect(txPowerNeedsWrite(radio, current: info) == false) + #expect(txPowerNeedsWrite(.init(frequency: 910_525, bandwidth: 62500, spreadingFactor: 7, codingRate: 5, txPower: 19), current: info) == true) + } + + @Test + func `An unchanged config skips every pref/radio device write but still reports progress`() async throws { + let plan = try Self.fullSectionsPlan() + let spy = ExecuteSpy() + let info = Self.matchingSelfInfo() + try await executeConfigImport( + plan: plan, sections: Self.fullSections, radioID: UUID(), + writers: makeSpyWriters(spy, selfInfo: { info }), logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) - @Test("An unchanged config skips every pref/radio device write but still reports progress") - func executeIdenticalConfigSkipsPrefWrites() async throws { - let plan = try Self.fullSectionsPlan() - let spy = ExecuteSpy() - let info = Self.matchingSelfInfo() - try await executeConfigImport( - plan: plan, sections: Self.fullSections, radioID: UUID(), - writers: makeSpyWriters(spy, selfInfo: { info }), logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) }) - - #expect(!spy.calls.contains("setNodeName"), "Unchanged name must not commit prefs") - #expect(!spy.calls.contains("setLocation"), "Unchanged position must not commit prefs") - #expect(!spy.calls.contains("setRadioParams"), "Unchanged radio params must not commit prefs") - #expect(!spy.calls.contains("setTxPower"), "Unchanged TX power must not commit prefs") - // Progress still completes so the bar reaches 100%; the skipped commits are the only change. - #expect(spy.progress.count == NodeConfigService.stepCount(for: plan)) - #expect(spy.progress.contains(.nodeName)) - #expect(spy.progress.contains(.radioParameters)) - #expect(spy.progress.contains(.txPower)) - } + #expect(!spy.calls.contains("setNodeName"), "Unchanged name must not commit prefs") + #expect(!spy.calls.contains("setLocation"), "Unchanged position must not commit prefs") + #expect(!spy.calls.contains("setRadioParams"), "Unchanged radio params must not commit prefs") + #expect(!spy.calls.contains("setTxPower"), "Unchanged TX power must not commit prefs") + // Progress still completes so the bar reaches 100%; the skipped commits are the only change. + #expect(spy.progress.count == NodeConfigService.stepCount(for: plan)) + #expect(spy.progress.contains(.nodeName)) + #expect(spy.progress.contains(.radioParameters)) + #expect(spy.progress.contains(.txPower)) + } + + @Test + func `A TX-power-only change writes only setTxPower among the radio writes`() async throws { + let plan = try Self.radioExecutePlan() // name "Node", radio txPower 20 + let spy = ExecuteSpy() + // Match name and radio params, but a different TX power. + let info = Self.matchingSelfInfo(name: "Node", txPower: 15) + try await executeConfigImport( + plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), + writers: makeSpyWriters(spy, selfInfo: { info }), logger: Self.executeLogger, + onProgress: { spy.recordProgress($0.step) } + ) - @Test("A TX-power-only change writes only setTxPower among the radio writes") - func executeTxPowerOnlyChangeGatesRadioParams() async throws { - let plan = try Self.radioExecutePlan() // name "Node", radio txPower 20 - let spy = ExecuteSpy() - // Match name and radio params, but a different TX power. - let info = Self.matchingSelfInfo(name: "Node", txPower: 15) - try await executeConfigImport( - plan: plan, sections: Self.radioExecuteSections, radioID: UUID(), - writers: makeSpyWriters(spy, selfInfo: { info }), logger: Self.executeLogger, - onProgress: { spy.recordProgress($0.step) }) - - #expect(!spy.calls.contains("setNodeName"), "Matching name must be skipped") - #expect(!spy.calls.contains("setRadioParams"), "Matching radio params must be skipped") - #expect(spy.calls.contains("setTxPower"), "The differing TX power must still write") - } + #expect(!spy.calls.contains("setNodeName"), "Matching name must be skipped") + #expect(!spy.calls.contains("setRadioParams"), "Matching radio params must be skipped") + #expect(spy.calls.contains("setTxPower"), "The differing TX power must still write") + } } // MARK: - Execute-seam spy @@ -922,54 +930,65 @@ struct NodeConfigServiceTests { /// failure behavior can be asserted without a live session. Lock-guarded so the `@Sendable` closures /// can record from any executor. private final class ExecuteSpy: @unchecked Sendable { - private let lock = NSLock() - private var _calls: [String] = [] - private var _progress: [ImportStep] = [] - - func record(_ label: String) { lock.lock(); _calls.append(label); lock.unlock() } - func recordProgress(_ step: ImportStep) { lock.lock(); _progress.append(step); lock.unlock() } - var calls: [String] { lock.lock(); defer { lock.unlock() }; return _calls } - var progress: [ImportStep] { lock.lock(); defer { lock.unlock() }; return _progress } + private let lock = NSLock() + private var _calls: [String] = [] + private var _progress: [ImportStep] = [] + + func record(_ label: String) { + lock.lock(); _calls.append(label); lock.unlock() + } + + func recordProgress(_ step: ImportStep) { + lock.lock(); _progress.append(step); lock.unlock() + } + + var calls: [String] { + lock.lock(); defer { lock.unlock() }; return _calls + } + + var progress: [ImportStep] { + lock.lock(); defer { lock.unlock() }; return _progress + } } /// A `SelfInfo` whose every diff-gated field differs from the execute-seam test plans, so the M3 /// pref/radio gates never skip and the ordering/progress assertions see every write. func nonMatchingSelfInfo() -> SelfInfo { - SelfInfo( - advertisementType: 0, txPower: 0, maxTxPower: 30, - publicKey: Data(repeating: 0, count: 32), - latitude: 0, longitude: 0, - multiAcks: 0, advertisementLocationPolicy: 0, - telemetryModeEnvironment: 0, telemetryModeLocation: 0, telemetryModeBase: 0, - manualAddContacts: false, - radioFrequency: 1, radioBandwidth: 1, radioSpreadingFactor: 1, radioCodingRate: 1, - name: "\u{0}unset" - ) + SelfInfo( + advertisementType: 0, txPower: 0, maxTxPower: 30, + publicKey: Data(repeating: 0, count: 32), + latitude: 0, longitude: 0, + multiAcks: 0, advertisementLocationPolicy: 0, + telemetryModeEnvironment: 0, telemetryModeLocation: 0, telemetryModeBase: 0, + manualAddContacts: false, + radioFrequency: 1, radioBandwidth: 1, radioSpreadingFactor: 1, radioCodingRate: 1, + name: "\u{0}unset" + ) } /// Builds `ConfigImportWriters` whose closures record their label and throw when `throwOn` matches, /// so a chosen write can be made to fail at a chosen point in the sequence. private func makeSpyWriters( - _ spy: ExecuteSpy, - selfInfo: @escaping @Sendable () -> SelfInfo = { nonMatchingSelfInfo() }, - throwOn: @escaping @Sendable (String) -> Bool = { _ in false } + _ spy: ExecuteSpy, + selfInfo: @escaping @Sendable () -> SelfInfo = { nonMatchingSelfInfo() }, + throwOn: @escaping @Sendable (String) -> Bool = { _ in false } ) -> ConfigImportWriters { - @Sendable func step(_ label: String) throws { - spy.record(label) - if throwOn(label) { throw NodeConfigServiceError.invalidRadioSettings(field: .frequency) } - } - return ConfigImportWriters( - getSelfInfo: { spy.record("getSelfInfo"); return selfInfo() }, - importPrivateKey: { _ in try step("importPrivateKey") }, - setNodeName: { _ in try step("setNodeName") }, - setLocation: { _, _ in try step("setLocation") }, - setOtherParams: { _ in try step("setOtherParams") }, - resolveEffectiveRadioID: { original, _ in spy.record("resolveEffectiveRadioID"); return original }, - setRadioParams: { _ in try step("setRadioParams") }, - setTxPower: { _ in try step("setTxPower") }, - setChannel: { _, write in try step("setChannel:\(write.name)") }, - addContact: { _, contact in try step("addContact:\(contact.advertisedName)") } - ) + @Sendable func step(_ label: String) throws { + spy.record(label) + if throwOn(label) { throw NodeConfigServiceError.invalidRadioSettings(field: .frequency) } + } + return ConfigImportWriters( + getSelfInfo: { spy.record("getSelfInfo"); return selfInfo() }, + importPrivateKey: { _ in try step("importPrivateKey") }, + setNodeName: { _ in try step("setNodeName") }, + setLocation: { _, _ in try step("setLocation") }, + setOtherParams: { _ in try step("setOtherParams") }, + resolveEffectiveRadioID: { original, _ in spy.record("resolveEffectiveRadioID"); return original }, + setRadioParams: { _ in try step("setRadioParams") }, + setTxPower: { _ in try step("setTxPower") }, + setChannel: { _, write in try step("setChannel:\(write.name)") }, + addContact: { _, contact in try step("addContact:\(contact.advertisedName)") } + ) } // MARK: - importOtherParams live-session forwarding @@ -979,114 +998,116 @@ private func makeSpyWriters( /// being coerced to `.none` by a future typed re-mapping. @Suite("NodeConfigService importOtherParams live session") struct NodeConfigImportOtherParamsLiveTests { - - @Test("importOtherParams forwards an unmodeled advert_loc_policy byte to the device verbatim") - @MainActor - func unmodeledAdvertPolicyForwardedVerbatim() async throws { - let unmodeledAdvertPolicyByte: UInt8 = 99 - #expect(AdvertLocationPolicy(rawValue: unmodeledAdvertPolicyByte) == nil) // confirms 99 is unmodeled - - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) - - // Complete session.start() so the session is ready to issue commands. - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - - let store = PersistenceStore(modelContainer: try PersistenceStore.createContainer(inMemory: true)) - let settings = SettingsService(session: session) - let channels = ChannelService(session: session, dataStore: store, rxLogService: nil) - let service = NodeConfigService( - session: session, settingsService: settings, - channelService: channels, dataStore: store, syncCoordinator: nil) - - let imported = MeshCoreNodeConfig.OtherSettings( - manualAddContacts: 0, advertLocationPolicy: unmodeledAdvertPolicyByte) - - let beforeImport = await transport.sentData.count // == 1 (the start handshake's appStart) - let importTask = Task { try await service.importOtherParams(imported) } - - // importOtherParams first calls getSelfInfo() -> sendAppStart() (a second appStart round-trip), - // then writes setOtherParams. Pump the self-info reply, then ack the setOtherParams OK. - try await waitUntil("importOtherParams should send getSelfInfo appStart") { - await transport.sentData.count == beforeImport + 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await waitUntil("importOtherParams should send setOtherParams") { - await transport.sentData.count == beforeImport + 2 - } - await transport.simulateOK() - try await importTask.value - - let advertLocationPolicyByteIndex = 3 // [0]=cmd,[1]=manualAdd,[2]=telemetry,[3]=policy,[4]=multiAcks - let sent = await transport.sentData - let otherParamsPacket = try #require(sent.first { $0.first == CommandCode.setOtherParams.rawValue }) - #expect(otherParamsPacket[advertLocationPolicyByteIndex] == unmodeledAdvertPolicyByte) - - await session.stop() + @Test + @MainActor + func `importOtherParams forwards an unmodeled advert_loc_policy byte to the device verbatim`() async throws { + let unmodeledAdvertPolicyByte: UInt8 = 99 + #expect(AdvertLocationPolicy(rawValue: unmodeledAdvertPolicyByte) == nil) // confirms 99 is unmodeled + + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + + // Complete session.start() so the session is ready to issue commands. + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + + let store = try PersistenceStore(modelContainer: PersistenceStore.createContainer(inMemory: true)) + let settings = SettingsService(session: session) + let channels = ChannelService(session: session, dataStore: store, rxLogService: nil) + let service = NodeConfigService( + session: session, settingsService: settings, + channelService: channels, dataStore: store, syncCoordinator: nil + ) + + let imported = MeshCoreNodeConfig.OtherSettings( + manualAddContacts: 0, advertLocationPolicy: unmodeledAdvertPolicyByte + ) + + let beforeImport = await transport.sentData.count // == 1 (the start handshake's appStart) + let importTask = Task { try await service.importOtherParams(imported) } - @Test("importOtherParams whose merge matches the device sends no setOtherParams commit") - @MainActor - func unchangedOtherParamsSkipsCommit() async throws { - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) - - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - - let store = PersistenceStore(modelContainer: try PersistenceStore.createContainer(inMemory: true)) - let settings = SettingsService(session: session) - let channels = ChannelService(session: session, dataStore: store, rxLogService: nil) - let service = NodeConfigService( - session: session, settingsService: settings, - channelService: channels, dataStore: store, syncCoordinator: nil) - - // All-nil import: every merged field falls back to the device's current value, so the - // result equals current and the commit must be elided. - let beforeImport = await transport.sentData.count - let importTask = Task { try await service.importOtherParams(MeshCoreNodeConfig.OtherSettings()) } - - try await waitUntil("importOtherParams should fetch getSelfInfo") { - await transport.sentData.count == beforeImport + 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await importTask.value - - let sent = await transport.sentData - #expect(!sent.contains { $0.first == CommandCode.setOtherParams.rawValue }, - "A no-op merge must not commit /new_prefs") - - await session.stop() + // importOtherParams first calls getSelfInfo() -> sendAppStart() (a second appStart round-trip), + // then writes setOtherParams. Pump the self-info reply, then ack the setOtherParams OK. + try await waitUntil("importOtherParams should send getSelfInfo appStart") { + await transport.sentData.count == beforeImport + 1 } + await transport.simulateReceive(makeSelfInfoPacket()) + try await waitUntil("importOtherParams should send setOtherParams") { + await transport.sentData.count == beforeImport + 2 + } + await transport.simulateOK() + try await importTask.value + + let advertLocationPolicyByteIndex = 3 // [0]=cmd,[1]=manualAdd,[2]=telemetry,[3]=policy,[4]=multiAcks + let sent = await transport.sentData + let otherParamsPacket = try #require(sent.first { $0.first == CommandCode.setOtherParams.rawValue }) + #expect(otherParamsPacket[advertLocationPolicyByteIndex] == unmodeledAdvertPolicyByte) + + await session.stop() + } + + @Test + @MainActor + func `importOtherParams whose merge matches the device sends no setOtherParams commit`() async throws { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + + let store = try PersistenceStore(modelContainer: PersistenceStore.createContainer(inMemory: true)) + let settings = SettingsService(session: session) + let channels = ChannelService(session: session, dataStore: store, rxLogService: nil) + let service = NodeConfigService( + session: session, settingsService: settings, + channelService: channels, dataStore: store, syncCoordinator: nil + ) + + // All-nil import: every merged field falls back to the device's current value, so the + // result equals current and the commit must be elided. + let beforeImport = await transport.sentData.count + let importTask = Task { try await service.importOtherParams(MeshCoreNodeConfig.OtherSettings()) } - private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) - payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(withUnsafeBytes(of: UInt32(915_000).littleEndian) { Data($0) }) - payload.append(withUnsafeBytes(of: UInt32(125_000).littleEndian) { Data($0) }) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) - - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet + try await waitUntil("importOtherParams should fetch getSelfInfo") { + await transport.sentData.count == beforeImport + 1 } + await transport.simulateReceive(makeSelfInfoPacket()) + try await importTask.value + + let sent = await transport.sentData + #expect(!sent.contains { $0.first == CommandCode.setOtherParams.rawValue }, + "A no-op merge must not commit /new_prefs") + + await session.stop() + } + + private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) + payload.append(withUnsafeBytes(of: Int32(0).littleEndian) { Data($0) }) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(withUnsafeBytes(of: UInt32(915_000).littleEndian) { Data($0) }) + payload.append(withUnsafeBytes(of: UInt32(125_000).littleEndian) { Data($0) }) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigTests.swift index 0b6b3768..67a4f6e2 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NodeConfigTests.swift @@ -1,519 +1,518 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("MeshCoreNodeConfig Decoding") struct NodeConfigTests { - - // MARK: - Test data - - /// Full config export fixture with synthetic test data. - private static let fullConfigJSON = Data(""" - { - "name": "TestNode-2", - "public_key": "d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9", - "private_key": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", - "radio_settings": { - "frequency": 910525, - "bandwidth": 62500, - "spreading_factor": 7, - "coding_rate": 5, - "tx_power": 22 + // MARK: - Test data + + /// Full config export fixture with synthetic test data. + private static let fullConfigJSON = Data(""" + { + "name": "TestNode-2", + "public_key": "d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9", + "private_key": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", + "radio_settings": { + "frequency": 910525, + "bandwidth": 62500, + "spreading_factor": 7, + "coding_rate": 5, + "tx_power": 22 + }, + "position_settings": { + "latitude": "0.0", + "longitude": "0.0" + }, + "other_settings": { + "manual_add_contacts": 0, + "advert_location_policy": 0 + }, + "channels": [ + { "name": "General", "secret": "aa11bb22cc33dd44ee55ff6600778899" }, + { "name": "Alpha", "secret": "11223344556677889900aabbccddeeff" }, + { "name": "#bravo", "secret": "ffeeddccbbaa99887766554433221100" }, + { "name": "Charlie", "secret": "abcdef0123456789abcdef0123456789" }, + { "name": "#delta", "secret": "0123456789abcdef0123456789abcdef" }, + { "name": "Echo", "secret": "deadbeef01234567deadbeef01234567" } + ], + "contacts": [ + { + "type": 3, + "name": "Base-W (Room)", + "custom_name": null, + "public_key": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "flags": 0, + "latitude": "40.7128", + "longitude": "-74.006", + "last_advert": 1767392516, + "last_modified": 1767392535, + "out_path": null }, - "position_settings": { - "latitude": "0.0", - "longitude": "0.0" + { + "type": 2, + "name": "Base-NW (Repeater)", + "custom_name": null, + "public_key": "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5", + "flags": 0, + "latitude": "34.0522", + "longitude": "-118.2437", + "last_advert": 1768515147, + "last_modified": 1768515165, + "out_path": null }, - "other_settings": { - "manual_add_contacts": 0, - "advert_location_policy": 0 + { + "type": 1, + "name": "TestNode-1", + "custom_name": null, + "public_key": "0102030405060708091011121314151617181920212223242526272829303132", + "flags": 0, + "latitude": "0.0", + "longitude": "0.0", + "last_advert": 1770439152, + "last_modified": 1770439154, + "out_path": "" + } + ] + } + """.utf8) + + /// Channels-only export fixture. + private static let channelsOnlyJSON = Data(""" + { + "channels": [ + { "name": "General", "secret": "aa11bb22cc33dd44ee55ff6600778899" }, + { "name": "Alpha", "secret": "11223344556677889900aabbccddeeff" }, + { "name": "#bravo", "secret": "ffeeddccbbaa99887766554433221100" }, + { "name": "Charlie", "secret": "abcdef0123456789abcdef0123456789" }, + { "name": "#delta", "secret": "0123456789abcdef0123456789abcdef" }, + { "name": "Echo", "secret": "deadbeef01234567deadbeef01234567" } + ] + } + """.utf8) + + /// Contacts-only export fixture. + private static let contactsOnlyJSON = Data(""" + { + "contacts": [ + { + "type": 3, + "name": "Base-W (Room)", + "custom_name": null, + "public_key": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "flags": 0, + "latitude": "40.7128", + "longitude": "-74.006", + "last_advert": 1767392516, + "last_modified": 1767392535, + "out_path": null }, - "channels": [ - { "name": "General", "secret": "aa11bb22cc33dd44ee55ff6600778899" }, - { "name": "Alpha", "secret": "11223344556677889900aabbccddeeff" }, - { "name": "#bravo", "secret": "ffeeddccbbaa99887766554433221100" }, - { "name": "Charlie", "secret": "abcdef0123456789abcdef0123456789" }, - { "name": "#delta", "secret": "0123456789abcdef0123456789abcdef" }, - { "name": "Echo", "secret": "deadbeef01234567deadbeef01234567" } - ], - "contacts": [ - { - "type": 3, - "name": "Base-W (Room)", - "custom_name": null, - "public_key": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - "flags": 0, - "latitude": "40.7128", - "longitude": "-74.006", - "last_advert": 1767392516, - "last_modified": 1767392535, - "out_path": null - }, - { - "type": 2, - "name": "Base-NW (Repeater)", - "custom_name": null, - "public_key": "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5", - "flags": 0, - "latitude": "34.0522", - "longitude": "-118.2437", - "last_advert": 1768515147, - "last_modified": 1768515165, - "out_path": null - }, - { - "type": 1, - "name": "TestNode-1", - "custom_name": null, - "public_key": "0102030405060708091011121314151617181920212223242526272829303132", - "flags": 0, - "latitude": "0.0", - "longitude": "0.0", - "last_advert": 1770439152, - "last_modified": 1770439154, - "out_path": "" - } - ] - } - """.utf8) - - /// Channels-only export fixture. - private static let channelsOnlyJSON = Data(""" + { + "type": 1, + "name": "TestNode-1", + "custom_name": null, + "public_key": "0102030405060708091011121314151617181920212223242526272829303132", + "flags": 0, + "latitude": "0.0", + "longitude": "0.0", + "last_advert": 1770439152, + "last_modified": 1770439154, + "out_path": "" + } + ] + } + """.utf8) + + private static let decoder = JSONDecoder() + + // MARK: - Full config + + @Test + func `Full config decodes all top-level fields`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + + #expect(config.name == "TestNode-2") + #expect(config.publicKey == "d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9") + #expect(config.privateKey == "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d") + #expect(config.radioSettings != nil) + #expect(config.positionSettings != nil) + #expect(config.otherSettings != nil) + #expect(config.channels?.count == 6) + #expect(config.contacts?.count == 3) + } + + @Test + func `Full config decodes radio settings`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let radio = try #require(config.radioSettings) + + #expect(radio.frequency == 910_525) + #expect(radio.bandwidth == 62500) + #expect(radio.spreadingFactor == 7) + #expect(radio.codingRate == 5) + #expect(radio.txPower == 22) + } + + @Test + func `Full config decodes position settings with isZero`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let position = try #require(config.positionSettings) + + #expect(position.latitude == "0.0") + #expect(position.longitude == "0.0") + #expect(position.isZero) + } + + @Test + func `Full config decodes companion-app other settings (2 of 7 fields)`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let other = try #require(config.otherSettings) + + #expect(other.manualAddContacts == 0) + #expect(other.advertLocationPolicy == 0) + // Companion app omits these fields + #expect(other.telemetryModeBase == nil) + #expect(other.telemetryModeLocation == nil) + #expect(other.telemetryModeEnvironment == nil) + #expect(other.multiAcks == nil) + #expect(other.advertisementType == nil) + } + + @Test + func `Full config decodes channel names and secrets`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let channels = try #require(config.channels) + + #expect(channels[0].name == "General") + #expect(channels[0].secret == "aa11bb22cc33dd44ee55ff6600778899") + #expect(channels[2].name == "#bravo") + #expect(channels[5].name == "Echo") + } + + @Test + func `Full config decodes contacts with varying types and positions`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let contacts = try #require(config.contacts) + + // Room (type 3) with position + let room = contacts[0] + #expect(room.type == 3) + #expect(room.name == "Base-W (Room)") + #expect(room.customName == nil) + #expect(room.publicKey == "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6") + #expect(room.flags == 0) + #expect(room.latitude == "40.7128") + #expect(room.longitude == "-74.006") + #expect(room.lastAdvert == 1_767_392_516) + #expect(room.lastModified == 1_767_392_535) + #expect(room.outPath == nil) + + // Repeater (type 2) with position + let repeater = contacts[1] + #expect(repeater.type == 2) + #expect(repeater.name == "Base-NW (Repeater)") + + // Client (type 1) with empty out_path + let client = contacts[2] + #expect(client.type == 1) + #expect(client.name == "TestNode-1") + #expect(client.outPath == "") + } + + // MARK: - Channels-only config + + @Test + func `Channels-only config has nil for absent sections`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.channelsOnlyJSON) + + #expect(config.name == nil) + #expect(config.publicKey == nil) + #expect(config.privateKey == nil) + #expect(config.radioSettings == nil) + #expect(config.positionSettings == nil) + #expect(config.otherSettings == nil) + #expect(config.contacts == nil) + + let channels = try #require(config.channels) + #expect(channels.count == 6) + #expect(channels[0].name == "General") + #expect(channels[4].name == "#delta") + } + + // MARK: - Contacts-only config + + @Test + func `Contacts-only config has nil for absent sections`() throws { + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.contactsOnlyJSON) + + #expect(config.name == nil) + #expect(config.publicKey == nil) + #expect(config.privateKey == nil) + #expect(config.radioSettings == nil) + #expect(config.positionSettings == nil) + #expect(config.otherSettings == nil) + #expect(config.channels == nil) + + let contacts = try #require(config.contacts) + #expect(contacts.count == 2) + #expect(contacts[0].name == "Base-W (Room)") + #expect(contacts[0].outPath == nil) + #expect(contacts[1].name == "TestNode-1") + #expect(contacts[1].outPath == "") + } + + // MARK: - Round-trip + + @Test + func `Round-trip encode/decode preserves all fields`() throws { + let original = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) + let encoded = try JSONEncoder().encode(original) + let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) + + #expect(decoded.name == original.name) + #expect(decoded.publicKey == original.publicKey) + #expect(decoded.privateKey == original.privateKey) + #expect(decoded.radioSettings == original.radioSettings) + #expect(decoded.positionSettings == original.positionSettings) + #expect(decoded.otherSettings == original.otherSettings) + #expect(decoded.channels == original.channels) + #expect(decoded.contacts == original.contacts) + } + + @Test + func `Round-trip preserves channels-only config`() throws { + let original = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.channelsOnlyJSON) + let encoded = try JSONEncoder().encode(original) + let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) + + #expect(decoded.name == nil) + #expect(decoded.channels == original.channels) + #expect(decoded.contacts == nil) + } + + // MARK: - Edge cases + + @Test + func `Contact with null out_path decodes as nil`() throws { + let json = Data(""" { - "channels": [ - { "name": "General", "secret": "aa11bb22cc33dd44ee55ff6600778899" }, - { "name": "Alpha", "secret": "11223344556677889900aabbccddeeff" }, - { "name": "#bravo", "secret": "ffeeddccbbaa99887766554433221100" }, - { "name": "Charlie", "secret": "abcdef0123456789abcdef0123456789" }, - { "name": "#delta", "secret": "0123456789abcdef0123456789abcdef" }, - { "name": "Echo", "secret": "deadbeef01234567deadbeef01234567" } - ] + "contacts": [{ + "type": 1, "name": "Test", "custom_name": null, + "public_key": "aabb", "flags": 0, + "latitude": "0.0", "longitude": "0.0", + "last_advert": 0, "last_modified": 0, + "out_path": null + }] } """.utf8) - /// Contacts-only export fixture. - private static let contactsOnlyJSON = Data(""" + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + let contact = try #require(config.contacts?.first) + #expect(contact.outPath == nil) + } + + @Test + func `Contact with empty string out_path decodes as empty string`() throws { + let json = Data(""" { - "contacts": [ - { - "type": 3, - "name": "Base-W (Room)", - "custom_name": null, - "public_key": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - "flags": 0, - "latitude": "40.7128", - "longitude": "-74.006", - "last_advert": 1767392516, - "last_modified": 1767392535, - "out_path": null - }, - { - "type": 1, - "name": "TestNode-1", - "custom_name": null, - "public_key": "0102030405060708091011121314151617181920212223242526272829303132", - "flags": 0, - "latitude": "0.0", - "longitude": "0.0", - "last_advert": 1770439152, - "last_modified": 1770439154, - "out_path": "" - } - ] + "contacts": [{ + "type": 1, "name": "Test", "custom_name": null, + "public_key": "aabb", "flags": 0, + "latitude": "0.0", "longitude": "0.0", + "last_advert": 0, "last_modified": 0, + "out_path": "" + }] } """.utf8) - private static let decoder = JSONDecoder() - - // MARK: - Full config - - @Test("Full config decodes all top-level fields") - func fullConfigTopLevel() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - - #expect(config.name == "TestNode-2") - #expect(config.publicKey == "d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9") - #expect(config.privateKey == "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d") - #expect(config.radioSettings != nil) - #expect(config.positionSettings != nil) - #expect(config.otherSettings != nil) - #expect(config.channels?.count == 6) - #expect(config.contacts?.count == 3) - } - - @Test("Full config decodes radio settings") - func fullConfigRadioSettings() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let radio = try #require(config.radioSettings) - - #expect(radio.frequency == 910_525) - #expect(radio.bandwidth == 62_500) - #expect(radio.spreadingFactor == 7) - #expect(radio.codingRate == 5) - #expect(radio.txPower == 22) - } - - @Test("Full config decodes position settings with isZero") - func fullConfigPositionSettings() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let position = try #require(config.positionSettings) - - #expect(position.latitude == "0.0") - #expect(position.longitude == "0.0") - #expect(position.isZero) - } - - @Test("Full config decodes companion-app other settings (2 of 7 fields)") - func fullConfigOtherSettings() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let other = try #require(config.otherSettings) - - #expect(other.manualAddContacts == 0) - #expect(other.advertLocationPolicy == 0) - // Companion app omits these fields - #expect(other.telemetryModeBase == nil) - #expect(other.telemetryModeLocation == nil) - #expect(other.telemetryModeEnvironment == nil) - #expect(other.multiAcks == nil) - #expect(other.advertisementType == nil) - } - - @Test("Full config decodes channel names and secrets") - func fullConfigChannels() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let channels = try #require(config.channels) - - #expect(channels[0].name == "General") - #expect(channels[0].secret == "aa11bb22cc33dd44ee55ff6600778899") - #expect(channels[2].name == "#bravo") - #expect(channels[5].name == "Echo") - } - - @Test("Full config decodes contacts with varying types and positions") - func fullConfigContacts() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let contacts = try #require(config.contacts) - - // Room (type 3) with position - let room = contacts[0] - #expect(room.type == 3) - #expect(room.name == "Base-W (Room)") - #expect(room.customName == nil) - #expect(room.publicKey == "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6") - #expect(room.flags == 0) - #expect(room.latitude == "40.7128") - #expect(room.longitude == "-74.006") - #expect(room.lastAdvert == 1_767_392_516) - #expect(room.lastModified == 1_767_392_535) - #expect(room.outPath == nil) - - // Repeater (type 2) with position - let repeater = contacts[1] - #expect(repeater.type == 2) - #expect(repeater.name == "Base-NW (Repeater)") - - // Client (type 1) with empty out_path - let client = contacts[2] - #expect(client.type == 1) - #expect(client.name == "TestNode-1") - #expect(client.outPath == "") - } - - // MARK: - Channels-only config - - @Test("Channels-only config has nil for absent sections") - func channelsOnlyConfig() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.channelsOnlyJSON) - - #expect(config.name == nil) - #expect(config.publicKey == nil) - #expect(config.privateKey == nil) - #expect(config.radioSettings == nil) - #expect(config.positionSettings == nil) - #expect(config.otherSettings == nil) - #expect(config.contacts == nil) - - let channels = try #require(config.channels) - #expect(channels.count == 6) - #expect(channels[0].name == "General") - #expect(channels[4].name == "#delta") - } - - // MARK: - Contacts-only config - - @Test("Contacts-only config has nil for absent sections") - func contactsOnlyConfig() throws { - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.contactsOnlyJSON) - - #expect(config.name == nil) - #expect(config.publicKey == nil) - #expect(config.privateKey == nil) - #expect(config.radioSettings == nil) - #expect(config.positionSettings == nil) - #expect(config.otherSettings == nil) - #expect(config.channels == nil) - - let contacts = try #require(config.contacts) - #expect(contacts.count == 2) - #expect(contacts[0].name == "Base-W (Room)") - #expect(contacts[0].outPath == nil) - #expect(contacts[1].name == "TestNode-1") - #expect(contacts[1].outPath == "") - } - - // MARK: - Round-trip - - @Test("Round-trip encode/decode preserves all fields") - func roundTrip() throws { - let original = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.fullConfigJSON) - let encoded = try JSONEncoder().encode(original) - let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) - - #expect(decoded.name == original.name) - #expect(decoded.publicKey == original.publicKey) - #expect(decoded.privateKey == original.privateKey) - #expect(decoded.radioSettings == original.radioSettings) - #expect(decoded.positionSettings == original.positionSettings) - #expect(decoded.otherSettings == original.otherSettings) - #expect(decoded.channels == original.channels) - #expect(decoded.contacts == original.contacts) - } - - @Test("Round-trip preserves channels-only config") - func roundTripChannelsOnly() throws { - let original = try Self.decoder.decode(MeshCoreNodeConfig.self, from: Self.channelsOnlyJSON) - let encoded = try JSONEncoder().encode(original) - let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) - - #expect(decoded.name == nil) - #expect(decoded.channels == original.channels) - #expect(decoded.contacts == nil) - } - - // MARK: - Edge cases - - @Test("Contact with null out_path decodes as nil") - func nullOutPath() throws { - let json = Data(""" - { - "contacts": [{ - "type": 1, "name": "Test", "custom_name": null, - "public_key": "aabb", "flags": 0, - "latitude": "0.0", "longitude": "0.0", - "last_advert": 0, "last_modified": 0, - "out_path": null - }] - } - """.utf8) - - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - let contact = try #require(config.contacts?.first) - #expect(contact.outPath == nil) - } - - @Test("Contact with empty string out_path decodes as empty string") - func emptyStringOutPath() throws { - let json = Data(""" - { - "contacts": [{ - "type": 1, "name": "Test", "custom_name": null, - "public_key": "aabb", "flags": 0, - "latitude": "0.0", "longitude": "0.0", - "last_advert": 0, "last_modified": 0, - "out_path": "" - }] - } - """.utf8) - - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - let contact = try #require(config.contacts?.first) - #expect(contact.outPath == "") - } - - @Test("Contact with null custom_name decodes as nil") - func nullCustomName() throws { - let json = Data(""" - { - "contacts": [{ - "type": 1, "name": "Test", "custom_name": null, - "public_key": "aabb", "flags": 0, - "latitude": "0.0", "longitude": "0.0", - "last_advert": 0, "last_modified": 0, - "out_path": null - }] - } - """.utf8) - - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - let contact = try #require(config.contacts?.first) - #expect(contact.customName == nil) - } - - @Test("Contact with non-null custom_name decodes correctly") - func nonNullCustomName() throws { - let json = Data(""" - { - "contacts": [{ - "type": 1, "name": "Test", "custom_name": "My Custom Name", - "public_key": "aabb", "flags": 0, - "latitude": "0.0", "longitude": "0.0", - "last_advert": 0, "last_modified": 0, - "out_path": null - }] - } - """.utf8) - - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - let contact = try #require(config.contacts?.first) - #expect(contact.customName == "My Custom Name") - } - - @Test("Empty JSON object decodes with all nil fields") - func emptyObject() throws { - let json = Data("{}".utf8) - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - - #expect(config.name == nil) - #expect(config.publicKey == nil) - #expect(config.privateKey == nil) - #expect(config.radioSettings == nil) - #expect(config.positionSettings == nil) - #expect(config.otherSettings == nil) - #expect(config.channels == nil) - #expect(config.contacts == nil) - } - - @Test("ContactConfig encodes nil customName and outPath as explicit null") - func contactConfigEncodesNullFields() throws { - let contact = MeshCoreNodeConfig.ContactConfig( - type: 1, name: "Test", - publicKey: "aabb", flags: 0, - latitude: "0.0", longitude: "0.0", - lastAdvert: 0, lastModified: 0 - ) - - let data = try JSONEncoder().encode(contact) - let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - // Both keys must be present with NSNull (JSON null) - #expect(json["custom_name"] is NSNull) - #expect(json["out_path"] is NSNull) - } - - @Test("ContactConfig encodes non-nil customName and outPath as values") - func contactConfigEncodesNonNullFields() throws { - let contact = MeshCoreNodeConfig.ContactConfig( - type: 1, name: "Test", customName: "Nick", - publicKey: "aabb", flags: 0, - latitude: "0.0", longitude: "0.0", - lastAdvert: 0, lastModified: 0, - outPath: "aabbcc" - ) - - let data = try JSONEncoder().encode(contact) - let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - #expect(json["custom_name"] as? String == "Nick") - #expect(json["out_path"] as? String == "aabbcc") - } + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + let contact = try #require(config.contacts?.first) + #expect(contact.outPath == "") + } - @Test("Position isZero returns false for non-zero coordinates") - func positionIsZeroFalse() { - let position = MeshCoreNodeConfig.PositionSettings( - latitude: "40.7128", - longitude: "-74.006" - ) - #expect(!position.isZero) - } - - @Test("OtherSettings with all 7 fields round-trips correctly") - func otherSettingsFullRoundTrip() throws { - let json = Data(""" - { - "other_settings": { - "manual_add_contacts": 1, - "advert_location_policy": 2, - "telemetry_mode_base": 3, - "telemetry_mode_location": 4, - "telemetry_mode_environment": 5, - "multi_acks": 6, - "advertisement_type": 7 - } - } - """.utf8) - - let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) - let other = try #require(config.otherSettings) - - #expect(other.manualAddContacts == 1) - #expect(other.advertLocationPolicy == 2) - #expect(other.telemetryModeBase == 3) - #expect(other.telemetryModeLocation == 4) - #expect(other.telemetryModeEnvironment == 5) - #expect(other.multiAcks == 6) - #expect(other.advertisementType == 7) - - // Round-trip - let encoded = try JSONEncoder().encode(config) - let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) - #expect(decoded.otherSettings == other) - } - - // MARK: - ConfigSections - - @Test("ConfigSections defaults to all false") - func configSectionsDefaults() { - let sections = ConfigSections() - - #expect(!sections.nodeIdentity) - #expect(!sections.radioSettings) - #expect(!sections.positionSettings) - #expect(!sections.otherSettings) - #expect(!sections.channels) - #expect(!sections.contacts) - #expect(!sections.allSelected) - #expect(!sections.anySectionSelected) + @Test + func `Contact with null custom_name decodes as nil`() throws { + let json = Data(""" + { + "contacts": [{ + "type": 1, "name": "Test", "custom_name": null, + "public_key": "aabb", "flags": 0, + "latitude": "0.0", "longitude": "0.0", + "last_advert": 0, "last_modified": 0, + "out_path": null + }] } + """.utf8) - @Test("ConfigSections allSelected is false when any section is false") - func configSectionsPartial() { - var sections = ConfigSections() - sections.selectAll() - sections.channels = false - - #expect(!sections.allSelected) - } + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + let contact = try #require(config.contacts?.first) + #expect(contact.customName == nil) + } - @Test("ConfigSections allSelected is false when only one section is true") - func configSectionsMinimal() { - let sections = ConfigSections( - nodeIdentity: false, - radioSettings: false, - positionSettings: false, - otherSettings: false, - channels: true, - contacts: false - ) - - #expect(!sections.allSelected) + @Test + func `Contact with non-null custom_name decodes correctly`() throws { + let json = Data(""" + { + "contacts": [{ + "type": 1, "name": "Test", "custom_name": "My Custom Name", + "public_key": "aabb", "flags": 0, + "latitude": "0.0", "longitude": "0.0", + "last_advert": 0, "last_modified": 0, + "out_path": null + }] } + """.utf8) - @Test("ConfigSections selectAll sets all to true") - func configSectionsSelectAll() { - var sections = ConfigSections() - sections.selectAll() - - #expect(sections.allSelected) - #expect(sections.anySectionSelected) + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + let contact = try #require(config.contacts?.first) + #expect(contact.customName == "My Custom Name") + } + + @Test + func `Empty JSON object decodes with all nil fields`() throws { + let json = Data("{}".utf8) + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + + #expect(config.name == nil) + #expect(config.publicKey == nil) + #expect(config.privateKey == nil) + #expect(config.radioSettings == nil) + #expect(config.positionSettings == nil) + #expect(config.otherSettings == nil) + #expect(config.channels == nil) + #expect(config.contacts == nil) + } + + @Test + func `ContactConfig encodes nil customName and outPath as explicit null`() throws { + let contact = MeshCoreNodeConfig.ContactConfig( + type: 1, name: "Test", + publicKey: "aabb", flags: 0, + latitude: "0.0", longitude: "0.0", + lastAdvert: 0, lastModified: 0 + ) + + let data = try JSONEncoder().encode(contact) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // Both keys must be present with NSNull (JSON null) + #expect(json["custom_name"] is NSNull) + #expect(json["out_path"] is NSNull) + } + + @Test + func `ContactConfig encodes non-nil customName and outPath as values`() throws { + let contact = MeshCoreNodeConfig.ContactConfig( + type: 1, name: "Test", customName: "Nick", + publicKey: "aabb", flags: 0, + latitude: "0.0", longitude: "0.0", + lastAdvert: 0, lastModified: 0, + outPath: "aabbcc" + ) + + let data = try JSONEncoder().encode(contact) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(json["custom_name"] as? String == "Nick") + #expect(json["out_path"] as? String == "aabbcc") + } + + @Test + func `Position isZero returns false for non-zero coordinates`() { + let position = MeshCoreNodeConfig.PositionSettings( + latitude: "40.7128", + longitude: "-74.006" + ) + #expect(!position.isZero) + } + + @Test + func `OtherSettings with all 7 fields round-trips correctly`() throws { + let json = Data(""" + { + "other_settings": { + "manual_add_contacts": 1, + "advert_location_policy": 2, + "telemetry_mode_base": 3, + "telemetry_mode_location": 4, + "telemetry_mode_environment": 5, + "multi_acks": 6, + "advertisement_type": 7 + } } + """.utf8) - @Test("ConfigSections deselectAll sets all to false") - func configSectionsDeselectAll() { - var sections = ConfigSections() - sections.selectAll() - sections.deselectAll() - - #expect(!sections.allSelected) - #expect(!sections.anySectionSelected) - } + let config = try Self.decoder.decode(MeshCoreNodeConfig.self, from: json) + let other = try #require(config.otherSettings) + + #expect(other.manualAddContacts == 1) + #expect(other.advertLocationPolicy == 2) + #expect(other.telemetryModeBase == 3) + #expect(other.telemetryModeLocation == 4) + #expect(other.telemetryModeEnvironment == 5) + #expect(other.multiAcks == 6) + #expect(other.advertisementType == 7) + + // Round-trip + let encoded = try JSONEncoder().encode(config) + let decoded = try Self.decoder.decode(MeshCoreNodeConfig.self, from: encoded) + #expect(decoded.otherSettings == other) + } + + // MARK: - ConfigSections + + @Test + func `ConfigSections defaults to all false`() { + let sections = ConfigSections() + + #expect(!sections.nodeIdentity) + #expect(!sections.radioSettings) + #expect(!sections.positionSettings) + #expect(!sections.otherSettings) + #expect(!sections.channels) + #expect(!sections.contacts) + #expect(!sections.allSelected) + #expect(!sections.anySectionSelected) + } + + @Test + func `ConfigSections allSelected is false when any section is false`() { + var sections = ConfigSections() + sections.selectAll() + sections.channels = false + + #expect(!sections.allSelected) + } + + @Test + func `ConfigSections allSelected is false when only one section is true`() { + let sections = ConfigSections( + nodeIdentity: false, + radioSettings: false, + positionSettings: false, + otherSettings: false, + channels: true, + contacts: false + ) + + #expect(!sections.allSelected) + } + + @Test + func `ConfigSections selectAll sets all to true`() { + var sections = ConfigSections() + sections.selectAll() + + #expect(sections.allSelected) + #expect(sections.anySectionSelected) + } + + @Test + func `ConfigSections deselectAll sets all to false`() { + var sections = ConfigSections() + sections.selectAll() + sections.deselectAll() + + #expect(!sections.allSelected) + #expect(!sections.anySectionSelected) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NotificationActionHandlerTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NotificationActionHandlerTests.swift index 758cbd30..6b753f69 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NotificationActionHandlerTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NotificationActionHandlerTests.swift @@ -1,172 +1,200 @@ import Foundation -import Testing -import MeshCore @testable import MC1Services +import MeshCore +import Testing // MARK: - Helpers @MainActor private func makeServicesAndHandler() async throws -> (ServiceContainer, NotificationActionHandler) { - let session = MeshCoreSession(transport: SimulatorMockTransport()) - let services = try await ServiceContainer.forTesting(session: session) - return (services, services.notificationActionHandler) + let session = MeshCoreSession(transport: SimulatorMockTransport()) + let services = try await ServiceContainer.forTesting(session: session) + return (services, services.notificationActionHandler) } @Suite("NotificationActionHandler Tests") struct NotificationActionHandlerTests { - - /// Minimal provider for channel-name fallback selection tests. - private struct MockStringProvider: NotificationStringProvider { - func discoveryNotificationTitle(for type: ContactType) -> String { "Mock Title" } - var replyActionTitle: String { "Mock Reply" } - var sendButtonTitle: String { "Mock Send" } - var messagePlaceholder: String { "Mock Placeholder" } - var markAsReadActionTitle: String { "Mock Mark as Read" } - var lowBatteryTitle: String { "Mock Low Battery" } - func lowBatteryBody(deviceName: String, percentage: Int) -> String { "Mock Battery" } - var quickReplyFailedTitle: String { "Mock Not Sent" } - func quickReplyFailedBody(conversationName: String) -> String { "Mock Failed" } - var unknownContactName: String { "Mock Unknown" } - - func defaultChannelName(index: Int) -> String { - "Localized Channel \(index)" - } - - func reactionNotificationBody(emoji: String, messagePreview: String) -> String { - "Mock reacted \(emoji) to \(messagePreview)" - } + /// Minimal provider for channel-name fallback selection tests. + private struct MockStringProvider: NotificationStringProvider { + func discoveryNotificationTitle(for type: ContactType) -> String { + "Mock Title" } - @MainActor - private func makeHandler() async throws -> NotificationActionHandler { - let session = MeshCoreSession(transport: SimulatorMockTransport()) - let services = try await ServiceContainer.forTesting(session: session) - return services.notificationActionHandler + var replyActionTitle: String { + "Mock Reply" } - // MARK: - Reaction Configure Guard - - @Test("Handler is not configured before configure() is called") - @MainActor - func handlerNotConfiguredBeforeConfigure() async throws { - let handler = try await makeHandler() - #expect(handler.isConfigured == false) + var sendButtonTitle: String { + "Mock Send" } - @Test("Handler is configured after configure() is called") - @MainActor - func handlerConfiguredAfterConfigure() async throws { - let handler = try await makeHandler() - handler.configure(isConnectionReady: { true }, localNodeName: { nil }) - #expect(handler.isConfigured == true) + var messagePlaceholder: String { + "Mock Placeholder" } - @Test("Reaction notification before configure completes without posting") - @MainActor - func reactionNotificationBeforeConfigureDoesNotPost() async throws { - let (services, handler) = try await makeServicesAndHandler() - #expect(handler.isConfigured == false) - - let radioID = UUID() - let contactID = UUID() - let messageID = UUID() - let message = MessageDTO( - id: messageID, radioID: radioID, contactID: contactID, - channelIndex: nil, text: "Hello", timestamp: 1000, - createdAt: Date(), direction: .outgoing, status: .sent, - textType: .plain, ackCode: nil, pathLength: 0, snr: nil, - senderKeyPrefix: nil, senderNodeName: "Alice", - isRead: true, replyToID: nil, roundTripTime: nil, - heardRepeats: 0, retryAttempt: 0, maxRetryAttempts: 3 - ) - try await services.dataStore.saveMessage(message) - let reaction = ReactionDTO( - messageID: messageID, emoji: "👍", senderName: "Bob", - messageHash: "hash", rawText: "raw", contactID: contactID, radioID: radioID - ) - try await services.dataStore.saveReaction(reaction) - - // Should return early before reaching the notification post path - await handler.handleReactionNotification(messageID: messageID) - // Reaching here confirms no crash and no spurious self-notification + var markAsReadActionTitle: String { + "Mock Mark as Read" } - @Test("Reaction notification after configure is self-suppressed when names match") - @MainActor - func reactionNotificationSelfSuppressedAfterConfigure() async throws { - let (services, handler) = try await makeServicesAndHandler() - let selfName = "Alice" - handler.configure(isConnectionReady: { true }, localNodeName: { selfName }) - #expect(handler.isConfigured == true) - - let radioID = UUID() - let contactID = UUID() - let messageID = UUID() - let message = MessageDTO( - id: messageID, radioID: radioID, contactID: contactID, - channelIndex: nil, text: "Hello", timestamp: 1000, - createdAt: Date(), direction: .outgoing, status: .sent, - textType: .plain, ackCode: nil, pathLength: 0, snr: nil, - senderKeyPrefix: nil, senderNodeName: selfName, - isRead: true, replyToID: nil, roundTripTime: nil, - heardRepeats: 0, retryAttempt: 0, maxRetryAttempts: 3 - ) - try await services.dataStore.saveMessage(message) - let reaction = ReactionDTO( - messageID: messageID, emoji: "👍", senderName: selfName, - messageHash: "hash", rawText: "raw", contactID: contactID, radioID: radioID - ) - try await services.dataStore.saveReaction(reaction) - - // Self-reaction: should be suppressed (senderName == localNodeName) - await handler.handleReactionNotification(messageID: messageID) - // Completing without posting confirms self-suppression works after configure + var lowBatteryTitle: String { + "Mock Low Battery" } - // MARK: - Reaction Preview Truncation - - @Test("Text at 49 characters is returned unchanged") - func previewBelowLimitUnchanged() { - let text = String(repeating: "a", count: 49) - #expect(NotificationActionHandler.reactionPreview(for: text) == text) + func lowBatteryBody(deviceName: String, percentage: Int) -> String { + "Mock Battery" } - @Test("Text at exactly 50 characters is returned unchanged") - func previewAtLimitUnchanged() { - let text = String(repeating: "b", count: 50) - #expect(NotificationActionHandler.reactionPreview(for: text) == text) + var quickReplyFailedTitle: String { + "Mock Not Sent" } - @Test("Text at 51 characters is truncated to 47 plus ellipsis") - func previewOverLimitTruncated() { - let text = String(repeating: "c", count: 51) - let expected = String(repeating: "c", count: 47) + "..." - #expect(NotificationActionHandler.reactionPreview(for: text) == expected) + func quickReplyFailedBody(conversationName: String) -> String { + "Mock Failed" } - // MARK: - Channel Display Name Fallback - - @Test("Stored channel name wins over the localized fallback") - @MainActor - func channelDisplayNamePrefersStoredName() async throws { - let handler = try await makeHandler() - #expect(handler.channelDisplayName(name: "Rescue Net", index: 2) == "Rescue Net") + var unknownContactName: String { + "Mock Unknown" } - @Test("Missing name falls back to the string provider") - @MainActor - func channelDisplayNameUsesProvider() async throws { - let session = MeshCoreSession(transport: SimulatorMockTransport()) - let services = try await ServiceContainer.forTesting(session: session) - services.notificationService.setStringProvider(MockStringProvider()) - let handler = services.notificationActionHandler - #expect(handler.channelDisplayName(name: nil, index: 3) == "Localized Channel 3") + func defaultChannelName(index: Int) -> String { + "Localized Channel \(index)" } - @Test("Missing name and provider fall back to the English literal") - @MainActor - func channelDisplayNameLastResortLiteral() async throws { - let handler = try await makeHandler() - #expect(handler.channelDisplayName(name: nil, index: 7) == "Channel 7") + func reactionNotificationBody(emoji: String, messagePreview: String) -> String { + "Mock reacted \(emoji) to \(messagePreview)" } + } + + @MainActor + private func makeHandler() async throws -> NotificationActionHandler { + let session = MeshCoreSession(transport: SimulatorMockTransport()) + let services = try await ServiceContainer.forTesting(session: session) + return services.notificationActionHandler + } + + // MARK: - Reaction Configure Guard + + @Test + @MainActor + func `Handler is not configured before configure() is called`() async throws { + let handler = try await makeHandler() + #expect(handler.isConfigured == false) + } + + @Test + @MainActor + func `Handler is configured after configure() is called`() async throws { + let handler = try await makeHandler() + handler.configure(isConnectionReady: { true }, localNodeName: { nil }) + #expect(handler.isConfigured == true) + } + + @Test + @MainActor + func `Reaction notification before configure completes without posting`() async throws { + let (services, handler) = try await makeServicesAndHandler() + #expect(handler.isConfigured == false) + + let radioID = UUID() + let contactID = UUID() + let messageID = UUID() + let message = MessageDTO( + id: messageID, radioID: radioID, contactID: contactID, + channelIndex: nil, text: "Hello", timestamp: 1000, + createdAt: Date(), direction: .outgoing, status: .sent, + textType: .plain, ackCode: nil, pathLength: 0, snr: nil, + senderKeyPrefix: nil, senderNodeName: "Alice", + isRead: true, replyToID: nil, roundTripTime: nil, + heardRepeats: 0, retryAttempt: 0, maxRetryAttempts: 3 + ) + try await services.dataStore.saveMessage(message) + let reaction = ReactionDTO( + messageID: messageID, emoji: "👍", senderName: "Bob", + messageHash: "hash", rawText: "raw", contactID: contactID, radioID: radioID + ) + try await services.dataStore.saveReaction(reaction) + + // Should return early before reaching the notification post path + await handler.handleReactionNotification(messageID: messageID) + // Reaching here confirms no crash and no spurious self-notification + } + + @Test + @MainActor + func `Reaction notification after configure is self-suppressed when names match`() async throws { + let (services, handler) = try await makeServicesAndHandler() + let selfName = "Alice" + handler.configure(isConnectionReady: { true }, localNodeName: { selfName }) + #expect(handler.isConfigured == true) + + let radioID = UUID() + let contactID = UUID() + let messageID = UUID() + let message = MessageDTO( + id: messageID, radioID: radioID, contactID: contactID, + channelIndex: nil, text: "Hello", timestamp: 1000, + createdAt: Date(), direction: .outgoing, status: .sent, + textType: .plain, ackCode: nil, pathLength: 0, snr: nil, + senderKeyPrefix: nil, senderNodeName: selfName, + isRead: true, replyToID: nil, roundTripTime: nil, + heardRepeats: 0, retryAttempt: 0, maxRetryAttempts: 3 + ) + try await services.dataStore.saveMessage(message) + let reaction = ReactionDTO( + messageID: messageID, emoji: "👍", senderName: selfName, + messageHash: "hash", rawText: "raw", contactID: contactID, radioID: radioID + ) + try await services.dataStore.saveReaction(reaction) + + // Self-reaction: should be suppressed (senderName == localNodeName) + await handler.handleReactionNotification(messageID: messageID) + // Completing without posting confirms self-suppression works after configure + } + + // MARK: - Reaction Preview Truncation + + @Test + func `Text at 49 characters is returned unchanged`() { + let text = String(repeating: "a", count: 49) + #expect(NotificationActionHandler.reactionPreview(for: text) == text) + } + + @Test + func `Text at exactly 50 characters is returned unchanged`() { + let text = String(repeating: "b", count: 50) + #expect(NotificationActionHandler.reactionPreview(for: text) == text) + } + + @Test + func `Text at 51 characters is truncated to 47 plus ellipsis`() { + let text = String(repeating: "c", count: 51) + let expected = String(repeating: "c", count: 47) + "..." + #expect(NotificationActionHandler.reactionPreview(for: text) == expected) + } + + // MARK: - Channel Display Name Fallback + + @Test + @MainActor + func `Stored channel name wins over the localized fallback`() async throws { + let handler = try await makeHandler() + #expect(handler.channelDisplayName(name: "Rescue Net", index: 2) == "Rescue Net") + } + + @Test + @MainActor + func `Missing name falls back to the string provider`() async throws { + let session = MeshCoreSession(transport: SimulatorMockTransport()) + let services = try await ServiceContainer.forTesting(session: session) + services.notificationService.setStringProvider(MockStringProvider()) + let handler = services.notificationActionHandler + #expect(handler.channelDisplayName(name: nil, index: 3) == "Localized Channel 3") + } + + @Test + @MainActor + func `Missing name and provider fall back to the English literal`() async throws { + let handler = try await makeHandler() + #expect(handler.channelDisplayName(name: nil, index: 7) == "Channel 7") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NotificationServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NotificationServiceTests.swift index 1ad398f3..af9f0adb 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NotificationServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NotificationServiceTests.swift @@ -1,275 +1,274 @@ // NotificationServiceTests.swift import Foundation -import Testing @testable import MC1Services +import Testing @Suite("NotificationService Tests") struct NotificationServiceTests { - - @Test("Suppression flag defaults to false") - @MainActor - func suppressionFlagDefaultsToFalse() async { - let service = NotificationService() - #expect(service.isSuppressingNotifications == false) - } - - @Test("Suppression flag can be set and cleared") - @MainActor - func suppressionFlagCanBeSetAndCleared() async { - let service = NotificationService() - - service.isSuppressingNotifications = true - #expect(service.isSuppressingNotifications == true) - - service.isSuppressingNotifications = false - #expect(service.isSuppressingNotifications == false) - } - - @Test("Suppression flag can be toggled multiple times") - @MainActor - func suppressionFlagCanBeToggledMultipleTimes() async { - let service = NotificationService() - - // Toggle several times - service.isSuppressingNotifications = true - service.isSuppressingNotifications = false - service.isSuppressingNotifications = true - service.isSuppressingNotifications = true // Setting same value - service.isSuppressingNotifications = false - - #expect(service.isSuppressingNotifications == false) - } - - @Test("postNewContactNotification uses provider for title") - @MainActor - func postNewContactNotificationUsesProviderForTitle() async { - // This test verifies the method signature accepts ContactType - // Actual notification posting requires UNUserNotificationCenter authorization - let service = NotificationService() - - // Verify method exists with correct signature (compile-time check) - // The actual notification won't post without authorization, but we can verify - // the provider is called by checking the method accepts the new parameter - await service.postNewContactNotification( - contactName: "TestNode", - contactID: UUID(), - contactType: ContactType.repeater - ) - - // If we got here without compile error, the signature is correct - #expect(true) - } - - // MARK: - Reaction Notification Tests - - @Test("postReactionNotification has correct method signature") - @MainActor - func postReactionNotificationHasCorrectSignature() async { - let service = NotificationService() - - // Verify method exists with correct signature (compile-time check) - // Actual notification won't post without authorization - await service.postReactionNotification( - reactorName: "Alice", - body: "Reacted 👍 to your message: \"Hello world\"", - messageID: UUID(), - contactID: UUID(), - channelIndex: nil, - radioID: nil - ) - - #expect(true) - } - - @Test("postReactionNotification accepts channel parameters") - @MainActor - func postReactionNotificationAcceptsChannelParameters() async { - let service = NotificationService() - - // Verify method accepts channel parameters for channel reactions - await service.postReactionNotification( - reactorName: "Bob", - body: "Reacted ❤️ to your message: \"Team update\"", - messageID: UUID(), - contactID: nil, - channelIndex: 3, - radioID: UUID() - ) - - #expect(true) - } - - @Test("onReactionNotificationTapped callback can be set") - @MainActor - func onReactionNotificationTappedCallbackCanBeSet() async { - let service = NotificationService() - var callbackInvoked = false - - service.onReactionNotificationTapped = { _, _, _, _ in - callbackInvoked = true - } - - // Verify callback is settable - #expect(service.onReactionNotificationTapped != nil) - - // Invoke callback to verify it works - await service.onReactionNotificationTapped?(UUID(), nil, nil, UUID()) - #expect(callbackInvoked) - } - - @Test("onReactionNotificationTapped receives all parameters") - @MainActor - func onReactionNotificationTappedReceivesAllParameters() async { - let service = NotificationService() - let expectedContactID = UUID() - let expectedChannelIndex: UInt8 = 5 - let expectedDeviceID = UUID() - let expectedMessageID = UUID() - - var receivedContactID: UUID? - var receivedChannelIndex: UInt8? - var receivedDeviceID: UUID? - var receivedMessageID: UUID? - - service.onReactionNotificationTapped = { contactID, channelIndex, radioID, messageID in - receivedContactID = contactID - receivedChannelIndex = channelIndex - receivedDeviceID = radioID - receivedMessageID = messageID - } - - await service.onReactionNotificationTapped?( - expectedContactID, - expectedChannelIndex, - expectedDeviceID, - expectedMessageID - ) - - #expect(receivedContactID == expectedContactID) - #expect(receivedChannelIndex == expectedChannelIndex) - #expect(receivedDeviceID == expectedDeviceID) - #expect(receivedMessageID == expectedMessageID) - } - - @Test("Room message notification is suppressed when isSuppressingNotifications is true") - @MainActor - func roomMessageNotificationSuppressedDuringSync() async { - let service = NotificationService() - service.isSuppressingNotifications = true - - // Should return without posting (no crash, no notification) - await service.postRoomMessageNotification( - roomName: "TestRoom", - sessionID: UUID(), - senderName: "Alice", - messageText: "Hello", - messageID: UUID(), - notificationLevel: .all - ) - - // Badge count should not increment when suppressed - #expect(service.badgeCount == 0) - } - - @Test("Notification category includes reaction") - func notificationCategoryIncludesReaction() { - // Verify reaction category exists in the enum - let category = NotificationCategory.reaction - #expect(category.rawValue == "REACTION") + @Test + @MainActor + func `Suppression flag defaults to false`() { + let service = NotificationService() + #expect(service.isSuppressingNotifications == false) + } + + @Test + @MainActor + func `Suppression flag can be set and cleared`() { + let service = NotificationService() + + service.isSuppressingNotifications = true + #expect(service.isSuppressingNotifications == true) + + service.isSuppressingNotifications = false + #expect(service.isSuppressingNotifications == false) + } + + @Test + @MainActor + func `Suppression flag can be toggled multiple times`() { + let service = NotificationService() + + // Toggle several times + service.isSuppressingNotifications = true + service.isSuppressingNotifications = false + service.isSuppressingNotifications = true + service.isSuppressingNotifications = true // Setting same value + service.isSuppressingNotifications = false + + #expect(service.isSuppressingNotifications == false) + } + + @Test + @MainActor + func `postNewContactNotification uses provider for title`() async { + // This test verifies the method signature accepts ContactType + // Actual notification posting requires UNUserNotificationCenter authorization + let service = NotificationService() + + // Verify method exists with correct signature (compile-time check) + // The actual notification won't post without authorization, but we can verify + // the provider is called by checking the method accepts the new parameter + await service.postNewContactNotification( + contactName: "TestNode", + contactID: UUID(), + contactType: ContactType.repeater + ) + + // If we got here without compile error, the signature is correct + #expect(true) + } + + // MARK: - Reaction Notification Tests + + @Test + @MainActor + func `postReactionNotification has correct method signature`() async { + let service = NotificationService() + + // Verify method exists with correct signature (compile-time check) + // Actual notification won't post without authorization + await service.postReactionNotification( + reactorName: "Alice", + body: "Reacted 👍 to your message: \"Hello world\"", + messageID: UUID(), + contactID: UUID(), + channelIndex: nil, + radioID: nil + ) + + #expect(true) + } + + @Test + @MainActor + func `postReactionNotification accepts channel parameters`() async { + let service = NotificationService() + + // Verify method accepts channel parameters for channel reactions + await service.postReactionNotification( + reactorName: "Bob", + body: "Reacted ❤️ to your message: \"Team update\"", + messageID: UUID(), + contactID: nil, + channelIndex: 3, + radioID: UUID() + ) + + #expect(true) + } + + @Test + @MainActor + func `onReactionNotificationTapped callback can be set`() async { + let service = NotificationService() + var callbackInvoked = false + + service.onReactionNotificationTapped = { _, _, _, _ in + callbackInvoked = true } - // MARK: - Room Notification Tests - - @Test("Active room session tracking can be set and cleared") - @MainActor - func activeRoomSessionTrackingCanBeSetAndCleared() async { - let service = NotificationService() - let sessionID = UUID() - - #expect(service.activeRoomSessionID == nil) - - service.activeRoomSessionID = sessionID - #expect(service.activeRoomSessionID == sessionID) - - service.activeRoomSessionID = nil - #expect(service.activeRoomSessionID == nil) + // Verify callback is settable + #expect(service.onReactionNotificationTapped != nil) + + // Invoke callback to verify it works + await service.onReactionNotificationTapped?(UUID(), nil, nil, UUID()) + #expect(callbackInvoked) + } + + @Test + @MainActor + func `onReactionNotificationTapped receives all parameters`() async { + let service = NotificationService() + let expectedContactID = UUID() + let expectedChannelIndex: UInt8 = 5 + let expectedDeviceID = UUID() + let expectedMessageID = UUID() + + var receivedContactID: UUID? + var receivedChannelIndex: UInt8? + var receivedDeviceID: UUID? + var receivedMessageID: UUID? + + service.onReactionNotificationTapped = { contactID, channelIndex, radioID, messageID in + receivedContactID = contactID + receivedChannelIndex = channelIndex + receivedDeviceID = radioID + receivedMessageID = messageID } - @Test("setActiveConversation populates only the passed slot and clears the rest") - @MainActor - func setActiveConversationIsAtomicAcrossTypes() async { - let service = NotificationService() - let contactID = UUID() - let channelRadioID = UUID() - let roomSessionID = UUID() - - // Pre-populate every slot so the setter must clear the unpassed ones. - service.activeContactID = contactID - service.activeChannelIndex = 3 - service.activeChannelRadioID = channelRadioID - service.activeRoomSessionID = roomSessionID - - // Opening a DM clears channel and room slots. - service.setActiveConversation(contactID: contactID) - #expect(service.activeContactID == contactID) - #expect(service.activeChannelIndex == nil) - #expect(service.activeChannelRadioID == nil) - #expect(service.activeRoomSessionID == nil) - - // Opening a channel clears the contact slot. - service.setActiveConversation(channelIndex: 5, channelRadioID: channelRadioID) - #expect(service.activeContactID == nil) - #expect(service.activeChannelIndex == 5) - #expect(service.activeChannelRadioID == channelRadioID) - #expect(service.activeRoomSessionID == nil) - - // Opening a room clears the channel slots. - service.setActiveConversation(roomSessionID: roomSessionID) - #expect(service.activeContactID == nil) - #expect(service.activeChannelIndex == nil) - #expect(service.activeChannelRadioID == nil) - #expect(service.activeRoomSessionID == roomSessionID) + await service.onReactionNotificationTapped?( + expectedContactID, + expectedChannelIndex, + expectedDeviceID, + expectedMessageID + ) + + #expect(receivedContactID == expectedContactID) + #expect(receivedChannelIndex == expectedChannelIndex) + #expect(receivedDeviceID == expectedDeviceID) + #expect(receivedMessageID == expectedMessageID) + } + + @Test + @MainActor + func `Room message notification is suppressed when isSuppressingNotifications is true`() async { + let service = NotificationService() + service.isSuppressingNotifications = true + + // Should return without posting (no crash, no notification) + await service.postRoomMessageNotification( + roomName: "TestRoom", + sessionID: UUID(), + senderName: "Alice", + messageText: "Hello", + messageID: UUID(), + notificationLevel: .all + ) + + // Badge count should not increment when suppressed + #expect(service.badgeCount == 0) + } + + @Test + func `Notification category includes reaction`() { + // Verify reaction category exists in the enum + let category = NotificationCategory.reaction + #expect(category.rawValue == "REACTION") + } + + // MARK: - Room Notification Tests + + @Test + @MainActor + func `Active room session tracking can be set and cleared`() { + let service = NotificationService() + let sessionID = UUID() + + #expect(service.activeRoomSessionID == nil) + + service.activeRoomSessionID = sessionID + #expect(service.activeRoomSessionID == sessionID) + + service.activeRoomSessionID = nil + #expect(service.activeRoomSessionID == nil) + } + + @Test + @MainActor + func `setActiveConversation populates only the passed slot and clears the rest`() { + let service = NotificationService() + let contactID = UUID() + let channelRadioID = UUID() + let roomSessionID = UUID() + + // Pre-populate every slot so the setter must clear the unpassed ones. + service.activeContactID = contactID + service.activeChannelIndex = 3 + service.activeChannelRadioID = channelRadioID + service.activeRoomSessionID = roomSessionID + + // Opening a DM clears channel and room slots. + service.setActiveConversation(contactID: contactID) + #expect(service.activeContactID == contactID) + #expect(service.activeChannelIndex == nil) + #expect(service.activeChannelRadioID == nil) + #expect(service.activeRoomSessionID == nil) + + // Opening a channel clears the contact slot. + service.setActiveConversation(channelIndex: 5, channelRadioID: channelRadioID) + #expect(service.activeContactID == nil) + #expect(service.activeChannelIndex == 5) + #expect(service.activeChannelRadioID == channelRadioID) + #expect(service.activeRoomSessionID == nil) + + // Opening a room clears the channel slots. + service.setActiveConversation(roomSessionID: roomSessionID) + #expect(service.activeContactID == nil) + #expect(service.activeChannelIndex == nil) + #expect(service.activeChannelRadioID == nil) + #expect(service.activeRoomSessionID == roomSessionID) + } + + @Test + @MainActor + func `onRoomMarkAsRead callback can be set and receives parameters`() async { + let service = NotificationService() + let expectedSessionID = UUID() + let expectedMessageID = UUID() + + var receivedSessionID: UUID? + var receivedMessageID: UUID? + + service.onRoomMarkAsRead = { sessionID, messageID in + receivedSessionID = sessionID + receivedMessageID = messageID } - @Test("onRoomMarkAsRead callback can be set and receives parameters") - @MainActor - func onRoomMarkAsReadCallbackReceivesParameters() async { - let service = NotificationService() - let expectedSessionID = UUID() - let expectedMessageID = UUID() + #expect(service.onRoomMarkAsRead != nil) - var receivedSessionID: UUID? - var receivedMessageID: UUID? + await service.onRoomMarkAsRead?(expectedSessionID, expectedMessageID) - service.onRoomMarkAsRead = { sessionID, messageID in - receivedSessionID = sessionID - receivedMessageID = messageID - } + #expect(receivedSessionID == expectedSessionID) + #expect(receivedMessageID == expectedMessageID) + } - #expect(service.onRoomMarkAsRead != nil) + @Test + @MainActor + func `onRoomNotificationTapped callback can be set and receives the session ID`() async { + let service = NotificationService() + let expectedSessionID = UUID() - await service.onRoomMarkAsRead?(expectedSessionID, expectedMessageID) + var receivedSessionID: UUID? - #expect(receivedSessionID == expectedSessionID) - #expect(receivedMessageID == expectedMessageID) + service.onRoomNotificationTapped = { sessionID in + receivedSessionID = sessionID } - @Test("onRoomNotificationTapped callback can be set and receives the session ID") - @MainActor - func onRoomNotificationTappedCallbackReceivesSessionID() async { - let service = NotificationService() - let expectedSessionID = UUID() + #expect(service.onRoomNotificationTapped != nil) - var receivedSessionID: UUID? + await service.onRoomNotificationTapped?(expectedSessionID) - service.onRoomNotificationTapped = { sessionID in - receivedSessionID = sessionID - } - - #expect(service.onRoomNotificationTapped != nil) - - await service.onRoomNotificationTapped?(expectedSessionID) - - #expect(receivedSessionID == expectedSessionID) - } + #expect(receivedSessionID == expectedSessionID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/NotificationStringProviderTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/NotificationStringProviderTests.swift index 7a18a050..25a89e88 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/NotificationStringProviderTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/NotificationStringProviderTests.swift @@ -1,86 +1,103 @@ import Foundation -import Testing @testable import MC1Services +import Testing struct NotificationStringProviderTests { + /// Mock implementation for testing + struct MockStringProvider: NotificationStringProvider { + func discoveryNotificationTitle(for type: ContactType) -> String { + switch type { + case .chat: "Mock Contact Title" + case .repeater: "Mock Repeater Title" + case .room: "Mock Room Title" + } + } + + var replyActionTitle: String { + "Mock Reply" + } + + var sendButtonTitle: String { + "Mock Send" + } + + var messagePlaceholder: String { + "Mock Placeholder" + } - /// Mock implementation for testing - struct MockStringProvider: NotificationStringProvider { - func discoveryNotificationTitle(for type: ContactType) -> String { - switch type { - case .chat: "Mock Contact Title" - case .repeater: "Mock Repeater Title" - case .room: "Mock Room Title" - } - } - - var replyActionTitle: String { "Mock Reply" } - var sendButtonTitle: String { "Mock Send" } - var messagePlaceholder: String { "Mock Placeholder" } - var markAsReadActionTitle: String { "Mock Mark as Read" } - var lowBatteryTitle: String { "Mock Low Battery" } - - func lowBatteryBody(deviceName: String, percentage: Int) -> String { - "Mock \(deviceName) at \(percentage)%" - } - - var quickReplyFailedTitle: String { "Mock Not Sent" } - - func quickReplyFailedBody(conversationName: String) -> String { - "Mock reply to \(conversationName) failed" - } - - var unknownContactName: String { "Mock Unknown Contact" } - - func defaultChannelName(index: Int) -> String { - "Mock Channel \(index)" - } - - func reactionNotificationBody(emoji: String, messagePreview: String) -> String { - "Mock reacted \(emoji) to \(messagePreview)" - } + var markAsReadActionTitle: String { + "Mock Mark as Read" } - @Test("Provider returns correct title for chat type") - func providerReturnsChatTitle() { - let provider = MockStringProvider() - let title = provider.discoveryNotificationTitle(for: .chat) - #expect(title == "Mock Contact Title") + var lowBatteryTitle: String { + "Mock Low Battery" } - @Test("Provider returns correct title for repeater type") - func providerReturnsRepeaterTitle() { - let provider = MockStringProvider() - let title = provider.discoveryNotificationTitle(for: .repeater) - #expect(title == "Mock Repeater Title") + func lowBatteryBody(deviceName: String, percentage: Int) -> String { + "Mock \(deviceName) at \(percentage)%" } - @Test("Provider returns correct title for room type") - func providerReturnsRoomTitle() { - let provider = MockStringProvider() - let title = provider.discoveryNotificationTitle(for: .room) - #expect(title == "Mock Room Title") + var quickReplyFailedTitle: String { + "Mock Not Sent" } - @Test("Provider returns correct low battery title") - func providerReturnsLowBatteryTitle() { - let provider = MockStringProvider() - #expect(provider.lowBatteryTitle == "Mock Low Battery") + func quickReplyFailedBody(conversationName: String) -> String { + "Mock reply to \(conversationName) failed" } - @Test("Provider returns correct low battery body with device name and percentage") - func providerReturnsLowBatteryBody() { - let provider = MockStringProvider() - let body = provider.lowBatteryBody(deviceName: "Node-7", percentage: 15) - #expect(body == "Mock Node-7 at 15%") + var unknownContactName: String { + "Mock Unknown Contact" } - @Test("Default fallback titles are English") - @MainActor - func defaultFallbackTitlesAreEnglish() { - let service = NotificationService() - #expect(service.defaultDiscoveryTitle(for: .chat) == "New Contact Discovered") - #expect(service.defaultDiscoveryTitle(for: .repeater) == "New Repeater Discovered") - #expect(service.defaultDiscoveryTitle(for: .room) == "New Room Discovered") + func defaultChannelName(index: Int) -> String { + "Mock Channel \(index)" } + + func reactionNotificationBody(emoji: String, messagePreview: String) -> String { + "Mock reacted \(emoji) to \(messagePreview)" + } + } + + @Test + func `Provider returns correct title for chat type`() { + let provider = MockStringProvider() + let title = provider.discoveryNotificationTitle(for: .chat) + #expect(title == "Mock Contact Title") + } + + @Test + func `Provider returns correct title for repeater type`() { + let provider = MockStringProvider() + let title = provider.discoveryNotificationTitle(for: .repeater) + #expect(title == "Mock Repeater Title") + } + + @Test + func `Provider returns correct title for room type`() { + let provider = MockStringProvider() + let title = provider.discoveryNotificationTitle(for: .room) + #expect(title == "Mock Room Title") + } + + @Test + func `Provider returns correct low battery title`() { + let provider = MockStringProvider() + #expect(provider.lowBatteryTitle == "Mock Low Battery") + } + + @Test + func `Provider returns correct low battery body with device name and percentage`() { + let provider = MockStringProvider() + let body = provider.lowBatteryBody(deviceName: "Node-7", percentage: 15) + #expect(body == "Mock Node-7 at 15%") + } + + @Test + @MainActor + func `Default fallback titles are English`() { + let service = NotificationService() + #expect(service.defaultDiscoveryTitle(for: .chat) == "New Contact Discovered") + #expect(service.defaultDiscoveryTitle(for: .repeater) == "New Repeater Discovered") + #expect(service.defaultDiscoveryTitle(for: .room) == "New Room Discovered") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ReactionParserTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ReactionParserTests.swift index e026a85c..43a8801a 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ReactionParserTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ReactionParserTests.swift @@ -1,368 +1,367 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ReactionParser Tests") struct ReactionParserTests { - - // MARK: - Valid Format Tests - - @Test("Parses simple reaction with thumbs up") - func parsesSimpleReaction() { - let text = "👍@[AlphaNode]\n7f3a9c12" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.emoji == "👍") - #expect(result?.targetSender == "AlphaNode") - #expect(result?.messageHash == "7f3a9c12") - } - - @Test("Parses reaction with heart emoji") - func parsesHeartReaction() { - let text = "❤️@[BetaNode]\ne4d8b1a0" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.emoji == "❤️") - #expect(result?.targetSender == "BetaNode") - #expect(result?.messageHash == "e4d8b1a0") - } - - @Test("Parses reaction with uppercase identifier and normalizes to lowercase") - func parsesUppercaseIdentifier() { - let text = "👍@[Node]\nABCDEF12" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.messageHash == "abcdef12") - } - - @Test("Parses reaction with mixed case identifier") - func parsesMixedCaseIdentifier() { - let text = "👍@[Node]\nAbCdEf12" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.messageHash == "abcdef12") - } - - // MARK: - Crockford Base32 Identifier Tests - - @Test("Generates 8-character Crockford Base32 identifier") - func generatesEightCharBase32() { - let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - #expect(hash.count == 8) - // Verify all characters are valid Crockford Base32 (lowercase) - let validChars = CharacterSet(charactersIn: "0123456789abcdefghjkmnpqrstvwxyz") - #expect(hash.unicodeScalars.allSatisfy { validChars.contains($0) }) - } - - @Test("Same input produces same identifier") - func sameInputSameHash() { - let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let hash2 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - #expect(hash1 == hash2) - } - - @Test("Different text produces different identifier") - func differentTextDifferentHash() { - let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let hash2 = ReactionParser.generateMessageHash(text: "World", timestamp: 1704067200) - #expect(hash1 != hash2) - } - - @Test("Different timestamp produces different identifier") - func differentTimestampDifferentHash() { - let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067200) - let hash2 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1704067201) - #expect(hash1 != hash2) - } - - @Test("Crockford O is decoded as 0") - func crockfordODecodesAsZero() { - let text = "👍@[Node]\nOOOOOOOO" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.messageHash == "00000000") - } - - @Test("Crockford I/L are decoded as 1") - func crockfordILDecodeAsOne() { - let textI = "👍@[Node]\niiiiiiii" - let resultI = ReactionParser.parse(textI) - #expect(resultI?.messageHash == "11111111") - - let textL = "👍@[Node]\nLLLLLLLL" - let resultL = ReactionParser.parse(textL) - #expect(resultL?.messageHash == "11111111") - } - - // MARK: - Edge Cases - - @Test("Parses sender name containing colon") - func parsesSenderWithColon() { - let text = "👍@[Node:Alpha]\na1b2c3d4" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.targetSender == "Node:Alpha") - } - - // MARK: - Invalid Format Tests - - @Test("Returns nil for plain text message") - func returnsNilForPlainText() { - let text = "Just a normal message" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for missing identifier") - func returnsNilForMissingHash() { - let text = "👍@[Node]" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for missing @ symbol") - func returnsNilForMissingAt() { - let text = "👍 [Node]\na1b2c3d4" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for missing brackets around sender") - func returnsNilForMissingBrackets() { - let text = "👍@Node\na1b2c3d4" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for invalid identifier length") - func returnsNilForInvalidHashLength() { - let text = "👍@[Node]\nabc" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for invalid Crockford characters (U)") - func returnsNilForInvalidCrockfordU() { - let text = "👍@[Node]\nuuuuuuuu" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for empty sender") - func returnsNilForEmptySender() { - let text = "👍@[]\na1b2c3d4" - #expect(ReactionParser.parse(text) == nil) - } - - @Test("Returns nil for text not starting with emoji") - func returnsNilForNonEmojiStart() { - let text = "A@[Node]\na1b2c3d4" - #expect(ReactionParser.parse(text) == nil) - } - - // MARK: - ZWJ Emoji Tests - - @Test("Parses reaction with skin tone modifier") - func parsesEmojiWithSkinTone() { - let text = "👍🏽@[Node]\na1b2c3d4" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.emoji == "👍🏽") - } - - @Test("Parses reaction with family ZWJ emoji") - func parsesFamilyEmoji() { - let text = "👨‍👩‍👧@[Node]\na1b2c3d4" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.emoji == "👨‍👩‍👧") - } - - @Test("Parses reaction with flag emoji") - func parsesFlagEmoji() { - let text = "🇺🇸@[Node]\na1b2c3d4" - let result = ReactionParser.parse(text) - - #expect(result != nil) - #expect(result?.emoji == "🇺🇸") - } - - // MARK: - Summary Cache Tests - - @Test("Builds summary from reactions") - func buildsSummary() { - let reactions = [ - ("👍", 3), - ("❤️", 2), - ("😂", 1) - ] - let summary = ReactionParser.buildSummary(from: reactions) - #expect(summary == "👍:3,❤️:2,😂:1") - } - - @Test("Parses summary string") - func parsesSummary() { - let summary = "👍:3,❤️:2,😂:1" - let parsed = ReactionParser.parseSummary(summary) - - #expect(parsed.count == 3) - #expect(parsed[0] == ("👍", 3)) - #expect(parsed[1] == ("❤️", 2)) - #expect(parsed[2] == ("😂", 1)) - } - - @Test("Parses empty summary") - func parsesEmptySummary() { - let parsed = ReactionParser.parseSummary(nil) - #expect(parsed.isEmpty) - } - - @Test("Sorts summary by count descending") - func sortsSummaryByCount() { - let reactions = [ - ("😂", 1), - ("👍", 5), - ("❤️", 3) - ] - let summary = ReactionParser.buildSummary(from: reactions) - #expect(summary == "👍:5,❤️:3,😂:1") - } - - // MARK: - ReactionDTO DM Support Tests - - @Test("ReactionDTO can be created with contactID for DMs") - func reactionDTOWithContactID() { - let contactID = UUID() - let radioID = UUID() - let messageID = UUID() - - let dto = ReactionDTO( - messageID: messageID, - emoji: "👍", - senderName: "TestNode", - messageHash: "a1b2c3d4", - rawText: "👍@[TestNode]\na1b2c3d4", - contactID: contactID, - radioID: radioID - ) - - #expect(dto.contactID == contactID) - #expect(dto.channelIndex == nil) - } - - @Test("ReactionDTO can be created with channelIndex for channels") - func reactionDTOWithChannelIndex() { - let radioID = UUID() - let messageID = UUID() - - let dto = ReactionDTO( - messageID: messageID, - emoji: "👍", - senderName: "TestNode", - messageHash: "a1b2c3d4", - rawText: "👍@[TestNode]\na1b2c3d4", - channelIndex: 5, - radioID: radioID - ) - - #expect(dto.channelIndex == 5) - #expect(dto.contactID == nil) - } - - // MARK: - DM Reaction Format Tests - - @Test("Parses DM reaction format without sender") - func parsesDMReaction() { - let text = "👍\n7f3a9c12" - let result = ReactionParser.parseDM(text) - - #expect(result != nil) - #expect(result?.emoji == "👍") - #expect(result?.messageHash == "7f3a9c12") - } - - @Test("Parses DM reaction with heart emoji") - func parsesDMHeartReaction() { - let text = "❤️\ne4d8b1a0" - let result = ReactionParser.parseDM(text) - - #expect(result != nil) - #expect(result?.emoji == "❤️") - } - - @Test("Returns nil for DM format missing hash") - func returnsNilForDMMissingHash() { - let text = "👍" - #expect(ReactionParser.parseDM(text) == nil) - } - - @Test("DM parser rejects channel format") - func dmParserRejectsChannelFormat() { - let text = "👍@[Node]\nabcd1234" - #expect(ReactionParser.parseDM(text) == nil) - } - - @Test("Builds DM reaction text correctly") - func buildsDMReactionText() { - let text = ReactionParser.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: 1704067200 - ) - #expect(text.hasPrefix("👍\n")) - #expect(text.count == 10) // emoji (grapheme cluster) + newline + 8 char hash - #expect(!text.contains("@[")) - } - - @Test("Parses DM reaction with uppercase hash and normalizes to lowercase") - func parsesDMUppercaseHash() { - let text = "👍\nABCDEF12" - let result = ReactionParser.parseDM(text) - - #expect(result != nil) - #expect(result?.messageHash == "abcdef12") - } - - @Test("DM parser rejects invalid Crockford characters") - func dmParserRejectsInvalidCrockford() { - let text = "👍\nuuuuuuuu" - #expect(ReactionParser.parseDM(text) == nil) - } - - @Test("DM parser rejects non-emoji start") - func dmParserRejectsNonEmojiStart() { - let text = "A\na1b2c3d4" - #expect(ReactionParser.parseDM(text) == nil) - } - - @Test("DM parser handles skin tone modifier emoji") - func dmParserHandlesSkinToneEmoji() { - let text = "👍🏽\na1b2c3d4" - let result = ReactionParser.parseDM(text) - - #expect(result != nil) - #expect(result?.emoji == "👍🏽") - } - - @Test("DM round-trip: build then parse produces same emoji and hash") - func dmRoundTrip() { - let originalEmoji = "👍" - let targetText = "Hello world" - let timestamp: UInt32 = 1704067200 - - let text = ReactionParser.buildDMReactionText( - emoji: originalEmoji, - targetText: targetText, - targetTimestamp: timestamp - ) - - let parsed = ReactionParser.parseDM(text) - #expect(parsed != nil) - #expect(parsed?.emoji == originalEmoji) - - let expectedHash = ReactionParser.generateMessageHash(text: targetText, timestamp: timestamp) - #expect(parsed?.messageHash == expectedHash) - } + // MARK: - Valid Format Tests + + @Test + func `Parses simple reaction with thumbs up`() { + let text = "👍@[AlphaNode]\n7f3a9c12" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.emoji == "👍") + #expect(result?.targetSender == "AlphaNode") + #expect(result?.messageHash == "7f3a9c12") + } + + @Test + func `Parses reaction with heart emoji`() { + let text = "❤️@[BetaNode]\ne4d8b1a0" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.emoji == "❤️") + #expect(result?.targetSender == "BetaNode") + #expect(result?.messageHash == "e4d8b1a0") + } + + @Test + func `Parses reaction with uppercase identifier and normalizes to lowercase`() { + let text = "👍@[Node]\nABCDEF12" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.messageHash == "abcdef12") + } + + @Test + func `Parses reaction with mixed case identifier`() { + let text = "👍@[Node]\nAbCdEf12" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.messageHash == "abcdef12") + } + + // MARK: - Crockford Base32 Identifier Tests + + @Test + func `Generates 8-character Crockford Base32 identifier`() { + let hash = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + #expect(hash.count == 8) + // Verify all characters are valid Crockford Base32 (lowercase) + let validChars = CharacterSet(charactersIn: "0123456789abcdefghjkmnpqrstvwxyz") + #expect(hash.unicodeScalars.allSatisfy { validChars.contains($0) }) + } + + @Test + func `Same input produces same identifier`() { + let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let hash2 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + #expect(hash1 == hash2) + } + + @Test + func `Different text produces different identifier`() { + let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let hash2 = ReactionParser.generateMessageHash(text: "World", timestamp: 1_704_067_200) + #expect(hash1 != hash2) + } + + @Test + func `Different timestamp produces different identifier`() { + let hash1 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_200) + let hash2 = ReactionParser.generateMessageHash(text: "Hello", timestamp: 1_704_067_201) + #expect(hash1 != hash2) + } + + @Test + func `Crockford O is decoded as 0`() { + let text = "👍@[Node]\nOOOOOOOO" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.messageHash == "00000000") + } + + @Test + func `Crockford I/L are decoded as 1`() { + let textI = "👍@[Node]\niiiiiiii" + let resultI = ReactionParser.parse(textI) + #expect(resultI?.messageHash == "11111111") + + let textL = "👍@[Node]\nLLLLLLLL" + let resultL = ReactionParser.parse(textL) + #expect(resultL?.messageHash == "11111111") + } + + // MARK: - Edge Cases + + @Test + func `Parses sender name containing colon`() { + let text = "👍@[Node:Alpha]\na1b2c3d4" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.targetSender == "Node:Alpha") + } + + // MARK: - Invalid Format Tests + + @Test + func `Returns nil for plain text message`() { + let text = "Just a normal message" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for missing identifier`() { + let text = "👍@[Node]" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for missing @ symbol`() { + let text = "👍 [Node]\na1b2c3d4" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for missing brackets around sender`() { + let text = "👍@Node\na1b2c3d4" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for invalid identifier length`() { + let text = "👍@[Node]\nabc" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for invalid Crockford characters (U)`() { + let text = "👍@[Node]\nuuuuuuuu" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for empty sender`() { + let text = "👍@[]\na1b2c3d4" + #expect(ReactionParser.parse(text) == nil) + } + + @Test + func `Returns nil for text not starting with emoji`() { + let text = "A@[Node]\na1b2c3d4" + #expect(ReactionParser.parse(text) == nil) + } + + // MARK: - ZWJ Emoji Tests + + @Test + func `Parses reaction with skin tone modifier`() { + let text = "👍🏽@[Node]\na1b2c3d4" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.emoji == "👍🏽") + } + + @Test + func `Parses reaction with family ZWJ emoji`() { + let text = "👨‍👩‍👧@[Node]\na1b2c3d4" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.emoji == "👨‍👩‍👧") + } + + @Test + func `Parses reaction with flag emoji`() { + let text = "🇺🇸@[Node]\na1b2c3d4" + let result = ReactionParser.parse(text) + + #expect(result != nil) + #expect(result?.emoji == "🇺🇸") + } + + // MARK: - Summary Cache Tests + + @Test + func `Builds summary from reactions`() { + let reactions = [ + ("👍", 3), + ("❤️", 2), + ("😂", 1) + ] + let summary = ReactionParser.buildSummary(from: reactions) + #expect(summary == "👍:3,❤️:2,😂:1") + } + + @Test + func `Parses summary string`() { + let summary = "👍:3,❤️:2,😂:1" + let parsed = ReactionParser.parseSummary(summary) + + #expect(parsed.count == 3) + #expect(parsed[0] == ("👍", 3)) + #expect(parsed[1] == ("❤️", 2)) + #expect(parsed[2] == ("😂", 1)) + } + + @Test + func `Parses empty summary`() { + let parsed = ReactionParser.parseSummary(nil) + #expect(parsed.isEmpty) + } + + @Test + func `Sorts summary by count descending`() { + let reactions = [ + ("😂", 1), + ("👍", 5), + ("❤️", 3) + ] + let summary = ReactionParser.buildSummary(from: reactions) + #expect(summary == "👍:5,❤️:3,😂:1") + } + + // MARK: - ReactionDTO DM Support Tests + + @Test + func `ReactionDTO can be created with contactID for DMs`() { + let contactID = UUID() + let radioID = UUID() + let messageID = UUID() + + let dto = ReactionDTO( + messageID: messageID, + emoji: "👍", + senderName: "TestNode", + messageHash: "a1b2c3d4", + rawText: "👍@[TestNode]\na1b2c3d4", + contactID: contactID, + radioID: radioID + ) + + #expect(dto.contactID == contactID) + #expect(dto.channelIndex == nil) + } + + @Test + func `ReactionDTO can be created with channelIndex for channels`() { + let radioID = UUID() + let messageID = UUID() + + let dto = ReactionDTO( + messageID: messageID, + emoji: "👍", + senderName: "TestNode", + messageHash: "a1b2c3d4", + rawText: "👍@[TestNode]\na1b2c3d4", + channelIndex: 5, + radioID: radioID + ) + + #expect(dto.channelIndex == 5) + #expect(dto.contactID == nil) + } + + // MARK: - DM Reaction Format Tests + + @Test + func `Parses DM reaction format without sender`() { + let text = "👍\n7f3a9c12" + let result = ReactionParser.parseDM(text) + + #expect(result != nil) + #expect(result?.emoji == "👍") + #expect(result?.messageHash == "7f3a9c12") + } + + @Test + func `Parses DM reaction with heart emoji`() { + let text = "❤️\ne4d8b1a0" + let result = ReactionParser.parseDM(text) + + #expect(result != nil) + #expect(result?.emoji == "❤️") + } + + @Test + func `Returns nil for DM format missing hash`() { + let text = "👍" + #expect(ReactionParser.parseDM(text) == nil) + } + + @Test + func `DM parser rejects channel format`() { + let text = "👍@[Node]\nabcd1234" + #expect(ReactionParser.parseDM(text) == nil) + } + + @Test + func `Builds DM reaction text correctly`() { + let text = ReactionParser.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: 1_704_067_200 + ) + #expect(text.hasPrefix("👍\n")) + #expect(text.count == 10) // emoji (grapheme cluster) + newline + 8 char hash + #expect(!text.contains("@[")) + } + + @Test + func `Parses DM reaction with uppercase hash and normalizes to lowercase`() { + let text = "👍\nABCDEF12" + let result = ReactionParser.parseDM(text) + + #expect(result != nil) + #expect(result?.messageHash == "abcdef12") + } + + @Test + func `DM parser rejects invalid Crockford characters`() { + let text = "👍\nuuuuuuuu" + #expect(ReactionParser.parseDM(text) == nil) + } + + @Test + func `DM parser rejects non-emoji start`() { + let text = "A\na1b2c3d4" + #expect(ReactionParser.parseDM(text) == nil) + } + + @Test + func `DM parser handles skin tone modifier emoji`() { + let text = "👍🏽\na1b2c3d4" + let result = ReactionParser.parseDM(text) + + #expect(result != nil) + #expect(result?.emoji == "👍🏽") + } + + @Test + func `DM round-trip: build then parse produces same emoji and hash`() { + let originalEmoji = "👍" + let targetText = "Hello world" + let timestamp: UInt32 = 1_704_067_200 + + let text = ReactionParser.buildDMReactionText( + emoji: originalEmoji, + targetText: targetText, + targetTimestamp: timestamp + ) + + let parsed = ReactionParser.parseDM(text) + #expect(parsed != nil) + #expect(parsed?.emoji == originalEmoji) + + let expectedHash = ReactionParser.generateMessageHash(text: targetText, timestamp: timestamp) + #expect(parsed?.messageHash == expectedHash) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ReactionServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ReactionServiceTests.swift index 3bbc833f..81ccd778 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ReactionServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ReactionServiceTests.swift @@ -1,583 +1,582 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("ReactionService Tests") struct ReactionServiceTests { - - @Test("Builds correct wire format with Crockford Base32 identifier") - func buildsWireFormat() async { - let service = ReactionService() - let timestamp: UInt32 = 1704067200 - - let text = service.buildReactionText( - emoji: "👍", - targetSender: "AlphaNode", - targetText: "What's the situation at Main St today?", - targetTimestamp: timestamp - ) - - // Verify format: {emoji}@[{sender}]\n{hash} - #expect(text.hasPrefix("👍@[AlphaNode]\n")) - - // Verify 8-char Crockford Base32 identifier is present (lowercase) at end - let idPattern = #/\n([0-9a-hj-km-np-tv-z]{8})$/# - #expect(text.firstMatch(of: idPattern) != nil) - } - - @Test("Builds wire format with short message") - func buildsWireFormatShortMessage() async { - let service = ReactionService() - let timestamp: UInt32 = 1704067200 - - let text = service.buildReactionText( - emoji: "❤️", - targetSender: "Node", - targetText: "ok", - targetTimestamp: timestamp - ) - - #expect(text.hasPrefix("❤️@[Node]\n")) - #expect(text.hasSuffix(text.suffix(8))) // ends with 8-char hash - } - - @Test("Generated identifier is consistent") - func generatedIdentifierIsConsistent() async { - let service = ReactionService() - let timestamp: UInt32 = 1704067200 - let targetText = "Hello world" - - let text1 = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: targetText, - targetTimestamp: timestamp - ) - - let text2 = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: targetText, - targetTimestamp: timestamp - ) - - #expect(text1 == text2) - } - - @Test("Different timestamps produce different identifiers") - func differentTimestampsDifferentIdentifiers() async { - let service = ReactionService() - let targetText = "Hello world" - - let text1 = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: targetText, - targetTimestamp: 1704067200 - ) - - let text2 = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: targetText, - targetTimestamp: 1704067201 - ) - - #expect(text1 != text2) - } - - // MARK: - Disambiguation Tests - - @Test("Finds indexed message by hash and preview") - func findsIndexedMessage() async { - let service = ReactionService() - let messageID = UUID() - let timestamp: UInt32 = 1704067200 - - await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "Node", - text: "Hello world", - timestamp: timestamp - ) - - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: "Hello world", - targetTimestamp: timestamp - ) - - let parsed = ReactionParser.parse(reactionText)! - let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) - - #expect(foundID == messageID) - } - - @Test("Returns nil when no candidates exist") - func returnsNilWhenNoCandidates() async { - let service = ReactionService() - - let parsed = ParsedReaction( - emoji: "👍", - targetSender: "Node", - messageHash: "abcd1234" - ) - - let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) - - #expect(foundID == nil) - } - - @Test("Returns most recently indexed when multiple candidates have same hash") - func returnsMostRecentWhenMultipleCandidates() async { - let service = ReactionService() - let id1 = UUID() - let id2 = UUID() - let timestamp: UInt32 = 1704067200 - - // Index two messages with same hash (same text and timestamp) - _ = await service.indexMessage( - id: id1, - channelIndex: 0, - senderName: "Node", - text: "Same message", - timestamp: timestamp - ) - - // Small delay to ensure different indexedAt times - try? await Task.sleep(for: .milliseconds(10)) - - _ = await service.indexMessage( - id: id2, - channelIndex: 0, - senderName: "Node", - text: "Same message", - timestamp: timestamp - ) - - // Build reaction for the message - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "Node", - targetText: "Same message", - targetTimestamp: timestamp - ) - - let parsed = ReactionParser.parse(reactionText)! - let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) - - // Should find the most recently indexed (id2) - #expect(foundID == id2) - } - - // MARK: - Pending Reactions Queue Tests - - @Test("Queued reaction matches when message indexed") - func queuedReactionMatchesWhenMessageIndexed() async { - let service = ReactionService() - let messageID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Build reaction text for a message that doesn't exist yet - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "AlphaNode", - targetText: "Hello world", - targetTimestamp: timestamp - ) - - let parsed = ReactionParser.parse(reactionText)! - - // Queue the reaction (target message not indexed yet) - await service.queuePendingReaction( - parsed: parsed, - channelIndex: 0, - senderNodeName: "BetaNode", - rawText: reactionText, - radioID: radioID - ) - - // Now index the target message - should return the pending reaction - let matches = await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "AlphaNode", - text: "Hello world", - timestamp: timestamp - ) - - #expect(matches.count == 1) - #expect(matches.first?.parsed.emoji == "👍") - #expect(matches.first?.senderNodeName == "BetaNode") - } - - @Test("Multiple reactions for same target all match") - func multipleReactionsForSameTargetAllMatch() async { - let service = ReactionService() - let messageID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue multiple reactions for the same message - for emoji in ["👍", "❤️", "😂"] { - let reactionText = service.buildReactionText( - emoji: emoji, - targetSender: "AlphaNode", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parse(reactionText)! - - await service.queuePendingReaction( - parsed: parsed, - channelIndex: 0, - senderNodeName: "BetaNode", - rawText: reactionText, - radioID: radioID - ) - } - - // Index the target message - should return all pending reactions - let matches = await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "AlphaNode", - text: "Hello world", - timestamp: timestamp - ) - - #expect(matches.count == 3) - let emojis = Set(matches.map { $0.parsed.emoji }) - #expect(emojis == ["👍", "❤️", "😂"]) - } - - @Test("Hash mismatch prevents false match") - func hashMismatchPreventsFalseMatch() async { - let service = ReactionService() - let messageID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue a reaction for "Hello world" - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "AlphaNode", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parse(reactionText)! - - await service.queuePendingReaction( - parsed: parsed, - channelIndex: 0, - senderNodeName: "BetaNode", - rawText: reactionText, - radioID: radioID - ) - - // Index a different message (different hash) - let matches = await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "AlphaNode", - text: "Different text", - timestamp: timestamp - ) - - // Should NOT match because hash is different - #expect(matches.isEmpty) - } - - @Test("Clear removes all pending reactions") - func clearRemovesAllPending() async { - let service = ReactionService() - let messageID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue a reaction - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "AlphaNode", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parse(reactionText)! - - await service.queuePendingReaction( - parsed: parsed, - channelIndex: 0, - senderNodeName: "BetaNode", - rawText: reactionText, - radioID: radioID - ) - - // Clear all pending - await service.clearPendingReactions() - - // Index the target message - should return nothing - let matches = await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "AlphaNode", - text: "Hello world", - timestamp: timestamp - ) - - #expect(matches.isEmpty) - } - - @Test("Pending reactions are scoped by channel") - func pendingReactionsScopedByChannel() async { - let service = ReactionService() - let messageID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue a reaction for channel 0 - let reactionText = service.buildReactionText( - emoji: "👍", - targetSender: "AlphaNode", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parse(reactionText)! - - await service.queuePendingReaction( - parsed: parsed, - channelIndex: 0, - senderNodeName: "BetaNode", - rawText: reactionText, - radioID: radioID - ) - - // Index on channel 1 - should NOT match - let matchesChannel1 = await service.indexMessage( - id: messageID, - channelIndex: 1, - senderName: "AlphaNode", - text: "Hello world", - timestamp: timestamp - ) - - #expect(matchesChannel1.isEmpty) - - // Index on channel 0 - should match - let matchesChannel0 = await service.indexMessage( - id: messageID, - channelIndex: 0, - senderName: "AlphaNode", - text: "Hello world", - timestamp: timestamp - ) - - #expect(matchesChannel0.count == 1) - } - - // MARK: - DM Reaction Tests - - @Test("Builds DM wire format") - func buildsDMWireFormat() async { - let service = ReactionService() - let text = service.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: 1704067200 - ) - #expect(text.hasPrefix("👍\n")) - #expect(text.count == 10) // emoji + newline + 8 char hash - #expect(!text.contains("@[")) - } - - @Test("Indexes DM message and finds by hash") - func indexesDMMessageAndFinds() async { - let service = ReactionService() - let messageID = UUID() - let contactID = UUID() - let timestamp: UInt32 = 1704067200 - - _ = await service.indexDMMessage( - id: messageID, - contactID: contactID, - text: "Hello world", - timestamp: timestamp - ) - - let hash = ReactionParser.generateMessageHash(text: "Hello world", timestamp: timestamp) - let foundID = await service.findDMTargetMessage( - messageHash: hash, - contactID: contactID - ) - - #expect(foundID == messageID) - } - - @Test("DM pending reactions match when message indexed") - func dmPendingReactionsMatch() async { - let service = ReactionService() - let messageID = UUID() - let contactID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - let reactionText = service.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: timestamp - ) - - let parsed = ReactionParser.parseDM(reactionText)! - - await service.queuePendingDMReaction( - parsed: parsed, - contactID: contactID, - senderName: "Alice", - rawText: reactionText, - radioID: radioID - ) - - let matches = await service.indexDMMessage( - id: messageID, - contactID: contactID, - text: "Hello world", - timestamp: timestamp - ) - - #expect(matches.count == 1) - #expect(matches.first?.parsed.emoji == "👍") - } - - @Test("DM reactions scoped by contact") - func dmReactionsScopedByContact() async { - let service = ReactionService() - let messageID = UUID() - let contactID1 = UUID() - let contactID2 = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - let reactionText = service.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parseDM(reactionText)! - - // Queue for contact1 - await service.queuePendingDMReaction( - parsed: parsed, - contactID: contactID1, - senderName: "Alice", - rawText: reactionText, - radioID: radioID - ) - - // Index for contact2 - should NOT match - let matchesContact2 = await service.indexDMMessage( - id: messageID, - contactID: contactID2, - text: "Hello world", - timestamp: timestamp - ) - #expect(matchesContact2.isEmpty) - - // Index for contact1 - should match - let matchesContact1 = await service.indexDMMessage( - id: messageID, - contactID: contactID1, - text: "Hello world", - timestamp: timestamp - ) - #expect(matchesContact1.count == 1) - } - - @Test("DM returns nil when no candidates in cache") - func dmReturnsNilWhenNoCandidates() async { - let service = ReactionService() - let contactID = UUID() - - let foundID = await service.findDMTargetMessage( - messageHash: "abcd1234", - contactID: contactID - ) - - #expect(foundID == nil) + @Test + func `Builds correct wire format with Crockford Base32 identifier`() { + let service = ReactionService() + let timestamp: UInt32 = 1_704_067_200 + + let text = service.buildReactionText( + emoji: "👍", + targetSender: "AlphaNode", + targetText: "What's the situation at Main St today?", + targetTimestamp: timestamp + ) + + // Verify format: {emoji}@[{sender}]\n{hash} + #expect(text.hasPrefix("👍@[AlphaNode]\n")) + + // Verify 8-char Crockford Base32 identifier is present (lowercase) at end + let idPattern = #/\n([0-9a-hj-km-np-tv-z]{8})$/# + #expect(text.firstMatch(of: idPattern) != nil) + } + + @Test + func `Builds wire format with short message`() { + let service = ReactionService() + let timestamp: UInt32 = 1_704_067_200 + + let text = service.buildReactionText( + emoji: "❤️", + targetSender: "Node", + targetText: "ok", + targetTimestamp: timestamp + ) + + #expect(text.hasPrefix("❤️@[Node]\n")) + #expect(text.hasSuffix(text.suffix(8))) // ends with 8-char hash + } + + @Test + func `Generated identifier is consistent`() { + let service = ReactionService() + let timestamp: UInt32 = 1_704_067_200 + let targetText = "Hello world" + + let text1 = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: targetText, + targetTimestamp: timestamp + ) + + let text2 = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: targetText, + targetTimestamp: timestamp + ) + + #expect(text1 == text2) + } + + @Test + func `Different timestamps produce different identifiers`() { + let service = ReactionService() + let targetText = "Hello world" + + let text1 = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: targetText, + targetTimestamp: 1_704_067_200 + ) + + let text2 = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: targetText, + targetTimestamp: 1_704_067_201 + ) + + #expect(text1 != text2) + } + + // MARK: - Disambiguation Tests + + @Test + func `Finds indexed message by hash and preview`() async throws { + let service = ReactionService() + let messageID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "Node", + text: "Hello world", + timestamp: timestamp + ) + + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: "Hello world", + targetTimestamp: timestamp + ) + + let parsed = try #require(ReactionParser.parse(reactionText)) + let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) + + #expect(foundID == messageID) + } + + @Test + func `Returns nil when no candidates exist`() async { + let service = ReactionService() + + let parsed = ParsedReaction( + emoji: "👍", + targetSender: "Node", + messageHash: "abcd1234" + ) + + let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) + + #expect(foundID == nil) + } + + @Test + func `Returns most recently indexed when multiple candidates have same hash`() async throws { + let service = ReactionService() + let id1 = UUID() + let id2 = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Index two messages with same hash (same text and timestamp) + _ = await service.indexMessage( + id: id1, + channelIndex: 0, + senderName: "Node", + text: "Same message", + timestamp: timestamp + ) + + // Small delay to ensure different indexedAt times + try? await Task.sleep(for: .milliseconds(10)) + + _ = await service.indexMessage( + id: id2, + channelIndex: 0, + senderName: "Node", + text: "Same message", + timestamp: timestamp + ) + + // Build reaction for the message + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "Node", + targetText: "Same message", + targetTimestamp: timestamp + ) + + let parsed = try #require(ReactionParser.parse(reactionText)) + let foundID = await service.findTargetMessage(parsed: parsed, channelIndex: 0) + + // Should find the most recently indexed (id2) + #expect(foundID == id2) + } + + // MARK: - Pending Reactions Queue Tests + + @Test + func `Queued reaction matches when message indexed`() async throws { + let service = ReactionService() + let messageID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Build reaction text for a message that doesn't exist yet + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "AlphaNode", + targetText: "Hello world", + targetTimestamp: timestamp + ) + + let parsed = try #require(ReactionParser.parse(reactionText)) + + // Queue the reaction (target message not indexed yet) + await service.queuePendingReaction( + parsed: parsed, + channelIndex: 0, + senderNodeName: "BetaNode", + rawText: reactionText, + radioID: radioID + ) + + // Now index the target message - should return the pending reaction + let matches = await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "AlphaNode", + text: "Hello world", + timestamp: timestamp + ) + + #expect(matches.count == 1) + #expect(matches.first?.parsed.emoji == "👍") + #expect(matches.first?.senderNodeName == "BetaNode") + } + + @Test + func `Multiple reactions for same target all match`() async throws { + let service = ReactionService() + let messageID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue multiple reactions for the same message + for emoji in ["👍", "❤️", "😂"] { + let reactionText = service.buildReactionText( + emoji: emoji, + targetSender: "AlphaNode", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + await service.queuePendingReaction( + parsed: parsed, + channelIndex: 0, + senderNodeName: "BetaNode", + rawText: reactionText, + radioID: radioID + ) } - @Test("DM hash mismatch prevents false match") - func dmHashMismatchPreventsFalseMatch() async { - let service = ReactionService() - let messageID = UUID() - let contactID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue a reaction for "Hello world" - let reactionText = service.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parseDM(reactionText)! - - await service.queuePendingDMReaction( - parsed: parsed, - contactID: contactID, - senderName: "Alice", - rawText: reactionText, - radioID: radioID - ) - - // Index a different message (different hash) - let matches = await service.indexDMMessage( - id: messageID, - contactID: contactID, - text: "Different text", - timestamp: timestamp - ) - - // Should NOT match because hash is different - #expect(matches.isEmpty) - } - - @Test("Clear removes DM pending reactions") - func clearRemovesDMPending() async { - let service = ReactionService() - let messageID = UUID() - let contactID = UUID() - let radioID = UUID() - let timestamp: UInt32 = 1704067200 - - // Queue a DM reaction - let reactionText = service.buildDMReactionText( - emoji: "👍", - targetText: "Hello world", - targetTimestamp: timestamp - ) - let parsed = ReactionParser.parseDM(reactionText)! - - await service.queuePendingDMReaction( - parsed: parsed, - contactID: contactID, - senderName: "Alice", - rawText: reactionText, - radioID: radioID - ) - - // Clear all pending - await service.clearPendingReactions() - - // Index the target message - should return nothing - let matches = await service.indexDMMessage( - id: messageID, - contactID: contactID, - text: "Hello world", - timestamp: timestamp - ) - - #expect(matches.isEmpty) - } + // Index the target message - should return all pending reactions + let matches = await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "AlphaNode", + text: "Hello world", + timestamp: timestamp + ) + + #expect(matches.count == 3) + let emojis = Set(matches.map(\.parsed.emoji)) + #expect(emojis == ["👍", "❤️", "😂"]) + } + + @Test + func `Hash mismatch prevents false match`() async throws { + let service = ReactionService() + let messageID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue a reaction for "Hello world" + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "AlphaNode", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + await service.queuePendingReaction( + parsed: parsed, + channelIndex: 0, + senderNodeName: "BetaNode", + rawText: reactionText, + radioID: radioID + ) + + // Index a different message (different hash) + let matches = await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "AlphaNode", + text: "Different text", + timestamp: timestamp + ) + + // Should NOT match because hash is different + #expect(matches.isEmpty) + } + + @Test + func `Clear removes all pending reactions`() async throws { + let service = ReactionService() + let messageID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue a reaction + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "AlphaNode", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + await service.queuePendingReaction( + parsed: parsed, + channelIndex: 0, + senderNodeName: "BetaNode", + rawText: reactionText, + radioID: radioID + ) + + // Clear all pending + await service.clearPendingReactions() + + // Index the target message - should return nothing + let matches = await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "AlphaNode", + text: "Hello world", + timestamp: timestamp + ) + + #expect(matches.isEmpty) + } + + @Test + func `Pending reactions are scoped by channel`() async throws { + let service = ReactionService() + let messageID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue a reaction for channel 0 + let reactionText = service.buildReactionText( + emoji: "👍", + targetSender: "AlphaNode", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parse(reactionText)) + + await service.queuePendingReaction( + parsed: parsed, + channelIndex: 0, + senderNodeName: "BetaNode", + rawText: reactionText, + radioID: radioID + ) + + // Index on channel 1 - should NOT match + let matchesChannel1 = await service.indexMessage( + id: messageID, + channelIndex: 1, + senderName: "AlphaNode", + text: "Hello world", + timestamp: timestamp + ) + + #expect(matchesChannel1.isEmpty) + + // Index on channel 0 - should match + let matchesChannel0 = await service.indexMessage( + id: messageID, + channelIndex: 0, + senderName: "AlphaNode", + text: "Hello world", + timestamp: timestamp + ) + + #expect(matchesChannel0.count == 1) + } + + // MARK: - DM Reaction Tests + + @Test + func `Builds DM wire format`() { + let service = ReactionService() + let text = service.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: 1_704_067_200 + ) + #expect(text.hasPrefix("👍\n")) + #expect(text.count == 10) // emoji + newline + 8 char hash + #expect(!text.contains("@[")) + } + + @Test + func `Indexes DM message and finds by hash`() async { + let service = ReactionService() + let messageID = UUID() + let contactID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + _ = await service.indexDMMessage( + id: messageID, + contactID: contactID, + text: "Hello world", + timestamp: timestamp + ) + + let hash = ReactionParser.generateMessageHash(text: "Hello world", timestamp: timestamp) + let foundID = await service.findDMTargetMessage( + messageHash: hash, + contactID: contactID + ) + + #expect(foundID == messageID) + } + + @Test + func `DM pending reactions match when message indexed`() async throws { + let service = ReactionService() + let messageID = UUID() + let contactID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + let reactionText = service.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: timestamp + ) + + let parsed = try #require(ReactionParser.parseDM(reactionText)) + + await service.queuePendingDMReaction( + parsed: parsed, + contactID: contactID, + senderName: "Alice", + rawText: reactionText, + radioID: radioID + ) + + let matches = await service.indexDMMessage( + id: messageID, + contactID: contactID, + text: "Hello world", + timestamp: timestamp + ) + + #expect(matches.count == 1) + #expect(matches.first?.parsed.emoji == "👍") + } + + @Test + func `DM reactions scoped by contact`() async throws { + let service = ReactionService() + let messageID = UUID() + let contactID1 = UUID() + let contactID2 = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + let reactionText = service.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parseDM(reactionText)) + + // Queue for contact1 + await service.queuePendingDMReaction( + parsed: parsed, + contactID: contactID1, + senderName: "Alice", + rawText: reactionText, + radioID: radioID + ) + + // Index for contact2 - should NOT match + let matchesContact2 = await service.indexDMMessage( + id: messageID, + contactID: contactID2, + text: "Hello world", + timestamp: timestamp + ) + #expect(matchesContact2.isEmpty) + + // Index for contact1 - should match + let matchesContact1 = await service.indexDMMessage( + id: messageID, + contactID: contactID1, + text: "Hello world", + timestamp: timestamp + ) + #expect(matchesContact1.count == 1) + } + + @Test + func `DM returns nil when no candidates in cache`() async { + let service = ReactionService() + let contactID = UUID() + + let foundID = await service.findDMTargetMessage( + messageHash: "abcd1234", + contactID: contactID + ) + + #expect(foundID == nil) + } + + @Test + func `DM hash mismatch prevents false match`() async throws { + let service = ReactionService() + let messageID = UUID() + let contactID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue a reaction for "Hello world" + let reactionText = service.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parseDM(reactionText)) + + await service.queuePendingDMReaction( + parsed: parsed, + contactID: contactID, + senderName: "Alice", + rawText: reactionText, + radioID: radioID + ) + + // Index a different message (different hash) + let matches = await service.indexDMMessage( + id: messageID, + contactID: contactID, + text: "Different text", + timestamp: timestamp + ) + + // Should NOT match because hash is different + #expect(matches.isEmpty) + } + + @Test + func `Clear removes DM pending reactions`() async throws { + let service = ReactionService() + let messageID = UUID() + let contactID = UUID() + let radioID = UUID() + let timestamp: UInt32 = 1_704_067_200 + + // Queue a DM reaction + let reactionText = service.buildDMReactionText( + emoji: "👍", + targetText: "Hello world", + targetTimestamp: timestamp + ) + let parsed = try #require(ReactionParser.parseDM(reactionText)) + + await service.queuePendingDMReaction( + parsed: parsed, + contactID: contactID, + senderName: "Alice", + rawText: reactionText, + radioID: radioID + ) + + // Clear all pending + await service.clearPendingReactions() + + // Index the target message - should return nothing + let matches = await service.indexDMMessage( + id: messageID, + contactID: contactID, + text: "Hello world", + timestamp: timestamp + ) + + #expect(matches.isEmpty) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift index ab510dd7..09b8a35e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift @@ -1,82 +1,81 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("RegionDiscoveryService query-target building") struct RegionDiscoveryServiceTests { + private let radioID = UUID() - private let radioID = UUID() - - private func repeaterContact(_ pub: Data) -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: radioID, - publicKey: pub, - name: "Repeater", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - } + private func repeaterContact(_ pub: Data) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: pub, + name: "Repeater", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } - private func repeaterNode(_ pub: Data) -> DiscoveredNodeDTO { - DiscoveredNodeDTO( - id: UUID(), - radioID: radioID, - publicKey: pub, - name: "Discovered", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(timeIntervalSince1970: 0), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - } + private func repeaterNode(_ pub: Data) -> DiscoveredNodeDTO { + DiscoveredNodeDTO( + id: UUID(), + radioID: radioID, + publicKey: pub, + name: "Discovered", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(timeIntervalSince1970: 0), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + } - @Test("Non-contact responders are queried when ad-hoc requests are supported") - func includesNonContactNodesWhenSupported() { - let contactKey = Data(repeating: 0x11, count: 32) - let nonContactKey = Data(repeating: 0x22, count: 32) + @Test + func `Non-contact responders are queried when ad-hoc requests are supported`() { + let contactKey = Data(repeating: 0x11, count: 32) + let nonContactKey = Data(repeating: 0x22, count: 32) - let targets = RegionDiscoveryService.buildRegionQueryTargets( - responders: [contactKey, nonContactKey], - contacts: [repeaterContact(contactKey)], - discoveredNodes: [repeaterNode(nonContactKey)], - supportsAdHocRequest: true - ) + let targets = RegionDiscoveryService.buildRegionQueryTargets( + responders: [contactKey, nonContactKey], + contacts: [repeaterContact(contactKey)], + discoveredNodes: [repeaterNode(nonContactKey)], + supportsAdHocRequest: true + ) - let keys = Set(targets.map(\.publicKey)) - #expect(keys == [contactKey, nonContactKey]) - } + let keys = Set(targets.map(\.publicKey)) + #expect(keys == [contactKey, nonContactKey]) + } - @Test("Non-contact responders are skipped when ad-hoc requests are unsupported") - func excludesNonContactNodesWhenUnsupported() { - let contactKey = Data(repeating: 0x11, count: 32) - let nonContactKey = Data(repeating: 0x22, count: 32) + @Test + func `Non-contact responders are skipped when ad-hoc requests are unsupported`() { + let contactKey = Data(repeating: 0x11, count: 32) + let nonContactKey = Data(repeating: 0x22, count: 32) - let targets = RegionDiscoveryService.buildRegionQueryTargets( - responders: [contactKey, nonContactKey], - contacts: [repeaterContact(contactKey)], - discoveredNodes: [repeaterNode(nonContactKey)], - supportsAdHocRequest: false - ) + let targets = RegionDiscoveryService.buildRegionQueryTargets( + responders: [contactKey, nonContactKey], + contacts: [repeaterContact(contactKey)], + discoveredNodes: [repeaterNode(nonContactKey)], + supportsAdHocRequest: false + ) - let keys = Set(targets.map(\.publicKey)) - #expect(keys == [contactKey]) - } + let keys = Set(targets.map(\.publicKey)) + #expect(keys == [contactKey]) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeKeepAliveTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeKeepAliveTests.swift index 108a124c..b5a8ccad 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeKeepAliveTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeKeepAliveTests.swift @@ -1,176 +1,175 @@ import Foundation -import Testing -import MeshCore @testable import MC1Services +import MeshCore +import Testing @Suite("RemoteNodeService keep-alive retry logic") struct RemoteNodeKeepAliveTests { - - // MARK: - Transient failures - - @Test("single transient failure retries without disconnecting") - func singleTransientFailureRetries() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.sessionError(.timeout), - consecutiveFailures: &failures - ) - #expect(failures == 1) - #expect(action == .retryNextInterval) - } - - @Test("two consecutive transient failures triggers disconnect") - func twoConsecutiveTransientFailuresDisconnect() { - var failures = 1 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.sessionError(.timeout), - consecutiveFailures: &failures - ) - #expect(failures == 2) - #expect(action == .disconnect) - } - - @Test("success resets failure counter") - func successResetsCounter() { - var failures = 1 - KeepAliveRetryPolicy.recordSuccess(consecutiveFailures: &failures) - #expect(failures == 0) - } - - // MARK: - Terminal failures - - @Test("sessionNotFound disconnects immediately without incrementing counter") - func sessionNotFoundDisconnectsImmediately() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.sessionNotFound, - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .disconnectNow) - } - - @Test("contactNotFound disconnects immediately without incrementing counter") - func contactNotFoundDisconnectsImmediately() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.contactNotFound, - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .disconnectNow) - } - - // MARK: - Transient error variants - - @Test("deviceError is treated as transient failure") - func deviceErrorIsTransient() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.sessionError(.deviceError(code: 7)), - consecutiveFailures: &failures - ) - #expect(failures == 1) - #expect(action == .retryNextInterval) - } - - @Test("notConnected is treated as transient failure") - func notConnectedIsTransient() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.sessionError(.notConnected), - consecutiveFailures: &failures - ) - #expect(failures == 1) - #expect(action == .retryNextInterval) - } - - // MARK: - Skip and stop - - @Test("floodRouted is not counted as a failure") - func floodRoutedNotCounted() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.floodRouted, - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .skip) - } - - @Test("CancellationError stops the loop quietly") - func cancellationErrorStops() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: CancellationError(), - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .stop) - } - - @Test("RemoteNodeError.cancelled stops the loop quietly") - func cancelledErrorStops() { - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: RemoteNodeError.cancelled, - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .stop) - } - - @Test("unknown non-RemoteNodeError disconnects immediately") - func unknownErrorDisconnectsImmediately() { - struct PersistenceError: Error {} - var failures = 0 - let action = KeepAliveRetryPolicy.evaluate( - error: PersistenceError(), - consecutiveFailures: &failures - ) - #expect(failures == 0) - #expect(action == .disconnectNow) - } - - // MARK: - Failure reasons - - @Test("failure reason describes timeout") - func failureReasonTimeout() { - let reason = KeepAliveRetryPolicy.failureReason( - for: RemoteNodeError.sessionError(.timeout) - ) - #expect(reason == "timeout") - } - - @Test("failure reason describes device error with code") - func failureReasonDeviceError() { - let reason = KeepAliveRetryPolicy.failureReason( - for: RemoteNodeError.sessionError(.deviceError(code: 42)) - ) - #expect(reason == "device error (code: 42)") - } - - @Test("failure reason describes transport not connected") - func failureReasonTransport() { - let reason = KeepAliveRetryPolicy.failureReason( - for: RemoteNodeError.sessionError(.notConnected) - ) - #expect(reason == "transport not connected") - } - - @Test("failure reason describes session not found") - func failureReasonSessionNotFound() { - let reason = KeepAliveRetryPolicy.failureReason( - for: RemoteNodeError.sessionNotFound - ) - #expect(reason == "session not found") - } - - @Test("failure reason describes contact not found") - func failureReasonContactNotFound() { - let reason = KeepAliveRetryPolicy.failureReason( - for: RemoteNodeError.contactNotFound - ) - #expect(reason == "contact not found") - } + // MARK: - Transient failures + + @Test + func `single transient failure retries without disconnecting`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.sessionError(.timeout), + consecutiveFailures: &failures + ) + #expect(failures == 1) + #expect(action == .retryNextInterval) + } + + @Test + func `two consecutive transient failures triggers disconnect`() { + var failures = 1 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.sessionError(.timeout), + consecutiveFailures: &failures + ) + #expect(failures == 2) + #expect(action == .disconnect) + } + + @Test + func `success resets failure counter`() { + var failures = 1 + KeepAliveRetryPolicy.recordSuccess(consecutiveFailures: &failures) + #expect(failures == 0) + } + + // MARK: - Terminal failures + + @Test + func `sessionNotFound disconnects immediately without incrementing counter`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.sessionNotFound, + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .disconnectNow) + } + + @Test + func `contactNotFound disconnects immediately without incrementing counter`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.contactNotFound, + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .disconnectNow) + } + + // MARK: - Transient error variants + + @Test + func `deviceError is treated as transient failure`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.sessionError(.deviceError(code: 7)), + consecutiveFailures: &failures + ) + #expect(failures == 1) + #expect(action == .retryNextInterval) + } + + @Test + func `notConnected is treated as transient failure`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.sessionError(.notConnected), + consecutiveFailures: &failures + ) + #expect(failures == 1) + #expect(action == .retryNextInterval) + } + + // MARK: - Skip and stop + + @Test + func `floodRouted is not counted as a failure`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.floodRouted, + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .skip) + } + + @Test + func `CancellationError stops the loop quietly`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: CancellationError(), + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .stop) + } + + @Test + func `RemoteNodeError.cancelled stops the loop quietly`() { + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: RemoteNodeError.cancelled, + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .stop) + } + + @Test + func `unknown non-RemoteNodeError disconnects immediately`() { + struct PersistenceError: Error {} + var failures = 0 + let action = KeepAliveRetryPolicy.evaluate( + error: PersistenceError(), + consecutiveFailures: &failures + ) + #expect(failures == 0) + #expect(action == .disconnectNow) + } + + // MARK: - Failure reasons + + @Test + func `failure reason describes timeout`() { + let reason = KeepAliveRetryPolicy.failureReason( + for: RemoteNodeError.sessionError(.timeout) + ) + #expect(reason == "timeout") + } + + @Test + func `failure reason describes device error with code`() { + let reason = KeepAliveRetryPolicy.failureReason( + for: RemoteNodeError.sessionError(.deviceError(code: 42)) + ) + #expect(reason == "device error (code: 42)") + } + + @Test + func `failure reason describes transport not connected`() { + let reason = KeepAliveRetryPolicy.failureReason( + for: RemoteNodeError.sessionError(.notConnected) + ) + #expect(reason == "transport not connected") + } + + @Test + func `failure reason describes session not found`() { + let reason = KeepAliveRetryPolicy.failureReason( + for: RemoteNodeError.sessionNotFound + ) + #expect(reason == "session not found") + } + + @Test + func `failure reason describes contact not found`() { + let reason = KeepAliveRetryPolicy.failureReason( + for: RemoteNodeError.contactNotFound + ) + #expect(reason == "contact not found") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift index 7b6c885d..c3500922 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeLoginHealTests.swift @@ -1,255 +1,254 @@ import Foundation -import Testing -import MeshCore @testable import MC1Services +import MeshCore +import Testing /// Covers the login auto-heal path: when the radio reports a contact missing from its table /// (firmware notFound, 0x02) during `sendLogin`, the service pushes the local contact to the radio /// and retries once. @Suite("RemoteNodeService login heal") struct RemoteNodeLoginHealTests { - - private static let publicKey = Data(repeating: 0xCC, count: 32) - private static let directPath = Data([0x01, 0x02]) - - @Test("notFound on login pushes the local contact to the radio and retries") - func healsMissingContactThenRetries() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - // A direct-routed contact present locally but absent from the radio's table. - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Self.publicKey, - outPathLength: 2, - outPath: Self.directPath - ) - try await dataStore.saveContact(contact) - - let session = MockMeshCoreSession() - await session.setSendLoginResults([ - .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), - .success(MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000)) - ]) - - let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) - - _ = try await service.sendLoginHealingIfNeeded( - publicKey: Self.publicKey, - radioID: radioID, - password: "" - ) - - // The radio was sent the missing contact exactly once, and login was retried. - #expect(await session.addContactInvocations.count == 1) - #expect(await session.sendLoginInvocations.count == 2) - // The pushed contact is the right node, flood-routed, and the local row is reconciled to match. - let pushed = await session.addContactInvocations.first?.contact - #expect(pushed?.publicKey == Self.publicKey) - #expect(pushed?.outPathLength == PacketBuilder.floodPathSentinel) - let healed = try await dataStore.fetchContact(radioID: radioID, publicKey: Self.publicKey) - #expect(healed?.isFloodRouted == true) + private static let publicKey = Data(repeating: 0xCC, count: 32) + private static let directPath = Data([0x01, 0x02]) + + @Test + func `notFound on login pushes the local contact to the radio and retries`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // A direct-routed contact present locally but absent from the radio's table. + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), + .success(MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + + // The radio was sent the missing contact exactly once, and login was retried. + #expect(await session.addContactInvocations.count == 1) + #expect(await session.sendLoginInvocations.count == 2) + // The pushed contact is the right node, flood-routed, and the local row is reconciled to match. + let pushed = await session.addContactInvocations.first?.contact + #expect(pushed?.publicKey == Self.publicKey) + #expect(pushed?.outPathLength == PacketBuilder.floodPathSentinel) + let healed = try await dataStore.fetchContact(radioID: radioID, publicKey: Self.publicKey) + #expect(healed?.isFloodRouted == true) + } + + @Test + func `healing preserves a contact type byte not modeled by ContactType`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + // A type byte newer firmware might use that ContactType does not model; it must reach the + // radio verbatim rather than being coerced to .chat by the typed accessor. + let unmodeledType: UInt8 = 0x7F + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + typeRawValue: unmodeledType, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), + .success(MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + + let pushed = await session.addContactInvocations.first?.contact + #expect(pushed?.typeRawValue == unmodeledType) + } + + @Test + func `a non-notFound login error skips healing and is not retried`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + // tableFull shares its code with the add-contact path; only notFound from sendLogin may heal. + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: MeshCoreError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) } - - @Test("healing preserves a contact type byte not modeled by ContactType") - func healingPreservesUnmodeledTypeByte() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - // A type byte newer firmware might use that ContactType does not model; it must reach the - // radio verbatim rather than being coerced to .chat by the typed accessor. - let unmodeledType: UInt8 = 0x7F - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Self.publicKey, - typeRawValue: unmodeledType, - outPathLength: 2, - outPath: Self.directPath - ) - try await dataStore.saveContact(contact) - - let session = MockMeshCoreSession() - await session.setSendLoginResults([ - .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), - .success(MessageSentInfo(route: 0, expectedAck: Data([0x01, 0x02, 0x03, 0x04]), suggestedTimeoutMs: 5000)) - ]) - - let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) - - _ = try await service.sendLoginHealingIfNeeded( - publicKey: Self.publicKey, - radioID: radioID, - password: "" - ) - - let pushed = await session.addContactInvocations.first?.contact - #expect(pushed?.typeRawValue == unmodeledType) + guard case let .deviceError(code) = thrown, code == ProtocolError.tableFull.rawValue else { + Issue.record("expected the original tableFull device error, got \(String(describing: thrown))") + return } - - @Test("a non-notFound login error skips healing and is not retried") - func nonNotFoundErrorSkipsHealing() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Self.publicKey, - outPathLength: 2, - outPath: Self.directPath - ) - try await dataStore.saveContact(contact) - - let session = MockMeshCoreSession() - // tableFull shares its code with the add-contact path; only notFound from sendLogin may heal. - await session.setSendLoginResults([ - .failure(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) - ]) - - let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) - - let thrown = await #expect(throws: MeshCoreError.self) { - _ = try await service.sendLoginHealingIfNeeded( - publicKey: Self.publicKey, - radioID: radioID, - password: "" - ) - } - guard case .deviceError(let code) = thrown, code == ProtocolError.tableFull.rawValue else { - Issue.record("expected the original tableFull device error, got \(String(describing: thrown))") - return - } - #expect(await session.addContactInvocations.isEmpty) - #expect(await session.sendLoginInvocations.count == 1) + #expect(await session.addContactInvocations.isEmpty) + #expect(await session.sendLoginInvocations.count == 1) + } + + @Test + func `a second notFound after re-adding the contact surfaces the device error unhealed`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: MeshCoreError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) } - - @Test("a second notFound after re-adding the contact surfaces the device error unhealed") - func secondNotFoundAfterHealPropagates() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Self.publicKey, - outPathLength: 2, - outPath: Self.directPath - ) - try await dataStore.saveContact(contact) - - let session = MockMeshCoreSession() - await session.setSendLoginResults([ - .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)), - .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) - ]) - - let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) - - let thrown = await #expect(throws: MeshCoreError.self) { - _ = try await service.sendLoginHealingIfNeeded( - publicKey: Self.publicKey, - radioID: radioID, - password: "" - ) - } - guard case .deviceError(let code) = thrown, code == ProtocolError.notFound.rawValue else { - Issue.record("expected notFound device error, got \(String(describing: thrown))") - return - } - // The contact was re-added once, then the retry's second notFound surfaced without re-adding. - #expect(await session.addContactInvocations.count == 1) - #expect(await session.sendLoginInvocations.count == 2) + guard case let .deviceError(code) = thrown, code == ProtocolError.notFound.rawValue else { + Issue.record("expected notFound device error, got \(String(describing: thrown))") + return } - - @Test("a full radio contact table surfaces radioContactsFull") - func tableFullSurfacesActionableError() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Self.publicKey, - outPathLength: 2, - outPath: Self.directPath - ) - try await dataStore.saveContact(contact) - - let session = MockMeshCoreSession() - await session.setSendLoginResults([ - .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) - ]) - await session.setAddContactError(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) - - let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) - - let thrown = await #expect(throws: RemoteNodeError.self) { - _ = try await service.sendLoginHealingIfNeeded( - publicKey: Self.publicKey, - radioID: radioID, - password: "" - ) - } - guard case .radioContactsFull = thrown else { - Issue.record("expected radioContactsFull, got \(String(describing: thrown))") - return - } - // The add was attempted once and the retry never fired, so login stops at the full table. - #expect(await session.addContactInvocations.count == 1) - #expect(await session.sendLoginInvocations.count == 1) + // The contact was re-added once, then the retry's second notFound surfaced without re-adding. + #expect(await session.addContactInvocations.count == 1) + #expect(await session.sendLoginInvocations.count == 2) + } + + @Test + func `a full radio contact table surfaces radioContactsFull`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + await session.setAddContactError(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: RemoteNodeError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) } - - @Test("a contact absent from the local store cannot be healed") - func missingLocalContactCannotBeHealed() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - let session = MockMeshCoreSession() - await session.setSendLoginResults([ - .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) - ]) - - let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) - - let thrown = await #expect(throws: RemoteNodeError.self) { - _ = try await service.sendLoginHealingIfNeeded( - publicKey: Self.publicKey, - radioID: radioID, - password: "" - ) - } - guard case .contactNotFound = thrown else { - Issue.record("expected contactNotFound, got \(String(describing: thrown))") - return - } - #expect(await session.addContactInvocations.isEmpty) + guard case .radioContactsFull = thrown else { + Issue.record("expected radioContactsFull, got \(String(describing: thrown))") + return } - - @Test("login() surfaces radioContactsFull through the continuation instead of a generic session error") - func loginPreservesTypedHealError() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID, publicKey: Self.publicKey) - try await dataStore.saveRemoteNodeSessionDTO(remoteSession) - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: Self.publicKey, - outPathLength: 2, - outPath: Self.directPath - ) - try await dataStore.saveContact(contact) - - // The radio is missing the contact (notFound), and re-adding it hits a full table. The error the - // public login() resolves the continuation with must stay the typed RemoteNodeError.radioContactsFull, - // not collapse into the generic .sessionError that the catch falls back to. - let session = MockMeshCoreSession() - await session.setSendLoginResults([ - .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) - ]) - await session.setAddContactError(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) - - let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) - - let thrown = await #expect(throws: RemoteNodeError.self) { - _ = try await service.login(sessionID: remoteSession.id, password: "") - } - guard case .radioContactsFull = thrown else { - Issue.record("expected radioContactsFull to propagate unwrapped, got \(String(describing: thrown))") - return - } - #expect(await session.sendLoginInvocations.count == 1) - #expect(await session.addContactInvocations.count == 1) + // The add was attempted once and the retry never fired, so login stops at the full table. + #expect(await session.addContactInvocations.count == 1) + #expect(await session.sendLoginInvocations.count == 1) + } + + @Test + func `a contact absent from the local store cannot be healed`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: RemoteNodeError.self) { + _ = try await service.sendLoginHealingIfNeeded( + publicKey: Self.publicKey, + radioID: radioID, + password: "" + ) + } + guard case .contactNotFound = thrown else { + Issue.record("expected contactNotFound, got \(String(describing: thrown))") + return + } + #expect(await session.addContactInvocations.isEmpty) + } + + @Test + func `login() surfaces radioContactsFull through the continuation instead of a generic session error`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID, publicKey: Self.publicKey) + try await dataStore.saveRemoteNodeSessionDTO(remoteSession) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: Self.publicKey, + outPathLength: 2, + outPath: Self.directPath + ) + try await dataStore.saveContact(contact) + + // The radio is missing the contact (notFound), and re-adding it hits a full table. The error the + // public login() resolves the continuation with must stay the typed RemoteNodeError.radioContactsFull, + // not collapse into the generic .sessionError that the catch falls back to. + let session = MockMeshCoreSession() + await session.setSendLoginResults([ + .failure(MeshCoreError.deviceError(code: ProtocolError.notFound.rawValue)) + ]) + await session.setAddContactError(MeshCoreError.deviceError(code: ProtocolError.tableFull.rawValue)) + + let service = RemoteNodeService(session: session, dataStore: dataStore, keychainService: KeychainService()) + + let thrown = await #expect(throws: RemoteNodeError.self) { + _ = try await service.login(sessionID: remoteSession.id, password: "") + } + guard case .radioContactsFull = thrown else { + Issue.record("expected radioContactsFull to propagate unwrapped, got \(String(describing: thrown))") + return } + #expect(await session.sendLoginInvocations.count == 1) + #expect(await session.addContactInvocations.count == 1) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeTeardownTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeTeardownTests.swift index 08dfdd6f..54bea942 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeTeardownTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeTeardownTests.swift @@ -1,105 +1,110 @@ import Foundation -import Testing -import MeshCore @testable import MC1Services +import MeshCore +import Testing /// A transport whose `send` succeeds but that never yields session bytes, so any /// command awaiting a device response stays parked. Models a radio that accepted /// the write but went silent, which is the state `stopAllKeepAlives` must unwind. private actor SilentTransport: MeshTransport { - private let dataStream: AsyncStream - private let dataContinuation: AsyncStream.Continuation - - init() { - var continuation: AsyncStream.Continuation! - self.dataStream = AsyncStream { continuation = $0 } - self.dataContinuation = continuation - } - - var receivedData: AsyncStream { dataStream } - var isConnected: Bool { true } - func connect() async throws {} - func disconnect() async {} - func send(_ data: Data) async throws {} + private let dataStream: AsyncStream + private let dataContinuation: AsyncStream.Continuation + + init() { + var continuation: AsyncStream.Continuation! + dataStream = AsyncStream { continuation = $0 } + dataContinuation = continuation + } + + var receivedData: AsyncStream { + dataStream + } + + var isConnected: Bool { + true + } + + func connect() async throws {} + func disconnect() async {} + func send(_ data: Data) async throws {} } @Suite("RemoteNodeService teardown") struct RemoteNodeTeardownTests { + @Test + func `stopAllKeepAlives resumes a parked login continuation`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + + // Long timeout keeps `sendLogin` blocked on the silent transport, so the + // login continuation stays parked in `pendingLogins` for the duration. + let session = MeshCoreSession( + transport: SilentTransport(), + configuration: SessionConfiguration(defaultTimeout: 30.0, clientIdentifier: "MCTst") + ) + + let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID) + try await dataStore.saveRemoteNodeSessionDTO(remoteSession) + + let service = RemoteNodeService( + session: session, + dataStore: dataStore, + keychainService: KeychainService() + ) + + let loginTask = Task { + try await service.login(sessionID: remoteSession.id, password: "test-password") + } - @Test("stopAllKeepAlives resumes a parked login continuation") - func stopAllKeepAlivesResumesParkedLogin() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - - // Long timeout keeps `sendLogin` blocked on the silent transport, so the - // login continuation stays parked in `pendingLogins` for the duration. - let session = MeshCoreSession( - transport: SilentTransport(), - configuration: SessionConfiguration(defaultTimeout: 30.0, clientIdentifier: "MCTst") - ) - - let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID) - try await dataStore.saveRemoteNodeSessionDTO(remoteSession) - - let service = RemoteNodeService( - session: session, - dataStore: dataStore, - keychainService: KeychainService() - ) - - let loginTask = Task { - try await service.login(sessionID: remoteSession.id, password: "test-password") - } - - // Wait until the continuation is registered before tearing down. - try await waitUntil("login continuation was never parked") { - await service.pendingLoginCount == 1 - } - - await service.stopAllKeepAlives() - - await #expect(throws: RemoteNodeError.self) { - _ = try await loginTask.value - } - #expect(await service.pendingLoginCount == 0) + // Wait until the continuation is registered before tearing down. + try await waitUntil("login continuation was never parked") { + await service.pendingLoginCount == 1 } - @Test("keep-alive loop does not retain the service after the last strong reference is dropped") - func keepAliveLoopDoesNotRetainService() async throws { - let radioID = UUID() - let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) - let session = MeshCoreSession( - transport: SilentTransport(), - configuration: SessionConfiguration(defaultTimeout: 30.0, clientIdentifier: "MCTst") - ) - - let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID) - try await dataStore.saveRemoteNodeSessionDTO(remoteSession) - - // A flood-routed contact makes each keep-alive tick a skip, so the - // loop stays parked in its inter-tick sleep without touching the session. - let contact = ContactDTO.testContact( - radioID: radioID, - publicKey: remoteSession.publicKey, - outPathLength: 0xFF - ) - try await dataStore.saveContact(contact) - - var service: RemoteNodeService? = RemoteNodeService( - session: session, - dataStore: dataStore, - keychainService: KeychainService() - ) - weak var weakService = service - - await service?.startSessionKeepAlive( - sessionID: remoteSession.id, - publicKey: remoteSession.publicKey - ) - service = nil - - try await waitUntil("keep-alive task must not keep the service alive") { - weakService == nil - } + await service.stopAllKeepAlives() + + await #expect(throws: RemoteNodeError.self) { + _ = try await loginTask.value + } + #expect(await service.pendingLoginCount == 0) + } + + @Test + func `keep-alive loop does not retain the service after the last strong reference is dropped`() async throws { + let radioID = UUID() + let dataStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let session = MeshCoreSession( + transport: SilentTransport(), + configuration: SessionConfiguration(defaultTimeout: 30.0, clientIdentifier: "MCTst") + ) + + let remoteSession = RemoteNodeSessionDTO.testSession(radioID: radioID) + try await dataStore.saveRemoteNodeSessionDTO(remoteSession) + + // A flood-routed contact makes each keep-alive tick a skip, so the + // loop stays parked in its inter-tick sleep without touching the session. + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: remoteSession.publicKey, + outPathLength: 0xFF + ) + try await dataStore.saveContact(contact) + + var service: RemoteNodeService? = RemoteNodeService( + session: session, + dataStore: dataStore, + keychainService: KeychainService() + ) + weak var weakService = service + + await service?.startSessionKeepAlive( + sessionID: remoteSession.id, + publicKey: remoteSession.publicKey + ) + service = nil + + try await waitUntil("keep-alive task must not keep the service alive") { + weakService == nil } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceAdvertHopTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceAdvertHopTests.swift index 3df76d4f..76a1e66b 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceAdvertHopTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceAdvertHopTests.swift @@ -1,185 +1,184 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("RxLogService inbound advert hop count") struct RxLogServiceAdvertHopTests { - - private static let advertiserKey = Data((0.. Data { - pubKey + Data([0xAA, 0xBB, 0xCC, 0xDD]) - } - - private static func makeParsed( - payloadType: PayloadType, - pathLength: UInt8, - packetPayload: Data, - routeType: RouteType = .flood - ) -> ParsedRxLogData { - ParsedRxLogData( - snr: 8.0, - rssi: -70, - rawPayload: packetPayload, - routeType: routeType, - payloadType: payloadType, - payloadVersion: 0, - payloadTypeBits: 0, - transportCode: nil, - pathLength: pathLength, - pathNodes: [], - packetPayload: packetPayload - ) - } + private static let advertiserKey = Data((0.. Data { + pubKey + Data([0xAA, 0xBB, 0xCC, 0xDD]) + } + + private static func makeParsed( + payloadType: PayloadType, + pathLength: UInt8, + packetPayload: Data, + routeType: RouteType = .flood + ) -> ParsedRxLogData { + ParsedRxLogData( + snr: 8.0, + rssi: -70, + rawPayload: packetPayload, + routeType: routeType, + payloadType: payloadType, + payloadVersion: 0, + payloadTypeBits: 0, + transportCode: nil, + pathLength: pathLength, + pathNodes: [], + packetPayload: packetPayload + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceReprocessTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceReprocessTests.swift index a164aba1..0408ee25 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceReprocessTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceReprocessTests.swift @@ -1,189 +1,193 @@ -import Testing -import Foundation import CommonCrypto import CryptoKit +import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("RxLogService reprocessing and streaming") struct RxLogServiceReprocessTests { - - private actor EntryCollector { - var entries: [RxLogEntryDTO] = [] - func record(_ entry: RxLogEntryDTO) { entries.append(entry) } - var count: Int { entries.count } + private actor EntryCollector { + var entries: [RxLogEntryDTO] = [] + func record(_ entry: RxLogEntryDTO) { + entries.append(entry) } - @Test("late-decrypted channel entries keep the matching channel index and name") - func reprocessAttributesMatchingChannel() async throws { - let radioID = UUID() - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let session = MeshCoreSession(transport: MockTransport()) - let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) - - let channelZeroSecret = Data(repeating: 0x99, count: 16) - let channelThreeSecret = Data(repeating: 0x42, count: 16) - - // Seed the channels in the store first so the event monitor's secret - // load cannot interleave with different values than updateChannels sets. - try await dataStore.saveChannel(ChannelDTO.testChannel( - radioID: radioID, index: 0, name: "Public", secret: channelZeroSecret - )) - try await dataStore.saveChannel(ChannelDTO.testChannel( - radioID: radioID, index: 3, name: "Three", secret: channelThreeSecret - )) - - await service.startEventMonitoring(radioID: radioID) - defer { Task { await service.stopEventMonitoring() } } - - let senderTimestamp: UInt32 = 1_700_000_000 - let payload = Self.encryptedChannelPayload( - timestamp: senderTimestamp, - text: "hello", - secret: channelThreeSecret - ) - let entry = RxLogEntryDTO( - radioID: radioID, - from: Self.makeParsed(payloadType: .groupText, packetPayload: payload), - decryptStatus: .noMatchingKey - ) - try await dataStore.saveRxLogEntry(entry) - - await service.updateChannels( - secrets: [0: channelZeroSecret, 3: channelThreeSecret], - names: [0: "Public", 3: "Three"] - ) - - let entries = try await dataStore.fetchRxLogEntries(radioID: radioID) - let updated = try #require(entries.first { $0.id == entry.id }) - #expect(updated.channelIndex == 3, - "reprocessing must record the channel whose secret matched, not nil") - #expect(updated.channelName == "Three", - "reprocessing must not fall back to channel 0's name") - #expect(updated.senderTimestamp == senderTimestamp) - #expect(updated.decryptStatus == .success) + var count: Int { + entries.count } - - @Test("coexisting entry stream subscribers each receive every entry") - func coexistingSubscribersBothReceiveEntries() async throws { - let radioID = UUID() - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let session = MeshCoreSession(transport: MockTransport()) - let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) - await service.startEventMonitoring(radioID: radioID) - defer { Task { await service.stopEventMonitoring() } } - - let streamA = service.entryStream() - let streamB = service.entryStream() - - let collectorA = EntryCollector() - let collectorB = EntryCollector() - let taskA = Task { - for await entry in streamA { - await collectorA.record(entry) - } - } - let taskB = Task { - for await entry in streamB { - await collectorB.record(entry) - } - } - - await service.process(Self.makeParsed( - payloadType: .advert, - packetPayload: Data([0x01, 0x02, 0x03, 0x04]) - )) - - try await waitUntil("the first subscriber must receive the entry") { - await collectorA.count > 0 - } - try await waitUntil("the second subscriber must also receive the entry") { - await collectorB.count > 0 - } - taskA.cancel() - taskB.cancel() + } + + @Test + func `late-decrypted channel entries keep the matching channel index and name`() async throws { + let radioID = UUID() + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let session = MeshCoreSession(transport: MockTransport()) + let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) + + let channelZeroSecret = Data(repeating: 0x99, count: 16) + let channelThreeSecret = Data(repeating: 0x42, count: 16) + + // Seed the channels in the store first so the event monitor's secret + // load cannot interleave with different values than updateChannels sets. + try await dataStore.saveChannel(ChannelDTO.testChannel( + radioID: radioID, index: 0, name: "Public", secret: channelZeroSecret + )) + try await dataStore.saveChannel(ChannelDTO.testChannel( + radioID: radioID, index: 3, name: "Three", secret: channelThreeSecret + )) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + let senderTimestamp: UInt32 = 1_700_000_000 + let payload = Self.encryptedChannelPayload( + timestamp: senderTimestamp, + text: "hello", + secret: channelThreeSecret + ) + let entry = RxLogEntryDTO( + radioID: radioID, + from: Self.makeParsed(payloadType: .groupText, packetPayload: payload), + decryptStatus: .noMatchingKey + ) + try await dataStore.saveRxLogEntry(entry) + + await service.updateChannels( + secrets: [0: channelZeroSecret, 3: channelThreeSecret], + names: [0: "Public", 3: "Three"] + ) + + let entries = try await dataStore.fetchRxLogEntries(radioID: radioID) + let updated = try #require(entries.first { $0.id == entry.id }) + #expect(updated.channelIndex == 3, + "reprocessing must record the channel whose secret matched, not nil") + #expect(updated.channelName == "Three", + "reprocessing must not fall back to channel 0's name") + #expect(updated.senderTimestamp == senderTimestamp) + #expect(updated.decryptStatus == .success) + } + + @Test + func `coexisting entry stream subscribers each receive every entry`() async throws { + let radioID = UUID() + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let session = MeshCoreSession(transport: MockTransport()) + let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + let streamA = service.entryStream() + let streamB = service.entryStream() + + let collectorA = EntryCollector() + let collectorB = EntryCollector() + let taskA = Task { + for await entry in streamA { + await collectorA.record(entry) + } + } + let taskB = Task { + for await entry in streamB { + await collectorB.record(entry) + } } - @Test("finishEntryStream ends every subscriber's iteration") - func finishEndsAllSubscribers() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let session = MeshCoreSession(transport: MockTransport()) - let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) - - let streamA = service.entryStream() - let streamB = service.entryStream() - let iterationA = Task { - for await _ in streamA {} - return true - } - let iterationB = Task { - for await _ in streamB {} - return true - } - - service.finishEntryStream() + await service.process(Self.makeParsed( + payloadType: .advert, + packetPayload: Data([0x01, 0x02, 0x03, 0x04]) + )) - #expect(await iterationA.value, "finish must end the first subscriber's loop") - #expect(await iterationB.value, "finish must end the second subscriber's loop") + try await waitUntil("the first subscriber must receive the entry") { + await collectorA.count > 0 } - - // MARK: - Helpers - - private static func makeParsed(payloadType: PayloadType, packetPayload: Data) -> ParsedRxLogData { - ParsedRxLogData( - snr: 8.0, - rssi: -70, - rawPayload: packetPayload, - routeType: .flood, - payloadType: payloadType, - payloadVersion: 0, - payloadTypeBits: 0, - transportCode: nil, - pathLength: 0, - pathNodes: [], - packetPayload: packetPayload - ) + try await waitUntil("the second subscriber must also receive the entry") { + await collectorB.count > 0 + } + taskA.cancel() + taskB.cancel() + } + + @Test + func `finishEntryStream ends every subscriber's iteration`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let session = MeshCoreSession(transport: MockTransport()) + let service = RxLogService(session: session, dataStore: dataStore, heardRepeatsService: nil) + + let streamA = service.entryStream() + let streamB = service.entryStream() + let iterationA = Task { + for await _ in streamA {} + return true + } + let iterationB = Task { + for await _ in streamB {} + return true } - /// Builds a wire-format channel payload (`[channelHash:1][MAC:2][ciphertext:N]`) - /// that `ChannelCrypto.decrypt` accepts: AES-128 ECB ciphertext authenticated - /// with a truncated HMAC-SHA256, mirroring the firmware's encrypt-then-MAC. - private static func encryptedChannelPayload(timestamp: UInt32, text: String, secret: Data) -> Data { - var plaintext = withUnsafeBytes(of: timestamp.littleEndian) { Data($0) } - plaintext.append(0) - plaintext.append(Data(text.utf8)) - let blockSize = kCCBlockSizeAES128 - let paddedCount = (plaintext.count + blockSize - 1) / blockSize * blockSize - plaintext.append(Data(count: paddedCount - plaintext.count)) - - var ciphertext = Data(count: plaintext.count) - var encryptedCount: size_t = 0 - let keyBytes = secret.prefix(kCCKeySizeAES128) - let status = ciphertext.withUnsafeMutableBytes { outPtr in - plaintext.withUnsafeBytes { inPtr in - keyBytes.withUnsafeBytes { keyPtr in - CCCrypt( - CCOperation(kCCEncrypt), - CCAlgorithm(kCCAlgorithmAES), - CCOptions(kCCOptionECBMode), - keyPtr.baseAddress, kCCKeySizeAES128, - nil, - inPtr.baseAddress, plaintext.count, - outPtr.baseAddress, plaintext.count, - &encryptedCount - ) - } - } + service.finishEntryStream() + + #expect(await iterationA.value, "finish must end the first subscriber's loop") + #expect(await iterationB.value, "finish must end the second subscriber's loop") + } + + // MARK: - Helpers + + private static func makeParsed(payloadType: PayloadType, packetPayload: Data) -> ParsedRxLogData { + ParsedRxLogData( + snr: 8.0, + rssi: -70, + rawPayload: packetPayload, + routeType: .flood, + payloadType: payloadType, + payloadVersion: 0, + payloadTypeBits: 0, + transportCode: nil, + pathLength: 0, + pathNodes: [], + packetPayload: packetPayload + ) + } + + /// Builds a wire-format channel payload (`[channelHash:1][MAC:2][ciphertext:N]`) + /// that `ChannelCrypto.decrypt` accepts: AES-128 ECB ciphertext authenticated + /// with a truncated HMAC-SHA256, mirroring the firmware's encrypt-then-MAC. + private static func encryptedChannelPayload(timestamp: UInt32, text: String, secret: Data) -> Data { + var plaintext = withUnsafeBytes(of: timestamp.littleEndian) { Data($0) } + plaintext.append(0) + plaintext.append(Data(text.utf8)) + let blockSize = kCCBlockSizeAES128 + let paddedCount = (plaintext.count + blockSize - 1) / blockSize * blockSize + plaintext.append(Data(count: paddedCount - plaintext.count)) + + var ciphertext = Data(count: plaintext.count) + var encryptedCount: size_t = 0 + let keyBytes = secret.prefix(kCCKeySizeAES128) + let status = ciphertext.withUnsafeMutableBytes { outPtr in + plaintext.withUnsafeBytes { inPtr in + keyBytes.withUnsafeBytes { keyPtr in + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionECBMode), + keyPtr.baseAddress, kCCKeySizeAES128, + nil, + inPtr.baseAddress, plaintext.count, + outPtr.baseAddress, plaintext.count, + &encryptedCount + ) } - precondition(status == kCCSuccess, "test fixture encryption failed") - - let mac = Data( - HMAC.authenticationCode(for: ciphertext, using: SymmetricKey(data: secret)) - .prefix(ChannelCrypto.macSize) - ) - return Data([0x00]) + mac + ciphertext.prefix(encryptedCount) + } } + precondition(status == kCCSuccess, "test fixture encryption failed") + + let mac = Data( + HMAC.authenticationCode(for: ciphertext, using: SymmetricKey(data: secret)) + .prefix(ChannelCrypto.macSize) + ) + return Data([0x00]) + mac + ciphertext.prefix(encryptedCount) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceDefaultFloodScopeTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceDefaultFloodScopeTests.swift index 3fc9adad..9bee276e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceDefaultFloodScopeTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceDefaultFloodScopeTests.swift @@ -1,146 +1,147 @@ import Foundation -import Testing -@testable import MeshCore @testable import MC1Services +@testable import MeshCore +import Testing @Suite("SettingsService default flood scope") struct SettingsServiceDefaultFloodScopeTests { + @Test + @MainActor + func `setDefaultFloodScopeVerified truncates overlong names before send and verify`() async throws { + let maxBytes = ProtocolLimits.maxDefaultFloodScopeNameBytes + let overlong = String(repeating: "a", count: maxBytes + 5) + let truncated = String(repeating: "a", count: maxBytes) - @Test("setDefaultFloodScopeVerified truncates overlong names before send and verify") - @MainActor - func setDefaultFloodScopeVerified_truncatesOverlongName() async throws { - let maxBytes = ProtocolLimits.maxDefaultFloodScopeNameBytes - let overlong = String(repeating: "a", count: maxBytes + 5) - let truncated = String(repeating: "a", count: maxBytes) - - let (service, session, transport) = try await makeService() - defer { Task { await session.stop() } } - - let setTask = Task { try await service.setDefaultFloodScopeVerified(name: overlong) } - - try await waitUntil("service should send setDefaultFloodScope command") { - await transport.sentData.count == 2 - } - let sentAfterWrite = await transport.sentData - let expectedPacket = PacketBuilder.setDefaultFloodScope( - name: truncated, - scope: .region(truncated) - ) - #expect(sentAfterWrite[1] == expectedPacket) - await transport.simulateOK() - - try await waitUntil("service should verify via getDefaultFloodScope") { - await transport.sentData.count == 3 - } - let sentAfterVerify = await transport.sentData - #expect(sentAfterVerify[2] == PacketBuilder.getDefaultFloodScope()) - await transport.simulateReceive(makeDefaultFloodScopePacket(name: truncated)) - - let result = try await setTask.value - #expect(result == truncated) - } - - @Test("setDefaultFloodScopeVerified forwards names at or below the cap unchanged") - @MainActor - func setDefaultFloodScopeVerified_passesShortNameThrough() async throws { - let name = "Germany" - - let (service, session, transport) = try await makeService() - defer { Task { await session.stop() } } - - let setTask = Task { try await service.setDefaultFloodScopeVerified(name: name) } + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } - try await waitUntil("service should send setDefaultFloodScope command") { - await transport.sentData.count == 2 - } - let sent = await transport.sentData - #expect(sent[1] == PacketBuilder.setDefaultFloodScope(name: name, scope: .region(name))) - await transport.simulateOK() + let setTask = Task { try await service.setDefaultFloodScopeVerified(name: overlong) } - try await waitUntil("service should verify via getDefaultFloodScope") { - await transport.sentData.count == 3 - } - await transport.simulateReceive(makeDefaultFloodScopePacket(name: name)) - - let result = try await setTask.value - #expect(result == name) + try await waitUntil("service should send setDefaultFloodScope command") { + await transport.sentData.count == 2 + } + let sentAfterWrite = await transport.sentData + let expectedPacket = PacketBuilder.setDefaultFloodScope( + name: truncated, + scope: .region(truncated) + ) + #expect(sentAfterWrite[1] == expectedPacket) + await transport.simulateOK() + + try await waitUntil("service should verify via getDefaultFloodScope") { + await transport.sentData.count == 3 } + let sentAfterVerify = await transport.sentData + #expect(sentAfterVerify[2] == PacketBuilder.getDefaultFloodScope()) + await transport.simulateReceive(makeDefaultFloodScopePacket(name: truncated)) - @Test("setDefaultFloodScopeVerified clears the scope when name is nil") - @MainActor - func setDefaultFloodScopeVerified_clear() async throws { - let (service, session, transport) = try await makeService() - defer { Task { await session.stop() } } + let result = try await setTask.value + #expect(result == truncated) + } - let setTask = Task { try await service.setDefaultFloodScopeVerified(name: nil) } + @Test + @MainActor + func `setDefaultFloodScopeVerified forwards names at or below the cap unchanged`() async throws { + let name = "Germany" - try await waitUntil("service should send setDefaultFloodScope clear command") { - await transport.sentData.count == 2 - } - let sent = await transport.sentData - #expect(sent[1] == PacketBuilder.setDefaultFloodScope(name: "", scope: .disabled)) - await transport.simulateOK() + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } - try await waitUntil("service should verify via getDefaultFloodScope") { - await transport.sentData.count == 3 - } - await transport.simulateReceive(Data([ResponseCode.defaultFloodScope.rawValue])) + let setTask = Task { try await service.setDefaultFloodScopeVerified(name: name) } - let result = try await setTask.value - #expect(result == nil) + try await waitUntil("service should send setDefaultFloodScope command") { + await transport.sentData.count == 2 } + let sent = await transport.sentData + #expect(sent[1] == PacketBuilder.setDefaultFloodScope(name: name, scope: .region(name))) + await transport.simulateOK() - private func makeService() async throws -> (SettingsService, MeshCoreSession, MockTransport) { - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) + try await waitUntil("service should verify via getDefaultFloodScope") { + await transport.sentData.count == 3 + } + await transport.simulateReceive(makeDefaultFloodScopePacket(name: name)) - let startTask = Task { try await session.start() } + let result = try await setTask.value + #expect(result == name) + } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value + @Test + @MainActor + func `setDefaultFloodScopeVerified clears the scope when name is nil`() async throws { + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } - return (SettingsService(session: session), session, transport) - } + let setTask = Task { try await service.setDefaultFloodScopeVerified(name: nil) } - private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(int32Bytes(0)) - payload.append(int32Bytes(0)) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(uint32Bytes(915_000)) - payload.append(uint32Bytes(125_000)) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) - - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet + try await waitUntil("service should send setDefaultFloodScope clear command") { + await transport.sentData.count == 2 } + let sent = await transport.sentData + #expect(sent[1] == PacketBuilder.setDefaultFloodScope(name: "", scope: .disabled)) + await transport.simulateOK() - private func makeDefaultFloodScopePacket(name: String) -> Data { - var packet = Data([ResponseCode.defaultFloodScope.rawValue]) - var nameField = Data(name.utf8) - while nameField.count < 31 { nameField.append(0) } - packet.append(nameField) - packet.append(Data(repeating: 0, count: 16)) - return packet + try await waitUntil("service should verify via getDefaultFloodScope") { + await transport.sentData.count == 3 } + await transport.simulateReceive(Data([ResponseCode.defaultFloodScope.rawValue])) - private func int32Bytes(_ value: Double) -> Data { - withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } - } + let result = try await setTask.value + #expect(result == nil) + } + + private func makeService() async throws -> (SettingsService, MeshCoreSession, MockTransport) { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) - private func uint32Bytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.littleEndian) { Data($0) } + let startTask = Task { try await session.start() } + + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + + return (SettingsService(session: session), session, transport) + } + + private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(0)) + payload.append(int32Bytes(0)) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } + + private func makeDefaultFloodScopePacket(name: String) -> Data { + var packet = Data([ResponseCode.defaultFloodScope.rawValue]) + var nameField = Data(name.utf8) + while nameField.count < 31 { + nameField.append(0) } + packet.append(nameField) + packet.append(Data(repeating: 0, count: 16)) + return packet + } + + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } + + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceEventStreamTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceEventStreamTests.swift index ccf1a7b8..4fb19c4a 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceEventStreamTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceEventStreamTests.swift @@ -1,86 +1,90 @@ -import Testing import Foundation @testable import MC1Services @testable import MeshCore +import Testing @Suite("SettingsService event stream") struct SettingsServiceEventStreamTests { + private actor EventCollector { + var events: [SettingsEvent] = [] + func record(_ event: SettingsEvent) { + events.append(event) + } - private actor EventCollector { - var events: [SettingsEvent] = [] - func record(_ event: SettingsEvent) { events.append(event) } - var count: Int { events.count } + var count: Int { + events.count } + } - @Test("replacing the event subscriber keeps the replacement connected") - @MainActor - func replacedSubscriberKeepsReplacementConnected() async throws { - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) - let startTask = Task { try await session.start() } - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await startTask.value - defer { Task { await session.stop() } } + @Test + @MainActor + func `replacing the event subscriber keeps the replacement connected`() async throws { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + let startTask = Task { try await session.start() } + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + defer { Task { await session.stop() } } - let service = SettingsService(session: session) + let service = SettingsService(session: session) - let streamA = await service.events() - let streamB = await service.events() + let streamA = await service.events() + let streamB = await service.events() - // The second subscription finished A; drain it so its termination - // callback has run (it must not disconnect B). - for await _ in streamA {} + // The second subscription finished A; drain it so its termination + // callback has run (it must not disconnect B). + for await _ in streamA {} - let collector = EventCollector() - let collectTask = Task { - for await event in streamB { - await collector.record(event) - } - } + let collector = EventCollector() + let collectTask = Task { + for await event in streamB { + await collector.record(event) + } + } - let refreshTask = Task { try await service.refreshDeviceInfo() } - try await waitUntil("service should re-request self info") { - await transport.sentData.count == 2 - } - await transport.simulateReceive(makeSelfInfoPacket()) - try await refreshTask.value + let refreshTask = Task { try await service.refreshDeviceInfo() } + try await waitUntil("service should re-request self info") { + await transport.sentData.count == 2 + } + await transport.simulateReceive(makeSelfInfoPacket()) + try await refreshTask.value - try await waitUntil("the replacement subscriber must receive the deviceUpdated event") { - await collector.count > 0 - } - collectTask.cancel() + try await waitUntil("the replacement subscriber must receive the deviceUpdated event") { + await collector.count > 0 } + collectTask.cancel() + } - private func makeSelfInfoPacket() -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(int32Bytes(0)) - payload.append(int32Bytes(0)) - payload.append(0) - payload.append(0) - payload.append(0) - payload.append(uint32Bytes(915_000)) - payload.append(uint32Bytes(125_000)) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) + private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(0)) + payload.append(int32Bytes(0)) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet - } + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } - private func int32Bytes(_ value: Double) -> Data { - withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } - } + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } - private func uint32Bytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.littleEndian) { Data($0) } - } + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceLocationTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceLocationTests.swift index 425c02b3..681a2a01 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceLocationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/SettingsServiceLocationTests.swift @@ -1,221 +1,220 @@ import Foundation -import Testing -@testable import MeshCore @testable import MC1Services +@testable import MeshCore +import Testing @Suite("SettingsService location and device GPS") struct SettingsServiceLocationTests { + @Test + @MainActor + func `getDeviceGPSState returns unsupported when gps custom var is missing`() async throws { + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } + + let stateTask = Task { try await service.getDeviceGPSState() } + try await waitUntil("service should request custom vars") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive(makeCustomVarsPacket()) + let state = try await stateTask.value + + #expect(state == DeviceGPSState(isSupported: false, isEnabled: false)) + } + + @Test + @MainActor + func `getDeviceGPSState returns enabled when gps custom var is on`() async throws { + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } + + let stateTask = Task { try await service.getDeviceGPSState() } + try await waitUntil("service should request custom vars") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive(makeCustomVarsPacket("gps:1,foo:bar")) + let state = try await stateTask.value + + #expect(state == DeviceGPSState(isSupported: true, isEnabled: true)) + } + + @Test + @MainActor + func `setDeviceGPSEnabledVerified writes, verifies, and refreshes device info`() async throws { + let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) + defer { Task { await session.stop() } } + + let stateTask = Task { try await service.setDeviceGPSEnabledVerified(false) } + + try await waitUntil("service should send device GPS update") { + await transport.sentData.count == 2 + } + let sentAfterWrite = await transport.sentData + #expect(sentAfterWrite[1] == PacketBuilder.setCustomVar(key: "gps", value: "0")) + await transport.simulateOK() + + try await waitUntil("service should verify device GPS state") { + await transport.sentData.count == 3 + } + let sentAfterVerify = await transport.sentData + #expect(sentAfterVerify[2] == PacketBuilder.getCustomVars()) + await transport.simulateReceive(makeCustomVarsPacket("gps:0")) + + try await waitUntil("service should refresh self info") { + await transport.sentData.count == 4 + } + let sentAfterRefresh = await transport.sentData + #expect(sentAfterRefresh[3] == PacketBuilder.appStart(clientId: SessionConfiguration.default.clientIdentifier)) + await transport.simulateReceive(makeSelfInfoPacket(latitude: 47.491031, longitude: -120.339279)) + + let state = try await stateTask.value + #expect(state == DeviceGPSState(isSupported: true, isEnabled: false)) + } + + @Test + @MainActor + func `setManualLocationVerified disables device GPS before writing location`() async throws { + let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) + defer { Task { await session.stop() } } + + let saveTask = Task { + try await service.setManualLocationVerified(latitude: 0, longitude: 0) + } + + try await waitUntil("manual save should query device GPS state") { + await transport.sentData.count == 2 + } + await transport.simulateReceive(makeCustomVarsPacket("gps:1")) + + try await waitUntil("manual save should disable device GPS") { + await transport.sentData.count == 3 + } + let sentAfterDisable = await transport.sentData + #expect(sentAfterDisable[2] == PacketBuilder.setCustomVar(key: "gps", value: "0")) + await transport.simulateOK() - @Test("getDeviceGPSState returns unsupported when gps custom var is missing") - @MainActor - func getDeviceGPSState_unsupported() async throws { - let (service, session, transport) = try await makeService() - defer { Task { await session.stop() } } - - let stateTask = Task { try await service.getDeviceGPSState() } - try await waitUntil("service should request custom vars") { - await transport.sentData.count == 2 - } - - await transport.simulateReceive(makeCustomVarsPacket()) - let state = try await stateTask.value - - #expect(state == DeviceGPSState(isSupported: false, isEnabled: false)) - } - - @Test("getDeviceGPSState returns enabled when gps custom var is on") - @MainActor - func getDeviceGPSState_enabled() async throws { - let (service, session, transport) = try await makeService() - defer { Task { await session.stop() } } - - let stateTask = Task { try await service.getDeviceGPSState() } - try await waitUntil("service should request custom vars") { - await transport.sentData.count == 2 - } - - await transport.simulateReceive(makeCustomVarsPacket("gps:1,foo:bar")) - let state = try await stateTask.value - - #expect(state == DeviceGPSState(isSupported: true, isEnabled: true)) - } - - @Test("setDeviceGPSEnabledVerified writes, verifies, and refreshes device info") - @MainActor - func setDeviceGPSEnabledVerified_success() async throws { - let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) - defer { Task { await session.stop() } } - - let stateTask = Task { try await service.setDeviceGPSEnabledVerified(false) } - - try await waitUntil("service should send device GPS update") { - await transport.sentData.count == 2 - } - let sentAfterWrite = await transport.sentData - #expect(sentAfterWrite[1] == PacketBuilder.setCustomVar(key: "gps", value: "0")) - await transport.simulateOK() - - try await waitUntil("service should verify device GPS state") { - await transport.sentData.count == 3 - } - let sentAfterVerify = await transport.sentData - #expect(sentAfterVerify[2] == PacketBuilder.getCustomVars()) - await transport.simulateReceive(makeCustomVarsPacket("gps:0")) - - try await waitUntil("service should refresh self info") { - await transport.sentData.count == 4 - } - let sentAfterRefresh = await transport.sentData - #expect(sentAfterRefresh[3] == PacketBuilder.appStart(clientId: SessionConfiguration.default.clientIdentifier)) - await transport.simulateReceive(makeSelfInfoPacket(latitude: 47.491031, longitude: -120.339279)) - - let state = try await stateTask.value - #expect(state == DeviceGPSState(isSupported: true, isEnabled: false)) - } - - @Test("setManualLocationVerified disables device GPS before writing location") - @MainActor - func setManualLocationVerified_turnsOffGPSFirst() async throws { - let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) - defer { Task { await session.stop() } } - - let saveTask = Task { - try await service.setManualLocationVerified(latitude: 0, longitude: 0) - } - - try await waitUntil("manual save should query device GPS state") { - await transport.sentData.count == 2 - } - await transport.simulateReceive(makeCustomVarsPacket("gps:1")) - - try await waitUntil("manual save should disable device GPS") { - await transport.sentData.count == 3 - } - let sentAfterDisable = await transport.sentData - #expect(sentAfterDisable[2] == PacketBuilder.setCustomVar(key: "gps", value: "0")) - await transport.simulateOK() - - try await waitUntil("manual save should verify device GPS off") { - await transport.sentData.count == 4 - } - await transport.simulateReceive(makeCustomVarsPacket("gps:0")) - - try await waitUntil("manual save should refresh after device GPS change") { - await transport.sentData.count == 5 - } - await transport.simulateReceive(makeSelfInfoPacket(latitude: 47.491031, longitude: -120.339279)) - - try await waitUntil("manual save should send location update") { - await transport.sentData.count == 6 - } - let sentAfterLocationWrite = await transport.sentData - #expect(sentAfterLocationWrite[5] == PacketBuilder.setCoordinates(latitude: 0, longitude: 0)) - await transport.simulateOK() - - try await waitUntil("manual save should verify location through self info") { - await transport.sentData.count == 7 - } - await transport.simulateReceive(makeSelfInfoPacket(latitude: 0, longitude: 0)) - - let selfInfo = try await saveTask.value - #expect(selfInfo.latitude == 0) - #expect(selfInfo.longitude == 0) - } - - @Test("setManualLocationVerified aborts when device GPS stays on") - @MainActor - func setManualLocationVerified_abortsWhenGPSDisableDoesNotStick() async throws { - let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) - defer { Task { await session.stop() } } - - let saveTask = Task { - try await service.setManualLocationVerified(latitude: 0, longitude: 0) - } - - try await waitUntil("manual save should query device GPS state") { - await transport.sentData.count == 2 - } - await transport.simulateReceive(makeCustomVarsPacket("gps:1")) - - try await waitUntil("manual save should disable device GPS") { - await transport.sentData.count == 3 - } - await transport.simulateOK() - - try await waitUntil("manual save should verify device GPS off") { - await transport.sentData.count == 4 - } - await transport.simulateReceive(makeCustomVarsPacket("gps:1")) - - await #expect(throws: SettingsServiceError.self) { - _ = try await saveTask.value - } - - let sent = await transport.sentData - #expect(sent.count == 4) - } - - private func makeService( - initialLatitude: Double = 0, - initialLongitude: Double = 0 - ) async throws -> (SettingsService, MeshCoreSession, MockTransport) { - let transport = MockTransport() - let session = MeshCoreSession(transport: transport) - - let startTask = Task { - try await session.start() - } - - try await waitUntil("session should send app start") { - await transport.sentData.count == 1 - } - - await transport.simulateReceive( - makeSelfInfoPacket(latitude: initialLatitude, longitude: initialLongitude) - ) - try await startTask.value - - return (SettingsService(session: session), session, transport) - } - - private func makeCustomVarsPacket(_ raw: String = "") -> Data { - var packet = Data([ResponseCode.customVars.rawValue]) - packet.append(contentsOf: raw.utf8) - return packet - } - - private func makeSelfInfoPacket( - latitude: Double, - longitude: Double, - advertisementLocationPolicy: UInt8 = 0 - ) -> Data { - var payload = Data() - payload.append(1) - payload.append(22) - payload.append(22) - payload.append(Data(repeating: 0x01, count: 32)) - payload.append(int32Bytes(latitude * 1_000_000)) - payload.append(int32Bytes(longitude * 1_000_000)) - payload.append(0) - payload.append(advertisementLocationPolicy) - payload.append(0) - payload.append(0) - payload.append(uint32Bytes(915_000)) - payload.append(uint32Bytes(125_000)) - payload.append(7) - payload.append(5) - payload.append(contentsOf: "Test".utf8) - - var packet = Data([ResponseCode.selfInfo.rawValue]) - packet.append(payload) - return packet - } - - private func int32Bytes(_ value: Double) -> Data { - withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } - } - - private func uint32Bytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.littleEndian) { Data($0) } + try await waitUntil("manual save should verify device GPS off") { + await transport.sentData.count == 4 } + await transport.simulateReceive(makeCustomVarsPacket("gps:0")) + + try await waitUntil("manual save should refresh after device GPS change") { + await transport.sentData.count == 5 + } + await transport.simulateReceive(makeSelfInfoPacket(latitude: 47.491031, longitude: -120.339279)) + + try await waitUntil("manual save should send location update") { + await transport.sentData.count == 6 + } + let sentAfterLocationWrite = await transport.sentData + #expect(sentAfterLocationWrite[5] == PacketBuilder.setCoordinates(latitude: 0, longitude: 0)) + await transport.simulateOK() + + try await waitUntil("manual save should verify location through self info") { + await transport.sentData.count == 7 + } + await transport.simulateReceive(makeSelfInfoPacket(latitude: 0, longitude: 0)) + + let selfInfo = try await saveTask.value + #expect(selfInfo.latitude == 0) + #expect(selfInfo.longitude == 0) + } + + @Test + @MainActor + func `setManualLocationVerified aborts when device GPS stays on`() async throws { + let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) + defer { Task { await session.stop() } } + + let saveTask = Task { + try await service.setManualLocationVerified(latitude: 0, longitude: 0) + } + + try await waitUntil("manual save should query device GPS state") { + await transport.sentData.count == 2 + } + await transport.simulateReceive(makeCustomVarsPacket("gps:1")) + + try await waitUntil("manual save should disable device GPS") { + await transport.sentData.count == 3 + } + await transport.simulateOK() + + try await waitUntil("manual save should verify device GPS off") { + await transport.sentData.count == 4 + } + await transport.simulateReceive(makeCustomVarsPacket("gps:1")) + + await #expect(throws: SettingsServiceError.self) { + _ = try await saveTask.value + } + + let sent = await transport.sentData + #expect(sent.count == 4) + } + + private func makeService( + initialLatitude: Double = 0, + initialLongitude: Double = 0 + ) async throws -> (SettingsService, MeshCoreSession, MockTransport) { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + + let startTask = Task { + try await session.start() + } + + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + + await transport.simulateReceive( + makeSelfInfoPacket(latitude: initialLatitude, longitude: initialLongitude) + ) + try await startTask.value + + return (SettingsService(session: session), session, transport) + } + + private func makeCustomVarsPacket(_ raw: String = "") -> Data { + var packet = Data([ResponseCode.customVars.rawValue]) + packet.append(contentsOf: raw.utf8) + return packet + } + + private func makeSelfInfoPacket( + latitude: Double, + longitude: Double, + advertisementLocationPolicy: UInt8 = 0 + ) -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(latitude * 1_000_000)) + payload.append(int32Bytes(longitude * 1_000_000)) + payload.append(0) + payload.append(advertisementLocationPolicy) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } + + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } + + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift index 0ded0691..b7a6ba5b 100644 --- a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift @@ -1,90 +1,89 @@ import Foundation -import Testing @testable import MC1Services +import Testing /// `saveMessage` does not persist the link-preview or `reactionSummary` columns, and /// the wire `ChannelInfo` carries no notification/favorite state. These verify the /// seed's dedicated mutators (and the DTO-based `saveChannel`) write those through. @MainActor struct SimulatorSeedTests { + private func seededStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + try await SimulatorConnectionMode().seedDataStore(store) + return store + } - private func seededStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - try await SimulatorConnectionMode().seedDataStore(store) - return store - } - - private let radioID = MockDataProvider.simulatorDeviceID + private let radioID = MockDataProvider.simulatorDeviceID - @Test - func linkPreviewColumnsLand() async throws { - let store = try await seededStore() - let message = try await store.fetchMessage(id: MockDataProvider.aliceLinkPreviewMessageID) - let unwrapped = try #require(message) - #expect(unwrapped.linkPreviewTitle == "Skyline Ridge Trail Guide") - #expect(unwrapped.linkPreviewFetched == true) - let imageData = try #require(unwrapped.linkPreviewImageData) - #expect(!imageData.isEmpty) - } + @Test + func `link preview columns land`() async throws { + let store = try await seededStore() + let message = try await store.fetchMessage(id: MockDataProvider.aliceLinkPreviewMessageID) + let unwrapped = try #require(message) + #expect(unwrapped.linkPreviewTitle == "Skyline Ridge Trail Guide") + #expect(unwrapped.linkPreviewFetched == true) + let imageData = try #require(unwrapped.linkPreviewImageData) + #expect(!imageData.isEmpty) + } - @Test - func reactionSummaryAndRowsLand() async throws { - let store = try await seededStore() + @Test + func `reaction summary and rows land`() async throws { + let store = try await seededStore() - let dmMessage = try #require(try await store.fetchMessage(id: MockDataProvider.aliceReactedMessageID)) - #expect(dmMessage.reactionSummary == "👍:2,❤️:1") - let dmReactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) - #expect(dmReactions.count == 3) + let dmMessage = try #require(try await store.fetchMessage(id: MockDataProvider.aliceReactedMessageID)) + #expect(dmMessage.reactionSummary == "👍:2,❤️:1") + let dmReactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) + #expect(dmReactions.count == 3) - let channelMessage = try #require(try await store.fetchMessage(id: MockDataProvider.bayAreaReactedMessageID)) - #expect(channelMessage.reactionSummary == "🎉:2") - let channelReactions = try await store.fetchReactions(for: MockDataProvider.bayAreaReactedMessageID) - #expect(channelReactions.count == 2) - } + let channelMessage = try #require(try await store.fetchMessage(id: MockDataProvider.bayAreaReactedMessageID)) + #expect(channelMessage.reactionSummary == "🎉:2") + let channelReactions = try await store.fetchReactions(for: MockDataProvider.bayAreaReactedMessageID) + #expect(channelReactions.count == 2) + } - @Test - func channelNotificationStateLands() async throws { - let store = try await seededStore() - let channels = try await store.fetchChannels(radioID: radioID) + @Test + func `channel notification state lands`() async throws { + let store = try await seededStore() + let channels = try await store.fetchChannels(radioID: radioID) - let muted = try #require(channels.first { $0.index == MockDataProvider.trailCrewChannelIndex }) - #expect(muted.notificationLevel == .muted) + let muted = try #require(channels.first { $0.index == MockDataProvider.trailCrewChannelIndex }) + #expect(muted.notificationLevel == .muted) - let favorite = try #require(channels.first { $0.index == MockDataProvider.bayAreaChannelIndex }) - #expect(favorite.isFavorite) - } + let favorite = try #require(channels.first { $0.index == MockDataProvider.bayAreaChannelIndex }) + #expect(favorite.isFavorite) + } - @Test - func heardRepeatsLand() async throws { - let store = try await seededStore() - let repeats = try await store.fetchMessageRepeats(messageID: MockDataProvider.frankRepeatMessageID) - #expect(repeats.count == 3) - } + @Test + func `heard repeats land`() async throws { + let store = try await seededStore() + let repeats = try await store.fetchMessageRepeats(messageID: MockDataProvider.frankRepeatMessageID) + #expect(repeats.count == 3) + } - @Test - func floodRouteFieldsRoundTripThroughSaveMessage() async throws { - let store = try await seededStore() - let floodMessageID = UUID(uuidString: "60000000-0000-0000-0000-000000000004")! - let message = try #require(try await store.fetchMessage(id: floodMessageID)) - #expect(message.routeType == .tcFlood) - #expect(message.regionScope == "US915") - } + @Test + func `flood route fields round trip through save message`() async throws { + let store = try await seededStore() + let floodMessageID = try #require(UUID(uuidString: "60000000-0000-0000-0000-000000000004")) + let message = try #require(try await store.fetchMessage(id: floodMessageID)) + #expect(message.routeType == .tcFlood) + #expect(message.regionScope == "US915") + } - @Test - func reseedingIsIdempotent() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let mode = SimulatorConnectionMode() - try await mode.seedDataStore(store) - try await mode.seedDataStore(store) + @Test + func `reseeding is idempotent`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let mode = SimulatorConnectionMode() + try await mode.seedDataStore(store) + try await mode.seedDataStore(store) - // Unique-id upsert means a second pass does not duplicate rows. - let channels = try await store.fetchChannels(radioID: radioID) - #expect(channels.count == 3) - let repeats = try await store.fetchMessageRepeats(messageID: MockDataProvider.frankRepeatMessageID) - #expect(repeats.count == 3) - let reactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) - #expect(reactions.count == 3) - } + // Unique-id upsert means a second pass does not duplicate rows. + let channels = try await store.fetchChannels(radioID: radioID) + #expect(channels.count == 3) + let repeats = try await store.fetchMessageRepeats(messageID: MockDataProvider.frankRepeatMessageID) + #expect(repeats.count == 3) + let reactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) + #expect(reactions.count == 3) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SortDateBackfillMigrationTests.swift b/MC1Services/Tests/MC1ServicesTests/SortDateBackfillMigrationTests.swift index 91b4418d..d42fcc6f 100644 --- a/MC1Services/Tests/MC1ServicesTests/SortDateBackfillMigrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SortDateBackfillMigrationTests.swift @@ -1,78 +1,77 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("Message sortDate backfill migration", .serialized) struct SortDateBackfillMigrationTests { + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } + @Test + func `Pre-existing rows get sortDate backfilled to createdAt`() async throws { + let store = try await createTestStore() + await store.resetSortDateBackfillMigrationFlag() + let radioID = UUID() - @Test("Pre-existing rows get sortDate backfilled to createdAt") - func backfillSetsSortDateToCreatedAt() async throws { - let store = try await createTestStore() - await store.resetSortDateBackfillMigrationFlag() - let radioID = UUID() + let firstID = UUID() + let firstCreatedAt = Date(timeIntervalSince1970: 1_704_067_200) + try await store.insertMessageWithSortDate( + id: firstID, + radioID: radioID, + text: "Hello", + createdAt: firstCreatedAt, + sortDate: .distantPast + ) - let firstID = UUID() - let firstCreatedAt = Date(timeIntervalSince1970: 1_704_067_200) - try await store.insertMessageWithSortDate( - id: firstID, - radioID: radioID, - text: "Hello", - createdAt: firstCreatedAt, - sortDate: .distantPast - ) + let secondID = UUID() + let secondCreatedAt = Date(timeIntervalSince1970: 1_704_070_800) + try await store.insertMessageWithSortDate( + id: secondID, + radioID: radioID, + text: "World", + createdAt: secondCreatedAt, + sortDate: .distantPast + ) - let secondID = UUID() - let secondCreatedAt = Date(timeIntervalSince1970: 1_704_070_800) - try await store.insertMessageWithSortDate( - id: secondID, - radioID: radioID, - text: "World", - createdAt: secondCreatedAt, - sortDate: .distantPast - ) + try await store.performSortDateBackfillMigration() - try await store.performSortDateBackfillMigration() - - let messages = try await store.fetchAllMessages() - #expect(messages.count == 2) - for message in messages { - #expect(message.sortDate == message.createdAt) - } + let messages = try await store.fetchAllMessages() + #expect(messages.count == 2) + for message in messages { + #expect(message.sortDate == message.createdAt) } + } - @Test("Migration is idempotent — second run is a no-op") - func migrationIsIdempotent() async throws { - let store = try await createTestStore() - await store.resetSortDateBackfillMigrationFlag() - let radioID = UUID() + @Test + func `Migration is idempotent — second run is a no-op`() async throws { + let store = try await createTestStore() + await store.resetSortDateBackfillMigrationFlag() + let radioID = UUID() - let messageID = UUID() - let createdAt = Date(timeIntervalSince1970: 1_704_067_200) - try await store.insertMessageWithSortDate( - id: messageID, - radioID: radioID, - text: "Hello", - createdAt: createdAt, - sortDate: .distantPast - ) + let messageID = UUID() + let createdAt = Date(timeIntervalSince1970: 1_704_067_200) + try await store.insertMessageWithSortDate( + id: messageID, + radioID: radioID, + text: "Hello", + createdAt: createdAt, + sortDate: .distantPast + ) - try await store.performSortDateBackfillMigration() + try await store.performSortDateBackfillMigration() - let backfilled = try await store.fetchMessage(id: messageID) - #expect(backfilled?.sortDate == createdAt) + let backfilled = try await store.fetchMessage(id: messageID) + #expect(backfilled?.sortDate == createdAt) - // Re-skew the row after the first run. A second call with the flag set must - // not touch it — otherwise the backfill would re-run every launch. - try await store.setMessageSortDate(id: messageID, sortDate: .distantPast) - try await store.performSortDateBackfillMigration() + // Re-skew the row after the first run. A second call with the flag set must + // not touch it — otherwise the backfill would re-run every launch. + try await store.setMessageSortDate(id: messageID, sortDate: .distantPast) + try await store.performSortDateBackfillMigration() - let afterSecondRun = try await store.fetchMessage(id: messageID) - #expect(afterSecondRun?.sortDate == .distantPast, "second run must be a no-op") - } + let afterSecondRun = try await store.fetchMessage(id: messageID) + #expect(afterSecondRun?.sortDate == .distantPast, "second run must be a no-op") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SortDateResetMigrationTests.swift b/MC1Services/Tests/MC1ServicesTests/SortDateResetMigrationTests.swift index 07d06c78..30b7ba93 100644 --- a/MC1Services/Tests/MC1ServicesTests/SortDateResetMigrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SortDateResetMigrationTests.swift @@ -1,64 +1,63 @@ import Foundation +@testable import MC1Services import SwiftData import Testing -@testable import MC1Services @Suite("Message sortDate reset migration", .serialized) struct SortDateResetMigrationTests { - - private func createTestStore() async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - @Test("Reset re-normalizes a send-time sortDate back to createdAt") - func resetUnburiesSendTimeSortDate() async throws { - let store = try await createTestStore() - await store.resetSortDateResetMigrationFlag() - let radioID = UUID() - - // A row buried by the interim send-time sort: createdAt is the drain time, - // but sortDate was written far in the past from the sender's clock. The - // original backfill already ran (flag set), so only this reset can fix it. - let messageID = UUID() - let createdAt = Date(timeIntervalSince1970: 1_704_067_200) - let buriedSortDate = Date(timeIntervalSince1970: 1_700_000_000) - try await store.insertMessageWithSortDate( - id: messageID, - radioID: radioID, - text: "Buried backlog", - createdAt: createdAt, - sortDate: buriedSortDate - ) - - try await store.performSortDateResetMigration() - - let migrated = try await store.fetchMessage(id: messageID) - #expect(migrated?.sortDate == createdAt) - } - - @Test("Reset is idempotent — second run is a no-op") - func resetIsIdempotent() async throws { - let store = try await createTestStore() - await store.resetSortDateResetMigrationFlag() - let radioID = UUID() - - let messageID = UUID() - let createdAt = Date(timeIntervalSince1970: 1_704_067_200) - try await store.insertMessageWithSortDate( - id: messageID, - radioID: radioID, - text: "Hello", - createdAt: createdAt, - sortDate: Date(timeIntervalSince1970: 1_700_000_000) - ) - - try await store.performSortDateResetMigration() - #expect(try await store.fetchMessage(id: messageID)?.sortDate == createdAt) - - // Re-skew after the first run; the flag must keep a second run from touching it. - try await store.setMessageSortDate(id: messageID, sortDate: .distantPast) - try await store.performSortDateResetMigration() - #expect(try await store.fetchMessage(id: messageID)?.sortDate == .distantPast, "second run must be a no-op") - } + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + @Test + func `Reset re-normalizes a send-time sortDate back to createdAt`() async throws { + let store = try await createTestStore() + await store.resetSortDateResetMigrationFlag() + let radioID = UUID() + + // A row buried by the interim send-time sort: createdAt is the drain time, + // but sortDate was written far in the past from the sender's clock. The + // original backfill already ran (flag set), so only this reset can fix it. + let messageID = UUID() + let createdAt = Date(timeIntervalSince1970: 1_704_067_200) + let buriedSortDate = Date(timeIntervalSince1970: 1_700_000_000) + try await store.insertMessageWithSortDate( + id: messageID, + radioID: radioID, + text: "Buried backlog", + createdAt: createdAt, + sortDate: buriedSortDate + ) + + try await store.performSortDateResetMigration() + + let migrated = try await store.fetchMessage(id: messageID) + #expect(migrated?.sortDate == createdAt) + } + + @Test + func `Reset is idempotent — second run is a no-op`() async throws { + let store = try await createTestStore() + await store.resetSortDateResetMigrationFlag() + let radioID = UUID() + + let messageID = UUID() + let createdAt = Date(timeIntervalSince1970: 1_704_067_200) + try await store.insertMessageWithSortDate( + id: messageID, + radioID: radioID, + text: "Hello", + createdAt: createdAt, + sortDate: Date(timeIntervalSince1970: 1_700_000_000) + ) + + try await store.performSortDateResetMigration() + #expect(try await store.fetchMessage(id: messageID)?.sortDate == createdAt) + + // Re-skew after the first run; the flag must keep a second run from touching it. + try await store.setMessageSortDate(id: messageID, sortDate: .distantPast) + try await store.performSortDateResetMigration() + #expect(try await store.fetchMessage(id: messageID)?.sortDate == .distantPast, "second run must be a no-op") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/StoreCatalogTests.swift b/MC1Services/Tests/MC1ServicesTests/StoreCatalogTests.swift index bcd0601e..fba6e701 100644 --- a/MC1Services/Tests/MC1ServicesTests/StoreCatalogTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/StoreCatalogTests.swift @@ -1,52 +1,50 @@ -import Testing @testable import MC1Services +import Testing @Suite("StoreCatalog") struct StoreCatalogTests { - - @Test("sellableProductIDs are the bundle plus the six tips — themes are not sold standalone") - func sellableProductIDsCount() { - #expect(StoreCatalog.sellableProductIDs.count == 7) - #expect(StoreCatalog.sellableProductIDs.contains(StoreCatalog.Theme.bundleAll)) - #expect(StoreCatalog.sellableProductIDs.isSuperset(of: StoreCatalog.Tip.all)) - #expect(StoreCatalog.sellableProductIDs.isDisjoint(with: StoreCatalog.Theme.bundledThemeIDs)) - } - - @Test("bundledThemeIDs are the nine themes the bundle unlocks, excluding the bundle itself") - func bundledThemeCount() { - #expect(StoreCatalog.Theme.bundledThemeIDs.count == 9) - #expect(!StoreCatalog.Theme.bundledThemeIDs.contains(StoreCatalog.Theme.bundleAll)) - } - - @Test("Tip.all has six entries, disjoint from the themes") - func tipCounts() { - #expect(StoreCatalog.Tip.all.count == 6) - #expect(StoreCatalog.Tip.all.isDisjoint(with: StoreCatalog.Theme.bundledThemeIDs)) - #expect(!StoreCatalog.Tip.all.contains(StoreCatalog.Theme.bundleAll)) - } - - @Test("every product ID uses the io.pocketmesh.app prefix") - func productIDPrefix() { - for id in StoreCatalog.sellableProductIDs.union(StoreCatalog.Theme.bundledThemeIDs) { - #expect(id.hasPrefix("io.pocketmesh.app.")) - } + @Test + func `sellableProductIDs are the bundle plus the six tips — themes are not sold standalone`() { + #expect(StoreCatalog.sellableProductIDs.count == 7) + #expect(StoreCatalog.sellableProductIDs.contains(StoreCatalog.Theme.bundleAll)) + #expect(StoreCatalog.sellableProductIDs.isSuperset(of: StoreCatalog.Tip.all)) + #expect(StoreCatalog.sellableProductIDs.isDisjoint(with: StoreCatalog.Theme.bundledThemeIDs)) + } + + @Test + func `bundledThemeIDs are the nine themes the bundle unlocks, excluding the bundle itself`() { + #expect(StoreCatalog.Theme.bundledThemeIDs.count == 9) + #expect(!StoreCatalog.Theme.bundledThemeIDs.contains(StoreCatalog.Theme.bundleAll)) + } + + @Test + func `Tip.all has six entries, disjoint from the themes`() { + #expect(StoreCatalog.Tip.all.count == 6) + #expect(StoreCatalog.Tip.all.isDisjoint(with: StoreCatalog.Theme.bundledThemeIDs)) + #expect(!StoreCatalog.Tip.all.contains(StoreCatalog.Theme.bundleAll)) + } + + @Test + func `every product ID uses the io.pocketmesh.app prefix`() { + for id in StoreCatalog.sellableProductIDs.union(StoreCatalog.Theme.bundledThemeIDs) { + #expect(id.hasPrefix("io.pocketmesh.app.")) } + } } @Suite("Store value types") struct StoreValueTypeTests { - - @Test("StoreLoadState is Equatable across its cases") - func loadStateEquatable() { - #expect(StoreLoadState.idle == .idle) - #expect(StoreLoadState.loading != .loaded) - #expect(StoreLoadState.failed != .idle) - } - - @Test("StorePurchaseOutcome distinguishes its three cases") - func purchaseOutcomeEquatable() { - #expect(StorePurchaseOutcome.purchased == .purchased) - #expect(StorePurchaseOutcome.pending != .purchased) - #expect(StorePurchaseOutcome.userCancelled != .pending) - } + @Test + func `StoreLoadState is Equatable across its cases`() { + #expect(StoreLoadState.idle == .idle) + #expect(StoreLoadState.loading != .loaded) + #expect(StoreLoadState.failed != .idle) + } + + @Test + func `StorePurchaseOutcome distinguishes its three cases`() { + #expect(StorePurchaseOutcome.purchased == .purchased) + #expect(StorePurchaseOutcome.pending != .purchased) + #expect(StorePurchaseOutcome.userCancelled != .pending) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/StoreServiceErrorTests.swift b/MC1Services/Tests/MC1ServicesTests/StoreServiceErrorTests.swift index 79656ffe..b130b8bc 100644 --- a/MC1Services/Tests/MC1ServicesTests/StoreServiceErrorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/StoreServiceErrorTests.swift @@ -1,59 +1,58 @@ -import Testing -import StoreKit import Foundation @testable import MC1Services +import StoreKit +import Testing @Suite("StoreServiceError mapping") struct StoreServiceErrorTests { - - @Test("every case has a non-empty English description") - func descriptions() { - let cases: [StoreServiceError] = [ - .productsNotLoaded, .productNotFound(productID: "x"), - .purchaseFailed(reason: "boom"), .verificationFailed, .notEntitled, - .networkUnavailable, .storefrontUnavailable, .unsupported - ] - for error in cases { - #expect(error.errorDescription?.isEmpty == false) - } - } - - @Test("purchaseFailed Equatable compares the reason string") - func purchaseFailedEquatable() { - #expect(StoreServiceError.purchaseFailed(reason: "a") == .purchaseFailed(reason: "a")) - #expect(StoreServiceError.purchaseFailed(reason: "a") != .purchaseFailed(reason: "b")) + @Test + func `every case has a non-empty English description`() { + let cases: [StoreServiceError] = [ + .productsNotLoaded, .productNotFound(productID: "x"), + .purchaseFailed(reason: "boom"), .verificationFailed, .notEntitled, + .networkUnavailable, .storefrontUnavailable, .unsupported + ] + for error in cases { + #expect(error.errorDescription?.isEmpty == false) } - - @Test("network error maps to networkUnavailable") - func mapNetwork() { - let mapped = StoreServiceError.from(.networkError(URLError(.notConnectedToInternet))) - #expect(mapped == .networkUnavailable) + } + + @Test + func `purchaseFailed Equatable compares the reason string`() { + #expect(StoreServiceError.purchaseFailed(reason: "a") == .purchaseFailed(reason: "a")) + #expect(StoreServiceError.purchaseFailed(reason: "a") != .purchaseFailed(reason: "b")) + } + + @Test + func `network error maps to networkUnavailable`() { + let mapped = StoreServiceError.from(.networkError(URLError(.notConnectedToInternet))) + #expect(mapped == .networkUnavailable) + } + + @Test + func `storefront / unsupported / notEntitled map correctly`() { + #expect(StoreServiceError.from(.notAvailableInStorefront) == .storefrontUnavailable) + if #available(iOS 18.4, macOS 15.4, *) { + #expect(StoreServiceError.from(.unsupported) == .unsupported) } - - @Test("storefront / unsupported / notEntitled map correctly") - func mapDirectCases() { - #expect(StoreServiceError.from(.notAvailableInStorefront) == .storefrontUnavailable) - if #available(iOS 18.4, macOS 15.4, *) { - #expect(StoreServiceError.from(.unsupported) == .unsupported) - } - #expect(StoreServiceError.from(.notEntitled) == .notEntitled) - } - - @Test("userCancelled maps to nil (handled as a non-error outcome)") - func mapUserCancelled() { - #expect(StoreServiceError.from(.userCancelled) == nil) - } - - @Test("unknown maps to a purchaseFailed with a fixed reason") - func mapUnknown() { - #expect(StoreServiceError.from(.unknown) == .purchaseFailed(reason: "Unknown StoreKit error")) - } - - @Test("systemError maps to purchaseFailed") - func mapSystemError() { - let mapped = StoreServiceError.from(.systemError(URLError(.unknown))) - if case .purchaseFailed = mapped { } else { - Issue.record("expected .purchaseFailed, got \(String(describing: mapped))") - } + #expect(StoreServiceError.from(.notEntitled) == .notEntitled) + } + + @Test + func `userCancelled maps to nil (handled as a non-error outcome)`() { + #expect(StoreServiceError.from(.userCancelled) == nil) + } + + @Test + func `unknown maps to a purchaseFailed with a fixed reason`() { + #expect(StoreServiceError.from(.unknown) == .purchaseFailed(reason: "Unknown StoreKit error")) + } + + @Test + func `systemError maps to purchaseFailed`() { + let mapped = StoreServiceError.from(.systemError(URLError(.unknown))) + if case .purchaseFailed = mapped { } else { + Issue.record("expected .purchaseFailed, got \(String(describing: mapped))") } + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorChannelSkipTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorChannelSkipTests.swift index 83e4b6f3..feeebe4a 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorChannelSkipTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorChannelSkipTests.swift @@ -1,411 +1,409 @@ // SyncCoordinatorChannelSkipTests.swift -import Testing import Foundation +@testable import MC1Services import MeshCore import MeshCoreTestSupport -@testable import MC1Services +import Testing @Suite("SyncCoordinator Channel Skip Tests") struct SyncCoordinatorChannelSkipTests { - - private func createTestDataStore( - radioID: UUID, - maxChannels: UInt8 = 8, - lastContactSync: UInt32 = 0 - ) async throws -> PersistenceStore { - try await PersistenceStore.createTestDataStore( - radioID: radioID, - maxChannels: maxChannels, - lastContactSync: lastContactSync - ) - } - - // MARK: - Channel Skip Logic - - @Test("Channels skipped when lastCleanChannelSync is recent and skip window > 0") - @MainActor - func channelsSkippedWhenRecentCleanSync() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.isEmpty, "Channel sync should be skipped when clean sync completed recently") - } - - @Test("Channels sync when lastCleanChannelSync is nil") - @MainActor - func channelsSyncWhenNoLastCleanSync() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30)) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Channel sync should run when lastCleanChannelSync is nil") + private func createTestDataStore( + radioID: UUID, + maxChannels: UInt8 = 8, + lastContactSync: UInt32 = 0 + ) async throws -> PersistenceStore { + try await PersistenceStore.createTestDataStore( + radioID: radioID, + maxChannels: maxChannels, + lastContactSync: lastContactSync + ) + } + + // MARK: - Channel Skip Logic + + @Test + @MainActor + func `Channels skipped when lastCleanChannelSync is recent and skip window > 0`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.isEmpty, "Channel sync should be skipped when clean sync completed recently") + } + + @Test + @MainActor + func `Channels sync when lastCleanChannelSync is nil`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30)) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Channel sync should run when lastCleanChannelSync is nil") + } + + @Test + @MainActor + func `Channels skipped when last attempted channel sync is recent`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig( + channelSyncSkipWindow: .seconds(30), + lastAttemptedChannelSync: Date() + ) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.isEmpty, "Channel sync should be skipped after a recent partial attempt") + } + + @Test + @MainActor + func `Channels sync when lastCleanChannelSync is expired (outside window)`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let expiredDate = Date().addingTimeInterval(-60) // 60s ago, outside 30s window + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: expiredDate) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Channel sync should run when lastCleanChannelSync is expired") + } + + @Test + @MainActor + func `forceFullSync bypasses channel skip`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + forceFullSync: true, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Channel sync should run when forceFullSync is true") + } + + @Test + @MainActor + func `Zero skip window disables skip`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(lastCleanChannelSync: Date()) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Channel sync should run when skip window is zero") + } + + // MARK: - Clean Channel Callback + + @Test + @MainActor + func `Callback fires on clean channel phase (zero errors)`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Channel sync returns success (no errors) + await mockChannelService.setStubbedSyncChannelsResult(.success( + ChannelSyncResult(channelsSynced: 8, errors: []) + )) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { radioID in + #expect(radioID == testDeviceID) + callbackTracker.markCalled() } - @Test("Channels skipped when last attempted channel sync is recent") - @MainActor - func channelsSkippedWhenRecentAttemptedSync() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig( - channelSyncSkipWindow: .seconds(30), - lastAttemptedChannelSync: Date() - ) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.isEmpty, "Channel sync should be skipped after a recent partial attempt") + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(callbackTracker.wasCalled, "onCleanChannelSync should fire when channel phase is clean") + } + + @Test + @MainActor + func `Callback fires when initial sync fails but retry recovers`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Initial sync has errors, but retry succeeds + let errors = [ChannelSyncError(index: 2, errorType: .timeout, description: "timeout")] + await mockChannelService.setStubbedSyncChannelsResult(.success( + ChannelSyncResult(channelsSynced: 7, errors: errors) + )) + await mockChannelService.setStubbedRetryResult(.success( + ChannelSyncResult(channelsSynced: 1, errors: []) + )) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { _ in + callbackTracker.markCalled() } - @Test("Channels sync when lastCleanChannelSync is expired (outside window)") - @MainActor - func channelsSyncWhenExpired() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let expiredDate = Date().addingTimeInterval(-60) // 60s ago, outside 30s window - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: expiredDate) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Channel sync should run when lastCleanChannelSync is expired") + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(callbackTracker.wasCalled, "onCleanChannelSync should fire when retry recovers all errors") + } + + @Test + @MainActor + func `Callback does not fire when channel sync has errors after retries`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Initial sync has errors, retry also has errors + let errors = [ChannelSyncError(index: 2, errorType: .timeout, description: "timeout")] + await mockChannelService.setStubbedSyncChannelsResult(.success( + ChannelSyncResult(channelsSynced: 7, errors: errors) + )) + let retryErrors = [ChannelSyncError(index: 2, errorType: .timeout, description: "still failing")] + await mockChannelService.setStubbedRetryResult(.success( + ChannelSyncResult(channelsSynced: 0, errors: retryErrors) + )) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { _ in + callbackTracker.markCalled() } - @Test("forceFullSync bypasses channel skip") - @MainActor - func forceFullSyncBypassesSkip() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - forceFullSync: true, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Channel sync should run when forceFullSync is true") + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when errors remain after retry") + } + + @Test + @MainActor + func `Callback does not fire with mixed retryable and non-retryable errors even when retry succeeds`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Initial sync: one non-retryable deviceError + one retryable timeout + let errors = [ + ChannelSyncError(index: 5, errorType: .deviceError(code: 3), description: "device error"), + ChannelSyncError(index: 10, errorType: .timeout, description: "timeout"), + ] + await mockChannelService.setStubbedSyncChannelsResult(.success( + ChannelSyncResult(channelsSynced: 6, errors: errors) + )) + // Retry succeeds for the retryable timeout (index 10), but deviceError (index 5) was never retried + await mockChannelService.setStubbedRetryResult(.success( + ChannelSyncResult(channelsSynced: 1, errors: []) + )) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { _ in + callbackTracker.markCalled() } - @Test("Zero skip window disables skip") - @MainActor - func zeroSkipWindowDisablesSkip() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(lastCleanChannelSync: Date()) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Channel sync should run when skip window is zero") + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(!callbackTracker.wasCalled, "onCleanChannelSync must not fire when non-retryable errors remain unresolved") + } + + @Test + @MainActor + func `Callback does not fire when channels are skipped`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { _ in + callbackTracker.markCalled() } - // MARK: - Clean Channel Callback - - @Test("Callback fires on clean channel phase (zero errors)") - @MainActor - func callbackFiresOnCleanPhase() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Channel sync returns success (no errors) - await mockChannelService.setStubbedSyncChannelsResult(.success( - ChannelSyncResult(channelsSynced: 8, errors: []) - )) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { radioID in - #expect(radioID == testDeviceID) - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(callbackTracker.wasCalled, "onCleanChannelSync should fire when channel phase is clean") - } - - @Test("Callback fires when initial sync fails but retry recovers") - @MainActor - func callbackFiresWhenRetryRecovers() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Initial sync has errors, but retry succeeds - let errors = [ChannelSyncError(index: 2, errorType: .timeout, description: "timeout")] - await mockChannelService.setStubbedSyncChannelsResult(.success( - ChannelSyncResult(channelsSynced: 7, errors: errors) - )) - await mockChannelService.setStubbedRetryResult(.success( - ChannelSyncResult(channelsSynced: 1, errors: []) - )) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { _ in - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(callbackTracker.wasCalled, "onCleanChannelSync should fire when retry recovers all errors") - } - - @Test("Callback does not fire when channel sync has errors after retries") - @MainActor - func callbackDoesNotFireWithErrors() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Initial sync has errors, retry also has errors - let errors = [ChannelSyncError(index: 2, errorType: .timeout, description: "timeout")] - await mockChannelService.setStubbedSyncChannelsResult(.success( - ChannelSyncResult(channelsSynced: 7, errors: errors) - )) - let retryErrors = [ChannelSyncError(index: 2, errorType: .timeout, description: "still failing")] - await mockChannelService.setStubbedRetryResult(.success( - ChannelSyncResult(channelsSynced: 0, errors: retryErrors) - )) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { _ in - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when errors remain after retry") - } - - @Test("Callback does not fire with mixed retryable and non-retryable errors even when retry succeeds") - @MainActor - func callbackDoesNotFireWithMixedErrors() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Initial sync: one non-retryable deviceError + one retryable timeout - let errors = [ - ChannelSyncError(index: 5, errorType: .deviceError(code: 3), description: "device error"), - ChannelSyncError(index: 10, errorType: .timeout, description: "timeout"), - ] - await mockChannelService.setStubbedSyncChannelsResult(.success( - ChannelSyncResult(channelsSynced: 6, errors: errors) - )) - // Retry succeeds for the retryable timeout (index 10), but deviceError (index 5) was never retried - await mockChannelService.setStubbedRetryResult(.success( - ChannelSyncResult(channelsSynced: 1, errors: []) - )) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { _ in - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(!callbackTracker.wasCalled, "onCleanChannelSync must not fire when non-retryable errors remain unresolved") - } - - @Test("Callback does not fire when channels are skipped") - @MainActor - func callbackDoesNotFireWhenSkipped() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { _ in - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) - ) - - #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when channels are skipped") - } - - @Test("Callback does not fire when initial sync is clean but channels skipped in background") - @MainActor - func callbackDoesNotFireInBackground() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let mockAppStateProvider = MockAppStateProvider(isInForeground: false) - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let callbackTracker = CallTracker() - await coordinator.setCleanChannelSyncCallback { _ in - callbackTracker.markCalled() - } - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: mockAppStateProvider - ) - - #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when channels are skipped in background") - } - - // MARK: - Post-sync diagnostics still run when channels skipped - - @Test("Post-sync diagnostics still run when channels are skipped") - @MainActor - func diagnosticsRunWhenChannelsSkipped() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Skip channels (recent clean sync) -- performFullSync should still complete - // because logPostSyncChannelDiagnostics and refreshRxLogChannels read from the - // database (not the mock), so they execute regardless of whether channels were skipped. - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.isEmpty, "Channel sync should be skipped") - - // Sync should complete successfully (state == .synced) even with skipped channels - #expect(coordinator.state == .synced, "Sync should complete successfully when channels are skipped") + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) + ) + + #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when channels are skipped") + } + + @Test + @MainActor + func `Callback does not fire when initial sync is clean but channels skipped in background`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let mockAppStateProvider = MockAppStateProvider(isInForeground: false) + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let callbackTracker = CallTracker() + await coordinator.setCleanChannelSyncCallback { _ in + callbackTracker.markCalled() } + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: mockAppStateProvider + ) + + #expect(!callbackTracker.wasCalled, "onCleanChannelSync should not fire when channels are skipped in background") + } + + // MARK: - Post-sync diagnostics still run when channels skipped + + @Test + @MainActor + func `Post-sync diagnostics still run when channels are skipped`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Skip channels (recent clean sync) -- performFullSync should still complete + // because logPostSyncChannelDiagnostics and refreshRxLogChannels read from the + // database (not the mock), so they execute regardless of whether channels were skipped. + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + channelSyncConfig: ChannelSyncConfig(channelSyncSkipWindow: .seconds(30), lastCleanChannelSync: Date()) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.isEmpty, "Channel sync should be skipped") + + // Sync should complete successfully (state == .synced) even with skipped channels + #expect(coordinator.state == .synced, "Sync should complete successfully when channels are skipped") + } } // MARK: - Mock Helper Extensions extension MockChannelService { - func setStubbedSyncChannelsResult(_ result: Result) { - stubbedSyncChannelsResult = result - } + func setStubbedSyncChannelsResult(_ result: Result) { + stubbedSyncChannelsResult = result + } - func setStubbedRetryResult(_ result: Result) { - stubbedRetryResult = result - } + func setStubbedRetryResult(_ result: Result) { + stubbedRetryResult = result + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorDataEventTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorDataEventTests.swift index 6928f0fb..ab9443a4 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorDataEventTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorDataEventTests.swift @@ -1,66 +1,65 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("SyncCoordinator data events") struct SyncCoordinatorDataEventTests { + @Test + @MainActor + func `two subscribers both observe contactsChanged after notifyContactsChanged`() async { + let coordinator = SyncCoordinator() + let streamA = coordinator.dataEvents() + let streamB = coordinator.dataEvents() - @Test("two subscribers both observe contactsChanged after notifyContactsChanged") - @MainActor - func twoSubscribersObserveContactsChanged() async { - let coordinator = SyncCoordinator() - let streamA = coordinator.dataEvents() - let streamB = coordinator.dataEvents() + coordinator.notifyContactsChanged() + coordinator.notifyConversationsChanged() + coordinator.finishDataEvents() - coordinator.notifyContactsChanged() - coordinator.notifyConversationsChanged() - coordinator.finishDataEvents() - - var eventsA: [SyncDataEvent] = [] - for await event in streamA { - eventsA.append(event) - } - var eventsB: [SyncDataEvent] = [] - for await event in streamB { - eventsB.append(event) - } + var eventsA: [SyncDataEvent] = [] + for await event in streamA { + eventsA.append(event) + } + var eventsB: [SyncDataEvent] = [] + for await event in streamB { + eventsB.append(event) + } - #expect(eventsA.count == 2) - #expect(eventsB.count == 2) - for events in [eventsA, eventsB] { - guard events.count == 2 else { continue } - guard case .contactsChanged = events[0] else { - Issue.record("Expected first event to be contactsChanged, got \(events[0])") - continue - } - guard case .conversationsChanged = events[1] else { - Issue.record("Expected second event to be conversationsChanged, got \(events[1])") - continue - } - } - #expect(coordinator.contactsVersion == 1) - #expect(coordinator.conversationsVersion == 1) + #expect(eventsA.count == 2) + #expect(eventsB.count == 2) + for events in [eventsA, eventsB] { + guard events.count == 2 else { continue } + guard case .contactsChanged = events[0] else { + Issue.record("Expected first event to be contactsChanged, got \(events[0])") + continue + } + guard case .conversationsChanged = events[1] else { + Issue.record("Expected second event to be conversationsChanged, got \(events[1])") + continue + } } + #expect(coordinator.contactsVersion == 1) + #expect(coordinator.conversationsVersion == 1) + } - @Test("finishDataEvents ends every subscriber's iteration") - func finishEndsSubscriberIteration() async { - let coordinator = SyncCoordinator() - let streamA = coordinator.dataEvents() - let streamB = coordinator.dataEvents() + @Test + func `finishDataEvents ends every subscriber's iteration`() async { + let coordinator = SyncCoordinator() + let streamA = coordinator.dataEvents() + let streamB = coordinator.dataEvents() - let consumerA = Task { - for await _ in streamA {} - return true - } - let consumerB = Task { - for await _ in streamB {} - return true - } + let consumerA = Task { + for await _ in streamA {} + return true + } + let consumerB = Task { + for await _ in streamB {} + return true + } - coordinator.finishDataEvents() + coordinator.finishDataEvents() - #expect(await consumerA.value) - #expect(await consumerB.value) - #expect(coordinator.dataEventBroadcaster.subscriberCount == 0) - } + #expect(await consumerA.value) + #expect(await consumerB.value) + #expect(coordinator.dataEventBroadcaster.subscriberCount == 0) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift index aa34a496..3db4db3e 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift @@ -1,239 +1,237 @@ -import Testing import Foundation -import MeshCoreTestSupport @testable import MC1Services +import MeshCoreTestSupport +import Testing @Suite("SyncCoordinator Message Handler Tests") @MainActor struct SyncCoordinatorMessageHandlerTests { - - // MARK: - Test Helpers - - private func createTestDataStore(radioID: UUID) async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - let store = PersistenceStore(modelContainer: container) - let device = DeviceDTO.testDevice(id: radioID, nodeName: "TestNode") - try await store.saveDevice(device) - return store - } - - private func createTestServices() async throws -> (MeshCoreSession, ServiceContainer) { - let transport = SimulatorMockTransport() - let session = MeshCoreSession(transport: transport) - let services = try await ServiceContainer.forTesting(session: session) - return (session, services) - } - - // MARK: - parseChannelMessage Tests - - @Test("parseChannelMessage parses standard 'Name: text' format") - func parseStandardFormat() { - let (sender, text) = SyncCoordinator.parseChannelMessage("NodeAlpha: Hello world") - #expect(sender == "NodeAlpha") - #expect(text == "Hello world") - } - - @Test("parseChannelMessage handles multiple colons") - func parseMultipleColons() { - let (sender, text) = SyncCoordinator.parseChannelMessage("Node: time is 12:30:00") - #expect(sender == "Node") - #expect(text == "time is 12:30:00") - } - - @Test("parseChannelMessage returns nil sender for text without colon") - func parseNoColon() { - let (sender, text) = SyncCoordinator.parseChannelMessage("just plain text") - #expect(sender == nil) - #expect(text == "just plain text") - } - - @Test("parseChannelMessage returns nil sender for empty string") - func parseEmptyString() { - let (sender, text) = SyncCoordinator.parseChannelMessage("") - #expect(sender == nil) - #expect(text == "") - } - - @Test("parseChannelMessage handles colon only — split omits empty subsequences") - func parseColonOnly() { - let (sender, text) = SyncCoordinator.parseChannelMessage(":") - #expect(sender == nil) - #expect(text == ":") - } - - @Test("parseChannelMessage trims whitespace from sender and text") - func parseTrimsWhitespace() { - let (sender, text) = SyncCoordinator.parseChannelMessage(" NodeName : hello there ") - #expect(sender == "NodeName") - #expect(text == "hello there") - } - - @Test("parseChannelMessage handles colon at start — leading empty part omitted by split") - func parseColonAtStart() { - let (sender, text) = SyncCoordinator.parseChannelMessage(": some text") - #expect(sender == nil) - #expect(text == ": some text") - } - - @Test("parseChannelMessage handles emoji in name") - func parseEmojiInName() { - let (sender, text) = SyncCoordinator.parseChannelMessage("Node🔥: hello") - #expect(sender == "Node🔥") - #expect(text == "hello") - } - - @Test("parseChannelMessage handles unicode characters") - func parseUnicode() { - let (sender, text) = SyncCoordinator.parseChannelMessage("Ñoño: café time") - #expect(sender == "Ñoño") - #expect(text == "café time") - } - - @Test("parseChannelMessage handles text with only sender and colon — trailing empty part omitted") - func parseSenderColonNoText() { - let (sender, text) = SyncCoordinator.parseChannelMessage("NodeName:") - #expect(sender == nil) - #expect(text == "NodeName:") - } - - // MARK: - Blocked Sender Cache Tests - - @Test("isBlockedSender returns false for empty cache") - func blockedCacheEmptyReturnsFalse() async { - let coordinator = SyncCoordinator() - let result = await coordinator.isBlockedSender("SomeNode") - #expect(!result) - } - - @Test("refreshBlockedContactsCache loads blocked contacts by name") - func refreshBlockedCacheLoads() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let dataStore = try await createTestDataStore(radioID: radioID) - - let blockedContact = ContactDTO.testContact( - radioID: radioID, - name: "BlockedPerson", - isBlocked: true - ) - try await dataStore.saveContact(blockedContact) - - await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) - - let result = await coordinator.isBlockedSender("BlockedPerson") - #expect(result, "Blocked contact name should be in cache") - } - - @Test("refreshBlockedContactsCache does not cache non-blocked contacts") - func refreshBlockedCacheIgnoresUnblocked() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let dataStore = try await createTestDataStore(radioID: radioID) - - let normalContact = ContactDTO.testContact( - radioID: radioID, - name: "NormalPerson", - isBlocked: false - ) - try await dataStore.saveContact(normalContact) - - await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) - - let result = await coordinator.isBlockedSender("NormalPerson") - #expect(!result, "Non-blocked contact name should not be in cache") - } - - @Test("refreshBlockedContactsCache replaces previous cache") - func refreshBlockedCacheReplaces() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let dataStore = try await createTestDataStore(radioID: radioID) - - // First: add a blocked contact - let contact = ContactDTO.testContact( - id: UUID(), - radioID: radioID, - name: "WasBlocked", - isBlocked: true - ) - try await dataStore.saveContact(contact) - await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) - #expect(await coordinator.isBlockedSender("WasBlocked")) - - // Delete the contact and refresh — cache should be empty - try await dataStore.deleteContact(id: contact.id) - await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) - #expect(await !coordinator.isBlockedSender("WasBlocked")) - } - - @Test("isBlockedSender returns false for nil name") - func blockedSenderNilReturnsFalse() async { - let coordinator = SyncCoordinator() - let result = await coordinator.isBlockedSender(nil) - #expect(!result) - } - - @Test("blockedSenderNames returns snapshot of cached names") - func blockedSenderNamesReturnsSnapshot() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let dataStore = try await createTestDataStore(radioID: radioID) - - let blocked1 = ContactDTO.testContact(radioID: radioID, name: "Blocked1", isBlocked: true) - let blocked2 = ContactDTO.testContact(radioID: radioID, name: "Blocked2", isBlocked: true) - try await dataStore.saveContact(blocked1) - try await dataStore.saveContact(blocked2) - - await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) - - let names = await coordinator.blockedSenderNames() - #expect(names.contains("Blocked1")) - #expect(names.contains("Blocked2")) - } - - // MARK: - Handler Wiring Smoke Tests - - @Test("wireMessageHandlers completes without error") - func wireMessageHandlersSmoke() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let (_, services) = try await createTestServices() - try await services.dataStore.saveDevice(DeviceDTO.testDevice(id: radioID, nodeName: "TestNode")) - - await coordinator.wireMessageHandlers(dependencies: services.syncDependencies, radioID: radioID) - } - - @Test("startDiscoveryEventMonitoring completes without error") - func startDiscoveryEventMonitoringSmoke() async throws { - let coordinator = SyncCoordinator() - let radioID = UUID() - let (_, services) = try await createTestServices() - - await coordinator.startDiscoveryEventMonitoring(dependencies: services.syncDependencies, radioID: radioID) - await coordinator.cancelDiscoveryEventMonitoring() - } - - // MARK: - Unresolved Channel Notification Guard - - @Test("Channel message that resolves to no local channel must not post a notification") - func unresolvedChannelSuppressesNotification() { - #expect(SyncCoordinator.shouldPostChannelNotification(forResolvedChannel: nil) == false) - } - - @Test("Channel message that resolves to a known local channel posts a notification") - func resolvedChannelPostsNotification() { - let channel = ChannelDTO( - id: UUID(), - radioID: UUID(), - index: 3, - name: "Test", - secret: Data(repeating: 1, count: 16), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - floodScope: .inherit - ) - #expect(SyncCoordinator.shouldPostChannelNotification(forResolvedChannel: channel) == true) - } - + // MARK: - Test Helpers + + private func createTestDataStore(radioID: UUID) async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let device = DeviceDTO.testDevice(id: radioID, nodeName: "TestNode") + try await store.saveDevice(device) + return store + } + + private func createTestServices() async throws -> (MeshCoreSession, ServiceContainer) { + let transport = SimulatorMockTransport() + let session = MeshCoreSession(transport: transport) + let services = try await ServiceContainer.forTesting(session: session) + return (session, services) + } + + // MARK: - parseChannelMessage Tests + + @Test + func `parseChannelMessage parses standard 'Name: text' format`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("NodeAlpha: Hello world") + #expect(sender == "NodeAlpha") + #expect(text == "Hello world") + } + + @Test + func `parseChannelMessage handles multiple colons`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("Node: time is 12:30:00") + #expect(sender == "Node") + #expect(text == "time is 12:30:00") + } + + @Test + func `parseChannelMessage returns nil sender for text without colon`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("just plain text") + #expect(sender == nil) + #expect(text == "just plain text") + } + + @Test + func `parseChannelMessage returns nil sender for empty string`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("") + #expect(sender == nil) + #expect(text == "") + } + + @Test + func `parseChannelMessage handles colon only — split omits empty subsequences`() { + let (sender, text) = SyncCoordinator.parseChannelMessage(":") + #expect(sender == nil) + #expect(text == ":") + } + + @Test + func `parseChannelMessage trims whitespace from sender and text`() { + let (sender, text) = SyncCoordinator.parseChannelMessage(" NodeName : hello there ") + #expect(sender == "NodeName") + #expect(text == "hello there") + } + + @Test + func `parseChannelMessage handles colon at start — leading empty part omitted by split`() { + let (sender, text) = SyncCoordinator.parseChannelMessage(": some text") + #expect(sender == nil) + #expect(text == ": some text") + } + + @Test + func `parseChannelMessage handles emoji in name`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("Node🔥: hello") + #expect(sender == "Node🔥") + #expect(text == "hello") + } + + @Test + func `parseChannelMessage handles unicode characters`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("Ñoño: café time") + #expect(sender == "Ñoño") + #expect(text == "café time") + } + + @Test + func `parseChannelMessage handles text with only sender and colon — trailing empty part omitted`() { + let (sender, text) = SyncCoordinator.parseChannelMessage("NodeName:") + #expect(sender == nil) + #expect(text == "NodeName:") + } + + // MARK: - Blocked Sender Cache Tests + + @Test + func `isBlockedSender returns false for empty cache`() async { + let coordinator = SyncCoordinator() + let result = await coordinator.isBlockedSender("SomeNode") + #expect(!result) + } + + @Test + func `refreshBlockedContactsCache loads blocked contacts by name`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + + let blockedContact = ContactDTO.testContact( + radioID: radioID, + name: "BlockedPerson", + isBlocked: true + ) + try await dataStore.saveContact(blockedContact) + + await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) + + let result = await coordinator.isBlockedSender("BlockedPerson") + #expect(result, "Blocked contact name should be in cache") + } + + @Test + func `refreshBlockedContactsCache does not cache non-blocked contacts`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + + let normalContact = ContactDTO.testContact( + radioID: radioID, + name: "NormalPerson", + isBlocked: false + ) + try await dataStore.saveContact(normalContact) + + await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) + + let result = await coordinator.isBlockedSender("NormalPerson") + #expect(!result, "Non-blocked contact name should not be in cache") + } + + @Test + func `refreshBlockedContactsCache replaces previous cache`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + + // First: add a blocked contact + let contact = ContactDTO.testContact( + id: UUID(), + radioID: radioID, + name: "WasBlocked", + isBlocked: true + ) + try await dataStore.saveContact(contact) + await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) + #expect(await coordinator.isBlockedSender("WasBlocked")) + + // Delete the contact and refresh — cache should be empty + try await dataStore.deleteContact(id: contact.id) + await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) + #expect(await !coordinator.isBlockedSender("WasBlocked")) + } + + @Test + func `isBlockedSender returns false for nil name`() async { + let coordinator = SyncCoordinator() + let result = await coordinator.isBlockedSender(nil) + #expect(!result) + } + + @Test + func `blockedSenderNames returns snapshot of cached names`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + + let blocked1 = ContactDTO.testContact(radioID: radioID, name: "Blocked1", isBlocked: true) + let blocked2 = ContactDTO.testContact(radioID: radioID, name: "Blocked2", isBlocked: true) + try await dataStore.saveContact(blocked1) + try await dataStore.saveContact(blocked2) + + await coordinator.refreshBlockedContactsCache(radioID: radioID, dataStore: dataStore) + + let names = await coordinator.blockedSenderNames() + #expect(names.contains("Blocked1")) + #expect(names.contains("Blocked2")) + } + + // MARK: - Handler Wiring Smoke Tests + + @Test + func `wireMessageHandlers completes without error`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let (_, services) = try await createTestServices() + try await services.dataStore.saveDevice(DeviceDTO.testDevice(id: radioID, nodeName: "TestNode")) + + await coordinator.wireMessageHandlers(dependencies: services.syncDependencies, radioID: radioID) + } + + @Test + func `startDiscoveryEventMonitoring completes without error`() async throws { + let coordinator = SyncCoordinator() + let radioID = UUID() + let (_, services) = try await createTestServices() + + await coordinator.startDiscoveryEventMonitoring(dependencies: services.syncDependencies, radioID: radioID) + await coordinator.cancelDiscoveryEventMonitoring() + } + + // MARK: - Unresolved Channel Notification Guard + + @Test + func `Channel message that resolves to no local channel must not post a notification`() { + #expect(SyncCoordinator.shouldPostChannelNotification(forResolvedChannel: nil) == false) + } + + @Test + func `Channel message that resolves to a known local channel posts a notification`() { + let channel = ChannelDTO( + id: UUID(), + radioID: UUID(), + index: 3, + name: "Test", + secret: Data(repeating: 1, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + floodScope: .inherit + ) + #expect(SyncCoordinator.shouldPostChannelNotification(forResolvedChannel: channel) == true) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift index 25a77d37..2c23800e 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift @@ -1,892 +1,892 @@ // SyncCoordinatorTests.swift -import Testing import Foundation +@testable import MC1Services import MeshCore import MeshCoreTestSupport -@testable import MC1Services +import Testing @Suite("SyncCoordinator Tests") struct SyncCoordinatorTests { - - private func createTestDataStore( - radioID: UUID, - maxChannels: UInt8 = 8, - lastContactSync: UInt32 = 0 - ) async throws -> PersistenceStore { - try await PersistenceStore.createTestDataStore( - radioID: radioID, - maxChannels: maxChannels, - lastContactSync: lastContactSync - ) - } - - @Test("SyncState cases are distinct") - func syncStateCasesDistinct() { - let idle = SyncState.idle - let syncing = SyncState.syncing(progress: SyncProgress(phase: .contacts, current: 0, total: 0)) - let synced = SyncState.synced - let failed = SyncState.failed(SyncCoordinatorError.notConnected) - - // Verify they're not equal - #expect(idle != syncing) - #expect(syncing != synced) - #expect(synced != failed) - } - - @Test("SyncProgress initializes correctly") - func syncProgressInitializes() { - let progress = SyncProgress(phase: .contacts, current: 5, total: 10) - #expect(progress.phase == .contacts) - #expect(progress.current == 5) - #expect(progress.total == 10) - } - - @Test("SyncPhase has all expected cases") - func syncPhaseHasAllCases() { - let phases: [SyncPhase] = [.contacts, .channels, .messages] - #expect(phases.count == 3) - } - - @Test("SyncCoordinator initializes with idle state") - @MainActor - func syncCoordinatorInitializesIdle() async { - let coordinator = SyncCoordinator() - #expect(coordinator.state == .idle) - #expect(coordinator.contactsVersion == 0) - #expect(coordinator.conversationsVersion == 0) - #expect(coordinator.lastSyncDate == nil) - } - - @Test("notifyContactsChanged increments contactsVersion") - @MainActor - func notifyContactsChangedIncrementsVersion() async { - let coordinator = SyncCoordinator() - let initialVersion = coordinator.contactsVersion - - await coordinator.notifyContactsChanged() - - #expect(coordinator.contactsVersion == initialVersion + 1) - } - - @Test("notifyConversationsChanged increments conversationsVersion") - @MainActor - func notifyConversationsChangedIncrementsVersion() async { - let coordinator = SyncCoordinator() - let initialVersion = coordinator.conversationsVersion - - await coordinator.notifyConversationsChanged() - - #expect(coordinator.conversationsVersion == initialVersion + 1) - } - - @Test("Multiple notifications increment correctly") - @MainActor - func multipleNotificationsIncrementCorrectly() async { - let coordinator = SyncCoordinator() - - await coordinator.notifyContactsChanged() - await coordinator.notifyContactsChanged() - await coordinator.notifyConversationsChanged() - - #expect(coordinator.contactsVersion == 2) - #expect(coordinator.conversationsVersion == 1) - } - - @Test("Sync activity callbacks fire during full sync") - @MainActor - func syncActivityCallbacksFire() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let startedTracker = CallTracker() - let endedTracker = CallTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { _ in endedTracker.markCalled() }, - onPhaseChanged: { _ in } - ) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(startedTracker.wasCalled, "onSyncActivityStarted should have been called") - #expect(endedTracker.wasCalled, "onSyncActivityEnded should have been called") - } - - @Test("Channel phase failure is partial and keeps connection usable") - @MainActor - func channelFailureIsPartialAndConnectionUsable() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - await mockChannelService.setStubbedSyncChannelsResult(.failure( - ChannelServiceError.circuitBreakerOpen(consecutiveFailures: 3) - )) - - let result = try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(result.contacts == .clean) - #expect(result.channels == .partial) - #expect(result.messages == .clean) - #expect(result.isConnectionUsable) - #expect(coordinator.state == .synced) - #expect(await mockMessagePollingService.pollAllMessagesCallCount == 1) - } - - @Test("Message polling failure does not fail contacts and channels") - @MainActor - func messagePollingFailureDoesNotFailContactsAndChannels() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - await mockMessagePollingService.setStubbedPollAllMessagesResult(.failure( - SyncCoordinatorError.syncFailed("messages saturated") - )) - - let result = try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(result.contacts == .clean) - #expect(result.channels == .clean) - guard case .failed(let reason) = result.messages else { - Issue.record("Expected failed message phase, got \(result.messages)") - return - } - #expect(reason.localizedStandardContains("messages saturated")) - #expect(result.isConnectionUsable) - #expect(coordinator.state == .synced) - } - - @Test("Sync activity callbacks not double called on error") - @MainActor - func syncActivityCallbacksNotDoubleCalledOnError() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let endedTracker = CallTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { _ in endedTracker.markCalled() }, - onPhaseChanged: { _ in } - ) - - // Configure mock to throw error during contacts sync - await mockContactService.setStubbedSyncContactsResult(.failure(SyncCoordinatorError.syncFailed("Test error"))) - - do { - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - Issue.record("Should have thrown error") - } catch { - // Expected - } - - #expect(endedTracker.callCount == 1, "onSyncActivityEnded should be called exactly once on error") - } - - @Test("Sync activity ends before messages phase") - @MainActor - func syncActivityEndsBeforeMessagesPhase() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let orderTracker = OrderTrackingMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { _ in - // Record when activity ended - await orderTracker.recordActivityEnded() - }, - onPhaseChanged: { _ in } - ) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: orderTracker - ) - - // Verify that activity ended before message polling started - let activityEndedBeforeMessages = await orderTracker.activityEndedBeforeMessagePoll - #expect(activityEndedBeforeMessages, "Activity should end before message polling starts") - } - - @Test("onDisconnected clears notification suppression flag") - @MainActor - func onDisconnectedClearsSuppressionFlag() async throws { - let coordinator = SyncCoordinator() - - // Create a test ServiceContainer - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) - - // Manually set suppression flag to true (simulating mid-sync state) - services.notificationService.isSuppressingNotifications = true - #expect(services.notificationService.isSuppressingNotifications == true) - - // Call onDisconnected - await coordinator.onDisconnected(notificationService: services.notificationService) - - // Verify flag is cleared - #expect(services.notificationService.isSuppressingNotifications == false) - } - - @Test("onDisconnected resets sync state to idle") - @MainActor - func onDisconnectedResetsSyncState() async throws { - let coordinator = SyncCoordinator() - - // Create a test ServiceContainer - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) - - // Call onDisconnected - await coordinator.onDisconnected(notificationService: services.notificationService) - - // Verify state is idle - #expect(coordinator.state == .idle) - } - - @Test("onDisconnected calls onSyncActivityEnded when mid-sync in contacts phase") - @MainActor - func onDisconnectedCallsActivityEndedDuringContactsSync() async throws { - let coordinator = SyncCoordinator() - let delayingContactService = DelayingContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - // Create a test ServiceContainer - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) - - let startedTracker = CallTracker() - let endedTracker = CallTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { _ in endedTracker.markCalled() }, - onPhaseChanged: { _ in } - ) - - // Start sync in background task - it will block during contacts phase - let syncTask = Task { - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: delayingContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - } - - // Wait for sync to start (activity started callback) - try await waitUntil("Sync activity should have started") { - startedTracker.wasCalled - } - #expect(startedTracker.wasCalled, "Sync activity should have started") - - // Call onDisconnected while sync is in contacts phase - await coordinator.onDisconnected(notificationService: services.notificationService) - - // Verify onSyncActivityEnded was called by onDisconnected - #expect(endedTracker.wasCalled, "onSyncActivityEnded should be called when disconnecting mid-sync") - - // Cleanup: resume the sync so it doesn't hang - await delayingContactService.completeSync() - syncTask.cancel() - } - - @Test("Background sync skips channel sync") - @MainActor - func backgroundSyncSkipsChannels() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let mockAppStateProvider = MockAppStateProvider(isInForeground: false) - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: mockAppStateProvider - ) - - // Channel sync should be skipped in background - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.isEmpty, "Channel sync should be skipped when in background") - - // Contact sync should still happen - let contactInvocations = await mockContactService.syncContactsInvocations - #expect(contactInvocations.count == 1, "Contact sync should still run in background") - } - - @Test("Foreground sync includes channel sync") - @MainActor - func foregroundSyncIncludesChannels() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let mockAppStateProvider = MockAppStateProvider(isInForeground: true) - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: mockAppStateProvider - ) - - // Channel sync should run in foreground - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Channel sync should run when in foreground") - - // Contact sync should also run - let contactInvocations = await mockContactService.syncContactsInvocations - #expect(contactInvocations.count == 1, "Contact sync should run in foreground") - } - - @Test("performFullSync forwards the pipelined-read flag from config into channel sync") - @MainActor - func performFullSyncForwardsPipelinedReadFlag() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: nil, - channelSyncConfig: ChannelSyncConfig(usePipelinedChannelRead: true) - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1) - #expect(channelInvocations.last?.usePipelinedRead == true, "Config flag should reach channel sync") - } - - @Test("performFullSync defaults channel sync to the serial read path") - @MainActor - func performFullSyncDefaultsToSerialRead() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: nil - ) - - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1) - #expect(channelInvocations.last?.usePipelinedRead == false, "Default config should use the serial path") - } - - @Test("Nil appStateProvider defaults to foreground behavior") - @MainActor - func nilAppStateProviderDefaultsToForeground() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: nil - ) - - // Should default to foreground (run channels) - let channelInvocations = await mockChannelService.syncChannelsInvocations - #expect(channelInvocations.count == 1, "Nil appStateProvider should default to foreground behavior") - - // Contact sync should also run - let contactInvocations = await mockContactService.syncContactsInvocations - #expect(contactInvocations.count == 1, "Contact sync should run with nil appStateProvider") - } - - @Test("performFullSync ignores duplicate calls when already syncing") - @MainActor - func performFullSyncIgnoresDuplicateWhenSyncing() async throws { - let coordinator = SyncCoordinator() - let delayingContactService = DelayingContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let startedTracker = CallTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { _ in }, - onPhaseChanged: { _ in } - ) - - // Start first sync in background - it will block during contacts phase - let firstSyncTask = Task { - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: delayingContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - } - - // Wait for first sync to start - try await waitUntil("First sync should have started") { - startedTracker.callCount >= 1 - } - - // Try to start a second sync while first is still running - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: delayingContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - // Verify onSyncActivityStarted was only called once (not twice) - #expect(startedTracker.callCount == 1, "onSyncActivityStarted should only be called once even with duplicate performFullSync calls") - - // Cleanup - await delayingContactService.completeSync() - firstSyncTask.cancel() - } - - @Test("Cancellation during channels phase ends sync activity once and resets state") - @MainActor - func cancellationDuringChannelSyncEndsActivityAndResetsState() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let delayingChannelService = DelayingChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let endedTracker = CallTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { _ in endedTracker.markCalled() }, - onPhaseChanged: { _ in } - ) - - let syncTask = Task { - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: delayingChannelService, - messagePollingService: mockMessagePollingService - ) - } - - await delayingChannelService.waitForSyncStart() - syncTask.cancel() - - do { - try await syncTask.value - Issue.record("Expected cancellation") - } catch is CancellationError { - // Expected - } catch { - Issue.record("Expected CancellationError, got \(error)") - } - - #expect(endedTracker.callCount == 1, "onSyncActivityEnded should be called exactly once on cancellation") - #expect(coordinator.state == .idle, "Sync state should reset to idle on cancellation") - } - - @Test("performFullSync clears notification suppression after poll completes") - @MainActor - func performFullSyncClearsSuppressionAfterPoll() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) - - // Simulate suppression being active (as it would be during a real sync) - services.notificationService.isSuppressingNotifications = true - #expect(services.notificationService.isSuppressingNotifications == true) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - notificationService: services.notificationService - ) - - // Suppression should be cleared after pollAllMessages() completes - #expect(services.notificationService.isSuppressingNotifications == false) - } - - @Test("Contact sync passes lastContactSync timestamp from device") - @MainActor - func contactSyncPassesTimestamp() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - - // Create device with a lastContactSync timestamp - let lastSyncTimestamp: UInt32 = 1704067200 // 2024-01-01 00:00:00 UTC - let dataStore = try await createTestDataStore( - radioID: testDeviceID, - lastContactSync: lastSyncTimestamp - ) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService, - appStateProvider: nil - ) - - let invocations = await mockContactService.syncContactsInvocations - #expect(invocations.count == 1) - - // Verify the since parameter was passed - let since = invocations[0].since - let expectedDate = Date(timeIntervalSince1970: Double(lastSyncTimestamp)) - - // Use try #require to safely unwrap and produce a clear failure message - let actualSince = try #require(since, "Should pass lastContactSync as since parameter") - #expect(actualSince == expectedDate, "Since date should match device lastContactSync") - } - // MARK: - Succeeded Parameter Tests - - @Test("Successful sync passes succeeded: true to onEnded callback") - @MainActor - func syncActivityEndedWithSuccessPassesTrue() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let succeededValues = ValueTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) - - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - - #expect(succeededValues.values == [true], "Successful sync should pass succeeded: true") - } - - @Test("Failed sync passes succeeded: false to onEnded callback") - @MainActor - func syncActivityEndedWithFailurePassesFalse() async throws { - let coordinator = SyncCoordinator() - let mockContactService = MockContactService() - let mockChannelService = MockChannelService() - let mockMessagePollingService = MockMessagePollingService() - let testDeviceID = UUID() - let dataStore = try await createTestDataStore(radioID: testDeviceID) - - let succeededValues = ValueTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) - - await mockContactService.setStubbedSyncContactsResult(.failure(SyncCoordinatorError.syncFailed("Test error"))) - - do { - try await coordinator.performFullSync( - radioID: testDeviceID, - dataStore: dataStore, - contactService: mockContactService, - channelService: mockChannelService, - messagePollingService: mockMessagePollingService - ) - Issue.record("Should have thrown error") - } catch { - // Expected - } - - #expect(succeededValues.values == [false], "Failed sync should pass succeeded: false") - } - - // MARK: - Resync Activity Bracket Tests - - @Test("beginResyncActivity and endResyncActivity fire the correct callbacks") - @MainActor - func resyncActivityBracketCallsStartedAndEnded() async { - let coordinator = SyncCoordinator() - - let startedTracker = CallTracker() - let succeededValues = ValueTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) - - await coordinator.beginResyncActivity() - #expect(startedTracker.callCount == 1, "beginResyncActivity should fire onStarted") - - await coordinator.endResyncActivity(succeeded: true) - #expect(succeededValues.values == [true], "endResyncActivity(succeeded: true) should pass true") - - // Call again with false to verify the value is forwarded - await coordinator.beginResyncActivity() - await coordinator.endResyncActivity(succeeded: false) - #expect(succeededValues.values == [true, false], "endResyncActivity(succeeded: false) should pass false") - } - - @Test("Disconnect during resync does not double-end the resync bracket") - @MainActor - func disconnectDuringResyncDoesNotInterfereWithResyncBracket() async throws { - let coordinator = SyncCoordinator() - - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) - - let succeededValues = ValueTracker() - - await coordinator.setSyncActivityCallbacks( - onStarted: { }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } - ) - - // Simulate resync bracket open - await coordinator.beginResyncActivity() - - // Disconnect while resync bracket is open - await coordinator.onDisconnected(notificationService: services.notificationService) - - // onDisconnected calls endSyncActivityOnce, which is for the initial sync bracket, - // not the resync bracket. Since no initial sync was started, hasEndedSyncActivity - // is already true and endSyncActivityOnce should be a no-op. - #expect(succeededValues.values.isEmpty, "onDisconnected should not end the resync bracket") - } + private func createTestDataStore( + radioID: UUID, + maxChannels: UInt8 = 8, + lastContactSync: UInt32 = 0 + ) async throws -> PersistenceStore { + try await PersistenceStore.createTestDataStore( + radioID: radioID, + maxChannels: maxChannels, + lastContactSync: lastContactSync + ) + } + + @Test + func `SyncState cases are distinct`() { + let idle = SyncState.idle + let syncing = SyncState.syncing(progress: SyncProgress(phase: .contacts, current: 0, total: 0)) + let synced = SyncState.synced + let failed = SyncState.failed(SyncCoordinatorError.notConnected) + + // Verify they're not equal + #expect(idle != syncing) + #expect(syncing != synced) + #expect(synced != failed) + } + + @Test + func `SyncProgress initializes correctly`() { + let progress = SyncProgress(phase: .contacts, current: 5, total: 10) + #expect(progress.phase == .contacts) + #expect(progress.current == 5) + #expect(progress.total == 10) + } + + @Test + func `SyncPhase has all expected cases`() { + let phases: [SyncPhase] = [.contacts, .channels, .messages] + #expect(phases.count == 3) + } + + @Test + @MainActor + func `SyncCoordinator initializes with idle state`() { + let coordinator = SyncCoordinator() + #expect(coordinator.state == .idle) + #expect(coordinator.contactsVersion == 0) + #expect(coordinator.conversationsVersion == 0) + #expect(coordinator.lastSyncDate == nil) + } + + @Test + @MainActor + func `notifyContactsChanged increments contactsVersion`() async { + let coordinator = SyncCoordinator() + let initialVersion = coordinator.contactsVersion + + await coordinator.notifyContactsChanged() + + #expect(coordinator.contactsVersion == initialVersion + 1) + } + + @Test + @MainActor + func `notifyConversationsChanged increments conversationsVersion`() async { + let coordinator = SyncCoordinator() + let initialVersion = coordinator.conversationsVersion + + await coordinator.notifyConversationsChanged() + + #expect(coordinator.conversationsVersion == initialVersion + 1) + } + + @Test + @MainActor + func `Multiple notifications increment correctly`() async { + let coordinator = SyncCoordinator() + + await coordinator.notifyContactsChanged() + await coordinator.notifyContactsChanged() + await coordinator.notifyConversationsChanged() + + #expect(coordinator.contactsVersion == 2) + #expect(coordinator.conversationsVersion == 1) + } + + @Test + @MainActor + func `Sync activity callbacks fire during full sync`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let startedTracker = CallTracker() + let endedTracker = CallTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { _ in endedTracker.markCalled() }, + onPhaseChanged: { _ in } + ) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(startedTracker.wasCalled, "onSyncActivityStarted should have been called") + #expect(endedTracker.wasCalled, "onSyncActivityEnded should have been called") + } + + @Test + @MainActor + func `Channel phase failure is partial and keeps connection usable`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + await mockChannelService.setStubbedSyncChannelsResult(.failure( + ChannelServiceError.circuitBreakerOpen(consecutiveFailures: 3) + )) + + let result = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(result.contacts == .clean) + #expect(result.channels == .partial) + #expect(result.messages == .clean) + #expect(result.isConnectionUsable) + #expect(coordinator.state == .synced) + #expect(await mockMessagePollingService.pollAllMessagesCallCount == 1) + } + + @Test + @MainActor + func `Message polling failure does not fail contacts and channels`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + await mockMessagePollingService.setStubbedPollAllMessagesResult(.failure( + SyncCoordinatorError.syncFailed("messages saturated") + )) + + let result = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(result.contacts == .clean) + #expect(result.channels == .clean) + guard case let .failed(reason) = result.messages else { + Issue.record("Expected failed message phase, got \(result.messages)") + return + } + #expect(reason.localizedStandardContains("messages saturated")) + #expect(result.isConnectionUsable) + #expect(coordinator.state == .synced) + } + + @Test + @MainActor + func `Sync activity callbacks not double called on error`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let endedTracker = CallTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { _ in endedTracker.markCalled() }, + onPhaseChanged: { _ in } + ) + + // Configure mock to throw error during contacts sync + await mockContactService.setStubbedSyncContactsResult(.failure(SyncCoordinatorError.syncFailed("Test error"))) + + do { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + Issue.record("Should have thrown error") + } catch { + // Expected + } + + #expect(endedTracker.callCount == 1, "onSyncActivityEnded should be called exactly once on error") + } + + @Test + @MainActor + func `Sync activity ends before messages phase`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let orderTracker = OrderTrackingMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { _ in + // Record when activity ended + await orderTracker.recordActivityEnded() + }, + onPhaseChanged: { _ in } + ) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: orderTracker + ) + + // Verify that activity ended before message polling started + let activityEndedBeforeMessages = await orderTracker.activityEndedBeforeMessagePoll + #expect(activityEndedBeforeMessages, "Activity should end before message polling starts") + } + + @Test + @MainActor + func `onDisconnected clears notification suppression flag`() async throws { + let coordinator = SyncCoordinator() + + // Create a test ServiceContainer + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + // Manually set suppression flag to true (simulating mid-sync state) + services.notificationService.isSuppressingNotifications = true + #expect(services.notificationService.isSuppressingNotifications == true) + + // Call onDisconnected + await coordinator.onDisconnected(notificationService: services.notificationService) + + // Verify flag is cleared + #expect(services.notificationService.isSuppressingNotifications == false) + } + + @Test + @MainActor + func `onDisconnected resets sync state to idle`() async throws { + let coordinator = SyncCoordinator() + + // Create a test ServiceContainer + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + // Call onDisconnected + await coordinator.onDisconnected(notificationService: services.notificationService) + + // Verify state is idle + #expect(coordinator.state == .idle) + } + + @Test + @MainActor + func `onDisconnected calls onSyncActivityEnded when mid-sync in contacts phase`() async throws { + let coordinator = SyncCoordinator() + let delayingContactService = DelayingContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + // Create a test ServiceContainer + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + let startedTracker = CallTracker() + let endedTracker = CallTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { _ in endedTracker.markCalled() }, + onPhaseChanged: { _ in } + ) + + // Start sync in background task - it will block during contacts phase + let syncTask = Task { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: delayingContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + } + + // Wait for sync to start (activity started callback) + try await waitUntil("Sync activity should have started") { + startedTracker.wasCalled + } + #expect(startedTracker.wasCalled, "Sync activity should have started") + + // Call onDisconnected while sync is in contacts phase + await coordinator.onDisconnected(notificationService: services.notificationService) + + // Verify onSyncActivityEnded was called by onDisconnected + #expect(endedTracker.wasCalled, "onSyncActivityEnded should be called when disconnecting mid-sync") + + // Cleanup: resume the sync so it doesn't hang + await delayingContactService.completeSync() + syncTask.cancel() + } + + @Test + @MainActor + func `Background sync skips channel sync`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let mockAppStateProvider = MockAppStateProvider(isInForeground: false) + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: mockAppStateProvider + ) + + // Channel sync should be skipped in background + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.isEmpty, "Channel sync should be skipped when in background") + + // Contact sync should still happen + let contactInvocations = await mockContactService.syncContactsInvocations + #expect(contactInvocations.count == 1, "Contact sync should still run in background") + } + + @Test + @MainActor + func `Foreground sync includes channel sync`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let mockAppStateProvider = MockAppStateProvider(isInForeground: true) + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: mockAppStateProvider + ) + + // Channel sync should run in foreground + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Channel sync should run when in foreground") + + // Contact sync should also run + let contactInvocations = await mockContactService.syncContactsInvocations + #expect(contactInvocations.count == 1, "Contact sync should run in foreground") + } + + @Test + @MainActor + func `performFullSync forwards the pipelined-read flag from config into channel sync`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: nil, + channelSyncConfig: ChannelSyncConfig(usePipelinedChannelRead: true) + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1) + #expect(channelInvocations.last?.usePipelinedRead == true, "Config flag should reach channel sync") + } + + @Test + @MainActor + func `performFullSync defaults channel sync to the serial read path`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: nil + ) + + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1) + #expect(channelInvocations.last?.usePipelinedRead == false, "Default config should use the serial path") + } + + @Test + @MainActor + func `Nil appStateProvider defaults to foreground behavior`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: nil + ) + + // Should default to foreground (run channels) + let channelInvocations = await mockChannelService.syncChannelsInvocations + #expect(channelInvocations.count == 1, "Nil appStateProvider should default to foreground behavior") + + // Contact sync should also run + let contactInvocations = await mockContactService.syncContactsInvocations + #expect(contactInvocations.count == 1, "Contact sync should run with nil appStateProvider") + } + + @Test + @MainActor + func `performFullSync ignores duplicate calls when already syncing`() async throws { + let coordinator = SyncCoordinator() + let delayingContactService = DelayingContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let startedTracker = CallTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { _ in }, + onPhaseChanged: { _ in } + ) + + // Start first sync in background - it will block during contacts phase + let firstSyncTask = Task { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: delayingContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + } + + // Wait for first sync to start + try await waitUntil("First sync should have started") { + startedTracker.callCount >= 1 + } + + // Try to start a second sync while first is still running + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: delayingContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + // Verify onSyncActivityStarted was only called once (not twice) + #expect(startedTracker.callCount == 1, "onSyncActivityStarted should only be called once even with duplicate performFullSync calls") + + // Cleanup + await delayingContactService.completeSync() + firstSyncTask.cancel() + } + + @Test + @MainActor + func `Cancellation during channels phase ends sync activity once and resets state`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let delayingChannelService = DelayingChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let endedTracker = CallTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { _ in endedTracker.markCalled() }, + onPhaseChanged: { _ in } + ) + + let syncTask = Task { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: delayingChannelService, + messagePollingService: mockMessagePollingService + ) + } + + await delayingChannelService.waitForSyncStart() + syncTask.cancel() + + do { + try await syncTask.value + Issue.record("Expected cancellation") + } catch is CancellationError { + // Expected + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + + #expect(endedTracker.callCount == 1, "onSyncActivityEnded should be called exactly once on cancellation") + #expect(coordinator.state == .idle, "Sync state should reset to idle on cancellation") + } + + @Test + @MainActor + func `performFullSync clears notification suppression after poll completes`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + // Simulate suppression being active (as it would be during a real sync) + services.notificationService.isSuppressingNotifications = true + #expect(services.notificationService.isSuppressingNotifications == true) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + notificationService: services.notificationService + ) + + // Suppression should be cleared after pollAllMessages() completes + #expect(services.notificationService.isSuppressingNotifications == false) + } + + @Test + @MainActor + func `Contact sync passes lastContactSync timestamp from device`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + + // Create device with a lastContactSync timestamp + let lastSyncTimestamp: UInt32 = 1_704_067_200 // 2024-01-01 00:00:00 UTC + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: lastSyncTimestamp + ) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService, + appStateProvider: nil + ) + + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + + // Verify the since parameter was passed + let since = invocations[0].since + let expectedDate = Date(timeIntervalSince1970: Double(lastSyncTimestamp)) + + // Use try #require to safely unwrap and produce a clear failure message + let actualSince = try #require(since, "Should pass lastContactSync as since parameter") + #expect(actualSince == expectedDate, "Since date should match device lastContactSync") + } + + // MARK: - Succeeded Parameter Tests + + @Test + @MainActor + func `Successful sync passes succeeded: true to onEnded callback`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let succeededValues = ValueTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) + + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + + #expect(succeededValues.values == [true], "Successful sync should pass succeeded: true") + } + + @Test + @MainActor + func `Failed sync passes succeeded: false to onEnded callback`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore(radioID: testDeviceID) + + let succeededValues = ValueTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) + + await mockContactService.setStubbedSyncContactsResult(.failure(SyncCoordinatorError.syncFailed("Test error"))) + + do { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + Issue.record("Should have thrown error") + } catch { + // Expected + } + + #expect(succeededValues.values == [false], "Failed sync should pass succeeded: false") + } + + // MARK: - Resync Activity Bracket Tests + + @Test + @MainActor + func `beginResyncActivity and endResyncActivity fire the correct callbacks`() async { + let coordinator = SyncCoordinator() + + let startedTracker = CallTracker() + let succeededValues = ValueTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) + + await coordinator.beginResyncActivity() + #expect(startedTracker.callCount == 1, "beginResyncActivity should fire onStarted") + + await coordinator.endResyncActivity(succeeded: true) + #expect(succeededValues.values == [true], "endResyncActivity(succeeded: true) should pass true") + + // Call again with false to verify the value is forwarded + await coordinator.beginResyncActivity() + await coordinator.endResyncActivity(succeeded: false) + #expect(succeededValues.values == [true, false], "endResyncActivity(succeeded: false) should pass false") + } + + @Test + @MainActor + func `Disconnect during resync does not double-end the resync bracket`() async throws { + let coordinator = SyncCoordinator() + + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + let succeededValues = ValueTracker() + + await coordinator.setSyncActivityCallbacks( + onStarted: {}, + onEnded: { succeeded in succeededValues.record(succeeded) }, + onPhaseChanged: { _ in } + ) + + // Simulate resync bracket open + await coordinator.beginResyncActivity() + + // Disconnect while resync bracket is open + await coordinator.onDisconnected(notificationService: services.notificationService) + + // onDisconnected calls endSyncActivityOnce, which is for the initial sync bracket, + // not the resync bracket. Since no initial sync was started, hasEndedSyncActivity + // is already true and endSyncActivityOnce should be a no-op. + #expect(succeededValues.values.isEmpty, "onDisconnected should not end the resync bracket") + } } // MARK: - Test Helpers /// Thread-safe value recorder for verifying callback arguments in tests. final class ValueTracker: @unchecked Sendable { - private var _values: [T] = [] - private let lock = NSLock() - - var values: [T] { - lock.lock() - defer { lock.unlock() } - return _values - } - - func record(_ value: T) { - lock.lock() - defer { lock.unlock() } - _values.append(value) - } + private var _values: [T] = [] + private let lock = NSLock() + + var values: [T] { + lock.lock() + defer { lock.unlock() } + return _values + } + + func record(_ value: T) { + lock.lock() + defer { lock.unlock() } + _values.append(value) + } } /// Actor to safely track callback invocations from concurrent closures /// Mock that tracks the order of activity ended callback vs message polling actor OrderTrackingMessagePollingService: MessagePollingServiceProtocol { - private var activityEndedTime: Date? - private var messagePollTime: Date? + private var activityEndedTime: Date? + private var messagePollTime: Date? - /// Records when the activity ended callback was invoked - func recordActivityEnded() { - activityEndedTime = Date() - } + /// Records when the activity ended callback was invoked + func recordActivityEnded() { + activityEndedTime = Date() + } - /// Whether activity ended before message polling started - var activityEndedBeforeMessagePoll: Bool { - guard let ended = activityEndedTime, let poll = messagePollTime else { - return false - } - return ended < poll + /// Whether activity ended before message polling started + var activityEndedBeforeMessagePoll: Bool { + guard let ended = activityEndedTime, let poll = messagePollTime else { + return false } + return ended < poll + } - // MARK: - MessagePollingServiceProtocol + // MARK: - MessagePollingServiceProtocol - func pollAllMessages() async throws -> Int { - messagePollTime = Date() - return 0 - } + func pollAllMessages() async throws -> Int { + messagePollTime = Date() + return 0 + } - func waitForPendingHandlers(timeout: Duration) async -> Bool { - true - } + func waitForPendingHandlers(timeout: Duration) async -> Bool { + true + } - func startAutoFetch(radioID: UUID) async {} + func startAutoFetch(radioID: UUID) async {} - func pauseAutoFetch() async {} + func pauseAutoFetch() async {} - func resumeAutoFetch() async {} + func resumeAutoFetch() async {} - func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) {} + func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) {} - func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) {} + func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) {} - func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) {} + func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) {} - func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) {} + func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) {} } /// Mock contact service that delays and signals when sync has started actor DelayingContactService: ContactServiceProtocol { - private var continuation: CheckedContinuation? - - /// Wait to be signaled that contacts sync has started - func waitForSyncStart() async { - await withCheckedContinuation { continuation in - self.continuation = continuation - } - } - - /// Allow the sync to complete - func completeSync() { - continuation?.resume() - continuation = nil - } - - func syncContacts(radioID: UUID, since: Date?) async throws -> ContactSyncResult { - // Signal that sync has started, then wait to be resumed - await withCheckedContinuation { (cont: CheckedContinuation) in - Task { - // Store the continuation so completeSync can resume it - self.continuation?.resume() - self.continuation = cont - } - } - return ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: false) - } + private var continuation: CheckedContinuation? + + /// Wait to be signaled that contacts sync has started + func waitForSyncStart() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + /// Allow the sync to complete + func completeSync() { + continuation?.resume() + continuation = nil + } + + func syncContacts(radioID: UUID, since: Date?) async throws -> ContactSyncResult { + // Signal that sync has started, then wait to be resumed + await withCheckedContinuation { (cont: CheckedContinuation) in + Task { + // Store the continuation so completeSync can resume it + self.continuation?.resume() + self.continuation = cont + } + } + return ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: false) + } } /// Mock channel service that blocks in syncChannels until cancelled. actor DelayingChannelService: ChannelServiceProtocol { - private var hasStarted = false - private var startWaiters: [CheckedContinuation] = [] - - func waitForSyncStart() async { - if hasStarted { return } - await withCheckedContinuation { continuation in - startWaiters.append(continuation) - } - } + private var hasStarted = false + private var startWaiters: [CheckedContinuation] = [] - func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult { - hasStarted = true - while !startWaiters.isEmpty { - startWaiters.removeFirst().resume() - } + func waitForSyncStart() async { + if hasStarted { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } - while true { - try Task.checkCancellation() - try await Task.sleep(for: .milliseconds(50)) - } + func syncChannels(radioID: UUID, maxChannels: UInt8, usePipelinedRead: Bool) async throws -> ChannelSyncResult { + hasStarted = true + while !startWaiters.isEmpty { + startWaiters.removeFirst().resume() } - func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { - ChannelSyncResult(channelsSynced: 0, errors: []) + while true { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(50)) } + } + + func retryFailedChannels(radioID: UUID, indices: [UInt8]) async throws -> ChannelSyncResult { + ChannelSyncResult(channelsSynced: 0, errors: []) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTimestampTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTimestampTests.swift index b145fa6e..24631d48 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTimestampTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTimestampTests.swift @@ -1,601 +1,598 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("SyncCoordinator Timestamp Correction") struct SyncCoordinatorTimestampTests { + // MARK: - Test Constants - // MARK: - Test Constants - - private let oneMinute: TimeInterval = 60 - private let fiveMinutes: TimeInterval = 5 * 60 - private let sixMinutes: TimeInterval = 6 * 60 - private let oneWeek: TimeInterval = 7 * 24 * 60 * 60 - private let threeMonths: TimeInterval = 3 * 30 * 24 * 60 * 60 - private let sixMonths: TimeInterval = 6 * 30 * 24 * 60 * 60 - private let sevenMonths: TimeInterval = 7 * 30 * 24 * 60 * 60 + private let oneMinute: TimeInterval = 60 + private let fiveMinutes: TimeInterval = 5 * 60 + private let sixMinutes: TimeInterval = 6 * 60 + private let oneWeek: TimeInterval = 7 * 24 * 60 * 60 + private let threeMonths: TimeInterval = 3 * 30 * 24 * 60 * 60 + private let sixMonths: TimeInterval = 6 * 30 * 24 * 60 * 60 + private let sevenMonths: TimeInterval = 7 * 30 * 24 * 60 * 60 - // MARK: - Valid Range Tests + // MARK: - Valid Range Tests - @Test("Timestamp within valid range is not corrected") - func validTimestampNotCorrected() { - let now = Date() - let timestamp = UInt32(now.timeIntervalSince1970) + @Test + func `Timestamp within valid range is not corrected`() { + let now = Date() + let timestamp = UInt32(now.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - // MARK: - Future Timestamp Tests + // MARK: - Future Timestamp Tests - @Test("Timestamp 1 minute in future is not corrected") - func oneMinuteFutureNotCorrected() { - let now = Date() - let futureDate = now.addingTimeInterval(oneMinute) - let timestamp = UInt32(futureDate.timeIntervalSince1970) + @Test + func `Timestamp 1 minute in future is not corrected`() { + let now = Date() + let futureDate = now.addingTimeInterval(oneMinute) + let timestamp = UInt32(futureDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - @Test("Timestamp exactly 5 minutes in future is not corrected") - func exactlyFiveMinutesFutureNotCorrected() { - let now = Date() - let futureDate = now.addingTimeInterval(fiveMinutes) - let timestamp = UInt32(futureDate.timeIntervalSince1970) + @Test + func `Timestamp exactly 5 minutes in future is not corrected`() { + let now = Date() + let futureDate = now.addingTimeInterval(fiveMinutes) + let timestamp = UInt32(futureDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - @Test("Timestamp 6 minutes in future is corrected") - func sixMinutesFutureIsCorrected() { - let now = Date() - let futureDate = now.addingTimeInterval(sixMinutes) - let timestamp = UInt32(futureDate.timeIntervalSince1970) + @Test + func `Timestamp 6 minutes in future is corrected`() { + let now = Date() + let futureDate = now.addingTimeInterval(sixMinutes) + let timestamp = UInt32(futureDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - } + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + } - // MARK: - Past Timestamp Tests + // MARK: - Past Timestamp Tests - @Test("Timestamp 1 week ago is not corrected") - func oneWeekAgoNotCorrected() { - let now = Date() - let pastDate = now.addingTimeInterval(-oneWeek) - let timestamp = UInt32(pastDate.timeIntervalSince1970) + @Test + func `Timestamp 1 week ago is not corrected`() { + let now = Date() + let pastDate = now.addingTimeInterval(-oneWeek) + let timestamp = UInt32(pastDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - @Test("Timestamp 3 months ago is not corrected") - func threeMonthsAgoNotCorrected() { - let now = Date() - let pastDate = now.addingTimeInterval(-threeMonths) - let timestamp = UInt32(pastDate.timeIntervalSince1970) + @Test + func `Timestamp 3 months ago is not corrected`() { + let now = Date() + let pastDate = now.addingTimeInterval(-threeMonths) + let timestamp = UInt32(pastDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - @Test("Timestamp exactly 6 months in past is not corrected") - func exactlySixMonthsAgoNotCorrected() { - // Use whole-second receive time so UInt32 truncation doesn't push - // the timestamp past the boundary (fractional seconds are lost in UInt32). - let now = Date(timeIntervalSince1970: Double(Int(Date().timeIntervalSince1970))) - let pastDate = now.addingTimeInterval(-sixMonths) - let timestamp = UInt32(pastDate.timeIntervalSince1970) + @Test + func `Timestamp exactly 6 months in past is not corrected`() { + // Use whole-second receive time so UInt32 truncation doesn't push + // the timestamp past the boundary (fractional seconds are lost in UInt32). + let now = Date(timeIntervalSince1970: Double(Int(Date().timeIntervalSince1970))) + let pastDate = now.addingTimeInterval(-sixMonths) + let timestamp = UInt32(pastDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } + #expect(!wasCorrected) + #expect(corrected == timestamp) + } - @Test("Timestamp 7 months ago is corrected") - func sevenMonthsAgoIsCorrected() { - let now = Date() - let pastDate = now.addingTimeInterval(-sevenMonths) - let timestamp = UInt32(pastDate.timeIntervalSince1970) + @Test + func `Timestamp 7 months ago is corrected`() { + let now = Date() + let pastDate = now.addingTimeInterval(-sevenMonths) + let timestamp = UInt32(pastDate.timeIntervalSince1970) - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - } + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + } - // MARK: - Edge Case Tests + // MARK: - Edge Case Tests - @Test("Timestamp of zero (Unix epoch) is corrected") - func unixEpochIsCorrected() { - let now = Date() - let timestamp: UInt32 = 0 - - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + @Test + func `Timestamp of zero (Unix epoch) is corrected`() { + let now = Date() + let timestamp: UInt32 = 0 + + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - } - - @Test("Timestamp from year 2020 is corrected") - func year2020IsCorrected() { - let now = Date() - let oldDate = Date(timeIntervalSince1970: 1577836800) // Jan 1, 2020 - let timestamp = UInt32(oldDate.timeIntervalSince1970) - - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - } - - @Test("Timestamp from year 2030 is corrected") - func year2030IsCorrected() { - let now = Date() - let futureDate = Date(timeIntervalSince1970: 1893456000) // Jan 1, 2030 - let timestamp = UInt32(futureDate.timeIntervalSince1970) - - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) - - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - } - - // MARK: - Original Timestamp Preservation Tests - - @Test("Original timestamp is preserved when correction is applied") - func originalTimestampPreservedForCorrelation() { - // This test documents critical behavior: the original timestamp must be preserved - // for RxLogEntry correlation (per payloads.md:65 - ACK deduplication uses original timestamp) - let now = Date() - let brokenClockTimestamp: UInt32 = 0 // Unix epoch - clearly invalid - - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(brokenClockTimestamp, receiveTime: now) + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + } + + @Test + func `Timestamp from year 2020 is corrected`() { + let now = Date() + let oldDate = Date(timeIntervalSince1970: 1_577_836_800) // Jan 1, 2020 + let timestamp = UInt32(oldDate.timeIntervalSince1970) + + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + } + + @Test + func `Timestamp from year 2030 is corrected`() { + let now = Date() + let futureDate = Date(timeIntervalSince1970: 1_893_456_000) // Jan 1, 2030 + let timestamp = UInt32(futureDate.timeIntervalSince1970) + + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: now) + + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + } + + // MARK: - Original Timestamp Preservation Tests + + @Test + func `Original timestamp is preserved when correction is applied`() { + // This test documents critical behavior: the original timestamp must be preserved + // for RxLogEntry correlation (per payloads.md:65 - ACK deduplication uses original timestamp) + let now = Date() + let brokenClockTimestamp: UInt32 = 0 // Unix epoch - clearly invalid + + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(brokenClockTimestamp, receiveTime: now) - // Verify correction was applied - #expect(wasCorrected) - #expect(corrected == UInt32(now.timeIntervalSince1970)) - - // The original timestamp (0) is still available as the input parameter - // and should be used for RxLogEntry lookup, not the corrected value. - // This is verified by the fact that correctTimestampIfNeeded returns - // ONLY the corrected timestamp - the caller must preserve the original. - #expect(brokenClockTimestamp == 0) // Original unchanged - #expect(corrected != brokenClockTimestamp) // Different from original - } - - @Test("Corrected timestamp differs from original for invalid input") - func correctedTimestampDiffersFromOriginal() { - let now = Date() - let farFuture = now.addingTimeInterval(365 * 24 * 60 * 60) // 1 year in future - let originalTimestamp = UInt32(farFuture.timeIntervalSince1970) - - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(originalTimestamp, receiveTime: now) + // Verify correction was applied + #expect(wasCorrected) + #expect(corrected == UInt32(now.timeIntervalSince1970)) + + // The original timestamp (0) is still available as the input parameter + // and should be used for RxLogEntry lookup, not the corrected value. + // This is verified by the fact that correctTimestampIfNeeded returns + // ONLY the corrected timestamp - the caller must preserve the original. + #expect(brokenClockTimestamp == 0) // Original unchanged + #expect(corrected != brokenClockTimestamp) // Different from original + } + + @Test + func `Corrected timestamp differs from original for invalid input`() { + let now = Date() + let farFuture = now.addingTimeInterval(365 * 24 * 60 * 60) // 1 year in future + let originalTimestamp = UInt32(farFuture.timeIntervalSince1970) + + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(originalTimestamp, receiveTime: now) - #expect(wasCorrected) - // The corrected timestamp should be the receive time, not the invalid original - #expect(corrected == UInt32(now.timeIntervalSince1970)) - // Original and corrected must be different (caller uses original for RxLogEntry lookup) - #expect(corrected != originalTimestamp) - } - - // MARK: - Underflow Prevention Tests - - @Test("Receive time near Unix epoch does not crash") - func nearEpochReceiveTimeDoesNotCrash() { - // Device clock set to early 1970 - would previously cause UInt32 underflow crash - let nearEpoch = Date(timeIntervalSince1970: 1000) // ~16 minutes after Unix epoch - let timestamp: UInt32 = 500 - - // This should not crash - the fix uses TimeInterval arithmetic instead of UInt32 - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: nearEpoch) - - // Timestamp is within range (500 is less than 6 months before 1000) - #expect(!wasCorrected) - #expect(corrected == timestamp) - } - - @Test("Receive time at Unix epoch handles timestamp validation") - func epochReceiveTimeHandlesValidation() { - let epoch = Date(timeIntervalSince1970: 0) - let timestamp: UInt32 = 1_000_000 // ~11 days after epoch + #expect(wasCorrected) + // The corrected timestamp should be the receive time, not the invalid original + #expect(corrected == UInt32(now.timeIntervalSince1970)) + // Original and corrected must be different (caller uses original for RxLogEntry lookup) + #expect(corrected != originalTimestamp) + } + + // MARK: - Underflow Prevention Tests + + @Test + func `Receive time near Unix epoch does not crash`() { + // Device clock set to early 1970 - would previously cause UInt32 underflow crash + let nearEpoch = Date(timeIntervalSince1970: 1000) // ~16 minutes after Unix epoch + let timestamp: UInt32 = 500 + + // This should not crash - the fix uses TimeInterval arithmetic instead of UInt32 + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: nearEpoch) + + // Timestamp is within range (500 is less than 6 months before 1000) + #expect(!wasCorrected) + #expect(corrected == timestamp) + } + + @Test + func `Receive time at Unix epoch handles timestamp validation`() { + let epoch = Date(timeIntervalSince1970: 0) + let timestamp: UInt32 = 1_000_000 // ~11 days after epoch - let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: epoch) - - // Timestamp is too far in the future from epoch perspective (> 5 minutes) - #expect(wasCorrected) - #expect(corrected == 0) - } + let (corrected, wasCorrected) = SyncCoordinator.correctTimestampIfNeeded(timestamp, receiveTime: epoch) + + // Timestamp is too far in the future from epoch perspective (> 5 minutes) + #expect(wasCorrected) + #expect(corrected == 0) + } } // MARK: - Sort Date Derivation Tests @Suite("SyncCoordinator Sort Date Derivation") struct SyncCoordinatorSortDateTests { + // MARK: - Test Constants - // MARK: - Test Constants + private let oneMinute: TimeInterval = 60 - private let oneMinute: TimeInterval = 60 + // MARK: - Live Delivery Tests - // MARK: - Live Delivery Tests + @Test + func `Live message sorts by receive time`() { + let now = Date() - @Test("Live message sorts by receive time") - func liveUsesReceiveTime() { - let now = Date() + let result = SyncCoordinator.sortDate(for: .live, receiveTime: now) - let result = SyncCoordinator.sortDate(for: .live, receiveTime: now) + #expect(result == now) + } - #expect(result == now) - } + // MARK: - Backlog Delivery Tests - // MARK: - Backlog Delivery Tests + @Test + func `Backlog message sorts by the drain anchor, not its receive time`() { + let anchor = Date().addingTimeInterval(-oneMinute) + let receiveTime = Date() - @Test("Backlog message sorts by the drain anchor, not its receive time") - func initialSyncUsesAnchor() { - let anchor = Date().addingTimeInterval(-oneMinute) - let receiveTime = Date() + let result = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: receiveTime) - let result = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: receiveTime) + // The anchor is the block's delivery-time position; the per-message + // receive time is ignored so the whole drain stays contiguous. + #expect(result == anchor) + } - // The anchor is the block's delivery-time position; the per-message - // receive time is ignored so the whole drain stays contiguous. - #expect(result == anchor) - } + @Test + func `Every message in one drain shares the anchor as its sort date`() { + let anchor = Date() - @Test("Every message in one drain shares the anchor as its sort date") - func initialSyncBlockSharesAnchor() { - let anchor = Date() + let first = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date()) + let second = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date().addingTimeInterval(oneMinute)) + let third = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date().addingTimeInterval(2 * oneMinute)) - let first = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date()) - let second = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date().addingTimeInterval(oneMinute)) - let third = SyncCoordinator.sortDate(for: .initialSync(anchor: anchor), receiveTime: Date().addingTimeInterval(2 * oneMinute)) + #expect(first == anchor) + #expect(second == anchor) + #expect(third == anchor) + } - #expect(first == anchor) - #expect(second == anchor) - #expect(third == anchor) - } + @Test + func `Distinct drains sort as distinct blocks in delivery order`() { + let earlierDrain = Date().addingTimeInterval(-oneMinute) + let laterDrain = Date() - @Test("Distinct drains sort as distinct blocks in delivery order") - func distinctDrainsSortAsDistinctBlocks() { - let earlierDrain = Date().addingTimeInterval(-oneMinute) - let laterDrain = Date() + let earlier = SyncCoordinator.sortDate(for: .initialSync(anchor: earlierDrain), receiveTime: Date()) + let later = SyncCoordinator.sortDate(for: .initialSync(anchor: laterDrain), receiveTime: Date()) - let earlier = SyncCoordinator.sortDate(for: .initialSync(anchor: earlierDrain), receiveTime: Date()) - let later = SyncCoordinator.sortDate(for: .initialSync(anchor: laterDrain), receiveTime: Date()) - - #expect(earlier == earlierDrain) - #expect(later == laterDrain) - #expect(earlier < later) - } + #expect(earlier == earlierDrain) + #expect(later == laterDrain) + #expect(earlier < later) + } } // MARK: - Same-Sender Reordering Tests @Suite("Same-Sender Reordering") struct SameSenderReorderingTests { - - private func makeDMMessage( - timestamp: UInt32, - createdAt: Date, - sortDate: Date? = nil, - direction: MessageDirection = .incoming - ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: UUID(), - contactID: UUID(), - channelIndex: nil, - text: "msg-\(timestamp)", - timestamp: timestamp, - createdAt: createdAt, - sortDate: sortDate, - direction: direction, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - } - - private func makeChannelMessage( - timestamp: UInt32, - createdAt: Date, - senderName: String? = nil, - direction: MessageDirection = .incoming - ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: UUID(), - contactID: nil, - channelIndex: 0, - text: "msg-\(timestamp)", - timestamp: timestamp, - createdAt: createdAt, - direction: direction, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: senderName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) - } - - @Test("Empty array returns empty") - func emptyArray() { - let result = MessageDTO.reorderSameSenderClusters([]) - #expect(result.isEmpty) - } - - @Test("Single message returns unchanged") - func singleMessage() { - let msg = makeDMMessage(timestamp: 100, createdAt: Date()) - let result = MessageDTO.reorderSameSenderClusters([msg]) - #expect(result.count == 1) - #expect(result[0].id == msg.id) - } - - @Test("DM messages within 5 seconds are reordered by sender timestamp") - func dmReorderWithinWindow() { - let base = Date() - // Messages arrived out of order: msg2 arrived first, then msg1 - let msg1 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) - let msg2 = makeDMMessage(timestamp: 200, createdAt: base) - - // Sorted by createdAt: [msg2(t=200), msg1(t=100)] - let input = [msg2, msg1] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Should reorder by sender timestamp: [msg1(t=100), msg2(t=200)] - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } - - @Test("Outgoing DM messages within 5 seconds are reordered by sender timestamp") - func outgoingDMReorderWithinWindow() { - let base = Date() - let msg1 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2), direction: .outgoing) - let msg2 = makeDMMessage(timestamp: 200, createdAt: base, direction: .outgoing) - - let input = [msg2, msg1] - let result = MessageDTO.reorderSameSenderClusters(input) - - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } - - @Test("DM messages beyond 5 seconds are not reordered") - func dmNoReorderBeyondWindow() { - let base = Date() - let msg1 = makeDMMessage(timestamp: 100, createdAt: base) - let msg2 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(6)) - - let input = [msg1, msg2] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Beyond window — stays in createdAt order - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } - - @Test("Channel messages from different senders are not clustered") - func channelDifferentSendersNotClustered() { - let base = Date() - // Alice sends at t=200, Bob sends at t=100, both arrive within 2 seconds - let alice = makeChannelMessage(timestamp: 200, createdAt: base, senderName: "Alice") - let bob = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(2), senderName: "Bob") - - let input = [alice, bob] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Different senders — no reordering - #expect(result[0].senderNodeName == "Alice") - #expect(result[1].senderNodeName == "Bob") - } - - @Test("Channel messages from same sender within window are reordered") - func channelSameSenderReordered() { - let base = Date() - let msg1 = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(3), senderName: "Alice") - let msg2 = makeChannelMessage(timestamp: 200, createdAt: base, senderName: "Alice") - - // createdAt order: [msg2(t=200), msg1(t=100)] - let input = [msg2, msg1] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Same sender within window — reordered by timestamp - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } - - @Test("Mixed directions break clusters") - func mixedDirectionsBreakCluster() { - let base = Date() - let incoming = makeDMMessage(timestamp: 200, createdAt: base, direction: .incoming) - let outgoing = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(1), direction: .outgoing) - - let input = [incoming, outgoing] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Different directions — no reordering - #expect(result[0].direction == .incoming) - #expect(result[1].direction == .outgoing) - } - - @Test("Three messages in cluster are fully sorted") - func threeMessageCluster() { - let base = Date() - // Arrived in reverse order within 4 seconds - let msg1 = makeDMMessage(timestamp: 300, createdAt: base) - let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) - let msg3 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(4)) - - let input = [msg1, msg2, msg3] - let result = MessageDTO.reorderSameSenderClusters(input) - - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - #expect(result[2].timestamp == 300) - } - - @Test("Exactly 5 second gap is included in cluster") - func exactlyFiveSecondGap() { - let base = Date() - let msg1 = makeDMMessage(timestamp: 200, createdAt: base) - let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(5)) - - let input = [msg1, msg2] - let result = MessageDTO.reorderSameSenderClusters(input) - - // 5 seconds is within window (<=5) - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } - - @Test("Multiple consecutive clusters are each reordered independently") - func multipleConsecutiveClusters() { - let base = Date() - // Cluster 1: two messages within 3s, out of timestamp order - let c1a = makeDMMessage(timestamp: 200, createdAt: base) - let c1b = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(3)) - - // Gap of 10 seconds separates the clusters - // Cluster 2: two messages within 2s, out of timestamp order - let c2a = makeDMMessage(timestamp: 400, createdAt: base.addingTimeInterval(13)) - let c2b = makeDMMessage(timestamp: 300, createdAt: base.addingTimeInterval(15)) - - let input = [c1a, c1b, c2a, c2b] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Cluster 1 reordered by timestamp - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - // Cluster 2 reordered by timestamp - #expect(result[2].timestamp == 300) - #expect(result[3].timestamp == 400) - } - - @Test("Channel messages with nil sender names are not clustered") - func nilSenderNamesNotClustered() { - let base = Date() - let msg1 = makeChannelMessage(timestamp: 200, createdAt: base) - let msg2 = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) - - let result = MessageDTO.reorderSameSenderClusters([msg1, msg2]) - - // Nil senders should NOT cluster — stays in createdAt order - #expect(result[0].timestamp == 200) - #expect(result[1].timestamp == 100) - } - - @Test("Same-sender messages with identical timestamps use createdAt as tiebreaker") - func identicalTimestampsUsesCreatedAtTiebreaker() { - let base = Date() - let msg1 = makeDMMessage(timestamp: 100, createdAt: base) - let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(0.5)) - - let input = [msg2, msg1] // reverse createdAt order - let result = MessageDTO.reorderSameSenderClusters(input) - - #expect(result[0].id == msg1.id) - #expect(result[1].id == msg2.id) - } - - @Test("Messages already in correct order are unchanged") - func alreadyCorrectOrder() { - let base = Date() - let msg1 = makeDMMessage(timestamp: 100, createdAt: base) - let msg2 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(1)) - let msg3 = makeDMMessage(timestamp: 300, createdAt: base.addingTimeInterval(2)) - - let input = [msg1, msg2, msg3] - let result = MessageDTO.reorderSameSenderClusters(input) - - #expect(result[0].id == msg1.id) - #expect(result[1].id == msg2.id) - #expect(result[2].id == msg3.id) - } - - @Test("Far-apart sortDates are not clustered even when createdAt order is inverted") - func nonMonotonicCreatedAtDoesNotWidenWindow() { - let base = Date() - // Sorted by sortDate ascending, but createdAt runs backwards: a backlog row - // sent early yet drained an hour later, followed by a later-sent row received - // first. The createdAt gap is hugely negative, which the old createdAt-based - // window silently accepted (any negative gap is within the window) and - // clustered, scrambling send order. - let early = makeDMMessage( - timestamp: 200, - createdAt: base.addingTimeInterval(3600), - sortDate: base - ) - let late = makeDMMessage( - timestamp: 100, - createdAt: base, - sortDate: base.addingTimeInterval(60) - ) - - let input = [early, late] - let result = MessageDTO.reorderSameSenderClusters(input) - - // sortDate gap is 60s (> window): the two rows must stay in sortDate order, - // not be reordered by raw timestamp. - #expect(result[0].id == early.id) - #expect(result[1].id == late.id) - } - - @Test("Clustering window follows sortDate, not createdAt") - func clusterWindowMeasuredOnSortDate() { - let base = Date() - // sortDates are 2s apart (within window) but createdAt are 100s apart (beyond - // the old window). Reordering must follow the sortDate axis the array is sorted - // by, so the tight send-time cluster is reordered by sender timestamp. - let first = makeDMMessage( - timestamp: 200, - createdAt: base, - sortDate: base - ) - let second = makeDMMessage( - timestamp: 100, - createdAt: base.addingTimeInterval(100), - sortDate: base.addingTimeInterval(2) - ) - - let input = [first, second] - let result = MessageDTO.reorderSameSenderClusters(input) - - // Within the sortDate window: reordered by sender timestamp. - #expect(result[0].timestamp == 100) - #expect(result[1].timestamp == 200) - } + private func makeDMMessage( + timestamp: UInt32, + createdAt: Date, + sortDate: Date? = nil, + direction: MessageDirection = .incoming + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + channelIndex: nil, + text: "msg-\(timestamp)", + timestamp: timestamp, + createdAt: createdAt, + sortDate: sortDate, + direction: direction, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + private func makeChannelMessage( + timestamp: UInt32, + createdAt: Date, + senderName: String? = nil, + direction: MessageDirection = .incoming + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: nil, + channelIndex: 0, + text: "msg-\(timestamp)", + timestamp: timestamp, + createdAt: createdAt, + direction: direction, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + @Test + func `Empty array returns empty`() { + let result = MessageDTO.reorderSameSenderClusters([]) + #expect(result.isEmpty) + } + + @Test + func `Single message returns unchanged`() { + let msg = makeDMMessage(timestamp: 100, createdAt: Date()) + let result = MessageDTO.reorderSameSenderClusters([msg]) + #expect(result.count == 1) + #expect(result[0].id == msg.id) + } + + @Test + func `DM messages within 5 seconds are reordered by sender timestamp`() { + let base = Date() + // Messages arrived out of order: msg2 arrived first, then msg1 + let msg1 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base) + + // Sorted by createdAt: [msg2(t=200), msg1(t=100)] + let input = [msg2, msg1] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Should reorder by sender timestamp: [msg1(t=100), msg2(t=200)] + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test + func `Outgoing DM messages within 5 seconds are reordered by sender timestamp`() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2), direction: .outgoing) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base, direction: .outgoing) + + let input = [msg2, msg1] + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test + func `DM messages beyond 5 seconds are not reordered`() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(6)) + + let input = [msg1, msg2] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Beyond window — stays in createdAt order + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test + func `Channel messages from different senders are not clustered`() { + let base = Date() + // Alice sends at t=200, Bob sends at t=100, both arrive within 2 seconds + let alice = makeChannelMessage(timestamp: 200, createdAt: base, senderName: "Alice") + let bob = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(2), senderName: "Bob") + + let input = [alice, bob] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Different senders — no reordering + #expect(result[0].senderNodeName == "Alice") + #expect(result[1].senderNodeName == "Bob") + } + + @Test + func `Channel messages from same sender within window are reordered`() { + let base = Date() + let msg1 = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(3), senderName: "Alice") + let msg2 = makeChannelMessage(timestamp: 200, createdAt: base, senderName: "Alice") + + // createdAt order: [msg2(t=200), msg1(t=100)] + let input = [msg2, msg1] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Same sender within window — reordered by timestamp + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test + func `Mixed directions break clusters`() { + let base = Date() + let incoming = makeDMMessage(timestamp: 200, createdAt: base, direction: .incoming) + let outgoing = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(1), direction: .outgoing) + + let input = [incoming, outgoing] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Different directions — no reordering + #expect(result[0].direction == .incoming) + #expect(result[1].direction == .outgoing) + } + + @Test + func `Three messages in cluster are fully sorted`() { + let base = Date() + // Arrived in reverse order within 4 seconds + let msg1 = makeDMMessage(timestamp: 300, createdAt: base) + let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) + let msg3 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(4)) + + let input = [msg1, msg2, msg3] + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + #expect(result[2].timestamp == 300) + } + + @Test + func `Exactly 5 second gap is included in cluster`() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 200, createdAt: base) + let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(5)) + + let input = [msg1, msg2] + let result = MessageDTO.reorderSameSenderClusters(input) + + // 5 seconds is within window (<=5) + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test + func `Multiple consecutive clusters are each reordered independently`() { + let base = Date() + // Cluster 1: two messages within 3s, out of timestamp order + let c1a = makeDMMessage(timestamp: 200, createdAt: base) + let c1b = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(3)) + + // Gap of 10 seconds separates the clusters + // Cluster 2: two messages within 2s, out of timestamp order + let c2a = makeDMMessage(timestamp: 400, createdAt: base.addingTimeInterval(13)) + let c2b = makeDMMessage(timestamp: 300, createdAt: base.addingTimeInterval(15)) + + let input = [c1a, c1b, c2a, c2b] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Cluster 1 reordered by timestamp + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + // Cluster 2 reordered by timestamp + #expect(result[2].timestamp == 300) + #expect(result[3].timestamp == 400) + } + + @Test + func `Channel messages with nil sender names are not clustered`() { + let base = Date() + let msg1 = makeChannelMessage(timestamp: 200, createdAt: base) + let msg2 = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) + + let result = MessageDTO.reorderSameSenderClusters([msg1, msg2]) + + // Nil senders should NOT cluster — stays in createdAt order + #expect(result[0].timestamp == 200) + #expect(result[1].timestamp == 100) + } + + @Test + func `Same-sender messages with identical timestamps use createdAt as tiebreaker`() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base) + let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(0.5)) + + let input = [msg2, msg1] // reverse createdAt order + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].id == msg1.id) + #expect(result[1].id == msg2.id) + } + + @Test + func `Messages already in correct order are unchanged`() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(1)) + let msg3 = makeDMMessage(timestamp: 300, createdAt: base.addingTimeInterval(2)) + + let input = [msg1, msg2, msg3] + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].id == msg1.id) + #expect(result[1].id == msg2.id) + #expect(result[2].id == msg3.id) + } + + @Test + func `Far-apart sortDates are not clustered even when createdAt order is inverted`() { + let base = Date() + // Sorted by sortDate ascending, but createdAt runs backwards: a backlog row + // sent early yet drained an hour later, followed by a later-sent row received + // first. The createdAt gap is hugely negative, which the old createdAt-based + // window silently accepted (any negative gap is within the window) and + // clustered, scrambling send order. + let early = makeDMMessage( + timestamp: 200, + createdAt: base.addingTimeInterval(3600), + sortDate: base + ) + let late = makeDMMessage( + timestamp: 100, + createdAt: base, + sortDate: base.addingTimeInterval(60) + ) + + let input = [early, late] + let result = MessageDTO.reorderSameSenderClusters(input) + + // sortDate gap is 60s (> window): the two rows must stay in sortDate order, + // not be reordered by raw timestamp. + #expect(result[0].id == early.id) + #expect(result[1].id == late.id) + } + + @Test + func `Clustering window follows sortDate, not createdAt`() { + let base = Date() + // sortDates are 2s apart (within window) but createdAt are 100s apart (beyond + // the old window). Reordering must follow the sortDate axis the array is sorted + // by, so the tight send-time cluster is reordered by sender timestamp. + let first = makeDMMessage( + timestamp: 200, + createdAt: base, + sortDate: base + ) + let second = makeDMMessage( + timestamp: 100, + createdAt: base.addingTimeInterval(100), + sortDate: base.addingTimeInterval(2) + ) + + let input = [first, second] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Within the sortDate window: reordered by sender timestamp. + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEPhaseTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEPhaseTests.swift index d363ec1f..611a8ada 100644 --- a/MC1Services/Tests/MC1ServicesTests/Transport/BLEPhaseTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEPhaseTests.swift @@ -1,50 +1,49 @@ -import Testing @testable import MC1Services +import Testing @Suite("BLEPhase Tests") struct BLEPhaseTests { + // MARK: - Name Tests - // MARK: - Name Tests - - @Test("idle phase has correct name") - func idlePhaseHasCorrectName() { - let phase = BLEPhase.idle - #expect(phase.name == "idle") - } + @Test + func `idle phase has correct name`() { + let phase = BLEPhase.idle + #expect(phase.name == "idle") + } - // MARK: - isDiscoveryChain Tests + // MARK: - isDiscoveryChain Tests - @Test("idle is not part of discovery chain") - func idleIsNotDiscoveryChain() { - #expect(BLEPhase.idle.isDiscoveryChain == false) - } + @Test + func `idle is not part of discovery chain`() { + #expect(BLEPhase.idle.isDiscoveryChain == false) + } - // Note: discoveringServices, discoveringCharacteristics, and subscribingToNotifications - // require CBPeripheral instances which can't be created in unit tests. - // Their isDiscoveryChain == true is verified implicitly through integration tests - // and the switch statement exhaustiveness check. + // Note: discoveringServices, discoveringCharacteristics, and subscribingToNotifications + // require CBPeripheral instances which can't be created in unit tests. + // Their isDiscoveryChain == true is verified implicitly through integration tests + // and the switch statement exhaustiveness check. - // MARK: - isActive Tests + // MARK: - isActive Tests - @Test("idle phase is not active") - func idlePhaseIsNotActive() { - let phase = BLEPhase.idle - #expect(phase.isActive == false) - } + @Test + func `idle phase is not active`() { + let phase = BLEPhase.idle + #expect(phase.isActive == false) + } - // MARK: - Peripheral Tests + // MARK: - Peripheral Tests - @Test("idle phase has no peripheral") - func idlePhaseHasNoPeripheral() { - let phase = BLEPhase.idle - #expect(phase.peripheral == nil) - } + @Test + func `idle phase has no peripheral`() { + let phase = BLEPhase.idle + #expect(phase.peripheral == nil) + } - // MARK: - DeviceID Tests + // MARK: - DeviceID Tests - @Test("idle phase has no deviceID") - func idlePhaseHasNoDeviceID() { - let phase = BLEPhase.idle - #expect(phase.deviceID == nil) - } + @Test + func `idle phase has no deviceID`() { + let phase = BLEPhase.idle + #expect(phase.deviceID == nil) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineTests.swift index e262092a..2ec4c314 100644 --- a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineTests.swift @@ -1,274 +1,273 @@ import CoreBluetooth import Foundation -import Testing @testable import MC1Services +import Testing @Suite("BLEStateMachine Tests") struct BLEStateMachineTests { + // MARK: - Initial State Tests - // MARK: - Initial State Tests - - @Test("initializes in idle phase") - func initializesInIdlePhase() async { - let sm = BLEStateMachine() - let phase = await sm.currentPhase - #expect(phase.name == "idle") - } - - @Test("isConnected returns false when idle") - func isConnectedReturnsFalseWhenIdle() async { - let sm = BLEStateMachine() - let connected = await sm.isConnected - #expect(connected == false) - } - - @Test("connectedDeviceID returns nil when idle") - func connectedDeviceIDReturnsNilWhenIdle() async { - let sm = BLEStateMachine() - let deviceID = await sm.connectedDeviceID - #expect(deviceID == nil) - } - - @Test("isAutoReconnecting returns false when idle") - func isAutoReconnectingReturnsFalseWhenIdle() async { - let sm = BLEStateMachine() - let reconnecting = await sm.isAutoReconnecting - #expect(reconnecting == false) - } - - @Test("currentPhaseName returns idle when idle") - func currentPhaseNameReturnsIdleWhenIdle() async { - let sm = BLEStateMachine() - let name = await sm.currentPhaseName - #expect(name == "idle") - } - - // MARK: - Handler Registration Tests - - @Test("setDisconnectionHandler can be registered") - func setDisconnectionHandlerCanBeRegistered() async { - let sm = BLEStateMachine() + @Test + func `initializes in idle phase`() async { + let sm = BLEStateMachine() + let phase = await sm.currentPhase + #expect(phase.name == "idle") + } - await sm.setDisconnectionHandler { _, _ in } + @Test + func `isConnected returns false when idle`() async { + let sm = BLEStateMachine() + let connected = await sm.isConnected + #expect(connected == false) + } - #expect(await sm.currentPhase.name == "idle") - } - - @Test("setReconnectionHandler can be registered") - func setReconnectionHandlerCanBeRegistered() async { - let sm = BLEStateMachine() - - await sm.setReconnectionHandler { _, _ in } - - #expect(await sm.currentPhase.name == "idle") - } - - @Test("setBluetoothStateChangeHandler can be registered") - func setBluetoothStateChangeHandlerCanBeRegistered() async { - let sm = BLEStateMachine() - - await sm.setBluetoothStateChangeHandler { _ in } + @Test + func `connectedDeviceID returns nil when idle`() async { + let sm = BLEStateMachine() + let deviceID = await sm.connectedDeviceID + #expect(deviceID == nil) + } - #expect(await sm.currentPhase.name == "idle") - } + @Test + func `isAutoReconnecting returns false when idle`() async { + let sm = BLEStateMachine() + let reconnecting = await sm.isAutoReconnecting + #expect(reconnecting == false) + } - @Test("setAutoReconnectingHandler can be registered") - func setAutoReconnectingHandlerCanBeRegistered() async { - let sm = BLEStateMachine() + @Test + func `currentPhaseName returns idle when idle`() async { + let sm = BLEStateMachine() + let name = await sm.currentPhaseName + #expect(name == "idle") + } - await sm.setAutoReconnectingHandler { _, _ in } + // MARK: - Handler Registration Tests - #expect(await sm.currentPhase.name == "idle") - } + @Test + func `setDisconnectionHandler can be registered`() async { + let sm = BLEStateMachine() - // MARK: - Disconnect Tests + await sm.setDisconnectionHandler { _, _ in } - @Test("disconnect returns immediately when idle") - func disconnectReturnsImmediatelyWhenIdle() async { - let sm = BLEStateMachine() + #expect(await sm.currentPhase.name == "idle") + } - await sm.disconnect() + @Test + func `setReconnectionHandler can be registered`() async { + let sm = BLEStateMachine() - #expect(await sm.currentPhase.name == "idle") - #expect(await sm.isConnected == false) - } + await sm.setReconnectionHandler { _, _ in } - // MARK: - Connection Error Tests + #expect(await sm.currentPhase.name == "idle") + } - @Test("connect throws appropriate error for unknown UUID") - func connectThrowsAppropriateErrorForUnknownUUID() async throws { - let sm = BLEStateMachine() - let unknownID = UUID() + @Test + func `setBluetoothStateChangeHandler can be registered`() async { + let sm = BLEStateMachine() - await sm.activate() + await sm.setBluetoothStateChangeHandler { _ in } - await #expect(throws: BLEError.self) { - _ = try await sm.connect(to: unknownID) - } - } + #expect(await sm.currentPhase.name == "idle") + } - @Test("send throws notConnected when idle") - func sendThrowsNotConnectedWhenIdle() async throws { - let sm = BLEStateMachine() - let testData = Data([0x01, 0x02, 0x03]) - - do { - try await sm.send(testData) - Issue.record("Expected notConnected error") - } catch let error as BLEError { - if case .notConnected = error { - // Expected - } else { - Issue.record("Expected notConnected error, got \(error)") - } - } - } + @Test + func `setAutoReconnectingHandler can be registered`() async { + let sm = BLEStateMachine() - // MARK: - Idempotency Tests + await sm.setAutoReconnectingHandler { _, _ in } - @Test("disconnect is idempotent") - func disconnectIsIdempotent() async { - let sm = BLEStateMachine() + #expect(await sm.currentPhase.name == "idle") + } - // Multiple disconnects should not crash - await sm.disconnect() - await sm.disconnect() - await sm.disconnect() + // MARK: - Disconnect Tests - #expect(await sm.currentPhase.name == "idle") - } + @Test + func `disconnect returns immediately when idle`() async { + let sm = BLEStateMachine() - @Test("activate is idempotent") - func activateIsIdempotent() async { - let sm = BLEStateMachine() + await sm.disconnect() - // Multiple activations should not crash or create duplicate managers - await sm.activate() - await sm.activate() - await sm.activate() + #expect(await sm.currentPhase.name == "idle") + #expect(await sm.isConnected == false) + } - #expect(await sm.currentPhase.name == "idle") - } + // MARK: - Connection Error Tests - @Test("handler replacement works correctly") - func handlerReplacementWorksCorrectly() async { - let sm = BLEStateMachine() + @Test + func `connect throws appropriate error for unknown UUID`() async throws { + let sm = BLEStateMachine() + let unknownID = UUID() - await sm.setDisconnectionHandler { _, _ in } - await sm.setDisconnectionHandler { _, _ in } + await sm.activate() - // Multiple handler registrations should not crash - #expect(await sm.currentPhase.name == "idle") + await #expect(throws: BLEError.self) { + _ = try await sm.connect(to: unknownID) } - - // MARK: - Connection Generation Tests - - @Test("connection generation starts at zero") - func connectionGenerationStartsAtZero() async { - let sm = BLEStateMachine() - let generation = await sm.currentConnectionGeneration - #expect(generation == 0) - } - - @Test("disconnect callback from previous generation is rejected") - func disconnectCallbackFromPreviousGenerationIsRejected() { - let timestamp: CFAbsoluteTime = 98 - let generationStart: CFAbsoluteTime = 101 - - let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( - timestamp: timestamp, - generationStart: generationStart - ) - - #expect(isStale) // 98 + 1.0 = 99 < 101 → stale - } - - @Test("disconnect callback at tolerance boundary is accepted") - func disconnectCallbackAtToleranceBoundaryIsAccepted() { - let generationStart: CFAbsoluteTime = 200 - let timestamp = generationStart - 1.0 - - let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( - timestamp: timestamp, - generationStart: generationStart - ) - - #expect(!isStale) // 199 + 1.0 = 200, not < 200 → accepted - } - - @Test("disconnect callback beyond tolerance is rejected") - func disconnectCallbackBeyondToleranceIsRejected() { - let generationStart: CFAbsoluteTime = 200 - let timestamp = generationStart - 1.5 - - let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( - timestamp: timestamp, - generationStart: generationStart - ) - - #expect(isStale) // 198.5 + 1.0 = 199.5 < 200 → stale - } - - // MARK: - Discovery Timeout Extension Tests - - @Test("extend predicate allows extension while connected and within budget") - func extendPredicateAllowsExtensionWhileConnected() { - let shouldExtend = BLEStateMachine.shouldExtendDiscoveryTimeout( - peripheralState: .connected, - extensions: 0, - maxExtensions: BLEStateMachine.maxDiscoveryTimeoutExtensions - ) - - #expect(shouldExtend) // link is up; a didConnect/discovery callback is in flight - } - - @Test("extend predicate rejects extension when peripheral is not connected") - func extendPredicateRejectsExtensionWhenNotConnected() { - for state in [CBPeripheralState.connecting, .disconnected, .disconnecting] { - let shouldExtend = BLEStateMachine.shouldExtendDiscoveryTimeout( - peripheralState: state, - extensions: 0, - maxExtensions: BLEStateMachine.maxDiscoveryTimeoutExtensions - ) - - #expect(!shouldExtend, "state \(state.rawValue) should tear down, not extend") - } - } - - @Test("extend predicate rejects extension once the budget is spent") - func extendPredicateRejectsExtensionAtBudget() { - let max = BLEStateMachine.maxDiscoveryTimeoutExtensions - - #expect(BLEStateMachine.shouldExtendDiscoveryTimeout(peripheralState: .connected, extensions: max - 1, maxExtensions: max)) - #expect(!BLEStateMachine.shouldExtendDiscoveryTimeout(peripheralState: .connected, extensions: max, maxExtensions: max)) - } - - @Test("advancing the connection generation resets the discovery-extension budget") - func advanceConnectionGenerationResetsDiscoveryExtensions() async { - let sm = BLEStateMachine() - await sm.recordDiscoveryTimeoutExtension() - await sm.recordDiscoveryTimeoutExtension() - #expect(await sm.currentDiscoveryTimeoutExtensions == BLEStateMachine.maxDiscoveryTimeoutExtensions) - - await sm.advanceConnectionGeneration() - - #expect(await sm.currentDiscoveryTimeoutExtensions == 0) + } + + @Test + func `send throws notConnected when idle`() async throws { + let sm = BLEStateMachine() + let testData = Data([0x01, 0x02, 0x03]) + + do { + try await sm.send(testData) + Issue.record("Expected notConnected error") + } catch let error as BLEError { + if case .notConnected = error { + // Expected + } else { + Issue.record("Expected notConnected error, got \(error)") + } } + } - @Test("disconnect callback is accepted when timestamp is at or after generation start") - func disconnectCallbackAtOrAfterGenerationStartIsAccepted() { - let generationStart: CFAbsoluteTime = 500 - - let atStart = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( - timestamp: generationStart, - generationStart: generationStart - ) - let afterStart = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( - timestamp: generationStart + 300, - generationStart: generationStart - ) - - #expect(!atStart) - #expect(!afterStart) + // MARK: - Idempotency Tests + + @Test + func `disconnect is idempotent`() async { + let sm = BLEStateMachine() + + // Multiple disconnects should not crash + await sm.disconnect() + await sm.disconnect() + await sm.disconnect() + + #expect(await sm.currentPhase.name == "idle") + } + + @Test + func `activate is idempotent`() async { + let sm = BLEStateMachine() + + // Multiple activations should not crash or create duplicate managers + await sm.activate() + await sm.activate() + await sm.activate() + + #expect(await sm.currentPhase.name == "idle") + } + + @Test + func `handler replacement works correctly`() async { + let sm = BLEStateMachine() + + await sm.setDisconnectionHandler { _, _ in } + await sm.setDisconnectionHandler { _, _ in } + + // Multiple handler registrations should not crash + #expect(await sm.currentPhase.name == "idle") + } + + // MARK: - Connection Generation Tests + + @Test + func `connection generation starts at zero`() async { + let sm = BLEStateMachine() + let generation = await sm.currentConnectionGeneration + #expect(generation == 0) + } + + @Test + func `disconnect callback from previous generation is rejected`() { + let timestamp: CFAbsoluteTime = 98 + let generationStart: CFAbsoluteTime = 101 + + let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( + timestamp: timestamp, + generationStart: generationStart + ) + + #expect(isStale) // 98 + 1.0 = 99 < 101 → stale + } + + @Test + func `disconnect callback at tolerance boundary is accepted`() { + let generationStart: CFAbsoluteTime = 200 + let timestamp = generationStart - 1.0 + + let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( + timestamp: timestamp, + generationStart: generationStart + ) + + #expect(!isStale) // 199 + 1.0 = 200, not < 200 → accepted + } + + @Test + func `disconnect callback beyond tolerance is rejected`() { + let generationStart: CFAbsoluteTime = 200 + let timestamp = generationStart - 1.5 + + let isStale = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( + timestamp: timestamp, + generationStart: generationStart + ) + + #expect(isStale) // 198.5 + 1.0 = 199.5 < 200 → stale + } + + // MARK: - Discovery Timeout Extension Tests + + @Test + func `extend predicate allows extension while connected and within budget`() { + let shouldExtend = BLEStateMachine.shouldExtendDiscoveryTimeout( + peripheralState: .connected, + extensions: 0, + maxExtensions: BLEStateMachine.maxDiscoveryTimeoutExtensions + ) + + #expect(shouldExtend) // link is up; a didConnect/discovery callback is in flight + } + + @Test + func `extend predicate rejects extension when peripheral is not connected`() { + for state in [CBPeripheralState.connecting, .disconnected, .disconnecting] { + let shouldExtend = BLEStateMachine.shouldExtendDiscoveryTimeout( + peripheralState: state, + extensions: 0, + maxExtensions: BLEStateMachine.maxDiscoveryTimeoutExtensions + ) + + #expect(!shouldExtend, "state \(state.rawValue) should tear down, not extend") } + } + + @Test + func `extend predicate rejects extension once the budget is spent`() { + let max = BLEStateMachine.maxDiscoveryTimeoutExtensions + + #expect(BLEStateMachine.shouldExtendDiscoveryTimeout(peripheralState: .connected, extensions: max - 1, maxExtensions: max)) + #expect(!BLEStateMachine.shouldExtendDiscoveryTimeout(peripheralState: .connected, extensions: max, maxExtensions: max)) + } + + @Test + func `advancing the connection generation resets the discovery-extension budget`() async { + let sm = BLEStateMachine() + await sm.recordDiscoveryTimeoutExtension() + await sm.recordDiscoveryTimeoutExtension() + #expect(await sm.currentDiscoveryTimeoutExtensions == BLEStateMachine.maxDiscoveryTimeoutExtensions) + + await sm.advanceConnectionGeneration() + + #expect(await sm.currentDiscoveryTimeoutExtensions == 0) + } + + @Test + func `disconnect callback is accepted when timestamp is at or after generation start`() { + let generationStart: CFAbsoluteTime = 500 + + let atStart = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( + timestamp: generationStart, + generationStart: generationStart + ) + let afterStart = BLEStateMachine.isDisconnectCallbackFromPreviousGeneration( + timestamp: generationStart + 300, + generationStart: generationStart + ) + + #expect(!atStart) + #expect(!afterStart) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineWriteWithoutResponseTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineWriteWithoutResponseTests.swift index 95d4dc7e..b32c7d82 100644 --- a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineWriteWithoutResponseTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineWriteWithoutResponseTests.swift @@ -1,7 +1,7 @@ @preconcurrency import CoreBluetooth import Foundation -import Testing @testable import MC1Services +import Testing /// Covers the `.withoutResponse` write path's backpressure continuation lifecycle and /// capability capture. The full `sendWithoutResponse` round-trip needs a real `CBPeripheral` @@ -9,130 +9,129 @@ import Testing /// critical parts that can hang or corrupt state if they regress. @Suite("BLEStateMachine write-without-response path") struct BLEStateMachineWriteWithoutResponseTests { - - // MARK: - Helpers - - /// Polls until the state machine has installed its readiness continuation, so the test - /// can deterministically signal readiness without racing the suspending task. - private func waitUntilAwaiting(_ sm: BLEStateMachine) async -> Bool { - for _ in 0..<200 { - if await sm.isAwaitingWriteWithoutResponseReady { return true } - try? await Task.sleep(for: .milliseconds(5)) - } - return false + // MARK: - Helpers + + /// Polls until the state machine has installed its readiness continuation, so the test + /// can deterministically signal readiness without racing the suspending task. + private func waitUntilAwaiting(_ sm: BLEStateMachine) async -> Bool { + for _ in 0..<200 { + if await sm.isAwaitingWriteWithoutResponseReady { return true } + try? await Task.sleep(for: .milliseconds(5)) } - - /// Returns true if `work` finishes within `duration`; false if it is still running - /// (i.e. the awaited continuation never resumed — a hang). Converts a hang into a - /// fast assertion failure instead of stalling the whole suite. - private func completesWithin(_ duration: Duration, _ work: @escaping @Sendable () async -> Void) async -> Bool { - await withTaskGroup(of: Bool.self) { group in - group.addTask { await work(); return true } - group.addTask { try? await Task.sleep(for: duration); return false } - defer { group.cancelAll() } - return await group.next() ?? false - } + return false + } + + /// Returns true if `work` finishes within `duration`; false if it is still running + /// (i.e. the awaited continuation never resumed — a hang). Converts a hang into a + /// fast assertion failure instead of stalling the whole suite. + private func completesWithin(_ duration: Duration, _ work: @escaping @Sendable () async -> Void) async -> Bool { + await withTaskGroup(of: Bool.self) { group in + group.addTask { await work(); return true } + group.addTask { try? await Task.sleep(for: duration); return false } + defer { group.cancelAll() } + return await group.next() ?? false } + } - // MARK: - Readiness gating + // MARK: - Readiness gating - @Test("readiness wait returns immediately when peripheral is already ready") - func waitReturnsImmediatelyWhenReady() async { - let sm = BLEStateMachine() + @Test + func `readiness wait returns immediately when peripheral is already ready`() async { + let sm = BLEStateMachine() - let finished = await completesWithin(.seconds(1)) { - _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: true) - } - - #expect(finished) - #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + let finished = await completesWithin(.seconds(1)) { + _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: true) } - @Test("readiness wait resumes when the peripheral signals it can write again") - func waitResumesOnPeripheralReadyCallback() async { - let sm = BLEStateMachine() + #expect(finished) + #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + } - let waiter = Task { _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) } - #expect(await waitUntilAwaiting(sm)) + @Test + func `readiness wait resumes when the peripheral signals it can write again`() async { + let sm = BLEStateMachine() - await sm.handlePeripheralReadyForWriteWithoutResponse() + let waiter = Task { _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) } + #expect(await waitUntilAwaiting(sm)) - #expect(await completesWithin(.seconds(3)) { await waiter.value }) - #expect(await sm.isAwaitingWriteWithoutResponseReady == false) - } + await sm.handlePeripheralReadyForWriteWithoutResponse() - @Test("readiness wait resumes on disconnect so a sender never hangs across a drop") - func waitResumesOnDisconnect() async { - let sm = BLEStateMachine() + #expect(await completesWithin(.seconds(3)) { await waiter.value }) + #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + } - let waiter = Task { _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) } - #expect(await waitUntilAwaiting(sm)) + @Test + func `readiness wait resumes on disconnect so a sender never hangs across a drop`() async { + let sm = BLEStateMachine() - await sm.disconnect() + let waiter = Task { _ = try? await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) } + #expect(await waitUntilAwaiting(sm)) - #expect(await completesWithin(.seconds(3)) { await waiter.value }) - #expect(await sm.isAwaitingWriteWithoutResponseReady == false) - } - - @Test("readiness wait fails via the timeout backstop when the peripheral stays silent") - func waitTimesOutWhenPeripheralStaysSilent() async { - let sm = BLEStateMachine(writeTimeout: 0.5) - - let waiter = Task { () -> Bool in - do { - try await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) - return false - } catch { - return true - } - } - #expect(await waitUntilAwaiting(sm)) - - // No ready callback and no disconnect — only the backstop timeout can release it. - let finished = await completesWithin(.seconds(3)) { _ = await waiter.value } - #expect(finished) - #expect(await waiter.value) - #expect(await sm.isAwaitingWriteWithoutResponseReady == false) - } + await sm.disconnect() - @Test("peripheral-ready callback with no pending waiter is a safe no-op") - func readyCallbackWithoutWaiterIsSafe() async { - let sm = BLEStateMachine() + #expect(await completesWithin(.seconds(3)) { await waiter.value }) + #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + } - await sm.handlePeripheralReadyForWriteWithoutResponse() + @Test + func `readiness wait fails via the timeout backstop when the peripheral stays silent`() async { + let sm = BLEStateMachine(writeTimeout: 0.5) - #expect(await sm.isAwaitingWriteWithoutResponseReady == false) - } - - // MARK: - Capability capture - - @Test("capability is true when the write characteristic advertises writeWithoutResponse") - func capabilityTrueForWriteWithoutResponseCharacteristic() async { - let sm = BLEStateMachine() - let characteristic = CBMutableCharacteristic( - type: CBUUID(string: BLEServiceUUID.txCharacteristic), - properties: [.write, .writeWithoutResponse], - value: nil, - permissions: [.writeable] - ) - - await sm.captureWriteWithoutResponseCapability(from: characteristic) - - #expect(await sm.supportsWriteWithoutResponse == true) - } - - @Test("capability is false when the write characteristic is write-only (ESP32)") - func capabilityFalseForWriteOnlyCharacteristic() async { - let sm = BLEStateMachine() - let characteristic = CBMutableCharacteristic( - type: CBUUID(string: BLEServiceUUID.txCharacteristic), - properties: [.write], - value: nil, - permissions: [.writeable] - ) - - await sm.captureWriteWithoutResponseCapability(from: characteristic) - - #expect(await sm.supportsWriteWithoutResponse == false) + let waiter = Task { () -> Bool in + do { + try await sm.awaitWriteWithoutResponseReadiness(alreadyReady: false) + return false + } catch { + return true + } } + #expect(await waitUntilAwaiting(sm)) + + // No ready callback and no disconnect — only the backstop timeout can release it. + let finished = await completesWithin(.seconds(3)) { _ = await waiter.value } + #expect(finished) + #expect(await waiter.value) + #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + } + + @Test + func `peripheral-ready callback with no pending waiter is a safe no-op`() async { + let sm = BLEStateMachine() + + await sm.handlePeripheralReadyForWriteWithoutResponse() + + #expect(await sm.isAwaitingWriteWithoutResponseReady == false) + } + + // MARK: - Capability capture + + @Test + func `capability is true when the write characteristic advertises writeWithoutResponse`() async { + let sm = BLEStateMachine() + let characteristic = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.txCharacteristic), + properties: [.write, .writeWithoutResponse], + value: nil, + permissions: [.writeable] + ) + + await sm.captureWriteWithoutResponseCapability(from: characteristic) + + #expect(await sm.supportsWriteWithoutResponse == true) + } + + @Test + func `capability is false when the write characteristic is write-only (ESP32)`() async { + let sm = BLEStateMachine() + let characteristic = CBMutableCharacteristic( + type: CBUUID(string: BLEServiceUUID.txCharacteristic), + properties: [.write], + value: nil, + permissions: [.writeable] + ) + + await sm.captureWriteWithoutResponseCapability(from: characteristic) + + #expect(await sm.supportsWriteWithoutResponse == false) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Utilities/DeviceIdentityTests.swift b/MC1Services/Tests/MC1ServicesTests/Utilities/DeviceIdentityTests.swift index 2757292f..aeec89c7 100644 --- a/MC1Services/Tests/MC1ServicesTests/Utilities/DeviceIdentityTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Utilities/DeviceIdentityTests.swift @@ -1,48 +1,47 @@ -import Testing -import Foundation import CryptoKit +import Foundation @testable import MC1Services +import Testing @Suite("DeviceIdentity Tests") struct DeviceIdentityTests { + @Test + func `Derives consistent UUID from public key`() { + let publicKey = Data(repeating: 0xAB, count: 32) - @Test("Derives consistent UUID from public key") - func derivesConsistentUUID() { - let publicKey = Data(repeating: 0xAB, count: 32) - - let uuid1 = DeviceIdentity.deriveUUID(from: publicKey) - let uuid2 = DeviceIdentity.deriveUUID(from: publicKey) + let uuid1 = DeviceIdentity.deriveUUID(from: publicKey) + let uuid2 = DeviceIdentity.deriveUUID(from: publicKey) - #expect(uuid1 == uuid2) - } + #expect(uuid1 == uuid2) + } - @Test("Different public keys produce different UUIDs") - func differentKeysProduceDifferentUUIDs() { - let key1 = Data(repeating: 0xAA, count: 32) - let key2 = Data(repeating: 0xBB, count: 32) + @Test + func `Different public keys produce different UUIDs`() { + let key1 = Data(repeating: 0xAA, count: 32) + let key2 = Data(repeating: 0xBB, count: 32) - let uuid1 = DeviceIdentity.deriveUUID(from: key1) - let uuid2 = DeviceIdentity.deriveUUID(from: key2) + let uuid1 = DeviceIdentity.deriveUUID(from: key1) + let uuid2 = DeviceIdentity.deriveUUID(from: key2) - #expect(uuid1 != uuid2) - } + #expect(uuid1 != uuid2) + } - @Test("UUID derivation uses SHA256") - func usesSecureHash() { - let publicKey = Data([0x01, 0x02, 0x03, 0x04] + Array(repeating: UInt8(0), count: 28)) + @Test + func `UUID derivation uses SHA256`() { + let publicKey = Data([0x01, 0x02, 0x03, 0x04] + Array(repeating: UInt8(0), count: 28)) - let uuid = DeviceIdentity.deriveUUID(from: publicKey) + let uuid = DeviceIdentity.deriveUUID(from: publicKey) - // Manually compute expected hash - let hash = SHA256.hash(data: publicKey) - let hashBytes = Array(hash) - let expectedUUID = UUID(uuid: ( - hashBytes[0], hashBytes[1], hashBytes[2], hashBytes[3], - hashBytes[4], hashBytes[5], hashBytes[6], hashBytes[7], - hashBytes[8], hashBytes[9], hashBytes[10], hashBytes[11], - hashBytes[12], hashBytes[13], hashBytes[14], hashBytes[15] - )) + // Manually compute expected hash + let hash = SHA256.hash(data: publicKey) + let hashBytes = Array(hash) + let expectedUUID = UUID(uuid: ( + hashBytes[0], hashBytes[1], hashBytes[2], hashBytes[3], + hashBytes[4], hashBytes[5], hashBytes[6], hashBytes[7], + hashBytes[8], hashBytes[9], hashBytes[10], hashBytes[11], + hashBytes[12], hashBytes[13], hashBytes[14], hashBytes[15] + )) - #expect(uuid == expectedUUID) - } + #expect(uuid == expectedUUID) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Utilities/HashtagUtilitiesTests.swift b/MC1Services/Tests/MC1ServicesTests/Utilities/HashtagUtilitiesTests.swift index b6bfc50f..98ef5a4d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Utilities/HashtagUtilitiesTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Utilities/HashtagUtilitiesTests.swift @@ -1,116 +1,115 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("HashtagUtilities Tests") struct HashtagUtilitiesTests { - - // MARK: - Regex Pattern Tests - - @Test("hashtag pattern matches valid hashtags", arguments: ["#general", "#General", "#test-channel", "#abc123", "#a"]) - func testPatternMatchesValid(text: String) { - // swiftlint:disable:next force_try - let regex = try! NSRegularExpression(pattern: HashtagUtilities.hashtagPattern) - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) - #expect(matches.count == 1, "Expected match for: \(text)") - } - - @Test("hashtag pattern rejects invalid hashtags", arguments: ["#test_underscore", "#test.dot", "#", "#-bad", "#bad!", "#white space"]) - func testPatternRejectsInvalid(text: String) { - // Use anchored pattern for full-string validation (extraction pattern finds partial matches) - let anchoredPattern = "^" + HashtagUtilities.hashtagPattern + "$" - // swiftlint:disable:next force_try - let regex = try! NSRegularExpression(pattern: anchoredPattern) - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) - #expect(matches.isEmpty, "Expected no match for: \(text)") - } - - // MARK: - extractHashtags Tests - - @Test("extractHashtags finds single hashtag") - func testExtractSingle() { - let result = HashtagUtilities.extractHashtags(from: "Join #general today") - #expect(result.count == 1) - #expect(result.first?.name == "#general") - } - - @Test("extractHashtags accepts uppercase hashtags") - func testExtractUppercase() { - let result = HashtagUtilities.extractHashtags(from: "Join #General today") - #expect(result.count == 1) - #expect(result.first?.name == "#General") - } - - @Test("extractHashtags finds multiple hashtags") - func testExtractMultiple() { - let result = HashtagUtilities.extractHashtags(from: "Try #one and #two") - #expect(result.count == 2) - #expect(result[0].name == "#one") - #expect(result[1].name == "#two") - } - - @Test("extractHashtags returns empty for no hashtags") - func testExtractNone() { - let result = HashtagUtilities.extractHashtags(from: "No hashtags here") - #expect(result.isEmpty) - } - - @Test("extractHashtags excludes hashtags inside URLs") - func testExtractExcludesURLs() { - let result = HashtagUtilities.extractHashtags(from: "See https://example.com#section and #general") - #expect(result.count == 1) - #expect(result.first?.name == "#general") - } - - @Test("extractHashtags handles hashtag at end with punctuation") - func testExtractWithPunctuation() { - let result = HashtagUtilities.extractHashtags(from: "Join #general.") - #expect(result.count == 1) - #expect(result.first?.name == "#general") - } - - @Test("extractHashtags handles adjacent hashtags") - func testExtractAdjacent() { - let result = HashtagUtilities.extractHashtags(from: "#one#two") - #expect(result.count == 2) - } - - // MARK: - isValidHashtagName Tests - - @Test("isValidHashtagName accepts valid names") - func testIsValidAccepts() { - #expect(HashtagUtilities.isValidHashtagName("general")) - #expect(HashtagUtilities.isValidHashtagName("General")) - #expect(HashtagUtilities.isValidHashtagName("TEST")) - #expect(HashtagUtilities.isValidHashtagName("test-channel")) - #expect(HashtagUtilities.isValidHashtagName("abc123")) - #expect(HashtagUtilities.isValidHashtagName("a")) - } - - @Test("isValidHashtagName rejects invalid names") - func testIsValidRejects() { - #expect(!HashtagUtilities.isValidHashtagName("")) - #expect(!HashtagUtilities.isValidHashtagName("-bad")) - #expect(!HashtagUtilities.isValidHashtagName("test_underscore")) - #expect(!HashtagUtilities.isValidHashtagName("test.dot")) - #expect(!HashtagUtilities.isValidHashtagName("bad!")) - } - - // MARK: - normalizeHashtagName Tests - - @Test("normalizeHashtagName lowercases and strips prefix") - func testNormalize() { - #expect(HashtagUtilities.normalizeHashtagName("#General") == "general") - #expect(HashtagUtilities.normalizeHashtagName("#TEST") == "test") - #expect(HashtagUtilities.normalizeHashtagName("general") == "general") - } - - @Test("sanitizeHashtagNameInput lowercases and strips invalid characters") - func testSanitizeInput() { - #expect(HashtagUtilities.sanitizeHashtagNameInput("General") == "general") - #expect(HashtagUtilities.sanitizeHashtagNameInput("-General") == "general") - #expect(HashtagUtilities.sanitizeHashtagNameInput("gen_eral") == "general") - } + // MARK: - Regex Pattern Tests + + @Test(arguments: ["#general", "#General", "#test-channel", "#abc123", "#a"]) + func `hashtag pattern matches valid hashtags`(text: String) throws { + // swiftlint:disable:next force_try + let regex = try NSRegularExpression(pattern: HashtagUtilities.hashtagPattern) + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) + #expect(matches.count == 1, "Expected match for: \(text)") + } + + @Test(arguments: ["#test_underscore", "#test.dot", "#", "#-bad", "#bad!", "#white space"]) + func `hashtag pattern rejects invalid hashtags`(text: String) throws { + // Use anchored pattern for full-string validation (extraction pattern finds partial matches) + let anchoredPattern = "^" + HashtagUtilities.hashtagPattern + "$" + // swiftlint:disable:next force_try + let regex = try NSRegularExpression(pattern: anchoredPattern) + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) + #expect(matches.isEmpty, "Expected no match for: \(text)") + } + + // MARK: - extractHashtags Tests + + @Test + func `extractHashtags finds single hashtag`() { + let result = HashtagUtilities.extractHashtags(from: "Join #general today") + #expect(result.count == 1) + #expect(result.first?.name == "#general") + } + + @Test + func `extractHashtags accepts uppercase hashtags`() { + let result = HashtagUtilities.extractHashtags(from: "Join #General today") + #expect(result.count == 1) + #expect(result.first?.name == "#General") + } + + @Test + func `extractHashtags finds multiple hashtags`() { + let result = HashtagUtilities.extractHashtags(from: "Try #one and #two") + #expect(result.count == 2) + #expect(result[0].name == "#one") + #expect(result[1].name == "#two") + } + + @Test + func `extractHashtags returns empty for no hashtags`() { + let result = HashtagUtilities.extractHashtags(from: "No hashtags here") + #expect(result.isEmpty) + } + + @Test + func `extractHashtags excludes hashtags inside URLs`() { + let result = HashtagUtilities.extractHashtags(from: "See https://example.com#section and #general") + #expect(result.count == 1) + #expect(result.first?.name == "#general") + } + + @Test + func `extractHashtags handles hashtag at end with punctuation`() { + let result = HashtagUtilities.extractHashtags(from: "Join #general.") + #expect(result.count == 1) + #expect(result.first?.name == "#general") + } + + @Test + func `extractHashtags handles adjacent hashtags`() { + let result = HashtagUtilities.extractHashtags(from: "#one#two") + #expect(result.count == 2) + } + + // MARK: - isValidHashtagName Tests + + @Test + func `isValidHashtagName accepts valid names`() { + #expect(HashtagUtilities.isValidHashtagName("general")) + #expect(HashtagUtilities.isValidHashtagName("General")) + #expect(HashtagUtilities.isValidHashtagName("TEST")) + #expect(HashtagUtilities.isValidHashtagName("test-channel")) + #expect(HashtagUtilities.isValidHashtagName("abc123")) + #expect(HashtagUtilities.isValidHashtagName("a")) + } + + @Test + func `isValidHashtagName rejects invalid names`() { + #expect(!HashtagUtilities.isValidHashtagName("")) + #expect(!HashtagUtilities.isValidHashtagName("-bad")) + #expect(!HashtagUtilities.isValidHashtagName("test_underscore")) + #expect(!HashtagUtilities.isValidHashtagName("test.dot")) + #expect(!HashtagUtilities.isValidHashtagName("bad!")) + } + + // MARK: - normalizeHashtagName Tests + + @Test + func `normalizeHashtagName lowercases and strips prefix`() { + #expect(HashtagUtilities.normalizeHashtagName("#General") == "general") + #expect(HashtagUtilities.normalizeHashtagName("#TEST") == "test") + #expect(HashtagUtilities.normalizeHashtagName("general") == "general") + } + + @Test + func `sanitizeHashtagNameInput lowercases and strips invalid characters`() { + #expect(HashtagUtilities.sanitizeHashtagNameInput("General") == "general") + #expect(HashtagUtilities.sanitizeHashtagNameInput("-General") == "general") + #expect(HashtagUtilities.sanitizeHashtagNameInput("gen_eral") == "general") + } } diff --git a/MC1Tests/AppColorSchemePreferenceTests.swift b/MC1Tests/AppColorSchemePreferenceTests.swift index 1f545f0c..ef730c91 100644 --- a/MC1Tests/AppColorSchemePreferenceTests.swift +++ b/MC1Tests/AppColorSchemePreferenceTests.swift @@ -1,27 +1,26 @@ -import Testing -import SwiftUI @testable import MC1 +import SwiftUI +import Testing @Suite("AppColorSchemePreference") struct AppColorSchemePreferenceTests { + @Test + func `raw values are pinned to the on-disk format`() { + #expect(AppColorSchemePreference.system.rawValue == "system") + #expect(AppColorSchemePreference.light.rawValue == "light") + #expect(AppColorSchemePreference.dark.rawValue == "dark") + } - @Test("raw values are pinned to the on-disk format") - func rawValuesPinned() { - #expect(AppColorSchemePreference.system.rawValue == "system") - #expect(AppColorSchemePreference.light.rawValue == "light") - #expect(AppColorSchemePreference.dark.rawValue == "dark") - } - - @Test("colorScheme maps system to nil and light/dark to their schemes") - func colorSchemeMapping() { - #expect(AppColorSchemePreference.system.colorScheme == nil) - #expect(AppColorSchemePreference.light.colorScheme == .light) - #expect(AppColorSchemePreference.dark.colorScheme == .dark) - } + @Test + func `colorScheme maps system to nil and light/dark to their schemes`() { + #expect(AppColorSchemePreference.system.colorScheme == nil) + #expect(AppColorSchemePreference.light.colorScheme == .light) + #expect(AppColorSchemePreference.dark.colorScheme == .dark) + } - @Test("allCases is exactly system, light, dark and id equals rawValue") - func allCasesAndID() { - #expect(AppColorSchemePreference.allCases == [.system, .light, .dark]) - #expect(AppColorSchemePreference.dark.id == "dark") - } + @Test + func `allCases is exactly system, light, dark and id equals rawValue`() { + #expect(AppColorSchemePreference.allCases == [.system, .light, .dark]) + #expect(AppColorSchemePreference.dark.id == "dark") + } } diff --git a/MC1Tests/AppState/AppStateRegionTests.swift b/MC1Tests/AppState/AppStateRegionTests.swift index 99d5bb5f..5ef55787 100644 --- a/MC1Tests/AppState/AppStateRegionTests.swift +++ b/MC1Tests/AppState/AppStateRegionTests.swift @@ -1,44 +1,43 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @Suite("AppState region preference", .serialized) @MainActor struct AppStateRegionTests { + private static let regionKey = BackupUserDefaults.regionSelectionKey - private static let regionKey = BackupUserDefaults.regionSelectionKey - - @Test("regionSelection persists to UserDefaults on set") - func persistsOnSet() throws { - UserDefaults.standard.removeObject(forKey: Self.regionKey) - let appState = AppState() - let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", - countyKey: "los angeles", source: .location) - appState.regionSelection = region + @Test + func `regionSelection persists to UserDefaults on set`() throws { + UserDefaults.standard.removeObject(forKey: Self.regionKey) + let appState = AppState() + let region = RegionSelection(countryCode: "US", administrativeAreaCode: "US-CA", + countyKey: "los angeles", source: .location) + appState.regionSelection = region - let data = try #require(UserDefaults.standard.data(forKey: Self.regionKey)) - let decoded = try JSONDecoder().decode(RegionSelection.self, from: data) - #expect(decoded == region) - } + let data = try #require(UserDefaults.standard.data(forKey: Self.regionKey)) + let decoded = try JSONDecoder().decode(RegionSelection.self, from: data) + #expect(decoded == region) + } - @Test("regionSelection clears UserDefaults on nil") - func clearsOnNil() throws { - UserDefaults.standard.removeObject(forKey: Self.regionKey) - let appState = AppState() - appState.regionSelection = RegionSelection(countryCode: "US", source: .manual) - appState.regionSelection = nil - #expect(UserDefaults.standard.data(forKey: Self.regionKey) == nil) - } + @Test + func `regionSelection clears UserDefaults on nil`() { + UserDefaults.standard.removeObject(forKey: Self.regionKey) + let appState = AppState() + appState.regionSelection = RegionSelection(countryCode: "US", source: .manual) + appState.regionSelection = nil + #expect(UserDefaults.standard.data(forKey: Self.regionKey) == nil) + } - @Test("AppState loads persisted regionSelection on init") - func loadsOnInit() throws { - defer { UserDefaults.standard.removeObject(forKey: Self.regionKey) } - let region = RegionSelection(countryCode: "PT", source: .manual) - let data = try JSONEncoder().encode(region) - UserDefaults.standard.set(data, forKey: Self.regionKey) + @Test + func `AppState loads persisted regionSelection on init`() throws { + defer { UserDefaults.standard.removeObject(forKey: Self.regionKey) } + let region = RegionSelection(countryCode: "PT", source: .manual) + let data = try JSONEncoder().encode(region) + UserDefaults.standard.set(data, forKey: Self.regionKey) - let appState = AppState() - #expect(appState.regionSelection == region) - } + let appState = AppState() + #expect(appState.regionSelection == region) + } } diff --git a/MC1Tests/AppState/BatteryMonitoringTests.swift b/MC1Tests/AppState/BatteryMonitoringTests.swift index 1699377c..dbab53ea 100644 --- a/MC1Tests/AppState/BatteryMonitoringTests.swift +++ b/MC1Tests/AppState/BatteryMonitoringTests.swift @@ -1,68 +1,67 @@ -import Testing import Foundation -import MeshCore -@testable import MC1Services @testable import MC1 +@testable import MC1Services +import MeshCore +import Testing @Suite("Battery Monitoring Tests") @MainActor struct BatteryMonitoringTests { + // MARK: - Default State - // MARK: - Default State - - @Test("deviceBattery is nil by default") - func deviceBatteryDefault() { - let appState = AppState() - #expect(appState.batteryMonitor.deviceBattery == nil) - } + @Test + func `deviceBattery is nil by default`() { + let appState = AppState() + #expect(appState.batteryMonitor.deviceBattery == nil) + } - @Test("activeBatteryOCVArray returns liIon default when no device connected") - func activeBatteryOCVArrayDefault() { - let appState = AppState() - #expect(appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) == OCVPreset.liIon.ocvArray) - } + @Test + func `activeBatteryOCVArray returns liIon default when no device connected`() { + let appState = AppState() + #expect(appState.batteryMonitor.activeBatteryOCVArray(for: appState.connectedDevice) == OCVPreset.liIon.ocvArray) + } - // MARK: - fetchDeviceBattery + // MARK: - fetchDeviceBattery - @Test("fetchDeviceBattery is no-op when services is nil") - func fetchDeviceBatteryNoServices() async { - let appState = AppState() + @Test + func `fetchDeviceBattery is no-op when services is nil`() async { + let appState = AppState() - await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) + await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) - #expect(appState.batteryMonitor.deviceBattery == nil) - } + #expect(appState.batteryMonitor.deviceBattery == nil) + } - @Test("fetchDeviceBattery does not crash when called on fresh state") - func fetchDeviceBatterySafe() async { - let appState = AppState() - #expect(appState.services == nil) + @Test + func `fetchDeviceBattery does not crash when called on fresh state`() async { + let appState = AppState() + #expect(appState.services == nil) - // Should not throw or crash - await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) - #expect(appState.batteryMonitor.deviceBattery == nil) - } + // Should not throw or crash + await appState.batteryMonitor.fetchDeviceBattery(services: appState.services, device: appState.connectedDevice) + #expect(appState.batteryMonitor.deviceBattery == nil) + } - // MARK: - Battery State Observation + // MARK: - Battery State Observation - @Test("deviceBattery can be set directly for testing") - func deviceBatterySettable() { - let appState = AppState() - let battery = BatteryInfo(level: 3700) + @Test + func `deviceBattery can be set directly for testing`() { + let appState = AppState() + let battery = BatteryInfo(level: 3700) - appState.batteryMonitor.deviceBattery = battery + appState.batteryMonitor.deviceBattery = battery - #expect(appState.batteryMonitor.deviceBattery == battery) - #expect(appState.batteryMonitor.deviceBattery?.level == 3700) - } + #expect(appState.batteryMonitor.deviceBattery == battery) + #expect(appState.batteryMonitor.deviceBattery?.level == 3700) + } - @Test("deviceBattery can be cleared") - func deviceBatteryClearable() { - let appState = AppState() - appState.batteryMonitor.deviceBattery = BatteryInfo(level: 3700) + @Test + func `deviceBattery can be cleared`() { + let appState = AppState() + appState.batteryMonitor.deviceBattery = BatteryInfo(level: 3700) - appState.batteryMonitor.deviceBattery = nil + appState.batteryMonitor.deviceBattery = nil - #expect(appState.batteryMonitor.deviceBattery == nil) - } + #expect(appState.batteryMonitor.deviceBattery == nil) + } } diff --git a/MC1Tests/AppState/ConnectionUIStateTests.swift b/MC1Tests/AppState/ConnectionUIStateTests.swift index b1d31d5b..6b69ae4e 100644 --- a/MC1Tests/AppState/ConnectionUIStateTests.swift +++ b/MC1Tests/AppState/ConnectionUIStateTests.swift @@ -1,379 +1,378 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing @Suite("Connection UI State Tests") @MainActor struct ConnectionUIStateTests { + // MARK: - statusPillState Priority - // MARK: - statusPillState Priority - - @Test("statusPillState is hidden by default") - func hiddenByDefault() { - let appState = AppState() - #expect(appState.statusPillState == .hidden) - } + @Test + func `statusPillState is hidden by default`() { + let appState = AppState() + #expect(appState.statusPillState == .hidden) + } - @Test("Failed state takes priority over syncing") - func failedOverSyncing() { - let appState = AppState() - appState.connectionUI.simulateSyncStarted() - appState.connectionUI.showSyncFailedPill() + @Test + func `Failed state takes priority over syncing`() { + let appState = AppState() + appState.connectionUI.simulateSyncStarted() + appState.connectionUI.showSyncFailedPill() - #expect(appState.statusPillState == .failed(message: "Sync Failed")) - } + #expect(appState.statusPillState == .failed(message: "Sync Failed")) + } - @Test("Syncing takes priority over ready toast") - func syncingOverReady() { - let appState = AppState() - appState.connectionUI.showReadyToastBriefly() - appState.connectionUI.simulateSyncStarted() + @Test + func `Syncing takes priority over ready toast`() { + let appState = AppState() + appState.connectionUI.showReadyToastBriefly() + appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) - } + #expect(appState.statusPillState == .syncing) + } - @Test("Ready toast takes priority over disconnected") - func readyOverDisconnected() { - let appState = AppState() - appState.connectionUI.showReadyToastBriefly() + @Test + func `Ready toast takes priority over disconnected`() { + let appState = AppState() + appState.connectionUI.showReadyToastBriefly() - // Even if disconnectedPillVisible were true, ready should win - #expect(appState.statusPillState == .ready) - } + // Even if disconnectedPillVisible were true, ready should win + #expect(appState.statusPillState == .ready) + } - @Test("Multiple sync activities keep syncing state until all end") - func multipleSyncActivities() { - let appState = AppState() + @Test + func `Multiple sync activities keep syncing state until all end`() { + let appState = AppState() - appState.connectionUI.simulateSyncStarted() - appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) + appState.connectionUI.simulateSyncStarted() + appState.connectionUI.simulateSyncStarted() + #expect(appState.statusPillState == .syncing) - appState.connectionUI.simulateSyncEnded() - #expect(appState.statusPillState == .syncing) + appState.connectionUI.simulateSyncEnded() + #expect(appState.statusPillState == .syncing) - appState.connectionUI.simulateSyncEnded() - // After all sync activity ends, should not be syncing - #expect(appState.statusPillState != .syncing) - } + appState.connectionUI.simulateSyncEnded() + // After all sync activity ends, should not be syncing + #expect(appState.statusPillState != .syncing) + } - // MARK: - Ready Toast + // MARK: - Ready Toast - @Test("showReadyToastBriefly sets showReadyToast to true") - func showReadyToast() { - let appState = AppState() + @Test + func `showReadyToastBriefly sets showReadyToast to true`() { + let appState = AppState() - appState.connectionUI.showReadyToastBriefly() + appState.connectionUI.showReadyToastBriefly() - #expect(appState.connectionUI.showReadyToast == true) - #expect(appState.statusPillState == .ready) - } + #expect(appState.connectionUI.showReadyToast == true) + #expect(appState.statusPillState == .ready) + } - @Test("hideReadyToast immediately clears toast") - func hideReadyToast() { - let appState = AppState() - appState.connectionUI.showReadyToastBriefly() - #expect(appState.connectionUI.showReadyToast == true) + @Test + func `hideReadyToast immediately clears toast`() { + let appState = AppState() + appState.connectionUI.showReadyToastBriefly() + #expect(appState.connectionUI.showReadyToast == true) - appState.connectionUI.hideReadyToast() + appState.connectionUI.hideReadyToast() - #expect(appState.connectionUI.showReadyToast == false) - #expect(appState.statusPillState == .hidden) - } + #expect(appState.connectionUI.showReadyToast == false) + #expect(appState.statusPillState == .hidden) + } - @Test("showReadyToastBriefly auto-hides after delay") - func readyToastAutoHides() async throws { - let appState = AppState() + @Test + func `showReadyToastBriefly auto-hides after delay`() async throws { + let appState = AppState() - appState.connectionUI.showReadyToastBriefly() - #expect(appState.connectionUI.showReadyToast == true) + appState.connectionUI.showReadyToastBriefly() + #expect(appState.connectionUI.showReadyToast == true) - // Wait for the 2-second auto-hide plus margin - try await Task.sleep(for: .seconds(2.3)) + // Wait for the 2-second auto-hide plus margin + try await Task.sleep(for: .seconds(2.3)) - #expect(appState.connectionUI.showReadyToast == false) - } + #expect(appState.connectionUI.showReadyToast == false) + } - @Test("Calling showReadyToastBriefly again resets the timer") - func readyToastTimerReset() async throws { - let appState = AppState() + @Test + func `Calling showReadyToastBriefly again resets the timer`() async throws { + let appState = AppState() - appState.connectionUI.showReadyToastBriefly() - try await Task.sleep(for: .seconds(1.5)) + appState.connectionUI.showReadyToastBriefly() + try await Task.sleep(for: .seconds(1.5)) - // Call again to reset - appState.connectionUI.showReadyToastBriefly() - #expect(appState.connectionUI.showReadyToast == true) + // Call again to reset + appState.connectionUI.showReadyToastBriefly() + #expect(appState.connectionUI.showReadyToast == true) - // Wait past original timer but within new timer - try await Task.sleep(for: .seconds(1.0)) - #expect(appState.connectionUI.showReadyToast == true) - } + // Wait past original timer but within new timer + try await Task.sleep(for: .seconds(1.0)) + #expect(appState.connectionUI.showReadyToast == true) + } - // MARK: - Sync Failed Pill + // MARK: - Sync Failed Pill - @Test("showSyncFailedPill sets visible flag") - func showSyncFailedPill() { - let appState = AppState() + @Test + func `showSyncFailedPill sets visible flag`() { + let appState = AppState() - appState.connectionUI.showSyncFailedPill() + appState.connectionUI.showSyncFailedPill() - #expect(appState.connectionUI.syncFailedPillVisible == true) - #expect(appState.statusPillState == .failed(message: "Sync Failed")) - } + #expect(appState.connectionUI.syncFailedPillVisible == true) + #expect(appState.statusPillState == .failed(message: "Sync Failed")) + } - @Test("hideSyncFailedPill immediately clears pill") - func hideSyncFailedPill() { - let appState = AppState() - appState.connectionUI.showSyncFailedPill() + @Test + func `hideSyncFailedPill immediately clears pill`() { + let appState = AppState() + appState.connectionUI.showSyncFailedPill() - appState.connectionUI.hideSyncFailedPill() + appState.connectionUI.hideSyncFailedPill() - #expect(appState.connectionUI.syncFailedPillVisible == false) - } + #expect(appState.connectionUI.syncFailedPillVisible == false) + } - @Test("showSyncFailedPill auto-hides after delay") - func syncFailedPillAutoHides() async throws { - let appState = AppState() + @Test + func `showSyncFailedPill auto-hides after delay`() async throws { + let appState = AppState() - appState.connectionUI.showSyncFailedPill() - #expect(appState.connectionUI.syncFailedPillVisible == true) + appState.connectionUI.showSyncFailedPill() + #expect(appState.connectionUI.syncFailedPillVisible == true) - // Wait for the 7-second auto-hide plus margin - try await Task.sleep(for: .seconds(7.3)) + // Wait for the 7-second auto-hide plus margin + try await Task.sleep(for: .seconds(7.3)) - #expect(appState.connectionUI.syncFailedPillVisible == false) - } + #expect(appState.connectionUI.syncFailedPillVisible == false) + } - // MARK: - Disconnected Pill + // MARK: - Disconnected Pill - @Test("disconnectedPillVisible is false by default") - func disconnectedPillDefault() { - let appState = AppState() - #expect(appState.connectionUI.disconnectedPillVisible == false) - } + @Test + func `disconnectedPillVisible is false by default`() { + let appState = AppState() + #expect(appState.connectionUI.disconnectedPillVisible == false) + } - @Test("hideDisconnectedPill clears pill immediately") - func hideDisconnectedPill() { - let appState = AppState() + @Test + func `hideDisconnectedPill clears pill immediately`() { + let appState = AppState() - appState.connectionUI.hideDisconnectedPill() + appState.connectionUI.hideDisconnectedPill() - #expect(appState.connectionUI.disconnectedPillVisible == false) - } + #expect(appState.connectionUI.disconnectedPillVisible == false) + } - @Test("updateDisconnectedPillState without paired device stays hidden") - func disconnectedPillNoPairedDevice() async throws { - let appState = AppState() + @Test + func `updateDisconnectedPillState without paired device stays hidden`() async throws { + let appState = AppState() - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) - try await Task.sleep(for: .seconds(1.3)) - #expect(appState.connectionUI.disconnectedPillVisible == false) - } + try await Task.sleep(for: .seconds(1.3)) + #expect(appState.connectionUI.disconnectedPillVisible == false) + } - // MARK: - canRunSettingsStartupReads + // MARK: - canRunSettingsStartupReads - @Test("canRunSettingsStartupReads is false when disconnected") - func cannotRunSettingsWhenDisconnected() { - let appState = AppState() - #expect(appState.canRunSettingsStartupReads == false) - } + @Test + func `canRunSettingsStartupReads is false when disconnected`() { + let appState = AppState() + #expect(appState.canRunSettingsStartupReads == false) + } - // MARK: - Sync Activity Tracking + // MARK: - Sync Activity Tracking - @Test("Sync activity shows syncing pill while active") - func syncActivityShowsSyncing() { - let appState = AppState() - - appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) - - appState.connectionUI.simulateSyncEnded() - #expect(appState.statusPillState != .syncing) - } - - // MARK: - UI State Defaults - - @Test("Connection alert state defaults") - func connectionAlertDefaults() { - let appState = AppState() - #expect(appState.connectionUI.showingConnectionFailedAlert == false) - #expect(appState.connectionUI.connectionFailedMessage == nil) - #expect(appState.connectionUI.failedPairingDeviceID == nil) - #expect(appState.connectionUI.pairingFailureKind == nil) - #expect(appState.connectionUI.otherAppWarningDeviceID == nil) - #expect(appState.connectionUI.isBusy == false) - #expect(appState.connectionUI.isNodeStorageFull == false) - } - - // MARK: - presentPairingFailure / presentConnectionFailure - - @Test("presentPairingFailure(auth) sets pairingFailureKind to .authentication") - func presentPairingFailureSetsAuthKind() { - let sut = ConnectionUIState() - let deviceID = UUID() - sut.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.authenticationFailed)) - - #expect(sut.failedPairingDeviceID == deviceID) - #expect(sut.pairingFailureKind == .authentication) - #expect(sut.connectionFailedTitle != nil) - #expect(sut.showingConnectionFailedAlert == true) - } - - @Test("presentPairingFailure(transient) sets pairingFailureKind to .transient") - func presentPairingFailureSetsTransientKind() { - let sut = ConnectionUIState() - let deviceID = UUID() - sut.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.connectionFailed("timeout"))) - - #expect(sut.failedPairingDeviceID == deviceID) - #expect(sut.pairingFailureKind == .transient) - #expect(sut.connectionFailedTitle == nil) - #expect(sut.showingConnectionFailedAlert == true) - } - - @Test("presentConnectionFailure clears pairingFailureKind set by a prior pairing failure") - func presentConnectionFailureClearsPairingKind() { - let sut = ConnectionUIState() - sut.presentPairingFailure(.connectionFailed(deviceID: UUID(), underlying: BLEError.authenticationFailed)) - #expect(sut.pairingFailureKind == .authentication) - - sut.presentConnectionFailure(message: "generic") - - #expect(sut.pairingFailureKind == nil) - #expect(sut.connectionFailedTitle == nil) - } - - // MARK: - handleDisconnect - - @Test("handleDisconnect resets syncActivityCount to zero") - func handleDisconnectResetsSyncActivityCount() { - let sut = ConnectionUIState() - sut.simulateSyncStarted() - sut.simulateSyncStarted() - #expect(sut.syncActivityCount == 2) - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - #expect(sut.syncActivityCount == 0) - } - - @Test("handleDisconnect clears currentSyncPhase") - func handleDisconnectClearsSyncPhase() { - let sut = ConnectionUIState() - sut.currentSyncPhase = .contacts - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - #expect(sut.currentSyncPhase == nil) - } - - @Test("handleDisconnect sets isNodeStorageFull to false") - func handleDisconnectClearsNodeStorageFull() { - let sut = ConnectionUIState() - sut.isNodeStorageFull = true - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - #expect(sut.isNodeStorageFull == false) - } - - @Test("handleDisconnect hides ready toast") - func handleDisconnectHidesReadyToast() { - let sut = ConnectionUIState() - sut.showReadyToastBriefly() - #expect(sut.showReadyToast == true) - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - #expect(sut.showReadyToast == false) - } - - @Test("handleDisconnect shows disconnected pill when device was paired") - func handleDisconnectShowsDisconnectedPill() async throws { - let sut = ConnectionUIState() - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: false - ) - - // Disconnected pill shows after 1s delay - try await Task.sleep(for: .seconds(1.3)) - #expect(sut.disconnectedPillVisible == true) - } - - @Test("handleDisconnect does not show disconnected pill when suppressed") - func handleDisconnectSuppressesDisconnectedPill() async throws { - let sut = ConnectionUIState() - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: true - ) - - try await Task.sleep(for: .seconds(1.3)) - #expect(sut.disconnectedPillVisible == false) - } - - @Test("handleDisconnect does not show disconnected pill without paired device") - func handleDisconnectNoPairedDevice() async throws { - let sut = ConnectionUIState() - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - try await Task.sleep(for: .seconds(1.3)) - #expect(sut.disconnectedPillVisible == false) - } - - @Test("handleDisconnect resets all state in a single call") - func handleDisconnectResetsAllState() { - let sut = ConnectionUIState() - - // Set up various dirty state - sut.simulateSyncStarted() - sut.simulateSyncStarted() - sut.currentSyncPhase = .channels - sut.isNodeStorageFull = true - sut.showReadyToastBriefly() - - sut.handleDisconnect( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - #expect(sut.syncActivityCount == 0) - #expect(sut.currentSyncPhase == nil) - #expect(sut.isNodeStorageFull == false) - #expect(sut.showReadyToast == false) - } + @Test + func `Sync activity shows syncing pill while active`() { + let appState = AppState() + + appState.connectionUI.simulateSyncStarted() + #expect(appState.statusPillState == .syncing) + + appState.connectionUI.simulateSyncEnded() + #expect(appState.statusPillState != .syncing) + } + + // MARK: - UI State Defaults + + @Test + func `Connection alert state defaults`() { + let appState = AppState() + #expect(appState.connectionUI.showingConnectionFailedAlert == false) + #expect(appState.connectionUI.connectionFailedMessage == nil) + #expect(appState.connectionUI.failedPairingDeviceID == nil) + #expect(appState.connectionUI.pairingFailureKind == nil) + #expect(appState.connectionUI.otherAppWarningDeviceID == nil) + #expect(appState.connectionUI.isBusy == false) + #expect(appState.connectionUI.isNodeStorageFull == false) + } + + // MARK: - presentPairingFailure / presentConnectionFailure + + @Test + func `presentPairingFailure(auth) sets pairingFailureKind to .authentication`() { + let sut = ConnectionUIState() + let deviceID = UUID() + sut.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.authenticationFailed)) + + #expect(sut.failedPairingDeviceID == deviceID) + #expect(sut.pairingFailureKind == .authentication) + #expect(sut.connectionFailedTitle != nil) + #expect(sut.showingConnectionFailedAlert == true) + } + + @Test + func `presentPairingFailure(transient) sets pairingFailureKind to .transient`() { + let sut = ConnectionUIState() + let deviceID = UUID() + sut.presentPairingFailure(.connectionFailed(deviceID: deviceID, underlying: BLEError.connectionFailed("timeout"))) + + #expect(sut.failedPairingDeviceID == deviceID) + #expect(sut.pairingFailureKind == .transient) + #expect(sut.connectionFailedTitle == nil) + #expect(sut.showingConnectionFailedAlert == true) + } + + @Test + func `presentConnectionFailure clears pairingFailureKind set by a prior pairing failure`() { + let sut = ConnectionUIState() + sut.presentPairingFailure(.connectionFailed(deviceID: UUID(), underlying: BLEError.authenticationFailed)) + #expect(sut.pairingFailureKind == .authentication) + + sut.presentConnectionFailure(message: "generic") + + #expect(sut.pairingFailureKind == nil) + #expect(sut.connectionFailedTitle == nil) + } + + // MARK: - handleDisconnect + + @Test + func `handleDisconnect resets syncActivityCount to zero`() { + let sut = ConnectionUIState() + sut.simulateSyncStarted() + sut.simulateSyncStarted() + #expect(sut.syncActivityCount == 2) + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + #expect(sut.syncActivityCount == 0) + } + + @Test + func `handleDisconnect clears currentSyncPhase`() { + let sut = ConnectionUIState() + sut.currentSyncPhase = .contacts + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + #expect(sut.currentSyncPhase == nil) + } + + @Test + func `handleDisconnect sets isNodeStorageFull to false`() { + let sut = ConnectionUIState() + sut.isNodeStorageFull = true + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + #expect(sut.isNodeStorageFull == false) + } + + @Test + func `handleDisconnect hides ready toast`() { + let sut = ConnectionUIState() + sut.showReadyToastBriefly() + #expect(sut.showReadyToast == true) + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + #expect(sut.showReadyToast == false) + } + + @Test + func `handleDisconnect shows disconnected pill when device was paired`() async throws { + let sut = ConnectionUIState() + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: false + ) + + // Disconnected pill shows after 1s delay + try await Task.sleep(for: .seconds(1.3)) + #expect(sut.disconnectedPillVisible == true) + } + + @Test + func `handleDisconnect does not show disconnected pill when suppressed`() async throws { + let sut = ConnectionUIState() + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: true + ) + + try await Task.sleep(for: .seconds(1.3)) + #expect(sut.disconnectedPillVisible == false) + } + + @Test + func `handleDisconnect does not show disconnected pill without paired device`() async throws { + let sut = ConnectionUIState() + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + try await Task.sleep(for: .seconds(1.3)) + #expect(sut.disconnectedPillVisible == false) + } + + @Test + func `handleDisconnect resets all state in a single call`() { + let sut = ConnectionUIState() + + // Set up various dirty state + sut.simulateSyncStarted() + sut.simulateSyncStarted() + sut.currentSyncPhase = .channels + sut.isNodeStorageFull = true + sut.showReadyToastBriefly() + + sut.handleDisconnect( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + #expect(sut.syncActivityCount == 0) + #expect(sut.currentSyncPhase == nil) + #expect(sut.isNodeStorageFull == false) + #expect(sut.showReadyToast == false) + } } diff --git a/MC1Tests/AppState/DisconnectedPillTests.swift b/MC1Tests/AppState/DisconnectedPillTests.swift index 5cae077f..8e4009f7 100644 --- a/MC1Tests/AppState/DisconnectedPillTests.swift +++ b/MC1Tests/AppState/DisconnectedPillTests.swift @@ -1,125 +1,124 @@ -import Testing import Foundation -import SwiftData @testable import MC1 @testable import MC1Services +import SwiftData +import Testing @Suite("Disconnected Pill Tests") @MainActor struct DisconnectedPillTests { - - // MARK: - shouldSuppressDisconnectedPill Integration Tests - - private let defaults: UserDefaults - - init() { - defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } - - private func makeTestManager() throws -> ConnectionManager { - let schema = Schema([ - Device.self, - Contact.self, - Message.self, - Channel.self, - RemoteNodeSession.self, - RoomMessage.self - ]) - let config = ModelConfiguration(isStoredInMemoryOnly: true) - let container = try ModelContainer(for: schema, configurations: [config]) - return ConnectionManager(modelContainer: container, defaults: defaults) - } - - @Test("shouldSuppressDisconnectedPill returns true when user explicitly disconnected") - func testShouldSuppressWhenUserDisconnected() throws { - ConnectionIntent.userDisconnected.persist(to: defaults) - - let manager = try makeTestManager() - #expect(manager.shouldSuppressDisconnectedPill == true) - } - - @Test("shouldSuppressDisconnectedPill returns false when user did not explicitly disconnect") - func testShouldNotSuppressWhenNoExplicitDisconnect() throws { - let manager = try makeTestManager() - #expect(manager.shouldSuppressDisconnectedPill == false) - } - - // MARK: - updateDisconnectedPillState Tests (pass values directly) - - @Test("disconnected pill not shown when user explicitly disconnected") - func testPillNotShownAfterExplicitDisconnect() async throws { - let appState = AppState() - - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: true - ) - - try await Task.sleep(for: .seconds(1.2)) - #expect(appState.connectionUI.disconnectedPillVisible == false) - } - - @Test("disconnected pill shown after unexpected disconnect") - func testPillShownAfterUnexpectedDisconnect() async throws { - let appState = AppState() - - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: false - ) - - try await Task.sleep(for: .seconds(1.2)) - #expect(appState.connectionUI.disconnectedPillVisible == true) - } - - @Test("disconnected pill not shown when no last connected device") - func testPillNotShownWhenNoLastDevice() async throws { - let appState = AppState() - - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: nil, - shouldSuppressDisconnectedPill: false - ) - - try await Task.sleep(for: .seconds(1.2)) - #expect(appState.connectionUI.disconnectedPillVisible == false) - } - - @Test("disconnected pill hidden when connection starts") - func testPillHiddenWhenConnecting() async throws { - let appState = AppState() - - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: false - ) - try await Task.sleep(for: .seconds(1.2)) - #expect(appState.connectionUI.disconnectedPillVisible == true) - - appState.connectionUI.hideDisconnectedPill() - #expect(appState.connectionUI.disconnectedPillVisible == false) - } - - @Test("disconnected pill delay prevents flash during brief reconnects") - func testPillDelayPreventsFlash() async throws { - let appState = AppState() - - appState.connectionUI.updateDisconnectedPillState( - connectionState: .disconnected, - lastConnectedDeviceID: UUID(), - shouldSuppressDisconnectedPill: false - ) - - #expect(appState.connectionUI.disconnectedPillVisible == false) - - try await Task.sleep(for: .seconds(0.5)) - appState.connectionUI.hideDisconnectedPill() - - try await Task.sleep(for: .seconds(1.0)) - #expect(appState.connectionUI.disconnectedPillVisible == false) - } + // MARK: - shouldSuppressDisconnectedPill Integration Tests + + private let defaults: UserDefaults + + init() { + defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + private func makeTestManager() throws -> ConnectionManager { + let schema = Schema([ + Device.self, + Contact.self, + Message.self, + Channel.self, + RemoteNodeSession.self, + RoomMessage.self + ]) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [config]) + return ConnectionManager(modelContainer: container, defaults: defaults) + } + + @Test + func `shouldSuppressDisconnectedPill returns true when user explicitly disconnected`() throws { + ConnectionIntent.userDisconnected.persist(to: defaults) + + let manager = try makeTestManager() + #expect(manager.shouldSuppressDisconnectedPill == true) + } + + @Test + func `shouldSuppressDisconnectedPill returns false when user did not explicitly disconnect`() throws { + let manager = try makeTestManager() + #expect(manager.shouldSuppressDisconnectedPill == false) + } + + // MARK: - updateDisconnectedPillState Tests (pass values directly) + + @Test + func `disconnected pill not shown when user explicitly disconnected`() async throws { + let appState = AppState() + + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: true + ) + + try await Task.sleep(for: .seconds(1.2)) + #expect(appState.connectionUI.disconnectedPillVisible == false) + } + + @Test + func `disconnected pill shown after unexpected disconnect`() async throws { + let appState = AppState() + + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: false + ) + + try await Task.sleep(for: .seconds(1.2)) + #expect(appState.connectionUI.disconnectedPillVisible == true) + } + + @Test + func `disconnected pill not shown when no last connected device`() async throws { + let appState = AppState() + + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: nil, + shouldSuppressDisconnectedPill: false + ) + + try await Task.sleep(for: .seconds(1.2)) + #expect(appState.connectionUI.disconnectedPillVisible == false) + } + + @Test + func `disconnected pill hidden when connection starts`() async throws { + let appState = AppState() + + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: false + ) + try await Task.sleep(for: .seconds(1.2)) + #expect(appState.connectionUI.disconnectedPillVisible == true) + + appState.connectionUI.hideDisconnectedPill() + #expect(appState.connectionUI.disconnectedPillVisible == false) + } + + @Test + func `disconnected pill delay prevents flash during brief reconnects`() async throws { + let appState = AppState() + + appState.connectionUI.updateDisconnectedPillState( + connectionState: .disconnected, + lastConnectedDeviceID: UUID(), + shouldSuppressDisconnectedPill: false + ) + + #expect(appState.connectionUI.disconnectedPillVisible == false) + + try await Task.sleep(for: .seconds(0.5)) + appState.connectionUI.hideDisconnectedPill() + + try await Task.sleep(for: .seconds(1.0)) + #expect(appState.connectionUI.disconnectedPillVisible == false) + } } diff --git a/MC1Tests/AppState/LifecycleTransitionTests.swift b/MC1Tests/AppState/LifecycleTransitionTests.swift index 58cd38c6..e4881f44 100644 --- a/MC1Tests/AppState/LifecycleTransitionTests.swift +++ b/MC1Tests/AppState/LifecycleTransitionTests.swift @@ -1,89 +1,88 @@ -import Testing @testable import MC1 +import Testing @Suite("AppState Lifecycle Transition Tests") @MainActor struct LifecycleTransitionTests { + @Test + func `BLE foreground waits for queued background transition`() async { + let appState = AppState() + let recorder = TransitionRecorder() - @Test("BLE foreground waits for queued background transition") - func foregroundWaitsForBackgroundTransition() async { - let appState = AppState() - let recorder = TransitionRecorder() + appState.setBLELifecycleOverridesForTesting( + enterBackground: { + await recorder.record("background-start") + try? await Task.sleep(for: .milliseconds(150)) + await recorder.record("background-end") + }, + becomeActive: { + await recorder.record("foreground-start") + await recorder.record("foreground-end") + } + ) - appState.setBLELifecycleOverridesForTesting( - enterBackground: { - await recorder.record("background-start") - try? await Task.sleep(for: .milliseconds(150)) - await recorder.record("background-end") - }, - becomeActive: { - await recorder.record("foreground-start") - await recorder.record("foreground-end") - } - ) + appState.handleEnterBackground() + await appState.handleReturnToForeground() - appState.handleEnterBackground() - await appState.handleReturnToForeground() + let events = await recorder.events + #expect(events == [ + "background-start", + "background-end", + "foreground-start", + "foreground-end" + ]) + } - let events = await recorder.events - #expect(events == [ - "background-start", - "background-end", - "foreground-start", - "foreground-end" - ]) + @Test + func `Explicit disconnect runs the per-session teardown the loss path performs`() async { + let appState = AppState() + appState.settingsEventsTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(60)) + } } + appState.navigation.nodesShowingDiscovery = true - @Test("Explicit disconnect runs the per-session teardown the loss path performs") - func explicitDisconnectTearsDownSessionState() async { - let appState = AppState() - appState.settingsEventsTask = Task { - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(60)) - } - } - appState.navigation.nodesShowingDiscovery = true - - await appState.disconnect() + await appState.disconnect() - #expect(appState.settingsEventsTask == nil) - #expect(appState.navigation.nodesShowingDiscovery == false) - } + #expect(appState.settingsEventsTask == nil) + #expect(appState.navigation.nodesShowingDiscovery == false) + } - @Test("rapid background-active bounces keep BLE transitions ordered") - func rapidBouncesKeepTransitionsOrdered() async { - let appState = AppState() - let recorder = TransitionRecorder() + @Test + func `rapid background-active bounces keep BLE transitions ordered`() async { + let appState = AppState() + let recorder = TransitionRecorder() - appState.setBLELifecycleOverridesForTesting( - enterBackground: { - try? await Task.sleep(for: .milliseconds(20)) - await recorder.record("background") - }, - becomeActive: { - await recorder.record("foreground") - } - ) + appState.setBLELifecycleOverridesForTesting( + enterBackground: { + try? await Task.sleep(for: .milliseconds(20)) + await recorder.record("background") + }, + becomeActive: { + await recorder.record("foreground") + } + ) - for _ in 0..<5 { - appState.handleEnterBackground() - await appState.handleReturnToForeground() - } + for _ in 0..<5 { + appState.handleEnterBackground() + await appState.handleReturnToForeground() + } - let events = await recorder.events - #expect(events.count == 10) + let events = await recorder.events + #expect(events.count == 10) - for index in stride(from: 0, to: events.count, by: 2) { - #expect(events[index] == "background") - #expect(events[index + 1] == "foreground") - } + for index in stride(from: 0, to: events.count, by: 2) { + #expect(events[index] == "background") + #expect(events[index + 1] == "foreground") } + } } private actor TransitionRecorder { - private(set) var events: [String] = [] + private(set) var events: [String] = [] - func record(_ event: String) { - events.append(event) - } + func record(_ event: String) { + events.append(event) + } } diff --git a/MC1Tests/AppState/NavigationCoordinatorTests.swift b/MC1Tests/AppState/NavigationCoordinatorTests.swift index 6d7b5b65..06cc45d8 100644 --- a/MC1Tests/AppState/NavigationCoordinatorTests.swift +++ b/MC1Tests/AppState/NavigationCoordinatorTests.swift @@ -1,483 +1,480 @@ -import Testing -import Foundation import CoreLocation -@testable import MC1Services +import Foundation @testable import MC1 +@testable import MC1Services +import Testing @Suite("Navigation Coordinator Notification Handler Tests") @MainActor struct NavigationCoordinatorNotificationTests { - - // MARK: - Test Helpers - - private static func makeContact( - id: UUID = UUID(), - radioID: UUID = UUID(), - name: String = "TestContact" - ) -> ContactDTO { - ContactDTO( - id: id, - radioID: radioID, - publicKey: Data(repeating: 0xAA, count: 32), - name: name, - typeRawValue: 0x01, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - ocvPreset: nil, - customOCVArrayString: nil - ) - } - - private static func makeChannel( - id: UUID = UUID(), - radioID: UUID = UUID(), - name: String = "TestChannel", - index: UInt8 = 0 - ) -> ChannelDTO { - ChannelDTO( - id: id, - radioID: radioID, - index: index, - name: name, - secret: Data(), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false - ) - } - - private static func makeDeviceDTO(manualAddContacts: Bool = false) -> DeviceDTO { - DeviceDTO( - id: UUID(), - publicKey: Data(repeating: 0xBB, count: 32), - nodeName: "TestNode", - firmwareVersion: 1, - firmwareVersionString: "1.12.0", - manufacturerName: "Test", - buildDate: "2025-01-01", - maxContacts: 100, - maxChannels: 8, - frequency: 915_000, - bandwidth: 250_000, - spreadingFactor: 10, - codingRate: 5, - txPower: 20, - maxTxPower: 20, - latitude: 0, - longitude: 0, - blePin: 0, - manualAddContacts: manualAddContacts, - multiAcks: 2, - telemetryModeBase: 2, - telemetryModeLoc: 0, - telemetryModeEnv: 0, - advertLocationPolicy: 0, - lastConnected: Date(), - lastContactSync: 0, - isActive: true, - ocvPreset: nil, - customOCVArrayString: nil - ) - } - - /// Creates an in-memory data store seeded with a contact and channel. - private static func makeSeededDataStore( - contact: ContactDTO, - channel: ChannelDTO - ) async throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - try await dataStore.saveContact(contact) - try await dataStore.saveChannel(channel) - return dataStore - } - - // MARK: - DM Notification Tap - - @Test("DM notification tap navigates to chat with contact") - func dmNotificationTapNavigatesToChat() async throws { - let contact = Self.makeContact() - let dataStore = try await Self.makeSeededDataStore( - contact: contact, - channel: Self.makeChannel() - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { nil } - ) - - // Invoke the handler directly - await notificationService.onNotificationTapped?(contact.id) - - #expect(coordinator.pendingChatContact?.id == contact.id) - #expect(coordinator.chatsSelectedRoute == .direct(contact)) - #expect(coordinator.selectedTab == 0) - } - - // MARK: - New Contact Notification Tap - - @Test("New contact notification with manualAddContacts navigates to discovery") - func newContactManualAddNavigatesToDiscovery() async throws { - let contact = Self.makeContact() - let dataStore = try await Self.makeSeededDataStore( - contact: contact, - channel: Self.makeChannel() - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - let device = Self.makeDeviceDTO(manualAddContacts: true) - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { device } - ) - - await notificationService.onNewContactNotificationTapped?(contact.id) - - #expect(coordinator.pendingDiscoveryNavigation == true) - #expect(coordinator.selectedTab == 1) - } - - @Test("New contact notification without manualAddContacts navigates to contact detail") - func newContactAutoAddNavigatesToContactDetail() async throws { - let contact = Self.makeContact() - let dataStore = try await Self.makeSeededDataStore( - contact: contact, - channel: Self.makeChannel() - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - let device = Self.makeDeviceDTO(manualAddContacts: false) - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { device } - ) - - await notificationService.onNewContactNotificationTapped?(contact.id) - - #expect(coordinator.pendingContactDetail?.id == contact.id) - #expect(coordinator.selectedTab == 1) - } - - // MARK: - Channel Notification Tap - - @Test("Channel notification tap navigates to channel") - func channelNotificationTapNavigatesToChannel() async throws { - let radioID = UUID() - let channelIndex: UInt8 = 3 - let channel = Self.makeChannel(radioID: radioID, index: channelIndex) - let dataStore = try await Self.makeSeededDataStore( - contact: Self.makeContact(), - channel: channel - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { nil } - ) - - await notificationService.onChannelNotificationTapped?(radioID, channelIndex) - - #expect(coordinator.pendingChannel?.id == channel.id) - #expect(coordinator.chatsSelectedRoute == .channel(channel)) - #expect(coordinator.selectedTab == 0) - } - - // MARK: - Reaction Notification Tap - - @Test("Reaction notification on DM navigates to chat with scrollToMessageID") - func reactionOnDMNavigatesToChatWithScroll() async throws { - let contact = Self.makeContact() - let messageID = UUID() - let dataStore = try await Self.makeSeededDataStore( - contact: contact, - channel: Self.makeChannel() - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { nil } - ) - - await notificationService.onReactionNotificationTapped?(contact.id, nil, nil, messageID) - - #expect(coordinator.pendingChatContact?.id == contact.id) - #expect(coordinator.pendingScrollToMessageID == messageID) - #expect(coordinator.selectedTab == 0) - } - - @Test("Reaction notification on channel navigates to channel with scrollToMessageID") - func reactionOnChannelNavigatesToChannelWithScroll() async throws { - let radioID = UUID() - let channelIndex: UInt8 = 1 - let channel = Self.makeChannel(radioID: radioID, index: channelIndex) - let messageID = UUID() - let dataStore = try await Self.makeSeededDataStore( - contact: Self.makeContact(), - channel: channel - ) - let coordinator = NavigationCoordinator() - let notificationService = NotificationService() - - coordinator.configureNotificationHandlers( - notificationService: notificationService, - dataStore: dataStore, - connectedDevice: { nil } - ) - - // contactID is nil → falls through to channel branch - await notificationService.onReactionNotificationTapped?(nil, channelIndex, radioID, messageID) - - #expect(coordinator.pendingChannel?.id == channel.id) - #expect(coordinator.pendingScrollToMessageID == messageID) - #expect(coordinator.selectedTab == 0) - } + // MARK: - Test Helpers + + private static func makeContact( + id: UUID = UUID(), + radioID: UUID = UUID(), + name: String = "TestContact" + ) -> ContactDTO { + ContactDTO( + id: id, + radioID: radioID, + publicKey: Data(repeating: 0xAA, count: 32), + name: name, + typeRawValue: 0x01, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + ocvPreset: nil, + customOCVArrayString: nil + ) + } + + private static func makeChannel( + id: UUID = UUID(), + radioID: UUID = UUID(), + name: String = "TestChannel", + index: UInt8 = 0 + ) -> ChannelDTO { + ChannelDTO( + id: id, + radioID: radioID, + index: index, + name: name, + secret: Data(), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + } + + private static func makeDeviceDTO(manualAddContacts: Bool = false) -> DeviceDTO { + DeviceDTO( + id: UUID(), + publicKey: Data(repeating: 0xBB, count: 32), + nodeName: "TestNode", + firmwareVersion: 1, + firmwareVersionString: "1.12.0", + manufacturerName: "Test", + buildDate: "2025-01-01", + maxContacts: 100, + maxChannels: 8, + frequency: 915_000, + bandwidth: 250_000, + spreadingFactor: 10, + codingRate: 5, + txPower: 20, + maxTxPower: 20, + latitude: 0, + longitude: 0, + blePin: 0, + manualAddContacts: manualAddContacts, + multiAcks: 2, + telemetryModeBase: 2, + telemetryModeLoc: 0, + telemetryModeEnv: 0, + advertLocationPolicy: 0, + lastConnected: Date(), + lastContactSync: 0, + isActive: true, + ocvPreset: nil, + customOCVArrayString: nil + ) + } + + /// Creates an in-memory data store seeded with a contact and channel. + private static func makeSeededDataStore( + contact: ContactDTO, + channel: ChannelDTO + ) async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + try await dataStore.saveContact(contact) + try await dataStore.saveChannel(channel) + return dataStore + } + + // MARK: - DM Notification Tap + + @Test + func `DM notification tap navigates to chat with contact`() async throws { + let contact = Self.makeContact() + let dataStore = try await Self.makeSeededDataStore( + contact: contact, + channel: Self.makeChannel() + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { nil } + ) + + // Invoke the handler directly + await notificationService.onNotificationTapped?(contact.id) + + #expect(coordinator.pendingChatContact?.id == contact.id) + #expect(coordinator.chatsSelectedRoute == .direct(contact)) + #expect(coordinator.selectedTab == 0) + } + + // MARK: - New Contact Notification Tap + + @Test + func `New contact notification with manualAddContacts navigates to discovery`() async throws { + let contact = Self.makeContact() + let dataStore = try await Self.makeSeededDataStore( + contact: contact, + channel: Self.makeChannel() + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + let device = Self.makeDeviceDTO(manualAddContacts: true) + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { device } + ) + + await notificationService.onNewContactNotificationTapped?(contact.id) + + #expect(coordinator.pendingDiscoveryNavigation == true) + #expect(coordinator.selectedTab == 1) + } + + @Test + func `New contact notification without manualAddContacts navigates to contact detail`() async throws { + let contact = Self.makeContact() + let dataStore = try await Self.makeSeededDataStore( + contact: contact, + channel: Self.makeChannel() + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + let device = Self.makeDeviceDTO(manualAddContacts: false) + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { device } + ) + + await notificationService.onNewContactNotificationTapped?(contact.id) + + #expect(coordinator.pendingContactDetail?.id == contact.id) + #expect(coordinator.selectedTab == 1) + } + + // MARK: - Channel Notification Tap + + @Test + func `Channel notification tap navigates to channel`() async throws { + let radioID = UUID() + let channelIndex: UInt8 = 3 + let channel = Self.makeChannel(radioID: radioID, index: channelIndex) + let dataStore = try await Self.makeSeededDataStore( + contact: Self.makeContact(), + channel: channel + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { nil } + ) + + await notificationService.onChannelNotificationTapped?(radioID, channelIndex) + + #expect(coordinator.pendingChannel?.id == channel.id) + #expect(coordinator.chatsSelectedRoute == .channel(channel)) + #expect(coordinator.selectedTab == 0) + } + + // MARK: - Reaction Notification Tap + + @Test + func `Reaction notification on DM navigates to chat with scrollToMessageID`() async throws { + let contact = Self.makeContact() + let messageID = UUID() + let dataStore = try await Self.makeSeededDataStore( + contact: contact, + channel: Self.makeChannel() + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { nil } + ) + + await notificationService.onReactionNotificationTapped?(contact.id, nil, nil, messageID) + + #expect(coordinator.pendingChatContact?.id == contact.id) + #expect(coordinator.pendingScrollToMessageID == messageID) + #expect(coordinator.selectedTab == 0) + } + + @Test + func `Reaction notification on channel navigates to channel with scrollToMessageID`() async throws { + let radioID = UUID() + let channelIndex: UInt8 = 1 + let channel = Self.makeChannel(radioID: radioID, index: channelIndex) + let messageID = UUID() + let dataStore = try await Self.makeSeededDataStore( + contact: Self.makeContact(), + channel: channel + ) + let coordinator = NavigationCoordinator() + let notificationService = NotificationService() + + coordinator.configureNotificationHandlers( + notificationService: notificationService, + dataStore: dataStore, + connectedDevice: { nil } + ) + + // contactID is nil → falls through to channel branch + await notificationService.onReactionNotificationTapped?(nil, channelIndex, radioID, messageID) + + #expect(coordinator.pendingChannel?.id == channel.id) + #expect(coordinator.pendingScrollToMessageID == messageID) + #expect(coordinator.selectedTab == 0) + } } @Suite("NavigationCoordinator Map Navigation Tests") @MainActor struct NavigationCoordinatorMapTests { - - @Test("navigateToMap sets pendingMapFocus and selects the map tab") - func navigateToMapSetsFocusAndTab() { - let coordinator = NavigationCoordinator() - let coordinate = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.00902) - - coordinator.navigateToMap(coordinate: coordinate) - - #expect(coordinator.pendingMapFocus?.latitude == 37.3349) - #expect(coordinator.pendingMapFocus?.longitude == -122.00902) - #expect(coordinator.pendingMapFocus?.coordinate.latitude == 37.3349) - #expect(coordinator.selectedTab == AppTab.map.rawValue) - } - - @Test("ChatViewModel.navigateToMap forwards the coordinate to the navigation sink") - func chatViewModelForwardsToNavigationSink() { - let coordinator = NavigationCoordinator() - let viewModel = ChatViewModel() - viewModel.onNavigateToMap = { coordinator.navigateToMap(coordinate: $0) } - let coordinate = CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278) - - // The thumbnail tap path ends at ChatViewModel.navigateToMap, which forwards - // to the same navigation sink ChatsView.handleMeshCoreLink uses for the text link. - viewModel.navigateToMap(coordinate) - - #expect(coordinator.pendingMapFocus?.latitude == 51.5074) - #expect(coordinator.pendingMapFocus?.longitude == -0.1278) - #expect(coordinator.selectedTab == AppTab.map.rawValue) - } - - @Test("clearPendingMapFocus resets the pending focus") - func clearPendingMapFocusResets() { - let coordinator = NavigationCoordinator() - coordinator.navigateToMap(coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2)) - - coordinator.clearPendingMapFocus() - - #expect(coordinator.pendingMapFocus == nil) - } + @Test + func `navigateToMap sets pendingMapFocus and selects the map tab`() { + let coordinator = NavigationCoordinator() + let coordinate = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.00902) + + coordinator.navigateToMap(coordinate: coordinate) + + #expect(coordinator.pendingMapFocus?.latitude == 37.3349) + #expect(coordinator.pendingMapFocus?.longitude == -122.00902) + #expect(coordinator.pendingMapFocus?.coordinate.latitude == 37.3349) + #expect(coordinator.selectedTab == AppTab.map.rawValue) + } + + @Test + func `ChatViewModel.navigateToMap forwards the coordinate to the navigation sink`() { + let coordinator = NavigationCoordinator() + let viewModel = ChatViewModel() + viewModel.onNavigateToMap = { coordinator.navigateToMap(coordinate: $0) } + let coordinate = CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278) + + // The thumbnail tap path ends at ChatViewModel.navigateToMap, which forwards + // to the same navigation sink ChatsView.handleMeshCoreLink uses for the text link. + viewModel.navigateToMap(coordinate) + + #expect(coordinator.pendingMapFocus?.latitude == 51.5074) + #expect(coordinator.pendingMapFocus?.longitude == -0.1278) + #expect(coordinator.selectedTab == AppTab.map.rawValue) + } + + @Test + func `clearPendingMapFocus resets the pending focus`() { + let coordinator = NavigationCoordinator() + coordinator.navigateToMap(coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2)) + + coordinator.clearPendingMapFocus() + + #expect(coordinator.pendingMapFocus == nil) + } } @Suite("NavigationCoordinator Pending Link Tests") @MainActor struct NavigationCoordinatorPendingLinkTests { - - @Test("pendingContactLink starts nil and clears via helper") - func pendingContactLinkClears() { - let coordinator = NavigationCoordinator() - #expect(coordinator.pendingContactLink == nil) - coordinator.pendingContactLink = MeshCoreURLParser.ContactResult( - name: "Alice", - publicKey: Data(repeating: 0xAB, count: 32), - contactType: .chat - ) - #expect(coordinator.pendingContactLink != nil) - coordinator.clearPendingContactLink() - #expect(coordinator.pendingContactLink == nil) - } - - @Test("pendingChannelLink starts nil and clears via helper") - func pendingChannelLinkClears() { - let coordinator = NavigationCoordinator() - #expect(coordinator.pendingChannelLink == nil) - coordinator.pendingChannelLink = MeshCoreURLParser.ChannelResult( - name: "general", - secret: Data(repeating: 0xCC, count: 16) - ) - #expect(coordinator.pendingChannelLink != nil) - coordinator.clearPendingChannelLink() - #expect(coordinator.pendingChannelLink == nil) - } - - @Test("pendingHashtag starts nil and clears via helper") - func pendingHashtagClears() { - let coordinator = NavigationCoordinator() - #expect(coordinator.pendingHashtag == nil) - coordinator.pendingHashtag = HashtagJoinRequest(id: "#general") - #expect(coordinator.pendingHashtag != nil) - coordinator.clearPendingHashtag() - #expect(coordinator.pendingHashtag == nil) - } - - // MARK: - clearPendingLinks (per-radio teardown) - - private static func makeContact(name: String = "TestContact") -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0xAA, count: 32), - name: name, - typeRawValue: 0x01, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - ocvPreset: nil, - customOCVArrayString: nil - ) - } - - @Test("clearPendingLinks clears the hoisted Nodes selected contact") - func clearPendingLinksClearsSelectedContact() { - let coordinator = NavigationCoordinator() - coordinator.selectedContact = Self.makeContact() - #expect(coordinator.selectedContact != nil) - - coordinator.clearPendingLinks() - - #expect(coordinator.selectedContact == nil) - } - - @Test("clearPendingLinks clears the hoisted Nodes discovery flag") - func clearPendingLinksClearsNodesShowingDiscovery() { - let coordinator = NavigationCoordinator() - coordinator.nodesShowingDiscovery = true - - coordinator.clearPendingLinks() - - #expect(coordinator.nodesShowingDiscovery == false) - } - - @Test("clearPendingLinks clears every staged per-radio field at once") - func clearPendingLinksClearsAllPendingFields() { - let coordinator = NavigationCoordinator() - coordinator.pendingContactLink = MeshCoreURLParser.ContactResult( - name: "Alice", - publicKey: Data(repeating: 0xAB, count: 32), - contactType: .chat - ) - coordinator.pendingChannelLink = MeshCoreURLParser.ChannelResult( - name: "general", - secret: Data(repeating: 0xCC, count: 16) - ) - coordinator.pendingHashtag = HashtagJoinRequest(id: "#general") - coordinator.selectedContact = Self.makeContact() - coordinator.nodesShowingDiscovery = true - coordinator.chatsSelectedRoute = .direct(Self.makeContact()) - coordinator.selectedTool = .cli - - coordinator.clearPendingLinks() - - #expect(coordinator.pendingContactLink == nil) - #expect(coordinator.pendingChannelLink == nil) - #expect(coordinator.pendingHashtag == nil) - #expect(coordinator.selectedContact == nil) - #expect(coordinator.nodesShowingDiscovery == false) - #expect(coordinator.chatsSelectedRoute == nil) - #expect(coordinator.selectedTool == nil) - } - - // MARK: - clearPerRadioSelection - - @Test("clearPerRadioSelection clears the hoisted Chats route") - func clearPerRadioSelectionClearsChatsRoute() { - let coordinator = NavigationCoordinator() - coordinator.chatsSelectedRoute = .direct(Self.makeContact()) - - coordinator.clearPerRadioSelection() - - #expect(coordinator.chatsSelectedRoute == nil) - } - - @Test("clearPerRadioSelection clears a radio-requiring tool") - func clearPerRadioSelectionClearsRadioRequiringTool() { - let coordinator = NavigationCoordinator() - coordinator.selectedTool = .cli - #expect(coordinator.selectedTool?.requiresRadio == true) - - coordinator.clearPerRadioSelection() - - #expect(coordinator.selectedTool == nil) - } - - @Test("clearPerRadioSelection preserves the offline Line of Sight tool") - func clearPerRadioSelectionPreservesOfflineTool() { - let coordinator = NavigationCoordinator() - coordinator.selectedTool = .lineOfSight - #expect(coordinator.selectedTool?.requiresRadio == false) - - coordinator.clearPerRadioSelection() - - #expect(coordinator.selectedTool == .lineOfSight) - } - - @Test("clearPerRadioSelection clears a per-device settings page") - func clearPerRadioSelectionClearsDeviceSetting() { - let coordinator = NavigationCoordinator() - coordinator.selectedSetting = .radio - #expect(coordinator.selectedSetting?.requiresDevice == true) - - coordinator.clearPerRadioSelection() - - #expect(coordinator.selectedSetting == nil) - } - - @Test("clearPerRadioSelection preserves a device-independent settings page") - func clearPerRadioSelectionPreservesAppSetting() { - let coordinator = NavigationCoordinator() - coordinator.selectedSetting = .appearance - #expect(coordinator.selectedSetting?.requiresDevice == false) - - coordinator.clearPerRadioSelection() - - #expect(coordinator.selectedSetting == .appearance) - } + @Test + func `pendingContactLink starts nil and clears via helper`() { + let coordinator = NavigationCoordinator() + #expect(coordinator.pendingContactLink == nil) + coordinator.pendingContactLink = MeshCoreURLParser.ContactResult( + name: "Alice", + publicKey: Data(repeating: 0xAB, count: 32), + contactType: .chat + ) + #expect(coordinator.pendingContactLink != nil) + coordinator.clearPendingContactLink() + #expect(coordinator.pendingContactLink == nil) + } + + @Test + func `pendingChannelLink starts nil and clears via helper`() { + let coordinator = NavigationCoordinator() + #expect(coordinator.pendingChannelLink == nil) + coordinator.pendingChannelLink = MeshCoreURLParser.ChannelResult( + name: "general", + secret: Data(repeating: 0xCC, count: 16) + ) + #expect(coordinator.pendingChannelLink != nil) + coordinator.clearPendingChannelLink() + #expect(coordinator.pendingChannelLink == nil) + } + + @Test + func `pendingHashtag starts nil and clears via helper`() { + let coordinator = NavigationCoordinator() + #expect(coordinator.pendingHashtag == nil) + coordinator.pendingHashtag = HashtagJoinRequest(id: "#general") + #expect(coordinator.pendingHashtag != nil) + coordinator.clearPendingHashtag() + #expect(coordinator.pendingHashtag == nil) + } + + // MARK: - clearPendingLinks (per-radio teardown) + + private static func makeContact(name: String = "TestContact") -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0xAA, count: 32), + name: name, + typeRawValue: 0x01, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + ocvPreset: nil, + customOCVArrayString: nil + ) + } + + @Test + func `clearPendingLinks clears the hoisted Nodes selected contact`() { + let coordinator = NavigationCoordinator() + coordinator.selectedContact = Self.makeContact() + #expect(coordinator.selectedContact != nil) + + coordinator.clearPendingLinks() + + #expect(coordinator.selectedContact == nil) + } + + @Test + func `clearPendingLinks clears the hoisted Nodes discovery flag`() { + let coordinator = NavigationCoordinator() + coordinator.nodesShowingDiscovery = true + + coordinator.clearPendingLinks() + + #expect(coordinator.nodesShowingDiscovery == false) + } + + @Test + func `clearPendingLinks clears every staged per-radio field at once`() { + let coordinator = NavigationCoordinator() + coordinator.pendingContactLink = MeshCoreURLParser.ContactResult( + name: "Alice", + publicKey: Data(repeating: 0xAB, count: 32), + contactType: .chat + ) + coordinator.pendingChannelLink = MeshCoreURLParser.ChannelResult( + name: "general", + secret: Data(repeating: 0xCC, count: 16) + ) + coordinator.pendingHashtag = HashtagJoinRequest(id: "#general") + coordinator.selectedContact = Self.makeContact() + coordinator.nodesShowingDiscovery = true + coordinator.chatsSelectedRoute = .direct(Self.makeContact()) + coordinator.selectedTool = .cli + + coordinator.clearPendingLinks() + + #expect(coordinator.pendingContactLink == nil) + #expect(coordinator.pendingChannelLink == nil) + #expect(coordinator.pendingHashtag == nil) + #expect(coordinator.selectedContact == nil) + #expect(coordinator.nodesShowingDiscovery == false) + #expect(coordinator.chatsSelectedRoute == nil) + #expect(coordinator.selectedTool == nil) + } + + // MARK: - clearPerRadioSelection + + @Test + func `clearPerRadioSelection clears the hoisted Chats route`() { + let coordinator = NavigationCoordinator() + coordinator.chatsSelectedRoute = .direct(Self.makeContact()) + + coordinator.clearPerRadioSelection() + + #expect(coordinator.chatsSelectedRoute == nil) + } + + @Test + func `clearPerRadioSelection clears a radio-requiring tool`() { + let coordinator = NavigationCoordinator() + coordinator.selectedTool = .cli + #expect(coordinator.selectedTool?.requiresRadio == true) + + coordinator.clearPerRadioSelection() + + #expect(coordinator.selectedTool == nil) + } + + @Test + func `clearPerRadioSelection preserves the offline Line of Sight tool`() { + let coordinator = NavigationCoordinator() + coordinator.selectedTool = .lineOfSight + #expect(coordinator.selectedTool?.requiresRadio == false) + + coordinator.clearPerRadioSelection() + + #expect(coordinator.selectedTool == .lineOfSight) + } + + @Test + func `clearPerRadioSelection clears a per-device settings page`() { + let coordinator = NavigationCoordinator() + coordinator.selectedSetting = .radio + #expect(coordinator.selectedSetting?.requiresDevice == true) + + coordinator.clearPerRadioSelection() + + #expect(coordinator.selectedSetting == nil) + } + + @Test + func `clearPerRadioSelection preserves a device-independent settings page`() { + let coordinator = NavigationCoordinator() + coordinator.selectedSetting = .appearance + #expect(coordinator.selectedSetting?.requiresDevice == false) + + coordinator.clearPerRadioSelection() + + #expect(coordinator.selectedSetting == .appearance) + } } diff --git a/MC1Tests/AppState/NavigationStateTests.swift b/MC1Tests/AppState/NavigationStateTests.swift index e3d4b187..8b73bcfc 100644 --- a/MC1Tests/AppState/NavigationStateTests.swift +++ b/MC1Tests/AppState/NavigationStateTests.swift @@ -1,343 +1,342 @@ -import Testing import Foundation -@testable import MC1Services @testable import MC1 +@testable import MC1Services +import Testing @Suite("Navigation State Tests") @MainActor struct NavigationStateTests { + // MARK: - Test Helpers + + private static func makeContact( + id: UUID = UUID(), + name: String = "TestContact" + ) -> ContactDTO { + ContactDTO( + id: id, + radioID: UUID(), + publicKey: Data(repeating: 0xAA, count: 32), + name: name, + typeRawValue: 0x01, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + ocvPreset: nil, + customOCVArrayString: nil + ) + } + + private static func makeChannel( + id: UUID = UUID(), + name: String = "TestChannel", + index: UInt8 = 0 + ) -> ChannelDTO { + ChannelDTO( + id: id, + radioID: UUID(), + index: index, + name: name, + secret: Data(), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + } + + private static func makeRoomSession( + id: UUID = UUID(), + name: String = "TestRoom" + ) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: id, + radioID: UUID(), + publicKey: Data(repeating: 0xBB, count: 32), + name: name, + role: .roomServer, + latitude: 0, + longitude: 0, + isConnected: false, + permissionLevel: .readWrite, + lastConnectedDate: nil, + lastBatteryMillivolts: nil, + lastUptimeSeconds: nil, + lastNoiseFloor: nil, + unreadCount: 0, + notificationLevel: .all, + isFavorite: false, + lastRxAirtimeSeconds: nil, + neighborCount: 0, + lastSyncTimestamp: 0, + lastMessageDate: nil + ) + } + + // MARK: - Default State + + @Test + func `Default navigation state is tab 0 with no pending navigation`() { + let appState = AppState() + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.pendingChatContact == nil) + #expect(appState.navigation.pendingChannel == nil) + #expect(appState.navigation.pendingRoomSession == nil) + #expect(appState.navigation.pendingRoomAuthentication == nil) + #expect(appState.navigation.pendingDiscoveryNavigation == false) + #expect(appState.navigation.pendingContactDetail == nil) + #expect(appState.navigation.pendingScrollToMessageID == nil) + #expect(appState.navigation.chatsSelectedRoute == nil) + #expect(appState.navigation.tabBarVisibility == .visible) + } + + // MARK: - navigateToChat + + @Test + func `navigateToChat sets contact, route, and tab`() { + let appState = AppState() + let contact = Self.makeContact() + + appState.navigation.navigateToChat(with: contact) + + #expect(appState.navigation.pendingChatContact == contact) + #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.tabBarVisibility == .hidden) + #expect(appState.navigation.pendingScrollToMessageID == nil) + } + + @Test + func `navigateToChat with scrollToMessageID sets message ID`() { + let appState = AppState() + let contact = Self.makeContact() + let messageID = UUID() + + appState.navigation.navigateToChat(with: contact, scrollToMessageID: messageID) + + #expect(appState.navigation.pendingChatContact == contact) + #expect(appState.navigation.pendingScrollToMessageID == messageID) + #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) + #expect(appState.navigation.selectedTab == 0) + } + + @Test + func `navigateToChat switches to Chats tab from another tab`() { + let appState = AppState() + appState.navigation.selectedTab = 3 // Settings tab + let contact = Self.makeContact() + + appState.navigation.navigateToChat(with: contact) + + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.pendingChatContact == contact) + } + + // MARK: - navigateToRoom + + @Test + func `navigateToRoom sets session, route, and tab`() { + let appState = AppState() + let session = Self.makeRoomSession() + + appState.navigation.navigateToRoom(with: session) + + #expect(appState.navigation.pendingRoomSession == session) + #expect(appState.navigation.chatsSelectedRoute == .room(session)) + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.tabBarVisibility == .hidden) + } + + // MARK: - navigateToChannel + + @Test + func `navigateToChannel sets channel, route, and tab`() { + let appState = AppState() + let channel = Self.makeChannel() + + appState.navigation.navigateToChannel(with: channel) + + #expect(appState.navigation.pendingChannel == channel) + #expect(appState.navigation.chatsSelectedRoute == .channel(channel)) + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.tabBarVisibility == .hidden) + #expect(appState.navigation.pendingScrollToMessageID == nil) + } - // MARK: - Test Helpers - - private static func makeContact( - id: UUID = UUID(), - name: String = "TestContact" - ) -> ContactDTO { - ContactDTO( - id: id, - radioID: UUID(), - publicKey: Data(repeating: 0xAA, count: 32), - name: name, - typeRawValue: 0x01, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - ocvPreset: nil, - customOCVArrayString: nil - ) - } - - private static func makeChannel( - id: UUID = UUID(), - name: String = "TestChannel", - index: UInt8 = 0 - ) -> ChannelDTO { - ChannelDTO( - id: id, - radioID: UUID(), - index: index, - name: name, - secret: Data(), - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false - ) - } - - private static func makeRoomSession( - id: UUID = UUID(), - name: String = "TestRoom" - ) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: id, - radioID: UUID(), - publicKey: Data(repeating: 0xBB, count: 32), - name: name, - role: .roomServer, - latitude: 0, - longitude: 0, - isConnected: false, - permissionLevel: .readWrite, - lastConnectedDate: nil, - lastBatteryMillivolts: nil, - lastUptimeSeconds: nil, - lastNoiseFloor: nil, - unreadCount: 0, - notificationLevel: .all, - isFavorite: false, - lastRxAirtimeSeconds: nil, - neighborCount: 0, - lastSyncTimestamp: 0, - lastMessageDate: nil - ) - } - - // MARK: - Default State - - @Test("Default navigation state is tab 0 with no pending navigation") - func defaultState() { - let appState = AppState() - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.pendingChatContact == nil) - #expect(appState.navigation.pendingChannel == nil) - #expect(appState.navigation.pendingRoomSession == nil) - #expect(appState.navigation.pendingRoomAuthentication == nil) - #expect(appState.navigation.pendingDiscoveryNavigation == false) - #expect(appState.navigation.pendingContactDetail == nil) - #expect(appState.navigation.pendingScrollToMessageID == nil) - #expect(appState.navigation.chatsSelectedRoute == nil) - #expect(appState.navigation.tabBarVisibility == .visible) - } - - // MARK: - navigateToChat - - @Test("navigateToChat sets contact, route, and tab") - func navigateToChat() { - let appState = AppState() - let contact = Self.makeContact() - - appState.navigation.navigateToChat(with: contact) - - #expect(appState.navigation.pendingChatContact == contact) - #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.tabBarVisibility == .hidden) - #expect(appState.navigation.pendingScrollToMessageID == nil) - } - - @Test("navigateToChat with scrollToMessageID sets message ID") - func navigateToChatWithScrollTo() { - let appState = AppState() - let contact = Self.makeContact() - let messageID = UUID() - - appState.navigation.navigateToChat(with: contact, scrollToMessageID: messageID) - - #expect(appState.navigation.pendingChatContact == contact) - #expect(appState.navigation.pendingScrollToMessageID == messageID) - #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) - #expect(appState.navigation.selectedTab == 0) - } - - @Test("navigateToChat switches to Chats tab from another tab") - func navigateToChatFromOtherTab() { - let appState = AppState() - appState.navigation.selectedTab = 3 // Settings tab - let contact = Self.makeContact() - - appState.navigation.navigateToChat(with: contact) - - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.pendingChatContact == contact) - } - - // MARK: - navigateToRoom - - @Test("navigateToRoom sets session, route, and tab") - func navigateToRoom() { - let appState = AppState() - let session = Self.makeRoomSession() - - appState.navigation.navigateToRoom(with: session) - - #expect(appState.navigation.pendingRoomSession == session) - #expect(appState.navigation.chatsSelectedRoute == .room(session)) - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.tabBarVisibility == .hidden) - } - - // MARK: - navigateToChannel - - @Test("navigateToChannel sets channel, route, and tab") - func navigateToChannel() { - let appState = AppState() - let channel = Self.makeChannel() - - appState.navigation.navigateToChannel(with: channel) - - #expect(appState.navigation.pendingChannel == channel) - #expect(appState.navigation.chatsSelectedRoute == .channel(channel)) - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.tabBarVisibility == .hidden) - #expect(appState.navigation.pendingScrollToMessageID == nil) - } - - @Test("navigateToChannel with scrollToMessageID sets message ID") - func navigateToChannelWithScrollTo() { - let appState = AppState() - let channel = Self.makeChannel() - let messageID = UUID() + @Test + func `navigateToChannel with scrollToMessageID sets message ID`() { + let appState = AppState() + let channel = Self.makeChannel() + let messageID = UUID() - appState.navigation.navigateToChannel(with: channel, scrollToMessageID: messageID) + appState.navigation.navigateToChannel(with: channel, scrollToMessageID: messageID) - #expect(appState.navigation.pendingChannel == channel) - #expect(appState.navigation.pendingScrollToMessageID == messageID) - } + #expect(appState.navigation.pendingChannel == channel) + #expect(appState.navigation.pendingScrollToMessageID == messageID) + } - // MARK: - navigateToDiscovery + // MARK: - navigateToDiscovery - @Test("navigateToDiscovery sets pending flag and contacts tab") - func navigateToDiscovery() { - let appState = AppState() + @Test + func `navigateToDiscovery sets pending flag and contacts tab`() { + let appState = AppState() - appState.navigation.navigateToDiscovery() + appState.navigation.navigateToDiscovery() - #expect(appState.navigation.pendingDiscoveryNavigation == true) - #expect(appState.navigation.selectedTab == 1) - } + #expect(appState.navigation.pendingDiscoveryNavigation == true) + #expect(appState.navigation.selectedTab == 1) + } - @Test("navigateToDiscovery does not hide tab bar") - func navigateToDiscoveryTabBarVisible() { - let appState = AppState() + @Test + func `navigateToDiscovery does not hide tab bar`() { + let appState = AppState() - appState.navigation.navigateToDiscovery() + appState.navigation.navigateToDiscovery() - #expect(appState.navigation.tabBarVisibility == .visible) - } + #expect(appState.navigation.tabBarVisibility == .visible) + } - // MARK: - navigateToContacts + // MARK: - navigateToContacts - @Test("navigateToContacts switches to contacts tab") - func navigateToContacts() { - let appState = AppState() - appState.navigation.selectedTab = 3 + @Test + func `navigateToContacts switches to contacts tab`() { + let appState = AppState() + appState.navigation.selectedTab = 3 - appState.navigation.navigateToContacts() + appState.navigation.navigateToContacts() - #expect(appState.navigation.selectedTab == 1) - } + #expect(appState.navigation.selectedTab == 1) + } - // MARK: - navigateToContactDetail + // MARK: - navigateToContactDetail - @Test("navigateToContactDetail sets contact and contacts tab") - func navigateToContactDetail() { - let appState = AppState() - let contact = Self.makeContact() + @Test + func `navigateToContactDetail sets contact and contacts tab`() { + let appState = AppState() + let contact = Self.makeContact() - appState.navigation.navigateToContactDetail(contact) + appState.navigation.navigateToContactDetail(contact) - #expect(appState.navigation.pendingContactDetail == contact) - #expect(appState.navigation.selectedTab == 1) - } + #expect(appState.navigation.pendingContactDetail == contact) + #expect(appState.navigation.selectedTab == 1) + } - // MARK: - Clear Methods + // MARK: - Clear Methods - @Test("clearPendingNavigation clears chat contact") - func clearPendingNavigation() { - let appState = AppState() - appState.navigation.pendingChatContact = Self.makeContact() + @Test + func `clearPendingNavigation clears chat contact`() { + let appState = AppState() + appState.navigation.pendingChatContact = Self.makeContact() - appState.navigation.clearPendingNavigation() + appState.navigation.clearPendingNavigation() - #expect(appState.navigation.pendingChatContact == nil) - } + #expect(appState.navigation.pendingChatContact == nil) + } - @Test("clearPendingRoomNavigation clears room session") - func clearPendingRoomNavigation() { - let appState = AppState() - appState.navigation.pendingRoomSession = Self.makeRoomSession() + @Test + func `clearPendingRoomNavigation clears room session`() { + let appState = AppState() + appState.navigation.pendingRoomSession = Self.makeRoomSession() - appState.navigation.clearPendingRoomNavigation() + appState.navigation.clearPendingRoomNavigation() - #expect(appState.navigation.pendingRoomSession == nil) - } + #expect(appState.navigation.pendingRoomSession == nil) + } - @Test("clearPendingRoomAuthentication clears room auth session") - func clearPendingRoomAuthentication() { - let appState = AppState() - appState.navigation.pendingRoomAuthentication = Self.makeRoomSession() + @Test + func `clearPendingRoomAuthentication clears room auth session`() { + let appState = AppState() + appState.navigation.pendingRoomAuthentication = Self.makeRoomSession() - appState.navigation.clearPendingRoomAuthentication() + appState.navigation.clearPendingRoomAuthentication() - #expect(appState.navigation.pendingRoomAuthentication == nil) - } + #expect(appState.navigation.pendingRoomAuthentication == nil) + } - @Test("clearPendingChannelNavigation clears channel") - func clearPendingChannelNavigation() { - let appState = AppState() - appState.navigation.pendingChannel = Self.makeChannel() + @Test + func `clearPendingChannelNavigation clears channel`() { + let appState = AppState() + appState.navigation.pendingChannel = Self.makeChannel() - appState.navigation.clearPendingChannelNavigation() + appState.navigation.clearPendingChannelNavigation() - #expect(appState.navigation.pendingChannel == nil) - } + #expect(appState.navigation.pendingChannel == nil) + } - @Test("clearPendingDiscoveryNavigation clears discovery flag") - func clearPendingDiscoveryNavigation() { - let appState = AppState() - appState.navigation.pendingDiscoveryNavigation = true + @Test + func `clearPendingDiscoveryNavigation clears discovery flag`() { + let appState = AppState() + appState.navigation.pendingDiscoveryNavigation = true - appState.navigation.clearPendingDiscoveryNavigation() + appState.navigation.clearPendingDiscoveryNavigation() - #expect(appState.navigation.pendingDiscoveryNavigation == false) - } + #expect(appState.navigation.pendingDiscoveryNavigation == false) + } - @Test("clearPendingScrollToMessage clears message ID") - func clearPendingScrollToMessage() { - let appState = AppState() - appState.navigation.pendingScrollToMessageID = UUID() + @Test + func `clearPendingScrollToMessage clears message ID`() { + let appState = AppState() + appState.navigation.pendingScrollToMessageID = UUID() - appState.navigation.clearPendingScrollToMessage() + appState.navigation.clearPendingScrollToMessage() - #expect(appState.navigation.pendingScrollToMessageID == nil) - } + #expect(appState.navigation.pendingScrollToMessageID == nil) + } - @Test("clearPendingContactDetailNavigation clears contact detail") - func clearPendingContactDetailNavigation() { - let appState = AppState() - appState.navigation.pendingContactDetail = Self.makeContact() + @Test + func `clearPendingContactDetailNavigation clears contact detail`() { + let appState = AppState() + appState.navigation.pendingContactDetail = Self.makeContact() - appState.navigation.clearPendingContactDetailNavigation() + appState.navigation.clearPendingContactDetailNavigation() - #expect(appState.navigation.pendingContactDetail == nil) - } + #expect(appState.navigation.pendingContactDetail == nil) + } - // MARK: - Cross-Tab Navigation + // MARK: - Cross-Tab Navigation - @Test("navigateToChat from contacts tab hides tab bar and switches tab") - func crossTabChatNavigation() { - let appState = AppState() - appState.navigation.selectedTab = 1 // Contacts tab - let contact = Self.makeContact() + @Test + func `navigateToChat from contacts tab hides tab bar and switches tab`() { + let appState = AppState() + appState.navigation.selectedTab = 1 // Contacts tab + let contact = Self.makeContact() - appState.navigation.navigateToChat(with: contact) + appState.navigation.navigateToChat(with: contact) - #expect(appState.navigation.tabBarVisibility == .hidden) - #expect(appState.navigation.selectedTab == 0) - #expect(appState.navigation.pendingChatContact == contact) - #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) - } + #expect(appState.navigation.tabBarVisibility == .hidden) + #expect(appState.navigation.selectedTab == 0) + #expect(appState.navigation.pendingChatContact == contact) + #expect(appState.navigation.chatsSelectedRoute == .direct(contact)) + } - @Test("Multiple navigation calls overwrite pending state") - func multipleNavigations() { - let appState = AppState() - let contact1 = Self.makeContact(name: "First") - let contact2 = Self.makeContact(name: "Second") + @Test + func `Multiple navigation calls overwrite pending state`() { + let appState = AppState() + let contact1 = Self.makeContact(name: "First") + let contact2 = Self.makeContact(name: "Second") - appState.navigation.navigateToChat(with: contact1) - appState.navigation.navigateToChat(with: contact2) + appState.navigation.navigateToChat(with: contact1) + appState.navigation.navigateToChat(with: contact2) - #expect(appState.navigation.pendingChatContact == contact2) - #expect(appState.navigation.chatsSelectedRoute == .direct(contact2)) - } + #expect(appState.navigation.pendingChatContact == contact2) + #expect(appState.navigation.chatsSelectedRoute == .direct(contact2)) + } - @Test("Device menu tip donation is pending by default when false") - func deviceMenuTipDonationDefault() { - let appState = AppState() - #expect(appState.navigation.pendingDeviceMenuTipDonation == false) - } + @Test + func `Device menu tip donation is pending by default when false`() { + let appState = AppState() + #expect(appState.navigation.pendingDeviceMenuTipDonation == false) + } } diff --git a/MC1Tests/AppState/OnboardingStateTests.swift b/MC1Tests/AppState/OnboardingStateTests.swift index 1e894e14..543c85b9 100644 --- a/MC1Tests/AppState/OnboardingStateTests.swift +++ b/MC1Tests/AppState/OnboardingStateTests.swift @@ -1,231 +1,230 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing @Suite("Onboarding State Tests") @MainActor struct OnboardingStateTests { + private let defaults: UserDefaults - private let defaults: UserDefaults + init() { + defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } - init() { - defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } + // MARK: - completeOnboarding - // MARK: - completeOnboarding + @Test + func `completeOnboarding sets flag to true`() { + let onboarding = OnboardingState(defaults: defaults) + onboarding.hasCompletedOnboarding = false - @Test("completeOnboarding sets flag to true") - func completeOnboardingSetsFlag() { - let onboarding = OnboardingState(defaults: defaults) - onboarding.hasCompletedOnboarding = false + onboarding.completeOnboarding() - onboarding.completeOnboarding() + #expect(onboarding.hasCompletedOnboarding == true) + } - #expect(onboarding.hasCompletedOnboarding == true) - } + @Test + func `completeOnboarding persists to UserDefaults`() { + let onboarding = OnboardingState(defaults: defaults) + onboarding.hasCompletedOnboarding = false - @Test("completeOnboarding persists to UserDefaults") - func completeOnboardingPersists() { - let onboarding = OnboardingState(defaults: defaults) - onboarding.hasCompletedOnboarding = false + onboarding.completeOnboarding() - onboarding.completeOnboarding() + #expect(defaults.bool(forKey: "hasCompletedOnboarding") == true) + } - #expect(defaults.bool(forKey: "hasCompletedOnboarding") == true) - } + // MARK: - resetOnboarding - // MARK: - resetOnboarding + @Test + func `resetOnboarding clears flag`() { + let onboarding = OnboardingState(defaults: defaults) + onboarding.hasCompletedOnboarding = true - @Test("resetOnboarding clears flag") - func resetOnboardingClearsFlag() { - let onboarding = OnboardingState(defaults: defaults) - onboarding.hasCompletedOnboarding = true + onboarding.resetOnboarding() - onboarding.resetOnboarding() + #expect(onboarding.hasCompletedOnboarding == false) + } - #expect(onboarding.hasCompletedOnboarding == false) - } + @Test + func `resetOnboarding clears onboarding path`() { + let onboarding = OnboardingState(defaults: defaults) + onboarding.onboardingPath = [.welcome, .permissions] - @Test("resetOnboarding clears onboarding path") - func resetOnboardingClearsPath() { - let onboarding = OnboardingState(defaults: defaults) - onboarding.onboardingPath = [.welcome, .permissions] + onboarding.resetOnboarding() - onboarding.resetOnboarding() + #expect(onboarding.onboardingPath.isEmpty) + } - #expect(onboarding.onboardingPath.isEmpty) - } + @Test + func `resetOnboarding persists false to UserDefaults`() { + let onboarding = OnboardingState(defaults: defaults) + onboarding.hasCompletedOnboarding = true + onboarding.resetOnboarding() - @Test("resetOnboarding persists false to UserDefaults") - func resetOnboardingPersists() { - let onboarding = OnboardingState(defaults: defaults) - onboarding.hasCompletedOnboarding = true - onboarding.resetOnboarding() + #expect(defaults.bool(forKey: "hasCompletedOnboarding") == false) + } - #expect(defaults.bool(forKey: "hasCompletedOnboarding") == false) - } + // MARK: - onboardingPath - // MARK: - onboardingPath + @Test + func `onboardingPath starts empty`() { + let appState = AppState() + #expect(appState.onboarding.onboardingPath.isEmpty) + } - @Test("onboardingPath starts empty") - func onboardingPathDefault() { - let appState = AppState() - #expect(appState.onboarding.onboardingPath.isEmpty) - } + @Test + func `onboardingPath can be appended to`() { + let appState = AppState() - @Test("onboardingPath can be appended to") - func onboardingPathAppend() { - let appState = AppState() + appState.onboarding.onboardingPath.append(.welcome) + appState.onboarding.onboardingPath.append(.permissions) - appState.onboarding.onboardingPath.append(.welcome) - appState.onboarding.onboardingPath.append(.permissions) + #expect(appState.onboarding.onboardingPath == [.welcome, .permissions]) + } - #expect(appState.onboarding.onboardingPath == [.welcome, .permissions]) - } + // MARK: - donateDeviceMenuTipIfOnValidTab - // MARK: - donateDeviceMenuTipIfOnValidTab + @Test + func `donateDeviceMenuTipIfOnValidTab on Chats tab clears pending`() async { + let appState = AppState() + appState.navigation.selectedTab = 0 + appState.navigation.pendingDeviceMenuTipDonation = true - @Test("donateDeviceMenuTipIfOnValidTab on Chats tab clears pending") - func donateOnChatsTab() async { - let appState = AppState() - appState.navigation.selectedTab = 0 - appState.navigation.pendingDeviceMenuTipDonation = true + await appState.donateDeviceMenuTipIfOnValidTab() - await appState.donateDeviceMenuTipIfOnValidTab() + #expect(appState.navigation.pendingDeviceMenuTipDonation == false) + } - #expect(appState.navigation.pendingDeviceMenuTipDonation == false) - } + @Test + func `donateDeviceMenuTipIfOnValidTab on Contacts tab clears pending`() async { + let appState = AppState() + appState.navigation.selectedTab = 1 + appState.navigation.pendingDeviceMenuTipDonation = true - @Test("donateDeviceMenuTipIfOnValidTab on Contacts tab clears pending") - func donateOnContactsTab() async { - let appState = AppState() - appState.navigation.selectedTab = 1 - appState.navigation.pendingDeviceMenuTipDonation = true + await appState.donateDeviceMenuTipIfOnValidTab() - await appState.donateDeviceMenuTipIfOnValidTab() + #expect(appState.navigation.pendingDeviceMenuTipDonation == false) + } - #expect(appState.navigation.pendingDeviceMenuTipDonation == false) - } + @Test + func `donateDeviceMenuTipIfOnValidTab on Map tab clears pending`() async { + let appState = AppState() + appState.navigation.selectedTab = 2 + appState.navigation.pendingDeviceMenuTipDonation = true - @Test("donateDeviceMenuTipIfOnValidTab on Map tab clears pending") - func donateOnMapTab() async { - let appState = AppState() - appState.navigation.selectedTab = 2 - appState.navigation.pendingDeviceMenuTipDonation = true + await appState.donateDeviceMenuTipIfOnValidTab() - await appState.donateDeviceMenuTipIfOnValidTab() + #expect(appState.navigation.pendingDeviceMenuTipDonation == false) + } - #expect(appState.navigation.pendingDeviceMenuTipDonation == false) - } + @Test + func `donateDeviceMenuTipIfOnValidTab on Settings tab sets pending`() async { + let appState = AppState() + appState.navigation.selectedTab = 3 + appState.navigation.pendingDeviceMenuTipDonation = false - @Test("donateDeviceMenuTipIfOnValidTab on Settings tab sets pending") - func donateOnSettingsTab() async { - let appState = AppState() - appState.navigation.selectedTab = 3 - appState.navigation.pendingDeviceMenuTipDonation = false + await appState.donateDeviceMenuTipIfOnValidTab() - await appState.donateDeviceMenuTipIfOnValidTab() + #expect(appState.navigation.pendingDeviceMenuTipDonation == true) + } - #expect(appState.navigation.pendingDeviceMenuTipDonation == true) - } + @Test + func `donateDeviceMenuTipIfOnValidTab on Tools tab sets pending`() async { + let appState = AppState() + appState.navigation.selectedTab = 4 + appState.navigation.pendingDeviceMenuTipDonation = false - @Test("donateDeviceMenuTipIfOnValidTab on Tools tab sets pending") - func donateOnToolsTab() async { - let appState = AppState() - appState.navigation.selectedTab = 4 - appState.navigation.pendingDeviceMenuTipDonation = false + await appState.donateDeviceMenuTipIfOnValidTab() - await appState.donateDeviceMenuTipIfOnValidTab() + #expect(appState.navigation.pendingDeviceMenuTipDonation == true) + } - #expect(appState.navigation.pendingDeviceMenuTipDonation == true) - } + // MARK: - hasCompletedOnboarding didSet + + @Test + func `hasCompletedOnboarding syncs to UserDefaults on set`() { + let onboarding = OnboardingState(defaults: defaults) - // MARK: - hasCompletedOnboarding didSet + onboarding.hasCompletedOnboarding = true + #expect(defaults.bool(forKey: "hasCompletedOnboarding") == true) - @Test("hasCompletedOnboarding syncs to UserDefaults on set") - func hasCompletedOnboardingDidSet() { - let onboarding = OnboardingState(defaults: defaults) + onboarding.hasCompletedOnboarding = false + #expect(defaults.bool(forKey: "hasCompletedOnboarding") == false) + } - onboarding.hasCompletedOnboarding = true - #expect(defaults.bool(forKey: "hasCompletedOnboarding") == true) + // MARK: - suggestedStartingPath + + @Suite("suggestedStartingPath") + @MainActor + struct SuggestedStartingPathTests { + @Test + func `Returns empty when onboarding is already complete`() async throws { + let testDefaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + let onboarding = OnboardingState(defaults: testDefaults) + onboarding.hasCompletedOnboarding = true + let appState = AppState() + let path = await onboarding.suggestedStartingPath( + connectionManager: appState.connectionManager, + locationAuthorizationStatus: .notDetermined, + regionAlreadySet: false + ) + #expect(path.isEmpty) + } - onboarding.hasCompletedOnboarding = false - #expect(defaults.bool(forKey: "hasCompletedOnboarding") == false) + @Test + func `Returns empty when no paired device`() async throws { + let testDefaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + let onboarding = OnboardingState(defaults: testDefaults) + + // Build the ConnectionManager with a registry-less stub so pairedAccessoriesCount == 0 + // is a guaranteed precondition; a bare AppState() would inherit whatever the host's + // pairing registry reports. The fresh defaults suite leaves lastConnectedDeviceID nil, + // so neither half of the resume guard fires and onboarding must not resume. + let container = try PersistenceStore.createContainer(inMemory: true) + let connectionManager = ConnectionManager( + modelContainer: container, + defaults: testDefaults, + pairing: StubDevicePairingService() + ) + #expect(connectionManager.pairedAccessoriesCount == 0) + #expect(connectionManager.lastConnectedDeviceID == nil) + + let path = await onboarding.suggestedStartingPath( + connectionManager: connectionManager, + locationAuthorizationStatus: .notDetermined, + regionAlreadySet: false + ) + #expect(path.isEmpty) } - // MARK: - suggestedStartingPath - - @Suite("suggestedStartingPath") - @MainActor - struct SuggestedStartingPathTests { - @Test("Returns empty when onboarding is already complete") - func emptyWhenCompleted() async { - let testDefaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - let onboarding = OnboardingState(defaults: testDefaults) - onboarding.hasCompletedOnboarding = true - let appState = AppState() - let path = await onboarding.suggestedStartingPath( - connectionManager: appState.connectionManager, - locationAuthorizationStatus: .notDetermined, - regionAlreadySet: false - ) - #expect(path.isEmpty) - } - - @Test("Returns empty when no paired device") - func emptyWithNoPairing() async throws { - let testDefaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - let onboarding = OnboardingState(defaults: testDefaults) - - // Build the ConnectionManager with a registry-less stub so pairedAccessoriesCount == 0 - // is a guaranteed precondition; a bare AppState() would inherit whatever the host's - // pairing registry reports. The fresh defaults suite leaves lastConnectedDeviceID nil, - // so neither half of the resume guard fires and onboarding must not resume. - let container = try PersistenceStore.createContainer(inMemory: true) - let connectionManager = ConnectionManager( - modelContainer: container, - defaults: testDefaults, - pairing: StubDevicePairingService() - ) - #expect(connectionManager.pairedAccessoriesCount == 0) - #expect(connectionManager.lastConnectedDeviceID == nil) - - let path = await onboarding.suggestedStartingPath( - connectionManager: connectionManager, - locationAuthorizationStatus: .notDetermined, - regionAlreadySet: false - ) - #expect(path.isEmpty) - } - - @Test("Resumes when a device was connected even with no system pairing registry (macOS)") - func resumesViaLastConnectedDeviceWithoutRegistry() async throws { - let testDefaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - let onboarding = OnboardingState(defaults: testDefaults) - - // Build a ConnectionManager with a registry-less stub so pairedAccessoriesCount == 0 is - // a guaranteed precondition; a bare AppState() would inherit whatever the host's pairing - // registry reports. A real connect happened, so lastConnectedDeviceID is set — onboarding - // must still resume via that signal alone. - let container = try PersistenceStore.createContainer(inMemory: true) - let connectionManager = ConnectionManager( - modelContainer: container, - defaults: testDefaults, - pairing: StubDevicePairingService() - ) - connectionManager.testLastConnectedDeviceID = UUID() - #expect(connectionManager.pairedAccessoriesCount == 0) - - let path = await onboarding.suggestedStartingPath( - connectionManager: connectionManager, - locationAuthorizationStatus: .notDetermined, - regionAlreadySet: false - ) - // Passed the resume guard; halts at the permissions step (location is .notDetermined). - #expect(path == [.permissions]) - } + @Test + func `Resumes when a device was connected even with no system pairing registry (macOS)`() async throws { + let testDefaults = try #require(UserDefaults(suiteName: "test.\(UUID().uuidString)")) + let onboarding = OnboardingState(defaults: testDefaults) + + // Build a ConnectionManager with a registry-less stub so pairedAccessoriesCount == 0 is + // a guaranteed precondition; a bare AppState() would inherit whatever the host's pairing + // registry reports. A real connect happened, so lastConnectedDeviceID is set — onboarding + // must still resume via that signal alone. + let container = try PersistenceStore.createContainer(inMemory: true) + let connectionManager = ConnectionManager( + modelContainer: container, + defaults: testDefaults, + pairing: StubDevicePairingService() + ) + connectionManager.testLastConnectedDeviceID = UUID() + #expect(connectionManager.pairedAccessoriesCount == 0) + + let path = await onboarding.suggestedStartingPath( + connectionManager: connectionManager, + locationAuthorizationStatus: .notDetermined, + regionAlreadySet: false + ) + // Passed the resume guard; halts at the permissions step (location is .notDetermined). + #expect(path == [.permissions]) } + } } /// Registry-less `DevicePairingService` stub mirroring macOS "Designed for iPad": its @@ -233,17 +232,37 @@ struct OnboardingStateTests { /// `pairedAccessoriesCount == 0` regardless of the test host's real pairing registry. @MainActor private final class StubDevicePairingService: DevicePairingService { - var delegate: (any DevicePairingDelegate)? - var isSessionActive: Bool { false } - var registeredDeviceCount: Int { 0 } - var hasSystemPairingRegistry: Bool { false } - var supportsSystemRename: Bool { false } - - func activate() async throws {} - func discoverDevice() async throws -> UUID { throw CancellationError() } - func isDeviceConnectable(_ id: UUID) -> Bool { true } - func registeredDeviceInfos() -> [(id: UUID, name: String)] { [] } - func removeDevice(_ id: UUID) async throws {} - func renameDevice(_ id: UUID) async throws {} - func clearStaleRegistrations() async {} + var delegate: (any DevicePairingDelegate)? + var isSessionActive: Bool { + false + } + + var registeredDeviceCount: Int { + 0 + } + + var hasSystemPairingRegistry: Bool { + false + } + + var supportsSystemRename: Bool { + false + } + + func activate() async throws {} + func discoverDevice() async throws -> UUID { + throw CancellationError() + } + + func isDeviceConnectable(_ id: UUID) -> Bool { + true + } + + func registeredDeviceInfos() -> [(id: UUID, name: String)] { + [] + } + + func removeDevice(_ id: UUID) async throws {} + func renameDevice(_ id: UUID) async throws {} + func clearStaleRegistrations() async {} } diff --git a/MC1Tests/AppState/StatusPillStateTests.swift b/MC1Tests/AppState/StatusPillStateTests.swift index c21190b5..ad9857ff 100644 --- a/MC1Tests/AppState/StatusPillStateTests.swift +++ b/MC1Tests/AppState/StatusPillStateTests.swift @@ -1,76 +1,75 @@ -import Testing -@testable import MC1Services @testable import MC1 +@testable import MC1Services +import Testing @Suite("StatusPillState Tests") struct StatusPillStateTests { + @Test + @MainActor + func `Failed state takes highest priority`() { + let appState = AppState() + appState.connectionUI.showSyncFailedPill() + #expect(appState.statusPillState == .failed(message: "Sync Failed")) + } - @Test("Failed state takes highest priority") - @MainActor - func failedTakesPriority() { - let appState = AppState() - appState.connectionUI.showSyncFailedPill() - #expect(appState.statusPillState == .failed(message: "Sync Failed")) - } - - @Test("Syncing takes priority over connecting") - @MainActor - func syncingOverConnecting() { - let appState = AppState() - appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) - appState.connectionUI.simulateSyncEnded() - } + @Test + @MainActor + func `Syncing takes priority over connecting`() { + let appState = AppState() + appState.connectionUI.simulateSyncStarted() + #expect(appState.statusPillState == .syncing) + appState.connectionUI.simulateSyncEnded() + } - @Test("Ready state shows when toast is active") - @MainActor - func readyStateShowsWithToast() { - let appState = AppState() - appState.connectionUI.showReadyToastBriefly() - #expect(appState.statusPillState == .ready) - } + @Test + @MainActor + func `Ready state shows when toast is active`() { + let appState = AppState() + appState.connectionUI.showReadyToastBriefly() + #expect(appState.statusPillState == .ready) + } - @Test("Hidden when no conditions met") - @MainActor - func hiddenByDefault() { - let appState = AppState() - #expect(appState.statusPillState == .hidden) - } + @Test + @MainActor + func `Hidden when no conditions met`() { + let appState = AppState() + #expect(appState.statusPillState == .hidden) + } - @Test("Disconnected shows after delay when device was paired") - @MainActor - func disconnectedAfterDelay() async throws { - let appState = AppState() - // This test verifies the delay mechanism exists - // Full integration test would require mocking connectionManager - appState.connectionUI.updateDisconnectedPillState( - connectionState: appState.connectionState, - lastConnectedDeviceID: appState.connectionManager.lastConnectedDeviceID, - shouldSuppressDisconnectedPill: appState.connectionManager.shouldSuppressDisconnectedPill - ) - // Without a paired device, should remain hidden - #expect(appState.statusPillState == .hidden) - } + @Test + @MainActor + func `Disconnected shows after delay when device was paired`() { + let appState = AppState() + // This test verifies the delay mechanism exists + // Full integration test would require mocking connectionManager + appState.connectionUI.updateDisconnectedPillState( + connectionState: appState.connectionState, + lastConnectedDeviceID: appState.connectionManager.lastConnectedDeviceID, + shouldSuppressDisconnectedPill: appState.connectionManager.shouldSuppressDisconnectedPill + ) + // Without a paired device, should remain hidden + #expect(appState.statusPillState == .hidden) + } - @Test("Double onSyncActivityEnded does not drive count below zero") - @MainActor - func doubleEndedCallDoesNotGoNegative() { - let appState = AppState() + @Test + @MainActor + func `Double onSyncActivityEnded does not drive count below zero`() { + let appState = AppState() - // Simulate sync starting - appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) + // Simulate sync starting + appState.connectionUI.simulateSyncStarted() + #expect(appState.statusPillState == .syncing) - // First end call (simulates onDisconnected path) - appState.connectionUI.simulateSyncEnded() - #expect(appState.statusPillState != .syncing) + // First end call (simulates onDisconnected path) + appState.connectionUI.simulateSyncEnded() + #expect(appState.statusPillState != .syncing) - // Second end call (simulates error path) - should be no-op due to guard - appState.connectionUI.simulateSyncEnded() - #expect(appState.statusPillState == .hidden) + // Second end call (simulates error path) - should be no-op due to guard + appState.connectionUI.simulateSyncEnded() + #expect(appState.statusPillState == .hidden) - // Start new sync - pill should show (proves count didn't go negative) - appState.connectionUI.simulateSyncStarted() - #expect(appState.statusPillState == .syncing) - } + // Start new sync - pill should show (proves count didn't go negative) + appState.connectionUI.simulateSyncStarted() + #expect(appState.statusPillState == .syncing) + } } diff --git a/MC1Tests/AppStateThemeWiringTests.swift b/MC1Tests/AppStateThemeWiringTests.swift index ab5398d2..7747a0d0 100644 --- a/MC1Tests/AppStateThemeWiringTests.swift +++ b/MC1Tests/AppStateThemeWiringTests.swift @@ -1,39 +1,38 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @MainActor @Suite("AppState theme wiring", .serialized) struct AppStateThemeWiringTests { + @Test + func `AppState exposes a themeService defaulting to the default theme`() { + UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) + let appState = AppState() + #expect(appState.themeService.current.id == Theme.default.id) + } - @Test("AppState exposes a themeService defaulting to the default theme") - func exposesThemeService() { - UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) - let appState = AppState() - #expect(appState.themeService.current.id == Theme.default.id) - } - - @Test("AppState exposes a storeState wrapping an idle StoreService") - func exposesStoreState() { - let appState = AppState() - #expect(appState.storeState.service.loadState == .idle) - } + @Test + func `AppState exposes a storeState wrapping an idle StoreService`() { + let appState = AppState() + #expect(appState.storeState.service.loadState == .idle) + } - @Test("notifyDataRestored does not wipe a restored paid theme while the store is unloaded") - func notifyDataRestoredDefersWhileUnloaded() { - // notifyDataRestored delegates to refreshFromUserDefaults. AppState's StoreService is .idle - // until load(), so ownership is not yet authoritative: a restored paid theme is adopted, not - // wiped, to protect an owner who restores before the entitlement walk. Enforcement once the - // store is loaded is covered by ThemeServiceOwnershipTests.refreshRevertsUnownedThemeWhenLoaded. - defer { UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) } - UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) - let appState = AppState() - #expect(appState.storeState.service.loadState == .idle) - #expect(appState.themeService.current.id == Theme.default.id) + @Test + func `notifyDataRestored does not wipe a restored paid theme while the store is unloaded`() { + // notifyDataRestored delegates to refreshFromUserDefaults. AppState's StoreService is .idle + // until load(), so ownership is not yet authoritative: a restored paid theme is adopted, not + // wiped, to protect an owner who restores before the entitlement walk. Enforcement once the + // store is loaded is covered by ThemeServiceOwnershipTests.refreshRevertsUnownedThemeWhenLoaded. + defer { UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) } + UserDefaults.standard.removeObject(forKey: PersistenceKeys.selectedThemeID) + let appState = AppState() + #expect(appState.storeState.service.loadState == .idle) + #expect(appState.themeService.current.id == Theme.default.id) - UserDefaults.standard.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - appState.notifyDataRestored() - #expect(appState.themeService.current.id == Theme.ember.id) - } + UserDefaults.standard.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + appState.notifyDataRestored() + #expect(appState.themeService.current.id == Theme.ember.id) + } } diff --git a/MC1Tests/AppThemeEnvironmentTests.swift b/MC1Tests/AppThemeEnvironmentTests.swift index 2b705195..74e792eb 100644 --- a/MC1Tests/AppThemeEnvironmentTests.swift +++ b/MC1Tests/AppThemeEnvironmentTests.swift @@ -1,14 +1,13 @@ -import Testing -import SwiftUI @testable import MC1 +import SwiftUI +import Testing @MainActor @Suite("AppTheme environment") struct AppThemeEnvironmentTests { - - @Test("the default appTheme environment value is Theme.default") - func defaultValueIsDefaultTheme() { - let values = EnvironmentValues() - #expect(values.appTheme.id == Theme.default.id) - } + @Test + func `the default appTheme environment value is Theme.default`() { + let values = EnvironmentValues() + #expect(values.appTheme.id == Theme.default.id) + } } diff --git a/MC1Tests/AppearanceSelectionTests.swift b/MC1Tests/AppearanceSelectionTests.swift index fe89b7a2..d7864df4 100644 --- a/MC1Tests/AppearanceSelectionTests.swift +++ b/MC1Tests/AppearanceSelectionTests.swift @@ -1,62 +1,62 @@ -import Testing -import SwiftUI +@testable import MC1 +@testable import MC1Services import StoreKit import StoreKitTest -@testable import MC1Services -@testable import MC1 +import SwiftUI +import Testing @MainActor @Suite("Appearance selection logic", .serialized, .enabled(if: StoreKitTestAvailability.servesProducts)) final class AppearanceSelectionTests { - let session: SKTestSession - - init() throws { - session = try SKTestSession(configurationFileNamed: "MC1") - session.disableDialogs = true - session.askToBuyEnabled = false // reset: SKTestSession leaks session flags across instances in-process - session.clearTransactions() - } - - deinit { session.clearTransactions() } - - private func freshDefaults() -> UserDefaults { - UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } - - @Test("with no purchases, only the default theme is available and Browse-more is shown") - func emptyOwnership() async throws { - let store = StoreService() - await store.load() - let theme = ThemeService(store: store, defaults: freshDefaults()) - - #expect(theme.availableToCurrentUser().map(\.id) == [Theme.default.id]) - #expect(AppearanceView.shouldShowBrowseMore(available: theme.availableToCurrentUser())) - } - - @Test("a theme owned via the bundle becomes available; selecting it updates current") - func ownedThemeSelectable() async throws { - let store = StoreService() - await store.load() - let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: store) - let theme = ThemeService(store: store, defaults: freshDefaults()) - - let available = theme.availableToCurrentUser().map(\.id) - #expect(available.contains(Theme.marine.id)) - - try theme.setCurrent(.marine) - #expect(theme.current.id == Theme.marine.id) - } - - @Test("owning the bundle makes every theme available and hides Browse-more") - func bundleOwnershipHidesBrowseMore() async throws { - let store = StoreService() - await store.load() - let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: store) - let theme = ThemeService(store: store, defaults: freshDefaults()) - - #expect(theme.availableToCurrentUser().count == ThemeRegistry.allThemes.count) - #expect(!AppearanceView.shouldShowBrowseMore(available: theme.availableToCurrentUser())) - } + let session: SKTestSession + + init() throws { + session = try SKTestSession(configurationFileNamed: "MC1") + session.disableDialogs = true + session.askToBuyEnabled = false // reset: SKTestSession leaks session flags across instances in-process + session.clearTransactions() + } + + deinit { session.clearTransactions() } + + private func freshDefaults() -> UserDefaults { + UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + @Test + func `with no purchases, only the default theme is available and Browse-more is shown`() async { + let store = StoreService() + await store.load() + let theme = ThemeService(store: store, defaults: freshDefaults()) + + #expect(theme.availableToCurrentUser().map(\.id) == [Theme.default.id]) + #expect(AppearanceView.shouldShowBrowseMore(available: theme.availableToCurrentUser())) + } + + @Test + func `a theme owned via the bundle becomes available; selecting it updates current`() async throws { + let store = StoreService() + await store.load() + let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: store) + let theme = ThemeService(store: store, defaults: freshDefaults()) + + let available = theme.availableToCurrentUser().map(\.id) + #expect(available.contains(Theme.marine.id)) + + try theme.setCurrent(.marine) + #expect(theme.current.id == Theme.marine.id) + } + + @Test + func `owning the bundle makes every theme available and hides Browse-more`() async throws { + let store = StoreService() + await store.load() + let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: store) + let theme = ThemeService(store: store, defaults: freshDefaults()) + + #expect(theme.availableToCurrentUser().count == ThemeRegistry.allThemes.count) + #expect(!AppearanceView.shouldShowBrowseMore(available: theme.availableToCurrentUser())) + } } diff --git a/MC1Tests/Extensions/BatteryInfoDisplayTests.swift b/MC1Tests/Extensions/BatteryInfoDisplayTests.swift index 704922b3..dae7d139 100644 --- a/MC1Tests/Extensions/BatteryInfoDisplayTests.swift +++ b/MC1Tests/Extensions/BatteryInfoDisplayTests.swift @@ -1,106 +1,105 @@ -import Testing @testable import MC1 import MeshCore +import Testing struct BatteryInfoDisplayTests { - - // MARK: - Voltage Tests - - @Test func voltage_convertsMillivoltsCorrectly() { - let battery = BatteryInfo(level: 3700) - #expect(battery.voltage == 3.7) - } - - @Test func voltage_zeroMillivolts() { - let battery = BatteryInfo(level: 0) - #expect(battery.voltage == 0.0) - } - - // MARK: - Percentage Tests - - @Test func percentage_fullBattery() { - let battery = BatteryInfo(level: 4200) - #expect(battery.percentage == 100) - } - - @Test func percentage_emptyBattery() { - let battery = BatteryInfo(level: 3000) - #expect(battery.percentage == 0) - } - - @Test func percentage_midRange() { - let battery = BatteryInfo(level: 3600) // 50% point - #expect(battery.percentage == 50) - } - - @Test func percentage_clampsAbove100() { - let battery = BatteryInfo(level: 4500) - #expect(battery.percentage == 100) - } - - @Test func percentage_clampsBelow0() { - let battery = BatteryInfo(level: 2500) - #expect(battery.percentage == 0) - } - - // MARK: - Icon Tests - - @Test func iconName_fullBattery() { - let battery = BatteryInfo(level: 4200) - #expect(battery.iconName == "battery.100") - } - - @Test func iconName_75percent() { - let battery = BatteryInfo(level: 3900) // ~75% - #expect(battery.iconName == "battery.75") - } - - @Test func iconName_50percent() { - let battery = BatteryInfo(level: 3600) // ~50% - #expect(battery.iconName == "battery.50") - } - - @Test func iconName_25percent() { - let battery = BatteryInfo(level: 3300) // ~25% - #expect(battery.iconName == "battery.25") - } - - @Test func iconName_lowBattery() { - let battery = BatteryInfo(level: 3100) // ~8% - #expect(battery.iconName == "battery.0") - } - - // MARK: - Color Tests - - @Test func levelColor_normalLevel() { - let battery = BatteryInfo(level: 3600) // 50% - #expect(battery.levelColor == .primary) - } - - @Test func levelColor_warningLevel() { - let battery = BatteryInfo(level: 3180) // ~15% - #expect(battery.levelColor == .orange) - } - - @Test func levelColor_criticalLevel() { - let battery = BatteryInfo(level: 3060) // ~5% - #expect(battery.levelColor == .red) - } - - // MARK: - Battery Presence Tests - - @Test func isBatteryPresent_zeroMillivolts_returnsFalse() { - let battery = BatteryInfo(level: 0) - #expect(!battery.isBatteryPresent) - } - - @Test func isBatteryPresent_normalVoltage_returnsTrue() { - let battery = BatteryInfo(level: 3700) - #expect(battery.isBatteryPresent) - } - - @Test func isBatteryPresent_minimumValidVoltage_returnsTrue() { - let battery = BatteryInfo(level: 1) - #expect(battery.isBatteryPresent) - } + // MARK: - Voltage Tests + + @Test func `voltage converts millivolts correctly`() { + let battery = BatteryInfo(level: 3700) + #expect(battery.voltage == 3.7) + } + + @Test func `voltage zero millivolts`() { + let battery = BatteryInfo(level: 0) + #expect(battery.voltage == 0.0) + } + + // MARK: - Percentage Tests + + @Test func `percentage full battery`() { + let battery = BatteryInfo(level: 4200) + #expect(battery.percentage == 100) + } + + @Test func `percentage empty battery`() { + let battery = BatteryInfo(level: 3000) + #expect(battery.percentage == 0) + } + + @Test func `percentage mid range`() { + let battery = BatteryInfo(level: 3600) // 50% point + #expect(battery.percentage == 50) + } + + @Test func `percentage clamps above 100`() { + let battery = BatteryInfo(level: 4500) + #expect(battery.percentage == 100) + } + + @Test func `percentage clamps below 0`() { + let battery = BatteryInfo(level: 2500) + #expect(battery.percentage == 0) + } + + // MARK: - Icon Tests + + @Test func `icon name full battery`() { + let battery = BatteryInfo(level: 4200) + #expect(battery.iconName == "battery.100") + } + + @Test func `icon name 75 percent`() { + let battery = BatteryInfo(level: 3900) // ~75% + #expect(battery.iconName == "battery.75") + } + + @Test func `icon name 50 percent`() { + let battery = BatteryInfo(level: 3600) // ~50% + #expect(battery.iconName == "battery.50") + } + + @Test func `icon name 25 percent`() { + let battery = BatteryInfo(level: 3300) // ~25% + #expect(battery.iconName == "battery.25") + } + + @Test func `icon name low battery`() { + let battery = BatteryInfo(level: 3100) // ~8% + #expect(battery.iconName == "battery.0") + } + + // MARK: - Color Tests + + @Test func `level color normal level`() { + let battery = BatteryInfo(level: 3600) // 50% + #expect(battery.levelColor == .primary) + } + + @Test func `level color warning level`() { + let battery = BatteryInfo(level: 3180) // ~15% + #expect(battery.levelColor == .orange) + } + + @Test func `level color critical level`() { + let battery = BatteryInfo(level: 3060) // ~5% + #expect(battery.levelColor == .red) + } + + // MARK: - Battery Presence Tests + + @Test func `is battery present zero millivolts returns false`() { + let battery = BatteryInfo(level: 0) + #expect(!battery.isBatteryPresent) + } + + @Test func `is battery present normal voltage returns true`() { + let battery = BatteryInfo(level: 3700) + #expect(battery.isBatteryPresent) + } + + @Test func `is battery present minimum valid voltage returns true`() { + let battery = BatteryInfo(level: 1) + #expect(battery.isBatteryPresent) + } } diff --git a/MC1Tests/Extensions/BatteryPercentageCalculationTests.swift b/MC1Tests/Extensions/BatteryPercentageCalculationTests.swift index 9ab3d596..7c904fe2 100644 --- a/MC1Tests/Extensions/BatteryPercentageCalculationTests.swift +++ b/MC1Tests/Extensions/BatteryPercentageCalculationTests.swift @@ -1,49 +1,48 @@ -import Testing -import MeshCore @testable import MC1 +import MeshCore +import Testing @Suite("Battery Percentage Calculation Tests") struct BatteryPercentageCalculationTests { - - // Li-Ion array for testing: [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] - let liIonArray = [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] - - @Test("Voltage at 100% point returns 100") - func voltageAt100Percent() { - let battery = BatteryInfo(level: 4190) - #expect(battery.percentage(using: liIonArray) == 100) - } - - @Test("Voltage at 0% point returns 0") - func voltageAt0Percent() { - let battery = BatteryInfo(level: 3100) - #expect(battery.percentage(using: liIonArray) == 0) - } - - @Test("Voltage above max returns 100") - func voltageAboveMax() { - let battery = BatteryInfo(level: 4500) - #expect(battery.percentage(using: liIonArray) == 100) - } - - @Test("Voltage below min returns 0") - func voltageBelowMin() { - let battery = BatteryInfo(level: 2800) - #expect(battery.percentage(using: liIonArray) == 0) - } - - @Test("Voltage at 50% point returns 50") - func voltageAt50Percent() { - // 50% is at index 5 = 3720mV - let battery = BatteryInfo(level: 3720) - #expect(battery.percentage(using: liIonArray) == 50) - } - - @Test("Voltage interpolates between points") - func voltageInterpolates() { - // Midpoint between 4190 (100%) and 4050 (90%) = 4120 should be ~95% - let battery = BatteryInfo(level: 4120) - let percent = battery.percentage(using: liIonArray) - #expect(percent >= 94 && percent <= 96, "Expected ~95%, got \(percent)") - } + /// Li-Ion array for testing: [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] + let liIonArray = [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] + + @Test + func `Voltage at 100% point returns 100`() { + let battery = BatteryInfo(level: 4190) + #expect(battery.percentage(using: liIonArray) == 100) + } + + @Test + func `Voltage at 0% point returns 0`() { + let battery = BatteryInfo(level: 3100) + #expect(battery.percentage(using: liIonArray) == 0) + } + + @Test + func `Voltage above max returns 100`() { + let battery = BatteryInfo(level: 4500) + #expect(battery.percentage(using: liIonArray) == 100) + } + + @Test + func `Voltage below min returns 0`() { + let battery = BatteryInfo(level: 2800) + #expect(battery.percentage(using: liIonArray) == 0) + } + + @Test + func `Voltage at 50% point returns 50`() { + // 50% is at index 5 = 3720mV + let battery = BatteryInfo(level: 3720) + #expect(battery.percentage(using: liIonArray) == 50) + } + + @Test + func `Voltage interpolates between points`() { + // Midpoint between 4190 (100%) and 4050 (90%) = 4120 should be ~95% + let battery = BatteryInfo(level: 4120) + let percent = battery.percentage(using: liIonArray) + #expect(percent >= 94 && percent <= 96, "Expected ~95%, got \(percent)") + } } diff --git a/MC1Tests/Extensions/DataExtensionsTests.swift b/MC1Tests/Extensions/DataExtensionsTests.swift index b670e3b1..aa33db24 100644 --- a/MC1Tests/Extensions/DataExtensionsTests.swift +++ b/MC1Tests/Extensions/DataExtensionsTests.swift @@ -1,107 +1,106 @@ import Foundation -import Testing @testable import MC1Services +import Testing @Suite("Data Extensions Tests") struct DataExtensionsTests { - - // MARK: - uppercaseHexString() Tests - - @Test("Empty data returns empty string") - func uppercaseHexStringEmpty() { - let data = Data() - #expect(data.uppercaseHexString() == "") - } - - @Test("Hex string with no separator") - func uppercaseHexStringNoSeparator() { - let data = Data([0xAA, 0xBB, 0xCC, 0xDD]) - #expect(data.uppercaseHexString() == "AABBCCDD") - } - - @Test("Hex string with space separator") - func uppercaseHexStringWithSpaceSeparator() { - let data = Data([0xAA, 0xBB, 0xCC, 0xDD]) - #expect(data.uppercaseHexString(separator: " ") == "AA BB CC DD") - } - - @Test("Hex string with custom separator") - func uppercaseHexStringWithCustomSeparator() { - let data = Data([0xAA, 0xBB, 0xCC]) - #expect(data.uppercaseHexString(separator: ":") == "AA:BB:CC") - } - - @Test("Hex string for single byte") - func uppercaseHexStringSingleByte() { - let data = Data([0x0F]) - #expect(data.uppercaseHexString() == "0F") - } - - @Test("Hex string preserves leading zeros") - func uppercaseHexStringLeadingZero() { - let data = Data([0x00, 0x01, 0x02]) - #expect(data.uppercaseHexString() == "000102") - } - - // MARK: - init?(hexString:) Tests - - @Test("Init from valid hex string") - func initFromHexStringValid() { - let data = Data(hexString: "AABBCCDD") - #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) - } - - @Test("Init from hex string with spaces") - func initFromHexStringWithSpaces() { - let data = Data(hexString: "AA BB CC DD") - #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) - } - - @Test("Init from lowercase hex string") - func initFromHexStringLowercase() { - let data = Data(hexString: "aabbccdd") - #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) - } - - @Test("Init from mixed case hex string") - func initFromHexStringMixedCase() { - let data = Data(hexString: "AaBbCcDd") - #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) - } - - @Test("Init from empty hex string") - func initFromHexStringEmpty() { - let data = Data(hexString: "") - #expect(data == Data()) - } - - @Test("Init from odd-length hex string returns nil") - func initFromHexStringOddLength() { - let data = Data(hexString: "ABC") - #expect(data == nil) - } - - @Test("Init filters out non-hex characters") - func initFromHexStringWithNonHexCharacters() { - let data = Data(hexString: "AA-BB-CC") - #expect(data == Data([0xAA, 0xBB, 0xCC])) - } - - // MARK: - Round-trip Tests - - @Test("Round-trip preserves data") - func roundTrip() { - let original = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) - let hexString = original.uppercaseHexString() - let restored = Data(hexString: hexString) - #expect(restored == original) - } - - @Test("Round-trip with spaces preserves data") - func roundTripWithSpaces() { - let original = Data([0xDE, 0xAD, 0xBE, 0xEF]) - let hexString = original.uppercaseHexString(separator: " ") - let restored = Data(hexString: hexString) - #expect(restored == original) - } + // MARK: - uppercaseHexString() Tests + + @Test + func `Empty data returns empty string`() { + let data = Data() + #expect(data.uppercaseHexString() == "") + } + + @Test + func `Hex string with no separator`() { + let data = Data([0xAA, 0xBB, 0xCC, 0xDD]) + #expect(data.uppercaseHexString() == "AABBCCDD") + } + + @Test + func `Hex string with space separator`() { + let data = Data([0xAA, 0xBB, 0xCC, 0xDD]) + #expect(data.uppercaseHexString(separator: " ") == "AA BB CC DD") + } + + @Test + func `Hex string with custom separator`() { + let data = Data([0xAA, 0xBB, 0xCC]) + #expect(data.uppercaseHexString(separator: ":") == "AA:BB:CC") + } + + @Test + func `Hex string for single byte`() { + let data = Data([0x0F]) + #expect(data.uppercaseHexString() == "0F") + } + + @Test + func `Hex string preserves leading zeros`() { + let data = Data([0x00, 0x01, 0x02]) + #expect(data.uppercaseHexString() == "000102") + } + + // MARK: - init?(hexString:) Tests + + @Test + func `Init from valid hex string`() { + let data = Data(hexString: "AABBCCDD") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test + func `Init from hex string with spaces`() { + let data = Data(hexString: "AA BB CC DD") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test + func `Init from lowercase hex string`() { + let data = Data(hexString: "aabbccdd") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test + func `Init from mixed case hex string`() { + let data = Data(hexString: "AaBbCcDd") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test + func `Init from empty hex string`() { + let data = Data(hexString: "") + #expect(data == Data()) + } + + @Test + func `Init from odd-length hex string returns nil`() { + let data = Data(hexString: "ABC") + #expect(data == nil) + } + + @Test + func `Init filters out non-hex characters`() { + let data = Data(hexString: "AA-BB-CC") + #expect(data == Data([0xAA, 0xBB, 0xCC])) + } + + // MARK: - Round-trip Tests + + @Test + func `Round-trip preserves data`() { + let original = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) + let hexString = original.uppercaseHexString() + let restored = Data(hexString: hexString) + #expect(restored == original) + } + + @Test + func `Round-trip with spaces preserves data`() { + let original = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let hexString = original.uppercaseHexString(separator: " ") + let restored = Data(hexString: hexString) + #expect(restored == original) + } } diff --git a/MC1Tests/Extensions/ErrorDispatchCoverageTests.swift b/MC1Tests/Extensions/ErrorDispatchCoverageTests.swift index c698c029..a59866c1 100644 --- a/MC1Tests/Extensions/ErrorDispatchCoverageTests.swift +++ b/MC1Tests/Extensions/ErrorDispatchCoverageTests.swift @@ -16,130 +16,129 @@ import Testing /// if it never reaches .errorAlert. @Suite("ErrorDispatchCoverage") struct ErrorDispatchCoverageTests { - - // MARK: - Allowlist - - /// Types that implement LocalizedError but are intentionally omitted from the - /// Error+UserFacingMessage dispatcher because they never reach .errorAlert. - /// - /// Each entry includes a one-line justification. The test will fail if an - /// allowlisted name no longer appears in the source tree (stale allowlist). - private static let intentionallyUnmappedTypes: [String: String] = [ - "PairingError": "Routed through ConnectionUIState.presentPairingFailure; " - + "produces bespoke auth-failure vs generic-failure alerts, not a plain message.", - "DevicePairingError": "Control-flow signal (cancelled / alreadyInProgress); " - + "caught at every call site before the error reaches .errorAlert.", - ] - - // MARK: - Test - - @Test("Every LocalizedError enum in MC1Services and MeshCore has a dispatch arm or is allowlisted") - func everyLocalizedErrorEnumIsDispatched() throws { - let repoRoot = try resolveRepoRoot() - let discovered = try discoverLocalizedErrorEnumNames(under: repoRoot) - let dispatched = try dispatchedTypeNames(in: repoRoot) - let allowlisted = Set(Self.intentionallyUnmappedTypes.keys) - - let unmapped = discovered.subtracting(dispatched).subtracting(allowlisted) - let staleAllowList = allowlisted.subtracting(discovered) - - #expect(unmapped.isEmpty, """ - LocalizedError enums without a dispatch arm or allowlist entry: \(unmapped.sorted()). - Add a `case let error as : error.userFacingMessage` arm to - Error+UserFacingMessage.swift and a matching +UserFacingMessage file, - OR add the type to intentionallyUnmappedTypes with a justification. - """) - - #expect(staleAllowList.isEmpty, """ - intentionallyUnmappedTypes entries whose type name no longer appears in the source tree: \ - \(staleAllowList.sorted()). - Remove stale entries from intentionallyUnmappedTypes. - """) - } - - // MARK: - Source scanning helpers - - /// Resolves the repo root from this file's compile-time path. - /// This file lives at MC1Tests/Extensions/ErrorDispatchCoverageTests.swift, - /// so the root is three parent directories up. - private func resolveRepoRoot(filePath: StaticString = #filePath) throws -> URL { - let thisFile = URL(fileURLWithPath: "\(filePath)") - // MC1Tests/Extensions/ErrorDispatchCoverageTests.swift -> go up 3 levels - let root = thisFile - .deletingLastPathComponent() // Extensions/ - .deletingLastPathComponent() // MC1Tests/ - .deletingLastPathComponent() // repo root - guard FileManager.default.fileExists(atPath: root.path) else { - throw CocoaError(.fileNoSuchFile, - userInfo: [NSFilePathErrorKey: root.path]) - } - return root + // MARK: - Allowlist + + /// Types that implement LocalizedError but are intentionally omitted from the + /// Error+UserFacingMessage dispatcher because they never reach .errorAlert. + /// + /// Each entry includes a one-line justification. The test will fail if an + /// allowlisted name no longer appears in the source tree (stale allowlist). + private static let intentionallyUnmappedTypes: [String: String] = [ + "PairingError": "Routed through ConnectionUIState.presentPairingFailure; " + + "produces bespoke auth-failure vs generic-failure alerts, not a plain message.", + "DevicePairingError": "Control-flow signal (cancelled / alreadyInProgress); " + + "caught at every call site before the error reaches .errorAlert.", + ] + + // MARK: - Test + + @Test + func `Every LocalizedError enum in MC1Services and MeshCore has a dispatch arm or is allowlisted`() throws { + let repoRoot = try resolveRepoRoot() + let discovered = try discoverLocalizedErrorEnumNames(under: repoRoot) + let dispatched = try dispatchedTypeNames(in: repoRoot) + let allowlisted = Set(Self.intentionallyUnmappedTypes.keys) + + let unmapped = discovered.subtracting(dispatched).subtracting(allowlisted) + let staleAllowList = allowlisted.subtracting(discovered) + + #expect(unmapped.isEmpty, """ + LocalizedError enums without a dispatch arm or allowlist entry: \(unmapped.sorted()). + Add a `case let error as : error.userFacingMessage` arm to + Error+UserFacingMessage.swift and a matching +UserFacingMessage file, + OR add the type to intentionallyUnmappedTypes with a justification. + """) + + #expect(staleAllowList.isEmpty, """ + intentionallyUnmappedTypes entries whose type name no longer appears in the source tree: \ + \(staleAllowList.sorted()). + Remove stale entries from intentionallyUnmappedTypes. + """) + } + + // MARK: - Source scanning helpers + + /// Resolves the repo root from this file's compile-time path. + /// This file lives at MC1Tests/Extensions/ErrorDispatchCoverageTests.swift, + /// so the root is three parent directories up. + private func resolveRepoRoot(filePath: StaticString = #filePath) throws -> URL { + let thisFile = URL(fileURLWithPath: "\(filePath)") + // MC1Tests/Extensions/ErrorDispatchCoverageTests.swift -> go up 3 levels + let root = thisFile + .deletingLastPathComponent() // Extensions/ + .deletingLastPathComponent() // MC1Tests/ + .deletingLastPathComponent() // repo root + guard FileManager.default.fileExists(atPath: root.path) else { + throw CocoaError(.fileNoSuchFile, + userInfo: [NSFilePathErrorKey: root.path]) } + return root + } + + /// Scans MC1Services/Sources and MeshCore/Sources for Swift files and extracts + /// every type name that conforms to LocalizedError (directly or via extension). + /// + /// Patterns matched: + /// - `enum Foo: ..., LocalizedError, ...` + /// - `extension Foo: LocalizedError` / `extension Foo: @retroactive LocalizedError` + private func discoverLocalizedErrorEnumNames(under root: URL) throws -> Set { + let sourceDirs = [ + root.appendingPathComponent("MC1Services/Sources"), + root.appendingPathComponent("MeshCore/Sources"), + ] - /// Scans MC1Services/Sources and MeshCore/Sources for Swift files and extracts - /// every type name that conforms to LocalizedError (directly or via extension). - /// - /// Patterns matched: - /// - `enum Foo: ..., LocalizedError, ...` - /// - `extension Foo: LocalizedError` / `extension Foo: @retroactive LocalizedError` - private func discoverLocalizedErrorEnumNames(under root: URL) throws -> Set { - let sourceDirs = [ - root.appendingPathComponent("MC1Services/Sources"), - root.appendingPathComponent("MeshCore/Sources"), - ] - - // Regex: matches `enum Name` where the conformance list contains LocalizedError, - // or `extension Name: ... LocalizedError ...` (with optional @retroactive). - let enumPattern = try NSRegularExpression( - pattern: #"(?m)^\s*(?:public\s+)?enum\s+(\w+)\s*:[^{]*\bLocalizedError\b"# - ) - // Extension conformances, covering multi-protocol lists and @retroactive: - // `extension Foo: LocalizedError`, `extension Foo: Bar, LocalizedError` - let multiExtensionPattern = try NSRegularExpression( - pattern: #"(?m)^\s*extension\s+(\w+)\s*:[^{]*\bLocalizedError\b"# - ) - - var names = Set() - - for dir in sourceDirs { - guard let enumerator = FileManager.default.enumerator( - at: dir, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) else { continue } - - for case let fileURL as URL in enumerator { - guard fileURL.pathExtension == "swift" else { continue } - let source = try String(contentsOf: fileURL, encoding: .utf8) - let range = NSRange(source.startIndex..., in: source) - for pattern in [enumPattern, multiExtensionPattern] { - let matches = pattern.matches(in: source, range: range) - for match in matches { - if let nameRange = Range(match.range(at: 1), in: source) { - names.insert(String(source[nameRange])) - } - } - } + // Regex: matches `enum Name` where the conformance list contains LocalizedError, + // or `extension Name: ... LocalizedError ...` (with optional @retroactive). + let enumPattern = try NSRegularExpression( + pattern: #"(?m)^\s*(?:public\s+)?enum\s+(\w+)\s*:[^{]*\bLocalizedError\b"# + ) + // Extension conformances, covering multi-protocol lists and @retroactive: + // `extension Foo: LocalizedError`, `extension Foo: Bar, LocalizedError` + let multiExtensionPattern = try NSRegularExpression( + pattern: #"(?m)^\s*extension\s+(\w+)\s*:[^{]*\bLocalizedError\b"# + ) + + var names = Set() + + for dir in sourceDirs { + guard let enumerator = FileManager.default.enumerator( + at: dir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { continue } + + for case let fileURL as URL in enumerator { + guard fileURL.pathExtension == "swift" else { continue } + let source = try String(contentsOf: fileURL, encoding: .utf8) + let range = NSRange(source.startIndex..., in: source) + for pattern in [enumPattern, multiExtensionPattern] { + let matches = pattern.matches(in: source, range: range) + for match in matches { + if let nameRange = Range(match.range(at: 1), in: source) { + names.insert(String(source[nameRange])) } + } } - - return names + } } - /// Scans Error+UserFacingMessage.swift for `case let error as :` arms. - private func dispatchedTypeNames(in root: URL) throws -> Set { - let dispatchFile = root.appendingPathComponent( - "MC1/Extensions/Errors/Error+UserFacingMessage.swift" - ) - let source = try String(contentsOf: dispatchFile, encoding: .utf8) - let pattern = try NSRegularExpression( - pattern: #"case\s+let\s+\w+\s+as\s+(\w+)\s*:"# - ) - let range = NSRange(source.startIndex..., in: source) - let matches = pattern.matches(in: source, range: range) - return Set(matches.compactMap { match -> String? in - guard let nameRange = Range(match.range(at: 1), in: source) else { return nil } - return String(source[nameRange]) - }) - } + return names + } + + /// Scans Error+UserFacingMessage.swift for `case let error as :` arms. + private func dispatchedTypeNames(in root: URL) throws -> Set { + let dispatchFile = root.appendingPathComponent( + "MC1/Extensions/Errors/Error+UserFacingMessage.swift" + ) + let source = try String(contentsOf: dispatchFile, encoding: .utf8) + let pattern = try NSRegularExpression( + pattern: #"case\s+let\s+\w+\s+as\s+(\w+)\s*:"# + ) + let range = NSRange(source.startIndex..., in: source) + let matches = pattern.matches(in: source, range: range) + return Set(matches.compactMap { match -> String? in + guard let nameRange = Range(match.range(at: 1), in: source) else { return nil } + return String(source[nameRange]) + }) + } } diff --git a/MC1Tests/Extensions/ErrorUserFacingMessageTests.swift b/MC1Tests/Extensions/ErrorUserFacingMessageTests.swift index d4ccddc4..927815cc 100644 --- a/MC1Tests/Extensions/ErrorUserFacingMessageTests.swift +++ b/MC1Tests/Extensions/ErrorUserFacingMessageTests.swift @@ -1,277 +1,276 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing struct ErrorUserFacingMessageTests { - - // MARK: - Dispatch Through `any Error` - - @Test func meshCoreErrorDispatchesToConcreteMapping() { - let error: any Error = MeshCoreError.timeout - #expect(error.userFacingMessage == L10n.Localizable.Error.MeshCore.timeout) - } - - @Test func protocolErrorDispatchesToConcreteMapping() { - let error: any Error = ProtocolError.tableFull - #expect(error.userFacingMessage == L10n.Localizable.Error.Device.storageFull) - } - - @Test func timeoutErrorDispatchesToConcreteMapping() { - let error: any Error = TimeoutError(operationName: "sync", timeout: .seconds(5)) - #expect(error.userFacingMessage == L10n.Localizable.Error.Timeout.operationTimedOut) - } - - @Test func appBackupErrorDispatchesToConcreteMapping() { - let error: any Error = AppBackupError.invalidFile - #expect(error.userFacingMessage == AppBackupError.invalidFile.userFacingMessage) - } - - @Test func bleErrorDispatchesToConcreteMapping() { - let detail = "peripheral unreachable" - let error: any Error = BLEError.connectionFailed(detail) - #expect(error.userFacingMessage == L10n.Localizable.Error.Ble.connectionFailed(detail)) - } - - @Test func connectionErrorDispatchesToConcreteMapping() { - let reason = "handshake rejected" - let error: any Error = ConnectionError.initializationFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.Connection.initializationFailed(reason)) - } - - @Test func wifiTransportErrorDispatchesToConcreteMapping() { - let error: any Error = WiFiTransportError.invalidHost - #expect(error.userFacingMessage == L10n.Localizable.Error.Wifi.invalidHost) - } - - @Test func accessorySetupKitErrorDispatchesToConcreteMapping() { - let reason = "user declined" - let error: any Error = AccessorySetupKitError.pairingFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.AccessorySetup.pairingFailed(reason)) - } - - @Test func contactServiceErrorDispatchesToConcreteMapping() { - let error: any Error = ContactServiceError.contactTableFull - #expect(error.userFacingMessage == L10n.Localizable.Error.ContactService.contactTableFull) - } - - @Test func messageServiceErrorDispatchesToConcreteMapping() { - let reason = "queue rejected" - let error: any Error = MessageServiceError.sendFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.MessageService.sendFailed) - #expect(!error.userFacingMessage.contains(reason)) - } - - @Test func channelServiceErrorDispatchesToConcreteMapping() { - let failureCount = 3 - let error: any Error = ChannelServiceError.circuitBreakerOpen(consecutiveFailures: failureCount) - #expect(error.userFacingMessage == L10n.Localizable.Error.ChannelService.circuitBreakerOpen(failureCount)) - } - - @Test func chatSendQueueServiceErrorDispatchesToConcreteMapping() { - let error: any Error = ChatSendQueueServiceError.notConnected - #expect(error.userFacingMessage == L10n.Localizable.Error.ChatSendQueue.notConnected) - } - - @Test func messagePollingErrorDispatchesToConcreteMapping() { - let error: any Error = MessagePollingError.pollingFailed - #expect(error.userFacingMessage == L10n.Localizable.Error.MessagePolling.pollingFailed) - } - - @Test func advertisementErrorDispatchesToConcreteMapping() { - let error: any Error = AdvertisementError.sendFailed - #expect(error.userFacingMessage == L10n.Localizable.Error.Advertisement.sendFailed) - } - - @Test func remoteNodeErrorDispatchesToConcreteMapping() { - let reason = "authentication failed" - let error: any Error = RemoteNodeError.loginFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.RemoteNode.loginFailed) - #expect(!error.userFacingMessage.contains(reason)) - } - - @Test func roomServerErrorDispatchesToConcreteMapping() { - let error: any Error = RoomServerError.sessionNotFound - #expect(error.userFacingMessage == L10n.Localizable.Error.RoomServer.sessionNotFound) - } - - @Test func roomServerSendFailedDropsRawReason() { - let reason = "Retry already in progress" - let error: any Error = RoomServerError.sendFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.RoomServer.sendFailed) - #expect(!error.userFacingMessage.contains(reason)) - } - - @Test func binaryProtocolErrorDispatchesToConcreteMapping() { - let error: any Error = BinaryProtocolError.timeout - #expect(error.userFacingMessage == L10n.Localizable.Error.BinaryProtocol.timeout) - } - - @Test func persistenceStoreErrorDispatchesToConcreteMapping() { - let reason = "store unavailable" - let error: any Error = PersistenceStoreError.saveFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.Persistence.saveFailed(reason)) - } - - @Test func syncCoordinatorErrorDispatchesToConcreteMapping() { - let error: any Error = SyncCoordinatorError.alreadySyncing - #expect(error.userFacingMessage == L10n.Localizable.Error.SyncCoordinator.alreadySyncing) - } - - @Test func deviceServiceErrorDispatchesToConcreteMapping() { - let reason = "write rejected" - let error: any Error = DeviceServiceError.persistenceFailed(reason) - #expect(error.userFacingMessage == L10n.Localizable.Error.DeviceService.persistenceFailed(reason)) - } - - @Test func settingsServiceErrorDispatchesToConcreteMapping() { - let expected = "915.5" - let actual = "868.0" - let error: any Error = SettingsServiceError.verificationFailed(expected: expected, actual: actual) - #expect(error.userFacingMessage == L10n.Localizable.Error.Settings.verificationFailed(expected, actual)) - } - - @Test func keychainErrorDispatchesToConcreteMapping() { - let status: OSStatus = errSecDuplicateItem - let error: any Error = KeychainError.storageFailed(status) - #expect(error.userFacingMessage == L10n.Localizable.Error.Keychain.storageFailed(Int(status))) - } - - @Test func keyGenerationErrorDispatchesToConcreteMapping() { - let error: any Error = KeyGenerationError.reservedPrefix - #expect(error.userFacingMessage == L10n.Localizable.Error.KeyGeneration.reservedPrefix) - } - - @Test func nodeConfigServiceErrorDispatchesToConcreteMapping() { - let channelIndex = 2 - let hexLength = 30 - let error: any Error = NodeConfigServiceError.invalidChannelSecret(index: channelIndex, hexLength: hexLength) - #expect( - error.userFacingMessage - == L10n.Settings.ConfigImport.Error.invalidChannelSecret(channelIndex, hexLength) - ) - } - - @Test func storeServiceErrorDispatchesToConcreteMapping() { - let reason = "storefront rejected" - let error: any Error = StoreServiceError.purchaseFailed(reason: reason) - #expect(error.userFacingMessage == L10n.Settings.Support.Error.purchaseFailed(reason)) - } - - @Test func deviceGPSVerificationFailedPicksBooleanVariantKey() { - #expect( - SettingsServiceError.deviceGPSVerificationFailed(expectedEnabled: true, actualEnabled: false) - .userFacingMessage == L10n.Localizable.Error.Settings.gpsNotSavedExpectedOn - ) - #expect( - SettingsServiceError.deviceGPSVerificationFailed(expectedEnabled: false, actualEnabled: true) - .userFacingMessage == L10n.Localizable.Error.Settings.gpsNotSavedExpectedOff - ) - } - - @Test func unmappedErrorFallsBackToLocalizedDescription() { - let description = "Something went wrong" - let error: any Error = NSError( - domain: "MC1Tests", - code: 1, - userInfo: [NSLocalizedDescriptionKey: description] - ) - #expect(error.userFacingMessage == description) - } - - // MARK: - Service Error sessionError Delegation - - @Test func sessionErrorDelegatesToCentralMeshCoreMapping() { - #expect( - (ContactServiceError.sessionError(.timeout) as any Error).userFacingMessage - == L10n.Localizable.Error.MeshCore.timeout - ) - #expect( - MessageServiceError.sessionError(.notConnected).userFacingMessage - == L10n.Localizable.Error.MeshCore.notConnected - ) - #expect( - ChannelServiceError.sessionError(.sessionNotStarted).userFacingMessage - == L10n.Localizable.Error.MeshCore.sessionNotStarted - ) - #expect( - MessagePollingError.sessionError(.bluetoothPoweredOff).userFacingMessage - == L10n.Localizable.Error.MeshCore.bluetoothPoweredOff + // MARK: - Dispatch Through `any Error` + + @Test func `mesh core error dispatches to concrete mapping`() { + let error: any Error = MeshCoreError.timeout + #expect(error.userFacingMessage == L10n.Localizable.Error.MeshCore.timeout) + } + + @Test func `protocol error dispatches to concrete mapping`() { + let error: any Error = ProtocolError.tableFull + #expect(error.userFacingMessage == L10n.Localizable.Error.Device.storageFull) + } + + @Test func `timeout error dispatches to concrete mapping`() { + let error: any Error = TimeoutError(operationName: "sync", timeout: .seconds(5)) + #expect(error.userFacingMessage == L10n.Localizable.Error.Timeout.operationTimedOut) + } + + @Test func `app backup error dispatches to concrete mapping`() { + let error: any Error = AppBackupError.invalidFile + #expect(error.userFacingMessage == AppBackupError.invalidFile.userFacingMessage) + } + + @Test func `ble error dispatches to concrete mapping`() { + let detail = "peripheral unreachable" + let error: any Error = BLEError.connectionFailed(detail) + #expect(error.userFacingMessage == L10n.Localizable.Error.Ble.connectionFailed(detail)) + } + + @Test func `connection error dispatches to concrete mapping`() { + let reason = "handshake rejected" + let error: any Error = ConnectionError.initializationFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.Connection.initializationFailed(reason)) + } + + @Test func `wifi transport error dispatches to concrete mapping`() { + let error: any Error = WiFiTransportError.invalidHost + #expect(error.userFacingMessage == L10n.Localizable.Error.Wifi.invalidHost) + } + + @Test func `accessory setup kit error dispatches to concrete mapping`() { + let reason = "user declined" + let error: any Error = AccessorySetupKitError.pairingFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.AccessorySetup.pairingFailed(reason)) + } + + @Test func `contact service error dispatches to concrete mapping`() { + let error: any Error = ContactServiceError.contactTableFull + #expect(error.userFacingMessage == L10n.Localizable.Error.ContactService.contactTableFull) + } + + @Test func `message service error dispatches to concrete mapping`() { + let reason = "queue rejected" + let error: any Error = MessageServiceError.sendFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.MessageService.sendFailed) + #expect(!error.userFacingMessage.contains(reason)) + } + + @Test func `channel service error dispatches to concrete mapping`() { + let failureCount = 3 + let error: any Error = ChannelServiceError.circuitBreakerOpen(consecutiveFailures: failureCount) + #expect(error.userFacingMessage == L10n.Localizable.Error.ChannelService.circuitBreakerOpen(failureCount)) + } + + @Test func `chat send queue service error dispatches to concrete mapping`() { + let error: any Error = ChatSendQueueServiceError.notConnected + #expect(error.userFacingMessage == L10n.Localizable.Error.ChatSendQueue.notConnected) + } + + @Test func `message polling error dispatches to concrete mapping`() { + let error: any Error = MessagePollingError.pollingFailed + #expect(error.userFacingMessage == L10n.Localizable.Error.MessagePolling.pollingFailed) + } + + @Test func `advertisement error dispatches to concrete mapping`() { + let error: any Error = AdvertisementError.sendFailed + #expect(error.userFacingMessage == L10n.Localizable.Error.Advertisement.sendFailed) + } + + @Test func `remote node error dispatches to concrete mapping`() { + let reason = "authentication failed" + let error: any Error = RemoteNodeError.loginFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.RemoteNode.loginFailed) + #expect(!error.userFacingMessage.contains(reason)) + } + + @Test func `room server error dispatches to concrete mapping`() { + let error: any Error = RoomServerError.sessionNotFound + #expect(error.userFacingMessage == L10n.Localizable.Error.RoomServer.sessionNotFound) + } + + @Test func `room server send failed drops raw reason`() { + let reason = "Retry already in progress" + let error: any Error = RoomServerError.sendFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.RoomServer.sendFailed) + #expect(!error.userFacingMessage.contains(reason)) + } + + @Test func `binary protocol error dispatches to concrete mapping`() { + let error: any Error = BinaryProtocolError.timeout + #expect(error.userFacingMessage == L10n.Localizable.Error.BinaryProtocol.timeout) + } + + @Test func `persistence store error dispatches to concrete mapping`() { + let reason = "store unavailable" + let error: any Error = PersistenceStoreError.saveFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.Persistence.saveFailed(reason)) + } + + @Test func `sync coordinator error dispatches to concrete mapping`() { + let error: any Error = SyncCoordinatorError.alreadySyncing + #expect(error.userFacingMessage == L10n.Localizable.Error.SyncCoordinator.alreadySyncing) + } + + @Test func `device service error dispatches to concrete mapping`() { + let reason = "write rejected" + let error: any Error = DeviceServiceError.persistenceFailed(reason) + #expect(error.userFacingMessage == L10n.Localizable.Error.DeviceService.persistenceFailed(reason)) + } + + @Test func `settings service error dispatches to concrete mapping`() { + let expected = "915.5" + let actual = "868.0" + let error: any Error = SettingsServiceError.verificationFailed(expected: expected, actual: actual) + #expect(error.userFacingMessage == L10n.Localizable.Error.Settings.verificationFailed(expected, actual)) + } + + @Test func `keychain error dispatches to concrete mapping`() { + let status: OSStatus = errSecDuplicateItem + let error: any Error = KeychainError.storageFailed(status) + #expect(error.userFacingMessage == L10n.Localizable.Error.Keychain.storageFailed(Int(status))) + } + + @Test func `key generation error dispatches to concrete mapping`() { + let error: any Error = KeyGenerationError.reservedPrefix + #expect(error.userFacingMessage == L10n.Localizable.Error.KeyGeneration.reservedPrefix) + } + + @Test func `node config service error dispatches to concrete mapping`() { + let channelIndex = 2 + let hexLength = 30 + let error: any Error = NodeConfigServiceError.invalidChannelSecret(index: channelIndex, hexLength: hexLength) + #expect( + error.userFacingMessage + == L10n.Settings.ConfigImport.Error.invalidChannelSecret(channelIndex, hexLength) + ) + } + + @Test func `store service error dispatches to concrete mapping`() { + let reason = "storefront rejected" + let error: any Error = StoreServiceError.purchaseFailed(reason: reason) + #expect(error.userFacingMessage == L10n.Settings.Support.Error.purchaseFailed(reason)) + } + + @Test func `device GPS verification failed picks boolean variant key`() { + #expect( + SettingsServiceError.deviceGPSVerificationFailed(expectedEnabled: true, actualEnabled: false) + .userFacingMessage == L10n.Localizable.Error.Settings.gpsNotSavedExpectedOn + ) + #expect( + SettingsServiceError.deviceGPSVerificationFailed(expectedEnabled: false, actualEnabled: true) + .userFacingMessage == L10n.Localizable.Error.Settings.gpsNotSavedExpectedOff + ) + } + + @Test func `unmapped error falls back to localized description`() { + let description = "Something went wrong" + let error: any Error = NSError( + domain: "MC1Tests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: description] + ) + #expect(error.userFacingMessage == description) + } + + // MARK: - Service Error sessionError Delegation + + @Test func `session error delegates to central mesh core mapping`() { + #expect( + (ContactServiceError.sessionError(.timeout) as any Error).userFacingMessage + == L10n.Localizable.Error.MeshCore.timeout + ) + #expect( + MessageServiceError.sessionError(.notConnected).userFacingMessage + == L10n.Localizable.Error.MeshCore.notConnected + ) + #expect( + ChannelServiceError.sessionError(.sessionNotStarted).userFacingMessage + == L10n.Localizable.Error.MeshCore.sessionNotStarted + ) + #expect( + MessagePollingError.sessionError(.bluetoothPoweredOff).userFacingMessage + == L10n.Localizable.Error.MeshCore.bluetoothPoweredOff + ) + #expect( + AdvertisementError.sessionError(.featureDisabled).userFacingMessage + == L10n.Localizable.Error.MeshCore.featureDisabled + ) + #expect( + RemoteNodeError.sessionError(.timeout).userFacingMessage + == L10n.Localizable.Error.MeshCore.timeout + ) + #expect( + RoomServerError.sessionError(.notConnected).userFacingMessage + == L10n.Localizable.Error.MeshCore.notConnected + ) + #expect( + BinaryProtocolError.sessionError(.sessionNotStarted).userFacingMessage + == L10n.Localizable.Error.MeshCore.sessionNotStarted + ) + #expect( + SettingsServiceError.sessionError(.timeout).userFacingMessage + == L10n.Localizable.Error.MeshCore.timeout + ) + } + + @Test func `persist failed recurses into underlying error`() { + let underlying = MessageServiceError.notConnected + #expect( + ChatSendQueueServiceError.persistFailed(underlying: underlying).userFacingMessage + == L10n.Localizable.Error.ChatSendQueue.persistFailed( + L10n.Localizable.Error.MessageService.notConnected ) - #expect( - AdvertisementError.sessionError(.featureDisabled).userFacingMessage - == L10n.Localizable.Error.MeshCore.featureDisabled + ) + } + + // MARK: - MeshCoreError Mapping + + @Test func `interpolated cases carry associated values`() { + let detail = "bad frame" + #expect( + MeshCoreError.parseError(detail).userFacingMessage + == L10n.Localizable.Error.MeshCore.parseError(detail) + ) + #expect( + MeshCoreError.dataTooLarge(maxSize: 184, actualSize: 200).userFacingMessage + == L10n.Localizable.Error.MeshCore.dataTooLarge(200, 184) + ) + } + + @Test func `device error maps known codes through protocol error`() { + for protocolError in [ProtocolError.unsupportedCommand, .notFound, .tableFull, .badState, .fileIOError, .illegalArgument] { + #expect( + MeshCoreError.deviceError(code: protocolError.rawValue).userFacingMessage + == protocolError.userFacingMessage + ) + } + } + + @Test func `device error falls back for unknown code`() { + let unknownCode: UInt8 = 0x42 + #expect( + MeshCoreError.deviceError(code: unknownCode).userFacingMessage + == L10n.Localizable.Error.Device.unknown(Int(unknownCode)) + ) + } + + @Test func `connection lost recurses into underlying error`() { + let underlying = MeshCoreError.bluetoothPoweredOff + #expect( + MeshCoreError.connectionLost(underlying: underlying).userFacingMessage + == L10n.Localizable.Error.MeshCore.connectionLost( + L10n.Localizable.Error.MeshCore.bluetoothPoweredOff ) - #expect( - RemoteNodeError.sessionError(.timeout).userFacingMessage - == L10n.Localizable.Error.MeshCore.timeout - ) - #expect( - RoomServerError.sessionError(.notConnected).userFacingMessage - == L10n.Localizable.Error.MeshCore.notConnected - ) - #expect( - BinaryProtocolError.sessionError(.sessionNotStarted).userFacingMessage - == L10n.Localizable.Error.MeshCore.sessionNotStarted - ) - #expect( - SettingsServiceError.sessionError(.timeout).userFacingMessage - == L10n.Localizable.Error.MeshCore.timeout - ) - } - - @Test func persistFailedRecursesIntoUnderlyingError() { - let underlying = MessageServiceError.notConnected - #expect( - ChatSendQueueServiceError.persistFailed(underlying: underlying).userFacingMessage - == L10n.Localizable.Error.ChatSendQueue.persistFailed( - L10n.Localizable.Error.MessageService.notConnected - ) - ) - } - - // MARK: - MeshCoreError Mapping - - @Test func interpolatedCasesCarryAssociatedValues() { - let detail = "bad frame" - #expect( - MeshCoreError.parseError(detail).userFacingMessage - == L10n.Localizable.Error.MeshCore.parseError(detail) - ) - #expect( - MeshCoreError.dataTooLarge(maxSize: 184, actualSize: 200).userFacingMessage - == L10n.Localizable.Error.MeshCore.dataTooLarge(200, 184) - ) - } - - @Test func deviceErrorMapsKnownCodesThroughProtocolError() { - for protocolError in [ProtocolError.unsupportedCommand, .notFound, .tableFull, .badState, .fileIOError, .illegalArgument] { - #expect( - MeshCoreError.deviceError(code: protocolError.rawValue).userFacingMessage - == protocolError.userFacingMessage - ) - } - } - - @Test func deviceErrorFallsBackForUnknownCode() { - let unknownCode: UInt8 = 0x42 - #expect( - MeshCoreError.deviceError(code: unknownCode).userFacingMessage - == L10n.Localizable.Error.Device.unknown(Int(unknownCode)) - ) - } - - @Test func connectionLostRecursesIntoUnderlyingError() { - let underlying = MeshCoreError.bluetoothPoweredOff - #expect( - MeshCoreError.connectionLost(underlying: underlying).userFacingMessage - == L10n.Localizable.Error.MeshCore.connectionLost( - L10n.Localizable.Error.MeshCore.bluetoothPoweredOff - ) - ) - #expect( - MeshCoreError.connectionLost(underlying: nil).userFacingMessage - == L10n.Localizable.Error.MeshCore.connectionLostNoDetail - ) - } + ) + #expect( + MeshCoreError.connectionLost(underlying: nil).userFacingMessage + == L10n.Localizable.Error.MeshCore.connectionLostNoDetail + ) + } } diff --git a/MC1Tests/Formatters/MessagePathFormatterTests.swift b/MC1Tests/Formatters/MessagePathFormatterTests.swift index 524a601c..b467065d 100644 --- a/MC1Tests/Formatters/MessagePathFormatterTests.swift +++ b/MC1Tests/Formatters/MessagePathFormatterTests.swift @@ -1,160 +1,160 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @Suite("MessagePathFormatter Tests") struct MessagePathFormatterTests { - // MARK: - Direct Path Tests - - @Test("pathLength 0 with no pathNodes returns Flood (zero-hop flood)") - func floodPathLengthZero() { - let message = createMessage(pathLength: 0, pathNodes: nil) - let result = MessagePathFormatter.format(message) - #expect(result == L10n.Chats.Chats.Message.Path.flood) - } - - @Test("pathLength 0xFF returns Direct") - func directPathLengthMax() { - let message = createMessage(pathLength: 0xFF, pathNodes: nil) - let result = MessagePathFormatter.format(message) - #expect(result == L10n.Chats.Chats.Message.Path.direct) - } - - @Test("pathLength 1 with 0xFF destination marker returns Direct") - func directWithDestinationMarker() { - let message = createMessage(pathLength: 1, pathNodes: Data([0xFF])) - let result = MessagePathFormatter.format(message) - #expect(result == L10n.Chats.Chats.Message.Path.direct) - } - - // MARK: - Path Nodes Tests - - @Test("Single node path formats correctly") - func singleNode() { - let message = createMessage(pathLength: 1, pathNodes: Data([0xA3])) - let result = MessagePathFormatter.format(message) - #expect(result == "A3") - } - - @Test("Three node path formats with commas") - func threeNodes() { - let message = createMessage(pathLength: 3, pathNodes: Data([0xA3, 0x7F, 0x42])) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42") - } - - @Test("Four node path shows all nodes") - func fourNodes() { - let message = createMessage(pathLength: 4, pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2])) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42,B2") - } - - // MARK: - Truncation Tests - - @Test("Six node path shows all nodes (no truncation)") - func sixNodesNotTruncated() { - let message = createMessage( - pathLength: 6, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42,B2,C1,D4") - } - - @Test("Seven node path truncates with ellipsis") - func sevenNodesTruncated() { - let message = createMessage( - pathLength: 7, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4, 0xE5]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42…C1,D4,E5") - } - - @Test("Ten node path truncates correctly") - func tenNodesTruncated() { - let message = createMessage( - pathLength: 10, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4, 0xE5, 0xF6, 0x11, 0x22]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42…F6,11,22") - } - - // MARK: - Boundary & Edge Case Tests - - @Test("Five node path shows all nodes (boundary before truncation)") - func fiveNodesNoTruncation() { - let message = createMessage( - pathLength: 5, - pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1]) - ) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42,B2,C1") - } - - @Test("Zero-byte node formats correctly") - func zeroByteNode() { - let message = createMessage(pathLength: 2, pathNodes: Data([0x00, 0xA3])) - let result = MessagePathFormatter.format(message) - #expect(result == "00,A3") - } - - // MARK: - Fallback Tests - - @Test("Missing pathNodes on flood-routed returns Flood") - func fallbackToFlood() { - let message = createMessage(pathLength: 3, pathNodes: nil) - let result = MessagePathFormatter.format(message) - #expect(result == L10n.Chats.Chats.Message.Path.flood) - } - - // MARK: - Edge Case Tests - - @Test("pathLength doesn't match pathNodes count - shows actual nodes") - func mismatchedPathData() { - // pathLength says 5, but only 3 nodes in data - should show what we have - let message = createMessage(pathLength: 5, pathNodes: Data([0xA3, 0x7F, 0x42])) - let result = MessagePathFormatter.format(message) - #expect(result == "A3,7F,42") - } - - // MARK: - Multibyte Hash Mode Tests - - @Test("Mode-1 path chunks bytes into 2-byte hops") - func mode1TwoByteHops() { - // 0x42 = mode 1 (2 bytes/hop), 2 hops → 4 wire bytes → ["A1B2","C3D4"]. - let message = createMessage( - pathLength: 0x42, - pathNodes: Data([0xA1, 0xB2, 0xC3, 0xD4]) - ) - #expect(MessagePathFormatter.format(message) == "A1B2,C3D4") - } - - @Test("Mode-2 path chunks bytes into 3-byte hops") - func mode2ThreeByteHops() { - // 0x82 = mode 2 (3 bytes/hop), 2 hops → 6 wire bytes → ["010203","040506"]. - let message = createMessage( - pathLength: 0x82, - pathNodes: Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]) - ) - #expect(MessagePathFormatter.format(message) == "010203,040506") - } - - // MARK: - Helper - - private func createMessage(pathLength: UInt8, pathNodes: Data?) -> MessageDTO { - let message = Message( - radioID: UUID(), - contactID: UUID(), - text: "Test", - directionRawValue: MessageDirection.incoming.rawValue, - statusRawValue: MessageStatus.delivered.rawValue, - pathLength: pathLength, - pathNodes: pathNodes - ) - return MessageDTO(from: message) - } + // MARK: - Direct Path Tests + + @Test + func `pathLength 0 with no pathNodes returns Flood (zero-hop flood)`() { + let message = createMessage(pathLength: 0, pathNodes: nil) + let result = MessagePathFormatter.format(message) + #expect(result == L10n.Chats.Chats.Message.Path.flood) + } + + @Test + func `pathLength 0xFF returns Direct`() { + let message = createMessage(pathLength: 0xFF, pathNodes: nil) + let result = MessagePathFormatter.format(message) + #expect(result == L10n.Chats.Chats.Message.Path.direct) + } + + @Test + func `pathLength 1 with 0xFF destination marker returns Direct`() { + let message = createMessage(pathLength: 1, pathNodes: Data([0xFF])) + let result = MessagePathFormatter.format(message) + #expect(result == L10n.Chats.Chats.Message.Path.direct) + } + + // MARK: - Path Nodes Tests + + @Test + func `Single node path formats correctly`() { + let message = createMessage(pathLength: 1, pathNodes: Data([0xA3])) + let result = MessagePathFormatter.format(message) + #expect(result == "A3") + } + + @Test + func `Three node path formats with commas`() { + let message = createMessage(pathLength: 3, pathNodes: Data([0xA3, 0x7F, 0x42])) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42") + } + + @Test + func `Four node path shows all nodes`() { + let message = createMessage(pathLength: 4, pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2])) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42,B2") + } + + // MARK: - Truncation Tests + + @Test + func `Six node path shows all nodes (no truncation)`() { + let message = createMessage( + pathLength: 6, + pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4]) + ) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42,B2,C1,D4") + } + + @Test + func `Seven node path truncates with ellipsis`() { + let message = createMessage( + pathLength: 7, + pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4, 0xE5]) + ) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42…C1,D4,E5") + } + + @Test + func `Ten node path truncates correctly`() { + let message = createMessage( + pathLength: 10, + pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1, 0xD4, 0xE5, 0xF6, 0x11, 0x22]) + ) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42…F6,11,22") + } + + // MARK: - Boundary & Edge Case Tests + + @Test + func `Five node path shows all nodes (boundary before truncation)`() { + let message = createMessage( + pathLength: 5, + pathNodes: Data([0xA3, 0x7F, 0x42, 0xB2, 0xC1]) + ) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42,B2,C1") + } + + @Test + func `Zero-byte node formats correctly`() { + let message = createMessage(pathLength: 2, pathNodes: Data([0x00, 0xA3])) + let result = MessagePathFormatter.format(message) + #expect(result == "00,A3") + } + + // MARK: - Fallback Tests + + @Test + func `Missing pathNodes on flood-routed returns Flood`() { + let message = createMessage(pathLength: 3, pathNodes: nil) + let result = MessagePathFormatter.format(message) + #expect(result == L10n.Chats.Chats.Message.Path.flood) + } + + // MARK: - Edge Case Tests + + @Test + func `pathLength doesn't match pathNodes count - shows actual nodes`() { + // pathLength says 5, but only 3 nodes in data - should show what we have + let message = createMessage(pathLength: 5, pathNodes: Data([0xA3, 0x7F, 0x42])) + let result = MessagePathFormatter.format(message) + #expect(result == "A3,7F,42") + } + + // MARK: - Multibyte Hash Mode Tests + + @Test + func `Mode-1 path chunks bytes into 2-byte hops`() { + // 0x42 = mode 1 (2 bytes/hop), 2 hops → 4 wire bytes → ["A1B2","C3D4"]. + let message = createMessage( + pathLength: 0x42, + pathNodes: Data([0xA1, 0xB2, 0xC3, 0xD4]) + ) + #expect(MessagePathFormatter.format(message) == "A1B2,C3D4") + } + + @Test + func `Mode-2 path chunks bytes into 3-byte hops`() { + // 0x82 = mode 2 (3 bytes/hop), 2 hops → 6 wire bytes → ["010203","040506"]. + let message = createMessage( + pathLength: 0x82, + pathNodes: Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]) + ) + #expect(MessagePathFormatter.format(message) == "010203,040506") + } + + // MARK: - Helper + + private func createMessage(pathLength: UInt8, pathNodes: Data?) -> MessageDTO { + let message = Message( + radioID: UUID(), + contactID: UUID(), + text: "Test", + directionRawValue: MessageDirection.incoming.rawValue, + statusRawValue: MessageStatus.delivered.rawValue, + pathLength: pathLength, + pathNodes: pathNodes + ) + return MessageDTO(from: message) + } } diff --git a/MC1Tests/Helpers/StoreKitTestPurchase.swift b/MC1Tests/Helpers/StoreKitTestPurchase.swift index ff73ef6e..1557f28f 100644 --- a/MC1Tests/Helpers/StoreKitTestPurchase.swift +++ b/MC1Tests/Helpers/StoreKitTestPurchase.swift @@ -1,5 +1,5 @@ -import StoreKit @testable import MC1Services +import StoreKit /// Retries `StoreService.purchase` past the transient `StoreKitError.unknown` that storekitd raises /// intermittently under SKTestSession churn (it surfaces as `.purchaseFailed`). A real purchase @@ -7,16 +7,16 @@ import StoreKit /// every SKTestSession suite so setup purchases don't flake under `make test-store`. @MainActor func purchaseWithRetry( - _ product: Product, - on service: StoreService, - attempts: Int = 4 + _ product: Product, + on service: StoreService, + attempts: Int = 4 ) async throws -> StorePurchaseOutcome { - for attempt in 1...attempts { - do { - return try await service.purchase(product) - } catch let error as StoreServiceError { - guard case .purchaseFailed = error, attempt < attempts else { throw error } - } + for attempt in 1...attempts { + do { + return try await service.purchase(product) + } catch let error as StoreServiceError { + guard case .purchaseFailed = error, attempt < attempts else { throw error } } - throw StoreServiceError.purchaseFailed(reason: "purchase retries exhausted") + } + throw StoreServiceError.purchaseFailed(reason: "purchase retries exhausted") } diff --git a/MC1Tests/Helpers/TestHelpers.swift b/MC1Tests/Helpers/TestHelpers.swift index 6c9b3365..84719505 100644 --- a/MC1Tests/Helpers/TestHelpers.swift +++ b/MC1Tests/Helpers/TestHelpers.swift @@ -4,9 +4,9 @@ import Foundation /// This is needed because Swift's strict concurrency does not allow capturing /// `var` in async closures that may execute concurrently. public final class MutableBox: @unchecked Sendable { - public var value: T + public var value: T - public init(_ value: T) { - self.value = value - } + public init(_ value: T) { + self.value = value + } } diff --git a/MC1Tests/Helpers/TestPolling.swift b/MC1Tests/Helpers/TestPolling.swift index e9b93bdb..cb90223d 100644 --- a/MC1Tests/Helpers/TestPolling.swift +++ b/MC1Tests/Helpers/TestPolling.swift @@ -2,22 +2,22 @@ import Foundation /// Polls a condition until it becomes true or a timeout expires. func waitUntil( - timeout: Duration = .seconds(2), - pollingInterval: Duration = .milliseconds(10), - _ message: String = "waitUntil timed out", - condition: @escaping @MainActor () async -> Bool + timeout: Duration = .seconds(2), + pollingInterval: Duration = .milliseconds(10), + _ message: String = "waitUntil timed out", + condition: @escaping @MainActor () async -> Bool ) async throws { - let deadline = ContinuousClock.now + timeout - - while ContinuousClock.now < deadline { - if await condition() { return } - try await Task.sleep(for: pollingInterval) - } + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { if await condition() { return } + try await Task.sleep(for: pollingInterval) + } + + if await condition() { return } - struct WaitTimeoutError: Error, CustomStringConvertible { - let description: String - } - throw WaitTimeoutError(description: message) + struct WaitTimeoutError: Error, CustomStringConvertible { + let description: String + } + throw WaitTimeoutError(description: message) } diff --git a/MC1Tests/Intents/EntityIdentityTests.swift b/MC1Tests/Intents/EntityIdentityTests.swift index a729b8d0..00fe9bca 100644 --- a/MC1Tests/Intents/EntityIdentityTests.swift +++ b/MC1Tests/Intents/EntityIdentityTests.swift @@ -1,8 +1,8 @@ import CryptoKit import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing /// The App Intents entity ids are persisted by the framework inside user-saved /// shortcuts, so they must be stable across backup export/import (which re-mints @@ -12,188 +12,187 @@ import Testing /// failing safe to nil rather than resolving against the wrong radio. @MainActor struct EntityIdentityTests { - - private static let radioID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - private static let otherRadioID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! - - private static func makeContact( - rowID: UUID = UUID(), - radioID: UUID = radioID, - publicKey: Data, - name: String = "Alice" - ) -> ContactDTO { - ContactDTO( - id: rowID, - radioID: radioID, - publicKey: publicKey, - name: name, - typeRawValue: 0x01, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - ocvPreset: nil, - customOCVArrayString: nil - ) - } - - private static func makeChannel( - rowID: UUID = UUID(), - radioID: UUID = radioID, - index: UInt8 = 0, - name: String = "Ops", - secret: Data - ) -> ChannelDTO { - ChannelDTO( - id: rowID, - radioID: radioID, - index: index, - name: name, - secret: secret, - isEnabled: true, - lastMessageDate: nil, - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false, - floodScope: .inherit - ) - } - - // MARK: - Composite id round-trip - - @Test func compositeIDRoundTrips() { - let keyHex = Data(repeating: 0xAB, count: 32).hexString - let id = formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: keyHex) - - let parsed = parseCompositeID(id) - #expect(parsed?.radioID == Self.radioID) - #expect(parsed?.kind == .contact) - #expect(parsed?.keyHex == keyHex) - } - - @Test(arguments: [ - "", // empty - "not-a-uuid/contact/abc", // bad radio scope - "11111111-1111-1111-1111-111111111111", // missing kind + key - "11111111-1111-1111-1111-111111111111/contact", // missing key - "11111111-1111-1111-1111-111111111111/contact/", // empty key - "11111111-1111-1111-1111-111111111111/bogus/abc" // unknown kind - ]) - func malformedCompositeIDFailsSafe(_ id: String) { - #expect(parseCompositeID(id) == nil) - } - - // MARK: - Contact identity - - @Test func contactIDDerivesFromPublicKeyNotRowID() { - let publicKey = Data(repeating: 0xCD, count: 32) - let first = MessageTargetEntity(dto: Self.makeContact(rowID: UUID(), publicKey: publicKey)) - let second = MessageTargetEntity(dto: Self.makeContact(rowID: UUID(), publicKey: publicKey)) - - // Same radio + public key must yield the same id even though the volatile - // row UUID differs (as it would after a backup export/import). - #expect(first.id == second.id) - #expect(first.id == formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: publicKey.hexString)) - #expect(first.kind == .contact) - } - - @Test func contactIDIsRadioScoped() { - let publicKey = Data(repeating: 0xCD, count: 32) - let here = MessageTargetEntity(dto: Self.makeContact(radioID: Self.radioID, publicKey: publicKey)) - let elsewhere = MessageTargetEntity(dto: Self.makeContact(radioID: Self.otherRadioID, publicKey: publicKey)) - - #expect(here.id != elsewhere.id) - } - - // MARK: - Channel identity - - @Test func channelIDDerivesFromSecretDigestNotRawSecretOrIndexOrRowID() { - let secret = Data(repeating: 0x5A, count: 16) - let first = MessageTargetEntity(dto: Self.makeChannel(rowID: UUID(), index: 0, secret: secret)) - let second = MessageTargetEntity(dto: Self.makeChannel(rowID: UUID(), index: 7, secret: secret)) - - // Stable across a relocated slot and a re-minted row id. - #expect(first.id == second.id) - #expect(first.kind == .channel) - - // The raw secret must never appear in the id (it is the live encryption key). - let secretHex = secret.hexString - #expect(!first.id.contains(secretHex)) - - // The key portion is the domain-separated digest, not the secret. - let parsed = parseCompositeID(first.id) - let expectedDigest = channelSecretDigestHex(radioID: Self.radioID, secret: secret) - #expect(parsed?.kind == .channel) - #expect(parsed?.keyHex == expectedDigest) - #expect(parsed?.keyHex != secretHex) - } - - // MARK: - Contact / channel ids never collide across kinds - - @Test func contactAndChannelIDsAreDistinctEvenWhenKeyMatches() { - // A contact public key and a channel digest are different lengths, but the - // kind segment guarantees the two id spaces never overlap regardless. - let contact = MessageTargetEntity(dto: Self.makeContact(publicKey: Data(repeating: 0x11, count: 32))) - let channel = MessageTargetEntity(dto: Self.makeChannel(secret: Data(repeating: 0x11, count: 16))) - #expect(contact.id != channel.id) - #expect(parseCompositeID(contact.id)?.kind == .contact) - #expect(parseCompositeID(channel.id)?.kind == .channel) - } - - @Test func channelDigestIsRadioScoped() { - let secret = Data(repeating: 0x5A, count: 16) - let here = channelSecretDigestHex(radioID: Self.radioID, secret: secret) - let elsewhere = channelSecretDigestHex(radioID: Self.otherRadioID, secret: secret) - - #expect(here != elsewhere) - } - - @Test func distinctChannelSecretsDoNotCollide() { - // Seed a radio's worth of channels with distinct secrets and assert every - // entity id is unique, so a digest never mis-routes a send to the wrong - // channel. - let channels = (0..<8).map { i in - Self.makeChannel(index: UInt8(i), secret: Data(repeating: UInt8(i), count: 16)) - } - let ids = channels.map { MessageTargetEntity(dto: $0).id } - #expect(Set(ids).count == ids.count) - } - - @Test func channelDigestLengthIsFixedWidth() { - let secret = Data(repeating: 0x5A, count: 16) - let digestHex = channelSecretDigestHex(radioID: Self.radioID, secret: secret) - // 16 bytes rendered as lowercase hex is 32 characters. - #expect(digestHex.count == 32) - } - - // MARK: - Persisted kind-segment string format - - @Test func kindRawValuesArePinnedStrings() { - // The raw value is the literal segment written into framework-persisted - // saved-shortcut ids; assert it independently of the enum symbol so a - // future case rename that changed the on-disk string is caught here. - #expect(MessageTargetKind.contact.rawValue == "contact") - #expect(MessageTargetKind.channel.rawValue == "channel") - } - - @Test func compositeIDEmbedsTheLiteralKindSegment() { - let contactID = formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: "ab") - let channelID = formatCompositeID(radioID: Self.radioID, kind: .channel, keyHex: "ab") - - #expect(contactID.contains("/contact/")) - #expect(channelID.contains("/channel/")) + private static let radioID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + private static let otherRadioID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + + private static func makeContact( + rowID: UUID = UUID(), + radioID: UUID = radioID, + publicKey: Data, + name: String = "Alice" + ) -> ContactDTO { + ContactDTO( + id: rowID, + radioID: radioID, + publicKey: publicKey, + name: name, + typeRawValue: 0x01, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + ocvPreset: nil, + customOCVArrayString: nil + ) + } + + private static func makeChannel( + rowID: UUID = UUID(), + radioID: UUID = radioID, + index: UInt8 = 0, + name: String = "Ops", + secret: Data + ) -> ChannelDTO { + ChannelDTO( + id: rowID, + radioID: radioID, + index: index, + name: name, + secret: secret, + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false, + floodScope: .inherit + ) + } + + // MARK: - Composite id round-trip + + @Test func `composite ID round trips`() { + let keyHex = Data(repeating: 0xAB, count: 32).hexString + let id = formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: keyHex) + + let parsed = parseCompositeID(id) + #expect(parsed?.radioID == Self.radioID) + #expect(parsed?.kind == .contact) + #expect(parsed?.keyHex == keyHex) + } + + @Test(arguments: [ + "", // empty + "not-a-uuid/contact/abc", // bad radio scope + "11111111-1111-1111-1111-111111111111", // missing kind + key + "11111111-1111-1111-1111-111111111111/contact", // missing key + "11111111-1111-1111-1111-111111111111/contact/", // empty key + "11111111-1111-1111-1111-111111111111/bogus/abc" // unknown kind + ]) + func `malformed composite ID fails safe`(_ id: String) { + #expect(parseCompositeID(id) == nil) + } + + // MARK: - Contact identity + + @Test func `contact ID derives from public key not row ID`() { + let publicKey = Data(repeating: 0xCD, count: 32) + let first = MessageTargetEntity(dto: Self.makeContact(rowID: UUID(), publicKey: publicKey)) + let second = MessageTargetEntity(dto: Self.makeContact(rowID: UUID(), publicKey: publicKey)) + + // Same radio + public key must yield the same id even though the volatile + // row UUID differs (as it would after a backup export/import). + #expect(first.id == second.id) + #expect(first.id == formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: publicKey.hexString)) + #expect(first.kind == .contact) + } + + @Test func `contact ID is radio scoped`() { + let publicKey = Data(repeating: 0xCD, count: 32) + let here = MessageTargetEntity(dto: Self.makeContact(radioID: Self.radioID, publicKey: publicKey)) + let elsewhere = MessageTargetEntity(dto: Self.makeContact(radioID: Self.otherRadioID, publicKey: publicKey)) + + #expect(here.id != elsewhere.id) + } + + // MARK: - Channel identity + + @Test func `channel ID derives from secret digest not raw secret or index or row ID`() { + let secret = Data(repeating: 0x5A, count: 16) + let first = MessageTargetEntity(dto: Self.makeChannel(rowID: UUID(), index: 0, secret: secret)) + let second = MessageTargetEntity(dto: Self.makeChannel(rowID: UUID(), index: 7, secret: secret)) + + // Stable across a relocated slot and a re-minted row id. + #expect(first.id == second.id) + #expect(first.kind == .channel) + + // The raw secret must never appear in the id (it is the live encryption key). + let secretHex = secret.hexString + #expect(!first.id.contains(secretHex)) + + // The key portion is the domain-separated digest, not the secret. + let parsed = parseCompositeID(first.id) + let expectedDigest = channelSecretDigestHex(radioID: Self.radioID, secret: secret) + #expect(parsed?.kind == .channel) + #expect(parsed?.keyHex == expectedDigest) + #expect(parsed?.keyHex != secretHex) + } + + // MARK: - Contact / channel ids never collide across kinds + + @Test func `contact and channel I ds are distinct even when key matches`() { + // A contact public key and a channel digest are different lengths, but the + // kind segment guarantees the two id spaces never overlap regardless. + let contact = MessageTargetEntity(dto: Self.makeContact(publicKey: Data(repeating: 0x11, count: 32))) + let channel = MessageTargetEntity(dto: Self.makeChannel(secret: Data(repeating: 0x11, count: 16))) + #expect(contact.id != channel.id) + #expect(parseCompositeID(contact.id)?.kind == .contact) + #expect(parseCompositeID(channel.id)?.kind == .channel) + } + + @Test func `channel digest is radio scoped`() { + let secret = Data(repeating: 0x5A, count: 16) + let here = channelSecretDigestHex(radioID: Self.radioID, secret: secret) + let elsewhere = channelSecretDigestHex(radioID: Self.otherRadioID, secret: secret) + + #expect(here != elsewhere) + } + + @Test func `distinct channel secrets do not collide`() { + // Seed a radio's worth of channels with distinct secrets and assert every + // entity id is unique, so a digest never mis-routes a send to the wrong + // channel. + let channels = (0..<8).map { i in + Self.makeChannel(index: UInt8(i), secret: Data(repeating: UInt8(i), count: 16)) } + let ids = channels.map { MessageTargetEntity(dto: $0).id } + #expect(Set(ids).count == ids.count) + } + + @Test func `channel digest length is fixed width`() { + let secret = Data(repeating: 0x5A, count: 16) + let digestHex = channelSecretDigestHex(radioID: Self.radioID, secret: secret) + // 16 bytes rendered as lowercase hex is 32 characters. + #expect(digestHex.count == 32) + } + + // MARK: - Persisted kind-segment string format + + @Test func `kind raw values are pinned strings`() { + // The raw value is the literal segment written into framework-persisted + // saved-shortcut ids; assert it independently of the enum symbol so a + // future case rename that changed the on-disk string is caught here. + #expect(MessageTargetKind.contact.rawValue == "contact") + #expect(MessageTargetKind.channel.rawValue == "channel") + } + + @Test func `composite ID embeds the literal kind segment`() { + let contactID = formatCompositeID(radioID: Self.radioID, kind: .contact, keyHex: "ab") + let channelID = formatCompositeID(radioID: Self.radioID, kind: .channel, keyHex: "ab") + + #expect(contactID.contains("/contact/")) + #expect(channelID.contains("/channel/")) + } } /// Exercises the radio-scoped fetch and the digest disambiguation that @@ -203,171 +202,170 @@ struct EntityIdentityTests { /// scoping and fail-safe resolution without standing up a connection. @MainActor struct EntityQueryScopingTests { - - private static let radioA = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! - private static let radioB = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! - - private func makeStore() throws -> PersistenceStore { - let container = try PersistenceStore.createContainer(inMemory: true) - return PersistenceStore(modelContainer: container) - } - - private func makeContact(radioID: UUID, publicKey: Data, name: String, type: ContactType = .chat) -> ContactDTO { - ContactDTO( - id: UUID(), radioID: radioID, publicKey: publicKey, name: name, - typeRawValue: type.rawValue, flags: 0, outPathLength: 0, outPath: Data(), - lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0, - nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, - lastMessageDate: nil, unreadCount: 0 - ) - } - - private func makeChannel(radioID: UUID, index: UInt8, name: String, secret: Data) -> ChannelDTO { - ChannelDTO( - id: UUID(), radioID: radioID, index: index, name: name, secret: secret, - isEnabled: true, lastMessageDate: nil, unreadCount: 0 - ) - } - - @Test func fetchReturnsOnlyScopedRadioRows() async throws { - let store = try makeStore() - try await store.saveContact(makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0xA1, count: 32), name: "A-contact")) - try await store.saveContact(makeContact(radioID: Self.radioB, publicKey: Data(repeating: 0xB1, count: 32), name: "B-contact")) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "A-channel", secret: Data(repeating: 0xA2, count: 16))) - try await store.saveChannel(makeChannel(radioID: Self.radioB, index: 0, name: "B-channel", secret: Data(repeating: 0xB2, count: 16))) - - let contactsA = try await store.fetchContacts(radioID: Self.radioA) - let channelsA = try await store.fetchChannels(radioID: Self.radioA) - - #expect(contactsA.map(\.name) == ["A-contact"]) - #expect(channelsA.map(\.name) == ["A-channel"]) - #expect(contactsA.allSatisfy { $0.radioID == Self.radioA }) - #expect(channelsA.allSatisfy { $0.radioID == Self.radioA }) - } - - @Test func fetchIsEmptyForUnseededRadio() async throws { - // A `nil` current radio short-circuits to `[]` before any fetch; an - // unknown-but-non-nil radio likewise yields no rows. - let store = try makeStore() - try await store.saveContact(makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0xA1, count: 32), name: "A-contact")) - - let stranger = UUID() - #expect(try await store.fetchContacts(radioID: stranger).isEmpty) - #expect(try await store.fetchChannels(radioID: stranger).isEmpty) - } - - private func channelID(radioID: UUID, secret: Data) -> String { - formatCompositeID(radioID: radioID, kind: .channel, keyHex: channelSecretDigestHex(radioID: radioID, secret: secret)) - } - - @Test func channelResolveFailsSafeOnDuplicateDigest() async throws { - let store = try makeStore() - let secret = Data(repeating: 0x09, count: 16) - // Two distinct rows sharing a secret collide to one digest id; the resolver - // must refuse both rather than mis-route a send to an arbitrary one. - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Dup0", secret: secret)) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Dup1", secret: secret)) - - let id = channelID(radioID: Self.radioA, secret: secret) - let resolved = resolveUniqueChannels(matching: [id], in: try await store.fetchChannels(radioID: Self.radioA)) - - #expect(resolved.isEmpty) - } - - @Test func channelResolveFailsSafeOnZeroMatch() async throws { - let store = try makeStore() - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Only", secret: Data(repeating: 0x01, count: 16))) - - let unknownID = channelID(radioID: Self.radioA, secret: Data(repeating: 0xFE, count: 16)) - let resolved = resolveUniqueChannels(matching: [unknownID], in: try await store.fetchChannels(radioID: Self.radioA)) - - #expect(resolved.isEmpty) - } - - @Test func channelResolveAcceptsUniqueDigest() async throws { - let store = try makeStore() - let secret = Data(repeating: 0x33, count: 16) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Wanted", secret: secret)) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Other", secret: Data(repeating: 0x44, count: 16))) - - let id = channelID(radioID: Self.radioA, secret: secret) - let resolved = resolveUniqueChannels(matching: [id], in: try await store.fetchChannels(radioID: Self.radioA)) - - #expect(resolved.map { MessageTargetEntity(dto: $0).id } == [id]) - #expect(resolved.map(\.name) == ["Wanted"]) - } - - // MARK: - Merged resolution keeps the channel fail-safe - - @Test func mergedResolveExcludesDuplicateDigestChannel() async throws { - // The same duplicate-digest fail-safe must survive inside the merged - // contact+channel resolution, not just the channel helper, so a future - // collapse-to-one-filter refactor of the query fails loudly here. - let store = try makeStore() - let secret = Data(repeating: 0x07, count: 16) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Dup0", secret: secret)) - try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Dup1", secret: secret)) - - let id = channelID(radioID: Self.radioA, secret: secret) - let resolved = resolveMessageTargets( - matching: [id], - contacts: [], - channels: try await store.fetchChannels(radioID: Self.radioA) - ) - - #expect(resolved.isEmpty) - } - - // MARK: - Picker list is chat-only - - @Test func pickerListExcludesRepeaterAndRoomContacts() { - let contacts = [ - makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x01, count: 32), name: "Person", type: .chat), - makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x02, count: 32), name: "Repeater", type: .repeater), - makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x03, count: 32), name: "Room", type: .room) - ] - let channels = [makeChannel(radioID: Self.radioA, index: 0, name: "Ops", secret: Data(repeating: 0x0A, count: 16))] - - let targets = buildMessageTargets(contacts: chatContacts(contacts), channels: channels) - - let contactNames = targets.filter { $0.kind == .contact }.map(\.displayName) - #expect(contactNames == ["Person"]) - #expect(targets.contains { $0.kind == .channel && $0.displayName == "Ops" }) - } - - @Test func pickerListExcludesDuplicateDigestChannels() { - // Two slots sharing a secret collide to one unresolvable id. Listing - // either would offer a target the send path refuses with invalidRecipient, - // so the picker must drop both and keep only the uniquely resolvable one. - let sharedSecret = Data(repeating: 0x0B, count: 16) - let channels = [ - makeChannel(radioID: Self.radioA, index: 0, name: "DupA", secret: sharedSecret), - makeChannel(radioID: Self.radioA, index: 1, name: "DupB", secret: sharedSecret), - makeChannel(radioID: Self.radioA, index: 2, name: "Unique", secret: Data(repeating: 0x0C, count: 16)) - ] - - let targets = buildMessageTargets(contacts: [], channels: channels) - - #expect(targets.filter { $0.kind == .channel }.map(\.displayName) == ["Unique"]) - } - - // MARK: - Scope fail-safe (nil radio yields nil scope) - - @Test func scopeIsNilWhenBridgeHasNoAppState() { - // A pre-launch bridge that was never adopted has a nil appState, so the - // scope resolves nil and every caller maps that to an empty result. - let bridge = IntentBridge() - #expect(currentRadioScope(bridge)?.radioID == nil) - } - - @Test func scopeIsNilWhenAppStateHasNoCurrentRadio() { - // A fresh AppState with no connection has a nil currentRadioID, so even an - // adopted bridge resolves no scope rather than reading the wrong radio. - let bridge = IntentBridge() - let appState = AppState() - bridge.adopt(appState) - - #expect(appState.currentRadioID == nil) - #expect(currentRadioScope(bridge)?.radioID == nil) - } + private static let radioA = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + private static let radioB = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + + private func makeStore() throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func makeContact(radioID: UUID, publicKey: Data, name: String, type: ContactType = .chat) -> ContactDTO { + ContactDTO( + id: UUID(), radioID: radioID, publicKey: publicKey, name: name, + typeRawValue: type.rawValue, flags: 0, outPathLength: 0, outPath: Data(), + lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0, + nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, + lastMessageDate: nil, unreadCount: 0 + ) + } + + private func makeChannel(radioID: UUID, index: UInt8, name: String, secret: Data) -> ChannelDTO { + ChannelDTO( + id: UUID(), radioID: radioID, index: index, name: name, secret: secret, + isEnabled: true, lastMessageDate: nil, unreadCount: 0 + ) + } + + @Test func `fetch returns only scoped radio rows`() async throws { + let store = try makeStore() + try await store.saveContact(makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0xA1, count: 32), name: "A-contact")) + try await store.saveContact(makeContact(radioID: Self.radioB, publicKey: Data(repeating: 0xB1, count: 32), name: "B-contact")) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "A-channel", secret: Data(repeating: 0xA2, count: 16))) + try await store.saveChannel(makeChannel(radioID: Self.radioB, index: 0, name: "B-channel", secret: Data(repeating: 0xB2, count: 16))) + + let contactsA = try await store.fetchContacts(radioID: Self.radioA) + let channelsA = try await store.fetchChannels(radioID: Self.radioA) + + #expect(contactsA.map(\.name) == ["A-contact"]) + #expect(channelsA.map(\.name) == ["A-channel"]) + #expect(contactsA.allSatisfy { $0.radioID == Self.radioA }) + #expect(channelsA.allSatisfy { $0.radioID == Self.radioA }) + } + + @Test func `fetch is empty for unseeded radio`() async throws { + // A `nil` current radio short-circuits to `[]` before any fetch; an + // unknown-but-non-nil radio likewise yields no rows. + let store = try makeStore() + try await store.saveContact(makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0xA1, count: 32), name: "A-contact")) + + let stranger = UUID() + #expect(try await store.fetchContacts(radioID: stranger).isEmpty) + #expect(try await store.fetchChannels(radioID: stranger).isEmpty) + } + + private func channelID(radioID: UUID, secret: Data) -> String { + formatCompositeID(radioID: radioID, kind: .channel, keyHex: channelSecretDigestHex(radioID: radioID, secret: secret)) + } + + @Test func `channel resolve fails safe on duplicate digest`() async throws { + let store = try makeStore() + let secret = Data(repeating: 0x09, count: 16) + // Two distinct rows sharing a secret collide to one digest id; the resolver + // must refuse both rather than mis-route a send to an arbitrary one. + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Dup0", secret: secret)) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Dup1", secret: secret)) + + let id = channelID(radioID: Self.radioA, secret: secret) + let resolved = try await resolveUniqueChannels(matching: [id], in: store.fetchChannels(radioID: Self.radioA)) + + #expect(resolved.isEmpty) + } + + @Test func `channel resolve fails safe on zero match`() async throws { + let store = try makeStore() + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Only", secret: Data(repeating: 0x01, count: 16))) + + let unknownID = channelID(radioID: Self.radioA, secret: Data(repeating: 0xFE, count: 16)) + let resolved = try await resolveUniqueChannels(matching: [unknownID], in: store.fetchChannels(radioID: Self.radioA)) + + #expect(resolved.isEmpty) + } + + @Test func `channel resolve accepts unique digest`() async throws { + let store = try makeStore() + let secret = Data(repeating: 0x33, count: 16) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Wanted", secret: secret)) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Other", secret: Data(repeating: 0x44, count: 16))) + + let id = channelID(radioID: Self.radioA, secret: secret) + let resolved = try await resolveUniqueChannels(matching: [id], in: store.fetchChannels(radioID: Self.radioA)) + + #expect(resolved.map { MessageTargetEntity(dto: $0).id } == [id]) + #expect(resolved.map(\.name) == ["Wanted"]) + } + + // MARK: - Merged resolution keeps the channel fail-safe + + @Test func `merged resolve excludes duplicate digest channel`() async throws { + // The same duplicate-digest fail-safe must survive inside the merged + // contact+channel resolution, not just the channel helper, so a future + // collapse-to-one-filter refactor of the query fails loudly here. + let store = try makeStore() + let secret = Data(repeating: 0x07, count: 16) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 0, name: "Dup0", secret: secret)) + try await store.saveChannel(makeChannel(radioID: Self.radioA, index: 1, name: "Dup1", secret: secret)) + + let id = channelID(radioID: Self.radioA, secret: secret) + let resolved = try await resolveMessageTargets( + matching: [id], + contacts: [], + channels: store.fetchChannels(radioID: Self.radioA) + ) + + #expect(resolved.isEmpty) + } + + // MARK: - Picker list is chat-only + + @Test func `picker list excludes repeater and room contacts`() { + let contacts = [ + makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x01, count: 32), name: "Person", type: .chat), + makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x02, count: 32), name: "Repeater", type: .repeater), + makeContact(radioID: Self.radioA, publicKey: Data(repeating: 0x03, count: 32), name: "Room", type: .room) + ] + let channels = [makeChannel(radioID: Self.radioA, index: 0, name: "Ops", secret: Data(repeating: 0x0A, count: 16))] + + let targets = buildMessageTargets(contacts: chatContacts(contacts), channels: channels) + + let contactNames = targets.filter { $0.kind == .contact }.map(\.displayName) + #expect(contactNames == ["Person"]) + #expect(targets.contains { $0.kind == .channel && $0.displayName == "Ops" }) + } + + @Test func `picker list excludes duplicate digest channels`() { + // Two slots sharing a secret collide to one unresolvable id. Listing + // either would offer a target the send path refuses with invalidRecipient, + // so the picker must drop both and keep only the uniquely resolvable one. + let sharedSecret = Data(repeating: 0x0B, count: 16) + let channels = [ + makeChannel(radioID: Self.radioA, index: 0, name: "DupA", secret: sharedSecret), + makeChannel(radioID: Self.radioA, index: 1, name: "DupB", secret: sharedSecret), + makeChannel(radioID: Self.radioA, index: 2, name: "Unique", secret: Data(repeating: 0x0C, count: 16)) + ] + + let targets = buildMessageTargets(contacts: [], channels: channels) + + #expect(targets.filter { $0.kind == .channel }.map(\.displayName) == ["Unique"]) + } + + // MARK: - Scope fail-safe (nil radio yields nil scope) + + @Test func `scope is nil when bridge has no app state`() { + // A pre-launch bridge that was never adopted has a nil appState, so the + // scope resolves nil and every caller maps that to an empty result. + let bridge = IntentBridge() + #expect(currentRadioScope(bridge)?.radioID == nil) + } + + @Test func `scope is nil when app state has no current radio`() { + // A fresh AppState with no connection has a nil currentRadioID, so even an + // adopted bridge resolves no scope rather than reading the wrong radio. + let bridge = IntentBridge() + let appState = AppState() + bridge.adopt(appState) + + #expect(appState.currentRadioID == nil) + #expect(currentRadioScope(bridge)?.radioID == nil) + } } diff --git a/MC1Tests/Intents/IntentBridgeTests.swift b/MC1Tests/Intents/IntentBridgeTests.swift index 14391cc7..d9695891 100644 --- a/MC1Tests/Intents/IntentBridgeTests.swift +++ b/MC1Tests/Intents/IntentBridgeTests.swift @@ -1,7 +1,7 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing /// `IntentBridge` is the stable holder that survives the before-first-unlock /// `AppState` swap in `MC1App`. These tests pin its adopt semantics so a future @@ -9,26 +9,25 @@ import Testing /// is caught: the bridge must always point at the most recently adopted state. @MainActor struct IntentBridgeTests { + @Test func `adopt stores the adopted app state`() { + let bridge = IntentBridge() + #expect(bridge.appState == nil) - @Test func adoptStoresTheAdoptedAppState() { - let bridge = IntentBridge() - #expect(bridge.appState == nil) - - let appState = AppState() - bridge.adopt(appState) + let appState = AppState() + bridge.adopt(appState) - #expect(bridge.appState === appState) - } + #expect(bridge.appState === appState) + } - @Test func reAdoptReplacesThePreviousAppState() { - let bridge = IntentBridge() - let first = AppState() - let second = AppState() + @Test func `re adopt replaces the previous app state`() { + let bridge = IntentBridge() + let first = AppState() + let second = AppState() - bridge.adopt(first) - bridge.adopt(second) + bridge.adopt(first) + bridge.adopt(second) - #expect(bridge.appState === second) - #expect(bridge.appState !== first) - } + #expect(bridge.appState === second) + #expect(bridge.appState !== first) + } } diff --git a/MC1Tests/Intents/IntentErrorLocalizationTests.swift b/MC1Tests/Intents/IntentErrorLocalizationTests.swift index beef2359..312b1ec6 100644 --- a/MC1Tests/Intents/IntentErrorLocalizationTests.swift +++ b/MC1Tests/Intents/IntentErrorLocalizationTests.swift @@ -1,95 +1,94 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing /// `IntentError.errorDescription` is the localization seam Siri and Shortcuts /// read, so it must route through `L10n` in every locale, never an English /// fallback. These tests pin every shipped case to its `L10n` key and confirm /// the raw key resolves to real copy in all 9 locales. struct IntentErrorLocalizationTests { + /// The localizable table backing the `error.intent.*` keys. + private static let table = "Localizable" - /// The localizable table backing the `error.intent.*` keys. - private static let table = "Localizable" - - /// Every `IntentError` case that carries its own L10n key, paired with the - /// raw `.strings` key and the generated accessor that must agree with it. - private static let casesWithKeys: [(error: IntentError, rawKey: String, generatedAccessor: String)] = [ - (.notConnected, "error.intent.notConnected", L10n.Localizable.Error.Intent.notConnected), - (.invalidRecipient, "error.intent.invalidRecipient", L10n.Localizable.Error.Intent.invalidRecipient), - (.messageTooLong, "error.intent.messageTooLong", L10n.Localizable.Error.Intent.messageTooLong), - (.sendFailed, "error.intent.sendFailed", L10n.Localizable.Error.Intent.sendFailed), - // `.advertFailed` reuses the existing advertisement-error copy rather than - // minting a duplicate intent-scoped key. - (.advertFailed, "error.advertisement.sendFailed", L10n.Localizable.Error.Advertisement.sendFailed), - ] + /// Every `IntentError` case that carries its own L10n key, paired with the + /// raw `.strings` key and the generated accessor that must agree with it. + private static let casesWithKeys: [(error: IntentError, rawKey: String, generatedAccessor: String)] = [ + (.notConnected, "error.intent.notConnected", L10n.Localizable.Error.Intent.notConnected), + (.invalidRecipient, "error.intent.invalidRecipient", L10n.Localizable.Error.Intent.invalidRecipient), + (.messageTooLong, "error.intent.messageTooLong", L10n.Localizable.Error.Intent.messageTooLong), + (.sendFailed, "error.intent.sendFailed", L10n.Localizable.Error.Intent.sendFailed), + // `.advertFailed` reuses the existing advertisement-error copy rather than + // minting a duplicate intent-scoped key. + (.advertFailed, "error.advertisement.sendFailed", L10n.Localizable.Error.Advertisement.sendFailed), + ] - /// The 9 shipped locales. A key missing from any one would fall back to the - /// raw key string at runtime, so each must resolve real copy. - private static let locales = ["de", "en", "es", "fr", "nl", "pl", "ru", "uk", "zh-Hans"] + /// The 9 shipped locales. A key missing from any one would fall back to the + /// raw key string at runtime, so each must resolve real copy. + private static let locales = ["de", "en", "es", "fr", "nl", "pl", "ru", "uk", "zh-Hans"] - // MARK: - Generated accessor agreement + // MARK: - Generated accessor agreement - @Test func errorDescriptionRoutesThroughL10nForEachCase() { - for entry in Self.casesWithKeys { - #expect(entry.error.errorDescription == entry.generatedAccessor) - } + @Test func `error description routes through L 10 n for each case`() { + for entry in Self.casesWithKeys { + #expect(entry.error.errorDescription == entry.generatedAccessor) } + } - // MARK: - sessionError recursion + // MARK: - sessionError recursion - @Test func sessionErrorRecursesIntoWrappedMeshCoreError() { - #expect( - IntentError.sessionError(.timeout).errorDescription - == L10n.Localizable.Error.MeshCore.timeout - ) - #expect( - IntentError.sessionError(.notConnected).errorDescription - == MeshCoreError.notConnected.userFacingMessage - ) - } + @Test func `session error recurses into wrapped mesh core error`() { + #expect( + IntentError.sessionError(.timeout).errorDescription + == L10n.Localizable.Error.MeshCore.timeout + ) + #expect( + IntentError.sessionError(.notConnected).errorDescription + == MeshCoreError.notConnected.userFacingMessage + ) + } - @Test func sessionErrorCarriesNoKeyOfItsOwn() { - // `.sessionError` must resolve to the wrapped error's copy, never the - // raw key, in every locale (the wrapped error owns the localization). - let resolved = IntentError.sessionError(.featureDisabled).errorDescription - #expect(resolved == MeshCoreError.featureDisabled.userFacingMessage) - #expect(resolved?.isEmpty == false) - } + @Test func `session error carries no key of its own`() { + // `.sessionError` must resolve to the wrapped error's copy, never the + // raw key, in every locale (the wrapped error owns the localization). + let resolved = IntentError.sessionError(.featureDisabled).errorDescription + #expect(resolved == MeshCoreError.featureDisabled.userFacingMessage) + #expect(resolved?.isEmpty == false) + } - // MARK: - No raw-key fallback across all 9 locales + // MARK: - No raw-key fallback across all 9 locales - @Test func everyIntentKeyResolvesInEveryLocale() throws { - for locale in Self.locales { - let bundle = try #require( - Self.localeBundle(locale), - "Missing \(locale).lproj in the app bundle" - ) - for entry in Self.casesWithKeys { - let resolved = bundle.localizedString( - forKey: entry.rawKey, - value: Self.sentinel, - table: Self.table - ) - #expect( - resolved != Self.sentinel && resolved != entry.rawKey, - "\(entry.rawKey) falls back to the raw key in \(locale).lproj" - ) - } - } + @Test func `every intent key resolves in every locale`() throws { + for locale in Self.locales { + let bundle = try #require( + Self.localeBundle(locale), + "Missing \(locale).lproj in the app bundle" + ) + for entry in Self.casesWithKeys { + let resolved = bundle.localizedString( + forKey: entry.rawKey, + value: Self.sentinel, + table: Self.table + ) + #expect( + resolved != Self.sentinel && resolved != entry.rawKey, + "\(entry.rawKey) falls back to the raw key in \(locale).lproj" + ) + } } + } - // MARK: - Helpers + // MARK: - Helpers - /// A value the bundle returns verbatim when the key is missing, so a - /// missing key is distinguishable from real (possibly key-shaped) copy. - private static let sentinel = "\u{0}__intent_key_missing__\u{0}" + /// A value the bundle returns verbatim when the key is missing, so a + /// missing key is distinguishable from real (possibly key-shaped) copy. + private static let sentinel = "\u{0}__intent_key_missing__\u{0}" - private static func localeBundle(_ locale: String) -> Bundle? { - // The test host is the app, so Bundle.main carries every .lproj. - guard let url = Bundle.main.url(forResource: locale, withExtension: "lproj") else { - return nil - } - return Bundle(url: url) + private static func localeBundle(_ locale: String) -> Bundle? { + // The test host is the app, so Bundle.main carries every .lproj. + guard let url = Bundle.main.url(forResource: locale, withExtension: "lproj") else { + return nil } + return Bundle(url: url) + } } diff --git a/MC1Tests/Intents/IntentMetadataLocalizationTests.swift b/MC1Tests/Intents/IntentMetadataLocalizationTests.swift index 1620f509..b25a5b33 100644 --- a/MC1Tests/Intents/IntentMetadataLocalizationTests.swift +++ b/MC1Tests/Intents/IntentMetadataLocalizationTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import MC1 +import Testing /// App Intents resolves every static metadata literal (`LocalizedStringResource` /// title, description, parameter title, short title, and `ParameterSummary`) @@ -9,96 +9,95 @@ import Testing /// tests pin every metadata key the intents reference and confirm each resolves /// to real copy in all 9 locales, never the raw-key fallback. struct IntentMetadataLocalizationTests { + /// The table backing every App Intents static metadata literal. + private static let table = "Tools" - /// The table backing every App Intents static metadata literal. - private static let table = "Tools" - - /// Every key passed to `LocalizedStringResource("...", table: "Tools")` by the - /// Intents sources. Each is a dotted identifier, so a value equal to the key - /// signals a missing translation. The `ParameterSummary` keys are checked - /// separately because their English value legitimately equals the key string. - private static let keys: [String] = [ - "intent.entity.target", - "intent.entity.channel", - "intent.status.title", - "intent.status.shortTitle", - "intent.status.description", - "intent.send.title", - "intent.send.shortTitle", - "intent.send.description", - "intent.send.param.target", - "intent.send.param.message", - "intent.advert.title", - "intent.advert.shortTitle", - "intent.advert.description", - "intent.advert.param.reach", - "intent.advert.reach.type", - "intent.advert.reach.zeroHop", - "intent.advert.reach.flood", - ] + /// Every key passed to `LocalizedStringResource("...", table: "Tools")` by the + /// Intents sources. Each is a dotted identifier, so a value equal to the key + /// signals a missing translation. The `ParameterSummary` keys are checked + /// separately because their English value legitimately equals the key string. + private static let keys: [String] = [ + "intent.entity.target", + "intent.entity.channel", + "intent.status.title", + "intent.status.shortTitle", + "intent.status.description", + "intent.send.title", + "intent.send.shortTitle", + "intent.send.description", + "intent.send.param.target", + "intent.send.param.message", + "intent.advert.title", + "intent.advert.shortTitle", + "intent.advert.description", + "intent.advert.param.reach", + "intent.advert.reach.type", + "intent.advert.reach.zeroHop", + "intent.advert.reach.flood", + ] - /// The 9 shipped locales. A key missing from any one falls back to the raw - /// key string at runtime, so each must resolve real copy. - private static let locales = ["de", "en", "es", "fr", "nl", "pl", "ru", "uk", "zh-Hans"] + /// The 9 shipped locales. A key missing from any one falls back to the raw + /// key string at runtime, so each must resolve real copy. + private static let locales = ["de", "en", "es", "fr", "nl", "pl", "ru", "uk", "zh-Hans"] - @Test func everyMetadataKeyResolvesInEveryLocale() throws { - for locale in Self.locales { - let bundle = try #require( - Self.localeBundle(locale), - "Missing \(locale).lproj in the app bundle" - ) - for key in Self.keys { - let resolved = bundle.localizedString( - forKey: key, - value: Self.sentinel, - table: Self.table - ) - #expect( - resolved != Self.sentinel && resolved != key, - "\(key) falls back to the raw key in \(locale).lproj" - ) - } - } + @Test func `every metadata key resolves in every locale`() throws { + for locale in Self.locales { + let bundle = try #require( + Self.localeBundle(locale), + "Missing \(locale).lproj in the app bundle" + ) + for key in Self.keys { + let resolved = bundle.localizedString( + forKey: key, + value: Self.sentinel, + table: Self.table + ) + #expect( + resolved != Self.sentinel && resolved != key, + "\(key) falls back to the raw key in \(locale).lproj" + ) + } } + } - /// The `ParameterSummary` format strings substitute `${message}` and the - /// recipient token at runtime, so every locale's translation must keep both - /// tokens or the displayed summary loses a value. This also proves the keys - /// are present in every locale: a missing key resolves to the sentinel, which - /// carries neither token, so the token assertion fails. - @Test func parameterSummaryKeysKeepTheirTokensInEveryLocale() throws { - let summaryTokens: [(key: String, tokens: [String])] = [ - ("Send ${message} to ${target}", ["${message}", "${target}"]), - ("Send a ${reach} advert", ["${reach}"]), - ] - for locale in Self.locales { - let bundle = try #require(Self.localeBundle(locale)) - for entry in summaryTokens { - let resolved = bundle.localizedString( - forKey: entry.key, - value: Self.sentinel, - table: Self.table - ) - for token in entry.tokens { - #expect( - resolved.contains(token), - "\(entry.key) drops \(token) in \(locale).lproj: \(resolved)" - ) - } - } + /// The `ParameterSummary` format strings substitute `${message}` and the + /// recipient token at runtime, so every locale's translation must keep both + /// tokens or the displayed summary loses a value. This also proves the keys + /// are present in every locale: a missing key resolves to the sentinel, which + /// carries neither token, so the token assertion fails. + @Test func `parameter summary keys keep their tokens in every locale`() throws { + let summaryTokens: [(key: String, tokens: [String])] = [ + ("Send ${message} to ${target}", ["${message}", "${target}"]), + ("Send a ${reach} advert", ["${reach}"]), + ] + for locale in Self.locales { + let bundle = try #require(Self.localeBundle(locale)) + for entry in summaryTokens { + let resolved = bundle.localizedString( + forKey: entry.key, + value: Self.sentinel, + table: Self.table + ) + for token in entry.tokens { + #expect( + resolved.contains(token), + "\(entry.key) drops \(token) in \(locale).lproj: \(resolved)" + ) } + } } + } - // MARK: - Helpers + // MARK: - Helpers - /// A value the bundle returns verbatim when the key is missing, so a missing - /// key is distinguishable from real (possibly key-shaped) copy. - private static let sentinel = "\u{0}__intent_metadata_key_missing__\u{0}" + /// A value the bundle returns verbatim when the key is missing, so a missing + /// key is distinguishable from real (possibly key-shaped) copy. + private static let sentinel = "\u{0}__intent_metadata_key_missing__\u{0}" - private static func localeBundle(_ locale: String) -> Bundle? { - guard let url = Bundle.main.url(forResource: locale, withExtension: "lproj") else { - return nil - } - return Bundle(url: url) + private static func localeBundle(_ locale: String) -> Bundle? { + guard let url = Bundle.main.url(forResource: locale, withExtension: "lproj") else { + return nil } + return Bundle(url: url) + } } diff --git a/MC1Tests/Intents/SendAdvertIntentTests.swift b/MC1Tests/Intents/SendAdvertIntentTests.swift index d5648aaf..5d0fa6d8 100644 --- a/MC1Tests/Intents/SendAdvertIntentTests.swift +++ b/MC1Tests/Intents/SendAdvertIntentTests.swift @@ -1,8 +1,8 @@ import Foundation -import MeshCore -import Testing @testable import MC1 @testable import MC1Services +import MeshCore +import Testing /// `SendAdvertIntent` broadcasts a self-advertisement. Its `perform()` is a /// `ProvidesDialog` flow that isn't unit-assertable, so coverage lands on the @@ -11,150 +11,149 @@ import Testing /// Siri/Shortcuts invocation and the connected send are verified on device. @MainActor struct SendAdvertIntentTests { - - // MARK: - Reach mapping - - @Test func reachMapsToFloodFlag() { - #expect(AdvertReach.zeroHop.sendsFlood == false) - #expect(AdvertReach.flood.sendsFlood == true) - } - - // These raw values are the persisted identifiers the Shortcuts framework - // stores in saved shortcuts; renaming one breaks every shortcut on disk. - @Test func reachRawValuesArePinnedShortcutIdentifiers() { - #expect(AdvertReach.zeroHop.rawValue == "zeroHop") - #expect(AdvertReach.flood.rawValue == "flood") + // MARK: - Reach mapping + + @Test func `reach maps to flood flag`() { + #expect(AdvertReach.zeroHop.sendsFlood == false) + #expect(AdvertReach.flood.sendsFlood == true) + } + + /// These raw values are the persisted identifiers the Shortcuts framework + /// stores in saved shortcuts; renaming one breaks every shortcut on disk. + @Test func `reach raw values are pinned shortcut identifiers`() { + #expect(AdvertReach.zeroHop.rawValue == "zeroHop") + #expect(AdvertReach.flood.rawValue == "flood") + } + + // MARK: - Spoken dialog + + @Test func `success dialog is reach specific and honest`() { + #expect(SendAdvertIntent.successDialog(for: .zeroHop) == L10n.Tools.Intent.Advert.Dialog.sentZeroHop) + #expect(SendAdvertIntent.successDialog(for: .flood) == L10n.Tools.Intent.Advert.Dialog.sentFlood) + // An advert has no delivery ACK, so the dialog must never claim delivery. + #expect(!SendAdvertIntent.successDialog(for: .zeroHop).lowercased().contains("delivered")) + #expect(!SendAdvertIntent.successDialog(for: .flood).lowercased().contains("delivered")) + } + + // MARK: - Error mapping + + @Test func `map to intent error routes every case`() { + #expect(Self.tag(SendAdvertIntent.mapToIntentError(.notConnected)) == "notConnected") + #expect(Self.tag(SendAdvertIntent.mapToIntentError(.sendFailed)) == "advertFailed") + #expect(Self.tag(SendAdvertIntent.mapToIntentError(.invalidResponse)) == "advertFailed") + + let mapped = SendAdvertIntent.mapToIntentError(.sessionError(.timeout)) + #expect(Self.tag(mapped) == "sessionError") + #expect(mapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) + } + + @Test func `general error mapping never leaks raw errors`() { + // An AdvertisementError routes through the typed, exhaustive mapper. + #expect(Self.tag(SendAdvertIntent.mapToIntentError(AdvertisementError.notConnected as Error)) == "notConnected") + // A MeshCoreError the service forwards becomes a localized session error. + #expect(Self.tag(SendAdvertIntent.mapToIntentError(MeshCoreError.timeout as Error)) == "sessionError") + // A raw transport error the service does not rewrap still maps, so Siri + // never speaks it verbatim. + #expect(Self.tag(SendAdvertIntent.mapToIntentError(Self.UnmappedError())) == "advertFailed") + // An already-localized IntentError passes through unchanged. + #expect(Self.tag(SendAdvertIntent.mapToIntentError(IntentError.messageTooLong as Error)) == "messageTooLong") + } + + private struct UnmappedError: Error {} + + // MARK: - GPS gate (privacy) + + @Test func `gps gate respects the per device preference`() throws { + let appState = AppState() + let suite = try #require(UserDefaults(suiteName: "test.advert.gate.\(UUID().uuidString)")) + let store = DevicePreferenceStore(userDefaults: suite) + let deviceID = UUID() + + // Disabled by default: no location is refreshed even with a permissive policy. + #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 1), store: store) == nil) + + // Enabled, but the device policy forbids location: still no refresh. + store.setAutoUpdateLocationEnabled(true, deviceID: deviceID) + #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 0), store: store) == nil) + + // Enabled and policy allows: the configured source is returned. + store.setGPSSource(.device, deviceID: deviceID) + #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 1), store: store) == .device) + } + + @Test func `gps gate is nil without A device`() throws { + let appState = AppState() + let store = try DevicePreferenceStore(userDefaults: #require(UserDefaults(suiteName: "test.advert.gate.\(UUID().uuidString)"))) + #expect(appState.advertGPSSource(device: nil, store: store) == nil) + } + + // MARK: - Disconnected guard + + @Test func `send self advert without services throws not connected`() async { + let appState = AppState() + do { + try await appState.sendSelfAdvert(flood: false) + Issue.record("expected sendSelfAdvert to throw while disconnected") + } catch let error as AdvertisementError { + guard case .notConnected = error else { + Issue.record("expected .notConnected, got \(error)") + return + } + } catch { + Issue.record("unexpected error: \(error)") } - - // MARK: - Spoken dialog - - @Test func successDialogIsReachSpecificAndHonest() { - #expect(SendAdvertIntent.successDialog(for: .zeroHop) == L10n.Tools.Intent.Advert.Dialog.sentZeroHop) - #expect(SendAdvertIntent.successDialog(for: .flood) == L10n.Tools.Intent.Advert.Dialog.sentFlood) - // An advert has no delivery ACK, so the dialog must never claim delivery. - #expect(!SendAdvertIntent.successDialog(for: .zeroHop).lowercased().contains("delivered")) - #expect(!SendAdvertIntent.successDialog(for: .flood).lowercased().contains("delivered")) - } - - // MARK: - Error mapping - - @Test func mapToIntentErrorRoutesEveryCase() { - #expect(Self.tag(SendAdvertIntent.mapToIntentError(.notConnected)) == "notConnected") - #expect(Self.tag(SendAdvertIntent.mapToIntentError(.sendFailed)) == "advertFailed") - #expect(Self.tag(SendAdvertIntent.mapToIntentError(.invalidResponse)) == "advertFailed") - - let mapped = SendAdvertIntent.mapToIntentError(.sessionError(.timeout)) - #expect(Self.tag(mapped) == "sessionError") - #expect(mapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) - } - - @Test func generalErrorMappingNeverLeaksRawErrors() { - // An AdvertisementError routes through the typed, exhaustive mapper. - #expect(Self.tag(SendAdvertIntent.mapToIntentError(AdvertisementError.notConnected as Error)) == "notConnected") - // A MeshCoreError the service forwards becomes a localized session error. - #expect(Self.tag(SendAdvertIntent.mapToIntentError(MeshCoreError.timeout as Error)) == "sessionError") - // A raw transport error the service does not rewrap still maps, so Siri - // never speaks it verbatim. - #expect(Self.tag(SendAdvertIntent.mapToIntentError(Self.UnmappedError())) == "advertFailed") - // An already-localized IntentError passes through unchanged. - #expect(Self.tag(SendAdvertIntent.mapToIntentError(IntentError.messageTooLong as Error)) == "messageTooLong") - } - - private struct UnmappedError: Error {} - - // MARK: - GPS gate (privacy) - - @Test func gpsGateRespectsThePerDevicePreference() { - let appState = AppState() - let suite = UserDefaults(suiteName: "test.advert.gate.\(UUID().uuidString)")! - let store = DevicePreferenceStore(userDefaults: suite) - let deviceID = UUID() - - // Disabled by default: no location is refreshed even with a permissive policy. - #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 1), store: store) == nil) - - // Enabled, but the device policy forbids location: still no refresh. - store.setAutoUpdateLocationEnabled(true, deviceID: deviceID) - #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 0), store: store) == nil) - - // Enabled and policy allows: the configured source is returned. - store.setGPSSource(.device, deviceID: deviceID) - #expect(appState.advertGPSSource(device: Self.makeDevice(id: deviceID, advertLocationPolicy: 1), store: store) == .device) - } - - @Test func gpsGateIsNilWithoutADevice() { - let appState = AppState() - let store = DevicePreferenceStore(userDefaults: UserDefaults(suiteName: "test.advert.gate.\(UUID().uuidString)")!) - #expect(appState.advertGPSSource(device: nil, store: store) == nil) - } - - // MARK: - Disconnected guard - - @Test func sendSelfAdvertWithoutServicesThrowsNotConnected() async { - let appState = AppState() - do { - try await appState.sendSelfAdvert(flood: false) - Issue.record("expected sendSelfAdvert to throw while disconnected") - } catch let error as AdvertisementError { - guard case .notConnected = error else { - Issue.record("expected .notConnected, got \(error)") - return - } - } catch { - Issue.record("unexpected error: \(error)") - } - } - - // MARK: - Helpers - - private static func tag(_ error: IntentError) -> String { - switch error { - case .notConnected: "notConnected" - case .invalidRecipient: "invalidRecipient" - case .messageTooLong: "messageTooLong" - case .sendFailed: "sendFailed" - case .advertFailed: "advertFailed" - case .sessionError: "sessionError" - } - } - - private static func makeDevice(id: UUID, advertLocationPolicy: UInt8) -> DeviceDTO { - DeviceDTO( - id: id, - radioID: UUID(), - publicKey: Data(repeating: 0x01, count: 32), - nodeName: "Test", - firmwareVersion: 8, - firmwareVersionString: "1.10", - manufacturerName: "Test", - buildDate: "", - maxContacts: 100, - maxChannels: 16, - frequency: 0, - bandwidth: 0, - spreadingFactor: 0, - codingRate: 0, - txPower: 0, - maxTxPower: 0, - latitude: 0, - longitude: 0, - blePin: 0, - clientRepeat: false, - pathHashMode: 0, - manualAddContacts: false, - autoAddConfig: 0, - autoAddMaxHops: 0, - multiAcks: 0, - telemetryModeBase: 0, - telemetryModeLoc: 0, - telemetryModeEnv: 0, - advertLocationPolicy: advertLocationPolicy, - lastConnected: Date(), - lastContactSync: 0, - isActive: true, - ocvPreset: nil, - customOCVArrayString: nil, - connectionMethods: [] - ) + } + + // MARK: - Helpers + + private static func tag(_ error: IntentError) -> String { + switch error { + case .notConnected: "notConnected" + case .invalidRecipient: "invalidRecipient" + case .messageTooLong: "messageTooLong" + case .sendFailed: "sendFailed" + case .advertFailed: "advertFailed" + case .sessionError: "sessionError" } + } + + private static func makeDevice(id: UUID, advertLocationPolicy: UInt8) -> DeviceDTO { + DeviceDTO( + id: id, + radioID: UUID(), + publicKey: Data(repeating: 0x01, count: 32), + nodeName: "Test", + firmwareVersion: 8, + firmwareVersionString: "1.10", + manufacturerName: "Test", + buildDate: "", + maxContacts: 100, + maxChannels: 16, + frequency: 0, + bandwidth: 0, + spreadingFactor: 0, + codingRate: 0, + txPower: 0, + maxTxPower: 0, + latitude: 0, + longitude: 0, + blePin: 0, + clientRepeat: false, + pathHashMode: 0, + manualAddContacts: false, + autoAddConfig: 0, + autoAddMaxHops: 0, + multiAcks: 0, + telemetryModeBase: 0, + telemetryModeLoc: 0, + telemetryModeEnv: 0, + advertLocationPolicy: advertLocationPolicy, + lastConnected: Date(), + lastContactSync: 0, + isActive: true, + ocvPreset: nil, + customOCVArrayString: nil, + connectionMethods: [] + ) + } } diff --git a/MC1Tests/Intents/SendMessageIntentTests.swift b/MC1Tests/Intents/SendMessageIntentTests.swift index 99e4d282..3532d986 100644 --- a/MC1Tests/Intents/SendMessageIntentTests.swift +++ b/MC1Tests/Intents/SendMessageIntentTests.swift @@ -1,8 +1,8 @@ import Foundation -import MeshCore -import Testing @testable import MC1 @testable import MC1Services +import MeshCore +import Testing /// `SendMessageIntent` is the safety-critical send: a message reported as sent /// that wasn't is a safety lie. These tests pin the routing decision, the @@ -13,431 +13,430 @@ import Testing /// framework-driven confirmation and foreground handoff are verified on device. @MainActor struct SendMessageIntentTests { - - private static let radioID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")! - - // MARK: - Fixtures - - private static func makeContact( - radioID: UUID = radioID, - type: ContactType = .chat, - name: String = "Alice" - ) -> ContactDTO { - ContactDTO( - id: UUID(), radioID: radioID, publicKey: Data(repeating: 0xC1, count: 32), name: name, - typeRawValue: type.rawValue, flags: 0, outPathLength: 0, outPath: Data(), - lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0, - nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, - lastMessageDate: nil, unreadCount: 0 - ) - } - - private static func makeChannel(radioID: UUID = radioID, name: String = "Ops") -> ChannelDTO { - ChannelDTO( - id: UUID(), radioID: radioID, index: 0, name: name, - secret: Data(repeating: 0x5A, count: 16), isEnabled: true, - lastMessageDate: nil, unreadCount: 0 - ) - } - - private static func makeDevice(radioID: UUID = radioID, name: String = "Base Camp") -> DeviceDTO { - DeviceDTO( - id: UUID(), radioID: radioID, publicKey: Data(repeating: 0x01, count: 32), - nodeName: name, firmwareVersion: 8, firmwareVersionString: "1.10", - manufacturerName: "Test", buildDate: "", maxContacts: 100, maxChannels: 16, - frequency: 0, bandwidth: 0, spreadingFactor: 0, codingRate: 0, txPower: 0, - maxTxPower: 0, latitude: 0, longitude: 0, blePin: 0, clientRepeat: false, - pathHashMode: 0, manualAddContacts: false, autoAddConfig: 0, autoAddMaxHops: 0, - multiAcks: 0, telemetryModeBase: 0, telemetryModeLoc: 0, telemetryModeEnv: 0, - advertLocationPolicy: 0, lastConnected: Date(), lastContactSync: 0, isActive: true, - ocvPreset: nil, customOCVArrayString: nil, connectionMethods: [] - ) - } - - /// Seeds an `AppState` with a real `ServiceContainer` over an in-memory store - /// scoped to `radioID`, at the given rung. Returns the live store so a test - /// can assert the durable `PendingSend` write. - private func seedReady( - _ appState: AppState, - state: DeviceConnectionState - ) throws -> ServiceContainer { - let container = try PersistenceStore.createContainer(inMemory: true) - let services = ServiceContainer( - session: MeshCoreSession(transport: MockTransport()), - modelContainer: container, - radioID: Self.radioID - ) - appState.connectionManager.setTestState( - connectionState: state, - services: services, - connectedDevice: Self.makeDevice() - ) - return services - } - - // MARK: - Routing (pure) - - @Test func readyRoutesToHeadlessQueue() { - #expect(SendMessageIntent.route(for: .ready) == .headlessQueue) - } - - @Test func syncingRoutesToQueueAfterSync() { - #expect(SendMessageIntent.route(for: .syncing) == .queueAfterSync) - } - - @Test(arguments: [DeviceConnectionState.connected, .connecting]) - func transientRoutesToForegroundEscalation(_ state: DeviceConnectionState) { - #expect(SendMessageIntent.route(for: state) == .foregroundEscalate) - } - - @Test func disconnectedRoutesToNotConnected() { - #expect(SendMessageIntent.route(for: .disconnected) == .notConnected) - } - - @Test func restorableRadioForegroundEscalatesNeverThrows() { - // A last-connected radio is restorable, so the send hands off to the - // foregrounded app rather than failing. - #expect(SendMessageIntent.disconnectedRoute(hasRestorableRadio: true) == .foregroundEscalate) - } - - @Test func neverConnectedSurfacesNotConnected() { - // No prior radio: retrying alone cannot help, so the send throws. - #expect(SendMessageIntent.disconnectedRoute(hasRestorableRadio: false) == .notConnected) - } - - // MARK: - Validation - - @Test func repeaterDMRejected() throws { - let repeater = Self.makeContact(type: .repeater) - let thrown = #expect(throws: IntentError.self) { - try SendMessageIntent.validate(message: "hi", for: .contact(repeater), nodeNameByteCount: 0) - } - #expect(isCase(try #require(thrown), .invalidRecipient)) - } - - @Test func roomDMRejected() throws { - let room = Self.makeContact(type: .room) - let thrown = #expect(throws: IntentError.self) { - try SendMessageIntent.validate(message: "hi", for: .contact(room), nodeNameByteCount: 0) - } - #expect(isCase(try #require(thrown), .invalidRecipient)) - } - - @Test func chatDMAccepted() throws { - try SendMessageIntent.validate(message: "hi", for: .contact(Self.makeContact()), nodeNameByteCount: 0) - } - - @Test func overlongChannelMessageRejected() throws { - let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0) - let tooLong = String(repeating: "x", count: maxBytes + 1) - let thrown = #expect(throws: IntentError.self) { - try SendMessageIntent.validate(message: tooLong, for: .channel(Self.makeChannel()), nodeNameByteCount: 0) - } - #expect(isCase(try #require(thrown), .messageTooLong)) - } - - @Test func channelMessageAtLimitAccepted() throws { - let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0) - let atLimit = String(repeating: "x", count: maxBytes) - try SendMessageIntent.validate(message: atLimit, for: .channel(Self.makeChannel()), nodeNameByteCount: 0) + private static let radioID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")! + + // MARK: - Fixtures + + private static func makeContact( + radioID: UUID = radioID, + type: ContactType = .chat, + name: String = "Alice" + ) -> ContactDTO { + ContactDTO( + id: UUID(), radioID: radioID, publicKey: Data(repeating: 0xC1, count: 32), name: name, + typeRawValue: type.rawValue, flags: 0, outPathLength: 0, outPath: Data(), + lastAdvertTimestamp: 0, latitude: 0, longitude: 0, lastModified: 0, + nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, + lastMessageDate: nil, unreadCount: 0 + ) + } + + private static func makeChannel(radioID: UUID = radioID, name: String = "Ops") -> ChannelDTO { + ChannelDTO( + id: UUID(), radioID: radioID, index: 0, name: name, + secret: Data(repeating: 0x5A, count: 16), isEnabled: true, + lastMessageDate: nil, unreadCount: 0 + ) + } + + private static func makeDevice(radioID: UUID = radioID, name: String = "Base Camp") -> DeviceDTO { + DeviceDTO( + id: UUID(), radioID: radioID, publicKey: Data(repeating: 0x01, count: 32), + nodeName: name, firmwareVersion: 8, firmwareVersionString: "1.10", + manufacturerName: "Test", buildDate: "", maxContacts: 100, maxChannels: 16, + frequency: 0, bandwidth: 0, spreadingFactor: 0, codingRate: 0, txPower: 0, + maxTxPower: 0, latitude: 0, longitude: 0, blePin: 0, clientRepeat: false, + pathHashMode: 0, manualAddContacts: false, autoAddConfig: 0, autoAddMaxHops: 0, + multiAcks: 0, telemetryModeBase: 0, telemetryModeLoc: 0, telemetryModeEnv: 0, + advertLocationPolicy: 0, lastConnected: Date(), lastContactSync: 0, isActive: true, + ocvPreset: nil, customOCVArrayString: nil, connectionMethods: [] + ) + } + + /// Seeds an `AppState` with a real `ServiceContainer` over an in-memory store + /// scoped to `radioID`, at the given rung. Returns the live store so a test + /// can assert the durable `PendingSend` write. + private func seedReady( + _ appState: AppState, + state: DeviceConnectionState + ) throws -> ServiceContainer { + let container = try PersistenceStore.createContainer(inMemory: true) + let services = ServiceContainer( + session: MeshCoreSession(transport: MockTransport()), + modelContainer: container, + radioID: Self.radioID + ) + appState.connectionManager.setTestState( + connectionState: state, + services: services, + connectedDevice: Self.makeDevice() + ) + return services + } + + // MARK: - Routing (pure) + + @Test func `ready routes to headless queue`() { + #expect(SendMessageIntent.route(for: .ready) == .headlessQueue) + } + + @Test func `syncing routes to queue after sync`() { + #expect(SendMessageIntent.route(for: .syncing) == .queueAfterSync) + } + + @Test(arguments: [DeviceConnectionState.connected, .connecting]) + func `transient routes to foreground escalation`(_ state: DeviceConnectionState) { + #expect(SendMessageIntent.route(for: state) == .foregroundEscalate) + } + + @Test func `disconnected routes to not connected`() { + #expect(SendMessageIntent.route(for: .disconnected) == .notConnected) + } + + @Test func `restorable radio foreground escalates never throws`() { + // A last-connected radio is restorable, so the send hands off to the + // foregrounded app rather than failing. + #expect(SendMessageIntent.disconnectedRoute(hasRestorableRadio: true) == .foregroundEscalate) + } + + @Test func `never connected surfaces not connected`() { + // No prior radio: retrying alone cannot help, so the send throws. + #expect(SendMessageIntent.disconnectedRoute(hasRestorableRadio: false) == .notConnected) + } + + // MARK: - Validation + + @Test func `repeater DM rejected`() throws { + let repeater = Self.makeContact(type: .repeater) + let thrown = #expect(throws: IntentError.self) { + try SendMessageIntent.validate(message: "hi", for: .contact(repeater), nodeNameByteCount: 0) } + #expect(try isCase(#require(thrown), .invalidRecipient)) + } - @Test func channelMessageFittingTotalButExceedingNodeNameBudgetRejected() throws { - // The firmware prepends ": " to channel broadcasts, so the - // usable text is the total length minus the node name and separator. A - // message that fits the 147-byte total but not the adjusted budget would - // be silently truncated on the air, so the intent must reject it. - let nodeName = "Base Camp" - let nodeNameByteCount = nodeName.utf8.count - let adjustedMax = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: nodeNameByteCount) - let message = String(repeating: "x", count: adjustedMax + 1) - #expect(message.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength) - - let thrown = #expect(throws: IntentError.self) { - try SendMessageIntent.validate( - message: message, for: .channel(Self.makeChannel()), nodeNameByteCount: nodeNameByteCount - ) - } - #expect(isCase(try #require(thrown), .messageTooLong)) + @Test func `room DM rejected`() throws { + let room = Self.makeContact(type: .room) + let thrown = #expect(throws: IntentError.self) { + try SendMessageIntent.validate(message: "hi", for: .contact(room), nodeNameByteCount: 0) } - - // MARK: - Error rewrap - - /// `IntentError` carries a non-Equatable `MeshCoreError`, so cases are matched - /// structurally; the surfaced `errorDescription` (what Siri speaks) pins the - /// localized mapping. - private func isCase(_ error: IntentError, _ expected: IntentError) -> Bool { - switch (error, expected) { - case (.notConnected, .notConnected), - (.invalidRecipient, .invalidRecipient), (.messageTooLong, .messageTooLong), - (.sendFailed, .sendFailed), (.advertFailed, .advertFailed), - (.sessionError, .sessionError): - return true - default: - return false - } - } - - @Test func serviceErrorsRewrapToLocalizedIntentError() { - #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.notConnected), .notConnected)) - #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.messageTooLong), .messageTooLong)) - #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.invalidRecipient), .invalidRecipient)) - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.channelNotFound), .invalidRecipient)) - - // A wrapped session error preserves its underlying MeshCoreError, and the - // surfaced description routes through L10n (never a raw service string). - let wrapped = SendMessageIntent.mapToIntentError(MessageServiceError.sessionError(.timeout)) - #expect(isCase(wrapped, .sessionError(.timeout))) - #expect(wrapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) - } - - /// Pins the full `ChannelServiceError` bucketing in `mapToIntentError`: a - /// bad index reads as an invalid recipient, every local-failure case reads as - /// a send failure (never a disconnect), and a wrapped session error preserves - /// its underlying `MeshCoreError`. - @Test func channelServiceErrorsBucketIntoIntentErrors() { - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.notConnected), .notConnected)) - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.invalidChannelIndex), .invalidRecipient)) - - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.secretHashingFailed), .sendFailed)) - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.sendFailed("io")), .sendFailed)) - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.syncAlreadyInProgress), .sendFailed)) - #expect( - isCase( - SendMessageIntent.mapToIntentError(ChannelServiceError.circuitBreakerOpen(consecutiveFailures: 3)), - .sendFailed - ) - ) - - let wrapped = SendMessageIntent.mapToIntentError(ChannelServiceError.sessionError(.timeout)) - #expect(isCase(wrapped, .sessionError(.timeout))) - #expect(wrapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) - } - - /// A failure to persist or enqueue a connected send must speak as a send - /// failure, never "not connected"; the radio is up, the local write failed. - @Test func sendWriteFailuresMapToSendFailedNotNotConnected() { - let queuePersistFailed = ChatSendQueueServiceError.persistFailed(underlying: MeshCoreError.timeout) - #expect(isCase(SendMessageIntent.mapToIntentError(queuePersistFailed), .sendFailed)) - #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.sendFailed("io")), .sendFailed)) - #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.saveFailed("io")), .sendFailed)) - - // The spoken line routes through L10n, not a raw error string. - #expect( - SendMessageIntent.mapToIntentError(queuePersistFailed).errorDescription - == L10n.Localizable.Error.Intent.sendFailed - ) - - // A genuine not-connected from the queue still reads as not connected. - #expect(isCase(SendMessageIntent.mapToIntentError(ChatSendQueueServiceError.notConnected), .notConnected)) - - // An unrecognized error during a send is a send failure, not a disconnect. - let unknown = NSError(domain: "test", code: 1) - #expect(isCase(SendMessageIntent.mapToIntentError(unknown), .sendFailed)) - } - - // MARK: - .ready DM enqueue (durable write) - - @Test func readyDMEnqueuesPendingSend() async throws { - let appState = AppState() - let services = try seedReady(appState, state: .ready) - let contact = Self.makeContact(name: "Alice") - try await services.dataStore.saveContact(contact) - - let outcome = try await SendMessageIntent.performSend( - message: "on my way", recipient: .contact(contact), in: appState - ) - #expect(outcome == .queued) - - // The durable PendingSend row is what survives a drop and drains at .ready. - let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) - #expect(pending.count == 1) + #expect(try isCase(#require(thrown), .invalidRecipient)) + } + + @Test func `chat DM accepted`() throws { + try SendMessageIntent.validate(message: "hi", for: .contact(Self.makeContact()), nodeNameByteCount: 0) + } + + @Test func `overlong channel message rejected`() throws { + let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0) + let tooLong = String(repeating: "x", count: maxBytes + 1) + let thrown = #expect(throws: IntentError.self) { + try SendMessageIntent.validate(message: tooLong, for: .channel(Self.makeChannel()), nodeNameByteCount: 0) } - - // MARK: - Conversation list refresh - - @Test func readySendBumpsConversationsVersionSoTheListRefreshes() async throws { - // A headless send writes the message row but, unlike an in-app send, never - // crosses the chat view model. Without an explicit refresh the Chats list - // keeps its cached snapshot, so the preview stays stale until an unrelated - // reload fires. The send must announce the change itself. - let appState = AppState() - let services = try seedReady(appState, state: .ready) - let contact = Self.makeContact(name: "Alice") - try await services.dataStore.saveContact(contact) - let before = appState.conversationsVersion - - let outcome = try await SendMessageIntent.performSend( - message: "on my way", recipient: .contact(contact), in: appState - ) - - #expect(outcome == .queued) - #expect(appState.conversationsVersion > before) + #expect(try isCase(#require(thrown), .messageTooLong)) + } + + @Test func `channel message at limit accepted`() throws { + let maxBytes = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0) + let atLimit = String(repeating: "x", count: maxBytes) + try SendMessageIntent.validate(message: atLimit, for: .channel(Self.makeChannel()), nodeNameByteCount: 0) + } + + @Test func `channel message fitting total but exceeding node name budget rejected`() throws { + // The firmware prepends ": " to channel broadcasts, so the + // usable text is the total length minus the node name and separator. A + // message that fits the 147-byte total but not the adjusted budget would + // be silently truncated on the air, so the intent must reject it. + let nodeName = "Base Camp" + let nodeNameByteCount = nodeName.utf8.count + let adjustedMax = ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: nodeNameByteCount) + let message = String(repeating: "x", count: adjustedMax + 1) + #expect(message.utf8.count <= ProtocolLimits.maxChannelMessageTotalLength) + + let thrown = #expect(throws: IntentError.self) { + try SendMessageIntent.validate( + message: message, for: .channel(Self.makeChannel()), nodeNameByteCount: nodeNameByteCount + ) } - - @Test func mustForegroundDoesNotBumpConversationsVersion() async throws { - // A nil-services re-read returns `.mustForeground` before any write, so - // there is nothing to announce and the list must not be reloaded. - let appState = AppState() - let before = appState.conversationsVersion - - let outcome = try await SendMessageIntent.performSend( - message: "on my way", recipient: .contact(Self.makeContact()), in: appState - ) - - #expect(outcome == .mustForeground) - #expect(appState.conversationsVersion == before) + #expect(try isCase(#require(thrown), .messageTooLong)) + } + + // MARK: - Error rewrap + + /// `IntentError` carries a non-Equatable `MeshCoreError`, so cases are matched + /// structurally; the surfaced `errorDescription` (what Siri speaks) pins the + /// localized mapping. + private func isCase(_ error: IntentError, _ expected: IntentError) -> Bool { + switch (error, expected) { + case (.notConnected, .notConnected), + (.invalidRecipient, .invalidRecipient), (.messageTooLong, .messageTooLong), + (.sendFailed, .sendFailed), (.advertFailed, .advertFailed), + (.sessionError, .sessionError): + true + default: + false } - - // MARK: - .ready channel enqueue (durable write) - - @Test func readyChannelEnqueuesPendingSend() async throws { - let appState = AppState() - let services = try seedReady(appState, state: .ready) - let channel = Self.makeChannel(name: "Ops") - try await services.dataStore.saveChannel(channel) - - let outcome = try await SendMessageIntent.performSend( - message: "radio check", recipient: .channel(channel), in: appState - ) - #expect(outcome == .queued) - - let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) - #expect(pending.count == 1) - } - - // MARK: - channel length re-validated against the live node name at send time - - @Test func channelMessageRevalidatedAgainstLiveNodeNameAtSendTime() async throws { - // A message sized to the zero-node-name budget clears the pre-confirmation - // validate, but the live radio ("Base Camp") shrinks the on-air budget by - // the prepended ": ". performSend must re-validate and reject it - // rather than enqueue a message the firmware would silently truncate. - let appState = AppState() - let services = try seedReady(appState, state: .ready) - let channel = Self.makeChannel(name: "Ops") - try await services.dataStore.saveChannel(channel) - - let overBudget = String(repeating: "x", count: ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0)) - - let thrown = await #expect(throws: IntentError.self) { - try await SendMessageIntent.performSend( - message: overBudget, recipient: .channel(channel), in: appState - ) - } - #expect(isCase(try #require(thrown), .messageTooLong)) - - // The reject precedes the durable write, so nothing is enqueued. - let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) - #expect(pending.isEmpty) - } - - // MARK: - enqueue-failure orphan recovery primitive - - /// `performSend`'s catch marks a persisted-but-unenqueued row `.failed` via - /// `updateMessageStatusUnlessDelivered` so an enqueue-write failure surfaces a - /// retry instead of hanging `.pending`. Forcing the enqueue itself to throw is - /// not reachable through any existing test seam (the queue persists through the - /// concrete in-memory `PersistenceStore`, which does not fail), so this pins - /// the recovery primitive on a real row built by production `createPendingMessage`: - /// a `.pending` row flips to `.failed`, while a `.delivered` row is left intact - /// so the recovery can never downgrade a delivered send. - @Test func updateMessageStatusUnlessDeliveredFailsPendingButSparesDelivered() async throws { - let appState = AppState() - let services = try seedReady(appState, state: .ready) - let contact = Self.makeContact() - try await services.dataStore.saveContact(contact) - - let pending = try await services.messageService.createPendingMessage(text: "queued", to: contact) - #expect(try await services.dataStore.fetchMessage(id: pending.id)?.status == .pending) - - let flipped = try await services.dataStore.updateMessageStatusUnlessDelivered(id: pending.id, status: .failed) - #expect(flipped) - #expect(try await services.dataStore.fetchMessage(id: pending.id)?.status == .failed) - - let delivered = try await services.messageService.createPendingMessage(text: "acked", to: contact) - try await services.dataStore.updateMessageAck(id: delivered.id, ackCode: 0x1234_5678, status: .delivered) - - let spared = try await services.dataStore.updateMessageStatusUnlessDelivered(id: delivered.id, status: .failed) - #expect(!spared) - #expect(try await services.dataStore.fetchMessage(id: delivered.id)?.status == .delivered) - } - - // MARK: - .syncing takes the queue - - @Test func syncingEnqueuesPendingSend() async throws { - let appState = AppState() - let services = try seedReady(appState, state: .syncing) - let contact = Self.makeContact(name: "Bravo") - try await services.dataStore.saveContact(contact) - - // The .syncing rung classifies as .queueAfterSync, which shares the queue - // branch with .ready: the durable row is written either way. - #expect(SendMessageIntent.route(for: .syncing) == .queueAfterSync) - let outcome = try await SendMessageIntent.performSend( - message: "staging", recipient: .contact(contact), in: appState - ) - #expect(outcome == .queued) - - let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) - #expect(pending.count == 1) - } - - // MARK: - services-nil during confirmation re-routes to foreground (no fabricated queued) - - @Test func servicesNilAfterClassifyRoutesToForegroundNotASilentEnqueue() async throws { - let appState = AppState() - // Classified .ready with a live service, then the radio drops during the - // confirmation await: services go nil. performSend must report - // .mustForeground, never a "queued" for a send that never enqueued. - _ = try seedReady(appState, state: .ready) - let contact = Self.makeContact() - appState.connectionManager.setTestState(services: .some(nil)) - - let outcome = try await SendMessageIntent.performSend( - message: "dropped", recipient: .contact(contact), in: appState - ) - #expect(outcome == .mustForeground) - } - - // MARK: - radio switch during confirmation re-routes to foreground (no cross-radio enqueue) - - @Test func recipientFromAnotherRadioRoutesToForegroundNotACrossRadioEnqueue() async throws { - let appState = AppState() - // Live radio is B (the seeded service and connected device). The recipient - // was resolved against radio A before the confirmation await, so enqueuing - // would scope the message row to A and the PendingSend to B, mis-routing - // the send. performSend must report .mustForeground and write nothing. - let services = try seedReady(appState, state: .ready) - let otherRadioID = UUID(uuidString: "55555555-5555-5555-5555-555555555555")! - let contact = Self.makeContact(radioID: otherRadioID) - - let outcome = try await SendMessageIntent.performSend( - message: "switched radios", recipient: .contact(contact), in: appState - ) - #expect(outcome == .mustForeground) - - let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) - #expect(pending.isEmpty) - let otherPending = try await services.dataStore.fetchPendingSends(radioID: otherRadioID) - #expect(otherPending.isEmpty) - } - - // MARK: - bare .connected with no services never silently enqueues - - @Test func connectedWithoutServicesNeverEnqueues() async throws { - let appState = AppState() - // Bare .connected: the rung is set before services are built, so services - // can be nil. The route classifies .foregroundEscalate (never the queue), - // and even if performSend were reached it returns .mustForeground. - appState.connectionManager.setTestState( - connectionState: .connected, services: .some(nil), connectedDevice: Self.makeDevice() - ) - #expect(SendMessageIntent.route(for: .connected) == .foregroundEscalate) - - let outcome = try await SendMessageIntent.performSend( - message: "nope", recipient: .contact(Self.makeContact()), in: appState - ) - #expect(outcome == .mustForeground) + } + + @Test func `service errors rewrap to localized intent error`() { + #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.notConnected), .notConnected)) + #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.messageTooLong), .messageTooLong)) + #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.invalidRecipient), .invalidRecipient)) + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.channelNotFound), .invalidRecipient)) + + // A wrapped session error preserves its underlying MeshCoreError, and the + // surfaced description routes through L10n (never a raw service string). + let wrapped = SendMessageIntent.mapToIntentError(MessageServiceError.sessionError(.timeout)) + #expect(isCase(wrapped, .sessionError(.timeout))) + #expect(wrapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) + } + + /// Pins the full `ChannelServiceError` bucketing in `mapToIntentError`: a + /// bad index reads as an invalid recipient, every local-failure case reads as + /// a send failure (never a disconnect), and a wrapped session error preserves + /// its underlying `MeshCoreError`. + @Test func `channel service errors bucket into intent errors`() { + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.notConnected), .notConnected)) + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.invalidChannelIndex), .invalidRecipient)) + + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.secretHashingFailed), .sendFailed)) + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.sendFailed("io")), .sendFailed)) + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.syncAlreadyInProgress), .sendFailed)) + #expect( + isCase( + SendMessageIntent.mapToIntentError(ChannelServiceError.circuitBreakerOpen(consecutiveFailures: 3)), + .sendFailed + ) + ) + + let wrapped = SendMessageIntent.mapToIntentError(ChannelServiceError.sessionError(.timeout)) + #expect(isCase(wrapped, .sessionError(.timeout))) + #expect(wrapped.errorDescription == L10n.Localizable.Error.MeshCore.timeout) + } + + /// A failure to persist or enqueue a connected send must speak as a send + /// failure, never "not connected"; the radio is up, the local write failed. + @Test func `send write failures map to send failed not not connected`() { + let queuePersistFailed = ChatSendQueueServiceError.persistFailed(underlying: MeshCoreError.timeout) + #expect(isCase(SendMessageIntent.mapToIntentError(queuePersistFailed), .sendFailed)) + #expect(isCase(SendMessageIntent.mapToIntentError(MessageServiceError.sendFailed("io")), .sendFailed)) + #expect(isCase(SendMessageIntent.mapToIntentError(ChannelServiceError.saveFailed("io")), .sendFailed)) + + // The spoken line routes through L10n, not a raw error string. + #expect( + SendMessageIntent.mapToIntentError(queuePersistFailed).errorDescription + == L10n.Localizable.Error.Intent.sendFailed + ) + + // A genuine not-connected from the queue still reads as not connected. + #expect(isCase(SendMessageIntent.mapToIntentError(ChatSendQueueServiceError.notConnected), .notConnected)) + + // An unrecognized error during a send is a send failure, not a disconnect. + let unknown = NSError(domain: "test", code: 1) + #expect(isCase(SendMessageIntent.mapToIntentError(unknown), .sendFailed)) + } + + // MARK: - .ready DM enqueue (durable write) + + @Test func `ready DM enqueues pending send`() async throws { + let appState = AppState() + let services = try seedReady(appState, state: .ready) + let contact = Self.makeContact(name: "Alice") + try await services.dataStore.saveContact(contact) + + let outcome = try await SendMessageIntent.performSend( + message: "on my way", recipient: .contact(contact), in: appState + ) + #expect(outcome == .queued) + + // The durable PendingSend row is what survives a drop and drains at .ready. + let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) + #expect(pending.count == 1) + } + + // MARK: - Conversation list refresh + + @Test func `ready send bumps conversations version so the list refreshes`() async throws { + // A headless send writes the message row but, unlike an in-app send, never + // crosses the chat view model. Without an explicit refresh the Chats list + // keeps its cached snapshot, so the preview stays stale until an unrelated + // reload fires. The send must announce the change itself. + let appState = AppState() + let services = try seedReady(appState, state: .ready) + let contact = Self.makeContact(name: "Alice") + try await services.dataStore.saveContact(contact) + let before = appState.conversationsVersion + + let outcome = try await SendMessageIntent.performSend( + message: "on my way", recipient: .contact(contact), in: appState + ) + + #expect(outcome == .queued) + #expect(appState.conversationsVersion > before) + } + + @Test func `must foreground does not bump conversations version`() async throws { + // A nil-services re-read returns `.mustForeground` before any write, so + // there is nothing to announce and the list must not be reloaded. + let appState = AppState() + let before = appState.conversationsVersion + + let outcome = try await SendMessageIntent.performSend( + message: "on my way", recipient: .contact(Self.makeContact()), in: appState + ) + + #expect(outcome == .mustForeground) + #expect(appState.conversationsVersion == before) + } + + // MARK: - .ready channel enqueue (durable write) + + @Test func `ready channel enqueues pending send`() async throws { + let appState = AppState() + let services = try seedReady(appState, state: .ready) + let channel = Self.makeChannel(name: "Ops") + try await services.dataStore.saveChannel(channel) + + let outcome = try await SendMessageIntent.performSend( + message: "radio check", recipient: .channel(channel), in: appState + ) + #expect(outcome == .queued) + + let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) + #expect(pending.count == 1) + } + + // MARK: - channel length re-validated against the live node name at send time + + @Test func `channel message revalidated against live node name at send time`() async throws { + // A message sized to the zero-node-name budget clears the pre-confirmation + // validate, but the live radio ("Base Camp") shrinks the on-air budget by + // the prepended ": ". performSend must re-validate and reject it + // rather than enqueue a message the firmware would silently truncate. + let appState = AppState() + let services = try seedReady(appState, state: .ready) + let channel = Self.makeChannel(name: "Ops") + try await services.dataStore.saveChannel(channel) + + let overBudget = String(repeating: "x", count: ProtocolLimits.maxChannelMessageLength(nodeNameByteCount: 0)) + + let thrown = await #expect(throws: IntentError.self) { + try await SendMessageIntent.performSend( + message: overBudget, recipient: .channel(channel), in: appState + ) } + #expect(try isCase(#require(thrown), .messageTooLong)) + + // The reject precedes the durable write, so nothing is enqueued. + let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) + #expect(pending.isEmpty) + } + + // MARK: - enqueue-failure orphan recovery primitive + + /// `performSend`'s catch marks a persisted-but-unenqueued row `.failed` via + /// `updateMessageStatusUnlessDelivered` so an enqueue-write failure surfaces a + /// retry instead of hanging `.pending`. Forcing the enqueue itself to throw is + /// not reachable through any existing test seam (the queue persists through the + /// concrete in-memory `PersistenceStore`, which does not fail), so this pins + /// the recovery primitive on a real row built by production `createPendingMessage`: + /// a `.pending` row flips to `.failed`, while a `.delivered` row is left intact + /// so the recovery can never downgrade a delivered send. + @Test func `update message status unless delivered fails pending but spares delivered`() async throws { + let appState = AppState() + let services = try seedReady(appState, state: .ready) + let contact = Self.makeContact() + try await services.dataStore.saveContact(contact) + + let pending = try await services.messageService.createPendingMessage(text: "queued", to: contact) + #expect(try await services.dataStore.fetchMessage(id: pending.id)?.status == .pending) + + let flipped = try await services.dataStore.updateMessageStatusUnlessDelivered(id: pending.id, status: .failed) + #expect(flipped) + #expect(try await services.dataStore.fetchMessage(id: pending.id)?.status == .failed) + + let delivered = try await services.messageService.createPendingMessage(text: "acked", to: contact) + try await services.dataStore.updateMessageAck(id: delivered.id, ackCode: 0x1234_5678, status: .delivered) + + let spared = try await services.dataStore.updateMessageStatusUnlessDelivered(id: delivered.id, status: .failed) + #expect(!spared) + #expect(try await services.dataStore.fetchMessage(id: delivered.id)?.status == .delivered) + } + + // MARK: - .syncing takes the queue + + @Test func `syncing enqueues pending send`() async throws { + let appState = AppState() + let services = try seedReady(appState, state: .syncing) + let contact = Self.makeContact(name: "Bravo") + try await services.dataStore.saveContact(contact) + + // The .syncing rung classifies as .queueAfterSync, which shares the queue + // branch with .ready: the durable row is written either way. + #expect(SendMessageIntent.route(for: .syncing) == .queueAfterSync) + let outcome = try await SendMessageIntent.performSend( + message: "staging", recipient: .contact(contact), in: appState + ) + #expect(outcome == .queued) + + let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) + #expect(pending.count == 1) + } + + // MARK: - services-nil during confirmation re-routes to foreground (no fabricated queued) + + @Test func `services nil after classify routes to foreground not A silent enqueue`() async throws { + let appState = AppState() + // Classified .ready with a live service, then the radio drops during the + // confirmation await: services go nil. performSend must report + // .mustForeground, never a "queued" for a send that never enqueued. + _ = try seedReady(appState, state: .ready) + let contact = Self.makeContact() + appState.connectionManager.setTestState(services: .some(nil)) + + let outcome = try await SendMessageIntent.performSend( + message: "dropped", recipient: .contact(contact), in: appState + ) + #expect(outcome == .mustForeground) + } + + // MARK: - radio switch during confirmation re-routes to foreground (no cross-radio enqueue) + + @Test func `recipient from another radio routes to foreground not A cross radio enqueue`() async throws { + let appState = AppState() + // Live radio is B (the seeded service and connected device). The recipient + // was resolved against radio A before the confirmation await, so enqueuing + // would scope the message row to A and the PendingSend to B, mis-routing + // the send. performSend must report .mustForeground and write nothing. + let services = try seedReady(appState, state: .ready) + let otherRadioID = try #require(UUID(uuidString: "55555555-5555-5555-5555-555555555555")) + let contact = Self.makeContact(radioID: otherRadioID) + + let outcome = try await SendMessageIntent.performSend( + message: "switched radios", recipient: .contact(contact), in: appState + ) + #expect(outcome == .mustForeground) + + let pending = try await services.dataStore.fetchPendingSends(radioID: Self.radioID) + #expect(pending.isEmpty) + let otherPending = try await services.dataStore.fetchPendingSends(radioID: otherRadioID) + #expect(otherPending.isEmpty) + } + + // MARK: - bare .connected with no services never silently enqueues + + @Test func `connected without services never enqueues`() async throws { + let appState = AppState() + // Bare .connected: the rung is set before services are built, so services + // can be nil. The route classifies .foregroundEscalate (never the queue), + // and even if performSend were reached it returns .mustForeground. + appState.connectionManager.setTestState( + connectionState: .connected, services: .some(nil), connectedDevice: Self.makeDevice() + ) + #expect(SendMessageIntent.route(for: .connected) == .foregroundEscalate) + + let outcome = try await SendMessageIntent.performSend( + message: "nope", recipient: .contact(Self.makeContact()), in: appState + ) + #expect(outcome == .mustForeground) + } } diff --git a/MC1Tests/Intents/StatusQueryIntentTests.swift b/MC1Tests/Intents/StatusQueryIntentTests.swift index 132c023b..3c52221a 100644 --- a/MC1Tests/Intents/StatusQueryIntentTests.swift +++ b/MC1Tests/Intents/StatusQueryIntentTests.swift @@ -1,8 +1,8 @@ import Foundation -import MeshCore -import Testing @testable import MC1 @testable import MC1Services +import MeshCore +import Testing /// `StatusQueryIntent` is the read-only, cached-only voice glance: it answers from /// synchronous `@Observable` state with no radio round-trip, never throws on a @@ -14,172 +14,171 @@ import Testing /// is verified on device rather than here. @MainActor struct StatusQueryIntentTests { - - private static let radioID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")! - - /// An OCV curve (11 points, 100%..0%) used to pin the conversion: the seeded - /// level maps to a deterministic percent through `percentage(using:)`, a value - /// that can never be confused with the raw millivolts. - private static let ocvArray = [4200, 4060, 3980, 3920, 3870, 3820, 3790, 3770, 3730, 3680, 3000] - - /// A seeded millivolt level whose digits never coincide with its resulting - /// percent, so a test catching raw-mV leakage stays meaningful. 4100mV resolves - /// to 93% against `ocvArray`. - private static let seededLevel = 4100 - private static let expectedPercent = 93 - - private static func makeDevice(name: String) -> DeviceDTO { - DeviceDTO( - id: UUID(), - radioID: radioID, - publicKey: Data(repeating: 0x01, count: 32), - nodeName: name, - firmwareVersion: 8, - firmwareVersionString: "1.10", - manufacturerName: "Test", - buildDate: "", - maxContacts: 100, - maxChannels: 16, - frequency: 0, - bandwidth: 0, - spreadingFactor: 0, - codingRate: 0, - txPower: 0, - maxTxPower: 0, - latitude: 0, - longitude: 0, - blePin: 0, - clientRepeat: false, - pathHashMode: 0, - manualAddContacts: false, - autoAddConfig: 0, - autoAddMaxHops: 0, - multiAcks: 0, - telemetryModeBase: 0, - telemetryModeLoc: 0, - telemetryModeEnv: 0, - advertLocationPolicy: 0, - lastConnected: Date(), - lastContactSync: 0, - isActive: true, - ocvPreset: nil, - customOCVArrayString: nil, - connectionMethods: [] - ) - } - - private func seedConnected( - _ appState: AppState, - state: DeviceConnectionState, - name: String, - battery: BatteryInfo? - ) { - appState.connectionManager.setTestState( - connectionState: state, - connectedDevice: Self.makeDevice(name: name), - currentTransportType: .bluetooth - ) - appState.batteryMonitor.deviceBattery = battery - } - - // MARK: - Connected rungs - - @Test(arguments: [DeviceConnectionState.ready, .syncing, .connected]) - func connectedWithBatterySpeaksNameAndPercent(_ state: DeviceConnectionState) { - let appState = AppState() - let battery = BatteryInfo(level: Self.seededLevel) - seedConnected(appState, state: state, name: "Base Camp", battery: battery) - - let percent = battery.percentage(using: appState.connectedDevice!.activeOCVArray) - let spoken = StatusQueryIntent.dialogText(for: appState) - - #expect(spoken == L10n.Tools.Intent.Status.Dialog.connectedWithBattery("Base Camp", percent)) - // Raw millivolts must never be spoken as the percentage. - #expect(!spoken.contains(String(Self.seededLevel))) - } - - @Test func absentBatteryReportsNoReadingNotZeroPercent() { - let appState = AppState() - // 0mV = no battery hardware (mains-powered); `isBatteryPresent` is false. - seedConnected(appState, state: .ready, name: "Mains Node", battery: BatteryInfo(level: 0)) - - let spoken = StatusQueryIntent.dialogText(for: appState) - #expect(spoken == L10n.Tools.Intent.Status.Dialog.connectedNoBattery("Mains Node")) - #expect(!spoken.contains("0%")) - } - - @Test func missingBatteryReadingReportsNoReading() { - let appState = AppState() - seedConnected(appState, state: .ready, name: "No Poll Yet", battery: nil) - - #expect( - StatusQueryIntent.dialogText(for: appState) - == L10n.Tools.Intent.Status.Dialog.connectedNoBattery("No Poll Yet") - ) - } - - // MARK: - Connecting and disconnected rungs (offline name, no throw) - - @Test func connectingSpeaksConnecting() { - let appState = AppState() - appState.connectionManager.persistConnection( - deviceID: UUID(), radioID: Self.radioID, deviceName: "Field Radio" - ) - appState.connectionManager.setTestState(connectionState: .connecting, connectedDevice: .some(nil)) - - #expect( - StatusQueryIntent.dialogText(for: appState) - == L10n.Tools.Intent.Status.Dialog.connecting("Field Radio") - ) - appState.connectionManager.clearPersistedConnection() - } - - @Test func disconnectedWithKnownRadioSpeaksOfflineName() { - let appState = AppState() - appState.connectionManager.persistConnection( - deviceID: UUID(), radioID: Self.radioID, deviceName: "Last Radio" - ) - appState.connectionManager.setTestState(connectionState: .disconnected, connectedDevice: .some(nil)) - - #expect( - StatusQueryIntent.dialogText(for: appState) - == L10n.Tools.Intent.Status.Dialog.disconnectedNamed("Last Radio") - ) - appState.connectionManager.clearPersistedConnection() - } - - @Test func neverConnectedReportsNoRadio() { - let appState = AppState() - appState.connectionManager.clearPersistedConnection() - appState.connectionManager.setTestState(connectionState: .disconnected, connectedDevice: .some(nil)) - - #expect( - StatusQueryIntent.dialogText(for: appState) - == L10n.Tools.Intent.Status.Dialog.disconnectedUnknown - ) - } - - // MARK: - Pre-unlock (no AppState) - - @Test func nilAppStateMeansNotReady() { - let bridge = IntentBridge() - // No `adopt`, so `bridge.appState` is nil; the pre-unlock BFU window, where - // `perform()` returns the not-ready dialog without reading any state. The - // `nil` bridge is what gates that branch. - #expect(bridge.appState == nil) - } - - // MARK: - Conversion correctness - - @Test func millivoltsConvertToPercentThroughOCVCurve() { - let battery = BatteryInfo(level: Self.seededLevel) - let percent = battery.percentage(using: Self.ocvArray) - #expect(percent == Self.expectedPercent) - #expect(percent != battery.level) - } - - @Test func absentBatteryIsNotPresent() { - #expect(BatteryInfo(level: 0).isBatteryPresent == false) - #expect(BatteryInfo(level: Self.seededLevel).isBatteryPresent == true) - } + private static let radioID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")! + + /// An OCV curve (11 points, 100%..0%) used to pin the conversion: the seeded + /// level maps to a deterministic percent through `percentage(using:)`, a value + /// that can never be confused with the raw millivolts. + private static let ocvArray = [4200, 4060, 3980, 3920, 3870, 3820, 3790, 3770, 3730, 3680, 3000] + + /// A seeded millivolt level whose digits never coincide with its resulting + /// percent, so a test catching raw-mV leakage stays meaningful. 4100mV resolves + /// to 93% against `ocvArray`. + private static let seededLevel = 4100 + private static let expectedPercent = 93 + + private static func makeDevice(name: String) -> DeviceDTO { + DeviceDTO( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0x01, count: 32), + nodeName: name, + firmwareVersion: 8, + firmwareVersionString: "1.10", + manufacturerName: "Test", + buildDate: "", + maxContacts: 100, + maxChannels: 16, + frequency: 0, + bandwidth: 0, + spreadingFactor: 0, + codingRate: 0, + txPower: 0, + maxTxPower: 0, + latitude: 0, + longitude: 0, + blePin: 0, + clientRepeat: false, + pathHashMode: 0, + manualAddContacts: false, + autoAddConfig: 0, + autoAddMaxHops: 0, + multiAcks: 0, + telemetryModeBase: 0, + telemetryModeLoc: 0, + telemetryModeEnv: 0, + advertLocationPolicy: 0, + lastConnected: Date(), + lastContactSync: 0, + isActive: true, + ocvPreset: nil, + customOCVArrayString: nil, + connectionMethods: [] + ) + } + + private func seedConnected( + _ appState: AppState, + state: DeviceConnectionState, + name: String, + battery: BatteryInfo? + ) { + appState.connectionManager.setTestState( + connectionState: state, + connectedDevice: Self.makeDevice(name: name), + currentTransportType: .bluetooth + ) + appState.batteryMonitor.deviceBattery = battery + } + + // MARK: - Connected rungs + + @Test(arguments: [DeviceConnectionState.ready, .syncing, .connected]) + func `connected with battery speaks name and percent`(_ state: DeviceConnectionState) throws { + let appState = AppState() + let battery = BatteryInfo(level: Self.seededLevel) + seedConnected(appState, state: state, name: "Base Camp", battery: battery) + + let percent = try battery.percentage(using: #require(appState.connectedDevice?.activeOCVArray)) + let spoken = StatusQueryIntent.dialogText(for: appState) + + #expect(spoken == L10n.Tools.Intent.Status.Dialog.connectedWithBattery("Base Camp", percent)) + // Raw millivolts must never be spoken as the percentage. + #expect(!spoken.contains(String(Self.seededLevel))) + } + + @Test func `absent battery reports no reading not zero percent`() { + let appState = AppState() + // 0mV = no battery hardware (mains-powered); `isBatteryPresent` is false. + seedConnected(appState, state: .ready, name: "Mains Node", battery: BatteryInfo(level: 0)) + + let spoken = StatusQueryIntent.dialogText(for: appState) + #expect(spoken == L10n.Tools.Intent.Status.Dialog.connectedNoBattery("Mains Node")) + #expect(!spoken.contains("0%")) + } + + @Test func `missing battery reading reports no reading`() { + let appState = AppState() + seedConnected(appState, state: .ready, name: "No Poll Yet", battery: nil) + + #expect( + StatusQueryIntent.dialogText(for: appState) + == L10n.Tools.Intent.Status.Dialog.connectedNoBattery("No Poll Yet") + ) + } + + // MARK: - Connecting and disconnected rungs (offline name, no throw) + + @Test func `connecting speaks connecting`() { + let appState = AppState() + appState.connectionManager.persistConnection( + deviceID: UUID(), radioID: Self.radioID, deviceName: "Field Radio" + ) + appState.connectionManager.setTestState(connectionState: .connecting, connectedDevice: .some(nil)) + + #expect( + StatusQueryIntent.dialogText(for: appState) + == L10n.Tools.Intent.Status.Dialog.connecting("Field Radio") + ) + appState.connectionManager.clearPersistedConnection() + } + + @Test func `disconnected with known radio speaks offline name`() { + let appState = AppState() + appState.connectionManager.persistConnection( + deviceID: UUID(), radioID: Self.radioID, deviceName: "Last Radio" + ) + appState.connectionManager.setTestState(connectionState: .disconnected, connectedDevice: .some(nil)) + + #expect( + StatusQueryIntent.dialogText(for: appState) + == L10n.Tools.Intent.Status.Dialog.disconnectedNamed("Last Radio") + ) + appState.connectionManager.clearPersistedConnection() + } + + @Test func `never connected reports no radio`() { + let appState = AppState() + appState.connectionManager.clearPersistedConnection() + appState.connectionManager.setTestState(connectionState: .disconnected, connectedDevice: .some(nil)) + + #expect( + StatusQueryIntent.dialogText(for: appState) + == L10n.Tools.Intent.Status.Dialog.disconnectedUnknown + ) + } + + // MARK: - Pre-unlock (no AppState) + + @Test func `nil app state means not ready`() { + let bridge = IntentBridge() + // No `adopt`, so `bridge.appState` is nil; the pre-unlock BFU window, where + // `perform()` returns the not-ready dialog without reading any state. The + // `nil` bridge is what gates that branch. + #expect(bridge.appState == nil) + } + + // MARK: - Conversion correctness + + @Test func `millivolts convert to percent through OCV curve`() { + let battery = BatteryInfo(level: Self.seededLevel) + let percent = battery.percentage(using: Self.ocvArray) + #expect(percent == Self.expectedPercent) + #expect(percent != battery.level) + } + + @Test func `absent battery is not present`() { + #expect(BatteryInfo(level: 0).isBatteryPresent == false) + #expect(BatteryInfo(level: Self.seededLevel).isBatteryPresent == true) + } } diff --git a/MC1Tests/Localization/AirtimePercentLabelTests.swift b/MC1Tests/Localization/AirtimePercentLabelTests.swift index f4ff0056..186fb44a 100644 --- a/MC1Tests/Localization/AirtimePercentLabelTests.swift +++ b/MC1Tests/Localization/AirtimePercentLabelTests.swift @@ -1,13 +1,12 @@ -import Testing @testable import MC1 +import Testing @Suite("Airtime % label") struct AirtimePercentLabelTests { - - // A bare `%` in the .strings value is consumed by String(format:) inside L10n.tr, - // dropping the percent sign. The value must be escaped as `%%` to render literally. - @Test("airtimePercent renders a literal percent sign") - func rendersLiteralPercent() { - #expect(L10n.RemoteNodes.RemoteNodes.Status.airtimePercent == "Airtime %") - } + /// A bare `%` in the .strings value is consumed by String(format:) inside L10n.tr, + /// dropping the percent sign. The value must be escaped as `%%` to render literally. + @Test + func `airtimePercent renders a literal percent sign`() { + #expect(L10n.RemoteNodes.RemoteNodes.Status.airtimePercent == "Airtime %") + } } diff --git a/MC1Tests/MessageTextThemeTests.swift b/MC1Tests/MessageTextThemeTests.swift index 76611ac5..17b89f9c 100644 --- a/MC1Tests/MessageTextThemeTests.swift +++ b/MC1Tests/MessageTextThemeTests.swift @@ -1,53 +1,52 @@ -import Testing -import SwiftUI @testable import MC1 +import SwiftUI +import Testing @MainActor @Suite("MessageText theme colors") struct MessageTextThemeTests { + @Test + func `incoming hashtag color is baked: different hashtagColor yields different output`() { + let cyan = MessageText.buildFormattedText( + text: "see #news", isOutgoing: false, currentUserName: nil, + isHighContrast: false, outgoingTextColor: .white, hashtagColor: .cyan, + identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] + ) + let orange = MessageText.buildFormattedText( + text: "see #news", isOutgoing: false, currentUserName: nil, + isHighContrast: false, outgoingTextColor: .white, hashtagColor: .orange, + identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] + ) + #expect(cyan.text != orange.text) + } - @Test("incoming hashtag color is baked: different hashtagColor yields different output") - func incomingHashtagColorIsBaked() { - let cyan = MessageText.buildFormattedText( - text: "see #news", isOutgoing: false, currentUserName: nil, - isHighContrast: false, outgoingTextColor: .white, hashtagColor: .cyan, - identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] - ) - let orange = MessageText.buildFormattedText( - text: "see #news", isOutgoing: false, currentUserName: nil, - isHighContrast: false, outgoingTextColor: .white, hashtagColor: .orange, - identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] - ) - #expect(cyan.text != orange.text) - } - - @Test("outgoing text color is baked: different outgoingTextColor yields different output") - func outgoingTextColorIsBaked() { - let white = MessageText.buildFormattedText( - text: "hello #news", isOutgoing: true, currentUserName: nil, - isHighContrast: false, outgoingTextColor: .white, hashtagColor: .cyan, - identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] - ) - let pink = MessageText.buildFormattedText( - text: "hello #news", isOutgoing: true, currentUserName: nil, - isHighContrast: false, outgoingTextColor: .pink, hashtagColor: .cyan, - identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] - ) - #expect(white.text != pink.text) - } + @Test + func `outgoing text color is baked: different outgoingTextColor yields different output`() { + let white = MessageText.buildFormattedText( + text: "hello #news", isOutgoing: true, currentUserName: nil, + isHighContrast: false, outgoingTextColor: .white, hashtagColor: .cyan, + identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] + ) + let pink = MessageText.buildFormattedText( + text: "hello #news", isOutgoing: true, currentUserName: nil, + isHighContrast: false, outgoingTextColor: .pink, hashtagColor: .cyan, + identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] + ) + #expect(white.text != pink.text) + } - @Test("default theme's hashtag color is the one baked into the hashtag run") - func defaultThemeHashtagColorIsBaked() { - // Drive the input from the real default theme rather than a literal, so retinting the - // default theme's hashtag asset cannot pass this test by coincidence. - let themed = MessageText.buildFormattedText( - text: "tap #news", isOutgoing: false, currentUserName: nil, - isHighContrast: false, - outgoingTextColor: Theme.default.outgoingTextColor, - hashtagColor: Theme.default.hashtagColor, - identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] - ) - let hashtagRun = themed.text.runs.first { $0.link != nil } - #expect(hashtagRun?.foregroundColor == Theme.default.hashtagColor) - } + @Test + func `default theme's hashtag color is the one baked into the hashtag run`() { + // Drive the input from the real default theme rather than a literal, so retinting the + // default theme's hashtag asset cannot pass this test by coincidence. + let themed = MessageText.buildFormattedText( + text: "tap #news", isOutgoing: false, currentUserName: nil, + isHighContrast: false, + outgoingTextColor: Theme.default.outgoingTextColor, + hashtagColor: Theme.default.hashtagColor, + identityGamut: Theme.default.identityGamut, identityBackgroundLuminances: [1.0] + ) + let hashtagRun = themed.text.runs.first { $0.link != nil } + #expect(hashtagRun?.foregroundColor == Theme.default.hashtagColor) + } } diff --git a/MC1Tests/Models/ContactOCVTests.swift b/MC1Tests/Models/ContactOCVTests.swift index 7db82107..f9abc7ec 100644 --- a/MC1Tests/Models/ContactOCVTests.swift +++ b/MC1Tests/Models/ContactOCVTests.swift @@ -1,122 +1,121 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("Contact OCV Tests") struct ContactOCVTests { + @Test + func `activeOCVArray returns Li-Ion by default`() { + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0xFF, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + ocvPreset: nil, + customOCVArrayString: nil + ) - @Test("activeOCVArray returns Li-Ion by default") - func activeOCVArrayReturnsLiIonByDefault() { - let contact = ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0xFF, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - ocvPreset: nil, - customOCVArrayString: nil - ) - - #expect(contact.activeOCVArray == OCVPreset.liIon.ocvArray) - } + #expect(contact.activeOCVArray == OCVPreset.liIon.ocvArray) + } - @Test("activeOCVArray returns preset array when set") - func activeOCVArrayReturnsPresetWhenSet() { - let contact = ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0xFF, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - ocvPreset: OCVPreset.liFePO4.rawValue, - customOCVArrayString: nil - ) + @Test + func `activeOCVArray returns preset array when set`() { + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0xFF, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + ocvPreset: OCVPreset.liFePO4.rawValue, + customOCVArrayString: nil + ) - #expect(contact.activeOCVArray == OCVPreset.liFePO4.ocvArray) - } + #expect(contact.activeOCVArray == OCVPreset.liFePO4.ocvArray) + } - @Test("activeOCVArray returns custom array when valid") - func activeOCVArrayReturnsCustomWhenValid() { - let customArray = [4200, 4100, 4000, 3900, 3800, 3700, 3600, 3500, 3400, 3300, 3200] - let customString = customArray.map(String.init).joined(separator: ",") + @Test + func `activeOCVArray returns custom array when valid`() { + let customArray = [4200, 4100, 4000, 3900, 3800, 3700, 3600, 3500, 3400, 3300, 3200] + let customString = customArray.map(String.init).joined(separator: ",") - let contact = ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0xFF, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - ocvPreset: OCVPreset.custom.rawValue, - customOCVArrayString: customString - ) + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0xFF, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + ocvPreset: OCVPreset.custom.rawValue, + customOCVArrayString: customString + ) - #expect(contact.activeOCVArray == customArray) - } + #expect(contact.activeOCVArray == customArray) + } - @Test("activeOCVArray falls back to Li-Ion for invalid custom array") - func activeOCVArrayFallsBackForInvalidCustom() { - let contact = ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0x42, count: 32), - name: "Test", - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0xFF, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0, - ocvPreset: OCVPreset.custom.rawValue, - customOCVArrayString: "invalid" - ) + @Test + func `activeOCVArray falls back to Li-Ion for invalid custom array`() { + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0xFF, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + ocvPreset: OCVPreset.custom.rawValue, + customOCVArrayString: "invalid" + ) - #expect(contact.activeOCVArray == OCVPreset.liIon.ocvArray) - } + #expect(contact.activeOCVArray == OCVPreset.liIon.ocvArray) + } } diff --git a/MC1Tests/Models/ConversationFilteringTests.swift b/MC1Tests/Models/ConversationFilteringTests.swift index e779c8ca..ce04271e 100644 --- a/MC1Tests/Models/ConversationFilteringTests.swift +++ b/MC1Tests/Models/ConversationFilteringTests.swift @@ -1,203 +1,202 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing -@Suite struct ConversationFilteringTests { - - // MARK: - Test Data - - private func makeContact( - name: String, - isFavorite: Bool = false, - unreadCount: Int = 0, - isMuted: Bool = false - ) -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0, count: 32), - name: name, - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: isMuted, - isFavorite: isFavorite, - lastMessageDate: Date(), - unreadCount: unreadCount - ) - } - - private func makeChannel( - name: String, - unreadCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false - ) -> ChannelDTO { - ChannelDTO( - id: UUID(), - radioID: UUID(), - index: 1, - name: name, - secret: Data(repeating: 0, count: 16), - isEnabled: true, - lastMessageDate: Date(), - unreadCount: unreadCount, - notificationLevel: notificationLevel, - isFavorite: isFavorite - ) - } - - private func makeRoom( - name: String, - unreadCount: Int = 0, - notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false - ) -> RemoteNodeSessionDTO { - RemoteNodeSessionDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data(repeating: 0, count: 32), - name: name, - role: .roomServer, - isConnected: true, - lastConnectedDate: Date(), - unreadCount: unreadCount, - notificationLevel: notificationLevel, - isFavorite: isFavorite - ) - } - - // MARK: - Filter Tests - - @Test func allFilterShowsAll() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .channel(makeChannel(name: "General")), - .room(makeRoom(name: "Room1")) - ] - - let result = conversations.filtered(by: .all, searchText: "") - - #expect(result.count == 3) - } - - @Test func filterByUnread() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice", unreadCount: 5)), - .direct(makeContact(name: "Bob", unreadCount: 0)), - .channel(makeChannel(name: "General", unreadCount: 2)) - ] - - let result = conversations.filtered(by: .unread, searchText: "") - - #expect(result.count == 2) - #expect(result.allSatisfy { $0.unreadCount > 0 }) - } - - @Test func filterByDirectMessages() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .direct(makeContact(name: "Bob")), - .channel(makeChannel(name: "General")), - .room(makeRoom(name: "Room1")) - ] - - let result = conversations.filtered(by: .directMessages, searchText: "") - - #expect(result.count == 2) - #expect(result.allSatisfy { - if case .direct = $0 { return true } - return false - }) - } - - @Test func filterByChannelsExcludesRooms() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .channel(makeChannel(name: "General")), - .room(makeRoom(name: "Room1")) - ] - - let result = conversations.filtered(by: .channels, searchText: "") - - #expect(result.count == 1) - #expect(result.allSatisfy { - if case .channel = $0 { return true } - return false - }) - } - - @Test func filterByRoomsExcludesChannels() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .channel(makeChannel(name: "General")), - .room(makeRoom(name: "Room1")), - .room(makeRoom(name: "Room2")) - ] - - let result = conversations.filtered(by: .rooms, searchText: "") - - #expect(result.count == 2) - #expect(result.allSatisfy { - if case .room = $0 { return true } - return false - }) - } - - @Test func searchWithinFilter() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice", unreadCount: 1)), - .direct(makeContact(name: "Bob", unreadCount: 1)), - .direct(makeContact(name: "Charlie", unreadCount: 0)) - ] - - let result = conversations.filtered(by: .unread, searchText: "Ali") - - #expect(result.count == 1) - #expect(result.first?.displayName == "Alice") - } - - @Test func searchOnlyWithoutFilter() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .direct(makeContact(name: "Bob")), - .channel(makeChannel(name: "Alpha")) - ] - - let result = conversations.filtered(by: .all, searchText: "Al") - - #expect(result.count == 2) - } - - @Test func emptyResultsWhenNoMatch() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice")), - .direct(makeContact(name: "Bob")) - ] - - let result = conversations.filtered(by: .all, searchText: "Zzzz") - - #expect(result.isEmpty) - } - - @Test func unreadFilterExcludesMuted() { - let conversations: [Conversation] = [ - .direct(makeContact(name: "Alice", unreadCount: 5, isMuted: false)), - .direct(makeContact(name: "Bob", unreadCount: 3, isMuted: true)), - .channel(makeChannel(name: "General", unreadCount: 2, notificationLevel: .all)) - ] - - let result = conversations.filtered(by: .unread, searchText: "") - - #expect(result.count == 2) - #expect(!result.contains { $0.displayName == "Bob" }) - } +struct ConversationFilteringTests { + // MARK: - Test Data + + private func makeContact( + name: String, + isFavorite: Bool = false, + unreadCount: Int = 0, + isMuted: Bool = false + ) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0, count: 32), + name: name, + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: isMuted, + isFavorite: isFavorite, + lastMessageDate: Date(), + unreadCount: unreadCount + ) + } + + private func makeChannel( + name: String, + unreadCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false + ) -> ChannelDTO { + ChannelDTO( + id: UUID(), + radioID: UUID(), + index: 1, + name: name, + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: unreadCount, + notificationLevel: notificationLevel, + isFavorite: isFavorite + ) + } + + private func makeRoom( + name: String, + unreadCount: Int = 0, + notificationLevel: NotificationLevel = .all, + isFavorite: Bool = false + ) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0, count: 32), + name: name, + role: .roomServer, + isConnected: true, + lastConnectedDate: Date(), + unreadCount: unreadCount, + notificationLevel: notificationLevel, + isFavorite: isFavorite + ) + } + + // MARK: - Filter Tests + + @Test func `all filter shows all`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .channel(makeChannel(name: "General")), + .room(makeRoom(name: "Room1")) + ] + + let result = conversations.filtered(by: .all, searchText: "") + + #expect(result.count == 3) + } + + @Test func `filter by unread`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice", unreadCount: 5)), + .direct(makeContact(name: "Bob", unreadCount: 0)), + .channel(makeChannel(name: "General", unreadCount: 2)) + ] + + let result = conversations.filtered(by: .unread, searchText: "") + + #expect(result.count == 2) + #expect(result.allSatisfy { $0.unreadCount > 0 }) + } + + @Test func `filter by direct messages`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .direct(makeContact(name: "Bob")), + .channel(makeChannel(name: "General")), + .room(makeRoom(name: "Room1")) + ] + + let result = conversations.filtered(by: .directMessages, searchText: "") + + #expect(result.count == 2) + #expect(result.allSatisfy { + if case .direct = $0 { return true } + return false + }) + } + + @Test func `filter by channels excludes rooms`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .channel(makeChannel(name: "General")), + .room(makeRoom(name: "Room1")) + ] + + let result = conversations.filtered(by: .channels, searchText: "") + + #expect(result.count == 1) + #expect(result.allSatisfy { + if case .channel = $0 { return true } + return false + }) + } + + @Test func `filter by rooms excludes channels`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .channel(makeChannel(name: "General")), + .room(makeRoom(name: "Room1")), + .room(makeRoom(name: "Room2")) + ] + + let result = conversations.filtered(by: .rooms, searchText: "") + + #expect(result.count == 2) + #expect(result.allSatisfy { + if case .room = $0 { return true } + return false + }) + } + + @Test func `search within filter`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice", unreadCount: 1)), + .direct(makeContact(name: "Bob", unreadCount: 1)), + .direct(makeContact(name: "Charlie", unreadCount: 0)) + ] + + let result = conversations.filtered(by: .unread, searchText: "Ali") + + #expect(result.count == 1) + #expect(result.first?.displayName == "Alice") + } + + @Test func `search only without filter`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .direct(makeContact(name: "Bob")), + .channel(makeChannel(name: "Alpha")) + ] + + let result = conversations.filtered(by: .all, searchText: "Al") + + #expect(result.count == 2) + } + + @Test func `empty results when no match`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice")), + .direct(makeContact(name: "Bob")) + ] + + let result = conversations.filtered(by: .all, searchText: "Zzzz") + + #expect(result.isEmpty) + } + + @Test func `unread filter excludes muted`() { + let conversations: [Conversation] = [ + .direct(makeContact(name: "Alice", unreadCount: 5, isMuted: false)), + .direct(makeContact(name: "Bob", unreadCount: 3, isMuted: true)), + .channel(makeChannel(name: "General", unreadCount: 2, notificationLevel: .all)) + ] + + let result = conversations.filtered(by: .unread, searchText: "") + + #expect(result.count == 2) + #expect(!result.contains { $0.displayName == "Bob" }) + } } diff --git a/MC1Tests/Models/DevicePreferenceStoreTests.swift b/MC1Tests/Models/DevicePreferenceStoreTests.swift index 6842ba52..3efad871 100644 --- a/MC1Tests/Models/DevicePreferenceStoreTests.swift +++ b/MC1Tests/Models/DevicePreferenceStoreTests.swift @@ -1,118 +1,116 @@ import Foundation -import Testing - @testable import MC1 +import Testing @Suite("DevicePreferenceStore Tests") struct DevicePreferenceStoreTests { - - @Test("Auto-update location defaults to false") - func autoUpdateDefaultsFalse() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - #expect(store.isAutoUpdateLocationEnabled(deviceID: UUID()) == false) - } - - @Test("GPS source defaults to phone") - func gpsSourceDefaultsToPhone() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - #expect(store.gpsSource(deviceID: UUID()) == .phone) - } - - @Test("Auto-update values are scoped per device") - func autoUpdatePerDeviceIsolation() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - let deviceA = UUID() - let deviceB = UUID() - - store.setAutoUpdateLocationEnabled(true, deviceID: deviceA) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == true) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == false) - - store.setAutoUpdateLocationEnabled(true, deviceID: deviceB) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == true) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == true) - - store.setAutoUpdateLocationEnabled(false, deviceID: deviceA) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == false) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == true) - } - - @Test("GPS source values are scoped per device") - func gpsSourcePerDeviceIsolation() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - let deviceA = UUID() - let deviceB = UUID() - - store.setGPSSource(.device, deviceID: deviceA) - #expect(store.gpsSource(deviceID: deviceA) == .device) - #expect(store.gpsSource(deviceID: deviceB) == .phone) - - store.setGPSSource(.device, deviceID: deviceB) - #expect(store.gpsSource(deviceID: deviceA) == .device) - #expect(store.gpsSource(deviceID: deviceB) == .device) - - store.setGPSSource(.phone, deviceID: deviceA) - #expect(store.gpsSource(deviceID: deviceA) == .phone) - #expect(store.gpsSource(deviceID: deviceB) == .device) - } - - @Test("hasSetGPSSource returns false when no source has been set") - func hasSetGPSSourceDefaultsFalse() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - #expect(store.hasSetGPSSource(deviceID: UUID()) == false) - } - - @Test("hasSetGPSSource returns true after setting a source") - func hasSetGPSSourceAfterSet() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - let deviceID = UUID() - - #expect(store.hasSetGPSSource(deviceID: deviceID) == false) - store.setGPSSource(.phone, deviceID: deviceID) - #expect(store.hasSetGPSSource(deviceID: deviceID) == true) - } - - @Test("Setting and getting round-trips correctly") - func roundTrip() throws { - let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - - let store = DevicePreferenceStore(userDefaults: defaults) - let deviceID = UUID() - - store.setAutoUpdateLocationEnabled(true, deviceID: deviceID) - store.setGPSSource(.device, deviceID: deviceID) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceID) == true) - #expect(store.gpsSource(deviceID: deviceID) == .device) - - store.setAutoUpdateLocationEnabled(false, deviceID: deviceID) - store.setGPSSource(.phone, deviceID: deviceID) - #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceID) == false) - #expect(store.gpsSource(deviceID: deviceID) == .phone) - } + @Test + func `Auto-update location defaults to false`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + #expect(store.isAutoUpdateLocationEnabled(deviceID: UUID()) == false) + } + + @Test + func `GPS source defaults to phone`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + #expect(store.gpsSource(deviceID: UUID()) == .phone) + } + + @Test + func `Auto-update values are scoped per device`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + let deviceA = UUID() + let deviceB = UUID() + + store.setAutoUpdateLocationEnabled(true, deviceID: deviceA) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == true) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == false) + + store.setAutoUpdateLocationEnabled(true, deviceID: deviceB) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == true) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == true) + + store.setAutoUpdateLocationEnabled(false, deviceID: deviceA) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceA) == false) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceB) == true) + } + + @Test + func `GPS source values are scoped per device`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + let deviceA = UUID() + let deviceB = UUID() + + store.setGPSSource(.device, deviceID: deviceA) + #expect(store.gpsSource(deviceID: deviceA) == .device) + #expect(store.gpsSource(deviceID: deviceB) == .phone) + + store.setGPSSource(.device, deviceID: deviceB) + #expect(store.gpsSource(deviceID: deviceA) == .device) + #expect(store.gpsSource(deviceID: deviceB) == .device) + + store.setGPSSource(.phone, deviceID: deviceA) + #expect(store.gpsSource(deviceID: deviceA) == .phone) + #expect(store.gpsSource(deviceID: deviceB) == .device) + } + + @Test + func `hasSetGPSSource returns false when no source has been set`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + #expect(store.hasSetGPSSource(deviceID: UUID()) == false) + } + + @Test + func `hasSetGPSSource returns true after setting a source`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + let deviceID = UUID() + + #expect(store.hasSetGPSSource(deviceID: deviceID) == false) + store.setGPSSource(.phone, deviceID: deviceID) + #expect(store.hasSetGPSSource(deviceID: deviceID) == true) + } + + @Test + func `Setting and getting round-trips correctly`() throws { + let suite = "DevicePreferenceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = DevicePreferenceStore(userDefaults: defaults) + let deviceID = UUID() + + store.setAutoUpdateLocationEnabled(true, deviceID: deviceID) + store.setGPSSource(.device, deviceID: deviceID) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceID) == true) + #expect(store.gpsSource(deviceID: deviceID) == .device) + + store.setAutoUpdateLocationEnabled(false, deviceID: deviceID) + store.setGPSSource(.phone, deviceID: deviceID) + #expect(store.isAutoUpdateLocationEnabled(deviceID: deviceID) == false) + #expect(store.gpsSource(deviceID: deviceID) == .phone) + } } diff --git a/MC1Tests/Models/LinkPreviewPreferencesTests.swift b/MC1Tests/Models/LinkPreviewPreferencesTests.swift index 3d9d4358..753f5059 100644 --- a/MC1Tests/Models/LinkPreviewPreferencesTests.swift +++ b/MC1Tests/Models/LinkPreviewPreferencesTests.swift @@ -1,63 +1,62 @@ -import Testing import Foundation @testable import MC1 +import Testing @Suite("LinkPreviewPreferences Tests") struct LinkPreviewPreferencesTests { - - private let defaults: UserDefaults - - init() { - defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } - - @Test("Has expected defaults: previews off, auto-resolve on") - func defaultsToEnabled() { - let prefs = LinkPreviewPreferences(defaults: defaults) - #expect(prefs.previewsEnabled == false) - #expect(prefs.autoResolveDM == true) - #expect(prefs.autoResolveChannels == true) - } - - @Test("shouldAutoResolve for DM respects settings") - func shouldAutoResolveForDM() { - var prefs = LinkPreviewPreferences(defaults: defaults) - - // Master on, auto on -> true - prefs.previewsEnabled = true - prefs.autoResolveDM = true - #expect(prefs.shouldAutoResolve(isChannelMessage: false) == true) - - // Master on, auto off -> false - prefs.autoResolveDM = false - #expect(prefs.shouldAutoResolve(isChannelMessage: false) == false) - - // Master off -> false regardless - prefs.previewsEnabled = false - prefs.autoResolveDM = true - #expect(prefs.shouldAutoResolve(isChannelMessage: false) == false) - } - - @Test("shouldAutoResolve for channel respects settings") - func shouldAutoResolveForChannel() { - var prefs = LinkPreviewPreferences(defaults: defaults) - - prefs.previewsEnabled = true - prefs.autoResolveChannels = true - #expect(prefs.shouldAutoResolve(isChannelMessage: true) == true) - - prefs.autoResolveChannels = false - #expect(prefs.shouldAutoResolve(isChannelMessage: true) == false) - } - - @Test("shouldShowPreview reflects master toggle") - func shouldShowPreview() { - var prefs = LinkPreviewPreferences(defaults: defaults) - - prefs.previewsEnabled = true - #expect(prefs.shouldShowPreview == true) - - prefs.previewsEnabled = false - #expect(prefs.shouldShowPreview == false) - } + private let defaults: UserDefaults + + init() { + defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + @Test + func `Has expected defaults: previews off, auto-resolve on`() { + let prefs = LinkPreviewPreferences(defaults: defaults) + #expect(prefs.previewsEnabled == false) + #expect(prefs.autoResolveDM == true) + #expect(prefs.autoResolveChannels == true) + } + + @Test + func `shouldAutoResolve for DM respects settings`() { + var prefs = LinkPreviewPreferences(defaults: defaults) + + // Master on, auto on -> true + prefs.previewsEnabled = true + prefs.autoResolveDM = true + #expect(prefs.shouldAutoResolve(isChannelMessage: false) == true) + + // Master on, auto off -> false + prefs.autoResolveDM = false + #expect(prefs.shouldAutoResolve(isChannelMessage: false) == false) + + // Master off -> false regardless + prefs.previewsEnabled = false + prefs.autoResolveDM = true + #expect(prefs.shouldAutoResolve(isChannelMessage: false) == false) + } + + @Test + func `shouldAutoResolve for channel respects settings`() { + var prefs = LinkPreviewPreferences(defaults: defaults) + + prefs.previewsEnabled = true + prefs.autoResolveChannels = true + #expect(prefs.shouldAutoResolve(isChannelMessage: true) == true) + + prefs.autoResolveChannels = false + #expect(prefs.shouldAutoResolve(isChannelMessage: true) == false) + } + + @Test + func `shouldShowPreview reflects master toggle`() { + var prefs = LinkPreviewPreferences(defaults: defaults) + + prefs.previewsEnabled = true + #expect(prefs.shouldShowPreview == true) + + prefs.previewsEnabled = false + #expect(prefs.shouldShowPreview == false) + } } diff --git a/MC1Tests/Models/OCVPresetTests.swift b/MC1Tests/Models/OCVPresetTests.swift index 8bcc7a90..806d0b26 100644 --- a/MC1Tests/Models/OCVPresetTests.swift +++ b/MC1Tests/Models/OCVPresetTests.swift @@ -1,148 +1,147 @@ -import Testing @testable import MC1Services +import Testing @Suite("OCVPreset Tests") struct OCVPresetTests { - - @Test("All presets have exactly 11 values", arguments: OCVPreset.allCases.filter { $0 != .custom }) - func presetsHaveCorrectLength(preset: OCVPreset) { - #expect(preset.ocvArray.count == 11, "Preset \(preset) should have 11 values") - } - - @Test("All preset arrays are descending", arguments: OCVPreset.allCases.filter { $0 != .custom }) - func presetsAreDescending(preset: OCVPreset) { - let array = preset.ocvArray - for i in 0..<(array.count - 1) { - #expect(array[i] > array[i + 1], "Preset \(preset) should be descending at index \(i)") - } - } - - @Test("All presets fit within UI validation range", arguments: OCVPreset.allCases.filter { $0 != .custom }) - func presetsFitValidationRange(preset: OCVPreset) { - for value in preset.ocvArray { - #expect( - OCVPreset.validMillivoltRange.contains(value), - "Preset \(preset) has value \(value) outside \(OCVPreset.validMillivoltRange)" - ) - } - } - - @Test("All presets have display names", arguments: OCVPreset.allCases) - func presetsHaveDisplayNames(preset: OCVPreset) { - #expect(!preset.displayName.isEmpty, "Preset \(preset) should have a display name") - } - - @Test("Selectable presets excludes custom") - func selectablePresetsExcludesCustom() { - #expect(!OCVPreset.selectablePresets.contains(.custom)) - #expect(OCVPreset.selectablePresets.count == OCVPreset.allCases.count - 1) - } - - @Test("Li-Ion preset has expected values") - func liIonPresetValues() { - let expected = [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] - #expect(OCVPreset.liIon.ocvArray == expected) - } - - @Test("WisMesh Tag preset has expected values") - func wisMeshTagPresetValues() { - let expected = [4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990] - #expect(OCVPreset.wisMeshTag.ocvArray == expected) - } - - @Test("LilyGo T-Beam 1W preset has expected values") - func lilyGoTBeam1WPresetValues() { - let expected = [7950, 7850, 7750, 7580, 7440, 7310, 7150, 7005, 6860, 6685, 6000] - #expect(OCVPreset.lilyGoTBeam1W.ocvArray == expected) - } - - @Test("ThinkNode M6 preset has expected values") - func thinkNodeM6PresetValues() { - let expected = [4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3450] - #expect(OCVPreset.thinkNodeM6.ocvArray == expected) - } - - // MARK: - Category Tests - - @Test("Battery chemistry presets include only chemistry types") - func batteryChemistryPresetsIncludeOnlyChemistryTypes() { - let presets = OCVPreset.batteryChemistryPresets - - #expect(presets.contains(.liIon)) - #expect(presets.contains(.liFePO4)) - #expect(presets.contains(.leadAcid)) - #expect(presets.contains(.alkaline)) - #expect(presets.contains(.niMH)) - #expect(presets.contains(.lto)) - #expect(presets.count == 6) - } - - @Test("Battery chemistry presets exclude device-specific presets") - func batteryChemistryPresetsExcludeDeviceSpecific() { - let presets = OCVPreset.batteryChemistryPresets - - #expect(!presets.contains(.trackerT1000E)) - #expect(!presets.contains(.heltecPocket5000)) - #expect(!presets.contains(.custom)) - } - - @Test("Li-Ion is battery chemistry category") - func liIonIsBatteryChemistry() { - #expect(OCVPreset.liIon.category == .batteryChemistry) - } - - @Test("Tracker T1000-E is device specific category") - func trackerIsDeviceSpecific() { - #expect(OCVPreset.trackerT1000E.category == .deviceSpecific) - } - - @Test("Custom is device specific category") - func customIsDeviceSpecific() { - #expect(OCVPreset.custom.category == .deviceSpecific) - } - - // MARK: - Manufacturer Matching Tests - - @Test("Seeed Tracker T1000-e maps to trackerT1000E preset") - func seeedTrackerMapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "Seeed Tracker T1000-e") == .trackerT1000E) - } - - @Test("Seeed Wio Tracker L1 maps to seeedWioTracker preset") - func seeedWioTrackerMapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "Seeed Wio Tracker L1") == .seeedWioTracker) - } - - @Test("Seeed SenseCap Solar maps to seeedSolarNode preset") - func seeedSenseCapMapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "Seeed SenseCap Solar") == .seeedSolarNode) - } - - @Test("RAK WisMesh Tag maps to wisMeshTag preset") - func rakWisMeshTagMapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "RAK WisMesh Tag") == .wisMeshTag) - } - - @Test("LilyGo T-Beam 1W maps to lilyGoTBeam1W preset") - func lilyGoTBeam1WMapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "LilyGo T-Beam 1W") == .lilyGoTBeam1W) - } - - @Test("Elecrow ThinkNode M6 maps to thinkNodeM6 preset") - func elecrowThinkNodeM6MapsCorrectly() { - #expect(OCVPreset.preset(forManufacturer: "Elecrow ThinkNode M6") == .thinkNodeM6) - } - - @Test("Unknown manufacturer returns nil") - func unknownManufacturerReturnsNil() { - #expect(OCVPreset.preset(forManufacturer: "Generic ESP32") == nil) - #expect(OCVPreset.preset(forManufacturer: "Heltec MeshPocket") == nil) - #expect(OCVPreset.preset(forManufacturer: "") == nil) - } - - @Test("Manufacturer matching is case-sensitive") - func manufacturerMatchingIsCaseSensitive() { - #expect(OCVPreset.preset(forManufacturer: "seeed tracker t1000-e") == nil) - #expect(OCVPreset.preset(forManufacturer: "SEEED TRACKER T1000-E") == nil) - } + @Test(arguments: OCVPreset.allCases.filter { $0 != .custom }) + func `All presets have exactly 11 values`(preset: OCVPreset) { + #expect(preset.ocvArray.count == 11, "Preset \(preset) should have 11 values") + } + + @Test(arguments: OCVPreset.allCases.filter { $0 != .custom }) + func `All preset arrays are descending`(preset: OCVPreset) { + let array = preset.ocvArray + for i in 0..<(array.count - 1) { + #expect(array[i] > array[i + 1], "Preset \(preset) should be descending at index \(i)") + } + } + + @Test(arguments: OCVPreset.allCases.filter { $0 != .custom }) + func `All presets fit within UI validation range`(preset: OCVPreset) { + for value in preset.ocvArray { + #expect( + OCVPreset.validMillivoltRange.contains(value), + "Preset \(preset) has value \(value) outside \(OCVPreset.validMillivoltRange)" + ) + } + } + + @Test(arguments: OCVPreset.allCases) + func `All presets have display names`(preset: OCVPreset) { + #expect(!preset.displayName.isEmpty, "Preset \(preset) should have a display name") + } + + @Test + func `Selectable presets excludes custom`() { + #expect(!OCVPreset.selectablePresets.contains(.custom)) + #expect(OCVPreset.selectablePresets.count == OCVPreset.allCases.count - 1) + } + + @Test + func `Li-Ion preset has expected values`() { + let expected = [4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100] + #expect(OCVPreset.liIon.ocvArray == expected) + } + + @Test + func `WisMesh Tag preset has expected values`() { + let expected = [4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990] + #expect(OCVPreset.wisMeshTag.ocvArray == expected) + } + + @Test + func `LilyGo T-Beam 1W preset has expected values`() { + let expected = [7950, 7850, 7750, 7580, 7440, 7310, 7150, 7005, 6860, 6685, 6000] + #expect(OCVPreset.lilyGoTBeam1W.ocvArray == expected) + } + + @Test + func `ThinkNode M6 preset has expected values`() { + let expected = [4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3450] + #expect(OCVPreset.thinkNodeM6.ocvArray == expected) + } + + // MARK: - Category Tests + + @Test + func `Battery chemistry presets include only chemistry types`() { + let presets = OCVPreset.batteryChemistryPresets + + #expect(presets.contains(.liIon)) + #expect(presets.contains(.liFePO4)) + #expect(presets.contains(.leadAcid)) + #expect(presets.contains(.alkaline)) + #expect(presets.contains(.niMH)) + #expect(presets.contains(.lto)) + #expect(presets.count == 6) + } + + @Test + func `Battery chemistry presets exclude device-specific presets`() { + let presets = OCVPreset.batteryChemistryPresets + + #expect(!presets.contains(.trackerT1000E)) + #expect(!presets.contains(.heltecPocket5000)) + #expect(!presets.contains(.custom)) + } + + @Test + func `Li-Ion is battery chemistry category`() { + #expect(OCVPreset.liIon.category == .batteryChemistry) + } + + @Test + func `Tracker T1000-E is device specific category`() { + #expect(OCVPreset.trackerT1000E.category == .deviceSpecific) + } + + @Test + func `Custom is device specific category`() { + #expect(OCVPreset.custom.category == .deviceSpecific) + } + + // MARK: - Manufacturer Matching Tests + + @Test + func `Seeed Tracker T1000-e maps to trackerT1000E preset`() { + #expect(OCVPreset.preset(forManufacturer: "Seeed Tracker T1000-e") == .trackerT1000E) + } + + @Test + func `Seeed Wio Tracker L1 maps to seeedWioTracker preset`() { + #expect(OCVPreset.preset(forManufacturer: "Seeed Wio Tracker L1") == .seeedWioTracker) + } + + @Test + func `Seeed SenseCap Solar maps to seeedSolarNode preset`() { + #expect(OCVPreset.preset(forManufacturer: "Seeed SenseCap Solar") == .seeedSolarNode) + } + + @Test + func `RAK WisMesh Tag maps to wisMeshTag preset`() { + #expect(OCVPreset.preset(forManufacturer: "RAK WisMesh Tag") == .wisMeshTag) + } + + @Test + func `LilyGo T-Beam 1W maps to lilyGoTBeam1W preset`() { + #expect(OCVPreset.preset(forManufacturer: "LilyGo T-Beam 1W") == .lilyGoTBeam1W) + } + + @Test + func `Elecrow ThinkNode M6 maps to thinkNodeM6 preset`() { + #expect(OCVPreset.preset(forManufacturer: "Elecrow ThinkNode M6") == .thinkNodeM6) + } + + @Test + func `Unknown manufacturer returns nil`() { + #expect(OCVPreset.preset(forManufacturer: "Generic ESP32") == nil) + #expect(OCVPreset.preset(forManufacturer: "Heltec MeshPocket") == nil) + #expect(OCVPreset.preset(forManufacturer: "") == nil) + } + + @Test + func `Manufacturer matching is case-sensitive`() { + #expect(OCVPreset.preset(forManufacturer: "seeed tracker t1000-e") == nil) + #expect(OCVPreset.preset(forManufacturer: "SEEED TRACKER T1000-E") == nil) + } } diff --git a/MC1Tests/Models/RemoteNodeModelTests.swift b/MC1Tests/Models/RemoteNodeModelTests.swift index 8141453e..8a046cf0 100644 --- a/MC1Tests/Models/RemoteNodeModelTests.swift +++ b/MC1Tests/Models/RemoteNodeModelTests.swift @@ -1,241 +1,240 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("Remote Node Model Tests") struct RemoteNodeModelTests { - - // MARK: - RemoteNodeSession Tests - - @Test("RemoteNodeSession correctly stores role") - func remoteNodeSessionStoresRole() async throws { - let container = try DataStore.createContainer(inMemory: true) - let dataStore = DataStore(modelContainer: container) - - let deviceID = UUID() - let publicKey = Data((0.. WhatsNewVersion(major: 1, minor: 9)) - } + @Test + func `comparison is lexicographic on (major, minor)`() { + #expect(WhatsNewVersion(major: 1, minor: 0) < WhatsNewVersion(major: 1, minor: 1)) + #expect(WhatsNewVersion(major: 1, minor: 9) < WhatsNewVersion(major: 2, minor: 0)) + #expect(WhatsNewVersion(major: 1, minor: 0) < WhatsNewVersion(major: 1, minor: 2)) + #expect(WhatsNewVersion(major: 2, minor: 0) > WhatsNewVersion(major: 1, minor: 9)) + } - @Test("a patch bump is not greater", arguments: [ - ("1.0", "1.0.2"), - ("1.0.0", "1.0.9") - ]) - func patchBumpNotGreater(baseline: String, current: String) { - let lhs = WhatsNewVersion(marketingVersion: current) - let rhs = WhatsNewVersion(marketingVersion: baseline) - #expect(lhs == rhs) - #expect(!(lhs! > rhs!)) - } + @Test(arguments: [ + ("1.0", "1.0.2"), + ("1.0.0", "1.0.9") + ]) + func `a patch bump is not greater`(baseline: String, current: String) throws { + let lhs = WhatsNewVersion(marketingVersion: current) + let rhs = WhatsNewVersion(marketingVersion: baseline) + #expect(lhs == rhs) + #expect(try !(#require(lhs) > rhs!)) + } - @Test("unparseable strings yield nil", arguments: [ - "unknown", "2", "2.0-beta", "1.0 (123)", "", "x.y", "1." - ]) - func unparseableYieldsNil(input: String) { - #expect(WhatsNewVersion(marketingVersion: input) == nil) - } + @Test(arguments: [ + "unknown", "2", "2.0-beta", "1.0 (123)", "", "x.y", "1." + ]) + func `unparseable strings yield nil`(input: String) { + #expect(WhatsNewVersion(marketingVersion: input) == nil) + } } diff --git a/MC1Tests/Protocol/CLIResponseTests.swift b/MC1Tests/Protocol/CLIResponseTests.swift index b094916c..3e44068a 100644 --- a/MC1Tests/Protocol/CLIResponseTests.swift +++ b/MC1Tests/Protocol/CLIResponseTests.swift @@ -1,346 +1,345 @@ -import Testing @testable import MC1Services +import Testing struct CLIResponseTests { - - // MARK: - Prompt Prefix Stripping - - @Test func parse_promptPrefix_stripsPrefix() { - let result = CLIResponse.parse("> OK") - #expect(result == .ok) - } - - @Test func parse_noPromptPrefix_stillWorks() { - let result = CLIResponse.parse("OK") - #expect(result == .ok) - } - - @Test func parse_promptPrefix_withWhitespace() { - let result = CLIResponse.parse(" > OK ") - #expect(result == .ok) - } - - @Test func parse_okWithMessage_clockSet() { - let result = CLIResponse.parse("OK - clock set: 16:13 - 9/1/2026 UTC") - #expect(result == .ok) - } - - @Test func parse_okWithMessage_withPromptPrefix() { - let result = CLIResponse.parse("> OK - clock set: 16:13 - 9/1/2026 UTC") - #expect(result == .ok) - } - - // MARK: - Radio with Prompt - - @Test func parse_radio_withPromptPrefix() { - let result = CLIResponse.parse("> 910.5250244,62.5,7,8", forQuery: "get radio") - if case let .radio(freq, bw, sf, cr) = result { - #expect(abs(freq - 910.5250244) < 0.0001) - #expect(abs(bw - 62.5) < 0.01) - #expect(sf == 7) - #expect(cr == 8) - } else { - Issue.record("Expected .radio, got \(result)") - } - } - - @Test func parse_radio_withoutPromptPrefix() { - let result = CLIResponse.parse("915.000,250.0,10,5", forQuery: "get radio") - if case let .radio(freq, bw, sf, cr) = result { - #expect(abs(freq - 915.0) < 0.0001) - #expect(abs(bw - 250.0) < 0.01) - #expect(sf == 10) - #expect(cr == 5) - } else { - Issue.record("Expected .radio, got \(result)") - } - } - - // MARK: - Name with Prompt - - @Test func parse_name_withPromptPrefix() { - let result = CLIResponse.parse("> Sunnyslope Repeater", forQuery: "get name") - #expect(result == .name("Sunnyslope Repeater")) - } - - @Test func parse_name_withoutPromptPrefix() { - let result = CLIResponse.parse("My Node", forQuery: "get name") - #expect(result == .name("My Node")) - } - - // MARK: - Repeat Mode with Prompt - - @Test func parse_repeatMode_on_withPromptPrefix() { - let result = CLIResponse.parse("> on", forQuery: "get repeat") - #expect(result == .repeatMode(true)) - } - - @Test func parse_repeatMode_off_withPromptPrefix() { - let result = CLIResponse.parse("> off", forQuery: "get repeat") - #expect(result == .repeatMode(false)) - } - - @Test func parse_repeatMode_withoutPromptPrefix() { - let result = CLIResponse.parse("on", forQuery: "get repeat") - #expect(result == .repeatMode(true)) - } - - // MARK: - TX Power with Prompt - - @Test func parse_txPower_withPromptPrefix() { - let result = CLIResponse.parse("> 22", forQuery: "get tx") - #expect(result == .txPower(22)) - } - - @Test func parse_txPower_withoutPromptPrefix() { - let result = CLIResponse.parse("17", forQuery: "get tx") - #expect(result == .txPower(17)) - } - - // MARK: - Coordinates with Prompt - - @Test func parse_latitude_withPromptPrefix() { - let result = CLIResponse.parse("> 33.4484", forQuery: "get lat") - if case let .latitude(lat) = result { - #expect(abs(lat - 33.4484) < 0.0001) - } else { - Issue.record("Expected .latitude, got \(result)") - } - } - - @Test func parse_latitude_withoutPromptPrefix() { - let result = CLIResponse.parse("40.7128", forQuery: "get lat") - if case let .latitude(lat) = result { - #expect(abs(lat - 40.7128) < 0.0001) - } else { - Issue.record("Expected .latitude, got \(result)") - } - } - - @Test func parse_longitude_withPromptPrefix() { - let result = CLIResponse.parse("> -112.0740", forQuery: "get lon") - if case let .longitude(lon) = result { - #expect(abs(lon - (-112.0740)) < 0.0001) - } else { - Issue.record("Expected .longitude, got \(result)") - } - } - - @Test func parse_longitude_withoutPromptPrefix() { - let result = CLIResponse.parse("-74.0060", forQuery: "get lon") - if case let .longitude(lon) = result { - #expect(abs(lon - (-74.0060)) < 0.0001) - } else { - Issue.record("Expected .longitude, got \(result)") - } - } - - // MARK: - Intervals with Prompt - - @Test func parse_advertInterval_withPromptPrefix() { - let result = CLIResponse.parse("> 5", forQuery: "get advert.interval") - #expect(result == .advertInterval(5)) - } - - @Test func parse_advertInterval_withoutPromptPrefix() { - let result = CLIResponse.parse("10", forQuery: "get advert.interval") - #expect(result == .advertInterval(10)) - } - - @Test func parse_floodAdvertInterval_withPromptPrefix() { - let result = CLIResponse.parse("> 12", forQuery: "get flood.advert.interval") - #expect(result == .floodAdvertInterval(12)) - } - - @Test func parse_floodAdvertInterval_withoutPromptPrefix() { - let result = CLIResponse.parse("6", forQuery: "get flood.advert.interval") - #expect(result == .floodAdvertInterval(6)) - } - - @Test func parse_floodMax_withPromptPrefix() { - let result = CLIResponse.parse("> 3", forQuery: "get flood.max") - #expect(result == .floodMax(3)) - } - - @Test func parse_floodMax_withoutPromptPrefix() { - let result = CLIResponse.parse("4", forQuery: "get flood.max") - #expect(result == .floodMax(4)) - } - - // MARK: - Error with Prompt - - @Test func parse_error_withPromptPrefix() { - let result = CLIResponse.parse("> Error: permission denied") - if case let .error(msg) = result { - #expect(msg == "Error: permission denied") - } else { - Issue.record("Expected .error, got \(result)") - } - } - - @Test func parse_error_withoutPromptPrefix() { - let result = CLIResponse.parse("Error: invalid value") - if case let .error(msg) = result { - #expect(msg == "Error: invalid value") - } else { - Issue.record("Expected .error, got \(result)") - } - } - - @Test func parse_unknownCommand_withPromptPrefix() { - let result = CLIResponse.parse("> Error: unknown command") - #expect(result == .unknownCommand("Error: unknown command")) - } - - // MARK: - Version with Prompt - - @Test func parse_version_withPromptPrefix() { - let result = CLIResponse.parse("> MeshCore v1.10.0 (2025-04-18)") - #expect(result == .version("MeshCore v1.10.0 (2025-04-18)")) - } - - @Test func parse_version_withoutPromptPrefix() { - let result = CLIResponse.parse("MeshCore v1.11.0 (2025-05-01)") - #expect(result == .version("MeshCore v1.11.0 (2025-05-01)")) - } - - @Test func parse_version_shortFormat_withPromptPrefix() { - let result = CLIResponse.parse("> v1.12.0 (2025-06-15)") - #expect(result == .version("v1.12.0 (2025-06-15)")) - } - - @Test func parse_version_nonStandardFormat_withQueryHint() { - let result = CLIResponse.parse("1.11.0-letsmesh.net-dev-2026-01-06-09005fa (Build: 06-Jan-2026)", forQuery: "ver") - #expect(result == .version("1.11.0-letsmesh.net-dev-2026-01-06-09005fa (Build: 06-Jan-2026)")) - } - - // MARK: - Device Time with Prompt - - @Test func parse_deviceTime_withPromptPrefix() { - let result = CLIResponse.parse("> 06:40 - 18/4/2025 UTC") - #expect(result == .deviceTime("06:40 - 18/4/2025 UTC")) - } - - @Test func parse_deviceTime_withoutPromptPrefix() { - let result = CLIResponse.parse("14:30 - 25/12/2025 UTC") - #expect(result == .deviceTime("14:30 - 25/12/2025 UTC")) - } - - // MARK: - Owner Info - - @Test func parse_ownerInfo_plainText() { - let result = CLIResponse.parse("some|pipe|text", forQuery: "get owner.info") - #expect(result == .ownerInfo("some|pipe|text")) - } - - @Test func parse_ownerInfo_withPromptPrefix() { - let result = CLIResponse.parse("> 915MHz|KD7ABC|example.com", forQuery: "get owner.info") - #expect(result == .ownerInfo("915MHz|KD7ABC|example.com")) - } - - @Test func parse_ownerInfo_empty() { - let result = CLIResponse.parse("", forQuery: "get owner.info") - #expect(result == .ownerInfo("")) - } - - @Test func parse_ownerInfo_barePrompt() { - // Firmware returns just "> " when owner.info is empty - let result = CLIResponse.parse("> ", forQuery: "get owner.info") - #expect(result == .ownerInfo("")) - } - - @Test func parse_ownerInfo_singleLine() { - let result = CLIResponse.parse("just a name", forQuery: "get owner.info") - #expect(result == .ownerInfo("just a name")) - } - - @Test func parse_ownerInfo_withColonAndSlash() { - // Contact info with ":" and "/" was misclassified as deviceTime - let result = CLIResponse.parse("> Contact: KD7ABC / 145.230", forQuery: "get owner.info") - #expect(result == .ownerInfo("Contact: KD7ABC / 145.230")) - } - - @Test func parse_name_withColonAndSlash() { - // Name with ":" and "/" was misclassified as deviceTime - let result = CLIResponse.parse("> Repeater: East / West", forQuery: "get name") - #expect(result == .name("Repeater: East / West")) - } - - @Test func parse_deviceTime_stillWorks_withoutQueryHint() { - let result = CLIResponse.parse("06:40 - 18/4/2025 UTC") - #expect(result == .deviceTime("06:40 - 18/4/2025 UTC")) - } - - // MARK: - Edge Cases - - @Test func parse_greaterThanInContent_notStripped() { - // Content that starts with ">" but not "> " should not be stripped - let result = CLIResponse.parse(">nosuchcommand", forQuery: "get name") - #expect(result == .name(">nosuchcommand")) - } - - @Test func parse_multipleGreaterThan_onlyFirstStripped() { - // Only the first "> " should be stripped - let result = CLIResponse.parse("> > nested prompt", forQuery: "get name") - #expect(result == .name("> nested prompt")) - } - - @Test func parse_emptyAfterStrip() { - // When input is "> ", trimming whitespace gives ">", which is the bare - // CLI prompt character — treated as empty content - let result = CLIResponse.parse("> ") - #expect(result == .raw("")) - } - - @Test func parse_justPrompt() { - // Just the prompt character without space — treated as empty content - let result = CLIResponse.parse(">") - #expect(result == .raw("")) - } - - // MARK: - Query Hint Matching (Integration) - - /// These tests verify that query hint matching correctly identifies response types - /// even when responses have the prompt prefix. This tests the full flow that - /// RepeaterSettingsViewModel.handleCLIResponse() uses. - - @Test func queryHintMatching_longitude_withPromptPrefix() { - // Simulate the query hint matching logic from handleCLIResponse - var trimmedText = "> -120.338211".trimmingCharacters(in: .whitespacesAndNewlines) - - // Strip prompt - this is what we're fixing - if trimmedText.hasPrefix("> ") { - trimmedText = String(trimmedText.dropFirst(2)) - } - - // Now the query matching pattern should work - let isValidDouble = Double(trimmedText) != nil && !trimmedText.contains(",") - #expect(isValidDouble == true) - - // And parsing should succeed - let result = CLIResponse.parse("> -120.338211", forQuery: "get lon") - if case let .longitude(lon) = result { - #expect(abs(lon - (-120.338211)) < 0.0001) - } else { - Issue.record("Expected .longitude, got \(result)") - } - } - - @Test func queryHintMatching_repeatMode_withPromptPrefix() { - // Simulate the query hint matching logic from handleCLIResponse - var trimmedText = "> on".trimmingCharacters(in: .whitespacesAndNewlines) - - // Strip prompt - this is what we're fixing - if trimmedText.hasPrefix("> ") { - trimmedText = String(trimmedText.dropFirst(2)) - } - - // Now the query matching pattern should work - let isValidRepeatMode = trimmedText.lowercased() == "on" || trimmedText.lowercased() == "off" - #expect(isValidRepeatMode == true) - - // And parsing should succeed - let result = CLIResponse.parse("> on", forQuery: "get repeat") - #expect(result == .repeatMode(true)) - } + // MARK: - Prompt Prefix Stripping + + @Test func `parse prompt prefix strips prefix`() { + let result = CLIResponse.parse("> OK") + #expect(result == .ok) + } + + @Test func `parse no prompt prefix still works`() { + let result = CLIResponse.parse("OK") + #expect(result == .ok) + } + + @Test func `parse prompt prefix with whitespace`() { + let result = CLIResponse.parse(" > OK ") + #expect(result == .ok) + } + + @Test func `parse ok with message clock set`() { + let result = CLIResponse.parse("OK - clock set: 16:13 - 9/1/2026 UTC") + #expect(result == .ok) + } + + @Test func `parse ok with message with prompt prefix`() { + let result = CLIResponse.parse("> OK - clock set: 16:13 - 9/1/2026 UTC") + #expect(result == .ok) + } + + // MARK: - Radio with Prompt + + @Test func `parse radio with prompt prefix`() { + let result = CLIResponse.parse("> 910.5250244,62.5,7,8", forQuery: "get radio") + if case let .radio(freq, bw, sf, cr) = result { + #expect(abs(freq - 910.5250244) < 0.0001) + #expect(abs(bw - 62.5) < 0.01) + #expect(sf == 7) + #expect(cr == 8) + } else { + Issue.record("Expected .radio, got \(result)") + } + } + + @Test func `parse radio without prompt prefix`() { + let result = CLIResponse.parse("915.000,250.0,10,5", forQuery: "get radio") + if case let .radio(freq, bw, sf, cr) = result { + #expect(abs(freq - 915.0) < 0.0001) + #expect(abs(bw - 250.0) < 0.01) + #expect(sf == 10) + #expect(cr == 5) + } else { + Issue.record("Expected .radio, got \(result)") + } + } + + // MARK: - Name with Prompt + + @Test func `parse name with prompt prefix`() { + let result = CLIResponse.parse("> Sunnyslope Repeater", forQuery: "get name") + #expect(result == .name("Sunnyslope Repeater")) + } + + @Test func `parse name without prompt prefix`() { + let result = CLIResponse.parse("My Node", forQuery: "get name") + #expect(result == .name("My Node")) + } + + // MARK: - Repeat Mode with Prompt + + @Test func `parse repeat mode on with prompt prefix`() { + let result = CLIResponse.parse("> on", forQuery: "get repeat") + #expect(result == .repeatMode(true)) + } + + @Test func `parse repeat mode off with prompt prefix`() { + let result = CLIResponse.parse("> off", forQuery: "get repeat") + #expect(result == .repeatMode(false)) + } + + @Test func `parse repeat mode without prompt prefix`() { + let result = CLIResponse.parse("on", forQuery: "get repeat") + #expect(result == .repeatMode(true)) + } + + // MARK: - TX Power with Prompt + + @Test func `parse tx power with prompt prefix`() { + let result = CLIResponse.parse("> 22", forQuery: "get tx") + #expect(result == .txPower(22)) + } + + @Test func `parse tx power without prompt prefix`() { + let result = CLIResponse.parse("17", forQuery: "get tx") + #expect(result == .txPower(17)) + } + + // MARK: - Coordinates with Prompt + + @Test func `parse latitude with prompt prefix`() { + let result = CLIResponse.parse("> 33.4484", forQuery: "get lat") + if case let .latitude(lat) = result { + #expect(abs(lat - 33.4484) < 0.0001) + } else { + Issue.record("Expected .latitude, got \(result)") + } + } + + @Test func `parse latitude without prompt prefix`() { + let result = CLIResponse.parse("40.7128", forQuery: "get lat") + if case let .latitude(lat) = result { + #expect(abs(lat - 40.7128) < 0.0001) + } else { + Issue.record("Expected .latitude, got \(result)") + } + } + + @Test func `parse longitude with prompt prefix`() { + let result = CLIResponse.parse("> -112.0740", forQuery: "get lon") + if case let .longitude(lon) = result { + #expect(abs(lon - -112.0740) < 0.0001) + } else { + Issue.record("Expected .longitude, got \(result)") + } + } + + @Test func `parse longitude without prompt prefix`() { + let result = CLIResponse.parse("-74.0060", forQuery: "get lon") + if case let .longitude(lon) = result { + #expect(abs(lon - -74.0060) < 0.0001) + } else { + Issue.record("Expected .longitude, got \(result)") + } + } + + // MARK: - Intervals with Prompt + + @Test func `parse advert interval with prompt prefix`() { + let result = CLIResponse.parse("> 5", forQuery: "get advert.interval") + #expect(result == .advertInterval(5)) + } + + @Test func `parse advert interval without prompt prefix`() { + let result = CLIResponse.parse("10", forQuery: "get advert.interval") + #expect(result == .advertInterval(10)) + } + + @Test func `parse flood advert interval with prompt prefix`() { + let result = CLIResponse.parse("> 12", forQuery: "get flood.advert.interval") + #expect(result == .floodAdvertInterval(12)) + } + + @Test func `parse flood advert interval without prompt prefix`() { + let result = CLIResponse.parse("6", forQuery: "get flood.advert.interval") + #expect(result == .floodAdvertInterval(6)) + } + + @Test func `parse flood max with prompt prefix`() { + let result = CLIResponse.parse("> 3", forQuery: "get flood.max") + #expect(result == .floodMax(3)) + } + + @Test func `parse flood max without prompt prefix`() { + let result = CLIResponse.parse("4", forQuery: "get flood.max") + #expect(result == .floodMax(4)) + } + + // MARK: - Error with Prompt + + @Test func `parse error with prompt prefix`() { + let result = CLIResponse.parse("> Error: permission denied") + if case let .error(msg) = result { + #expect(msg == "Error: permission denied") + } else { + Issue.record("Expected .error, got \(result)") + } + } + + @Test func `parse error without prompt prefix`() { + let result = CLIResponse.parse("Error: invalid value") + if case let .error(msg) = result { + #expect(msg == "Error: invalid value") + } else { + Issue.record("Expected .error, got \(result)") + } + } + + @Test func `parse unknown command with prompt prefix`() { + let result = CLIResponse.parse("> Error: unknown command") + #expect(result == .unknownCommand("Error: unknown command")) + } + + // MARK: - Version with Prompt + + @Test func `parse version with prompt prefix`() { + let result = CLIResponse.parse("> MeshCore v1.10.0 (2025-04-18)") + #expect(result == .version("MeshCore v1.10.0 (2025-04-18)")) + } + + @Test func `parse version without prompt prefix`() { + let result = CLIResponse.parse("MeshCore v1.11.0 (2025-05-01)") + #expect(result == .version("MeshCore v1.11.0 (2025-05-01)")) + } + + @Test func `parse version short format with prompt prefix`() { + let result = CLIResponse.parse("> v1.12.0 (2025-06-15)") + #expect(result == .version("v1.12.0 (2025-06-15)")) + } + + @Test func `parse version non standard format with query hint`() { + let result = CLIResponse.parse("1.11.0-letsmesh.net-dev-2026-01-06-09005fa (Build: 06-Jan-2026)", forQuery: "ver") + #expect(result == .version("1.11.0-letsmesh.net-dev-2026-01-06-09005fa (Build: 06-Jan-2026)")) + } + + // MARK: - Device Time with Prompt + + @Test func `parse device time with prompt prefix`() { + let result = CLIResponse.parse("> 06:40 - 18/4/2025 UTC") + #expect(result == .deviceTime("06:40 - 18/4/2025 UTC")) + } + + @Test func `parse device time without prompt prefix`() { + let result = CLIResponse.parse("14:30 - 25/12/2025 UTC") + #expect(result == .deviceTime("14:30 - 25/12/2025 UTC")) + } + + // MARK: - Owner Info + + @Test func `parse owner info plain text`() { + let result = CLIResponse.parse("some|pipe|text", forQuery: "get owner.info") + #expect(result == .ownerInfo("some|pipe|text")) + } + + @Test func `parse owner info with prompt prefix`() { + let result = CLIResponse.parse("> 915MHz|KD7ABC|example.com", forQuery: "get owner.info") + #expect(result == .ownerInfo("915MHz|KD7ABC|example.com")) + } + + @Test func `parse owner info empty`() { + let result = CLIResponse.parse("", forQuery: "get owner.info") + #expect(result == .ownerInfo("")) + } + + @Test func `parse owner info bare prompt`() { + // Firmware returns just "> " when owner.info is empty + let result = CLIResponse.parse("> ", forQuery: "get owner.info") + #expect(result == .ownerInfo("")) + } + + @Test func `parse owner info single line`() { + let result = CLIResponse.parse("just a name", forQuery: "get owner.info") + #expect(result == .ownerInfo("just a name")) + } + + @Test func `parse owner info with colon and slash`() { + // Contact info with ":" and "/" was misclassified as deviceTime + let result = CLIResponse.parse("> Contact: KD7ABC / 145.230", forQuery: "get owner.info") + #expect(result == .ownerInfo("Contact: KD7ABC / 145.230")) + } + + @Test func `parse name with colon and slash`() { + // Name with ":" and "/" was misclassified as deviceTime + let result = CLIResponse.parse("> Repeater: East / West", forQuery: "get name") + #expect(result == .name("Repeater: East / West")) + } + + @Test func `parse device time still works without query hint`() { + let result = CLIResponse.parse("06:40 - 18/4/2025 UTC") + #expect(result == .deviceTime("06:40 - 18/4/2025 UTC")) + } + + // MARK: - Edge Cases + + @Test func `parse greater than in content not stripped`() { + // Content that starts with ">" but not "> " should not be stripped + let result = CLIResponse.parse(">nosuchcommand", forQuery: "get name") + #expect(result == .name(">nosuchcommand")) + } + + @Test func `parse multiple greater than only first stripped`() { + // Only the first "> " should be stripped + let result = CLIResponse.parse("> > nested prompt", forQuery: "get name") + #expect(result == .name("> nested prompt")) + } + + @Test func `parse empty after strip`() { + // When input is "> ", trimming whitespace gives ">", which is the bare + // CLI prompt character — treated as empty content + let result = CLIResponse.parse("> ") + #expect(result == .raw("")) + } + + @Test func `parse just prompt`() { + // Just the prompt character without space — treated as empty content + let result = CLIResponse.parse(">") + #expect(result == .raw("")) + } + + // MARK: - Query Hint Matching (Integration) + + // These tests verify that query hint matching correctly identifies response types + // even when responses have the prompt prefix. This tests the full flow that + // RepeaterSettingsViewModel.handleCLIResponse() uses. + + @Test func `query hint matching longitude with prompt prefix`() { + // Simulate the query hint matching logic from handleCLIResponse + var trimmedText = "> -120.338211".trimmingCharacters(in: .whitespacesAndNewlines) + + // Strip prompt - this is what we're fixing + if trimmedText.hasPrefix("> ") { + trimmedText = String(trimmedText.dropFirst(2)) + } + + // Now the query matching pattern should work + let isValidDouble = Double(trimmedText) != nil && !trimmedText.contains(",") + #expect(isValidDouble == true) + + // And parsing should succeed + let result = CLIResponse.parse("> -120.338211", forQuery: "get lon") + if case let .longitude(lon) = result { + #expect(abs(lon - -120.338211) < 0.0001) + } else { + Issue.record("Expected .longitude, got \(result)") + } + } + + @Test func `query hint matching repeat mode with prompt prefix`() { + // Simulate the query hint matching logic from handleCLIResponse + var trimmedText = "> on".trimmingCharacters(in: .whitespacesAndNewlines) + + // Strip prompt - this is what we're fixing + if trimmedText.hasPrefix("> ") { + trimmedText = String(trimmedText.dropFirst(2)) + } + + // Now the query matching pattern should work + let isValidRepeatMode = trimmedText.lowercased() == "on" || trimmedText.lowercased() == "off" + #expect(isValidRepeatMode == true) + + // And parsing should succeed + let result = CLIResponse.parse("> on", forQuery: "get repeat") + #expect(result == .repeatMode(true)) + } } diff --git a/MC1Tests/Protocol/LPPDataPointDisplayTests.swift b/MC1Tests/Protocol/LPPDataPointDisplayTests.swift index df414994..09090803 100644 --- a/MC1Tests/Protocol/LPPDataPointDisplayTests.swift +++ b/MC1Tests/Protocol/LPPDataPointDisplayTests.swift @@ -1,124 +1,123 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("LPP Data Point Display Tests") struct LPPDataPointDisplayTests { + // MARK: - Battery Percentage Tests - // MARK: - Battery Percentage Tests + @Test + func `Battery percentage at minimum voltage (3.0V) returns 0%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.0)) - @Test("Battery percentage at minimum voltage (3.0V) returns 0%") - func batteryPercentage3_0VReturns0() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.0)) - - #expect(dataPoint.batteryPercentage == 0) - } + #expect(dataPoint.batteryPercentage == 0) + } - @Test("Battery percentage at maximum voltage (4.2V) returns 100%") - func batteryPercentage4_2VReturns100() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(4.2)) + @Test + func `Battery percentage at maximum voltage (4.2V) returns 100%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(4.2)) - #expect(dataPoint.batteryPercentage == 100) - } + #expect(dataPoint.batteryPercentage == 100) + } - @Test("Battery percentage at midpoint voltage (3.6V) returns approximately 50%") - func batteryPercentage3_6VReturns50() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.6)) + @Test + func `Battery percentage at midpoint voltage (3.6V) returns approximately 50%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.6)) - #expect(dataPoint.batteryPercentage == 50) - } + #expect(dataPoint.batteryPercentage == 50) + } - @Test("Battery percentage at 3.9V returns 75%") - func batteryPercentage3_9VReturns75() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.9)) + @Test + func `Battery percentage at 3.9V returns 75%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.9)) - #expect(dataPoint.batteryPercentage == 75) - } + #expect(dataPoint.batteryPercentage == 75) + } - @Test("Battery percentage below minimum voltage clamps to 0%") - func batteryPercentageBelowMinClampsTo0() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(2.5)) + @Test + func `Battery percentage below minimum voltage clamps to 0%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(2.5)) - #expect(dataPoint.batteryPercentage == 0) - } + #expect(dataPoint.batteryPercentage == 0) + } - @Test("Battery percentage above maximum voltage clamps to 100%") - func batteryPercentageAboveMaxClampsTo100() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(5.0)) + @Test + func `Battery percentage above maximum voltage clamps to 100%`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(5.0)) - #expect(dataPoint.batteryPercentage == 100) - } + #expect(dataPoint.batteryPercentage == 100) + } - @Test("Battery percentage for non-voltage type returns nil") - func batteryPercentageNonVoltageReturnsNil() { - let dataPoint = LPPDataPoint(channel: 0, type: .temperature, value: .float(25.0)) + @Test + func `Battery percentage for non-voltage type returns nil`() { + let dataPoint = LPPDataPoint(channel: 0, type: .temperature, value: .float(25.0)) - #expect(dataPoint.batteryPercentage == nil) - } + #expect(dataPoint.batteryPercentage == nil) + } - @Test("Battery percentage for integer value returns nil") - func batteryPercentageIntegerValueReturnsNil() { - // Voltage should be float, but test edge case where it's not - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .integer(4)) + @Test + func `Battery percentage for integer value returns nil`() { + // Voltage should be float, but test edge case where it's not + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .integer(4)) - #expect(dataPoint.batteryPercentage == nil) - } + #expect(dataPoint.batteryPercentage == nil) + } - @Test("Battery percentage for percentage type returns nil") - func batteryPercentageForPercentageTypeReturnsNil() { - let dataPoint = LPPDataPoint(channel: 0, type: .percentage, value: .integer(75)) + @Test + func `Battery percentage for percentage type returns nil`() { + let dataPoint = LPPDataPoint(channel: 0, type: .percentage, value: .integer(75)) - #expect(dataPoint.batteryPercentage == nil) - } + #expect(dataPoint.batteryPercentage == nil) + } - // MARK: - Formatted Value Tests + // MARK: - Formatted Value Tests - @Test("Voltage formatted value includes V suffix") - func voltageFormattedValueIncludesVSuffix() { - let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.85)) + @Test + func `Voltage formatted value includes V suffix`() { + let dataPoint = LPPDataPoint(channel: 0, type: .voltage, value: .float(3.85)) - #expect(dataPoint.formattedValue == "\(3.85.formatted(.number.precision(.fractionLength(3)))) V") - } + #expect(dataPoint.formattedValue == "\(3.85.formatted(.number.precision(.fractionLength(3)))) V") + } - @Test("Temperature formatted value uses locale-appropriate unit") - func temperatureFormattedValueUsesLocaleUnit() { - let dataPoint = LPPDataPoint(channel: 0, type: .temperature, value: .float(25.5)) - let sensorType = LPPSensorType.temperature - let converted = sensorType.convertedValue(25.5) - let precision = sensorType.convertedFractionLength - let expected = "\(converted.formatted(.number.precision(.fractionLength(precision)))) \(sensorType.localizedUnitSymbol)" + @Test + func `Temperature formatted value uses locale-appropriate unit`() { + let dataPoint = LPPDataPoint(channel: 0, type: .temperature, value: .float(25.5)) + let sensorType = LPPSensorType.temperature + let converted = sensorType.convertedValue(25.5) + let precision = sensorType.convertedFractionLength + let expected = "\(converted.formatted(.number.precision(.fractionLength(precision)))) \(sensorType.localizedUnitSymbol)" - #expect(dataPoint.formattedValue == expected) - } + #expect(dataPoint.formattedValue == expected) + } - // MARK: - Locale Conversion Tests - - @Test("Temperature conversion from Celsius to Fahrenheit") - func temperatureConversion() { - let celsius = 25.0 - let converted = LPPSensorType.temperature.convertedValue(celsius) - if Locale.current.measurementSystem == .metric { - #expect(converted == celsius) - } else { - #expect(abs(converted - 77.0) < 0.01) - } - } + // MARK: - Locale Conversion Tests - @Test("Voltage is not locale-sensitive") - func voltageNotLocaleSensitive() { - let volts = 3.85 - let converted = LPPSensorType.voltage.convertedValue(volts) - #expect(converted == volts) - #expect(LPPSensorType.voltage.localizedUnitSymbol == "V") + @Test + func `Temperature conversion from Celsius to Fahrenheit`() { + let celsius = 25.0 + let converted = LPPSensorType.temperature.convertedValue(celsius) + if Locale.current.measurementSystem == .metric { + #expect(converted == celsius) + } else { + #expect(abs(converted - 77.0) < 0.01) } - - @Test("Altitude uses locale-appropriate unit symbol") - func altitudeLocalizedUnit() { - let symbol = LPPSensorType.altitude.localizedUnitSymbol - if Locale.current.measurementSystem == .metric { - #expect(symbol == "m") - } else { - #expect(symbol == "ft") - } + } + + @Test + func `Voltage is not locale-sensitive`() { + let volts = 3.85 + let converted = LPPSensorType.voltage.convertedValue(volts) + #expect(converted == volts) + #expect(LPPSensorType.voltage.localizedUnitSymbol == "V") + } + + @Test + func `Altitude uses locale-appropriate unit symbol`() { + let symbol = LPPSensorType.altitude.localizedUnitSymbol + if Locale.current.measurementSystem == .metric { + #expect(symbol == "m") + } else { + #expect(symbol == "ft") } + } } diff --git a/MC1Tests/RefundLinkSectionTests.swift b/MC1Tests/RefundLinkSectionTests.swift index a5305f45..59cd8668 100644 --- a/MC1Tests/RefundLinkSectionTests.swift +++ b/MC1Tests/RefundLinkSectionTests.swift @@ -1,9 +1,9 @@ -import Testing -import SwiftUI +@testable import MC1 +@testable import MC1Services import StoreKit import StoreKitTest -@testable import MC1Services -@testable import MC1 +import SwiftUI +import Testing /// SKTestSession-driven coverage for `RefundLinkSection.latestRefundableTransactionID()`. /// Only the bundle is purchasable, so it is the only refundable product. `Transaction.latest(for:)` @@ -12,68 +12,68 @@ import StoreKitTest @MainActor @Suite("RefundLinkSection helpers", .serialized, .enabled(if: StoreKitTestAvailability.servesProducts)) final class RefundLinkSectionTests { - let session: SKTestSession - - init() throws { - session = try SKTestSession(configurationFileNamed: "MC1") - session.disableDialogs = true - // Reset Ask-to-Buy: SKTestSession does not clear this on storekitd across instances. - session.askToBuyEnabled = false - session.clearTransactions() - } + let session: SKTestSession - deinit { session.clearTransactions() } + init() throws { + session = try SKTestSession(configurationFileNamed: "MC1") + session.disableDialogs = true + // Reset Ask-to-Buy: SKTestSession does not clear this on storekitd across instances. + session.askToBuyEnabled = false + session.clearTransactions() + } - @Test("latestRefundableTransactionID excludes a refunded (revoked) bundle transaction") - func latestRefundableTransactionIDExcludesRevoked() async throws { - // Purchase the bundle via product.purchase() (synchronously consistent, unlike - // session.buyProduct() which is eventually consistent under storekitd churn). - let bundle = try #require( - try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first - ) - try await purchaseUnfinished(bundle) + deinit { session.clearTransactions() } - // Confirm the helper finds the verified, non-revoked purchase before refunding so a - // post-refund nil is provably from the revocation filter, not from a missing setup. - let section = RefundLinkSection() - let preRefundID = await section.latestRefundableTransactionID() - #expect(preRefundID != nil) + @Test + func `latestRefundableTransactionID excludes a refunded (revoked) bundle transaction`() async throws { + // Purchase the bundle via product.purchase() (synchronously consistent, unlike + // session.buyProduct() which is eventually consistent under storekitd churn). + let bundle = try #require( + try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first + ) + try await purchaseUnfinished(bundle) - let txn = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Theme.bundleAll - }) - try session.refundTransaction(identifier: txn.identifier) + // Confirm the helper finds the verified, non-revoked purchase before refunding so a + // post-refund nil is provably from the revocation filter, not from a missing setup. + let section = RefundLinkSection() + let preRefundID = await section.latestRefundableTransactionID() + #expect(preRefundID != nil) - // Wait for refund propagation: Transaction.latest's revocationDate is the field - // RefundLinkSection.latestRefundableTransactionID guards on. - try await waitUntil(timeout: .seconds(5)) { - guard case .verified(let latest) = - await Transaction.latest(for: StoreCatalog.Theme.bundleAll) - else { return false } - return latest.revocationDate != nil - } + let txn = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Theme.bundleAll + }) + try session.refundTransaction(identifier: txn.identifier) - let postRefundID = await section.latestRefundableTransactionID() - #expect(postRefundID == nil) + // Wait for refund propagation: Transaction.latest's revocationDate is the field + // RefundLinkSection.latestRefundableTransactionID guards on. + try await waitUntil(timeout: .seconds(5)) { + guard case let .verified(latest) = + await Transaction.latest(for: StoreCatalog.Theme.bundleAll) + else { return false } + return latest.revocationDate != nil } - /// Commits a purchase through `product.purchase()` and leaves the transaction unfinished; - /// retries the transient `StoreKitError.unknown` storekitd raises under SKTestSession churn. - /// Same shape as `StoreServiceTests.purchaseUnfinished` — duplicated locally because this - /// suite is decoupled from `StoreService`. - private func purchaseUnfinished(_ product: Product, attempts: Int = 4) async throws { - for attempt in 1...attempts { - do { - let result = try await product.purchase() - guard case .success = result else { - throw RefundLinkTestError.purchaseDidNotSucceed - } - return - } catch let error as StoreKitError { - guard case .unknown = error, attempt < attempts else { throw error } - } + let postRefundID = await section.latestRefundableTransactionID() + #expect(postRefundID == nil) + } + + /// Commits a purchase through `product.purchase()` and leaves the transaction unfinished; + /// retries the transient `StoreKitError.unknown` storekitd raises under SKTestSession churn. + /// Same shape as `StoreServiceTests.purchaseUnfinished` — duplicated locally because this + /// suite is decoupled from `StoreService`. + private func purchaseUnfinished(_ product: Product, attempts: Int = 4) async throws { + for attempt in 1...attempts { + do { + let result = try await product.purchase() + guard case .success = result else { + throw RefundLinkTestError.purchaseDidNotSucceed } + return + } catch let error as StoreKitError { + guard case .unknown = error, attempt < attempts else { throw error } + } } + } } private enum RefundLinkTestError: Error { case purchaseDidNotSucceed } diff --git a/MC1Tests/Services/DecodedPreviewCacheTests.swift b/MC1Tests/Services/DecodedPreviewCacheTests.swift index 3fd7e178..9e1d254a 100644 --- a/MC1Tests/Services/DecodedPreviewCacheTests.swift +++ b/MC1Tests/Services/DecodedPreviewCacheTests.swift @@ -1,139 +1,138 @@ import Foundation +@testable import MC1 +@testable import MC1Services import Testing import UIKit -@testable import MC1Services -@testable import MC1 @Suite("DecodedPreviewCache Tests") struct DecodedPreviewCacheTests { - - @Test("Returns nil for an unseen URL") - func decodedReturnsNilForMissingKey() { - let cache = DecodedPreviewCache() - #expect(cache.decoded(for: uniqueURL()) == nil) - } - - @Test("Round-trips a stored entry with DTO, hero, and icon") - @MainActor - func decodedRoundTrip() { - let cache = DecodedPreviewCache() - let url = uniqueURL() - let dto = Self.makeDTO(url: url.absoluteString) - let hero = Self.makeImage() - let icon = Self.makeImage() - cache.store(CachedDecodedPreview(dto: dto, hero: hero, icon: icon), for: url) - - let result = cache.decoded(for: url) - #expect(result?.dto.url == dto.url) - #expect(result?.dto.title == dto.title) - #expect(result?.hero === hero) - #expect(result?.icon === icon) - } - - @Test("Tolerates a nil hero or icon") - @MainActor - func decodedToleratesNilAssets() { - let cache = DecodedPreviewCache() - let url = uniqueURL() - let dto = Self.makeDTO(url: url.absoluteString) - cache.store(CachedDecodedPreview(dto: dto, hero: nil, icon: Self.makeImage()), for: url) - - let result = cache.decoded(for: url) - #expect(result?.hero == nil) - #expect(result?.icon != nil) - } - - @Test("Re-storing the same key replaces the entry") - @MainActor - func decodedReplaceSameKey() { - let cache = DecodedPreviewCache() - let url = uniqueURL() - let first = CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString, title: "first"), hero: Self.makeImage(), icon: nil) - let second = CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString, title: "second"), hero: Self.makeImage(), icon: nil) - - cache.store(first, for: url) - cache.store(second, for: url) - - let result = cache.decoded(for: url) - #expect(result?.hero === second.hero) - #expect(result?.dto.title == "second") - } - - @Test("Strips raw image and icon bytes from the cached DTO") - @MainActor - func stripsRawBytesFromDTO() { - let dto = LinkPreviewDataDTO( - url: "https://example.invalid/strip", - title: "Title", - imageData: Data([0x01, 0x02, 0x03]), - iconData: Data([0x04, 0x05]), - imageWidth: 100, - imageHeight: 50 - ) - let entry = CachedDecodedPreview(dto: dto, hero: Self.makeImage(), icon: Self.makeImage()) - - #expect(entry.dto.imageData == nil) - #expect(entry.dto.iconData == nil) - // Metadata the rehydration render path reads is preserved. - #expect(entry.dto.url == dto.url) - #expect(entry.dto.title == dto.title) - #expect(entry.dto.imageWidth == 100) - #expect(entry.dto.imageHeight == 50) - } - - @Test("Cost reflects decoded pixel bytes") - @MainActor - func costIsPixelBytes() { - let dto = Self.makeDTO(url: "https://example.invalid/cost") - let entry = CachedDecodedPreview(dto: dto, hero: Self.makeImage(width: 100, height: 50), icon: nil) - // 100x50 RGBA bitmap is 100 * 50 * 4 = 20_000 bytes minimum; - // bytesPerRow may be padded for alignment, so assert a lower bound. - #expect(entry.cost >= 20_000) - } - - @Test("clear() empties the cache") - @MainActor - func clearEmpties() { - let cache = DecodedPreviewCache() - let url = uniqueURL() - cache.store(CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString), hero: Self.makeImage(), icon: nil), for: url) - #expect(cache.decoded(for: url) != nil) - - cache.clear() - #expect(cache.decoded(for: url) == nil) - } - - @Test("FIFO eviction drops the oldest entry past the count cap") - @MainActor - func fifoEvictsOldest() { - let cache = DecodedPreviewCache() - // maxEntryCount is 50; store 60 tiny entries so the first 10 evict. - var urls: [URL] = [] - for _ in 0..<60 { - let url = uniqueURL() - urls.append(url) - cache.store(CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString), hero: Self.makeImage(), icon: nil), for: url) - } - #expect(cache.decoded(for: urls.first!) == nil) - #expect(cache.decoded(for: urls.last!) != nil) - } - - // MARK: - Helpers - - private func uniqueURL() -> URL { - URL(string: "https://example.invalid/\(UUID().uuidString)")! + @Test + func `Returns nil for an unseen URL`() { + let cache = DecodedPreviewCache() + #expect(cache.decoded(for: uniqueURL()) == nil) + } + + @Test + @MainActor + func `Round-trips a stored entry with DTO, hero, and icon`() { + let cache = DecodedPreviewCache() + let url = uniqueURL() + let dto = Self.makeDTO(url: url.absoluteString) + let hero = Self.makeImage() + let icon = Self.makeImage() + cache.store(CachedDecodedPreview(dto: dto, hero: hero, icon: icon), for: url) + + let result = cache.decoded(for: url) + #expect(result?.dto.url == dto.url) + #expect(result?.dto.title == dto.title) + #expect(result?.hero === hero) + #expect(result?.icon === icon) + } + + @Test + @MainActor + func `Tolerates a nil hero or icon`() { + let cache = DecodedPreviewCache() + let url = uniqueURL() + let dto = Self.makeDTO(url: url.absoluteString) + cache.store(CachedDecodedPreview(dto: dto, hero: nil, icon: Self.makeImage()), for: url) + + let result = cache.decoded(for: url) + #expect(result?.hero == nil) + #expect(result?.icon != nil) + } + + @Test + @MainActor + func `Re-storing the same key replaces the entry`() { + let cache = DecodedPreviewCache() + let url = uniqueURL() + let first = CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString, title: "first"), hero: Self.makeImage(), icon: nil) + let second = CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString, title: "second"), hero: Self.makeImage(), icon: nil) + + cache.store(first, for: url) + cache.store(second, for: url) + + let result = cache.decoded(for: url) + #expect(result?.hero === second.hero) + #expect(result?.dto.title == "second") + } + + @Test + @MainActor + func `Strips raw image and icon bytes from the cached DTO`() { + let dto = LinkPreviewDataDTO( + url: "https://example.invalid/strip", + title: "Title", + imageData: Data([0x01, 0x02, 0x03]), + iconData: Data([0x04, 0x05]), + imageWidth: 100, + imageHeight: 50 + ) + let entry = CachedDecodedPreview(dto: dto, hero: Self.makeImage(), icon: Self.makeImage()) + + #expect(entry.dto.imageData == nil) + #expect(entry.dto.iconData == nil) + // Metadata the rehydration render path reads is preserved. + #expect(entry.dto.url == dto.url) + #expect(entry.dto.title == dto.title) + #expect(entry.dto.imageWidth == 100) + #expect(entry.dto.imageHeight == 50) + } + + @Test + @MainActor + func `Cost reflects decoded pixel bytes`() { + let dto = Self.makeDTO(url: "https://example.invalid/cost") + let entry = CachedDecodedPreview(dto: dto, hero: Self.makeImage(width: 100, height: 50), icon: nil) + // 100x50 RGBA bitmap is 100 * 50 * 4 = 20_000 bytes minimum; + // bytesPerRow may be padded for alignment, so assert a lower bound. + #expect(entry.cost >= 20000) + } + + @Test + @MainActor + func `clear() empties the cache`() { + let cache = DecodedPreviewCache() + let url = uniqueURL() + cache.store(CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString), hero: Self.makeImage(), icon: nil), for: url) + #expect(cache.decoded(for: url) != nil) + + cache.clear() + #expect(cache.decoded(for: url) == nil) + } + + @Test + @MainActor + func `FIFO eviction drops the oldest entry past the count cap`() throws { + let cache = DecodedPreviewCache() + // maxEntryCount is 50; store 60 tiny entries so the first 10 evict. + var urls: [URL] = [] + for _ in 0..<60 { + let url = uniqueURL() + urls.append(url) + cache.store(CachedDecodedPreview(dto: Self.makeDTO(url: url.absoluteString), hero: Self.makeImage(), icon: nil), for: url) } - - private static func makeDTO(url: String, title: String? = "Title") -> LinkPreviewDataDTO { - LinkPreviewDataDTO(url: url, title: title, imageWidth: 100, imageHeight: 50) - } - - @MainActor - private static func makeImage(width: Int = 1, height: Int = 1) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) - return renderer.image { ctx in - UIColor.black.setFill() - ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) - } + #expect(try cache.decoded(for: #require(urls.first)) == nil) + #expect(try cache.decoded(for: #require(urls.last)) != nil) + } + + // MARK: - Helpers + + private func uniqueURL() -> URL { + URL(string: "https://example.invalid/\(UUID().uuidString)")! + } + + private static func makeDTO(url: String, title: String? = "Title") -> LinkPreviewDataDTO { + LinkPreviewDataDTO(url: url, title: title, imageWidth: 100, imageHeight: 50) + } + + @MainActor + private static func makeImage(width: Int = 1, height: Int = 1) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) + return renderer.image { ctx in + UIColor.black.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) } + } } diff --git a/MC1Tests/Services/ElevationServiceTests.swift b/MC1Tests/Services/ElevationServiceTests.swift index 434c0c0e..185003ff 100644 --- a/MC1Tests/Services/ElevationServiceTests.swift +++ b/MC1Tests/Services/ElevationServiceTests.swift @@ -1,198 +1,192 @@ import CoreLocation -import Testing - @testable import MC1 @testable import MC1Services +import Testing @Suite("ElevationService Tests") struct ElevationServiceTests { + // MARK: - Sample Count Tests + + @Suite("optimalSampleCount") + struct OptimalSampleCountTests { + @Test + func `Returns 20 samples for distances under 1km`() { + #expect(ElevationService.optimalSampleCount(distanceMeters: 0) == 20) + #expect(ElevationService.optimalSampleCount(distanceMeters: 500) == 20) + #expect(ElevationService.optimalSampleCount(distanceMeters: 999) == 20) + } + + @Test + func `Returns 50 samples for distances 1-5km`() { + #expect(ElevationService.optimalSampleCount(distanceMeters: 1000) == 50) + #expect(ElevationService.optimalSampleCount(distanceMeters: 2500) == 50) + #expect(ElevationService.optimalSampleCount(distanceMeters: 4999) == 50) + } - // MARK: - Sample Count Tests - - @Suite("optimalSampleCount") - struct OptimalSampleCountTests { - - @Test("Returns 20 samples for distances under 1km") - func sampleCountUnder1km() { - #expect(ElevationService.optimalSampleCount(distanceMeters: 0) == 20) - #expect(ElevationService.optimalSampleCount(distanceMeters: 500) == 20) - #expect(ElevationService.optimalSampleCount(distanceMeters: 999) == 20) - } - - @Test("Returns 50 samples for distances 1-5km") - func sampleCount1to5km() { - #expect(ElevationService.optimalSampleCount(distanceMeters: 1000) == 50) - #expect(ElevationService.optimalSampleCount(distanceMeters: 2500) == 50) - #expect(ElevationService.optimalSampleCount(distanceMeters: 4999) == 50) - } - - @Test("Returns 80 samples for distances 5-20km") - func sampleCount5to20km() { - #expect(ElevationService.optimalSampleCount(distanceMeters: 5000) == 80) - #expect(ElevationService.optimalSampleCount(distanceMeters: 10000) == 80) - #expect(ElevationService.optimalSampleCount(distanceMeters: 19999) == 80) - } - - @Test("Returns 100 samples for distances over 20km") - func sampleCountOver20km() { - #expect(ElevationService.optimalSampleCount(distanceMeters: 20000) == 100) - #expect(ElevationService.optimalSampleCount(distanceMeters: 50000) == 100) - #expect(ElevationService.optimalSampleCount(distanceMeters: 100000) == 100) - } - - @Test("Sample count never exceeds 100") - func sampleCountNeverExceeds100() { - // Test a range of distances to ensure we never exceed 100 - let distances = [0, 100, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000, 1_000_000] - for distance in distances { - let count = ElevationService.optimalSampleCount(distanceMeters: Double(distance)) - #expect(count <= 100, "Sample count \(count) exceeds 100 for distance \(distance)m") - } - } - } - - // MARK: - Sample Coordinates Tests - - @Suite("sampleCoordinates") - struct SampleCoordinatesTests { - - private let pointA = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - private let pointB = CLLocationCoordinate2D(latitude: 37.8049, longitude: -122.3894) - - @Test("First coordinate equals pointA") - func firstCoordinateIsPointA() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 10) - - #expect(samples.first?.latitude == pointA.latitude) - #expect(samples.first?.longitude == pointA.longitude) - } - - @Test("Last coordinate equals pointB") - func lastCoordinateIsPointB() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 10) - - #expect(samples.last?.latitude == pointB.latitude) - #expect(samples.last?.longitude == pointB.longitude) - } - - @Test("Returns correct number of samples") - func correctSampleCount() { - for count in [2, 5, 10, 20, 50, 100] { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: count) - #expect(samples.count == count, "Expected \(count) samples, got \(samples.count)") - } - } - - @Test("Sample count clamped to minimum of 2") - func minimumSampleCount() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 1) - #expect(samples.count == 2) - - let zeroSamples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 0) - #expect(zeroSamples.count == 2) - } - - @Test("Sample count clamped to maximum of 100") - func maximumSampleCount() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 150) - #expect(samples.count == 100) - } - - @Test("Coordinates are evenly distributed") - func evenlyDistributed() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 5) - - // Calculate expected latitude/longitude step - let latStep = (pointB.latitude - pointA.latitude) / 4 - let lonStep = (pointB.longitude - pointA.longitude) / 4 - - // Check each point is at expected position - for i in 0..<5 { - let expectedLat = pointA.latitude + Double(i) * latStep - let expectedLon = pointA.longitude + Double(i) * lonStep - - #expect( - abs(samples[i].latitude - expectedLat) < 0.0001, - "Latitude at index \(i) differs: expected \(expectedLat), got \(samples[i].latitude)" - ) - #expect( - abs(samples[i].longitude - expectedLon) < 0.0001, - "Longitude at index \(i) differs: expected \(expectedLon), got \(samples[i].longitude)" - ) - } - } - - @Test("Identical points return same coordinate repeated") - func identicalPoints() { - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointA, sampleCount: 5) - - #expect(samples.count == 5) - for sample in samples { - #expect(sample.latitude == pointA.latitude) - #expect(sample.longitude == pointA.longitude) - } - } - } - - // MARK: - Error Type Tests - - @Suite("ElevationServiceError") - struct ErrorTests { - - @Test("networkError has descriptive message") - func networkErrorDescription() { - let underlyingError = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connection failed"]) - let error = ElevationServiceError.networkError(underlyingError.localizedDescription) - - #expect(error.errorDescription?.contains("Network error") == true) - #expect(error.errorDescription?.contains("Connection failed") == true) - } - - @Test("invalidResponse has descriptive message") - func invalidResponseDescription() { - let error = ElevationServiceError.invalidResponse - #expect(error.errorDescription == "Invalid response from elevation API") - } - - @Test("apiError includes message") - func apiErrorDescription() { - let error = ElevationServiceError.apiError("Rate limit exceeded") - #expect(error.errorDescription?.contains("API error") == true) - #expect(error.errorDescription?.contains("Rate limit exceeded") == true) - } - - @Test("noData has descriptive message") - func noDataDescription() { - let error = ElevationServiceError.noData - #expect(error.errorDescription == "No elevation data returned") - } - } - - // MARK: - Integration with RFCalculator Distance - - @Suite("Distance Integration") - struct DistanceIntegrationTests { - - @Test("Sample coordinates work with RFCalculator distance") - func sampleCoordinatesDistanceIntegration() { - let pointA = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) - let pointB = CLLocationCoordinate2D(latitude: 37.8049, longitude: -122.3894) - - let totalDistance = RFCalculator.distance(from: pointA, to: pointB) - let samples = ElevationService.sampleCoordinates(from: pointA, to: pointB, sampleCount: 5) - - // Calculate distance between each consecutive pair - var cumulativeDistance = 0.0 - for i in 1.. Data? { + let bytesPerRow = width * 4 + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + context.setFillColor(CGColor(red: 0, green: 1, blue: 0, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage() else { return nil } - /// Creates an in-memory PNG of the requested pixel dimensions. - private static func makePNG(width: Int, height: Int) -> Data? { - let bytesPerRow = width * 4 - guard let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: bytesPerRow, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { return nil } - context.setFillColor(CGColor(red: 0, green: 1, blue: 0, alpha: 1)) - context.fill(CGRect(x: 0, y: 0, width: width, height: height)) - guard let image = context.makeImage() else { return nil } - - let mutable = NSMutableData() - guard let destination = CGImageDestinationCreateWithData( - mutable, - UTType.png.identifier as CFString, - 1, - nil - ) else { return nil } - CGImageDestinationAddImage(destination, image, nil) - guard CGImageDestinationFinalize(destination) else { return nil } - return mutable as Data - } + let mutable = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + mutable, + UTType.png.identifier as CFString, + 1, + nil + ) else { return nil } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { return nil } + return mutable as Data + } - @Test("decodeDimensions returns pixel dims for a valid PNG") - func decodeReturnsDimsForValidPNG() throws { - let png = try #require(Self.makePNG(width: 480, height: 270)) - let dims = ImageHeaderDecoder.decodeDimensions(from: png) - #expect(dims?.width == 480) - #expect(dims?.height == 270) - } + @Test + func `decodeDimensions returns pixel dims for a valid PNG`() throws { + let png = try #require(Self.makePNG(width: 480, height: 270)) + let dims = ImageHeaderDecoder.decodeDimensions(from: png) + #expect(dims?.width == 480) + #expect(dims?.height == 270) + } - @Test("decodeDimensions returns nil for non-image bytes") - func decodeReturnsNilForCorruptData() { - let dims = ImageHeaderDecoder.decodeDimensions(from: Data("not an image".utf8)) - #expect(dims == nil) - } + @Test + func `decodeDimensions returns nil for non-image bytes`() { + let dims = ImageHeaderDecoder.decodeDimensions(from: Data("not an image".utf8)) + #expect(dims == nil) + } - @Test("decodeDimensions returns nil for empty data") - func decodeReturnsNilForEmptyData() { - let dims = ImageHeaderDecoder.decodeDimensions(from: Data()) - #expect(dims == nil) - } + @Test + func `decodeDimensions returns nil for empty data`() { + let dims = ImageHeaderDecoder.decodeDimensions(from: Data()) + #expect(dims == nil) + } } diff --git a/MC1Tests/Services/ImageURLDetectorTests.swift b/MC1Tests/Services/ImageURLDetectorTests.swift index 24571893..14dade18 100644 --- a/MC1Tests/Services/ImageURLDetectorTests.swift +++ b/MC1Tests/Services/ImageURLDetectorTests.swift @@ -1,166 +1,165 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing struct ImageURLDetectorTests { - - // MARK: - Direct Image URL Detection - - @Test("Detects common image extensions", arguments: ["jpg", "jpeg", "png", "gif", "webp", "heic"]) - func detectsImageExtensions(ext: String) { - let url = URL(string: "https://example.com/photo.\(ext)")! - #expect(ImageURLClassifier.isDirectImageURL(url), "Should detect .\(ext)") - } - - @Test("Rejects non-image extensions", arguments: ["html", "pdf", "mp4", "txt", "js", "css"]) - func rejectsNonImageExtensions(ext: String) { - let url = URL(string: "https://example.com/file.\(ext)")! - #expect(!ImageURLClassifier.isDirectImageURL(url), "Should reject .\(ext)") - } - - @Test("Case insensitive extension detection") - func caseInsensitiveExtension() { - let url = URL(string: "https://example.com/photo.JPG")! - #expect(ImageURLClassifier.isDirectImageURL(url)) - - let urlMixed = URL(string: "https://example.com/photo.Png")! - #expect(ImageURLClassifier.isDirectImageURL(urlMixed)) - } - - @Test("Handles URLs with query parameters") - func urlWithQueryParameters() { - let url = URL(string: "https://example.com/photo.jpg?width=100&height=100")! - #expect(ImageURLClassifier.isDirectImageURL(url)) - } - - @Test("Rejects URL with no extension") - func noExtension() { - let url = URL(string: "https://example.com/photo")! - #expect(!ImageURLClassifier.isDirectImageURL(url)) - } - - @Test("Rejects empty path") - func emptyPath() { - let url = URL(string: "https://example.com/")! - #expect(!ImageURLClassifier.isDirectImageURL(url)) - } - - // MARK: - GIF Magic Byte Detection - - @Test("Detects GIF87a magic bytes") - func detectsGIF87a() { - let data = Data([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) // GIF87a - #expect(ImageURLDetector.isGIFData(data)) - } - - @Test("Detects GIF89a magic bytes") - func detectsGIF89a() { - let data = Data([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) // GIF89a - #expect(ImageURLDetector.isGIFData(data)) - } - - @Test("Rejects non-GIF data") - func rejectsNonGIFData() { - let pngData = Data([0x89, 0x50, 0x4E, 0x47]) // PNG header - #expect(!ImageURLDetector.isGIFData(pngData)) - } - - @Test("Rejects data shorter than 4 bytes") - func rejectsShortData() { - let data = Data([0x47, 0x49, 0x46]) // Only 3 bytes - #expect(!ImageURLDetector.isGIFData(data)) - } - - @Test("Rejects empty data") - func rejectsEmptyData() { - #expect(!ImageURLDetector.isGIFData(Data())) - } - - // MARK: - Giphy URL Resolution - - @Test("Resolves giphy.com/gifs/slug-text-ID") - func resolvesGiphySlugURL() { - let url = URL(string: "https://giphy.com/gifs/meme-cute-penguin-UTYwlUGi5iiRHtqEgj")! - let resolved = ImageURLClassifier.resolveImageURL(url) - #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") - } - - @Test("Resolves giphy.com/gifs/ID (no slug)") - func resolvesGiphyIDOnly() { - let url = URL(string: "https://giphy.com/gifs/UTYwlUGi5iiRHtqEgj")! - let resolved = ImageURLClassifier.resolveImageURL(url) - #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") - } - - @Test("Resolves giphy.com/embed/ID") - func resolvesGiphyEmbedURL() { - let url = URL(string: "https://giphy.com/embed/UTYwlUGi5iiRHtqEgj")! - let resolved = ImageURLClassifier.resolveImageURL(url) - #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") - } - - @Test("Recognizes media.giphy.com as direct image URL") - func recognizesMediaGiphy() { - let url = URL(string: "https://media.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif")! - #expect(ImageURLClassifier.isDirectImageURL(url), "Should be detected as direct image URL via .gif extension") - } - - @Test("Recognizes i.giphy.com as direct image URL") - func recognizesIGiphy() { - let url = URL(string: "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif")! - #expect(ImageURLClassifier.isDirectImageURL(url), "Should be detected as direct image URL via .gif extension") - } - - @Test("Returns nil for non-Giphy URLs") - func returnsNilForNonGiphy() { - let url = URL(string: "https://example.com/gifs/test-123")! - #expect(ImageURLClassifier.resolveImageURL(url) == nil) - } - - @Test("Returns nil for Giphy URLs without valid path") - func returnsNilForInvalidGiphyPath() { - let url = URL(string: "https://giphy.com/")! - #expect(ImageURLClassifier.resolveImageURL(url) == nil) - } - - @Test("Resolves www.giphy.com URLs") - func resolvesWWWGiphy() { - let url = URL(string: "https://www.giphy.com/gifs/test-ID123")! - let resolved = ImageURLClassifier.resolveImageURL(url) - #expect(resolved?.absoluteString == "https://i.giphy.com/media/ID123/giphy.gif") - } - - // MARK: - Composite Detection - - @Test("isImageURL returns true for direct image URLs") - func isImageURLDirectImage() { - let url = URL(string: "https://example.com/photo.png")! - #expect(ImageURLClassifier.isImageURL(url)) - } - - @Test("isImageURL returns true for resolvable Giphy URLs") - func isImageURLGiphy() { - let url = URL(string: "https://giphy.com/gifs/test-ABC123")! - #expect(ImageURLClassifier.isImageURL(url)) - } - - @Test("isImageURL returns false for non-image URLs") - func isImageURLNonImage() { - let url = URL(string: "https://example.com/page.html")! - #expect(!ImageURLClassifier.isImageURL(url)) - } - - @Test("directImageURL returns self for direct images") - func directImageURLSelf() { - let url = URL(string: "https://example.com/photo.jpg")! - #expect(ImageURLClassifier.directImageURL(for: url) == url) - } - - @Test("directImageURL resolves Giphy URLs") - func directImageURLResolvesGiphy() { - let url = URL(string: "https://giphy.com/gifs/funny-ABC123")! - let resolved = ImageURLClassifier.directImageURL(for: url) - #expect(resolved.absoluteString == "https://i.giphy.com/media/ABC123/giphy.gif") - } + // MARK: - Direct Image URL Detection + + @Test(arguments: ["jpg", "jpeg", "png", "gif", "webp", "heic"]) + func `Detects common image extensions`(ext: String) throws { + let url = try #require(URL(string: "https://example.com/photo.\(ext)")) + #expect(ImageURLClassifier.isDirectImageURL(url), "Should detect .\(ext)") + } + + @Test(arguments: ["html", "pdf", "mp4", "txt", "js", "css"]) + func `Rejects non-image extensions`(ext: String) throws { + let url = try #require(URL(string: "https://example.com/file.\(ext)")) + #expect(!ImageURLClassifier.isDirectImageURL(url), "Should reject .\(ext)") + } + + @Test + func `Case insensitive extension detection`() throws { + let url = try #require(URL(string: "https://example.com/photo.JPG")) + #expect(ImageURLClassifier.isDirectImageURL(url)) + + let urlMixed = try #require(URL(string: "https://example.com/photo.Png")) + #expect(ImageURLClassifier.isDirectImageURL(urlMixed)) + } + + @Test + func `Handles URLs with query parameters`() throws { + let url = try #require(URL(string: "https://example.com/photo.jpg?width=100&height=100")) + #expect(ImageURLClassifier.isDirectImageURL(url)) + } + + @Test + func `Rejects URL with no extension`() throws { + let url = try #require(URL(string: "https://example.com/photo")) + #expect(!ImageURLClassifier.isDirectImageURL(url)) + } + + @Test + func `Rejects empty path`() throws { + let url = try #require(URL(string: "https://example.com/")) + #expect(!ImageURLClassifier.isDirectImageURL(url)) + } + + // MARK: - GIF Magic Byte Detection + + @Test + func `Detects GIF87a magic bytes`() { + let data = Data([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) // GIF87a + #expect(ImageURLDetector.isGIFData(data)) + } + + @Test + func `Detects GIF89a magic bytes`() { + let data = Data([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) // GIF89a + #expect(ImageURLDetector.isGIFData(data)) + } + + @Test + func `Rejects non-GIF data`() { + let pngData = Data([0x89, 0x50, 0x4E, 0x47]) // PNG header + #expect(!ImageURLDetector.isGIFData(pngData)) + } + + @Test + func `Rejects data shorter than 4 bytes`() { + let data = Data([0x47, 0x49, 0x46]) // Only 3 bytes + #expect(!ImageURLDetector.isGIFData(data)) + } + + @Test + func `Rejects empty data`() { + #expect(!ImageURLDetector.isGIFData(Data())) + } + + // MARK: - Giphy URL Resolution + + @Test + func `Resolves giphy.com/gifs/slug-text-ID`() throws { + let url = try #require(URL(string: "https://giphy.com/gifs/meme-cute-penguin-UTYwlUGi5iiRHtqEgj")) + let resolved = ImageURLClassifier.resolveImageURL(url) + #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") + } + + @Test + func `Resolves giphy.com/gifs/ID (no slug)`() throws { + let url = try #require(URL(string: "https://giphy.com/gifs/UTYwlUGi5iiRHtqEgj")) + let resolved = ImageURLClassifier.resolveImageURL(url) + #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") + } + + @Test + func `Resolves giphy.com/embed/ID`() throws { + let url = try #require(URL(string: "https://giphy.com/embed/UTYwlUGi5iiRHtqEgj")) + let resolved = ImageURLClassifier.resolveImageURL(url) + #expect(resolved?.absoluteString == "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif") + } + + @Test + func `Recognizes media.giphy.com as direct image URL`() throws { + let url = try #require(URL(string: "https://media.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif")) + #expect(ImageURLClassifier.isDirectImageURL(url), "Should be detected as direct image URL via .gif extension") + } + + @Test + func `Recognizes i.giphy.com as direct image URL`() throws { + let url = try #require(URL(string: "https://i.giphy.com/media/UTYwlUGi5iiRHtqEgj/giphy.gif")) + #expect(ImageURLClassifier.isDirectImageURL(url), "Should be detected as direct image URL via .gif extension") + } + + @Test + func `Returns nil for non-Giphy URLs`() throws { + let url = try #require(URL(string: "https://example.com/gifs/test-123")) + #expect(ImageURLClassifier.resolveImageURL(url) == nil) + } + + @Test + func `Returns nil for Giphy URLs without valid path`() throws { + let url = try #require(URL(string: "https://giphy.com/")) + #expect(ImageURLClassifier.resolveImageURL(url) == nil) + } + + @Test + func `Resolves www.giphy.com URLs`() throws { + let url = try #require(URL(string: "https://www.giphy.com/gifs/test-ID123")) + let resolved = ImageURLClassifier.resolveImageURL(url) + #expect(resolved?.absoluteString == "https://i.giphy.com/media/ID123/giphy.gif") + } + + // MARK: - Composite Detection + + @Test + func `isImageURL returns true for direct image URLs`() throws { + let url = try #require(URL(string: "https://example.com/photo.png")) + #expect(ImageURLClassifier.isImageURL(url)) + } + + @Test + func `isImageURL returns true for resolvable Giphy URLs`() throws { + let url = try #require(URL(string: "https://giphy.com/gifs/test-ABC123")) + #expect(ImageURLClassifier.isImageURL(url)) + } + + @Test + func `isImageURL returns false for non-image URLs`() throws { + let url = try #require(URL(string: "https://example.com/page.html")) + #expect(!ImageURLClassifier.isImageURL(url)) + } + + @Test + func `directImageURL returns self for direct images`() throws { + let url = try #require(URL(string: "https://example.com/photo.jpg")) + #expect(ImageURLClassifier.directImageURL(for: url) == url) + } + + @Test + func `directImageURL resolves Giphy URLs`() throws { + let url = try #require(URL(string: "https://giphy.com/gifs/funny-ABC123")) + let resolved = ImageURLClassifier.directImageURL(for: url) + #expect(resolved.absoluteString == "https://i.giphy.com/media/ABC123/giphy.gif") + } } diff --git a/MC1Tests/Services/InlineImageCacheTests.swift b/MC1Tests/Services/InlineImageCacheTests.swift index b0bc42c9..60eac18c 100644 --- a/MC1Tests/Services/InlineImageCacheTests.swift +++ b/MC1Tests/Services/InlineImageCacheTests.swift @@ -1,103 +1,102 @@ import Foundation +@testable import MC1 import Testing import UIKit -@testable import MC1 @Suite("InlineImageCache Tests") struct InlineImageCacheTests { - - // MARK: - Safety gate - - @Test("Probe returns nil for a private-IP host") - func probeRejectsPrivateIP() async { - let url = URL(string: "http://127.0.0.1/test.png")! - let result = await InlineImageCache.shared.probeImageDimensions(url: url) - #expect(result == nil) - } - - @Test("Probe returns nil for a non-HTTP scheme") - func probeRejectsNonHTTPScheme() async { - let url = URL(string: "ftp://example.com/test.png")! - let result = await InlineImageCache.shared.probeImageDimensions(url: url) - #expect(result == nil) - } - - // MARK: - Decoded cache - - @Test("Decoded cache returns nil for an unseen URL") - func decodedReturnsNilForMissingKey() async { - let url = uniqueURL() - #expect(InlineImageCache.shared.decoded(for: url) == nil) - } - - @Test("Decoded cache round-trips a stored entry with raw bytes") - @MainActor - func decodedRoundTrip() async { - let url = uniqueURL() - let image = Self.makeImage() - let bytes = Data([0xFF, 0xD8, 0xFF, 0xE0]) - let entry = CachedDecodedImage(image: image, isGIF: false, data: bytes) - await InlineImageCache.shared.storeDecoded(entry, for: url) - - let result = InlineImageCache.shared.decoded(for: url) - #expect(result?.image === image) - #expect(result?.isGIF == false) - #expect(result?.data == bytes) - } - - @Test("Decoded cache preserves the GIF flag and omits bytes") - @MainActor - func decodedPreservesIsGIF() async { - let url = uniqueURL() - let image = Self.makeImage() - let entry = CachedDecodedImage(image: image, isGIF: true, data: nil) - await InlineImageCache.shared.storeDecoded(entry, for: url) - - let result = InlineImageCache.shared.decoded(for: url) - #expect(result?.isGIF == true) - #expect(result?.data == nil) - } - - @Test("Re-storing the same key replaces the entry without growing the cache") - @MainActor - func decodedReplaceSameKey() async { - let url = uniqueURL() - let first = CachedDecodedImage(image: Self.makeImage(), isGIF: false, data: Data([0x01])) - let second = CachedDecodedImage(image: Self.makeImage(), isGIF: true, data: nil) - - await InlineImageCache.shared.storeDecoded(first, for: url) - await InlineImageCache.shared.storeDecoded(second, for: url) - - let result = InlineImageCache.shared.decoded(for: url) - #expect(result?.image === second.image) - #expect(result?.isGIF == true) - #expect(result?.data == nil) - } - - @Test("Decoded cost reflects pixel size plus raw bytes") - @MainActor - func decodedCostIsPixelBytes() { - let image = Self.makeImage(width: 100, height: 50) - let bytes = Data(repeating: 0, count: 1_000) - let entry = CachedDecodedImage(image: image, isGIF: false, data: bytes) - // 100x50 RGBA bitmap is 100 * 50 * 4 = 20_000 bytes minimum, plus - // 1_000 raw bytes. CGImage.bytesPerRow may be padded for alignment, - // so we assert a sane lower bound rather than an exact value. - #expect(entry.cost >= 21_000) - } - - // MARK: - Helpers - - private func uniqueURL() -> URL { - URL(string: "https://example.invalid/\(UUID().uuidString).png")! - } - - @MainActor - private static func makeImage(width: Int = 1, height: Int = 1) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) - return renderer.image { ctx in - UIColor.black.setFill() - ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) - } + // MARK: - Safety gate + + @Test + func `Probe returns nil for a private-IP host`() async throws { + let url = try #require(URL(string: "http://127.0.0.1/test.png")) + let result = await InlineImageCache.shared.probeImageDimensions(url: url) + #expect(result == nil) + } + + @Test + func `Probe returns nil for a non-HTTP scheme`() async throws { + let url = try #require(URL(string: "ftp://example.com/test.png")) + let result = await InlineImageCache.shared.probeImageDimensions(url: url) + #expect(result == nil) + } + + // MARK: - Decoded cache + + @Test + func `Decoded cache returns nil for an unseen URL`() { + let url = uniqueURL() + #expect(InlineImageCache.shared.decoded(for: url) == nil) + } + + @Test + @MainActor + func `Decoded cache round-trips a stored entry with raw bytes`() async { + let url = uniqueURL() + let image = Self.makeImage() + let bytes = Data([0xFF, 0xD8, 0xFF, 0xE0]) + let entry = CachedDecodedImage(image: image, isGIF: false, data: bytes) + await InlineImageCache.shared.storeDecoded(entry, for: url) + + let result = InlineImageCache.shared.decoded(for: url) + #expect(result?.image === image) + #expect(result?.isGIF == false) + #expect(result?.data == bytes) + } + + @Test + @MainActor + func `Decoded cache preserves the GIF flag and omits bytes`() async { + let url = uniqueURL() + let image = Self.makeImage() + let entry = CachedDecodedImage(image: image, isGIF: true, data: nil) + await InlineImageCache.shared.storeDecoded(entry, for: url) + + let result = InlineImageCache.shared.decoded(for: url) + #expect(result?.isGIF == true) + #expect(result?.data == nil) + } + + @Test + @MainActor + func `Re-storing the same key replaces the entry without growing the cache`() async { + let url = uniqueURL() + let first = CachedDecodedImage(image: Self.makeImage(), isGIF: false, data: Data([0x01])) + let second = CachedDecodedImage(image: Self.makeImage(), isGIF: true, data: nil) + + await InlineImageCache.shared.storeDecoded(first, for: url) + await InlineImageCache.shared.storeDecoded(second, for: url) + + let result = InlineImageCache.shared.decoded(for: url) + #expect(result?.image === second.image) + #expect(result?.isGIF == true) + #expect(result?.data == nil) + } + + @Test + @MainActor + func `Decoded cost reflects pixel size plus raw bytes`() { + let image = Self.makeImage(width: 100, height: 50) + let bytes = Data(repeating: 0, count: 1000) + let entry = CachedDecodedImage(image: image, isGIF: false, data: bytes) + // 100x50 RGBA bitmap is 100 * 50 * 4 = 20_000 bytes minimum, plus + // 1_000 raw bytes. CGImage.bytesPerRow may be padded for alignment, + // so we assert a sane lower bound rather than an exact value. + #expect(entry.cost >= 21000) + } + + // MARK: - Helpers + + private func uniqueURL() -> URL { + URL(string: "https://example.invalid/\(UUID().uuidString).png")! + } + + @MainActor + private static func makeImage(width: Int = 1, height: Int = 1) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) + return renderer.image { ctx in + UIColor.black.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) } + } } diff --git a/MC1Tests/Services/InlineImagePrefetcherTests.swift b/MC1Tests/Services/InlineImagePrefetcherTests.swift index a4aa37d6..a1f433dd 100644 --- a/MC1Tests/Services/InlineImagePrefetcherTests.swift +++ b/MC1Tests/Services/InlineImagePrefetcherTests.swift @@ -1,398 +1,615 @@ -import Testing -import Foundation import CoreGraphics -import MeshCore +import Foundation @testable import MC1 @testable import MC1Services +import MeshCore +import Testing @Suite("InlineImagePrefetcher Tests") @MainActor struct InlineImagePrefetcherTests { - - // MARK: - URL extraction - - @Test("Text with no URLs returns immediately without probes or previews") - func noURLsInTextNoCalls() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - await prefetcher.prefetch(urlsIn: "hello world, no links here", isChannelMessage: false) - - let probeCalls = await imageCache.probedURLs - let previewCalls = await linkCache.fetchedURLs - #expect(probeCalls.isEmpty) - #expect(previewCalls.isEmpty) - } - - @Test("Direct image suffix invokes the dimension probe path") - func directImageInvokesProbe() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - await prefetcher.prefetch( - urlsIn: "look at https://example.com/cat.png", - isChannelMessage: false - ) - - let probeCalls = await imageCache.probedURLs - let previewCalls = await linkCache.fetchedURLs - #expect(probeCalls.map(\.absoluteString) == ["https://example.com/cat.png"]) - #expect(previewCalls.isEmpty) - } - - // MARK: - Parallel fan-out - - @Test("Multiple URLs fan out across both classifier paths") - func multipleURLsParallel() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - await prefetcher.prefetch( - urlsIn: "image https://example.com/cat.png and article https://example.com/article", - isChannelMessage: false - ) - - let probeCalls = await imageCache.probedURLs - let previewCalls = await linkCache.fetchedURLs - #expect(probeCalls.map(\.absoluteString) == ["https://example.com/cat.png"]) - #expect(previewCalls.map(\.absoluteString) == ["https://example.com/article"]) - } - - // MARK: - Skip-if-cached - - @Test("Direct image probe is skipped when dimensions are already cached") - func skipsCachedDirectImage() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let cachedURL = URL(string: "https://example.com/cat.png")! - await store.save(url: cachedURL, size: CGSize(width: 200, height: 100)) - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - await prefetcher.prefetch( - urlsIn: "look at https://example.com/cat.png", - isChannelMessage: false - ) - - let probeCalls = await imageCache.probedURLs - #expect(probeCalls.isEmpty) - } - - // MARK: - Mixed direct image + link preview - - @Test("Mixed direct image and link preview URLs both invoke their paths") - func mixedDirectAndPreview() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - await prefetcher.prefetch( - urlsIn: "see https://example.com/cat.jpg then read https://news.example.com/post", - isChannelMessage: true - ) - - let probeCalls = await imageCache.probedURLs - let previewCalls = await linkCache.fetchedURLs - #expect(probeCalls.count == 1) - #expect(probeCalls.first?.absoluteString == "https://example.com/cat.jpg") - #expect(previewCalls.count == 1) - #expect(previewCalls.first?.absoluteString == "https://news.example.com/post") - - let channelFlags = await linkCache.fetchedChannelFlags - #expect(channelFlags == [true]) - } - - // MARK: - Giphy hosting page routes to probe - - @Test("Giphy hosting URL routes to probe path with resolved direct image URL") - func giphyHostingRoutesToProbe() async { - let imageCache = StubImageProber() - let linkCache = StubLinkPreviewFetcher() - let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) - let dataStore = StubDataStore() - - let prefetcher = InlineImagePrefetcher( - imageCache: imageCache, - linkPreviewCache: linkCache, - dimensionsStore: store, - dataStore: dataStore - ) - - let hostingURL = URL(string: "https://giphy.com/gifs/abc123")! - let expectedProbeURL = ImageURLClassifier.directImageURL(for: hostingURL) - - await prefetcher.prefetch( - urlsIn: "look at \(hostingURL.absoluteString)", - isChannelMessage: false - ) - - let probeCalls = await imageCache.probedURLs - let previewCalls = await linkCache.fetchedURLs - #expect(probeCalls.map(\.absoluteString) == [expectedProbeURL.absoluteString]) - #expect(previewCalls.isEmpty) - } - - // MARK: - Helpers - - private static func makeTempDimensionsURL() -> URL { - let directory = FileManager.default.temporaryDirectory - .appending(path: "InlineImagePrefetcherTests-\(UUID().uuidString)") - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return directory.appending(path: "dimensions.json") - } + // MARK: - URL extraction + + @Test + func `Text with no URLs returns immediately without probes or previews`() async { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch(urlsIn: "hello world, no links here", isChannelMessage: false) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.isEmpty) + #expect(previewCalls.isEmpty) + } + + @Test + func `Direct image suffix invokes the dimension probe path`() async { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch( + urlsIn: "look at https://example.com/cat.png", + isChannelMessage: false + ) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.map(\.absoluteString) == ["https://example.com/cat.png"]) + #expect(previewCalls.isEmpty) + } + + // MARK: - Parallel fan-out + + @Test + func `Multiple URLs fan out across both classifier paths`() async { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch( + urlsIn: "image https://example.com/cat.png and article https://example.com/article", + isChannelMessage: false + ) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.map(\.absoluteString) == ["https://example.com/cat.png"]) + #expect(previewCalls.map(\.absoluteString) == ["https://example.com/article"]) + } + + // MARK: - Skip-if-cached + + @Test + func `Direct image probe is skipped when dimensions are already cached`() async throws { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let cachedURL = try #require(URL(string: "https://example.com/cat.png")) + await store.save(url: cachedURL, size: CGSize(width: 200, height: 100)) + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch( + urlsIn: "look at https://example.com/cat.png", + isChannelMessage: false + ) + + let probeCalls = await imageCache.probedURLs + #expect(probeCalls.isEmpty) + } + + // MARK: - Mixed direct image + link preview + + @Test + func `Mixed direct image and link preview URLs both invoke their paths`() async { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + await prefetcher.prefetch( + urlsIn: "see https://example.com/cat.jpg then read https://news.example.com/post", + isChannelMessage: true + ) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.count == 1) + #expect(probeCalls.first?.absoluteString == "https://example.com/cat.jpg") + #expect(previewCalls.count == 1) + #expect(previewCalls.first?.absoluteString == "https://news.example.com/post") + + let channelFlags = await linkCache.fetchedChannelFlags + #expect(channelFlags == [true]) + } + + // MARK: - Giphy hosting page routes to probe + + @Test + func `Giphy hosting URL routes to probe path with resolved direct image URL`() async throws { + let imageCache = StubImageProber() + let linkCache = StubLinkPreviewFetcher() + let store = InlineImageDimensionsStore(fileURL: Self.makeTempDimensionsURL()) + let dataStore = StubDataStore() + + let prefetcher = InlineImagePrefetcher( + imageCache: imageCache, + linkPreviewCache: linkCache, + dimensionsStore: store, + dataStore: dataStore + ) + + let hostingURL = try #require(URL(string: "https://giphy.com/gifs/abc123")) + let expectedProbeURL = ImageURLClassifier.directImageURL(for: hostingURL) + + await prefetcher.prefetch( + urlsIn: "look at \(hostingURL.absoluteString)", + isChannelMessage: false + ) + + let probeCalls = await imageCache.probedURLs + let previewCalls = await linkCache.fetchedURLs + #expect(probeCalls.map(\.absoluteString) == [expectedProbeURL.absoluteString]) + #expect(previewCalls.isEmpty) + } + + // MARK: - Helpers + + private static func makeTempDimensionsURL() -> URL { + let directory = FileManager.default.temporaryDirectory + .appending(path: "InlineImagePrefetcherTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appending(path: "dimensions.json") + } } // MARK: - Stubs private actor StubImageProber: InlineImageDimensionProbing { - private(set) var probedURLs: [URL] = [] + private(set) var probedURLs: [URL] = [] - func probeImageDimensions(url: URL) async -> CGSize? { - probedURLs.append(url) - return nil - } + func probeImageDimensions(url: URL) async -> CGSize? { + probedURLs.append(url) + return nil + } } private actor StubLinkPreviewFetcher: LinkPreviewCaching { - private(set) var fetchedURLs: [URL] = [] - private(set) var fetchedChannelFlags: [Bool] = [] - - func preview( - for url: URL, - using dataStore: any PersistenceStoreProtocol, - isChannelMessage: Bool - ) async -> LinkPreviewResult { - fetchedURLs.append(url) - fetchedChannelFlags.append(isChannelMessage) - return .noPreviewAvailable - } - - func manualFetch( - for url: URL, - using dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult { - .noPreviewAvailable - } - - func isFetching(_ url: URL) async -> Bool { false } - - func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { nil } + private(set) var fetchedURLs: [URL] = [] + private(set) var fetchedChannelFlags: [Bool] = [] + + func preview( + for url: URL, + using dataStore: any PersistenceStoreProtocol, + isChannelMessage: Bool + ) async -> LinkPreviewResult { + fetchedURLs.append(url) + fetchedChannelFlags.append(isChannelMessage) + return .noPreviewAvailable + } + + func manualFetch( + for url: URL, + using dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult { + .noPreviewAvailable + } + + func isFetching(_ url: URL) async -> Bool { + false + } + + func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { + nil + } } private actor StubDataStore: PersistenceStoreProtocol { - - // MARK: - Link Preview Data - - func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { nil } - func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws {} - - // MARK: - Required Protocol Stubs - - func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { false } - func saveMessage(_ dto: MessageDTO) async throws {} - func fetchMessage(id: UUID) async throws -> MessageDTO? { nil } - func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } - func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } - func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } - func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } - func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} - func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} - func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} - func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} - func updateMessageLinkPreview(id: UUID, url: String?, title: String?, imageData: Data?, iconData: Data?, fetched: Bool) throws {} - - func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { [] } - func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { [] } - func fetchContact(id: UUID) async throws -> ContactDTO? { nil } - func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { nil } - func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { nil } - @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { UUID() } - func saveContact(_ dto: ContactDTO) async throws {} - func deleteContact(id: UUID) async throws {} - func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} - func incrementUnreadCount(contactID: UUID) async throws {} - func clearUnreadCount(contactID: UUID) async throws {} - - func markMentionSeen(messageID: UUID) async throws {} - func incrementUnreadMentionCount(contactID: UUID) async throws {} - func decrementUnreadMentionCount(contactID: UUID) async throws {} - func clearUnreadMentionCount(contactID: UUID) async throws {} - func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func clearChannelUnreadMentionCount(channelID: UUID) async throws {} - func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { [] } - func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { [] } - func deleteMessagesForContact(contactID: UUID) async throws {} - func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { [] } - - func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} - func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} - func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} - func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { [] } - - func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { [] } - func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { nil } - func fetchChannel(id: UUID) async throws -> ChannelDTO? { nil } - @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { UUID() } - func saveChannel(_ dto: ChannelDTO) async throws {} - func deleteChannel(id: UUID) async throws {} - func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} - func incrementChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} - - func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { [] } - func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { nil } - func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { - SavedTracePathDTO(id: UUID(), radioID: radioID, name: name, pathBytes: pathBytes, hashSize: hashSize, createdDate: Date(), runs: []) - } - func updateSavedTracePathName(id: UUID, name: String) async throws {} - func deleteSavedTracePath(id: UUID) async throws {} - func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} - - func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? { nil } - func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} - func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { [] } - func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { false } - func incrementMessageHeardRepeats(id: UUID) async throws -> Int { 0 } - func deleteMessageRepeats(messageID: UUID) async throws {} - func incrementMessageSendCount(id: UUID) async throws -> Int { 0 } - func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} - - func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} - func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { [] } - func countDebugLogEntries() async throws -> Int { 0 } - func pruneDebugLogEntries(keepCount: Int) async throws {} - func clearDebugLogEntries() async throws {} - - func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { [:] } - - func findRxLogEntry(radioID: UUID, channelIndex: UInt8?, senderTimestamp: UInt32) async throws -> RxLogEntryDTO? { nil } - func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { nil } - - func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} - func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { nil } - func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { [] } - func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { false } - func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} - func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} - func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} - - func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { - fatalError("Not implemented") - } - func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} - func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { [] } - func deleteDiscoveredNode(id: UUID) async throws {} - func clearDiscoveredNodes(radioID: UUID) async throws {} - func fetchContactPublicKeys(radioID: UUID) async throws -> Set { Set() } - - func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { [] } - func saveReaction(_ dto: ReactionDTO) async throws {} - func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { false } - func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} - func deleteReactionsForMessage(messageID: UUID) async throws {} - func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - - func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} - func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} - func fetchDevice(id: UUID) async throws -> DeviceDTO? { nil } - func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { nil } - func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} - func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { nil } - func markSessionDisconnected(_ sessionID: UUID) async throws {} - func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { false } - func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { false } - func clearRetryingToSent(id: UUID) async throws -> Bool { false } - func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { false } - func markMessageAsRead(id: UUID) async throws {} - func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { nil } - func saveDevice(_ dto: DeviceDTO) async throws {} - func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { [] } - func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { [] } - func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} - func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} - func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} - func deleteRemoteNodeSession(id: UUID) async throws {} - func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} - func resetRoomUnreadCount(_ sessionID: UUID) async throws {} - func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { nil } - func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { nil } - func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} - func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } - func clearRxLogEntries(radioID: UUID) async throws {} - func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { [] } - func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} - func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} - func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - - func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} - - // swiftlint:disable:next line_length function_parameter_count - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } - func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } - func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } - func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} - func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} - func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { UUID() } - func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { UUID() } - func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} - - func upsertPendingSend(_ dto: PendingSendDTO) async throws {} - func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { 0 } - func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { [] } - func deletePendingSend(id: UUID) async throws {} - func deletePendingSendsForMessage(messageID: UUID) async throws {} - func hasPendingSend(messageID: UUID) async throws -> Bool { false } + // MARK: - Link Preview Data + + func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { + nil + } + + func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws {} + + // MARK: - Required Protocol Stubs + + func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { + false + } + + func saveMessage(_ dto: MessageDTO) async throws {} + func fetchMessage(id: UUID) async throws -> MessageDTO? { + nil + } + + func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { + [] + } + + func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { + [] + } + + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + [:] + } + + func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + [:] + } + + func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} + func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} + func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} + func updateMessageLinkPreview(id: UUID, url: String?, title: String?, imageData: Data?, iconData: Data?, fetched: Bool) throws {} + + func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + func fetchContact(id: UUID) async throws -> ContactDTO? { + nil + } + + func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { + nil + } + + func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { + nil + } + + @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { + UUID() + } + + func saveContact(_ dto: ContactDTO) async throws {} + func deleteContact(id: UUID) async throws {} + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} + func incrementUnreadCount(contactID: UUID) async throws {} + func clearUnreadCount(contactID: UUID) async throws {} + + func markMentionSeen(messageID: UUID) async throws {} + func incrementUnreadMentionCount(contactID: UUID) async throws {} + func decrementUnreadMentionCount(contactID: UUID) async throws {} + func clearUnreadMentionCount(contactID: UUID) async throws {} + func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} + func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} + func clearChannelUnreadMentionCount(channelID: UUID) async throws {} + func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { + [] + } + + func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { + [] + } + + func deleteMessagesForContact(contactID: UUID) async throws {} + func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} + func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} + func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} + func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { + [] + } + + func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { + [] + } + + func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { + nil + } + + func fetchChannel(id: UUID) async throws -> ChannelDTO? { + nil + } + + @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { + UUID() + } + + func saveChannel(_ dto: ChannelDTO) async throws {} + func deleteChannel(id: UUID) async throws {} + func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} + func incrementChannelUnreadCount(channelID: UUID) async throws {} + func clearChannelUnreadCount(channelID: UUID) async throws {} + func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} + + func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { + [] + } + + func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { + nil + } + + func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { + SavedTracePathDTO(id: UUID(), radioID: radioID, name: name, pathBytes: pathBytes, hashSize: hashSize, createdDate: Date(), runs: []) + } + + func updateSavedTracePathName(id: UUID, name: String) async throws {} + func deleteSavedTracePath(id: UUID) async throws {} + func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} + + func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? { + nil + } + + func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} + func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { + [] + } + + func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { + false + } + + func incrementMessageHeardRepeats(id: UUID) async throws -> Int { + 0 + } + + func deleteMessageRepeats(messageID: UUID) async throws {} + func incrementMessageSendCount(id: UUID) async throws -> Int { + 0 + } + + func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} + + func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} + func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { + [] + } + + func countDebugLogEntries() async throws -> Int { + 0 + } + + func pruneDebugLogEntries(keepCount: Int) async throws {} + func clearDebugLogEntries() async throws {} + + func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { + [:] + } + + func findRxLogEntry(radioID: UUID, channelIndex: UInt8?, senderTimestamp: UInt32) async throws -> RxLogEntryDTO? { + nil + } + + func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { + nil + } + + func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} + func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { + nil + } + + func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { + [] + } + + func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { + false + } + + func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} + func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} + func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} + + func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { + fatalError("Not implemented") + } + + func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} + func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { + [] + } + + func deleteDiscoveredNode(id: UUID) async throws {} + func clearDiscoveredNodes(radioID: UUID) async throws {} + func fetchContactPublicKeys(radioID: UUID) async throws -> Set { + Set() + } + + func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { + [] + } + + func saveReaction(_ dto: ReactionDTO) async throws {} + func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { + false + } + + func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} + func deleteReactionsForMessage(messageID: UUID) async throws {} + func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { + nil + } + + func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { + [] + } + + func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { + [] + } + + func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { + nil + } + + func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} + func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} + func fetchDevice(id: UUID) async throws -> DeviceDTO? { + nil + } + + func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { + nil + } + + func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} + func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { + nil + } + + func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { + nil + } + + func markSessionDisconnected(_ sessionID: UUID) async throws {} + func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { + false + } + + func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { + false + } + + func clearRetryingToSent(id: UUID) async throws -> Bool { + false + } + + func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { + false + } + + func markMessageAsRead(id: UUID) async throws {} + func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { + nil + } + + func saveDevice(_ dto: DeviceDTO) async throws {} + func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { + nil + } + + func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + [] + } + + func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { + [] + } + + func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} + func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} + func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} + func deleteRemoteNodeSession(id: UUID) async throws {} + func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} + func resetRoomUnreadCount(_ sessionID: UUID) async throws {} + func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { + nil + } + + func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { + nil + } + + func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} + func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { + [] + } + + func clearRxLogEntries(radioID: UUID) async throws {} + func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} + func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + [] + } + + func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { + [] + } + + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} + + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} + + // swiftlint:disable:next line_length function_parameter_count + func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? + ) async throws -> UUID { + UUID() + } + + func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { + nil + } + + func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { + [] + } + + func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} + func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} + func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { + UUID() + } + + func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { + UUID() + } + + func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} + + func upsertPendingSend(_ dto: PendingSendDTO) async throws {} + func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { + 0 + } + + func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { + [] + } + + func deletePendingSend(id: UUID) async throws {} + func deletePendingSendsForMessage(messageID: UUID) async throws {} + func hasPendingSend(messageID: UUID) async throws -> Bool { + false + } } diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index 16b778d7..4ac7128c 100644 --- a/MC1Tests/Services/LinkPreviewCacheTests.swift +++ b/MC1Tests/Services/LinkPreviewCacheTests.swift @@ -1,225 +1,224 @@ -import Testing import Foundation -import MeshCore @testable import MC1 @testable import MC1Services +import MeshCore +import Testing @Suite("LinkPreviewCache Tests") struct LinkPreviewCacheTests { - - // MARK: - Memory Cache Tests - - @Test("Returns cached preview from memory on subsequent requests") - func returnsCachedPreviewFromMemory() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/article")! - - // Seed the database with a preview - let dto = LinkPreviewDataDTO( - url: url.absoluteString, - title: "Test Article", - imageData: nil, - iconData: nil - ) - await dataStore.setStoredPreview(dto, for: url.absoluteString) - - // First request should hit database - let result1 = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - #expect(isLoaded(result1, withTitle: "Test Article")) - let fetchCount1 = await dataStore.fetchCallCount - #expect(fetchCount1 == 1) - - // Second request should hit memory cache (no additional fetch) - let result2 = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - #expect(isLoaded(result2, withTitle: "Test Article")) - let fetchCount2 = await dataStore.fetchCallCount - #expect(fetchCount2 == 1) // Should not increase - } - - @Test("Memory cache returns correct preview data") - func memoryCacheReturnsCorrectData() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/test")! - - let dto = LinkPreviewDataDTO( - url: url.absoluteString, - title: "Memory Cache Test", - imageData: Data([1, 2, 3]), - iconData: Data([4, 5, 6]) - ) - await dataStore.setStoredPreview(dto, for: url.absoluteString) - - // Load into memory cache - _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - - // Verify cached data matches - let cached = await cache.cachedPreview(for: url) - #expect(cached?.title == "Memory Cache Test") - #expect(cached?.imageData == Data([1, 2, 3])) - #expect(cached?.iconData == Data([4, 5, 6])) - } - - // MARK: - Negative Cache Tests - - @Test("Negative cache prevents repeated network fetches for unavailable previews") - func negativeCachePreventsRepeatedFetches() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/no-preview")! - - // First request finds no preview (returns noPreviewAvailable) - _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - - let initialFetchCount = await dataStore.fetchCallCount - - // Subsequent requests should hit negative cache (no database lookup) - _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - - // The key assertion is that repeated requests don't exponentially increase fetches - let finalFetchCount = await dataStore.fetchCallCount - #expect(finalFetchCount <= initialFetchCount + 2) - } - - @Test("Manual fetch clears negative cache and retries") - func manualFetchClearsNegativeCache() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/retry")! - - // First auto-fetch finds nothing - _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - - // Manual fetch should attempt again (clearing negative cache) - _ = await cache.manualFetch(for: url, using: dataStore) - - // Verify manual fetch was attempted (fetch count increased) - let fetchCount = await dataStore.fetchCallCount - #expect(fetchCount >= 1) - } - - // MARK: - In-Flight Deduplication Tests - - @Test("Concurrent requests for same URL don't create duplicate fetches") - func concurrentRequestsAreDeduplicated() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/concurrent")! - - // Add delay to database fetch to simulate slow operation - await dataStore.setFetchDelay(.milliseconds(100)) - - // Launch multiple concurrent requests - async let result1 = cache.preview(for: url, using: dataStore, isChannelMessage: false) - async let result2 = cache.preview(for: url, using: dataStore, isChannelMessage: false) - async let result3 = cache.preview(for: url, using: dataStore, isChannelMessage: false) - - // Wait for all to complete - let results = await [result1, result2, result3] - - // All results should be consistent - #expect(results.count == 3) + // MARK: - Memory Cache Tests + + @Test + func `Returns cached preview from memory on subsequent requests`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/article")) + + // Seed the database with a preview + let dto = LinkPreviewDataDTO( + url: url.absoluteString, + title: "Test Article", + imageData: nil, + iconData: nil + ) + await dataStore.setStoredPreview(dto, for: url.absoluteString) + + // First request should hit database + let result1 = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + #expect(isLoaded(result1, withTitle: "Test Article")) + let fetchCount1 = await dataStore.fetchCallCount + #expect(fetchCount1 == 1) + + // Second request should hit memory cache (no additional fetch) + let result2 = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + #expect(isLoaded(result2, withTitle: "Test Article")) + let fetchCount2 = await dataStore.fetchCallCount + #expect(fetchCount2 == 1) // Should not increase + } + + @Test + func `Memory cache returns correct preview data`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/test")) + + let dto = LinkPreviewDataDTO( + url: url.absoluteString, + title: "Memory Cache Test", + imageData: Data([1, 2, 3]), + iconData: Data([4, 5, 6]) + ) + await dataStore.setStoredPreview(dto, for: url.absoluteString) + + // Load into memory cache + _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + + // Verify cached data matches + let cached = await cache.cachedPreview(for: url) + #expect(cached?.title == "Memory Cache Test") + #expect(cached?.imageData == Data([1, 2, 3])) + #expect(cached?.iconData == Data([4, 5, 6])) + } + + // MARK: - Negative Cache Tests + + @Test + func `Negative cache prevents repeated network fetches for unavailable previews`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/no-preview")) + + // First request finds no preview (returns noPreviewAvailable) + _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + + let initialFetchCount = await dataStore.fetchCallCount + + // Subsequent requests should hit negative cache (no database lookup) + _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + + // The key assertion is that repeated requests don't exponentially increase fetches + let finalFetchCount = await dataStore.fetchCallCount + #expect(finalFetchCount <= initialFetchCount + 2) + } + + @Test + func `Manual fetch clears negative cache and retries`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/retry")) + + // First auto-fetch finds nothing + _ = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + + // Manual fetch should attempt again (clearing negative cache) + _ = await cache.manualFetch(for: url, using: dataStore) + + // Verify manual fetch was attempted (fetch count increased) + let fetchCount = await dataStore.fetchCallCount + #expect(fetchCount >= 1) + } + + // MARK: - In-Flight Deduplication Tests + + @Test + func `Concurrent requests for same URL don't create duplicate fetches`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/concurrent")) + + // Add delay to database fetch to simulate slow operation + await dataStore.setFetchDelay(.milliseconds(100)) + + // Launch multiple concurrent requests + async let result1 = cache.preview(for: url, using: dataStore, isChannelMessage: false) + async let result2 = cache.preview(for: url, using: dataStore, isChannelMessage: false) + async let result3 = cache.preview(for: url, using: dataStore, isChannelMessage: false) + + // Wait for all to complete + let results = await [result1, result2, result3] + + // All results should be consistent + #expect(results.count == 3) + } + + @Test + func `isFetching returns true while fetch is in progress`() async throws { + let cache = LinkPreviewCache() + let url = try #require(URL(string: "https://example.com/inflight")) + + // Initially not fetching + let initiallyFetching = await cache.isFetching(url) + #expect(!initiallyFetching) + } + + @Test + func `Concurrent fetches for the same URL coalesce; every caller receives the loaded result`() async throws { + let fetcher = FakeMetadataFetcher(delay: .milliseconds(200), title: "Coalesced") + let cache = LinkPreviewCache(service: fetcher) + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/coalesce")) + + // manualFetch bypasses the auto-resolve preference gate (off by default in + // tests) and routes through the same coalescing network fetch path. + // Start the first fetch and wait until it is registered in-flight, so the + // second request deterministically arrives during the network fetch window. + let first = Task { await cache.manualFetch(for: url, using: dataStore) } + var spins = 0 + while await !cache.isFetching(url), spins < 1000 { + await Task.yield() + spins += 1 } - @Test("isFetching returns true while fetch is in progress") - func isFetchingReturnsTrueDuringFetch() async { - let cache = LinkPreviewCache() - let url = URL(string: "https://example.com/inflight")! + // A follower arriving mid-flight must receive the resolved result, not a + // `.loading` placeholder that would strand its preview state forever. + let second = await cache.manualFetch(for: url, using: dataStore) + let firstResult = await first.value - // Initially not fetching - let initiallyFetching = await cache.isFetching(url) - #expect(!initiallyFetching) - } + #expect(isLoaded(firstResult, withTitle: "Coalesced")) + #expect(isLoaded(second, withTitle: "Coalesced")) - @Test("Concurrent fetches for the same URL coalesce; every caller receives the loaded result") - func concurrentNetworkFetchesCoalesceToLoaded() async { - let fetcher = FakeMetadataFetcher(delay: .milliseconds(200), title: "Coalesced") - let cache = LinkPreviewCache(service: fetcher) - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/coalesce")! - - // manualFetch bypasses the auto-resolve preference gate (off by default in - // tests) and routes through the same coalescing network fetch path. - // Start the first fetch and wait until it is registered in-flight, so the - // second request deterministically arrives during the network fetch window. - let first = Task { await cache.manualFetch(for: url, using: dataStore) } - var spins = 0 - while await !cache.isFetching(url), spins < 1000 { - await Task.yield() - spins += 1 - } - - // A follower arriving mid-flight must receive the resolved result, not a - // `.loading` placeholder that would strand its preview state forever. - let second = await cache.manualFetch(for: url, using: dataStore) - let firstResult = await first.value - - #expect(isLoaded(firstResult, withTitle: "Coalesced")) - #expect(isLoaded(second, withTitle: "Coalesced")) - - // Coalescing means the underlying network fetch ran exactly once. - let calls = await fetcher.callCount - #expect(calls == 1) - } + // Coalescing means the underlying network fetch ran exactly once. + let calls = await fetcher.callCount + #expect(calls == 1) + } - // MARK: - Database Integration Tests + // MARK: - Database Integration Tests - @Test("Preview is persisted to database after network fetch") - func previewIsPersistedToDatabase() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/persist")! + @Test + func `Preview is persisted to database after network fetch`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/persist")) - // Seed a preview that will be "fetched" - let dto = LinkPreviewDataDTO( - url: url.absoluteString, - title: "Persisted Preview", - imageData: nil, - iconData: nil - ) - await dataStore.setStoredPreview(dto, for: url.absoluteString) + // Seed a preview that will be "fetched" + let dto = LinkPreviewDataDTO( + url: url.absoluteString, + title: "Persisted Preview", + imageData: nil, + iconData: nil + ) + await dataStore.setStoredPreview(dto, for: url.absoluteString) - // Request preview - let result = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + // Request preview + let result = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - #expect(isLoaded(result, withTitle: "Persisted Preview")) - } + #expect(isLoaded(result, withTitle: "Persisted Preview")) + } - @Test("Database errors are handled gracefully") - func databaseErrorsHandledGracefully() async { - let cache = LinkPreviewCache() - let dataStore = MockPreviewDataStore() - let url = URL(string: "https://example.com/error")! + @Test + func `Database errors are handled gracefully`() async throws { + let cache = LinkPreviewCache() + let dataStore = MockPreviewDataStore() + let url = try #require(URL(string: "https://example.com/error")) - // Configure dataStore to throw on fetch - await dataStore.setShouldThrowOnFetch(true) + // Configure dataStore to throw on fetch + await dataStore.setShouldThrowOnFetch(true) - // Request should not crash - let result = await cache.preview(for: url, using: dataStore, isChannelMessage: false) + // Request should not crash + let result = await cache.preview(for: url, using: dataStore, isChannelMessage: false) - // Should return disabled or noPreviewAvailable, not crash - #expect(isDisabledOrNoPreview(result)) - } + // Should return disabled or noPreviewAvailable, not crash + #expect(isDisabledOrNoPreview(result)) + } - // MARK: - Helper Functions + // MARK: - Helper Functions - private func isLoaded(_ result: LinkPreviewResult, withTitle title: String) -> Bool { - if case .loaded(let dto) = result { - return dto.title == title - } - return false + private func isLoaded(_ result: LinkPreviewResult, withTitle title: String) -> Bool { + if case let .loaded(dto) = result { + return dto.title == title } - - private func isDisabledOrNoPreview(_ result: LinkPreviewResult) -> Bool { - switch result { - case .disabled, .noPreviewAvailable: - return true - default: - return false - } + return false + } + + private func isDisabledOrNoPreview(_ result: LinkPreviewResult) -> Bool { + switch result { + case .disabled, .noPreviewAvailable: + true + default: + false } + } } // MARK: - Fake Metadata Fetcher @@ -227,256 +226,468 @@ struct LinkPreviewCacheTests { /// Deterministic stand-in for the LinkPresentation network fetch. Counts calls /// so a test can assert that concurrent same-URL requests coalesce onto one fetch. private actor FakeMetadataFetcher: LinkMetadataFetching { - private(set) var callCount = 0 - private let delay: Duration - private let title: String? - - init(delay: Duration, title: String?) { - self.delay = delay - self.title = title - } - - func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? { - callCount += 1 - if delay > .zero { try? await Task.sleep(for: delay) } - guard let title else { return nil } - return LinkPreviewMetadata(url: url, title: title, imageData: nil, iconData: nil) - } + private(set) var callCount = 0 + private let delay: Duration + private let title: String? + + init(delay: Duration, title: String?) { + self.delay = delay + self.title = title + } + + func fetchMetadata(for url: URL) async -> LinkPreviewMetadata? { + callCount += 1 + if delay > .zero { try? await Task.sleep(for: delay) } + guard let title else { return nil } + return LinkPreviewMetadata(url: url, title: title, imageData: nil, iconData: nil) + } } // MARK: - Mock Data Store private actor MockPreviewDataStore: PersistenceStoreProtocol { - private var storedPreviews: [String: LinkPreviewDataDTO] = [:] - private(set) var fetchCallCount = 0 - private var saveCallCount = 0 - private var fetchDelay: Duration = .zero - private var shouldThrowOnFetch = false - private var shouldThrowOnSave = false - - // Async setters for actor-isolated properties - func setStoredPreview(_ dto: LinkPreviewDataDTO, for url: String) { - storedPreviews[url] = dto + private var storedPreviews: [String: LinkPreviewDataDTO] = [:] + private(set) var fetchCallCount = 0 + private var saveCallCount = 0 + private var fetchDelay: Duration = .zero + private var shouldThrowOnFetch = false + private var shouldThrowOnSave = false + + /// Async setters for actor-isolated properties + func setStoredPreview(_ dto: LinkPreviewDataDTO, for url: String) { + storedPreviews[url] = dto + } + + func setFetchDelay(_ delay: Duration) { + fetchDelay = delay + } + + func setShouldThrowOnFetch(_ value: Bool) { + shouldThrowOnFetch = value + } + + func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { + fetchCallCount += 1 + + if shouldThrowOnFetch { + throw MockError.fetchFailed } - func setFetchDelay(_ delay: Duration) { - fetchDelay = delay + if fetchDelay > .zero { + try? await Task.sleep(for: fetchDelay) } - func setShouldThrowOnFetch(_ value: Bool) { - shouldThrowOnFetch = value - } - - func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { - fetchCallCount += 1 - - if shouldThrowOnFetch { - throw MockError.fetchFailed - } + return storedPreviews[url] + } - if fetchDelay > .zero { - try? await Task.sleep(for: fetchDelay) - } + func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws { + saveCallCount += 1 - return storedPreviews[url] + if shouldThrowOnSave { + throw MockError.saveFailed } - func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws { - saveCallCount += 1 - - if shouldThrowOnSave { - throw MockError.saveFailed - } - - storedPreviews[dto.url] = dto - } - - private enum MockError: Error { - case fetchFailed - case saveFailed - } - - // MARK: - Required Protocol Stubs - - // Message Operations - func saveMessage(_ dto: MessageDTO) async throws {} - func fetchMessage(id: UUID) async throws -> MessageDTO? { nil } - func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } - func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } - func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } - func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } - func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} - func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} - func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} - func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} - func updateMessageLinkPreview(id: UUID, url: String?, title: String?, imageData: Data?, iconData: Data?, fetched: Bool) throws {} - - // Contact Operations - func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { [] } - func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { [] } - func fetchContact(id: UUID) async throws -> ContactDTO? { nil } - func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { nil } - func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { nil } - @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { UUID() } - func saveContact(_ dto: ContactDTO) async throws {} - func deleteContact(id: UUID) async throws {} - func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} - func incrementUnreadCount(contactID: UUID) async throws {} - func clearUnreadCount(contactID: UUID) async throws {} - - // Mention Tracking - func markMentionSeen(messageID: UUID) async throws {} - func incrementUnreadMentionCount(contactID: UUID) async throws {} - func decrementUnreadMentionCount(contactID: UUID) async throws {} - func clearUnreadMentionCount(contactID: UUID) async throws {} - func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func clearChannelUnreadMentionCount(channelID: UUID) async throws {} - func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { [] } - func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { [] } - func deleteMessagesForContact(contactID: UUID) async throws {} - func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { [] } - - // Blocked Channel Senders - func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} - func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} - func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} - func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { [] } - - // Channel Operations - func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { [] } - func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { nil } - func fetchChannel(id: UUID) async throws -> ChannelDTO? { nil } - @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { UUID() } - func saveChannel(_ dto: ChannelDTO) async throws {} - func deleteChannel(id: UUID) async throws {} - func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} - func incrementChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} - - // Saved Trace Paths - func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { [] } - func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { nil } - func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { - SavedTracePathDTO(id: UUID(), radioID: radioID, name: name, pathBytes: pathBytes, hashSize: hashSize, createdDate: Date(), runs: []) - } - func updateSavedTracePathName(id: UUID, name: String) async throws {} - func deleteSavedTracePath(id: UUID) async throws {} - func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} - - // Heard Repeats - func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? { nil } - func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} - func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { [] } - func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { false } - func incrementMessageHeardRepeats(id: UUID) async throws -> Int { 0 } - func deleteMessageRepeats(messageID: UUID) async throws {} - func incrementMessageSendCount(id: UUID) async throws -> Int { 0 } - func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} - - // Debug Log Entries - func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} - func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { [] } - func countDebugLogEntries() async throws -> Int { 0 } - func pruneDebugLogEntries(keepCount: Int) async throws {} - func clearDebugLogEntries() async throws {} - - // Contact Public Keys - func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { [:] } - - // RxLogEntry Lookup - func findRxLogEntry(radioID: UUID, channelIndex: UInt8?, senderTimestamp: UInt32) async throws -> RxLogEntryDTO? { nil } - func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { nil } - - // Room Message Operations - func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} - func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { nil } - func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { [] } - func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { false } - func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { false } - func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} - func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} - func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} - - // Discovered Nodes - func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { - fatalError("Not implemented") - } - func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} - func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { [] } - func deleteDiscoveredNode(id: UUID) async throws {} - func clearDiscoveredNodes(radioID: UUID) async throws {} - func fetchContactPublicKeys(radioID: UUID) async throws -> Set { Set() } - - // Reactions - func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { [] } - func saveReaction(_ dto: ReactionDTO) async throws {} - func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { false } - func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} - func deleteReactionsForMessage(messageID: UUID) async throws {} - func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - - // Notification Level - func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} - func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} - func fetchDevice(id: UUID) async throws -> DeviceDTO? { nil } - func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { nil } - func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} - func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { nil } - func markSessionDisconnected(_ sessionID: UUID) async throws {} - func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { false } - func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { false } - func clearRetryingToSent(id: UUID) async throws -> Bool { false } - func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { false } - func markMessageAsRead(id: UUID) async throws {} - func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { nil } - func saveDevice(_ dto: DeviceDTO) async throws {} - func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { [] } - func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { [] } - func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} - func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} - func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} - func deleteRemoteNodeSession(id: UUID) async throws {} - func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} - func resetRoomUnreadCount(_ sessionID: UUID) async throws {} - func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { nil } - func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { nil } - func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} - func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } - func clearRxLogEntries(radioID: UUID) async throws {} - func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { [] } - func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} - func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} - func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - - // Channel Message Deletion - func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} - - // Node Status Snapshots - // swiftlint:disable:next line_length function_parameter_count - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } - func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } - func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } - func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} - func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} - func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { UUID() } - func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { UUID() } - func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} - - // Pending Sends - func upsertPendingSend(_ dto: PendingSendDTO) async throws {} - func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { 0 } - func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { [] } - func deletePendingSend(id: UUID) async throws {} - func deletePendingSendsForMessage(messageID: UUID) async throws {} - func hasPendingSend(messageID: UUID) async throws -> Bool { false } + storedPreviews[dto.url] = dto + } + + private enum MockError: Error { + case fetchFailed + case saveFailed + } + + // MARK: - Required Protocol Stubs + + // Message Operations + func saveMessage(_ dto: MessageDTO) async throws {} + func fetchMessage(id: UUID) async throws -> MessageDTO? { + nil + } + + func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { + [] + } + + func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { + [] + } + + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + [:] + } + + func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + [:] + } + + func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} + func updateMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} + func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} + func updateMessageLinkPreview(id: UUID, url: String?, title: String?, imageData: Data?, iconData: Data?, fetched: Bool) throws {} + + /// Contact Operations + func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + func fetchContact(id: UUID) async throws -> ContactDTO? { + nil + } + + func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { + nil + } + + func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { + nil + } + + @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { + UUID() + } + + func saveContact(_ dto: ContactDTO) async throws {} + func deleteContact(id: UUID) async throws {} + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} + func incrementUnreadCount(contactID: UUID) async throws {} + func clearUnreadCount(contactID: UUID) async throws {} + + // Mention Tracking + func markMentionSeen(messageID: UUID) async throws {} + func incrementUnreadMentionCount(contactID: UUID) async throws {} + func decrementUnreadMentionCount(contactID: UUID) async throws {} + func clearUnreadMentionCount(contactID: UUID) async throws {} + func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} + func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} + func clearChannelUnreadMentionCount(channelID: UUID) async throws {} + func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { + [] + } + + func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { + [] + } + + func deleteMessagesForContact(contactID: UUID) async throws {} + func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { + [] + } + + // Blocked Channel Senders + func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} + func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} + func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} + func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { + [] + } + + /// Channel Operations + func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { + [] + } + + func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { + nil + } + + func fetchChannel(id: UUID) async throws -> ChannelDTO? { + nil + } + + @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { + UUID() + } + + func saveChannel(_ dto: ChannelDTO) async throws {} + func deleteChannel(id: UUID) async throws {} + func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} + func incrementChannelUnreadCount(channelID: UUID) async throws {} + func clearChannelUnreadCount(channelID: UUID) async throws {} + func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} + + /// Saved Trace Paths + func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { + [] + } + + func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { + nil + } + + func createSavedTracePath(radioID: UUID, name: String, pathBytes: Data, hashSize: Int, initialRun: TracePathRunDTO?) async throws -> SavedTracePathDTO { + SavedTracePathDTO(id: UUID(), radioID: radioID, name: name, pathBytes: pathBytes, hashSize: hashSize, createdDate: Date(), runs: []) + } + + func updateSavedTracePathName(id: UUID, name: String) async throws {} + func deleteSavedTracePath(id: UUID) async throws {} + func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} + + /// Heard Repeats + func findSentChannelMessage(radioID: UUID, channelIndex: UInt8, timestamp: UInt32, text: String, withinSeconds: Int) async throws -> MessageDTO? { + nil + } + + func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} + func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { + [] + } + + func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { + false + } + + func incrementMessageHeardRepeats(id: UUID) async throws -> Int { + 0 + } + + func deleteMessageRepeats(messageID: UUID) async throws {} + func incrementMessageSendCount(id: UUID) async throws -> Int { + 0 + } + + func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} + + // Debug Log Entries + func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} + func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { + [] + } + + func countDebugLogEntries() async throws -> Int { + 0 + } + + func pruneDebugLogEntries(keepCount: Int) async throws {} + func clearDebugLogEntries() async throws {} + + /// Contact Public Keys + func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { + [:] + } + + /// RxLogEntry Lookup + func findRxLogEntry(radioID: UUID, channelIndex: UInt8?, senderTimestamp: UInt32) async throws -> RxLogEntryDTO? { + nil + } + + func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { + nil + } + + // Room Message Operations + func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} + func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { + nil + } + + func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { + [] + } + + func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { + false + } + + func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { + false + } + + func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} + func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} + func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} + + /// Discovered Nodes + func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { + fatalError("Not implemented") + } + + func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} + func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { + [] + } + + func deleteDiscoveredNode(id: UUID) async throws {} + func clearDiscoveredNodes(radioID: UUID) async throws {} + func fetchContactPublicKeys(radioID: UUID) async throws -> Set { + Set() + } + + /// Reactions + func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { + [] + } + + func saveReaction(_ dto: ReactionDTO) async throws {} + func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { + false + } + + func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} + func deleteReactionsForMessage(messageID: UUID) async throws {} + func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { + nil + } + + func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { + [] + } + + func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { + [] + } + + func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { + nil + } + + // Notification Level + func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} + func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} + func fetchDevice(id: UUID) async throws -> DeviceDTO? { + nil + } + + func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { + nil + } + + func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} + func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { + nil + } + + func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { + nil + } + + func markSessionDisconnected(_ sessionID: UUID) async throws {} + func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { + false + } + + func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { + false + } + + func clearRetryingToSent(id: UUID) async throws -> Bool { + false + } + + func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { + false + } + + func markMessageAsRead(id: UUID) async throws {} + func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { + nil + } + + func saveDevice(_ dto: DeviceDTO) async throws {} + func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { + nil + } + + func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + [] + } + + func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { + [] + } + + func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} + func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} + func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} + func deleteRemoteNodeSession(id: UUID) async throws {} + func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} + func resetRoomUnreadCount(_ sessionID: UUID) async throws {} + func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { + nil + } + + func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { + nil + } + + func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} + func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { + [] + } + + func clearRxLogEntries(radioID: UUID) async throws {} + func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} + func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + [] + } + + func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { + [] + } + + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} + + /// Channel Message Deletion + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} + + // Node Status Snapshots + // swiftlint:disable:next line_length function_parameter_count + func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? + ) async throws -> UUID { + UUID() + } + + func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { + nil + } + + func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { + [] + } + + func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} + func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} + func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { + UUID() + } + + func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { + UUID() + } + + func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} + + // Pending Sends + func upsertPendingSend(_ dto: PendingSendDTO) async throws {} + func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { + 0 + } + + func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { + [] + } + + func deletePendingSend(id: UUID) async throws {} + func deletePendingSendsForMessage(messageID: UUID) async throws {} + func hasPendingSend(messageID: UUID) async throws -> Bool { + false + } } diff --git a/MC1Tests/Services/LinkPreviewServiceTests.swift b/MC1Tests/Services/LinkPreviewServiceTests.swift index 35907887..f7d55cd6 100644 --- a/MC1Tests/Services/LinkPreviewServiceTests.swift +++ b/MC1Tests/Services/LinkPreviewServiceTests.swift @@ -1,170 +1,169 @@ -import Testing - @testable import MC1 +import Testing @Suite("LinkPreviewService Tests") @MainActor struct LinkPreviewServiceTests { - @Test("Extracts HTTPS URL from text") - func extractsHTTPSURL() { - let text = "Check out https://example.com/article for more info" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://example.com/article") - } - - @Test("Extracts HTTP URL from text") - func extractsHTTPURL() { - let text = "Visit http://example.com" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.scheme == "http") - } - - @Test("Returns nil for text without URLs") - func returnsNilForNoURL() { - let text = "Just some plain text without links" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil) - } - - @Test("Extracts first URL when multiple URLs present") - func extractsFirstURLOnly() { - let text = "First https://first.com then https://second.com" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.host == "first.com") - } - - @Test("Ignores non-HTTP schemes like tel: and mailto:") - func ignoresNonHTTPSchemes() { - let text = "Call me at tel:+1234567890 or mailto:test@example.com" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil) - } - - @Test("Extracts URL with path and query string") - func extractsURLWithPath() { - let text = "Read https://example.com/blog/2024/article-title?ref=social" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.path == "/blog/2024/article-title") - #expect(url?.query == "ref=social") - } - - @Test("Extracts URL at beginning of text") - func extractsURLAtBeginning() { - let text = "https://example.com is a great site" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://example.com") - } - - @Test("Extracts URL at end of text") - func extractsURLAtEnd() { - let text = "Check this out: https://example.com" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://example.com") - } - - @Test("Returns nil for empty text") - func returnsNilForEmptyText() { - let text = "" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil) - } - - @Test("Handles URL with fragment") - func handlesURLWithFragment() { - let text = "See https://example.com/page#section" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.fragment == "section") - } - - // MARK: - URL in Mention Tests - - @Test("Ignores URL-like text within mention brackets") - func ignoresURLInMention() { - let text = "Hey @[Ferret PocketMesh WCMesh.com], check this out!" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil, "WCMesh.com within @[] should not be extracted as a URL") - } - - @Test("Ignores domain-like text within mention brackets") - func ignoresDomainInMention() { - let text = "@[Server node.example.com] says hello" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil, "node.example.com within @[] should not be extracted") - } - - @Test("Extracts real URL when mention also contains URL-like text") - func extractsRealURLNotMentionURL() { - let text = "@[Server node.example.com] says check https://docs.example.com" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://docs.example.com") - } - - @Test("Extracts URL when no mentions present") - func extractsURLWithoutMentions() { - let text = "Just a normal message with https://example.com link" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://example.com") - } - - @Test("Returns nil when only URL-like text in mention") - func returnsNilForOnlyMentionURL() { - let text = "Message from @[192.168.1.100]" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil, "IP address in mention should not be extracted") - } - - // MARK: - Meshcore-open GIF Format Tests - - @Test("Extracts Giphy URL from g: prefix message") - func extractsGiphyFromGPrefix() { - let text = "g:JgWZYoIgjzsIQO8joZ" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://media.giphy.com/media/JgWZYoIgjzsIQO8joZ/giphy.gif") - } - - @Test("Extracts Giphy URL from g: with whitespace") - func extractsGiphyWithWhitespace() { - let text = " g:ABC123xyz " - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://media.giphy.com/media/ABC123xyz/giphy.gif") - } - - @Test("Handles g: with hyphens and underscores in ID") - func extractsGiphyWithHyphensUnderscores() { - let text = "g:my-gif_ID-123" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url?.absoluteString == "https://media.giphy.com/media/my-gif_ID-123/giphy.gif") - } - - @Test("Returns nil for g: with no ID") - func returnsNilForEmptyGPrefix() { - let text = "g:" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil) - } - - @Test("Does not match g: embedded in longer text") - func doesNotMatchEmbeddedGPrefix() { - let text = "Check out g:ABC123 please" - let url = LinkPreviewService.extractFirstURL(from: text) - // Should not match because wholeMatch requires entire string - #expect(url == nil) - } - - @Test("Does not match g: with invalid characters in ID") - func doesNotMatchInvalidGPrefixChars() { - let text = "g:ABC 123" - let url = LinkPreviewService.extractFirstURL(from: text) - #expect(url == nil) - } - - @Test("extractGiphyGIFURL returns nil for plain text") - func giphyExtractReturnsNilForPlainText() { - #expect(LinkPreviewService.extractGiphyGIFURL(from: "hello world") == nil) - } - - @Test("extractGiphyGIFURL returns nil for regular URL") - func giphyExtractReturnsNilForRegularURL() { - #expect(LinkPreviewService.extractGiphyGIFURL(from: "https://example.com") == nil) - } + @Test + func `Extracts HTTPS URL from text`() { + let text = "Check out https://example.com/article for more info" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://example.com/article") + } + + @Test + func `Extracts HTTP URL from text`() { + let text = "Visit http://example.com" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.scheme == "http") + } + + @Test + func `Returns nil for text without URLs`() { + let text = "Just some plain text without links" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil) + } + + @Test + func `Extracts first URL when multiple URLs present`() { + let text = "First https://first.com then https://second.com" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.host == "first.com") + } + + @Test + func `Ignores non-HTTP schemes like tel: and mailto:`() { + let text = "Call me at tel:+1234567890 or mailto:test@example.com" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil) + } + + @Test + func `Extracts URL with path and query string`() { + let text = "Read https://example.com/blog/2024/article-title?ref=social" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.path == "/blog/2024/article-title") + #expect(url?.query == "ref=social") + } + + @Test + func `Extracts URL at beginning of text`() { + let text = "https://example.com is a great site" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://example.com") + } + + @Test + func `Extracts URL at end of text`() { + let text = "Check this out: https://example.com" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://example.com") + } + + @Test + func `Returns nil for empty text`() { + let text = "" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil) + } + + @Test + func `Handles URL with fragment`() { + let text = "See https://example.com/page#section" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.fragment == "section") + } + + // MARK: - URL in Mention Tests + + @Test + func `Ignores URL-like text within mention brackets`() { + let text = "Hey @[Ferret PocketMesh WCMesh.com], check this out!" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil, "WCMesh.com within @[] should not be extracted as a URL") + } + + @Test + func `Ignores domain-like text within mention brackets`() { + let text = "@[Server node.example.com] says hello" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil, "node.example.com within @[] should not be extracted") + } + + @Test + func `Extracts real URL when mention also contains URL-like text`() { + let text = "@[Server node.example.com] says check https://docs.example.com" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://docs.example.com") + } + + @Test + func `Extracts URL when no mentions present`() { + let text = "Just a normal message with https://example.com link" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://example.com") + } + + @Test + func `Returns nil when only URL-like text in mention`() { + let text = "Message from @[192.168.1.100]" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil, "IP address in mention should not be extracted") + } + + // MARK: - Meshcore-open GIF Format Tests + + @Test + func `Extracts Giphy URL from g: prefix message`() { + let text = "g:JgWZYoIgjzsIQO8joZ" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://media.giphy.com/media/JgWZYoIgjzsIQO8joZ/giphy.gif") + } + + @Test + func `Extracts Giphy URL from g: with whitespace`() { + let text = " g:ABC123xyz " + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://media.giphy.com/media/ABC123xyz/giphy.gif") + } + + @Test + func `Handles g: with hyphens and underscores in ID`() { + let text = "g:my-gif_ID-123" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url?.absoluteString == "https://media.giphy.com/media/my-gif_ID-123/giphy.gif") + } + + @Test + func `Returns nil for g: with no ID`() { + let text = "g:" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil) + } + + @Test + func `Does not match g: embedded in longer text`() { + let text = "Check out g:ABC123 please" + let url = LinkPreviewService.extractFirstURL(from: text) + // Should not match because wholeMatch requires entire string + #expect(url == nil) + } + + @Test + func `Does not match g: with invalid characters in ID`() { + let text = "g:ABC 123" + let url = LinkPreviewService.extractFirstURL(from: text) + #expect(url == nil) + } + + @Test + func `extractGiphyGIFURL returns nil for plain text`() { + #expect(LinkPreviewService.extractGiphyGIFURL(from: "hello world") == nil) + } + + @Test + func `extractGiphyGIFURL returns nil for regular URL`() { + #expect(LinkPreviewService.extractGiphyGIFURL(from: "https://example.com") == nil) + } } diff --git a/MC1Tests/Services/MapSnapshotStoreTests.swift b/MC1Tests/Services/MapSnapshotStoreTests.swift index 307432cc..065bc4c3 100644 --- a/MC1Tests/Services/MapSnapshotStoreTests.swift +++ b/MC1Tests/Services/MapSnapshotStoreTests.swift @@ -1,220 +1,223 @@ +@testable import MC1 import Testing import UIKit -@testable import MC1 @Suite("MapSnapshotStore Tests") @MainActor struct MapSnapshotStoreTests { - - /// Fake renderer: returns a 1x1 solid image after an optional gate, counting - /// calls. No MapLibre/GL. - final class FakeRenderer: MapSnapshotRendering { - private(set) var renderCount = 0 - var gate: CheckedContinuation? - var shouldFail = false - - func render(_ request: MapSnapshotRequest) async -> UIImage? { - renderCount += 1 - if gate == nil, awaitGate { - await withCheckedContinuation { gate = $0 } - } - if shouldFail { return nil } - let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) - return renderer.image { ctx in - UIColor.red.setFill() - ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) - } - } - - var awaitGate = false - func release() { gate?.resume(); gate = nil } - } - - private func request( - _ lat: Double = 37.0, - _ lon: Double = -122.0, - dark: Bool = false, - offline: Bool = false - ) -> MapSnapshotRequest { - MapSnapshotRequest(latitude: lat, longitude: lon, isDark: dark, isOffline: offline) - } - - @Test("A cache miss returns nil synchronously; isResolved is false") - func missIsNilAndUnresolved() { - let store = MapSnapshotStore(renderer: FakeRenderer()) - let req = request() - #expect(store.image(for: req) == nil) - #expect(store.isResolved(req) == false) - } - - @Test("Signed-zero coordinates normalize so cacheKey agrees with Hashable equality") - func signedZeroNormalizes() { - // A latitude that rounds to -0.0 must key identically to +0.0: the two are - // Hashable-equal, so a divergent cacheKey string would split the cache. - let negativeZero = MapSnapshotRequest(latitude: -0.000004, longitude: 50.0, isDark: false, isOffline: false) - let positiveZero = MapSnapshotRequest(latitude: 0.0, longitude: 50.0, isDark: false, isOffline: false) - #expect(negativeZero == positiveZero) - #expect(negativeZero.hashValue == positiveZero.hashValue) - #expect(negativeZero.cacheKey == positiveZero.cacheKey) + /// Fake renderer: returns a 1x1 solid image after an optional gate, counting + /// calls. No MapLibre/GL. + final class FakeRenderer: MapSnapshotRendering { + private(set) var renderCount = 0 + var gate: CheckedContinuation? + var shouldFail = false + + func render(_ request: MapSnapshotRequest) async -> UIImage? { + renderCount += 1 + if gate == nil, awaitGate { + await withCheckedContinuation { gate = $0 } + } + if shouldFail { return nil } + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) + return renderer.image { ctx in + UIColor.red.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + } } - @Test("Online and offline requests for the same coordinate produce distinct cache keys") - func isOfflineDistinguishesCacheKey() { - let online = MapSnapshotRequest(latitude: 37.0, longitude: -122.0, isDark: false, isOffline: false) - let offline = MapSnapshotRequest(latitude: 37.0, longitude: -122.0, isDark: false, isOffline: true) - #expect(online != offline) - #expect(online.hashValue != offline.hashValue) - #expect(online.cacheKey != offline.cacheKey) + var awaitGate = false + func release() { + gate?.resume(); gate = nil } - - @Test("A completed render caches the image and emits on the stream") - func completedRenderCachesAndEmits() async { - let fake = FakeRenderer() - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - let emitted = await iterator.next() - - #expect(emitted == req) - #expect(store.image(for: req) != nil) - #expect(store.isResolved(req) == true) - #expect(fake.renderCount == 1) + } + + private func request( + _ lat: Double = 37.0, + _ lon: Double = -122.0, + dark: Bool = false, + offline: Bool = false + ) -> MapSnapshotRequest { + MapSnapshotRequest(latitude: lat, longitude: lon, isDark: dark, isOffline: offline) + } + + @Test + func `A cache miss returns nil synchronously; isResolved is false`() { + let store = MapSnapshotStore(renderer: FakeRenderer()) + let req = request() + #expect(store.image(for: req) == nil) + #expect(store.isResolved(req) == false) + } + + @Test + func `Signed-zero coordinates normalize so cacheKey agrees with Hashable equality`() { + // A latitude that rounds to -0.0 must key identically to +0.0: the two are + // Hashable-equal, so a divergent cacheKey string would split the cache. + let negativeZero = MapSnapshotRequest(latitude: -0.000004, longitude: 50.0, isDark: false, isOffline: false) + let positiveZero = MapSnapshotRequest(latitude: 0.0, longitude: 50.0, isDark: false, isOffline: false) + #expect(negativeZero == positiveZero) + #expect(negativeZero.hashValue == positiveZero.hashValue) + #expect(negativeZero.cacheKey == positiveZero.cacheKey) + } + + @Test + func `Online and offline requests for the same coordinate produce distinct cache keys`() { + let online = MapSnapshotRequest(latitude: 37.0, longitude: -122.0, isDark: false, isOffline: false) + let offline = MapSnapshotRequest(latitude: 37.0, longitude: -122.0, isDark: false, isOffline: true) + #expect(online != offline) + #expect(online.hashValue != offline.hashValue) + #expect(online.cacheKey != offline.cacheKey) + } + + @Test + func `A completed render caches the image and emits on the stream`() async { + let fake = FakeRenderer() + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + let emitted = await iterator.next() + + #expect(emitted == req) + #expect(store.image(for: req) != nil) + #expect(store.isResolved(req) == true) + #expect(fake.renderCount == 1) + } + + @Test + func `A failed render marks the request resolved with no image`() async { + let fake = FakeRenderer() + fake.shouldFail = true + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + _ = await iterator.next() + + #expect(store.image(for: req) == nil) + #expect(store.isResolved(req) == true) + } + + @Test + func `N concurrent requests for the same key enqueue exactly one render`() async { + let fake = FakeRenderer() + fake.awaitGate = true + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + store.request(req) + store.request(req) + // Wait until the single render reaches the gate (it bumps renderCount + // before parking on the gate); poll instead of guessing the yield count. + while fake.renderCount == 0 { + await Task.yield() } - - @Test("A failed render marks the request resolved with no image") - func failedRenderResolvesWithoutImage() async { - let fake = FakeRenderer() - fake.shouldFail = true - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - _ = await iterator.next() - - #expect(store.image(for: req) == nil) - #expect(store.isResolved(req) == true) + #expect(fake.renderCount == 1) + + fake.release() + _ = await iterator.next() + #expect(store.isResolved(req) == true) + } + + @Test + func `A cached request is not re-rendered`() async { + let fake = FakeRenderer() + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + _ = await iterator.next() + #expect(fake.renderCount == 1) + + store.request(req) + await Task.yield() + #expect(fake.renderCount == 1) + } + + @Test + func `The failed set is capped; the oldest entry is evicted when the limit is exceeded`() async { + let fake = FakeRenderer() + fake.shouldFail = true + let store = MapSnapshotStore(renderer: fake) + let limit = MapSnapshotStore.failedSetSizeLimit + + var iterator = store.resolutionStream().makeAsyncIterator() + for i in 0..<(limit + 1) { + store.request(request(Double(i), 0)) + _ = await iterator.next() } - @Test("N concurrent requests for the same key enqueue exactly one render") - func dedupesConcurrentSameKey() async { - let fake = FakeRenderer() - fake.awaitGate = true - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - store.request(req) - store.request(req) - // Wait until the single render reaches the gate (it bumps renderCount - // before parking on the gate); poll instead of guessing the yield count. - while fake.renderCount == 0 { await Task.yield() } - #expect(fake.renderCount == 1) - - fake.release() - _ = await iterator.next() - #expect(store.isResolved(req) == true) - } - - @Test("A cached request is not re-rendered") - func cachedRequestNotRerendered() async { - let fake = FakeRenderer() - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - _ = await iterator.next() - #expect(fake.renderCount == 1) - - store.request(req) - await Task.yield() - #expect(fake.renderCount == 1) - } - - @Test("The failed set is capped; the oldest entry is evicted when the limit is exceeded") - func failedSetCapEvictsOldest() async { - let fake = FakeRenderer() - fake.shouldFail = true - let store = MapSnapshotStore(renderer: fake) - let limit = MapSnapshotStore.failedSetSizeLimit - - var iterator = store.resolutionStream().makeAsyncIterator() - for i in 0..<(limit + 1) { - store.request(request(Double(i), 0)) - _ = await iterator.next() - } - - // Oldest entry was evicted; a fresh request would re-render. - #expect(store.isResolved(request(0, 0)) == false) - // Newest entry is still marked failed. - #expect(store.isResolved(request(Double(limit), 0)) == true) - } - - @Test("clear() yields resolutions for previously-resolved requests so on-screen rows reload") - func clearYieldsResolutionsForResolvedRequests() async { - let fake = FakeRenderer() - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - let firstEmit = await iterator.next() - #expect(firstEmit == req) - #expect(store.image(for: req) != nil) - - store.clear() - - let secondEmit = await iterator.next() - #expect(secondEmit == req) - #expect(store.image(for: req) == nil) - #expect(store.isResolved(req) == false) - } - - @Test("clearFailures() drops failed entries so the next request triggers a retry") - func clearFailuresAllowsRetry() async { - let fake = FakeRenderer() - fake.shouldFail = true - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iterator = store.resolutionStream().makeAsyncIterator() - store.request(req) - _ = await iterator.next() - #expect(store.isResolved(req) == true) - - store.clearFailures() - // Drain the invalidation yield emitted by clearFailures() so the next - // iterator.next() sees the retry's resolution, not the stale invalidation. - _ = await iterator.next() - #expect(store.isResolved(req) == false) - - fake.shouldFail = false - store.request(req) - _ = await iterator.next() - #expect(store.image(for: req) != nil) - #expect(fake.renderCount == 2) - } - - @Test("Every subscriber receives each resolution (multicast, not single-consumer split)") - func multicastDeliversToAllSubscribers() async { - let fake = FakeRenderer() - let store = MapSnapshotStore(renderer: fake) - let req = request() - - var iteratorA = store.resolutionStream().makeAsyncIterator() - var iteratorB = store.resolutionStream().makeAsyncIterator() - store.request(req) - - // A single shared AsyncStream would hand the yield to only one iterator; - // both must see it. - let emittedA = await iteratorA.next() - let emittedB = await iteratorB.next() - #expect(emittedA == req) - #expect(emittedB == req) - #expect(fake.renderCount == 1) - } + // Oldest entry was evicted; a fresh request would re-render. + #expect(store.isResolved(request(0, 0)) == false) + // Newest entry is still marked failed. + #expect(store.isResolved(request(Double(limit), 0)) == true) + } + + @Test + func `clear() yields resolutions for previously-resolved requests so on-screen rows reload`() async { + let fake = FakeRenderer() + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + let firstEmit = await iterator.next() + #expect(firstEmit == req) + #expect(store.image(for: req) != nil) + + store.clear() + + let secondEmit = await iterator.next() + #expect(secondEmit == req) + #expect(store.image(for: req) == nil) + #expect(store.isResolved(req) == false) + } + + @Test + func `clearFailures() drops failed entries so the next request triggers a retry`() async { + let fake = FakeRenderer() + fake.shouldFail = true + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iterator = store.resolutionStream().makeAsyncIterator() + store.request(req) + _ = await iterator.next() + #expect(store.isResolved(req) == true) + + store.clearFailures() + // Drain the invalidation yield emitted by clearFailures() so the next + // iterator.next() sees the retry's resolution, not the stale invalidation. + _ = await iterator.next() + #expect(store.isResolved(req) == false) + + fake.shouldFail = false + store.request(req) + _ = await iterator.next() + #expect(store.image(for: req) != nil) + #expect(fake.renderCount == 2) + } + + @Test + func `Every subscriber receives each resolution (multicast, not single-consumer split)`() async { + let fake = FakeRenderer() + let store = MapSnapshotStore(renderer: fake) + let req = request() + + var iteratorA = store.resolutionStream().makeAsyncIterator() + var iteratorB = store.resolutionStream().makeAsyncIterator() + store.request(req) + + // A single shared AsyncStream would hand the yield to only one iterator; + // both must see it. + let emittedA = await iteratorA.next() + let emittedB = await iteratorB.next() + #expect(emittedA == req) + #expect(emittedB == req) + #expect(fake.renderCount == 1) + } } diff --git a/MC1Tests/Services/RegionResolverTests.swift b/MC1Tests/Services/RegionResolverTests.swift index 34293360..57fd2e86 100644 --- a/MC1Tests/Services/RegionResolverTests.swift +++ b/MC1Tests/Services/RegionResolverTests.swift @@ -1,64 +1,64 @@ import CoreLocation import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @Suite("RegionResolver") @MainActor struct RegionResolverTests { + // MARK: - Test doubles - // MARK: - Test doubles + private final class StubGeocoder: Geocoder, @unchecked Sendable { + var stub: GeocodeResult? + var error: Error? + private(set) var cancelGeocodeCallCount = 0 - private final class StubGeocoder: Geocoder, @unchecked Sendable { - var stub: GeocodeResult? - var error: Error? - private(set) var cancelGeocodeCallCount = 0 - - func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? { - if let error { throw error } - return stub - } + func reverseGeocode(_ location: CLLocation, preferredLocale: Locale?) async throws -> GeocodeResult? { + if let error { throw error } + return stub + } - func cancelGeocode() { - cancelGeocodeCallCount += 1 - } + func cancelGeocode() { + cancelGeocodeCallCount += 1 } + } - // MARK: - Failure paths - // - // These three tests exercise the `location.isAuthorized` guard — the resolver - // returns nil before the geocoder runs when authorization is undetermined. - // Success-path coverage requires injecting a stubbed `LocationService`, which - // is a follow-up (LocationService is not currently abstracted behind a - // protocol). + // MARK: - Failure paths - @Test("nil isoCountryCode → nil") - func nilCountryCodeReturnsNil() async { - let location = LocationService() - let geocoder = StubGeocoder() - geocoder.stub = nil - let resolver = RegionResolver(location: location, geocoder: geocoder) - let result = await resolver.resolve() - #expect(result == nil) - } + // + // These three tests exercise the `location.isAuthorized` guard — the resolver + // returns nil before the geocoder runs when authorization is undetermined. + // Success-path coverage requires injecting a stubbed `LocationService`, which + // is a follow-up (LocationService is not currently abstracted behind a + // protocol). - @Test("Geocoder error → nil") - func geocoderErrorReturnsNil() async { - let location = LocationService() - let geocoder = StubGeocoder() - geocoder.error = NSError(domain: "test", code: -1) - let resolver = RegionResolver(location: location, geocoder: geocoder) - let result = await resolver.resolve() - #expect(result == nil) - } + @Test + func `nil isoCountryCode → nil`() async { + let location = LocationService() + let geocoder = StubGeocoder() + geocoder.stub = nil + let resolver = RegionResolver(location: location, geocoder: geocoder) + let result = await resolver.resolve() + #expect(result == nil) + } - @Test("Unauthorized location → nil") - func unauthorizedReturnsNil() async { - let location = LocationService() // .notDetermined by default - let geocoder = StubGeocoder() - let resolver = RegionResolver(location: location, geocoder: geocoder) - let result = await resolver.resolve() - #expect(result == nil) - } + @Test + func `Geocoder error → nil`() async { + let location = LocationService() + let geocoder = StubGeocoder() + geocoder.error = NSError(domain: "test", code: -1) + let resolver = RegionResolver(location: location, geocoder: geocoder) + let result = await resolver.resolve() + #expect(result == nil) + } + + @Test + func `Unauthorized location → nil`() async { + let location = LocationService() // .notDetermined by default + let geocoder = StubGeocoder() + let resolver = RegionResolver(location: location, geocoder: geocoder) + let result = await resolver.resolve() + #expect(result == nil) + } } diff --git a/MC1Tests/SidebarNavigationLayoutTests.swift b/MC1Tests/SidebarNavigationLayoutTests.swift index 083b5ffe..f953db4e 100644 --- a/MC1Tests/SidebarNavigationLayoutTests.swift +++ b/MC1Tests/SidebarNavigationLayoutTests.swift @@ -1,78 +1,77 @@ -import Testing -import SwiftUI @testable import MC1 +import SwiftUI +import Testing @Suite("SidebarNavigationLayout") struct SidebarNavigationLayoutTests { + /// Point width of an 11-inch iPad in portrait. The narrowest device we promise + /// three-column tiling for (iPad mini at 744pt intentionally collapses, matching Mail). + static let iPad11InchPortraitWidth: CGFloat = 834 - /// Point width of an 11-inch iPad in portrait. The narrowest device we promise - /// three-column tiling for (iPad mini at 744pt intentionally collapses, matching Mail). - static let iPad11InchPortraitWidth: CGFloat = 834 + /// Point width of an iPad mini in portrait. Intentionally below the tiling breakpoint + /// so it collapses to the section's hidden shape rather than tiling three columns. + static let iPadMiniPortraitWidth: CGFloat = 744 - /// Point width of an iPad mini in portrait. Intentionally below the tiling breakpoint - /// so it collapses to the section's hidden shape rather than tiling three columns. - static let iPadMiniPortraitWidth: CGFloat = 744 + /// Upper bound for an icon-only sidebar width; a full text sidebar would exceed this. + static let iconSidebarUpperBound: CGFloat = 115 - /// Upper bound for an icon-only sidebar width; a full text sidebar would exceed this. - static let iconSidebarUpperBound: CGFloat = 115 + @Test + func `Section sidebar is narrow enough that 11-inch portrait tiles all three columns`() { + #expect(MainSidebarView.sidebarTileableMinWidth <= Self.iPad11InchPortraitWidth) + } - @Test("Section sidebar is narrow enough that 11-inch portrait tiles all three columns") - func sidebarTilesInPortrait() { - #expect(MainSidebarView.sidebarTileableMinWidth <= Self.iPad11InchPortraitWidth) - } + @Test + func `iPad mini portrait intentionally collapses rather than tiling`() { + #expect(MainSidebarView.sidebarTileableMinWidth > Self.iPadMiniPortraitWidth) + } - @Test("iPad mini portrait intentionally collapses rather than tiling") - func iPadMiniPortraitCollapses() { - #expect(MainSidebarView.sidebarTileableMinWidth > Self.iPadMiniPortraitWidth) - } + @Test + func `Sidebar width is in icon-only range, not a full sidebar`() { + // Guards the configured constant. That iPadOS actually renders the sidebar this narrow and + // tiles three columns is validated by manual layout testing on device, not asserted here. + #expect(MainSidebarView.sidebarColumnWidth <= Self.iconSidebarUpperBound) + } - @Test("Sidebar width is in icon-only range, not a full sidebar") - func sidebarWidthIsCompact() { - // Guards the configured constant. That iPadOS actually renders the sidebar this narrow and - // tiles three columns is validated by manual layout testing on device, not asserted here. - #expect(MainSidebarView.sidebarColumnWidth <= Self.iconSidebarUpperBound) - } + @Test + func `A sidebar-collapsing tool collapses the sidebar even when the container is wide`() { + let visibility = MainSidebarView.sidebarVisibility( + isWide: true, + toolCollapsesSidebar: true, + sectionCollapsed: .doubleColumn + ) + #expect(visibility == .doubleColumn) + } - @Test("A sidebar-collapsing tool collapses the sidebar even when the container is wide") - func sidebarCollapsingToolCollapsesSidebarWhenWide() { - let visibility = MainSidebarView.sidebarVisibility( - isWide: true, - toolCollapsesSidebar: true, - sectionCollapsed: .doubleColumn - ) - #expect(visibility == .doubleColumn) - } + @Test + func `A wide container tiles the sidebar when no sidebar-collapsing tool is open`() { + let visibility = MainSidebarView.sidebarVisibility( + isWide: true, + toolCollapsesSidebar: false, + sectionCollapsed: .doubleColumn + ) + #expect(visibility == .all) + } - @Test("A wide container tiles the sidebar when no sidebar-collapsing tool is open") - func wideContainerTilesSidebar() { - let visibility = MainSidebarView.sidebarVisibility( - isWide: true, - toolCollapsesSidebar: false, - sectionCollapsed: .doubleColumn - ) - #expect(visibility == .all) - } - - @Test("A narrow container collapses to the section's hidden shape") - func narrowContainerCollapsesToSectionShape() { - #expect( - MainSidebarView.sidebarVisibility( - isWide: false, toolCollapsesSidebar: false, sectionCollapsed: .doubleColumn - ) == .doubleColumn - ) - #expect( - MainSidebarView.sidebarVisibility( - isWide: false, toolCollapsesSidebar: false, sectionCollapsed: .detailOnly - ) == .detailOnly - ) - } + @Test + func `A narrow container collapses to the section's hidden shape`() { + #expect( + MainSidebarView.sidebarVisibility( + isWide: false, toolCollapsesSidebar: false, sectionCollapsed: .doubleColumn + ) == .doubleColumn + ) + #expect( + MainSidebarView.sidebarVisibility( + isWide: false, toolCollapsesSidebar: false, sectionCollapsed: .detailOnly + ) == .detailOnly + ) + } - @Test("Line of Sight and Trace Path collapse the sidebar; other tools keep it") - func onlyWideToolsCollapseSidebar() { - #expect(ToolSelection.lineOfSight.prefersCollapsedSidebar) - #expect(ToolSelection.tracePath.prefersCollapsedSidebar) - for tool in ToolSelection.allCases where tool != .lineOfSight && tool != .tracePath { - #expect(!tool.prefersCollapsedSidebar) - } + @Test + func `Line of Sight and Trace Path collapse the sidebar; other tools keep it`() { + #expect(ToolSelection.lineOfSight.prefersCollapsedSidebar) + #expect(ToolSelection.tracePath.prefersCollapsedSidebar) + for tool in ToolSelection.allCases where tool != .lineOfSight && tool != .tracePath { + #expect(!tool.prefersCollapsedSidebar) } + } } diff --git a/MC1Tests/State/LiveActivityManagerTests.swift b/MC1Tests/State/LiveActivityManagerTests.swift index 1b07a067..5cd3c71b 100644 --- a/MC1Tests/State/LiveActivityManagerTests.swift +++ b/MC1Tests/State/LiveActivityManagerTests.swift @@ -1,44 +1,43 @@ -import Testing import Foundation @testable import MC1 +import Testing @Suite("LiveActivityManager packet rate") @MainActor struct LiveActivityManagerTests { + private let now = Date(timeIntervalSinceReferenceDate: 1_000_000) - private let now = Date(timeIntervalSinceReferenceDate: 1_000_000) - - @Test("no packets reads as zero") - func emptyIsZero() { - #expect(LiveActivityManager.packetsPerMinute(timestamps: [], now: now) == 0) - } + @Test + func `no packets reads as zero`() { + #expect(LiveActivityManager.packetsPerMinute(timestamps: [], now: now) == 0) + } - @Test("default 60s window returns the exact count, matching the RX Log") - func sixtySecondWindowIsTrueCount() { - let timestamps = (1...7).map { now.addingTimeInterval(-Double($0) * 5) } - #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now) == 7) - } + @Test + func `default 60s window returns the exact count, matching the RX Log`() { + let timestamps = (1...7).map { now.addingTimeInterval(-Double($0) * 5) } + #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now) == 7) + } - @Test("packets older than the window are excluded") - func staleTimestampsExcluded() { - let timestamps = [ - now.addingTimeInterval(-10), - now.addingTimeInterval(-59), - now.addingTimeInterval(-61), - now.addingTimeInterval(-120) - ] - #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now) == 2) - } + @Test + func `packets older than the window are excluded`() { + let timestamps = [ + now.addingTimeInterval(-10), + now.addingTimeInterval(-59), + now.addingTimeInterval(-61), + now.addingTimeInterval(-120) + ] + #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now) == 2) + } - @Test("a timestamp exactly at the cutoff is included") - func boundaryIsInclusive() { - let atCutoff = now.addingTimeInterval(-LiveActivityManager.packetWindowSeconds) - #expect(LiveActivityManager.packetsPerMinute(timestamps: [atCutoff], now: now) == 1) - } + @Test + func `a timestamp exactly at the cutoff is included`() { + let atCutoff = now.addingTimeInterval(-LiveActivityManager.packetWindowSeconds) + #expect(LiveActivityManager.packetsPerMinute(timestamps: [atCutoff], now: now) == 1) + } - @Test("a shorter window still projects to a per-minute rate") - func shorterWindowProjects() { - let timestamps = (1...3).map { now.addingTimeInterval(-Double($0)) } - #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now, window: 15) == 12) - } + @Test + func `a shorter window still projects to a per-minute rate`() { + let timestamps = (1...3).map { now.addingTimeInterval(-Double($0)) } + #expect(LiveActivityManager.packetsPerMinute(timestamps: timestamps, now: now, window: 15) == 12) + } } diff --git a/MC1Tests/State/MessageEventStreamTests.swift b/MC1Tests/State/MessageEventStreamTests.swift index d6c89341..83131b73 100644 --- a/MC1Tests/State/MessageEventStreamTests.swift +++ b/MC1Tests/State/MessageEventStreamTests.swift @@ -1,109 +1,113 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing @Suite("MessageEventStream") @MainActor struct MessageEventStreamTests { + @Test + func `Single consumer receives an event sent via send(_:)`() async { + let stream = MessageEventStream() + let event = MessageEvent.messageStatusResolved(messageID: UUID(), status: .sent) + + let received = Task { @MainActor () -> MessageEvent? in + var iterator = stream.events().makeAsyncIterator() + return await iterator.next() + } - @Test("Single consumer receives an event sent via send(_:)") - func singleConsumerReceivesEvent() async { - let stream = MessageEventStream() - let event = MessageEvent.messageStatusResolved(messageID: UUID(), status: .sent) + await Task.yield() + stream.send(event) - let received = Task { @MainActor () -> MessageEvent? in - var iterator = stream.events().makeAsyncIterator() - return await iterator.next() - } + #expect(await received.value == event) + } - await Task.yield() - stream.send(event) + @Test + func `Multiple consumers each receive the same event`() async { + let stream = MessageEventStream() + let event = MessageEvent.heardRepeatRecorded(messageID: UUID(), count: 3) - #expect(await received.value == event) + let firstReceived = Task { @MainActor () -> MessageEvent? in + var iterator = stream.events().makeAsyncIterator() + return await iterator.next() } + let secondReceived = Task { @MainActor () -> MessageEvent? in + var iterator = stream.events().makeAsyncIterator() + return await iterator.next() + } + + await Task.yield() + await Task.yield() + #expect(stream.subscriberCount() == 2) - @Test("Multiple consumers each receive the same event") - func multipleConsumersBothReceive() async { - let stream = MessageEventStream() - let event = MessageEvent.heardRepeatRecorded(messageID: UUID(), count: 3) + stream.send(event) - let firstReceived = Task { @MainActor () -> MessageEvent? in - var iterator = stream.events().makeAsyncIterator() - return await iterator.next() - } - let secondReceived = Task { @MainActor () -> MessageEvent? in - var iterator = stream.events().makeAsyncIterator() - return await iterator.next() - } + #expect(await firstReceived.value == event) + #expect(await secondReceived.value == event) + } - await Task.yield() - await Task.yield() - #expect(stream.subscriberCount() == 2) + @Test + func `Events sent across consumer task restarts are not dropped when the consumer is held by a long-lived task`() async { + let stream = MessageEventStream() + let event1 = MessageEvent.messageStatusResolved(messageID: UUID(), status: .sent) + let event2 = MessageEvent.messageStatusResolved(messageID: UUID(), status: .delivered) - stream.send(event) + actor Collector { + var items: [MessageEvent] = [] + func append(_ value: MessageEvent) { + items.append(value) + } - #expect(await firstReceived.value == event) - #expect(await secondReceived.value == event) + func snapshot() -> [MessageEvent] { + items + } } + let received = Collector() + + let consumer = Task { @MainActor in + for await event in stream.events() { + await received.append(event) + if await received.snapshot().count == 2 { return } + } + } + + await Task.yield() + stream.send(event1) + stream.send(event2) + + _ = await consumer.value + let collected = await received.snapshot() + #expect(collected.count == 2) + #expect(collected[0] == event1) + #expect(collected[1] == event2) + } + + @Test + func `subscriberCount reflects active subscriptions`() async { + let stream = MessageEventStream() + #expect(stream.subscriberCount() == 0) - @Test("Events sent across consumer task restarts are not dropped when the consumer is held by a long-lived task") - func longLivedConsumer_DoesNotDropEventsAcrossSimulatedReconnect() async { - let stream = MessageEventStream() - let event1 = MessageEvent.messageStatusResolved(messageID: UUID(), status: .sent) - let event2 = MessageEvent.messageStatusResolved(messageID: UUID(), status: .delivered) - - actor Collector { - var items: [MessageEvent] = [] - func append(_ value: MessageEvent) { items.append(value) } - func snapshot() -> [MessageEvent] { items } - } - let received = Collector() - - let consumer = Task { @MainActor in - for await event in stream.events() { - await received.append(event) - if await received.snapshot().count == 2 { return } - } - } - - await Task.yield() - stream.send(event1) - stream.send(event2) - - _ = await consumer.value - let collected = await received.snapshot() - #expect(collected.count == 2) - #expect(collected[0] == event1) - #expect(collected[1] == event2) + let firstTask = Task { @MainActor in + for await _ in stream.events() { /* hold subscription */ } } + await Task.yield() + #expect(stream.subscriberCount() == 1) - @Test("subscriberCount reflects active subscriptions") - func subscriberCountTracksRegistration() async { - let stream = MessageEventStream() - #expect(stream.subscriberCount() == 0) - - let firstTask = Task { @MainActor in - for await _ in stream.events() { /* hold subscription */ } - } - await Task.yield() - #expect(stream.subscriberCount() == 1) - - let secondTask = Task { @MainActor in - for await _ in stream.events() { /* hold subscription */ } - } - await Task.yield() - #expect(stream.subscriberCount() == 2) - - firstTask.cancel() - secondTask.cancel() - _ = await firstTask.value - _ = await secondTask.value - - // The onTermination hops to main via a Task; pump a send through - // to force the opportunistic-prune branch to run. After the pump, - // both terminated slots should be removed. - stream.send(.messageFailed(messageID: UUID())) - #expect(stream.subscriberCount() == 0) + let secondTask = Task { @MainActor in + for await _ in stream.events() { /* hold subscription */ } } + await Task.yield() + #expect(stream.subscriberCount() == 2) + + firstTask.cancel() + secondTask.cancel() + _ = await firstTask.value + _ = await secondTask.value + + // The onTermination hops to main via a Task; pump a send through + // to force the opportunistic-prune branch to run. After the pump, + // both terminated slots should be removed. + stream.send(.messageFailed(messageID: UUID())) + #expect(stream.subscriberCount() == 0) + } } diff --git a/MC1Tests/State/SendQueueTests.swift b/MC1Tests/State/SendQueueTests.swift index be635f7e..c91dfc79 100644 --- a/MC1Tests/State/SendQueueTests.swift +++ b/MC1Tests/State/SendQueueTests.swift @@ -1,391 +1,442 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing @Suite("SendQueue") struct SendQueueTests { - - /// A trivial envelope used in tests where the payload is just an - /// identifier the assertions can compare against. - private struct Marker: Sendable, Equatable { - let id: Int + /// A trivial envelope used in tests where the payload is just an + /// identifier the assertions can compare against. + private struct Marker: Equatable { + let id: Int + } + + @Test + func `Serial draining: enqueueing N envelopes triggers N sends in FIFO order`() async { + actor Collector { + var observed: [Int] = [] + func record(_ value: Int) { + observed.append(value) + } + + func snapshot() -> [Int] { + observed + } } + let collector = Collector() + let drainSignal = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { envelope in + await collector.record(envelope.id) + }, + onError: { _, _ in }, + onDrain: { _ in + drainSignal.continuation.yield(()) + drainSignal.continuation.finish() + } + ) - @Test("Serial draining: enqueueing N envelopes triggers N sends in FIFO order") - func serialDrainProcessesEnvelopesInOrder() async { - actor Collector { - var observed: [Int] = [] - func record(_ value: Int) { observed.append(value) } - func snapshot() -> [Int] { observed } - } - let collector = Collector() - let drainSignal = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - await collector.record(envelope.id) - }, - onError: { _, _ in }, - onDrain: { _ in - drainSignal.continuation.yield(()) - drainSignal.continuation.finish() - } - ) - - for id in 1...5 { - await queue.enqueue(Marker(id: id)) - } - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - - let observed = await collector.snapshot() - let count = await queue.count - #expect(observed == [1, 2, 3, 4, 5]) - #expect(count == 0) + for id in 1...5 { + await queue.enqueue(Marker(id: id)) } - @Test("CancellationError requeues at the front and the drain auto-respawns") - func cancellationRequeuesAndAutoRespawns() async { - // The drain's `catch is CancellationError` branch re-inserts the - // in-flight envelope at index 0, then `taskCompleted` respawns - // because pending is non-empty. The retried send succeeds (the - // latch flips after the first throw), and the envelope drains - // without an external nudge. - actor Tracker { - var sendCalls = 0 - var succeededIDs: [Int] = [] - func recordSend() -> Bool { - sendCalls += 1 - return sendCalls == 1 - } - func recordSuccess(_ id: Int) { succeededIDs.append(id) } - func snapshot() -> (Int, [Int]) { (sendCalls, succeededIDs) } - } - let tracker = Tracker() - let drainSignal = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - let throwThisTime = await tracker.recordSend() - if throwThisTime { throw CancellationError() } - await tracker.recordSuccess(envelope.id) - }, - onError: { _, _ in }, - onDrain: { _ in - drainSignal.continuation.yield(()) - drainSignal.continuation.finish() - } - ) - - await queue.enqueue(Marker(id: 42)) - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - - let (sendCalls, succeeded) = await tracker.snapshot() - #expect(sendCalls == 2, "Envelope is attempted twice: once cancelled, once successful") - #expect(succeeded == [42], "The auto-respawn retry delivers the envelope") - - let finalCount = await queue.count - #expect(finalCount == 0) + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + + let observed = await collector.snapshot() + let count = await queue.count + #expect(observed == [1, 2, 3, 4, 5]) + #expect(count == 0) + } + + @Test + func `CancellationError requeues at the front and the drain auto-respawns`() async { + // The drain's `catch is CancellationError` branch re-inserts the + // in-flight envelope at index 0, then `taskCompleted` respawns + // because pending is non-empty. The retried send succeeds (the + // latch flips after the first throw), and the envelope drains + // without an external nudge. + actor Tracker { + var sendCalls = 0 + var succeededIDs: [Int] = [] + func recordSend() -> Bool { + sendCalls += 1 + return sendCalls == 1 + } + + func recordSuccess(_ id: Int) { + succeededIDs.append(id) + } + + func snapshot() -> (Int, [Int]) { + (sendCalls, succeededIDs) + } } - - @Test("Non-cancellation error fires onError and the drain continues") - func nonCancellationErrorFiresOnErrorAndContinues() async { - struct Boom: Error {} - actor Collector { - var sent: [Int] = [] - var errors: [Int] = [] - func recordSend(_ id: Int) { sent.append(id) } - func recordError(_ id: Int) { errors.append(id) } - func sentSnapshot() -> [Int] { sent } - func errorSnapshot() -> [Int] { errors } - } - let collector = Collector() - let drainSignal = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - if envelope.id == 2 { - throw Boom() - } - await collector.recordSend(envelope.id) - }, - onError: { _, envelope in - await collector.recordError(envelope.id) - }, - onDrain: { _ in - drainSignal.continuation.yield(()) - drainSignal.continuation.finish() - } - ) - - for id in 1...3 { - await queue.enqueue(Marker(id: id)) - } - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - - let sent = await collector.sentSnapshot() - let errored = await collector.errorSnapshot() - #expect(sent == [1, 3], "Drain must continue past the failing envelope") - #expect(errored == [2]) + let tracker = Tracker() + let drainSignal = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { envelope in + let throwThisTime = await tracker.recordSend() + if throwThisTime { throw CancellationError() } + await tracker.recordSuccess(envelope.id) + }, + onError: { _, _ in }, + onDrain: { _ in + drainSignal.continuation.yield(()) + drainSignal.continuation.finish() + } + ) + + await queue.enqueue(Marker(id: 42)) + + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + + let (sendCalls, succeeded) = await tracker.snapshot() + #expect(sendCalls == 2, "Envelope is attempted twice: once cancelled, once successful") + #expect(succeeded == [42], "The auto-respawn retry delivers the envelope") + + let finalCount = await queue.count + #expect(finalCount == 0) + } + + @Test + func `Non-cancellation error fires onError and the drain continues`() async { + struct Boom: Error {} + actor Collector { + var sent: [Int] = [] + var errors: [Int] = [] + func recordSend(_ id: Int) { + sent.append(id) + } + + func recordError(_ id: Int) { + errors.append(id) + } + + func sentSnapshot() -> [Int] { + sent + } + + func errorSnapshot() -> [Int] { + errors + } } + let collector = Collector() + let drainSignal = AsyncStream.makeStream(of: Void.self) - @Test("onDrain fires exactly once per drain pass") - func onDrainFiresOncePerPass() async { - // Block the first send call on a barrier so the test can enqueue - // envelopes 2 and 3 while drain is parked. Without the barrier, - // a synchronous send closure lets T1 finish draining envelope 1 - // (and fire onDrain) before the test thread submits enqueues 2 - // and 3 to the actor — exact ordering is up to the scheduler. - actor State { - var firstSendSeen = false - var sent: [Int] = [] - func markFirstSend() -> Bool { - let isFirst = !firstSendSeen - firstSendSeen = true - return isFirst - } - func recordSent(_ id: Int) { sent.append(id) } - func snapshot() -> [Int] { sent } + let queue = SendQueue( + send: { envelope in + if envelope.id == 2 { + throw Boom() } - actor DrainCounter { - var count = 0 - func bump() { count += 1 } - func snapshot() -> Int { count } - } - let state = State() - let counter = DrainCounter() - let drainSignal = AsyncStream.makeStream(of: Void.self) - let firstSendReached = AsyncStream.makeStream(of: Void.self) - let releaseFirstSend = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - if await state.markFirstSend() { - firstSendReached.continuation.yield(()) - firstSendReached.continuation.finish() - var releaseIter = releaseFirstSend.stream.makeAsyncIterator() - _ = await releaseIter.next() - } - await state.recordSent(envelope.id) - }, - onError: { _, _ in }, - onDrain: { _ in - await counter.bump() - drainSignal.continuation.yield(()) - } - ) - - await queue.enqueue(Marker(id: 1)) - - // Park here until drain has popped envelope 1 and entered send. - var firstIter = firstSendReached.stream.makeAsyncIterator() - _ = await firstIter.next() - - await queue.enqueue(Marker(id: 2)) - await queue.enqueue(Marker(id: 3)) - - releaseFirstSend.continuation.yield(()) - releaseFirstSend.continuation.finish() - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - await queue.awaitDrainCompletion() + await collector.recordSend(envelope.id) + }, + onError: { _, envelope in + await collector.recordError(envelope.id) + }, + onDrain: { _ in + drainSignal.continuation.yield(()) drainSignal.continuation.finish() + } + ) - let sent = await state.snapshot() - let observed = await counter.snapshot() - #expect(sent == [1, 2, 3]) - #expect(observed == 1, "All three envelopes drain in one pass; onDrain fires once") + for id in 1...3 { + await queue.enqueue(Marker(id: id)) } - @Test("onDrain receives the most recent non-cancellation error from the drain pass") - func onDrainReceivesLastError() async { - struct Boom: Error, Equatable { - let id: Int - } - actor ErrorBox { - var value: Error? - func record(_ error: Error?) { value = error } - func snapshot() -> Error? { value } - } - let box = ErrorBox() - let drainSignal = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - throw Boom(id: envelope.id) - }, - onError: { _, _ in }, - onDrain: { lastError in - await box.record(lastError) - drainSignal.continuation.yield(()) - drainSignal.continuation.finish() - } - ) - - await queue.enqueue(Marker(id: 1)) - await queue.enqueue(Marker(id: 2)) - await queue.enqueue(Marker(id: 3)) - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - - let recorded = await box.snapshot() - // Last-error-wins: the third envelope's Boom should reach onDrain. - #expect((recorded as? Boom) == Boom(id: 3)) + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + + let sent = await collector.sentSnapshot() + let errored = await collector.errorSnapshot() + #expect(sent == [1, 3], "Drain must continue past the failing envelope") + #expect(errored == [2]) + } + + @Test + func `onDrain fires exactly once per drain pass`() async { + // Block the first send call on a barrier so the test can enqueue + // envelopes 2 and 3 while drain is parked. Without the barrier, + // a synchronous send closure lets T1 finish draining envelope 1 + // (and fire onDrain) before the test thread submits enqueues 2 + // and 3 to the actor — exact ordering is up to the scheduler. + actor State { + var firstSendSeen = false + var sent: [Int] = [] + func markFirstSend() -> Bool { + let isFirst = !firstSendSeen + firstSendSeen = true + return isFirst + } + + func recordSent(_ id: Int) { + sent.append(id) + } + + func snapshot() -> [Int] { + sent + } } - - @Test("onDrain receives nil when no envelope failed during the drain pass") - func onDrainReceivesNilOnSuccessfulPass() async { - actor ErrorBox { - var value: Error? - var didFire = false - func record(_ error: Error?) { value = error; didFire = true } - func snapshot() -> (Error?, Bool) { (value, didFire) } - } - let box = ErrorBox() - let drainSignal = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { _ in }, - onError: { _, _ in }, - onDrain: { lastError in - await box.record(lastError) - drainSignal.continuation.yield(()) - drainSignal.continuation.finish() - } - ) - - await queue.enqueue(Marker(id: 1)) - - var iterator = drainSignal.stream.makeAsyncIterator() - _ = await iterator.next() - - let (recorded, fired) = await box.snapshot() - #expect(fired) - #expect(recorded == nil) + actor DrainCounter { + var count = 0 + func bump() { + count += 1 + } + + func snapshot() -> Int { + count + } } - - @Test("drain's outer loop processes envelopes enqueued during onDrain") - func outerLoopProcessesEnqueueDuringOnDrain() async { - // The outer `while !pending.isEmpty` re-check after onDrain is what - // catches an enqueue that lands during the onDrain await. Park - // the first onDrain on a barrier, enqueue a follow-up envelope - // from outside, release. The same drain task should pop the new - // envelope and fire onDrain a second time. - actor State { - var sent: [Int] = [] - var onDrainCalls = 0 - func recordSend(_ id: Int) { sent.append(id) } - func bump() -> Int { - onDrainCalls += 1 - return onDrainCalls - } - func snapshot() -> (sent: [Int], onDrainCalls: Int) { - (sent, onDrainCalls) - } + let state = State() + let counter = DrainCounter() + let drainSignal = AsyncStream.makeStream(of: Void.self) + let firstSendReached = AsyncStream.makeStream(of: Void.self) + let releaseFirstSend = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { envelope in + if await state.markFirstSend() { + firstSendReached.continuation.yield(()) + firstSendReached.continuation.finish() + var releaseIter = releaseFirstSend.stream.makeAsyncIterator() + _ = await releaseIter.next() } - let state = State() - let firstOnDrainReached = AsyncStream.makeStream(of: Void.self) - let releaseFirstOnDrain = AsyncStream.makeStream(of: Void.self) - let secondOnDrainFired = AsyncStream.makeStream(of: Void.self) - - let queue = SendQueue( - send: { envelope in - await state.recordSend(envelope.id) - }, - onError: { _, _ in }, - onDrain: { _ in - let callNumber = await state.bump() - if callNumber == 1 { - firstOnDrainReached.continuation.yield(()) - firstOnDrainReached.continuation.finish() - var releaseIter = releaseFirstOnDrain.stream.makeAsyncIterator() - _ = await releaseIter.next() - } else { - secondOnDrainFired.continuation.yield(()) - secondOnDrainFired.continuation.finish() - } - } - ) - - await queue.enqueue(Marker(id: 1)) - - // Park here until the first onDrain has fired and is blocked. - var firstIter = firstOnDrainReached.stream.makeAsyncIterator() - _ = await firstIter.next() - - // Enqueue 2 while onDrain is parked. The outer loop must pick it up. - await queue.enqueue(Marker(id: 2)) - - releaseFirstOnDrain.continuation.yield(()) - releaseFirstOnDrain.continuation.finish() - - var secondIter = secondOnDrainFired.stream.makeAsyncIterator() - _ = await secondIter.next() - await queue.awaitDrainCompletion() - - let snapshot = await state.snapshot() - #expect(snapshot.sent == [1, 2]) - #expect(snapshot.onDrainCalls == 2, "Outer loop produces a second onDrain after the re-enqueued envelope drains") + await state.recordSent(envelope.id) + }, + onError: { _, _ in }, + onDrain: { _ in + await counter.bump() + drainSignal.continuation.yield(()) + } + ) + + await queue.enqueue(Marker(id: 1)) + + // Park here until drain has popped envelope 1 and entered send. + var firstIter = firstSendReached.stream.makeAsyncIterator() + _ = await firstIter.next() + + await queue.enqueue(Marker(id: 2)) + await queue.enqueue(Marker(id: 3)) + + releaseFirstSend.continuation.yield(()) + releaseFirstSend.continuation.finish() + + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + await queue.awaitDrainCompletion() + drainSignal.continuation.finish() + + let sent = await state.snapshot() + let observed = await counter.snapshot() + #expect(sent == [1, 2, 3]) + #expect(observed == 1, "All three envelopes drain in one pass; onDrain fires once") + } + + @Test + func `onDrain receives the most recent non-cancellation error from the drain pass`() async { + struct Boom: Error, Equatable { + let id: Int } - - @Test("Drain task captures actor strongly: drain completes after only external ref drops") - func drainCompletesAfterExternalRefDrops() async { - // Drop the local strong ref to the queue while the drain's send - // closure is parked. Because the drain Task captures the actor - // strongly, the actor stays alive until drain completes; the - // send closure resumes after release and observably runs to - // completion. - actor State { - var sendCompleted = false - func markCompleted() { sendCompleted = true } - func snapshot() -> Bool { sendCompleted } - } - let state = State() - let sendReached = AsyncStream.makeStream(of: Void.self) - let releaseSend = AsyncStream.makeStream(of: Void.self) - - do { - let queue = SendQueue( - send: { _ in - sendReached.continuation.yield(()) - sendReached.continuation.finish() - var releaseIter = releaseSend.stream.makeAsyncIterator() - _ = await releaseIter.next() - await state.markCompleted() - }, - onError: { _, _ in }, - onDrain: { _ in } - ) - await queue.enqueue(Marker(id: 1)) - - var iter = sendReached.stream.makeAsyncIterator() - _ = await iter.next() - // Local `queue` strong ref drops at the end of this `do` block. - } - - releaseSend.continuation.yield(()) - releaseSend.continuation.finish() - - // Poll for send completion. The drain Task is the sole remaining - // strong ref to the actor; if strong-self capture works, send - // resumes and runs to completion. - var completed = false - for _ in 0..<200 { - if await state.snapshot() { - completed = true - break - } - try? await Task.sleep(nanoseconds: 5_000_000) + actor ErrorBox { + var value: Error? + func record(_ error: Error?) { + value = error + } + + func snapshot() -> Error? { + value + } + } + let box = ErrorBox() + let drainSignal = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { envelope in + throw Boom(id: envelope.id) + }, + onError: { _, _ in }, + onDrain: { lastError in + await box.record(lastError) + drainSignal.continuation.yield(()) + drainSignal.continuation.finish() + } + ) + + await queue.enqueue(Marker(id: 1)) + await queue.enqueue(Marker(id: 2)) + await queue.enqueue(Marker(id: 3)) + + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + + let recorded = await box.snapshot() + // Last-error-wins: the third envelope's Boom should reach onDrain. + #expect((recorded as? Boom) == Boom(id: 3)) + } + + @Test + func `onDrain receives nil when no envelope failed during the drain pass`() async { + actor ErrorBox { + var value: Error? + var didFire = false + func record(_ error: Error?) { + value = error; didFire = true + } + + func snapshot() -> (Error?, Bool) { + (value, didFire) + } + } + let box = ErrorBox() + let drainSignal = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { _ in }, + onError: { _, _ in }, + onDrain: { lastError in + await box.record(lastError) + drainSignal.continuation.yield(()) + drainSignal.continuation.finish() + } + ) + + await queue.enqueue(Marker(id: 1)) + + var iterator = drainSignal.stream.makeAsyncIterator() + _ = await iterator.next() + + let (recorded, fired) = await box.snapshot() + #expect(fired) + #expect(recorded == nil) + } + + @Test + func `drain's outer loop processes envelopes enqueued during onDrain`() async { + // The outer `while !pending.isEmpty` re-check after onDrain is what + // catches an enqueue that lands during the onDrain await. Park + // the first onDrain on a barrier, enqueue a follow-up envelope + // from outside, release. The same drain task should pop the new + // envelope and fire onDrain a second time. + actor State { + var sent: [Int] = [] + var onDrainCalls = 0 + func recordSend(_ id: Int) { + sent.append(id) + } + + func bump() -> Int { + onDrainCalls += 1 + return onDrainCalls + } + + func snapshot() -> (sent: [Int], onDrainCalls: Int) { + (sent, onDrainCalls) + } + } + let state = State() + let firstOnDrainReached = AsyncStream.makeStream(of: Void.self) + let releaseFirstOnDrain = AsyncStream.makeStream(of: Void.self) + let secondOnDrainFired = AsyncStream.makeStream(of: Void.self) + + let queue = SendQueue( + send: { envelope in + await state.recordSend(envelope.id) + }, + onError: { _, _ in }, + onDrain: { _ in + let callNumber = await state.bump() + if callNumber == 1 { + firstOnDrainReached.continuation.yield(()) + firstOnDrainReached.continuation.finish() + var releaseIter = releaseFirstOnDrain.stream.makeAsyncIterator() + _ = await releaseIter.next() + } else { + secondOnDrainFired.continuation.yield(()) + secondOnDrainFired.continuation.finish() } + } + ) + + await queue.enqueue(Marker(id: 1)) + + // Park here until the first onDrain has fired and is blocked. + var firstIter = firstOnDrainReached.stream.makeAsyncIterator() + _ = await firstIter.next() + + // Enqueue 2 while onDrain is parked. The outer loop must pick it up. + await queue.enqueue(Marker(id: 2)) + + releaseFirstOnDrain.continuation.yield(()) + releaseFirstOnDrain.continuation.finish() + + var secondIter = secondOnDrainFired.stream.makeAsyncIterator() + _ = await secondIter.next() + await queue.awaitDrainCompletion() + + let snapshot = await state.snapshot() + #expect(snapshot.sent == [1, 2]) + #expect(snapshot.onDrainCalls == 2, "Outer loop produces a second onDrain after the re-enqueued envelope drains") + } + + @Test + func `Drain task captures actor strongly: drain completes after only external ref drops`() async { + // Drop the local strong ref to the queue while the drain's send + // closure is parked. Because the drain Task captures the actor + // strongly, the actor stays alive until drain completes; the + // send closure resumes after release and observably runs to + // completion. + actor State { + var sendCompleted = false + func markCompleted() { + sendCompleted = true + } + + func snapshot() -> Bool { + sendCompleted + } + } + let state = State() + let sendReached = AsyncStream.makeStream(of: Void.self) + let releaseSend = AsyncStream.makeStream(of: Void.self) + + do { + let queue = SendQueue( + send: { _ in + sendReached.continuation.yield(()) + sendReached.continuation.finish() + var releaseIter = releaseSend.stream.makeAsyncIterator() + _ = await releaseIter.next() + await state.markCompleted() + }, + onError: { _, _ in }, + onDrain: { _ in } + ) + await queue.enqueue(Marker(id: 1)) + + var iter = sendReached.stream.makeAsyncIterator() + _ = await iter.next() + // Local `queue` strong ref drops at the end of this `do` block. + } - #expect(completed, "Send must complete even after the only external strong reference was released") + releaseSend.continuation.yield(()) + releaseSend.continuation.finish() + + // Poll for send completion. The drain Task is the sole remaining + // strong ref to the actor; if strong-self capture works, send + // resumes and runs to completion. + var completed = false + for _ in 0..<200 { + if await state.snapshot() { + completed = true + break + } + try? await Task.sleep(nanoseconds: 5_000_000) } + + #expect(completed, "Send must complete even after the only external strong reference was released") + } } diff --git a/MC1Tests/State/WhatsNewStateTests.swift b/MC1Tests/State/WhatsNewStateTests.swift index 9aaae66d..c073b584 100644 --- a/MC1Tests/State/WhatsNewStateTests.swift +++ b/MC1Tests/State/WhatsNewStateTests.swift @@ -1,180 +1,179 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @Suite("WhatsNewState") @MainActor final class WhatsNewStateTests { - - private let baselineKey = AppStorageKey.lastShownWhatsNewVersion.rawValue - private let suiteName = "test.\(UUID().uuidString)" - private let defaults: UserDefaults - - init() { - defaults = UserDefaults(suiteName: suiteName)! - } - - deinit { - UserDefaults().removePersistentDomain(forName: suiteName) - } - - private func version(_ string: String) -> WhatsNewVersion { - WhatsNewVersion(marketingVersion: string)! - } - - private func release(_ major: Int, _ minor: Int, items: Int = 1) -> WhatsNewRelease { - WhatsNewRelease( - version: WhatsNewVersion(major: major, minor: minor), - items: (0.. WhatsNewVersion { + WhatsNewVersion(marketingVersion: string)! + } + + private func release(_ major: Int, _ minor: Int, items: Int = 1) -> WhatsNewRelease { + WhatsNewRelease( + version: WhatsNewVersion(major: major, minor: minor), + items: (0.. StoreService { - let service = StoreService() - service.shutdown() - return service - } + /// A service with its `Transaction.updates` listener cancelled — the fold logic needs no + /// live StoreKit, and detaching avoids an idle listener Task outliving the test. + private func makeService() -> StoreService { + let service = StoreService() + service.shutdown() + return service + } - @Test("granting the bundle unlocks every bundled theme") - func grantBundle() { - let service = makeService() - service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - } + @Test + func `granting the bundle unlocks every bundled theme`() { + let service = makeService() + service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + } - @Test("revoking the bundle removes every theme it granted") - func revokeBundle() { - let service = makeService() - service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) - service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: true) - #expect(service.ownedThemeIDs.isEmpty) - } + @Test + func `revoking the bundle removes every theme it granted`() { + let service = makeService() + service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) + service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: true) + #expect(service.ownedThemeIDs.isEmpty) + } - @Test("a consumable tip product grants no theme entitlement") - func tipGrantsNothing() { - let service = makeService() - service.applyEntitlement(productID: StoreCatalog.Tip.coffee, isRevoked: false) - #expect(service.ownedThemeIDs.isEmpty) - } + @Test + func `a consumable tip product grants no theme entitlement`() { + let service = makeService() + service.applyEntitlement(productID: StoreCatalog.Tip.coffee, isRevoked: false) + #expect(service.ownedThemeIDs.isEmpty) + } - @Test("the entitlements-changed callback fires only on a real change") - func callbackFiresOnChangeOnly() { - let service = makeService() - let count = MutableBox(0) - service.onEntitlementsChanged = { count.value += 1 } + @Test + func `the entitlements-changed callback fires only on a real change`() { + let service = makeService() + let count = MutableBox(0) + service.onEntitlementsChanged = { count.value += 1 } - service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) - service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) // already owned + service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) + service.applyEntitlement(productID: StoreCatalog.Theme.bundleAll, isRevoked: false) // already owned - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - #expect(count.value == 1) - } + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + #expect(count.value == 1) + } } diff --git a/MC1Tests/StoreKitTestAvailability.swift b/MC1Tests/StoreKitTestAvailability.swift index f3839f03..104ec4af 100644 --- a/MC1Tests/StoreKitTestAvailability.swift +++ b/MC1Tests/StoreKitTestAvailability.swift @@ -8,10 +8,10 @@ import Foundation /// affected runtime — keeping the default iOS 26 run green — and run on iOS 18.x, which /// `make test-store` pins explicitly. enum StoreKitTestAvailability { - /// iOS 26.x is the known-broken runtime; every other version serves products normally. - static let brokenMajorVersion = 26 + /// iOS 26.x is the known-broken runtime; every other version serves products normally. + static let brokenMajorVersion = 26 - static var servesProducts: Bool { - ProcessInfo.processInfo.operatingSystemVersion.majorVersion != brokenMajorVersion - } + static var servesProducts: Bool { + ProcessInfo.processInfo.operatingSystemVersion.majorVersion != brokenMajorVersion + } } diff --git a/MC1Tests/StoreServiceTests.swift b/MC1Tests/StoreServiceTests.swift index a9b13dab..b5e1a7af 100644 --- a/MC1Tests/StoreServiceTests.swift +++ b/MC1Tests/StoreServiceTests.swift @@ -1,255 +1,257 @@ -import Testing +@testable import MC1Services import StoreKit import StoreKitTest -@testable import MC1Services +import Testing @MainActor @Suite("StoreService", .serialized, .enabled(if: StoreKitTestAvailability.servesProducts)) final class StoreServiceTests { - // Two spec behaviors are covered by code inspection, not automated tests, because - // SKTestSession cannot produce the required conditions: - // 1. Unverified-transaction drop + deduped warning log. SKTestSession only ever - // produces VerificationResult.verified transactions, so the `.unverified` branch - // in walkCurrentEntitlements / applyTransactionUpdate / processUnfinishedTransactions - // is unreachable from a test. The production handling (noteUnverified) is verified by - // inspecting StoreService and exercised in the sandbox pre-submission checklist. - // 2. CancellationError filtering in restorePurchases(). AppStore.sync() cannot be made to - // throw CancellationError on demand; the `catch is CancellationError { return }` guard - // is verified by inspection. - let session: SKTestSession - - init() throws { - session = try SKTestSession(configurationFileNamed: "MC1") - session.disableDialogs = true - // Keep Ask-to-Buy off by default; askToBuyPendingThenApproved opts in, and the - // setting otherwise persists across SKTestSession instances in the same process. - session.askToBuyEnabled = false - session.clearTransactions() + /// Two spec behaviors are covered by code inspection, not automated tests, because + /// SKTestSession cannot produce the required conditions: + /// 1. Unverified-transaction drop + deduped warning log. SKTestSession only ever + /// produces VerificationResult.verified transactions, so the `.unverified` branch + /// in walkCurrentEntitlements / applyTransactionUpdate / processUnfinishedTransactions + /// is unreachable from a test. The production handling (noteUnverified) is verified by + /// inspecting StoreService and exercised in the sandbox pre-submission checklist. + /// 2. CancellationError filtering in restorePurchases(). AppStore.sync() cannot be made to + /// throw CancellationError on demand; the `catch is CancellationError { return }` guard + /// is verified by inspection. + let session: SKTestSession + + init() throws { + session = try SKTestSession(configurationFileNamed: "MC1") + session.disableDialogs = true + // Keep Ask-to-Buy off by default; askToBuyPendingThenApproved opts in, and the + // setting otherwise persists across SKTestSession instances in the same process. + session.askToBuyEnabled = false + session.clearTransactions() + } + + deinit { session.clearTransactions() } + + @Test + func `the bundled configuration exposes all seven sellable products`() async throws { + let products = try await Product.products(for: StoreCatalog.sellableProductIDs) + #expect(products.count == 7) + } + + @Test + func `load populates products and reports loaded`() async { + let service = StoreService() + await service.load() + #expect(service.loadState == .loaded) + #expect(service.products.count == 7) + #expect(service.product(for: StoreCatalog.Theme.bundleAll) != nil) + } + + @Test + func `a clean account owns no themes after load`() async { + let service = StoreService() + await service.load() + #expect(service.ownedThemeIDs.isEmpty) + } + + @Test + func `load with only unknown IDs fails`() async { + let service = StoreService() + await service.load(productIDs: ["io.pocketmesh.app.nonexistent"]) + #expect(service.loadState == .failed) + #expect(service.products.isEmpty) + } + + @Test + func `shutdown cancels the transaction listener`() { + let service = StoreService() + #expect(service.transactionListenerTask != nil) + service.shutdown() + #expect(service.transactionListenerTask == nil) + } + + @Test + func `buying the bundle grants every theme, fires the callback, and finishes the transaction`() async throws { + let service = StoreService() + await service.load() + let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) + + let callbackCount = MutableBox(0) + service.onEntitlementsChanged = { callbackCount.value += 1 } + + let outcome = try await purchaseWithRetry(bundle, on: service) + + #expect(outcome == .purchased) + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + #expect(callbackCount.value >= 1) + } + + @Test + func `buying a consumable tip succeeds without granting a theme entitlement`() async throws { + let service = StoreService() + await service.load() + let coffee = try #require(service.product(for: StoreCatalog.Tip.coffee)) + + let outcome = try await purchaseWithRetry(coffee, on: service) + + #expect(outcome == .purchased) + #expect(service.ownedThemeIDs.isEmpty) + } + + @Test + func `an Ask-to-Buy-approved consumable fires onConsumablePurchased with the productID`() async throws { + // `onConsumablePurchased` exists to clear a pending banner when an Ask-to-Buy tip is + // approved out-of-band (the inline product.purchase() path never reaches it for inline + // buys; only `applyTransactionUpdate` does, and that path only runs for transactions + // delivered through `Transaction.updates`). This test exercises the production trigger. + session.askToBuyEnabled = true + let service = StoreService() + await service.load() + let coffee = try #require(service.product(for: StoreCatalog.Tip.coffee)) + + let received = MutableBox<[String]>([]) + service.onConsumablePurchased = { productID in received.value.append(productID) } + + let outcome = try await purchaseWithRetry(coffee, on: service) + #expect(outcome == .pending) + + let pendingTxn = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Tip.coffee + }) + try session.approveAskToBuyTransaction(identifier: pendingTxn.identifier) + + try await waitUntil(timeout: .seconds(5)) { + received.value.contains(StoreCatalog.Tip.coffee) } - - deinit { session.clearTransactions() } - - @Test("the bundled configuration exposes all seven sellable products") - func configExposesAllProducts() async throws { - let products = try await Product.products(for: StoreCatalog.sellableProductIDs) - #expect(products.count == 7) + #expect(received.value == [StoreCatalog.Tip.coffee]) + } + + @Test + func `refunding the bundle revokes its theme entitlements via the listener`() async throws { + let service = StoreService() + await service.load() + let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: service) + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + + let txn = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Theme.bundleAll + }) + try session.refundTransaction(identifier: txn.identifier) + + try await waitUntil(timeout: .seconds(5)) { + service.ownedThemeIDs.isEmpty } - - @Test("load populates products and reports loaded") - func loadPopulatesProducts() async throws { - let service = StoreService() - await service.load() - #expect(service.loadState == .loaded) - #expect(service.products.count == 7) - #expect(service.product(for: StoreCatalog.Theme.bundleAll) != nil) + } + + @Test + func `load drains unfinished transactions and applies their entitlement`() async throws { + // Simulate a purchase committed while no StoreService was running: buy directly and leave + // the transaction unfinished. product.purchase() is synchronously consistent (unlike the + // eventually-consistent session.buyProduct), so the transaction is committed before any + // StoreService exists and load()'s drain path sees it deterministically. + let bundle = try #require( + try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first + ) + try await purchaseUnfinished(bundle) + + // Guard against any residual lag before Transaction.unfinished reflects the committed sale. + try await waitUntil( + timeout: .seconds(5), + pollingInterval: .milliseconds(100), + "committed bundle transaction did not surface in Transaction.unfinished" + ) { + await self.hasUnfinished(StoreCatalog.Theme.bundleAll) } - @Test("a clean account owns no themes after load") - func cleanAccountOwnsNothing() async throws { - let service = StoreService() - await service.load() - #expect(service.ownedThemeIDs.isEmpty) - } + let service = StoreService() + service.shutdown() // detach the listener so only load()'s drain path can finish the transaction + await service.load() - @Test("load with only unknown IDs fails") - func loadEmptyFails() async throws { - let service = StoreService() - await service.load(productIDs: ["io.pocketmesh.app.nonexistent"]) - #expect(service.loadState == .failed) - #expect(service.products.isEmpty) - } + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - @Test("shutdown cancels the transaction listener") - func shutdownCancelsListener() async throws { - let service = StoreService() - #expect(service.transactionListenerTask != nil) - service.shutdown() - #expect(service.transactionListenerTask == nil) + var remainingUnfinished = 0 + for await _ in Transaction.unfinished { + remainingUnfinished += 1 } - - @Test("buying the bundle grants every theme, fires the callback, and finishes the transaction") - func purchaseBundleGrantsAllThemes() async throws { - let service = StoreService() - await service.load() - let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) - - let callbackCount = MutableBox(0) - service.onEntitlementsChanged = { callbackCount.value += 1 } - - let outcome = try await purchaseWithRetry(bundle, on: service) - - #expect(outcome == .purchased) - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - #expect(callbackCount.value >= 1) - } - - @Test("buying a consumable tip succeeds without granting a theme entitlement") - func purchaseTipGrantsNoEntitlement() async throws { - let service = StoreService() - await service.load() - let coffee = try #require(service.product(for: StoreCatalog.Tip.coffee)) - - let outcome = try await purchaseWithRetry(coffee, on: service) - - #expect(outcome == .purchased) - #expect(service.ownedThemeIDs.isEmpty) + #expect(remainingUnfinished == 0) + } + + @Test + func `restore re-walks entitlements after AppStore.sync`() async throws { + // Establish ownership via the synchronously-consistent product.purchase(), then build a + // fresh service whose ownedThemeIDs starts empty and recover it through restore. + let bundle = try #require( + try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first + ) + try await purchaseUnfinished(bundle) + + let service = StoreService() + service.shutdown() // isolate the restore path from the listener + try await service.restorePurchases() + + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + } + + @Test + func `Ask-to-Buy yields a pending outcome, then the approval grants entitlement`() async throws { + session.askToBuyEnabled = true + let service = StoreService() + await service.load() + let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) + + let outcome = try await purchaseWithRetry(bundle, on: service) + #expect(outcome == .pending) + #expect(service.ownedThemeIDs.isEmpty) + + let pending = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Theme.bundleAll + }) + try session.approveAskToBuyTransaction(identifier: pending.identifier) + + try await waitUntil(timeout: .seconds(5)) { + service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs } - - @Test("an Ask-to-Buy-approved consumable fires onConsumablePurchased with the productID") - func consumablePurchaseFiresOnConsumablePurchased() async throws { - // `onConsumablePurchased` exists to clear a pending banner when an Ask-to-Buy tip is - // approved out-of-band (the inline product.purchase() path never reaches it for inline - // buys; only `applyTransactionUpdate` does, and that path only runs for transactions - // delivered through `Transaction.updates`). This test exercises the production trigger. - session.askToBuyEnabled = true - let service = StoreService() - await service.load() - let coffee = try #require(service.product(for: StoreCatalog.Tip.coffee)) - - let received = MutableBox<[String]>([]) - service.onConsumablePurchased = { productID in received.value.append(productID) } - - let outcome = try await purchaseWithRetry(coffee, on: service) - #expect(outcome == .pending) - - let pendingTxn = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Tip.coffee - }) - try session.approveAskToBuyTransaction(identifier: pendingTxn.identifier) - - try await waitUntil(timeout: .seconds(5)) { - received.value.contains(StoreCatalog.Tip.coffee) + } + + @Test + func `listener and load applying the same transaction do not corrupt state`() async throws { + let service = StoreService() + await service.load() + let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) + + _ = try await purchaseWithRetry(bundle, on: service) // listener + purchase both walk + let afterPurchase = service.ownedThemeIDs + await service.load() // walk again + let afterReload = service.ownedThemeIDs + + #expect(afterPurchase == afterReload) + #expect(afterReload == StoreCatalog.Theme.bundledThemeIDs) + } + + // MARK: - SKTestSession robustness helpers + + /// Commits a purchase through the synchronously-consistent `product.purchase()` (unlike the + /// eventually-consistent `session.buyProduct`) and deliberately leaves the transaction + /// unfinished, so a later `load()` or `restorePurchases()` sees it deterministically. Retries + /// the transient `StoreKitError.unknown` that storekitd raises under SKTestSession churn. + private func purchaseUnfinished(_ product: Product, attempts: Int = 4) async throws { + for attempt in 1...attempts { + do { + let result = try await product.purchase() + guard case .success = result else { + throw StoreServiceError.purchaseFailed(reason: "setup purchase did not succeed: \(result)") } - #expect(received.value == [StoreCatalog.Tip.coffee]) + return + } catch let error as StoreKitError { + guard case .unknown = error, attempt < attempts else { throw error } + } } + } - @Test("refunding the bundle revokes its theme entitlements via the listener") - func refundRevokesThemes() async throws { - let service = StoreService() - await service.load() - let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: service) - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - - let txn = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Theme.bundleAll - }) - try session.refundTransaction(identifier: txn.identifier) - - try await waitUntil(timeout: .seconds(5)) { - service.ownedThemeIDs.isEmpty - } - } - - @Test("load drains unfinished transactions and applies their entitlement") - func loadDrainsUnfinished() async throws { - // Simulate a purchase committed while no StoreService was running: buy directly and leave - // the transaction unfinished. product.purchase() is synchronously consistent (unlike the - // eventually-consistent session.buyProduct), so the transaction is committed before any - // StoreService exists and load()'s drain path sees it deterministically. - let bundle = try #require( - try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first - ) - try await purchaseUnfinished(bundle) - - // Guard against any residual lag before Transaction.unfinished reflects the committed sale. - try await waitUntil( - timeout: .seconds(5), - pollingInterval: .milliseconds(100), - "committed bundle transaction did not surface in Transaction.unfinished" - ) { - await self.hasUnfinished(StoreCatalog.Theme.bundleAll) - } - - let service = StoreService() - service.shutdown() // detach the listener so only load()'s drain path can finish the transaction - await service.load() - - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - - var remainingUnfinished = 0 - for await _ in Transaction.unfinished { remainingUnfinished += 1 } - #expect(remainingUnfinished == 0) - } - - @Test("restore re-walks entitlements after AppStore.sync") - func restoreRecoversEntitlements() async throws { - // Establish ownership via the synchronously-consistent product.purchase(), then build a - // fresh service whose ownedThemeIDs starts empty and recover it through restore. - let bundle = try #require( - try await Product.products(for: [StoreCatalog.Theme.bundleAll]).first - ) - try await purchaseUnfinished(bundle) - - let service = StoreService() - service.shutdown() // isolate the restore path from the listener - try await service.restorePurchases() - - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - } - - @Test("Ask-to-Buy yields a pending outcome, then the approval grants entitlement") - func askToBuyPendingThenApproved() async throws { - session.askToBuyEnabled = true - let service = StoreService() - await service.load() - let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) - - let outcome = try await purchaseWithRetry(bundle, on: service) - #expect(outcome == .pending) - #expect(service.ownedThemeIDs.isEmpty) - - let pending = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Theme.bundleAll - }) - try session.approveAskToBuyTransaction(identifier: pending.identifier) - - try await waitUntil(timeout: .seconds(5)) { - service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs - } - } - - @Test("listener and load applying the same transaction do not corrupt state") - func listenerLoadIdempotence() async throws { - let service = StoreService() - await service.load() - let bundle = try #require(service.product(for: StoreCatalog.Theme.bundleAll)) - - _ = try await purchaseWithRetry(bundle, on: service) // listener + purchase both walk - let afterPurchase = service.ownedThemeIDs - await service.load() // walk again - let afterReload = service.ownedThemeIDs - - #expect(afterPurchase == afterReload) - #expect(afterReload == StoreCatalog.Theme.bundledThemeIDs) - } - - // MARK: - SKTestSession robustness helpers - - /// Commits a purchase through the synchronously-consistent `product.purchase()` (unlike the - /// eventually-consistent `session.buyProduct`) and deliberately leaves the transaction - /// unfinished, so a later `load()` or `restorePurchases()` sees it deterministically. Retries - /// the transient `StoreKitError.unknown` that storekitd raises under SKTestSession churn. - private func purchaseUnfinished(_ product: Product, attempts: Int = 4) async throws { - for attempt in 1...attempts { - do { - let result = try await product.purchase() - guard case .success = result else { - throw StoreServiceError.purchaseFailed(reason: "setup purchase did not succeed: \(result)") - } - return - } catch let error as StoreKitError { - guard case .unknown = error, attempt < attempts else { throw error } - } - } - } - - /// True once `productID` has an unfinished transaction, so a single `load()` sees it on the - /// drain path. - private func hasUnfinished(_ productID: String) async -> Bool { - for await result in Transaction.unfinished { - if case .verified(let txn) = result, txn.productID == productID { return true } - } - return false + /// True once `productID` has an unfinished transaction, so a single `load()` sees it on the + /// drain path. + private func hasUnfinished(_ productID: String) async -> Bool { + for await result in Transaction.unfinished { + if case let .verified(txn) = result, txn.productID == productID { return true } } + return false + } } diff --git a/MC1Tests/StoreStateTests.swift b/MC1Tests/StoreStateTests.swift index 32d8a400..2d50093a 100644 --- a/MC1Tests/StoreStateTests.swift +++ b/MC1Tests/StoreStateTests.swift @@ -1,204 +1,202 @@ -import Testing @testable import MC1 +import Testing @Suite("Store value-model types") struct StoreValueModelTests { - - @Test("RestoreState distinguishes its five cases") - func restoreStateEquatable() { - #expect(RestoreState.idle == .idle) - #expect(RestoreState.syncing != .completed) - #expect(RestoreState.failed != .idle) - #expect(RestoreState.cancelled != .completed) - #expect(RestoreState.cancelled != .failed) - } - - @Test("PendingPurchase carries productID and displayName") - func pendingPurchaseFields() { - let pending = PendingPurchase(productID: "io.pocketmesh.app.theme.marine", displayName: "Marine") - #expect(pending.productID == "io.pocketmesh.app.theme.marine") - #expect(pending.displayName == "Marine") - #expect(pending == PendingPurchase(productID: "io.pocketmesh.app.theme.marine", displayName: "Marine")) - } + @Test + func `RestoreState distinguishes its five cases`() { + #expect(RestoreState.idle == .idle) + #expect(RestoreState.syncing != .completed) + #expect(RestoreState.failed != .idle) + #expect(RestoreState.cancelled != .completed) + #expect(RestoreState.cancelled != .failed) + } + + @Test + func `PendingPurchase carries productID and displayName`() { + let pending = PendingPurchase(productID: "io.pocketmesh.app.theme.marine", displayName: "Marine") + #expect(pending.productID == "io.pocketmesh.app.theme.marine") + #expect(pending.displayName == "Marine") + #expect(pending == PendingPurchase(productID: "io.pocketmesh.app.theme.marine", displayName: "Marine")) + } } -import SwiftUI +@testable import MC1Services import StoreKit import StoreKitTest -@testable import MC1Services +import SwiftUI @MainActor @Suite("StoreState error mapping") struct StoreStateErrorMappingTests { - - private func makeState() -> StoreState { - let service = StoreService() - service.shutdown() - return StoreState(service: service) + private func makeState() -> StoreState { + let service = StoreService() + service.shutdown() + return StoreState(service: service) + } + + @Test + func `every StoreServiceError maps to a non-empty localized message`() { + let state = makeState() + let cases: [StoreServiceError] = [ + .productsNotLoaded, .productNotFound(productID: "x"), + .purchaseFailed(reason: "boom"), .verificationFailed, + .networkUnavailable, .storefrontUnavailable, .unsupported + ] + for error in cases { + #expect(state.localizedMessage(for: error).isEmpty == false) } - - @Test("every StoreServiceError maps to a non-empty localized message") - func everyErrorMaps() { - let state = makeState() - let cases: [StoreServiceError] = [ - .productsNotLoaded, .productNotFound(productID: "x"), - .purchaseFailed(reason: "boom"), .verificationFailed, - .networkUnavailable, .storefrontUnavailable, .unsupported - ] - for error in cases { - #expect(state.localizedMessage(for: error).isEmpty == false) - } - } - - @Test("purchaseFailed embeds the underlying reason") - func purchaseFailedEmbedsReason() { - let state = makeState() - #expect(state.localizedMessage(for: .purchaseFailed(reason: "boom")).contains("boom")) - } - - @Test("localizedMessage delegates to the shared userFacingMessage mapping") - func localizedMessageMatchesUserFacingMessage() { - let state = makeState() - let cases: [StoreServiceError] = [ - .productsNotLoaded, .productNotFound(productID: "x"), - .purchaseFailed(reason: "boom"), .verificationFailed, - .notEntitled, .networkUnavailable, .storefrontUnavailable, .unsupported - ] - for error in cases { - #expect(state.localizedMessage(for: error) == error.userFacingMessage) - } - } - - @Test("reconcilePendingPurchase is a no-op when there is no pending purchase") - func reconcileNoPending() { - let state = makeState() - state.reconcilePendingPurchase() - #expect(state.pendingPurchase == nil) + } + + @Test + func `purchaseFailed embeds the underlying reason`() { + let state = makeState() + #expect(state.localizedMessage(for: .purchaseFailed(reason: "boom")).contains("boom")) + } + + @Test + func `localizedMessage delegates to the shared userFacingMessage mapping`() { + let state = makeState() + let cases: [StoreServiceError] = [ + .productsNotLoaded, .productNotFound(productID: "x"), + .purchaseFailed(reason: "boom"), .verificationFailed, + .notEntitled, .networkUnavailable, .storefrontUnavailable, .unsupported + ] + for error in cases { + #expect(state.localizedMessage(for: error) == error.userFacingMessage) } + } + + @Test + func `reconcilePendingPurchase is a no-op when there is no pending purchase`() { + let state = makeState() + state.reconcilePendingPurchase() + #expect(state.pendingPurchase == nil) + } } @MainActor @Suite("StoreState purchase/restore", .serialized, .enabled(if: StoreKitTestAvailability.servesProducts)) final class StoreStatePurchaseTests { - let session: SKTestSession - - init() throws { - session = try SKTestSession(configurationFileNamed: "MC1") - session.disableDialogs = true - session.askToBuyEnabled = false // reset: SKTestSession leaks session flags across instances in-process - session.clearTransactions() - } - - deinit { session.clearTransactions() } - - @Test("buying the bundle sets no error and leaves no pending purchase") - func purchaseBundleNoPending() async throws { - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } - - #expect(state.errorMessage == nil) - #expect(state.pendingPurchase == nil) - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - } - - @Test("an Ask-to-Buy purchase sets a pending banner, then reconcile clears it on approval") - func askToBuySetsAndClearsPending() async throws { - session.askToBuyEnabled = true - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } - #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) - - let pending = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Theme.bundleAll - }) - try session.approveAskToBuyTransaction(identifier: pending.identifier) - - try await waitUntil(timeout: .seconds(5)) { - service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs - } - state.reconcilePendingPurchase() - #expect(state.pendingPurchase == nil) - } - - @Test("restore transitions syncing then completed") - func restoreCompletes() async throws { - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - await state.restorePurchases() - - #expect(state.restoreState == .completed) - #expect(state.errorMessage == nil) - } - - @Test("purchase returns true on a verified .purchased outcome") - func purchaseReturnsTrueOnPurchased() async throws { - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - let result = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } - - #expect(result == true) - #expect(state.errorMessage == nil) - #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) - } - - @Test("purchase returns false on .userCancelled without setting an error or pending banner") - func purchaseReturnsFalseOnUserCancel() async throws { - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - // SKTestSession exposes no `.userCancelled` `Product.PurchaseResult`; the purchase closure - // stubs it so this branch is reachable from tests. - let result = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { _ in .userCancelled } - - #expect(result == false) - #expect(state.errorMessage == nil) - #expect(state.pendingPurchase == nil) - } - - @Test("an unrelated .purchased does not clear an in-flight pending purchase") - func purchaseDoesNotClearUnrelatedPending() async throws { - // Ask-to-Buy on while the bundle is bought: outcome is .pending, banner is set. - session.askToBuyEnabled = true - let service = StoreService() - await service.load() - let state = StoreState(service: service) - - _ = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } - #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) - - // Ask-to-Buy off so the next purchase completes immediately. - session.askToBuyEnabled = false - let coffeeResult = await state.purchase(productID: StoreCatalog.Tip.coffee) { try await $0.purchase() } - - #expect(coffeeResult == true) - // Clearing the pending banner is gated on matching productID, so completing an - // unrelated tip purchase leaves the bundle's banner intact. - #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) - } - - @Test("restore that the user cancels maps to .cancelled, not .completed") - func restoreUserCancelMapsToCancelledNotCompleted() async throws { - let service = StoreService() - await service.load() - // AppStore.sync() cannot be made to throw userCancelled from SKTestSession; the seam - // throws on its behalf so the `.cancelled` arm of `RestoreOutcome` is reachable. - service.appStoreSyncForTesting = { throw StoreKitError.userCancelled } - let state = StoreState(service: service) - - await state.restorePurchases() - - #expect(state.restoreState == .cancelled) - #expect(state.errorMessage == nil) + let session: SKTestSession + + init() throws { + session = try SKTestSession(configurationFileNamed: "MC1") + session.disableDialogs = true + session.askToBuyEnabled = false // reset: SKTestSession leaks session flags across instances in-process + session.clearTransactions() + } + + deinit { session.clearTransactions() } + + @Test + func `buying the bundle sets no error and leaves no pending purchase`() async { + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } + + #expect(state.errorMessage == nil) + #expect(state.pendingPurchase == nil) + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + } + + @Test + func `an Ask-to-Buy purchase sets a pending banner, then reconcile clears it on approval`() async throws { + session.askToBuyEnabled = true + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } + #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) + + let pending = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Theme.bundleAll + }) + try session.approveAskToBuyTransaction(identifier: pending.identifier) + + try await waitUntil(timeout: .seconds(5)) { + service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs } + state.reconcilePendingPurchase() + #expect(state.pendingPurchase == nil) + } + + @Test + func `restore transitions syncing then completed`() async { + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + await state.restorePurchases() + + #expect(state.restoreState == .completed) + #expect(state.errorMessage == nil) + } + + @Test + func `purchase returns true on a verified .purchased outcome`() async { + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + let result = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } + + #expect(result == true) + #expect(state.errorMessage == nil) + #expect(service.ownedThemeIDs == StoreCatalog.Theme.bundledThemeIDs) + } + + @Test + func `purchase returns false on .userCancelled without setting an error or pending banner`() async { + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + // SKTestSession exposes no `.userCancelled` `Product.PurchaseResult`; the purchase closure + // stubs it so this branch is reachable from tests. + let result = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { _ in .userCancelled } + + #expect(result == false) + #expect(state.errorMessage == nil) + #expect(state.pendingPurchase == nil) + } + + @Test + func `an unrelated .purchased does not clear an in-flight pending purchase`() async { + // Ask-to-Buy on while the bundle is bought: outcome is .pending, banner is set. + session.askToBuyEnabled = true + let service = StoreService() + await service.load() + let state = StoreState(service: service) + + _ = await state.purchase(productID: StoreCatalog.Theme.bundleAll) { try await $0.purchase() } + #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) + + // Ask-to-Buy off so the next purchase completes immediately. + session.askToBuyEnabled = false + let coffeeResult = await state.purchase(productID: StoreCatalog.Tip.coffee) { try await $0.purchase() } + + #expect(coffeeResult == true) + // Clearing the pending banner is gated on matching productID, so completing an + // unrelated tip purchase leaves the bundle's banner intact. + #expect(state.pendingPurchase?.productID == StoreCatalog.Theme.bundleAll) + } + + @Test + func `restore that the user cancels maps to .cancelled, not .completed`() async { + let service = StoreService() + await service.load() + // AppStore.sync() cannot be made to throw userCancelled from SKTestSession; the seam + // throws on its behalf so the `.cancelled` arm of `RestoreOutcome` is reachable. + service.appStoreSyncForTesting = { throw StoreKitError.userCancelled } + let state = StoreState(service: service) + + await state.restorePurchases() + + #expect(state.restoreState == .cancelled) + #expect(state.errorMessage == nil) + } } diff --git a/MC1Tests/Theme/IdentityGamutTests.swift b/MC1Tests/Theme/IdentityGamutTests.swift index 9ef95997..2990361c 100644 --- a/MC1Tests/Theme/IdentityGamutTests.swift +++ b/MC1Tests/Theme/IdentityGamutTests.swift @@ -1,80 +1,79 @@ -import Testing +@testable import MC1 import SwiftUI +import Testing import UIKit -@testable import MC1 /// Validates the identity-color solver in isolation: for a representative gamut, every name must /// resolve to a color that clears WCAG AA against the surfaces it renders on, in both appearances /// and both contrast settings, and the avatar glyph must clear AA against that color as a fill. @Suite("IdentityGamut contrast") struct IdentityGamutTests { + private let gamut = IdentityGamut( + hueAnchors: [20, 50, 95, 150, 195, 235, 280, 320], + saturation: 0.45...0.75 + ) - private let gamut = IdentityGamut( - hueAnchors: [20, 50, 95, 150, 195, 235, 280, 320], - saturation: 0.45...0.75 - ) + /// A broad spread of names, including anagrams and near-duplicates that stress hash collisions. + private static let names: [String] = (0..<600).map { "Sender \($0)" } + ["Bob", "obB", "Alice", "alice", "灯火", "Søren"] - /// A broad spread of names, including anagrams and near-duplicates that stress hash collisions. - private static let names: [String] = (0..<600).map { "Sender \($0)" } + ["Bob", "obB", "Alice", "alice", "灯火", "Søren"] + private static let light = UITraitCollection(userInterfaceStyle: .light) + private static let dark = UITraitCollection(userInterfaceStyle: .dark) - private static let light = UITraitCollection(userInterfaceStyle: .light) - private static let dark = UITraitCollection(userInterfaceStyle: .dark) + private func luminance(of color: Color, _ trait: UITraitCollection) -> Double { + WCAGContrast.relativeLuminance(of: UIColor(color).resolvedColor(with: trait)) + } - private func luminance(of color: Color, _ trait: UITraitCollection) -> Double { - WCAGContrast.relativeLuminance(of: UIColor(color).resolvedColor(with: trait)) - } + private func surfaceLuminances(_ trait: UITraitCollection) -> [Double] { + [ + luminance(of: Color(.systemBackground), trait), + luminance(of: Color(.secondarySystemBackground), trait), + luminance(of: Color(UIColor.systemGray5), trait) + ] + } - private func surfaceLuminances(_ trait: UITraitCollection) -> [Double] { - [ - luminance(of: Color(.systemBackground), trait), - luminance(of: Color(.secondarySystemBackground), trait), - luminance(of: Color(UIColor.systemGray5), trait) - ] - } - - @Test("every identity color clears AA against its surfaces in both appearances and contrasts") - func identityColorsClearAA() { - for trait in [Self.light, Self.dark] { - for highContrast in [false, true] { - let backgrounds = surfaceLuminances(trait) - let floor = highContrast ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor - for name in Self.names { - let color = gamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: highContrast) - let colorLuminance = luminance(of: color, trait) - for background in backgrounds { - let ratio = WCAGContrast.contrastRatio(colorLuminance, background) - #expect( - ratio >= floor, - "\(name) color luminance \(colorLuminance) vs surface \(background) is \(ratio), below \(floor)" - ) - } - } - } + @Test + func `every identity color clears AA against its surfaces in both appearances and contrasts`() { + for trait in [Self.light, Self.dark] { + for highContrast in [false, true] { + let backgrounds = surfaceLuminances(trait) + let floor = highContrast ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor + for name in Self.names { + let color = gamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: highContrast) + let colorLuminance = luminance(of: color, trait) + for background in backgrounds { + let ratio = WCAGContrast.contrastRatio(colorLuminance, background) + #expect( + ratio >= floor, + "\(name) color luminance \(colorLuminance) vs surface \(background) is \(ratio), below \(floor)" + ) + } } + } } + } - @Test("avatar glyph clears AA against the identity fill in both appearances") - func glyphClearsAA() { - for trait in [Self.light, Self.dark] { - let backgrounds = surfaceLuminances(trait) - for name in Self.names { - let fill = gamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: false) - let fillLuminance = luminance(of: fill, trait) - let glyph = IdentityGamut.glyphColor(forFillLuminance: fillLuminance) - let ratio = WCAGContrast.contrastRatio(luminance(of: glyph, trait), fillLuminance) - #expect(ratio >= WCAGContrast.aaFloor, "\(name) glyph vs fill is \(ratio)") - } - } + @Test + func `avatar glyph clears AA against the identity fill in both appearances`() { + for trait in [Self.light, Self.dark] { + let backgrounds = surfaceLuminances(trait) + for name in Self.names { + let fill = gamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: false) + let fillLuminance = luminance(of: fill, trait) + let glyph = IdentityGamut.glyphColor(forFillLuminance: fillLuminance) + let ratio = WCAGContrast.contrastRatio(luminance(of: glyph, trait), fillLuminance) + #expect(ratio >= WCAGContrast.aaFloor, "\(name) glyph vs fill is \(ratio)") + } } + } - @Test("hue is stable across appearance and contrast for a given name") - func hueStableAcrossAppearance() { - let lightBackgrounds = surfaceLuminances(Self.light) - let darkBackgrounds = surfaceLuminances(Self.dark) - for name in Self.names.prefix(50) { - let lightHue = gamut.resolve(forName: name, backgroundLuminances: lightBackgrounds, highContrast: false).hue - let darkHue = gamut.resolve(forName: name, backgroundLuminances: darkBackgrounds, highContrast: false).hue - #expect(lightHue == darkHue, "\(name) hue drifted across appearance: \(lightHue) vs \(darkHue)") - } + @Test + func `hue is stable across appearance and contrast for a given name`() { + let lightBackgrounds = surfaceLuminances(Self.light) + let darkBackgrounds = surfaceLuminances(Self.dark) + for name in Self.names.prefix(50) { + let lightHue = gamut.resolve(forName: name, backgroundLuminances: lightBackgrounds, highContrast: false).hue + let darkHue = gamut.resolve(forName: name, backgroundLuminances: darkBackgrounds, highContrast: false).hue + #expect(lightHue == darkHue, "\(name) hue drifted across appearance: \(lightHue) vs \(darkHue)") } + } } diff --git a/MC1Tests/Theme/ThemeContrastTests.swift b/MC1Tests/Theme/ThemeContrastTests.swift index ee95885d..5e37b269 100644 --- a/MC1Tests/Theme/ThemeContrastTests.swift +++ b/MC1Tests/Theme/ThemeContrastTests.swift @@ -1,7 +1,7 @@ -import Testing +@testable import MC1 import SwiftUI +import Testing import UIKit -@testable import MC1 /// Guards the legibility of outgoing message text against its bubble fill (the theme accent) for /// every paid theme, in every appearance/contrast combination, and the legibility of identity and @@ -9,161 +9,160 @@ import UIKit /// stress and outdoor glare, so they must clear the WCAG AA 4.5:1 floor. @Suite("Theme contrast") struct ThemeContrastTests { - - /// Derived from the registry so a future paid theme is covered automatically rather than - /// shipping with no contrast validation because someone forgot to extend a hand-written list. - private static let paidThemes: [Theme] = ThemeRegistry.allThemes.filter { $0.productID != nil } - - private static let traits: [(name: String, style: UIUserInterfaceStyle, collection: UITraitCollection)] = [ - ("light", .light, UITraitCollection(userInterfaceStyle: .light)), - ("dark", .dark, UITraitCollection(userInterfaceStyle: .dark)), - ("light+highContrast", .light, UITraitCollection(traitsFrom: [ - UITraitCollection(userInterfaceStyle: .light), - UITraitCollection(accessibilityContrast: .high) - ])), - ("dark+highContrast", .dark, UITraitCollection(traitsFrom: [ - UITraitCollection(userInterfaceStyle: .dark), - UITraitCollection(accessibilityContrast: .high) - ])) - ] - - /// The appearances a theme actually renders in. A forced color scheme pins the theme to one - /// appearance, so asserting contrast in the other tests a combination users never see — e.g. - /// Ember is dark-only, and its hashtag is tuned for its dark surface, not the light bubble it - /// never shows on. - private static func traits( - for theme: Theme - ) -> [(name: String, style: UIUserInterfaceStyle, collection: UITraitCollection)] { - guard let forced = theme.preferredColorScheme else { return traits } - let style: UIUserInterfaceStyle = (forced == .dark) ? .dark : .light - return traits.filter { $0.style == style } - } - - /// A broad spread of names, including anagrams and near-duplicates that stress hash collisions. - private static let identityNames: [String] = - (0..<400).map { "Sender \($0)" } + ["Bob", "obB", "Alice", "alice", "灯火", "Søren", "#general"] - - private static func floor(forTraitNamed name: String) -> Double { - name.contains("highContrast") ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor - } - - private static func luminance(of color: Color, _ collection: UITraitCollection) -> Double { - WCAGContrast.relativeLuminance(of: UIColor(color).resolvedColor(with: collection)) - } - - /// Canvas (or system background for the surfaceless default theme) and incoming bubble — the two - /// surfaces identity colors must stay legible against. - private static func surfaceLuminances(_ theme: Theme, _ collection: UITraitCollection) -> [Double] { - [theme.surfaces?.canvas ?? Color(.systemBackground), theme.incomingBubbleColor] - .map { luminance(of: $0, collection) } + /// Derived from the registry so a future paid theme is covered automatically rather than + /// shipping with no contrast validation because someone forgot to extend a hand-written list. + private static let paidThemes: [Theme] = ThemeRegistry.allThemes.filter { $0.productID != nil } + + private static let traits: [(name: String, style: UIUserInterfaceStyle, collection: UITraitCollection)] = [ + ("light", .light, UITraitCollection(userInterfaceStyle: .light)), + ("dark", .dark, UITraitCollection(userInterfaceStyle: .dark)), + ("light+highContrast", .light, UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .light), + UITraitCollection(accessibilityContrast: .high) + ])), + ("dark+highContrast", .dark, UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .dark), + UITraitCollection(accessibilityContrast: .high) + ])) + ] + + /// The appearances a theme actually renders in. A forced color scheme pins the theme to one + /// appearance, so asserting contrast in the other tests a combination users never see — e.g. + /// Ember is dark-only, and its hashtag is tuned for its dark surface, not the light bubble it + /// never shows on. + private static func traits( + for theme: Theme + ) -> [(name: String, style: UIUserInterfaceStyle, collection: UITraitCollection)] { + guard let forced = theme.preferredColorScheme else { return traits } + let style: UIUserInterfaceStyle = (forced == .dark) ? .dark : .light + return traits.filter { $0.style == style } + } + + /// A broad spread of names, including anagrams and near-duplicates that stress hash collisions. + private static let identityNames: [String] = + (0..<400).map { "Sender \($0)" } + ["Bob", "obB", "Alice", "alice", "灯火", "Søren", "#general"] + + private static func floor(forTraitNamed name: String) -> Double { + name.contains("highContrast") ? WCAGContrast.increasedContrastFloor : WCAGContrast.aaFloor + } + + private static func luminance(of color: Color, _ collection: UITraitCollection) -> Double { + WCAGContrast.relativeLuminance(of: UIColor(color).resolvedColor(with: collection)) + } + + /// Canvas (or system background for the surfaceless default theme) and incoming bubble — the two + /// surfaces identity colors must stay legible against. + private static func surfaceLuminances(_ theme: Theme, _ collection: UITraitCollection) -> [Double] { + [theme.surfaces?.canvas ?? Color(.systemBackground), theme.incomingBubbleColor] + .map { luminance(of: $0, collection) } + } + + /// The list canvas — the only surface a category avatar renders on. + private static func canvasLuminance(_ theme: Theme, _ collection: UITraitCollection) -> Double { + luminance(of: theme.surfaces?.canvas ?? Color(.systemBackground), collection) + } + + @Test + func `outgoing text clears WCAG AA 4.5:1 against the accent in every appearance`() { + for theme in Self.paidThemes { + for trait in Self.traits(for: theme) { + let text = Self.luminance(of: theme.outgoingTextColor, trait.collection) + let fill = Self.luminance(of: theme.accentColor, trait.collection) + let ratio = WCAGContrast.contrastRatio(text, fill) + #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) outgoing text vs accent in \(trait.name) is \(ratio)") + } } - - /// The list canvas — the only surface a category avatar renders on. - private static func canvasLuminance(_ theme: Theme, _ collection: UITraitCollection) -> Double { - luminance(of: theme.surfaces?.canvas ?? Color(.systemBackground), collection) - } - - @Test("outgoing text clears WCAG AA 4.5:1 against the accent in every appearance") - func outgoingTextClearsAAFloor() { - for theme in Self.paidThemes { - for trait in Self.traits(for: theme) { - let text = Self.luminance(of: theme.outgoingTextColor, trait.collection) - let fill = Self.luminance(of: theme.accentColor, trait.collection) - let ratio = WCAGContrast.contrastRatio(text, fill) - #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) outgoing text vs accent in \(trait.name) is \(ratio)") - } - } - } - - @Test("incoming hashtag links clear WCAG AA 4.5:1 against the incoming bubble in every appearance") - func incomingHashtagClearsAAFloor() { - for theme in Self.paidThemes { - for trait in Self.traits(for: theme) { - let hashtag = Self.luminance(of: theme.hashtagColor, trait.collection) - let bubble = Self.luminance(of: theme.incomingBubbleColor, trait.collection) - let ratio = WCAGContrast.contrastRatio(hashtag, bubble) - #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) incoming hashtag vs bubble in \(trait.name) is \(ratio)") - } - } + } + + @Test + func `incoming hashtag links clear WCAG AA 4.5:1 against the incoming bubble in every appearance`() { + for theme in Self.paidThemes { + for trait in Self.traits(for: theme) { + let hashtag = Self.luminance(of: theme.hashtagColor, trait.collection) + let bubble = Self.luminance(of: theme.incomingBubbleColor, trait.collection) + let ratio = WCAGContrast.contrastRatio(hashtag, bubble) + #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) incoming hashtag vs bubble in \(trait.name) is \(ratio)") + } } - - @Test("identity colors clear AA against their surfaces for every theme and appearance") - func identityColorsClearAA() { - for theme in ThemeRegistry.allThemes { - for trait in Self.traits(for: theme) { - let floor = Self.floor(forTraitNamed: trait.name) - let highContrast = trait.name.contains("highContrast") - let backgrounds = Self.surfaceLuminances(theme, trait.collection) - for name in Self.identityNames { - let color = theme.identityGamut.color( - forName: name, - backgroundLuminances: backgrounds, - highContrast: highContrast - ) - let colorLuminance = Self.luminance(of: color, trait.collection) - for background in backgrounds { - let ratio = WCAGContrast.contrastRatio(colorLuminance, background) - #expect(ratio >= floor, "\(theme.id) identity '\(name)' vs surface \(background) in \(trait.name) is \(ratio)") - } - } - } + } + + @Test + func `identity colors clear AA against their surfaces for every theme and appearance`() { + for theme in ThemeRegistry.allThemes { + for trait in Self.traits(for: theme) { + let floor = Self.floor(forTraitNamed: trait.name) + let highContrast = trait.name.contains("highContrast") + let backgrounds = Self.surfaceLuminances(theme, trait.collection) + for name in Self.identityNames { + let color = theme.identityGamut.color( + forName: name, + backgroundLuminances: backgrounds, + highContrast: highContrast + ) + let colorLuminance = Self.luminance(of: color, trait.collection) + for background in backgrounds { + let ratio = WCAGContrast.contrastRatio(colorLuminance, background) + #expect(ratio >= floor, "\(theme.id) identity '\(name)' vs surface \(background) in \(trait.name) is \(ratio)") + } } + } } - - /// Resolves the channel / repeater / room avatar colors the way `Theme.categoryAvatarColor` does: - /// each at its curated hue, against the list canvas only, at the darkest legible brightness. - private static func categoryColors(_ theme: Theme, canvas: Double, highContrast: Bool) -> [(AvatarCategory, Color)] { - AvatarCategory.anchorPriority.map { category in - (category, theme.identityGamut.color( - forName: category.gamutSeed, - backgroundLuminances: [canvas], - highContrast: highContrast, - atHue: theme.categoryHue(for: category), - atVariety: Theme.categoryDarkestVariety - )) - } + } + + /// Resolves the channel / repeater / room avatar colors the way `Theme.categoryAvatarColor` does: + /// each at its curated hue, against the list canvas only, at the darkest legible brightness. + private static func categoryColors(_ theme: Theme, canvas: Double, highContrast: Bool) -> [(AvatarCategory, Color)] { + AvatarCategory.anchorPriority.map { category in + (category, theme.identityGamut.color( + forName: category.gamutSeed, + backgroundLuminances: [canvas], + highContrast: highContrast, + atHue: theme.categoryHue(for: category), + atVariety: Theme.categoryDarkestVariety + )) } - - @Test("gamut-derived category colors clear AA against the list canvas") - func categoryColorsClearAA() { - // The System theme pins category colors to fixed legacy values that predate this AA bar, so - // it is exempt; every gamut-derived theme must clear it against the canvas it renders on. - for theme in ThemeRegistry.allThemes where theme.categoryAvatarOverride == nil { - for trait in Self.traits(for: theme) { - let floor = Self.floor(forTraitNamed: trait.name) - let highContrast = trait.name.contains("highContrast") - let canvas = Self.canvasLuminance(theme, trait.collection) - for (category, color) in Self.categoryColors(theme, canvas: canvas, highContrast: highContrast) { - let ratio = WCAGContrast.contrastRatio(Self.luminance(of: color, trait.collection), canvas) - #expect(ratio >= floor, "\(theme.id) category \(category) vs canvas \(canvas) in \(trait.name) is \(ratio)") - } - } + } + + @Test + func `gamut-derived category colors clear AA against the list canvas`() { + // The System theme pins category colors to fixed legacy values that predate this AA bar, so + // it is exempt; every gamut-derived theme must clear it against the canvas it renders on. + for theme in ThemeRegistry.allThemes where theme.categoryAvatarOverride == nil { + for trait in Self.traits(for: theme) { + let floor = Self.floor(forTraitNamed: trait.name) + let highContrast = trait.name.contains("highContrast") + let canvas = Self.canvasLuminance(theme, trait.collection) + for (category, color) in Self.categoryColors(theme, canvas: canvas, highContrast: highContrast) { + let ratio = WCAGContrast.contrastRatio(Self.luminance(of: color, trait.collection), canvas) + #expect(ratio >= floor, "\(theme.id) category \(category) vs canvas \(canvas) in \(trait.name) is \(ratio)") } + } } - - @Test("channel, repeater, and room avatars resolve to distinct on-anchor hues for every gamut theme") - func categoryColorsAreDistinct() { - for theme in ThemeRegistry.allThemes where theme.categoryAvatarOverride == nil { - let anchors = Set(theme.identityGamut.sortedAnchors) - let hues = AvatarCategory.anchorPriority.map { theme.categoryHue(for: $0) } - #expect(Set(hues).count == hues.count, "\(theme.id) category hues collide: \(hues)") - #expect(hues.allSatisfy { anchors.contains($0) }, "\(theme.id) category hue left the palette: \(hues)") - } + } + + @Test + func `channel, repeater, and room avatars resolve to distinct on-anchor hues for every gamut theme`() { + for theme in ThemeRegistry.allThemes where theme.categoryAvatarOverride == nil { + let anchors = Set(theme.identityGamut.sortedAnchors) + let hues = AvatarCategory.anchorPriority.map { theme.categoryHue(for: $0) } + #expect(Set(hues).count == hues.count, "\(theme.id) category hues collide: \(hues)") + #expect(hues.allSatisfy { anchors.contains($0) }, "\(theme.id) category hue left the palette: \(hues)") } - - @Test("avatar glyph clears AA against the identity fill for every theme and appearance") - func glyphClearsAA() { - for theme in ThemeRegistry.allThemes { - for trait in Self.traits(for: theme) { - let backgrounds = Self.surfaceLuminances(theme, trait.collection) - for name in Self.identityNames.prefix(100) { - let fill = theme.identityGamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: false) - let fillLuminance = Self.luminance(of: fill, trait.collection) - let glyph = IdentityGamut.glyphColor(forFillLuminance: fillLuminance) - let ratio = WCAGContrast.contrastRatio(Self.luminance(of: glyph, trait.collection), fillLuminance) - #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) glyph vs fill '\(name)' in \(trait.name) is \(ratio)") - } - } + } + + @Test + func `avatar glyph clears AA against the identity fill for every theme and appearance`() { + for theme in ThemeRegistry.allThemes { + for trait in Self.traits(for: theme) { + let backgrounds = Self.surfaceLuminances(theme, trait.collection) + for name in Self.identityNames.prefix(100) { + let fill = theme.identityGamut.color(forName: name, backgroundLuminances: backgrounds, highContrast: false) + let fillLuminance = Self.luminance(of: fill, trait.collection) + let glyph = IdentityGamut.glyphColor(forFillLuminance: fillLuminance) + let ratio = WCAGContrast.contrastRatio(Self.luminance(of: glyph, trait.collection), fillLuminance) + #expect(ratio >= WCAGContrast.aaFloor, "\(theme.id) glyph vs fill '\(name)' in \(trait.name) is \(ratio)") } + } } + } } diff --git a/MC1Tests/Theme/ThemeStructureTests.swift b/MC1Tests/Theme/ThemeStructureTests.swift index d86d58c2..cea7203e 100644 --- a/MC1Tests/Theme/ThemeStructureTests.swift +++ b/MC1Tests/Theme/ThemeStructureTests.swift @@ -1,80 +1,79 @@ -import Testing +@testable import MC1 import SwiftUI +import Testing import UIKit -@testable import MC1 @Suite("Theme structure") struct ThemeStructureTests { + @Test + func `Default theme paints no surfaces`() { + #expect(Theme.default.surfaces == nil) + } - @Test("Default theme paints no surfaces") - func defaultThemeHasNoSurfaces() { - #expect(Theme.default.surfaces == nil) - } - - @Test("Default theme imposes no chrome tint, deferring to the system") - func defaultThemeHasNoChromeTint() { - #expect(Theme.default.chromeTint == nil) - } + @Test + func `Default theme imposes no chrome tint, deferring to the system`() { + #expect(Theme.default.chromeTint == nil) + } - /// Derived from the registry so a future paid theme is covered automatically. - private static let paidThemes: [Theme] = ThemeRegistry.allThemes.filter { $0.productID != nil } + /// Derived from the registry so a future paid theme is covered automatically. + private static let paidThemes: [Theme] = ThemeRegistry.allThemes.filter { $0.productID != nil } - @Test("Paid themes impose their accent on chrome") - func paidThemesTintChromeWithAccent() { - for theme in Self.paidThemes { - #expect(theme.chromeTint == theme.accentColor, "\(theme.id) must tint chrome with its accent") - } + @Test + func `Paid themes impose their accent on chrome`() { + for theme in Self.paidThemes { + #expect(theme.chromeTint == theme.accentColor, "\(theme.id) must tint chrome with its accent") } + } - @Test("Ember paints the canvas but not the card tier") - func emberPaintsCanvasButNotCard() throws { - let surfaces = try #require(Theme.ember.surfaces) - #expect(surfaces.canvas == .black) - #expect(surfaces.card == nil) - } + @Test + func `Ember paints the canvas but not the card tier`() throws { + let surfaces = try #require(Theme.ember.surfaces) + #expect(surfaces.canvas == .black) + #expect(surfaces.card == nil) + } - @Test("Every painted theme defines both canvas and card tiers") - func allPaintedThemesHaveBothTiers() throws { - // Painted = every paid theme except the canvas-only Ember. Excluding by identity (not by - // card-presence) keeps the assertion meaningful: a future paid theme that forgets its card - // tier lands here and fails instead of being silently filtered out. - let painted = Self.paidThemes.filter { $0.id != Theme.ember.id } - for theme in painted { - let surfaces = try #require(theme.surfaces, "\(theme.id) must have surfaces") - #expect(surfaces.card != nil, "\(theme.id) must define card tier") - } + @Test + func `Every painted theme defines both canvas and card tiers`() throws { + // Painted = every paid theme except the canvas-only Ember. Excluding by identity (not by + // card-presence) keeps the assertion meaningful: a future paid theme that forgets its card + // tier lands here and fails instead of being silently filtered out. + let painted = Self.paidThemes.filter { $0.id != Theme.ember.id } + for theme in painted { + let surfaces = try #require(theme.surfaces, "\(theme.id) must have surfaces") + #expect(surfaces.card != nil, "\(theme.id) must define card tier") } + } - @Test("Every theme defines a usable identity gamut") - func everyThemeHasIdentityGamut() { - for theme in ThemeRegistry.allThemes { - let gamut = theme.identityGamut - #expect(!gamut.hueAnchors.isEmpty, "\(theme.id) must define identity hue anchors") - #expect(gamut.hueAnchors.allSatisfy { (0..<360).contains($0) }, "\(theme.id) hue anchors must be 0..<360") - #expect(gamut.saturation.lowerBound >= 0 && gamut.saturation.upperBound <= 1, - "\(theme.id) saturation must be within 0...1") - #expect(gamut.saturation.lowerBound < gamut.saturation.upperBound, - "\(theme.id) saturation must be a non-empty range") - } + @Test + func `Every theme defines a usable identity gamut`() { + for theme in ThemeRegistry.allThemes { + let gamut = theme.identityGamut + #expect(!gamut.hueAnchors.isEmpty, "\(theme.id) must define identity hue anchors") + #expect(gamut.hueAnchors.allSatisfy { (0..<360).contains($0) }, "\(theme.id) hue anchors must be 0..<360") + #expect(gamut.saturation.lowerBound >= 0 && gamut.saturation.upperBound <= 1, + "\(theme.id) saturation must be within 0...1") + #expect(gamut.saturation.lowerBound < gamut.saturation.upperBound, + "\(theme.id) saturation must be a non-empty range") } + } - @Test("Only the System theme pins fixed category avatar colors") - func onlySystemPinsCategoryColors() { - #expect(Theme.default.categoryAvatarOverride != nil, "System theme must pin category colors") - for theme in Self.paidThemes { - #expect(theme.categoryAvatarOverride == nil, "\(theme.id) must derive category colors from its gamut") - } + @Test + func `Only the System theme pins fixed category avatar colors`() { + #expect(Theme.default.categoryAvatarOverride != nil, "System theme must pin category colors") + for theme in Self.paidThemes { + #expect(theme.categoryAvatarOverride == nil, "\(theme.id) must derive category colors from its gamut") } + } - @Test("Migrated themes' outgoingTextColor resolves differently in light vs dark") - func outgoingTextFlipsAcrossAppearance() { - let migrated: [Theme] = [.fern, .olive, .lavender, .sakura] - let light = UITraitCollection(userInterfaceStyle: .light) - let dark = UITraitCollection(userInterfaceStyle: .dark) - for theme in migrated { - let lightResolved = UIColor(theme.outgoingTextColor).resolvedColor(with: light) - let darkResolved = UIColor(theme.outgoingTextColor).resolvedColor(with: dark) - #expect(lightResolved != darkResolved, "\(theme.id) outgoingText must differ across appearance") - } + @Test + func `Migrated themes' outgoingTextColor resolves differently in light vs dark`() { + let migrated: [Theme] = [.fern, .olive, .lavender, .sakura] + let light = UITraitCollection(userInterfaceStyle: .light) + let dark = UITraitCollection(userInterfaceStyle: .dark) + for theme in migrated { + let lightResolved = UIColor(theme.outgoingTextColor).resolvedColor(with: light) + let darkResolved = UIColor(theme.outgoingTextColor).resolvedColor(with: dark) + #expect(lightResolved != darkResolved, "\(theme.id) outgoingText must differ across appearance") } + } } diff --git a/MC1Tests/ThemeLocalizedNameTests.swift b/MC1Tests/ThemeLocalizedNameTests.swift index 06876519..18c5f20e 100644 --- a/MC1Tests/ThemeLocalizedNameTests.swift +++ b/MC1Tests/ThemeLocalizedNameTests.swift @@ -1,35 +1,34 @@ -import Testing @testable import MC1 +import Testing @Suite("Theme.localizedName") struct ThemeLocalizedNameTests { - - @Test("every registry theme has a non-empty localized name") - func everyThemeNamed() { - for theme in ThemeRegistry.allThemes { - #expect(theme.localizedName.isEmpty == false) - } + @Test + func `every registry theme has a non-empty localized name`() { + for theme in ThemeRegistry.allThemes { + #expect(theme.localizedName.isEmpty == false) } + } - @Test("every registry theme resolves to copy distinct from its raw display-name key") - func everyThemeResolvesToLocalizedCopy() { - // A theme added to the registry without a `localizedName` case would fall back to the raw - // dotted key. Asserting resolution differs from `displayNameKey` catches that regression - // (and the missing case also traps via assertionFailure on this debug test run). - for theme in ThemeRegistry.allThemes { - #expect(theme.localizedName != theme.displayNameKey) - } + @Test + func `every registry theme resolves to copy distinct from its raw display-name key`() { + // A theme added to the registry without a `localizedName` case would fall back to the raw + // dotted key. Asserting resolution differs from `displayNameKey` catches that regression + // (and the missing case also traps via assertionFailure on this debug test run). + for theme in ThemeRegistry.allThemes { + #expect(theme.localizedName != theme.displayNameKey) } + } - @Test("theme names are distinct across the registry") - func namesAreDistinct() { - let names = ThemeRegistry.allThemes.map(\.localizedName) - #expect(Set(names).count == names.count) - } + @Test + func `theme names are distinct across the registry`() { + let names = ThemeRegistry.allThemes.map(\.localizedName) + #expect(Set(names).count == names.count) + } - @Test("default and ember resolve to their expected English copy") - func knownNames() { - #expect(Theme.default.localizedName == L10n.Settings.Support.Theme.default) - #expect(Theme.ember.localizedName == L10n.Settings.Support.Theme.ember) - } + @Test + func `default and ember resolve to their expected English copy`() { + #expect(Theme.default.localizedName == L10n.Settings.Support.Theme.default) + #expect(Theme.ember.localizedName == L10n.Settings.Support.Theme.ember) + } } diff --git a/MC1Tests/ThemeRegistryTests.swift b/MC1Tests/ThemeRegistryTests.swift index 51494083..0911076d 100644 --- a/MC1Tests/ThemeRegistryTests.swift +++ b/MC1Tests/ThemeRegistryTests.swift @@ -1,51 +1,50 @@ -import Testing -import SwiftUI -@testable import MC1Services @testable import MC1 +@testable import MC1Services +import SwiftUI +import Testing @Suite("ThemeRegistry") struct ThemeRegistryTests { + @Test + func `allThemes holds the ten built-ins`() { + #expect(ThemeRegistry.allThemes.count == 10) + #expect(ThemeRegistry.allThemes.first?.id == "default") + } - @Test("allThemes holds the ten built-ins") - func allThemesCount() { - #expect(ThemeRegistry.allThemes.count == 10) - #expect(ThemeRegistry.allThemes.first?.id == "default") - } - - @Test("theme(forID:) resolves known IDs and returns nil for unknown") - func themeLookup() { - #expect(ThemeRegistry.theme(forID: "default")?.id == Theme.default.id) - #expect(ThemeRegistry.theme(forID: "ember")?.id == "ember") - #expect(ThemeRegistry.theme(forID: "does-not-exist") == nil) - } + @Test + func `theme(forID:) resolves known IDs and returns nil for unknown`() { + #expect(ThemeRegistry.theme(forID: "default")?.id == Theme.default.id) + #expect(ThemeRegistry.theme(forID: "ember")?.id == "ember") + #expect(ThemeRegistry.theme(forID: "does-not-exist") == nil) + } - @Test("default theme has no productID; all paid themes do") - func productIDPresence() { - #expect(Theme.default.productID == nil) - let paid = ThemeRegistry.allThemes.filter { $0.id != "default" } - #expect(paid.count == 9) - #expect(paid.allSatisfy { $0.productID != nil }) - } + @Test + func `default theme has no productID; all paid themes do`() { + #expect(Theme.default.productID == nil) + let paid = ThemeRegistry.allThemes.filter { $0.id != "default" } + #expect(paid.count == 9) + #expect(paid.allSatisfy { $0.productID != nil }) + } - @Test("paid theme productIDs match StoreCatalog's bundled theme IDs") - func productIDsMatchCatalog() { - let registryProductIDs = Set(ThemeRegistry.allThemes.compactMap { $0.productID }) - #expect(registryProductIDs == StoreCatalog.Theme.bundledThemeIDs) - #expect(Theme.ember.productID == StoreCatalog.Theme.ember) - #expect(Theme.solarized.productID == StoreCatalog.Theme.solarized) - } + @Test + func `paid theme productIDs match StoreCatalog's bundled theme IDs`() { + let registryProductIDs = Set(ThemeRegistry.allThemes.compactMap(\.productID)) + #expect(registryProductIDs == StoreCatalog.Theme.bundledThemeIDs) + #expect(Theme.ember.productID == StoreCatalog.Theme.ember) + #expect(Theme.solarized.productID == StoreCatalog.Theme.solarized) + } - @Test("only Ember forces a color scheme, and it forces dark") - func forcedColorSchemeThemes() { - #expect(Theme.ember.preferredColorScheme == .dark) - let forced: Set = ["ember"] - let others = ThemeRegistry.allThemes.filter { !forced.contains($0.id) } - #expect(others.allSatisfy { $0.preferredColorScheme == nil }) - } + @Test + func `only Ember forces a color scheme, and it forces dark`() { + #expect(Theme.ember.preferredColorScheme == .dark) + let forced: Set = ["ember"] + let others = ThemeRegistry.allThemes.filter { !forced.contains($0.id) } + #expect(others.allSatisfy { $0.preferredColorScheme == nil }) + } - @Test("theme IDs are unique") - func idsAreUnique() { - let ids = ThemeRegistry.allThemes.map(\.id) - #expect(Set(ids).count == ids.count) - } + @Test + func `theme IDs are unique`() { + let ids = ThemeRegistry.allThemes.map(\.id) + #expect(Set(ids).count == ids.count) + } } diff --git a/MC1Tests/ThemeServiceTests.swift b/MC1Tests/ThemeServiceTests.swift index f6549f37..b9704b9d 100644 --- a/MC1Tests/ThemeServiceTests.swift +++ b/MC1Tests/ThemeServiceTests.swift @@ -1,307 +1,305 @@ -import Testing -import SwiftUI +@testable import MC1 +@testable import MC1Services import StoreKit import StoreKitTest -@testable import MC1Services -@testable import MC1 +import SwiftUI +import Testing @Suite("ThemeServiceError") struct ThemeServiceErrorTests { - - @Test("notOwned carries the productID and has a non-empty localized description") - func notOwnedDescription() { - let error = ThemeServiceError.notOwned(productID: StoreCatalog.Theme.ember) - #expect(error.errorDescription?.isEmpty == false) - } + @Test + func `notOwned carries the productID and has a non-empty localized description`() { + let error = ThemeServiceError.notOwned(productID: StoreCatalog.Theme.ember) + #expect(error.errorDescription?.isEmpty == false) + } } @MainActor @Suite("ThemeService pure") struct ThemeServicePureTests { - - /// A StoreService with no purchases and its listener detached, for ownership-independent tests. - private func emptyStore() -> StoreService { - let store = StoreService() - store.shutdown() - return store - } - - private func freshDefaults() -> UserDefaults { - UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } - - @Test("missing selectedThemeID uses default in memory and does NOT write back") - func missingThemeIDNoWriteBack() { - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.current.id == Theme.default.id) - #expect(defaults.object(forKey: PersistenceKeys.selectedThemeID) == nil) - } - - @Test("unknown selectedThemeID falls back to default and overwrites the stored value") - func unknownThemeIDOverwrites() { - let defaults = freshDefaults() - defaults.set("ghost-theme", forKey: PersistenceKeys.selectedThemeID) - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.current.id == Theme.default.id) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) - } - - @Test("cold start retains a persisted paid theme even with empty ownership") - func coldStartRetainsPaidTheme() { - let defaults = freshDefaults() - defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.current.id == Theme.ember.id) - // Valid registry ID — not overwritten on init. - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) - } - - @Test("with no purchases, only the default theme is available and paid themes throw on setCurrent") - func emptyOwnershipAccessRule() { - let service = ThemeService(store: emptyStore(), defaults: freshDefaults()) - #expect(service.availableToCurrentUser().map(\.id) == [Theme.default.id]) - #expect(throws: ThemeServiceError.self) { - try service.setCurrent(.ember) - } - } - - @Test("missing appColorSchemePreference uses .system without writing back") - func missingColorSchemeNoWriteBack() { - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.colorSchemePreference == .system) - #expect(defaults.object(forKey: PersistenceKeys.appColorSchemePreference) == nil) - } - - @Test("unknown appColorSchemePreference falls back to .system and overwrites") - func unknownColorSchemeOverwrites() { - let defaults = freshDefaults() - defaults.set("auto", forKey: PersistenceKeys.appColorSchemePreference) - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.colorSchemePreference == .system) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) - == AppColorSchemePreference.system.rawValue) - } - - @Test("setColorSchemePreference persists the raw value and updates state") - func setColorSchemePersists() { - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - service.setColorSchemePreference(.dark) - #expect(service.colorSchemePreference == .dark) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "dark") - } - - @Test("effectiveColorScheme: default theme defers to the preference") - func effectiveColorSchemeDefersForDefault() { - let service = ThemeService(store: emptyStore(), defaults: freshDefaults()) - service.setColorSchemePreference(.system) - #expect(service.effectiveColorScheme == nil) - service.setColorSchemePreference(.light) - #expect(service.effectiveColorScheme == .light) - service.setColorSchemePreference(.dark) - #expect(service.effectiveColorScheme == .dark) - } - - @Test("effectiveColorScheme: Ember forces .dark regardless of the preference") - func effectiveColorSchemeEmberForcesDark() { - let defaults = freshDefaults() - // init adopts the persisted theme without enforcing ownership. - defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - let service = ThemeService(store: emptyStore(), defaults: defaults) - service.setColorSchemePreference(.light) - #expect(service.effectiveColorScheme == .dark) - service.setColorSchemePreference(.system) - #expect(service.effectiveColorScheme == .dark) - } - - @Test("refreshFromUserDefaults adopts an externally written scheme and retains the theme while unloaded") - func refreshAdoptsExternalScheme() { - // Scheme adoption has no ownership gate, so it applies regardless of load state. The theme - // is retained (not reverted) because `emptyStore()` is `.idle` — ownership is not yet - // authoritative, so refresh must not destructively revert (mirrors `init`). - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - defaults.set(Theme.fern.id, forKey: PersistenceKeys.selectedThemeID) - defaults.set("dark", forKey: PersistenceKeys.appColorSchemePreference) - service.refreshFromUserDefaults() - #expect(service.current.id == Theme.fern.id) - #expect(service.colorSchemePreference == .dark) - } - - @Test("refreshFromUserDefaults does not revert a persisted theme while the store is unloaded") - func refreshRetainsThemeWhileStoreUnloaded() { - // Regression guard: an owner restoring a backup before the entitlement walk has populated - // ownedThemeIDs (store still `.idle`/`.failed`) must keep their selection. `emptyStore()` is - // `.idle`, so the ownership revert is deferred to the post-load listener — refresh neither - // changes `current` nor overwrites the persisted value. - let defaults = freshDefaults() - defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.current.id == Theme.ember.id) - - service.refreshFromUserDefaults() - - #expect(service.current.id == Theme.ember.id) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) - } - - @Test("refreshFromUserDefaults overwrites an unknown selectedThemeID with the default") - func refreshFromUserDefaultsOverwritesUnknownThemeID() { - // Seed after init so the unknown-value path is exercised in refresh, not init. This is - // the mid-session backup-import shape: BackupUserDefaults.restore writes the foreign - // value into defaults, then notifyDataRestored calls refreshFromUserDefaults. - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - defaults.set("future-build-theme-id", forKey: PersistenceKeys.selectedThemeID) - - service.refreshFromUserDefaults() - - #expect(service.current.id == Theme.default.id) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) - } - - @Test("refreshFromUserDefaults overwrites an unknown color-scheme preference with .system") - func refreshFromUserDefaultsOverwritesUnknownColorScheme() { - let defaults = freshDefaults() - let service = ThemeService(store: emptyStore(), defaults: defaults) - defaults.set("auto", forKey: PersistenceKeys.appColorSchemePreference) - - service.refreshFromUserDefaults() - - #expect(service.colorSchemePreference == .system) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) - == AppColorSchemePreference.system.rawValue) - } - - @Test("restore-origin downgrade: an unknown backed-up scheme falls back to .system on init") - func restoreUnknownSchemeDowngrades() { - let defaults = freshDefaults() - var prefs = BackupUserDefaults() - prefs.appColorSchemePreference = "auto" // a future raw value this build doesn't know - prefs.restore(to: defaults) // write-if-missing writes "auto" - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.colorSchemePreference == .system) - #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) - == AppColorSchemePreference.system.rawValue) - } - - @Test("restore-origin downgrade: an unknown backed-up theme ID falls back to default on init") - func restoreUnknownThemeDowngrades() { - let defaults = freshDefaults() - var prefs = BackupUserDefaults() - prefs.selectedThemeID = "theme-from-a-future-build" - prefs.restore(to: defaults) - let service = ThemeService(store: emptyStore(), defaults: defaults) - #expect(service.current.id == Theme.default.id) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + /// A StoreService with no purchases and its listener detached, for ownership-independent tests. + private func emptyStore() -> StoreService { + let store = StoreService() + store.shutdown() + return store + } + + private func freshDefaults() -> UserDefaults { + UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + @Test + func `missing selectedThemeID uses default in memory and does NOT write back`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.current.id == Theme.default.id) + #expect(defaults.object(forKey: PersistenceKeys.selectedThemeID) == nil) + } + + @Test + func `unknown selectedThemeID falls back to default and overwrites the stored value`() { + let defaults = freshDefaults() + defaults.set("ghost-theme", forKey: PersistenceKeys.selectedThemeID) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.current.id == Theme.default.id) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + } + + @Test + func `cold start retains a persisted paid theme even with empty ownership`() { + let defaults = freshDefaults() + defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.current.id == Theme.ember.id) + // Valid registry ID — not overwritten on init. + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) + } + + @Test + func `with no purchases, only the default theme is available and paid themes throw on setCurrent`() { + let service = ThemeService(store: emptyStore(), defaults: freshDefaults()) + #expect(service.availableToCurrentUser().map(\.id) == [Theme.default.id]) + #expect(throws: ThemeServiceError.self) { + try service.setCurrent(.ember) } + } + + @Test + func `missing appColorSchemePreference uses .system without writing back`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.colorSchemePreference == .system) + #expect(defaults.object(forKey: PersistenceKeys.appColorSchemePreference) == nil) + } + + @Test + func `unknown appColorSchemePreference falls back to .system and overwrites`() { + let defaults = freshDefaults() + defaults.set("auto", forKey: PersistenceKeys.appColorSchemePreference) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.colorSchemePreference == .system) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) + == AppColorSchemePreference.system.rawValue) + } + + @Test + func `setColorSchemePreference persists the raw value and updates state`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + service.setColorSchemePreference(.dark) + #expect(service.colorSchemePreference == .dark) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "dark") + } + + @Test + func `effectiveColorScheme: default theme defers to the preference`() { + let service = ThemeService(store: emptyStore(), defaults: freshDefaults()) + service.setColorSchemePreference(.system) + #expect(service.effectiveColorScheme == nil) + service.setColorSchemePreference(.light) + #expect(service.effectiveColorScheme == .light) + service.setColorSchemePreference(.dark) + #expect(service.effectiveColorScheme == .dark) + } + + @Test + func `effectiveColorScheme: Ember forces .dark regardless of the preference`() { + let defaults = freshDefaults() + // init adopts the persisted theme without enforcing ownership. + defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + let service = ThemeService(store: emptyStore(), defaults: defaults) + service.setColorSchemePreference(.light) + #expect(service.effectiveColorScheme == .dark) + service.setColorSchemePreference(.system) + #expect(service.effectiveColorScheme == .dark) + } + + @Test + func `refreshFromUserDefaults adopts an externally written scheme and retains the theme while unloaded`() { + // Scheme adoption has no ownership gate, so it applies regardless of load state. The theme + // is retained (not reverted) because `emptyStore()` is `.idle` — ownership is not yet + // authoritative, so refresh must not destructively revert (mirrors `init`). + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + defaults.set(Theme.fern.id, forKey: PersistenceKeys.selectedThemeID) + defaults.set("dark", forKey: PersistenceKeys.appColorSchemePreference) + service.refreshFromUserDefaults() + #expect(service.current.id == Theme.fern.id) + #expect(service.colorSchemePreference == .dark) + } + + @Test + func `refreshFromUserDefaults does not revert a persisted theme while the store is unloaded`() { + // Regression guard: an owner restoring a backup before the entitlement walk has populated + // ownedThemeIDs (store still `.idle`/`.failed`) must keep their selection. `emptyStore()` is + // `.idle`, so the ownership revert is deferred to the post-load listener — refresh neither + // changes `current` nor overwrites the persisted value. + let defaults = freshDefaults() + defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.current.id == Theme.ember.id) + + service.refreshFromUserDefaults() + + #expect(service.current.id == Theme.ember.id) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) + } + + @Test + func `refreshFromUserDefaults overwrites an unknown selectedThemeID with the default`() { + // Seed after init so the unknown-value path is exercised in refresh, not init. This is + // the mid-session backup-import shape: BackupUserDefaults.restore writes the foreign + // value into defaults, then notifyDataRestored calls refreshFromUserDefaults. + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + defaults.set("future-build-theme-id", forKey: PersistenceKeys.selectedThemeID) + + service.refreshFromUserDefaults() + + #expect(service.current.id == Theme.default.id) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + } + + @Test + func `refreshFromUserDefaults overwrites an unknown color-scheme preference with .system`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + defaults.set("auto", forKey: PersistenceKeys.appColorSchemePreference) + + service.refreshFromUserDefaults() + + #expect(service.colorSchemePreference == .system) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) + == AppColorSchemePreference.system.rawValue) + } + + @Test + func `restore-origin downgrade: an unknown backed-up scheme falls back to .system on init`() { + let defaults = freshDefaults() + var prefs = BackupUserDefaults() + prefs.appColorSchemePreference = "auto" // a future raw value this build doesn't know + prefs.restore(to: defaults) // write-if-missing writes "auto" + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.colorSchemePreference == .system) + #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) + == AppColorSchemePreference.system.rawValue) + } + + @Test + func `restore-origin downgrade: an unknown backed-up theme ID falls back to default on init`() { + let defaults = freshDefaults() + var prefs = BackupUserDefaults() + prefs.selectedThemeID = "theme-from-a-future-build" + prefs.restore(to: defaults) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.current.id == Theme.default.id) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + } } @MainActor @Suite("ThemeService ownership", .serialized, .enabled(if: StoreKitTestAvailability.servesProducts)) final class ThemeServiceOwnershipTests { - let session: SKTestSession - - init() throws { - session = try SKTestSession(configurationFileNamed: "MC1") - session.disableDialogs = true - // Reset Ask-to-Buy: a fresh SKTestSession does not clear this flag on storekitd, so a - // prior suite that enabled it would otherwise leak a pending-purchase mode into these tests. - session.askToBuyEnabled = false - session.clearTransactions() - } - - deinit { session.clearTransactions() } - - private func freshDefaults() -> UserDefaults { - UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } - - @Test("a theme owned via the bundle is accessible and selectable") - func ownedThemeAccessible() async throws { - let store = StoreService() - await store.load() - let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: store) - store.shutdown() - - let theme = ThemeService(store: store, defaults: freshDefaults()) - #expect(theme.availableToCurrentUser().contains { $0.id == Theme.ember.id }) - try theme.setCurrent(.ember) - #expect(theme.current.id == Theme.ember.id) - } - - @Test("owning the bundle makes every theme available") - func bundleGrantsAllThemes() async throws { - let store = StoreService() - await store.load() - let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: store) - store.shutdown() - - let theme = ThemeService(store: store, defaults: freshDefaults()) - #expect(Set(theme.availableToCurrentUser().map(\.id)) - == Set(ThemeRegistry.allThemes.map(\.id))) - } - - @Test("refunding the bundle reverts the selected theme to default via the listener") - func refundRevertsSelectedTheme() async throws { - let store = StoreService() // listener stays live for the refund path - await store.load() - let defaults = freshDefaults() - let theme = ThemeService(store: store, defaults: defaults) - - let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) - _ = try await purchaseWithRetry(bundle, on: store) - try theme.setCurrent(.ember) - #expect(theme.current.id == Theme.ember.id) - - let txn = try #require(session.allTransactions().first { - $0.productIdentifier == StoreCatalog.Theme.bundleAll - }) - try session.refundTransaction(identifier: txn.identifier) - - try await waitUntil(timeout: .seconds(5)) { - theme.current.id == Theme.default.id - } - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) - } - - @Test("the load() walk does not wipe a persisted theme on an empty ownership read") - func loadWalkDoesNotWipePersistedTheme() async throws { - // Guards the cold-start reversion hole: the first entitlement walk fires - // onEntitlementsChanged from inside load() while still .loading, and on a cold storekitd - // it can read empty. With the listener live and a paid theme already persisted, that empty - // read must not overwrite the selection. - let store = StoreService() - let defaults = freshDefaults() - defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - let theme = ThemeService(store: store, defaults: defaults) - #expect(theme.current.id == Theme.ember.id) - - await store.load() - store.shutdown() - - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) - } - - @Test("refreshFromUserDefaults reverts an unowned restored theme once the store is loaded") - func refreshRevertsUnownedThemeWhenLoaded() async throws { - // The post-load enforcement path: with a loaded store and no purchases, a restored paid - // theme is genuinely unowned, so refresh reverts it and overwrites the persisted value. - let store = StoreService() - await store.load() - store.shutdown() - let defaults = freshDefaults() - defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) - let theme = ThemeService(store: store, defaults: defaults) - #expect(theme.current.id == Theme.ember.id) - - theme.refreshFromUserDefaults() - - #expect(theme.current.id == Theme.default.id) - #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + let session: SKTestSession + + init() throws { + session = try SKTestSession(configurationFileNamed: "MC1") + session.disableDialogs = true + // Reset Ask-to-Buy: a fresh SKTestSession does not clear this flag on storekitd, so a + // prior suite that enabled it would otherwise leak a pending-purchase mode into these tests. + session.askToBuyEnabled = false + session.clearTransactions() + } + + deinit { session.clearTransactions() } + + private func freshDefaults() -> UserDefaults { + UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } + + @Test + func `a theme owned via the bundle is accessible and selectable`() async throws { + let store = StoreService() + await store.load() + let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: store) + store.shutdown() + + let theme = ThemeService(store: store, defaults: freshDefaults()) + #expect(theme.availableToCurrentUser().contains { $0.id == Theme.ember.id }) + try theme.setCurrent(.ember) + #expect(theme.current.id == Theme.ember.id) + } + + @Test + func `owning the bundle makes every theme available`() async throws { + let store = StoreService() + await store.load() + let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: store) + store.shutdown() + + let theme = ThemeService(store: store, defaults: freshDefaults()) + #expect(Set(theme.availableToCurrentUser().map(\.id)) + == Set(ThemeRegistry.allThemes.map(\.id))) + } + + @Test + func `refunding the bundle reverts the selected theme to default via the listener`() async throws { + let store = StoreService() // listener stays live for the refund path + await store.load() + let defaults = freshDefaults() + let theme = ThemeService(store: store, defaults: defaults) + + let bundle = try #require(store.product(for: StoreCatalog.Theme.bundleAll)) + _ = try await purchaseWithRetry(bundle, on: store) + try theme.setCurrent(.ember) + #expect(theme.current.id == Theme.ember.id) + + let txn = try #require(session.allTransactions().first { + $0.productIdentifier == StoreCatalog.Theme.bundleAll + }) + try session.refundTransaction(identifier: txn.identifier) + + try await waitUntil(timeout: .seconds(5)) { + theme.current.id == Theme.default.id } + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + } + + @Test + func `the load() walk does not wipe a persisted theme on an empty ownership read`() async { + // Guards the cold-start reversion hole: the first entitlement walk fires + // onEntitlementsChanged from inside load() while still .loading, and on a cold storekitd + // it can read empty. With the listener live and a paid theme already persisted, that empty + // read must not overwrite the selection. + let store = StoreService() + let defaults = freshDefaults() + defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + let theme = ThemeService(store: store, defaults: defaults) + #expect(theme.current.id == Theme.ember.id) + + await store.load() + store.shutdown() + + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.ember.id) + } + + @Test + func `refreshFromUserDefaults reverts an unowned restored theme once the store is loaded`() async { + // The post-load enforcement path: with a loaded store and no purchases, a restored paid + // theme is genuinely unowned, so refresh reverts it and overwrites the persisted value. + let store = StoreService() + await store.load() + store.shutdown() + let defaults = freshDefaults() + defaults.set(Theme.ember.id, forKey: PersistenceKeys.selectedThemeID) + let theme = ThemeService(store: store, defaults: defaults) + #expect(theme.current.id == Theme.ember.id) + + theme.refreshFromUserDefaults() + + #expect(theme.current.id == Theme.default.id) + #expect(defaults.string(forKey: PersistenceKeys.selectedThemeID) == Theme.default.id) + } } diff --git a/MC1Tests/ThemedSurfaceRowFillTests.swift b/MC1Tests/ThemedSurfaceRowFillTests.swift index 731c60b1..9ddc6319 100644 --- a/MC1Tests/ThemedSurfaceRowFillTests.swift +++ b/MC1Tests/ThemedSurfaceRowFillTests.swift @@ -1,6 +1,6 @@ -import Testing -import SwiftUI @testable import MC1 +import SwiftUI +import Testing /// Guards the row fill that emits no background in the iPad Settings sidebar (`flatten: true`), so /// sidebar rows stay transparent and keep the native `.sidebar` selection highlight, while @@ -8,29 +8,28 @@ import SwiftUI /// This is logic-only coverage; the rendered result is verified visually on device/simulator. @Suite("Themed surface row fill") struct ThemedSurfaceRowFillTests { + @Test + func `card theme paints rows with the card tier in a normal (non-flattened) context`() throws { + let surfaces = try #require(Theme.marine.surfaces) + #expect(surfaces.rowFill(flatten: false) == surfaces.card) + } - @Test("card theme paints rows with the card tier in a normal (non-flattened) context") - func cardThemeUsesCardByDefault() { - let surfaces = Theme.marine.surfaces! - #expect(surfaces.rowFill(flatten: false) == surfaces.card) - } - - @Test("card theme emits no row fill when flattened, so the iPad Settings sidebar keeps native selection") - func cardThemeFlattenedHasNoFill() { - let surfaces = Theme.marine.surfaces! - #expect(surfaces.rowFill(flatten: true) == nil) - } + @Test + func `card theme emits no row fill when flattened, so the iPad Settings sidebar keeps native selection`() throws { + let surfaces = try #require(Theme.marine.surfaces) + #expect(surfaces.rowFill(flatten: true) == nil) + } - @Test("canvas-only theme (Ember) leaves rows on the system tier regardless of flattening") - func cardlessSurfaceIsAlwaysNil() { - let surfaces = Theme.ember.surfaces! - #expect(surfaces.card == nil) - #expect(surfaces.rowFill(flatten: false) == nil) - #expect(surfaces.rowFill(flatten: true) == nil) - } + @Test + func `canvas-only theme (Ember) leaves rows on the system tier regardless of flattening`() throws { + let surfaces = try #require(Theme.ember.surfaces) + #expect(surfaces.card == nil) + #expect(surfaces.rowFill(flatten: false) == nil) + #expect(surfaces.rowFill(flatten: true) == nil) + } - @Test("default theme has no surfaces, so themed row backgrounds are a no-op") - func defaultThemeHasNoSurfaces() { - #expect(Theme.default.surfaces == nil) - } + @Test + func `default theme has no surfaces, so themed row backgrounds are a no-op`() { + #expect(Theme.default.surfaces == nil) + } } diff --git a/MC1Tests/Utilities/ChatCoordinateDetectorTests.swift b/MC1Tests/Utilities/ChatCoordinateDetectorTests.swift index 86602560..1a82119e 100644 --- a/MC1Tests/Utilities/ChatCoordinateDetectorTests.swift +++ b/MC1Tests/Utilities/ChatCoordinateDetectorTests.swift @@ -1,63 +1,62 @@ -import Testing import CoreLocation @testable import MC1 +import Testing @Suite("ChatCoordinateDetector Tests") struct ChatCoordinateDetectorTests { - - @Test("A valid decimal pair is detected") - func validPairDetected() { - let coord = ChatCoordinateDetector.firstCoordinate(in: "Meet at 37.334900, -122.009020 tonight") - #expect(coord != nil) - #expect(abs((coord?.latitude ?? 0) - 37.3349) < 0.000001) - #expect(abs((coord?.longitude ?? 0) - (-122.00902)) < 0.000001) - } - - @Test("An integer pair is not detected") - func integerPairRejected() { - #expect(ChatCoordinateDetector.firstCoordinate(in: "ratio is 3, 4 today") == nil) - } - - @Test("An out-of-range pair is rejected by the clamp, not the regex") - func outOfRangeRejected() { - // `200.0, 400.0` passes the \d{1,3} regex; only the -90...90 / -180...180 - // clamp rejects it. This is the sole validity gate for the thumbnail path. - #expect(ChatCoordinateDetector.firstCoordinate(in: "bad 200.0, 400.0 coord") == nil) - } - - @Test("A three-number decimal list is treated as a list, not a coordinate") - func decimalListRejected() { - #expect(ChatCoordinateDetector.firstCoordinate(in: "values 1.0, 2.0, 3.0 here") == nil) - } - - @Test("A version-like string is not detected") - func versionLikeRejected() { - #expect(ChatCoordinateDetector.firstCoordinate(in: "v1.2, 3.4 release") == nil) - } - - @Test("firstCoordinate returns nil when there is no coordinate") - func noneReturnsNil() { - #expect(ChatCoordinateDetector.firstCoordinate(in: "no coordinates here") == nil) - } - - @Test("firstCoordinate returns the first of several coordinates") - func firstOfMany() { - let coord = ChatCoordinateDetector.firstCoordinate(in: "A 10.0, 20.0 and B 30.0, 40.0") - #expect(coord?.latitude == 10.0) - #expect(coord?.longitude == 20.0) - } - - @Test("matches returns every valid coordinate in document order") - func matchesInOrder() { - let matches = ChatCoordinateDetector.matches(in: "A 10.0, 20.0 and B 30.0, 40.0") - #expect(matches.count == 2) - #expect(matches.first?.coordinate.latitude == 10.0) - #expect(matches.last?.coordinate.latitude == 30.0) - } - - @Test("A coordinate ending a sentence is detected, period excluded") - func trailingPeriod() { - let coord = ChatCoordinateDetector.firstCoordinate(in: "Meet at 37.7749, -122.4194.") - #expect(coord?.latitude == 37.7749) - } + @Test + func `A valid decimal pair is detected`() { + let coord = ChatCoordinateDetector.firstCoordinate(in: "Meet at 37.334900, -122.009020 tonight") + #expect(coord != nil) + #expect(abs((coord?.latitude ?? 0) - 37.3349) < 0.000001) + #expect(abs((coord?.longitude ?? 0) - -122.00902) < 0.000001) + } + + @Test + func `An integer pair is not detected`() { + #expect(ChatCoordinateDetector.firstCoordinate(in: "ratio is 3, 4 today") == nil) + } + + @Test + func `An out-of-range pair is rejected by the clamp, not the regex`() { + // `200.0, 400.0` passes the \d{1,3} regex; only the -90...90 / -180...180 + // clamp rejects it. This is the sole validity gate for the thumbnail path. + #expect(ChatCoordinateDetector.firstCoordinate(in: "bad 200.0, 400.0 coord") == nil) + } + + @Test + func `A three-number decimal list is treated as a list, not a coordinate`() { + #expect(ChatCoordinateDetector.firstCoordinate(in: "values 1.0, 2.0, 3.0 here") == nil) + } + + @Test + func `A version-like string is not detected`() { + #expect(ChatCoordinateDetector.firstCoordinate(in: "v1.2, 3.4 release") == nil) + } + + @Test + func `firstCoordinate returns nil when there is no coordinate`() { + #expect(ChatCoordinateDetector.firstCoordinate(in: "no coordinates here") == nil) + } + + @Test + func `firstCoordinate returns the first of several coordinates`() { + let coord = ChatCoordinateDetector.firstCoordinate(in: "A 10.0, 20.0 and B 30.0, 40.0") + #expect(coord?.latitude == 10.0) + #expect(coord?.longitude == 20.0) + } + + @Test + func `matches returns every valid coordinate in document order`() { + let matches = ChatCoordinateDetector.matches(in: "A 10.0, 20.0 and B 30.0, 40.0") + #expect(matches.count == 2) + #expect(matches.first?.coordinate.latitude == 10.0) + #expect(matches.last?.coordinate.latitude == 30.0) + } + + @Test + func `A coordinate ending a sentence is detected, period excluded`() { + let coord = ChatCoordinateDetector.firstCoordinate(in: "Meet at 37.7749, -122.4194.") + #expect(coord?.latitude == 37.7749) + } } diff --git a/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift b/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift index 2eedf6be..9b1226ab 100644 --- a/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift +++ b/MC1Tests/Utilities/ChatScrollToMentionPolicyTests.swift @@ -1,61 +1,60 @@ -import Testing import Foundation @testable import MC1 +import Testing @Suite("ChatScrollToMentionPolicy Tests") struct ChatScrollToMentionPolicyTests { - - @Test("shouldScrollToBottom returns false when mentionTargetID is nil") - func mentionTargetIDNilReturnsFalse() { - let newestID = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: nil, newestItemID: newestID) == false) - } - - @Test("shouldScrollToBottom returns false when newestItemID is nil") - func newestItemIDNilReturnsFalse() { - let mentionID = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: mentionID, newestItemID: nil) == false) - } - - @Test("shouldScrollToBottom returns false when IDs differ") - func differentIDsReturnFalse() { - let mentionID = UUID() - let newestID = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: mentionID, newestItemID: newestID) == false) - } - - @Test("shouldScrollToBottom returns true when IDs match") - func matchingIDsReturnTrue() { - let id = UUID() - #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: id, newestItemID: id) == true) - } - - @Test("nextTarget returns nil when there are no off-screen mentions") - func nextTargetEmptyReturnsNil() { - #expect(ChatScrollToMentionPolicy.nextTarget(offscreenMentions: []) == nil) - } - - @Test("nextTarget picks the newest (last) of an oldest-to-newest list") - func nextTargetPicksNewest() { - let oldest = UUID() - let middle = UUID() - let newest = UUID() - #expect(ChatScrollToMentionPolicy.nextTarget(offscreenMentions: [oldest, middle, newest]) == newest) + @Test + func `shouldScrollToBottom returns false when mentionTargetID is nil`() { + let newestID = UUID() + #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: nil, newestItemID: newestID) == false) + } + + @Test + func `shouldScrollToBottom returns false when newestItemID is nil`() { + let mentionID = UUID() + #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: mentionID, newestItemID: nil) == false) + } + + @Test + func `shouldScrollToBottom returns false when IDs differ`() { + let mentionID = UUID() + let newestID = UUID() + #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: mentionID, newestItemID: newestID) == false) + } + + @Test + func `shouldScrollToBottom returns true when IDs match`() { + let id = UUID() + #expect(ChatScrollToMentionPolicy.shouldScrollToBottom(mentionTargetID: id, newestItemID: id) == true) + } + + @Test + func `nextTarget returns nil when there are no off-screen mentions`() { + #expect(ChatScrollToMentionPolicy.nextTarget(offscreenMentions: []) == nil) + } + + @Test + func `nextTarget picks the newest (last) of an oldest-to-newest list`() { + let oldest = UUID() + let middle = UUID() + let newest = UUID() + #expect(ChatScrollToMentionPolicy.nextTarget(offscreenMentions: [oldest, middle, newest]) == newest) + } + + @Test + func `nextTarget walks upward to the earliest as newer targets are consumed`() { + let oldest = UUID() + let middle = UUID() + let newest = UUID() + var remaining = [oldest, middle, newest] + + var visited: [UUID] = [] + while let target = ChatScrollToMentionPolicy.nextTarget(offscreenMentions: remaining) { + visited.append(target) + remaining.removeAll { $0 == target } } - @Test("nextTarget walks upward to the earliest as newer targets are consumed") - func nextTargetWalksUpward() { - let oldest = UUID() - let middle = UUID() - let newest = UUID() - var remaining = [oldest, middle, newest] - - var visited: [UUID] = [] - while let target = ChatScrollToMentionPolicy.nextTarget(offscreenMentions: remaining) { - visited.append(target) - remaining.removeAll { $0 == target } - } - - #expect(visited == [newest, middle, oldest]) - } + #expect(visited == [newest, middle, oldest]) + } } diff --git a/MC1Tests/Utilities/DemoModeManagerTests.swift b/MC1Tests/Utilities/DemoModeManagerTests.swift index a1e26746..f4d10dcb 100644 --- a/MC1Tests/Utilities/DemoModeManagerTests.swift +++ b/MC1Tests/Utilities/DemoModeManagerTests.swift @@ -1,92 +1,91 @@ -import Testing import Foundation @testable import MC1 +import Testing @Suite("DemoModeManager Tests") @MainActor struct DemoModeManagerTests { + private let defaults: UserDefaults - private let defaults: UserDefaults - - init() { - defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! - } + init() { + defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! + } - // MARK: - Singleton Pattern Tests + // MARK: - Singleton Pattern Tests - @Test("shared returns the same instance", .serialized) - func testSingletonPattern() { - let instance1 = DemoModeManager.shared - let instance2 = DemoModeManager.shared - #expect(instance1 === instance2) - } + @Test(.serialized) + func `shared returns the same instance`() { + let instance1 = DemoModeManager.shared + let instance2 = DemoModeManager.shared + #expect(instance1 === instance2) + } - // MARK: - Default Values Tests + // MARK: - Default Values Tests - @Test("properties default to false for new instances") - func testDefaultValues() { - let manager = DemoModeManager(defaults: defaults) - #expect(manager.isUnlocked == false) - #expect(manager.isEnabled == false) - } + @Test + func `properties default to false for new instances`() { + let manager = DemoModeManager(defaults: defaults) + #expect(manager.isUnlocked == false) + #expect(manager.isEnabled == false) + } - // MARK: - unlock() Method Tests + // MARK: - unlock() Method Tests - @Test("unlock sets both isUnlocked and isEnabled to true") - func testUnlockSetsBothFlags() { - let manager = DemoModeManager(defaults: defaults) + @Test + func `unlock sets both isUnlocked and isEnabled to true`() { + let manager = DemoModeManager(defaults: defaults) - #expect(manager.isUnlocked == false) - #expect(manager.isEnabled == false) + #expect(manager.isUnlocked == false) + #expect(manager.isEnabled == false) - manager.unlock() + manager.unlock() - #expect(manager.isUnlocked == true) - #expect(manager.isEnabled == true) - } + #expect(manager.isUnlocked == true) + #expect(manager.isEnabled == true) + } - // MARK: - UserDefaults Persistence Tests + // MARK: - UserDefaults Persistence Tests - @Test("UserDefaults persistence works for isUnlocked") - func testUserDefaultsPersistenceForIsUnlocked() { - let manager = DemoModeManager(defaults: defaults) + @Test + func `UserDefaults persistence works for isUnlocked`() { + let manager = DemoModeManager(defaults: defaults) - manager.isUnlocked = true + manager.isUnlocked = true - let persistedValue = defaults.bool(forKey: "isDemoModeUnlocked") - #expect(persistedValue == true) - } + let persistedValue = defaults.bool(forKey: "isDemoModeUnlocked") + #expect(persistedValue == true) + } - @Test("UserDefaults persistence works for isEnabled") - func testUserDefaultsPersistenceForIsEnabled() { - let manager = DemoModeManager(defaults: defaults) + @Test + func `UserDefaults persistence works for isEnabled`() { + let manager = DemoModeManager(defaults: defaults) - manager.isEnabled = true + manager.isEnabled = true - let persistedValue = defaults.bool(forKey: "isDemoModeEnabled") - #expect(persistedValue == true) - } + let persistedValue = defaults.bool(forKey: "isDemoModeEnabled") + #expect(persistedValue == true) + } - @Test("unlock persists both values to UserDefaults") - func testUnlockPersistsToUserDefaults() { - let manager = DemoModeManager(defaults: defaults) + @Test + func `unlock persists both values to UserDefaults`() { + let manager = DemoModeManager(defaults: defaults) - manager.unlock() + manager.unlock() - let unlockedValue = defaults.bool(forKey: "isDemoModeUnlocked") - let enabledValue = defaults.bool(forKey: "isDemoModeEnabled") + let unlockedValue = defaults.bool(forKey: "isDemoModeUnlocked") + let enabledValue = defaults.bool(forKey: "isDemoModeEnabled") - #expect(unlockedValue == true) - #expect(enabledValue == true) - } + #expect(unlockedValue == true) + #expect(enabledValue == true) + } - @Test("values persist and can be read back") - func testPersistenceReadBack() { - defaults.set(true, forKey: "isDemoModeUnlocked") - defaults.set(true, forKey: "isDemoModeEnabled") + @Test + func `values persist and can be read back`() { + defaults.set(true, forKey: "isDemoModeUnlocked") + defaults.set(true, forKey: "isDemoModeEnabled") - let manager = DemoModeManager(defaults: defaults) - #expect(manager.isUnlocked == true) - #expect(manager.isEnabled == true) - } + let manager = DemoModeManager(defaults: defaults) + #expect(manager.isUnlocked == true) + #expect(manager.isEnabled == true) + } } diff --git a/MC1Tests/Utilities/FirmwareSuggestedTimeoutTests.swift b/MC1Tests/Utilities/FirmwareSuggestedTimeoutTests.swift index 1cd60eab..a6099efd 100644 --- a/MC1Tests/Utilities/FirmwareSuggestedTimeoutTests.swift +++ b/MC1Tests/Utilities/FirmwareSuggestedTimeoutTests.swift @@ -1,29 +1,29 @@ -import Testing @testable import MC1 +import Testing @Suite("FirmwareSuggestedTimeout Sanitizing") struct FirmwareSuggestedTimeoutTests { - @Test("Accepts sane firmware timeout") - func acceptsSaneFirmwareTimeout() { - let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 5_000) - #expect(timeout == 6.0) - } + @Test + func `Accepts sane firmware timeout`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 5000) + #expect(timeout == 6.0) + } - @Test("Falls back on zero timeout") - func fallsBackOnZeroTimeout() { - let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 0) - #expect(timeout == 30.0) - } + @Test + func `Falls back on zero timeout`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 0) + #expect(timeout == 30.0) + } - @Test("Falls back below minimum timeout") - func fallsBackBelowMinimumTimeout() { - let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 3_000) - #expect(timeout == 30.0) - } + @Test + func `Falls back below minimum timeout`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 3000) + #expect(timeout == 30.0) + } - @Test("Falls back for absurdly large timeout") - func fallsBackForAbsurdlyLargeTimeout() { - let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 68_719_800) - #expect(timeout == 30.0) - } + @Test + func `Falls back for absurdly large timeout`() { + let timeout = FirmwareSuggestedTimeout.sanitizedSeconds(suggestedTimeoutMs: 68_719_800) + #expect(timeout == 30.0) + } } diff --git a/MC1Tests/Utilities/MalwareDomainFilterTests.swift b/MC1Tests/Utilities/MalwareDomainFilterTests.swift index 51fd542e..9d9d45ff 100644 --- a/MC1Tests/Utilities/MalwareDomainFilterTests.swift +++ b/MC1Tests/Utilities/MalwareDomainFilterTests.swift @@ -1,71 +1,70 @@ -import Testing import Foundation @testable import MC1 +import Testing struct MalwareDomainFilterTests { + // MARK: - Basic Filtering - // MARK: - Basic Filtering - - @Test("Blocks domain from bundled list") - func blocksBundledDomain() async { - // Read a domain from the bundled list to test against. - // The test host is the app, so Bundle.main contains app resources. - guard let bundleURL = Bundle.main.url( - forResource: "urlhaus-filter-hosts-online", - withExtension: "txt" - ), - let text = try? String(contentsOf: bundleURL, encoding: .utf8) else { - Issue.record("Bundled malware domain list not found") - return - } - - // Find first non-comment, non-empty domain - var testDomain: String? - for line in text.split(separator: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } - let parts = trimmed.split(separator: " ", maxSplits: 1) - if parts.count == 2 { - testDomain = String(parts[1]) - break - } - } + @Test + func `Blocks domain from bundled list`() async { + // Read a domain from the bundled list to test against. + // The test host is the app, so Bundle.main contains app resources. + guard let bundleURL = Bundle.main.url( + forResource: "urlhaus-filter-hosts-online", + withExtension: "txt" + ), + let text = try? String(contentsOf: bundleURL, encoding: .utf8) else { + Issue.record("Bundled malware domain list not found") + return + } - guard let domain = testDomain else { - Issue.record("No valid domain found in bundled list") - return - } + // Find first non-comment, non-empty domain + var testDomain: String? + for line in text.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + let parts = trimmed.split(separator: " ", maxSplits: 1) + if parts.count == 2 { + testDomain = String(parts[1]) + break + } + } - let result = await MalwareDomainFilter.shared.isBlocked(domain) - #expect(result, "Domain '\(domain)' from bundled list should be blocked") + guard let domain = testDomain else { + Issue.record("No valid domain found in bundled list") + return } - @Test("Allows legitimate domains") - func allowsLegitimateDomains() async { - let safeDomains = [ - "apple.com", - "google.com", - "github.com", - "example.com", - "media.giphy.com" - ] + let result = await MalwareDomainFilter.shared.isBlocked(domain) + #expect(result, "Domain '\(domain)' from bundled list should be blocked") + } - for domain in safeDomains { - let result = await MalwareDomainFilter.shared.isBlocked(domain) - #expect(!result, "'\(domain)' should not be blocked") - } - } + @Test + func `Allows legitimate domains`() async { + let safeDomains = [ + "apple.com", + "google.com", + "github.com", + "example.com", + "media.giphy.com" + ] - @Test("Exact match only, no substring matching") - func exactMatchOnly() async { - // "evil.com" should not block "not-evil.com" - let result = await MalwareDomainFilter.shared.isBlocked("definitely-not-malware-\(UUID().uuidString).example.com") - #expect(!result, "Random domain should not be blocked") + for domain in safeDomains { + let result = await MalwareDomainFilter.shared.isBlocked(domain) + #expect(!result, "'\(domain)' should not be blocked") } + } - @Test("Empty host is not blocked") - func emptyHostNotBlocked() async { - let result = await MalwareDomainFilter.shared.isBlocked("") - #expect(!result, "Empty string should not be blocked") - } + @Test + func `Exact match only, no substring matching`() async { + // "evil.com" should not block "not-evil.com" + let result = await MalwareDomainFilter.shared.isBlocked("definitely-not-malware-\(UUID().uuidString).example.com") + #expect(!result, "Random domain should not be blocked") + } + + @Test + func `Empty host is not blocked`() async { + let result = await MalwareDomainFilter.shared.isBlocked("") + #expect(!result, "Empty string should not be blocked") + } } diff --git a/MC1Tests/Utilities/MentionInsertionTests.swift b/MC1Tests/Utilities/MentionInsertionTests.swift index b6f005e9..120ec0b9 100644 --- a/MC1Tests/Utilities/MentionInsertionTests.swift +++ b/MC1Tests/Utilities/MentionInsertionTests.swift @@ -1,66 +1,65 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("Mention Insertion Tests") struct MentionInsertionTests { + @Test + func `insertMention replaces @query with mention format`() throws { + var text = "hey @ali" + let query = try #require(MentionUtilities.detectActiveMention(in: text)) + let searchPattern = "@" + query - @Test("insertMention replaces @query with mention format") - func testInsertMention() { - var text = "hey @ali" - let query = MentionUtilities.detectActiveMention(in: text)! - let searchPattern = "@" + query - - if let range = text.range(of: searchPattern, options: .backwards) { - let mention = MentionUtilities.createMention(for: "Alice") - text.replaceSubrange(range, with: mention + " ") - } - - #expect(text == "hey @[Alice] ") + if let range = text.range(of: searchPattern, options: .backwards) { + let mention = MentionUtilities.createMention(for: "Alice") + text.replaceSubrange(range, with: mention + " ") } - @Test("insertMention handles query at start of text") - func testInsertMentionAtStart() { - var text = "@bob" - let query = MentionUtilities.detectActiveMention(in: text)! - let searchPattern = "@" + query + #expect(text == "hey @[Alice] ") + } - if let range = text.range(of: searchPattern, options: .backwards) { - let mention = MentionUtilities.createMention(for: "Bob") - text.replaceSubrange(range, with: mention + " ") - } + @Test + func `insertMention handles query at start of text`() throws { + var text = "@bob" + let query = try #require(MentionUtilities.detectActiveMention(in: text)) + let searchPattern = "@" + query - #expect(text == "@[Bob] ") + if let range = text.range(of: searchPattern, options: .backwards) { + let mention = MentionUtilities.createMention(for: "Bob") + text.replaceSubrange(range, with: mention + " ") } - @Test("insertMention preserves preceding text") - func testInsertMentionPreservesText() { - var text = "Hello @[Alice] and @jo" - let query = MentionUtilities.detectActiveMention(in: text)! - let searchPattern = "@" + query + #expect(text == "@[Bob] ") + } - if let range = text.range(of: searchPattern, options: .backwards) { - let mention = MentionUtilities.createMention(for: "John") - text.replaceSubrange(range, with: mention + " ") - } + @Test + func `insertMention preserves preceding text`() throws { + var text = "Hello @[Alice] and @jo" + let query = try #require(MentionUtilities.detectActiveMention(in: text)) + let searchPattern = "@" + query - #expect(text == "Hello @[Alice] and @[John] ") + if let range = text.range(of: searchPattern, options: .backwards) { + let mention = MentionUtilities.createMention(for: "John") + text.replaceSubrange(range, with: mention + " ") } - @Test("insertMention uses contact name not nickname") - func testInsertMentionUsesNodeName() { - // Simulates: user searches "Bob" (nickname), selects contact with name "Bob's Solar Node" - var text = "@bob" - let contactNodeName = "Bob's Solar Node" + #expect(text == "Hello @[Alice] and @[John] ") + } - let query = MentionUtilities.detectActiveMention(in: text)! - let searchPattern = "@" + query + @Test + func `insertMention uses contact name not nickname`() throws { + // Simulates: user searches "Bob" (nickname), selects contact with name "Bob's Solar Node" + var text = "@bob" + let contactNodeName = "Bob's Solar Node" - if let range = text.range(of: searchPattern, options: .backwards) { - let mention = MentionUtilities.createMention(for: contactNodeName) - text.replaceSubrange(range, with: mention + " ") - } + let query = try #require(MentionUtilities.detectActiveMention(in: text)) + let searchPattern = "@" + query - #expect(text == "@[Bob's Solar Node] ") + if let range = text.range(of: searchPattern, options: .backwards) { + let mention = MentionUtilities.createMention(for: contactNodeName) + text.replaceSubrange(range, with: mention + " ") } + + #expect(text == "@[Bob's Solar Node] ") + } } diff --git a/MC1Tests/Utilities/MentionUtilitiesTests.swift b/MC1Tests/Utilities/MentionUtilitiesTests.swift index 8acdae3a..92c64b93 100644 --- a/MC1Tests/Utilities/MentionUtilitiesTests.swift +++ b/MC1Tests/Utilities/MentionUtilitiesTests.swift @@ -1,391 +1,390 @@ -import Testing import Foundation @testable import MC1Services +import Testing @Suite("MentionUtilities Tests") struct MentionUtilitiesTests { - - // MARK: - createMention Tests - - @Test("createMention creates correct format") - func testCreateMention() { - let mention = MentionUtilities.createMention(for: "Alice") - #expect(mention == "@[Alice]") - } - - @Test("createMention handles names with spaces") - func testCreateMentionWithSpaces() { - let mention = MentionUtilities.createMention(for: "My Node") - #expect(mention == "@[My Node]") - } - - @Test("createMention handles special characters") - func testCreateMentionWithSpecialChars() { - let mention = MentionUtilities.createMention(for: "Node-123") - #expect(mention == "@[Node-123]") - } - - // MARK: - appendMention Tests - - @Test("appendMention into empty draft yields just the mention") - func testAppendMentionEmptyDraft() { - let result = MentionUtilities.appendMention(for: "Alice", to: "") - #expect(result == "@[Alice] ") - } - - @Test("appendMention preserves draft and adds a separating space") - func testAppendMentionPreservesDraftWithoutTrailingSpace() { - let result = MentionUtilities.appendMention(for: "Alice", to: "hello") - #expect(result == "hello @[Alice] ") - } - - @Test("appendMention does not double the space when draft ends in whitespace") - func testAppendMentionDraftWithTrailingSpace() { - let result = MentionUtilities.appendMention(for: "Alice", to: "hello ") - #expect(result == "hello @[Alice] ") - } - - @Test("createMention handles empty name") - func testCreateMentionEmpty() { - let mention = MentionUtilities.createMention(for: "") - #expect(mention == "@[]") - } - - // MARK: - extractMentions Tests - - @Test("extractMentions parses single mention") - func testExtractSingleMention() { - let mentions = MentionUtilities.extractMentions(from: "@[Alice] hello!") - #expect(mentions == ["Alice"]) - } - - @Test("extractMentions parses multiple mentions") - func testExtractMultipleMentions() { - let mentions = MentionUtilities.extractMentions(from: "@[Alice] and @[Bob] hello!") - #expect(mentions == ["Alice", "Bob"]) - } - - @Test("extractMentions returns empty for no mentions") - func testExtractNoMentions() { - let mentions = MentionUtilities.extractMentions(from: "Hello world!") - #expect(mentions.isEmpty) - } - - @Test("extractMentions handles names with spaces") - func testExtractMentionWithSpaces() { - let mentions = MentionUtilities.extractMentions(from: "@[My Node] says hi") - #expect(mentions == ["My Node"]) - } - - @Test("extractMentions handles special characters") - func testExtractMentionWithSpecialChars() { - let mentions = MentionUtilities.extractMentions(from: "@[Node-123] testing") - #expect(mentions == ["Node-123"]) - } - - @Test("extractMentions handles adjacent mentions") - func testExtractAdjacentMentions() { - let mentions = MentionUtilities.extractMentions(from: "@[Alice]@[Bob]") - #expect(mentions == ["Alice", "Bob"]) - } - - @Test("extractMentions ignores malformed patterns") - func testExtractMalformedPatterns() { - // Missing closing bracket - let mentions1 = MentionUtilities.extractMentions(from: "@[Alice hello") - #expect(mentions1.isEmpty) - - // Missing opening bracket - let mentions2 = MentionUtilities.extractMentions(from: "@Alice] hello") - #expect(mentions2.isEmpty) - - // Just @ symbol - let mentions3 = MentionUtilities.extractMentions(from: "@ hello") - #expect(mentions3.isEmpty) - } - - @Test("extractMentions handles empty message") - func testExtractFromEmptyMessage() { - let mentions = MentionUtilities.extractMentions(from: "") - #expect(mentions.isEmpty) - } - - @Test("extractMentions handles Unicode names") - func testExtractUnicodeMentions() { - let mentions = MentionUtilities.extractMentions(from: "@[日本語] hello") - #expect(mentions == ["日本語"]) - } - - // MARK: - detectActiveMention Tests - - @Test("detectActiveMention returns nil for empty text") - func testDetectActiveMentionEmpty() { - let result = MentionUtilities.detectActiveMention(in: "") - #expect(result == nil) - } - - @Test("detectActiveMention returns nil for text without @") - func testDetectActiveMentionNoAt() { - let result = MentionUtilities.detectActiveMention(in: "hello world") - #expect(result == nil) - } - - @Test("detectActiveMention returns empty string for @ alone") - func testDetectActiveMentionAtAlone() { - let result = MentionUtilities.detectActiveMention(in: "@") - #expect(result == "") - } - - @Test("detectActiveMention returns query after @") - func testDetectActiveMentionBasic() { - let result = MentionUtilities.detectActiveMention(in: "@jo") - #expect(result == "jo") - } - - @Test("detectActiveMention works at start of message") - func testDetectActiveMentionAtStart() { - let result = MentionUtilities.detectActiveMention(in: "@alice") - #expect(result == "alice") - } - - @Test("detectActiveMention works after space") - func testDetectActiveMentionAfterSpace() { - let result = MentionUtilities.detectActiveMention(in: "hey @bob") - #expect(result == "bob") - } - - @Test("detectActiveMention returns nil for @ mid-word") - func testDetectActiveMentionMidWord() { - let result = MentionUtilities.detectActiveMention(in: "email@domain") - #expect(result == nil) - } - - @Test("detectActiveMention returns nil when space follows @") - func testDetectActiveMentionSpaceAfter() { - let result = MentionUtilities.detectActiveMention(in: "@ hello") - #expect(result == nil) - } - - @Test("detectActiveMention returns last active mention") - func testDetectActiveMentionMultiple() { - let result = MentionUtilities.detectActiveMention(in: "@[Alice] hey @bo") - #expect(result == "bo") - } - - @Test("detectActiveMention returns nil for completed mention") - func testDetectActiveMentionCompleted() { - let result = MentionUtilities.detectActiveMention(in: "@[Alice] hello") - #expect(result == nil) - } - - @Test("detectActiveMention handles Unicode") - func testDetectActiveMentionUnicode() { - let result = MentionUtilities.detectActiveMention(in: "@日本") - #expect(result == "日本") - } - - @Test("detectActiveMention ignores email addresses") - func testDetectActiveMentionEmail() { - let result = MentionUtilities.detectActiveMention(in: "contact me at test@example.com") - #expect(result == nil) - } - - @Test("detectActiveMention handles double @ symbols") - func testDetectActiveMentionDoubleAt() { - let result = MentionUtilities.detectActiveMention(in: "@@alice") - #expect(result == nil) - } - - @Test("detectActiveMention returns nil for unclosed bracket") - func testDetectActiveMentionUnclosedBracket() { - let result = MentionUtilities.detectActiveMention(in: "@[Alice") - #expect(result == nil) - } - - // MARK: - filterContacts Tests - - private func makeContact( - name: String, - type: ContactType = .chat, - publicKey: Data = Data([0xAB]) - ) -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: publicKey, - name: name, - typeRawValue: type.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - } - - @Test("filterContacts matches using localizedStandardContains") - func testFilterContactsMatches() { - let contacts = [ - makeContact(name: "Alice"), - makeContact(name: "Bob"), - makeContact(name: "Amanda") - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "a") - #expect(filtered.count == 2) - #expect(filtered.map(\.name).contains("Alice")) - #expect(filtered.map(\.name).contains("Amanda")) - } - - @Test("filterContacts excludes repeaters") - func testFilterContactsExcludesRepeaters() { - let contacts = [ - makeContact(name: "Alice", type: .chat), - makeContact(name: "Repeater1", type: .repeater) - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "") - #expect(filtered.count == 1) - #expect(filtered.first?.name == "Alice") - } - - @Test("filterContacts excludes rooms") - func testFilterContactsExcludesRooms() { - let contacts = [ - makeContact(name: "Alice", type: .chat), - makeContact(name: "Room1", type: .room) - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "") - #expect(filtered.count == 1) - } - - @Test("filterContacts sorts alphabetically") - func testFilterContactsSortsAlphabetically() { - let contacts = [ - makeContact(name: "Zoe"), - makeContact(name: "Alice"), - makeContact(name: "Bob") - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "") - #expect(filtered.map(\.name) == ["Alice", "Bob", "Zoe"]) - } - - @Test("filterContacts returns empty for no matches") - func testFilterContactsNoMatches() { - let contacts = [makeContact(name: "Alice")] - let filtered = MentionUtilities.filterContacts(contacts, query: "xyz") - #expect(filtered.isEmpty) - } - - @Test("filterContacts handles empty input") - func testFilterContactsEmptyInput() { - let filtered = MentionUtilities.filterContacts([], query: "a") - #expect(filtered.isEmpty) - } - - @Test("filterContacts sorts by sender order when provided") - func testFilterContactsSortsBySenderOrder() { - let contacts = [ - makeContact(name: "Alice"), - makeContact(name: "Bob"), - makeContact(name: "Charlie") - ] - let senderOrder: [String: UInt32] = [ - "Charlie": 300, - "Alice": 200, - "Bob": 100 - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "", senderOrder: senderOrder) - #expect(filtered.map(\.name) == ["Charlie", "Alice", "Bob"]) - } - - @Test("filterContacts sender order partial match: ordered first, then alphabetical") - func testFilterContactsSenderOrderPartialMatch() { - let contacts = [ - makeContact(name: "Zoe"), - makeContact(name: "Alice"), - makeContact(name: "Bob"), - makeContact(name: "Dan") - ] - // Only Bob and Alice have timestamps; Zoe and Dan don't - let senderOrder: [String: UInt32] = [ - "Bob": 500, - "Alice": 100 - ] - let filtered = MentionUtilities.filterContacts(contacts, query: "", senderOrder: senderOrder) - // Bob (500) first, Alice (100) second, then Dan and Zoe alphabetically - #expect(filtered.map(\.name) == ["Bob", "Alice", "Dan", "Zoe"]) - } - - // MARK: - containsSelfMention Tests - - @Test("containsSelfMention returns true for exact match") - func testContainsSelfMentionExact() { - let result = MentionUtilities.containsSelfMention(in: "Hello @[Alice]!", selfName: "Alice") - #expect(result == true) - } - - @Test("containsSelfMention is case insensitive") - func testContainsSelfMentionCaseInsensitive() { - #expect(MentionUtilities.containsSelfMention(in: "@[ALICE]", selfName: "alice")) - #expect(MentionUtilities.containsSelfMention(in: "@[alice]", selfName: "ALICE")) - #expect(MentionUtilities.containsSelfMention(in: "@[Alice]", selfName: "aLiCe")) - } - - @Test("containsSelfMention returns false for different name") - func testContainsSelfMentionDifferentName() { - let result = MentionUtilities.containsSelfMention(in: "@[Bob] hello", selfName: "Alice") - #expect(result == false) - } - - @Test("containsSelfMention handles multiple mentions") - func testContainsSelfMentionMultiple() { - // Self mention is second - #expect(MentionUtilities.containsSelfMention(in: "@[Bob] @[Alice]", selfName: "Alice")) - // Self mention is first - #expect(MentionUtilities.containsSelfMention(in: "@[Alice] @[Bob]", selfName: "Alice")) - } - - @Test("containsSelfMention returns false for empty text") - func testContainsSelfMentionEmptyText() { - let result = MentionUtilities.containsSelfMention(in: "", selfName: "Alice") - #expect(result == false) - } - - @Test("containsSelfMention returns false for empty selfName") - func testContainsSelfMentionEmptySelfName() { - let result = MentionUtilities.containsSelfMention(in: "@[Alice]", selfName: "") - #expect(result == false) - } - - @Test("containsSelfMention handles names with spaces") - func testContainsSelfMentionWithSpaces() { - let result = MentionUtilities.containsSelfMention(in: "@[My Node] hello", selfName: "My Node") - #expect(result == true) - } - - @Test("containsSelfMention handles special characters") - func testContainsSelfMentionSpecialChars() { - let result = MentionUtilities.containsSelfMention(in: "@[Node-123] test", selfName: "Node-123") - #expect(result == true) - } - - @Test("containsSelfMention returns false for partial match") - func testContainsSelfMentionPartialMatch() { - // "Ali" should not match "Alice" - let result = MentionUtilities.containsSelfMention(in: "@[Ali]", selfName: "Alice") - #expect(result == false) - } - - @Test("containsSelfMention returns false for text without mentions") - func testContainsSelfMentionNoMentions() { - let result = MentionUtilities.containsSelfMention(in: "Hello world", selfName: "Alice") - #expect(result == false) - } + // MARK: - createMention Tests + + @Test + func `createMention creates correct format`() { + let mention = MentionUtilities.createMention(for: "Alice") + #expect(mention == "@[Alice]") + } + + @Test + func `createMention handles names with spaces`() { + let mention = MentionUtilities.createMention(for: "My Node") + #expect(mention == "@[My Node]") + } + + @Test + func `createMention handles special characters`() { + let mention = MentionUtilities.createMention(for: "Node-123") + #expect(mention == "@[Node-123]") + } + + // MARK: - appendMention Tests + + @Test + func `appendMention into empty draft yields just the mention`() { + let result = MentionUtilities.appendMention(for: "Alice", to: "") + #expect(result == "@[Alice] ") + } + + @Test + func `appendMention preserves draft and adds a separating space`() { + let result = MentionUtilities.appendMention(for: "Alice", to: "hello") + #expect(result == "hello @[Alice] ") + } + + @Test + func `appendMention does not double the space when draft ends in whitespace`() { + let result = MentionUtilities.appendMention(for: "Alice", to: "hello ") + #expect(result == "hello @[Alice] ") + } + + @Test + func `createMention handles empty name`() { + let mention = MentionUtilities.createMention(for: "") + #expect(mention == "@[]") + } + + // MARK: - extractMentions Tests + + @Test + func `extractMentions parses single mention`() { + let mentions = MentionUtilities.extractMentions(from: "@[Alice] hello!") + #expect(mentions == ["Alice"]) + } + + @Test + func `extractMentions parses multiple mentions`() { + let mentions = MentionUtilities.extractMentions(from: "@[Alice] and @[Bob] hello!") + #expect(mentions == ["Alice", "Bob"]) + } + + @Test + func `extractMentions returns empty for no mentions`() { + let mentions = MentionUtilities.extractMentions(from: "Hello world!") + #expect(mentions.isEmpty) + } + + @Test + func `extractMentions handles names with spaces`() { + let mentions = MentionUtilities.extractMentions(from: "@[My Node] says hi") + #expect(mentions == ["My Node"]) + } + + @Test + func `extractMentions handles special characters`() { + let mentions = MentionUtilities.extractMentions(from: "@[Node-123] testing") + #expect(mentions == ["Node-123"]) + } + + @Test + func `extractMentions handles adjacent mentions`() { + let mentions = MentionUtilities.extractMentions(from: "@[Alice]@[Bob]") + #expect(mentions == ["Alice", "Bob"]) + } + + @Test + func `extractMentions ignores malformed patterns`() { + // Missing closing bracket + let mentions1 = MentionUtilities.extractMentions(from: "@[Alice hello") + #expect(mentions1.isEmpty) + + // Missing opening bracket + let mentions2 = MentionUtilities.extractMentions(from: "@Alice] hello") + #expect(mentions2.isEmpty) + + // Just @ symbol + let mentions3 = MentionUtilities.extractMentions(from: "@ hello") + #expect(mentions3.isEmpty) + } + + @Test + func `extractMentions handles empty message`() { + let mentions = MentionUtilities.extractMentions(from: "") + #expect(mentions.isEmpty) + } + + @Test + func `extractMentions handles Unicode names`() { + let mentions = MentionUtilities.extractMentions(from: "@[日本語] hello") + #expect(mentions == ["日本語"]) + } + + // MARK: - detectActiveMention Tests + + @Test + func `detectActiveMention returns nil for empty text`() { + let result = MentionUtilities.detectActiveMention(in: "") + #expect(result == nil) + } + + @Test + func `detectActiveMention returns nil for text without @`() { + let result = MentionUtilities.detectActiveMention(in: "hello world") + #expect(result == nil) + } + + @Test + func `detectActiveMention returns empty string for @ alone`() { + let result = MentionUtilities.detectActiveMention(in: "@") + #expect(result == "") + } + + @Test + func `detectActiveMention returns query after @`() { + let result = MentionUtilities.detectActiveMention(in: "@jo") + #expect(result == "jo") + } + + @Test + func `detectActiveMention works at start of message`() { + let result = MentionUtilities.detectActiveMention(in: "@alice") + #expect(result == "alice") + } + + @Test + func `detectActiveMention works after space`() { + let result = MentionUtilities.detectActiveMention(in: "hey @bob") + #expect(result == "bob") + } + + @Test + func `detectActiveMention returns nil for @ mid-word`() { + let result = MentionUtilities.detectActiveMention(in: "email@domain") + #expect(result == nil) + } + + @Test + func `detectActiveMention returns nil when space follows @`() { + let result = MentionUtilities.detectActiveMention(in: "@ hello") + #expect(result == nil) + } + + @Test + func `detectActiveMention returns last active mention`() { + let result = MentionUtilities.detectActiveMention(in: "@[Alice] hey @bo") + #expect(result == "bo") + } + + @Test + func `detectActiveMention returns nil for completed mention`() { + let result = MentionUtilities.detectActiveMention(in: "@[Alice] hello") + #expect(result == nil) + } + + @Test + func `detectActiveMention handles Unicode`() { + let result = MentionUtilities.detectActiveMention(in: "@日本") + #expect(result == "日本") + } + + @Test + func `detectActiveMention ignores email addresses`() { + let result = MentionUtilities.detectActiveMention(in: "contact me at test@example.com") + #expect(result == nil) + } + + @Test + func `detectActiveMention handles double @ symbols`() { + let result = MentionUtilities.detectActiveMention(in: "@@alice") + #expect(result == nil) + } + + @Test + func `detectActiveMention returns nil for unclosed bracket`() { + let result = MentionUtilities.detectActiveMention(in: "@[Alice") + #expect(result == nil) + } + + // MARK: - filterContacts Tests + + private func makeContact( + name: String, + type: ContactType = .chat, + publicKey: Data = Data([0xAB]) + ) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: publicKey, + name: name, + typeRawValue: type.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } + + @Test + func `filterContacts matches using localizedStandardContains`() { + let contacts = [ + makeContact(name: "Alice"), + makeContact(name: "Bob"), + makeContact(name: "Amanda") + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "a") + #expect(filtered.count == 2) + #expect(filtered.map(\.name).contains("Alice")) + #expect(filtered.map(\.name).contains("Amanda")) + } + + @Test + func `filterContacts excludes repeaters`() { + let contacts = [ + makeContact(name: "Alice", type: .chat), + makeContact(name: "Repeater1", type: .repeater) + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "") + #expect(filtered.count == 1) + #expect(filtered.first?.name == "Alice") + } + + @Test + func `filterContacts excludes rooms`() { + let contacts = [ + makeContact(name: "Alice", type: .chat), + makeContact(name: "Room1", type: .room) + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "") + #expect(filtered.count == 1) + } + + @Test + func `filterContacts sorts alphabetically`() { + let contacts = [ + makeContact(name: "Zoe"), + makeContact(name: "Alice"), + makeContact(name: "Bob") + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "") + #expect(filtered.map(\.name) == ["Alice", "Bob", "Zoe"]) + } + + @Test + func `filterContacts returns empty for no matches`() { + let contacts = [makeContact(name: "Alice")] + let filtered = MentionUtilities.filterContacts(contacts, query: "xyz") + #expect(filtered.isEmpty) + } + + @Test + func `filterContacts handles empty input`() { + let filtered = MentionUtilities.filterContacts([], query: "a") + #expect(filtered.isEmpty) + } + + @Test + func `filterContacts sorts by sender order when provided`() { + let contacts = [ + makeContact(name: "Alice"), + makeContact(name: "Bob"), + makeContact(name: "Charlie") + ] + let senderOrder: [String: UInt32] = [ + "Charlie": 300, + "Alice": 200, + "Bob": 100 + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "", senderOrder: senderOrder) + #expect(filtered.map(\.name) == ["Charlie", "Alice", "Bob"]) + } + + @Test + func `filterContacts sender order partial match: ordered first, then alphabetical`() { + let contacts = [ + makeContact(name: "Zoe"), + makeContact(name: "Alice"), + makeContact(name: "Bob"), + makeContact(name: "Dan") + ] + // Only Bob and Alice have timestamps; Zoe and Dan don't + let senderOrder: [String: UInt32] = [ + "Bob": 500, + "Alice": 100 + ] + let filtered = MentionUtilities.filterContacts(contacts, query: "", senderOrder: senderOrder) + // Bob (500) first, Alice (100) second, then Dan and Zoe alphabetically + #expect(filtered.map(\.name) == ["Bob", "Alice", "Dan", "Zoe"]) + } + + // MARK: - containsSelfMention Tests + + @Test + func `containsSelfMention returns true for exact match`() { + let result = MentionUtilities.containsSelfMention(in: "Hello @[Alice]!", selfName: "Alice") + #expect(result == true) + } + + @Test + func `containsSelfMention is case insensitive`() { + #expect(MentionUtilities.containsSelfMention(in: "@[ALICE]", selfName: "alice")) + #expect(MentionUtilities.containsSelfMention(in: "@[alice]", selfName: "ALICE")) + #expect(MentionUtilities.containsSelfMention(in: "@[Alice]", selfName: "aLiCe")) + } + + @Test + func `containsSelfMention returns false for different name`() { + let result = MentionUtilities.containsSelfMention(in: "@[Bob] hello", selfName: "Alice") + #expect(result == false) + } + + @Test + func `containsSelfMention handles multiple mentions`() { + // Self mention is second + #expect(MentionUtilities.containsSelfMention(in: "@[Bob] @[Alice]", selfName: "Alice")) + // Self mention is first + #expect(MentionUtilities.containsSelfMention(in: "@[Alice] @[Bob]", selfName: "Alice")) + } + + @Test + func `containsSelfMention returns false for empty text`() { + let result = MentionUtilities.containsSelfMention(in: "", selfName: "Alice") + #expect(result == false) + } + + @Test + func `containsSelfMention returns false for empty selfName`() { + let result = MentionUtilities.containsSelfMention(in: "@[Alice]", selfName: "") + #expect(result == false) + } + + @Test + func `containsSelfMention handles names with spaces`() { + let result = MentionUtilities.containsSelfMention(in: "@[My Node] hello", selfName: "My Node") + #expect(result == true) + } + + @Test + func `containsSelfMention handles special characters`() { + let result = MentionUtilities.containsSelfMention(in: "@[Node-123] test", selfName: "Node-123") + #expect(result == true) + } + + @Test + func `containsSelfMention returns false for partial match`() { + // "Ali" should not match "Alice" + let result = MentionUtilities.containsSelfMention(in: "@[Ali]", selfName: "Alice") + #expect(result == false) + } + + @Test + func `containsSelfMention returns false for text without mentions`() { + let result = MentionUtilities.containsSelfMention(in: "Hello world", selfName: "Alice") + #expect(result == false) + } } diff --git a/MC1Tests/Utilities/MeshCoreURLParserTests.swift b/MC1Tests/Utilities/MeshCoreURLParserTests.swift index 39a6024b..5ea17313 100644 --- a/MC1Tests/Utilities/MeshCoreURLParserTests.swift +++ b/MC1Tests/Utilities/MeshCoreURLParserTests.swift @@ -1,119 +1,118 @@ -import Testing -import Foundation import CoreLocation -@testable import MC1Services +import Foundation @testable import MC1 +@testable import MC1Services +import Testing @Suite("MeshCoreURLParser Tests") struct MeshCoreURLParserTests { - - /// A real 32-byte public key rendered as uppercase hex (64 chars). - static let validHex = "A1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" - - /// A second, distinct 32-byte key used to prove an injected key never wins. - static let otherHex = String(repeating: "BC", count: 32) - - /// Exports a contact then parses it back, exercising the full emit -> parse round-trip. - private static func roundTrip(name: String, type: ContactType = .chat) throws -> MeshCoreURLParser.ContactResult { - let key = try #require(Data(hexString: validHex)) - let uri = ContactService.exportContactURI(name: name, publicKey: key, type: type) - return try #require(MeshCoreURLParser.parseContactURL(uri), "round-trip should parse for name: \(name)") - } - - @Test("parseContactURL falls back to .chat for an out-of-range type without trapping") - func testOutOfRangeTypeFallsBackToChat() throws { - let url = "meshcore://contact/add?name=Node&public_key=\(Self.validHex)&type=300" - let result = try #require(MeshCoreURLParser.parseContactURL(url)) - #expect(result.name == "Node") - #expect(result.contactType == .chat) - } - - @Test("parseContactURL maps type=2 to .repeater") - func testRepeaterType() throws { - let url = "meshcore://contact/add?name=Node&public_key=\(Self.validHex)&type=2" - let result = try #require(MeshCoreURLParser.parseContactURL(url)) - #expect(result.contactType == .repeater) - } - - // MARK: - Export/parse round-trip and query injection - - @Test("A name carrying an injected public_key/type query does not override the declared key or type") - func exportNeutralizesQueryInjection() throws { - let spoofName = "Alice&public_key=\(Self.otherHex)&type=3" - let declaredKey = try #require(Data(hexString: Self.validHex)) - - let uri = ContactService.exportContactURI(name: spoofName, publicKey: declaredKey, type: .chat) - let result = try #require(MeshCoreURLParser.parseContactURL(uri)) - - #expect(result.publicKey == declaredKey, "Declared key must win over the injected public_key") - #expect(result.contactType == .chat, "Declared type must win over the injected type") - #expect(result.name == spoofName, "The whole injected string is the name, preserved verbatim") - } - - @Test("Names containing query-significant characters round-trip intact") - func exportRoundTripsQuerySignificantCharacters() throws { - for name in ["a & b", "a=b", "key & value = pair", "100% sure?", "a#b"] { - let result = try Self.roundTrip(name: name) - #expect(result.name == name, "name should survive round-trip: expected \(name), got \(result.name)") - } - } - - @Test("A literal plus in a name is preserved, not turned into a space") - func exportPreservesLiteralPlus() throws { - let result = try Self.roundTrip(name: "C++ dev") - #expect(result.name == "C++ dev") - } - - @Test("Spaces, colons, and unicode round-trip intact") - func exportRoundTripsSpacesColonsAndUnicode() throws { - for name in ["Field Base", "12:30 rally point", "Café au lait", "北京"] { - let result = try Self.roundTrip(name: name) - #expect(result.name == name, "name should survive round-trip: expected \(name), got \(result.name)") - } - } - - @Test("Every contact type round-trips through export and parse") - func exportRoundTripsContactType() throws { - for type in [ContactType.chat, .repeater, .room] { - let result = try Self.roundTrip(name: "Node", type: type) - #expect(result.contactType == type) - } + /// A real 32-byte public key rendered as uppercase hex (64 chars). + static let validHex = "A1432C142E1615EAB6414856F58C90CD61E7C5901650142E5EFE4D2F1332654D" + + /// A second, distinct 32-byte key used to prove an injected key never wins. + static let otherHex = String(repeating: "BC", count: 32) + + /// Exports a contact then parses it back, exercising the full emit -> parse round-trip. + private static func roundTrip(name: String, type: ContactType = .chat) throws -> MeshCoreURLParser.ContactResult { + let key = try #require(Data(hexString: validHex)) + let uri = ContactService.exportContactURI(name: name, publicKey: key, type: type) + return try #require(MeshCoreURLParser.parseContactURL(uri), "round-trip should parse for name: \(name)") + } + + @Test + func `parseContactURL falls back to .chat for an out-of-range type without trapping`() throws { + let url = "meshcore://contact/add?name=Node&public_key=\(Self.validHex)&type=300" + let result = try #require(MeshCoreURLParser.parseContactURL(url)) + #expect(result.name == "Node") + #expect(result.contactType == .chat) + } + + @Test + func `parseContactURL maps type=2 to .repeater`() throws { + let url = "meshcore://contact/add?name=Node&public_key=\(Self.validHex)&type=2" + let result = try #require(MeshCoreURLParser.parseContactURL(url)) + #expect(result.contactType == .repeater) + } + + // MARK: - Export/parse round-trip and query injection + + @Test + func `A name carrying an injected public_key/type query does not override the declared key or type`() throws { + let spoofName = "Alice&public_key=\(Self.otherHex)&type=3" + let declaredKey = try #require(Data(hexString: Self.validHex)) + + let uri = ContactService.exportContactURI(name: spoofName, publicKey: declaredKey, type: .chat) + let result = try #require(MeshCoreURLParser.parseContactURL(uri)) + + #expect(result.publicKey == declaredKey, "Declared key must win over the injected public_key") + #expect(result.contactType == .chat, "Declared type must win over the injected type") + #expect(result.name == spoofName, "The whole injected string is the name, preserved verbatim") + } + + @Test + func `Names containing query-significant characters round-trip intact`() throws { + for name in ["a & b", "a=b", "key & value = pair", "100% sure?", "a#b"] { + let result = try Self.roundTrip(name: name) + #expect(result.name == name, "name should survive round-trip: expected \(name), got \(result.name)") } - - // MARK: - parseMapURL - - @Test("parseMapURL parses a valid map link into the correct coordinate") - func mapURLValidRoundTrip() throws { - let url = "meshcore://map?lat=37.334900&lon=-122.009020" - let coordinate = try #require(MeshCoreURLParser.parseMapURL(url)) - #expect(abs(coordinate.latitude - 37.3349) < 0.000001) - #expect(abs(coordinate.longitude - (-122.00902)) < 0.000001) - } - - @Test("parseMapURL returns nil when lat or lon is missing") - func mapURLMissingParams() { - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=37.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lon=-122.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map") == nil) - } - - @Test("parseMapURL rejects out-of-range coordinates") - func mapURLOutOfRange() { - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=91.0&lon=0.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0.0&lon=181.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=-90.001&lon=0.0") == nil) - } - - @Test("parseMapURL rejects a non-map host") - func mapURLWrongHost() { - #expect(MeshCoreURLParser.parseMapURL("meshcore://contact?lat=10.0&lon=10.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://channel/add?lat=10.0&lon=10.0") == nil) + } + + @Test + func `A literal plus in a name is preserved, not turned into a space`() throws { + let result = try Self.roundTrip(name: "C++ dev") + #expect(result.name == "C++ dev") + } + + @Test + func `Spaces, colons, and unicode round-trip intact`() throws { + for name in ["Field Base", "12:30 rally point", "Café au lait", "北京"] { + let result = try Self.roundTrip(name: name) + #expect(result.name == name, "name should survive round-trip: expected \(name), got \(result.name)") } + } - @Test("parseMapURL rejects non-finite and hex-float values that Double would otherwise accept") - func mapURLRejectsNonDecimalValues() { - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=nan&lon=0.0") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0.0&lon=inf") == nil) - #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0x1p4&lon=0.0") == nil) + @Test + func `Every contact type round-trips through export and parse`() throws { + for type in [ContactType.chat, .repeater, .room] { + let result = try Self.roundTrip(name: "Node", type: type) + #expect(result.contactType == type) } + } + + // MARK: - parseMapURL + + @Test + func `parseMapURL parses a valid map link into the correct coordinate`() throws { + let url = "meshcore://map?lat=37.334900&lon=-122.009020" + let coordinate = try #require(MeshCoreURLParser.parseMapURL(url)) + #expect(abs(coordinate.latitude - 37.3349) < 0.000001) + #expect(abs(coordinate.longitude - -122.00902) < 0.000001) + } + + @Test + func `parseMapURL returns nil when lat or lon is missing`() { + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=37.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lon=-122.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map") == nil) + } + + @Test + func `parseMapURL rejects out-of-range coordinates`() { + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=91.0&lon=0.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0.0&lon=181.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=-90.001&lon=0.0") == nil) + } + + @Test + func `parseMapURL rejects a non-map host`() { + #expect(MeshCoreURLParser.parseMapURL("meshcore://contact?lat=10.0&lon=10.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://channel/add?lat=10.0&lon=10.0") == nil) + } + + @Test + func `parseMapURL rejects non-finite and hex-float values that Double would otherwise accept`() { + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=nan&lon=0.0") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0.0&lon=inf") == nil) + #expect(MeshCoreURLParser.parseMapURL("meshcore://map?lat=0x1p4&lon=0.0") == nil) + } } diff --git a/MC1Tests/Utilities/RepeaterResolverTests.swift b/MC1Tests/Utilities/RepeaterResolverTests.swift index ac8a5877..011911b0 100644 --- a/MC1Tests/Utilities/RepeaterResolverTests.swift +++ b/MC1Tests/Utilities/RepeaterResolverTests.swift @@ -1,506 +1,505 @@ import CoreLocation import Foundation -import Testing @testable import MC1 @testable import MC1Services +import Testing @Suite("RepeaterResolver") struct RepeaterResolverTests { - - private func createRepeater( - prefix: UInt8, - secondByte: UInt8, - name: String, - lastAdvertTimestamp: UInt32, - latitude: Double, - longitude: Double - ) -> ContactDTO { - ContactDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([prefix, secondByte] + Array(repeating: UInt8(0), count: 30)), - name: name, - typeRawValue: ContactType.repeater.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - } - - @Test("prefers closest repeater when location available") - func prefersClosestWithLocation() { - let repeaterA = createRepeater( - prefix: 0x3F, - secondByte: 0x01, - name: "Near", - lastAdvertTimestamp: 10, - latitude: 37.0, - longitude: -122.0 - ) - let repeaterB = createRepeater( - prefix: 0x3F, - secondByte: 0x02, - name: "Far", - lastAdvertTimestamp: 200, - latitude: 38.0, - longitude: -123.0 - ) - - let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) - let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [repeaterA, repeaterB], userLocation: userLocation) - - #expect(match?.displayName == "Near") - } - - @Test("exact match with full public key ignores proximity/recency") - func exactMatchWithFullPublicKey() { - let repeaterA = createRepeater( - prefix: 0x3F, - secondByte: 0x01, - name: "Target", - lastAdvertTimestamp: 10, - latitude: 38.0, - longitude: -123.0 - ) - let repeaterB = createRepeater( - prefix: 0x3F, - secondByte: 0x02, - name: "Closer and Newer", - lastAdvertTimestamp: 200, - latitude: 37.0, - longitude: -122.0 - ) - - let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) - // PathHop with full key of repeaterA - should match exactly despite repeaterB being closer/newer - let hop = PathHop(hashBytes: Data([0x3F]), publicKey: repeaterA.publicKey, resolvedName: "Target") - let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA, repeaterB], userLocation: userLocation) - - #expect(match?.displayName == "Target") - } - - @Test("PathHop without public key falls back to proximity/recency") - func pathHopWithoutKeyFallsBackToProximity() { - let repeaterA = createRepeater( - prefix: 0x3F, - secondByte: 0x01, - name: "Far", - lastAdvertTimestamp: 10, - latitude: 38.0, - longitude: -123.0 - ) - let repeaterB = createRepeater( - prefix: 0x3F, - secondByte: 0x02, - name: "Near", - lastAdvertTimestamp: 200, - latitude: 37.0, - longitude: -122.0 - ) - - let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) - // PathHop with nil publicKey - should fall back to proximity match - let hop = PathHop(hashBytes: Data([0x3F]), resolvedName: nil) - let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA, repeaterB], userLocation: userLocation) - - #expect(match?.displayName == "Near") - } - - @Test("PathHop with deleted contact key falls back to hash byte match") - func pathHopWithDeletedContactFallsBack() { - let repeaterA = createRepeater( - prefix: 0x3F, - secondByte: 0x01, - name: "Only Match", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - - // PathHop has a key that doesn't match any current repeater (contact was deleted) - let deletedKey = Data([0x3F, 0xFF] + Array(repeating: UInt8(0), count: 30)) - let hop = PathHop(hashBytes: Data([0x3F]), publicKey: deletedKey, resolvedName: "Deleted") - let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA], userLocation: nil) - - // Falls back to hash byte match - #expect(match?.displayName == "Only Match") - } - - // MARK: - DiscoveredNodeDTO Tests - - private func createDiscoveredNode( - prefix: UInt8, - secondByte: UInt8, - name: String, - lastAdvertTimestamp: UInt32, - lastHeard: Date = Date(), - latitude: Double, - longitude: Double - ) -> DiscoveredNodeDTO { - DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([prefix, secondByte] + Array(repeating: UInt8(0), count: 30)), - name: name, - typeRawValue: ContactType.repeater.rawValue, - lastHeard: lastHeard, - lastAdvertTimestamp: lastAdvertTimestamp, - latitude: latitude, - longitude: longitude, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - } - - @Test("prefers closest discovered node when location available") - func prefersClosestDiscoveredNodeWithLocation() { - let nodeA = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x01, - name: "Near Node", - lastAdvertTimestamp: 10, - latitude: 37.0, - longitude: -122.0 - ) - let nodeB = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x02, - name: "Far Node", - lastAdvertTimestamp: 200, - latitude: 38.0, - longitude: -123.0 - ) - - let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) - let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [nodeA, nodeB], userLocation: userLocation) - - #expect(match?.name == "Near Node") - } - - @Test("prefers most recent discovered node without location") - func prefersMostRecentDiscoveredNodeWithoutLocation() { - let nodeA = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x01, - name: "Older Node", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - let nodeB = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x02, - name: "Newer Node", - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0 - ) - - let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [nodeA, nodeB], userLocation: nil) - - #expect(match?.name == "Newer Node") - } - - @Test("exact match with full public key for discovered node PathHop variant") - func exactMatchDiscoveredNodePathHop() { - let nodeA = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x01, - name: "Target Node", - lastAdvertTimestamp: 10, - latitude: 38.0, - longitude: -123.0 - ) - let nodeB = createDiscoveredNode( - prefix: 0x3F, - secondByte: 0x02, - name: "Closer and Newer", - lastAdvertTimestamp: 200, - latitude: 37.0, - longitude: -122.0 - ) - - let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) - let hop = PathHop(hashBytes: Data([0x3F]), publicKey: nodeA.publicKey, resolvedName: "Target Node") - let match = RepeaterResolver.bestMatch(for: hop, in: [nodeA, nodeB], userLocation: userLocation) - - #expect(match?.name == "Target Node") - } - - // MARK: - ContactDTO Tests - - @Test("prefers most recent when location unavailable") - func prefersMostRecentWithoutLocation() { - let repeaterA = createRepeater( - prefix: 0x3F, - secondByte: 0x01, - name: "Older", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - let repeaterB = createRepeater( - prefix: 0x3F, - secondByte: 0x02, - name: "Newer", - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0 - ) - - let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [repeaterA, repeaterB], userLocation: nil) - - #expect(match?.displayName == "Newer") - } - - @Test("neighbor name resolver uses discovered node when contact is absent") - func neighborNameResolverUsesDiscoveredNode() { - let node = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0xEF] + Array(repeating: UInt8(0), count: 29)), - name: "Ridge Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: 100, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let name = NeighborNameResolver.resolveName( - for: Data([0xAB, 0xCD]), - contacts: [], - discoveredNodes: [node], - userLocation: nil - ) - - #expect(name == "Ridge Repeater") - } - - @Test("neighbor name resolver marks unique short discovered prefix as exact") - func neighborNameResolverMarksUniqueShortDiscoveredPrefixAsExact() { - let node = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0xEF] + Array(repeating: UInt8(0), count: 29)), - name: "Ridge Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: 100, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let result = NeighborNameResolver.resolve( - for: Data([0xAB, 0xCD]), - contacts: [], - discoveredNodes: [node], - userLocation: nil - ) - - #expect(result?.displayName == "Ridge Repeater") - #expect(result?.matchKind == .exact) - } - - @Test("neighbor name resolver marks full prefix contact match as exact") - func neighborNameResolverMarksFullPrefixContactMatchAsExact() { - let contact = createRepeater( - prefix: 0xAB, - secondByte: 0xCD, - name: "Saved Repeater", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - - let result = NeighborNameResolver.resolve( - for: contact.publicKeyPrefix, - contacts: [contact], - discoveredNodes: [], - userLocation: nil - ) - - #expect(result?.displayName == "Saved Repeater") - #expect(result?.matchKind == .exact) - } - - @Test("neighbor name resolver prefers contacts over discovered nodes") - func neighborNameResolverPrefersContacts() { - let contact = createRepeater( - prefix: 0xAB, - secondByte: 0xCD, - name: "Saved Repeater", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - let node = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: contact.publicKey, - name: "Advert Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let name = NeighborNameResolver.resolveName( - for: Data([0xAB, 0xCD]), - contacts: [contact], - discoveredNodes: [node], - userLocation: nil - ) - - #expect(name == "Saved Repeater") - } - - @Test("neighbor name resolver marks cross-source short prefix ambiguity as fallback") - func neighborNameResolverMarksCrossSourceShortPrefixAmbiguityAsFallback() { - let contact = createRepeater( - prefix: 0xAB, - secondByte: 0xCD, - name: "Saved Repeater", - lastAdvertTimestamp: 10, - latitude: 0, - longitude: 0 - ) - let node = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xEF] + Array(repeating: UInt8(0), count: 30)), - name: "Advert Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(), - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let result = NeighborNameResolver.resolve( - for: Data([0xAB]), - contacts: [contact], - discoveredNodes: [node], - userLocation: nil - ) - - #expect(result?.displayName == "Saved Repeater") - #expect(result?.matchKind == .fallback) - } - - @Test("neighbor name resolver disambiguates short discovered prefixes by recency") - func neighborNameResolverDisambiguatesShortPrefixesByRecency() { - let older = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0x01] + Array(repeating: UInt8(0), count: 29)), - name: "Older Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(timeIntervalSince1970: 1_000), - lastAdvertTimestamp: 100, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - let newer = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0x02] + Array(repeating: UInt8(0), count: 29)), - name: "Newer Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(timeIntervalSince1970: 2_000), - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let name = NeighborNameResolver.resolveName( - for: Data([0xAB, 0xCD]), - contacts: [], - discoveredNodes: [older, newer], - userLocation: nil - ) - - #expect(name == "Newer Repeater") - } - - @Test("neighbor name resolver marks ambiguous short discovered prefix as fallback") - func neighborNameResolverMarksAmbiguousShortDiscoveredPrefixAsFallback() { - let older = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0x01] + Array(repeating: UInt8(0), count: 29)), - name: "Older Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(timeIntervalSince1970: 1_000), - lastAdvertTimestamp: 100, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - let newer = DiscoveredNodeDTO( - id: UUID(), - radioID: UUID(), - publicKey: Data([0xAB, 0xCD, 0x02] + Array(repeating: UInt8(0), count: 29)), - name: "Newer Repeater", - typeRawValue: ContactType.repeater.rawValue, - lastHeard: Date(timeIntervalSince1970: 2_000), - lastAdvertTimestamp: 200, - latitude: 0, - longitude: 0, - outPathLength: 0, - outPath: Data(), - inboundHopCount: nil, - inboundHopAdvertTimestamp: nil - ) - - let result = NeighborNameResolver.resolve( - for: Data([0xAB, 0xCD]), - contacts: [], - discoveredNodes: [older, newer], - userLocation: nil - ) - - #expect(result?.displayName == "Newer Repeater") - #expect(result?.matchKind == .fallback) - } + private func createRepeater( + prefix: UInt8, + secondByte: UInt8, + name: String, + lastAdvertTimestamp: UInt32, + latitude: Double, + longitude: Double + ) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([prefix, secondByte] + Array(repeating: UInt8(0), count: 30)), + name: name, + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } + + @Test + func `prefers closest repeater when location available`() { + let repeaterA = createRepeater( + prefix: 0x3F, + secondByte: 0x01, + name: "Near", + lastAdvertTimestamp: 10, + latitude: 37.0, + longitude: -122.0 + ) + let repeaterB = createRepeater( + prefix: 0x3F, + secondByte: 0x02, + name: "Far", + lastAdvertTimestamp: 200, + latitude: 38.0, + longitude: -123.0 + ) + + let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) + let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [repeaterA, repeaterB], userLocation: userLocation) + + #expect(match?.displayName == "Near") + } + + @Test + func `exact match with full public key ignores proximity/recency`() { + let repeaterA = createRepeater( + prefix: 0x3F, + secondByte: 0x01, + name: "Target", + lastAdvertTimestamp: 10, + latitude: 38.0, + longitude: -123.0 + ) + let repeaterB = createRepeater( + prefix: 0x3F, + secondByte: 0x02, + name: "Closer and Newer", + lastAdvertTimestamp: 200, + latitude: 37.0, + longitude: -122.0 + ) + + let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) + // PathHop with full key of repeaterA - should match exactly despite repeaterB being closer/newer + let hop = PathHop(hashBytes: Data([0x3F]), publicKey: repeaterA.publicKey, resolvedName: "Target") + let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA, repeaterB], userLocation: userLocation) + + #expect(match?.displayName == "Target") + } + + @Test + func `PathHop without public key falls back to proximity/recency`() { + let repeaterA = createRepeater( + prefix: 0x3F, + secondByte: 0x01, + name: "Far", + lastAdvertTimestamp: 10, + latitude: 38.0, + longitude: -123.0 + ) + let repeaterB = createRepeater( + prefix: 0x3F, + secondByte: 0x02, + name: "Near", + lastAdvertTimestamp: 200, + latitude: 37.0, + longitude: -122.0 + ) + + let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) + // PathHop with nil publicKey - should fall back to proximity match + let hop = PathHop(hashBytes: Data([0x3F]), resolvedName: nil) + let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA, repeaterB], userLocation: userLocation) + + #expect(match?.displayName == "Near") + } + + @Test + func `PathHop with deleted contact key falls back to hash byte match`() { + let repeaterA = createRepeater( + prefix: 0x3F, + secondByte: 0x01, + name: "Only Match", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + + // PathHop has a key that doesn't match any current repeater (contact was deleted) + let deletedKey = Data([0x3F, 0xFF] + Array(repeating: UInt8(0), count: 30)) + let hop = PathHop(hashBytes: Data([0x3F]), publicKey: deletedKey, resolvedName: "Deleted") + let match = RepeaterResolver.bestMatch(for: hop, in: [repeaterA], userLocation: nil) + + // Falls back to hash byte match + #expect(match?.displayName == "Only Match") + } + + // MARK: - DiscoveredNodeDTO Tests + + private func createDiscoveredNode( + prefix: UInt8, + secondByte: UInt8, + name: String, + lastAdvertTimestamp: UInt32, + lastHeard: Date = Date(), + latitude: Double, + longitude: Double + ) -> DiscoveredNodeDTO { + DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([prefix, secondByte] + Array(repeating: UInt8(0), count: 30)), + name: name, + typeRawValue: ContactType.repeater.rawValue, + lastHeard: lastHeard, + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + } + + @Test + func `prefers closest discovered node when location available`() { + let nodeA = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x01, + name: "Near Node", + lastAdvertTimestamp: 10, + latitude: 37.0, + longitude: -122.0 + ) + let nodeB = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x02, + name: "Far Node", + lastAdvertTimestamp: 200, + latitude: 38.0, + longitude: -123.0 + ) + + let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) + let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [nodeA, nodeB], userLocation: userLocation) + + #expect(match?.name == "Near Node") + } + + @Test + func `prefers most recent discovered node without location`() { + let nodeA = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x01, + name: "Older Node", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + let nodeB = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x02, + name: "Newer Node", + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0 + ) + + let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [nodeA, nodeB], userLocation: nil) + + #expect(match?.name == "Newer Node") + } + + @Test + func `exact match with full public key for discovered node PathHop variant`() { + let nodeA = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x01, + name: "Target Node", + lastAdvertTimestamp: 10, + latitude: 38.0, + longitude: -123.0 + ) + let nodeB = createDiscoveredNode( + prefix: 0x3F, + secondByte: 0x02, + name: "Closer and Newer", + lastAdvertTimestamp: 200, + latitude: 37.0, + longitude: -122.0 + ) + + let userLocation = CLLocation(latitude: 37.0005, longitude: -122.0005) + let hop = PathHop(hashBytes: Data([0x3F]), publicKey: nodeA.publicKey, resolvedName: "Target Node") + let match = RepeaterResolver.bestMatch(for: hop, in: [nodeA, nodeB], userLocation: userLocation) + + #expect(match?.name == "Target Node") + } + + // MARK: - ContactDTO Tests + + @Test + func `prefers most recent when location unavailable`() { + let repeaterA = createRepeater( + prefix: 0x3F, + secondByte: 0x01, + name: "Older", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + let repeaterB = createRepeater( + prefix: 0x3F, + secondByte: 0x02, + name: "Newer", + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0 + ) + + let match = RepeaterResolver.bestMatch(for: Data([0x3F]), in: [repeaterA, repeaterB], userLocation: nil) + + #expect(match?.displayName == "Newer") + } + + @Test + func `neighbor name resolver uses discovered node when contact is absent`() { + let node = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0xEF] + Array(repeating: UInt8(0), count: 29)), + name: "Ridge Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: 100, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let name = NeighborNameResolver.resolveName( + for: Data([0xAB, 0xCD]), + contacts: [], + discoveredNodes: [node], + userLocation: nil + ) + + #expect(name == "Ridge Repeater") + } + + @Test + func `neighbor name resolver marks unique short discovered prefix as exact`() { + let node = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0xEF] + Array(repeating: UInt8(0), count: 29)), + name: "Ridge Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: 100, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let result = NeighborNameResolver.resolve( + for: Data([0xAB, 0xCD]), + contacts: [], + discoveredNodes: [node], + userLocation: nil + ) + + #expect(result?.displayName == "Ridge Repeater") + #expect(result?.matchKind == .exact) + } + + @Test + func `neighbor name resolver marks full prefix contact match as exact`() { + let contact = createRepeater( + prefix: 0xAB, + secondByte: 0xCD, + name: "Saved Repeater", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + + let result = NeighborNameResolver.resolve( + for: contact.publicKeyPrefix, + contacts: [contact], + discoveredNodes: [], + userLocation: nil + ) + + #expect(result?.displayName == "Saved Repeater") + #expect(result?.matchKind == .exact) + } + + @Test + func `neighbor name resolver prefers contacts over discovered nodes`() { + let contact = createRepeater( + prefix: 0xAB, + secondByte: 0xCD, + name: "Saved Repeater", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + let node = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: contact.publicKey, + name: "Advert Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let name = NeighborNameResolver.resolveName( + for: Data([0xAB, 0xCD]), + contacts: [contact], + discoveredNodes: [node], + userLocation: nil + ) + + #expect(name == "Saved Repeater") + } + + @Test + func `neighbor name resolver marks cross-source short prefix ambiguity as fallback`() { + let contact = createRepeater( + prefix: 0xAB, + secondByte: 0xCD, + name: "Saved Repeater", + lastAdvertTimestamp: 10, + latitude: 0, + longitude: 0 + ) + let node = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xEF] + Array(repeating: UInt8(0), count: 30)), + name: "Advert Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(), + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let result = NeighborNameResolver.resolve( + for: Data([0xAB]), + contacts: [contact], + discoveredNodes: [node], + userLocation: nil + ) + + #expect(result?.displayName == "Saved Repeater") + #expect(result?.matchKind == .fallback) + } + + @Test + func `neighbor name resolver disambiguates short discovered prefixes by recency`() { + let older = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0x01] + Array(repeating: UInt8(0), count: 29)), + name: "Older Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(timeIntervalSince1970: 1000), + lastAdvertTimestamp: 100, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + let newer = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0x02] + Array(repeating: UInt8(0), count: 29)), + name: "Newer Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(timeIntervalSince1970: 2000), + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let name = NeighborNameResolver.resolveName( + for: Data([0xAB, 0xCD]), + contacts: [], + discoveredNodes: [older, newer], + userLocation: nil + ) + + #expect(name == "Newer Repeater") + } + + @Test + func `neighbor name resolver marks ambiguous short discovered prefix as fallback`() { + let older = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0x01] + Array(repeating: UInt8(0), count: 29)), + name: "Older Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(timeIntervalSince1970: 1000), + lastAdvertTimestamp: 100, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + let newer = DiscoveredNodeDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data([0xAB, 0xCD, 0x02] + Array(repeating: UInt8(0), count: 29)), + name: "Newer Repeater", + typeRawValue: ContactType.repeater.rawValue, + lastHeard: Date(timeIntervalSince1970: 2000), + lastAdvertTimestamp: 200, + latitude: 0, + longitude: 0, + outPathLength: 0, + outPath: Data(), + inboundHopCount: nil, + inboundHopAdvertTimestamp: nil + ) + + let result = NeighborNameResolver.resolve( + for: Data([0xAB, 0xCD]), + contacts: [], + discoveredNodes: [older, newer], + userLocation: nil + ) + + #expect(result?.displayName == "Newer Repeater") + #expect(result?.matchKind == .fallback) + } } diff --git a/MC1Tests/Utilities/URLSafetyCheckerTests.swift b/MC1Tests/Utilities/URLSafetyCheckerTests.swift index 10475c09..ee7bed49 100644 --- a/MC1Tests/Utilities/URLSafetyCheckerTests.swift +++ b/MC1Tests/Utilities/URLSafetyCheckerTests.swift @@ -1,211 +1,210 @@ -import Testing import Foundation @testable import MC1 +import Testing struct URLSafetyCheckerTests { - - // MARK: - Scheme Validation - - @Test("Allows HTTPS URLs") - func allowsHTTPS() async { - let url = URL(string: "https://example.com/page")! - let result = await URLSafetyChecker.isSafe(url) - #expect(result, "HTTPS should be allowed") - } - - @Test("Allows HTTP URLs") - func allowsHTTP() async { - let url = URL(string: "http://example.com/page")! - let result = await URLSafetyChecker.isSafe(url) - #expect(result, "HTTP should be allowed") - } - - @Test("Rejects FTP scheme") - func rejectsFTP() async { - let url = URL(string: "ftp://example.com/file")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "FTP should be rejected") - } - - @Test("Rejects file scheme") - func rejectsFile() async { - let url = URL(string: "file:///etc/passwd")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "file:// should be rejected") - } - - @Test("Rejects javascript scheme") - func rejectsJavascript() async { - let url = URL(string: "javascript:alert(1)")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "javascript: should be rejected") - } - - // MARK: - Host Validation - - @Test("Rejects URL with no host") - func rejectsNoHost() async { - let url = URL(string: "https://")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "URL with no host should be rejected") - } - - // MARK: - Allow-listed Hosts - - @Test("Allows media.giphy.com") - func allowsMediaGiphy() async { - let url = URL(string: "https://media.giphy.com/media/abc123/giphy.gif")! - let result = await URLSafetyChecker.isSafe(url) - #expect(result, "media.giphy.com should be allow-listed") - } - - @Test("Allows i.giphy.com") - func allowsIGiphy() async { - let url = URL(string: "https://i.giphy.com/media/abc123/giphy.gif")! - let result = await URLSafetyChecker.isSafe(url) - #expect(result, "i.giphy.com should be allow-listed") - } - - // MARK: - Private/Reserved IP Detection (IPv4) - - @Test("Detects loopback 127.0.0.1") - func detectsLoopback() { - #expect(URLSafetyChecker.isPrivateOrReserved("127.0.0.1")) - } - - @Test("Detects loopback 127.255.255.255") - func detectsLoopbackRange() { - #expect(URLSafetyChecker.isPrivateOrReserved("127.255.255.255")) - } - - @Test("Detects 10.0.0.0/8") - func detects10Network() { - #expect(URLSafetyChecker.isPrivateOrReserved("10.0.0.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("10.255.255.255")) - } - - @Test("Detects 172.16.0.0/12") - func detects172Network() { - #expect(URLSafetyChecker.isPrivateOrReserved("172.16.0.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("172.31.255.255")) - #expect(!URLSafetyChecker.isPrivateOrReserved("172.32.0.1"), "172.32.x.x is public") - } - - @Test("Detects 192.168.0.0/16") - func detects192Network() { - #expect(URLSafetyChecker.isPrivateOrReserved("192.168.0.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("192.168.255.255")) - } - - @Test("Detects link-local 169.254.0.0/16") - func detectsLinkLocal() { - #expect(URLSafetyChecker.isPrivateOrReserved("169.254.1.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("169.254.169.254"), "AWS metadata endpoint") - } - - @Test("Detects 0.0.0.0") - func detectsZeroAddress() { - #expect(URLSafetyChecker.isPrivateOrReserved("0.0.0.0")) - } - - @Test("Detects multicast 224.0.0.0/4") - func detectsMulticast() { - #expect(URLSafetyChecker.isPrivateOrReserved("224.0.0.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("239.255.255.255")) - } - - @Test("Detects reserved 240.0.0.0/4") - func detectsReserved() { - #expect(URLSafetyChecker.isPrivateOrReserved("240.0.0.1")) - #expect(URLSafetyChecker.isPrivateOrReserved("255.255.255.254")) - } - - @Test("Allows public IPv4 addresses") - func allowsPublicIPv4() { - #expect(!URLSafetyChecker.isPrivateOrReserved("8.8.8.8"), "Google DNS is public") - #expect(!URLSafetyChecker.isPrivateOrReserved("1.1.1.1"), "Cloudflare DNS is public") - #expect(!URLSafetyChecker.isPrivateOrReserved("93.184.216.34"), "example.com is public") - } - - // MARK: - Private/Reserved IP Detection (IPv6) - - @Test("Detects IPv6 loopback ::1") - func detectsIPv6Loopback() { - #expect(URLSafetyChecker.isPrivateOrReserved("::1")) - } - - @Test("Detects IPv6 unspecified ::") - func detectsIPv6Unspecified() { - #expect(URLSafetyChecker.isPrivateOrReserved("::")) - } - - @Test("Detects IPv6 link-local fe80::") - func detectsIPv6LinkLocal() { - #expect(URLSafetyChecker.isPrivateOrReserved("fe80::1")) - #expect(URLSafetyChecker.isPrivateOrReserved("fe80::abcd:ef01:2345:6789")) - } - - @Test("Detects IPv6 unique local fc00::/7") - func detectsIPv6UniqueLocal() { - #expect(URLSafetyChecker.isPrivateOrReserved("fc00::1")) - #expect(URLSafetyChecker.isPrivateOrReserved("fd00::1")) - } - - @Test("Detects IPv4-mapped IPv6 addresses") - func detectsIPv4MappedIPv6() { - #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:127.0.0.1"), "Mapped loopback") - #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:192.168.1.1"), "Mapped private") - #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:10.0.0.1"), "Mapped 10.x private") - #expect(!URLSafetyChecker.isPrivateOrReserved("::ffff:8.8.8.8"), "Mapped public should pass") - } - - @Test("Detects IPv6 multicast ff00::/8") - func detectsIPv6Multicast() { - #expect(URLSafetyChecker.isPrivateOrReserved("ff02::1")) - #expect(URLSafetyChecker.isPrivateOrReserved("ff05::2")) - } - - @Test("Allows public IPv6 addresses") - func allowsPublicIPv6() { - #expect(!URLSafetyChecker.isPrivateOrReserved("2001:4860:4860::8888"), "Google DNS v6") - #expect(!URLSafetyChecker.isPrivateOrReserved("2606:4700:4700::1111"), "Cloudflare v6") - } - - // MARK: - Non-IP Hostnames - - @Test("Non-IP strings are not private") - func nonIPNotPrivate() { - #expect(!URLSafetyChecker.isPrivateOrReserved("example.com")) - #expect(!URLSafetyChecker.isPrivateOrReserved("localhost")) - } - - // MARK: - IP Literal URLs - - @Test("Rejects URL with private IP literal") - func rejectsPrivateIPLiteral() async { - let url = URL(string: "http://192.168.1.1/admin")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "Private IP URL should be rejected") - } - - @Test("Rejects URL with loopback IP literal") - func rejectsLoopbackLiteral() async { - let url = URL(string: "http://127.0.0.1:8080/api")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "Loopback IP URL should be rejected") - } - - @Test("Rejects metadata endpoint") - func rejectsMetadataEndpoint() async { - let url = URL(string: "http://169.254.169.254/latest/meta-data/")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "AWS metadata endpoint should be rejected") - } - - @Test("Rejects private IP with port") - func rejectsPrivateIPWithPort() async { - let url = URL(string: "http://10.0.0.1:3000/api")! - let result = await URLSafetyChecker.isSafe(url) - #expect(!result, "Private IP with port should be rejected") - } + // MARK: - Scheme Validation + + @Test + func `Allows HTTPS URLs`() async throws { + let url = try #require(URL(string: "https://example.com/page")) + let result = await URLSafetyChecker.isSafe(url) + #expect(result, "HTTPS should be allowed") + } + + @Test + func `Allows HTTP URLs`() async throws { + let url = try #require(URL(string: "http://example.com/page")) + let result = await URLSafetyChecker.isSafe(url) + #expect(result, "HTTP should be allowed") + } + + @Test + func `Rejects FTP scheme`() async throws { + let url = try #require(URL(string: "ftp://example.com/file")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "FTP should be rejected") + } + + @Test + func `Rejects file scheme`() async throws { + let url = try #require(URL(string: "file:///etc/passwd")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "file:// should be rejected") + } + + @Test + func `Rejects javascript scheme`() async throws { + let url = try #require(URL(string: "javascript:alert(1)")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "javascript: should be rejected") + } + + // MARK: - Host Validation + + @Test + func `Rejects URL with no host`() async throws { + let url = try #require(URL(string: "https://")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "URL with no host should be rejected") + } + + // MARK: - Allow-listed Hosts + + @Test + func `Allows media.giphy.com`() async throws { + let url = try #require(URL(string: "https://media.giphy.com/media/abc123/giphy.gif")) + let result = await URLSafetyChecker.isSafe(url) + #expect(result, "media.giphy.com should be allow-listed") + } + + @Test + func `Allows i.giphy.com`() async throws { + let url = try #require(URL(string: "https://i.giphy.com/media/abc123/giphy.gif")) + let result = await URLSafetyChecker.isSafe(url) + #expect(result, "i.giphy.com should be allow-listed") + } + + // MARK: - Private/Reserved IP Detection (IPv4) + + @Test + func `Detects loopback 127.0.0.1`() { + #expect(URLSafetyChecker.isPrivateOrReserved("127.0.0.1")) + } + + @Test + func `Detects loopback 127.255.255.255`() { + #expect(URLSafetyChecker.isPrivateOrReserved("127.255.255.255")) + } + + @Test + func `Detects 10.0.0.0/8`() { + #expect(URLSafetyChecker.isPrivateOrReserved("10.0.0.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("10.255.255.255")) + } + + @Test + func `Detects 172.16.0.0/12`() { + #expect(URLSafetyChecker.isPrivateOrReserved("172.16.0.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("172.31.255.255")) + #expect(!URLSafetyChecker.isPrivateOrReserved("172.32.0.1"), "172.32.x.x is public") + } + + @Test + func `Detects 192.168.0.0/16`() { + #expect(URLSafetyChecker.isPrivateOrReserved("192.168.0.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("192.168.255.255")) + } + + @Test + func `Detects link-local 169.254.0.0/16`() { + #expect(URLSafetyChecker.isPrivateOrReserved("169.254.1.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("169.254.169.254"), "AWS metadata endpoint") + } + + @Test + func `Detects 0.0.0.0`() { + #expect(URLSafetyChecker.isPrivateOrReserved("0.0.0.0")) + } + + @Test + func `Detects multicast 224.0.0.0/4`() { + #expect(URLSafetyChecker.isPrivateOrReserved("224.0.0.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("239.255.255.255")) + } + + @Test + func `Detects reserved 240.0.0.0/4`() { + #expect(URLSafetyChecker.isPrivateOrReserved("240.0.0.1")) + #expect(URLSafetyChecker.isPrivateOrReserved("255.255.255.254")) + } + + @Test + func `Allows public IPv4 addresses`() { + #expect(!URLSafetyChecker.isPrivateOrReserved("8.8.8.8"), "Google DNS is public") + #expect(!URLSafetyChecker.isPrivateOrReserved("1.1.1.1"), "Cloudflare DNS is public") + #expect(!URLSafetyChecker.isPrivateOrReserved("93.184.216.34"), "example.com is public") + } + + // MARK: - Private/Reserved IP Detection (IPv6) + + @Test + func `Detects IPv6 loopback ::1`() { + #expect(URLSafetyChecker.isPrivateOrReserved("::1")) + } + + @Test + func `Detects IPv6 unspecified ::`() { + #expect(URLSafetyChecker.isPrivateOrReserved("::")) + } + + @Test + func `Detects IPv6 link-local fe80::`() { + #expect(URLSafetyChecker.isPrivateOrReserved("fe80::1")) + #expect(URLSafetyChecker.isPrivateOrReserved("fe80::abcd:ef01:2345:6789")) + } + + @Test + func `Detects IPv6 unique local fc00::/7`() { + #expect(URLSafetyChecker.isPrivateOrReserved("fc00::1")) + #expect(URLSafetyChecker.isPrivateOrReserved("fd00::1")) + } + + @Test + func `Detects IPv4-mapped IPv6 addresses`() { + #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:127.0.0.1"), "Mapped loopback") + #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:192.168.1.1"), "Mapped private") + #expect(URLSafetyChecker.isPrivateOrReserved("::ffff:10.0.0.1"), "Mapped 10.x private") + #expect(!URLSafetyChecker.isPrivateOrReserved("::ffff:8.8.8.8"), "Mapped public should pass") + } + + @Test + func `Detects IPv6 multicast ff00::/8`() { + #expect(URLSafetyChecker.isPrivateOrReserved("ff02::1")) + #expect(URLSafetyChecker.isPrivateOrReserved("ff05::2")) + } + + @Test + func `Allows public IPv6 addresses`() { + #expect(!URLSafetyChecker.isPrivateOrReserved("2001:4860:4860::8888"), "Google DNS v6") + #expect(!URLSafetyChecker.isPrivateOrReserved("2606:4700:4700::1111"), "Cloudflare v6") + } + + // MARK: - Non-IP Hostnames + + @Test + func `Non-IP strings are not private`() { + #expect(!URLSafetyChecker.isPrivateOrReserved("example.com")) + #expect(!URLSafetyChecker.isPrivateOrReserved("localhost")) + } + + // MARK: - IP Literal URLs + + @Test + func `Rejects URL with private IP literal`() async throws { + let url = try #require(URL(string: "http://192.168.1.1/admin")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "Private IP URL should be rejected") + } + + @Test + func `Rejects URL with loopback IP literal`() async throws { + let url = try #require(URL(string: "http://127.0.0.1:8080/api")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "Loopback IP URL should be rejected") + } + + @Test + func `Rejects metadata endpoint`() async throws { + let url = try #require(URL(string: "http://169.254.169.254/latest/meta-data/")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "AWS metadata endpoint should be rejected") + } + + @Test + func `Rejects private IP with port`() async throws { + let url = try #require(URL(string: "http://10.0.0.1:3000/api")) + let result = await URLSafetyChecker.isSafe(url) + #expect(!result, "Private IP with port should be rejected") + } } diff --git a/MC1Tests/ViewModels/AppBackupViewModelTests.swift b/MC1Tests/ViewModels/AppBackupViewModelTests.swift index 8b25394d..cedc9925 100644 --- a/MC1Tests/ViewModels/AppBackupViewModelTests.swift +++ b/MC1Tests/ViewModels/AppBackupViewModelTests.swift @@ -1,207 +1,205 @@ import Foundation -import Testing @testable import MC1 @testable import MC1Services @testable import MeshCore +import Testing @Suite("AppBackupViewModel — export success handling") @MainActor struct AppBackupViewModelExportTests { - - private func makeViewModel() throws -> AppBackupViewModel { - let container = try PersistenceStore.createContainer(inMemory: true) - let manager = ConnectionManager(modelContainer: container) - return AppBackupViewModel(connectionManager: manager) - } - - private func sampleManifest(messages: Int = 3, contacts: Int = 2) -> BackupManifest { - BackupManifest(contactCount: contacts, messageCount: messages) - } - - @Test("handleExportResult(.success) promotes pending to success") - func successPromotesToSummary() throws { - let vm = try makeViewModel() - let manifest = sampleManifest() - vm.exportState = .pending(AppBackupViewModel.PendingExport( - data: Data(repeating: 0xAB, count: 128), - manifest: manifest - )) - - let saveURL = URL(fileURLWithPath: "/tmp/MC1-backup-2026-04-19.mc1backup") - vm.handleExportResult(.success(saveURL)) - - #expect(vm.pendingExport == nil) - #expect(vm.exportSummary != nil) - #expect(vm.exportSummary?.filename == "MC1-backup-2026-04-19.mc1backup") - #expect(vm.exportSummary?.byteCount == 128) - #expect(vm.exportSummary?.manifest.messageCount == 3) - #expect(vm.exportSummary?.manifest.contactCount == 2) - #expect(vm.errorMessage == nil) - } - - @Test("handleExportResult(.failure(userCancelled)) returns to idle without errorMessage") - func userCancelIsSilent() throws { - let vm = try makeViewModel() - vm.exportState = .pending(AppBackupViewModel.PendingExport( - data: Data([0x01]), - manifest: sampleManifest() - )) - vm.handleExportResult(.failure(CocoaError(.userCancelled))) - - #expect(vm.pendingExport == nil) - #expect(vm.exportSummary == nil) - #expect(vm.errorMessage == nil) - } - - @Test("handleExportResult(.failure(other)) surfaces errorMessage") - func genericFailureSurfacesErrorMessage() throws { - let vm = try makeViewModel() - vm.exportState = .pending(AppBackupViewModel.PendingExport( - data: Data([0x01]), - manifest: sampleManifest() - )) - let saveError = NSError(domain: NSPOSIXErrorDomain, code: 28) // ENOSPC - - vm.handleExportResult(.failure(saveError)) - - #expect(vm.pendingExport == nil) - #expect(vm.exportSummary == nil) - #expect(vm.errorMessage != nil) - } - - @Test("handleExportResult(.success) with no pending export is a no-op") - func successWithoutPendingIsNoOp() throws { - let vm = try makeViewModel() - #expect(vm.pendingExport == nil) - - vm.handleExportResult(.success(URL(fileURLWithPath: "/tmp/x.mc1backup"))) - - #expect(vm.exportSummary == nil) - #expect(vm.errorMessage == nil) - } - - @Test("dismissExportSuccess clears the summary") - func dismissClearsSummary() throws { - let vm = try makeViewModel() - vm.exportState = .success(AppBackupViewModel.ExportSuccessSummary( - filename: "x.mc1backup", - byteCount: 1, - manifest: sampleManifest() - )) - - vm.dismissExportSuccess() - - #expect(vm.exportSummary == nil) - } + private func makeViewModel() throws -> AppBackupViewModel { + let container = try PersistenceStore.createContainer(inMemory: true) + let manager = ConnectionManager(modelContainer: container) + return AppBackupViewModel(connectionManager: manager) + } + + private func sampleManifest(messages: Int = 3, contacts: Int = 2) -> BackupManifest { + BackupManifest(contactCount: contacts, messageCount: messages) + } + + @Test + func `handleExportResult(.success) promotes pending to success`() throws { + let vm = try makeViewModel() + let manifest = sampleManifest() + vm.exportState = .pending(AppBackupViewModel.PendingExport( + data: Data(repeating: 0xAB, count: 128), + manifest: manifest + )) + + let saveURL = URL(fileURLWithPath: "/tmp/MC1-backup-2026-04-19.mc1backup") + vm.handleExportResult(.success(saveURL)) + + #expect(vm.pendingExport == nil) + #expect(vm.exportSummary != nil) + #expect(vm.exportSummary?.filename == "MC1-backup-2026-04-19.mc1backup") + #expect(vm.exportSummary?.byteCount == 128) + #expect(vm.exportSummary?.manifest.messageCount == 3) + #expect(vm.exportSummary?.manifest.contactCount == 2) + #expect(vm.errorMessage == nil) + } + + @Test + func `handleExportResult(.failure(userCancelled)) returns to idle without errorMessage`() throws { + let vm = try makeViewModel() + vm.exportState = .pending(AppBackupViewModel.PendingExport( + data: Data([0x01]), + manifest: sampleManifest() + )) + vm.handleExportResult(.failure(CocoaError(.userCancelled))) + + #expect(vm.pendingExport == nil) + #expect(vm.exportSummary == nil) + #expect(vm.errorMessage == nil) + } + + @Test + func `handleExportResult(.failure(other)) surfaces errorMessage`() throws { + let vm = try makeViewModel() + vm.exportState = .pending(AppBackupViewModel.PendingExport( + data: Data([0x01]), + manifest: sampleManifest() + )) + let saveError = NSError(domain: NSPOSIXErrorDomain, code: 28) // ENOSPC + + vm.handleExportResult(.failure(saveError)) + + #expect(vm.pendingExport == nil) + #expect(vm.exportSummary == nil) + #expect(vm.errorMessage != nil) + } + + @Test + func `handleExportResult(.success) with no pending export is a no-op`() throws { + let vm = try makeViewModel() + #expect(vm.pendingExport == nil) + + vm.handleExportResult(.success(URL(fileURLWithPath: "/tmp/x.mc1backup"))) + + #expect(vm.exportSummary == nil) + #expect(vm.errorMessage == nil) + } + + @Test + func `dismissExportSuccess clears the summary`() throws { + let vm = try makeViewModel() + vm.exportState = .success(AppBackupViewModel.ExportSuccessSummary( + filename: "x.mc1backup", + byteCount: 1, + manifest: sampleManifest() + )) + + vm.dismissExportSuccess() + + #expect(vm.exportSummary == nil) + } } @Suite("App Backup") @MainActor struct AppBackupViewModelTests { + @Test + func `Import picker cancellation does not surface an error`() throws { + let viewModel = try makeViewModel() - @Test("Import picker cancellation does not surface an error") - func handleFileSelected_IgnoresUserCancellation() throws { - let viewModel = try makeViewModel() + viewModel.handleFileSelected(result: .failure(CocoaError(.userCancelled))) - viewModel.handleFileSelected(result: .failure(CocoaError(.userCancelled))) + #expect(viewModel.errorMessage == nil) + } - #expect(viewModel.errorMessage == nil) - } + @Test + func `Readable backup URLs load even when no security scope is granted`() async throws { + let viewModel = try makeViewModel() + let backupURL = try makeReadableBackupURL() - @Test("Readable backup URLs load even when no security scope is granted") - func loadAndParseBackup_AcceptsReadableURLWithoutSecurityScope() async throws { - let viewModel = try makeViewModel() - let backupURL = try makeReadableBackupURL() + viewModel.loadAndParseBackup(from: backupURL) - viewModel.loadAndParseBackup(from: backupURL) - - try await waitUntil("Backup preview never loaded") { - viewModel.previewEnvelope != nil || viewModel.errorMessage != nil - } - - let previewEnvelope = try #require(viewModel.previewEnvelope) - #expect(viewModel.errorMessage == nil) - #expect(previewEnvelope.manifest == BackupManifest()) - } - - @Test("Export uses the active services store when one is available") - func performExport_PrefersActiveServicesStore() async throws { - let manager = ConnectionManager(modelContainer: try PersistenceStore.createContainer(inMemory: true)) - let radioID = UUID() - let services = ServiceContainer( - session: MeshCoreSession(transport: MockTransport()), - modelContainer: try PersistenceStore.createContainer(inMemory: true), - radioID: radioID - ) - try await services.dataStore.saveContact( - ContactDTO( - id: UUID(), - radioID: radioID, - publicKey: Data(repeating: 0xAB, count: 32), - name: "Backed Up Contact", - typeRawValue: ContactType.chat.rawValue, - flags: 0, - outPathLength: 0, - outPath: Data(), - lastAdvertTimestamp: 0, - latitude: 0, - longitude: 0, - lastModified: 0, - nickname: nil, - isBlocked: false, - isMuted: false, - isFavorite: false, - lastMessageDate: nil, - unreadCount: 0 - ) - ) - manager.setTestState(services: services) - - let viewModel = AppBackupViewModel(connectionManager: manager) - viewModel.performExport() - - try await waitUntil("Backup export never completed") { - viewModel.pendingExport != nil || viewModel.errorMessage != nil - } - - let pending = try #require(viewModel.pendingExport) - let envelope = try parseBackup(data: pending.data) - #expect(viewModel.errorMessage == nil) - #expect(envelope.contacts.count == 1) - #expect(envelope.contacts.first?.name == "Backed Up Contact") + try await waitUntil("Backup preview never loaded") { + viewModel.previewEnvelope != nil || viewModel.errorMessage != nil } - @Test("performImport aborts and dismisses when the radio is connected") - func performImportAbortsWhenConnected() async throws { - // The VM's `connectionManager` is private, so drive connection state on the manager - // before constructing the VM, using the existing test seam. - let container = try PersistenceStore.createContainer(inMemory: true) - let manager = ConnectionManager(modelContainer: container) - manager.setTestState(connectionState: .ready) - let vm = AppBackupViewModel(connectionManager: manager) - let envelope = AppBackupEnvelope(appVersion: "test", appBuild: "1", manifest: BackupManifest()) - vm.importState = .preview(envelope) - vm.performImport() - // Aborted: no import ran and the sheet was dismissed back to idle. - #expect(vm.previewEnvelope == nil) - if case .importing = vm.importState { Issue.record("import ran while connected") } + let previewEnvelope = try #require(viewModel.previewEnvelope) + #expect(viewModel.errorMessage == nil) + #expect(previewEnvelope.manifest == BackupManifest()) + } + + @Test + func `Export uses the active services store when one is available`() async throws { + let manager = try ConnectionManager(modelContainer: PersistenceStore.createContainer(inMemory: true)) + let radioID = UUID() + let services = try ServiceContainer( + session: MeshCoreSession(transport: MockTransport()), + modelContainer: PersistenceStore.createContainer(inMemory: true), + radioID: radioID + ) + try await services.dataStore.saveContact( + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0xAB, count: 32), + name: "Backed Up Contact", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + ) + manager.setTestState(services: services) + + let viewModel = AppBackupViewModel(connectionManager: manager) + viewModel.performExport() + + try await waitUntil("Backup export never completed") { + viewModel.pendingExport != nil || viewModel.errorMessage != nil } - private func makeViewModel() throws -> AppBackupViewModel { - let container = try PersistenceStore.createContainer(inMemory: true) - let manager = ConnectionManager(modelContainer: container) - return AppBackupViewModel(connectionManager: manager) - } - - private func makeReadableBackupURL() throws -> URL { - let envelope = AppBackupEnvelope( - appVersion: "test", - appBuild: "1", - manifest: BackupManifest() - ) - let jsonData = try makeBackupJSONEncoder().encode(envelope) - let compressed = try jsonData.zlibCompressed() - let encodedData = compressed.base64EncodedString() - return try #require(URL(string: "data:application/octet-stream;base64,\(encodedData)")) - } + let pending = try #require(viewModel.pendingExport) + let envelope = try parseBackup(data: pending.data) + #expect(viewModel.errorMessage == nil) + #expect(envelope.contacts.count == 1) + #expect(envelope.contacts.first?.name == "Backed Up Contact") + } + + @Test + func `performImport aborts and dismisses when the radio is connected`() throws { + // The VM's `connectionManager` is private, so drive connection state on the manager + // before constructing the VM, using the existing test seam. + let container = try PersistenceStore.createContainer(inMemory: true) + let manager = ConnectionManager(modelContainer: container) + manager.setTestState(connectionState: .ready) + let vm = AppBackupViewModel(connectionManager: manager) + let envelope = AppBackupEnvelope(appVersion: "test", appBuild: "1", manifest: BackupManifest()) + vm.importState = .preview(envelope) + vm.performImport() + // Aborted: no import ran and the sheet was dismissed back to idle. + #expect(vm.previewEnvelope == nil) + if case .importing = vm.importState { Issue.record("import ran while connected") } + } + + private func makeViewModel() throws -> AppBackupViewModel { + let container = try PersistenceStore.createContainer(inMemory: true) + let manager = ConnectionManager(modelContainer: container) + return AppBackupViewModel(connectionManager: manager) + } + + private func makeReadableBackupURL() throws -> URL { + let envelope = AppBackupEnvelope( + appVersion: "test", + appBuild: "1", + manifest: BackupManifest() + ) + let jsonData = try makeBackupJSONEncoder().encode(envelope) + let compressed = try jsonData.zlibCompressed() + let encodedData = compressed.base64EncodedString() + return try #require(URL(string: "data:application/octet-stream;base64,\(encodedData)")) + } } diff --git a/MC1Tests/ViewModels/BatchTraceTests.swift b/MC1Tests/ViewModels/BatchTraceTests.swift index f48a1160..b81b4c94 100644 --- a/MC1Tests/ViewModels/BatchTraceTests.swift +++ b/MC1Tests/ViewModels/BatchTraceTests.swift @@ -1,333 +1,329 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services @testable import MeshCore +import Testing // MARK: - Test Helpers private func createTestHop(snr: Double, isStartNode: Bool = false, isEndNode: Bool = false) -> TraceHop { - TraceHop( - hashBytes: isStartNode || isEndNode ? nil : Data([0xAA]), - resolvedName: nil, - snr: snr, - isStartNode: isStartNode, - isEndNode: isEndNode, - latitude: nil, - longitude: nil - ) + TraceHop( + hashBytes: isStartNode || isEndNode ? nil : Data([0xAA]), + resolvedName: nil, + snr: snr, + isStartNode: isStartNode, + isEndNode: isEndNode, + latitude: nil, + longitude: nil + ) } private func createTestResult(hopSNRs: [Double], durationMs: Int, success: Bool = true) -> TraceResult { - var hops: [TraceHop] = [createTestHop(snr: 0, isStartNode: true)] - for snr in hopSNRs { - hops.append(createTestHop(snr: snr)) - } - hops.append(createTestHop(snr: hopSNRs.last ?? 0, isEndNode: true)) - - return TraceResult( - hops: hops, - durationMs: durationMs, - success: success, - errorMessage: success ? nil : "Failed", - tracedPathBytes: [0xAA], - hashSize: 1 - ) + var hops: [TraceHop] = [createTestHop(snr: 0, isStartNode: true)] + for snr in hopSNRs { + hops.append(createTestHop(snr: snr)) + } + hops.append(createTestHop(snr: hopSNRs.last ?? 0, isEndNode: true)) + + return TraceResult( + hops: hops, + durationMs: durationMs, + success: success, + errorMessage: success ? nil : "Failed", + tracedPathBytes: [0xAA], + hashSize: 1 + ) } @Suite("Batch Trace State") @MainActor struct BatchTraceStateTests { + @Test + func `batch properties have correct defaults`() { + let viewModel = TracePathViewModel() + + #expect(viewModel.batchEnabled == false) + #expect(viewModel.batchSize == 5) + #expect(viewModel.currentTraceIndex == 0) + #expect(viewModel.completedResults.isEmpty) + #expect(viewModel.isBatchInProgress == false) + #expect(viewModel.isBatchComplete == false) + } + + @Test + func `successfulResults filters to successful traces only`() { + let viewModel = TracePathViewModel() + + let successResult = TraceResult( + hops: [], + durationMs: 100, + success: true, + errorMessage: nil, + tracedPathBytes: [0xAA], + hashSize: 1 + ) + let failedResult = TraceResult( + hops: [], + durationMs: 0, + success: false, + errorMessage: "Timeout", + tracedPathBytes: [0xAA], + hashSize: 1 + ) + + viewModel.completedResults = [successResult, failedResult, successResult] + + #expect(viewModel.successfulResults.count == 2) + } - @Test("batch properties have correct defaults") - func batchPropertiesHaveCorrectDefaults() { - let viewModel = TracePathViewModel() - - #expect(viewModel.batchEnabled == false) - #expect(viewModel.batchSize == 5) - #expect(viewModel.currentTraceIndex == 0) - #expect(viewModel.completedResults.isEmpty) - #expect(viewModel.isBatchInProgress == false) - #expect(viewModel.isBatchComplete == false) - } - - @Test("successfulResults filters to successful traces only") - func successfulResultsFiltersCorrectly() { - let viewModel = TracePathViewModel() - - let successResult = TraceResult( - hops: [], - durationMs: 100, - success: true, - errorMessage: nil, - tracedPathBytes: [0xAA], - hashSize: 1 - ) - let failedResult = TraceResult( - hops: [], - durationMs: 0, - success: false, - errorMessage: "Timeout", - tracedPathBytes: [0xAA], - hashSize: 1 - ) - - viewModel.completedResults = [successResult, failedResult, successResult] - - #expect(viewModel.successfulResults.count == 2) - } - - @Test("successCount returns number of successful traces") - func successCountReturnsCorrectValue() { - let viewModel = TracePathViewModel() - - let successResult = TraceResult( - hops: [], - durationMs: 100, - success: true, - errorMessage: nil, - tracedPathBytes: [0xAA], - hashSize: 1 - ) - let failedResult = TraceResult( - hops: [], - durationMs: 0, - success: false, - errorMessage: "Timeout", - tracedPathBytes: [0xAA], - hashSize: 1 - ) - - viewModel.completedResults = [successResult, failedResult, successResult] - - #expect(viewModel.successCount == 2) - } - - @Test("batchEnabled didSet clears batch state when disabled") - func batchEnabledDidSetClearsBatchState() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.currentTraceIndex = 3 - viewModel.completedResults = [ - TraceResult(hops: [], durationMs: 100, success: true, errorMessage: nil, tracedPathBytes: [0xAA], hashSize: 1) - ] - - viewModel.batchEnabled = false - - #expect(viewModel.currentTraceIndex == 0) - #expect(viewModel.completedResults.isEmpty) - } + @Test + func `successCount returns number of successful traces`() { + let viewModel = TracePathViewModel() + + let successResult = TraceResult( + hops: [], + durationMs: 100, + success: true, + errorMessage: nil, + tracedPathBytes: [0xAA], + hashSize: 1 + ) + let failedResult = TraceResult( + hops: [], + durationMs: 0, + success: false, + errorMessage: "Timeout", + tracedPathBytes: [0xAA], + hashSize: 1 + ) + + viewModel.completedResults = [successResult, failedResult, successResult] + + #expect(viewModel.successCount == 2) + } + + @Test + func `batchEnabled didSet clears batch state when disabled`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.currentTraceIndex = 3 + viewModel.completedResults = [ + TraceResult(hops: [], durationMs: 100, success: true, errorMessage: nil, tracedPathBytes: [0xAA], hashSize: 1) + ] + + viewModel.batchEnabled = false + + #expect(viewModel.currentTraceIndex == 0) + #expect(viewModel.completedResults.isEmpty) + } } @Suite("Batch Aggregate Computation") @MainActor struct BatchAggregateTests { - - @Test("RTT aggregates compute correctly") - func rttAggregatesComputeCorrectly() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100), - createTestResult(hopSNRs: [5.0], durationMs: 200), - createTestResult(hopSNRs: [5.0], durationMs: 150) - ] - - #expect(viewModel.averageRTT == 150) - #expect(viewModel.minRTT == 100) - #expect(viewModel.maxRTT == 200) - } - - @Test("RTT aggregates exclude failed traces") - func rttAggregatesExcludeFailedTraces() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100), - createTestResult(hopSNRs: [], durationMs: 0, success: false), - createTestResult(hopSNRs: [5.0], durationMs: 200) - ] - - #expect(viewModel.averageRTT == 150) - #expect(viewModel.minRTT == 100) - #expect(viewModel.maxRTT == 200) - } - - @Test("RTT aggregates return nil when no successful traces") - func rttAggregatesReturnNilWhenNoSuccess() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - viewModel.completedResults = [ - createTestResult(hopSNRs: [], durationMs: 0, success: false) - ] - - #expect(viewModel.averageRTT == nil) - #expect(viewModel.minRTT == nil) - #expect(viewModel.maxRTT == nil) - } - - @Test("hop stats compute correctly for intermediate hops") - func hopStatsComputeCorrectly() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - // 2 intermediate hops per result - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0, 3.0], durationMs: 100), - createTestResult(hopSNRs: [7.0, 1.0], durationMs: 100), - createTestResult(hopSNRs: [6.0, 2.0], durationMs: 100) - ] - - // Hop index 1 is first intermediate hop (index 0 is start node) - let stats1 = viewModel.hopStats(at: 1) - #expect(stats1 != nil) - #expect(stats1?.avg == 6.0) // (5+7+6)/3 - #expect(stats1?.min == 5.0) - #expect(stats1?.max == 7.0) - - // Hop index 2 is second intermediate hop - let stats2 = viewModel.hopStats(at: 2) - #expect(stats2 != nil) - #expect(stats2?.avg == 2.0) // (3+1+2)/3 - #expect(stats2?.min == 1.0) - #expect(stats2?.max == 3.0) - } - - @Test("hop stats return nil for start node") - func hopStatsReturnNilForStartNode() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100) - ] - - #expect(viewModel.hopStats(at: 0) == nil) - } - - @Test("latestHopSNR returns SNR from most recent successful result") - func latestHopSNRReturnsCorrectValue() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100), - createTestResult(hopSNRs: [7.0], durationMs: 100) - ] - - #expect(viewModel.latestHopSNR(at: 1) == 7.0) - } + @Test + func `RTT aggregates compute correctly`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100), + createTestResult(hopSNRs: [5.0], durationMs: 200), + createTestResult(hopSNRs: [5.0], durationMs: 150) + ] + + #expect(viewModel.averageRTT == 150) + #expect(viewModel.minRTT == 100) + #expect(viewModel.maxRTT == 200) + } + + @Test + func `RTT aggregates exclude failed traces`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100), + createTestResult(hopSNRs: [], durationMs: 0, success: false), + createTestResult(hopSNRs: [5.0], durationMs: 200) + ] + + #expect(viewModel.averageRTT == 150) + #expect(viewModel.minRTT == 100) + #expect(viewModel.maxRTT == 200) + } + + @Test + func `RTT aggregates return nil when no successful traces`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + viewModel.completedResults = [ + createTestResult(hopSNRs: [], durationMs: 0, success: false) + ] + + #expect(viewModel.averageRTT == nil) + #expect(viewModel.minRTT == nil) + #expect(viewModel.maxRTT == nil) + } + + @Test + func `hop stats compute correctly for intermediate hops`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + // 2 intermediate hops per result + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0, 3.0], durationMs: 100), + createTestResult(hopSNRs: [7.0, 1.0], durationMs: 100), + createTestResult(hopSNRs: [6.0, 2.0], durationMs: 100) + ] + + // Hop index 1 is first intermediate hop (index 0 is start node) + let stats1 = viewModel.hopStats(at: 1) + #expect(stats1 != nil) + #expect(stats1?.avg == 6.0) // (5+7+6)/3 + #expect(stats1?.min == 5.0) + #expect(stats1?.max == 7.0) + + // Hop index 2 is second intermediate hop + let stats2 = viewModel.hopStats(at: 2) + #expect(stats2 != nil) + #expect(stats2?.avg == 2.0) // (3+1+2)/3 + #expect(stats2?.min == 1.0) + #expect(stats2?.max == 3.0) + } + + @Test + func `hop stats return nil for start node`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100) + ] + + #expect(viewModel.hopStats(at: 0) == nil) + } + + @Test + func `latestHopSNR returns SNR from most recent successful result`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100), + createTestResult(hopSNRs: [7.0], durationMs: 100) + ] + + #expect(viewModel.latestHopSNR(at: 1) == 7.0) + } } @Suite("Batch Execution") @MainActor struct BatchExecutionTests { - - @Test("runBatchTrace resets batch state before starting") - func runBatchTraceResetsBatchState() async { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.batchSize = 3 - - // Pre-populate with stale data - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100) - ] - viewModel.currentTraceIndex = 2 - - // Can't actually run without appState, but we can verify reset happens - await viewModel.runBatchTrace() - - // Should have reset (even though trace won't actually run without appState) - #expect(viewModel.completedResults.isEmpty) - #expect(viewModel.currentTraceIndex == 0) - } - - @Test("clearBatchState resets all batch properties") - func clearBatchStateResetsAll() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.currentTraceIndex = 3 - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100) - ] - - viewModel.clearBatchState() - - #expect(viewModel.currentTraceIndex == 0) - #expect(viewModel.completedResults.isEmpty) - } - - @Test("cancelBatchTrace clears running state") - func cancelBatchTraceClearsRunningState() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.isRunning = true - viewModel.currentTraceIndex = 2 - viewModel.setPendingTagForTesting(12345) - - viewModel.cancelBatchTrace() - - #expect(viewModel.isRunning == false) - #expect(viewModel.currentTraceIndex == 0) - } - - @Test("cancelBatchTrace resumes pending continuation") - func cancelBatchTraceResumesContinuation() async { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - - // This test verifies the continuation isn't leaked - // (actual continuation behavior requires integration test) - viewModel.cancelBatchTrace() - - // Should not crash or hang - continuation was properly cleaned up - #expect(viewModel.isRunning == false) - } + @Test + func `runBatchTrace resets batch state before starting`() async { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.batchSize = 3 + + // Pre-populate with stale data + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100) + ] + viewModel.currentTraceIndex = 2 + + // Can't actually run without appState, but we can verify reset happens + await viewModel.runBatchTrace() + + // Should have reset (even though trace won't actually run without appState) + #expect(viewModel.completedResults.isEmpty) + #expect(viewModel.currentTraceIndex == 0) + } + + @Test + func `clearBatchState resets all batch properties`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.currentTraceIndex = 3 + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100) + ] + + viewModel.clearBatchState() + + #expect(viewModel.currentTraceIndex == 0) + #expect(viewModel.completedResults.isEmpty) + } + + @Test + func `cancelBatchTrace clears running state`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.isRunning = true + viewModel.currentTraceIndex = 2 + viewModel.setPendingTagForTesting(12345) + + viewModel.cancelBatchTrace() + + #expect(viewModel.isRunning == false) + #expect(viewModel.currentTraceIndex == 0) + } + + @Test + func `cancelBatchTrace resumes pending continuation`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + + // This test verifies the continuation isn't leaked + // (actual continuation behavior requires integration test) + viewModel.cancelBatchTrace() + + // Should not crash or hang - continuation was properly cleaned up + #expect(viewModel.isRunning == false) + } } @Suite("Batch Cancellation Behavior") @MainActor struct BatchCancellationTests { - - @Test("isBatchInProgress returns false after cancel") - func isBatchInProgressFalseAfterCancel() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.batchSize = 5 - viewModel.currentTraceIndex = 2 - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100) - ] - - // Verify it's in progress - #expect(viewModel.isBatchInProgress == true) - - viewModel.cancelBatchTrace() - - // Should no longer be in progress - #expect(viewModel.isBatchInProgress == false) - } - - @Test("batch state preserved on cancel for partial results access") - func batchStatePreservedOnCancel() { - let viewModel = TracePathViewModel() - viewModel.batchEnabled = true - viewModel.batchSize = 5 - viewModel.completedResults = [ - createTestResult(hopSNRs: [5.0], durationMs: 100), - createTestResult(hopSNRs: [6.0], durationMs: 110) - ] - viewModel.currentTraceIndex = 3 - - viewModel.cancelBatchTrace() - - // Completed results should be preserved for viewing - #expect(viewModel.completedResults.count == 2) - // But execution state should be reset - #expect(viewModel.currentTraceIndex == 0) - } + @Test + func `isBatchInProgress returns false after cancel`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.batchSize = 5 + viewModel.currentTraceIndex = 2 + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100) + ] + + // Verify it's in progress + #expect(viewModel.isBatchInProgress == true) + + viewModel.cancelBatchTrace() + + // Should no longer be in progress + #expect(viewModel.isBatchInProgress == false) + } + + @Test + func `batch state preserved on cancel for partial results access`() { + let viewModel = TracePathViewModel() + viewModel.batchEnabled = true + viewModel.batchSize = 5 + viewModel.completedResults = [ + createTestResult(hopSNRs: [5.0], durationMs: 100), + createTestResult(hopSNRs: [6.0], durationMs: 110) + ] + viewModel.currentTraceIndex = 3 + + viewModel.cancelBatchTrace() + + // Completed results should be preserved for viewing + #expect(viewModel.completedResults.count == 2) + // But execution state should be reset + #expect(viewModel.currentTraceIndex == 0) + } } diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index 471937d4..1aebf085 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -1,122 +1,122 @@ -import Testing import Foundation -import SwiftData @testable import MC1 @testable import MC1Services +import SwiftData +import Testing // MARK: - Test Helpers private func createTestContact( - id: UUID = UUID(), - radioID: UUID, - name: String = "TestContact" + id: UUID = UUID(), + radioID: UUID, + name: String = "TestContact" ) -> ContactDTO { - ContactDTO( - id: id, - radioID: radioID, - publicKey: Data((0.. ChannelDTO { - ChannelDTO( - id: id, - radioID: radioID, - index: index, - name: name, - secret: Data(), - isEnabled: true, - lastMessageDate: Date(), - unreadCount: 0, - unreadMentionCount: 0, - notificationLevel: .all, - isFavorite: false - ) + ChannelDTO( + id: id, + radioID: radioID, + index: index, + name: name, + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) } private func createTestMessage( - contactID: UUID, - radioID: UUID, - timestamp: UInt32, - createdAt: Date = Date(), - direction: MessageDirection = .incoming, - text: String = "Test message" + contactID: UUID, + radioID: UUID, + timestamp: UInt32, + createdAt: Date = Date(), + direction: MessageDirection = .incoming, + text: String = "Test message" ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: radioID, - contactID: contactID, - channelIndex: nil, - text: text, - timestamp: timestamp, - createdAt: createdAt, - direction: direction, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: nil, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: text, + timestamp: timestamp, + createdAt: createdAt, + direction: direction, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) } private func createChannelMessage( - radioID: UUID, - channelIndex: UInt8, - timestamp: UInt32, - senderName: String = "Sender", - text: String = "Test message" + radioID: UUID, + channelIndex: UInt8, + timestamp: UInt32, + senderName: String = "Sender", + text: String = "Test message" ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: radioID, - contactID: nil, - channelIndex: channelIndex, - text: text, - timestamp: timestamp, - createdAt: Date(), - direction: .incoming, - status: .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, - senderNodeName: senderName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: channelIndex, + text: text, + timestamp: timestamp, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) } // MARK: - Mock DataStore for Pagination Testing @@ -124,339 +124,531 @@ private func createChannelMessage( /// A minimal mock data store for testing pagination behavior. /// Uses in-memory storage and allows configuring responses. actor PaginationTestDataStore: PersistenceStoreProtocol { - var messages: [UUID: MessageDTO] = [:] - var contacts: [UUID: ContactDTO] = [:] - var channels: [UUID: ChannelDTO] = [:] - var blockedContacts: [ContactDTO] = [] + var messages: [UUID: MessageDTO] = [:] + var contacts: [UUID: ContactDTO] = [:] + var channels: [UUID: ChannelDTO] = [:] + var blockedContacts: [ContactDTO] = [] - var stubbedFetchError: Error? + var stubbedFetchError: Error? - init() {} + init() {} - // MARK: - Message Operations + // MARK: - Message Operations - func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} + func setInboundHopCount(radioID: UUID, publicKey: Data, hopCount: Int, advertTimestamp: UInt32?) async throws {} - func saveMessage(_ dto: MessageDTO) async throws { - messages[dto.id] = dto - } + func saveMessage(_ dto: MessageDTO) async throws { + messages[dto.id] = dto + } - func fetchMessage(id: UUID) async throws -> MessageDTO? { - messages[id] - } + func fetchMessage(id: UUID) async throws -> MessageDTO? { + messages[id] + } - func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { - var result: [UUID: [MessageDTO]] = [:] - for contactID in contactIDs { - let filtered = messages.values.filter { $0.contactID == contactID } - .sorted { $0.timestamp < $1.timestamp } - result[contactID] = Array(filtered.prefix(limit)) - } - return result + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + var result: [UUID: [MessageDTO]] = [:] + for contactID in contactIDs { + let filtered = messages.values.filter { $0.contactID == contactID } + .sorted { $0.timestamp < $1.timestamp } + result[contactID] = Array(filtered.prefix(limit)) } - - func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { - var result: [UUID: [MessageDTO]] = [:] - for channel in channels { - let filtered = messages.values.filter { $0.radioID == channel.radioID && $0.channelIndex == channel.channelIndex } - .sorted { $0.timestamp < $1.timestamp } - result[channel.id] = Array(filtered.prefix(limit)) - } - return result + return result + } + + func fetchLastChannelMessages(channels: [(radioID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + var result: [UUID: [MessageDTO]] = [:] + for channel in channels { + let filtered = messages.values.filter { $0.radioID == channel.radioID && $0.channelIndex == channel.channelIndex } + .sorted { $0.timestamp < $1.timestamp } + result[channel.id] = Array(filtered.prefix(limit)) } + return result + } - func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { - if let error = stubbedFetchError { - throw error - } - // Match production: sort descending (newest first), apply offset/limit, then reverse to ascending - let filtered = messages.values.filter { $0.contactID == contactID } - .sorted { $0.timestamp > $1.timestamp } - return Array(filtered.dropFirst(offset).prefix(limit).reversed()) + func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { + if let error = stubbedFetchError { + throw error } - - func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { - if let error = stubbedFetchError { - throw error - } - // Match production: sort descending (newest first), apply offset/limit, then reverse to ascending - let filtered = messages.values.filter { $0.radioID == radioID && $0.channelIndex == channelIndex } - .sorted { $0.timestamp > $1.timestamp } - return Array(filtered.dropFirst(offset).prefix(limit).reversed()) + // Match production: sort descending (newest first), apply offset/limit, then reverse to ascending + let filtered = messages.values.filter { $0.contactID == contactID } + .sorted { $0.timestamp > $1.timestamp } + return Array(filtered.dropFirst(offset).prefix(limit).reversed()) + } + + func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { + if let error = stubbedFetchError { + throw error } + // Match production: sort descending (newest first), apply offset/limit, then reverse to ascending + let filtered = messages.values.filter { $0.radioID == radioID && $0.channelIndex == channelIndex } + .sorted { $0.timestamp > $1.timestamp } + return Array(filtered.dropFirst(offset).prefix(limit).reversed()) + } + + func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} + func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} + func updateMessageRetryStatus( + id: UUID, + status: MessageStatus, + retryAttempt: Int, + maxRetryAttempts: Int + ) async throws {} + func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} + func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} + func updateMessageLinkPreview( + id: UUID, + url: String?, + title: String?, + imageData: Data?, + iconData: Data?, + fetched: Bool + ) throws {} + + // MARK: - Contact Operations + + func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { + contacts.values.filter { $0.radioID == radioID } + } + + func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { + contacts.values.filter { $0.radioID == radioID && $0.lastMessageDate != nil } + } + + func fetchContact(id: UUID) async throws -> ContactDTO? { + contacts[id] + } + + func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { + contacts.values.first { $0.radioID == radioID && $0.publicKey == publicKey } + } + + func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { + contacts.values.first { $0.radioID == radioID && $0.publicKey.prefix(6) == publicKeyPrefix } + } + + func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { + [:] + } + + @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { + UUID() + } + + func saveContact(_ dto: ContactDTO) async throws { + contacts[dto.id] = dto + } + + func deleteContact(id: UUID) async throws { + contacts.removeValue(forKey: id) + } + + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} + func incrementUnreadCount(contactID: UUID) async throws {} + func clearUnreadCount(contactID: UUID) async throws {} + + // MARK: - Mention Tracking + + func markMentionSeen(messageID: UUID) async throws {} + func incrementUnreadMentionCount(contactID: UUID) async throws {} + func decrementUnreadMentionCount(contactID: UUID) async throws {} + func clearUnreadMentionCount(contactID: UUID) async throws {} + func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} + func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} + func clearChannelUnreadMentionCount(channelID: UUID) async throws {} + func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { + [] + } + + func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { + [] + } + + func deleteMessagesForContact(contactID: UUID) async throws {} + func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { + blockedContacts.filter { $0.radioID == radioID } + } + + // MARK: - Blocked Channel Senders + + func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} + func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} + func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} + func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { + [] + } + + // MARK: - Channel Operations + + func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { + channels.values.filter { $0.radioID == radioID }.sorted { $0.index < $1.index } + } + + func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { + channels.values.first { $0.radioID == radioID && $0.index == index } + } + + func fetchChannel(id: UUID) async throws -> ChannelDTO? { + channels[id] + } + + @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { + UUID() + } + + func saveChannel(_ dto: ChannelDTO) async throws { + channels[dto.id] = dto + } + + func deleteChannel(id: UUID) async throws { + channels.removeValue(forKey: id) + } - func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} - func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} - func updateMessageRetryStatus( - id: UUID, - status: MessageStatus, - retryAttempt: Int, - maxRetryAttempts: Int - ) async throws {} - func updateMessageTimestamp(id: UUID, timestamp: UInt32) async throws {} - func updateMessageHeardRepeats(id: UUID, heardRepeats: Int) async throws {} - func updateMessageLinkPreview( - id: UUID, - url: String?, - title: String?, - imageData: Data?, - iconData: Data?, - fetched: Bool - ) throws {} - - // MARK: - Contact Operations - - func fetchContacts(radioID: UUID) async throws -> [ContactDTO] { - contacts.values.filter { $0.radioID == radioID } - } + func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} + func incrementChannelUnreadCount(channelID: UUID) async throws {} + func clearChannelUnreadCount(channelID: UUID) async throws {} + func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} + + // MARK: - Saved Trace Paths - func fetchConversations(radioID: UUID) async throws -> [ContactDTO] { - contacts.values.filter { $0.radioID == radioID && $0.lastMessageDate != nil } - } + func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { + [] + } - func fetchContact(id: UUID) async throws -> ContactDTO? { - contacts[id] - } + func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { + nil + } - func fetchContact(radioID: UUID, publicKey: Data) async throws -> ContactDTO? { - contacts.values.first { $0.radioID == radioID && $0.publicKey == publicKey } - } + func createSavedTracePath( + radioID: UUID, + name: String, + pathBytes: Data, + hashSize: Int, + initialRun: TracePathRunDTO? + ) async throws -> SavedTracePathDTO { + SavedTracePathDTO( + id: UUID(), + radioID: radioID, + name: name, + pathBytes: pathBytes, + hashSize: hashSize, + createdDate: Date(), + runs: [] + ) + } - func fetchContact(radioID: UUID, publicKeyPrefix: Data) async throws -> ContactDTO? { - contacts.values.first { $0.radioID == radioID && $0.publicKey.prefix(6) == publicKeyPrefix } - } + func updateSavedTracePathName(id: UUID, name: String) async throws {} + func deleteSavedTracePath(id: UUID) async throws {} + func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} - func fetchContactPublicKeysByPrefix(radioID: UUID) async throws -> [UInt8: [Data]] { [:] } - @discardableResult func saveContact(radioID: UUID, from frame: ContactFrame) async throws -> UUID { UUID() } - func saveContact(_ dto: ContactDTO) async throws { contacts[dto.id] = dto } - func deleteContact(id: UUID) async throws { contacts.removeValue(forKey: id) } - func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} - func incrementUnreadCount(contactID: UUID) async throws {} - func clearUnreadCount(contactID: UUID) async throws {} - - // MARK: - Mention Tracking - - func markMentionSeen(messageID: UUID) async throws {} - func incrementUnreadMentionCount(contactID: UUID) async throws {} - func decrementUnreadMentionCount(contactID: UUID) async throws {} - func clearUnreadMentionCount(contactID: UUID) async throws {} - func incrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func decrementChannelUnreadMentionCount(channelID: UUID) async throws {} - func clearChannelUnreadMentionCount(channelID: UUID) async throws {} - func fetchUnseenMentionIDs(contactID: UUID) async throws -> [UUID] { [] } - func fetchUnseenChannelMentionIDs(radioID: UUID, channelIndex: UInt8) async throws -> [UUID] { [] } - func deleteMessagesForContact(contactID: UUID) async throws {} - func fetchBlockedContacts(radioID: UUID) async throws -> [ContactDTO] { - blockedContacts.filter { $0.radioID == radioID } - } + // MARK: - Heard Repeats - // MARK: - Blocked Channel Senders + func findSentChannelMessage( + radioID: UUID, + channelIndex: UInt8, + timestamp: UInt32, + text: String, + withinSeconds: Int + ) async throws -> MessageDTO? { + nil + } - func saveBlockedChannelSender(_ dto: BlockedChannelSenderDTO) async throws {} - func deleteBlockedChannelSender(radioID: UUID, name: String) async throws {} - func deleteChannelMessages(fromSender senderName: String, radioID: UUID) async throws {} - func fetchBlockedChannelSenders(radioID: UUID) async throws -> [BlockedChannelSenderDTO] { [] } + func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} + func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { + [] + } - // MARK: - Channel Operations + func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { + false + } - func fetchChannels(radioID: UUID) async throws -> [ChannelDTO] { - channels.values.filter { $0.radioID == radioID }.sorted { $0.index < $1.index } - } + func incrementMessageHeardRepeats(id: UUID) async throws -> Int { + 0 + } - func fetchChannel(radioID: UUID, index: UInt8) async throws -> ChannelDTO? { - channels.values.first { $0.radioID == radioID && $0.index == index } - } + func deleteMessageRepeats(messageID: UUID) async throws {} + func incrementMessageSendCount(id: UUID) async throws -> Int { + 0 + } - func fetchChannel(id: UUID) async throws -> ChannelDTO? { - channels[id] - } + // MARK: - Debug Log Operations - @discardableResult func saveChannel(radioID: UUID, from info: ChannelInfo) async throws -> UUID { UUID() } - func saveChannel(_ dto: ChannelDTO) async throws { channels[dto.id] = dto } - func deleteChannel(id: UUID) async throws { channels.removeValue(forKey: id) } - func updateChannelLastMessage(channelID: UUID, date: Date?) async throws {} - func incrementChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(channelID: UUID) async throws {} - func clearChannelUnreadCount(radioID: UUID, index: UInt8) async throws {} - - // MARK: - Saved Trace Paths - - func fetchSavedTracePaths(radioID: UUID) async throws -> [SavedTracePathDTO] { [] } - func fetchSavedTracePath(id: UUID) async throws -> SavedTracePathDTO? { nil } - func createSavedTracePath( - radioID: UUID, - name: String, - pathBytes: Data, - hashSize: Int, - initialRun: TracePathRunDTO? - ) async throws -> SavedTracePathDTO { - SavedTracePathDTO( - id: UUID(), - radioID: radioID, - name: name, - pathBytes: pathBytes, - hashSize: hashSize, - createdDate: Date(), - runs: [] - ) - } - func updateSavedTracePathName(id: UUID, name: String) async throws {} - func deleteSavedTracePath(id: UUID) async throws {} - func appendTracePathRun(pathID: UUID, run: TracePathRunDTO) async throws {} - - // MARK: - Heard Repeats - - func findSentChannelMessage( - radioID: UUID, - channelIndex: UInt8, - timestamp: UInt32, - text: String, - withinSeconds: Int - ) async throws -> MessageDTO? { nil } - func saveMessageRepeat(_ dto: MessageRepeatDTO) async throws {} - func fetchMessageRepeats(messageID: UUID) async throws -> [MessageRepeatDTO] { [] } - func messageRepeatExists(rxLogEntryID: UUID) async throws -> Bool { false } - func incrementMessageHeardRepeats(id: UUID) async throws -> Int { 0 } - func deleteMessageRepeats(messageID: UUID) async throws {} - func incrementMessageSendCount(id: UUID) async throws -> Int { 0 } - - // MARK: - Debug Log Operations - - func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} - func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { [] } - func countDebugLogEntries() async throws -> Int { 0 } - func pruneDebugLogEntries(keepCount: Int) async throws {} - func clearDebugLogEntries() async throws {} - - // MARK: - Link Preview Data - - func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { nil } - func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws {} - - // MARK: - RxLogEntry Lookup - - func findRxLogEntry( - radioID: UUID, - channelIndex: UInt8?, - senderTimestamp: UInt32 - ) async throws -> RxLogEntryDTO? { nil } - func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { nil } - - // MARK: - Discovered Nodes - - func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { - fatalError("Not implemented") - } - func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { [] } - func deleteDiscoveredNode(id: UUID) async throws {} - func clearDiscoveredNodes(radioID: UUID) async throws {} - func fetchContactPublicKeys(radioID: UUID) async throws -> Set { Set() } - func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { [] } - func saveReaction(_ dto: ReactionDTO) async throws {} - func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { false } - func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} - func deleteReactionsForMessage(messageID: UUID) async throws {} - func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } - func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } - - // MARK: - Notification Level - - func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} - func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} - func fetchDevice(id: UUID) async throws -> DeviceDTO? { nil } - func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { nil } - func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} - func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { nil } - func markSessionDisconnected(_ sessionID: UUID) async throws {} - func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { false } - func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { false } - func clearRetryingToSent(id: UUID) async throws -> Bool { false } - func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { false } - func markMessageAsRead(id: UUID) async throws {} - func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { nil } - func saveDevice(_ dto: DeviceDTO) async throws {} - func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { nil } - func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { [] } - func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { [] } - func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} - func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} - func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} - func deleteRemoteNodeSession(id: UUID) async throws {} - func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} - func resetRoomUnreadCount(_ sessionID: UUID) async throws {} - func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { nil } - func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { nil } - func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} - func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } - func clearRxLogEntries(radioID: UUID) async throws {} - func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { [] } - func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} - func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} - func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} - - // MARK: - Channel Message Deletion - - func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} - - // MARK: - Pending Sends - - func upsertPendingSend(_ dto: PendingSendDTO) async throws {} - func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { 0 } - func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { [] } - func deletePendingSend(id: UUID) async throws {} - func deletePendingSendsForMessage(messageID: UUID) async throws {} - func hasPendingSend(messageID: UUID) async throws -> Bool { false } - - // MARK: - Room Messages - - func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} - func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { nil } - func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { [] } - func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { false } - func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { false } - func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} - func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} - func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} - - // MARK: - Node Status Snapshots - - // swiftlint:disable:next line_length function_parameter_count - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } - func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } - func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } - func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} - func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} - func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { UUID() } - func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { UUID() } - func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} + func saveDebugLogEntries(_ dtos: [DebugLogEntryDTO]) async throws {} + func fetchDebugLogEntries(since date: Date, limit: Int) async throws -> [DebugLogEntryDTO] { + [] + } + + func countDebugLogEntries() async throws -> Int { + 0 + } + + func pruneDebugLogEntries(keepCount: Int) async throws {} + func clearDebugLogEntries() async throws {} + + // MARK: - Link Preview Data + + func fetchLinkPreview(url: String) async throws -> LinkPreviewDataDTO? { + nil + } + + func saveLinkPreview(_ dto: LinkPreviewDataDTO) async throws {} + + // MARK: - RxLogEntry Lookup + + func findRxLogEntry( + radioID: UUID, + channelIndex: UInt8?, + senderTimestamp: UInt32 + ) async throws -> RxLogEntryDTO? { + nil + } + + func findRxLogEntryBySenderPrefix(radioID: UUID, senderPrefixByte: UInt8, receivedSince: Date) async throws -> RxLogEntryDTO? { + nil + } + + // MARK: - Discovered Nodes + + func upsertDiscoveredNode(radioID: UUID, from frame: ContactFrame) async throws -> (node: DiscoveredNodeDTO, isNew: Bool) { + fatalError("Not implemented") + } + + func fetchDiscoveredNodes(radioID: UUID) async throws -> [DiscoveredNodeDTO] { + [] + } + + func deleteDiscoveredNode(id: UUID) async throws {} + func clearDiscoveredNodes(radioID: UUID) async throws {} + func fetchContactPublicKeys(radioID: UUID) async throws -> Set { + Set() + } + + func fetchReactions(for messageID: UUID, limit: Int) async throws -> [ReactionDTO] { + [] + } + + func saveReaction(_ dto: ReactionDTO) async throws {} + func reactionExists(messageID: UUID, senderName: String, emoji: String) async throws -> Bool { + false + } + + func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} + func deleteReactionsForMessage(messageID: UUID) async throws {} + func findChannelMessageForReaction(radioID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { + nil + } + + func fetchChannelMessageCandidates(radioID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { + [] + } + + func fetchDMMessageCandidates(radioID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { + [] + } + + func findDMMessageForReaction(radioID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { + nil + } + + // MARK: - Notification Level + + func setChannelNotificationLevel(_ channelID: UUID, level: NotificationLevel) async throws {} + func setSessionNotificationLevel(_ sessionID: UUID, level: NotificationLevel) async throws {} + func fetchDevice(id: UUID) async throws -> DeviceDTO? { + nil + } + + func fetchDevice(radioID: UUID) async throws -> DeviceDTO? { + nil + } + + func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) async throws {} + func fetchRemoteNodeSession(id: UUID) async throws -> RemoteNodeSessionDTO? { + nil + } + + func fetchRemoteNodeSession(publicKey: Data) async throws -> RemoteNodeSessionDTO? { + nil + } + + func markSessionDisconnected(_ sessionID: UUID) async throws {} + func markRoomSessionConnected(_ sessionID: UUID) async throws -> Bool { + false + } + + func updateMessageStatusUnlessDelivered(id: UUID, status: MessageStatus) async throws -> Bool { + false + } + + func clearRetryingToSent(id: UUID) async throws -> Bool { + false + } + + func hasOutgoingSentDM(ackCode: UInt32) async throws -> Bool { + false + } + + func markMessageAsRead(id: UUID) async throws {} + func incrementPendingSendAttemptCount(messageID: UUID) async throws -> Int? { + nil + } + + func saveDevice(_ dto: DeviceDTO) async throws {} + func fetchRemoteNodeSessionByPrefix(_ prefix: Data) async throws -> RemoteNodeSessionDTO? { + nil + } + + func fetchRemoteNodeSessions(radioID: UUID) async throws -> [RemoteNodeSessionDTO] { + [] + } + + func fetchConnectedRemoteNodeSessions() async throws -> [RemoteNodeSessionDTO] { + [] + } + + func saveRemoteNodeSessionDTO(_ dto: RemoteNodeSessionDTO) async throws {} + func updateRemoteNodeSessionConnection(id: UUID, isConnected: Bool, permissionLevel: RoomPermissionLevel) async throws {} + func cleanupDuplicateRemoteNodeSessions(publicKey: Data, keepID: UUID) async throws {} + func deleteRemoteNodeSession(id: UUID) async throws {} + func incrementRoomUnreadCount(_ sessionID: UUID) async throws {} + func resetRoomUnreadCount(_ sessionID: UUID) async throws {} + func findContactByPublicKey(_ publicKey: Data) async throws -> ContactDTO? { + nil + } + + func findContactNameByKeyPrefix(_ prefix: Data) async throws -> String? { + nil + } + + func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws {} + func fetchRxLogEntries(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { + [] + } + + func clearRxLogEntries(radioID: UUID) async throws {} + func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} + func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + [] + } + + func fetchRecentEntriesByDecryptStatus(radioID: UUID, status: DecryptStatus, since: Date) async throws -> [RxLogEntryDTO] { + [] + } + + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogDecryption(_ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)]) async throws {} + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)]) async throws {} + + // MARK: - Channel Message Deletion + + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} + + // MARK: - Pending Sends + + func upsertPendingSend(_ dto: PendingSendDTO) async throws {} + func insertPendingSendAssigningSequence(_ dto: PendingSendDTO) async throws -> Int { + 0 + } + + func fetchPendingSends(radioID: UUID) async throws -> [PendingSendDTO] { + [] + } + + func deletePendingSend(id: UUID) async throws {} + func deletePendingSendsForMessage(messageID: UUID) async throws {} + func hasPendingSend(messageID: UUID) async throws -> Bool { + false + } + + // MARK: - Room Messages + + func saveRoomMessage(_ dto: RoomMessageDTO) async throws {} + func fetchRoomMessage(id: UUID) async throws -> RoomMessageDTO? { + nil + } + + func fetchRoomMessages(sessionID: UUID, limit: Int?, offset: Int?) async throws -> [RoomMessageDTO] { + [] + } + + func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { + false + } + + func isDuplicateRoomMessage(sessionID: UUID, deduplicationKey: String) async throws -> Bool { + false + } + + func updateRoomMessageStatus(id: UUID, status: MessageStatus, ackCode: UInt32?, roundTripTime: UInt32?) async throws {} + func updateRoomMessageRetryStatus(id: UUID, status: MessageStatus, retryAttempt: Int, maxRetryAttempts: Int) async throws {} + func updateRoomActivity(_ sessionID: UUID, syncTimestamp: UInt32?) async throws {} + + // MARK: - Node Status Snapshots + + // swiftlint:disable:next line_length function_parameter_count + func saveNodeStatusSnapshot( + nodePublicKey: Data, + batteryMillivolts: UInt16?, + lastSNR: Double?, + lastRSSI: Int16?, + noiseFloor: Int16?, + uptimeSeconds: UInt32?, + rxAirtimeSeconds: UInt32?, + packetsSent: UInt32?, + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? + ) async throws -> UUID { + UUID() + } + + func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { + nil + } + + func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { + [] + } + + func updateSnapshotNeighbors(id: UUID, neighbors: [NeighborSnapshotEntry]) async throws {} + func updateSnapshotTelemetry(id: UUID, telemetry: [TelemetrySnapshotEntry]) async throws {} + func recordNodeStatusSnapshot(nodePublicKey: Data, status: NodeStatusMetrics?, telemetry: [TelemetrySnapshotEntry]?, neighbors: [NeighborSnapshotEntry]?) async throws -> UUID { + UUID() + } + + func saveTelemetryOnlySnapshot(nodePublicKey: Data, telemetryEntries: [TelemetrySnapshotEntry]) async throws -> UUID { + UUID() + } + + func deleteOldNodeStatusSnapshots(olderThan date: Date) async throws {} } // MARK: - Mock Link Preview Cache actor MockLinkPreviewCacheForPagination: LinkPreviewCaching { - func preview( - for url: URL, - using dataStore: any PersistenceStoreProtocol, - isChannelMessage: Bool - ) async -> LinkPreviewResult { - .noPreviewAvailable - } - - func manualFetch( - for url: URL, - using dataStore: any PersistenceStoreProtocol - ) async -> LinkPreviewResult { - .noPreviewAvailable - } - - func isFetching(_ url: URL) async -> Bool { false } - func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { nil } + func preview( + for url: URL, + using dataStore: any PersistenceStoreProtocol, + isChannelMessage: Bool + ) async -> LinkPreviewResult { + .noPreviewAvailable + } + + func manualFetch( + for url: URL, + using dataStore: any PersistenceStoreProtocol + ) async -> LinkPreviewResult { + .noPreviewAvailable + } + + func isFetching(_ url: URL) async -> Bool { + false + } + + func cachedPreview(for url: URL) async -> LinkPreviewDataDTO? { + nil + } } // MARK: - Pagination Tests @@ -464,217 +656,216 @@ actor MockLinkPreviewCacheForPagination: LinkPreviewCaching { @Suite("ChatViewModel Pagination Tests") @MainActor struct ChatViewModelPaginationTests { + // MARK: - Test: loadOlderMessages sets hasMoreMessages = false when fewer than pageSize returned - // MARK: - Test: loadOlderMessages sets hasMoreMessages = false when fewer than pageSize returned - - @Test("Loading fewer messages than pageSize marks no more messages available") - func loadFewerThanPageSizeStopsLoading() async throws { - let dataStore = PaginationTestDataStore() - let linkPreviewCache = MockLinkPreviewCacheForPagination() - let viewModel = ChatViewModel() - - let radioID = UUID() - let contactID = UUID() - let contact = createTestContact(id: contactID, radioID: radioID) - - try await dataStore.saveContact(contact) - - // Add only 10 messages (less than pageSize of 50) - for index in 0..<10 { - let message = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: UInt32(1000 + index) - ) - try await dataStore.saveMessage(message) - } - - // Configure view model - need to use the internal configure method - // Since we can't directly inject a PersistenceStoreProtocol, we'll test through observable behavior - viewModel.currentContact = contact - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - let fetched = try await dataStore.fetchMessages(contactID: contactID, limit: 50, offset: 0) - coordinator.replaceAll(fetched) - - let initialCount = viewModel.messages.count - #expect(initialCount == 10) - - // After loading 10 messages (< 50 pageSize), hasMoreMessages should be false internally - // We verify this by checking that calling loadOlderMessages has no effect - // when there are no more messages (since we loaded all 10 and offset would be 10) - - // Direct unit test of the pagination logic: if initial load < pageSize, no more loading - #expect(viewModel.messages.count < 50, "Should have fewer than pageSize messages") - } - - @Test("loadOlderMessages prepends messages to array") - func loadOlderMessagesPrepends() async throws { - let dataStore = PaginationTestDataStore() - let radioID = UUID() - let contactID = UUID() - let contact = createTestContact(id: contactID, radioID: radioID) - - try await dataStore.saveContact(contact) - - // Add 60 messages with sequential timestamps (0-59) - for index in 0..<60 { - let message = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: UInt32(1000 + index), - text: "Message \(index)" - ) - try await dataStore.saveMessage(message) - } - - // Production pagination: sort descending, apply offset/limit, reverse to ascending - // With 60 messages (timestamps 1000-1059): - // - offset 0, limit 50 returns the 50 most recent (1010-1059), sorted ascending - // - offset 50, limit 50 returns the next 10 older (1000-1009), sorted ascending - - let firstPage = try await dataStore.fetchMessages(contactID: contactID, limit: 50, offset: 0) - #expect(firstPage.count == 50) - #expect(firstPage.first?.timestamp == 1010, "First page starts with oldest of the 50 most recent") - #expect(firstPage.last?.timestamp == 1059, "First page ends with the most recent message") - - // Fetch older messages (what loadOlderMessages does) - let secondPage = try await dataStore.fetchMessages(contactID: contactID, limit: 50, offset: 50) - #expect(secondPage.count == 10, "Second page has remaining 10 older messages") - #expect(secondPage.first?.timestamp == 1000, "Second page starts with the oldest message") - #expect(secondPage.last?.timestamp == 1009, "Second page ends before first page starts") - - // Simulate loadOlderMessages: prepend older messages - var messages = firstPage - messages.insert(contentsOf: secondPage, at: 0) - - #expect(messages.count == 60) - #expect(messages.first?.timestamp == 1000, "After prepend, oldest is first") - #expect(messages.last?.timestamp == 1059, "After prepend, newest is last") - } + @Test + func `Loading fewer messages than pageSize marks no more messages available`() async throws { + let dataStore = PaginationTestDataStore() + let linkPreviewCache = MockLinkPreviewCacheForPagination() + let viewModel = ChatViewModel() - @Test("loadOlderMessages guards against concurrent fetches") - func loadOlderMessagesGuardsConcurrent() async { - let viewModel = ChatViewModel() + let radioID = UUID() + let contactID = UUID() + let contact = createTestContact(id: contactID, radioID: radioID) - // isLoadingOlder starts false - #expect(viewModel.isLoadingOlder == false) + try await dataStore.saveContact(contact) - // After initial setup without dataStore, calling loadOlderMessages returns early - // This tests the guard condition - await viewModel.loadOlderMessages() - #expect(viewModel.isLoadingOlder == false) + // Add only 10 messages (less than pageSize of 50) + for index in 0..<10 { + let message = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: UInt32(1000 + index) + ) + try await dataStore.saveMessage(message) } - @Test("loadOlderMessages returns early without dataStore") - func loadOlderMessagesWithoutDataStoreDoesNothing() async { - let viewModel = ChatViewModel() - let radioID = UUID() - let contactID = UUID() - let contact = createTestContact(id: contactID, radioID: radioID) - - viewModel.currentContact = contact - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - - // Without configuring dataStore, loadOlderMessages should return early - await viewModel.loadOlderMessages() - - // No error should be set - #expect(viewModel.errorMessage == nil) - #expect(viewModel.messages.isEmpty) + // Configure view model - need to use the internal configure method + // Since we can't directly inject a PersistenceStoreProtocol, we'll test through observable behavior + viewModel.currentContact = contact + let coordinator = ChatCoordinator.makeForTesting() + viewModel.coordinator = coordinator + let fetched = try await dataStore.fetchMessages(contactID: contactID, limit: 50, offset: 0) + coordinator.replaceAll(fetched) + + let initialCount = viewModel.messages.count + #expect(initialCount == 10) + + // After loading 10 messages (< 50 pageSize), hasMoreMessages should be false internally + // We verify this by checking that calling loadOlderMessages has no effect + // when there are no more messages (since we loaded all 10 and offset would be 10) + + // Direct unit test of the pagination logic: if initial load < pageSize, no more loading + #expect(viewModel.messages.count < 50, "Should have fewer than pageSize messages") + } + + @Test + func `loadOlderMessages prepends messages to array`() async throws { + let dataStore = PaginationTestDataStore() + let radioID = UUID() + let contactID = UUID() + let contact = createTestContact(id: contactID, radioID: radioID) + + try await dataStore.saveContact(contact) + + // Add 60 messages with sequential timestamps (0-59) + for index in 0..<60 { + let message = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: UInt32(1000 + index), + text: "Message \(index)" + ) + try await dataStore.saveMessage(message) } - @Test("Pagination state resets when loading messages for new contact") - func paginationStateResetsOnConversationSwitch() async { - let viewModel = ChatViewModel() - let radioID = UUID() - - // Create two contacts - let contactA = createTestContact(id: UUID(), radioID: radioID, name: "Alice") - let contactB = createTestContact(id: UUID(), radioID: radioID, name: "Bob") - - // Start with contact A - viewModel.currentContact = contactA - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - coordinator.replaceAll([ - createTestMessage(contactID: contactA.id, radioID: radioID, timestamp: 1000) - ]) - - // isLoadingOlder should be false - #expect(viewModel.isLoadingOlder == false) - - // Switch to contact B - viewModel.currentContact = contactB - coordinator.replaceAll([]) - - // State should be clean for new contact - #expect(viewModel.messages.isEmpty) - #expect(viewModel.isLoadingOlder == false) + // Production pagination: sort descending, apply offset/limit, reverse to ascending + // With 60 messages (timestamps 1000-1059): + // - offset 0, limit 50 returns the 50 most recent (1010-1059), sorted ascending + // - offset 50, limit 50 returns the next 10 older (1000-1009), sorted ascending + + let firstPage = try await dataStore.fetchMessages(contactID: contactID, limit: 50, offset: 0) + #expect(firstPage.count == 50) + #expect(firstPage.first?.timestamp == 1010, "First page starts with oldest of the 50 most recent") + #expect(firstPage.last?.timestamp == 1059, "First page ends with the most recent message") + + // Fetch older messages (what loadOlderMessages does) + let secondPage = try await dataStore.fetchMessages(contactID: contactID, limit: 50, offset: 50) + #expect(secondPage.count == 10, "Second page has remaining 10 older messages") + #expect(secondPage.first?.timestamp == 1000, "Second page starts with the oldest message") + #expect(secondPage.last?.timestamp == 1009, "Second page ends before first page starts") + + // Simulate loadOlderMessages: prepend older messages + var messages = firstPage + messages.insert(contentsOf: secondPage, at: 0) + + #expect(messages.count == 60) + #expect(messages.first?.timestamp == 1000, "After prepend, oldest is first") + #expect(messages.last?.timestamp == 1059, "After prepend, newest is last") + } + + @Test + func `loadOlderMessages guards against concurrent fetches`() async { + let viewModel = ChatViewModel() + + // isLoadingOlder starts false + #expect(viewModel.isLoadingOlder == false) + + // After initial setup without dataStore, calling loadOlderMessages returns early + // This tests the guard condition + await viewModel.loadOlderMessages() + #expect(viewModel.isLoadingOlder == false) + } + + @Test + func `loadOlderMessages returns early without dataStore`() async { + let viewModel = ChatViewModel() + let radioID = UUID() + let contactID = UUID() + let contact = createTestContact(id: contactID, radioID: radioID) + + viewModel.currentContact = contact + let coordinator = ChatCoordinator.makeForTesting() + viewModel.coordinator = coordinator + + // Without configuring dataStore, loadOlderMessages should return early + await viewModel.loadOlderMessages() + + // No error should be set + #expect(viewModel.errorMessage == nil) + #expect(viewModel.messages.isEmpty) + } + + @Test + func `Pagination state resets when loading messages for new contact`() { + let viewModel = ChatViewModel() + let radioID = UUID() + + // Create two contacts + let contactA = createTestContact(id: UUID(), radioID: radioID, name: "Alice") + let contactB = createTestContact(id: UUID(), radioID: radioID, name: "Bob") + + // Start with contact A + viewModel.currentContact = contactA + let coordinator = ChatCoordinator.makeForTesting() + viewModel.coordinator = coordinator + coordinator.replaceAll([ + createTestMessage(contactID: contactA.id, radioID: radioID, timestamp: 1000) + ]) + + // isLoadingOlder should be false + #expect(viewModel.isLoadingOlder == false) + + // Switch to contact B + viewModel.currentContact = contactB + coordinator.replaceAll([]) + + // State should be clean for new contact + #expect(viewModel.messages.isEmpty) + #expect(viewModel.isLoadingOlder == false) + } + + @Test + func `Initial message load sets hasMoreMessages based on count`() { + let viewModel = ChatViewModel() + + // When messages.count equals pageSize (50), hasMoreMessages should remain true + // When messages.count < pageSize, hasMoreMessages becomes false + + // This is tested indirectly through the loadMessages behavior + // The key is that with < 50 messages, subsequent loadOlderMessages calls should not fetch + + #expect(viewModel.messages.isEmpty) + } + + /// Verifies that `loadOlderMessages` clears `isLoadingOlder` before + /// entering the reaction-indexing loop, so the pagination spinner is not + /// held open by a slow `ReactionService.indexMessage` actor hop on a + /// busy channel. The structural ordering is enforced in + /// `ChatViewModel.loadOlderMessages`: the spinner is cleared immediately + /// after `buildItems()` and before the channel/DM reaction-indexing + /// blocks. This test exercises the success path with the reaction-service + /// provider at its nil default (so the indexing loops are skipped) and asserts the post-return + /// state matches the documented contract — `isLoadingOlder == false`, + /// older messages prepended, no error surfaced. The deeper "spinner + /// already false while indexing still running" property is verified by + /// source-level review at PR time; `ReactionService` is a concrete + /// actor without a protocol surface, so an injectable continuation- + /// blocking stub is not available. + @Test + func `loadOlderMessages clears isLoadingOlder on the success path`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contactID = UUID() + + let viewModel = ChatViewModel() + viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) + viewModel.currentContact = createTestContact(id: contactID, radioID: radioID) + let coordinator = ChatCoordinator.makeForTesting() + viewModel.coordinator = coordinator + + // Seed the database with a page worth of messages so the + // pagination fetch returns rows and proceeds past the + // prepend/buildItems block where the early-clear lives. + for index in 0..<10 { + let message = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: UInt32(1000 + index) + ) + try await dataStore.saveMessage(message) } - @Test("Initial message load sets hasMoreMessages based on count") - func initialLoadSetsHasMoreMessages() async { - let viewModel = ChatViewModel() - - // When messages.count equals pageSize (50), hasMoreMessages should remain true - // When messages.count < pageSize, hasMoreMessages becomes false - - // This is tested indirectly through the loadMessages behavior - // The key is that with < 50 messages, subsequent loadOlderMessages calls should not fetch + #expect(viewModel.isLoadingOlder == false) - #expect(viewModel.messages.isEmpty) - } + await viewModel.loadOlderMessages() - /// Verifies that `loadOlderMessages` clears `isLoadingOlder` before - /// entering the reaction-indexing loop, so the pagination spinner is not - /// held open by a slow `ReactionService.indexMessage` actor hop on a - /// busy channel. The structural ordering is enforced in - /// `ChatViewModel.loadOlderMessages`: the spinner is cleared immediately - /// after `buildItems()` and before the channel/DM reaction-indexing - /// blocks. This test exercises the success path with the reaction-service - /// provider at its nil default (so the indexing loops are skipped) and asserts the post-return - /// state matches the documented contract — `isLoadingOlder == false`, - /// older messages prepended, no error surfaced. The deeper "spinner - /// already false while indexing still running" property is verified by - /// source-level review at PR time; `ReactionService` is a concrete - /// actor without a protocol surface, so an injectable continuation- - /// blocking stub is not available. - @Test("loadOlderMessages clears isLoadingOlder on the success path") - func loadOlderMessagesClearsSpinnerBeforeIndexing() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let radioID = UUID() - let contactID = UUID() - - let viewModel = ChatViewModel() - viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) - viewModel.currentContact = createTestContact(id: contactID, radioID: radioID) - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - - // Seed the database with a page worth of messages so the - // pagination fetch returns rows and proceeds past the - // prepend/buildItems block where the early-clear lives. - for index in 0..<10 { - let message = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: UInt32(1000 + index) - ) - try await dataStore.saveMessage(message) - } - - #expect(viewModel.isLoadingOlder == false) - - await viewModel.loadOlderMessages() - - #expect(viewModel.isLoadingOlder == false, "Pagination must release the spinner before returning") - #expect(viewModel.errorMessage == nil) - #expect(viewModel.errorBannerMessage == nil) - #expect(viewModel.messages.count == 10, "Pagination should have prepended fetched messages") - } + #expect(viewModel.isLoadingOlder == false, "Pagination must release the spinner before returning") + #expect(viewModel.errorMessage == nil) + #expect(viewModel.errorBannerMessage == nil) + #expect(viewModel.messages.count == 10, "Pagination should have prepended fetched messages") + } } // MARK: - Channel Pagination Tests @@ -682,130 +873,129 @@ struct ChatViewModelPaginationTests { @Suite("ChatViewModel Channel Pagination Tests") @MainActor struct ChatViewModelChannelPaginationTests { + @Test + func `Channel message pagination works similar to direct messages`() async throws { + let dataStore = PaginationTestDataStore() + let radioID = UUID() + let channelIndex: UInt8 = 0 + let channel = createTestChannel(radioID: radioID, index: channelIndex) + + try await dataStore.saveChannel(channel) + + // Add 30 channel messages + for index in 0..<30 { + let message = createChannelMessage( + radioID: radioID, + channelIndex: channelIndex, + timestamp: UInt32(1000 + index), + senderName: "User\(index % 3)" + ) + try await dataStore.saveMessage(message) + } + + // Fetch first page + let messages = try await dataStore.fetchMessages( + radioID: radioID, + channelIndex: channelIndex, + limit: 50, + offset: 0 + ) - @Test("Channel message pagination works similar to direct messages") - func channelPaginationWorks() async throws { - let dataStore = PaginationTestDataStore() - let radioID = UUID() - let channelIndex: UInt8 = 0 - let channel = createTestChannel(radioID: radioID, index: channelIndex) - - try await dataStore.saveChannel(channel) - - // Add 30 channel messages - for index in 0..<30 { - let message = createChannelMessage( - radioID: radioID, - channelIndex: channelIndex, - timestamp: UInt32(1000 + index), - senderName: "User\(index % 3)" - ) - try await dataStore.saveMessage(message) - } - - // Fetch first page - let messages = try await dataStore.fetchMessages( - radioID: radioID, - channelIndex: channelIndex, - limit: 50, - offset: 0 - ) - - #expect(messages.count == 30) - #expect(messages.count < 50, "Fewer than pageSize means no more messages available") + #expect(messages.count == 30) + #expect(messages.count < 50, "Fewer than pageSize means no more messages available") + } + + @Test + func `loadOlderMessages handles channel messages`() async { + let viewModel = ChatViewModel() + let radioID = UUID() + let channelIndex: UInt8 = 1 + let channel = createTestChannel(radioID: radioID, index: channelIndex, name: "General") + + viewModel.currentChannel = channel + viewModel.currentContact = nil + + // Without dataStore configured, loadOlderMessages returns early + await viewModel.loadOlderMessages() + + #expect(viewModel.isLoadingOlder == false) + #expect(viewModel.errorMessage == nil) + } + + @Test + func `Initial channel load uses unfiltered count for hasMoreMessages`() async throws { + // If we fetch 50 messages and 10 are blocked, hasMoreMessages should still be true + // because the unfiltered count (50) equals pageSize + let dataStore = PaginationTestDataStore() + let radioID = UUID() + let channelIndex: UInt8 = 0 + + // Add exactly 50 messages (pageSize), some from blocked sender + for index in 0..<50 { + let senderName = index < 10 ? "BlockedUser" : "User\(index)" + let message = createChannelMessage( + radioID: radioID, + channelIndex: channelIndex, + timestamp: UInt32(1000 + index), + senderName: senderName + ) + try await dataStore.saveMessage(message) } - @Test("loadOlderMessages handles channel messages") - func loadOlderMessagesHandlesChannels() async { - let viewModel = ChatViewModel() - let radioID = UUID() - let channelIndex: UInt8 = 1 - let channel = createTestChannel(radioID: radioID, index: channelIndex, name: "General") + // Fetch all messages + let messages = try await dataStore.fetchMessages( + radioID: radioID, + channelIndex: channelIndex, + limit: 50, + offset: 0 + ) - viewModel.currentChannel = channel - viewModel.currentContact = nil + #expect(messages.count == 50, "Should fetch 50 messages before filtering") - // Without dataStore configured, loadOlderMessages returns early - await viewModel.loadOlderMessages() + // After filtering, would have 40 messages, but hasMoreMessages should be based on 50 + let filtered = messages.filter { $0.senderNodeName != "BlockedUser" } + #expect(filtered.count == 40, "After filtering should have 40 messages") - #expect(viewModel.isLoadingOlder == false) - #expect(viewModel.errorMessage == nil) - } + // The key insight: unfiltered count (50) == pageSize means hasMoreMessages = true + #expect(messages.count == 50, "Unfiltered count should drive pagination decision") + } - @Test("Initial channel load uses unfiltered count for hasMoreMessages") - func initialLoadUsesUnfilteredCountForPagination() async throws { - // If we fetch 50 messages and 10 are blocked, hasMoreMessages should still be true - // because the unfiltered count (50) equals pageSize - let dataStore = PaginationTestDataStore() - let radioID = UUID() - let channelIndex: UInt8 = 0 - - // Add exactly 50 messages (pageSize), some from blocked sender - for index in 0..<50 { - let senderName = index < 10 ? "BlockedUser" : "User\(index)" - let message = createChannelMessage( - radioID: radioID, - channelIndex: channelIndex, - timestamp: UInt32(1000 + index), - senderName: senderName - ) - try await dataStore.saveMessage(message) - } - - // Fetch all messages - let messages = try await dataStore.fetchMessages( - radioID: radioID, - channelIndex: channelIndex, - limit: 50, - offset: 0 - ) - - #expect(messages.count == 50, "Should fetch 50 messages before filtering") - - // After filtering, would have 40 messages, but hasMoreMessages should be based on 50 - let filtered = messages.filter { $0.senderNodeName != "BlockedUser" } - #expect(filtered.count == 40, "After filtering should have 40 messages") - - // The key insight: unfiltered count (50) == pageSize means hasMoreMessages = true - #expect(messages.count == 50, "Unfiltered count should drive pagination decision") - } + @Test + func `Switching from a channel to a DM clears the channel axis so an incoming channel message is not admitted into the open DM`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channelIndex: UInt8 = 1 + let contactID = UUID() - @Test("Switching from a channel to a DM clears the channel axis so an incoming channel message is not admitted into the open DM") - func channelToDMSwitchRejectsLateChannelMessage() async throws { - let container = try PersistenceStore.createContainer(inMemory: true) - let dataStore = PersistenceStore(modelContainer: container) - let radioID = UUID() - let channelIndex: UInt8 = 1 - let contactID = UUID() - - let viewModel = ChatViewModel() - viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) - viewModel.coordinator = ChatCoordinator.makeForTesting() - - let channel = createTestChannel(radioID: radioID, index: channelIndex, name: "General") - let contact = createTestContact(id: contactID, radioID: radioID) - - // Open the channel, then switch to the DM. loadMessages(for:) must clear currentChannel. - await viewModel.loadChannelMessages(for: channel) - #expect(viewModel.currentChannel?.index == channelIndex) - - await viewModel.loadMessages(for: contact) - #expect(viewModel.currentChannel == nil, "Loading a DM must clear the channel axis") - #expect(viewModel.currentContact?.id == contactID) - - let baselineCount = viewModel.messages.count - - // A channel message for the channel that was just open must not enter the open DM. - let channelMessage = createChannelMessage( - radioID: radioID, - channelIndex: channelIndex, - timestamp: 2_000 - ) - await viewModel.handle(.channelMessageReceived(message: channelMessage, channelIndex: channelIndex)) - - #expect(viewModel.messages.count == baselineCount, - "An incoming channel message must not be admitted into the open DM after the axis switch") - } + let viewModel = ChatViewModel() + viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) + viewModel.coordinator = ChatCoordinator.makeForTesting() + + let channel = createTestChannel(radioID: radioID, index: channelIndex, name: "General") + let contact = createTestContact(id: contactID, radioID: radioID) + + // Open the channel, then switch to the DM. loadMessages(for:) must clear currentChannel. + await viewModel.loadChannelMessages(for: channel) + #expect(viewModel.currentChannel?.index == channelIndex) + + await viewModel.loadMessages(for: contact) + #expect(viewModel.currentChannel == nil, "Loading a DM must clear the channel axis") + #expect(viewModel.currentContact?.id == contactID) + + let baselineCount = viewModel.messages.count + + // A channel message for the channel that was just open must not enter the open DM. + let channelMessage = createChannelMessage( + radioID: radioID, + channelIndex: channelIndex, + timestamp: 2000 + ) + await viewModel.handle(.channelMessageReceived(message: channelMessage, channelIndex: channelIndex)) + + #expect(viewModel.messages.count == baselineCount, + "An incoming channel message must not be admitted into the open DM after the axis switch") + } } // MARK: - Display Items Tests @@ -813,67 +1003,66 @@ struct ChatViewModelChannelPaginationTests { @Suite("ChatViewModel Display Items Pagination Tests") @MainActor struct ChatViewModelDisplayItemsPaginationTests { - - @Test("Display items are rebuilt after loading older messages") - func displayItemsRebuildAfterLoadingOlder() async { - let viewModel = ChatViewModel() - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - - // Start with some messages - let radioID = UUID() - let contactID = UUID() - - let messages = (0..<5).map { index in - createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: UInt32(1000 + index) - ) - } - - coordinator.replaceAll(messages) - viewModel.buildItems() - await coordinator.buildItemsTask?.value - - #expect(viewModel.items.count == 5) - - // Add more messages (simulating loadOlderMessages prepend) - let olderMessages = (0..<3).map { index in - createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: UInt32(900 + index) - ) - } - - coordinator.prepend(olderMessages) - viewModel.buildItems() - await coordinator.buildItemsTask?.value - - #expect(viewModel.items.count == 8) + @Test + func `Display items are rebuilt after loading older messages`() async { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.coordinator = coordinator + + // Start with some messages + let radioID = UUID() + let contactID = UUID() + + let messages = (0..<5).map { index in + createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: UInt32(1000 + index) + ) } - @Test("Message lookup by ID works after pagination") - func messageLookupWorksAfterPagination() async { - let viewModel = ChatViewModel() - let coordinator = ChatCoordinator.makeForTesting() - viewModel.coordinator = coordinator - let radioID = UUID() - let contactID = UUID() - - let message1 = createTestMessage(contactID: contactID, radioID: radioID, timestamp: 1000) - let message2 = createTestMessage(contactID: contactID, radioID: radioID, timestamp: 1001) - - coordinator.replaceAll([message1, message2]) - viewModel.buildItems() - await coordinator.buildItemsTask?.value - - // Lookup should work - #expect(viewModel.items.count == 2) - let foundMessage = viewModel.message(for: viewModel.items[0]) - #expect(foundMessage?.id == message1.id) + coordinator.replaceAll(messages) + viewModel.buildItems() + await coordinator.buildItemsTask?.value + + #expect(viewModel.items.count == 5) + + // Add more messages (simulating loadOlderMessages prepend) + let olderMessages = (0..<3).map { index in + createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: UInt32(900 + index) + ) } + + coordinator.prepend(olderMessages) + viewModel.buildItems() + await coordinator.buildItemsTask?.value + + #expect(viewModel.items.count == 8) + } + + @Test + func `Message lookup by ID works after pagination`() async { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.coordinator = coordinator + let radioID = UUID() + let contactID = UUID() + + let message1 = createTestMessage(contactID: contactID, radioID: radioID, timestamp: 1000) + let message2 = createTestMessage(contactID: contactID, radioID: radioID, timestamp: 1001) + + coordinator.replaceAll([message1, message2]) + viewModel.buildItems() + await coordinator.buildItemsTask?.value + + // Lookup should work + #expect(viewModel.items.count == 2) + let foundMessage = viewModel.message(for: viewModel.items[0]) + #expect(foundMessage?.id == message1.id) + } } // MARK: - Cross-Boundary Reordering Tests @@ -881,105 +1070,104 @@ struct ChatViewModelDisplayItemsPaginationTests { @Suite("Same-Sender Cluster Reordering Across Page Boundaries") @MainActor struct CrossBoundaryReorderingTests { + @Test + func `Reordering fixes same-sender cluster split across pagination boundary`() { + // Scenario: Sender sends msg1 (t=100), msg2 (t=101), msg3 (t=102) rapidly. + // Mesh delivers them out of order: msg3, msg1, msg2. + // msg3 ends up on page 2 (older), msg1 and msg2 on page 1 (newer). + // + // Each page is reordered independently, but the cross-boundary cluster + // (msg3 on page 2, msg1+msg2 on page 1) is NOT reordered until merge. + + let radioID = UUID() + let contactID = UUID() + let base = Date(timeIntervalSince1970: 1_000_000) + + // Page 2 (older, loaded second via loadOlderMessages): msg3 arrived first + let msg3 = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 102, + createdAt: base.addingTimeInterval(0), // received first + text: "msg3" + ) - @Test("Reordering fixes same-sender cluster split across pagination boundary") - func reorderingFixesSplitCluster() { - // Scenario: Sender sends msg1 (t=100), msg2 (t=101), msg3 (t=102) rapidly. - // Mesh delivers them out of order: msg3, msg1, msg2. - // msg3 ends up on page 2 (older), msg1 and msg2 on page 1 (newer). - // - // Each page is reordered independently, but the cross-boundary cluster - // (msg3 on page 2, msg1+msg2 on page 1) is NOT reordered until merge. - - let radioID = UUID() - let contactID = UUID() - let base = Date(timeIntervalSince1970: 1_000_000) - - // Page 2 (older, loaded second via loadOlderMessages): msg3 arrived first - let msg3 = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 102, - createdAt: base.addingTimeInterval(0), // received first - text: "msg3" - ) - - // Page 1 (newer, loaded first): msg1 and msg2 arrived later - let msg1 = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 100, - createdAt: base.addingTimeInterval(2), // received second - text: "msg1" - ) - let msg2 = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 101, - createdAt: base.addingTimeInterval(3), // received third - text: "msg2" - ) - - // Simulate independent per-page reordering (as production does) - let page2Reordered = MessageDTO.reorderSameSenderClusters([msg3]) // single msg, no-op - let page1Reordered = MessageDTO.reorderSameSenderClusters([msg1, msg2]) // already ordered - - // Merge: prepend older page - var merged = page2Reordered - merged.append(contentsOf: page1Reordered) - - // Without cross-boundary reordering: msg3, msg1, msg2 (receive order at boundary) - #expect(merged.map(\.text) == ["msg3", "msg1", "msg2"]) - - // After re-running reorderSameSenderClusters on the full merged array - let fixed = MessageDTO.reorderSameSenderClusters(merged) - - // All three are from the same sender (DM, same direction), within 5s window, - // so they're reordered by sender timestamp: msg1, msg2, msg3 - #expect(fixed.map(\.text) == ["msg1", "msg2", "msg3"]) - } + // Page 1 (newer, loaded first): msg1 and msg2 arrived later + let msg1 = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 100, + createdAt: base.addingTimeInterval(2), // received second + text: "msg1" + ) + let msg2 = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 101, + createdAt: base.addingTimeInterval(3), // received third + text: "msg2" + ) - @Test("Reordering does not merge clusters beyond the 5-second window") - func reorderingRespectsWindowAtBoundary() { - let radioID = UUID() - let contactID = UUID() - let base = Date(timeIntervalSince1970: 1_000_000) - - // Page 2 message: received well before the page 1 messages (>5s gap) - let oldMsg = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 100, - createdAt: base.addingTimeInterval(0), - text: "old" - ) - - // Page 1 messages: received 10 seconds later - let newMsg1 = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 99, // earlier sender timestamp but later receive - createdAt: base.addingTimeInterval(10), - text: "new1" - ) - let newMsg2 = createTestMessage( - contactID: contactID, - radioID: radioID, - timestamp: 102, - createdAt: base.addingTimeInterval(11), - text: "new2" - ) - - // Merge: prepend older page - var merged = [oldMsg] - merged.append(contentsOf: [newMsg1, newMsg2]) - - let result = MessageDTO.reorderSameSenderClusters(merged) - - // The 10-second gap between oldMsg and newMsg1 exceeds the 5s window, - // so they should NOT be clustered — order stays as-is - #expect(result.map(\.text) == ["old", "new1", "new2"]) - } + // Simulate independent per-page reordering (as production does) + let page2Reordered = MessageDTO.reorderSameSenderClusters([msg3]) // single msg, no-op + let page1Reordered = MessageDTO.reorderSameSenderClusters([msg1, msg2]) // already ordered + + // Merge: prepend older page + var merged = page2Reordered + merged.append(contentsOf: page1Reordered) + + // Without cross-boundary reordering: msg3, msg1, msg2 (receive order at boundary) + #expect(merged.map(\.text) == ["msg3", "msg1", "msg2"]) + + // After re-running reorderSameSenderClusters on the full merged array + let fixed = MessageDTO.reorderSameSenderClusters(merged) + + // All three are from the same sender (DM, same direction), within 5s window, + // so they're reordered by sender timestamp: msg1, msg2, msg3 + #expect(fixed.map(\.text) == ["msg1", "msg2", "msg3"]) + } + + @Test + func `Reordering does not merge clusters beyond the 5-second window`() { + let radioID = UUID() + let contactID = UUID() + let base = Date(timeIntervalSince1970: 1_000_000) + + // Page 2 message: received well before the page 1 messages (>5s gap) + let oldMsg = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 100, + createdAt: base.addingTimeInterval(0), + text: "old" + ) + + // Page 1 messages: received 10 seconds later + let newMsg1 = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 99, // earlier sender timestamp but later receive + createdAt: base.addingTimeInterval(10), + text: "new1" + ) + let newMsg2 = createTestMessage( + contactID: contactID, + radioID: radioID, + timestamp: 102, + createdAt: base.addingTimeInterval(11), + text: "new2" + ) + + // Merge: prepend older page + var merged = [oldMsg] + merged.append(contentsOf: [newMsg1, newMsg2]) + + let result = MessageDTO.reorderSameSenderClusters(merged) + + // The 10-second gap between oldMsg and newMsg1 exceeds the 5s window, + // so they should NOT be clustered — order stays as-is + #expect(result.map(\.text) == ["old", "new1", "new2"]) + } } // MARK: - Channel Sender Registration (Pagination Regression) @@ -987,83 +1175,82 @@ struct CrossBoundaryReorderingTests { @Suite("ChatViewModel Channel Sender Registration") @MainActor struct ChatViewModelChannelSenderRegistrationTests { - - @Test("addChannelSenderIfNew inserts synthetic sender and records timestamp") - func registersNewSender() { - let viewModel = ChatViewModel() - let radioID = UUID() - - viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 1000) - - #expect(viewModel.channelSenderNames.contains("Alice")) - #expect(viewModel.channelSenders.count == 1) - #expect(viewModel.channelSenderOrder["Alice"] == 1000) - } - - @Test("addChannelSenderIfNew max-merges timestamps for the same sender") - func maxMergesTimestamp() { - let viewModel = ChatViewModel() - let radioID = UUID() - - viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 1000) - viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 500) - - // Older message arriving after a newer one must not lower the recency stamp. - #expect(viewModel.channelSenderOrder["Alice"] == 1000) - #expect(viewModel.channelSenders.count == 1) - } - - @Test("addChannelSenderIfNew skips synthetic for known contacts but still records order") - func recordsOrderForKnownContact() { - let viewModel = ChatViewModel() - let radioID = UUID() - viewModel.contactNameSet = ["Bob"] - - viewModel.addChannelSenderIfNew("Bob", radioID: radioID, timestamp: 1500) - - #expect(viewModel.channelSenders.isEmpty, "No synthetic for a known contact") - #expect(viewModel.channelSenderNames.contains("Bob") == false) - #expect(viewModel.channelSenderOrder["Bob"] == 1500, "Order tracks all senders for mention recency") - } - - @Test("addChannelSenderIfNew rejects empty and oversized names") - func rejectsInvalidNames() { - let viewModel = ChatViewModel() - let radioID = UUID() - let tooLong = String(repeating: "x", count: 129) - - viewModel.addChannelSenderIfNew(" ", radioID: radioID, timestamp: 1) - viewModel.addChannelSenderIfNew("", radioID: radioID, timestamp: 1) - viewModel.addChannelSenderIfNew(tooLong, radioID: radioID, timestamp: 1) - - #expect(viewModel.channelSenders.isEmpty) - #expect(viewModel.channelSenderOrder.isEmpty) - } - - @Test("Pagination registration: older-page senders join channelSenderNames and channelSenderOrder") - func paginationRegistersOlderSenders() { - // Simulates the loadOlderMessages call site: live receive registers - // recent senders first, then a paginated older page registers earlier - // senders. After both steps, the mention picker must see all senders - // and the order map must reflect each sender's own timestamp. - let viewModel = ChatViewModel() - let radioID = UUID() - - // Live-receive: senders A and B on the current page - viewModel.addChannelSenderIfNew("A", radioID: radioID, timestamp: 2000) - viewModel.addChannelSenderIfNew("B", radioID: radioID, timestamp: 2100) - - // Pagination: older page reveals senders C and D, plus another - // A message from earlier - viewModel.addChannelSenderIfNew("C", radioID: radioID, timestamp: 500) - viewModel.addChannelSenderIfNew("D", radioID: radioID, timestamp: 600) - viewModel.addChannelSenderIfNew("A", radioID: radioID, timestamp: 100) - - #expect(viewModel.channelSenderNames == ["A", "B", "C", "D"]) - #expect(viewModel.channelSenders.count == 4) - #expect(viewModel.channelSenderOrder["A"] == 2000, "A keeps its live recency, not the older one") - #expect(viewModel.channelSenderOrder["B"] == 2100) - #expect(viewModel.channelSenderOrder["C"] == 500) - #expect(viewModel.channelSenderOrder["D"] == 600) - } + @Test + func `addChannelSenderIfNew inserts synthetic sender and records timestamp`() { + let viewModel = ChatViewModel() + let radioID = UUID() + + viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 1000) + + #expect(viewModel.channelSenderNames.contains("Alice")) + #expect(viewModel.channelSenders.count == 1) + #expect(viewModel.channelSenderOrder["Alice"] == 1000) + } + + @Test + func `addChannelSenderIfNew max-merges timestamps for the same sender`() { + let viewModel = ChatViewModel() + let radioID = UUID() + + viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 1000) + viewModel.addChannelSenderIfNew("Alice", radioID: radioID, timestamp: 500) + + // Older message arriving after a newer one must not lower the recency stamp. + #expect(viewModel.channelSenderOrder["Alice"] == 1000) + #expect(viewModel.channelSenders.count == 1) + } + + @Test + func `addChannelSenderIfNew skips synthetic for known contacts but still records order`() { + let viewModel = ChatViewModel() + let radioID = UUID() + viewModel.contactNameSet = ["Bob"] + + viewModel.addChannelSenderIfNew("Bob", radioID: radioID, timestamp: 1500) + + #expect(viewModel.channelSenders.isEmpty, "No synthetic for a known contact") + #expect(viewModel.channelSenderNames.contains("Bob") == false) + #expect(viewModel.channelSenderOrder["Bob"] == 1500, "Order tracks all senders for mention recency") + } + + @Test + func `addChannelSenderIfNew rejects empty and oversized names`() { + let viewModel = ChatViewModel() + let radioID = UUID() + let tooLong = String(repeating: "x", count: 129) + + viewModel.addChannelSenderIfNew(" ", radioID: radioID, timestamp: 1) + viewModel.addChannelSenderIfNew("", radioID: radioID, timestamp: 1) + viewModel.addChannelSenderIfNew(tooLong, radioID: radioID, timestamp: 1) + + #expect(viewModel.channelSenders.isEmpty) + #expect(viewModel.channelSenderOrder.isEmpty) + } + + @Test + func `Pagination registration: older-page senders join channelSenderNames and channelSenderOrder`() { + // Simulates the loadOlderMessages call site: live receive registers + // recent senders first, then a paginated older page registers earlier + // senders. After both steps, the mention picker must see all senders + // and the order map must reflect each sender's own timestamp. + let viewModel = ChatViewModel() + let radioID = UUID() + + // Live-receive: senders A and B on the current page + viewModel.addChannelSenderIfNew("A", radioID: radioID, timestamp: 2000) + viewModel.addChannelSenderIfNew("B", radioID: radioID, timestamp: 2100) + + // Pagination: older page reveals senders C and D, plus another + // A message from earlier + viewModel.addChannelSenderIfNew("C", radioID: radioID, timestamp: 500) + viewModel.addChannelSenderIfNew("D", radioID: radioID, timestamp: 600) + viewModel.addChannelSenderIfNew("A", radioID: radioID, timestamp: 100) + + #expect(viewModel.channelSenderNames == ["A", "B", "C", "D"]) + #expect(viewModel.channelSenders.count == 4) + #expect(viewModel.channelSenderOrder["A"] == 2000, "A keeps its live recency, not the older one") + #expect(viewModel.channelSenderOrder["B"] == 2100) + #expect(viewModel.channelSenderOrder["C"] == 500) + #expect(viewModel.channelSenderOrder["D"] == 600) + } } diff --git a/MC1Tests/ViewModels/ChatViewModelTests.swift b/MC1Tests/ViewModels/ChatViewModelTests.swift index ca144d7e..80f10f5a 100644 --- a/MC1Tests/ViewModels/ChatViewModelTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelTests.swift @@ -1,96 +1,96 @@ -import Testing import Foundation @testable import MC1 @testable import MC1Services +import Testing // MARK: - Test Helpers private func createTestContact( - radioID: UUID = UUID(), - name: String = "TestContact", - type: ContactType = .chat, - isBlocked: Bool = false + radioID: UUID = UUID(), + name: String = "TestContact", + type: ContactType = .chat, + isBlocked: Bool = false ) -> ContactDTO { - let contact = Contact( - id: UUID(), - radioID: radioID, - publicKey: Data((0.. MessageDTO { - let resolvedCreatedAt = createdAt ?? Date(timeIntervalSince1970: TimeInterval(timestamp)) - let message = Message( - id: UUID(), - radioID: UUID(), - contactID: UUID(), - text: text, - timestamp: timestamp, - createdAt: resolvedCreatedAt, - sortDate: sortDate, - directionRawValue: MessageDirection.outgoing.rawValue, - statusRawValue: MessageStatus.sent.rawValue - ) - return MessageDTO(from: message) + let resolvedCreatedAt = createdAt ?? Date(timeIntervalSince1970: TimeInterval(timestamp)) + let message = Message( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + text: text, + timestamp: timestamp, + createdAt: resolvedCreatedAt, + sortDate: sortDate, + directionRawValue: MessageDirection.outgoing.rawValue, + statusRawValue: MessageStatus.sent.rawValue + ) + return MessageDTO(from: message) } private func createChannelMessage( - timestamp: UInt32, - createdAt: Date? = nil, - senderName: String? = nil, - isOutgoing: Bool = false, - text: String = "Test message" + timestamp: UInt32, + createdAt: Date? = nil, + senderName: String? = nil, + isOutgoing: Bool = false, + text: String = "Test message" ) -> MessageDTO { - MessageDTO( - id: UUID(), - radioID: UUID(), - contactID: nil, // nil = channel message - channelIndex: 0, - text: text, - timestamp: timestamp, - createdAt: createdAt ?? Date(timeIntervalSince1970: TimeInterval(timestamp)), - direction: isOutgoing ? .outgoing : .incoming, - status: isOutgoing ? .sent : .delivered, - textType: .plain, - ackCode: nil, - pathLength: 0, - snr: nil, - senderKeyPrefix: nil, // Always nil for channel messages per MeshCore protocol - senderNodeName: senderName, - isRead: false, - replyToID: nil, - roundTripTime: nil, - heardRepeats: 0, - retryAttempt: 0, - maxRetryAttempts: 0 - ) + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: nil, // nil = channel message + channelIndex: 0, + text: text, + timestamp: timestamp, + createdAt: createdAt ?? Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: isOutgoing ? .outgoing : .incoming, + status: isOutgoing ? .sent : .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, // Always nil for channel messages per MeshCore protocol + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) } /// Builds a calendar date at a specific day and time in the current calendar. private func makeDate(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int = 0) -> Date { - Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: hour, minute: minute))! + Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: hour, minute: minute))! } /// Sender-clock timestamp for a day/time. Day-divider detection keys on /// `MessageDTO.senderDate`, which derives from `timestamp`, so this drives the real path. private func makeTimestamp(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int = 0) -> UInt32 { - UInt32(makeDate(year, month, day, hour, minute).timeIntervalSince1970) + UInt32(makeDate(year, month, day, hour, minute).timeIntervalSince1970) } // MARK: - ChatViewModel Tests @@ -98,497 +98,495 @@ private func makeTimestamp(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ @Suite("ChatViewModel Tests") @MainActor struct ChatViewModelTests { + /// `ChatViewModel.makeBuildInputs` calls `MapSnapshotStore.shared.isResolved`, + /// which lazily initializes the process-lifetime singleton. Swift Testing + /// constructs a fresh suite instance per `@Test`, so resetting the singleton + /// here keeps `resolvedKeys`, `imageEntries`, and `failed` from leaking + /// between tests in this suite (and from earlier suites that touched it). + init() { + MapSnapshotStore.shared.clear() + } + + // MARK: - Timestamp Logic Tests + + @Test + func `First message always shows timestamp`() { + let messages = [ + createTestMessage(timestamp: 1000) + ] + + let flags = ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil) + #expect(flags.showTimestamp == true) + } + + @Test + func `Consecutive messages within 5 minutes don't show timestamp`() { + let baseTime: UInt32 = 1000 + let messages = [ + createTestMessage(timestamp: baseTime), + createTestMessage(timestamp: baseTime + 60), // 1 minute later + createTestMessage(timestamp: baseTime + 120), // 2 minutes later + createTestMessage(timestamp: baseTime + 180), // 3 minutes later + createTestMessage(timestamp: baseTime + 240) // 4 minutes later + ] + + // First message always shows timestamp + #expect(ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) + + // Messages 1-4 shouldn't show timestamp (within 5 min of previous) + #expect(ChatViewModel.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == false) + #expect(ChatViewModel.computeDisplayFlags(for: messages[2], previous: messages[1]).showTimestamp == false) + #expect(ChatViewModel.computeDisplayFlags(for: messages[3], previous: messages[2]).showTimestamp == false) + #expect(ChatViewModel.computeDisplayFlags(for: messages[4], previous: messages[3]).showTimestamp == false) + } + + @Test + func `Message after 5+ minute gap shows timestamp`() { + let baseTime: UInt32 = 1000 + let messages = [ + createTestMessage(timestamp: baseTime), + createTestMessage(timestamp: baseTime + 301) // 5 min 1 sec later + ] + + #expect(ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) + #expect(ChatViewModel.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == true) + } + + @Test + func `Exactly 5 minute gap does not show timestamp`() { + let baseTime: UInt32 = 1000 + let messages = [ + createTestMessage(timestamp: baseTime), + createTestMessage(timestamp: baseTime + 300) // Exactly 5 minutes + ] + + #expect(ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) + #expect(ChatViewModel.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == false) // 300 is not > 300 + } + + @Test + func `Backlog block keys divider grouping on send time, not the shared drain anchor`() { + // Two backlog rows drained together share the anchor as their sortDate, but were + // sent ten minutes apart. Grouping must follow send time so the divider still + // appears inside the block; keying on the shared sortDate would collapse it. + let anchor = Date(timeIntervalSince1970: 5_000_000) + let earlier = createTestMessage(timestamp: 1000, sortDate: anchor) + let later = createTestMessage(timestamp: 1600, sortDate: anchor) // +10 min send time + #expect(ChatViewModel.computeDisplayFlags(for: later, previous: earlier).showTimestamp == true) + } + + @Test + @MainActor + func `Unread divider lands on the first unread row of the recent block`() { + // Block-at-reconnect layout: older already-read rows, then a recent unread block + // at the tail. The positional divider must land on the block's first row. This also + // guards against a regression to a first(where: { !$0.isRead }) scan — every row here + // has the default isRead == false, so such a scan would wrongly pick index 0. + let vm = ChatViewModel() + let readCount = 8 + let unreadCount = 12 + var messages: [MessageDTO] = [] + let readBase = Date(timeIntervalSince1970: 1_000_000) + for i in 0..